diff --git a/frontend/src/components/domain/onboarding/OnboardingWizard.tsx b/frontend/src/components/domain/onboarding/OnboardingWizard.tsx index 82de7f55..0d5d37c6 100644 --- a/frontend/src/components/domain/onboarding/OnboardingWizard.tsx +++ b/frontend/src/components/domain/onboarding/OnboardingWizard.tsx @@ -46,10 +46,15 @@ export const OnboardingWizard: React.FC = ({ const currentStep = steps[currentStepIndex]; const updateStepData = useCallback((stepId: string, data: any) => { - setStepData(prev => ({ - ...prev, - [stepId]: { ...prev[stepId], ...data } - })); + console.log(`OnboardingWizard - Updating step '${stepId}' with data:`, data); + setStepData(prev => { + const newStepData = { + ...prev, + [stepId]: { ...prev[stepId], ...data } + }; + console.log(`OnboardingWizard - Full step data after update:`, newStepData); + return newStepData; + }); // Clear validation error for this step setValidationErrors(prev => { @@ -414,7 +419,10 @@ export const OnboardingWizard: React.FC = ({ // Pass all step data to allow access to previous steps allStepData: stepData }} - onDataChange={(data) => updateStepData(currentStep.id, data)} + onDataChange={(data) => { + console.log(`OnboardingWizard - Step ${currentStep.id} calling onDataChange with:`, data); + updateStepData(currentStep.id, data); + }} onNext={goToNextStep} onPrevious={goToPreviousStep} isFirstStep={currentStepIndex === 0} diff --git a/frontend/src/components/domain/onboarding/steps/CompletionStep.tsx b/frontend/src/components/domain/onboarding/steps/CompletionStep.tsx index 3f26dda7..0c4976c7 100644 --- a/frontend/src/components/domain/onboarding/steps/CompletionStep.tsx +++ b/frontend/src/components/domain/onboarding/steps/CompletionStep.tsx @@ -2,6 +2,9 @@ import React, { useState, useEffect } from 'react'; import { CheckCircle, Star, Rocket, Gift, Download, Share2, ArrowRight, Calendar } from 'lucide-react'; import { Button, Card, Badge } from '../../../ui'; import { OnboardingStepProps } from '../OnboardingWizard'; +import { onboardingApiService } from '../../../../services/api/onboarding.service'; +import { useAuthUser } from '../../../../stores/auth.store'; +import { useAlertActions } from '../../../../stores/alerts.store'; interface CompletionStats { totalProducts: number; @@ -10,6 +13,8 @@ interface CompletionStats { mlModelAccuracy: number; estimatedTimeSaved: string; completionScore: number; + salesImported: boolean; + salesImportRecords: number; } export const CompletionStep: React.FC = ({ @@ -20,8 +25,73 @@ export const CompletionStep: React.FC = ({ isFirstStep, isLastStep }) => { + const user = useAuthUser(); + const { createAlert } = useAlertActions(); const [showConfetti, setShowConfetti] = useState(false); const [completionStats, setCompletionStats] = useState(null); + const [isImportingSales, setIsImportingSales] = useState(false); + + // Handle final sales import + const handleFinalSalesImport = async () => { + if (!user?.tenant_id || !data.files?.salesData || !data.inventoryMapping) { + createAlert({ + type: 'error', + category: 'system', + priority: 'high', + title: 'Error en importación final', + message: 'Faltan datos necesarios para importar las ventas.', + source: 'onboarding' + }); + return; + } + + setIsImportingSales(true); + try { + const result = await onboardingApiService.importSalesWithInventory( + user.tenant_id, + data.files.salesData, + data.inventoryMapping + ); + + createAlert({ + type: 'success', + category: 'system', + priority: 'medium', + title: 'Importación completada', + message: `Se importaron ${result.successful_imports} registros de ventas exitosamente.`, + source: 'onboarding' + }); + + // Update completion stats + const updatedStats = { + ...completionStats!, + salesImported: true, + salesImportRecords: result.successful_imports || 0 + }; + setCompletionStats(updatedStats); + + onDataChange({ + ...data, + completionStats: updatedStats, + salesImportResult: result, + finalImportCompleted: true + }); + + } catch (error) { + console.error('Sales import error:', error); + const errorMessage = error instanceof Error ? error.message : 'Error al importar datos de ventas'; + createAlert({ + type: 'error', + category: 'system', + priority: 'high', + title: 'Error en importación', + message: errorMessage, + source: 'onboarding' + }); + } finally { + setIsImportingSales(false); + } + }; useEffect(() => { // Show confetti animation @@ -35,7 +105,9 @@ export const CompletionStep: React.FC = ({ suppliersConfigured: data.suppliers?.length || 0, mlModelAccuracy: data.trainingMetrics?.accuracy * 100 || 0, estimatedTimeSaved: '15-20 horas', - completionScore: calculateCompletionScore() + completionScore: calculateCompletionScore(), + salesImported: data.finalImportCompleted || false, + salesImportRecords: data.salesImportResult?.successful_imports || 0 }; setCompletionStats(stats); @@ -48,6 +120,11 @@ export const CompletionStep: React.FC = ({ completedAt: new Date().toISOString() }); + // Trigger final sales import if not already done + if (!data.finalImportCompleted && data.inventoryMapping && data.files?.salesData) { + handleFinalSalesImport(); + } + return () => clearTimeout(timer); }, []); @@ -221,6 +298,10 @@ export const CompletionStep: React.FC = ({

{completionStats.mlModelAccuracy.toFixed(1)}%

Precisión IA

+
+

{completionStats.salesImported ? completionStats.salesImportRecords : '⏳'}

+

Ventas {completionStats.salesImported ? 'Importadas' : 'Importando...'}

+

{completionStats.estimatedTimeSaved}

Tiempo Ahorrado

diff --git a/frontend/src/components/domain/onboarding/steps/DataProcessingStep.tsx b/frontend/src/components/domain/onboarding/steps/DataProcessingStep.tsx index ac970844..cec8bbca 100644 --- a/frontend/src/components/domain/onboarding/steps/DataProcessingStep.tsx +++ b/frontend/src/components/domain/onboarding/steps/DataProcessingStep.tsx @@ -2,6 +2,10 @@ import React, { useState, useRef, useEffect } from 'react'; import { Upload, Brain, CheckCircle, AlertCircle, Download, FileText, Activity, TrendingUp } from 'lucide-react'; import { Button, Card, Badge } from '../../../ui'; import { OnboardingStepProps } from '../OnboardingWizard'; +import { onboardingApiService } from '../../../../services/api/onboarding.service'; +import { useAuthUser, useAuthLoading } from '../../../../stores/auth.store'; +import { useCurrentTenant, useTenantLoading } from '../../../../stores/tenant.store'; +import { useAlertActions } from '../../../../stores/alerts.store'; type ProcessingStage = 'upload' | 'validating' | 'analyzing' | 'completed' | 'error'; @@ -26,64 +30,70 @@ interface ProcessingResult { recommendations: string[]; } -// Unified mock service that handles both validation and analysis -const mockDataProcessingService = { - processFile: async (file: File, onProgress: (progress: number, stage: string, message: string) => void) => { - return new Promise((resolve, reject) => { - let progress = 0; +// Real data processing service using backend APIs +const dataProcessingService = { + processFile: async ( + file: File, + tenantId: string, + onProgress: (progress: number, stage: string, message: string) => void + ) => { + try { + // Stage 1: Validate file with sales service + onProgress(20, 'validating', 'Validando estructura del archivo...'); + const validationResult = await onboardingApiService.validateOnboardingFile(tenantId, file); - const stages = [ - { threshold: 20, stage: 'validating', message: 'Validando estructura del archivo...' }, - { threshold: 40, stage: 'validating', message: 'Verificando integridad de datos...' }, - { threshold: 60, stage: 'analyzing', message: 'Identificando productos únicos...' }, - { threshold: 80, stage: 'analyzing', message: 'Analizando patrones de venta...' }, - { threshold: 90, stage: 'analyzing', message: 'Generando recomendaciones con IA...' }, - { threshold: 100, stage: 'completed', message: 'Procesamiento completado' } - ]; + onProgress(40, 'validating', 'Verificando integridad de datos...'); + + if (!validationResult.is_valid) { + throw new Error('Archivo de datos inválido'); + } + + if (!validationResult.product_list || validationResult.product_list.length === 0) { + throw new Error('No se encontraron productos en el archivo'); + } - const interval = setInterval(() => { - if (progress < 100) { - progress += 10; - const currentStage = stages.find(s => progress <= s.threshold); - if (currentStage) { - onProgress(progress, currentStage.stage, currentStage.message); - } - } - - if (progress >= 100) { - clearInterval(interval); - // Return combined validation + analysis results - resolve({ - // Validation results - is_valid: true, - total_records: Math.floor(Math.random() * 1000) + 100, - unique_products: Math.floor(Math.random() * 50) + 10, - product_list: ['Pan Integral', 'Croissant', 'Baguette', 'Empanadas', 'Pan de Centeno', 'Medialunas'], - validation_errors: [], - validation_warnings: [ - 'Algunas fechas podrían tener formato inconsistente', - '3 productos sin categoría definida' - ], - summary: { - date_range: '2024-01-01 to 2024-12-31', - total_sales: 15420.50, - average_daily_sales: 42.25 - }, - // Analysis results - productsIdentified: 15, - categoriesDetected: 4, - businessModel: 'artisan', - confidenceScore: 94, - recommendations: [ - 'Se detectó un modelo de panadería artesanal con producción propia', - 'Los productos más vendidos son panes tradicionales y bollería', - 'Recomendamos categorizar el inventario por tipo de producto', - 'Considera ampliar la línea de productos de repostería' - ] - }); - } - }, 400); - }); + // Stage 2: Generate AI suggestions with inventory service + onProgress(60, 'analyzing', 'Identificando productos únicos...'); + onProgress(80, 'analyzing', 'Analizando patrones de venta...'); + + console.log('DataProcessingStep - Calling generateInventorySuggestions with:', { + tenantId, + fileName: file.name, + productList: validationResult.product_list + }); + + const suggestionsResult = await onboardingApiService.generateInventorySuggestions( + tenantId, + file, + validationResult.product_list + ); + + console.log('DataProcessingStep - AI suggestions result:', suggestionsResult); + + onProgress(90, 'analyzing', 'Generando recomendaciones con IA...'); + onProgress(100, 'completed', 'Procesamiento completado'); + + // Combine results + const combinedResult = { + ...validationResult, + productsIdentified: suggestionsResult.total_products || validationResult.unique_products, + categoriesDetected: suggestionsResult.suggestions ? + new Set(suggestionsResult.suggestions.map(s => s.category)).size : 4, + businessModel: suggestionsResult.business_model_analysis?.model || 'production', + confidenceScore: suggestionsResult.high_confidence_count && suggestionsResult.total_products ? + Math.round((suggestionsResult.high_confidence_count / suggestionsResult.total_products) * 100) : 85, + recommendations: suggestionsResult.business_model_analysis?.recommendations || [], + aiSuggestions: suggestionsResult.suggestions || [] + }; + + console.log('DataProcessingStep - Combined result:', combinedResult); + console.log('DataProcessingStep - Combined result aiSuggestions:', combinedResult.aiSuggestions); + console.log('DataProcessingStep - Combined result aiSuggestions length:', combinedResult.aiSuggestions?.length); + return combinedResult; + } catch (error) { + console.error('Data processing error:', error); + throw error; + } } }; @@ -95,6 +105,34 @@ export const DataProcessingStep: React.FC = ({ isFirstStep, isLastStep }) => { + const user = useAuthUser(); + const authLoading = useAuthLoading(); + const currentTenant = useCurrentTenant(); + const tenantLoading = useTenantLoading(); + const { createAlert } = useAlertActions(); + + // Check if we're still loading user or tenant data + const isLoadingUserData = authLoading || tenantLoading; + + // Get tenant ID from multiple sources with fallback + const getTenantId = (): string | null => { + const tenantId = currentTenant?.id || user?.tenant_id || null; + console.log('DataProcessingStep - getTenantId:', { + currentTenant: currentTenant?.id, + userTenantId: user?.tenant_id, + finalTenantId: tenantId, + isLoadingUserData, + authLoading, + tenantLoading, + user: user ? { id: user.id, email: user.email } : null + }); + return tenantId; + }; + + // Check if tenant data is available (not loading and has ID) + const isTenantAvailable = (): boolean => { + return !isLoadingUserData && getTenantId() !== null; + }; const [stage, setStage] = useState(data.processingStage || 'upload'); const [uploadedFile, setUploadedFile] = useState(data.files?.salesData || null); const [progress, setProgress] = useState(data.processingProgress || 0); @@ -166,8 +204,41 @@ export const DataProcessingStep: React.FC = ({ setProgress(0); try { - const result = await mockDataProcessingService.processFile( + // Wait for user data to load if still loading + if (!isTenantAvailable()) { + createAlert({ + type: 'info', + category: 'system', + priority: 'low', + title: 'Cargando datos de usuario', + message: 'Por favor espere mientras cargamos su información...', + source: 'onboarding' + }); + // Reset file state since we can't process it yet + setUploadedFile(null); + setStage('upload'); + return; + } + + const tenantId = getTenantId(); + if (!tenantId) { + console.error('DataProcessingStep - No tenant ID available:', { + user, + currentTenant, + userTenantId: user?.tenant_id, + currentTenantId: currentTenant?.id, + isLoadingUserData, + authLoading, + tenantLoading + }); + throw new Error('No se pudo obtener información del tenant. Intente cerrar sesión y volver a iniciar.'); + } + + console.log('DataProcessingStep - Starting file processing with tenant:', tenantId); + + const result = await dataProcessingService.processFile( file, + tenantId, (newProgress, newStage, message) => { setProgress(newProgress); setStage(newStage as ProcessingStage); @@ -177,32 +248,112 @@ export const DataProcessingStep: React.FC = ({ setResults(result); setStage('completed'); + + // Store results for next steps + onDataChange({ + ...data, + files: { ...data.files, salesData: file }, + processingResults: result, + processingStage: 'completed', + processingProgress: 100 + }); + + console.log('DataProcessingStep - File processing completed:', result); + + createAlert({ + type: 'success', + category: 'system', + priority: 'medium', + title: 'Procesamiento completado', + message: `Se procesaron ${result.total_records} registros y se identificaron ${result.unique_products} productos únicos.`, + source: 'onboarding' + }); } catch (error) { - console.error('Processing error:', error); + console.error('DataProcessingStep - Processing error:', error); + console.error('DataProcessingStep - Error details:', { + errorMessage: error instanceof Error ? error.message : 'Unknown error', + errorStack: error instanceof Error ? error.stack : null, + tenantInfo: { + user: user ? { id: user.id, tenant_id: user.tenant_id } : null, + currentTenant: currentTenant ? { id: currentTenant.id } : null + } + }); + setStage('error'); - setCurrentMessage('Error en el procesamiento de datos'); + const errorMessage = error instanceof Error ? error.message : 'Error en el procesamiento de datos'; + setCurrentMessage(errorMessage); + + createAlert({ + type: 'error', + category: 'system', + priority: 'high', + title: 'Error en el procesamiento', + message: errorMessage, + source: 'onboarding' + }); } }; - const downloadTemplate = () => { - const csvContent = `fecha,producto,cantidad,precio_unitario,precio_total,cliente,canal_venta + const downloadTemplate = async () => { + try { + if (!isTenantAvailable()) { + createAlert({ + type: 'info', + category: 'system', + priority: 'low', + title: 'Cargando datos de usuario', + message: 'Por favor espere mientras cargamos su información...', + source: 'onboarding' + }); + return; + } + + const tenantId = getTenantId(); + if (!tenantId) { + createAlert({ + type: 'error', + category: 'system', + priority: 'high', + title: 'Error', + message: 'No se pudo obtener información del tenant. Intente cerrar sesión y volver a iniciar.', + source: 'onboarding' + }); + return; + } + + const templateData = await onboardingApiService.getSalesImportTemplate(tenantId, 'csv'); + onboardingApiService.downloadTemplate(templateData, 'plantilla_ventas.csv', 'csv'); + + createAlert({ + type: 'success', + category: 'system', + priority: 'low', + title: 'Plantilla descargada', + message: 'La plantilla de ventas se ha descargado correctamente', + source: 'onboarding' + }); + } catch (error) { + console.error('Error downloading template:', error); + // Fallback to static template + const csvContent = `fecha,producto,cantidad,precio_unitario,precio_total,cliente,canal_venta 2024-01-15,Pan Integral,5,2.50,12.50,Cliente A,Tienda 2024-01-15,Croissant,3,1.80,5.40,Cliente B,Online 2024-01-15,Baguette,2,3.00,6.00,Cliente C,Tienda 2024-01-16,Pan de Centeno,4,2.80,11.20,Cliente A,Tienda 2024-01-16,Empanadas,6,4.50,27.00,Cliente D,Delivery`; - const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); - const link = document.createElement('a'); - const url = URL.createObjectURL(blob); - - link.setAttribute('href', url); - link.setAttribute('download', 'plantilla_ventas.csv'); - link.style.visibility = 'hidden'; - - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + const url = URL.createObjectURL(blob); + + link.setAttribute('href', url); + link.setAttribute('download', 'plantilla_ventas.csv'); + link.style.visibility = 'hidden'; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } }; const resetProcess = () => { @@ -218,8 +369,23 @@ export const DataProcessingStep: React.FC = ({ return (
+ {/* Loading state when tenant data is not available */} + {!isTenantAvailable() && ( + +
+ +
+

+ Cargando datos de usuario... +

+

+ Por favor espere mientras cargamos su información de tenant +

+
+ )} + {/* Improved Upload Stage */} - {stage === 'upload' && ( + {stage === 'upload' && isTenantAvailable() && ( <>
{ + return approvedProducts.map((product, index) => ({ + id: `inventory-${index}`, + name: product.suggested_name || product.name, + category: product.product_type || 'finished_product', + current_stock: 0, // To be configured by user + min_stock: 1, // Default minimum + max_stock: 100, // Default maximum + unit: product.unit_of_measure || 'unidad', + requires_refrigeration: product.requires_refrigeration || false, + // Store API data + suggestion_id: product.suggestion_id, + original_name: product.original_name, + estimated_shelf_life_days: product.estimated_shelf_life_days, + is_seasonal: product.is_seasonal, + // Optional fields to be filled by user + expiry_date: undefined, + supplier: product.suggested_supplier, + cost_per_unit: 0 + })); +}; export const InventorySetupStep: React.FC = ({ data, @@ -52,22 +56,121 @@ export const InventorySetupStep: React.FC = ({ isFirstStep, isLastStep }) => { - const [items, setItems] = useState( - data.inventoryItems || mockInventoryItems - ); + const user = useAuthUser(); + const { createAlert } = useAlertActions(); + const [isCreating, setIsCreating] = useState(false); const [editingItem, setEditingItem] = useState(null); const [isAddingNew, setIsAddingNew] = useState(false); + + // Generate inventory items from approved products + const generateInventoryFromProducts = (approvedProducts: any[]): InventoryItem[] => { + if (!approvedProducts || approvedProducts.length === 0) { + createAlert({ + type: 'warning', + category: 'system', + priority: 'medium', + title: 'Sin productos aprobados', + message: 'No hay productos aprobados para crear inventario. Regrese al paso anterior.', + source: 'onboarding' + }); + return []; + } + + return convertProductsToInventory(approvedProducts); + }; + + const [items, setItems] = useState(() => { + if (data.inventoryItems) { + return data.inventoryItems; + } + // Try to get approved products from current step data first, then from review step data + const approvedProducts = data.approvedProducts || data.allStepData?.['review']?.approvedProducts; + return generateInventoryFromProducts(approvedProducts || []); + }); + + // Update items when approved products become available (for when component is already mounted) + useEffect(() => { + const approvedProducts = data.approvedProducts || data.allStepData?.['review']?.approvedProducts; + + if (approvedProducts && approvedProducts.length > 0 && items.length === 0) { + const newItems = generateInventoryFromProducts(approvedProducts); + setItems(newItems); + } + }, [data.approvedProducts, data.allStepData]); + const [filterCategory, setFilterCategory] = useState<'all' | 'ingredient' | 'finished_product'>('all'); + const filteredItems = filterCategory === 'all' + ? items + : items.filter(item => item.category === filterCategory); + + // Create inventory items via API + const handleCreateInventory = async () => { + const approvedProducts = data.approvedProducts || data.allStepData?.['review']?.approvedProducts; + if (!user?.tenant_id || !approvedProducts || approvedProducts.length === 0) { + createAlert({ + type: 'error', + category: 'system', + priority: 'high', + title: 'Error', + message: 'No se pueden crear elementos de inventario sin productos aprobados.', + source: 'onboarding' + }); + return; + } + + setIsCreating(true); + try { + const result = await onboardingApiService.createInventoryFromSuggestions( + user.tenant_id, + approvedProducts + ); + + createAlert({ + type: 'success', + category: 'system', + priority: 'medium', + title: 'Inventario creado', + message: `Se crearon ${result.created_items.length} elementos de inventario exitosamente.`, + source: 'onboarding' + }); + + // Update the step data with created inventory + onDataChange({ + ...data, + inventoryItems: items, + inventoryConfigured: true, + inventoryMapping: result.inventory_mapping, + createdInventoryItems: result.created_items + }); + + } catch (error) { + console.error('Error creating inventory:', error); + const errorMessage = error instanceof Error ? error.message : 'Error al crear inventario'; + createAlert({ + type: 'error', + category: 'system', + priority: 'high', + title: 'Error al crear inventario', + message: errorMessage, + source: 'onboarding' + }); + } finally { + setIsCreating(false); + } + }; + useEffect(() => { + const hasValidStock = items.length > 0 && items.every(item => + item.min_stock >= 0 && item.max_stock > item.min_stock + ); + onDataChange({ ...data, inventoryItems: items, - inventoryConfigured: items.length > 0 && items.every(item => - item.min_stock > 0 && item.max_stock > item.min_stock - ) + inventoryConfigured: hasValidStock && !isCreating }); - }, [items]); + }, [items, isCreating]); const handleAddItem = () => { const newItem: InventoryItem = { @@ -75,491 +178,370 @@ export const InventorySetupStep: React.FC = ({ name: '', category: 'ingredient', current_stock: 0, - min_stock: 0, - max_stock: 0, - unit: 'kg', + min_stock: 1, + max_stock: 10, + unit: 'unidad', requires_refrigeration: false }; + setItems([...items, newItem]); setEditingItem(newItem); setIsAddingNew(true); }; - const handleSaveItem = (item: InventoryItem) => { - if (isAddingNew) { - setItems(prev => [...prev, item]); - } else { - setItems(prev => prev.map(i => i.id === item.id ? item : i)); - } + const handleSaveItem = (updatedItem: InventoryItem) => { + setItems(items.map(item => + item.id === updatedItem.id ? updatedItem : item + )); setEditingItem(null); setIsAddingNew(false); }; const handleDeleteItem = (id: string) => { - if (window.confirm('¿Estás seguro de eliminar este elemento del inventario?')) { - setItems(prev => prev.filter(item => item.id !== id)); + setItems(items.filter(item => item.id !== id)); + if (editingItem?.id === id) { + setEditingItem(null); + setIsAddingNew(false); } }; - const handleQuickSetup = () => { - // Auto-configure basic inventory based on approved products - const autoItems = data.detectedProducts - ?.filter((p: any) => p.status === 'approved') - .map((product: any, index: number) => ({ - id: `auto_${index}`, - name: product.name, - category: 'finished_product' as const, - current_stock: Math.floor(Math.random() * 20) + 5, - min_stock: 5, - max_stock: 50, - unit: 'unidades', - requires_refrigeration: product.category === 'Repostería' || product.category === 'Salados' - })) || []; - - setItems(prev => [...prev, ...autoItems]); - }; - - const getFilteredItems = () => { - return filterCategory === 'all' - ? items - : items.filter(item => item.category === filterCategory); + const handleCancelEdit = () => { + if (isAddingNew && editingItem) { + setItems(items.filter(item => item.id !== editingItem.id)); + } + setEditingItem(null); + setIsAddingNew(false); }; const getStockStatus = (item: InventoryItem) => { - if (item.current_stock <= item.min_stock) return { status: 'low', color: 'red', text: 'Stock Bajo' }; - if (item.current_stock >= item.max_stock) return { status: 'high', color: 'blue', text: 'Stock Alto' }; - return { status: 'normal', color: 'green', text: 'Normal' }; + if (item.current_stock <= item.min_stock) return 'critical'; + if (item.current_stock <= item.min_stock * 1.5) return 'warning'; + return 'good'; }; - const isNearExpiry = (expiryDate?: string) => { - if (!expiryDate) return false; - const expiry = new Date(expiryDate); - const today = new Date(); - const diffDays = (expiry.getTime() - today.getTime()) / (1000 * 3600 * 24); - return diffDays <= 7; + const getStockStatusColor = (status: string) => { + switch (status) { + case 'critical': return 'text-red-600 bg-red-50'; + case 'warning': return 'text-yellow-600 bg-yellow-50'; + default: return 'text-green-600 bg-green-50'; + } }; - const stats = { - total: items.length, - ingredients: items.filter(i => i.category === 'ingredient').length, - products: items.filter(i => i.category === 'finished_product').length, - lowStock: items.filter(i => i.current_stock <= i.min_stock).length, - nearExpiry: items.filter(i => isNearExpiry(i.expiry_date)).length, - refrigerated: items.filter(i => i.requires_refrigeration).length - }; + if (items.length === 0) { + return ( +
+
+ +

+ Sin productos para inventario +

+

+ No hay productos aprobados para crear inventario. + Regrese al paso anterior para aprobar productos. +

+ +
+
+ ); + } return ( -
- {/* Quick Actions */} - -
-
-

- {stats.total} elementos configurados -

-
- -
- - -
-
-
+
+ {/* Header */} +
+

+ Configuración de Inventario +

+

+ Configure los niveles de stock, fechas de vencimiento y otros detalles para sus productos. +

+
{/* Stats */} -
- -

{stats.total}

-

Total

+
+ +
{items.length}
+
Elementos totales
- -

{stats.ingredients}

-

Ingredientes

+ + +
+ {items.filter(item => item.category === 'ingredient').length} +
+
Ingredientes
- -

{stats.products}

-

Productos

+ + +
+ {items.filter(item => item.category === 'finished_product').length} +
+
Productos terminados
- -

{stats.lowStock}

-

Stock Bajo

-
- -

{stats.nearExpiry}

-

Por Vencer

-
- -

{stats.refrigerated}

-

Refrigerado

+ + +
+ {items.filter(item => getStockStatus(item) === 'critical').length} +
+
Stock crítico
- {/* Filters */} - + {/* Controls */} +
- -
- {[ - { value: 'all', label: 'Todos' }, - { value: 'ingredient', label: 'Ingredientes' }, - { value: 'finished_product', label: 'Productos' } - ].map(filter => ( - - ))} -
-
- - - {/* Inventory Items */} -
- {getFilteredItems().map((item) => { - const stockStatus = getStockStatus(item); - const nearExpiry = isNearExpiry(item.expiry_date); + - return ( - -
-
- {/* Category Icon */} -
- -
+ + {filteredItems.length} elementos + +
+ +
+ + +
+
- {/* Item Info */} -
-

{item.name}

- -
- - {item.category === 'ingredient' ? 'Ingrediente' : 'Producto'} + {/* Items List */} +
+ {filteredItems.map((item) => ( + + {editingItem?.id === item.id ? ( + + ) : ( +
+
+
+

{item.name}

+ + {item.category === 'ingredient' ? 'Ingrediente' : 'Producto terminado'} + + + Stock: {getStockStatus(item)} + + {item.requires_refrigeration && ( + + Refrigeración - {item.requires_refrigeration && ( - ❄️ Refrigeración - )} - - {stockStatus.text} - - {nearExpiry && ( - Vence Pronto - )} -
- -
-
- Stock Actual: - - {item.current_stock} {item.unit} - -
- -
- Rango: - - {item.min_stock} - {item.max_stock} {item.unit} - -
- - {item.expiry_date && ( -
- Vencimiento: - - {new Date(item.expiry_date).toLocaleDateString()} - -
- )} - - {item.cost_per_unit && ( -
- Costo/Unidad: - - ${item.cost_per_unit.toFixed(2)} - -
- )} + )} +
+ +
+
+ Stock actual: {item.current_stock} {item.unit} + Mínimo: {item.min_stock} + Máximo: {item.max_stock}
+ {item.expiry_date && ( +
Vence: {item.expiry_date}
+ )} + {item.supplier && ( +
Proveedor: {item.supplier}
+ )}
- {/* Actions */} -
- -
-
- ); - })} - - {getFilteredItems().length === 0 && ( - - -

No hay elementos en esta categoría

+ )}
- )} + ))}
- {/* Warnings */} - {(stats.lowStock > 0 || stats.nearExpiry > 0) && ( - -
- -
-

Advertencias de Inventario

- {stats.lowStock > 0 && ( -

- • {stats.lowStock} elemento(s) con stock bajo -

- )} - {stats.nearExpiry > 0 && ( -

- • {stats.nearExpiry} elemento(s) próximos a vencer -

- )} -
-
-
- )} - - {/* Edit Modal */} - {editingItem && ( -
- -

- {isAddingNew ? 'Agregar Elemento' : 'Editar Elemento'} -

- - { - setEditingItem(null); - setIsAddingNew(false); - }} - /> -
-
- )} - - {/* Information */} - -

- 📦 Configuración de Inventario: -

-
    -
  • Stock Mínimo: Nivel que dispara alertas de reabastecimiento
  • -
  • Stock Máximo: Capacidad máxima de almacenamiento
  • -
  • Fechas de Vencimiento: Control automático de productos perecederos
  • -
  • Refrigeración: Identifica productos que requieren frío
  • -
-
- + {/* Navigation */} +
+ + +
); }; -// Component for editing inventory items -interface InventoryItemFormProps { +// Inventory Item Editor Component +const InventoryItemEditor: React.FC<{ item: InventoryItem; onSave: (item: InventoryItem) => void; onCancel: () => void; -} +}> = ({ item, onSave, onCancel }) => { + const [editedItem, setEditedItem] = useState(item); -const InventoryItemForm: React.FC = ({ item, onSave, onCancel }) => { - const [formData, setFormData] = useState(item); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (!formData.name.trim()) { + const handleSave = () => { + if (!editedItem.name.trim()) { alert('El nombre es requerido'); return; } - if (formData.min_stock >= formData.max_stock) { - alert('El stock máximo debe ser mayor al mínimo'); + if (editedItem.min_stock < 0 || editedItem.max_stock <= editedItem.min_stock) { + alert('Los niveles de stock deben ser válidos (máximo > mínimo >= 0)'); return; } - onSave(formData); + onSave(editedItem); }; return ( -
-
- - setFormData(prev => ({ ...prev, name: e.target.value }))} - placeholder="Nombre del producto/ingrediente" - /> -
- -
+
+
- - setEditedItem({ ...editedItem, name: e.target.value })} + placeholder="Nombre del producto" + /> +
+ +
+ +
- - setFormData(prev => ({ ...prev, unit: e.target.value }))} - placeholder="kg, unidades, litros..." - /> -
-
- -
-
- + setFormData(prev => ({ ...prev, current_stock: Number(e.target.value) }))} + value={editedItem.current_stock} + onChange={(e) => setEditedItem({ ...editedItem, current_stock: Number(e.target.value) })} min="0" />
- + + setEditedItem({ ...editedItem, unit: e.target.value })} + placeholder="kg, litros, unidades..." + /> +
+ +
+ setFormData(prev => ({ ...prev, min_stock: Number(e.target.value) }))} + value={editedItem.min_stock} + onChange={(e) => setEditedItem({ ...editedItem, min_stock: Number(e.target.value) })} min="0" />
- + setFormData(prev => ({ ...prev, max_stock: Number(e.target.value) }))} + value={editedItem.max_stock} + onChange={(e) => setEditedItem({ ...editedItem, max_stock: Number(e.target.value) })} min="1" />
-
-
- + setFormData(prev => ({ ...prev, expiry_date: e.target.value }))} + value={editedItem.expiry_date || ''} + onChange={(e) => setEditedItem({ ...editedItem, expiry_date: e.target.value })} />
- + setFormData(prev => ({ ...prev, cost_per_unit: Number(e.target.value) }))} - min="0" + value={editedItem.supplier || ''} + onChange={(e) => setEditedItem({ ...editedItem, supplier: e.target.value })} + placeholder="Nombre del proveedor" />
-
- - setFormData(prev => ({ ...prev, supplier: e.target.value }))} - placeholder="Nombre del proveedor" - /> -
- -
- setFormData(prev => ({ ...prev, requires_refrigeration: e.target.checked }))} - className="rounded" - /> -
); }; \ No newline at end of file diff --git a/frontend/src/components/domain/onboarding/steps/MLTrainingStep.tsx b/frontend/src/components/domain/onboarding/steps/MLTrainingStep.tsx index 10c4d283..e8160be3 100644 --- a/frontend/src/components/domain/onboarding/steps/MLTrainingStep.tsx +++ b/frontend/src/components/domain/onboarding/steps/MLTrainingStep.tsx @@ -1,17 +1,22 @@ -import React, { useState, useEffect } from 'react'; -import { Brain, Activity, Zap, CheckCircle, AlertCircle, TrendingUp } from 'lucide-react'; +import React, { useState, useEffect, useRef } from 'react'; +import { Brain, Activity, Zap, CheckCircle, AlertCircle, TrendingUp, Upload, Database } from 'lucide-react'; import { Button, Card, Badge } from '../../../ui'; import { OnboardingStepProps } from '../OnboardingWizard'; +import { useAuthUser } from '../../../../stores/auth.store'; +import { useCurrentTenant } from '../../../../stores/tenant.store'; +import { useAlertActions } from '../../../../stores/alerts.store'; +import { + WebSocketService, + TrainingProgressMessage, + TrainingCompletedMessage, + TrainingErrorMessage +} from '../../../../services/realtime/websocket.service'; interface TrainingMetrics { accuracy: number; - precision: number; - recall: number; - f1_score: number; - training_loss: number; - validation_loss: number; - epochs_completed: number; - total_epochs: number; + mape: number; + mae: number; + rmse: number; } interface TrainingLog { @@ -20,205 +25,54 @@ interface TrainingLog { level: 'info' | 'warning' | 'error' | 'success'; } -// Enhanced Mock ML training service that matches backend behavior -const mockMLService = { - startTraining: async (trainingData: any) => { - return new Promise((resolve) => { - let progress = 0; - let step_count = 0; - const total_steps = 12; - - // Backend-matching training steps and messages - const trainingSteps = [ - { step: 'data_validation', message: 'Validando datos de entrenamiento...', progress: 10 }, - { step: 'data_preparation_start', message: 'Preparando conjunto de datos...', progress: 20 }, - { step: 'feature_engineering', message: 'Creando características para el modelo...', progress: 30 }, - { step: 'data_preparation_complete', message: 'Preparación de datos completada', progress: 35 }, - { step: 'ml_training_start', message: 'Iniciando entrenamiento del modelo Prophet...', progress: 40 }, - { step: 'model_fitting', message: 'Ajustando modelo a los datos históricos...', progress: 55 }, - { step: 'pattern_detection', message: 'Detectando patrones estacionales y tendencias...', progress: 70 }, - { step: 'validation', message: 'Validando precisión del modelo...', progress: 80 }, - { step: 'training_complete', message: 'Entrenamiento ML completado', progress: 85 }, - { step: 'storing_models', message: 'Guardando modelo entrenado...', progress: 90 }, - { step: 'performance_metrics', message: 'Calculando métricas de rendimiento...', progress: 95 }, - { step: 'completed', message: 'Tu Asistente Inteligente está listo!', progress: 100 } - ]; - - const interval = setInterval(() => { - if (step_count >= trainingSteps.length) { - clearInterval(interval); - return; - } +interface TrainingJob { + id: string; + status: 'pending' | 'running' | 'completed' | 'failed'; + progress: number; + started_at?: string; + completed_at?: string; + error_message?: string; + metrics?: TrainingMetrics; +} - const currentStep = trainingSteps[step_count]; - progress = currentStep.progress; +// Real training service using backend APIs +class TrainingApiService { + private async apiCall(endpoint: string, options: RequestInit = {}) { + const response = await fetch(`/api${endpoint}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }); - // Generate realistic metrics that improve over time - const stepProgress = step_count / trainingSteps.length; - const baseAccuracy = 0.65; - const maxAccuracy = 0.93; - const currentAccuracy = baseAccuracy + (maxAccuracy - baseAccuracy) * Math.pow(stepProgress, 0.8); - - const metrics: TrainingMetrics = { - accuracy: Math.min(maxAccuracy, currentAccuracy + (Math.random() * 0.02 - 0.01)), - precision: Math.min(0.95, 0.70 + stepProgress * 0.23 + (Math.random() * 0.02 - 0.01)), - recall: Math.min(0.94, 0.68 + stepProgress * 0.24 + (Math.random() * 0.02 - 0.01)), - f1_score: Math.min(0.94, 0.69 + stepProgress * 0.23 + (Math.random() * 0.02 - 0.01)), - training_loss: Math.max(0.08, 1.2 - stepProgress * 1.0 + (Math.random() * 0.05 - 0.025)), - validation_loss: Math.max(0.10, 1.3 - stepProgress * 1.1 + (Math.random() * 0.06 - 0.03)), - epochs_completed: Math.floor(stepProgress * 15) + 1, - total_epochs: 15 - }; + if (!response.ok) { + throw new Error(`API call failed: ${response.statusText}`); + } - // Generate step-specific logs that match backend behavior - const logs: TrainingLog[] = []; - - // Add the current step message - logs.push({ - timestamp: new Date().toLocaleTimeString(), - message: currentStep.message, - level: currentStep.step === 'completed' ? 'success' : 'info' - }); + return response.json(); + } - // Add specific logs based on the training step - if (currentStep.step === 'data_validation') { - logs.push({ - timestamp: new Date().toLocaleTimeString(), - message: `Productos analizados: ${trainingData.products?.length || 3}`, - level: 'info' - }); - logs.push({ - timestamp: new Date().toLocaleTimeString(), - message: `Registros de inventario: ${trainingData.inventory?.length || 3}`, - level: 'info' - }); - } else if (currentStep.step === 'ml_training_start') { - logs.push({ - timestamp: new Date().toLocaleTimeString(), - message: 'Usando modelo Prophet optimizado para panadería', - level: 'info' - }); - } else if (currentStep.step === 'model_fitting') { - logs.push({ - timestamp: new Date().toLocaleTimeString(), - message: `Precisión actual: ${(metrics.accuracy * 100).toFixed(1)}%`, - level: 'info' - }); - } else if (currentStep.step === 'pattern_detection') { - logs.push({ - timestamp: new Date().toLocaleTimeString(), - message: 'Patrones estacionales detectados: días de la semana, horas pico', - level: 'success' - }); - } else if (currentStep.step === 'validation') { - logs.push({ - timestamp: new Date().toLocaleTimeString(), - message: `MAE: ${(Math.random() * 2 + 1.5).toFixed(2)}, MAPE: ${((1 - metrics.accuracy) * 100).toFixed(1)}%`, - level: 'info' - }); - } else if (currentStep.step === 'storing_models') { - logs.push({ - timestamp: new Date().toLocaleTimeString(), - message: `Modelo guardado: bakery_prophet_${Date.now()}`, - level: 'success' - }); - } - - // Emit progress event that matches backend WebSocket message structure - window.dispatchEvent(new CustomEvent('mlTrainingProgress', { - detail: { - type: 'progress', - job_id: `training_${Date.now()}`, - data: { - progress, - current_step: currentStep.step, - step_details: currentStep.message, - products_completed: Math.floor(step_count / 3), - products_total: Math.max(3, trainingData.products?.length || 3), - estimated_time_remaining_minutes: Math.max(0, Math.floor((total_steps - step_count) * 0.75)) - }, - timestamp: new Date().toISOString(), - // Keep old format for backward compatibility - status: progress >= 100 ? 'completed' : 'training', - currentPhase: currentStep.message, - metrics, - logs - } - })); - - step_count++; - - if (progress >= 100) { - clearInterval(interval); - - // Generate final results matching backend response structure - const finalAccuracy = 0.89 + Math.random() * 0.05; - const finalResult = { - success: true, - job_id: `training_${Date.now()}`, - tenant_id: 'demo_tenant', - status: 'completed', - training_results: { - total_products: Math.max(3, trainingData.products?.length || 3), - successful_trainings: Math.max(3, trainingData.products?.length || 3), - failed_trainings: 0, - products: Array.from({length: Math.max(3, trainingData.products?.length || 3)}, (_, i) => ({ - inventory_product_id: `product_${i + 1}`, - status: 'completed', - model_id: `model_${i + 1}_${Date.now()}`, - data_points: 45 + Math.floor(Math.random() * 30), - metrics: { - mape: (1 - finalAccuracy) * 100 + (Math.random() * 2 - 1), - mae: 1.5 + Math.random() * 1.0, - rmse: 2.1 + Math.random() * 1.2, - r2_score: finalAccuracy + (Math.random() * 0.02 - 0.01) - } - })), - overall_training_time_seconds: 45 + Math.random() * 15 - }, - data_summary: { - sales_records: trainingData.inventory?.length * 30 || 1500, - weather_records: 90, - traffic_records: 85, - date_range: { - start: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString(), - end: new Date().toISOString() - }, - data_sources_used: ['bakery_sales', 'weather_forecast', 'madrid_traffic'], - constraints_applied: {} - }, - finalMetrics: { - accuracy: finalAccuracy, - precision: finalAccuracy * 0.98, - recall: finalAccuracy * 0.96, - f1_score: finalAccuracy * 0.97, - training_loss: 0.12 + Math.random() * 0.05, - validation_loss: 0.15 + Math.random() * 0.05, - total_epochs: 15 - }, - modelId: `bakery_prophet_${Date.now()}`, - deploymentUrl: '/api/v1/training/models/predict', - trainingDuration: `${(45 + Math.random() * 15).toFixed(1)} segundos`, - datasetSize: `${trainingData.inventory?.length * 30 || 1500} registros`, - modelVersion: '2.1.0', - completed_at: new Date().toISOString() - }; - - resolve(finalResult); - } - }, 1800); // Matches backend timing + async startTrainingJob(tenantId: string, startDate?: string, endDate?: string): Promise { + return this.apiCall(`/tenants/${tenantId}/training/jobs`, { + method: 'POST', + body: JSON.stringify({ + start_date: startDate, + end_date: endDate + }), }); } -}; -const getTrainingPhase = (epoch: number, total: number) => { - const progress = epoch / total; - if (progress <= 0.3) return 'Inicializando modelo de IA...'; - if (progress <= 0.6) return 'Entrenando patrones de venta...'; - if (progress <= 0.8) return 'Optimizando predicciones...'; - if (progress <= 0.95) return 'Validando rendimiento...'; - return 'Finalizando entrenamiento...'; -}; + async getTrainingJob(tenantId: string, jobId: string): Promise { + return this.apiCall(`/tenants/${tenantId}/training/jobs/${jobId}`); + } + + async getTrainingJobs(tenantId: string): Promise { + return this.apiCall(`/tenants/${tenantId}/training/jobs`); + } +} + +const trainingService = new TrainingApiService(); export const MLTrainingStep: React.FC = ({ data, @@ -228,553 +82,485 @@ export const MLTrainingStep: React.FC = ({ isFirstStep, isLastStep }) => { - const [trainingStatus, setTrainingStatus] = useState(data.trainingStatus || 'pending'); - const [progress, setProgress] = useState(data.trainingProgress || 0); - const [currentPhase, setCurrentPhase] = useState(data.currentPhase || ''); - const [metrics, setMetrics] = useState(data.trainingMetrics || null); - const [logs, setLogs] = useState(data.trainingLogs || []); - const [finalResults, setFinalResults] = useState(data.finalResults || null); + const user = useAuthUser(); + const currentTenant = useCurrentTenant(); + const { createAlert } = useAlertActions(); - // New state variables for backend-compatible data - const [productsCompleted, setProductsCompleted] = useState(0); - const [productsTotal, setProductsTotal] = useState(0); - const [estimatedTimeRemaining, setEstimatedTimeRemaining] = useState(null); + const [trainingStatus, setTrainingStatus] = useState<'idle' | 'validating' | 'training' | 'completed' | 'failed'>( + data.trainingStatus || 'idle' + ); + const [progress, setProgress] = useState(data.trainingProgress || 0); + const [currentJob, setCurrentJob] = useState(data.trainingJob || null); + const [trainingLogs, setTrainingLogs] = useState(data.trainingLogs || []); + const [metrics, setMetrics] = useState(data.trainingMetrics || null); + const [currentStep, setCurrentStep] = useState(''); + const [estimatedTimeRemaining, setEstimatedTimeRemaining] = useState(0); + + const wsRef = useRef(null); - useEffect(() => { - // Listen for ML training progress updates - const handleProgress = (event: CustomEvent) => { - const detail = event.detail; + // Validate that required data is available for training + const validateDataRequirements = (): { isValid: boolean; missingItems: string[] } => { + const missingItems: string[] = []; + + console.log('MLTrainingStep - Validating data requirements'); + console.log('MLTrainingStep - Current data:', data); + console.log('MLTrainingStep - allStepData keys:', Object.keys(data.allStepData || {})); + + // Get data from previous steps + const dataProcessingData = data.allStepData?.['data-processing']; + const reviewData = data.allStepData?.['review']; + const inventoryData = data.allStepData?.['inventory']; + + console.log('MLTrainingStep - dataProcessingData:', dataProcessingData); + console.log('MLTrainingStep - reviewData:', reviewData); + console.log('MLTrainingStep - inventoryData:', inventoryData); + + // Check if sales data was processed + const hasProcessingResults = dataProcessingData?.processingResults && + dataProcessingData.processingResults.is_valid && + dataProcessingData.processingResults.total_records > 0; + + if (!hasProcessingResults) { + missingItems.push('Datos de ventas importados'); + } + + // Check if products were approved in review step + const hasApprovedProducts = reviewData?.approvedProducts && + reviewData.approvedProducts.length > 0 && + reviewData.reviewCompleted; + + if (!hasApprovedProducts) { + missingItems.push('Productos aprobados en revisión'); + } + + // Check if inventory was configured + const hasInventoryConfig = inventoryData?.inventoryConfigured && + inventoryData?.inventoryItems && + inventoryData.inventoryItems.length > 0; + + if (!hasInventoryConfig) { + missingItems.push('Inventario configurado'); + } + + // Check if we have enough data for training + if (dataProcessingData?.processingResults?.total_records && + dataProcessingData.processingResults.total_records < 10) { + missingItems.push('Suficientes registros de ventas (mínimo 10)'); + } + + console.log('MLTrainingStep - Validation result:', { + isValid: missingItems.length === 0, + missingItems, + hasProcessingResults, + hasApprovedProducts, + hasInventoryConfig + }); + + return { + isValid: missingItems.length === 0, + missingItems + }; + }; + + const addLog = (message: string, level: TrainingLog['level'] = 'info') => { + const newLog: TrainingLog = { + timestamp: new Date().toISOString(), + message, + level + }; + setTrainingLogs(prev => [...prev, newLog]); + }; + + const startTraining = async () => { + const tenantId = currentTenant?.id || user?.tenant_id; + if (!tenantId) { + createAlert({ + type: 'error', + category: 'system', + priority: 'high', + title: 'Error', + message: 'No se pudo obtener información del tenant', + source: 'onboarding' + }); + return; + } + + // Validate data requirements + const validation = validateDataRequirements(); + if (!validation.isValid) { + createAlert({ + type: 'error', + category: 'system', + priority: 'high', + title: 'Datos insuficientes para entrenamiento', + message: `Faltan los siguientes elementos: ${validation.missingItems.join(', ')}`, + source: 'onboarding' + }); + return; + } + + setTrainingStatus('validating'); + addLog('Validando disponibilidad de datos...', 'info'); + + try { + // Start training job + addLog('Iniciando trabajo de entrenamiento ML...', 'info'); + const job = await trainingService.startTrainingJob(tenantId); - // Handle new backend-compatible WebSocket message format - if (detail.type === 'progress' && detail.data) { - const progressData = detail.data; - setProgress(progressData.progress || 0); - setCurrentPhase(progressData.step_details || progressData.current_step || 'En progreso...'); + setCurrentJob(job); + setTrainingStatus('training'); + addLog(`Trabajo de entrenamiento iniciado: ${job.id}`, 'success'); + + // Initialize WebSocket connection for real-time updates + const ws = new WebSocketService(tenantId, job.id); + wsRef.current = ws; + + // Set up WebSocket event listeners + ws.subscribe('progress', (message: TrainingProgressMessage) => { + console.log('Training progress received:', message); + setProgress(message.progress.percentage); + setCurrentStep(message.progress.current_step); + setEstimatedTimeRemaining(message.progress.estimated_time_remaining); - // Update products progress if available - if (progressData.products_completed !== undefined) { - setProductsCompleted(progressData.products_completed); - } - if (progressData.products_total !== undefined) { - setProductsTotal(progressData.products_total); - } + addLog( + `${message.progress.current_step} - ${message.progress.products_completed}/${message.progress.products_total} productos procesados (${message.progress.percentage}%)`, + 'info' + ); + }); + + ws.subscribe('completed', (message: TrainingCompletedMessage) => { + console.log('Training completed:', message); + setTrainingStatus('completed'); + setProgress(100); - // Update time estimate if available - if (progressData.estimated_time_remaining_minutes !== undefined) { - setEstimatedTimeRemaining(progressData.estimated_time_remaining_minutes); - } + const metrics: TrainingMetrics = { + accuracy: message.results.performance_metrics.accuracy, + mape: message.results.performance_metrics.mape, + mae: message.results.performance_metrics.mae, + rmse: message.results.performance_metrics.rmse + }; - // Handle metrics (from legacy format for backward compatibility) - if (detail.metrics) { - setMetrics(detail.metrics); - } + setMetrics(metrics); + addLog('¡Entrenamiento ML completado exitosamente!', 'success'); + addLog(`${message.results.successful_trainings} modelos creados exitosamente`, 'success'); + addLog(`Duración total: ${Math.round(message.results.training_duration / 60)} minutos`, 'info'); - // Handle logs (from legacy format for backward compatibility) - if (detail.logs && detail.logs.length > 0) { - setLogs(prev => [...prev, ...detail.logs]); - } + createAlert({ + type: 'success', + category: 'system', + priority: 'medium', + title: 'Entrenamiento completado', + message: `Tu modelo de IA ha sido entrenado exitosamente. Precisión: ${(metrics.accuracy * 100).toFixed(1)}%`, + source: 'onboarding' + }); + + // Update parent data + onDataChange({ + ...data, + trainingStatus: 'completed', + trainingProgress: 100, + trainingJob: { ...job, status: 'completed', progress: 100, metrics }, + trainingLogs, + trainingMetrics: metrics + }); + + // Disconnect WebSocket + ws.disconnect(); + wsRef.current = null; + }); + + ws.subscribe('error', (message: TrainingErrorMessage) => { + console.error('Training error received:', message); + setTrainingStatus('failed'); + addLog(`Error en entrenamiento: ${message.error}`, 'error'); - // Check completion status - if (progressData.progress >= 100) { - setTrainingStatus('completed'); - } else { - setTrainingStatus('training'); - } - } else { - // Handle legacy format for backward compatibility - const { progress: newProgress, metrics: newMetrics, logs: newLogs, status, currentPhase: newPhase } = detail; - - setProgress(newProgress || 0); - setMetrics(newMetrics); - setCurrentPhase(newPhase || 'En progreso...'); - setTrainingStatus(status || 'training'); - - if (newLogs && newLogs.length > 0) { - setLogs(prev => [...prev, ...newLogs]); - } + createAlert({ + type: 'error', + category: 'system', + priority: 'high', + title: 'Error en entrenamiento', + message: message.error, + source: 'onboarding' + }); + + // Disconnect WebSocket + ws.disconnect(); + wsRef.current = null; + }); + + // Connect to WebSocket + await ws.connect(); + addLog('Conectado a WebSocket para actualizaciones en tiempo real', 'info'); + + } catch (error) { + console.error('Training start error:', error); + setTrainingStatus('failed'); + const errorMessage = error instanceof Error ? error.message : 'Error al iniciar entrenamiento'; + addLog(`Error: ${errorMessage}`, 'error'); + + createAlert({ + type: 'error', + category: 'system', + priority: 'high', + title: 'Error al iniciar entrenamiento', + message: errorMessage, + source: 'onboarding' + }); + + // Clean up WebSocket if it was created + if (wsRef.current) { + wsRef.current.disconnect(); + wsRef.current = null; + } + } + }; + + // Cleanup WebSocket on unmount + useEffect(() => { + return () => { + if (wsRef.current) { + wsRef.current.disconnect(); + wsRef.current = null; } }; - - window.addEventListener('mlTrainingProgress', handleProgress as EventListener); - return () => window.removeEventListener('mlTrainingProgress', handleProgress as EventListener); }, []); useEffect(() => { - // Update parent data when state changes - onDataChange({ - ...data, - trainingStatus, - trainingProgress: progress, - currentPhase, - trainingMetrics: metrics, - trainingLogs: logs, - finalResults - }); - }, [trainingStatus, progress, currentPhase, metrics, logs, finalResults]); - - // Auto-start training when coming from suppliers step - useEffect(() => { - if (data.autoStartTraining && trainingStatus === 'pending') { + // Auto-start training if all requirements are met and not already started + const validation = validateDataRequirements(); + console.log('MLTrainingStep - useEffect validation:', validation); + + if (validation.isValid && trainingStatus === 'idle' && data.autoStartTraining) { + console.log('MLTrainingStep - Auto-starting training...'); + // Auto-start after a brief delay to allow user to see the step const timer = setTimeout(() => { startTraining(); - // Remove the auto-start flag so it doesn't trigger again - onDataChange({ ...data, autoStartTraining: false }); }, 1000); - return () => clearTimeout(timer); } - }, [data.autoStartTraining, trainingStatus]); + }, [data.allStepData, data.autoStartTraining, trainingStatus]); - const startTraining = async () => { - // Access data from previous steps through allStepData - const inventoryData = data.allStepData?.inventory?.inventoryItems || - data.allStepData?.['inventory-setup']?.inventoryItems || - data.inventoryItems || []; - - const detectedProducts = data.allStepData?.review?.detectedProducts?.filter((p: any) => p.status === 'approved') || - data.allStepData?.['data-processing']?.detectedProducts?.filter((p: any) => p.status === 'approved') || - data.detectedProducts?.filter((p: any) => p.status === 'approved') || []; - - const salesValidation = data.allStepData?.['data-processing']?.validation || - data.allStepData?.review?.validation || - data.validation || {}; - - // If no data is available, create mock data for demo purposes - let finalInventoryData = inventoryData; - let finalDetectedProducts = detectedProducts; - let finalValidation = salesValidation; - - if (inventoryData.length === 0 && detectedProducts.length === 0) { - console.log('No data found from previous steps, using mock data for demo'); - - // Create mock data for demonstration - finalInventoryData = [ - { id: '1', name: 'Harina de Trigo', category: 'ingredient', current_stock: 50, min_stock: 20, max_stock: 100, unit: 'kg' }, - { id: '2', name: 'Levadura Fresca', category: 'ingredient', current_stock: 5, min_stock: 2, max_stock: 10, unit: 'kg' }, - { id: '3', name: 'Pan Integral', category: 'finished_product', current_stock: 20, min_stock: 10, max_stock: 50, unit: 'unidades' } - ]; - - finalDetectedProducts = [ - { name: 'Pan Francés', status: 'approved', category: 'Panadería' }, - { name: 'Croissants', status: 'approved', category: 'Repostería' }, - { name: 'Pan Integral', status: 'approved', category: 'Panadería' } - ]; - - finalValidation = { - total_records: 1500, - summary: { - date_range: '2024-01-01 to 2024-12-31' - } - }; - } - - setTrainingStatus('training'); - setProgress(0); - setLogs([{ - timestamp: new Date().toLocaleTimeString(), - message: 'Iniciando entrenamiento del modelo de Machine Learning...', - level: 'info' - }]); - - try { - const result = await mockMLService.startTraining({ - products: finalDetectedProducts, - inventory: finalInventoryData, - salesData: finalValidation - }); - - setFinalResults(result); - setTrainingStatus('completed'); - - } catch (error) { - console.error('ML Training error:', error); - setTrainingStatus('error'); - setLogs(prev => [...prev, { - timestamp: new Date().toLocaleTimeString(), - message: 'Error durante el entrenamiento del modelo', - level: 'error' - }]); + const getStatusIcon = () => { + switch (trainingStatus) { + case 'idle': return ; + case 'validating': return ; + case 'training': return ; + case 'completed': return ; + case 'failed': return ; + default: return ; } }; - const retryTraining = () => { - setTrainingStatus('pending'); - setProgress(0); - setMetrics(null); - setLogs([]); - setFinalResults(null); - }; - - const getLogIcon = (level: string) => { - switch (level) { - case 'success': return '✅'; - case 'warning': return '⚠️'; - case 'error': return '❌'; - default: return 'ℹ️'; + const getStatusColor = () => { + switch (trainingStatus) { + case 'completed': return 'text-[var(--color-success)]'; + case 'failed': return 'text-[var(--color-error)]'; + case 'training': + case 'validating': return 'text-[var(--color-info)]'; + default: return 'text-[var(--text-primary)]'; } }; - const getLogColor = (level: string) => { - switch (level) { - case 'success': return 'text-[var(--color-success)]'; - case 'warning': return 'text-[var(--color-warning)]'; - case 'error': return 'text-[var(--color-error)]'; - default: return 'text-[var(--text-secondary)]'; + const getStatusMessage = () => { + switch (trainingStatus) { + case 'idle': return 'Listo para entrenar tu asistente IA'; + case 'validating': return 'Validando datos para entrenamiento...'; + case 'training': return 'Entrenando modelo de predicción...'; + case 'completed': return '¡Tu asistente IA está listo!'; + case 'failed': return 'Error en el entrenamiento'; + default: return 'Estado desconocido'; } }; + // Check data requirements for display + const validation = validateDataRequirements(); + + if (!validation.isValid) { + return ( +
+
+ +

+ Datos insuficientes para entrenamiento +

+

+ Para entrenar tu modelo de IA, necesitamos que completes los siguientes elementos: +

+ + +

Elementos requeridos:

+
    + {validation.missingItems.map((item, index) => ( +
  • + + {item} +
  • + ))} +
+
+ +

+ Una vez completados estos elementos, el entrenamiento se iniciará automáticamente. +

+ + +
+
+ ); + } + return ( -
+
{/* Header */} -
-
- +
+
+ {getStatusIcon()}
+

+ Entrenamiento de IA +

+

+ {getStatusMessage()} +

- Tu asistente inteligente analizará tus datos históricos para ayudarte a tomar mejores decisiones de negocio + Creando tu asistente inteligente personalizado con tus datos de ventas e inventario

- {/* Auto-start notification */} - {data.autoStartTraining && trainingStatus === 'pending' && ( - -
-
-

- Creando tu asistente inteligente automáticamente... -

+ {/* Progress Bar */} + +
+ Progreso del entrenamiento + {progress.toFixed(1)}% +
+ + {currentStep && ( +
+ Paso actual: + {currentStep} +
+ )} + + {estimatedTimeRemaining > 0 && ( +
+ Tiempo estimado restante: + + {Math.round(estimatedTimeRemaining / 60)} minutos + +
+ )} + +
+
+
+ + + {/* Training Logs */} + +

+ + Registro de entrenamiento +

+
+ {trainingLogs.length === 0 ? ( +

Esperando inicio de entrenamiento...

+ ) : ( + trainingLogs.map((log, index) => ( +
+
+
+

{log.message}

+

+ {new Date(log.timestamp).toLocaleTimeString()} +

+
+
+ )) + )} +
+ + + {/* Training Metrics */} + {metrics && trainingStatus === 'completed' && ( + +

+ + Métricas del modelo +

+
+
+

+ {(metrics.accuracy * 100).toFixed(1)}% +

+

Precisión

+
+
+

+ {metrics.mape.toFixed(1)}% +

+

MAPE

+
+
+

+ {metrics.mae.toFixed(2)} +

+

MAE

+
+
+

+ {metrics.rmse.toFixed(2)} +

+

RMSE

+
)} - {/* Training Controls */} - {trainingStatus === 'pending' && !data.autoStartTraining && ( + {/* Manual Start Button (if not auto-started) */} + {trainingStatus === 'idle' && ( -

- Crear tu Asistente Inteligente -

-

- Analizaremos tus datos de ventas y productos para crear un asistente que te ayude a predecir demanda y optimizar tu inventario. -

- -
-
- -

Predicción de Ventas

-

Anticipa cuánto vas a vender

-
- -
- -

Recomendaciones

-

Cuánto producir y comprar

-
- -
- -

Alertas Automáticas

-

Te avisa cuando reordenar

-
-
- -
-

Información que analizaremos:

-
-
- Productos: -
- {(() => { - const allStepData = data.allStepData || {}; - const detectedProducts = allStepData.review?.detectedProducts?.filter((p: any) => p.status === 'approved') || - allStepData['data-processing']?.detectedProducts?.filter((p: any) => p.status === 'approved') || - data.detectedProducts?.filter((p: any) => p.status === 'approved') || []; - return detectedProducts.length || 3; // fallback to mock data count - })()} -
-
- Inventario: -
- {(() => { - const allStepData = data.allStepData || {}; - const inventoryData = allStepData.inventory?.inventoryItems || - allStepData['inventory-setup']?.inventoryItems || - allStepData['inventory']?.inventoryItems || - data.inventoryItems || []; - return inventoryData.length || 3; // fallback to mock data count - })()} elementos -
-
- Registros: -
- {(() => { - const allStepData = data.allStepData || {}; - const validation = allStepData['data-processing']?.validation || - allStepData.review?.validation || - data.validation || {}; - return validation.total_records || 1500; // fallback to mock data - })()} -
-
- Período: -
- {(() => { - const allStepData = data.allStepData || {}; - const validation = allStepData['data-processing']?.validation || - allStepData.review?.validation || - data.validation || {}; - const dateRange = validation.summary?.date_range || '2024-01-01 to 2024-12-31'; - return dateRange.split(' to ').map((date: string) => - new Date(date).toLocaleDateString() - ).join(' - '); - })()} -
-
-
- -
)} - {/* Training Progress */} - {trainingStatus === 'training' && ( -
- -
-
- -
-

- Creando tu Asistente Inteligente... -

-

{currentPhase}

-
- - {/* Progress Bar */} -
-
- - Progreso de Creación - -
- - {progress.toFixed(0)}% - - {productsTotal > 0 && ( -
- {productsCompleted}/{productsTotal} productos -
- )} - {estimatedTimeRemaining !== null && estimatedTimeRemaining > 0 && ( -
- ~{estimatedTimeRemaining} min restantes -
- )} -
-
-
-
-
-
-
-
- - {/* Current Metrics */} - {metrics && ( -
-
-

- {(metrics.accuracy * 100).toFixed(1)}% -

-

Precisión

-
-
-

- {metrics.training_loss.toFixed(3)} -

-

Loss

-
-
-

- {metrics.epochs_completed}/{metrics.total_epochs} -

-

Epochs

-
-
-

- {(metrics.f1_score * 100).toFixed(1)}% -

-

F1-Score

-
-
- )} - - - {/* Training Logs */} - -

Progreso del Asistente

-
- {logs.slice(-10).map((log, index) => ( -
- [{log.timestamp}] - {getLogIcon(log.level)} {log.message} -
- ))} -
-
-
- )} - - {/* Training Completed */} - {trainingStatus === 'completed' && finalResults && ( -
- -
-
- -
-

- ¡Tu Asistente Inteligente está Listo! -

-

- Tu asistente personalizado está listo para ayudarte con predicciones y recomendaciones -

-
- - {/* Final Metrics */} -
-
-

- {(finalResults.finalMetrics.accuracy * 100).toFixed(1)}% -

-

Exactitud

-
-
-

- {(finalResults.finalMetrics.precision * 100).toFixed(1)}% -

-

Confiabilidad

-
-
-

- {(finalResults.finalMetrics.recall * 100).toFixed(1)}% -

-

Cobertura

-
-
-

- {(finalResults.finalMetrics.f1_score * 100).toFixed(1)}% -

-

Rendimiento

-
-
-

- {finalResults.finalMetrics.total_epochs} -

-

Iteraciones

-
-
- - {/* Model Info */} -
-

Información del Modelo:

-
-
- ID del Modelo: -

{finalResults.modelId}

-
-
- Versión: -

{finalResults.modelVersion}

-
-
- Duración: -

{finalResults.trainingDuration}

-
-
- Dataset: -

{finalResults.datasetSize}

-
-
- Endpoint: -

{finalResults.deploymentUrl}

-
-
- Estado: -

✓ Activo

-
-
-
- - {/* Capabilities */} -
-
- -

Predicción de Ventas

-

- Te dice cuánto vas a vender cada día -

-
- -
- -

Recomendaciones

-

- Te sugiere cuánto producir y comprar -

-
- -
- -

Alertas Automáticas

-

- Te avisa cuando necesitas reordenar -

-
-
-
-
- )} - - {/* Training Error */} - {trainingStatus === 'error' && ( - -
-
- -
-

- Error al Crear el Asistente -

-

- Ocurrió un problema durante la creación de tu asistente inteligente. Por favor, intenta nuevamente. -

- -
-
- )} - - {/* Information */} - -

- 🎯 ¿Cómo funciona tu Asistente Inteligente? -

-
    -
  • Analiza tus ventas: Estudia tus datos históricos para encontrar patrones
  • -
  • Entiende tu negocio: Aprende sobre temporadas altas y bajas
  • -
  • Hace predicciones: Te dice cuánto vas a vender cada día
  • -
  • Da recomendaciones: Te sugiere cuánto producir y comprar
  • -
  • Mejora con el tiempo: Se hace más inteligente con cada venta nueva
  • -
-
- + {/* Navigation */} +
+ + +
); }; \ No newline at end of file diff --git a/frontend/src/components/domain/onboarding/steps/ReviewStep.tsx b/frontend/src/components/domain/onboarding/steps/ReviewStep.tsx index 77834d86..f70621a8 100644 --- a/frontend/src/components/domain/onboarding/steps/ReviewStep.tsx +++ b/frontend/src/components/domain/onboarding/steps/ReviewStep.tsx @@ -1,30 +1,68 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { Eye, CheckCircle, AlertCircle, Edit, Trash2 } from 'lucide-react'; import { Button, Card, Badge } from '../../../ui'; import { OnboardingStepProps } from '../OnboardingWizard'; +import { useAlertActions } from '../../../../stores/alerts.store'; interface Product { id: string; name: string; category: string; confidence: number; - sales_count: number; - estimated_price: number; + sales_count?: number; + estimated_price?: number; status: 'approved' | 'rejected' | 'pending'; notes?: string; + // Fields from API suggestion + suggestion_id?: string; + original_name: string; + suggested_name: string; + product_type: 'ingredient' | 'finished_product'; + unit_of_measure: string; + estimated_shelf_life_days: number; + requires_refrigeration: boolean; + requires_freezing: boolean; + is_seasonal: boolean; + suggested_supplier?: string; + sales_data?: { + total_quantity: number; + average_daily_sales: number; + peak_day: string; + frequency: number; + }; } -// Mock detected products -const mockDetectedProducts: Product[] = [ - { id: '1', name: 'Pan Integral', category: 'Panadería', confidence: 95, sales_count: 45, estimated_price: 2.50, status: 'pending' }, - { id: '2', name: 'Croissant', category: 'Bollería', confidence: 92, sales_count: 38, estimated_price: 1.80, status: 'pending' }, - { id: '3', name: 'Baguette', category: 'Panadería', confidence: 88, sales_count: 22, estimated_price: 3.00, status: 'pending' }, - { id: '4', name: 'Empanada de Pollo', category: 'Salados', confidence: 85, sales_count: 31, estimated_price: 4.50, status: 'pending' }, - { id: '5', name: 'Tarta de Manzana', category: 'Repostería', confidence: 78, sales_count: 12, estimated_price: 15.00, status: 'pending' }, - { id: '6', name: 'Pan de Centeno', category: 'Panadería', confidence: 91, sales_count: 18, estimated_price: 2.80, status: 'pending' }, - { id: '7', name: 'Medialunas', category: 'Bollería', confidence: 87, sales_count: 29, estimated_price: 1.20, status: 'pending' }, - { id: '8', name: 'Sandwich Mixto', category: 'Salados', confidence: 82, sales_count: 25, estimated_price: 5.50, status: 'pending' } -]; +// Convert API suggestions to Product interface +const convertSuggestionsToProducts = (suggestions: any[]): Product[] => { + console.log('ReviewStep - convertSuggestionsToProducts called with:', suggestions); + + const products = suggestions.map((suggestion, index) => ({ + id: suggestion.suggestion_id || `product-${index}`, + name: suggestion.suggested_name, + category: suggestion.category, + confidence: Math.round(suggestion.confidence_score * 100), + status: 'pending' as const, + // Store original API data + suggestion_id: suggestion.suggestion_id, + original_name: suggestion.original_name, + suggested_name: suggestion.suggested_name, + product_type: suggestion.product_type, + unit_of_measure: suggestion.unit_of_measure, + estimated_shelf_life_days: suggestion.estimated_shelf_life_days, + requires_refrigeration: suggestion.requires_refrigeration, + requires_freezing: suggestion.requires_freezing, + is_seasonal: suggestion.is_seasonal, + suggested_supplier: suggestion.suggested_supplier, + notes: suggestion.notes, + sales_data: suggestion.sales_data, + // Legacy fields for display + sales_count: suggestion.sales_data?.total_quantity || 0, + estimated_price: 0 // Price estimation not provided by current API + })); + + console.log('ReviewStep - Converted products:', products); + return products; +}; export const ReviewStep: React.FC = ({ data, @@ -34,35 +72,128 @@ export const ReviewStep: React.FC = ({ isFirstStep, isLastStep }) => { - // Generate products from processing results or use mock data + const { createAlert } = useAlertActions(); + + // Generate products from AI suggestions in processing results const generateProductsFromResults = (results: any) => { - if (!results?.product_list) return mockDetectedProducts; + console.log('ReviewStep - generateProductsFromResults called with:', results); + console.log('ReviewStep - results keys:', Object.keys(results || {})); + console.log('ReviewStep - results.aiSuggestions:', results?.aiSuggestions); + console.log('ReviewStep - aiSuggestions length:', results?.aiSuggestions?.length); + console.log('ReviewStep - aiSuggestions type:', typeof results?.aiSuggestions); + console.log('ReviewStep - aiSuggestions is array:', Array.isArray(results?.aiSuggestions)); - return results.product_list.map((name: string, index: number) => ({ - id: (index + 1).toString(), - name, - category: index < 3 ? 'Panadería' : index < 5 ? 'Bollería' : 'Salados', - confidence: Math.max(75, results.confidenceScore - Math.random() * 15), - sales_count: Math.floor(Math.random() * 50) + 10, - estimated_price: Math.random() * 5 + 1.5, - status: 'pending' as const - })); + if (results?.aiSuggestions && results.aiSuggestions.length > 0) { + console.log('ReviewStep - Using AI suggestions:', results.aiSuggestions); + return convertSuggestionsToProducts(results.aiSuggestions); + } + // Fallback: create products from product list if no AI suggestions + if (results?.product_list) { + console.log('ReviewStep - Using fallback product list:', results.product_list); + return results.product_list.map((name: string, index: number) => ({ + id: `fallback-${index}`, + name, + original_name: name, + suggested_name: name, + category: 'Sin clasificar', + confidence: 50, + status: 'pending' as const, + product_type: 'finished_product' as const, + unit_of_measure: 'unidad', + estimated_shelf_life_days: 7, + requires_refrigeration: false, + requires_freezing: false, + is_seasonal: false + })); + } + return []; }; - const [products, setProducts] = useState( - data.detectedProducts || generateProductsFromResults(data.processingResults) - ); + const [products, setProducts] = useState(() => { + if (data.detectedProducts) { + return data.detectedProducts; + } + // Try to get processing results from current step data first, then from previous step data + const processingResults = data.processingResults || data.allStepData?.['data-processing']?.processingResults; + console.log('ReviewStep - Initializing with processingResults:', processingResults); + return generateProductsFromResults(processingResults); + }); + + // Check for empty products and show alert after component mounts + useEffect(() => { + const processingResults = data.processingResults || data.allStepData?.['data-processing']?.processingResults; + if (products.length === 0 && processingResults) { + createAlert({ + type: 'warning', + category: 'system', + priority: 'medium', + title: 'Sin productos detectados', + message: 'No se encontraron productos en los datos procesados. Verifique el archivo de ventas.', + source: 'onboarding' + }); + } + }, [products.length, data.processingResults, data.allStepData, createAlert]); + const [selectedCategory, setSelectedCategory] = useState('all'); const categories = ['all', ...Array.from(new Set(products.map(p => p.category)))]; + // Memoize computed values to avoid unnecessary recalculations + const approvedProducts = useMemo(() => + products.filter(p => p.status === 'approved'), + [products] + ); + + const reviewCompleted = useMemo(() => + products.length > 0 && products.every(p => p.status !== 'pending'), + [products] + ); + + const [lastReviewCompleted, setLastReviewCompleted] = useState(false); + + const dataChangeRef = useRef({ products: [], approvedProducts: [], reviewCompleted: false }); + + // Update parent data when products change useEffect(() => { - onDataChange({ - ...data, - detectedProducts: products, - reviewCompleted: products.every(p => p.status !== 'pending') - }); - }, [products]); + const currentState = { products, approvedProducts, reviewCompleted }; + const lastState = dataChangeRef.current; + + // Only call onDataChange if the state actually changed + if (JSON.stringify(currentState) !== JSON.stringify(lastState)) { + console.log('ReviewStep - Updating parent data with:', { + detectedProducts: products, + approvedProducts, + reviewCompleted, + approvedProductsCount: approvedProducts.length + }); + onDataChange({ + ...data, + detectedProducts: products, + approvedProducts, + reviewCompleted + }); + dataChangeRef.current = currentState; + } + }, [products, approvedProducts, reviewCompleted]); + + // Handle review completion alert separately + useEffect(() => { + if (reviewCompleted && approvedProducts.length > 0 && !lastReviewCompleted) { + createAlert({ + type: 'success', + category: 'system', + priority: 'medium', + title: 'Revisión completada', + message: `Se aprobaron ${approvedProducts.length} de ${products.length} productos detectados.`, + source: 'onboarding' + }); + setLastReviewCompleted(true); + } + + if (!reviewCompleted && lastReviewCompleted) { + setLastReviewCompleted(false); + } + }, [reviewCompleted, approvedProducts.length, products.length, lastReviewCompleted, createAlert]); const handleProductAction = (productId: string, action: 'approve' | 'reject') => { setProducts(prev => prev.map(product => @@ -95,191 +226,188 @@ export const ReviewStep: React.FC = ({ pending: products.filter(p => p.status === 'pending').length }; + const getConfidenceColor = (confidence: number) => { + if (confidence >= 90) return 'text-green-600 bg-green-50'; + if (confidence >= 75) return 'text-yellow-600 bg-yellow-50'; + return 'text-red-600 bg-red-50'; + }; + + const getStatusColor = (status: string) => { + switch (status) { + case 'approved': return 'text-green-600 bg-green-50'; + case 'rejected': return 'text-red-600 bg-red-50'; + default: return 'text-gray-600 bg-gray-50'; + } + }; + + if (products.length === 0) { + return ( +
+
+ +

+ No se encontraron productos +

+

+ No se pudieron detectar productos en el archivo procesado. + Verifique que el archivo contenga datos de ventas válidos. +

+ +
+
+ ); + } + return (
{/* Summary Stats */} -
-
-

{stats.total}

-

Total

+
+ +
{stats.total}
+
Productos detectados
+
+ + +
{stats.approved}
+
Aprobados
+
+ + +
{stats.rejected}
+
Rechazados
+
+ + +
{stats.pending}
+
Pendientes
+
+
+ + {/* Controls */} +
+
+ + + + {getFilteredProducts().length} productos +
-
-

{stats.approved}

-

Aprobados

-
-
-

{stats.rejected}

-

Rechazados

-
-
-

{stats.pending}

-

Pendientes

+ +
+ +
- {/* Filters and Actions */} - -
-
- - -
- -
- - -
-
-
- {/* Products List */} -
+
{getFilteredProducts().map((product) => ( - -
-
- {/* Status Icon */} -
- {product.status === 'approved' ? ( - - ) : product.status === 'rejected' ? ( - - ) : ( - - )} + +
+
+
+

{product.name}

+ + {product.category} + + + {product.confidence}% confianza + + + {product.status === 'approved' ? 'Aprobado' : + product.status === 'rejected' ? 'Rechazado' : 'Pendiente'} +
- - {/* Product Info */} -
-

{product.name}

- -
- {product.category} - {product.status !== 'pending' && ( - - {product.status === 'approved' ? 'Aprobado' : 'Rechazado'} - + +
+ {product.original_name && product.original_name !== product.name && ( +
Nombre original: {product.original_name}
+ )} +
+ Tipo: {product.product_type === 'ingredient' ? 'Ingrediente' : 'Producto terminado'} + Unidad: {product.unit_of_measure} + {product.sales_data && ( + Ventas: {product.sales_data.total_quantity} )}
- -
- = 90 - ? 'bg-[var(--color-success)]/10 text-[var(--color-success)]' - : product.confidence >= 75 - ? 'bg-[var(--color-warning)]/10 text-[var(--color-warning)]' - : 'bg-[var(--color-error)]/10 text-[var(--color-error)]' - }`}> - {product.confidence}% confianza - - {product.sales_count} ventas - ${product.estimated_price.toFixed(2)} -
+ {product.notes && ( +
Nota: {product.notes}
+ )}
- - {/* Actions */} -
- {product.status === 'pending' ? ( - <> - - - - ) : ( - - )} + +
+ +
))}
- {/* Progress Indicator */} - {stats.pending > 0 && ( - -
- -
-

- {stats.pending} productos pendientes de revisión -

-

- Revisa todos los productos antes de continuar al siguiente paso -

-
-
-
- )} - - {/* Help Information */} - -

- 💡 Consejos para la revisión: -

-
    -
  • Confianza alta (90%+): Productos identificados con alta precisión
  • -
  • Confianza media (75-89%): Revisar nombres y categorías
  • -
  • Confianza baja (<75%): Verificar que corresponden a tu catálogo
  • -
  • • Usa las acciones masivas para aprobar/rechazar por categoría completa
  • -
-
+ {/* Navigation */} +
+ + +
); }; \ No newline at end of file diff --git a/frontend/src/components/domain/onboarding/steps/SuppliersStep.tsx b/frontend/src/components/domain/onboarding/steps/SuppliersStep.tsx index 772cdc89..6b8018c3 100644 --- a/frontend/src/components/domain/onboarding/steps/SuppliersStep.tsx +++ b/frontend/src/components/domain/onboarding/steps/SuppliersStep.tsx @@ -1,66 +1,40 @@ import React, { useState, useEffect } from 'react'; -import { Truck, Phone, Mail, Plus, Edit, Trash2, MapPin } from 'lucide-react'; +import { Truck, Phone, Mail, Plus, Edit, Trash2, MapPin, AlertCircle, Loader } from 'lucide-react'; import { Button, Card, Input, Badge } from '../../../ui'; import { OnboardingStepProps } from '../OnboardingWizard'; +import { procurementService, type Supplier } from '../../../../services/api/procurement.service'; +import { useAuthUser } from '../../../../stores/auth.store'; +import { useCurrentTenant } from '../../../../stores/tenant.store'; +import { useAlertActions } from '../../../../stores/alerts.store'; -interface Supplier { - id: string; +// Frontend supplier interface that matches the form needs +interface SupplierFormData { + id?: string; name: string; - contact_person: string; + contact_name: string; phone: string; email: string; address: string; - categories: string[]; payment_terms: string; - delivery_days: string[]; - min_order_amount?: number; - notes?: string; - status: 'active' | 'inactive'; - created_at: string; + delivery_terms: string; + tax_id?: string; + is_active: boolean; } -// Mock suppliers -const mockSuppliers: Supplier[] = [ - { - id: '1', - name: 'Molinos del Sur', - contact_person: 'Juan Pérez', - phone: '+1 555-0123', - email: 'ventas@molinosdelsur.com', - address: 'Av. Industrial 123, Zona Sur', - categories: ['Harinas', 'Granos'], - payment_terms: '30 días', - delivery_days: ['Lunes', 'Miércoles', 'Viernes'], - min_order_amount: 200, - notes: 'Proveedor principal de harinas, muy confiable', - status: 'active', - created_at: '2024-01-15' - }, - { - id: '2', - name: 'Lácteos Premium', - contact_person: 'María González', - phone: '+1 555-0456', - email: 'pedidos@lacteospremium.com', - address: 'Calle Central 456, Centro', - categories: ['Lácteos', 'Mantequillas', 'Quesos'], - payment_terms: '15 días', - delivery_days: ['Martes', 'Jueves', 'Sábado'], - min_order_amount: 150, - status: 'active', - created_at: '2024-01-20' - } -]; - -const daysOfWeek = [ - 'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo' -]; - const commonCategories = [ 'Harinas', 'Lácteos', 'Levaduras', 'Azúcares', 'Grasas', 'Huevos', 'Frutas', 'Chocolates', 'Frutos Secos', 'Especias', 'Conservantes' ]; +const paymentTermsOptions = [ + 'Inmediato', + '15 días', + '30 días', + '45 días', + '60 días', + '90 días' +]; + export const SuppliersStep: React.FC = ({ data, onDataChange, @@ -69,13 +43,57 @@ export const SuppliersStep: React.FC = ({ isFirstStep, isLastStep }) => { - const [suppliers, setSuppliers] = useState( - data.suppliers || mockSuppliers - ); - const [editingSupplier, setEditingSupplier] = useState(null); + const user = useAuthUser(); + const currentTenant = useCurrentTenant(); + const { createAlert } = useAlertActions(); + + const [suppliers, setSuppliers] = useState([]); + const [editingSupplier, setEditingSupplier] = useState(null); const [isAddingNew, setIsAddingNew] = useState(false); const [filterStatus, setFilterStatus] = useState<'all' | 'active' | 'inactive'>('all'); + const [loading, setLoading] = useState(false); + const [creating, setCreating] = useState(false); + const [updating, setUpdating] = useState(false); + const [deleting, setDeleting] = useState(null); + // Load suppliers from backend on component mount + useEffect(() => { + const loadSuppliers = async () => { + // Check if we already have suppliers loaded + if (data.suppliers && Array.isArray(data.suppliers) && data.suppliers.length > 0) { + setSuppliers(data.suppliers); + return; + } + + if (!currentTenant?.id) { + return; + } + + setLoading(true); + try { + const response = await procurementService.getSuppliers({ size: 100 }); + if (response.success && response.data) { + setSuppliers(response.data.items); + } + } catch (error) { + console.error('Failed to load suppliers:', error); + createAlert({ + type: 'error', + category: 'system', + priority: 'medium', + title: 'Error al cargar proveedores', + message: 'No se pudieron cargar los proveedores existentes.', + source: 'onboarding' + }); + } finally { + setLoading(false); + } + }; + + loadSuppliers(); + }, [currentTenant?.id]); + + // Update parent data when suppliers change useEffect(() => { onDataChange({ ...data, @@ -85,60 +103,202 @@ export const SuppliersStep: React.FC = ({ }, [suppliers]); const handleAddSupplier = () => { - const newSupplier: Supplier = { - id: Date.now().toString(), + const newSupplier: SupplierFormData = { name: '', - contact_person: '', + contact_name: '', phone: '', email: '', address: '', - categories: [], payment_terms: '30 días', - delivery_days: [], - status: 'active', - created_at: new Date().toISOString().split('T')[0] + delivery_terms: 'Recoger en tienda', + tax_id: '', + is_active: true }; setEditingSupplier(newSupplier); setIsAddingNew(true); }; - const handleSaveSupplier = (supplier: Supplier) => { + const handleSaveSupplier = async (supplierData: SupplierFormData) => { if (isAddingNew) { - setSuppliers(prev => [...prev, supplier]); + setCreating(true); + try { + const response = await procurementService.createSupplier({ + name: supplierData.name, + contact_name: supplierData.contact_name, + phone: supplierData.phone, + email: supplierData.email, + address: supplierData.address, + payment_terms: supplierData.payment_terms, + delivery_terms: supplierData.delivery_terms, + tax_id: supplierData.tax_id, + is_active: supplierData.is_active + }); + + if (response.success && response.data) { + setSuppliers(prev => [...prev, response.data]); + createAlert({ + type: 'success', + category: 'system', + priority: 'low', + title: 'Proveedor creado', + message: `El proveedor ${response.data.name} se ha creado exitosamente.`, + source: 'onboarding' + }); + } + } catch (error) { + console.error('Failed to create supplier:', error); + createAlert({ + type: 'error', + category: 'system', + priority: 'high', + title: 'Error al crear proveedor', + message: 'No se pudo crear el proveedor. Intente nuevamente.', + source: 'onboarding' + }); + } finally { + setCreating(false); + } } else { - setSuppliers(prev => prev.map(s => s.id === supplier.id ? supplier : s)); + // Update existing supplier + if (!supplierData.id) return; + + setUpdating(true); + try { + const response = await procurementService.updateSupplier(supplierData.id, { + name: supplierData.name, + contact_name: supplierData.contact_name, + phone: supplierData.phone, + email: supplierData.email, + address: supplierData.address, + payment_terms: supplierData.payment_terms, + delivery_terms: supplierData.delivery_terms, + tax_id: supplierData.tax_id, + is_active: supplierData.is_active + }); + + if (response.success && response.data) { + setSuppliers(prev => prev.map(s => s.id === response.data.id ? response.data : s)); + createAlert({ + type: 'success', + category: 'system', + priority: 'low', + title: 'Proveedor actualizado', + message: `El proveedor ${response.data.name} se ha actualizado exitosamente.`, + source: 'onboarding' + }); + } + } catch (error) { + console.error('Failed to update supplier:', error); + createAlert({ + type: 'error', + category: 'system', + priority: 'high', + title: 'Error al actualizar proveedor', + message: 'No se pudo actualizar el proveedor. Intente nuevamente.', + source: 'onboarding' + }); + } finally { + setUpdating(false); + } } + setEditingSupplier(null); setIsAddingNew(false); }; - const handleDeleteSupplier = (id: string) => { - if (window.confirm('¿Estás seguro de eliminar este proveedor?')) { - setSuppliers(prev => prev.filter(s => s.id !== id)); + const handleDeleteSupplier = async (id: string) => { + if (!window.confirm('¿Estás seguro de eliminar este proveedor? Esta acción no se puede deshacer.')) { + return; + } + + setDeleting(id); + try { + const response = await procurementService.deleteSupplier(id); + if (response.success) { + setSuppliers(prev => prev.filter(s => s.id !== id)); + createAlert({ + type: 'success', + category: 'system', + priority: 'low', + title: 'Proveedor eliminado', + message: 'El proveedor se ha eliminado exitosamente.', + source: 'onboarding' + }); + } + } catch (error) { + console.error('Failed to delete supplier:', error); + createAlert({ + type: 'error', + category: 'system', + priority: 'high', + title: 'Error al eliminar proveedor', + message: 'No se pudo eliminar el proveedor. Intente nuevamente.', + source: 'onboarding' + }); + } finally { + setDeleting(null); } }; - const toggleSupplierStatus = (id: string) => { - setSuppliers(prev => prev.map(s => - s.id === id - ? { ...s, status: s.status === 'active' ? 'inactive' : 'active' } - : s - )); + const toggleSupplierStatus = async (id: string, currentStatus: boolean) => { + try { + const response = await procurementService.updateSupplier(id, { + is_active: !currentStatus + }); + + if (response.success && response.data) { + setSuppliers(prev => prev.map(s => + s.id === id ? response.data : s + )); + createAlert({ + type: 'success', + category: 'system', + priority: 'low', + title: 'Estado actualizado', + message: `El proveedor se ha ${!currentStatus ? 'activado' : 'desactivado'} exitosamente.`, + source: 'onboarding' + }); + } + } catch (error) { + console.error('Failed to toggle supplier status:', error); + createAlert({ + type: 'error', + category: 'system', + priority: 'high', + title: 'Error al cambiar estado', + message: 'No se pudo cambiar el estado del proveedor.', + source: 'onboarding' + }); + } }; const getFilteredSuppliers = () => { - return filterStatus === 'all' - ? suppliers - : suppliers.filter(s => s.status === filterStatus); + if (filterStatus === 'all') { + return suppliers; + } + return suppliers.filter(s => + filterStatus === 'active' ? s.is_active : !s.is_active + ); }; const stats = { total: suppliers.length, - active: suppliers.filter(s => s.status === 'active').length, - inactive: suppliers.filter(s => s.status === 'inactive').length, - categories: Array.from(new Set(suppliers.flatMap(s => s.categories))).length + active: suppliers.filter(s => s.is_active).length, + inactive: suppliers.filter(s => !s.is_active).length, + totalOrders: suppliers.reduce((sum, s) => sum + s.performance_metrics.total_orders, 0) }; + if (loading) { + return ( +
+
+ +

Cargando proveedores...

+
+
+ ); + } + return (
{/* Optional Step Notice */} @@ -161,8 +321,13 @@ export const SuppliersStep: React.FC = ({
@@ -184,8 +349,8 @@ export const SuppliersStep: React.FC = ({

Inactivos

-

{stats.categories}

-

Categorías

+

{stats.totalOrders}

+

Órdenes

@@ -223,12 +388,12 @@ export const SuppliersStep: React.FC = ({
{/* Status Icon */}
@@ -239,63 +404,70 @@ export const SuppliersStep: React.FC = ({

{supplier.name}

- - {supplier.status === 'active' ? 'Activo' : 'Inactivo'} + + {supplier.is_active ? 'Activo' : 'Inactivo'} - {supplier.categories.slice(0, 2).map((cat, idx) => ( - {cat} - ))} - {supplier.categories.length > 2 && ( - +{supplier.categories.length - 2} + {supplier.rating && ( + ★ {supplier.rating.toFixed(1)} + )} + {supplier.performance_metrics.total_orders > 0 && ( + {supplier.performance_metrics.total_orders} órdenes )}
-
- Contacto: - {supplier.contact_person} -
- -
- Entrega: - - {supplier.delivery_days.slice(0, 2).join(', ')} - {supplier.delivery_days.length > 2 && ` +${supplier.delivery_days.length - 2}`} - -
- -
- Pago: - {supplier.payment_terms} -
- - {supplier.min_order_amount && ( + {supplier.contact_name && (
- Mín: - ${supplier.min_order_amount} + Contacto: + {supplier.contact_name} +
+ )} + + {supplier.delivery_terms && ( +
+ Entrega: + {supplier.delivery_terms} +
+ )} + + {supplier.payment_terms && ( +
+ Pago: + {supplier.payment_terms}
)}
-
- - {supplier.phone} -
-
- - {supplier.email} -
+ {supplier.phone && ( +
+ + {supplier.phone} +
+ )} + {supplier.email && ( +
+ + {supplier.email} +
+ )}
{supplier.address}
- {supplier.notes && ( + {supplier.performance_metrics.on_time_delivery_rate > 0 && (
- Notas: - {supplier.notes} + Rendimiento: + + {supplier.performance_metrics.on_time_delivery_rate}% entregas a tiempo + + {supplier.performance_metrics.quality_score > 0 && ( + + , {supplier.performance_metrics.quality_score}/5 calidad + + )}
)}
@@ -306,7 +478,24 @@ export const SuppliersStep: React.FC = ({
))} - {getFilteredSuppliers().length === 0 && ( + {getFilteredSuppliers().length === 0 && !loading && ( -

No hay proveedores en esta categoría

- @@ -364,6 +564,8 @@ export const SuppliersStep: React.FC = ({ setEditingSupplier(null); setIsAddingNew(false); }} + isCreating={creating} + isUpdating={updating} />
@@ -388,45 +590,37 @@ export const SuppliersStep: React.FC = ({ // Component for editing suppliers interface SupplierFormProps { - supplier: Supplier; - onSave: (supplier: Supplier) => void; + supplier: SupplierFormData; + onSave: (supplier: SupplierFormData) => void; onCancel: () => void; + isCreating: boolean; + isUpdating: boolean; } -const SupplierForm: React.FC = ({ supplier, onSave, onCancel }) => { +const SupplierForm: React.FC = ({ + supplier, + onSave, + onCancel, + isCreating, + isUpdating +}) => { const [formData, setFormData] = useState(supplier); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); + if (!formData.name.trim()) { - alert('El nombre es requerido'); + alert('El nombre de la empresa es requerido'); return; } - if (!formData.contact_person.trim()) { - alert('El contacto es requerido'); + if (!formData.address.trim()) { + alert('La dirección es requerida'); return; } + onSave(formData); }; - const toggleCategory = (category: string) => { - setFormData(prev => ({ - ...prev, - categories: prev.categories.includes(category) - ? prev.categories.filter(c => c !== category) - : [...prev.categories, category] - })); - }; - - const toggleDeliveryDay = (day: string) => { - setFormData(prev => ({ - ...prev, - delivery_days: prev.delivery_days.includes(day) - ? prev.delivery_days.filter(d => d !== day) - : [...prev.delivery_days, day] - })); - }; - return (
@@ -438,17 +632,19 @@ const SupplierForm: React.FC = ({ supplier, onSave, onCancel value={formData.name} onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))} placeholder="Molinos del Sur" + disabled={isCreating || isUpdating} />
setFormData(prev => ({ ...prev, contact_person: e.target.value }))} + value={formData.contact_name} + onChange={(e) => setFormData(prev => ({ ...prev, contact_name: e.target.value }))} placeholder="Juan Pérez" + disabled={isCreating || isUpdating} />
@@ -462,6 +658,7 @@ const SupplierForm: React.FC = ({ supplier, onSave, onCancel value={formData.phone} onChange={(e) => setFormData(prev => ({ ...prev, phone: e.target.value }))} placeholder="+1 555-0123" + disabled={isCreating || isUpdating} />
@@ -474,40 +671,23 @@ const SupplierForm: React.FC = ({ supplier, onSave, onCancel value={formData.email} onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))} placeholder="ventas@proveedor.com" + disabled={isCreating || isUpdating} />
setFormData(prev => ({ ...prev, address: e.target.value }))} placeholder="Av. Industrial 123, Zona Sur" + disabled={isCreating || isUpdating} />
-
- -
- {commonCategories.map(category => ( - - ))} -
-
-
setFormData(prev => ({ ...prev, min_order_amount: Number(e.target.value) }))} - placeholder="200" - min="0" + value={formData.delivery_terms} + onChange={(e) => setFormData(prev => ({ ...prev, delivery_terms: e.target.value }))} + placeholder="Recoger en tienda" + disabled={isCreating || isUpdating} />
-
- -
- {daysOfWeek.map(day => ( - - ))} -
-
-
-