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:
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user