New Frontend
This commit is contained in:
@@ -1,30 +1,11 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { Toaster } from 'react-hot-toast';
|
import { RouterProvider } from 'react-router-dom';
|
||||||
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 { Provider } from 'react-redux';
|
import { Provider } from 'react-redux';
|
||||||
|
import { Toaster } from 'react-hot-toast';
|
||||||
// Onboarding utilities
|
import { router } from './router';
|
||||||
import { OnboardingRouter, type NextAction, type RoutingDecision } from './utils/onboardingRouter';
|
import { store } from './store';
|
||||||
|
import ErrorBoundary from './components/ErrorBoundary';
|
||||||
|
import { useAuth } from './hooks/useAuth';
|
||||||
|
|
||||||
// i18n
|
// i18n
|
||||||
import './i18n';
|
import './i18n';
|
||||||
@@ -32,334 +13,53 @@ import './i18n';
|
|||||||
// Global styles
|
// Global styles
|
||||||
import './styles/globals.css';
|
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(() => {
|
useEffect(() => {
|
||||||
const initializeApp = async () => {
|
initializeAuth();
|
||||||
try {
|
}, [initializeAuth]);
|
||||||
// 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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Provider store={store}>
|
<ErrorBoundary>
|
||||||
<ErrorBoundary>
|
<div className="App min-h-screen bg-gray-50">
|
||||||
<div className="App min-h-screen bg-gray-50">
|
<RouterProvider router={router} />
|
||||||
{renderCurrentPage()}
|
|
||||||
|
|
||||||
{/* Global Toast Notifications */}
|
{/* Global Toast Notifications */}
|
||||||
<Toaster
|
<Toaster
|
||||||
position="top-right"
|
position="top-right"
|
||||||
toastOptions={{
|
toastOptions={{
|
||||||
duration: 4000,
|
duration: 4000,
|
||||||
style: {
|
style: {
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
color: '#333',
|
color: '#333',
|
||||||
boxShadow: '0 4px 25px -5px rgba(0, 0, 0, 0.1)',
|
boxShadow: '0 4px 25px -5px rgba(0, 0, 0, 0.1)',
|
||||||
borderRadius: '12px',
|
borderRadius: '12px',
|
||||||
padding: '16px',
|
padding: '16px',
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
iconTheme: {
|
||||||
|
primary: '#22c55e',
|
||||||
|
secondary: '#fff',
|
||||||
},
|
},
|
||||||
success: {
|
},
|
||||||
iconTheme: {
|
error: {
|
||||||
primary: '#22c55e',
|
iconTheme: {
|
||||||
secondary: '#fff',
|
primary: '#ef4444',
|
||||||
},
|
secondary: '#fff',
|
||||||
},
|
},
|
||||||
error: {
|
},
|
||||||
iconTheme: {
|
}}
|
||||||
primary: '#ef4444',
|
/>
|
||||||
secondary: '#fff',
|
</div>
|
||||||
},
|
</ErrorBoundary>
|
||||||
},
|
);
|
||||||
}}
|
};
|
||||||
/>
|
|
||||||
</div>
|
const App: React.FC = () => {
|
||||||
</ErrorBoundary>
|
return (
|
||||||
|
<Provider store={store}>
|
||||||
|
<AppContent />
|
||||||
</Provider>
|
</Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ interface UseTrainingOptions {
|
|||||||
export const useTraining = (options: UseTrainingOptions = {}) => {
|
export const useTraining = (options: UseTrainingOptions = {}) => {
|
||||||
|
|
||||||
const { disablePolling = false } = options;
|
const { disablePolling = false } = options;
|
||||||
|
|
||||||
|
// Debug logging for option changes
|
||||||
|
console.log('🔧 useTraining initialized with options:', { disablePolling, options });
|
||||||
const [jobs, setJobs] = useState<TrainingJobResponse[]>([]);
|
const [jobs, setJobs] = useState<TrainingJobResponse[]>([]);
|
||||||
const [currentJob, setCurrentJob] = useState<TrainingJobResponse | null>(null);
|
const [currentJob, setCurrentJob] = useState<TrainingJobResponse | null>(null);
|
||||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||||
@@ -193,22 +196,41 @@ export const useTraining = (options: UseTrainingOptions = {}) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Skip polling if disabled or no running jobs
|
// Always check disablePolling first and log for debugging
|
||||||
if (disablePolling) {
|
console.log('🔍 useTraining polling check:', {
|
||||||
console.log('🚫 HTTP status polling disabled - using WebSocket instead');
|
disablePolling,
|
||||||
return;
|
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');
|
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');
|
console.log('🔄 Starting HTTP status polling for', runningJobs.length, 'jobs');
|
||||||
|
|
||||||
const interval = setInterval(async () => {
|
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) {
|
for (const job of runningJobs) {
|
||||||
try {
|
try {
|
||||||
const tenantId = job.tenant_id;
|
const tenantId = job.tenant_id;
|
||||||
|
console.log('📡 HTTP polling job status:', job.job_id);
|
||||||
await getTrainingJobStatus(tenantId, job.job_id);
|
await getTrainingJobStatus(tenantId, job.job_id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to refresh job status:', error);
|
console.error('Failed to refresh job status:', error);
|
||||||
@@ -217,7 +239,7 @@ export const useTraining = (options: UseTrainingOptions = {}) => {
|
|||||||
}, 5000); // Refresh every 5 seconds
|
}, 5000); // Refresh every 5 seconds
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
console.log('🛑 Stopping HTTP status polling');
|
console.log('🛑 Stopping HTTP status polling (cleanup)');
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
};
|
};
|
||||||
}, [jobs, getTrainingJobStatus, disablePolling]);
|
}, [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 React, { useState } from 'react';
|
||||||
|
import { Outlet, Link, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Home,
|
Home,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
@@ -10,18 +11,17 @@ import {
|
|||||||
User,
|
User,
|
||||||
Bell,
|
Bell,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChefHat,
|
BarChart3,
|
||||||
Warehouse,
|
Building
|
||||||
ShoppingCart,
|
|
||||||
BookOpen
|
|
||||||
} from 'lucide-react';
|
} 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 {
|
interface LayoutProps {
|
||||||
children: React.ReactNode;
|
// No props needed - using React Router
|
||||||
user: any;
|
|
||||||
currentPage: string;
|
|
||||||
onNavigate: (page: string) => void;
|
|
||||||
onLogout: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavigationItem {
|
interface NavigationItem {
|
||||||
@@ -29,32 +29,52 @@ interface NavigationItem {
|
|||||||
label: string;
|
label: string;
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
href: string;
|
href: string;
|
||||||
|
requiresRole?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const Layout: React.FC<LayoutProps> = ({
|
const Layout: React.FC<LayoutProps> = () => {
|
||||||
children,
|
const location = useLocation();
|
||||||
user,
|
const navigate = useNavigate();
|
||||||
currentPage,
|
const dispatch = useDispatch();
|
||||||
onNavigate,
|
const { user } = useSelector((state: RootState) => state.auth);
|
||||||
onLogout
|
const { hasRole } = usePermissions();
|
||||||
}) => {
|
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||||
|
|
||||||
const navigation: NavigationItem[] = [
|
const navigation: NavigationItem[] = [
|
||||||
{ id: 'dashboard', label: 'Inicio', icon: Home, href: '/dashboard' },
|
{ id: 'dashboard', label: 'Dashboard', icon: Home, href: '/app/dashboard' },
|
||||||
{ id: 'orders', label: 'Pedidos', icon: Package, href: '/orders' },
|
{ id: 'operations', label: 'Operaciones', icon: Package, href: '/app/operations' },
|
||||||
{ id: 'production', label: 'Producción', icon: ChefHat, href: '/production' },
|
{
|
||||||
{ id: 'recipes', label: 'Recetas', icon: BookOpen, href: '/recipes' },
|
id: 'analytics',
|
||||||
{ id: 'inventory', label: 'Inventario', icon: Warehouse, href: '/inventory' },
|
label: 'Analytics',
|
||||||
{ id: 'sales', label: 'Ventas', icon: ShoppingCart, href: '/sales' },
|
icon: BarChart3,
|
||||||
{ id: 'reports', label: 'Informes', icon: TrendingUp, href: '/reports' },
|
href: '/app/analytics',
|
||||||
{ id: 'settings', label: 'Configuración', icon: Settings, href: '/settings' },
|
requiresRole: ['admin', 'manager']
|
||||||
|
},
|
||||||
|
{ id: 'settings', label: 'Configuración', icon: Settings, href: '/app/settings' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleNavigate = (pageId: string) => {
|
// Filter navigation based on user role
|
||||||
onNavigate(pageId);
|
const filteredNavigation = navigation.filter(item => {
|
||||||
setIsMobileMenuOpen(false);
|
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 (
|
return (
|
||||||
@@ -88,14 +108,14 @@ const Layout: React.FC<LayoutProps> = ({
|
|||||||
|
|
||||||
{/* Desktop Navigation */}
|
{/* Desktop Navigation */}
|
||||||
<div className="hidden md:flex md:ml-10 md:space-x-1">
|
<div className="hidden md:flex md:ml-10 md:space-x-1">
|
||||||
{navigation.map((item) => {
|
{filteredNavigation.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
const isActive = currentPage === item.id;
|
const isActive = isActiveRoute(item.href);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Link
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => handleNavigate(item.id)}
|
to={item.href}
|
||||||
className={`
|
className={`
|
||||||
flex items-center px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200
|
flex items-center px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200
|
||||||
${isActive
|
${isActive
|
||||||
@@ -103,17 +123,20 @@ const Layout: React.FC<LayoutProps> = ({
|
|||||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
>
|
>
|
||||||
<Icon className="h-4 w-4 mr-2" />
|
<Icon className="h-4 w-4 mr-2" />
|
||||||
{item.label}
|
{item.label}
|
||||||
</button>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side - Notifications and User Menu */}
|
{/* Right side - Tenant Selector, Notifications and User Menu */}
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
|
{/* Tenant Selector */}
|
||||||
|
<TenantSelector />
|
||||||
{/* Notifications */}
|
{/* Notifications */}
|
||||||
<button className="p-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors relative">
|
<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" />
|
<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 font-medium text-gray-900">{user.fullName}</p>
|
||||||
<p className="text-sm text-gray-500">{user.email}</p>
|
<p className="text-sm text-gray-500">{user.email}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<Link
|
||||||
onClick={() => {
|
to="/app/settings"
|
||||||
handleNavigate('settings');
|
onClick={() => setIsUserMenuOpen(false)}
|
||||||
setIsUserMenuOpen(false);
|
|
||||||
}}
|
|
||||||
className="w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 flex items-center"
|
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" />
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
Configuración
|
Configuración
|
||||||
</button>
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onLogout();
|
handleLogout();
|
||||||
setIsUserMenuOpen(false);
|
setIsUserMenuOpen(false);
|
||||||
}}
|
}}
|
||||||
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 flex items-center"
|
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 && (
|
{isMobileMenuOpen && (
|
||||||
<div className="md:hidden border-t border-gray-200 bg-white">
|
<div className="md:hidden border-t border-gray-200 bg-white">
|
||||||
<div className="px-2 pt-2 pb-3 space-y-1">
|
<div className="px-2 pt-2 pb-3 space-y-1">
|
||||||
{navigation.map((item) => {
|
{filteredNavigation.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
const isActive = currentPage === item.id;
|
const isActive = isActiveRoute(item.href);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<Link
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => handleNavigate(item.id)}
|
to={item.href}
|
||||||
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
className={`
|
className={`
|
||||||
w-full flex items-center px-3 py-2 rounded-lg text-base font-medium transition-all duration-200
|
w-full flex items-center px-3 py-2 rounded-lg text-base font-medium transition-all duration-200
|
||||||
${isActive
|
${isActive
|
||||||
@@ -191,7 +213,7 @@ const Layout: React.FC<LayoutProps> = ({
|
|||||||
>
|
>
|
||||||
<Icon className="h-5 w-5 mr-3" />
|
<Icon className="h-5 w-5 mr-3" />
|
||||||
{item.label}
|
{item.label}
|
||||||
</button>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -201,9 +223,7 @@ const Layout: React.FC<LayoutProps> = ({
|
|||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<main className="flex-1">
|
<main className="flex-1">
|
||||||
<div className="max-w-7xl mx-auto">
|
<Outlet />
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Click outside handler for dropdowns */}
|
{/* 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 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 { Eye, EyeOff, Loader2 } from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
import { loginSuccess } from '../../store/slices/authSlice';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useAuth,
|
useAuth,
|
||||||
@@ -8,8 +11,7 @@ import {
|
|||||||
} from '../../api';
|
} from '../../api';
|
||||||
|
|
||||||
interface LoginPageProps {
|
interface LoginPageProps {
|
||||||
onLogin: (user: any, token: string) => void;
|
// No props needed with React Router
|
||||||
onNavigateToRegister: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LoginForm {
|
interface LoginForm {
|
||||||
@@ -17,11 +19,15 @@ interface LoginForm {
|
|||||||
password: string;
|
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();
|
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>({
|
const [formData, setFormData] = useState<LoginForm>({
|
||||||
email: '',
|
email: '',
|
||||||
password: ''
|
password: ''
|
||||||
@@ -70,7 +76,13 @@ const LoginPage: React.FC<LoginPageProps> = ({ onLogin, onNavigateToRegister })
|
|||||||
const token = localStorage.getItem('auth_token');
|
const token = localStorage.getItem('auth_token');
|
||||||
|
|
||||||
if (userData && 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) {
|
} catch (error: any) {
|
||||||
@@ -245,12 +257,12 @@ const LoginPage: React.FC<LoginPageProps> = ({ onLogin, onNavigateToRegister })
|
|||||||
<div className="mt-6 text-center">
|
<div className="mt-6 text-center">
|
||||||
<p className="text-sm text-gray-600">
|
<p className="text-sm text-gray-600">
|
||||||
¿No tienes una cuenta?{' '}
|
¿No tienes una cuenta?{' '}
|
||||||
<button
|
<Link
|
||||||
onClick={onNavigateToRegister}
|
to="/register"
|
||||||
className="font-medium text-primary-600 hover:text-primary-500 transition-colors"
|
className="font-medium text-primary-600 hover:text-primary-500 transition-colors"
|
||||||
>
|
>
|
||||||
Regístrate gratis
|
Regístrate gratis
|
||||||
</button>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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 React, { useState, useEffect } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
TrendingDown,
|
TrendingDown,
|
||||||
@@ -18,11 +19,10 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface LandingPageProps {
|
interface LandingPageProps {
|
||||||
onNavigateToLogin: () => void;
|
// No props needed with React Router
|
||||||
onNavigateToRegister: () => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const LandingPage: React.FC<LandingPageProps> = ({ onNavigateToLogin, onNavigateToRegister }) => {
|
const LandingPage: React.FC<LandingPageProps> = () => {
|
||||||
const [isVideoModalOpen, setIsVideoModalOpen] = useState(false);
|
const [isVideoModalOpen, setIsVideoModalOpen] = useState(false);
|
||||||
const [currentTestimonial, setCurrentTestimonial] = useState(0);
|
const [currentTestimonial, setCurrentTestimonial] = useState(0);
|
||||||
|
|
||||||
@@ -120,18 +120,18 @@ const LandingPage: React.FC<LandingPageProps> = ({ onNavigateToLogin, onNavigate
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<button
|
<Link
|
||||||
onClick={onNavigateToLogin}
|
to="/login"
|
||||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||||
>
|
>
|
||||||
Iniciar sesión
|
Iniciar sesión
|
||||||
</button>
|
</Link>
|
||||||
<button
|
<Link
|
||||||
onClick={onNavigateToRegister}
|
to="/register"
|
||||||
className="bg-primary-500 text-white px-6 py-2 rounded-lg hover:bg-primary-600 transition-all hover:shadow-lg"
|
className="bg-primary-500 text-white px-6 py-2 rounded-lg hover:bg-primary-600 transition-all hover:shadow-lg"
|
||||||
>
|
>
|
||||||
Prueba gratis
|
Prueba gratis
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -159,13 +159,13 @@ const LandingPage: React.FC<LandingPageProps> = ({ onNavigateToLogin, onNavigate
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 mb-8">
|
<div className="flex flex-col sm:flex-row gap-4 mb-8">
|
||||||
<button
|
<Link
|
||||||
onClick={onNavigateToRegister}
|
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"
|
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
|
Comenzar gratis
|
||||||
<ArrowRight className="h-5 w-5 ml-2" />
|
<ArrowRight className="h-5 w-5 ml-2" />
|
||||||
</button>
|
</Link>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsVideoModalOpen(true)}
|
onClick={() => setIsVideoModalOpen(true)}
|
||||||
@@ -419,18 +419,18 @@ const LandingPage: React.FC<LandingPageProps> = ({ onNavigateToLogin, onNavigate
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row gap-4 justify-center mb-8">
|
<div className="flex flex-col sm:flex-row gap-4 justify-center mb-8">
|
||||||
<button
|
<Link
|
||||||
onClick={onNavigateToRegister}
|
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"
|
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
|
Comenzar prueba gratuita
|
||||||
</button>
|
</Link>
|
||||||
<button
|
<Link
|
||||||
onClick={onNavigateToLogin}
|
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"
|
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
|
Ya tengo cuenta
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-8 text-primary-100">
|
<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">
|
<p className="text-sm text-gray-500 mt-2">
|
||||||
Mientras tanto, puedes comenzar tu prueba gratuita
|
Mientras tanto, puedes comenzar tu prueba gratuita
|
||||||
</p>
|
</p>
|
||||||
<button
|
<Link
|
||||||
onClick={() => {
|
to="/register"
|
||||||
setIsVideoModalOpen(false);
|
onClick={() => setIsVideoModalOpen(false)}
|
||||||
onNavigateToRegister();
|
className="mt-4 bg-primary-500 text-white px-6 py-2 rounded-lg hover:bg-primary-600 transition-colors inline-block"
|
||||||
}}
|
|
||||||
className="mt-4 bg-primary-500 text-white px-6 py-2 rounded-lg hover:bg-primary-600 transition-colors"
|
|
||||||
>
|
>
|
||||||
Comenzar prueba gratis
|
Comenzar prueba gratis
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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,
|
completeStep,
|
||||||
refreshProgress
|
refreshProgress
|
||||||
} = useOnboarding();
|
} = 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>({
|
const [bakeryData, setBakeryData] = useState<BakeryData>({
|
||||||
name: '',
|
name: '',
|
||||||
address: '',
|
address: '',
|
||||||
@@ -179,12 +235,14 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Mark training step as completed in onboarding API
|
// 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(),
|
training_completed_at: new Date().toISOString(),
|
||||||
user_id: user?.id,
|
user_id: user?.id,
|
||||||
tenant_id: tenantId
|
tenant_id: tenantId,
|
||||||
}).catch(error => {
|
completion_source: 'websocket_training_completion'
|
||||||
// Failed to mark training as completed in API
|
}, 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
|
// 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 });
|
console.log('Connecting to training WebSocket:', { tenantId, trainingJobId, wsUrl });
|
||||||
connect();
|
connect();
|
||||||
|
|
||||||
// Simple polling fallback for training completion detection (now that we fixed the 404 issue)
|
// ✅ DISABLED: Polling fallback now unnecessary since WebSocket is working properly
|
||||||
const pollingInterval = setInterval(async () => {
|
// The WebSocket connection now handles all training status updates in real-time
|
||||||
if (trainingProgress.status === 'running' || trainingProgress.status === 'pending') {
|
console.log('🚫 REST polling disabled - using WebSocket exclusively for training updates');
|
||||||
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) {
|
// Create dummy interval for cleanup compatibility (no actual polling)
|
||||||
const jobStatus = await response.json();
|
const pollingInterval = setInterval(() => {
|
||||||
|
// No-op - REST polling is disabled, WebSocket handles all training updates
|
||||||
// If the job is completed but we haven't received WebSocket notification
|
}, 60000); // Set to 1 minute but does nothing
|
||||||
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)
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (isConnected) {
|
if (isConnected) {
|
||||||
@@ -445,9 +436,9 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
storeTenantId(newTenant.id);
|
storeTenantId(newTenant.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark step as completed in onboarding API (non-blocking)
|
// Mark bakery_registered step as completed (dependencies will be handled automatically)
|
||||||
try {
|
try {
|
||||||
await completeStep('bakery_registered', {
|
await completeStepWithDependencies('bakery_registered', {
|
||||||
bakery_name: bakeryData.name,
|
bakery_name: bakeryData.name,
|
||||||
bakery_address: bakeryData.address,
|
bakery_address: bakeryData.address,
|
||||||
business_type: 'bakery', // Default - will be auto-detected from sales data
|
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
|
user_id: user?.id
|
||||||
});
|
});
|
||||||
} catch (stepError) {
|
} catch (stepError) {
|
||||||
|
console.warn('Step completion error:', stepError);
|
||||||
// Don't throw here - step completion is not critical for UI flow
|
// 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;
|
stepData.has_historical_data = bakeryData.hasHistoricalData;
|
||||||
}
|
}
|
||||||
|
|
||||||
await completeStep(stepName, stepData);
|
await completeStepWithDependencies(stepName, stepData);
|
||||||
// Note: Not calling refreshProgress() here to avoid step reset
|
// Note: Not calling refreshProgress() here to avoid step reset
|
||||||
|
|
||||||
toast.success(`✅ Paso ${currentStep} completado`);
|
toast.success(`✅ Paso ${currentStep} completado`);
|
||||||
@@ -589,7 +581,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
// Mark final step as completed
|
// Mark final step as completed
|
||||||
await completeStep('dashboard_accessible', {
|
await completeStepWithDependencies('dashboard_accessible', {
|
||||||
completion_time: new Date().toISOString(),
|
completion_time: new Date().toISOString(),
|
||||||
user_id: user?.id,
|
user_id: user?.id,
|
||||||
tenant_id: tenantId,
|
tenant_id: tenantId,
|
||||||
@@ -724,7 +716,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
|||||||
tenantId={tenantId}
|
tenantId={tenantId}
|
||||||
onComplete={(result) => {
|
onComplete={(result) => {
|
||||||
// Mark sales data as uploaded and proceed to training
|
// Mark sales data as uploaded and proceed to training
|
||||||
completeStep('sales_data_uploaded', {
|
completeStepWithDependencies('sales_data_uploaded', {
|
||||||
smart_import: true,
|
smart_import: true,
|
||||||
records_imported: result.successful_imports,
|
records_imported: result.successful_imports,
|
||||||
import_job_id: result.import_job_id,
|
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;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
fullName: string;
|
fullName: string;
|
||||||
role: string;
|
role: 'owner' | 'admin' | 'manager' | 'worker';
|
||||||
isOnboardingComplete: boolean;
|
isOnboardingComplete: boolean;
|
||||||
|
tenant_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
|
|||||||
@@ -36,9 +36,10 @@ logger = structlog.get_logger()
|
|||||||
|
|
||||||
|
|
||||||
def make_json_serializable(obj):
|
def make_json_serializable(obj):
|
||||||
"""Convert numpy/pandas types and UUID objects to JSON-serializable Python types"""
|
"""Convert numpy/pandas types, datetime, and UUID objects to JSON-serializable Python types"""
|
||||||
import uuid
|
import uuid
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
from datetime import datetime, date
|
||||||
|
|
||||||
if isinstance(obj, (np.integer, pd.Int64Dtype)):
|
if isinstance(obj, (np.integer, pd.Int64Dtype)):
|
||||||
return int(obj)
|
return int(obj)
|
||||||
@@ -50,6 +51,10 @@ def make_json_serializable(obj):
|
|||||||
return obj.tolist()
|
return obj.tolist()
|
||||||
elif isinstance(obj, pd.DataFrame):
|
elif isinstance(obj, pd.DataFrame):
|
||||||
return obj.to_dict('records')
|
return obj.to_dict('records')
|
||||||
|
elif isinstance(obj, datetime):
|
||||||
|
return obj.isoformat()
|
||||||
|
elif isinstance(obj, date):
|
||||||
|
return obj.isoformat()
|
||||||
elif isinstance(obj, uuid.UUID):
|
elif isinstance(obj, uuid.UUID):
|
||||||
return str(obj)
|
return str(obj)
|
||||||
elif hasattr(obj, '__class__') and 'UUID' in str(obj.__class__):
|
elif hasattr(obj, '__class__') and 'UUID' in str(obj.__class__):
|
||||||
@@ -127,7 +132,8 @@ class EnhancedTrainingService:
|
|||||||
tenant_id=tenant_id)
|
tenant_id=tenant_id)
|
||||||
|
|
||||||
# Get session and initialize repositories
|
# Get session and initialize repositories
|
||||||
async with self.database_manager.get_session() as session:
|
from app.core.database import get_background_db_session
|
||||||
|
async with get_background_db_session() as session:
|
||||||
await self._init_repositories(session)
|
await self._init_repositories(session)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -168,15 +174,24 @@ class EnhancedTrainingService:
|
|||||||
logger.info(f"Pre-flight check passed: {len(sales_data)} sales records found",
|
logger.info(f"Pre-flight check passed: {len(sales_data)} sales records found",
|
||||||
tenant_id=tenant_id, job_id=job_id)
|
tenant_id=tenant_id, job_id=job_id)
|
||||||
|
|
||||||
# Create training log entry
|
# Check if training log already exists, create if not
|
||||||
log_data = {
|
existing_log = await self.training_log_repo.get_log_by_job_id(job_id)
|
||||||
"job_id": job_id,
|
|
||||||
"tenant_id": tenant_id,
|
if existing_log:
|
||||||
"status": "running",
|
logger.info("Training log already exists, updating status", job_id=job_id)
|
||||||
"progress": 0,
|
training_log = await self.training_log_repo.update_log_progress(
|
||||||
"current_step": "initializing"
|
job_id, 0, "initializing", "running"
|
||||||
}
|
)
|
||||||
training_log = await self.training_log_repo.create_training_log(log_data)
|
else:
|
||||||
|
# Create new training log entry
|
||||||
|
log_data = {
|
||||||
|
"job_id": job_id,
|
||||||
|
"tenant_id": tenant_id,
|
||||||
|
"status": "running",
|
||||||
|
"progress": 0,
|
||||||
|
"current_step": "initializing"
|
||||||
|
}
|
||||||
|
training_log = await self.training_log_repo.create_training_log(log_data)
|
||||||
|
|
||||||
# Initialize status publisher
|
# Initialize status publisher
|
||||||
status_publisher = TrainingStatusPublisher(job_id, tenant_id)
|
status_publisher = TrainingStatusPublisher(job_id, tenant_id)
|
||||||
@@ -422,7 +437,8 @@ class EnhancedTrainingService:
|
|||||||
async def get_training_status(self, job_id: str) -> Dict[str, Any]:
|
async def get_training_status(self, job_id: str) -> Dict[str, Any]:
|
||||||
"""Get training job status using repository"""
|
"""Get training job status using repository"""
|
||||||
try:
|
try:
|
||||||
async with self.database_manager.get_session() as session:
|
from app.core.database import get_background_db_session
|
||||||
|
async with get_background_db_session() as session:
|
||||||
await self._init_repositories(session)
|
await self._init_repositories(session)
|
||||||
|
|
||||||
log = await self.training_log_repo.get_log_by_job_id(job_id)
|
log = await self.training_log_repo.get_log_by_job_id(job_id)
|
||||||
@@ -456,7 +472,8 @@ class EnhancedTrainingService:
|
|||||||
) -> List[Dict[str, Any]]:
|
) -> List[Dict[str, Any]]:
|
||||||
"""Get models for a tenant using repository"""
|
"""Get models for a tenant using repository"""
|
||||||
try:
|
try:
|
||||||
async with self.database_manager.get_session() as session:
|
from app.core.database import get_background_db_session
|
||||||
|
async with get_background_db_session() as session:
|
||||||
await self._init_repositories(session)
|
await self._init_repositories(session)
|
||||||
|
|
||||||
if active_only:
|
if active_only:
|
||||||
@@ -483,7 +500,8 @@ class EnhancedTrainingService:
|
|||||||
async def get_model_performance(self, model_id: str) -> Dict[str, Any]:
|
async def get_model_performance(self, model_id: str) -> Dict[str, Any]:
|
||||||
"""Get model performance metrics using repository"""
|
"""Get model performance metrics using repository"""
|
||||||
try:
|
try:
|
||||||
async with self.database_manager.get_session() as session:
|
from app.core.database import get_background_db_session
|
||||||
|
async with get_background_db_session() as session:
|
||||||
await self._init_repositories(session)
|
await self._init_repositories(session)
|
||||||
|
|
||||||
# Get model summary
|
# Get model summary
|
||||||
@@ -514,7 +532,8 @@ class EnhancedTrainingService:
|
|||||||
async def get_tenant_statistics(self, tenant_id: str) -> Dict[str, Any]:
|
async def get_tenant_statistics(self, tenant_id: str) -> Dict[str, Any]:
|
||||||
"""Get comprehensive tenant statistics using repositories"""
|
"""Get comprehensive tenant statistics using repositories"""
|
||||||
try:
|
try:
|
||||||
async with self.database_manager.get_session() as session:
|
from app.core.database import get_background_db_session
|
||||||
|
async with get_background_db_session() as session:
|
||||||
await self._init_repositories(session)
|
await self._init_repositories(session)
|
||||||
|
|
||||||
# Get model statistics
|
# Get model statistics
|
||||||
@@ -564,7 +583,8 @@ class EnhancedTrainingService:
|
|||||||
tenant_id: str = None):
|
tenant_id: str = None):
|
||||||
"""Update job status using repository pattern"""
|
"""Update job status using repository pattern"""
|
||||||
try:
|
try:
|
||||||
async with self.database_manager.get_session() as session:
|
from app.core.database import get_background_db_session
|
||||||
|
async with get_background_db_session() as session:
|
||||||
await self._init_repositories(session)
|
await self._init_repositories(session)
|
||||||
|
|
||||||
# Check if log exists, create if not
|
# Check if log exists, create if not
|
||||||
|
|||||||
Reference in New Issue
Block a user