diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5cc89777..f9c70db1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -39,7 +39,7 @@ "tailwind-merge": "^2.1.0", "xlsx": "^0.18.5", "zod": "^3.22.4", - "zustand": "^4.4.7" + "zustand": "^4.5.7" }, "devDependencies": { "@storybook/addon-essentials": "^7.6.0", diff --git a/frontend/package.json b/frontend/package.json index 3e2d1a07..d4b7d709 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -49,7 +49,7 @@ "tailwind-merge": "^2.1.0", "xlsx": "^0.18.5", "zod": "^3.22.4", - "zustand": "^4.4.7" + "zustand": "^4.5.7" }, "devDependencies": { "@storybook/addon-essentials": "^7.6.0", diff --git a/frontend/src/api/hooks/classification.ts b/frontend/src/api/hooks/classification.ts index 10516e0f..9791363e 100644 --- a/frontend/src/api/hooks/classification.ts +++ b/frontend/src/api/hooks/classification.ts @@ -6,10 +6,7 @@ import { classificationService } from '../services/classification'; import { ProductClassificationRequest, BatchClassificationRequest, - ProductSuggestionResponse, - BusinessModelAnalysisResponse, - ClassificationApprovalRequest, - ClassificationApprovalResponse, + ProductSuggestionResponse } from '../types/classification'; import { ApiError } from '../client'; @@ -28,48 +25,6 @@ export const classificationKeys = { }, } as const; -// Queries -export const usePendingSuggestions = ( - tenantId: string, - options?: Omit, 'queryKey' | 'queryFn'> -) => { - return useQuery({ - queryKey: classificationKeys.suggestions.pending(tenantId), - queryFn: () => classificationService.getPendingSuggestions(tenantId), - enabled: !!tenantId, - staleTime: 30 * 1000, // 30 seconds - ...options, - }); -}; - -export const useSuggestionHistory = ( - tenantId: string, - limit: number = 50, - offset: number = 0, - options?: Omit, 'queryKey' | 'queryFn'> -) => { - return useQuery<{ items: ProductSuggestionResponse[]; total: number }, ApiError>({ - queryKey: classificationKeys.suggestions.history(tenantId, limit, offset), - queryFn: () => classificationService.getSuggestionHistory(tenantId, limit, offset), - enabled: !!tenantId, - staleTime: 2 * 60 * 1000, // 2 minutes - ...options, - }); -}; - -export const useBusinessModelAnalysis = ( - tenantId: string, - options?: Omit, 'queryKey' | 'queryFn'> -) => { - return useQuery({ - queryKey: classificationKeys.analysis.businessModel(tenantId), - queryFn: () => classificationService.getBusinessModelAnalysis(tenantId), - enabled: !!tenantId, - staleTime: 10 * 60 * 1000, // 10 minutes - ...options, - }); -}; - // Mutations export const useClassifyProduct = ( options?: UseMutationOptions< @@ -117,84 +72,4 @@ export const useClassifyProductsBatch = ( }, ...options, }); -}; - -export const useApproveClassification = ( - options?: UseMutationOptions< - ClassificationApprovalResponse, - ApiError, - { tenantId: string; approvalData: ClassificationApprovalRequest } - > -) => { - const queryClient = useQueryClient(); - - return useMutation< - ClassificationApprovalResponse, - ApiError, - { tenantId: string; approvalData: ClassificationApprovalRequest } - >({ - mutationFn: ({ tenantId, approvalData }) => - classificationService.approveClassification(tenantId, approvalData), - onSuccess: (data, { tenantId }) => { - // Invalidate suggestions lists - queryClient.invalidateQueries({ queryKey: classificationKeys.suggestions.pending(tenantId) }); - queryClient.invalidateQueries({ queryKey: classificationKeys.suggestions.history(tenantId) }); - - // If approved and ingredient was created, invalidate inventory queries - if (data.approved && data.created_ingredient) { - queryClient.invalidateQueries({ queryKey: ['inventory', 'ingredients'] }); - } - }, - ...options, - }); -}; - -export const useUpdateSuggestion = ( - options?: UseMutationOptions< - ProductSuggestionResponse, - ApiError, - { tenantId: string; suggestionId: string; updateData: Partial } - > -) => { - const queryClient = useQueryClient(); - - return useMutation< - ProductSuggestionResponse, - ApiError, - { tenantId: string; suggestionId: string; updateData: Partial } - >({ - mutationFn: ({ tenantId, suggestionId, updateData }) => - classificationService.updateSuggestion(tenantId, suggestionId, updateData), - onSuccess: (data, { tenantId }) => { - // Invalidate suggestions lists - queryClient.invalidateQueries({ queryKey: classificationKeys.suggestions.pending(tenantId) }); - queryClient.invalidateQueries({ queryKey: classificationKeys.suggestions.history(tenantId) }); - }, - ...options, - }); -}; - -export const useDeleteSuggestion = ( - options?: UseMutationOptions< - { message: string }, - ApiError, - { tenantId: string; suggestionId: string } - > -) => { - const queryClient = useQueryClient(); - - return useMutation< - { message: string }, - ApiError, - { tenantId: string; suggestionId: string } - >({ - mutationFn: ({ tenantId, suggestionId }) => - classificationService.deleteSuggestion(tenantId, suggestionId), - onSuccess: (data, { tenantId }) => { - // Invalidate suggestions lists - queryClient.invalidateQueries({ queryKey: classificationKeys.suggestions.pending(tenantId) }); - queryClient.invalidateQueries({ queryKey: classificationKeys.suggestions.history(tenantId) }); - }, - ...options, - }); }; \ No newline at end of file diff --git a/frontend/src/api/services/onboarding.ts b/frontend/src/api/services/onboarding.ts index ae57bd93..9f5b2c10 100644 --- a/frontend/src/api/services/onboarding.ts +++ b/frontend/src/api/services/onboarding.ts @@ -1,9 +1,19 @@ /** * Onboarding Service - Mirror backend onboarding endpoints + * Frontend and backend step names now match directly! */ import { apiClient } from '../client'; import { UserProgress, UpdateStepRequest } from '../types/onboarding'; +// Frontend step order for navigation (matches backend ONBOARDING_STEPS) +export const FRONTEND_STEP_ORDER = [ + 'setup', // Step 1: Basic bakery setup and tenant creation + 'smart-inventory-setup', // Step 2: Sales data upload and inventory configuration + 'suppliers', // Step 3: Suppliers configuration (optional) + 'ml-training', // Step 4: AI model training + 'completion' // Step 5: Onboarding completed +]; + export class OnboardingService { private readonly baseUrl = '/users/me/onboarding'; @@ -71,6 +81,65 @@ export class OnboardingService { // This endpoint exists in backend return apiClient.post(`${this.baseUrl}/complete`); } + + /** + * Helper method to mark a step as completed (now direct mapping) + */ + async markStepAsCompleted( + stepId: string, + data?: Record + ): Promise { + try { + return await this.markStepCompleted('', stepId, data); + } catch (error) { + console.error(`Error marking step ${stepId} as completed:`, error); + throw error; + } + } + + /** + * Helper method to get the next step based on backend progress + */ + async getNextStepId(): Promise { + try { + const result = await this.getNextStep(); + return result.step || 'setup'; + } catch (error) { + console.error('Error getting next step:', error); + return 'setup'; + } + } + + /** + * Helper method to determine which step the user should resume from + */ + async getResumeStep(): Promise<{ stepId: string; stepIndex: number }> { + try { + const progress = await this.getUserProgress(''); + + // If fully completed, go to completion + if (progress.fully_completed) { + return { stepId: 'completion', stepIndex: FRONTEND_STEP_ORDER.indexOf('completion') }; + } + + // Get the current step from backend + const currentStep = progress.current_step; + + // If current step is user_registered, start from setup + const resumeStep = currentStep === 'user_registered' ? 'setup' : currentStep; + + // Find the step index in our frontend order + let stepIndex = FRONTEND_STEP_ORDER.indexOf(resumeStep); + if (stepIndex === -1) { + stepIndex = 0; // Default to first step + } + + return { stepId: FRONTEND_STEP_ORDER[stepIndex], stepIndex }; + } catch (error) { + console.error('Error determining resume step:', error); + return { stepId: 'setup', stepIndex: 0 }; + } + } } export const onboardingService = new OnboardingService(); \ No newline at end of file diff --git a/frontend/src/api/types/classification.ts b/frontend/src/api/types/classification.ts index 5ff6d09f..9c5a00c6 100644 --- a/frontend/src/api/types/classification.ts +++ b/frontend/src/api/types/classification.ts @@ -26,4 +26,10 @@ export interface ProductSuggestionResponse { is_seasonal: boolean; suggested_supplier?: string; notes?: string; + sales_data?: { + total_quantity: number; + average_daily_sales: number; + peak_day: string; + frequency: number; + }; } \ No newline at end of file diff --git a/frontend/src/components/domain/onboarding/index.ts b/frontend/src/components/domain/onboarding/index.ts index a0a01551..60cc2829 100644 --- a/frontend/src/components/domain/onboarding/index.ts +++ b/frontend/src/components/domain/onboarding/index.ts @@ -3,8 +3,7 @@ export { default as OnboardingWizard } from './OnboardingWizard'; // Individual step components export { BakerySetupStep } from './steps/BakerySetupStep'; -export { HistoricalSalesValidationStep } from './steps/HistoricalSalesValidationStep'; -export { InventorySetupStep } from './steps/InventorySetupStep'; +export { SmartInventorySetupStep } from './steps/SmartInventorySetupStep'; export { SuppliersStep } from './steps/SuppliersStep'; export { MLTrainingStep } from './steps/MLTrainingStep'; export { CompletionStep } from './steps/CompletionStep'; diff --git a/frontend/src/components/domain/onboarding/steps/HistoricalSalesValidationStep.tsx b/frontend/src/components/domain/onboarding/steps/HistoricalSalesValidationStep.tsx deleted file mode 100644 index 18122302..00000000 --- a/frontend/src/components/domain/onboarding/steps/HistoricalSalesValidationStep.tsx +++ /dev/null @@ -1,854 +0,0 @@ -import React, { useState, useRef, useEffect, useMemo } from 'react'; -import { Upload, Brain, CheckCircle, AlertCircle, Download, FileText, Activity } from 'lucide-react'; -import { Button, Card, Badge } from '../../../ui'; -import { OnboardingStepProps } from '../OnboardingWizard'; -import { useModal } from '../../../../hooks/ui/useModal'; -import { useToast } from '../../../../hooks/ui/useToast'; -import { useOnboarding } from '../../../../hooks/business/onboarding'; -import { useAuthUser, useAuthLoading } from '../../../../stores/auth.store'; -import { useCurrentTenant, useTenantLoading } from '../../../../stores'; -import type { ProductSuggestionResponse } from '../../../../api'; - -type ProcessingStage = 'upload' | 'validating' | 'analyzing' | 'review' | 'completed' | 'error'; - -interface ProcessingResult { - // Validation data - is_valid: boolean; - total_records: number; - unique_products: number; - product_list: string[]; - validation_errors: string[]; - validation_warnings: string[]; - summary: { - date_range: string; - total_sales: number; - average_daily_sales: number; - }; - // Analysis data - productsIdentified: number; - categoriesDetected: number; - businessModel: string; - confidenceScore: number; - recommendations: string[]; -} - -interface Product { - id: string; - name: string; - category: string; - confidence: 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; - }; -} - -// Convert API suggestions to Product interface -const convertSuggestionsToProducts = (suggestions: ProductSuggestionResponse[]): Product[] => { - 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 - })); - - return products; -}; - -export const HistoricalSalesValidationStep: React.FC = ({ - data, - onDataChange, - _onNext, - _onPrevious, - _isFirstStep, - _isLastStep -}) => { - const user = useAuthUser(); - const authLoading = useAuthLoading(); - const currentTenant = useCurrentTenant(); - const tenantLoading = useTenantLoading(); - - // Use the new onboarding hooks - const { - processSalesFile, - salesProcessing: { - stage: onboardingStage, - progress: onboardingProgress, - currentMessage: onboardingMessage, - validationResults, - suggestions - }, - tenantCreation, - error, - clearError - } = useOnboarding(); - - const toast = useToast(); - - // Get tenant ID from multiple sources with fallback - const getTenantId = (): string | null => { - const onboardingTenantId = data.bakery?.tenant_id; - const tenantId = currentTenant?.id || user?.tenant_id || onboardingTenantId || null; - return tenantId; - }; - - // Check if tenant data is available - const isTenantAvailable = (): boolean => { - const hasAuth = !authLoading && user; - const hasTenantId = getTenantId() !== null; - const tenantCreatedSuccessfully = tenantCreation.isSuccess; - const tenantCreatedInOnboarding = data.bakery?.tenantCreated === true; - - const isAvailable = hasAuth && (hasTenantId || tenantCreatedSuccessfully || tenantCreatedInOnboarding); - return isAvailable; - }; - - // State management - const [localStage, setLocalStage] = useState(data.processingStage || 'upload'); - const [uploadedFile, setUploadedFile] = useState(data.files?.salesData || null); - const [localResults, setLocalResults] = useState(data.processingResults || null); - const [products, setProducts] = useState(() => { - if (data.detectedProducts) { - return data.detectedProducts; - } - // Generate from existing suggestions if available - if (suggestions && suggestions.length > 0) { - return convertSuggestionsToProducts(suggestions); - } - return []; - }); - const [selectedCategory, setSelectedCategory] = useState('all'); - - // Derive current state from onboarding hooks or local state - const stage = (localStage === 'completed' || localStage === 'error') - ? localStage - : (onboardingStage || localStage); - const progress = onboardingProgress || 0; - const currentMessage = onboardingMessage || ''; - const results = useMemo(() => (validationResults && suggestions) ? { - ...validationResults, - aiSuggestions: suggestions, - productsIdentified: validationResults.unique_products || validationResults.product_list?.length || 0, - categoriesDetected: suggestions ? new Set(suggestions.map((s: ProductSuggestionResponse) => s.category)).size : 0, - businessModel: 'production', - confidenceScore: 85, - recommendations: validationResults.summary?.suggestions || [], - totalRecords: validationResults.total_records || 0, - validRecords: validationResults.valid_records || 0, - invalidRecords: validationResults.invalid_records || 0, - fileFormat: validationResults.summary?.file_format || 'csv', - fileSizeMb: validationResults.summary?.file_size_mb || 0, - estimatedProcessingTime: validationResults.summary?.estimated_processing_time_seconds || 0, - detectedColumns: validationResults.summary?.detected_columns || [], - validationMessage: validationResults.message || 'Validación completada' - } : localResults, [validationResults, suggestions, localResults]); - - // Update products when suggestions change - useEffect(() => { - if (suggestions && suggestions.length > 0 && products.length === 0) { - const newProducts = convertSuggestionsToProducts(suggestions); - setProducts(newProducts); - } - }, [suggestions, products.length]); - - // Auto-progress to review stage when validation completes and suggestions are available - useEffect(() => { - if (stage === 'analyzing' && results && suggestions && suggestions.length > 0) { - setLocalStage('review'); - } - }, [stage, results, suggestions]); - - const [dragActive, setDragActive] = useState(false); - const fileInputRef = useRef(null); - const lastStateRef = useRef({ stage, progress, currentMessage, results, suggestions, uploadedFile, products }); - - // Memoized computed values - const approvedProducts = useMemo(() => - products.filter(p => p.status === 'approved'), - [products] - ); - - const reviewCompleted = useMemo(() => - products.length > 0 && products.every(p => p.status !== 'pending'), - [products] - ); - - const categories = ['all', ...Array.from(new Set(products.map(p => p.category)))]; - - const getFilteredProducts = () => { - if (selectedCategory === 'all') { - return products; - } - return products.filter(p => p.category === selectedCategory); - }; - - const stats = { - total: products.length, - approved: products.filter(p => p.status === 'approved').length, - rejected: products.filter(p => p.status === 'rejected').length, - pending: products.filter(p => p.status === 'pending').length - }; - - // Update parent data when state changes - useEffect(() => { - const currentState = { stage, progress, currentMessage, results, suggestions, uploadedFile, products }; - if (JSON.stringify(currentState) !== JSON.stringify(lastStateRef.current)) { - lastStateRef.current = currentState; - - onDataChange({ - processingStage: stage === 'review' ? 'completed' : stage, - processingProgress: progress, - currentMessage: currentMessage, - processingResults: results, - suggestions: suggestions, - files: { - ...data.files, - salesData: uploadedFile - }, - detectedProducts: products, - approvedProducts, - reviewCompleted - }); - } - }, [stage, progress, currentMessage, results, suggestions, uploadedFile, products, approvedProducts, reviewCompleted, onDataChange, data.files]); - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - setDragActive(true); - }; - - const handleDragLeave = (e: React.DragEvent) => { - e.preventDefault(); - setDragActive(false); - }; - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - setDragActive(false); - - const files = Array.from(e.dataTransfer.files); - if (files.length > 0) { - handleFileUpload(files[0]); - } - }; - - const handleFileSelect = (e: React.ChangeEvent) => { - if (e.target.files && e.target.files.length > 0) { - handleFileUpload(e.target.files[0]); - } - }; - - const handleFileUpload = async (file: File) => { - // Validate file type - const validExtensions = ['.csv', '.xlsx', '.xls', '.json']; - const fileExtension = file.name.toLowerCase().substring(file.name.lastIndexOf('.')); - - if (!validExtensions.includes(fileExtension)) { - toast.addToast('Formato de archivo no válido. Usa CSV, JSON o Excel (.xlsx, .xls)', { - title: 'Formato inválido', - type: 'error' - }); - return; - } - - // Check file size (max 10MB) - if (file.size > 10 * 1024 * 1024) { - toast.addToast('El archivo es demasiado grande. Máximo 10MB permitido.', { - title: 'Archivo muy grande', - type: 'error' - }); - return; - } - - setUploadedFile(file); - setLocalStage('validating'); - - try { - // Wait for user data to load if still loading - if (!isTenantAvailable()) { - setUploadedFile(null); - setLocalStage('upload'); - toast.addToast('Por favor espere mientras cargamos su información...', { - title: 'Esperando datos de usuario', - type: 'info' - }); - return; - } - - // Use the onboarding hook for file processing - const success = await processSalesFile(file, (progress, stage, message) => { - console.log(`Processing: ${progress}% - ${stage} - ${message}`); - }); - - if (success) { - setLocalStage('review'); - - // Update products from suggestions - if (validationResults && suggestions) { - const newProducts = convertSuggestionsToProducts(suggestions); - setProducts(newProducts); - - const processedResults = { - ...validationResults, - aiSuggestions: suggestions, - productsIdentified: validationResults.product_list?.length || 0, - categoriesDetected: suggestions ? new Set(suggestions.map((s: any) => s.category)).size : 0, - businessModel: 'production', - confidenceScore: 85, - recommendations: [] - }; - setLocalResults(processedResults); - } - - toast.addToast('El archivo se procesó correctamente. Revisa los productos detectados.', { - title: 'Procesamiento completado', - type: 'success' - }); - } else { - throw new Error('Error procesando el archivo'); - } - - } catch (error) { - console.error('HistoricalSalesValidationStep - Processing error:', error); - setLocalStage('error'); - const errorMessage = error instanceof Error ? error.message : 'Error en el procesamiento de datos'; - - toast.addToast(errorMessage, { - title: 'Error en el procesamiento', - type: 'error' - }); - } - }; - - const downloadTemplate = () => { - 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); - - toast.addToast('La plantilla se descargó correctamente.', { - title: 'Plantilla descargada', - type: 'success' - }); - }; - - const resetProcess = () => { - setLocalStage('upload'); - setUploadedFile(null); - setLocalResults(null); - setProducts([]); - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } - if (error) { - clearError(); - } - }; - - const handleProductAction = (productId: string, action: 'approve' | 'reject') => { - setProducts(prev => prev.map(product => - product.id === productId - ? { ...product, status: action === 'approve' ? 'approved' : 'rejected' } - : product - )); - }; - - const handleBulkAction = (action: 'approve' | 'reject') => { - const filteredProducts = getFilteredProducts(); - setProducts(prev => prev.map(product => - filteredProducts.some(fp => fp.id === product.id) - ? { ...product, status: action === 'approve' ? 'approved' : 'rejected' } - : product - )); - }; - - const getConfidenceColor = (confidence: number) => { - if (confidence >= 90) return 'text-[var(--color-success)] bg-[var(--color-success)]/10 border-[var(--color-success)]/20'; - if (confidence >= 75) return 'text-[var(--color-warning)] bg-[var(--color-warning)]/10 border-[var(--color-warning)]/20'; - return 'text-[var(--color-error)] bg-[var(--color-error)]/10 border-[var(--color-error)]/20'; - }; - - const getStatusColor = (status: string) => { - switch (status) { - case 'approved': return 'text-[var(--color-success)] bg-[var(--color-success)]/10 border-[var(--color-success)]/20'; - case 'rejected': return 'text-[var(--color-error)] bg-[var(--color-error)]/10 border-[var(--color-error)]/20'; - default: return 'text-[var(--text-secondary)] bg-[var(--bg-secondary)] border-[var(--border-secondary)]'; - } - }; - - return ( -
- {/* Loading state when tenant data is not available */} - {!isTenantAvailable() && ( - -
- -
-

- Cargando datos de usuario... -

-

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

-
- )} - - {/* Upload Stage */} - {(stage === 'idle' || localStage === 'upload') && isTenantAvailable() && ( - <> -
fileInputRef.current?.click()} - > - - -
- {uploadedFile ? ( - <> -
- -
-
-

- ¡Perfecto! Archivo listo -

-
-

- 📄 {uploadedFile.name} -

-

- {(uploadedFile.size / 1024 / 1024).toFixed(2)} MB • Listo para procesar -

-
-
- - ) : ( - <> -
- -
-
-

- Sube tu historial de ventas -

-

- Arrastra y suelta tu archivo aquí, o haz clic para seleccionar -

-
- -
-
-
- 📊 -
- CSV -
-
-
- 📈 -
- Excel -
-
-
- -
- Hasta 10MB -
-
- - )} -
- -
- 💡 Formatos aceptados: CSV, Excel (XLSX, XLS) • Tamaño máximo: 10MB -
-
- - {/* Template Download Section */} -
-
-
- -
-
-

- ¿Necesitas ayuda con el formato? -

-

- Descarga nuestra plantilla Excel con ejemplos y formato correcto para tus datos de ventas -

- -
-
-
- - )} - - {/* Processing Stages */} - {(stage === 'validating' || stage === 'analyzing') && ( - -
-
-
- {stage === 'validating' ? ( - - ) : ( - - )} -
- -

- {stage === 'validating' ? 'Validando datos...' : 'Analizando con IA...'} -

-

- {currentMessage} -

-
- - {/* Progress Bar */} -
-
- - Progreso - - {progress}% -
-
-
-
-
- - {/* Processing Steps */} -
-
= 40 ? 'bg-[var(--color-success)]/10 text-[var(--color-success)]' : 'bg-[var(--bg-secondary)]' - }`}> - - Validación -
-
= 70 ? 'bg-[var(--color-success)]/10 text-[var(--color-success)]' : 'bg-[var(--bg-secondary)]' - }`}> - - Análisis IA -
-
= 100 ? 'bg-[var(--color-success)]/10 text-[var(--color-success)]' : 'bg-[var(--bg-secondary)]' - }`}> - - Completo -
-
-
- - )} - - {/* Review Stage - Combined validation results and product approval */} - {(stage === 'review' || (stage === 'completed' && products.length > 0)) && results && ( -
- {/* Success Header with Validation Results */} -
-
- -
-

- ¡Validación Completada! -

-

- {results.validationMessage || 'Tus datos han sido procesados exitosamente. Revisa y aprueba los productos detectados.'} -

-
- - {/* Enhanced Stats Cards */} -
-
-

{results.totalRecords || results.total_records}

-

Total Registros

-
-
-

{results.validRecords || results.valid_records}

-

Válidos

-
-
-

{results.invalidRecords || results.invalid_records || 0}

-

Inválidos

-
-
-

{results.productsIdentified}

-

Productos

-
-
- - {/* Product Review Section */} - {products.length > 0 && ( - <> - {/* Summary Stats */} -
- -
{stats.total}
-
Productos detectados
-
- - -
{stats.approved}
-
Aprobados
-
- - -
{stats.rejected}
-
Rechazados
-
- - -
{stats.pending}
-
Pendientes
-
-
- - {/* Controls */} -
-
- - - - {getFilteredProducts().length} productos - -
- -
- - -
-
- - {/* Products List */} -
- {getFilteredProducts().map((product) => ( - -
-
-
-

{product.name}

- - {product.status === 'approved' ? '✓ Aprobado' : - product.status === 'rejected' ? '✗ Rechazado' : '⏳ Pendiente'} - -
- -
- - {product.category} - - - {product.confidence}% confianza - -
- -
- {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} -
- )} -
- {product.notes && ( -
- Nota: {product.notes} -
- )} -
-
- -
- - -
-
-
- ))} -
- - )} - - {/* Information */} - -

- 📋 Revisión de Datos de Ventas: -

-
    -
  • Datos validados - Tu archivo ha sido procesado y validado correctamente
  • -
  • Productos detectados - La IA ha identificado automáticamente productos desde tus ventas
  • -
  • Revisa cuidadosamente - Aprueba o rechaza cada producto según sea correcto para tu negocio
  • -
  • Verifica nombres - Compara el nombre original vs. el nombre sugerido
  • -
  • Revisa clasificaciones - Confirma si son ingredientes o productos terminados
  • -
  • Usa filtros - Filtra por categoría para revisar productos similares
  • -
  • Acciones masivas - Use "Aprobar todos" o "Rechazar todos" para agilizar el proceso
  • -
-
-
- )} - - {/* Error State */} - {stage === 'error' && ( - -
- -
-

- Error en el procesamiento -

-

- {currentMessage} -

- -
- )} -
- ); -}; \ No newline at end of file diff --git a/frontend/src/components/domain/onboarding/steps/InventorySetupStep.tsx b/frontend/src/components/domain/onboarding/steps/InventorySetupStep.tsx deleted file mode 100644 index 9288d859..00000000 --- a/frontend/src/components/domain/onboarding/steps/InventorySetupStep.tsx +++ /dev/null @@ -1,769 +0,0 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { Package, Calendar, AlertTriangle, Plus, Edit, Trash2, CheckCircle } from 'lucide-react'; -import { Button, Card, Input, Badge } from '../../../ui'; -import { OnboardingStepProps } from '../OnboardingWizard'; -import { useOnboarding } from '../../../../hooks/business/onboarding'; -import { useModal } from '../../../../hooks/ui/useModal'; -import { useToast } from '../../../../hooks/ui/useToast'; -import { useAuthUser } from '../../../../stores/auth.store'; -import { useCurrentTenant } from '../../../../stores'; - -interface InventoryItem { - id: string; - name: string; - category: 'ingredient' | 'finished_product'; - current_stock: number; - min_stock: number; - max_stock: number; - unit: string; - expiry_date?: string; - supplier?: string; - cost_per_unit?: number; - requires_refrigeration: boolean; - // API fields - suggestion_id?: string; - original_name?: string; - estimated_shelf_life_days?: number; - is_seasonal?: boolean; -} - -// Convert approved products to inventory items -const convertProductsToInventory = (approvedProducts: any[]): InventoryItem[] => { - return approvedProducts.map((product, index) => ({ - id: `inventory-${index}`, - name: product.suggested_name || product.original_name, - category: product.product_type === 'ingredient' ? 'ingredient' : '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 || 'units', - 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, - onDataChange, - onNext, - onPrevious, - isFirstStep, - isLastStep -}) => { - const user = useAuthUser(); - const currentTenant = useCurrentTenant(); - const { showToast } = useToast(); - - // Use the business onboarding hooks - const { - updateStepData, - createInventoryFromSuggestions, - importSalesData, - inventorySetup: { - createdItems, - inventoryMapping, - salesImportResult, - isInventoryConfigured - }, - allStepData, - isLoading, - error, - clearError - } = useOnboarding(); - - const createAlert = (alert: any) => { - console.log('Alert:', alert); - if (showToast && typeof showToast === 'function') { - showToast({ - title: alert.title, - message: alert.message, - type: alert.type - }); - } else { - // Fallback to console if showToast is not available - console.warn(`Toast would show: ${alert.title} - ${alert.message}`); - } - }; - - // Use modal for confirmations and editing - const editModal = useModal(); - const confirmModal = useModal(); - - const [isCreating, setIsCreating] = useState(false); - const [editingItem, setEditingItem] = useState(null); - const [isAddingNew, setIsAddingNew] = useState(false); - const [inventoryCreationAttempted, setInventoryCreationAttempted] = 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 business hooks data first, then from component props - const approvedProducts = data.approvedProducts || - allStepData?.['sales-validation']?.approvedProducts || - data.allStepData?.['sales-validation']?.approvedProducts; - return generateInventoryFromProducts(approvedProducts || []); - }); - - // Update items when approved products become available (for when component is already mounted) - useEffect(() => { - const approvedProducts = data.approvedProducts || - allStepData?.['sales-validation']?.approvedProducts || - data.allStepData?.['sales-validation']?.approvedProducts; - - if (approvedProducts && approvedProducts.length > 0 && items.length === 0) { - const newItems = generateInventoryFromProducts(approvedProducts); - setItems(newItems); - } - }, [data.approvedProducts, allStepData, 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 using business hooks - const handleCreateInventory = async () => { - console.log('InventorySetup - Starting handleCreateInventory'); - - const approvedProducts = data.approvedProducts || - allStepData?.['sales-validation']?.approvedProducts || - data.allStepData?.['sales-validation']?.approvedProducts; - console.log('InventorySetup - approvedProducts:', { - fromDataProp: data.approvedProducts, - fromAllStepData: allStepData?.['sales-validation']?.approvedProducts, - fromDataAllStepData: data.allStepData?.['sales-validation']?.approvedProducts, - finalProducts: approvedProducts, - allStepDataKeys: Object.keys(allStepData || {}), - dataKeys: Object.keys(data || {}) - }); - - // Get tenant ID from current tenant context or user - const tenantId = currentTenant?.id || user?.tenant_id; - console.log('InventorySetup - tenantId:', tenantId); - - if (!tenantId || !approvedProducts || approvedProducts.length === 0) { - console.log('InventorySetup - Missing requirements: tenantId =', tenantId, 'approvedProducts length =', approvedProducts?.length); - - createAlert({ - type: 'error', - category: 'system', - priority: 'high', - title: 'Error', - message: !tenantId ? 'No se pudo obtener información del tenant' : 'No se pueden crear elementos de inventario sin productos aprobados.', - source: 'onboarding' - }); - return; - } - - setIsCreating(true); - try { - // Approved products should already be in ProductSuggestionResponse format - // Just ensure they have all required fields - const suggestions = approvedProducts.map((product: any, index: number) => ({ - suggestion_id: product.suggestion_id || `suggestion-${Date.now()}-${index}`, - original_name: product.original_name, - suggested_name: product.suggested_name, - product_type: product.product_type, - category: product.category, - unit_of_measure: product.unit_of_measure, - confidence_score: product.confidence_score || 0.8, - estimated_shelf_life_days: product.estimated_shelf_life_days, - requires_refrigeration: product.requires_refrigeration, - requires_freezing: product.requires_freezing, - is_seasonal: product.is_seasonal, - suggested_supplier: product.suggested_supplier, - notes: product.notes - })); - - // Use business onboarding hook to create inventory - const inventorySuccess = await createInventoryFromSuggestions(suggestions); - - if (inventorySuccess) { - createAlert({ - type: 'success', - category: 'system', - priority: 'medium', - title: 'Inventario creado', - message: `Se crearon ${suggestions.length} elementos de inventario exitosamente.`, - source: 'onboarding' - }); - - // Now try to import sales data if available - const salesDataFile = data.allStepData?.['sales-validation']?.salesDataFile || - allStepData?.['sales-validation']?.salesDataFile; - const processingResults = data.allStepData?.['sales-validation']?.processingResults || - allStepData?.['sales-validation']?.processingResults; - - if (salesDataFile && processingResults?.is_valid && inventoryMapping) { - try { - createAlert({ - type: 'info', - category: 'system', - priority: 'medium', - title: 'Subiendo datos de ventas', - message: 'Subiendo historial de ventas al sistema para entrenamiento de IA...', - source: 'onboarding' - }); - - const salesSuccess = await importSalesData(processingResults, inventoryMapping); - - if (salesSuccess) { - createAlert({ - type: 'success', - category: 'system', - priority: 'medium', - title: 'Datos de ventas subidos', - message: `Se subieron ${processingResults.total_records} registros de ventas al sistema exitosamente.`, - source: 'onboarding' - }); - } - } catch (salesError) { - console.error('Error uploading sales data:', salesError); - createAlert({ - type: 'error', - category: 'system', - priority: 'high', - title: 'Error al subir datos de ventas', - message: 'El inventario se creó correctamente, pero hubo un problema al subir los datos de ventas.', - source: 'onboarding' - }); - } - } - - // Update component data and business hook data - const updatedData = { - ...data, - inventoryItems: items, - inventoryConfigured: true, - inventoryCreated: true, - inventoryMapping, - createdInventoryItems: createdItems, - salesImportResult - }; - onDataChange(updatedData); - - // Also update step data in business hooks - updateStepData('inventory', { - inventoryItems: items, - inventoryConfigured: true, - inventoryCreated: true, - inventoryMapping, - createdInventoryItems: createdItems, - salesImportResult - }); - - } else { - createAlert({ - type: 'error', - category: 'system', - priority: 'high', - title: 'Error al crear inventario', - message: 'No se pudieron crear los elementos de inventario.', - source: 'onboarding' - }); - } - - } 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); - } - }; - - const lastItemsRef = useRef(items); - const lastIsCreatingRef = useRef(isCreating); - - useEffect(() => { - // Only update if items or isCreating actually changed - if (JSON.stringify(items) !== JSON.stringify(lastItemsRef.current) || isCreating !== lastIsCreatingRef.current) { - lastItemsRef.current = items; - lastIsCreatingRef.current = isCreating; - - const hasValidStock = items.length > 0 && items.every(item => - item.min_stock >= 0 && item.max_stock > item.min_stock - ); - - const stepData = { - inventoryItems: items, - inventoryConfigured: hasValidStock && !isCreating - }; - - // Update component props - onDataChange({ - ...data, - ...stepData - }); - - // Update business hooks data - updateStepData('inventory', stepData); - } - }, [items, isCreating]); // Only depend on items and isCreating - - // Auto-create inventory when step is completed (when user clicks Next) - useEffect(() => { - const hasValidStock = items.length > 0 && items.every(item => - item.min_stock >= 0 && item.max_stock > item.min_stock - ); - - // If inventory is configured but not yet created in backend, create it automatically - if (hasValidStock && !data.inventoryCreated && !isCreating && !inventoryCreationAttempted) { - console.log('InventorySetup - Auto-creating inventory on step completion'); - setInventoryCreationAttempted(true); - handleCreateInventory(); - } - }, [data.inventoryCreated, items, isCreating, inventoryCreationAttempted]); - - const handleAddItem = () => { - const newItem: InventoryItem = { - id: Date.now().toString(), - name: '', - category: 'ingredient', - current_stock: 0, - min_stock: 1, - max_stock: 10, - unit: 'unidad', - requires_refrigeration: false - }; - setItems([...items, newItem]); - setEditingItem(newItem); - setIsAddingNew(true); - }; - - const handleSaveItem = (updatedItem: InventoryItem) => { - setItems(items.map(item => - item.id === updatedItem.id ? updatedItem : item - )); - setEditingItem(null); - setIsAddingNew(false); - }; - - const handleDeleteItem = (id: string) => { - setItems(items.filter(item => item.id !== id)); - if (editingItem?.id === id) { - setEditingItem(null); - setIsAddingNew(false); - } - }; - - 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 'critical'; - if (item.current_stock <= item.min_stock * 1.5) return 'warning'; - return 'good'; - }; - - const getStockStatusColor = (status: string) => { - switch (status) { - case 'critical': return 'text-[var(--color-error)] bg-[var(--color-error)]/10 border-[var(--color-error)]/20'; - case 'warning': return 'text-[var(--color-warning)] bg-[var(--color-warning)]/10 border-[var(--color-warning)]/20'; - default: return 'text-[var(--color-success)] bg-[var(--color-success)]/10 border-[var(--color-success)]/20'; - } - }; - - if (items.length === 0) { - return ( -
-
- -

- Sin productos para inventario -

-

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

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

- Configuración de Inventario -

-

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

-
- - {/* Stats */} -
- -
{items.length}
-
Elementos totales
-
- - -
- {items.filter(item => item.category === 'ingredient').length} -
-
Ingredientes
-
- - -
- {items.filter(item => item.category === 'finished_product').length} -
-
Productos terminados
-
- - -
- {items.filter(item => getStockStatus(item) === 'critical').length} -
-
Stock crítico
-
-
- - {/* Controls */} -
-
- - - - {filteredItems.length} elementos - -
- -
- - -
-
- - {/* Items List */} -
- {filteredItems.map((item) => ( - - {editingItem?.id === item.id ? ( - - ) : ( -
-
-
-

{item.name}

- - Stock: {getStockStatus(item) === 'critical' ? '🔴 Crítico' : getStockStatus(item) === 'warning' ? '🟡 Bajo' : '🟢 Bueno'} - -
- -
- - {item.category === 'ingredient' ? 'Ingrediente' : 'Producto terminado'} - - {item.requires_refrigeration && ( - - ❄️ Refrigeración - - )} -
- -
-
-
- Stock actual: - {item.current_stock} {item.unit} -
-
- Mínimo: - {item.min_stock} -
-
- Máximo: - {item.max_stock} -
-
- {(item.expiry_date || item.supplier) && ( -
- {item.expiry_date && ( -
- Vence: - {item.expiry_date} -
- )} - {item.supplier && ( -
- Proveedor: - {item.supplier} -
- )} -
- )} -
-
- -
- - -
-
- )} -
- ))} -
- - {/* Information */} - -

- 📦 Configuración de Inventario: -

-
    -
  • Configure stock inicial - Establezca los niveles de stock actuales para cada producto
  • -
  • Defina límites - Establezca stock mínimo y máximo para recibir alertas automáticas
  • -
  • Agregue detalles - Incluya fechas de vencimiento, proveedores y unidades de medida
  • -
  • Marque refrigeración - Indique qué productos requieren condiciones especiales de almacenamiento
  • -
  • Edite elementos - Haga clic en "Editar" para modificar cualquier producto
  • -
  • Creación automática - El inventario se creará automáticamente al hacer clic en "Siguiente"
  • -
-
- -
- ); -}; - -// Inventory Item Editor Component -const InventoryItemEditor: React.FC<{ - item: InventoryItem; - onSave: (item: InventoryItem) => void; - onCancel: () => void; - showToast: (toast: any) => void; -}> = ({ item, onSave, onCancel, showToast }) => { - const [editedItem, setEditedItem] = useState(item); - - const handleSave = () => { - if (!editedItem.name.trim()) { - showToast({ - title: 'Error de validación', - message: 'El nombre es requerido', - type: 'error' - }); - return; - } - if (editedItem.min_stock < 0 || editedItem.max_stock <= editedItem.min_stock) { - showToast({ - title: 'Error de validación', - message: 'Los niveles de stock deben ser válidos (máximo > mínimo >= 0)', - type: 'error' - }); - return; - } - onSave(editedItem); - }; - - return ( -
-
-
- - setEditedItem({ ...editedItem, name: e.target.value })} - placeholder="Nombre del producto" - className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]" - /> -
- -
- - -
- -
- - setEditedItem({ ...editedItem, current_stock: Number(e.target.value) })} - min="0" - className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]" - /> -
- -
- - setEditedItem({ ...editedItem, unit: e.target.value })} - placeholder="kg, litros, unidades..." - className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]" - /> -
- -
- - setEditedItem({ ...editedItem, min_stock: Number(e.target.value) })} - min="0" - className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]" - /> -
- -
- - setEditedItem({ ...editedItem, max_stock: Number(e.target.value) })} - min="1" - className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]" - /> -
- -
- - setEditedItem({ ...editedItem, expiry_date: e.target.value })} - className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]" - /> -
- -
- - setEditedItem({ ...editedItem, supplier: e.target.value })} - placeholder="Nombre del proveedor" - className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]" - /> -
-
- -
- -
- -
- - -
-
- ); -}; \ No newline at end of file diff --git a/frontend/src/components/domain/onboarding/steps/SmartInventorySetupStep.tsx b/frontend/src/components/domain/onboarding/steps/SmartInventorySetupStep.tsx new file mode 100644 index 00000000..ec77438d --- /dev/null +++ b/frontend/src/components/domain/onboarding/steps/SmartInventorySetupStep.tsx @@ -0,0 +1,922 @@ +import React, { useState, useRef, useEffect, useMemo } from 'react'; +import { Upload, Brain, CheckCircle, AlertCircle, Download, FileText, Activity, Package, Edit } from 'lucide-react'; +import { Button, Card, Badge, Input } from '../../../ui'; +import { OnboardingStepProps } from '../OnboardingWizard'; +import { useToast } from '../../../../hooks/ui/useToast'; +import { useOnboarding } from '../../../../hooks/business/onboarding'; +import { useAuthUser, useAuthLoading } from '../../../../stores/auth.store'; +import { useCurrentTenant } from '../../../../stores'; +import type { ProductSuggestionResponse } from '../../../../api'; + +type ProcessingStage = 'upload' | 'validating' | 'analyzing' | 'review' | 'inventory-config' | 'creating' | 'completed' | 'error'; + +interface EnhancedProductCard { + // From AI suggestion + suggestion_id: string; + original_name: string; + suggested_name: string; + product_type: 'ingredient' | 'finished_product'; + category: string; + unit_of_measure: string; + confidence_score: number; + estimated_shelf_life_days?: number; + requires_refrigeration: boolean; + requires_freezing: boolean; + is_seasonal: boolean; + suggested_supplier?: string; + notes?: string; + sales_data?: { + total_quantity: number; + average_daily_sales: number; + peak_day: string; + frequency: number; + }; + + // Inventory configuration (user editable) + current_stock: number; + min_stock: number; + max_stock: number; + cost_per_unit?: number; + custom_expiry_date?: string; + + // UI state + status: 'pending' | 'approved' | 'rejected'; +} + +// Convert API suggestions to enhanced product cards +const convertSuggestionsToCards = (suggestions: ProductSuggestionResponse[]): EnhancedProductCard[] => { + return suggestions.map(suggestion => ({ + // From API + suggestion_id: suggestion.suggestion_id, + original_name: suggestion.original_name, + suggested_name: suggestion.suggested_name, + product_type: suggestion.product_type as 'ingredient' | 'finished_product', + category: suggestion.category, + unit_of_measure: suggestion.unit_of_measure, + confidence_score: suggestion.confidence_score, + estimated_shelf_life_days: suggestion.estimated_shelf_life_days, + requires_refrigeration: suggestion.requires_refrigeration, + requires_freezing: suggestion.requires_freezing, + is_seasonal: suggestion.is_seasonal, + suggested_supplier: suggestion.suggested_supplier, + notes: suggestion.notes, + sales_data: suggestion.sales_data, + + // Smart defaults based on sales data + current_stock: 0, + min_stock: Math.max(1, Math.ceil((suggestion.sales_data?.average_daily_sales || 1) * 7)), // Week buffer + max_stock: Math.max(10, Math.ceil((suggestion.sales_data?.total_quantity || 10) * 2)), // 2x peak quantity + cost_per_unit: 0, + custom_expiry_date: undefined, + + // Initial state + status: suggestion.confidence_score >= 0.8 ? 'approved' : 'pending' + })); +}; + +export const SmartInventorySetupStep: React.FC = ({ + data, + onDataChange +}) => { + const user = useAuthUser(); + const authLoading = useAuthLoading(); + const currentTenant = useCurrentTenant(); + + // Use onboarding hooks + const { + processSalesFile, + createInventoryFromSuggestions, + importSalesData, + salesProcessing: { + stage: onboardingStage, + progress: onboardingProgress, + currentMessage: onboardingMessage, + validationResults, + suggestions + }, + inventorySetup: { + createdItems, + inventoryMapping, + salesImportResult + }, + tenantCreation, + error, + clearError + } = useOnboarding(); + + const toast = useToast(); + + // Get tenant ID + const getTenantId = (): string | null => { + const onboardingTenantId = data.bakery?.tenant_id; + const tenantId = currentTenant?.id || user?.tenant_id || onboardingTenantId || null; + return tenantId; + }; + + // Check if tenant data is available + const isTenantAvailable = (): boolean => { + const hasAuth = !authLoading && user; + const hasTenantId = getTenantId() !== null; + const tenantCreatedSuccessfully = tenantCreation.isSuccess; + const tenantCreatedInOnboarding = data.bakery?.tenantCreated === true; + + return Boolean(hasAuth && (hasTenantId || tenantCreatedSuccessfully || tenantCreatedInOnboarding)); + }; + + // Local state + const [localStage, setLocalStage] = useState(data.processingStage || 'upload'); + const [uploadedFile, setUploadedFile] = useState(data.files?.salesData || null); + const [products, setProducts] = useState(() => { + if (data.detectedProducts) { + return data.detectedProducts; + } + if (suggestions && suggestions.length > 0) { + return convertSuggestionsToCards(suggestions); + } + return []; + }); + const [selectedCategory, setSelectedCategory] = useState('all'); + const [editingProduct, setEditingProduct] = useState(null); + const [isCreating, setIsCreating] = useState(false); + const [dragActive, setDragActive] = useState(false); + + const fileInputRef = useRef(null); + + // Update products when suggestions change + useEffect(() => { + if (suggestions && suggestions.length > 0 && products.length === 0) { + const newProducts = convertSuggestionsToCards(suggestions); + setProducts(newProducts); + setLocalStage('review'); + } + }, [suggestions, products.length]); + + // Derive current stage + const stage = (localStage === 'completed' || localStage === 'error') + ? localStage + : (onboardingStage === 'completed' ? 'review' : onboardingStage || localStage); + const progress = onboardingProgress || 0; + const currentMessage = onboardingMessage || ''; + + // Product stats + const stats = useMemo(() => ({ + total: products.length, + approved: products.filter(p => p.status === 'approved').length, + rejected: products.filter(p => p.status === 'rejected').length, + pending: products.filter(p => p.status === 'pending').length + }), [products]); + + const approvedProducts = useMemo(() => + products.filter(p => p.status === 'approved'), + [products] + ); + + const reviewCompleted = useMemo(() => + products.length > 0 && products.every(p => p.status !== 'pending'), + [products] + ); + + const inventoryConfigured = useMemo(() => + approvedProducts.length > 0 && + approvedProducts.every(p => p.min_stock >= 0 && p.max_stock > p.min_stock), + [approvedProducts] + ); + + const categories = ['all', ...Array.from(new Set(products.map(p => p.category)))]; + + const getFilteredProducts = () => { + if (selectedCategory === 'all') return products; + return products.filter(p => p.category === selectedCategory); + }; + + // File handling + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setDragActive(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + setDragActive(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setDragActive(false); + const files = Array.from(e.dataTransfer.files); + if (files.length > 0) { + handleFileUpload(files[0]); + } + }; + + const handleFileSelect = (e: React.ChangeEvent) => { + if (e.target.files && e.target.files.length > 0) { + handleFileUpload(e.target.files[0]); + } + }; + + const handleFileUpload = async (file: File) => { + const validExtensions = ['.csv', '.xlsx', '.xls', '.json']; + const fileExtension = file.name.toLowerCase().substring(file.name.lastIndexOf('.')); + + if (!validExtensions.includes(fileExtension)) { + toast.addToast('Formato de archivo no válido. Usa CSV, JSON o Excel (.xlsx, .xls)', { + title: 'Formato inválido', + type: 'error' + }); + return; + } + + if (file.size > 10 * 1024 * 1024) { + toast.addToast('El archivo es demasiado grande. Máximo 10MB permitido.', { + title: 'Archivo muy grande', + type: 'error' + }); + return; + } + + if (!isTenantAvailable()) { + toast.addToast('Por favor espere mientras cargamos su información...', { + title: 'Esperando datos de usuario', + type: 'info' + }); + return; + } + + setUploadedFile(file); + setLocalStage('validating'); + + try { + const success = await processSalesFile(file); + + if (success) { + setLocalStage('review'); + toast.addToast('El archivo se procesó correctamente. Revisa los productos detectados.', { + title: 'Procesamiento completado', + type: 'success' + }); + } else { + throw new Error('Error procesando el archivo'); + } + } catch (error) { + console.error('Error processing file:', error); + setLocalStage('error'); + const errorMessage = error instanceof Error ? error.message : 'Error en el procesamiento de datos'; + + toast.addToast(errorMessage, { + title: 'Error en el procesamiento', + type: 'error' + }); + } + }; + + const downloadTemplate = () => { + 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`; + + 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); + + toast.addToast('La plantilla se descargó correctamente.', { + title: 'Plantilla descargada', + type: 'success' + }); + }; + + // Product actions + const handleProductAction = (productId: string, action: 'approve' | 'reject') => { + setProducts(prev => prev.map(product => + product.suggestion_id === productId + ? { ...product, status: action === 'approve' ? 'approved' : 'rejected' } + : product + )); + }; + + const handleBulkAction = (action: 'approve' | 'reject') => { + const filteredProducts = getFilteredProducts(); + setProducts(prev => prev.map(product => + filteredProducts.some(fp => fp.suggestion_id === product.suggestion_id) + ? { ...product, status: action === 'approve' ? 'approved' : 'rejected' } + : product + )); + }; + + const handleEditProduct = (product: EnhancedProductCard) => { + setEditingProduct({ ...product }); + }; + + const handleSaveProduct = (updatedProduct: EnhancedProductCard) => { + setProducts(prev => prev.map(product => + product.suggestion_id === updatedProduct.suggestion_id ? updatedProduct : product + )); + setEditingProduct(null); + }; + + const handleCancelEdit = () => { + setEditingProduct(null); + }; + + // Update parent data + useEffect(() => { + const updatedData = { + ...data, + files: { ...data.files, salesData: uploadedFile }, + processingStage: stage === 'review' ? 'completed' : (stage === 'completed' ? 'completed' : stage), + processingResults: validationResults, + suggestions: suggestions, + detectedProducts: products, + approvedProducts: approvedProducts, + reviewCompleted: reviewCompleted, + inventoryConfigured: inventoryConfigured, + inventoryItems: createdItems, + inventoryMapping: inventoryMapping, + salesImportResult: salesImportResult + }; + + // Debug logging + console.log('SmartInventorySetupStep - Updating parent data:', { + stage, + reviewCompleted, + inventoryConfigured, + hasSalesImportResult: !!salesImportResult, + salesImportResult, + approvedProductsCount: approvedProducts.length + }); + + // Only update if data actually changed to prevent infinite loops + const dataString = JSON.stringify(updatedData); + const currentDataString = JSON.stringify(data); + if (dataString !== currentDataString) { + onDataChange(updatedData); + } + }, [stage, uploadedFile, validationResults, suggestions, products, approvedProducts, reviewCompleted, inventoryConfigured, createdItems, inventoryMapping, salesImportResult, data, onDataChange]); + + const getConfidenceColor = (confidence: number) => { + if (confidence >= 0.9) return 'text-[var(--color-success)] bg-[var(--color-success)]/10 border-[var(--color-success)]/20'; + if (confidence >= 0.75) return 'text-[var(--color-warning)] bg-[var(--color-warning)]/10 border-[var(--color-warning)]/20'; + return 'text-[var(--color-error)] bg-[var(--color-error)]/10 border-[var(--color-error)]/20'; + }; + + const getStatusColor = (status: string) => { + switch (status) { + case 'approved': return 'text-[var(--color-success)] bg-[var(--color-success)]/10 border-[var(--color-success)]/20'; + case 'rejected': return 'text-[var(--color-error)] bg-[var(--color-error)]/10 border-[var(--color-error)]/20'; + default: return 'text-[var(--text-secondary)] bg-[var(--bg-secondary)] border-[var(--border-secondary)]'; + } + }; + + return ( +
+ {/* Loading state when tenant data is not available */} + {!isTenantAvailable() && ( + +
+ +
+

+ Cargando datos de usuario... +

+

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

+
+ )} + + {/* Upload Stage */} + {(stage === 'idle' || localStage === 'upload') && isTenantAvailable() && ( + <> +
+

+ 📦 Configuración Inteligente de Inventario +

+

+ Sube tu historial de ventas y crearemos automáticamente tu inventario con configuraciones inteligentes +

+
+ +
fileInputRef.current?.click()} + > + + +
+ {uploadedFile ? ( + <> +
+ +
+
+

+ ¡Perfecto! Archivo listo +

+
+

+ 📄 {uploadedFile.name} +

+

+ {(uploadedFile.size / 1024 / 1024).toFixed(2)} MB • Listo para procesar +

+
+
+ + ) : ( + <> +
+ +
+
+

+ Sube tu historial de ventas +

+

+ Arrastra y suelta tu archivo aquí, o haz clic para seleccionar +

+
+ + )} +
+
+ + {/* Template Download Section */} +
+
+
+ +
+
+

+ ¿Necesitas ayuda con el formato? +

+

+ Descarga nuestra plantilla con ejemplos para tus datos de ventas +

+ +
+
+
+ + )} + + {/* Processing Stages */} + {(stage === 'validating' || stage === 'analyzing') && ( + +
+
+
+ {stage === 'validating' ? ( + + ) : ( + + )} +
+ +

+ {stage === 'validating' ? 'Validando datos...' : 'Analizando con IA...'} +

+

+ {currentMessage} +

+
+ +
+
+ + Progreso + + {progress}% +
+
+
+
+
+
+ + )} + + {/* Creating Stage */} + {isCreating && ( + +
+
+ +
+

+ Creando tu inventario... +

+

+ Configurando productos e importando datos de ventas +

+
+
+ )} + + {/* Review & Configure Stage */} + {(stage === 'review') && products.length > 0 && ( +
+
+
+ +
+

+ ¡Productos Detectados! +

+

+ Revisa, aprueba y configura los niveles de inventario para tus productos +

+
+ + {/* Stats */} +
+ +
{stats.total}
+
Productos detectados
+
+ + +
{stats.approved}
+
Aprobados
+
+ + +
{stats.rejected}
+
Rechazados
+
+ + +
{stats.pending}
+
Pendientes
+
+
+ + {/* Controls */} +
+
+ + + + {getFilteredProducts().length} productos + +
+ +
+ + +
+
+ + {/* Products List */} +
+ {getFilteredProducts().map((product) => ( + +
+
+
+

+ {product.suggested_name} +

+ + {product.status === 'approved' ? '✓ Aprobado' : + product.status === 'rejected' ? '✗ Rechazado' : '⏳ Pendiente'} + + + {Math.round(product.confidence_score * 100)}% confianza + +
+ +
+ + {product.category} + + + {product.product_type === 'ingredient' ? 'Ingrediente' : 'Producto terminado'} + + {product.requires_refrigeration && ( + + ❄️ Refrigeración + + )} +
+ +
+ {product.original_name !== product.suggested_name && ( +
+ Original: + {product.original_name} +
+ )} + +
+
+ Stock min: + {product.min_stock} +
+
+ Stock max: + {product.max_stock} +
+
+ Unidad: + {product.unit_of_measure} +
+
+ + {product.sales_data && ( +
+
Datos de Ventas:
+
+ Total vendido: {product.sales_data.total_quantity} + Promedio diario: {product.sales_data.average_daily_sales.toFixed(1)} +
+
+ )} + + {product.notes && ( +
+ Nota: {product.notes} +
+ )} +
+
+ +
+ + + +
+
+
+ ))} +
+ + {/* Ready to Proceed Status */} + {approvedProducts.length > 0 && reviewCompleted && ( +
+ +
+
+
+ +
+
+

+ ✅ Listo para crear inventario +

+

+ {approvedProducts.length} productos aprobados y listos para crear el inventario. +

+
+ Haz clic en "Siguiente" para crear automáticamente el inventario e importar los datos de ventas. +
+
+
+
+ )} + + {/* Information */} + +

+ 📦 Inventario Inteligente: +

+
    +
  • Configuración automática - Los niveles de stock se calculan basándose en tus datos de ventas
  • +
  • Revisa y aprueba - Confirma que cada producto sea correcto para tu negocio
  • +
  • Edita configuración - Ajusta niveles de stock, proveedores y otros detalles
  • +
  • Creación integral - Se creará el inventario e importarán los datos de ventas automáticamente
  • +
+
+
+ )} + + {/* Completed Stage */} + {stage === 'completed' && ( +
+
+ +
+

+ ¡Inventario Creado! +

+

+ Tu inventario inteligente ha sido configurado exitosamente con {approvedProducts.length} productos. +

+ {salesImportResult?.success && ( +

+ Se importaron {salesImportResult.records_created} registros de ventas para entrenar tu IA. +

+ )} +
+ )} + + {/* Error State */} + {stage === 'error' && ( + +
+ +
+

+ Error en el procesamiento +

+

+ {currentMessage || error} +

+ +
+ )} + + {/* Product Editor Modal */} + {editingProduct && ( +
+ +
+

+ Editar Producto: {editingProduct.suggested_name} +

+ +
+
+ + setEditingProduct({ ...editingProduct, current_stock: Number(e.target.value) })} + min="0" + className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]" + /> +
+ +
+ + setEditingProduct({ ...editingProduct, min_stock: Number(e.target.value) })} + min="0" + className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]" + /> +
+ +
+ + setEditingProduct({ ...editingProduct, max_stock: Number(e.target.value) })} + min="1" + className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]" + /> +
+ +
+ + setEditingProduct({ ...editingProduct, cost_per_unit: Number(e.target.value) })} + min="0" + step="0.01" + className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]" + /> +
+ +
+ + setEditingProduct({ ...editingProduct, custom_expiry_date: e.target.value })} + className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]" + /> +
+
+ +
+ + +
+
+
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/hooks/business/onboarding/README.md b/frontend/src/hooks/business/onboarding/README.md new file mode 100644 index 00000000..7ea05d8b --- /dev/null +++ b/frontend/src/hooks/business/onboarding/README.md @@ -0,0 +1,225 @@ +# Onboarding Hooks - Complete Clean Architecture + +## Overview + +This onboarding system has been **completely refactored** with no legacy code or backwards compatibility. It follows modern clean architecture principles with: + +- **Standardized patterns** across all services +- **Zero circular dependencies** +- **Centralized state management** with Zustand +- **Type-safe interfaces** throughout +- **Service isolation** with standardized APIs +- **Factory pattern** for service hooks + +## Architecture + +### 🏗️ Core Layer (`core/`) + +**`store.ts`** - Centralized Zustand store +- Single source of truth for all onboarding state +- Atomic operations with devtools integration +- Computed getters for derived state + +**`actions.ts`** - Business logic orchestration +- Coordinates between services +- Handles complex step transitions +- Manages validation and error states + +**`types.ts`** - Complete type definitions +- Unified interfaces for all services +- Standardized error and state patterns +- Re-exports for external consumption + +### 🔧 Services Layer (`services/`) + +All services follow the same standardized pattern using `createServiceHook`: + +**`useTenantCreation.ts`** - Bakery registration and tenant setup +**`useSalesProcessing.ts`** - File validation and AI classification +**`useInventorySetup.ts`** - Inventory creation and sales import +**`useTrainingOrchestration.ts`** - ML model training workflow +**`useProgressTracking.ts`** - Backend progress synchronization +**`useResumeLogic.ts`** - Flow resumption management + +### 🛠️ Utils Layer (`utils/`) + +**`createServiceHook.ts`** - Factory for standardized service hooks +- Eliminates duplicate patterns +- Provides consistent async execution +- Standardized error handling + +## New File Structure + +``` +src/hooks/business/onboarding/ +├── core/ +│ ├── store.ts # Zustand centralized store +│ ├── actions.ts # Business logic orchestration +│ └── types.ts # Complete type definitions +├── services/ +│ ├── useTenantCreation.ts # Tenant service +│ ├── useSalesProcessing.ts # Sales processing service +│ ├── useInventorySetup.ts # Inventory service +│ ├── useTrainingOrchestration.ts # Training service +│ ├── useProgressTracking.ts # Progress service +│ └── useResumeLogic.ts # Resume service +├── utils/ +│ └── createServiceHook.ts # Service factory +├── useOnboarding.ts # Main unified hook +├── useAutoResume.ts # Auto-resume wrapper +├── steps.ts # Step definitions +├── types.ts # Legacy type exports +└── index.ts # Clean exports +``` + +## Component Usage + +### Primary Interface - useOnboarding + +```typescript +import { useOnboarding } from '../hooks/business/onboarding'; + +const OnboardingComponent = () => { + const { + // Core state + currentStep, + steps, + data, + progress, + isLoading, + error, + + // Service states (when needed) + tenantCreation: { isLoading: tenantLoading, isSuccess }, + salesProcessing: { stage, progress: fileProgress, suggestions }, + inventorySetup: { createdItems, inventoryMapping }, + trainingOrchestration: { status, logs, metrics }, + + // Actions + nextStep, + previousStep, + updateStepData, + createTenant, + processSalesFile, + startTraining, + completeOnboarding, + clearError, + } = useOnboarding(); + + // Clean, consistent API across all functionality +}; +``` + +### Auto-Resume Functionality + +```typescript +import { useAutoResume } from '../hooks/business/onboarding'; + +const OnboardingPage = () => { + const { isCheckingResume, completionPercentage } = useAutoResume(); + + if (isCheckingResume) { + return ; + } + + // Continue with onboarding flow +}; +``` + +## Key Improvements + +### ✅ **Eliminated All Legacy Issues** + +1. **No Circular Dependencies** - Clear dependency hierarchy +2. **No Massive God Hooks** - Focused, single-responsibility services +3. **No Inconsistent Patterns** - Standardized service factory +4. **No Type Confusion** - Clean, unified type system +5. **No Duplicate Code** - DRY principles throughout + +### ✅ **Modern Patterns** + +1. **Zustand Store** - Performant, devtools-enabled state +2. **Factory Pattern** - Consistent service creation +3. **Service Composition** - Clean separation of concerns +4. **Type-Safe** - Full TypeScript coverage +5. **Async-First** - Proper error handling and loading states + +### ✅ **Developer Experience** + +1. **Predictable API** - Same patterns across all services +2. **Easy Testing** - Isolated, mockable services +3. **Clear Documentation** - Self-documenting code structure +4. **Performance** - Optimized renders and state updates +5. **Debugging** - Zustand devtools integration + +## Benefits for Components + +### Before (Legacy) +```typescript +// Inconsistent APIs, complex imports, circular deps +import { useOnboarding } from './useOnboarding'; +import { useAutoResume } from './useAutoResume'; // Circular dependency! + +const { + // 50+ properties mixed together + currentStep, data, isLoading, tenantCreation: { isLoading: tenantLoading }, + salesProcessing: { stage, progress }, // Nested complexity +} = useOnboarding(); +``` + +### After (Clean) +```typescript +// Clean, consistent, predictable +import { useOnboarding, useAutoResume } from '../hooks/business/onboarding'; + +const onboarding = useOnboarding(); +const autoResume = useAutoResume(); + +// Clear separation, no circular deps, standardized patterns +``` + +## Migration Path + +### ❌ Removed (No Backwards Compatibility) +- Old `useOnboarding` (400+ lines) +- Old `useOnboardingData` +- Old `useOnboardingFlow` +- Old `useAutoResume` +- All inconsistent service hooks + +### ✅ New Clean Interfaces +- Unified `useOnboarding` hook +- Standardized service hooks +- Centralized store management +- Factory-created services + +## Advanced Usage + +### Direct Service Access +```typescript +import { useSalesProcessing, useInventorySetup } from '../hooks/business/onboarding'; + +// When you need direct service control +const salesService = useSalesProcessing(); +const inventoryService = useInventorySetup(); +``` + +### Custom Service Creation +```typescript +import { createServiceHook } from '../hooks/business/onboarding'; + +// Create new services following the same pattern +const useMyCustomService = createServiceHook({ + initialState: { customData: null }, + // ... configuration +}); +``` + +## Performance + +- **Zustand** - Minimal re-renders, optimized updates +- **Memoized Selectors** - Computed values cached +- **Service Isolation** - Independent loading states +- **Factory Pattern** - Reduced bundle size + +This is a **complete rewrite** with zero legacy baggage. Modern, maintainable, and built for the future. \ No newline at end of file diff --git a/frontend/src/hooks/business/onboarding/core/actions.ts b/frontend/src/hooks/business/onboarding/core/actions.ts new file mode 100644 index 00000000..d9fb2179 --- /dev/null +++ b/frontend/src/hooks/business/onboarding/core/actions.ts @@ -0,0 +1,283 @@ +/** + * Core onboarding actions - Business logic orchestration + */ + +import { useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useOnboardingStore } from './store'; +import { useTenantCreation } from '../services/useTenantCreation'; +import { useSalesProcessing } from '../services/useSalesProcessing'; +import { useInventorySetup } from '../services/useInventorySetup'; +import { useTrainingOrchestration } from '../services/useTrainingOrchestration'; +import { useProgressTracking } from '../services/useProgressTracking'; +import { getStepById } from '../steps'; +import type { ProductSuggestionResponse } from '../core/types'; +import type { BakeryRegistration } from '../../../../api'; + +export const useOnboardingActions = () => { + const navigate = useNavigate(); + const store = useOnboardingStore(); + + // Service hooks + const tenantCreation = useTenantCreation(); + const salesProcessing = useSalesProcessing(); + const inventorySetup = useInventorySetup(); + const trainingOrchestration = useTrainingOrchestration(); + const progressTracking = useProgressTracking(); + + const validateCurrentStep = useCallback((): string | null => { + const currentStep = store.getCurrentStep(); + if (!currentStep) return null; + + const stepConfig = getStepById(currentStep.id); + if (stepConfig?.validation) { + return stepConfig.validation(store.data); + } + return null; + }, [store]); + + const nextStep = useCallback(async (): Promise => { + try { + const currentStep = store.getCurrentStep(); + if (!currentStep) return false; + + // Validate current step + const validation = validateCurrentStep(); + if (validation) { + store.setError(validation); + return false; + } + + store.setError(null); + + // Handle step-specific actions before moving to next step + if (currentStep.id === 'setup') { + const bakeryData = store.getStepData('setup').bakery; + if (bakeryData && !tenantCreation.isSuccess) { + store.setLoading(true); + const success = await tenantCreation.createTenant(bakeryData); + store.setLoading(false); + + if (!success) { + store.setError(tenantCreation.error || 'Error creating tenant'); + return false; + } + } + } + + if (currentStep.id === 'smart-inventory-setup') { + const inventoryData = store.getStepData('smart-inventory-setup'); + + if (inventoryData?.approvedProducts?.length > 0 && !inventoryData?.inventoryConfigured) { + store.setLoading(true); + + // Create inventory from approved products + const inventoryResult = await inventorySetup.createInventoryFromSuggestions(inventoryData.approvedProducts); + + if (!inventoryResult.success) { + store.setLoading(false); + store.setError(inventorySetup.error || 'Error creating inventory'); + return false; + } + + // Import sales data after inventory creation + if (inventoryData?.processingResults && inventoryResult?.inventoryMapping) { + const salesImportResult = await inventorySetup.importSalesData( + inventoryData.processingResults, + inventoryResult.inventoryMapping + ); + + if (!salesImportResult.success) { + store.setLoading(false); + store.setError(inventorySetup.error || 'Error importing sales data'); + return false; + } + } + + store.setLoading(false); + } + } + + // Save progress to backend + const stepData = store.getStepData(currentStep.id); + await progressTracking.markStepCompleted(currentStep.id, stepData); + + // Move to next step + if (store.nextStep()) { + store.markStepCompleted(store.currentStep - 1); + return true; + } + + return false; + } catch (error) { + console.error('Error in nextStep:', error); + store.setError(error instanceof Error ? error.message : 'Error moving to next step'); + store.setLoading(false); + return false; + } + }, [store, validateCurrentStep, tenantCreation, inventorySetup, progressTracking]); + + const previousStep = useCallback((): boolean => { + return store.previousStep(); + }, [store]); + + const goToStep = useCallback((stepIndex: number): boolean => { + return store.goToStep(stepIndex); + }, [store]); + + const createTenant = useCallback(async (bakeryData: BakeryRegistration): Promise => { + store.setLoading(true); + const success = await tenantCreation.createTenant(bakeryData); + store.setLoading(false); + + if (!success) { + store.setError(tenantCreation.error || 'Error creating tenant'); + } + + return success; + }, [store, tenantCreation]); + + const processSalesFile = useCallback(async (file: File): Promise => { + store.setLoading(true); + const result = await salesProcessing.processFile(file); + store.setLoading(false); + + if (!result.success) { + store.setError(salesProcessing.error || 'Error processing sales file'); + } + + return result.success; + }, [store, salesProcessing]); + + const createInventoryFromSuggestions = useCallback(async ( + suggestions: ProductSuggestionResponse[] + ): Promise => { + store.setLoading(true); + const result = await inventorySetup.createInventoryFromSuggestions(suggestions); + store.setLoading(false); + + if (!result.success) { + store.setError(inventorySetup.error || 'Error creating inventory'); + } + + return result.success; + }, [store, inventorySetup]); + + const importSalesData = useCallback(async ( + salesData: any, + inventoryMapping: { [productName: string]: string } + ): Promise => { + store.setLoading(true); + const result = await inventorySetup.importSalesData(salesData, inventoryMapping); + store.setLoading(false); + + if (!result.success) { + store.setError(inventorySetup.error || 'Error importing sales data'); + } + + return result.success; + }, [store, inventorySetup]); + + const startTraining = useCallback(async (options?: { + products?: string[]; + startDate?: string; + endDate?: string; + }): Promise => { + // Validate training prerequisites + const validation = await trainingOrchestration.validateTrainingData(store.getAllStepData()); + if (!validation.isValid) { + store.setError(`Training prerequisites not met: ${validation.missingItems.join(', ')}`); + return false; + } + + store.setLoading(true); + const success = await trainingOrchestration.startTraining(options); + store.setLoading(false); + + if (!success) { + store.setError(trainingOrchestration.error || 'Error starting training'); + } + + return success; + }, [store, trainingOrchestration]); + + const completeOnboarding = useCallback(async (): Promise => { + try { + store.setLoading(true); + + // Mark final completion + const completionStats = { + totalProducts: store.data.processingResults?.unique_products || 0, + inventoryItems: store.data.inventoryItems?.length || 0, + suppliersConfigured: store.data.suppliers?.length || 0, + mlModelAccuracy: store.data.trainingMetrics?.accuracy || 0, + estimatedTimeSaved: '2-3 horas por día', + completionScore: 95, + }; + + store.setStepData('completion', { completionStats }); + + // Complete in backend + const success = await progressTracking.completeOnboarding(); + + store.setLoading(false); + + if (success) { + console.log('✅ Onboarding fully completed!'); + store.markStepCompleted(store.steps.length - 1); + + // Navigate to dashboard after completion + setTimeout(() => { + navigate('/app/dashboard'); + }, 2000); + + return true; + } else { + store.setError('Error completing onboarding'); + return false; + } + } catch (error) { + console.error('Error completing onboarding:', error); + store.setError(error instanceof Error ? error.message : 'Error completing onboarding'); + store.setLoading(false); + return false; + } + }, [store, progressTracking, navigate]); + + const clearError = useCallback(() => { + store.setError(null); + tenantCreation.clearError(); + salesProcessing.clearError(); + inventorySetup.clearError(); + trainingOrchestration.clearError(); + progressTracking.clearError(); + }, [store, tenantCreation, salesProcessing, inventorySetup, trainingOrchestration, progressTracking]); + + const reset = useCallback(() => { + store.reset(); + tenantCreation.reset(); + salesProcessing.reset(); + inventorySetup.reset(); + trainingOrchestration.reset(); + }, [store, tenantCreation, salesProcessing, inventorySetup, trainingOrchestration]); + + return { + // Navigation actions + nextStep, + previousStep, + goToStep, + validateCurrentStep, + + // Step-specific actions + createTenant, + processSalesFile, + createInventoryFromSuggestions, + importSalesData, + startTraining, + completeOnboarding, + + // Utility actions + clearError, + reset, + }; +}; \ No newline at end of file diff --git a/frontend/src/hooks/business/onboarding/core/store.ts b/frontend/src/hooks/business/onboarding/core/store.ts new file mode 100644 index 00000000..bbe4d01a --- /dev/null +++ b/frontend/src/hooks/business/onboarding/core/store.ts @@ -0,0 +1,195 @@ +/** + * Onboarding store - Centralized state management with Zustand + * Handles all onboarding data, steps, and global state + */ + +import { create } from 'zustand'; +import { devtools } from 'zustand/middleware'; +import type { OnboardingData, OnboardingStep, OnboardingProgress } from './types'; +import { DEFAULT_STEPS } from '../steps'; + +interface OnboardingStore { + // Flow state + currentStep: number; + steps: OnboardingStep[]; + + // Data state + data: OnboardingData; + + // UI state + isLoading: boolean; + error: string | null; + + // Progress state + progress: OnboardingProgress | null; + + // Actions - Flow management + setCurrentStep: (step: number) => void; + nextStep: () => boolean; + previousStep: () => boolean; + goToStep: (stepIndex: number) => boolean; + markStepCompleted: (stepIndex: number) => void; + + // Actions - Data management + setStepData: (stepId: string, stepData: Partial) => void; + clearStepData: (stepId: string) => void; + + // Actions - UI state + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; + + // Actions - Progress + setProgress: (progress: OnboardingProgress) => void; + + // Actions - Utilities + reset: () => void; + + // Getters + getCurrentStep: () => OnboardingStep; + getStepData: (stepId: string) => any; + getAllStepData: () => { [stepId: string]: any }; + getProgress: () => OnboardingProgress; + canNavigateToStep: (stepIndex: number) => boolean; +} + +const initialState = { + currentStep: 0, + steps: DEFAULT_STEPS.map(step => ({ ...step, isCompleted: false })), + data: { allStepData: {} }, + isLoading: false, + error: null, + progress: null, +}; + +export const useOnboardingStore = create()( + devtools( + (set, get) => ({ + ...initialState, + + // Flow management actions + setCurrentStep: (step: number) => { + set({ currentStep: step }, false, 'setCurrentStep'); + }, + + nextStep: () => { + const { currentStep, steps } = get(); + if (currentStep < steps.length - 1) { + set({ currentStep: currentStep + 1 }, false, 'nextStep'); + return true; + } + return false; + }, + + previousStep: () => { + const { currentStep } = get(); + if (currentStep > 0) { + set({ currentStep: currentStep - 1 }, false, 'previousStep'); + return true; + } + return false; + }, + + goToStep: (stepIndex: number) => { + const { steps, canNavigateToStep } = get(); + if (stepIndex >= 0 && stepIndex < steps.length && canNavigateToStep(stepIndex)) { + set({ currentStep: stepIndex }, false, 'goToStep'); + return true; + } + return false; + }, + + markStepCompleted: (stepIndex: number) => { + set((state) => ({ + steps: state.steps.map((step, index) => + index === stepIndex ? { ...step, isCompleted: true } : step + ), + }), false, 'markStepCompleted'); + }, + + // Data management actions + setStepData: (stepId: string, stepData: Partial) => { + set((state) => ({ + data: { + ...state.data, + ...stepData, + allStepData: { + ...state.data.allStepData, + [stepId]: { + ...state.data.allStepData?.[stepId], + ...stepData, + }, + }, + }, + }), false, 'setStepData'); + }, + + clearStepData: (stepId: string) => { + set((state) => ({ + data: { + ...state.data, + allStepData: { + ...state.data.allStepData, + [stepId]: undefined, + }, + }, + }), false, 'clearStepData'); + }, + + // UI state actions + setLoading: (loading: boolean) => { + set({ isLoading: loading }, false, 'setLoading'); + }, + + setError: (error: string | null) => { + set({ error, isLoading: false }, false, 'setError'); + }, + + // Progress actions + setProgress: (progress: OnboardingProgress) => { + set({ progress }, false, 'setProgress'); + }, + + // Utility actions + reset: () => { + set(initialState, false, 'reset'); + }, + + // Getters + getCurrentStep: () => { + const { steps, currentStep } = get(); + return steps[currentStep]; + }, + + getStepData: (stepId: string) => { + const { data } = get(); + return data.allStepData?.[stepId] || {}; + }, + + getAllStepData: () => { + const { data } = get(); + return data.allStepData || {}; + }, + + getProgress: () => { + const { steps, currentStep } = get(); + const completedSteps = steps.filter(step => step.isCompleted).length; + const requiredSteps = steps.filter(step => step.isRequired).length; + + return { + currentStep, + totalSteps: steps.length, + completedSteps, + isComplete: completedSteps === requiredSteps, + progressPercentage: Math.round((completedSteps / requiredSteps) * 100), + }; + }, + + canNavigateToStep: (stepIndex: number) => { + const { steps } = get(); + // Allow navigation to any step for now - can add more complex logic later + return stepIndex >= 0 && stepIndex < steps.length; + }, + }), + { name: 'onboarding-store' } + ) +); \ No newline at end of file diff --git a/frontend/src/hooks/business/onboarding/core/types.ts b/frontend/src/hooks/business/onboarding/core/types.ts new file mode 100644 index 00000000..a4b8e39b --- /dev/null +++ b/frontend/src/hooks/business/onboarding/core/types.ts @@ -0,0 +1,205 @@ +/** + * Core types for the entire onboarding system + */ + +import type { + BakeryRegistration, + TrainingJobResponse, + UserProgress, +} from '../../../../api'; + +// Re-export TrainingMetrics locally +export interface TrainingMetrics { + accuracy: number; + mape: number; + mae: number; + rmse: number; + r2_score: number; +} + +// Re-export ProductSuggestionResponse type locally since it's used across many files +export interface ProductSuggestionResponse { + suggestion_id: string; + original_name: string; + suggested_name: string; + product_type: string; + category: string; + unit_of_measure: string; + confidence_score: number; + estimated_shelf_life_days?: number; + requires_refrigeration: boolean; + requires_freezing: boolean; + is_seasonal: boolean; + suggested_supplier?: string; + notes?: string; + sales_data?: { + total_quantity: number; + average_daily_sales: number; + peak_day: string; + frequency: number; + }; +} + +// Base service state pattern +export interface ServiceState { + data: T | null; + isLoading: boolean; + error: string | null; + isSuccess: boolean; +} + +// Base service actions pattern +export interface ServiceActions { + clearError: () => void; + reset: () => void; +} + +// Onboarding step definitions +export interface OnboardingStep { + id: string; + title: string; + description: string; + isRequired: boolean; + isCompleted: boolean; + validation?: (data: OnboardingData) => string | null; +} + +// Complete onboarding data structure +export interface OnboardingData { + // Step 1: Setup + bakery?: BakeryRegistration; + + // Step 2: Smart Inventory Setup + files?: { + salesData?: File; + }; + processingStage?: 'upload' | 'validating' | 'analyzing' | 'review' | 'completed' | 'error'; + processingResults?: { + is_valid: boolean; + total_records: number; + unique_products: number; + product_list: string[]; + validation_errors: string[]; + validation_warnings: string[]; + summary: { + date_range: string; + total_sales: number; + average_daily_sales: number; + }; + }; + suggestions?: ProductSuggestionResponse[]; + detectedProducts?: any[]; + approvedSuggestions?: ProductSuggestionResponse[]; + approvedProducts?: ProductSuggestionResponse[]; + reviewCompleted?: boolean; + inventoryItems?: any[]; + inventoryMapping?: { [productName: string]: string }; + inventoryConfigured?: boolean; + salesImportResult?: { + success: boolean; + imported: boolean; + records_created: number; + message: string; + }; + + // Step 3: Suppliers + suppliers?: any[]; + supplierMappings?: any[]; + + // Step 4: ML Training + trainingStatus?: 'idle' | 'validating' | 'training' | 'completed' | 'failed'; + trainingProgress?: number; + trainingJob?: TrainingJobResponse; + trainingLogs?: TrainingLog[]; + trainingMetrics?: TrainingMetrics; + autoStartTraining?: boolean; + + // Step 5: Completion + completionStats?: { + totalProducts: number; + inventoryItems: number; + suppliersConfigured: number; + mlModelAccuracy: number; + estimatedTimeSaved: string; + completionScore: number; + }; + + // Cross-step data sharing + allStepData?: { [stepId: string]: any }; +} + +export interface OnboardingProgress { + currentStep: number; + totalSteps: number; + completedSteps: number; + isComplete: boolean; + progressPercentage: number; +} + +export interface OnboardingError { + step?: string; + message: string; + details?: any; +} + +// Training types +export interface TrainingLog { + timestamp: string; + message: string; + level: 'info' | 'warning' | 'error' | 'success'; +} + +// Progress callback +export type ProgressCallback = (progress: number, stage: string, message: string) => void; + +// Step validation function +export type StepValidator = (data: OnboardingData) => string | null; + +// Service-specific state types +export interface TenantCreationState extends ServiceState { + tenantData: BakeryRegistration | null; +} + +export interface SalesProcessingState extends ServiceState { + stage: 'idle' | 'validating' | 'analyzing' | 'completed' | 'error'; + progress: number; + currentMessage: string; + validationResults: any | null; + suggestions: ProductSuggestionResponse[] | null; +} + +export interface InventorySetupState extends ServiceState { + createdItems: any[]; + inventoryMapping: { [productName: string]: string }; + salesImportResult: { + success: boolean; + imported: boolean; + records_created: number; + message: string; + } | null; + isInventoryConfigured: boolean; +} + +export interface TrainingOrchestrationState extends ServiceState { + status: 'idle' | 'validating' | 'training' | 'completed' | 'failed'; + progress: number; + currentStep: string; + estimatedTimeRemaining: number; + job: TrainingJobResponse | null; + logs: TrainingLog[]; + metrics: TrainingMetrics | null; +} + +export interface ProgressTrackingState extends ServiceState { + progress: UserProgress | null; + isInitialized: boolean; + isCompleted: boolean; + completionPercentage: number; + currentBackendStep: string | null; +} + +export interface ResumeState extends ServiceState { + isCheckingResume: boolean; + resumePoint: { stepId: string; stepIndex: number } | null; + shouldResume: boolean; +} \ No newline at end of file diff --git a/frontend/src/hooks/business/onboarding/index.ts b/frontend/src/hooks/business/onboarding/index.ts index 8beaff7d..2bbdafdb 100644 --- a/frontend/src/hooks/business/onboarding/index.ts +++ b/frontend/src/hooks/business/onboarding/index.ts @@ -1,21 +1,29 @@ /** - * Onboarding hooks index - * Exports all focused onboarding hooks + * Onboarding hooks index - Complete clean architecture + * No legacy code, no backwards compatibility, clean modern patterns */ -// Types -export type * from './types'; +// Types (re-export core types for external usage) +export type * from './core/types'; // Steps configuration export { DEFAULT_STEPS, getStepById, getStepIndex, canAccessStep, calculateProgress } from './steps'; -// Focused hooks -export { useOnboardingFlow } from './useOnboardingFlow'; -export { useOnboardingData } from './useOnboardingData'; -export { useTenantCreation } from './useTenantCreation'; -export { useSalesProcessing } from './useSalesProcessing'; -export { useInventorySetup } from './useInventorySetup'; -export { useTrainingOrchestration } from './useTrainingOrchestration'; +// Core architecture (for advanced usage) +export { useOnboardingStore } from './core/store'; +export { useOnboardingActions } from './core/actions'; -// Main orchestrating hook -export { useOnboarding } from './useOnboarding'; \ No newline at end of file +// Service hooks (for direct service access when needed) +export { useTenantCreation } from './services/useTenantCreation'; +export { useSalesProcessing } from './services/useSalesProcessing'; +export { useInventorySetup } from './services/useInventorySetup'; +export { useTrainingOrchestration } from './services/useTrainingOrchestration'; +export { useProgressTracking } from './services/useProgressTracking'; +export { useResumeLogic } from './services/useResumeLogic'; + +// Main hooks - PRIMARY INTERFACE for components +export { useOnboarding } from './useOnboarding'; +export { useAutoResume } from './useAutoResume'; + +// Utility +export { createServiceHook } from './utils/createServiceHook'; \ No newline at end of file diff --git a/frontend/src/hooks/business/onboarding/useInventorySetup.ts b/frontend/src/hooks/business/onboarding/services/useInventorySetup.ts similarity index 61% rename from frontend/src/hooks/business/onboarding/useInventorySetup.ts rename to frontend/src/hooks/business/onboarding/services/useInventorySetup.ts index df0fe6c2..6b829008 100644 --- a/frontend/src/hooks/business/onboarding/useInventorySetup.ts +++ b/frontend/src/hooks/business/onboarding/services/useInventorySetup.ts @@ -1,62 +1,32 @@ /** - * Inventory setup hook for creating inventory from suggestions + * Inventory setup service - Clean, standardized implementation */ -import { useState, useCallback } from 'react'; +import { useCallback } from 'react'; import { useCreateIngredient, useCreateSalesRecord, -} from '../../../api'; -import { useCurrentTenant } from '../../../stores'; -import type { ProductSuggestionResponse } from './types'; +} from '../../../../api'; +import { useCurrentTenant } from '../../../../stores'; +import { useOnboardingStore } from '../core/store'; +import { createServiceHook } from '../utils/createServiceHook'; +import type { InventorySetupState, ProductSuggestionResponse } from '../core/types'; -interface InventorySetupState { - isLoading: boolean; - error: string | null; - createdItems: any[]; - inventoryMapping: { [productName: string]: string }; - salesImportResult: { - success: boolean; - imported: boolean; - records_created: number; - message: string; - } | null; - isInventoryConfigured: boolean; -} - -interface InventorySetupActions { - createInventoryFromSuggestions: ( - suggestions: ProductSuggestionResponse[] - ) => Promise<{ - success: boolean; - createdItems?: any[]; - inventoryMapping?: { [productName: string]: string }; - }>; - importSalesData: ( - salesData: any, - inventoryMapping: { [productName: string]: string } - ) => Promise<{ - success: boolean; - recordsCreated: number; - message: string; - }>; - clearError: () => void; - reset: () => void; -} - -export const useInventorySetup = () => { - const [state, setState] = useState({ - isLoading: false, - error: null, +const useInventorySetupService = createServiceHook({ + initialState: { createdItems: [], inventoryMapping: {}, salesImportResult: null, isInventoryConfigured: false, - }); + }, +}); +export const useInventorySetup = () => { + const service = useInventorySetupService(); const createIngredientMutation = useCreateIngredient(); const createSalesRecordMutation = useCreateSalesRecord(); const currentTenant = useCurrentTenant(); + const { setStepData } = useOnboardingStore(); const createInventoryFromSuggestions = useCallback(async ( suggestions: ProductSuggestionResponse[] @@ -73,29 +43,17 @@ export const useInventorySetup = () => { if (!suggestions || suggestions.length === 0) { console.error('useInventorySetup - No suggestions provided'); - setState(prev => ({ - ...prev, - error: 'No hay sugerencias para crear el inventario', - })); + service.setError('No hay sugerencias para crear el inventario'); return { success: false }; } if (!currentTenant?.id) { console.error('useInventorySetup - No tenant ID available'); - setState(prev => ({ - ...prev, - error: 'No se pudo obtener información del tenant', - })); + service.setError('No se pudo obtener información del tenant'); return { success: false }; } - setState(prev => ({ - ...prev, - isLoading: true, - error: null - })); - - try { + const result = await service.executeAsync(async () => { const createdItems = []; const inventoryMapping: { [key: string]: string } = {}; @@ -109,14 +67,16 @@ export const useInventorySetup = () => { category: suggestion.category || 'Sin categoría', description: suggestion.notes || '', unit_of_measure: suggestion.unit_of_measure || 'units', - low_stock_threshold: 10, // Default low stock threshold - reorder_point: 15, // Default reorder point (must be > low_stock_threshold) - reorder_quantity: 50, // Default reorder quantity + minimum_stock_level: 10, + maximum_stock_level: 100, + low_stock_threshold: 10, + reorder_point: 15, + reorder_quantity: 50, shelf_life_days: suggestion.estimated_shelf_life_days || 30, requires_refrigeration: suggestion.requires_refrigeration || false, requires_freezing: suggestion.requires_freezing || false, is_seasonal: suggestion.is_seasonal || false, - cost_per_unit: 0, // Will be set by user later + cost_per_unit: 0, notes: suggestion.notes || `Producto clasificado automáticamente desde: ${suggestion.original_name}`, }; @@ -161,30 +121,33 @@ export const useInventorySetup = () => { inventoryMapping }); - setState(prev => ({ - ...prev, - isLoading: false, + // Update service state + service.setSuccess({ + ...service.data, createdItems, inventoryMapping, isInventoryConfigured: true, - })); + }); + + // Update onboarding store + setStepData('smart-inventory-setup', { + inventoryItems: createdItems, + inventoryMapping, + inventoryConfigured: true, + }); return { - success: true, createdItems, inventoryMapping, }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Error creando el inventario'; - console.error('useInventorySetup - Error in createInventoryFromSuggestions:', error); - setState(prev => ({ - ...prev, - isLoading: false, - error: errorMessage - })); - return { success: false }; - } - }, [createIngredientMutation, currentTenant]); + }); + + return { + success: result.success, + createdItems: result.data?.createdItems, + inventoryMapping: result.data?.inventoryMapping, + }; + }, [service, createIngredientMutation, currentTenant, setStepData]); const importSalesData = useCallback(async ( salesData: any, @@ -195,10 +158,7 @@ export const useInventorySetup = () => { message: string; }> => { if (!currentTenant?.id) { - setState(prev => ({ - ...prev, - error: 'No se pudo obtener información del tenant', - })); + service.setError('No se pudo obtener información del tenant'); return { success: false, recordsCreated: 0, @@ -207,10 +167,7 @@ export const useInventorySetup = () => { } if (!salesData || !salesData.product_list) { - setState(prev => ({ - ...prev, - error: 'No hay datos de ventas para importar', - })); + service.setError('No hay datos de ventas para importar'); return { success: false, recordsCreated: 0, @@ -218,13 +175,7 @@ export const useInventorySetup = () => { }; } - setState(prev => ({ - ...prev, - isLoading: true, - error: null - })); - - try { + const result = await service.executeAsync(async () => { let recordsCreated = 0; // Process actual sales data and create sales records @@ -261,7 +212,7 @@ export const useInventorySetup = () => { } } - const result = { + const importResult = { success: recordsCreated > 0, imported: recordsCreated > 0, records_created: recordsCreated, @@ -270,69 +221,43 @@ export const useInventorySetup = () => { : 'No se pudieron importar registros de ventas', }; - setState(prev => ({ - ...prev, - isLoading: false, - salesImportResult: result, - })); + // Update service state + service.setSuccess({ + ...service.data, + salesImportResult: importResult, + }); + + // Update onboarding store + setStepData('smart-inventory-setup', { + salesImportResult: importResult, + }); return { - success: result.success, recordsCreated, - message: result.message, + message: importResult.message, }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Error importando datos de ventas'; - const result = { - success: false, - imported: false, - records_created: 0, - message: errorMessage, - }; - - setState(prev => ({ - ...prev, - isLoading: false, - error: errorMessage, - salesImportResult: result, - })); - - return { - success: false, - recordsCreated: 0, - message: errorMessage, - }; - } - }, [createSalesRecordMutation, currentTenant]); - - const clearError = useCallback(() => { - setState(prev => ({ ...prev, error: null })); - }, []); - - const reset = useCallback(() => { - setState({ - isLoading: false, - error: null, - createdItems: [], - inventoryMapping: {}, - salesImportResult: null, - isInventoryConfigured: false, }); - }, []); + + return { + success: result.success, + recordsCreated: result.data?.recordsCreated || 0, + message: result.data?.message || (result.error || 'Error importando datos de ventas'), + }; + }, [service, createSalesRecordMutation, currentTenant, setStepData]); return { // State - isLoading: state.isLoading, - error: state.error, - createdItems: state.createdItems, - inventoryMapping: state.inventoryMapping, - salesImportResult: state.salesImportResult, - isInventoryConfigured: state.isInventoryConfigured, + isLoading: service.isLoading, + error: service.error, + createdItems: service.data?.createdItems || [], + inventoryMapping: service.data?.inventoryMapping || {}, + salesImportResult: service.data?.salesImportResult || null, + isInventoryConfigured: service.data?.isInventoryConfigured || false, // Actions createInventoryFromSuggestions, importSalesData, - clearError, - reset, - } satisfies InventorySetupState & InventorySetupActions; + clearError: service.clearError, + reset: service.reset, + }; }; \ No newline at end of file diff --git a/frontend/src/hooks/business/onboarding/services/useProgressTracking.ts b/frontend/src/hooks/business/onboarding/services/useProgressTracking.ts new file mode 100644 index 00000000..bfbe559d --- /dev/null +++ b/frontend/src/hooks/business/onboarding/services/useProgressTracking.ts @@ -0,0 +1,150 @@ +/** + * Progress tracking service - Clean, standardized implementation + */ + +import { useCallback, useEffect } from 'react'; +import { onboardingService } from '../../../../api/services/onboarding'; +import { createServiceHook } from '../utils/createServiceHook'; +import type { ProgressTrackingState } from '../core/types'; +import type { UserProgress } from '../../../../api/types/onboarding'; + +const useProgressTrackingService = createServiceHook({ + initialState: { + progress: null, + isInitialized: false, + isCompleted: false, + completionPercentage: 0, + currentBackendStep: null, + }, +}); + +export const useProgressTracking = () => { + const service = useProgressTrackingService(); + + // Load initial progress from backend + const loadProgress = useCallback(async (): Promise => { + const result = await service.executeAsync(async () => { + const progress = await onboardingService.getUserProgress(''); + + // Update service state with additional computed values + const updatedData = { + ...service.data!, + progress, + isInitialized: true, + isCompleted: progress?.fully_completed || false, + completionPercentage: progress?.completion_percentage || 0, + currentBackendStep: progress?.current_step || null, + }; + + service.setSuccess(updatedData); + + return progress; + }); + + return result.data || null; + }, [service]); + + // Mark a step as completed and save to backend + const markStepCompleted = useCallback(async ( + stepId: string, + data?: Record + ): Promise => { + const result = await service.executeAsync(async () => { + const updatedProgress = await onboardingService.markStepAsCompleted(stepId, data); + + // Update service state + service.setSuccess({ + ...service.data!, + progress: updatedProgress, + isCompleted: updatedProgress?.fully_completed || false, + completionPercentage: updatedProgress?.completion_percentage || 0, + currentBackendStep: updatedProgress?.current_step || null, + }); + + console.log(`✅ Step "${stepId}" marked as completed in backend`); + return updatedProgress; + }); + + if (!result.success) { + console.error(`❌ Error marking step "${stepId}" as completed:`, result.error); + } + + return result.success; + }, [service]); + + // Get the next step the user should work on + const getNextStep = useCallback(async (): Promise => { + try { + return await onboardingService.getNextStepId(); + } catch (error) { + console.error('Error getting next step:', error); + return 'setup'; + } + }, []); + + // Get the step and index where user should resume + const getResumePoint = useCallback(async (): Promise<{ stepId: string; stepIndex: number }> => { + try { + return await onboardingService.getResumeStep(); + } catch (error) { + console.error('Error getting resume point:', error); + return { stepId: 'setup', stepIndex: 0 }; + } + }, []); + + // Complete the entire onboarding process + const completeOnboarding = useCallback(async (): Promise => { + const result = await service.executeAsync(async () => { + const result = await onboardingService.completeOnboarding(); + + if (result.success) { + // Reload progress to get updated status + await loadProgress(); + console.log('🎉 Onboarding completed successfully!'); + return result; + } + + throw new Error('Failed to complete onboarding'); + }); + + return result.success; + }, [service, loadProgress]); + + // Check if user can access a specific step + const canAccessStep = useCallback(async (stepId: string): Promise => { + try { + const result = await onboardingService.canAccessStep(stepId); + return result.can_access; + } catch (error) { + console.error(`Error checking access for step "${stepId}":`, error); + return true; // Allow access on error + } + }, []); + + // Auto-load progress on hook initialization + useEffect(() => { + if (!service.data?.isInitialized) { + loadProgress(); + } + }, [loadProgress, service.data?.isInitialized]); + + return { + // State + isLoading: service.isLoading, + error: service.error, + progress: service.data?.progress || null, + isInitialized: service.data?.isInitialized || false, + isCompleted: service.data?.isCompleted || false, + completionPercentage: service.data?.completionPercentage || 0, + currentBackendStep: service.data?.currentBackendStep || null, + + // Actions + loadProgress, + markStepCompleted, + getNextStep, + getResumePoint, + completeOnboarding, + canAccessStep, + clearError: service.clearError, + }; +}; \ No newline at end of file diff --git a/frontend/src/hooks/business/onboarding/services/useResumeLogic.ts b/frontend/src/hooks/business/onboarding/services/useResumeLogic.ts new file mode 100644 index 00000000..df5d2cd6 --- /dev/null +++ b/frontend/src/hooks/business/onboarding/services/useResumeLogic.ts @@ -0,0 +1,143 @@ +/** + * Resume logic service - Clean, standardized implementation + */ + +import { useCallback, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useOnboardingStore } from '../core/store'; +import { useProgressTracking } from './useProgressTracking'; +import { createServiceHook } from '../utils/createServiceHook'; +import type { ResumeState } from '../core/types'; + +const useResumeLogicService = createServiceHook({ + initialState: { + isCheckingResume: false, + resumePoint: null, + shouldResume: false, + }, +}); + +export const useResumeLogic = () => { + const service = useResumeLogicService(); + const navigate = useNavigate(); + const progressTracking = useProgressTracking(); + const { setCurrentStep } = useOnboardingStore(); + + // Check if user should resume onboarding + const checkForResume = useCallback(async (): Promise => { + const result = await service.executeAsync(async () => { + // Set checking state + service.setSuccess({ + ...service.data!, + isCheckingResume: true, + }); + + // Load user's progress from backend + await progressTracking.loadProgress(); + + if (!progressTracking.progress) { + console.log('🔍 No progress found, starting from beginning'); + service.setSuccess({ + ...service.data!, + isCheckingResume: false, + shouldResume: false, + }); + return false; + } + + // If onboarding is already completed, don't resume + if (progressTracking.isCompleted) { + console.log('✅ Onboarding already completed, redirecting to dashboard'); + navigate('/app/dashboard'); + service.setSuccess({ + ...service.data!, + isCheckingResume: false, + shouldResume: false, + }); + return false; + } + + // Get the resume point from backend + const resumePoint = await progressTracking.getResumePoint(); + + console.log('🔄 Resume point found:', resumePoint); + service.setSuccess({ + ...service.data!, + isCheckingResume: false, + resumePoint, + shouldResume: true, + }); + + return true; + }); + + return result.success; + }, [service, progressTracking, navigate]); + + // Resume the onboarding flow from the correct step + const resumeFlow = useCallback((): boolean => { + if (!service.data?.resumePoint) { + console.warn('⚠️ No resume point available'); + return false; + } + + try { + const { stepIndex } = service.data.resumePoint; + + console.log(`🎯 Resuming onboarding from step index: ${stepIndex}`); + + // Navigate to the correct step in the flow + setCurrentStep(stepIndex); + + console.log('✅ Successfully resumed onboarding flow'); + service.setSuccess({ + ...service.data!, + shouldResume: false, + }); + + return true; + } catch (error) { + console.error('❌ Error resuming flow:', error); + return false; + } + }, [service, setCurrentStep]); + + // Handle automatic resume on mount + const handleAutoResume = useCallback(async () => { + const shouldResume = await checkForResume(); + + if (shouldResume) { + // Wait a bit for state to update, then resume + setTimeout(() => { + resumeFlow(); + }, 100); + } + }, [checkForResume, resumeFlow]); + + // Auto-check for resume when the hook is first used + useEffect(() => { + if (progressTracking.isInitialized && !service.data?.isCheckingResume) { + handleAutoResume(); + } + }, [progressTracking.isInitialized, handleAutoResume, service.data?.isCheckingResume]); + + return { + // State + isLoading: service.isLoading, + error: service.error, + isCheckingResume: service.data?.isCheckingResume || false, + resumePoint: service.data?.resumePoint || null, + shouldResume: service.data?.shouldResume || false, + + // Actions + checkForResume, + resumeFlow, + handleAutoResume, + clearError: service.clearError, + + // Progress tracking state for convenience + progress: progressTracking.progress, + isCompleted: progressTracking.isCompleted, + completionPercentage: progressTracking.completionPercentage, + }; +}; \ No newline at end of file diff --git a/frontend/src/hooks/business/onboarding/useSalesProcessing.ts b/frontend/src/hooks/business/onboarding/services/useSalesProcessing.ts similarity index 70% rename from frontend/src/hooks/business/onboarding/useSalesProcessing.ts rename to frontend/src/hooks/business/onboarding/services/useSalesProcessing.ts index 1369947f..7f211f21 100644 --- a/frontend/src/hooks/business/onboarding/useSalesProcessing.ts +++ b/frontend/src/hooks/business/onboarding/services/useSalesProcessing.ts @@ -1,59 +1,101 @@ /** - * Sales file processing and validation hook + * Sales processing service - Clean, standardized implementation */ -import { useState, useCallback } from 'react'; -import { useClassifyProductsBatch, useValidateFileOnly } from '../../../api'; -import { useCurrentTenant } from '../../../stores'; -import type { ProductSuggestionResponse, ProgressCallback } from './types'; +import { useCallback } from 'react'; +import { useClassifyProductsBatch, useValidateFileOnly } from '../../../../api'; +import { useCurrentTenant } from '../../../../stores'; +import { useOnboardingStore } from '../core/store'; +import { createServiceHook } from '../utils/createServiceHook'; +import type { SalesProcessingState, ProgressCallback, ProductSuggestionResponse } from '../core/types'; -interface SalesProcessingState { - isLoading: boolean; - error: string | null; - stage: 'idle' | 'validating' | 'analyzing' | 'completed' | 'error'; - progress: number; - currentMessage: string; - validationResults: any | null; - suggestions: ProductSuggestionResponse[] | null; -} - -interface SalesProcessingActions { - processFile: (file: File, onProgress?: ProgressCallback) => Promise<{ - success: boolean; - validationResults?: any; - suggestions?: ProductSuggestionResponse[]; - }>; - generateSuggestions: (productList: string[]) => Promise; - clearError: () => void; - reset: () => void; -} - -export const useSalesProcessing = () => { - const [state, setState] = useState({ - isLoading: false, - error: null, +const useSalesProcessingService = createServiceHook({ + initialState: { stage: 'idle', progress: 0, currentMessage: '', validationResults: null, suggestions: null, - }); + }, +}); +export const useSalesProcessing = () => { + const service = useSalesProcessingService(); const classifyProductsMutation = useClassifyProductsBatch(); const currentTenant = useCurrentTenant(); const { validateFile } = useValidateFileOnly(); + const { setStepData } = useOnboardingStore(); - const updateProgress = useCallback((progress: number, stage: string, message: string, onProgress?: ProgressCallback) => { - setState(prev => ({ - ...prev, + const updateProgress = useCallback(( + progress: number, + stage: SalesProcessingState['stage'], + message: string, + onProgress?: ProgressCallback + ) => { + service.setSuccess({ + ...service.data, progress, - stage: stage as any, + stage, currentMessage: message, - })); - + }); onProgress?.(progress, stage, message); + }, [service]); + + const extractProductList = useCallback((validationResult: any): string[] => { + console.log('extractProductList - Input validation result:', { + hasProductList: !!validationResult?.product_list, + productList: validationResult?.product_list, + hasSampleRecords: !!validationResult?.sample_records, + keys: Object.keys(validationResult || {}) + }); + + // First try to use the direct product_list from backend response + if (validationResult.product_list && Array.isArray(validationResult.product_list)) { + console.log('extractProductList - Using direct product_list:', validationResult.product_list); + return validationResult.product_list; + } + + // Fallback: Extract unique product names from sample records + if (validationResult.sample_records && Array.isArray(validationResult.sample_records)) { + const productSet = new Set(); + validationResult.sample_records.forEach((record: any) => { + if (record.product_name) { + productSet.add(record.product_name); + } + }); + const extractedList = Array.from(productSet); + console.log('extractProductList - Extracted from sample_records:', extractedList); + return extractedList; + } + + console.log('extractProductList - No products found, returning empty array'); + return []; }, []); + const generateSuggestions = useCallback(async (productList: string[]): Promise => { + try { + if (!currentTenant?.id) { + console.error('No tenant ID available for classification'); + return []; + } + + const response = await classifyProductsMutation.mutateAsync({ + tenantId: currentTenant.id, + batchData: { + products: productList.map(name => ({ + product_name: name, + description: '' + })) + } + }); + + return response || []; + } catch (error) { + console.error('Error generating inventory suggestions:', error); + return []; + } + }, [classifyProductsMutation, currentTenant]); + const processFile = useCallback(async ( file: File, onProgress?: ProgressCallback @@ -62,20 +104,14 @@ export const useSalesProcessing = () => { validationResults?: any; suggestions?: ProductSuggestionResponse[]; }> => { - setState(prev => ({ - ...prev, - isLoading: true, - error: null, - stage: 'idle', - progress: 0, - })); + service.setLoading(true); + service.setError(null); try { // Stage 1: Validate file structure updateProgress(10, 'validating', 'Iniciando validación del archivo...', onProgress); updateProgress(20, 'validating', 'Validando estructura del archivo...', onProgress); - // Use the actual data import hook for file validation console.log('useSalesProcessing - currentTenant check:', { currentTenant, tenantId: currentTenant?.id, @@ -92,7 +128,7 @@ export const useSalesProcessing = () => { file, { onProgress: (stage, progress, message) => { - updateProgress(progress, stage, message, onProgress); + updateProgress(progress, stage as any, message, onProgress); } } ); @@ -141,13 +177,22 @@ export const useSalesProcessing = () => { updateProgress(90, 'analyzing', 'Analizando patrones de venta...', onProgress); updateProgress(100, 'completed', 'Procesamiento completado exitosamente', onProgress); - setState(prev => ({ - ...prev, - isLoading: false, + // Update service state + service.setSuccess({ stage: 'completed', + progress: 100, + currentMessage: 'Procesamiento completado exitosamente', validationResults: validationResult, suggestions: suggestions || [], - })); + }); + + // Update onboarding store + setStepData('smart-inventory-setup', { + files: { salesData: file }, + processingStage: 'completed', + processingResults: validationResult, + suggestions: suggestions || [], + }); return { success: true, @@ -157,108 +202,29 @@ export const useSalesProcessing = () => { } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Error procesando el archivo'; - setState(prev => ({ - ...prev, - isLoading: false, - error: errorMessage, - stage: 'error', - })); - + service.setError(errorMessage); updateProgress(0, 'error', errorMessage, onProgress); return { success: false, }; } - }, [updateProgress, currentTenant, validateFile]); - - // Helper to extract product list from validation result - const extractProductList = useCallback((validationResult: any): string[] => { - console.log('extractProductList - Input validation result:', { - hasProductList: !!validationResult?.product_list, - productList: validationResult?.product_list, - hasSampleRecords: !!validationResult?.sample_records, - keys: Object.keys(validationResult || {}) - }); - - // First try to use the direct product_list from backend response - if (validationResult.product_list && Array.isArray(validationResult.product_list)) { - console.log('extractProductList - Using direct product_list:', validationResult.product_list); - return validationResult.product_list; - } - - // Fallback: Extract unique product names from sample records - if (validationResult.sample_records && Array.isArray(validationResult.sample_records)) { - const productSet = new Set(); - validationResult.sample_records.forEach((record: any) => { - if (record.product_name) { - productSet.add(record.product_name); - } - }); - const extractedList = Array.from(productSet); - console.log('extractProductList - Extracted from sample_records:', extractedList); - return extractedList; - } - - console.log('extractProductList - No products found, returning empty array'); - return []; - }, []); - - const generateSuggestions = useCallback(async (productList: string[]): Promise => { - try { - if (!currentTenant?.id) { - console.error('No tenant ID available for classification'); - return []; - } - - const response = await classifyProductsMutation.mutateAsync({ - tenantId: currentTenant.id, - batchData: { - products: productList.map(name => ({ - product_name: name, - description: '' - })) - } - }); - - return response || []; - } catch (error) { - console.error('Error generating inventory suggestions:', error); - // Don't throw here - suggestions are optional - return []; - } - }, [classifyProductsMutation, currentTenant]); - - const clearError = useCallback(() => { - setState(prev => ({ ...prev, error: null })); - }, []); - - const reset = useCallback(() => { - setState({ - isLoading: false, - error: null, - stage: 'idle', - progress: 0, - currentMessage: '', - validationResults: null, - suggestions: null, - }); - }, []); + }, [service, updateProgress, currentTenant, validateFile, extractProductList, generateSuggestions, setStepData]); return { // State - isLoading: state.isLoading, - error: state.error, - stage: state.stage, - progress: state.progress, - currentMessage: state.currentMessage, - validationResults: state.validationResults, - suggestions: state.suggestions, + isLoading: service.isLoading, + error: service.error, + stage: service.data?.stage || 'idle', + progress: service.data?.progress || 0, + currentMessage: service.data?.currentMessage || '', + validationResults: service.data?.validationResults || null, + suggestions: service.data?.suggestions || null, // Actions processFile, generateSuggestions, - clearError, - reset, - } satisfies SalesProcessingState & SalesProcessingActions; + clearError: service.clearError, + reset: service.reset, + }; }; \ No newline at end of file diff --git a/frontend/src/hooks/business/onboarding/services/useTenantCreation.ts b/frontend/src/hooks/business/onboarding/services/useTenantCreation.ts new file mode 100644 index 00000000..5f28a09b --- /dev/null +++ b/frontend/src/hooks/business/onboarding/services/useTenantCreation.ts @@ -0,0 +1,70 @@ +/** + * Tenant creation service - Clean, standardized implementation + */ + +import { useCallback } from 'react'; +import { useRegisterBakery } from '../../../../api'; +import { useTenantStore } from '../../../../stores/tenant.store'; +import { useOnboardingStore } from '../core/store'; +import { createServiceHook } from '../utils/createServiceHook'; +import type { TenantCreationState } from '../core/types'; +import type { BakeryRegistration, TenantResponse } from '../../../../api'; + +const useTenantCreationService = createServiceHook({ + initialState: { + tenantData: null, + }, +}); + +export const useTenantCreation = () => { + const service = useTenantCreationService(); + const registerBakeryMutation = useRegisterBakery(); + const { setCurrentTenant, loadUserTenants } = useTenantStore(); + const { setStepData } = useOnboardingStore(); + + const createTenant = useCallback(async (bakeryData: BakeryRegistration): Promise => { + if (!bakeryData) { + service.setError('Los datos de la panadería son requeridos'); + return false; + } + + const result = await service.executeAsync(async () => { + // Call API to register bakery + const tenantResponse: TenantResponse = await registerBakeryMutation.mutateAsync(bakeryData); + + // Update tenant store + console.log('useTenantCreation - Setting current tenant:', tenantResponse); + setCurrentTenant(tenantResponse); + + // Reload user tenants + await loadUserTenants(); + + // Update onboarding data + setStepData('setup', { + bakery: { + ...bakeryData, + tenantCreated: true, + tenant_id: tenantResponse.id, + } as any, + }); + + console.log('useTenantCreation - Tenant created and set successfully'); + return tenantResponse; + }); + + return result.success; + }, [service, registerBakeryMutation, setCurrentTenant, loadUserTenants, setStepData]); + + return { + // State + isLoading: service.isLoading, + error: service.error, + isSuccess: service.isSuccess, + tenantData: service.tenantData, + + // Actions + createTenant, + clearError: service.clearError, + reset: service.reset, + }; +}; \ No newline at end of file diff --git a/frontend/src/hooks/business/onboarding/services/useTrainingOrchestration.ts b/frontend/src/hooks/business/onboarding/services/useTrainingOrchestration.ts new file mode 100644 index 00000000..412a3442 --- /dev/null +++ b/frontend/src/hooks/business/onboarding/services/useTrainingOrchestration.ts @@ -0,0 +1,271 @@ +/** + * Training orchestration service - Clean, standardized implementation + */ + +import { useCallback, useEffect } from 'react'; +import { + useCreateTrainingJob, + useTrainingJobStatus, + useTrainingWebSocket, +} from '../../../../api'; +import { useCurrentTenant } from '../../../../stores'; +import { useAuthUser } from '../../../../stores/auth.store'; +import { useOnboardingStore } from '../core/store'; +import { createServiceHook } from '../utils/createServiceHook'; +import type { TrainingOrchestrationState, TrainingLog, TrainingMetrics } from '../core/types'; +import type { TrainingJobResponse } from '../../../../api'; + +const useTrainingOrchestrationService = createServiceHook({ + initialState: { + status: 'idle', + progress: 0, + currentStep: '', + estimatedTimeRemaining: 0, + job: null, + logs: [], + metrics: null, + }, +}); + +export const useTrainingOrchestration = () => { + const service = useTrainingOrchestrationService(); + const currentTenant = useCurrentTenant(); + const user = useAuthUser(); + const createTrainingJobMutation = useCreateTrainingJob(); + const { setStepData, getAllStepData } = useOnboardingStore(); + + // Get job status when we have a job ID + const { data: jobStatus } = useTrainingJobStatus( + currentTenant?.id || '', + service.data?.job?.job_id || '', + { + enabled: !!currentTenant?.id && !!service.data?.job?.job_id && service.data?.status === 'training', + refetchInterval: 5000, + } + ); + + // WebSocket for real-time updates + const { data: wsData } = useTrainingWebSocket( + currentTenant?.id || '', + service.data?.job?.job_id || '', + (user as any)?.token, + { + onProgress: (data: any) => { + service.setSuccess({ + ...service.data!, + progress: data.progress?.percentage || service.data?.progress || 0, + currentStep: data.progress?.current_step || service.data?.currentStep || '', + estimatedTimeRemaining: data.progress?.estimated_time_remaining || service.data?.estimatedTimeRemaining || 0, + }); + addLog( + `${data.progress?.current_step} - ${data.progress?.products_completed}/${data.progress?.products_total} productos procesados (${data.progress?.percentage}%)`, + 'info' + ); + }, + onCompleted: (data: any) => { + service.setSuccess({ + ...service.data!, + status: 'completed', + progress: 100, + metrics: { + accuracy: data.results.performance_metrics.accuracy, + mape: data.results.performance_metrics.mape, + mae: data.results.performance_metrics.mae, + rmse: data.results.performance_metrics.rmse, + r2_score: data.results.performance_metrics.r2_score || 0, + }, + }); + addLog('¡Entrenamiento ML completado exitosamente!', 'success'); + addLog(`${data.results.successful_trainings} modelos creados exitosamente`, 'success'); + addLog(`Duración total: ${Math.round(data.results.training_duration / 60)} minutos`, 'info'); + }, + onError: (data: any) => { + service.setError(data.error); + service.setSuccess({ + ...service.data!, + status: 'failed', + }); + addLog(`Error en entrenamiento: ${data.error}`, 'error'); + }, + onStarted: (data: any) => { + addLog(`Trabajo de entrenamiento iniciado: ${data.job_id}`, 'success'); + }, + } + ); + + // Update status from polling when WebSocket is not available + useEffect(() => { + if (jobStatus && service.data?.job?.job_id === jobStatus.job_id) { + service.setSuccess({ + ...service.data!, + status: jobStatus.status as any, + progress: jobStatus.progress || service.data?.progress || 0, + currentStep: jobStatus.current_step || service.data?.currentStep || '', + estimatedTimeRemaining: jobStatus.estimated_time_remaining || service.data?.estimatedTimeRemaining || 0, + }); + } + }, [jobStatus, service.data?.job?.job_id, service]); + + const addLog = useCallback((message: string, level: TrainingLog['level'] = 'info') => { + const newLog: TrainingLog = { + timestamp: new Date().toISOString(), + message, + level + }; + + service.setSuccess({ + ...service.data!, + logs: [...(service.data?.logs || []), newLog] + }); + }, [service]); + + const validateTrainingData = useCallback(async (allStepData?: any): Promise<{ + isValid: boolean; + missingItems: string[]; + }> => { + const missingItems: string[] = []; + const stepData = allStepData || getAllStepData(); + + console.log('Training Validation - All step data:', stepData); + + // Get data from the smart-inventory-setup step (where sales and inventory are handled) + const smartInventoryData = stepData?.['smart-inventory-setup']; + + // CRITICAL REQUIREMENT 1: Sales file must be uploaded + if (!smartInventoryData?.files?.salesData) { + missingItems.push('Archivo de datos de ventas históricos'); + } + + // CRITICAL REQUIREMENT 2: Sales data must be processed and valid + const hasProcessingResults = smartInventoryData?.processingResults && + smartInventoryData.processingResults.is_valid && + smartInventoryData.processingResults.total_records > 0; + + if (!hasProcessingResults) { + missingItems.push('Datos de ventas procesados y validados'); + } + + // CRITICAL REQUIREMENT 3: Sales data must be imported to backend (MANDATORY for training) + const hasImportResults = smartInventoryData?.salesImportResult && + (smartInventoryData.salesImportResult.records_created > 0 || + smartInventoryData.salesImportResult.success === true || + smartInventoryData.salesImportResult.imported === true); + + if (!hasImportResults) { + missingItems.push('Datos de ventas históricos importados al sistema'); + } + + // CRITICAL REQUIREMENT 4: Products must be approved + const hasApprovedProducts = smartInventoryData?.approvedProducts && + smartInventoryData.approvedProducts.length > 0 && + smartInventoryData.reviewCompleted; + + if (!hasApprovedProducts) { + missingItems.push('Productos revisados y aprobados'); + } + + // CRITICAL REQUIREMENT 5: Inventory must be configured + const hasInventoryConfig = smartInventoryData?.inventoryConfigured && + smartInventoryData?.inventoryItems && + smartInventoryData.inventoryItems.length > 0; + + if (!hasInventoryConfig) { + missingItems.push('Inventario configurado con productos'); + } + + // CRITICAL REQUIREMENT 6: Minimum data volume for training + if (smartInventoryData?.processingResults?.total_records && + smartInventoryData.processingResults.total_records < 10) { + missingItems.push('Suficientes registros de ventas para entrenar (mínimo 10)'); + } + + const isValid = missingItems.length === 0; + + console.log('Training Validation Result:', { + isValid, + missingItems, + smartInventoryData: { + hasFile: !!smartInventoryData?.files?.salesData, + hasProcessingResults, + hasImportResults, + hasApprovedProducts, + hasInventoryConfig, + totalRecords: smartInventoryData?.processingResults?.total_records || 0 + } + }); + + return { isValid, missingItems }; + }, [getAllStepData]); + + const startTraining = useCallback(async (options?: { + products?: string[]; + startDate?: string; + endDate?: string; + }): Promise => { + if (!currentTenant?.id) { + service.setError('No se pudo obtener información del tenant'); + return false; + } + + service.setLoading(true); + service.setSuccess({ + ...service.data!, + status: 'validating', + progress: 0, + }); + + addLog('Validando disponibilidad de datos...', 'info'); + + const result = await service.executeAsync(async () => { + // Start training job + addLog('Iniciando trabajo de entrenamiento ML...', 'info'); + const job = await createTrainingJobMutation.mutateAsync({ + tenantId: currentTenant.id, + request: { + products: options?.products, + start_date: options?.startDate, + end_date: options?.endDate, + } + }); + + // Update service state + const updatedData = { + ...service.data!, + job, + status: 'training' as const, + }; + service.setSuccess(updatedData); + + // Update onboarding store + setStepData('ml-training', { + trainingStatus: 'training', + trainingJob: job, + }); + + addLog(`Trabajo de entrenamiento iniciado: ${job.job_id}`, 'success'); + return job; + }); + + return result.success; + }, [currentTenant, createTrainingJobMutation, addLog, service, setStepData]); + + return { + // State + isLoading: service.isLoading, + error: service.error, + status: service.data?.status || 'idle', + progress: service.data?.progress || 0, + currentStep: service.data?.currentStep || '', + estimatedTimeRemaining: service.data?.estimatedTimeRemaining || 0, + job: service.data?.job || null, + logs: service.data?.logs || [], + metrics: service.data?.metrics || null, + + // Actions + startTraining, + validateTrainingData, + addLog, + clearError: service.clearError, + reset: service.reset, + }; +}; \ No newline at end of file diff --git a/frontend/src/hooks/business/onboarding/steps.ts b/frontend/src/hooks/business/onboarding/steps.ts index 6b2ba3d5..3c366e47 100644 --- a/frontend/src/hooks/business/onboarding/steps.ts +++ b/frontend/src/hooks/business/onboarding/steps.ts @@ -22,37 +22,55 @@ export const DEFAULT_STEPS: OnboardingStep[] = [ }, }, { - id: 'sales-validation', - title: '📊 Validación de Ventas', - description: 'Sube, valida y aprueba tus datos de ventas históricas con IA', + id: 'smart-inventory-setup', + title: '📦 Inventario Inteligente', + description: 'Sube datos de ventas, configura inventario y crea tu catálogo de productos', isRequired: true, isCompleted: false, validation: (data: OnboardingData) => { + console.log('Smart Inventory Step Validation - Data:', { + hasSalesData: !!data.files?.salesData, + processingStage: data.processingStage, + isValid: data.processingResults?.is_valid, + reviewCompleted: data.reviewCompleted, + approvedProductsCount: data.approvedProducts?.length || 0, + inventoryConfigured: data.inventoryConfigured, + salesImportResult: data.salesImportResult + }); + if (!data.files?.salesData) return 'Debes cargar el archivo de datos de ventas'; if (data.processingStage !== 'completed' && data.processingStage !== 'review') return 'El procesamiento debe completarse antes de continuar'; if (!data.processingResults?.is_valid) return 'Los datos deben ser válidos para continuar'; if (!data.reviewCompleted) return 'Debes revisar y aprobar los productos detectados'; const hasApprovedProducts = data.approvedProducts && data.approvedProducts.length > 0; if (!hasApprovedProducts) return 'Debes aprobar al menos un producto para continuar'; - return null; - }, - }, - { - id: 'inventory', - title: '⚙️ Inventario', - description: 'Configuración de inventario e importación de datos de ventas', - isRequired: true, - isCompleted: false, - validation: (data: OnboardingData) => { - if (!data.inventoryConfigured) return 'Debes configurar el inventario básico'; - // Check if sales data was imported successfully - const hasImportResults = data.salesImportResult && - (data.salesImportResult.records_created > 0 || - data.salesImportResult.success === true || - data.salesImportResult.imported === true); - - if (!hasImportResults) return 'Debes importar los datos de ventas al inventario'; + // Check if ready for automatic inventory creation and sales import + // If inventory is already configured, check if sales data was imported + if (data.inventoryConfigured) { + const hasImportResults = data.salesImportResult && + (data.salesImportResult.records_created > 0 || + data.salesImportResult.success === true || + data.salesImportResult.imported === true); + + if (!hasImportResults) { + console.log('Smart Inventory Step Validation - Sales import validation failed:', { + hasSalesImportResult: !!data.salesImportResult, + salesImportResult: data.salesImportResult, + inventoryConfigured: data.inventoryConfigured + }); + + return 'Los datos de ventas históricos deben estar importados para continuar al entrenamiento de IA.'; + } + } else { + // If inventory is not configured yet, ensure all prerequisites are ready + // The actual creation will happen automatically on "Next Step" + console.log('Smart Inventory Step Validation - Ready for automatic inventory creation:', { + hasApprovedProducts: hasApprovedProducts, + reviewCompleted: data.reviewCompleted, + readyForCreation: hasApprovedProducts && data.reviewCompleted + }); + } return null; }, }, @@ -71,9 +89,43 @@ export const DEFAULT_STEPS: OnboardingStep[] = [ isRequired: true, isCompleted: false, validation: (data: OnboardingData) => { - if (data.trainingStatus !== 'completed') { - return 'El entrenamiento del modelo debe completarse'; + console.log('ML Training Step Validation - Data:', { + inventoryConfigured: data.inventoryConfigured, + hasSalesFile: !!data.files?.salesData, + processingResults: data.processingResults, + salesImportResult: data.salesImportResult, + trainingStatus: data.trainingStatus + }); + + // CRITICAL PREREQUISITE 1: Inventory must be configured + if (!data.inventoryConfigured) { + return 'Debes configurar el inventario antes de entrenar el modelo de IA'; } + + // CRITICAL PREREQUISITE 2: Sales file must be uploaded and processed + if (!data.files?.salesData) { + return 'Debes cargar un archivo de datos de ventas históricos para entrenar el modelo'; + } + + // CRITICAL PREREQUISITE 3: Sales data must be processed and valid + if (!data.processingResults?.is_valid) { + return 'Los datos de ventas deben ser procesados y validados antes del entrenamiento'; + } + + // CRITICAL PREREQUISITE 4: Sales data must be imported to backend + const hasSalesDataImported = data.salesImportResult && + (data.salesImportResult.records_created > 0 || + data.salesImportResult.success === true); + + if (!hasSalesDataImported && data.trainingStatus !== 'completed') { + return 'Los datos de ventas históricos deben estar importados en el sistema para iniciar el entrenamiento del modelo de IA'; + } + + // CRITICAL PREREQUISITE 5: Training must be completed to proceed + if (data.trainingStatus !== 'completed') { + return 'El entrenamiento del modelo de IA debe completarse antes de continuar'; + } + return null; }, }, @@ -122,7 +174,7 @@ export const calculateProgress = (completedSteps: boolean[]): { percentage: number; } => { const requiredSteps = DEFAULT_STEPS.filter(step => step.isRequired); - const completedRequired = requiredSteps.filter((step, index) => { + const completedRequired = requiredSteps.filter((step) => { const stepIndex = getStepIndex(step.id); return completedSteps[stepIndex]; }); diff --git a/frontend/src/hooks/business/onboarding/useAutoResume.ts b/frontend/src/hooks/business/onboarding/useAutoResume.ts new file mode 100644 index 00000000..aa28abb5 --- /dev/null +++ b/frontend/src/hooks/business/onboarding/useAutoResume.ts @@ -0,0 +1,20 @@ +/** + * Auto-resume hook - Simple wrapper around resume logic service + */ + +import { useResumeLogic } from './services/useResumeLogic'; + +export const useAutoResume = () => { + const resumeLogic = useResumeLogic(); + + return { + // State + isCheckingResume: resumeLogic.isCheckingResume, + isCompleted: resumeLogic.isCompleted, + completionPercentage: resumeLogic.completionPercentage, + + // Actions + checkForSavedProgress: resumeLogic.checkForResume, + resumeFromSavedProgress: resumeLogic.resumeFlow, + }; +}; \ No newline at end of file diff --git a/frontend/src/hooks/business/onboarding/useOnboarding.ts b/frontend/src/hooks/business/onboarding/useOnboarding.ts index fc70216e..50359ad8 100644 --- a/frontend/src/hooks/business/onboarding/useOnboarding.ts +++ b/frontend/src/hooks/business/onboarding/useOnboarding.ts @@ -1,282 +1,87 @@ /** - * Main onboarding hook - orchestrates all focused onboarding hooks - * This is the primary hook that components should use + * Main onboarding hook - Clean, unified interface for components + * This is the primary hook that all components should use */ -import { useCallback } from 'react'; -import { useNavigate } from 'react-router-dom'; import { useAuthUser } from '../../../stores/auth.store'; import { useCurrentTenant } from '../../../stores'; -import { useOnboardingFlow } from './useOnboardingFlow'; -import { useOnboardingData } from './useOnboardingData'; -import { useTenantCreation } from './useTenantCreation'; -import { useSalesProcessing } from './useSalesProcessing'; -import { useInventorySetup } from './useInventorySetup'; -import { useTrainingOrchestration } from './useTrainingOrchestration'; -import type { - OnboardingData, - ProgressCallback, - ProductSuggestionResponse -} from './types'; -import type { BakeryRegistration } from '../../../api'; - +import { useOnboardingStore } from './core/store'; +import { useOnboardingActions } from './core/actions'; +import { useTenantCreation } from './services/useTenantCreation'; +import { useSalesProcessing } from './services/useSalesProcessing'; +import { useInventorySetup } from './services/useInventorySetup'; +import { useTrainingOrchestration } from './services/useTrainingOrchestration'; +import { useProgressTracking } from './services/useProgressTracking'; +import { useResumeLogic } from './services/useResumeLogic'; export const useOnboarding = () => { - const navigate = useNavigate(); const user = useAuthUser(); const currentTenant = useCurrentTenant(); - // Focused hooks - const flow = useOnboardingFlow(); - const data = useOnboardingData(); + // Core store and actions + const store = useOnboardingStore(); + const actions = useOnboardingActions(); + + // Service hooks for detailed state access const tenantCreation = useTenantCreation(); const salesProcessing = useSalesProcessing(); const inventorySetup = useInventorySetup(); const trainingOrchestration = useTrainingOrchestration(); - - // Data management - const updateStepData = useCallback((stepId: string, stepData: Partial) => { - data.updateStepData(stepId, stepData); - }, [data]); - - const validateCurrentStep = useCallback((): string | null => { - const currentStep = flow.getCurrentStep(); - const validationResult = data.validateStep(currentStep.id); - - // Also check for specific step validations - if (validationResult) return validationResult; - - return null; - }, [flow, data]); - - // Step-specific actions - const createTenant = useCallback(async (bakeryData: BakeryRegistration): Promise => { - const success = await tenantCreation.createTenant(bakeryData); - if (success) { - // Store the bakery data with tenant creation success flag and tenant ID - updateStepData('setup', { - bakery: { - ...bakeryData, - tenantCreated: true, - tenant_id: currentTenant?.id || 'created' - } as any // Type assertion to allow the additional properties - }); - console.log('useOnboarding - Tenant created successfully, updated step data with tenant ID:', currentTenant?.id); - } - return success; - }, [tenantCreation, updateStepData, currentTenant]); - - // Navigation actions - const nextStep = useCallback(async (): Promise => { - try { - const currentStep = flow.getCurrentStep(); - console.log('useOnboarding - nextStep called from step:', currentStep.id); - - const validation = validateCurrentStep(); - if (validation) { - console.log('useOnboarding - Validation failed:', validation); - return false; - } - - // Handle step-specific actions before moving to next step - if (currentStep.id === 'setup') { - console.log('useOnboarding - Creating tenant before leaving setup step'); - const allStepData = data.getAllStepData(); - const bakeryData = allStepData?.setup?.bakery; - - if (bakeryData && !tenantCreation.isSuccess) { - console.log('useOnboarding - Tenant data found, creating tenant:', bakeryData); - const tenantSuccess = await createTenant(bakeryData); - console.log('useOnboarding - Tenant creation result:', tenantSuccess); - - if (!tenantSuccess) { - console.log('useOnboarding - Tenant creation failed, stopping navigation'); - return false; - } - } else { - console.log('useOnboarding - No tenant data found or tenant already created'); - } - } - - if (flow.nextStep()) { - flow.markStepCompleted(flow.currentStep - 1); - return true; - } - return false; - } catch (error) { - console.error('useOnboarding - Error in nextStep:', error); - return false; - } - }, [flow, validateCurrentStep, data, createTenant, tenantCreation]); - - const previousStep = useCallback((): boolean => { - return flow.previousStep(); - }, [flow]); - - const goToStep = useCallback((stepIndex: number): boolean => { - return flow.goToStep(stepIndex); - }, [flow]); - - const processSalesFile = useCallback(async ( - file: File, - onProgress?: ProgressCallback - ): Promise => { - const result = await salesProcessing.processFile(file, onProgress); - if (result.success) { - updateStepData('sales-validation', { - files: { salesData: file }, - processingStage: 'completed', - processingResults: result.validationResults, - suggestions: result.suggestions || [] - }); - } - return result.success; - }, [salesProcessing, updateStepData]); - - const generateInventorySuggestions = useCallback(async ( - productList: string[] - ): Promise => { - return salesProcessing.generateSuggestions(productList); - }, [salesProcessing]); - - const createInventoryFromSuggestions = useCallback(async ( - suggestions: ProductSuggestionResponse[] - ): Promise => { - const result = await inventorySetup.createInventoryFromSuggestions(suggestions); - if (result.success) { - updateStepData('inventory', { - inventoryItems: result.createdItems, - inventoryMapping: result.inventoryMapping, - inventoryConfigured: true, - }); - } - return result.success; - }, [inventorySetup, updateStepData]); - - const importSalesData = useCallback(async ( - salesData: any, - inventoryMapping: { [productName: string]: string } - ): Promise => { - const result = await inventorySetup.importSalesData(salesData, inventoryMapping); - if (result.success) { - updateStepData('inventory', { - salesImportResult: { - success: result.success, - imported: true, - records_created: result.recordsCreated, - message: result.message, - }, - }); - } - return result.success; - }, [inventorySetup, updateStepData]); - - const startTraining = useCallback(async (options?: { - products?: string[]; - startDate?: string; - endDate?: string; - }): Promise => { - // First validate training data requirements - const allStepData = data.getAllStepData(); - const validation = await trainingOrchestration.validateTrainingData(allStepData); - - if (!validation.isValid) { - return false; - } - - const success = await trainingOrchestration.startTraining(options); - if (success) { - updateStepData('ml-training', { - trainingStatus: 'training', - trainingJob: trainingOrchestration.job, - trainingLogs: trainingOrchestration.logs, - }); - } - return success; - }, [trainingOrchestration, data, updateStepData]); - - const completeOnboarding = useCallback(async (): Promise => { - // Mark final completion - updateStepData('completion', { - completionStats: { - totalProducts: data.data.processingResults?.unique_products || 0, - inventoryItems: data.data.inventoryItems?.length || 0, - suppliersConfigured: data.data.suppliers?.length || 0, - mlModelAccuracy: data.data.trainingMetrics?.accuracy || 0, - estimatedTimeSaved: '2-3 horas por día', - completionScore: 95, - }, - }); - - flow.markStepCompleted(flow.steps.length - 1); - - // Navigate to dashboard after completion - setTimeout(() => { - navigate('/app/dashboard'); - }, 2000); - - return true; - }, [data, flow, navigate, updateStepData]); - - // Utilities - const clearError = useCallback(() => { - data.clearError(); - tenantCreation.clearError(); - salesProcessing.clearError(); - inventorySetup.clearError(); - trainingOrchestration.clearError(); - }, [data, tenantCreation, salesProcessing, inventorySetup, trainingOrchestration]); - - const reset = useCallback(() => { - flow.resetFlow(); - data.resetData(); - tenantCreation.reset(); - salesProcessing.reset(); - inventorySetup.reset(); - trainingOrchestration.reset(); - }, [flow, data, tenantCreation, salesProcessing, inventorySetup, trainingOrchestration]); - - // Determine overall loading and error state - const isLoading = tenantCreation.isLoading || - salesProcessing.isLoading || - inventorySetup.isLoading || - trainingOrchestration.isLoading; - - const error = data.error?.message || - tenantCreation.error || - salesProcessing.error || - inventorySetup.error || - trainingOrchestration.error; + const progressTracking = useProgressTracking(); + const resumeLogic = useResumeLogic(); return { - // State from flow management - currentStep: flow.currentStep, - steps: flow.steps, - progress: flow.getProgress(), + // Core state from store + currentStep: store.getCurrentStep(), + steps: store.steps, + data: store.data, + progress: store.getProgress(), + isLoading: store.isLoading, + error: store.error, - // State from data management - data: data.data, - allStepData: data.getAllStepData(), + // User context + user, + currentTenant, - // State from individual hooks + // Step data helpers + stepData: { + setup: store.getStepData('setup'), + 'smart-inventory-setup': store.getStepData('smart-inventory-setup'), + suppliers: store.getStepData('suppliers'), + 'ml-training': store.getStepData('ml-training'), + completion: store.getStepData('completion'), + }, + + // Service states (for components that need detailed service info) tenantCreation: { isLoading: tenantCreation.isLoading, isSuccess: tenantCreation.isSuccess, + error: tenantCreation.error, + tenantData: tenantCreation.tenantData, }, + salesProcessing: { + isLoading: salesProcessing.isLoading, + error: salesProcessing.error, stage: salesProcessing.stage, progress: salesProcessing.progress, currentMessage: salesProcessing.currentMessage, validationResults: salesProcessing.validationResults, suggestions: salesProcessing.suggestions, }, + inventorySetup: { + isLoading: inventorySetup.isLoading, + error: inventorySetup.error, createdItems: inventorySetup.createdItems, inventoryMapping: inventorySetup.inventoryMapping, salesImportResult: inventorySetup.salesImportResult, isInventoryConfigured: inventorySetup.isInventoryConfigured, }, + trainingOrchestration: { + isLoading: trainingOrchestration.isLoading, + error: trainingOrchestration.error, status: trainingOrchestration.status, progress: trainingOrchestration.progress, currentStep: trainingOrchestration.currentStep, @@ -285,27 +90,55 @@ export const useOnboarding = () => { logs: trainingOrchestration.logs, metrics: trainingOrchestration.metrics, }, + + progressTracking: { + isLoading: progressTracking.isLoading, + error: progressTracking.error, + progress: progressTracking.progress, + isCompleted: progressTracking.isCompleted, + completionPercentage: progressTracking.completionPercentage, + isInitialized: progressTracking.isInitialized, + currentBackendStep: progressTracking.currentBackendStep, + }, + + resumeLogic: { + isCheckingResume: resumeLogic.isCheckingResume, + resumePoint: resumeLogic.resumePoint, + shouldResume: resumeLogic.shouldResume, + progress: resumeLogic.progress, + isCompleted: resumeLogic.isCompleted, + completionPercentage: resumeLogic.completionPercentage, + }, - // Overall state - isLoading, - error, - user, - currentTenant, - - // Actions - nextStep, - previousStep, - goToStep, - updateStepData, - validateCurrentStep, - createTenant, - processSalesFile, - generateInventorySuggestions, - createInventoryFromSuggestions, - importSalesData, - startTraining, - completeOnboarding, - clearError, - reset, + // Actions from the core actions hook + nextStep: actions.nextStep, + previousStep: actions.previousStep, + goToStep: actions.goToStep, + validateCurrentStep: actions.validateCurrentStep, + + // Step data management + updateStepData: store.setStepData, + clearStepData: store.clearStepData, + + // Step-specific actions + createTenant: actions.createTenant, + processSalesFile: actions.processSalesFile, + createInventoryFromSuggestions: actions.createInventoryFromSuggestions, + importSalesData: actions.importSalesData, + startTraining: actions.startTraining, + completeOnboarding: actions.completeOnboarding, + + // Service-specific actions (for components that need direct service access) + generateSuggestions: salesProcessing.generateSuggestions, + addTrainingLog: trainingOrchestration.addLog, + validateTrainingData: trainingOrchestration.validateTrainingData, + + // Resume actions + checkForSavedProgress: resumeLogic.checkForResume, + resumeFromSavedProgress: resumeLogic.resumeFlow, + + // Utility actions + clearError: actions.clearError, + reset: actions.reset, }; }; \ No newline at end of file diff --git a/frontend/src/hooks/business/onboarding/useOnboardingData.ts b/frontend/src/hooks/business/onboarding/useOnboardingData.ts deleted file mode 100644 index abe011d8..00000000 --- a/frontend/src/hooks/business/onboarding/useOnboardingData.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * Data persistence and validation for onboarding - */ - -import { useState, useCallback } from 'react'; -import { getStepById } from './steps'; -import type { OnboardingData, OnboardingError, StepValidator } from './types'; - -interface OnboardingDataState { - data: OnboardingData; - error: OnboardingError | null; -} - -interface OnboardingDataActions { - updateStepData: (stepId: string, stepData: Partial) => void; - validateStep: (stepId: string) => string | null; - clearError: () => void; - resetData: () => void; - getStepData: (stepId: string) => any; - setAllStepData: (allData: { [stepId: string]: any }) => void; - getAllStepData: () => { [stepId: string]: any }; -} - -export const useOnboardingData = () => { - const [state, setState] = useState({ - data: { - allStepData: {}, - }, - error: null, - }); - - const updateStepData = useCallback((stepId: string, stepData: Partial) => { - setState(prev => ({ - ...prev, - data: { - ...prev.data, - ...stepData, - // Also store in allStepData for cross-step access - allStepData: { - ...prev.data.allStepData, - [stepId]: { - ...prev.data.allStepData?.[stepId], - ...stepData, - }, - }, - }, - error: null, // Clear error when data is updated successfully - })); - }, []); - - const validateStep = useCallback((stepId: string): string | null => { - const step = getStepById(stepId); - if (step?.validation) { - try { - const validationResult = step.validation(state.data); - if (validationResult) { - setState(prev => ({ - ...prev, - error: { - step: stepId, - message: validationResult, - }, - })); - return validationResult; - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Validation error'; - setState(prev => ({ - ...prev, - error: { - step: stepId, - message: errorMessage, - details: error, - }, - })); - return errorMessage; - } - } - return null; - }, [state.data]); - - const clearError = useCallback(() => { - setState(prev => ({ - ...prev, - error: null, - })); - }, []); - - const resetData = useCallback(() => { - setState({ - data: { - allStepData: {}, - }, - error: null, - }); - }, []); - - const getStepData = useCallback((stepId: string): any => { - return state.data.allStepData?.[stepId] || {}; - }, [state.data.allStepData]); - - const setAllStepData = useCallback((allData: { [stepId: string]: any }) => { - setState(prev => ({ - ...prev, - data: { - ...prev.data, - allStepData: allData, - }, - })); - }, []); - - const getAllStepData = useCallback((): { [stepId: string]: any } => { - return state.data.allStepData || {}; - }, [state.data.allStepData]); - - return { - // State - data: state.data, - error: state.error, - - // Actions - updateStepData, - validateStep, - clearError, - resetData, - getStepData, - setAllStepData, - getAllStepData, - } satisfies OnboardingDataState & OnboardingDataActions; -}; \ No newline at end of file diff --git a/frontend/src/hooks/business/onboarding/useOnboardingFlow.ts b/frontend/src/hooks/business/onboarding/useOnboardingFlow.ts deleted file mode 100644 index d15c9959..00000000 --- a/frontend/src/hooks/business/onboarding/useOnboardingFlow.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Navigation and step management for onboarding - */ - -import { useState, useCallback } from 'react'; -import { DEFAULT_STEPS, canAccessStep, calculateProgress } from './steps'; -import type { OnboardingStep, OnboardingProgress } from './types'; - -interface OnboardingFlowState { - currentStep: number; - steps: OnboardingStep[]; -} - -interface OnboardingFlowActions { - nextStep: () => boolean; - previousStep: () => boolean; - goToStep: (stepIndex: number) => boolean; - getCurrentStep: () => OnboardingStep; - getProgress: () => OnboardingProgress; - canNavigateToStep: (stepIndex: number) => boolean; - markStepCompleted: (stepIndex: number) => void; - resetFlow: () => void; -} - -export const useOnboardingFlow = () => { - const [state, setState] = useState({ - currentStep: 0, - steps: DEFAULT_STEPS.map(step => ({ ...step })), // Create a copy - }); - - const nextStep = useCallback((): boolean => { - if (state.currentStep < state.steps.length - 1) { - setState(prev => ({ - ...prev, - currentStep: prev.currentStep + 1, - })); - return true; - } - return false; - }, [state.currentStep, state.steps.length]); - - const previousStep = useCallback((): boolean => { - if (state.currentStep > 0) { - setState(prev => ({ - ...prev, - currentStep: prev.currentStep - 1, - })); - return true; - } - return false; - }, [state.currentStep]); - - const goToStep = useCallback((stepIndex: number): boolean => { - if (stepIndex >= 0 && stepIndex < state.steps.length) { - const completedSteps = state.steps.map(step => step.isCompleted); - if (canAccessStep(stepIndex, completedSteps)) { - setState(prev => ({ - ...prev, - currentStep: stepIndex, - })); - return true; - } - } - return false; - }, [state.steps]); - - const getCurrentStep = useCallback((): OnboardingStep => { - return state.steps[state.currentStep]; - }, [state.currentStep, state.steps]); - - const getProgress = useCallback((): OnboardingProgress => { - const completedSteps = state.steps.map(step => step.isCompleted); - const progress = calculateProgress(completedSteps); - - return { - currentStep: state.currentStep, - totalSteps: state.steps.length, - completedSteps: progress.completedCount, - isComplete: progress.completedCount === progress.totalRequired, - progressPercentage: progress.percentage, - }; - }, [state.currentStep, state.steps]); - - const canNavigateToStep = useCallback((stepIndex: number): boolean => { - const completedSteps = state.steps.map(step => step.isCompleted); - return canAccessStep(stepIndex, completedSteps); - }, [state.steps]); - - const markStepCompleted = useCallback((stepIndex: number): void => { - setState(prev => ({ - ...prev, - steps: prev.steps.map((step, index) => - index === stepIndex - ? { ...step, isCompleted: true } - : step - ), - })); - }, []); - - const resetFlow = useCallback((): void => { - setState({ - currentStep: 0, - steps: DEFAULT_STEPS.map(step => ({ ...step, isCompleted: false })), - }); - }, []); - - return { - // State - currentStep: state.currentStep, - steps: state.steps, - - // Actions - nextStep, - previousStep, - goToStep, - getCurrentStep, - getProgress, - canNavigateToStep, - markStepCompleted, - resetFlow, - } satisfies OnboardingFlowState & OnboardingFlowActions; -}; \ No newline at end of file diff --git a/frontend/src/hooks/business/onboarding/useTenantCreation.ts b/frontend/src/hooks/business/onboarding/useTenantCreation.ts deleted file mode 100644 index e1c19c19..00000000 --- a/frontend/src/hooks/business/onboarding/useTenantCreation.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Tenant creation hook for bakery registration - */ - -import { useState, useCallback } from 'react'; -import { useRegisterBakery } from '../../../api'; -import type { BakeryRegistration, TenantResponse } from '../../../api'; -import { useTenantStore } from '../../../stores/tenant.store'; - -interface TenantCreationState { - isLoading: boolean; - error: string | null; - isSuccess: boolean; - tenantData: BakeryRegistration | null; -} - -interface TenantCreationActions { - createTenant: (bakeryData: BakeryRegistration) => Promise; - clearError: () => void; - reset: () => void; -} - -export const useTenantCreation = () => { - const [state, setState] = useState({ - isLoading: false, - error: null, - isSuccess: false, - tenantData: null, - }); - - const registerBakeryMutation = useRegisterBakery(); - const { setCurrentTenant, loadUserTenants } = useTenantStore(); - - const createTenant = useCallback(async (bakeryData: BakeryRegistration): Promise => { - if (!bakeryData) { - setState(prev => ({ - ...prev, - error: 'Los datos de la panadería son requeridos', - })); - return false; - } - - setState(prev => ({ - ...prev, - isLoading: true, - error: null, - isSuccess: false - })); - - try { - const tenantResponse: TenantResponse = await registerBakeryMutation.mutateAsync(bakeryData); - - // Update the tenant store with the newly created tenant - console.log('useTenantCreation - Setting current tenant:', tenantResponse); - setCurrentTenant(tenantResponse); - - // Reload user tenants to ensure the list is up to date - await loadUserTenants(); - - setState(prev => ({ - ...prev, - isLoading: false, - isSuccess: true, - tenantData: bakeryData, - })); - - console.log('useTenantCreation - Tenant created and set successfully'); - return true; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Error al crear la panadería'; - setState(prev => ({ - ...prev, - isLoading: false, - error: errorMessage, - isSuccess: false, - })); - return false; - } - }, [registerBakeryMutation]); - - const clearError = useCallback(() => { - setState(prev => ({ ...prev, error: null })); - }, []); - - const reset = useCallback(() => { - setState({ - isLoading: false, - error: null, - isSuccess: false, - tenantData: null, - }); - }, []); - - return { - // State - isLoading: state.isLoading, - error: state.error, - isSuccess: state.isSuccess, - tenantData: state.tenantData, - - // Actions - createTenant, - clearError, - reset, - } satisfies TenantCreationState & TenantCreationActions; -}; \ No newline at end of file diff --git a/frontend/src/hooks/business/onboarding/useTrainingOrchestration.ts b/frontend/src/hooks/business/onboarding/useTrainingOrchestration.ts deleted file mode 100644 index 1869e4e1..00000000 --- a/frontend/src/hooks/business/onboarding/useTrainingOrchestration.ts +++ /dev/null @@ -1,302 +0,0 @@ -/** - * ML model training orchestration hook - */ - -import { useState, useCallback, useEffect } from 'react'; -import { - useCreateTrainingJob, - useTrainingJobStatus, - useTrainingWebSocket, -} from '../../../api'; -import { useCurrentTenant } from '../../../stores'; -import { useAuthUser } from '../../../stores/auth.store'; -import type { TrainingJobResponse, TrainingMetrics } from '../../../api'; - -interface TrainingOrchestrationState { - isLoading: boolean; - error: string | null; - status: 'idle' | 'validating' | 'training' | 'completed' | 'failed'; - progress: number; - currentStep: string; - estimatedTimeRemaining: number; - job: TrainingJobResponse | null; - logs: TrainingLog[]; - metrics: TrainingMetrics | null; -} - -interface TrainingLog { - timestamp: string; - message: string; - level: 'info' | 'warning' | 'error' | 'success'; -} - -interface TrainingOrchestrationActions { - startTraining: (options?: { - products?: string[]; - startDate?: string; - endDate?: string; - }) => Promise; - validateTrainingData: (allStepData: any) => Promise<{ - isValid: boolean; - missingItems: string[]; - }>; - clearError: () => void; - reset: () => void; - addLog: (message: string, level?: TrainingLog['level']) => void; -} - -export const useTrainingOrchestration = () => { - const [state, setState] = useState({ - isLoading: false, - error: null, - status: 'idle', - progress: 0, - currentStep: '', - estimatedTimeRemaining: 0, - job: null, - logs: [], - metrics: null, - }); - - const currentTenant = useCurrentTenant(); - const user = useAuthUser(); - const createTrainingJobMutation = useCreateTrainingJob(); - - // Get job status when we have a job ID - const { data: jobStatus } = useTrainingJobStatus( - currentTenant?.id || '', - state.job?.job_id || '', - { - enabled: !!currentTenant?.id && !!state.job?.job_id && state.status === 'training', - refetchInterval: 5000, - } - ); - - // WebSocket for real-time updates - const { data: wsData } = useTrainingWebSocket( - currentTenant?.id || '', - state.job?.job_id || '', - user?.token, - { - onProgress: (data) => { - setState(prev => ({ - ...prev, - progress: data.progress?.percentage || prev.progress, - currentStep: data.progress?.current_step || prev.currentStep, - estimatedTimeRemaining: data.progress?.estimated_time_remaining || prev.estimatedTimeRemaining, - })); - addLog( - `${data.progress?.current_step} - ${data.progress?.products_completed}/${data.progress?.products_total} productos procesados (${data.progress?.percentage}%)`, - 'info' - ); - }, - onCompleted: (data) => { - setState(prev => ({ - ...prev, - status: 'completed', - progress: 100, - metrics: { - accuracy: data.results.performance_metrics.accuracy, - mape: data.results.performance_metrics.mape, - mae: data.results.performance_metrics.mae, - rmse: data.results.performance_metrics.rmse, - }, - })); - addLog('¡Entrenamiento ML completado exitosamente!', 'success'); - addLog(`${data.results.successful_trainings} modelos creados exitosamente`, 'success'); - addLog(`Duración total: ${Math.round(data.results.training_duration / 60)} minutos`, 'info'); - }, - onError: (data) => { - setState(prev => ({ - ...prev, - status: 'failed', - error: data.error, - })); - addLog(`Error en entrenamiento: ${data.error}`, 'error'); - }, - onStarted: (data) => { - addLog(`Trabajo de entrenamiento iniciado: ${data.job_id}`, 'success'); - }, - } - ); - - // Update status from polling when WebSocket is not available - useEffect(() => { - if (jobStatus && state.job?.job_id === jobStatus.job_id) { - setState(prev => ({ - ...prev, - status: jobStatus.status as any, - progress: jobStatus.progress || prev.progress, - currentStep: jobStatus.current_step || prev.currentStep, - estimatedTimeRemaining: jobStatus.estimated_time_remaining || prev.estimatedTimeRemaining, - })); - } - }, [jobStatus, state.job?.job_id]); - - const addLog = useCallback((message: string, level: TrainingLog['level'] = 'info') => { - const newLog: TrainingLog = { - timestamp: new Date().toISOString(), - message, - level - }; - setState(prev => ({ - ...prev, - logs: [...prev.logs, newLog] - })); - }, []); - - const validateTrainingData = useCallback(async (allStepData: any): Promise<{ - isValid: boolean; - missingItems: string[]; - }> => { - const missingItems: string[] = []; - - // Get data from previous steps - const salesValidationData = allStepData?.['sales-validation']; - const inventoryData = allStepData?.['inventory']; - - // Check if sales data was processed - const hasProcessingResults = salesValidationData?.processingResults && - salesValidationData.processingResults.is_valid && - salesValidationData.processingResults.total_records > 0; - - // Check if sales data was imported (required for training) - const hasImportResults = inventoryData?.salesImportResult && - (inventoryData.salesImportResult.records_created > 0 || - inventoryData.salesImportResult.success === true || - inventoryData.salesImportResult.imported === true); - - if (!hasProcessingResults) { - missingItems.push('Datos de ventas validados'); - } - - // Sales data must be imported for ML training to work - if (!hasImportResults) { - missingItems.push('Datos de ventas importados'); - } - - // Check if products were approved in sales validation step - const hasApprovedProducts = salesValidationData?.approvedProducts && - salesValidationData.approvedProducts.length > 0 && - salesValidationData.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 (salesValidationData?.processingResults?.total_records && - salesValidationData.processingResults.total_records < 10) { - missingItems.push('Suficientes registros de ventas (mínimo 10)'); - } - - return { - isValid: missingItems.length === 0, - missingItems - }; - }, []); - - const startTraining = useCallback(async (options?: { - products?: string[]; - startDate?: string; - endDate?: string; - }): Promise => { - if (!currentTenant?.id) { - setState(prev => ({ - ...prev, - error: 'No se pudo obtener información del tenant', - })); - return false; - } - - setState(prev => ({ - ...prev, - isLoading: true, - error: null, - status: 'validating', - progress: 0, - })); - - addLog('Validando disponibilidad de datos...', 'info'); - - try { - // Start training job - addLog('Iniciando trabajo de entrenamiento ML...', 'info'); - const job = await createTrainingJobMutation.mutateAsync({ - tenantId: currentTenant.id, - request: { - products: options?.products, - start_date: options?.startDate, - end_date: options?.endDate, - } - }); - - setState(prev => ({ - ...prev, - job, - status: 'training', - isLoading: false, - })); - - addLog(`Trabajo de entrenamiento iniciado: ${job.job_id}`, 'success'); - return true; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Error al iniciar entrenamiento'; - setState(prev => ({ - ...prev, - isLoading: false, - error: errorMessage, - status: 'failed', - })); - addLog(`Error: ${errorMessage}`, 'error'); - return false; - } - }, [currentTenant, createTrainingJobMutation, addLog]); - - const clearError = useCallback(() => { - setState(prev => ({ ...prev, error: null })); - }, []); - - const reset = useCallback(() => { - setState({ - isLoading: false, - error: null, - status: 'idle', - progress: 0, - currentStep: '', - estimatedTimeRemaining: 0, - job: null, - logs: [], - metrics: null, - }); - }, []); - - return { - // State - isLoading: state.isLoading, - error: state.error, - status: state.status, - progress: state.progress, - currentStep: state.currentStep, - estimatedTimeRemaining: state.estimatedTimeRemaining, - job: state.job, - logs: state.logs, - metrics: state.metrics, - - // Actions - startTraining, - validateTrainingData, - clearError, - reset, - addLog, - } satisfies TrainingOrchestrationState & TrainingOrchestrationActions; -}; \ No newline at end of file diff --git a/frontend/src/hooks/business/onboarding/utils/createServiceHook.ts b/frontend/src/hooks/business/onboarding/utils/createServiceHook.ts new file mode 100644 index 00000000..c2d5d5b4 --- /dev/null +++ b/frontend/src/hooks/business/onboarding/utils/createServiceHook.ts @@ -0,0 +1,103 @@ +/** + * Universal service hook factory - creates standardized service hooks + * This eliminates all the duplicate patterns across service hooks + */ + +import { useState, useCallback } from 'react'; +import type { ServiceState, ServiceActions } from '../core/types'; + +export interface ServiceHookConfig { + initialState?: Partial; + onSuccess?: (data: any, state: T) => T; + onError?: (error: string, state: T) => T; + resetState?: () => T; +} + +export function createServiceHook( + config: ServiceHookConfig = {} +) { + return function useService() { + const defaultState: ServiceState = { + data: null, + isLoading: false, + error: null, + isSuccess: false, + }; + + const [state, setState] = useState({ + ...defaultState, + ...config.initialState, + } as TState); + + const setLoading = useCallback((loading: boolean) => { + setState(prev => ({ ...prev, isLoading: loading })); + }, []); + + const setError = useCallback((error: string | null) => { + setState(prev => ({ + ...prev, + error, + isLoading: false, + isSuccess: false + })); + }, []); + + const setSuccess = useCallback((data: any) => { + setState(prev => { + const newState = { + ...prev, + data, + isLoading: false, + error: null, + isSuccess: true + }; + return config.onSuccess ? config.onSuccess(data, newState) : newState; + }); + }, []); + + const clearError = useCallback(() => { + setState(prev => ({ ...prev, error: null })); + }, []); + + const reset = useCallback(() => { + if (config.resetState) { + setState(config.resetState()); + } else { + setState({ + ...defaultState, + ...config.initialState, + } as TState); + } + }, []); + + const executeAsync = useCallback(async ( + asyncFn: () => Promise + ): Promise<{ success: boolean; data?: T; error?: string }> => { + try { + setLoading(true); + const result = await asyncFn(); + setSuccess(result); + return { success: true, data: result }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + setError(errorMessage); + return { success: false, error: errorMessage }; + } + }, [setLoading, setSuccess, setError]); + + return { + // State + ...state, + + // Core actions + setLoading, + setError, + setSuccess, + clearError, + reset, + + // Async execution helper + executeAsync, + }; + }; +} \ No newline at end of file diff --git a/frontend/src/pages/app/onboarding/OnboardingPage.tsx b/frontend/src/pages/app/onboarding/OnboardingPage.tsx index 8933ebde..82de59c7 100644 --- a/frontend/src/pages/app/onboarding/OnboardingPage.tsx +++ b/frontend/src/pages/app/onboarding/OnboardingPage.tsx @@ -1,14 +1,13 @@ import React, { useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { OnboardingWizard, OnboardingStep } from '../../../components/domain/onboarding/OnboardingWizard'; -import { useOnboarding } from '../../../hooks/business/onboarding'; +import { useOnboarding, useAutoResume } from '../../../hooks/business/onboarding'; import { useAuthUser, useIsAuthenticated } from '../../../stores/auth.store'; import { LoadingSpinner } from '../../../components/shared/LoadingSpinner'; // Step Components import { BakerySetupStep } from '../../../components/domain/onboarding/steps/BakerySetupStep'; -import { HistoricalSalesValidationStep } from '../../../components/domain/onboarding/steps/HistoricalSalesValidationStep'; -import { InventorySetupStep } from '../../../components/domain/onboarding/steps/InventorySetupStep'; +import { SmartInventorySetupStep } from '../../../components/domain/onboarding/steps/SmartInventorySetupStep'; import { SuppliersStep } from '../../../components/domain/onboarding/steps/SuppliersStep'; import { MLTrainingStep } from '../../../components/domain/onboarding/steps/MLTrainingStep'; import { CompletionStep } from '../../../components/domain/onboarding/steps/CompletionStep'; @@ -18,6 +17,9 @@ const OnboardingPage: React.FC = () => { const user = useAuthUser(); const isAuthenticated = useIsAuthenticated(); + // Use auto-resume functionality + const autoResume = useAutoResume(); + // Use the onboarding business hook const { currentStep, @@ -42,8 +44,7 @@ const OnboardingPage: React.FC = () => { // Map steps to components const stepComponents: { [key: string]: React.ComponentType } = { 'setup': BakerySetupStep, - 'sales-validation': HistoricalSalesValidationStep, - 'inventory': InventorySetupStep, + 'smart-inventory-setup': SmartInventorySetupStep, 'suppliers': SuppliersStep, 'ml-training': MLTrainingStep, 'completion': CompletionStep @@ -104,11 +105,15 @@ const OnboardingPage: React.FC = () => { }; }, [error, clearError]); - // Show loading while processing - if (isLoading) { + // Show loading while processing or checking for saved progress + if (isLoading || autoResume.isCheckingResume) { + const message = autoResume.isCheckingResume + ? "Verificando progreso guardado..." + : "Procesando..."; + return (
- +
); } diff --git a/services/auth/app/api/onboarding.py b/services/auth/app/api/onboarding.py index 5990c778..c46cd88f 100644 --- a/services/auth/app/api/onboarding.py +++ b/services/auth/app/api/onboarding.py @@ -38,20 +38,22 @@ class UpdateStepRequest(BaseModel): completed: bool data: Optional[Dict[str, Any]] = None -# Define the onboarding steps and their order +# Define the onboarding steps and their order - matching frontend step IDs ONBOARDING_STEPS = [ - "user_registered", # Step 1: User account created - "bakery_registered", # Step 2: Bakery/tenant created - "sales_data_uploaded", # Step 3: Historical sales data uploaded - "training_completed", # Step 4: AI model training completed - "dashboard_accessible" # Step 5: Ready to use dashboard + "user_registered", # Auto-completed: User account created + "setup", # Step 1: Basic bakery setup and tenant creation + "smart-inventory-setup", # Step 2: Sales data upload and inventory configuration + "suppliers", # Step 3: Suppliers configuration (optional) + "ml-training", # Step 4: AI model training + "completion" # Step 5: Onboarding completed, ready to use dashboard ] STEP_DEPENDENCIES = { - "bakery_registered": ["user_registered"], - "sales_data_uploaded": ["user_registered", "bakery_registered"], - "training_completed": ["user_registered", "bakery_registered", "sales_data_uploaded"], - "dashboard_accessible": ["user_registered", "bakery_registered", "sales_data_uploaded", "training_completed"] + "setup": ["user_registered"], + "smart-inventory-setup": ["user_registered", "setup"], + "suppliers": ["user_registered", "setup", "smart-inventory-setup"], # Optional step + "ml-training": ["user_registered", "setup", "smart-inventory-setup"], + "completion": ["user_registered", "setup", "smart-inventory-setup", "ml-training"] } class OnboardingService: @@ -216,6 +218,30 @@ class OnboardingService: if not user_progress_data.get(required_step, {}).get("completed", False): return False + # SPECIAL VALIDATION FOR ML TRAINING STEP + if step_name == "ml-training": + # Ensure that smart-inventory-setup was completed with sales data imported + smart_inventory_data = user_progress_data.get("smart-inventory-setup", {}).get("data", {}) + + # Check if sales data was imported successfully + sales_import_result = smart_inventory_data.get("salesImportResult", {}) + has_sales_data_imported = ( + sales_import_result.get("records_created", 0) > 0 or + sales_import_result.get("success", False) or + sales_import_result.get("imported", False) + ) + + if not has_sales_data_imported: + logger.warning(f"ML training blocked for user {user_id}: No sales data imported", + extra={"sales_import_result": sales_import_result}) + return False + + # Also check if inventory is configured + inventory_configured = smart_inventory_data.get("inventoryConfigured", False) + if not inventory_configured: + logger.warning(f"ML training blocked for user {user_id}: Inventory not configured") + return False + return True async def _get_user_onboarding_data(self, user_id: str) -> Dict[str, Any]: diff --git a/services/inventory/Dockerfile b/services/inventory/Dockerfile index a8283a25..80795454 100644 --- a/services/inventory/Dockerfile +++ b/services/inventory/Dockerfile @@ -25,9 +25,9 @@ ENV PYTHONPATH=/app # Expose port EXPOSE 8000 -# Health check +# Health check HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ - CMD python -c "import requests; requests.get('http://localhost:8000/health', timeout=5)" || exit 1 + CMD python -c "import requests; requests.get('http://localhost:8000/health/', timeout=5)" || exit 1 # Run the application CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/services/inventory/app/services/inventory_alert_service.py b/services/inventory/app/services/inventory_alert_service.py index 0d582c9e..b515fd20 100644 --- a/services/inventory/app/services/inventory_alert_service.py +++ b/services/inventory/app/services/inventory_alert_service.py @@ -79,22 +79,25 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin): query = """ WITH stock_analysis AS ( SELECT - i.*, - COALESCE(p.scheduled_quantity, 0) as tomorrow_needed, - COALESCE(s.avg_daily_usage, 0) as avg_daily_usage, - COALESCE(s.lead_time_days, 7) as lead_time_days, + i.id, i.name, i.tenant_id, + COALESCE(SUM(s.current_quantity), 0) as current_stock, + i.low_stock_threshold as minimum_stock, + i.max_stock_level as maximum_stock, + i.reorder_point, + 0 as tomorrow_needed, + 0 as avg_daily_usage, + 7 as lead_time_days, CASE - WHEN i.current_stock < i.minimum_stock THEN 'critical' - WHEN i.current_stock < i.minimum_stock * 1.2 THEN 'low' - WHEN i.current_stock > i.maximum_stock THEN 'overstock' + WHEN COALESCE(SUM(s.current_quantity), 0) < i.low_stock_threshold THEN 'critical' + WHEN COALESCE(SUM(s.current_quantity), 0) < i.low_stock_threshold * 1.2 THEN 'low' + WHEN i.max_stock_level IS NOT NULL AND COALESCE(SUM(s.current_quantity), 0) > i.max_stock_level THEN 'overstock' ELSE 'normal' END as status, - GREATEST(0, i.minimum_stock - i.current_stock) as shortage_amount - FROM inventory_items i - LEFT JOIN production_schedule p ON p.ingredient_id = i.id - AND p.date = CURRENT_DATE + INTERVAL '1 day' - LEFT JOIN supplier_items s ON s.ingredient_id = i.id - WHERE i.tenant_id = $1 AND i.active = true + GREATEST(0, i.low_stock_threshold - COALESCE(SUM(s.current_quantity), 0)) as shortage_amount + FROM ingredients i + LEFT JOIN stock s ON s.ingredient_id = i.id AND s.is_available = true + WHERE i.tenant_id = $1 AND i.is_active = true + GROUP BY i.id, i.name, i.tenant_id, i.low_stock_threshold, i.max_stock_level, i.reorder_point ) SELECT * FROM stock_analysis WHERE status != 'normal' ORDER BY @@ -212,15 +215,16 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin): query = """ SELECT - i.id, i.name, i.current_stock, i.tenant_id, - b.id as batch_id, b.expiry_date, b.quantity, - EXTRACT(days FROM (b.expiry_date - CURRENT_DATE)) as days_to_expiry - FROM inventory_items i - JOIN inventory_batches b ON b.ingredient_id = i.id - WHERE b.expiry_date <= CURRENT_DATE + INTERVAL '7 days' - AND b.quantity > 0 - AND b.status = 'active' - ORDER BY b.expiry_date ASC + i.id, i.name, i.tenant_id, + s.id as stock_id, s.expiration_date, s.current_quantity, + EXTRACT(days FROM (s.expiration_date - CURRENT_DATE)) as days_to_expiry + FROM ingredients i + JOIN stock s ON s.ingredient_id = i.id + WHERE s.expiration_date <= CURRENT_DATE + INTERVAL '7 days' + AND s.current_quantity > 0 + AND s.is_available = true + AND s.expiration_date IS NOT NULL + ORDER BY s.expiration_date ASC """ from sqlalchemy import text @@ -275,8 +279,8 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin): { 'id': str(item['id']), 'name': item['name'], - 'batch_id': str(item['batch_id']), - 'quantity': float(item['quantity']), + 'stock_id': str(item['stock_id']), + 'quantity': float(item['current_quantity']), 'days_expired': abs(item['days_to_expiry']) } for item in expired ] @@ -294,9 +298,9 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin): 'actions': ['Usar inmediatamente', 'Promoción especial', 'Revisar recetas', 'Documentar'], 'metadata': { 'ingredient_id': str(item['id']), - 'batch_id': str(item['batch_id']), + 'stock_id': str(item['stock_id']), 'days_to_expiry': item['days_to_expiry'], - 'quantity': float(item['quantity']) + 'quantity': float(item['current_quantity']) } }, item_type='alert') @@ -312,14 +316,16 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin): query = """ SELECT - t.id, t.sensor_id, t.location, t.temperature, - t.max_threshold, t.tenant_id, - EXTRACT(minutes FROM (NOW() - t.first_breach_time)) as breach_duration_minutes - FROM temperature_readings t - WHERE t.temperature > t.max_threshold - AND t.breach_duration_minutes >= 30 -- Only after 30 minutes - AND t.last_alert_sent < NOW() - INTERVAL '15 minutes' -- Avoid spam - ORDER BY t.temperature DESC, t.breach_duration_minutes DESC + t.id, t.equipment_id as sensor_id, t.storage_location as location, + t.temperature_celsius as temperature, + t.target_temperature_max as max_threshold, t.tenant_id, + COALESCE(t.deviation_minutes, 0) as breach_duration_minutes + FROM temperature_logs t + WHERE t.temperature_celsius > COALESCE(t.target_temperature_max, 25) + AND NOT t.is_within_range + AND COALESCE(t.deviation_minutes, 0) >= 30 -- Only after 30 minutes + AND (t.recorded_at < NOW() - INTERVAL '15 minutes' OR t.alert_triggered = false) -- Avoid spam + ORDER BY t.temperature_celsius DESC, t.deviation_minutes DESC """ from sqlalchemy import text @@ -371,11 +377,14 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin): } }, item_type='alert') - # Update last alert sent time to avoid spam - await self.db_manager.execute( - "UPDATE temperature_readings SET last_alert_sent = NOW() WHERE id = $1", - breach['id'] - ) + # Update alert triggered flag to avoid spam + from sqlalchemy import text + async with self.db_manager.get_session() as session: + await session.execute( + text("UPDATE temperature_logs SET alert_triggered = true WHERE id = :id"), + {"id": breach['id']} + ) + await session.commit() except Exception as e: logger.error("Error processing temperature breach", @@ -391,24 +400,27 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin): query = """ WITH usage_analysis AS ( SELECT - i.id, i.name, i.tenant_id, i.minimum_stock, i.maximum_stock, - i.current_stock, - AVG(sm.quantity) FILTER (WHERE sm.movement_type = 'out' + i.id, i.name, i.tenant_id, + i.low_stock_threshold as minimum_stock, + i.max_stock_level as maximum_stock, + COALESCE(SUM(s.current_quantity), 0) as current_stock, + AVG(sm.quantity) FILTER (WHERE sm.movement_type = 'production_use' AND sm.created_at > CURRENT_DATE - INTERVAL '30 days') as avg_daily_usage, - COUNT(sm.id) FILTER (WHERE sm.movement_type = 'out' + COUNT(sm.id) FILTER (WHERE sm.movement_type = 'production_use' AND sm.created_at > CURRENT_DATE - INTERVAL '30 days') as usage_days, - MAX(sm.created_at) FILTER (WHERE sm.movement_type = 'out') as last_used - FROM inventory_items i + MAX(sm.created_at) FILTER (WHERE sm.movement_type = 'production_use') as last_used + FROM ingredients i + LEFT JOIN stock s ON s.ingredient_id = i.id AND s.is_available = true LEFT JOIN stock_movements sm ON sm.ingredient_id = i.id - WHERE i.active = true AND i.tenant_id = $1 - GROUP BY i.id - HAVING COUNT(sm.id) FILTER (WHERE sm.movement_type = 'out' - AND sm.created_at > CURRENT_DATE - INTERVAL '30 days') >= 5 + WHERE i.is_active = true AND i.tenant_id = $1 + GROUP BY i.id, i.name, i.tenant_id, i.low_stock_threshold, i.max_stock_level + HAVING COUNT(sm.id) FILTER (WHERE sm.movement_type = 'production_use' + AND sm.created_at > CURRENT_DATE - INTERVAL '30 days') >= 3 ), recommendations AS ( SELECT *, CASE - WHEN avg_daily_usage * 7 > maximum_stock THEN 'increase_max' + WHEN avg_daily_usage * 7 > maximum_stock AND maximum_stock IS NOT NULL THEN 'increase_max' WHEN avg_daily_usage * 3 < minimum_stock THEN 'decrease_min' WHEN current_stock / NULLIF(avg_daily_usage, 0) > 14 THEN 'reduce_stock' WHEN avg_daily_usage > 0 AND minimum_stock / avg_daily_usage < 3 THEN 'increase_min' @@ -500,20 +512,21 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin): async def generate_waste_reduction_recommendations(self): """Generate waste reduction recommendations""" try: - # Analyze waste patterns + # Analyze waste patterns from stock movements query = """ SELECT i.id, i.name, i.tenant_id, - SUM(w.quantity) as total_waste_30d, - COUNT(w.id) as waste_incidents, - AVG(w.quantity) as avg_waste_per_incident, - w.waste_reason - FROM inventory_items i - JOIN waste_logs w ON w.ingredient_id = i.id - WHERE w.created_at > CURRENT_DATE - INTERVAL '30 days' + SUM(sm.quantity) as total_waste_30d, + COUNT(sm.id) as waste_incidents, + AVG(sm.quantity) as avg_waste_per_incident, + COALESCE(sm.reason_code, 'unknown') as waste_reason + FROM ingredients i + JOIN stock_movements sm ON sm.ingredient_id = i.id + WHERE sm.movement_type = 'waste' + AND sm.created_at > CURRENT_DATE - INTERVAL '30 days' AND i.tenant_id = $1 - GROUP BY i.id, w.waste_reason - HAVING SUM(w.quantity) > 5 -- More than 5kg wasted + GROUP BY i.id, i.name, i.tenant_id, sm.reason_code + HAVING SUM(sm.quantity) > 5 -- More than 5kg wasted ORDER BY total_waste_30d DESC """ @@ -694,10 +707,14 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin): """Get stock information after hypothetical order""" try: query = """ - SELECT id, name, current_stock, minimum_stock, - (current_stock - $2) as remaining - FROM inventory_items - WHERE id = $1 + SELECT i.id, i.name, + COALESCE(SUM(s.current_quantity), 0) as current_stock, + i.low_stock_threshold as minimum_stock, + (COALESCE(SUM(s.current_quantity), 0) - $2) as remaining + FROM ingredients i + LEFT JOIN stock s ON s.ingredient_id = i.id AND s.is_available = true + WHERE i.id = $1 + GROUP BY i.id, i.name, i.low_stock_threshold """ result = await self.db_manager.fetchrow(query, ingredient_id, order_quantity)