Add new Frontend
This commit is contained in:
@@ -590,9 +590,8 @@ services:
|
|||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile.${ENVIRONMENT}
|
dockerfile: Dockerfile.${ENVIRONMENT}
|
||||||
args:
|
args:
|
||||||
- NEXT_PUBLIC_API_URL=${FRONTEND_API_URL}
|
- NODE_ENV=development
|
||||||
- NEXT_PUBLIC_WS_URL=${FRONTEND_WS_URL}
|
- VITE_API_URL=http://localhost:8000
|
||||||
- NEXT_PUBLIC_ENVIRONMENT=${ENVIRONMENT}
|
|
||||||
image: bakery/dashboard:${IMAGE_TAG}
|
image: bakery/dashboard:${IMAGE_TAG}
|
||||||
container_name: bakery-dashboard
|
container_name: bakery-dashboard
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -1,18 +1,32 @@
|
|||||||
|
# Development Dockerfile
|
||||||
FROM node:18-alpine
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
# Install curl for healthchecks
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files first (better caching)
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Install dependencies
|
# Install all dependencies (including dev dependencies)
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
# Copy application files
|
# Copy source code
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Expose port
|
# Create non-root user for security
|
||||||
|
RUN addgroup -g 1001 -S nodejs
|
||||||
|
RUN adduser -S reactjs -u 1001
|
||||||
|
USER reactjs
|
||||||
|
|
||||||
|
# Expose port 3000 (Vite default)
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Start development server
|
# Add healthcheck
|
||||||
CMD ["npm", "run", "dev"]
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:3000/ || exit 1
|
||||||
|
|
||||||
|
# Start development server with host binding
|
||||||
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
|
||||||
const nextConfig = {
|
|
||||||
reactStrictMode: true,
|
|
||||||
swcMinify: true,
|
|
||||||
i18n: {
|
|
||||||
locales: ['es', 'en'],
|
|
||||||
defaultLocale: 'es',
|
|
||||||
},
|
|
||||||
images: {
|
|
||||||
domains: ['bakeryforecast.es'],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = nextConfig
|
|
||||||
6426
frontend/package-lock.json
generated
6426
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,39 +1,71 @@
|
|||||||
{
|
{
|
||||||
"name": "bakery-dashboard",
|
"name": "pania-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"description": "AI-powered bakery demand forecasting platform for Madrid",
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "vite",
|
||||||
"build": "next build",
|
"build": "tsc && vite build",
|
||||||
"start": "next start",
|
"preview": "vite preview",
|
||||||
"lint": "next lint",
|
"test": "vitest",
|
||||||
"type-check": "tsc --noEmit"
|
"test:ui": "vitest --ui",
|
||||||
|
"test:coverage": "vitest --coverage",
|
||||||
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||||
|
"lint:fix": "eslint . --ext ts,tsx --fix"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "14.0.0",
|
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"chart.js": "^4.4.0",
|
"react-router-dom": "^6.15.0",
|
||||||
"react-chartjs-2": "^5.2.0",
|
"@reduxjs/toolkit": "^1.9.5",
|
||||||
"axios": "^1.6.0",
|
"react-redux": "^8.1.2",
|
||||||
"@tailwindcss/forms": "^0.5.7",
|
"i18next": "^23.4.4",
|
||||||
|
"react-i18next": "^13.1.2",
|
||||||
|
"i18next-browser-languagedetector": "^7.1.0",
|
||||||
|
"react-hook-form": "^7.45.4",
|
||||||
|
"@hookform/resolvers": "^3.3.1",
|
||||||
|
"zod": "^3.22.2",
|
||||||
|
"recharts": "^2.8.0",
|
||||||
"date-fns": "^2.30.0",
|
"date-fns": "^2.30.0",
|
||||||
"react-hook-form": "^7.47.0",
|
"date-fns-tz": "^2.0.0",
|
||||||
"zustand": "^4.4.6",
|
"react-hot-toast": "^2.4.1",
|
||||||
"@headlessui/react": "^2.0.0",
|
"lucide-react": "^0.263.1",
|
||||||
"@heroicons/react": "^2.0.18",
|
"clsx": "^2.0.0",
|
||||||
"framer-motion": "^10.16.4",
|
"tailwind-merge": "^1.14.0"
|
||||||
"jwt-decode": "^4.0.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.8.0",
|
"@types/react": "^18.2.15",
|
||||||
"@types/react": "^18.2.0",
|
"@types/react-dom": "^18.2.7",
|
||||||
"@types/react-dom": "^18.2.0",
|
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||||
"autoprefixer": "^10.4.16",
|
"@typescript-eslint/parser": "^6.0.0",
|
||||||
"eslint": "^8.52.0",
|
"@vitejs/plugin-react": "^4.0.3",
|
||||||
"eslint-config-next": "14.0.0",
|
"autoprefixer": "^10.4.14",
|
||||||
"postcss": "^8.4.31",
|
"eslint": "^8.45.0",
|
||||||
"tailwindcss": "^3.3.5",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"typescript": "^5.2.2"
|
"eslint-plugin-react-refresh": "^0.4.3",
|
||||||
}
|
"postcss": "^8.4.27",
|
||||||
|
"tailwindcss": "^3.3.0",
|
||||||
|
"typescript": "^5.0.2",
|
||||||
|
"vite": "^4.4.5",
|
||||||
|
"vitest": "^0.34.1",
|
||||||
|
"@vitest/ui": "^0.34.1",
|
||||||
|
"@testing-library/react": "^13.4.0",
|
||||||
|
"@testing-library/jest-dom": "^5.17.0",
|
||||||
|
"@testing-library/user-event": "^14.4.3"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"bakery",
|
||||||
|
"forecasting",
|
||||||
|
"ai",
|
||||||
|
"madrid",
|
||||||
|
"react",
|
||||||
|
"typescript",
|
||||||
|
"tailwind"
|
||||||
|
],
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/pania-es/frontend"
|
||||||
|
},
|
||||||
|
"author": "PanIA Team",
|
||||||
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
// postcss.config.js
|
|
||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
170
frontend/public/index.html
Normal file
170
frontend/public/index.html
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
|
||||||
|
<!-- Primary Meta Tags -->
|
||||||
|
<title>PanIA - Inteligencia Artificial para tu Panadería en Madrid</title>
|
||||||
|
<meta name="title" content="PanIA - Inteligencia Artificial para tu Panadería en Madrid" />
|
||||||
|
<meta name="description" content="La primera IA diseñada para panaderías españolas. Reduce desperdicios hasta un 25%, aumenta ganancias y optimiza producción con predicciones precisas en Madrid." />
|
||||||
|
<meta name="keywords" content="inteligencia artificial panadería, predicción ventas panadería, IA para panaderías Madrid, sistema predicción panadería, optimización panadería, reducir desperdicios panadería" />
|
||||||
|
<meta name="author" content="PanIA Team" />
|
||||||
|
<meta name="robots" content="index, follow" />
|
||||||
|
|
||||||
|
<!-- Open Graph / Facebook -->
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content="https://pania.es/" />
|
||||||
|
<meta property="og:title" content="PanIA - Inteligencia Artificial para tu Panadería en Madrid" />
|
||||||
|
<meta property="og:description" content="La primera IA diseñada para panaderías españolas. Reduce desperdicios hasta un 25% y optimiza tu producción con predicciones precisas." />
|
||||||
|
<meta property="og:image" content="https://pania.es/og-image.jpg" />
|
||||||
|
<meta property="og:locale" content="es_ES" />
|
||||||
|
<meta property="og:site_name" content="PanIA" />
|
||||||
|
|
||||||
|
<!-- Twitter -->
|
||||||
|
<meta property="twitter:card" content="summary_large_image" />
|
||||||
|
<meta property="twitter:url" content="https://pania.es/" />
|
||||||
|
<meta property="twitter:title" content="PanIA - Inteligencia Artificial para tu Panadería en Madrid" />
|
||||||
|
<meta property="twitter:description" content="La primera IA diseñada para panaderías españolas. Reduce desperdicios hasta un 25% y optimiza tu producción con predicciones precisas." />
|
||||||
|
<meta property="twitter:image" content="https://pania.es/twitter-image.jpg" />
|
||||||
|
|
||||||
|
<!-- Favicon -->
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
|
|
||||||
|
<!-- Preconnect to important domains -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
|
||||||
|
<!-- Google Fonts -->
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Poppins:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
|
||||||
|
<!-- Local Business Schema.org markup -->
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@type": "SoftwareApplication",
|
||||||
|
"name": "PanIA",
|
||||||
|
"description": "Inteligencia Artificial para panaderías en Madrid",
|
||||||
|
"url": "https://pania.es",
|
||||||
|
"applicationCategory": "BusinessApplication",
|
||||||
|
"operatingSystem": "Web",
|
||||||
|
"offers": {
|
||||||
|
"@type": "Offer",
|
||||||
|
"price": "0",
|
||||||
|
"priceCurrency": "EUR",
|
||||||
|
"description": "30 días de prueba gratuita"
|
||||||
|
},
|
||||||
|
"provider": {
|
||||||
|
"@type": "Organization",
|
||||||
|
"name": "PanIA",
|
||||||
|
"url": "https://pania.es",
|
||||||
|
"address": {
|
||||||
|
"@type": "PostalAddress",
|
||||||
|
"addressLocality": "Madrid",
|
||||||
|
"@country": "España"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"targetAudience": {
|
||||||
|
"@type": "Audience",
|
||||||
|
"name": "Panaderías en Madrid"
|
||||||
|
},
|
||||||
|
"featureList": [
|
||||||
|
"Predicciones de demanda con IA",
|
||||||
|
"Reducción de desperdicios",
|
||||||
|
"Optimización de producción",
|
||||||
|
"Gestión inteligente de pedidos"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Performance and Analytics -->
|
||||||
|
<link rel="dns-prefetch" href="//api.pania.es" />
|
||||||
|
|
||||||
|
<!-- Prevent FOUC (Flash of Unstyled Content) -->
|
||||||
|
<style>
|
||||||
|
/* Critical CSS for initial paint */
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background-color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading spinner for initial load */
|
||||||
|
.initial-loading {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(135deg, #fff7ed 0%, #fed7aa 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-content {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bakery-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
background: #f97316;
|
||||||
|
border-radius: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
font-size: 32px;
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
color: #9a3412;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.05); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Initial loading screen -->
|
||||||
|
<div id="initial-loading" class="initial-loading">
|
||||||
|
<div class="loading-content">
|
||||||
|
<div class="bakery-icon">🥖</div>
|
||||||
|
<div class="loading-text">Cargando PanIA...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- React app container -->
|
||||||
|
<div id="root"></div>
|
||||||
|
|
||||||
|
<!-- Hide loading screen once React loads -->
|
||||||
|
<script>
|
||||||
|
// Hide loading screen when React app is ready
|
||||||
|
window.addEventListener('DOMContentLoaded', function() {
|
||||||
|
setTimeout(function() {
|
||||||
|
const loadingScreen = document.getElementById('initial-loading');
|
||||||
|
if (loadingScreen) {
|
||||||
|
loadingScreen.style.opacity = '0';
|
||||||
|
loadingScreen.style.transition = 'opacity 0.5s ease-out';
|
||||||
|
setTimeout(() => {
|
||||||
|
loadingScreen.style.display = 'none';
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}, 1000); // Show loading for at least 1 second
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Main application script -->
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
246
frontend/src/App.tsx
Normal file
246
frontend/src/App.tsx
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
const [appState, setAppState] = useState<AppState>({
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: true,
|
||||||
|
user: null,
|
||||||
|
currentPage: 'landing' // 👈 Startimport React, { useState, useEffect } from 'react';
|
||||||
|
import { Toaster } from 'react-hot-toast';
|
||||||
|
|
||||||
|
// Components
|
||||||
|
import LoadingSpinner from './components/ui/LoadingSpinner';
|
||||||
|
import ErrorBoundary from './components/ErrorBoundary';
|
||||||
|
import LandingPage from './pages/landing/LandingPage';
|
||||||
|
import LoginPage from './pages/auth/LoginPage';
|
||||||
|
import RegisterPage from './pages/auth/RegisterPage';
|
||||||
|
import OnboardingPage from './pages/onboarding/OnboardingPage';
|
||||||
|
import DashboardPage from './pages/dashboard/DashboardPage';
|
||||||
|
import ForecastPage from './pages/forecast/ForecastPage';
|
||||||
|
import OrdersPage from './pages/orders/OrdersPage';
|
||||||
|
import SettingsPage from './pages/settings/SettingsPage';
|
||||||
|
import Layout from './components/layout/Layout';
|
||||||
|
|
||||||
|
// Store and types
|
||||||
|
import { store } from './store';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
|
||||||
|
// i18n
|
||||||
|
import './i18n';
|
||||||
|
|
||||||
|
// Global styles
|
||||||
|
import './styles/globals.css';
|
||||||
|
|
||||||
|
type CurrentPage = 'landing' | 'login' | 'register' | 'onboarding' | 'dashboard' | 'forecast' | 'orders' | 'settings';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
fullName: string;
|
||||||
|
role: string;
|
||||||
|
isOnboardingComplete: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AppState {
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
user: User | null;
|
||||||
|
currentPage: CurrentPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LoadingFallback = () => (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<div className="text-center">
|
||||||
|
<LoadingSpinner size="lg" />
|
||||||
|
<p className="mt-4 text-gray-600">Cargando PanIA...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const App: React.FC = () => {
|
||||||
|
const [appState, setAppState] = useState<AppState>({
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: true,
|
||||||
|
user: null,
|
||||||
|
currentPage: 'landing' // 👈 Start with landing page
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize app and check authentication
|
||||||
|
useEffect(() => {
|
||||||
|
const initializeApp = async () => {
|
||||||
|
try {
|
||||||
|
// Check for stored auth token
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
const userData = localStorage.getItem('user_data');
|
||||||
|
|
||||||
|
if (token && userData) {
|
||||||
|
const user = JSON.parse(userData);
|
||||||
|
setAppState({
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
user,
|
||||||
|
currentPage: user.isOnboardingComplete ? 'dashboard' : 'onboarding'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setAppState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isLoading: false,
|
||||||
|
currentPage: 'landing' // 👈 Show landing page for non-authenticated users
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('App initialization error:', error);
|
||||||
|
setAppState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isLoading: false,
|
||||||
|
currentPage: 'landing' // 👈 Fallback to landing page
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initializeApp();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLogin = (user: User, token: string) => {
|
||||||
|
localStorage.setItem('auth_token', token);
|
||||||
|
localStorage.setItem('user_data', JSON.stringify(user));
|
||||||
|
|
||||||
|
setAppState({
|
||||||
|
isAuthenticated: true,
|
||||||
|
isLoading: false,
|
||||||
|
user,
|
||||||
|
currentPage: user.isOnboardingComplete ? 'dashboard' : 'onboarding'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
localStorage.removeItem('auth_token');
|
||||||
|
localStorage.removeItem('user_data');
|
||||||
|
|
||||||
|
setAppState({
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
user: null,
|
||||||
|
currentPage: 'landing' // 👈 Return to landing page after logout
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOnboardingComplete = () => {
|
||||||
|
const updatedUser = { ...appState.user!, isOnboardingComplete: true };
|
||||||
|
localStorage.setItem('user_data', JSON.stringify(updatedUser));
|
||||||
|
|
||||||
|
setAppState(prev => ({
|
||||||
|
...prev,
|
||||||
|
user: updatedUser,
|
||||||
|
currentPage: 'dashboard'
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateTo = (page: CurrentPage) => {
|
||||||
|
setAppState(prev => ({ ...prev, currentPage: page }));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (appState.isLoading) {
|
||||||
|
return <LoadingFallback />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderCurrentPage = () => {
|
||||||
|
// Public pages (non-authenticated)
|
||||||
|
if (!appState.isAuthenticated) {
|
||||||
|
switch (appState.currentPage) {
|
||||||
|
case 'login':
|
||||||
|
return (
|
||||||
|
<LoginPage
|
||||||
|
onLogin={handleLogin}
|
||||||
|
onNavigateToRegister={() => navigateTo('register')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'register':
|
||||||
|
return (
|
||||||
|
<RegisterPage
|
||||||
|
onLogin={handleLogin}
|
||||||
|
onNavigateToLogin={() => navigateTo('login')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<LandingPage
|
||||||
|
onNavigateToLogin={() => navigateTo('login')}
|
||||||
|
onNavigateToRegister={() => navigateTo('register')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticated pages
|
||||||
|
if (!appState.user?.isOnboardingComplete && appState.currentPage !== 'settings') {
|
||||||
|
return (
|
||||||
|
<OnboardingPage
|
||||||
|
user={appState.user!}
|
||||||
|
onComplete={handleOnboardingComplete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main app pages with layout
|
||||||
|
const pageComponent = () => {
|
||||||
|
switch (appState.currentPage) {
|
||||||
|
case 'forecast':
|
||||||
|
return <ForecastPage />;
|
||||||
|
case 'orders':
|
||||||
|
return <OrdersPage />;
|
||||||
|
case 'settings':
|
||||||
|
return <SettingsPage user={appState.user!} onLogout={handleLogout} />;
|
||||||
|
default:
|
||||||
|
return <DashboardPage user={appState.user!} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout
|
||||||
|
user={appState.user!}
|
||||||
|
currentPage={appState.currentPage}
|
||||||
|
onNavigate={navigateTo}
|
||||||
|
onLogout={handleLogout}
|
||||||
|
>
|
||||||
|
{pageComponent()}
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Provider store={store}>
|
||||||
|
<ErrorBoundary>
|
||||||
|
<div className="App min-h-screen bg-gray-50">
|
||||||
|
{renderCurrentPage()}
|
||||||
|
|
||||||
|
{/* Global Toast Notifications */}
|
||||||
|
<Toaster
|
||||||
|
position="top-right"
|
||||||
|
toastOptions={{
|
||||||
|
duration: 4000,
|
||||||
|
style: {
|
||||||
|
background: '#fff',
|
||||||
|
color: '#333',
|
||||||
|
boxShadow: '0 4px 25px -5px rgba(0, 0, 0, 0.1)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '16px',
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
iconTheme: {
|
||||||
|
primary: '#22c55e',
|
||||||
|
secondary: '#fff',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
iconTheme: {
|
||||||
|
primary: '#ef4444',
|
||||||
|
secondary: '#fff',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
68
frontend/src/components/ErrorBoundary.tsx
Normal file
68
frontend/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
// src/components/ErrorBoundary.tsx
|
||||||
|
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ErrorBoundary extends Component<Props, State> {
|
||||||
|
public state: State = {
|
||||||
|
hasError: false
|
||||||
|
};
|
||||||
|
|
||||||
|
public static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||||
|
<div className="max-w-md w-full text-center">
|
||||||
|
<div className="bg-white rounded-2xl p-8 shadow-strong">
|
||||||
|
<div className="mx-auto h-16 w-16 bg-red-100 rounded-full flex items-center justify-center mb-6">
|
||||||
|
<AlertTriangle className="h-8 w-8 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 mb-4">
|
||||||
|
¡Oops! Algo salió mal
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
Ha ocurrido un error inesperado. Por favor, recarga la página.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="w-full bg-primary-500 text-white py-3 px-4 rounded-xl font-medium hover:bg-primary-600 transition-colors"
|
||||||
|
>
|
||||||
|
Recargar página
|
||||||
|
</button>
|
||||||
|
{process.env.NODE_ENV === 'development' && (
|
||||||
|
<details className="mt-4 text-left">
|
||||||
|
<summary className="text-sm text-gray-500 cursor-pointer">
|
||||||
|
Detalles del error
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-2 text-xs text-red-600 bg-red-50 p-2 rounded overflow-auto">
|
||||||
|
{this.state.error?.stack}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorBoundary;
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
// src/components/auth/ProtectedRoute.tsx
|
|
||||||
import React from 'react';
|
|
||||||
import { Navigate, useLocation } from 'react-router-dom';
|
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
|
||||||
|
|
||||||
interface ProtectedRouteProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
requireAuth?: boolean;
|
|
||||||
redirectTo?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
|
||||||
children,
|
|
||||||
requireAuth = true,
|
|
||||||
redirectTo = '/login'
|
|
||||||
}) => {
|
|
||||||
const { isAuthenticated, isLoading } = useAuth();
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requireAuth && !isAuthenticated) {
|
|
||||||
return <Navigate to={redirectTo} state={{ from: location }} replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
};
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
// ForecastChart.tsx (Modified)
|
|
||||||
import React from 'react';
|
|
||||||
import {
|
|
||||||
Chart as ChartJS,
|
|
||||||
CategoryScale,
|
|
||||||
LinearScale,
|
|
||||||
PointElement,
|
|
||||||
LineElement,
|
|
||||||
Title,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
Filler,
|
|
||||||
} from 'chart.js';
|
|
||||||
import { Line } from 'react-chartjs-2';
|
|
||||||
import { format } from 'date-fns';
|
|
||||||
import { es } from 'date-fns/locale';
|
|
||||||
|
|
||||||
ChartJS.register(
|
|
||||||
CategoryScale,
|
|
||||||
LinearScale,
|
|
||||||
PointElement,
|
|
||||||
LineElement,
|
|
||||||
Title,
|
|
||||||
Tooltip,
|
|
||||||
Legend,
|
|
||||||
Filler
|
|
||||||
);
|
|
||||||
|
|
||||||
interface ForecastData {
|
|
||||||
date: string;
|
|
||||||
predicted_quantity: number;
|
|
||||||
confidence_lower: number;
|
|
||||||
confidence_upper: number;
|
|
||||||
actual_quantity?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ForecastChartProps {
|
|
||||||
data: ForecastData[];
|
|
||||||
productName: string;
|
|
||||||
// height?: number; // Removed fixed height prop
|
|
||||||
}
|
|
||||||
|
|
||||||
const ForecastChart: React.FC<ForecastChartProps> = ({ data, productName /*, height = 400*/ }) => { // Removed height from props
|
|
||||||
const chartData = {
|
|
||||||
labels: data.map(d => format(new Date(d.date), 'dd MMM', { locale: es })),
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
label: 'Predicción',
|
|
||||||
data: data.map(d => d.predicted_quantity),
|
|
||||||
borderColor: 'rgb(59, 130, 246)',
|
|
||||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
|
||||||
borderWidth: 2,
|
|
||||||
tension: 0.1,
|
|
||||||
pointRadius: 4,
|
|
||||||
pointHoverRadius: 6,
|
|
||||||
fill: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Intervalo Inferior',
|
|
||||||
data: data.map(d => d.confidence_lower),
|
|
||||||
borderColor: 'rgba(59, 130, 246, 0.3)',
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
borderDash: [5, 5],
|
|
||||||
pointRadius: 0,
|
|
||||||
tension: 0.1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Intervalo Superior',
|
|
||||||
data: data.map(d => d.confidence_upper),
|
|
||||||
borderColor: 'rgba(59, 130, 246, 0.3)',
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
borderDash: [5, 5],
|
|
||||||
pointRadius: 0,
|
|
||||||
tension: 0.1,
|
|
||||||
},
|
|
||||||
// Optional: Actual quantity if available
|
|
||||||
...(data[0]?.actual_quantity !== undefined && data.some(d => d.actual_quantity !== undefined) ? [{
|
|
||||||
label: 'Real',
|
|
||||||
data: data.map(d => d.actual_quantity),
|
|
||||||
borderColor: 'rgb(255, 99, 132)',
|
|
||||||
backgroundColor: 'rgba(255, 99, 132, 0.1)',
|
|
||||||
borderWidth: 2,
|
|
||||||
tension: 0.1,
|
|
||||||
pointRadius: 4,
|
|
||||||
pointHoverRadius: 6,
|
|
||||||
hidden: true, // Initially hidden, can be toggled
|
|
||||||
}] : []),
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const chartOptions = {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false, // Ensures the chart fills its parent container's dimensions
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
position: 'top' as const,
|
|
||||||
labels: {
|
|
||||||
font: {
|
|
||||||
size: 12,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
display: true,
|
|
||||||
text: `Predicción de Demanda - ${productName}`,
|
|
||||||
font: {
|
|
||||||
size: 16,
|
|
||||||
weight: 'bold' as const,
|
|
||||||
},
|
|
||||||
padding: {
|
|
||||||
bottom: 20,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
mode: 'index' as const,
|
|
||||||
intersect: false,
|
|
||||||
callbacks: {
|
|
||||||
label: function(context: any) {
|
|
||||||
const label = context.dataset.label || '';
|
|
||||||
const value = context.parsed.y;
|
|
||||||
if (value !== null && value !== undefined) {
|
|
||||||
return `${label}: ${Math.round(value)} unidades`;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
x: {
|
|
||||||
grid: {
|
|
||||||
display: false,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
display: true,
|
|
||||||
text: 'Fecha',
|
|
||||||
font: {
|
|
||||||
size: 14,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
y: {
|
|
||||||
beginAtZero: true,
|
|
||||||
grid: {
|
|
||||||
color: 'rgba(0, 0, 0, 0.05)',
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
display: true,
|
|
||||||
text: 'Cantidad (unidades)',
|
|
||||||
font: {
|
|
||||||
size: 14,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
interaction: {
|
|
||||||
mode: 'nearest' as const,
|
|
||||||
axis: 'x' as const,
|
|
||||||
intersect: false,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return <Line data={chartData} options={chartOptions} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ForecastChart;
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
// src/components/common/ErrorBoundary.tsx
|
|
||||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
children: ReactNode;
|
|
||||||
fallback?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
hasError: boolean;
|
|
||||||
error: Error | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ErrorBoundary extends Component<Props, State> {
|
|
||||||
state: State = {
|
|
||||||
hasError: false,
|
|
||||||
error: null
|
|
||||||
};
|
|
||||||
|
|
||||||
static getDerivedStateFromError(error: Error): State {
|
|
||||||
return { hasError: true, error };
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
|
||||||
console.error('ErrorBoundary caught:', error, errorInfo);
|
|
||||||
|
|
||||||
// Send error to monitoring service
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
|
||||||
// logErrorToService(error, errorInfo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
if (this.state.hasError) {
|
|
||||||
return this.props.fallback || (
|
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-4">
|
|
||||||
Algo salió mal
|
|
||||||
</h1>
|
|
||||||
<p className="text-gray-600 mb-6">
|
|
||||||
Ha ocurrido un error inesperado. Por favor, recarga la página.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => window.location.reload()}
|
|
||||||
className="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700"
|
|
||||||
>
|
|
||||||
Recargar página
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.props.children;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
// src/components/common/NotificationToast.tsx
|
|
||||||
import React, { useEffect } from 'react';
|
|
||||||
import { XMarkIcon } from '@heroicons/react/24/outline';
|
|
||||||
import {
|
|
||||||
CheckCircleIcon,
|
|
||||||
ExclamationCircleIcon,
|
|
||||||
ExclamationTriangleIcon,
|
|
||||||
InformationCircleIcon
|
|
||||||
} from '@heroicons/react/24/solid';
|
|
||||||
|
|
||||||
interface NotificationToastProps {
|
|
||||||
id: string;
|
|
||||||
type: 'success' | 'error' | 'warning' | 'info';
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NotificationToast: React.FC<NotificationToastProps> = ({
|
|
||||||
type,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
onClose
|
|
||||||
}) => {
|
|
||||||
const icons = {
|
|
||||||
success: CheckCircleIcon,
|
|
||||||
error: ExclamationCircleIcon,
|
|
||||||
warning: ExclamationTriangleIcon,
|
|
||||||
info: InformationCircleIcon
|
|
||||||
};
|
|
||||||
|
|
||||||
const colors = {
|
|
||||||
success: 'text-green-400',
|
|
||||||
error: 'text-red-400',
|
|
||||||
warning: 'text-yellow-400',
|
|
||||||
info: 'text-blue-400'
|
|
||||||
};
|
|
||||||
|
|
||||||
const Icon = icons[type];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto ring-1 ring-black ring-opacity-5 overflow-hidden">
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="flex items-start">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<Icon className={`h-6 w-6 ${colors[type]}`} />
|
|
||||||
</div>
|
|
||||||
<div className="ml-3 w-0 flex-1 pt-0.5">
|
|
||||||
<p className="text-sm font-medium text-gray-900">{title}</p>
|
|
||||||
<p className="mt-1 text-sm text-gray-500">{message}</p>
|
|
||||||
</div>
|
|
||||||
<div className="ml-4 flex-shrink-0 flex">
|
|
||||||
<button
|
|
||||||
className="bg-white rounded-md inline-flex text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<XMarkIcon className="h-5 w-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Fragment } from 'react';
|
|
||||||
import { Listbox, Transition } from '@headlessui/react';
|
|
||||||
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/20/solid';
|
|
||||||
|
|
||||||
interface Product {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
displayName: string;
|
|
||||||
icon?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProductSelectorProps {
|
|
||||||
products: Product[];
|
|
||||||
selected: Product;
|
|
||||||
onChange: (product: Product) => void;
|
|
||||||
label?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ProductSelector: React.FC<ProductSelectorProps> = ({
|
|
||||||
products,
|
|
||||||
selected,
|
|
||||||
onChange,
|
|
||||||
label = 'Seleccionar Producto',
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className="w-full">
|
|
||||||
{label && (
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
<Listbox value={selected} onChange={onChange}>
|
|
||||||
<div className="relative">
|
|
||||||
<Listbox.Button className="relative w-full cursor-default rounded-lg bg-white py-2 pl-3 pr-10 text-left shadow-md focus:outline-none focus-visible:border-orange-500 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 focus-visible:ring-offset-orange-300 sm:text-sm">
|
|
||||||
<span className="flex items-center">
|
|
||||||
{selected.icon && (
|
|
||||||
<span className="mr-2 text-lg">{selected.icon}</span>
|
|
||||||
)}
|
|
||||||
<span className="block truncate">{selected.displayName}</span>
|
|
||||||
</span>
|
|
||||||
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
|
||||||
<ChevronUpDownIcon
|
|
||||||
className="h-5 w-5 text-gray-400"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</Listbox.Button>
|
|
||||||
<Transition
|
|
||||||
as={Fragment}
|
|
||||||
leave="transition ease-in duration-100"
|
|
||||||
leaveFrom="opacity-100"
|
|
||||||
leaveTo="opacity-0"
|
|
||||||
>
|
|
||||||
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
|
|
||||||
{products.map((product) => (
|
|
||||||
<Listbox.Option
|
|
||||||
key={product.id}
|
|
||||||
className={({ active }) =>
|
|
||||||
`relative cursor-default select-none py-2 pl-10 pr-4 ${
|
|
||||||
active ? 'bg-orange-100 text-orange-900' : 'text-gray-900'
|
|
||||||
}`
|
|
||||||
}
|
|
||||||
value={product}
|
|
||||||
>
|
|
||||||
{({ selected }) => (
|
|
||||||
<>
|
|
||||||
<span className="flex items-center">
|
|
||||||
{product.icon && (
|
|
||||||
<span className="mr-2 text-lg">{product.icon}</span>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className={`block truncate ${
|
|
||||||
selected ? 'font-medium' : 'font-normal'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{product.displayName}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
{selected ? (
|
|
||||||
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-orange-600">
|
|
||||||
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Listbox.Option>
|
|
||||||
))}
|
|
||||||
</Listbox.Options>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
</Listbox>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Export default products list
|
|
||||||
export const defaultProducts: Product[] = [
|
|
||||||
{ id: 'pan', name: 'pan', displayName: 'Pan', icon: '🍞' },
|
|
||||||
{ id: 'croissant', name: 'croissant', displayName: 'Croissant', icon: '🥐' },
|
|
||||||
{ id: 'napolitana', name: 'napolitana', displayName: 'Napolitana', icon: '🥮' },
|
|
||||||
{ id: 'palmera', name: 'palmera', displayName: 'Palmera', icon: '🍪' },
|
|
||||||
{ id: 'cafe', name: 'cafe', displayName: 'Café', icon: '☕' },
|
|
||||||
{ id: 'bocadillo', name: 'bocadillo', displayName: 'Bocadillo', icon: '🥖' },
|
|
||||||
{ id: 'tarta', name: 'tarta', displayName: 'Tarta', icon: '🎂' },
|
|
||||||
{ id: 'donut', name: 'donut', displayName: 'Donut', icon: '🍩' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default ProductSelector;
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
// src/components/data/SalesUploader.tsx
|
|
||||||
import React, { useRef, useState } from 'react';
|
|
||||||
import { CloudArrowUpIcon } from '@heroicons/react/24/outline';
|
|
||||||
|
|
||||||
interface SalesUploaderProps {
|
|
||||||
onUpload: (file: File) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SalesUploader: React.FC<SalesUploaderProps> = ({ onUpload }) => {
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
|
||||||
|
|
||||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
setIsUploading(true);
|
|
||||||
try {
|
|
||||||
await onUpload(file);
|
|
||||||
} finally {
|
|
||||||
setIsUploading(false);
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept=".csv,.xlsx,.xls"
|
|
||||||
onChange={handleFileSelect}
|
|
||||||
className="hidden"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
disabled={isUploading}
|
|
||||||
className="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<CloudArrowUpIcon className="h-5 w-5 mr-2" />
|
|
||||||
{isUploading ? 'Uploading...' : 'Upload Sales Data'}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
215
frontend/src/components/layout/Layout.tsx
Normal file
215
frontend/src/components/layout/Layout.tsx
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Home,
|
||||||
|
TrendingUp,
|
||||||
|
Package,
|
||||||
|
Settings,
|
||||||
|
Menu,
|
||||||
|
X,
|
||||||
|
LogOut,
|
||||||
|
User,
|
||||||
|
Bell,
|
||||||
|
ChevronDown
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
user: any;
|
||||||
|
currentPage: string;
|
||||||
|
onNavigate: (page: string) => void;
|
||||||
|
onLogout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavigationItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
href: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Layout: React.FC<LayoutProps> = ({
|
||||||
|
children,
|
||||||
|
user,
|
||||||
|
currentPage,
|
||||||
|
onNavigate,
|
||||||
|
onLogout
|
||||||
|
}) => {
|
||||||
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
|
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
const navigation: NavigationItem[] = [
|
||||||
|
{ id: 'dashboard', label: 'Panel Principal', icon: Home, href: '/dashboard' },
|
||||||
|
{ id: 'forecast', label: 'Predicciones', icon: TrendingUp, href: '/forecast' },
|
||||||
|
{ id: 'orders', label: 'Pedidos', icon: Package, href: '/orders' },
|
||||||
|
{ id: 'settings', label: 'Configuración', icon: Settings, href: '/settings' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleNavigate = (pageId: string) => {
|
||||||
|
onNavigate(pageId);
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50">
|
||||||
|
{/* Top Navigation Bar */}
|
||||||
|
<nav className="bg-white shadow-soft border-b border-gray-200">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex justify-between h-16">
|
||||||
|
{/* Left side - Logo and Navigation */}
|
||||||
|
<div className="flex items-center">
|
||||||
|
{/* Mobile menu button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="md:hidden p-2 rounded-md text-gray-600 hover:text-gray-900 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||||
|
>
|
||||||
|
{isMobileMenuOpen ? (
|
||||||
|
<X className="h-6 w-6" />
|
||||||
|
) : (
|
||||||
|
<Menu className="h-6 w-6" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex items-center ml-4 md:ml-0">
|
||||||
|
<div className="h-8 w-8 bg-primary-500 rounded-lg flex items-center justify-center mr-3">
|
||||||
|
<span className="text-white text-sm font-bold">🥖</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-bold text-gray-900">PanIA</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop Navigation */}
|
||||||
|
<div className="hidden md:flex md:ml-10 md:space-x-1">
|
||||||
|
{navigation.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = currentPage === item.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => handleNavigate(item.id)}
|
||||||
|
className={`
|
||||||
|
flex items-center px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200
|
||||||
|
${isActive
|
||||||
|
? 'bg-primary-100 text-primary-700 shadow-soft'
|
||||||
|
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 mr-2" />
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - Notifications and User Menu */}
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
{/* Notifications */}
|
||||||
|
<button className="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors relative">
|
||||||
|
<Bell className="h-5 w-5" />
|
||||||
|
<span className="absolute top-0 right-0 h-2 w-2 bg-red-500 rounded-full"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* User Menu */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsUserMenuOpen(!isUserMenuOpen)}
|
||||||
|
className="flex items-center text-sm bg-white rounded-lg p-2 hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
|
>
|
||||||
|
<div className="h-8 w-8 bg-primary-500 rounded-full flex items-center justify-center mr-2">
|
||||||
|
<User className="h-4 w-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="hidden md:block text-gray-700 font-medium">
|
||||||
|
{user.fullName?.split(' ')[0] || 'Usuario'}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="hidden md:block h-4 w-4 ml-1 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* User Dropdown */}
|
||||||
|
{isUserMenuOpen && (
|
||||||
|
<div className="absolute right-0 mt-2 w-48 bg-white rounded-xl shadow-strong border border-gray-200 py-1 z-50">
|
||||||
|
<div className="px-4 py-3 border-b border-gray-100">
|
||||||
|
<p className="text-sm font-medium text-gray-900">{user.fullName}</p>
|
||||||
|
<p className="text-sm text-gray-500">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
handleNavigate('settings');
|
||||||
|
setIsUserMenuOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 flex items-center"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
|
Configuración
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onLogout();
|
||||||
|
setIsUserMenuOpen(false);
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 flex items-center"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4 mr-2" />
|
||||||
|
Cerrar sesión
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Navigation Menu */}
|
||||||
|
{isMobileMenuOpen && (
|
||||||
|
<div className="md:hidden border-t border-gray-200 bg-white">
|
||||||
|
<div className="px-2 pt-2 pb-3 space-y-1">
|
||||||
|
{navigation.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive = currentPage === item.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
onClick={() => handleNavigate(item.id)}
|
||||||
|
className={`
|
||||||
|
w-full flex items-center px-3 py-2 rounded-lg text-base font-medium transition-all duration-200
|
||||||
|
${isActive
|
||||||
|
? 'bg-primary-100 text-primary-700'
|
||||||
|
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5 mr-3" />
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="flex-1">
|
||||||
|
<div className="max-w-7xl mx-auto">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Click outside handler for dropdowns */}
|
||||||
|
{(isUserMenuOpen || isMobileMenuOpen) && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40"
|
||||||
|
onClick={() => {
|
||||||
|
setIsUserMenuOpen(false);
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
// src/components/training/TrainingProgressCard.tsx
|
|
||||||
import React from 'react';
|
|
||||||
import { useTrainingProgress } from '../../api/hooks/useTrainingProgress';
|
|
||||||
import { CheckCircleIcon, XCircleIcon } from '@heroicons/react/24/outline';
|
|
||||||
|
|
||||||
interface TrainingProgressCardProps {
|
|
||||||
jobId: string;
|
|
||||||
onComplete?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TrainingProgressCard: React.FC<TrainingProgressCardProps> = ({
|
|
||||||
jobId,
|
|
||||||
onComplete
|
|
||||||
}) => {
|
|
||||||
const { progress, error, isComplete, isConnected } = useTrainingProgress(jobId);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (isComplete && onComplete) {
|
|
||||||
onComplete();
|
|
||||||
}
|
|
||||||
}, [isComplete, onComplete]);
|
|
||||||
|
|
||||||
if (!progress) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
|
||||||
<div className="animate-pulse">
|
|
||||||
<div className="h-4 bg-gray-200 rounded w-3/4 mb-4"></div>
|
|
||||||
<div className="h-2 bg-gray-200 rounded"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<h3 className="text-lg font-semibold">Training Progress</h3>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
{isConnected && (
|
|
||||||
<span className="flex items-center text-sm text-green-600">
|
|
||||||
<span className="w-2 h-2 bg-green-600 rounded-full mr-1 animate-pulse"></span>
|
|
||||||
Live
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{progress.status === 'completed' && (
|
|
||||||
<CheckCircleIcon className="w-5 h-5 text-green-600" />
|
|
||||||
)}
|
|
||||||
{progress.status === 'failed' && (
|
|
||||||
<XCircleIcon className="w-5 h-5 text-red-600" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-md text-sm">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between text-sm text-gray-600 mb-1">
|
|
||||||
<span>{progress.current_step}</span>
|
|
||||||
<span>{Math.round(progress.progress)}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className="bg-indigo-600 h-2 rounded-full transition-all duration-300 ease-out"
|
|
||||||
style={{ width: `${progress.progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{progress.estimated_time_remaining && (
|
|
||||||
<p className="text-sm text-gray-600">
|
|
||||||
Tiempo estimado: {formatTime(progress.estimated_time_remaining)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{progress.metrics && (
|
|
||||||
<div className="mt-4 grid grid-cols-2 gap-4">
|
|
||||||
{Object.entries(progress.metrics).map(([key, value]) => (
|
|
||||||
<div key={key} className="text-sm">
|
|
||||||
<span className="text-gray-600">{formatMetricName(key)}:</span>
|
|
||||||
<span className="ml-2 font-medium">{formatMetricValue(value)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Utility functions
|
|
||||||
const formatTime = (seconds: number): string => {
|
|
||||||
if (seconds < 60) return `${seconds}s`;
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
|
||||||
if (minutes < 60) return `${minutes}m`;
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
return `${hours}h ${minutes % 60}m`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatMetricName = (name: string): string => {
|
|
||||||
return name.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatMetricValue = (value: any): string => {
|
|
||||||
if (typeof value === 'number') {
|
|
||||||
return value.toFixed(2);
|
|
||||||
}
|
|
||||||
return String(value);
|
|
||||||
};
|
|
||||||
57
frontend/src/components/ui/Button.tsx
Normal file
57
frontend/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
// src/components/ui/Button.tsx
|
||||||
|
import React from 'react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
import LoadingSpinner from './LoadingSpinner';
|
||||||
|
|
||||||
|
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'primary' | 'secondary' | 'outline' | 'danger';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
isLoading?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button: React.FC<ButtonProps> = ({
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
isLoading = false,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const baseClasses = 'inline-flex items-center justify-center font-medium rounded-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2';
|
||||||
|
|
||||||
|
const variantClasses = {
|
||||||
|
primary: 'bg-primary-500 text-white hover:bg-primary-600 focus:ring-primary-500 shadow-soft hover:shadow-medium',
|
||||||
|
secondary: 'bg-gray-500 text-white hover:bg-gray-600 focus:ring-gray-500 shadow-soft hover:shadow-medium',
|
||||||
|
outline: 'border border-gray-300 text-gray-700 hover:bg-gray-50 focus:ring-primary-500',
|
||||||
|
danger: 'bg-red-500 text-white hover:bg-red-600 focus:ring-red-500 shadow-soft hover:shadow-medium',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'px-3 py-1.5 text-sm',
|
||||||
|
md: 'px-4 py-2.5 text-sm',
|
||||||
|
lg: 'px-6 py-3 text-base',
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDisabled = disabled || isLoading;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={clsx(
|
||||||
|
baseClasses,
|
||||||
|
variantClasses[variant],
|
||||||
|
sizeClasses[size],
|
||||||
|
isDisabled && 'opacity-50 cursor-not-allowed',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
disabled={isDisabled}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{isLoading && <LoadingSpinner size="sm" className="mr-2" />}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Button;
|
||||||
34
frontend/src/components/ui/Card.tsx
Normal file
34
frontend/src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// src/components/ui/Card.tsx
|
||||||
|
import React from 'react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
|
||||||
|
interface CardProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
padding?: 'none' | 'sm' | 'md' | 'lg';
|
||||||
|
}
|
||||||
|
|
||||||
|
const Card: React.FC<CardProps> = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
padding = 'md'
|
||||||
|
}) => {
|
||||||
|
const paddingClasses = {
|
||||||
|
none: '',
|
||||||
|
sm: 'p-4',
|
||||||
|
md: 'p-6',
|
||||||
|
lg: 'p-8'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx(
|
||||||
|
'bg-white rounded-xl shadow-soft',
|
||||||
|
paddingClasses[padding],
|
||||||
|
className
|
||||||
|
)}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Card;
|
||||||
54
frontend/src/components/ui/Input.tsx
Normal file
54
frontend/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// src/components/ui/Input.tsx
|
||||||
|
import React from 'react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
|
||||||
|
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
helperText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Input: React.FC<InputProps> = ({
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
helperText,
|
||||||
|
className,
|
||||||
|
id,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const inputId = id || `input-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{label && (
|
||||||
|
<label
|
||||||
|
htmlFor={inputId}
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-2"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
className={clsx(
|
||||||
|
'w-full px-4 py-3 border rounded-xl transition-all duration-200',
|
||||||
|
'placeholder-gray-400 text-gray-900',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500',
|
||||||
|
error
|
||||||
|
? 'border-red-300 bg-red-50'
|
||||||
|
: 'border-gray-300 hover:border-gray-400',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{error && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{error}</p>
|
||||||
|
)}
|
||||||
|
{helperText && !error && (
|
||||||
|
<p className="mt-1 text-sm text-gray-500">{helperText}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Input;
|
||||||
27
frontend/src/components/ui/LoadingSpinner.tsx
Normal file
27
frontend/src/components/ui/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// src/components/ui/LoadingSpinner.tsx
|
||||||
|
import React from 'react';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface LoadingSpinnerProps {
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LoadingSpinner: React.FC<LoadingSpinnerProps> = ({
|
||||||
|
size = 'md',
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'h-4 w-4',
|
||||||
|
md: 'h-6 w-6',
|
||||||
|
lg: 'h-8 w-8'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Loader2
|
||||||
|
className={`animate-spin text-primary-500 ${sizeClasses[size]} ${className}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoadingSpinner;
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
// frontend/src/contexts/AuthContext.tsx - FIXED VERSION
|
|
||||||
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
|
||||||
// FIXED: Import authService directly, not through the index
|
|
||||||
import { authService } from '../api/services/authService';
|
|
||||||
import { tokenManager } from '../api/auth/tokenManager';
|
|
||||||
import {
|
|
||||||
UserProfile,
|
|
||||||
RegisterRequest,
|
|
||||||
} from '../api/types/api';
|
|
||||||
|
|
||||||
interface AuthContextType {
|
|
||||||
user: UserProfile | null;
|
|
||||||
isAuthenticated: boolean;
|
|
||||||
isLoading: boolean;
|
|
||||||
login: (email: string, password: string) => Promise<void>;
|
|
||||||
register: (data: RegisterRequest) => Promise<void>;
|
|
||||||
logout: () => Promise<void>;
|
|
||||||
updateProfile: (updates: Partial<UserProfile>) => Promise<void>;
|
|
||||||
refreshUser: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | null>(null);
|
|
||||||
|
|
||||||
export const useAuth = () => {
|
|
||||||
const context = useContext(AuthContext);
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useAuth must be used within an AuthProvider');
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
||||||
const [user, setUser] = useState<UserProfile | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
|
|
||||||
// Initialize auth state
|
|
||||||
useEffect(() => {
|
|
||||||
const initAuth = async () => {
|
|
||||||
try {
|
|
||||||
await tokenManager.initialize();
|
|
||||||
|
|
||||||
if (authService.isAuthenticated()) {
|
|
||||||
// Get user from token first (faster), then validate with API
|
|
||||||
const tokenUser = tokenManager.getUserFromToken();
|
|
||||||
if (tokenUser) {
|
|
||||||
setUser({
|
|
||||||
id: tokenUser.user_id,
|
|
||||||
email: tokenUser.email,
|
|
||||||
full_name: tokenUser.full_name,
|
|
||||||
is_active: true,
|
|
||||||
is_verified: tokenUser.is_verified,
|
|
||||||
role: 'user', // Default role
|
|
||||||
language: 'es',
|
|
||||||
timezone: 'Europe/Madrid',
|
|
||||||
created_at: '', // Will be filled by API call
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate with API and get complete profile
|
|
||||||
try {
|
|
||||||
const profile = await authService.getCurrentUser();
|
|
||||||
setUser(profile);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch user profile:', error);
|
|
||||||
// Keep token-based user data if API fails
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Auth initialization failed:', error);
|
|
||||||
// Clear potentially corrupted tokens
|
|
||||||
tokenManager.clearTokens();
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
initAuth();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const login = useCallback(async (email: string, password: string) => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
// Login and store tokens
|
|
||||||
const tokenResponse = await authService.login({ email, password });
|
|
||||||
|
|
||||||
// After login, get user profile
|
|
||||||
const profile = await authService.getCurrentUser();
|
|
||||||
setUser(profile);
|
|
||||||
} catch (error) {
|
|
||||||
setIsLoading(false);
|
|
||||||
throw error; // Re-throw to let components handle the error
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const register = useCallback(async (data: RegisterRequest) => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
// ✅ FIX: Handle registration conflicts properly
|
|
||||||
try {
|
|
||||||
// Try to register first
|
|
||||||
const tokenResponse = await authService.register(data);
|
|
||||||
|
|
||||||
// After successful registration, get user profile
|
|
||||||
const profile = await authService.getCurrentUser();
|
|
||||||
setUser(profile);
|
|
||||||
} catch (registrationError: any) {
|
|
||||||
// ✅ FIX: If user already exists (409), try to login instead
|
|
||||||
if (registrationError.response?.status === 409 ||
|
|
||||||
registrationError.message?.includes('already exists')) {
|
|
||||||
|
|
||||||
console.log('User already exists');
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// If it's not a "user exists" error, re-throw it
|
|
||||||
throw registrationError;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
setIsLoading(false);
|
|
||||||
throw error; // Re-throw to let components handle the error
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
await authService.logout();
|
|
||||||
setUser(null);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Logout error:', error);
|
|
||||||
// Clear local state even if API call fails
|
|
||||||
setUser(null);
|
|
||||||
tokenManager.clearTokens();
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const updateProfile = useCallback(async (updates: Partial<UserProfile>) => {
|
|
||||||
if (!user) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updated = await authService.updateProfile(updates);
|
|
||||||
setUser(updated);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Profile update error:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}, [user]);
|
|
||||||
|
|
||||||
const refreshUser = useCallback(async () => {
|
|
||||||
if (!authService.isAuthenticated()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const profile = await authService.getCurrentUser();
|
|
||||||
setUser(profile);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('User refresh error:', error);
|
|
||||||
// If refresh fails with 401, user might need to re-login
|
|
||||||
if (error.status === 401) {
|
|
||||||
await logout();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [logout]);
|
|
||||||
|
|
||||||
// Set up token refresh interval
|
|
||||||
useEffect(() => {
|
|
||||||
if (!user) return;
|
|
||||||
|
|
||||||
const interval = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
await tokenManager.refreshAccessToken();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Scheduled token refresh failed:', error);
|
|
||||||
// If token refresh fails, user needs to re-login
|
|
||||||
await logout();
|
|
||||||
}
|
|
||||||
}, 60000); // Check every 1 minute
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [user, logout]);
|
|
||||||
|
|
||||||
// Monitor token expiration
|
|
||||||
useEffect(() => {
|
|
||||||
if (!user) return;
|
|
||||||
|
|
||||||
const checkTokenValidity = () => {
|
|
||||||
if (!authService.isAuthenticated()) {
|
|
||||||
console.warn('Token became invalid, logging out user');
|
|
||||||
logout();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check token validity every 30 seconds
|
|
||||||
const interval = setInterval(checkTokenValidity, 30000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [user, logout]);
|
|
||||||
|
|
||||||
const contextValue = {
|
|
||||||
user,
|
|
||||||
isAuthenticated: !!user && authService.isAuthenticated(),
|
|
||||||
isLoading,
|
|
||||||
login,
|
|
||||||
register,
|
|
||||||
logout,
|
|
||||||
updateProfile,
|
|
||||||
refreshUser,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AuthContext.Provider value={contextValue}>
|
|
||||||
{children}
|
|
||||||
</AuthContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Export the RegisterRequest type for use in components
|
|
||||||
export type { RegisterRequest };
|
|
||||||
137
frontend/src/i18n/index.ts
Normal file
137
frontend/src/i18n/index.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
// src/i18n/index.ts
|
||||||
|
import i18n from 'i18next';
|
||||||
|
import { initReactI18next } from 'react-i18next';
|
||||||
|
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||||
|
|
||||||
|
const resources = {
|
||||||
|
es: {
|
||||||
|
translation: {
|
||||||
|
// Common
|
||||||
|
"loading": "Cargando...",
|
||||||
|
"save": "Guardar",
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"delete": "Eliminar",
|
||||||
|
"edit": "Editar",
|
||||||
|
"close": "Cerrar",
|
||||||
|
"yes": "Sí",
|
||||||
|
"no": "No",
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
"dashboard": "Panel Principal",
|
||||||
|
"forecasts": "Predicciones",
|
||||||
|
"orders": "Pedidos",
|
||||||
|
"settings": "Configuración",
|
||||||
|
"logout": "Cerrar Sesión",
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
"login": "Iniciar Sesión",
|
||||||
|
"register": "Registrarse",
|
||||||
|
"email": "Correo electrónico",
|
||||||
|
"password": "Contraseña",
|
||||||
|
"confirmPassword": "Confirmar contraseña",
|
||||||
|
"fullName": "Nombre completo",
|
||||||
|
"welcomeBack": "¡Bienvenido de vuelta!",
|
||||||
|
"createAccount": "Crear cuenta",
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
"todaySales": "Ventas de Hoy",
|
||||||
|
"wasteReduction": "Reducción Desperdicio",
|
||||||
|
"aiAccuracy": "Precisión IA",
|
||||||
|
"stockouts": "Roturas Stock",
|
||||||
|
|
||||||
|
// Forecasts
|
||||||
|
"highConfidence": "Alta confianza",
|
||||||
|
"mediumConfidence": "Confianza media",
|
||||||
|
"lowConfidence": "Baja confianza",
|
||||||
|
"predictionsForToday": "Predicciones para Hoy",
|
||||||
|
"weatherImpact": "Impacto del clima",
|
||||||
|
|
||||||
|
// Orders
|
||||||
|
"newOrder": "Nuevo Pedido",
|
||||||
|
"pending": "Pendiente",
|
||||||
|
"confirmed": "Confirmado",
|
||||||
|
"delivered": "Entregado",
|
||||||
|
"cancelled": "Cancelado",
|
||||||
|
|
||||||
|
// Products
|
||||||
|
"croissants": "Croissants",
|
||||||
|
"bread": "Pan de molde",
|
||||||
|
"baguettes": "Baguettes",
|
||||||
|
"coffee": "Café",
|
||||||
|
"pastries": "Napolitanas",
|
||||||
|
"muffins": "Magdalenas",
|
||||||
|
"donuts": "Donuts",
|
||||||
|
"sandwiches": "Bocadillos"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
translation: {
|
||||||
|
// Common
|
||||||
|
"loading": "Loading...",
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"delete": "Delete",
|
||||||
|
"edit": "Edit",
|
||||||
|
"close": "Close",
|
||||||
|
"yes": "Yes",
|
||||||
|
"no": "No",
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"forecasts": "Forecasts",
|
||||||
|
"orders": "Orders",
|
||||||
|
"settings": "Settings",
|
||||||
|
"logout": "Logout",
|
||||||
|
|
||||||
|
// Auth
|
||||||
|
"login": "Login",
|
||||||
|
"register": "Register",
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Password",
|
||||||
|
"confirmPassword": "Confirm password",
|
||||||
|
"fullName": "Full name",
|
||||||
|
"welcomeBack": "Welcome back!",
|
||||||
|
"createAccount": "Create account",
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
"todaySales": "Today's Sales",
|
||||||
|
"wasteReduction": "Waste Reduction",
|
||||||
|
"aiAccuracy": "AI Accuracy",
|
||||||
|
"stockouts": "Stockouts",
|
||||||
|
|
||||||
|
// Forecasts
|
||||||
|
"highConfidence": "High confidence",
|
||||||
|
"mediumConfidence": "Medium confidence",
|
||||||
|
"lowConfidence": "Low confidence",
|
||||||
|
"predictionsForToday": "Today's Predictions",
|
||||||
|
"weatherImpact": "Weather impact",
|
||||||
|
|
||||||
|
// Orders
|
||||||
|
"newOrder": "New Order",
|
||||||
|
"pending": "Pending",
|
||||||
|
"confirmed": "Confirmed",
|
||||||
|
"delivered": "Delivered",
|
||||||
|
"cancelled": "Cancelled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
i18n
|
||||||
|
.use(LanguageDetector)
|
||||||
|
.use(initReactI18next)
|
||||||
|
.init({
|
||||||
|
resources,
|
||||||
|
fallbackLng: 'es',
|
||||||
|
debug: process.env.NODE_ENV === 'development',
|
||||||
|
|
||||||
|
interpolation: {
|
||||||
|
escapeValue: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
detection: {
|
||||||
|
order: ['localStorage', 'navigator', 'htmlTag'],
|
||||||
|
caches: ['localStorage'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default i18n;
|
||||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App.tsx' 👈 Imports from ./App.tsx
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App /> 👈 Renders the App component
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { AuthProvider } from '../contexts/AuthContext';
|
|
||||||
import '../styles/globals.css';
|
|
||||||
|
|
||||||
function App({ Component, pageProps }: any) {
|
|
||||||
return (
|
|
||||||
<AuthProvider>
|
|
||||||
<Component {...pageProps} />
|
|
||||||
</AuthProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
278
frontend/src/pages/auth/LoginPage.tsx
Normal file
278
frontend/src/pages/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Eye, EyeOff, Loader2 } from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface LoginPageProps {
|
||||||
|
onLogin: (user: any, token: string) => void;
|
||||||
|
onNavigateToRegister: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginForm {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LoginPage: React.FC<LoginPageProps> = ({ onLogin, onNavigateToRegister }) => {
|
||||||
|
const [formData, setFormData] = useState<LoginForm>({
|
||||||
|
email: '',
|
||||||
|
password: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [errors, setErrors] = useState<Partial<LoginForm>>({});
|
||||||
|
|
||||||
|
const validateForm = (): boolean => {
|
||||||
|
const newErrors: Partial<LoginForm> = {};
|
||||||
|
|
||||||
|
if (!formData.email) {
|
||||||
|
newErrors.email = 'El email es obligatorio';
|
||||||
|
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
|
||||||
|
newErrors.email = 'El email no es válido';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.password) {
|
||||||
|
newErrors.password = 'La contraseña es obligatoria';
|
||||||
|
} else if (formData.password.length < 6) {
|
||||||
|
newErrors.password = 'La contraseña debe tener al menos 6 caracteres';
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!validateForm()) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.message || 'Error al iniciar sesión');
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success('¡Bienvenido a PanIA!');
|
||||||
|
onLogin(data.user, data.access_token);
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
toast.error(error.message || 'Error al iniciar sesión');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[name]: value
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Clear error when user starts typing
|
||||||
|
if (errors[name as keyof LoginForm]) {
|
||||||
|
setErrors(prev => ({
|
||||||
|
...prev,
|
||||||
|
[name]: undefined
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-orange-100 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-md w-full space-y-8">
|
||||||
|
{/* Logo and Header */}
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mx-auto h-20 w-20 bg-primary-500 rounded-2xl flex items-center justify-center mb-6 shadow-lg">
|
||||||
|
<span className="text-white text-2xl font-bold">🥖</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl font-bold font-display text-gray-900 mb-2">
|
||||||
|
PanIA
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 text-lg">
|
||||||
|
Inteligencia Artificial para tu Panadería
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-500 text-sm mt-2">
|
||||||
|
Inicia sesión para acceder a tus predicciones
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Login Form */}
|
||||||
|
<div className="bg-white py-8 px-6 shadow-strong rounded-3xl">
|
||||||
|
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||||
|
{/* Email Field */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Correo electrónico
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className={`
|
||||||
|
appearance-none relative block w-full px-4 py-3 border rounded-xl
|
||||||
|
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
|
||||||
|
focus:ring-primary-500 focus:border-primary-500 focus:z-10
|
||||||
|
transition-all duration-200
|
||||||
|
${errors.email
|
||||||
|
? 'border-red-300 bg-red-50'
|
||||||
|
: 'border-gray-300 hover:border-gray-400'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
placeholder="tu@panaderia.com"
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.email}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password Field */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Contraseña
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className={`
|
||||||
|
appearance-none relative block w-full px-4 py-3 pr-12 border rounded-xl
|
||||||
|
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
|
||||||
|
focus:ring-primary-500 focus:border-primary-500 focus:z-10
|
||||||
|
transition-all duration-200
|
||||||
|
${errors.password
|
||||||
|
? 'border-red-300 bg-red-50'
|
||||||
|
: 'border-gray-300 hover:border-gray-400'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{errors.password && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.password}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Remember Me & Forgot Password */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<input
|
||||||
|
id="remember-me"
|
||||||
|
name="remember-me"
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<label htmlFor="remember-me" className="ml-2 block text-sm text-gray-700">
|
||||||
|
Recordarme
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-sm">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
className="font-medium text-primary-600 hover:text-primary-500 transition-colors"
|
||||||
|
>
|
||||||
|
¿Olvidaste tu contraseña?
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className={`
|
||||||
|
group relative w-full flex justify-center py-3 px-4 border border-transparent
|
||||||
|
text-sm font-medium rounded-xl text-white transition-all duration-200
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500
|
||||||
|
${isLoading
|
||||||
|
? 'bg-gray-400 cursor-not-allowed'
|
||||||
|
: 'bg-primary-500 hover:bg-primary-600 hover:shadow-lg transform hover:-translate-y-0.5'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
|
||||||
|
Iniciando sesión...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Iniciar sesión'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Register Link */}
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
¿No tienes una cuenta?{' '}
|
||||||
|
<button
|
||||||
|
onClick={onNavigateToRegister}
|
||||||
|
className="font-medium text-primary-600 hover:text-primary-500 transition-colors"
|
||||||
|
>
|
||||||
|
Regístrate gratis
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Features Preview */}
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-xs text-gray-500 mb-4">
|
||||||
|
Más de 500 panaderías en Madrid confían en PanIA
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-center space-x-6 text-xs text-gray-400">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="w-2 h-2 bg-success-500 rounded-full mr-2"></span>
|
||||||
|
Predicciones precisas
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="w-2 h-2 bg-success-500 rounded-full mr-2"></span>
|
||||||
|
Reduce desperdicios
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="w-2 h-2 bg-success-500 rounded-full mr-2"></span>
|
||||||
|
Fácil de usar
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LoginPage;
|
||||||
686
frontend/src/pages/auth/RegisterPage.tsx
Normal file
686
frontend/src/pages/auth/RegisterPage.tsx
Normal file
@@ -0,0 +1,686 @@
|
|||||||
|
{/* Register Form */}
|
||||||
|
<div className="bg-white py-8 px-6 shadow-strong rounded-3xl">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Full Name Field */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="fullName" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Nombre completo
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="fullName"
|
||||||
|
name="fullName"
|
||||||
|
type="text"
|
||||||
|
autoComplete="name"
|
||||||
|
required
|
||||||
|
value={formData.fullName}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className={`
|
||||||
|
appearance-none relative block w-full px-4 py-3 border rounded-xl
|
||||||
|
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
|
||||||
|
focus:ring-primary-500 focus:border-primary-500 focus:z-10
|
||||||
|
transition-all duration-200
|
||||||
|
${errors.fullName
|
||||||
|
? 'border-red-300 bg-red-50'
|
||||||
|
: 'border-gray-300 hover:border-gray-400'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
placeholder="Tu nombre completo"
|
||||||
|
/>
|
||||||
|
{errors.fullName && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.fullName}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email Field */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Correo electrónico
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className={`
|
||||||
|
appearance-none relative block w-full px-4 py-3 border rounded-xl
|
||||||
|
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
|
||||||
|
focus:ring-primary-500 focus:border-primary-500 focus:z-10
|
||||||
|
transition-all duration-200
|
||||||
|
${errors.email
|
||||||
|
? 'border-red-300 bg-red-50'
|
||||||
|
: 'border-gray-300 hover:border-gray-400'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
placeholder="tu@panaderia.com"
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.email}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password Field */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Contraseña
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className={`
|
||||||
|
appearance-none relative block w-full px-4 py-3 pr-12 border rounded-xl
|
||||||
|
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
|
||||||
|
focus:ring-primary-500 focus:border-primary-500 focus:z-10
|
||||||
|
transition-all duration-200
|
||||||
|
${errors.password
|
||||||
|
? 'border-red-300 bg-red-50'
|
||||||
|
: 'border-gray-300 hover:border-gray-400'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password Strength Indicator */}
|
||||||
|
{formData.password && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="flex space-x-1">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`h-1 flex-1 rounded ${
|
||||||
|
i < passwordStrength ? strengthColors[passwordStrength - 1] : 'bg-gray-200'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600 mt-1">
|
||||||
|
Seguridad: {strengthLabels[passwordStrength - 1] || 'Muy débil'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{errors.password && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.password}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm Password Field */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Confirmar contraseña
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="confirmPassword"
|
||||||
|
name="confirmPassword"
|
||||||
|
type={showConfirmPassword ? 'text' : 'password'}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
value={formData.confirmPassword}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className={`
|
||||||
|
appearance-none relative block w-full px-4 py-3 pr-12 border rounded-xl
|
||||||
|
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
|
||||||
|
focus:ring-primary-500 focus:border-primary-500 focus:z-10
|
||||||
|
transition-all duration-200
|
||||||
|
${errors.confirmPassword
|
||||||
|
? 'border-red-300 bg-red-50'
|
||||||
|
: formData.confirmPassword && formData.password === formData.confirmPassword
|
||||||
|
? 'border-green-300 bg-green-50'
|
||||||
|
: 'border-gray-300 hover:border-gray-400'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||||
|
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||||
|
>
|
||||||
|
{showConfirmPassword ? (
|
||||||
|
<EyeOff className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{formData.confirmPassword && formData.password === formData.confirmPassword && (
|
||||||
|
<div className="absolute inset-y-0 right-10 flex items-center">
|
||||||
|
<Check className="h-5 w-5 text-green-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{errors.confirmPassword && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.confirmPassword}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Terms and Conditions */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-start">
|
||||||
|
<input
|
||||||
|
id="acceptTerms"
|
||||||
|
name="acceptTerms"
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.acceptTerms}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded mt-0.5"
|
||||||
|
/>
|
||||||
|
<label htmlFor="acceptTerms" className="ml-2 block text-sm text-gray-700">
|
||||||
|
Acepto los{' '}
|
||||||
|
<a href="#" className="font-medium text-primary-600 hover:text-primary-500">
|
||||||
|
términos y condiciones
|
||||||
|
</a>{' '}
|
||||||
|
y la{' '}
|
||||||
|
<a href="#" className="font-medium text-primary-600 hover:text-primary-500">
|
||||||
|
política de privacidad
|
||||||
|
</a>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{errors.acceptTerms && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.acceptTerms}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isLoading}
|
||||||
|
className={`
|
||||||
|
group relative w-full flex justify-center py-3 px-4 border border-transparent
|
||||||
|
text-sm font-medium rounded-xl text-white transition-all duration-200
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500
|
||||||
|
${isLoading
|
||||||
|
? 'bg-gray-400 cursor-not-allowed'
|
||||||
|
: 'bg-primary-500 hover:bg-primary-600 hover:shadow-lg transform hover:-translate-y-0.5'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
|
||||||
|
Creando cuenta...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Crear cuenta gratis'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Login Link */}
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
¿Ya tienes una cuenta?{' '}
|
||||||
|
<button
|
||||||
|
onClick={onNavigateToLogin}
|
||||||
|
className="font-medium text-primary-600 hover:text-primary-500 transition-colors"
|
||||||
|
>
|
||||||
|
Inicia sesión aquí
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>import React, { useState } from 'react';
|
||||||
|
import { Eye, EyeOff, Loader2, Check } from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface RegisterPageProps {
|
||||||
|
onLogin: (user: any, token: string) => void;
|
||||||
|
onNavigateToLogin: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RegisterForm {
|
||||||
|
fullName: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
confirmPassword: string;
|
||||||
|
acceptTerms: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RegisterPage: React.FC<RegisterPageProps> = ({ onLogin, onNavigateToLogin }) => {
|
||||||
|
const [formData, setFormData] = useState<RegisterForm>({
|
||||||
|
fullName: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
acceptTerms: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [errors, setErrors] = useState<Partial<RegisterForm>>({});
|
||||||
|
|
||||||
|
const validateForm = (): boolean => {
|
||||||
|
const newErrors: Partial<RegisterForm> = {};
|
||||||
|
|
||||||
|
if (!formData.fullName.trim()) {
|
||||||
|
newErrors.fullName = 'El nombre completo es obligatorio';
|
||||||
|
} else if (formData.fullName.trim().length < 2) {
|
||||||
|
newErrors.fullName = 'El nombre debe tener al menos 2 caracteres';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.email) {
|
||||||
|
newErrors.email = 'El email es obligatorio';
|
||||||
|
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
|
||||||
|
newErrors.email = 'El email no es válido';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.password) {
|
||||||
|
newErrors.password = 'La contraseña es obligatoria';
|
||||||
|
} else if (formData.password.length < 8) {
|
||||||
|
newErrors.password = 'La contraseña debe tener al menos 8 caracteres';
|
||||||
|
} else if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(formData.password)) {
|
||||||
|
newErrors.password = 'La contraseña debe incluir mayúsculas, minúsculas y números';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.confirmPassword) {
|
||||||
|
newErrors.confirmPassword = 'Confirma tu contraseña';
|
||||||
|
} else if (formData.password !== formData.confirmPassword) {
|
||||||
|
newErrors.confirmPassword = 'Las contraseñas no coinciden';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.acceptTerms) {
|
||||||
|
newErrors.acceptTerms = 'Debes aceptar los términos y condiciones';
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!validateForm()) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
full_name: formData.fullName,
|
||||||
|
email: formData.email,
|
||||||
|
password: formData.password,
|
||||||
|
role: 'admin' // Default role for bakery owners
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(data.message || 'Error al crear la cuenta');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-login after successful registration
|
||||||
|
const loginResponse = await fetch('/api/v1/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: formData.email,
|
||||||
|
password: formData.password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const loginData = await loginResponse.json();
|
||||||
|
|
||||||
|
if (!loginResponse.ok) {
|
||||||
|
throw new Error('Cuenta creada, pero error al iniciar sesión');
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success('¡Cuenta creada exitosamente! Bienvenido a PanIA');
|
||||||
|
onLogin(loginData.user, loginData.access_token);
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Registration error:', error);
|
||||||
|
toast.error(error.message || 'Error al crear la cuenta');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const { name, value, type, checked } = e.target;
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
[name]: type === 'checkbox' ? checked : value
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Clear error when user starts typing
|
||||||
|
if (errors[name as keyof RegisterForm]) {
|
||||||
|
setErrors(prev => ({
|
||||||
|
...prev,
|
||||||
|
[name]: undefined
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPasswordStrength = (password: string) => {
|
||||||
|
let strength = 0;
|
||||||
|
if (password.length >= 8) strength++;
|
||||||
|
if (/[a-z]/.test(password)) strength++;
|
||||||
|
if (/[A-Z]/.test(password)) strength++;
|
||||||
|
if (/\d/.test(password)) strength++;
|
||||||
|
if (/[^A-Za-z0-9]/.test(password)) strength++;
|
||||||
|
return strength;
|
||||||
|
};
|
||||||
|
|
||||||
|
const passwordStrength = getPasswordStrength(formData.password);
|
||||||
|
const strengthLabels = ['Muy débil', 'Débil', 'Regular', 'Buena', 'Excelente'];
|
||||||
|
const strengthColors = ['bg-red-500', 'bg-orange-500', 'bg-yellow-500', 'bg-blue-500', 'bg-green-500'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-orange-100 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="max-w-md w-full space-y-8">
|
||||||
|
{/* Logo and Header */}
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mx-auto h-20 w-20 bg-primary-500 rounded-2xl flex items-center justify-center mb-6 shadow-lg">
|
||||||
|
<span className="text-white text-2xl font-bold">🥖</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl font-bold font-display text-gray-900 mb-2">
|
||||||
|
Únete a PanIA
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 text-lg">
|
||||||
|
Crea tu cuenta y transforma tu panadería
|
||||||
|
</p>
|
||||||
|
<p className="text-gray-500 text-sm mt-2">
|
||||||
|
Únete a más de 500 panaderías en Madrid
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Register Form */}
|
||||||
|
<div className="bg-white py-8 px-6 shadow-strong rounded-3xl">
|
||||||
|
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||||
|
{/* Full Name Field */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="fullName" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Nombre completo
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="fullName"
|
||||||
|
name="fullName"
|
||||||
|
type="text"
|
||||||
|
autoComplete="name"
|
||||||
|
required
|
||||||
|
value={formData.fullName}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className={`
|
||||||
|
appearance-none relative block w-full px-4 py-3 border rounded-xl
|
||||||
|
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
|
||||||
|
focus:ring-primary-500 focus:border-primary-500 focus:z-10
|
||||||
|
transition-all duration-200
|
||||||
|
${errors.fullName
|
||||||
|
? 'border-red-300 bg-red-50'
|
||||||
|
: 'border-gray-300 hover:border-gray-400'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
placeholder="Tu nombre completo"
|
||||||
|
/>
|
||||||
|
{errors.fullName && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.fullName}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email Field */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Correo electrónico
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className={`
|
||||||
|
appearance-none relative block w-full px-4 py-3 border rounded-xl
|
||||||
|
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
|
||||||
|
focus:ring-primary-500 focus:border-primary-500 focus:z-10
|
||||||
|
transition-all duration-200
|
||||||
|
${errors.email
|
||||||
|
? 'border-red-300 bg-red-50'
|
||||||
|
: 'border-gray-300 hover:border-gray-400'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
placeholder="tu@panaderia.com"
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.email}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password Field */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Contraseña
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
value={formData.password}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className={`
|
||||||
|
appearance-none relative block w-full px-4 py-3 pr-12 border rounded-xl
|
||||||
|
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
|
||||||
|
focus:ring-primary-500 focus:border-primary-500 focus:z-10
|
||||||
|
transition-all duration-200
|
||||||
|
${errors.password
|
||||||
|
? 'border-red-300 bg-red-50'
|
||||||
|
: 'border-gray-300 hover:border-gray-400'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Password Strength Indicator */}
|
||||||
|
{formData.password && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="flex space-x-1">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={`h-1 flex-1 rounded ${
|
||||||
|
i < passwordStrength ? strengthColors[passwordStrength - 1] : 'bg-gray-200'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-600 mt-1">
|
||||||
|
Seguridad: {strengthLabels[passwordStrength - 1] || 'Muy débil'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{errors.password && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.password}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm Password Field */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Confirmar contraseña
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="confirmPassword"
|
||||||
|
name="confirmPassword"
|
||||||
|
type={showConfirmPassword ? 'text' : 'password'}
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
value={formData.confirmPassword}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className={`
|
||||||
|
appearance-none relative block w-full px-4 py-3 pr-12 border rounded-xl
|
||||||
|
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
|
||||||
|
focus:ring-primary-500 focus:border-primary-500 focus:z-10
|
||||||
|
transition-all duration-200
|
||||||
|
${errors.confirmPassword
|
||||||
|
? 'border-red-300 bg-red-50'
|
||||||
|
: formData.confirmPassword && formData.password === formData.confirmPassword
|
||||||
|
? 'border-green-300 bg-green-50'
|
||||||
|
: 'border-gray-300 hover:border-gray-400'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||||
|
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||||
|
>
|
||||||
|
{showConfirmPassword ? (
|
||||||
|
<EyeOff className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||||
|
) : (
|
||||||
|
<Eye className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{formData.confirmPassword && formData.password === formData.confirmPassword && (
|
||||||
|
<div className="absolute inset-y-0 right-10 flex items-center">
|
||||||
|
<Check className="h-5 w-5 text-green-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{errors.confirmPassword && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.confirmPassword}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Terms and Conditions */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-start">
|
||||||
|
<input
|
||||||
|
id="acceptTerms"
|
||||||
|
name="acceptTerms"
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.acceptTerms}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded mt-0.5"
|
||||||
|
/>
|
||||||
|
<label htmlFor="acceptTerms" className="ml-2 block text-sm text-gray-700">
|
||||||
|
Acepto los{' '}
|
||||||
|
<a href="#" className="font-medium text-primary-600 hover:text-primary-500">
|
||||||
|
términos y condiciones
|
||||||
|
</a>{' '}
|
||||||
|
y la{' '}
|
||||||
|
<a href="#" className="font-medium text-primary-600 hover:text-primary-500">
|
||||||
|
política de privacidad
|
||||||
|
</a>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{errors.acceptTerms && (
|
||||||
|
<p className="mt-1 text-sm text-red-600">{errors.acceptTerms}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
className={`
|
||||||
|
group relative w-full flex justify-center py-3 px-4 border border-transparent
|
||||||
|
text-sm font-medium rounded-xl text-white transition-all duration-200
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500
|
||||||
|
${isLoading
|
||||||
|
? 'bg-gray-400 cursor-not-allowed'
|
||||||
|
: 'bg-primary-500 hover:bg-primary-600 hover:shadow-lg transform hover:-translate-y-0.5'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
|
||||||
|
Creando cuenta...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Crear cuenta gratis'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Login Link */}
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
¿Ya tienes una cuenta?{' '}
|
||||||
|
<button
|
||||||
|
onClick={onNavigateToLogin}
|
||||||
|
className="font-medium text-primary-600 hover:text-primary-500 transition-colors"
|
||||||
|
>
|
||||||
|
Inicia sesión aquí
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Benefits */}
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-xs text-gray-500 mb-4">
|
||||||
|
Al registrarte obtienes acceso completo durante 30 días gratis
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs text-gray-400">
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<span className="w-2 h-2 bg-success-500 rounded-full mr-2"></span>
|
||||||
|
Predicciones IA
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<span className="w-2 h-2 bg-success-500 rounded-full mr-2"></span>
|
||||||
|
Soporte 24/7
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<span className="w-2 h-2 bg-success-500 rounded-full mr-2"></span>
|
||||||
|
Sin compromiso
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RegisterPage;
|
||||||
429
frontend/src/pages/dashboard/DashboardPage.tsx
Normal file
429
frontend/src/pages/dashboard/DashboardPage.tsx
Normal file
@@ -0,0 +1,429 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { TrendingUp, TrendingDown, Package, AlertTriangle, Cloud, Users } from 'lucide-react';
|
||||||
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar } from 'recharts';
|
||||||
|
|
||||||
|
interface DashboardPageProps {
|
||||||
|
user: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WeatherData {
|
||||||
|
temperature: number;
|
||||||
|
description: string;
|
||||||
|
precipitation: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ForecastData {
|
||||||
|
product: string;
|
||||||
|
predicted: number;
|
||||||
|
confidence: 'high' | 'medium' | 'low';
|
||||||
|
change: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetricsData {
|
||||||
|
totalSales: number;
|
||||||
|
wasteReduction: number;
|
||||||
|
accuracy: number;
|
||||||
|
stockouts: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DashboardPage: React.FC<DashboardPageProps> = ({ user }) => {
|
||||||
|
const topProducts = [
|
||||||
|
{ name: 'Croissants', quantity: 45, trend: 'up' },
|
||||||
|
{ name: 'Pan de molde', quantity: 32, trend: 'up' },
|
||||||
|
{ name: 'Baguettes', quantity: 28, trend: 'down' },
|
||||||
|
{ name: 'Napolitanas', quantity: 23, trend: 'up' },
|
||||||
|
{ name: 'Café', quantity: 67, trend: 'up' },
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadDashboardData = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Simulate API calls - in real implementation, these would be actual API calls
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
// Mock weather data
|
||||||
|
setWeather({
|
||||||
|
temperature: 18,
|
||||||
|
description: 'Parcialmente nublado',
|
||||||
|
precipitation: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock today's forecasts
|
||||||
|
setTodayForecasts([
|
||||||
|
{ product: 'Croissants', predicted: 48, confidence: 'high', change: 8 },
|
||||||
|
{ product: 'Pan de molde', predicted: 35, confidence: 'high', change: 3 },
|
||||||
|
{ product: 'Baguettes', predicted: 25, confidence: 'medium', change: -3 },
|
||||||
|
{ product: 'Café', predicted: 72, confidence: 'high', change: 5 },
|
||||||
|
{ product: 'Napolitanas', predicted: 26, confidence: 'medium', change: 3 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Mock metrics
|
||||||
|
setMetrics({
|
||||||
|
totalSales: 1247,
|
||||||
|
wasteReduction: 15.3,
|
||||||
|
accuracy: 87.2,
|
||||||
|
stockouts: 2
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading dashboard data:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadDashboardData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getConfidenceColor = (confidence: string) => {
|
||||||
|
switch (confidence) {
|
||||||
|
case 'high':
|
||||||
|
return 'text-success-600 bg-success-100';
|
||||||
|
case 'medium':
|
||||||
|
return 'text-warning-600 bg-warning-100';
|
||||||
|
case 'low':
|
||||||
|
return 'text-danger-600 bg-danger-100';
|
||||||
|
default:
|
||||||
|
return 'text-gray-600 bg-gray-100';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getConfidenceLabel = (confidence: string) => {
|
||||||
|
switch (confidence) {
|
||||||
|
case 'high':
|
||||||
|
return 'Alta';
|
||||||
|
case 'medium':
|
||||||
|
return 'Media';
|
||||||
|
case 'low':
|
||||||
|
return 'Baja';
|
||||||
|
default:
|
||||||
|
return 'N/A';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="animate-pulse space-y-6">
|
||||||
|
<div className="h-8 bg-gray-200 rounded w-1/4"></div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<div key={i} className="h-32 bg-gray-200 rounded-xl"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div className="h-64 bg-gray-200 rounded-xl"></div>
|
||||||
|
<div className="h-64 bg-gray-200 rounded-xl"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
|
¡Hola, {user.fullName?.split(' ')[0] || 'Usuario'}! 👋
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 mt-1">
|
||||||
|
Aquí tienes un resumen de tu panadería para hoy
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{weather && (
|
||||||
|
<div className="mt-4 sm:mt-0 flex items-center text-sm text-gray-600 bg-white rounded-lg px-4 py-2 shadow-soft">
|
||||||
|
<Cloud className="h-4 w-4 mr-2" />
|
||||||
|
<span>{weather.temperature}°C - {weather.description}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Key Metrics */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="p-2 bg-primary-100 rounded-lg">
|
||||||
|
<Package className="h-6 w-6 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4">
|
||||||
|
<p className="text-sm font-medium text-gray-600">Ventas de Hoy</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{metrics.totalSales}</p>
|
||||||
|
<p className="text-xs text-success-600 flex items-center mt-1">
|
||||||
|
<TrendingUp className="h-3 w-3 mr-1" />
|
||||||
|
+12% vs ayer
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="p-2 bg-success-100 rounded-lg">
|
||||||
|
<TrendingUp className="h-6 w-6 text-success-600" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4">
|
||||||
|
<p className="text-sm font-medium text-gray-600">Reducción Desperdicio</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{metrics.wasteReduction}%</p>
|
||||||
|
<p className="text-xs text-success-600 flex items-center mt-1">
|
||||||
|
<TrendingUp className="h-3 w-3 mr-1" />
|
||||||
|
Mejorando
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="p-2 bg-blue-100 rounded-lg">
|
||||||
|
<Users className="h-6 w-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4">
|
||||||
|
<p className="text-sm font-medium text-gray-600">Precisión IA</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{metrics.accuracy}%</p>
|
||||||
|
<p className="text-xs text-success-600 flex items-center mt-1">
|
||||||
|
<TrendingUp className="h-3 w-3 mr-1" />
|
||||||
|
Excelente
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="p-2 bg-warning-100 rounded-lg">
|
||||||
|
<AlertTriangle className="h-6 w-6 text-warning-600" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4">
|
||||||
|
<p className="text-sm font-medium text-gray-600">Roturas Stock</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{metrics.stockouts}</p>
|
||||||
|
<p className="text-xs text-success-600 flex items-center mt-1">
|
||||||
|
<TrendingDown className="h-3 w-3 mr-1" />
|
||||||
|
Reduciendo
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content Grid */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Sales Chart */}
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
Ventas vs Predicciones (Última Semana)
|
||||||
|
</h3>
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={salesHistory}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
stroke="#666"
|
||||||
|
fontSize={12}
|
||||||
|
tickFormatter={(value) => {
|
||||||
|
const date = new Date(value);
|
||||||
|
return `${date.getDate()}/${date.getMonth() + 1}`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<YAxis stroke="#666" fontSize={12} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)'
|
||||||
|
}}
|
||||||
|
labelFormatter={(value) => {
|
||||||
|
const date = new Date(value);
|
||||||
|
return `${date.getDate()}/${date.getMonth() + 1}/${date.getFullYear()}`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="ventas"
|
||||||
|
stroke="#f97316"
|
||||||
|
strokeWidth={3}
|
||||||
|
name="Ventas Reales"
|
||||||
|
dot={{ fill: '#f97316', strokeWidth: 2, r: 4 }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="prediccion"
|
||||||
|
stroke="#64748b"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="5 5"
|
||||||
|
name="Predicción IA"
|
||||||
|
dot={{ fill: '#64748b', strokeWidth: 2, r: 3 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Today's Forecasts */}
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
Predicciones para Hoy
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{todayForecasts.map((forecast, index) => (
|
||||||
|
<div key={index} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-medium text-gray-900">{forecast.product}</span>
|
||||||
|
<span className={`px-2 py-1 rounded text-xs font-medium ${getConfidenceColor(forecast.confidence)}`}>
|
||||||
|
{getConfidenceLabel(forecast.confidence)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center mt-1">
|
||||||
|
<span className="text-xl font-bold text-gray-900 mr-2">
|
||||||
|
{forecast.predicted}
|
||||||
|
</span>
|
||||||
|
<span className={`text-sm flex items-center ${
|
||||||
|
forecast.change >= 0 ? 'text-success-600' : 'text-danger-600'
|
||||||
|
}`}>
|
||||||
|
{forecast.change >= 0 ? (
|
||||||
|
<TrendingUp className="h-3 w-3 mr-1" />
|
||||||
|
) : (
|
||||||
|
<TrendingDown className="h-3 w-3 mr-1" />
|
||||||
|
)}
|
||||||
|
{Math.abs(forecast.change)} vs ayer
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Section */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Top Products */}
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
Productos Más Vendidos (Esta Semana)
|
||||||
|
</h3>
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={topProducts}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="name"
|
||||||
|
stroke="#666"
|
||||||
|
fontSize={12}
|
||||||
|
angle={-45}
|
||||||
|
textAnchor="end"
|
||||||
|
height={80}
|
||||||
|
/>
|
||||||
|
<YAxis stroke="#666" fontSize={12} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="quantity"
|
||||||
|
fill="#f97316"
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
name="Cantidad Vendida"
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
Acciones Rápidas
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button className="w-full p-4 text-left border border-gray-200 rounded-lg hover:border-primary-300 hover:bg-primary-50 transition-all">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="p-2 bg-primary-100 rounded-lg mr-3">
|
||||||
|
<TrendingUp className="h-5 w-5 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900">Ver Predicciones Detalladas</div>
|
||||||
|
<div className="text-sm text-gray-500">Analiza las predicciones completas</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button className="w-full p-4 text-left border border-gray-200 rounded-lg hover:border-primary-300 hover:bg-primary-50 transition-all">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="p-2 bg-success-100 rounded-lg mr-3">
|
||||||
|
<Package className="h-5 w-5 text-success-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900">Gestionar Pedidos</div>
|
||||||
|
<div className="text-sm text-gray-500">Revisa y ajusta tus pedidos</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button className="w-full p-4 text-left border border-gray-200 rounded-lg hover:border-primary-300 hover:bg-primary-50 transition-all">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="p-2 bg-blue-100 rounded-lg mr-3">
|
||||||
|
<Users className="h-5 w-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900">Configurar Alertas</div>
|
||||||
|
<div className="text-sm text-gray-500">Personaliza tus notificaciones</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Weather Impact Alert */}
|
||||||
|
{weather && weather.precipitation > 0 && (
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||||
|
<div className="flex items-start">
|
||||||
|
<Cloud className="h-5 w-5 text-blue-600 mt-0.5 mr-3" />
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-blue-900">Impacto del Clima</h4>
|
||||||
|
<p className="text-blue-800 text-sm mt-1">
|
||||||
|
Se esperan precipitaciones hoy. Esto puede reducir el tráfico peatonal en un 20-30%.
|
||||||
|
Considera ajustar la producción de productos frescos.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DashboardPage; [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [weather, setWeather] = useState<WeatherData | null>(null);
|
||||||
|
const [todayForecasts, setTodayForecasts] = useState<ForecastData[]>([]);
|
||||||
|
const [metrics, setMetrics] = useState<MetricsData>({
|
||||||
|
totalSales: 0,
|
||||||
|
wasteReduction: 0,
|
||||||
|
accuracy: 0,
|
||||||
|
stockouts: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sample historical data for charts
|
||||||
|
const salesHistory = [
|
||||||
|
{ date: '2024-10-28', ventas: 145, prediccion: 140 },
|
||||||
|
{ date: '2024-10-29', ventas: 128, prediccion: 135 },
|
||||||
|
{ date: '2024-10-30', ventas: 167, prediccion: 160 },
|
||||||
|
{ date: '2024-10-31', ventas: 143, prediccion: 145 },
|
||||||
|
{ date: '2024-11-01', ventas: 156, prediccion: 150 },
|
||||||
|
{ date: '2024-11-02', ventas: 189, prediccion: 185 },
|
||||||
|
{ date: '2024-11-03', ventas: 134, prediccion: 130 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const
|
||||||
@@ -1,384 +0,0 @@
|
|||||||
// src/pages/dashboard/index.tsx
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
|
||||||
import Head from 'next/head';
|
|
||||||
import {
|
|
||||||
ChartBarIcon,
|
|
||||||
CloudArrowUpIcon,
|
|
||||||
CpuChipIcon,
|
|
||||||
BellIcon,
|
|
||||||
ArrowPathIcon,
|
|
||||||
ScaleIcon, // For accuracy
|
|
||||||
CalendarDaysIcon, // For last training date
|
|
||||||
CurrencyEuroIcon
|
|
||||||
} from '@heroicons/react/24/outline';
|
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
|
||||||
import { useTrainingProgress } from '../../api/hooks/useTrainingProgress'; // Path corrected
|
|
||||||
import { TrainingProgressCard } from '../../components/training/TrainingProgressCard';
|
|
||||||
import { ForecastChart } from '../../components/charts/ForecastChart';
|
|
||||||
import { SalesUploader } from '../../components/data/SalesUploader';
|
|
||||||
import { NotificationToast } from '../../components/common/NotificationToast';
|
|
||||||
import { ErrorBoundary } from '../../components/common/ErrorBoundary';
|
|
||||||
import { defaultProducts } from '../../components/common/ProductSelector';
|
|
||||||
import {
|
|
||||||
ApiResponse,
|
|
||||||
ForecastRecord,
|
|
||||||
TrainingRequest,
|
|
||||||
TrainingJobProgress
|
|
||||||
} from '@/api/services';
|
|
||||||
|
|
||||||
|
|
||||||
import api from '@/api/services';
|
|
||||||
|
|
||||||
|
|
||||||
// Dashboard specific types
|
|
||||||
interface DashboardStats {
|
|
||||||
totalSales: number;
|
|
||||||
totalRevenue: number;
|
|
||||||
lastTrainingDate: string | null;
|
|
||||||
forecastAccuracy: number; // e.g., MAPE or RMSE
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Notification {
|
|
||||||
id: string;
|
|
||||||
type: 'success' | 'error' | 'warning' | 'info';
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
timestamp: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
// StatsCard Component (moved here for completeness, or keep in common if reused)
|
|
||||||
interface StatsCardProps {
|
|
||||||
title: string;
|
|
||||||
value: any;
|
|
||||||
icon: React.ElementType;
|
|
||||||
format: 'number' | 'currency' | 'percentage' | 'date' | 'string'; // Added 'string' for flexibility
|
|
||||||
loading?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const StatsCard: React.FC<StatsCardProps> = ({ title, value, icon: Icon, format, loading }) => {
|
|
||||||
const formatValue = () => {
|
|
||||||
if (loading) return (
|
|
||||||
<div className="h-6 bg-gray-200 rounded w-3/4 animate-pulse"></div>
|
|
||||||
);
|
|
||||||
if (value === null || value === undefined) return 'N/A';
|
|
||||||
|
|
||||||
switch (format) {
|
|
||||||
case 'number':
|
|
||||||
return value.toLocaleString('es-ES');
|
|
||||||
case 'currency':
|
|
||||||
return new Intl.NumberFormat('es-ES', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'EUR',
|
|
||||||
}).format(value);
|
|
||||||
case 'percentage':
|
|
||||||
return `${(value * 100).toFixed(1)}%`;
|
|
||||||
case 'date':
|
|
||||||
return value === 'Never' ? value : new Date(value).toLocaleDateString('es-ES');
|
|
||||||
default:
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-lg shadow p-6 flex items-center">
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<Icon className="h-8 w-8 text-pania-blue" /> {/* Changed icon color */}
|
|
||||||
</div>
|
|
||||||
<div className="ml-5">
|
|
||||||
<dt className="text-sm font-medium text-gray-500">{title}</dt>
|
|
||||||
<dd className="mt-1 text-3xl font-semibold text-gray-900">{formatValue()}</dd>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const DashboardPage: React.FC = () => {
|
|
||||||
const { user, isAuthenticated, isLoading: authLoading } = useAuth();
|
|
||||||
const [activeJobId, setActiveJobId] = useState<string | null>(null);
|
|
||||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
|
||||||
const [forecasts, setForecasts] = useState<ForecastRecord[]>([]);
|
|
||||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
|
||||||
const [chartProductName, setChartProductName] = useState<string>(''); // Currently selected product for chart
|
|
||||||
const [loadingData, setLoadingData] = useState(true);
|
|
||||||
|
|
||||||
// Hook for training progress (if an active job ID is present)
|
|
||||||
const {
|
|
||||||
progress: trainingProgress,
|
|
||||||
error: trainingError,
|
|
||||||
isComplete: isTrainingComplete,
|
|
||||||
isConnected: isTrainingWebSocketConnected,
|
|
||||||
} = useTrainingProgress(activeJobId);
|
|
||||||
|
|
||||||
// Effect to handle training completion
|
|
||||||
useEffect(() => {
|
|
||||||
if (isTrainingComplete && activeJobId) {
|
|
||||||
addNotification('success', 'Entrenamiento Completado', `El modelo para el trabajo ${activeJobId} ha terminado de entrenar.`);
|
|
||||||
setActiveJobId(null); // Clear active job
|
|
||||||
fetchDashboardData(); // Refresh dashboard data after training
|
|
||||||
}
|
|
||||||
if (trainingError && activeJobId) {
|
|
||||||
addNotification('error', 'Error de Entrenamiento', `El entrenamiento para el trabajo ${activeJobId} falló: ${trainingError}`);
|
|
||||||
setActiveJobId(null);
|
|
||||||
}
|
|
||||||
}, [isTrainingComplete, trainingError, activeJobId]); // Dependencies
|
|
||||||
|
|
||||||
|
|
||||||
// Notification handling
|
|
||||||
const addNotification = useCallback((type: Notification['type'], title: string, message: string) => {
|
|
||||||
const newNotification: Notification = {
|
|
||||||
id: Date.now().toString(),
|
|
||||||
type,
|
|
||||||
title,
|
|
||||||
message,
|
|
||||||
timestamp: new Date(),
|
|
||||||
};
|
|
||||||
setNotifications((prev) => [...prev, newNotification]);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const removeNotification = useCallback((id: string) => {
|
|
||||||
setNotifications((prev) => prev.filter((n) => n.id !== id));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Fetch initial dashboard data
|
|
||||||
const fetchDashboardData = useCallback(async () => {
|
|
||||||
setLoadingData(true);
|
|
||||||
try {
|
|
||||||
// Fetch Dashboard Stats
|
|
||||||
const statsResponse: ApiResponse<DashboardStats> = await api.data.dataApi.getDashboardStats();
|
|
||||||
if (statsResponse.data) {
|
|
||||||
setStats(statsResponse.data);
|
|
||||||
} else if (statsResponse.message) {
|
|
||||||
addNotification('warning', 'Dashboard Stats', statsResponse.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch initial forecasts (e.g., for a default product or the first available product)
|
|
||||||
const forecastResponse: ApiResponse<ForecastRecord[]> = await api.forecasting.getForecast({
|
|
||||||
forecast_days: 7, // Example: 7 days forecast
|
|
||||||
product_name: user?.tenant_id ? 'pan' : undefined, // Default to 'pan' or first product
|
|
||||||
});
|
|
||||||
if (forecastResponse.data && forecastResponse.data.length > 0) {
|
|
||||||
setForecasts(forecastResponse.data);
|
|
||||||
setChartProductName(forecastResponse.data[0].product_name); // Set the product name for the chart
|
|
||||||
} else if (forecastResponse.message) {
|
|
||||||
addNotification('info', 'Previsiones', forecastResponse.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to fetch dashboard data:', error);
|
|
||||||
addNotification('error', 'Error de Carga', error.message || 'No se pudieron cargar los datos del dashboard.');
|
|
||||||
} finally {
|
|
||||||
setLoadingData(false);
|
|
||||||
}
|
|
||||||
}, [user, addNotification]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isAuthenticated) {
|
|
||||||
fetchDashboardData();
|
|
||||||
}
|
|
||||||
}, [isAuthenticated, fetchDashboardData]);
|
|
||||||
|
|
||||||
const handleSalesUpload = async (file: File) => {
|
|
||||||
try {
|
|
||||||
addNotification('info', 'Subiendo archivo', 'Cargando historial de ventas...');
|
|
||||||
const response = await api.data.dataApi.uploadSalesHistory(file);
|
|
||||||
addNotification('success', 'Subida Completa', 'Historial de ventas cargado exitosamente.');
|
|
||||||
|
|
||||||
// After upload, trigger a new training (assuming this is the flow)
|
|
||||||
const trainingRequest: TrainingRequest = {
|
|
||||||
force_retrain: true,
|
|
||||||
// You might want to specify products if the uploader supports it,
|
|
||||||
// or let the backend determine based on the uploaded data.
|
|
||||||
};
|
|
||||||
const trainingTask: TrainingJobProgress = await api.training.trainingApi.startTraining(trainingRequest);
|
|
||||||
setActiveJobId(trainingTask.id);
|
|
||||||
addNotification('info', 'Entrenamiento iniciado', `Un nuevo entrenamiento ha comenzado (ID: ${trainingTask.id}).`);
|
|
||||||
// No need to fetch dashboard data here, as useEffect for isTrainingComplete will handle it
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error uploading sales or starting training:', error);
|
|
||||||
addNotification('error', 'Error al subir', error.message || 'No se pudo subir el archivo o iniciar el entrenamiento.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleForecastProductChange = async (productName: string) => {
|
|
||||||
setLoadingData(true);
|
|
||||||
try {
|
|
||||||
const forecastResponse: ApiResponse<ForecastRecord[]> = await api.forecasting.forecastingApi.getForecast({
|
|
||||||
forecast_days: 7,
|
|
||||||
product_name: productName,
|
|
||||||
});
|
|
||||||
if (forecastResponse.data) {
|
|
||||||
setForecasts(forecastResponse.data);
|
|
||||||
setChartProductName(productName);
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
addNotification('error', 'Error de Previsión', error.message || `No se pudieron cargar las previsiones para ${productName}.`);
|
|
||||||
} finally {
|
|
||||||
setLoadingData(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
if (authLoading) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-pania-white">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-pania-blue"></div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
// If not authenticated, ProtectedRoute should handle redirect, but a fallback is good
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ErrorBoundary>
|
|
||||||
<div className="min-h-screen bg-gray-100">
|
|
||||||
<Head>
|
|
||||||
<title>Dashboard - PanIA</title>
|
|
||||||
<meta name="description" content="Dashboard de predicción de demanda para Panaderías" />
|
|
||||||
</Head>
|
|
||||||
|
|
||||||
{/* Top Notification Area */}
|
|
||||||
<div className="fixed top-4 right-4 z-50 space-y-2">
|
|
||||||
{notifications.map(notification => (
|
|
||||||
<NotificationToast
|
|
||||||
key={notification.id}
|
|
||||||
{...notification}
|
|
||||||
onClose={() => removeNotification(notification.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Header/Navbar (You might want a dedicated Layout component for this) */}
|
|
||||||
<header className="bg-white shadow-sm py-4">
|
|
||||||
<nav className="container mx-auto flex justify-between items-center px-4">
|
|
||||||
<div className="text-3xl font-extrabold text-pania-charcoal">PanIA Dashboard</div>
|
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
<span className="text-gray-700">Bienvenido, {user?.full_name || user?.email}!</span>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
useAuth().logout(); // Call logout from AuthContext
|
|
||||||
}}
|
|
||||||
className="text-pania-blue hover:text-pania-blue-dark font-medium px-4 py-2 rounded-md border border-pania-blue"
|
|
||||||
>
|
|
||||||
Cerrar Sesión
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className="container mx-auto px-4 py-8">
|
|
||||||
{/* Dashboard Overview Section */}
|
|
||||||
<section className="mb-8">
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">Resumen del Negocio</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
||||||
<StatsCard
|
|
||||||
title="Ventas Totales"
|
|
||||||
value={stats?.totalSales}
|
|
||||||
icon={ChartBarIcon}
|
|
||||||
format="number"
|
|
||||||
loading={loadingData}
|
|
||||||
/>
|
|
||||||
<StatsCard
|
|
||||||
title="Ingresos Totales"
|
|
||||||
value={stats?.totalRevenue}
|
|
||||||
icon={CurrencyEuroIcon}
|
|
||||||
format="currency"
|
|
||||||
loading={loadingData}
|
|
||||||
/>
|
|
||||||
<StatsCard
|
|
||||||
title="Último Entrenamiento"
|
|
||||||
value={stats?.lastTrainingDate || 'Nunca'}
|
|
||||||
icon={CalendarDaysIcon}
|
|
||||||
format="date"
|
|
||||||
loading={loadingData}
|
|
||||||
/>
|
|
||||||
<StatsCard
|
|
||||||
title="Precisión (MAPE)"
|
|
||||||
value={stats?.forecastAccuracy}
|
|
||||||
icon={ScaleIcon}
|
|
||||||
format="percentage"
|
|
||||||
loading={loadingData}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Training Section */}
|
|
||||||
<section className="mb-8">
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">Entrenamiento del Modelo</h2>
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">Subir Nuevos Datos de Ventas</h3>
|
|
||||||
<p className="text-gray-600 mb-4">
|
|
||||||
Carga tu último historial de ventas para mantener tus predicciones actualizadas.
|
|
||||||
</p>
|
|
||||||
<SalesUploader onUpload={handleSalesUpload} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-800 mb-4">Estado del Entrenamiento</h3>
|
|
||||||
{activeJobId ? (
|
|
||||||
<TrainingProgressCard jobId={activeJobId} />
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col items-center justify-center p-8 text-gray-500">
|
|
||||||
<CpuChipIcon className="h-16 w-16 mb-4" />
|
|
||||||
<p className="text-lg text-center">No hay un entrenamiento activo en este momento.</p>
|
|
||||||
<p className="text-sm text-center mt-2">Sube un nuevo archivo de ventas para iniciar un entrenamiento.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Forecast Chart Section */}
|
|
||||||
<section>
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">Previsiones de Demanda</h2>
|
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
|
||||||
<div className="mb-4 flex items-center justify-between">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-800">Previsión para {chartProductName || 'Productos'}</h3>
|
|
||||||
{/* Product Selector for Forecast Chart (assuming ProductSelector can be used for single selection) */}
|
|
||||||
<select
|
|
||||||
value={chartProductName}
|
|
||||||
onChange={(e) => handleForecastProductChange(e.target.value)}
|
|
||||||
className="mt-1 block w-48 pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-pania-blue focus:border-pania-blue sm:text-sm rounded-md"
|
|
||||||
>
|
|
||||||
{/* You'll need to fetch the list of products associated with the user/tenant */}
|
|
||||||
{/* For now, using defaultProducts as an example */}
|
|
||||||
{defaultProducts.map((product) => (
|
|
||||||
<option key={product.id} value={product.displayName}>
|
|
||||||
{product.displayName}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{loadingData ? (
|
|
||||||
<div className="flex justify-center items-center h-64">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-pania-blue"></div>
|
|
||||||
</div>
|
|
||||||
) : forecasts.length > 0 ? (
|
|
||||||
<ForecastChart data={forecasts} productName={chartProductName} />
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-10 text-gray-500">
|
|
||||||
<ChartBarIcon className="mx-auto h-16 w-16 text-gray-400" />
|
|
||||||
<p className="mt-4 text-lg">No hay datos de previsión disponibles.</p>
|
|
||||||
<p className="text-sm">Sube tu historial de ventas o selecciona otro producto.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="bg-gray-800 text-gray-300 py-6 text-center mt-8">
|
|
||||||
<div className="container mx-auto px-4">
|
|
||||||
<p>© {new Date().getFullYear()} PanIA. Todos los derechos reservados.</p>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</ErrorBoundary>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DashboardPage;
|
|
||||||
411
frontend/src/pages/forecast/ForecastPage.tsx
Normal file
411
frontend/src/pages/forecast/ForecastPage.tsx
Normal file
@@ -0,0 +1,411 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { TrendingUp, TrendingDown, Calendar, Cloud, AlertTriangle, Info } from 'lucide-react';
|
||||||
|
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
|
|
||||||
|
interface ForecastData {
|
||||||
|
date: string;
|
||||||
|
product: string;
|
||||||
|
predicted: number;
|
||||||
|
confidence: 'high' | 'medium' | 'low';
|
||||||
|
factors: string[];
|
||||||
|
weatherImpact?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WeatherAlert {
|
||||||
|
type: 'rain' | 'heat' | 'cold';
|
||||||
|
impact: string;
|
||||||
|
recommendation: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ForecastPage: React.FC = () => {
|
||||||
|
const [selectedDate, setSelectedDate] = useState(new Date().toISOString().split('T')[0]);
|
||||||
|
const [selectedProduct, setSelectedProduct] = useState('all');
|
||||||
|
const [forecasts, setForecasts] = useState<ForecastData[]>([]);
|
||||||
|
const [weatherAlert, setWeatherAlert] = useState<WeatherAlert | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
const products = [
|
||||||
|
'Croissants', 'Pan de molde', 'Baguettes', 'Napolitanas',
|
||||||
|
'Café', 'Magdalenas', 'Donuts', 'Bocadillos'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Sample forecast data for the next 7 days
|
||||||
|
const sampleForecastData = [
|
||||||
|
{ date: '2024-11-04', croissants: 48, pan: 35, cafe: 72 },
|
||||||
|
{ date: '2024-11-05', croissants: 52, pan: 38, cafe: 78 },
|
||||||
|
{ date: '2024-11-06', croissants: 45, pan: 32, cafe: 65 },
|
||||||
|
{ date: '2024-11-07', croissants: 41, pan: 29, cafe: 58 },
|
||||||
|
{ date: '2024-11-08', croissants: 56, pan: 42, cafe: 82 },
|
||||||
|
{ date: '2024-11-09', croissants: 61, pan: 45, cafe: 89 },
|
||||||
|
{ date: '2024-11-10', croissants: 38, pan: 28, cafe: 55 },
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadForecasts = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Simulate API call
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
// Mock weather alert
|
||||||
|
setWeatherAlert({
|
||||||
|
type: 'rain',
|
||||||
|
impact: 'Se esperan lluvias moderadas mañana',
|
||||||
|
recommendation: 'Reduce la producción de productos frescos en un 20%'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock forecast data
|
||||||
|
const mockForecasts: ForecastData[] = [
|
||||||
|
{
|
||||||
|
date: selectedDate,
|
||||||
|
product: 'Croissants',
|
||||||
|
predicted: 48,
|
||||||
|
confidence: 'high',
|
||||||
|
factors: ['Día laboral', 'Clima estable', 'Sin eventos especiales'],
|
||||||
|
weatherImpact: 'Sin impacto significativo'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: selectedDate,
|
||||||
|
product: 'Pan de molde',
|
||||||
|
predicted: 35,
|
||||||
|
confidence: 'high',
|
||||||
|
factors: ['Demanda constante', 'Histórico estable'],
|
||||||
|
weatherImpact: 'Sin impacto'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: selectedDate,
|
||||||
|
product: 'Café',
|
||||||
|
predicted: 72,
|
||||||
|
confidence: 'medium',
|
||||||
|
factors: ['Temperatura fresca', 'Día laboral'],
|
||||||
|
weatherImpact: 'Aumento del 10% por temperatura'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: selectedDate,
|
||||||
|
product: 'Baguettes',
|
||||||
|
predicted: 28,
|
||||||
|
confidence: 'medium',
|
||||||
|
factors: ['Día entre semana', 'Demanda normal'],
|
||||||
|
weatherImpact: 'Sin impacto'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: selectedDate,
|
||||||
|
product: 'Napolitanas',
|
||||||
|
predicted: 23,
|
||||||
|
confidence: 'low',
|
||||||
|
factors: ['Variabilidad alta', 'Datos limitados'],
|
||||||
|
weatherImpact: 'Posible reducción del 5%'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
setForecasts(mockForecasts);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading forecasts:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadForecasts();
|
||||||
|
}, [selectedDate]);
|
||||||
|
|
||||||
|
const getConfidenceColor = (confidence: string) => {
|
||||||
|
switch (confidence) {
|
||||||
|
case 'high':
|
||||||
|
return 'bg-success-100 text-success-800 border-success-200';
|
||||||
|
case 'medium':
|
||||||
|
return 'bg-warning-100 text-warning-800 border-warning-200';
|
||||||
|
case 'low':
|
||||||
|
return 'bg-danger-100 text-danger-800 border-danger-200';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getConfidenceLabel = (confidence: string) => {
|
||||||
|
switch (confidence) {
|
||||||
|
case 'high':
|
||||||
|
return 'Alta confianza';
|
||||||
|
case 'medium':
|
||||||
|
return 'Confianza media';
|
||||||
|
case 'low':
|
||||||
|
return 'Baja confianza';
|
||||||
|
default:
|
||||||
|
return 'N/A';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredForecasts = selectedProduct === 'all'
|
||||||
|
? forecasts
|
||||||
|
: forecasts.filter(f => f.product.toLowerCase().includes(selectedProduct.toLowerCase()));
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="animate-pulse space-y-6">
|
||||||
|
<div className="h-8 bg-gray-200 rounded w-1/3"></div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="h-32 bg-gray-200 rounded-xl"></div>
|
||||||
|
<div className="h-32 bg-gray-200 rounded-xl"></div>
|
||||||
|
</div>
|
||||||
|
<div className="h-64 bg-gray-200 rounded-xl"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Predicciones IA</h1>
|
||||||
|
<p className="text-gray-600 mt-1">
|
||||||
|
Predicciones inteligentes para optimizar tu producción
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Weather Alert */}
|
||||||
|
{weatherAlert && (
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||||
|
<div className="flex items-start">
|
||||||
|
<Cloud className="h-5 w-5 text-blue-600 mt-0.5 mr-3 flex-shrink-0" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="font-medium text-blue-900">Alerta Meteorológica</h4>
|
||||||
|
<p className="text-blue-800 text-sm mt-1">{weatherAlert.impact}</p>
|
||||||
|
<p className="text-blue-700 text-sm mt-2 font-medium">
|
||||||
|
💡 Recomendación: {weatherAlert.recommendation}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Fecha de predicción
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Calendar className="absolute left-3 top-3 h-5 w-5 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={selectedDate}
|
||||||
|
onChange={(e) => setSelectedDate(e.target.value)}
|
||||||
|
min={new Date().toISOString().split('T')[0]}
|
||||||
|
max={new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]}
|
||||||
|
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Filtrar por producto
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={selectedProduct}
|
||||||
|
onChange={(e) => setSelectedProduct(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
>
|
||||||
|
<option value="all">Todos los productos</option>
|
||||||
|
{products.map(product => (
|
||||||
|
<option key={product} value={product.toLowerCase()}>{product}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Forecast Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{filteredForecasts.map((forecast, index) => (
|
||||||
|
<div key={index} className="bg-white p-6 rounded-xl shadow-soft hover:shadow-medium transition-shadow">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">{forecast.product}</h3>
|
||||||
|
<span className={`px-3 py-1 rounded-lg text-xs font-medium border ${getConfidenceColor(forecast.confidence)}`}>
|
||||||
|
{getConfidenceLabel(forecast.confidence)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex items-baseline">
|
||||||
|
<span className="text-3xl font-bold text-gray-900">{forecast.predicted}</span>
|
||||||
|
<span className="text-gray-500 ml-2">unidades</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
|
Predicción para {new Date(forecast.date).toLocaleDateString('es-ES', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-2 flex items-center">
|
||||||
|
<Info className="h-4 w-4 mr-1" />
|
||||||
|
Factores considerados
|
||||||
|
</h4>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{forecast.factors.map((factor, i) => (
|
||||||
|
<li key={i} className="text-xs text-gray-600 flex items-center">
|
||||||
|
<span className="w-1.5 h-1.5 bg-primary-500 rounded-full mr-2"></span>
|
||||||
|
{factor}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{forecast.weatherImpact && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-1 flex items-center">
|
||||||
|
<Cloud className="h-4 w-4 mr-1" />
|
||||||
|
Impacto del clima
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-gray-600">{forecast.weatherImpact}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Trend Chart */}
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
Tendencia de Predicciones (Próximos 7 Días)
|
||||||
|
</h3>
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={sampleForecastData}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#f0f0f0" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
stroke="#666"
|
||||||
|
fontSize={12}
|
||||||
|
tickFormatter={(value) => {
|
||||||
|
const date = new Date(value);
|
||||||
|
return `${date.getDate()}/${date.getMonth() + 1}`;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<YAxis stroke="#666" fontSize={12} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
border: '1px solid #e5e7eb',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)'
|
||||||
|
}}
|
||||||
|
labelFormatter={(value) => {
|
||||||
|
const date = new Date(value);
|
||||||
|
return date.toLocaleDateString('es-ES', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long'
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="croissants"
|
||||||
|
stroke="#f97316"
|
||||||
|
strokeWidth={3}
|
||||||
|
name="Croissants"
|
||||||
|
dot={{ fill: '#f97316', strokeWidth: 2, r: 4 }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="pan"
|
||||||
|
stroke="#22c55e"
|
||||||
|
strokeWidth={3}
|
||||||
|
name="Pan de molde"
|
||||||
|
dot={{ fill: '#22c55e', strokeWidth: 2, r: 4 }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="cafe"
|
||||||
|
stroke="#3b82f6"
|
||||||
|
strokeWidth={3}
|
||||||
|
name="Café"
|
||||||
|
dot={{ fill: '#3b82f6', strokeWidth: 2, r: 4 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recommendations */}
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
Recomendaciones Inteligentes
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-start p-4 bg-success-50 rounded-lg border border-success-200">
|
||||||
|
<TrendingUp className="h-5 w-5 text-success-600 mt-0.5 mr-3 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-success-900">Oportunidad de Aumento</h4>
|
||||||
|
<p className="text-success-800 text-sm mt-1">
|
||||||
|
La demanda de café aumentará un 15% esta semana por las bajas temperaturas.
|
||||||
|
Considera aumentar el stock de café y bebidas calientes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start p-4 bg-warning-50 rounded-lg border border-warning-200">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-warning-600 mt-0.5 mr-3 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-warning-900">Ajuste Recomendado</h4>
|
||||||
|
<p className="text-warning-800 text-sm mt-1">
|
||||||
|
Las napolitanas muestran alta variabilidad. Considera reducir la producción
|
||||||
|
inicial y hornear más según demanda en tiempo real.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||||
|
<Info className="h-5 w-5 text-blue-600 mt-0.5 mr-3 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-blue-900">Optimización de Horarios</h4>
|
||||||
|
<p className="text-blue-800 text-sm mt-1">
|
||||||
|
El pico de demanda de croissants será entre 7:30-9:00 AM.
|
||||||
|
Asegúrate de tener suficiente stock listo para esas horas.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Export Actions */}
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
Acciones Rápidas
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<button className="p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all text-left">
|
||||||
|
<div className="font-medium text-gray-900">Exportar Predicciones</div>
|
||||||
|
<div className="text-sm text-gray-500 mt-1">Descargar en formato CSV</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button className="p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all text-left">
|
||||||
|
<div className="font-medium text-gray-900">Configurar Alertas</div>
|
||||||
|
<div className="text-sm text-gray-500 mt-1">Recibir notificaciones automáticas</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button className="p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all text-left">
|
||||||
|
<div className="font-medium text-gray-900">Ver Precisión Histórica</div>
|
||||||
|
<div className="text-sm text-gray-500 mt-1">Analizar rendimiento del modelo</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ForecastPage;
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
import Head from 'next/head';
|
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
const HomePage = () => {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-pania-white"> {/* Set overall background to PanIA white */}
|
|
||||||
<Head>
|
|
||||||
<title>PanIA - Inteligencia Artificial para tu Panadería</title> {/* Updated title and tagline */}
|
|
||||||
<meta name="description" content="La primera IA diseñada para panaderías españolas que transforma tus datos en predicciones precisas." /> {/* Updated meta description */}
|
|
||||||
</Head>
|
|
||||||
|
|
||||||
{/* Navigation Bar */}
|
|
||||||
<header className="bg-pania-white shadow-sm py-4">
|
|
||||||
<nav className="container mx-auto flex justify-between items-center px-4">
|
|
||||||
<div className="text-3xl font-extrabold text-pania-charcoal">PanIA</div> {/* PanIA brand name */}
|
|
||||||
<div>
|
|
||||||
<Link href="/login" className="text-pania-blue hover:text-pania-blue-dark font-medium px-4 py-2 rounded-md">
|
|
||||||
Iniciar Sesión
|
|
||||||
</Link>
|
|
||||||
<Link href="/onboarding" className="ml-4 bg-pania-blue text-pania-white px-4 py-2 rounded-md hover:bg-pania-blue-dark transition-colors duration-200"> {/* CTA to onboarding */}
|
|
||||||
Prueba Gratis
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
{/* Hero Section */}
|
|
||||||
<section className="bg-pania-golden text-pania-white py-20 text-center"> {/* Warm Golden background */}
|
|
||||||
<div className="container mx-auto px-4">
|
|
||||||
<h1 className="text-5xl md:text-6xl font-bold leading-tight mb-4">
|
|
||||||
Inteligencia Artificial que Revoluciona tu Panadería
|
|
||||||
</h1>
|
|
||||||
<p className="text-xl md:text-2xl mb-8 max-w-3xl mx-auto">
|
|
||||||
Reduce desperdicios hasta <span className="font-bold">25%</span> y aumenta ganancias con predicciones precisas diseñadas para panaderías españolas.
|
|
||||||
</p>
|
|
||||||
<Link href="/onboarding" className="bg-pania-blue text-pania-white text-lg font-semibold px-8 py-4 rounded-lg shadow-lg hover:bg-pania-blue-dark transition-transform transform hover:scale-105">
|
|
||||||
Prueba Gratis 30 Días
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Social Proof Section */}
|
|
||||||
<section className="py-16 bg-pania-white">
|
|
||||||
<div className="container mx-auto px-4 text-center">
|
|
||||||
<h2 className="text-3xl font-bold text-pania-charcoal mb-8">
|
|
||||||
Más de 150 panaderías confían en PanIA
|
|
||||||
</h2>
|
|
||||||
<div className="flex justify-center items-center space-x-8 mb-8">
|
|
||||||
{/* Placeholder for customer logos */}
|
|
||||||
<div className="h-16 w-32 bg-gray-200 rounded-lg flex items-center justify-center text-gray-500">Logo 1</div>
|
|
||||||
<div className="h-16 w-32 bg-gray-200 rounded-lg flex items-center justify-center text-gray-500">Logo 2</div>
|
|
||||||
<div className="h-16 w-32 bg-gray-200 rounded-lg flex items-center justify-center text-gray-500">Logo 3</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-lg text-gray-600 italic">
|
|
||||||
"PanIA ha transformado completamente nuestra gestión de inventario. ¡Menos desperdicio y más beneficios!" - Panadería San Miguel, Madrid
|
|
||||||
</p>
|
|
||||||
{/* Placeholder for star ratings */}
|
|
||||||
<div className="text-2xl text-yellow-500 mt-4">★★★★★</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Features Section - Cómo Funciona PanIA */}
|
|
||||||
<section className="bg-gray-50 py-20">
|
|
||||||
<div className="container mx-auto px-4">
|
|
||||||
<h2 className="text-4xl font-bold text-pania-charcoal text-center mb-12">
|
|
||||||
Cómo Funciona PanIA
|
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
|
||||||
<div className="bg-pania-white p-8 rounded-lg shadow-md text-center">
|
|
||||||
<div className="text-pania-blue text-5xl mb-4">📊</div> {/* Icon placeholder */}
|
|
||||||
<h3 className="text-xl font-semibold text-pania-charcoal mb-2">Conecta tus Datos</h3>
|
|
||||||
<p className="text-gray-600">Sube tus ventas históricas en 5 minutos de forma segura y sencilla.</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-pania-white p-8 rounded-lg shadow-md text-center">
|
|
||||||
<div className="text-pania-blue text-5xl mb-4">🧠</div> {/* Icon placeholder */}
|
|
||||||
<h3 className="text-xl font-semibold text-pania-charcoal mb-2">IA Entrena tu Modelo</h3>
|
|
||||||
<p className="text-gray-600">Nuestra Inteligencia Artificial aprende los patrones únicos de tu negocio y mercado local.</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-pania-white p-8 rounded-lg shadow-md text-center">
|
|
||||||
<div className="text-pania-blue text-5xl mb-4">📈</div> {/* Icon placeholder */}
|
|
||||||
<h3 className="text-xl font-semibold text-pania-charcoal mb-2">Recibe Predicciones</h3>
|
|
||||||
<p className="text-gray-600">Obtén predicciones diarias precisas automáticamente para optimizar tu producción.</p>
|
|
||||||
</div>
|
|
||||||
<div className="bg-pania-white p-8 rounded-lg shadow-md text-center">
|
|
||||||
<div className="text-pania-blue text-5xl mb-4">💰</div> {/* Icon placeholder */}
|
|
||||||
<h3 className="text-xl font-semibold text-pania-charcoal mb-2">Reduce Desperdicios</h3>
|
|
||||||
<p className="text-gray-600">Ve resultados inmediatos en tu desperdicio y un aumento significativo en tus ganancias.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Call to Action Section */}
|
|
||||||
<section className="bg-pania-blue text-pania-white py-16 text-center"> {/* Tech Blue background */}
|
|
||||||
<div className="container mx-auto px-4">
|
|
||||||
<h2 className="text-3xl md:text-4xl font-bold mb-4">
|
|
||||||
¿Listo para transformar tu panadería?
|
|
||||||
</h2>
|
|
||||||
<p className="text-xl mb-8">
|
|
||||||
Únete a las panaderías que ya están viendo el futuro con PanIA.
|
|
||||||
</p>
|
|
||||||
<Link href="/onboarding" className="bg-pania-golden text-pania-white text-lg font-semibold px-8 py-4 rounded-lg shadow-lg hover:bg-pania-golden-dark transition-transform transform hover:scale-105"> {/* Golden CTA button */}
|
|
||||||
Comienza tu Prueba Gratis
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Trust Signals Section */}
|
|
||||||
<section className="bg-pania-charcoal text-pania-white py-12"> {/* Charcoal background */}
|
|
||||||
<div className="container mx-auto px-4 grid grid-cols-1 md:grid-cols-3 gap-8 text-center">
|
|
||||||
<div>
|
|
||||||
<p className="font-bold text-lg mb-2">Datos seguros y protegidos</p>
|
|
||||||
<p className="text-sm">(GDPR compliant)</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-bold text-lg mb-2">Soporte en español</p>
|
|
||||||
<p className="text-sm">7 días a la semana</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="font-bold text-lg mb-2">Garantía de satisfacción</p>
|
|
||||||
<p className="text-sm">100%</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<footer className="bg-gray-800 text-gray-300 py-8 text-center">
|
|
||||||
<div className="container mx-auto px-4">
|
|
||||||
<p>© {new Date().getFullYear()} PanIA. Todos los derechos reservados.</p>
|
|
||||||
<div className="mt-4 flex justify-center space-x-6">
|
|
||||||
<Link href="#" className="hover:text-white">Política de Privacidad</Link>
|
|
||||||
<Link href="#" className="hover:text-white">Términos de Servicio</Link>
|
|
||||||
<Link href="#" className="hover:text-white">Contacto</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default HomePage;
|
|
||||||
549
frontend/src/pages/landing/LandingPage.tsx
Normal file
549
frontend/src/pages/landing/LandingPage.tsx
Normal file
@@ -0,0 +1,549 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
TrendingUp,
|
||||||
|
TrendingDown,
|
||||||
|
Package,
|
||||||
|
Clock,
|
||||||
|
Users,
|
||||||
|
Star,
|
||||||
|
ChevronRight,
|
||||||
|
CheckCircle,
|
||||||
|
BarChart3,
|
||||||
|
Shield,
|
||||||
|
Smartphone,
|
||||||
|
Play,
|
||||||
|
ArrowRight,
|
||||||
|
MapPin,
|
||||||
|
Quote
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface LandingPageProps {
|
||||||
|
onNavigateToLogin: () => void;
|
||||||
|
onNavigateToRegister: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LandingPage: React.FC<LandingPageProps> = ({ onNavigateToLogin, onNavigateToRegister }) => {
|
||||||
|
const [isVideoModalOpen, setIsVideoModalOpen] = useState(false);
|
||||||
|
const [currentTestimonial, setCurrentTestimonial] = useState(0);
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
icon: TrendingUp,
|
||||||
|
title: "Predicciones Precisas",
|
||||||
|
description: "IA que aprende de tu negocio único para predecir demanda con 87% de precisión",
|
||||||
|
color: "bg-success-100 text-success-600"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: TrendingDown,
|
||||||
|
title: "Reduce Desperdicios",
|
||||||
|
description: "Disminuye hasta un 25% el desperdicio diario optimizando tu producción",
|
||||||
|
color: "bg-primary-100 text-primary-600"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Clock,
|
||||||
|
title: "Ahorra Tiempo",
|
||||||
|
description: "30-45 minutos menos al día en planificación manual de producción",
|
||||||
|
color: "bg-blue-100 text-blue-600"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Package,
|
||||||
|
title: "Gestión Inteligente",
|
||||||
|
description: "Pedidos automáticos y alertas de stock basados en predicciones",
|
||||||
|
color: "bg-purple-100 text-purple-600"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const testimonials = [
|
||||||
|
{
|
||||||
|
name: "María González",
|
||||||
|
business: "Panadería San Miguel",
|
||||||
|
location: "Chamberí, Madrid",
|
||||||
|
text: "Con PanIA reduje mis desperdicios un 20% en el primer mes. La IA realmente entiende mi negocio.",
|
||||||
|
rating: 5,
|
||||||
|
savings: "€280/mes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Carlos Ruiz",
|
||||||
|
business: "Obrador Central Goya",
|
||||||
|
location: "Salamanca, Madrid",
|
||||||
|
text: "Gestiono 4 puntos de venta y PanIA me ahorra 2 horas diarias de planificación. Imprescindible.",
|
||||||
|
rating: 5,
|
||||||
|
savings: "€450/mes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Ana Martín",
|
||||||
|
business: "Café & Pan Malasaña",
|
||||||
|
location: "Malasaña, Madrid",
|
||||||
|
text: "Las predicciones son increíblemente precisas. Ya no me quedo sin croissants en el desayuno.",
|
||||||
|
rating: 5,
|
||||||
|
savings: "€190/mes"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ number: "500+", label: "Panaderías en Madrid" },
|
||||||
|
{ number: "87%", label: "Precisión en predicciones" },
|
||||||
|
{ number: "25%", label: "Reducción desperdicios" },
|
||||||
|
{ number: "€350", label: "Ahorro mensual promedio" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const madridDistricts = [
|
||||||
|
"Centro", "Salamanca", "Chamberí", "Retiro", "Arganzuela",
|
||||||
|
"Moncloa", "Chamartín", "Hortaleza", "Fuencarral", "Tetuán"
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setCurrentTestimonial((prev) => (prev + 1) % testimonials.length);
|
||||||
|
}, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [testimonials.length]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="bg-white shadow-sm sticky top-0 z-40">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex justify-between items-center py-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="h-10 w-10 bg-primary-500 rounded-xl flex items-center justify-center mr-3">
|
||||||
|
<span className="text-white text-xl font-bold">🥖</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-2xl font-bold text-gray-900">PanIA</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="hidden md:flex space-x-8">
|
||||||
|
<a href="#features" className="text-gray-600 hover:text-primary-600 transition-colors">Características</a>
|
||||||
|
<a href="#testimonials" className="text-gray-600 hover:text-primary-600 transition-colors">Testimonios</a>
|
||||||
|
<a href="#pricing" className="text-gray-600 hover:text-primary-600 transition-colors">Precios</a>
|
||||||
|
<a href="#contact" className="text-gray-600 hover:text-primary-600 transition-colors">Contacto</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<button
|
||||||
|
onClick={onNavigateToLogin}
|
||||||
|
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||||
|
>
|
||||||
|
Iniciar sesión
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onNavigateToRegister}
|
||||||
|
className="bg-primary-500 text-white px-6 py-2 rounded-lg hover:bg-primary-600 transition-all hover:shadow-lg"
|
||||||
|
>
|
||||||
|
Prueba gratis
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Hero Section */}
|
||||||
|
<section className="bg-gradient-to-br from-primary-50 to-orange-100 py-20">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
|
||||||
|
<div>
|
||||||
|
<div className="inline-flex items-center bg-primary-100 text-primary-800 px-4 py-2 rounded-full text-sm font-medium mb-6">
|
||||||
|
<Star className="h-4 w-4 mr-2" />
|
||||||
|
IA líder para panaderías en Madrid
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-5xl lg:text-6xl font-bold text-gray-900 mb-6 leading-tight">
|
||||||
|
La primera IA para
|
||||||
|
<span className="text-primary-600 block">tu panadería</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="text-xl text-gray-600 mb-8 leading-relaxed">
|
||||||
|
Transforma tus datos de ventas en predicciones precisas.
|
||||||
|
Reduce desperdicios, maximiza ganancias y optimiza tu producción
|
||||||
|
con inteligencia artificial diseñada para panaderías madrileñas.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 mb-8">
|
||||||
|
<button
|
||||||
|
onClick={onNavigateToRegister}
|
||||||
|
className="bg-primary-500 text-white px-8 py-4 rounded-xl font-semibold text-lg hover:bg-primary-600 transition-all hover:shadow-lg transform hover:-translate-y-1 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
Comenzar gratis
|
||||||
|
<ArrowRight className="h-5 w-5 ml-2" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setIsVideoModalOpen(true)}
|
||||||
|
className="border-2 border-gray-300 text-gray-700 px-8 py-4 rounded-xl font-semibold text-lg hover:border-primary-500 hover:text-primary-600 transition-all flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Play className="h-5 w-5 mr-2" />
|
||||||
|
Ver demo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center text-sm text-gray-500">
|
||||||
|
<CheckCircle className="h-4 w-4 text-green-500 mr-2" />
|
||||||
|
<span>30 días gratis • Sin tarjeta de crédito • Configuración en 5 minutos</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<div className="bg-white rounded-2xl shadow-2xl p-8 border">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">Predicciones para Hoy</h3>
|
||||||
|
<span className="bg-green-100 text-green-800 px-3 py-1 rounded-full text-sm font-medium">
|
||||||
|
87% precisión
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[
|
||||||
|
{ product: "Croissants", predicted: 48, confidence: "high", change: 8 },
|
||||||
|
{ product: "Pan de molde", predicted: 35, confidence: "high", change: 3 },
|
||||||
|
{ product: "Café", predicted: 72, confidence: "medium", change: -5 }
|
||||||
|
].map((item, index) => (
|
||||||
|
<div key={index} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900">{item.product}</div>
|
||||||
|
<div className="text-sm text-gray-500 flex items-center">
|
||||||
|
{item.change > 0 ? (
|
||||||
|
<TrendingUp className="h-3 w-3 text-green-500 mr-1" />
|
||||||
|
) : (
|
||||||
|
<TrendingDown className="h-3 w-3 text-red-500 mr-1" />
|
||||||
|
)}
|
||||||
|
{Math.abs(item.change)} vs ayer
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-2xl font-bold text-gray-900">{item.predicted}</div>
|
||||||
|
<div className={`text-xs px-2 py-1 rounded ${
|
||||||
|
item.confidence === 'high'
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: 'bg-yellow-100 text-yellow-800'
|
||||||
|
}`}>
|
||||||
|
{item.confidence === 'high' ? 'Alta confianza' : 'Media confianza'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating stats */}
|
||||||
|
<div className="absolute -top-6 -right-6 bg-white rounded-xl shadow-lg p-4 border">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-green-600">-25%</div>
|
||||||
|
<div className="text-sm text-gray-600">Desperdicios</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute -bottom-6 -left-6 bg-white rounded-xl shadow-lg p-4 border">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold text-primary-600">€350</div>
|
||||||
|
<div className="text-sm text-gray-600">Ahorro/mes</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Stats Section */}
|
||||||
|
<section className="py-16 bg-white">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="text-center mb-12">
|
||||||
|
<h2 className="text-3xl font-bold text-gray-900 mb-4">
|
||||||
|
Resultados que hablan por sí solos
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 max-w-2xl mx-auto">
|
||||||
|
Más de 500 panaderías en Madrid ya confían en PanIA para optimizar su producción
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
|
||||||
|
{stats.map((stat, index) => (
|
||||||
|
<div key={index} className="text-center">
|
||||||
|
<div className="text-4xl lg:text-5xl font-bold text-primary-600 mb-2">
|
||||||
|
{stat.number}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-600 font-medium">{stat.label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Features Section */}
|
||||||
|
<section id="features" className="py-20 bg-gray-50">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="text-center mb-16">
|
||||||
|
<h2 className="text-4xl font-bold text-gray-900 mb-4">
|
||||||
|
IA diseñada para panaderías madrileñas
|
||||||
|
</h2>
|
||||||
|
<p className="text-xl text-gray-600 max-w-3xl mx-auto">
|
||||||
|
Cada característica está pensada para resolver los desafíos específicos
|
||||||
|
de las panaderías en Madrid: desde el clima hasta los patrones de consumo locales
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 mb-16">
|
||||||
|
{features.map((feature, index) => {
|
||||||
|
const Icon = feature.icon;
|
||||||
|
return (
|
||||||
|
<div key={index} className="bg-white p-8 rounded-2xl shadow-soft hover:shadow-medium transition-all">
|
||||||
|
<div className={`w-12 h-12 ${feature.color} rounded-xl flex items-center justify-center mb-6`}>
|
||||||
|
<Icon className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-xl font-semibold text-gray-900 mb-3">{feature.title}</h3>
|
||||||
|
<p className="text-gray-600 leading-relaxed">{feature.description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Madrid-specific features */}
|
||||||
|
<div className="bg-white rounded-2xl p-8 shadow-soft">
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-bold text-gray-900 mb-4">
|
||||||
|
Especializado en Madrid
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
PanIA conoce Madrid como ninguna otra IA. Integra datos del clima,
|
||||||
|
tráfico, eventos y patrones de consumo específicos de la capital.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-500 mr-3" />
|
||||||
|
<span>Integración con datos meteorológicos de AEMET</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-500 mr-3" />
|
||||||
|
<span>Análisis de eventos y festividades locales</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-500 mr-3" />
|
||||||
|
<span>Patrones de tráfico peatonal por distrito</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-500 mr-3" />
|
||||||
|
<span>Horarios de siesta y patrones españoles</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gradient-to-br from-blue-50 to-primary-50 p-6 rounded-xl">
|
||||||
|
<div className="flex items-center mb-4">
|
||||||
|
<MapPin className="h-5 w-5 text-primary-600 mr-2" />
|
||||||
|
<h4 className="font-semibold text-gray-900">Distritos cubiertos</h4>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{madridDistricts.map((district, index) => (
|
||||||
|
<div key={index} className="text-sm text-gray-700 bg-white px-3 py-1 rounded-lg">
|
||||||
|
{district}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Testimonials Section */}
|
||||||
|
<section id="testimonials" className="py-20 bg-white">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="text-center mb-16">
|
||||||
|
<h2 className="text-4xl font-bold text-gray-900 mb-4">
|
||||||
|
Lo que dicen nuestros clientes
|
||||||
|
</h2>
|
||||||
|
<p className="text-xl text-gray-600">
|
||||||
|
Panaderías reales, resultados reales en Madrid
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<div className="bg-gradient-to-br from-primary-50 to-orange-50 rounded-2xl p-8 lg:p-12">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
<div className="text-center">
|
||||||
|
<Quote className="h-12 w-12 text-primary-400 mx-auto mb-6" />
|
||||||
|
|
||||||
|
<blockquote className="text-2xl lg:text-3xl font-medium text-gray-900 mb-8 leading-relaxed">
|
||||||
|
"{testimonials[currentTestimonial].text}"
|
||||||
|
</blockquote>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center mb-6">
|
||||||
|
{[...Array(testimonials[currentTestimonial].rating)].map((_, i) => (
|
||||||
|
<Star key={i} className="h-5 w-5 text-yellow-400 fill-current" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="font-semibold text-gray-900 text-lg">
|
||||||
|
{testimonials[currentTestimonial].name}
|
||||||
|
</div>
|
||||||
|
<div className="text-primary-600 font-medium">
|
||||||
|
{testimonials[currentTestimonial].business}
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-500 text-sm">
|
||||||
|
{testimonials[currentTestimonial].location}
|
||||||
|
</div>
|
||||||
|
<div className="inline-flex items-center bg-green-100 text-green-800 px-3 py-1 rounded-full text-sm font-medium mt-2">
|
||||||
|
Ahorro: {testimonials[currentTestimonial].savings}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Testimonial indicators */}
|
||||||
|
<div className="flex justify-center mt-8 space-x-2">
|
||||||
|
{testimonials.map((_, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => setCurrentTestimonial(index)}
|
||||||
|
className={`w-3 h-3 rounded-full transition-all ${
|
||||||
|
index === currentTestimonial ? 'bg-primary-500' : 'bg-gray-300'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* CTA Section */}
|
||||||
|
<section className="py-20 bg-gradient-to-br from-primary-600 to-orange-600">
|
||||||
|
<div className="max-w-4xl mx-auto text-center px-4 sm:px-6 lg:px-8">
|
||||||
|
<h2 className="text-4xl lg:text-5xl font-bold text-white mb-6">
|
||||||
|
¿Listo para transformar tu panadería?
|
||||||
|
</h2>
|
||||||
|
<p className="text-xl text-primary-100 mb-8 leading-relaxed">
|
||||||
|
Únete a más de 500 panaderías en Madrid que ya reducen desperdicios
|
||||||
|
y maximizan ganancias con PanIA
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 justify-center mb-8">
|
||||||
|
<button
|
||||||
|
onClick={onNavigateToRegister}
|
||||||
|
className="bg-white text-primary-600 px-8 py-4 rounded-xl font-semibold text-lg hover:bg-gray-50 transition-all hover:shadow-lg transform hover:-translate-y-1"
|
||||||
|
>
|
||||||
|
Comenzar prueba gratuita
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onNavigateToLogin}
|
||||||
|
className="border-2 border-white text-white px-8 py-4 rounded-xl font-semibold text-lg hover:bg-white hover:text-primary-600 transition-all"
|
||||||
|
>
|
||||||
|
Ya tengo cuenta
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-center gap-8 text-primary-100">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<CheckCircle className="h-5 w-5 mr-2" />
|
||||||
|
<span>30 días gratis</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<CheckCircle className="h-5 w-5 mr-2" />
|
||||||
|
<span>Sin tarjeta de crédito</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<CheckCircle className="h-5 w-5 mr-2" />
|
||||||
|
<span>Soporte en español</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="bg-gray-900 text-white py-12">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center mb-4">
|
||||||
|
<div className="h-8 w-8 bg-primary-500 rounded-lg flex items-center justify-center mr-3">
|
||||||
|
<span className="text-white text-lg font-bold">🥖</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-bold">PanIA</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-400 mb-4">
|
||||||
|
Inteligencia Artificial para panaderías en Madrid
|
||||||
|
</p>
|
||||||
|
<div className="text-gray-400 text-sm">
|
||||||
|
<p>📍 Madrid, España</p>
|
||||||
|
<p>📧 hola@pania.es</p>
|
||||||
|
<p>📞 +34 900 123 456</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-4">Producto</h3>
|
||||||
|
<ul className="space-y-2 text-gray-400">
|
||||||
|
<li><a href="#features" className="hover:text-white transition-colors">Características</a></li>
|
||||||
|
<li><a href="#pricing" className="hover:text-white transition-colors">Precios</a></li>
|
||||||
|
<li><a href="#" className="hover:text-white transition-colors">Demo</a></li>
|
||||||
|
<li><a href="#" className="hover:text-white transition-colors">API</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-4">Soporte</h3>
|
||||||
|
<ul className="space-y-2 text-gray-400">
|
||||||
|
<li><a href="#" className="hover:text-white transition-colors">Centro de ayuda</a></li>
|
||||||
|
<li><a href="#" className="hover:text-white transition-colors">Documentación</a></li>
|
||||||
|
<li><a href="#contact" className="hover:text-white transition-colors">Contacto</a></li>
|
||||||
|
<li><a href="#" className="hover:text-white transition-colors">Estado del sistema</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold mb-4">Legal</h3>
|
||||||
|
<ul className="space-y-2 text-gray-400">
|
||||||
|
<li><a href="#" className="hover:text-white transition-colors">Privacidad</a></li>
|
||||||
|
<li><a href="#" className="hover:text-white transition-colors">Términos</a></li>
|
||||||
|
<li><a href="#" className="hover:text-white transition-colors">Cookies</a></li>
|
||||||
|
<li><a href="#" className="hover:text-white transition-colors">GDPR</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-800 mt-12 pt-8 text-center text-gray-400">
|
||||||
|
<p>© 2024 PanIA. Todos los derechos reservados. Hecho con ❤️ en Madrid.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{/* Video Modal */}
|
||||||
|
{isVideoModalOpen && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center p-4 z-50">
|
||||||
|
<div className="bg-white rounded-2xl p-6 max-w-4xl w-full max-h-[90vh] overflow-auto">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h3 className="text-2xl font-bold text-gray-900">Demo de PanIA</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsVideoModalOpen(false)}
|
||||||
|
className="text-gray-500 hover:text-gray-700 text-2xl"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="aspect-video bg-gray-100 rounded-lg flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<Play className="h-16 w-16 text-gray-400 mx-auto mb-4" />
|
||||||
|
<p className="text-gray-600">Video demo disponible próximamente</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-2">
|
||||||
|
Mientras tanto, puedes comenzar tu prueba gratuita
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsVideoModalOpen(false);
|
||||||
|
onNavigateToRegister();
|
||||||
|
}}
|
||||||
|
className="mt-4 bg-primary-500 text-white px-6 py-2 rounded-lg hover:bg-primary-600 transition-colors"
|
||||||
|
>
|
||||||
|
Comenzar prueba gratis
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LandingPage;
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import Head from 'next/head';
|
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
|
||||||
|
|
||||||
const Login = () => {
|
|
||||||
const [username, setUsername] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [error, setError] = useState('');
|
|
||||||
const { login } = useAuth();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
await login(username, password);
|
|
||||||
router.push('/dashboard'); // Assuming a dashboard route after login
|
|
||||||
} catch (err) {
|
|
||||||
setError('Credenciales inválidas. Inténtalo de nuevo.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-pania-golden"> {/* Updated background to PanIA golden */}
|
|
||||||
<Head>
|
|
||||||
<title>Login - PanIA</title> {/* Updated title with PanIA */}
|
|
||||||
</Head>
|
|
||||||
<div className="bg-pania-white p-8 rounded-lg shadow-lg max-w-md w-full"> {/* Updated background to PanIA white */}
|
|
||||||
<div className="text-center mb-6">
|
|
||||||
<h1 className="text-4xl font-extrabold text-pania-charcoal mb-2">PanIA</h1> {/* Updated to PanIA brand name and charcoal color */}
|
|
||||||
<p className="text-pania-blue text-lg">Inteligencia Artificial para tu Panadería</p> {/* Added tagline and tech blue color */}
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="username" className="block text-sm font-medium text-pania-charcoal">
|
|
||||||
Usuario
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="username"
|
|
||||||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:ring-pania-blue focus:border-pania-blue sm:text-sm"
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="password" className="block text-sm font-medium text-pania-charcoal">
|
|
||||||
Contraseña
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
id="password"
|
|
||||||
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:ring-pania-blue focus:border-pania-blue sm:text-sm"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{error && <p className="text-red-500 text-sm text-center">{error}</p>}
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-pania-white bg-pania-blue hover:bg-pania-blue-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pania-blue" // Updated button styles
|
|
||||||
>
|
|
||||||
Iniciar Sesión
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Login;
|
|
||||||
File diff suppressed because it is too large
Load Diff
516
frontend/src/pages/onboarding/OnboardingPage.tsx
Normal file
516
frontend/src/pages/onboarding/OnboardingPage.tsx
Normal file
@@ -0,0 +1,516 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { ChevronLeft, ChevronRight, Upload, MapPin, Store, Factory, Check } from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface OnboardingPageProps {
|
||||||
|
user: any;
|
||||||
|
onComplete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BakeryData {
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
businessType: 'individual' | 'central_workshop';
|
||||||
|
coordinates?: { lat: number; lng: number };
|
||||||
|
products: string[];
|
||||||
|
hasHistoricalData: boolean;
|
||||||
|
csvFile?: File;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MADRID_PRODUCTS = [
|
||||||
|
'Croissants', 'Pan de molde', 'Baguettes', 'Panecillos', 'Ensaimadas',
|
||||||
|
'Napolitanas', 'Magdalenas', 'Donuts', 'Palmeras', 'Café',
|
||||||
|
'Chocolate caliente', 'Zumos', 'Bocadillos', 'Empanadas', 'Tartas'
|
||||||
|
];
|
||||||
|
|
||||||
|
const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) => {
|
||||||
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [bakeryData, setBakeryData] = useState<BakeryData>({
|
||||||
|
name: '',
|
||||||
|
address: '',
|
||||||
|
businessType: 'individual',
|
||||||
|
products: [],
|
||||||
|
hasHistoricalData: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ id: 1, title: 'Datos de Panadería', icon: Store },
|
||||||
|
{ id: 2, title: 'Productos y Servicios', icon: Factory },
|
||||||
|
{ id: 3, title: 'Datos Históricos', icon: Upload },
|
||||||
|
{ id: 4, title: 'Configuración Final', icon: Check }
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
if (validateCurrentStep()) {
|
||||||
|
setCurrentStep(prev => Math.min(prev + 1, steps.length));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrevious = () => {
|
||||||
|
setCurrentStep(prev => Math.max(prev - 1, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateCurrentStep = (): boolean => {
|
||||||
|
switch (currentStep) {
|
||||||
|
case 1:
|
||||||
|
if (!bakeryData.name.trim()) {
|
||||||
|
toast.error('El nombre de la panadería es obligatorio');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!bakeryData.address.trim()) {
|
||||||
|
toast.error('La dirección es obligatoria');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
case 2:
|
||||||
|
if (bakeryData.products.length === 0) {
|
||||||
|
toast.error('Selecciona al menos un producto');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
case 3:
|
||||||
|
if (bakeryData.hasHistoricalData && !bakeryData.csvFile) {
|
||||||
|
toast.error('Por favor, sube tu archivo CSV con datos históricos');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleComplete = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Register tenant/bakery
|
||||||
|
const tenantResponse = await fetch('/api/v1/tenants/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: bakeryData.name,
|
||||||
|
address: bakeryData.address,
|
||||||
|
business_type: bakeryData.businessType,
|
||||||
|
coordinates: bakeryData.coordinates,
|
||||||
|
products: bakeryData.products
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tenantResponse.ok) {
|
||||||
|
throw new Error('Error al registrar la panadería');
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenantData = await tenantResponse.json();
|
||||||
|
const tenantId = tenantData.tenant.id;
|
||||||
|
|
||||||
|
// Step 2: Upload CSV data if provided
|
||||||
|
if (bakeryData.hasHistoricalData && bakeryData.csvFile) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', bakeryData.csvFile);
|
||||||
|
|
||||||
|
const uploadResponse = await fetch(`/api/v1/tenants/${tenantId}/data/upload`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!uploadResponse.ok) {
|
||||||
|
throw new Error('Error al subir los datos históricos');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Start training process
|
||||||
|
const trainingResponse = await fetch(`/api/v1/tenants/${tenantId}/training/start`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
products: bakeryData.products
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!trainingResponse.ok) {
|
||||||
|
throw new Error('Error al iniciar el entrenamiento del modelo');
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success('¡Datos subidos! El entrenamiento del modelo comenzará pronto.');
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success('¡Configuración completada! Bienvenido a PanIA');
|
||||||
|
onComplete();
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Onboarding completion error:', error);
|
||||||
|
toast.error(error.message || 'Error al completar la configuración');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStep = () => {
|
||||||
|
switch (currentStep) {
|
||||||
|
case 1:
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
Información de tu Panadería
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Nombre de la panadería
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={bakeryData.name}
|
||||||
|
onChange={(e) => setBakeryData(prev => ({ ...prev, name: e.target.value }))}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
placeholder="Ej: Panadería San Miguel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Dirección completa
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<MapPin className="absolute left-3 top-3 h-5 w-5 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={bakeryData.address}
|
||||||
|
onChange={(e) => setBakeryData(prev => ({ ...prev, address: e.target.value }))}
|
||||||
|
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
placeholder="Calle Mayor, 123, Madrid"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||||
|
Tipo de negocio
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBakeryData(prev => ({ ...prev, businessType: 'individual' }))}
|
||||||
|
className={`p-4 border rounded-xl text-left transition-all ${
|
||||||
|
bakeryData.businessType === 'individual'
|
||||||
|
? 'border-primary-500 bg-primary-50 text-primary-700'
|
||||||
|
: 'border-gray-300 hover:border-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Store className="h-6 w-6 mb-2" />
|
||||||
|
<div className="font-medium">Panadería Individual</div>
|
||||||
|
<div className="text-sm text-gray-500">Una sola ubicación</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBakeryData(prev => ({ ...prev, businessType: 'central_workshop' }))}
|
||||||
|
className={`p-4 border rounded-xl text-left transition-all ${
|
||||||
|
bakeryData.businessType === 'central_workshop'
|
||||||
|
? 'border-primary-500 bg-primary-50 text-primary-700'
|
||||||
|
: 'border-gray-300 hover:border-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Factory className="h-6 w-6 mb-2" />
|
||||||
|
<div className="font-medium">Obrador Central</div>
|
||||||
|
<div className="text-sm text-gray-500">Múltiples ubicaciones</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 2:
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
¿Qué productos vendes?
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
Selecciona los productos más comunes en tu panadería
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||||
|
{MADRID_PRODUCTS.map((product) => (
|
||||||
|
<button
|
||||||
|
key={product}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setBakeryData(prev => ({
|
||||||
|
...prev,
|
||||||
|
products: prev.products.includes(product)
|
||||||
|
? prev.products.filter(p => p !== product)
|
||||||
|
: [...prev.products, product]
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
className={`p-3 text-sm border rounded-lg transition-all text-left ${
|
||||||
|
bakeryData.products.includes(product)
|
||||||
|
? 'border-primary-500 bg-primary-50 text-primary-700'
|
||||||
|
: 'border-gray-300 hover:border-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{product}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Productos seleccionados: {bakeryData.products.length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 3:
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
Datos Históricos de Ventas
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
Para obtener mejores predicciones, puedes subir tus datos históricos de ventas
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center space-x-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={bakeryData.hasHistoricalData}
|
||||||
|
onChange={(e) => setBakeryData(prev => ({
|
||||||
|
...prev,
|
||||||
|
hasHistoricalData: e.target.checked,
|
||||||
|
csvFile: e.target.checked ? prev.csvFile : undefined
|
||||||
|
}))}
|
||||||
|
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<span className="text-gray-700">
|
||||||
|
Tengo datos históricos de ventas (recomendado)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bakeryData.hasHistoricalData && (
|
||||||
|
<div className="border-2 border-dashed border-gray-300 rounded-xl p-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
|
||||||
|
|
||||||
|
{bakeryData.csvFile ? (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900 mb-2">
|
||||||
|
Archivo seleccionado:
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
|
{bakeryData.csvFile.name}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setBakeryData(prev => ({ ...prev, csvFile: undefined }))}
|
||||||
|
className="text-sm text-red-600 hover:text-red-500"
|
||||||
|
>
|
||||||
|
Eliminar archivo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
|
Sube tu archivo CSV con las ventas históricas
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".csv"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
setBakeryData(prev => ({ ...prev, csvFile: file }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="hidden"
|
||||||
|
id="csv-upload"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="csv-upload"
|
||||||
|
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 cursor-pointer"
|
||||||
|
>
|
||||||
|
Seleccionar archivo CSV
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 text-xs text-gray-500">
|
||||||
|
<p className="font-medium mb-1">Formato esperado del CSV:</p>
|
||||||
|
<p>Fecha, Producto, Cantidad</p>
|
||||||
|
<p>2024-01-01, Croissants, 45</p>
|
||||||
|
<p>2024-01-01, Pan de molde, 12</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!bakeryData.hasHistoricalData && (
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||||
|
<p className="text-blue-800 text-sm">
|
||||||
|
No te preocupes, PanIA puede empezar a funcionar sin datos históricos.
|
||||||
|
Las predicciones mejorarán automáticamente conforme uses el sistema.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 4:
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="mx-auto h-16 w-16 bg-success-100 rounded-full flex items-center justify-center mb-4">
|
||||||
|
<Check className="h-8 w-8 text-success-600" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
¡Todo listo para comenzar!
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
Revisa los datos de tu panadería antes de continuar
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-50 rounded-xl p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-gray-500">Panadería:</span>
|
||||||
|
<p className="text-gray-900">{bakeryData.name}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-gray-500">Dirección:</span>
|
||||||
|
<p className="text-gray-900">{bakeryData.address}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-gray-500">Tipo de negocio:</span>
|
||||||
|
<p className="text-gray-900">
|
||||||
|
{bakeryData.businessType === 'individual' ? 'Panadería Individual' : 'Obrador Central'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-gray-500">Productos:</span>
|
||||||
|
<p className="text-gray-900">{bakeryData.products.join(', ')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-gray-500">Datos históricos:</span>
|
||||||
|
<p className="text-gray-900">
|
||||||
|
{bakeryData.hasHistoricalData ? `Sí (${bakeryData.csvFile?.name})` : 'No'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 py-8">
|
||||||
|
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="mx-auto h-12 w-12 bg-primary-500 rounded-xl flex items-center justify-center mb-4">
|
||||||
|
<span className="text-white text-xl font-bold">🥖</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">
|
||||||
|
Configuración inicial
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Vamos a configurar PanIA para tu panadería
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Steps */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
{steps.map((step) => (
|
||||||
|
<div key={step.id} className="flex flex-col items-center">
|
||||||
|
<div
|
||||||
|
className={`w-10 h-10 rounded-full flex items-center justify-center border-2 transition-all ${
|
||||||
|
currentStep >= step.id
|
||||||
|
? 'bg-primary-500 border-primary-500 text-white'
|
||||||
|
: 'border-gray-300 text-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<step.icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<span className="mt-2 text-xs text-gray-500 text-center max-w-20">
|
||||||
|
{step.title}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-primary-500 h-2 rounded-full transition-all duration-500"
|
||||||
|
style={{ width: `${(currentStep / steps.length) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step Content */}
|
||||||
|
<div className="bg-white rounded-2xl shadow-soft p-6 mb-8">
|
||||||
|
{renderStep()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation */}
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<button
|
||||||
|
onClick={handlePrevious}
|
||||||
|
disabled={currentStep === 1}
|
||||||
|
className="flex items-center px-4 py-2 text-gray-600 disabled:text-gray-400 disabled:cursor-not-allowed hover:text-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-5 w-5 mr-1" />
|
||||||
|
Anterior
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{currentStep < steps.length ? (
|
||||||
|
<button
|
||||||
|
onClick={handleNext}
|
||||||
|
className="flex items-center px-6 py-2 bg-primary-500 text-white rounded-xl hover:bg-primary-600 transition-all hover:shadow-lg"
|
||||||
|
>
|
||||||
|
Siguiente
|
||||||
|
<ChevronRight className="h-5 w-5 ml-1" />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleComplete}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex items-center px-6 py-2 bg-success-500 text-white rounded-xl hover:bg-success-600 transition-all hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Configurando...' : 'Completar configuración'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OnboardingPage;
|
||||||
424
frontend/src/pages/orders/OrdersPage.tsx
Normal file
424
frontend/src/pages/orders/OrdersPage.tsx
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { Package, Plus, Edit, Trash2, Calendar, CheckCircle, AlertCircle, Clock } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Order {
|
||||||
|
id: string;
|
||||||
|
supplier: string;
|
||||||
|
items: OrderItem[];
|
||||||
|
orderDate: string;
|
||||||
|
deliveryDate: string;
|
||||||
|
status: 'pending' | 'confirmed' | 'delivered' | 'cancelled';
|
||||||
|
total: number;
|
||||||
|
type: 'ingredients' | 'consumables';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrderItem {
|
||||||
|
name: string;
|
||||||
|
quantity: number;
|
||||||
|
unit: string;
|
||||||
|
price: number;
|
||||||
|
suggested?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OrdersPage: React.FC = () => {
|
||||||
|
const [orders, setOrders] = useState<Order[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [showNewOrder, setShowNewOrder] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<'all' | 'pending' | 'delivered'>('all');
|
||||||
|
|
||||||
|
// Sample orders data
|
||||||
|
const sampleOrders: Order[] = [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
supplier: 'Harinas Castellana',
|
||||||
|
items: [
|
||||||
|
{ name: 'Harina de trigo', quantity: 50, unit: 'kg', price: 0.85, suggested: true },
|
||||||
|
{ name: 'Levadura fresca', quantity: 2, unit: 'kg', price: 3.20 },
|
||||||
|
{ name: 'Sal marina', quantity: 5, unit: 'kg', price: 1.10 }
|
||||||
|
],
|
||||||
|
orderDate: '2024-11-03',
|
||||||
|
deliveryDate: '2024-11-05',
|
||||||
|
status: 'pending',
|
||||||
|
total: 52.50,
|
||||||
|
type: 'ingredients'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
supplier: 'Distribuciones Madrid',
|
||||||
|
items: [
|
||||||
|
{ name: 'Vasos de café 250ml', quantity: 1000, unit: 'unidades', price: 0.08 },
|
||||||
|
{ name: 'Bolsas papel kraft', quantity: 500, unit: 'unidades', price: 0.12, suggested: true },
|
||||||
|
{ name: 'Servilletas', quantity: 20, unit: 'paquetes', price: 2.50 }
|
||||||
|
],
|
||||||
|
orderDate: '2024-11-02',
|
||||||
|
deliveryDate: '2024-11-04',
|
||||||
|
status: 'confirmed',
|
||||||
|
total: 190.00,
|
||||||
|
type: 'consumables'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
supplier: 'Lácteos Frescos SA',
|
||||||
|
items: [
|
||||||
|
{ name: 'Leche entera', quantity: 20, unit: 'litros', price: 0.95 },
|
||||||
|
{ name: 'Mantequilla', quantity: 5, unit: 'kg', price: 4.20 },
|
||||||
|
{ name: 'Nata para montar', quantity: 3, unit: 'litros', price: 2.80 }
|
||||||
|
],
|
||||||
|
orderDate: '2024-11-01',
|
||||||
|
deliveryDate: '2024-11-03',
|
||||||
|
status: 'delivered',
|
||||||
|
total: 47.40,
|
||||||
|
type: 'ingredients'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadOrders = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Simulate API call
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 800));
|
||||||
|
setOrders(sampleOrders);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading orders:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadOrders();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending':
|
||||||
|
return 'bg-warning-100 text-warning-800 border-warning-200';
|
||||||
|
case 'confirmed':
|
||||||
|
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||||
|
case 'delivered':
|
||||||
|
return 'bg-success-100 text-success-800 border-success-200';
|
||||||
|
case 'cancelled':
|
||||||
|
return 'bg-red-100 text-red-800 border-red-200';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusLabel = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending':
|
||||||
|
return 'Pendiente';
|
||||||
|
case 'confirmed':
|
||||||
|
return 'Confirmado';
|
||||||
|
case 'delivered':
|
||||||
|
return 'Entregado';
|
||||||
|
case 'cancelled':
|
||||||
|
return 'Cancelado';
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusIcon = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending':
|
||||||
|
return <Clock className="h-4 w-4" />;
|
||||||
|
case 'confirmed':
|
||||||
|
return <AlertCircle className="h-4 w-4" />;
|
||||||
|
case 'delivered':
|
||||||
|
return <CheckCircle className="h-4 w-4" />;
|
||||||
|
case 'cancelled':
|
||||||
|
return <AlertCircle className="h-4 w-4" />;
|
||||||
|
default:
|
||||||
|
return <Clock className="h-4 w-4" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredOrders = orders.filter(order => {
|
||||||
|
if (activeTab === 'all') return true;
|
||||||
|
if (activeTab === 'pending') return order.status === 'pending' || order.status === 'confirmed';
|
||||||
|
if (activeTab === 'delivered') return order.status === 'delivered';
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDeleteOrder = (orderId: string) => {
|
||||||
|
setOrders(prev => prev.filter(order => order.id !== orderId));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="animate-pulse space-y-6">
|
||||||
|
<div className="h-8 bg-gray-200 rounded w-1/3"></div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<div key={i} className="h-48 bg-gray-200 rounded-xl"></div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Gestión de Pedidos</h1>
|
||||||
|
<p className="text-gray-600 mt-1">
|
||||||
|
Administra tus pedidos de ingredientes y consumibles
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewOrder(true)}
|
||||||
|
className="mt-4 sm:mt-0 inline-flex items-center px-4 py-2 bg-primary-500 text-white rounded-xl hover:bg-primary-600 transition-all hover:shadow-lg"
|
||||||
|
>
|
||||||
|
<Plus className="h-5 w-5 mr-2" />
|
||||||
|
Nuevo Pedido
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="bg-white rounded-xl shadow-soft p-1">
|
||||||
|
<div className="flex space-x-1">
|
||||||
|
{[
|
||||||
|
{ id: 'all', label: 'Todos', count: orders.length },
|
||||||
|
{ id: 'pending', label: 'Pendientes', count: orders.filter(o => o.status === 'pending' || o.status === 'confirmed').length },
|
||||||
|
{ id: 'delivered', label: 'Entregados', count: orders.filter(o => o.status === 'delivered').length }
|
||||||
|
].map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id as any)}
|
||||||
|
className={`flex-1 py-2 px-4 text-sm font-medium rounded-lg transition-all ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'bg-primary-100 text-primary-700'
|
||||||
|
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.label} ({tab.count})
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AI Suggestions */}
|
||||||
|
<div className="bg-gradient-to-r from-primary-50 to-orange-50 border border-primary-200 rounded-xl p-6">
|
||||||
|
<div className="flex items-start">
|
||||||
|
<div className="p-2 bg-primary-100 rounded-lg mr-4">
|
||||||
|
<Package className="h-6 w-6 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold text-primary-900 mb-2">Sugerencias Inteligentes de Pedidos</h3>
|
||||||
|
<div className="space-y-2 text-sm text-primary-800">
|
||||||
|
<p>• <strong>Harina de trigo:</strong> Stock bajo detectado. Recomendamos pedir 50kg para cubrir 2 semanas.</p>
|
||||||
|
<p>• <strong>Bolsas de papel:</strong> Aumento del 15% en takeaway. Considera aumentar el pedido habitual.</p>
|
||||||
|
<p>• <strong>Café en grano:</strong> Predicción de alta demanda por temperaturas bajas. +20% recomendado.</p>
|
||||||
|
</div>
|
||||||
|
<button className="mt-3 text-primary-700 hover:text-primary-600 font-medium text-sm">
|
||||||
|
Ver todas las sugerencias →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Orders Grid */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{filteredOrders.map((order) => (
|
||||||
|
<div key={order.id} className="bg-white rounded-xl shadow-soft hover:shadow-medium transition-shadow">
|
||||||
|
{/* Order Header */}
|
||||||
|
<div className="p-6 border-b border-gray-100">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h3 className="font-semibold text-gray-900">{order.supplier}</h3>
|
||||||
|
<span className={`px-2 py-1 rounded-lg text-xs font-medium border flex items-center ${getStatusColor(order.status)}`}>
|
||||||
|
{getStatusIcon(order.status)}
|
||||||
|
<span className="ml-1">{getStatusLabel(order.status)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center text-sm text-gray-600 mb-2">
|
||||||
|
<Calendar className="h-4 w-4 mr-1" />
|
||||||
|
<span>Entrega: {new Date(order.deliveryDate).toLocaleDateString('es-ES')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className={`text-xs px-2 py-1 rounded ${
|
||||||
|
order.type === 'ingredients'
|
||||||
|
? 'bg-green-100 text-green-800'
|
||||||
|
: 'bg-blue-100 text-blue-800'
|
||||||
|
}`}>
|
||||||
|
{order.type === 'ingredients' ? 'Ingredientes' : 'Consumibles'}
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-bold text-gray-900">€{order.total.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Order Items */}
|
||||||
|
<div className="p-6">
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-3">Artículos ({order.items.length})</h4>
|
||||||
|
<div className="space-y-2 max-h-32 overflow-y-auto">
|
||||||
|
{order.items.map((item, index) => (
|
||||||
|
<div key={index} className="flex justify-between text-sm">
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="text-gray-900">{item.name}</span>
|
||||||
|
{item.suggested && (
|
||||||
|
<span className="ml-2 text-xs bg-primary-100 text-primary-700 px-1 py-0.5 rounded">
|
||||||
|
IA
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="text-gray-500 text-xs">
|
||||||
|
{item.quantity} {item.unit} × €{item.price.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-900 font-medium">
|
||||||
|
€{(item.quantity * item.price).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Order Actions */}
|
||||||
|
<div className="px-6 pb-6">
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<button className="flex-1 py-2 px-3 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors flex items-center justify-center">
|
||||||
|
<Edit className="h-4 w-4 mr-1" />
|
||||||
|
Editar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteOrder(order.id)}
|
||||||
|
className="py-2 px-3 text-sm bg-red-100 text-red-700 rounded-lg hover:bg-red-200 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{filteredOrders.length === 0 && (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Package className="mx-auto h-12 w-12 text-gray-400 mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">No hay pedidos</h3>
|
||||||
|
<p className="text-gray-600 mb-4">
|
||||||
|
{activeTab === 'all'
|
||||||
|
? 'Aún no has creado ningún pedido'
|
||||||
|
: `No hay pedidos ${activeTab === 'pending' ? 'pendientes' : 'entregados'}`
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewOrder(true)}
|
||||||
|
className="inline-flex items-center px-4 py-2 bg-primary-500 text-white rounded-xl hover:bg-primary-600 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="h-5 w-5 mr-2" />
|
||||||
|
Crear primer pedido
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick Stats */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="p-2 bg-primary-100 rounded-lg">
|
||||||
|
<Package className="h-6 w-6 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4">
|
||||||
|
<p className="text-sm font-medium text-gray-600">Total Pedidos</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{orders.length}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="p-2 bg-warning-100 rounded-lg">
|
||||||
|
<Clock className="h-6 w-6 text-warning-600" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4">
|
||||||
|
<p className="text-sm font-medium text-gray-600">Pendientes</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
|
{orders.filter(o => o.status === 'pending' || o.status === 'confirmed').length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="p-2 bg-success-100 rounded-lg">
|
||||||
|
<CheckCircle className="h-6 w-6 text-success-600" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4">
|
||||||
|
<p className="text-sm font-medium text-gray-600">Gasto Mensual</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
|
€{orders.reduce((sum, order) => sum + order.total, 0).toFixed(0)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
|
<div className="bg-white p-6 rounded-xl shadow-soft">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
Acciones Rápidas
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<button className="p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all text-left">
|
||||||
|
<div className="font-medium text-gray-900">Pedido Automático</div>
|
||||||
|
<div className="text-sm text-gray-500 mt-1">Basado en predicciones IA</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button className="p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all text-left">
|
||||||
|
<div className="font-medium text-gray-900">Gestión de Proveedores</div>
|
||||||
|
<div className="text-sm text-gray-500 mt-1">Añadir o editar proveedores</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button className="p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all text-left">
|
||||||
|
<div className="font-medium text-gray-900">Historial de Gastos</div>
|
||||||
|
<div className="text-sm text-gray-500 mt-1">Ver análisis de costos</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button className="p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all text-left">
|
||||||
|
<div className="font-medium text-gray-900">Configurar Alertas</div>
|
||||||
|
<div className="text-sm text-gray-500 mt-1">Stock bajo y vencimientos</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New Order Modal Placeholder */}
|
||||||
|
{showNewOrder && (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||||
|
<div className="bg-white rounded-2xl p-6 max-w-md w-full">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Nuevo Pedido</h3>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
Esta funcionalidad estará disponible próximamente. PanIA analizará tus necesidades
|
||||||
|
y creará pedidos automáticos basados en las predicciones de demanda.
|
||||||
|
</p>
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewOrder(false)}
|
||||||
|
className="flex-1 py-2 px-4 bg-gray-100 text-gray-700 rounded-xl hover:bg-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
Cerrar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewOrder(false)}
|
||||||
|
className="flex-1 py-2 px-4 bg-primary-500 text-white rounded-xl hover:bg-primary-600 transition-colors"
|
||||||
|
>
|
||||||
|
Entendido
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OrdersPage;
|
||||||
616
frontend/src/pages/settings/SettingsPage.tsx
Normal file
616
frontend/src/pages/settings/SettingsPage.tsx
Normal file
@@ -0,0 +1,616 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
User,
|
||||||
|
Bell,
|
||||||
|
Shield,
|
||||||
|
Globe,
|
||||||
|
Smartphone,
|
||||||
|
Mail,
|
||||||
|
LogOut,
|
||||||
|
Save,
|
||||||
|
ChevronRight,
|
||||||
|
MapPin,
|
||||||
|
Clock,
|
||||||
|
DollarSign
|
||||||
|
} from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
|
interface SettingsPageProps {
|
||||||
|
user: any;
|
||||||
|
onLogout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserSettings {
|
||||||
|
fullName: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
language: string;
|
||||||
|
timezone: string;
|
||||||
|
currency: string;
|
||||||
|
bakeryName: string;
|
||||||
|
bakeryAddress: string;
|
||||||
|
businessType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationSettings {
|
||||||
|
emailNotifications: boolean;
|
||||||
|
smsNotifications: boolean;
|
||||||
|
dailyReports: boolean;
|
||||||
|
weeklyReports: boolean;
|
||||||
|
forecastAlerts: boolean;
|
||||||
|
stockAlerts: boolean;
|
||||||
|
orderReminders: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SettingsPage: React.FC<SettingsPageProps> = ({ user, onLogout }) => {
|
||||||
|
const [activeTab, setActiveTab] = useState('profile');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const [userSettings, setUserSettings] = useState<UserSettings>({
|
||||||
|
fullName: user.fullName || '',
|
||||||
|
email: user.email || '',
|
||||||
|
phone: '',
|
||||||
|
language: 'es',
|
||||||
|
timezone: 'Europe/Madrid',
|
||||||
|
currency: 'EUR',
|
||||||
|
bakeryName: 'Mi Panadería',
|
||||||
|
bakeryAddress: '',
|
||||||
|
businessType: 'individual'
|
||||||
|
});
|
||||||
|
|
||||||
|
const [notificationSettings, setNotificationSettings] = useState<NotificationSettings>({
|
||||||
|
emailNotifications: true,
|
||||||
|
smsNotifications: false,
|
||||||
|
dailyReports: true,
|
||||||
|
weeklyReports: true,
|
||||||
|
forecastAlerts: true,
|
||||||
|
stockAlerts: true,
|
||||||
|
orderReminders: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: 'profile', label: 'Perfil', icon: User },
|
||||||
|
{ id: 'notifications', label: 'Notificaciones', icon: Bell },
|
||||||
|
{ id: 'security', label: 'Seguridad', icon: Shield },
|
||||||
|
{ id: 'preferences', label: 'Preferencias', icon: Globe },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleSaveSettings = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Simulate API call
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
|
||||||
|
toast.success('Configuración guardada exitosamente');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Error al guardar la configuración');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
if (window.confirm('¿Estás seguro de que quieres cerrar sesión?')) {
|
||||||
|
onLogout();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderProfileTab = () => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Información Personal</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Nombre completo
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={userSettings.fullName}
|
||||||
|
onChange={(e) => setUserSettings(prev => ({ ...prev, fullName: e.target.value }))}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Correo electrónico
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={userSettings.email}
|
||||||
|
onChange={(e) => setUserSettings(prev => ({ ...prev, email: e.target.value }))}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Teléfono
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
value={userSettings.phone}
|
||||||
|
onChange={(e) => setUserSettings(prev => ({ ...prev, phone: e.target.value }))}
|
||||||
|
placeholder="+34 600 000 000"
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Información del Negocio</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Nombre de la panadería
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={userSettings.bakeryName}
|
||||||
|
onChange={(e) => setUserSettings(prev => ({ ...prev, bakeryName: e.target.value }))}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Dirección
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<MapPin className="absolute left-3 top-3 h-5 w-5 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={userSettings.bakeryAddress}
|
||||||
|
onChange={(e) => setUserSettings(prev => ({ ...prev, bakeryAddress: e.target.value }))}
|
||||||
|
placeholder="Calle Mayor, 123, Madrid"
|
||||||
|
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Tipo de negocio
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={userSettings.businessType}
|
||||||
|
onChange={(e) => setUserSettings(prev => ({ ...prev, businessType: e.target.value }))}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
>
|
||||||
|
<option value="individual">Panadería Individual</option>
|
||||||
|
<option value="central_workshop">Obrador Central</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Horarios de Operación</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Hora de apertura
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
defaultValue="07:00"
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Hora de cierre
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
defaultValue="20:00"
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||||
|
Días de operación
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-4 gap-2 sm:grid-cols-7">
|
||||||
|
{['L', 'M', 'X', 'J', 'V', 'S', 'D'].map((day, index) => (
|
||||||
|
<label key={day} className="flex items-center justify-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
defaultChecked={index < 6} // Monday to Saturday checked by default
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-10 h-10 bg-gray-200 peer-checked:bg-primary-500 peer-checked:text-white rounded-lg flex items-center justify-center font-medium text-sm cursor-pointer transition-colors">
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Integración con POS</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 bg-gray-50 rounded-lg">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium text-gray-900">Sistema POS Conectado</h4>
|
||||||
|
<p className="text-sm text-gray-600">Sincroniza ventas automáticamente</p>
|
||||||
|
</div>
|
||||||
|
<span className="px-2 py-1 bg-red-100 text-red-800 rounded text-xs">
|
||||||
|
Desconectado
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button className="w-full px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 transition-colors">
|
||||||
|
Conectar Sistema POS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderNotificationsTab = () => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Canales de Notificación</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Mail className="h-5 w-5 text-gray-600 mr-3" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900">Notificaciones por Email</div>
|
||||||
|
<div className="text-sm text-gray-500">Recibe alertas y reportes por correo</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={notificationSettings.emailNotifications}
|
||||||
|
onChange={(e) => setNotificationSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
emailNotifications: e.target.checked
|
||||||
|
}))}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-600"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Smartphone className="h-5 w-5 text-gray-600 mr-3" />
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900">Notificaciones SMS</div>
|
||||||
|
<div className="text-sm text-gray-500">Alertas urgentes por mensaje de texto</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={notificationSettings.smsNotifications}
|
||||||
|
onChange={(e) => setNotificationSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
smsNotifications: e.target.checked
|
||||||
|
}))}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-600"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Tipos de Notificación</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[
|
||||||
|
{ key: 'dailyReports', label: 'Reportes Diarios', desc: 'Resumen diario de ventas y predicciones' },
|
||||||
|
{ key: 'weeklyReports', label: 'Reportes Semanales', desc: 'Análisis semanal de rendimiento' },
|
||||||
|
{ key: 'forecastAlerts', label: 'Alertas de Predicción', desc: 'Cambios significativos en demanda' },
|
||||||
|
{ key: 'stockAlerts', label: 'Alertas de Stock', desc: 'Inventario bajo o próximos vencimientos' },
|
||||||
|
{ key: 'orderReminders', label: 'Recordatorios de Pedidos', desc: 'Próximas entregas y fechas límite' }
|
||||||
|
].map((item) => (
|
||||||
|
<div key={item.key} className="flex items-center justify-between p-3 border border-gray-200 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900">{item.label}</div>
|
||||||
|
<div className="text-sm text-gray-500">{item.desc}</div>
|
||||||
|
</div>
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={notificationSettings[item.key as keyof NotificationSettings] as boolean}
|
||||||
|
onChange={(e) => setNotificationSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
[item.key]: e.target.checked
|
||||||
|
}))}
|
||||||
|
className="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-600"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderSecurityTab = () => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Cambiar Contraseña</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Contraseña actual
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Nueva contraseña
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Confirmar nueva contraseña
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button className="w-full sm:w-auto px-6 py-3 bg-primary-500 text-white rounded-xl hover:bg-primary-600 transition-colors">
|
||||||
|
Actualizar Contraseña
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t pt-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Sesiones Activas</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900">Navegador actual</div>
|
||||||
|
<div className="text-sm text-gray-500">Chrome en Windows • Madrid, España</div>
|
||||||
|
<div className="text-xs text-green-600 mt-1">Sesión actual</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900">Mobile App</div>
|
||||||
|
<div className="text-sm text-gray-500">iPhone • Hace 2 días</div>
|
||||||
|
</div>
|
||||||
|
<button className="text-red-600 hover:text-red-700 text-sm">
|
||||||
|
Cerrar sesión
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t pt-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4 text-red-600">Zona Peligrosa</h3>
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||||
|
<h4 className="font-medium text-red-900 mb-2">Eliminar Cuenta</h4>
|
||||||
|
<p className="text-red-800 text-sm mb-4">
|
||||||
|
Esta acción eliminará permanentemente tu cuenta y todos los datos asociados.
|
||||||
|
No se puede deshacer.
|
||||||
|
</p>
|
||||||
|
<button className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm">
|
||||||
|
Eliminar Cuenta
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderPreferencesTab = () => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Configuración Regional</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
<Globe className="inline h-4 w-4 mr-1" />
|
||||||
|
Idioma
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={userSettings.language}
|
||||||
|
onChange={(e) => setUserSettings(prev => ({ ...prev, language: e.target.value }))}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
>
|
||||||
|
<option value="es">Español</option>
|
||||||
|
<option value="en">English</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
<Clock className="inline h-4 w-4 mr-1" />
|
||||||
|
Zona horaria
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={userSettings.timezone}
|
||||||
|
onChange={(e) => setUserSettings(prev => ({ ...prev, timezone: e.target.value }))}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
>
|
||||||
|
<option value="Europe/Madrid">Europa/Madrid (CET)</option>
|
||||||
|
<option value="Europe/London">Europa/Londres (GMT)</option>
|
||||||
|
<option value="America/New_York">América/Nueva York (EST)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
<DollarSign className="inline h-4 w-4 mr-1" />
|
||||||
|
Moneda
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={userSettings.currency}
|
||||||
|
onChange={(e) => setUserSettings(prev => ({ ...prev, currency: e.target.value }))}
|
||||||
|
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
>
|
||||||
|
<option value="EUR">Euro (€)</option>
|
||||||
|
<option value="USD">Dólar americano ($)</option>
|
||||||
|
<option value="GBP">Libra esterlina (£)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Exportar Datos</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<button className="w-full p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all text-left">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900">Exportar todas las predicciones</div>
|
||||||
|
<div className="text-sm text-gray-500">Descargar historial completo en CSV</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="h-5 w-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button className="w-full p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all text-left">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900">Exportar datos de ventas</div>
|
||||||
|
<div className="text-sm text-gray-500">Historial de ventas y análisis</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="h-5 w-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button className="w-full p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all text-left">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-gray-900">Exportar configuración</div>
|
||||||
|
<div className="text-sm text-gray-500">Respaldo de toda la configuración</div>
|
||||||
|
</div>
|
||||||
|
<ChevronRight className="h-5 w-5 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderTabContent = () => {
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'profile':
|
||||||
|
return renderProfileTab();
|
||||||
|
case 'notifications':
|
||||||
|
return renderNotificationsTab();
|
||||||
|
case 'security':
|
||||||
|
return renderSecurityTab();
|
||||||
|
case 'preferences':
|
||||||
|
return renderPreferencesTab();
|
||||||
|
default:
|
||||||
|
return renderProfileTab();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 max-w-6xl mx-auto">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">Configuración</h1>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Administra tu cuenta y personaliza tu experiencia en PanIA
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
||||||
|
{/* Sidebar Navigation */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<nav className="space-y-1">
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const Icon = tab.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className={`w-full flex items-center px-4 py-3 text-sm font-medium rounded-lg transition-all ${
|
||||||
|
activeTab === tab.id
|
||||||
|
? 'bg-primary-100 text-primary-700 shadow-soft'
|
||||||
|
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5 mr-3" />
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Logout Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="w-full flex items-center px-4 py-3 text-sm font-medium text-red-600 hover:text-red-700 hover:bg-red-50 rounded-lg transition-all mt-6"
|
||||||
|
>
|
||||||
|
<LogOut className="h-5 w-5 mr-3" />
|
||||||
|
Cerrar Sesión
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="lg:col-span-3">
|
||||||
|
<div className="bg-white rounded-xl shadow-soft p-6">
|
||||||
|
{renderTabContent()}
|
||||||
|
|
||||||
|
{/* Save Button */}
|
||||||
|
{(activeTab === 'profile' || activeTab === 'notifications' || activeTab === 'preferences') && (
|
||||||
|
<div className="mt-8 pt-6 border-t border-gray-200">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p className="text-sm text-gray-600 mb-4 sm:mb-0">
|
||||||
|
Los cambios se guardarán automáticamente
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveSettings}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="inline-flex items-center px-6 py-3 bg-primary-500 text-white rounded-xl hover:bg-primary-600 transition-all hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||||
|
Guardando...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="h-4 w-4 mr-2" />
|
||||||
|
Guardar Cambios
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SettingsPage;
|
||||||
22
frontend/src/store/index.ts
Normal file
22
frontend/src/store/index.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/ src/store/index.ts
|
||||||
|
import { configureStore } from '@reduxjs/toolkit';
|
||||||
|
import authSlice from './slices/authSlice';
|
||||||
|
import tenantSlice from './slices/tenantSlice';
|
||||||
|
import forecastSlice from './slices/forecastSlice';
|
||||||
|
|
||||||
|
export const store = configureStore({
|
||||||
|
reducer: {
|
||||||
|
auth: authSlice,
|
||||||
|
tenant: tenantSlice,
|
||||||
|
forecast: forecastSlice,
|
||||||
|
},
|
||||||
|
middleware: (getDefaultMiddleware) =>
|
||||||
|
getDefaultMiddleware({
|
||||||
|
serializableCheck: {
|
||||||
|
ignoredActions: ['persist/PERSIST'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RootState = ReturnType<typeof store.getState>;
|
||||||
|
export type AppDispatch = typeof store.dispatch;
|
||||||
64
frontend/src/store/slices/authSlice.ts
Normal file
64
frontend/src/store/slices/authSlice.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// src/store/slices/authSlice.ts
|
||||||
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
fullName: string;
|
||||||
|
role: string;
|
||||||
|
isOnboardingComplete: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
user: User | null;
|
||||||
|
token: string | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialState: AuthState = {
|
||||||
|
isAuthenticated: false,
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const authSlice = createSlice({
|
||||||
|
name: 'auth',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
loginStart: (state) => {
|
||||||
|
state.isLoading = true;
|
||||||
|
state.error = null;
|
||||||
|
},
|
||||||
|
loginSuccess: (state, action: PayloadAction<{ user: User; token: string }>) => {
|
||||||
|
state.isAuthenticated = true;
|
||||||
|
state.user = action.payload.user;
|
||||||
|
state.token = action.payload.token;
|
||||||
|
state.isLoading = false;
|
||||||
|
state.error = null;
|
||||||
|
},
|
||||||
|
loginFailure: (state, action: PayloadAction<string>) => {
|
||||||
|
state.isAuthenticated = false;
|
||||||
|
state.user = null;
|
||||||
|
state.token = null;
|
||||||
|
state.isLoading = false;
|
||||||
|
state.error = action.payload;
|
||||||
|
},
|
||||||
|
logout: (state) => {
|
||||||
|
state.isAuthenticated = false;
|
||||||
|
state.user = null;
|
||||||
|
state.token = null;
|
||||||
|
state.isLoading = false;
|
||||||
|
state.error = null;
|
||||||
|
},
|
||||||
|
clearError: (state) => {
|
||||||
|
state.error = null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { loginStart, loginSuccess, loginFailure, logout, clearError } = authSlice.actions;
|
||||||
|
export default authSlice.reducer;
|
||||||
0
frontend/src/store/slices/forecastSlice.ts
Normal file
0
frontend/src/store/slices/forecastSlice.ts
Normal file
0
frontend/src/store/slices/tenantSlice.ts
Normal file
0
frontend/src/store/slices/tenantSlice.ts
Normal file
@@ -1,6 +1,96 @@
|
|||||||
/* src/styles/globals.css */
|
@import 'tailwindcss/base';
|
||||||
@tailwind base;
|
@import 'tailwindcss/components';
|
||||||
@tailwind components;
|
@import 'tailwindcss/utilities';
|
||||||
@tailwind 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,35 +1,133 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
export default {
|
||||||
content: [
|
content: [
|
||||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
"./index.html",
|
||||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
|
// PanIA Brand Colors
|
||||||
primary: {
|
primary: {
|
||||||
50: '#eff6ff',
|
50: '#fff7ed',
|
||||||
500: '#3b82f6',
|
100: '#ffedd5',
|
||||||
600: '#2563eb',
|
200: '#fed7aa',
|
||||||
700: '#1d4ed8',
|
300: '#fdba74',
|
||||||
|
400: '#fb923c',
|
||||||
|
500: '#f97316', // Main orange
|
||||||
|
600: '#ea580c',
|
||||||
|
700: '#c2410c',
|
||||||
|
800: '#9a3412',
|
||||||
|
900: '#7c2d12',
|
||||||
|
950: '#431407',
|
||||||
},
|
},
|
||||||
bakery: { // Existing bakery colors, can be potentially phased out or used as accents
|
secondary: {
|
||||||
brown: '#8B4513',
|
50: '#f8fafc',
|
||||||
cream: '#FFF8DC',
|
100: '#f1f5f9',
|
||||||
wheat: '#F5DEB3',
|
200: '#e2e8f0',
|
||||||
|
300: '#cbd5e1',
|
||||||
|
400: '#94a3b8',
|
||||||
|
500: '#64748b',
|
||||||
|
600: '#475569',
|
||||||
|
700: '#334155',
|
||||||
|
800: '#1e293b',
|
||||||
|
900: '#0f172a',
|
||||||
|
950: '#020617',
|
||||||
|
},
|
||||||
|
// Traffic Light Indicators
|
||||||
|
success: {
|
||||||
|
50: '#f0fdf4',
|
||||||
|
100: '#dcfce7',
|
||||||
|
500: '#22c55e',
|
||||||
|
600: '#16a34a',
|
||||||
|
700: '#15803d',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
50: '#fffbeb',
|
||||||
|
100: '#fef3c7',
|
||||||
|
500: '#f59e0b',
|
||||||
|
600: '#d97706',
|
||||||
|
700: '#b45309',
|
||||||
|
},
|
||||||
|
danger: {
|
||||||
|
50: '#fef2f2',
|
||||||
|
100: '#fee2e2',
|
||||||
|
500: '#ef4444',
|
||||||
|
600: '#dc2626',
|
||||||
|
700: '#b91c1c',
|
||||||
|
},
|
||||||
|
// Spanish Theme Colors
|
||||||
|
madrid: {
|
||||||
|
50: '#fdf2f8',
|
||||||
|
100: '#fce7f3',
|
||||||
|
200: '#fbcfe8',
|
||||||
|
300: '#f9a8d4',
|
||||||
|
400: '#f472b6',
|
||||||
|
500: '#ec4899',
|
||||||
|
600: '#db2777',
|
||||||
|
700: '#be185d',
|
||||||
|
800: '#9d174d',
|
||||||
|
900: '#831843',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
'sans': ['Inter', 'system-ui', 'sans-serif'],
|
||||||
|
'display': ['Poppins', 'Inter', 'system-ui', 'sans-serif'],
|
||||||
|
},
|
||||||
|
fontSize: {
|
||||||
|
'xs': ['0.75rem', { lineHeight: '1rem' }],
|
||||||
|
'sm': ['0.875rem', { lineHeight: '1.25rem' }],
|
||||||
|
'base': ['1rem', { lineHeight: '1.5rem' }],
|
||||||
|
'lg': ['1.125rem', { lineHeight: '1.75rem' }],
|
||||||
|
'xl': ['1.25rem', { lineHeight: '1.75rem' }],
|
||||||
|
'2xl': ['1.5rem', { lineHeight: '2rem' }],
|
||||||
|
'3xl': ['1.875rem', { lineHeight: '2.25rem' }],
|
||||||
|
'4xl': ['2.25rem', { lineHeight: '2.5rem' }],
|
||||||
|
},
|
||||||
|
spacing: {
|
||||||
|
'18': '4.5rem',
|
||||||
|
'88': '22rem',
|
||||||
|
'128': '32rem',
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
'xl': '0.75rem',
|
||||||
|
'2xl': '1rem',
|
||||||
|
'3xl': '1.5rem',
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
'soft': '0 2px 15px -3px rgba(0, 0, 0, 0.07), 0 10px 20px -2px rgba(0, 0, 0, 0.04)',
|
||||||
|
'medium': '0 4px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04)',
|
||||||
|
'strong': '0 10px 40px -10px rgba(0, 0, 0, 0.15), 0 2px 10px -2px rgba(0, 0, 0, 0.04)',
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'fade-in': 'fadeIn 0.5s ease-in-out',
|
||||||
|
'slide-up': 'slideUp 0.3s ease-out',
|
||||||
|
'slide-down': 'slideDown 0.3s ease-out',
|
||||||
|
'scale-in': 'scaleIn 0.2s ease-out',
|
||||||
|
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
fadeIn: {
|
||||||
|
'0%': { opacity: '0' },
|
||||||
|
'100%': { opacity: '1' },
|
||||||
|
},
|
||||||
|
slideUp: {
|
||||||
|
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
||||||
|
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||||
|
},
|
||||||
|
slideDown: {
|
||||||
|
'0%': { transform: 'translateY(-10px)', opacity: '0' },
|
||||||
|
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||||
|
},
|
||||||
|
scaleIn: {
|
||||||
|
'0%': { transform: 'scale(0.95)', opacity: '0' },
|
||||||
|
'100%': { transform: 'scale(1)', opacity: '1' },
|
||||||
},
|
},
|
||||||
pania: { // New PanIA brand colors
|
|
||||||
golden: '#F4A261', // Primary: Warm Golden - representing bread/warmth
|
|
||||||
blue: '#2A9D8F', // Secondary: Tech Blue - representing AI/innovation
|
|
||||||
brown: '#8B4513', // Accent: Deep Brown - representing traditional bakery
|
|
||||||
white: '#FFFFFF', // Neutral: Clean White
|
|
||||||
charcoal: '#333333', // Neutral: Charcoal
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
require('@tailwindcss/forms'),
|
require('@tailwindcss/forms'),
|
||||||
|
require('@tailwindcss/typography'),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "es5",
|
|
||||||
"lib": [
|
|
||||||
"dom",
|
|
||||||
"dom.iterable",
|
|
||||||
"esnext"
|
|
||||||
],
|
|
||||||
"allowJs": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"strict": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"module": "esnext",
|
|
||||||
"moduleResolution": "node",
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"jsx": "preserve",
|
|
||||||
"incremental": true,
|
|
||||||
"paths": {
|
|
||||||
"@/*": [
|
|
||||||
"./src/*"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"plugins": [
|
|
||||||
{
|
|
||||||
"name": "next"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
"next-env.d.ts",
|
|
||||||
"**/*.ts",
|
|
||||||
"**/*.tsx",
|
|
||||||
".next/types/**/*.ts"
|
|
||||||
],
|
|
||||||
"exclude": [
|
|
||||||
"node_modules"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
45
frontend/vite.config.js
Normal file
45
frontend/vite.config.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
host: true,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, '/api'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
sourcemap: true,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
vendor: ['react', 'react-dom'],
|
||||||
|
router: ['react-router-dom'],
|
||||||
|
state: ['@reduxjs/toolkit', 'react-redux'],
|
||||||
|
i18n: ['i18next', 'react-i18next', 'i18next-browser-languagedetector'],
|
||||||
|
charts: ['recharts'],
|
||||||
|
utils: ['date-fns', 'date-fns-tz', 'zod', 'clsx', 'tailwind-merge'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./src/test/setup.ts'],
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user