New Frontend
This commit is contained in:
17
frontend/src/pages/analytics/AIInsightsPage.tsx
Normal file
17
frontend/src/pages/analytics/AIInsightsPage.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
|
||||
const AIInsightsPage: React.FC = () => {
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Insights de IA</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-8 text-center">
|
||||
<p className="text-gray-500">Insights de IA en desarrollo</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AIInsightsPage;
|
||||
17
frontend/src/pages/analytics/FinancialReportsPage.tsx
Normal file
17
frontend/src/pages/analytics/FinancialReportsPage.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
|
||||
const FinancialReportsPage: React.FC = () => {
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Reportes Financieros</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-8 text-center">
|
||||
<p className="text-gray-500">Reportes financieros en desarrollo</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FinancialReportsPage;
|
||||
17
frontend/src/pages/analytics/PerformanceKPIsPage.tsx
Normal file
17
frontend/src/pages/analytics/PerformanceKPIsPage.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
|
||||
const PerformanceKPIsPage: React.FC = () => {
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">KPIs de Rendimiento</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-8 text-center">
|
||||
<p className="text-gray-500">KPIs de rendimiento en desarrollo</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PerformanceKPIsPage;
|
||||
20
frontend/src/pages/analytics/ProductionReportsPage.tsx
Normal file
20
frontend/src/pages/analytics/ProductionReportsPage.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import { useBakeryType } from '../../hooks/useBakeryType';
|
||||
|
||||
const ProductionReportsPage: React.FC = () => {
|
||||
const { getProductionLabel } = useBakeryType();
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Reportes de {getProductionLabel()}</h1>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-8 text-center">
|
||||
<p className="text-gray-500">Reportes de {getProductionLabel().toLowerCase()} en desarrollo</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductionReportsPage;
|
||||
120
frontend/src/pages/analytics/SalesAnalyticsPage.tsx
Normal file
120
frontend/src/pages/analytics/SalesAnalyticsPage.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React, { useState } from 'react';
|
||||
import { TrendingUp, DollarSign, ShoppingCart, Calendar } from 'lucide-react';
|
||||
import { useBakeryType } from '../../hooks/useBakeryType';
|
||||
|
||||
const SalesAnalyticsPage: React.FC = () => {
|
||||
const { isIndividual, isCentral } = useBakeryType();
|
||||
const [timeRange, setTimeRange] = useState('week');
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Análisis de Ventas</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
{isIndividual
|
||||
? 'Analiza el rendimiento de ventas de tu panadería'
|
||||
: 'Analiza el rendimiento de ventas de todos tus puntos de venta'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Time Range Selector */}
|
||||
<div className="mb-6">
|
||||
<div className="flex space-x-2">
|
||||
{['day', 'week', 'month', 'quarter'].map((range) => (
|
||||
<button
|
||||
key={range}
|
||||
onClick={() => setTimeRange(range)}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
timeRange === range
|
||||
? 'bg-primary-100 text-primary-700'
|
||||
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{range === 'day' && 'Hoy'}
|
||||
{range === 'week' && 'Esta Semana'}
|
||||
{range === 'month' && 'Este Mes'}
|
||||
{range === 'quarter' && 'Este Trimestre'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="h-8 w-8 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<DollarSign className="h-4 w-4 text-green-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Ingresos Totales</p>
|
||||
<p className="text-2xl font-bold text-gray-900">€2,847</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="h-8 w-8 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||
<ShoppingCart className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">
|
||||
{isIndividual ? 'Productos Vendidos' : 'Productos Distribuidos'}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-gray-900">1,429</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="h-8 w-8 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||
<TrendingUp className="h-4 w-4 text-purple-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Crecimiento</p>
|
||||
<p className="text-2xl font-bold text-gray-900">+12.5%</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="h-8 w-8 bg-yellow-100 rounded-lg flex items-center justify-center">
|
||||
<Calendar className="h-4 w-4 text-yellow-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-600">Días Activos</p>
|
||||
<p className="text-2xl font-bold text-gray-900">6/7</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts placeholder */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
Tendencia de Ventas
|
||||
</h3>
|
||||
<div className="h-64 bg-gray-50 rounded-lg flex items-center justify-center">
|
||||
<p className="text-gray-500">Gráfico de tendencias aquí</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
{isIndividual ? 'Productos Más Vendidos' : 'Productos Más Distribuidos'}
|
||||
</h3>
|
||||
<div className="h-64 bg-gray-50 rounded-lg flex items-center justify-center">
|
||||
<p className="text-gray-500">Gráfico de productos aquí</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SalesAnalyticsPage;
|
||||
@@ -1,6 +1,9 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Eye, EyeOff, Loader2 } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { loginSuccess } from '../../store/slices/authSlice';
|
||||
|
||||
import {
|
||||
useAuth,
|
||||
@@ -8,8 +11,7 @@ import {
|
||||
} from '../../api';
|
||||
|
||||
interface LoginPageProps {
|
||||
onLogin: (user: any, token: string) => void;
|
||||
onNavigateToRegister: () => void;
|
||||
// No props needed with React Router
|
||||
}
|
||||
|
||||
interface LoginForm {
|
||||
@@ -17,11 +19,15 @@ interface LoginForm {
|
||||
password: string;
|
||||
}
|
||||
|
||||
const LoginPage: React.FC<LoginPageProps> = ({ onLogin, onNavigateToRegister }) => {
|
||||
|
||||
|
||||
const LoginPage: React.FC<LoginPageProps> = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const dispatch = useDispatch();
|
||||
const { login, isLoading, isAuthenticated } = useAuth();
|
||||
|
||||
// Get the intended destination from state, default to app
|
||||
const from = (location.state as any)?.from?.pathname || '/app';
|
||||
|
||||
const [formData, setFormData] = useState<LoginForm>({
|
||||
email: '',
|
||||
password: ''
|
||||
@@ -70,7 +76,13 @@ const LoginPage: React.FC<LoginPageProps> = ({ onLogin, onNavigateToRegister })
|
||||
const token = localStorage.getItem('auth_token');
|
||||
|
||||
if (userData && token) {
|
||||
onLogin(JSON.parse(userData), token);
|
||||
const user = JSON.parse(userData);
|
||||
|
||||
// Set auth state
|
||||
dispatch(loginSuccess({ user, token }));
|
||||
|
||||
// Navigate to intended destination
|
||||
navigate(from, { replace: true });
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
@@ -245,12 +257,12 @@ const LoginPage: React.FC<LoginPageProps> = ({ onLogin, onNavigateToRegister })
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
¿No tienes una cuenta?{' '}
|
||||
<button
|
||||
onClick={onNavigateToRegister}
|
||||
<Link
|
||||
to="/register"
|
||||
className="font-medium text-primary-600 hover:text-primary-500 transition-colors"
|
||||
>
|
||||
Regístrate gratis
|
||||
</button>
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
421
frontend/src/pages/auth/SimpleRegisterPage.tsx
Normal file
421
frontend/src/pages/auth/SimpleRegisterPage.tsx
Normal file
@@ -0,0 +1,421 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Eye, EyeOff, Loader2, User, Mail, Lock } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { loginSuccess } from '../../store/slices/authSlice';
|
||||
import { authService } from '../../api/services/auth.service';
|
||||
import { onboardingService } from '../../api/services/onboarding.service';
|
||||
import type { RegisterRequest } from '../../api/types/auth';
|
||||
|
||||
interface RegisterForm {
|
||||
fullName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
acceptTerms: boolean;
|
||||
}
|
||||
|
||||
const RegisterPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [formData, setFormData] = useState<RegisterForm>({
|
||||
fullName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
acceptTerms: false
|
||||
});
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<Partial<RegisterForm>>({});
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Partial<RegisterForm> = {};
|
||||
|
||||
if (!formData.fullName.trim()) {
|
||||
newErrors.fullName = 'El nombre es obligatorio';
|
||||
}
|
||||
|
||||
if (!formData.email) {
|
||||
newErrors.email = 'El email es obligatorio';
|
||||
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
|
||||
newErrors.email = 'El email no es válido';
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = 'La contraseña es obligatoria';
|
||||
} else if (formData.password.length < 8) {
|
||||
newErrors.password = 'La contraseña debe tener al menos 8 caracteres';
|
||||
}
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
newErrors.confirmPassword = 'Las contraseñas no coinciden';
|
||||
}
|
||||
|
||||
if (!formData.acceptTerms) {
|
||||
newErrors.acceptTerms = 'Debes aceptar los términos y condiciones';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Prepare registration data
|
||||
const registrationData: RegisterRequest = {
|
||||
email: formData.email,
|
||||
password: formData.password,
|
||||
full_name: formData.fullName,
|
||||
role: 'admin',
|
||||
language: 'es'
|
||||
};
|
||||
|
||||
// Call real authentication API
|
||||
const response = await authService.register(registrationData);
|
||||
|
||||
// Extract user data from response
|
||||
const userData = response.user;
|
||||
if (!userData) {
|
||||
throw new Error('No se recibieron datos del usuario');
|
||||
}
|
||||
|
||||
// Convert API response to internal format
|
||||
const user = {
|
||||
id: userData.id,
|
||||
email: userData.email,
|
||||
fullName: userData.full_name,
|
||||
role: userData.role || 'admin',
|
||||
isOnboardingComplete: false, // New users need onboarding
|
||||
tenant_id: userData.tenant_id
|
||||
};
|
||||
|
||||
// Store tokens in localStorage
|
||||
localStorage.setItem('auth_token', response.access_token);
|
||||
if (response.refresh_token) {
|
||||
localStorage.setItem('refresh_token', response.refresh_token);
|
||||
}
|
||||
localStorage.setItem('user_data', JSON.stringify(user));
|
||||
|
||||
// Set auth state
|
||||
dispatch(loginSuccess({ user, token: response.access_token }));
|
||||
|
||||
// Mark user_registered step as completed in onboarding
|
||||
try {
|
||||
await onboardingService.completeStep('user_registered', {
|
||||
user_id: userData.id,
|
||||
registration_completed_at: new Date().toISOString(),
|
||||
registration_method: 'web_form'
|
||||
});
|
||||
console.log('✅ user_registered step marked as completed');
|
||||
} catch (onboardingError) {
|
||||
console.warn('Failed to mark user_registered step as completed:', onboardingError);
|
||||
// Don't block the flow if onboarding step completion fails
|
||||
}
|
||||
|
||||
toast.success('¡Cuenta creada exitosamente!');
|
||||
|
||||
// Navigate to onboarding
|
||||
navigate('/app/onboarding');
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Registration error:', error);
|
||||
const errorMessage = error?.response?.data?.detail || error?.message || 'Error al crear la cuenta';
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
}));
|
||||
|
||||
// Clear error when user starts typing
|
||||
if (errors[name as keyof RegisterForm]) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
[name]: undefined
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-orange-100 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
{/* Logo and Header */}
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-20 w-20 bg-primary-500 rounded-2xl flex items-center justify-center mb-6 shadow-lg">
|
||||
<span className="text-white text-2xl font-bold">🥖</span>
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold text-gray-900 mb-2">
|
||||
Únete a PanIA
|
||||
</h1>
|
||||
<p className="text-gray-600 text-lg">
|
||||
Crea tu cuenta y comienza a optimizar tu panadería
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Registration Form */}
|
||||
<div className="bg-white py-8 px-6 shadow-strong rounded-3xl">
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
{/* Full Name Field */}
|
||||
<div>
|
||||
<label htmlFor="fullName" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Nombre completo
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-3 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
id="fullName"
|
||||
name="fullName"
|
||||
type="text"
|
||||
required
|
||||
value={formData.fullName}
|
||||
onChange={handleInputChange}
|
||||
className={`
|
||||
appearance-none relative block w-full pl-10 pr-4 py-3 border rounded-xl
|
||||
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
|
||||
focus:ring-primary-500 focus:border-primary-500 focus:z-10
|
||||
transition-all duration-200
|
||||
${errors.fullName
|
||||
? 'border-red-300 bg-red-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}
|
||||
`}
|
||||
placeholder="Juan Pérez"
|
||||
/>
|
||||
</div>
|
||||
{errors.fullName && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.fullName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email Field */}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Correo electrónico
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-3 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
className={`
|
||||
appearance-none relative block w-full pl-10 pr-4 py-3 border rounded-xl
|
||||
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
|
||||
focus:ring-primary-500 focus:border-primary-500 focus:z-10
|
||||
transition-all duration-200
|
||||
${errors.email
|
||||
? 'border-red-300 bg-red-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}
|
||||
`}
|
||||
placeholder="tu@panaderia.com"
|
||||
/>
|
||||
</div>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Contraseña
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
className={`
|
||||
appearance-none relative block w-full pl-10 pr-12 py-3 border rounded-xl
|
||||
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
|
||||
focus:ring-primary-500 focus:border-primary-500 focus:z-10
|
||||
transition-all duration-200
|
||||
${errors.password
|
||||
? 'border-red-300 bg-red-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}
|
||||
`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.password}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirm Password Field */}
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Confirmar contraseña
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
id="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
required
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
className={`
|
||||
appearance-none relative block w-full pl-10 pr-12 py-3 border rounded-xl
|
||||
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
|
||||
focus:ring-primary-500 focus:border-primary-500 focus:z-10
|
||||
transition-all duration-200
|
||||
${errors.confirmPassword
|
||||
? 'border-red-300 bg-red-50'
|
||||
: 'border-gray-300 hover:border-gray-400'
|
||||
}
|
||||
`}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeOff className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5 text-gray-400 hover:text-gray-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.confirmPassword && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.confirmPassword}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Terms and Conditions */}
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="acceptTerms"
|
||||
name="acceptTerms"
|
||||
type="checkbox"
|
||||
checked={formData.acceptTerms}
|
||||
onChange={handleInputChange}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="acceptTerms" className="ml-2 block text-sm text-gray-700">
|
||||
Acepto los{' '}
|
||||
<a href="#" className="text-primary-600 hover:text-primary-500">
|
||||
términos y condiciones
|
||||
</a>{' '}
|
||||
y la{' '}
|
||||
<a href="#" className="text-primary-600 hover:text-primary-500">
|
||||
política de privacidad
|
||||
</a>
|
||||
</label>
|
||||
</div>
|
||||
{errors.acceptTerms && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.acceptTerms}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className={`
|
||||
group relative w-full flex justify-center py-3 px-4 border border-transparent
|
||||
text-sm font-medium rounded-xl text-white transition-all duration-200
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500
|
||||
${isLoading
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-primary-500 hover:bg-primary-600 hover:shadow-lg transform hover:-translate-y-0.5'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
|
||||
Creando cuenta...
|
||||
</>
|
||||
) : (
|
||||
'Crear cuenta'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Login Link */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
¿Ya tienes una cuenta?{' '}
|
||||
<Link
|
||||
to="/login"
|
||||
className="font-medium text-primary-600 hover:text-primary-500 transition-colors"
|
||||
>
|
||||
Inicia sesión
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features Preview */}
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500 mb-4">
|
||||
Prueba gratuita de 14 días • No se requiere tarjeta de crédito
|
||||
</p>
|
||||
<div className="flex justify-center space-x-6 text-xs text-gray-400">
|
||||
<div className="flex items-center">
|
||||
<span className="w-2 h-2 bg-success-500 rounded-full mr-2"></span>
|
||||
Setup en 5 minutos
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className="w-2 h-2 bg-success-500 rounded-full mr-2"></span>
|
||||
Soporte incluido
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<span className="w-2 h-2 bg-success-500 rounded-full mr-2"></span>
|
||||
Cancela cuando quieras
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RegisterPage;
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
@@ -18,11 +19,10 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
interface LandingPageProps {
|
||||
onNavigateToLogin: () => void;
|
||||
onNavigateToRegister: () => void;
|
||||
// No props needed with React Router
|
||||
}
|
||||
|
||||
const LandingPage: React.FC<LandingPageProps> = ({ onNavigateToLogin, onNavigateToRegister }) => {
|
||||
const LandingPage: React.FC<LandingPageProps> = () => {
|
||||
const [isVideoModalOpen, setIsVideoModalOpen] = useState(false);
|
||||
const [currentTestimonial, setCurrentTestimonial] = useState(0);
|
||||
|
||||
@@ -120,18 +120,18 @@ const LandingPage: React.FC<LandingPageProps> = ({ onNavigateToLogin, onNavigate
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={onNavigateToLogin}
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
Iniciar sesión
|
||||
</button>
|
||||
<button
|
||||
onClick={onNavigateToRegister}
|
||||
</Link>
|
||||
<Link
|
||||
to="/register"
|
||||
className="bg-primary-500 text-white px-6 py-2 rounded-lg hover:bg-primary-600 transition-all hover:shadow-lg"
|
||||
>
|
||||
Prueba gratis
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -159,13 +159,13 @@ const LandingPage: React.FC<LandingPageProps> = ({ onNavigateToLogin, onNavigate
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-8">
|
||||
<button
|
||||
onClick={onNavigateToRegister}
|
||||
<Link
|
||||
to="/register"
|
||||
className="bg-primary-500 text-white px-8 py-4 rounded-xl font-semibold text-lg hover:bg-primary-600 transition-all hover:shadow-lg transform hover:-translate-y-1 flex items-center justify-center"
|
||||
>
|
||||
Comenzar gratis
|
||||
<ArrowRight className="h-5 w-5 ml-2" />
|
||||
</button>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => setIsVideoModalOpen(true)}
|
||||
@@ -419,18 +419,18 @@ const LandingPage: React.FC<LandingPageProps> = ({ onNavigateToLogin, onNavigate
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center mb-8">
|
||||
<button
|
||||
onClick={onNavigateToRegister}
|
||||
<Link
|
||||
to="/register"
|
||||
className="bg-white text-primary-600 px-8 py-4 rounded-xl font-semibold text-lg hover:bg-gray-50 transition-all hover:shadow-lg transform hover:-translate-y-1"
|
||||
>
|
||||
Comenzar prueba gratuita
|
||||
</button>
|
||||
<button
|
||||
onClick={onNavigateToLogin}
|
||||
</Link>
|
||||
<Link
|
||||
to="/login"
|
||||
className="border-2 border-white text-white px-8 py-4 rounded-xl font-semibold text-lg hover:bg-white hover:text-primary-600 transition-all"
|
||||
>
|
||||
Ya tengo cuenta
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-8 text-primary-100">
|
||||
@@ -528,15 +528,13 @@ const LandingPage: React.FC<LandingPageProps> = ({ onNavigateToLogin, onNavigate
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
Mientras tanto, puedes comenzar tu prueba gratuita
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsVideoModalOpen(false);
|
||||
onNavigateToRegister();
|
||||
}}
|
||||
className="mt-4 bg-primary-500 text-white px-6 py-2 rounded-lg hover:bg-primary-600 transition-colors"
|
||||
<Link
|
||||
to="/register"
|
||||
onClick={() => setIsVideoModalOpen(false)}
|
||||
className="mt-4 bg-primary-500 text-white px-6 py-2 rounded-lg hover:bg-primary-600 transition-colors inline-block"
|
||||
>
|
||||
Comenzar prueba gratis
|
||||
</button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
168
frontend/src/pages/landing/SimpleLandingPage.tsx
Normal file
168
frontend/src/pages/landing/SimpleLandingPage.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRight, TrendingUp, Clock, DollarSign, BarChart3 } from 'lucide-react';
|
||||
|
||||
const LandingPage: React.FC = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-orange-100">
|
||||
{/* Header */}
|
||||
<header className="bg-white shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center py-6">
|
||||
<div className="flex items-center">
|
||||
<div className="h-8 w-8 bg-primary-500 rounded-lg flex items-center justify-center mr-3">
|
||||
<span className="text-white text-sm font-bold">🥖</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-gray-900">PanIA</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link
|
||||
to="/login"
|
||||
className="text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
Iniciar sesión
|
||||
</Link>
|
||||
<Link
|
||||
to="/register"
|
||||
className="bg-primary-500 text-white px-6 py-2 rounded-lg hover:bg-primary-600 transition-all hover:shadow-lg"
|
||||
>
|
||||
Prueba gratis
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="py-20">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center bg-primary-100 text-primary-800 px-4 py-2 rounded-full text-sm font-medium mb-6">
|
||||
⭐ IA líder para panaderías en Madrid
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl lg:text-6xl font-bold text-gray-900 mb-6 leading-tight">
|
||||
La primera IA para
|
||||
<span className="text-primary-600 block">tu panadería</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-xl text-gray-600 mb-8 leading-relaxed max-w-3xl mx-auto">
|
||||
Transforma tus datos de ventas en predicciones precisas.
|
||||
Reduce desperdicios, maximiza ganancias y optimiza tu producción
|
||||
con inteligencia artificial diseñada para panaderías madrileñas.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center mb-16">
|
||||
<Link
|
||||
to="/register"
|
||||
className="w-full sm:w-auto bg-primary-500 text-white px-8 py-4 rounded-xl font-semibold text-lg hover:bg-primary-600 transition-all hover:shadow-xl hover:-translate-y-1 flex items-center justify-center group"
|
||||
>
|
||||
Empezar Gratis
|
||||
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
<Link
|
||||
to="/login"
|
||||
className="w-full sm:w-auto border-2 border-primary-500 text-primary-500 px-8 py-4 rounded-xl font-semibold text-lg hover:bg-primary-50 transition-all"
|
||||
>
|
||||
Iniciar Sesión
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="py-16 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-16">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
Todo lo que necesitas para optimizar tu panadería
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600">
|
||||
Tecnología de vanguardia diseñada específicamente para panaderías
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{[
|
||||
{
|
||||
icon: TrendingUp,
|
||||
title: "Predicciones Precisas",
|
||||
description: "IA que aprende de tu negocio único para predecir demanda con 87% de precisión",
|
||||
color: "bg-green-100 text-green-600"
|
||||
},
|
||||
{
|
||||
icon: Clock,
|
||||
title: "Reduce Desperdicios",
|
||||
description: "Disminuye hasta un 25% el desperdicio diario optimizando tu producción",
|
||||
color: "bg-blue-100 text-blue-600"
|
||||
},
|
||||
{
|
||||
icon: DollarSign,
|
||||
title: "Ahorra Dinero",
|
||||
description: "Ahorra hasta €500/mes reduciendo costos operativos y desperdicios",
|
||||
color: "bg-purple-100 text-purple-600"
|
||||
},
|
||||
{
|
||||
icon: BarChart3,
|
||||
title: "Analytics Avanzados",
|
||||
description: "Reportes detallados y insights que te ayudan a tomar mejores decisiones",
|
||||
color: "bg-orange-100 text-orange-600"
|
||||
}
|
||||
].map((feature, index) => (
|
||||
<div key={index} className="text-center">
|
||||
<div className={`inline-flex h-16 w-16 items-center justify-center rounded-xl ${feature.color} mb-6`}>
|
||||
<feature.icon className="h-8 w-8" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-3">{feature.title}</h3>
|
||||
<p className="text-gray-600 leading-relaxed">{feature.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="bg-primary-500 py-16">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h2 className="text-3xl font-bold text-white mb-4">
|
||||
¿Listo para revolucionar tu panadería?
|
||||
</h2>
|
||||
<p className="text-xl text-primary-100 mb-8 max-w-2xl mx-auto">
|
||||
Únete a más de 500 panaderías en Madrid que ya confían en PanIA para optimizar su negocio
|
||||
</p>
|
||||
<Link
|
||||
to="/register"
|
||||
className="inline-flex items-center bg-white text-primary-600 px-8 py-4 rounded-xl font-semibold text-lg hover:bg-gray-50 transition-all hover:shadow-xl hover:-translate-y-1"
|
||||
>
|
||||
Empezar Prueba Gratuita
|
||||
<ArrowRight className="ml-2 h-5 w-5" />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="bg-gray-900 text-white py-12">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center mb-4">
|
||||
<div className="h-8 w-8 bg-primary-500 rounded-lg flex items-center justify-center mr-3">
|
||||
<span className="text-white text-sm font-bold">🥖</span>
|
||||
</div>
|
||||
<span className="text-xl font-bold">PanIA</span>
|
||||
</div>
|
||||
<p className="text-gray-400 mb-4">
|
||||
Inteligencia Artificial para panaderías madrileñas
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
© 2024 PanIA. Todos los derechos reservados.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LandingPage;
|
||||
@@ -60,6 +60,62 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
completeStep,
|
||||
refreshProgress
|
||||
} = useOnboarding();
|
||||
|
||||
// Helper function to complete steps ensuring dependencies are met
|
||||
const completeStepWithDependencies = async (stepName: string, stepData: any = {}, allowDirectTrainingCompletion: boolean = false) => {
|
||||
try {
|
||||
console.log(`🔄 Completing step: ${stepName} with dependencies check`);
|
||||
|
||||
// Special case: Allow direct completion of training_completed when called from WebSocket
|
||||
if (stepName === 'training_completed' && allowDirectTrainingCompletion) {
|
||||
console.log(`🎯 Direct training completion via WebSocket - bypassing dependency checks`);
|
||||
await completeStep(stepName, stepData);
|
||||
return;
|
||||
}
|
||||
|
||||
// Define step dependencies
|
||||
const stepOrder = ['user_registered', 'bakery_registered', 'sales_data_uploaded', 'training_completed', 'dashboard_accessible'];
|
||||
const stepIndex = stepOrder.indexOf(stepName);
|
||||
|
||||
if (stepIndex === -1) {
|
||||
throw new Error(`Unknown step: ${stepName}`);
|
||||
}
|
||||
|
||||
// Complete all prerequisite steps first, EXCEPT training_completed
|
||||
// training_completed can only be marked when actual training finishes via WebSocket
|
||||
for (let i = 0; i < stepIndex; i++) {
|
||||
const prereqStep = stepOrder[i];
|
||||
const prereqCompleted = progress?.steps.find(s => s.step_name === prereqStep)?.completed;
|
||||
|
||||
if (!prereqCompleted) {
|
||||
// NEVER auto-complete training_completed as a prerequisite
|
||||
// It must be completed only when actual training finishes via WebSocket
|
||||
if (prereqStep === 'training_completed') {
|
||||
console.warn(`⚠️ Cannot auto-complete training_completed as prerequisite. Training must finish first.`);
|
||||
console.warn(`⚠️ Skipping prerequisite ${prereqStep} - it will be completed when training finishes`);
|
||||
continue; // Skip this prerequisite instead of throwing error
|
||||
}
|
||||
|
||||
console.log(`🔄 Completing prerequisite step: ${prereqStep}`);
|
||||
|
||||
// user_registered should have been completed during registration
|
||||
if (prereqStep === 'user_registered') {
|
||||
console.warn('⚠️ user_registered step not completed - this should have been done during registration');
|
||||
}
|
||||
|
||||
await completeStep(prereqStep, { user_id: user?.id });
|
||||
}
|
||||
}
|
||||
|
||||
// Now complete the target step
|
||||
console.log(`✅ Completing target step: ${stepName}`);
|
||||
await completeStep(stepName, stepData);
|
||||
|
||||
} catch (error) {
|
||||
console.warn(`Step completion error for ${stepName}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const [bakeryData, setBakeryData] = useState<BakeryData>({
|
||||
name: '',
|
||||
address: '',
|
||||
@@ -179,12 +235,14 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
}));
|
||||
|
||||
// Mark training step as completed in onboarding API
|
||||
completeStep('training_completed', {
|
||||
// Use allowDirectTrainingCompletion=true since this is triggered by WebSocket completion
|
||||
completeStepWithDependencies('training_completed', {
|
||||
training_completed_at: new Date().toISOString(),
|
||||
user_id: user?.id,
|
||||
tenant_id: tenantId
|
||||
}).catch(error => {
|
||||
// Failed to mark training as completed in API
|
||||
tenant_id: tenantId,
|
||||
completion_source: 'websocket_training_completion'
|
||||
}, true).catch(error => {
|
||||
console.error('Failed to mark training as completed in API:', error);
|
||||
});
|
||||
|
||||
// Show celebration and auto-advance to final step after 3 seconds
|
||||
@@ -245,81 +303,14 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
console.log('Connecting to training WebSocket:', { tenantId, trainingJobId, wsUrl });
|
||||
connect();
|
||||
|
||||
// Simple polling fallback for training completion detection (now that we fixed the 404 issue)
|
||||
const pollingInterval = setInterval(async () => {
|
||||
if (trainingProgress.status === 'running' || trainingProgress.status === 'pending') {
|
||||
try {
|
||||
// Check training job status via REST API as fallback
|
||||
const response = await fetch(`http://localhost:8000/api/v1/tenants/${tenantId}/training/jobs/${trainingJobId}/status`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`,
|
||||
'X-Tenant-ID': tenantId
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const jobStatus = await response.json();
|
||||
|
||||
// If the job is completed but we haven't received WebSocket notification
|
||||
if (jobStatus.status === 'completed' && (trainingProgress.status === 'running' || trainingProgress.status === 'pending')) {
|
||||
console.log('Training completed detected via REST polling fallback');
|
||||
|
||||
setTrainingProgress(prev => ({
|
||||
...prev,
|
||||
progress: 100,
|
||||
status: 'completed',
|
||||
currentStep: 'Entrenamiento completado',
|
||||
estimatedTimeRemaining: 0
|
||||
}));
|
||||
|
||||
// Mark training step as completed in onboarding API
|
||||
completeStep('training_completed', {
|
||||
training_completed_at: new Date().toISOString(),
|
||||
user_id: user?.id,
|
||||
tenant_id: tenantId,
|
||||
completion_detected_via: 'rest_polling_fallback'
|
||||
}).catch(error => {
|
||||
console.warn('Failed to mark training as completed in API:', error);
|
||||
});
|
||||
|
||||
// Show celebration and auto-advance to final step after 3 seconds
|
||||
toast.success('🎉 Training completed! Your AI model is ready to use.', {
|
||||
duration: 5000,
|
||||
icon: '🤖'
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
manualNavigation.current = true;
|
||||
setCurrentStep(4);
|
||||
}, 3000);
|
||||
|
||||
// Clear the polling interval
|
||||
clearInterval(pollingInterval);
|
||||
}
|
||||
|
||||
// If job failed, update status
|
||||
if (jobStatus.status === 'failed' && (trainingProgress.status === 'running' || trainingProgress.status === 'pending')) {
|
||||
console.log('Training failure detected via REST polling fallback');
|
||||
|
||||
setTrainingProgress(prev => ({
|
||||
...prev,
|
||||
status: 'failed',
|
||||
error: jobStatus.error_message || 'Error en el entrenamiento',
|
||||
currentStep: 'Error en el entrenamiento'
|
||||
}));
|
||||
|
||||
clearInterval(pollingInterval);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore polling errors to avoid noise
|
||||
console.debug('REST polling error (expected if training not started):', error);
|
||||
}
|
||||
} else if (trainingProgress.status === 'completed' || trainingProgress.status === 'failed') {
|
||||
// Clear polling if training is finished
|
||||
clearInterval(pollingInterval);
|
||||
}
|
||||
}, 15000); // Poll every 15 seconds (less aggressive than before)
|
||||
// ✅ DISABLED: Polling fallback now unnecessary since WebSocket is working properly
|
||||
// The WebSocket connection now handles all training status updates in real-time
|
||||
console.log('🚫 REST polling disabled - using WebSocket exclusively for training updates');
|
||||
|
||||
// Create dummy interval for cleanup compatibility (no actual polling)
|
||||
const pollingInterval = setInterval(() => {
|
||||
// No-op - REST polling is disabled, WebSocket handles all training updates
|
||||
}, 60000); // Set to 1 minute but does nothing
|
||||
|
||||
return () => {
|
||||
if (isConnected) {
|
||||
@@ -445,9 +436,9 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
storeTenantId(newTenant.id);
|
||||
}
|
||||
|
||||
// Mark step as completed in onboarding API (non-blocking)
|
||||
// Mark bakery_registered step as completed (dependencies will be handled automatically)
|
||||
try {
|
||||
await completeStep('bakery_registered', {
|
||||
await completeStepWithDependencies('bakery_registered', {
|
||||
bakery_name: bakeryData.name,
|
||||
bakery_address: bakeryData.address,
|
||||
business_type: 'bakery', // Default - will be auto-detected from sales data
|
||||
@@ -456,6 +447,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
user_id: user?.id
|
||||
});
|
||||
} catch (stepError) {
|
||||
console.warn('Step completion error:', stepError);
|
||||
// Don't throw here - step completion is not critical for UI flow
|
||||
}
|
||||
|
||||
@@ -500,7 +492,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
stepData.has_historical_data = bakeryData.hasHistoricalData;
|
||||
}
|
||||
|
||||
await completeStep(stepName, stepData);
|
||||
await completeStepWithDependencies(stepName, stepData);
|
||||
// Note: Not calling refreshProgress() here to avoid step reset
|
||||
|
||||
toast.success(`✅ Paso ${currentStep} completado`);
|
||||
@@ -589,7 +581,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
} else {
|
||||
try {
|
||||
// Mark final step as completed
|
||||
await completeStep('dashboard_accessible', {
|
||||
await completeStepWithDependencies('dashboard_accessible', {
|
||||
completion_time: new Date().toISOString(),
|
||||
user_id: user?.id,
|
||||
tenant_id: tenantId,
|
||||
@@ -724,7 +716,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
|
||||
tenantId={tenantId}
|
||||
onComplete={(result) => {
|
||||
// Mark sales data as uploaded and proceed to training
|
||||
completeStep('sales_data_uploaded', {
|
||||
completeStepWithDependencies('sales_data_uploaded', {
|
||||
smart_import: true,
|
||||
records_imported: result.successful_imports,
|
||||
import_job_id: result.import_job_id,
|
||||
|
||||
338
frontend/src/pages/settings/AccountSettingsPage.tsx
Normal file
338
frontend/src/pages/settings/AccountSettingsPage.tsx
Normal file
@@ -0,0 +1,338 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
User,
|
||||
Mail,
|
||||
Phone,
|
||||
Shield,
|
||||
Save,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Clock
|
||||
} from 'lucide-react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { RootState } from '../../store';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface UserProfile {
|
||||
fullName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
interface PasswordChange {
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
device: string;
|
||||
location: string;
|
||||
lastActive: string;
|
||||
isCurrent: boolean;
|
||||
}
|
||||
|
||||
const AccountSettingsPage: React.FC = () => {
|
||||
const { user } = useSelector((state: RootState) => state.auth);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [profile, setProfile] = useState<UserProfile>({
|
||||
fullName: user?.fullName || '',
|
||||
email: user?.email || '',
|
||||
phone: ''
|
||||
});
|
||||
|
||||
const [passwordForm, setPasswordForm] = useState<PasswordChange>({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
|
||||
const [activeSessions] = useState<Session[]>([
|
||||
{
|
||||
id: '1',
|
||||
device: 'Chrome en Windows',
|
||||
location: 'Madrid, España',
|
||||
lastActive: new Date().toISOString(),
|
||||
isCurrent: true
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
device: 'iPhone App',
|
||||
location: 'Madrid, España',
|
||||
lastActive: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
isCurrent: false
|
||||
}
|
||||
]);
|
||||
|
||||
const handleUpdateProfile = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
toast.success('Perfil actualizado exitosamente');
|
||||
} catch (error) {
|
||||
toast.error('Error al actualizar el perfil');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
|
||||
toast.error('Las contraseñas no coinciden');
|
||||
return;
|
||||
}
|
||||
|
||||
if (passwordForm.newPassword.length < 8) {
|
||||
toast.error('La contraseña debe tener al menos 8 caracteres');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
toast.success('Contraseña actualizada exitosamente');
|
||||
setPasswordForm({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error('Error al actualizar la contraseña');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTerminateSession = async (sessionId: string) => {
|
||||
if (window.confirm('¿Estás seguro de que quieres cerrar esta sesión?')) {
|
||||
try {
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
toast.success('Sesión cerrada exitosamente');
|
||||
} catch (error) {
|
||||
toast.error('Error al cerrar la sesión');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
const confirmation = window.prompt(
|
||||
'Esta acción eliminará permanentemente tu cuenta y todos los datos asociados.\n\n' +
|
||||
'Para confirmar, escribe "ELIMINAR CUENTA" exactamente como aparece:'
|
||||
);
|
||||
|
||||
if (confirmation === 'ELIMINAR CUENTA') {
|
||||
try {
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
toast.success('Cuenta eliminada exitosamente');
|
||||
// In real app, this would redirect to login
|
||||
} catch (error) {
|
||||
toast.error('Error al eliminar la cuenta');
|
||||
}
|
||||
} else if (confirmation !== null) {
|
||||
toast.error('Confirmación incorrecta. La cuenta no se ha eliminado.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<div className="space-y-8">
|
||||
{/* Profile Information */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-6">Información Personal</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<User className="inline h-4 w-4 mr-1" />
|
||||
Nombre completo
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={profile.fullName}
|
||||
onChange={(e) => setProfile(prev => ({ ...prev, fullName: e.target.value }))}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<Mail className="inline h-4 w-4 mr-1" />
|
||||
Correo electrónico
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={profile.email}
|
||||
onChange={(e) => setProfile(prev => ({ ...prev, email: e.target.value }))}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<Phone className="inline h-4 w-4 mr-1" />
|
||||
Teléfono
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={profile.phone}
|
||||
onChange={(e) => setProfile(prev => ({ ...prev, phone: e.target.value }))}
|
||||
placeholder="+34 600 000 000"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-6">
|
||||
<button
|
||||
onClick={handleUpdateProfile}
|
||||
disabled={isLoading}
|
||||
className="inline-flex items-center px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Guardar Cambios
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security Settings */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-6">
|
||||
<Shield className="inline h-5 w-5 mr-2" />
|
||||
Seguridad
|
||||
</h3>
|
||||
|
||||
{/* Change Password */}
|
||||
<div className="space-y-4 mb-8">
|
||||
<h4 className="font-medium text-gray-900">Cambiar Contraseña</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Contraseña actual
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={passwordForm.currentPassword}
|
||||
onChange={(e) => setPasswordForm(prev => ({ ...prev, currentPassword: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Nueva contraseña
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={passwordForm.newPassword}
|
||||
onChange={(e) => setPasswordForm(prev => ({ ...prev, newPassword: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Confirmar contraseña
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={passwordForm.confirmPassword}
|
||||
onChange={(e) => setPasswordForm(prev => ({ ...prev, confirmPassword: e.target.value }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleChangePassword}
|
||||
disabled={isLoading || !passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword}
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Actualizar Contraseña
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Active Sessions */}
|
||||
<div className="border-t pt-6">
|
||||
<h4 className="font-medium text-gray-900 mb-4">Sesiones Activas</h4>
|
||||
<div className="space-y-3">
|
||||
{activeSessions.map((session) => (
|
||||
<div key={session.id} className="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<div className="h-10 w-10 bg-gray-100 rounded-full flex items-center justify-center mr-3">
|
||||
<Shield className="h-5 w-5 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{session.device}</div>
|
||||
<div className="text-sm text-gray-500">{session.location}</div>
|
||||
<div className="flex items-center text-xs mt-1">
|
||||
{session.isCurrent ? (
|
||||
<>
|
||||
<CheckCircle className="h-3 w-3 text-green-600 mr-1" />
|
||||
<span className="text-green-600">Sesión actual</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Clock className="h-3 w-3 text-gray-400 mr-1" />
|
||||
<span className="text-gray-500">
|
||||
Último acceso: {new Date(session.lastActive).toLocaleDateString()}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!session.isCurrent && (
|
||||
<button
|
||||
onClick={() => handleTerminateSession(session.id)}
|
||||
className="text-red-600 hover:text-red-700 text-sm font-medium"
|
||||
>
|
||||
Cerrar sesión
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-red-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-red-600 mb-4">
|
||||
<AlertCircle className="inline h-5 w-5 mr-2" />
|
||||
Zona Peligrosa
|
||||
</h3>
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<h4 className="font-medium text-red-900 mb-2">Eliminar Cuenta</h4>
|
||||
<p className="text-red-800 text-sm mb-4">
|
||||
Esta acción eliminará permanentemente tu cuenta y todos los datos asociados.
|
||||
No se puede deshacer.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleDeleteAccount}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm"
|
||||
>
|
||||
Eliminar Cuenta
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountSettingsPage;
|
||||
421
frontend/src/pages/settings/BakeriesManagementPage.tsx
Normal file
421
frontend/src/pages/settings/BakeriesManagementPage.tsx
Normal file
@@ -0,0 +1,421 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Building,
|
||||
MapPin,
|
||||
Clock,
|
||||
Users,
|
||||
MoreVertical,
|
||||
Edit,
|
||||
Trash2,
|
||||
Settings,
|
||||
TrendingUp
|
||||
} from 'lucide-react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { RootState } from '../../store';
|
||||
import { setCurrentTenant } from '../../store/slices/tenantSlice';
|
||||
import { useTenant } from '../../api/hooks/useTenant';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface BakeryFormData {
|
||||
name: string;
|
||||
address: string;
|
||||
business_type: 'individual' | 'central_workshop';
|
||||
coordinates?: {
|
||||
lat: number;
|
||||
lng: number;
|
||||
};
|
||||
products: string[];
|
||||
settings?: {
|
||||
operating_hours?: {
|
||||
open: string;
|
||||
close: string;
|
||||
};
|
||||
operating_days?: number[];
|
||||
timezone?: string;
|
||||
currency?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const BakeriesManagementPage: React.FC = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { currentTenant } = useSelector((state: RootState) => state.tenant);
|
||||
|
||||
const {
|
||||
tenants,
|
||||
getUserTenants,
|
||||
createTenant,
|
||||
updateTenant,
|
||||
getTenantStats,
|
||||
isLoading,
|
||||
error
|
||||
} = useTenant();
|
||||
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingTenant, setEditingTenant] = useState<any>(null);
|
||||
const [formData, setFormData] = useState<BakeryFormData>({
|
||||
name: '',
|
||||
address: '',
|
||||
business_type: 'individual',
|
||||
products: ['Pan', 'Croissants', 'Magdalenas'],
|
||||
settings: {
|
||||
operating_hours: { open: '07:00', close: '20:00' },
|
||||
operating_days: [1, 2, 3, 4, 5, 6],
|
||||
timezone: 'Europe/Madrid',
|
||||
currency: 'EUR'
|
||||
}
|
||||
});
|
||||
const [tenantStats, setTenantStats] = useState<any>({});
|
||||
|
||||
useEffect(() => {
|
||||
getUserTenants();
|
||||
}, [getUserTenants]);
|
||||
|
||||
useEffect(() => {
|
||||
// Load stats for each tenant
|
||||
tenants.forEach(async (tenant) => {
|
||||
try {
|
||||
const stats = await getTenantStats(tenant.id);
|
||||
setTenantStats(prev => ({ ...prev, [tenant.id]: stats }));
|
||||
} catch (error) {
|
||||
console.error(`Failed to load stats for tenant ${tenant.id}:`, error);
|
||||
}
|
||||
});
|
||||
}, [tenants, getTenantStats]);
|
||||
|
||||
const handleCreateBakery = async () => {
|
||||
try {
|
||||
const newTenant = await createTenant(formData);
|
||||
toast.success('Panadería creada exitosamente');
|
||||
setShowCreateModal(false);
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
toast.error('Error al crear la panadería');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateBakery = async () => {
|
||||
if (!editingTenant) return;
|
||||
|
||||
try {
|
||||
await updateTenant(editingTenant.id, formData);
|
||||
toast.success('Panadería actualizada exitosamente');
|
||||
setEditingTenant(null);
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
toast.error('Error al actualizar la panadería');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSwitchTenant = (tenant: any) => {
|
||||
dispatch(setCurrentTenant(tenant));
|
||||
localStorage.setItem('selectedTenantId', tenant.id);
|
||||
toast.success(`Cambiado a ${tenant.name}`);
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
name: '',
|
||||
address: '',
|
||||
business_type: 'individual',
|
||||
products: ['Pan', 'Croissants', 'Magdalenas'],
|
||||
settings: {
|
||||
operating_hours: { open: '07:00', close: '20:00' },
|
||||
operating_days: [1, 2, 3, 4, 5, 6],
|
||||
timezone: 'Europe/Madrid',
|
||||
currency: 'EUR'
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const openEditModal = (tenant: any) => {
|
||||
setEditingTenant(tenant);
|
||||
setFormData({
|
||||
name: tenant.name,
|
||||
address: tenant.address,
|
||||
business_type: tenant.business_type,
|
||||
products: tenant.products || ['Pan', 'Croissants', 'Magdalenas'],
|
||||
settings: tenant.settings || {
|
||||
operating_hours: { open: '07:00', close: '20:00' },
|
||||
operating_days: [1, 2, 3, 4, 5, 6],
|
||||
timezone: 'Europe/Madrid',
|
||||
currency: 'EUR'
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getBakeryTypeInfo = (type: string) => {
|
||||
return type === 'individual'
|
||||
? { label: 'Panadería Individual', color: 'bg-blue-100 text-blue-800' }
|
||||
: { label: 'Obrador Central', color: 'bg-purple-100 text-purple-800' };
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Cargando panaderías...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Gestión de Panaderías</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Administra todas tus panaderías y puntos de venta
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="inline-flex items-center px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Nueva Panadería
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bakeries Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{tenants.map((tenant) => {
|
||||
const typeInfo = getBakeryTypeInfo(tenant.business_type);
|
||||
const stats = tenantStats[tenant.id];
|
||||
const isActive = currentTenant?.id === tenant.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tenant.id}
|
||||
className={`bg-white rounded-xl shadow-sm border-2 p-6 transition-all hover:shadow-md ${
|
||||
isActive ? 'border-primary-500 ring-2 ring-primary-100' : 'border-gray-200'
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center">
|
||||
<Building className="h-5 w-5 text-gray-600 mr-2" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
||||
{tenant.name}
|
||||
</h3>
|
||||
{isActive && (
|
||||
<span className="ml-2 px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">
|
||||
Activa
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded text-xs font-medium mt-2 ${typeInfo.color}`}>
|
||||
{typeInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<button className="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
<div className="flex items-center text-gray-600 mb-4">
|
||||
<MapPin className="h-4 w-4 mr-2 flex-shrink-0" />
|
||||
<span className="text-sm truncate">{tenant.address}</span>
|
||||
</div>
|
||||
|
||||
{/* Operating Hours */}
|
||||
{tenant.settings?.operating_hours && (
|
||||
<div className="flex items-center text-gray-600 mb-4">
|
||||
<Clock className="h-4 w-4 mr-2 flex-shrink-0" />
|
||||
<span className="text-sm">
|
||||
{tenant.settings.operating_hours.open} - {tenant.settings.operating_hours.close}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 gap-4 mb-4 pt-4 border-t border-gray-100">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{stats.total_sales || 0}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Ventas (mes)</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{stats.active_users || 0}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Usuarios</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex space-x-2">
|
||||
{!isActive && (
|
||||
<button
|
||||
onClick={() => handleSwitchTenant(tenant)}
|
||||
className="flex-1 px-3 py-2 bg-primary-600 text-white text-sm rounded-lg hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
Activar
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => openEditModal(tenant)}
|
||||
className="flex-1 px-3 py-2 bg-gray-100 text-gray-700 text-sm rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Editar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{(showCreateModal || editingTenant) && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl p-6 w-full max-w-2xl mx-4 max-h-screen overflow-y-auto">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-6">
|
||||
{editingTenant ? 'Editar Panadería' : 'Nueva Panadería'}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Basic Info */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Nombre
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="Mi Panadería"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Tipo de negocio
|
||||
</label>
|
||||
<select
|
||||
value={formData.business_type}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, business_type: e.target.value as any }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="individual">Panadería Individual</option>
|
||||
<option value="central_workshop">Obrador Central</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Dirección
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.address}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, address: e.target.value }))}
|
||||
placeholder="Calle Mayor, 123, Madrid"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Operating Hours */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Horarios de operación
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Apertura</label>
|
||||
<input
|
||||
type="time"
|
||||
value={formData.settings?.operating_hours?.open || '07:00'}
|
||||
onChange={(e) => setFormData(prev => ({
|
||||
...prev,
|
||||
settings: {
|
||||
...prev.settings,
|
||||
operating_hours: {
|
||||
...prev.settings?.operating_hours,
|
||||
open: e.target.value
|
||||
}
|
||||
}
|
||||
}))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-500 mb-1">Cierre</label>
|
||||
<input
|
||||
type="time"
|
||||
value={formData.settings?.operating_hours?.close || '20:00'}
|
||||
onChange={(e) => setFormData(prev => ({
|
||||
...prev,
|
||||
settings: {
|
||||
...prev.settings,
|
||||
operating_hours: {
|
||||
...prev.settings?.operating_hours,
|
||||
close: e.target.value
|
||||
}
|
||||
}
|
||||
}))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Products */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Productos
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{formData.products.map((product, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-3 py-1 rounded-full text-sm bg-primary-100 text-primary-800"
|
||||
>
|
||||
{product}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 mt-8">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCreateModal(false);
|
||||
setEditingTenant(null);
|
||||
resetForm();
|
||||
}}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={editingTenant ? handleUpdateBakery : handleCreateBakery}
|
||||
disabled={!formData.name || !formData.address}
|
||||
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{editingTenant ? 'Actualizar' : 'Crear'} Panadería
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BakeriesManagementPage;
|
||||
402
frontend/src/pages/settings/GeneralSettingsPage.tsx
Normal file
402
frontend/src/pages/settings/GeneralSettingsPage.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Globe,
|
||||
Clock,
|
||||
DollarSign,
|
||||
MapPin,
|
||||
Save,
|
||||
ChevronRight,
|
||||
Mail,
|
||||
Smartphone
|
||||
} from 'lucide-react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { RootState } from '../../store';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface GeneralSettings {
|
||||
language: string;
|
||||
timezone: string;
|
||||
currency: string;
|
||||
bakeryName: string;
|
||||
bakeryAddress: string;
|
||||
businessType: string;
|
||||
operatingHours: {
|
||||
open: string;
|
||||
close: string;
|
||||
};
|
||||
operatingDays: number[];
|
||||
}
|
||||
|
||||
interface NotificationSettings {
|
||||
emailNotifications: boolean;
|
||||
smsNotifications: boolean;
|
||||
dailyReports: boolean;
|
||||
weeklyReports: boolean;
|
||||
forecastAlerts: boolean;
|
||||
stockAlerts: boolean;
|
||||
orderReminders: boolean;
|
||||
}
|
||||
|
||||
const GeneralSettingsPage: React.FC = () => {
|
||||
const { user } = useSelector((state: RootState) => state.auth);
|
||||
const { currentTenant } = useSelector((state: RootState) => state.tenant);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [settings, setSettings] = useState<GeneralSettings>({
|
||||
language: 'es',
|
||||
timezone: 'Europe/Madrid',
|
||||
currency: 'EUR',
|
||||
bakeryName: currentTenant?.name || 'Mi Panadería',
|
||||
bakeryAddress: currentTenant?.address || '',
|
||||
businessType: currentTenant?.business_type || 'individual',
|
||||
operatingHours: {
|
||||
open: currentTenant?.settings?.operating_hours?.open || '07:00',
|
||||
close: currentTenant?.settings?.operating_hours?.close || '20:00'
|
||||
},
|
||||
operatingDays: currentTenant?.settings?.operating_days || [1, 2, 3, 4, 5, 6]
|
||||
});
|
||||
|
||||
const [notifications, setNotifications] = useState<NotificationSettings>({
|
||||
emailNotifications: true,
|
||||
smsNotifications: false,
|
||||
dailyReports: true,
|
||||
weeklyReports: true,
|
||||
forecastAlerts: true,
|
||||
stockAlerts: true,
|
||||
orderReminders: true
|
||||
});
|
||||
|
||||
const handleSaveSettings = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
toast.success('Configuración guardada exitosamente');
|
||||
} catch (error) {
|
||||
toast.error('Error al guardar la configuración');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const dayLabels = ['L', 'M', 'X', 'J', 'V', 'S', 'D'];
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
<div className="space-y-8">
|
||||
{/* Business Information */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-6">Información del Negocio</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Nombre de la panadería
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.bakeryName}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, bakeryName: e.target.value }))}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Tipo de negocio
|
||||
</label>
|
||||
<select
|
||||
value={settings.businessType}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, businessType: e.target.value }))}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="individual">Panadería Individual</option>
|
||||
<option value="central_workshop">Obrador Central</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Dirección
|
||||
</label>
|
||||
<div className="relative">
|
||||
<MapPin className="absolute left-3 top-3 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={settings.bakeryAddress}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, bakeryAddress: e.target.value }))}
|
||||
placeholder="Calle Mayor, 123, Madrid"
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Operating Hours */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-6">Horarios de Operación</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Hora de apertura
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={settings.operatingHours.open}
|
||||
onChange={(e) => setSettings(prev => ({
|
||||
...prev,
|
||||
operatingHours: { ...prev.operatingHours, open: e.target.value }
|
||||
}))}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Hora de cierre
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={settings.operatingHours.close}
|
||||
onChange={(e) => setSettings(prev => ({
|
||||
...prev,
|
||||
operatingHours: { ...prev.operatingHours, close: e.target.value }
|
||||
}))}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
Días de operación
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-2 sm:grid-cols-7">
|
||||
{dayLabels.map((day, index) => (
|
||||
<label key={day} className="flex items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.operatingDays.includes(index + 1)}
|
||||
onChange={(e) => {
|
||||
const dayNum = index + 1;
|
||||
setSettings(prev => ({
|
||||
...prev,
|
||||
operatingDays: e.target.checked
|
||||
? [...prev.operatingDays, dayNum]
|
||||
: prev.operatingDays.filter(d => d !== dayNum)
|
||||
}));
|
||||
}}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-10 h-10 bg-gray-200 peer-checked:bg-primary-500 peer-checked:text-white rounded-lg flex items-center justify-center font-medium text-sm cursor-pointer transition-colors">
|
||||
{day}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Regional Settings */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-6">Configuración Regional</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<Globe className="inline h-4 w-4 mr-1" />
|
||||
Idioma
|
||||
</label>
|
||||
<select
|
||||
value={settings.language}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, language: e.target.value }))}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="es">Español</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<Clock className="inline h-4 w-4 mr-1" />
|
||||
Zona horaria
|
||||
</label>
|
||||
<select
|
||||
value={settings.timezone}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, timezone: e.target.value }))}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="Europe/Madrid">Europa/Madrid (CET)</option>
|
||||
<option value="Europe/London">Europa/Londres (GMT)</option>
|
||||
<option value="America/New_York">América/Nueva York (EST)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
<DollarSign className="inline h-4 w-4 mr-1" />
|
||||
Moneda
|
||||
</label>
|
||||
<select
|
||||
value={settings.currency}
|
||||
onChange={(e) => setSettings(prev => ({ ...prev, currency: e.target.value }))}
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="EUR">Euro (€)</option>
|
||||
<option value="USD">Dólar americano ($)</option>
|
||||
<option value="GBP">Libra esterlina (£)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification Settings */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-6">Notificaciones</h3>
|
||||
|
||||
{/* Notification Channels */}
|
||||
<div className="space-y-4 mb-6">
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<Mail className="h-5 w-5 text-gray-600 mr-3" />
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Notificaciones por Email</div>
|
||||
<div className="text-sm text-gray-500">Recibe alertas y reportes por correo</div>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notifications.emailNotifications}
|
||||
onChange={(e) => setNotifications(prev => ({
|
||||
...prev,
|
||||
emailNotifications: e.target.checked
|
||||
}))}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<Smartphone className="h-5 w-5 text-gray-600 mr-3" />
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Notificaciones SMS</div>
|
||||
<div className="text-sm text-gray-500">Alertas urgentes por mensaje de texto</div>
|
||||
</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notifications.smsNotifications}
|
||||
onChange={(e) => setNotifications(prev => ({
|
||||
...prev,
|
||||
smsNotifications: e.target.checked
|
||||
}))}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification Types */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="font-medium text-gray-900">Tipos de Notificación</h4>
|
||||
{[
|
||||
{ key: 'dailyReports', label: 'Reportes Diarios', desc: 'Resumen diario de ventas y predicciones' },
|
||||
{ key: 'weeklyReports', label: 'Reportes Semanales', desc: 'Análisis semanal de rendimiento' },
|
||||
{ key: 'forecastAlerts', label: 'Alertas de Predicción', desc: 'Cambios significativos en demanda' },
|
||||
{ key: 'stockAlerts', label: 'Alertas de Stock', desc: 'Inventario bajo o próximos vencimientos' },
|
||||
{ key: 'orderReminders', label: 'Recordatorios de Pedidos', desc: 'Próximas entregas y fechas límite' }
|
||||
].map((item) => (
|
||||
<div key={item.key} className="flex items-center justify-between p-3 border border-gray-200 rounded-lg">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{item.label}</div>
|
||||
<div className="text-sm text-gray-500">{item.desc}</div>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notifications[item.key as keyof NotificationSettings] as boolean}
|
||||
onChange={(e) => setNotifications(prev => ({
|
||||
...prev,
|
||||
[item.key]: e.target.checked
|
||||
}))}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data Export */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-6">Exportar Datos</h3>
|
||||
<div className="space-y-3">
|
||||
<button className="w-full p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all text-left">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Exportar todas las predicciones</div>
|
||||
<div className="text-sm text-gray-500">Descargar historial completo en CSV</div>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button className="w-full p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all text-left">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Exportar datos de ventas</div>
|
||||
<div className="text-sm text-gray-500">Historial de ventas y análisis</div>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button className="w-full p-4 border border-gray-300 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-all text-left">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">Exportar configuración</div>
|
||||
<div className="text-sm text-gray-500">Respaldo de toda la configuración</div>
|
||||
</div>
|
||||
<ChevronRight className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleSaveSettings}
|
||||
disabled={isLoading}
|
||||
className="inline-flex items-center px-6 py-3 bg-primary-500 text-white rounded-xl hover:bg-primary-600 transition-all hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Guardando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Guardar Cambios
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GeneralSettingsPage;
|
||||
326
frontend/src/pages/settings/UsersManagementPage.tsx
Normal file
326
frontend/src/pages/settings/UsersManagementPage.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
UserPlus,
|
||||
Mail,
|
||||
Shield,
|
||||
MoreVertical,
|
||||
Trash2,
|
||||
Edit,
|
||||
Send,
|
||||
User,
|
||||
Crown,
|
||||
Briefcase,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { RootState } from '../../store';
|
||||
import { useTenant } from '../../api/hooks/useTenant';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface UserMember {
|
||||
id: string;
|
||||
user_id: string;
|
||||
role: 'owner' | 'admin' | 'manager' | 'worker';
|
||||
status: 'active' | 'pending' | 'inactive';
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string;
|
||||
last_active?: string;
|
||||
};
|
||||
joined_at: string;
|
||||
}
|
||||
|
||||
const UsersManagementPage: React.FC = () => {
|
||||
const { currentTenant } = useSelector((state: RootState) => state.tenant);
|
||||
const { user: currentUser } = useSelector((state: RootState) => state.auth);
|
||||
|
||||
const {
|
||||
members,
|
||||
getTenantMembers,
|
||||
inviteUser,
|
||||
removeMember,
|
||||
updateMemberRole,
|
||||
isLoading,
|
||||
error
|
||||
} = useTenant();
|
||||
|
||||
const [showInviteModal, setShowInviteModal] = useState(false);
|
||||
const [inviteForm, setInviteForm] = useState({
|
||||
email: '',
|
||||
role: 'worker' as const,
|
||||
message: ''
|
||||
});
|
||||
const [selectedMember, setSelectedMember] = useState<UserMember | null>(null);
|
||||
const [showRoleModal, setShowRoleModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentTenant) {
|
||||
getTenantMembers(currentTenant.id);
|
||||
}
|
||||
}, [currentTenant, getTenantMembers]);
|
||||
|
||||
const handleInviteUser = async () => {
|
||||
if (!currentTenant || !inviteForm.email) return;
|
||||
|
||||
try {
|
||||
await inviteUser(currentTenant.id, {
|
||||
email: inviteForm.email,
|
||||
role: inviteForm.role,
|
||||
message: inviteForm.message
|
||||
});
|
||||
|
||||
toast.success('Invitación enviada exitosamente');
|
||||
setShowInviteModal(false);
|
||||
setInviteForm({ email: '', role: 'worker', message: '' });
|
||||
} catch (error) {
|
||||
toast.error('Error al enviar la invitación');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (member: UserMember) => {
|
||||
if (!currentTenant) return;
|
||||
|
||||
if (window.confirm(`¿Estás seguro de que quieres eliminar a ${member.user.full_name}?`)) {
|
||||
try {
|
||||
await removeMember(currentTenant.id, member.user_id);
|
||||
toast.success('Usuario eliminado exitosamente');
|
||||
} catch (error) {
|
||||
toast.error('Error al eliminar usuario');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateRole = async (newRole: string) => {
|
||||
if (!currentTenant || !selectedMember) return;
|
||||
|
||||
try {
|
||||
await updateMemberRole(currentTenant.id, selectedMember.user_id, newRole);
|
||||
toast.success('Rol actualizado exitosamente');
|
||||
setShowRoleModal(false);
|
||||
setSelectedMember(null);
|
||||
} catch (error) {
|
||||
toast.error('Error al actualizar el rol');
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleInfo = (role: string) => {
|
||||
const roleMap = {
|
||||
owner: { label: 'Propietario', icon: Crown, color: 'text-yellow-600 bg-yellow-100' },
|
||||
admin: { label: 'Administrador', icon: Shield, color: 'text-red-600 bg-red-100' },
|
||||
manager: { label: 'Gerente', icon: Briefcase, color: 'text-blue-600 bg-blue-100' },
|
||||
worker: { label: 'Empleado', icon: User, color: 'text-green-600 bg-green-100' }
|
||||
};
|
||||
return roleMap[role as keyof typeof roleMap] || roleMap.worker;
|
||||
};
|
||||
|
||||
const getStatusInfo = (status: string) => {
|
||||
const statusMap = {
|
||||
active: { label: 'Activo', icon: CheckCircle, color: 'text-green-600' },
|
||||
pending: { label: 'Pendiente', icon: Clock, color: 'text-yellow-600' },
|
||||
inactive: { label: 'Inactivo', icon: AlertCircle, color: 'text-gray-600' }
|
||||
};
|
||||
return statusMap[status as keyof typeof statusMap] || statusMap.inactive;
|
||||
};
|
||||
|
||||
const canManageUser = (member: UserMember): boolean => {
|
||||
// Owners can manage everyone except other owners
|
||||
// Admins can manage managers and workers
|
||||
// Managers and workers can't manage anyone
|
||||
if (currentUser?.role === 'owner') {
|
||||
return member.role !== 'owner' || member.user_id === currentUser.id;
|
||||
}
|
||||
if (currentUser?.role === 'admin') {
|
||||
return ['manager', 'worker'].includes(member.role);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Cargando usuarios...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Gestión de Usuarios</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Administra los miembros de tu equipo en {currentTenant?.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowInviteModal(true)}
|
||||
className="inline-flex items-center px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
<UserPlus className="h-4 w-4 mr-2" />
|
||||
Invitar Usuario
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Users List */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
Miembros del Equipo ({members.length})
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-gray-200">
|
||||
{members.map((member) => {
|
||||
const roleInfo = getRoleInfo(member.role);
|
||||
const statusInfo = getStatusInfo(member.status);
|
||||
const RoleIcon = roleInfo.icon;
|
||||
const StatusIcon = statusInfo.icon;
|
||||
|
||||
return (
|
||||
<div key={member.id} className="px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center flex-1 min-w-0">
|
||||
{/* Avatar */}
|
||||
<div className="h-10 w-10 bg-primary-100 rounded-full flex items-center justify-center">
|
||||
<User className="h-5 w-5 text-primary-600" />
|
||||
</div>
|
||||
|
||||
{/* User Info */}
|
||||
<div className="ml-4 flex-1 min-w-0">
|
||||
<div className="flex items-center">
|
||||
<h4 className="text-sm font-medium text-gray-900 truncate">
|
||||
{member.user.full_name}
|
||||
</h4>
|
||||
{member.user_id === currentUser?.id && (
|
||||
<span className="ml-2 text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">
|
||||
Tú
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 truncate">
|
||||
{member.user.email}
|
||||
</p>
|
||||
<div className="flex items-center mt-1 space-x-4">
|
||||
<div className="flex items-center">
|
||||
<StatusIcon className={`h-3 w-3 mr-1 ${statusInfo.color}`} />
|
||||
<span className={`text-xs ${statusInfo.color}`}>
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
</div>
|
||||
{member.user.last_active && (
|
||||
<span className="text-xs text-gray-400">
|
||||
Último acceso: {new Date(member.user.last_active).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role Badge */}
|
||||
<div className="flex items-center ml-4">
|
||||
<div className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${roleInfo.color}`}>
|
||||
<RoleIcon className="h-3 w-3 mr-1" />
|
||||
{roleInfo.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{canManageUser(member) && (
|
||||
<div className="flex items-center ml-4">
|
||||
<div className="relative">
|
||||
<button className="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Dropdown would go here */}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invite User Modal */}
|
||||
{showInviteModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl p-6 w-full max-w-md mx-4">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||
Invitar Nuevo Usuario
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Correo electrónico
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={inviteForm.email}
|
||||
onChange={(e) => setInviteForm(prev => ({ ...prev, email: e.target.value }))}
|
||||
placeholder="usuario@ejemplo.com"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Rol
|
||||
</label>
|
||||
<select
|
||||
value={inviteForm.role}
|
||||
onChange={(e) => setInviteForm(prev => ({ ...prev, role: e.target.value as any }))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="worker">Empleado</option>
|
||||
<option value="manager">Gerente</option>
|
||||
{currentUser?.role === 'owner' && <option value="admin">Administrador</option>}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Mensaje personal (opcional)
|
||||
</label>
|
||||
<textarea
|
||||
value={inviteForm.message}
|
||||
onChange={(e) => setInviteForm(prev => ({ ...prev, message: e.target.value }))}
|
||||
placeholder="Mensaje de bienvenida..."
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<button
|
||||
onClick={() => setShowInviteModal(false)}
|
||||
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
onClick={handleInviteUser}
|
||||
disabled={!inviteForm.email}
|
||||
className="inline-flex items-center px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
Enviar Invitación
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsersManagementPage;
|
||||
Reference in New Issue
Block a user