New Frontend

This commit is contained in:
Urtzi Alfaro
2025-08-16 20:13:40 +02:00
parent 23c5f50111
commit 8914786973
35 changed files with 4223 additions and 538 deletions

View File

@@ -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 = () => (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<LoadingSpinner size="lg" />
<p className="mt-4 text-gray-600">Cargando PanIA...</p>
</div>
</div>
);
const App: React.FC = () => {
const [appState, setAppState] = useState<AppState>({
isAuthenticated: false,
isLoading: true,
user: null,
currentPage: 'landing',
routingDecision: null
});
// Helper function to map NextAction to CurrentPage
const mapActionToPage = (action: NextAction): CurrentPage => {
const actionPageMap: Record<NextAction, CurrentPage> = {
'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 <LoadingFallback />;
}
const renderCurrentPage = () => {
// Public pages (non-authenticated)
if (!appState.isAuthenticated) {
switch (appState.currentPage) {
case 'login':
return (
<LoginPage
onLogin={handleLogin}
onNavigateToRegister={() => navigateTo('register')}
/>
);
case 'register':
return (
<RegisterPage
onLogin={handleLogin}
onNavigateToLogin={() => navigateTo('login')}
/>
);
default:
return (
<LandingPage
onNavigateToLogin={() => navigateTo('login')}
onNavigateToRegister={() => navigateTo('register')}
/>
);
}
}
// Authenticated pages
if (!appState.user?.isOnboardingComplete && appState.currentPage !== 'settings') {
return (
<OnboardingPage
user={appState.user!}
onComplete={handleOnboardingComplete}
/>
);
}
// Main app pages with layout
const pageComponent = () => {
switch (appState.currentPage) {
case 'reports':
return <ForecastPage />;
case 'orders':
return <OrdersPage />;
case 'production':
return <ProductionPage />;
case 'inventory':
return <InventoryPage />;
case 'recipes':
return <RecipesPage />;
case 'sales':
return <SalesPage />;
case 'settings':
return <SettingsPage user={appState.user!} onLogout={handleLogout} />;
default:
return <DashboardPage
onNavigateToOrders={() => navigateTo('orders')}
onNavigateToReports={() => navigateTo('reports')}
onNavigateToProduction={() => navigateTo('production')}
onNavigateToInventory={() => navigateTo('inventory')}
onNavigateToRecipes={() => navigateTo('recipes')}
onNavigateToSales={() => navigateTo('sales')}
/>;
}
};
return (
<Layout
user={appState.user!}
currentPage={appState.currentPage}
onNavigate={navigateTo}
onLogout={handleLogout}
>
{pageComponent()}
</Layout>
);
};
initializeAuth();
}, [initializeAuth]);
return (
<ErrorBoundary>
<div className="App min-h-screen bg-gray-50">
<RouterProvider router={router} />
{/* Global Toast Notifications */}
<Toaster
position="top-right"
toastOptions={{
duration: 4000,
style: {
background: '#fff',
color: '#333',
boxShadow: '0 4px 25px -5px rgba(0, 0, 0, 0.1)',
borderRadius: '12px',
padding: '16px',
},
success: {
iconTheme: {
primary: '#22c55e',
secondary: '#fff',
},
},
error: {
iconTheme: {
primary: '#ef4444',
secondary: '#fff',
},
},
}}
/>
</div>
</ErrorBoundary>
);
};
const App: React.FC = () => {
return (
<Provider store={store}>
<ErrorBoundary>
<div className="App min-h-screen bg-gray-50">
{renderCurrentPage()}
{/* Global Toast Notifications */}
<Toaster
position="top-right"
toastOptions={{
duration: 4000,
style: {
background: '#fff',
color: '#333',
boxShadow: '0 4px 25px -5px rgba(0, 0, 0, 0.1)',
borderRadius: '12px',
padding: '16px',
},
success: {
iconTheme: {
primary: '#22c55e',
secondary: '#fff',
},
},
error: {
iconTheme: {
primary: '#ef4444',
secondary: '#fff',
},
},
}}
/>
</div>
</ErrorBoundary>
<AppContent />
</Provider>
);
};

View File

@@ -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<TrainingJobResponse[]>([]);
const [currentJob, setCurrentJob] = useState<TrainingJobResponse | null>(null);
const [models, setModels] = useState<ModelInfo[]>([]);
@@ -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]);

View File

