Remove manual path and add inventory lots UI to AI-assisted onboarding

## Architectural Changes

**1. Remove Manual Entry Path**
- Deleted data-source-choice step (DataSourceChoiceStep)
- Removed manual inventory-setup step (InventorySetupStep)
- Removed all manual path conditions from wizard flow
- Set dataSource to 'ai-assisted' by default in WizardContext

Files modified:
- frontend/src/components/domain/onboarding/UnifiedOnboardingWizard.tsx:11-28,61-162
- frontend/src/components/domain/onboarding/context/WizardContext.tsx:64

**2. Add Inventory Lots UI to AI Inventory Step**
Added full stock lot management with expiration tracking to UploadSalesDataStep:

**Features Added:**
- Inline stock lot entry form after each AI-suggested ingredient
- Multi-lot support - add multiple lots per ingredient with different expiration dates
- Fields: quantity*, expiration date, supplier, batch/lot number
- Visual list of added lots with expiration dates
- Delete individual lots before completing
- Smart validation with expiration date warnings
- FIFO help text
- Auto-select supplier if only one exists

**Technical Implementation:**
- Added useAddStock and useSuppliers hooks (lines 5,7,102-103)
- Added stock state management (lines 106-114)
- Stock handler functions (lines 336-428):
  - handleAddStockClick - Opens stock form
  - handleCancelStock - Closes and resets form
  - validateStockForm - Validates quantity and expiration
  - handleSaveStockLot - Saves to local state, supports "Add Another Lot"
  - handleDeleteStockLot - Removes from list
- Modified handleNext to create stock lots after ingredients (lines 490-533)
- Added stock lots UI section in ingredient rendering (lines 679-830)

**UI Flow:**
1. User uploads sales data
2. AI suggests ingredients
3. User reviews/edits ingredients
4. **NEW**: User can optionally add stock lots with expiration dates
5. Click "Next" creates both ingredients AND stock lots
6. FIFO tracking enabled from day one

**Benefits:**
- Addresses JTBD: waste prevention, expiration tracking from onboarding
- Progressive disclosure - optional but encouraged
- Maintains simplicity of AI-assisted path
- Enables inventory best practices from the start

Files modified:
- frontend/src/components/domain/onboarding/steps/UploadSalesDataStep.tsx:1-12,90-114,335-533,679-830

**Build Status:** ✓ Successful in 20.78s
This commit is contained in:
Claude
2025-11-06 21:40:39 +00:00
parent 163d4ba60d
commit 011843dff9
3 changed files with 328 additions and 39 deletions

View File

