2025-09-08 17:19:00 +02:00
|
|
|
import React, { useState, useRef } from 'react';
|
|
|
|
|
import { Button } from '../../../ui/Button';
|
|
|
|
|
import { Input } from '../../../ui/Input';
|
|
|
|
|
import { useValidateFileOnly } from '../../../../api/hooks/dataImport';
|
|
|
|
|
import { ImportValidationResponse } from '../../../../api/types/dataImport';
|
|
|
|
|
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
|
|
|
|
import { useCreateIngredient } from '../../../../api/hooks/inventory';
|
|
|
|
|
import { useImportFileOnly } from '../../../../api/hooks/dataImport';
|
|
|
|
|
import { useClassifyProductsBatch } from '../../../../api/hooks/classification';
|
2025-09-08 22:28:26 +02:00
|
|
|
import { useAuth } from '../../../../contexts/AuthContext';
|
2025-09-08 17:19:00 +02:00
|
|
|
|
|
|
|
|
interface UploadSalesDataStepProps {
|
|
|
|
|
onNext: () => void;
|
|
|
|
|
onPrevious: () => void;
|
|
|
|
|
onComplete: (data?: any) => void;
|
|
|
|
|
isFirstStep: boolean;
|
|
|
|
|
isLastStep: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ProgressState {
|
|
|
|
|
stage: string;
|
|
|
|
|
progress: number;
|
|
|
|
|
message: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface InventoryItem {
|
|
|
|
|
suggestion_id: string;
|
2025-09-08 21:44:04 +02:00
|
|
|
original_name: string;
|
2025-09-08 17:19:00 +02:00
|
|
|
suggested_name: string;
|
2025-09-08 21:44:04 +02:00
|
|
|
product_type: string;
|
2025-09-08 17:19:00 +02:00
|
|
|
category: string;
|
|
|
|
|
unit_of_measure: string;
|
|
|
|
|
confidence_score: number;
|
2025-09-08 21:44:04 +02:00
|
|
|
estimated_shelf_life_days?: number;
|
2025-09-08 17:19:00 +02:00
|
|
|
requires_refrigeration: boolean;
|
|
|
|
|
requires_freezing: boolean;
|
|
|
|
|
is_seasonal: boolean;
|
2025-09-08 21:44:04 +02:00
|
|
|
suggested_supplier?: string;
|
2025-09-08 17:19:00 +02:00
|
|
|
notes?: string;
|
2025-09-08 21:44:04 +02:00
|
|
|
sales_data?: {
|
|
|
|
|
total_quantity: number;
|
|
|
|
|
average_daily_sales: number;
|
|
|
|
|
peak_day: string;
|
|
|
|
|
frequency: number;
|
|
|
|
|
};
|
|
|
|
|
// UI-specific fields
|
|
|
|
|
selected: boolean;
|
|
|
|
|
stock_quantity: number;
|
|
|
|
|
cost_per_unit: number;
|
2025-09-08 17:19:00 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const UploadSalesDataStep: React.FC<UploadSalesDataStepProps> = ({
|
|
|
|
|
onPrevious,
|
|
|
|
|
onComplete,
|
|
|
|
|
isFirstStep
|
|
|
|
|
}) => {
|
|
|
|
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
|
|
|
|
const [isValidating, setIsValidating] = useState(false);
|
|
|
|
|
const [validationResult, setValidationResult] = useState<ImportValidationResponse | null>(null);
|
|
|
|
|
const [inventoryItems, setInventoryItems] = useState<InventoryItem[]>([]);
|
|
|
|
|
const [showInventoryStep, setShowInventoryStep] = useState(false);
|
|
|
|
|
const [isCreating, setIsCreating] = useState(false);
|
|
|
|
|
const [error, setError] = useState<string>('');
|
|
|
|
|
const [progressState, setProgressState] = useState<ProgressState | null>(null);
|
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
|
|
|
|
|
const currentTenant = useCurrentTenant();
|
2025-09-08 22:28:26 +02:00
|
|
|
const { user } = useAuth();
|
2025-09-08 17:19:00 +02:00
|
|
|
const { validateFile } = useValidateFileOnly();
|
|
|
|
|
const createIngredient = useCreateIngredient();
|
|
|
|
|
const { importFile } = useImportFileOnly();
|
|
|
|
|
const classifyProducts = useClassifyProductsBatch();
|
|
|
|
|
|
2025-09-08 22:28:26 +02:00
|
|
|
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
2025-09-08 17:19:00 +02:00
|
|
|
const file = event.target.files?.[0];
|
|
|
|
|
if (file) {
|
|
|
|
|
setSelectedFile(file);
|
|
|
|
|
setValidationResult(null);
|
|
|
|
|
setError('');
|
2025-09-08 22:28:26 +02:00
|
|
|
|
|
|
|
|
// Automatically trigger validation and classification
|
|
|
|
|
await handleAutoValidateAndClassify(file);
|
2025-09-08 17:19:00 +02:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-09-08 22:28:26 +02:00
|
|
|
const handleDrop = async (event: React.DragEvent<HTMLDivElement>) => {
|
2025-09-08 17:19:00 +02:00
|
|
|
event.preventDefault();
|
|
|
|
|
const file = event.dataTransfer.files[0];
|
|
|
|
|
if (file) {
|
|
|
|
|
setSelectedFile(file);
|
|
|
|
|
setValidationResult(null);
|
|
|
|
|
setError('');
|
2025-09-08 22:28:26 +02:00
|
|
|
|
|
|
|
|
// Automatically trigger validation and classification
|
|
|
|
|
await handleAutoValidateAndClassify(file);
|
2025-09-08 17:19:00 +02:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
};
|
|
|
|
|
|
2025-09-08 22:28:26 +02:00
|
|
|
const handleAutoValidateAndClassify = async (file: File) => {
|
|
|
|
|
if (!currentTenant?.id) return;
|
2025-09-08 17:19:00 +02:00
|
|
|
|
|
|
|
|
setIsValidating(true);
|
|
|
|
|
setError('');
|
2025-09-08 22:28:26 +02:00
|
|
|
setProgressState({ stage: 'preparing', progress: 0, message: 'Preparando validación automática del archivo...' });
|
2025-09-08 17:19:00 +02:00
|
|
|
|
|
|
|
|
try {
|
2025-09-08 22:28:26 +02:00
|
|
|
// Step 1: Validate the file
|
2025-09-08 17:19:00 +02:00
|
|
|
const result = await validateFile(
|
|
|
|
|
currentTenant.id,
|
2025-09-08 22:28:26 +02:00
|
|
|
file,
|
2025-09-08 17:19:00 +02:00
|
|
|
{
|
|
|
|
|
onProgress: (stage: string, progress: number, message: string) => {
|
2025-09-08 22:28:26 +02:00
|
|
|
// Map validation progress to 0-50%
|
|
|
|
|
setProgressState({ stage, progress: Math.min(progress * 0.5, 50), message });
|
2025-09-08 17:19:00 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (result.success && result.validationResult) {
|
|
|
|
|
setValidationResult(result.validationResult);
|
2025-09-08 22:28:26 +02:00
|
|
|
setProgressState({ stage: 'analyzing', progress: 60, message: 'Validación exitosa. Generando sugerencias automáticamente...' });
|
|
|
|
|
|
|
|
|
|
// Step 2: Automatically trigger classification
|
|
|
|
|
await generateInventorySuggestionsAuto(result.validationResult);
|
2025-09-08 17:19:00 +02:00
|
|
|
} else {
|
|
|
|
|
setError(result.error || 'Error al validar el archivo');
|
|
|
|
|
setProgressState(null);
|
2025-09-08 22:28:26 +02:00
|
|
|
setIsValidating(false);
|
2025-09-08 17:19:00 +02:00
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
setError('Error validando archivo: ' + (error instanceof Error ? error.message : 'Error desconocido'));
|
|
|
|
|
setProgressState(null);
|
2025-09-08 22:28:26 +02:00
|
|
|
setIsValidating(false);
|
2025-09-08 17:19:00 +02:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
2025-09-08 22:28:26 +02:00
|
|
|
const generateInventorySuggestionsAuto = async (validationData: ImportValidationResponse) => {
|
|
|
|
|
if (!currentTenant?.id) {
|
2025-09-08 17:19:00 +02:00
|
|
|
setError('No hay datos de validación disponibles para generar sugerencias');
|
2025-09-08 22:28:26 +02:00
|
|
|
setIsValidating(false);
|
|
|
|
|
setProgressState(null);
|
2025-09-08 17:19:00 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2025-09-08 22:28:26 +02:00
|
|
|
setProgressState({ stage: 'analyzing', progress: 65, message: 'Analizando productos de ventas...' });
|
|
|
|
|
|
2025-09-08 21:44:04 +02:00
|
|
|
// Extract product data from validation result - use the exact backend structure
|
2025-09-08 22:28:26 +02:00
|
|
|
const products = validationData.product_list?.map((productName: string) => ({
|
2025-09-08 21:44:04 +02:00
|
|
|
product_name: productName
|
2025-09-08 17:19:00 +02:00
|
|
|
})) || [];
|
|
|
|
|
|
|
|
|
|
if (products.length === 0) {
|
|
|
|
|
setError('No se encontraron productos en los datos de ventas');
|
|
|
|
|
setProgressState(null);
|
2025-09-08 22:28:26 +02:00
|
|
|
setIsValidating(false);
|
2025-09-08 17:19:00 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-08 22:28:26 +02:00
|
|
|
setProgressState({ stage: 'classifying', progress: 75, message: 'Clasificando productos con IA...' });
|
2025-09-08 17:19:00 +02:00
|
|
|
|
|
|
|
|
// Call the classification API
|
|
|
|
|
const suggestions = await classifyProducts.mutateAsync({
|
|
|
|
|
tenantId: currentTenant.id,
|
|
|
|
|
batchData: { products }
|
|
|
|
|
});
|
|
|
|
|
|
2025-09-08 22:28:26 +02:00
|
|
|
setProgressState({ stage: 'preparing', progress: 90, message: 'Preparando sugerencias de inventario...' });
|
2025-09-08 17:19:00 +02:00
|
|
|
|
2025-09-08 21:44:04 +02:00
|
|
|
// Convert API response to InventoryItem format - use exact backend structure plus UI fields
|
2025-09-08 17:19:00 +02:00
|
|
|
const items: InventoryItem[] = suggestions.map(suggestion => {
|
|
|
|
|
// Calculate default stock quantity based on sales data
|
|
|
|
|
const defaultStock = Math.max(
|
|
|
|
|
Math.ceil((suggestion.sales_data?.average_daily_sales || 1) * 7), // 1 week supply
|
|
|
|
|
1
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Estimate cost per unit based on category
|
|
|
|
|
const estimatedCost = suggestion.category === 'Dairy' ? 5.0 :
|
|
|
|
|
suggestion.category === 'Baking Ingredients' ? 2.0 :
|
|
|
|
|
3.0;
|
|
|
|
|
|
|
|
|
|
return {
|
2025-09-08 21:44:04 +02:00
|
|
|
// Exact backend fields
|
2025-09-08 17:19:00 +02:00
|
|
|
suggestion_id: suggestion.suggestion_id,
|
2025-09-08 21:44:04 +02:00
|
|
|
original_name: suggestion.original_name,
|
2025-09-08 17:19:00 +02:00
|
|
|
suggested_name: suggestion.suggested_name,
|
2025-09-08 21:44:04 +02:00
|
|
|
product_type: suggestion.product_type,
|
2025-09-08 17:19:00 +02:00
|
|
|
category: suggestion.category,
|
|
|
|
|
unit_of_measure: suggestion.unit_of_measure,
|
|
|
|
|
confidence_score: suggestion.confidence_score,
|
2025-09-08 21:44:04 +02:00
|
|
|
estimated_shelf_life_days: suggestion.estimated_shelf_life_days,
|
2025-09-08 17:19:00 +02:00
|
|
|
requires_refrigeration: suggestion.requires_refrigeration,
|
|
|
|
|
requires_freezing: suggestion.requires_freezing,
|
|
|
|
|
is_seasonal: suggestion.is_seasonal,
|
2025-09-08 21:44:04 +02:00
|
|
|
suggested_supplier: suggestion.suggested_supplier,
|
|
|
|
|
notes: suggestion.notes,
|
|
|
|
|
sales_data: suggestion.sales_data,
|
|
|
|
|
// UI-specific fields
|
|
|
|
|
selected: suggestion.confidence_score > 0.7, // Auto-select high confidence items
|
|
|
|
|
stock_quantity: defaultStock,
|
|
|
|
|
cost_per_unit: estimatedCost
|
2025-09-08 17:19:00 +02:00
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
setInventoryItems(items);
|
|
|
|
|
setShowInventoryStep(true);
|
|
|
|
|
setProgressState(null);
|
2025-09-08 22:28:26 +02:00
|
|
|
setIsValidating(false);
|
2025-09-08 17:19:00 +02:00
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Error generating inventory suggestions:', err);
|
|
|
|
|
setError('Error al generar sugerencias de inventario. Por favor, inténtalo de nuevo.');
|
|
|
|
|
setProgressState(null);
|
2025-09-08 22:28:26 +02:00
|
|
|
setIsValidating(false);
|
2025-09-08 17:19:00 +02:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-09-08 22:28:26 +02:00
|
|
|
|
2025-09-08 17:19:00 +02:00
|
|
|
const handleToggleSelection = (id: string) => {
|
|
|
|
|
setInventoryItems(items =>
|
|
|
|
|
items.map(item =>
|
|
|
|
|
item.suggestion_id === id ? { ...item, selected: !item.selected } : item
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleUpdateItem = (id: string, field: keyof InventoryItem, value: number) => {
|
|
|
|
|
setInventoryItems(items =>
|
|
|
|
|
items.map(item =>
|
|
|
|
|
item.suggestion_id === id ? { ...item, [field]: value } : item
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleSelectAll = () => {
|
|
|
|
|
const allSelected = inventoryItems.every(item => item.selected);
|
|
|
|
|
setInventoryItems(items =>
|
|
|
|
|
items.map(item => ({ ...item, selected: !allSelected }))
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleCreateInventory = async () => {
|
|
|
|
|
const selectedItems = inventoryItems.filter(item => item.selected);
|
|
|
|
|
|
|
|
|
|
if (selectedItems.length === 0) {
|
|
|
|
|
setError('Por favor selecciona al menos un artículo de inventario para crear');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!currentTenant?.id) {
|
|
|
|
|
setError('No se encontró información del tenant');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setIsCreating(true);
|
|
|
|
|
setError('');
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const createdIngredients = [];
|
|
|
|
|
|
|
|
|
|
for (const item of selectedItems) {
|
2025-09-08 21:44:04 +02:00
|
|
|
// Ensure reorder_point > minimum_stock_level as required by backend validation
|
|
|
|
|
const minimumStock = Math.max(1, Math.ceil(item.stock_quantity * 0.2));
|
|
|
|
|
const calculatedReorderPoint = Math.ceil(item.stock_quantity * 0.3);
|
|
|
|
|
const reorderPoint = Math.max(minimumStock + 2, calculatedReorderPoint, minimumStock + 1);
|
|
|
|
|
|
|
|
|
|
console.log(`📊 Inventory validation for "${item.suggested_name}":`, {
|
|
|
|
|
stockQuantity: item.stock_quantity,
|
|
|
|
|
minimumStock,
|
|
|
|
|
calculatedReorderPoint,
|
|
|
|
|
finalReorderPoint: reorderPoint,
|
|
|
|
|
isValid: reorderPoint > minimumStock
|
|
|
|
|
});
|
|
|
|
|
|
2025-09-08 17:19:00 +02:00
|
|
|
const ingredientData = {
|
|
|
|
|
name: item.suggested_name,
|
|
|
|
|
category: item.category,
|
|
|
|
|
unit_of_measure: item.unit_of_measure,
|
2025-09-08 21:44:04 +02:00
|
|
|
low_stock_threshold: minimumStock,
|
|
|
|
|
max_stock_level: item.stock_quantity * 2,
|
|
|
|
|
reorder_point: reorderPoint,
|
|
|
|
|
shelf_life_days: item.estimated_shelf_life_days || 30,
|
2025-09-08 17:19:00 +02:00
|
|
|
requires_refrigeration: item.requires_refrigeration,
|
|
|
|
|
requires_freezing: item.requires_freezing,
|
|
|
|
|
is_seasonal: item.is_seasonal,
|
2025-09-08 21:44:04 +02:00
|
|
|
average_cost: item.cost_per_unit,
|
2025-09-08 17:19:00 +02:00
|
|
|
notes: item.notes || `Creado durante onboarding - Confianza: ${Math.round(item.confidence_score * 100)}%`
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const created = await createIngredient.mutateAsync({
|
|
|
|
|
tenantId: currentTenant.id,
|
|
|
|
|
ingredientData
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
createdIngredients.push({
|
|
|
|
|
...created,
|
|
|
|
|
initialStock: item.stock_quantity
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// After inventory creation, import the sales data
|
|
|
|
|
console.log('Importing sales data after inventory creation...');
|
|
|
|
|
let salesImportResult = null;
|
|
|
|
|
try {
|
|
|
|
|
if (selectedFile) {
|
|
|
|
|
const result = await importFile(
|
|
|
|
|
currentTenant.id,
|
|
|
|
|
selectedFile,
|
|
|
|
|
{
|
|
|
|
|
onProgress: (stage, progress, message) => {
|
|
|
|
|
console.log(`Import progress: ${stage} - ${progress}% - ${message}`);
|
|
|
|
|
setProgressState({
|
|
|
|
|
stage: 'importing',
|
|
|
|
|
progress,
|
|
|
|
|
message: `Importando datos de ventas: ${message}`
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
salesImportResult = result;
|
|
|
|
|
if (result.success) {
|
|
|
|
|
console.log('Sales data imported successfully');
|
|
|
|
|
} else {
|
|
|
|
|
console.warn('Sales import completed with issues:', result.error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch (importError) {
|
|
|
|
|
console.error('Error importing sales data:', importError);
|
|
|
|
|
// Don't fail the entire process if import fails - the inventory has been created successfully
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setProgressState(null);
|
|
|
|
|
onComplete({
|
|
|
|
|
createdIngredients,
|
|
|
|
|
totalItems: selectedItems.length,
|
|
|
|
|
validationResult,
|
|
|
|
|
file: selectedFile,
|
2025-09-08 22:28:26 +02:00
|
|
|
salesImportResult,
|
|
|
|
|
inventoryConfigured: true, // Flag for ML training dependency
|
|
|
|
|
shouldAutoCompleteSuppliers: true, // Flag to trigger suppliers auto-completion after step completion
|
|
|
|
|
userId: user?.id // Pass user ID for suppliers completion
|
2025-09-08 17:19:00 +02:00
|
|
|
});
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error('Error creating inventory items:', err);
|
|
|
|
|
setError('Error al crear artículos de inventario. Por favor, inténtalo de nuevo.');
|
|
|
|
|
setIsCreating(false);
|
|
|
|
|
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 selectedCount = inventoryItems.filter(item => item.selected).length;
|
|
|
|
|
const allSelected = inventoryItems.length > 0 && inventoryItems.every(item => item.selected);
|
|
|
|
|
|
|
|
|
|
if (showInventoryStep) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
<p className="text-[var(--text-secondary)] mb-6">
|
2025-09-08 22:28:26 +02:00
|
|
|
¡Perfecto! Hemos analizado automáticamente tus datos de ventas y generado estas sugerencias de inventario inteligentes.
|
2025-09-08 17:19:00 +02:00
|
|
|
Revisa y selecciona los artículos que te gustaría agregar a tu inventario.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Summary */}
|
|
|
|
|
<div className="bg-[var(--bg-secondary)] rounded-lg p-4">
|
2025-09-08 21:44:04 +02:00
|
|
|
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center space-y-3 sm:space-y-0">
|
|
|
|
|
<div className="text-center sm:text-left">
|
|
|
|
|
<p className="font-medium text-sm sm:text-base">
|
2025-09-08 17:19:00 +02:00
|
|
|
{selectedCount} de {inventoryItems.length} artículos seleccionados
|
|
|
|
|
</p>
|
2025-09-08 21:44:04 +02:00
|
|
|
<p className="text-xs sm:text-sm text-[var(--text-secondary)]">
|
2025-09-08 17:19:00 +02:00
|
|
|
Los artículos con alta confianza están preseleccionados
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={handleSelectAll}
|
2025-09-08 21:44:04 +02:00
|
|
|
className="w-full sm:w-auto"
|
2025-09-08 17:19:00 +02:00
|
|
|
>
|
|
|
|
|
{allSelected ? 'Deseleccionar Todos' : 'Seleccionar Todos'}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Inventory Items */}
|
|
|
|
|
<div className="space-y-4 max-h-96 overflow-y-auto">
|
|
|
|
|
{inventoryItems.map((item) => (
|
|
|
|
|
<div
|
2025-09-08 21:44:04 +02:00
|
|
|
key={item.suggestion_id}
|
2025-09-08 17:19:00 +02:00
|
|
|
className={`border rounded-lg p-4 transition-colors ${
|
|
|
|
|
item.selected
|
|
|
|
|
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/5'
|
|
|
|
|
: 'border-[var(--border-secondary)]'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-start gap-4">
|
|
|
|
|
<div className="flex-shrink-0 pt-1">
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={item.selected}
|
2025-09-08 21:44:04 +02:00
|
|
|
onChange={() => handleToggleSelection(item.suggestion_id)}
|
2025-09-08 17:19:00 +02:00
|
|
|
className="w-4 h-4 text-[var(--color-primary)] border-[var(--border-secondary)] rounded focus:ring-[var(--color-primary)]"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex-1 space-y-3">
|
|
|
|
|
<div>
|
|
|
|
|
<h3 className="font-medium text-[var(--text-primary)]">
|
2025-09-08 21:44:04 +02:00
|
|
|
{item.suggested_name}
|
2025-09-08 17:19:00 +02:00
|
|
|
</h3>
|
|
|
|
|
<p className="text-sm text-[var(--text-secondary)]">
|
|
|
|
|
{item.category} • Unidad: {item.unit_of_measure}
|
|
|
|
|
</p>
|
|
|
|
|
<div className="flex items-center gap-2 mt-1">
|
|
|
|
|
<span className="text-xs bg-[var(--bg-tertiary)] px-2 py-1 rounded">
|
|
|
|
|
Confianza: {Math.round(item.confidence_score * 100)}%
|
|
|
|
|
</span>
|
|
|
|
|
{item.requires_refrigeration && (
|
|
|
|
|
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">
|
|
|
|
|
Requiere refrigeración
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
2025-09-08 21:44:04 +02:00
|
|
|
{item.requires_freezing && (
|
|
|
|
|
<span className="text-xs bg-cyan-100 text-cyan-800 px-2 py-1 rounded">
|
|
|
|
|
Requiere congelación
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{item.is_seasonal && (
|
|
|
|
|
<span className="text-xs bg-green-100 text-green-800 px-2 py-1 rounded">
|
|
|
|
|
Producto estacional
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
2025-09-08 17:19:00 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{item.selected && (
|
2025-09-08 21:44:04 +02:00
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 sm:gap-4 pt-3 border-t border-[var(--border-secondary)]">
|
2025-09-08 17:19:00 +02:00
|
|
|
<Input
|
|
|
|
|
label="Stock Inicial"
|
|
|
|
|
type="number"
|
|
|
|
|
min="0"
|
|
|
|
|
value={item.stock_quantity.toString()}
|
|
|
|
|
onChange={(e) => handleUpdateItem(
|
2025-09-08 21:44:04 +02:00
|
|
|
item.suggestion_id,
|
2025-09-08 17:19:00 +02:00
|
|
|
'stock_quantity',
|
|
|
|
|
Number(e.target.value)
|
|
|
|
|
)}
|
|
|
|
|
size="sm"
|
|
|
|
|
/>
|
|
|
|
|
<Input
|
|
|
|
|
label="Costo por Unidad (€)"
|
|
|
|
|
type="number"
|
|
|
|
|
min="0"
|
|
|
|
|
step="0.01"
|
|
|
|
|
value={item.cost_per_unit.toString()}
|
|
|
|
|
onChange={(e) => handleUpdateItem(
|
2025-09-08 21:44:04 +02:00
|
|
|
item.suggestion_id,
|
2025-09-08 17:19:00 +02:00
|
|
|
'cost_per_unit',
|
|
|
|
|
Number(e.target.value)
|
|
|
|
|
)}
|
|
|
|
|
size="sm"
|
|
|
|
|
/>
|
|
|
|
|
<Input
|
|
|
|
|
label="Días de Caducidad"
|
|
|
|
|
type="number"
|
|
|
|
|
min="1"
|
2025-09-08 21:44:04 +02:00
|
|
|
value={(item.estimated_shelf_life_days || 30).toString()}
|
2025-09-08 17:19:00 +02:00
|
|
|
onChange={(e) => handleUpdateItem(
|
2025-09-08 21:44:04 +02:00
|
|
|
item.suggestion_id,
|
|
|
|
|
'estimated_shelf_life_days',
|
2025-09-08 17:19:00 +02:00
|
|
|
Number(e.target.value)
|
|
|
|
|
)}
|
|
|
|
|
size="sm"
|
2025-09-08 21:44:04 +02:00
|
|
|
className="sm:col-span-2 lg:col-span-1"
|
2025-09-08 17:19:00 +02:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</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>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Actions */}
|
2025-09-08 22:28:26 +02:00
|
|
|
<div className="flex flex-col sm:flex-row justify-end gap-3 sm:gap-0">{/* Removed back button */}
|
2025-09-08 17:19:00 +02:00
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
onClick={handleCreateInventory}
|
|
|
|
|
isLoading={isCreating}
|
|
|
|
|
loadingText="Creando Inventario..."
|
|
|
|
|
size="lg"
|
|
|
|
|
disabled={selectedCount === 0}
|
2025-09-08 22:28:26 +02:00
|
|
|
className="w-full sm:w-auto"
|
2025-09-08 17:19:00 +02:00
|
|
|
>
|
2025-09-08 21:44:04 +02:00
|
|
|
<span className="hidden sm:inline">
|
|
|
|
|
Crear {selectedCount} Artículo{selectedCount !== 1 ? 's' : ''} de Inventario
|
|
|
|
|
</span>
|
|
|
|
|
<span className="sm:hidden">
|
|
|
|
|
Crear {selectedCount} Artículo{selectedCount !== 1 ? 's' : ''}
|
|
|
|
|
</span>
|
2025-09-08 17:19:00 +02:00
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
<div className="text-center">
|
|
|
|
|
<p className="text-[var(--text-secondary)] mb-6">
|
2025-09-08 22:28:26 +02:00
|
|
|
Sube tus datos de ventas (formato CSV o JSON) y automáticamente validaremos y generaremos sugerencias de inventario inteligentes.
|
2025-09-08 17:19:00 +02:00
|
|
|
</p>
|
|
|
|
|
</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()}
|
|
|
|
|
>
|
|
|
|
|
Choose Different File
|
|
|
|
|
</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">Drop your sales data here</p>
|
|
|
|
|
<p className="text-[var(--text-secondary)]">or click to browse files</p>
|
|
|
|
|
<p className="text-sm text-[var(--text-tertiary)] mt-2">
|
2025-09-08 22:28:26 +02:00
|
|
|
Supported formats: CSV, JSON (max 100MB)<br/>
|
|
|
|
|
<span className="text-[var(--color-primary)]">Auto-validates and generates suggestions</span>
|
2025-09-08 17:19:00 +02:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => fileInputRef.current?.click()}
|
|
|
|
|
>
|
|
|
|
|
Choose File
|
|
|
|
|
</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">Validation Successful!</h3>
|
|
|
|
|
<div className="space-y-2 text-sm">
|
|
|
|
|
<p>Total records: {validationResult.total_records}</p>
|
|
|
|
|
<p>Valid records: {validationResult.valid_records}</p>
|
|
|
|
|
{validationResult.invalid_records > 0 && (
|
|
|
|
|
<p className="text-[var(--color-warning)]">
|
|
|
|
|
Invalid records: {validationResult.invalid_records}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
{validationResult.warnings && validationResult.warnings.length > 0 && (
|
|
|
|
|
<div className="mt-2">
|
|
|
|
|
<p className="font-medium text-[var(--color-warning)]">Warnings:</p>
|
|
|
|
|
<ul className="list-disc list-inside">
|
|
|
|
|
{validationResult.warnings.map((warning, index) => (
|
2025-09-08 21:44:04 +02:00
|
|
|
<li key={index} className="text-[var(--color-warning)]">
|
|
|
|
|
{typeof warning === 'string' ? warning : JSON.stringify(warning)}
|
|
|
|
|
</li>
|
2025-09-08 17:19:00 +02:00
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</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>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Actions */}
|
2025-09-08 22:28:26 +02:00
|
|
|
<div className="flex flex-col sm:flex-row justify-end gap-3 sm:gap-0">{/* Removed back button */}
|
|
|
|
|
|
|
|
|
|
{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>
|
|
|
|
|
)}
|
2025-09-08 17:19:00 +02:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|