New Frontend
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
|
||||
205
frontend/src/components/adaptive/AdaptiveInventoryWidget.tsx
Normal file
205
frontend/src/components/adaptive/AdaptiveInventoryWidget.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
158
frontend/src/components/adaptive/AdaptiveProductionCard.tsx
Normal file
158
frontend/src/components/adaptive/AdaptiveProductionCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
36
frontend/src/components/auth/ProtectedRoute.tsx
Normal file
36
frontend/src/components/auth/ProtectedRoute.tsx
Normal 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;
|
||||
58
frontend/src/components/auth/RoleBasedAccess.tsx
Normal file
58
frontend/src/components/auth/RoleBasedAccess.tsx
Normal 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;
|
||||
42
frontend/src/components/auth/RoleBasedRoute.tsx
Normal file
42
frontend/src/components/auth/RoleBasedRoute.tsx
Normal 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;
|
||||
67
frontend/src/components/layout/AnalyticsLayout.tsx
Normal file
67
frontend/src/components/layout/AnalyticsLayout.tsx
Normal 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;
|
||||
12
frontend/src/components/layout/AuthLayout.tsx
Normal file
12
frontend/src/components/layout/AuthLayout.tsx
Normal 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;
|
||||
@@ -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 */}
|
||||
|
||||
99
frontend/src/components/layout/OperationsLayout.tsx
Normal file
99
frontend/src/components/layout/OperationsLayout.tsx
Normal 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;
|
||||
65
frontend/src/components/layout/SettingsLayout.tsx
Normal file
65
frontend/src/components/layout/SettingsLayout.tsx
Normal 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;
|
||||
123
frontend/src/components/navigation/Breadcrumbs.tsx
Normal file
123
frontend/src/components/navigation/Breadcrumbs.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
126
frontend/src/components/navigation/SecondaryNavigation.tsx
Normal file
126
frontend/src/components/navigation/SecondaryNavigation.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
128
frontend/src/components/navigation/TenantSelector.tsx
Normal file
128
frontend/src/components/navigation/TenantSelector.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
64
frontend/src/hooks/useAuth.ts
Normal file
64
frontend/src/hooks/useAuth.ts
Normal 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
|
||||
};
|
||||
};
|
||||
56
frontend/src/hooks/useBakeryType.ts
Normal file
56
frontend/src/hooks/useBakeryType.ts
Normal 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
|
||||
};
|
||||
};
|
||||
112
frontend/src/hooks/usePermissions.ts
Normal file
112
frontend/src/hooks/usePermissions.ts
Normal 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
|
||||
};
|
||||
};
|
||||
17
frontend/src/pages/analytics/AIInsightsPage.tsx
Normal file
17
frontend/src/pages/analytics/AIInsightsPage.tsx
Normal 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;
|
||||
17
frontend/src/pages/analytics/FinancialReportsPage.tsx
Normal file
17
frontend/src/pages/analytics/FinancialReportsPage.tsx
Normal 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;
|
||||
17
frontend/src/pages/analytics/PerformanceKPIsPage.tsx
Normal file
17
frontend/src/pages/analytics/PerformanceKPIsPage.tsx
Normal 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;
|
||||
20
frontend/src/pages/analytics/ProductionReportsPage.tsx
Normal file
20
frontend/src/pages/analytics/ProductionReportsPage.tsx
Normal 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;
|
||||
120
frontend/src/pages/analytics/SalesAnalyticsPage.tsx
Normal file
120
frontend/src/pages/analytics/SalesAnalyticsPage.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
421
frontend/src/pages/auth/SimpleRegisterPage.tsx
Normal file
421
frontend/src/pages/auth/SimpleRegisterPage.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
168
frontend/src/pages/landing/SimpleLandingPage.tsx
Normal file
168
frontend/src/pages/landing/SimpleLandingPage.tsx
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
338
frontend/src/pages/settings/AccountSettingsPage.tsx
Normal file
338
frontend/src/pages/settings/AccountSettingsPage.tsx
Normal 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;
|
||||
421
frontend/src/pages/settings/BakeriesManagementPage.tsx
Normal file
421
frontend/src/pages/settings/BakeriesManagementPage.tsx
Normal 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;
|
||||
402
frontend/src/pages/settings/GeneralSettingsPage.tsx
Normal file
402
frontend/src/pages/settings/GeneralSettingsPage.tsx
Normal 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;
|
||||
326
frontend/src/pages/settings/UsersManagementPage.tsx
Normal file
326
frontend/src/pages/settings/UsersManagementPage.tsx
Normal 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">
|
||||
Tú
|
||||
</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;
|
||||
302
frontend/src/router/index.tsx
Normal file
302
frontend/src/router/index.tsx
Normal 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 />
|
||||
}
|
||||
]);
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user