@@ -10,7 +10,6 @@ import { useTenantInitializer } from '../../../stores/useTenantInitializer';
import { WizardProvider, useWizardContext, BakeryType, DataSource } from './context'; import { WizardProvider, useWizardContext, BakeryType, DataSource } from './context';
import { import {
BakeryTypeSelectionStep, BakeryTypeSelectionStep,
DataSourceChoiceStep,
RegisterTenantStep, RegisterTenantStep,
UploadSalesDataStep, UploadSalesDataStep,
ProductCategorizationStep, ProductCategorizationStep,
@@ -22,7 +21,6 @@ import {
// Import setup wizard steps // Import setup wizard steps
import { import {
SuppliersSetupStep, SuppliersSetupStep,
InventorySetupStep,
RecipesSetupStep, RecipesSetupStep,
QualitySetupStep, QualitySetupStep,
TeamSetupStep, TeamSetupStep,
@@ -66,14 +64,6 @@ const OnboardingWizardContent: React.FC = () => {
description: t('onboarding:steps.bakery_type.description', 'Selecciona tu tipo de negocio'), description: t('onboarding:steps.bakery_type.description', 'Selecciona tu tipo de negocio'),
component: BakeryTypeSelectionStep, 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 // Phase 2: Core Setup
{ {
id: 'setup', id: 'setup',
@@ -81,16 +71,16 @@ const OnboardingWizardContent: React.FC = () => {
description: t('onboarding:steps.setup.description', 'Información básica'), description: t('onboarding:steps.setup.description', 'Información básica'),
component: RegisterTenantStep, component: RegisterTenantStep,
isConditional: true, 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', id: 'smart-inventory-setup',
title: t('onboarding:steps.smart_inventory.title', 'Subir Datos de Ventas'), title: t('onboarding:steps.smart_inventory.title', 'Subir Datos de Ventas'),
description: t('onboarding:steps.smart_inventory.description', 'Configuración con IA'), description: t('onboarding:steps.smart_inventory.description', 'Configuración con IA'),
component: UploadSalesDataStep, component: UploadSalesDataStep,
isConditional: true, isConditional: true,
condition: (ctx) => ctx.state.dataSource === 'ai-assisted', condition: (ctx) => ctx.tenantId !== null,
}, },
{ {
id: 'product-categorization', id: 'product-categorization',
@@ -98,7 +88,7 @@ const OnboardingWizardContent: React.FC = () => {
description: t('onboarding:steps.categorization.description', 'Clasifica ingredientes vs productos'), description: t('onboarding:steps.categorization.description', 'Clasifica ingredientes vs productos'),
component: ProductCategorizationStep, component: ProductCategorizationStep,
isConditional: true, isConditional: true,
condition: (ctx) => ctx.state.dataSource === 'ai-assisted' && ctx.state.aiAnalysisComplete, condition: (ctx) => ctx.state.aiAnalysisComplete,
}, },
{ {
id: 'initial-stock-entry', id: 'initial-stock-entry',
@@ -106,19 +96,7 @@ const OnboardingWizardContent: React.FC = () => {
description: t('onboarding:steps.stock.description', 'Cantidades iniciales'), description: t('onboarding:steps.stock.description', 'Cantidades iniciales'),
component: InitialStockEntryStep, component: InitialStockEntryStep,
isConditional: true, isConditional: true,
condition: (ctx) => ctx.state.dataSource === 'ai-assisted' && ctx.state.categorizationCompleted, condition: (ctx) => 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',
}, },
{ {
id: 'suppliers-setup', id: 'suppliers-setup',
@@ -126,10 +104,7 @@ const OnboardingWizardContent: React.FC = () => {
description: t('onboarding:steps.suppliers.description', 'Configura tus proveedores'), description: t('onboarding:steps.suppliers.description', 'Configura tus proveedores'),
component: SuppliersSetupStep, component: SuppliersSetupStep,
isConditional: true, isConditional: true,
condition: (ctx) => condition: (ctx) => ctx.state.stockEntryCompleted,
// 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),
}, },
{ {
id: 'recipes-setup', id: 'recipes-setup',
@@ -156,7 +131,7 @@ const OnboardingWizardContent: React.FC = () => {
description: t('onboarding:steps.quality.description', 'Estándares de calidad'), description: t('onboarding:steps.quality.description', 'Estándares de calidad'),
component: QualitySetupStep, component: QualitySetupStep,
isConditional: true, isConditional: true,
condition: (ctx) => ctx.state.dataSource !== null, condition: (ctx) => ctx.tenantId !== null,
}, },
{ {
id: 'team-setup', id: 'team-setup',
@@ -164,7 +139,7 @@ const OnboardingWizardContent: React.FC = () => {
description: t('onboarding:steps.team.description', 'Miembros del equipo'), description: t('onboarding:steps.team.description', 'Miembros del equipo'),
component: TeamSetupStep, component: TeamSetupStep,
isConditional: true, isConditional: true,
condition: (ctx) => ctx.state.dataSource !== null, condition: (ctx) => ctx.tenantId !== null,
}, },
// Phase 4: ML & Finalization // Phase 4: ML & Finalization
{ {
@@ -173,7 +148,7 @@ const OnboardingWizardContent: React.FC = () => {
description: t('onboarding:steps.ml_training.description', 'Modelo personalizado'), description: t('onboarding:steps.ml_training.description', 'Modelo personalizado'),
component: MLTrainingStep, component: MLTrainingStep,
isConditional: true, isConditional: true,
condition: (ctx) => ctx.state.inventoryCompleted || ctx.state.aiAnalysisComplete, condition: (ctx) => ctx.state.aiAnalysisComplete,
}, },
{ {
id: 'setup-review', id: 'setup-review',
@@ -181,7 +156,7 @@ const OnboardingWizardContent: React.FC = () => {
description: t('onboarding:steps.review.description', 'Confirma tu configuración'), description: t('onboarding:steps.review.description', 'Confirma tu configuración'),
component: ReviewSetupStep, component: ReviewSetupStep,
isConditional: true, isConditional: true,
condition: (ctx) => ctx.state.dataSource !== null, condition: (ctx) => ctx.tenantId !== null,
}, },
{ {
id: 'completion', id: 'completion',

View File

@@ -61,7 +61,7 @@ export interface WizardContextValue {
const initialState: WizardState = { const initialState: WizardState = {
bakeryType: null, bakeryType: null,
dataSource: null, dataSource: 'ai-assisted', // Only AI-assisted path supported now
aiSuggestions: [], aiSuggestions: [],
aiAnalysisComplete: false, aiAnalysisComplete: false,
categorizedProducts: undefined, categorizedProducts: undefined,

View File

@@ -2,10 +2,12 @@ import React, { useState, useRef } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button } from '../../../ui/Button'; import { Button } from '../../../ui/Button';
import { useCurrentTenant } from '../../../../stores/tenant.store'; 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 { useValidateImportFile, useImportSalesData } from '../../../../api/hooks/sales';
import { useSuppliers } from '../../../../api/hooks/suppliers';
import type { ImportValidationResponse } from '../../../../api/types/dataImport'; 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 { useAuth } from '../../../../contexts/AuthContext';
import { BatchAddIngredientsModal } from '../../inventory/BatchAddIngredientsModal'; import { BatchAddIngredientsModal } from '../../inventory/BatchAddIngredientsModal';
@@ -87,10 +89,29 @@ export const UploadSalesDataStep: React.FC<UploadSalesDataStepProps> = ({
const currentTenant = useCurrentTenant(); const currentTenant = useCurrentTenant();
const { user } = useAuth(); const { user } = useAuth();
const tenantId = currentTenant?.id || '';
// API hooks
const validateFileMutation = useValidateImportFile(); const validateFileMutation = useValidateImportFile();
const createIngredient = useCreateIngredient(); const createIngredient = useCreateIngredient();
const importMutation = useImportSalesData(); const importMutation = useImportSalesData();
const classifyBatchMutation = useClassifyBatch(); 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<string | null>(null);
const [stockFormData, setStockFormData] = useState({
current_quantity: '',
expiration_date: '',
supplier_id: '',
batch_number: '',
});
const [stockErrors, setStockErrors] = useState<Record<string, string>>({});
const [ingredientStocks, setIngredientStocks] = useState<Record<string, StockResponse[]>>({});
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
@@ -311,6 +332,101 @@ export const UploadSalesDataStep: React.FC<UploadSalesDataStepProps> = ({
setInventoryItems(items => items.filter(item => item.id !== itemId)); 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<string, string> = {};
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 // Create all inventory items when Next is clicked
const handleNext = async () => { const handleNext = async () => {
if (inventoryItems.length === 0) { if (inventoryItems.length === 0) {
@@ -371,10 +487,55 @@ export const UploadSalesDataStep: React.FC<UploadSalesDataStepProps> = ({
console.log(`Creados exitosamente ${createdIngredients.length} ingredientes`); 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<any>[] = [];
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 // Import sales data if available
setProgressState({ setProgressState({
stage: 'importing_sales', stage: 'importing_sales',
progress: 50, progress: 60,
message: 'Importando datos de ventas...' message: 'Importando datos de ventas...'
}); });
@@ -514,6 +675,159 @@ export const UploadSalesDataStep: React.FC<UploadSalesDataStepProps> = ({
</button> </button>
</div> </div>
</div> </div>
{/* Stock Lots Section */}
{(() => {
const lots = ingredientStocks[item.id] || [];
const isAddingStock = addingStockForId === item.id;
return (
<div className="mt-4 pt-4 border-t border-[var(--border-secondary)]">
{lots.length > 0 && (
<div className="mb-3">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs font-medium text-[var(--text-secondary)]">
Lotes agregados ({lots.length})
</span>
</div>
<div className="space-y-1">
{lots.map((lot) => (
<div
key={lot.id}
className="flex items-center justify-between p-2 bg-[var(--bg-primary)] rounded text-xs"
>
<div className="flex items-center gap-3">
<span className="font-medium">{lot.current_quantity} {item.unit_of_measure}</span>
{lot.expiration_date && (
<span className="text-[var(--text-tertiary)]">
📅 Caduca: {new Date(lot.expiration_date).toLocaleDateString('es-ES')}
</span>
)}
{lot.batch_number && (
<span className="text-[var(--text-tertiary)]">
Lote: {lot.batch_number}
</span>
)}
</div>
<button
onClick={() => handleDeleteStockLot(item.id, lot.id)}
className="p-1 text-[var(--text-secondary)] hover:text-[var(--color-error)] rounded"
title="Eliminar lote"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
</div>
</div>
)}
{isAddingStock ? (
<div className="p-3 bg-[var(--color-primary)]/5 border-2 border-[var(--color-primary)] rounded-lg">
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-[var(--text-primary)] mb-1">
Cantidad * <span className="text-[var(--text-tertiary)]">({item.unit_of_measure})</span>
</label>
<input
type="number"
value={stockFormData.current_quantity}
onChange={(e) => 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 && (
<p className="text-xs text-[var(--color-error)] mt-1">{stockErrors.current_quantity}</p>
)}
</div>
<div>
<label className="block text-xs font-medium text-[var(--text-primary)] mb-1">
Fecha de caducidad
</label>
<input
type="date"
value={stockFormData.expiration_date}
onChange={(e) => setStockFormData(prev => ({ ...prev, expiration_date: e.target.value }))}
className="w-full px-2 py-1 text-sm border rounded"
/>
{stockErrors.expiration_date && (
<p className="text-xs text-[var(--color-error)] mt-1">{stockErrors.expiration_date}</p>
)}
{stockErrors.expiration_warning && (
<p className="text-xs text-[var(--color-warning)] mt-1">{stockErrors.expiration_warning}</p>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-[var(--text-primary)] mb-1">
Proveedor
</label>
<select
value={stockFormData.supplier_id}
onChange={(e) => setStockFormData(prev => ({ ...prev, supplier_id: e.target.value }))}
className="w-full px-2 py-1 text-sm border rounded"
>
<option value="">Seleccionar...</option>
{suppliers.map(s => (
<option key={s.id} value={s.id}>{s.company_name}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-[var(--text-primary)] mb-1">
Número de lote
</label>
<input
type="text"
value={stockFormData.batch_number}
onChange={(e) => setStockFormData(prev => ({ ...prev, batch_number: e.target.value }))}
className="w-full px-2 py-1 text-sm border rounded"
placeholder="Opcional"
/>
</div>
</div>
<div className="text-xs text-[var(--text-tertiary)] italic">
💡 Los lotes con fecha de caducidad se gestionarán automáticamente con FIFO
</div>
<div className="flex gap-2">
<button
onClick={() => handleSaveStockLot(true)}
className="px-3 py-1 text-xs bg-[var(--bg-secondary)] hover:bg-[var(--bg-tertiary)] border rounded transition-colors"
>
+ Agregar Otro Lote
</button>
<button
onClick={() => handleSaveStockLot(false)}
className="px-3 py-1 text-xs bg-[var(--color-primary)] text-white rounded hover:opacity-90 transition-opacity"
>
Guardar
</button>
<button
onClick={handleCancelStock}
className="px-3 py-1 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Cancelar
</button>
</div>
</div>
</div>
) : (
<button
onClick={() => handleAddStockClick(item.id)}
className="w-full px-3 py-2 text-xs border-2 border-dashed border-[var(--border-secondary)] hover:border-[var(--color-primary)] hover:bg-[var(--bg-secondary)] rounded-lg transition-colors text-[var(--text-secondary)] hover:text-[var(--color-primary)] font-medium"
>
{lots.length === 0 ? '+ Agregar Stock Inicial (Opcional)' : '+ Agregar Otro Lote'}
</button>
)}
</div>
);
})()}
</div> </div>
))} ))}
</div> </div>