**Issue:** When clicking "Edit" on an ingredient in the list, the edit form
appeared at the bottom of the page after all ingredients, forcing users to
scroll down. This was poor UX especially with 10+ ingredients.
**Solution:** Moved edit form to appear inline directly below the ingredient
being edited.
**Changes Made:**
1. **Inline Edit Form Display**
- Edit form now renders inside the ingredient map loop
- Shows conditionally when `editingId === item.id`
- Appears immediately below the specific ingredient being edited
- Location: frontend/src/components/domain/onboarding/steps/UploadSalesDataStep.tsx:834-1029
2. **Hide Ingredient Card While Editing**
- Ingredient card (with stock lots) hidden when that ingredient is being edited
- Condition: `{editingId !== item.id && (...)}`
- Prevents duplication of information
- Location: lines 629-832
3. **Separate Add Manually Form**
- Bottom form now only shows when adding new ingredient (not editing)
- Condition changed from `{isAdding ? (` to `{isAdding && !editingId ? (`
- Title simplified to "Agregar Ingrediente Manualmente"
- Button label simplified to "Agregar a Lista"
- Location: lines 1042-1237
**User Experience:**
Before: Edit button → scroll to bottom → edit form → scroll back up
After: Edit button → form appears right there → edit → save → continues
**Structure:**
```jsx
{inventoryItems.map((item) => (
<div key={item.id}>
{editingId !== item.id && (
<>
{/* Ingredient card */}
{/* Stock lots section */}
</>
)}
{editingId === item.id && (
{/* Inline edit form */}
)}
</div>
))}
{isAdding && !editingId && (
{/* Add manually form at bottom */}
)}
```
**Build Status:** ✓ Successful in 20.61s
1523 lines
68 KiB
TypeScript
1523 lines
68 KiB
TypeScript
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, 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, StockCreate, StockResponse } from '../../../../api/types/inventory';
|
|
import { ProductionStage } from '../../../../api/types/inventory';
|
|
import { useAuth } from '../../../../contexts/AuthContext';
|
|
import { BatchAddIngredientsModal } from '../../inventory/BatchAddIngredientsModal';
|
|
|
|
interface UploadSalesDataStepProps {
|
|
onNext: () => void;
|
|
onPrevious: () => void;
|
|
onComplete: (data?: any) => void;
|
|
isFirstStep: boolean;
|
|
isLastStep: boolean;
|
|
}
|
|
|
|
interface ProgressState {
|
|
stage: string;
|
|
progress: number;
|
|
message: string;
|
|
}
|
|
|
|
interface InventoryItemForm {
|
|
id: string; // Unique ID for UI tracking
|
|
name: string;
|
|
product_type: string;
|
|
category: string;
|
|
unit_of_measure: string;
|
|
stock_quantity: number;
|
|
cost_per_unit: number;
|
|
estimated_shelf_life_days: number;
|
|
requires_refrigeration: boolean;
|
|
requires_freezing: boolean;
|
|
is_seasonal: boolean;
|
|
low_stock_threshold: number;
|
|
reorder_point: number;
|
|
notes: string;
|
|
// AI suggestion metadata (if from AI)
|
|
isSuggested: boolean;
|
|
confidence_score?: number;
|
|
sales_data?: {
|
|
total_quantity: number;
|
|
average_daily_sales: number;
|
|
};
|
|
}
|
|
|
|
export const UploadSalesDataStep: React.FC<UploadSalesDataStepProps> = ({
|
|
onComplete,
|
|
isFirstStep
|
|
}) => {
|
|
const { t } = useTranslation();
|
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
|
const [isValidating, setIsValidating] = useState(false);
|
|
const [validationResult, setValidationResult] = useState<ImportValidationResponse | null>(null);
|
|
const [inventoryItems, setInventoryItems] = useState<InventoryItemForm[]>([]);
|
|
const [showInventoryStep, setShowInventoryStep] = useState(false);
|
|
const [error, setError] = useState<string>('');
|
|
const [progressState, setProgressState] = useState<ProgressState | null>(null);
|
|
const [showGuide, setShowGuide] = useState(false);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// Form state for adding/editing
|
|
const [isAdding, setIsAdding] = useState(false);
|
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
const [showBatchModal, setShowBatchModal] = useState(false);
|
|
const [formData, setFormData] = useState<InventoryItemForm>({
|
|
id: '',
|
|
name: '',
|
|
product_type: 'ingredient',
|
|
category: '',
|
|
unit_of_measure: 'kg',
|
|
stock_quantity: 0,
|
|
cost_per_unit: 0,
|
|
estimated_shelf_life_days: 30,
|
|
requires_refrigeration: false,
|
|
requires_freezing: false,
|
|
is_seasonal: false,
|
|
low_stock_threshold: 0,
|
|
reorder_point: 0,
|
|
notes: '',
|
|
isSuggested: false,
|
|
});
|
|
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
|
|
|
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<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 file = event.target.files?.[0];
|
|
if (file) {
|
|
setSelectedFile(file);
|
|
setValidationResult(null);
|
|
setError('');
|
|
await handleAutoValidateAndClassify(file);
|
|
}
|
|
};
|
|
|
|
const handleDrop = async (event: React.DragEvent<HTMLDivElement>) => {
|
|
event.preventDefault();
|
|
const file = event.dataTransfer.files[0];
|
|
if (file) {
|
|
setSelectedFile(file);
|
|
setValidationResult(null);
|
|
setError('');
|
|
await handleAutoValidateAndClassify(file);
|
|
}
|
|
};
|
|
|
|
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
|
|
event.preventDefault();
|
|
};
|
|
|
|
const handleAutoValidateAndClassify = async (file: File) => {
|
|
if (!currentTenant?.id) return;
|
|
|
|
setIsValidating(true);
|
|
setError('');
|
|
setProgressState({ stage: 'preparing', progress: 0, message: 'Preparando validación automática del archivo...' });
|
|
|
|
try {
|
|
// Step 1: Validate the file
|
|
const validationResult = await validateFileMutation.mutateAsync({
|
|
tenantId: currentTenant.id,
|
|
file
|
|
});
|
|
|
|
if (validationResult && validationResult.is_valid !== undefined) {
|
|
setValidationResult(validationResult);
|
|
setProgressState({ stage: 'analyzing', progress: 60, message: 'Validación exitosa. Generando sugerencias automáticamente...' });
|
|
await generateInventorySuggestionsAuto(validationResult);
|
|
} else {
|
|
setError('Respuesta de validación inválida del servidor');
|
|
setProgressState(null);
|
|
setIsValidating(false);
|
|
}
|
|
} catch (error) {
|
|
setError('Error validando archivo: ' + (error instanceof Error ? error.message : 'Error desconocido'));
|
|
setProgressState(null);
|
|
setIsValidating(false);
|
|
}
|
|
};
|
|
|
|
const generateInventorySuggestionsAuto = async (validationData: ImportValidationResponse) => {
|
|
if (!currentTenant?.id) {
|
|
setError('No hay datos de validación disponibles para generar sugerencias');
|
|
setIsValidating(false);
|
|
setProgressState(null);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setProgressState({ stage: 'analyzing', progress: 65, message: 'Analizando productos de ventas...' });
|
|
|
|
const products = validationData.product_list?.map((productName: string) => ({
|
|
product_name: productName
|
|
})) || [];
|
|
|
|
if (products.length === 0) {
|
|
setError('No se encontraron productos en los datos de ventas');
|
|
setProgressState(null);
|
|
setIsValidating(false);
|
|
return;
|
|
}
|
|
|
|
setProgressState({ stage: 'classifying', progress: 75, message: 'Clasificando productos con IA...' });
|
|
|
|
const classificationResponse = await classifyBatchMutation.mutateAsync({
|
|
tenantId: currentTenant.id,
|
|
products
|
|
});
|
|
|
|
setProgressState({ stage: 'preparing', progress: 90, message: 'Preparando sugerencias de inventario...' });
|
|
|
|
// Convert AI suggestions to inventory items (NOT created yet, just added to list)
|
|
const items: InventoryItemForm[] = classificationResponse.suggestions.map((suggestion: ProductSuggestionResponse, index: number) => {
|
|
const defaultStock = Math.max(
|
|
Math.ceil((suggestion.sales_data?.average_daily_sales || 1) * 7),
|
|
1
|
|
);
|
|
const estimatedCost = suggestion.category === 'Dairy' ? 5.0 :
|
|
suggestion.category === 'Baking Ingredients' ? 2.0 : 3.0;
|
|
const minimumStock = Math.max(1, Math.ceil(defaultStock * 0.2));
|
|
const reorderPoint = Math.max(minimumStock + 2, Math.ceil(defaultStock * 0.3), minimumStock + 1);
|
|
|
|
return {
|
|
id: `ai-${index}-${Date.now()}`,
|
|
name: suggestion.suggested_name,
|
|
product_type: suggestion.product_type,
|
|
category: suggestion.category,
|
|
unit_of_measure: suggestion.unit_of_measure,
|
|
stock_quantity: defaultStock,
|
|
cost_per_unit: estimatedCost,
|
|
estimated_shelf_life_days: suggestion.estimated_shelf_life_days || 30,
|
|
requires_refrigeration: suggestion.requires_refrigeration,
|
|
requires_freezing: suggestion.requires_freezing,
|
|
is_seasonal: suggestion.is_seasonal,
|
|
low_stock_threshold: minimumStock,
|
|
reorder_point: reorderPoint,
|
|
notes: `AI generado - Confianza: ${Math.round(suggestion.confidence_score * 100)}%`,
|
|
isSuggested: true,
|
|
confidence_score: suggestion.confidence_score,
|
|
sales_data: suggestion.sales_data ? {
|
|
total_quantity: suggestion.sales_data.total_quantity,
|
|
average_daily_sales: suggestion.sales_data.average_daily_sales,
|
|
} : undefined,
|
|
};
|
|
});
|
|
|
|
setInventoryItems(items);
|
|
setShowInventoryStep(true);
|
|
setProgressState(null);
|
|
setIsValidating(false);
|
|
} catch (err) {
|
|
console.error('Error generating inventory suggestions:', err);
|
|
setError('Error al generar sugerencias de inventario. Por favor, inténtalo de nuevo.');
|
|
setProgressState(null);
|
|
setIsValidating(false);
|
|
}
|
|
};
|
|
|
|
// Form validation
|
|
const validateForm = (): boolean => {
|
|
const newErrors: Record<string, string> = {};
|
|
|
|
if (!formData.name.trim()) {
|
|
newErrors.name = 'El nombre es requerido';
|
|
}
|
|
if (!formData.category.trim()) {
|
|
newErrors.category = 'La categoría es requerida';
|
|
}
|
|
if (formData.stock_quantity < 0) {
|
|
newErrors.stock_quantity = 'El stock debe ser 0 o mayor';
|
|
}
|
|
if (formData.cost_per_unit < 0) {
|
|
newErrors.cost_per_unit = 'El costo debe ser 0 o mayor';
|
|
}
|
|
if (formData.estimated_shelf_life_days <= 0) {
|
|
newErrors.estimated_shelf_life_days = 'Los días de caducidad deben ser mayores a 0';
|
|
}
|
|
|
|
setFormErrors(newErrors);
|
|
return Object.keys(newErrors).length === 0;
|
|
};
|
|
|
|
// Add or update item in list
|
|
const handleSubmitForm = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!validateForm()) return;
|
|
|
|
if (editingId) {
|
|
// Update existing item
|
|
setInventoryItems(items =>
|
|
items.map(item =>
|
|
item.id === editingId ? { ...formData, id: editingId } : item
|
|
)
|
|
);
|
|
setEditingId(null);
|
|
} else {
|
|
// Add new item
|
|
const newItem: InventoryItemForm = {
|
|
...formData,
|
|
id: `manual-${Date.now()}`,
|
|
isSuggested: false,
|
|
};
|
|
setInventoryItems(items => [...items, newItem]);
|
|
}
|
|
|
|
resetForm();
|
|
};
|
|
|
|
const resetForm = () => {
|
|
setFormData({
|
|
id: '',
|
|
name: '',
|
|
product_type: 'ingredient',
|
|
category: '',
|
|
unit_of_measure: 'kg',
|
|
stock_quantity: 0,
|
|
cost_per_unit: 0,
|
|
estimated_shelf_life_days: 30,
|
|
requires_refrigeration: false,
|
|
requires_freezing: false,
|
|
is_seasonal: false,
|
|
low_stock_threshold: 0,
|
|
reorder_point: 0,
|
|
notes: '',
|
|
isSuggested: false,
|
|
});
|
|
setFormErrors({});
|
|
setIsAdding(false);
|
|
setEditingId(null);
|
|
};
|
|
|
|
const handleEdit = (item: InventoryItemForm) => {
|
|
setFormData(item);
|
|
setEditingId(item.id);
|
|
setIsAdding(true);
|
|
};
|
|
|
|
const handleDelete = (itemId: string) => {
|
|
if (!window.confirm('¿Estás seguro de que quieres eliminar este ingrediente de la lista?')) {
|
|
return;
|
|
}
|
|
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
|
|
const handleNext = async () => {
|
|
if (inventoryItems.length === 0) {
|
|
setError('Por favor agrega al menos un ingrediente antes de continuar');
|
|
return;
|
|
}
|
|
|
|
if (!currentTenant?.id) {
|
|
setError('No se encontró información del tenant');
|
|
return;
|
|
}
|
|
|
|
setProgressState({
|
|
stage: 'creating_inventory',
|
|
progress: 10,
|
|
message: `Creando ${inventoryItems.length} ingredientes...`
|
|
});
|
|
|
|
try {
|
|
// Create all ingredients in parallel
|
|
const creationPromises = inventoryItems.map(item => {
|
|
const ingredientData = {
|
|
name: item.name,
|
|
product_type: item.product_type,
|
|
category: item.category,
|
|
unit_of_measure: item.unit_of_measure,
|
|
low_stock_threshold: item.low_stock_threshold,
|
|
max_stock_level: item.stock_quantity * 2,
|
|
reorder_point: item.reorder_point,
|
|
shelf_life_days: item.estimated_shelf_life_days,
|
|
requires_refrigeration: item.requires_refrigeration,
|
|
requires_freezing: item.requires_freezing,
|
|
is_seasonal: item.is_seasonal,
|
|
average_cost: item.cost_per_unit,
|
|
notes: item.notes || undefined,
|
|
};
|
|
|
|
return createIngredient.mutateAsync({
|
|
tenantId: currentTenant.id,
|
|
ingredientData
|
|
}).then(created => ({
|
|
...created,
|
|
initialStock: item.stock_quantity
|
|
}));
|
|
});
|
|
|
|
const results = await Promise.allSettled(creationPromises);
|
|
|
|
const createdIngredients = results
|
|
.filter(r => r.status === 'fulfilled')
|
|
.map(r => (r as PromiseFulfilledResult<any>).value);
|
|
|
|
const failedCount = results.filter(r => r.status === 'rejected').length;
|
|
|
|
if (failedCount > 0) {
|
|
console.warn(`${failedCount} ingredientes fallaron al crear de ${inventoryItems.length}`);
|
|
}
|
|
|
|
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
|
|
setProgressState({
|
|
stage: 'importing_sales',
|
|
progress: 60,
|
|
message: 'Importando datos de ventas...'
|
|
});
|
|
|
|
let salesImportResult = null;
|
|
try {
|
|
if (selectedFile) {
|
|
const result = await importMutation.mutateAsync({
|
|
tenantId: currentTenant.id,
|
|
file: selectedFile
|
|
});
|
|
salesImportResult = result;
|
|
if (result.success) {
|
|
console.log('Datos de ventas importados exitosamente');
|
|
}
|
|
}
|
|
} catch (importError) {
|
|
console.error('Error importando datos de ventas:', importError);
|
|
}
|
|
|
|
setProgressState(null);
|
|
|
|
// Complete step
|
|
onComplete({
|
|
createdIngredients,
|
|
totalItems: createdIngredients.length,
|
|
validationResult,
|
|
file: selectedFile,
|
|
salesImportResult,
|
|
inventoryConfigured: true,
|
|
shouldAutoCompleteSuppliers: true,
|
|
userId: user?.id
|
|
});
|
|
} catch (err) {
|
|
console.error('Error creando ingredientes:', err);
|
|
setError('Error al crear ingredientes. Por favor, inténtalo de nuevo.');
|
|
setProgressState(null);
|
|
}
|
|
};
|
|
|
|
const formatFileSize = (bytes: number) => {
|
|
if (bytes === 0) return '0 Bytes';
|
|
const k = 1024;
|
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
};
|
|
|
|
const categoryOptions = [
|
|
'Baking Ingredients',
|
|
'Dairy',
|
|
'Fruits',
|
|
'Vegetables',
|
|
'Meat',
|
|
'Seafood',
|
|
'Spices',
|
|
'Other'
|
|
];
|
|
|
|
const unitOptions = ['kg', 'g', 'L', 'ml', 'units', 'dozen'];
|
|
|
|
// INVENTORY LIST VIEW (after AI suggestions loaded)
|
|
if (showInventoryStep) {
|
|
const canContinue = inventoryItems.length >= 1;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Why This Matters */}
|
|
<div className="bg-[var(--color-info)]/10 border border-[var(--color-info)]/20 rounded-lg p-4">
|
|
<h3 className="font-semibold text-[var(--text-primary)] mb-2 flex items-center gap-2">
|
|
<svg className="w-5 h-5 text-[var(--color-info)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
{t('onboarding:ai_suggestions.why_title', 'Configurar Inventario')}
|
|
</h3>
|
|
<p className="text-sm text-[var(--text-secondary)]">
|
|
Revisa y edita los ingredientes sugeridos por IA. Puedes agregar más ingredientes manualmente. Cuando hagas clic en "Siguiente", se crearán todos los ingredientes.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Inventory Items List */}
|
|
<div>
|
|
<h4 className="text-sm font-medium text-[var(--text-secondary)] mb-3">
|
|
Ingredientes ({inventoryItems.length})
|
|
</h4>
|
|
|
|
{inventoryItems.length > 0 ? (
|
|
<div className="space-y-3">
|
|
{inventoryItems.map((item) => (
|
|
<div key={item.id}>
|
|
{/* Show ingredient card only if NOT editing this item */}
|
|
{editingId !== item.id && (
|
|
<div className="p-4 bg-[var(--bg-secondary)] border border-[var(--border-secondary)] rounded-lg">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<h5 className="font-medium text-[var(--text-primary)]">
|
|
{item.name}
|
|
</h5>
|
|
{item.isSuggested && item.confidence_score && (
|
|
<span className="text-xs bg-[var(--color-info)]/10 text-[var(--color-info)] px-2 py-0.5 rounded-full">
|
|
IA {Math.round(item.confidence_score * 100)}%
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-sm text-[var(--text-secondary)] mt-1">
|
|
{item.category} • {item.unit_of_measure}
|
|
</p>
|
|
<div className="mt-2 flex items-center gap-4 text-xs text-[var(--text-secondary)]">
|
|
<span>Stock: {item.stock_quantity} {item.unit_of_measure}</span>
|
|
<span>Costo: €{item.cost_per_unit.toFixed(2)}/{item.unit_of_measure}</span>
|
|
<span>Caducidad: {item.estimated_shelf_life_days} días</span>
|
|
</div>
|
|
{item.sales_data && (
|
|
<div className="mt-2 text-xs text-[var(--text-tertiary)]">
|
|
📊 Ventas: {item.sales_data.average_daily_sales.toFixed(1)}/día
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2 ml-4">
|
|
<button
|
|
onClick={() => handleEdit(item)}
|
|
className="p-2 text-[var(--text-secondary)] hover:text-[var(--color-primary)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
|
title="Editar"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(item.id)}
|
|
className="p-2 text-[var(--text-secondary)] hover:text-[var(--color-error)] hover:bg-[var(--color-error)]/10 rounded transition-colors"
|
|
title="Eliminar"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
|
</svg>
|
|
</button>
|
|
</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>
|
|
)}
|
|
|
|
{/* Inline Edit Form - show only when editing this specific ingredient */}
|
|
{editingId === item.id && (
|
|
<div className="border-2 border-[var(--color-primary)] rounded-lg p-4 bg-gradient-to-br from-[var(--color-primary)]/5 to-transparent">
|
|
<h4 className="font-semibold text-[var(--text-primary)] mb-4">
|
|
Editar Ingrediente
|
|
</h4>
|
|
<form onSubmit={handleSubmitForm} className="space-y-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
Nombre *
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={formData.name}
|
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
|
|
placeholder="Ej: Harina de trigo"
|
|
/>
|
|
{formErrors.name && (
|
|
<p className="text-xs text-[var(--color-error)] mt-1">{formErrors.name}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
Categoría *
|
|
</label>
|
|
<select
|
|
value={formData.category}
|
|
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
|
|
>
|
|
<option value="">Seleccionar...</option>
|
|
{categoryOptions.map(cat => (
|
|
<option key={cat} value={cat}>{cat}</option>
|
|
))}
|
|
</select>
|
|
{formErrors.category && (
|
|
<p className="text-xs text-[var(--color-error)] mt-1">{formErrors.category}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
Unidad de Medida
|
|
</label>
|
|
<select
|
|
value={formData.unit_of_measure}
|
|
onChange={(e) => setFormData({ ...formData, unit_of_measure: e.target.value })}
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
|
|
>
|
|
{unitOptions.map(unit => (
|
|
<option key={unit} value={unit}>{unit}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
Stock Inicial
|
|
</label>
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
value={formData.stock_quantity}
|
|
onChange={(e) => setFormData({ ...formData, stock_quantity: parseFloat(e.target.value) || 0 })}
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
|
|
/>
|
|
{formErrors.stock_quantity && (
|
|
<p className="text-xs text-[var(--color-error)] mt-1">{formErrors.stock_quantity}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
Costo por Unidad (€)
|
|
</label>
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
value={formData.cost_per_unit}
|
|
onChange={(e) => setFormData({ ...formData, cost_per_unit: parseFloat(e.target.value) || 0 })}
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
|
|
/>
|
|
{formErrors.cost_per_unit && (
|
|
<p className="text-xs text-[var(--color-error)] mt-1">{formErrors.cost_per_unit}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
Días de Caducidad
|
|
</label>
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
value={formData.estimated_shelf_life_days}
|
|
onChange={(e) => setFormData({ ...formData, estimated_shelf_life_days: parseInt(e.target.value) || 30 })}
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
|
|
/>
|
|
{formErrors.estimated_shelf_life_days && (
|
|
<p className="text-xs text-[var(--color-error)] mt-1">{formErrors.estimated_shelf_life_days}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
Stock Mínimo
|
|
</label>
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
value={formData.low_stock_threshold}
|
|
onChange={(e) => setFormData({ ...formData, low_stock_threshold: parseInt(e.target.value) || 0 })}
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
Punto de Reorden
|
|
</label>
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
value={formData.reorder_point}
|
|
onChange={(e) => setFormData({ ...formData, reorder_point: parseInt(e.target.value) || 0 })}
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4">
|
|
<label className="flex items-center gap-2 text-sm text-[var(--text-primary)]">
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.requires_refrigeration}
|
|
onChange={(e) => setFormData({ ...formData, requires_refrigeration: e.target.checked })}
|
|
className="rounded"
|
|
/>
|
|
Requiere Refrigeración
|
|
</label>
|
|
<label className="flex items-center gap-2 text-sm text-[var(--text-primary)]">
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.requires_freezing}
|
|
onChange={(e) => setFormData({ ...formData, requires_freezing: e.target.checked })}
|
|
className="rounded"
|
|
/>
|
|
Requiere Congelación
|
|
</label>
|
|
<label className="flex items-center gap-2 text-sm text-[var(--text-primary)]">
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.is_seasonal}
|
|
onChange={(e) => setFormData({ ...formData, is_seasonal: e.target.checked })}
|
|
className="rounded"
|
|
/>
|
|
Estacional
|
|
</label>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
Notas (opcional)
|
|
</label>
|
|
<textarea
|
|
value={formData.notes}
|
|
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
|
rows={2}
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
|
|
placeholder="Información adicional..."
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex gap-2 pt-2">
|
|
<button
|
|
type="submit"
|
|
className="px-4 py-2 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary-dark)] transition-colors font-medium"
|
|
>
|
|
Guardar Cambios
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={resetForm}
|
|
className="px-4 py-2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded-lg transition-colors"
|
|
>
|
|
Cancelar
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-8 border border-dashed border-[var(--border-secondary)] rounded-lg">
|
|
<p className="text-[var(--text-tertiary)] text-sm">
|
|
No hay ingredientes en la lista todavía
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Add Manually Form - only show when adding new (not editing existing) */}
|
|
{isAdding && !editingId ? (
|
|
<div className="border-2 border-[var(--color-primary)] rounded-lg p-4 bg-gradient-to-br from-[var(--color-primary)]/5 to-transparent">
|
|
<h4 className="font-semibold text-[var(--text-primary)] mb-4">
|
|
Agregar Ingrediente Manualmente
|
|
</h4>
|
|
<form onSubmit={handleSubmitForm} className="space-y-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
Nombre *
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={formData.name}
|
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
|
|
placeholder="Ej: Harina de trigo"
|
|
/>
|
|
{formErrors.name && (
|
|
<p className="text-xs text-[var(--color-error)] mt-1">{formErrors.name}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
Categoría *
|
|
</label>
|
|
<select
|
|
value={formData.category}
|
|
onChange={(e) => setFormData({ ...formData, category: e.target.value })}
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
|
|
>
|
|
<option value="">Seleccionar...</option>
|
|
{categoryOptions.map(cat => (
|
|
<option key={cat} value={cat}>{cat}</option>
|
|
))}
|
|
</select>
|
|
{formErrors.category && (
|
|
<p className="text-xs text-[var(--color-error)] mt-1">{formErrors.category}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
Unidad de Medida
|
|
</label>
|
|
<select
|
|
value={formData.unit_of_measure}
|
|
onChange={(e) => setFormData({ ...formData, unit_of_measure: e.target.value })}
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
|
|
>
|
|
{unitOptions.map(unit => (
|
|
<option key={unit} value={unit}>{unit}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
Stock Inicial
|
|
</label>
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
value={formData.stock_quantity}
|
|
onChange={(e) => setFormData({ ...formData, stock_quantity: parseFloat(e.target.value) || 0 })}
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
|
|
/>
|
|
{formErrors.stock_quantity && (
|
|
<p className="text-xs text-[var(--color-error)] mt-1">{formErrors.stock_quantity}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
Costo por Unidad (€)
|
|
</label>
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
value={formData.cost_per_unit}
|
|
onChange={(e) => setFormData({ ...formData, cost_per_unit: parseFloat(e.target.value) || 0 })}
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
|
|
/>
|
|
{formErrors.cost_per_unit && (
|
|
<p className="text-xs text-[var(--color-error)] mt-1">{formErrors.cost_per_unit}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
Días de Caducidad
|
|
</label>
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
value={formData.estimated_shelf_life_days}
|
|
onChange={(e) => setFormData({ ...formData, estimated_shelf_life_days: parseInt(e.target.value) || 30 })}
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
|
|
/>
|
|
{formErrors.estimated_shelf_life_days && (
|
|
<p className="text-xs text-[var(--color-error)] mt-1">{formErrors.estimated_shelf_life_days}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
Stock Mínimo
|
|
</label>
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
value={formData.low_stock_threshold}
|
|
onChange={(e) => setFormData({ ...formData, low_stock_threshold: parseInt(e.target.value) || 0 })}
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
Punto de Reorden
|
|
</label>
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
value={formData.reorder_point}
|
|
onChange={(e) => setFormData({ ...formData, reorder_point: parseInt(e.target.value) || 0 })}
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4">
|
|
<label className="flex items-center gap-2 text-sm text-[var(--text-primary)]">
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.requires_refrigeration}
|
|
onChange={(e) => setFormData({ ...formData, requires_refrigeration: e.target.checked })}
|
|
className="rounded"
|
|
/>
|
|
Requiere Refrigeración
|
|
</label>
|
|
<label className="flex items-center gap-2 text-sm text-[var(--text-primary)]">
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.requires_freezing}
|
|
onChange={(e) => setFormData({ ...formData, requires_freezing: e.target.checked })}
|
|
className="rounded"
|
|
/>
|
|
Requiere Congelación
|
|
</label>
|
|
<label className="flex items-center gap-2 text-sm text-[var(--text-primary)]">
|
|
<input
|
|
type="checkbox"
|
|
checked={formData.is_seasonal}
|
|
onChange={(e) => setFormData({ ...formData, is_seasonal: e.target.checked })}
|
|
className="rounded"
|
|
/>
|
|
Estacional
|
|
</label>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
|
Notas (opcional)
|
|
</label>
|
|
<textarea
|
|
value={formData.notes}
|
|
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
|
rows={2}
|
|
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-secondary)] rounded-lg focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] text-[var(--text-primary)]"
|
|
placeholder="Información adicional..."
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex gap-2 pt-2">
|
|
<button
|
|
type="submit"
|
|
className="px-4 py-2 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary-dark)] transition-colors font-medium"
|
|
>
|
|
Agregar a Lista
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={resetForm}
|
|
className="px-4 py-2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] rounded-lg transition-colors"
|
|
>
|
|
Cancelar
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsAdding(true)}
|
|
className="p-4 border-2 border-dashed border-[var(--border-secondary)] rounded-lg hover:border-[var(--color-primary)] hover:bg-[var(--bg-secondary)] transition-colors group"
|
|
>
|
|
<div className="flex items-center justify-center gap-2 text-[var(--text-secondary)] group-hover:text-[var(--color-primary)]">
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
|
</svg>
|
|
<span className="font-medium">
|
|
Agregar Uno
|
|
</span>
|
|
</div>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowBatchModal(true)}
|
|
className="p-4 border-2 border-dashed border-[var(--color-primary)]/30 rounded-lg hover:border-[var(--color-primary)] hover:bg-[var(--color-primary)]/5 transition-colors group"
|
|
>
|
|
<div className="flex items-center justify-center gap-2 text-[var(--color-primary)]">
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
</svg>
|
|
<span className="font-medium">
|
|
Agregar Varios
|
|
</span>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="bg-[var(--color-error)]/10 border border-[var(--color-error)]/20 rounded-lg p-4">
|
|
<p className="text-[var(--color-error)]">{error}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Progress during creation */}
|
|
{progressState && (
|
|
<div className="bg-[var(--bg-secondary)] rounded-lg p-4">
|
|
<div className="flex justify-between text-sm mb-2">
|
|
<span className="font-medium">{progressState.message}</span>
|
|
<span>{progressState.progress}%</span>
|
|
</div>
|
|
<div className="w-full bg-[var(--bg-tertiary)] rounded-full h-2">
|
|
<div
|
|
className="bg-[var(--color-primary)] h-2 rounded-full transition-all duration-300"
|
|
style={{ width: `${progressState.progress}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Navigation - Show Next button when minimum requirement met */}
|
|
{!isAdding && (
|
|
<div className="flex items-center justify-between pt-6 border-t border-[var(--border-secondary)] mt-6">
|
|
<div className="flex items-center gap-2">
|
|
{canContinue ? (
|
|
<div className="flex items-center gap-2 text-sm text-[var(--color-success)]">
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
<span>
|
|
{inventoryItems.length} ingrediente(s) - ¡Listo para continuar!
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-[var(--color-warning)]">
|
|
Agrega al menos 1 ingrediente para continuar
|
|
</p>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={handleNext}
|
|
disabled={!canContinue || !!progressState}
|
|
className="px-6 py-2.5 bg-[var(--color-primary)] text-white rounded-lg hover:bg-[var(--color-primary-dark)] disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium flex items-center gap-2"
|
|
>
|
|
{progressState ? 'Creando...' : 'Siguiente'}
|
|
→
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Batch Add Modal */}
|
|
<BatchAddIngredientsModal
|
|
isOpen={showBatchModal}
|
|
onClose={() => setShowBatchModal(false)}
|
|
onCreated={(ingredients) => {
|
|
// Add all created ingredients to the list
|
|
const newItems: InventoryItemForm[] = ingredients.map(ing => ({
|
|
id: ing.id,
|
|
name: ing.name,
|
|
product_type: ing.product_type,
|
|
category: ing.category,
|
|
unit_of_measure: ing.unit_of_measure,
|
|
stock_quantity: 0,
|
|
cost_per_unit: ing.average_cost || 0,
|
|
estimated_shelf_life_days: ing.shelf_life_days || 30,
|
|
requires_refrigeration: ing.requires_refrigeration || false,
|
|
requires_freezing: ing.requires_freezing || false,
|
|
is_seasonal: ing.is_seasonal || false,
|
|
low_stock_threshold: ing.low_stock_threshold || 0,
|
|
reorder_point: ing.reorder_point || 0,
|
|
notes: ing.notes || '',
|
|
isSuggested: false,
|
|
}));
|
|
setInventoryItems([...inventoryItems, ...newItems]);
|
|
setShowBatchModal(false);
|
|
}}
|
|
tenantId={currentTenant?.id || ''}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// FILE UPLOAD VIEW (initial step)
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="text-center">
|
|
<p className="text-[var(--text-secondary)] mb-6">
|
|
Sube tus datos de ventas (formato CSV o JSON) y automáticamente validaremos y generaremos sugerencias de inventario inteligentes.
|
|
</p>
|
|
</div>
|
|
|
|
{/* File Format Guide */}
|
|
<div className="bg-[var(--color-info)]/10 border border-[var(--color-info)]/20 rounded-lg p-4">
|
|
<div className="flex items-start justify-between">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<svg className="w-5 h-5 text-[var(--color-info)]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
<h3 className="font-semibold text-[var(--text-primary)]">
|
|
{t('onboarding:steps.inventory_setup.file_format_guide.title', 'Guía de Formato de Archivo')}
|
|
</h3>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowGuide(!showGuide)}
|
|
className="text-[var(--color-info)] hover:text-[var(--color-primary)] text-sm font-medium"
|
|
>
|
|
{showGuide ? 'Ocultar Guía' : 'Ver Guía Completa'}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="text-sm text-[var(--text-secondary)] space-y-1">
|
|
<p>
|
|
<strong className="text-[var(--text-primary)]">Formatos Soportados:</strong>{' '}
|
|
CSV, JSON, Excel (XLSX) • Tamaño máximo: 10MB
|
|
</p>
|
|
<p>
|
|
<strong className="text-[var(--text-primary)]">Columnas Requeridas:</strong>{' '}
|
|
Fecha, Nombre del Producto, Cantidad Vendida
|
|
</p>
|
|
</div>
|
|
|
|
{showGuide && (
|
|
<div className="mt-4 pt-4 border-t border-[var(--border-secondary)] space-y-3 text-sm text-[var(--text-secondary)]">
|
|
<p>✓ Detección multiidioma de columnas</p>
|
|
<p>✓ Validación automática con reporte detallado</p>
|
|
<p>✓ Clasificación de productos con IA</p>
|
|
<p>✓ Sugerencias inteligentes de inventario</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* File Upload Area */}
|
|
<div
|
|
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
|
|
selectedFile
|
|
? 'border-[var(--color-success)] bg-[var(--color-success)]/5'
|
|
: 'border-[var(--border-secondary)] hover:border-[var(--color-primary)] hover:bg-[var(--color-primary)]/5'
|
|
}`}
|
|
onDrop={handleDrop}
|
|
onDragOver={handleDragOver}
|
|
>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept=".csv,.json"
|
|
onChange={handleFileSelect}
|
|
className="hidden"
|
|
/>
|
|
|
|
{selectedFile ? (
|
|
<div className="space-y-4">
|
|
<div className="text-[var(--color-success)]">
|
|
<svg className="mx-auto h-12 w-12 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
<p className="text-lg font-medium">Archivo Seleccionado</p>
|
|
<p className="text-[var(--text-secondary)]">{selectedFile.name}</p>
|
|
<p className="text-sm text-[var(--text-tertiary)]">
|
|
{formatFileSize(selectedFile.size)}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
>
|
|
Elegir Archivo Diferente
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
<svg className="mx-auto h-12 w-12 text-[var(--text-tertiary)]" stroke="currentColor" fill="none" viewBox="0 0 48 48">
|
|
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
<div>
|
|
<p className="text-lg font-medium">Arrastra tus datos de ventas aquí</p>
|
|
<p className="text-[var(--text-secondary)]">o haz clic para seleccionar archivos</p>
|
|
<p className="text-sm text-[var(--text-tertiary)] mt-2">
|
|
Formatos soportados: CSV, JSON (máx 100MB)<br/>
|
|
<span className="text-[var(--color-primary)]">Validación y sugerencias automáticas</span>
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
>
|
|
Elegir Archivo
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Progress */}
|
|
{progressState && (
|
|
<div className="bg-[var(--bg-secondary)] rounded-lg p-4">
|
|
<div className="flex justify-between text-sm mb-2">
|
|
<span className="font-medium">{progressState.message}</span>
|
|
<span>{progressState.progress}%</span>
|
|
</div>
|
|
<div className="w-full bg-[var(--bg-tertiary)] rounded-full h-2">
|
|
<div
|
|
className="bg-[var(--color-primary)] h-2 rounded-full transition-all duration-300"
|
|
style={{ width: `${progressState.progress}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Validation Results */}
|
|
{validationResult && (
|
|
<div className="bg-[var(--color-success)]/10 border border-[var(--color-success)]/20 rounded-lg p-4">
|
|
<h3 className="font-semibold text-[var(--color-success)] mb-2">¡Validación Exitosa!</h3>
|
|
<div className="space-y-2 text-sm">
|
|
<p>Registros totales: {validationResult.total_records}</p>
|
|
<p>Registros válidos: {validationResult.valid_records}</p>
|
|
{validationResult.invalid_records > 0 && (
|
|
<p className="text-[var(--color-warning)]">
|
|
Registros inválidos: {validationResult.invalid_records}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error */}
|
|
{error && (
|
|
<div className="bg-[var(--color-error)]/10 border border-[var(--color-error)]/20 rounded-lg p-4">
|
|
<p className="text-[var(--color-error)]">{error}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Status indicator */}
|
|
{selectedFile && !showInventoryStep && (
|
|
<div className="flex items-center justify-center px-4 py-2 bg-[var(--bg-secondary)] rounded-lg">
|
|
{isValidating ? (
|
|
<>
|
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-[var(--color-primary)] mr-2"></div>
|
|
<span className="text-sm text-[var(--text-secondary)]">Procesando automáticamente...</span>
|
|
</>
|
|
) : validationResult ? (
|
|
<>
|
|
<svg className="w-4 h-4 text-[var(--color-success)] mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
<span className="text-sm text-[var(--color-success)]">Archivo procesado exitosamente</span>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|