From 8914786973ff98d56d9341442b4f081764e9c356 Mon Sep 17 00:00:00 2001
From: Urtzi Alfaro
Date: Sat, 16 Aug 2025 20:13:40 +0200
Subject: [PATCH] New Frontend
---
frontend/src/App.tsx | 398 ++---------------
frontend/src/api/hooks/useTraining.ts | 34 +-
.../adaptive/AdaptiveInventoryWidget.tsx | 205 +++++++++
.../adaptive/AdaptiveProductionCard.tsx | 158 +++++++
.../src/components/auth/ProtectedRoute.tsx | 36 ++
.../src/components/auth/RoleBasedAccess.tsx | 58 +++
.../src/components/auth/RoleBasedRoute.tsx | 42 ++
.../src/components/layout/AnalyticsLayout.tsx | 67 +++
frontend/src/components/layout/AuthLayout.tsx | 12 +
frontend/src/components/layout/Layout.tsx | 116 +++--
.../components/layout/OperationsLayout.tsx | 99 ++++
.../src/components/layout/SettingsLayout.tsx | 65 +++
.../src/components/navigation/Breadcrumbs.tsx | 123 +++++
.../navigation/SecondaryNavigation.tsx | 126 ++++++
.../components/navigation/TenantSelector.tsx | 128 ++++++
frontend/src/hooks/useAuth.ts | 64 +++
frontend/src/hooks/useBakeryType.ts | 56 +++
frontend/src/hooks/usePermissions.ts | 112 +++++
.../src/pages/analytics/AIInsightsPage.tsx | 17 +
.../pages/analytics/FinancialReportsPage.tsx | 17 +
.../pages/analytics/PerformanceKPIsPage.tsx | 17 +
.../pages/analytics/ProductionReportsPage.tsx | 20 +
.../pages/analytics/SalesAnalyticsPage.tsx | 120 +++++
frontend/src/pages/auth/LoginPage.tsx | 30 +-
.../src/pages/auth/SimpleRegisterPage.tsx | 421 ++++++++++++++++++
frontend/src/pages/landing/LandingPage.tsx | 48 +-
.../src/pages/landing/SimpleLandingPage.tsx | 168 +++++++
.../src/pages/onboarding/OnboardingPage.tsx | 160 ++++---
.../pages/settings/AccountSettingsPage.tsx | 338 ++++++++++++++
.../pages/settings/BakeriesManagementPage.tsx | 421 ++++++++++++++++++
.../pages/settings/GeneralSettingsPage.tsx | 402 +++++++++++++++++
.../pages/settings/UsersManagementPage.tsx | 326 ++++++++++++++
frontend/src/router/index.tsx | 302 +++++++++++++
frontend/src/store/slices/authSlice.ts | 3 +-
.../training/app/services/training_service.py | 52 ++-
35 files changed, 4223 insertions(+), 538 deletions(-)
create mode 100644 frontend/src/components/adaptive/AdaptiveInventoryWidget.tsx
create mode 100644 frontend/src/components/adaptive/AdaptiveProductionCard.tsx
create mode 100644 frontend/src/components/auth/ProtectedRoute.tsx
create mode 100644 frontend/src/components/auth/RoleBasedAccess.tsx
create mode 100644 frontend/src/components/auth/RoleBasedRoute.tsx
create mode 100644 frontend/src/components/layout/AnalyticsLayout.tsx
create mode 100644 frontend/src/components/layout/AuthLayout.tsx
create mode 100644 frontend/src/components/layout/OperationsLayout.tsx
create mode 100644 frontend/src/components/layout/SettingsLayout.tsx
create mode 100644 frontend/src/components/navigation/Breadcrumbs.tsx
create mode 100644 frontend/src/components/navigation/SecondaryNavigation.tsx
create mode 100644 frontend/src/components/navigation/TenantSelector.tsx
create mode 100644 frontend/src/hooks/useAuth.ts
create mode 100644 frontend/src/hooks/useBakeryType.ts
create mode 100644 frontend/src/hooks/usePermissions.ts
create mode 100644 frontend/src/pages/analytics/AIInsightsPage.tsx
create mode 100644 frontend/src/pages/analytics/FinancialReportsPage.tsx
create mode 100644 frontend/src/pages/analytics/PerformanceKPIsPage.tsx
create mode 100644 frontend/src/pages/analytics/ProductionReportsPage.tsx
create mode 100644 frontend/src/pages/analytics/SalesAnalyticsPage.tsx
create mode 100644 frontend/src/pages/auth/SimpleRegisterPage.tsx
create mode 100644 frontend/src/pages/landing/SimpleLandingPage.tsx
create mode 100644 frontend/src/pages/settings/AccountSettingsPage.tsx
create mode 100644 frontend/src/pages/settings/BakeriesManagementPage.tsx
create mode 100644 frontend/src/pages/settings/GeneralSettingsPage.tsx
create mode 100644 frontend/src/pages/settings/UsersManagementPage.tsx
create mode 100644 frontend/src/router/index.tsx
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 = () => (
-
-);
-
-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 */}
+
+
+ Ver Todo el {getInventoryLabel()}
+
+
+
+ );
+};
\ 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 */}
+
+
+
+ {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' && (
+ onStatusChange(item.id, 'in_progress')}
+ className="flex-1 px-3 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors"
+ >
+ {isIndividual ? 'Iniciar Horneado' : 'Iniciar Distribución'}
+
+ )}
+
+ {item.status === 'in_progress' && (
+ onStatusChange(item.id, 'completed')}
+ className="flex-1 px-3 py-2 bg-green-600 text-white text-sm rounded-lg hover:bg-green-700 transition-colors"
+ >
+ {isIndividual ? 'Marcar Terminado' : 'Marcar Entregado'}
+
+ )}
+
+ )}
+
+ );
+};
\ 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 (
- handleNavigate(item.id)}
+ to={item.href}
className={`
flex items-center px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200
${isActive
@@ -103,17 +123,20 @@ const Layout: React.FC = ({
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
}
`}
+ onClick={() => setIsMobileMenuOpen(false)}
>
{item.label}
-
+
);
})}
- {/* Right side - Notifications and User Menu */}
+ {/* Right side - Tenant Selector, Notifications and User Menu */}
+ {/* Tenant Selector */}
+
{/* Notifications */}
@@ -142,19 +165,17 @@ const Layout: React.FC = ({
{user.fullName}
{user.email}
- {
- handleNavigate('settings');
- setIsUserMenuOpen(false);
- }}
+ setIsUserMenuOpen(false)}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 flex items-center"
>
Configuración
-
+
{
- onLogout();
+ handleLogout();
setIsUserMenuOpen(false);
}}
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 flex items-center"
@@ -173,14 +194,15 @@ const Layout: React.FC = ({
{isMobileMenuOpen && (
- {navigation.map((item) => {
+ {filteredNavigation.map((item) => {
const Icon = item.icon;
- const isActive = currentPage === item.id;
+ const isActive = isActiveRoute(item.href);
return (
- handleNavigate(item.id)}
+ to={item.href}
+ onClick={() => setIsMobileMenuOpen(false)}
className={`
w-full flex items-center px-3 py-2 rounded-lg text-base font-medium transition-all duration-200
${isActive
@@ -191,7 +213,7 @@ const Layout: React.FC = ({
>
{item.label}
-
+
);
})}
@@ -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 (
+
+
+ {breadcrumbs.map((breadcrumb, index) => (
+
+ {index > 0 && (
+
+ )}
+
+ {breadcrumb.href ? (
+
+ {index === 0 && }
+ {breadcrumb.label}
+
+ ) : (
+
+ {index === 0 && }
+ {breadcrumb.label}
+
+ )}
+
+ ))}
+
+
+ );
+};
\ 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 (
+
+
+ {items.map((item) => {
+ const Icon = getIcon(item.icon);
+ const isItemActive = isActive(item.href);
+ const hasChildren = item.children && item.children.length > 0;
+ const isExpanded = expandedItems.has(item.id);
+ const hasActiveChildItem = hasActiveChild(item.children);
+
+ return (
+
+
+
+
+ {item.label}
+
+
+ {hasChildren && (
+ toggleExpanded(item.id)}
+ className="ml-1 p-1 rounded hover:bg-gray-100 transition-colors"
+ >
+ {isExpanded ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ {/* Dropdown for children */}
+ {hasChildren && isExpanded && (
+
+ {item.children!.map((child) => (
+
+ {child.label}
+
+ ))}
+
+ )}
+
+ );
+ })}
+
+
+ );
+};
\ 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 (
+
+
setIsOpen(!isOpen)}
+ className="flex items-center text-sm bg-white rounded-lg p-2 hover:bg-gray-50 transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 border border-gray-200"
+ >
+
+
+ {currentTenant?.name || 'Seleccionar panadería'}
+
+
+
+
+ {isOpen && (
+ <>
+ {/* Backdrop */}
+
setIsOpen(false)}
+ />
+
+ {/* Dropdown */}
+
+
+
+
+ {tenants.map((tenant) => (
+
handleTenantChange(tenant)}
+ className="w-full text-left px-4 py-3 text-sm hover:bg-gray-50 flex items-center justify-between transition-colors"
+ >
+
+
+
+
+ {tenant.name}
+
+
+ {tenant.address}
+
+
+
+ {tenant.business_type === 'individual' ? 'Individual' : 'Obrador Central'}
+
+
+
+
+
+ {currentTenant?.id === tenant.id && (
+
+ )}
+
+ ))}
+
+
+
+ {
+ setIsOpen(false);
+ // Navigate to bakeries management
+ window.location.href = '/app/settings/bakeries';
+ }}
+ className="text-xs text-primary-600 hover:text-primary-700 font-medium"
+ >
+ + Administrar panaderías
+
+
+
+ >
+ )}
+
+ );
+};
\ 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) => (
+ setTimeRange(range)}
+ className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
+ timeRange === range
+ ? 'bg-primary-100 text-primary-700'
+ : 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
+ }`}
+ >
+ {range === 'day' && 'Hoy'}
+ {range === 'week' && 'Esta Semana'}
+ {range === 'month' && 'Este Mes'}
+ {range === 'quarter' && 'Este Trimestre'}
+
+ ))}
+
+
+
+ {/* KPI Cards */}
+
+
+
+
+
+
+
+
Ingresos Totales
+
€2,847
+
+
+
+
+
+
+
+
+
+
+
+ {isIndividual ? 'Productos Vendidos' : 'Productos Distribuidos'}
+
+
1,429
+
+
+
+
+
+
+
+
+
+ {/* 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?{' '}
-
Regístrate gratis
-
+
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 */}
+
+
+
+ {/* 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
-
Iniciar sesión
-
-
+
Prueba gratis
-
+
@@ -159,13 +159,13 @@ const LandingPage: React.FC = ({ onNavigateToLogin, onNavigate
-
Comenzar gratis
-
+
setIsVideoModalOpen(true)}
@@ -419,18 +419,18 @@ const LandingPage: React.FC = ({ onNavigateToLogin, onNavigate
-
Comenzar prueba gratuita
-
-
+
Ya tengo cuenta
-
+
@@ -528,15 +528,13 @@ const LandingPage: React.FC
= ({ onNavigateToLogin, onNavigate
Mientras tanto, puedes comenzar tu prueba gratuita
- {
- setIsVideoModalOpen(false);
- onNavigateToRegister();
- }}
- className="mt-4 bg-primary-500 text-white px-6 py-2 rounded-lg hover:bg-primary-600 transition-colors"
+ setIsVideoModalOpen(false)}
+ className="mt-4 bg-primary-500 text-white px-6 py-2 rounded-lg hover:bg-primary-600 transition-colors inline-block"
>
Comenzar prueba gratis
-
+
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 */}
+
+
+
+
+
+
+
+ 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 */}
+
+
+
+
+
+ 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
+
+
+
+
+
+
+ Teléfono
+
+
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"
+ />
+
+
+
+
+
+
+ Guardar Cambios
+
+
+
+
+ {/* Security Settings */}
+
+
+
+ Seguridad
+
+
+ {/* Change Password */}
+
+
Cambiar Contraseña
+
+
+
+ Actualizar Contraseña
+
+
+
+ {/* 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 && (
+
handleTerminateSession(session.id)}
+ className="text-red-600 hover:text-red-700 text-sm font-medium"
+ >
+ Cerrar sesión
+
+ )}
+
+ ))}
+
+
+
+
+ {/* Danger Zone */}
+
+
+
+ Zona Peligrosa
+
+
+
Eliminar Cuenta
+
+ Esta acción eliminará permanentemente tu cuenta y todos los datos asociados.
+ No se puede deshacer.
+
+
+ Eliminar Cuenta
+
+
+
+
+
+ );
+};
+
+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
+
+
+
+
setShowCreateModal(true)}
+ className="inline-flex items-center px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
+ >
+
+ Nueva Panadería
+
+
+
+ {/* 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 && (
+ handleSwitchTenant(tenant)}
+ className="flex-1 px-3 py-2 bg-primary-600 text-white text-sm rounded-lg hover:bg-primary-700 transition-colors"
+ >
+ Activar
+
+ )}
+ openEditModal(tenant)}
+ className="flex-1 px-3 py-2 bg-gray-100 text-gray-700 text-sm rounded-lg hover:bg-gray-200 transition-colors"
+ >
+ Editar
+
+
+
+ );
+ })}
+
+
+ {/* Create/Edit Modal */}
+ {(showCreateModal || editingTenant) && (
+
+
+
+ {editingTenant ? 'Editar Panadería' : 'Nueva Panadería'}
+
+
+
+ {/* Basic Info */}
+
+
+
+ Nombre
+
+ 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"
+ />
+
+
+
+
+ Tipo de negocio
+
+ setFormData(prev => ({ ...prev, business_type: e.target.value as any }))}
+ className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
+ >
+ Panadería Individual
+ Obrador Central
+
+
+
+
+
+
+ Dirección
+
+ 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 */}
+
+
+ Horarios de operación
+
+
+
+
+ {/* Products */}
+
+
+ Productos
+
+
+ {formData.products.map((product, index) => (
+
+ {product}
+
+ ))}
+
+
+
+
+
+ {
+ setShowCreateModal(false);
+ setEditingTenant(null);
+ resetForm();
+ }}
+ className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
+ >
+ Cancelar
+
+
+ {editingTenant ? 'Actualizar' : 'Crear'} Panadería
+
+
+
+
+ )}
+
+ );
+};
+
+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
+
+
+
+
+ Nombre de la panadería
+
+ 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"
+ />
+
+
+
+
+ Tipo de negocio
+
+ setSettings(prev => ({ ...prev, businessType: e.target.value }))}
+ className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
+ >
+ Panadería Individual
+ Obrador Central
+
+
+
+
+
+
+ Dirección
+
+
+
+ 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
+
+
+
+
+
+ Días de operación
+
+
+ {dayLabels.map((day, index) => (
+
+ {
+ const dayNum = index + 1;
+ setSettings(prev => ({
+ ...prev,
+ operatingDays: e.target.checked
+ ? [...prev.operatingDays, dayNum]
+ : prev.operatingDays.filter(d => d !== dayNum)
+ }));
+ }}
+ className="sr-only peer"
+ />
+
+ {day}
+
+
+ ))}
+
+
+
+
+
+ {/* Regional Settings */}
+
+
Configuración Regional
+
+
+
+
+ Idioma
+
+ setSettings(prev => ({ ...prev, language: e.target.value }))}
+ className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
+ >
+ Español
+ English
+
+
+
+
+
+
+ Zona horaria
+
+ setSettings(prev => ({ ...prev, timezone: e.target.value }))}
+ className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
+ >
+ Europa/Madrid (CET)
+ Europa/Londres (GMT)
+ América/Nueva York (EST)
+
+
+
+
+
+
+ Moneda
+
+ setSettings(prev => ({ ...prev, currency: e.target.value }))}
+ className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
+ >
+ Euro (€)
+ Dólar americano ($)
+ Libra esterlina (£)
+
+
+
+
+
+ {/* Notification Settings */}
+
+
Notificaciones
+
+ {/* Notification Channels */}
+
+
+
+
+
+
Notificaciones por Email
+
Recibe alertas y reportes por correo
+
+
+
+ setNotifications(prev => ({
+ ...prev,
+ emailNotifications: e.target.checked
+ }))}
+ className="sr-only peer"
+ />
+
+
+
+
+
+
+
+
+
Notificaciones SMS
+
Alertas urgentes por mensaje de texto
+
+
+
+ setNotifications(prev => ({
+ ...prev,
+ smsNotifications: e.target.checked
+ }))}
+ className="sr-only peer"
+ />
+
+
+
+
+
+ {/* 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}
+
+
+ setNotifications(prev => ({
+ ...prev,
+ [item.key]: e.target.checked
+ }))}
+ className="sr-only peer"
+ />
+
+
+
+ ))}
+
+
+
+ {/* Data Export */}
+
+
Exportar Datos
+
+
+
+
+
Exportar todas las predicciones
+
Descargar historial completo en CSV
+
+
+
+
+
+
+
+
+
Exportar datos de ventas
+
Historial de ventas y análisis
+
+
+
+
+
+
+
+
+
Exportar configuración
+
Respaldo de toda la configuración
+
+
+
+
+
+
+
+ {/* Save Button */}
+
+
+ {isLoading ? (
+ <>
+
+ Guardando...
+ >
+ ) : (
+ <>
+
+ Guardar Cambios
+ >
+ )}
+
+
+
+
+ );
+};
+
+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}
+
+
+
+
setShowInviteModal(true)}
+ className="inline-flex items-center px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
+ >
+
+ Invitar Usuario
+
+
+
+ {/* 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
+
+
+
+
+
+ Correo electrónico
+
+ 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"
+ />
+
+
+
+
+ Rol
+
+ setInviteForm(prev => ({ ...prev, role: e.target.value as any }))}
+ className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
+ >
+ Empleado
+ Gerente
+ {currentUser?.role === 'owner' && Administrador }
+
+
+
+
+
+ Mensaje personal (opcional)
+
+
+
+
+
+ setShowInviteModal(false)}
+ className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
+ >
+ Cancelar
+
+
+
+ Enviar Invitación
+
+
+
+
+ )}
+
+ );
+};
+
+export default UsersManagementPage;
\ No newline at end of file
diff --git a/frontend/src/router/index.tsx b/frontend/src/router/index.tsx
new file mode 100644
index 00000000..193347e0
--- /dev/null
+++ b/frontend/src/router/index.tsx
@@ -0,0 +1,302 @@
+import React from 'react';
+import { createBrowserRouter, Navigate } from 'react-router-dom';
+import { useSelector } from 'react-redux';
+
+// Layout components
+import Layout from '../components/layout/Layout';
+import AuthLayout from '../components/layout/AuthLayout';
+
+// Pages
+import LandingPage from '../pages/landing/LandingPage';
+import LoginPage from '../pages/auth/LoginPage';
+import RegisterPage from '../pages/auth/SimpleRegisterPage';
+import OnboardingPage from '../pages/onboarding/OnboardingPage';
+import DashboardPage from '../pages/dashboard/DashboardPage';
+
+// Operations Hub Pages
+import OperationsLayout from '../components/layout/OperationsLayout';
+import ProductionPage from '../pages/production/ProductionPage';
+import OrdersPage from '../pages/orders/OrdersPage';
+import InventoryPage from '../pages/inventory/InventoryPage';
+import SalesPage from '../pages/sales/SalesPage';
+import RecipesPage from '../pages/recipes/RecipesPage';
+
+// Analytics Hub Pages
+import AnalyticsLayout from '../components/layout/AnalyticsLayout';
+import ForecastPage from '../pages/forecast/ForecastPage';
+import SalesAnalyticsPage from '../pages/analytics/SalesAnalyticsPage';
+import ProductionReportsPage from '../pages/analytics/ProductionReportsPage';
+import FinancialReportsPage from '../pages/analytics/FinancialReportsPage';
+import PerformanceKPIsPage from '../pages/analytics/PerformanceKPIsPage';
+import AIInsightsPage from '../pages/analytics/AIInsightsPage';
+
+// Settings Pages
+import SettingsLayout from '../components/layout/SettingsLayout';
+import SettingsPage from '../pages/settings/SettingsPage';
+import UsersManagementPage from '../pages/settings/UsersManagementPage';
+import BakeriesManagementPage from '../pages/settings/BakeriesManagementPage';
+import GeneralSettingsPage from '../pages/settings/GeneralSettingsPage';
+import AccountSettingsPage from '../pages/settings/AccountSettingsPage';
+
+// Route Guards
+import ProtectedRoute from '../components/auth/ProtectedRoute';
+import RoleBasedRoute from '../components/auth/RoleBasedRoute';
+
+// Add RootState import for type checking
+import type { RootState } from '../store';
+
+export const router = createBrowserRouter([
+ // Public Routes
+ {
+ path: '/',
+ element: ,
+ children: [
+ {
+ index: true,
+ element:
+ },
+ {
+ path: 'login',
+ element:
+ },
+ {
+ path: 'register',
+ element:
+ }
+ ]
+ },
+
+ // Protected Routes
+ {
+ path: '/app',
+ element: (
+
+
+
+ ),
+ children: [
+ // Dashboard
+ {
+ index: true,
+ element:
+ },
+ {
+ path: 'dashboard',
+ element:
+ },
+
+ // Onboarding (conditional)
+ {
+ path: 'onboarding',
+ element:
+ },
+
+ // Operations Hub
+ {
+ path: 'operations',
+ element: ,
+ children: [
+ {
+ index: true,
+ element:
+ },
+ {
+ path: 'production',
+ children: [
+ {
+ index: true,
+ element:
+ },
+ {
+ path: 'schedule',
+ element:
+ },
+ {
+ path: 'active-batches',
+ element:
+ },
+ {
+ path: 'equipment',
+ element:
+ }
+ ]
+ },
+ {
+ path: 'orders',
+ children: [
+ {
+ index: true,
+ element:
+ },
+ {
+ path: 'incoming',
+ element:
+ },
+ {
+ path: 'in-progress',
+ element:
+ },
+ {
+ path: 'supplier-orders',
+ element:
+ }
+ ]
+ },
+ {
+ path: 'inventory',
+ children: [
+ {
+ index: true,
+ element:
+ },
+ {
+ path: 'stock-levels',
+ element:
+ },
+ {
+ path: 'movements',
+ element:
+ },
+ {
+ path: 'alerts',
+ element:
+ }
+ ]
+ },
+ {
+ path: 'sales',
+ children: [
+ {
+ index: true,
+ element:
+ },
+ {
+ path: 'daily-sales',
+ element:
+ },
+ {
+ path: 'customer-orders',
+ element:
+ },
+ {
+ path: 'pos-integration',
+ element:
+ }
+ ]
+ },
+ {
+ path: 'recipes',
+ children: [
+ {
+ index: true,
+ element:
+ },
+ {
+ path: 'active-recipes',
+ element:
+ },
+ {
+ path: 'development',
+ element: (
+
+
+
+ )
+ },
+ {
+ path: 'costing',
+ element: (
+
+
+
+ )
+ }
+ ]
+ }
+ ]
+ },
+
+ // Analytics Hub (Admin/Manager only)
+ {
+ path: 'analytics',
+ element: (
+
+
+
+ ),
+ children: [
+ {
+ index: true,
+ element:
+ },
+ {
+ path: 'forecasting',
+ element:
+ },
+ {
+ path: 'sales-analytics',
+ element:
+ },
+ {
+ path: 'production-reports',
+ element:
+ },
+ {
+ path: 'financial-reports',
+ element:
+ },
+ {
+ path: 'performance-kpis',
+ element:
+ },
+ {
+ path: 'ai-insights',
+ element:
+ }
+ ]
+ },
+
+ // Settings Hub
+ {
+ path: 'settings',
+ element: ,
+ children: [
+ {
+ index: true,
+ element:
+ },
+ {
+ path: 'general',
+ element:
+ },
+ {
+ path: 'users',
+ element: (
+
+
+
+ )
+ },
+ {
+ path: 'bakeries',
+ element: (
+
+
+
+ )
+ },
+ {
+ path: 'account',
+ element:
+ }
+ ]
+ }
+ ]
+ },
+
+ // Catch all route
+ {
+ path: '*',
+ element:
+ }
+]);
\ No newline at end of file
diff --git a/frontend/src/store/slices/authSlice.ts b/frontend/src/store/slices/authSlice.ts
index 5624b2bf..2f6a0f50 100644
--- a/frontend/src/store/slices/authSlice.ts
+++ b/frontend/src/store/slices/authSlice.ts
@@ -5,8 +5,9 @@ interface User {
id: string;
email: string;
fullName: string;
- role: string;
+ role: 'owner' | 'admin' | 'manager' | 'worker';
isOnboardingComplete: boolean;
+ tenant_id?: string;
}
interface AuthState {
diff --git a/services/training/app/services/training_service.py b/services/training/app/services/training_service.py
index 52515543..ad6b8c20 100644
--- a/services/training/app/services/training_service.py
+++ b/services/training/app/services/training_service.py
@@ -36,9 +36,10 @@ logger = structlog.get_logger()
def make_json_serializable(obj):
- """Convert numpy/pandas types and UUID objects to JSON-serializable Python types"""
+ """Convert numpy/pandas types, datetime, and UUID objects to JSON-serializable Python types"""
import uuid
from decimal import Decimal
+ from datetime import datetime, date
if isinstance(obj, (np.integer, pd.Int64Dtype)):
return int(obj)
@@ -50,6 +51,10 @@ def make_json_serializable(obj):
return obj.tolist()
elif isinstance(obj, pd.DataFrame):
return obj.to_dict('records')
+ elif isinstance(obj, datetime):
+ return obj.isoformat()
+ elif isinstance(obj, date):
+ return obj.isoformat()
elif isinstance(obj, uuid.UUID):
return str(obj)
elif hasattr(obj, '__class__') and 'UUID' in str(obj.__class__):
@@ -127,7 +132,8 @@ class EnhancedTrainingService:
tenant_id=tenant_id)
# Get session and initialize repositories
- async with self.database_manager.get_session() as session:
+ from app.core.database import get_background_db_session
+ async with get_background_db_session() as session:
await self._init_repositories(session)
try:
@@ -168,15 +174,24 @@ class EnhancedTrainingService:
logger.info(f"Pre-flight check passed: {len(sales_data)} sales records found",
tenant_id=tenant_id, job_id=job_id)
- # Create training log entry
- log_data = {
- "job_id": job_id,
- "tenant_id": tenant_id,
- "status": "running",
- "progress": 0,
- "current_step": "initializing"
- }
- training_log = await self.training_log_repo.create_training_log(log_data)
+ # Check if training log already exists, create if not
+ existing_log = await self.training_log_repo.get_log_by_job_id(job_id)
+
+ if existing_log:
+ logger.info("Training log already exists, updating status", job_id=job_id)
+ training_log = await self.training_log_repo.update_log_progress(
+ job_id, 0, "initializing", "running"
+ )
+ else:
+ # Create new training log entry
+ log_data = {
+ "job_id": job_id,
+ "tenant_id": tenant_id,
+ "status": "running",
+ "progress": 0,
+ "current_step": "initializing"
+ }
+ training_log = await self.training_log_repo.create_training_log(log_data)
# Initialize status publisher
status_publisher = TrainingStatusPublisher(job_id, tenant_id)
@@ -422,7 +437,8 @@ class EnhancedTrainingService:
async def get_training_status(self, job_id: str) -> Dict[str, Any]:
"""Get training job status using repository"""
try:
- async with self.database_manager.get_session() as session:
+ from app.core.database import get_background_db_session
+ async with get_background_db_session() as session:
await self._init_repositories(session)
log = await self.training_log_repo.get_log_by_job_id(job_id)
@@ -456,7 +472,8 @@ class EnhancedTrainingService:
) -> List[Dict[str, Any]]:
"""Get models for a tenant using repository"""
try:
- async with self.database_manager.get_session() as session:
+ from app.core.database import get_background_db_session
+ async with get_background_db_session() as session:
await self._init_repositories(session)
if active_only:
@@ -483,7 +500,8 @@ class EnhancedTrainingService:
async def get_model_performance(self, model_id: str) -> Dict[str, Any]:
"""Get model performance metrics using repository"""
try:
- async with self.database_manager.get_session() as session:
+ from app.core.database import get_background_db_session
+ async with get_background_db_session() as session:
await self._init_repositories(session)
# Get model summary
@@ -514,7 +532,8 @@ class EnhancedTrainingService:
async def get_tenant_statistics(self, tenant_id: str) -> Dict[str, Any]:
"""Get comprehensive tenant statistics using repositories"""
try:
- async with self.database_manager.get_session() as session:
+ from app.core.database import get_background_db_session
+ async with get_background_db_session() as session:
await self._init_repositories(session)
# Get model statistics
@@ -564,7 +583,8 @@ class EnhancedTrainingService:
tenant_id: str = None):
"""Update job status using repository pattern"""
try:
- async with self.database_manager.get_session() as session:
+ from app.core.database import get_background_db_session
+ async with get_background_db_session() as session:
await self._init_repositories(session)
# Check if log exists, create if not