Start integrating the onboarding flow with backend 4

This commit is contained in:
Urtzi Alfaro
2025-09-05 12:55:26 +02:00
parent 0faaa25e58
commit 3fe1f17610
26 changed files with 2161 additions and 1002 deletions

View File

@@ -46,13 +46,11 @@ export const OnboardingWizard: React.FC<OnboardingWizardProps> = ({
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<OnboardingWizardProps> = ({
allStepData: stepData
}}
onDataChange={(data) => {
console.log(`OnboardingWizard - Step ${currentStep.id} calling onDataChange with:`, data);
updateStepData(currentStep.id, data);
}}
onNext={goToNextStep}

View File

@@ -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<OnboardingStepProps> = ({
}) => {
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<CompletionStats | null>(null);
const [isImportingSales, setIsImportingSales] = useState(false);
@@ -47,10 +55,10 @@ export const CompletionStep: React.FC<OnboardingStepProps> = ({
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<OnboardingStepProps> = ({
};
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 = [

View File

@@ -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<OnboardingStepProps> = ({
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<OnboardingStepProps> = ({
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<OnboardingStepProps> = ({
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<OnboardingStepProps> = ({
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',

View File

@@ -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<OnboardingStepProps> = ({
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<InventoryItem | null>(null);
const [isAddingNew, setIsAddingNew] = useState(false);
@@ -104,16 +129,30 @@ export const InventorySetupStep: React.FC<OnboardingStepProps> = ({
? 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<OnboardingStepProps> = ({
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<OnboardingStepProps> = ({
});
}, [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<OnboardingStepProps> = ({
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<OnboardingStepProps> = ({
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<Card className="p-6 text-center">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="p-6 text-center bg-[var(--bg-primary)] border-[var(--border-primary)]">
<div className="text-3xl font-bold text-[var(--color-primary)] mb-2">{items.length}</div>
<div className="text-sm text-[var(--text-secondary)]">Elementos totales</div>
<div className="text-sm font-medium text-[var(--text-secondary)]">Elementos totales</div>
</Card>
<Card className="p-6 text-center">
<div className="text-3xl font-bold text-blue-600 mb-2">
<Card className="p-6 text-center bg-[var(--color-info)]/5 border-[var(--color-info)]/20">
<div className="text-3xl font-bold text-[var(--color-info)] mb-2">
{items.filter(item => item.category === 'ingredient').length}
</div>
<div className="text-sm text-[var(--text-secondary)]">Ingredientes</div>
<div className="text-sm font-medium text-[var(--color-info)]">Ingredientes</div>
</Card>
<Card className="p-6 text-center">
<div className="text-3xl font-bold text-green-600 mb-2">
<Card className="p-6 text-center bg-[var(--color-success)]/5 border-[var(--color-success)]/20">
<div className="text-3xl font-bold text-[var(--color-success)] mb-2">
{items.filter(item => item.category === 'finished_product').length}
</div>
<div className="text-sm text-[var(--text-secondary)]">Productos terminados</div>
<div className="text-sm font-medium text-[var(--color-success)]">Productos terminados</div>
</Card>
<Card className="p-6 text-center">
<div className="text-3xl font-bold text-red-600 mb-2">
<Card className="p-6 text-center bg-[var(--color-error)]/5 border-[var(--color-error)]/20">
<div className="text-3xl font-bold text-[var(--color-error)] mb-2">
{items.filter(item => getStockStatus(item) === 'critical').length}
</div>
<div className="text-sm text-[var(--text-secondary)]">Stock crítico</div>
<div className="text-sm font-medium text-[var(--color-error)]">Stock crítico</div>
</Card>
</div>
{/* Controls */}
<div className="flex flex-col sm:flex-row gap-4 justify-between items-start sm:items-center bg-[var(--bg-secondary)] p-4 rounded-lg">
<div className="flex flex-col sm:flex-row gap-4 justify-between items-start sm:items-center bg-[var(--bg-secondary)] p-4 rounded-lg border border-[var(--border-secondary)]">
<div className="flex items-center space-x-4">
<select
value={filterCategory}
onChange={(e) => setFilterCategory(e.target.value as any)}
className="px-3 py-2 border border-[var(--border-primary)] rounded-lg bg-white"
className="px-3 py-2 border border-[var(--border-primary)] rounded-lg bg-[var(--bg-primary)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-[var(--color-primary)]"
>
<option value="all">Todos los elementos</option>
<option value="ingredient">Ingredientes</option>
<option value="finished_product">Productos terminados</option>
</select>
<Badge variant="outline" className="text-sm">
<Badge variant="outline" className="text-sm font-medium px-3 py-1 bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-secondary)]">
{filteredItems.length} elementos
</Badge>
</div>
@@ -313,72 +472,96 @@ export const InventorySetupStep: React.FC<OnboardingStepProps> = ({
<Button
onClick={handleAddItem}
size="sm"
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary)]/90"
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary)]/90 text-white shadow-sm"
>
<Plus className="w-4 h-4 mr-1" />
Agregar elemento
</Button>
<Button
onClick={handleCreateInventory}
disabled={isCreating || items.length === 0 || data.inventoryConfigured}
disabled={isCreating || items.length === 0 || data.inventoryCreated}
size="sm"
variant="outline"
className="border-green-200 text-green-600 hover:bg-green-50"
className="border-[var(--color-success)]/30 text-[var(--color-success)] hover:bg-[var(--color-success)]/10 bg-[var(--color-success)]/5 disabled:opacity-50 disabled:cursor-not-allowed"
>
<CheckCircle className="w-4 h-4 mr-1" />
{isCreating ? 'Creando...' : data.inventoryConfigured ? 'Inventario creado' : 'Crear inventario'}
{isCreating ? 'Creando...' : data.inventoryCreated ? 'Inventario creado automáticamente' : 'Crear inventario manualmente'}
</Button>
</div>
</div>
{/* Items List */}
<div className="space-y-3">
<div className="space-y-4">
{filteredItems.map((item) => (
<Card key={item.id} className="p-4 hover:shadow-md transition-shadow">
<Card key={item.id} className="p-6 hover:shadow-lg transition-all duration-200 border border-[var(--border-primary)] bg-[var(--bg-primary)]">
{editingItem?.id === item.id ? (
<InventoryItemEditor
item={item}
onSave={handleSaveItem}
onCancel={handleCancelEdit}
showToast={showToast}
/>
) : (
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3 mb-2">
<h3 className="font-semibold text-[var(--text-primary)]">{item.name}</h3>
<Badge variant="outline" className="text-xs">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0 pr-4">
<div className="flex items-center flex-wrap gap-2 mb-3">
<h3 className="font-semibold text-lg text-[var(--text-primary)] mr-2">{item.name}</h3>
<Badge className={`text-xs font-medium px-2 py-1 rounded-full border ${getStockStatus(item) === 'critical' ? getStockStatusColor('critical') : getStockStatus(item) === 'warning' ? getStockStatusColor('warning') : getStockStatusColor('good')}`}>
Stock: {getStockStatus(item) === 'critical' ? '🔴 Crítico' : getStockStatus(item) === 'warning' ? '🟡 Bajo' : '🟢 Bueno'}
</Badge>
</div>
<div className="flex items-center flex-wrap gap-2 mb-3">
<Badge className="text-xs font-medium px-2 py-1 rounded-full bg-[var(--color-primary)]/10 text-[var(--color-primary)] border border-[var(--color-primary)]/20">
{item.category === 'ingredient' ? 'Ingrediente' : 'Producto terminado'}
</Badge>
<Badge className={`text-xs ${getStockStatusColor(getStockStatus(item))}`}>
Stock: {getStockStatus(item)}
</Badge>
{item.requires_refrigeration && (
<Badge variant="outline" className="text-xs text-blue-600">
Refrigeración
<Badge className="text-xs font-medium px-2 py-1 rounded-full bg-[var(--color-info)]/10 text-[var(--color-info)] border border-[var(--color-info)]/20">
Refrigeración
</Badge>
)}
</div>
<div className="text-sm text-[var(--text-secondary)] space-y-1">
<div className="flex space-x-4">
<span>Stock actual: <span className="font-medium">{item.current_stock} {item.unit}</span></span>
<span>Mínimo: <span className="font-medium">{item.min_stock}</span></span>
<span>Máximo: <span className="font-medium">{item.max_stock}</span></span>
<div className="text-sm text-[var(--text-secondary)] space-y-2">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="flex items-center">
<span className="font-medium text-[var(--text-tertiary)] min-w-[100px]">Stock actual:</span>
<span className="font-bold text-[var(--text-primary)]">{item.current_stock} {item.unit}</span>
</div>
<div className="flex items-center">
<span className="font-medium text-[var(--text-tertiary)] min-w-[70px]">Mínimo:</span>
<span className="font-medium text-[var(--text-primary)]">{item.min_stock}</span>
</div>
<div className="flex items-center">
<span className="font-medium text-[var(--text-tertiary)] min-w-[70px]">Máximo:</span>
<span className="font-medium text-[var(--text-primary)]">{item.max_stock}</span>
</div>
</div>
{item.expiry_date && (
<div>Vence: <span className="font-medium">{item.expiry_date}</span></div>
)}
{item.supplier && (
<div>Proveedor: <span className="font-medium">{item.supplier}</span></div>
{(item.expiry_date || item.supplier) && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{item.expiry_date && (
<div className="flex items-center">
<span className="font-medium text-[var(--text-tertiary)] min-w-[60px]">Vence:</span>
<span className="font-medium text-[var(--text-primary)]">{item.expiry_date}</span>
</div>
)}
{item.supplier && (
<div className="flex items-center">
<span className="font-medium text-[var(--text-tertiary)] min-w-[80px]">Proveedor:</span>
<span className="font-medium text-[var(--text-primary)]">{item.supplier}</span>
</div>
)}
</div>
)}
</div>
</div>
<div className="flex items-center space-x-2">
<div className="flex flex-col sm:flex-row gap-2 flex-shrink-0">
<Button
size="sm"
variant="outline"
onClick={() => setEditingItem(item)}
className="text-[var(--color-primary)] border-[var(--color-primary)]/30 hover:bg-[var(--color-primary)]/10 bg-[var(--color-primary)]/5"
>
<Edit className="w-4 h-4 mr-1" />
Editar
@@ -387,7 +570,7 @@ export const InventorySetupStep: React.FC<OnboardingStepProps> = ({
size="sm"
variant="outline"
onClick={() => handleDeleteItem(item.id)}
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"
>
<Trash2 className="w-4 h-4 mr-1" />
Eliminar
@@ -399,23 +582,21 @@ export const InventorySetupStep: React.FC<OnboardingStepProps> = ({
))}
</div>
{/* Navigation */}
<div className="flex justify-between pt-6 border-t border-[var(--border-primary)]">
<Button
variant="outline"
onClick={onPrevious}
disabled={isFirstStep}
>
Anterior
</Button>
<Button
onClick={onNext}
disabled={!data.inventoryConfigured}
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary)]/90"
>
{isLastStep ? 'Finalizar' : 'Siguiente'}
</Button>
</div>
{/* Information */}
<Card className="p-4 bg-[var(--color-info)]/5 border-[var(--color-info)]/20">
<h4 className="font-medium text-[var(--color-info)] mb-2">
📦 Configuración de Inventario:
</h4>
<ul className="text-sm text-[var(--color-info)] space-y-1">
<li> <strong>Configure stock inicial</strong> - Establezca los niveles de stock actuales para cada producto</li>
<li> <strong>Defina límites</strong> - Establezca stock mínimo y máximo para recibir alertas automáticas</li>
<li> <strong>Agregue detalles</strong> - Incluya fechas de vencimiento, proveedores y unidades de medida</li>
<li> <strong>Marque refrigeración</strong> - Indique qué productos requieren condiciones especiales de almacenamiento</li>
<li> <strong>Edite elementos</strong> - Haga clic en "Editar" para modificar cualquier producto</li>
<li> <strong>Creación automática</strong> - El inventario se creará automáticamente al hacer clic en "Siguiente"</li>
</ul>
</Card>
</div>
);
};
@@ -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<InventoryItem>(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 (
<div className="space-y-4 p-4 bg-gray-50 rounded-lg">
<div className="space-y-4 p-6 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-secondary)]">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">Nombre</label>
<label className="block text-sm font-medium mb-2 text-[var(--text-primary)]">Nombre</label>
<Input
value={editedItem.name}
onChange={(e) => setEditedItem({ ...editedItem, name: e.target.value })}
placeholder="Nombre del producto"
className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Categoría</label>
<label className="block text-sm font-medium mb-2 text-[var(--text-primary)]">Categoría</label>
<select
value={editedItem.category}
onChange={(e) => setEditedItem({ ...editedItem, category: e.target.value as any })}
className="w-full px-3 py-2 border border-[var(--border-primary)] rounded-lg bg-white"
className="w-full px-3 py-2 border border-[var(--border-primary)] rounded-lg bg-[var(--bg-primary)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-[var(--color-primary)]"
>
<option value="ingredient">Ingrediente</option>
<option value="finished_product">Producto terminado</option>
@@ -465,59 +656,65 @@ const InventoryItemEditor: React.FC<{
</div>
<div>
<label className="block text-sm font-medium mb-1">Stock actual</label>
<label className="block text-sm font-medium mb-2 text-[var(--text-primary)]">Stock actual</label>
<Input
type="number"
value={editedItem.current_stock}
onChange={(e) => setEditedItem({ ...editedItem, current_stock: Number(e.target.value) })}
min="0"
className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Unidad</label>
<label className="block text-sm font-medium mb-2 text-[var(--text-primary)]">Unidad</label>
<Input
value={editedItem.unit}
onChange={(e) => setEditedItem({ ...editedItem, unit: e.target.value })}
placeholder="kg, litros, unidades..."
className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Stock mínimo</label>
<label className="block text-sm font-medium mb-2 text-[var(--text-primary)]">Stock mínimo</label>
<Input
type="number"
value={editedItem.min_stock}
onChange={(e) => setEditedItem({ ...editedItem, min_stock: Number(e.target.value) })}
min="0"
className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Stock máximo</label>
<label className="block text-sm font-medium mb-2 text-[var(--text-primary)]">Stock máximo</label>
<Input
type="number"
value={editedItem.max_stock}
onChange={(e) => setEditedItem({ ...editedItem, max_stock: Number(e.target.value) })}
min="1"
className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Fecha de vencimiento</label>
<label className="block text-sm font-medium mb-2 text-[var(--text-primary)]">Fecha de vencimiento</label>
<Input
type="date"
value={editedItem.expiry_date || ''}
onChange={(e) => setEditedItem({ ...editedItem, expiry_date: e.target.value })}
className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Proveedor</label>
<label className="block text-sm font-medium mb-2 text-[var(--text-primary)]">Proveedor</label>
<Input
value={editedItem.supplier || ''}
onChange={(e) => setEditedItem({ ...editedItem, supplier: e.target.value })}
placeholder="Nombre del proveedor"
className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]"
/>
</div>
</div>
@@ -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)]"
/>
<span className="text-sm">Requiere refrigeración</span>
<span className="text-sm font-medium text-[var(--text-primary)]">Requiere refrigeración</span>
</label>
</div>
<div className="flex justify-end space-x-2">
<Button variant="outline" onClick={onCancel}>
<div className="flex justify-end space-x-3 pt-4 border-t border-[var(--border-primary)]">
<Button
variant="outline"
onClick={onCancel}
className="text-[var(--text-secondary)] border-[var(--border-primary)] hover:bg-[var(--bg-tertiary)]"
>
Cancelar
</Button>
<Button onClick={handleSave}>
<Button
onClick={handleSave}
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary)]/90 text-white"
>
Guardar
</Button>
</div>

View File

@@ -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<TrainingJob> {
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<TrainingJob> {
return this.apiCall(`/tenants/${tenantId}/training/jobs/${jobId}`);
}
async getTrainingJobs(tenantId: string): Promise<TrainingJob[]> {
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<OnboardingStepProps> = ({
data,
@@ -114,13 +78,25 @@ export const MLTrainingStep: React.FC<OnboardingStepProps> = ({
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<OnboardingStepProps> = ({
isValid: missingItems.length === 0,
missingItems,
hasProcessingResults,
hasImportResults,
hasApprovedProducts,
hasInventoryConfig
});
@@ -205,7 +182,11 @@ export const MLTrainingStep: React.FC<OnboardingStepProps> = ({
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');

View File

@@ -227,16 +227,16 @@ export const ReviewStep: React.FC<OnboardingStepProps> = ({
};
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<OnboardingStepProps> = ({
return (
<div className="space-y-8">
{/* Summary Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<Card className="p-6 text-center">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="p-6 text-center bg-[var(--bg-primary)] border-[var(--border-primary)]">
<div className="text-3xl font-bold text-[var(--color-primary)] mb-2">{stats.total}</div>
<div className="text-sm text-[var(--text-secondary)]">Productos detectados</div>
<div className="text-sm font-medium text-[var(--text-secondary)]">Productos detectados</div>
</Card>
<Card className="p-6 text-center">
<div className="text-3xl font-bold text-green-600 mb-2">{stats.approved}</div>
<div className="text-sm text-[var(--text-secondary)]">Aprobados</div>
<Card className="p-6 text-center bg-[var(--color-success)]/5 border-[var(--color-success)]/20">
<div className="text-3xl font-bold text-[var(--color-success)] mb-2">{stats.approved}</div>
<div className="text-sm font-medium text-[var(--color-success)]">Aprobados</div>
</Card>
<Card className="p-6 text-center">
<div className="text-3xl font-bold text-red-600 mb-2">{stats.rejected}</div>
<div className="text-sm text-[var(--text-secondary)]">Rechazados</div>
<Card className="p-6 text-center bg-[var(--color-error)]/5 border-[var(--color-error)]/20">
<div className="text-3xl font-bold text-[var(--color-error)] mb-2">{stats.rejected}</div>
<div className="text-sm font-medium text-[var(--color-error)]">Rechazados</div>
</Card>
<Card className="p-6 text-center">
<div className="text-3xl font-bold text-yellow-600 mb-2">{stats.pending}</div>
<div className="text-sm text-[var(--text-secondary)]">Pendientes</div>
<Card className="p-6 text-center bg-[var(--color-warning)]/5 border-[var(--color-warning)]/20">
<div className="text-3xl font-bold text-[var(--color-warning)] mb-2">{stats.pending}</div>
<div className="text-sm font-medium text-[var(--color-warning)]">Pendientes</div>
</Card>
</div>
{/* Controls */}
<div className="flex flex-col sm:flex-row gap-4 justify-between items-start sm:items-center bg-[var(--bg-secondary)] p-4 rounded-lg">
<div className="flex flex-col sm:flex-row gap-4 justify-between items-start sm:items-center bg-[var(--bg-secondary)] p-4 rounded-lg border border-[var(--border-secondary)]">
<div className="flex items-center space-x-4">
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className="px-3 py-2 border border-[var(--border-primary)] rounded-lg bg-white"
className="px-3 py-2 border border-[var(--border-primary)] rounded-lg bg-[var(--bg-primary)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-[var(--color-primary)]"
>
{categories.map(category => (
<option key={category} value={category}>
@@ -304,7 +304,7 @@ export const ReviewStep: React.FC<OnboardingStepProps> = ({
))}
</select>
<Badge variant="outline" className="text-sm">
<Badge variant="outline" className="text-sm font-medium px-3 py-1 bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-secondary)]">
{getFilteredProducts().length} productos
</Badge>
</div>
@@ -314,7 +314,7 @@ export const ReviewStep: React.FC<OnboardingStepProps> = ({
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
</Button>
@@ -322,7 +322,7 @@ export const ReviewStep: React.FC<OnboardingStepProps> = ({
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
</Button>
@@ -330,48 +330,70 @@ export const ReviewStep: React.FC<OnboardingStepProps> = ({
</div>
{/* Products List */}
<div className="space-y-3">
<div className="space-y-4">
{getFilteredProducts().map((product) => (
<Card key={product.id} className="p-4 hover:shadow-md transition-shadow">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3 mb-2">
<h3 className="font-semibold text-[var(--text-primary)]">{product.name}</h3>
<Badge variant="outline" className="text-xs">
<Card key={product.id} className="p-6 hover:shadow-lg transition-all duration-200 border border-[var(--border-primary)] bg-[var(--bg-primary)]">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0 pr-4">
<div className="flex items-center flex-wrap gap-2 mb-3">
<h3 className="font-semibold text-lg text-[var(--text-primary)] mr-2">{product.name}</h3>
<Badge className={`text-xs font-medium px-2 py-1 rounded-full border ${getStatusColor(product.status)}`}>
{product.status === 'approved' ? '✓ Aprobado' :
product.status === 'rejected' ? '✗ Rechazado' : '⏳ Pendiente'}
</Badge>
</div>
<div className="flex items-center flex-wrap gap-2 mb-3">
<Badge className="text-xs font-medium px-2 py-1 rounded-full bg-[var(--color-primary)]/10 text-[var(--color-primary)] border border-[var(--color-primary)]/20">
{product.category}
</Badge>
<Badge className={`text-xs ${getConfidenceColor(product.confidence)}`}>
<Badge className={`text-xs font-medium px-2 py-1 rounded-full border ${getConfidenceColor(product.confidence)}`}>
{product.confidence}% confianza
</Badge>
<Badge className={`text-xs ${getStatusColor(product.status)}`}>
{product.status === 'approved' ? 'Aprobado' :
product.status === 'rejected' ? 'Rechazado' : 'Pendiente'}
</Badge>
</div>
<div className="text-sm text-[var(--text-secondary)] space-y-1">
<div className="text-sm text-[var(--text-secondary)] space-y-2">
{product.original_name && product.original_name !== product.name && (
<div>Nombre original: <span className="font-medium">{product.original_name}</span></div>
<div className="flex items-center">
<span className="font-medium text-[var(--text-tertiary)] min-w-[120px]">Nombre original:</span>
<span className="font-medium text-[var(--text-primary)]">{product.original_name}</span>
</div>
)}
<div className="flex space-x-4">
<span>Tipo: {product.product_type === 'ingredient' ? 'Ingrediente' : 'Producto terminado'}</span>
<span>Unidad: {product.unit_of_measure}</span>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div className="flex items-center">
<span className="font-medium text-[var(--text-tertiary)] min-w-[60px]">Tipo:</span>
<span className="font-medium text-[var(--text-primary)]">
{product.product_type === 'ingredient' ? 'Ingrediente' : 'Producto terminado'}
</span>
</div>
<div className="flex items-center">
<span className="font-medium text-[var(--text-tertiary)] min-w-[60px]">Unidad:</span>
<span className="font-medium text-[var(--text-primary)]">{product.unit_of_measure}</span>
</div>
{product.sales_data && (
<span>Ventas: {product.sales_data.total_quantity}</span>
<div className="flex items-center">
<span className="font-medium text-[var(--text-tertiary)] min-w-[60px]">Ventas:</span>
<span className="font-medium text-[var(--text-primary)]">{product.sales_data.total_quantity}</span>
</div>
)}
</div>
{product.notes && (
<div className="text-xs italic">Nota: {product.notes}</div>
<div className="text-xs italic bg-[var(--bg-secondary)] p-2 rounded border-l-4 border-[var(--color-primary)]/30">
<span className="font-medium text-[var(--text-tertiary)]">Nota:</span> {product.notes}
</div>
)}
</div>
</div>
<div className="flex items-center space-x-2">
<div className="flex flex-col sm:flex-row gap-2 flex-shrink-0">
<Button
size="sm"
variant={product.status === 'approved' ? 'default' : 'outline'}
onClick={() => handleProductAction(product.id, 'approve')}
className={product.status === 'approved' ? 'bg-green-600 hover:bg-green-700' : 'text-green-600 border-green-200 hover:bg-green-50'}
className={product.status === 'approved'
? 'bg-[var(--color-success)] hover:bg-[var(--color-success)]/90 text-white shadow-sm'
: 'text-[var(--color-success)] border-[var(--color-success)]/30 hover:bg-[var(--color-success)]/10 bg-[var(--color-success)]/5'
}
>
<CheckCircle className="w-4 h-4 mr-1" />
Aprobar
@@ -380,7 +402,10 @@ export const ReviewStep: React.FC<OnboardingStepProps> = ({
size="sm"
variant={product.status === 'rejected' ? 'default' : 'outline'}
onClick={() => handleProductAction(product.id, 'reject')}
className={product.status === 'rejected' ? 'bg-red-600 hover:bg-red-700' : 'text-red-600 border-red-200 hover:bg-red-50'}
className={product.status === 'rejected'
? 'bg-[var(--color-error)] hover:bg-[var(--color-error)]/90 text-white shadow-sm'
: 'text-[var(--color-error)] border-[var(--color-error)]/30 hover:bg-[var(--color-error)]/10 bg-[var(--color-error)]/5'
}
>
<AlertCircle className="w-4 h-4 mr-1" />
Rechazar
@@ -391,23 +416,21 @@ export const ReviewStep: React.FC<OnboardingStepProps> = ({
))}
</div>
{/* Navigation */}
<div className="flex justify-between pt-6 border-t border-[var(--border-primary)]">
<Button
variant="outline"
onClick={onPrevious}
disabled={isFirstStep}
>
Anterior
</Button>
<Button
onClick={onNext}
disabled={!data.reviewCompleted || stats.approved === 0}
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary)]/90"
>
{isLastStep ? 'Finalizar' : 'Siguiente'}
</Button>
</div>
{/* Information */}
<Card className="p-4 bg-[var(--color-info)]/5 border-[var(--color-info)]/20">
<h4 className="font-medium text-[var(--color-info)] mb-2">
📋 Revisión de Productos Detectados:
</h4>
<ul className="text-sm text-[var(--color-info)] space-y-1">
<li> <strong>Revise cuidadosamente</strong> - Los productos fueron detectados automáticamente desde sus datos de ventas</li>
<li> <strong>Apruebe o rechace</strong> cada producto según sea correcto para su negocio</li>
<li> <strong>Verifique nombres</strong> - Compare el nombre original vs. el nombre sugerido</li>
<li> <strong>Revise clasificaciones</strong> - Confirme si son ingredientes o productos terminados</li>
<li> <strong>Use filtros</strong> - Filtre por categoría para revisar productos similares</li>
<li> <strong>Acciones masivas</strong> - Use "Aprobar todos" o "Rechazar todos" para agilizar el proceso</li>
</ul>
</Card>
</div>
);
};

View File

@@ -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<OnboardingStepProps> = ({
const user = useAuthUser();
const currentTenant = useCurrentTenant();
const { createAlert } = useAlertActions();
const { showToast } = useToast();
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
// Use modals for confirmations and editing
const deleteModal = useModal();
const editModal = useModal();
const [suppliers, setSuppliers] = useState<any[]>([]);
const [editingSupplier, setEditingSupplier] = useState<SupplierFormData | null>(null);
const [isAddingNew, setIsAddingNew] = useState(false);
const [filterStatus, setFilterStatus] = useState<'all' | 'active' | 'inactive'>('all');
@@ -207,9 +214,17 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
};
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<OnboardingStepProps> = ({
{stats.total} proveedores configurados ({stats.active} activos)
</p>
</div>
<div className="flex gap-2">
<Button
size="sm"
onClick={handleAddSupplier}
disabled={creating}
>
{creating ? (
<Loader className="w-4 h-4 mr-2 animate-spin" />
) : (
<Plus className="w-4 h-4 mr-2" />
)}
Agregar Proveedor
</Button>
</div>
</div>
</Card>
@@ -533,20 +533,53 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
))}
{getFilteredSuppliers().length === 0 && !loading && (
<Card className="p-8 text-center">
<Truck className="w-12 h-12 text-[var(--text-tertiary)] mx-auto mb-4" />
<p className="text-[var(--text-secondary)] mb-4">
<Card className="p-8 text-center bg-[var(--bg-primary)] border-2 border-dashed border-[var(--border-secondary)]">
<Truck className="w-16 h-16 text-[var(--color-primary)] mx-auto mb-4" />
<h3 className="text-xl font-semibold text-[var(--text-primary)] mb-2">
{filterStatus === 'all'
? 'No hay proveedores registrados'
? 'Comienza agregando tu primer proveedor'
: `No hay proveedores ${filterStatus === 'active' ? 'activos' : 'inactivos'}`
}
</h3>
<p className="text-[var(--text-secondary)] mb-6 max-w-md mx-auto">
{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.'
}
</p>
<Button onClick={handleAddSupplier} disabled={creating}>
<Plus className="w-4 h-4 mr-2" />
Agregar Primer Proveedor
<Button
onClick={handleAddSupplier}
disabled={creating}
size="lg"
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary)]/90 text-white px-8 py-3 text-base font-medium shadow-lg"
>
{creating ? (
<Loader className="w-5 h-5 mr-2 animate-spin" />
) : (
<Plus className="w-5 h-5 mr-2" />
)}
{filterStatus === 'all' ? 'Agregar Primer Proveedor' : 'Agregar Proveedor'}
</Button>
</Card>
)}
{/* Floating Action Button for when suppliers exist */}
{suppliers.length > 0 && !editingSupplier && (
<div className="fixed bottom-8 right-8 z-40">
<Button
onClick={handleAddSupplier}
disabled={creating}
className="w-14 h-14 rounded-full bg-[var(--color-primary)] hover:bg-[var(--color-primary)]/90 text-white shadow-2xl hover:shadow-3xl transition-all duration-200 hover:scale-105"
title="Agregar nuevo proveedor"
>
{creating ? (
<Loader className="w-6 h-6 animate-spin" />
) : (
<Plus className="w-6 h-6" />
)}
</Button>
</div>
)}
</div>
{/* Edit Modal */}
@@ -571,18 +604,20 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
</div>
)}
{/* Information */}
<Card className="p-4 bg-[var(--color-info)]/5 border-[var(--color-info)]/20">
<h4 className="font-medium text-[var(--color-info)] mb-2">
🚚 Gestión de Proveedores:
</h4>
<ul className="text-sm text-[var(--color-info)] space-y-1">
<li> <strong>Este paso es opcional</strong> - puedes configurar proveedores más tarde</li>
<li> Define categorías de productos para facilitar la búsqueda</li>
<li> Establece días de entrega y términos de pago</li>
<li> Configura montos mínimos de pedido para optimizar compras</li>
</ul>
</Card>
{/* Information - Only show when there are suppliers */}
{suppliers.length > 0 && (
<Card className="p-4 bg-[var(--color-info)]/5 border-[var(--color-info)]/20">
<h4 className="font-medium text-[var(--color-info)] mb-2">
🚚 Gestión de Proveedores:
</h4>
<ul className="text-sm text-[var(--color-info)] space-y-1">
<li> <strong>Editar información</strong> - Actualiza datos de contacto y términos comerciales</li>
<li> <strong>Gestionar estado</strong> - Activa o pausa proveedores según necesidades</li>
<li> <strong>Revisar rendimiento</strong> - Evalúa entregas a tiempo y calidad de productos</li>
<li> <strong>Filtrar vista</strong> - Usa los filtros para encontrar proveedores específicos</li>
</ul>
</Card>
)}
</div>
);
@@ -610,11 +645,19 @@ const SupplierForm: React.FC<SupplierFormProps> = ({
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;
}

View File

@@ -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<OnboardingData>) => void;
validateCurrentStep: () => string | null;
// Step-specific Actions
createTenant: (bakeryData: OnboardingData['bakery']) => Promise<boolean>;
processSalesFile: (file: File, onProgress: (progress: number, stage: string, message: string) => void) => Promise<boolean>;
generateInventorySuggestions: (productList: string[]) => Promise<ProductSuggestionsResponse | null>;
createInventoryFromSuggestions: (suggestions: ProductSuggestion[]) => Promise<InventoryCreationResponse | null>;
getBusinessModelGuide: (model: BusinessModelType) => Promise<BusinessModelGuide | null>;
downloadTemplate: (templateData: TemplateData, filename: string, format?: 'csv' | 'json') => void;
// Completion
completeOnboarding: () => Promise<boolean>;
checkOnboardingStatus: () => Promise<void>;
// 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<OnboardingState>({
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<OnboardingData>) => {
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<boolean> => {
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<boolean> => {
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<ProductSuggestionsResponse | null> => {
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<InventoryCreationResponse | null> => {
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<BusinessModelGuide | null> => {
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<boolean> => {
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,
};
};

View File

@@ -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<any>({});
// 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<any> } = {
'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 <LoadingSpinner overlay text="Completando configuración..." />;
// Clear error when user navigates away
useEffect(() => {
return () => {
if (error) {
clearError();
}
};
}, [error, clearError]);
// Show loading while initializing
if (!isInitialized || isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<LoadingSpinner size="lg" message="Inicializando onboarding..." />
</div>
);
}
// Don't render if not authenticated (will redirect)
if (!isAuthenticated || !user) {
return <LoadingSpinner overlay text="Verificando autenticación..." />;
// Show error state
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h2 className="text-xl font-semibold text-red-600 mb-4">Error en Onboarding</h2>
<p className="text-gray-600 mb-4">{error}</p>
<div className="space-x-4">
<button
onClick={clearError}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Reintentar
</button>
<button
onClick={reset}
className="px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
>
Reiniciar
</button>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-[var(--bg-primary)]">
<div className="min-h-screen bg-gray-50">
<OnboardingWizard
steps={steps}
steps={wizardSteps}
currentStep={currentStep}
data={data}
onStepChange={handleStepChange}
onNext={handleNext}
onPrevious={handlePrevious}
onComplete={handleComplete}
onExit={handleExit}
className="py-8"
onGoToStep={goToStep}
/>
</div>
);

View File

@@ -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<ApiResponse<OnboardingStatus>> {
try {
// Use the /me endpoint which gets proxied to auth service
const response = await apiClient.get<any>('/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<ApiResponse<{ message: string }>> {
try {
// Update user profile to mark onboarding as complete
const response = await apiClient.patch<any>('/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();

View File

@@ -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<T = any>(url: string, config = {}): Promise<ApiResponse<T>> {
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<T = any>(url: string, data = {}, config = {}): Promise<ApiResponse<T>> {
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<T = any>(url: string, data = {}, config = {}): Promise<ApiResponse<T>> {
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<T = any>(url: string, data = {}, config = {}): Promise<ApiResponse<T>> {
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<T = any>(url: string, config = {}): Promise<ApiResponse<T>> {
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<T = any>(url: string, file: File, progressCallback?: (progress: number) => void): Promise<ApiResponse<T>> {
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);
}

View File

@@ -264,4 +264,5 @@ class ForecastingService {
}
}
export { ForecastingService };
export const forecastingService = new ForecastingService();

View File

@@ -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<ApiResponse<ProductSuggestionsResponse>> {
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<ProductSuggestionsResponse>(
`${this.baseUrl}/classify-products-batch`,
requestData
);
return response;
} catch (error) {
console.error('Suggestion generation failed:', error);
throw error;
}
}
async createInventoryFromSuggestions(
approvedSuggestions: ProductSuggestion[]
): Promise<ApiResponse<InventoryCreationResponse>> {
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<any>(
'/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();

View File

@@ -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<OnboardingFileValidationResponse> {
try {
const formData = new FormData();
formData.append('file', file);
const response = await apiClient.post<OnboardingFileValidationResponse>(
`${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<ProductSuggestionsResponse> {
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<ProductSuggestionsResponse>(
`${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<InventoryCreationResponse> {
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<any>(
`${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<SalesImportResponse> {
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<SalesImportResponse>(
`${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<BusinessModelGuide> {
// 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<any> {
try {
const formData = new FormData();
formData.append('file', file);
const response = await apiClient.post<any>(
`${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<any> {
try {
const formData = new FormData();
formData.append('file', file);
formData.append('update_existing', updateExisting.toString());
const response = await apiClient.post<any>(
`${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<any> {
try {
const response = await apiClient.get<any>(
`${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<any>(
'/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<void> {
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;

View File

@@ -0,0 +1 @@
/Users/urtzialfaro/Documents/bakery-ia/frontend/src/services/api/orders.service.ts

View File

@@ -187,4 +187,6 @@ class OrdersService {
}
}
export { OrdersService };
export { OrdersService as OrderService }; // Alias for compatibility
export const ordersService = new OrdersService();

View File

@@ -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();

View File

@@ -464,4 +464,5 @@ class ProductionService {
}
}
export { ProductionService };
export const productionService = new ProductionService();

View File

@@ -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<ApiResponse<SalesImportResult>> {
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<ApiResponse<{
valid_records: number;
invalid_records: number;
errors: Array<{
row: number;
field: string;
message: string;
}>;
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<ApiResponse<BusinessModelGuide>> {
// 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();

View File

@@ -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<ApiResponse<TrainingJob[]>> {

View File

@@ -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<T = any> {
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<T>(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<T>(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<T> = 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<string, any> = {};
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<string, any>): boolean {
return this.setItem('app_preferences', preferences);
}
/**
* Get app preferences
*/
getPreferences<T = Record<string, any>>(): T | null {
return this.getItem<T>('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<T>(key: string): T | null {
return this.getItem<T>(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 = <T>(key: string): T | null => storageService.getItem<T>(key);
export const setStorageItem = <T>(key: string, value: T, options?: StorageOptions): boolean =>
storageService.setItem(key, value, options);
export const removeStorageItem = (key: string): boolean => storageService.removeItem(key);

View File

@@ -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<T = any> {
data?: T;
success?: boolean;
message?: string;
data: T;
success: boolean;
message: string;
detail?: string;
error?: string;
}

View File

@@ -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';
};
};
// Onboarding status types (moved from onboarding)
export interface OnboardingStatus {
completed: boolean;
steps_completed: string[];
}
export interface OnboardingProgressRequest {
metadata?: any;
}

View File

@@ -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;
};
};
// 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;
}>;
}

View File

@@ -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';
};
};
// 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;
}

View File

@@ -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