Add new Frontend
This commit is contained in:
246
frontend/src/App.tsx
Normal file
246
frontend/src/App.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
const [appState, setAppState] = useState<AppState>({
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
user: null,
|
||||
currentPage: 'landing' // 👈 Startimport React, { useState, useEffect } from 'react';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
|
||||
// Components
|
||||
import LoadingSpinner from './components/ui/LoadingSpinner';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
import LandingPage from './pages/landing/LandingPage';
|
||||
import LoginPage from './pages/auth/LoginPage';
|
||||
import RegisterPage from './pages/auth/RegisterPage';
|
||||
import OnboardingPage from './pages/onboarding/OnboardingPage';
|
||||
import DashboardPage from './pages/dashboard/DashboardPage';
|
||||
import ForecastPage from './pages/forecast/ForecastPage';
|
||||
import OrdersPage from './pages/orders/OrdersPage';
|
||||
import SettingsPage from './pages/settings/SettingsPage';
|
||||
import Layout from './components/layout/Layout';
|
||||
|
||||
// Store and types
|
||||
import { store } from './store';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
// i18n
|
||||
import './i18n';
|
||||
|
||||
// Global styles
|
||||
import './styles/globals.css';
|
||||
|
||||
type CurrentPage = 'landing' | 'login' | 'register' | 'onboarding' | 'dashboard' | 'forecast' | 'orders' | 'settings';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
fullName: string;
|
||||
role: string;
|
||||
isOnboardingComplete: boolean;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
user: User | null;
|
||||
currentPage: CurrentPage;
|
||||
}
|
||||
|
||||
const LoadingFallback = () => (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<LoadingSpinner size="lg" />
|
||||
<p className="mt-4 text-gray-600">Cargando PanIA...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [appState, setAppState] = useState<AppState>({
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
user: null,
|
||||
currentPage: 'landing' // 👈 Start with landing page
|
||||
});
|
||||
|
||||
// Initialize app and check authentication
|
||||
useEffect(() => {
|
||||
const initializeApp = async () => {
|
||||
try {
|
||||
// Check for stored auth token
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const userData = localStorage.getItem('user_data');
|
||||
|
||||
if (token && userData) {
|
||||
const user = JSON.parse(userData);
|
||||
setAppState({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user,
|
||||
currentPage: user.isOnboardingComplete ? 'dashboard' : 'onboarding'
|
||||
});
|
||||
} else {
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
currentPage: 'landing' // 👈 Show landing page for non-authenticated users
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('App initialization error:', error);
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
currentPage: 'landing' // 👈 Fallback to landing page
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
initializeApp();
|
||||
}, []);
|
||||
|
||||
const handleLogin = (user: User, token: string) => {
|
||||
localStorage.setItem('auth_token', token);
|
||||
localStorage.setItem('user_data', JSON.stringify(user));
|
||||
|
||||
setAppState({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user,
|
||||
currentPage: user.isOnboardingComplete ? 'dashboard' : 'onboarding'
|
||||
});
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('user_data');
|
||||
|
||||
setAppState({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
user: null,
|
||||
currentPage: 'landing' // 👈 Return to landing page after logout
|
||||
});
|
||||
};
|
||||
|
||||
const handleOnboardingComplete = () => {
|
||||
const updatedUser = { ...appState.user!, isOnboardingComplete: true };
|
||||
localStorage.setItem('user_data', JSON.stringify(updatedUser));
|
||||
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
user: updatedUser,
|
||||
currentPage: 'dashboard'
|
||||
}));
|
||||
};
|
||||
|
||||
const navigateTo = (page: CurrentPage) => {
|
||||
setAppState(prev => ({ ...prev, currentPage: page }));
|
||||
};
|
||||
|
||||
if (appState.isLoading) {
|
||||
return <LoadingFallback />;
|
||||
}
|
||||
|
||||
const renderCurrentPage = () => {
|
||||
// Public pages (non-authenticated)
|
||||
if (!appState.isAuthenticated) {
|
||||
switch (appState.currentPage) {
|
||||
case 'login':
|
||||
return (
|
||||
<LoginPage
|
||||
onLogin={handleLogin}
|
||||
onNavigateToRegister={() => navigateTo('register')}
|
||||
/>
|
||||
);
|
||||
case 'register':
|
||||
return (
|
||||
<RegisterPage
|
||||
onLogin={handleLogin}
|
||||
onNavigateToLogin={() => navigateTo('login')}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<LandingPage
|
||||
onNavigateToLogin={() => navigateTo('login')}
|
||||
onNavigateToRegister={() => navigateTo('register')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticated pages
|
||||
if (!appState.user?.isOnboardingComplete && appState.currentPage !== 'settings') {
|
||||
return (
|
||||
<OnboardingPage
|
||||
user={appState.user!}
|
||||
onComplete={handleOnboardingComplete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Main app pages with layout
|
||||
const pageComponent = () => {
|
||||
switch (appState.currentPage) {
|
||||
case 'forecast':
|
||||
return <ForecastPage />;
|
||||
case 'orders':
|
||||
return <OrdersPage />;
|
||||
case 'settings':
|
||||
return <SettingsPage user={appState.user!} onLogout={handleLogout} />;
|
||||
default:
|
||||
return <DashboardPage user={appState.user!} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
user={appState.user!}
|
||||
currentPage={appState.currentPage}
|
||||
onNavigate={navigateTo}
|
||||
onLogout={handleLogout}
|
||||
>
|
||||
{pageComponent()}
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<ErrorBoundary>
|
||||
<div className="App min-h-screen bg-gray-50">
|
||||
{renderCurrentPage()}
|
||||
|
||||
{/* Global Toast Notifications */}
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 4000,
|
||||
style: {
|
||||
background: '#fff',
|
||||
color: '#333',
|
||||
boxShadow: '0 4px 25px -5px rgba(0, 0, 0, 0.1)',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
},
|
||||
success: {
|
||||
iconTheme: {
|
||||
primary: '#22c55e',
|
||||
secondary: '#fff',
|
||||
},
|
||||
},
|
||||
error: {
|
||||
iconTheme: {
|
||||
primary: '#ef4444',
|
||||
secondary: '#fff',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
68
frontend/src/components/ErrorBoundary.tsx
Normal file
68
frontend/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
// src/components/ErrorBoundary.tsx
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends Component<Props, State> {
|
||||
public state: State = {
|
||||
hasError: false
|
||||
};
|
||||
|
||||
public static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<div className="max-w-md w-full text-center">
|
||||
<div className="bg-white rounded-2xl p-8 shadow-strong">
|
||||
<div className="mx-auto h-16 w-16 bg-red-100 rounded-full flex items-center justify-center mb-6">
|
||||
<AlertTriangle className="h-8 w-8 text-red-600" />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-gray-900 mb-4">
|
||||
¡Oops! Algo salió mal
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Ha ocurrido un error inesperado. Por favor, recarga la página.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="w-full bg-primary-500 text-white py-3 px-4 rounded-xl font-medium hover:bg-primary-600 transition-colors"
|
||||
>
|
||||
Recargar página
|
||||
</button>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<details className="mt-4 text-left">
|
||||
<summary className="text-sm text-gray-500 cursor-pointer">
|
||||
Detalles del error
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs text-red-600 bg-red-50 p-2 rounded overflow-auto">
|
||||
{this.state.error?.stack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
@@ -1,33 +0,0 @@
|
||||
// src/components/auth/ProtectedRoute.tsx
|
||||
import React from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
requireAuth?: boolean;
|
||||
redirectTo?: string;
|
||||
}
|
||||
|
||||
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||
children,
|
||||
requireAuth = true,
|
||||
redirectTo = '/login'
|
||||
}) => {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (requireAuth && !isAuthenticated) {
|
||||
return <Navigate to={redirectTo} state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -1,166 +0,0 @@
|
||||
// ForecastChart.tsx (Modified)
|
||||
import React from 'react';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler,
|
||||
} from 'chart.js';
|
||||
import { Line } from 'react-chartjs-2';
|
||||
import { format } from 'date-fns';
|
||||
import { es } from 'date-fns/locale';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
);
|
||||
|
||||
interface ForecastData {
|
||||
date: string;
|
||||
predicted_quantity: number;
|
||||
confidence_lower: number;
|
||||
confidence_upper: number;
|
||||
actual_quantity?: number;
|
||||
}
|
||||
|
||||
interface ForecastChartProps {
|
||||
data: ForecastData[];
|
||||
productName: string;
|
||||
// height?: number; // Removed fixed height prop
|
||||
}
|
||||
|
||||
const ForecastChart: React.FC<ForecastChartProps> = ({ data, productName /*, height = 400*/ }) => { // Removed height from props
|
||||
const chartData = {
|
||||
labels: data.map(d => format(new Date(d.date), 'dd MMM', { locale: es })),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Predicción',
|
||||
data: data.map(d => d.predicted_quantity),
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
borderWidth: 2,
|
||||
tension: 0.1,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
fill: true,
|
||||
},
|
||||
{
|
||||
label: 'Intervalo Inferior',
|
||||
data: data.map(d => d.confidence_lower),
|
||||
borderColor: 'rgba(59, 130, 246, 0.3)',
|
||||
backgroundColor: 'transparent',
|
||||
borderDash: [5, 5],
|
||||
pointRadius: 0,
|
||||
tension: 0.1,
|
||||
},
|
||||
{
|
||||
label: 'Intervalo Superior',
|
||||
data: data.map(d => d.confidence_upper),
|
||||
borderColor: 'rgba(59, 130, 246, 0.3)',
|
||||
backgroundColor: 'transparent',
|
||||
borderDash: [5, 5],
|
||||
pointRadius: 0,
|
||||
tension: 0.1,
|
||||
},
|
||||
// Optional: Actual quantity if available
|
||||
...(data[0]?.actual_quantity !== undefined && data.some(d => d.actual_quantity !== undefined) ? [{
|
||||
label: 'Real',
|
||||
data: data.map(d => d.actual_quantity),
|
||||
borderColor: 'rgb(255, 99, 132)',
|
||||
backgroundColor: 'rgba(255, 99, 132, 0.1)',
|
||||
borderWidth: 2,
|
||||
tension: 0.1,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6,
|
||||
hidden: true, // Initially hidden, can be toggled
|
||||
}] : []),
|
||||
],
|
||||
};
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false, // Ensures the chart fills its parent container's dimensions
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top' as const,
|
||||
labels: {
|
||||
font: {
|
||||
size: 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: `Predicción de Demanda - ${productName}`,
|
||||
font: {
|
||||
size: 16,
|
||||
weight: 'bold' as const,
|
||||
},
|
||||
padding: {
|
||||
bottom: 20,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index' as const,
|
||||
intersect: false,
|
||||
callbacks: {
|
||||
label: function(context: any) {
|
||||
const label = context.dataset.label || '';
|
||||
const value = context.parsed.y;
|
||||
if (value !== null && value !== undefined) {
|
||||
return `${label}: ${Math.round(value)} unidades`;
|
||||
}
|
||||
return '';
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Fecha',
|
||||
font: {
|
||||
size: 14,
|
||||
},
|
||||
},
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.05)',
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Cantidad (unidades)',
|
||||
font: {
|
||||
size: 14,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
interaction: {
|
||||
mode: 'nearest' as const,
|
||||
axis: 'x' as const,
|
||||
intersect: false,
|
||||
},
|
||||
};
|
||||
|
||||
return <Line data={chartData} options={chartOptions} />;
|
||||
};
|
||||
|
||||
export default ForecastChart;
|
||||
@@ -1,57 +0,0 @@
|
||||
// src/components/common/ErrorBoundary.tsx
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
state: State = {
|
||||
hasError: false,
|
||||
error: null
|
||||
};
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('ErrorBoundary caught:', error, errorInfo);
|
||||
|
||||
// Send error to monitoring service
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
// logErrorToService(error, errorInfo);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback || (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
Algo salió mal
|
||||
</h1>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Ha ocurrido un error inesperado. Por favor, recarga la página.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700"
|
||||
>
|
||||
Recargar página
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
// src/components/common/NotificationToast.tsx
|
||||
import React, { useEffect } from 'react';
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ExclamationCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
InformationCircleIcon
|
||||
} from '@heroicons/react/24/solid';
|
||||
|
||||
interface NotificationToastProps {
|
||||
id: string;
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
title: string;
|
||||
message: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const NotificationToast: React.FC<NotificationToastProps> = ({
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
onClose
|
||||
}) => {
|
||||
const icons = {
|
||||
success: CheckCircleIcon,
|
||||
error: ExclamationCircleIcon,
|
||||
warning: ExclamationTriangleIcon,
|
||||
info: InformationCircleIcon
|
||||
};
|
||||
|
||||
const colors = {
|
||||
success: 'text-green-400',
|
||||
error: 'text-red-400',
|
||||
warning: 'text-yellow-400',
|
||||
info: 'text-blue-400'
|
||||
};
|
||||
|
||||
const Icon = icons[type];
|
||||
|
||||
return (
|
||||
<div className="max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto ring-1 ring-black ring-opacity-5 overflow-hidden">
|
||||
<div className="p-4">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-shrink-0">
|
||||
<Icon className={`h-6 w-6 ${colors[type]}`} />
|
||||
</div>
|
||||
<div className="ml-3 w-0 flex-1 pt-0.5">
|
||||
<p className="text-sm font-medium text-gray-900">{title}</p>
|
||||
<p className="mt-1 text-sm text-gray-500">{message}</p>
|
||||
</div>
|
||||
<div className="ml-4 flex-shrink-0 flex">
|
||||
<button
|
||||
className="bg-white rounded-md inline-flex text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||
onClick={onClose}
|
||||
>
|
||||
<XMarkIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,109 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Fragment } from 'react';
|
||||
import { Listbox, Transition } from '@headlessui/react';
|
||||
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/20/solid';
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
interface ProductSelectorProps {
|
||||
products: Product[];
|
||||
selected: Product;
|
||||
onChange: (product: Product) => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
const ProductSelector: React.FC<ProductSelectorProps> = ({
|
||||
products,
|
||||
selected,
|
||||
onChange,
|
||||
label = 'Seleccionar Producto',
|
||||
}) => {
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<Listbox value={selected} onChange={onChange}>
|
||||
<div className="relative">
|
||||
<Listbox.Button className="relative w-full cursor-default rounded-lg bg-white py-2 pl-3 pr-10 text-left shadow-md focus:outline-none focus-visible:border-orange-500 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 focus-visible:ring-offset-orange-300 sm:text-sm">
|
||||
<span className="flex items-center">
|
||||
{selected.icon && (
|
||||
<span className="mr-2 text-lg">{selected.icon}</span>
|
||||
)}
|
||||
<span className="block truncate">{selected.displayName}</span>
|
||||
</span>
|
||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||
<ChevronUpDownIcon
|
||||
className="h-5 w-5 text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</Listbox.Button>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
|
||||
{products.map((product) => (
|
||||
<Listbox.Option
|
||||
key={product.id}
|
||||
className={({ active }) =>
|
||||
`relative cursor-default select-none py-2 pl-10 pr-4 ${
|
||||
active ? 'bg-orange-100 text-orange-900' : 'text-gray-900'
|
||||
}`
|
||||
}
|
||||
value={product}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
<span className="flex items-center">
|
||||
{product.icon && (
|
||||
<span className="mr-2 text-lg">{product.icon}</span>
|
||||
)}
|
||||
<span
|
||||
className={`block truncate ${
|
||||
selected ? 'font-medium' : 'font-normal'
|
||||
}`}
|
||||
>
|
||||
{product.displayName}
|
||||
</span>
|
||||
</span>
|
||||
{selected ? (
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-orange-600">
|
||||
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Listbox.Option>
|
||||
))}
|
||||
</Listbox.Options>
|
||||
</Transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Export default products list
|
||||
export const defaultProducts: Product[] = [
|
||||
{ id: 'pan', name: 'pan', displayName: 'Pan', icon: '🍞' },
|
||||
{ id: 'croissant', name: 'croissant', displayName: 'Croissant', icon: '🥐' },
|
||||
{ id: 'napolitana', name: 'napolitana', displayName: 'Napolitana', icon: '🥮' },
|
||||
{ id: 'palmera', name: 'palmera', displayName: 'Palmera', icon: '🍪' },
|
||||
{ id: 'cafe', name: 'cafe', displayName: 'Café', icon: '☕' },
|
||||
{ id: 'bocadillo', name: 'bocadillo', displayName: 'Bocadillo', icon: '🥖' },
|
||||
{ id: 'tarta', name: 'tarta', displayName: 'Tarta', icon: '🎂' },
|
||||
{ id: 'donut', name: 'donut', displayName: 'Donut', icon: '🍩' },
|
||||
];
|
||||
|
||||
export default ProductSelector;
|
||||
@@ -1,47 +0,0 @@
|
||||
// src/components/data/SalesUploader.tsx
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { CloudArrowUpIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface SalesUploaderProps {
|
||||
onUpload: (file: File) => Promise<void>;
|
||||
}
|
||||
|
||||
export const SalesUploader: React.FC<SalesUploaderProps> = ({ onUpload }) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
await onUpload(file);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,.xlsx,.xls"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<CloudArrowUpIcon className="h-5 w-5 mr-2" />
|
||||
{isUploading ? 'Uploading...' : 'Upload Sales Data'}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
215
frontend/src/components/layout/Layout.tsx
Normal file
215
frontend/src/components/layout/Layout.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Home,
|
||||
TrendingUp,
|
||||
Package,
|
||||
Settings,
|
||||
Menu,
|
||||
X,
|
||||
LogOut,
|
||||
User,
|
||||
Bell,
|
||||
ChevronDown
|
||||
} from 'lucide-react';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
user: any;
|
||||
currentPage: string;
|
||||
onNavigate: (page: string) => void;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
interface NavigationItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
href: string;
|
||||
}
|
||||
|
||||
const Layout: React.FC<LayoutProps> = ({
|
||||
children,
|
||||
user,
|
||||
currentPage,
|
||||
onNavigate,
|
||||
onLogout
|
||||
}) => {
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||
|
||||
const navigation: NavigationItem[] = [
|
||||
{ id: 'dashboard', label: 'Panel Principal', icon: Home, href: '/dashboard' },
|
||||
{ id: 'forecast', label: 'Predicciones', icon: TrendingUp, href: '/forecast' },
|
||||
{ id: 'orders', label: 'Pedidos', icon: Package, href: '/orders' },
|
||||
{ id: 'settings', label: 'Configuración', icon: Settings, href: '/settings' },
|
||||
];
|
||||
|
||||
const handleNavigate = (pageId: string) => {
|
||||
onNavigate(pageId);
|
||||
setIsMobileMenuOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Top Navigation Bar */}
|
||||
<nav className="bg-white shadow-soft border-b border-gray-200">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16">
|
||||
{/* Left side - Logo and Navigation */}
|
||||
<div className="flex items-center">
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
type="button"
|
||||
className="md:hidden p-2 rounded-md text-gray-600 hover:text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
>
|
||||
{isMobileMenuOpen ? (
|
||||
<X className="h-6 w-6" />
|
||||
) : (
|
||||
<Menu className="h-6 w-6" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Logo */}
|
||||
<div className="flex items-center ml-4 md:ml-0">
|
||||
<div className="h-8 w-8 bg-primary-500 rounded-lg flex items-center justify-center mr-3">
|
||||
<span className="text-white text-sm font-bold">🥖</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-gray-900">PanIA</span>
|
||||
</div>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<div className="hidden md:flex md:ml-10 md:space-x-1">
|
||||
{navigation.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = currentPage === item.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleNavigate(item.id)}
|
||||
className={`
|
||||
flex items-center px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200
|
||||
${isActive
|
||||
? 'bg-primary-100 text-primary-700 shadow-soft'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon className="h-4 w-4 mr-2" />
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right side - Notifications and User Menu */}
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Notifications */}
|
||||
<button className="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors relative">
|
||||
<Bell className="h-5 w-5" />
|
||||
<span className="absolute top-0 right-0 h-2 w-2 bg-red-500 rounded-full"></span>
|
||||
</button>
|
||||
|
||||
{/* User Menu */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
|
||||
className="flex items-center text-sm bg-white rounded-lg p-2 hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<div className="h-8 w-8 bg-primary-500 rounded-full flex items-center justify-center mr-2">
|
||||
<User className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<span className="hidden md:block text-gray-700 font-medium">
|
||||
{user.fullName?.split(' ')[0] || 'Usuario'}
|
||||
</span>
|
||||
<ChevronDown className="hidden md:block h-4 w-4 ml-1 text-gray-500" />
|
||||
</button>
|
||||
|
||||
{/* User Dropdown */}
|
||||
{isUserMenuOpen && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-xl shadow-strong border border-gray-200 py-1 z-50">
|
||||
<div className="px-4 py-3 border-b border-gray-100">
|
||||
<p className="text-sm font-medium text-gray-900">{user.fullName}</p>
|
||||
<p className="text-sm text-gray-500">{user.email}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
handleNavigate('settings');
|
||||
setIsUserMenuOpen(false);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 flex items-center"
|
||||
>
|
||||
<Settings className="h-4 w-4 mr-2" />
|
||||
Configuración
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onLogout();
|
||||
setIsUserMenuOpen(false);
|
||||
}}
|
||||
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 flex items-center"
|
||||
>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
Cerrar sesión
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Navigation Menu */}
|
||||
{isMobileMenuOpen && (
|
||||
<div className="md:hidden border-t border-gray-200 bg-white">
|
||||
<div className="px-2 pt-2 pb-3 space-y-1">
|
||||
{navigation.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = currentPage === item.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleNavigate(item.id)}
|
||||
className={`
|
||||
w-full flex items-center px-3 py-2 rounded-lg text-base font-medium transition-all duration-200
|
||||
${isActive
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon className="h-5 w-5 mr-3" />
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Click outside handler for dropdowns */}
|
||||
{(isUserMenuOpen || isMobileMenuOpen) && (
|
||||
<div
|
||||
className="fixed inset-0 z-40"
|
||||
onClick={() => {
|
||||
setIsUserMenuOpen(false);
|
||||
setIsMobileMenuOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
@@ -1,113 +0,0 @@
|
||||
// src/components/training/TrainingProgressCard.tsx
|
||||
import React from 'react';
|
||||
import { useTrainingProgress } from '../../api/hooks/useTrainingProgress';
|
||||
import { CheckCircleIcon, XCircleIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface TrainingProgressCardProps {
|
||||
jobId: string;
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
export const TrainingProgressCard: React.FC<TrainingProgressCardProps> = ({
|
||||
jobId,
|
||||
onComplete
|
||||
}) => {
|
||||
const { progress, error, isComplete, isConnected } = useTrainingProgress(jobId);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isComplete && onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
}, [isComplete, onComplete]);
|
||||
|
||||
if (!progress) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4 mb-4"></div>
|
||||
<div className="h-2 bg-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">Training Progress</h3>
|
||||
<div className="flex items-center space-x-2">
|
||||
{isConnected && (
|
||||
<span className="flex items-center text-sm text-green-600">
|
||||
<span className="w-2 h-2 bg-green-600 rounded-full mr-1 animate-pulse"></span>
|
||||
Live
|
||||
</span>
|
||||
)}
|
||||
{progress.status === 'completed' && (
|
||||
<CheckCircleIcon className="w-5 h-5 text-green-600" />
|
||||
)}
|
||||
{progress.status === 'failed' && (
|
||||
<XCircleIcon className="w-5 h-5 text-red-600" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-md text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex justify-between text-sm text-gray-600 mb-1">
|
||||
<span>{progress.current_step}</span>
|
||||
<span>{Math.round(progress.progress)}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-indigo-600 h-2 rounded-full transition-all duration-300 ease-out"
|
||||
style={{ width: `${progress.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{progress.estimated_time_remaining && (
|
||||
<p className="text-sm text-gray-600">
|
||||
Tiempo estimado: {formatTime(progress.estimated_time_remaining)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{progress.metrics && (
|
||||
<div className="mt-4 grid grid-cols-2 gap-4">
|
||||
{Object.entries(progress.metrics).map(([key, value]) => (
|
||||
<div key={key} className="text-sm">
|
||||
<span className="text-gray-600">{formatMetricName(key)}:</span>
|
||||
<span className="ml-2 font-medium">{formatMetricValue(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Utility functions
|
||||
const formatTime = (seconds: number): string => {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
};
|
||||
|
||||
const formatMetricName = (name: string): string => {
|
||||
return name.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
};
|
||||
|
||||
const formatMetricValue = (value: any): string => {
|
||||
if (typeof value === 'number') {
|
||||
return value.toFixed(2);
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
57
frontend/src/components/ui/Button.tsx
Normal file
57
frontend/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
// src/components/ui/Button.tsx
|
||||
import React from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import LoadingSpinner from './LoadingSpinner';
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'danger';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
isLoading?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Button: React.FC<ButtonProps> = ({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
isLoading = false,
|
||||
className,
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
}) => {
|
||||
const baseClasses = 'inline-flex items-center justify-center font-medium rounded-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2';
|
||||
|
||||
const variantClasses = {
|
||||
primary: 'bg-primary-500 text-white hover:bg-primary-600 focus:ring-primary-500 shadow-soft hover:shadow-medium',
|
||||
secondary: 'bg-gray-500 text-white hover:bg-gray-600 focus:ring-gray-500 shadow-soft hover:shadow-medium',
|
||||
outline: 'border border-gray-300 text-gray-700 hover:bg-gray-50 focus:ring-primary-500',
|
||||
danger: 'bg-red-500 text-white hover:bg-red-600 focus:ring-red-500 shadow-soft hover:shadow-medium',
|
||||
};
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2.5 text-sm',
|
||||
lg: 'px-6 py-3 text-base',
|
||||
};
|
||||
|
||||
const isDisabled = disabled || isLoading;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={clsx(
|
||||
baseClasses,
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
className
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
{...props}
|
||||
>
|
||||
{isLoading && <LoadingSpinner size="sm" className="mr-2" />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
||||
34
frontend/src/components/ui/Card.tsx
Normal file
34
frontend/src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
// src/components/ui/Card.tsx
|
||||
import React from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const Card: React.FC<CardProps> = ({
|
||||
children,
|
||||
className,
|
||||
padding = 'md'
|
||||
}) => {
|
||||
const paddingClasses = {
|
||||
none: '',
|
||||
sm: 'p-4',
|
||||
md: 'p-6',
|
||||
lg: 'p-8'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx(
|
||||
'bg-white rounded-xl shadow-soft',
|
||||
paddingClasses[padding],
|
||||
className
|
||||
)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Card;
|
||||
54
frontend/src/components/ui/Input.tsx
Normal file
54
frontend/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
// src/components/ui/Input.tsx
|
||||
import React from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
helperText?: string;
|
||||
}
|
||||
|
||||
const Input: React.FC<InputProps> = ({
|
||||
label,
|
||||
error,
|
||||
helperText,
|
||||
className,
|
||||
id,
|
||||
...props
|
||||
}) => {
|
||||
const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-sm font-medium text-gray-700 mb-2"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
id={inputId}
|
||||
className={clsx(
|
||||
'w-full px-4 py-3 border rounded-xl transition-all duration-200',
|
||||
'placeholder-gray-400 text-gray-900',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500',
|
||||
error
|
||||
? 'border-red-300 bg-red-50'
|
||||
: 'border-gray-300 hover:border-gray-400',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
{helperText && !error && (
|
||||
<p className="mt-1 text-sm text-gray-500">{helperText}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Input;
|
||||
27
frontend/src/components/ui/LoadingSpinner.tsx
Normal file
27
frontend/src/components/ui/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
// src/components/ui/LoadingSpinner.tsx
|
||||
import React from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||
size = 'md',
|
||||
className = ''
|
||||
}) => {
|
||||
const sizeClasses = {
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-6 w-6',
|
||||
lg: 'h-8 w-8'
|
||||
};
|
||||
|
||||
return (
|
||||
<Loader2
|
||||
className={`animate-spin text-primary-500 ${sizeClasses[size]} ${className}`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingSpinner;
|
||||
@@ -1,222 +0,0 @@
|
||||
// frontend/src/contexts/AuthContext.tsx - FIXED VERSION
|
||||
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||
// FIXED: Import authService directly, not through the index
|
||||
import { authService } from '../api/services/authService';
|
||||
import { tokenManager } from '../api/auth/tokenManager';
|
||||
import {
|
||||
UserProfile,
|
||||
RegisterRequest,
|
||||
} from '../api/types/api';
|
||||
|
||||
interface AuthContextType {
|
||||
user: UserProfile | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (data: RegisterRequest) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
updateProfile: (updates: Partial<UserProfile>) => Promise<void>;
|
||||
refreshUser: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null);
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<UserProfile | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Initialize auth state
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
try {
|
||||
await tokenManager.initialize();
|
||||
|
||||
if (authService.isAuthenticated()) {
|
||||
// Get user from token first (faster), then validate with API
|
||||
const tokenUser = tokenManager.getUserFromToken();
|
||||
if (tokenUser) {
|
||||
setUser({
|
||||
id: tokenUser.user_id,
|
||||
email: tokenUser.email,
|
||||
full_name: tokenUser.full_name,
|
||||
is_active: true,
|
||||
is_verified: tokenUser.is_verified,
|
||||
role: 'user', // Default role
|
||||
language: 'es',
|
||||
timezone: 'Europe/Madrid',
|
||||
created_at: '', // Will be filled by API call
|
||||
});
|
||||
}
|
||||
|
||||
// Validate with API and get complete profile
|
||||
try {
|
||||
const profile = await authService.getCurrentUser();
|
||||
setUser(profile);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user profile:', error);
|
||||
// Keep token-based user data if API fails
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth initialization failed:', error);
|
||||
// Clear potentially corrupted tokens
|
||||
tokenManager.clearTokens();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initAuth();
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Login and store tokens
|
||||
const tokenResponse = await authService.login({ email, password });
|
||||
|
||||
// After login, get user profile
|
||||
const profile = await authService.getCurrentUser();
|
||||
setUser(profile);
|
||||
} catch (error) {
|
||||
setIsLoading(false);
|
||||
throw error; // Re-throw to let components handle the error
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const register = useCallback(async (data: RegisterRequest) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// ✅ FIX: Handle registration conflicts properly
|
||||
try {
|
||||
// Try to register first
|
||||
const tokenResponse = await authService.register(data);
|
||||
|
||||
// After successful registration, get user profile
|
||||
const profile = await authService.getCurrentUser();
|
||||
setUser(profile);
|
||||
} catch (registrationError: any) {
|
||||
// ✅ FIX: If user already exists (409), try to login instead
|
||||
if (registrationError.response?.status === 409 ||
|
||||
registrationError.message?.includes('already exists')) {
|
||||
|
||||
console.log('User already exists');
|
||||
|
||||
} else {
|
||||
// If it's not a "user exists" error, re-throw it
|
||||
throw registrationError;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setIsLoading(false);
|
||||
throw error; // Re-throw to let components handle the error
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await authService.logout();
|
||||
setUser(null);
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
// Clear local state even if API call fails
|
||||
setUser(null);
|
||||
tokenManager.clearTokens();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateProfile = useCallback(async (updates: Partial<UserProfile>) => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
const updated = await authService.updateProfile(updates);
|
||||
setUser(updated);
|
||||
} catch (error) {
|
||||
console.error('Profile update error:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const refreshUser = useCallback(async () => {
|
||||
if (!authService.isAuthenticated()) return;
|
||||
|
||||
try {
|
||||
const profile = await authService.getCurrentUser();
|
||||
setUser(profile);
|
||||
} catch (error) {
|
||||
console.error('User refresh error:', error);
|
||||
// If refresh fails with 401, user might need to re-login
|
||||
if (error.status === 401) {
|
||||
await logout();
|
||||
}
|
||||
}
|
||||
}, [logout]);
|
||||
|
||||
// Set up token refresh interval
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
await tokenManager.refreshAccessToken();
|
||||
} catch (error) {
|
||||
console.error('Scheduled token refresh failed:', error);
|
||||
// If token refresh fails, user needs to re-login
|
||||
await logout();
|
||||
}
|
||||
}, 60000); // Check every 1 minute
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [user, logout]);
|
||||
|
||||
// Monitor token expiration
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
const checkTokenValidity = () => {
|
||||
if (!authService.isAuthenticated()) {
|
||||
console.warn('Token became invalid, logging out user');
|
||||
logout();
|
||||
}
|
||||
};
|
||||
|
||||
// Check token validity every 30 seconds
|
||||
const interval = setInterval(checkTokenValidity, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [user, logout]);
|
||||
|
||||
const contextValue = {
|
||||
user,
|
||||
isAuthenticated: !!user && authService.isAuthenticated(),
|
||||
isLoading,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
updateProfile,
|
||||
refreshUser,
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Export the RegisterRequest type for use in components
|
||||
export type { RegisterRequest };
|
||||
137
frontend/src/i18n/index.ts
Normal file
137
frontend/src/i18n/index.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
// src/i18n/index.ts
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
|
||||
const resources = {
|
||||
es: {
|
||||
translation: {
|
||||
// Common
|
||||
"loading": "Cargando...",
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar",
|
||||
"delete": "Eliminar",
|
||||
"edit": "Editar",
|
||||
"close": "Cerrar",
|
||||
"yes": "Sí",
|
||||
"no": "No",
|
||||
|
||||
// Navigation
|
||||
"dashboard": "Panel Principal",
|
||||
"forecasts": "Predicciones",
|
||||
"orders": "Pedidos",
|
||||
"settings": "Configuración",
|
||||
"logout": "Cerrar Sesión",
|
||||
|
||||
// Auth
|
||||
"login": "Iniciar Sesión",
|
||||
"register": "Registrarse",
|
||||
"email": "Correo electrónico",
|
||||
"password": "Contraseña",
|
||||
"confirmPassword": "Confirmar contraseña",
|
||||
"fullName": "Nombre completo",
|
||||
"welcomeBack": "¡Bienvenido de vuelta!",
|
||||
"createAccount": "Crear cuenta",
|
||||
|
||||
// Dashboard
|
||||
"todaySales": "Ventas de Hoy",
|
||||
"wasteReduction": "Reducción Desperdicio",
|
||||
"aiAccuracy": "Precisión IA",
|
||||
"stockouts": "Roturas Stock",
|
||||
|
||||
// Forecasts
|
||||
"highConfidence": "Alta confianza",
|
||||
"mediumConfidence": "Confianza media",
|
||||
"lowConfidence": "Baja confianza",
|
||||
"predictionsForToday": "Predicciones para Hoy",
|
||||
"weatherImpact": "Impacto del clima",
|
||||
|
||||
// Orders
|
||||
"newOrder": "Nuevo Pedido",
|
||||
"pending": "Pendiente",
|
||||
"confirmed": "Confirmado",
|
||||
"delivered": "Entregado",
|
||||
"cancelled": "Cancelado",
|
||||
|
||||
// Products
|
||||
"croissants": "Croissants",
|
||||
"bread": "Pan de molde",
|
||||
"baguettes": "Baguettes",
|
||||
"coffee": "Café",
|
||||
"pastries": "Napolitanas",
|
||||
"muffins": "Magdalenas",
|
||||
"donuts": "Donuts",
|
||||
"sandwiches": "Bocadillos"
|
||||
}
|
||||
},
|
||||
en: {
|
||||
translation: {
|
||||
// Common
|
||||
"loading": "Loading...",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"delete": "Delete",
|
||||
"edit": "Edit",
|
||||
"close": "Close",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
|
||||
// Navigation
|
||||
"dashboard": "Dashboard",
|
||||
"forecasts": "Forecasts",
|
||||
"orders": "Orders",
|
||||
"settings": "Settings",
|
||||
"logout": "Logout",
|
||||
|
||||
// Auth
|
||||
"login": "Login",
|
||||
"register": "Register",
|
||||
"email": "Email",
|
||||
"password": "Password",
|
||||
"confirmPassword": "Confirm password",
|
||||
"fullName": "Full name",
|
||||
"welcomeBack": "Welcome back!",
|
||||
"createAccount": "Create account",
|
||||
|
||||
// Dashboard
|
||||
"todaySales": "Today's Sales",
|
||||
"wasteReduction": "Waste Reduction",
|
||||
"aiAccuracy": "AI Accuracy",
|
||||
"stockouts": "Stockouts",
|
||||
|
||||
// Forecasts
|
||||
"highConfidence": "High confidence",
|
||||
"mediumConfidence": "Medium confidence",
|
||||
"lowConfidence": "Low confidence",
|
||||
"predictionsForToday": "Today's Predictions",
|
||||
"weatherImpact": "Weather impact",
|
||||
|
||||
// Orders
|
||||
"newOrder": "New Order",
|
||||
"pending": "Pending",
|
||||
"confirmed": "Confirmed",
|
||||
"delivered": "Delivered",
|
||||
"cancelled": "Cancelled"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources,
|
||||
fallbackLng: 'es',
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
|
||||
detection: {
|
||||
order: ['localStorage', 'navigator', 'htmlTag'],
|
||||
caches: ['localStorage'],
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx' 👈 Imports from ./App.tsx
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App /> 👈 Renders the App component
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -1,12 +0,0 @@
|
||||
import { AuthProvider } from '../contexts/AuthContext';
|
||||
import '../styles/globals.css';
|
||||
|
||||
function App({ Component, pageProps }: any) {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<Component {...pageProps} />
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
278
frontend/src/pages/auth/LoginPage.tsx
Normal file
278
frontend/src/pages/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Eye, EyeOff, Loader2 } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface LoginPageProps {
|
||||
onLogin: (user: any, token: string) => void;
|
||||
onNavigateToRegister: () => void;
|
||||
}
|
||||
|
||||
interface LoginForm {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
const LoginPage: React.FC<LoginPageProps> = ({ onLogin, onNavigateToRegister }) => {
|
||||
const [formData, setFormData] = useState<LoginForm>({
|
||||
email: '',
|
||||
password: ''
|
||||
});
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<Partial<LoginForm>>({});
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Partial<LoginForm> = {};
|
||||
|
||||
if (!formData.email) {
|
||||
newErrors.email = 'El email es obligatorio';
|
||||
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
|
||||
newErrors.email = 'El email no es válido';
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = 'La contraseña es obligatoria';
|
||||
} else if (formData.password.length < 6) {
|
||||
newErrors.password = 'La contraseña debe tener al menos 6 caracteres';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Error al iniciar sesión');
|
||||
}
|
||||
|
||||
toast.success('¡Bienvenido a PanIA!');
|
||||
onLogin(data.user, data.access_token);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Login error:', error);
|
||||
toast.error(error.message || 'Error al iniciar sesión');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
|
||||
// Clear error when user starts typing
|
||||
if (errors[name as keyof LoginForm]) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
[name]: undefined
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-orange-100 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
{/* Logo and Header */}
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-20 w-20 bg-primary-500 rounded-2xl flex items-center justify-center mb-6 shadow-lg">
|
||||
<span className="text-white text-2xl font-bold">🥖</span>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold font-display text-gray-900 mb-2">
|
||||
PanIA
|
||||
</h1>
|
||||
<p className="text-gray-600 text-lg">
|
||||
Inteligencia Artificial para tu Panadería
|
||||
</p>
|
||||
<p className="text-gray-500 text-sm mt-2">
|
||||
Inicia sesión para acceder a tus predicciones
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login Form */}
|
||||
<div className="bg-white py-8 px-6 shadow-strong rounded-3xl">
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
{/* Email Field */}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Correo electrónico
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
className={`
|
||||
appearance-none relative block w-full px-4 py-3 border rounded-xl
|
||||
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
|
||||
focus:ring-primary-500 focus:border-primary-500 focus:z-10
|
||||
transition-all duration-200
|
||||
${errors.email
|
||||
? 'border-red-300 bg-red-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}
|
||||
`}
|
||||
placeholder="tu@panaderia.com"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Contraseña
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
className={`
|
||||
appearance-none relative block w-full px-4 py-3 pr-12 border rounded-xl
|
||||
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
|
||||
focus:ring-primary-500 focus:border-primary-500 focus:z-10
|
||||
transition-all duration-200
|
||||
${errors.password
|
||||
? 'border-red-300 bg-red-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}
|
||||
`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Remember Me & Forgot Password */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="remember-me"
|
||||
name="remember-me"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="remember-me" className="ml-2 block text-sm text-gray-700">
|
||||
Recordarme
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<a
|
||||
href="#"
|
||||
className="font-medium text-primary-600 hover:text-primary-500 transition-colors"
|
||||
>
|
||||
¿Olvidaste tu contraseña?
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className={`
|
||||
group relative w-full flex justify-center py-3 px-4 border border-transparent
|
||||
text-sm font-medium rounded-xl text-white transition-all duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500
|
||||
${isLoading
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-primary-500 hover:bg-primary-600 hover:shadow-lg transform hover:-translate-y-0.5'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
|
||||
Iniciando sesión...
|
||||
</>
|
||||
) : (
|
||||
'Iniciar sesión'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Register Link */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
¿No tienes una cuenta?{' '}
|
||||
<button
|
||||
onClick={onNavigateToRegister}
|
||||
className="font-medium text-primary-600 hover:text-primary-500 transition-colors"
|
||||
>
|
||||
Regístrate gratis
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features Preview */}
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Más de 500 panaderías en Madrid confían en PanIA
|
||||
</p>
|
||||
<div className="flex justify-center space-x-6 text-xs text-gray-400">
|
||||
<div className="flex items-center">
|
||||
<span className="w-2 h-2 bg-success-500 rounded-full mr-2"></span>
|
||||
Predicciones precisas
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className="w-2 h-2 bg-success-500 rounded-full mr-2"></span>
|
||||
Reduce desperdicios
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className="w-2 h-2 bg-success-500 rounded-full mr-2"></span>
|
||||
Fácil de usar
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
686
frontend/src/pages/auth/RegisterPage.tsx
Normal file
686
frontend/src/pages/auth/RegisterPage.tsx
Normal file
@@ -0,0 +1,686 @@
|
||||
{/* Register Form */}
|
||||
<div className="bg-white py-8 px-6 shadow-strong rounded-3xl">
|
||||
<div className="space-y-6">
|
||||
{/* Full Name Field */}
|
||||
<div>
|
||||
<label htmlFor="fullName" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Nombre completo
|
||||
</label>
|
||||
<input
|
||||
id="fullName"
|
||||
name="fullName"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
required
|
||||
value={formData.fullName}
|
||||
onChange={handleInputChange}
|
||||
className={`
|
||||
appearance-none relative block w-full px-4 py-3 border rounded-xl
|
||||
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
|
||||
focus:ring-primary-500 focus:border-primary-500 focus:z-10
|
||||
transition-all duration-200
|
||||
${errors.fullName
|
||||
? 'border-red-300 bg-red-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}
|
||||
`}
|
||||
placeholder="Tu nombre completo"
|
||||
/>
|
||||
{errors.fullName && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.fullName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email Field */}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Correo electrónico
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
className={`
|
||||
appearance-none relative block w-full px-4 py-3 border rounded-xl
|
||||
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
|
||||
focus:ring-primary-500 focus:border-primary-500 focus:z-10
|
||||
transition-all duration-200
|
||||
${errors.email
|
||||
? 'border-red-300 bg-red-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}
|
||||
`}
|
||||
placeholder="tu@panaderia.com"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Contraseña
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
className={`
|
||||
appearance-none relative block w-full px-4 py-3 pr-12 border rounded-xl
|
||||
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
|
||||
focus:ring-primary-500 focus:border-primary-500 focus:z-10
|
||||
transition-all duration-200
|
||||
${errors.password
|
||||
? 'border-red-300 bg-red-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}
|
||||
`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Password Strength Indicator */}
|
||||
{formData.password && (
|
||||
<div className="mt-2">
|
||||
<div className="flex space-x-1">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`h-1 flex-1 rounded ${
|
||||
i < passwordStrength ? strengthColors[passwordStrength - 1] : 'bg-gray-200'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
Seguridad: {strengthLabels[passwordStrength - 1] || 'Muy débil'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Password Field */}
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Confirmar contraseña
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
className={`
|
||||
appearance-none relative block w-full px-4 py-3 pr-12 border rounded-xl
|
||||
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
|
||||
focus:ring-primary-500 focus:border-primary-500 focus:z-10
|
||||
transition-all duration-200
|
||||
${errors.confirmPassword
|
||||
? 'border-red-300 bg-red-50'
|
||||
: formData.confirmPassword && formData.password === formData.confirmPassword
|
||||
? 'border-green-300 bg-green-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}
|
||||
`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
{formData.confirmPassword && formData.password === formData.confirmPassword && (
|
||||
<div className="absolute inset-y-0 right-10 flex items-center">
|
||||
<Check className="h-5 w-5 text-green-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{errors.confirmPassword && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.confirmPassword}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Terms and Conditions */}
|
||||
<div>
|
||||
<div className="flex items-start">
|
||||
<input
|
||||
id="acceptTerms"
|
||||
name="acceptTerms"
|
||||
type="checkbox"
|
||||
checked={formData.acceptTerms}
|
||||
onChange={handleInputChange}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded mt-0.5"
|
||||
/>
|
||||
<label htmlFor="acceptTerms" className="ml-2 block text-sm text-gray-700">
|
||||
Acepto los{' '}
|
||||
<a href="#" className="font-medium text-primary-600 hover:text-primary-500">
|
||||
términos y condiciones
|
||||
</a>{' '}
|
||||
y la{' '}
|
||||
<a href="#" className="font-medium text-primary-600 hover:text-primary-500">
|
||||
política de privacidad
|
||||
</a>
|
||||
</label>
|
||||
</div>
|
||||
{errors.acceptTerms && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.acceptTerms}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isLoading}
|
||||
className={`
|
||||
group relative w-full flex justify-center py-3 px-4 border border-transparent
|
||||
text-sm font-medium rounded-xl text-white transition-all duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500
|
||||
${isLoading
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-primary-500 hover:bg-primary-600 hover:shadow-lg transform hover:-translate-y-0.5'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
|
||||
Creando cuenta...
|
||||
</>
|
||||
) : (
|
||||
'Crear cuenta gratis'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Login Link */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
¿Ya tienes una cuenta?{' '}
|
||||
<button
|
||||
onClick={onNavigateToLogin}
|
||||
className="font-medium text-primary-600 hover:text-primary-500 transition-colors"
|
||||
>
|
||||
Inicia sesión aquí
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>import React, { useState } from 'react';
|
||||
import { Eye, EyeOff, Loader2, Check } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface RegisterPageProps {
|
||||
onLogin: (user: any, token: string) => void;
|
||||
onNavigateToLogin: () => void;
|
||||
}
|
||||
|
||||
interface RegisterForm {
|
||||
fullName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
acceptTerms: boolean;
|
||||
}
|
||||
|
||||
const RegisterPage: React.FC<RegisterPageProps> = ({ onLogin, onNavigateToLogin }) => {
|
||||
const [formData, setFormData] = useState<RegisterForm>({
|
||||
fullName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
acceptTerms: false
|
||||
});
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<Partial<RegisterForm>>({});
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Partial<RegisterForm> = {};
|
||||
|
||||
if (!formData.fullName.trim()) {
|
||||
newErrors.fullName = 'El nombre completo es obligatorio';
|
||||
} else if (formData.fullName.trim().length < 2) {
|
||||
newErrors.fullName = 'El nombre debe tener al menos 2 caracteres';
|
||||
}
|
||||
|
||||
if (!formData.email) {
|
||||
newErrors.email = 'El email es obligatorio';
|
||||
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
|
||||
newErrors.email = 'El email no es válido';
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = 'La contraseña es obligatoria';
|
||||
} else if (formData.password.length < 8) {
|
||||
newErrors.password = 'La contraseña debe tener al menos 8 caracteres';
|
||||
} else if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(formData.password)) {
|
||||
newErrors.password = 'La contraseña debe incluir mayúsculas, minúsculas y números';
|
||||
}
|
||||
|
||||
if (!formData.confirmPassword) {
|
||||
newErrors.confirmPassword = 'Confirma tu contraseña';
|
||||
} else if (formData.password !== formData.confirmPassword) {
|
||||
newErrors.confirmPassword = 'Las contraseñas no coinciden';
|
||||
}
|
||||
|
||||
if (!formData.acceptTerms) {
|
||||
newErrors.acceptTerms = 'Debes aceptar los términos y condiciones';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/register', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
full_name: formData.fullName,
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
role: 'admin' // Default role for bakery owners
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Error al crear la cuenta');
|
||||
}
|
||||
|
||||
// Auto-login after successful registration
|
||||
const loginResponse = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
}),
|
||||
});
|
||||
|
||||
const loginData = await loginResponse.json();
|
||||
|
||||
if (!loginResponse.ok) {
|
||||
throw new Error('Cuenta creada, pero error al iniciar sesión');
|
||||
}
|
||||
|
||||
toast.success('¡Cuenta creada exitosamente! Bienvenido a PanIA');
|
||||
onLogin(loginData.user, loginData.access_token);
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Registration error:', error);
|
||||
toast.error(error.message || 'Error al crear la cuenta');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
}));
|
||||
|
||||
// Clear error when user starts typing
|
||||
if (errors[name as keyof RegisterForm]) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
[name]: undefined
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const getPasswordStrength = (password: string) => {
|
||||
let strength = 0;
|
||||
if (password.length >= 8) strength++;
|
||||
if (/[a-z]/.test(password)) strength++;
|
||||
if (/[A-Z]/.test(password)) strength++;
|
||||
if (/\d/.test(password)) strength++;
|
||||
if (/[^A-Za-z0-9]/.test(password)) strength++;
|
||||
return strength;
|
||||
};
|
||||
|
||||
const passwordStrength = getPasswordStrength(formData.password);
|
||||
const strengthLabels = ['Muy débil', 'Débil', 'Regular', 'Buena', 'Excelente'];
|
||||
const strengthColors = ['bg-red-500', 'bg-orange-500', 'bg-yellow-500', 'bg-blue-500', 'bg-green-500'];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-orange-100 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
{/* Logo and Header */}
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-20 w-20 bg-primary-500 rounded-2xl flex items-center justify-center mb-6 shadow-lg">
|
||||
<span className="text-white text-2xl font-bold">🥖</span>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold font-display text-gray-900 mb-2">
|
||||
Únete a PanIA
|
||||
</h1>
|
||||
<p className="text-gray-600 text-lg">
|
||||
Crea tu cuenta y transforma tu panadería
|
||||
</p>
|
||||
<p className="text-gray-500 text-sm mt-2">
|
||||
Únete a más de 500 panaderías en Madrid
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Register Form */}
|
||||
<div className="bg-white py-8 px-6 shadow-strong rounded-3xl">
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
{/* Full Name Field */}
|
||||
<div>
|
||||
<label htmlFor="fullName" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Nombre completo
|
||||
</label>
|
||||
<input
|
||||
id="fullName"
|
||||
name="fullName"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
required
|
||||
value={formData.fullName}
|
||||
onChange={handleInputChange}
|
||||
className={`
|
||||
appearance-none relative block w-full px-4 py-3 border rounded-xl
|
||||
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
|
||||
focus:ring-primary-500 focus:border-primary-500 focus:z-10
|
||||
transition-all duration-200
|
||||
${errors.fullName
|
||||
? 'border-red-300 bg-red-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}
|
||||
`}
|
||||
placeholder="Tu nombre completo"
|
||||
/>
|
||||
{errors.fullName && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.fullName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email Field */}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Correo electrónico
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
className={`
|
||||
appearance-none relative block w-full px-4 py-3 border rounded-xl
|
||||
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
|
||||
focus:ring-primary-500 focus:border-primary-500 focus:z-10
|
||||
transition-all duration-200
|
||||
${errors.email
|
||||
? 'border-red-300 bg-red-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}
|
||||
`}
|
||||
placeholder="tu@panaderia.com"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Contraseña
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
className={`
|
||||
appearance-none relative block w-full px-4 py-3 pr-12 border rounded-xl
|
||||
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
|
||||
focus:ring-primary-500 focus:border-primary-500 focus:z-10
|
||||
transition-all duration-200
|
||||
${errors.password
|
||||
? 'border-red-300 bg-red-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}
|
||||
`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Password Strength Indicator */}
|
||||
{formData.password && (
|
||||
<div className="mt-2">
|
||||
<div className="flex space-x-1">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`h-1 flex-1 rounded ${
|
||||
i < passwordStrength ? strengthColors[passwordStrength - 1] : 'bg-gray-200'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-600 mt-1">
|
||||
Seguridad: {strengthLabels[passwordStrength - 1] || 'Muy débil'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Password Field */}
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Confirmar contraseña
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
className={`
|
||||
appearance-none relative block w-full px-4 py-3 pr-12 border rounded-xl
|
||||
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
|
||||
focus:ring-primary-500 focus:border-primary-500 focus:z-10
|
||||
transition-all duration-200
|
||||
${errors.confirmPassword
|
||||
? 'border-red-300 bg-red-50'
|
||||
: formData.confirmPassword && formData.password === formData.confirmPassword
|
||||
? 'border-green-300 bg-green-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}
|
||||
`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
{formData.confirmPassword && formData.password === formData.confirmPassword && (
|
||||
<div className="absolute inset-y-0 right-10 flex items-center">
|
||||
<Check className="h-5 w-5 text-green-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{errors.confirmPassword && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.confirmPassword}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Terms and Conditions */}
|
||||
<div>
|
||||
<div className="flex items-start">
|
||||
<input
|
||||
id="acceptTerms"
|
||||
name="acceptTerms"
|
||||
type="checkbox"
|
||||
checked={formData.acceptTerms}
|
||||
onChange={handleInputChange}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded mt-0.5"
|
||||
/>
|
||||
<label htmlFor="acceptTerms" className="ml-2 block text-sm text-gray-700">
|
||||
Acepto los{' '}
|
||||
<a href="#" className="font-medium text-primary-600 hover:text-primary-500">
|
||||
términos y condiciones
|
||||
</a>{' '}
|
||||
y la{' '}
|
||||
<a href="#" className="font-medium text-primary-600 hover:text-primary-500">
|
||||
política de privacidad
|
||||
</a>
|
||||
</label>
|
||||
</div>
|
||||
{errors.acceptTerms && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.acceptTerms}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className={`
|
||||
group relative w-full flex justify-center py-3 px-4 border border-transparent
|
||||
text-sm font-medium rounded-xl text-white transition-all duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500
|
||||
${isLoading
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-primary-500 hover:bg-primary-600 hover:shadow-lg transform hover:-translate-y-0.5'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
|
||||
Creando cuenta...
|
||||
</>
|
||||
) : (
|
||||
'Crear cuenta gratis'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Login Link */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
¿Ya tienes una cuenta?{' '}
|
||||
<button
|
||||
onClick={onNavigateToLogin}
|
||||
className="font-medium text-primary-600 hover:text-primary-500 transition-colors"
|
||||
>
|
||||
Inicia sesión aquí
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Benefits */}
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Al registrarte obtienes acceso completo durante 30 días gratis
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs text-gray-400">
|
||||
<div className="flex items-center justify-center">
|
||||
<span className="w-2 h-2 bg-success-500 rounded-full mr-2"></span>
|
||||
Predicciones IA
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<span className="w-2 h-2 bg-success-500 rounded-full mr-2"></span>
|
||||
Soporte 24/7
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<span className="w-2 h-2 bg-success-500 rounded-full mr-2"></span>
|
||||
Sin compromiso
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterPage;
|
||||
429
frontend/src/pages/dashboard/DashboardPage.tsx
Normal file
429
frontend/src/pages/dashboard/DashboardPage.tsx
Normal file
@@ -0,0 +1,429 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { TrendingUp, TrendingDown, Package, AlertTriangle, Cloud, Users } from 'lucide-react';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar } from 'recharts';
|
||||
|
||||
interface DashboardPageProps {
|
||||
user: any;
|
||||
}
|
||||
|
||||
interface WeatherData {
|
||||
temperature: number;
|
||||
description: string;
|
||||
precipitation: number;
|
||||
}
|
||||
|
||||
interface ForecastData {
|
||||
product: string;
|
||||
predicted: number;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
change: number;
|
||||
}
|
||||
|
||||
interface MetricsData {
|
||||
totalSales: number;
|
||||
wasteReduction: number;
|
||||
accuracy: number;
|
||||
stockouts: number;
|
||||
}
|
||||
|
||||
const DashboardPage: React.FC<DashboardPageProps> = ({ user }) => {
|
||||
const topProducts = [
|
||||
{ name: 'Croissants', quantity: 45, trend: 'up' },
|
||||
{ name: 'Pan de molde', quantity: 32, trend: 'up' },
|
||||
{ name: 'Baguettes', quantity: 28, trend: 'down' },
|
||||
{ name: 'Napolitanas', quantity: 23, trend: 'up' },
|
||||
{ name: 'Café', quantity: 67, trend: 'up' },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const loadDashboardData = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Simulate API calls - in real implementation, these would be actual API calls
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Mock weather data
|
||||
setWeather({
|
||||
temperature: 18,
|
||||
description: 'Parcialmente nublado',
|
||||
precipitation: 0
|
||||
});
|
||||
|
||||
// Mock today's forecasts
|
||||
setTodayForecasts([
|
||||
{ product: 'Croissants', predicted: 48, confidence: 'high', change: 8 },
|
||||
{ product: 'Pan de molde', predicted: 35, confidence: 'high', change: 3 },
|
||||
{ product: 'Baguettes', predicted: 25, confidence: 'medium', change: -3 },
|
||||
{ product: 'Café', predicted: 72, confidence: 'high', change: 5 },
|
||||
{ product: 'Napolitanas', predicted: 26, confidence: 'medium', change: 3 }
|
||||
]);
|
||||
|
||||
// Mock metrics
|
||||
setMetrics({
|
||||
totalSales: 1247,
|
||||
wasteReduction: 15.3,
|
||||
accuracy: 87.2,
|
||||
stockouts: 2
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading dashboard data:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadDashboardData();
|
||||
}, []);
|
||||
|
||||
const getConfidenceColor = (confidence: string) => {
|
||||
switch (confidence) {
|
||||
case 'high':
|
||||
return 'text-success-600 bg-success-100';
|
||||
case 'medium':
|
||||
return 'text-warning-600 bg-warning-100';
|
||||
case 'low':
|
||||
return 'text-danger-600 bg-danger-100';
|
||||
default:
|
||||
return 'text-gray-600 bg-gray-100';
|
||||
}
|
||||
};
|
||||
|
||||
const getConfidenceLabel = (confidence: string) => {
|
||||
switch (confidence) {
|
||||
case 'high':
|
||||
return 'Alta';
|
||||
case 'medium':
|
||||
return 'Media';
|
||||
case 'low':
|
||||
return 'Baja';
|
||||
default:
|
||||
return 'N/A';
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{[...Array(4)].map((_, i) => (
|
||||
<div key={i} className="h-32 bg-gray-200 rounded-xl"></div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="h-64 bg-gray-200 rounded-xl"></div>
|
||||
<div className="h-64 bg-gray-200 rounded-xl"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
¡Hola, {user.fullName?.split(' ')[0] || 'Usuario'}! 👋
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Aquí tienes un resumen de tu panadería para hoy
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{weather && (
|
||||
<div className="mt-4 sm:mt-0 flex items-center text-sm text-gray-600 bg-white rounded-lg px-4 py-2 shadow-soft">
|
||||
<Cloud className="h-4 w-4 mr-2" />
|
||||
<span>{weather.temperature}°C - {weather.description}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-primary-100 rounded-lg">
|
||||
<Package className="h-6 w-6 text-primary-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Ventas de Hoy</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{metrics.totalSales}</p>
|
||||
<p className="text-xs text-success-600 flex items-center mt-1">
|
||||
<TrendingUp className="h-3 w-3 mr-1" />
|
||||
+12% vs ayer
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-success-100 rounded-lg">
|
||||
<TrendingUp className="h-6 w-6 text-success-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Reducción Desperdicio</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{metrics.wasteReduction}%</p>
|
||||
<p className="text-xs text-success-600 flex items-center mt-1">
|
||||
<TrendingUp className="h-3 w-3 mr-1" />
|
||||
Mejorando
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<Users className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Precisión IA</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{metrics.accuracy}%</p>
|
||||
<p className="text-xs text-success-600 flex items-center mt-1">
|
||||
<TrendingUp className="h-3 w-3 mr-1" />
|
||||
Excelente
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-warning-100 rounded-lg">
|
||||
<AlertTriangle className="h-6 w-6 text-warning-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Roturas Stock</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{metrics.stockouts}</p>
|
||||
<p className="text-xs text-success-600 flex items-center mt-1">
|
||||
<TrendingDown className="h-3 w-3 mr-1" />
|
||||
Reduciendo
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Sales Chart */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Ventas vs Predicciones (Última Semana)
|
||||
</h3>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={salesHistory}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="#666"
|
||||
fontSize={12}
|
||||
tickFormatter={(value) => {
|
||||
const date = new Date(value);
|
||||
return `${date.getDate()}/${date.getMonth() + 1}`;
|
||||
}}
|
||||
/>
|
||||
<YAxis stroke="#666" fontSize={12} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#fff',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)'
|
||||
}}
|
||||
labelFormatter={(value) => {
|
||||
const date = new Date(value);
|
||||
return `${date.getDate()}/${date.getMonth() + 1}/${date.getFullYear()}`;
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="ventas"
|
||||
stroke="#f97316"
|
||||
strokeWidth={3}
|
||||
name="Ventas Reales"
|
||||
dot={{ fill: '#f97316', strokeWidth: 2, r: 4 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="prediccion"
|
||||
stroke="#64748b"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="5 5"
|
||||
name="Predicción IA"
|
||||
dot={{ fill: '#64748b', strokeWidth: 2, r: 3 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Today's Forecasts */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Predicciones para Hoy
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{todayForecasts.map((forecast, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-gray-900">{forecast.product}</span>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${getConfidenceColor(forecast.confidence)}`}>
|
||||
{getConfidenceLabel(forecast.confidence)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center mt-1">
|
||||
<span className="text-xl font-bold text-gray-900 mr-2">
|
||||
{forecast.predicted}
|
||||
</span>
|
||||
<span className={`text-sm flex items-center ${
|
||||
forecast.change >= 0 ? 'text-success-600' : 'text-danger-600'
|
||||
}`}>
|
||||
{forecast.change >= 0 ? (
|
||||
<TrendingUp className="h-3 w-3 mr-1" />
|
||||
) : (
|
||||
<TrendingDown className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
{Math.abs(forecast.change)} vs ayer
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Section */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Top Products */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Productos Más Vendidos (Esta Semana)
|
||||
</h3>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={topProducts}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="#666"
|
||||
fontSize={12}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={80}
|
||||
/>
|
||||
<YAxis stroke="#666" fontSize={12} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#fff',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)'
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="quantity"
|
||||
fill="#f97316"
|
||||
radius={[4, 4, 0, 0]}
|
||||
name="Cantidad Vendida"
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Acciones Rápidas
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<button className="w-full p-4 text-left border border-gray-200 rounded-lg hover:border-primary-300 hover:bg-primary-50 transition-all">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-primary-100 rounded-lg mr-3">
|
||||
<TrendingUp className="h-5 w-5 text-primary-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Ver Predicciones Detalladas</div>
|
||||
<div className="text-sm text-gray-500">Analiza las predicciones completas</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button className="w-full p-4 text-left border border-gray-200 rounded-lg hover:border-primary-300 hover:bg-primary-50 transition-all">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-success-100 rounded-lg mr-3">
|
||||
<Package className="h-5 w-5 text-success-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Gestionar Pedidos</div>
|
||||
<div className="text-sm text-gray-500">Revisa y ajusta tus pedidos</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button className="w-full p-4 text-left border border-gray-200 rounded-lg hover:border-primary-300 hover:bg-primary-50 transition-all">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-blue-100 rounded-lg mr-3">
|
||||
<Users className="h-5 w-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Configurar Alertas</div>
|
||||
<div className="text-sm text-gray-500">Personaliza tus notificaciones</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weather Impact Alert */}
|
||||
{weather && weather.precipitation > 0 && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<div className="flex items-start">
|
||||
<Cloud className="h-5 w-5 text-blue-600 mt-0.5 mr-3" />
|
||||
<div>
|
||||
<h4 className="font-medium text-blue-900">Impacto del Clima</h4>
|
||||
<p className="text-blue-800 text-sm mt-1">
|
||||
Se esperan precipitaciones hoy. Esto puede reducir el tráfico peatonal en un 20-30%.
|
||||
Considera ajustar la producción de productos frescos.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage; [isLoading, setIsLoading] = useState(true);
|
||||
const [weather, setWeather] = useState<WeatherData | null>(null);
|
||||
const [todayForecasts, setTodayForecasts] = useState<ForecastData[]>([]);
|
||||
const [metrics, setMetrics] = useState<MetricsData>({
|
||||
totalSales: 0,
|
||||
wasteReduction: 0,
|
||||
accuracy: 0,
|
||||
stockouts: 0
|
||||
});
|
||||
|
||||
// Sample historical data for charts
|
||||
const salesHistory = [
|
||||
{ date: '2024-10-28', ventas: 145, prediccion: 140 },
|
||||
{ date: '2024-10-29', ventas: 128, prediccion: 135 },
|
||||
{ date: '2024-10-30', ventas: 167, prediccion: 160 },
|
||||
{ date: '2024-10-31', ventas: 143, prediccion: 145 },
|
||||
{ date: '2024-11-01', ventas: 156, prediccion: 150 },
|
||||
{ date: '2024-11-02', ventas: 189, prediccion: 185 },
|
||||
{ date: '2024-11-03', ventas: 134, prediccion: 130 },
|
||||
];
|
||||
|
||||
const
|
||||
@@ -1,384 +0,0 @@
|
||||
// src/pages/dashboard/index.tsx
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import Head from 'next/head';
|
||||
import {
|
||||
ChartBarIcon,
|
||||
CloudArrowUpIcon,
|
||||
CpuChipIcon,
|
||||
BellIcon,
|
||||
ArrowPathIcon,
|
||||
ScaleIcon, // For accuracy
|
||||
CalendarDaysIcon, // For last training date
|
||||
CurrencyEuroIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useTrainingProgress } from '../../api/hooks/useTrainingProgress'; // Path corrected
|
||||
import { TrainingProgressCard } from '../../components/training/TrainingProgressCard';
|
||||
import { ForecastChart } from '../../components/charts/ForecastChart';
|
||||
import { SalesUploader } from '../../components/data/SalesUploader';
|
||||
import { NotificationToast } from '../../components/common/NotificationToast';
|
||||
import { ErrorBoundary } from '../../components/common/ErrorBoundary';
|
||||
import { defaultProducts } from '../../components/common/ProductSelector';
|
||||
import {
|
||||
ApiResponse,
|
||||
ForecastRecord,
|
||||
TrainingRequest,
|
||||
TrainingJobProgress
|
||||
} from '@/api/services';
|
||||
|
||||
|
||||
import api from '@/api/services';
|
||||
|
||||
|
||||
// Dashboard specific types
|
||||
interface DashboardStats {
|
||||
totalSales: number;
|
||||
totalRevenue: number;
|
||||
lastTrainingDate: string | null;
|
||||
forecastAccuracy: number; // e.g., MAPE or RMSE
|
||||
}
|
||||
|
||||
interface Notification {
|
||||
id: string;
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
title: string;
|
||||
message: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
// StatsCard Component (moved here for completeness, or keep in common if reused)
|
||||
interface StatsCardProps {
|
||||
title: string;
|
||||
value: any;
|
||||
icon: React.ElementType;
|
||||
format: 'number' | 'currency' | 'percentage' | 'date' | 'string'; // Added 'string' for flexibility
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const StatsCard: React.FC<StatsCardProps> = ({ title, value, icon: Icon, format, loading }) => {
|
||||
const formatValue = () => {
|
||||
if (loading) return (
|
||||
<div className="h-6 bg-gray-200 rounded w-3/4 animate-pulse"></div>
|
||||
);
|
||||
if (value === null || value === undefined) return 'N/A';
|
||||
|
||||
switch (format) {
|
||||
case 'number':
|
||||
return value.toLocaleString('es-ES');
|
||||
case 'currency':
|
||||
return new Intl.NumberFormat('es-ES', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(value);
|
||||
case 'percentage':
|
||||
return `${(value * 100).toFixed(1)}%`;
|
||||
case 'date':
|
||||
return value === 'Never' ? value : new Date(value).toLocaleDateString('es-ES');
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6 flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<Icon className="h-8 w-8 text-pania-blue" /> {/* Changed icon color */}
|
||||
</div>
|
||||
<div className="ml-5">
|
||||
<dt className="text-sm font-medium text-gray-500">{title}</dt>
|
||||
<dd className="mt-1 text-3xl font-semibold text-gray-900">{formatValue()}</dd>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const { user, isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const [activeJobId, setActiveJobId] = useState<string | null>(null);
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [forecasts, setForecasts] = useState<ForecastRecord[]>([]);
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [chartProductName, setChartProductName] = useState<string>(''); // Currently selected product for chart
|
||||
const [loadingData, setLoadingData] = useState(true);
|
||||
|
||||
// Hook for training progress (if an active job ID is present)
|
||||
const {
|
||||
progress: trainingProgress,
|
||||
error: trainingError,
|
||||
isComplete: isTrainingComplete,
|
||||
isConnected: isTrainingWebSocketConnected,
|
||||
} = useTrainingProgress(activeJobId);
|
||||
|
||||
// Effect to handle training completion
|
||||
useEffect(() => {
|
||||
if (isTrainingComplete && activeJobId) {
|
||||
addNotification('success', 'Entrenamiento Completado', `El modelo para el trabajo ${activeJobId} ha terminado de entrenar.`);
|
||||
setActiveJobId(null); // Clear active job
|
||||
fetchDashboardData(); // Refresh dashboard data after training
|
||||
}
|
||||
if (trainingError && activeJobId) {
|
||||
addNotification('error', 'Error de Entrenamiento', `El entrenamiento para el trabajo ${activeJobId} falló: ${trainingError}`);
|
||||
setActiveJobId(null);
|
||||
}
|
||||
}, [isTrainingComplete, trainingError, activeJobId]); // Dependencies
|
||||
|
||||
|
||||
// Notification handling
|
||||
const addNotification = useCallback((type: Notification['type'], title: string, message: string) => {
|
||||
const newNotification: Notification = {
|
||||
id: Date.now().toString(),
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
setNotifications((prev) => [...prev, newNotification]);
|
||||
}, []);
|
||||
|
||||
const removeNotification = useCallback((id: string) => {
|
||||
setNotifications((prev) => prev.filter((n) => n.id !== id));
|
||||
}, []);
|
||||
|
||||
// Fetch initial dashboard data
|
||||
const fetchDashboardData = useCallback(async () => {
|
||||
setLoadingData(true);
|
||||
try {
|
||||
// Fetch Dashboard Stats
|
||||
const statsResponse: ApiResponse<DashboardStats> = await api.data.dataApi.getDashboardStats();
|
||||
if (statsResponse.data) {
|
||||
setStats(statsResponse.data);
|
||||
} else if (statsResponse.message) {
|
||||
addNotification('warning', 'Dashboard Stats', statsResponse.message);
|
||||
}
|
||||
|
||||
// Fetch initial forecasts (e.g., for a default product or the first available product)
|
||||
const forecastResponse: ApiResponse<ForecastRecord[]> = await api.forecasting.getForecast({
|
||||
forecast_days: 7, // Example: 7 days forecast
|
||||
product_name: user?.tenant_id ? 'pan' : undefined, // Default to 'pan' or first product
|
||||
});
|
||||
if (forecastResponse.data && forecastResponse.data.length > 0) {
|
||||
setForecasts(forecastResponse.data);
|
||||
setChartProductName(forecastResponse.data[0].product_name); // Set the product name for the chart
|
||||
} else if (forecastResponse.message) {
|
||||
addNotification('info', 'Previsiones', forecastResponse.message);
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Failed to fetch dashboard data:', error);
|
||||
addNotification('error', 'Error de Carga', error.message || 'No se pudieron cargar los datos del dashboard.');
|
||||
} finally {
|
||||
setLoadingData(false);
|
||||
}
|
||||
}, [user, addNotification]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
fetchDashboardData();
|
||||
}
|
||||
}, [isAuthenticated, fetchDashboardData]);
|
||||
|
||||
const handleSalesUpload = async (file: File) => {
|
||||
try {
|
||||
addNotification('info', 'Subiendo archivo', 'Cargando historial de ventas...');
|
||||
const response = await api.data.dataApi.uploadSalesHistory(file);
|
||||
addNotification('success', 'Subida Completa', 'Historial de ventas cargado exitosamente.');
|
||||
|
||||
// After upload, trigger a new training (assuming this is the flow)
|
||||
const trainingRequest: TrainingRequest = {
|
||||
force_retrain: true,
|
||||
// You might want to specify products if the uploader supports it,
|
||||
// or let the backend determine based on the uploaded data.
|
||||
};
|
||||
const trainingTask: TrainingJobProgress = await api.training.trainingApi.startTraining(trainingRequest);
|
||||
setActiveJobId(trainingTask.id);
|
||||
addNotification('info', 'Entrenamiento iniciado', `Un nuevo entrenamiento ha comenzado (ID: ${trainingTask.id}).`);
|
||||
// No need to fetch dashboard data here, as useEffect for isTrainingComplete will handle it
|
||||
} catch (error: any) {
|
||||
console.error('Error uploading sales or starting training:', error);
|
||||
addNotification('error', 'Error al subir', error.message || 'No se pudo subir el archivo o iniciar el entrenamiento.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleForecastProductChange = async (productName: string) => {
|
||||
setLoadingData(true);
|
||||
try {
|
||||
const forecastResponse: ApiResponse<ForecastRecord[]> = await api.forecasting.forecastingApi.getForecast({
|
||||
forecast_days: 7,
|
||||
product_name: productName,
|
||||
});
|
||||
if (forecastResponse.data) {
|
||||
setForecasts(forecastResponse.data);
|
||||
setChartProductName(productName);
|
||||
}
|
||||
} catch (error: any) {
|
||||
addNotification('error', 'Error de Previsión', error.message || `No se pudieron cargar las previsiones para ${productName}.`);
|
||||
} finally {
|
||||
setLoadingData(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-pania-white">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-pania-blue"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
// If not authenticated, ProtectedRoute should handle redirect, but a fallback is good
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<Head>
|
||||
<title>Dashboard - PanIA</title>
|
||||
<meta name="description" content="Dashboard de predicción de demanda para Panaderías" />
|
||||
</Head>
|
||||
|
||||
{/* Top Notification Area */}
|
||||
<div className="fixed top-4 right-4 z-50 space-y-2">
|
||||
{notifications.map(notification => (
|
||||
<NotificationToast
|
||||
key={notification.id}
|
||||
{...notification}
|
||||
onClose={() => removeNotification(notification.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Header/Navbar (You might want a dedicated Layout component for this) */}
|
||||
<header className="bg-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 Dashboard</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-gray-700">Bienvenido, {user?.full_name || user?.email}!</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
useAuth().logout(); // Call logout from AuthContext
|
||||
}}
|
||||
className="text-pania-blue hover:text-pania-blue-dark font-medium px-4 py-2 rounded-md border border-pania-blue"
|
||||
>
|
||||
Cerrar Sesión
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main className="container mx-auto px-4 py-8">
|
||||
{/* Dashboard Overview Section */}
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">Resumen del Negocio</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<StatsCard
|
||||
title="Ventas Totales"
|
||||
value={stats?.totalSales}
|
||||
icon={ChartBarIcon}
|
||||
format="number"
|
||||
loading={loadingData}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Ingresos Totales"
|
||||
value={stats?.totalRevenue}
|
||||
icon={CurrencyEuroIcon}
|
||||
format="currency"
|
||||
loading={loadingData}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Último Entrenamiento"
|
||||
value={stats?.lastTrainingDate || 'Nunca'}
|
||||
icon={CalendarDaysIcon}
|
||||
format="date"
|
||||
loading={loadingData}
|
||||
/>
|
||||
<StatsCard
|
||||
title="Precisión (MAPE)"
|
||||
value={stats?.forecastAccuracy}
|
||||
icon={ScaleIcon}
|
||||
format="percentage"
|
||||
loading={loadingData}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Training Section */}
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">Entrenamiento del Modelo</h2>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">Subir Nuevos Datos de Ventas</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Carga tu último historial de ventas para mantener tus predicciones actualizadas.
|
||||
</p>
|
||||
<SalesUploader onUpload={handleSalesUpload} />
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">Estado del Entrenamiento</h3>
|
||||
{activeJobId ? (
|
||||
<TrainingProgressCard jobId={activeJobId} />
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-gray-500">
|
||||
<CpuChipIcon className="h-16 w-16 mb-4" />
|
||||
<p className="text-lg text-center">No hay un entrenamiento activo en este momento.</p>
|
||||
<p className="text-sm text-center mt-2">Sube un nuevo archivo de ventas para iniciar un entrenamiento.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Forecast Chart Section */}
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">Previsiones de Demanda</h2>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-800">Previsión para {chartProductName || 'Productos'}</h3>
|
||||
{/* Product Selector for Forecast Chart (assuming ProductSelector can be used for single selection) */}
|
||||
<select
|
||||
value={chartProductName}
|
||||
onChange={(e) => handleForecastProductChange(e.target.value)}
|
||||
className="mt-1 block w-48 pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-pania-blue focus:border-pania-blue sm:text-sm rounded-md"
|
||||
>
|
||||
{/* You'll need to fetch the list of products associated with the user/tenant */}
|
||||
{/* For now, using defaultProducts as an example */}
|
||||
{defaultProducts.map((product) => (
|
||||
<option key={product.id} value={product.displayName}>
|
||||
{product.displayName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{loadingData ? (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-pania-blue"></div>
|
||||
</div>
|
||||
) : forecasts.length > 0 ? (
|
||||
<ForecastChart data={forecasts} productName={chartProductName} />
|
||||
) : (
|
||||
<div className="text-center py-10 text-gray-500">
|
||||
<ChartBarIcon className="mx-auto h-16 w-16 text-gray-400" />
|
||||
<p className="mt-4 text-lg">No hay datos de previsión disponibles.</p>
|
||||
<p className="text-sm">Sube tu historial de ventas o selecciona otro producto.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-gray-800 text-gray-300 py-6 text-center mt-8">
|
||||
<div className="container mx-auto px-4">
|
||||
<p>© {new Date().getFullYear()} PanIA. Todos los derechos reservados.</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
411
frontend/src/pages/forecast/ForecastPage.tsx
Normal file
411
frontend/src/pages/forecast/ForecastPage.tsx
Normal file
@@ -0,0 +1,411 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { TrendingUp, TrendingDown, Calendar, Cloud, AlertTriangle, Info } from 'lucide-react';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
|
||||
interface ForecastData {
|
||||
date: string;
|
||||
product: string;
|
||||
predicted: number;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
factors: string[];
|
||||
weatherImpact?: string;
|
||||
}
|
||||
|
||||
interface WeatherAlert {
|
||||
type: 'rain' | 'heat' | 'cold';
|
||||
impact: string;
|
||||
recommendation: string;
|
||||
}
|
||||
|
||||
const ForecastPage: React.FC = () => {
|
||||
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
|
||||
const [selectedProduct, setSelectedProduct] = useState('all');
|
||||
const [forecasts, setForecasts] = useState<ForecastData[]>([]);
|
||||
const [weatherAlert, setWeatherAlert] = useState<WeatherAlert | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const products = [
|
||||
'Croissants', 'Pan de molde', 'Baguettes', 'Napolitanas',
|
||||
'Café', 'Magdalenas', 'Donuts', 'Bocadillos'
|
||||
];
|
||||
|
||||
// Sample forecast data for the next 7 days
|
||||
const sampleForecastData = [
|
||||
{ date: '2024-11-04', croissants: 48, pan: 35, cafe: 72 },
|
||||
{ date: '2024-11-05', croissants: 52, pan: 38, cafe: 78 },
|
||||
{ date: '2024-11-06', croissants: 45, pan: 32, cafe: 65 },
|
||||
{ date: '2024-11-07', croissants: 41, pan: 29, cafe: 58 },
|
||||
{ date: '2024-11-08', croissants: 56, pan: 42, cafe: 82 },
|
||||
{ date: '2024-11-09', croissants: 61, pan: 45, cafe: 89 },
|
||||
{ date: '2024-11-10', croissants: 38, pan: 28, cafe: 55 },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const loadForecasts = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Mock weather alert
|
||||
setWeatherAlert({
|
||||
type: 'rain',
|
||||
impact: 'Se esperan lluvias moderadas mañana',
|
||||
recommendation: 'Reduce la producción de productos frescos en un 20%'
|
||||
});
|
||||
|
||||
// Mock forecast data
|
||||
const mockForecasts: ForecastData[] = [
|
||||
{
|
||||
date: selectedDate,
|
||||
product: 'Croissants',
|
||||
predicted: 48,
|
||||
confidence: 'high',
|
||||
factors: ['Día laboral', 'Clima estable', 'Sin eventos especiales'],
|
||||
weatherImpact: 'Sin impacto significativo'
|
||||
},
|
||||
{
|
||||
date: selectedDate,
|
||||
product: 'Pan de molde',
|
||||
predicted: 35,
|
||||
confidence: 'high',
|
||||
factors: ['Demanda constante', 'Histórico estable'],
|
||||
weatherImpact: 'Sin impacto'
|
||||
},
|
||||
{
|
||||
date: selectedDate,
|
||||
product: 'Café',
|
||||
predicted: 72,
|
||||
confidence: 'medium',
|
||||
factors: ['Temperatura fresca', 'Día laboral'],
|
||||
weatherImpact: 'Aumento del 10% por temperatura'
|
||||
},
|
||||
{
|
||||
date: selectedDate,
|
||||
product: 'Baguettes',
|
||||
predicted: 28,
|
||||
confidence: 'medium',
|
||||
factors: ['Día entre semana', 'Demanda normal'],
|
||||
weatherImpact: 'Sin impacto'
|
||||
},
|
||||
{
|
||||
date: selectedDate,
|
||||
product: 'Napolitanas',
|
||||
predicted: 23,
|
||||
confidence: 'low',
|
||||
factors: ['Variabilidad alta', 'Datos limitados'],
|
||||
weatherImpact: 'Posible reducción del 5%'
|
||||
}
|
||||
];
|
||||
|
||||
setForecasts(mockForecasts);
|
||||
} catch (error) {
|
||||
console.error('Error loading forecasts:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadForecasts();
|
||||
}, [selectedDate]);
|
||||
|
||||
const getConfidenceColor = (confidence: string) => {
|
||||
switch (confidence) {
|
||||
case 'high':
|
||||
return 'bg-success-100 text-success-800 border-success-200';
|
||||
case 'medium':
|
||||
return 'bg-warning-100 text-warning-800 border-warning-200';
|
||||
case 'low':
|
||||
return 'bg-danger-100 text-danger-800 border-danger-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
const getConfidenceLabel = (confidence: string) => {
|
||||
switch (confidence) {
|
||||
case 'high':
|
||||
return 'Alta confianza';
|
||||
case 'medium':
|
||||
return 'Confianza media';
|
||||
case 'low':
|
||||
return 'Baja confianza';
|
||||
default:
|
||||
return 'N/A';
|
||||
}
|
||||
};
|
||||
|
||||
const filteredForecasts = selectedProduct === 'all'
|
||||
? forecasts
|
||||
: forecasts.filter(f => f.product.toLowerCase().includes(selectedProduct.toLowerCase()));
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/3"></div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="h-32 bg-gray-200 rounded-xl"></div>
|
||||
<div className="h-32 bg-gray-200 rounded-xl"></div>
|
||||
</div>
|
||||
<div className="h-64 bg-gray-200 rounded-xl"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Predicciones IA</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Predicciones inteligentes para optimizar tu producción
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weather Alert */}
|
||||
{weatherAlert && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<div className="flex items-start">
|
||||
<Cloud className="h-5 w-5 text-blue-600 mt-0.5 mr-3 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-blue-900">Alerta Meteorológica</h4>
|
||||
<p className="text-blue-800 text-sm mt-1">{weatherAlert.impact}</p>
|
||||
<p className="text-blue-700 text-sm mt-2 font-medium">
|
||||
💡 Recomendación: {weatherAlert.recommendation}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Fecha de predicción
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-3 top-3 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="date"
|
||||
value={selectedDate}
|
||||
onChange={(e) => setSelectedDate(e.target.value)}
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
max={new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Filtrar por producto
|
||||
</label>
|
||||
<select
|
||||
value={selectedProduct}
|
||||
onChange={(e) => setSelectedProduct(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"
|
||||
>
|
||||
<option value="all">Todos los productos</option>
|
||||
{products.map(product => (
|
||||
<option key={product} value={product.toLowerCase()}>{product}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Forecast Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredForecasts.map((forecast, index) => (
|
||||
<div key={index} className="bg-white p-6 rounded-xl shadow-soft hover:shadow-medium transition-shadow">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{forecast.product}</h3>
|
||||
<span className={`px-3 py-1 rounded-lg text-xs font-medium border ${getConfidenceColor(forecast.confidence)}`}>
|
||||
{getConfidenceLabel(forecast.confidence)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="flex items-baseline">
|
||||
<span className="text-3xl font-bold text-gray-900">{forecast.predicted}</span>
|
||||
<span className="text-gray-500 ml-2">unidades</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Predicción para {new Date(forecast.date).toLocaleDateString('es-ES', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2 flex items-center">
|
||||
<Info className="h-4 w-4 mr-1" />
|
||||
Factores considerados
|
||||
</h4>
|
||||
<ul className="space-y-1">
|
||||
{forecast.factors.map((factor, i) => (
|
||||
<li key={i} className="text-xs text-gray-600 flex items-center">
|
||||
<span className="w-1.5 h-1.5 bg-primary-500 rounded-full mr-2"></span>
|
||||
{factor}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{forecast.weatherImpact && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-1 flex items-center">
|
||||
<Cloud className="h-4 w-4 mr-1" />
|
||||
Impacto del clima
|
||||
</h4>
|
||||
<p className="text-xs text-gray-600">{forecast.weatherImpact}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Trend Chart */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Tendencia de Predicciones (Próximos 7 Días)
|
||||
</h3>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={sampleForecastData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="#666"
|
||||
fontSize={12}
|
||||
tickFormatter={(value) => {
|
||||
const date = new Date(value);
|
||||
return `${date.getDate()}/${date.getMonth() + 1}`;
|
||||
}}
|
||||
/>
|
||||
<YAxis stroke="#666" fontSize={12} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#fff',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)'
|
||||
}}
|
||||
labelFormatter={(value) => {
|
||||
const date = new Date(value);
|
||||
return date.toLocaleDateString('es-ES', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long'
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="croissants"
|
||||
stroke="#f97316"
|
||||
strokeWidth={3}
|
||||
name="Croissants"
|
||||
dot={{ fill: '#f97316', strokeWidth: 2, r: 4 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="pan"
|
||||
stroke="#22c55e"
|
||||
strokeWidth={3}
|
||||
name="Pan de molde"
|
||||
dot={{ fill: '#22c55e', strokeWidth: 2, r: 4 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="cafe"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={3}
|
||||
name="Café"
|
||||
dot={{ fill: '#3b82f6', strokeWidth: 2, r: 4 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommendations */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Recomendaciones Inteligentes
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start p-4 bg-success-50 rounded-lg border border-success-200">
|
||||
<TrendingUp className="h-5 w-5 text-success-600 mt-0.5 mr-3 flex-shrink-0" />
|
||||
<div>
|
||||
<h4 className="font-medium text-success-900">Oportunidad de Aumento</h4>
|
||||
<p className="text-success-800 text-sm mt-1">
|
||||
La demanda de café aumentará un 15% esta semana por las bajas temperaturas.
|
||||
Considera aumentar el stock de café y bebidas calientes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start p-4 bg-warning-50 rounded-lg border border-warning-200">
|
||||
<AlertTriangle className="h-5 w-5 text-warning-600 mt-0.5 mr-3 flex-shrink-0" />
|
||||
<div>
|
||||
<h4 className="font-medium text-warning-900">Ajuste Recomendado</h4>
|
||||
<p className="text-warning-800 text-sm mt-1">
|
||||
Las napolitanas muestran alta variabilidad. Considera reducir la producción
|
||||
inicial y hornear más según demanda en tiempo real.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<Info className="h-5 w-5 text-blue-600 mt-0.5 mr-3 flex-shrink-0" />
|
||||
<div>
|
||||
<h4 className="font-medium text-blue-900">Optimización de Horarios</h4>
|
||||
<p className="text-blue-800 text-sm mt-1">
|
||||
El pico de demanda de croissants será entre 7:30-9:00 AM.
|
||||
Asegúrate de tener suficiente stock listo para esas horas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export Actions */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Acciones Rápidas
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<button className="p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all text-left">
|
||||
<div className="font-medium text-gray-900">Exportar Predicciones</div>
|
||||
<div className="text-sm text-gray-500 mt-1">Descargar en formato CSV</div>
|
||||
</button>
|
||||
|
||||
<button className="p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all text-left">
|
||||
<div className="font-medium text-gray-900">Configurar Alertas</div>
|
||||
<div className="text-sm text-gray-500 mt-1">Recibir notificaciones automáticas</div>
|
||||
</button>
|
||||
|
||||
<button className="p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all text-left">
|
||||
<div className="font-medium text-gray-900">Ver Precisión Histórica</div>
|
||||
<div className="text-sm text-gray-500 mt-1">Analizar rendimiento del modelo</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForecastPage;
|
||||
@@ -1,143 +0,0 @@
|
||||
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;
|
||||
549
frontend/src/pages/landing/LandingPage.tsx
Normal file
549
frontend/src/pages/landing/LandingPage.tsx
Normal file
@@ -0,0 +1,549 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Package,
|
||||
Clock,
|
||||
Users,
|
||||
Star,
|
||||
ChevronRight,
|
||||
CheckCircle,
|
||||
BarChart3,
|
||||
Shield,
|
||||
Smartphone,
|
||||
Play,
|
||||
ArrowRight,
|
||||
MapPin,
|
||||
Quote
|
||||
} from 'lucide-react';
|
||||
|
||||
interface LandingPageProps {
|
||||
onNavigateToLogin: () => void;
|
||||
onNavigateToRegister: () => void;
|
||||
}
|
||||
|
||||
const LandingPage: React.FC<LandingPageProps> = ({ onNavigateToLogin, onNavigateToRegister }) => {
|
||||
const [isVideoModalOpen, setIsVideoModalOpen] = useState(false);
|
||||
const [currentTestimonial, setCurrentTestimonial] = useState(0);
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: TrendingUp,
|
||||
title: "Predicciones Precisas",
|
||||
description: "IA que aprende de tu negocio único para predecir demanda con 87% de precisión",
|
||||
color: "bg-success-100 text-success-600"
|
||||
},
|
||||
{
|
||||
icon: TrendingDown,
|
||||
title: "Reduce Desperdicios",
|
||||
description: "Disminuye hasta un 25% el desperdicio diario optimizando tu producción",
|
||||
color: "bg-primary-100 text-primary-600"
|
||||
},
|
||||
{
|
||||
icon: Clock,
|
||||
title: "Ahorra Tiempo",
|
||||
description: "30-45 minutos menos al día en planificación manual de producción",
|
||||
color: "bg-blue-100 text-blue-600"
|
||||
},
|
||||
{
|
||||
icon: Package,
|
||||
title: "Gestión Inteligente",
|
||||
description: "Pedidos automáticos y alertas de stock basados en predicciones",
|
||||
color: "bg-purple-100 text-purple-600"
|
||||
}
|
||||
];
|
||||
|
||||
const testimonials = [
|
||||
{
|
||||
name: "María González",
|
||||
business: "Panadería San Miguel",
|
||||
location: "Chamberí, Madrid",
|
||||
text: "Con PanIA reduje mis desperdicios un 20% en el primer mes. La IA realmente entiende mi negocio.",
|
||||
rating: 5,
|
||||
savings: "€280/mes"
|
||||
},
|
||||
{
|
||||
name: "Carlos Ruiz",
|
||||
business: "Obrador Central Goya",
|
||||
location: "Salamanca, Madrid",
|
||||
text: "Gestiono 4 puntos de venta y PanIA me ahorra 2 horas diarias de planificación. Imprescindible.",
|
||||
rating: 5,
|
||||
savings: "€450/mes"
|
||||
},
|
||||
{
|
||||
name: "Ana Martín",
|
||||
business: "Café & Pan Malasaña",
|
||||
location: "Malasaña, Madrid",
|
||||
text: "Las predicciones son increíblemente precisas. Ya no me quedo sin croissants en el desayuno.",
|
||||
rating: 5,
|
||||
savings: "€190/mes"
|
||||
}
|
||||
];
|
||||
|
||||
const stats = [
|
||||
{ number: "500+", label: "Panaderías en Madrid" },
|
||||
{ number: "87%", label: "Precisión en predicciones" },
|
||||
{ number: "25%", label: "Reducción desperdicios" },
|
||||
{ number: "€350", label: "Ahorro mensual promedio" }
|
||||
];
|
||||
|
||||
const madridDistricts = [
|
||||
"Centro", "Salamanca", "Chamberí", "Retiro", "Arganzuela",
|
||||
"Moncloa", "Chamartín", "Hortaleza", "Fuencarral", "Tetuán"
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setCurrentTestimonial((prev) => (prev + 1) % testimonials.length);
|
||||
}, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [testimonials.length]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
{/* Header */}
|
||||
<header className="bg-white shadow-sm sticky top-0 z-40">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center py-4">
|
||||
<div className="flex items-center">
|
||||
<div className="h-10 w-10 bg-primary-500 rounded-xl flex items-center justify-center mr-3">
|
||||
<span className="text-white text-xl font-bold">🥖</span>
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-gray-900">PanIA</span>
|
||||
</div>
|
||||
|
||||
<nav className="hidden md:flex space-x-8">
|
||||
<a href="#features" className="text-gray-600 hover:text-primary-600 transition-colors">Características</a>
|
||||
<a href="#testimonials" className="text-gray-600 hover:text-primary-600 transition-colors">Testimonios</a>
|
||||
<a href="#pricing" className="text-gray-600 hover:text-primary-600 transition-colors">Precios</a>
|
||||
<a href="#contact" className="text-gray-600 hover:text-primary-600 transition-colors">Contacto</a>
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={onNavigateToLogin}
|
||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
Iniciar sesión
|
||||
</button>
|
||||
<button
|
||||
onClick={onNavigateToRegister}
|
||||
className="bg-primary-500 text-white px-6 py-2 rounded-lg hover:bg-primary-600 transition-all hover:shadow-lg"
|
||||
>
|
||||
Prueba gratis
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="bg-gradient-to-br from-primary-50 to-orange-100 py-20">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
|
||||
<div>
|
||||
<div className="inline-flex items-center bg-primary-100 text-primary-800 px-4 py-2 rounded-full text-sm font-medium mb-6">
|
||||
<Star className="h-4 w-4 mr-2" />
|
||||
IA líder para panaderías en Madrid
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl lg:text-6xl font-bold text-gray-900 mb-6 leading-tight">
|
||||
La primera IA para
|
||||
<span className="text-primary-600 block">tu panadería</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-xl text-gray-600 mb-8 leading-relaxed">
|
||||
Transforma tus datos de ventas en predicciones precisas.
|
||||
Reduce desperdicios, maximiza ganancias y optimiza tu producción
|
||||
con inteligencia artificial diseñada para panaderías madrileñas.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-8">
|
||||
<button
|
||||
onClick={onNavigateToRegister}
|
||||
className="bg-primary-500 text-white px-8 py-4 rounded-xl font-semibold text-lg hover:bg-primary-600 transition-all hover:shadow-lg transform hover:-translate-y-1 flex items-center justify-center"
|
||||
>
|
||||
Comenzar gratis
|
||||
<ArrowRight className="h-5 w-5 ml-2" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setIsVideoModalOpen(true)}
|
||||
className="border-2 border-gray-300 text-gray-700 px-8 py-4 rounded-xl font-semibold text-lg hover:border-primary-500 hover:text-primary-600 transition-all flex items-center justify-center"
|
||||
>
|
||||
<Play className="h-5 w-5 mr-2" />
|
||||
Ver demo
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<CheckCircle className="h-4 w-4 text-green-500 mr-2" />
|
||||
<span>30 días gratis • Sin tarjeta de crédito • Configuración en 5 minutos</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="bg-white rounded-2xl shadow-2xl p-8 border">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Predicciones para Hoy</h3>
|
||||
<span className="bg-green-100 text-green-800 px-3 py-1 rounded-full text-sm font-medium">
|
||||
87% precisión
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ product: "Croissants", predicted: 48, confidence: "high", change: 8 },
|
||||
{ product: "Pan de molde", predicted: 35, confidence: "high", change: 3 },
|
||||
{ product: "Café", predicted: 72, confidence: "medium", change: -5 }
|
||||
].map((item, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{item.product}</div>
|
||||
<div className="text-sm text-gray-500 flex items-center">
|
||||
{item.change > 0 ? (
|
||||
<TrendingUp className="h-3 w-3 text-green-500 mr-1" />
|
||||
) : (
|
||||
<TrendingDown className="h-3 w-3 text-red-500 mr-1" />
|
||||
)}
|
||||
{Math.abs(item.change)} vs ayer
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-gray-900">{item.predicted}</div>
|
||||
<div className={`text-xs px-2 py-1 rounded ${
|
||||
item.confidence === 'high'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{item.confidence === 'high' ? 'Alta confianza' : 'Media confianza'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating stats */}
|
||||
<div className="absolute -top-6 -right-6 bg-white rounded-xl shadow-lg p-4 border">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-green-600">-25%</div>
|
||||
<div className="text-sm text-gray-600">Desperdicios</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute -bottom-6 -left-6 bg-white rounded-xl shadow-lg p-4 border">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-primary-600">€350</div>
|
||||
<div className="text-sm text-gray-600">Ahorro/mes</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats Section */}
|
||||
<section className="py-16 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
Resultados que hablan por sí solos
|
||||
</h2>
|
||||
<p className="text-gray-600 max-w-2xl mx-auto">
|
||||
Más de 500 panaderías en Madrid ya confían en PanIA para optimizar su producción
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{stats.map((stat, index) => (
|
||||
<div key={index} className="text-center">
|
||||
<div className="text-4xl lg:text-5xl font-bold text-primary-600 mb-2">
|
||||
{stat.number}
|
||||
</div>
|
||||
<div className="text-gray-600 font-medium">{stat.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section id="features" className="py-20 bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
IA diseñada para panaderías madrileñas
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||
Cada característica está pensada para resolver los desafíos específicos
|
||||
de las panaderías en Madrid: desde el clima hasta los patrones de consumo locales
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-16">
|
||||
{features.map((feature, index) => {
|
||||
const Icon = feature.icon;
|
||||
return (
|
||||
<div key={index} className="bg-white p-8 rounded-2xl shadow-soft hover:shadow-medium transition-all">
|
||||
<div className={`w-12 h-12 ${feature.color} rounded-xl flex items-center justify-center mb-6`}>
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">{feature.title}</h3>
|
||||
<p className="text-gray-600 leading-relaxed">{feature.description}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Madrid-specific features */}
|
||||
<div className="bg-white rounded-2xl p-8 shadow-soft">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
Especializado en Madrid
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
PanIA conoce Madrid como ninguna otra IA. Integra datos del clima,
|
||||
tráfico, eventos y patrones de consumo específicos de la capital.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center">
|
||||
<CheckCircle className="h-5 w-5 text-green-500 mr-3" />
|
||||
<span>Integración con datos meteorológicos de AEMET</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle className="h-5 w-5 text-green-500 mr-3" />
|
||||
<span>Análisis de eventos y festividades locales</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle className="h-5 w-5 text-green-500 mr-3" />
|
||||
<span>Patrones de tráfico peatonal por distrito</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle className="h-5 w-5 text-green-500 mr-3" />
|
||||
<span>Horarios de siesta y patrones españoles</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gradient-to-br from-blue-50 to-primary-50 p-6 rounded-xl">
|
||||
<div className="flex items-center mb-4">
|
||||
<MapPin className="h-5 w-5 text-primary-600 mr-2" />
|
||||
<h4 className="font-semibold text-gray-900">Distritos cubiertos</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{madridDistricts.map((district, index) => (
|
||||
<div key={index} className="text-sm text-gray-700 bg-white px-3 py-1 rounded-lg">
|
||||
{district}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Testimonials Section */}
|
||||
<section id="testimonials" className="py-20 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-4xl font-bold text-gray-900 mb-4">
|
||||
Lo que dicen nuestros clientes
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600">
|
||||
Panaderías reales, resultados reales en Madrid
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="bg-gradient-to-br from-primary-50 to-orange-50 rounded-2xl p-8 lg:p-12">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center">
|
||||
<Quote className="h-12 w-12 text-primary-400 mx-auto mb-6" />
|
||||
|
||||
<blockquote className="text-2xl lg:text-3xl font-medium text-gray-900 mb-8 leading-relaxed">
|
||||
"{testimonials[currentTestimonial].text}"
|
||||
</blockquote>
|
||||
|
||||
<div className="flex items-center justify-center mb-6">
|
||||
{[...Array(testimonials[currentTestimonial].rating)].map((_, i) => (
|
||||
<Star key={i} className="h-5 w-5 text-yellow-400 fill-current" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="font-semibold text-gray-900 text-lg">
|
||||
{testimonials[currentTestimonial].name}
|
||||
</div>
|
||||
<div className="text-primary-600 font-medium">
|
||||
{testimonials[currentTestimonial].business}
|
||||
</div>
|
||||
<div className="text-gray-500 text-sm">
|
||||
{testimonials[currentTestimonial].location}
|
||||
</div>
|
||||
<div className="inline-flex items-center bg-green-100 text-green-800 px-3 py-1 rounded-full text-sm font-medium mt-2">
|
||||
Ahorro: {testimonials[currentTestimonial].savings}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Testimonial indicators */}
|
||||
<div className="flex justify-center mt-8 space-x-2">
|
||||
{testimonials.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => setCurrentTestimonial(index)}
|
||||
className={`w-3 h-3 rounded-full transition-all ${
|
||||
index === currentTestimonial ? 'bg-primary-500' : 'bg-gray-300'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-20 bg-gradient-to-br from-primary-600 to-orange-600">
|
||||
<div className="max-w-4xl mx-auto text-center px-4 sm:px-6 lg:px-8">
|
||||
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6">
|
||||
¿Listo para transformar tu panadería?
|
||||
</h2>
|
||||
<p className="text-xl text-primary-100 mb-8 leading-relaxed">
|
||||
Únete a más de 500 panaderías en Madrid que ya reducen desperdicios
|
||||
y maximizan ganancias con PanIA
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center mb-8">
|
||||
<button
|
||||
onClick={onNavigateToRegister}
|
||||
className="bg-white text-primary-600 px-8 py-4 rounded-xl font-semibold text-lg hover:bg-gray-50 transition-all hover:shadow-lg transform hover:-translate-y-1"
|
||||
>
|
||||
Comenzar prueba gratuita
|
||||
</button>
|
||||
<button
|
||||
onClick={onNavigateToLogin}
|
||||
className="border-2 border-white text-white px-8 py-4 rounded-xl font-semibold text-lg hover:bg-white hover:text-primary-600 transition-all"
|
||||
>
|
||||
Ya tengo cuenta
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-8 text-primary-100">
|
||||
<div className="flex items-center">
|
||||
<CheckCircle className="h-5 w-5 mr-2" />
|
||||
<span>30 días gratis</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle className="h-5 w-5 mr-2" />
|
||||
<span>Sin tarjeta de crédito</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle className="h-5 w-5 mr-2" />
|
||||
<span>Soporte en español</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-gray-900 text-white py-12">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<div>
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="h-8 w-8 bg-primary-500 rounded-lg flex items-center justify-center mr-3">
|
||||
<span className="text-white text-lg font-bold">🥖</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold">PanIA</span>
|
||||
</div>
|
||||
<p className="text-gray-400 mb-4">
|
||||
Inteligencia Artificial para panaderías en Madrid
|
||||
</p>
|
||||
<div className="text-gray-400 text-sm">
|
||||
<p>📍 Madrid, España</p>
|
||||
<p>📧 hola@pania.es</p>
|
||||
<p>📞 +34 900 123 456</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4">Producto</h3>
|
||||
<ul className="space-y-2 text-gray-400">
|
||||
<li><a href="#features" className="hover:text-white transition-colors">Características</a></li>
|
||||
<li><a href="#pricing" className="hover:text-white transition-colors">Precios</a></li>
|
||||
<li><a href="#" className="hover:text-white transition-colors">Demo</a></li>
|
||||
<li><a href="#" className="hover:text-white transition-colors">API</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4">Soporte</h3>
|
||||
<ul className="space-y-2 text-gray-400">
|
||||
<li><a href="#" className="hover:text-white transition-colors">Centro de ayuda</a></li>
|
||||
<li><a href="#" className="hover:text-white transition-colors">Documentación</a></li>
|
||||
<li><a href="#contact" className="hover:text-white transition-colors">Contacto</a></li>
|
||||
<li><a href="#" className="hover:text-white transition-colors">Estado del sistema</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4">Legal</h3>
|
||||
<ul className="space-y-2 text-gray-400">
|
||||
<li><a href="#" className="hover:text-white transition-colors">Privacidad</a></li>
|
||||
<li><a href="#" className="hover:text-white transition-colors">Términos</a></li>
|
||||
<li><a href="#" className="hover:text-white transition-colors">Cookies</a></li>
|
||||
<li><a href="#" className="hover:text-white transition-colors">GDPR</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-800 mt-12 pt-8 text-center text-gray-400">
|
||||
<p>© 2024 PanIA. Todos los derechos reservados. Hecho con ❤️ en Madrid.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Video Modal */}
|
||||
{isVideoModalOpen && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-2xl p-6 max-w-4xl w-full max-h-[90vh] overflow-auto">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-2xl font-bold text-gray-900">Demo de PanIA</h3>
|
||||
<button
|
||||
onClick={() => setIsVideoModalOpen(false)}
|
||||
className="text-gray-500 hover:text-gray-700 text-2xl"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="aspect-video bg-gray-100 rounded-lg flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<Play className="h-16 w-16 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-gray-600">Video demo disponible próximamente</p>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
Mientras tanto, puedes comenzar tu prueba gratuita
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsVideoModalOpen(false);
|
||||
onNavigateToRegister();
|
||||
}}
|
||||
className="mt-4 bg-primary-500 text-white px-6 py-2 rounded-lg hover:bg-primary-600 transition-colors"
|
||||
>
|
||||
Comenzar prueba gratis
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LandingPage;
|
||||
@@ -1,76 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Head from 'next/head';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
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;
|
||||
File diff suppressed because it is too large
Load Diff
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;
|
||||
424
frontend/src/pages/orders/OrdersPage.tsx
Normal file
424
frontend/src/pages/orders/OrdersPage.tsx
Normal file
@@ -0,0 +1,424 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Package, Plus, Edit, Trash2, Calendar, CheckCircle, AlertCircle, Clock } from 'lucide-react';
|
||||
|
||||
interface Order {
|
||||
id: string;
|
||||
supplier: string;
|
||||
items: OrderItem[];
|
||||
orderDate: string;
|
||||
deliveryDate: string;
|
||||
status: 'pending' | 'confirmed' | 'delivered' | 'cancelled';
|
||||
total: number;
|
||||
type: 'ingredients' | 'consumables';
|
||||
}
|
||||
|
||||
interface OrderItem {
|
||||
name: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
price: number;
|
||||
suggested?: boolean;
|
||||
}
|
||||
|
||||
const OrdersPage: React.FC = () => {
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showNewOrder, setShowNewOrder] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<'all' | 'pending' | 'delivered'>('all');
|
||||
|
||||
// Sample orders data
|
||||
const sampleOrders: Order[] = [
|
||||
{
|
||||
id: '1',
|
||||
supplier: 'Harinas Castellana',
|
||||
items: [
|
||||
{ name: 'Harina de trigo', quantity: 50, unit: 'kg', price: 0.85, suggested: true },
|
||||
{ name: 'Levadura fresca', quantity: 2, unit: 'kg', price: 3.20 },
|
||||
{ name: 'Sal marina', quantity: 5, unit: 'kg', price: 1.10 }
|
||||
],
|
||||
orderDate: '2024-11-03',
|
||||
deliveryDate: '2024-11-05',
|
||||
status: 'pending',
|
||||
total: 52.50,
|
||||
type: 'ingredients'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
supplier: 'Distribuciones Madrid',
|
||||
items: [
|
||||
{ name: 'Vasos de café 250ml', quantity: 1000, unit: 'unidades', price: 0.08 },
|
||||
{ name: 'Bolsas papel kraft', quantity: 500, unit: 'unidades', price: 0.12, suggested: true },
|
||||
{ name: 'Servilletas', quantity: 20, unit: 'paquetes', price: 2.50 }
|
||||
],
|
||||
orderDate: '2024-11-02',
|
||||
deliveryDate: '2024-11-04',
|
||||
status: 'confirmed',
|
||||
total: 190.00,
|
||||
type: 'consumables'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
supplier: 'Lácteos Frescos SA',
|
||||
items: [
|
||||
{ name: 'Leche entera', quantity: 20, unit: 'litros', price: 0.95 },
|
||||
{ name: 'Mantequilla', quantity: 5, unit: 'kg', price: 4.20 },
|
||||
{ name: 'Nata para montar', quantity: 3, unit: 'litros', price: 2.80 }
|
||||
],
|
||||
orderDate: '2024-11-01',
|
||||
deliveryDate: '2024-11-03',
|
||||
status: 'delivered',
|
||||
total: 47.40,
|
||||
type: 'ingredients'
|
||||
}
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const loadOrders = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
setOrders(sampleOrders);
|
||||
} catch (error) {
|
||||
console.error('Error loading orders:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadOrders();
|
||||
}, []);
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'bg-warning-100 text-warning-800 border-warning-200';
|
||||
case 'confirmed':
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
case 'delivered':
|
||||
return 'bg-success-100 text-success-800 border-success-200';
|
||||
case 'cancelled':
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'Pendiente';
|
||||
case 'confirmed':
|
||||
return 'Confirmado';
|
||||
case 'delivered':
|
||||
return 'Entregado';
|
||||
case 'cancelled':
|
||||
return 'Cancelado';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <Clock className="h-4 w-4" />;
|
||||
case 'confirmed':
|
||||
return <AlertCircle className="h-4 w-4" />;
|
||||
case 'delivered':
|
||||
return <CheckCircle className="h-4 w-4" />;
|
||||
case 'cancelled':
|
||||
return <AlertCircle className="h-4 w-4" />;
|
||||
default:
|
||||
return <Clock className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredOrders = orders.filter(order => {
|
||||
if (activeTab === 'all') return true;
|
||||
if (activeTab === 'pending') return order.status === 'pending' || order.status === 'confirmed';
|
||||
if (activeTab === 'delivered') return order.status === 'delivered';
|
||||
return true;
|
||||
});
|
||||
|
||||
const handleDeleteOrder = (orderId: string) => {
|
||||
setOrders(prev => prev.filter(order => order.id !== orderId));
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="animate-pulse space-y-6">
|
||||
<div className="h-8 bg-gray-200 rounded w-1/3"></div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div key={i} className="h-48 bg-gray-200 rounded-xl"></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Gestión de Pedidos</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Administra tus pedidos de ingredientes y consumibles
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowNewOrder(true)}
|
||||
className="mt-4 sm:mt-0 inline-flex items-center px-4 py-2 bg-primary-500 text-white rounded-xl hover:bg-primary-600 transition-all hover:shadow-lg"
|
||||
>
|
||||
<Plus className="h-5 w-5 mr-2" />
|
||||
Nuevo Pedido
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="bg-white rounded-xl shadow-soft p-1">
|
||||
<div className="flex space-x-1">
|
||||
{[
|
||||
{ id: 'all', label: 'Todos', count: orders.length },
|
||||
{ id: 'pending', label: 'Pendientes', count: orders.filter(o => o.status === 'pending' || o.status === 'confirmed').length },
|
||||
{ id: 'delivered', label: 'Entregados', count: orders.filter(o => o.status === 'delivered').length }
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
className={`flex-1 py-2 px-4 text-sm font-medium rounded-lg transition-all ${
|
||||
activeTab === tab.id
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{tab.label} ({tab.count})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Suggestions */}
|
||||
<div className="bg-gradient-to-r from-primary-50 to-orange-50 border border-primary-200 rounded-xl p-6">
|
||||
<div className="flex items-start">
|
||||
<div className="p-2 bg-primary-100 rounded-lg mr-4">
|
||||
<Package className="h-6 w-6 text-primary-600" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-primary-900 mb-2">Sugerencias Inteligentes de Pedidos</h3>
|
||||
<div className="space-y-2 text-sm text-primary-800">
|
||||
<p>• <strong>Harina de trigo:</strong> Stock bajo detectado. Recomendamos pedir 50kg para cubrir 2 semanas.</p>
|
||||
<p>• <strong>Bolsas de papel:</strong> Aumento del 15% en takeaway. Considera aumentar el pedido habitual.</p>
|
||||
<p>• <strong>Café en grano:</strong> Predicción de alta demanda por temperaturas bajas. +20% recomendado.</p>
|
||||
</div>
|
||||
<button className="mt-3 text-primary-700 hover:text-primary-600 font-medium text-sm">
|
||||
Ver todas las sugerencias →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Orders Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredOrders.map((order) => (
|
||||
<div key={order.id} className="bg-white rounded-xl shadow-soft hover:shadow-medium transition-shadow">
|
||||
{/* Order Header */}
|
||||
<div className="p-6 border-b border-gray-100">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold text-gray-900">{order.supplier}</h3>
|
||||
<span className={`px-2 py-1 rounded-lg text-xs font-medium border flex items-center ${getStatusColor(order.status)}`}>
|
||||
{getStatusIcon(order.status)}
|
||||
<span className="ml-1">{getStatusLabel(order.status)}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-sm text-gray-600 mb-2">
|
||||
<Calendar className="h-4 w-4 mr-1" />
|
||||
<span>Entrega: {new Date(order.deliveryDate).toLocaleDateString('es-ES')}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-xs px-2 py-1 rounded ${
|
||||
order.type === 'ingredients'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-blue-100 text-blue-800'
|
||||
}`}>
|
||||
{order.type === 'ingredients' ? 'Ingredientes' : 'Consumibles'}
|
||||
</span>
|
||||
<span className="text-lg font-bold text-gray-900">€{order.total.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order Items */}
|
||||
<div className="p-6">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">Artículos ({order.items.length})</h4>
|
||||
<div className="space-y-2 max-h-32 overflow-y-auto">
|
||||
{order.items.map((item, index) => (
|
||||
<div key={index} className="flex justify-between text-sm">
|
||||
<div className="flex-1">
|
||||
<span className="text-gray-900">{item.name}</span>
|
||||
{item.suggested && (
|
||||
<span className="ml-2 text-xs bg-primary-100 text-primary-700 px-1 py-0.5 rounded">
|
||||
IA
|
||||
</span>
|
||||
)}
|
||||
<div className="text-gray-500 text-xs">
|
||||
{item.quantity} {item.unit} × €{item.price.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-gray-900 font-medium">
|
||||
€{(item.quantity * item.price).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order Actions */}
|
||||
<div className="px-6 pb-6">
|
||||
<div className="flex space-x-2">
|
||||
<button className="flex-1 py-2 px-3 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors flex items-center justify-center">
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
Editar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteOrder(order.id)}
|
||||
className="py-2 px-3 text-sm bg-red-100 text-red-700 rounded-lg hover:bg-red-200 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Empty State */}
|
||||
{filteredOrders.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<Package className="mx-auto h-12 w-12 text-gray-400 mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No hay pedidos</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
{activeTab === 'all'
|
||||
? 'Aún no has creado ningún pedido'
|
||||
: `No hay pedidos ${activeTab === 'pending' ? 'pendientes' : 'entregados'}`
|
||||
}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowNewOrder(true)}
|
||||
className="inline-flex items-center px-4 py-2 bg-primary-500 text-white rounded-xl hover:bg-primary-600 transition-colors"
|
||||
>
|
||||
<Plus className="h-5 w-5 mr-2" />
|
||||
Crear primer pedido
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-primary-100 rounded-lg">
|
||||
<Package className="h-6 w-6 text-primary-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Total Pedidos</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{orders.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-warning-100 rounded-lg">
|
||||
<Clock className="h-6 w-6 text-warning-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Pendientes</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{orders.filter(o => o.status === 'pending' || o.status === 'confirmed').length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-success-100 rounded-lg">
|
||||
<CheckCircle className="h-6 w-6 text-success-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Gasto Mensual</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
€{orders.reduce((sum, order) => sum + order.total, 0).toFixed(0)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Acciones Rápidas
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<button className="p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all text-left">
|
||||
<div className="font-medium text-gray-900">Pedido Automático</div>
|
||||
<div className="text-sm text-gray-500 mt-1">Basado en predicciones IA</div>
|
||||
</button>
|
||||
|
||||
<button className="p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all text-left">
|
||||
<div className="font-medium text-gray-900">Gestión de Proveedores</div>
|
||||
<div className="text-sm text-gray-500 mt-1">Añadir o editar proveedores</div>
|
||||
</button>
|
||||
|
||||
<button className="p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all text-left">
|
||||
<div className="font-medium text-gray-900">Historial de Gastos</div>
|
||||
<div className="text-sm text-gray-500 mt-1">Ver análisis de costos</div>
|
||||
</button>
|
||||
|
||||
<button className="p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all text-left">
|
||||
<div className="font-medium text-gray-900">Configurar Alertas</div>
|
||||
<div className="text-sm text-gray-500 mt-1">Stock bajo y vencimientos</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* New Order Modal Placeholder */}
|
||||
{showNewOrder && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white rounded-2xl p-6 max-w-md w-full">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Nuevo Pedido</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Esta funcionalidad estará disponible próximamente. PanIA analizará tus necesidades
|
||||
y creará pedidos automáticos basados en las predicciones de demanda.
|
||||
</p>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={() => setShowNewOrder(false)}
|
||||
className="flex-1 py-2 px-4 bg-gray-100 text-gray-700 rounded-xl hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Cerrar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowNewOrder(false)}
|
||||
className="flex-1 py-2 px-4 bg-primary-500 text-white rounded-xl hover:bg-primary-600 transition-colors"
|
||||
>
|
||||
Entendido
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrdersPage;
|
||||
616
frontend/src/pages/settings/SettingsPage.tsx
Normal file
616
frontend/src/pages/settings/SettingsPage.tsx
Normal file
@@ -0,0 +1,616 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
User,
|
||||
Bell,
|
||||
Shield,
|
||||
Globe,
|
||||
Smartphone,
|
||||
Mail,
|
||||
LogOut,
|
||||
Save,
|
||||
ChevronRight,
|
||||
MapPin,
|
||||
Clock,
|
||||
DollarSign
|
||||
} from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface SettingsPageProps {
|
||||
user: any;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
interface UserSettings {
|
||||
fullName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
language: string;
|
||||
timezone: string;
|
||||
currency: string;
|
||||
bakeryName: string;
|
||||
bakeryAddress: string;
|
||||
businessType: string;
|
||||
}
|
||||
|
||||
interface NotificationSettings {
|
||||
emailNotifications: boolean;
|
||||
smsNotifications: boolean;
|
||||
dailyReports: boolean;
|
||||
weeklyReports: boolean;
|
||||
forecastAlerts: boolean;
|
||||
stockAlerts: boolean;
|
||||
orderReminders: boolean;
|
||||
}
|
||||
|
||||
const SettingsPage: React.FC<SettingsPageProps> = ({ user, onLogout }) => {
|
||||
const [activeTab, setActiveTab] = useState('profile');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [userSettings, setUserSettings] = useState<UserSettings>({
|
||||
fullName: user.fullName || '',
|
||||
email: user.email || '',
|
||||
phone: '',
|
||||
language: 'es',
|
||||
timezone: 'Europe/Madrid',
|
||||
currency: 'EUR',
|
||||
bakeryName: 'Mi Panadería',
|
||||
bakeryAddress: '',
|
||||
businessType: 'individual'
|
||||
});
|
||||
|
||||
const [notificationSettings, setNotificationSettings] = useState<NotificationSettings>({
|
||||
emailNotifications: true,
|
||||
smsNotifications: false,
|
||||
dailyReports: true,
|
||||
weeklyReports: true,
|
||||
forecastAlerts: true,
|
||||
stockAlerts: true,
|
||||
orderReminders: true
|
||||
});
|
||||
|
||||
const tabs = [
|
||||
{ id: 'profile', label: 'Perfil', icon: User },
|
||||
{ id: 'notifications', label: 'Notificaciones', icon: Bell },
|
||||
{ id: 'security', label: 'Seguridad', icon: Shield },
|
||||
{ id: 'preferences', label: 'Preferencias', icon: Globe },
|
||||
];
|
||||
|
||||
const handleSaveSettings = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
toast.success('Configuración guardada exitosamente');
|
||||
} catch (error) {
|
||||
toast.error('Error al guardar la configuración');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
if (window.confirm('¿Estás seguro de que quieres cerrar sesión?')) {
|
||||
onLogout();
|
||||
}
|
||||
};
|
||||
|
||||
const renderProfileTab = () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Información Personal</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Nombre completo
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={userSettings.fullName}
|
||||
onChange={(e) => setUserSettings(prev => ({ ...prev, fullName: 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Correo electrónico
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={userSettings.email}
|
||||
onChange={(e) => setUserSettings(prev => ({ ...prev, email: 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Teléfono
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={userSettings.phone}
|
||||
onChange={(e) => setUserSettings(prev => ({ ...prev, phone: e.target.value }))}
|
||||
placeholder="+34 600 000 000"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Información del Negocio</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={userSettings.bakeryName}
|
||||
onChange={(e) => setUserSettings(prev => ({ ...prev, bakeryName: 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Dirección
|
||||
</label>
|
||||
<div className="relative">
|
||||
<MapPin className="absolute left-3 top-3 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={userSettings.bakeryAddress}
|
||||
onChange={(e) => setUserSettings(prev => ({ ...prev, bakeryAddress: e.target.value }))}
|
||||
placeholder="Calle Mayor, 123, Madrid"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Tipo de negocio
|
||||
</label>
|
||||
<select
|
||||
value={userSettings.businessType}
|
||||
onChange={(e) => setUserSettings(prev => ({ ...prev, businessType: 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"
|
||||
>
|
||||
<option value="individual">Panadería Individual</option>
|
||||
<option value="central_workshop">Obrador Central</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Horarios de Operación</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Hora de apertura
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
defaultValue="07:00"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Hora de cierre
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
defaultValue="20:00"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
Días de operación
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-2 sm:grid-cols-7">
|
||||
{['L', 'M', 'X', 'J', 'V', 'S', 'D'].map((day, index) => (
|
||||
<label key={day} className="flex items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
defaultChecked={index < 6} // Monday to Saturday checked by default
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-10 h-10 bg-gray-200 peer-checked:bg-primary-500 peer-checked:text-white rounded-lg flex items-center justify-center font-medium text-sm cursor-pointer transition-colors">
|
||||
{day}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Integración con POS</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-900">Sistema POS Conectado</h4>
|
||||
<p className="text-sm text-gray-600">Sincroniza ventas automáticamente</p>
|
||||
</div>
|
||||
<span className="px-2 py-1 bg-red-100 text-red-800 rounded text-xs">
|
||||
Desconectado
|
||||
</span>
|
||||
</div>
|
||||
<button className="w-full px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 transition-colors">
|
||||
Conectar Sistema POS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderNotificationsTab = () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Canales de Notificación</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<Mail className="h-5 w-5 text-gray-600 mr-3" />
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Notificaciones por Email</div>
|
||||
<div className="text-sm text-gray-500">Recibe alertas y reportes por correo</div>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationSettings.emailNotifications}
|
||||
onChange={(e) => setNotificationSettings(prev => ({
|
||||
...prev,
|
||||
emailNotifications: e.target.checked
|
||||
}))}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<Smartphone className="h-5 w-5 text-gray-600 mr-3" />
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Notificaciones SMS</div>
|
||||
<div className="text-sm text-gray-500">Alertas urgentes por mensaje de texto</div>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationSettings.smsNotifications}
|
||||
onChange={(e) => setNotificationSettings(prev => ({
|
||||
...prev,
|
||||
smsNotifications: e.target.checked
|
||||
}))}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Tipos de Notificación</h3>
|
||||
<div className="space-y-3">
|
||||
{[
|
||||
{ key: 'dailyReports', label: 'Reportes Diarios', desc: 'Resumen diario de ventas y predicciones' },
|
||||
{ key: 'weeklyReports', label: 'Reportes Semanales', desc: 'Análisis semanal de rendimiento' },
|
||||
{ key: 'forecastAlerts', label: 'Alertas de Predicción', desc: 'Cambios significativos en demanda' },
|
||||
{ key: 'stockAlerts', label: 'Alertas de Stock', desc: 'Inventario bajo o próximos vencimientos' },
|
||||
{ key: 'orderReminders', label: 'Recordatorios de Pedidos', desc: 'Próximas entregas y fechas límite' }
|
||||
].map((item) => (
|
||||
<div key={item.key} className="flex items-center justify-between p-3 border border-gray-200 rounded-lg">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{item.label}</div>
|
||||
<div className="text-sm text-gray-500">{item.desc}</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notificationSettings[item.key as keyof NotificationSettings] as boolean}
|
||||
onChange={(e) => setNotificationSettings(prev => ({
|
||||
...prev,
|
||||
[item.key]: e.target.checked
|
||||
}))}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSecurityTab = () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Cambiar Contraseña</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Contraseña actual
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
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="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Nueva contraseña
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
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="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Confirmar nueva contraseña
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
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="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button className="w-full sm:w-auto px-6 py-3 bg-primary-500 text-white rounded-xl hover:bg-primary-600 transition-colors">
|
||||
Actualizar Contraseña
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Sesiones Activas</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Navegador actual</div>
|
||||
<div className="text-sm text-gray-500">Chrome en Windows • Madrid, España</div>
|
||||
<div className="text-xs text-green-600 mt-1">Sesión actual</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Mobile App</div>
|
||||
<div className="text-sm text-gray-500">iPhone • Hace 2 días</div>
|
||||
</div>
|
||||
<button className="text-red-600 hover:text-red-700 text-sm">
|
||||
Cerrar sesión
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4 text-red-600">Zona Peligrosa</h3>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<h4 className="font-medium text-red-900 mb-2">Eliminar Cuenta</h4>
|
||||
<p className="text-red-800 text-sm mb-4">
|
||||
Esta acción eliminará permanentemente tu cuenta y todos los datos asociados.
|
||||
No se puede deshacer.
|
||||
</p>
|
||||
<button className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm">
|
||||
Eliminar Cuenta
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderPreferencesTab = () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Configuración Regional</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<Globe className="inline h-4 w-4 mr-1" />
|
||||
Idioma
|
||||
</label>
|
||||
<select
|
||||
value={userSettings.language}
|
||||
onChange={(e) => setUserSettings(prev => ({ ...prev, language: 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"
|
||||
>
|
||||
<option value="es">Español</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<Clock className="inline h-4 w-4 mr-1" />
|
||||
Zona horaria
|
||||
</label>
|
||||
<select
|
||||
value={userSettings.timezone}
|
||||
onChange={(e) => setUserSettings(prev => ({ ...prev, timezone: 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"
|
||||
>
|
||||
<option value="Europe/Madrid">Europa/Madrid (CET)</option>
|
||||
<option value="Europe/London">Europa/Londres (GMT)</option>
|
||||
<option value="America/New_York">América/Nueva York (EST)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<DollarSign className="inline h-4 w-4 mr-1" />
|
||||
Moneda
|
||||
</label>
|
||||
<select
|
||||
value={userSettings.currency}
|
||||
onChange={(e) => setUserSettings(prev => ({ ...prev, currency: 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"
|
||||
>
|
||||
<option value="EUR">Euro (€)</option>
|
||||
<option value="USD">Dólar americano ($)</option>
|
||||
<option value="GBP">Libra esterlina (£)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Exportar Datos</h3>
|
||||
<div className="space-y-3">
|
||||
<button className="w-full p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all text-left">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Exportar todas las predicciones</div>
|
||||
<div className="text-sm text-gray-500">Descargar historial completo en CSV</div>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button className="w-full p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all text-left">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Exportar datos de ventas</div>
|
||||
<div className="text-sm text-gray-500">Historial de ventas y análisis</div>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button className="w-full p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all text-left">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Exportar configuración</div>
|
||||
<div className="text-sm text-gray-500">Respaldo de toda la configuración</div>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'profile':
|
||||
return renderProfileTab();
|
||||
case 'notifications':
|
||||
return renderNotificationsTab();
|
||||
case 'security':
|
||||
return renderSecurityTab();
|
||||
case 'preferences':
|
||||
return renderPreferencesTab();
|
||||
default:
|
||||
return renderProfileTab();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-2">Configuración</h1>
|
||||
<p className="text-gray-600">
|
||||
Administra tu cuenta y personaliza tu experiencia en PanIA
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||
{/* Sidebar Navigation */}
|
||||
<div className="lg:col-span-1">
|
||||
<nav className="space-y-1">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`w-full flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-all ${
|
||||
activeTab === tab.id
|
||||
? 'bg-primary-100 text-primary-700 shadow-soft'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-5 w-5 mr-3" />
|
||||
{tab.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Logout Button */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full flex items-center px-4 py-3 text-sm font-medium text-red-600 hover:text-red-700 hover:bg-red-50 rounded-lg transition-all mt-6"
|
||||
>
|
||||
<LogOut className="h-5 w-5 mr-3" />
|
||||
Cerrar Sesión
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="lg:col-span-3">
|
||||
<div className="bg-white rounded-xl shadow-soft p-6">
|
||||
{renderTabContent()}
|
||||
|
||||
{/* Save Button */}
|
||||
{(activeTab === 'profile' || activeTab === 'notifications' || activeTab === 'preferences') && (
|
||||
<div className="mt-8 pt-6 border-t border-gray-200">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-gray-600 mb-4 sm:mb-0">
|
||||
Los cambios se guardarán automáticamente
|
||||
</p>
|
||||
<button
|
||||
onClick={handleSaveSettings}
|
||||
disabled={isLoading}
|
||||
className="inline-flex items-center px-6 py-3 bg-primary-500 text-white rounded-xl hover:bg-primary-600 transition-all hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Guardando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Guardar Cambios
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPage;
|
||||
22
frontend/src/store/index.ts
Normal file
22
frontend/src/store/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/ src/store/index.ts
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import authSlice from './slices/authSlice';
|
||||
import tenantSlice from './slices/tenantSlice';
|
||||
import forecastSlice from './slices/forecastSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
auth: authSlice,
|
||||
tenant: tenantSlice,
|
||||
forecast: forecastSlice,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware({
|
||||
serializableCheck: {
|
||||
ignoredActions: ['persist/PERSIST'],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
64
frontend/src/store/slices/authSlice.ts
Normal file
64
frontend/src/store/slices/authSlice.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
// src/store/slices/authSlice.ts
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
fullName: string;
|
||||
role: string;
|
||||
isOnboardingComplete: boolean;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
isAuthenticated: boolean;
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialState: AuthState = {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
token: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const authSlice = createSlice({
|
||||
name: 'auth',
|
||||
initialState,
|
||||
reducers: {
|
||||
loginStart: (state) => {
|
||||
state.isLoading = true;
|
||||
state.error = null;
|
||||
},
|
||||
loginSuccess: (state, action: PayloadAction<{ user: User; token: string }>) => {
|
||||
state.isAuthenticated = true;
|
||||
state.user = action.payload.user;
|
||||
state.token = action.payload.token;
|
||||
state.isLoading = false;
|
||||
state.error = null;
|
||||
},
|
||||
loginFailure: (state, action: PayloadAction<string>) => {
|
||||
state.isAuthenticated = false;
|
||||
state.user = null;
|
||||
state.token = null;
|
||||
state.isLoading = false;
|
||||
state.error = action.payload;
|
||||
},
|
||||
logout: (state) => {
|
||||
state.isAuthenticated = false;
|
||||
state.user = null;
|
||||
state.token = null;
|
||||
state.isLoading = false;
|
||||
state.error = null;
|
||||
},
|
||||
clearError: (state) => {
|
||||
state.error = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { loginStart, loginSuccess, loginFailure, logout, clearError } = authSlice.actions;
|
||||
export default authSlice.reducer;
|
||||
0
frontend/src/store/slices/forecastSlice.ts
Normal file
0
frontend/src/store/slices/forecastSlice.ts
Normal file
0
frontend/src/store/slices/tenantSlice.ts
Normal file
0
frontend/src/store/slices/tenantSlice.ts
Normal file
@@ -1,6 +1,96 @@
|
||||
/* src/styles/globals.css */
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
@import 'tailwindcss/base';
|
||||
@import 'tailwindcss/components';
|
||||
@import 'tailwindcss/utilities';
|
||||
|
||||
/* You can add any custom global CSS here */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Poppins:wght@400;500;600;700&display=swap');
|
||||
|
||||
/* Base styles */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
/* Focus styles */
|
||||
.focus-ring:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(249, 115, 22, 0.1);
|
||||
border-color: #f97316;
|
||||
}
|
||||
|
||||
/* Animation classes */
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom components */
|
||||
.bakery-card {
|
||||
@apply bg-white rounded-xl shadow-soft p-6 hover:shadow-medium transition-all duration-200;
|
||||
}
|
||||
|
||||
.confidence-high {
|
||||
@apply bg-green-100 text-green-800 border-green-200;
|
||||
}
|
||||
|
||||
.confidence-medium {
|
||||
@apply bg-yellow-100 text-yellow-800 border-yellow-200;
|
||||
}
|
||||
|
||||
.confidence-low {
|
||||
@apply bg-red-100 text-red-800 border-red-200;
|
||||
}
|
||||
|
||||
/* Mobile-first responsive design helpers */
|
||||
@media (max-width: 640px) {
|
||||
.mobile-padding {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.mobile-text-sm {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user