Add onboarding flow improvements
This commit is contained in:
@@ -101,7 +101,7 @@ export const RegisterForm: React.FC<RegisterFormProps> = ({
|
||||
} catch (error) {
|
||||
console.error('Error calling onSuccess:', error);
|
||||
// Fallback: direct redirect if callback fails
|
||||
window.location.href = '/app/onboarding/setup';
|
||||
window.location.href = '/app/onboarding';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,402 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Input, Select, Badge } from '../../ui';
|
||||
import type { OnboardingStepProps } from './OnboardingWizard';
|
||||
|
||||
interface CompanyInfo {
|
||||
name: string;
|
||||
type: 'artisan' | 'dependent';
|
||||
size: 'small' | 'medium' | 'large';
|
||||
locations: number;
|
||||
specialties: string[];
|
||||
address: {
|
||||
street: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postal_code: string;
|
||||
country: string;
|
||||
};
|
||||
contact: {
|
||||
phone: string;
|
||||
email: string;
|
||||
website?: string;
|
||||
};
|
||||
established_year?: number;
|
||||
tax_id?: string;
|
||||
}
|
||||
|
||||
const BAKERY_TYPES = [
|
||||
{ value: 'artisan', label: 'Panadería Artesanal Local', description: 'Producción propia y tradicional en el local' },
|
||||
{ value: 'dependent', label: 'Panadería Dependiente', description: 'Dependiente de un panadero central' },
|
||||
];
|
||||
|
||||
const BAKERY_SIZES = [
|
||||
{ value: 'small', label: 'Pequeña', description: '1-10 empleados' },
|
||||
{ value: 'medium', label: 'Mediana', description: '11-50 empleados' },
|
||||
{ value: 'large', label: 'Grande', description: '50+ empleados' },
|
||||
];
|
||||
|
||||
const COMMON_SPECIALTIES = [
|
||||
'Pan tradicional',
|
||||
'Bollería',
|
||||
'Repostería',
|
||||
'Pan integral',
|
||||
'Pasteles',
|
||||
'Productos sin gluten',
|
||||
'Productos veganos',
|
||||
'Pan artesanal',
|
||||
'Productos de temporada',
|
||||
'Catering',
|
||||
];
|
||||
|
||||
export const CompanyInfoStep: React.FC<OnboardingStepProps> = ({
|
||||
data,
|
||||
onDataChange,
|
||||
}) => {
|
||||
const companyData: CompanyInfo = {
|
||||
name: '',
|
||||
type: 'artisan',
|
||||
size: 'small',
|
||||
locations: 1,
|
||||
specialties: [],
|
||||
address: {
|
||||
street: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postal_code: '',
|
||||
country: 'España',
|
||||
},
|
||||
contact: {
|
||||
phone: '',
|
||||
email: '',
|
||||
website: '',
|
||||
},
|
||||
...data,
|
||||
};
|
||||
|
||||
const handleInputChange = (field: string, value: any) => {
|
||||
onDataChange({
|
||||
...companyData,
|
||||
[field]: value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddressChange = (field: string, value: string) => {
|
||||
onDataChange({
|
||||
...companyData,
|
||||
address: {
|
||||
...companyData.address,
|
||||
[field]: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleContactChange = (field: string, value: string) => {
|
||||
onDataChange({
|
||||
...companyData,
|
||||
contact: {
|
||||
...companyData.contact,
|
||||
[field]: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSpecialtyToggle = (specialty: string) => {
|
||||
const currentSpecialties = companyData.specialties || [];
|
||||
const updatedSpecialties = currentSpecialties.includes(specialty)
|
||||
? currentSpecialties.filter(s => s !== specialty)
|
||||
: [...currentSpecialties, specialty];
|
||||
|
||||
handleInputChange('specialties', updatedSpecialties);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Basic Information */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] border-b pb-2">
|
||||
Información básica
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-1">
|
||||
Nombre de la panadería *
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={companyData.name}
|
||||
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||
placeholder="Ej: Panadería San Miguel"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-1">
|
||||
Tipo de panadería *
|
||||
</label>
|
||||
<Select
|
||||
value={companyData.type}
|
||||
onChange={(e) => handleInputChange('type', e.target.value)}
|
||||
className="w-full"
|
||||
>
|
||||
{BAKERY_TYPES.map((type) => (
|
||||
<option key={type.value} value={type.value}>
|
||||
{type.label} - {type.description}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-1">
|
||||
Tamaño de la empresa *
|
||||
</label>
|
||||
<Select
|
||||
value={companyData.size}
|
||||
onChange={(e) => handleInputChange('size', e.target.value)}
|
||||
className="w-full"
|
||||
>
|
||||
{BAKERY_SIZES.map((size) => (
|
||||
<option key={size.value} value={size.value}>
|
||||
{size.label} - {size.description}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-1">
|
||||
Número de ubicaciones
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={companyData.locations}
|
||||
onChange={(e) => handleInputChange('locations', parseInt(e.target.value) || 1)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-1">
|
||||
Año de fundación
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1900"
|
||||
max={new Date().getFullYear()}
|
||||
value={companyData.established_year || ''}
|
||||
onChange={(e) => handleInputChange('established_year', parseInt(e.target.value) || undefined)}
|
||||
placeholder="Ej: 1995"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-1">
|
||||
NIF/CIF
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={companyData.tax_id || ''}
|
||||
onChange={(e) => handleInputChange('tax_id', e.target.value)}
|
||||
placeholder="Ej: B12345678"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Specialties */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] border-b pb-2">
|
||||
Especialidades
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Selecciona los productos que produces habitualmente
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||
{COMMON_SPECIALTIES.map((specialty) => (
|
||||
<button
|
||||
key={specialty}
|
||||
onClick={() => handleSpecialtyToggle(specialty)}
|
||||
className={`p-3 text-left border rounded-lg transition-colors ${
|
||||
companyData.specialties?.includes(specialty)
|
||||
? 'border-blue-500 bg-[var(--color-info)]/5 text-[var(--color-info)]'
|
||||
: 'border-[var(--border-primary)] hover:border-[var(--border-secondary)]'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm font-medium">{specialty}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{companyData.specialties && companyData.specialties.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Especialidades seleccionadas:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{companyData.specialties.map((specialty) => (
|
||||
<Badge key={specialty} className="bg-[var(--color-info)]/10 text-[var(--color-info)]">
|
||||
{specialty}
|
||||
<button
|
||||
onClick={() => handleSpecialtyToggle(specialty)}
|
||||
className="ml-1 text-[var(--color-info)] hover:text-[var(--color-info)]"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] border-b pb-2">
|
||||
Dirección principal
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-1">
|
||||
Dirección *
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={companyData.address.street}
|
||||
onChange={(e) => handleAddressChange('street', e.target.value)}
|
||||
placeholder="Calle, número, piso, puerta"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-1">
|
||||
Ciudad *
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={companyData.address.city}
|
||||
onChange={(e) => handleAddressChange('city', e.target.value)}
|
||||
placeholder="Ej: Madrid"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-1">
|
||||
Provincia/Estado *
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={companyData.address.state}
|
||||
onChange={(e) => handleAddressChange('state', e.target.value)}
|
||||
placeholder="Ej: Madrid"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-1">
|
||||
Código postal *
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={companyData.address.postal_code}
|
||||
onChange={(e) => handleAddressChange('postal_code', e.target.value)}
|
||||
placeholder="Ej: 28001"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-1">
|
||||
País *
|
||||
</label>
|
||||
<Select
|
||||
value={companyData.address.country}
|
||||
onChange={(e) => handleAddressChange('country', e.target.value)}
|
||||
className="w-full"
|
||||
>
|
||||
<option value="España">España</option>
|
||||
<option value="Francia">Francia</option>
|
||||
<option value="Portugal">Portugal</option>
|
||||
<option value="Italia">Italia</option>
|
||||
<option value="México">México</option>
|
||||
<option value="Argentina">Argentina</option>
|
||||
<option value="Colombia">Colombia</option>
|
||||
<option value="Otro">Otro</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Information */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] border-b pb-2">
|
||||
Información de contacto
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-1">
|
||||
Teléfono *
|
||||
</label>
|
||||
<Input
|
||||
type="tel"
|
||||
value={companyData.contact.phone}
|
||||
onChange={(e) => handleContactChange('phone', e.target.value)}
|
||||
placeholder="Ej: +34 911 234 567"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-1">
|
||||
Email de contacto *
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={companyData.contact.email}
|
||||
onChange={(e) => handleContactChange('email', e.target.value)}
|
||||
placeholder="contacto@panaderia.com"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-1">
|
||||
Sitio web (opcional)
|
||||
</label>
|
||||
<Input
|
||||
type="url"
|
||||
value={companyData.contact.website || ''}
|
||||
onChange={(e) => handleContactChange('website', e.target.value)}
|
||||
placeholder="https://www.panaderia.com"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="bg-[var(--bg-secondary)] p-4 rounded-lg">
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-2">Resumen</h4>
|
||||
<div className="text-sm text-[var(--text-secondary)] space-y-1">
|
||||
<p><strong>Panadería:</strong> {companyData.name || 'Sin especificar'}</p>
|
||||
<p><strong>Tipo:</strong> {BAKERY_TYPES.find(t => t.value === companyData.type)?.label}</p>
|
||||
<p><strong>Tamaño:</strong> {BAKERY_SIZES.find(s => s.value === companyData.size)?.label}</p>
|
||||
<p><strong>Especialidades:</strong> {companyData.specialties?.length || 0} seleccionadas</p>
|
||||
<p><strong>Ubicaciones:</strong> {companyData.locations}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompanyInfoStep;
|
||||
@@ -74,7 +74,67 @@ export const OnboardingWizard: React.FC<OnboardingWizardProps> = ({
|
||||
return true;
|
||||
}, [currentStep, stepData]);
|
||||
|
||||
const goToNextStep = useCallback(() => {
|
||||
const goToNextStep = useCallback(async () => {
|
||||
// Special handling for setup step - create tenant if needed
|
||||
if (currentStep.id === 'setup') {
|
||||
const data = stepData[currentStep.id] || {};
|
||||
|
||||
if (!data.bakery?.tenant_id) {
|
||||
// Create tenant inline
|
||||
updateStepData(currentStep.id, {
|
||||
...data,
|
||||
bakery: { ...data.bakery, isCreating: true }
|
||||
});
|
||||
|
||||
try {
|
||||
const mockTenantService = {
|
||||
createTenant: async (formData: any) => {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
return {
|
||||
tenant_id: `tenant_${Date.now()}`,
|
||||
name: formData.name,
|
||||
...formData
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const tenantData = await mockTenantService.createTenant(data.bakery);
|
||||
|
||||
updateStepData(currentStep.id, {
|
||||
...data,
|
||||
bakery: {
|
||||
...data.bakery,
|
||||
tenant_id: tenantData.tenant_id,
|
||||
created_at: new Date().toISOString(),
|
||||
isCreating: false
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating tenant:', error);
|
||||
updateStepData(currentStep.id, {
|
||||
...data,
|
||||
bakery: { ...data.bakery, isCreating: false }
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Special handling for suppliers step - trigger ML training
|
||||
if (currentStep.id === 'suppliers') {
|
||||
const nextStepIndex = currentStepIndex + 1;
|
||||
const nextStep = steps[nextStepIndex];
|
||||
|
||||
if (nextStep && nextStep.id === 'ml-training') {
|
||||
// Set autoStartTraining flag for the ML training step
|
||||
updateStepData(nextStep.id, {
|
||||
...stepData[nextStep.id],
|
||||
autoStartTraining: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (validateCurrentStep()) {
|
||||
if (currentStepIndex < steps.length - 1) {
|
||||
setCurrentStepIndex(currentStepIndex + 1);
|
||||
@@ -83,7 +143,7 @@ export const OnboardingWizard: React.FC<OnboardingWizardProps> = ({
|
||||
onComplete(stepData);
|
||||
}
|
||||
}
|
||||
}, [currentStepIndex, steps.length, validateCurrentStep, onComplete, stepData]);
|
||||
}, [currentStep.id, currentStepIndex, steps, validateCurrentStep, onComplete, stepData, updateStepData]);
|
||||
|
||||
const goToPreviousStep = useCallback(() => {
|
||||
if (currentStepIndex > 0) {
|
||||
@@ -100,72 +160,186 @@ export const OnboardingWizard: React.FC<OnboardingWizardProps> = ({
|
||||
};
|
||||
|
||||
const renderStepIndicator = () => (
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
{steps.map((step, index) => {
|
||||
const isCompleted = completedSteps.has(step.id);
|
||||
const isCurrent = index === currentStepIndex;
|
||||
const hasError = validationErrors[step.id];
|
||||
<div className="mb-8" role="navigation" aria-label="Progreso del proceso de configuración">
|
||||
<div className="max-w-5xl mx-auto px-4">
|
||||
|
||||
{/* Progress summary */}
|
||||
<div className="text-center mb-6">
|
||||
<div className="inline-flex items-center space-x-3 bg-[var(--bg-secondary)]/50 rounded-full px-6 py-3 backdrop-blur-sm">
|
||||
<span className="text-sm text-[var(--text-secondary)]">Paso</span>
|
||||
<span className="font-bold text-[var(--color-primary)] text-lg">
|
||||
{currentStepIndex + 1}
|
||||
</span>
|
||||
<span className="text-sm text-[var(--text-secondary)]">de</span>
|
||||
<span className="font-bold text-[var(--text-primary)] text-lg">
|
||||
{steps.length}
|
||||
</span>
|
||||
<div className="w-px h-6 bg-[var(--border-secondary)]" />
|
||||
<span className="text-sm font-medium text-[var(--color-primary)]">
|
||||
{Math.round(calculateProgress())}% completado
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
className="flex items-center flex-1"
|
||||
>
|
||||
<button
|
||||
onClick={() => goToStep(index)}
|
||||
disabled={index > currentStepIndex + 1}
|
||||
className={`flex items-center justify-center w-8 h-8 rounded-full text-sm font-medium transition-colors ${
|
||||
isCompleted
|
||||
? 'bg-green-500 text-white'
|
||||
: isCurrent
|
||||
? hasError
|
||||
? 'bg-red-500 text-white'
|
||||
: 'bg-[var(--color-info)]/50 text-white'
|
||||
: 'bg-[var(--bg-quaternary)] text-[var(--text-secondary)] hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
{isCompleted ? '✓' : index + 1}
|
||||
</button>
|
||||
|
||||
<div className="ml-3 flex-1">
|
||||
<p className={`text-sm font-medium ${isCurrent ? 'text-[var(--color-info)]' : 'text-[var(--text-secondary)]'}`}>
|
||||
{step.title}
|
||||
{step.isRequired && <span className="text-red-500 ml-1">*</span>}
|
||||
</p>
|
||||
{hasError && (
|
||||
<p className="text-xs text-[var(--color-error)] mt-1">{hasError}</p>
|
||||
)}
|
||||
{/* Progress connections between steps */}
|
||||
<div className="relative mb-8" role="progressbar"
|
||||
aria-valuenow={completedSteps.size}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={steps.length}
|
||||
aria-label={`${completedSteps.size} de ${steps.length} pasos completados`}>
|
||||
|
||||
{/* Step circles */}
|
||||
<div className="relative">
|
||||
{/* Desktop view */}
|
||||
<div className="hidden md:flex justify-between items-center">
|
||||
{steps.map((step, index) => {
|
||||
const isCompleted = completedSteps.has(step.id);
|
||||
const isCurrent = index === currentStepIndex;
|
||||
const hasError = validationErrors[step.id];
|
||||
const isAccessible = index <= currentStepIndex + 1;
|
||||
const nextStepCompleted = index < steps.length - 1 && completedSteps.has(steps[index + 1].id);
|
||||
|
||||
return (
|
||||
<React.Fragment key={step.id}>
|
||||
<div className="flex flex-col items-center">
|
||||
<button
|
||||
onClick={() => isAccessible ? goToStep(index) : null}
|
||||
disabled={!isAccessible}
|
||||
className={`
|
||||
relative flex items-center justify-center w-12 h-12 rounded-full text-sm font-bold z-10
|
||||
transition-all duration-300 transform hover:scale-110 focus:outline-none focus:ring-4 focus:ring-opacity-50
|
||||
${
|
||||
isCompleted
|
||||
? 'bg-[var(--color-success)] text-white shadow-lg hover:bg-[var(--color-success)]/90 focus:ring-[var(--color-success)]'
|
||||
: isCurrent
|
||||
? hasError
|
||||
? 'bg-[var(--color-error)] text-white shadow-lg animate-pulse focus:ring-[var(--color-error)]'
|
||||
: 'bg-[var(--color-primary)] text-white shadow-xl scale-110 ring-4 ring-[var(--color-primary)]/20 focus:ring-[var(--color-primary)]'
|
||||
: isAccessible
|
||||
? 'bg-[var(--bg-primary)] border-2 border-[var(--border-secondary)] text-[var(--text-secondary)] shadow-sm hover:border-[var(--color-primary)]/50 hover:shadow-md focus:ring-[var(--color-primary)]'
|
||||
: 'bg-[var(--bg-secondary)] border-2 border-[var(--border-tertiary)] text-[var(--text-tertiary)] cursor-not-allowed opacity-50'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<span>{index + 1}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Step label */}
|
||||
<div className={`mt-3 text-center max-w-20 ${
|
||||
isCurrent
|
||||
? 'text-[var(--color-primary)] font-semibold'
|
||||
: isCompleted
|
||||
? 'text-[var(--color-success)] font-medium'
|
||||
: 'text-[var(--text-secondary)]'
|
||||
}`}>
|
||||
<div className="text-xs leading-tight">
|
||||
{step.title.replace(/🏢|📊|🤖|📋|⚙️|🏪|🧠|🎉/g, '').trim()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error indicator */}
|
||||
{hasError && (
|
||||
<div className="absolute -top-1 -right-1 w-4 h-4 bg-[var(--color-error)] rounded-full flex items-center justify-center z-20">
|
||||
<span className="text-white text-xs">!</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Connection line to next step */}
|
||||
{index < steps.length - 1 && (
|
||||
<div className="flex items-center flex-1">
|
||||
<div className="h-1 w-full mx-4 rounded-full transition-all duration-500">
|
||||
<div className={`h-full rounded-full ${
|
||||
isCompleted ? 'bg-[var(--color-success)]' : 'bg-[var(--bg-secondary)]'
|
||||
}`} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{index < steps.length - 1 && (
|
||||
<div className={`flex-1 h-0.5 mx-4 ${
|
||||
isCompleted ? 'bg-green-500' : 'bg-[var(--bg-quaternary)]'
|
||||
}`} />
|
||||
)}
|
||||
{/* Mobile view - simplified horizontal scroll */}
|
||||
<div className="md:hidden">
|
||||
<div className="flex items-center overflow-x-auto pb-4 px-4">
|
||||
{steps.map((step, index) => {
|
||||
const isCompleted = completedSteps.has(step.id);
|
||||
const isCurrent = index === currentStepIndex;
|
||||
const hasError = validationErrors[step.id];
|
||||
const isAccessible = index <= currentStepIndex + 1;
|
||||
|
||||
return (
|
||||
<React.Fragment key={step.id}>
|
||||
<div className="flex-shrink-0 flex flex-col items-center">
|
||||
<button
|
||||
onClick={() => isAccessible ? goToStep(index) : null}
|
||||
disabled={!isAccessible}
|
||||
className={`
|
||||
relative flex items-center justify-center w-10 h-10 rounded-full text-xs font-bold z-10
|
||||
transition-all duration-300 focus:outline-none focus:ring-3 focus:ring-opacity-50
|
||||
${
|
||||
isCompleted
|
||||
? 'bg-[var(--color-success)] text-white shadow-lg focus:ring-[var(--color-success)]'
|
||||
: isCurrent
|
||||
? hasError
|
||||
? 'bg-[var(--color-error)] text-white shadow-lg focus:ring-[var(--color-error)]'
|
||||
: 'bg-[var(--color-primary)] text-white shadow-lg ring-3 ring-[var(--color-primary)]/20 focus:ring-[var(--color-primary)]'
|
||||
: isAccessible
|
||||
? 'bg-[var(--bg-primary)] border-2 border-[var(--border-secondary)] text-[var(--text-secondary)] focus:ring-[var(--color-primary)]'
|
||||
: 'bg-[var(--bg-secondary)] border-2 border-[var(--border-tertiary)] text-[var(--text-tertiary)] cursor-not-allowed opacity-50'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<span>{index + 1}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Step label for mobile */}
|
||||
<div className={`mt-2 text-center min-w-16 ${
|
||||
isCurrent
|
||||
? 'text-[var(--color-primary)] font-semibold'
|
||||
: isCompleted
|
||||
? 'text-[var(--color-success)] font-medium'
|
||||
: 'text-[var(--text-secondary)]'
|
||||
}`}>
|
||||
<div className="text-xs leading-tight">
|
||||
{step.title.replace(/🏢|📊|🤖|📋|⚙️|🏪|🧠|🎉/g, '').trim()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection line for mobile */}
|
||||
{index < steps.length - 1 && (
|
||||
<div className="flex-shrink-0 w-8 h-1 mx-2 rounded-full transition-all duration-500">
|
||||
<div className={`w-full h-full rounded-full ${
|
||||
isCompleted ? 'bg-[var(--color-success)]' : 'bg-[var(--bg-secondary)]'
|
||||
}`} />
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderProgressBar = () => (
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm font-medium text-[var(--text-secondary)]">
|
||||
Progreso del onboarding
|
||||
</span>
|
||||
<span className="text-sm text-[var(--text-tertiary)]">
|
||||
{completedSteps.size} de {steps.length} completados
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-[var(--bg-quaternary)] rounded-full h-2">
|
||||
<div
|
||||
className="bg-[var(--color-info)]/50 h-2 rounded-full transition-all duration-500"
|
||||
style={{ width: `${calculateProgress()}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!currentStep) {
|
||||
return (
|
||||
@@ -178,75 +352,92 @@ export const OnboardingWizard: React.FC<OnboardingWizardProps> = ({
|
||||
const StepComponent = currentStep.component;
|
||||
|
||||
return (
|
||||
<div className={`max-w-4xl mx-auto ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[var(--text-primary)]">
|
||||
<div className={`min-h-screen bg-[var(--bg-primary)] ${className}`}>
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
{/* Header with clean design */}
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-3xl font-bold text-[var(--text-primary)] mb-2">
|
||||
Configuración inicial
|
||||
</h1>
|
||||
<p className="text-[var(--text-secondary)] mt-1">
|
||||
<p className="text-[var(--text-secondary)] mb-4">
|
||||
Completa estos pasos para comenzar a usar la plataforma
|
||||
</p>
|
||||
|
||||
{onExit && (
|
||||
<button
|
||||
onClick={onExit}
|
||||
className="absolute top-8 right-8 text-[var(--text-tertiary)] hover:text-[var(--text-secondary)] text-2xl"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{onExit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onExit}
|
||||
className="text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]"
|
||||
>
|
||||
✕ Salir
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{renderStepIndicator()}
|
||||
|
||||
{renderProgressBar()}
|
||||
{renderStepIndicator()}
|
||||
{/* Main Content - Single clean card */}
|
||||
<div className="bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-2xl shadow-xl overflow-hidden">
|
||||
{/* Step header */}
|
||||
<div className="bg-[var(--bg-secondary)] px-8 py-6 border-b border-[var(--border-secondary)]">
|
||||
<h2 className="text-2xl font-semibold text-[var(--text-primary)] mb-2">
|
||||
{currentStep.title}
|
||||
</h2>
|
||||
<p className="text-[var(--text-secondary)]">
|
||||
{currentStep.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Current Step Content */}
|
||||
<Card className="p-6">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)] mb-2">
|
||||
{currentStep.title}
|
||||
</h2>
|
||||
<p className="text-[var(--text-secondary)]">
|
||||
{currentStep.description}
|
||||
</p>
|
||||
{/* Step content */}
|
||||
<div className="px-8 py-8">
|
||||
<StepComponent
|
||||
data={{
|
||||
...stepData[currentStep.id],
|
||||
// Pass all step data to allow access to previous steps
|
||||
allStepData: stepData
|
||||
}}
|
||||
onDataChange={(data) => updateStepData(currentStep.id, data)}
|
||||
onNext={goToNextStep}
|
||||
onPrevious={goToPreviousStep}
|
||||
isFirstStep={currentStepIndex === 0}
|
||||
isLastStep={currentStepIndex === steps.length - 1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Navigation footer */}
|
||||
<div className="bg-[var(--bg-secondary)] px-8 py-6 border-t border-[var(--border-secondary)]">
|
||||
<div className="flex justify-between items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={goToPreviousStep}
|
||||
disabled={currentStepIndex === 0}
|
||||
className="px-6"
|
||||
>
|
||||
← Anterior
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={goToNextStep}
|
||||
disabled={
|
||||
(currentStep.validation && currentStep.validation(stepData[currentStep.id] || {})) ||
|
||||
(currentStep.id === 'setup' && stepData[currentStep.id]?.bakery?.isCreating)
|
||||
}
|
||||
className="px-8"
|
||||
>
|
||||
{currentStep.id === 'setup' && stepData[currentStep.id]?.bakery?.isCreating ? (
|
||||
<>
|
||||
<div className="animate-spin w-4 h-4 border-2 border-white border-t-transparent rounded-full mr-2" />
|
||||
Creando espacio...
|
||||
</>
|
||||
) : currentStep.id === 'suppliers' ? (
|
||||
'Siguiente →'
|
||||
) : (
|
||||
currentStepIndex === steps.length - 1 ? '🎉 Finalizar' : 'Siguiente →'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StepComponent
|
||||
data={stepData[currentStep.id] || {}}
|
||||
onDataChange={(data) => updateStepData(currentStep.id, data)}
|
||||
onNext={goToNextStep}
|
||||
onPrevious={goToPreviousStep}
|
||||
isFirstStep={currentStepIndex === 0}
|
||||
isLastStep={currentStepIndex === steps.length - 1}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between items-center mt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={goToPreviousStep}
|
||||
disabled={currentStepIndex === 0}
|
||||
>
|
||||
← Anterior
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-[var(--text-tertiary)]">
|
||||
Paso {currentStepIndex + 1} de {steps.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={goToNextStep}
|
||||
>
|
||||
{currentStepIndex === steps.length - 1 ? 'Finalizar' : 'Siguiente →'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,556 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Card, Button, Input, Select, Badge } from '../../ui';
|
||||
import type { OnboardingStepProps } from './OnboardingWizard';
|
||||
|
||||
interface SystemConfig {
|
||||
timezone: string;
|
||||
currency: string;
|
||||
language: string;
|
||||
date_format: string;
|
||||
number_format: string;
|
||||
working_hours: {
|
||||
start: string;
|
||||
end: string;
|
||||
days: number[];
|
||||
};
|
||||
notifications: {
|
||||
email_enabled: boolean;
|
||||
sms_enabled: boolean;
|
||||
push_enabled: boolean;
|
||||
alert_preferences: string[];
|
||||
};
|
||||
integrations: {
|
||||
pos_system?: string;
|
||||
accounting_software?: string;
|
||||
payment_provider?: string;
|
||||
};
|
||||
features: {
|
||||
inventory_management: boolean;
|
||||
production_planning: boolean;
|
||||
sales_analytics: boolean;
|
||||
customer_management: boolean;
|
||||
financial_reporting: boolean;
|
||||
quality_control: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const TIMEZONES = [
|
||||
{ value: 'Europe/Madrid', label: 'Madrid (GMT+1)' },
|
||||
{ value: 'Europe/London', label: 'London (GMT+0)' },
|
||||
{ value: 'Europe/Paris', label: 'Paris (GMT+1)' },
|
||||
{ value: 'America/New_York', label: 'New York (GMT-5)' },
|
||||
{ value: 'America/Los_Angeles', label: 'Los Angeles (GMT-8)' },
|
||||
{ value: 'America/Mexico_City', label: 'Mexico City (GMT-6)' },
|
||||
{ value: 'America/Buenos_Aires', label: 'Buenos Aires (GMT-3)' },
|
||||
];
|
||||
|
||||
const CURRENCIES = [
|
||||
{ value: 'EUR', label: 'Euro (€)', symbol: '€' },
|
||||
{ value: 'USD', label: 'US Dollar ($)', symbol: '$' },
|
||||
{ value: 'GBP', label: 'British Pound (£)', symbol: '£' },
|
||||
{ value: 'MXN', label: 'Mexican Peso ($)', symbol: '$' },
|
||||
{ value: 'ARS', label: 'Argentine Peso ($)', symbol: '$' },
|
||||
{ value: 'COP', label: 'Colombian Peso ($)', symbol: '$' },
|
||||
];
|
||||
|
||||
const LANGUAGES = [
|
||||
{ value: 'es', label: 'Español' },
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'fr', label: 'Français' },
|
||||
{ value: 'pt', label: 'Português' },
|
||||
{ value: 'it', label: 'Italiano' },
|
||||
];
|
||||
|
||||
const DAYS_OF_WEEK = [
|
||||
{ value: 1, label: 'Lunes', short: 'L' },
|
||||
{ value: 2, label: 'Martes', short: 'M' },
|
||||
{ value: 3, label: 'Miércoles', short: 'X' },
|
||||
{ value: 4, label: 'Jueves', short: 'J' },
|
||||
{ value: 5, label: 'Viernes', short: 'V' },
|
||||
{ value: 6, label: 'Sábado', short: 'S' },
|
||||
{ value: 0, label: 'Domingo', short: 'D' },
|
||||
];
|
||||
|
||||
const ALERT_TYPES = [
|
||||
{ value: 'low_stock', label: 'Stock bajo', description: 'Cuando los ingredientes están por debajo del mínimo' },
|
||||
{ value: 'production_delays', label: 'Retrasos de producción', description: 'Cuando los lotes se retrasan' },
|
||||
{ value: 'quality_issues', label: 'Problemas de calidad', description: 'Cuando se detectan problemas de calidad' },
|
||||
{ value: 'financial_targets', label: 'Objetivos financieros', description: 'Cuando se alcanzan o no se cumplen objetivos' },
|
||||
{ value: 'equipment_maintenance', label: 'Mantenimiento de equipos', description: 'Recordatorios de mantenimiento' },
|
||||
{ value: 'food_safety', label: 'Seguridad alimentaria', description: 'Alertas relacionadas con seguridad alimentaria' },
|
||||
];
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
key: 'inventory_management',
|
||||
title: 'Gestión de inventario',
|
||||
description: 'Control de stock, ingredientes y materias primas',
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
key: 'production_planning',
|
||||
title: 'Planificación de producción',
|
||||
description: 'Programación de lotes y gestión de recetas',
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
key: 'sales_analytics',
|
||||
title: 'Analytics de ventas',
|
||||
description: 'Reportes y análisis de ventas y tendencias',
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
key: 'customer_management',
|
||||
title: 'Gestión de clientes',
|
||||
description: 'Base de datos de clientes y programa de fidelización',
|
||||
recommended: false,
|
||||
},
|
||||
{
|
||||
key: 'financial_reporting',
|
||||
title: 'Reportes financieros',
|
||||
description: 'Análisis de costos, márgenes y rentabilidad',
|
||||
recommended: true,
|
||||
},
|
||||
{
|
||||
key: 'quality_control',
|
||||
title: 'Control de calidad',
|
||||
description: 'Seguimiento de calidad y estándares',
|
||||
recommended: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const SystemSetupStep: React.FC<OnboardingStepProps> = ({
|
||||
data,
|
||||
onDataChange,
|
||||
}) => {
|
||||
const systemData: SystemConfig = {
|
||||
timezone: 'Europe/Madrid',
|
||||
currency: 'EUR',
|
||||
language: 'es',
|
||||
date_format: 'DD/MM/YYYY',
|
||||
number_format: 'European',
|
||||
working_hours: {
|
||||
start: '06:00',
|
||||
end: '20:00',
|
||||
days: [1, 2, 3, 4, 5, 6], // Monday to Saturday
|
||||
},
|
||||
notifications: {
|
||||
email_enabled: true,
|
||||
sms_enabled: false,
|
||||
push_enabled: true,
|
||||
alert_preferences: ['low_stock', 'production_delays', 'quality_issues'],
|
||||
},
|
||||
integrations: {},
|
||||
features: {
|
||||
inventory_management: true,
|
||||
production_planning: true,
|
||||
sales_analytics: true,
|
||||
customer_management: false,
|
||||
financial_reporting: true,
|
||||
quality_control: true,
|
||||
},
|
||||
...data,
|
||||
};
|
||||
|
||||
const handleInputChange = (field: string, value: any) => {
|
||||
onDataChange({
|
||||
...systemData,
|
||||
[field]: value,
|
||||
});
|
||||
};
|
||||
|
||||
const handleWorkingHoursChange = (field: string, value: any) => {
|
||||
onDataChange({
|
||||
...systemData,
|
||||
working_hours: {
|
||||
...systemData.working_hours,
|
||||
[field]: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleNotificationChange = (field: string, value: any) => {
|
||||
onDataChange({
|
||||
...systemData,
|
||||
notifications: {
|
||||
...systemData.notifications,
|
||||
[field]: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleIntegrationChange = (field: string, value: string) => {
|
||||
onDataChange({
|
||||
...systemData,
|
||||
integrations: {
|
||||
...systemData.integrations,
|
||||
[field]: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleFeatureToggle = (feature: string) => {
|
||||
onDataChange({
|
||||
...systemData,
|
||||
features: {
|
||||
...systemData.features,
|
||||
[feature]: !systemData.features[feature as keyof typeof systemData.features],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const toggleWorkingDay = (day: number) => {
|
||||
const currentDays = systemData.working_hours.days;
|
||||
const updatedDays = currentDays.includes(day)
|
||||
? currentDays.filter(d => d !== day)
|
||||
: [...currentDays, day].sort();
|
||||
|
||||
handleWorkingHoursChange('days', updatedDays);
|
||||
};
|
||||
|
||||
const toggleAlertPreference = (alert: string) => {
|
||||
const current = systemData.notifications.alert_preferences;
|
||||
const updated = current.includes(alert)
|
||||
? current.filter(a => a !== alert)
|
||||
: [...current, alert];
|
||||
|
||||
handleNotificationChange('alert_preferences', updated);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Regional Settings */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] border-b pb-2">
|
||||
Configuración regional
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-1">
|
||||
Zona horaria *
|
||||
</label>
|
||||
<Select
|
||||
value={systemData.timezone}
|
||||
onChange={(e) => handleInputChange('timezone', e.target.value)}
|
||||
className="w-full"
|
||||
>
|
||||
{TIMEZONES.map((tz) => (
|
||||
<option key={tz.value} value={tz.value}>
|
||||
{tz.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-1">
|
||||
Moneda *
|
||||
</label>
|
||||
<Select
|
||||
value={systemData.currency}
|
||||
onChange={(e) => handleInputChange('currency', e.target.value)}
|
||||
className="w-full"
|
||||
>
|
||||
{CURRENCIES.map((currency) => (
|
||||
<option key={currency.value} value={currency.value}>
|
||||
{currency.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-1">
|
||||
Idioma *
|
||||
</label>
|
||||
<Select
|
||||
value={systemData.language}
|
||||
onChange={(e) => handleInputChange('language', e.target.value)}
|
||||
className="w-full"
|
||||
>
|
||||
{LANGUAGES.map((lang) => (
|
||||
<option key={lang.value} value={lang.value}>
|
||||
{lang.label}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-1">
|
||||
Formato de fecha *
|
||||
</label>
|
||||
<Select
|
||||
value={systemData.date_format}
|
||||
onChange={(e) => handleInputChange('date_format', e.target.value)}
|
||||
className="w-full"
|
||||
>
|
||||
<option value="DD/MM/YYYY">DD/MM/YYYY</option>
|
||||
<option value="MM/DD/YYYY">MM/DD/YYYY</option>
|
||||
<option value="YYYY-MM-DD">YYYY-MM-DD</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Working Hours */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] border-b pb-2">
|
||||
Horario de trabajo
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-1">
|
||||
Hora de apertura *
|
||||
</label>
|
||||
<Input
|
||||
type="time"
|
||||
value={systemData.working_hours.start}
|
||||
onChange={(e) => handleWorkingHoursChange('start', e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-1">
|
||||
Hora de cierre *
|
||||
</label>
|
||||
<Input
|
||||
type="time"
|
||||
value={systemData.working_hours.end}
|
||||
onChange={(e) => handleWorkingHoursChange('end', e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Días de operación *
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DAYS_OF_WEEK.map((day) => (
|
||||
<button
|
||||
key={day.value}
|
||||
onClick={() => toggleWorkingDay(day.value)}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
systemData.working_hours.days.includes(day.value)
|
||||
? 'bg-[var(--color-info)]/50 text-white'
|
||||
: 'bg-[var(--bg-quaternary)] text-[var(--text-secondary)] hover:bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
{day.short} - {day.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] border-b pb-2">
|
||||
Módulos y características
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Selecciona las características que quieres activar. Podrás cambiar esto más tarde.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{FEATURES.map((feature) => (
|
||||
<Card
|
||||
key={feature.key}
|
||||
className={`p-4 cursor-pointer transition-colors ${
|
||||
systemData.features[feature.key as keyof typeof systemData.features]
|
||||
? 'border-blue-500 bg-[var(--color-info)]/5'
|
||||
: 'border-[var(--border-primary)] hover:border-[var(--border-secondary)]'
|
||||
}`}
|
||||
onClick={() => handleFeatureToggle(feature.key)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className="font-medium text-[var(--text-primary)]">{feature.title}</h4>
|
||||
{feature.recommended && (
|
||||
<Badge className="bg-[var(--color-success)]/10 text-[var(--color-success)] text-xs">
|
||||
Recomendado
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{feature.description}</p>
|
||||
</div>
|
||||
|
||||
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center ${
|
||||
systemData.features[feature.key as keyof typeof systemData.features]
|
||||
? 'border-blue-500 bg-[var(--color-info)]/50'
|
||||
: 'border-[var(--border-secondary)]'
|
||||
}`}>
|
||||
{systemData.features[feature.key as keyof typeof systemData.features] && (
|
||||
<span className="text-white text-xs">✓</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notifications */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] border-b pb-2">
|
||||
Notificaciones
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={systemData.notifications.email_enabled}
|
||||
onChange={(e) => handleNotificationChange('email_enabled', e.target.checked)}
|
||||
className="mr-2 rounded"
|
||||
/>
|
||||
Notificaciones por email
|
||||
</label>
|
||||
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={systemData.notifications.push_enabled}
|
||||
onChange={(e) => handleNotificationChange('push_enabled', e.target.checked)}
|
||||
className="mr-2 rounded"
|
||||
/>
|
||||
Notificaciones push
|
||||
</label>
|
||||
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={systemData.notifications.sms_enabled}
|
||||
onChange={(e) => handleNotificationChange('sms_enabled', e.target.checked)}
|
||||
className="mr-2 rounded"
|
||||
/>
|
||||
Notificaciones por SMS
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Tipos de alertas que deseas recibir
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{ALERT_TYPES.map((alert) => (
|
||||
<div
|
||||
key={alert.value}
|
||||
className={`p-3 border rounded-lg cursor-pointer transition-colors ${
|
||||
systemData.notifications.alert_preferences.includes(alert.value)
|
||||
? 'border-blue-500 bg-[var(--color-info)]/5'
|
||||
: 'border-[var(--border-primary)] hover:border-[var(--border-secondary)]'
|
||||
}`}
|
||||
onClick={() => toggleAlertPreference(alert.value)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h5 className="font-medium text-[var(--text-primary)]">{alert.label}</h5>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{alert.description}</p>
|
||||
</div>
|
||||
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center ${
|
||||
systemData.notifications.alert_preferences.includes(alert.value)
|
||||
? 'border-blue-500 bg-[var(--color-info)]/50'
|
||||
: 'border-[var(--border-secondary)]'
|
||||
}`}>
|
||||
{systemData.notifications.alert_preferences.includes(alert.value) && (
|
||||
<span className="text-white text-xs">✓</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Integrations */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] border-b pb-2">
|
||||
Integraciones (opcional)
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Estas integraciones se pueden configurar más tarde desde el panel de administración.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-1">
|
||||
Sistema POS
|
||||
</label>
|
||||
<Select
|
||||
value={systemData.integrations.pos_system || ''}
|
||||
onChange={(e) => handleIntegrationChange('pos_system', e.target.value)}
|
||||
className="w-full"
|
||||
>
|
||||
<option value="">Seleccionar...</option>
|
||||
<option value="square">Square</option>
|
||||
<option value="shopify">Shopify POS</option>
|
||||
<option value="toast">Toast</option>
|
||||
<option value="lightspeed">Lightspeed</option>
|
||||
<option value="other">Otro</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-1">
|
||||
Software de contabilidad
|
||||
</label>
|
||||
<Select
|
||||
value={systemData.integrations.accounting_software || ''}
|
||||
onChange={(e) => handleIntegrationChange('accounting_software', e.target.value)}
|
||||
className="w-full"
|
||||
>
|
||||
<option value="">Seleccionar...</option>
|
||||
<option value="sage">Sage</option>
|
||||
<option value="contaplus">ContaPlus</option>
|
||||
<option value="a3">A3 Software</option>
|
||||
<option value="quickbooks">QuickBooks</option>
|
||||
<option value="other">Otro</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-1">
|
||||
Proveedor de pagos
|
||||
</label>
|
||||
<Select
|
||||
value={systemData.integrations.payment_provider || ''}
|
||||
onChange={(e) => handleIntegrationChange('payment_provider', e.target.value)}
|
||||
className="w-full"
|
||||
>
|
||||
<option value="">Seleccionar...</option>
|
||||
<option value="stripe">Stripe</option>
|
||||
<option value="paypal">PayPal</option>
|
||||
<option value="redsys">Redsys</option>
|
||||
<option value="bizum">Bizum</option>
|
||||
<option value="other">Otro</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="bg-[var(--bg-secondary)] p-4 rounded-lg">
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-2">Configuración seleccionada</h4>
|
||||
<div className="text-sm text-[var(--text-secondary)] space-y-1">
|
||||
<p><strong>Zona horaria:</strong> {TIMEZONES.find(tz => tz.value === systemData.timezone)?.label}</p>
|
||||
<p><strong>Moneda:</strong> {CURRENCIES.find(c => c.value === systemData.currency)?.label}</p>
|
||||
<p><strong>Horario:</strong> {systemData.working_hours.start} - {systemData.working_hours.end}</p>
|
||||
<p><strong>Días operativos:</strong> {systemData.working_hours.days.length} días por semana</p>
|
||||
<p><strong>Módulos activados:</strong> {Object.values(systemData.features).filter(Boolean).length} de {Object.keys(systemData.features).length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SystemSetupStep;
|
||||
@@ -1,7 +1,14 @@
|
||||
// Onboarding domain components
|
||||
export { default as OnboardingWizard } from './OnboardingWizard';
|
||||
export { default as CompanyInfoStep } from './CompanyInfoStep';
|
||||
export { default as SystemSetupStep } from './SystemSetupStep';
|
||||
|
||||
// Individual step components
|
||||
export { BakerySetupStep } from './steps/BakerySetupStep';
|
||||
export { DataProcessingStep } from './steps/DataProcessingStep';
|
||||
export { ReviewStep } from './steps/ReviewStep';
|
||||
export { InventorySetupStep } from './steps/InventorySetupStep';
|
||||
export { SuppliersStep } from './steps/SuppliersStep';
|
||||
export { MLTrainingStep } from './steps/MLTrainingStep';
|
||||
export { CompletionStep } from './steps/CompletionStep';
|
||||
|
||||
// Re-export types from wizard
|
||||
export type { OnboardingStep, OnboardingStepProps } from './OnboardingWizard';
|
||||
@@ -0,0 +1,191 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Store, MapPin, Phone, Mail } from 'lucide-react';
|
||||
import { Button, Card, Input } from '../../../ui';
|
||||
import { OnboardingStepProps } from '../OnboardingWizard';
|
||||
|
||||
export const BakerySetupStep: React.FC<OnboardingStepProps> = ({
|
||||
data,
|
||||
onDataChange,
|
||||
onNext,
|
||||
onPrevious,
|
||||
isFirstStep,
|
||||
isLastStep
|
||||
}) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: data.bakery?.name || '',
|
||||
type: data.bakery?.type || '',
|
||||
location: data.bakery?.location || '',
|
||||
phone: data.bakery?.phone || '',
|
||||
email: data.bakery?.email || '',
|
||||
description: data.bakery?.description || ''
|
||||
});
|
||||
|
||||
const bakeryTypes = [
|
||||
{
|
||||
value: 'artisan',
|
||||
label: 'Panadería Artesanal',
|
||||
description: 'Producción propia tradicional con recetas artesanales',
|
||||
icon: '🥖'
|
||||
},
|
||||
{
|
||||
value: 'industrial',
|
||||
label: 'Panadería Industrial',
|
||||
description: 'Producción a gran escala con procesos automatizados',
|
||||
icon: '🏭'
|
||||
},
|
||||
{
|
||||
value: 'retail',
|
||||
label: 'Panadería Retail',
|
||||
description: 'Punto de venta que compra productos terminados',
|
||||
icon: '🏪'
|
||||
},
|
||||
{
|
||||
value: 'hybrid',
|
||||
label: 'Modelo Híbrido',
|
||||
description: 'Combina producción propia con productos externos',
|
||||
icon: '🔄'
|
||||
}
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
// Update parent data when form changes
|
||||
onDataChange({
|
||||
bakery: {
|
||||
...formData,
|
||||
tenant_id: data.bakery?.tenant_id
|
||||
}
|
||||
});
|
||||
}, [formData]);
|
||||
|
||||
const handleInputChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
|
||||
{/* Form */}
|
||||
<div className="space-y-8">
|
||||
{/* Basic Info */}
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-[var(--text-primary)] mb-3">
|
||||
Nombre de la Panadería *
|
||||
</label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||
placeholder="Ej: Panadería Artesanal El Buen Pan"
|
||||
className="w-full text-lg py-3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bakery Type - Simplified */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-[var(--text-primary)] mb-4">
|
||||
Tipo de Panadería *
|
||||
</label>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{bakeryTypes.map((type) => (
|
||||
<label
|
||||
key={type.value}
|
||||
className={`
|
||||
flex items-center p-4 border-2 rounded-lg cursor-pointer transition-all duration-200 hover:shadow-sm
|
||||
${formData.type === type.value
|
||||
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/5'
|
||||
: 'border-[var(--border-secondary)] hover:border-[var(--color-primary)]/50'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="bakeryType"
|
||||
value={type.value}
|
||||
checked={formData.type === type.value}
|
||||
onChange={(e) => handleInputChange('type', e.target.value)}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className="flex items-center space-x-3 w-full">
|
||||
<span className="text-2xl">{type.icon}</span>
|
||||
<div>
|
||||
<h4 className="font-medium text-[var(--text-primary)]">
|
||||
{type.label}
|
||||
</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
{type.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location and Contact - Simplified */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-[var(--text-primary)] mb-3">
|
||||
Ubicación *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<MapPin className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-[var(--text-tertiary)]" />
|
||||
<Input
|
||||
value={formData.location}
|
||||
onChange={(e) => handleInputChange('location', e.target.value)}
|
||||
placeholder="Dirección completa de tu panadería"
|
||||
className="w-full pl-12 py-3"
|
||||
/>
|
||||
</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-2">
|
||||
Teléfono (opcional)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Phone className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-tertiary)]" />
|
||||
<Input
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleInputChange('phone', e.target.value)}
|
||||
placeholder="+1 234 567 8900"
|
||||
className="w-full pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
|
||||
Email (opcional)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-tertiary)]" />
|
||||
<Input
|
||||
value={formData.email}
|
||||
onChange={(e) => handleInputChange('email', e.target.value)}
|
||||
placeholder="contacto@panaderia.com"
|
||||
className="w-full pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Optional: Show loading state when creating tenant */}
|
||||
{data.bakery?.isCreating && (
|
||||
<div className="text-center p-6 bg-[var(--color-primary)]/5 rounded-lg">
|
||||
<div className="animate-spin w-8 h-8 border-3 border-[var(--color-primary)] border-t-transparent rounded-full mx-auto mb-4" />
|
||||
<p className="text-[var(--color-primary)] font-medium">
|
||||
Creando tu espacio de trabajo...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,387 @@
|
||||
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';
|
||||
|
||||
interface CompletionStats {
|
||||
totalProducts: number;
|
||||
inventoryItems: number;
|
||||
suppliersConfigured: number;
|
||||
mlModelAccuracy: number;
|
||||
estimatedTimeSaved: string;
|
||||
completionScore: number;
|
||||
}
|
||||
|
||||
export const CompletionStep: React.FC<OnboardingStepProps> = ({
|
||||
data,
|
||||
onDataChange,
|
||||
onNext,
|
||||
onPrevious,
|
||||
isFirstStep,
|
||||
isLastStep
|
||||
}) => {
|
||||
const [showConfetti, setShowConfetti] = useState(false);
|
||||
const [completionStats, setCompletionStats] = useState<CompletionStats | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Show confetti animation
|
||||
setShowConfetti(true);
|
||||
const timer = setTimeout(() => setShowConfetti(false), 3000);
|
||||
|
||||
// Calculate completion stats
|
||||
const stats: CompletionStats = {
|
||||
totalProducts: data.detectedProducts?.filter((p: any) => p.status === 'approved').length || 0,
|
||||
inventoryItems: data.inventoryItems?.length || 0,
|
||||
suppliersConfigured: data.suppliers?.length || 0,
|
||||
mlModelAccuracy: data.trainingMetrics?.accuracy * 100 || 0,
|
||||
estimatedTimeSaved: '15-20 horas',
|
||||
completionScore: calculateCompletionScore()
|
||||
};
|
||||
|
||||
setCompletionStats(stats);
|
||||
|
||||
// Update parent data
|
||||
onDataChange({
|
||||
...data,
|
||||
completionStats: stats,
|
||||
onboardingCompleted: true,
|
||||
completedAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
const calculateCompletionScore = () => {
|
||||
let score = 0;
|
||||
|
||||
// Base score for completing setup
|
||||
if (data.bakery?.tenant_id) score += 20;
|
||||
|
||||
// Data upload and analysis
|
||||
if (data.validation?.is_valid) score += 15;
|
||||
if (data.analysisStatus === 'completed') score += 15;
|
||||
|
||||
// Product review
|
||||
const approvedProducts = data.detectedProducts?.filter((p: any) => p.status === 'approved').length || 0;
|
||||
if (approvedProducts > 0) score += 20;
|
||||
|
||||
// Inventory setup
|
||||
if (data.inventoryItems?.length > 0) score += 15;
|
||||
|
||||
// ML training
|
||||
if (data.trainingStatus === 'completed') score += 15;
|
||||
|
||||
return Math.min(score, 100);
|
||||
};
|
||||
|
||||
const generateCertificate = () => {
|
||||
// Mock certificate generation
|
||||
const certificateData = {
|
||||
bakeryName: data.bakery?.name || 'Tu Panadería',
|
||||
completionDate: new Date().toLocaleDateString('es-ES'),
|
||||
score: completionStats?.completionScore || 0,
|
||||
features: [
|
||||
'Configuración de Tenant Multi-inquilino',
|
||||
'Análisis de Datos con IA',
|
||||
'Gestión de Inventario Inteligente',
|
||||
'Entrenamiento de Modelo ML',
|
||||
'Integración de Proveedores'
|
||||
]
|
||||
};
|
||||
|
||||
console.log('Generating certificate:', certificateData);
|
||||
alert(`🎓 Certificado generado para ${certificateData.bakeryName}\nPuntuación: ${certificateData.score}/100`);
|
||||
};
|
||||
|
||||
const scheduleDemo = () => {
|
||||
// Mock demo scheduling
|
||||
alert('📅 Te contactaremos pronto para agendar una demostración personalizada de las funcionalidades avanzadas.');
|
||||
};
|
||||
|
||||
const shareSuccess = () => {
|
||||
// Mock social sharing
|
||||
const shareText = `¡Acabo de completar la configuración de mi panadería inteligente con IA! 🥖🤖 Puntuación: ${completionStats?.completionScore}/100`;
|
||||
navigator.clipboard.writeText(shareText);
|
||||
alert('✅ Texto copiado al portapapeles. ¡Compártelo en tus redes sociales!');
|
||||
};
|
||||
|
||||
const quickStartActions = [
|
||||
{
|
||||
id: 'dashboard',
|
||||
title: 'Ir al Dashboard',
|
||||
description: 'Explora tu panel de control personalizado',
|
||||
icon: <ArrowRight className="w-5 h-5" />,
|
||||
action: () => onNext(),
|
||||
primary: true
|
||||
},
|
||||
{
|
||||
id: 'inventory',
|
||||
title: 'Gestionar Inventario',
|
||||
description: 'Revisa y actualiza tu stock actual',
|
||||
icon: <Gift className="w-5 h-5" />,
|
||||
action: () => console.log('Navigate to inventory'),
|
||||
primary: false
|
||||
},
|
||||
{
|
||||
id: 'predictions',
|
||||
title: 'Ver Predicciones IA',
|
||||
description: 'Consulta las predicciones de demanda',
|
||||
icon: <Rocket className="w-5 h-5" />,
|
||||
action: () => console.log('Navigate to predictions'),
|
||||
primary: false
|
||||
}
|
||||
];
|
||||
|
||||
const achievementBadges = [
|
||||
{
|
||||
title: 'Pionero IA',
|
||||
description: 'Primera configuración con Machine Learning',
|
||||
icon: '🤖',
|
||||
earned: data.trainingStatus === 'completed'
|
||||
},
|
||||
{
|
||||
title: 'Organizador',
|
||||
description: 'Inventario completamente configurado',
|
||||
icon: '📦',
|
||||
earned: (data.inventoryItems?.length || 0) >= 5
|
||||
},
|
||||
{
|
||||
title: 'Analista',
|
||||
description: 'Análisis de datos exitoso',
|
||||
icon: '📊',
|
||||
earned: data.analysisStatus === 'completed'
|
||||
},
|
||||
{
|
||||
title: 'Perfeccionista',
|
||||
description: 'Puntuación de configuración 90+',
|
||||
icon: '⭐',
|
||||
earned: (completionStats?.completionScore || 0) >= 90
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Confetti Effect */}
|
||||
{showConfetti && (
|
||||
<div className="fixed inset-0 pointer-events-none z-50 overflow-hidden">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-6xl animate-bounce">🎉</div>
|
||||
</div>
|
||||
{/* Additional confetti elements could be added here */}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Celebration Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-24 h-24 bg-gradient-to-br from-[var(--color-success)] to-[var(--color-primary)] rounded-full flex items-center justify-center mx-auto mb-6 shadow-lg">
|
||||
<CheckCircle className="w-12 h-12 text-white" />
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold text-[var(--text-primary)] mb-4">
|
||||
¡Felicidades! 🎉
|
||||
</h1>
|
||||
<h2 className="text-xl font-semibold text-[var(--color-success)] mb-4">
|
||||
{data.bakery?.name} está listo para funcionar
|
||||
</h2>
|
||||
<p className="text-[var(--text-secondary)] max-w-3xl mx-auto text-lg leading-relaxed">
|
||||
Has completado exitosamente la configuración inicial de tu panadería inteligente.
|
||||
Tu sistema está optimizado con IA y listo para transformar la gestión de tu negocio.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Completion Stats */}
|
||||
{completionStats && (
|
||||
<Card className="p-6">
|
||||
<div className="text-center mb-6">
|
||||
<div className="inline-flex items-center justify-center w-24 h-24 bg-gradient-to-br from-[var(--color-success)] to-[var(--color-primary)] rounded-full mb-4">
|
||||
<span className="text-2xl font-bold text-white">{completionStats.completionScore}</span>
|
||||
<span className="text-sm text-white/80 ml-1">/100</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||
Puntuación de Configuración
|
||||
</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
¡Excelente trabajo! Has superado el promedio de la industria (78/100)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
<div className="text-center p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-2xl font-bold text-[var(--color-info)]">{completionStats.totalProducts}</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">Productos</p>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-2xl font-bold text-[var(--color-primary)]">{completionStats.inventoryItems}</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">Inventario</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.suppliersConfigured}</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">Proveedores</p>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<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-lg font-bold text-[var(--color-warning)]">{completionStats.estimatedTimeSaved}</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">Tiempo Ahorrado</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Achievement Badges */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4 text-center">
|
||||
🏆 Logros Desbloqueados
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{achievementBadges.map((badge, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`text-center p-4 rounded-lg border-2 transition-all duration-200 ${
|
||||
badge.earned
|
||||
? 'border-[var(--color-success)]/50 bg-[var(--color-success)]/10'
|
||||
: 'border-[var(--border-secondary)] bg-[var(--bg-secondary)] opacity-50'
|
||||
}`}
|
||||
>
|
||||
<div className="text-2xl mb-2">{badge.icon}</div>
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-1 text-sm">{badge.title}</h4>
|
||||
<p className="text-xs text-[var(--text-secondary)]">{badge.description}</p>
|
||||
{badge.earned && (
|
||||
<div className="mt-2">
|
||||
<Badge variant="green" className="text-xs">Conseguido</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">
|
||||
🚀 Próximos Pasos
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{quickStartActions.map((action) => (
|
||||
<button
|
||||
key={action.id}
|
||||
onClick={action.action}
|
||||
className={`w-full text-left p-4 rounded-lg border transition-all duration-200 hover:shadow-md ${
|
||||
action.primary
|
||||
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/5'
|
||||
: 'border-[var(--border-secondary)] bg-[var(--bg-secondary)]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
|
||||
action.primary ? 'bg-[var(--color-primary)]' : 'bg-[var(--bg-tertiary)]'
|
||||
}`}>
|
||||
<span className={action.primary ? 'text-white' : 'text-[var(--text-secondary)]'}>
|
||||
{action.icon}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className={`font-medium ${
|
||||
action.primary ? 'text-[var(--color-primary)]' : 'text-[var(--text-primary)]'
|
||||
}`}>
|
||||
{action.title}
|
||||
</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)]">{action.description}</p>
|
||||
</div>
|
||||
<ArrowRight className="w-4 h-4 text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Additional Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card className="p-4 text-center">
|
||||
<Download className="w-6 h-6 text-[var(--color-info)] mx-auto mb-2" />
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-2">Certificado</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-3">
|
||||
Descarga tu certificado de configuración
|
||||
</p>
|
||||
<Button size="sm" variant="outline" onClick={generateCertificate}>
|
||||
Descargar
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4 text-center">
|
||||
<Calendar className="w-6 h-6 text-[var(--color-primary)] mx-auto mb-2" />
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-2">Demo Personal</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-3">
|
||||
Agenda una demostración 1-a-1
|
||||
</p>
|
||||
<Button size="sm" variant="outline" onClick={scheduleDemo}>
|
||||
Agendar
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4 text-center">
|
||||
<Share2 className="w-6 h-6 text-[var(--color-success)] mx-auto mb-2" />
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-2">Compartir Éxito</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-3">
|
||||
Comparte tu logro en redes sociales
|
||||
</p>
|
||||
<Button size="sm" variant="outline" onClick={shareSuccess}>
|
||||
Compartir
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Summary & Thanks */}
|
||||
<Card className="p-6 bg-gradient-to-br from-[var(--color-primary)]/5 to-[var(--color-success)]/5 border-[var(--color-primary)]/20">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-3">
|
||||
🙏 ¡Gracias por confiar en nuestra plataforma!
|
||||
</h3>
|
||||
<p className="text-[var(--text-secondary)] mb-4">
|
||||
Tu panadería ahora cuenta con tecnología de vanguardia para:
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-left mb-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2 text-sm">
|
||||
<CheckCircle className="w-4 h-4 text-[var(--color-success)]" />
|
||||
<span>Predicciones de demanda con IA</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-sm">
|
||||
<CheckCircle className="w-4 h-4 text-[var(--color-success)]" />
|
||||
<span>Gestión inteligente de inventario</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-sm">
|
||||
<CheckCircle className="w-4 h-4 text-[var(--color-success)]" />
|
||||
<span>Optimización de compras</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2 text-sm">
|
||||
<CheckCircle className="w-4 h-4 text-[var(--color-success)]" />
|
||||
<span>Alertas automáticas de restock</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-sm">
|
||||
<CheckCircle className="w-4 h-4 text-[var(--color-success)]" />
|
||||
<span>Análisis de tendencias de venta</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-sm">
|
||||
<CheckCircle className="w-4 h-4 text-[var(--color-success)]" />
|
||||
<span>Control multi-tenant seguro</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--color-info)]/10 rounded-lg p-4">
|
||||
<p className="text-sm text-[var(--color-info)]">
|
||||
💡 <strong>Consejo:</strong> Explora el dashboard para descubrir todas las funcionalidades disponibles.
|
||||
El sistema aprenderá de tus patrones y mejorará sus recomendaciones con el tiempo.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,467 @@
|
||||
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';
|
||||
|
||||
type ProcessingStage = 'upload' | 'validating' | 'analyzing' | 'completed' | 'error';
|
||||
|
||||
interface ProcessingResult {
|
||||
// Validation data
|
||||
is_valid: boolean;
|
||||
total_records: number;
|
||||
unique_products: number;
|
||||
product_list: string[];
|
||||
validation_errors: string[];
|
||||
validation_warnings: string[];
|
||||
summary: {
|
||||
date_range: string;
|
||||
total_sales: number;
|
||||
average_daily_sales: number;
|
||||
};
|
||||
// Analysis data
|
||||
productsIdentified: number;
|
||||
categoriesDetected: number;
|
||||
businessModel: string;
|
||||
confidenceScore: number;
|
||||
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;
|
||||
|
||||
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' }
|
||||
];
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const DataProcessingStep: React.FC<OnboardingStepProps> = ({
|
||||
data,
|
||||
onDataChange,
|
||||
onNext,
|
||||
onPrevious,
|
||||
isFirstStep,
|
||||
isLastStep
|
||||
}) => {
|
||||
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);
|
||||
const [currentMessage, setCurrentMessage] = useState(data.currentMessage || '');
|
||||
const [results, setResults] = useState<ProcessingResult | null>(data.processingResults || null);
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Update parent data when state changes
|
||||
onDataChange({
|
||||
...data,
|
||||
processingStage: stage,
|
||||
processingProgress: progress,
|
||||
currentMessage: currentMessage,
|
||||
processingResults: results,
|
||||
files: {
|
||||
...data.files,
|
||||
salesData: uploadedFile
|
||||
}
|
||||
});
|
||||
}, [stage, progress, currentMessage, results, uploadedFile]);
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragActive(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragActive(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragActive(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length > 0) {
|
||||
handleFileUpload(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files.length > 0) {
|
||||
handleFileUpload(e.target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async (file: File) => {
|
||||
// Validate file type
|
||||
const validExtensions = ['.csv', '.xlsx', '.xls'];
|
||||
const fileExtension = file.name.toLowerCase().substring(file.name.lastIndexOf('.'));
|
||||
|
||||
if (!validExtensions.includes(fileExtension)) {
|
||||
alert('Formato de archivo no válido. Usa CSV o Excel (.xlsx, .xls)');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check file size (max 10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
alert('El archivo es demasiado grande. Máximo 10MB permitido.');
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadedFile(file);
|
||||
setStage('validating');
|
||||
setProgress(0);
|
||||
|
||||
try {
|
||||
const result = await mockDataProcessingService.processFile(
|
||||
file,
|
||||
(newProgress, newStage, message) => {
|
||||
setProgress(newProgress);
|
||||
setStage(newStage as ProcessingStage);
|
||||
setCurrentMessage(message);
|
||||
}
|
||||
);
|
||||
|
||||
setResults(result);
|
||||
setStage('completed');
|
||||
} catch (error) {
|
||||
console.error('Processing error:', error);
|
||||
setStage('error');
|
||||
setCurrentMessage('Error en el procesamiento de datos');
|
||||
}
|
||||
};
|
||||
|
||||
const downloadTemplate = () => {
|
||||
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 resetProcess = () => {
|
||||
setStage('upload');
|
||||
setUploadedFile(null);
|
||||
setProgress(0);
|
||||
setCurrentMessage('');
|
||||
setResults(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Improved Upload Stage */}
|
||||
{stage === 'upload' && (
|
||||
<>
|
||||
<div
|
||||
className={`
|
||||
border-2 border-dashed rounded-2xl p-16 text-center transition-all duration-300 cursor-pointer group
|
||||
${
|
||||
dragActive
|
||||
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/10 scale-[1.02] shadow-lg'
|
||||
: uploadedFile
|
||||
? 'border-[var(--color-success)] bg-[var(--color-success)]/10 shadow-md'
|
||||
: 'border-[var(--border-secondary)] hover:border-[var(--color-primary)] hover:bg-[var(--bg-secondary)]/30 hover:shadow-lg'
|
||||
}
|
||||
`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<div className="space-y-8">
|
||||
{uploadedFile ? (
|
||||
<>
|
||||
<div className="w-20 h-20 bg-[var(--color-success)]/10 rounded-full flex items-center justify-center mx-auto">
|
||||
<CheckCircle className="w-10 h-10 text-[var(--color-success)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-3xl font-bold text-[var(--color-success)] mb-3">
|
||||
¡Perfecto! Archivo listo
|
||||
</h3>
|
||||
<div className="bg-[var(--bg-secondary)] rounded-lg p-4 inline-block">
|
||||
<p className="text-[var(--text-primary)] font-medium text-lg">
|
||||
📄 {uploadedFile.name}
|
||||
</p>
|
||||
<p className="text-[var(--text-secondary)] text-sm mt-1">
|
||||
{(uploadedFile.size / 1024 / 1024).toFixed(2)} MB • Listo para procesar
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-20 h-20 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center mx-auto group-hover:scale-110 transition-transform duration-300">
|
||||
<Upload className="w-10 h-10 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-3xl font-bold text-[var(--text-primary)] mb-4">
|
||||
Sube tu historial de ventas
|
||||
</h3>
|
||||
<p className="text-[var(--text-secondary)] text-xl leading-relaxed max-w-md mx-auto">
|
||||
Arrastra y suelta tu archivo aquí, o <span className="text-[var(--color-primary)] font-semibold">haz clic para seleccionar</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Visual indicators */}
|
||||
<div className="flex justify-center space-x-8 mt-8">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mx-auto mb-2">
|
||||
<span className="text-2xl">📊</span>
|
||||
</div>
|
||||
<span className="text-sm text-[var(--text-secondary)]">CSV</span>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center mx-auto mb-2">
|
||||
<span className="text-2xl">📈</span>
|
||||
</div>
|
||||
<span className="text-sm text-[var(--text-secondary)]">Excel</span>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center mx-auto mb-2">
|
||||
<span className="text-2xl">⚡</span>
|
||||
</div>
|
||||
<span className="text-sm text-[var(--text-secondary)]">Hasta 10MB</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-10 px-4 py-2 bg-[var(--bg-secondary)]/50 rounded-lg text-sm text-[var(--text-tertiary)] inline-block">
|
||||
💡 Formatos aceptados: CSV, Excel (XLSX, XLS) • Tamaño máximo: 10MB
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Improved Template Download Section */}
|
||||
<div className="bg-gradient-to-r from-[var(--color-info)]/5 to-[var(--color-primary)]/5 rounded-xl p-6 border border-[var(--color-info)]/20">
|
||||
<div className="flex flex-col md:flex-row items-center space-y-4 md:space-y-0 md:space-x-6">
|
||||
<div className="w-16 h-16 rounded-full bg-[var(--color-info)]/10 flex items-center justify-center flex-shrink-0">
|
||||
<Download className="w-8 h-8 text-[var(--color-info)]" />
|
||||
</div>
|
||||
<div className="flex-1 text-center md:text-left">
|
||||
<h4 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||
¿Necesitas ayuda con el formato?
|
||||
</h4>
|
||||
<p className="text-[var(--text-secondary)] mb-4">
|
||||
Descarga nuestra plantilla Excel con ejemplos y formato correcto para tus datos de ventas
|
||||
</p>
|
||||
<Button
|
||||
onClick={downloadTemplate}
|
||||
className="bg-[var(--color-info)] hover:bg-[var(--color-info)]/90 text-white shadow-lg"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Descargar Plantilla Gratuita
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Processing Stages */}
|
||||
{(stage === 'validating' || stage === 'analyzing') && (
|
||||
<Card className="p-8">
|
||||
<div className="text-center">
|
||||
<div className="relative mb-8">
|
||||
<div className={`w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-6 ${
|
||||
stage === 'validating'
|
||||
? 'bg-[var(--color-info)]/10 animate-pulse'
|
||||
: 'bg-[var(--color-primary)]/10 animate-pulse'
|
||||
}`}>
|
||||
{stage === 'validating' ? (
|
||||
<FileText className={`w-8 h-8 ${stage === 'validating' ? 'text-[var(--color-info)]' : 'text-[var(--color-primary)]'}`} />
|
||||
) : (
|
||||
<Brain className="w-8 h-8 text-[var(--color-primary)]" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="text-2xl font-semibold text-[var(--text-primary)] mb-2">
|
||||
{stage === 'validating' ? 'Validando datos...' : 'Analizando con IA...'}
|
||||
</h3>
|
||||
<p className="text-[var(--text-secondary)] mb-8">
|
||||
{currentMessage}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||
Progreso
|
||||
</span>
|
||||
<span className="text-sm text-[var(--text-secondary)]">{progress}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-[var(--bg-secondary)] rounded-full h-3">
|
||||
<div
|
||||
className="bg-gradient-to-r from-[var(--color-info)] to-[var(--color-primary)] h-3 rounded-full transition-all duration-500 ease-out"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Processing Steps */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className={`p-4 rounded-lg text-center ${
|
||||
progress >= 40 ? 'bg-[var(--color-success)]/10 text-[var(--color-success)]' : 'bg-[var(--bg-secondary)]'
|
||||
}`}>
|
||||
<FileText className="w-6 h-6 mx-auto mb-2" />
|
||||
<span className="text-sm font-medium">Validación</span>
|
||||
</div>
|
||||
<div className={`p-4 rounded-lg text-center ${
|
||||
progress >= 70 ? 'bg-[var(--color-success)]/10 text-[var(--color-success)]' : 'bg-[var(--bg-secondary)]'
|
||||
}`}>
|
||||
<Brain className="w-6 h-6 mx-auto mb-2" />
|
||||
<span className="text-sm font-medium">Análisis IA</span>
|
||||
</div>
|
||||
<div className={`p-4 rounded-lg text-center ${
|
||||
progress >= 100 ? 'bg-[var(--color-success)]/10 text-[var(--color-success)]' : 'bg-[var(--bg-secondary)]'
|
||||
}`}>
|
||||
<CheckCircle className="w-6 h-6 mx-auto mb-2" />
|
||||
<span className="text-sm font-medium">Completo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Simplified Results Stage */}
|
||||
{stage === 'completed' && results && (
|
||||
<div className="space-y-8">
|
||||
{/* Success Header */}
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-[var(--color-success)] rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<CheckCircle className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold text-[var(--color-success)] mb-3">
|
||||
¡Procesamiento Completado!
|
||||
</h3>
|
||||
<p className="text-[var(--text-secondary)] max-w-2xl mx-auto">
|
||||
Tus datos han sido procesados exitosamente
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Simple Stats Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center p-4 bg-[var(--color-info)]/10 rounded-lg">
|
||||
<p className="text-2xl font-bold text-[var(--color-info)]">{results.total_records}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Registros</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-[var(--color-primary)]/10 rounded-lg">
|
||||
<p className="text-2xl font-bold text-[var(--color-primary)]">{results.productsIdentified}</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Productos</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-[var(--color-success)]/10 rounded-lg">
|
||||
<p className="text-2xl font-bold text-[var(--color-success)]">{results.confidenceScore}%</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Confianza</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-lg font-bold text-[var(--text-primary)]">
|
||||
{results.businessModel === 'artisan' ? 'Artesanal' :
|
||||
results.businessModel === 'retail' ? 'Retail' : 'Híbrido'}
|
||||
</p>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Modelo</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{stage === 'error' && (
|
||||
<Card className="p-8 text-center">
|
||||
<div className="w-16 h-16 bg-[var(--color-error)] rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<AlertCircle className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-[var(--color-error)] mb-3">
|
||||
Error en el procesamiento
|
||||
</h3>
|
||||
<p className="text-[var(--text-secondary)] mb-6">
|
||||
{currentMessage}
|
||||
</p>
|
||||
<Button onClick={resetProcess} variant="outline">
|
||||
Intentar nuevamente
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,565 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Package, Calendar, AlertTriangle, Plus, Edit, Trash2 } from 'lucide-react';
|
||||
import { Button, Card, Input, Badge } from '../../../ui';
|
||||
import { OnboardingStepProps } from '../OnboardingWizard';
|
||||
|
||||
interface InventoryItem {
|
||||
id: string;
|
||||
name: string;
|
||||
category: 'ingredient' | 'finished_product';
|
||||
current_stock: number;
|
||||
min_stock: number;
|
||||
max_stock: number;
|
||||
unit: string;
|
||||
expiry_date?: string;
|
||||
supplier?: string;
|
||||
cost_per_unit?: number;
|
||||
requires_refrigeration: 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
|
||||
}
|
||||
];
|
||||
|
||||
export const InventorySetupStep: React.FC<OnboardingStepProps> = ({
|
||||
data,
|
||||
onDataChange,
|
||||
onNext,
|
||||
onPrevious,
|
||||
isFirstStep,
|
||||
isLastStep
|
||||
}) => {
|
||||
const [items, setItems] = useState<InventoryItem[]>(
|
||||
data.inventoryItems || mockInventoryItems
|
||||
);
|
||||
const [editingItem, setEditingItem] = useState<InventoryItem | null>(null);
|
||||
const [isAddingNew, setIsAddingNew] = useState(false);
|
||||
const [filterCategory, setFilterCategory] = useState<'all' | 'ingredient' | 'finished_product'>('all');
|
||||
|
||||
useEffect(() => {
|
||||
onDataChange({
|
||||
...data,
|
||||
inventoryItems: items,
|
||||
inventoryConfigured: items.length > 0 && items.every(item =>
|
||||
item.min_stock > 0 && item.max_stock > item.min_stock
|
||||
)
|
||||
});
|
||||
}, [items]);
|
||||
|
||||
const handleAddItem = () => {
|
||||
const newItem: InventoryItem = {
|
||||
id: Date.now().toString(),
|
||||
name: '',
|
||||
category: 'ingredient',
|
||||
current_stock: 0,
|
||||
min_stock: 0,
|
||||
max_stock: 0,
|
||||
unit: 'kg',
|
||||
requires_refrigeration: false
|
||||
};
|
||||
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));
|
||||
}
|
||||
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));
|
||||
}
|
||||
};
|
||||
|
||||
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 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' };
|
||||
};
|
||||
|
||||
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 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
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="p-4">
|
||||
<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);
|
||||
|
||||
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>
|
||||
|
||||
{/* 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'}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 ml-4 mt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setEditingItem(item)}
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-1" />
|
||||
Editar
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleDeleteItem(item.id)}
|
||||
className="text-[var(--color-error)] border-[var(--color-error)] hover:bg-[var(--color-error)]/10"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</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>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Component for editing inventory items
|
||||
interface InventoryItemFormProps {
|
||||
item: InventoryItem;
|
||||
onSave: (item: InventoryItem) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const InventoryItemForm: React.FC<InventoryItemFormProps> = ({ item, onSave, onCancel }) => {
|
||||
const [formData, setFormData] = useState(item);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formData.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');
|
||||
return;
|
||||
}
|
||||
onSave(formData);
|
||||
};
|
||||
|
||||
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>
|
||||
<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)]"
|
||||
>
|
||||
<option value="ingredient">Ingrediente</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>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.current_stock}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, 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>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.min_stock}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, 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>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.max_stock}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, 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>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.expiry_date || ''}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, expiry_date: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||
Costo por Unidad
|
||||
</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"
|
||||
/>
|
||||
</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
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
Guardar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,780 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Brain, Activity, Zap, CheckCircle, AlertCircle, TrendingUp } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../ui';
|
||||
import { OnboardingStepProps } from '../OnboardingWizard';
|
||||
|
||||
interface TrainingMetrics {
|
||||
accuracy: number;
|
||||
precision: number;
|
||||
recall: number;
|
||||
f1_score: number;
|
||||
training_loss: number;
|
||||
validation_loss: number;
|
||||
epochs_completed: number;
|
||||
total_epochs: number;
|
||||
}
|
||||
|
||||
interface TrainingLog {
|
||||
timestamp: string;
|
||||
message: string;
|
||||
level: 'info' | 'warning' | 'error' | 'success';
|
||||
}
|
||||
|
||||
// Enhanced Mock ML training service that matches backend behavior
|
||||
const mockMLService = {
|
||||
startTraining: async (trainingData: any) => {
|
||||
return new Promise((resolve) => {
|
||||
let progress = 0;
|
||||
let step_count = 0;
|
||||
const total_steps = 12;
|
||||
|
||||
// Backend-matching training steps and messages
|
||||
const trainingSteps = [
|
||||
{ step: 'data_validation', message: 'Validando datos de entrenamiento...', progress: 10 },
|
||||
{ step: 'data_preparation_start', message: 'Preparando conjunto de datos...', progress: 20 },
|
||||
{ step: 'feature_engineering', message: 'Creando características para el modelo...', progress: 30 },
|
||||
{ step: 'data_preparation_complete', message: 'Preparación de datos completada', progress: 35 },
|
||||
{ step: 'ml_training_start', message: 'Iniciando entrenamiento del modelo Prophet...', progress: 40 },
|
||||
{ step: 'model_fitting', message: 'Ajustando modelo a los datos históricos...', progress: 55 },
|
||||
{ step: 'pattern_detection', message: 'Detectando patrones estacionales y tendencias...', progress: 70 },
|
||||
{ step: 'validation', message: 'Validando precisión del modelo...', progress: 80 },
|
||||
{ step: 'training_complete', message: 'Entrenamiento ML completado', progress: 85 },
|
||||
{ step: 'storing_models', message: 'Guardando modelo entrenado...', progress: 90 },
|
||||
{ step: 'performance_metrics', message: 'Calculando métricas de rendimiento...', progress: 95 },
|
||||
{ step: 'completed', message: 'Tu Asistente Inteligente está listo!', progress: 100 }
|
||||
];
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (step_count >= trainingSteps.length) {
|
||||
clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentStep = trainingSteps[step_count];
|
||||
progress = currentStep.progress;
|
||||
|
||||
// Generate realistic metrics that improve over time
|
||||
const stepProgress = step_count / trainingSteps.length;
|
||||
const baseAccuracy = 0.65;
|
||||
const maxAccuracy = 0.93;
|
||||
const currentAccuracy = baseAccuracy + (maxAccuracy - baseAccuracy) * Math.pow(stepProgress, 0.8);
|
||||
|
||||
const metrics: TrainingMetrics = {
|
||||
accuracy: Math.min(maxAccuracy, currentAccuracy + (Math.random() * 0.02 - 0.01)),
|
||||
precision: Math.min(0.95, 0.70 + stepProgress * 0.23 + (Math.random() * 0.02 - 0.01)),
|
||||
recall: Math.min(0.94, 0.68 + stepProgress * 0.24 + (Math.random() * 0.02 - 0.01)),
|
||||
f1_score: Math.min(0.94, 0.69 + stepProgress * 0.23 + (Math.random() * 0.02 - 0.01)),
|
||||
training_loss: Math.max(0.08, 1.2 - stepProgress * 1.0 + (Math.random() * 0.05 - 0.025)),
|
||||
validation_loss: Math.max(0.10, 1.3 - stepProgress * 1.1 + (Math.random() * 0.06 - 0.03)),
|
||||
epochs_completed: Math.floor(stepProgress * 15) + 1,
|
||||
total_epochs: 15
|
||||
};
|
||||
|
||||
// Generate step-specific logs that match backend behavior
|
||||
const logs: TrainingLog[] = [];
|
||||
|
||||
// Add the current step message
|
||||
logs.push({
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
message: currentStep.message,
|
||||
level: currentStep.step === 'completed' ? 'success' : 'info'
|
||||
});
|
||||
|
||||
// Add specific logs based on the training step
|
||||
if (currentStep.step === 'data_validation') {
|
||||
logs.push({
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
message: `Productos analizados: ${trainingData.products?.length || 3}`,
|
||||
level: 'info'
|
||||
});
|
||||
logs.push({
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
message: `Registros de inventario: ${trainingData.inventory?.length || 3}`,
|
||||
level: 'info'
|
||||
});
|
||||
} else if (currentStep.step === 'ml_training_start') {
|
||||
logs.push({
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
message: 'Usando modelo Prophet optimizado para panadería',
|
||||
level: 'info'
|
||||
});
|
||||
} else if (currentStep.step === 'model_fitting') {
|
||||
logs.push({
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
message: `Precisión actual: ${(metrics.accuracy * 100).toFixed(1)}%`,
|
||||
level: 'info'
|
||||
});
|
||||
} else if (currentStep.step === 'pattern_detection') {
|
||||
logs.push({
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
message: 'Patrones estacionales detectados: días de la semana, horas pico',
|
||||
level: 'success'
|
||||
});
|
||||
} else if (currentStep.step === 'validation') {
|
||||
logs.push({
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
message: `MAE: ${(Math.random() * 2 + 1.5).toFixed(2)}, MAPE: ${((1 - metrics.accuracy) * 100).toFixed(1)}%`,
|
||||
level: 'info'
|
||||
});
|
||||
} else if (currentStep.step === 'storing_models') {
|
||||
logs.push({
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
message: `Modelo guardado: bakery_prophet_${Date.now()}`,
|
||||
level: 'success'
|
||||
});
|
||||
}
|
||||
|
||||
// Emit progress event that matches backend WebSocket message structure
|
||||
window.dispatchEvent(new CustomEvent('mlTrainingProgress', {
|
||||
detail: {
|
||||
type: 'progress',
|
||||
job_id: `training_${Date.now()}`,
|
||||
data: {
|
||||
progress,
|
||||
current_step: currentStep.step,
|
||||
step_details: currentStep.message,
|
||||
products_completed: Math.floor(step_count / 3),
|
||||
products_total: Math.max(3, trainingData.products?.length || 3),
|
||||
estimated_time_remaining_minutes: Math.max(0, Math.floor((total_steps - step_count) * 0.75))
|
||||
},
|
||||
timestamp: new Date().toISOString(),
|
||||
// Keep old format for backward compatibility
|
||||
status: progress >= 100 ? 'completed' : 'training',
|
||||
currentPhase: currentStep.message,
|
||||
metrics,
|
||||
logs
|
||||
}
|
||||
}));
|
||||
|
||||
step_count++;
|
||||
|
||||
if (progress >= 100) {
|
||||
clearInterval(interval);
|
||||
|
||||
// Generate final results matching backend response structure
|
||||
const finalAccuracy = 0.89 + Math.random() * 0.05;
|
||||
const finalResult = {
|
||||
success: true,
|
||||
job_id: `training_${Date.now()}`,
|
||||
tenant_id: 'demo_tenant',
|
||||
status: 'completed',
|
||||
training_results: {
|
||||
total_products: Math.max(3, trainingData.products?.length || 3),
|
||||
successful_trainings: Math.max(3, trainingData.products?.length || 3),
|
||||
failed_trainings: 0,
|
||||
products: Array.from({length: Math.max(3, trainingData.products?.length || 3)}, (_, i) => ({
|
||||
inventory_product_id: `product_${i + 1}`,
|
||||
status: 'completed',
|
||||
model_id: `model_${i + 1}_${Date.now()}`,
|
||||
data_points: 45 + Math.floor(Math.random() * 30),
|
||||
metrics: {
|
||||
mape: (1 - finalAccuracy) * 100 + (Math.random() * 2 - 1),
|
||||
mae: 1.5 + Math.random() * 1.0,
|
||||
rmse: 2.1 + Math.random() * 1.2,
|
||||
r2_score: finalAccuracy + (Math.random() * 0.02 - 0.01)
|
||||
}
|
||||
})),
|
||||
overall_training_time_seconds: 45 + Math.random() * 15
|
||||
},
|
||||
data_summary: {
|
||||
sales_records: trainingData.inventory?.length * 30 || 1500,
|
||||
weather_records: 90,
|
||||
traffic_records: 85,
|
||||
date_range: {
|
||||
start: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
end: new Date().toISOString()
|
||||
},
|
||||
data_sources_used: ['bakery_sales', 'weather_forecast', 'madrid_traffic'],
|
||||
constraints_applied: {}
|
||||
},
|
||||
finalMetrics: {
|
||||
accuracy: finalAccuracy,
|
||||
precision: finalAccuracy * 0.98,
|
||||
recall: finalAccuracy * 0.96,
|
||||
f1_score: finalAccuracy * 0.97,
|
||||
training_loss: 0.12 + Math.random() * 0.05,
|
||||
validation_loss: 0.15 + Math.random() * 0.05,
|
||||
total_epochs: 15
|
||||
},
|
||||
modelId: `bakery_prophet_${Date.now()}`,
|
||||
deploymentUrl: '/api/v1/training/models/predict',
|
||||
trainingDuration: `${(45 + Math.random() * 15).toFixed(1)} segundos`,
|
||||
datasetSize: `${trainingData.inventory?.length * 30 || 1500} registros`,
|
||||
modelVersion: '2.1.0',
|
||||
completed_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
resolve(finalResult);
|
||||
}
|
||||
}, 1800); // Matches backend timing
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getTrainingPhase = (epoch: number, total: number) => {
|
||||
const progress = epoch / total;
|
||||
if (progress <= 0.3) return 'Inicializando modelo de IA...';
|
||||
if (progress <= 0.6) return 'Entrenando patrones de venta...';
|
||||
if (progress <= 0.8) return 'Optimizando predicciones...';
|
||||
if (progress <= 0.95) return 'Validando rendimiento...';
|
||||
return 'Finalizando entrenamiento...';
|
||||
};
|
||||
|
||||
export const MLTrainingStep: React.FC<OnboardingStepProps> = ({
|
||||
data,
|
||||
onDataChange,
|
||||
onNext,
|
||||
onPrevious,
|
||||
isFirstStep,
|
||||
isLastStep
|
||||
}) => {
|
||||
const [trainingStatus, setTrainingStatus] = useState(data.trainingStatus || 'pending');
|
||||
const [progress, setProgress] = useState(data.trainingProgress || 0);
|
||||
const [currentPhase, setCurrentPhase] = useState(data.currentPhase || '');
|
||||
const [metrics, setMetrics] = useState<TrainingMetrics | null>(data.trainingMetrics || null);
|
||||
const [logs, setLogs] = useState<TrainingLog[]>(data.trainingLogs || []);
|
||||
const [finalResults, setFinalResults] = useState<any>(data.finalResults || null);
|
||||
|
||||
// New state variables for backend-compatible data
|
||||
const [productsCompleted, setProductsCompleted] = useState(0);
|
||||
const [productsTotal, setProductsTotal] = useState(0);
|
||||
const [estimatedTimeRemaining, setEstimatedTimeRemaining] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Listen for ML training progress updates
|
||||
const handleProgress = (event: CustomEvent) => {
|
||||
const detail = event.detail;
|
||||
|
||||
// Handle new backend-compatible WebSocket message format
|
||||
if (detail.type === 'progress' && detail.data) {
|
||||
const progressData = detail.data;
|
||||
setProgress(progressData.progress || 0);
|
||||
setCurrentPhase(progressData.step_details || progressData.current_step || 'En progreso...');
|
||||
|
||||
// Update products progress if available
|
||||
if (progressData.products_completed !== undefined) {
|
||||
setProductsCompleted(progressData.products_completed);
|
||||
}
|
||||
if (progressData.products_total !== undefined) {
|
||||
setProductsTotal(progressData.products_total);
|
||||
}
|
||||
|
||||
// Update time estimate if available
|
||||
if (progressData.estimated_time_remaining_minutes !== undefined) {
|
||||
setEstimatedTimeRemaining(progressData.estimated_time_remaining_minutes);
|
||||
}
|
||||
|
||||
// Handle metrics (from legacy format for backward compatibility)
|
||||
if (detail.metrics) {
|
||||
setMetrics(detail.metrics);
|
||||
}
|
||||
|
||||
// Handle logs (from legacy format for backward compatibility)
|
||||
if (detail.logs && detail.logs.length > 0) {
|
||||
setLogs(prev => [...prev, ...detail.logs]);
|
||||
}
|
||||
|
||||
// Check completion status
|
||||
if (progressData.progress >= 100) {
|
||||
setTrainingStatus('completed');
|
||||
} else {
|
||||
setTrainingStatus('training');
|
||||
}
|
||||
} else {
|
||||
// Handle legacy format for backward compatibility
|
||||
const { progress: newProgress, metrics: newMetrics, logs: newLogs, status, currentPhase: newPhase } = detail;
|
||||
|
||||
setProgress(newProgress || 0);
|
||||
setMetrics(newMetrics);
|
||||
setCurrentPhase(newPhase || 'En progreso...');
|
||||
setTrainingStatus(status || 'training');
|
||||
|
||||
if (newLogs && newLogs.length > 0) {
|
||||
setLogs(prev => [...prev, ...newLogs]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('mlTrainingProgress', handleProgress as EventListener);
|
||||
return () => window.removeEventListener('mlTrainingProgress', handleProgress as EventListener);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Update parent data when state changes
|
||||
onDataChange({
|
||||
...data,
|
||||
trainingStatus,
|
||||
trainingProgress: progress,
|
||||
currentPhase,
|
||||
trainingMetrics: metrics,
|
||||
trainingLogs: logs,
|
||||
finalResults
|
||||
});
|
||||
}, [trainingStatus, progress, currentPhase, metrics, logs, finalResults]);
|
||||
|
||||
// Auto-start training when coming from suppliers step
|
||||
useEffect(() => {
|
||||
if (data.autoStartTraining && trainingStatus === 'pending') {
|
||||
const timer = setTimeout(() => {
|
||||
startTraining();
|
||||
// Remove the auto-start flag so it doesn't trigger again
|
||||
onDataChange({ ...data, autoStartTraining: false });
|
||||
}, 1000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [data.autoStartTraining, trainingStatus]);
|
||||
|
||||
const startTraining = async () => {
|
||||
// Access data from previous steps through allStepData
|
||||
const inventoryData = data.allStepData?.inventory?.inventoryItems ||
|
||||
data.allStepData?.['inventory-setup']?.inventoryItems ||
|
||||
data.inventoryItems || [];
|
||||
|
||||
const detectedProducts = data.allStepData?.review?.detectedProducts?.filter((p: any) => p.status === 'approved') ||
|
||||
data.allStepData?.['data-processing']?.detectedProducts?.filter((p: any) => p.status === 'approved') ||
|
||||
data.detectedProducts?.filter((p: any) => p.status === 'approved') || [];
|
||||
|
||||
const salesValidation = data.allStepData?.['data-processing']?.validation ||
|
||||
data.allStepData?.review?.validation ||
|
||||
data.validation || {};
|
||||
|
||||
// If no data is available, create mock data for demo purposes
|
||||
let finalInventoryData = inventoryData;
|
||||
let finalDetectedProducts = detectedProducts;
|
||||
let finalValidation = salesValidation;
|
||||
|
||||
if (inventoryData.length === 0 && detectedProducts.length === 0) {
|
||||
console.log('No data found from previous steps, using mock data for demo');
|
||||
|
||||
// Create mock data for demonstration
|
||||
finalInventoryData = [
|
||||
{ id: '1', name: 'Harina de Trigo', category: 'ingredient', current_stock: 50, min_stock: 20, max_stock: 100, unit: 'kg' },
|
||||
{ id: '2', name: 'Levadura Fresca', category: 'ingredient', current_stock: 5, min_stock: 2, max_stock: 10, unit: 'kg' },
|
||||
{ id: '3', name: 'Pan Integral', category: 'finished_product', current_stock: 20, min_stock: 10, max_stock: 50, unit: 'unidades' }
|
||||
];
|
||||
|
||||
finalDetectedProducts = [
|
||||
{ name: 'Pan Francés', status: 'approved', category: 'Panadería' },
|
||||
{ name: 'Croissants', status: 'approved', category: 'Repostería' },
|
||||
{ name: 'Pan Integral', status: 'approved', category: 'Panadería' }
|
||||
];
|
||||
|
||||
finalValidation = {
|
||||
total_records: 1500,
|
||||
summary: {
|
||||
date_range: '2024-01-01 to 2024-12-31'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
setTrainingStatus('training');
|
||||
setProgress(0);
|
||||
setLogs([{
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
message: 'Iniciando entrenamiento del modelo de Machine Learning...',
|
||||
level: 'info'
|
||||
}]);
|
||||
|
||||
try {
|
||||
const result = await mockMLService.startTraining({
|
||||
products: finalDetectedProducts,
|
||||
inventory: finalInventoryData,
|
||||
salesData: finalValidation
|
||||
});
|
||||
|
||||
setFinalResults(result);
|
||||
setTrainingStatus('completed');
|
||||
|
||||
} catch (error) {
|
||||
console.error('ML Training error:', error);
|
||||
setTrainingStatus('error');
|
||||
setLogs(prev => [...prev, {
|
||||
timestamp: new Date().toLocaleTimeString(),
|
||||
message: 'Error durante el entrenamiento del modelo',
|
||||
level: 'error'
|
||||
}]);
|
||||
}
|
||||
};
|
||||
|
||||
const retryTraining = () => {
|
||||
setTrainingStatus('pending');
|
||||
setProgress(0);
|
||||
setMetrics(null);
|
||||
setLogs([]);
|
||||
setFinalResults(null);
|
||||
};
|
||||
|
||||
const getLogIcon = (level: string) => {
|
||||
switch (level) {
|
||||
case 'success': return '✅';
|
||||
case 'warning': return '⚠️';
|
||||
case 'error': return '❌';
|
||||
default: return 'ℹ️';
|
||||
}
|
||||
};
|
||||
|
||||
const getLogColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'success': return 'text-[var(--color-success)]';
|
||||
case 'warning': return 'text-[var(--color-warning)]';
|
||||
case 'error': return 'text-[var(--color-error)]';
|
||||
default: return 'text-[var(--text-secondary)]';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-16 h-16 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Brain className="w-8 h-8 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
<p className="text-[var(--text-secondary)]">
|
||||
Tu asistente inteligente analizará tus datos históricos para ayudarte a tomar mejores decisiones de negocio
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Auto-start notification */}
|
||||
{data.autoStartTraining && trainingStatus === 'pending' && (
|
||||
<Card className="p-4 bg-[var(--color-info)]/10 border-[var(--color-info)]/20 mb-4">
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<div className="animate-spin w-5 h-5 border-2 border-[var(--color-info)] border-t-transparent rounded-full"></div>
|
||||
<p className="text-[var(--color-info)] font-medium">
|
||||
Creando tu asistente inteligente automáticamente...
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Training Controls */}
|
||||
{trainingStatus === 'pending' && !data.autoStartTraining && (
|
||||
<Card className="p-6 text-center">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">
|
||||
Crear tu Asistente Inteligente
|
||||
</h3>
|
||||
<p className="text-[var(--text-secondary)] mb-6">
|
||||
Analizaremos tus datos de ventas y productos para crear un asistente que te ayude a predecir demanda y optimizar tu inventario.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<Activity className="w-6 h-6 text-[var(--color-info)] mx-auto mb-2" />
|
||||
<h4 className="font-medium text-[var(--text-primary)]">Predicción de Ventas</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Anticipa cuánto vas a vender</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<TrendingUp className="w-6 h-6 text-[var(--color-success)] mx-auto mb-2" />
|
||||
<h4 className="font-medium text-[var(--text-primary)]">Recomendaciones</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Cuánto producir y comprar</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<Zap className="w-6 h-6 text-[var(--color-warning)] mx-auto mb-2" />
|
||||
<h4 className="font-medium text-[var(--text-primary)]">Alertas Automáticas</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)]">Te avisa cuando reordenar</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[var(--color-info)]/10 border border-[var(--color-info)]/20 rounded-lg p-4 mb-6">
|
||||
<h4 className="font-medium text-[var(--color-info)] mb-2">Información que analizaremos:</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm text-[var(--color-info)]">
|
||||
<div>
|
||||
<span className="font-medium">Productos:</span>
|
||||
<br />
|
||||
{(() => {
|
||||
const allStepData = data.allStepData || {};
|
||||
const detectedProducts = allStepData.review?.detectedProducts?.filter((p: any) => p.status === 'approved') ||
|
||||
allStepData['data-processing']?.detectedProducts?.filter((p: any) => p.status === 'approved') ||
|
||||
data.detectedProducts?.filter((p: any) => p.status === 'approved') || [];
|
||||
return detectedProducts.length || 3; // fallback to mock data count
|
||||
})()}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Inventario:</span>
|
||||
<br />
|
||||
{(() => {
|
||||
const allStepData = data.allStepData || {};
|
||||
const inventoryData = allStepData.inventory?.inventoryItems ||
|
||||
allStepData['inventory-setup']?.inventoryItems ||
|
||||
allStepData['inventory']?.inventoryItems ||
|
||||
data.inventoryItems || [];
|
||||
return inventoryData.length || 3; // fallback to mock data count
|
||||
})()} elementos
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Registros:</span>
|
||||
<br />
|
||||
{(() => {
|
||||
const allStepData = data.allStepData || {};
|
||||
const validation = allStepData['data-processing']?.validation ||
|
||||
allStepData.review?.validation ||
|
||||
data.validation || {};
|
||||
return validation.total_records || 1500; // fallback to mock data
|
||||
})()}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Período:</span>
|
||||
<br />
|
||||
{(() => {
|
||||
const allStepData = data.allStepData || {};
|
||||
const validation = allStepData['data-processing']?.validation ||
|
||||
allStepData.review?.validation ||
|
||||
data.validation || {};
|
||||
const dateRange = validation.summary?.date_range || '2024-01-01 to 2024-12-31';
|
||||
return dateRange.split(' to ').map((date: string) =>
|
||||
new Date(date).toLocaleDateString()
|
||||
).join(' - ');
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={startTraining} className="px-8 py-2">
|
||||
<Brain className="w-4 h-4 mr-2" />
|
||||
Crear mi Asistente Inteligente
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Training Progress */}
|
||||
{trainingStatus === 'training' && (
|
||||
<div className="space-y-6">
|
||||
<Card className="p-6">
|
||||
<div className="text-center mb-6">
|
||||
<div className="animate-pulse w-12 h-12 bg-[var(--color-primary)] rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Brain className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">
|
||||
Creando tu Asistente Inteligente...
|
||||
</h3>
|
||||
<p className="text-[var(--text-secondary)]">{currentPhase}</p>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">
|
||||
Progreso de Creación
|
||||
</span>
|
||||
<div className="text-right">
|
||||
<span className="text-sm text-[var(--text-secondary)]">
|
||||
{progress.toFixed(0)}%
|
||||
</span>
|
||||
{productsTotal > 0 && (
|
||||
<div className="text-xs text-[var(--text-tertiary)]">
|
||||
{productsCompleted}/{productsTotal} productos
|
||||
</div>
|
||||
)}
|
||||
{estimatedTimeRemaining !== null && estimatedTimeRemaining > 0 && (
|
||||
<div className="text-xs text-[var(--text-tertiary)]">
|
||||
~{estimatedTimeRemaining} min restantes
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full bg-[var(--bg-secondary)] rounded-full h-4">
|
||||
<div
|
||||
className="bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-info)] h-4 rounded-full transition-all duration-1000 relative overflow-hidden"
|
||||
style={{ width: `${progress}%` }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Metrics */}
|
||||
{metrics && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="text-center p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-xl font-bold text-[var(--color-success)]">
|
||||
{(metrics.accuracy * 100).toFixed(1)}%
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">Precisión</p>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-xl font-bold text-[var(--color-info)]">
|
||||
{metrics.training_loss.toFixed(3)}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">Loss</p>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-xl font-bold text-[var(--color-primary)]">
|
||||
{metrics.epochs_completed}/{metrics.total_epochs}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">Epochs</p>
|
||||
</div>
|
||||
<div className="text-center p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||
<p className="text-xl font-bold text-[var(--color-secondary)]">
|
||||
{(metrics.f1_score * 100).toFixed(1)}%
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">F1-Score</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Training Logs */}
|
||||
<Card className="p-6">
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-4">Progreso del Asistente</h4>
|
||||
<div className="bg-black rounded-lg p-4 max-h-48 overflow-y-auto font-mono text-sm">
|
||||
{logs.slice(-10).map((log, index) => (
|
||||
<div key={index} className={`mb-1 ${getLogColor(log.level)}`}>
|
||||
<span className="text-[var(--text-quaternary)]">[{log.timestamp}]</span>
|
||||
<span className="ml-2">{getLogIcon(log.level)} {log.message}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Training Completed */}
|
||||
{trainingStatus === 'completed' && finalResults && (
|
||||
<div className="space-y-6">
|
||||
<Card className="p-6">
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-12 h-12 bg-[var(--color-success)] rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<CheckCircle className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-[var(--color-success)] mb-2">
|
||||
¡Tu Asistente Inteligente está Listo!
|
||||
</h3>
|
||||
<p className="text-[var(--text-secondary)]">
|
||||
Tu asistente personalizado está listo para ayudarte con predicciones y recomendaciones
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Final Metrics */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
|
||||
<div className="text-center p-4 bg-[var(--color-success)]/10 rounded-lg">
|
||||
<p className="text-2xl font-bold text-[var(--color-success)]">
|
||||
{(finalResults.finalMetrics.accuracy * 100).toFixed(1)}%
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">Exactitud</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--color-info)]/10 rounded-lg">
|
||||
<p className="text-2xl font-bold text-[var(--color-info)]">
|
||||
{(finalResults.finalMetrics.precision * 100).toFixed(1)}%
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">Confiabilidad</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--color-primary)]/10 rounded-lg">
|
||||
<p className="text-2xl font-bold text-[var(--color-primary)]">
|
||||
{(finalResults.finalMetrics.recall * 100).toFixed(1)}%
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">Cobertura</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--color-secondary-100)] rounded-lg">
|
||||
<p className="text-2xl font-bold text-[var(--color-secondary)]">
|
||||
{(finalResults.finalMetrics.f1_score * 100).toFixed(1)}%
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">Rendimiento</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-[var(--color-warning-100)] rounded-lg">
|
||||
<p className="text-2xl font-bold text-[var(--color-warning)]">
|
||||
{finalResults.finalMetrics.total_epochs}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">Iteraciones</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model Info */}
|
||||
<div className="bg-[var(--bg-secondary)] rounded-lg p-4 mb-6">
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-3">Información del Modelo:</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="font-medium text-[var(--text-tertiary)]">ID del Modelo:</span>
|
||||
<p className="font-mono text-[var(--text-primary)]">{finalResults.modelId}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-[var(--text-tertiary)]">Versión:</span>
|
||||
<p className="font-mono text-[var(--text-primary)]">{finalResults.modelVersion}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-[var(--text-tertiary)]">Duración:</span>
|
||||
<p className="text-[var(--text-primary)]">{finalResults.trainingDuration}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-[var(--text-tertiary)]">Dataset:</span>
|
||||
<p className="text-[var(--text-primary)]">{finalResults.datasetSize}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-[var(--text-tertiary)]">Endpoint:</span>
|
||||
<p className="font-mono text-[var(--text-primary)]">{finalResults.deploymentUrl}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-[var(--text-tertiary)]">Estado:</span>
|
||||
<p className="text-[var(--color-success)] font-medium">✓ Activo</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Capabilities */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="p-4 border border-[var(--color-success)]/20 rounded-lg">
|
||||
<TrendingUp className="w-6 h-6 text-[var(--color-success)] mb-2" />
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-1">Predicción de Ventas</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Te dice cuánto vas a vender cada día
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-[var(--color-info)]/20 rounded-lg">
|
||||
<Activity className="w-6 h-6 text-[var(--color-info)] mb-2" />
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-1">Recomendaciones</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Te sugiere cuánto producir y comprar
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-[var(--color-warning)]/20 rounded-lg">
|
||||
<Zap className="w-6 h-6 text-[var(--color-warning)] mb-2" />
|
||||
<h4 className="font-medium text-[var(--text-primary)] mb-1">Alertas Automáticas</h4>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Te avisa cuando necesitas reordenar
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Training Error */}
|
||||
{trainingStatus === 'error' && (
|
||||
<Card className="p-6">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 bg-[var(--color-error)] rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<AlertCircle className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-[var(--color-error)] mb-2">
|
||||
Error al Crear el Asistente
|
||||
</h3>
|
||||
<p className="text-[var(--text-secondary)] mb-4">
|
||||
Ocurrió un problema durante la creación de tu asistente inteligente. Por favor, intenta nuevamente.
|
||||
</p>
|
||||
<Button onClick={retryTraining} variant="outline">
|
||||
Intentar Nuevamente
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
🎯 ¿Cómo funciona tu Asistente Inteligente?
|
||||
</h4>
|
||||
<ul className="text-sm text-[var(--color-info)] space-y-1">
|
||||
<li>• <strong>Analiza tus ventas:</strong> Estudia tus datos históricos para encontrar patrones</li>
|
||||
<li>• <strong>Entiende tu negocio:</strong> Aprende sobre temporadas altas y bajas</li>
|
||||
<li>• <strong>Hace predicciones:</strong> Te dice cuánto vas a vender cada día</li>
|
||||
<li>• <strong>Da recomendaciones:</strong> Te sugiere cuánto producir y comprar</li>
|
||||
<li>• <strong>Mejora con el tiempo:</strong> Se hace más inteligente con cada venta nueva</li>
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
285
frontend/src/components/domain/onboarding/steps/ReviewStep.tsx
Normal file
285
frontend/src/components/domain/onboarding/steps/ReviewStep.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Eye, CheckCircle, AlertCircle, Edit, Trash2 } from 'lucide-react';
|
||||
import { Button, Card, Badge } from '../../../ui';
|
||||
import { OnboardingStepProps } from '../OnboardingWizard';
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
confidence: number;
|
||||
sales_count: number;
|
||||
estimated_price: number;
|
||||
status: 'approved' | 'rejected' | 'pending';
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
// 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' }
|
||||
];
|
||||
|
||||
export const ReviewStep: React.FC<OnboardingStepProps> = ({
|
||||
data,
|
||||
onDataChange,
|
||||
onNext,
|
||||
onPrevious,
|
||||
isFirstStep,
|
||||
isLastStep
|
||||
}) => {
|
||||
// Generate products from processing results or use mock data
|
||||
const generateProductsFromResults = (results: any) => {
|
||||
if (!results?.product_list) return mockDetectedProducts;
|
||||
|
||||
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
|
||||
}));
|
||||
};
|
||||
|
||||
const [products, setProducts] = useState<Product[]>(
|
||||
data.detectedProducts || generateProductsFromResults(data.processingResults)
|
||||
);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||
|
||||
const categories = ['all', ...Array.from(new Set(products.map(p => p.category)))];
|
||||
|
||||
useEffect(() => {
|
||||
onDataChange({
|
||||
...data,
|
||||
detectedProducts: products,
|
||||
reviewCompleted: products.every(p => p.status !== 'pending')
|
||||
});
|
||||
}, [products]);
|
||||
|
||||
const handleProductAction = (productId: string, action: 'approve' | 'reject') => {
|
||||
setProducts(prev => prev.map(product =>
|
||||
product.id === productId
|
||||
? { ...product, status: action === 'approve' ? 'approved' : 'rejected' }
|
||||
: product
|
||||
));
|
||||
};
|
||||
|
||||
const handleBulkAction = (action: 'approve' | 'reject') => {
|
||||
const filteredProducts = getFilteredProducts();
|
||||
setProducts(prev => prev.map(product =>
|
||||
filteredProducts.some(fp => fp.id === product.id)
|
||||
? { ...product, status: action === 'approve' ? 'approved' : 'rejected' }
|
||||
: product
|
||||
));
|
||||
};
|
||||
|
||||
const getFilteredProducts = () => {
|
||||
if (selectedCategory === 'all') {
|
||||
return products;
|
||||
}
|
||||
return products.filter(p => p.category === selectedCategory);
|
||||
};
|
||||
|
||||
const stats = {
|
||||
total: products.length,
|
||||
approved: products.filter(p => p.status === 'approved').length,
|
||||
rejected: products.filter(p => p.status === 'rejected').length,
|
||||
pending: products.filter(p => p.status === 'pending').length
|
||||
};
|
||||
|
||||
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>
|
||||
<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>
|
||||
</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">
|
||||
{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)]" />
|
||||
)}
|
||||
</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>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,585 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Truck, Phone, Mail, Plus, Edit, Trash2, MapPin } from 'lucide-react';
|
||||
import { Button, Card, Input, Badge } from '../../../ui';
|
||||
import { OnboardingStepProps } from '../OnboardingWizard';
|
||||
|
||||
interface Supplier {
|
||||
id: string;
|
||||
name: string;
|
||||
contact_person: 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;
|
||||
}
|
||||
|
||||
// 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'
|
||||
];
|
||||
|
||||
export const SuppliersStep: React.FC<OnboardingStepProps> = ({
|
||||
data,
|
||||
onDataChange,
|
||||
onNext,
|
||||
onPrevious,
|
||||
isFirstStep,
|
||||
isLastStep
|
||||
}) => {
|
||||
const [suppliers, setSuppliers] = useState<Supplier[]>(
|
||||
data.suppliers || mockSuppliers
|
||||
);
|
||||
const [editingSupplier, setEditingSupplier] = useState<Supplier | null>(null);
|
||||
const [isAddingNew, setIsAddingNew] = useState(false);
|
||||
const [filterStatus, setFilterStatus] = useState<'all' | 'active' | 'inactive'>('all');
|
||||
|
||||
useEffect(() => {
|
||||
onDataChange({
|
||||
...data,
|
||||
suppliers: suppliers,
|
||||
suppliersConfigured: true // This step is optional
|
||||
});
|
||||
}, [suppliers]);
|
||||
|
||||
const handleAddSupplier = () => {
|
||||
const newSupplier: Supplier = {
|
||||
id: Date.now().toString(),
|
||||
name: '',
|
||||
contact_person: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
address: '',
|
||||
categories: [],
|
||||
payment_terms: '30 días',
|
||||
delivery_days: [],
|
||||
status: 'active',
|
||||
created_at: new Date().toISOString().split('T')[0]
|
||||
};
|
||||
setEditingSupplier(newSupplier);
|
||||
setIsAddingNew(true);
|
||||
};
|
||||
|
||||
const handleSaveSupplier = (supplier: Supplier) => {
|
||||
if (isAddingNew) {
|
||||
setSuppliers(prev => [...prev, supplier]);
|
||||
} else {
|
||||
setSuppliers(prev => prev.map(s => s.id === supplier.id ? supplier : s));
|
||||
}
|
||||
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 toggleSupplierStatus = (id: string) => {
|
||||
setSuppliers(prev => prev.map(s =>
|
||||
s.id === id
|
||||
? { ...s, status: s.status === 'active' ? 'inactive' : 'active' }
|
||||
: s
|
||||
));
|
||||
};
|
||||
|
||||
const getFilteredSuppliers = () => {
|
||||
return filterStatus === 'all'
|
||||
? suppliers
|
||||
: suppliers.filter(s => s.status === filterStatus);
|
||||
};
|
||||
|
||||
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
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Optional Step Notice */}
|
||||
<Card className="p-4 bg-[var(--color-info)]/5 border-[var(--color-info)]/20">
|
||||
<p className="text-sm text-[var(--color-info)]">
|
||||
💡 <strong>Paso Opcional:</strong> Puedes saltar este paso y configurar proveedores más tarde desde el módulo de compras
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
{/* 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} proveedores configurados ({stats.active} activos)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleAddSupplier}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Agregar Proveedor
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 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>
|
||||
</Card>
|
||||
<Card className="p-4 text-center">
|
||||
<p className="text-2xl font-bold text-[var(--color-success)]">{stats.active}</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">Activos</p>
|
||||
</Card>
|
||||
<Card className="p-4 text-center">
|
||||
<p className="text-2xl font-bold text-[var(--text-tertiary)]">{stats.inactive}</p>
|
||||
<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>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<label className="text-sm font-medium text-[var(--text-secondary)]">Estado:</label>
|
||||
<div className="flex space-x-2">
|
||||
{[
|
||||
{ value: 'all', label: 'Todos' },
|
||||
{ value: 'active', label: 'Activos' },
|
||||
{ value: 'inactive', label: 'Inactivos' }
|
||||
].map(filter => (
|
||||
<button
|
||||
key={filter.value}
|
||||
onClick={() => setFilterStatus(filter.value as any)}
|
||||
className={`px-3 py-1 text-sm rounded-full transition-colors ${
|
||||
filterStatus === 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>
|
||||
|
||||
{/* Suppliers List */}
|
||||
<div className="space-y-4">
|
||||
{getFilteredSuppliers().map((supplier) => (
|
||||
<Card key={supplier.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 ${
|
||||
supplier.status === 'active'
|
||||
? 'bg-[var(--color-success)]/10'
|
||||
: 'bg-[var(--bg-secondary)] border border-[var(--border-secondary)]'
|
||||
}`}>
|
||||
<Truck className={`w-4 h-4 ${
|
||||
supplier.status === 'active'
|
||||
? 'text-[var(--color-success)]'
|
||||
: 'text-[var(--text-tertiary)]'
|
||||
}`} />
|
||||
</div>
|
||||
|
||||
{/* Supplier Info */}
|
||||
<div className="flex-1">
|
||||
<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>
|
||||
{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>
|
||||
)}
|
||||
</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 && (
|
||||
<div>
|
||||
<span className="text-[var(--text-tertiary)]">Mín: </span>
|
||||
<span className="font-medium text-[var(--text-primary)]">${supplier.min_order_amount}</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>
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPin className="w-3 h-3" />
|
||||
<span>{supplier.address}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{supplier.notes && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 ml-4 mt-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setEditingSupplier(supplier)}
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-1" />
|
||||
Editar
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => toggleSupplierStatus(supplier.id)}
|
||||
className={supplier.status === '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'
|
||||
}
|
||||
>
|
||||
{supplier.status === 'active' ? 'Pausar' : 'Activar'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleDeleteSupplier(supplier.id)}
|
||||
className="text-[var(--color-error)] border-[var(--color-error)] hover:bg-[var(--color-error)]/10"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{getFilteredSuppliers().length === 0 && (
|
||||
<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}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Agregar Primer Proveedor
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit Modal */}
|
||||
{editingSupplier && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<Card className="p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">
|
||||
{isAddingNew ? 'Agregar Proveedor' : 'Editar Proveedor'}
|
||||
</h3>
|
||||
|
||||
<SupplierForm
|
||||
supplier={editingSupplier}
|
||||
onSave={handleSaveSupplier}
|
||||
onCancel={() => {
|
||||
setEditingSupplier(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">
|
||||
🚚 Gestión de Proveedores:
|
||||
</h4>
|
||||
<ul className="text-sm text-[var(--color-info)] space-y-1">
|
||||
<li>• <strong>Este paso es opcional</strong> - puedes configurar proveedores más tarde</li>
|
||||
<li>• Define categorías de productos para facilitar la búsqueda</li>
|
||||
<li>• Establece días de entrega y términos de pago</li>
|
||||
<li>• Configura montos mínimos de pedido para optimizar compras</li>
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Component for editing suppliers
|
||||
interface SupplierFormProps {
|
||||
supplier: Supplier;
|
||||
onSave: (supplier: Supplier) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const SupplierForm: React.FC<SupplierFormProps> = ({ supplier, onSave, onCancel }) => {
|
||||
const [formData, setFormData] = useState(supplier);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formData.name.trim()) {
|
||||
alert('El nombre es requerido');
|
||||
return;
|
||||
}
|
||||
if (!formData.contact_person.trim()) {
|
||||
alert('El contacto es requerido');
|
||||
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">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||
Nombre de la Empresa *
|
||||
</label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="Molinos del Sur"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||
Persona de Contacto *
|
||||
</label>
|
||||
<Input
|
||||
value={formData.contact_person}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, contact_person: e.target.value }))}
|
||||
placeholder="Juan Pérez"
|
||||
/>
|
||||
</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">
|
||||
Teléfono
|
||||
</label>
|
||||
<Input
|
||||
value={formData.phone}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, phone: e.target.value }))}
|
||||
placeholder="+1 555-0123"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||
Email
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
|
||||
placeholder="ventas@proveedor.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||
Dirección
|
||||
</label>
|
||||
<Input
|
||||
value={formData.address}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, address: e.target.value }))}
|
||||
placeholder="Av. Industrial 123, Zona Sur"
|
||||
/>
|
||||
</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">
|
||||
Términos de Pago
|
||||
</label>
|
||||
<select
|
||||
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)]"
|
||||
>
|
||||
<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>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--text-primary)] mb-1">
|
||||
Pedido Mínimo
|
||||
</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"
|
||||
/>
|
||||
</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
|
||||
</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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4 border-t">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
Guardar Proveedor
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user