diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2633c969..5ccda2ad 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,30 +1,11 @@ -import React, { useState, useEffect } from 'react'; -import { Toaster } from 'react-hot-toast'; -import toast 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 ProductionPage from './pages/production/ProductionPage'; -import ForecastPage from './pages/forecast/ForecastPage'; -import OrdersPage from './pages/orders/OrdersPage'; -import InventoryPage from './pages/inventory/InventoryPage'; -import SalesPage from './pages/sales/SalesPage'; -import RecipesPage from './pages/recipes/RecipesPage'; -import SettingsPage from './pages/settings/SettingsPage'; -import Layout from './components/layout/Layout'; - -// Store and types -import { store } from './store'; +import React, { useEffect } from 'react'; +import { RouterProvider } from 'react-router-dom'; import { Provider } from 'react-redux'; - -// Onboarding utilities -import { OnboardingRouter, type NextAction, type RoutingDecision } from './utils/onboardingRouter'; +import { Toaster } from 'react-hot-toast'; +import { router } from './router'; +import { store } from './store'; +import ErrorBoundary from './components/ErrorBoundary'; +import { useAuth } from './hooks/useAuth'; // i18n import './i18n'; @@ -32,334 +13,53 @@ import './i18n'; // Global styles import './styles/globals.css'; -type CurrentPage = 'landing' | 'login' | 'register' | 'onboarding' | 'dashboard' | 'reports' | 'orders' | 'production' | 'inventory' | 'recipes' | 'sales' | 'settings'; +const AppContent: React.FC = () => { + const { initializeAuth } = useAuth(); -interface User { - id: string; - email: string; - fullName: string; - role: string; - isOnboardingComplete: boolean; - tenant_id?: string; -} - -interface AppState { - isAuthenticated: boolean; - isLoading: boolean; - user: User | null; - currentPage: CurrentPage; - routingDecision: RoutingDecision | null; -} - -const LoadingFallback = () => ( -
-
- -

Cargando PanIA...

