516 lines
19 KiB
TypeScript
516 lines
19 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { ChevronLeft, ChevronRight, Upload, MapPin, Store, Factory, Check } from 'lucide-react';
|
|
import toast from 'react-hot-toast';
|
|
|
|
interface OnboardingPageProps {
|
|
user: any;
|
|
onComplete: () => void;
|
|
}
|
|
|
|
interface BakeryData {
|
|
name: string;
|
|
address: string;
|
|
businessType: 'individual' | 'central_workshop';
|
|
coordinates?: { lat: number; lng: number };
|
|
products: string[];
|
|
hasHistoricalData: boolean;
|
|
csvFile?: File;
|
|
}
|
|
|
|
const MADRID_PRODUCTS = [
|
|
'Croissants', 'Pan de molde', 'Baguettes', 'Panecillos', 'Ensaimadas',
|
|
'Napolitanas', 'Magdalenas', 'Donuts', 'Palmeras', 'Café',
|
|
'Chocolate caliente', 'Zumos', 'Bocadillos', 'Empanadas', 'Tartas'
|
|
];
|
|
|
|
const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) => {
|
|
const [currentStep, setCurrentStep] = useState(1);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [bakeryData, setBakeryData] = useState<BakeryData>({
|
|
name: '',
|
|
address: '',
|
|
businessType: 'individual',
|
|
products: [],
|
|
hasHistoricalData: false
|
|
});
|
|
|
|
const steps = [
|
|
{ id: 1, title: 'Datos de Panadería', icon: Store },
|
|
{ id: 2, title: 'Productos y Servicios', icon: Factory },
|
|
{ id: 3, title: 'Datos Históricos', icon: Upload },
|
|
{ id: 4, title: 'Configuración Final', icon: Check }
|
|
];
|
|
|
|
const handleNext = () => {
|
|
if (validateCurrentStep()) {
|
|
setCurrentStep(prev => Math.min(prev + 1, steps.length));
|
|
}
|
|
};
|
|
|
|
const handlePrevious = () => {
|
|
setCurrentStep(prev => Math.max(prev - 1, 1));
|
|
};
|
|
|
|
const validateCurrentStep = (): boolean => {
|
|
switch (currentStep) {
|
|
case 1:
|
|
if (!bakeryData.name.trim()) {
|
|
toast.error('El nombre de la panadería es obligatorio');
|
|
return false;
|
|
}
|
|
if (!bakeryData.address.trim()) {
|
|
toast.error('La dirección es obligatoria');
|
|
return false;
|
|
}
|
|
return true;
|
|
case 2:
|
|
if (bakeryData.products.length === 0) {
|
|
toast.error('Selecciona al menos un producto');
|
|
return false;
|
|
}
|
|
return true;
|
|
case 3:
|
|
if (bakeryData.hasHistoricalData && !bakeryData.csvFile) {
|
|
toast.error('Por favor, sube tu archivo CSV con datos históricos');
|
|
return false;
|
|
}
|
|
return true;
|
|
default:
|
|
return true;
|
|
}
|
|
};
|
|
|
|
const handleComplete = async () => {
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
// Step 1: Register tenant/bakery
|
|
const tenantResponse = await fetch('/api/v1/tenants/register', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
|
},
|
|
body: JSON.stringify({
|
|
name: bakeryData.name,
|
|
address: bakeryData.address,
|
|
business_type: bakeryData.businessType,
|
|
coordinates: bakeryData.coordinates,
|
|
products: bakeryData.products
|
|
})
|
|
});
|
|
|
|
if (!tenantResponse.ok) {
|
|
throw new Error('Error al registrar la panadería');
|
|
}
|
|
|
|
const tenantData = await tenantResponse.json();
|
|
const tenantId = tenantData.tenant.id;
|
|
|
|
// Step 2: Upload CSV data if provided
|
|
if (bakeryData.hasHistoricalData && bakeryData.csvFile) {
|
|
const formData = new FormData();
|
|
formData.append('file', bakeryData.csvFile);
|
|
|
|
const uploadResponse = await fetch(`/api/v1/tenants/${tenantId}/data/upload`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
|
},
|
|
body: formData
|
|
});
|
|
|
|
if (!uploadResponse.ok) {
|
|
throw new Error('Error al subir los datos históricos');
|
|
}
|
|
|
|
// Step 3: Start training process
|
|
const trainingResponse = await fetch(`/api/v1/tenants/${tenantId}/training/start`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
|
},
|
|
body: JSON.stringify({
|
|
products: bakeryData.products
|
|
})
|
|
});
|
|
|
|
if (!trainingResponse.ok) {
|
|
throw new Error('Error al iniciar el entrenamiento del modelo');
|
|
}
|
|
|
|
toast.success('¡Datos subidos! El entrenamiento del modelo comenzará pronto.');
|
|
}
|
|
|
|
toast.success('¡Configuración completada! Bienvenido a PanIA');
|
|
onComplete();
|
|
|
|
} catch (error: any) {
|
|
console.error('Onboarding completion error:', error);
|
|
toast.error(error.message || 'Error al completar la configuración');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const renderStep = () => {
|
|
switch (currentStep) {
|
|
case 1:
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
|
Información de tu Panadería
|
|
</h3>
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Nombre de la panadería
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={bakeryData.name}
|
|
onChange={(e) => setBakeryData(prev => ({ ...prev, name: e.target.value }))}
|
|
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
|
placeholder="Ej: Panadería San Miguel"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Dirección completa
|
|
</label>
|
|
<div className="relative">
|
|
<MapPin className="absolute left-3 top-3 h-5 w-5 text-gray-400" />
|
|
<input
|
|
type="text"
|
|
value={bakeryData.address}
|
|
onChange={(e) => setBakeryData(prev => ({ ...prev, address: e.target.value }))}
|
|
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
|
placeholder="Calle Mayor, 123, Madrid"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-3">
|
|
Tipo de negocio
|
|
</label>
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setBakeryData(prev => ({ ...prev, businessType: 'individual' }))}
|
|
className={`p-4 border rounded-xl text-left transition-all ${
|
|
bakeryData.businessType === 'individual'
|
|
? 'border-primary-500 bg-primary-50 text-primary-700'
|
|
: 'border-gray-300 hover:border-gray-400'
|
|
}`}
|
|
>
|
|
<Store className="h-6 w-6 mb-2" />
|
|
<div className="font-medium">Panadería Individual</div>
|
|
<div className="text-sm text-gray-500">Una sola ubicación</div>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => setBakeryData(prev => ({ ...prev, businessType: 'central_workshop' }))}
|
|
className={`p-4 border rounded-xl text-left transition-all ${
|
|
bakeryData.businessType === 'central_workshop'
|
|
? 'border-primary-500 bg-primary-50 text-primary-700'
|
|
: 'border-gray-300 hover:border-gray-400'
|
|
}`}
|
|
>
|
|
<Factory className="h-6 w-6 mb-2" />
|
|
<div className="font-medium">Obrador Central</div>
|
|
<div className="text-sm text-gray-500">Múltiples ubicaciones</div>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
case 2:
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
|
¿Qué productos vendes?
|
|
</h3>
|
|
<p className="text-gray-600 mb-6">
|
|
Selecciona los productos más comunes en tu panadería
|
|
</p>
|
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
|
{MADRID_PRODUCTS.map((product) => (
|
|
<button
|
|
key={product}
|
|
type="button"
|
|
onClick={() => {
|
|
setBakeryData(prev => ({
|
|
...prev,
|
|
products: prev.products.includes(product)
|
|
? prev.products.filter(p => p !== product)
|
|
: [...prev.products, product]
|
|
}));
|
|
}}
|
|
className={`p-3 text-sm border rounded-lg transition-all text-left ${
|
|
bakeryData.products.includes(product)
|
|
? 'border-primary-500 bg-primary-50 text-primary-700'
|
|
: 'border-gray-300 hover:border-gray-400'
|
|
}`}
|
|
>
|
|
{product}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="mt-4">
|
|
<p className="text-sm text-gray-500">
|
|
Productos seleccionados: {bakeryData.products.length}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
case 3:
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
|
Datos Históricos de Ventas
|
|
</h3>
|
|
<p className="text-gray-600 mb-6">
|
|
Para obtener mejores predicciones, puedes subir tus datos históricos de ventas
|
|
</p>
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="flex items-center space-x-3">
|
|
<input
|
|
type="checkbox"
|
|
checked={bakeryData.hasHistoricalData}
|
|
onChange={(e) => setBakeryData(prev => ({
|
|
...prev,
|
|
hasHistoricalData: e.target.checked,
|
|
csvFile: e.target.checked ? prev.csvFile : undefined
|
|
}))}
|
|
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
|
/>
|
|
<span className="text-gray-700">
|
|
Tengo datos históricos de ventas (recomendado)
|
|
</span>
|
|
</label>
|
|
</div>
|
|
|
|
{bakeryData.hasHistoricalData && (
|
|
<div className="border-2 border-dashed border-gray-300 rounded-xl p-6">
|
|
<div className="text-center">
|
|
<Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
|
|
|
|
{bakeryData.csvFile ? (
|
|
<div>
|
|
<p className="text-sm font-medium text-gray-900 mb-2">
|
|
Archivo seleccionado:
|
|
</p>
|
|
<p className="text-sm text-gray-600 mb-4">
|
|
{bakeryData.csvFile.name}
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => setBakeryData(prev => ({ ...prev, csvFile: undefined }))}
|
|
className="text-sm text-red-600 hover:text-red-500"
|
|
>
|
|
Eliminar archivo
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<p className="text-sm text-gray-600 mb-4">
|
|
Sube tu archivo CSV con las ventas históricas
|
|
</p>
|
|
<input
|
|
type="file"
|
|
accept=".csv"
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0];
|
|
if (file) {
|
|
setBakeryData(prev => ({ ...prev, csvFile: file }));
|
|
}
|
|
}}
|
|
className="hidden"
|
|
id="csv-upload"
|
|
/>
|
|
<label
|
|
htmlFor="csv-upload"
|
|
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 cursor-pointer"
|
|
>
|
|
Seleccionar archivo CSV
|
|
</label>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="mt-4 text-xs text-gray-500">
|
|
<p className="font-medium mb-1">Formato esperado del CSV:</p>
|
|
<p>Fecha, Producto, Cantidad</p>
|
|
<p>2024-01-01, Croissants, 45</p>
|
|
<p>2024-01-01, Pan de molde, 12</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!bakeryData.hasHistoricalData && (
|
|
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
|
<p className="text-blue-800 text-sm">
|
|
No te preocupes, PanIA puede empezar a funcionar sin datos históricos.
|
|
Las predicciones mejorarán automáticamente conforme uses el sistema.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
case 4:
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="text-center">
|
|
<div className="mx-auto h-16 w-16 bg-success-100 rounded-full flex items-center justify-center mb-4">
|
|
<Check className="h-8 w-8 text-success-600" />
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
|
¡Todo listo para comenzar!
|
|
</h3>
|
|
<p className="text-gray-600 mb-6">
|
|
Revisa los datos de tu panadería antes de continuar
|
|
</p>
|
|
</div>
|
|
|
|
<div className="bg-gray-50 rounded-xl p-6 space-y-4">
|
|
<div>
|
|
<span className="text-sm font-medium text-gray-500">Panadería:</span>
|
|
<p className="text-gray-900">{bakeryData.name}</p>
|
|
</div>
|
|
|
|
<div>
|
|
<span className="text-sm font-medium text-gray-500">Dirección:</span>
|
|
<p className="text-gray-900">{bakeryData.address}</p>
|
|
</div>
|
|
|
|
<div>
|
|
<span className="text-sm font-medium text-gray-500">Tipo de negocio:</span>
|
|
<p className="text-gray-900">
|
|
{bakeryData.businessType === 'individual' ? 'Panadería Individual' : 'Obrador Central'}
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<span className="text-sm font-medium text-gray-500">Productos:</span>
|
|
<p className="text-gray-900">{bakeryData.products.join(', ')}</p>
|
|
</div>
|
|
|
|
<div>
|
|
<span className="text-sm font-medium text-gray-500">Datos históricos:</span>
|
|
<p className="text-gray-900">
|
|
{bakeryData.hasHistoricalData ? `Sí (${bakeryData.csvFile?.name})` : 'No'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-50 py-8">
|
|
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
{/* Header */}
|
|
<div className="text-center mb-8">
|
|
<div className="mx-auto h-12 w-12 bg-primary-500 rounded-xl flex items-center justify-center mb-4">
|
|
<span className="text-white text-xl font-bold">🥖</span>
|
|
</div>
|
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
|
Configuración inicial
|
|
</h1>
|
|
<p className="text-gray-600">
|
|
Vamos a configurar PanIA para tu panadería
|
|
</p>
|
|
</div>
|
|
|
|
{/* Progress Steps */}
|
|
<div className="mb-8">
|
|
<div className="flex justify-between">
|
|
{steps.map((step) => (
|
|
<div key={step.id} className="flex flex-col items-center">
|
|
<div
|
|
className={`w-10 h-10 rounded-full flex items-center justify-center border-2 transition-all ${
|
|
currentStep >= step.id
|
|
? 'bg-primary-500 border-primary-500 text-white'
|
|
: 'border-gray-300 text-gray-500'
|
|
}`}
|
|
>
|
|
<step.icon className="h-5 w-5" />
|
|
</div>
|
|
<span className="mt-2 text-xs text-gray-500 text-center max-w-20">
|
|
{step.title}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="mt-4 bg-gray-200 rounded-full h-2">
|
|
<div
|
|
className="bg-primary-500 h-2 rounded-full transition-all duration-500"
|
|
style={{ width: `${(currentStep / steps.length) * 100}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Step Content */}
|
|
<div className="bg-white rounded-2xl shadow-soft p-6 mb-8">
|
|
{renderStep()}
|
|
</div>
|
|
|
|
{/* Navigation */}
|
|
<div className="flex justify-between">
|
|
<button
|
|
onClick={handlePrevious}
|
|
disabled={currentStep === 1}
|
|
className="flex items-center px-4 py-2 text-gray-600 disabled:text-gray-400 disabled:cursor-not-allowed hover:text-gray-800 transition-colors"
|
|
>
|
|
<ChevronLeft className="h-5 w-5 mr-1" />
|
|
Anterior
|
|
</button>
|
|
|
|
{currentStep < steps.length ? (
|
|
<button
|
|
onClick={handleNext}
|
|
className="flex items-center px-6 py-2 bg-primary-500 text-white rounded-xl hover:bg-primary-600 transition-all hover:shadow-lg"
|
|
>
|
|
Siguiente
|
|
<ChevronRight className="h-5 w-5 ml-1" />
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={handleComplete}
|
|
disabled={isLoading}
|
|
className="flex items-center px-6 py-2 bg-success-500 text-white rounded-xl hover:bg-success-600 transition-all hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{isLoading ? 'Configurando...' : 'Completar configuración'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default OnboardingPage; |