Start integrating the onboarding flow with backend 2

This commit is contained in:
Urtzi Alfaro
2025-09-04 18:59:56 +02:00
parent a11fdfba24
commit 9eedc2e5f2
30 changed files with 3432 additions and 4735 deletions

View File

@@ -2,6 +2,9 @@ import React, { useState, useEffect } from 'react';
import { CheckCircle, Star, Rocket, Gift, Download, Share2, ArrowRight, Calendar } from 'lucide-react';
import { Button, Card, Badge } from '../../../ui';
import { OnboardingStepProps } from '../OnboardingWizard';
import { onboardingApiService } from '../../../../services/api/onboarding.service';
import { useAuthUser } from '../../../../stores/auth.store';
import { useAlertActions } from '../../../../stores/alerts.store';
interface CompletionStats {
totalProducts: number;
@@ -10,6 +13,8 @@ interface CompletionStats {
mlModelAccuracy: number;
estimatedTimeSaved: string;
completionScore: number;
salesImported: boolean;
salesImportRecords: number;
}
export const CompletionStep: React.FC<OnboardingStepProps> = ({
@@ -20,8 +25,73 @@ export const CompletionStep: React.FC<OnboardingStepProps> = ({
isFirstStep,
isLastStep
}) => {
const user = useAuthUser();
const { createAlert } = useAlertActions();
const [showConfetti, setShowConfetti] = useState(false);
const [completionStats, setCompletionStats] = useState<CompletionStats | null>(null);
const [isImportingSales, setIsImportingSales] = useState(false);
// Handle final sales import
const handleFinalSalesImport = async () => {
if (!user?.tenant_id || !data.files?.salesData || !data.inventoryMapping) {
createAlert({
type: 'error',
category: 'system',
priority: 'high',
title: 'Error en importación final',
message: 'Faltan datos necesarios para importar las ventas.',
source: 'onboarding'
});
return;
}
setIsImportingSales(true);
try {
const result = await onboardingApiService.importSalesWithInventory(
user.tenant_id,
data.files.salesData,
data.inventoryMapping
);
createAlert({
type: 'success',
category: 'system',
priority: 'medium',
title: 'Importación completada',
message: `Se importaron ${result.successful_imports} registros de ventas exitosamente.`,
source: 'onboarding'
});
// Update completion stats
const updatedStats = {
...completionStats!,
salesImported: true,
salesImportRecords: result.successful_imports || 0
};
setCompletionStats(updatedStats);
onDataChange({
...data,
completionStats: updatedStats,
salesImportResult: result,
finalImportCompleted: true
});
} catch (error) {
console.error('Sales import error:', error);
const errorMessage = error instanceof Error ? error.message : 'Error al importar datos de ventas';
createAlert({
type: 'error',
category: 'system',
priority: 'high',
title: 'Error en importación',
message: errorMessage,
source: 'onboarding'
});
} finally {
setIsImportingSales(false);
}
};
useEffect(() => {
// Show confetti animation
@@ -35,7 +105,9 @@ export const CompletionStep: React.FC<OnboardingStepProps> = ({
suppliersConfigured: data.suppliers?.length || 0,
mlModelAccuracy: data.trainingMetrics?.accuracy * 100 || 0,
estimatedTimeSaved: '15-20 horas',
completionScore: calculateCompletionScore()
completionScore: calculateCompletionScore(),
salesImported: data.finalImportCompleted || false,
salesImportRecords: data.salesImportResult?.successful_imports || 0
};
setCompletionStats(stats);
@@ -48,6 +120,11 @@ export const CompletionStep: React.FC<OnboardingStepProps> = ({
completedAt: new Date().toISOString()
});
// Trigger final sales import if not already done
if (!data.finalImportCompleted && data.inventoryMapping && data.files?.salesData) {
handleFinalSalesImport();
}
return () => clearTimeout(timer);
}, []);
@@ -221,6 +298,10 @@ export const CompletionStep: React.FC<OnboardingStepProps> = ({
<p className="text-2xl font-bold text-[var(--color-secondary)]">{completionStats.mlModelAccuracy.toFixed(1)}%</p>
<p className="text-xs text-[var(--text-secondary)]">Precisión IA</p>
</div>
<div className="text-center p-3 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-2xl font-bold text-[var(--color-success)]">{completionStats.salesImported ? completionStats.salesImportRecords : '⏳'}</p>
<p className="text-xs text-[var(--text-secondary)]">Ventas {completionStats.salesImported ? 'Importadas' : 'Importando...'}</p>
</div>
<div className="text-center p-3 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-lg font-bold text-[var(--color-warning)]">{completionStats.estimatedTimeSaved}</p>
<p className="text-xs text-[var(--text-secondary)]">Tiempo Ahorrado</p>

View File

@@ -2,6 +2,10 @@ import React, { useState, useRef, useEffect } from 'react';
import { Upload, Brain, CheckCircle, AlertCircle, Download, FileText, Activity, TrendingUp } from 'lucide-react';
import { Button, Card, Badge } from '../../../ui';
import { OnboardingStepProps } from '../OnboardingWizard';
import { onboardingApiService } from '../../../../services/api/onboarding.service';
import { useAuthUser, useAuthLoading } from '../../../../stores/auth.store';
import { useCurrentTenant, useTenantLoading } from '../../../../stores/tenant.store';
import { useAlertActions } from '../../../../stores/alerts.store';
type ProcessingStage = 'upload' | 'validating' | 'analyzing' | 'completed' | 'error';
@@ -26,64 +30,70 @@ interface ProcessingResult {
recommendations: string[];
}
// Unified mock service that handles both validation and analysis
const mockDataProcessingService = {
processFile: async (file: File, onProgress: (progress: number, stage: string, message: string) => void) => {
return new Promise<ProcessingResult>((resolve, reject) => {
let progress = 0;
// Real data processing service using backend APIs
const dataProcessingService = {
processFile: async (
file: File,
tenantId: string,
onProgress: (progress: number, stage: string, message: string) => void
) => {
try {
// Stage 1: Validate file with sales service
onProgress(20, 'validating', 'Validando estructura del archivo...');
const validationResult = await onboardingApiService.validateOnboardingFile(tenantId, file);
const stages = [
{ threshold: 20, stage: 'validating', message: 'Validando estructura del archivo...' },
{ threshold: 40, stage: 'validating', message: 'Verificando integridad de datos...' },
{ threshold: 60, stage: 'analyzing', message: 'Identificando productos únicos...' },
{ threshold: 80, stage: 'analyzing', message: 'Analizando patrones de venta...' },
{ threshold: 90, stage: 'analyzing', message: 'Generando recomendaciones con IA...' },
{ threshold: 100, stage: 'completed', message: 'Procesamiento completado' }
];
onProgress(40, 'validating', 'Verificando integridad de datos...');
if (!validationResult.is_valid) {
throw new Error('Archivo de datos inválido');
}
if (!validationResult.product_list || validationResult.product_list.length === 0) {
throw new Error('No se encontraron productos en el archivo');
}
const interval = setInterval(() => {
if (progress < 100) {
progress += 10;
const currentStage = stages.find(s => progress <= s.threshold);
if (currentStage) {
onProgress(progress, currentStage.stage, currentStage.message);
}
}
if (progress >= 100) {
clearInterval(interval);
// Return combined validation + analysis results
resolve({
// Validation results
is_valid: true,
total_records: Math.floor(Math.random() * 1000) + 100,
unique_products: Math.floor(Math.random() * 50) + 10,
product_list: ['Pan Integral', 'Croissant', 'Baguette', 'Empanadas', 'Pan de Centeno', 'Medialunas'],
validation_errors: [],
validation_warnings: [
'Algunas fechas podrían tener formato inconsistente',
'3 productos sin categoría definida'
],
summary: {
date_range: '2024-01-01 to 2024-12-31',
total_sales: 15420.50,
average_daily_sales: 42.25
},
// Analysis results
productsIdentified: 15,
categoriesDetected: 4,
businessModel: 'artisan',
confidenceScore: 94,
recommendations: [
'Se detectó un modelo de panadería artesanal con producción propia',
'Los productos más vendidos son panes tradicionales y bollería',
'Recomendamos categorizar el inventario por tipo de producto',
'Considera ampliar la línea de productos de repostería'
]
});
}
}, 400);
});
// Stage 2: Generate AI suggestions with inventory service
onProgress(60, 'analyzing', 'Identificando productos únicos...');
onProgress(80, 'analyzing', 'Analizando patrones de venta...');
console.log('DataProcessingStep - Calling generateInventorySuggestions with:', {
tenantId,
fileName: file.name,
productList: validationResult.product_list
});
const suggestionsResult = await onboardingApiService.generateInventorySuggestions(
tenantId,
file,
validationResult.product_list
);
console.log('DataProcessingStep - AI suggestions result:', suggestionsResult);
onProgress(90, 'analyzing', 'Generando recomendaciones con IA...');
onProgress(100, 'completed', 'Procesamiento completado');
// Combine results
const combinedResult = {
...validationResult,
productsIdentified: suggestionsResult.total_products || validationResult.unique_products,
categoriesDetected: suggestionsResult.suggestions ?
new Set(suggestionsResult.suggestions.map(s => s.category)).size : 4,
businessModel: suggestionsResult.business_model_analysis?.model || 'production',
confidenceScore: suggestionsResult.high_confidence_count && suggestionsResult.total_products ?
Math.round((suggestionsResult.high_confidence_count / suggestionsResult.total_products) * 100) : 85,
recommendations: suggestionsResult.business_model_analysis?.recommendations || [],
aiSuggestions: suggestionsResult.suggestions || []
};
console.log('DataProcessingStep - Combined result:', combinedResult);
console.log('DataProcessingStep - Combined result aiSuggestions:', combinedResult.aiSuggestions);
console.log('DataProcessingStep - Combined result aiSuggestions length:', combinedResult.aiSuggestions?.length);
return combinedResult;
} catch (error) {
console.error('Data processing error:', error);
throw error;
}
}
};
@@ -95,6 +105,34 @@ export const DataProcessingStep: React.FC<OnboardingStepProps> = ({
isFirstStep,
isLastStep
}) => {
const user = useAuthUser();
const authLoading = useAuthLoading();
const currentTenant = useCurrentTenant();
const tenantLoading = useTenantLoading();
const { createAlert } = useAlertActions();
// Check if we're still loading user or tenant data
const isLoadingUserData = authLoading || tenantLoading;
// Get tenant ID from multiple sources with fallback
const getTenantId = (): string | null => {
const tenantId = currentTenant?.id || user?.tenant_id || null;
console.log('DataProcessingStep - getTenantId:', {
currentTenant: currentTenant?.id,
userTenantId: user?.tenant_id,
finalTenantId: tenantId,
isLoadingUserData,
authLoading,
tenantLoading,
user: user ? { id: user.id, email: user.email } : null
});
return tenantId;
};
// Check if tenant data is available (not loading and has ID)
const isTenantAvailable = (): boolean => {
return !isLoadingUserData && getTenantId() !== null;
};
const [stage, setStage] = useState<ProcessingStage>(data.processingStage || 'upload');
const [uploadedFile, setUploadedFile] = useState<File | null>(data.files?.salesData || null);
const [progress, setProgress] = useState(data.processingProgress || 0);
@@ -166,8 +204,41 @@ export const DataProcessingStep: React.FC<OnboardingStepProps> = ({
setProgress(0);
try {
const result = await mockDataProcessingService.processFile(
// Wait for user data to load if still loading
if (!isTenantAvailable()) {
createAlert({
type: 'info',
category: 'system',
priority: 'low',
title: 'Cargando datos de usuario',
message: 'Por favor espere mientras cargamos su información...',
source: 'onboarding'
});
// Reset file state since we can't process it yet
setUploadedFile(null);
setStage('upload');
return;
}
const tenantId = getTenantId();
if (!tenantId) {
console.error('DataProcessingStep - No tenant ID available:', {
user,
currentTenant,
userTenantId: user?.tenant_id,
currentTenantId: currentTenant?.id,
isLoadingUserData,
authLoading,
tenantLoading
});
throw new Error('No se pudo obtener información del tenant. Intente cerrar sesión y volver a iniciar.');
}
console.log('DataProcessingStep - Starting file processing with tenant:', tenantId);
const result = await dataProcessingService.processFile(
file,
tenantId,
(newProgress, newStage, message) => {
setProgress(newProgress);
setStage(newStage as ProcessingStage);
@@ -177,32 +248,112 @@ export const DataProcessingStep: React.FC<OnboardingStepProps> = ({
setResults(result);
setStage('completed');
// Store results for next steps
onDataChange({
...data,
files: { ...data.files, salesData: file },
processingResults: result,
processingStage: 'completed',
processingProgress: 100
});
console.log('DataProcessingStep - File processing completed:', result);
createAlert({
type: 'success',
category: 'system',
priority: 'medium',
title: 'Procesamiento completado',
message: `Se procesaron ${result.total_records} registros y se identificaron ${result.unique_products} productos únicos.`,
source: 'onboarding'
});
} catch (error) {
console.error('Processing error:', error);
console.error('DataProcessingStep - Processing error:', error);
console.error('DataProcessingStep - Error details:', {
errorMessage: error instanceof Error ? error.message : 'Unknown error',
errorStack: error instanceof Error ? error.stack : null,
tenantInfo: {
user: user ? { id: user.id, tenant_id: user.tenant_id } : null,
currentTenant: currentTenant ? { id: currentTenant.id } : null
}
});
setStage('error');
setCurrentMessage('Error en el procesamiento de datos');
const errorMessage = error instanceof Error ? error.message : 'Error en el procesamiento de datos';
setCurrentMessage(errorMessage);
createAlert({
type: 'error',
category: 'system',
priority: 'high',
title: 'Error en el procesamiento',
message: errorMessage,
source: 'onboarding'
});
}
};
const downloadTemplate = () => {
const csvContent = `fecha,producto,cantidad,precio_unitario,precio_total,cliente,canal_venta
const downloadTemplate = async () => {
try {
if (!isTenantAvailable()) {
createAlert({
type: 'info',
category: 'system',
priority: 'low',
title: 'Cargando datos de usuario',
message: 'Por favor espere mientras cargamos su información...',
source: 'onboarding'
});
return;
}
const tenantId = getTenantId();
if (!tenantId) {
createAlert({
type: 'error',
category: 'system',
priority: 'high',
title: 'Error',
message: 'No se pudo obtener información del tenant. Intente cerrar sesión y volver a iniciar.',
source: 'onboarding'
});
return;
}
const templateData = await onboardingApiService.getSalesImportTemplate(tenantId, 'csv');
onboardingApiService.downloadTemplate(templateData, 'plantilla_ventas.csv', 'csv');
createAlert({
type: 'success',
category: 'system',
priority: 'low',
title: 'Plantilla descargada',
message: 'La plantilla de ventas se ha descargado correctamente',
source: 'onboarding'
});
} catch (error) {
console.error('Error downloading template:', error);
// Fallback to static template
const csvContent = `fecha,producto,cantidad,precio_unitario,precio_total,cliente,canal_venta
2024-01-15,Pan Integral,5,2.50,12.50,Cliente A,Tienda
2024-01-15,Croissant,3,1.80,5.40,Cliente B,Online
2024-01-15,Baguette,2,3.00,6.00,Cliente C,Tienda
2024-01-16,Pan de Centeno,4,2.80,11.20,Cliente A,Tienda
2024-01-16,Empanadas,6,4.50,27.00,Cliente D,Delivery`;
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', 'plantilla_ventas.csv');
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', 'plantilla_ventas.csv');
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
};
const resetProcess = () => {
@@ -218,8 +369,23 @@ export const DataProcessingStep: React.FC<OnboardingStepProps> = ({
return (
<div className="space-y-8">
{/* Loading state when tenant data is not available */}
{!isTenantAvailable() && (
<Card className="p-8 text-center">
<div className="w-16 h-16 bg-[var(--color-info)]/10 rounded-full flex items-center justify-center mx-auto mb-6">
<Activity className="w-8 h-8 text-[var(--color-info)] animate-pulse" />
</div>
<h3 className="text-xl font-semibold text-[var(--text-primary)] mb-3">
Cargando datos de usuario...
</h3>
<p className="text-[var(--text-secondary)]">
Por favor espere mientras cargamos su información de tenant
</p>
</Card>
)}
{/* Improved Upload Stage */}
{stage === 'upload' && (
{stage === 'upload' && isTenantAvailable() && (
<>
<div
className={`

View File

@@ -1,7 +1,10 @@
import React, { useState, useEffect } from 'react';
import { Package, Calendar, AlertTriangle, Plus, Edit, Trash2 } from 'lucide-react';
import { Package, Calendar, AlertTriangle, Plus, Edit, Trash2, CheckCircle } from 'lucide-react';
import { Button, Card, Input, Badge } from '../../../ui';
import { OnboardingStepProps } from '../OnboardingWizard';
import { onboardingApiService } from '../../../../services/api/onboarding.service';
import { useAuthUser } from '../../../../stores/auth.store';
import { useAlertActions } from '../../../../stores/alerts.store';
interface InventoryItem {
id: string;
@@ -15,34 +18,35 @@ interface InventoryItem {
supplier?: string;
cost_per_unit?: number;
requires_refrigeration: boolean;
// API fields
suggestion_id?: string;
original_name?: string;
estimated_shelf_life_days?: number;
is_seasonal?: boolean;
}
// Mock inventory items based on approved products
const mockInventoryItems: InventoryItem[] = [
{
id: '1', name: 'Harina de Trigo', category: 'ingredient',
current_stock: 50, min_stock: 20, max_stock: 100, unit: 'kg',
expiry_date: '2024-12-31', supplier: 'Molinos del Sur',
cost_per_unit: 1.20, requires_refrigeration: false
},
{
id: '2', name: 'Levadura Fresca', category: 'ingredient',
current_stock: 5, min_stock: 2, max_stock: 10, unit: 'kg',
expiry_date: '2024-03-15', supplier: 'Levaduras Pro',
cost_per_unit: 3.50, requires_refrigeration: true
},
{
id: '3', name: 'Pan Integral', category: 'finished_product',
current_stock: 20, min_stock: 10, max_stock: 50, unit: 'unidades',
expiry_date: '2024-01-25', requires_refrigeration: false
},
{
id: '4', name: 'Mantequilla', category: 'ingredient',
current_stock: 15, min_stock: 5, max_stock: 30, unit: 'kg',
expiry_date: '2024-02-28', supplier: 'Lácteos Premium',
cost_per_unit: 4.20, requires_refrigeration: true
}
];
// Convert approved products to inventory items
const convertProductsToInventory = (approvedProducts: any[]): InventoryItem[] => {
return approvedProducts.map((product, index) => ({
id: `inventory-${index}`,
name: product.suggested_name || product.name,
category: product.product_type || 'finished_product',
current_stock: 0, // To be configured by user
min_stock: 1, // Default minimum
max_stock: 100, // Default maximum
unit: product.unit_of_measure || 'unidad',
requires_refrigeration: product.requires_refrigeration || false,
// Store API data
suggestion_id: product.suggestion_id,
original_name: product.original_name,
estimated_shelf_life_days: product.estimated_shelf_life_days,
is_seasonal: product.is_seasonal,
// Optional fields to be filled by user
expiry_date: undefined,
supplier: product.suggested_supplier,
cost_per_unit: 0
}));
};
export const InventorySetupStep: React.FC<OnboardingStepProps> = ({
data,
@@ -52,22 +56,121 @@ export const InventorySetupStep: React.FC<OnboardingStepProps> = ({
isFirstStep,
isLastStep
}) => {
const [items, setItems] = useState<InventoryItem[]>(
data.inventoryItems || mockInventoryItems
);
const user = useAuthUser();
const { createAlert } = useAlertActions();
const [isCreating, setIsCreating] = useState(false);
const [editingItem, setEditingItem] = useState<InventoryItem | null>(null);
const [isAddingNew, setIsAddingNew] = useState(false);
// Generate inventory items from approved products
const generateInventoryFromProducts = (approvedProducts: any[]): InventoryItem[] => {
if (!approvedProducts || approvedProducts.length === 0) {
createAlert({
type: 'warning',
category: 'system',
priority: 'medium',
title: 'Sin productos aprobados',
message: 'No hay productos aprobados para crear inventario. Regrese al paso anterior.',
source: 'onboarding'
});
return [];
}
return convertProductsToInventory(approvedProducts);
};
const [items, setItems] = useState<InventoryItem[]>(() => {
if (data.inventoryItems) {
return data.inventoryItems;
}
// Try to get approved products from current step data first, then from review step data
const approvedProducts = data.approvedProducts || data.allStepData?.['review']?.approvedProducts;
return generateInventoryFromProducts(approvedProducts || []);
});
// Update items when approved products become available (for when component is already mounted)
useEffect(() => {
const approvedProducts = data.approvedProducts || data.allStepData?.['review']?.approvedProducts;
if (approvedProducts && approvedProducts.length > 0 && items.length === 0) {
const newItems = generateInventoryFromProducts(approvedProducts);
setItems(newItems);
}
}, [data.approvedProducts, data.allStepData]);
const [filterCategory, setFilterCategory] = useState<'all' | 'ingredient' | 'finished_product'>('all');
const filteredItems = filterCategory === 'all'
? items
: items.filter(item => item.category === filterCategory);
// Create inventory items via API
const handleCreateInventory = async () => {
const approvedProducts = data.approvedProducts || data.allStepData?.['review']?.approvedProducts;
if (!user?.tenant_id || !approvedProducts || approvedProducts.length === 0) {
createAlert({
type: 'error',
category: 'system',
priority: 'high',
title: 'Error',
message: 'No se pueden crear elementos de inventario sin productos aprobados.',
source: 'onboarding'
});
return;
}
setIsCreating(true);
try {
const result = await onboardingApiService.createInventoryFromSuggestions(
user.tenant_id,
approvedProducts
);
createAlert({
type: 'success',
category: 'system',
priority: 'medium',
title: 'Inventario creado',
message: `Se crearon ${result.created_items.length} elementos de inventario exitosamente.`,
source: 'onboarding'
});
// Update the step data with created inventory
onDataChange({
...data,
inventoryItems: items,
inventoryConfigured: true,
inventoryMapping: result.inventory_mapping,
createdInventoryItems: result.created_items
});
} catch (error) {
console.error('Error creating inventory:', error);
const errorMessage = error instanceof Error ? error.message : 'Error al crear inventario';
createAlert({
type: 'error',
category: 'system',
priority: 'high',
title: 'Error al crear inventario',
message: errorMessage,
source: 'onboarding'
});
} finally {
setIsCreating(false);
}
};
useEffect(() => {
const hasValidStock = items.length > 0 && items.every(item =>
item.min_stock >= 0 && item.max_stock > item.min_stock
);
onDataChange({
...data,
inventoryItems: items,
inventoryConfigured: items.length > 0 && items.every(item =>
item.min_stock > 0 && item.max_stock > item.min_stock
)
inventoryConfigured: hasValidStock && !isCreating
});
}, [items]);
}, [items, isCreating]);
const handleAddItem = () => {
const newItem: InventoryItem = {
@@ -75,491 +178,370 @@ export const InventorySetupStep: React.FC<OnboardingStepProps> = ({
name: '',
category: 'ingredient',
current_stock: 0,
min_stock: 0,
max_stock: 0,
unit: 'kg',
min_stock: 1,
max_stock: 10,
unit: 'unidad',
requires_refrigeration: false
};
setItems([...items, newItem]);
setEditingItem(newItem);
setIsAddingNew(true);
};
const handleSaveItem = (item: InventoryItem) => {
if (isAddingNew) {
setItems(prev => [...prev, item]);
} else {
setItems(prev => prev.map(i => i.id === item.id ? item : i));
}
const handleSaveItem = (updatedItem: InventoryItem) => {
setItems(items.map(item =>
item.id === updatedItem.id ? updatedItem : item
));
setEditingItem(null);
setIsAddingNew(false);
};
const handleDeleteItem = (id: string) => {
if (window.confirm('¿Estás seguro de eliminar este elemento del inventario?')) {
setItems(prev => prev.filter(item => item.id !== id));
setItems(items.filter(item => item.id !== id));
if (editingItem?.id === id) {
setEditingItem(null);
setIsAddingNew(false);
}
};
const handleQuickSetup = () => {
// Auto-configure basic inventory based on approved products
const autoItems = data.detectedProducts
?.filter((p: any) => p.status === 'approved')
.map((product: any, index: number) => ({
id: `auto_${index}`,
name: product.name,
category: 'finished_product' as const,
current_stock: Math.floor(Math.random() * 20) + 5,
min_stock: 5,
max_stock: 50,
unit: 'unidades',
requires_refrigeration: product.category === 'Repostería' || product.category === 'Salados'
})) || [];
setItems(prev => [...prev, ...autoItems]);
};
const getFilteredItems = () => {
return filterCategory === 'all'
? items
: items.filter(item => item.category === filterCategory);
const handleCancelEdit = () => {
if (isAddingNew && editingItem) {
setItems(items.filter(item => item.id !== editingItem.id));
}
setEditingItem(null);
setIsAddingNew(false);
};
const getStockStatus = (item: InventoryItem) => {
if (item.current_stock <= item.min_stock) return { status: 'low', color: 'red', text: 'Stock Bajo' };
if (item.current_stock >= item.max_stock) return { status: 'high', color: 'blue', text: 'Stock Alto' };
return { status: 'normal', color: 'green', text: 'Normal' };
if (item.current_stock <= item.min_stock) return 'critical';
if (item.current_stock <= item.min_stock * 1.5) return 'warning';
return 'good';
};
const isNearExpiry = (expiryDate?: string) => {
if (!expiryDate) return false;
const expiry = new Date(expiryDate);
const today = new Date();
const diffDays = (expiry.getTime() - today.getTime()) / (1000 * 3600 * 24);
return diffDays <= 7;
const getStockStatusColor = (status: string) => {
switch (status) {
case 'critical': return 'text-red-600 bg-red-50';
case 'warning': return 'text-yellow-600 bg-yellow-50';
default: return 'text-green-600 bg-green-50';
}
};
const stats = {
total: items.length,
ingredients: items.filter(i => i.category === 'ingredient').length,
products: items.filter(i => i.category === 'finished_product').length,
lowStock: items.filter(i => i.current_stock <= i.min_stock).length,
nearExpiry: items.filter(i => isNearExpiry(i.expiry_date)).length,
refrigerated: items.filter(i => i.requires_refrigeration).length
};
if (items.length === 0) {
return (
<div className="space-y-8">
<div className="text-center py-16">
<Package className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-2xl font-bold text-gray-600 mb-2">
Sin productos para inventario
</h3>
<p className="text-gray-500 mb-6 max-w-md mx-auto">
No hay productos aprobados para crear inventario.
Regrese al paso anterior para aprobar productos.
</p>
<Button
variant="outline"
onClick={onPrevious}
disabled={isFirstStep}
>
Volver al paso anterior
</Button>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Quick Actions */}
<Card className="p-4">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<p className="text-sm text-[var(--text-secondary)]">
{stats.total} elementos configurados
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleQuickSetup}
className="text-[var(--color-info)]"
>
Auto-configurar Productos
</Button>
<Button
size="sm"
onClick={handleAddItem}
>
<Plus className="w-4 h-4 mr-2" />
Agregar Elemento
</Button>
</div>
</div>
</Card>
<div className="space-y-8">
{/* Header */}
<div className="text-center">
<h2 className="text-3xl font-bold text-[var(--text-primary)] mb-4">
Configuración de Inventario
</h2>
<p className="text-[var(--text-secondary)] text-lg max-w-2xl mx-auto">
Configure los niveles de stock, fechas de vencimiento y otros detalles para sus productos.
</p>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-6 gap-4">
<Card className="p-4 text-center">
<p className="text-2xl font-bold text-[var(--color-info)]">{stats.total}</p>
<p className="text-xs text-[var(--text-secondary)]">Total</p>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<Card className="p-6 text-center">
<div className="text-3xl font-bold text-[var(--color-primary)] mb-2">{items.length}</div>
<div className="text-sm text-[var(--text-secondary)]">Elementos totales</div>
</Card>
<Card className="p-4 text-center">
<p className="text-2xl font-bold text-[var(--color-primary)]">{stats.ingredients}</p>
<p className="text-xs text-[var(--text-secondary)]">Ingredientes</p>
<Card className="p-6 text-center">
<div className="text-3xl font-bold text-blue-600 mb-2">
{items.filter(item => item.category === 'ingredient').length}
</div>
<div className="text-sm text-[var(--text-secondary)]">Ingredientes</div>
</Card>
<Card className="p-4 text-center">
<p className="text-2xl font-bold text-[var(--color-success)]">{stats.products}</p>
<p className="text-xs text-[var(--text-secondary)]">Productos</p>
<Card className="p-6 text-center">
<div className="text-3xl font-bold text-green-600 mb-2">
{items.filter(item => item.category === 'finished_product').length}
</div>
<div className="text-sm text-[var(--text-secondary)]">Productos terminados</div>
</Card>
<Card className="p-4 text-center">
<p className="text-2xl font-bold text-[var(--color-error)]">{stats.lowStock}</p>
<p className="text-xs text-[var(--text-secondary)]">Stock Bajo</p>
</Card>
<Card className="p-4 text-center">
<p className="text-2xl font-bold text-[var(--color-warning)]">{stats.nearExpiry}</p>
<p className="text-xs text-[var(--text-secondary)]">Por Vencer</p>
</Card>
<Card className="p-4 text-center">
<p className="text-2xl font-bold text-[var(--color-info)]">{stats.refrigerated}</p>
<p className="text-xs text-[var(--text-secondary)]">Refrigerado</p>
<Card className="p-6 text-center">
<div className="text-3xl font-bold text-red-600 mb-2">
{items.filter(item => getStockStatus(item) === 'critical').length}
</div>
<div className="text-sm text-[var(--text-secondary)]">Stock crítico</div>
</Card>
</div>
{/* Filters */}
<Card className="p-4">
{/* Controls */}
<div className="flex flex-col sm:flex-row gap-4 justify-between items-start sm:items-center bg-[var(--bg-secondary)] p-4 rounded-lg">
<div className="flex items-center space-x-4">
<label className="text-sm font-medium text-[var(--text-secondary)]">Filtrar:</label>
<div className="flex space-x-2">
{[
{ value: 'all', label: 'Todos' },
{ value: 'ingredient', label: 'Ingredientes' },
{ value: 'finished_product', label: 'Productos' }
].map(filter => (
<button
key={filter.value}
onClick={() => setFilterCategory(filter.value as any)}
className={`px-3 py-1 text-sm rounded-full transition-colors ${
filterCategory === filter.value
? 'bg-[var(--color-primary)] text-white'
: 'bg-[var(--bg-secondary)] text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)]'
}`}
>
{filter.label}
</button>
))}
</div>
</div>
</Card>
{/* Inventory Items */}
<div className="space-y-4">
{getFilteredItems().map((item) => {
const stockStatus = getStockStatus(item);
const nearExpiry = isNearExpiry(item.expiry_date);
<select
value={filterCategory}
onChange={(e) => setFilterCategory(e.target.value as any)}
className="px-3 py-2 border border-[var(--border-primary)] rounded-lg bg-white"
>
<option value="all">Todos los elementos</option>
<option value="ingredient">Ingredientes</option>
<option value="finished_product">Productos terminados</option>
</select>
return (
<Card key={item.id} className="p-4">
<div className="flex items-start justify-between">
<div className="flex items-start space-x-4 flex-1">
{/* Category Icon */}
<div className={`w-8 h-8 rounded-full flex items-center justify-center mt-1 ${
item.category === 'ingredient'
? 'bg-[var(--color-primary)]/10'
: 'bg-[var(--color-success)]/10'
}`}>
<Package className={`w-4 h-4 ${
item.category === 'ingredient'
? 'text-[var(--color-primary)]'
: 'text-[var(--color-success)]'
}`} />
</div>
<Badge variant="outline" className="text-sm">
{filteredItems.length} elementos
</Badge>
</div>
<div className="flex space-x-2">
<Button
onClick={handleAddItem}
size="sm"
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary)]/90"
>
<Plus className="w-4 h-4 mr-1" />
Agregar elemento
</Button>
<Button
onClick={handleCreateInventory}
disabled={isCreating || items.length === 0 || data.inventoryConfigured}
size="sm"
variant="outline"
className="border-green-200 text-green-600 hover:bg-green-50"
>
<CheckCircle className="w-4 h-4 mr-1" />
{isCreating ? 'Creando...' : data.inventoryConfigured ? 'Inventario creado' : 'Crear inventario'}
</Button>
</div>
</div>
{/* Item Info */}
<div className="flex-1">
<h4 className="font-semibold text-[var(--text-primary)] mb-2">{item.name}</h4>
<div className="flex items-center gap-2 mb-3">
<Badge variant={item.category === 'ingredient' ? 'blue' : 'green'}>
{item.category === 'ingredient' ? 'Ingrediente' : 'Producto'}
{/* Items List */}
<div className="space-y-3">
{filteredItems.map((item) => (
<Card key={item.id} className="p-4 hover:shadow-md transition-shadow">
{editingItem?.id === item.id ? (
<InventoryItemEditor
item={item}
onSave={handleSaveItem}
onCancel={handleCancelEdit}
/>
) : (
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3 mb-2">
<h3 className="font-semibold text-[var(--text-primary)]">{item.name}</h3>
<Badge variant="outline" className="text-xs">
{item.category === 'ingredient' ? 'Ingrediente' : 'Producto terminado'}
</Badge>
<Badge className={`text-xs ${getStockStatusColor(getStockStatus(item))}`}>
Stock: {getStockStatus(item)}
</Badge>
{item.requires_refrigeration && (
<Badge variant="outline" className="text-xs text-blue-600">
Refrigeración
</Badge>
{item.requires_refrigeration && (
<Badge variant="gray"> Refrigeración</Badge>
)}
<Badge variant={stockStatus.color}>
{stockStatus.text}
</Badge>
{nearExpiry && (
<Badge variant="red">Vence Pronto</Badge>
)}
</div>
<div className="flex items-center gap-6 text-sm text-[var(--text-secondary)]">
<div>
<span className="text-[var(--text-tertiary)]">Stock Actual: </span>
<span className={`font-medium ${stockStatus.status === 'low' ? 'text-[var(--color-error)]' : 'text-[var(--text-primary)]'}`}>
{item.current_stock} {item.unit}
</span>
</div>
<div>
<span className="text-[var(--text-tertiary)]">Rango: </span>
<span className="font-medium text-[var(--text-primary)]">
{item.min_stock} - {item.max_stock} {item.unit}
</span>
</div>
{item.expiry_date && (
<div>
<span className="text-[var(--text-tertiary)]">Vencimiento: </span>
<span className={`font-medium ${nearExpiry ? 'text-[var(--color-error)]' : 'text-[var(--text-primary)]'}`}>
{new Date(item.expiry_date).toLocaleDateString()}
</span>
</div>
)}
{item.cost_per_unit && (
<div>
<span className="text-[var(--text-tertiary)]">Costo/Unidad: </span>
<span className="font-medium text-[var(--text-primary)]">
${item.cost_per_unit.toFixed(2)}
</span>
</div>
)}
)}
</div>
<div className="text-sm text-[var(--text-secondary)] space-y-1">
<div className="flex space-x-4">
<span>Stock actual: <span className="font-medium">{item.current_stock} {item.unit}</span></span>
<span>Mínimo: <span className="font-medium">{item.min_stock}</span></span>
<span>Máximo: <span className="font-medium">{item.max_stock}</span></span>
</div>
{item.expiry_date && (
<div>Vence: <span className="font-medium">{item.expiry_date}</span></div>
)}
{item.supplier && (
<div>Proveedor: <span className="font-medium">{item.supplier}</span></div>
)}
</div>
</div>
{/* Actions */}
<div className="flex gap-2 ml-4 mt-1">
<Button
size="sm"
<div className="flex items-center space-x-2">
<Button
size="sm"
variant="outline"
onClick={() => setEditingItem(item)}
>
<Edit className="w-4 h-4 mr-1" />
Editar
</Button>
<Button
size="sm"
<Button
size="sm"
variant="outline"
onClick={() => handleDeleteItem(item.id)}
className="text-[var(--color-error)] border-[var(--color-error)] hover:bg-[var(--color-error)]/10"
className="text-red-600 border-red-200 hover:bg-red-50"
>
<Trash2 className="w-4 h-4" />
<Trash2 className="w-4 h-4 mr-1" />
Eliminar
</Button>
</div>
</div>
</Card>
);
})}
{getFilteredItems().length === 0 && (
<Card className="p-8 text-center">
<Package className="w-12 h-12 text-[var(--text-tertiary)] mx-auto mb-4" />
<p className="text-[var(--text-secondary)]">No hay elementos en esta categoría</p>
)}
</Card>
)}
))}
</div>
{/* Warnings */}
{(stats.lowStock > 0 || stats.nearExpiry > 0) && (
<Card className="p-4 bg-[var(--color-warning-50)] border-[var(--color-warning-200)]">
<div className="flex items-start space-x-3">
<AlertTriangle className="w-5 h-5 text-[var(--color-warning)] flex-shrink-0 mt-0.5" />
<div>
<h4 className="font-medium text-[var(--color-warning-800)] mb-1">Advertencias de Inventario</h4>
{stats.lowStock > 0 && (
<p className="text-sm text-[var(--color-warning-700)] mb-1">
{stats.lowStock} elemento(s) con stock bajo
</p>
)}
{stats.nearExpiry > 0 && (
<p className="text-sm text-[var(--color-warning-700)]">
{stats.nearExpiry} elemento(s) próximos a vencer
</p>
)}
</div>
</div>
</Card>
)}
{/* Edit Modal */}
{editingItem && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<Card className="p-6 max-w-md w-full mx-4 max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">
{isAddingNew ? 'Agregar Elemento' : 'Editar Elemento'}
</h3>
<InventoryItemForm
item={editingItem}
onSave={handleSaveItem}
onCancel={() => {
setEditingItem(null);
setIsAddingNew(false);
}}
/>
</Card>
</div>
)}
{/* Information */}
<Card className="p-4 bg-[var(--color-info)]/5 border-[var(--color-info)]/20">
<h4 className="font-medium text-[var(--color-info)] mb-2">
📦 Configuración de Inventario:
</h4>
<ul className="text-sm text-[var(--color-info)] space-y-1">
<li> <strong>Stock Mínimo:</strong> Nivel que dispara alertas de reabastecimiento</li>
<li> <strong>Stock Máximo:</strong> Capacidad máxima de almacenamiento</li>
<li> <strong>Fechas de Vencimiento:</strong> Control automático de productos perecederos</li>
<li> <strong>Refrigeración:</strong> Identifica productos que requieren frío</li>
</ul>
</Card>
{/* Navigation */}
<div className="flex justify-between pt-6 border-t border-[var(--border-primary)]">
<Button
variant="outline"
onClick={onPrevious}
disabled={isFirstStep}
>
Anterior
</Button>
<Button
onClick={onNext}
disabled={!data.inventoryConfigured}
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary)]/90"
>
{isLastStep ? 'Finalizar' : 'Siguiente'}
</Button>
</div>
</div>
);
};
// Component for editing inventory items
interface InventoryItemFormProps {
// Inventory Item Editor Component
const InventoryItemEditor: React.FC<{
item: InventoryItem;
onSave: (item: InventoryItem) => void;
onCancel: () => void;
}
}> = ({ item, onSave, onCancel }) => {
const [editedItem, setEditedItem] = useState<InventoryItem>(item);
const InventoryItemForm: React.FC<InventoryItemFormProps> = ({ item, onSave, onCancel }) => {
const [formData, setFormData] = useState(item);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim()) {
const handleSave = () => {
if (!editedItem.name.trim()) {
alert('El nombre es requerido');
return;
}
if (formData.min_stock >= formData.max_stock) {
alert('El stock máximo debe ser mayor al mínimo');
if (editedItem.min_stock < 0 || editedItem.max_stock <= editedItem.min_stock) {
alert('Los niveles de stock deben ser válidos (máximo > mínimo >= 0)');
return;
}
onSave(formData);
onSave(editedItem);
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Nombre *
</label>
<Input
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="Nombre del producto/ingrediente"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-4 p-4 bg-gray-50 rounded-lg">
<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">
Categoría *
</label>
<select
value={formData.category}
onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value as any }))}
className="w-full p-2 border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
<label className="block text-sm font-medium mb-1">Nombre</label>
<Input
value={editedItem.name}
onChange={(e) => setEditedItem({ ...editedItem, name: e.target.value })}
placeholder="Nombre del producto"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Categoría</label>
<select
value={editedItem.category}
onChange={(e) => setEditedItem({ ...editedItem, category: e.target.value as any })}
className="w-full px-3 py-2 border border-[var(--border-primary)] rounded-lg bg-white"
>
<option value="ingredient">Ingrediente</option>
<option value="finished_product">Producto Terminado</option>
<option value="finished_product">Producto terminado</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Unidad *
</label>
<Input
value={formData.unit}
onChange={(e) => setFormData(prev => ({ ...prev, unit: e.target.value }))}
placeholder="kg, unidades, litros..."
/>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Stock Actual
</label>
<label className="block text-sm font-medium mb-1">Stock actual</label>
<Input
type="number"
value={formData.current_stock}
onChange={(e) => setFormData(prev => ({ ...prev, current_stock: Number(e.target.value) }))}
value={editedItem.current_stock}
onChange={(e) => setEditedItem({ ...editedItem, current_stock: Number(e.target.value) })}
min="0"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Stock Mín. *
</label>
<label className="block text-sm font-medium mb-1">Unidad</label>
<Input
value={editedItem.unit}
onChange={(e) => setEditedItem({ ...editedItem, unit: e.target.value })}
placeholder="kg, litros, unidades..."
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Stock mínimo</label>
<Input
type="number"
value={formData.min_stock}
onChange={(e) => setFormData(prev => ({ ...prev, min_stock: Number(e.target.value) }))}
value={editedItem.min_stock}
onChange={(e) => setEditedItem({ ...editedItem, min_stock: Number(e.target.value) })}
min="0"
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Stock Máx. *
</label>
<label className="block text-sm font-medium mb-1">Stock máximo</label>
<Input
type="number"
value={formData.max_stock}
onChange={(e) => setFormData(prev => ({ ...prev, max_stock: Number(e.target.value) }))}
value={editedItem.max_stock}
onChange={(e) => setEditedItem({ ...editedItem, max_stock: Number(e.target.value) })}
min="1"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Fecha Vencimiento
</label>
<label className="block text-sm font-medium mb-1">Fecha de vencimiento</label>
<Input
type="date"
value={formData.expiry_date || ''}
onChange={(e) => setFormData(prev => ({ ...prev, expiry_date: e.target.value }))}
value={editedItem.expiry_date || ''}
onChange={(e) => setEditedItem({ ...editedItem, expiry_date: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Costo por Unidad
</label>
<label className="block text-sm font-medium mb-1">Proveedor</label>
<Input
type="number"
step="0.01"
value={formData.cost_per_unit || ''}
onChange={(e) => setFormData(prev => ({ ...prev, cost_per_unit: Number(e.target.value) }))}
min="0"
value={editedItem.supplier || ''}
onChange={(e) => setEditedItem({ ...editedItem, supplier: e.target.value })}
placeholder="Nombre del proveedor"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Proveedor
</label>
<Input
value={formData.supplier || ''}
onChange={(e) => setFormData(prev => ({ ...prev, supplier: e.target.value }))}
placeholder="Nombre del proveedor"
/>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="refrigeration"
checked={formData.requires_refrigeration}
onChange={(e) => setFormData(prev => ({ ...prev, requires_refrigeration: e.target.checked }))}
className="rounded"
/>
<label htmlFor="refrigeration" className="text-sm text-[var(--text-primary)]">
Requiere refrigeración
<div className="flex items-center space-x-4">
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={editedItem.requires_refrigeration}
onChange={(e) => setEditedItem({ ...editedItem, requires_refrigeration: e.target.checked })}
className="rounded"
/>
<span className="text-sm">Requiere refrigeración</span>
</label>
</div>
<div className="flex justify-end space-x-3 pt-4">
<Button type="button" variant="outline" onClick={onCancel}>
<div className="flex justify-end space-x-2">
<Button variant="outline" onClick={onCancel}>
Cancelar
</Button>
<Button type="submit">
<Button onClick={handleSave}>
Guardar
</Button>
</div>
</form>
</div>
);
};

View File

@@ -1,30 +1,68 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { Eye, CheckCircle, AlertCircle, Edit, Trash2 } from 'lucide-react';
import { Button, Card, Badge } from '../../../ui';
import { OnboardingStepProps } from '../OnboardingWizard';
import { useAlertActions } from '../../../../stores/alerts.store';
interface Product {
id: string;
name: string;
category: string;
confidence: number;
sales_count: number;
estimated_price: number;
sales_count?: number;
estimated_price?: number;
status: 'approved' | 'rejected' | 'pending';
notes?: string;
// Fields from API suggestion
suggestion_id?: string;
original_name: string;
suggested_name: string;
product_type: 'ingredient' | 'finished_product';
unit_of_measure: string;
estimated_shelf_life_days: number;
requires_refrigeration: boolean;
requires_freezing: boolean;
is_seasonal: boolean;
suggested_supplier?: string;
sales_data?: {
total_quantity: number;
average_daily_sales: number;
peak_day: string;
frequency: number;
};
}
// Mock detected products
const mockDetectedProducts: Product[] = [
{ id: '1', name: 'Pan Integral', category: 'Panadería', confidence: 95, sales_count: 45, estimated_price: 2.50, status: 'pending' },
{ id: '2', name: 'Croissant', category: 'Bollería', confidence: 92, sales_count: 38, estimated_price: 1.80, status: 'pending' },
{ id: '3', name: 'Baguette', category: 'Panadería', confidence: 88, sales_count: 22, estimated_price: 3.00, status: 'pending' },
{ id: '4', name: 'Empanada de Pollo', category: 'Salados', confidence: 85, sales_count: 31, estimated_price: 4.50, status: 'pending' },
{ id: '5', name: 'Tarta de Manzana', category: 'Repostería', confidence: 78, sales_count: 12, estimated_price: 15.00, status: 'pending' },
{ id: '6', name: 'Pan de Centeno', category: 'Panadería', confidence: 91, sales_count: 18, estimated_price: 2.80, status: 'pending' },
{ id: '7', name: 'Medialunas', category: 'Bollería', confidence: 87, sales_count: 29, estimated_price: 1.20, status: 'pending' },
{ id: '8', name: 'Sandwich Mixto', category: 'Salados', confidence: 82, sales_count: 25, estimated_price: 5.50, status: 'pending' }
];
// Convert API suggestions to Product interface
const convertSuggestionsToProducts = (suggestions: any[]): Product[] => {
console.log('ReviewStep - convertSuggestionsToProducts called with:', suggestions);
const products = suggestions.map((suggestion, index) => ({
id: suggestion.suggestion_id || `product-${index}`,
name: suggestion.suggested_name,
category: suggestion.category,
confidence: Math.round(suggestion.confidence_score * 100),
status: 'pending' as const,
// Store original API data
suggestion_id: suggestion.suggestion_id,
original_name: suggestion.original_name,
suggested_name: suggestion.suggested_name,
product_type: suggestion.product_type,
unit_of_measure: suggestion.unit_of_measure,
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,
// Legacy fields for display
sales_count: suggestion.sales_data?.total_quantity || 0,
estimated_price: 0 // Price estimation not provided by current API
}));
console.log('ReviewStep - Converted products:', products);
return products;
};
export const ReviewStep: React.FC<OnboardingStepProps> = ({
data,
@@ -34,35 +72,128 @@ export const ReviewStep: React.FC<OnboardingStepProps> = ({
isFirstStep,
isLastStep
}) => {
// Generate products from processing results or use mock data
const { createAlert } = useAlertActions();
// Generate products from AI suggestions in processing results
const generateProductsFromResults = (results: any) => {
if (!results?.product_list) return mockDetectedProducts;
console.log('ReviewStep - generateProductsFromResults called with:', results);
console.log('ReviewStep - results keys:', Object.keys(results || {}));
console.log('ReviewStep - results.aiSuggestions:', results?.aiSuggestions);
console.log('ReviewStep - aiSuggestions length:', results?.aiSuggestions?.length);
console.log('ReviewStep - aiSuggestions type:', typeof results?.aiSuggestions);
console.log('ReviewStep - aiSuggestions is array:', Array.isArray(results?.aiSuggestions));
return results.product_list.map((name: string, index: number) => ({
id: (index + 1).toString(),
name,
category: index < 3 ? 'Panadería' : index < 5 ? 'Bollería' : 'Salados',
confidence: Math.max(75, results.confidenceScore - Math.random() * 15),
sales_count: Math.floor(Math.random() * 50) + 10,
estimated_price: Math.random() * 5 + 1.5,
status: 'pending' as const
}));
if (results?.aiSuggestions && results.aiSuggestions.length > 0) {
console.log('ReviewStep - Using AI suggestions:', results.aiSuggestions);
return convertSuggestionsToProducts(results.aiSuggestions);
}
// Fallback: create products from product list if no AI suggestions
if (results?.product_list) {
console.log('ReviewStep - Using fallback product list:', results.product_list);
return results.product_list.map((name: string, index: number) => ({
id: `fallback-${index}`,
name,
original_name: name,
suggested_name: name,
category: 'Sin clasificar',
confidence: 50,
status: 'pending' as const,
product_type: 'finished_product' as const,
unit_of_measure: 'unidad',
estimated_shelf_life_days: 7,
requires_refrigeration: false,
requires_freezing: false,
is_seasonal: false
}));
}
return [];
};
const [products, setProducts] = useState<Product[]>(
data.detectedProducts || generateProductsFromResults(data.processingResults)
);
const [products, setProducts] = useState<Product[]>(() => {
if (data.detectedProducts) {
return data.detectedProducts;
}
// Try to get processing results from current step data first, then from previous step data
const processingResults = data.processingResults || data.allStepData?.['data-processing']?.processingResults;
console.log('ReviewStep - Initializing with processingResults:', processingResults);
return generateProductsFromResults(processingResults);
});
// Check for empty products and show alert after component mounts
useEffect(() => {
const processingResults = data.processingResults || data.allStepData?.['data-processing']?.processingResults;
if (products.length === 0 && processingResults) {
createAlert({
type: 'warning',
category: 'system',
priority: 'medium',
title: 'Sin productos detectados',
message: 'No se encontraron productos en los datos procesados. Verifique el archivo de ventas.',
source: 'onboarding'
});
}
}, [products.length, data.processingResults, data.allStepData, createAlert]);
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const categories = ['all', ...Array.from(new Set(products.map(p => p.category)))];
// Memoize computed values to avoid unnecessary recalculations
const approvedProducts = useMemo(() =>
products.filter(p => p.status === 'approved'),
[products]
);
const reviewCompleted = useMemo(() =>
products.length > 0 && products.every(p => p.status !== 'pending'),
[products]
);
const [lastReviewCompleted, setLastReviewCompleted] = useState(false);
const dataChangeRef = useRef({ products: [], approvedProducts: [], reviewCompleted: false });
// Update parent data when products change
useEffect(() => {
onDataChange({
...data,
detectedProducts: products,
reviewCompleted: products.every(p => p.status !== 'pending')
});
}, [products]);
const currentState = { products, approvedProducts, reviewCompleted };
const lastState = dataChangeRef.current;
// Only call onDataChange if the state actually changed
if (JSON.stringify(currentState) !== JSON.stringify(lastState)) {
console.log('ReviewStep - Updating parent data with:', {
detectedProducts: products,
approvedProducts,
reviewCompleted,
approvedProductsCount: approvedProducts.length
});
onDataChange({
...data,
detectedProducts: products,
approvedProducts,
reviewCompleted
});
dataChangeRef.current = currentState;
}
}, [products, approvedProducts, reviewCompleted]);
// Handle review completion alert separately
useEffect(() => {
if (reviewCompleted && approvedProducts.length > 0 && !lastReviewCompleted) {
createAlert({
type: 'success',
category: 'system',
priority: 'medium',
title: 'Revisión completada',
message: `Se aprobaron ${approvedProducts.length} de ${products.length} productos detectados.`,
source: 'onboarding'
});
setLastReviewCompleted(true);
}
if (!reviewCompleted && lastReviewCompleted) {
setLastReviewCompleted(false);
}
}, [reviewCompleted, approvedProducts.length, products.length, lastReviewCompleted, createAlert]);
const handleProductAction = (productId: string, action: 'approve' | 'reject') => {
setProducts(prev => prev.map(product =>
@@ -95,191 +226,188 @@ export const ReviewStep: React.FC<OnboardingStepProps> = ({
pending: products.filter(p => p.status === 'pending').length
};
const getConfidenceColor = (confidence: number) => {
if (confidence >= 90) return 'text-green-600 bg-green-50';
if (confidence >= 75) return 'text-yellow-600 bg-yellow-50';
return 'text-red-600 bg-red-50';
};
const getStatusColor = (status: string) => {
switch (status) {
case 'approved': return 'text-green-600 bg-green-50';
case 'rejected': return 'text-red-600 bg-red-50';
default: return 'text-gray-600 bg-gray-50';
}
};
if (products.length === 0) {
return (
<div className="space-y-8">
<div className="text-center py-16">
<AlertCircle className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-2xl font-bold text-gray-600 mb-2">
No se encontraron productos
</h3>
<p className="text-gray-500 mb-6 max-w-md mx-auto">
No se pudieron detectar productos en el archivo procesado.
Verifique que el archivo contenga datos de ventas válidos.
</p>
<Button
variant="outline"
onClick={onPrevious}
disabled={isFirstStep}
>
Volver al paso anterior
</Button>
</div>
</div>
);
}
return (
<div className="space-y-8">
{/* Summary Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-2xl font-bold text-[var(--color-info)]">{stats.total}</p>
<p className="text-sm text-[var(--text-secondary)]">Total</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<Card className="p-6 text-center">
<div className="text-3xl font-bold text-[var(--color-primary)] mb-2">{stats.total}</div>
<div className="text-sm text-[var(--text-secondary)]">Productos detectados</div>
</Card>
<Card className="p-6 text-center">
<div className="text-3xl font-bold text-green-600 mb-2">{stats.approved}</div>
<div className="text-sm text-[var(--text-secondary)]">Aprobados</div>
</Card>
<Card className="p-6 text-center">
<div className="text-3xl font-bold text-red-600 mb-2">{stats.rejected}</div>
<div className="text-sm text-[var(--text-secondary)]">Rechazados</div>
</Card>
<Card className="p-6 text-center">
<div className="text-3xl font-bold text-yellow-600 mb-2">{stats.pending}</div>
<div className="text-sm text-[var(--text-secondary)]">Pendientes</div>
</Card>
</div>
{/* Controls */}
<div className="flex flex-col sm:flex-row gap-4 justify-between items-start sm:items-center bg-[var(--bg-secondary)] p-4 rounded-lg">
<div className="flex items-center space-x-4">
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className="px-3 py-2 border border-[var(--border-primary)] rounded-lg bg-white"
>
{categories.map(category => (
<option key={category} value={category}>
{category === 'all' ? 'Todas las categorías' : category}
</option>
))}
</select>
<Badge variant="outline" className="text-sm">
{getFilteredProducts().length} productos
</Badge>
</div>
<div className="text-center p-4 bg-[var(--color-success)]/10 rounded-lg">
<p className="text-2xl font-bold text-[var(--color-success)]">{stats.approved}</p>
<p className="text-sm text-[var(--text-secondary)]">Aprobados</p>
</div>
<div className="text-center p-4 bg-[var(--color-error)]/10 rounded-lg">
<p className="text-2xl font-bold text-[var(--color-error)]">{stats.rejected}</p>
<p className="text-sm text-[var(--text-secondary)]">Rechazados</p>
</div>
<div className="text-center p-4 bg-[var(--color-warning)]/10 rounded-lg">
<p className="text-2xl font-bold text-[var(--color-warning)]">{stats.pending}</p>
<p className="text-sm text-[var(--text-secondary)]">Pendientes</p>
<div className="flex space-x-2">
<Button
size="sm"
variant="outline"
onClick={() => handleBulkAction('approve')}
className="text-green-600 border-green-200 hover:bg-green-50"
>
Aprobar todos
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleBulkAction('reject')}
className="text-red-600 border-red-200 hover:bg-red-50"
>
Rechazar todos
</Button>
</div>
</div>
{/* Filters and Actions */}
<Card className="p-6">
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
<div>
<label className="block text-sm font-semibold text-[var(--text-primary)] mb-2">
Filtrar por categoría:
</label>
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className="border border-[var(--border-secondary)] rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] focus:border-[var(--color-primary)]"
>
{categories.map(cat => (
<option key={cat} value={cat}>
{cat === 'all' ? 'Todas las categorías' : cat}
</option>
))}
</select>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => handleBulkAction('approve')}
className="text-[var(--color-success)] border-[var(--color-success)] hover:bg-[var(--color-success)]/10"
>
<CheckCircle className="w-4 h-4 mr-1" />
Aprobar Visibles
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleBulkAction('reject')}
className="text-[var(--color-error)] border-[var(--color-error)] hover:bg-[var(--color-error)]/10"
>
<Trash2 className="w-4 h-4 mr-1" />
Rechazar Visibles
</Button>
</div>
</div>
</Card>
{/* Products List */}
<div className="space-y-4">
<div className="space-y-3">
{getFilteredProducts().map((product) => (
<Card key={product.id} className="p-4">
<div className="flex items-start justify-between">
<div className="flex items-start space-x-4 flex-1">
{/* Status Icon */}
<div className={`w-8 h-8 rounded-full flex items-center justify-center mt-1 ${
product.status === 'approved'
? 'bg-[var(--color-success)]'
: product.status === 'rejected'
? 'bg-[var(--color-error)]'
: 'bg-[var(--bg-secondary)] border border-[var(--border-secondary)]'
}`}>
{product.status === 'approved' ? (
<CheckCircle className="w-4 h-4 text-white" />
) : product.status === 'rejected' ? (
<Trash2 className="w-4 h-4 text-white" />
) : (
<Eye className="w-4 h-4 text-[var(--text-tertiary)]" />
)}
<Card key={product.id} className="p-4 hover:shadow-md transition-shadow">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3 mb-2">
<h3 className="font-semibold text-[var(--text-primary)]">{product.name}</h3>
<Badge variant="outline" className="text-xs">
{product.category}
</Badge>
<Badge className={`text-xs ${getConfidenceColor(product.confidence)}`}>
{product.confidence}% confianza
</Badge>
<Badge className={`text-xs ${getStatusColor(product.status)}`}>
{product.status === 'approved' ? 'Aprobado' :
product.status === 'rejected' ? 'Rechazado' : 'Pendiente'}
</Badge>
</div>
{/* Product Info */}
<div className="flex-1">
<h4 className="font-semibold text-[var(--text-primary)] mb-2">{product.name}</h4>
<div className="flex items-center gap-2 mb-3">
<Badge variant="gray">{product.category}</Badge>
{product.status !== 'pending' && (
<Badge variant={product.status === 'approved' ? 'green' : 'red'}>
{product.status === 'approved' ? 'Aprobado' : 'Rechazado'}
</Badge>
<div className="text-sm text-[var(--text-secondary)] space-y-1">
{product.original_name && product.original_name !== product.name && (
<div>Nombre original: <span className="font-medium">{product.original_name}</span></div>
)}
<div className="flex space-x-4">
<span>Tipo: {product.product_type === 'ingredient' ? 'Ingrediente' : 'Producto terminado'}</span>
<span>Unidad: {product.unit_of_measure}</span>
{product.sales_data && (
<span>Ventas: {product.sales_data.total_quantity}</span>
)}
</div>
<div className="flex items-center gap-4 text-sm text-[var(--text-secondary)]">
<span className={`px-2 py-1 rounded text-xs ${
product.confidence >= 90
? 'bg-[var(--color-success)]/10 text-[var(--color-success)]'
: product.confidence >= 75
? 'bg-[var(--color-warning)]/10 text-[var(--color-warning)]'
: 'bg-[var(--color-error)]/10 text-[var(--color-error)]'
}`}>
{product.confidence}% confianza
</span>
<span>{product.sales_count} ventas</span>
<span>${product.estimated_price.toFixed(2)}</span>
</div>
{product.notes && (
<div className="text-xs italic">Nota: {product.notes}</div>
)}
</div>
</div>
{/* Actions */}
<div className="flex gap-2 ml-4 mt-1">
{product.status === 'pending' ? (
<>
<Button
size="sm"
onClick={() => handleProductAction(product.id, 'approve')}
className="bg-[var(--color-success)] hover:bg-[var(--color-success)]/90 text-white"
>
<CheckCircle className="w-4 h-4 mr-1" />
Aprobar
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleProductAction(product.id, 'reject')}
className="text-[var(--color-error)] border-[var(--color-error)] hover:bg-[var(--color-error)]/10"
>
<Trash2 className="w-4 h-4 mr-1" />
Rechazar
</Button>
</>
) : (
<Button
size="sm"
variant="outline"
onClick={() => setProducts(prev => prev.map(p => p.id === product.id ? {...p, status: 'pending'} : p))}
className="text-[var(--text-secondary)] hover:text-[var(--color-primary)]"
>
<Edit className="w-4 h-4 mr-1" />
Modificar
</Button>
)}
<div className="flex items-center space-x-2">
<Button
size="sm"
variant={product.status === 'approved' ? 'default' : 'outline'}
onClick={() => handleProductAction(product.id, 'approve')}
className={product.status === 'approved' ? 'bg-green-600 hover:bg-green-700' : 'text-green-600 border-green-200 hover:bg-green-50'}
>
<CheckCircle className="w-4 h-4 mr-1" />
Aprobar
</Button>
<Button
size="sm"
variant={product.status === 'rejected' ? 'default' : 'outline'}
onClick={() => handleProductAction(product.id, 'reject')}
className={product.status === 'rejected' ? 'bg-red-600 hover:bg-red-700' : 'text-red-600 border-red-200 hover:bg-red-50'}
>
<AlertCircle className="w-4 h-4 mr-1" />
Rechazar
</Button>
</div>
</div>
</Card>
))}
</div>
{/* Progress Indicator */}
{stats.pending > 0 && (
<Card className="p-4 bg-[var(--color-warning)]/5 border-[var(--color-warning)]/20">
<div className="flex items-center space-x-3">
<AlertCircle className="w-5 h-5 text-[var(--color-warning)]" />
<div>
<p className="font-medium text-[var(--text-primary)]">
{stats.pending} productos pendientes de revisión
</p>
<p className="text-sm text-[var(--text-secondary)]">
Revisa todos los productos antes de continuar al siguiente paso
</p>
</div>
</div>
</Card>
)}
{/* Help Information */}
<Card className="p-4 bg-[var(--color-info)]/5 border-[var(--color-info)]/20">
<h4 className="font-medium text-[var(--color-info)] mb-3">
💡 Consejos para la revisión:
</h4>
<ul className="text-sm text-[var(--color-info)] space-y-1">
<li> <strong>Confianza alta (90%+):</strong> Productos identificados con alta precisión</li>
<li> <strong>Confianza media (75-89%):</strong> Revisar nombres y categorías</li>
<li> <strong>Confianza baja (&lt;75%):</strong> Verificar que corresponden a tu catálogo</li>
<li> Usa las acciones masivas para aprobar/rechazar por categoría completa</li>
</ul>
</Card>
{/* Navigation */}
<div className="flex justify-between pt-6 border-t border-[var(--border-primary)]">
<Button
variant="outline"
onClick={onPrevious}
disabled={isFirstStep}
>
Anterior
</Button>
<Button
onClick={onNext}
disabled={!data.reviewCompleted || stats.approved === 0}
className="bg-[var(--color-primary)] hover:bg-[var(--color-primary)]/90"
>
{isLastStep ? 'Finalizar' : 'Siguiente'}
</Button>
</div>
</div>
);
};

View File

@@ -1,66 +1,40 @@
import React, { useState, useEffect } from 'react';
import { Truck, Phone, Mail, Plus, Edit, Trash2, MapPin } from 'lucide-react';
import { Truck, Phone, Mail, Plus, Edit, Trash2, MapPin, AlertCircle, Loader } from 'lucide-react';
import { Button, Card, Input, Badge } from '../../../ui';
import { OnboardingStepProps } from '../OnboardingWizard';
import { procurementService, type Supplier } from '../../../../services/api/procurement.service';
import { useAuthUser } from '../../../../stores/auth.store';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import { useAlertActions } from '../../../../stores/alerts.store';
interface Supplier {
id: string;
// Frontend supplier interface that matches the form needs
interface SupplierFormData {
id?: string;
name: string;
contact_person: string;
contact_name: string;
phone: string;
email: string;
address: string;
categories: string[];
payment_terms: string;
delivery_days: string[];
min_order_amount?: number;
notes?: string;
status: 'active' | 'inactive';
created_at: string;
delivery_terms: string;
tax_id?: string;
is_active: boolean;
}
// Mock suppliers
const mockSuppliers: Supplier[] = [
{
id: '1',
name: 'Molinos del Sur',
contact_person: 'Juan Pérez',
phone: '+1 555-0123',
email: 'ventas@molinosdelsur.com',
address: 'Av. Industrial 123, Zona Sur',
categories: ['Harinas', 'Granos'],
payment_terms: '30 días',
delivery_days: ['Lunes', 'Miércoles', 'Viernes'],
min_order_amount: 200,
notes: 'Proveedor principal de harinas, muy confiable',
status: 'active',
created_at: '2024-01-15'
},
{
id: '2',
name: 'Lácteos Premium',
contact_person: 'María González',
phone: '+1 555-0456',
email: 'pedidos@lacteospremium.com',
address: 'Calle Central 456, Centro',
categories: ['Lácteos', 'Mantequillas', 'Quesos'],
payment_terms: '15 días',
delivery_days: ['Martes', 'Jueves', 'Sábado'],
min_order_amount: 150,
status: 'active',
created_at: '2024-01-20'
}
];
const daysOfWeek = [
'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'
];
const commonCategories = [
'Harinas', 'Lácteos', 'Levaduras', 'Azúcares', 'Grasas', 'Huevos',
'Frutas', 'Chocolates', 'Frutos Secos', 'Especias', 'Conservantes'
];
const paymentTermsOptions = [
'Inmediato',
'15 días',
'30 días',
'45 días',
'60 días',
'90 días'
];
export const SuppliersStep: React.FC<OnboardingStepProps> = ({
data,
onDataChange,
@@ -69,13 +43,57 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
isFirstStep,
isLastStep
}) => {
const [suppliers, setSuppliers] = useState<Supplier[]>(
data.suppliers || mockSuppliers
);
const [editingSupplier, setEditingSupplier] = useState<Supplier | null>(null);
const user = useAuthUser();
const currentTenant = useCurrentTenant();
const { createAlert } = useAlertActions();
const [suppliers, setSuppliers] = useState<Supplier[]>([]);
const [editingSupplier, setEditingSupplier] = useState<SupplierFormData | null>(null);
const [isAddingNew, setIsAddingNew] = useState(false);
const [filterStatus, setFilterStatus] = useState<'all' | 'active' | 'inactive'>('all');
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false);
const [updating, setUpdating] = useState(false);
const [deleting, setDeleting] = useState<string | null>(null);
// Load suppliers from backend on component mount
useEffect(() => {
const loadSuppliers = async () => {
// Check if we already have suppliers loaded
if (data.suppliers && Array.isArray(data.suppliers) && data.suppliers.length > 0) {
setSuppliers(data.suppliers);
return;
}
if (!currentTenant?.id) {
return;
}
setLoading(true);
try {
const response = await procurementService.getSuppliers({ size: 100 });
if (response.success && response.data) {
setSuppliers(response.data.items);
}
} catch (error) {
console.error('Failed to load suppliers:', error);
createAlert({
type: 'error',
category: 'system',
priority: 'medium',
title: 'Error al cargar proveedores',
message: 'No se pudieron cargar los proveedores existentes.',
source: 'onboarding'
});
} finally {
setLoading(false);
}
};
loadSuppliers();
}, [currentTenant?.id]);
// Update parent data when suppliers change
useEffect(() => {
onDataChange({
...data,
@@ -85,60 +103,202 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
}, [suppliers]);
const handleAddSupplier = () => {
const newSupplier: Supplier = {
id: Date.now().toString(),
const newSupplier: SupplierFormData = {
name: '',
contact_person: '',
contact_name: '',
phone: '',
email: '',
address: '',
categories: [],
payment_terms: '30 días',
delivery_days: [],
status: 'active',
created_at: new Date().toISOString().split('T')[0]
delivery_terms: 'Recoger en tienda',
tax_id: '',
is_active: true
};
setEditingSupplier(newSupplier);
setIsAddingNew(true);
};
const handleSaveSupplier = (supplier: Supplier) => {
const handleSaveSupplier = async (supplierData: SupplierFormData) => {
if (isAddingNew) {
setSuppliers(prev => [...prev, supplier]);
setCreating(true);
try {
const response = await procurementService.createSupplier({
name: supplierData.name,
contact_name: supplierData.contact_name,
phone: supplierData.phone,
email: supplierData.email,
address: supplierData.address,
payment_terms: supplierData.payment_terms,
delivery_terms: supplierData.delivery_terms,
tax_id: supplierData.tax_id,
is_active: supplierData.is_active
});
if (response.success && response.data) {
setSuppliers(prev => [...prev, response.data]);
createAlert({
type: 'success',
category: 'system',
priority: 'low',
title: 'Proveedor creado',
message: `El proveedor ${response.data.name} se ha creado exitosamente.`,
source: 'onboarding'
});
}
} catch (error) {
console.error('Failed to create supplier:', error);
createAlert({
type: 'error',
category: 'system',
priority: 'high',
title: 'Error al crear proveedor',
message: 'No se pudo crear el proveedor. Intente nuevamente.',
source: 'onboarding'
});
} finally {
setCreating(false);
}
} else {
setSuppliers(prev => prev.map(s => s.id === supplier.id ? supplier : s));
// Update existing supplier
if (!supplierData.id) return;
setUpdating(true);
try {
const response = await procurementService.updateSupplier(supplierData.id, {
name: supplierData.name,
contact_name: supplierData.contact_name,
phone: supplierData.phone,
email: supplierData.email,
address: supplierData.address,
payment_terms: supplierData.payment_terms,
delivery_terms: supplierData.delivery_terms,
tax_id: supplierData.tax_id,
is_active: supplierData.is_active
});
if (response.success && response.data) {
setSuppliers(prev => prev.map(s => s.id === response.data.id ? response.data : s));
createAlert({
type: 'success',
category: 'system',
priority: 'low',
title: 'Proveedor actualizado',
message: `El proveedor ${response.data.name} se ha actualizado exitosamente.`,
source: 'onboarding'
});
}
} catch (error) {
console.error('Failed to update supplier:', error);
createAlert({
type: 'error',
category: 'system',
priority: 'high',
title: 'Error al actualizar proveedor',
message: 'No se pudo actualizar el proveedor. Intente nuevamente.',
source: 'onboarding'
});
} finally {
setUpdating(false);
}
}
setEditingSupplier(null);
setIsAddingNew(false);
};
const handleDeleteSupplier = (id: string) => {
if (window.confirm('¿Estás seguro de eliminar este proveedor?')) {
setSuppliers(prev => prev.filter(s => s.id !== id));
const handleDeleteSupplier = async (id: string) => {
if (!window.confirm('¿Estás seguro de eliminar este proveedor? Esta acción no se puede deshacer.')) {
return;
}
setDeleting(id);
try {
const response = await procurementService.deleteSupplier(id);
if (response.success) {
setSuppliers(prev => prev.filter(s => s.id !== id));
createAlert({
type: 'success',
category: 'system',
priority: 'low',
title: 'Proveedor eliminado',
message: 'El proveedor se ha eliminado exitosamente.',
source: 'onboarding'
});
}
} catch (error) {
console.error('Failed to delete supplier:', error);
createAlert({
type: 'error',
category: 'system',
priority: 'high',
title: 'Error al eliminar proveedor',
message: 'No se pudo eliminar el proveedor. Intente nuevamente.',
source: 'onboarding'
});
} finally {
setDeleting(null);
}
};
const toggleSupplierStatus = (id: string) => {
setSuppliers(prev => prev.map(s =>
s.id === id
? { ...s, status: s.status === 'active' ? 'inactive' : 'active' }
: s
));
const toggleSupplierStatus = async (id: string, currentStatus: boolean) => {
try {
const response = await procurementService.updateSupplier(id, {
is_active: !currentStatus
});
if (response.success && response.data) {
setSuppliers(prev => prev.map(s =>
s.id === id ? response.data : s
));
createAlert({
type: 'success',
category: 'system',
priority: 'low',
title: 'Estado actualizado',
message: `El proveedor se ha ${!currentStatus ? 'activado' : 'desactivado'} exitosamente.`,
source: 'onboarding'
});
}
} catch (error) {
console.error('Failed to toggle supplier status:', error);
createAlert({
type: 'error',
category: 'system',
priority: 'high',
title: 'Error al cambiar estado',
message: 'No se pudo cambiar el estado del proveedor.',
source: 'onboarding'
});
}
};
const getFilteredSuppliers = () => {
return filterStatus === 'all'
? suppliers
: suppliers.filter(s => s.status === filterStatus);
if (filterStatus === 'all') {
return suppliers;
}
return suppliers.filter(s =>
filterStatus === 'active' ? s.is_active : !s.is_active
);
};
const stats = {
total: suppliers.length,
active: suppliers.filter(s => s.status === 'active').length,
inactive: suppliers.filter(s => s.status === 'inactive').length,
categories: Array.from(new Set(suppliers.flatMap(s => s.categories))).length
active: suppliers.filter(s => s.is_active).length,
inactive: suppliers.filter(s => !s.is_active).length,
totalOrders: suppliers.reduce((sum, s) => sum + s.performance_metrics.total_orders, 0)
};
if (loading) {
return (
<div className="flex items-center justify-center py-16">
<div className="text-center">
<Loader className="w-8 h-8 animate-spin text-[var(--color-primary)] mx-auto mb-4" />
<p className="text-[var(--text-secondary)]">Cargando proveedores...</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Optional Step Notice */}
@@ -161,8 +321,13 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
<Button
size="sm"
onClick={handleAddSupplier}
disabled={creating}
>
<Plus className="w-4 h-4 mr-2" />
{creating ? (
<Loader className="w-4 h-4 mr-2 animate-spin" />
) : (
<Plus className="w-4 h-4 mr-2" />
)}
Agregar Proveedor
</Button>
</div>
@@ -184,8 +349,8 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
<p className="text-xs text-[var(--text-secondary)]">Inactivos</p>
</Card>
<Card className="p-4 text-center">
<p className="text-2xl font-bold text-[var(--color-primary)]">{stats.categories}</p>
<p className="text-xs text-[var(--text-secondary)]">Categorías</p>
<p className="text-2xl font-bold text-[var(--color-primary)]">{stats.totalOrders}</p>
<p className="text-xs text-[var(--text-secondary)]">Órdenes</p>
</Card>
</div>
@@ -223,12 +388,12 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
<div className="flex items-start space-x-4 flex-1">
{/* Status Icon */}
<div className={`w-8 h-8 rounded-full flex items-center justify-center mt-1 ${
supplier.status === 'active'
supplier.is_active
? 'bg-[var(--color-success)]/10'
: 'bg-[var(--bg-secondary)] border border-[var(--border-secondary)]'
}`}>
<Truck className={`w-4 h-4 ${
supplier.status === 'active'
supplier.is_active
? 'text-[var(--color-success)]'
: 'text-[var(--text-tertiary)]'
}`} />
@@ -239,63 +404,70 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
<h4 className="font-semibold text-[var(--text-primary)] mb-2">{supplier.name}</h4>
<div className="flex items-center gap-2 mb-3">
<Badge variant={supplier.status === 'active' ? 'green' : 'gray'}>
{supplier.status === 'active' ? 'Activo' : 'Inactivo'}
<Badge variant={supplier.is_active ? 'green' : 'gray'}>
{supplier.is_active ? 'Activo' : 'Inactivo'}
</Badge>
{supplier.categories.slice(0, 2).map((cat, idx) => (
<Badge key={idx} variant="blue">{cat}</Badge>
))}
{supplier.categories.length > 2 && (
<Badge variant="gray">+{supplier.categories.length - 2}</Badge>
{supplier.rating && (
<Badge variant="blue"> {supplier.rating.toFixed(1)}</Badge>
)}
{supplier.performance_metrics.total_orders > 0 && (
<Badge variant="purple">{supplier.performance_metrics.total_orders} órdenes</Badge>
)}
</div>
<div className="flex items-center gap-6 text-sm text-[var(--text-secondary)] mb-2">
<div>
<span className="text-[var(--text-tertiary)]">Contacto: </span>
<span className="font-medium text-[var(--text-primary)]">{supplier.contact_person}</span>
</div>
<div>
<span className="text-[var(--text-tertiary)]">Entrega: </span>
<span className="font-medium text-[var(--text-primary)]">
{supplier.delivery_days.slice(0, 2).join(', ')}
{supplier.delivery_days.length > 2 && ` +${supplier.delivery_days.length - 2}`}
</span>
</div>
<div>
<span className="text-[var(--text-tertiary)]">Pago: </span>
<span className="font-medium text-[var(--text-primary)]">{supplier.payment_terms}</span>
</div>
{supplier.min_order_amount && (
{supplier.contact_name && (
<div>
<span className="text-[var(--text-tertiary)]">Mín: </span>
<span className="font-medium text-[var(--text-primary)]">${supplier.min_order_amount}</span>
<span className="text-[var(--text-tertiary)]">Contacto: </span>
<span className="font-medium text-[var(--text-primary)]">{supplier.contact_name}</span>
</div>
)}
{supplier.delivery_terms && (
<div>
<span className="text-[var(--text-tertiary)]">Entrega: </span>
<span className="font-medium text-[var(--text-primary)]">{supplier.delivery_terms}</span>
</div>
)}
{supplier.payment_terms && (
<div>
<span className="text-[var(--text-tertiary)]">Pago: </span>
<span className="font-medium text-[var(--text-primary)]">{supplier.payment_terms}</span>
</div>
)}
</div>
<div className="flex items-center gap-4 text-sm text-[var(--text-secondary)]">
<div className="flex items-center gap-1">
<Phone className="w-3 h-3" />
<span>{supplier.phone}</span>
</div>
<div className="flex items-center gap-1">
<Mail className="w-3 h-3" />
<span>{supplier.email}</span>
</div>
{supplier.phone && (
<div className="flex items-center gap-1">
<Phone className="w-3 h-3" />
<span>{supplier.phone}</span>
</div>
)}
{supplier.email && (
<div className="flex items-center gap-1">
<Mail className="w-3 h-3" />
<span>{supplier.email}</span>
</div>
)}
<div className="flex items-center gap-1">
<MapPin className="w-3 h-3" />
<span>{supplier.address}</span>
</div>
</div>
{supplier.notes && (
{supplier.performance_metrics.on_time_delivery_rate > 0 && (
<div className="mt-3 p-2 bg-[var(--bg-secondary)] rounded text-sm">
<span className="text-[var(--text-tertiary)]">Notas: </span>
<span className="text-[var(--text-primary)]">{supplier.notes}</span>
<span className="text-[var(--text-tertiary)]">Rendimiento: </span>
<span className="text-[var(--text-primary)]">
{supplier.performance_metrics.on_time_delivery_rate}% entregas a tiempo
</span>
{supplier.performance_metrics.quality_score > 0 && (
<span className="text-[var(--text-primary)]">
, {supplier.performance_metrics.quality_score}/5 calidad
</span>
)}
</div>
)}
</div>
@@ -306,7 +478,24 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
<Button
size="sm"
variant="outline"
onClick={() => setEditingSupplier(supplier)}
onClick={() => {
// Convert backend supplier to form data
const formData: SupplierFormData = {
id: supplier.id,
name: supplier.name,
contact_name: supplier.contact_name || '',
phone: supplier.phone || '',
email: supplier.email || '',
address: supplier.address,
payment_terms: supplier.payment_terms || '30 días',
delivery_terms: supplier.delivery_terms || 'Recoger en tienda',
tax_id: supplier.tax_id || '',
is_active: supplier.is_active
};
setEditingSupplier(formData);
setIsAddingNew(false);
}}
disabled={updating}
>
<Edit className="w-4 h-4 mr-1" />
Editar
@@ -315,13 +504,14 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
<Button
size="sm"
variant="outline"
onClick={() => toggleSupplierStatus(supplier.id)}
className={supplier.status === 'active'
onClick={() => toggleSupplierStatus(supplier.id, supplier.is_active)}
className={supplier.is_active
? 'text-[var(--color-warning)] border-[var(--color-warning)] hover:bg-[var(--color-warning)]/10'
: 'text-[var(--color-success)] border-[var(--color-success)] hover:bg-[var(--color-success)]/10'
}
disabled={updating}
>
{supplier.status === 'active' ? 'Pausar' : 'Activar'}
{supplier.is_active ? 'Pausar' : 'Activar'}
</Button>
<Button
@@ -329,19 +519,29 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
variant="outline"
onClick={() => handleDeleteSupplier(supplier.id)}
className="text-[var(--color-error)] border-[var(--color-error)] hover:bg-[var(--color-error)]/10"
disabled={deleting === supplier.id}
>
<Trash2 className="w-4 h-4" />
{deleting === supplier.id ? (
<Loader className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
</Button>
</div>
</div>
</Card>
))}
{getFilteredSuppliers().length === 0 && (
{getFilteredSuppliers().length === 0 && !loading && (
<Card className="p-8 text-center">
<Truck className="w-12 h-12 text-[var(--text-tertiary)] mx-auto mb-4" />
<p className="text-[var(--text-secondary)] mb-4">No hay proveedores en esta categoría</p>
<Button onClick={handleAddSupplier}>
<p className="text-[var(--text-secondary)] mb-4">
{filterStatus === 'all'
? 'No hay proveedores registrados'
: `No hay proveedores ${filterStatus === 'active' ? 'activos' : 'inactivos'}`
}
</p>
<Button onClick={handleAddSupplier} disabled={creating}>
<Plus className="w-4 h-4 mr-2" />
Agregar Primer Proveedor
</Button>
@@ -364,6 +564,8 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
setEditingSupplier(null);
setIsAddingNew(false);
}}
isCreating={creating}
isUpdating={updating}
/>
</Card>
</div>
@@ -388,45 +590,37 @@ export const SuppliersStep: React.FC<OnboardingStepProps> = ({
// Component for editing suppliers
interface SupplierFormProps {
supplier: Supplier;
onSave: (supplier: Supplier) => void;
supplier: SupplierFormData;
onSave: (supplier: SupplierFormData) => void;
onCancel: () => void;
isCreating: boolean;
isUpdating: boolean;
}
const SupplierForm: React.FC<SupplierFormProps> = ({ supplier, onSave, onCancel }) => {
const SupplierForm: React.FC<SupplierFormProps> = ({
supplier,
onSave,
onCancel,
isCreating,
isUpdating
}) => {
const [formData, setFormData] = useState(supplier);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim()) {
alert('El nombre es requerido');
alert('El nombre de la empresa es requerido');
return;
}
if (!formData.contact_person.trim()) {
alert('El contacto es requerido');
if (!formData.address.trim()) {
alert('La dirección es requerida');
return;
}
onSave(formData);
};
const toggleCategory = (category: string) => {
setFormData(prev => ({
...prev,
categories: prev.categories.includes(category)
? prev.categories.filter(c => c !== category)
: [...prev.categories, category]
}));
};
const toggleDeliveryDay = (day: string) => {
setFormData(prev => ({
...prev,
delivery_days: prev.delivery_days.includes(day)
? prev.delivery_days.filter(d => d !== day)
: [...prev.delivery_days, day]
}));
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
@@ -438,17 +632,19 @@ const SupplierForm: React.FC<SupplierFormProps> = ({ supplier, onSave, onCancel
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="Molinos del Sur"
disabled={isCreating || isUpdating}
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Persona de Contacto *
Persona de Contacto
</label>
<Input
value={formData.contact_person}
onChange={(e) => setFormData(prev => ({ ...prev, contact_person: e.target.value }))}
value={formData.contact_name}
onChange={(e) => setFormData(prev => ({ ...prev, contact_name: e.target.value }))}
placeholder="Juan Pérez"
disabled={isCreating || isUpdating}
/>
</div>
</div>
@@ -462,6 +658,7 @@ const SupplierForm: React.FC<SupplierFormProps> = ({ supplier, onSave, onCancel
value={formData.phone}
onChange={(e) => setFormData(prev => ({ ...prev, phone: e.target.value }))}
placeholder="+1 555-0123"
disabled={isCreating || isUpdating}
/>
</div>
@@ -474,40 +671,23 @@ const SupplierForm: React.FC<SupplierFormProps> = ({ supplier, onSave, onCancel
value={formData.email}
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
placeholder="ventas@proveedor.com"
disabled={isCreating || isUpdating}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Dirección
Dirección *
</label>
<Input
value={formData.address}
onChange={(e) => setFormData(prev => ({ ...prev, address: e.target.value }))}
placeholder="Av. Industrial 123, Zona Sur"
disabled={isCreating || isUpdating}
/>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
Categorías de Productos
</label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{commonCategories.map(category => (
<label key={category} className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={formData.categories.includes(category)}
onChange={() => toggleCategory(category)}
className="rounded"
/>
<span className="text-sm text-[var(--text-primary)]">{category}</span>
</label>
))}
</div>
</div>
<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">
@@ -517,67 +697,74 @@ const SupplierForm: React.FC<SupplierFormProps> = ({ supplier, onSave, onCancel
value={formData.payment_terms}
onChange={(e) => setFormData(prev => ({ ...prev, payment_terms: e.target.value }))}
className="w-full p-2 border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
disabled={isCreating || isUpdating}
>
<option value="Inmediato">Inmediato</option>
<option value="15 días">15 días</option>
<option value="30 días">30 días</option>
<option value="45 días">45 días</option>
<option value="60 días">60 días</option>
{paymentTermsOptions.map(term => (
<option key={term} value={term}>{term}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Pedido Mínimo
Términos de Entrega
</label>
<Input
type="number"
value={formData.min_order_amount || ''}
onChange={(e) => setFormData(prev => ({ ...prev, min_order_amount: Number(e.target.value) }))}
placeholder="200"
min="0"
value={formData.delivery_terms}
onChange={(e) => setFormData(prev => ({ ...prev, delivery_terms: e.target.value }))}
placeholder="Recoger en tienda"
disabled={isCreating || isUpdating}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
Días de Entrega
</label>
<div className="flex flex-wrap gap-2">
{daysOfWeek.map(day => (
<label key={day} className="flex items-center space-x-2 cursor-pointer">
<input
type="checkbox"
checked={formData.delivery_days.includes(day)}
onChange={() => toggleDeliveryDay(day)}
className="rounded"
/>
<span className="text-sm text-[var(--text-primary)]">{day}</span>
</label>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
Notas
RUT/NIT (Opcional)
</label>
<textarea
value={formData.notes || ''}
onChange={(e) => setFormData(prev => ({ ...prev, notes: e.target.value }))}
placeholder="Información adicional sobre el proveedor..."
className="w-full p-2 border border-[var(--border-secondary)] rounded resize-none focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)]"
rows={3}
<Input
value={formData.tax_id || ''}
onChange={(e) => setFormData(prev => ({ ...prev, tax_id: e.target.value }))}
placeholder="12345678-9"
disabled={isCreating || isUpdating}
/>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
id="is_active"
checked={formData.is_active}
onChange={(e) => setFormData(prev => ({ ...prev, is_active: e.target.checked }))}
disabled={isCreating || isUpdating}
className="rounded"
/>
<label htmlFor="is_active" className="text-sm text-[var(--text-primary)]">
Proveedor activo
</label>
</div>
<div className="flex justify-end space-x-3 pt-4 border-t">
<Button type="button" variant="outline" onClick={onCancel}>
<Button
type="button"
variant="outline"
onClick={onCancel}
disabled={isCreating || isUpdating}
>
Cancelar
</Button>
<Button type="submit">
Guardar Proveedor
<Button
type="submit"
disabled={isCreating || isUpdating}
>
{isCreating || isUpdating ? (
<>
<Loader className="w-4 h-4 mr-2 animate-spin" />
{isCreating ? 'Creando...' : 'Actualizando...'}
</>
) : (
'Guardar Proveedor'
)}
</Button>
</div>
</form>