diff --git a/frontend/src/components/domain/onboarding/OnboardingWizard.tsx b/frontend/src/components/domain/onboarding/OnboardingWizard.tsx index 0d5d37c6..05f0caa9 100644 --- a/frontend/src/components/domain/onboarding/OnboardingWizard.tsx +++ b/frontend/src/components/domain/onboarding/OnboardingWizard.tsx @@ -46,13 +46,11 @@ export const OnboardingWizard: React.FC = ({ const currentStep = steps[currentStepIndex]; const updateStepData = useCallback((stepId: string, data: any) => { - console.log(`OnboardingWizard - Updating step '${stepId}' with data:`, data); setStepData(prev => { const newStepData = { ...prev, [stepId]: { ...prev[stepId], ...data } }; - console.log(`OnboardingWizard - Full step data after update:`, newStepData); return newStepData; }); @@ -420,7 +418,6 @@ export const OnboardingWizard: React.FC = ({ allStepData: stepData }} onDataChange={(data) => { - console.log(`OnboardingWizard - Step ${currentStep.id} calling onDataChange with:`, data); updateStepData(currentStep.id, data); }} onNext={goToNextStep} diff --git a/frontend/src/components/domain/onboarding/steps/CompletionStep.tsx b/frontend/src/components/domain/onboarding/steps/CompletionStep.tsx index 0c4976c7..6f97f35f 100644 --- a/frontend/src/components/domain/onboarding/steps/CompletionStep.tsx +++ b/frontend/src/components/domain/onboarding/steps/CompletionStep.tsx @@ -2,7 +2,9 @@ import React, { useState, useEffect } from 'react'; import { CheckCircle, Star, Rocket, Gift, Download, Share2, ArrowRight, Calendar } from 'lucide-react'; import { Button, Card, Badge } from '../../../ui'; import { OnboardingStepProps } from '../OnboardingWizard'; -import { onboardingApiService } from '../../../../services/api/onboarding.service'; +import { useInventory } from '../../../../hooks/api/useInventory'; +import { useModal } from '../../../../hooks/ui/useModal'; +import { useToast } from '../../../../hooks/ui/useToast'; import { useAuthUser } from '../../../../stores/auth.store'; import { useAlertActions } from '../../../../stores/alerts.store'; @@ -27,6 +29,12 @@ export const CompletionStep: React.FC = ({ }) => { const user = useAuthUser(); const { createAlert } = useAlertActions(); + const { showToast } = useToast(); + const { createInventoryFromSuggestions, isLoading: inventoryLoading } = useInventory(); + const certificateModal = useModal(); + const demoModal = useModal(); + const shareModal = useModal(); + const [showConfetti, setShowConfetti] = useState(false); const [completionStats, setCompletionStats] = useState(null); const [isImportingSales, setIsImportingSales] = useState(false); @@ -47,10 +55,10 @@ export const CompletionStep: React.FC = ({ setIsImportingSales(true); try { - const result = await onboardingApiService.importSalesWithInventory( - user.tenant_id, - data.files.salesData, - data.inventoryMapping + // Sales data should already be imported during DataProcessingStep + // Just create inventory items from approved suggestions + const result = await createInventoryFromSuggestions( + data.approvedSuggestions || [] ); createAlert({ @@ -167,19 +175,28 @@ export const CompletionStep: React.FC = ({ }; console.log('Generating certificate:', certificateData); - alert(`🎓 Certificado generado para ${certificateData.bakeryName}\nPuntuación: ${certificateData.score}/100`); + certificateModal.openModal({ + title: '🎓 Certificado Generado', + message: `Certificado generado para ${certificateData.bakeryName}\nPuntuación: ${certificateData.score}/100` + }); }; const scheduleDemo = () => { // Mock demo scheduling - alert('📅 Te contactaremos pronto para agendar una demostración personalizada de las funcionalidades avanzadas.'); + demoModal.openModal({ + title: '📅 Demo Agendado', + message: 'Te contactaremos pronto para agendar una demostración personalizada de las funcionalidades avanzadas.' + }); }; const shareSuccess = () => { // Mock social sharing const shareText = `¡Acabo de completar la configuración de mi panadería inteligente con IA! 🥖🤖 Puntuación: ${completionStats?.completionScore}/100`; navigator.clipboard.writeText(shareText); - alert('✅ Texto copiado al portapapeles. ¡Compártelo en tus redes sociales!'); + shareModal.openModal({ + title: '✅ ¡Compartido!', + message: 'Texto copiado al portapapeles. ¡Compártelo en tus redes sociales!' + }); }; const quickStartActions = [ diff --git a/frontend/src/components/domain/onboarding/steps/DataProcessingStep.tsx b/frontend/src/components/domain/onboarding/steps/DataProcessingStep.tsx index cec8bbca..b3f82853 100644 --- a/frontend/src/components/domain/onboarding/steps/DataProcessingStep.tsx +++ b/frontend/src/components/domain/onboarding/steps/DataProcessingStep.tsx @@ -2,7 +2,12 @@ import React, { useState, useRef, useEffect } from 'react'; import { Upload, Brain, CheckCircle, AlertCircle, Download, FileText, Activity, TrendingUp } from 'lucide-react'; import { Button, Card, Badge } from '../../../ui'; import { OnboardingStepProps } from '../OnboardingWizard'; -import { onboardingApiService } from '../../../../services/api/onboarding.service'; +import { useInventory } from '../../../../hooks/api/useInventory'; +import { useSales } from '../../../../hooks/api/useSales'; +import { useModal } from '../../../../hooks/ui/useModal'; +import { useToast } from '../../../../hooks/ui/useToast'; +import { salesService } from '../../../../services/api/sales.service'; +import { inventoryService } from '../../../../services/api/inventory.service'; import { useAuthUser, useAuthLoading } from '../../../../stores/auth.store'; import { useCurrentTenant, useTenantLoading } from '../../../../stores/tenant.store'; import { useAlertActions } from '../../../../stores/alerts.store'; @@ -30,70 +35,90 @@ interface ProcessingResult { recommendations: string[]; } -// Real data processing service using backend APIs -const dataProcessingService = { - processFile: async ( - file: File, - tenantId: string, - onProgress: (progress: number, stage: string, message: string) => void - ) => { - try { - // Stage 1: Validate file with sales service - onProgress(20, 'validating', 'Validando estructura del archivo...'); - const validationResult = await onboardingApiService.validateOnboardingFile(tenantId, file); - - onProgress(40, 'validating', 'Verificando integridad de datos...'); - - if (!validationResult.is_valid) { - throw new Error('Archivo de datos inválido'); - } - - if (!validationResult.product_list || validationResult.product_list.length === 0) { - throw new Error('No se encontraron productos en el archivo'); - } - - // Stage 2: Generate AI suggestions with inventory service - onProgress(60, 'analyzing', 'Identificando productos únicos...'); - onProgress(80, 'analyzing', 'Analizando patrones de venta...'); - - console.log('DataProcessingStep - Calling generateInventorySuggestions with:', { - tenantId, - fileName: file.name, - productList: validationResult.product_list - }); - - const suggestionsResult = await onboardingApiService.generateInventorySuggestions( - tenantId, - file, - validationResult.product_list - ); - - console.log('DataProcessingStep - AI suggestions result:', suggestionsResult); - - onProgress(90, 'analyzing', 'Generando recomendaciones con IA...'); - onProgress(100, 'completed', 'Procesamiento completado'); - - // Combine results - const combinedResult = { - ...validationResult, - productsIdentified: suggestionsResult.total_products || validationResult.unique_products, - categoriesDetected: suggestionsResult.suggestions ? - new Set(suggestionsResult.suggestions.map(s => s.category)).size : 4, - businessModel: suggestionsResult.business_model_analysis?.model || 'production', - confidenceScore: suggestionsResult.high_confidence_count && suggestionsResult.total_products ? - Math.round((suggestionsResult.high_confidence_count / suggestionsResult.total_products) * 100) : 85, - recommendations: suggestionsResult.business_model_analysis?.recommendations || [], - aiSuggestions: suggestionsResult.suggestions || [] - }; - - console.log('DataProcessingStep - Combined result:', combinedResult); - console.log('DataProcessingStep - Combined result aiSuggestions:', combinedResult.aiSuggestions); - console.log('DataProcessingStep - Combined result aiSuggestions length:', combinedResult.aiSuggestions?.length); - return combinedResult; - } catch (error) { - console.error('Data processing error:', error); - throw error; +// Data processing utility function +const processDataFile = async ( + file: File, + onProgress: (progress: number, stage: string, message: string) => void, + validateSalesData: any, + generateInventorySuggestions: any +) => { + try { + // Stage 1: Validate file with sales service + onProgress(20, 'validating', 'Validando estructura del archivo...'); + const validationResult = await validateSalesData(file); + + onProgress(40, 'validating', 'Verificando integridad de datos...'); + + if (!validationResult.is_valid) { + throw new Error('Archivo de datos inválido'); } + + if (!validationResult.product_list || validationResult.product_list.length === 0) { + throw new Error('No se encontraron productos en el archivo'); + } + + // Stage 2: Store validation result for later import (after inventory setup) + onProgress(50, 'validating', 'Procesando datos identificados...'); + + // Stage 3: Generate AI suggestions with inventory service + onProgress(60, 'analyzing', 'Identificando productos únicos...'); + onProgress(80, 'analyzing', 'Analizando patrones de venta...'); + + console.log('DataProcessingStep - Validation result:', validationResult); + console.log('DataProcessingStep - Product list:', validationResult.product_list); + console.log('DataProcessingStep - Product list length:', validationResult.product_list?.length); + + // Extract product list from validation result + const productList = validationResult.product_list || []; + + console.log('DataProcessingStep - Generating AI suggestions with:', { + fileName: file.name, + productList: productList, + productListLength: productList.length + }); + + let suggestionsResult; + if (productList.length > 0) { + suggestionsResult = await generateInventorySuggestions(productList); + } else { + console.warn('DataProcessingStep - No products found, creating default suggestions'); + suggestionsResult = { + suggestions: [], + total_products: validationResult.unique_products || 0, + business_model_analysis: { + model: 'production' as const, + recommendations: [] + }, + high_confidence_count: 0 + }; + } + + console.log('DataProcessingStep - AI suggestions result:', suggestionsResult); + + onProgress(90, 'analyzing', 'Generando recomendaciones con IA...'); + onProgress(100, 'completed', 'Procesamiento completado'); + + // Combine results + const combinedResult = { + ...validationResult, + salesDataFile: file, // Store file for later import after inventory setup + productsIdentified: suggestionsResult.total_products || validationResult.unique_products, + categoriesDetected: suggestionsResult.suggestions ? + new Set(suggestionsResult.suggestions.map(s => s.category)).size : 4, + businessModel: suggestionsResult.business_model_analysis?.model || 'production', + confidenceScore: suggestionsResult.high_confidence_count && suggestionsResult.total_products ? + Math.round((suggestionsResult.high_confidence_count / suggestionsResult.total_products) * 100) : 85, + recommendations: suggestionsResult.business_model_analysis?.recommendations || [], + aiSuggestions: suggestionsResult.suggestions || [] + }; + + console.log('DataProcessingStep - Combined result:', combinedResult); + console.log('DataProcessingStep - Combined result aiSuggestions:', combinedResult.aiSuggestions); + console.log('DataProcessingStep - Combined result aiSuggestions length:', combinedResult.aiSuggestions?.length); + return combinedResult; + } catch (error) { + console.error('Data processing error:', error); + throw error; } }; @@ -111,6 +136,12 @@ export const DataProcessingStep: React.FC = ({ const tenantLoading = useTenantLoading(); const { createAlert } = useAlertActions(); + // Use hooks for UI and direct service calls for now (until we extend hooks) + const { isLoading: inventoryLoading } = useInventory(); + const { isLoading: salesLoading } = useSales(); + const errorModal = useModal(); + const { showToast } = useToast(); + // Check if we're still loading user or tenant data const isLoadingUserData = authLoading || tenantLoading; @@ -189,13 +220,21 @@ export const DataProcessingStep: React.FC = ({ const fileExtension = file.name.toLowerCase().substring(file.name.lastIndexOf('.')); if (!validExtensions.includes(fileExtension)) { - alert('Formato de archivo no válido. Usa CSV o Excel (.xlsx, .xls)'); + showToast({ + title: 'Formato inválido', + message: 'Formato de archivo no válido. Usa CSV o Excel (.xlsx, .xls)', + type: 'error' + }); return; } // Check file size (max 10MB) if (file.size > 10 * 1024 * 1024) { - alert('El archivo es demasiado grande. Máximo 10MB permitido.'); + showToast({ + title: 'Archivo muy grande', + message: 'El archivo es demasiado grande. Máximo 10MB permitido.', + type: 'error' + }); return; } @@ -236,14 +275,15 @@ export const DataProcessingStep: React.FC = ({ console.log('DataProcessingStep - Starting file processing with tenant:', tenantId); - const result = await dataProcessingService.processFile( + const result = await processDataFile( file, - tenantId, (newProgress, newStage, message) => { setProgress(newProgress); setStage(newStage as ProcessingStage); setCurrentMessage(message); - } + }, + salesService.validateSalesData.bind(salesService), + inventoryService.generateInventorySuggestions.bind(inventoryService) ); setResults(result); @@ -321,8 +361,14 @@ export const DataProcessingStep: React.FC = ({ return; } - const templateData = await onboardingApiService.getSalesImportTemplate(tenantId, 'csv'); - onboardingApiService.downloadTemplate(templateData, 'plantilla_ventas.csv', 'csv'); + // Template download functionality can be implemented later if needed + console.warn('Template download not yet implemented in reorganized structure'); + createAlert({ + type: 'info', + category: 'system', + title: 'Descarga de plantilla no disponible', + message: 'Esta funcionalidad se implementará próximamente.' + }); createAlert({ type: 'success', diff --git a/frontend/src/components/domain/onboarding/steps/InventorySetupStep.tsx b/frontend/src/components/domain/onboarding/steps/InventorySetupStep.tsx index 25826b78..77e17216 100644 --- a/frontend/src/components/domain/onboarding/steps/InventorySetupStep.tsx +++ b/frontend/src/components/domain/onboarding/steps/InventorySetupStep.tsx @@ -2,8 +2,12 @@ import React, { useState, useEffect } 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 { onboardingApiService } from '../../../../services/api/onboarding.service'; +import { useInventory } from '../../../../hooks/api/useInventory'; +import { useSales } from '../../../../hooks/api/useSales'; +import { useModal } from '../../../../hooks/ui/useModal'; +import { useToast } from '../../../../hooks/ui/useToast'; import { useAuthUser } from '../../../../stores/auth.store'; +import { useCurrentTenant } from '../../../../stores/tenant.store'; import { useAlertActions } from '../../../../stores/alerts.store'; interface InventoryItem { @@ -57,7 +61,28 @@ export const InventorySetupStep: React.FC = ({ isLastStep }) => { const user = useAuthUser(); + const currentTenant = useCurrentTenant(); const { createAlert } = useAlertActions(); + const { showToast } = useToast(); + + // Use inventory hook for API operations + const { + createIngredient, + isLoading: inventoryLoading, + error: inventoryError, + clearError: clearInventoryError + } = useInventory(); + + // Use sales hook for importing sales data + const { + importSalesData, + isLoading: salesLoading + } = useSales(); + + // 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); @@ -104,16 +129,30 @@ export const InventorySetupStep: React.FC = ({ ? items : items.filter(item => item.category === filterCategory); - // Create inventory items via API + // Create inventory items via API using hooks const handleCreateInventory = async () => { + console.log('InventorySetup - Starting handleCreateInventory'); + console.log('InventorySetup - data:', data); + console.log('InventorySetup - data.allStepData keys:', Object.keys(data.allStepData || {})); + const approvedProducts = data.approvedProducts || data.allStepData?.['review']?.approvedProducts; - if (!user?.tenant_id || !approvedProducts || approvedProducts.length === 0) { + console.log('InventorySetup - approvedProducts:', approvedProducts); + + // Get tenant ID from current tenant context or user + const tenantId = currentTenant?.id || user?.tenant_id; + console.log('InventorySetup - tenantId from currentTenant:', currentTenant?.id); + console.log('InventorySetup - tenantId from user:', user?.tenant_id); + console.log('InventorySetup - final 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: 'No se pueden crear elementos de inventario sin productos aprobados.', + message: !tenantId ? 'No se pudo obtener información del tenant' : 'No se pueden crear elementos de inventario sin productos aprobados.', source: 'onboarding' }); return; @@ -121,28 +160,135 @@ export const InventorySetupStep: React.FC = ({ setIsCreating(true); try { - const result = await onboardingApiService.createInventoryFromSuggestions( - user.tenant_id, - approvedProducts - ); + // Create ingredients one by one using the inventory hook + let successCount = 0; + let failCount = 0; + const createdItems: any[] = []; + const inventoryMapping: { [productName: string]: string } = {}; + + for (const product of approvedProducts) { + const ingredientData = { + name: product.suggested_name || product.name, + category: product.category || 'general', + unit_of_measure: product.unit_of_measure || 'unit', + shelf_life_days: product.estimated_shelf_life_days || 30, + requires_refrigeration: product.requires_refrigeration || false, + requires_freezing: product.requires_freezing || false, + is_seasonal: product.is_seasonal || false + }; + + try { + const success = await createIngredient(ingredientData); + if (success) { + successCount++; + // Mock created item data since hook doesn't return it + const createdItem = { ...ingredientData, id: `created-${Date.now()}-${successCount}` }; + createdItems.push(createdItem); + inventoryMapping[product.original_name || product.name] = createdItem.id; + } else { + failCount++; + } + } catch (ingredientError) { + console.error('Error creating ingredient:', product.name, ingredientError); + failCount++; + // For onboarding, continue even if backend is not ready + } + } - createAlert({ - type: 'success', - category: 'system', - priority: 'medium', - title: 'Inventario creado', - message: `Se crearon ${result.created_items.length} elementos de inventario exitosamente.`, - source: 'onboarding' - }); + // Show results + if (successCount > 0) { + createAlert({ + type: 'success', + category: 'system', + priority: 'medium', + title: 'Inventario creado', + message: `Se crearon ${successCount} elementos de inventario exitosamente.`, + source: 'onboarding' + }); + } else if (failCount > 0) { + createAlert({ + type: 'error', + category: 'system', + priority: 'high', + title: 'Error al crear inventario', + message: `No se pudieron crear los elementos de inventario. Backend no disponible.`, + source: 'onboarding' + }); + // Don't continue with sales import if inventory creation failed + return; + } - // Update the step data with created inventory - onDataChange({ + // Now upload sales data to backend (required for ML training) + const salesDataFile = data.allStepData?.['data-processing']?.salesDataFile; + const processingResults = data.allStepData?.['data-processing']?.processingResults; + console.log('InventorySetup - salesDataFile:', salesDataFile); + console.log('InventorySetup - processingResults:', processingResults); + let salesImportResult = null; + + if (salesDataFile && processingResults?.is_valid) { + 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 importSuccess = await importSalesData(salesDataFile, 'csv'); + + if (importSuccess) { + salesImportResult = { + records_created: processingResults.total_records, + success: true, + imported: true + }; + + 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' + }); + } else { + throw new Error('Failed to upload sales data'); + } + } 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. Esto es requerido para el entrenamiento de IA.', + source: 'onboarding' + }); + + // Set failed result + salesImportResult = { + records_created: 0, + success: false, + error: salesError instanceof Error ? salesError.message : 'Error uploading sales data' + }; + } + } + + // Update the step data with created inventory and sales import result + console.log('InventorySetup - Updating step data with salesImportResult:', salesImportResult); + const updatedData = { ...data, inventoryItems: items, inventoryConfigured: true, - inventoryMapping: result.inventory_mapping, - createdInventoryItems: result.created_items - }); + inventoryCreated: true, // Mark as created to prevent duplicate calls + inventoryMapping: inventoryMapping, + createdInventoryItems: createdItems, + salesImportResult: salesImportResult + }; + console.log('InventorySetup - updatedData:', updatedData); + onDataChange(updatedData); } catch (error) { console.error('Error creating inventory:', error); @@ -172,6 +318,19 @@ export const InventorySetupStep: React.FC = ({ }); }, [items, 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) { + console.log('InventorySetup - Auto-creating inventory on step completion'); + handleCreateInventory(); + } + }, [data.inventoryCreated, items, isCreating]); + const handleAddItem = () => { const newItem: InventoryItem = { id: Date.now().toString(), @@ -220,9 +379,9 @@ export const InventorySetupStep: React.FC = ({ const getStockStatusColor = (status: string) => { switch (status) { - case 'critical': return 'text-red-600 bg-red-50'; - case 'warning': return 'text-yellow-600 bg-yellow-50'; - default: return 'text-green-600 bg-green-50'; + 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'; } }; @@ -263,48 +422,48 @@ export const InventorySetupStep: React.FC = ({ {/* Stats */} -
- +
+
{items.length}
-
Elementos totales
+
Elementos totales
- -
+ +
{items.filter(item => item.category === 'ingredient').length}
-
Ingredientes
+
Ingredientes
- -
+ +
{items.filter(item => item.category === 'finished_product').length}
-
Productos terminados
+
Productos terminados
- -
+ +
{items.filter(item => getStockStatus(item) === 'critical').length}
-
Stock crítico
+
Stock crítico
{/* Controls */} -
+
- + {filteredItems.length} elementos
@@ -313,72 +472,96 @@ export const InventorySetupStep: React.FC = ({
{/* Items List */} -
+
{filteredItems.map((item) => ( - + {editingItem?.id === item.id ? ( ) : ( -
-
-
-

{item.name}

- +
+
+
+

{item.name}

+ + Stock: {getStockStatus(item) === 'critical' ? '🔴 Crítico' : getStockStatus(item) === 'warning' ? '🟡 Bajo' : '🟢 Bueno'} + +
+ +
+ {item.category === 'ingredient' ? 'Ingrediente' : 'Producto terminado'} - - Stock: {getStockStatus(item)} - {item.requires_refrigeration && ( - - Refrigeración + + ❄️ Refrigeración )}
-
-
- Stock actual: {item.current_stock} {item.unit} - Mínimo: {item.min_stock} - Máximo: {item.max_stock} +
+
+
+ Stock actual: + {item.current_stock} {item.unit} +
+
+ Mínimo: + {item.min_stock} +
+
+ Máximo: + {item.max_stock} +
- {item.expiry_date && ( -
Vence: {item.expiry_date}
- )} - {item.supplier && ( -
Proveedor: {item.supplier}
+ {(item.expiry_date || item.supplier) && ( +
+ {item.expiry_date && ( +
+ Vence: + {item.expiry_date} +
+ )} + {item.supplier && ( +
+ Proveedor: + {item.supplier} +
+ )} +
)}
-
+
- {/* Navigation */} -
- - -
+ {/* 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"
  • +
+
+
); }; @@ -425,39 +606,49 @@ const InventoryItemEditor: React.FC<{ item: InventoryItem; onSave: (item: InventoryItem) => void; onCancel: () => void; -}> = ({ item, onSave, onCancel }) => { + showToast: (toast: any) => void; +}> = ({ item, onSave, onCancel, showToast }) => { const [editedItem, setEditedItem] = useState(item); const handleSave = () => { if (!editedItem.name.trim()) { - alert('El nombre es requerido'); + 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) { - alert('Los niveles de stock deben ser válidos (máximo > mínimo >= 0)'); + 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)]" />
@@ -528,17 +725,24 @@ const InventoryItemEditor: React.FC<{ type="checkbox" checked={editedItem.requires_refrigeration} onChange={(e) => setEditedItem({ ...editedItem, requires_refrigeration: e.target.checked })} - className="rounded" + className="rounded border-[var(--border-primary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]" /> - Requiere refrigeración + Requiere refrigeración
-
- -
diff --git a/frontend/src/components/domain/onboarding/steps/MLTrainingStep.tsx b/frontend/src/components/domain/onboarding/steps/MLTrainingStep.tsx index e8160be3..8ec0f9d7 100644 --- a/frontend/src/components/domain/onboarding/steps/MLTrainingStep.tsx +++ b/frontend/src/components/domain/onboarding/steps/MLTrainingStep.tsx @@ -11,6 +11,7 @@ import { TrainingCompletedMessage, TrainingErrorMessage } from '../../../../services/realtime/websocket.service'; +import { trainingService } from '../../../../services/api/training.service'; interface TrainingMetrics { accuracy: number; @@ -35,44 +36,7 @@ interface TrainingJob { metrics?: TrainingMetrics; } -// Real training service using backend APIs -class TrainingApiService { - private async apiCall(endpoint: string, options: RequestInit = {}) { - const response = await fetch(`/api${endpoint}`, { - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers, - }, - }); - - if (!response.ok) { - throw new Error(`API call failed: ${response.statusText}`); - } - - return response.json(); - } - - async startTrainingJob(tenantId: string, startDate?: string, endDate?: string): Promise { - return this.apiCall(`/tenants/${tenantId}/training/jobs`, { - method: 'POST', - body: JSON.stringify({ - start_date: startDate, - end_date: endDate - }), - }); - } - - async getTrainingJob(tenantId: string, jobId: string): Promise { - return this.apiCall(`/tenants/${tenantId}/training/jobs/${jobId}`); - } - - async getTrainingJobs(tenantId: string): Promise { - return this.apiCall(`/tenants/${tenantId}/training/jobs`); - } -} - -const trainingService = new TrainingApiService(); +// Using the proper training service from services/api/training.service.ts export const MLTrainingStep: React.FC = ({ data, @@ -114,13 +78,25 @@ export const MLTrainingStep: React.FC = ({ console.log('MLTrainingStep - dataProcessingData:', dataProcessingData); console.log('MLTrainingStep - reviewData:', reviewData); console.log('MLTrainingStep - inventoryData:', inventoryData); + console.log('MLTrainingStep - inventoryData.salesImportResult:', inventoryData?.salesImportResult); // Check if sales data was processed const hasProcessingResults = dataProcessingData?.processingResults && dataProcessingData.processingResults.is_valid && dataProcessingData.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'); } @@ -152,6 +128,7 @@ export const MLTrainingStep: React.FC = ({ isValid: missingItems.length === 0, missingItems, hasProcessingResults, + hasImportResults, hasApprovedProducts, hasInventoryConfig }); @@ -205,7 +182,11 @@ export const MLTrainingStep: React.FC = ({ try { // Start training job addLog('Iniciando trabajo de entrenamiento ML...', 'info'); - const job = await trainingService.startTrainingJob(tenantId); + const response = await trainingService.createTrainingJob({ + start_date: undefined, + end_date: undefined + }); + const job = response.data; setCurrentJob(job); setTrainingStatus('training'); diff --git a/frontend/src/components/domain/onboarding/steps/ReviewStep.tsx b/frontend/src/components/domain/onboarding/steps/ReviewStep.tsx index f70621a8..8d408d29 100644 --- a/frontend/src/components/domain/onboarding/steps/ReviewStep.tsx +++ b/frontend/src/components/domain/onboarding/steps/ReviewStep.tsx @@ -227,16 +227,16 @@ export const ReviewStep: React.FC = ({ }; const getConfidenceColor = (confidence: number) => { - if (confidence >= 90) return 'text-green-600 bg-green-50'; - if (confidence >= 75) return 'text-yellow-600 bg-yellow-50'; - return 'text-red-600 bg-red-50'; + 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-green-600 bg-green-50'; - case 'rejected': return 'text-red-600 bg-red-50'; - default: return 'text-gray-600 bg-gray-50'; + 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)]'; } }; @@ -267,35 +267,35 @@ export const ReviewStep: React.FC = ({ return (
{/* Summary Stats */} -
- +
+
{stats.total}
-
Productos detectados
+
Productos detectados
- -
{stats.approved}
-
Aprobados
+ +
{stats.approved}
+
Aprobados
- -
{stats.rejected}
-
Rechazados
+ +
{stats.rejected}
+
Rechazados
- -
{stats.pending}
-
Pendientes
+ +
{stats.pending}
+
Pendientes
{/* Controls */} -
+
- + {getFilteredProducts().length} productos
@@ -314,7 +314,7 @@ export const ReviewStep: React.FC = ({ size="sm" variant="outline" onClick={() => handleBulkAction('approve')} - className="text-green-600 border-green-200 hover:bg-green-50" + className="text-[var(--color-success)] border-[var(--color-success)]/30 hover:bg-[var(--color-success)]/10 bg-[var(--color-success)]/5" > Aprobar todos @@ -322,7 +322,7 @@ export const ReviewStep: React.FC = ({ size="sm" variant="outline" onClick={() => handleBulkAction('reject')} - className="text-red-600 border-red-200 hover:bg-red-50" + className="text-[var(--color-error)] border-[var(--color-error)]/30 hover:bg-[var(--color-error)]/10 bg-[var(--color-error)]/5" > Rechazar todos @@ -330,48 +330,70 @@ export const ReviewStep: React.FC = ({
{/* Products List */} -
+
{getFilteredProducts().map((product) => ( - -
-
-
-

{product.name}

- + +
+
+
+

{product.name}

+ + {product.status === 'approved' ? '✓ Aprobado' : + product.status === 'rejected' ? '✗ Rechazado' : '⏳ Pendiente'} + +
+ +
+ {product.category} - + {product.confidence}% confianza - - {product.status === 'approved' ? 'Aprobado' : - product.status === 'rejected' ? 'Rechazado' : 'Pendiente'} -
-
+
{product.original_name && product.original_name !== product.name && ( -
Nombre original: {product.original_name}
+
+ Nombre original: + {product.original_name} +
)} -
- Tipo: {product.product_type === 'ingredient' ? 'Ingrediente' : 'Producto terminado'} - Unidad: {product.unit_of_measure} +
+
+ Tipo: + + {product.product_type === 'ingredient' ? 'Ingrediente' : 'Producto terminado'} + +
+
+ Unidad: + {product.unit_of_measure} +
{product.sales_data && ( - Ventas: {product.sales_data.total_quantity} +
+ Ventas: + {product.sales_data.total_quantity} +
)}
{product.notes && ( -
Nota: {product.notes}
+
+ Nota: {product.notes} +
)}
-
+
- {/* Navigation */} -
- - -
+ {/* Information */} + +

+ 📋 Revisión de Productos Detectados: +

+
    +
  • Revise cuidadosamente - Los productos fueron detectados automáticamente desde sus datos de ventas
  • +
  • Apruebe o rechace cada producto según sea correcto para su negocio
  • +
  • Verifique nombres - Compare el nombre original vs. el nombre sugerido
  • +
  • Revise clasificaciones - Confirme si son ingredientes o productos terminados
  • +
  • Use filtros - Filtre por categoría para revisar productos similares
  • +
  • Acciones masivas - Use "Aprobar todos" o "Rechazar todos" para agilizar el proceso
  • +
+
+
); }; \ No newline at end of file diff --git a/frontend/src/components/domain/onboarding/steps/SuppliersStep.tsx b/frontend/src/components/domain/onboarding/steps/SuppliersStep.tsx index 6b8018c3..7d4cb8b9 100644 --- a/frontend/src/components/domain/onboarding/steps/SuppliersStep.tsx +++ b/frontend/src/components/domain/onboarding/steps/SuppliersStep.tsx @@ -2,10 +2,12 @@ import React, { useState, useEffect } from 'react'; import { Truck, Phone, Mail, Plus, Edit, Trash2, MapPin, AlertCircle, Loader } from 'lucide-react'; import { Button, Card, Input, Badge } from '../../../ui'; import { OnboardingStepProps } from '../OnboardingWizard'; -import { procurementService, type Supplier } from '../../../../services/api/procurement.service'; +import { useModal } from '../../../../hooks/ui/useModal'; +import { useToast } from '../../../../hooks/ui/useToast'; import { useAuthUser } from '../../../../stores/auth.store'; import { useCurrentTenant } from '../../../../stores/tenant.store'; import { useAlertActions } from '../../../../stores/alerts.store'; +import { procurementService } from '../../../../services/api/procurement.service'; // Frontend supplier interface that matches the form needs interface SupplierFormData { @@ -46,8 +48,13 @@ export const SuppliersStep: React.FC = ({ const user = useAuthUser(); const currentTenant = useCurrentTenant(); const { createAlert } = useAlertActions(); + const { showToast } = useToast(); - const [suppliers, setSuppliers] = useState([]); + // Use modals for confirmations and editing + const deleteModal = useModal(); + const editModal = useModal(); + + const [suppliers, setSuppliers] = useState([]); const [editingSupplier, setEditingSupplier] = useState(null); const [isAddingNew, setIsAddingNew] = useState(false); const [filterStatus, setFilterStatus] = useState<'all' | 'active' | 'inactive'>('all'); @@ -207,9 +214,17 @@ export const SuppliersStep: React.FC = ({ }; const handleDeleteSupplier = async (id: string) => { - if (!window.confirm('¿Estás seguro de eliminar este proveedor? Esta acción no se puede deshacer.')) { - return; - } + deleteModal.openModal({ + title: 'Confirmar eliminación', + message: '¿Estás seguro de eliminar este proveedor? Esta acción no se puede deshacer.', + onConfirm: () => performDelete(id), + onCancel: () => deleteModal.closeModal() + }); + return; + }; + + const performDelete = async (id: string) => { + deleteModal.closeModal(); setDeleting(id); try { @@ -316,21 +331,6 @@ export const SuppliersStep: React.FC = ({ {stats.total} proveedores configurados ({stats.active} activos)

- -
- -
@@ -533,20 +533,53 @@ export const SuppliersStep: React.FC = ({ ))} {getFilteredSuppliers().length === 0 && !loading && ( - - -

+ + +

{filterStatus === 'all' - ? 'No hay proveedores registrados' + ? 'Comienza agregando tu primer proveedor' : `No hay proveedores ${filterStatus === 'active' ? 'activos' : 'inactivos'}` } +

+

+ {filterStatus === 'all' + ? 'Los proveedores te ayudarán a gestionar tus compras y mantener un control de calidad en tu panadería.' + : 'Ajusta los filtros para ver otros proveedores o agrega uno nuevo.' + }

-
)} + + {/* Floating Action Button for when suppliers exist */} + {suppliers.length > 0 && !editingSupplier && ( +
+ +
+ )}
{/* Edit Modal */} @@ -571,18 +604,20 @@ export const SuppliersStep: React.FC = ({
)} - {/* Information */} - -

- 🚚 Gestión de Proveedores: -

-
    -
  • Este paso es opcional - puedes configurar proveedores más tarde
  • -
  • • Define categorías de productos para facilitar la búsqueda
  • -
  • • Establece días de entrega y términos de pago
  • -
  • • Configura montos mínimos de pedido para optimizar compras
  • -
-
+ {/* Information - Only show when there are suppliers */} + {suppliers.length > 0 && ( + +

+ 🚚 Gestión de Proveedores: +

+
    +
  • Editar información - Actualiza datos de contacto y términos comerciales
  • +
  • Gestionar estado - Activa o pausa proveedores según necesidades
  • +
  • Revisar rendimiento - Evalúa entregas a tiempo y calidad de productos
  • +
  • Filtrar vista - Usa los filtros para encontrar proveedores específicos
  • +
+
+ )}
); @@ -610,11 +645,19 @@ const SupplierForm: React.FC = ({ e.preventDefault(); if (!formData.name.trim()) { - alert('El nombre de la empresa es requerido'); + showToast({ + title: 'Error de validación', + message: 'El nombre de la empresa es requerido', + type: 'error' + }); return; } if (!formData.address.trim()) { - alert('La dirección es requerida'); + showToast({ + title: 'Error de validación', + message: 'La dirección es requerida', + type: 'error' + }); return; } diff --git a/frontend/src/hooks/business/useOnboarding.ts b/frontend/src/hooks/business/useOnboarding.ts new file mode 100644 index 00000000..075e6428 --- /dev/null +++ b/frontend/src/hooks/business/useOnboarding.ts @@ -0,0 +1,561 @@ +/** + * Onboarding business hook for managing the complete onboarding workflow + */ + +import { useState, useEffect, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { inventoryService } from '../../services/api/inventory.service'; +import { salesService } from '../../services/api/sales.service'; +import { authService } from '../../services/api/auth.service'; +import { tenantService } from '../../services/api/tenant.service'; +import { useAuthUser } from '../../stores/auth.store'; +import { useAlertActions } from '../../stores/alerts.store'; +import { + ProductSuggestion, + ProductSuggestionsResponse, + InventoryCreationResponse +} from '../../types/inventory.types'; +import { + BusinessModelGuide, + BusinessModelType, + TemplateData +} from '../../types/sales.types'; +import { OnboardingStatus } from '../../types/auth.types'; + +export interface OnboardingStep { + id: string; + title: string; + description: string; + isRequired: boolean; + isCompleted: boolean; + validation?: (data: any) => string | null; +} + +export interface OnboardingData { + // Step 1: Setup + bakery?: { + name: string; + business_model: BusinessModelType; + address: string; + city: string; + postal_code: string; + phone: string; + email?: string; + description?: string; + }; + + // Step 2: Data Processing + files?: { + salesData?: File; + }; + processingStage?: 'upload' | 'validating' | 'analyzing' | '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; + }; + }; + + // Step 3: Review + suggestions?: ProductSuggestion[]; + approvedSuggestions?: ProductSuggestion[]; + reviewCompleted?: boolean; + + // Step 4: Inventory + inventoryItems?: any[]; + inventoryMapping?: { [productName: string]: string }; + inventoryConfigured?: boolean; + + // Step 5: Suppliers + suppliers?: any[]; + supplierMappings?: any[]; + + // Step 6: ML Training + trainingStatus?: 'not_started' | 'in_progress' | 'completed' | 'failed'; + modelAccuracy?: number; + + // Step 7: Completion + completionStats?: { + totalProducts: number; + inventoryItems: number; + suppliersConfigured: number; + mlModelAccuracy: number; + estimatedTimeSaved: string; + completionScore: number; + }; +} + +interface OnboardingState { + currentStep: number; + steps: OnboardingStep[]; + data: OnboardingData; + isLoading: boolean; + error: string | null; + isInitialized: boolean; + onboardingStatus: OnboardingStatus | null; +} + +interface OnboardingActions { + // Navigation + nextStep: () => boolean; + previousStep: () => boolean; + goToStep: (stepIndex: number) => boolean; + + // Data Management + updateStepData: (stepId: string, data: Partial) => void; + validateCurrentStep: () => string | null; + + // Step-specific Actions + createTenant: (bakeryData: OnboardingData['bakery']) => Promise; + processSalesFile: (file: File, onProgress: (progress: number, stage: string, message: string) => void) => Promise; + generateInventorySuggestions: (productList: string[]) => Promise; + createInventoryFromSuggestions: (suggestions: ProductSuggestion[]) => Promise; + getBusinessModelGuide: (model: BusinessModelType) => Promise; + downloadTemplate: (templateData: TemplateData, filename: string, format?: 'csv' | 'json') => void; + + // Completion + completeOnboarding: () => Promise; + checkOnboardingStatus: () => Promise; + + // Utilities + clearError: () => void; + reset: () => void; +} + +const DEFAULT_STEPS: OnboardingStep[] = [ + { + id: 'setup', + title: '🏢 Setup', + description: 'Configuración básica de tu panadería y creación del tenant', + isRequired: true, + isCompleted: false, + validation: (data: OnboardingData) => { + if (!data.bakery?.name) return 'El nombre de la panadería es requerido'; + if (!data.bakery?.business_model) return 'El modelo de negocio es requerido'; + if (!data.bakery?.address) return 'La dirección es requerida'; + if (!data.bakery?.city) return 'La ciudad es requerida'; + if (!data.bakery?.postal_code) return 'El código postal es requerido'; + if (!data.bakery?.phone) return 'El teléfono es requerido'; + return null; + }, + }, + { + id: 'data-processing', + title: '📊 Validación de Ventas', + description: 'Valida tus datos de ventas y detecta productos automáticamente', + isRequired: true, + isCompleted: false, + validation: (data: OnboardingData) => { + if (!data.files?.salesData) return 'Debes cargar el archivo de datos de ventas'; + if (data.processingStage !== 'completed') return 'El procesamiento debe completarse antes de continuar'; + if (!data.processingResults?.is_valid) return 'Los datos deben ser válidos para continuar'; + return null; + }, + }, + { + id: 'review', + title: '📋 Revisión', + description: 'Revisión de productos detectados por IA y resultados', + isRequired: true, + isCompleted: false, + validation: (data: OnboardingData) => { + if (!data.reviewCompleted) return 'Debes revisar y aprobar los productos detectados'; + 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'; + return null; + }, + }, + { + id: 'suppliers', + title: '🏪 Proveedores', + description: 'Configuración de proveedores y asociaciones', + isRequired: false, + isCompleted: false, + }, + { + id: 'ml-training', + title: '🎯 Inteligencia', + description: 'Creación de tu asistente inteligente personalizado', + isRequired: true, + isCompleted: false, + validation: (data: OnboardingData) => { + if (data.trainingStatus !== 'completed') return 'El entrenamiento del modelo debe completarse'; + return null; + }, + }, + { + id: 'completion', + title: '🎉 Listo', + description: 'Finalización y preparación para usar la plataforma', + isRequired: true, + isCompleted: false, + }, +]; + +export const useOnboarding = (): OnboardingState & OnboardingActions => { + const [state, setState] = useState({ + currentStep: 0, + steps: DEFAULT_STEPS, + data: {}, + isLoading: false, + error: null, + isInitialized: false, + onboardingStatus: null, + }); + + const navigate = useNavigate(); + const user = useAuthUser(); + const { createAlert } = useAlertActions(); + + // Initialize onboarding status + useEffect(() => { + if (user && !state.isInitialized) { + checkOnboardingStatus(); + } + }, [user]); + + // Navigation + const nextStep = useCallback((): boolean => { + const currentStepData = state.steps[state.currentStep]; + const validation = validateCurrentStep(); + + if (validation) { + createAlert({ + type: 'error', + category: 'validation', + priority: 'high', + title: 'Validación fallida', + message: validation, + source: 'onboarding' + }); + return false; + } + + if (state.currentStep < state.steps.length - 1) { + setState(prev => ({ + ...prev, + currentStep: prev.currentStep + 1, + steps: prev.steps.map((step, index) => + index === prev.currentStep + ? { ...step, isCompleted: true } + : step + ) + })); + return true; + } + + return false; + }, [state.currentStep, state.steps, createAlert]); + + 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) { + setState(prev => ({ + ...prev, + currentStep: stepIndex, + })); + return true; + } + return false; + }, [state.steps.length]); + + // Data Management + const updateStepData = useCallback((stepId: string, data: Partial) => { + setState(prev => ({ + ...prev, + data: { ...prev.data, ...data } + })); + }, []); + + const validateCurrentStep = useCallback((): string | null => { + const currentStepData = state.steps[state.currentStep]; + if (currentStepData?.validation) { + return currentStepData.validation(state.data); + } + return null; + }, [state.currentStep, state.steps, state.data]); + + // Step-specific Actions + const createTenant = useCallback(async (bakeryData: OnboardingData['bakery']): Promise => { + if (!bakeryData) return false; + + setState(prev => ({ ...prev, isLoading: true, error: null })); + + try { + const response = await tenantService.createTenant({ + name: bakeryData.name, + description: bakeryData.description || '', + business_type: bakeryData.business_model, + settings: { + address: bakeryData.address, + city: bakeryData.city, + postal_code: bakeryData.postal_code, + phone: bakeryData.phone, + email: bakeryData.email, + } + }); + + if (response.success) { + updateStepData('setup', { bakery: bakeryData }); + createAlert({ + type: 'success', + category: 'system', + priority: 'medium', + title: 'Tenant creado', + message: 'Tu panadería ha sido configurada exitosamente', + source: 'onboarding' + }); + setState(prev => ({ ...prev, isLoading: false })); + return true; + } else { + throw new Error(response.error || 'Error creating tenant'); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Error desconocido'; + setState(prev => ({ ...prev, isLoading: false, error: errorMessage })); + createAlert({ + type: 'error', + category: 'system', + priority: 'high', + title: 'Error al crear tenant', + message: errorMessage, + source: 'onboarding' + }); + return false; + } + }, [updateStepData, createAlert]); + + const processSalesFile = useCallback(async ( + file: File, + onProgress: (progress: number, stage: string, message: string) => void + ): Promise => { + setState(prev => ({ ...prev, isLoading: true, error: null })); + + try { + // Stage 1: Validate file + onProgress(20, 'validating', 'Validando estructura del archivo...'); + const validationResult = await salesService.validateSalesData(file); + + onProgress(40, 'validating', 'Verificando integridad de datos...'); + + if (!validationResult.is_valid) { + throw new Error('Archivo de datos inválido'); + } + + if (!validationResult.product_list || validationResult.product_list.length === 0) { + throw new Error('No se encontraron productos en el archivo'); + } + + // Stage 2: Generate AI suggestions + onProgress(60, 'analyzing', 'Identificando productos únicos...'); + onProgress(80, 'analyzing', 'Analizando patrones de venta...'); + + const suggestions = await generateInventorySuggestions(validationResult.product_list); + + onProgress(100, 'completed', 'Procesamiento completado'); + + updateStepData('data-processing', { + files: { salesData: file }, + processingStage: 'completed', + processingResults: validationResult, + suggestions: suggestions?.suggestions || [] + }); + + setState(prev => ({ ...prev, isLoading: false })); + return true; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Error processing file'; + setState(prev => ({ + ...prev, + isLoading: false, + error: errorMessage, + data: { + ...prev.data, + processingStage: 'error' + } + })); + return false; + } + }, [updateStepData]); + + const generateInventorySuggestions = useCallback(async (productList: string[]): Promise => { + try { + const response = await inventoryService.generateInventorySuggestions(productList); + return response.success ? response.data : null; + } catch (error) { + console.error('Error generating inventory suggestions:', error); + return null; + } + }, []); + + const createInventoryFromSuggestions = useCallback(async (suggestions: ProductSuggestion[]): Promise => { + setState(prev => ({ ...prev, isLoading: true, error: null })); + + try { + const response = await inventoryService.createInventoryFromSuggestions(suggestions); + + if (response.success) { + updateStepData('inventory', { + inventoryItems: response.data.created_items, + inventoryMapping: response.data.inventory_mapping, + inventoryConfigured: true + }); + + setState(prev => ({ ...prev, isLoading: false })); + return response.data; + } else { + throw new Error(response.error || 'Error creating inventory'); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Error creating inventory'; + setState(prev => ({ ...prev, isLoading: false, error: errorMessage })); + return null; + } + }, [updateStepData]); + + const getBusinessModelGuide = useCallback(async (model: BusinessModelType): Promise => { + try { + const response = await salesService.getBusinessModelGuide(model); + return response.success ? response.data : null; + } catch (error) { + console.error('Error getting business model guide:', error); + return null; + } + }, []); + + const downloadTemplate = useCallback((templateData: TemplateData, filename: string, format: 'csv' | 'json' = 'csv') => { + salesService.downloadTemplate(templateData, filename, format); + }, []); + + const checkOnboardingStatus = useCallback(async () => { + setState(prev => ({ ...prev, isLoading: true })); + + try { + const response = await authService.checkOnboardingStatus(); + setState(prev => ({ + ...prev, + onboardingStatus: response.success ? response.data : null, + isInitialized: true, + isLoading: false + })); + } catch (error) { + setState(prev => ({ + ...prev, + isInitialized: true, + isLoading: false + })); + } + }, []); + + const completeOnboarding = useCallback(async (): Promise => { + setState(prev => ({ ...prev, isLoading: true, error: null })); + + try { + const response = await authService.completeOnboarding({ + completedAt: new Date().toISOString(), + data: state.data + }); + + if (response.success) { + createAlert({ + type: 'success', + category: 'system', + priority: 'high', + title: '¡Onboarding completado!', + message: 'Has completado exitosamente la configuración inicial', + source: 'onboarding' + }); + + setState(prev => ({ + ...prev, + isLoading: false, + steps: prev.steps.map(step => ({ ...step, isCompleted: true })) + })); + + // Navigate to dashboard after a short delay + setTimeout(() => { + navigate('/app/dashboard'); + }, 2000); + + return true; + } else { + throw new Error(response.error || 'Error completing onboarding'); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Error completing onboarding'; + setState(prev => ({ ...prev, isLoading: false, error: errorMessage })); + + createAlert({ + type: 'error', + category: 'system', + priority: 'high', + title: 'Error al completar onboarding', + message: errorMessage, + source: 'onboarding' + }); + + return false; + } + }, [state.data, createAlert, navigate]); + + const clearError = useCallback(() => { + setState(prev => ({ ...prev, error: null })); + }, []); + + const reset = useCallback(() => { + setState({ + currentStep: 0, + steps: DEFAULT_STEPS.map(step => ({ ...step, isCompleted: false })), + data: {}, + isLoading: false, + error: null, + isInitialized: false, + onboardingStatus: null, + }); + }, []); + + return { + ...state, + nextStep, + previousStep, + goToStep, + updateStepData, + validateCurrentStep, + createTenant, + processSalesFile, + generateInventorySuggestions, + createInventoryFromSuggestions, + getBusinessModelGuide, + downloadTemplate, + completeOnboarding, + checkOnboardingStatus, + clearError, + reset, + }; +}; \ 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 8afe271a..4aa877b3 100644 --- a/frontend/src/pages/app/onboarding/OnboardingPage.tsx +++ b/frontend/src/pages/app/onboarding/OnboardingPage.tsx @@ -1,7 +1,7 @@ -import React, { useState, useEffect } from 'react'; +import React, { useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { OnboardingWizard, OnboardingStep } from '../../../components/domain/onboarding/OnboardingWizard'; -import { onboardingApiService } from '../../../services/api/onboarding.service'; +import { useOnboarding } from '../../../hooks/business/useOnboarding'; import { useAuthUser, useIsAuthenticated } from '../../../stores/auth.store'; import { LoadingSpinner } from '../../../components/shared/LoadingSpinner'; @@ -18,162 +18,138 @@ const OnboardingPage: React.FC = () => { const navigate = useNavigate(); const user = useAuthUser(); const isAuthenticated = useIsAuthenticated(); - const [isLoading, setIsLoading] = useState(false); - const [globalData, setGlobalData] = useState({}); + + // Use the onboarding business hook + const { + currentStep, + steps, + data, + isLoading, + error, + isInitialized, + onboardingStatus, + nextStep, + previousStep, + goToStep, + updateStepData, + validateCurrentStep, + createTenant, + processSalesFile, + generateInventorySuggestions, + createInventoryFromSuggestions, + getBusinessModelGuide, + downloadTemplate, + completeOnboarding, + clearError, + reset + } = useOnboarding(); - // Define the 8 onboarding steps (simplified by merging data upload + analysis) - const steps: OnboardingStep[] = [ - { - id: 'setup', - title: '🏢 Setup', - description: 'Configuración básica de tu panadería y creación del tenant', - component: BakerySetupStep, - isRequired: true, - validation: (data) => { - if (!data.bakery?.name) return 'El nombre de la panadería es requerido'; - if (!data.bakery?.business_model) return 'El modelo de negocio es requerido'; - if (!data.bakery?.address) return 'La dirección es requerida'; - if (!data.bakery?.city) return 'La ciudad es requerida'; - if (!data.bakery?.postal_code) return 'El código postal es requerido'; - if (!data.bakery?.phone) return 'El teléfono es requerido'; - // Tenant creation will happen automatically when validation passes - return null; - } - }, - { - id: 'data-processing', - title: '📊 Historial de Ventas', - description: 'Sube tus datos de ventas para obtener insights personalizados', - component: DataProcessingStep, - isRequired: true, - validation: (data) => { - if (!data.files?.salesData) return 'Debes cargar el archivo de datos de ventas'; - if (data.processingStage !== 'completed') return 'El procesamiento debe completarse antes de continuar'; - if (!data.processingResults?.is_valid) return 'Los datos deben ser válidos para continuar'; - return null; - } - }, - { - id: 'review', - title: '📋 Revisión', - description: 'Revisión de productos detectados por IA y resultados', - component: ReviewStep, - isRequired: true, - validation: (data) => { - if (!data.reviewCompleted) return 'Debes revisar y aprobar los productos detectados'; - return null; - } - }, - { - id: 'inventory', - title: '⚙️ Inventario', - description: 'Configuración de inventario (stock, fechas de vencimiento)', - component: InventorySetupStep, - isRequired: true, - validation: (data) => { - if (!data.inventoryConfigured) return 'Debes configurar el inventario básico'; - return null; - } - }, - { - id: 'suppliers', - title: '🏪 Proveedores', - description: 'Configuración de proveedores y asociaciones', - component: SuppliersStep, - isRequired: false, - validation: () => null // Optional step - }, - { - id: 'ml-training', - title: '🎯 Inteligencia', - description: 'Creación de tu asistente inteligente personalizado', - component: MLTrainingStep, - isRequired: true, - validation: (data) => { - if (data.trainingStatus !== 'completed') return 'El entrenamiento del modelo debe completarse'; - return null; - } - }, - { - id: 'completion', - title: '🎉 Listo', - description: 'Finalización y preparación para usar la plataforma', - component: CompletionStep, - isRequired: true, - validation: () => null + // Map steps to components + const stepComponents: { [key: string]: React.ComponentType } = { + 'setup': BakerySetupStep, + 'data-processing': DataProcessingStep, + 'review': ReviewStep, + 'inventory': InventorySetupStep, + 'suppliers': SuppliersStep, + 'ml-training': MLTrainingStep, + 'completion': CompletionStep + }; + + // Convert hook steps to OnboardingWizard format + const wizardSteps: OnboardingStep[] = steps.map(step => ({ + id: step.id, + title: step.title, + description: step.description, + component: stepComponents[step.id], + isRequired: step.isRequired, + validation: step.validation + })); + + const handleStepChange = (stepIndex: number, stepData: any) => { + const stepId = steps[stepIndex]?.id; + if (stepId) { + updateStepData(stepId, stepData); } - ]; + }; + + const handleNext = () => { + return nextStep(); + }; + + const handlePrevious = () => { + return previousStep(); + }; const handleComplete = async (allData: any) => { - setIsLoading(true); - try { - // Mark onboarding as complete in the backend - if (user?.tenant_id) { - await onboardingApiService.completeOnboarding(user.tenant_id, { - completedAt: new Date().toISOString(), - data: allData - }); - } - - // Navigate to dashboard - navigate('/app/dashboard', { - state: { - message: '¡Felicidades! Tu panadería ha sido configurada exitosamente.', - type: 'success' - } - }); - } catch (error) { - console.error('Error completing onboarding:', error); - // Still navigate to dashboard but show warning - navigate('/app/dashboard', { - state: { - message: 'Configuración completada. Algunos ajustes finales pueden estar pendientes.', - type: 'warning' - } - }); - } finally { - setIsLoading(false); + const success = await completeOnboarding(); + if (success) { + // Navigation is handled inside completeOnboarding + return; } }; - const handleExit = () => { - const confirmExit = window.confirm( - '¿Estás seguro de que quieres salir del proceso de configuración? Tu progreso se guardará automáticamente.' - ); - - if (confirmExit) { - navigate('/app/dashboard'); - } - }; - - // Redirect to login if not authenticated + // Redirect if user is not authenticated useEffect(() => { - if (!isAuthenticated) { - navigate('/login', { - state: { - message: 'Debes iniciar sesión para acceder al onboarding.', - returnUrl: '/app/onboarding' - } - }); + if (isInitialized && !isAuthenticated) { + navigate('/auth/login'); } - }, [isAuthenticated, navigate]); + }, [isAuthenticated, isInitialized, navigate]); - if (isLoading) { - return ; + // Clear error when user navigates away + useEffect(() => { + return () => { + if (error) { + clearError(); + } + }; + }, [error, clearError]); + + // Show loading while initializing + if (!isInitialized || isLoading) { + return ( +
+ +
+ ); } - // Don't render if not authenticated (will redirect) - if (!isAuthenticated || !user) { - return ; + // Show error state + if (error) { + return ( +
+
+

Error en Onboarding

+

{error}

+
+ + +
+
+
+ ); } return ( -
+
); diff --git a/frontend/src/services/api/auth.service.ts b/frontend/src/services/api/auth.service.ts index 593971b3..d8a4bc0c 100644 --- a/frontend/src/services/api/auth.service.ts +++ b/frontend/src/services/api/auth.service.ts @@ -10,7 +10,9 @@ import { PasswordResetConfirm, TokenVerification, UserResponse, - UserUpdate + UserUpdate, + OnboardingStatus, + OnboardingProgressRequest } from '../../types/auth.types'; class AuthService { @@ -200,6 +202,71 @@ class AuthService { return true; } + + // Onboarding progress tracking (moved from onboarding) + async checkOnboardingStatus(): Promise> { + try { + // Use the /me endpoint which gets proxied to auth service + const response = await apiClient.get('/me'); + + if (response.success && response.data) { + // Extract onboarding status from user profile + const onboardingStatus = { + completed: response.data.onboarding_completed || false, + steps_completed: response.data.completed_steps || [] + }; + return { + success: true, + data: onboardingStatus, + message: 'Onboarding status retrieved successfully' + }; + } + + return { + success: false, + data: { completed: false, steps_completed: [] }, + message: 'Could not retrieve onboarding status', + error: 'Invalid response data' + }; + } catch (error) { + console.warn('Could not check onboarding status:', error); + return { + success: false, + data: { completed: false, steps_completed: [] }, + message: 'Could not retrieve onboarding status', + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } + + async completeOnboarding(metadata?: any): Promise> { + try { + // Update user profile to mark onboarding as complete + const response = await apiClient.patch('/me', { + onboarding_completed: true, + completed_steps: ['setup', 'data-processing', 'review', 'inventory', 'suppliers', 'ml-training', 'completion'], + onboarding_metadata: metadata + }); + + if (response.success) { + return { + success: true, + data: { message: 'Onboarding completed successfully' }, + message: 'Onboarding marked as complete' + }; + } + + return response; + } catch (error) { + console.warn('Could not mark onboarding as complete:', error); + return { + success: false, + data: { message: 'Failed to complete onboarding' }, + message: 'Could not complete onboarding', + error: error instanceof Error ? error.message : 'Unknown error' + }; + } + } } export const authService = new AuthService(); \ No newline at end of file diff --git a/frontend/src/services/api/client.ts b/frontend/src/services/api/client.ts index 0f3e6c64..e147d8d0 100644 --- a/frontend/src/services/api/client.ts +++ b/frontend/src/services/api/client.ts @@ -56,6 +56,31 @@ class ApiClient { this.setupInterceptors(); } + // Helper method to build tenant-scoped URLs + private buildTenantUrl(path: string): string { + // If path already starts with /tenants, return as-is + if (path.startsWith('/tenants/')) { + return path; + } + + // If it's an auth endpoint, return as-is + if (path.startsWith('/auth')) { + return path; + } + + // Get tenant ID from stores + const tenantData = getTenantData(); + const authData = getAuthData(); + const tenantId = tenantData?.currentTenant?.id || authData?.user?.tenant_id; + + if (!tenantId) { + throw new Error('Tenant ID not available for API call'); + } + + // Build tenant-scoped URL: /tenants/{tenant-id}{original-path} + return `/tenants/${tenantId}${path}`; + } + private setupInterceptors(): void { // Request interceptor - add auth token and tenant ID this.axiosInstance.interceptors.request.use( @@ -168,33 +193,38 @@ class ApiClient { window.location.href = '/login'; } - // HTTP Methods with consistent response format + // HTTP Methods with consistent response format and automatic tenant scoping async get(url: string, config = {}): Promise> { - const response = await this.axiosInstance.get(url, config); + const tenantScopedUrl = this.buildTenantUrl(url); + const response = await this.axiosInstance.get(tenantScopedUrl, config); return this.transformResponse(response); } async post(url: string, data = {}, config = {}): Promise> { - const response = await this.axiosInstance.post(url, data, config); + const tenantScopedUrl = this.buildTenantUrl(url); + const response = await this.axiosInstance.post(tenantScopedUrl, data, config); return this.transformResponse(response); } async put(url: string, data = {}, config = {}): Promise> { - const response = await this.axiosInstance.put(url, data, config); + const tenantScopedUrl = this.buildTenantUrl(url); + const response = await this.axiosInstance.put(tenantScopedUrl, data, config); return this.transformResponse(response); } async patch(url: string, data = {}, config = {}): Promise> { - const response = await this.axiosInstance.patch(url, data, config); + const tenantScopedUrl = this.buildTenantUrl(url); + const response = await this.axiosInstance.patch(tenantScopedUrl, data, config); return this.transformResponse(response); } async delete(url: string, config = {}): Promise> { - const response = await this.axiosInstance.delete(url, config); + const tenantScopedUrl = this.buildTenantUrl(url); + const response = await this.axiosInstance.delete(tenantScopedUrl, config); return this.transformResponse(response); } - // File upload helper + // File upload helper with automatic tenant scoping async uploadFile(url: string, file: File, progressCallback?: (progress: number) => void): Promise> { const formData = new FormData(); formData.append('file', file); @@ -211,7 +241,8 @@ class ApiClient { }, }; - const response = await this.axiosInstance.post(url, formData, config); + const tenantScopedUrl = this.buildTenantUrl(url); + const response = await this.axiosInstance.post(tenantScopedUrl, formData, config); return this.transformResponse(response); } diff --git a/frontend/src/services/api/forecasting.service.ts b/frontend/src/services/api/forecasting.service.ts index 7685a345..d5acac84 100644 --- a/frontend/src/services/api/forecasting.service.ts +++ b/frontend/src/services/api/forecasting.service.ts @@ -264,4 +264,5 @@ class ForecastingService { } } +export { ForecastingService }; export const forecastingService = new ForecastingService(); \ No newline at end of file diff --git a/frontend/src/services/api/inventory.service.ts b/frontend/src/services/api/inventory.service.ts index 613d4ebf..26249ca4 100644 --- a/frontend/src/services/api/inventory.service.ts +++ b/frontend/src/services/api/inventory.service.ts @@ -9,7 +9,11 @@ import { StockMovement, StockAlert, InventorySummary, - StockLevelSummary + StockLevelSummary, + ProductSuggestion, + ProductSuggestionsResponse, + InventoryCreationResponse, + BatchClassificationRequest } from '../../types/inventory.types'; import { PaginatedResponse } from '../../types/api.types'; @@ -390,6 +394,92 @@ class InventoryService { { value: 'quarantine', label: 'Quarantine', color: 'purple' }, ]; } + + // AI-powered inventory classification and suggestions (moved from onboarding) + async generateInventorySuggestions( + productList: string[] + ): Promise> { + try { + if (!productList || !Array.isArray(productList) || productList.length === 0) { + throw new Error('Product list is empty or invalid'); + } + + // Transform product list into the expected format for BatchClassificationRequest + const products = productList.map(productName => ({ + product_name: productName, + sales_data: {} // Additional context can be added later + })); + + const requestData: BatchClassificationRequest = { + products: products + }; + + const response = await apiClient.post( + `${this.baseUrl}/classify-products-batch`, + requestData + ); + + return response; + } catch (error) { + console.error('Suggestion generation failed:', error); + throw error; + } + } + + async createInventoryFromSuggestions( + approvedSuggestions: ProductSuggestion[] + ): Promise> { + try { + const createdItems: any[] = []; + const failedItems: any[] = []; + const inventoryMapping: { [productName: string]: string } = {}; + + // Create inventory items one by one using inventory service + for (const suggestion of approvedSuggestions) { + try { + const ingredientData = { + name: suggestion.suggested_name, + category: suggestion.category, + unit_of_measure: suggestion.unit_of_measure, + shelf_life_days: suggestion.estimated_shelf_life_days, + requires_refrigeration: suggestion.requires_refrigeration, + requires_freezing: suggestion.requires_freezing, + is_seasonal: suggestion.is_seasonal, + product_type: suggestion.product_type + }; + + const response = await apiClient.post( + '/ingredients', + ingredientData + ); + + if (response.success) { + createdItems.push(response.data); + inventoryMapping[suggestion.original_name] = response.data.id; + } else { + failedItems.push({ suggestion, error: response.error }); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + failedItems.push({ suggestion, error: errorMessage }); + } + } + + const result = { + created_items: createdItems, + failed_items: failedItems, + total_approved: approvedSuggestions.length, + success_rate: createdItems.length / approvedSuggestions.length, + inventory_mapping: inventoryMapping + }; + + return { success: true, data: result }; + } catch (error) { + console.error('Inventory creation failed:', error); + throw error; + } + } } +export { InventoryService }; export const inventoryService = new InventoryService(); \ No newline at end of file diff --git a/frontend/src/services/api/onboarding.service.ts b/frontend/src/services/api/onboarding.service.ts deleted file mode 100644 index 240aa323..00000000 --- a/frontend/src/services/api/onboarding.service.ts +++ /dev/null @@ -1,496 +0,0 @@ -/** - * Enhanced Onboarding API Service - * Provides integration with backend AI-powered onboarding endpoints - */ - -import { apiClient } from './client'; - -export interface OnboardingFileValidationResponse { - is_valid: boolean; - total_records: number; - unique_products: number; - product_list: string[]; - validation_errors: any[]; - validation_warnings: any[]; - summary: any; -} - -export interface ProductSuggestion { - 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; - }; -} - -export interface BusinessModelAnalysis { - model: 'production' | 'retail' | 'hybrid'; - confidence: number; - ingredient_count: number; - finished_product_count: number; - ingredient_ratio: number; - recommendations: string[]; -} - -export interface ProductSuggestionsResponse { - suggestions: ProductSuggestion[]; - business_model_analysis: BusinessModelAnalysis; - total_products: number; - high_confidence_count: number; - low_confidence_count: number; - processing_time_seconds: number; -} - -export interface InventoryCreationResponse { - created_items: any[]; - failed_items: any[]; - total_approved: number; - success_rate: number; - inventory_mapping?: { [productName: string]: string }; -} - -export interface SalesImportResponse { - import_job_id: string; - status: 'completed' | 'failed' | 'partial'; - processed_rows: number; - successful_imports: number; - failed_imports: number; - errors: string[]; - warnings: string[]; - processing_time?: number; -} - -export interface BusinessModelGuide { - title: string; - description: string; - next_steps: string[]; - recommended_features: string[]; - sample_workflows: string[]; -} - -class OnboardingApiService { - private readonly basePath = '/tenants'; - private readonly salesBasePath = '/tenants'; - - /** - * Step 1: Validate uploaded file and extract unique products - * Now uses Sales Service directly - */ - async validateOnboardingFile( - tenantId: string, - file: File - ): Promise { - try { - const formData = new FormData(); - formData.append('file', file); - - const response = await apiClient.post( - `${this.salesBasePath}/${tenantId}/sales/import/validate`, - formData, - { - headers: { - 'Content-Type': 'multipart/form-data', - }, - } - ); - - if (!response.success) { - throw new Error(`Validation failed: ${response.error || 'Unknown error'}`); - } - - return response.data; - } catch (error) { - console.error('File validation failed:', error); - throw error; - } - } - - /** - * Step 2: Generate AI-powered inventory suggestions - * Now uses Inventory Service directly - */ - async generateInventorySuggestions( - tenantId: string, - file: File, - productList: string[] - ): Promise { - try { - if (!productList || !Array.isArray(productList) || productList.length === 0) { - throw new Error('Product list is empty or invalid'); - } - - // Transform product list into the expected format for BatchClassificationRequest - const products = productList.map(productName => ({ - product_name: productName, - // sales_volume is optional, omit it if we don't have the data - sales_data: {} // Additional context can be added later - })); - - const requestData = { - products: products - }; - - const response = await apiClient.post( - `${this.basePath}/${tenantId}/inventory/classify-products-batch`, - requestData - ); - - if (!response.success) { - throw new Error(`Suggestion generation failed: ${response.error || 'Unknown error'}`); - } - - return response.data; - } catch (error) { - console.error('Suggestion generation failed:', error); - throw error; - } - } - - /** - * Step 3: Create inventory items from approved suggestions - * Now uses Inventory Service directly - */ - async createInventoryFromSuggestions( - tenantId: string, - approvedSuggestions: any[] - ): Promise { - try { - const createdItems: any[] = []; - const failedItems: any[] = []; - const inventoryMapping: { [productName: string]: string } = {}; - - // Create inventory items one by one using inventory service - for (const suggestion of approvedSuggestions) { - try { - const ingredientData = { - name: suggestion.suggested_name, - category: suggestion.category, - unit_of_measure: suggestion.unit_of_measure, - shelf_life_days: suggestion.estimated_shelf_life_days, - requires_refrigeration: suggestion.requires_refrigeration, - requires_freezing: suggestion.requires_freezing, - is_seasonal: suggestion.is_seasonal, - product_type: suggestion.product_type - }; - - const response = await apiClient.post( - `${this.basePath}/${tenantId}/ingredients`, - ingredientData - ); - - if (response.success) { - createdItems.push(response.data); - inventoryMapping[suggestion.original_name] = response.data.id; - } else { - failedItems.push({ suggestion, error: response.error }); - } - } catch (error) { - failedItems.push({ suggestion, error: error.message }); - } - } - - const result = { - created_items: createdItems, - failed_items: failedItems, - total_approved: approvedSuggestions.length, - success_rate: createdItems.length / approvedSuggestions.length, - inventory_mapping: inventoryMapping - }; - - return result; - } catch (error) { - console.error('Inventory creation failed:', error); - throw error; - } - } - - /** - * Step 4: Import sales data with inventory mapping - * Now uses Sales Service directly with validation first - */ - async importSalesWithInventory( - tenantId: string, - file: File, - inventoryMapping: { [productName: string]: string } - ): Promise { - try { - // First validate the file with inventory mapping - await this.validateSalesData(tenantId, file); - - // Then import the sales data - const formData = new FormData(); - formData.append('file', file); - formData.append('update_existing', 'true'); - - const response = await apiClient.post( - `${this.salesBasePath}/${tenantId}/sales/import`, - formData, - { - headers: { - 'Content-Type': 'multipart/form-data', - }, - } - ); - - if (!response.success) { - throw new Error(`Sales import failed: ${response.error || 'Unknown error'}`); - } - - return response.data; - } catch (error) { - console.error('Sales import failed:', error); - throw error; - } - } - - /** - * Get business model specific recommendations - * Returns static recommendations since orchestration is removed - */ - async getBusinessModelGuide( - tenantId: string, - model: 'production' | 'retail' | 'hybrid' - ): Promise { - // Return static business model guides since we removed orchestration - const guides = { - production: { - title: 'Production Bakery Setup', - description: 'Your bakery focuses on creating products from raw ingredients.', - next_steps: [ - 'Set up ingredient inventory management', - 'Configure recipe management', - 'Set up production planning', - 'Implement quality control processes' - ], - recommended_features: [ - 'Inventory tracking for raw ingredients', - 'Recipe costing and management', - 'Production scheduling', - 'Supplier management' - ], - sample_workflows: [ - 'Daily production planning based on demand forecasts', - 'Inventory reordering based on production schedules', - 'Quality control checkpoints during production' - ] - }, - retail: { - title: 'Retail Bakery Setup', - description: 'Your bakery focuses on selling finished products to customers.', - next_steps: [ - 'Set up finished product inventory', - 'Configure point-of-sale integration', - 'Set up customer management', - 'Implement sales analytics' - ], - recommended_features: [ - 'Finished product inventory tracking', - 'Sales analytics and reporting', - 'Customer loyalty programs', - 'Promotional campaign management' - ], - sample_workflows: [ - 'Daily sales reporting and analysis', - 'Inventory reordering based on sales velocity', - 'Customer engagement and retention campaigns' - ] - }, - hybrid: { - title: 'Hybrid Bakery Setup', - description: 'Your bakery combines production and retail operations.', - next_steps: [ - 'Set up both ingredient and finished product inventory', - 'Configure production-to-retail workflows', - 'Set up integrated analytics', - 'Implement comprehensive supplier management' - ], - recommended_features: [ - 'Dual inventory management system', - 'Production-to-sales analytics', - 'Integrated supplier and customer management', - 'Cross-channel reporting' - ], - sample_workflows: [ - 'Production planning based on both wholesale and retail demand', - 'Integrated inventory management across production and retail', - 'Comprehensive business intelligence and reporting' - ] - } - }; - - return guides[model] || guides.hybrid; - } - - /** - * Validate sales data using the sales service (fallback) - */ - async validateSalesData( - tenantId: string, - file: File - ): Promise { - try { - const formData = new FormData(); - formData.append('file', file); - - const response = await apiClient.post( - `${this.salesBasePath}/${tenantId}/sales/import/validate`, - formData, - { - headers: { - 'Content-Type': 'multipart/form-data', - }, - } - ); - - if (!response.success) { - throw new Error(`Sales validation failed: ${response.error || 'Unknown error'}`); - } - - return response.data; - } catch (error) { - console.error('Sales validation failed:', error); - throw error; - } - } - - /** - * Import sales data using the sales service (fallback) - */ - async importSalesData( - tenantId: string, - file: File, - updateExisting: boolean = false - ): Promise { - try { - const formData = new FormData(); - formData.append('file', file); - formData.append('update_existing', updateExisting.toString()); - - const response = await apiClient.post( - `${this.salesBasePath}/${tenantId}/sales/import`, - formData, - { - headers: { - 'Content-Type': 'multipart/form-data', - }, - } - ); - - if (!response.success) { - throw new Error(`Sales import failed: ${response.error || 'Unknown error'}`); - } - - return response.data; - } catch (error) { - console.error('Sales import failed:', error); - throw error; - } - } - - /** - * Get sales data import template - */ - async getSalesImportTemplate( - tenantId: string, - format: 'csv' | 'json' = 'csv' - ): Promise { - try { - const response = await apiClient.get( - `${this.salesBasePath}/${tenantId}/sales/import/template?format=${format}` - ); - - if (!response.success) { - throw new Error(`Failed to get template: ${response.error || 'Unknown error'}`); - } - - return response.data; - } catch (error) { - console.error('Failed to get template:', error); - throw error; - } - } - - /** - * Download template file (utility method) - */ - downloadTemplate(templateData: any, filename: string, format: 'csv' | 'json' = 'csv'): void { - let content: string; - let mimeType: string; - - if (format === 'csv') { - content = templateData.template; - mimeType = 'text/csv'; - } else { - content = JSON.stringify(templateData.template, null, 2); - mimeType = 'application/json'; - } - - const blob = new Blob([content], { type: mimeType }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - - link.href = url; - link.download = filename; - link.style.display = 'none'; - - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - - URL.revokeObjectURL(url); - } - - /** - * Utility: Check if a tenant has completed onboarding - * Uses Auth Service for user progress tracking - */ - async checkOnboardingStatus(tenantId: string): Promise<{ completed: boolean; steps_completed: string[] }> { - try { - const response = await apiClient.get( - '/me/onboarding/progress' - ); - - return { - completed: response.data?.onboarding_completed || false, - steps_completed: response.data?.completed_steps || [] - }; - } catch (error) { - console.warn('Could not check onboarding status:', error); - return { completed: false, steps_completed: [] }; - } - } - - /** - * Utility: Mark onboarding as complete - * Uses Auth Service for user progress tracking - */ - async completeOnboarding(tenantId: string, metadata?: any): Promise { - try { - await apiClient.post( - '/me/onboarding/complete', - { metadata } - ); - } catch (error) { - console.warn('Could not mark onboarding as complete:', error); - // Don't throw error, this is not critical - } - } - -} - -export const onboardingApiService = new OnboardingApiService(); -export default OnboardingApiService; \ No newline at end of file diff --git a/frontend/src/services/api/order.service.ts b/frontend/src/services/api/order.service.ts new file mode 120000 index 00000000..d83d258a --- /dev/null +++ b/frontend/src/services/api/order.service.ts @@ -0,0 +1 @@ +/Users/urtzialfaro/Documents/bakery-ia/frontend/src/services/api/orders.service.ts \ No newline at end of file diff --git a/frontend/src/services/api/orders.service.ts b/frontend/src/services/api/orders.service.ts index 73d34486..9ef8ee6c 100644 --- a/frontend/src/services/api/orders.service.ts +++ b/frontend/src/services/api/orders.service.ts @@ -187,4 +187,6 @@ class OrdersService { } } +export { OrdersService }; +export { OrdersService as OrderService }; // Alias for compatibility export const ordersService = new OrdersService(); \ No newline at end of file diff --git a/frontend/src/services/api/procurement.service.ts b/frontend/src/services/api/procurement.service.ts index ee2daf61..7a53bf00 100644 --- a/frontend/src/services/api/procurement.service.ts +++ b/frontend/src/services/api/procurement.service.ts @@ -37,8 +37,7 @@ class ProcurementService { } private getBaseUrl(): string { - const tenantId = this.getTenantId(); - return `/tenants/${tenantId}`; + return ''; } // Purchase Order management @@ -216,4 +215,5 @@ class ProcurementService { } +export { ProcurementService }; export const procurementService = new ProcurementService(); \ No newline at end of file diff --git a/frontend/src/services/api/production.service.ts b/frontend/src/services/api/production.service.ts index 9fd91327..630eff63 100644 --- a/frontend/src/services/api/production.service.ts +++ b/frontend/src/services/api/production.service.ts @@ -464,4 +464,5 @@ class ProductionService { } } +export { ProductionService }; export const productionService = new ProductionService(); \ No newline at end of file diff --git a/frontend/src/services/api/sales.service.ts b/frontend/src/services/api/sales.service.ts index 565246b5..626e5a0b 100644 --- a/frontend/src/services/api/sales.service.ts +++ b/frontend/src/services/api/sales.service.ts @@ -1,4 +1,6 @@ -import { apiClient, ApiResponse } from './client'; +import { apiClient } from './client'; +import { ApiResponse } from '../../types/api.types'; +import { BusinessModelGuide, BusinessModelType, TemplateData } from '../../types/sales.types'; // Request/Response Types export interface SalesData { @@ -262,23 +264,44 @@ class SalesService { } // Data import and export - async importSalesData(file: File, progressCallback?: (progress: number) => void): Promise> { - return apiClient.uploadFile(`${this.baseUrl}/import`, file, progressCallback); + async importSalesData(file: File, progressCallback?: (progress: number) => void): Promise<{ + status: 'completed' | 'failed' | 'partial'; + records_processed: number; + records_created: number; + records_failed: number; + errors: string[]; + warnings: string[]; + processing_time?: number; + }> { + const response = await apiClient.uploadFile(`${this.baseUrl}/import`, file, progressCallback); + if (!response.success) { + throw new Error(`Sales import failed: ${response.error || response.detail || 'Unknown error'}`); + } + return response.data; } - async validateSalesData(file: File): Promise; - preview: SalesData[]; - }>> { - return apiClient.uploadFile(`${this.baseUrl}/validate`, file); + async validateSalesData(file: File): Promise<{ + is_valid: boolean; + total_records: number; + unique_products: number; + product_list: string[]; + errors: string[]; + warnings: string[]; + summary: { + date_range: string; + total_sales: number; + average_daily_sales: number; + }; + }> { + const response = await apiClient.uploadFile(`${this.baseUrl}/import/validate`, file); + if (!response.success) { + throw new Error(`Validation failed: ${response.error || response.detail || 'Unknown error'}`); + } + return response.data; } + + async exportSalesData(params?: { format?: 'csv' | 'xlsx'; start_date?: string; @@ -438,6 +461,110 @@ class SalesService { { value: 'xlsx', label: 'Excel (XLSX)' }, ]; } + + // Business model guidance (moved from onboarding) + async getBusinessModelGuide( + model: BusinessModelType + ): Promise> { + // Return static business model guides since we removed orchestration + const guides = { + [BusinessModelType.PRODUCTION]: { + title: 'Production Bakery Setup', + description: 'Your bakery focuses on creating products from raw ingredients.', + next_steps: [ + 'Set up ingredient inventory management', + 'Configure recipe management', + 'Set up production planning', + 'Implement quality control processes' + ], + recommended_features: [ + 'Inventory tracking for raw ingredients', + 'Recipe costing and management', + 'Production scheduling', + 'Supplier management' + ], + sample_workflows: [ + 'Daily production planning based on demand forecasts', + 'Inventory reordering based on production schedules', + 'Quality control checkpoints during production' + ] + }, + [BusinessModelType.RETAIL]: { + title: 'Retail Bakery Setup', + description: 'Your bakery focuses on selling finished products to customers.', + next_steps: [ + 'Set up finished product inventory', + 'Configure point-of-sale integration', + 'Set up customer management', + 'Implement sales analytics' + ], + recommended_features: [ + 'Finished product inventory tracking', + 'Sales analytics and reporting', + 'Customer loyalty programs', + 'Promotional campaign management' + ], + sample_workflows: [ + 'Daily sales reporting and analysis', + 'Inventory reordering based on sales velocity', + 'Customer engagement and retention campaigns' + ] + }, + [BusinessModelType.HYBRID]: { + title: 'Hybrid Bakery Setup', + description: 'Your bakery combines production and retail operations.', + next_steps: [ + 'Set up both ingredient and finished product inventory', + 'Configure production-to-retail workflows', + 'Set up integrated analytics', + 'Implement comprehensive supplier management' + ], + recommended_features: [ + 'Dual inventory management system', + 'Production-to-sales analytics', + 'Integrated supplier and customer management', + 'Cross-channel reporting' + ], + sample_workflows: [ + 'Production planning based on both wholesale and retail demand', + 'Integrated inventory management across production and retail', + 'Comprehensive business intelligence and reporting' + ] + } + }; + + const guide = guides[model] || guides[BusinessModelType.HYBRID]; + return { success: true, data: guide, message: 'Business model guide retrieved successfully' }; + } + + // Template download utility (moved from onboarding) + downloadTemplate(templateData: TemplateData, filename: string, format: 'csv' | 'json' = 'csv'): void { + let content: string; + let mimeType: string; + + if (format === 'csv') { + content = typeof templateData.template === 'string' ? templateData.template : JSON.stringify(templateData.template); + mimeType = 'text/csv'; + } else { + content = JSON.stringify(templateData.template, null, 2); + mimeType = 'application/json'; + } + + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + + link.href = url; + link.download = filename; + link.style.display = 'none'; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + URL.revokeObjectURL(url); + } } +export { SalesService }; export const salesService = new SalesService(); \ No newline at end of file diff --git a/frontend/src/services/api/training.service.ts b/frontend/src/services/api/training.service.ts index 91d2bd3b..d712af60 100644 --- a/frontend/src/services/api/training.service.ts +++ b/frontend/src/services/api/training.service.ts @@ -25,8 +25,7 @@ export class TrainingService { } private getBaseUrl(): string { - const tenantId = this.getTenantId(); - return `/tenants/${tenantId}/training`; + return '/training'; } async getTrainingJobs(modelId?: string): Promise> { diff --git a/frontend/src/services/api/utils/storage.service.ts b/frontend/src/services/api/utils/storage.service.ts new file mode 100644 index 00000000..8efd3c28 --- /dev/null +++ b/frontend/src/services/api/utils/storage.service.ts @@ -0,0 +1,397 @@ +/** + * Storage Service - Provides secure and consistent local/session storage management + * with encryption, expiration, and type safety + */ + +interface StorageOptions { + encrypt?: boolean; + expiresIn?: number; // milliseconds + storage?: 'local' | 'session'; +} + +interface StorageItem { + value: T; + encrypted?: boolean; + expiresAt?: number; + createdAt: number; +} + +class StorageService { + private readonly encryptionKey = 'bakery-app-key'; // In production, use proper key management + + /** + * Store data in browser storage + */ + setItem(key: string, value: T, options: StorageOptions = {}): boolean { + try { + const { + encrypt = false, + expiresIn, + storage = 'local' + } = options; + + const storageInstance = storage === 'session' ? sessionStorage : localStorage; + + const item: StorageItem = { + value: encrypt ? this.encrypt(JSON.stringify(value)) : value, + encrypted: encrypt, + createdAt: Date.now(), + ...(expiresIn && { expiresAt: Date.now() + expiresIn }) + }; + + storageInstance.setItem(key, JSON.stringify(item)); + return true; + } catch (error) { + console.error(`Storage error setting item "${key}":`, error); + return false; + } + } + + /** + * Retrieve data from browser storage + */ + getItem(key: string, storage: 'local' | 'session' = 'local'): T | null { + try { + const storageInstance = storage === 'session' ? sessionStorage : localStorage; + const itemStr = storageInstance.getItem(key); + + if (!itemStr) { + return null; + } + + const item: StorageItem = JSON.parse(itemStr); + + // Check expiration + if (item.expiresAt && Date.now() > item.expiresAt) { + this.removeItem(key, storage); + return null; + } + + // Handle encrypted data + if (item.encrypted && typeof item.value === 'string') { + try { + const decrypted = this.decrypt(item.value); + return JSON.parse(decrypted); + } catch (error) { + console.error(`Failed to decrypt item "${key}":`, error); + return null; + } + } + + return item.value; + } catch (error) { + console.error(`Storage error getting item "${key}":`, error); + return null; + } + } + + /** + * Remove item from storage + */ + removeItem(key: string, storage: 'local' | 'session' = 'local'): boolean { + try { + const storageInstance = storage === 'session' ? sessionStorage : localStorage; + storageInstance.removeItem(key); + return true; + } catch (error) { + console.error(`Storage error removing item "${key}":`, error); + return false; + } + } + + /** + * Check if item exists and is not expired + */ + hasItem(key: string, storage: 'local' | 'session' = 'local'): boolean { + return this.getItem(key, storage) !== null; + } + + /** + * Clear all items from storage + */ + clear(storage: 'local' | 'session' = 'local'): boolean { + try { + const storageInstance = storage === 'session' ? sessionStorage : localStorage; + storageInstance.clear(); + return true; + } catch (error) { + console.error('Storage error clearing storage:', error); + return false; + } + } + + /** + * Get all keys from storage with optional prefix filter + */ + getKeys(prefix?: string, storage: 'local' | 'session' = 'local'): string[] { + try { + const storageInstance = storage === 'session' ? sessionStorage : localStorage; + const keys: string[] = []; + + for (let i = 0; i < storageInstance.length; i++) { + const key = storageInstance.key(i); + if (key && (!prefix || key.startsWith(prefix))) { + keys.push(key); + } + } + + return keys; + } catch (error) { + console.error('Storage error getting keys:', error); + return []; + } + } + + /** + * Get storage usage information + */ + getStorageInfo(storage: 'local' | 'session' = 'local'): { + used: number; + total: number; + available: number; + itemCount: number; + } { + try { + const storageInstance = storage === 'session' ? sessionStorage : localStorage; + + // Calculate used space (approximate) + let used = 0; + for (let i = 0; i < storageInstance.length; i++) { + const key = storageInstance.key(i); + if (key) { + const value = storageInstance.getItem(key); + used += key.length + (value?.length || 0); + } + } + + // Most browsers have ~5-10MB limit for localStorage + const estimated_total = 5 * 1024 * 1024; // 5MB in bytes + + return { + used, + total: estimated_total, + available: estimated_total - used, + itemCount: storageInstance.length + }; + } catch (error) { + console.error('Storage error getting storage info:', error); + return { used: 0, total: 0, available: 0, itemCount: 0 }; + } + } + + /** + * Clean expired items from storage + */ + cleanExpired(storage: 'local' | 'session' = 'local'): number { + let cleanedCount = 0; + + try { + const storageInstance = storage === 'session' ? sessionStorage : localStorage; + const keysToRemove: string[] = []; + + for (let i = 0; i < storageInstance.length; i++) { + const key = storageInstance.key(i); + if (key) { + try { + const itemStr = storageInstance.getItem(key); + if (itemStr) { + const item: StorageItem = JSON.parse(itemStr); + if (item.expiresAt && Date.now() > item.expiresAt) { + keysToRemove.push(key); + } + } + } catch (error) { + // If we can't parse the item, it might be corrupted + keysToRemove.push(key); + } + } + } + + keysToRemove.forEach(key => { + storageInstance.removeItem(key); + cleanedCount++; + }); + + } catch (error) { + console.error('Storage error cleaning expired items:', error); + } + + return cleanedCount; + } + + /** + * Backup storage to JSON + */ + backup(storage: 'local' | 'session' = 'local'): string { + try { + const storageInstance = storage === 'session' ? sessionStorage : localStorage; + const backup: Record = {}; + + for (let i = 0; i < storageInstance.length; i++) { + const key = storageInstance.key(i); + if (key) { + const value = storageInstance.getItem(key); + if (value) { + backup[key] = value; + } + } + } + + return JSON.stringify({ + timestamp: new Date().toISOString(), + storage: storage, + data: backup + }, null, 2); + } catch (error) { + console.error('Storage error creating backup:', error); + return '{}'; + } + } + + /** + * Restore storage from JSON backup + */ + restore(backupData: string, storage: 'local' | 'session' = 'local'): boolean { + try { + const backup = JSON.parse(backupData); + const storageInstance = storage === 'session' ? sessionStorage : localStorage; + + if (backup.data) { + Object.entries(backup.data).forEach(([key, value]) => { + if (typeof value === 'string') { + storageInstance.setItem(key, value); + } + }); + return true; + } + + return false; + } catch (error) { + console.error('Storage error restoring backup:', error); + return false; + } + } + + // Encryption utilities (basic implementation - use proper crypto in production) + private encrypt(text: string): string { + try { + // This is a simple XOR cipher - replace with proper encryption in production + let result = ''; + for (let i = 0; i < text.length; i++) { + result += String.fromCharCode( + text.charCodeAt(i) ^ this.encryptionKey.charCodeAt(i % this.encryptionKey.length) + ); + } + return btoa(result); + } catch (error) { + console.error('Encryption error:', error); + return text; + } + } + + private decrypt(encryptedText: string): string { + try { + const text = atob(encryptedText); + let result = ''; + for (let i = 0; i < text.length; i++) { + result += String.fromCharCode( + text.charCodeAt(i) ^ this.encryptionKey.charCodeAt(i % this.encryptionKey.length) + ); + } + return result; + } catch (error) { + console.error('Decryption error:', error); + return encryptedText; + } + } + + // Convenience methods for common operations + /** + * Store user authentication data + */ + setAuthData(data: { + access_token: string; + refresh_token?: string; + user_data?: any; + tenant_id?: string; + }): boolean { + const success = [ + this.setItem('access_token', data.access_token, { encrypt: true }), + data.refresh_token ? this.setItem('refresh_token', data.refresh_token, { encrypt: true }) : true, + data.user_data ? this.setItem('user_data', data.user_data) : true, + data.tenant_id ? this.setItem('tenant_id', data.tenant_id) : true, + ].every(Boolean); + + return success; + } + + /** + * Clear all authentication data + */ + clearAuthData(): boolean { + return [ + this.removeItem('access_token'), + this.removeItem('refresh_token'), + this.removeItem('user_data'), + this.removeItem('tenant_id'), + ].every(Boolean); + } + + /** + * Store app preferences + */ + setPreferences(preferences: Record): boolean { + return this.setItem('app_preferences', preferences); + } + + /** + * Get app preferences + */ + getPreferences>(): T | null { + return this.getItem('app_preferences'); + } + + /** + * Store temporary session data with automatic expiration + */ + setSessionData(key: string, data: any, expiresInMinutes: number = 30): boolean { + return this.setItem(key, data, { + storage: 'session', + expiresIn: expiresInMinutes * 60 * 1000 + }); + } + + /** + * Get temporary session data + */ + getSessionData(key: string): T | null { + return this.getItem(key, 'session'); + } + + /** + * Check storage availability + */ + isStorageAvailable(storage: 'local' | 'session' = 'local'): boolean { + try { + const storageInstance = storage === 'session' ? sessionStorage : localStorage; + const test = '__storage_test__'; + storageInstance.setItem(test, test); + storageInstance.removeItem(test); + return true; + } catch (error) { + return false; + } + } +} + +// Export singleton instance +export const storageService = new StorageService(); + +// Export class for testing or multiple instances +export { StorageService }; + +// Legacy compatibility functions +export const getStorageItem = (key: string): T | null => storageService.getItem(key); +export const setStorageItem = (key: string, value: T, options?: StorageOptions): boolean => + storageService.setItem(key, value, options); +export const removeStorageItem = (key: string): boolean => storageService.removeItem(key); \ No newline at end of file diff --git a/frontend/src/types/api.types.ts b/frontend/src/types/api.types.ts index df7d6bbf..19a11b2e 100644 --- a/frontend/src/types/api.types.ts +++ b/frontend/src/types/api.types.ts @@ -2,11 +2,11 @@ * API Response Types - Matching actual backend implementation */ -// Standard FastAPI response structure +// Standard API response structure (matching client.ts transformResponse) export interface ApiResponse { - data?: T; - success?: boolean; - message?: string; + data: T; + success: boolean; + message: string; detail?: string; error?: string; } diff --git a/frontend/src/types/auth.types.ts b/frontend/src/types/auth.types.ts index da61ae78..3e3fd1f1 100644 --- a/frontend/src/types/auth.types.ts +++ b/frontend/src/types/auth.types.ts @@ -177,4 +177,14 @@ export const isTokenResponse = (obj: any): obj is TokenResponse => { export const isAuthError = (obj: any): obj is AuthError => { return obj && typeof obj.detail === 'string'; -}; \ No newline at end of file +}; + +// Onboarding status types (moved from onboarding) +export interface OnboardingStatus { + completed: boolean; + steps_completed: string[]; +} + +export interface OnboardingProgressRequest { + metadata?: any; +} \ No newline at end of file diff --git a/frontend/src/types/inventory.types.ts b/frontend/src/types/inventory.types.ts index b665d853..62aa87a0 100644 --- a/frontend/src/types/inventory.types.ts +++ b/frontend/src/types/inventory.types.ts @@ -437,4 +437,60 @@ export const isStock = (obj: any): obj is Stock => { export const isStockMovement = (obj: any): obj is StockMovement => { return obj && typeof obj.id === 'string' && obj.movement_type && obj.quantity !== undefined; -}; \ No newline at end of file +}; + +// Product classification and suggestion types (moved from onboarding) +export interface ProductSuggestion { + 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; + }; +} + +export interface BusinessModelAnalysis { + model: 'production' | 'retail' | 'hybrid'; + confidence: number; + ingredient_count: number; + finished_product_count: number; + ingredient_ratio: number; + recommendations: string[]; +} + +export interface ProductSuggestionsResponse { + suggestions: ProductSuggestion[]; + business_model_analysis: BusinessModelAnalysis; + total_products: number; + high_confidence_count: number; + low_confidence_count: number; + processing_time_seconds: number; +} + +export interface InventoryCreationResponse { + created_items: any[]; + failed_items: any[]; + total_approved: number; + success_rate: number; + inventory_mapping?: { [productName: string]: string }; +} + +export interface BatchClassificationRequest { + products: Array<{ + product_name: string; + sales_data?: any; + }>; +} \ No newline at end of file diff --git a/frontend/src/types/sales.types.ts b/frontend/src/types/sales.types.ts index 1486417c..d4816b4f 100644 --- a/frontend/src/types/sales.types.ts +++ b/frontend/src/types/sales.types.ts @@ -599,4 +599,24 @@ export const isProductPerformance = (obj: any): obj is ProductPerformance => { export const isSalesSummary = (obj: any): obj is SalesSummary => { return obj && typeof obj.total_revenue === 'number' && typeof obj.total_quantity === 'number'; -}; \ No newline at end of file +}; + +// Business model and onboarding guide types (moved from onboarding) +export interface BusinessModelGuide { + title: string; + description: string; + next_steps: string[]; + recommended_features: string[]; + sample_workflows: string[]; +} + +export enum BusinessModelType { + PRODUCTION = 'production', + RETAIL = 'retail', + HYBRID = 'hybrid', +} + +// Utility function for downloading templates (moved from onboarding) +export interface TemplateData { + template: string | any; +} \ No newline at end of file diff --git a/gateway/app/routes/tenant.py b/gateway/app/routes/tenant.py index ed7ceae6..3a836cb0 100644 --- a/gateway/app/routes/tenant.py +++ b/gateway/app/routes/tenant.py @@ -105,13 +105,13 @@ async def proxy_tenant_analytics(request: Request, tenant_id: str = Path(...), p async def proxy_tenant_training(request: Request, tenant_id: str = Path(...), path: str = ""): """Proxy tenant training requests to training service""" target_path = f"/api/v1/tenants/{tenant_id}/training/{path}".rstrip("/") - return await _proxy_to_training_service(request, target_path) + return await _proxy_to_training_service(request, target_path, tenant_id=tenant_id) @router.api_route("/{tenant_id}/models/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"]) async def proxy_tenant_models(request: Request, tenant_id: str = Path(...), path: str = ""): """Proxy tenant model requests to training service""" target_path = f"/api/v1/tenants/{tenant_id}/models/{path}".rstrip("/") - return await _proxy_to_training_service(request, target_path) + return await _proxy_to_training_service(request, target_path, tenant_id=tenant_id) # ================================================================ # TENANT-SCOPED FORECASTING SERVICE ENDPOINTS @@ -221,9 +221,9 @@ async def _proxy_to_external_service(request: Request, target_path: str): """Proxy request to external service""" return await _proxy_request(request, target_path, settings.EXTERNAL_SERVICE_URL) -async def _proxy_to_training_service(request: Request, target_path: str): +async def _proxy_to_training_service(request: Request, target_path: str, tenant_id: str = None): """Proxy request to training service""" - return await _proxy_request(request, target_path, settings.TRAINING_SERVICE_URL) + return await _proxy_request(request, target_path, settings.TRAINING_SERVICE_URL, tenant_id=tenant_id) async def _proxy_to_forecasting_service(request: Request, target_path: str, tenant_id: str = None): """Proxy request to forecasting service""" @@ -284,6 +284,11 @@ async def _proxy_request(request: Request, target_path: str, service_url: str, t headers["x-user-role"] = str(user.get('role', 'user')) headers["x-user-full-name"] = str(user.get('full_name', '')) headers["x-tenant-id"] = tenant_id or str(user.get('tenant_id', '')) + # Debug logging + logger.info(f"Forwarding request to {url} with user context: user_id={user.get('user_id')}, email={user.get('email')}, tenant_id={tenant_id}") + else: + # Debug logging when no user context available + logger.warning(f"No user context available when forwarding request to {url}. request.state.user: {getattr(request.state, 'user', 'NOT_SET')}") # Get request body if present body = None