Start integrating the onboarding flow with backend 2
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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={`
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 (<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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user