Start integrating the onboarding flow with backend 4
This commit is contained in:
@@ -46,13 +46,11 @@ export const OnboardingWizard: React.FC<OnboardingWizardProps> = ({
|
|||||||
const currentStep = steps[currentStepIndex];
|
const currentStep = steps[currentStepIndex];
|
||||||
|
|
||||||
const updateStepData = useCallback((stepId: string, data: any) => {
|
const updateStepData = useCallback((stepId: string, data: any) => {
|
||||||
console.log(`OnboardingWizard - Updating step '${stepId}' with data:`, data);
|
|
||||||
setStepData(prev => {
|
setStepData(prev => {
|
||||||
const newStepData = {
|
const newStepData = {
|
||||||
...prev,
|
...prev,
|
||||||
[stepId]: { ...prev[stepId], ...data }
|
[stepId]: { ...prev[stepId], ...data }
|
||||||
};
|
};
|
||||||
console.log(`OnboardingWizard - Full step data after update:`, newStepData);
|
|
||||||
return newStepData;
|
return newStepData;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -420,7 +418,6 @@ export const OnboardingWizard: React.FC<OnboardingWizardProps> = ({
|
|||||||
allStepData: stepData
|
allStepData: stepData
|
||||||
}}
|
}}
|
||||||
onDataChange={(data) => {
|
onDataChange={(data) => {
|
||||||
console.log(`OnboardingWizard - Step ${currentStep.id} calling onDataChange with:`, data);
|
|
||||||
updateStepData(currentStep.id, data);
|
updateStepData(currentStep.id, data);
|
||||||
}}
|
}}
|
||||||
onNext={goToNextStep}
|
onNext={goToNextStep}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { CheckCircle, Star, Rocket, Gift, Download, Share2, ArrowRight, Calendar } from 'lucide-react';
|
import { CheckCircle, Star, Rocket, Gift, Download, Share2, ArrowRight, Calendar } from 'lucide-react';
|
||||||
import { Button, Card, Badge } from '../../../ui';
|
import { Button, Card, Badge } from '../../../ui';
|
||||||
import { OnboardingStepProps } from '../OnboardingWizard';
|
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 { useAuthUser } from '../../../../stores/auth.store';
|
||||||
import { useAlertActions } from '../../../../stores/alerts.store';
|
import { useAlertActions } from '../../../../stores/alerts.store';
|
||||||
|
|
||||||
@@ -27,6 +29,12 @@ export const CompletionStep: React.FC<OnboardingStepProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const user = useAuthUser();
|
const user = useAuthUser();
|
||||||
const { createAlert } = useAlertActions();
|
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 [showConfetti, setShowConfetti] = useState(false);
|
||||||
const [completionStats, setCompletionStats] = useState<CompletionStats | null>(null);
|
const [completionStats, setCompletionStats] = useState<CompletionStats | null>(null);
|
||||||
const [isImportingSales, setIsImportingSales] = useState(false);
|
const [isImportingSales, setIsImportingSales] = useState(false);
|
||||||
@@ -47,10 +55,10 @@ export const CompletionStep: React.FC<OnboardingStepProps> = ({
|
|||||||
|
|
||||||
setIsImportingSales(true);
|
setIsImportingSales(true);
|
||||||
try {
|
try {
|
||||||
const result = await onboardingApiService.importSalesWithInventory(
|
// Sales data should already be imported during DataProcessingStep
|
||||||
user.tenant_id,
|
// Just create inventory items from approved suggestions
|
||||||
data.files.salesData,
|
const result = await createInventoryFromSuggestions(
|
||||||
data.inventoryMapping
|
data.approvedSuggestions || []
|
||||||
);
|
);
|
||||||
|
|
||||||
createAlert({
|
createAlert({
|
||||||
@@ -167,19 +175,28 @@ export const CompletionStep: React.FC<OnboardingStepProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
console.log('Generating certificate:', certificateData);
|
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 = () => {
|
const scheduleDemo = () => {
|
||||||
// Mock demo scheduling
|
// 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 = () => {
|
const shareSuccess = () => {
|
||||||
// Mock social sharing
|
// Mock social sharing
|
||||||
const shareText = `¡Acabo de completar la configuración de mi panadería inteligente con IA! 🥖🤖 Puntuación: ${completionStats?.completionScore}/100`;
|
const shareText = `¡Acabo de completar la configuración de mi panadería inteligente con IA! 🥖🤖 Puntuación: ${completionStats?.completionScore}/100`;
|
||||||
navigator.clipboard.writeText(shareText);
|
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 = [
|
const quickStartActions = [
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import React, { useState, useRef, useEffect } from 'react';
|
|||||||
import { Upload, Brain, CheckCircle, AlertCircle, Download, FileText, Activity, TrendingUp } from 'lucide-react';
|
import { Upload, Brain, CheckCircle, AlertCircle, Download, FileText, Activity, TrendingUp } from 'lucide-react';
|
||||||
import { Button, Card, Badge } from '../../../ui';
|
import { Button, Card, Badge } from '../../../ui';
|
||||||
import { OnboardingStepProps } from '../OnboardingWizard';
|
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 { useAuthUser, useAuthLoading } from '../../../../stores/auth.store';
|
||||||
import { useCurrentTenant, useTenantLoading } from '../../../../stores/tenant.store';
|
import { useCurrentTenant, useTenantLoading } from '../../../../stores/tenant.store';
|
||||||
import { useAlertActions } from '../../../../stores/alerts.store';
|
import { useAlertActions } from '../../../../stores/alerts.store';
|
||||||
@@ -30,70 +35,90 @@ interface ProcessingResult {
|
|||||||
recommendations: string[];
|
recommendations: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Real data processing service using backend APIs
|
// Data processing utility function
|
||||||
const dataProcessingService = {
|
const processDataFile = async (
|
||||||
processFile: async (
|
file: File,
|
||||||
file: File,
|
onProgress: (progress: number, stage: string, message: string) => void,
|
||||||
tenantId: string,
|
validateSalesData: any,
|
||||||
onProgress: (progress: number, stage: string, message: string) => void
|
generateInventorySuggestions: any
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
// Stage 1: Validate file with sales service
|
// Stage 1: Validate file with sales service
|
||||||
onProgress(20, 'validating', 'Validando estructura del archivo...');
|
onProgress(20, 'validating', 'Validando estructura del archivo...');
|
||||||
const validationResult = await onboardingApiService.validateOnboardingFile(tenantId, file);
|
const validationResult = await validateSalesData(file);
|
||||||
|
|
||||||
onProgress(40, 'validating', 'Verificando integridad de datos...');
|
onProgress(40, 'validating', 'Verificando integridad de datos...');
|
||||||
|
|
||||||
if (!validationResult.is_valid) {
|
if (!validationResult.is_valid) {
|
||||||
throw new Error('Archivo de datos inválido');
|
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 tenantLoading = useTenantLoading();
|
||||||
const { createAlert } = useAlertActions();
|
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
|
// Check if we're still loading user or tenant data
|
||||||
const isLoadingUserData = authLoading || tenantLoading;
|
const isLoadingUserData = authLoading || tenantLoading;
|
||||||
|
|
||||||
@@ -189,13 +220,21 @@ export const DataProcessingStep: React.FC<OnboardingStepProps> = ({
|
|||||||
const fileExtension = file.name.toLowerCase().substring(file.name.lastIndexOf('.'));
|
const fileExtension = file.name.toLowerCase().substring(file.name.lastIndexOf('.'));
|
||||||
|
|
||||||
if (!validExtensions.includes(fileExtension)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check file size (max 10MB)
|
// Check file size (max 10MB)
|
||||||
if (file.size > 10 * 1024 * 1024) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,14 +275,15 @@ export const DataProcessingStep: React.FC<OnboardingStepProps> = ({
|
|||||||
|
|
||||||
console.log('DataProcessingStep - Starting file processing with tenant:', tenantId);
|
console.log('DataProcessingStep - Starting file processing with tenant:', tenantId);
|
||||||
|
|
||||||
const result = await dataProcessingService.processFile(
|
const result = await processDataFile(
|
||||||
file,
|
file,
|
||||||
tenantId,
|
|
||||||
(newProgress, newStage, message) => {
|
(newProgress, newStage, message) => {
|
||||||
setProgress(newProgress);
|
setProgress(newProgress);
|
||||||
setStage(newStage as ProcessingStage);
|
setStage(newStage as ProcessingStage);
|
||||||
setCurrentMessage(message);
|
setCurrentMessage(message);
|
||||||
}
|
},
|
||||||
|
salesService.validateSalesData.bind(salesService),
|
||||||
|
inventoryService.generateInventorySuggestions.bind(inventoryService)
|
||||||
);
|
);
|
||||||
|
|
||||||
setResults(result);
|
setResults(result);
|
||||||
@@ -321,8 +361,14 @@ export const DataProcessingStep: React.FC<OnboardingStepProps> = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const templateData = await onboardingApiService.getSalesImportTemplate(tenantId, 'csv');
|
// Template download functionality can be implemented later if needed
|
||||||
onboardingApiService.downloadTemplate(templateData, 'plantilla_ventas.csv', 'csv');
|
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({
|
createAlert({
|
||||||
type: 'success',
|
type: 'success',
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { Package, Calendar, AlertTriangle, Plus, Edit, Trash2, CheckCircle } from 'lucide-react';
|
import { Package, Calendar, AlertTriangle, Plus, Edit, Trash2, CheckCircle } from 'lucide-react';
|
||||||
import { Button, Card, Input, Badge } from '../../../ui';
|
import { Button, Card, Input, Badge } from '../../../ui';
|
||||||
import { OnboardingStepProps } from '../OnboardingWizard';
|
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 { useAuthUser } from '../../../../stores/auth.store';
|
||||||
|
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||||
import { useAlertActions } from '../../../../stores/alerts.store';
|
import { useAlertActions } from '../../../../stores/alerts.store';
|
||||||
|
|
||||||
interface InventoryItem {
|
interface InventoryItem {
|
||||||
@@ -57,7 +61,28 @@ export const InventorySetupStep: React.FC<OnboardingStepProps> = ({
|
|||||||
isLastStep
|
isLastStep
|
||||||
}) => {
|
}) => {
|
||||||
const user = useAuthUser();
|
const user = useAuthUser();
|
||||||
|
const currentTenant = useCurrentTenant();
|
||||||
const { createAlert } = useAlertActions();
|
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 [isCreating, setIsCreating] = useState(false);
|
||||||
const [editingItem, setEditingItem] = useState<InventoryItem | null>(null);
|
const [editingItem, setEditingItem] = useState<InventoryItem | null>(null);
|
||||||
const [isAddingNew, setIsAddingNew] = useState(false);
|
const [isAddingNew, setIsAddingNew] = useState(false);
|
||||||
@@ -104,16 +129,30 @@ export const InventorySetupStep: React.FC<OnboardingStepProps> = ({
|
|||||||
? items
|
? items
|
||||||
: items.filter(item => item.category === filterCategory);
|
: items.filter(item => item.category === filterCategory);
|
||||||
|
|
||||||
// Create inventory items via API
|
// Create inventory items via API using hooks
|
||||||
const handleCreateInventory = async () => {
|
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;
|
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({
|
createAlert({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
category: 'system',
|
category: 'system',
|
||||||
priority: 'high',
|
priority: 'high',
|
||||||
title: 'Error',
|
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'
|
source: 'onboarding'
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -121,28 +160,135 @@ export const InventorySetupStep: React.FC<OnboardingStepProps> = ({
|
|||||||
|
|
||||||
setIsCreating(true);
|
setIsCreating(true);
|
||||||
try {
|
try {
|
||||||
const result = await onboardingApiService.createInventoryFromSuggestions(
|
// Create ingredients one by one using the inventory hook
|
||||||
user.tenant_id,
|
let successCount = 0;
|
||||||
approvedProducts
|
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({
|
// Show results
|
||||||
type: 'success',
|
if (successCount > 0) {
|
||||||
category: 'system',
|
createAlert({
|
||||||
priority: 'medium',
|
type: 'success',
|
||||||
title: 'Inventario creado',
|
category: 'system',
|
||||||
message: `Se crearon ${result.created_items.length} elementos de inventario exitosamente.`,
|
priority: 'medium',
|
||||||
source: 'onboarding'
|
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
|
// Now upload sales data to backend (required for ML training)
|
||||||
onDataChange({
|
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,
|
...data,
|
||||||
inventoryItems: items,
|
inventoryItems: items,
|
||||||
inventoryConfigured: true,
|
inventoryConfigured: true,
|
||||||
inventoryMapping: result.inventory_mapping,
|
inventoryCreated: true, // Mark as created to prevent duplicate calls
|
||||||
createdInventoryItems: result.created_items
|
inventoryMapping: inventoryMapping,
|
||||||
});
|
createdInventoryItems: createdItems,
|
||||||
|
salesImportResult: salesImportResult
|
||||||
|
};
|
||||||
|
console.log('InventorySetup - updatedData:', updatedData);
|
||||||
|
onDataChange(updatedData);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating inventory:', error);
|
console.error('Error creating inventory:', error);
|
||||||
@@ -172,6 +318,19 @@ export const InventorySetupStep: React.FC<OnboardingStepProps> = ({
|
|||||||
});
|
});
|
||||||
}, [items, isCreating]);
|
}, [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 handleAddItem = () => {
|
||||||
const newItem: InventoryItem = {
|
const newItem: InventoryItem = {
|
||||||
id: Date.now().toString(),
|
id: Date.now().toString(),
|
||||||
@@ -220,9 +379,9 @@ export const InventorySetupStep: React.FC<OnboardingStepProps> = ({
|
|||||||
|
|
||||||
const getStockStatusColor = (status: string) => {
|
const getStockStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'critical': return 'text-red-600 bg-red-50';
|
case 'critical': return 'text-[var(--color-error)] bg-[var(--color-error)]/10 border-[var(--color-error)]/20';
|
||||||
case 'warning': return 'text-yellow-600 bg-yellow-50';
|
case 'warning': return 'text-[var(--color-warning)] bg-[var(--color-warning)]/10 border-[var(--color-warning)]/20';
|
||||||
default: return 'text-green-600 bg-green-50';
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<Card className="p-6 text-center">
|
<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-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>
|
||||||
|
|
||||||
<Card className="p-6 text-center">
|
<Card className="p-6 text-center bg-[var(--color-info)]/5 border-[var(--color-info)]/20">
|
||||||
<div className="text-3xl font-bold text-blue-600 mb-2">
|
<div className="text-3xl font-bold text-[var(--color-info)] mb-2">
|
||||||
{items.filter(item => item.category === 'ingredient').length}
|
{items.filter(item => item.category === 'ingredient').length}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<Card className="p-6 text-center">
|
<Card className="p-6 text-center bg-[var(--color-success)]/5 border-[var(--color-success)]/20">
|
||||||
<div className="text-3xl font-bold text-green-600 mb-2">
|
<div className="text-3xl font-bold text-[var(--color-success)] mb-2">
|
||||||
{items.filter(item => item.category === 'finished_product').length}
|
{items.filter(item => item.category === 'finished_product').length}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<Card className="p-6 text-center">
|
<Card className="p-6 text-center bg-[var(--color-error)]/5 border-[var(--color-error)]/20">
|
||||||
<div className="text-3xl font-bold text-red-600 mb-2">
|
<div className="text-3xl font-bold text-[var(--color-error)] mb-2">
|
||||||
{items.filter(item => getStockStatus(item) === 'critical').length}
|
{items.filter(item => getStockStatus(item) === 'critical').length}
|
||||||
</div>
|
</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>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Controls */}
|
{/* 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">
|
<div className="flex items-center space-x-4">
|
||||||
<select
|
<select
|
||||||
value={filterCategory}
|
value={filterCategory}
|
||||||
onChange={(e) => setFilterCategory(e.target.value as any)}
|
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="all">Todos los elementos</option>
|
||||||
<option value="ingredient">Ingredientes</option>
|
<option value="ingredient">Ingredientes</option>
|
||||||
<option value="finished_product">Productos terminados</option>
|
<option value="finished_product">Productos terminados</option>
|
||||||
</select>
|
</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
|
{filteredItems.length} elementos
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -313,72 +472,96 @@ export const InventorySetupStep: React.FC<OnboardingStepProps> = ({
|
|||||||
<Button
|
<Button
|
||||||
onClick={handleAddItem}
|
onClick={handleAddItem}
|
||||||
size="sm"
|
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" />
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
Agregar elemento
|
Agregar elemento
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCreateInventory}
|
onClick={handleCreateInventory}
|
||||||
disabled={isCreating || items.length === 0 || data.inventoryConfigured}
|
disabled={isCreating || items.length === 0 || data.inventoryCreated}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
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" />
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Items List */}
|
{/* Items List */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
{filteredItems.map((item) => (
|
{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 ? (
|
{editingItem?.id === item.id ? (
|
||||||
<InventoryItemEditor
|
<InventoryItemEditor
|
||||||
item={item}
|
item={item}
|
||||||
onSave={handleSaveItem}
|
onSave={handleSaveItem}
|
||||||
onCancel={handleCancelEdit}
|
onCancel={handleCancelEdit}
|
||||||
|
showToast={showToast}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0 pr-4">
|
||||||
<div className="flex items-center space-x-3 mb-2">
|
<div className="flex items-center flex-wrap gap-2 mb-3">
|
||||||
<h3 className="font-semibold text-[var(--text-primary)]">{item.name}</h3>
|
<h3 className="font-semibold text-lg text-[var(--text-primary)] mr-2">{item.name}</h3>
|
||||||
<Badge variant="outline" className="text-xs">
|
<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'}
|
{item.category === 'ingredient' ? 'Ingrediente' : 'Producto terminado'}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge className={`text-xs ${getStockStatusColor(getStockStatus(item))}`}>
|
|
||||||
Stock: {getStockStatus(item)}
|
|
||||||
</Badge>
|
|
||||||
{item.requires_refrigeration && (
|
{item.requires_refrigeration && (
|
||||||
<Badge variant="outline" className="text-xs text-blue-600">
|
<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
|
❄️ Refrigeración
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-sm text-[var(--text-secondary)] space-y-1">
|
<div className="text-sm text-[var(--text-secondary)] space-y-2">
|
||||||
<div className="flex space-x-4">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
<span>Stock actual: <span className="font-medium">{item.current_stock} {item.unit}</span></span>
|
<div className="flex items-center">
|
||||||
<span>Mínimo: <span className="font-medium">{item.min_stock}</span></span>
|
<span className="font-medium text-[var(--text-tertiary)] min-w-[100px]">Stock actual:</span>
|
||||||
<span>Máximo: <span className="font-medium">{item.max_stock}</span></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>
|
</div>
|
||||||
{item.expiry_date && (
|
{(item.expiry_date || item.supplier) && (
|
||||||
<div>Vence: <span className="font-medium">{item.expiry_date}</span></div>
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
)}
|
{item.expiry_date && (
|
||||||
{item.supplier && (
|
<div className="flex items-center">
|
||||||
<div>Proveedor: <span className="font-medium">{item.supplier}</span></div>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex flex-col sm:flex-row gap-2 flex-shrink-0">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setEditingItem(item)}
|
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" />
|
<Edit className="w-4 h-4 mr-1" />
|
||||||
Editar
|
Editar
|
||||||
@@ -387,7 +570,7 @@ export const InventorySetupStep: React.FC<OnboardingStepProps> = ({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => handleDeleteItem(item.id)}
|
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" />
|
<Trash2 className="w-4 h-4 mr-1" />
|
||||||
Eliminar
|
Eliminar
|
||||||
@@ -399,23 +582,21 @@ export const InventorySetupStep: React.FC<OnboardingStepProps> = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Information */}
|
||||||
<div className="flex justify-between pt-6 border-t border-[var(--border-primary)]">
|
<Card className="p-4 bg-[var(--color-info)]/5 border-[var(--color-info)]/20">
|
||||||
<Button
|
<h4 className="font-medium text-[var(--color-info)] mb-2">
|
||||||
variant="outline"
|
📦 Configuración de Inventario:
|
||||||
onClick={onPrevious}
|
</h4>
|
||||||
disabled={isFirstStep}
|
<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>
|
||||||
Anterior
|
<li>• <strong>Defina límites</strong> - Establezca stock mínimo y máximo para recibir alertas automáticas</li>
|
||||||
</Button>
|
<li>• <strong>Agregue detalles</strong> - Incluya fechas de vencimiento, proveedores y unidades de medida</li>
|
||||||
<Button
|
<li>• <strong>Marque refrigeración</strong> - Indique qué productos requieren condiciones especiales de almacenamiento</li>
|
||||||
onClick={onNext}
|
<li>• <strong>Edite elementos</strong> - Haga clic en "Editar" para modificar cualquier producto</li>
|
||||||
disabled={!data.inventoryConfigured}
|
<li>• <strong>Creación automática</strong> - El inventario se creará automáticamente al hacer clic en "Siguiente"</li>
|
||||||
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary)]/90"
|
</ul>
|
||||||
>
|
</Card>
|
||||||
{isLastStep ? 'Finalizar' : 'Siguiente'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -425,39 +606,49 @@ const InventoryItemEditor: React.FC<{
|
|||||||
item: InventoryItem;
|
item: InventoryItem;
|
||||||
onSave: (item: InventoryItem) => void;
|
onSave: (item: InventoryItem) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}> = ({ item, onSave, onCancel }) => {
|
showToast: (toast: any) => void;
|
||||||
|
}> = ({ item, onSave, onCancel, showToast }) => {
|
||||||
const [editedItem, setEditedItem] = useState<InventoryItem>(item);
|
const [editedItem, setEditedItem] = useState<InventoryItem>(item);
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
if (!editedItem.name.trim()) {
|
if (!editedItem.name.trim()) {
|
||||||
alert('El nombre es requerido');
|
showToast({
|
||||||
|
title: 'Error de validación',
|
||||||
|
message: 'El nombre es requerido',
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (editedItem.min_stock < 0 || editedItem.max_stock <= editedItem.min_stock) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
onSave(editedItem);
|
onSave(editedItem);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<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
|
<Input
|
||||||
value={editedItem.name}
|
value={editedItem.name}
|
||||||
onChange={(e) => setEditedItem({ ...editedItem, name: e.target.value })}
|
onChange={(e) => setEditedItem({ ...editedItem, name: e.target.value })}
|
||||||
placeholder="Nombre del producto"
|
placeholder="Nombre del producto"
|
||||||
|
className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
<select
|
||||||
value={editedItem.category}
|
value={editedItem.category}
|
||||||
onChange={(e) => setEditedItem({ ...editedItem, category: e.target.value as any })}
|
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="ingredient">Ingrediente</option>
|
||||||
<option value="finished_product">Producto terminado</option>
|
<option value="finished_product">Producto terminado</option>
|
||||||
@@ -465,59 +656,65 @@ const InventoryItemEditor: React.FC<{
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={editedItem.current_stock}
|
value={editedItem.current_stock}
|
||||||
onChange={(e) => setEditedItem({ ...editedItem, current_stock: Number(e.target.value) })}
|
onChange={(e) => setEditedItem({ ...editedItem, current_stock: Number(e.target.value) })}
|
||||||
min="0"
|
min="0"
|
||||||
|
className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
<Input
|
||||||
value={editedItem.unit}
|
value={editedItem.unit}
|
||||||
onChange={(e) => setEditedItem({ ...editedItem, unit: e.target.value })}
|
onChange={(e) => setEditedItem({ ...editedItem, unit: e.target.value })}
|
||||||
placeholder="kg, litros, unidades..."
|
placeholder="kg, litros, unidades..."
|
||||||
|
className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={editedItem.min_stock}
|
value={editedItem.min_stock}
|
||||||
onChange={(e) => setEditedItem({ ...editedItem, min_stock: Number(e.target.value) })}
|
onChange={(e) => setEditedItem({ ...editedItem, min_stock: Number(e.target.value) })}
|
||||||
min="0"
|
min="0"
|
||||||
|
className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
value={editedItem.max_stock}
|
value={editedItem.max_stock}
|
||||||
onChange={(e) => setEditedItem({ ...editedItem, max_stock: Number(e.target.value) })}
|
onChange={(e) => setEditedItem({ ...editedItem, max_stock: Number(e.target.value) })}
|
||||||
min="1"
|
min="1"
|
||||||
|
className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
value={editedItem.expiry_date || ''}
|
value={editedItem.expiry_date || ''}
|
||||||
onChange={(e) => setEditedItem({ ...editedItem, expiry_date: e.target.value })}
|
onChange={(e) => setEditedItem({ ...editedItem, expiry_date: e.target.value })}
|
||||||
|
className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
<Input
|
||||||
value={editedItem.supplier || ''}
|
value={editedItem.supplier || ''}
|
||||||
onChange={(e) => setEditedItem({ ...editedItem, supplier: e.target.value })}
|
onChange={(e) => setEditedItem({ ...editedItem, supplier: e.target.value })}
|
||||||
placeholder="Nombre del proveedor"
|
placeholder="Nombre del proveedor"
|
||||||
|
className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -528,17 +725,24 @@ const InventoryItemEditor: React.FC<{
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={editedItem.requires_refrigeration}
|
checked={editedItem.requires_refrigeration}
|
||||||
onChange={(e) => setEditedItem({ ...editedItem, requires_refrigeration: e.target.checked })}
|
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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end space-x-2">
|
<div className="flex justify-end space-x-3 pt-4 border-t border-[var(--border-primary)]">
|
||||||
<Button variant="outline" onClick={onCancel}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="text-[var(--text-secondary)] border-[var(--border-primary)] hover:bg-[var(--bg-tertiary)]"
|
||||||
|
>
|
||||||
Cancelar
|
Cancelar
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave}>
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary)]/90 text-white"
|
||||||
|
>
|
||||||
Guardar
|
Guardar
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
TrainingCompletedMessage,
|
TrainingCompletedMessage,
|
||||||
TrainingErrorMessage
|
TrainingErrorMessage
|
||||||
} from '../../../../services/realtime/websocket.service';
|
} from '../../../../services/realtime/websocket.service';
|
||||||
|
import { trainingService } from '../../../../services/api/training.service';
|
||||||
|
|
||||||
interface TrainingMetrics {
|
interface TrainingMetrics {
|
||||||
accuracy: number;
|
accuracy: number;
|
||||||
@@ -35,44 +36,7 @@ interface TrainingJob {
|
|||||||
metrics?: TrainingMetrics;
|
metrics?: TrainingMetrics;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Real training service using backend APIs
|
// Using the proper training service from services/api/training.service.ts
|
||||||
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();
|
|
||||||
|
|
||||||
export const MLTrainingStep: React.FC<OnboardingStepProps> = ({
|
export const MLTrainingStep: React.FC<OnboardingStepProps> = ({
|
||||||
data,
|
data,
|
||||||
@@ -114,13 +78,25 @@ export const MLTrainingStep: React.FC<OnboardingStepProps> = ({
|
|||||||
console.log('MLTrainingStep - dataProcessingData:', dataProcessingData);
|
console.log('MLTrainingStep - dataProcessingData:', dataProcessingData);
|
||||||
console.log('MLTrainingStep - reviewData:', reviewData);
|
console.log('MLTrainingStep - reviewData:', reviewData);
|
||||||
console.log('MLTrainingStep - inventoryData:', inventoryData);
|
console.log('MLTrainingStep - inventoryData:', inventoryData);
|
||||||
|
console.log('MLTrainingStep - inventoryData.salesImportResult:', inventoryData?.salesImportResult);
|
||||||
|
|
||||||
// Check if sales data was processed
|
// Check if sales data was processed
|
||||||
const hasProcessingResults = dataProcessingData?.processingResults &&
|
const hasProcessingResults = dataProcessingData?.processingResults &&
|
||||||
dataProcessingData.processingResults.is_valid &&
|
dataProcessingData.processingResults.is_valid &&
|
||||||
dataProcessingData.processingResults.total_records > 0;
|
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) {
|
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');
|
missingItems.push('Datos de ventas importados');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +128,7 @@ export const MLTrainingStep: React.FC<OnboardingStepProps> = ({
|
|||||||
isValid: missingItems.length === 0,
|
isValid: missingItems.length === 0,
|
||||||
missingItems,
|
missingItems,
|
||||||
hasProcessingResults,
|
hasProcessingResults,
|
||||||
|
hasImportResults,
|
||||||
hasApprovedProducts,
|
hasApprovedProducts,
|
||||||
hasInventoryConfig
|
hasInventoryConfig
|
||||||
});
|
});
|
||||||
@@ -205,7 +182,11 @@ export const MLTrainingStep: React.FC<OnboardingStepProps> = ({
|
|||||||
try {
|
try {
|
||||||
// Start training job
|
// Start training job
|
||||||
addLog('Iniciando trabajo de entrenamiento ML...', 'info');
|
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);
|
setCurrentJob(job);
|
||||||
setTrainingStatus('training');
|
setTrainingStatus('training');
|
||||||
|
|||||||
@@ -227,16 +227,16 @@ export const ReviewStep: React.FC<OnboardingStepProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getConfidenceColor = (confidence: number) => {
|
const getConfidenceColor = (confidence: number) => {
|
||||||
if (confidence >= 90) return 'text-green-600 bg-green-50';
|
if (confidence >= 90) return 'text-[var(--color-success)] bg-[var(--color-success)]/10 border-[var(--color-success)]/20';
|
||||||
if (confidence >= 75) return 'text-yellow-600 bg-yellow-50';
|
if (confidence >= 75) return 'text-[var(--color-warning)] bg-[var(--color-warning)]/10 border-[var(--color-warning)]/20';
|
||||||
return 'text-red-600 bg-red-50';
|
return 'text-[var(--color-error)] bg-[var(--color-error)]/10 border-[var(--color-error)]/20';
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'approved': return 'text-green-600 bg-green-50';
|
case 'approved': return 'text-[var(--color-success)] bg-[var(--color-success)]/10 border-[var(--color-success)]/20';
|
||||||
case 'rejected': return 'text-red-600 bg-red-50';
|
case 'rejected': return 'text-[var(--color-error)] bg-[var(--color-error)]/10 border-[var(--color-error)]/20';
|
||||||
default: return 'text-gray-600 bg-gray-50';
|
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Summary Stats */}
|
{/* Summary Stats */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<Card className="p-6 text-center">
|
<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-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>
|
||||||
|
|
||||||
<Card className="p-6 text-center">
|
<Card className="p-6 text-center bg-[var(--color-success)]/5 border-[var(--color-success)]/20">
|
||||||
<div className="text-3xl font-bold text-green-600 mb-2">{stats.approved}</div>
|
<div className="text-3xl font-bold text-[var(--color-success)] mb-2">{stats.approved}</div>
|
||||||
<div className="text-sm text-[var(--text-secondary)]">Aprobados</div>
|
<div className="text-sm font-medium text-[var(--color-success)]">Aprobados</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="p-6 text-center">
|
<Card className="p-6 text-center bg-[var(--color-error)]/5 border-[var(--color-error)]/20">
|
||||||
<div className="text-3xl font-bold text-red-600 mb-2">{stats.rejected}</div>
|
<div className="text-3xl font-bold text-[var(--color-error)] mb-2">{stats.rejected}</div>
|
||||||
<div className="text-sm text-[var(--text-secondary)]">Rechazados</div>
|
<div className="text-sm font-medium text-[var(--color-error)]">Rechazados</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="p-6 text-center">
|
<Card className="p-6 text-center bg-[var(--color-warning)]/5 border-[var(--color-warning)]/20">
|
||||||
<div className="text-3xl font-bold text-yellow-600 mb-2">{stats.pending}</div>
|
<div className="text-3xl font-bold text-[var(--color-warning)] mb-2">{stats.pending}</div>
|
||||||
<div className="text-sm text-[var(--text-secondary)]">Pendientes</div>
|
<div className="text-sm font-medium text-[var(--color-warning)]">Pendientes</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Controls */}
|
{/* 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">
|
<div className="flex items-center space-x-4">
|
||||||
<select
|
<select
|
||||||
value={selectedCategory}
|
value={selectedCategory}
|
||||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
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 => (
|
{categories.map(category => (
|
||||||
<option key={category} value={category}>
|
<option key={category} value={category}>
|
||||||
@@ -304,7 +304,7 @@ export const ReviewStep: React.FC<OnboardingStepProps> = ({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</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
|
{getFilteredProducts().length} productos
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
@@ -314,7 +314,7 @@ export const ReviewStep: React.FC<OnboardingStepProps> = ({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => handleBulkAction('approve')}
|
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
|
Aprobar todos
|
||||||
</Button>
|
</Button>
|
||||||
@@ -322,7 +322,7 @@ export const ReviewStep: React.FC<OnboardingStepProps> = ({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => handleBulkAction('reject')}
|
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
|
Rechazar todos
|
||||||
</Button>
|
</Button>
|
||||||
@@ -330,48 +330,70 @@ export const ReviewStep: React.FC<OnboardingStepProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Products List */}
|
{/* Products List */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
{getFilteredProducts().map((product) => (
|
{getFilteredProducts().map((product) => (
|
||||||
<Card key={product.id} className="p-4 hover:shadow-md transition-shadow">
|
<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-center justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0 pr-4">
|
||||||
<div className="flex items-center space-x-3 mb-2">
|
<div className="flex items-center flex-wrap gap-2 mb-3">
|
||||||
<h3 className="font-semibold text-[var(--text-primary)]">{product.name}</h3>
|
<h3 className="font-semibold text-lg text-[var(--text-primary)] mr-2">{product.name}</h3>
|
||||||
<Badge variant="outline" className="text-xs">
|
<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}
|
{product.category}
|
||||||
</Badge>
|
</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
|
{product.confidence}% confianza
|
||||||
</Badge>
|
</Badge>
|
||||||
<Badge className={`text-xs ${getStatusColor(product.status)}`}>
|
|
||||||
{product.status === 'approved' ? 'Aprobado' :
|
|
||||||
product.status === 'rejected' ? 'Rechazado' : 'Pendiente'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</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 && (
|
{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">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
<span>Tipo: {product.product_type === 'ingredient' ? 'Ingrediente' : 'Producto terminado'}</span>
|
<div className="flex items-center">
|
||||||
<span>Unidad: {product.unit_of_measure}</span>
|
<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 && (
|
{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>
|
</div>
|
||||||
{product.notes && (
|
{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>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex flex-col sm:flex-row gap-2 flex-shrink-0">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant={product.status === 'approved' ? 'default' : 'outline'}
|
variant={product.status === 'approved' ? 'default' : 'outline'}
|
||||||
onClick={() => handleProductAction(product.id, 'approve')}
|
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" />
|
<CheckCircle className="w-4 h-4 mr-1" />
|
||||||
Aprobar
|
Aprobar
|
||||||
@@ -380,7 +402,10 @@ export const ReviewStep: React.FC<OnboardingStepProps> = ({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant={product.status === 'rejected' ? 'default' : 'outline'}
|
variant={product.status === 'rejected' ? 'default' : 'outline'}
|
||||||
onClick={() => handleProductAction(product.id, 'reject')}
|
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" />
|
<AlertCircle className="w-4 h-4 mr-1" />
|
||||||
Rechazar
|
Rechazar
|
||||||
@@ -391,23 +416,21 @@ export const ReviewStep: React.FC<OnboardingStepProps> = ({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Information */}
|
||||||
<div className="flex justify-between pt-6 border-t border-[var(--border-primary)]">
|
<Card className="p-4 bg-[var(--color-info)]/5 border-[var(--color-info)]/20">
|
||||||
<Button
|
<h4 className="font-medium text-[var(--color-info)] mb-2">
|
||||||
variant="outline"
|
📋 Revisión de Productos Detectados:
|
||||||
onClick={onPrevious}
|
</h4>
|
||||||
disabled={isFirstStep}
|
<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>
|
||||||
Anterior
|
<li>• <strong>Apruebe o rechace</strong> cada producto según sea correcto para su negocio</li>
|
||||||
</Button>
|
<li>• <strong>Verifique nombres</strong> - Compare el nombre original vs. el nombre sugerido</li>
|
||||||
<Button
|
<li>• <strong>Revise clasificaciones</strong> - Confirme si son ingredientes o productos terminados</li>
|
||||||
onClick={onNext}
|
<li>• <strong>Use filtros</strong> - Filtre por categoría para revisar productos similares</li>
|
||||||
disabled={!data.reviewCompleted || stats.approved === 0}
|
<li>• <strong>Acciones masivas</strong> - Use "Aprobar todos" o "Rechazar todos" para agilizar el proceso</li>
|
||||||
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary)]/90"
|
</ul>
|
||||||
>
|
</Card>
|
||||||
{isLastStep ? 'Finalizar' : 'Siguiente'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -2,10 +2,12 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { Truck, Phone, Mail, Plus, Edit, Trash2, MapPin, AlertCircle, Loader } from 'lucide-react';
|
import { Truck, Phone, Mail, Plus, Edit, Trash2, MapPin, AlertCircle, Loader } from 'lucide-react';
|
||||||
import { Button, Card, Input, Badge } from '../../../ui';
|
import { Button, Card, Input, Badge } from '../../../ui';
|
||||||
import { OnboardingStepProps } from '../OnboardingWizard';
|
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 { useAuthUser } from '../../../../stores/auth.store';
|
||||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||||
import { useAlertActions } from '../../../../stores/alerts.store';
|
import { useAlertActions } from '../../../../stores/alerts.store';
|
||||||
|
import { procurementService } from '../../../../services/api/procurement.service';
|
||||||
|
|
||||||
// Frontend supplier interface that matches the form needs
|
// Frontend supplier interface that matches the form needs
|
||||||
interface SupplierFormData {
|
interface SupplierFormData {
|
||||||
@@ -46,8 +48,13 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
|
|||||||
const user = useAuthUser();
|
const user = useAuthUser();
|
||||||
const currentTenant = useCurrentTenant();
|
const currentTenant = useCurrentTenant();
|
||||||
const { createAlert } = useAlertActions();
|
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 [editingSupplier, setEditingSupplier] = useState<SupplierFormData | null>(null);
|
||||||
const [isAddingNew, setIsAddingNew] = useState(false);
|
const [isAddingNew, setIsAddingNew] = useState(false);
|
||||||
const [filterStatus, setFilterStatus] = useState<'all' | 'active' | 'inactive'>('all');
|
const [filterStatus, setFilterStatus] = useState<'all' | 'active' | 'inactive'>('all');
|
||||||
@@ -207,9 +214,17 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteSupplier = async (id: string) => {
|
const handleDeleteSupplier = async (id: string) => {
|
||||||
if (!window.confirm('¿Estás seguro de eliminar este proveedor? Esta acción no se puede deshacer.')) {
|
deleteModal.openModal({
|
||||||
return;
|
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);
|
setDeleting(id);
|
||||||
try {
|
try {
|
||||||
@@ -316,21 +331,6 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
|
|||||||
{stats.total} proveedores configurados ({stats.active} activos)
|
{stats.total} proveedores configurados ({stats.active} activos)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -533,20 +533,53 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{getFilteredSuppliers().length === 0 && !loading && (
|
{getFilteredSuppliers().length === 0 && !loading && (
|
||||||
<Card className="p-8 text-center">
|
<Card className="p-8 text-center bg-[var(--bg-primary)] border-2 border-dashed border-[var(--border-secondary)]">
|
||||||
<Truck className="w-12 h-12 text-[var(--text-tertiary)] mx-auto mb-4" />
|
<Truck className="w-16 h-16 text-[var(--color-primary)] mx-auto mb-4" />
|
||||||
<p className="text-[var(--text-secondary)] mb-4">
|
<h3 className="text-xl font-semibold text-[var(--text-primary)] mb-2">
|
||||||
{filterStatus === 'all'
|
{filterStatus === 'all'
|
||||||
? 'No hay proveedores registrados'
|
? 'Comienza agregando tu primer proveedor'
|
||||||
: `No hay proveedores ${filterStatus === 'active' ? 'activos' : 'inactivos'}`
|
: `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>
|
</p>
|
||||||
<Button onClick={handleAddSupplier} disabled={creating}>
|
<Button
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
onClick={handleAddSupplier}
|
||||||
Agregar Primer Proveedor
|
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>
|
</Button>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Edit Modal */}
|
{/* Edit Modal */}
|
||||||
@@ -571,18 +604,20 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Information */}
|
{/* Information - Only show when there are suppliers */}
|
||||||
<Card className="p-4 bg-[var(--color-info)]/5 border-[var(--color-info)]/20">
|
{suppliers.length > 0 && (
|
||||||
<h4 className="font-medium text-[var(--color-info)] mb-2">
|
<Card className="p-4 bg-[var(--color-info)]/5 border-[var(--color-info)]/20">
|
||||||
🚚 Gestión de Proveedores:
|
<h4 className="font-medium text-[var(--color-info)] mb-2">
|
||||||
</h4>
|
🚚 Gestión de Proveedores:
|
||||||
<ul className="text-sm text-[var(--color-info)] space-y-1">
|
</h4>
|
||||||
<li>• <strong>Este paso es opcional</strong> - puedes configurar proveedores más tarde</li>
|
<ul className="text-sm text-[var(--color-info)] space-y-1">
|
||||||
<li>• Define categorías de productos para facilitar la búsqueda</li>
|
<li>• <strong>Editar información</strong> - Actualiza datos de contacto y términos comerciales</li>
|
||||||
<li>• Establece días de entrega y términos de pago</li>
|
<li>• <strong>Gestionar estado</strong> - Activa o pausa proveedores según necesidades</li>
|
||||||
<li>• Configura montos mínimos de pedido para optimizar compras</li>
|
<li>• <strong>Revisar rendimiento</strong> - Evalúa entregas a tiempo y calidad de productos</li>
|
||||||
</ul>
|
<li>• <strong>Filtrar vista</strong> - Usa los filtros para encontrar proveedores específicos</li>
|
||||||
</Card>
|
</ul>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -610,11 +645,19 @@ const SupplierForm: React.FC<SupplierFormProps> = ({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!formData.name.trim()) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (!formData.address.trim()) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
561
frontend/src/hooks/business/useOnboarding.ts
Normal file
561
frontend/src/hooks/business/useOnboarding.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { OnboardingWizard, OnboardingStep } from '../../../components/domain/onboarding/OnboardingWizard';
|
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 { useAuthUser, useIsAuthenticated } from '../../../stores/auth.store';
|
||||||
import { LoadingSpinner } from '../../../components/shared/LoadingSpinner';
|
import { LoadingSpinner } from '../../../components/shared/LoadingSpinner';
|
||||||
|
|
||||||
@@ -18,162 +18,138 @@ const OnboardingPage: React.FC = () => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const user = useAuthUser();
|
const user = useAuthUser();
|
||||||
const isAuthenticated = useIsAuthenticated();
|
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)
|
// Map steps to components
|
||||||
const steps: OnboardingStep[] = [
|
const stepComponents: { [key: string]: React.ComponentType<any> } = {
|
||||||
{
|
'setup': BakerySetupStep,
|
||||||
id: 'setup',
|
'data-processing': DataProcessingStep,
|
||||||
title: '🏢 Setup',
|
'review': ReviewStep,
|
||||||
description: 'Configuración básica de tu panadería y creación del tenant',
|
'inventory': InventorySetupStep,
|
||||||
component: BakerySetupStep,
|
'suppliers': SuppliersStep,
|
||||||
isRequired: true,
|
'ml-training': MLTrainingStep,
|
||||||
validation: (data) => {
|
'completion': CompletionStep
|
||||||
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';
|
// Convert hook steps to OnboardingWizard format
|
||||||
if (!data.bakery?.city) return 'La ciudad es requerida';
|
const wizardSteps: OnboardingStep[] = steps.map(step => ({
|
||||||
if (!data.bakery?.postal_code) return 'El código postal es requerido';
|
id: step.id,
|
||||||
if (!data.bakery?.phone) return 'El teléfono es requerido';
|
title: step.title,
|
||||||
// Tenant creation will happen automatically when validation passes
|
description: step.description,
|
||||||
return null;
|
component: stepComponents[step.id],
|
||||||
}
|
isRequired: step.isRequired,
|
||||||
},
|
validation: step.validation
|
||||||
{
|
}));
|
||||||
id: 'data-processing',
|
|
||||||
title: '📊 Historial de Ventas',
|
const handleStepChange = (stepIndex: number, stepData: any) => {
|
||||||
description: 'Sube tus datos de ventas para obtener insights personalizados',
|
const stepId = steps[stepIndex]?.id;
|
||||||
component: DataProcessingStep,
|
if (stepId) {
|
||||||
isRequired: true,
|
updateStepData(stepId, stepData);
|
||||||
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
|
|
||||||
}
|
}
|
||||||
];
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
return nextStep();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrevious = () => {
|
||||||
|
return previousStep();
|
||||||
|
};
|
||||||
|
|
||||||
const handleComplete = async (allData: any) => {
|
const handleComplete = async (allData: any) => {
|
||||||
setIsLoading(true);
|
const success = await completeOnboarding();
|
||||||
try {
|
if (success) {
|
||||||
// Mark onboarding as complete in the backend
|
// Navigation is handled inside completeOnboarding
|
||||||
if (user?.tenant_id) {
|
return;
|
||||||
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 handleExit = () => {
|
// Redirect if user is not authenticated
|
||||||
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
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated) {
|
if (isInitialized && !isAuthenticated) {
|
||||||
navigate('/login', {
|
navigate('/auth/login');
|
||||||
state: {
|
|
||||||
message: 'Debes iniciar sesión para acceder al onboarding.',
|
|
||||||
returnUrl: '/app/onboarding'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, navigate]);
|
}, [isAuthenticated, isInitialized, navigate]);
|
||||||
|
|
||||||
if (isLoading) {
|
// Clear error when user navigates away
|
||||||
return <LoadingSpinner overlay text="Completando configuración..." />;
|
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)
|
// Show error state
|
||||||
if (!isAuthenticated || !user) {
|
if (error) {
|
||||||
return <LoadingSpinner overlay text="Verificando autenticación..." />;
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-[var(--bg-primary)]">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<OnboardingWizard
|
<OnboardingWizard
|
||||||
steps={steps}
|
steps={wizardSteps}
|
||||||
|
currentStep={currentStep}
|
||||||
|
data={data}
|
||||||
|
onStepChange={handleStepChange}
|
||||||
|
onNext={handleNext}
|
||||||
|
onPrevious={handlePrevious}
|
||||||
onComplete={handleComplete}
|
onComplete={handleComplete}
|
||||||
onExit={handleExit}
|
onGoToStep={goToStep}
|
||||||
className="py-8"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import {
|
|||||||
PasswordResetConfirm,
|
PasswordResetConfirm,
|
||||||
TokenVerification,
|
TokenVerification,
|
||||||
UserResponse,
|
UserResponse,
|
||||||
UserUpdate
|
UserUpdate,
|
||||||
|
OnboardingStatus,
|
||||||
|
OnboardingProgressRequest
|
||||||
} from '../../types/auth.types';
|
} from '../../types/auth.types';
|
||||||
|
|
||||||
class AuthService {
|
class AuthService {
|
||||||
@@ -200,6 +202,71 @@ class AuthService {
|
|||||||
|
|
||||||
return true;
|
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();
|
export const authService = new AuthService();
|
||||||
@@ -56,6 +56,31 @@ class ApiClient {
|
|||||||
this.setupInterceptors();
|
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 {
|
private setupInterceptors(): void {
|
||||||
// Request interceptor - add auth token and tenant ID
|
// Request interceptor - add auth token and tenant ID
|
||||||
this.axiosInstance.interceptors.request.use(
|
this.axiosInstance.interceptors.request.use(
|
||||||
@@ -168,33 +193,38 @@ class ApiClient {
|
|||||||
window.location.href = '/login';
|
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>> {
|
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);
|
return this.transformResponse(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
async post<T = any>(url: string, data = {}, config = {}): Promise<ApiResponse<T>> {
|
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);
|
return this.transformResponse(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
async put<T = any>(url: string, data = {}, config = {}): Promise<ApiResponse<T>> {
|
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);
|
return this.transformResponse(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
async patch<T = any>(url: string, data = {}, config = {}): Promise<ApiResponse<T>> {
|
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);
|
return this.transformResponse(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete<T = any>(url: string, config = {}): Promise<ApiResponse<T>> {
|
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);
|
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>> {
|
async uploadFile<T = any>(url: string, file: File, progressCallback?: (progress: number) => void): Promise<ApiResponse<T>> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
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);
|
return this.transformResponse(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -264,4 +264,5 @@ class ForecastingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { ForecastingService };
|
||||||
export const forecastingService = new ForecastingService();
|
export const forecastingService = new ForecastingService();
|
||||||
@@ -9,7 +9,11 @@ import {
|
|||||||
StockMovement,
|
StockMovement,
|
||||||
StockAlert,
|
StockAlert,
|
||||||
InventorySummary,
|
InventorySummary,
|
||||||
StockLevelSummary
|
StockLevelSummary,
|
||||||
|
ProductSuggestion,
|
||||||
|
ProductSuggestionsResponse,
|
||||||
|
InventoryCreationResponse,
|
||||||
|
BatchClassificationRequest
|
||||||
} from '../../types/inventory.types';
|
} from '../../types/inventory.types';
|
||||||
import { PaginatedResponse } from '../../types/api.types';
|
import { PaginatedResponse } from '../../types/api.types';
|
||||||
|
|
||||||
@@ -390,6 +394,92 @@ class InventoryService {
|
|||||||
{ value: 'quarantine', label: 'Quarantine', color: 'purple' },
|
{ 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();
|
export const inventoryService = new InventoryService();
|
||||||
@@ -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;
|
|
||||||
1
frontend/src/services/api/order.service.ts
Symbolic link
1
frontend/src/services/api/order.service.ts
Symbolic link
@@ -0,0 +1 @@
|
|||||||
|
/Users/urtzialfaro/Documents/bakery-ia/frontend/src/services/api/orders.service.ts
|
||||||
@@ -187,4 +187,6 @@ class OrdersService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { OrdersService };
|
||||||
|
export { OrdersService as OrderService }; // Alias for compatibility
|
||||||
export const ordersService = new OrdersService();
|
export const ordersService = new OrdersService();
|
||||||
@@ -37,8 +37,7 @@ class ProcurementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getBaseUrl(): string {
|
private getBaseUrl(): string {
|
||||||
const tenantId = this.getTenantId();
|
return '';
|
||||||
return `/tenants/${tenantId}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Purchase Order management
|
// Purchase Order management
|
||||||
@@ -216,4 +215,5 @@ class ProcurementService {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { ProcurementService };
|
||||||
export const procurementService = new ProcurementService();
|
export const procurementService = new ProcurementService();
|
||||||
@@ -464,4 +464,5 @@ class ProductionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { ProductionService };
|
||||||
export const productionService = new ProductionService();
|
export const productionService = new ProductionService();
|
||||||
@@ -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
|
// Request/Response Types
|
||||||
export interface SalesData {
|
export interface SalesData {
|
||||||
@@ -262,23 +264,44 @@ class SalesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Data import and export
|
// Data import and export
|
||||||
async importSalesData(file: File, progressCallback?: (progress: number) => void): Promise<ApiResponse<SalesImportResult>> {
|
async importSalesData(file: File, progressCallback?: (progress: number) => void): Promise<{
|
||||||
return apiClient.uploadFile(`${this.baseUrl}/import`, file, progressCallback);
|
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<{
|
async validateSalesData(file: File): Promise<{
|
||||||
valid_records: number;
|
is_valid: boolean;
|
||||||
invalid_records: number;
|
total_records: number;
|
||||||
errors: Array<{
|
unique_products: number;
|
||||||
row: number;
|
product_list: string[];
|
||||||
field: string;
|
errors: string[];
|
||||||
message: string;
|
warnings: string[];
|
||||||
}>;
|
summary: {
|
||||||
preview: SalesData[];
|
date_range: string;
|
||||||
}>> {
|
total_sales: number;
|
||||||
return apiClient.uploadFile(`${this.baseUrl}/validate`, file);
|
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?: {
|
async exportSalesData(params?: {
|
||||||
format?: 'csv' | 'xlsx';
|
format?: 'csv' | 'xlsx';
|
||||||
start_date?: string;
|
start_date?: string;
|
||||||
@@ -438,6 +461,110 @@ class SalesService {
|
|||||||
{ value: 'xlsx', label: 'Excel (XLSX)' },
|
{ 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();
|
export const salesService = new SalesService();
|
||||||
@@ -25,8 +25,7 @@ export class TrainingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getBaseUrl(): string {
|
private getBaseUrl(): string {
|
||||||
const tenantId = this.getTenantId();
|
return '/training';
|
||||||
return `/tenants/${tenantId}/training`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTrainingJobs(modelId?: string): Promise<ApiResponse<TrainingJob[]>> {
|
async getTrainingJobs(modelId?: string): Promise<ApiResponse<TrainingJob[]>> {
|
||||||
|
|||||||
397
frontend/src/services/api/utils/storage.service.ts
Normal file
397
frontend/src/services/api/utils/storage.service.ts
Normal 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);
|
||||||
@@ -2,11 +2,11 @@
|
|||||||
* API Response Types - Matching actual backend implementation
|
* API Response Types - Matching actual backend implementation
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Standard FastAPI response structure
|
// Standard API response structure (matching client.ts transformResponse)
|
||||||
export interface ApiResponse<T = any> {
|
export interface ApiResponse<T = any> {
|
||||||
data?: T;
|
data: T;
|
||||||
success?: boolean;
|
success: boolean;
|
||||||
message?: string;
|
message: string;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,4 +177,14 @@ export const isTokenResponse = (obj: any): obj is TokenResponse => {
|
|||||||
|
|
||||||
export const isAuthError = (obj: any): obj is AuthError => {
|
export const isAuthError = (obj: any): obj is AuthError => {
|
||||||
return obj && typeof obj.detail === 'string';
|
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;
|
||||||
|
}
|
||||||
@@ -437,4 +437,60 @@ export const isStock = (obj: any): obj is Stock => {
|
|||||||
|
|
||||||
export const isStockMovement = (obj: any): obj is StockMovement => {
|
export const isStockMovement = (obj: any): obj is StockMovement => {
|
||||||
return obj && typeof obj.id === 'string' && obj.movement_type && obj.quantity !== undefined;
|
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;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
@@ -599,4 +599,24 @@ export const isProductPerformance = (obj: any): obj is ProductPerformance => {
|
|||||||
|
|
||||||
export const isSalesSummary = (obj: any): obj is SalesSummary => {
|
export const isSalesSummary = (obj: any): obj is SalesSummary => {
|
||||||
return obj && typeof obj.total_revenue === 'number' && typeof obj.total_quantity === 'number';
|
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;
|
||||||
|
}
|
||||||
@@ -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 = ""):
|
async def proxy_tenant_training(request: Request, tenant_id: str = Path(...), path: str = ""):
|
||||||
"""Proxy tenant training requests to training service"""
|
"""Proxy tenant training requests to training service"""
|
||||||
target_path = f"/api/v1/tenants/{tenant_id}/training/{path}".rstrip("/")
|
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"])
|
@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 = ""):
|
async def proxy_tenant_models(request: Request, tenant_id: str = Path(...), path: str = ""):
|
||||||
"""Proxy tenant model requests to training service"""
|
"""Proxy tenant model requests to training service"""
|
||||||
target_path = f"/api/v1/tenants/{tenant_id}/models/{path}".rstrip("/")
|
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
|
# 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"""
|
"""Proxy request to external service"""
|
||||||
return await _proxy_request(request, target_path, settings.EXTERNAL_SERVICE_URL)
|
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"""
|
"""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):
|
async def _proxy_to_forecasting_service(request: Request, target_path: str, tenant_id: str = None):
|
||||||
"""Proxy request to forecasting service"""
|
"""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-role"] = str(user.get('role', 'user'))
|
||||||
headers["x-user-full-name"] = str(user.get('full_name', ''))
|
headers["x-user-full-name"] = str(user.get('full_name', ''))
|
||||||
headers["x-tenant-id"] = tenant_id or str(user.get('tenant_id', ''))
|
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
|
# Get request body if present
|
||||||
body = None
|
body = None
|
||||||
|
|||||||
Reference in New Issue
Block a user