first commit
This commit is contained in:
12
frontend/src/pages/_app.tsx
Normal file
12
frontend/src/pages/_app.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { AuthProvider } from '../api';
|
||||
import '../styles/globals.css';
|
||||
|
||||
function App({ Component, pageProps }: any) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Component {...pageProps} />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
706
frontend/src/pages/dashboard/index.tsx
Normal file
706
frontend/src/pages/dashboard/index.tsx
Normal file
@@ -0,0 +1,706 @@
|
||||
// frontend/src/pages/dashboard/index.tsx (Fixed version)
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import ForecastChart from '../../components/charts/ForecastChart';
|
||||
import dashboardApi from '../../api/dashboardApi';
|
||||
import {
|
||||
ChartBarIcon,
|
||||
CalendarIcon,
|
||||
CloudIcon,
|
||||
TruckIcon,
|
||||
ExclamationTriangleIcon,
|
||||
CheckCircleIcon,
|
||||
ArrowTrendingUpIcon,
|
||||
ArrowTrendingDownIcon,
|
||||
CogIcon,
|
||||
BellIcon,
|
||||
UserCircleIcon,
|
||||
Bars3Icon,
|
||||
XMarkIcon,
|
||||
ArrowPathIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
BarElement,
|
||||
} from 'chart.js';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { format, subDays } from 'date-fns';
|
||||
import { es } from 'date-fns/locale';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
BarElement
|
||||
);
|
||||
|
||||
interface SalesRecord {
|
||||
id: string;
|
||||
product_name: string;
|
||||
quantity_sold: number;
|
||||
revenue: number;
|
||||
sale_date: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ForecastRecord {
|
||||
date: string;
|
||||
product_name: string;
|
||||
predicted_quantity: number;
|
||||
confidence_lower: number;
|
||||
confidence_upper: number;
|
||||
}
|
||||
|
||||
const Dashboard: React.FC = () => {
|
||||
const { user, tenant, logout } = useAuth();
|
||||
const [currentDate] = useState(new Date());
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
|
||||
const [salesHistory, setSalesHistory] = useState<SalesRecord[]>([]);
|
||||
const [panForecast, setPanForecast] = useState<ForecastRecord[]>([]);
|
||||
const [croissantForecast, setCroissantForecast] = useState<ForecastRecord[]>([]);
|
||||
const [cafeForecast, setCafeForecast] = useState<ForecastRecord[]>([]);
|
||||
const [bocadilloForecast, setBocadilloForecast] = useState<ForecastRecord[]>([]);
|
||||
|
||||
const fetchDashboardData = async (skipLoading = false) => {
|
||||
if (!skipLoading) {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
console.log('Fetching dashboard data...');
|
||||
|
||||
const endDate = format(currentDate, 'yyyy-MM-dd');
|
||||
const startDate = format(subDays(currentDate, 30), 'yyyy-MM-dd'); // Fetch 30 days instead of 7
|
||||
|
||||
console.log('Fetching sales history from', startDate, 'to', endDate);
|
||||
|
||||
// Fetch sales history
|
||||
const fetchedSales = await dashboardApi.getSalesHistory(startDate, endDate);
|
||||
console.log('Fetched sales:', fetchedSales);
|
||||
setSalesHistory(fetchedSales || []);
|
||||
|
||||
// Fetch forecasts for each product
|
||||
const products = ['Pan', 'Croissant', 'Cafe', 'Bocadillo'];
|
||||
|
||||
for (const product of products) {
|
||||
try {
|
||||
console.log(`Fetching forecast for ${product}`);
|
||||
const forecast = await dashboardApi.getProductForecast(product, 14);
|
||||
console.log(`Forecast for ${product}:`, forecast);
|
||||
|
||||
switch (product) {
|
||||
case 'Pan':
|
||||
setPanForecast(forecast || []);
|
||||
break;
|
||||
case 'Croissant':
|
||||
setCroissantForecast(forecast || []);
|
||||
break;
|
||||
case 'Cafe':
|
||||
setCafeForecast(forecast || []);
|
||||
break;
|
||||
case 'Bocadillo':
|
||||
setBocadilloForecast(forecast || []);
|
||||
break;
|
||||
}
|
||||
} catch (forecastError) {
|
||||
console.error(`Error fetching forecast for ${product}:`, forecastError);
|
||||
// Continue with other products if one fails
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Dashboard data loaded successfully');
|
||||
setRetryCount(0);
|
||||
|
||||
} catch (err: any) {
|
||||
console.error("Error fetching dashboard data:", err);
|
||||
|
||||
let errorMessage = "No se pudieron cargar los datos del dashboard.";
|
||||
|
||||
if (err.response?.status === 401) {
|
||||
errorMessage = "Sesión expirada. Por favor, inicia sesión nuevamente.";
|
||||
// Optionally logout the user
|
||||
// logout();
|
||||
} else if (err.response?.status === 403) {
|
||||
errorMessage = "No tienes permisos para acceder a estos datos.";
|
||||
} else if (err.response?.status === 404) {
|
||||
errorMessage = "No se encontraron datos. Esto puede ser normal para nuevos usuarios.";
|
||||
} else if (err.response?.status >= 500) {
|
||||
errorMessage = "Error del servidor. Por favor, inténtalo más tarde.";
|
||||
} else if (err.code === 'NETWORK_ERROR' || !err.response) {
|
||||
errorMessage = "Error de conexión. Verifica tu conexión a internet.";
|
||||
}
|
||||
|
||||
setError(errorMessage);
|
||||
|
||||
// Auto-retry logic for temporary network errors
|
||||
if (retryCount < 3 && (!err.response || err.response.status >= 500)) {
|
||||
console.log(`Retrying in 2 seconds... (attempt ${retryCount + 1}/3)`);
|
||||
setTimeout(() => {
|
||||
setRetryCount(prev => prev + 1);
|
||||
fetchDashboardData(true);
|
||||
}, 2000);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRetry = () => {
|
||||
setRetryCount(0);
|
||||
fetchDashboardData();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (user && tenant) {
|
||||
console.log('User and tenant loaded, fetching dashboard data');
|
||||
fetchDashboardData();
|
||||
} else {
|
||||
console.log('User or tenant not available yet');
|
||||
}
|
||||
}, [user, tenant]);
|
||||
|
||||
const salesChartData = useMemo(() => {
|
||||
if (!salesHistory.length) {
|
||||
return {
|
||||
labels: [],
|
||||
datasets: []
|
||||
};
|
||||
}
|
||||
|
||||
const salesByDateAndProduct: { [date: string]: { [product: string]: number } } = {};
|
||||
salesHistory.forEach(sale => {
|
||||
const date = format(new Date(sale.sale_date), 'yyyy-MM-dd');
|
||||
if (!salesByDateAndProduct[date]) {
|
||||
salesByDateAndProduct[date] = {};
|
||||
}
|
||||
salesByDateAndProduct[date][sale.product_name] = (salesByDateAndProduct[date][sale.product_name] || 0) + sale.quantity_sold;
|
||||
});
|
||||
|
||||
const uniqueDates = Object.keys(salesByDateAndProduct).sort();
|
||||
const allProductNames = Array.from(new Set(salesHistory.map(s => s.product_name)));
|
||||
|
||||
const datasets = allProductNames.map(productName => {
|
||||
const productData = uniqueDates.map(date => salesByDateAndProduct[date][productName] || 0);
|
||||
|
||||
let borderColor = '';
|
||||
let backgroundColor = '';
|
||||
switch(productName.toLowerCase()) {
|
||||
case 'pan':
|
||||
borderColor = 'rgb(255, 99, 132)';
|
||||
backgroundColor = 'rgba(255, 99, 132, 0.5)';
|
||||
break;
|
||||
case 'croissant':
|
||||
borderColor = 'rgb(53, 162, 235)';
|
||||
backgroundColor = 'rgba(53, 162, 235, 0.5)';
|
||||
break;
|
||||
case 'cafe':
|
||||
borderColor = 'rgb(75, 192, 192)';
|
||||
backgroundColor = 'rgba(75, 192, 192, 0.5)';
|
||||
break;
|
||||
case 'bocadillo':
|
||||
borderColor = 'rgb(153, 102, 255)';
|
||||
backgroundColor = 'rgba(153, 102, 255, 0.5)';
|
||||
break;
|
||||
default:
|
||||
borderColor = `hsl(${Math.random() * 360}, 70%, 50%)`;
|
||||
backgroundColor = `hsla(${Math.random() * 360}, 70%, 50%, 0.5)`;
|
||||
}
|
||||
|
||||
return {
|
||||
label: `Ventas de ${productName}`,
|
||||
data: productData,
|
||||
borderColor,
|
||||
backgroundColor,
|
||||
tension: 0.1,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
labels: uniqueDates.map(d => format(new Date(d), 'dd MMM', { locale: es })),
|
||||
datasets: datasets,
|
||||
};
|
||||
}, [salesHistory]);
|
||||
|
||||
const demoForecastData = useMemo(() => {
|
||||
if (!panForecast.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return panForecast.map(f => ({
|
||||
date: format(new Date(f.date), 'yyyy-MM-dd'),
|
||||
predicted_quantity: f.predicted_quantity,
|
||||
confidence_lower: f.confidence_lower,
|
||||
confidence_upper: f.confidence_upper,
|
||||
}));
|
||||
}, [panForecast]);
|
||||
|
||||
const productPredictions = useMemo(() => {
|
||||
const today = format(currentDate, 'yyyy-MM-dd');
|
||||
const todaySales = salesHistory.filter(s => format(new Date(s.sale_date), 'yyyy-MM-dd') === today);
|
||||
|
||||
const currentSalesByProduct: { [product: string]: number } = {};
|
||||
todaySales.forEach(sale => {
|
||||
currentSalesByProduct[sale.product_name] = (currentSalesByProduct[sale.product_name] || 0) + sale.quantity_sold;
|
||||
});
|
||||
|
||||
const allForecasts = [
|
||||
...panForecast,
|
||||
...croissantForecast,
|
||||
...cafeForecast,
|
||||
...bocadilloForecast
|
||||
];
|
||||
|
||||
if (!allForecasts.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const uniqueProductsInForecasts = Array.from(new Set(allForecasts.map(f => f.product_name)));
|
||||
|
||||
const predictions = uniqueProductsInForecasts.map((productName, index) => {
|
||||
const productTodayForecast = allForecasts.find(f =>
|
||||
f.product_name === productName && format(new Date(f.date), 'yyyy-MM-dd') === today
|
||||
);
|
||||
|
||||
const predicted = productTodayForecast?.predicted_quantity || 0;
|
||||
const current = currentSalesByProduct[productName] || 0;
|
||||
|
||||
let status: 'good' | 'warning' | 'bad' = 'good';
|
||||
if (predicted > 0) {
|
||||
const percentageAchieved = (current / predicted) * 100;
|
||||
if (percentageAchieved < 50) {
|
||||
status = 'bad';
|
||||
} else if (percentageAchieved < 90) {
|
||||
status = 'warning';
|
||||
}
|
||||
} else if (current > 0) {
|
||||
status = 'good';
|
||||
}
|
||||
|
||||
return {
|
||||
id: index + 1,
|
||||
product: productName,
|
||||
predicted: Math.round(predicted),
|
||||
current: current,
|
||||
status: status,
|
||||
};
|
||||
}).filter(p => p.predicted > 0 || p.current > 0);
|
||||
|
||||
return predictions;
|
||||
}, [salesHistory, panForecast, croissantForecast, cafeForecast, bocadilloForecast, currentDate]);
|
||||
|
||||
const kpiData = useMemo(() => {
|
||||
if (!salesHistory.length) {
|
||||
return {
|
||||
totalSalesToday: 0,
|
||||
salesChange: 0,
|
||||
totalProductsSoldToday: 0,
|
||||
productsSoldChange: 0,
|
||||
wasteToday: 0,
|
||||
wasteChange: 0,
|
||||
totalPredictedValueToday: 0,
|
||||
predictedValueChange: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const today = format(currentDate, 'yyyy-MM-dd');
|
||||
const yesterday = format(subDays(currentDate, 1), 'yyyy-MM-dd');
|
||||
|
||||
const salesToday = salesHistory.filter(s => format(new Date(s.sale_date), 'yyyy-MM-dd') === today);
|
||||
const salesYesterday = salesHistory.filter(s => format(new Date(s.sale_date), 'yyyy-MM-dd') === yesterday);
|
||||
|
||||
const totalSalesToday = salesToday.reduce((sum, s) => sum + s.revenue, 0);
|
||||
const totalProductsSoldToday = salesToday.reduce((sum, s) => sum + s.quantity_sold, 0);
|
||||
const totalSalesYesterday = salesYesterday.reduce((sum, s) => sum + s.revenue, 0);
|
||||
const totalProductsSoldYesterday = salesYesterday.reduce((sum, s) => sum + s.quantity_sold, 0);
|
||||
|
||||
const wasteToday = 15; // Mock data
|
||||
const wasteLastWeek = 15.3; // Mock data
|
||||
|
||||
const salesChange = totalSalesYesterday > 0 ? ((totalSalesToday - totalSalesYesterday) / totalSalesYesterday) * 100 : (totalSalesToday > 0 ? 100 : 0);
|
||||
const productsSoldChange = totalProductsSoldYesterday > 0 ? ((totalProductsSoldToday - totalProductsSoldYesterday) / totalProductsSoldYesterday) * 100 : (totalProductsSoldToday > 0 ? 100 : 0);
|
||||
const wasteChange = wasteLastWeek > 0 ? ((wasteToday - wasteLastWeek) / wasteLastWeek) * 100 : (wasteToday > 0 ? 100 : 0);
|
||||
|
||||
const totalPredictedValueToday = productPredictions.reduce((sum, p) => sum + p.predicted, 0) * 1.5;
|
||||
const predictedValueChange = totalPredictedValueToday > 0 ? ((totalSalesToday - totalPredictedValueToday) / totalPredictedValueToday) * 100 : (totalSalesToday > 0 ? 100 : 0);
|
||||
|
||||
return {
|
||||
totalSalesToday,
|
||||
salesChange,
|
||||
totalProductsSoldToday,
|
||||
productsSoldChange,
|
||||
wasteToday,
|
||||
wasteChange,
|
||||
totalPredictedValueToday,
|
||||
predictedValueChange,
|
||||
};
|
||||
}, [salesHistory, productPredictions, currentDate]);
|
||||
|
||||
const salesChartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top' as const,
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Histórico de Ventas Recientes',
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Cantidad Vendida (uds)',
|
||||
},
|
||||
},
|
||||
x: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Fecha',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-16 w-16 border-t-2 border-b-2 border-orange-600 mx-auto"></div>
|
||||
<p className="mt-4 text-lg text-gray-700">Cargando datos del dashboard...</p>
|
||||
{retryCount > 0 && (
|
||||
<p className="mt-2 text-sm text-gray-500">Reintentando... ({retryCount}/3)</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<div className="text-center bg-white p-8 rounded-lg shadow-md max-w-md mx-4">
|
||||
<ExclamationTriangleIcon className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-2">Error al cargar datos</h2>
|
||||
<p className="text-gray-600 mb-6">{error}</p>
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={handleRetry}
|
||||
className="w-full bg-orange-600 text-white px-4 py-2 rounded-lg hover:bg-orange-700 transition-colors flex items-center justify-center"
|
||||
>
|
||||
<ArrowPathIcon className="h-5 w-5 mr-2" />
|
||||
Reintentar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="w-full bg-gray-200 text-gray-800 px-4 py-2 rounded-lg hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
Recargar página
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex">
|
||||
{/* Overlay for mobile sidebar */}
|
||||
{isSidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-30 lg:hidden"
|
||||
onClick={() => setIsSidebarOpen(false)}
|
||||
></div>
|
||||
)}
|
||||
|
||||
{/* Sidebar - Made responsive */}
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-40 bg-white shadow-md overflow-y-auto transform transition-transform duration-300 ease-in-out
|
||||
${isSidebarOpen ? 'translate-x-0' : '-translate-x-full'} lg:translate-x-0 lg:static lg:inset-0 w-64`}
|
||||
>
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-gray-900">PanIA</h2>
|
||||
<button
|
||||
className="lg:hidden p-2"
|
||||
onClick={() => setIsSidebarOpen(false)}
|
||||
>
|
||||
<XMarkIcon className="h-6 w-6 text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1">{tenant?.name}</p>
|
||||
</div>
|
||||
|
||||
<nav className="mt-6">
|
||||
<div className="px-6 py-3">
|
||||
<div className="flex items-center text-orange-600">
|
||||
<ChartBarIcon className="h-5 w-5 mr-3" />
|
||||
<span className="font-medium">Dashboard</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 px-6">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Herramientas
|
||||
</h3>
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex items-center text-gray-700 hover:bg-gray-100 rounded-lg p-2 cursor-pointer">
|
||||
<CalendarIcon className="h-5 w-5 mr-3" />
|
||||
<span className="text-sm">Pronósticos</span>
|
||||
</div>
|
||||
<div className="flex items-center text-gray-700 hover:bg-gray-100 rounded-lg p-2 cursor-pointer">
|
||||
<CloudIcon className="h-5 w-5 mr-3" />
|
||||
<span className="text-sm">Datos Climáticos</span>
|
||||
</div>
|
||||
<div className="flex items-center text-gray-700 hover:bg-gray-100 rounded-lg p-2 cursor-pointer">
|
||||
<TruckIcon className="h-5 w-5 mr-3" />
|
||||
<span className="text-sm">Pedidos</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 px-6">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||
Configuración
|
||||
</h3>
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex items-center text-gray-700 hover:bg-gray-100 rounded-lg p-2 cursor-pointer">
|
||||
<CogIcon className="h-5 w-5 mr-3" />
|
||||
<span className="text-sm">Ajustes</span>
|
||||
</div>
|
||||
<div className="flex items-center text-gray-700 hover:bg-gray-100 rounded-lg p-2 cursor-pointer">
|
||||
<UserCircleIcon className="h-5 w-5 mr-3" />
|
||||
<span className="text-sm">Perfil</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="absolute bottom-0 w-full p-6 border-t border-gray-200">
|
||||
<div className="flex items-center">
|
||||
<div className="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center">
|
||||
<span className="text-white text-sm font-medium">
|
||||
{user?.full_name?.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm font-medium text-gray-900">{user?.full_name}</p>
|
||||
<p className="text-xs text-gray-500">{user?.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="mt-3 w-full text-left text-sm text-red-600 hover:text-red-700"
|
||||
>
|
||||
Cerrar sesión
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<header className="bg-white shadow-sm border-b border-gray-200 flex items-center justify-between px-4 py-3">
|
||||
<button
|
||||
className="lg:hidden p-2"
|
||||
onClick={() => setIsSidebarOpen(true)}
|
||||
>
|
||||
<Bars3Icon className="h-6 w-6 text-gray-500" />
|
||||
</button>
|
||||
<h1 className="text-xl font-semibold text-gray-900">Dashboard</h1>
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-gray-600 text-sm">
|
||||
{format(currentDate, 'dd MMMM yyyy', { locale: es })}
|
||||
</span>
|
||||
<button onClick={handleRetry} className="p-2 text-gray-500 hover:text-gray-700">
|
||||
<ArrowPathIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<BellIcon className="h-6 w-6 text-gray-500 cursor-pointer" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
|
||||
{/* Show a notice if no data is available */}
|
||||
{!salesHistory.length && !loading && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-center">
|
||||
<ExclamationTriangleIcon className="h-6 w-6 text-blue-600 mr-3" />
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-blue-900">
|
||||
No hay datos disponibles
|
||||
</h3>
|
||||
<p className="text-blue-700 mt-1">
|
||||
Parece que aún no tienes datos de ventas. Los datos se generarán automáticamente
|
||||
después de completar el proceso de configuración.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-gray-500">Ventas Hoy</h3>
|
||||
<ChartBarIcon className="h-6 w-6 text-orange-500" />
|
||||
</div>
|
||||
<p className="mt-1 text-3xl font-semibold text-gray-900">
|
||||
€ {kpiData.totalSalesToday.toFixed(2)}
|
||||
</p>
|
||||
<p className={`mt-2 text-sm flex items-center ${kpiData.salesChange >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{kpiData.salesChange >= 0 ? <ArrowTrendingUpIcon className="h-4 w-4 mr-1" /> : <ArrowTrendingDownIcon className="h-4 w-4 mr-1" />}
|
||||
{kpiData.salesChange.toFixed(1)}% desde ayer
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-gray-500">Productos Vendidos</h3>
|
||||
<CheckCircleIcon className="h-6 w-6 text-green-500" />
|
||||
</div>
|
||||
<p className="mt-1 text-3xl font-semibold text-gray-900">
|
||||
{kpiData.totalProductsSoldToday} uds.
|
||||
</p>
|
||||
<p className={`mt-2 text-sm flex items-center ${kpiData.productsSoldChange >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{kpiData.productsSoldChange >= 0 ? <ArrowTrendingUpIcon className="h-4 w-4 mr-1" /> : <ArrowTrendingDownIcon className="h-4 w-4 mr-1" />}
|
||||
{kpiData.productsSoldChange.toFixed(1)}% desde ayer
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-gray-500">Desperdicio</h3>
|
||||
<ExclamationTriangleIcon className="h-6 w-6 text-red-500" />
|
||||
</div>
|
||||
<p className="mt-1 text-3xl font-semibold text-gray-900">
|
||||
{kpiData.wasteToday} kg
|
||||
</p>
|
||||
<p className={`mt-2 text-sm flex items-center ${kpiData.wasteChange <= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{kpiData.wasteChange <= 0 ? <ArrowTrendingDownIcon className="h-4 w-4 mr-1" /> : <ArrowTrendingUpIcon className="h-4 w-4 mr-1" />}
|
||||
{kpiData.wasteChange.toFixed(1)}% desde la semana pasada
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-gray-500">Valor Pronosticado</h3>
|
||||
<ChartBarIcon className="h-6 w-6 text-blue-500" />
|
||||
</div>
|
||||
<p className="mt-1 text-3xl font-semibold text-gray-900">
|
||||
€ {kpiData.totalPredictedValueToday.toFixed(2)}
|
||||
</p>
|
||||
<p className={`mt-2 text-sm flex items-center ${kpiData.predictedValueChange >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{kpiData.predictedValueChange >= 0 ? <ArrowTrendingUpIcon className="h-4 w-4 mr-1" /> : <ArrowTrendingDownIcon className="h-4 w-4 mr-1" />}
|
||||
{kpiData.predictedValueChange.toFixed(1)}% sobre predicción
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts Section */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Ventas por Día</h2>
|
||||
{salesHistory.length > 0 ? (
|
||||
<div style={{ height: '350px' }}>
|
||||
<Line data={salesChartData} options={salesChartOptions} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-80 flex items-center justify-center text-gray-500">
|
||||
<div className="text-center">
|
||||
<ChartBarIcon className="h-12 w-12 mx-auto mb-4 text-gray-400" />
|
||||
<p>No hay datos de ventas disponibles</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Pronóstico de Demanda (Pan)</h2>
|
||||
{demoForecastData.length > 0 ? (
|
||||
<div style={{ height: '350px' }}>
|
||||
<ForecastChart data={demoForecastData} productName="Pan" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-80 flex items-center justify-center text-gray-500">
|
||||
<div className="text-center">
|
||||
<CalendarIcon className="h-12 w-12 mx-auto mb-4 text-gray-400" />
|
||||
<p>No hay pronósticos disponibles</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Predictions */}
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Predicciones de Productos Clave</h2>
|
||||
{productPredictions.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{productPredictions.map((prediction) => (
|
||||
<div key={prediction.id} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="text-sm font-medium text-gray-900">{prediction.product}</h4>
|
||||
<div className={`w-3 h-3 rounded-full ${
|
||||
prediction.status === 'good' ? 'bg-green-400' :
|
||||
prediction.status === 'warning' ? 'bg-yellow-400' : 'bg-red-400'
|
||||
}`}></div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Predicción:</span>
|
||||
<span className="font-medium">{prediction.predicted} uds</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Actual:</span>
|
||||
<span className="font-medium">{prediction.current} uds</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full ${
|
||||
prediction.status === 'good' ? 'bg-green-400' :
|
||||
prediction.status === 'warning' ? 'bg-yellow-400' : 'bg-red-400'
|
||||
}`}
|
||||
style={{
|
||||
width: `${Math.min((prediction.current / prediction.predicted) * 100, 100)}%`
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<div className="text-center">
|
||||
<TruckIcon className="h-12 w-12 mx-auto mb-4 text-gray-400" />
|
||||
<p>No hay predicciones disponibles</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
143
frontend/src/pages/index.tsx
Normal file
143
frontend/src/pages/index.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
|
||||
const HomePage = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-pania-white"> {/* Set overall background to PanIA white */}
|
||||
<Head>
|
||||
<title>PanIA - Inteligencia Artificial para tu Panadería</title> {/* Updated title and tagline */}
|
||||
<meta name="description" content="La primera IA diseñada para panaderías españolas que transforma tus datos en predicciones precisas." /> {/* Updated meta description */}
|
||||
</Head>
|
||||
|
||||
{/* Navigation Bar */}
|
||||
<header className="bg-pania-white shadow-sm py-4">
|
||||
<nav className="container mx-auto flex justify-between items-center px-4">
|
||||
<div className="text-3xl font-extrabold text-pania-charcoal">PanIA</div> {/* PanIA brand name */}
|
||||
<div>
|
||||
<Link href="/login" className="text-pania-blue hover:text-pania-blue-dark font-medium px-4 py-2 rounded-md">
|
||||
Iniciar Sesión
|
||||
</Link>
|
||||
<Link href="/onboarding" className="ml-4 bg-pania-blue text-pania-white px-4 py-2 rounded-md hover:bg-pania-blue-dark transition-colors duration-200"> {/* CTA to onboarding */}
|
||||
Prueba Gratis
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
{/* Hero Section */}
|
||||
<section className="bg-pania-golden text-pania-white py-20 text-center"> {/* Warm Golden background */}
|
||||
<div className="container mx-auto px-4">
|
||||
<h1 className="text-5xl md:text-6xl font-bold leading-tight mb-4">
|
||||
Inteligencia Artificial que Revoluciona tu Panadería
|
||||
</h1>
|
||||
<p className="text-xl md:text-2xl mb-8 max-w-3xl mx-auto">
|
||||
Reduce desperdicios hasta <span className="font-bold">25%</span> y aumenta ganancias con predicciones precisas diseñadas para panaderías españolas.
|
||||
</p>
|
||||
<Link href="/onboarding" className="bg-pania-blue text-pania-white text-lg font-semibold px-8 py-4 rounded-lg shadow-lg hover:bg-pania-blue-dark transition-transform transform hover:scale-105">
|
||||
Prueba Gratis 30 Días
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Social Proof Section */}
|
||||
<section className="py-16 bg-pania-white">
|
||||
<div className="container mx-auto px-4 text-center">
|
||||
<h2 className="text-3xl font-bold text-pania-charcoal mb-8">
|
||||
Más de 150 panaderías confían en PanIA
|
||||
</h2>
|
||||
<div className="flex justify-center items-center space-x-8 mb-8">
|
||||
{/* Placeholder for customer logos */}
|
||||
<div className="h-16 w-32 bg-gray-200 rounded-lg flex items-center justify-center text-gray-500">Logo 1</div>
|
||||
<div className="h-16 w-32 bg-gray-200 rounded-lg flex items-center justify-center text-gray-500">Logo 2</div>
|
||||
<div className="h-16 w-32 bg-gray-200 rounded-lg flex items-center justify-center text-gray-500">Logo 3</div>
|
||||
</div>
|
||||
<p className="text-lg text-gray-600 italic">
|
||||
"PanIA ha transformado completamente nuestra gestión de inventario. ¡Menos desperdicio y más beneficios!" - Panadería San Miguel, Madrid
|
||||
</p>
|
||||
{/* Placeholder for star ratings */}
|
||||
<div className="text-2xl text-yellow-500 mt-4">★★★★★</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section - Cómo Funciona PanIA */}
|
||||
<section className="bg-gray-50 py-20">
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-4xl font-bold text-pania-charcoal text-center mb-12">
|
||||
Cómo Funciona PanIA
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
<div className="bg-pania-white p-8 rounded-lg shadow-md text-center">
|
||||
<div className="text-pania-blue text-5xl mb-4">📊</div> {/* Icon placeholder */}
|
||||
<h3 className="text-xl font-semibold text-pania-charcoal mb-2">Conecta tus Datos</h3>
|
||||
<p className="text-gray-600">Sube tus ventas históricas en 5 minutos de forma segura y sencilla.</p>
|
||||
</div>
|
||||
<div className="bg-pania-white p-8 rounded-lg shadow-md text-center">
|
||||
<div className="text-pania-blue text-5xl mb-4">🧠</div> {/* Icon placeholder */}
|
||||
<h3 className="text-xl font-semibold text-pania-charcoal mb-2">IA Entrena tu Modelo</h3>
|
||||
<p className="text-gray-600">Nuestra Inteligencia Artificial aprende los patrones únicos de tu negocio y mercado local.</p>
|
||||
</div>
|
||||
<div className="bg-pania-white p-8 rounded-lg shadow-md text-center">
|
||||
<div className="text-pania-blue text-5xl mb-4">📈</div> {/* Icon placeholder */}
|
||||
<h3 className="text-xl font-semibold text-pania-charcoal mb-2">Recibe Predicciones</h3>
|
||||
<p className="text-gray-600">Obtén predicciones diarias precisas automáticamente para optimizar tu producción.</p>
|
||||
</div>
|
||||
<div className="bg-pania-white p-8 rounded-lg shadow-md text-center">
|
||||
<div className="text-pania-blue text-5xl mb-4">💰</div> {/* Icon placeholder */}
|
||||
<h3 className="text-xl font-semibold text-pania-charcoal mb-2">Reduce Desperdicios</h3>
|
||||
<p className="text-gray-600">Ve resultados inmediatos en tu desperdicio y un aumento significativo en tus ganancias.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Call to Action Section */}
|
||||
<section className="bg-pania-blue text-pania-white py-16 text-center"> {/* Tech Blue background */}
|
||||
<div className="container mx-auto px-4">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4">
|
||||
¿Listo para transformar tu panadería?
|
||||
</h2>
|
||||
<p className="text-xl mb-8">
|
||||
Únete a las panaderías que ya están viendo el futuro con PanIA.
|
||||
</p>
|
||||
<Link href="/onboarding" className="bg-pania-golden text-pania-white text-lg font-semibold px-8 py-4 rounded-lg shadow-lg hover:bg-pania-golden-dark transition-transform transform hover:scale-105"> {/* Golden CTA button */}
|
||||
Comienza tu Prueba Gratis
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Trust Signals Section */}
|
||||
<section className="bg-pania-charcoal text-pania-white py-12"> {/* Charcoal background */}
|
||||
<div className="container mx-auto px-4 grid grid-cols-1 md:grid-cols-3 gap-8 text-center">
|
||||
<div>
|
||||
<p className="font-bold text-lg mb-2">Datos seguros y protegidos</p>
|
||||
<p className="text-sm">(GDPR compliant)</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-lg mb-2">Soporte en español</p>
|
||||
<p className="text-sm">7 días a la semana</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-bold text-lg mb-2">Garantía de satisfacción</p>
|
||||
<p className="text-sm">100%</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-gray-800 text-gray-300 py-8 text-center">
|
||||
<div className="container mx-auto px-4">
|
||||
<p>© {new Date().getFullYear()} PanIA. Todos los derechos reservados.</p>
|
||||
<div className="mt-4 flex justify-center space-x-6">
|
||||
<Link href="#" className="hover:text-white">Política de Privacidad</Link>
|
||||
<Link href="#" className="hover:text-white">Términos de Servicio</Link>
|
||||
<Link href="#" className="hover:text-white">Contacto</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
76
frontend/src/pages/login.tsx
Normal file
76
frontend/src/pages/login.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Head from 'next/head';
|
||||
import { useAuth } from '../api';
|
||||
|
||||
const Login = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const { login } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
try {
|
||||
await login(username, password);
|
||||
router.push('/dashboard'); // Assuming a dashboard route after login
|
||||
} catch (err) {
|
||||
setError('Credenciales inválidas. Inténtalo de nuevo.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-pania-golden"> {/* Updated background to PanIA golden */}
|
||||
<Head>
|
||||
<title>Login - PanIA</title> {/* Updated title with PanIA */}
|
||||
</Head>
|
||||
<div className="bg-pania-white p-8 rounded-lg shadow-lg max-w-md w-full"> {/* Updated background to PanIA white */}
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-4xl font-extrabold text-pania-charcoal mb-2">PanIA</h1> {/* Updated to PanIA brand name and charcoal color */}
|
||||
<p className="text-pania-blue text-lg">Inteligencia Artificial para tu Panadería</p> {/* Added tagline and tech blue color */}
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-pania-charcoal">
|
||||
Usuario
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:ring-pania-blue focus:border-pania-blue sm:text-sm"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-pania-charcoal">
|
||||
Contraseña
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:ring-pania-blue focus:border-pania-blue sm:text-sm"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-red-500 text-sm text-center">{error}</p>}
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-pania-white bg-pania-blue hover:bg-pania-blue-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pania-blue" // Updated button styles
|
||||
>
|
||||
Iniciar Sesión
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
664
frontend/src/pages/onboarding.tsx
Normal file
664
frontend/src/pages/onboarding.tsx
Normal file
@@ -0,0 +1,664 @@
|
||||
// Fixed Frontend Onboarding with Auto-Training
|
||||
// frontend/src/pages/onboarding.tsx
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { CheckIcon, ArrowRightIcon, ArrowLeftIcon, CloudArrowUpIcon } from '@heroicons/react/24/outline';
|
||||
import onboardingApi from '../api/onboardingApi';
|
||||
|
||||
const OnboardingPage = () => {
|
||||
const router = useRouter();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [completedSteps, setCompletedSteps] = useState<number[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [authToken, setAuthToken] = useState<string | null>(null);
|
||||
const [tenantId, setTenantId] = useState<string | null>(null);
|
||||
const [trainingTaskId, setTrainingTaskId] = useState<string | null>(null);
|
||||
const [trainingStarted, setTrainingStarted] = useState(false); // New state to track training start
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
// Step 1: User Registration
|
||||
full_name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirm_password: '',
|
||||
|
||||
// Step 2: Bakery Information
|
||||
bakery_name: '',
|
||||
address: '',
|
||||
city: 'Madrid',
|
||||
postal_code: '',
|
||||
has_nearby_schools: false,
|
||||
has_nearby_offices: false,
|
||||
|
||||
// Step 3: Sales History File
|
||||
salesFile: null as File | null,
|
||||
|
||||
// Step 4: Model Training
|
||||
trainingStatus: 'pending'
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<{ [key: string]: string }>({});
|
||||
const [trainingProgress, setTrainingProgress] = useState({
|
||||
currentTask: '',
|
||||
progress: 0,
|
||||
tasks: [
|
||||
{ id: 1, name: 'Procesando archivo de ventas históricas...', completed: false },
|
||||
{ id: 2, name: 'Preparando datos para el entrenamiento...', completed: false },
|
||||
{ id: 3, name: 'Entrenando modelos de pronóstico (esto puede tardar unos minutos)...', completed: false },
|
||||
{ id: 4, name: 'Evaluando y optimizando modelos...', completed: false },
|
||||
{ id: 5, name: 'Desplegando modelos en producción...', completed: false },
|
||||
{ id: 6, name: 'Entrenamiento completado.', completed: false },
|
||||
]
|
||||
});
|
||||
|
||||
// Load auth token and tenantId on component mount
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('access_token');
|
||||
const storedTenantId = localStorage.getItem('tenant_id');
|
||||
if (token) {
|
||||
setAuthToken(token);
|
||||
onboardingApi.setAuthToken(token);
|
||||
}
|
||||
if (storedTenantId) {
|
||||
setTenantId(storedTenantId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Utility function to extract error message from FastAPI response
|
||||
const getErrorMessage = (error: any): string => {
|
||||
if (error.response && error.response.data && error.response.data.detail) {
|
||||
const detail = error.response.data.detail;
|
||||
if (typeof detail === 'string') {
|
||||
return detail;
|
||||
}
|
||||
if (Array.isArray(detail)) {
|
||||
return detail.map((err: any) => err.msg || JSON.stringify(err)).join(', ');
|
||||
}
|
||||
if (typeof detail === 'object') {
|
||||
return detail.msg || JSON.stringify(detail);
|
||||
}
|
||||
}
|
||||
return error.message || 'Ocurrió un error inesperado.';
|
||||
};
|
||||
|
||||
const startModelTraining = useCallback(async () => {
|
||||
console.log('Starting model training...');
|
||||
setLoading(true);
|
||||
setErrors({});
|
||||
setTrainingStarted(true); // Mark training as started
|
||||
|
||||
try {
|
||||
const response = await onboardingApi.startTraining();
|
||||
console.log('Training API response:', response);
|
||||
|
||||
setFormData(prev => ({ ...prev, trainingStatus: 'in_progress' }));
|
||||
setTrainingTaskId(response.data.task_id);
|
||||
setCompletedSteps(prev => [...prev, 4]);
|
||||
|
||||
console.log('Training started successfully with task ID:', response.data.task_id);
|
||||
} catch (err: any) {
|
||||
console.error('Error starting training:', err);
|
||||
setErrors({ general: getErrorMessage(err) });
|
||||
setTrainingStarted(false); // Reset if failed
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-start training when entering step 4
|
||||
useEffect(() => {
|
||||
if (currentStep === 4 && !trainingStarted && !trainingTaskId && !loading) {
|
||||
console.log('Auto-starting training on step 4...');
|
||||
startModelTraining();
|
||||
}
|
||||
}, [currentStep, trainingStarted, trainingTaskId, loading, startModelTraining]);
|
||||
|
||||
// Polling for training status
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
|
||||
if (currentStep === 4 && trainingTaskId) {
|
||||
console.log(`Starting to poll for training status with task ID: ${trainingTaskId}`);
|
||||
interval = setInterval(async () => {
|
||||
try {
|
||||
const statusResponse = await onboardingApi.getTrainingStatus(trainingTaskId);
|
||||
console.log("Polling status:", statusResponse);
|
||||
|
||||
const { status, progress, current_step, error: trainingError } = statusResponse.data;
|
||||
|
||||
setTrainingProgress(prev => ({
|
||||
...prev,
|
||||
currentTask: current_step || 'Procesando...',
|
||||
progress: progress || 0,
|
||||
tasks: prev.tasks.map(task =>
|
||||
task.name === current_step ? { ...task, completed: true } : task
|
||||
),
|
||||
}));
|
||||
|
||||
setFormData(prev => ({ ...prev, trainingStatus: status }));
|
||||
|
||||
if (status === 'completed') {
|
||||
clearInterval(interval);
|
||||
setLoading(false);
|
||||
setCompletedSteps(prev => [...prev.filter(s => s !== 4), 4]);
|
||||
console.log('Training completed successfully!');
|
||||
} else if (status === 'failed') {
|
||||
clearInterval(interval);
|
||||
setLoading(false);
|
||||
setTrainingStarted(false); // Allow retry
|
||||
setErrors({ general: trainingError || 'El entrenamiento falló.' });
|
||||
console.error('Training failed:', trainingError);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching training status:', error);
|
||||
clearInterval(interval);
|
||||
setLoading(false);
|
||||
setTrainingStarted(false); // Allow retry
|
||||
setErrors({ general: getErrorMessage(error) });
|
||||
}
|
||||
}, 3000); // Poll every 3 seconds
|
||||
}
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [currentStep, trainingTaskId]);
|
||||
|
||||
// Use useCallback for memoized functions
|
||||
const handleNext = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setErrors({});
|
||||
|
||||
try {
|
||||
if (currentStep === 1) {
|
||||
if (formData.password !== formData.confirm_password) {
|
||||
setErrors({ confirmPassword: 'Las contraseñas no coinciden' });
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const response = await onboardingApi.registerUser(formData);
|
||||
console.log('User registered:', response);
|
||||
|
||||
if (response.data?.access_token) {
|
||||
localStorage.setItem('access_token', response.data.access_token);
|
||||
onboardingApi.setAuthToken(response.data.access_token);
|
||||
setAuthToken(response.data.access_token);
|
||||
}
|
||||
if (response.data?.tenant_id) {
|
||||
localStorage.setItem('tenant_id', response.data.tenant_id);
|
||||
setTenantId(response.data.tenant_id);
|
||||
}
|
||||
setCompletedSteps(prev => [...prev, 1]);
|
||||
setCurrentStep(2);
|
||||
|
||||
} else if (currentStep === 2) {
|
||||
const response = await onboardingApi.registerBakery(formData);
|
||||
console.log('Bakery registered:', response);
|
||||
setCompletedSteps(prev => [...prev, 2]);
|
||||
setCurrentStep(3);
|
||||
|
||||
} else if (currentStep === 3) {
|
||||
if (!formData.salesFile) {
|
||||
setErrors({ salesFile: 'Por favor, suba el archivo de historial de ventas.' });
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const response = await onboardingApi.uploadSalesHistory(formData.salesFile);
|
||||
console.log('Sales history uploaded:', response);
|
||||
setCompletedSteps(prev => [...prev, 3]);
|
||||
setCurrentStep(4); // This will trigger auto-training via useEffect
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error in step', currentStep, err);
|
||||
setErrors({ general: getErrorMessage(err) });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentStep, formData]);
|
||||
|
||||
const handlePrevious = useCallback(() => {
|
||||
setCurrentStep(prev => Math.max(1, prev - 1));
|
||||
}, []);
|
||||
|
||||
const handleFileChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (event.target.files && event.target.files[0]) {
|
||||
setFormData(prev => ({ ...prev, salesFile: event.target.files![0] }));
|
||||
setErrors(prev => ({ ...prev, salesFile: '' }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSubmitFinal = async () => {
|
||||
setLoading(true);
|
||||
setErrors({});
|
||||
try {
|
||||
const response = await onboardingApi.completeOnboarding();
|
||||
console.log('Onboarding completed:', response);
|
||||
router.push('/dashboard');
|
||||
} catch (err: any) {
|
||||
console.error('Error completing onboarding:', err);
|
||||
setErrors({ general: getErrorMessage(err) });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderStep = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-800 mb-4">Paso 1: Datos de Usuario</h2>
|
||||
<p className="text-gray-600 mb-6">Regístrate para comenzar a usar BakeryForecast.</p>
|
||||
{errors.general && (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-4" role="alert">
|
||||
<span className="block sm:inline">{errors.general}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="fullName" className="block text-sm font-medium text-gray-700">Nombre Completo</label>
|
||||
<input
|
||||
type="text"
|
||||
id="fullName"
|
||||
value={formData.full_name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, full_name: e.target.value }))}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-orange-500 focus:ring-orange-500"
|
||||
placeholder="Tu nombre completo"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-orange-500 focus:ring-orange-500"
|
||||
placeholder="tu@email.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">Contraseña</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, password: e.target.value }))}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-orange-500 focus:ring-orange-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">Confirmar Contraseña</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
value={formData.confirm_password}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, confirm_password: e.target.value }))}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-orange-500 focus:ring-orange-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
{errors.confirmPassword && <p className="text-red-500 text-xs mt-1">{errors.confirmPassword}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 2:
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-800 mb-4">Paso 2: Información de la Panadería</h2>
|
||||
<p className="text-gray-600 mb-6">Cuéntanos sobre tu panadería para personalizar las predicciones.</p>
|
||||
{errors.general && (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-4" role="alert">
|
||||
<span className="block sm:inline">{errors.general}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="bakeryName" className="block text-sm font-medium text-gray-700">Nombre de la Panadería</label>
|
||||
<input
|
||||
type="text"
|
||||
id="bakeryName"
|
||||
value={formData.bakery_name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, bakery_name: e.target.value }))}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-orange-500 focus:ring-orange-500"
|
||||
placeholder="Panadería San José"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="address" className="block text-sm font-medium text-gray-700">Dirección</label>
|
||||
<input
|
||||
type="text"
|
||||
id="address"
|
||||
value={formData.address}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, address: e.target.value }))}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-orange-500 focus:ring-orange-500"
|
||||
placeholder="Calle Mayor 123, Madrid"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="postalCode" className="block text-sm font-medium text-gray-700">Código Postal</label>
|
||||
<input
|
||||
type="text"
|
||||
id="postalCode"
|
||||
value={formData.postal_code}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, postal_code: e.target.value }))}
|
||||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-orange-500 focus:ring-orange-500"
|
||||
placeholder="28001"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.has_nearby_schools}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, has_nearby_schools: e.target.checked }))}
|
||||
className="rounded border-gray-300 text-orange-600 focus:ring-orange-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-700">Hay colegios cerca</span>
|
||||
</label>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.has_nearby_offices}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, has_nearby_offices: e.target.checked }))}
|
||||
className="rounded border-gray-300 text-orange-600 focus:ring-orange-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-700">Hay oficinas cerca</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 3:
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-800 mb-4">Paso 3: Historial de Ventas</h2>
|
||||
<p className="text-gray-600 mb-6">Sube un archivo CSV con tu historial de ventas para entrenar el modelo de predicción.</p>
|
||||
{errors.general && (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-4" role="alert">
|
||||
<span className="block sm:inline">{errors.general}</span>
|
||||
</div>
|
||||
)}
|
||||
{errors.salesFile && <p className="text-red-500 text-xs mb-4">{errors.salesFile}</p>}
|
||||
|
||||
<div className="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md">
|
||||
<div className="space-y-1 text-center">
|
||||
<CloudArrowUpIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<div className="flex text-sm text-gray-600">
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className="relative cursor-pointer bg-white rounded-md font-medium text-orange-600 hover:text-orange-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-orange-500"
|
||||
>
|
||||
<span>Sube un archivo</span>
|
||||
<input id="file-upload" name="file-upload" type="file" className="sr-only" onChange={handleFileChange} accept=".csv,.xlsx" />
|
||||
</label>
|
||||
<p className="pl-1">o arrastra y suelta</p>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Archivo CSV o Excel</p>
|
||||
{formData.salesFile && (
|
||||
<p className="text-sm text-gray-700 mt-2">Archivo seleccionado: <strong>{formData.salesFile.name}</strong></p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-4 text-sm text-gray-500">
|
||||
Asegúrate de que tu archivo contiene columnas como: <strong>date</strong>, <strong>product_name</strong>, <strong>quantity_sold</strong>, <strong>revenue</strong> (opcional).
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 4:
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-800 mb-4">Paso 4: Entrenamiento del Modelo</h2>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{trainingStarted ?
|
||||
'Estamos entrenando los modelos de predicción con tus datos de ventas. Esto puede tardar unos minutos.' :
|
||||
'Preparándose para entrenar los modelos de predicción...'
|
||||
}
|
||||
</p>
|
||||
|
||||
{errors.general && (
|
||||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-4" role="alert">
|
||||
<span className="block sm:inline">{errors.general}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||
<div className="px-4 py-5 sm:px-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">Estado del Entrenamiento</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm text-gray-500">Progreso del entrenamiento del modelo de IA</p>
|
||||
</div>
|
||||
<div className="border-t border-gray-200">
|
||||
<dl>
|
||||
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt className="text-sm font-medium text-gray-500">Estado</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
|
||||
formData.trainingStatus === 'pending' ? 'bg-yellow-100 text-yellow-800' :
|
||||
formData.trainingStatus === 'in_progress' ? 'bg-blue-100 text-blue-800' :
|
||||
formData.trainingStatus === 'completed' ? 'bg-green-100 text-green-800' :
|
||||
'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{formData.trainingStatus === 'pending' ? 'En espera' :
|
||||
formData.trainingStatus === 'in_progress' ? 'En progreso' :
|
||||
formData.trainingStatus === 'completed' ? 'Completado' :
|
||||
formData.trainingStatus === 'failed' ? 'Fallido' : 'Desconocido'}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt className="text-sm font-medium text-gray-500">Progreso</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
||||
<div className="bg-orange-600 h-2.5 rounded-full" style={{ width: `${trainingProgress.progress}%` }}></div>
|
||||
</div>
|
||||
<p className="mt-1 text-right text-xs text-gray-500">{trainingProgress.progress}%</p>
|
||||
</dd>
|
||||
</div>
|
||||
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt className="text-sm font-medium text-gray-500">Paso Actual</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{trainingProgress.currentTask || 'Iniciando...'}</dd>
|
||||
</div>
|
||||
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||
<dt className="text-sm font-medium text-gray-500">Historial de Tareas</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{trainingProgress.tasks.map(task => (
|
||||
<li key={task.id} className="py-2 flex items-center justify-between">
|
||||
<span className="text-sm">{task.name}</span>
|
||||
{task.completed && <CheckIcon className="h-5 w-5 text-green-500" />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Manual start button (only shown if auto-start failed) */}
|
||||
{!trainingStarted && formData.trainingStatus === 'pending' && !loading && (
|
||||
<div className="mt-6 text-center">
|
||||
<button
|
||||
onClick={startModelTraining}
|
||||
className="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-orange-600 hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500"
|
||||
>
|
||||
Iniciar Entrenamiento
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Retry button if training failed */}
|
||||
{formData.trainingStatus === 'failed' && !loading && (
|
||||
<div className="mt-6 text-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
setTrainingStarted(false);
|
||||
setTrainingTaskId(null);
|
||||
setFormData(prev => ({ ...prev, trainingStatus: 'pending' }));
|
||||
setErrors({});
|
||||
}}
|
||||
className="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
||||
>
|
||||
Reintentar Entrenamiento
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 5:
|
||||
return (
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-gray-800 mb-4">¡Enhorabuena!</h2>
|
||||
<p className="text-gray-600 mb-6">Has completado el proceso de configuración. Tu sistema de predicción está listo para usar.</p>
|
||||
<div className="bg-green-50 border border-green-200 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<CheckIcon className="h-5 w-5 text-green-400" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-green-800">Sistema Configurado</h3>
|
||||
<div className="mt-2 text-sm text-green-700">
|
||||
<p>Tu modelo de predicción ha sido entrenado y está listo para generar pronósticos precisos para tu panadería.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const steps = [
|
||||
{ id: 1, name: 'Registro', status: currentStep > 1 || completedSteps.includes(1) ? 'complete' : currentStep === 1 ? 'current' : 'upcoming' },
|
||||
{ id: 2, name: 'Panadería', status: currentStep > 2 || completedSteps.includes(2) ? 'complete' : currentStep === 2 ? 'current' : 'upcoming' },
|
||||
{ id: 3, name: 'Historial de Ventas', status: currentStep > 3 || completedSteps.includes(3) ? 'complete' : currentStep === 3 ? 'current' : 'upcoming' },
|
||||
{ id: 4, name: 'Entrenamiento ML', status: currentStep > 4 || completedSteps.includes(4) ? 'complete' : currentStep === 4 ? 'current' : 'upcoming' },
|
||||
{ id: 5, name: 'Completar', status: currentStep > 5 || completedSteps.includes(5) ? 'complete' : currentStep === 5 ? 'current' : 'upcoming' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 flex items-center justify-center p-4 sm:p-6 lg:p-8">
|
||||
<div className="bg-white shadow-xl rounded-lg p-6 sm:p-8 w-full max-w-4xl">
|
||||
{/* Progress Stepper */}
|
||||
<nav aria-label="Progress" className="mb-8">
|
||||
<ol role="list" className="flex items-center justify-center">
|
||||
{steps.map((step, stepIdx) => (
|
||||
<li key={step.name} className="relative flex-1">
|
||||
<div className="flex flex-col items-center">
|
||||
<span className={`flex h-10 w-10 items-center justify-center rounded-full ${
|
||||
step.status === 'complete' ? 'bg-orange-600' :
|
||||
step.status === 'current' ? 'border-2 border-orange-600' :
|
||||
'bg-gray-200'
|
||||
}`}>
|
||||
{step.status === 'complete' ? (
|
||||
<CheckIcon className="h-6 w-6 text-white" aria-hidden="true" />
|
||||
) : (
|
||||
<span className={`text-sm font-medium ${step.status === 'current' ? 'text-orange-600' : 'text-gray-500'}`}>
|
||||
{step.id}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className={`mt-2 text-sm font-medium ${step.status === 'current' ? 'text-orange-600' : 'text-gray-500'}`}>
|
||||
{step.name}
|
||||
</span>
|
||||
</div>
|
||||
{stepIdx !== steps.length - 1 && (
|
||||
<div
|
||||
className={`absolute right-0 top-5 h-0.5 w-1/2 translate-x-1/2 transform ${
|
||||
step.status === 'complete' ? 'bg-orange-600' : 'bg-gray-200'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="mt-8">
|
||||
{renderStep()}
|
||||
</div>
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<div className="mt-8 flex justify-between">
|
||||
<button
|
||||
onClick={handlePrevious}
|
||||
disabled={currentStep === 1 || loading}
|
||||
className={`flex items-center px-6 py-3 rounded-lg font-medium transition-colors ${
|
||||
currentStep === 1 || loading
|
||||
? 'bg-gray-200 text-gray-500 cursor-not-allowed'
|
||||
: 'bg-orange-100 hover:bg-orange-200 text-orange-700'
|
||||
}`}
|
||||
>
|
||||
<ArrowLeftIcon className="w-5 h-5 mr-2" />
|
||||
Anterior
|
||||
</button>
|
||||
|
||||
{currentStep < 4 && (
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={loading}
|
||||
className={`flex items-center px-6 py-3 rounded-lg font-medium transition-colors ${
|
||||
loading
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-orange-600 hover:bg-orange-700'
|
||||
} text-white`}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
||||
) : (
|
||||
'Siguiente'
|
||||
)}
|
||||
{!loading && <ArrowRightIcon className="w-5 h-5 ml-2" />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{currentStep === 4 && (
|
||||
<button
|
||||
onClick={handleSubmitFinal}
|
||||
disabled={loading || formData.trainingStatus !== 'completed'}
|
||||
className={`flex items-center px-6 py-3 rounded-lg font-medium transition-colors ${
|
||||
loading || formData.trainingStatus !== 'completed'
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-green-600 hover:bg-green-700'
|
||||
} text-white`}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
||||
) : (
|
||||
'Ir al Dashboard'
|
||||
)}
|
||||
{!loading && <ArrowRightIcon className="w-5 h-5 ml-2" />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{currentStep === 5 && (
|
||||
<button
|
||||
onClick={handleSubmitFinal}
|
||||
disabled={loading}
|
||||
className={`flex items-center px-6 py-3 rounded-lg font-medium transition-colors ${
|
||||
loading
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-green-600 hover:bg-green-700'
|
||||
} text-white`}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
||||
) : (
|
||||
'Ir al Dashboard'
|
||||
)}
|
||||
{!loading && <ArrowRightIcon className="w-5 h-5 ml-2" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingPage;
|
||||
Reference in New Issue
Block a user