diff --git a/frontend/src/components/domain/onboarding/UnifiedOnboardingWizard.tsx b/frontend/src/components/domain/onboarding/UnifiedOnboardingWizard.tsx index ed4af6c0..aadde9c3 100644 --- a/frontend/src/components/domain/onboarding/UnifiedOnboardingWizard.tsx +++ b/frontend/src/components/domain/onboarding/UnifiedOnboardingWizard.tsx @@ -10,7 +10,6 @@ import { useTenantInitializer } from '../../../stores/useTenantInitializer'; import { WizardProvider, useWizardContext, BakeryType, DataSource } from './context'; import { BakeryTypeSelectionStep, - DataSourceChoiceStep, RegisterTenantStep, UploadSalesDataStep, ProductCategorizationStep, @@ -22,7 +21,6 @@ import { // Import setup wizard steps import { SuppliersSetupStep, - InventorySetupStep, RecipesSetupStep, QualitySetupStep, TeamSetupStep, @@ -66,14 +64,6 @@ const OnboardingWizardContent: React.FC = () => { description: t('onboarding:steps.bakery_type.description', 'Selecciona tu tipo de negocio'), component: BakeryTypeSelectionStep, }, - { - id: 'data-source-choice', - title: t('onboarding:steps.data_source.title', 'Método de Configuración'), - description: t('onboarding:steps.data_source.description', 'Elige cómo configurar'), - component: DataSourceChoiceStep, - isConditional: true, - condition: (ctx) => ctx.state.bakeryType !== null, - }, // Phase 2: Core Setup { id: 'setup', @@ -81,16 +71,16 @@ const OnboardingWizardContent: React.FC = () => { description: t('onboarding:steps.setup.description', 'Información básica'), component: RegisterTenantStep, isConditional: true, - condition: (ctx) => ctx.state.dataSource !== null, + condition: (ctx) => ctx.state.bakeryType !== null, }, - // Phase 2a: AI-Assisted Path + // Phase 2a: AI-Assisted Path (ONLY PATH NOW) { id: 'smart-inventory-setup', title: t('onboarding:steps.smart_inventory.title', 'Subir Datos de Ventas'), description: t('onboarding:steps.smart_inventory.description', 'Configuración con IA'), component: UploadSalesDataStep, isConditional: true, - condition: (ctx) => ctx.state.dataSource === 'ai-assisted', + condition: (ctx) => ctx.tenantId !== null, }, { id: 'product-categorization', @@ -98,7 +88,7 @@ const OnboardingWizardContent: React.FC = () => { description: t('onboarding:steps.categorization.description', 'Clasifica ingredientes vs productos'), component: ProductCategorizationStep, isConditional: true, - condition: (ctx) => ctx.state.dataSource === 'ai-assisted' && ctx.state.aiAnalysisComplete, + condition: (ctx) => ctx.state.aiAnalysisComplete, }, { id: 'initial-stock-entry', @@ -106,19 +96,7 @@ const OnboardingWizardContent: React.FC = () => { description: t('onboarding:steps.stock.description', 'Cantidades iniciales'), component: InitialStockEntryStep, isConditional: true, - condition: (ctx) => ctx.state.dataSource === 'ai-assisted' && ctx.state.categorizationCompleted, - }, - // Phase 2b: Core Data Entry (Manual Path) - // IMPORTANT: Inventory must come BEFORE suppliers so suppliers can associate products - { - id: 'inventory-setup', - title: t('onboarding:steps.inventory.title', 'Inventario'), - description: t('onboarding:steps.inventory.description', 'Productos e ingredientes'), - component: InventorySetupStep, - isConditional: true, - condition: (ctx) => - // Only show for manual path (AI path creates inventory earlier) - ctx.state.dataSource === 'manual', + condition: (ctx) => ctx.state.categorizationCompleted, }, { id: 'suppliers-setup', @@ -126,10 +104,7 @@ const OnboardingWizardContent: React.FC = () => { description: t('onboarding:steps.suppliers.description', 'Configura tus proveedores'), component: SuppliersSetupStep, isConditional: true, - condition: (ctx) => - // Show after inventory exists (either from AI or manual path) - (ctx.state.dataSource === 'ai-assisted' && ctx.state.stockEntryCompleted) || - (ctx.state.dataSource === 'manual' && ctx.state.inventoryCompleted), + condition: (ctx) => ctx.state.stockEntryCompleted, }, { id: 'recipes-setup', @@ -156,7 +131,7 @@ const OnboardingWizardContent: React.FC = () => { description: t('onboarding:steps.quality.description', 'Estándares de calidad'), component: QualitySetupStep, isConditional: true, - condition: (ctx) => ctx.state.dataSource !== null, + condition: (ctx) => ctx.tenantId !== null, }, { id: 'team-setup', @@ -164,7 +139,7 @@ const OnboardingWizardContent: React.FC = () => { description: t('onboarding:steps.team.description', 'Miembros del equipo'), component: TeamSetupStep, isConditional: true, - condition: (ctx) => ctx.state.dataSource !== null, + condition: (ctx) => ctx.tenantId !== null, }, // Phase 4: ML & Finalization { @@ -173,7 +148,7 @@ const OnboardingWizardContent: React.FC = () => { description: t('onboarding:steps.ml_training.description', 'Modelo personalizado'), component: MLTrainingStep, isConditional: true, - condition: (ctx) => ctx.state.inventoryCompleted || ctx.state.aiAnalysisComplete, + condition: (ctx) => ctx.state.aiAnalysisComplete, }, { id: 'setup-review', @@ -181,7 +156,7 @@ const OnboardingWizardContent: React.FC = () => { description: t('onboarding:steps.review.description', 'Confirma tu configuración'), component: ReviewSetupStep, isConditional: true, - condition: (ctx) => ctx.state.dataSource !== null, + condition: (ctx) => ctx.tenantId !== null, }, { id: 'completion', diff --git a/frontend/src/components/domain/onboarding/context/WizardContext.tsx b/frontend/src/components/domain/onboarding/context/WizardContext.tsx index 5d66b6d3..3f6454af 100644 --- a/frontend/src/components/domain/onboarding/context/WizardContext.tsx +++ b/frontend/src/components/domain/onboarding/context/WizardContext.tsx @@ -61,7 +61,7 @@ export interface WizardContextValue { const initialState: WizardState = { bakeryType: null, - dataSource: null, + dataSource: 'ai-assisted', // Only AI-assisted path supported now aiSuggestions: [], aiAnalysisComplete: false, categorizedProducts: undefined, diff --git a/frontend/src/components/domain/onboarding/steps/UploadSalesDataStep.tsx b/frontend/src/components/domain/onboarding/steps/UploadSalesDataStep.tsx index b206d187..814e0a51 100644 --- a/frontend/src/components/domain/onboarding/steps/UploadSalesDataStep.tsx +++ b/frontend/src/components/domain/onboarding/steps/UploadSalesDataStep.tsx @@ -2,10 +2,12 @@ import React, { useState, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { Button } from '../../../ui/Button'; import { useCurrentTenant } from '../../../../stores/tenant.store'; -import { useCreateIngredient, useClassifyBatch } from '../../../../api/hooks/inventory'; +import { useCreateIngredient, useClassifyBatch, useAddStock } from '../../../../api/hooks/inventory'; import { useValidateImportFile, useImportSalesData } from '../../../../api/hooks/sales'; +import { useSuppliers } from '../../../../api/hooks/suppliers'; import type { ImportValidationResponse } from '../../../../api/types/dataImport'; -import type { ProductSuggestionResponse } from '../../../../api/types/inventory'; +import type { ProductSuggestionResponse, StockCreate, StockResponse } from '../../../../api/types/inventory'; +import { ProductionStage } from '../../../../api/types/inventory'; import { useAuth } from '../../../../contexts/AuthContext'; import { BatchAddIngredientsModal } from '../../inventory/BatchAddIngredientsModal'; @@ -87,10 +89,29 @@ export const UploadSalesDataStep: React.FC = ({ const currentTenant = useCurrentTenant(); const { user } = useAuth(); + const tenantId = currentTenant?.id || ''; + + // API hooks const validateFileMutation = useValidateImportFile(); const createIngredient = useCreateIngredient(); const importMutation = useImportSalesData(); const classifyBatchMutation = useClassifyBatch(); + const addStockMutation = useAddStock(); + + // Fetch suppliers for stock entry + const { data: suppliersData } = useSuppliers(tenantId, { limit: 100 }, { enabled: !!tenantId }); + const suppliers = (suppliersData || []).filter(s => s.status === 'active'); + + // Stock lots state + const [addingStockForId, setAddingStockForId] = useState(null); + const [stockFormData, setStockFormData] = useState({ + current_quantity: '', + expiration_date: '', + supplier_id: '', + batch_number: '', + }); + const [stockErrors, setStockErrors] = useState>({}); + const [ingredientStocks, setIngredientStocks] = useState>({}); const handleFileSelect = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; @@ -311,6 +332,101 @@ export const UploadSalesDataStep: React.FC = ({ setInventoryItems(items => items.filter(item => item.id !== itemId)); }; + // Stock lot handlers + const handleAddStockClick = (ingredientId: string) => { + setAddingStockForId(ingredientId); + setStockFormData({ + current_quantity: '', + expiration_date: '', + supplier_id: suppliers.length === 1 ? suppliers[0].id : '', + batch_number: '', + }); + setStockErrors({}); + }; + + const handleCancelStock = () => { + setAddingStockForId(null); + setStockFormData({ + current_quantity: '', + expiration_date: '', + supplier_id: '', + batch_number: '', + }); + setStockErrors({}); + }; + + const validateStockForm = (): boolean => { + const newErrors: Record = {}; + + if (!stockFormData.current_quantity || Number(stockFormData.current_quantity) <= 0) { + newErrors.current_quantity = 'La cantidad debe ser mayor que cero'; + } + + if (stockFormData.expiration_date) { + const expDate = new Date(stockFormData.expiration_date); + const today = new Date(); + today.setHours(0, 0, 0, 0); + + if (expDate < today) { + newErrors.expiration_date = 'La fecha de caducidad está en el pasado'; + } + + const threeDaysFromNow = new Date(today); + threeDaysFromNow.setDate(threeDaysFromNow.getDate() + 3); + if (expDate < threeDaysFromNow) { + newErrors.expiration_warning = '⚠️ Este ingrediente caduca muy pronto!'; + } + } + + setStockErrors(newErrors); + return Object.keys(newErrors).filter(k => k !== 'expiration_warning').length === 0; + }; + + const handleSaveStockLot = (addAnother: boolean = false) => { + if (!addingStockForId || !validateStockForm()) return; + + // Create a temporary stock lot entry (will be saved when ingredients are created) + const newLot: StockResponse = { + id: `temp-${Date.now()}`, + tenant_id: tenantId, + ingredient_id: addingStockForId, + current_quantity: Number(stockFormData.current_quantity), + expiration_date: stockFormData.expiration_date || undefined, + supplier_id: stockFormData.supplier_id || undefined, + batch_number: stockFormData.batch_number || undefined, + production_stage: ProductionStage.RAW_INGREDIENT, + quality_status: 'good', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + } as StockResponse; + + // Add to local state + setIngredientStocks(prev => ({ + ...prev, + [addingStockForId]: [...(prev[addingStockForId] || []), newLot], + })); + + if (addAnother) { + // Reset form for adding another lot + setStockFormData({ + current_quantity: '', + expiration_date: '', + supplier_id: stockFormData.supplier_id, // Keep supplier selected + batch_number: '', + }); + setStockErrors({}); + } else { + handleCancelStock(); + } + }; + + const handleDeleteStockLot = (ingredientId: string, stockId: string) => { + setIngredientStocks(prev => ({ + ...prev, + [ingredientId]: (prev[ingredientId] || []).filter(s => s.id !== stockId), + })); + }; + // Create all inventory items when Next is clicked const handleNext = async () => { if (inventoryItems.length === 0) { @@ -371,10 +487,55 @@ export const UploadSalesDataStep: React.FC = ({ console.log(`Creados exitosamente ${createdIngredients.length} ingredientes`); + // Create stock lots for ingredients + setProgressState({ + stage: 'creating_stock', + progress: 40, + message: 'Creando lotes de stock...' + }); + + const stockCreationPromises: Promise[] = []; + + createdIngredients.forEach((ingredient) => { + // Find the original UI item to get its temporary ID + const originalItem = inventoryItems.find(item => item.name === ingredient.name); + if (originalItem) { + const lots = ingredientStocks[originalItem.id] || []; + + // Create stock lots for this ingredient + lots.forEach((lot) => { + const stockData: StockCreate = { + ingredient_id: ingredient.id, + current_quantity: lot.current_quantity, + production_stage: ProductionStage.RAW_INGREDIENT, + quality_status: 'good', + expiration_date: lot.expiration_date, + supplier_id: lot.supplier_id, + batch_number: lot.batch_number, + }; + + stockCreationPromises.push( + addStockMutation.mutateAsync({ + tenantId: currentTenant.id, + stockData + }).catch(err => { + console.error(`Error creando lote de stock para ${ingredient.name}:`, err); + return null; + }) + ); + }); + } + }); + + if (stockCreationPromises.length > 0) { + await Promise.allSettled(stockCreationPromises); + console.log(`Creados exitosamente ${stockCreationPromises.length} lotes de stock`); + } + // Import sales data if available setProgressState({ stage: 'importing_sales', - progress: 50, + progress: 60, message: 'Importando datos de ventas...' }); @@ -514,6 +675,159 @@ export const UploadSalesDataStep: React.FC = ({ + + {/* Stock Lots Section */} + {(() => { + const lots = ingredientStocks[item.id] || []; + const isAddingStock = addingStockForId === item.id; + + return ( +
+ {lots.length > 0 && ( +
+
+ + Lotes agregados ({lots.length}) + +
+
+ {lots.map((lot) => ( +
+
+ {lot.current_quantity} {item.unit_of_measure} + {lot.expiration_date && ( + + 📅 Caduca: {new Date(lot.expiration_date).toLocaleDateString('es-ES')} + + )} + {lot.batch_number && ( + + Lote: {lot.batch_number} + + )} +
+ +
+ ))} +
+
+ )} + + {isAddingStock ? ( +
+
+
+
+ + setStockFormData(prev => ({ ...prev, current_quantity: e.target.value }))} + className="w-full px-2 py-1 text-sm border rounded" + placeholder="0" + min="0" + step="0.01" + /> + {stockErrors.current_quantity && ( +

{stockErrors.current_quantity}

+ )} +
+
+ + setStockFormData(prev => ({ ...prev, expiration_date: e.target.value }))} + className="w-full px-2 py-1 text-sm border rounded" + /> + {stockErrors.expiration_date && ( +

{stockErrors.expiration_date}

+ )} + {stockErrors.expiration_warning && ( +

{stockErrors.expiration_warning}

+ )} +
+
+
+
+ + +
+
+ + setStockFormData(prev => ({ ...prev, batch_number: e.target.value }))} + className="w-full px-2 py-1 text-sm border rounded" + placeholder="Opcional" + /> +
+
+
+ 💡 Los lotes con fecha de caducidad se gestionarán automáticamente con FIFO +
+
+ + + +
+
+
+ ) : ( + + )} +
+ ); + })()} ))}