-
-
-); - -const App: React.FC = () => { - const [appState, setAppState] = useState({ - isAuthenticated: false, - isLoading: true, - user: null, - currentPage: 'landing', - routingDecision: null - }); - - // Helper function to map NextAction to CurrentPage - const mapActionToPage = (action: NextAction): CurrentPage => { - const actionPageMap: Record = { - 'register': 'register', - 'login': 'login', - 'onboarding_bakery': 'onboarding', - 'onboarding_data': 'onboarding', - 'onboarding_training': 'onboarding', - 'dashboard': 'dashboard', - 'landing': 'landing' - }; - - return actionPageMap[action] || 'landing'; - }; - - // 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); - - try { - // Use enhanced onboarding router to determine next action - const routingDecision = await OnboardingRouter.getNextActionForUser(); - const nextPage = mapActionToPage(routingDecision.nextAction); - - setAppState({ - isAuthenticated: true, - isLoading: false, - user, - currentPage: nextPage, - routingDecision - }); - - // Show welcome message with progress - if (routingDecision.message && routingDecision.completionPercentage > 0) { - toast.success(`Welcome back! ${routingDecision.message} (${Math.round(routingDecision.completionPercentage)}% complete)`); - } - } catch (onboardingError) { - // Fallback to legacy logic if onboarding API fails - console.warn('Onboarding API failed, using legacy logic:', onboardingError); - setAppState({ - isAuthenticated: true, - isLoading: false, - user, - currentPage: user.isOnboardingComplete ? 'dashboard' : 'onboarding', - routingDecision: null - }); - } - } else { - // Unauthenticated user - const routingDecision = OnboardingRouter.getNextActionForGuest(); - setAppState(prev => ({ - ...prev, - isLoading: false, - currentPage: 'landing', - routingDecision - })); - } - } catch (error) { - console.error('App initialization error:', error); - setAppState(prev => ({ - ...prev, - isLoading: false, - currentPage: 'landing', - routingDecision: null - })); - } - }; - - initializeApp(); - }, []); - - const handleLogin = async (user: User, token: string) => { - localStorage.setItem('auth_token', token); - localStorage.setItem('user_data', JSON.stringify(user)); - - try { - // Mark user registration as complete - await OnboardingRouter.completeStep('user_registered', { - user_id: user.id, - email: user.email, - login_type: 'existing_user' - }); - - // Determine next action based on current progress - const routingDecision = await OnboardingRouter.getNextActionForUser(); - const nextPage = mapActionToPage(routingDecision.nextAction); - - setAppState({ - isAuthenticated: true, - isLoading: false, - user, - currentPage: nextPage, - routingDecision - }); - - // Show progress message - if (routingDecision.message) { - toast.success(routingDecision.message); - } - } catch (error) { - console.warn('Enhanced login routing failed, using fallback:', error); - // Fallback to legacy logic - setAppState({ - isAuthenticated: true, - isLoading: false, - user, - currentPage: user.isOnboardingComplete ? 'dashboard' : 'onboarding', - routingDecision: null - }); - } - }; - - 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 - routingDecision: null - }); - }; - - const handleOnboardingComplete = async () => { - try { - // Mark all onboarding steps as complete - await OnboardingRouter.completeStep('dashboard_accessible', { - completion_time: new Date().toISOString(), - user_id: appState.user?.id - }); - - const updatedUser = { ...appState.user!, isOnboardingComplete: true }; - localStorage.setItem('user_data', JSON.stringify(updatedUser)); - - setAppState(prev => ({ - ...prev, - user: updatedUser, - currentPage: 'dashboard', - routingDecision: { - nextAction: 'dashboard', - currentStep: 'dashboard_accessible', - completionPercentage: 100, - message: 'Welcome to your PanIA dashboard!' - } - })); - - toast.success('¡Configuración completada! Bienvenido a tu dashboard de PanIA 🎉'); - } catch (error) { - console.warn('Enhanced onboarding completion failed, using fallback:', error); - // Fallback logic - 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 ; - } - - const renderCurrentPage = () => { - // Public pages (non-authenticated) - if (!appState.isAuthenticated) { - switch (appState.currentPage) { - case 'login': - return ( - navigateTo('register')} - /> - ); - case 'register': - return ( - navigateTo('login')} - /> - ); - default: - return ( - navigateTo('login')} - onNavigateToRegister={() => navigateTo('register')} - /> - ); - } - } - - // Authenticated pages - if (!appState.user?.isOnboardingComplete && appState.currentPage !== 'settings') { - return ( - - ); - } - - // Main app pages with layout - const pageComponent = () => { - switch (appState.currentPage) { - case 'reports': - return ; - case 'orders': - return ; - case 'production': - return ; - case 'inventory': - return ; - case 'recipes': - return ; - case 'sales': - return ; - case 'settings': - return ; - default: - return navigateTo('orders')} - onNavigateToReports={() => navigateTo('reports')} - onNavigateToProduction={() => navigateTo('production')} - onNavigateToInventory={() => navigateTo('inventory')} - onNavigateToRecipes={() => navigateTo('recipes')} - onNavigateToSales={() => navigateTo('sales')} - />; - } - }; - - return ( - - {pageComponent()} - - ); - }; + initializeAuth(); + }, [initializeAuth]); + return ( + +
+ + + {/* Global Toast Notifications */} + +
+
+ ); +}; + +const App: React.FC = () => { return ( - -
- {renderCurrentPage()} - - {/* Global Toast Notifications */} - -
-
+
); }; diff --git a/frontend/src/api/hooks/useTraining.ts b/frontend/src/api/hooks/useTraining.ts index 6c22438a..49efbd33 100644 --- a/frontend/src/api/hooks/useTraining.ts +++ b/frontend/src/api/hooks/useTraining.ts @@ -20,6 +20,9 @@ interface UseTrainingOptions { export const useTraining = (options: UseTrainingOptions = {}) => { const { disablePolling = false } = options; + + // Debug logging for option changes + console.log('🔧 useTraining initialized with options:', { disablePolling, options }); const [jobs, setJobs] = useState([]); const [currentJob, setCurrentJob] = useState(null); const [models, setModels] = useState([]); @@ -193,22 +196,41 @@ export const useTraining = (options: UseTrainingOptions = {}) => { }, []); useEffect(() => { - // Skip polling if disabled or no running jobs - if (disablePolling) { - console.log('🚫 HTTP status polling disabled - using WebSocket instead'); - return; + // Always check disablePolling first and log for debugging + console.log('🔍 useTraining polling check:', { + disablePolling, + jobsCount: jobs.length, + runningJobs: jobs.filter(job => job.status === 'running' || job.status === 'pending').length + }); + + // STRICT CHECK: Skip polling if disabled - NO EXCEPTIONS + if (disablePolling === true) { + console.log('🚫 HTTP status polling STRICTLY DISABLED - using WebSocket instead'); + console.log('🚫 Effect triggered but polling prevented by disablePolling flag'); + return; // Early return - no cleanup needed, no interval creation } const runningJobs = jobs.filter(job => job.status === 'running' || job.status === 'pending'); - if (runningJobs.length === 0) return; + if (runningJobs.length === 0) { + console.log('⏸️ No running jobs - skipping polling setup'); + return; + } console.log('🔄 Starting HTTP status polling for', runningJobs.length, 'jobs'); const interval = setInterval(async () => { + // Double-check disablePolling inside interval to prevent race conditions + if (disablePolling) { + console.log('🚫 Polling disabled during interval - clearing'); + clearInterval(interval); + return; + } + for (const job of runningJobs) { try { const tenantId = job.tenant_id; + console.log('📡 HTTP polling job status:', job.job_id); await getTrainingJobStatus(tenantId, job.job_id); } catch (error) { console.error('Failed to refresh job status:', error); @@ -217,7 +239,7 @@ export const useTraining = (options: UseTrainingOptions = {}) => { }, 5000); // Refresh every 5 seconds return () => { - console.log('🛑 Stopping HTTP status polling'); + console.log('🛑 Stopping HTTP status polling (cleanup)'); clearInterval(interval); }; }, [jobs, getTrainingJobStatus, disablePolling]); diff --git a/frontend/src/components/adaptive/AdaptiveInventoryWidget.tsx b/frontend/src/components/adaptive/AdaptiveInventoryWidget.tsx new file mode 100644 index 00000000..b50ae799 --- /dev/null +++ b/frontend/src/components/adaptive/AdaptiveInventoryWidget.tsx @@ -0,0 +1,205 @@ +import React from 'react'; +import { Package, AlertTriangle, TrendingDown, Clock, MapPin } from 'lucide-react'; +import { useBakeryType } from '../../hooks/useBakeryType'; + +interface InventoryItem { + id: string; + name: string; + currentStock: number; + minStock: number; + unit: string; + expiryDate?: string; + location?: string; + supplier?: string; + category: 'ingredient' | 'product' | 'packaging'; +} + +interface AdaptiveInventoryWidgetProps { + items: InventoryItem[]; + title?: string; + showAlerts?: boolean; +} + +export const AdaptiveInventoryWidget: React.FC = ({ + items, + title, + showAlerts = true +}) => { + const { isIndividual, isCentral, getInventoryLabel } = useBakeryType(); + + const getStockStatus = (item: InventoryItem) => { + const ratio = item.currentStock / item.minStock; + if (ratio <= 0.2) return 'critical'; + if (ratio <= 0.5) return 'low'; + return 'normal'; + }; + + const getStatusColor = (status: string) => { + switch (status) { + case 'critical': + return 'text-red-600 bg-red-100'; + case 'low': + return 'text-yellow-600 bg-yellow-100'; + default: + return 'text-green-600 bg-green-100'; + } + }; + + const getExpiryWarning = (expiryDate?: string) => { + if (!expiryDate) return null; + + const today = new Date(); + const expiry = new Date(expiryDate); + const daysUntilExpiry = Math.ceil((expiry.getTime() - today.getTime()) / (1000 * 3600 * 24)); + + if (daysUntilExpiry <= 1) return 'expires-today'; + if (daysUntilExpiry <= 3) return 'expires-soon'; + return null; + }; + + const getItemIcon = (category: string) => { + if (isIndividual) { + switch (category) { + case 'ingredient': + return '🌾'; + case 'packaging': + return '📦'; + default: + return '🥖'; + } + } else { + switch (category) { + case 'product': + return '🥖'; + case 'packaging': + return '📦'; + default: + return '📋'; + } + } + }; + + const filteredItems = items.filter(item => { + if (isIndividual) { + return item.category === 'ingredient' || item.category === 'packaging'; + } else { + return item.category === 'product' || item.category === 'packaging'; + } + }); + + const lowStockItems = filteredItems.filter(item => getStockStatus(item) !== 'normal'); + + return ( +
+ {/* Header */} +
+
+ +

+ {title || getInventoryLabel()} +

+
+ + {showAlerts && lowStockItems.length > 0 && ( +
+ + {lowStockItems.length} alertas +
+ )} +
+ + {/* Items List */} +
+ {filteredItems.slice(0, 6).map((item) => { + const stockStatus = getStockStatus(item); + const expiryWarning = getExpiryWarning(item.expiryDate); + + return ( +
+
+ {getItemIcon(item.category)} + +
+
+

+ {item.name} +

+ + {stockStatus !== 'normal' && ( + + {stockStatus === 'critical' ? 'Crítico' : 'Bajo'} + + )} +
+ +
+ + Stock: {item.currentStock} {item.unit} + + + {item.location && isCentral && ( +
+ + {item.location} +
+ )} + + {expiryWarning && ( +
+ + {expiryWarning === 'expires-today' ? 'Caduca hoy' : 'Caduca pronto'} +
+ )} +
+ + {item.supplier && isIndividual && ( +
+ Proveedor: {item.supplier} +
+ )} +
+
+ +
+
+
+
+
+
+ ); + })} +
+ + {/* Quick Stats */} +
+
+
+
{filteredItems.length}
+
+ {isIndividual ? 'Ingredientes' : 'Productos'} +
+
+
+
{lowStockItems.length}
+
Stock bajo
+
+
+
+ {filteredItems.filter(item => getExpiryWarning(item.expiryDate)).length} +
+
+ {isIndividual ? 'Próximos a caducar' : 'Próximos a vencer'} +
+
+
+
+ + {/* Action Button */} +
+ +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/adaptive/AdaptiveProductionCard.tsx b/frontend/src/components/adaptive/AdaptiveProductionCard.tsx new file mode 100644 index 00000000..20cf6599 --- /dev/null +++ b/frontend/src/components/adaptive/AdaptiveProductionCard.tsx @@ -0,0 +1,158 @@ +import React from 'react'; +import { ChefHat, Truck, Clock, Users, Package, MapPin } from 'lucide-react'; +import { useBakeryType } from '../../hooks/useBakeryType'; + +interface ProductionItem { + id: string; + name: string; + quantity: number; + status: 'pending' | 'in_progress' | 'completed'; + scheduledTime?: string; + location?: string; + assignedTo?: string; +} + +interface AdaptiveProductionCardProps { + item: ProductionItem; + onStatusChange?: (id: string, status: string) => void; + onQuantityChange?: (id: string, quantity: number) => void; +} + +export const AdaptiveProductionCard: React.FC = ({ + item, + onStatusChange, + onQuantityChange +}) => { + const { isIndividual, isCentral, getProductionLabel } = useBakeryType(); + + const getStatusColor = (status: string) => { + switch (status) { + case 'pending': + return 'bg-yellow-100 text-yellow-800'; + case 'in_progress': + return 'bg-blue-100 text-blue-800'; + case 'completed': + return 'bg-green-100 text-green-800'; + default: + return 'bg-gray-100 text-gray-800'; + } + }; + + const getIcon = () => { + return isIndividual ? : ; + }; + + const getStatusLabels = () => { + if (isIndividual) { + return { + pending: 'Pendiente', + in_progress: 'Horneando', + completed: 'Terminado' + }; + } else { + return { + pending: 'Pendiente', + in_progress: 'Distribuyendo', + completed: 'Entregado' + }; + } + }; + + const statusLabels = getStatusLabels(); + + return ( +
+ {/* Header */} +
+
+
+ {getIcon()} +
+
+

{item.name}

+

+ {isIndividual ? 'Lote de producción' : 'Envío a puntos de venta'} +

+
+
+ + + {statusLabels[item.status as keyof typeof statusLabels]} + +
+ + {/* Quantity */} +
+
+ + Cantidad: +
+
+ {onQuantityChange ? ( + onQuantityChange(item.id, parseInt(e.target.value))} + className="w-20 px-2 py-1 text-sm border border-gray-300 rounded text-right" + min="0" + /> + ) : ( + {item.quantity} + )} + + {isIndividual ? 'unidades' : 'cajas'} + +
+
+ + {/* Additional Info for Bakery Type */} + {item.scheduledTime && ( +
+ + + {isIndividual ? 'Hora de horneado:' : 'Hora de entrega:'} {item.scheduledTime} + +
+ )} + + {item.location && isCentral && ( +
+ + Destino: {item.location} +
+ )} + + {item.assignedTo && ( +
+ + + {isIndividual ? 'Panadero:' : 'Conductor:'} {item.assignedTo} + +
+ )} + + {/* Actions */} + {onStatusChange && item.status !== 'completed' && ( +
+ {item.status === 'pending' && ( + + )} + + {item.status === 'in_progress' && ( + + )} +
+ )} +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/auth/ProtectedRoute.tsx b/frontend/src/components/auth/ProtectedRoute.tsx new file mode 100644 index 00000000..428deafc --- /dev/null +++ b/frontend/src/components/auth/ProtectedRoute.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import { useSelector } from 'react-redux'; +import { RootState } from '../../store'; + +interface ProtectedRouteProps { + children: React.ReactNode; +} + +const ProtectedRoute: React.FC = ({ children }) => { + const location = useLocation(); + const { isAuthenticated, user } = useSelector((state: RootState) => state.auth); + + // Check if user is authenticated + if (!isAuthenticated || !user) { + // Redirect to login with the attempted location + return ; + } + + // Check if user needs onboarding (except for onboarding and settings routes) + const isOnboardingRoute = location.pathname.includes('/onboarding'); + const isSettingsRoute = location.pathname.includes('/settings'); + + if (!user.isOnboardingComplete && !isOnboardingRoute && !isSettingsRoute) { + return ; + } + + // If user completed onboarding but is on onboarding route, redirect to dashboard + if (user.isOnboardingComplete && isOnboardingRoute) { + return ; + } + + return <>{children}; +}; + +export default ProtectedRoute; \ No newline at end of file diff --git a/frontend/src/components/auth/RoleBasedAccess.tsx b/frontend/src/components/auth/RoleBasedAccess.tsx new file mode 100644 index 00000000..9ecf64ad --- /dev/null +++ b/frontend/src/components/auth/RoleBasedAccess.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { usePermissions, UserRole } from '../../hooks/usePermissions'; + +interface RoleBasedAccessProps { + children: React.ReactNode; + requiredRoles?: UserRole[]; + requiredPermissions?: string[]; + fallback?: React.ReactNode; + hideIfNoAccess?: boolean; +} + +export const RoleBasedAccess: React.FC = ({ + children, + requiredRoles = [], + requiredPermissions = [], + fallback = null, + hideIfNoAccess = false +}) => { + const { hasRole, hasPermission } = usePermissions(); + + // Check role requirements + const hasRequiredRole = requiredRoles.length === 0 || requiredRoles.some(role => hasRole(role)); + + // Check permission requirements + const hasRequiredPermission = requiredPermissions.length === 0 || requiredPermissions.some(permission => hasPermission(permission)); + + const hasAccess = hasRequiredRole && hasRequiredPermission; + + if (!hasAccess) { + if (hideIfNoAccess) { + return null; + } + return <>{fallback}; + } + + return <>{children}; +}; + +// Convenience components for common use cases +export const AdminOnly: React.FC<{ children: React.ReactNode; fallback?: React.ReactNode }> = ({ children, fallback }) => ( + + {children} + +); + +export const ManagerAndUp: React.FC<{ children: React.ReactNode; fallback?: React.ReactNode }> = ({ children, fallback }) => ( + + {children} + +); + +export const OwnerOnly: React.FC<{ children: React.ReactNode; fallback?: React.ReactNode }> = ({ children, fallback }) => ( + + {children} + +); + +export default RoleBasedAccess; \ No newline at end of file diff --git a/frontend/src/components/auth/RoleBasedRoute.tsx b/frontend/src/components/auth/RoleBasedRoute.tsx new file mode 100644 index 00000000..ffc6a12d --- /dev/null +++ b/frontend/src/components/auth/RoleBasedRoute.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { Navigate } from 'react-router-dom'; +import { useSelector } from 'react-redux'; +import { RootState } from '../../store'; +import { usePermissions } from '../../hooks/usePermissions'; + +interface RoleBasedRouteProps { + children: React.ReactNode; + requiredRoles?: string[]; + requiredPermissions?: string[]; + fallbackPath?: string; +} + +const RoleBasedRoute: React.FC = ({ + children, + requiredRoles = [], + requiredPermissions = [], + fallbackPath = '/app/dashboard' +}) => { + const { user } = useSelector((state: RootState) => state.auth); + const { hasRole, hasPermission } = usePermissions(); + + // Check role requirements + if (requiredRoles.length > 0) { + const hasRequiredRole = requiredRoles.some(role => hasRole(role)); + if (!hasRequiredRole) { + return ; + } + } + + // Check permission requirements + if (requiredPermissions.length > 0) { + const hasRequiredPermission = requiredPermissions.some(permission => hasPermission(permission)); + if (!hasRequiredPermission) { + return ; + } + } + + return <>{children}; +}; + +export default RoleBasedRoute; \ No newline at end of file diff --git a/frontend/src/components/layout/AnalyticsLayout.tsx b/frontend/src/components/layout/AnalyticsLayout.tsx new file mode 100644 index 00000000..d8797446 --- /dev/null +++ b/frontend/src/components/layout/AnalyticsLayout.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { Outlet } from 'react-router-dom'; +import { SecondaryNavigation } from '../navigation/SecondaryNavigation'; +import { Breadcrumbs } from '../navigation/Breadcrumbs'; +import { useBakeryType } from '../../hooks/useBakeryType'; + +const AnalyticsLayout: React.FC = () => { + const { bakeryType } = useBakeryType(); + + const navigationItems = [ + { + id: 'forecasting', + label: 'Predicciones', + href: '/app/analytics/forecasting', + icon: 'TrendingUp' + }, + { + id: 'sales-analytics', + label: 'Análisis Ventas', + href: '/app/analytics/sales-analytics', + icon: 'BarChart3' + }, + { + id: 'production-reports', + label: bakeryType === 'individual' ? 'Reportes Producción' : 'Reportes Distribución', + href: '/app/analytics/production-reports', + icon: 'FileBarChart' + }, + { + id: 'financial-reports', + label: 'Reportes Financieros', + href: '/app/analytics/financial-reports', + icon: 'DollarSign' + }, + { + id: 'performance-kpis', + label: 'KPIs Rendimiento', + href: '/app/analytics/performance-kpis', + icon: 'Target' + }, + { + id: 'ai-insights', + label: 'Insights IA', + href: '/app/analytics/ai-insights', + icon: 'Brain' + } + ]; + + return ( +
+
+
+ + +
+
+ +
+
+ +
+
+
+ ); +}; + +export default AnalyticsLayout; \ No newline at end of file diff --git a/frontend/src/components/layout/AuthLayout.tsx b/frontend/src/components/layout/AuthLayout.tsx new file mode 100644 index 00000000..368b2a09 --- /dev/null +++ b/frontend/src/components/layout/AuthLayout.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { Outlet } from 'react-router-dom'; + +const AuthLayout: React.FC = () => { + return ( +
+ +
+ ); +}; + +export default AuthLayout; \ No newline at end of file diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx index 0991a02c..5cbde239 100644 --- a/frontend/src/components/layout/Layout.tsx +++ b/frontend/src/components/layout/Layout.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom'; import { Home, TrendingUp, @@ -10,18 +11,17 @@ import { User, Bell, ChevronDown, - ChefHat, - Warehouse, - ShoppingCart, - BookOpen + BarChart3, + Building } from 'lucide-react'; +import { useSelector, useDispatch } from 'react-redux'; +import { RootState } from '../../store'; +import { logout } from '../../store/slices/authSlice'; +import { TenantSelector } from '../navigation/TenantSelector'; +import { usePermissions } from '../../hooks/usePermissions'; interface LayoutProps { - children: React.ReactNode; - user: any; - currentPage: string; - onNavigate: (page: string) => void; - onLogout: () => void; + // No props needed - using React Router } interface NavigationItem { @@ -29,32 +29,52 @@ interface NavigationItem { label: string; icon: React.ComponentType<{ className?: string }>; href: string; + requiresRole?: string[]; } -const Layout: React.FC = ({ - children, - user, - currentPage, - onNavigate, - onLogout -}) => { +const Layout: React.FC = () => { + const location = useLocation(); + const navigate = useNavigate(); + const dispatch = useDispatch(); + const { user } = useSelector((state: RootState) => state.auth); + const { hasRole } = usePermissions(); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); const navigation: NavigationItem[] = [ - { id: 'dashboard', label: 'Inicio', icon: Home, href: '/dashboard' }, - { id: 'orders', label: 'Pedidos', icon: Package, href: '/orders' }, - { id: 'production', label: 'Producción', icon: ChefHat, href: '/production' }, - { id: 'recipes', label: 'Recetas', icon: BookOpen, href: '/recipes' }, - { id: 'inventory', label: 'Inventario', icon: Warehouse, href: '/inventory' }, - { id: 'sales', label: 'Ventas', icon: ShoppingCart, href: '/sales' }, - { id: 'reports', label: 'Informes', icon: TrendingUp, href: '/reports' }, - { id: 'settings', label: 'Configuración', icon: Settings, href: '/settings' }, + { id: 'dashboard', label: 'Dashboard', icon: Home, href: '/app/dashboard' }, + { id: 'operations', label: 'Operaciones', icon: Package, href: '/app/operations' }, + { + id: 'analytics', + label: 'Analytics', + icon: BarChart3, + href: '/app/analytics', + requiresRole: ['admin', 'manager'] + }, + { id: 'settings', label: 'Configuración', icon: Settings, href: '/app/settings' }, ]; - const handleNavigate = (pageId: string) => { - onNavigate(pageId); - setIsMobileMenuOpen(false); + // Filter navigation based on user role + const filteredNavigation = navigation.filter(item => { + if (!item.requiresRole) return true; + return item.requiresRole.some(role => hasRole(role)); + }); + + const handleLogout = () => { + if (window.confirm('¿Estás seguro de que quieres cerrar sesión?')) { + dispatch(logout()); + localStorage.removeItem('auth_token'); + localStorage.removeItem('user_data'); + localStorage.removeItem('selectedTenantId'); + navigate('/'); + } + }; + + const isActiveRoute = (href: string): boolean => { + if (href === '/app/dashboard') { + return location.pathname === '/app/dashboard' || location.pathname === '/app'; + } + return location.pathname.startsWith(href); }; return ( @@ -88,14 +108,14 @@ const Layout: React.FC = ({ {/* Desktop Navigation */}
- {navigation.map((item) => { + {filteredNavigation.map((item) => { const Icon = item.icon; - const isActive = currentPage === item.id; + const isActive = isActiveRoute(item.href); return ( - + ); })}
- {/* Right side - Notifications and User Menu */} + {/* Right side - Tenant Selector, Notifications and User Menu */}
+ {/* Tenant Selector */} + {/* Notifications */}
- + + ); })} @@ -201,9 +223,7 @@ const Layout: React.FC = ({ {/* Main Content */}
-
- {children} -
+
{/* Click outside handler for dropdowns */} diff --git a/frontend/src/components/layout/OperationsLayout.tsx b/frontend/src/components/layout/OperationsLayout.tsx new file mode 100644 index 00000000..85345ea3 --- /dev/null +++ b/frontend/src/components/layout/OperationsLayout.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { Outlet } from 'react-router-dom'; +import { SecondaryNavigation } from '../navigation/SecondaryNavigation'; +import { Breadcrumbs } from '../navigation/Breadcrumbs'; +import { useBakeryType } from '../../hooks/useBakeryType'; + +const OperationsLayout: React.FC = () => { + const { bakeryType } = useBakeryType(); + + // Define navigation items based on bakery type + const getNavigationItems = () => { + const baseItems = [ + { + id: 'production', + label: bakeryType === 'individual' ? 'Producción' : 'Distribución', + href: '/app/operations/production', + icon: 'ChefHat', + children: bakeryType === 'individual' ? [ + { id: 'schedule', label: 'Programación', href: '/app/operations/production/schedule' }, + { id: 'active-batches', label: 'Lotes Activos', href: '/app/operations/production/active-batches' }, + { id: 'equipment', label: 'Equipamiento', href: '/app/operations/production/equipment' } + ] : [ + { id: 'schedule', label: 'Distribución', href: '/app/operations/production/schedule' }, + { id: 'active-batches', label: 'Asignaciones', href: '/app/operations/production/active-batches' }, + { id: 'equipment', label: 'Logística', href: '/app/operations/production/equipment' } + ] + }, + { + id: 'orders', + label: 'Pedidos', + href: '/app/operations/orders', + icon: 'Package', + children: [ + { id: 'incoming', label: bakeryType === 'individual' ? 'Entrantes' : 'Puntos de Venta', href: '/app/operations/orders/incoming' }, + { id: 'in-progress', label: 'En Proceso', href: '/app/operations/orders/in-progress' }, + { id: 'supplier-orders', label: bakeryType === 'individual' ? 'Proveedores' : 'Productos', href: '/app/operations/orders/supplier-orders' } + ] + }, + { + id: 'inventory', + label: 'Inventario', + href: '/app/operations/inventory', + icon: 'Warehouse', + children: [ + { id: 'stock-levels', label: bakeryType === 'individual' ? 'Ingredientes' : 'Productos', href: '/app/operations/inventory/stock-levels' }, + { id: 'movements', label: bakeryType === 'individual' ? 'Uso' : 'Distribución', href: '/app/operations/inventory/movements' }, + { id: 'alerts', label: bakeryType === 'individual' ? 'Caducidad' : 'Retrasos', href: '/app/operations/inventory/alerts' } + ] + }, + { + id: 'sales', + label: 'Ventas', + href: '/app/operations/sales', + icon: 'ShoppingCart', + children: [ + { id: 'daily-sales', label: 'Ventas Diarias', href: '/app/operations/sales/daily-sales' }, + { id: 'customer-orders', label: bakeryType === 'individual' ? 'Pedidos Cliente' : 'Pedidos Punto', href: '/app/operations/sales/customer-orders' }, + { id: 'pos-integration', label: bakeryType === 'individual' ? 'TPV' : 'Multi-TPV', href: '/app/operations/sales/pos-integration' } + ] + } + ]; + + // Add recipes for individual bakeries, hide for central + if (bakeryType === 'individual') { + baseItems.push({ + id: 'recipes', + label: 'Recetas', + href: '/app/operations/recipes', + icon: 'BookOpen', + children: [ + { id: 'active-recipes', label: 'Recetas Activas', href: '/app/operations/recipes/active-recipes' }, + { id: 'development', label: 'Desarrollo', href: '/app/operations/recipes/development' }, + { id: 'costing', label: 'Costeo', href: '/app/operations/recipes/costing' } + ] + }); + } + + return baseItems; + }; + + return ( +
+
+
+ + +
+
+ +
+
+ +
+
+
+ ); +}; + +export default OperationsLayout; \ No newline at end of file diff --git a/frontend/src/components/layout/SettingsLayout.tsx b/frontend/src/components/layout/SettingsLayout.tsx new file mode 100644 index 00000000..ea893c7b --- /dev/null +++ b/frontend/src/components/layout/SettingsLayout.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { Outlet } from 'react-router-dom'; +import { SecondaryNavigation } from '../navigation/SecondaryNavigation'; +import { Breadcrumbs } from '../navigation/Breadcrumbs'; +import { usePermissions } from '../../hooks/usePermissions'; + +const SettingsLayout: React.FC = () => { + const { hasRole } = usePermissions(); + + const getNavigationItems = () => { + const baseItems = [ + { + id: 'general', + label: 'General', + href: '/app/settings/general', + icon: 'Settings' + }, + { + id: 'account', + label: 'Cuenta', + href: '/app/settings/account', + icon: 'User' + } + ]; + + // Add admin-only items + if (hasRole('admin')) { + baseItems.unshift( + { + id: 'bakeries', + label: 'Panaderías', + href: '/app/settings/bakeries', + icon: 'Building' + }, + { + id: 'users', + label: 'Usuarios', + href: '/app/settings/users', + icon: 'Users' + } + ); + } + + return baseItems; + }; + + return ( +
+
+
+ + +
+
+ +
+
+ +
+
+
+ ); +}; + +export default SettingsLayout; \ No newline at end of file diff --git a/frontend/src/components/navigation/Breadcrumbs.tsx b/frontend/src/components/navigation/Breadcrumbs.tsx new file mode 100644 index 00000000..7e8813c4 --- /dev/null +++ b/frontend/src/components/navigation/Breadcrumbs.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import { ChevronRight, Home } from 'lucide-react'; + +interface BreadcrumbItem { + label: string; + href?: string; +} + +export const Breadcrumbs: React.FC = () => { + const location = useLocation(); + + const getBreadcrumbs = (): BreadcrumbItem[] => { + const pathSegments = location.pathname.split('/').filter(Boolean); + + // Remove 'app' from the beginning if present + if (pathSegments[0] === 'app') { + pathSegments.shift(); + } + + const breadcrumbs: BreadcrumbItem[] = [ + { label: 'Inicio', href: '/app/dashboard' } + ]; + + const segmentMap: Record = { + // Main sections + 'dashboard': 'Dashboard', + 'operations': 'Operaciones', + 'analytics': 'Analytics', + 'settings': 'Configuración', + + // Operations subsections + 'production': 'Producción', + 'orders': 'Pedidos', + 'inventory': 'Inventario', + 'sales': 'Ventas', + 'recipes': 'Recetas', + + // Operations sub-pages + 'schedule': 'Programación', + 'active-batches': 'Lotes Activos', + 'equipment': 'Equipamiento', + 'incoming': 'Entrantes', + 'in-progress': 'En Proceso', + 'supplier-orders': 'Proveedores', + 'stock-levels': 'Niveles Stock', + 'movements': 'Movimientos', + 'alerts': 'Alertas', + 'daily-sales': 'Ventas Diarias', + 'customer-orders': 'Pedidos Cliente', + 'pos-integration': 'Integración TPV', + 'active-recipes': 'Recetas Activas', + 'development': 'Desarrollo', + 'costing': 'Costeo', + + // Analytics subsections + 'forecasting': 'Predicciones', + 'sales-analytics': 'Análisis Ventas', + 'production-reports': 'Reportes Producción', + 'financial-reports': 'Reportes Financieros', + 'performance-kpis': 'KPIs', + 'ai-insights': 'Insights IA', + + // Settings subsections + 'general': 'General', + 'users': 'Usuarios', + 'bakeries': 'Panaderías', + 'account': 'Cuenta' + }; + + let currentPath = '/app'; + + pathSegments.forEach((segment, index) => { + currentPath += `/${segment}`; + const label = segmentMap[segment] || segment.charAt(0).toUpperCase() + segment.slice(1); + + // Don't make the last item clickable + const isLast = index === pathSegments.length - 1; + + breadcrumbs.push({ + label, + href: isLast ? undefined : currentPath + }); + }); + + return breadcrumbs; + }; + + const breadcrumbs = getBreadcrumbs(); + + if (breadcrumbs.length <= 1) { + return null; + } + + return ( + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/navigation/SecondaryNavigation.tsx b/frontend/src/components/navigation/SecondaryNavigation.tsx new file mode 100644 index 00000000..b9ec52bc --- /dev/null +++ b/frontend/src/components/navigation/SecondaryNavigation.tsx @@ -0,0 +1,126 @@ +import React, { useState } from 'react'; +import { Link, useLocation } from 'react-router-dom'; +import { ChevronDown, ChevronRight } from 'lucide-react'; +import * as Icons from 'lucide-react'; + +interface NavigationChild { + id: string; + label: string; + href: string; +} + +interface NavigationItem { + id: string; + label: string; + href: string; + icon: string; + children?: NavigationChild[]; +} + +interface SecondaryNavigationProps { + items: NavigationItem[]; +} + +export const SecondaryNavigation: React.FC = ({ items }) => { + const location = useLocation(); + const [expandedItems, setExpandedItems] = useState>(new Set()); + + const toggleExpanded = (itemId: string) => { + const newExpanded = new Set(expandedItems); + if (newExpanded.has(itemId)) { + newExpanded.delete(itemId); + } else { + newExpanded.add(itemId); + } + setExpandedItems(newExpanded); + }; + + const isActive = (href: string): boolean => { + return location.pathname === href || location.pathname.startsWith(href + '/'); + }; + + const hasActiveChild = (children?: NavigationChild[]): boolean => { + if (!children) return false; + return children.some(child => isActive(child.href)); + }; + + // Auto-expand items with active children + React.useEffect(() => { + const itemsToExpand = new Set(expandedItems); + items.forEach(item => { + if (hasActiveChild(item.children)) { + itemsToExpand.add(item.id); + } + }); + setExpandedItems(itemsToExpand); + }, [location.pathname]); + + const getIcon = (iconName: string) => { + const IconComponent = (Icons as any)[iconName]; + return IconComponent || Icons.Circle; + }; + + return ( + + ); +}; \ No newline at end of file diff --git a/frontend/src/components/navigation/TenantSelector.tsx b/frontend/src/components/navigation/TenantSelector.tsx new file mode 100644 index 00000000..4d72a07b --- /dev/null +++ b/frontend/src/components/navigation/TenantSelector.tsx @@ -0,0 +1,128 @@ +import React, { useState, useEffect } from 'react'; +import { ChevronDown, Building, Check } from 'lucide-react'; +import { useSelector, useDispatch } from 'react-redux'; +import { RootState } from '../../store'; +import { setCurrentTenant } from '../../store/slices/tenantSlice'; +import { useTenant } from '../../api/hooks/useTenant'; +import toast from 'react-hot-toast'; + +export const TenantSelector: React.FC = () => { + const dispatch = useDispatch(); + const { currentTenant } = useSelector((state: RootState) => state.tenant); + const { user } = useSelector((state: RootState) => state.auth); + const [isOpen, setIsOpen] = useState(false); + + const { + tenants, + getUserTenants, + isLoading, + error + } = useTenant(); + + useEffect(() => { + if (user) { + getUserTenants(); + } + }, [user, getUserTenants]); + + const handleTenantChange = async (tenant: any) => { + try { + dispatch(setCurrentTenant(tenant)); + localStorage.setItem('selectedTenantId', tenant.id); + setIsOpen(false); + + toast.success(`Cambiado a ${tenant.name}`); + + // Force a page reload to update data with new tenant context + window.location.reload(); + } catch (error) { + toast.error('Error al cambiar de panadería'); + } + }; + + if (isLoading || tenants.length <= 1) { + return null; + } + + return ( +
+ + + {isOpen && ( + <> + {/* Backdrop */} +
setIsOpen(false)} + /> + + {/* Dropdown */} +
+
+

+ Mis Panaderías +

+
+ +
+ {tenants.map((tenant) => ( + + ))} +
+ +
+ +
+
+ + )} +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts new file mode 100644 index 00000000..c545d577 --- /dev/null +++ b/frontend/src/hooks/useAuth.ts @@ -0,0 +1,64 @@ +import { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import { loginSuccess, logout } from '../store/slices/authSlice'; +import { setCurrentTenant } from '../store/slices/tenantSlice'; + +interface User { + id: string; + email: string; + fullName: string; + role: string; + isOnboardingComplete: boolean; + tenant_id?: string; +} + +export const useAuth = () => { + const dispatch = useDispatch(); + + const initializeAuth = useCallback(async () => { + try { + // Check for stored auth token + const token = localStorage.getItem('auth_token'); + const userData = localStorage.getItem('user_data'); + const selectedTenantId = localStorage.getItem('selectedTenantId'); + + if (token && userData) { + const user: User = JSON.parse(userData); + + // Set user in auth state + dispatch(loginSuccess({ user, token })); + + // If there's a selected tenant, try to load it + if (selectedTenantId) { + // This would normally fetch tenant data from API + // For now, we'll just set a placeholder + const tenantData = { + id: selectedTenantId, + name: 'Mi Panadería', + business_type: 'individual', + address: 'Dirección de ejemplo' + }; + dispatch(setCurrentTenant(tenantData)); + } + } + } catch (error) { + console.error('Failed to initialize auth:', error); + // Clear invalid tokens + localStorage.removeItem('auth_token'); + localStorage.removeItem('user_data'); + localStorage.removeItem('selectedTenantId'); + } + }, [dispatch]); + + const handleLogout = useCallback(() => { + dispatch(logout()); + localStorage.removeItem('auth_token'); + localStorage.removeItem('user_data'); + localStorage.removeItem('selectedTenantId'); + }, [dispatch]); + + return { + initializeAuth, + handleLogout + }; +}; \ No newline at end of file diff --git a/frontend/src/hooks/useBakeryType.ts b/frontend/src/hooks/useBakeryType.ts new file mode 100644 index 00000000..522d7f5d --- /dev/null +++ b/frontend/src/hooks/useBakeryType.ts @@ -0,0 +1,56 @@ +import { useSelector } from 'react-redux'; +import { RootState } from '../store'; + +export type BakeryType = 'individual' | 'central_workshop'; + +interface BakeryTypeConfig { + bakeryType: BakeryType; + isIndividual: boolean; + isCentral: boolean; + getLabel: () => string; + getDescription: () => string; + getInventoryLabel: () => string; + getProductionLabel: () => string; + getSupplierLabel: () => string; +} + +export const useBakeryType = (): BakeryTypeConfig => { + const { currentTenant } = useSelector((state: RootState) => state.tenant); + + const bakeryType: BakeryType = currentTenant?.business_type || 'individual'; + const isIndividual = bakeryType === 'individual'; + const isCentral = bakeryType === 'central_workshop'; + + const getLabel = (): string => { + return isIndividual ? 'Panadería Individual' : 'Obrador Central'; + }; + + const getDescription = (): string => { + return isIndividual + ? 'Panadería con producción in-situ usando ingredientes frescos' + : 'Obrador central que distribuye productos semi-terminados o terminados'; + }; + + const getInventoryLabel = (): string => { + return isIndividual ? 'Ingredientes' : 'Productos'; + }; + + const getProductionLabel = (): string => { + return isIndividual ? 'Producción' : 'Distribución'; + }; + + const getSupplierLabel = (): string => { + return isIndividual ? 'Proveedores de Ingredientes' : 'Proveedores de Productos'; + }; + + return { + bakeryType, + isIndividual, + isCentral, + getLabel, + getDescription, + getInventoryLabel, + getProductionLabel, + getSupplierLabel + }; +}; \ No newline at end of file diff --git a/frontend/src/hooks/usePermissions.ts b/frontend/src/hooks/usePermissions.ts new file mode 100644 index 00000000..d35ede7e --- /dev/null +++ b/frontend/src/hooks/usePermissions.ts @@ -0,0 +1,112 @@ +import { useSelector } from 'react-redux'; +import { RootState } from '../store'; + +export type UserRole = 'owner' | 'admin' | 'manager' | 'worker'; + +interface Permission { + action: string; + resource: string; +} + +interface PermissionsConfig { + userRole: UserRole; + hasRole: (role: UserRole | UserRole[]) => boolean; + hasPermission: (permission: string) => boolean; + canManageUsers: boolean; + canManageTenants: boolean; + canViewAnalytics: boolean; + canEditRecipes: boolean; + canViewFinancials: boolean; + canManageSettings: boolean; +} + +// Define role hierarchy (higher index = more permissions) +const ROLE_HIERARCHY: UserRole[] = ['worker', 'manager', 'admin', 'owner']; + +// Define permissions for each role +const ROLE_PERMISSIONS: Record = { + worker: [ + 'view:inventory', + 'view:production', + 'view:orders', + 'update:production_status', + 'view:recipes_basic' + ], + manager: [ + 'view:inventory', + 'view:production', + 'view:orders', + 'view:sales', + 'update:production_status', + 'update:inventory', + 'create:orders', + 'view:recipes_basic', + 'view:analytics_basic', + 'view:reports_operational' + ], + admin: [ + 'view:inventory', + 'view:production', + 'view:orders', + 'view:sales', + 'view:analytics', + 'view:financials', + 'update:production_status', + 'update:inventory', + 'create:orders', + 'manage:recipes', + 'manage:users', + 'view:reports_all', + 'manage:settings_tenant' + ], + owner: [ + '*' // All permissions + ] +}; + +export const usePermissions = (): PermissionsConfig => { + const { user } = useSelector((state: RootState) => state.auth); + + const userRole: UserRole = (user?.role as UserRole) || 'worker'; + + const hasRole = (role: UserRole | UserRole[]): boolean => { + if (Array.isArray(role)) { + return role.includes(userRole); + } + + const userRoleIndex = ROLE_HIERARCHY.indexOf(userRole); + const requiredRoleIndex = ROLE_HIERARCHY.indexOf(role); + + return userRoleIndex >= requiredRoleIndex; + }; + + const hasPermission = (permission: string): boolean => { + const userPermissions = ROLE_PERMISSIONS[userRole] || []; + + // Owner has all permissions + if (userPermissions.includes('*')) { + return true; + } + + return userPermissions.includes(permission); + }; + + const canManageUsers = hasPermission('manage:users'); + const canManageTenants = hasRole(['admin', 'owner']); + const canViewAnalytics = hasPermission('view:analytics') || hasPermission('view:analytics_basic'); + const canEditRecipes = hasPermission('manage:recipes'); + const canViewFinancials = hasPermission('view:financials'); + const canManageSettings = hasRole(['admin', 'owner']); + + return { + userRole, + hasRole, + hasPermission, + canManageUsers, + canManageTenants, + canViewAnalytics, + canEditRecipes, + canViewFinancials, + canManageSettings + }; +}; \ No newline at end of file diff --git a/frontend/src/pages/analytics/AIInsightsPage.tsx b/frontend/src/pages/analytics/AIInsightsPage.tsx new file mode 100644 index 00000000..0f77a732 --- /dev/null +++ b/frontend/src/pages/analytics/AIInsightsPage.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +const AIInsightsPage: React.FC = () => { + return ( +
+
+

Insights de IA

+
+ +
+

Insights de IA en desarrollo

+
+
+ ); +}; + +export default AIInsightsPage; \ No newline at end of file diff --git a/frontend/src/pages/analytics/FinancialReportsPage.tsx b/frontend/src/pages/analytics/FinancialReportsPage.tsx new file mode 100644 index 00000000..345eeefb --- /dev/null +++ b/frontend/src/pages/analytics/FinancialReportsPage.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +const FinancialReportsPage: React.FC = () => { + return ( +
+
+

Reportes Financieros

+
+ +
+

Reportes financieros en desarrollo

+
+
+ ); +}; + +export default FinancialReportsPage; \ No newline at end of file diff --git a/frontend/src/pages/analytics/PerformanceKPIsPage.tsx b/frontend/src/pages/analytics/PerformanceKPIsPage.tsx new file mode 100644 index 00000000..5cc85456 --- /dev/null +++ b/frontend/src/pages/analytics/PerformanceKPIsPage.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +const PerformanceKPIsPage: React.FC = () => { + return ( +
+
+

KPIs de Rendimiento

+
+ +
+

KPIs de rendimiento en desarrollo

+
+
+ ); +}; + +export default PerformanceKPIsPage; \ No newline at end of file diff --git a/frontend/src/pages/analytics/ProductionReportsPage.tsx b/frontend/src/pages/analytics/ProductionReportsPage.tsx new file mode 100644 index 00000000..374c8686 --- /dev/null +++ b/frontend/src/pages/analytics/ProductionReportsPage.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { useBakeryType } from '../../hooks/useBakeryType'; + +const ProductionReportsPage: React.FC = () => { + const { getProductionLabel } = useBakeryType(); + + return ( +
+
+

Reportes de {getProductionLabel()}

+
+ +
+

Reportes de {getProductionLabel().toLowerCase()} en desarrollo

+
+
+ ); +}; + +export default ProductionReportsPage; \ No newline at end of file diff --git a/frontend/src/pages/analytics/SalesAnalyticsPage.tsx b/frontend/src/pages/analytics/SalesAnalyticsPage.tsx new file mode 100644 index 00000000..b292fd58 --- /dev/null +++ b/frontend/src/pages/analytics/SalesAnalyticsPage.tsx @@ -0,0 +1,120 @@ +import React, { useState } from 'react'; +import { TrendingUp, DollarSign, ShoppingCart, Calendar } from 'lucide-react'; +import { useBakeryType } from '../../hooks/useBakeryType'; + +const SalesAnalyticsPage: React.FC = () => { + const { isIndividual, isCentral } = useBakeryType(); + const [timeRange, setTimeRange] = useState('week'); + + return ( +
+
+

Análisis de Ventas

+

+ {isIndividual + ? 'Analiza el rendimiento de ventas de tu panadería' + : 'Analiza el rendimiento de ventas de todos tus puntos de venta' + } +

+
+ + {/* Time Range Selector */} +
+
+ {['day', 'week', 'month', 'quarter'].map((range) => ( + + ))} +
+
+ + {/* KPI Cards */} +
+
+
+
+ +
+
+

Ingresos Totales

+

€2,847

+
+
+
+ +
+
+
+ +
+
+

+ {isIndividual ? 'Productos Vendidos' : 'Productos Distribuidos'} +

+

1,429

+
+
+
+ +
+
+
+ +
+
+

Crecimiento

+

+12.5%

+
+
+
+ +
+
+
+ +
+
+

Días Activos

+

6/7

+
+
+
+
+ + {/* Charts placeholder */} +
+
+

+ Tendencia de Ventas +

+
+

Gráfico de tendencias aquí

+
+
+ +
+

+ {isIndividual ? 'Productos Más Vendidos' : 'Productos Más Distribuidos'} +

+
+

Gráfico de productos aquí

+
+
+
+
+ ); +}; + +export default SalesAnalyticsPage; \ No newline at end of file diff --git a/frontend/src/pages/auth/LoginPage.tsx b/frontend/src/pages/auth/LoginPage.tsx index 47c68265..0982e802 100644 --- a/frontend/src/pages/auth/LoginPage.tsx +++ b/frontend/src/pages/auth/LoginPage.tsx @@ -1,6 +1,9 @@ import React, { useState } from 'react'; +import { Link, useNavigate, useLocation } from 'react-router-dom'; +import { useDispatch } from 'react-redux'; import { Eye, EyeOff, Loader2 } from 'lucide-react'; import toast from 'react-hot-toast'; +import { loginSuccess } from '../../store/slices/authSlice'; import { useAuth, @@ -8,8 +11,7 @@ import { } from '../../api'; interface LoginPageProps { - onLogin: (user: any, token: string) => void; - onNavigateToRegister: () => void; + // No props needed with React Router } interface LoginForm { @@ -17,11 +19,15 @@ interface LoginForm { password: string; } -const LoginPage: React.FC = ({ onLogin, onNavigateToRegister }) => { - - +const LoginPage: React.FC = () => { + const navigate = useNavigate(); + const location = useLocation(); + const dispatch = useDispatch(); const { login, isLoading, isAuthenticated } = useAuth(); + // Get the intended destination from state, default to app + const from = (location.state as any)?.from?.pathname || '/app'; + const [formData, setFormData] = useState({ email: '', password: '' @@ -70,7 +76,13 @@ const LoginPage: React.FC = ({ onLogin, onNavigateToRegister }) const token = localStorage.getItem('auth_token'); if (userData && token) { - onLogin(JSON.parse(userData), token); + const user = JSON.parse(userData); + + // Set auth state + dispatch(loginSuccess({ user, token })); + + // Navigate to intended destination + navigate(from, { replace: true }); } } catch (error: any) { @@ -245,12 +257,12 @@ const LoginPage: React.FC = ({ onLogin, onNavigateToRegister })

¿No tienes una cuenta?{' '} - +

diff --git a/frontend/src/pages/auth/SimpleRegisterPage.tsx b/frontend/src/pages/auth/SimpleRegisterPage.tsx new file mode 100644 index 00000000..802ee9ce --- /dev/null +++ b/frontend/src/pages/auth/SimpleRegisterPage.tsx @@ -0,0 +1,421 @@ +import React, { useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { useDispatch } from 'react-redux'; +import { Eye, EyeOff, Loader2, User, Mail, Lock } from 'lucide-react'; +import toast from 'react-hot-toast'; +import { loginSuccess } from '../../store/slices/authSlice'; +import { authService } from '../../api/services/auth.service'; +import { onboardingService } from '../../api/services/onboarding.service'; +import type { RegisterRequest } from '../../api/types/auth'; + +interface RegisterForm { + fullName: string; + email: string; + password: string; + confirmPassword: string; + acceptTerms: boolean; +} + +const RegisterPage: React.FC = () => { + const navigate = useNavigate(); + const dispatch = useDispatch(); + + const [formData, setFormData] = useState({ + 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>({}); + + const validateForm = (): boolean => { + const newErrors: Partial = {}; + + if (!formData.fullName.trim()) { + newErrors.fullName = 'El nombre es obligatorio'; + } + + 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'; + } + + 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 { + // Prepare registration data + const registrationData: RegisterRequest = { + email: formData.email, + password: formData.password, + full_name: formData.fullName, + role: 'admin', + language: 'es' + }; + + // Call real authentication API + const response = await authService.register(registrationData); + + // Extract user data from response + const userData = response.user; + if (!userData) { + throw new Error('No se recibieron datos del usuario'); + } + + // Convert API response to internal format + const user = { + id: userData.id, + email: userData.email, + fullName: userData.full_name, + role: userData.role || 'admin', + isOnboardingComplete: false, // New users need onboarding + tenant_id: userData.tenant_id + }; + + // Store tokens in localStorage + localStorage.setItem('auth_token', response.access_token); + if (response.refresh_token) { + localStorage.setItem('refresh_token', response.refresh_token); + } + localStorage.setItem('user_data', JSON.stringify(user)); + + // Set auth state + dispatch(loginSuccess({ user, token: response.access_token })); + + // Mark user_registered step as completed in onboarding + try { + await onboardingService.completeStep('user_registered', { + user_id: userData.id, + registration_completed_at: new Date().toISOString(), + registration_method: 'web_form' + }); + console.log('✅ user_registered step marked as completed'); + } catch (onboardingError) { + console.warn('Failed to mark user_registered step as completed:', onboardingError); + // Don't block the flow if onboarding step completion fails + } + + toast.success('¡Cuenta creada exitosamente!'); + + // Navigate to onboarding + navigate('/app/onboarding'); + + } catch (error: any) { + console.error('Registration error:', error); + const errorMessage = error?.response?.data?.detail || error?.message || 'Error al crear la cuenta'; + toast.error(errorMessage); + } finally { + setIsLoading(false); + } + }; + + const handleInputChange = (e: React.ChangeEvent) => { + 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 + })); + } + }; + + return ( +
+
+ {/* Logo and Header */} +
+
+ 🥖 +
+

+ Únete a PanIA +

+

+ Crea tu cuenta y comienza a optimizar tu panadería +

+
+ + {/* Registration Form */} +
+
+ {/* Full Name Field */} +
+ +
+ + +
+ {errors.fullName && ( +

{errors.fullName}

+ )} +
+ + {/* Email Field */} +
+ +
+ + +
+ {errors.email && ( +

{errors.email}

+ )} +
+ + {/* Password Field */} +
+ +
+ + + +
+ {errors.password && ( +

{errors.password}

+ )} +
+ + {/* Confirm Password Field */} +
+ +
+ + + +
+ {errors.confirmPassword && ( +

{errors.confirmPassword}

+ )} +
+ + {/* Terms and Conditions */} +
+
+ + +
+ {errors.acceptTerms && ( +

{errors.acceptTerms}

+ )} +
+ + {/* Submit Button */} +
+ +
+
+ + {/* Login Link */} +
+

+ ¿Ya tienes una cuenta?{' '} + + Inicia sesión + +

+
+
+ + {/* Features Preview */} +
+

+ Prueba gratuita de 14 días • No se requiere tarjeta de crédito +

+
+
+ + Setup en 5 minutos +
+
+ + Soporte incluido +
+
+ + Cancela cuando quieras +
+
+
+
+
+ ); +}; + +export default RegisterPage; \ No newline at end of file diff --git a/frontend/src/pages/landing/LandingPage.tsx b/frontend/src/pages/landing/LandingPage.tsx index afabd0ed..28cbb8a2 100644 --- a/frontend/src/pages/landing/LandingPage.tsx +++ b/frontend/src/pages/landing/LandingPage.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; import { TrendingUp, TrendingDown, @@ -18,11 +19,10 @@ import { } from 'lucide-react'; interface LandingPageProps { - onNavigateToLogin: () => void; - onNavigateToRegister: () => void; + // No props needed with React Router } -const LandingPage: React.FC = ({ onNavigateToLogin, onNavigateToRegister }) => { +const LandingPage: React.FC = () => { const [isVideoModalOpen, setIsVideoModalOpen] = useState(false); const [currentTestimonial, setCurrentTestimonial] = useState(0); @@ -120,18 +120,18 @@ const LandingPage: React.FC = ({ onNavigateToLogin, onNavigate
- - +
@@ -159,13 +159,13 @@ const LandingPage: React.FC = ({ onNavigateToLogin, onNavigate

- + - +
@@ -528,15 +528,13 @@ const LandingPage: React.FC = ({ onNavigateToLogin, onNavigate

Mientras tanto, puedes comenzar tu prueba gratuita

- +
diff --git a/frontend/src/pages/landing/SimpleLandingPage.tsx b/frontend/src/pages/landing/SimpleLandingPage.tsx new file mode 100644 index 00000000..bdfe4d23 --- /dev/null +++ b/frontend/src/pages/landing/SimpleLandingPage.tsx @@ -0,0 +1,168 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { ArrowRight, TrendingUp, Clock, DollarSign, BarChart3 } from 'lucide-react'; + +const LandingPage: React.FC = () => { + return ( +
+ {/* Header */} +
+
+
+
+
+ 🥖 +
+ PanIA +
+ +
+ + Iniciar sesión + + + Prueba gratis + +
+
+
+
+ + {/* Hero Section */} +
+
+
+
+ ⭐ IA líder para panaderías en Madrid +
+ +

+ La primera IA para + tu panadería +

+ +

+ 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. +

+ +
+ + Empezar Gratis + + + + Iniciar Sesión + +
+
+
+
+ + {/* Features Section */} +
+
+
+

+ Todo lo que necesitas para optimizar tu panadería +

+

+ Tecnología de vanguardia diseñada específicamente para panaderías +

+
+ +
+ {[ + { + icon: TrendingUp, + title: "Predicciones Precisas", + description: "IA que aprende de tu negocio único para predecir demanda con 87% de precisión", + color: "bg-green-100 text-green-600" + }, + { + icon: Clock, + title: "Reduce Desperdicios", + description: "Disminuye hasta un 25% el desperdicio diario optimizando tu producción", + color: "bg-blue-100 text-blue-600" + }, + { + icon: DollarSign, + title: "Ahorra Dinero", + description: "Ahorra hasta €500/mes reduciendo costos operativos y desperdicios", + color: "bg-purple-100 text-purple-600" + }, + { + icon: BarChart3, + title: "Analytics Avanzados", + description: "Reportes detallados y insights que te ayudan a tomar mejores decisiones", + color: "bg-orange-100 text-orange-600" + } + ].map((feature, index) => ( +
+
+ +
+

{feature.title}

+

{feature.description}

+
+ ))} +
+
+
+ + {/* CTA Section */} +
+
+

+ ¿Listo para revolucionar tu panadería? +

+

+ Únete a más de 500 panaderías en Madrid que ya confían en PanIA para optimizar su negocio +

+ + Empezar Prueba Gratuita + + +
+
+ + {/* Footer */} +
+
+
+
+
+ 🥖 +
+ PanIA +
+

+ Inteligencia Artificial para panaderías madrileñas +

+

+ © 2024 PanIA. Todos los derechos reservados. +

+
+
+
+
+ ); +}; + +export default LandingPage; \ No newline at end of file diff --git a/frontend/src/pages/onboarding/OnboardingPage.tsx b/frontend/src/pages/onboarding/OnboardingPage.tsx index 8ed22e35..7a099e44 100644 --- a/frontend/src/pages/onboarding/OnboardingPage.tsx +++ b/frontend/src/pages/onboarding/OnboardingPage.tsx @@ -60,6 +60,62 @@ const OnboardingPage: React.FC = ({ user, onComplete }) => completeStep, refreshProgress } = useOnboarding(); + + // Helper function to complete steps ensuring dependencies are met + const completeStepWithDependencies = async (stepName: string, stepData: any = {}, allowDirectTrainingCompletion: boolean = false) => { + try { + console.log(`🔄 Completing step: ${stepName} with dependencies check`); + + // Special case: Allow direct completion of training_completed when called from WebSocket + if (stepName === 'training_completed' && allowDirectTrainingCompletion) { + console.log(`🎯 Direct training completion via WebSocket - bypassing dependency checks`); + await completeStep(stepName, stepData); + return; + } + + // Define step dependencies + const stepOrder = ['user_registered', 'bakery_registered', 'sales_data_uploaded', 'training_completed', 'dashboard_accessible']; + const stepIndex = stepOrder.indexOf(stepName); + + if (stepIndex === -1) { + throw new Error(`Unknown step: ${stepName}`); + } + + // Complete all prerequisite steps first, EXCEPT training_completed + // training_completed can only be marked when actual training finishes via WebSocket + for (let i = 0; i < stepIndex; i++) { + const prereqStep = stepOrder[i]; + const prereqCompleted = progress?.steps.find(s => s.step_name === prereqStep)?.completed; + + if (!prereqCompleted) { + // NEVER auto-complete training_completed as a prerequisite + // It must be completed only when actual training finishes via WebSocket + if (prereqStep === 'training_completed') { + console.warn(`⚠️ Cannot auto-complete training_completed as prerequisite. Training must finish first.`); + console.warn(`⚠️ Skipping prerequisite ${prereqStep} - it will be completed when training finishes`); + continue; // Skip this prerequisite instead of throwing error + } + + console.log(`🔄 Completing prerequisite step: ${prereqStep}`); + + // user_registered should have been completed during registration + if (prereqStep === 'user_registered') { + console.warn('⚠️ user_registered step not completed - this should have been done during registration'); + } + + await completeStep(prereqStep, { user_id: user?.id }); + } + } + + // Now complete the target step + console.log(`✅ Completing target step: ${stepName}`); + await completeStep(stepName, stepData); + + } catch (error) { + console.warn(`Step completion error for ${stepName}:`, error); + throw error; + } + }; const [bakeryData, setBakeryData] = useState({ name: '', address: '', @@ -179,12 +235,14 @@ const OnboardingPage: React.FC = ({ user, onComplete }) => })); // Mark training step as completed in onboarding API - completeStep('training_completed', { + // Use allowDirectTrainingCompletion=true since this is triggered by WebSocket completion + completeStepWithDependencies('training_completed', { training_completed_at: new Date().toISOString(), user_id: user?.id, - tenant_id: tenantId - }).catch(error => { - // Failed to mark training as completed in API + tenant_id: tenantId, + completion_source: 'websocket_training_completion' + }, true).catch(error => { + console.error('Failed to mark training as completed in API:', error); }); // Show celebration and auto-advance to final step after 3 seconds @@ -245,81 +303,14 @@ const OnboardingPage: React.FC = ({ user, onComplete }) => console.log('Connecting to training WebSocket:', { tenantId, trainingJobId, wsUrl }); connect(); - // Simple polling fallback for training completion detection (now that we fixed the 404 issue) - const pollingInterval = setInterval(async () => { - if (trainingProgress.status === 'running' || trainingProgress.status === 'pending') { - try { - // Check training job status via REST API as fallback - const response = await fetch(`http://localhost:8000/api/v1/tenants/${tenantId}/training/jobs/${trainingJobId}/status`, { - headers: { - 'Authorization': `Bearer ${localStorage.getItem('auth_token')}`, - 'X-Tenant-ID': tenantId - } - }); - - if (response.ok) { - const jobStatus = await response.json(); - - // If the job is completed but we haven't received WebSocket notification - if (jobStatus.status === 'completed' && (trainingProgress.status === 'running' || trainingProgress.status === 'pending')) { - console.log('Training completed detected via REST polling fallback'); - - setTrainingProgress(prev => ({ - ...prev, - progress: 100, - status: 'completed', - currentStep: 'Entrenamiento completado', - estimatedTimeRemaining: 0 - })); - - // Mark training step as completed in onboarding API - completeStep('training_completed', { - training_completed_at: new Date().toISOString(), - user_id: user?.id, - tenant_id: tenantId, - completion_detected_via: 'rest_polling_fallback' - }).catch(error => { - console.warn('Failed to mark training as completed in API:', error); - }); - - // Show celebration and auto-advance to final step after 3 seconds - toast.success('🎉 Training completed! Your AI model is ready to use.', { - duration: 5000, - icon: '🤖' - }); - - setTimeout(() => { - manualNavigation.current = true; - setCurrentStep(4); - }, 3000); - - // Clear the polling interval - clearInterval(pollingInterval); - } - - // If job failed, update status - if (jobStatus.status === 'failed' && (trainingProgress.status === 'running' || trainingProgress.status === 'pending')) { - console.log('Training failure detected via REST polling fallback'); - - setTrainingProgress(prev => ({ - ...prev, - status: 'failed', - error: jobStatus.error_message || 'Error en el entrenamiento', - currentStep: 'Error en el entrenamiento' - })); - - clearInterval(pollingInterval); - } - } - } catch (error) { - // Ignore polling errors to avoid noise - console.debug('REST polling error (expected if training not started):', error); - } - } else if (trainingProgress.status === 'completed' || trainingProgress.status === 'failed') { - // Clear polling if training is finished - clearInterval(pollingInterval); - } - }, 15000); // Poll every 15 seconds (less aggressive than before) + // ✅ DISABLED: Polling fallback now unnecessary since WebSocket is working properly + // The WebSocket connection now handles all training status updates in real-time + console.log('🚫 REST polling disabled - using WebSocket exclusively for training updates'); + + // Create dummy interval for cleanup compatibility (no actual polling) + const pollingInterval = setInterval(() => { + // No-op - REST polling is disabled, WebSocket handles all training updates + }, 60000); // Set to 1 minute but does nothing return () => { if (isConnected) { @@ -445,9 +436,9 @@ const OnboardingPage: React.FC = ({ user, onComplete }) => storeTenantId(newTenant.id); } - // Mark step as completed in onboarding API (non-blocking) + // Mark bakery_registered step as completed (dependencies will be handled automatically) try { - await completeStep('bakery_registered', { + await completeStepWithDependencies('bakery_registered', { bakery_name: bakeryData.name, bakery_address: bakeryData.address, business_type: 'bakery', // Default - will be auto-detected from sales data @@ -456,6 +447,7 @@ const OnboardingPage: React.FC = ({ user, onComplete }) => user_id: user?.id }); } catch (stepError) { + console.warn('Step completion error:', stepError); // Don't throw here - step completion is not critical for UI flow } @@ -500,7 +492,7 @@ const OnboardingPage: React.FC = ({ user, onComplete }) => stepData.has_historical_data = bakeryData.hasHistoricalData; } - await completeStep(stepName, stepData); + await completeStepWithDependencies(stepName, stepData); // Note: Not calling refreshProgress() here to avoid step reset toast.success(`✅ Paso ${currentStep} completado`); @@ -589,7 +581,7 @@ const OnboardingPage: React.FC = ({ user, onComplete }) => } else { try { // Mark final step as completed - await completeStep('dashboard_accessible', { + await completeStepWithDependencies('dashboard_accessible', { completion_time: new Date().toISOString(), user_id: user?.id, tenant_id: tenantId, @@ -724,7 +716,7 @@ const OnboardingPage: React.FC = ({ user, onComplete }) => tenantId={tenantId} onComplete={(result) => { // Mark sales data as uploaded and proceed to training - completeStep('sales_data_uploaded', { + completeStepWithDependencies('sales_data_uploaded', { smart_import: true, records_imported: result.successful_imports, import_job_id: result.import_job_id, diff --git a/frontend/src/pages/settings/AccountSettingsPage.tsx b/frontend/src/pages/settings/AccountSettingsPage.tsx new file mode 100644 index 00000000..f3f9ee39 --- /dev/null +++ b/frontend/src/pages/settings/AccountSettingsPage.tsx @@ -0,0 +1,338 @@ +import React, { useState } from 'react'; +import { + User, + Mail, + Phone, + Shield, + Save, + AlertCircle, + CheckCircle, + Clock +} from 'lucide-react'; +import { useSelector } from 'react-redux'; +import { RootState } from '../../store'; +import toast from 'react-hot-toast'; + +interface UserProfile { + fullName: string; + email: string; + phone: string; +} + +interface PasswordChange { + currentPassword: string; + newPassword: string; + confirmPassword: string; +} + +interface Session { + id: string; + device: string; + location: string; + lastActive: string; + isCurrent: boolean; +} + +const AccountSettingsPage: React.FC = () => { + const { user } = useSelector((state: RootState) => state.auth); + const [isLoading, setIsLoading] = useState(false); + + const [profile, setProfile] = useState({ + fullName: user?.fullName || '', + email: user?.email || '', + phone: '' + }); + + const [passwordForm, setPasswordForm] = useState({ + currentPassword: '', + newPassword: '', + confirmPassword: '' + }); + + const [activeSessions] = useState([ + { + id: '1', + device: 'Chrome en Windows', + location: 'Madrid, España', + lastActive: new Date().toISOString(), + isCurrent: true + }, + { + id: '2', + device: 'iPhone App', + location: 'Madrid, España', + lastActive: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), + isCurrent: false + } + ]); + + const handleUpdateProfile = async () => { + setIsLoading(true); + + try { + // Simulate API call + await new Promise(resolve => setTimeout(resolve, 1000)); + + toast.success('Perfil actualizado exitosamente'); + } catch (error) { + toast.error('Error al actualizar el perfil'); + } finally { + setIsLoading(false); + } + }; + + const handleChangePassword = async () => { + if (passwordForm.newPassword !== passwordForm.confirmPassword) { + toast.error('Las contraseñas no coinciden'); + return; + } + + if (passwordForm.newPassword.length < 8) { + toast.error('La contraseña debe tener al menos 8 caracteres'); + return; + } + + setIsLoading(true); + + try { + // Simulate API call + await new Promise(resolve => setTimeout(resolve, 1000)); + + toast.success('Contraseña actualizada exitosamente'); + setPasswordForm({ + currentPassword: '', + newPassword: '', + confirmPassword: '' + }); + } catch (error) { + toast.error('Error al actualizar la contraseña'); + } finally { + setIsLoading(false); + } + }; + + const handleTerminateSession = async (sessionId: string) => { + if (window.confirm('¿Estás seguro de que quieres cerrar esta sesión?')) { + try { + // Simulate API call + await new Promise(resolve => setTimeout(resolve, 500)); + toast.success('Sesión cerrada exitosamente'); + } catch (error) { + toast.error('Error al cerrar la sesión'); + } + } + }; + + const handleDeleteAccount = async () => { + const confirmation = window.prompt( + 'Esta acción eliminará permanentemente tu cuenta y todos los datos asociados.\n\n' + + 'Para confirmar, escribe "ELIMINAR CUENTA" exactamente como aparece:' + ); + + if (confirmation === 'ELIMINAR CUENTA') { + try { + // Simulate API call + await new Promise(resolve => setTimeout(resolve, 1000)); + toast.success('Cuenta eliminada exitosamente'); + // In real app, this would redirect to login + } catch (error) { + toast.error('Error al eliminar la cuenta'); + } + } else if (confirmation !== null) { + toast.error('Confirmación incorrecta. La cuenta no se ha eliminado.'); + } + }; + + return ( +
+
+ {/* Profile Information */} +
+

Información Personal

+
+
+
+ + setProfile(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" + /> +
+ +
+ + setProfile(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" + /> +
+
+ +
+ + setProfile(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" + /> +
+
+ +
+ +
+
+ + {/* Security Settings */} +
+

+ + Seguridad +

+ + {/* Change Password */} +
+

Cambiar Contraseña

+
+
+ + setPasswordForm(prev => ({ ...prev, currentPassword: e.target.value }))} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" + placeholder="••••••••" + /> +
+ +
+ + setPasswordForm(prev => ({ ...prev, newPassword: e.target.value }))} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" + placeholder="••••••••" + /> +
+ +
+ + setPasswordForm(prev => ({ ...prev, confirmPassword: e.target.value }))} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" + placeholder="••••••••" + /> +
+
+ + +
+ + {/* Active Sessions */} +
+

Sesiones Activas

+
+ {activeSessions.map((session) => ( +
+
+
+ +
+
+
{session.device}
+
{session.location}
+
+ {session.isCurrent ? ( + <> + + Sesión actual + + ) : ( + <> + + + Último acceso: {new Date(session.lastActive).toLocaleDateString()} + + + )} +
+
+
+ + {!session.isCurrent && ( + + )} +
+ ))} +
+
+
+ + {/* Danger Zone */} +
+

+ + Zona Peligrosa +

+
+

Eliminar Cuenta

+

+ Esta acción eliminará permanentemente tu cuenta y todos los datos asociados. + No se puede deshacer. +

+ +
+
+
+
+ ); +}; + +export default AccountSettingsPage; \ No newline at end of file diff --git a/frontend/src/pages/settings/BakeriesManagementPage.tsx b/frontend/src/pages/settings/BakeriesManagementPage.tsx new file mode 100644 index 00000000..f87b07fc --- /dev/null +++ b/frontend/src/pages/settings/BakeriesManagementPage.tsx @@ -0,0 +1,421 @@ +import React, { useState, useEffect } from 'react'; +import { + Plus, + Building, + MapPin, + Clock, + Users, + MoreVertical, + Edit, + Trash2, + Settings, + TrendingUp +} from 'lucide-react'; +import { useSelector, useDispatch } from 'react-redux'; +import { RootState } from '../../store'; +import { setCurrentTenant } from '../../store/slices/tenantSlice'; +import { useTenant } from '../../api/hooks/useTenant'; +import toast from 'react-hot-toast'; + +interface BakeryFormData { + name: string; + address: string; + business_type: 'individual' | 'central_workshop'; + coordinates?: { + lat: number; + lng: number; + }; + products: string[]; + settings?: { + operating_hours?: { + open: string; + close: string; + }; + operating_days?: number[]; + timezone?: string; + currency?: string; + }; +} + +const BakeriesManagementPage: React.FC = () => { + const dispatch = useDispatch(); + const { currentTenant } = useSelector((state: RootState) => state.tenant); + + const { + tenants, + getUserTenants, + createTenant, + updateTenant, + getTenantStats, + isLoading, + error + } = useTenant(); + + const [showCreateModal, setShowCreateModal] = useState(false); + const [editingTenant, setEditingTenant] = useState(null); + const [formData, setFormData] = useState({ + name: '', + address: '', + business_type: 'individual', + products: ['Pan', 'Croissants', 'Magdalenas'], + settings: { + operating_hours: { open: '07:00', close: '20:00' }, + operating_days: [1, 2, 3, 4, 5, 6], + timezone: 'Europe/Madrid', + currency: 'EUR' + } + }); + const [tenantStats, setTenantStats] = useState({}); + + useEffect(() => { + getUserTenants(); + }, [getUserTenants]); + + useEffect(() => { + // Load stats for each tenant + tenants.forEach(async (tenant) => { + try { + const stats = await getTenantStats(tenant.id); + setTenantStats(prev => ({ ...prev, [tenant.id]: stats })); + } catch (error) { + console.error(`Failed to load stats for tenant ${tenant.id}:`, error); + } + }); + }, [tenants, getTenantStats]); + + const handleCreateBakery = async () => { + try { + const newTenant = await createTenant(formData); + toast.success('Panadería creada exitosamente'); + setShowCreateModal(false); + resetForm(); + } catch (error) { + toast.error('Error al crear la panadería'); + } + }; + + const handleUpdateBakery = async () => { + if (!editingTenant) return; + + try { + await updateTenant(editingTenant.id, formData); + toast.success('Panadería actualizada exitosamente'); + setEditingTenant(null); + resetForm(); + } catch (error) { + toast.error('Error al actualizar la panadería'); + } + }; + + const handleSwitchTenant = (tenant: any) => { + dispatch(setCurrentTenant(tenant)); + localStorage.setItem('selectedTenantId', tenant.id); + toast.success(`Cambiado a ${tenant.name}`); + }; + + const resetForm = () => { + setFormData({ + name: '', + address: '', + business_type: 'individual', + products: ['Pan', 'Croissants', 'Magdalenas'], + settings: { + operating_hours: { open: '07:00', close: '20:00' }, + operating_days: [1, 2, 3, 4, 5, 6], + timezone: 'Europe/Madrid', + currency: 'EUR' + } + }); + }; + + const openEditModal = (tenant: any) => { + setEditingTenant(tenant); + setFormData({ + name: tenant.name, + address: tenant.address, + business_type: tenant.business_type, + products: tenant.products || ['Pan', 'Croissants', 'Magdalenas'], + settings: tenant.settings || { + operating_hours: { open: '07:00', close: '20:00' }, + operating_days: [1, 2, 3, 4, 5, 6], + timezone: 'Europe/Madrid', + currency: 'EUR' + } + }); + }; + + const getBakeryTypeInfo = (type: string) => { + return type === 'individual' + ? { label: 'Panadería Individual', color: 'bg-blue-100 text-blue-800' } + : { label: 'Obrador Central', color: 'bg-purple-100 text-purple-800' }; + }; + + if (isLoading) { + return ( +
+
+
+

Cargando panaderías...

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Gestión de Panaderías

+

+ Administra todas tus panaderías y puntos de venta +

+
+ + +
+ + {/* Bakeries Grid */} +
+ {tenants.map((tenant) => { + const typeInfo = getBakeryTypeInfo(tenant.business_type); + const stats = tenantStats[tenant.id]; + const isActive = currentTenant?.id === tenant.id; + + return ( +
+ {/* Header */} +
+
+
+ +

+ {tenant.name} +

+ {isActive && ( + + Activa + + )} +
+ + {typeInfo.label} + +
+ +
+ +
+
+ + {/* Address */} +
+ + {tenant.address} +
+ + {/* Operating Hours */} + {tenant.settings?.operating_hours && ( +
+ + + {tenant.settings.operating_hours.open} - {tenant.settings.operating_hours.close} + +
+ )} + + {/* Stats */} + {stats && ( +
+
+
+ {stats.total_sales || 0} +
+
Ventas (mes)
+
+
+
+ {stats.active_users || 0} +
+
Usuarios
+
+
+ )} + + {/* Actions */} +
+ {!isActive && ( + + )} + +
+
+ ); + })} +
+ + {/* Create/Edit Modal */} + {(showCreateModal || editingTenant) && ( +
+
+

+ {editingTenant ? 'Editar Panadería' : 'Nueva Panadería'} +

+ +
+ {/* Basic Info */} +
+
+ + setFormData(prev => ({ ...prev, name: e.target.value }))} + placeholder="Mi Panadería" + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" + /> +
+ +
+ + +
+
+ +
+ + setFormData(prev => ({ ...prev, address: e.target.value }))} + placeholder="Calle Mayor, 123, Madrid" + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" + /> +
+ + {/* Operating Hours */} +
+ +
+
+ + setFormData(prev => ({ + ...prev, + settings: { + ...prev.settings, + operating_hours: { + ...prev.settings?.operating_hours, + open: e.target.value + } + } + }))} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" + /> +
+
+ + setFormData(prev => ({ + ...prev, + settings: { + ...prev.settings, + operating_hours: { + ...prev.settings?.operating_hours, + close: e.target.value + } + } + }))} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" + /> +
+
+
+ + {/* Products */} +
+ +
+ {formData.products.map((product, index) => ( + + {product} + + ))} +
+
+
+ +
+ + +
+
+
+ )} +
+ ); +}; + +export default BakeriesManagementPage; \ No newline at end of file diff --git a/frontend/src/pages/settings/GeneralSettingsPage.tsx b/frontend/src/pages/settings/GeneralSettingsPage.tsx new file mode 100644 index 00000000..ae9e16fd --- /dev/null +++ b/frontend/src/pages/settings/GeneralSettingsPage.tsx @@ -0,0 +1,402 @@ +import React, { useState } from 'react'; +import { + Globe, + Clock, + DollarSign, + MapPin, + Save, + ChevronRight, + Mail, + Smartphone +} from 'lucide-react'; +import { useSelector } from 'react-redux'; +import { RootState } from '../../store'; +import toast from 'react-hot-toast'; + +interface GeneralSettings { + language: string; + timezone: string; + currency: string; + bakeryName: string; + bakeryAddress: string; + businessType: string; + operatingHours: { + open: string; + close: string; + }; + operatingDays: number[]; +} + +interface NotificationSettings { + emailNotifications: boolean; + smsNotifications: boolean; + dailyReports: boolean; + weeklyReports: boolean; + forecastAlerts: boolean; + stockAlerts: boolean; + orderReminders: boolean; +} + +const GeneralSettingsPage: React.FC = () => { + const { user } = useSelector((state: RootState) => state.auth); + const { currentTenant } = useSelector((state: RootState) => state.tenant); + const [isLoading, setIsLoading] = useState(false); + + const [settings, setSettings] = useState({ + language: 'es', + timezone: 'Europe/Madrid', + currency: 'EUR', + bakeryName: currentTenant?.name || 'Mi Panadería', + bakeryAddress: currentTenant?.address || '', + businessType: currentTenant?.business_type || 'individual', + operatingHours: { + open: currentTenant?.settings?.operating_hours?.open || '07:00', + close: currentTenant?.settings?.operating_hours?.close || '20:00' + }, + operatingDays: currentTenant?.settings?.operating_days || [1, 2, 3, 4, 5, 6] + }); + + const [notifications, setNotifications] = useState({ + emailNotifications: true, + smsNotifications: false, + dailyReports: true, + weeklyReports: true, + forecastAlerts: true, + stockAlerts: true, + orderReminders: true + }); + + 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 dayLabels = ['L', 'M', 'X', 'J', 'V', 'S', 'D']; + + return ( +
+
+ {/* Business Information */} +
+

Información del Negocio

+
+
+
+ + setSettings(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" + /> +
+ +
+ + +
+
+ +
+ +
+ + setSettings(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" + /> +
+
+
+
+ + {/* Operating Hours */} +
+

Horarios de Operación

+
+
+
+ + setSettings(prev => ({ + ...prev, + operatingHours: { ...prev.operatingHours, open: 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" + /> +
+
+ + setSettings(prev => ({ + ...prev, + operatingHours: { ...prev.operatingHours, close: 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" + /> +
+
+ +
+ +
+ {dayLabels.map((day, index) => ( + + ))} +
+
+
+
+ + {/* Regional Settings */} +
+

Configuración Regional

+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + {/* Notification Settings */} +
+

Notificaciones

+ + {/* Notification Channels */} +
+
+
+ +
+
Notificaciones por Email
+
Recibe alertas y reportes por correo
+
+
+ +
+ +
+
+ +
+
Notificaciones SMS
+
Alertas urgentes por mensaje de texto
+
+
+ +
+
+ + {/* Notification Types */} +
+

Tipos de Notificación

+ {[ + { 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) => ( +
+
+
{item.label}
+
{item.desc}
+
+ +
+ ))} +
+
+ + {/* Data Export */} +
+

Exportar Datos

+
+ + + + + +
+
+ + {/* Save Button */} +
+ +
+
+
+ ); +}; + +export default GeneralSettingsPage; \ No newline at end of file diff --git a/frontend/src/pages/settings/UsersManagementPage.tsx b/frontend/src/pages/settings/UsersManagementPage.tsx new file mode 100644 index 00000000..ca75d5c5 --- /dev/null +++ b/frontend/src/pages/settings/UsersManagementPage.tsx @@ -0,0 +1,326 @@ +import React, { useState, useEffect } from 'react'; +import { + UserPlus, + Mail, + Shield, + MoreVertical, + Trash2, + Edit, + Send, + User, + Crown, + Briefcase, + CheckCircle, + Clock, + AlertCircle +} from 'lucide-react'; +import { useSelector } from 'react-redux'; +import { RootState } from '../../store'; +import { useTenant } from '../../api/hooks/useTenant'; +import toast from 'react-hot-toast'; + +interface UserMember { + id: string; + user_id: string; + role: 'owner' | 'admin' | 'manager' | 'worker'; + status: 'active' | 'pending' | 'inactive'; + user: { + id: string; + email: string; + full_name: string; + last_active?: string; + }; + joined_at: string; +} + +const UsersManagementPage: React.FC = () => { + const { currentTenant } = useSelector((state: RootState) => state.tenant); + const { user: currentUser } = useSelector((state: RootState) => state.auth); + + const { + members, + getTenantMembers, + inviteUser, + removeMember, + updateMemberRole, + isLoading, + error + } = useTenant(); + + const [showInviteModal, setShowInviteModal] = useState(false); + const [inviteForm, setInviteForm] = useState({ + email: '', + role: 'worker' as const, + message: '' + }); + const [selectedMember, setSelectedMember] = useState(null); + const [showRoleModal, setShowRoleModal] = useState(false); + + useEffect(() => { + if (currentTenant) { + getTenantMembers(currentTenant.id); + } + }, [currentTenant, getTenantMembers]); + + const handleInviteUser = async () => { + if (!currentTenant || !inviteForm.email) return; + + try { + await inviteUser(currentTenant.id, { + email: inviteForm.email, + role: inviteForm.role, + message: inviteForm.message + }); + + toast.success('Invitación enviada exitosamente'); + setShowInviteModal(false); + setInviteForm({ email: '', role: 'worker', message: '' }); + } catch (error) { + toast.error('Error al enviar la invitación'); + } + }; + + const handleRemoveMember = async (member: UserMember) => { + if (!currentTenant) return; + + if (window.confirm(`¿Estás seguro de que quieres eliminar a ${member.user.full_name}?`)) { + try { + await removeMember(currentTenant.id, member.user_id); + toast.success('Usuario eliminado exitosamente'); + } catch (error) { + toast.error('Error al eliminar usuario'); + } + } + }; + + const handleUpdateRole = async (newRole: string) => { + if (!currentTenant || !selectedMember) return; + + try { + await updateMemberRole(currentTenant.id, selectedMember.user_id, newRole); + toast.success('Rol actualizado exitosamente'); + setShowRoleModal(false); + setSelectedMember(null); + } catch (error) { + toast.error('Error al actualizar el rol'); + } + }; + + const getRoleInfo = (role: string) => { + const roleMap = { + owner: { label: 'Propietario', icon: Crown, color: 'text-yellow-600 bg-yellow-100' }, + admin: { label: 'Administrador', icon: Shield, color: 'text-red-600 bg-red-100' }, + manager: { label: 'Gerente', icon: Briefcase, color: 'text-blue-600 bg-blue-100' }, + worker: { label: 'Empleado', icon: User, color: 'text-green-600 bg-green-100' } + }; + return roleMap[role as keyof typeof roleMap] || roleMap.worker; + }; + + const getStatusInfo = (status: string) => { + const statusMap = { + active: { label: 'Activo', icon: CheckCircle, color: 'text-green-600' }, + pending: { label: 'Pendiente', icon: Clock, color: 'text-yellow-600' }, + inactive: { label: 'Inactivo', icon: AlertCircle, color: 'text-gray-600' } + }; + return statusMap[status as keyof typeof statusMap] || statusMap.inactive; + }; + + const canManageUser = (member: UserMember): boolean => { + // Owners can manage everyone except other owners + // Admins can manage managers and workers + // Managers and workers can't manage anyone + if (currentUser?.role === 'owner') { + return member.role !== 'owner' || member.user_id === currentUser.id; + } + if (currentUser?.role === 'admin') { + return ['manager', 'worker'].includes(member.role); + } + return false; + }; + + if (isLoading) { + return ( +
+
+
+

Cargando usuarios...

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Gestión de Usuarios

+

+ Administra los miembros de tu equipo en {currentTenant?.name} +

+
+ + +
+ + {/* Users List */} +
+
+

+ Miembros del Equipo ({members.length}) +

+
+ +
+ {members.map((member) => { + const roleInfo = getRoleInfo(member.role); + const statusInfo = getStatusInfo(member.status); + const RoleIcon = roleInfo.icon; + const StatusIcon = statusInfo.icon; + + return ( +
+
+ {/* Avatar */} +
+ +
+ + {/* User Info */} +
+
+

+ {member.user.full_name} +

+ {member.user_id === currentUser?.id && ( + + Tú + + )} +
+

+ {member.user.email} +

+
+
+ + + {statusInfo.label} + +
+ {member.user.last_active && ( + + Último acceso: {new Date(member.user.last_active).toLocaleDateString()} + + )} +
+
+
+ + {/* Role Badge */} +
+
+ + {roleInfo.label} +
+
+ + {/* Actions */} + {canManageUser(member) && ( +
+
+ + + {/* Dropdown would go here */} +
+
+ )} +
+ ); + })} +
+
+ + {/* Invite User Modal */} + {showInviteModal && ( +
+
+

+ Invitar Nuevo Usuario +

+ +
+
+ + setInviteForm(prev => ({ ...prev, email: e.target.value }))} + placeholder="usuario@ejemplo.com" + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500" + /> +
+ +
+ + +
+ +
+ +