@@ -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<AdaptiveInventoryWidgetProps> = ({
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 (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center">
<Package className="h-5 w-5 text-gray-600 mr-2" />
<h3 className="text-lg font-semibold text-gray-900">
{title || getInventoryLabel()}
</h3>
</div>
{showAlerts && lowStockItems.length > 0 && (
<div className="flex items-center text-sm text-orange-600 bg-orange-100 px-3 py-1 rounded-full">
<AlertTriangle className="h-4 w-4 mr-1" />
{lowStockItems.length} alertas
</div>
)}
</div>
{/* Items List */}
<div className="space-y-3">
{filteredItems.slice(0, 6).map((item) => {
const stockStatus = getStockStatus(item);
const expiryWarning = getExpiryWarning(item.expiryDate);
return (
<div key={item.id} className="flex items-center justify-between p-3 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
<div className="flex items-center flex-1 min-w-0">
<span className="text-2xl mr-3">{getItemIcon(item.category)}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center">
<h4 className="text-sm font-medium text-gray-900 truncate">
{item.name}
</h4>
{stockStatus !== 'normal' && (
<span className={`ml-2 px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(stockStatus)}`}>
{stockStatus === 'critical' ? 'Crítico' : 'Bajo'}
</span>
)}
</div>
<div className="flex items-center space-x-4 mt-1">
<span className="text-sm text-gray-600">
Stock: {item.currentStock} {item.unit}
</span>
{item.location && isCentral && (
<div className="flex items-center text-xs text-gray-500">
<MapPin className="h-3 w-3 mr-1" />
{item.location}
</div>
)}
{expiryWarning && (
<div className="flex items-center text-xs text-red-600">
<Clock className="h-3 w-3 mr-1" />
{expiryWarning === 'expires-today' ? 'Caduca hoy' : 'Caduca pronto'}
</div>
)}
</div>
{item.supplier && isIndividual && (
<div className="text-xs text-gray-500 mt-1">
Proveedor: {item.supplier}
</div>
)}
</div>
</div>
<div className="ml-4">
<div className="text-right">
<div className={`w-3 h-3 rounded-full ${getStatusColor(stockStatus).replace('text-', 'bg-').replace(' bg-', ' ').replace('100', '500')}`}></div>
</div>
</div>
</div>
);
})}
</div>
{/* Quick Stats */}
<div className="mt-6 pt-4 border-t border-gray-200">
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-2xl font-bold text-gray-900">{filteredItems.length}</div>
<div className="text-xs text-gray-500">
{isIndividual ? 'Ingredientes' : 'Productos'}
</div>
</div>
<div>
<div className="text-2xl font-bold text-yellow-600">{lowStockItems.length}</div>
<div className="text-xs text-gray-500">Stock bajo</div>
</div>
<div>
<div className="text-2xl font-bold text-red-600">
{filteredItems.filter(item => getExpiryWarning(item.expiryDate)).length}
</div>
<div className="text-xs text-gray-500">
{isIndividual ? 'Próximos a caducar' : 'Próximos a vencer'}
</div>
</div>
</div>
</div>
{/* Action Button */}
<div className="mt-4">
<button className="w-full px-4 py-2 bg-primary-600 text-white text-sm rounded-lg hover:bg-primary-700 transition-colors">
Ver Todo el {getInventoryLabel()}
</button>
</div>
</div>
);
};

View File

@@ -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<AdaptiveProductionCardProps> = ({
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 ? <ChefHat className="h-5 w-5" /> : <Truck className="h-5 w-5" />;
};
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 (
<div className="bg-white rounded-lg border border-gray-200 p-4 hover:shadow-md transition-shadow">
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center">
<div className="h-8 w-8 bg-primary-100 rounded-lg flex items-center justify-center mr-3">
{getIcon()}
</div>
<div>
<h4 className="font-medium text-gray-900">{item.name}</h4>
<p className="text-sm text-gray-500">
{isIndividual ? 'Lote de producción' : 'Envío a puntos de venta'}
</p>
</div>
</div>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(item.status)}`}>
{statusLabels[item.status as keyof typeof statusLabels]}
</span>
</div>
{/* Quantity */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center text-sm text-gray-600">
<Package className="h-4 w-4 mr-1" />
<span>Cantidad:</span>
</div>
<div className="flex items-center">
{onQuantityChange ? (
<input
type="number"
value={item.quantity}
onChange={(e) => 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"
/>
) : (
<span className="font-medium">{item.quantity}</span>
)}
<span className="ml-1 text-sm text-gray-500">
{isIndividual ? 'unidades' : 'cajas'}
</span>
</div>
</div>
{/* Additional Info for Bakery Type */}
{item.scheduledTime && (
<div className="flex items-center text-sm text-gray-600 mb-2">
<Clock className="h-4 w-4 mr-2" />
<span>
{isIndividual ? 'Hora de horneado:' : 'Hora de entrega:'} {item.scheduledTime}
</span>
</div>
)}
{item.location && isCentral && (
<div className="flex items-center text-sm text-gray-600 mb-2">
<MapPin className="h-4 w-4 mr-2" />
<span>Destino: {item.location}</span>
</div>
)}
{item.assignedTo && (
<div className="flex items-center text-sm text-gray-600 mb-3">
<Users className="h-4 w-4 mr-2" />
<span>
{isIndividual ? 'Panadero:' : 'Conductor:'} {item.assignedTo}
</span>
</div>
)}
{/* Actions */}
{onStatusChange && item.status !== 'completed' && (
<div className="flex space-x-2">
{item.status === 'pending' && (
<button
onClick={() => 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'}
</button>
)}
{item.status === 'in_progress' && (
<button
onClick={() => 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'}
</button>
)}
</div>
)}
</div>
);
};

View File

@@ -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<ProtectedRouteProps> = ({ 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 <Navigate to="/login" state={{ from: location }} replace />;
}
// 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 <Navigate to="/app/onboarding" replace />;
}
// If user completed onboarding but is on onboarding route, redirect to dashboard
if (user.isOnboardingComplete && isOnboardingRoute) {
return <Navigate to="/app/dashboard" replace />;
}
return <>{children}</>;
};
export default ProtectedRoute;

View File

@@ -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<RoleBasedAccessProps> = ({
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 }) => (
<RoleBasedAccess requiredRoles={['admin', 'owner']} fallback={fallback}>
{children}
</RoleBasedAccess>
);
export const ManagerAndUp: React.FC<{ children: React.ReactNode; fallback?: React.ReactNode }> = ({ children, fallback }) => (
<RoleBasedAccess requiredRoles={['manager', 'admin', 'owner']} fallback={fallback}>
{children}
</RoleBasedAccess>
);
export const OwnerOnly: React.FC<{ children: React.ReactNode; fallback?: React.ReactNode }> = ({ children, fallback }) => (
<RoleBasedAccess requiredRoles={['owner']} fallback={fallback}>
{children}
</RoleBasedAccess>
);
export default RoleBasedAccess;

View File

@@ -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<RoleBasedRouteProps> = ({
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 <Navigate to={fallbackPath} replace />;
}
}
// Check permission requirements
if (requiredPermissions.length > 0) {
const hasRequiredPermission = requiredPermissions.some(permission => hasPermission(permission));
if (!hasRequiredPermission) {
return <Navigate to={fallbackPath} replace />;
}
}
return <>{children}</>;
};
export default RoleBasedRoute;

View File

@@ -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 (
<div className="flex flex-col h-full">
<div className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<Breadcrumbs />
<SecondaryNavigation items={navigationItems} />
</div>
</div>
<div className="flex-1 bg-gray-50">
<div className="max-w-7xl mx-auto">
<Outlet />
</div>
</div>
</div>
);
};
export default AnalyticsLayout;

View File

@@ -0,0 +1,12 @@
import React from 'react';
import { Outlet } from 'react-router-dom';
const AuthLayout: React.FC = () => {
return (
<div className="min-h-screen bg-gray-50">
<Outlet />
</div>
);
};
export default AuthLayout;

View File

@@ -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<LayoutProps> = ({
children,
user,
currentPage,
onNavigate,
onLogout
}) => {
const Layout: React.FC<LayoutProps> = () => {
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<LayoutProps> = ({
{/* Desktop Navigation */}
<div className="hidden md:flex md:ml-10 md:space-x-1">
{navigation.map((item) => {
{filteredNavigation.map((item) => {
const Icon = item.icon;
const isActive = currentPage === item.id;
const isActive = isActiveRoute(item.href);
return (
<button
<Link
key={item.id}
onClick={() => 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<LayoutProps> = ({
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
}
`}
onClick={() => setIsMobileMenuOpen(false)}
>
<Icon className="h-4 w-4 mr-2" />
{item.label}
</button>
</Link>
);
})}
</div>
</div>
{/* Right side - Notifications and User Menu */}
{/* Right side - Tenant Selector, Notifications and User Menu */}
<div className="flex items-center space-x-4">
{/* Tenant Selector */}
<TenantSelector />
{/* Notifications */}
<button className="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors relative">
<Bell className="h-5 w-5" />
@@ -142,19 +165,17 @@ const Layout: React.FC<LayoutProps> = ({
<p className="text-sm font-medium text-gray-900">{user.fullName}</p>
<p className="text-sm text-gray-500">{user.email}</p>
</div>
<button
onClick={() => {
handleNavigate('settings');
setIsUserMenuOpen(false);
}}
<Link
to="/app/settings"
onClick={() => setIsUserMenuOpen(false)}
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 flex items-center"
>
<Settings className="h-4 w-4 mr-2" />
Configuración
</button>
</Link>
<button
onClick={() => {
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<LayoutProps> = ({
{isMobileMenuOpen && (
<div className="md:hidden border-t border-gray-200 bg-white">
<div className="px-2 pt-2 pb-3 space-y-1">
{navigation.map((item) => {
{filteredNavigation.map((item) => {
const Icon = item.icon;
const isActive = currentPage === item.id;
const isActive = isActiveRoute(item.href);
return (
<button
<Link
key={item.id}
onClick={() => 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<LayoutProps> = ({
>
<Icon className="h-5 w-5 mr-3" />
{item.label}
</button>
</Link>
);
})}
</div>
@@ -201,9 +223,7 @@ const Layout: React.FC<LayoutProps> = ({
{/* Main Content */}
<main className="flex-1">
<div className="max-w-7xl mx-auto">
{children}
</div>
<Outlet />
</main>
{/* Click outside handler for dropdowns */}

View File

@@ -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 (
<div className="flex flex-col h-full">
<div className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<Breadcrumbs />
<SecondaryNavigation items={getNavigationItems()} />
</div>
</div>
<div className="flex-1 bg-gray-50">
<div className="max-w-7xl mx-auto">
<Outlet />
</div>
</div>
</div>
);
};
export default OperationsLayout;

View File

@@ -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 (
<div className="flex flex-col h-full">
<div className="bg-white border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<Breadcrumbs />
<SecondaryNavigation items={getNavigationItems()} />
</div>
</div>
<div className="flex-1 bg-gray-50">
<div className="max-w-7xl mx-auto">
<Outlet />
</div>
</div>
</div>
);
};
export default SettingsLayout;

View File

@@ -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<string, string> = {
// 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 (
<nav className="flex items-center space-x-2 py-3 text-sm" aria-label="Breadcrumb">
<ol className="flex items-center space-x-2">
{breadcrumbs.map((breadcrumb, index) => (
<li key={index} className="flex items-center">
{index > 0 && (
<ChevronRight className="h-4 w-4 text-gray-400 mx-2" />
)}
{breadcrumb.href ? (
<Link
to={breadcrumb.href}
className="flex items-center text-gray-600 hover:text-gray-900 transition-colors"
>
{index === 0 && <Home className="h-4 w-4 mr-1" />}
{breadcrumb.label}
</Link>
) : (
<span className="flex items-center text-gray-900 font-medium">
{index === 0 && <Home className="h-4 w-4 mr-1" />}
{breadcrumb.label}
</span>
)}
</li>
))}
</ol>
</nav>
);
};

View File

@@ -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<SecondaryNavigationProps> = ({ items }) => {
const location = useLocation();
const [expandedItems, setExpandedItems] = useState<Set<string>>(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 (
<nav className="border-b border-gray-200">
<div className="flex space-x-8 overflow-x-auto">
{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 (
<div key={item.id} className="relative group">
<div className="flex items-center">
<Link
to={item.href}
className={`flex items-center px-4 py-4 text-sm font-medium border-b-2 transition-colors whitespace-nowrap ${
isItemActive || hasActiveChildItem
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-600 hover:text-gray-900 hover:border-gray-300'
}`}
>
<Icon className="h-4 w-4 mr-2" />
{item.label}
</Link>
{hasChildren && (
<button
onClick={() => toggleExpanded(item.id)}
className="ml-1 p-1 rounded hover:bg-gray-100 transition-colors"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-gray-500" />
) : (
<ChevronRight className="h-4 w-4 text-gray-500" />
)}
</button>
)}
</div>
{/* Dropdown for children */}
{hasChildren && isExpanded && (
<div className="absolute top-full left-0 mt-1 w-64 bg-white rounded-lg shadow-lg border border-gray-200 py-2 z-50">
{item.children!.map((child) => (
<Link
key={child.id}
to={child.href}
className={`block px-4 py-2 text-sm transition-colors ${
isActive(child.href)
? 'bg-primary-50 text-primary-700 border-r-2 border-primary-500'
: 'text-gray-700 hover:bg-gray-50'
}`}
>
{child.label}
</Link>
))}
</div>
)}
</div>
);
})}
</div>
</nav>
);
};

View File

@@ -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 (
<div className="relative">
<button
onClick={() => 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"
>
<Building className="h-4 w-4 text-gray-600 mr-2" />
<span className="text-gray-700 font-medium max-w-32 truncate">
{currentTenant?.name || 'Seleccionar panadería'}
</span>
<ChevronDown className="h-4 w-4 ml-1 text-gray-500" />
</button>
{isOpen && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-40"
onClick={() => setIsOpen(false)}
/>
{/* Dropdown */}
<div className="absolute right-0 mt-2 w-64 bg-white rounded-xl shadow-strong border border-gray-200 py-2 z-50">
<div className="px-4 py-2 border-b border-gray-100">
<p className="text-xs font-medium text-gray-500 uppercase tracking-wide">
Mis Panaderías
</p>
</div>
<div className="max-h-64 overflow-y-auto">
{tenants.map((tenant) => (
<button
key={tenant.id}
onClick={() => handleTenantChange(tenant)}
className="w-full text-left px-4 py-3 text-sm hover:bg-gray-50 flex items-center justify-between transition-colors"
>
<div className="flex items-center min-w-0">
<Building className="h-4 w-4 text-gray-400 mr-3 flex-shrink-0" />
<div className="min-w-0">
<p className="font-medium text-gray-900 truncate">
{tenant.name}
</p>
<p className="text-xs text-gray-500 truncate">
{tenant.address}
</p>
<div className="flex items-center mt-1">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
tenant.business_type === 'individual'
? 'bg-blue-100 text-blue-800'
: 'bg-purple-100 text-purple-800'
}`}>
{tenant.business_type === 'individual' ? 'Individual' : 'Obrador Central'}
</span>
</div>
</div>
</div>
{currentTenant?.id === tenant.id && (
<Check className="h-4 w-4 text-primary-600 flex-shrink-0" />
)}
</button>
))}
</div>
<div className="px-4 py-2 border-t border-gray-100">
<button
onClick={() => {
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
</button>
</div>
</div>
</>
)}
</div>
);
};

View File

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

View File

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

View File

@@ -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<UserRole, string[]> = {
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
};
};

View File

@@ -0,0 +1,17 @@
import React from 'react';
const AIInsightsPage: React.FC = () => {
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Insights de IA</h1>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-8 text-center">
<p className="text-gray-500">Insights de IA en desarrollo</p>
</div>
</div>
);
};
export default AIInsightsPage;

View File

@@ -0,0 +1,17 @@
import React from 'react';
const FinancialReportsPage: React.FC = () => {
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Reportes Financieros</h1>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-8 text-center">
<p className="text-gray-500">Reportes financieros en desarrollo</p>
</div>
</div>
);
};
export default FinancialReportsPage;

View File

@@ -0,0 +1,17 @@
import React from 'react';
const PerformanceKPIsPage: React.FC = () => {
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">KPIs de Rendimiento</h1>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-8 text-center">
<p className="text-gray-500">KPIs de rendimiento en desarrollo</p>
</div>
</div>
);
};
export default PerformanceKPIsPage;

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { useBakeryType } from '../../hooks/useBakeryType';
const ProductionReportsPage: React.FC = () => {
const { getProductionLabel } = useBakeryType();
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Reportes de {getProductionLabel()}</h1>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-8 text-center">
<p className="text-gray-500">Reportes de {getProductionLabel().toLowerCase()} en desarrollo</p>
</div>
</div>
);
};
export default ProductionReportsPage;

View File

@@ -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 (
<div className="p-6 max-w-7xl mx-auto">
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Análisis de Ventas</h1>
<p className="text-gray-600 mt-1">
{isIndividual
? 'Analiza el rendimiento de ventas de tu panadería'
: 'Analiza el rendimiento de ventas de todos tus puntos de venta'
}
</p>
</div>
{/* Time Range Selector */}
<div className="mb-6">
<div className="flex space-x-2">
{['day', 'week', 'month', 'quarter'].map((range) => (
<button
key={range}
onClick={() => 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'}
</button>
))}
</div>
</div>
{/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex items-center">
<div className="h-8 w-8 bg-green-100 rounded-lg flex items-center justify-center">
<DollarSign className="h-4 w-4 text-green-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Ingresos Totales</p>
<p className="text-2xl font-bold text-gray-900">2,847</p>
</div>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex items-center">
<div className="h-8 w-8 bg-blue-100 rounded-lg flex items-center justify-center">
<ShoppingCart className="h-4 w-4 text-blue-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">
{isIndividual ? 'Productos Vendidos' : 'Productos Distribuidos'}
</p>
<p className="text-2xl font-bold text-gray-900">1,429</p>
</div>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex items-center">
<div className="h-8 w-8 bg-purple-100 rounded-lg flex items-center justify-center">
<TrendingUp className="h-4 w-4 text-purple-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Crecimiento</p>
<p className="text-2xl font-bold text-gray-900">+12.5%</p>
</div>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex items-center">
<div className="h-8 w-8 bg-yellow-100 rounded-lg flex items-center justify-center">
<Calendar className="h-4 w-4 text-yellow-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Días Activos</p>
<p className="text-2xl font-bold text-gray-900">6/7</p>
</div>
</div>
</div>
</div>
{/* Charts placeholder */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Tendencia de Ventas
</h3>
<div className="h-64 bg-gray-50 rounded-lg flex items-center justify-center">
<p className="text-gray-500">Gráfico de tendencias aquí</p>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
{isIndividual ? 'Productos Más Vendidos' : 'Productos Más Distribuidos'}
</h3>
<div className="h-64 bg-gray-50 rounded-lg flex items-center justify-center">
<p className="text-gray-500">Gráfico de productos aquí</p>
</div>
</div>
</div>
</div>
);
};
export default SalesAnalyticsPage;

View File

@@ -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<LoginPageProps> = ({ onLogin, onNavigateToRegister }) => {
const LoginPage: React.FC<LoginPageProps> = () => {
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<LoginForm>({
email: '',
password: ''
@@ -70,7 +76,13 @@ const LoginPage: React.FC<LoginPageProps> = ({ 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<LoginPageProps> = ({ onLogin, onNavigateToRegister })
<div className="mt-6 text-center">
<p className="text-sm text-gray-600">
¿No tienes una cuenta?{' '}
<button
onClick={onNavigateToRegister}
<Link
to="/register"
className="font-medium text-primary-600 hover:text-primary-500 transition-colors"
>
Regístrate gratis
</button>
</Link>
</p>
</div>
</div>

View File

@@ -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<RegisterForm>({
fullName: '',
email: '',
password: '',
confirmPassword: '',
acceptTerms: false
});
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [errors, setErrors] = useState<Partial<RegisterForm>>({});
const validateForm = (): boolean => {
const newErrors: Partial<RegisterForm> = {};
if (!formData.fullName.trim()) {
newErrors.fullName = 'El nombre 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<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
// Clear error when user starts typing
if (errors[name as keyof RegisterForm]) {
setErrors(prev => ({
...prev,
[name]: undefined
}));
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-orange-100 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
{/* Logo and Header */}
<div className="text-center">
<div className="mx-auto h-20 w-20 bg-primary-500 rounded-2xl flex items-center justify-center mb-6 shadow-lg">
<span className="text-white text-2xl font-bold">🥖</span>
</div>
<h1 className="text-4xl font-bold text-gray-900 mb-2">
Únete a PanIA
</h1>
<p className="text-gray-600 text-lg">
Crea tu cuenta y comienza a optimizar tu panadería
</p>
</div>
{/* Registration Form */}
<div className="bg-white py-8 px-6 shadow-strong rounded-3xl">
<form className="space-y-6" onSubmit={handleSubmit}>
{/* Full Name Field */}
<div>
<label htmlFor="fullName" className="block text-sm font-medium text-gray-700 mb-2">
Nombre completo
</label>
<div className="relative">
<User className="absolute left-3 top-3 h-5 w-5 text-gray-400" />
<input
id="fullName"
name="fullName"
type="text"
required
value={formData.fullName}
onChange={handleInputChange}
className={`
appearance-none relative block w-full pl-10 pr-4 py-3 border rounded-xl
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
focus:ring-primary-500 focus:border-primary-500 focus:z-10
transition-all duration-200
${errors.fullName
? 'border-red-300 bg-red-50'
: 'border-gray-300 hover:border-gray-400'
}
`}
placeholder="Juan Pérez"
/>
</div>
{errors.fullName && (
<p className="mt-1 text-sm text-red-600">{errors.fullName}</p>
)}
</div>
{/* Email Field */}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
Correo electrónico
</label>
<div className="relative">
<Mail className="absolute left-3 top-3 h-5 w-5 text-gray-400" />
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={formData.email}
onChange={handleInputChange}
className={`
appearance-none relative block w-full pl-10 pr-4 py-3 border rounded-xl
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
focus:ring-primary-500 focus:border-primary-500 focus:z-10
transition-all duration-200
${errors.email
? 'border-red-300 bg-red-50'
: 'border-gray-300 hover:border-gray-400'
}
`}
placeholder="tu@panaderia.com"
/>
</div>
{errors.email && (
<p className="mt-1 text-sm text-red-600">{errors.email}</p>
)}
</div>
{/* Password Field */}
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
Contraseña
</label>
<div className="relative">
<Lock className="absolute left-3 top-3 h-5 w-5 text-gray-400" />
<input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
autoComplete="new-password"
required
value={formData.password}
onChange={handleInputChange}
className={`
appearance-none relative block w-full pl-10 pr-12 py-3 border rounded-xl
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
focus:ring-primary-500 focus:border-primary-500 focus:z-10
transition-all duration-200
${errors.password
? 'border-red-300 bg-red-50'
: 'border-gray-300 hover:border-gray-400'
}
`}
placeholder="••••••••"
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className="h-5 w-5 text-gray-400 hover:text-gray-600" />
) : (
<Eye className="h-5 w-5 text-gray-400 hover:text-gray-600" />
)}
</button>
</div>
{errors.password && (
<p className="mt-1 text-sm text-red-600">{errors.password}</p>
)}
</div>
{/* Confirm Password Field */}
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-2">
Confirmar contraseña
</label>
<div className="relative">
<Lock className="absolute left-3 top-3 h-5 w-5 text-gray-400" />
<input
id="confirmPassword"
name="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
autoComplete="new-password"
required
value={formData.confirmPassword}
onChange={handleInputChange}
className={`
appearance-none relative block w-full pl-10 pr-12 py-3 border rounded-xl
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
focus:ring-primary-500 focus:border-primary-500 focus:z-10
transition-all duration-200
${errors.confirmPassword
? 'border-red-300 bg-red-50'
: 'border-gray-300 hover:border-gray-400'
}
`}
placeholder="••••••••"
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
{showConfirmPassword ? (
<EyeOff className="h-5 w-5 text-gray-400 hover:text-gray-600" />
) : (
<Eye className="h-5 w-5 text-gray-400 hover:text-gray-600" />
)}
</button>
</div>
{errors.confirmPassword && (
<p className="mt-1 text-sm text-red-600">{errors.confirmPassword}</p>
)}
</div>
{/* Terms and Conditions */}
<div>
<div className="flex items-center">
<input
id="acceptTerms"
name="acceptTerms"
type="checkbox"
checked={formData.acceptTerms}
onChange={handleInputChange}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label htmlFor="acceptTerms" className="ml-2 block text-sm text-gray-700">
Acepto los{' '}
<a href="#" className="text-primary-600 hover:text-primary-500">
términos y condiciones
</a>{' '}
y la{' '}
<a href="#" className="text-primary-600 hover:text-primary-500">
política de privacidad
</a>
</label>
</div>
{errors.acceptTerms && (
<p className="mt-1 text-sm text-red-600">{errors.acceptTerms}</p>
)}
</div>
{/* Submit Button */}
<div>
<button
type="submit"
disabled={isLoading}
className={`
group relative w-full flex justify-center py-3 px-4 border border-transparent
text-sm font-medium rounded-xl text-white transition-all duration-200
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500
${isLoading
? 'bg-gray-400 cursor-not-allowed'
: 'bg-primary-500 hover:bg-primary-600 hover:shadow-lg transform hover:-translate-y-0.5'
}
`}
>
{isLoading ? (
<>
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
Creando cuenta...
</>
) : (
'Crear cuenta'
)}
</button>
</div>
</form>
{/* Login Link */}
<div className="mt-6 text-center">
<p className="text-sm text-gray-600">
¿Ya tienes una cuenta?{' '}
<Link
to="/login"
className="font-medium text-primary-600 hover:text-primary-500 transition-colors"
>
Inicia sesión
</Link>
</p>
</div>
</div>
{/* Features Preview */}
<div className="text-center">
<p className="text-xs text-gray-500 mb-4">
Prueba gratuita de 14 días No se requiere tarjeta de crédito
</p>
<div className="flex justify-center space-x-6 text-xs text-gray-400">
<div className="flex items-center">
<span className="w-2 h-2 bg-success-500 rounded-full mr-2"></span>
Setup en 5 minutos
</div>
<div className="flex items-center">
<span className="w-2 h-2 bg-success-500 rounded-full mr-2"></span>
Soporte incluido
</div>
<div className="flex items-center">
<span className="w-2 h-2 bg-success-500 rounded-full mr-2"></span>
Cancela cuando quieras
</div>
</div>
</div>
</div>
</div>
);
};
export default RegisterPage;

View File

@@ -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<LandingPageProps> = ({ onNavigateToLogin, onNavigateToRegister }) => {
const LandingPage: React.FC<LandingPageProps> = () => {
const [isVideoModalOpen, setIsVideoModalOpen] = useState(false);
const [currentTestimonial, setCurrentTestimonial] = useState(0);
@@ -120,18 +120,18 @@ const LandingPage: React.FC<LandingPageProps> = ({ onNavigateToLogin, onNavigate
</nav>
<div className="flex items-center space-x-4">
<button
onClick={onNavigateToLogin}
<Link
to="/login"
className="text-gray-600 hover:text-gray-900 transition-colors"
>
Iniciar sesión
</button>
<button
onClick={onNavigateToRegister}
</Link>
<Link
to="/register"
className="bg-primary-500 text-white px-6 py-2 rounded-lg hover:bg-primary-600 transition-all hover:shadow-lg"
>
Prueba gratis
</button>
</Link>
</div>
</div>
</div>
@@ -159,13 +159,13 @@ const LandingPage: React.FC<LandingPageProps> = ({ onNavigateToLogin, onNavigate
</p>
<div className="flex flex-col sm:flex-row gap-4 mb-8">
<button
onClick={onNavigateToRegister}
<Link
to="/register"
className="bg-primary-500 text-white px-8 py-4 rounded-xl font-semibold text-lg hover:bg-primary-600 transition-all hover:shadow-lg transform hover:-translate-y-1 flex items-center justify-center"
>
Comenzar gratis
<ArrowRight className="h-5 w-5 ml-2" />
</button>
</Link>
<button
onClick={() => setIsVideoModalOpen(true)}
@@ -419,18 +419,18 @@ const LandingPage: React.FC<LandingPageProps> = ({ onNavigateToLogin, onNavigate
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center mb-8">
<button
onClick={onNavigateToRegister}
<Link
to="/register"
className="bg-white text-primary-600 px-8 py-4 rounded-xl font-semibold text-lg hover:bg-gray-50 transition-all hover:shadow-lg transform hover:-translate-y-1"
>
Comenzar prueba gratuita
</button>
<button
onClick={onNavigateToLogin}
</Link>
<Link
to="/login"
className="border-2 border-white text-white px-8 py-4 rounded-xl font-semibold text-lg hover:bg-white hover:text-primary-600 transition-all"
>
Ya tengo cuenta
</button>
</Link>
</div>
<div className="flex flex-col sm:flex-row items-center justify-center gap-8 text-primary-100">
@@ -528,15 +528,13 @@ const LandingPage: React.FC<LandingPageProps> = ({ onNavigateToLogin, onNavigate
<p className="text-sm text-gray-500 mt-2">
Mientras tanto, puedes comenzar tu prueba gratuita
</p>
<button
onClick={() => {
setIsVideoModalOpen(false);
onNavigateToRegister();
}}
className="mt-4 bg-primary-500 text-white px-6 py-2 rounded-lg hover:bg-primary-600 transition-colors"
<Link
to="/register"
onClick={() => 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
</button>
</Link>
</div>
</div>
</div>

View File

@@ -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 (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-orange-100">
{/* Header */}
<header className="bg-white shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center py-6">
<div className="flex items-center">
<div className="h-8 w-8 bg-primary-500 rounded-lg flex items-center justify-center mr-3">
<span className="text-white text-sm font-bold">🥖</span>
</div>
<span className="text-xl font-bold text-gray-900">PanIA</span>
</div>
<div className="flex items-center space-x-4">
<Link
to="/login"
className="text-gray-600 hover:text-gray-900 transition-colors"
>
Iniciar sesión
</Link>
<Link
to="/register"
className="bg-primary-500 text-white px-6 py-2 rounded-lg hover:bg-primary-600 transition-all hover:shadow-lg"
>
Prueba gratis
</Link>
</div>
</div>
</div>
</header>
{/* Hero Section */}
<section className="py-20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center">
<div className="inline-flex items-center bg-primary-100 text-primary-800 px-4 py-2 rounded-full text-sm font-medium mb-6">
IA líder para panaderías en Madrid
</div>
<h1 className="text-5xl lg:text-6xl font-bold text-gray-900 mb-6 leading-tight">
La primera IA para
<span className="text-primary-600 block">tu panadería</span>
</h1>
<p className="text-xl text-gray-600 mb-8 leading-relaxed max-w-3xl mx-auto">
Transforma tus datos de ventas en predicciones precisas.
Reduce desperdicios, maximiza ganancias y optimiza tu producción
con inteligencia artificial diseñada para panaderías madrileñas.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center mb-16">
<Link
to="/register"
className="w-full sm:w-auto bg-primary-500 text-white px-8 py-4 rounded-xl font-semibold text-lg hover:bg-primary-600 transition-all hover:shadow-xl hover:-translate-y-1 flex items-center justify-center group"
>
Empezar Gratis
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
</Link>
<Link
to="/login"
className="w-full sm:w-auto border-2 border-primary-500 text-primary-500 px-8 py-4 rounded-xl font-semibold text-lg hover:bg-primary-50 transition-all"
>
Iniciar Sesión
</Link>
</div>
</div>
</div>
</section>
{/* Features Section */}
<section className="py-16 bg-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h2 className="text-3xl font-bold text-gray-900 mb-4">
Todo lo que necesitas para optimizar tu panadería
</h2>
<p className="text-xl text-gray-600">
Tecnología de vanguardia diseñada específicamente para panaderías
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
{[
{
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) => (
<div key={index} className="text-center">
<div className={`inline-flex h-16 w-16 items-center justify-center rounded-xl ${feature.color} mb-6`}>
<feature.icon className="h-8 w-8" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-3">{feature.title}</h3>
<p className="text-gray-600 leading-relaxed">{feature.description}</p>
</div>
))}
</div>
</div>
</section>
{/* CTA Section */}
<section className="bg-primary-500 py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 className="text-3xl font-bold text-white mb-4">
¿Listo para revolucionar tu panadería?
</h2>
<p className="text-xl text-primary-100 mb-8 max-w-2xl mx-auto">
Únete a más de 500 panaderías en Madrid que ya confían en PanIA para optimizar su negocio
</p>
<Link
to="/register"
className="inline-flex items-center bg-white text-primary-600 px-8 py-4 rounded-xl font-semibold text-lg hover:bg-gray-50 transition-all hover:shadow-xl hover:-translate-y-1"
>
Empezar Prueba Gratuita
<ArrowRight className="ml-2 h-5 w-5" />
</Link>
</div>
</section>
{/* Footer */}
<footer className="bg-gray-900 text-white py-12">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center">
<div className="flex items-center justify-center mb-4">
<div className="h-8 w-8 bg-primary-500 rounded-lg flex items-center justify-center mr-3">
<span className="text-white text-sm font-bold">🥖</span>
</div>
<span className="text-xl font-bold">PanIA</span>
</div>
<p className="text-gray-400 mb-4">
Inteligencia Artificial para panaderías madrileñas
</p>
<p className="text-sm text-gray-500">
© 2024 PanIA. Todos los derechos reservados.
</p>
</div>
</div>
</footer>
</div>
);
};
export default LandingPage;

View File

@@ -60,6 +60,62 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ 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<BakeryData>({
name: '',
address: '',
@@ -179,12 +235,14 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ 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<OnboardingPageProps> = ({ 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<OnboardingPageProps> = ({ 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<OnboardingPageProps> = ({ 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<OnboardingPageProps> = ({ 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<OnboardingPageProps> = ({ 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<OnboardingPageProps> = ({ 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,

View File

@@ -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<UserProfile>({
fullName: user?.fullName || '',
email: user?.email || '',
phone: ''
});
const [passwordForm, setPasswordForm] = useState<PasswordChange>({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
const [activeSessions] = useState<Session[]>([
{
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 (
<div className="p-6 max-w-4xl mx-auto">
<div className="space-y-8">
{/* Profile Information */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-6">Información Personal</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<User className="inline h-4 w-4 mr-1" />
Nombre completo
</label>
<input
type="text"
value={profile.fullName}
onChange={(e) => setProfile(prev => ({ ...prev, fullName: e.target.value }))}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<Mail className="inline h-4 w-4 mr-1" />
Correo electrónico
</label>
<input
type="email"
value={profile.email}
onChange={(e) => setProfile(prev => ({ ...prev, email: e.target.value }))}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<Phone className="inline h-4 w-4 mr-1" />
Teléfono
</label>
<input
type="tel"
value={profile.phone}
onChange={(e) => 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"
/>
</div>
</div>
<div className="flex justify-end mt-6">
<button
onClick={handleUpdateProfile}
disabled={isLoading}
className="inline-flex items-center px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50"
>
<Save className="h-4 w-4 mr-2" />
Guardar Cambios
</button>
</div>
</div>
{/* Security Settings */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-6">
<Shield className="inline h-5 w-5 mr-2" />
Seguridad
</h3>
{/* Change Password */}
<div className="space-y-4 mb-8">
<h4 className="font-medium text-gray-900">Cambiar Contraseña</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Contraseña actual
</label>
<input
type="password"
value={passwordForm.currentPassword}
onChange={(e) => setPasswordForm(prev => ({ ...prev, currentPassword: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="••••••••"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Nueva contraseña
</label>
<input
type="password"
value={passwordForm.newPassword}
onChange={(e) => setPasswordForm(prev => ({ ...prev, newPassword: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="••••••••"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Confirmar contraseña
</label>
<input
type="password"
value={passwordForm.confirmPassword}
onChange={(e) => setPasswordForm(prev => ({ ...prev, confirmPassword: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="••••••••"
/>
</div>
</div>
<button
onClick={handleChangePassword}
disabled={isLoading || !passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Actualizar Contraseña
</button>
</div>
{/* Active Sessions */}
<div className="border-t pt-6">
<h4 className="font-medium text-gray-900 mb-4">Sesiones Activas</h4>
<div className="space-y-3">
{activeSessions.map((session) => (
<div key={session.id} className="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
<div className="flex items-center">
<div className="h-10 w-10 bg-gray-100 rounded-full flex items-center justify-center mr-3">
<Shield className="h-5 w-5 text-gray-600" />
</div>
<div>
<div className="font-medium text-gray-900">{session.device}</div>
<div className="text-sm text-gray-500">{session.location}</div>
<div className="flex items-center text-xs mt-1">
{session.isCurrent ? (
<>
<CheckCircle className="h-3 w-3 text-green-600 mr-1" />
<span className="text-green-600">Sesión actual</span>
</>
) : (
<>
<Clock className="h-3 w-3 text-gray-400 mr-1" />
<span className="text-gray-500">
Último acceso: {new Date(session.lastActive).toLocaleDateString()}
</span>
</>
)}
</div>
</div>
</div>
{!session.isCurrent && (
<button
onClick={() => handleTerminateSession(session.id)}
className="text-red-600 hover:text-red-700 text-sm font-medium"
>
Cerrar sesión
</button>
)}
</div>
))}
</div>
</div>
</div>
{/* Danger Zone */}
<div className="bg-white rounded-xl shadow-sm border border-red-200 p-6">
<h3 className="text-lg font-semibold text-red-600 mb-4">
<AlertCircle className="inline h-5 w-5 mr-2" />
Zona Peligrosa
</h3>
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<h4 className="font-medium text-red-900 mb-2">Eliminar Cuenta</h4>
<p className="text-red-800 text-sm mb-4">
Esta acción eliminará permanentemente tu cuenta y todos los datos asociados.
No se puede deshacer.
</p>
<button
onClick={handleDeleteAccount}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm"
>
Eliminar Cuenta
</button>
</div>
</div>
</div>
</div>
);
};
export default AccountSettingsPage;

View File

@@ -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<any>(null);
const [formData, setFormData] = useState<BakeryFormData>({
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<any>({});
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 (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Cargando panaderías...</p>
</div>
</div>
);
}
return (
<div className="p-6 max-w-6xl mx-auto">
{/* Header */}
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">Gestión de Panaderías</h1>
<p className="text-gray-600 mt-1">
Administra todas tus panaderías y puntos de venta
</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
>
<Plus className="h-4 w-4 mr-2" />
Nueva Panadería
</button>
</div>
{/* Bakeries Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{tenants.map((tenant) => {
const typeInfo = getBakeryTypeInfo(tenant.business_type);
const stats = tenantStats[tenant.id];
const isActive = currentTenant?.id === tenant.id;
return (
<div
key={tenant.id}
className={`bg-white rounded-xl shadow-sm border-2 p-6 transition-all hover:shadow-md ${
isActive ? 'border-primary-500 ring-2 ring-primary-100' : 'border-gray-200'
}`}
>
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<div className="flex items-center">
<Building className="h-5 w-5 text-gray-600 mr-2" />
<h3 className="text-lg font-semibold text-gray-900 truncate">
{tenant.name}
</h3>
{isActive && (
<span className="ml-2 px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">
Activa
</span>
)}
</div>
<span className={`inline-flex items-center px-2 py-1 rounded text-xs font-medium mt-2 ${typeInfo.color}`}>
{typeInfo.label}
</span>
</div>
<div className="relative">
<button className="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100">
<MoreVertical className="h-4 w-4" />
</button>
</div>
</div>
{/* Address */}
<div className="flex items-center text-gray-600 mb-4">
<MapPin className="h-4 w-4 mr-2 flex-shrink-0" />
<span className="text-sm truncate">{tenant.address}</span>
</div>
{/* Operating Hours */}
{tenant.settings?.operating_hours && (
<div className="flex items-center text-gray-600 mb-4">
<Clock className="h-4 w-4 mr-2 flex-shrink-0" />
<span className="text-sm">
{tenant.settings.operating_hours.open} - {tenant.settings.operating_hours.close}
</span>
</div>
)}
{/* Stats */}
{stats && (
<div className="grid grid-cols-2 gap-4 mb-4 pt-4 border-t border-gray-100">
<div className="text-center">
<div className="text-2xl font-bold text-gray-900">
{stats.total_sales || 0}
</div>
<div className="text-xs text-gray-500">Ventas (mes)</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-gray-900">
{stats.active_users || 0}
</div>
<div className="text-xs text-gray-500">Usuarios</div>
</div>
</div>
)}
{/* Actions */}
<div className="flex space-x-2">
{!isActive && (
<button
onClick={() => 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
</button>
)}
<button
onClick={() => 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
</button>
</div>
</div>
);
})}
</div>
{/* Create/Edit Modal */}
{(showCreateModal || editingTenant) && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-2xl mx-4 max-h-screen overflow-y-auto">
<h3 className="text-lg font-medium text-gray-900 mb-6">
{editingTenant ? 'Editar Panadería' : 'Nueva Panadería'}
</h3>
<div className="space-y-6">
{/* Basic Info */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Nombre
</label>
<input
type="text"
value={formData.name}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Tipo de negocio
</label>
<select
value={formData.business_type}
onChange={(e) => 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"
>
<option value="individual">Panadería Individual</option>
<option value="central_workshop">Obrador Central</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Dirección
</label>
<input
type="text"
value={formData.address}
onChange={(e) => 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"
/>
</div>
{/* Operating Hours */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Horarios de operación
</label>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs text-gray-500 mb-1">Apertura</label>
<input
type="time"
value={formData.settings?.operating_hours?.open || '07:00'}
onChange={(e) => setFormData(prev => ({
...prev,
settings: {
...prev.settings,
operating_hours: {
...prev.settings?.operating_hours,
open: e.target.value
}
}
}))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Cierre</label>
<input
type="time"
value={formData.settings?.operating_hours?.close || '20:00'}
onChange={(e) => setFormData(prev => ({
...prev,
settings: {
...prev.settings,
operating_hours: {
...prev.settings?.operating_hours,
close: e.target.value
}
}
}))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
</div>
</div>
{/* Products */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Productos
</label>
<div className="flex flex-wrap gap-2">
{formData.products.map((product, index) => (
<span
key={index}
className="inline-flex items-center px-3 py-1 rounded-full text-sm bg-primary-100 text-primary-800"
>
{product}
</span>
))}
</div>
</div>
</div>
<div className="flex justify-end space-x-3 mt-8">
<button
onClick={() => {
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
</button>
<button
onClick={editingTenant ? handleUpdateBakery : handleCreateBakery}
disabled={!formData.name || !formData.address}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{editingTenant ? 'Actualizar' : 'Crear'} Panadería
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default BakeriesManagementPage;

View File

@@ -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<GeneralSettings>({
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<NotificationSettings>({
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 (
<div className="p-6 max-w-4xl mx-auto">
<div className="space-y-8">
{/* Business Information */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-6">Información del Negocio</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Nombre de la panadería
</label>
<input
type="text"
value={settings.bakeryName}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Tipo de negocio
</label>
<select
value={settings.businessType}
onChange={(e) => 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"
>
<option value="individual">Panadería Individual</option>
<option value="central_workshop">Obrador Central</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Dirección
</label>
<div className="relative">
<MapPin className="absolute left-3 top-3 h-5 w-5 text-gray-400" />
<input
type="text"
value={settings.bakeryAddress}
onChange={(e) => 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"
/>
</div>
</div>
</div>
</div>
{/* Operating Hours */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-6">Horarios de Operación</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Hora de apertura
</label>
<input
type="time"
value={settings.operatingHours.open}
onChange={(e) => setSettings(prev => ({
...prev,
operatingHours: { ...prev.operatingHours, open: e.target.value }
}))}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Hora de cierre
</label>
<input
type="time"
value={settings.operatingHours.close}
onChange={(e) => setSettings(prev => ({
...prev,
operatingHours: { ...prev.operatingHours, close: e.target.value }
}))}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
Días de operación
</label>
<div className="grid grid-cols-4 gap-2 sm:grid-cols-7">
{dayLabels.map((day, index) => (
<label key={day} className="flex items-center justify-center">
<input
type="checkbox"
checked={settings.operatingDays.includes(index + 1)}
onChange={(e) => {
const dayNum = index + 1;
setSettings(prev => ({
...prev,
operatingDays: e.target.checked
? [...prev.operatingDays, dayNum]
: prev.operatingDays.filter(d => d !== dayNum)
}));
}}
className="sr-only peer"
/>
<div className="w-10 h-10 bg-gray-200 peer-checked:bg-primary-500 peer-checked:text-white rounded-lg flex items-center justify-center font-medium text-sm cursor-pointer transition-colors">
{day}
</div>
</label>
))}
</div>
</div>
</div>
</div>
{/* Regional Settings */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-6">Configuración Regional</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<Globe className="inline h-4 w-4 mr-1" />
Idioma
</label>
<select
value={settings.language}
onChange={(e) => 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"
>
<option value="es">Español</option>
<option value="en">English</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<Clock className="inline h-4 w-4 mr-1" />
Zona horaria
</label>
<select
value={settings.timezone}
onChange={(e) => 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"
>
<option value="Europe/Madrid">Europa/Madrid (CET)</option>
<option value="Europe/London">Europa/Londres (GMT)</option>
<option value="America/New_York">América/Nueva York (EST)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<DollarSign className="inline h-4 w-4 mr-1" />
Moneda
</label>
<select
value={settings.currency}
onChange={(e) => 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"
>
<option value="EUR">Euro ()</option>
<option value="USD">Dólar americano ($)</option>
<option value="GBP">Libra esterlina (£)</option>
</select>
</div>
</div>
</div>
{/* Notification Settings */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-6">Notificaciones</h3>
{/* Notification Channels */}
<div className="space-y-4 mb-6">
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div className="flex items-center">
<Mail className="h-5 w-5 text-gray-600 mr-3" />
<div>
<div className="font-medium text-gray-900">Notificaciones por Email</div>
<div className="text-sm text-gray-500">Recibe alertas y reportes por correo</div>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={notifications.emailNotifications}
onChange={(e) => setNotifications(prev => ({
...prev,
emailNotifications: e.target.checked
}))}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-600"></div>
</label>
</div>
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div className="flex items-center">
<Smartphone className="h-5 w-5 text-gray-600 mr-3" />
<div>
<div className="font-medium text-gray-900">Notificaciones SMS</div>
<div className="text-sm text-gray-500">Alertas urgentes por mensaje de texto</div>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={notifications.smsNotifications}
onChange={(e) => setNotifications(prev => ({
...prev,
smsNotifications: e.target.checked
}))}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-600"></div>
</label>
</div>
</div>
{/* Notification Types */}
<div className="space-y-3">
<h4 className="font-medium text-gray-900">Tipos de Notificación</h4>
{[
{ key: 'dailyReports', label: 'Reportes Diarios', desc: 'Resumen diario de ventas y predicciones' },
{ key: 'weeklyReports', label: 'Reportes Semanales', desc: 'Análisis semanal de rendimiento' },
{ key: 'forecastAlerts', label: 'Alertas de Predicción', desc: 'Cambios significativos en demanda' },
{ key: 'stockAlerts', label: 'Alertas de Stock', desc: 'Inventario bajo o próximos vencimientos' },
{ key: 'orderReminders', label: 'Recordatorios de Pedidos', desc: 'Próximas entregas y fechas límite' }
].map((item) => (
<div key={item.key} className="flex items-center justify-between p-3 border border-gray-200 rounded-lg">
<div>
<div className="font-medium text-gray-900">{item.label}</div>
<div className="text-sm text-gray-500">{item.desc}</div>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={notifications[item.key as keyof NotificationSettings] as boolean}
onChange={(e) => setNotifications(prev => ({
...prev,
[item.key]: e.target.checked
}))}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-600"></div>
</label>
</div>
))}
</div>
</div>
{/* Data Export */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-6">Exportar Datos</h3>
<div className="space-y-3">
<button className="w-full p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all text-left">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-gray-900">Exportar todas las predicciones</div>
<div className="text-sm text-gray-500">Descargar historial completo en CSV</div>
</div>
<ChevronRight className="h-5 w-5 text-gray-400" />
</div>
</button>
<button className="w-full p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all text-left">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-gray-900">Exportar datos de ventas</div>
<div className="text-sm text-gray-500">Historial de ventas y análisis</div>
</div>
<ChevronRight className="h-5 w-5 text-gray-400" />
</div>
</button>
<button className="w-full p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all text-left">
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-gray-900">Exportar configuración</div>
<div className="text-sm text-gray-500">Respaldo de toda la configuración</div>
</div>
<ChevronRight className="h-5 w-5 text-gray-400" />
</div>
</button>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end">
<button
onClick={handleSaveSettings}
disabled={isLoading}
className="inline-flex items-center px-6 py-3 bg-primary-500 text-white rounded-xl hover:bg-primary-600 transition-all hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Guardando...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Guardar Cambios
</>
)}
</button>
</div>
</div>
</div>
);
};
export default GeneralSettingsPage;

View File

@@ -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<UserMember | null>(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 (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Cargando usuarios...</p>
</div>
</div>
);
}
return (
<div className="p-6 max-w-6xl mx-auto">
{/* Header */}
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">Gestión de Usuarios</h1>
<p className="text-gray-600 mt-1">
Administra los miembros de tu equipo en {currentTenant?.name}
</p>
</div>
<button
onClick={() => setShowInviteModal(true)}
className="inline-flex items-center px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
>
<UserPlus className="h-4 w-4 mr-2" />
Invitar Usuario
</button>
</div>
{/* Users List */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">
Miembros del Equipo ({members.length})
</h3>
</div>
<div className="divide-y divide-gray-200">
{members.map((member) => {
const roleInfo = getRoleInfo(member.role);
const statusInfo = getStatusInfo(member.status);
const RoleIcon = roleInfo.icon;
const StatusIcon = statusInfo.icon;
return (
<div key={member.id} className="px-6 py-4 flex items-center justify-between">
<div className="flex items-center flex-1 min-w-0">
{/* Avatar */}
<div className="h-10 w-10 bg-primary-100 rounded-full flex items-center justify-center">
<User className="h-5 w-5 text-primary-600" />
</div>
{/* User Info */}
<div className="ml-4 flex-1 min-w-0">
<div className="flex items-center">
<h4 className="text-sm font-medium text-gray-900 truncate">
{member.user.full_name}
</h4>
{member.user_id === currentUser?.id && (
<span className="ml-2 text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">
</span>
)}
</div>
<p className="text-sm text-gray-500 truncate">
{member.user.email}
</p>
<div className="flex items-center mt-1 space-x-4">
<div className="flex items-center">
<StatusIcon className={`h-3 w-3 mr-1 ${statusInfo.color}`} />
<span className={`text-xs ${statusInfo.color}`}>
{statusInfo.label}
</span>
</div>
{member.user.last_active && (
<span className="text-xs text-gray-400">
Último acceso: {new Date(member.user.last_active).toLocaleDateString()}
</span>
)}
</div>
</div>
</div>
{/* Role Badge */}
<div className="flex items-center ml-4">
<div className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${roleInfo.color}`}>
<RoleIcon className="h-3 w-3 mr-1" />
{roleInfo.label}
</div>
</div>
{/* Actions */}
{canManageUser(member) && (
<div className="flex items-center ml-4">
<div className="relative">
<button className="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100">
<MoreVertical className="h-4 w-4" />
</button>
{/* Dropdown would go here */}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
{/* Invite User Modal */}
{showInviteModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-medium text-gray-900 mb-4">
Invitar Nuevo Usuario
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Correo electrónico
</label>
<input
type="email"
value={inviteForm.email}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Rol
</label>
<select
value={inviteForm.role}
onChange={(e) => 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"
>
<option value="worker">Empleado</option>
<option value="manager">Gerente</option>
{currentUser?.role === 'owner' && <option value="admin">Administrador</option>}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Mensaje personal (opcional)
</label>
<textarea
value={inviteForm.message}
onChange={(e) => setInviteForm(prev => ({ ...prev, message: e.target.value }))}
placeholder="Mensaje de bienvenida..."
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button
onClick={() => setShowInviteModal(false)}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
>
Cancelar
</button>
<button
onClick={handleInviteUser}
disabled={!inviteForm.email}
className="inline-flex items-center px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Send className="h-4 w-4 mr-2" />
Enviar Invitación
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default UsersManagementPage;

View File

@@ -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: <AuthLayout />,
children: [
{
index: true,
element: <LandingPage />
},
{
path: 'login',
element: <LoginPage />
},
{
path: 'register',
element: <RegisterPage />
}
]
},
// Protected Routes
{
path: '/app',
element: (
<ProtectedRoute>
<Layout />
</ProtectedRoute>
),
children: [
// Dashboard
{
index: true,
element: <Navigate to="/app/dashboard" replace />
},
{
path: 'dashboard',
element: <DashboardPage />
},
// Onboarding (conditional)
{
path: 'onboarding',
element: <OnboardingPage />
},
// Operations Hub
{
path: 'operations',
element: <OperationsLayout />,
children: [
{
index: true,
element: <Navigate to="/app/operations/production" replace />
},
{
path: 'production',
children: [
{
index: true,
element: <ProductionPage />
},
{
path: 'schedule',
element: <ProductionPage view="schedule" />
},
{
path: 'active-batches',
element: <ProductionPage view="active-batches" />
},
{
path: 'equipment',
element: <ProductionPage view="equipment" />
}
]
},
{
path: 'orders',
children: [
{
index: true,
element: <OrdersPage />
},
{
path: 'incoming',
element: <OrdersPage view="incoming" />
},
{
path: 'in-progress',
element: <OrdersPage view="in-progress" />
},
{
path: 'supplier-orders',
element: <OrdersPage view="supplier-orders" />
}
]
},
{
path: 'inventory',
children: [
{
index: true,
element: <InventoryPage />
},
{
path: 'stock-levels',
element: <InventoryPage view="stock-levels" />
},
{
path: 'movements',
element: <InventoryPage view="movements" />
},
{
path: 'alerts',
element: <InventoryPage view="alerts" />
}
]
},
{
path: 'sales',
children: [
{
index: true,
element: <SalesPage />
},
{
path: 'daily-sales',
element: <SalesPage view="daily-sales" />
},
{
path: 'customer-orders',
element: <SalesPage view="customer-orders" />
},
{
path: 'pos-integration',
element: <SalesPage view="pos-integration" />
}
]
},
{
path: 'recipes',
children: [
{
index: true,
element: <RecipesPage />
},
{
path: 'active-recipes',
element: <RecipesPage view="active-recipes" />
},
{
path: 'development',
element: (
<RoleBasedRoute requiredRoles={['admin', 'manager']}>
<RecipesPage view="development" />
</RoleBasedRoute>
)
},
{
path: 'costing',
element: (
<RoleBasedRoute requiredRoles={['admin', 'manager']}>
<RecipesPage view="costing" />
</RoleBasedRoute>
)
}
]
}
]
},
// Analytics Hub (Admin/Manager only)
{
path: 'analytics',
element: (
<RoleBasedRoute requiredRoles={['admin', 'manager']}>
<AnalyticsLayout />
</RoleBasedRoute>
),
children: [
{
index: true,
element: <Navigate to="/app/analytics/forecasting" replace />
},
{
path: 'forecasting',
element: <ForecastPage />
},
{
path: 'sales-analytics',
element: <SalesAnalyticsPage />
},
{
path: 'production-reports',
element: <ProductionReportsPage />
},
{
path: 'financial-reports',
element: <FinancialReportsPage />
},
{
path: 'performance-kpis',
element: <PerformanceKPIsPage />
},
{
path: 'ai-insights',
element: <AIInsightsPage />
}
]
},
// Settings Hub
{
path: 'settings',
element: <SettingsLayout />,
children: [
{
index: true,
element: <Navigate to="/app/settings/general" replace />
},
{
path: 'general',
element: <GeneralSettingsPage />
},
{
path: 'users',
element: (
<RoleBasedRoute requiredRoles={['admin']}>
<UsersManagementPage />
</RoleBasedRoute>
)
},
{
path: 'bakeries',
element: (
<RoleBasedRoute requiredRoles={['admin']}>
<BakeriesManagementPage />
</RoleBasedRoute>
)
},
{
path: 'account',
element: <AccountSettingsPage />
}
]
}
]
},
// Catch all route
{
path: '*',
element: <Navigate to="/" replace />
}
]);

View File

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