Start integrating the onboarding flow with backend 12
This commit is contained in:
2
frontend/package-lock.json
generated
2
frontend/package-lock.json
generated
@@ -39,7 +39,7 @@
|
|||||||
"tailwind-merge": "^2.1.0",
|
"tailwind-merge": "^2.1.0",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^3.22.4",
|
"zod": "^3.22.4",
|
||||||
"zustand": "^4.4.7"
|
"zustand": "^4.5.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@storybook/addon-essentials": "^7.6.0",
|
"@storybook/addon-essentials": "^7.6.0",
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
"tailwind-merge": "^2.1.0",
|
"tailwind-merge": "^2.1.0",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^3.22.4",
|
"zod": "^3.22.4",
|
||||||
"zustand": "^4.4.7"
|
"zustand": "^4.5.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@storybook/addon-essentials": "^7.6.0",
|
"@storybook/addon-essentials": "^7.6.0",
|
||||||
|
|||||||
@@ -6,10 +6,7 @@ import { classificationService } from '../services/classification';
|
|||||||
import {
|
import {
|
||||||
ProductClassificationRequest,
|
ProductClassificationRequest,
|
||||||
BatchClassificationRequest,
|
BatchClassificationRequest,
|
||||||
ProductSuggestionResponse,
|
ProductSuggestionResponse
|
||||||
BusinessModelAnalysisResponse,
|
|
||||||
ClassificationApprovalRequest,
|
|
||||||
ClassificationApprovalResponse,
|
|
||||||
} from '../types/classification';
|
} from '../types/classification';
|
||||||
import { ApiError } from '../client';
|
import { ApiError } from '../client';
|
||||||
|
|
||||||
@@ -28,48 +25,6 @@ export const classificationKeys = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Queries
|
|
||||||
export const usePendingSuggestions = (
|
|
||||||
tenantId: string,
|
|
||||||
options?: Omit<UseQueryOptions<ProductSuggestionResponse[], ApiError>, 'queryKey' | 'queryFn'>
|
|
||||||
) => {
|
|
||||||
return useQuery<ProductSuggestionResponse[], ApiError>({
|
|
||||||
queryKey: classificationKeys.suggestions.pending(tenantId),
|
|
||||||
queryFn: () => classificationService.getPendingSuggestions(tenantId),
|
|
||||||
enabled: !!tenantId,
|
|
||||||
staleTime: 30 * 1000, // 30 seconds
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useSuggestionHistory = (
|
|
||||||
tenantId: string,
|
|
||||||
limit: number = 50,
|
|
||||||
offset: number = 0,
|
|
||||||
options?: Omit<UseQueryOptions<{ items: ProductSuggestionResponse[]; total: number }, ApiError>, 'queryKey' | 'queryFn'>
|
|
||||||
) => {
|
|
||||||
return useQuery<{ items: ProductSuggestionResponse[]; total: number }, ApiError>({
|
|
||||||
queryKey: classificationKeys.suggestions.history(tenantId, limit, offset),
|
|
||||||
queryFn: () => classificationService.getSuggestionHistory(tenantId, limit, offset),
|
|
||||||
enabled: !!tenantId,
|
|
||||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useBusinessModelAnalysis = (
|
|
||||||
tenantId: string,
|
|
||||||
options?: Omit<UseQueryOptions<BusinessModelAnalysisResponse, ApiError>, 'queryKey' | 'queryFn'>
|
|
||||||
) => {
|
|
||||||
return useQuery<BusinessModelAnalysisResponse, ApiError>({
|
|
||||||
queryKey: classificationKeys.analysis.businessModel(tenantId),
|
|
||||||
queryFn: () => classificationService.getBusinessModelAnalysis(tenantId),
|
|
||||||
enabled: !!tenantId,
|
|
||||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
export const useClassifyProduct = (
|
export const useClassifyProduct = (
|
||||||
options?: UseMutationOptions<
|
options?: UseMutationOptions<
|
||||||
@@ -117,84 +72,4 @@ export const useClassifyProductsBatch = (
|
|||||||
},
|
},
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
export const useApproveClassification = (
|
|
||||||
options?: UseMutationOptions<
|
|
||||||
ClassificationApprovalResponse,
|
|
||||||
ApiError,
|
|
||||||
{ tenantId: string; approvalData: ClassificationApprovalRequest }
|
|
||||||
>
|
|
||||||
) => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation<
|
|
||||||
ClassificationApprovalResponse,
|
|
||||||
ApiError,
|
|
||||||
{ tenantId: string; approvalData: ClassificationApprovalRequest }
|
|
||||||
>({
|
|
||||||
mutationFn: ({ tenantId, approvalData }) =>
|
|
||||||
classificationService.approveClassification(tenantId, approvalData),
|
|
||||||
onSuccess: (data, { tenantId }) => {
|
|
||||||
// Invalidate suggestions lists
|
|
||||||
queryClient.invalidateQueries({ queryKey: classificationKeys.suggestions.pending(tenantId) });
|
|
||||||
queryClient.invalidateQueries({ queryKey: classificationKeys.suggestions.history(tenantId) });
|
|
||||||
|
|
||||||
// If approved and ingredient was created, invalidate inventory queries
|
|
||||||
if (data.approved && data.created_ingredient) {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['inventory', 'ingredients'] });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useUpdateSuggestion = (
|
|
||||||
options?: UseMutationOptions<
|
|
||||||
ProductSuggestionResponse,
|
|
||||||
ApiError,
|
|
||||||
{ tenantId: string; suggestionId: string; updateData: Partial<ProductSuggestionResponse> }
|
|
||||||
>
|
|
||||||
) => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation<
|
|
||||||
ProductSuggestionResponse,
|
|
||||||
ApiError,
|
|
||||||
{ tenantId: string; suggestionId: string; updateData: Partial<ProductSuggestionResponse> }
|
|
||||||
>({
|
|
||||||
mutationFn: ({ tenantId, suggestionId, updateData }) =>
|
|
||||||
classificationService.updateSuggestion(tenantId, suggestionId, updateData),
|
|
||||||
onSuccess: (data, { tenantId }) => {
|
|
||||||
// Invalidate suggestions lists
|
|
||||||
queryClient.invalidateQueries({ queryKey: classificationKeys.suggestions.pending(tenantId) });
|
|
||||||
queryClient.invalidateQueries({ queryKey: classificationKeys.suggestions.history(tenantId) });
|
|
||||||
},
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useDeleteSuggestion = (
|
|
||||||
options?: UseMutationOptions<
|
|
||||||
{ message: string },
|
|
||||||
ApiError,
|
|
||||||
{ tenantId: string; suggestionId: string }
|
|
||||||
>
|
|
||||||
) => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
return useMutation<
|
|
||||||
{ message: string },
|
|
||||||
ApiError,
|
|
||||||
{ tenantId: string; suggestionId: string }
|
|
||||||
>({
|
|
||||||
mutationFn: ({ tenantId, suggestionId }) =>
|
|
||||||
classificationService.deleteSuggestion(tenantId, suggestionId),
|
|
||||||
onSuccess: (data, { tenantId }) => {
|
|
||||||
// Invalidate suggestions lists
|
|
||||||
queryClient.invalidateQueries({ queryKey: classificationKeys.suggestions.pending(tenantId) });
|
|
||||||
queryClient.invalidateQueries({ queryKey: classificationKeys.suggestions.history(tenantId) });
|
|
||||||
},
|
|
||||||
...options,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
@@ -1,9 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* Onboarding Service - Mirror backend onboarding endpoints
|
* Onboarding Service - Mirror backend onboarding endpoints
|
||||||
|
* Frontend and backend step names now match directly!
|
||||||
*/
|
*/
|
||||||
import { apiClient } from '../client';
|
import { apiClient } from '../client';
|
||||||
import { UserProgress, UpdateStepRequest } from '../types/onboarding';
|
import { UserProgress, UpdateStepRequest } from '../types/onboarding';
|
||||||
|
|
||||||
|
// Frontend step order for navigation (matches backend ONBOARDING_STEPS)
|
||||||
|
export const FRONTEND_STEP_ORDER = [
|
||||||
|
'setup', // Step 1: Basic bakery setup and tenant creation
|
||||||
|
'smart-inventory-setup', // Step 2: Sales data upload and inventory configuration
|
||||||
|
'suppliers', // Step 3: Suppliers configuration (optional)
|
||||||
|
'ml-training', // Step 4: AI model training
|
||||||
|
'completion' // Step 5: Onboarding completed
|
||||||
|
];
|
||||||
|
|
||||||
export class OnboardingService {
|
export class OnboardingService {
|
||||||
private readonly baseUrl = '/users/me/onboarding';
|
private readonly baseUrl = '/users/me/onboarding';
|
||||||
|
|
||||||
@@ -71,6 +81,65 @@ export class OnboardingService {
|
|||||||
// This endpoint exists in backend
|
// This endpoint exists in backend
|
||||||
return apiClient.post(`${this.baseUrl}/complete`);
|
return apiClient.post(`${this.baseUrl}/complete`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method to mark a step as completed (now direct mapping)
|
||||||
|
*/
|
||||||
|
async markStepAsCompleted(
|
||||||
|
stepId: string,
|
||||||
|
data?: Record<string, any>
|
||||||
|
): Promise<UserProgress> {
|
||||||
|
try {
|
||||||
|
return await this.markStepCompleted('', stepId, data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error marking step ${stepId} as completed:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method to get the next step based on backend progress
|
||||||
|
*/
|
||||||
|
async getNextStepId(): Promise<string> {
|
||||||
|
try {
|
||||||
|
const result = await this.getNextStep();
|
||||||
|
return result.step || 'setup';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting next step:', error);
|
||||||
|
return 'setup';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper method to determine which step the user should resume from
|
||||||
|
*/
|
||||||
|
async getResumeStep(): Promise<{ stepId: string; stepIndex: number }> {
|
||||||
|
try {
|
||||||
|
const progress = await this.getUserProgress('');
|
||||||
|
|
||||||
|
// If fully completed, go to completion
|
||||||
|
if (progress.fully_completed) {
|
||||||
|
return { stepId: 'completion', stepIndex: FRONTEND_STEP_ORDER.indexOf('completion') };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the current step from backend
|
||||||
|
const currentStep = progress.current_step;
|
||||||
|
|
||||||
|
// If current step is user_registered, start from setup
|
||||||
|
const resumeStep = currentStep === 'user_registered' ? 'setup' : currentStep;
|
||||||
|
|
||||||
|
// Find the step index in our frontend order
|
||||||
|
let stepIndex = FRONTEND_STEP_ORDER.indexOf(resumeStep);
|
||||||
|
if (stepIndex === -1) {
|
||||||
|
stepIndex = 0; // Default to first step
|
||||||
|
}
|
||||||
|
|
||||||
|
return { stepId: FRONTEND_STEP_ORDER[stepIndex], stepIndex };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error determining resume step:', error);
|
||||||
|
return { stepId: 'setup', stepIndex: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const onboardingService = new OnboardingService();
|
export const onboardingService = new OnboardingService();
|
||||||
@@ -26,4 +26,10 @@ export interface ProductSuggestionResponse {
|
|||||||
is_seasonal: boolean;
|
is_seasonal: boolean;
|
||||||
suggested_supplier?: string;
|
suggested_supplier?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
sales_data?: {
|
||||||
|
total_quantity: number;
|
||||||
|
average_daily_sales: number;
|
||||||
|
peak_day: string;
|
||||||
|
frequency: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -3,8 +3,7 @@ export { default as OnboardingWizard } from './OnboardingWizard';
|
|||||||
|
|
||||||
// Individual step components
|
// Individual step components
|
||||||
export { BakerySetupStep } from './steps/BakerySetupStep';
|
export { BakerySetupStep } from './steps/BakerySetupStep';
|
||||||
export { HistoricalSalesValidationStep } from './steps/HistoricalSalesValidationStep';
|
export { SmartInventorySetupStep } from './steps/SmartInventorySetupStep';
|
||||||
export { InventorySetupStep } from './steps/InventorySetupStep';
|
|
||||||
export { SuppliersStep } from './steps/SuppliersStep';
|
export { SuppliersStep } from './steps/SuppliersStep';
|
||||||
export { MLTrainingStep } from './steps/MLTrainingStep';
|
export { MLTrainingStep } from './steps/MLTrainingStep';
|
||||||
export { CompletionStep } from './steps/CompletionStep';
|
export { CompletionStep } from './steps/CompletionStep';
|
||||||
|
|||||||
@@ -1,854 +0,0 @@
|
|||||||
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
|
||||||
import { Upload, Brain, CheckCircle, AlertCircle, Download, FileText, Activity } from 'lucide-react';
|
|
||||||
import { Button, Card, Badge } from '../../../ui';
|
|
||||||
import { OnboardingStepProps } from '../OnboardingWizard';
|
|
||||||
import { useModal } from '../../../../hooks/ui/useModal';
|
|
||||||
import { useToast } from '../../../../hooks/ui/useToast';
|
|
||||||
import { useOnboarding } from '../../../../hooks/business/onboarding';
|
|
||||||
import { useAuthUser, useAuthLoading } from '../../../../stores/auth.store';
|
|
||||||
import { useCurrentTenant, useTenantLoading } from '../../../../stores';
|
|
||||||
import type { ProductSuggestionResponse } from '../../../../api';
|
|
||||||
|
|
||||||
type ProcessingStage = 'upload' | 'validating' | 'analyzing' | 'review' | 'completed' | 'error';
|
|
||||||
|
|
||||||
interface ProcessingResult {
|
|
||||||
// Validation data
|
|
||||||
is_valid: boolean;
|
|
||||||
total_records: number;
|
|
||||||
unique_products: number;
|
|
||||||
product_list: string[];
|
|
||||||
validation_errors: string[];
|
|
||||||
validation_warnings: string[];
|
|
||||||
summary: {
|
|
||||||
date_range: string;
|
|
||||||
total_sales: number;
|
|
||||||
average_daily_sales: number;
|
|
||||||
};
|
|
||||||
// Analysis data
|
|
||||||
productsIdentified: number;
|
|
||||||
categoriesDetected: number;
|
|
||||||
businessModel: string;
|
|
||||||
confidenceScore: number;
|
|
||||||
recommendations: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Product {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
category: string;
|
|
||||||
confidence: number;
|
|
||||||
sales_count?: number;
|
|
||||||
estimated_price?: number;
|
|
||||||
status: 'approved' | 'rejected' | 'pending';
|
|
||||||
notes?: string;
|
|
||||||
// Fields from API suggestion
|
|
||||||
suggestion_id?: string;
|
|
||||||
original_name: string;
|
|
||||||
suggested_name: string;
|
|
||||||
product_type: 'ingredient' | 'finished_product';
|
|
||||||
unit_of_measure: string;
|
|
||||||
estimated_shelf_life_days: number;
|
|
||||||
requires_refrigeration: boolean;
|
|
||||||
requires_freezing: boolean;
|
|
||||||
is_seasonal: boolean;
|
|
||||||
suggested_supplier?: string;
|
|
||||||
sales_data?: {
|
|
||||||
total_quantity: number;
|
|
||||||
average_daily_sales: number;
|
|
||||||
peak_day: string;
|
|
||||||
frequency: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert API suggestions to Product interface
|
|
||||||
const convertSuggestionsToProducts = (suggestions: ProductSuggestionResponse[]): Product[] => {
|
|
||||||
const products = suggestions.map((suggestion, index) => ({
|
|
||||||
id: suggestion.suggestion_id || `product-${index}`,
|
|
||||||
name: suggestion.suggested_name,
|
|
||||||
category: suggestion.category,
|
|
||||||
confidence: Math.round(suggestion.confidence_score * 100),
|
|
||||||
status: 'pending' as const,
|
|
||||||
// Store original API data
|
|
||||||
suggestion_id: suggestion.suggestion_id,
|
|
||||||
original_name: suggestion.original_name,
|
|
||||||
suggested_name: suggestion.suggested_name,
|
|
||||||
product_type: suggestion.product_type,
|
|
||||||
unit_of_measure: suggestion.unit_of_measure,
|
|
||||||
estimated_shelf_life_days: suggestion.estimated_shelf_life_days,
|
|
||||||
requires_refrigeration: suggestion.requires_refrigeration,
|
|
||||||
requires_freezing: suggestion.requires_freezing,
|
|
||||||
is_seasonal: suggestion.is_seasonal,
|
|
||||||
suggested_supplier: suggestion.suggested_supplier,
|
|
||||||
notes: suggestion.notes,
|
|
||||||
sales_data: suggestion.sales_data,
|
|
||||||
// Legacy fields for display
|
|
||||||
sales_count: suggestion.sales_data?.total_quantity || 0,
|
|
||||||
estimated_price: 0 // Price estimation not provided by current API
|
|
||||||
}));
|
|
||||||
|
|
||||||
return products;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const HistoricalSalesValidationStep: React.FC<OnboardingStepProps> = ({
|
|
||||||
data,
|
|
||||||
onDataChange,
|
|
||||||
_onNext,
|
|
||||||
_onPrevious,
|
|
||||||
_isFirstStep,
|
|
||||||
_isLastStep
|
|
||||||
}) => {
|
|
||||||
const user = useAuthUser();
|
|
||||||
const authLoading = useAuthLoading();
|
|
||||||
const currentTenant = useCurrentTenant();
|
|
||||||
const tenantLoading = useTenantLoading();
|
|
||||||
|
|
||||||
// Use the new onboarding hooks
|
|
||||||
const {
|
|
||||||
processSalesFile,
|
|
||||||
salesProcessing: {
|
|
||||||
stage: onboardingStage,
|
|
||||||
progress: onboardingProgress,
|
|
||||||
currentMessage: onboardingMessage,
|
|
||||||
validationResults,
|
|
||||||
suggestions
|
|
||||||
},
|
|
||||||
tenantCreation,
|
|
||||||
error,
|
|
||||||
clearError
|
|
||||||
} = useOnboarding();
|
|
||||||
|
|
||||||
const toast = useToast();
|
|
||||||
|
|
||||||
// Get tenant ID from multiple sources with fallback
|
|
||||||
const getTenantId = (): string | null => {
|
|
||||||
const onboardingTenantId = data.bakery?.tenant_id;
|
|
||||||
const tenantId = currentTenant?.id || user?.tenant_id || onboardingTenantId || null;
|
|
||||||
return tenantId;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if tenant data is available
|
|
||||||
const isTenantAvailable = (): boolean => {
|
|
||||||
const hasAuth = !authLoading && user;
|
|
||||||
const hasTenantId = getTenantId() !== null;
|
|
||||||
const tenantCreatedSuccessfully = tenantCreation.isSuccess;
|
|
||||||
const tenantCreatedInOnboarding = data.bakery?.tenantCreated === true;
|
|
||||||
|
|
||||||
const isAvailable = hasAuth && (hasTenantId || tenantCreatedSuccessfully || tenantCreatedInOnboarding);
|
|
||||||
return isAvailable;
|
|
||||||
};
|
|
||||||
|
|
||||||
// State management
|
|
||||||
const [localStage, setLocalStage] = useState<ProcessingStage>(data.processingStage || 'upload');
|
|
||||||
const [uploadedFile, setUploadedFile] = useState<File | null>(data.files?.salesData || null);
|
|
||||||
const [localResults, setLocalResults] = useState<ProcessingResult | null>(data.processingResults || null);
|
|
||||||
const [products, setProducts] = useState<Product[]>(() => {
|
|
||||||
if (data.detectedProducts) {
|
|
||||||
return data.detectedProducts;
|
|
||||||
}
|
|
||||||
// Generate from existing suggestions if available
|
|
||||||
if (suggestions && suggestions.length > 0) {
|
|
||||||
return convertSuggestionsToProducts(suggestions);
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
});
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
|
||||||
|
|
||||||
// Derive current state from onboarding hooks or local state
|
|
||||||
const stage = (localStage === 'completed' || localStage === 'error')
|
|
||||||
? localStage
|
|
||||||
: (onboardingStage || localStage);
|
|
||||||
const progress = onboardingProgress || 0;
|
|
||||||
const currentMessage = onboardingMessage || '';
|
|
||||||
const results = useMemo(() => (validationResults && suggestions) ? {
|
|
||||||
...validationResults,
|
|
||||||
aiSuggestions: suggestions,
|
|
||||||
productsIdentified: validationResults.unique_products || validationResults.product_list?.length || 0,
|
|
||||||
categoriesDetected: suggestions ? new Set(suggestions.map((s: ProductSuggestionResponse) => s.category)).size : 0,
|
|
||||||
businessModel: 'production',
|
|
||||||
confidenceScore: 85,
|
|
||||||
recommendations: validationResults.summary?.suggestions || [],
|
|
||||||
totalRecords: validationResults.total_records || 0,
|
|
||||||
validRecords: validationResults.valid_records || 0,
|
|
||||||
invalidRecords: validationResults.invalid_records || 0,
|
|
||||||
fileFormat: validationResults.summary?.file_format || 'csv',
|
|
||||||
fileSizeMb: validationResults.summary?.file_size_mb || 0,
|
|
||||||
estimatedProcessingTime: validationResults.summary?.estimated_processing_time_seconds || 0,
|
|
||||||
detectedColumns: validationResults.summary?.detected_columns || [],
|
|
||||||
validationMessage: validationResults.message || 'Validación completada'
|
|
||||||
} : localResults, [validationResults, suggestions, localResults]);
|
|
||||||
|
|
||||||
// Update products when suggestions change
|
|
||||||
useEffect(() => {
|
|
||||||
if (suggestions && suggestions.length > 0 && products.length === 0) {
|
|
||||||
const newProducts = convertSuggestionsToProducts(suggestions);
|
|
||||||
setProducts(newProducts);
|
|
||||||
}
|
|
||||||
}, [suggestions, products.length]);
|
|
||||||
|
|
||||||
// Auto-progress to review stage when validation completes and suggestions are available
|
|
||||||
useEffect(() => {
|
|
||||||
if (stage === 'analyzing' && results && suggestions && suggestions.length > 0) {
|
|
||||||
setLocalStage('review');
|
|
||||||
}
|
|
||||||
}, [stage, results, suggestions]);
|
|
||||||
|
|
||||||
const [dragActive, setDragActive] = useState(false);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const lastStateRef = useRef({ stage, progress, currentMessage, results, suggestions, uploadedFile, products });
|
|
||||||
|
|
||||||
// Memoized computed values
|
|
||||||
const approvedProducts = useMemo(() =>
|
|
||||||
products.filter(p => p.status === 'approved'),
|
|
||||||
[products]
|
|
||||||
);
|
|
||||||
|
|
||||||
const reviewCompleted = useMemo(() =>
|
|
||||||
products.length > 0 && products.every(p => p.status !== 'pending'),
|
|
||||||
[products]
|
|
||||||
);
|
|
||||||
|
|
||||||
const categories = ['all', ...Array.from(new Set(products.map(p => p.category)))];
|
|
||||||
|
|
||||||
const getFilteredProducts = () => {
|
|
||||||
if (selectedCategory === 'all') {
|
|
||||||
return products;
|
|
||||||
}
|
|
||||||
return products.filter(p => p.category === selectedCategory);
|
|
||||||
};
|
|
||||||
|
|
||||||
const stats = {
|
|
||||||
total: products.length,
|
|
||||||
approved: products.filter(p => p.status === 'approved').length,
|
|
||||||
rejected: products.filter(p => p.status === 'rejected').length,
|
|
||||||
pending: products.filter(p => p.status === 'pending').length
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update parent data when state changes
|
|
||||||
useEffect(() => {
|
|
||||||
const currentState = { stage, progress, currentMessage, results, suggestions, uploadedFile, products };
|
|
||||||
if (JSON.stringify(currentState) !== JSON.stringify(lastStateRef.current)) {
|
|
||||||
lastStateRef.current = currentState;
|
|
||||||
|
|
||||||
onDataChange({
|
|
||||||
processingStage: stage === 'review' ? 'completed' : stage,
|
|
||||||
processingProgress: progress,
|
|
||||||
currentMessage: currentMessage,
|
|
||||||
processingResults: results,
|
|
||||||
suggestions: suggestions,
|
|
||||||
files: {
|
|
||||||
...data.files,
|
|
||||||
salesData: uploadedFile
|
|
||||||
},
|
|
||||||
detectedProducts: products,
|
|
||||||
approvedProducts,
|
|
||||||
reviewCompleted
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [stage, progress, currentMessage, results, suggestions, uploadedFile, products, approvedProducts, reviewCompleted, onDataChange, data.files]);
|
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setDragActive(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragLeave = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setDragActive(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setDragActive(false);
|
|
||||||
|
|
||||||
const files = Array.from(e.dataTransfer.files);
|
|
||||||
if (files.length > 0) {
|
|
||||||
handleFileUpload(files[0]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
if (e.target.files && e.target.files.length > 0) {
|
|
||||||
handleFileUpload(e.target.files[0]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileUpload = async (file: File) => {
|
|
||||||
// Validate file type
|
|
||||||
const validExtensions = ['.csv', '.xlsx', '.xls', '.json'];
|
|
||||||
const fileExtension = file.name.toLowerCase().substring(file.name.lastIndexOf('.'));
|
|
||||||
|
|
||||||
if (!validExtensions.includes(fileExtension)) {
|
|
||||||
toast.addToast('Formato de archivo no válido. Usa CSV, JSON o Excel (.xlsx, .xls)', {
|
|
||||||
title: 'Formato inválido',
|
|
||||||
type: 'error'
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check file size (max 10MB)
|
|
||||||
if (file.size > 10 * 1024 * 1024) {
|
|
||||||
toast.addToast('El archivo es demasiado grande. Máximo 10MB permitido.', {
|
|
||||||
title: 'Archivo muy grande',
|
|
||||||
type: 'error'
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setUploadedFile(file);
|
|
||||||
setLocalStage('validating');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Wait for user data to load if still loading
|
|
||||||
if (!isTenantAvailable()) {
|
|
||||||
setUploadedFile(null);
|
|
||||||
setLocalStage('upload');
|
|
||||||
toast.addToast('Por favor espere mientras cargamos su información...', {
|
|
||||||
title: 'Esperando datos de usuario',
|
|
||||||
type: 'info'
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the onboarding hook for file processing
|
|
||||||
const success = await processSalesFile(file, (progress, stage, message) => {
|
|
||||||
console.log(`Processing: ${progress}% - ${stage} - ${message}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
setLocalStage('review');
|
|
||||||
|
|
||||||
// Update products from suggestions
|
|
||||||
if (validationResults && suggestions) {
|
|
||||||
const newProducts = convertSuggestionsToProducts(suggestions);
|
|
||||||
setProducts(newProducts);
|
|
||||||
|
|
||||||
const processedResults = {
|
|
||||||
...validationResults,
|
|
||||||
aiSuggestions: suggestions,
|
|
||||||
productsIdentified: validationResults.product_list?.length || 0,
|
|
||||||
categoriesDetected: suggestions ? new Set(suggestions.map((s: any) => s.category)).size : 0,
|
|
||||||
businessModel: 'production',
|
|
||||||
confidenceScore: 85,
|
|
||||||
recommendations: []
|
|
||||||
};
|
|
||||||
setLocalResults(processedResults);
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.addToast('El archivo se procesó correctamente. Revisa los productos detectados.', {
|
|
||||||
title: 'Procesamiento completado',
|
|
||||||
type: 'success'
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw new Error('Error procesando el archivo');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('HistoricalSalesValidationStep - Processing error:', error);
|
|
||||||
setLocalStage('error');
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Error en el procesamiento de datos';
|
|
||||||
|
|
||||||
toast.addToast(errorMessage, {
|
|
||||||
title: 'Error en el procesamiento',
|
|
||||||
type: 'error'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadTemplate = () => {
|
|
||||||
const csvContent = `fecha,producto,cantidad,precio_unitario,precio_total,cliente,canal_venta
|
|
||||||
2024-01-15,Pan Integral,5,2.50,12.50,Cliente A,Tienda
|
|
||||||
2024-01-15,Croissant,3,1.80,5.40,Cliente B,Online
|
|
||||||
2024-01-15,Baguette,2,3.00,6.00,Cliente C,Tienda
|
|
||||||
2024-01-16,Pan de Centeno,4,2.80,11.20,Cliente A,Tienda
|
|
||||||
2024-01-16,Empanadas,6,4.50,27.00,Cliente D,Delivery`;
|
|
||||||
|
|
||||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
|
||||||
const link = document.createElement('a');
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
link.setAttribute('href', url);
|
|
||||||
link.setAttribute('download', 'plantilla_ventas.csv');
|
|
||||||
link.style.visibility = 'hidden';
|
|
||||||
|
|
||||||
document.body.appendChild(link);
|
|
||||||
link.click();
|
|
||||||
document.body.removeChild(link);
|
|
||||||
|
|
||||||
toast.addToast('La plantilla se descargó correctamente.', {
|
|
||||||
title: 'Plantilla descargada',
|
|
||||||
type: 'success'
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetProcess = () => {
|
|
||||||
setLocalStage('upload');
|
|
||||||
setUploadedFile(null);
|
|
||||||
setLocalResults(null);
|
|
||||||
setProducts([]);
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = '';
|
|
||||||
}
|
|
||||||
if (error) {
|
|
||||||
clearError();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleProductAction = (productId: string, action: 'approve' | 'reject') => {
|
|
||||||
setProducts(prev => prev.map(product =>
|
|
||||||
product.id === productId
|
|
||||||
? { ...product, status: action === 'approve' ? 'approved' : 'rejected' }
|
|
||||||
: product
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBulkAction = (action: 'approve' | 'reject') => {
|
|
||||||
const filteredProducts = getFilteredProducts();
|
|
||||||
setProducts(prev => prev.map(product =>
|
|
||||||
filteredProducts.some(fp => fp.id === product.id)
|
|
||||||
? { ...product, status: action === 'approve' ? 'approved' : 'rejected' }
|
|
||||||
: product
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
const getConfidenceColor = (confidence: number) => {
|
|
||||||
if (confidence >= 90) return 'text-[var(--color-success)] bg-[var(--color-success)]/10 border-[var(--color-success)]/20';
|
|
||||||
if (confidence >= 75) return 'text-[var(--color-warning)] bg-[var(--color-warning)]/10 border-[var(--color-warning)]/20';
|
|
||||||
return 'text-[var(--color-error)] bg-[var(--color-error)]/10 border-[var(--color-error)]/20';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'approved': return 'text-[var(--color-success)] bg-[var(--color-success)]/10 border-[var(--color-success)]/20';
|
|
||||||
case 'rejected': return 'text-[var(--color-error)] bg-[var(--color-error)]/10 border-[var(--color-error)]/20';
|
|
||||||
default: return 'text-[var(--text-secondary)] bg-[var(--bg-secondary)] border-[var(--border-secondary)]';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
{/* Loading state when tenant data is not available */}
|
|
||||||
{!isTenantAvailable() && (
|
|
||||||
<Card className="p-8 text-center">
|
|
||||||
<div className="w-16 h-16 bg-[var(--color-info)]/10 rounded-full flex items-center justify-center mx-auto mb-6">
|
|
||||||
<Activity className="w-8 h-8 text-[var(--color-info)] animate-pulse" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-xl font-semibold text-[var(--text-primary)] mb-3">
|
|
||||||
Cargando datos de usuario...
|
|
||||||
</h3>
|
|
||||||
<p className="text-[var(--text-secondary)]">
|
|
||||||
Por favor espere mientras cargamos su información de tenant
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Upload Stage */}
|
|
||||||
{(stage === 'idle' || localStage === 'upload') && isTenantAvailable() && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className={`
|
|
||||||
border-2 border-dashed rounded-2xl p-16 text-center transition-all duration-300 cursor-pointer group
|
|
||||||
${
|
|
||||||
dragActive
|
|
||||||
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/10 scale-[1.02] shadow-lg'
|
|
||||||
: uploadedFile
|
|
||||||
? 'border-[var(--color-success)] bg-[var(--color-success)]/10 shadow-md'
|
|
||||||
: 'border-[var(--border-secondary)] hover:border-[var(--color-primary)] hover:bg-[var(--bg-secondary)]/30 hover:shadow-lg'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".csv,.xlsx,.xls"
|
|
||||||
onChange={handleFileSelect}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="space-y-8">
|
|
||||||
{uploadedFile ? (
|
|
||||||
<>
|
|
||||||
<div className="w-20 h-20 bg-[var(--color-success)]/10 rounded-full flex items-center justify-center mx-auto">
|
|
||||||
<CheckCircle className="w-10 h-10 text-[var(--color-success)]" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-3xl font-bold text-[var(--color-success)] mb-3">
|
|
||||||
¡Perfecto! Archivo listo
|
|
||||||
</h3>
|
|
||||||
<div className="bg-[var(--bg-secondary)] rounded-lg p-4 inline-block">
|
|
||||||
<p className="text-[var(--text-primary)] font-medium text-lg">
|
|
||||||
📄 {uploadedFile.name}
|
|
||||||
</p>
|
|
||||||
<p className="text-[var(--text-secondary)] text-sm mt-1">
|
|
||||||
{(uploadedFile.size / 1024 / 1024).toFixed(2)} MB • Listo para procesar
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="w-20 h-20 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center mx-auto group-hover:scale-110 transition-transform duration-300">
|
|
||||||
<Upload className="w-10 h-10 text-[var(--color-primary)]" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-3xl font-bold text-[var(--text-primary)] mb-4">
|
|
||||||
Sube tu historial de ventas
|
|
||||||
</h3>
|
|
||||||
<p className="text-[var(--text-secondary)] text-xl leading-relaxed max-w-md mx-auto">
|
|
||||||
Arrastra y suelta tu archivo aquí, o <span className="text-[var(--color-primary)] font-semibold">haz clic para seleccionar</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-center space-x-8 mt-8">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mx-auto mb-2">
|
|
||||||
<span className="text-2xl">📊</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-[var(--text-secondary)]">CSV</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mx-auto mb-2">
|
|
||||||
<span className="text-2xl">📈</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-[var(--text-secondary)]">Excel</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center mx-auto mb-2">
|
|
||||||
<span className="text-2xl">⚡</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-[var(--text-secondary)]">Hasta 10MB</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-10 px-4 py-2 bg-[var(--bg-secondary)]/50 rounded-lg text-sm text-[var(--text-tertiary)] inline-block">
|
|
||||||
💡 Formatos aceptados: CSV, Excel (XLSX, XLS) • Tamaño máximo: 10MB
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Template Download Section */}
|
|
||||||
<div className="bg-gradient-to-r from-[var(--color-info)]/5 to-[var(--color-primary)]/5 rounded-xl p-6 border border-[var(--color-info)]/20">
|
|
||||||
<div className="flex flex-col md:flex-row items-center space-y-4 md:space-y-0 md:space-x-6">
|
|
||||||
<div className="w-16 h-16 rounded-full bg-[var(--color-info)]/10 flex items-center justify-center flex-shrink-0">
|
|
||||||
<Download className="w-8 h-8 text-[var(--color-info)]" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 text-center md:text-left">
|
|
||||||
<h4 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
|
||||||
¿Necesitas ayuda con el formato?
|
|
||||||
</h4>
|
|
||||||
<p className="text-[var(--text-secondary)] mb-4">
|
|
||||||
Descarga nuestra plantilla Excel con ejemplos y formato correcto para tus datos de ventas
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
onClick={downloadTemplate}
|
|
||||||
className="bg-[var(--color-info)] hover:bg-[var(--color-info)]/90 text-white shadow-lg"
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4 mr-2" />
|
|
||||||
Descargar Plantilla Gratuita
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Processing Stages */}
|
|
||||||
{(stage === 'validating' || stage === 'analyzing') && (
|
|
||||||
<Card className="p-8">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="relative mb-8">
|
|
||||||
<div className={`w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-6 ${
|
|
||||||
stage === 'validating'
|
|
||||||
? 'bg-[var(--color-info)]/10 animate-pulse'
|
|
||||||
: 'bg-[var(--color-primary)]/10 animate-pulse'
|
|
||||||
}`}>
|
|
||||||
{stage === 'validating' ? (
|
|
||||||
<FileText className={`w-8 h-8 ${stage === 'validating' ? 'text-[var(--color-info)]' : 'text-[var(--color-primary)]'}`} />
|
|
||||||
) : (
|
|
||||||
<Brain className="w-8 h-8 text-[var(--color-primary)]" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 className="text-2xl font-semibold text-[var(--text-primary)] mb-2">
|
|
||||||
{stage === 'validating' ? 'Validando datos...' : 'Analizando con IA...'}
|
|
||||||
</h3>
|
|
||||||
<p className="text-[var(--text-secondary)] mb-8">
|
|
||||||
{currentMessage}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress Bar */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="flex justify-between items-center mb-3">
|
|
||||||
<span className="text-sm font-medium text-[var(--text-primary)]">
|
|
||||||
Progreso
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-[var(--text-secondary)]">{progress}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-[var(--bg-secondary)] rounded-full h-3">
|
|
||||||
<div
|
|
||||||
className="bg-gradient-to-r from-[var(--color-info)] to-[var(--color-primary)] h-3 rounded-full transition-all duration-500 ease-out"
|
|
||||||
style={{ width: `${progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Processing Steps */}
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<div className={`p-4 rounded-lg text-center ${
|
|
||||||
progress >= 40 ? 'bg-[var(--color-success)]/10 text-[var(--color-success)]' : 'bg-[var(--bg-secondary)]'
|
|
||||||
}`}>
|
|
||||||
<FileText className="w-6 h-6 mx-auto mb-2" />
|
|
||||||
<span className="text-sm font-medium">Validación</span>
|
|
||||||
</div>
|
|
||||||
<div className={`p-4 rounded-lg text-center ${
|
|
||||||
progress >= 70 ? 'bg-[var(--color-success)]/10 text-[var(--color-success)]' : 'bg-[var(--bg-secondary)]'
|
|
||||||
}`}>
|
|
||||||
<Brain className="w-6 h-6 mx-auto mb-2" />
|
|
||||||
<span className="text-sm font-medium">Análisis IA</span>
|
|
||||||
</div>
|
|
||||||
<div className={`p-4 rounded-lg text-center ${
|
|
||||||
progress >= 100 ? 'bg-[var(--color-success)]/10 text-[var(--color-success)]' : 'bg-[var(--bg-secondary)]'
|
|
||||||
}`}>
|
|
||||||
<CheckCircle className="w-6 h-6 mx-auto mb-2" />
|
|
||||||
<span className="text-sm font-medium">Completo</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Review Stage - Combined validation results and product approval */}
|
|
||||||
{(stage === 'review' || (stage === 'completed' && products.length > 0)) && results && (
|
|
||||||
<div className="space-y-8">
|
|
||||||
{/* Success Header with Validation Results */}
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="w-16 h-16 bg-[var(--color-success)] rounded-full flex items-center justify-center mx-auto mb-6">
|
|
||||||
<CheckCircle className="w-8 h-8 text-white" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-2xl font-semibold text-[var(--color-success)] mb-3">
|
|
||||||
¡Validación Completada!
|
|
||||||
</h3>
|
|
||||||
<p className="text-[var(--text-secondary)] max-w-2xl mx-auto">
|
|
||||||
{results.validationMessage || 'Tus datos han sido procesados exitosamente. Revisa y aprueba los productos detectados.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Enhanced Stats Cards */}
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
||||||
<div className="text-center p-4 bg-[var(--color-info)]/10 rounded-lg">
|
|
||||||
<p className="text-2xl font-bold text-[var(--color-info)]">{results.totalRecords || results.total_records}</p>
|
|
||||||
<p className="text-sm text-[var(--text-secondary)]">Total Registros</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-4 bg-[var(--color-success)]/10 rounded-lg">
|
|
||||||
<p className="text-2xl font-bold text-[var(--color-success)]">{results.validRecords || results.valid_records}</p>
|
|
||||||
<p className="text-sm text-[var(--text-secondary)]">Válidos</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-4 bg-[var(--color-error)]/10 rounded-lg">
|
|
||||||
<p className="text-2xl font-bold text-[var(--color-error)]">{results.invalidRecords || results.invalid_records || 0}</p>
|
|
||||||
<p className="text-sm text-[var(--text-secondary)]">Inválidos</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-center p-4 bg-[var(--color-primary)]/10 rounded-lg">
|
|
||||||
<p className="text-2xl font-bold text-[var(--color-primary)]">{results.productsIdentified}</p>
|
|
||||||
<p className="text-sm text-[var(--text-secondary)]">Productos</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Product Review Section */}
|
|
||||||
{products.length > 0 && (
|
|
||||||
<>
|
|
||||||
{/* Summary Stats */}
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
||||||
<Card className="p-6 text-center bg-[var(--bg-primary)] border-[var(--border-primary)]">
|
|
||||||
<div className="text-3xl font-bold text-[var(--color-primary)] mb-2">{stats.total}</div>
|
|
||||||
<div className="text-sm font-medium text-[var(--text-secondary)]">Productos detectados</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-6 text-center bg-[var(--color-success)]/5 border-[var(--color-success)]/20">
|
|
||||||
<div className="text-3xl font-bold text-[var(--color-success)] mb-2">{stats.approved}</div>
|
|
||||||
<div className="text-sm font-medium text-[var(--color-success)]">Aprobados</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-6 text-center bg-[var(--color-error)]/5 border-[var(--color-error)]/20">
|
|
||||||
<div className="text-3xl font-bold text-[var(--color-error)] mb-2">{stats.rejected}</div>
|
|
||||||
<div className="text-sm font-medium text-[var(--color-error)]">Rechazados</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-6 text-center bg-[var(--color-warning)]/5 border-[var(--color-warning)]/20">
|
|
||||||
<div className="text-3xl font-bold text-[var(--color-warning)] mb-2">{stats.pending}</div>
|
|
||||||
<div className="text-sm font-medium text-[var(--color-warning)]">Pendientes</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Controls */}
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-between items-start sm:items-center bg-[var(--bg-secondary)] p-4 rounded-lg border border-[var(--border-secondary)]">
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<select
|
|
||||||
value={selectedCategory}
|
|
||||||
onChange={(e) => setSelectedCategory(e.target.value)}
|
|
||||||
className="px-3 py-2 border border-[var(--border-primary)] rounded-lg bg-[var(--bg-primary)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-[var(--color-primary)]"
|
|
||||||
>
|
|
||||||
{categories.map(category => (
|
|
||||||
<option key={category} value={category}>
|
|
||||||
{category === 'all' ? 'Todas las categorías' : category}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<Badge variant="outline" className="text-sm font-medium px-3 py-1 bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-secondary)]">
|
|
||||||
{getFilteredProducts().length} productos
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handleBulkAction('approve')}
|
|
||||||
className="text-[var(--color-success)] border-[var(--color-success)]/30 hover:bg-[var(--color-success)]/10 bg-[var(--color-success)]/5"
|
|
||||||
>
|
|
||||||
Aprobar todos
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handleBulkAction('reject')}
|
|
||||||
className="text-[var(--color-error)] border-[var(--color-error)]/30 hover:bg-[var(--color-error)]/10 bg-[var(--color-error)]/5"
|
|
||||||
>
|
|
||||||
Rechazar todos
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Products List */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
{getFilteredProducts().map((product) => (
|
|
||||||
<Card key={product.id} className="p-6 hover:shadow-lg transition-all duration-200 border border-[var(--border-primary)] bg-[var(--bg-primary)]">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1 min-w-0 pr-4">
|
|
||||||
<div className="flex items-center flex-wrap gap-2 mb-3">
|
|
||||||
<h3 className="font-semibold text-lg text-[var(--text-primary)] mr-2">{product.name}</h3>
|
|
||||||
<Badge className={`text-xs font-medium px-2 py-1 rounded-full border ${getStatusColor(product.status)}`}>
|
|
||||||
{product.status === 'approved' ? '✓ Aprobado' :
|
|
||||||
product.status === 'rejected' ? '✗ Rechazado' : '⏳ Pendiente'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center flex-wrap gap-2 mb-3">
|
|
||||||
<Badge className="text-xs font-medium px-2 py-1 rounded-full bg-[var(--color-primary)]/10 text-[var(--color-primary)] border border-[var(--color-primary)]/20">
|
|
||||||
{product.category}
|
|
||||||
</Badge>
|
|
||||||
<Badge className={`text-xs font-medium px-2 py-1 rounded-full border ${getConfidenceColor(product.confidence)}`}>
|
|
||||||
{product.confidence}% confianza
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-sm text-[var(--text-secondary)] space-y-2">
|
|
||||||
{product.original_name && product.original_name !== product.name && (
|
|
||||||
<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="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span className="font-medium text-[var(--text-tertiary)] min-w-[60px]">Tipo:</span>
|
|
||||||
<span className="font-medium text-[var(--text-primary)]">
|
|
||||||
{product.product_type === 'ingredient' ? 'Ingrediente' : 'Producto terminado'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span className="font-medium text-[var(--text-tertiary)] min-w-[60px]">Unidad:</span>
|
|
||||||
<span className="font-medium text-[var(--text-primary)]">{product.unit_of_measure}</span>
|
|
||||||
</div>
|
|
||||||
{product.sales_data && (
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span className="font-medium text-[var(--text-tertiary)] min-w-[60px]">Ventas:</span>
|
|
||||||
<span className="font-medium text-[var(--text-primary)]">{product.sales_data.total_quantity}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{product.notes && (
|
|
||||||
<div className="text-xs italic bg-[var(--bg-secondary)] p-2 rounded border-l-4 border-[var(--color-primary)]/30">
|
|
||||||
<span className="font-medium text-[var(--text-tertiary)]">Nota:</span> {product.notes}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-2 flex-shrink-0">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={product.status === 'approved' ? 'default' : 'outline'}
|
|
||||||
onClick={() => handleProductAction(product.id, 'approve')}
|
|
||||||
className={product.status === 'approved'
|
|
||||||
? 'bg-[var(--color-success)] hover:bg-[var(--color-success)]/90 text-white shadow-sm'
|
|
||||||
: 'text-[var(--color-success)] border-[var(--color-success)]/30 hover:bg-[var(--color-success)]/10 bg-[var(--color-success)]/5'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<CheckCircle className="w-4 h-4 mr-1" />
|
|
||||||
Aprobar
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={product.status === 'rejected' ? 'default' : 'outline'}
|
|
||||||
onClick={() => handleProductAction(product.id, 'reject')}
|
|
||||||
className={product.status === 'rejected'
|
|
||||||
? 'bg-[var(--color-error)] hover:bg-[var(--color-error)]/90 text-white shadow-sm'
|
|
||||||
: 'text-[var(--color-error)] border-[var(--color-error)]/30 hover:bg-[var(--color-error)]/10 bg-[var(--color-error)]/5'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<AlertCircle className="w-4 h-4 mr-1" />
|
|
||||||
Rechazar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Information */}
|
|
||||||
<Card className="p-4 bg-[var(--color-info)]/5 border-[var(--color-info)]/20">
|
|
||||||
<h4 className="font-medium text-[var(--color-info)] mb-2">
|
|
||||||
📋 Revisión de Datos de Ventas:
|
|
||||||
</h4>
|
|
||||||
<ul className="text-sm text-[var(--color-info)] space-y-1">
|
|
||||||
<li>• <strong>Datos validados</strong> - Tu archivo ha sido procesado y validado correctamente</li>
|
|
||||||
<li>• <strong>Productos detectados</strong> - La IA ha identificado automáticamente productos desde tus ventas</li>
|
|
||||||
<li>• <strong>Revisa cuidadosamente</strong> - Aprueba o rechaza cada producto según sea correcto para tu negocio</li>
|
|
||||||
<li>• <strong>Verifica nombres</strong> - Compara el nombre original vs. el nombre sugerido</li>
|
|
||||||
<li>• <strong>Revisa clasificaciones</strong> - Confirma si son ingredientes o productos terminados</li>
|
|
||||||
<li>• <strong>Usa filtros</strong> - Filtra por categoría para revisar productos similares</li>
|
|
||||||
<li>• <strong>Acciones masivas</strong> - Use "Aprobar todos" o "Rechazar todos" para agilizar el proceso</li>
|
|
||||||
</ul>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Error State */}
|
|
||||||
{stage === 'error' && (
|
|
||||||
<Card className="p-8 text-center">
|
|
||||||
<div className="w-16 h-16 bg-[var(--color-error)] rounded-full flex items-center justify-center mx-auto mb-6">
|
|
||||||
<AlertCircle className="w-8 h-8 text-white" />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-xl font-semibold text-[var(--color-error)] mb-3">
|
|
||||||
Error en el procesamiento
|
|
||||||
</h3>
|
|
||||||
<p className="text-[var(--text-secondary)] mb-6">
|
|
||||||
{currentMessage}
|
|
||||||
</p>
|
|
||||||
<Button onClick={resetProcess} variant="outline">
|
|
||||||
Intentar nuevamente
|
|
||||||
</Button>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,769 +0,0 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
|
||||||
import { Package, Calendar, AlertTriangle, Plus, Edit, Trash2, CheckCircle } from 'lucide-react';
|
|
||||||
import { Button, Card, Input, Badge } from '../../../ui';
|
|
||||||
import { OnboardingStepProps } from '../OnboardingWizard';
|
|
||||||
import { useOnboarding } from '../../../../hooks/business/onboarding';
|
|
||||||
import { useModal } from '../../../../hooks/ui/useModal';
|
|
||||||
import { useToast } from '../../../../hooks/ui/useToast';
|
|
||||||
import { useAuthUser } from '../../../../stores/auth.store';
|
|
||||||
import { useCurrentTenant } from '../../../../stores';
|
|
||||||
|
|
||||||
interface InventoryItem {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
category: 'ingredient' | 'finished_product';
|
|
||||||
current_stock: number;
|
|
||||||
min_stock: number;
|
|
||||||
max_stock: number;
|
|
||||||
unit: string;
|
|
||||||
expiry_date?: string;
|
|
||||||
supplier?: string;
|
|
||||||
cost_per_unit?: number;
|
|
||||||
requires_refrigeration: boolean;
|
|
||||||
// API fields
|
|
||||||
suggestion_id?: string;
|
|
||||||
original_name?: string;
|
|
||||||
estimated_shelf_life_days?: number;
|
|
||||||
is_seasonal?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert approved products to inventory items
|
|
||||||
const convertProductsToInventory = (approvedProducts: any[]): InventoryItem[] => {
|
|
||||||
return approvedProducts.map((product, index) => ({
|
|
||||||
id: `inventory-${index}`,
|
|
||||||
name: product.suggested_name || product.original_name,
|
|
||||||
category: product.product_type === 'ingredient' ? 'ingredient' : 'finished_product',
|
|
||||||
current_stock: 0, // To be configured by user
|
|
||||||
min_stock: 1, // Default minimum
|
|
||||||
max_stock: 100, // Default maximum
|
|
||||||
unit: product.unit_of_measure || 'units',
|
|
||||||
requires_refrigeration: product.requires_refrigeration || false,
|
|
||||||
// Store API data
|
|
||||||
suggestion_id: product.suggestion_id,
|
|
||||||
original_name: product.original_name,
|
|
||||||
estimated_shelf_life_days: product.estimated_shelf_life_days,
|
|
||||||
is_seasonal: product.is_seasonal,
|
|
||||||
// Optional fields to be filled by user
|
|
||||||
expiry_date: undefined,
|
|
||||||
supplier: product.suggested_supplier,
|
|
||||||
cost_per_unit: 0
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const InventorySetupStep: React.FC<OnboardingStepProps> = ({
|
|
||||||
data,
|
|
||||||
onDataChange,
|
|
||||||
onNext,
|
|
||||||
onPrevious,
|
|
||||||
isFirstStep,
|
|
||||||
isLastStep
|
|
||||||
}) => {
|
|
||||||
const user = useAuthUser();
|
|
||||||
const currentTenant = useCurrentTenant();
|
|
||||||
const { showToast } = useToast();
|
|
||||||
|
|
||||||
// Use the business onboarding hooks
|
|
||||||
const {
|
|
||||||
updateStepData,
|
|
||||||
createInventoryFromSuggestions,
|
|
||||||
importSalesData,
|
|
||||||
inventorySetup: {
|
|
||||||
createdItems,
|
|
||||||
inventoryMapping,
|
|
||||||
salesImportResult,
|
|
||||||
isInventoryConfigured
|
|
||||||
},
|
|
||||||
allStepData,
|
|
||||||
isLoading,
|
|
||||||
error,
|
|
||||||
clearError
|
|
||||||
} = useOnboarding();
|
|
||||||
|
|
||||||
const createAlert = (alert: any) => {
|
|
||||||
console.log('Alert:', alert);
|
|
||||||
if (showToast && typeof showToast === 'function') {
|
|
||||||
showToast({
|
|
||||||
title: alert.title,
|
|
||||||
message: alert.message,
|
|
||||||
type: alert.type
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Fallback to console if showToast is not available
|
|
||||||
console.warn(`Toast would show: ${alert.title} - ${alert.message}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Use modal for confirmations and editing
|
|
||||||
const editModal = useModal();
|
|
||||||
const confirmModal = useModal();
|
|
||||||
|
|
||||||
const [isCreating, setIsCreating] = useState(false);
|
|
||||||
const [editingItem, setEditingItem] = useState<InventoryItem | null>(null);
|
|
||||||
const [isAddingNew, setIsAddingNew] = useState(false);
|
|
||||||
const [inventoryCreationAttempted, setInventoryCreationAttempted] = useState(false);
|
|
||||||
|
|
||||||
// Generate inventory items from approved products
|
|
||||||
const generateInventoryFromProducts = (approvedProducts: any[]): InventoryItem[] => {
|
|
||||||
if (!approvedProducts || approvedProducts.length === 0) {
|
|
||||||
createAlert({
|
|
||||||
type: 'warning',
|
|
||||||
category: 'system',
|
|
||||||
priority: 'medium',
|
|
||||||
title: 'Sin productos aprobados',
|
|
||||||
message: 'No hay productos aprobados para crear inventario. Regrese al paso anterior.',
|
|
||||||
source: 'onboarding'
|
|
||||||
});
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return convertProductsToInventory(approvedProducts);
|
|
||||||
};
|
|
||||||
|
|
||||||
const [items, setItems] = useState<InventoryItem[]>(() => {
|
|
||||||
if (data.inventoryItems) {
|
|
||||||
return data.inventoryItems;
|
|
||||||
}
|
|
||||||
// Try to get approved products from business hooks data first, then from component props
|
|
||||||
const approvedProducts = data.approvedProducts ||
|
|
||||||
allStepData?.['sales-validation']?.approvedProducts ||
|
|
||||||
data.allStepData?.['sales-validation']?.approvedProducts;
|
|
||||||
return generateInventoryFromProducts(approvedProducts || []);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update items when approved products become available (for when component is already mounted)
|
|
||||||
useEffect(() => {
|
|
||||||
const approvedProducts = data.approvedProducts ||
|
|
||||||
allStepData?.['sales-validation']?.approvedProducts ||
|
|
||||||
data.allStepData?.['sales-validation']?.approvedProducts;
|
|
||||||
|
|
||||||
if (approvedProducts && approvedProducts.length > 0 && items.length === 0) {
|
|
||||||
const newItems = generateInventoryFromProducts(approvedProducts);
|
|
||||||
setItems(newItems);
|
|
||||||
}
|
|
||||||
}, [data.approvedProducts, allStepData, data.allStepData]);
|
|
||||||
|
|
||||||
const [filterCategory, setFilterCategory] = useState<'all' | 'ingredient' | 'finished_product'>('all');
|
|
||||||
|
|
||||||
const filteredItems = filterCategory === 'all'
|
|
||||||
? items
|
|
||||||
: items.filter(item => item.category === filterCategory);
|
|
||||||
|
|
||||||
// Create inventory items via API using business hooks
|
|
||||||
const handleCreateInventory = async () => {
|
|
||||||
console.log('InventorySetup - Starting handleCreateInventory');
|
|
||||||
|
|
||||||
const approvedProducts = data.approvedProducts ||
|
|
||||||
allStepData?.['sales-validation']?.approvedProducts ||
|
|
||||||
data.allStepData?.['sales-validation']?.approvedProducts;
|
|
||||||
console.log('InventorySetup - approvedProducts:', {
|
|
||||||
fromDataProp: data.approvedProducts,
|
|
||||||
fromAllStepData: allStepData?.['sales-validation']?.approvedProducts,
|
|
||||||
fromDataAllStepData: data.allStepData?.['sales-validation']?.approvedProducts,
|
|
||||||
finalProducts: approvedProducts,
|
|
||||||
allStepDataKeys: Object.keys(allStepData || {}),
|
|
||||||
dataKeys: Object.keys(data || {})
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get tenant ID from current tenant context or user
|
|
||||||
const tenantId = currentTenant?.id || user?.tenant_id;
|
|
||||||
console.log('InventorySetup - tenantId:', tenantId);
|
|
||||||
|
|
||||||
if (!tenantId || !approvedProducts || approvedProducts.length === 0) {
|
|
||||||
console.log('InventorySetup - Missing requirements: tenantId =', tenantId, 'approvedProducts length =', approvedProducts?.length);
|
|
||||||
|
|
||||||
createAlert({
|
|
||||||
type: 'error',
|
|
||||||
category: 'system',
|
|
||||||
priority: 'high',
|
|
||||||
title: 'Error',
|
|
||||||
message: !tenantId ? 'No se pudo obtener información del tenant' : 'No se pueden crear elementos de inventario sin productos aprobados.',
|
|
||||||
source: 'onboarding'
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsCreating(true);
|
|
||||||
try {
|
|
||||||
// Approved products should already be in ProductSuggestionResponse format
|
|
||||||
// Just ensure they have all required fields
|
|
||||||
const suggestions = approvedProducts.map((product: any, index: number) => ({
|
|
||||||
suggestion_id: product.suggestion_id || `suggestion-${Date.now()}-${index}`,
|
|
||||||
original_name: product.original_name,
|
|
||||||
suggested_name: product.suggested_name,
|
|
||||||
product_type: product.product_type,
|
|
||||||
category: product.category,
|
|
||||||
unit_of_measure: product.unit_of_measure,
|
|
||||||
confidence_score: product.confidence_score || 0.8,
|
|
||||||
estimated_shelf_life_days: product.estimated_shelf_life_days,
|
|
||||||
requires_refrigeration: product.requires_refrigeration,
|
|
||||||
requires_freezing: product.requires_freezing,
|
|
||||||
is_seasonal: product.is_seasonal,
|
|
||||||
suggested_supplier: product.suggested_supplier,
|
|
||||||
notes: product.notes
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Use business onboarding hook to create inventory
|
|
||||||
const inventorySuccess = await createInventoryFromSuggestions(suggestions);
|
|
||||||
|
|
||||||
if (inventorySuccess) {
|
|
||||||
createAlert({
|
|
||||||
type: 'success',
|
|
||||||
category: 'system',
|
|
||||||
priority: 'medium',
|
|
||||||
title: 'Inventario creado',
|
|
||||||
message: `Se crearon ${suggestions.length} elementos de inventario exitosamente.`,
|
|
||||||
source: 'onboarding'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Now try to import sales data if available
|
|
||||||
const salesDataFile = data.allStepData?.['sales-validation']?.salesDataFile ||
|
|
||||||
allStepData?.['sales-validation']?.salesDataFile;
|
|
||||||
const processingResults = data.allStepData?.['sales-validation']?.processingResults ||
|
|
||||||
allStepData?.['sales-validation']?.processingResults;
|
|
||||||
|
|
||||||
if (salesDataFile && processingResults?.is_valid && inventoryMapping) {
|
|
||||||
try {
|
|
||||||
createAlert({
|
|
||||||
type: 'info',
|
|
||||||
category: 'system',
|
|
||||||
priority: 'medium',
|
|
||||||
title: 'Subiendo datos de ventas',
|
|
||||||
message: 'Subiendo historial de ventas al sistema para entrenamiento de IA...',
|
|
||||||
source: 'onboarding'
|
|
||||||
});
|
|
||||||
|
|
||||||
const salesSuccess = await importSalesData(processingResults, inventoryMapping);
|
|
||||||
|
|
||||||
if (salesSuccess) {
|
|
||||||
createAlert({
|
|
||||||
type: 'success',
|
|
||||||
category: 'system',
|
|
||||||
priority: 'medium',
|
|
||||||
title: 'Datos de ventas subidos',
|
|
||||||
message: `Se subieron ${processingResults.total_records} registros de ventas al sistema exitosamente.`,
|
|
||||||
source: 'onboarding'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (salesError) {
|
|
||||||
console.error('Error uploading sales data:', salesError);
|
|
||||||
createAlert({
|
|
||||||
type: 'error',
|
|
||||||
category: 'system',
|
|
||||||
priority: 'high',
|
|
||||||
title: 'Error al subir datos de ventas',
|
|
||||||
message: 'El inventario se creó correctamente, pero hubo un problema al subir los datos de ventas.',
|
|
||||||
source: 'onboarding'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update component data and business hook data
|
|
||||||
const updatedData = {
|
|
||||||
...data,
|
|
||||||
inventoryItems: items,
|
|
||||||
inventoryConfigured: true,
|
|
||||||
inventoryCreated: true,
|
|
||||||
inventoryMapping,
|
|
||||||
createdInventoryItems: createdItems,
|
|
||||||
salesImportResult
|
|
||||||
};
|
|
||||||
onDataChange(updatedData);
|
|
||||||
|
|
||||||
// Also update step data in business hooks
|
|
||||||
updateStepData('inventory', {
|
|
||||||
inventoryItems: items,
|
|
||||||
inventoryConfigured: true,
|
|
||||||
inventoryCreated: true,
|
|
||||||
inventoryMapping,
|
|
||||||
createdInventoryItems: createdItems,
|
|
||||||
salesImportResult
|
|
||||||
});
|
|
||||||
|
|
||||||
} else {
|
|
||||||
createAlert({
|
|
||||||
type: 'error',
|
|
||||||
category: 'system',
|
|
||||||
priority: 'high',
|
|
||||||
title: 'Error al crear inventario',
|
|
||||||
message: 'No se pudieron crear los elementos de inventario.',
|
|
||||||
source: 'onboarding'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating inventory:', error);
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Error al crear inventario';
|
|
||||||
createAlert({
|
|
||||||
type: 'error',
|
|
||||||
category: 'system',
|
|
||||||
priority: 'high',
|
|
||||||
title: 'Error al crear inventario',
|
|
||||||
message: errorMessage,
|
|
||||||
source: 'onboarding'
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsCreating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const lastItemsRef = useRef(items);
|
|
||||||
const lastIsCreatingRef = useRef(isCreating);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Only update if items or isCreating actually changed
|
|
||||||
if (JSON.stringify(items) !== JSON.stringify(lastItemsRef.current) || isCreating !== lastIsCreatingRef.current) {
|
|
||||||
lastItemsRef.current = items;
|
|
||||||
lastIsCreatingRef.current = isCreating;
|
|
||||||
|
|
||||||
const hasValidStock = items.length > 0 && items.every(item =>
|
|
||||||
item.min_stock >= 0 && item.max_stock > item.min_stock
|
|
||||||
);
|
|
||||||
|
|
||||||
const stepData = {
|
|
||||||
inventoryItems: items,
|
|
||||||
inventoryConfigured: hasValidStock && !isCreating
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update component props
|
|
||||||
onDataChange({
|
|
||||||
...data,
|
|
||||||
...stepData
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update business hooks data
|
|
||||||
updateStepData('inventory', stepData);
|
|
||||||
}
|
|
||||||
}, [items, isCreating]); // Only depend on items and isCreating
|
|
||||||
|
|
||||||
// Auto-create inventory when step is completed (when user clicks Next)
|
|
||||||
useEffect(() => {
|
|
||||||
const hasValidStock = items.length > 0 && items.every(item =>
|
|
||||||
item.min_stock >= 0 && item.max_stock > item.min_stock
|
|
||||||
);
|
|
||||||
|
|
||||||
// If inventory is configured but not yet created in backend, create it automatically
|
|
||||||
if (hasValidStock && !data.inventoryCreated && !isCreating && !inventoryCreationAttempted) {
|
|
||||||
console.log('InventorySetup - Auto-creating inventory on step completion');
|
|
||||||
setInventoryCreationAttempted(true);
|
|
||||||
handleCreateInventory();
|
|
||||||
}
|
|
||||||
}, [data.inventoryCreated, items, isCreating, inventoryCreationAttempted]);
|
|
||||||
|
|
||||||
const handleAddItem = () => {
|
|
||||||
const newItem: InventoryItem = {
|
|
||||||
id: Date.now().toString(),
|
|
||||||
name: '',
|
|
||||||
category: 'ingredient',
|
|
||||||
current_stock: 0,
|
|
||||||
min_stock: 1,
|
|
||||||
max_stock: 10,
|
|
||||||
unit: 'unidad',
|
|
||||||
requires_refrigeration: false
|
|
||||||
};
|
|
||||||
setItems([...items, newItem]);
|
|
||||||
setEditingItem(newItem);
|
|
||||||
setIsAddingNew(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveItem = (updatedItem: InventoryItem) => {
|
|
||||||
setItems(items.map(item =>
|
|
||||||
item.id === updatedItem.id ? updatedItem : item
|
|
||||||
));
|
|
||||||
setEditingItem(null);
|
|
||||||
setIsAddingNew(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteItem = (id: string) => {
|
|
||||||
setItems(items.filter(item => item.id !== id));
|
|
||||||
if (editingItem?.id === id) {
|
|
||||||
setEditingItem(null);
|
|
||||||
setIsAddingNew(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancelEdit = () => {
|
|
||||||
if (isAddingNew && editingItem) {
|
|
||||||
setItems(items.filter(item => item.id !== editingItem.id));
|
|
||||||
}
|
|
||||||
setEditingItem(null);
|
|
||||||
setIsAddingNew(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStockStatus = (item: InventoryItem) => {
|
|
||||||
if (item.current_stock <= item.min_stock) return 'critical';
|
|
||||||
if (item.current_stock <= item.min_stock * 1.5) return 'warning';
|
|
||||||
return 'good';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStockStatusColor = (status: string) => {
|
|
||||||
switch (status) {
|
|
||||||
case 'critical': return 'text-[var(--color-error)] bg-[var(--color-error)]/10 border-[var(--color-error)]/20';
|
|
||||||
case 'warning': return 'text-[var(--color-warning)] bg-[var(--color-warning)]/10 border-[var(--color-warning)]/20';
|
|
||||||
default: return 'text-[var(--color-success)] bg-[var(--color-success)]/10 border-[var(--color-success)]/20';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (items.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
<div className="text-center py-16">
|
|
||||||
<Package className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
|
||||||
<h3 className="text-2xl font-bold text-gray-600 mb-2">
|
|
||||||
Sin productos para inventario
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-500 mb-6 max-w-md mx-auto">
|
|
||||||
No hay productos aprobados para crear inventario.
|
|
||||||
Regrese al paso anterior para aprobar productos.
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={onPrevious}
|
|
||||||
disabled={isFirstStep}
|
|
||||||
>
|
|
||||||
Volver al paso anterior
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="text-center">
|
|
||||||
<h2 className="text-3xl font-bold text-[var(--text-primary)] mb-4">
|
|
||||||
Configuración de Inventario
|
|
||||||
</h2>
|
|
||||||
<p className="text-[var(--text-secondary)] text-lg max-w-2xl mx-auto">
|
|
||||||
Configure los niveles de stock, fechas de vencimiento y otros detalles para sus productos.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
||||||
<Card className="p-6 text-center bg-[var(--bg-primary)] border-[var(--border-primary)]">
|
|
||||||
<div className="text-3xl font-bold text-[var(--color-primary)] mb-2">{items.length}</div>
|
|
||||||
<div className="text-sm font-medium text-[var(--text-secondary)]">Elementos totales</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-6 text-center bg-[var(--color-info)]/5 border-[var(--color-info)]/20">
|
|
||||||
<div className="text-3xl font-bold text-[var(--color-info)] mb-2">
|
|
||||||
{items.filter(item => item.category === 'ingredient').length}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-medium text-[var(--color-info)]">Ingredientes</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-6 text-center bg-[var(--color-success)]/5 border-[var(--color-success)]/20">
|
|
||||||
<div className="text-3xl font-bold text-[var(--color-success)] mb-2">
|
|
||||||
{items.filter(item => item.category === 'finished_product').length}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-medium text-[var(--color-success)]">Productos terminados</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-6 text-center bg-[var(--color-error)]/5 border-[var(--color-error)]/20">
|
|
||||||
<div className="text-3xl font-bold text-[var(--color-error)] mb-2">
|
|
||||||
{items.filter(item => getStockStatus(item) === 'critical').length}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-medium text-[var(--color-error)]">Stock crítico</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Controls */}
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-between items-start sm:items-center bg-[var(--bg-secondary)] p-4 rounded-lg border border-[var(--border-secondary)]">
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<select
|
|
||||||
value={filterCategory}
|
|
||||||
onChange={(e) => setFilterCategory(e.target.value as any)}
|
|
||||||
className="px-3 py-2 border border-[var(--border-primary)] rounded-lg bg-[var(--bg-primary)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-[var(--color-primary)]"
|
|
||||||
>
|
|
||||||
<option value="all">Todos los elementos</option>
|
|
||||||
<option value="ingredient">Ingredientes</option>
|
|
||||||
<option value="finished_product">Productos terminados</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<Badge variant="outline" className="text-sm font-medium px-3 py-1 bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-secondary)]">
|
|
||||||
{filteredItems.length} elementos
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<Button
|
|
||||||
onClick={handleAddItem}
|
|
||||||
size="sm"
|
|
||||||
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary)]/90 text-white shadow-sm"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
|
||||||
Agregar elemento
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleCreateInventory}
|
|
||||||
disabled={isCreating || items.length === 0 || data.inventoryCreated}
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
className="border-[var(--color-success)]/30 text-[var(--color-success)] hover:bg-[var(--color-success)]/10 bg-[var(--color-success)]/5 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<CheckCircle className="w-4 h-4 mr-1" />
|
|
||||||
{isCreating ? 'Creando...' : data.inventoryCreated ? 'Inventario creado automáticamente' : 'Crear inventario manualmente'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Items List */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
{filteredItems.map((item) => (
|
|
||||||
<Card key={item.id} className="p-6 hover:shadow-lg transition-all duration-200 border border-[var(--border-primary)] bg-[var(--bg-primary)]">
|
|
||||||
{editingItem?.id === item.id ? (
|
|
||||||
<InventoryItemEditor
|
|
||||||
item={item}
|
|
||||||
onSave={handleSaveItem}
|
|
||||||
onCancel={handleCancelEdit}
|
|
||||||
showToast={showToast}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1 min-w-0 pr-4">
|
|
||||||
<div className="flex items-center flex-wrap gap-2 mb-3">
|
|
||||||
<h3 className="font-semibold text-lg text-[var(--text-primary)] mr-2">{item.name}</h3>
|
|
||||||
<Badge className={`text-xs font-medium px-2 py-1 rounded-full border ${getStockStatus(item) === 'critical' ? getStockStatusColor('critical') : getStockStatus(item) === 'warning' ? getStockStatusColor('warning') : getStockStatusColor('good')}`}>
|
|
||||||
Stock: {getStockStatus(item) === 'critical' ? '🔴 Crítico' : getStockStatus(item) === 'warning' ? '🟡 Bajo' : '🟢 Bueno'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center flex-wrap gap-2 mb-3">
|
|
||||||
<Badge className="text-xs font-medium px-2 py-1 rounded-full bg-[var(--color-primary)]/10 text-[var(--color-primary)] border border-[var(--color-primary)]/20">
|
|
||||||
{item.category === 'ingredient' ? 'Ingrediente' : 'Producto terminado'}
|
|
||||||
</Badge>
|
|
||||||
{item.requires_refrigeration && (
|
|
||||||
<Badge className="text-xs font-medium px-2 py-1 rounded-full bg-[var(--color-info)]/10 text-[var(--color-info)] border border-[var(--color-info)]/20">
|
|
||||||
❄️ Refrigeración
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-sm text-[var(--text-secondary)] space-y-2">
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span className="font-medium text-[var(--text-tertiary)] min-w-[100px]">Stock actual:</span>
|
|
||||||
<span className="font-bold text-[var(--text-primary)]">{item.current_stock} {item.unit}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span className="font-medium text-[var(--text-tertiary)] min-w-[70px]">Mínimo:</span>
|
|
||||||
<span className="font-medium text-[var(--text-primary)]">{item.min_stock}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span className="font-medium text-[var(--text-tertiary)] min-w-[70px]">Máximo:</span>
|
|
||||||
<span className="font-medium text-[var(--text-primary)]">{item.max_stock}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{(item.expiry_date || item.supplier) && (
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
|
||||||
{item.expiry_date && (
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span className="font-medium text-[var(--text-tertiary)] min-w-[60px]">Vence:</span>
|
|
||||||
<span className="font-medium text-[var(--text-primary)]">{item.expiry_date}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{item.supplier && (
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span className="font-medium text-[var(--text-tertiary)] min-w-[80px]">Proveedor:</span>
|
|
||||||
<span className="font-medium text-[var(--text-primary)]">{item.supplier}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-2 flex-shrink-0">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setEditingItem(item)}
|
|
||||||
className="text-[var(--color-primary)] border-[var(--color-primary)]/30 hover:bg-[var(--color-primary)]/10 bg-[var(--color-primary)]/5"
|
|
||||||
>
|
|
||||||
<Edit className="w-4 h-4 mr-1" />
|
|
||||||
Editar
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handleDeleteItem(item.id)}
|
|
||||||
className="text-[var(--color-error)] border-[var(--color-error)]/30 hover:bg-[var(--color-error)]/10 bg-[var(--color-error)]/5"
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4 mr-1" />
|
|
||||||
Eliminar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Information */}
|
|
||||||
<Card className="p-4 bg-[var(--color-info)]/5 border-[var(--color-info)]/20">
|
|
||||||
<h4 className="font-medium text-[var(--color-info)] mb-2">
|
|
||||||
📦 Configuración de Inventario:
|
|
||||||
</h4>
|
|
||||||
<ul className="text-sm text-[var(--color-info)] space-y-1">
|
|
||||||
<li>• <strong>Configure stock inicial</strong> - Establezca los niveles de stock actuales para cada producto</li>
|
|
||||||
<li>• <strong>Defina límites</strong> - Establezca stock mínimo y máximo para recibir alertas automáticas</li>
|
|
||||||
<li>• <strong>Agregue detalles</strong> - Incluya fechas de vencimiento, proveedores y unidades de medida</li>
|
|
||||||
<li>• <strong>Marque refrigeración</strong> - Indique qué productos requieren condiciones especiales de almacenamiento</li>
|
|
||||||
<li>• <strong>Edite elementos</strong> - Haga clic en "Editar" para modificar cualquier producto</li>
|
|
||||||
<li>• <strong>Creación automática</strong> - El inventario se creará automáticamente al hacer clic en "Siguiente"</li>
|
|
||||||
</ul>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Inventory Item Editor Component
|
|
||||||
const InventoryItemEditor: React.FC<{
|
|
||||||
item: InventoryItem;
|
|
||||||
onSave: (item: InventoryItem) => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
showToast: (toast: any) => void;
|
|
||||||
}> = ({ item, onSave, onCancel, showToast }) => {
|
|
||||||
const [editedItem, setEditedItem] = useState<InventoryItem>(item);
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
if (!editedItem.name.trim()) {
|
|
||||||
showToast({
|
|
||||||
title: 'Error de validación',
|
|
||||||
message: 'El nombre es requerido',
|
|
||||||
type: 'error'
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (editedItem.min_stock < 0 || editedItem.max_stock <= editedItem.min_stock) {
|
|
||||||
showToast({
|
|
||||||
title: 'Error de validación',
|
|
||||||
message: 'Los niveles de stock deben ser válidos (máximo > mínimo >= 0)',
|
|
||||||
type: 'error'
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onSave(editedItem);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4 p-6 bg-[var(--bg-secondary)] rounded-lg border border-[var(--border-secondary)]">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2 text-[var(--text-primary)]">Nombre</label>
|
|
||||||
<Input
|
|
||||||
value={editedItem.name}
|
|
||||||
onChange={(e) => setEditedItem({ ...editedItem, name: e.target.value })}
|
|
||||||
placeholder="Nombre del producto"
|
|
||||||
className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2 text-[var(--text-primary)]">Categoría</label>
|
|
||||||
<select
|
|
||||||
value={editedItem.category}
|
|
||||||
onChange={(e) => setEditedItem({ ...editedItem, category: e.target.value as any })}
|
|
||||||
className="w-full px-3 py-2 border border-[var(--border-primary)] rounded-lg bg-[var(--bg-primary)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-[var(--color-primary)]"
|
|
||||||
>
|
|
||||||
<option value="ingredient">Ingrediente</option>
|
|
||||||
<option value="finished_product">Producto terminado</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2 text-[var(--text-primary)]">Stock actual</label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={editedItem.current_stock}
|
|
||||||
onChange={(e) => setEditedItem({ ...editedItem, current_stock: Number(e.target.value) })}
|
|
||||||
min="0"
|
|
||||||
className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2 text-[var(--text-primary)]">Unidad</label>
|
|
||||||
<Input
|
|
||||||
value={editedItem.unit}
|
|
||||||
onChange={(e) => setEditedItem({ ...editedItem, unit: e.target.value })}
|
|
||||||
placeholder="kg, litros, unidades..."
|
|
||||||
className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2 text-[var(--text-primary)]">Stock mínimo</label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={editedItem.min_stock}
|
|
||||||
onChange={(e) => setEditedItem({ ...editedItem, min_stock: Number(e.target.value) })}
|
|
||||||
min="0"
|
|
||||||
className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2 text-[var(--text-primary)]">Stock máximo</label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={editedItem.max_stock}
|
|
||||||
onChange={(e) => setEditedItem({ ...editedItem, max_stock: Number(e.target.value) })}
|
|
||||||
min="1"
|
|
||||||
className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2 text-[var(--text-primary)]">Fecha de vencimiento</label>
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
value={editedItem.expiry_date || ''}
|
|
||||||
onChange={(e) => setEditedItem({ ...editedItem, expiry_date: e.target.value })}
|
|
||||||
className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-2 text-[var(--text-primary)]">Proveedor</label>
|
|
||||||
<Input
|
|
||||||
value={editedItem.supplier || ''}
|
|
||||||
onChange={(e) => setEditedItem({ ...editedItem, supplier: e.target.value })}
|
|
||||||
placeholder="Nombre del proveedor"
|
|
||||||
className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<label className="flex items-center space-x-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={editedItem.requires_refrigeration}
|
|
||||||
onChange={(e) => setEditedItem({ ...editedItem, requires_refrigeration: e.target.checked })}
|
|
||||||
className="rounded border-[var(--border-primary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium text-[var(--text-primary)]">Requiere refrigeración</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end space-x-3 pt-4 border-t border-[var(--border-primary)]">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={onCancel}
|
|
||||||
className="text-[var(--text-secondary)] border-[var(--border-primary)] hover:bg-[var(--bg-tertiary)]"
|
|
||||||
>
|
|
||||||
Cancelar
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleSave}
|
|
||||||
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary)]/90 text-white"
|
|
||||||
>
|
|
||||||
Guardar
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,922 @@
|
|||||||
|
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||||
|
import { Upload, Brain, CheckCircle, AlertCircle, Download, FileText, Activity, Package, Edit } from 'lucide-react';
|
||||||
|
import { Button, Card, Badge, Input } from '../../../ui';
|
||||||
|
import { OnboardingStepProps } from '../OnboardingWizard';
|
||||||
|
import { useToast } from '../../../../hooks/ui/useToast';
|
||||||
|
import { useOnboarding } from '../../../../hooks/business/onboarding';
|
||||||
|
import { useAuthUser, useAuthLoading } from '../../../../stores/auth.store';
|
||||||
|
import { useCurrentTenant } from '../../../../stores';
|
||||||
|
import type { ProductSuggestionResponse } from '../../../../api';
|
||||||
|
|
||||||
|
type ProcessingStage = 'upload' | 'validating' | 'analyzing' | 'review' | 'inventory-config' | 'creating' | 'completed' | 'error';
|
||||||
|
|
||||||
|
interface EnhancedProductCard {
|
||||||
|
// From AI suggestion
|
||||||
|
suggestion_id: string;
|
||||||
|
original_name: string;
|
||||||
|
suggested_name: string;
|
||||||
|
product_type: 'ingredient' | 'finished_product';
|
||||||
|
category: string;
|
||||||
|
unit_of_measure: string;
|
||||||
|
confidence_score: number;
|
||||||
|
estimated_shelf_life_days?: number;
|
||||||
|
requires_refrigeration: boolean;
|
||||||
|
requires_freezing: boolean;
|
||||||
|
is_seasonal: boolean;
|
||||||
|
suggested_supplier?: string;
|
||||||
|
notes?: string;
|
||||||
|
sales_data?: {
|
||||||
|
total_quantity: number;
|
||||||
|
average_daily_sales: number;
|
||||||
|
peak_day: string;
|
||||||
|
frequency: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Inventory configuration (user editable)
|
||||||
|
current_stock: number;
|
||||||
|
min_stock: number;
|
||||||
|
max_stock: number;
|
||||||
|
cost_per_unit?: number;
|
||||||
|
custom_expiry_date?: string;
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
status: 'pending' | 'approved' | 'rejected';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert API suggestions to enhanced product cards
|
||||||
|
const convertSuggestionsToCards = (suggestions: ProductSuggestionResponse[]): EnhancedProductCard[] => {
|
||||||
|
return suggestions.map(suggestion => ({
|
||||||
|
// From API
|
||||||
|
suggestion_id: suggestion.suggestion_id,
|
||||||
|
original_name: suggestion.original_name,
|
||||||
|
suggested_name: suggestion.suggested_name,
|
||||||
|
product_type: suggestion.product_type as 'ingredient' | 'finished_product',
|
||||||
|
category: suggestion.category,
|
||||||
|
unit_of_measure: suggestion.unit_of_measure,
|
||||||
|
confidence_score: suggestion.confidence_score,
|
||||||
|
estimated_shelf_life_days: suggestion.estimated_shelf_life_days,
|
||||||
|
requires_refrigeration: suggestion.requires_refrigeration,
|
||||||
|
requires_freezing: suggestion.requires_freezing,
|
||||||
|
is_seasonal: suggestion.is_seasonal,
|
||||||
|
suggested_supplier: suggestion.suggested_supplier,
|
||||||
|
notes: suggestion.notes,
|
||||||
|
sales_data: suggestion.sales_data,
|
||||||
|
|
||||||
|
// Smart defaults based on sales data
|
||||||
|
current_stock: 0,
|
||||||
|
min_stock: Math.max(1, Math.ceil((suggestion.sales_data?.average_daily_sales || 1) * 7)), // Week buffer
|
||||||
|
max_stock: Math.max(10, Math.ceil((suggestion.sales_data?.total_quantity || 10) * 2)), // 2x peak quantity
|
||||||
|
cost_per_unit: 0,
|
||||||
|
custom_expiry_date: undefined,
|
||||||
|
|
||||||
|
// Initial state
|
||||||
|
status: suggestion.confidence_score >= 0.8 ? 'approved' : 'pending'
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SmartInventorySetupStep: React.FC<OnboardingStepProps> = ({
|
||||||
|
data,
|
||||||
|
onDataChange
|
||||||
|
}) => {
|
||||||
|
const user = useAuthUser();
|
||||||
|
const authLoading = useAuthLoading();
|
||||||
|
const currentTenant = useCurrentTenant();
|
||||||
|
|
||||||
|
// Use onboarding hooks
|
||||||
|
const {
|
||||||
|
processSalesFile,
|
||||||
|
createInventoryFromSuggestions,
|
||||||
|
importSalesData,
|
||||||
|
salesProcessing: {
|
||||||
|
stage: onboardingStage,
|
||||||
|
progress: onboardingProgress,
|
||||||
|
currentMessage: onboardingMessage,
|
||||||
|
validationResults,
|
||||||
|
suggestions
|
||||||
|
},
|
||||||
|
inventorySetup: {
|
||||||
|
createdItems,
|
||||||
|
inventoryMapping,
|
||||||
|
salesImportResult
|
||||||
|
},
|
||||||
|
tenantCreation,
|
||||||
|
error,
|
||||||
|
clearError
|
||||||
|
} = useOnboarding();
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
// Get tenant ID
|
||||||
|
const getTenantId = (): string | null => {
|
||||||
|
const onboardingTenantId = data.bakery?.tenant_id;
|
||||||
|
const tenantId = currentTenant?.id || user?.tenant_id || onboardingTenantId || null;
|
||||||
|
return tenantId;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if tenant data is available
|
||||||
|
const isTenantAvailable = (): boolean => {
|
||||||
|
const hasAuth = !authLoading && user;
|
||||||
|
const hasTenantId = getTenantId() !== null;
|
||||||
|
const tenantCreatedSuccessfully = tenantCreation.isSuccess;
|
||||||
|
const tenantCreatedInOnboarding = data.bakery?.tenantCreated === true;
|
||||||
|
|
||||||
|
return Boolean(hasAuth && (hasTenantId || tenantCreatedSuccessfully || tenantCreatedInOnboarding));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Local state
|
||||||
|
const [localStage, setLocalStage] = useState<ProcessingStage>(data.processingStage || 'upload');
|
||||||
|
const [uploadedFile, setUploadedFile] = useState<File | null>(data.files?.salesData || null);
|
||||||
|
const [products, setProducts] = useState<EnhancedProductCard[]>(() => {
|
||||||
|
if (data.detectedProducts) {
|
||||||
|
return data.detectedProducts;
|
||||||
|
}
|
||||||
|
if (suggestions && suggestions.length > 0) {
|
||||||
|
return convertSuggestionsToCards(suggestions);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||||
|
const [editingProduct, setEditingProduct] = useState<EnhancedProductCard | null>(null);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
const [dragActive, setDragActive] = useState(false);
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Update products when suggestions change
|
||||||
|
useEffect(() => {
|
||||||
|
if (suggestions && suggestions.length > 0 && products.length === 0) {
|
||||||
|
const newProducts = convertSuggestionsToCards(suggestions);
|
||||||
|
setProducts(newProducts);
|
||||||
|
setLocalStage('review');
|
||||||
|
}
|
||||||
|
}, [suggestions, products.length]);
|
||||||
|
|
||||||
|
// Derive current stage
|
||||||
|
const stage = (localStage === 'completed' || localStage === 'error')
|
||||||
|
? localStage
|
||||||
|
: (onboardingStage === 'completed' ? 'review' : onboardingStage || localStage);
|
||||||
|
const progress = onboardingProgress || 0;
|
||||||
|
const currentMessage = onboardingMessage || '';
|
||||||
|
|
||||||
|
// Product stats
|
||||||
|
const stats = useMemo(() => ({
|
||||||
|
total: products.length,
|
||||||
|
approved: products.filter(p => p.status === 'approved').length,
|
||||||
|
rejected: products.filter(p => p.status === 'rejected').length,
|
||||||
|
pending: products.filter(p => p.status === 'pending').length
|
||||||
|
}), [products]);
|
||||||
|
|
||||||
|
const approvedProducts = useMemo(() =>
|
||||||
|
products.filter(p => p.status === 'approved'),
|
||||||
|
[products]
|
||||||
|
);
|
||||||
|
|
||||||
|
const reviewCompleted = useMemo(() =>
|
||||||
|
products.length > 0 && products.every(p => p.status !== 'pending'),
|
||||||
|
[products]
|
||||||
|
);
|
||||||
|
|
||||||
|
const inventoryConfigured = useMemo(() =>
|
||||||
|
approvedProducts.length > 0 &&
|
||||||
|
approvedProducts.every(p => p.min_stock >= 0 && p.max_stock > p.min_stock),
|
||||||
|
[approvedProducts]
|
||||||
|
);
|
||||||
|
|
||||||
|
const categories = ['all', ...Array.from(new Set(products.map(p => p.category)))];
|
||||||
|
|
||||||
|
const getFilteredProducts = () => {
|
||||||
|
if (selectedCategory === 'all') return products;
|
||||||
|
return products.filter(p => p.category === selectedCategory);
|
||||||
|
};
|
||||||
|
|
||||||
|
// File handling
|
||||||
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragActive(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragActive(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragActive(false);
|
||||||
|
const files = Array.from(e.dataTransfer.files);
|
||||||
|
if (files.length > 0) {
|
||||||
|
handleFileUpload(files[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
|
handleFileUpload(e.target.files[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = async (file: File) => {
|
||||||
|
const validExtensions = ['.csv', '.xlsx', '.xls', '.json'];
|
||||||
|
const fileExtension = file.name.toLowerCase().substring(file.name.lastIndexOf('.'));
|
||||||
|
|
||||||
|
if (!validExtensions.includes(fileExtension)) {
|
||||||
|
toast.addToast('Formato de archivo no válido. Usa CSV, JSON o Excel (.xlsx, .xls)', {
|
||||||
|
title: 'Formato inválido',
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
toast.addToast('El archivo es demasiado grande. Máximo 10MB permitido.', {
|
||||||
|
title: 'Archivo muy grande',
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isTenantAvailable()) {
|
||||||
|
toast.addToast('Por favor espere mientras cargamos su información...', {
|
||||||
|
title: 'Esperando datos de usuario',
|
||||||
|
type: 'info'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploadedFile(file);
|
||||||
|
setLocalStage('validating');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const success = await processSalesFile(file);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
setLocalStage('review');
|
||||||
|
toast.addToast('El archivo se procesó correctamente. Revisa los productos detectados.', {
|
||||||
|
title: 'Procesamiento completado',
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
throw new Error('Error procesando el archivo');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing file:', error);
|
||||||
|
setLocalStage('error');
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Error en el procesamiento de datos';
|
||||||
|
|
||||||
|
toast.addToast(errorMessage, {
|
||||||
|
title: 'Error en el procesamiento',
|
||||||
|
type: 'error'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const downloadTemplate = () => {
|
||||||
|
const csvContent = `fecha,producto,cantidad,precio_unitario,precio_total,cliente,canal_venta
|
||||||
|
2024-01-15,Pan Integral,5,2.50,12.50,Cliente A,Tienda
|
||||||
|
2024-01-15,Croissant,3,1.80,5.40,Cliente B,Online
|
||||||
|
2024-01-15,Baguette,2,3.00,6.00,Cliente C,Tienda`;
|
||||||
|
|
||||||
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const link = document.createElement('a');
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
link.setAttribute('href', url);
|
||||||
|
link.setAttribute('download', 'plantilla_ventas.csv');
|
||||||
|
link.style.visibility = 'hidden';
|
||||||
|
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
toast.addToast('La plantilla se descargó correctamente.', {
|
||||||
|
title: 'Plantilla descargada',
|
||||||
|
type: 'success'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Product actions
|
||||||
|
const handleProductAction = (productId: string, action: 'approve' | 'reject') => {
|
||||||
|
setProducts(prev => prev.map(product =>
|
||||||
|
product.suggestion_id === productId
|
||||||
|
? { ...product, status: action === 'approve' ? 'approved' : 'rejected' }
|
||||||
|
: product
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkAction = (action: 'approve' | 'reject') => {
|
||||||
|
const filteredProducts = getFilteredProducts();
|
||||||
|
setProducts(prev => prev.map(product =>
|
||||||
|
filteredProducts.some(fp => fp.suggestion_id === product.suggestion_id)
|
||||||
|
? { ...product, status: action === 'approve' ? 'approved' : 'rejected' }
|
||||||
|
: product
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditProduct = (product: EnhancedProductCard) => {
|
||||||
|
setEditingProduct({ ...product });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveProduct = (updatedProduct: EnhancedProductCard) => {
|
||||||
|
setProducts(prev => prev.map(product =>
|
||||||
|
product.suggestion_id === updatedProduct.suggestion_id ? updatedProduct : product
|
||||||
|
));
|
||||||
|
setEditingProduct(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setEditingProduct(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update parent data
|
||||||
|
useEffect(() => {
|
||||||
|
const updatedData = {
|
||||||
|
...data,
|
||||||
|
files: { ...data.files, salesData: uploadedFile },
|
||||||
|
processingStage: stage === 'review' ? 'completed' : (stage === 'completed' ? 'completed' : stage),
|
||||||
|
processingResults: validationResults,
|
||||||
|
suggestions: suggestions,
|
||||||
|
detectedProducts: products,
|
||||||
|
approvedProducts: approvedProducts,
|
||||||
|
reviewCompleted: reviewCompleted,
|
||||||
|
inventoryConfigured: inventoryConfigured,
|
||||||
|
inventoryItems: createdItems,
|
||||||
|
inventoryMapping: inventoryMapping,
|
||||||
|
salesImportResult: salesImportResult
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
console.log('SmartInventorySetupStep - Updating parent data:', {
|
||||||
|
stage,
|
||||||
|
reviewCompleted,
|
||||||
|
inventoryConfigured,
|
||||||
|
hasSalesImportResult: !!salesImportResult,
|
||||||
|
salesImportResult,
|
||||||
|
approvedProductsCount: approvedProducts.length
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only update if data actually changed to prevent infinite loops
|
||||||
|
const dataString = JSON.stringify(updatedData);
|
||||||
|
const currentDataString = JSON.stringify(data);
|
||||||
|
if (dataString !== currentDataString) {
|
||||||
|
onDataChange(updatedData);
|
||||||
|
}
|
||||||
|
}, [stage, uploadedFile, validationResults, suggestions, products, approvedProducts, reviewCompleted, inventoryConfigured, createdItems, inventoryMapping, salesImportResult, data, onDataChange]);
|
||||||
|
|
||||||
|
const getConfidenceColor = (confidence: number) => {
|
||||||
|
if (confidence >= 0.9) return 'text-[var(--color-success)] bg-[var(--color-success)]/10 border-[var(--color-success)]/20';
|
||||||
|
if (confidence >= 0.75) return 'text-[var(--color-warning)] bg-[var(--color-warning)]/10 border-[var(--color-warning)]/20';
|
||||||
|
return 'text-[var(--color-error)] bg-[var(--color-error)]/10 border-[var(--color-error)]/20';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'approved': return 'text-[var(--color-success)] bg-[var(--color-success)]/10 border-[var(--color-success)]/20';
|
||||||
|
case 'rejected': return 'text-[var(--color-error)] bg-[var(--color-error)]/10 border-[var(--color-error)]/20';
|
||||||
|
default: return 'text-[var(--text-secondary)] bg-[var(--bg-secondary)] border-[var(--border-secondary)]';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Loading state when tenant data is not available */}
|
||||||
|
{!isTenantAvailable() && (
|
||||||
|
<Card className="p-8 text-center">
|
||||||
|
<div className="w-16 h-16 bg-[var(--color-info)]/10 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
|
<Activity className="w-8 h-8 text-[var(--color-info)] animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold text-[var(--text-primary)] mb-3">
|
||||||
|
Cargando datos de usuario...
|
||||||
|
</h3>
|
||||||
|
<p className="text-[var(--text-secondary)]">
|
||||||
|
Por favor espere mientras cargamos su información de tenant
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Upload Stage */}
|
||||||
|
{(stage === 'idle' || localStage === 'upload') && isTenantAvailable() && (
|
||||||
|
<>
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-3xl font-bold text-[var(--text-primary)] mb-4">
|
||||||
|
📦 Configuración Inteligente de Inventario
|
||||||
|
</h2>
|
||||||
|
<p className="text-[var(--text-secondary)] text-lg max-w-2xl mx-auto">
|
||||||
|
Sube tu historial de ventas y crearemos automáticamente tu inventario con configuraciones inteligentes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
border-2 border-dashed rounded-2xl p-16 text-center transition-all duration-300 cursor-pointer group
|
||||||
|
${dragActive
|
||||||
|
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/10 scale-[1.02] shadow-lg'
|
||||||
|
: uploadedFile
|
||||||
|
? 'border-[var(--color-success)] bg-[var(--color-success)]/10 shadow-md'
|
||||||
|
: 'border-[var(--border-secondary)] hover:border-[var(--color-primary)] hover:bg-[var(--bg-secondary)]/30 hover:shadow-lg'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".csv,.xlsx,.xls"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
{uploadedFile ? (
|
||||||
|
<>
|
||||||
|
<div className="w-20 h-20 bg-[var(--color-success)]/10 rounded-full flex items-center justify-center mx-auto">
|
||||||
|
<CheckCircle className="w-10 h-10 text-[var(--color-success)]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-3xl font-bold text-[var(--color-success)] mb-3">
|
||||||
|
¡Perfecto! Archivo listo
|
||||||
|
</h3>
|
||||||
|
<div className="bg-[var(--bg-secondary)] rounded-lg p-4 inline-block">
|
||||||
|
<p className="text-[var(--text-primary)] font-medium text-lg">
|
||||||
|
📄 {uploadedFile.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-[var(--text-secondary)] text-sm mt-1">
|
||||||
|
{(uploadedFile.size / 1024 / 1024).toFixed(2)} MB • Listo para procesar
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="w-20 h-20 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center mx-auto group-hover:scale-110 transition-transform duration-300">
|
||||||
|
<Upload className="w-10 h-10 text-[var(--color-primary)]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-3xl font-bold text-[var(--text-primary)] mb-4">
|
||||||
|
Sube tu historial de ventas
|
||||||
|
</h3>
|
||||||
|
<p className="text-[var(--text-secondary)] text-xl leading-relaxed max-w-md mx-auto">
|
||||||
|
Arrastra y suelta tu archivo aquí, o <span className="text-[var(--color-primary)] font-semibold">haz clic para seleccionar</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Template Download Section */}
|
||||||
|
<div className="bg-gradient-to-r from-[var(--color-info)]/5 to-[var(--color-primary)]/5 rounded-xl p-6 border border-[var(--color-info)]/20">
|
||||||
|
<div className="flex flex-col md:flex-row items-center space-y-4 md:space-y-0 md:space-x-6">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-[var(--color-info)]/10 flex items-center justify-center flex-shrink-0">
|
||||||
|
<Download className="w-8 h-8 text-[var(--color-info)]" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-center md:text-left">
|
||||||
|
<h4 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||||
|
¿Necesitas ayuda con el formato?
|
||||||
|
</h4>
|
||||||
|
<p className="text-[var(--text-secondary)] mb-4">
|
||||||
|
Descarga nuestra plantilla con ejemplos para tus datos de ventas
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={downloadTemplate}
|
||||||
|
className="bg-[var(--color-info)] hover:bg-[var(--color-info)]/90 text-white shadow-lg"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Descargar Plantilla
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Processing Stages */}
|
||||||
|
{(stage === 'validating' || stage === 'analyzing') && (
|
||||||
|
<Card className="p-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="relative mb-8">
|
||||||
|
<div className={`w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-6 ${
|
||||||
|
stage === 'validating'
|
||||||
|
? 'bg-[var(--color-info)]/10 animate-pulse'
|
||||||
|
: 'bg-[var(--color-primary)]/10 animate-pulse'
|
||||||
|
}`}>
|
||||||
|
{stage === 'validating' ? (
|
||||||
|
<FileText className="w-8 h-8 text-[var(--color-info)]" />
|
||||||
|
) : (
|
||||||
|
<Brain className="w-8 h-8 text-[var(--color-primary)]" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-2xl font-semibold text-[var(--text-primary)] mb-2">
|
||||||
|
{stage === 'validating' ? 'Validando datos...' : 'Analizando con IA...'}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[var(--text-secondary)] mb-8">
|
||||||
|
{currentMessage}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex justify-between items-center mb-3">
|
||||||
|
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||||
|
Progreso
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-[var(--text-secondary)]">{progress}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-[var(--bg-secondary)] rounded-full h-3">
|
||||||
|
<div
|
||||||
|
className="bg-gradient-to-r from-[var(--color-info)] to-[var(--color-primary)] h-3 rounded-full transition-all duration-500 ease-out"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Creating Stage */}
|
||||||
|
{isCreating && (
|
||||||
|
<Card className="p-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-20 h-20 bg-[var(--color-success)]/10 rounded-full flex items-center justify-center mx-auto mb-6 animate-pulse">
|
||||||
|
<Package className="w-8 h-8 text-[var(--color-success)]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-semibold text-[var(--text-primary)] mb-2">
|
||||||
|
Creando tu inventario...
|
||||||
|
</h3>
|
||||||
|
<p className="text-[var(--text-secondary)]">
|
||||||
|
Configurando productos e importando datos de ventas
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Review & Configure Stage */}
|
||||||
|
{(stage === 'review') && products.length > 0 && (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-16 h-16 bg-[var(--color-success)] rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
|
<CheckCircle className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-2xl font-semibold text-[var(--color-success)] mb-3">
|
||||||
|
¡Productos Detectados!
|
||||||
|
</h3>
|
||||||
|
<p className="text-[var(--text-secondary)] max-w-2xl mx-auto">
|
||||||
|
Revisa, aprueba y configura los niveles de inventario para tus productos
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<Card className="p-6 text-center bg-[var(--bg-primary)] border-[var(--border-primary)]">
|
||||||
|
<div className="text-3xl font-bold text-[var(--color-primary)] mb-2">{stats.total}</div>
|
||||||
|
<div className="text-sm font-medium text-[var(--text-secondary)]">Productos detectados</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-6 text-center bg-[var(--color-success)]/5 border-[var(--color-success)]/20">
|
||||||
|
<div className="text-3xl font-bold text-[var(--color-success)] mb-2">{stats.approved}</div>
|
||||||
|
<div className="text-sm font-medium text-[var(--color-success)]">Aprobados</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-6 text-center bg-[var(--color-error)]/5 border-[var(--color-error)]/20">
|
||||||
|
<div className="text-3xl font-bold text-[var(--color-error)] mb-2">{stats.rejected}</div>
|
||||||
|
<div className="text-sm font-medium text-[var(--color-error)]">Rechazados</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-6 text-center bg-[var(--color-warning)]/5 border-[var(--color-warning)]/20">
|
||||||
|
<div className="text-3xl font-bold text-[var(--color-warning)] mb-2">{stats.pending}</div>
|
||||||
|
<div className="text-sm font-medium text-[var(--color-warning)]">Pendientes</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-between items-start sm:items-center bg-[var(--bg-secondary)] p-4 rounded-lg border border-[var(--border-secondary)]">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<select
|
||||||
|
value={selectedCategory}
|
||||||
|
onChange={(e) => setSelectedCategory(e.target.value)}
|
||||||
|
className="px-3 py-2 border border-[var(--border-primary)] rounded-lg bg-[var(--bg-primary)] text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-[var(--color-primary)]"
|
||||||
|
>
|
||||||
|
{categories.map(category => (
|
||||||
|
<option key={category} value={category}>
|
||||||
|
{category === 'all' ? 'Todas las categorías' : category}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<Badge variant="outline" className="text-sm font-medium px-3 py-1 bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-secondary)]">
|
||||||
|
{getFilteredProducts().length} productos
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleBulkAction('approve')}
|
||||||
|
className="text-[var(--color-success)] border-[var(--color-success)]/30 hover:bg-[var(--color-success)]/10 bg-[var(--color-success)]/5"
|
||||||
|
>
|
||||||
|
Aprobar todos
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleBulkAction('reject')}
|
||||||
|
className="text-[var(--color-error)] border-[var(--color-error)]/30 hover:bg-[var(--color-error)]/10 bg-[var(--color-error)]/5"
|
||||||
|
>
|
||||||
|
Rechazar todos
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Products List */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{getFilteredProducts().map((product) => (
|
||||||
|
<Card key={product.suggestion_id} className="p-6 hover:shadow-lg transition-all duration-200 border border-[var(--border-primary)] bg-[var(--bg-primary)]">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 min-w-0 pr-4">
|
||||||
|
<div className="flex items-center flex-wrap gap-2 mb-3">
|
||||||
|
<h3 className="font-semibold text-lg text-[var(--text-primary)] mr-2">
|
||||||
|
{product.suggested_name}
|
||||||
|
</h3>
|
||||||
|
<Badge className={`text-xs font-medium px-2 py-1 rounded-full border ${getStatusColor(product.status)}`}>
|
||||||
|
{product.status === 'approved' ? '✓ Aprobado' :
|
||||||
|
product.status === 'rejected' ? '✗ Rechazado' : '⏳ Pendiente'}
|
||||||
|
</Badge>
|
||||||
|
<Badge className={`text-xs font-medium px-2 py-1 rounded-full border ${getConfidenceColor(product.confidence_score)}`}>
|
||||||
|
{Math.round(product.confidence_score * 100)}% confianza
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center flex-wrap gap-2 mb-3">
|
||||||
|
<Badge className="text-xs font-medium px-2 py-1 rounded-full bg-[var(--color-primary)]/10 text-[var(--color-primary)] border border-[var(--color-primary)]/20">
|
||||||
|
{product.category}
|
||||||
|
</Badge>
|
||||||
|
<Badge className="text-xs font-medium px-2 py-1 rounded-full bg-[var(--color-info)]/10 text-[var(--color-info)] border border-[var(--color-info)]/20">
|
||||||
|
{product.product_type === 'ingredient' ? 'Ingrediente' : 'Producto terminado'}
|
||||||
|
</Badge>
|
||||||
|
{product.requires_refrigeration && (
|
||||||
|
<Badge className="text-xs font-medium px-2 py-1 rounded-full bg-blue-50 text-blue-600 border border-blue-200">
|
||||||
|
❄️ Refrigeración
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm text-[var(--text-secondary)] space-y-2">
|
||||||
|
{product.original_name !== product.suggested_name && (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="font-medium text-[var(--text-tertiary)] min-w-[100px]">Original:</span>
|
||||||
|
<span className="text-[var(--text-primary)]">{product.original_name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="font-medium text-[var(--text-tertiary)] min-w-[80px]">Stock min:</span>
|
||||||
|
<span className="font-bold text-[var(--text-primary)]">{product.min_stock}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="font-medium text-[var(--text-tertiary)] min-w-[80px]">Stock max:</span>
|
||||||
|
<span className="font-bold text-[var(--text-primary)]">{product.max_stock}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="font-medium text-[var(--text-tertiary)] min-w-[60px]">Unidad:</span>
|
||||||
|
<span className="text-[var(--text-primary)]">{product.unit_of_measure}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{product.sales_data && (
|
||||||
|
<div className="bg-[var(--color-info)]/5 p-3 rounded border-l-4 border-[var(--color-info)]/30">
|
||||||
|
<div className="text-xs font-medium text-[var(--color-info)] mb-1">Datos de Ventas:</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<span>Total vendido: {product.sales_data.total_quantity}</span>
|
||||||
|
<span>Promedio diario: {product.sales_data.average_daily_sales.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{product.notes && (
|
||||||
|
<div className="text-xs italic bg-[var(--bg-secondary)] p-2 rounded border-l-4 border-[var(--color-primary)]/30">
|
||||||
|
<span className="font-medium text-[var(--text-tertiary)]">Nota:</span> {product.notes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 flex-shrink-0">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={product.status === 'approved' ? 'primary' : 'outline'}
|
||||||
|
onClick={() => handleProductAction(product.suggestion_id, 'approve')}
|
||||||
|
className={product.status === 'approved'
|
||||||
|
? 'bg-[var(--color-success)] hover:bg-[var(--color-success)]/90 text-white'
|
||||||
|
: 'text-[var(--color-success)] border-[var(--color-success)]/30 hover:bg-[var(--color-success)]/10'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CheckCircle className="w-4 h-4 mr-1" />
|
||||||
|
Aprobar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={product.status === 'rejected' ? 'danger' : 'outline'}
|
||||||
|
onClick={() => handleProductAction(product.suggestion_id, 'reject')}
|
||||||
|
className={product.status === 'rejected'
|
||||||
|
? 'bg-[var(--color-error)] hover:bg-[var(--color-error)]/90 text-white'
|
||||||
|
: 'text-[var(--color-error)] border-[var(--color-error)]/30 hover:bg-[var(--color-error)]/10'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AlertCircle className="w-4 h-4 mr-1" />
|
||||||
|
Rechazar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleEditProduct(product)}
|
||||||
|
className="text-[var(--color-primary)] border-[var(--color-primary)]/30 hover:bg-[var(--color-primary)]/10"
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4 mr-1" />
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ready to Proceed Status */}
|
||||||
|
{approvedProducts.length > 0 && reviewCompleted && (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Card className="p-6 bg-gradient-to-r from-[var(--color-success)]/10 to-[var(--color-primary)]/10 border-[var(--color-success)]/20">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="flex justify-center items-center mb-3">
|
||||||
|
<div className="w-12 h-12 bg-[var(--color-success)]/20 rounded-full flex items-center justify-center">
|
||||||
|
<Package className="w-6 h-6 text-[var(--color-success)]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||||
|
✅ Listo para crear inventario
|
||||||
|
</h3>
|
||||||
|
<p className="text-[var(--text-secondary)] mb-4">
|
||||||
|
{approvedProducts.length} productos aprobados y listos para crear el inventario.
|
||||||
|
</p>
|
||||||
|
<div className="text-sm text-[var(--color-info)]">
|
||||||
|
Haz clic en "Siguiente" para crear automáticamente el inventario e importar los datos de ventas.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Information */}
|
||||||
|
<Card className="p-4 bg-[var(--color-info)]/5 border-[var(--color-info)]/20">
|
||||||
|
<h4 className="font-medium text-[var(--color-info)] mb-2">
|
||||||
|
📦 Inventario Inteligente:
|
||||||
|
</h4>
|
||||||
|
<ul className="text-sm text-[var(--color-info)] space-y-1">
|
||||||
|
<li>• <strong>Configuración automática</strong> - Los niveles de stock se calculan basándose en tus datos de ventas</li>
|
||||||
|
<li>• <strong>Revisa y aprueba</strong> - Confirma que cada producto sea correcto para tu negocio</li>
|
||||||
|
<li>• <strong>Edita configuración</strong> - Ajusta niveles de stock, proveedores y otros detalles</li>
|
||||||
|
<li>• <strong>Creación integral</strong> - Se creará el inventario e importarán los datos de ventas automáticamente</li>
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Completed Stage */}
|
||||||
|
{stage === 'completed' && (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<div className="w-20 h-20 bg-[var(--color-success)] rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
|
<CheckCircle className="w-10 h-10 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-3xl font-bold text-[var(--color-success)] mb-4">
|
||||||
|
¡Inventario Creado!
|
||||||
|
</h3>
|
||||||
|
<p className="text-[var(--text-secondary)] text-lg max-w-md mx-auto mb-6">
|
||||||
|
Tu inventario inteligente ha sido configurado exitosamente con {approvedProducts.length} productos.
|
||||||
|
</p>
|
||||||
|
{salesImportResult?.success && (
|
||||||
|
<p className="text-[var(--color-info)] font-medium">
|
||||||
|
Se importaron {salesImportResult.records_created} registros de ventas para entrenar tu IA.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error State */}
|
||||||
|
{stage === 'error' && (
|
||||||
|
<Card className="p-8 text-center">
|
||||||
|
<div className="w-16 h-16 bg-[var(--color-error)] rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
|
<AlertCircle className="w-8 h-8 text-white" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold text-[var(--color-error)] mb-3">
|
||||||
|
Error en el procesamiento
|
||||||
|
</h3>
|
||||||
|
<p className="text-[var(--text-secondary)] mb-6">
|
||||||
|
{currentMessage || error}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setLocalStage('upload');
|
||||||
|
setUploadedFile(null);
|
||||||
|
setProducts([]);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
clearError();
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Intentar nuevamente
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Product Editor Modal */}
|
||||||
|
{editingProduct && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<Card className="w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
|
||||||
|
<div className="p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">
|
||||||
|
Editar Producto: {editingProduct.suggested_name}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2 text-[var(--text-primary)]">Stock actual</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={editingProduct.current_stock}
|
||||||
|
onChange={(e) => setEditingProduct({ ...editingProduct, current_stock: Number(e.target.value) })}
|
||||||
|
min="0"
|
||||||
|
className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2 text-[var(--text-primary)]">Stock mínimo</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={editingProduct.min_stock}
|
||||||
|
onChange={(e) => setEditingProduct({ ...editingProduct, min_stock: Number(e.target.value) })}
|
||||||
|
min="0"
|
||||||
|
className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2 text-[var(--text-primary)]">Stock máximo</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={editingProduct.max_stock}
|
||||||
|
onChange={(e) => setEditingProduct({ ...editingProduct, max_stock: Number(e.target.value) })}
|
||||||
|
min="1"
|
||||||
|
className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2 text-[var(--text-primary)]">Costo por unidad</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={editingProduct.cost_per_unit || 0}
|
||||||
|
onChange={(e) => setEditingProduct({ ...editingProduct, cost_per_unit: Number(e.target.value) })}
|
||||||
|
min="0"
|
||||||
|
step="0.01"
|
||||||
|
className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-2 text-[var(--text-primary)]">Fecha de vencimiento personalizada</label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={editingProduct.custom_expiry_date || ''}
|
||||||
|
onChange={(e) => setEditingProduct({ ...editingProduct, custom_expiry_date: e.target.value })}
|
||||||
|
className="bg-[var(--bg-primary)] border-[var(--border-primary)] text-[var(--text-primary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
className="text-[var(--text-secondary)] border-[var(--border-primary)] hover:bg-[var(--bg-tertiary)]"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleSaveProduct(editingProduct)}
|
||||||
|
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary)]/90 text-white"
|
||||||
|
>
|
||||||
|
Guardar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
225
frontend/src/hooks/business/onboarding/README.md
Normal file
225
frontend/src/hooks/business/onboarding/README.md
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
# Onboarding Hooks - Complete Clean Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This onboarding system has been **completely refactored** with no legacy code or backwards compatibility. It follows modern clean architecture principles with:
|
||||||
|
|
||||||
|
- **Standardized patterns** across all services
|
||||||
|
- **Zero circular dependencies**
|
||||||
|
- **Centralized state management** with Zustand
|
||||||
|
- **Type-safe interfaces** throughout
|
||||||
|
- **Service isolation** with standardized APIs
|
||||||
|
- **Factory pattern** for service hooks
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### 🏗️ Core Layer (`core/`)
|
||||||
|
|
||||||
|
**`store.ts`** - Centralized Zustand store
|
||||||
|
- Single source of truth for all onboarding state
|
||||||
|
- Atomic operations with devtools integration
|
||||||
|
- Computed getters for derived state
|
||||||
|
|
||||||
|
**`actions.ts`** - Business logic orchestration
|
||||||
|
- Coordinates between services
|
||||||
|
- Handles complex step transitions
|
||||||
|
- Manages validation and error states
|
||||||
|
|
||||||
|
**`types.ts`** - Complete type definitions
|
||||||
|
- Unified interfaces for all services
|
||||||
|
- Standardized error and state patterns
|
||||||
|
- Re-exports for external consumption
|
||||||
|
|
||||||
|
### 🔧 Services Layer (`services/`)
|
||||||
|
|
||||||
|
All services follow the same standardized pattern using `createServiceHook`:
|
||||||
|
|
||||||
|
**`useTenantCreation.ts`** - Bakery registration and tenant setup
|
||||||
|
**`useSalesProcessing.ts`** - File validation and AI classification
|
||||||
|
**`useInventorySetup.ts`** - Inventory creation and sales import
|
||||||
|
**`useTrainingOrchestration.ts`** - ML model training workflow
|
||||||
|
**`useProgressTracking.ts`** - Backend progress synchronization
|
||||||
|
**`useResumeLogic.ts`** - Flow resumption management
|
||||||
|
|
||||||
|
### 🛠️ Utils Layer (`utils/`)
|
||||||
|
|
||||||
|
**`createServiceHook.ts`** - Factory for standardized service hooks
|
||||||
|
- Eliminates duplicate patterns
|
||||||
|
- Provides consistent async execution
|
||||||
|
- Standardized error handling
|
||||||
|
|
||||||
|
## New File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/hooks/business/onboarding/
|
||||||
|
├── core/
|
||||||
|
│ ├── store.ts # Zustand centralized store
|
||||||
|
│ ├── actions.ts # Business logic orchestration
|
||||||
|
│ └── types.ts # Complete type definitions
|
||||||
|
├── services/
|
||||||
|
│ ├── useTenantCreation.ts # Tenant service
|
||||||
|
│ ├── useSalesProcessing.ts # Sales processing service
|
||||||
|
│ ├── useInventorySetup.ts # Inventory service
|
||||||
|
│ ├── useTrainingOrchestration.ts # Training service
|
||||||
|
│ ├── useProgressTracking.ts # Progress service
|
||||||
|
│ └── useResumeLogic.ts # Resume service
|
||||||
|
├── utils/
|
||||||
|
│ └── createServiceHook.ts # Service factory
|
||||||
|
├── useOnboarding.ts # Main unified hook
|
||||||
|
├── useAutoResume.ts # Auto-resume wrapper
|
||||||
|
├── steps.ts # Step definitions
|
||||||
|
├── types.ts # Legacy type exports
|
||||||
|
└── index.ts # Clean exports
|
||||||
|
```
|
||||||
|
|
||||||
|
## Component Usage
|
||||||
|
|
||||||
|
### Primary Interface - useOnboarding
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useOnboarding } from '../hooks/business/onboarding';
|
||||||
|
|
||||||
|
const OnboardingComponent = () => {
|
||||||
|
const {
|
||||||
|
// Core state
|
||||||
|
currentStep,
|
||||||
|
steps,
|
||||||
|
data,
|
||||||
|
progress,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
|
||||||
|
// Service states (when needed)
|
||||||
|
tenantCreation: { isLoading: tenantLoading, isSuccess },
|
||||||
|
salesProcessing: { stage, progress: fileProgress, suggestions },
|
||||||
|
inventorySetup: { createdItems, inventoryMapping },
|
||||||
|
trainingOrchestration: { status, logs, metrics },
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
nextStep,
|
||||||
|
previousStep,
|
||||||
|
updateStepData,
|
||||||
|
createTenant,
|
||||||
|
processSalesFile,
|
||||||
|
startTraining,
|
||||||
|
completeOnboarding,
|
||||||
|
clearError,
|
||||||
|
} = useOnboarding();
|
||||||
|
|
||||||
|
// Clean, consistent API across all functionality
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auto-Resume Functionality
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useAutoResume } from '../hooks/business/onboarding';
|
||||||
|
|
||||||
|
const OnboardingPage = () => {
|
||||||
|
const { isCheckingResume, completionPercentage } = useAutoResume();
|
||||||
|
|
||||||
|
if (isCheckingResume) {
|
||||||
|
return <LoadingSpinner message="Checking saved progress..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue with onboarding flow
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Improvements
|
||||||
|
|
||||||
|
### ✅ **Eliminated All Legacy Issues**
|
||||||
|
|
||||||
|
1. **No Circular Dependencies** - Clear dependency hierarchy
|
||||||
|
2. **No Massive God Hooks** - Focused, single-responsibility services
|
||||||
|
3. **No Inconsistent Patterns** - Standardized service factory
|
||||||
|
4. **No Type Confusion** - Clean, unified type system
|
||||||
|
5. **No Duplicate Code** - DRY principles throughout
|
||||||
|
|
||||||
|
### ✅ **Modern Patterns**
|
||||||
|
|
||||||
|
1. **Zustand Store** - Performant, devtools-enabled state
|
||||||
|
2. **Factory Pattern** - Consistent service creation
|
||||||
|
3. **Service Composition** - Clean separation of concerns
|
||||||
|
4. **Type-Safe** - Full TypeScript coverage
|
||||||
|
5. **Async-First** - Proper error handling and loading states
|
||||||
|
|
||||||
|
### ✅ **Developer Experience**
|
||||||
|
|
||||||
|
1. **Predictable API** - Same patterns across all services
|
||||||
|
2. **Easy Testing** - Isolated, mockable services
|
||||||
|
3. **Clear Documentation** - Self-documenting code structure
|
||||||
|
4. **Performance** - Optimized renders and state updates
|
||||||
|
5. **Debugging** - Zustand devtools integration
|
||||||
|
|
||||||
|
## Benefits for Components
|
||||||
|
|
||||||
|
### Before (Legacy)
|
||||||
|
```typescript
|
||||||
|
// Inconsistent APIs, complex imports, circular deps
|
||||||
|
import { useOnboarding } from './useOnboarding';
|
||||||
|
import { useAutoResume } from './useAutoResume'; // Circular dependency!
|
||||||
|
|
||||||
|
const {
|
||||||
|
// 50+ properties mixed together
|
||||||
|
currentStep, data, isLoading, tenantCreation: { isLoading: tenantLoading },
|
||||||
|
salesProcessing: { stage, progress }, // Nested complexity
|
||||||
|
} = useOnboarding();
|
||||||
|
```
|
||||||
|
|
||||||
|
### After (Clean)
|
||||||
|
```typescript
|
||||||
|
// Clean, consistent, predictable
|
||||||
|
import { useOnboarding, useAutoResume } from '../hooks/business/onboarding';
|
||||||
|
|
||||||
|
const onboarding = useOnboarding();
|
||||||
|
const autoResume = useAutoResume();
|
||||||
|
|
||||||
|
// Clear separation, no circular deps, standardized patterns
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Path
|
||||||
|
|
||||||
|
### ❌ Removed (No Backwards Compatibility)
|
||||||
|
- Old `useOnboarding` (400+ lines)
|
||||||
|
- Old `useOnboardingData`
|
||||||
|
- Old `useOnboardingFlow`
|
||||||
|
- Old `useAutoResume`
|
||||||
|
- All inconsistent service hooks
|
||||||
|
|
||||||
|
### ✅ New Clean Interfaces
|
||||||
|
- Unified `useOnboarding` hook
|
||||||
|
- Standardized service hooks
|
||||||
|
- Centralized store management
|
||||||
|
- Factory-created services
|
||||||
|
|
||||||
|
## Advanced Usage
|
||||||
|
|
||||||
|
### Direct Service Access
|
||||||
|
```typescript
|
||||||
|
import { useSalesProcessing, useInventorySetup } from '../hooks/business/onboarding';
|
||||||
|
|
||||||
|
// When you need direct service control
|
||||||
|
const salesService = useSalesProcessing();
|
||||||
|
const inventoryService = useInventorySetup();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Service Creation
|
||||||
|
```typescript
|
||||||
|
import { createServiceHook } from '../hooks/business/onboarding';
|
||||||
|
|
||||||
|
// Create new services following the same pattern
|
||||||
|
const useMyCustomService = createServiceHook({
|
||||||
|
initialState: { customData: null },
|
||||||
|
// ... configuration
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **Zustand** - Minimal re-renders, optimized updates
|
||||||
|
- **Memoized Selectors** - Computed values cached
|
||||||
|
- **Service Isolation** - Independent loading states
|
||||||
|
- **Factory Pattern** - Reduced bundle size
|
||||||
|
|
||||||
|
This is a **complete rewrite** with zero legacy baggage. Modern, maintainable, and built for the future.
|
||||||
283
frontend/src/hooks/business/onboarding/core/actions.ts
Normal file
283
frontend/src/hooks/business/onboarding/core/actions.ts
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
/**
|
||||||
|
* Core onboarding actions - Business logic orchestration
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useOnboardingStore } from './store';
|
||||||
|
import { useTenantCreation } from '../services/useTenantCreation';
|
||||||
|
import { useSalesProcessing } from '../services/useSalesProcessing';
|
||||||
|
import { useInventorySetup } from '../services/useInventorySetup';
|
||||||
|
import { useTrainingOrchestration } from '../services/useTrainingOrchestration';
|
||||||
|
import { useProgressTracking } from '../services/useProgressTracking';
|
||||||
|
import { getStepById } from '../steps';
|
||||||
|
import type { ProductSuggestionResponse } from '../core/types';
|
||||||
|
import type { BakeryRegistration } from '../../../../api';
|
||||||
|
|
||||||
|
export const useOnboardingActions = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const store = useOnboardingStore();
|
||||||
|
|
||||||
|
// Service hooks
|
||||||
|
const tenantCreation = useTenantCreation();
|
||||||
|
const salesProcessing = useSalesProcessing();
|
||||||
|
const inventorySetup = useInventorySetup();
|
||||||
|
const trainingOrchestration = useTrainingOrchestration();
|
||||||
|
const progressTracking = useProgressTracking();
|
||||||
|
|
||||||
|
const validateCurrentStep = useCallback((): string | null => {
|
||||||
|
const currentStep = store.getCurrentStep();
|
||||||
|
if (!currentStep) return null;
|
||||||
|
|
||||||
|
const stepConfig = getStepById(currentStep.id);
|
||||||
|
if (stepConfig?.validation) {
|
||||||
|
return stepConfig.validation(store.data);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [store]);
|
||||||
|
|
||||||
|
const nextStep = useCallback(async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const currentStep = store.getCurrentStep();
|
||||||
|
if (!currentStep) return false;
|
||||||
|
|
||||||
|
// Validate current step
|
||||||
|
const validation = validateCurrentStep();
|
||||||
|
if (validation) {
|
||||||
|
store.setError(validation);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
store.setError(null);
|
||||||
|
|
||||||
|
// Handle step-specific actions before moving to next step
|
||||||
|
if (currentStep.id === 'setup') {
|
||||||
|
const bakeryData = store.getStepData('setup').bakery;
|
||||||
|
if (bakeryData && !tenantCreation.isSuccess) {
|
||||||
|
store.setLoading(true);
|
||||||
|
const success = await tenantCreation.createTenant(bakeryData);
|
||||||
|
store.setLoading(false);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
store.setError(tenantCreation.error || 'Error creating tenant');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStep.id === 'smart-inventory-setup') {
|
||||||
|
const inventoryData = store.getStepData('smart-inventory-setup');
|
||||||
|
|
||||||
|
if (inventoryData?.approvedProducts?.length > 0 && !inventoryData?.inventoryConfigured) {
|
||||||
|
store.setLoading(true);
|
||||||
|
|
||||||
|
// Create inventory from approved products
|
||||||
|
const inventoryResult = await inventorySetup.createInventoryFromSuggestions(inventoryData.approvedProducts);
|
||||||
|
|
||||||
|
if (!inventoryResult.success) {
|
||||||
|
store.setLoading(false);
|
||||||
|
store.setError(inventorySetup.error || 'Error creating inventory');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import sales data after inventory creation
|
||||||
|
if (inventoryData?.processingResults && inventoryResult?.inventoryMapping) {
|
||||||
|
const salesImportResult = await inventorySetup.importSalesData(
|
||||||
|
inventoryData.processingResults,
|
||||||
|
inventoryResult.inventoryMapping
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!salesImportResult.success) {
|
||||||
|
store.setLoading(false);
|
||||||
|
store.setError(inventorySetup.error || 'Error importing sales data');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
store.setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save progress to backend
|
||||||
|
const stepData = store.getStepData(currentStep.id);
|
||||||
|
await progressTracking.markStepCompleted(currentStep.id, stepData);
|
||||||
|
|
||||||
|
// Move to next step
|
||||||
|
if (store.nextStep()) {
|
||||||
|
store.markStepCompleted(store.currentStep - 1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in nextStep:', error);
|
||||||
|
store.setError(error instanceof Error ? error.message : 'Error moving to next step');
|
||||||
|
store.setLoading(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [store, validateCurrentStep, tenantCreation, inventorySetup, progressTracking]);
|
||||||
|
|
||||||
|
const previousStep = useCallback((): boolean => {
|
||||||
|
return store.previousStep();
|
||||||
|
}, [store]);
|
||||||
|
|
||||||
|
const goToStep = useCallback((stepIndex: number): boolean => {
|
||||||
|
return store.goToStep(stepIndex);
|
||||||
|
}, [store]);
|
||||||
|
|
||||||
|
const createTenant = useCallback(async (bakeryData: BakeryRegistration): Promise<boolean> => {
|
||||||
|
store.setLoading(true);
|
||||||
|
const success = await tenantCreation.createTenant(bakeryData);
|
||||||
|
store.setLoading(false);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
store.setError(tenantCreation.error || 'Error creating tenant');
|
||||||
|
}
|
||||||
|
|
||||||
|
return success;
|
||||||
|
}, [store, tenantCreation]);
|
||||||
|
|
||||||
|
const processSalesFile = useCallback(async (file: File): Promise<boolean> => {
|
||||||
|
store.setLoading(true);
|
||||||
|
const result = await salesProcessing.processFile(file);
|
||||||
|
store.setLoading(false);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
store.setError(salesProcessing.error || 'Error processing sales file');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.success;
|
||||||
|
}, [store, salesProcessing]);
|
||||||
|
|
||||||
|
const createInventoryFromSuggestions = useCallback(async (
|
||||||
|
suggestions: ProductSuggestionResponse[]
|
||||||
|
): Promise<boolean> => {
|
||||||
|
store.setLoading(true);
|
||||||
|
const result = await inventorySetup.createInventoryFromSuggestions(suggestions);
|
||||||
|
store.setLoading(false);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
store.setError(inventorySetup.error || 'Error creating inventory');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.success;
|
||||||
|
}, [store, inventorySetup]);
|
||||||
|
|
||||||
|
const importSalesData = useCallback(async (
|
||||||
|
salesData: any,
|
||||||
|
inventoryMapping: { [productName: string]: string }
|
||||||
|
): Promise<boolean> => {
|
||||||
|
store.setLoading(true);
|
||||||
|
const result = await inventorySetup.importSalesData(salesData, inventoryMapping);
|
||||||
|
store.setLoading(false);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
store.setError(inventorySetup.error || 'Error importing sales data');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.success;
|
||||||
|
}, [store, inventorySetup]);
|
||||||
|
|
||||||
|
const startTraining = useCallback(async (options?: {
|
||||||
|
products?: string[];
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
}): Promise<boolean> => {
|
||||||
|
// Validate training prerequisites
|
||||||
|
const validation = await trainingOrchestration.validateTrainingData(store.getAllStepData());
|
||||||
|
if (!validation.isValid) {
|
||||||
|
store.setError(`Training prerequisites not met: ${validation.missingItems.join(', ')}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
store.setLoading(true);
|
||||||
|
const success = await trainingOrchestration.startTraining(options);
|
||||||
|
store.setLoading(false);
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
store.setError(trainingOrchestration.error || 'Error starting training');
|
||||||
|
}
|
||||||
|
|
||||||
|
return success;
|
||||||
|
}, [store, trainingOrchestration]);
|
||||||
|
|
||||||
|
const completeOnboarding = useCallback(async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
store.setLoading(true);
|
||||||
|
|
||||||
|
// Mark final completion
|
||||||
|
const completionStats = {
|
||||||
|
totalProducts: store.data.processingResults?.unique_products || 0,
|
||||||
|
inventoryItems: store.data.inventoryItems?.length || 0,
|
||||||
|
suppliersConfigured: store.data.suppliers?.length || 0,
|
||||||
|
mlModelAccuracy: store.data.trainingMetrics?.accuracy || 0,
|
||||||
|
estimatedTimeSaved: '2-3 horas por día',
|
||||||
|
completionScore: 95,
|
||||||
|
};
|
||||||
|
|
||||||
|
store.setStepData('completion', { completionStats });
|
||||||
|
|
||||||
|
// Complete in backend
|
||||||
|
const success = await progressTracking.completeOnboarding();
|
||||||
|
|
||||||
|
store.setLoading(false);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
console.log('✅ Onboarding fully completed!');
|
||||||
|
store.markStepCompleted(store.steps.length - 1);
|
||||||
|
|
||||||
|
// Navigate to dashboard after completion
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate('/app/dashboard');
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
store.setError('Error completing onboarding');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error completing onboarding:', error);
|
||||||
|
store.setError(error instanceof Error ? error.message : 'Error completing onboarding');
|
||||||
|
store.setLoading(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [store, progressTracking, navigate]);
|
||||||
|
|
||||||
|
const clearError = useCallback(() => {
|
||||||
|
store.setError(null);
|
||||||
|
tenantCreation.clearError();
|
||||||
|
salesProcessing.clearError();
|
||||||
|
inventorySetup.clearError();
|
||||||
|
trainingOrchestration.clearError();
|
||||||
|
progressTracking.clearError();
|
||||||
|
}, [store, tenantCreation, salesProcessing, inventorySetup, trainingOrchestration, progressTracking]);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
store.reset();
|
||||||
|
tenantCreation.reset();
|
||||||
|
salesProcessing.reset();
|
||||||
|
inventorySetup.reset();
|
||||||
|
trainingOrchestration.reset();
|
||||||
|
}, [store, tenantCreation, salesProcessing, inventorySetup, trainingOrchestration]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Navigation actions
|
||||||
|
nextStep,
|
||||||
|
previousStep,
|
||||||
|
goToStep,
|
||||||
|
validateCurrentStep,
|
||||||
|
|
||||||
|
// Step-specific actions
|
||||||
|
createTenant,
|
||||||
|
processSalesFile,
|
||||||
|
createInventoryFromSuggestions,
|
||||||
|
importSalesData,
|
||||||
|
startTraining,
|
||||||
|
completeOnboarding,
|
||||||
|
|
||||||
|
// Utility actions
|
||||||
|
clearError,
|
||||||
|
reset,
|
||||||
|
};
|
||||||
|
};
|
||||||
195
frontend/src/hooks/business/onboarding/core/store.ts
Normal file
195
frontend/src/hooks/business/onboarding/core/store.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* Onboarding store - Centralized state management with Zustand
|
||||||
|
* Handles all onboarding data, steps, and global state
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { create } from 'zustand';
|
||||||
|
import { devtools } from 'zustand/middleware';
|
||||||
|
import type { OnboardingData, OnboardingStep, OnboardingProgress } from './types';
|
||||||
|
import { DEFAULT_STEPS } from '../steps';
|
||||||
|
|
||||||
|
interface OnboardingStore {
|
||||||
|
// Flow state
|
||||||
|
currentStep: number;
|
||||||
|
steps: OnboardingStep[];
|
||||||
|
|
||||||
|
// Data state
|
||||||
|
data: OnboardingData;
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Progress state
|
||||||
|
progress: OnboardingProgress | null;
|
||||||
|
|
||||||
|
// Actions - Flow management
|
||||||
|
setCurrentStep: (step: number) => void;
|
||||||
|
nextStep: () => boolean;
|
||||||
|
previousStep: () => boolean;
|
||||||
|
goToStep: (stepIndex: number) => boolean;
|
||||||
|
markStepCompleted: (stepIndex: number) => void;
|
||||||
|
|
||||||
|
// Actions - Data management
|
||||||
|
setStepData: (stepId: string, stepData: Partial<OnboardingData>) => void;
|
||||||
|
clearStepData: (stepId: string) => void;
|
||||||
|
|
||||||
|
// Actions - UI state
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
setError: (error: string | null) => void;
|
||||||
|
|
||||||
|
// Actions - Progress
|
||||||
|
setProgress: (progress: OnboardingProgress) => void;
|
||||||
|
|
||||||
|
// Actions - Utilities
|
||||||
|
reset: () => void;
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
getCurrentStep: () => OnboardingStep;
|
||||||
|
getStepData: (stepId: string) => any;
|
||||||
|
getAllStepData: () => { [stepId: string]: any };
|
||||||
|
getProgress: () => OnboardingProgress;
|
||||||
|
canNavigateToStep: (stepIndex: number) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
currentStep: 0,
|
||||||
|
steps: DEFAULT_STEPS.map(step => ({ ...step, isCompleted: false })),
|
||||||
|
data: { allStepData: {} },
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
progress: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useOnboardingStore = create<OnboardingStore>()(
|
||||||
|
devtools(
|
||||||
|
(set, get) => ({
|
||||||
|
...initialState,
|
||||||
|
|
||||||
|
// Flow management actions
|
||||||
|
setCurrentStep: (step: number) => {
|
||||||
|
set({ currentStep: step }, false, 'setCurrentStep');
|
||||||
|
},
|
||||||
|
|
||||||
|
nextStep: () => {
|
||||||
|
const { currentStep, steps } = get();
|
||||||
|
if (currentStep < steps.length - 1) {
|
||||||
|
set({ currentStep: currentStep + 1 }, false, 'nextStep');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
previousStep: () => {
|
||||||
|
const { currentStep } = get();
|
||||||
|
if (currentStep > 0) {
|
||||||
|
set({ currentStep: currentStep - 1 }, false, 'previousStep');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
goToStep: (stepIndex: number) => {
|
||||||
|
const { steps, canNavigateToStep } = get();
|
||||||
|
if (stepIndex >= 0 && stepIndex < steps.length && canNavigateToStep(stepIndex)) {
|
||||||
|
set({ currentStep: stepIndex }, false, 'goToStep');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
|
||||||
|
markStepCompleted: (stepIndex: number) => {
|
||||||
|
set((state) => ({
|
||||||
|
steps: state.steps.map((step, index) =>
|
||||||
|
index === stepIndex ? { ...step, isCompleted: true } : step
|
||||||
|
),
|
||||||
|
}), false, 'markStepCompleted');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Data management actions
|
||||||
|
setStepData: (stepId: string, stepData: Partial<OnboardingData>) => {
|
||||||
|
set((state) => ({
|
||||||
|
data: {
|
||||||
|
...state.data,
|
||||||
|
...stepData,
|
||||||
|
allStepData: {
|
||||||
|
...state.data.allStepData,
|
||||||
|
[stepId]: {
|
||||||
|
...state.data.allStepData?.[stepId],
|
||||||
|
...stepData,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}), false, 'setStepData');
|
||||||
|
},
|
||||||
|
|
||||||
|
clearStepData: (stepId: string) => {
|
||||||
|
set((state) => ({
|
||||||
|
data: {
|
||||||
|
...state.data,
|
||||||
|
allStepData: {
|
||||||
|
...state.data.allStepData,
|
||||||
|
[stepId]: undefined,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}), false, 'clearStepData');
|
||||||
|
},
|
||||||
|
|
||||||
|
// UI state actions
|
||||||
|
setLoading: (loading: boolean) => {
|
||||||
|
set({ isLoading: loading }, false, 'setLoading');
|
||||||
|
},
|
||||||
|
|
||||||
|
setError: (error: string | null) => {
|
||||||
|
set({ error, isLoading: false }, false, 'setError');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Progress actions
|
||||||
|
setProgress: (progress: OnboardingProgress) => {
|
||||||
|
set({ progress }, false, 'setProgress');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Utility actions
|
||||||
|
reset: () => {
|
||||||
|
set(initialState, false, 'reset');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
getCurrentStep: () => {
|
||||||
|
const { steps, currentStep } = get();
|
||||||
|
return steps[currentStep];
|
||||||
|
},
|
||||||
|
|
||||||
|
getStepData: (stepId: string) => {
|
||||||
|
const { data } = get();
|
||||||
|
return data.allStepData?.[stepId] || {};
|
||||||
|
},
|
||||||
|
|
||||||
|
getAllStepData: () => {
|
||||||
|
const { data } = get();
|
||||||
|
return data.allStepData || {};
|
||||||
|
},
|
||||||
|
|
||||||
|
getProgress: () => {
|
||||||
|
const { steps, currentStep } = get();
|
||||||
|
const completedSteps = steps.filter(step => step.isCompleted).length;
|
||||||
|
const requiredSteps = steps.filter(step => step.isRequired).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentStep,
|
||||||
|
totalSteps: steps.length,
|
||||||
|
completedSteps,
|
||||||
|
isComplete: completedSteps === requiredSteps,
|
||||||
|
progressPercentage: Math.round((completedSteps / requiredSteps) * 100),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
canNavigateToStep: (stepIndex: number) => {
|
||||||
|
const { steps } = get();
|
||||||
|
// Allow navigation to any step for now - can add more complex logic later
|
||||||
|
return stepIndex >= 0 && stepIndex < steps.length;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ name: 'onboarding-store' }
|
||||||
|
)
|
||||||
|
);
|
||||||
205
frontend/src/hooks/business/onboarding/core/types.ts
Normal file
205
frontend/src/hooks/business/onboarding/core/types.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
/**
|
||||||
|
* Core types for the entire onboarding system
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
BakeryRegistration,
|
||||||
|
TrainingJobResponse,
|
||||||
|
UserProgress,
|
||||||
|
} from '../../../../api';
|
||||||
|
|
||||||
|
// Re-export TrainingMetrics locally
|
||||||
|
export interface TrainingMetrics {
|
||||||
|
accuracy: number;
|
||||||
|
mape: number;
|
||||||
|
mae: number;
|
||||||
|
rmse: number;
|
||||||
|
r2_score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export ProductSuggestionResponse type locally since it's used across many files
|
||||||
|
export interface ProductSuggestionResponse {
|
||||||
|
suggestion_id: string;
|
||||||
|
original_name: string;
|
||||||
|
suggested_name: string;
|
||||||
|
product_type: string;
|
||||||
|
category: string;
|
||||||
|
unit_of_measure: string;
|
||||||
|
confidence_score: number;
|
||||||
|
estimated_shelf_life_days?: number;
|
||||||
|
requires_refrigeration: boolean;
|
||||||
|
requires_freezing: boolean;
|
||||||
|
is_seasonal: boolean;
|
||||||
|
suggested_supplier?: string;
|
||||||
|
notes?: string;
|
||||||
|
sales_data?: {
|
||||||
|
total_quantity: number;
|
||||||
|
average_daily_sales: number;
|
||||||
|
peak_day: string;
|
||||||
|
frequency: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base service state pattern
|
||||||
|
export interface ServiceState<T = any> {
|
||||||
|
data: T | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
isSuccess: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base service actions pattern
|
||||||
|
export interface ServiceActions {
|
||||||
|
clearError: () => void;
|
||||||
|
reset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Onboarding step definitions
|
||||||
|
export interface OnboardingStep {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
isRequired: boolean;
|
||||||
|
isCompleted: boolean;
|
||||||
|
validation?: (data: OnboardingData) => string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete onboarding data structure
|
||||||
|
export interface OnboardingData {
|
||||||
|
// Step 1: Setup
|
||||||
|
bakery?: BakeryRegistration;
|
||||||
|
|
||||||
|
// Step 2: Smart Inventory Setup
|
||||||
|
files?: {
|
||||||
|
salesData?: File;
|
||||||
|
};
|
||||||
|
processingStage?: 'upload' | 'validating' | 'analyzing' | 'review' | 'completed' | 'error';
|
||||||
|
processingResults?: {
|
||||||
|
is_valid: boolean;
|
||||||
|
total_records: number;
|
||||||
|
unique_products: number;
|
||||||
|
product_list: string[];
|
||||||
|
validation_errors: string[];
|
||||||
|
validation_warnings: string[];
|
||||||
|
summary: {
|
||||||
|
date_range: string;
|
||||||
|
total_sales: number;
|
||||||
|
average_daily_sales: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
suggestions?: ProductSuggestionResponse[];
|
||||||
|
detectedProducts?: any[];
|
||||||
|
approvedSuggestions?: ProductSuggestionResponse[];
|
||||||
|
approvedProducts?: ProductSuggestionResponse[];
|
||||||
|
reviewCompleted?: boolean;
|
||||||
|
inventoryItems?: any[];
|
||||||
|
inventoryMapping?: { [productName: string]: string };
|
||||||
|
inventoryConfigured?: boolean;
|
||||||
|
salesImportResult?: {
|
||||||
|
success: boolean;
|
||||||
|
imported: boolean;
|
||||||
|
records_created: number;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 3: Suppliers
|
||||||
|
suppliers?: any[];
|
||||||
|
supplierMappings?: any[];
|
||||||
|
|
||||||
|
// Step 4: ML Training
|
||||||
|
trainingStatus?: 'idle' | 'validating' | 'training' | 'completed' | 'failed';
|
||||||
|
trainingProgress?: number;
|
||||||
|
trainingJob?: TrainingJobResponse;
|
||||||
|
trainingLogs?: TrainingLog[];
|
||||||
|
trainingMetrics?: TrainingMetrics;
|
||||||
|
autoStartTraining?: boolean;
|
||||||
|
|
||||||
|
// Step 5: Completion
|
||||||
|
completionStats?: {
|
||||||
|
totalProducts: number;
|
||||||
|
inventoryItems: number;
|
||||||
|
suppliersConfigured: number;
|
||||||
|
mlModelAccuracy: number;
|
||||||
|
estimatedTimeSaved: string;
|
||||||
|
completionScore: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cross-step data sharing
|
||||||
|
allStepData?: { [stepId: string]: any };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnboardingProgress {
|
||||||
|
currentStep: number;
|
||||||
|
totalSteps: number;
|
||||||
|
completedSteps: number;
|
||||||
|
isComplete: boolean;
|
||||||
|
progressPercentage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OnboardingError {
|
||||||
|
step?: string;
|
||||||
|
message: string;
|
||||||
|
details?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Training types
|
||||||
|
export interface TrainingLog {
|
||||||
|
timestamp: string;
|
||||||
|
message: string;
|
||||||
|
level: 'info' | 'warning' | 'error' | 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress callback
|
||||||
|
export type ProgressCallback = (progress: number, stage: string, message: string) => void;
|
||||||
|
|
||||||
|
// Step validation function
|
||||||
|
export type StepValidator = (data: OnboardingData) => string | null;
|
||||||
|
|
||||||
|
// Service-specific state types
|
||||||
|
export interface TenantCreationState extends ServiceState<BakeryRegistration> {
|
||||||
|
tenantData: BakeryRegistration | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SalesProcessingState extends ServiceState<any> {
|
||||||
|
stage: 'idle' | 'validating' | 'analyzing' | 'completed' | 'error';
|
||||||
|
progress: number;
|
||||||
|
currentMessage: string;
|
||||||
|
validationResults: any | null;
|
||||||
|
suggestions: ProductSuggestionResponse[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventorySetupState extends ServiceState<any> {
|
||||||
|
createdItems: any[];
|
||||||
|
inventoryMapping: { [productName: string]: string };
|
||||||
|
salesImportResult: {
|
||||||
|
success: boolean;
|
||||||
|
imported: boolean;
|
||||||
|
records_created: number;
|
||||||
|
message: string;
|
||||||
|
} | null;
|
||||||
|
isInventoryConfigured: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrainingOrchestrationState extends ServiceState<TrainingJobResponse> {
|
||||||
|
status: 'idle' | 'validating' | 'training' | 'completed' | 'failed';
|
||||||
|
progress: number;
|
||||||
|
currentStep: string;
|
||||||
|
estimatedTimeRemaining: number;
|
||||||
|
job: TrainingJobResponse | null;
|
||||||
|
logs: TrainingLog[];
|
||||||
|
metrics: TrainingMetrics | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProgressTrackingState extends ServiceState<UserProgress> {
|
||||||
|
progress: UserProgress | null;
|
||||||
|
isInitialized: boolean;
|
||||||
|
isCompleted: boolean;
|
||||||
|
completionPercentage: number;
|
||||||
|
currentBackendStep: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResumeState extends ServiceState {
|
||||||
|
isCheckingResume: boolean;
|
||||||
|
resumePoint: { stepId: string; stepIndex: number } | null;
|
||||||
|
shouldResume: boolean;
|
||||||
|
}
|
||||||
@@ -1,21 +1,29 @@
|
|||||||
/**
|
/**
|
||||||
* Onboarding hooks index
|
* Onboarding hooks index - Complete clean architecture
|
||||||
* Exports all focused onboarding hooks
|
* No legacy code, no backwards compatibility, clean modern patterns
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Types
|
// Types (re-export core types for external usage)
|
||||||
export type * from './types';
|
export type * from './core/types';
|
||||||
|
|
||||||
// Steps configuration
|
// Steps configuration
|
||||||
export { DEFAULT_STEPS, getStepById, getStepIndex, canAccessStep, calculateProgress } from './steps';
|
export { DEFAULT_STEPS, getStepById, getStepIndex, canAccessStep, calculateProgress } from './steps';
|
||||||
|
|
||||||
// Focused hooks
|
// Core architecture (for advanced usage)
|
||||||
export { useOnboardingFlow } from './useOnboardingFlow';
|
export { useOnboardingStore } from './core/store';
|
||||||
export { useOnboardingData } from './useOnboardingData';
|
export { useOnboardingActions } from './core/actions';
|
||||||
export { useTenantCreation } from './useTenantCreation';
|
|
||||||
export { useSalesProcessing } from './useSalesProcessing';
|
|
||||||
export { useInventorySetup } from './useInventorySetup';
|
|
||||||
export { useTrainingOrchestration } from './useTrainingOrchestration';
|
|
||||||
|
|
||||||
// Main orchestrating hook
|
// Service hooks (for direct service access when needed)
|
||||||
export { useOnboarding } from './useOnboarding';
|
export { useTenantCreation } from './services/useTenantCreation';
|
||||||
|
export { useSalesProcessing } from './services/useSalesProcessing';
|
||||||
|
export { useInventorySetup } from './services/useInventorySetup';
|
||||||
|
export { useTrainingOrchestration } from './services/useTrainingOrchestration';
|
||||||
|
export { useProgressTracking } from './services/useProgressTracking';
|
||||||
|
export { useResumeLogic } from './services/useResumeLogic';
|
||||||
|
|
||||||
|
// Main hooks - PRIMARY INTERFACE for components
|
||||||
|
export { useOnboarding } from './useOnboarding';
|
||||||
|
export { useAutoResume } from './useAutoResume';
|
||||||
|
|
||||||
|
// Utility
|
||||||
|
export { createServiceHook } from './utils/createServiceHook';
|
||||||
@@ -1,62 +1,32 @@
|
|||||||
/**
|
/**
|
||||||
* Inventory setup hook for creating inventory from suggestions
|
* Inventory setup service - Clean, standardized implementation
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
useCreateIngredient,
|
useCreateIngredient,
|
||||||
useCreateSalesRecord,
|
useCreateSalesRecord,
|
||||||
} from '../../../api';
|
} from '../../../../api';
|
||||||
import { useCurrentTenant } from '../../../stores';
|
import { useCurrentTenant } from '../../../../stores';
|
||||||
import type { ProductSuggestionResponse } from './types';
|
import { useOnboardingStore } from '../core/store';
|
||||||
|
import { createServiceHook } from '../utils/createServiceHook';
|
||||||
|
import type { InventorySetupState, ProductSuggestionResponse } from '../core/types';
|
||||||
|
|
||||||
interface InventorySetupState {
|
const useInventorySetupService = createServiceHook<InventorySetupState>({
|
||||||
isLoading: boolean;
|
initialState: {
|
||||||
error: string | null;
|
|
||||||
createdItems: any[];
|
|
||||||
inventoryMapping: { [productName: string]: string };
|
|
||||||
salesImportResult: {
|
|
||||||
success: boolean;
|
|
||||||
imported: boolean;
|
|
||||||
records_created: number;
|
|
||||||
message: string;
|
|
||||||
} | null;
|
|
||||||
isInventoryConfigured: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface InventorySetupActions {
|
|
||||||
createInventoryFromSuggestions: (
|
|
||||||
suggestions: ProductSuggestionResponse[]
|
|
||||||
) => Promise<{
|
|
||||||
success: boolean;
|
|
||||||
createdItems?: any[];
|
|
||||||
inventoryMapping?: { [productName: string]: string };
|
|
||||||
}>;
|
|
||||||
importSalesData: (
|
|
||||||
salesData: any,
|
|
||||||
inventoryMapping: { [productName: string]: string }
|
|
||||||
) => Promise<{
|
|
||||||
success: boolean;
|
|
||||||
recordsCreated: number;
|
|
||||||
message: string;
|
|
||||||
}>;
|
|
||||||
clearError: () => void;
|
|
||||||
reset: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useInventorySetup = () => {
|
|
||||||
const [state, setState] = useState<InventorySetupState>({
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
createdItems: [],
|
createdItems: [],
|
||||||
inventoryMapping: {},
|
inventoryMapping: {},
|
||||||
salesImportResult: null,
|
salesImportResult: null,
|
||||||
isInventoryConfigured: false,
|
isInventoryConfigured: false,
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useInventorySetup = () => {
|
||||||
|
const service = useInventorySetupService();
|
||||||
const createIngredientMutation = useCreateIngredient();
|
const createIngredientMutation = useCreateIngredient();
|
||||||
const createSalesRecordMutation = useCreateSalesRecord();
|
const createSalesRecordMutation = useCreateSalesRecord();
|
||||||
const currentTenant = useCurrentTenant();
|
const currentTenant = useCurrentTenant();
|
||||||
|
const { setStepData } = useOnboardingStore();
|
||||||
|
|
||||||
const createInventoryFromSuggestions = useCallback(async (
|
const createInventoryFromSuggestions = useCallback(async (
|
||||||
suggestions: ProductSuggestionResponse[]
|
suggestions: ProductSuggestionResponse[]
|
||||||
@@ -73,29 +43,17 @@ export const useInventorySetup = () => {
|
|||||||
|
|
||||||
if (!suggestions || suggestions.length === 0) {
|
if (!suggestions || suggestions.length === 0) {
|
||||||
console.error('useInventorySetup - No suggestions provided');
|
console.error('useInventorySetup - No suggestions provided');
|
||||||
setState(prev => ({
|
service.setError('No hay sugerencias para crear el inventario');
|
||||||
...prev,
|
|
||||||
error: 'No hay sugerencias para crear el inventario',
|
|
||||||
}));
|
|
||||||
return { success: false };
|
return { success: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!currentTenant?.id) {
|
if (!currentTenant?.id) {
|
||||||
console.error('useInventorySetup - No tenant ID available');
|
console.error('useInventorySetup - No tenant ID available');
|
||||||
setState(prev => ({
|
service.setError('No se pudo obtener información del tenant');
|
||||||
...prev,
|
|
||||||
error: 'No se pudo obtener información del tenant',
|
|
||||||
}));
|
|
||||||
return { success: false };
|
return { success: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(prev => ({
|
const result = await service.executeAsync(async () => {
|
||||||
...prev,
|
|
||||||
isLoading: true,
|
|
||||||
error: null
|
|
||||||
}));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const createdItems = [];
|
const createdItems = [];
|
||||||
const inventoryMapping: { [key: string]: string } = {};
|
const inventoryMapping: { [key: string]: string } = {};
|
||||||
|
|
||||||
@@ -109,14 +67,16 @@ export const useInventorySetup = () => {
|
|||||||
category: suggestion.category || 'Sin categoría',
|
category: suggestion.category || 'Sin categoría',
|
||||||
description: suggestion.notes || '',
|
description: suggestion.notes || '',
|
||||||
unit_of_measure: suggestion.unit_of_measure || 'units',
|
unit_of_measure: suggestion.unit_of_measure || 'units',
|
||||||
low_stock_threshold: 10, // Default low stock threshold
|
minimum_stock_level: 10,
|
||||||
reorder_point: 15, // Default reorder point (must be > low_stock_threshold)
|
maximum_stock_level: 100,
|
||||||
reorder_quantity: 50, // Default reorder quantity
|
low_stock_threshold: 10,
|
||||||
|
reorder_point: 15,
|
||||||
|
reorder_quantity: 50,
|
||||||
shelf_life_days: suggestion.estimated_shelf_life_days || 30,
|
shelf_life_days: suggestion.estimated_shelf_life_days || 30,
|
||||||
requires_refrigeration: suggestion.requires_refrigeration || false,
|
requires_refrigeration: suggestion.requires_refrigeration || false,
|
||||||
requires_freezing: suggestion.requires_freezing || false,
|
requires_freezing: suggestion.requires_freezing || false,
|
||||||
is_seasonal: suggestion.is_seasonal || false,
|
is_seasonal: suggestion.is_seasonal || false,
|
||||||
cost_per_unit: 0, // Will be set by user later
|
cost_per_unit: 0,
|
||||||
notes: suggestion.notes || `Producto clasificado automáticamente desde: ${suggestion.original_name}`,
|
notes: suggestion.notes || `Producto clasificado automáticamente desde: ${suggestion.original_name}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -161,30 +121,33 @@ export const useInventorySetup = () => {
|
|||||||
inventoryMapping
|
inventoryMapping
|
||||||
});
|
});
|
||||||
|
|
||||||
setState(prev => ({
|
// Update service state
|
||||||
...prev,
|
service.setSuccess({
|
||||||
isLoading: false,
|
...service.data,
|
||||||
createdItems,
|
createdItems,
|
||||||
inventoryMapping,
|
inventoryMapping,
|
||||||
isInventoryConfigured: true,
|
isInventoryConfigured: true,
|
||||||
}));
|
});
|
||||||
|
|
||||||
|
// Update onboarding store
|
||||||
|
setStepData('smart-inventory-setup', {
|
||||||
|
inventoryItems: createdItems,
|
||||||
|
inventoryMapping,
|
||||||
|
inventoryConfigured: true,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
|
||||||
createdItems,
|
createdItems,
|
||||||
inventoryMapping,
|
inventoryMapping,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
});
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Error creando el inventario';
|
|
||||||
console.error('useInventorySetup - Error in createInventoryFromSuggestions:', error);
|
return {
|
||||||
setState(prev => ({
|
success: result.success,
|
||||||
...prev,
|
createdItems: result.data?.createdItems,
|
||||||
isLoading: false,
|
inventoryMapping: result.data?.inventoryMapping,
|
||||||
error: errorMessage
|
};
|
||||||
}));
|
}, [service, createIngredientMutation, currentTenant, setStepData]);
|
||||||
return { success: false };
|
|
||||||
}
|
|
||||||
}, [createIngredientMutation, currentTenant]);
|
|
||||||
|
|
||||||
const importSalesData = useCallback(async (
|
const importSalesData = useCallback(async (
|
||||||
salesData: any,
|
salesData: any,
|
||||||
@@ -195,10 +158,7 @@ export const useInventorySetup = () => {
|
|||||||
message: string;
|
message: string;
|
||||||
}> => {
|
}> => {
|
||||||
if (!currentTenant?.id) {
|
if (!currentTenant?.id) {
|
||||||
setState(prev => ({
|
service.setError('No se pudo obtener información del tenant');
|
||||||
...prev,
|
|
||||||
error: 'No se pudo obtener información del tenant',
|
|
||||||
}));
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
recordsCreated: 0,
|
recordsCreated: 0,
|
||||||
@@ -207,10 +167,7 @@ export const useInventorySetup = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!salesData || !salesData.product_list) {
|
if (!salesData || !salesData.product_list) {
|
||||||
setState(prev => ({
|
service.setError('No hay datos de ventas para importar');
|
||||||
...prev,
|
|
||||||
error: 'No hay datos de ventas para importar',
|
|
||||||
}));
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
recordsCreated: 0,
|
recordsCreated: 0,
|
||||||
@@ -218,13 +175,7 @@ export const useInventorySetup = () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(prev => ({
|
const result = await service.executeAsync(async () => {
|
||||||
...prev,
|
|
||||||
isLoading: true,
|
|
||||||
error: null
|
|
||||||
}));
|
|
||||||
|
|
||||||
try {
|
|
||||||
let recordsCreated = 0;
|
let recordsCreated = 0;
|
||||||
|
|
||||||
// Process actual sales data and create sales records
|
// Process actual sales data and create sales records
|
||||||
@@ -261,7 +212,7 @@ export const useInventorySetup = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = {
|
const importResult = {
|
||||||
success: recordsCreated > 0,
|
success: recordsCreated > 0,
|
||||||
imported: recordsCreated > 0,
|
imported: recordsCreated > 0,
|
||||||
records_created: recordsCreated,
|
records_created: recordsCreated,
|
||||||
@@ -270,69 +221,43 @@ export const useInventorySetup = () => {
|
|||||||
: 'No se pudieron importar registros de ventas',
|
: 'No se pudieron importar registros de ventas',
|
||||||
};
|
};
|
||||||
|
|
||||||
setState(prev => ({
|
// Update service state
|
||||||
...prev,
|
service.setSuccess({
|
||||||
isLoading: false,
|
...service.data,
|
||||||
salesImportResult: result,
|
salesImportResult: importResult,
|
||||||
}));
|
});
|
||||||
|
|
||||||
|
// Update onboarding store
|
||||||
|
setStepData('smart-inventory-setup', {
|
||||||
|
salesImportResult: importResult,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: result.success,
|
|
||||||
recordsCreated,
|
recordsCreated,
|
||||||
message: result.message,
|
message: importResult.message,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Error importando datos de ventas';
|
|
||||||
const result = {
|
|
||||||
success: false,
|
|
||||||
imported: false,
|
|
||||||
records_created: 0,
|
|
||||||
message: errorMessage,
|
|
||||||
};
|
|
||||||
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
isLoading: false,
|
|
||||||
error: errorMessage,
|
|
||||||
salesImportResult: result,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
recordsCreated: 0,
|
|
||||||
message: errorMessage,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [createSalesRecordMutation, currentTenant]);
|
|
||||||
|
|
||||||
const clearError = useCallback(() => {
|
|
||||||
setState(prev => ({ ...prev, error: null }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
|
||||||
setState({
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
createdItems: [],
|
|
||||||
inventoryMapping: {},
|
|
||||||
salesImportResult: null,
|
|
||||||
isInventoryConfigured: false,
|
|
||||||
});
|
});
|
||||||
}, []);
|
|
||||||
|
return {
|
||||||
|
success: result.success,
|
||||||
|
recordsCreated: result.data?.recordsCreated || 0,
|
||||||
|
message: result.data?.message || (result.error || 'Error importando datos de ventas'),
|
||||||
|
};
|
||||||
|
}, [service, createSalesRecordMutation, currentTenant, setStepData]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
isLoading: state.isLoading,
|
isLoading: service.isLoading,
|
||||||
error: state.error,
|
error: service.error,
|
||||||
createdItems: state.createdItems,
|
createdItems: service.data?.createdItems || [],
|
||||||
inventoryMapping: state.inventoryMapping,
|
inventoryMapping: service.data?.inventoryMapping || {},
|
||||||
salesImportResult: state.salesImportResult,
|
salesImportResult: service.data?.salesImportResult || null,
|
||||||
isInventoryConfigured: state.isInventoryConfigured,
|
isInventoryConfigured: service.data?.isInventoryConfigured || false,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
createInventoryFromSuggestions,
|
createInventoryFromSuggestions,
|
||||||
importSalesData,
|
importSalesData,
|
||||||
clearError,
|
clearError: service.clearError,
|
||||||
reset,
|
reset: service.reset,
|
||||||
} satisfies InventorySetupState & InventorySetupActions;
|
};
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/**
|
||||||
|
* Progress tracking service - Clean, standardized implementation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect } from 'react';
|
||||||
|
import { onboardingService } from '../../../../api/services/onboarding';
|
||||||
|
import { createServiceHook } from '../utils/createServiceHook';
|
||||||
|
import type { ProgressTrackingState } from '../core/types';
|
||||||
|
import type { UserProgress } from '../../../../api/types/onboarding';
|
||||||
|
|
||||||
|
const useProgressTrackingService = createServiceHook<ProgressTrackingState>({
|
||||||
|
initialState: {
|
||||||
|
progress: null,
|
||||||
|
isInitialized: false,
|
||||||
|
isCompleted: false,
|
||||||
|
completionPercentage: 0,
|
||||||
|
currentBackendStep: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useProgressTracking = () => {
|
||||||
|
const service = useProgressTrackingService();
|
||||||
|
|
||||||
|
// Load initial progress from backend
|
||||||
|
const loadProgress = useCallback(async (): Promise<UserProgress | null> => {
|
||||||
|
const result = await service.executeAsync(async () => {
|
||||||
|
const progress = await onboardingService.getUserProgress('');
|
||||||
|
|
||||||
|
// Update service state with additional computed values
|
||||||
|
const updatedData = {
|
||||||
|
...service.data!,
|
||||||
|
progress,
|
||||||
|
isInitialized: true,
|
||||||
|
isCompleted: progress?.fully_completed || false,
|
||||||
|
completionPercentage: progress?.completion_percentage || 0,
|
||||||
|
currentBackendStep: progress?.current_step || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
service.setSuccess(updatedData);
|
||||||
|
|
||||||
|
return progress;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.data || null;
|
||||||
|
}, [service]);
|
||||||
|
|
||||||
|
// Mark a step as completed and save to backend
|
||||||
|
const markStepCompleted = useCallback(async (
|
||||||
|
stepId: string,
|
||||||
|
data?: Record<string, any>
|
||||||
|
): Promise<boolean> => {
|
||||||
|
const result = await service.executeAsync(async () => {
|
||||||
|
const updatedProgress = await onboardingService.markStepAsCompleted(stepId, data);
|
||||||
|
|
||||||
|
// Update service state
|
||||||
|
service.setSuccess({
|
||||||
|
...service.data!,
|
||||||
|
progress: updatedProgress,
|
||||||
|
isCompleted: updatedProgress?.fully_completed || false,
|
||||||
|
completionPercentage: updatedProgress?.completion_percentage || 0,
|
||||||
|
currentBackendStep: updatedProgress?.current_step || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ Step "${stepId}" marked as completed in backend`);
|
||||||
|
return updatedProgress;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
console.error(`❌ Error marking step "${stepId}" as completed:`, result.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.success;
|
||||||
|
}, [service]);
|
||||||
|
|
||||||
|
// Get the next step the user should work on
|
||||||
|
const getNextStep = useCallback(async (): Promise<string> => {
|
||||||
|
try {
|
||||||
|
return await onboardingService.getNextStepId();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting next step:', error);
|
||||||
|
return 'setup';
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Get the step and index where user should resume
|
||||||
|
const getResumePoint = useCallback(async (): Promise<{ stepId: string; stepIndex: number }> => {
|
||||||
|
try {
|
||||||
|
return await onboardingService.getResumeStep();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting resume point:', error);
|
||||||
|
return { stepId: 'setup', stepIndex: 0 };
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Complete the entire onboarding process
|
||||||
|
const completeOnboarding = useCallback(async (): Promise<boolean> => {
|
||||||
|
const result = await service.executeAsync(async () => {
|
||||||
|
const result = await onboardingService.completeOnboarding();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
// Reload progress to get updated status
|
||||||
|
await loadProgress();
|
||||||
|
console.log('🎉 Onboarding completed successfully!');
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Failed to complete onboarding');
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.success;
|
||||||
|
}, [service, loadProgress]);
|
||||||
|
|
||||||
|
// Check if user can access a specific step
|
||||||
|
const canAccessStep = useCallback(async (stepId: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const result = await onboardingService.canAccessStep(stepId);
|
||||||
|
return result.can_access;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error checking access for step "${stepId}":`, error);
|
||||||
|
return true; // Allow access on error
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-load progress on hook initialization
|
||||||
|
useEffect(() => {
|
||||||
|
if (!service.data?.isInitialized) {
|
||||||
|
loadProgress();
|
||||||
|
}
|
||||||
|
}, [loadProgress, service.data?.isInitialized]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
isLoading: service.isLoading,
|
||||||
|
error: service.error,
|
||||||
|
progress: service.data?.progress || null,
|
||||||
|
isInitialized: service.data?.isInitialized || false,
|
||||||
|
isCompleted: service.data?.isCompleted || false,
|
||||||
|
completionPercentage: service.data?.completionPercentage || 0,
|
||||||
|
currentBackendStep: service.data?.currentBackendStep || null,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
loadProgress,
|
||||||
|
markStepCompleted,
|
||||||
|
getNextStep,
|
||||||
|
getResumePoint,
|
||||||
|
completeOnboarding,
|
||||||
|
canAccessStep,
|
||||||
|
clearError: service.clearError,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
/**
|
||||||
|
* Resume logic service - Clean, standardized implementation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useOnboardingStore } from '../core/store';
|
||||||
|
import { useProgressTracking } from './useProgressTracking';
|
||||||
|
import { createServiceHook } from '../utils/createServiceHook';
|
||||||
|
import type { ResumeState } from '../core/types';
|
||||||
|
|
||||||
|
const useResumeLogicService = createServiceHook<ResumeState>({
|
||||||
|
initialState: {
|
||||||
|
isCheckingResume: false,
|
||||||
|
resumePoint: null,
|
||||||
|
shouldResume: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useResumeLogic = () => {
|
||||||
|
const service = useResumeLogicService();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const progressTracking = useProgressTracking();
|
||||||
|
const { setCurrentStep } = useOnboardingStore();
|
||||||
|
|
||||||
|
// Check if user should resume onboarding
|
||||||
|
const checkForResume = useCallback(async (): Promise<boolean> => {
|
||||||
|
const result = await service.executeAsync(async () => {
|
||||||
|
// Set checking state
|
||||||
|
service.setSuccess({
|
||||||
|
...service.data!,
|
||||||
|
isCheckingResume: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load user's progress from backend
|
||||||
|
await progressTracking.loadProgress();
|
||||||
|
|
||||||
|
if (!progressTracking.progress) {
|
||||||
|
console.log('🔍 No progress found, starting from beginning');
|
||||||
|
service.setSuccess({
|
||||||
|
...service.data!,
|
||||||
|
isCheckingResume: false,
|
||||||
|
shouldResume: false,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If onboarding is already completed, don't resume
|
||||||
|
if (progressTracking.isCompleted) {
|
||||||
|
console.log('✅ Onboarding already completed, redirecting to dashboard');
|
||||||
|
navigate('/app/dashboard');
|
||||||
|
service.setSuccess({
|
||||||
|
...service.data!,
|
||||||
|
isCheckingResume: false,
|
||||||
|
shouldResume: false,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the resume point from backend
|
||||||
|
const resumePoint = await progressTracking.getResumePoint();
|
||||||
|
|
||||||
|
console.log('🔄 Resume point found:', resumePoint);
|
||||||
|
service.setSuccess({
|
||||||
|
...service.data!,
|
||||||
|
isCheckingResume: false,
|
||||||
|
resumePoint,
|
||||||
|
shouldResume: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.success;
|
||||||
|
}, [service, progressTracking, navigate]);
|
||||||
|
|
||||||
|
// Resume the onboarding flow from the correct step
|
||||||
|
const resumeFlow = useCallback((): boolean => {
|
||||||
|
if (!service.data?.resumePoint) {
|
||||||
|
console.warn('⚠️ No resume point available');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { stepIndex } = service.data.resumePoint;
|
||||||
|
|
||||||
|
console.log(`🎯 Resuming onboarding from step index: ${stepIndex}`);
|
||||||
|
|
||||||
|
// Navigate to the correct step in the flow
|
||||||
|
setCurrentStep(stepIndex);
|
||||||
|
|
||||||
|
console.log('✅ Successfully resumed onboarding flow');
|
||||||
|
service.setSuccess({
|
||||||
|
...service.data!,
|
||||||
|
shouldResume: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error resuming flow:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [service, setCurrentStep]);
|
||||||
|
|
||||||
|
// Handle automatic resume on mount
|
||||||
|
const handleAutoResume = useCallback(async () => {
|
||||||
|
const shouldResume = await checkForResume();
|
||||||
|
|
||||||
|
if (shouldResume) {
|
||||||
|
// Wait a bit for state to update, then resume
|
||||||
|
setTimeout(() => {
|
||||||
|
resumeFlow();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}, [checkForResume, resumeFlow]);
|
||||||
|
|
||||||
|
// Auto-check for resume when the hook is first used
|
||||||
|
useEffect(() => {
|
||||||
|
if (progressTracking.isInitialized && !service.data?.isCheckingResume) {
|
||||||
|
handleAutoResume();
|
||||||
|
}
|
||||||
|
}, [progressTracking.isInitialized, handleAutoResume, service.data?.isCheckingResume]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
isLoading: service.isLoading,
|
||||||
|
error: service.error,
|
||||||
|
isCheckingResume: service.data?.isCheckingResume || false,
|
||||||
|
resumePoint: service.data?.resumePoint || null,
|
||||||
|
shouldResume: service.data?.shouldResume || false,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
checkForResume,
|
||||||
|
resumeFlow,
|
||||||
|
handleAutoResume,
|
||||||
|
clearError: service.clearError,
|
||||||
|
|
||||||
|
// Progress tracking state for convenience
|
||||||
|
progress: progressTracking.progress,
|
||||||
|
isCompleted: progressTracking.isCompleted,
|
||||||
|
completionPercentage: progressTracking.completionPercentage,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,59 +1,101 @@
|
|||||||
/**
|
/**
|
||||||
* Sales file processing and validation hook
|
* Sales processing service - Clean, standardized implementation
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useClassifyProductsBatch, useValidateFileOnly } from '../../../api';
|
import { useClassifyProductsBatch, useValidateFileOnly } from '../../../../api';
|
||||||
import { useCurrentTenant } from '../../../stores';
|
import { useCurrentTenant } from '../../../../stores';
|
||||||
import type { ProductSuggestionResponse, ProgressCallback } from './types';
|
import { useOnboardingStore } from '../core/store';
|
||||||
|
import { createServiceHook } from '../utils/createServiceHook';
|
||||||
|
import type { SalesProcessingState, ProgressCallback, ProductSuggestionResponse } from '../core/types';
|
||||||
|
|
||||||
interface SalesProcessingState {
|
const useSalesProcessingService = createServiceHook<SalesProcessingState>({
|
||||||
isLoading: boolean;
|
initialState: {
|
||||||
error: string | null;
|
|
||||||
stage: 'idle' | 'validating' | 'analyzing' | 'completed' | 'error';
|
|
||||||
progress: number;
|
|
||||||
currentMessage: string;
|
|
||||||
validationResults: any | null;
|
|
||||||
suggestions: ProductSuggestionResponse[] | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SalesProcessingActions {
|
|
||||||
processFile: (file: File, onProgress?: ProgressCallback) => Promise<{
|
|
||||||
success: boolean;
|
|
||||||
validationResults?: any;
|
|
||||||
suggestions?: ProductSuggestionResponse[];
|
|
||||||
}>;
|
|
||||||
generateSuggestions: (productList: string[]) => Promise<ProductSuggestionResponse[] | null>;
|
|
||||||
clearError: () => void;
|
|
||||||
reset: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useSalesProcessing = () => {
|
|
||||||
const [state, setState] = useState<SalesProcessingState>({
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
stage: 'idle',
|
stage: 'idle',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
currentMessage: '',
|
currentMessage: '',
|
||||||
validationResults: null,
|
validationResults: null,
|
||||||
suggestions: null,
|
suggestions: null,
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useSalesProcessing = () => {
|
||||||
|
const service = useSalesProcessingService();
|
||||||
const classifyProductsMutation = useClassifyProductsBatch();
|
const classifyProductsMutation = useClassifyProductsBatch();
|
||||||
const currentTenant = useCurrentTenant();
|
const currentTenant = useCurrentTenant();
|
||||||
const { validateFile } = useValidateFileOnly();
|
const { validateFile } = useValidateFileOnly();
|
||||||
|
const { setStepData } = useOnboardingStore();
|
||||||
|
|
||||||
const updateProgress = useCallback((progress: number, stage: string, message: string, onProgress?: ProgressCallback) => {
|
const updateProgress = useCallback((
|
||||||
setState(prev => ({
|
progress: number,
|
||||||
...prev,
|
stage: SalesProcessingState['stage'],
|
||||||
|
message: string,
|
||||||
|
onProgress?: ProgressCallback
|
||||||
|
) => {
|
||||||
|
service.setSuccess({
|
||||||
|
...service.data,
|
||||||
progress,
|
progress,
|
||||||
stage: stage as any,
|
stage,
|
||||||
currentMessage: message,
|
currentMessage: message,
|
||||||
}));
|
});
|
||||||
|
|
||||||
onProgress?.(progress, stage, message);
|
onProgress?.(progress, stage, message);
|
||||||
|
}, [service]);
|
||||||
|
|
||||||
|
const extractProductList = useCallback((validationResult: any): string[] => {
|
||||||
|
console.log('extractProductList - Input validation result:', {
|
||||||
|
hasProductList: !!validationResult?.product_list,
|
||||||
|
productList: validationResult?.product_list,
|
||||||
|
hasSampleRecords: !!validationResult?.sample_records,
|
||||||
|
keys: Object.keys(validationResult || {})
|
||||||
|
});
|
||||||
|
|
||||||
|
// First try to use the direct product_list from backend response
|
||||||
|
if (validationResult.product_list && Array.isArray(validationResult.product_list)) {
|
||||||
|
console.log('extractProductList - Using direct product_list:', validationResult.product_list);
|
||||||
|
return validationResult.product_list;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Extract unique product names from sample records
|
||||||
|
if (validationResult.sample_records && Array.isArray(validationResult.sample_records)) {
|
||||||
|
const productSet = new Set<string>();
|
||||||
|
validationResult.sample_records.forEach((record: any) => {
|
||||||
|
if (record.product_name) {
|
||||||
|
productSet.add(record.product_name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const extractedList = Array.from(productSet);
|
||||||
|
console.log('extractProductList - Extracted from sample_records:', extractedList);
|
||||||
|
return extractedList;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('extractProductList - No products found, returning empty array');
|
||||||
|
return [];
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const generateSuggestions = useCallback(async (productList: string[]): Promise<ProductSuggestionResponse[]> => {
|
||||||
|
try {
|
||||||
|
if (!currentTenant?.id) {
|
||||||
|
console.error('No tenant ID available for classification');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await classifyProductsMutation.mutateAsync({
|
||||||
|
tenantId: currentTenant.id,
|
||||||
|
batchData: {
|
||||||
|
products: productList.map(name => ({
|
||||||
|
product_name: name,
|
||||||
|
description: ''
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return response || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating inventory suggestions:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [classifyProductsMutation, currentTenant]);
|
||||||
|
|
||||||
const processFile = useCallback(async (
|
const processFile = useCallback(async (
|
||||||
file: File,
|
file: File,
|
||||||
onProgress?: ProgressCallback
|
onProgress?: ProgressCallback
|
||||||
@@ -62,20 +104,14 @@ export const useSalesProcessing = () => {
|
|||||||
validationResults?: any;
|
validationResults?: any;
|
||||||
suggestions?: ProductSuggestionResponse[];
|
suggestions?: ProductSuggestionResponse[];
|
||||||
}> => {
|
}> => {
|
||||||
setState(prev => ({
|
service.setLoading(true);
|
||||||
...prev,
|
service.setError(null);
|
||||||
isLoading: true,
|
|
||||||
error: null,
|
|
||||||
stage: 'idle',
|
|
||||||
progress: 0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Stage 1: Validate file structure
|
// Stage 1: Validate file structure
|
||||||
updateProgress(10, 'validating', 'Iniciando validación del archivo...', onProgress);
|
updateProgress(10, 'validating', 'Iniciando validación del archivo...', onProgress);
|
||||||
updateProgress(20, 'validating', 'Validando estructura del archivo...', onProgress);
|
updateProgress(20, 'validating', 'Validando estructura del archivo...', onProgress);
|
||||||
|
|
||||||
// Use the actual data import hook for file validation
|
|
||||||
console.log('useSalesProcessing - currentTenant check:', {
|
console.log('useSalesProcessing - currentTenant check:', {
|
||||||
currentTenant,
|
currentTenant,
|
||||||
tenantId: currentTenant?.id,
|
tenantId: currentTenant?.id,
|
||||||
@@ -92,7 +128,7 @@ export const useSalesProcessing = () => {
|
|||||||
file,
|
file,
|
||||||
{
|
{
|
||||||
onProgress: (stage, progress, message) => {
|
onProgress: (stage, progress, message) => {
|
||||||
updateProgress(progress, stage, message, onProgress);
|
updateProgress(progress, stage as any, message, onProgress);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -141,13 +177,22 @@ export const useSalesProcessing = () => {
|
|||||||
updateProgress(90, 'analyzing', 'Analizando patrones de venta...', onProgress);
|
updateProgress(90, 'analyzing', 'Analizando patrones de venta...', onProgress);
|
||||||
updateProgress(100, 'completed', 'Procesamiento completado exitosamente', onProgress);
|
updateProgress(100, 'completed', 'Procesamiento completado exitosamente', onProgress);
|
||||||
|
|
||||||
setState(prev => ({
|
// Update service state
|
||||||
...prev,
|
service.setSuccess({
|
||||||
isLoading: false,
|
|
||||||
stage: 'completed',
|
stage: 'completed',
|
||||||
|
progress: 100,
|
||||||
|
currentMessage: 'Procesamiento completado exitosamente',
|
||||||
validationResults: validationResult,
|
validationResults: validationResult,
|
||||||
suggestions: suggestions || [],
|
suggestions: suggestions || [],
|
||||||
}));
|
});
|
||||||
|
|
||||||
|
// Update onboarding store
|
||||||
|
setStepData('smart-inventory-setup', {
|
||||||
|
files: { salesData: file },
|
||||||
|
processingStage: 'completed',
|
||||||
|
processingResults: validationResult,
|
||||||
|
suggestions: suggestions || [],
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -157,108 +202,29 @@ export const useSalesProcessing = () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Error procesando el archivo';
|
const errorMessage = error instanceof Error ? error.message : 'Error procesando el archivo';
|
||||||
|
|
||||||
setState(prev => ({
|
service.setError(errorMessage);
|
||||||
...prev,
|
|
||||||
isLoading: false,
|
|
||||||
error: errorMessage,
|
|
||||||
stage: 'error',
|
|
||||||
}));
|
|
||||||
|
|
||||||
updateProgress(0, 'error', errorMessage, onProgress);
|
updateProgress(0, 'error', errorMessage, onProgress);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [updateProgress, currentTenant, validateFile]);
|
}, [service, updateProgress, currentTenant, validateFile, extractProductList, generateSuggestions, setStepData]);
|
||||||
|
|
||||||
// Helper to extract product list from validation result
|
|
||||||
const extractProductList = useCallback((validationResult: any): string[] => {
|
|
||||||
console.log('extractProductList - Input validation result:', {
|
|
||||||
hasProductList: !!validationResult?.product_list,
|
|
||||||
productList: validationResult?.product_list,
|
|
||||||
hasSampleRecords: !!validationResult?.sample_records,
|
|
||||||
keys: Object.keys(validationResult || {})
|
|
||||||
});
|
|
||||||
|
|
||||||
// First try to use the direct product_list from backend response
|
|
||||||
if (validationResult.product_list && Array.isArray(validationResult.product_list)) {
|
|
||||||
console.log('extractProductList - Using direct product_list:', validationResult.product_list);
|
|
||||||
return validationResult.product_list;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: Extract unique product names from sample records
|
|
||||||
if (validationResult.sample_records && Array.isArray(validationResult.sample_records)) {
|
|
||||||
const productSet = new Set<string>();
|
|
||||||
validationResult.sample_records.forEach((record: any) => {
|
|
||||||
if (record.product_name) {
|
|
||||||
productSet.add(record.product_name);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const extractedList = Array.from(productSet);
|
|
||||||
console.log('extractProductList - Extracted from sample_records:', extractedList);
|
|
||||||
return extractedList;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('extractProductList - No products found, returning empty array');
|
|
||||||
return [];
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const generateSuggestions = useCallback(async (productList: string[]): Promise<ProductSuggestionResponse[] | null> => {
|
|
||||||
try {
|
|
||||||
if (!currentTenant?.id) {
|
|
||||||
console.error('No tenant ID available for classification');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await classifyProductsMutation.mutateAsync({
|
|
||||||
tenantId: currentTenant.id,
|
|
||||||
batchData: {
|
|
||||||
products: productList.map(name => ({
|
|
||||||
product_name: name,
|
|
||||||
description: ''
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return response || [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error generating inventory suggestions:', error);
|
|
||||||
// Don't throw here - suggestions are optional
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}, [classifyProductsMutation, currentTenant]);
|
|
||||||
|
|
||||||
const clearError = useCallback(() => {
|
|
||||||
setState(prev => ({ ...prev, error: null }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
|
||||||
setState({
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
stage: 'idle',
|
|
||||||
progress: 0,
|
|
||||||
currentMessage: '',
|
|
||||||
validationResults: null,
|
|
||||||
suggestions: null,
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State
|
// State
|
||||||
isLoading: state.isLoading,
|
isLoading: service.isLoading,
|
||||||
error: state.error,
|
error: service.error,
|
||||||
stage: state.stage,
|
stage: service.data?.stage || 'idle',
|
||||||
progress: state.progress,
|
progress: service.data?.progress || 0,
|
||||||
currentMessage: state.currentMessage,
|
currentMessage: service.data?.currentMessage || '',
|
||||||
validationResults: state.validationResults,
|
validationResults: service.data?.validationResults || null,
|
||||||
suggestions: state.suggestions,
|
suggestions: service.data?.suggestions || null,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
processFile,
|
processFile,
|
||||||
generateSuggestions,
|
generateSuggestions,
|
||||||
clearError,
|
clearError: service.clearError,
|
||||||
reset,
|
reset: service.reset,
|
||||||
} satisfies SalesProcessingState & SalesProcessingActions;
|
};
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Tenant creation service - Clean, standardized implementation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useRegisterBakery } from '../../../../api';
|
||||||
|
import { useTenantStore } from '../../../../stores/tenant.store';
|
||||||
|
import { useOnboardingStore } from '../core/store';
|
||||||
|
import { createServiceHook } from '../utils/createServiceHook';
|
||||||
|
import type { TenantCreationState } from '../core/types';
|
||||||
|
import type { BakeryRegistration, TenantResponse } from '../../../../api';
|
||||||
|
|
||||||
|
const useTenantCreationService = createServiceHook<TenantCreationState>({
|
||||||
|
initialState: {
|
||||||
|
tenantData: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useTenantCreation = () => {
|
||||||
|
const service = useTenantCreationService();
|
||||||
|
const registerBakeryMutation = useRegisterBakery();
|
||||||
|
const { setCurrentTenant, loadUserTenants } = useTenantStore();
|
||||||
|
const { setStepData } = useOnboardingStore();
|
||||||
|
|
||||||
|
const createTenant = useCallback(async (bakeryData: BakeryRegistration): Promise<boolean> => {
|
||||||
|
if (!bakeryData) {
|
||||||
|
service.setError('Los datos de la panadería son requeridos');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await service.executeAsync(async () => {
|
||||||
|
// Call API to register bakery
|
||||||
|
const tenantResponse: TenantResponse = await registerBakeryMutation.mutateAsync(bakeryData);
|
||||||
|
|
||||||
|
// Update tenant store
|
||||||
|
console.log('useTenantCreation - Setting current tenant:', tenantResponse);
|
||||||
|
setCurrentTenant(tenantResponse);
|
||||||
|
|
||||||
|
// Reload user tenants
|
||||||
|
await loadUserTenants();
|
||||||
|
|
||||||
|
// Update onboarding data
|
||||||
|
setStepData('setup', {
|
||||||
|
bakery: {
|
||||||
|
...bakeryData,
|
||||||
|
tenantCreated: true,
|
||||||
|
tenant_id: tenantResponse.id,
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('useTenantCreation - Tenant created and set successfully');
|
||||||
|
return tenantResponse;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.success;
|
||||||
|
}, [service, registerBakeryMutation, setCurrentTenant, loadUserTenants, setStepData]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
isLoading: service.isLoading,
|
||||||
|
error: service.error,
|
||||||
|
isSuccess: service.isSuccess,
|
||||||
|
tenantData: service.tenantData,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
createTenant,
|
||||||
|
clearError: service.clearError,
|
||||||
|
reset: service.reset,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
/**
|
||||||
|
* Training orchestration service - Clean, standardized implementation
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
useCreateTrainingJob,
|
||||||
|
useTrainingJobStatus,
|
||||||
|
useTrainingWebSocket,
|
||||||
|
} from '../../../../api';
|
||||||
|
import { useCurrentTenant } from '../../../../stores';
|
||||||
|
import { useAuthUser } from '../../../../stores/auth.store';
|
||||||
|
import { useOnboardingStore } from '../core/store';
|
||||||
|
import { createServiceHook } from '../utils/createServiceHook';
|
||||||
|
import type { TrainingOrchestrationState, TrainingLog, TrainingMetrics } from '../core/types';
|
||||||
|
import type { TrainingJobResponse } from '../../../../api';
|
||||||
|
|
||||||
|
const useTrainingOrchestrationService = createServiceHook<TrainingOrchestrationState>({
|
||||||
|
initialState: {
|
||||||
|
status: 'idle',
|
||||||
|
progress: 0,
|
||||||
|
currentStep: '',
|
||||||
|
estimatedTimeRemaining: 0,
|
||||||
|
job: null,
|
||||||
|
logs: [],
|
||||||
|
metrics: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useTrainingOrchestration = () => {
|
||||||
|
const service = useTrainingOrchestrationService();
|
||||||
|
const currentTenant = useCurrentTenant();
|
||||||
|
const user = useAuthUser();
|
||||||
|
const createTrainingJobMutation = useCreateTrainingJob();
|
||||||
|
const { setStepData, getAllStepData } = useOnboardingStore();
|
||||||
|
|
||||||
|
// Get job status when we have a job ID
|
||||||
|
const { data: jobStatus } = useTrainingJobStatus(
|
||||||
|
currentTenant?.id || '',
|
||||||
|
service.data?.job?.job_id || '',
|
||||||
|
{
|
||||||
|
enabled: !!currentTenant?.id && !!service.data?.job?.job_id && service.data?.status === 'training',
|
||||||
|
refetchInterval: 5000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// WebSocket for real-time updates
|
||||||
|
const { data: wsData } = useTrainingWebSocket(
|
||||||
|
currentTenant?.id || '',
|
||||||
|
service.data?.job?.job_id || '',
|
||||||
|
(user as any)?.token,
|
||||||
|
{
|
||||||
|
onProgress: (data: any) => {
|
||||||
|
service.setSuccess({
|
||||||
|
...service.data!,
|
||||||
|
progress: data.progress?.percentage || service.data?.progress || 0,
|
||||||
|
currentStep: data.progress?.current_step || service.data?.currentStep || '',
|
||||||
|
estimatedTimeRemaining: data.progress?.estimated_time_remaining || service.data?.estimatedTimeRemaining || 0,
|
||||||
|
});
|
||||||
|
addLog(
|
||||||
|
`${data.progress?.current_step} - ${data.progress?.products_completed}/${data.progress?.products_total} productos procesados (${data.progress?.percentage}%)`,
|
||||||
|
'info'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onCompleted: (data: any) => {
|
||||||
|
service.setSuccess({
|
||||||
|
...service.data!,
|
||||||
|
status: 'completed',
|
||||||
|
progress: 100,
|
||||||
|
metrics: {
|
||||||
|
accuracy: data.results.performance_metrics.accuracy,
|
||||||
|
mape: data.results.performance_metrics.mape,
|
||||||
|
mae: data.results.performance_metrics.mae,
|
||||||
|
rmse: data.results.performance_metrics.rmse,
|
||||||
|
r2_score: data.results.performance_metrics.r2_score || 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
addLog('¡Entrenamiento ML completado exitosamente!', 'success');
|
||||||
|
addLog(`${data.results.successful_trainings} modelos creados exitosamente`, 'success');
|
||||||
|
addLog(`Duración total: ${Math.round(data.results.training_duration / 60)} minutos`, 'info');
|
||||||
|
},
|
||||||
|
onError: (data: any) => {
|
||||||
|
service.setError(data.error);
|
||||||
|
service.setSuccess({
|
||||||
|
...service.data!,
|
||||||
|
status: 'failed',
|
||||||
|
});
|
||||||
|
addLog(`Error en entrenamiento: ${data.error}`, 'error');
|
||||||
|
},
|
||||||
|
onStarted: (data: any) => {
|
||||||
|
addLog(`Trabajo de entrenamiento iniciado: ${data.job_id}`, 'success');
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update status from polling when WebSocket is not available
|
||||||
|
useEffect(() => {
|
||||||
|
if (jobStatus && service.data?.job?.job_id === jobStatus.job_id) {
|
||||||
|
service.setSuccess({
|
||||||
|
...service.data!,
|
||||||
|
status: jobStatus.status as any,
|
||||||
|
progress: jobStatus.progress || service.data?.progress || 0,
|
||||||
|
currentStep: jobStatus.current_step || service.data?.currentStep || '',
|
||||||
|
estimatedTimeRemaining: jobStatus.estimated_time_remaining || service.data?.estimatedTimeRemaining || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [jobStatus, service.data?.job?.job_id, service]);
|
||||||
|
|
||||||
|
const addLog = useCallback((message: string, level: TrainingLog['level'] = 'info') => {
|
||||||
|
const newLog: TrainingLog = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
message,
|
||||||
|
level
|
||||||
|
};
|
||||||
|
|
||||||
|
service.setSuccess({
|
||||||
|
...service.data!,
|
||||||
|
logs: [...(service.data?.logs || []), newLog]
|
||||||
|
});
|
||||||
|
}, [service]);
|
||||||
|
|
||||||
|
const validateTrainingData = useCallback(async (allStepData?: any): Promise<{
|
||||||
|
isValid: boolean;
|
||||||
|
missingItems: string[];
|
||||||
|
}> => {
|
||||||
|
const missingItems: string[] = [];
|
||||||
|
const stepData = allStepData || getAllStepData();
|
||||||
|
|
||||||
|
console.log('Training Validation - All step data:', stepData);
|
||||||
|
|
||||||
|
// Get data from the smart-inventory-setup step (where sales and inventory are handled)
|
||||||
|
const smartInventoryData = stepData?.['smart-inventory-setup'];
|
||||||
|
|
||||||
|
// CRITICAL REQUIREMENT 1: Sales file must be uploaded
|
||||||
|
if (!smartInventoryData?.files?.salesData) {
|
||||||
|
missingItems.push('Archivo de datos de ventas históricos');
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL REQUIREMENT 2: Sales data must be processed and valid
|
||||||
|
const hasProcessingResults = smartInventoryData?.processingResults &&
|
||||||
|
smartInventoryData.processingResults.is_valid &&
|
||||||
|
smartInventoryData.processingResults.total_records > 0;
|
||||||
|
|
||||||
|
if (!hasProcessingResults) {
|
||||||
|
missingItems.push('Datos de ventas procesados y validados');
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL REQUIREMENT 3: Sales data must be imported to backend (MANDATORY for training)
|
||||||
|
const hasImportResults = smartInventoryData?.salesImportResult &&
|
||||||
|
(smartInventoryData.salesImportResult.records_created > 0 ||
|
||||||
|
smartInventoryData.salesImportResult.success === true ||
|
||||||
|
smartInventoryData.salesImportResult.imported === true);
|
||||||
|
|
||||||
|
if (!hasImportResults) {
|
||||||
|
missingItems.push('Datos de ventas históricos importados al sistema');
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL REQUIREMENT 4: Products must be approved
|
||||||
|
const hasApprovedProducts = smartInventoryData?.approvedProducts &&
|
||||||
|
smartInventoryData.approvedProducts.length > 0 &&
|
||||||
|
smartInventoryData.reviewCompleted;
|
||||||
|
|
||||||
|
if (!hasApprovedProducts) {
|
||||||
|
missingItems.push('Productos revisados y aprobados');
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL REQUIREMENT 5: Inventory must be configured
|
||||||
|
const hasInventoryConfig = smartInventoryData?.inventoryConfigured &&
|
||||||
|
smartInventoryData?.inventoryItems &&
|
||||||
|
smartInventoryData.inventoryItems.length > 0;
|
||||||
|
|
||||||
|
if (!hasInventoryConfig) {
|
||||||
|
missingItems.push('Inventario configurado con productos');
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL REQUIREMENT 6: Minimum data volume for training
|
||||||
|
if (smartInventoryData?.processingResults?.total_records &&
|
||||||
|
smartInventoryData.processingResults.total_records < 10) {
|
||||||
|
missingItems.push('Suficientes registros de ventas para entrenar (mínimo 10)');
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = missingItems.length === 0;
|
||||||
|
|
||||||
|
console.log('Training Validation Result:', {
|
||||||
|
isValid,
|
||||||
|
missingItems,
|
||||||
|
smartInventoryData: {
|
||||||
|
hasFile: !!smartInventoryData?.files?.salesData,
|
||||||
|
hasProcessingResults,
|
||||||
|
hasImportResults,
|
||||||
|
hasApprovedProducts,
|
||||||
|
hasInventoryConfig,
|
||||||
|
totalRecords: smartInventoryData?.processingResults?.total_records || 0
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { isValid, missingItems };
|
||||||
|
}, [getAllStepData]);
|
||||||
|
|
||||||
|
const startTraining = useCallback(async (options?: {
|
||||||
|
products?: string[];
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
}): Promise<boolean> => {
|
||||||
|
if (!currentTenant?.id) {
|
||||||
|
service.setError('No se pudo obtener información del tenant');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
service.setLoading(true);
|
||||||
|
service.setSuccess({
|
||||||
|
...service.data!,
|
||||||
|
status: 'validating',
|
||||||
|
progress: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
addLog('Validando disponibilidad de datos...', 'info');
|
||||||
|
|
||||||
|
const result = await service.executeAsync(async () => {
|
||||||
|
// Start training job
|
||||||
|
addLog('Iniciando trabajo de entrenamiento ML...', 'info');
|
||||||
|
const job = await createTrainingJobMutation.mutateAsync({
|
||||||
|
tenantId: currentTenant.id,
|
||||||
|
request: {
|
||||||
|
products: options?.products,
|
||||||
|
start_date: options?.startDate,
|
||||||
|
end_date: options?.endDate,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update service state
|
||||||
|
const updatedData = {
|
||||||
|
...service.data!,
|
||||||
|
job,
|
||||||
|
status: 'training' as const,
|
||||||
|
};
|
||||||
|
service.setSuccess(updatedData);
|
||||||
|
|
||||||
|
// Update onboarding store
|
||||||
|
setStepData('ml-training', {
|
||||||
|
trainingStatus: 'training',
|
||||||
|
trainingJob: job,
|
||||||
|
});
|
||||||
|
|
||||||
|
addLog(`Trabajo de entrenamiento iniciado: ${job.job_id}`, 'success');
|
||||||
|
return job;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.success;
|
||||||
|
}, [currentTenant, createTrainingJobMutation, addLog, service, setStepData]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
isLoading: service.isLoading,
|
||||||
|
error: service.error,
|
||||||
|
status: service.data?.status || 'idle',
|
||||||
|
progress: service.data?.progress || 0,
|
||||||
|
currentStep: service.data?.currentStep || '',
|
||||||
|
estimatedTimeRemaining: service.data?.estimatedTimeRemaining || 0,
|
||||||
|
job: service.data?.job || null,
|
||||||
|
logs: service.data?.logs || [],
|
||||||
|
metrics: service.data?.metrics || null,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
startTraining,
|
||||||
|
validateTrainingData,
|
||||||
|
addLog,
|
||||||
|
clearError: service.clearError,
|
||||||
|
reset: service.reset,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -22,37 +22,55 @@ export const DEFAULT_STEPS: OnboardingStep[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'sales-validation',
|
id: 'smart-inventory-setup',
|
||||||
title: '📊 Validación de Ventas',
|
title: '📦 Inventario Inteligente',
|
||||||
description: 'Sube, valida y aprueba tus datos de ventas históricas con IA',
|
description: 'Sube datos de ventas, configura inventario y crea tu catálogo de productos',
|
||||||
isRequired: true,
|
isRequired: true,
|
||||||
isCompleted: false,
|
isCompleted: false,
|
||||||
validation: (data: OnboardingData) => {
|
validation: (data: OnboardingData) => {
|
||||||
|
console.log('Smart Inventory Step Validation - Data:', {
|
||||||
|
hasSalesData: !!data.files?.salesData,
|
||||||
|
processingStage: data.processingStage,
|
||||||
|
isValid: data.processingResults?.is_valid,
|
||||||
|
reviewCompleted: data.reviewCompleted,
|
||||||
|
approvedProductsCount: data.approvedProducts?.length || 0,
|
||||||
|
inventoryConfigured: data.inventoryConfigured,
|
||||||
|
salesImportResult: data.salesImportResult
|
||||||
|
});
|
||||||
|
|
||||||
if (!data.files?.salesData) return 'Debes cargar el archivo de datos de ventas';
|
if (!data.files?.salesData) return 'Debes cargar el archivo de datos de ventas';
|
||||||
if (data.processingStage !== 'completed' && data.processingStage !== 'review') return 'El procesamiento debe completarse antes de continuar';
|
if (data.processingStage !== 'completed' && data.processingStage !== 'review') return 'El procesamiento debe completarse antes de continuar';
|
||||||
if (!data.processingResults?.is_valid) return 'Los datos deben ser válidos para continuar';
|
if (!data.processingResults?.is_valid) return 'Los datos deben ser válidos para continuar';
|
||||||
if (!data.reviewCompleted) return 'Debes revisar y aprobar los productos detectados';
|
if (!data.reviewCompleted) return 'Debes revisar y aprobar los productos detectados';
|
||||||
const hasApprovedProducts = data.approvedProducts && data.approvedProducts.length > 0;
|
const hasApprovedProducts = data.approvedProducts && data.approvedProducts.length > 0;
|
||||||
if (!hasApprovedProducts) return 'Debes aprobar al menos un producto para continuar';
|
if (!hasApprovedProducts) return 'Debes aprobar al menos un producto para continuar';
|
||||||
return null;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'inventory',
|
|
||||||
title: '⚙️ Inventario',
|
|
||||||
description: 'Configuración de inventario e importación de datos de ventas',
|
|
||||||
isRequired: true,
|
|
||||||
isCompleted: false,
|
|
||||||
validation: (data: OnboardingData) => {
|
|
||||||
if (!data.inventoryConfigured) return 'Debes configurar el inventario básico';
|
|
||||||
|
|
||||||
// Check if sales data was imported successfully
|
// Check if ready for automatic inventory creation and sales import
|
||||||
const hasImportResults = data.salesImportResult &&
|
// If inventory is already configured, check if sales data was imported
|
||||||
(data.salesImportResult.records_created > 0 ||
|
if (data.inventoryConfigured) {
|
||||||
data.salesImportResult.success === true ||
|
const hasImportResults = data.salesImportResult &&
|
||||||
data.salesImportResult.imported === true);
|
(data.salesImportResult.records_created > 0 ||
|
||||||
|
data.salesImportResult.success === true ||
|
||||||
if (!hasImportResults) return 'Debes importar los datos de ventas al inventario';
|
data.salesImportResult.imported === true);
|
||||||
|
|
||||||
|
if (!hasImportResults) {
|
||||||
|
console.log('Smart Inventory Step Validation - Sales import validation failed:', {
|
||||||
|
hasSalesImportResult: !!data.salesImportResult,
|
||||||
|
salesImportResult: data.salesImportResult,
|
||||||
|
inventoryConfigured: data.inventoryConfigured
|
||||||
|
});
|
||||||
|
|
||||||
|
return 'Los datos de ventas históricos deben estar importados para continuar al entrenamiento de IA.';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If inventory is not configured yet, ensure all prerequisites are ready
|
||||||
|
// The actual creation will happen automatically on "Next Step"
|
||||||
|
console.log('Smart Inventory Step Validation - Ready for automatic inventory creation:', {
|
||||||
|
hasApprovedProducts: hasApprovedProducts,
|
||||||
|
reviewCompleted: data.reviewCompleted,
|
||||||
|
readyForCreation: hasApprovedProducts && data.reviewCompleted
|
||||||
|
});
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -71,9 +89,43 @@ export const DEFAULT_STEPS: OnboardingStep[] = [
|
|||||||
isRequired: true,
|
isRequired: true,
|
||||||
isCompleted: false,
|
isCompleted: false,
|
||||||
validation: (data: OnboardingData) => {
|
validation: (data: OnboardingData) => {
|
||||||
if (data.trainingStatus !== 'completed') {
|
console.log('ML Training Step Validation - Data:', {
|
||||||
return 'El entrenamiento del modelo debe completarse';
|
inventoryConfigured: data.inventoryConfigured,
|
||||||
|
hasSalesFile: !!data.files?.salesData,
|
||||||
|
processingResults: data.processingResults,
|
||||||
|
salesImportResult: data.salesImportResult,
|
||||||
|
trainingStatus: data.trainingStatus
|
||||||
|
});
|
||||||
|
|
||||||
|
// CRITICAL PREREQUISITE 1: Inventory must be configured
|
||||||
|
if (!data.inventoryConfigured) {
|
||||||
|
return 'Debes configurar el inventario antes de entrenar el modelo de IA';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CRITICAL PREREQUISITE 2: Sales file must be uploaded and processed
|
||||||
|
if (!data.files?.salesData) {
|
||||||
|
return 'Debes cargar un archivo de datos de ventas históricos para entrenar el modelo';
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL PREREQUISITE 3: Sales data must be processed and valid
|
||||||
|
if (!data.processingResults?.is_valid) {
|
||||||
|
return 'Los datos de ventas deben ser procesados y validados antes del entrenamiento';
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL PREREQUISITE 4: Sales data must be imported to backend
|
||||||
|
const hasSalesDataImported = data.salesImportResult &&
|
||||||
|
(data.salesImportResult.records_created > 0 ||
|
||||||
|
data.salesImportResult.success === true);
|
||||||
|
|
||||||
|
if (!hasSalesDataImported && data.trainingStatus !== 'completed') {
|
||||||
|
return 'Los datos de ventas históricos deben estar importados en el sistema para iniciar el entrenamiento del modelo de IA';
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL PREREQUISITE 5: Training must be completed to proceed
|
||||||
|
if (data.trainingStatus !== 'completed') {
|
||||||
|
return 'El entrenamiento del modelo de IA debe completarse antes de continuar';
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -122,7 +174,7 @@ export const calculateProgress = (completedSteps: boolean[]): {
|
|||||||
percentage: number;
|
percentage: number;
|
||||||
} => {
|
} => {
|
||||||
const requiredSteps = DEFAULT_STEPS.filter(step => step.isRequired);
|
const requiredSteps = DEFAULT_STEPS.filter(step => step.isRequired);
|
||||||
const completedRequired = requiredSteps.filter((step, index) => {
|
const completedRequired = requiredSteps.filter((step) => {
|
||||||
const stepIndex = getStepIndex(step.id);
|
const stepIndex = getStepIndex(step.id);
|
||||||
return completedSteps[stepIndex];
|
return completedSteps[stepIndex];
|
||||||
});
|
});
|
||||||
|
|||||||
20
frontend/src/hooks/business/onboarding/useAutoResume.ts
Normal file
20
frontend/src/hooks/business/onboarding/useAutoResume.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Auto-resume hook - Simple wrapper around resume logic service
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useResumeLogic } from './services/useResumeLogic';
|
||||||
|
|
||||||
|
export const useAutoResume = () => {
|
||||||
|
const resumeLogic = useResumeLogic();
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
isCheckingResume: resumeLogic.isCheckingResume,
|
||||||
|
isCompleted: resumeLogic.isCompleted,
|
||||||
|
completionPercentage: resumeLogic.completionPercentage,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
checkForSavedProgress: resumeLogic.checkForResume,
|
||||||
|
resumeFromSavedProgress: resumeLogic.resumeFlow,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,282 +1,87 @@
|
|||||||
/**
|
/**
|
||||||
* Main onboarding hook - orchestrates all focused onboarding hooks
|
* Main onboarding hook - Clean, unified interface for components
|
||||||
* This is the primary hook that components should use
|
* This is the primary hook that all components should use
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { useAuthUser } from '../../../stores/auth.store';
|
import { useAuthUser } from '../../../stores/auth.store';
|
||||||
import { useCurrentTenant } from '../../../stores';
|
import { useCurrentTenant } from '../../../stores';
|
||||||
import { useOnboardingFlow } from './useOnboardingFlow';
|
import { useOnboardingStore } from './core/store';
|
||||||
import { useOnboardingData } from './useOnboardingData';
|
import { useOnboardingActions } from './core/actions';
|
||||||
import { useTenantCreation } from './useTenantCreation';
|
import { useTenantCreation } from './services/useTenantCreation';
|
||||||
import { useSalesProcessing } from './useSalesProcessing';
|
import { useSalesProcessing } from './services/useSalesProcessing';
|
||||||
import { useInventorySetup } from './useInventorySetup';
|
import { useInventorySetup } from './services/useInventorySetup';
|
||||||
import { useTrainingOrchestration } from './useTrainingOrchestration';
|
import { useTrainingOrchestration } from './services/useTrainingOrchestration';
|
||||||
import type {
|
import { useProgressTracking } from './services/useProgressTracking';
|
||||||
OnboardingData,
|
import { useResumeLogic } from './services/useResumeLogic';
|
||||||
ProgressCallback,
|
|
||||||
ProductSuggestionResponse
|
|
||||||
} from './types';
|
|
||||||
import type { BakeryRegistration } from '../../../api';
|
|
||||||
|
|
||||||
|
|
||||||
export const useOnboarding = () => {
|
export const useOnboarding = () => {
|
||||||
const navigate = useNavigate();
|
|
||||||
const user = useAuthUser();
|
const user = useAuthUser();
|
||||||
const currentTenant = useCurrentTenant();
|
const currentTenant = useCurrentTenant();
|
||||||
|
|
||||||
// Focused hooks
|
// Core store and actions
|
||||||
const flow = useOnboardingFlow();
|
const store = useOnboardingStore();
|
||||||
const data = useOnboardingData();
|
const actions = useOnboardingActions();
|
||||||
|
|
||||||
|
// Service hooks for detailed state access
|
||||||
const tenantCreation = useTenantCreation();
|
const tenantCreation = useTenantCreation();
|
||||||
const salesProcessing = useSalesProcessing();
|
const salesProcessing = useSalesProcessing();
|
||||||
const inventorySetup = useInventorySetup();
|
const inventorySetup = useInventorySetup();
|
||||||
const trainingOrchestration = useTrainingOrchestration();
|
const trainingOrchestration = useTrainingOrchestration();
|
||||||
|
const progressTracking = useProgressTracking();
|
||||||
// Data management
|
const resumeLogic = useResumeLogic();
|
||||||
const updateStepData = useCallback((stepId: string, stepData: Partial<OnboardingData>) => {
|
|
||||||
data.updateStepData(stepId, stepData);
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
const validateCurrentStep = useCallback((): string | null => {
|
|
||||||
const currentStep = flow.getCurrentStep();
|
|
||||||
const validationResult = data.validateStep(currentStep.id);
|
|
||||||
|
|
||||||
// Also check for specific step validations
|
|
||||||
if (validationResult) return validationResult;
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}, [flow, data]);
|
|
||||||
|
|
||||||
// Step-specific actions
|
|
||||||
const createTenant = useCallback(async (bakeryData: BakeryRegistration): Promise<boolean> => {
|
|
||||||
const success = await tenantCreation.createTenant(bakeryData);
|
|
||||||
if (success) {
|
|
||||||
// Store the bakery data with tenant creation success flag and tenant ID
|
|
||||||
updateStepData('setup', {
|
|
||||||
bakery: {
|
|
||||||
...bakeryData,
|
|
||||||
tenantCreated: true,
|
|
||||||
tenant_id: currentTenant?.id || 'created'
|
|
||||||
} as any // Type assertion to allow the additional properties
|
|
||||||
});
|
|
||||||
console.log('useOnboarding - Tenant created successfully, updated step data with tenant ID:', currentTenant?.id);
|
|
||||||
}
|
|
||||||
return success;
|
|
||||||
}, [tenantCreation, updateStepData, currentTenant]);
|
|
||||||
|
|
||||||
// Navigation actions
|
|
||||||
const nextStep = useCallback(async (): Promise<boolean> => {
|
|
||||||
try {
|
|
||||||
const currentStep = flow.getCurrentStep();
|
|
||||||
console.log('useOnboarding - nextStep called from step:', currentStep.id);
|
|
||||||
|
|
||||||
const validation = validateCurrentStep();
|
|
||||||
if (validation) {
|
|
||||||
console.log('useOnboarding - Validation failed:', validation);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle step-specific actions before moving to next step
|
|
||||||
if (currentStep.id === 'setup') {
|
|
||||||
console.log('useOnboarding - Creating tenant before leaving setup step');
|
|
||||||
const allStepData = data.getAllStepData();
|
|
||||||
const bakeryData = allStepData?.setup?.bakery;
|
|
||||||
|
|
||||||
if (bakeryData && !tenantCreation.isSuccess) {
|
|
||||||
console.log('useOnboarding - Tenant data found, creating tenant:', bakeryData);
|
|
||||||
const tenantSuccess = await createTenant(bakeryData);
|
|
||||||
console.log('useOnboarding - Tenant creation result:', tenantSuccess);
|
|
||||||
|
|
||||||
if (!tenantSuccess) {
|
|
||||||
console.log('useOnboarding - Tenant creation failed, stopping navigation');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log('useOnboarding - No tenant data found or tenant already created');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (flow.nextStep()) {
|
|
||||||
flow.markStepCompleted(flow.currentStep - 1);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('useOnboarding - Error in nextStep:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, [flow, validateCurrentStep, data, createTenant, tenantCreation]);
|
|
||||||
|
|
||||||
const previousStep = useCallback((): boolean => {
|
|
||||||
return flow.previousStep();
|
|
||||||
}, [flow]);
|
|
||||||
|
|
||||||
const goToStep = useCallback((stepIndex: number): boolean => {
|
|
||||||
return flow.goToStep(stepIndex);
|
|
||||||
}, [flow]);
|
|
||||||
|
|
||||||
const processSalesFile = useCallback(async (
|
|
||||||
file: File,
|
|
||||||
onProgress?: ProgressCallback
|
|
||||||
): Promise<boolean> => {
|
|
||||||
const result = await salesProcessing.processFile(file, onProgress);
|
|
||||||
if (result.success) {
|
|
||||||
updateStepData('sales-validation', {
|
|
||||||
files: { salesData: file },
|
|
||||||
processingStage: 'completed',
|
|
||||||
processingResults: result.validationResults,
|
|
||||||
suggestions: result.suggestions || []
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return result.success;
|
|
||||||
}, [salesProcessing, updateStepData]);
|
|
||||||
|
|
||||||
const generateInventorySuggestions = useCallback(async (
|
|
||||||
productList: string[]
|
|
||||||
): Promise<ProductSuggestionResponse[] | null> => {
|
|
||||||
return salesProcessing.generateSuggestions(productList);
|
|
||||||
}, [salesProcessing]);
|
|
||||||
|
|
||||||
const createInventoryFromSuggestions = useCallback(async (
|
|
||||||
suggestions: ProductSuggestionResponse[]
|
|
||||||
): Promise<boolean> => {
|
|
||||||
const result = await inventorySetup.createInventoryFromSuggestions(suggestions);
|
|
||||||
if (result.success) {
|
|
||||||
updateStepData('inventory', {
|
|
||||||
inventoryItems: result.createdItems,
|
|
||||||
inventoryMapping: result.inventoryMapping,
|
|
||||||
inventoryConfigured: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return result.success;
|
|
||||||
}, [inventorySetup, updateStepData]);
|
|
||||||
|
|
||||||
const importSalesData = useCallback(async (
|
|
||||||
salesData: any,
|
|
||||||
inventoryMapping: { [productName: string]: string }
|
|
||||||
): Promise<boolean> => {
|
|
||||||
const result = await inventorySetup.importSalesData(salesData, inventoryMapping);
|
|
||||||
if (result.success) {
|
|
||||||
updateStepData('inventory', {
|
|
||||||
salesImportResult: {
|
|
||||||
success: result.success,
|
|
||||||
imported: true,
|
|
||||||
records_created: result.recordsCreated,
|
|
||||||
message: result.message,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return result.success;
|
|
||||||
}, [inventorySetup, updateStepData]);
|
|
||||||
|
|
||||||
const startTraining = useCallback(async (options?: {
|
|
||||||
products?: string[];
|
|
||||||
startDate?: string;
|
|
||||||
endDate?: string;
|
|
||||||
}): Promise<boolean> => {
|
|
||||||
// First validate training data requirements
|
|
||||||
const allStepData = data.getAllStepData();
|
|
||||||
const validation = await trainingOrchestration.validateTrainingData(allStepData);
|
|
||||||
|
|
||||||
if (!validation.isValid) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const success = await trainingOrchestration.startTraining(options);
|
|
||||||
if (success) {
|
|
||||||
updateStepData('ml-training', {
|
|
||||||
trainingStatus: 'training',
|
|
||||||
trainingJob: trainingOrchestration.job,
|
|
||||||
trainingLogs: trainingOrchestration.logs,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return success;
|
|
||||||
}, [trainingOrchestration, data, updateStepData]);
|
|
||||||
|
|
||||||
const completeOnboarding = useCallback(async (): Promise<boolean> => {
|
|
||||||
// Mark final completion
|
|
||||||
updateStepData('completion', {
|
|
||||||
completionStats: {
|
|
||||||
totalProducts: data.data.processingResults?.unique_products || 0,
|
|
||||||
inventoryItems: data.data.inventoryItems?.length || 0,
|
|
||||||
suppliersConfigured: data.data.suppliers?.length || 0,
|
|
||||||
mlModelAccuracy: data.data.trainingMetrics?.accuracy || 0,
|
|
||||||
estimatedTimeSaved: '2-3 horas por día',
|
|
||||||
completionScore: 95,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
flow.markStepCompleted(flow.steps.length - 1);
|
|
||||||
|
|
||||||
// Navigate to dashboard after completion
|
|
||||||
setTimeout(() => {
|
|
||||||
navigate('/app/dashboard');
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}, [data, flow, navigate, updateStepData]);
|
|
||||||
|
|
||||||
// Utilities
|
|
||||||
const clearError = useCallback(() => {
|
|
||||||
data.clearError();
|
|
||||||
tenantCreation.clearError();
|
|
||||||
salesProcessing.clearError();
|
|
||||||
inventorySetup.clearError();
|
|
||||||
trainingOrchestration.clearError();
|
|
||||||
}, [data, tenantCreation, salesProcessing, inventorySetup, trainingOrchestration]);
|
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
|
||||||
flow.resetFlow();
|
|
||||||
data.resetData();
|
|
||||||
tenantCreation.reset();
|
|
||||||
salesProcessing.reset();
|
|
||||||
inventorySetup.reset();
|
|
||||||
trainingOrchestration.reset();
|
|
||||||
}, [flow, data, tenantCreation, salesProcessing, inventorySetup, trainingOrchestration]);
|
|
||||||
|
|
||||||
// Determine overall loading and error state
|
|
||||||
const isLoading = tenantCreation.isLoading ||
|
|
||||||
salesProcessing.isLoading ||
|
|
||||||
inventorySetup.isLoading ||
|
|
||||||
trainingOrchestration.isLoading;
|
|
||||||
|
|
||||||
const error = data.error?.message ||
|
|
||||||
tenantCreation.error ||
|
|
||||||
salesProcessing.error ||
|
|
||||||
inventorySetup.error ||
|
|
||||||
trainingOrchestration.error;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// State from flow management
|
// Core state from store
|
||||||
currentStep: flow.currentStep,
|
currentStep: store.getCurrentStep(),
|
||||||
steps: flow.steps,
|
steps: store.steps,
|
||||||
progress: flow.getProgress(),
|
data: store.data,
|
||||||
|
progress: store.getProgress(),
|
||||||
|
isLoading: store.isLoading,
|
||||||
|
error: store.error,
|
||||||
|
|
||||||
// State from data management
|
// User context
|
||||||
data: data.data,
|
user,
|
||||||
allStepData: data.getAllStepData(),
|
currentTenant,
|
||||||
|
|
||||||
// State from individual hooks
|
// Step data helpers
|
||||||
|
stepData: {
|
||||||
|
setup: store.getStepData('setup'),
|
||||||
|
'smart-inventory-setup': store.getStepData('smart-inventory-setup'),
|
||||||
|
suppliers: store.getStepData('suppliers'),
|
||||||
|
'ml-training': store.getStepData('ml-training'),
|
||||||
|
completion: store.getStepData('completion'),
|
||||||
|
},
|
||||||
|
|
||||||
|
// Service states (for components that need detailed service info)
|
||||||
tenantCreation: {
|
tenantCreation: {
|
||||||
isLoading: tenantCreation.isLoading,
|
isLoading: tenantCreation.isLoading,
|
||||||
isSuccess: tenantCreation.isSuccess,
|
isSuccess: tenantCreation.isSuccess,
|
||||||
|
error: tenantCreation.error,
|
||||||
|
tenantData: tenantCreation.tenantData,
|
||||||
},
|
},
|
||||||
|
|
||||||
salesProcessing: {
|
salesProcessing: {
|
||||||
|
isLoading: salesProcessing.isLoading,
|
||||||
|
error: salesProcessing.error,
|
||||||
stage: salesProcessing.stage,
|
stage: salesProcessing.stage,
|
||||||
progress: salesProcessing.progress,
|
progress: salesProcessing.progress,
|
||||||
currentMessage: salesProcessing.currentMessage,
|
currentMessage: salesProcessing.currentMessage,
|
||||||
validationResults: salesProcessing.validationResults,
|
validationResults: salesProcessing.validationResults,
|
||||||
suggestions: salesProcessing.suggestions,
|
suggestions: salesProcessing.suggestions,
|
||||||
},
|
},
|
||||||
|
|
||||||
inventorySetup: {
|
inventorySetup: {
|
||||||
|
isLoading: inventorySetup.isLoading,
|
||||||
|
error: inventorySetup.error,
|
||||||
createdItems: inventorySetup.createdItems,
|
createdItems: inventorySetup.createdItems,
|
||||||
inventoryMapping: inventorySetup.inventoryMapping,
|
inventoryMapping: inventorySetup.inventoryMapping,
|
||||||
salesImportResult: inventorySetup.salesImportResult,
|
salesImportResult: inventorySetup.salesImportResult,
|
||||||
isInventoryConfigured: inventorySetup.isInventoryConfigured,
|
isInventoryConfigured: inventorySetup.isInventoryConfigured,
|
||||||
},
|
},
|
||||||
|
|
||||||
trainingOrchestration: {
|
trainingOrchestration: {
|
||||||
|
isLoading: trainingOrchestration.isLoading,
|
||||||
|
error: trainingOrchestration.error,
|
||||||
status: trainingOrchestration.status,
|
status: trainingOrchestration.status,
|
||||||
progress: trainingOrchestration.progress,
|
progress: trainingOrchestration.progress,
|
||||||
currentStep: trainingOrchestration.currentStep,
|
currentStep: trainingOrchestration.currentStep,
|
||||||
@@ -285,27 +90,55 @@ export const useOnboarding = () => {
|
|||||||
logs: trainingOrchestration.logs,
|
logs: trainingOrchestration.logs,
|
||||||
metrics: trainingOrchestration.metrics,
|
metrics: trainingOrchestration.metrics,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
progressTracking: {
|
||||||
|
isLoading: progressTracking.isLoading,
|
||||||
|
error: progressTracking.error,
|
||||||
|
progress: progressTracking.progress,
|
||||||
|
isCompleted: progressTracking.isCompleted,
|
||||||
|
completionPercentage: progressTracking.completionPercentage,
|
||||||
|
isInitialized: progressTracking.isInitialized,
|
||||||
|
currentBackendStep: progressTracking.currentBackendStep,
|
||||||
|
},
|
||||||
|
|
||||||
|
resumeLogic: {
|
||||||
|
isCheckingResume: resumeLogic.isCheckingResume,
|
||||||
|
resumePoint: resumeLogic.resumePoint,
|
||||||
|
shouldResume: resumeLogic.shouldResume,
|
||||||
|
progress: resumeLogic.progress,
|
||||||
|
isCompleted: resumeLogic.isCompleted,
|
||||||
|
completionPercentage: resumeLogic.completionPercentage,
|
||||||
|
},
|
||||||
|
|
||||||
// Overall state
|
// Actions from the core actions hook
|
||||||
isLoading,
|
nextStep: actions.nextStep,
|
||||||
error,
|
previousStep: actions.previousStep,
|
||||||
user,
|
goToStep: actions.goToStep,
|
||||||
currentTenant,
|
validateCurrentStep: actions.validateCurrentStep,
|
||||||
|
|
||||||
// Actions
|
// Step data management
|
||||||
nextStep,
|
updateStepData: store.setStepData,
|
||||||
previousStep,
|
clearStepData: store.clearStepData,
|
||||||
goToStep,
|
|
||||||
updateStepData,
|
// Step-specific actions
|
||||||
validateCurrentStep,
|
createTenant: actions.createTenant,
|
||||||
createTenant,
|
processSalesFile: actions.processSalesFile,
|
||||||
processSalesFile,
|
createInventoryFromSuggestions: actions.createInventoryFromSuggestions,
|
||||||
generateInventorySuggestions,
|
importSalesData: actions.importSalesData,
|
||||||
createInventoryFromSuggestions,
|
startTraining: actions.startTraining,
|
||||||
importSalesData,
|
completeOnboarding: actions.completeOnboarding,
|
||||||
startTraining,
|
|
||||||
completeOnboarding,
|
// Service-specific actions (for components that need direct service access)
|
||||||
clearError,
|
generateSuggestions: salesProcessing.generateSuggestions,
|
||||||
reset,
|
addTrainingLog: trainingOrchestration.addLog,
|
||||||
|
validateTrainingData: trainingOrchestration.validateTrainingData,
|
||||||
|
|
||||||
|
// Resume actions
|
||||||
|
checkForSavedProgress: resumeLogic.checkForResume,
|
||||||
|
resumeFromSavedProgress: resumeLogic.resumeFlow,
|
||||||
|
|
||||||
|
// Utility actions
|
||||||
|
clearError: actions.clearError,
|
||||||
|
reset: actions.reset,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
/**
|
|
||||||
* Data persistence and validation for onboarding
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
|
||||||
import { getStepById } from './steps';
|
|
||||||
import type { OnboardingData, OnboardingError, StepValidator } from './types';
|
|
||||||
|
|
||||||
interface OnboardingDataState {
|
|
||||||
data: OnboardingData;
|
|
||||||
error: OnboardingError | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OnboardingDataActions {
|
|
||||||
updateStepData: (stepId: string, stepData: Partial<OnboardingData>) => void;
|
|
||||||
validateStep: (stepId: string) => string | null;
|
|
||||||
clearError: () => void;
|
|
||||||
resetData: () => void;
|
|
||||||
getStepData: (stepId: string) => any;
|
|
||||||
setAllStepData: (allData: { [stepId: string]: any }) => void;
|
|
||||||
getAllStepData: () => { [stepId: string]: any };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useOnboardingData = () => {
|
|
||||||
const [state, setState] = useState<OnboardingDataState>({
|
|
||||||
data: {
|
|
||||||
allStepData: {},
|
|
||||||
},
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateStepData = useCallback((stepId: string, stepData: Partial<OnboardingData>) => {
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
data: {
|
|
||||||
...prev.data,
|
|
||||||
...stepData,
|
|
||||||
// Also store in allStepData for cross-step access
|
|
||||||
allStepData: {
|
|
||||||
...prev.data.allStepData,
|
|
||||||
[stepId]: {
|
|
||||||
...prev.data.allStepData?.[stepId],
|
|
||||||
...stepData,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
error: null, // Clear error when data is updated successfully
|
|
||||||
}));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const validateStep = useCallback((stepId: string): string | null => {
|
|
||||||
const step = getStepById(stepId);
|
|
||||||
if (step?.validation) {
|
|
||||||
try {
|
|
||||||
const validationResult = step.validation(state.data);
|
|
||||||
if (validationResult) {
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
error: {
|
|
||||||
step: stepId,
|
|
||||||
message: validationResult,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
return validationResult;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Validation error';
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
error: {
|
|
||||||
step: stepId,
|
|
||||||
message: errorMessage,
|
|
||||||
details: error,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
return errorMessage;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [state.data]);
|
|
||||||
|
|
||||||
const clearError = useCallback(() => {
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
error: null,
|
|
||||||
}));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const resetData = useCallback(() => {
|
|
||||||
setState({
|
|
||||||
data: {
|
|
||||||
allStepData: {},
|
|
||||||
},
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getStepData = useCallback((stepId: string): any => {
|
|
||||||
return state.data.allStepData?.[stepId] || {};
|
|
||||||
}, [state.data.allStepData]);
|
|
||||||
|
|
||||||
const setAllStepData = useCallback((allData: { [stepId: string]: any }) => {
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
data: {
|
|
||||||
...prev.data,
|
|
||||||
allStepData: allData,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getAllStepData = useCallback((): { [stepId: string]: any } => {
|
|
||||||
return state.data.allStepData || {};
|
|
||||||
}, [state.data.allStepData]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
// State
|
|
||||||
data: state.data,
|
|
||||||
error: state.error,
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
updateStepData,
|
|
||||||
validateStep,
|
|
||||||
clearError,
|
|
||||||
resetData,
|
|
||||||
getStepData,
|
|
||||||
setAllStepData,
|
|
||||||
getAllStepData,
|
|
||||||
} satisfies OnboardingDataState & OnboardingDataActions;
|
|
||||||
};
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
/**
|
|
||||||
* Navigation and step management for onboarding
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
|
||||||
import { DEFAULT_STEPS, canAccessStep, calculateProgress } from './steps';
|
|
||||||
import type { OnboardingStep, OnboardingProgress } from './types';
|
|
||||||
|
|
||||||
interface OnboardingFlowState {
|
|
||||||
currentStep: number;
|
|
||||||
steps: OnboardingStep[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OnboardingFlowActions {
|
|
||||||
nextStep: () => boolean;
|
|
||||||
previousStep: () => boolean;
|
|
||||||
goToStep: (stepIndex: number) => boolean;
|
|
||||||
getCurrentStep: () => OnboardingStep;
|
|
||||||
getProgress: () => OnboardingProgress;
|
|
||||||
canNavigateToStep: (stepIndex: number) => boolean;
|
|
||||||
markStepCompleted: (stepIndex: number) => void;
|
|
||||||
resetFlow: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useOnboardingFlow = () => {
|
|
||||||
const [state, setState] = useState<OnboardingFlowState>({
|
|
||||||
currentStep: 0,
|
|
||||||
steps: DEFAULT_STEPS.map(step => ({ ...step })), // Create a copy
|
|
||||||
});
|
|
||||||
|
|
||||||
const nextStep = useCallback((): boolean => {
|
|
||||||
if (state.currentStep < state.steps.length - 1) {
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
currentStep: prev.currentStep + 1,
|
|
||||||
}));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}, [state.currentStep, state.steps.length]);
|
|
||||||
|
|
||||||
const previousStep = useCallback((): boolean => {
|
|
||||||
if (state.currentStep > 0) {
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
currentStep: prev.currentStep - 1,
|
|
||||||
}));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}, [state.currentStep]);
|
|
||||||
|
|
||||||
const goToStep = useCallback((stepIndex: number): boolean => {
|
|
||||||
if (stepIndex >= 0 && stepIndex < state.steps.length) {
|
|
||||||
const completedSteps = state.steps.map(step => step.isCompleted);
|
|
||||||
if (canAccessStep(stepIndex, completedSteps)) {
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
currentStep: stepIndex,
|
|
||||||
}));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}, [state.steps]);
|
|
||||||
|
|
||||||
const getCurrentStep = useCallback((): OnboardingStep => {
|
|
||||||
return state.steps[state.currentStep];
|
|
||||||
}, [state.currentStep, state.steps]);
|
|
||||||
|
|
||||||
const getProgress = useCallback((): OnboardingProgress => {
|
|
||||||
const completedSteps = state.steps.map(step => step.isCompleted);
|
|
||||||
const progress = calculateProgress(completedSteps);
|
|
||||||
|
|
||||||
return {
|
|
||||||
currentStep: state.currentStep,
|
|
||||||
totalSteps: state.steps.length,
|
|
||||||
completedSteps: progress.completedCount,
|
|
||||||
isComplete: progress.completedCount === progress.totalRequired,
|
|
||||||
progressPercentage: progress.percentage,
|
|
||||||
};
|
|
||||||
}, [state.currentStep, state.steps]);
|
|
||||||
|
|
||||||
const canNavigateToStep = useCallback((stepIndex: number): boolean => {
|
|
||||||
const completedSteps = state.steps.map(step => step.isCompleted);
|
|
||||||
return canAccessStep(stepIndex, completedSteps);
|
|
||||||
}, [state.steps]);
|
|
||||||
|
|
||||||
const markStepCompleted = useCallback((stepIndex: number): void => {
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
steps: prev.steps.map((step, index) =>
|
|
||||||
index === stepIndex
|
|
||||||
? { ...step, isCompleted: true }
|
|
||||||
: step
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const resetFlow = useCallback((): void => {
|
|
||||||
setState({
|
|
||||||
currentStep: 0,
|
|
||||||
steps: DEFAULT_STEPS.map(step => ({ ...step, isCompleted: false })),
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
// State
|
|
||||||
currentStep: state.currentStep,
|
|
||||||
steps: state.steps,
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
nextStep,
|
|
||||||
previousStep,
|
|
||||||
goToStep,
|
|
||||||
getCurrentStep,
|
|
||||||
getProgress,
|
|
||||||
canNavigateToStep,
|
|
||||||
markStepCompleted,
|
|
||||||
resetFlow,
|
|
||||||
} satisfies OnboardingFlowState & OnboardingFlowActions;
|
|
||||||
};
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
/**
|
|
||||||
* Tenant creation hook for bakery registration
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
|
||||||
import { useRegisterBakery } from '../../../api';
|
|
||||||
import type { BakeryRegistration, TenantResponse } from '../../../api';
|
|
||||||
import { useTenantStore } from '../../../stores/tenant.store';
|
|
||||||
|
|
||||||
interface TenantCreationState {
|
|
||||||
isLoading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
isSuccess: boolean;
|
|
||||||
tenantData: BakeryRegistration | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TenantCreationActions {
|
|
||||||
createTenant: (bakeryData: BakeryRegistration) => Promise<boolean>;
|
|
||||||
clearError: () => void;
|
|
||||||
reset: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useTenantCreation = () => {
|
|
||||||
const [state, setState] = useState<TenantCreationState>({
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
isSuccess: false,
|
|
||||||
tenantData: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const registerBakeryMutation = useRegisterBakery();
|
|
||||||
const { setCurrentTenant, loadUserTenants } = useTenantStore();
|
|
||||||
|
|
||||||
const createTenant = useCallback(async (bakeryData: BakeryRegistration): Promise<boolean> => {
|
|
||||||
if (!bakeryData) {
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
error: 'Los datos de la panadería son requeridos',
|
|
||||||
}));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
isLoading: true,
|
|
||||||
error: null,
|
|
||||||
isSuccess: false
|
|
||||||
}));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const tenantResponse: TenantResponse = await registerBakeryMutation.mutateAsync(bakeryData);
|
|
||||||
|
|
||||||
// Update the tenant store with the newly created tenant
|
|
||||||
console.log('useTenantCreation - Setting current tenant:', tenantResponse);
|
|
||||||
setCurrentTenant(tenantResponse);
|
|
||||||
|
|
||||||
// Reload user tenants to ensure the list is up to date
|
|
||||||
await loadUserTenants();
|
|
||||||
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
isLoading: false,
|
|
||||||
isSuccess: true,
|
|
||||||
tenantData: bakeryData,
|
|
||||||
}));
|
|
||||||
|
|
||||||
console.log('useTenantCreation - Tenant created and set successfully');
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Error al crear la panadería';
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
isLoading: false,
|
|
||||||
error: errorMessage,
|
|
||||||
isSuccess: false,
|
|
||||||
}));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, [registerBakeryMutation]);
|
|
||||||
|
|
||||||
const clearError = useCallback(() => {
|
|
||||||
setState(prev => ({ ...prev, error: null }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
|
||||||
setState({
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
isSuccess: false,
|
|
||||||
tenantData: null,
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
// State
|
|
||||||
isLoading: state.isLoading,
|
|
||||||
error: state.error,
|
|
||||||
isSuccess: state.isSuccess,
|
|
||||||
tenantData: state.tenantData,
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
createTenant,
|
|
||||||
clearError,
|
|
||||||
reset,
|
|
||||||
} satisfies TenantCreationState & TenantCreationActions;
|
|
||||||
};
|
|
||||||
@@ -1,302 +0,0 @@
|
|||||||
/**
|
|
||||||
* ML model training orchestration hook
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useState, useCallback, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
useCreateTrainingJob,
|
|
||||||
useTrainingJobStatus,
|
|
||||||
useTrainingWebSocket,
|
|
||||||
} from '../../../api';
|
|
||||||
import { useCurrentTenant } from '../../../stores';
|
|
||||||
import { useAuthUser } from '../../../stores/auth.store';
|
|
||||||
import type { TrainingJobResponse, TrainingMetrics } from '../../../api';
|
|
||||||
|
|
||||||
interface TrainingOrchestrationState {
|
|
||||||
isLoading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
status: 'idle' | 'validating' | 'training' | 'completed' | 'failed';
|
|
||||||
progress: number;
|
|
||||||
currentStep: string;
|
|
||||||
estimatedTimeRemaining: number;
|
|
||||||
job: TrainingJobResponse | null;
|
|
||||||
logs: TrainingLog[];
|
|
||||||
metrics: TrainingMetrics | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TrainingLog {
|
|
||||||
timestamp: string;
|
|
||||||
message: string;
|
|
||||||
level: 'info' | 'warning' | 'error' | 'success';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TrainingOrchestrationActions {
|
|
||||||
startTraining: (options?: {
|
|
||||||
products?: string[];
|
|
||||||
startDate?: string;
|
|
||||||
endDate?: string;
|
|
||||||
}) => Promise<boolean>;
|
|
||||||
validateTrainingData: (allStepData: any) => Promise<{
|
|
||||||
isValid: boolean;
|
|
||||||
missingItems: string[];
|
|
||||||
}>;
|
|
||||||
clearError: () => void;
|
|
||||||
reset: () => void;
|
|
||||||
addLog: (message: string, level?: TrainingLog['level']) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useTrainingOrchestration = () => {
|
|
||||||
const [state, setState] = useState<TrainingOrchestrationState>({
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
status: 'idle',
|
|
||||||
progress: 0,
|
|
||||||
currentStep: '',
|
|
||||||
estimatedTimeRemaining: 0,
|
|
||||||
job: null,
|
|
||||||
logs: [],
|
|
||||||
metrics: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const currentTenant = useCurrentTenant();
|
|
||||||
const user = useAuthUser();
|
|
||||||
const createTrainingJobMutation = useCreateTrainingJob();
|
|
||||||
|
|
||||||
// Get job status when we have a job ID
|
|
||||||
const { data: jobStatus } = useTrainingJobStatus(
|
|
||||||
currentTenant?.id || '',
|
|
||||||
state.job?.job_id || '',
|
|
||||||
{
|
|
||||||
enabled: !!currentTenant?.id && !!state.job?.job_id && state.status === 'training',
|
|
||||||
refetchInterval: 5000,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// WebSocket for real-time updates
|
|
||||||
const { data: wsData } = useTrainingWebSocket(
|
|
||||||
currentTenant?.id || '',
|
|
||||||
state.job?.job_id || '',
|
|
||||||
user?.token,
|
|
||||||
{
|
|
||||||
onProgress: (data) => {
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
progress: data.progress?.percentage || prev.progress,
|
|
||||||
currentStep: data.progress?.current_step || prev.currentStep,
|
|
||||||
estimatedTimeRemaining: data.progress?.estimated_time_remaining || prev.estimatedTimeRemaining,
|
|
||||||
}));
|
|
||||||
addLog(
|
|
||||||
`${data.progress?.current_step} - ${data.progress?.products_completed}/${data.progress?.products_total} productos procesados (${data.progress?.percentage}%)`,
|
|
||||||
'info'
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onCompleted: (data) => {
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
status: 'completed',
|
|
||||||
progress: 100,
|
|
||||||
metrics: {
|
|
||||||
accuracy: data.results.performance_metrics.accuracy,
|
|
||||||
mape: data.results.performance_metrics.mape,
|
|
||||||
mae: data.results.performance_metrics.mae,
|
|
||||||
rmse: data.results.performance_metrics.rmse,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
addLog('¡Entrenamiento ML completado exitosamente!', 'success');
|
|
||||||
addLog(`${data.results.successful_trainings} modelos creados exitosamente`, 'success');
|
|
||||||
addLog(`Duración total: ${Math.round(data.results.training_duration / 60)} minutos`, 'info');
|
|
||||||
},
|
|
||||||
onError: (data) => {
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
status: 'failed',
|
|
||||||
error: data.error,
|
|
||||||
}));
|
|
||||||
addLog(`Error en entrenamiento: ${data.error}`, 'error');
|
|
||||||
},
|
|
||||||
onStarted: (data) => {
|
|
||||||
addLog(`Trabajo de entrenamiento iniciado: ${data.job_id}`, 'success');
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update status from polling when WebSocket is not available
|
|
||||||
useEffect(() => {
|
|
||||||
if (jobStatus && state.job?.job_id === jobStatus.job_id) {
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
status: jobStatus.status as any,
|
|
||||||
progress: jobStatus.progress || prev.progress,
|
|
||||||
currentStep: jobStatus.current_step || prev.currentStep,
|
|
||||||
estimatedTimeRemaining: jobStatus.estimated_time_remaining || prev.estimatedTimeRemaining,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, [jobStatus, state.job?.job_id]);
|
|
||||||
|
|
||||||
const addLog = useCallback((message: string, level: TrainingLog['level'] = 'info') => {
|
|
||||||
const newLog: TrainingLog = {
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
message,
|
|
||||||
level
|
|
||||||
};
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
logs: [...prev.logs, newLog]
|
|
||||||
}));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const validateTrainingData = useCallback(async (allStepData: any): Promise<{
|
|
||||||
isValid: boolean;
|
|
||||||
missingItems: string[];
|
|
||||||
}> => {
|
|
||||||
const missingItems: string[] = [];
|
|
||||||
|
|
||||||
// Get data from previous steps
|
|
||||||
const salesValidationData = allStepData?.['sales-validation'];
|
|
||||||
const inventoryData = allStepData?.['inventory'];
|
|
||||||
|
|
||||||
// Check if sales data was processed
|
|
||||||
const hasProcessingResults = salesValidationData?.processingResults &&
|
|
||||||
salesValidationData.processingResults.is_valid &&
|
|
||||||
salesValidationData.processingResults.total_records > 0;
|
|
||||||
|
|
||||||
// Check if sales data was imported (required for training)
|
|
||||||
const hasImportResults = inventoryData?.salesImportResult &&
|
|
||||||
(inventoryData.salesImportResult.records_created > 0 ||
|
|
||||||
inventoryData.salesImportResult.success === true ||
|
|
||||||
inventoryData.salesImportResult.imported === true);
|
|
||||||
|
|
||||||
if (!hasProcessingResults) {
|
|
||||||
missingItems.push('Datos de ventas validados');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sales data must be imported for ML training to work
|
|
||||||
if (!hasImportResults) {
|
|
||||||
missingItems.push('Datos de ventas importados');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if products were approved in sales validation step
|
|
||||||
const hasApprovedProducts = salesValidationData?.approvedProducts &&
|
|
||||||
salesValidationData.approvedProducts.length > 0 &&
|
|
||||||
salesValidationData.reviewCompleted;
|
|
||||||
|
|
||||||
if (!hasApprovedProducts) {
|
|
||||||
missingItems.push('Productos aprobados en revisión');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if inventory was configured
|
|
||||||
const hasInventoryConfig = inventoryData?.inventoryConfigured &&
|
|
||||||
inventoryData?.inventoryItems &&
|
|
||||||
inventoryData.inventoryItems.length > 0;
|
|
||||||
|
|
||||||
if (!hasInventoryConfig) {
|
|
||||||
missingItems.push('Inventario configurado');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we have enough data for training
|
|
||||||
if (salesValidationData?.processingResults?.total_records &&
|
|
||||||
salesValidationData.processingResults.total_records < 10) {
|
|
||||||
missingItems.push('Suficientes registros de ventas (mínimo 10)');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
isValid: missingItems.length === 0,
|
|
||||||
missingItems
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const startTraining = useCallback(async (options?: {
|
|
||||||
products?: string[];
|
|
||||||
startDate?: string;
|
|
||||||
endDate?: string;
|
|
||||||
}): Promise<boolean> => {
|
|
||||||
if (!currentTenant?.id) {
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
error: 'No se pudo obtener información del tenant',
|
|
||||||
}));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
isLoading: true,
|
|
||||||
error: null,
|
|
||||||
status: 'validating',
|
|
||||||
progress: 0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
addLog('Validando disponibilidad de datos...', 'info');
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Start training job
|
|
||||||
addLog('Iniciando trabajo de entrenamiento ML...', 'info');
|
|
||||||
const job = await createTrainingJobMutation.mutateAsync({
|
|
||||||
tenantId: currentTenant.id,
|
|
||||||
request: {
|
|
||||||
products: options?.products,
|
|
||||||
start_date: options?.startDate,
|
|
||||||
end_date: options?.endDate,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
job,
|
|
||||||
status: 'training',
|
|
||||||
isLoading: false,
|
|
||||||
}));
|
|
||||||
|
|
||||||
addLog(`Trabajo de entrenamiento iniciado: ${job.job_id}`, 'success');
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Error al iniciar entrenamiento';
|
|
||||||
setState(prev => ({
|
|
||||||
...prev,
|
|
||||||
isLoading: false,
|
|
||||||
error: errorMessage,
|
|
||||||
status: 'failed',
|
|
||||||
}));
|
|
||||||
addLog(`Error: ${errorMessage}`, 'error');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, [currentTenant, createTrainingJobMutation, addLog]);
|
|
||||||
|
|
||||||
const clearError = useCallback(() => {
|
|
||||||
setState(prev => ({ ...prev, error: null }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
|
||||||
setState({
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
status: 'idle',
|
|
||||||
progress: 0,
|
|
||||||
currentStep: '',
|
|
||||||
estimatedTimeRemaining: 0,
|
|
||||||
job: null,
|
|
||||||
logs: [],
|
|
||||||
metrics: null,
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
// State
|
|
||||||
isLoading: state.isLoading,
|
|
||||||
error: state.error,
|
|
||||||
status: state.status,
|
|
||||||
progress: state.progress,
|
|
||||||
currentStep: state.currentStep,
|
|
||||||
estimatedTimeRemaining: state.estimatedTimeRemaining,
|
|
||||||
job: state.job,
|
|
||||||
logs: state.logs,
|
|
||||||
metrics: state.metrics,
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
startTraining,
|
|
||||||
validateTrainingData,
|
|
||||||
clearError,
|
|
||||||
reset,
|
|
||||||
addLog,
|
|
||||||
} satisfies TrainingOrchestrationState & TrainingOrchestrationActions;
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* Universal service hook factory - creates standardized service hooks
|
||||||
|
* This eliminates all the duplicate patterns across service hooks
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import type { ServiceState, ServiceActions } from '../core/types';
|
||||||
|
|
||||||
|
export interface ServiceHookConfig<T> {
|
||||||
|
initialState?: Partial<T>;
|
||||||
|
onSuccess?: (data: any, state: T) => T;
|
||||||
|
onError?: (error: string, state: T) => T;
|
||||||
|
resetState?: () => T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServiceHook<TState extends ServiceState, TActions extends ServiceActions = ServiceActions>(
|
||||||
|
config: ServiceHookConfig<TState> = {}
|
||||||
|
) {
|
||||||
|
return function useService() {
|
||||||
|
const defaultState: ServiceState = {
|
||||||
|
data: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
isSuccess: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [state, setState] = useState<TState>({
|
||||||
|
...defaultState,
|
||||||
|
...config.initialState,
|
||||||
|
} as TState);
|
||||||
|
|
||||||
|
const setLoading = useCallback((loading: boolean) => {
|
||||||
|
setState(prev => ({ ...prev, isLoading: loading }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setError = useCallback((error: string | null) => {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
error,
|
||||||
|
isLoading: false,
|
||||||
|
isSuccess: false
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setSuccess = useCallback((data: any) => {
|
||||||
|
setState(prev => {
|
||||||
|
const newState = {
|
||||||
|
...prev,
|
||||||
|
data,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
isSuccess: true
|
||||||
|
};
|
||||||
|
return config.onSuccess ? config.onSuccess(data, newState) : newState;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearError = useCallback(() => {
|
||||||
|
setState(prev => ({ ...prev, error: null }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
if (config.resetState) {
|
||||||
|
setState(config.resetState());
|
||||||
|
} else {
|
||||||
|
setState({
|
||||||
|
...defaultState,
|
||||||
|
...config.initialState,
|
||||||
|
} as TState);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const executeAsync = useCallback(async <T>(
|
||||||
|
asyncFn: () => Promise<T>
|
||||||
|
): Promise<{ success: boolean; data?: T; error?: string }> => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const result = await asyncFn();
|
||||||
|
setSuccess(result);
|
||||||
|
return { success: true, data: result };
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
setError(errorMessage);
|
||||||
|
return { success: false, error: errorMessage };
|
||||||
|
}
|
||||||
|
}, [setLoading, setSuccess, setError]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
...state,
|
||||||
|
|
||||||
|
// Core actions
|
||||||
|
setLoading,
|
||||||
|
setError,
|
||||||
|
setSuccess,
|
||||||
|
clearError,
|
||||||
|
reset,
|
||||||
|
|
||||||
|
// Async execution helper
|
||||||
|
executeAsync,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
import React, { 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 { useOnboarding } from '../../../hooks/business/onboarding';
|
import { useOnboarding, useAutoResume } from '../../../hooks/business/onboarding';
|
||||||
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';
|
||||||
|
|
||||||
// Step Components
|
// Step Components
|
||||||
import { BakerySetupStep } from '../../../components/domain/onboarding/steps/BakerySetupStep';
|
import { BakerySetupStep } from '../../../components/domain/onboarding/steps/BakerySetupStep';
|
||||||
import { HistoricalSalesValidationStep } from '../../../components/domain/onboarding/steps/HistoricalSalesValidationStep';
|
import { SmartInventorySetupStep } from '../../../components/domain/onboarding/steps/SmartInventorySetupStep';
|
||||||
import { InventorySetupStep } from '../../../components/domain/onboarding/steps/InventorySetupStep';
|
|
||||||
import { SuppliersStep } from '../../../components/domain/onboarding/steps/SuppliersStep';
|
import { SuppliersStep } from '../../../components/domain/onboarding/steps/SuppliersStep';
|
||||||
import { MLTrainingStep } from '../../../components/domain/onboarding/steps/MLTrainingStep';
|
import { MLTrainingStep } from '../../../components/domain/onboarding/steps/MLTrainingStep';
|
||||||
import { CompletionStep } from '../../../components/domain/onboarding/steps/CompletionStep';
|
import { CompletionStep } from '../../../components/domain/onboarding/steps/CompletionStep';
|
||||||
@@ -18,6 +17,9 @@ const OnboardingPage: React.FC = () => {
|
|||||||
const user = useAuthUser();
|
const user = useAuthUser();
|
||||||
const isAuthenticated = useIsAuthenticated();
|
const isAuthenticated = useIsAuthenticated();
|
||||||
|
|
||||||
|
// Use auto-resume functionality
|
||||||
|
const autoResume = useAutoResume();
|
||||||
|
|
||||||
// Use the onboarding business hook
|
// Use the onboarding business hook
|
||||||
const {
|
const {
|
||||||
currentStep,
|
currentStep,
|
||||||
@@ -42,8 +44,7 @@ const OnboardingPage: React.FC = () => {
|
|||||||
// Map steps to components
|
// Map steps to components
|
||||||
const stepComponents: { [key: string]: React.ComponentType<any> } = {
|
const stepComponents: { [key: string]: React.ComponentType<any> } = {
|
||||||
'setup': BakerySetupStep,
|
'setup': BakerySetupStep,
|
||||||
'sales-validation': HistoricalSalesValidationStep,
|
'smart-inventory-setup': SmartInventorySetupStep,
|
||||||
'inventory': InventorySetupStep,
|
|
||||||
'suppliers': SuppliersStep,
|
'suppliers': SuppliersStep,
|
||||||
'ml-training': MLTrainingStep,
|
'ml-training': MLTrainingStep,
|
||||||
'completion': CompletionStep
|
'completion': CompletionStep
|
||||||
@@ -104,11 +105,15 @@ const OnboardingPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}, [error, clearError]);
|
}, [error, clearError]);
|
||||||
|
|
||||||
// Show loading while processing
|
// Show loading while processing or checking for saved progress
|
||||||
if (isLoading) {
|
if (isLoading || autoResume.isCheckingResume) {
|
||||||
|
const message = autoResume.isCheckingResume
|
||||||
|
? "Verificando progreso guardado..."
|
||||||
|
: "Procesando...";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
<LoadingSpinner size="lg" message="Procesando..." />
|
<LoadingSpinner size="lg" message={message} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,20 +38,22 @@ class UpdateStepRequest(BaseModel):
|
|||||||
completed: bool
|
completed: bool
|
||||||
data: Optional[Dict[str, Any]] = None
|
data: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
# Define the onboarding steps and their order
|
# Define the onboarding steps and their order - matching frontend step IDs
|
||||||
ONBOARDING_STEPS = [
|
ONBOARDING_STEPS = [
|
||||||
"user_registered", # Step 1: User account created
|
"user_registered", # Auto-completed: User account created
|
||||||
"bakery_registered", # Step 2: Bakery/tenant created
|
"setup", # Step 1: Basic bakery setup and tenant creation
|
||||||
"sales_data_uploaded", # Step 3: Historical sales data uploaded
|
"smart-inventory-setup", # Step 2: Sales data upload and inventory configuration
|
||||||
"training_completed", # Step 4: AI model training completed
|
"suppliers", # Step 3: Suppliers configuration (optional)
|
||||||
"dashboard_accessible" # Step 5: Ready to use dashboard
|
"ml-training", # Step 4: AI model training
|
||||||
|
"completion" # Step 5: Onboarding completed, ready to use dashboard
|
||||||
]
|
]
|
||||||
|
|
||||||
STEP_DEPENDENCIES = {
|
STEP_DEPENDENCIES = {
|
||||||
"bakery_registered": ["user_registered"],
|
"setup": ["user_registered"],
|
||||||
"sales_data_uploaded": ["user_registered", "bakery_registered"],
|
"smart-inventory-setup": ["user_registered", "setup"],
|
||||||
"training_completed": ["user_registered", "bakery_registered", "sales_data_uploaded"],
|
"suppliers": ["user_registered", "setup", "smart-inventory-setup"], # Optional step
|
||||||
"dashboard_accessible": ["user_registered", "bakery_registered", "sales_data_uploaded", "training_completed"]
|
"ml-training": ["user_registered", "setup", "smart-inventory-setup"],
|
||||||
|
"completion": ["user_registered", "setup", "smart-inventory-setup", "ml-training"]
|
||||||
}
|
}
|
||||||
|
|
||||||
class OnboardingService:
|
class OnboardingService:
|
||||||
@@ -216,6 +218,30 @@ class OnboardingService:
|
|||||||
if not user_progress_data.get(required_step, {}).get("completed", False):
|
if not user_progress_data.get(required_step, {}).get("completed", False):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# SPECIAL VALIDATION FOR ML TRAINING STEP
|
||||||
|
if step_name == "ml-training":
|
||||||
|
# Ensure that smart-inventory-setup was completed with sales data imported
|
||||||
|
smart_inventory_data = user_progress_data.get("smart-inventory-setup", {}).get("data", {})
|
||||||
|
|
||||||
|
# Check if sales data was imported successfully
|
||||||
|
sales_import_result = smart_inventory_data.get("salesImportResult", {})
|
||||||
|
has_sales_data_imported = (
|
||||||
|
sales_import_result.get("records_created", 0) > 0 or
|
||||||
|
sales_import_result.get("success", False) or
|
||||||
|
sales_import_result.get("imported", False)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not has_sales_data_imported:
|
||||||
|
logger.warning(f"ML training blocked for user {user_id}: No sales data imported",
|
||||||
|
extra={"sales_import_result": sales_import_result})
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Also check if inventory is configured
|
||||||
|
inventory_configured = smart_inventory_data.get("inventoryConfigured", False)
|
||||||
|
if not inventory_configured:
|
||||||
|
logger.warning(f"ML training blocked for user {user_id}: Inventory not configured")
|
||||||
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def _get_user_onboarding_data(self, user_id: str) -> Dict[str, Any]:
|
async def _get_user_onboarding_data(self, user_id: str) -> Dict[str, Any]:
|
||||||
|
|||||||
@@ -25,9 +25,9 @@ ENV PYTHONPATH=/app
|
|||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# Health check
|
# Health check
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||||
CMD python -c "import requests; requests.get('http://localhost:8000/health', timeout=5)" || exit 1
|
CMD python -c "import requests; requests.get('http://localhost:8000/health/', timeout=5)" || exit 1
|
||||||
|
|
||||||
# Run the application
|
# Run the application
|
||||||
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -79,22 +79,25 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
|
|||||||
query = """
|
query = """
|
||||||
WITH stock_analysis AS (
|
WITH stock_analysis AS (
|
||||||
SELECT
|
SELECT
|
||||||
i.*,
|
i.id, i.name, i.tenant_id,
|
||||||
COALESCE(p.scheduled_quantity, 0) as tomorrow_needed,
|
COALESCE(SUM(s.current_quantity), 0) as current_stock,
|
||||||
COALESCE(s.avg_daily_usage, 0) as avg_daily_usage,
|
i.low_stock_threshold as minimum_stock,
|
||||||
COALESCE(s.lead_time_days, 7) as lead_time_days,
|
i.max_stock_level as maximum_stock,
|
||||||
|
i.reorder_point,
|
||||||
|
0 as tomorrow_needed,
|
||||||
|
0 as avg_daily_usage,
|
||||||
|
7 as lead_time_days,
|
||||||
CASE
|
CASE
|
||||||
WHEN i.current_stock < i.minimum_stock THEN 'critical'
|
WHEN COALESCE(SUM(s.current_quantity), 0) < i.low_stock_threshold THEN 'critical'
|
||||||
WHEN i.current_stock < i.minimum_stock * 1.2 THEN 'low'
|
WHEN COALESCE(SUM(s.current_quantity), 0) < i.low_stock_threshold * 1.2 THEN 'low'
|
||||||
WHEN i.current_stock > i.maximum_stock THEN 'overstock'
|
WHEN i.max_stock_level IS NOT NULL AND COALESCE(SUM(s.current_quantity), 0) > i.max_stock_level THEN 'overstock'
|
||||||
ELSE 'normal'
|
ELSE 'normal'
|
||||||
END as status,
|
END as status,
|
||||||
GREATEST(0, i.minimum_stock - i.current_stock) as shortage_amount
|
GREATEST(0, i.low_stock_threshold - COALESCE(SUM(s.current_quantity), 0)) as shortage_amount
|
||||||
FROM inventory_items i
|
FROM ingredients i
|
||||||
LEFT JOIN production_schedule p ON p.ingredient_id = i.id
|
LEFT JOIN stock s ON s.ingredient_id = i.id AND s.is_available = true
|
||||||
AND p.date = CURRENT_DATE + INTERVAL '1 day'
|
WHERE i.tenant_id = $1 AND i.is_active = true
|
||||||
LEFT JOIN supplier_items s ON s.ingredient_id = i.id
|
GROUP BY i.id, i.name, i.tenant_id, i.low_stock_threshold, i.max_stock_level, i.reorder_point
|
||||||
WHERE i.tenant_id = $1 AND i.active = true
|
|
||||||
)
|
)
|
||||||
SELECT * FROM stock_analysis WHERE status != 'normal'
|
SELECT * FROM stock_analysis WHERE status != 'normal'
|
||||||
ORDER BY
|
ORDER BY
|
||||||
@@ -212,15 +215,16 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
|
|||||||
|
|
||||||
query = """
|
query = """
|
||||||
SELECT
|
SELECT
|
||||||
i.id, i.name, i.current_stock, i.tenant_id,
|
i.id, i.name, i.tenant_id,
|
||||||
b.id as batch_id, b.expiry_date, b.quantity,
|
s.id as stock_id, s.expiration_date, s.current_quantity,
|
||||||
EXTRACT(days FROM (b.expiry_date - CURRENT_DATE)) as days_to_expiry
|
EXTRACT(days FROM (s.expiration_date - CURRENT_DATE)) as days_to_expiry
|
||||||
FROM inventory_items i
|
FROM ingredients i
|
||||||
JOIN inventory_batches b ON b.ingredient_id = i.id
|
JOIN stock s ON s.ingredient_id = i.id
|
||||||
WHERE b.expiry_date <= CURRENT_DATE + INTERVAL '7 days'
|
WHERE s.expiration_date <= CURRENT_DATE + INTERVAL '7 days'
|
||||||
AND b.quantity > 0
|
AND s.current_quantity > 0
|
||||||
AND b.status = 'active'
|
AND s.is_available = true
|
||||||
ORDER BY b.expiry_date ASC
|
AND s.expiration_date IS NOT NULL
|
||||||
|
ORDER BY s.expiration_date ASC
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
@@ -275,8 +279,8 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
|
|||||||
{
|
{
|
||||||
'id': str(item['id']),
|
'id': str(item['id']),
|
||||||
'name': item['name'],
|
'name': item['name'],
|
||||||
'batch_id': str(item['batch_id']),
|
'stock_id': str(item['stock_id']),
|
||||||
'quantity': float(item['quantity']),
|
'quantity': float(item['current_quantity']),
|
||||||
'days_expired': abs(item['days_to_expiry'])
|
'days_expired': abs(item['days_to_expiry'])
|
||||||
} for item in expired
|
} for item in expired
|
||||||
]
|
]
|
||||||
@@ -294,9 +298,9 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
|
|||||||
'actions': ['Usar inmediatamente', 'Promoción especial', 'Revisar recetas', 'Documentar'],
|
'actions': ['Usar inmediatamente', 'Promoción especial', 'Revisar recetas', 'Documentar'],
|
||||||
'metadata': {
|
'metadata': {
|
||||||
'ingredient_id': str(item['id']),
|
'ingredient_id': str(item['id']),
|
||||||
'batch_id': str(item['batch_id']),
|
'stock_id': str(item['stock_id']),
|
||||||
'days_to_expiry': item['days_to_expiry'],
|
'days_to_expiry': item['days_to_expiry'],
|
||||||
'quantity': float(item['quantity'])
|
'quantity': float(item['current_quantity'])
|
||||||
}
|
}
|
||||||
}, item_type='alert')
|
}, item_type='alert')
|
||||||
|
|
||||||
@@ -312,14 +316,16 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
|
|||||||
|
|
||||||
query = """
|
query = """
|
||||||
SELECT
|
SELECT
|
||||||
t.id, t.sensor_id, t.location, t.temperature,
|
t.id, t.equipment_id as sensor_id, t.storage_location as location,
|
||||||
t.max_threshold, t.tenant_id,
|
t.temperature_celsius as temperature,
|
||||||
EXTRACT(minutes FROM (NOW() - t.first_breach_time)) as breach_duration_minutes
|
t.target_temperature_max as max_threshold, t.tenant_id,
|
||||||
FROM temperature_readings t
|
COALESCE(t.deviation_minutes, 0) as breach_duration_minutes
|
||||||
WHERE t.temperature > t.max_threshold
|
FROM temperature_logs t
|
||||||
AND t.breach_duration_minutes >= 30 -- Only after 30 minutes
|
WHERE t.temperature_celsius > COALESCE(t.target_temperature_max, 25)
|
||||||
AND t.last_alert_sent < NOW() - INTERVAL '15 minutes' -- Avoid spam
|
AND NOT t.is_within_range
|
||||||
ORDER BY t.temperature DESC, t.breach_duration_minutes DESC
|
AND COALESCE(t.deviation_minutes, 0) >= 30 -- Only after 30 minutes
|
||||||
|
AND (t.recorded_at < NOW() - INTERVAL '15 minutes' OR t.alert_triggered = false) -- Avoid spam
|
||||||
|
ORDER BY t.temperature_celsius DESC, t.deviation_minutes DESC
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
@@ -371,11 +377,14 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
|
|||||||
}
|
}
|
||||||
}, item_type='alert')
|
}, item_type='alert')
|
||||||
|
|
||||||
# Update last alert sent time to avoid spam
|
# Update alert triggered flag to avoid spam
|
||||||
await self.db_manager.execute(
|
from sqlalchemy import text
|
||||||
"UPDATE temperature_readings SET last_alert_sent = NOW() WHERE id = $1",
|
async with self.db_manager.get_session() as session:
|
||||||
breach['id']
|
await session.execute(
|
||||||
)
|
text("UPDATE temperature_logs SET alert_triggered = true WHERE id = :id"),
|
||||||
|
{"id": breach['id']}
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error processing temperature breach",
|
logger.error("Error processing temperature breach",
|
||||||
@@ -391,24 +400,27 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
|
|||||||
query = """
|
query = """
|
||||||
WITH usage_analysis AS (
|
WITH usage_analysis AS (
|
||||||
SELECT
|
SELECT
|
||||||
i.id, i.name, i.tenant_id, i.minimum_stock, i.maximum_stock,
|
i.id, i.name, i.tenant_id,
|
||||||
i.current_stock,
|
i.low_stock_threshold as minimum_stock,
|
||||||
AVG(sm.quantity) FILTER (WHERE sm.movement_type = 'out'
|
i.max_stock_level as maximum_stock,
|
||||||
|
COALESCE(SUM(s.current_quantity), 0) as current_stock,
|
||||||
|
AVG(sm.quantity) FILTER (WHERE sm.movement_type = 'production_use'
|
||||||
AND sm.created_at > CURRENT_DATE - INTERVAL '30 days') as avg_daily_usage,
|
AND sm.created_at > CURRENT_DATE - INTERVAL '30 days') as avg_daily_usage,
|
||||||
COUNT(sm.id) FILTER (WHERE sm.movement_type = 'out'
|
COUNT(sm.id) FILTER (WHERE sm.movement_type = 'production_use'
|
||||||
AND sm.created_at > CURRENT_DATE - INTERVAL '30 days') as usage_days,
|
AND sm.created_at > CURRENT_DATE - INTERVAL '30 days') as usage_days,
|
||||||
MAX(sm.created_at) FILTER (WHERE sm.movement_type = 'out') as last_used
|
MAX(sm.created_at) FILTER (WHERE sm.movement_type = 'production_use') as last_used
|
||||||
FROM inventory_items i
|
FROM ingredients i
|
||||||
|
LEFT JOIN stock s ON s.ingredient_id = i.id AND s.is_available = true
|
||||||
LEFT JOIN stock_movements sm ON sm.ingredient_id = i.id
|
LEFT JOIN stock_movements sm ON sm.ingredient_id = i.id
|
||||||
WHERE i.active = true AND i.tenant_id = $1
|
WHERE i.is_active = true AND i.tenant_id = $1
|
||||||
GROUP BY i.id
|
GROUP BY i.id, i.name, i.tenant_id, i.low_stock_threshold, i.max_stock_level
|
||||||
HAVING COUNT(sm.id) FILTER (WHERE sm.movement_type = 'out'
|
HAVING COUNT(sm.id) FILTER (WHERE sm.movement_type = 'production_use'
|
||||||
AND sm.created_at > CURRENT_DATE - INTERVAL '30 days') >= 5
|
AND sm.created_at > CURRENT_DATE - INTERVAL '30 days') >= 3
|
||||||
),
|
),
|
||||||
recommendations AS (
|
recommendations AS (
|
||||||
SELECT *,
|
SELECT *,
|
||||||
CASE
|
CASE
|
||||||
WHEN avg_daily_usage * 7 > maximum_stock THEN 'increase_max'
|
WHEN avg_daily_usage * 7 > maximum_stock AND maximum_stock IS NOT NULL THEN 'increase_max'
|
||||||
WHEN avg_daily_usage * 3 < minimum_stock THEN 'decrease_min'
|
WHEN avg_daily_usage * 3 < minimum_stock THEN 'decrease_min'
|
||||||
WHEN current_stock / NULLIF(avg_daily_usage, 0) > 14 THEN 'reduce_stock'
|
WHEN current_stock / NULLIF(avg_daily_usage, 0) > 14 THEN 'reduce_stock'
|
||||||
WHEN avg_daily_usage > 0 AND minimum_stock / avg_daily_usage < 3 THEN 'increase_min'
|
WHEN avg_daily_usage > 0 AND minimum_stock / avg_daily_usage < 3 THEN 'increase_min'
|
||||||
@@ -500,20 +512,21 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
|
|||||||
async def generate_waste_reduction_recommendations(self):
|
async def generate_waste_reduction_recommendations(self):
|
||||||
"""Generate waste reduction recommendations"""
|
"""Generate waste reduction recommendations"""
|
||||||
try:
|
try:
|
||||||
# Analyze waste patterns
|
# Analyze waste patterns from stock movements
|
||||||
query = """
|
query = """
|
||||||
SELECT
|
SELECT
|
||||||
i.id, i.name, i.tenant_id,
|
i.id, i.name, i.tenant_id,
|
||||||
SUM(w.quantity) as total_waste_30d,
|
SUM(sm.quantity) as total_waste_30d,
|
||||||
COUNT(w.id) as waste_incidents,
|
COUNT(sm.id) as waste_incidents,
|
||||||
AVG(w.quantity) as avg_waste_per_incident,
|
AVG(sm.quantity) as avg_waste_per_incident,
|
||||||
w.waste_reason
|
COALESCE(sm.reason_code, 'unknown') as waste_reason
|
||||||
FROM inventory_items i
|
FROM ingredients i
|
||||||
JOIN waste_logs w ON w.ingredient_id = i.id
|
JOIN stock_movements sm ON sm.ingredient_id = i.id
|
||||||
WHERE w.created_at > CURRENT_DATE - INTERVAL '30 days'
|
WHERE sm.movement_type = 'waste'
|
||||||
|
AND sm.created_at > CURRENT_DATE - INTERVAL '30 days'
|
||||||
AND i.tenant_id = $1
|
AND i.tenant_id = $1
|
||||||
GROUP BY i.id, w.waste_reason
|
GROUP BY i.id, i.name, i.tenant_id, sm.reason_code
|
||||||
HAVING SUM(w.quantity) > 5 -- More than 5kg wasted
|
HAVING SUM(sm.quantity) > 5 -- More than 5kg wasted
|
||||||
ORDER BY total_waste_30d DESC
|
ORDER BY total_waste_30d DESC
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -694,10 +707,14 @@ class InventoryAlertService(BaseAlertService, AlertServiceMixin):
|
|||||||
"""Get stock information after hypothetical order"""
|
"""Get stock information after hypothetical order"""
|
||||||
try:
|
try:
|
||||||
query = """
|
query = """
|
||||||
SELECT id, name, current_stock, minimum_stock,
|
SELECT i.id, i.name,
|
||||||
(current_stock - $2) as remaining
|
COALESCE(SUM(s.current_quantity), 0) as current_stock,
|
||||||
FROM inventory_items
|
i.low_stock_threshold as minimum_stock,
|
||||||
WHERE id = $1
|
(COALESCE(SUM(s.current_quantity), 0) - $2) as remaining
|
||||||
|
FROM ingredients i
|
||||||
|
LEFT JOIN stock s ON s.ingredient_id = i.id AND s.is_available = true
|
||||||
|
WHERE i.id = $1
|
||||||
|
GROUP BY i.id, i.name, i.low_stock_threshold
|
||||||
"""
|
"""
|
||||||
|
|
||||||
result = await self.db_manager.fetchrow(query, ingredient_id, order_quantity)
|
result = await self.db_manager.fetchrow(query, ingredient_id, order_quantity)
|
||||||
|
|||||||
Reference in New Issue
Block a user