Add new Frontend
This commit is contained in:
516
frontend/src/pages/onboarding/OnboardingPage.tsx
Normal file
516
frontend/src/pages/onboarding/OnboardingPage.tsx
Normal file
@@ -0,0 +1,516 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user