Files
bakery-ia/frontend/src/components/domain/onboarding/steps/UploadSalesDataStep.tsx
2025-09-08 22:28:26 +02:00

670 lines
25 KiB
TypeScript

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';
import { useAuth } from '../../../../contexts/AuthContext';
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;
original_name: string;
suggested_name: string;
product_type: string;
category: string;
unit_of_measure: string;
confidence_score: number;
estimated_shelf_life_days?: number;
requires_refrigeration: boolean;
requires_freezing: boolean;
is_seasonal: boolean;
suggested_supplier?: string;
notes?: string;
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;
}
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();
const { user } = useAuth();
const { validateFile } = useValidateFileOnly();
const createIngredient = useCreateIngredient();
const { importFile } = useImportFileOnly();
const classifyProducts = useClassifyProductsBatch();
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
setSelectedFile(file);
setValidationResult(null);
setError('');
// Automatically trigger validation and classification
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('');
// Automatically trigger validation and classification
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 result = await validateFile(
currentTenant.id,
file,
{
onProgress: (stage: string, progress: number, message: string) => {
// Map validation progress to 0-50%
setProgressState({ stage, progress: Math.min(progress * 0.5, 50), message });
}
}
);
if (result.success && result.validationResult) {
setValidationResult(result.validationResult);
setProgressState({ stage: 'analyzing', progress: 60, message: 'Validación exitosa. Generando sugerencias automáticamente...' });
// Step 2: Automatically trigger classification
await generateInventorySuggestionsAuto(result.validationResult);
} else {
setError(result.error || 'Error al validar el archivo');
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...' });
// Extract product data from validation result - use the exact backend structure
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...' });
// Call the classification API
const suggestions = await classifyProducts.mutateAsync({
tenantId: currentTenant.id,
batchData: { products }
});
setProgressState({ stage: 'preparing', progress: 90, message: 'Preparando sugerencias de inventario...' });
// Convert API response to InventoryItem format - use exact backend structure plus UI fields
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 {
// Exact backend fields
suggestion_id: suggestion.suggestion_id,
original_name: suggestion.original_name,
suggested_name: suggestion.suggested_name,
product_type: suggestion.product_type,
category: suggestion.category,
unit_of_measure: suggestion.unit_of_measure,
confidence_score: suggestion.confidence_score,
estimated_shelf_life_days: suggestion.estimated_shelf_life_days,
requires_refrigeration: suggestion.requires_refrigeration,
requires_freezing: suggestion.requires_freezing,
is_seasonal: suggestion.is_seasonal,
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
};
});
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);
}
};
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) {
// 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
});
const ingredientData = {
name: item.suggested_name,
category: item.category,
unit_of_measure: item.unit_of_measure,
low_stock_threshold: minimumStock,
max_stock_level: item.stock_quantity * 2,
reorder_point: reorderPoint,
shelf_life_days: item.estimated_shelf_life_days || 30,
requires_refrigeration: item.requires_refrigeration,
requires_freezing: item.requires_freezing,
is_seasonal: item.is_seasonal,
average_cost: item.cost_per_unit,
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,
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
});
} 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">
¡Perfecto! Hemos analizado automáticamente tus datos de ventas y generado estas sugerencias de inventario inteligentes.
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">
<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">
{selectedCount} de {inventoryItems.length} artículos seleccionados
</p>
<p className="text-xs sm:text-sm text-[var(--text-secondary)]">
Los artículos con alta confianza están preseleccionados
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={handleSelectAll}
className="w-full sm:w-auto"
>
{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
key={item.suggestion_id}
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}
onChange={() => handleToggleSelection(item.suggestion_id)}
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)]">
{item.suggested_name}
</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>
)}
{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>
)}
</div>
</div>
{item.selected && (
<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)]">
<Input
label="Stock Inicial"
type="number"
min="0"
value={item.stock_quantity.toString()}
onChange={(e) => handleUpdateItem(
item.suggestion_id,
'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(
item.suggestion_id,
'cost_per_unit',
Number(e.target.value)
)}
size="sm"
/>
<Input
label="Días de Caducidad"
type="number"
min="1"
value={(item.estimated_shelf_life_days || 30).toString()}
onChange={(e) => handleUpdateItem(
item.suggestion_id,
'estimated_shelf_life_days',
Number(e.target.value)
)}
size="sm"
className="sm:col-span-2 lg:col-span-1"
/>
</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 */}
<div className="flex flex-col sm:flex-row justify-end gap-3 sm:gap-0">{/* Removed back button */}
<Button
onClick={handleCreateInventory}
isLoading={isCreating}
loadingText="Creando Inventario..."
size="lg"
disabled={selectedCount === 0}
className="w-full sm:w-auto"
>
<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>
</Button>
</div>
</div>
);
}
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 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">
Supported formats: CSV, JSON (max 100MB)<br/>
<span className="text-[var(--color-primary)]">Auto-validates and generates suggestions</span>
</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) => (
<li key={index} className="text-[var(--color-warning)]">
{typeof warning === 'string' ? warning : JSON.stringify(warning)}
</li>
))}
</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 */}
<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>
)}
</div>
</div>
);
};