Add new Frontend

This commit is contained in:
Urtzi Alfaro
2025-08-03 19:23:20 +02:00
parent 03e9dc6469
commit 376ce3ee0d
45 changed files with 5352 additions and 9230 deletions

246
frontend/src/App.tsx Normal file
View 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;

View 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;

View File

@@ -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}</>;
};

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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>
</>
);
};

View 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;

View File

@@ -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);
};

View 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;

View 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;

View 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;

View 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;

View File

@@ -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
View 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
View 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>,
)

View File

@@ -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;

View 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;

View 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;

View 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

View File

@@ -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>&copy; {new Date().getFullYear()} PanIA. Todos los derechos reservados.</p>
</div>
</footer>
</div>
</ErrorBoundary>
);
};
export default DashboardPage;

View 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;

View File

@@ -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>&copy; {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;

View 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 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>&copy; 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;

View File

@@ -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

View 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;

View 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;

View 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;

View 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;

View 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;

View File

View 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;
}
}