New Frontend

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,120 @@
import React, { useState } from 'react';
import { TrendingUp, DollarSign, ShoppingCart, Calendar } from 'lucide-react';
import { useBakeryType } from '../../hooks/useBakeryType';
const SalesAnalyticsPage: React.FC = () => {
const { isIndividual, isCentral } = useBakeryType();
const [timeRange, setTimeRange] = useState('week');
return (
<div className="p-6 max-w-7xl mx-auto">
<div className="mb-8">
<h1 className="text-2xl font-bold text-gray-900">Análisis de Ventas</h1>
<p className="text-gray-600 mt-1">
{isIndividual
? 'Analiza el rendimiento de ventas de tu panadería'
: 'Analiza el rendimiento de ventas de todos tus puntos de venta'
}
</p>
</div>
{/* Time Range Selector */}
<div className="mb-6">
<div className="flex space-x-2">
{['day', 'week', 'month', 'quarter'].map((range) => (
<button
key={range}
onClick={() => setTimeRange(range)}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
timeRange === range
? 'bg-primary-100 text-primary-700'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
}`}
>
{range === 'day' && 'Hoy'}
{range === 'week' && 'Esta Semana'}
{range === 'month' && 'Este Mes'}
{range === 'quarter' && 'Este Trimestre'}
</button>
))}
</div>
</div>
{/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex items-center">
<div className="h-8 w-8 bg-green-100 rounded-lg flex items-center justify-center">
<DollarSign className="h-4 w-4 text-green-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Ingresos Totales</p>
<p className="text-2xl font-bold text-gray-900">2,847</p>
</div>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex items-center">
<div className="h-8 w-8 bg-blue-100 rounded-lg flex items-center justify-center">
<ShoppingCart className="h-4 w-4 text-blue-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">
{isIndividual ? 'Productos Vendidos' : 'Productos Distribuidos'}
</p>
<p className="text-2xl font-bold text-gray-900">1,429</p>
</div>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex items-center">
<div className="h-8 w-8 bg-purple-100 rounded-lg flex items-center justify-center">
<TrendingUp className="h-4 w-4 text-purple-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Crecimiento</p>
<p className="text-2xl font-bold text-gray-900">+12.5%</p>
</div>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex items-center">
<div className="h-8 w-8 bg-yellow-100 rounded-lg flex items-center justify-center">
<Calendar className="h-4 w-4 text-yellow-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-gray-600">Días Activos</p>
<p className="text-2xl font-bold text-gray-900">6/7</p>
</div>
</div>
</div>
</div>
{/* Charts placeholder */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Tendencia de Ventas
</h3>
<div className="h-64 bg-gray-50 rounded-lg flex items-center justify-center">
<p className="text-gray-500">Gráfico de tendencias aquí</p>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
{isIndividual ? 'Productos Más Vendidos' : 'Productos Más Distribuidos'}
</h3>
<div className="h-64 bg-gray-50 rounded-lg flex items-center justify-center">
<p className="text-gray-500">Gráfico de productos aquí</p>
</div>
</div>
</div>
</div>
);
};
export default SalesAnalyticsPage;

View File

@@ -1,6 +1,9 @@
import React, { useState } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { useDispatch } from 'react-redux';
import { Eye, EyeOff, Loader2 } from 'lucide-react';
import toast from 'react-hot-toast';
import { loginSuccess } from '../../store/slices/authSlice';
import {
useAuth,
@@ -8,8 +11,7 @@ import {
} from '../../api';
interface LoginPageProps {
onLogin: (user: any, token: string) => void;
onNavigateToRegister: () => void;
// No props needed with React Router
}
interface LoginForm {
@@ -17,11 +19,15 @@ interface LoginForm {
password: string;
}
const LoginPage: React.FC<LoginPageProps> = ({ onLogin, onNavigateToRegister }) => {
const LoginPage: React.FC<LoginPageProps> = () => {
const navigate = useNavigate();
const location = useLocation();
const dispatch = useDispatch();
const { login, isLoading, isAuthenticated } = useAuth();
// Get the intended destination from state, default to app
const from = (location.state as any)?.from?.pathname || '/app';
const [formData, setFormData] = useState<LoginForm>({
email: '',
password: ''
@@ -70,7 +76,13 @@ const LoginPage: React.FC<LoginPageProps> = ({ onLogin, onNavigateToRegister })
const token = localStorage.getItem('auth_token');
if (userData && token) {
onLogin(JSON.parse(userData), token);
const user = JSON.parse(userData);
// Set auth state
dispatch(loginSuccess({ user, token }));
// Navigate to intended destination
navigate(from, { replace: true });
}
} catch (error: any) {
@@ -245,12 +257,12 @@ const LoginPage: React.FC<LoginPageProps> = ({ onLogin, onNavigateToRegister })
<div className="mt-6 text-center">
<p className="text-sm text-gray-600">
¿No tienes una cuenta?{' '}
<button
onClick={onNavigateToRegister}
<Link
to="/register"
className="font-medium text-primary-600 hover:text-primary-500 transition-colors"
>
Regístrate gratis
</button>
</Link>
</p>
</div>
</div>

View File

@@ -0,0 +1,421 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useDispatch } from 'react-redux';
import { Eye, EyeOff, Loader2, User, Mail, Lock } from 'lucide-react';
import toast from 'react-hot-toast';
import { loginSuccess } from '../../store/slices/authSlice';
import { authService } from '../../api/services/auth.service';
import { onboardingService } from '../../api/services/onboarding.service';
import type { RegisterRequest } from '../../api/types/auth';
interface RegisterForm {
fullName: string;
email: string;
password: string;
confirmPassword: string;
acceptTerms: boolean;
}
const RegisterPage: React.FC = () => {
const navigate = useNavigate();
const dispatch = useDispatch();
const [formData, setFormData] = useState<RegisterForm>({
fullName: '',
email: '',
password: '',
confirmPassword: '',
acceptTerms: false
});
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [errors, setErrors] = useState<Partial<RegisterForm>>({});
const validateForm = (): boolean => {
const newErrors: Partial<RegisterForm> = {};
if (!formData.fullName.trim()) {
newErrors.fullName = 'El nombre es obligatorio';
}
if (!formData.email) {
newErrors.email = 'El email es obligatorio';
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
newErrors.email = 'El email no es válido';
}
if (!formData.password) {
newErrors.password = 'La contraseña es obligatoria';
} else if (formData.password.length < 8) {
newErrors.password = 'La contraseña debe tener al menos 8 caracteres';
}
if (formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = 'Las contraseñas no coinciden';
}
if (!formData.acceptTerms) {
newErrors.acceptTerms = 'Debes aceptar los términos y condiciones';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) return;
setIsLoading(true);
try {
// Prepare registration data
const registrationData: RegisterRequest = {
email: formData.email,
password: formData.password,
full_name: formData.fullName,
role: 'admin',
language: 'es'
};
// Call real authentication API
const response = await authService.register(registrationData);
// Extract user data from response
const userData = response.user;
if (!userData) {
throw new Error('No se recibieron datos del usuario');
}
// Convert API response to internal format
const user = {
id: userData.id,
email: userData.email,
fullName: userData.full_name,
role: userData.role || 'admin',
isOnboardingComplete: false, // New users need onboarding
tenant_id: userData.tenant_id
};
// Store tokens in localStorage
localStorage.setItem('auth_token', response.access_token);
if (response.refresh_token) {
localStorage.setItem('refresh_token', response.refresh_token);
}
localStorage.setItem('user_data', JSON.stringify(user));
// Set auth state
dispatch(loginSuccess({ user, token: response.access_token }));
// Mark user_registered step as completed in onboarding
try {
await onboardingService.completeStep('user_registered', {
user_id: userData.id,
registration_completed_at: new Date().toISOString(),
registration_method: 'web_form'
});
console.log('✅ user_registered step marked as completed');
} catch (onboardingError) {
console.warn('Failed to mark user_registered step as completed:', onboardingError);
// Don't block the flow if onboarding step completion fails
}
toast.success('¡Cuenta creada exitosamente!');
// Navigate to onboarding
navigate('/app/onboarding');
} catch (error: any) {
console.error('Registration error:', error);
const errorMessage = error?.response?.data?.detail || error?.message || 'Error al crear la cuenta';
toast.error(errorMessage);
} finally {
setIsLoading(false);
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
// Clear error when user starts typing
if (errors[name as keyof RegisterForm]) {
setErrors(prev => ({
...prev,
[name]: undefined
}));
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-orange-100 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
{/* Logo and Header */}
<div className="text-center">
<div className="mx-auto h-20 w-20 bg-primary-500 rounded-2xl flex items-center justify-center mb-6 shadow-lg">
<span className="text-white text-2xl font-bold">🥖</span>
</div>
<h1 className="text-4xl font-bold text-gray-900 mb-2">
Únete a PanIA
</h1>
<p className="text-gray-600 text-lg">
Crea tu cuenta y comienza a optimizar tu panadería
</p>
</div>
{/* Registration Form */}
<div className="bg-white py-8 px-6 shadow-strong rounded-3xl">
<form className="space-y-6" onSubmit={handleSubmit}>
{/* Full Name Field */}
<div>
<label htmlFor="fullName" className="block text-sm font-medium text-gray-700 mb-2">
Nombre completo
</label>
<div className="relative">
<User className="absolute left-3 top-3 h-5 w-5 text-gray-400" />
<input
id="fullName"
name="fullName"
type="text"
required
value={formData.fullName}
onChange={handleInputChange}
className={`
appearance-none relative block w-full pl-10 pr-4 py-3 border rounded-xl
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
focus:ring-primary-500 focus:border-primary-500 focus:z-10
transition-all duration-200
${errors.fullName
? 'border-red-300 bg-red-50'
: 'border-gray-300 hover:border-gray-400'
}
`}
placeholder="Juan Pérez"
/>
</div>
{errors.fullName && (
<p className="mt-1 text-sm text-red-600">{errors.fullName}</p>
)}
</div>
{/* Email Field */}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
Correo electrónico
</label>
<div className="relative">
<Mail className="absolute left-3 top-3 h-5 w-5 text-gray-400" />
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
value={formData.email}
onChange={handleInputChange}
className={`
appearance-none relative block w-full pl-10 pr-4 py-3 border rounded-xl
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
focus:ring-primary-500 focus:border-primary-500 focus:z-10
transition-all duration-200
${errors.email
? 'border-red-300 bg-red-50'
: 'border-gray-300 hover:border-gray-400'
}
`}
placeholder="tu@panaderia.com"
/>
</div>
{errors.email && (
<p className="mt-1 text-sm text-red-600">{errors.email}</p>
)}
</div>
{/* Password Field */}
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
Contraseña
</label>
<div className="relative">
<Lock className="absolute left-3 top-3 h-5 w-5 text-gray-400" />
<input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
autoComplete="new-password"
required
value={formData.password}
onChange={handleInputChange}
className={`
appearance-none relative block w-full pl-10 pr-12 py-3 border rounded-xl
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
focus:ring-primary-500 focus:border-primary-500 focus:z-10
transition-all duration-200
${errors.password
? 'border-red-300 bg-red-50'
: 'border-gray-300 hover:border-gray-400'
}
`}
placeholder="••••••••"
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className="h-5 w-5 text-gray-400 hover:text-gray-600" />
) : (
<Eye className="h-5 w-5 text-gray-400 hover:text-gray-600" />
)}
</button>
</div>
{errors.password && (
<p className="mt-1 text-sm text-red-600">{errors.password}</p>
)}
</div>
{/* Confirm Password Field */}
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-2">
Confirmar contraseña
</label>
<div className="relative">
<Lock className="absolute left-3 top-3 h-5 w-5 text-gray-400" />
<input
id="confirmPassword"
name="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
autoComplete="new-password"
required
value={formData.confirmPassword}
onChange={handleInputChange}
className={`
appearance-none relative block w-full pl-10 pr-12 py-3 border rounded-xl
placeholder-gray-400 text-gray-900 focus:outline-none focus:ring-2
focus:ring-primary-500 focus:border-primary-500 focus:z-10
transition-all duration-200
${errors.confirmPassword
? 'border-red-300 bg-red-50'
: 'border-gray-300 hover:border-gray-400'
}
`}
placeholder="••••••••"
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
{showConfirmPassword ? (
<EyeOff className="h-5 w-5 text-gray-400 hover:text-gray-600" />
) : (
<Eye className="h-5 w-5 text-gray-400 hover:text-gray-600" />
)}
</button>
</div>
{errors.confirmPassword && (
<p className="mt-1 text-sm text-red-600">{errors.confirmPassword}</p>
)}
</div>
{/* Terms and Conditions */}
<div>
<div className="flex items-center">
<input
id="acceptTerms"
name="acceptTerms"
type="checkbox"
checked={formData.acceptTerms}
onChange={handleInputChange}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label htmlFor="acceptTerms" className="ml-2 block text-sm text-gray-700">
Acepto los{' '}
<a href="#" className="text-primary-600 hover:text-primary-500">
términos y condiciones
</a>{' '}
y la{' '}
<a href="#" className="text-primary-600 hover:text-primary-500">
política de privacidad
</a>
</label>
</div>
{errors.acceptTerms && (
<p className="mt-1 text-sm text-red-600">{errors.acceptTerms}</p>
)}
</div>
{/* Submit Button */}
<div>
<button
type="submit"
disabled={isLoading}
className={`
group relative w-full flex justify-center py-3 px-4 border border-transparent
text-sm font-medium rounded-xl text-white transition-all duration-200
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500
${isLoading
? 'bg-gray-400 cursor-not-allowed'
: 'bg-primary-500 hover:bg-primary-600 hover:shadow-lg transform hover:-translate-y-0.5'
}
`}
>
{isLoading ? (
<>
<Loader2 className="h-5 w-5 mr-2 animate-spin" />
Creando cuenta...
</>
) : (
'Crear cuenta'
)}
</button>
</div>
</form>
{/* Login Link */}
<div className="mt-6 text-center">
<p className="text-sm text-gray-600">
¿Ya tienes una cuenta?{' '}
<Link
to="/login"
className="font-medium text-primary-600 hover:text-primary-500 transition-colors"
>
Inicia sesión
</Link>
</p>
</div>
</div>
{/* Features Preview */}
<div className="text-center">
<p className="text-xs text-gray-500 mb-4">
Prueba gratuita de 14 días No se requiere tarjeta de crédito
</p>
<div className="flex justify-center space-x-6 text-xs text-gray-400">
<div className="flex items-center">
<span className="w-2 h-2 bg-success-500 rounded-full mr-2"></span>
Setup en 5 minutos
</div>
<div className="flex items-center">
<span className="w-2 h-2 bg-success-500 rounded-full mr-2"></span>
Soporte incluido
</div>
<div className="flex items-center">
<span className="w-2 h-2 bg-success-500 rounded-full mr-2"></span>
Cancela cuando quieras
</div>
</div>
</div>
</div>
</div>
);
};
export default RegisterPage;

View File

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

View File

@@ -0,0 +1,168 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { ArrowRight, TrendingUp, Clock, DollarSign, BarChart3 } from 'lucide-react';
const LandingPage: React.FC = () => {
return (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-orange-100">
{/* Header */}
<header className="bg-white shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center py-6">
<div className="flex items-center">
<div className="h-8 w-8 bg-primary-500 rounded-lg flex items-center justify-center mr-3">
<span className="text-white text-sm font-bold">🥖</span>
</div>
<span className="text-xl font-bold text-gray-900">PanIA</span>
</div>
<div className="flex items-center space-x-4">
<Link
to="/login"
className="text-gray-600 hover:text-gray-900 transition-colors"
>
Iniciar sesión
</Link>
<Link
to="/register"
className="bg-primary-500 text-white px-6 py-2 rounded-lg hover:bg-primary-600 transition-all hover:shadow-lg"
>
Prueba gratis
</Link>
</div>
</div>
</div>
</header>
{/* Hero Section */}
<section className="py-20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center">
<div className="inline-flex items-center bg-primary-100 text-primary-800 px-4 py-2 rounded-full text-sm font-medium mb-6">
IA líder para panaderías en Madrid
</div>
<h1 className="text-5xl lg:text-6xl font-bold text-gray-900 mb-6 leading-tight">
La primera IA para
<span className="text-primary-600 block">tu panadería</span>
</h1>
<p className="text-xl text-gray-600 mb-8 leading-relaxed max-w-3xl mx-auto">
Transforma tus datos de ventas en predicciones precisas.
Reduce desperdicios, maximiza ganancias y optimiza tu producción
con inteligencia artificial diseñada para panaderías madrileñas.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center mb-16">
<Link
to="/register"
className="w-full sm:w-auto bg-primary-500 text-white px-8 py-4 rounded-xl font-semibold text-lg hover:bg-primary-600 transition-all hover:shadow-xl hover:-translate-y-1 flex items-center justify-center group"
>
Empezar Gratis
<ArrowRight className="ml-2 h-5 w-5 group-hover:translate-x-1 transition-transform" />
</Link>
<Link
to="/login"
className="w-full sm:w-auto border-2 border-primary-500 text-primary-500 px-8 py-4 rounded-xl font-semibold text-lg hover:bg-primary-50 transition-all"
>
Iniciar Sesión
</Link>
</div>
</div>
</div>
</section>
{/* Features Section */}
<section className="py-16 bg-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center mb-16">
<h2 className="text-3xl font-bold text-gray-900 mb-4">
Todo lo que necesitas para optimizar tu panadería
</h2>
<p className="text-xl text-gray-600">
Tecnología de vanguardia diseñada específicamente para panaderías
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
{[
{
icon: TrendingUp,
title: "Predicciones Precisas",
description: "IA que aprende de tu negocio único para predecir demanda con 87% de precisión",
color: "bg-green-100 text-green-600"
},
{
icon: Clock,
title: "Reduce Desperdicios",
description: "Disminuye hasta un 25% el desperdicio diario optimizando tu producción",
color: "bg-blue-100 text-blue-600"
},
{
icon: DollarSign,
title: "Ahorra Dinero",
description: "Ahorra hasta €500/mes reduciendo costos operativos y desperdicios",
color: "bg-purple-100 text-purple-600"
},
{
icon: BarChart3,
title: "Analytics Avanzados",
description: "Reportes detallados y insights que te ayudan a tomar mejores decisiones",
color: "bg-orange-100 text-orange-600"
}
].map((feature, index) => (
<div key={index} className="text-center">
<div className={`inline-flex h-16 w-16 items-center justify-center rounded-xl ${feature.color} mb-6`}>
<feature.icon className="h-8 w-8" />
</div>
<h3 className="text-xl font-semibold text-gray-900 mb-3">{feature.title}</h3>
<p className="text-gray-600 leading-relaxed">{feature.description}</p>
</div>
))}
</div>
</div>
</section>
{/* CTA Section */}
<section className="bg-primary-500 py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 className="text-3xl font-bold text-white mb-4">
¿Listo para revolucionar tu panadería?
</h2>
<p className="text-xl text-primary-100 mb-8 max-w-2xl mx-auto">
Únete a más de 500 panaderías en Madrid que ya confían en PanIA para optimizar su negocio
</p>
<Link
to="/register"
className="inline-flex items-center bg-white text-primary-600 px-8 py-4 rounded-xl font-semibold text-lg hover:bg-gray-50 transition-all hover:shadow-xl hover:-translate-y-1"
>
Empezar Prueba Gratuita
<ArrowRight className="ml-2 h-5 w-5" />
</Link>
</div>
</section>
{/* Footer */}
<footer className="bg-gray-900 text-white py-12">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center">
<div className="flex items-center justify-center mb-4">
<div className="h-8 w-8 bg-primary-500 rounded-lg flex items-center justify-center mr-3">
<span className="text-white text-sm font-bold">🥖</span>
</div>
<span className="text-xl font-bold">PanIA</span>
</div>
<p className="text-gray-400 mb-4">
Inteligencia Artificial para panaderías madrileñas
</p>
<p className="text-sm text-gray-500">
© 2024 PanIA. Todos los derechos reservados.
</p>
</div>
</div>
</footer>
</div>
);
};
export default LandingPage;

View File

@@ -60,6 +60,62 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
completeStep,
refreshProgress
} = useOnboarding();
// Helper function to complete steps ensuring dependencies are met
const completeStepWithDependencies = async (stepName: string, stepData: any = {}, allowDirectTrainingCompletion: boolean = false) => {
try {
console.log(`🔄 Completing step: ${stepName} with dependencies check`);
// Special case: Allow direct completion of training_completed when called from WebSocket
if (stepName === 'training_completed' && allowDirectTrainingCompletion) {
console.log(`🎯 Direct training completion via WebSocket - bypassing dependency checks`);
await completeStep(stepName, stepData);
return;
}
// Define step dependencies
const stepOrder = ['user_registered', 'bakery_registered', 'sales_data_uploaded', 'training_completed', 'dashboard_accessible'];
const stepIndex = stepOrder.indexOf(stepName);
if (stepIndex === -1) {
throw new Error(`Unknown step: ${stepName}`);
}
// Complete all prerequisite steps first, EXCEPT training_completed
// training_completed can only be marked when actual training finishes via WebSocket
for (let i = 0; i < stepIndex; i++) {
const prereqStep = stepOrder[i];
const prereqCompleted = progress?.steps.find(s => s.step_name === prereqStep)?.completed;
if (!prereqCompleted) {
// NEVER auto-complete training_completed as a prerequisite
// It must be completed only when actual training finishes via WebSocket
if (prereqStep === 'training_completed') {
console.warn(`⚠️ Cannot auto-complete training_completed as prerequisite. Training must finish first.`);
console.warn(`⚠️ Skipping prerequisite ${prereqStep} - it will be completed when training finishes`);
continue; // Skip this prerequisite instead of throwing error
}
console.log(`🔄 Completing prerequisite step: ${prereqStep}`);
// user_registered should have been completed during registration
if (prereqStep === 'user_registered') {
console.warn('⚠️ user_registered step not completed - this should have been done during registration');
}
await completeStep(prereqStep, { user_id: user?.id });
}
}
// Now complete the target step
console.log(`✅ Completing target step: ${stepName}`);
await completeStep(stepName, stepData);
} catch (error) {
console.warn(`Step completion error for ${stepName}:`, error);
throw error;
}
};
const [bakeryData, setBakeryData] = useState<BakeryData>({
name: '',
address: '',
@@ -179,12 +235,14 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
}));
// Mark training step as completed in onboarding API
completeStep('training_completed', {
// Use allowDirectTrainingCompletion=true since this is triggered by WebSocket completion
completeStepWithDependencies('training_completed', {
training_completed_at: new Date().toISOString(),
user_id: user?.id,
tenant_id: tenantId
}).catch(error => {
// Failed to mark training as completed in API
tenant_id: tenantId,
completion_source: 'websocket_training_completion'
}, true).catch(error => {
console.error('Failed to mark training as completed in API:', error);
});
// Show celebration and auto-advance to final step after 3 seconds
@@ -245,81 +303,14 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
console.log('Connecting to training WebSocket:', { tenantId, trainingJobId, wsUrl });
connect();
// Simple polling fallback for training completion detection (now that we fixed the 404 issue)
const pollingInterval = setInterval(async () => {
if (trainingProgress.status === 'running' || trainingProgress.status === 'pending') {
try {
// Check training job status via REST API as fallback
const response = await fetch(`http://localhost:8000/api/v1/tenants/${tenantId}/training/jobs/${trainingJobId}/status`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`,
'X-Tenant-ID': tenantId
}
});
if (response.ok) {
const jobStatus = await response.json();
// If the job is completed but we haven't received WebSocket notification
if (jobStatus.status === 'completed' && (trainingProgress.status === 'running' || trainingProgress.status === 'pending')) {
console.log('Training completed detected via REST polling fallback');
setTrainingProgress(prev => ({
...prev,
progress: 100,
status: 'completed',
currentStep: 'Entrenamiento completado',
estimatedTimeRemaining: 0
}));
// Mark training step as completed in onboarding API
completeStep('training_completed', {
training_completed_at: new Date().toISOString(),
user_id: user?.id,
tenant_id: tenantId,
completion_detected_via: 'rest_polling_fallback'
}).catch(error => {
console.warn('Failed to mark training as completed in API:', error);
});
// Show celebration and auto-advance to final step after 3 seconds
toast.success('🎉 Training completed! Your AI model is ready to use.', {
duration: 5000,
icon: '🤖'
});
setTimeout(() => {
manualNavigation.current = true;
setCurrentStep(4);
}, 3000);
// Clear the polling interval
clearInterval(pollingInterval);
}
// If job failed, update status
if (jobStatus.status === 'failed' && (trainingProgress.status === 'running' || trainingProgress.status === 'pending')) {
console.log('Training failure detected via REST polling fallback');
setTrainingProgress(prev => ({
...prev,
status: 'failed',
error: jobStatus.error_message || 'Error en el entrenamiento',
currentStep: 'Error en el entrenamiento'
}));
clearInterval(pollingInterval);
}
}
} catch (error) {
// Ignore polling errors to avoid noise
console.debug('REST polling error (expected if training not started):', error);
}
} else if (trainingProgress.status === 'completed' || trainingProgress.status === 'failed') {
// Clear polling if training is finished
clearInterval(pollingInterval);
}
}, 15000); // Poll every 15 seconds (less aggressive than before)
// ✅ DISABLED: Polling fallback now unnecessary since WebSocket is working properly
// The WebSocket connection now handles all training status updates in real-time
console.log('🚫 REST polling disabled - using WebSocket exclusively for training updates');
// Create dummy interval for cleanup compatibility (no actual polling)
const pollingInterval = setInterval(() => {
// No-op - REST polling is disabled, WebSocket handles all training updates
}, 60000); // Set to 1 minute but does nothing
return () => {
if (isConnected) {
@@ -445,9 +436,9 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
storeTenantId(newTenant.id);
}
// Mark step as completed in onboarding API (non-blocking)
// Mark bakery_registered step as completed (dependencies will be handled automatically)
try {
await completeStep('bakery_registered', {
await completeStepWithDependencies('bakery_registered', {
bakery_name: bakeryData.name,
bakery_address: bakeryData.address,
business_type: 'bakery', // Default - will be auto-detected from sales data
@@ -456,6 +447,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
user_id: user?.id
});
} catch (stepError) {
console.warn('Step completion error:', stepError);
// Don't throw here - step completion is not critical for UI flow
}
@@ -500,7 +492,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
stepData.has_historical_data = bakeryData.hasHistoricalData;
}
await completeStep(stepName, stepData);
await completeStepWithDependencies(stepName, stepData);
// Note: Not calling refreshProgress() here to avoid step reset
toast.success(`✅ Paso ${currentStep} completado`);
@@ -589,7 +581,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
} else {
try {
// Mark final step as completed
await completeStep('dashboard_accessible', {
await completeStepWithDependencies('dashboard_accessible', {
completion_time: new Date().toISOString(),
user_id: user?.id,
tenant_id: tenantId,
@@ -724,7 +716,7 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
tenantId={tenantId}
onComplete={(result) => {
// Mark sales data as uploaded and proceed to training
completeStep('sales_data_uploaded', {
completeStepWithDependencies('sales_data_uploaded', {
smart_import: true,
records_imported: result.successful_imports,
import_job_id: result.import_job_id,

View File

@@ -0,0 +1,338 @@
import React, { useState } from 'react';
import {
User,
Mail,
Phone,
Shield,
Save,
AlertCircle,
CheckCircle,
Clock
} from 'lucide-react';
import { useSelector } from 'react-redux';
import { RootState } from '../../store';
import toast from 'react-hot-toast';
interface UserProfile {
fullName: string;
email: string;
phone: string;
}
interface PasswordChange {
currentPassword: string;
newPassword: string;
confirmPassword: string;
}
interface Session {
id: string;
device: string;
location: string;
lastActive: string;
isCurrent: boolean;
}
const AccountSettingsPage: React.FC = () => {
const { user } = useSelector((state: RootState) => state.auth);
const [isLoading, setIsLoading] = useState(false);
const [profile, setProfile] = useState<UserProfile>({
fullName: user?.fullName || '',
email: user?.email || '',
phone: ''
});
const [passwordForm, setPasswordForm] = useState<PasswordChange>({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
const [activeSessions] = useState<Session[]>([
{
id: '1',
device: 'Chrome en Windows',
location: 'Madrid, España',
lastActive: new Date().toISOString(),
isCurrent: true
},
{
id: '2',
device: 'iPhone App',
location: 'Madrid, España',
lastActive: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
isCurrent: false
}
]);
const handleUpdateProfile = async () => {
setIsLoading(true);
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
toast.success('Perfil actualizado exitosamente');
} catch (error) {
toast.error('Error al actualizar el perfil');
} finally {
setIsLoading(false);
}
};
const handleChangePassword = async () => {
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
toast.error('Las contraseñas no coinciden');
return;
}
if (passwordForm.newPassword.length < 8) {
toast.error('La contraseña debe tener al menos 8 caracteres');
return;
}
setIsLoading(true);
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
toast.success('Contraseña actualizada exitosamente');
setPasswordForm({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
} catch (error) {
toast.error('Error al actualizar la contraseña');
} finally {
setIsLoading(false);
}
};
const handleTerminateSession = async (sessionId: string) => {
if (window.confirm('¿Estás seguro de que quieres cerrar esta sesión?')) {
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 500));
toast.success('Sesión cerrada exitosamente');
} catch (error) {
toast.error('Error al cerrar la sesión');
}
}
};
const handleDeleteAccount = async () => {
const confirmation = window.prompt(
'Esta acción eliminará permanentemente tu cuenta y todos los datos asociados.\n\n' +
'Para confirmar, escribe "ELIMINAR CUENTA" exactamente como aparece:'
);
if (confirmation === 'ELIMINAR CUENTA') {
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
toast.success('Cuenta eliminada exitosamente');
// In real app, this would redirect to login
} catch (error) {
toast.error('Error al eliminar la cuenta');
}
} else if (confirmation !== null) {
toast.error('Confirmación incorrecta. La cuenta no se ha eliminado.');
}
};
return (
<div className="p-6 max-w-4xl mx-auto">
<div className="space-y-8">
{/* Profile Information */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-6">Información Personal</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<User className="inline h-4 w-4 mr-1" />
Nombre completo
</label>
<input
type="text"
value={profile.fullName}
onChange={(e) => setProfile(prev => ({ ...prev, fullName: e.target.value }))}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<Mail className="inline h-4 w-4 mr-1" />
Correo electrónico
</label>
<input
type="email"
value={profile.email}
onChange={(e) => setProfile(prev => ({ ...prev, email: e.target.value }))}
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<Phone className="inline h-4 w-4 mr-1" />
Teléfono
</label>
<input
type="tel"
value={profile.phone}
onChange={(e) => setProfile(prev => ({ ...prev, phone: e.target.value }))}
placeholder="+34 600 000 000"
className="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
</div>
<div className="flex justify-end mt-6">
<button
onClick={handleUpdateProfile}
disabled={isLoading}
className="inline-flex items-center px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50"
>
<Save className="h-4 w-4 mr-2" />
Guardar Cambios
</button>
</div>
</div>
{/* Security Settings */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-6">
<Shield className="inline h-5 w-5 mr-2" />
Seguridad
</h3>
{/* Change Password */}
<div className="space-y-4 mb-8">
<h4 className="font-medium text-gray-900">Cambiar Contraseña</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Contraseña actual
</label>
<input
type="password"
value={passwordForm.currentPassword}
onChange={(e) => setPasswordForm(prev => ({ ...prev, currentPassword: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="••••••••"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Nueva contraseña
</label>
<input
type="password"
value={passwordForm.newPassword}
onChange={(e) => setPasswordForm(prev => ({ ...prev, newPassword: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="••••••••"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Confirmar contraseña
</label>
<input
type="password"
value={passwordForm.confirmPassword}
onChange={(e) => setPasswordForm(prev => ({ ...prev, confirmPassword: e.target.value }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
placeholder="••••••••"
/>
</div>
</div>
<button
onClick={handleChangePassword}
disabled={isLoading || !passwordForm.currentPassword || !passwordForm.newPassword || !passwordForm.confirmPassword}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Actualizar Contraseña
</button>
</div>
{/* Active Sessions */}
<div className="border-t pt-6">
<h4 className="font-medium text-gray-900 mb-4">Sesiones Activas</h4>
<div className="space-y-3">
{activeSessions.map((session) => (
<div key={session.id} className="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
<div className="flex items-center">
<div className="h-10 w-10 bg-gray-100 rounded-full flex items-center justify-center mr-3">
<Shield className="h-5 w-5 text-gray-600" />
</div>
<div>
<div className="font-medium text-gray-900">{session.device}</div>
<div className="text-sm text-gray-500">{session.location}</div>
<div className="flex items-center text-xs mt-1">
{session.isCurrent ? (
<>
<CheckCircle className="h-3 w-3 text-green-600 mr-1" />
<span className="text-green-600">Sesión actual</span>
</>
) : (
<>
<Clock className="h-3 w-3 text-gray-400 mr-1" />
<span className="text-gray-500">
Último acceso: {new Date(session.lastActive).toLocaleDateString()}
</span>
</>
)}
</div>
</div>
</div>
{!session.isCurrent && (
<button
onClick={() => handleTerminateSession(session.id)}
className="text-red-600 hover:text-red-700 text-sm font-medium"
>
Cerrar sesión
</button>
)}
</div>
))}
</div>
</div>
</div>
{/* Danger Zone */}
<div className="bg-white rounded-xl shadow-sm border border-red-200 p-6">
<h3 className="text-lg font-semibold text-red-600 mb-4">
<AlertCircle className="inline h-5 w-5 mr-2" />
Zona Peligrosa
</h3>
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<h4 className="font-medium text-red-900 mb-2">Eliminar Cuenta</h4>
<p className="text-red-800 text-sm mb-4">
Esta acción eliminará permanentemente tu cuenta y todos los datos asociados.
No se puede deshacer.
</p>
<button
onClick={handleDeleteAccount}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm"
>
Eliminar Cuenta
</button>
</div>
</div>
</div>
</div>
);
};
export default AccountSettingsPage;

View File

@@ -0,0 +1,421 @@
import React, { useState, useEffect } from 'react';
import {
Plus,
Building,
MapPin,
Clock,
Users,
MoreVertical,
Edit,
Trash2,
Settings,
TrendingUp
} from 'lucide-react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState } from '../../store';
import { setCurrentTenant } from '../../store/slices/tenantSlice';
import { useTenant } from '../../api/hooks/useTenant';
import toast from 'react-hot-toast';
interface BakeryFormData {
name: string;
address: string;
business_type: 'individual' | 'central_workshop';
coordinates?: {
lat: number;
lng: number;
};
products: string[];
settings?: {
operating_hours?: {
open: string;
close: string;
};
operating_days?: number[];
timezone?: string;
currency?: string;
};
}
const BakeriesManagementPage: React.FC = () => {
const dispatch = useDispatch();
const { currentTenant } = useSelector((state: RootState) => state.tenant);
const {
tenants,
getUserTenants,
createTenant,
updateTenant,
getTenantStats,
isLoading,
error
} = useTenant();
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingTenant, setEditingTenant] = useState<any>(null);
const [formData, setFormData] = useState<BakeryFormData>({
name: '',
address: '',
business_type: 'individual',
products: ['Pan', 'Croissants', 'Magdalenas'],
settings: {
operating_hours: { open: '07:00', close: '20:00' },
operating_days: [1, 2, 3, 4, 5, 6],
timezone: 'Europe/Madrid',
currency: 'EUR'
}
});
const [tenantStats, setTenantStats] = useState<any>({});
useEffect(() => {
getUserTenants();
}, [getUserTenants]);
useEffect(() => {
// Load stats for each tenant
tenants.forEach(async (tenant) => {
try {
const stats = await getTenantStats(tenant.id);
setTenantStats(prev => ({ ...prev, [tenant.id]: stats }));
} catch (error) {
console.error(`Failed to load stats for tenant ${tenant.id}:`, error);
}
});
}, [tenants, getTenantStats]);
const handleCreateBakery = async () => {
try {
const newTenant = await createTenant(formData);
toast.success('Panadería creada exitosamente');
setShowCreateModal(false);
resetForm();
} catch (error) {
toast.error('Error al crear la panadería');
}
};
const handleUpdateBakery = async () => {
if (!editingTenant) return;
try {
await updateTenant(editingTenant.id, formData);
toast.success('Panadería actualizada exitosamente');
setEditingTenant(null);
resetForm();
} catch (error) {
toast.error('Error al actualizar la panadería');
}
};
const handleSwitchTenant = (tenant: any) => {
dispatch(setCurrentTenant(tenant));
localStorage.setItem('selectedTenantId', tenant.id);
toast.success(`Cambiado a ${tenant.name}`);
};
const resetForm = () => {
setFormData({
name: '',
address: '',
business_type: 'individual',
products: ['Pan', 'Croissants', 'Magdalenas'],
settings: {
operating_hours: { open: '07:00', close: '20:00' },
operating_days: [1, 2, 3, 4, 5, 6],
timezone: 'Europe/Madrid',
currency: 'EUR'
}
});
};
const openEditModal = (tenant: any) => {
setEditingTenant(tenant);
setFormData({
name: tenant.name,
address: tenant.address,
business_type: tenant.business_type,
products: tenant.products || ['Pan', 'Croissants', 'Magdalenas'],
settings: tenant.settings || {
operating_hours: { open: '07:00', close: '20:00' },
operating_days: [1, 2, 3, 4, 5, 6],
timezone: 'Europe/Madrid',
currency: 'EUR'
}
});
};
const getBakeryTypeInfo = (type: string) => {
return type === 'individual'
? { label: 'Panadería Individual', color: 'bg-blue-100 text-blue-800' }
: { label: 'Obrador Central', color: 'bg-purple-100 text-purple-800' };
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Cargando panaderías...</p>
</div>
</div>
);
}
return (
<div className="p-6 max-w-6xl mx-auto">
{/* Header */}
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">Gestión de Panaderías</h1>
<p className="text-gray-600 mt-1">
Administra todas tus panaderías y puntos de venta
</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
>
<Plus className="h-4 w-4 mr-2" />
Nueva Panadería
</button>
</div>
{/* Bakeries Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{tenants.map((tenant) => {
const typeInfo = getBakeryTypeInfo(tenant.business_type);
const stats = tenantStats[tenant.id];
const isActive = currentTenant?.id === tenant.id;
return (
<div
key={tenant.id}
className={`bg-white rounded-xl shadow-sm border-2 p-6 transition-all hover:shadow-md ${
isActive ? 'border-primary-500 ring-2 ring-primary-100' : 'border-gray-200'
}`}
>
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex-1">
<div className="flex items-center">
<Building className="h-5 w-5 text-gray-600 mr-2" />
<h3 className="text-lg font-semibold text-gray-900 truncate">
{tenant.name}
</h3>
{isActive && (
<span className="ml-2 px-2 py-1 bg-green-100 text-green-800 text-xs rounded-full">
Activa
</span>
)}
</div>
<span className={`inline-flex items-center px-2 py-1 rounded text-xs font-medium mt-2 ${typeInfo.color}`}>
{typeInfo.label}
</span>
</div>
<div className="relative">
<button className="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100">
<MoreVertical className="h-4 w-4" />
</button>
</div>
</div>
{/* Address */}
<div className="flex items-center text-gray-600 mb-4">
<MapPin className="h-4 w-4 mr-2 flex-shrink-0" />
<span className="text-sm truncate">{tenant.address}</span>
</div>
{/* Operating Hours */}
{tenant.settings?.operating_hours && (
<div className="flex items-center text-gray-600 mb-4">
<Clock className="h-4 w-4 mr-2 flex-shrink-0" />
<span className="text-sm">
{tenant.settings.operating_hours.open} - {tenant.settings.operating_hours.close}
</span>
</div>
)}
{/* Stats */}
{stats && (
<div className="grid grid-cols-2 gap-4 mb-4 pt-4 border-t border-gray-100">
<div className="text-center">
<div className="text-2xl font-bold text-gray-900">
{stats.total_sales || 0}
</div>
<div className="text-xs text-gray-500">Ventas (mes)</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-gray-900">
{stats.active_users || 0}
</div>
<div className="text-xs text-gray-500">Usuarios</div>
</div>
</div>
)}
{/* Actions */}
<div className="flex space-x-2">
{!isActive && (
<button
onClick={() => handleSwitchTenant(tenant)}
className="flex-1 px-3 py-2 bg-primary-600 text-white text-sm rounded-lg hover:bg-primary-700 transition-colors"
>
Activar
</button>
)}
<button
onClick={() => openEditModal(tenant)}
className="flex-1 px-3 py-2 bg-gray-100 text-gray-700 text-sm rounded-lg hover:bg-gray-200 transition-colors"
>
Editar
</button>
</div>
</div>
);
})}
</div>
{/* Create/Edit Modal */}
{(showCreateModal || editingTenant) && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-2xl mx-4 max-h-screen overflow-y-auto">
<h3 className="text-lg font-medium text-gray-900 mb-6">
{editingTenant ? 'Editar Panadería' : 'Nueva Panadería'}
</h3>
<div className="space-y-6">
{/* Basic Info */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Nombre
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
placeholder="Mi Panadería"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Tipo de negocio
</label>
<select
value={formData.business_type}
onChange={(e) => setFormData(prev => ({ ...prev, business_type: e.target.value as any }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="individual">Panadería Individual</option>
<option value="central_workshop">Obrador Central</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Dirección
</label>
<input
type="text"
value={formData.address}
onChange={(e) => setFormData(prev => ({ ...prev, address: e.target.value }))}
placeholder="Calle Mayor, 123, Madrid"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
{/* Operating Hours */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Horarios de operación
</label>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs text-gray-500 mb-1">Apertura</label>
<input
type="time"
value={formData.settings?.operating_hours?.open || '07:00'}
onChange={(e) => setFormData(prev => ({
...prev,
settings: {
...prev.settings,
operating_hours: {
...prev.settings?.operating_hours,
open: e.target.value
}
}
}))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label className="block text-xs text-gray-500 mb-1">Cierre</label>
<input
type="time"
value={formData.settings?.operating_hours?.close || '20:00'}
onChange={(e) => setFormData(prev => ({
...prev,
settings: {
...prev.settings,
operating_hours: {
...prev.settings?.operating_hours,
close: e.target.value
}
}
}))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
</div>
</div>
{/* Products */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Productos
</label>
<div className="flex flex-wrap gap-2">
{formData.products.map((product, index) => (
<span
key={index}
className="inline-flex items-center px-3 py-1 rounded-full text-sm bg-primary-100 text-primary-800"
>
{product}
</span>
))}
</div>
</div>
</div>
<div className="flex justify-end space-x-3 mt-8">
<button
onClick={() => {
setShowCreateModal(false);
setEditingTenant(null);
resetForm();
}}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
>
Cancelar
</button>
<button
onClick={editingTenant ? handleUpdateBakery : handleCreateBakery}
disabled={!formData.name || !formData.address}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{editingTenant ? 'Actualizar' : 'Crear'} Panadería
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default BakeriesManagementPage;

View File

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

View File

@@ -0,0 +1,326 @@
import React, { useState, useEffect } from 'react';
import {
UserPlus,
Mail,
Shield,
MoreVertical,
Trash2,
Edit,
Send,
User,
Crown,
Briefcase,
CheckCircle,
Clock,
AlertCircle
} from 'lucide-react';
import { useSelector } from 'react-redux';
import { RootState } from '../../store';
import { useTenant } from '../../api/hooks/useTenant';
import toast from 'react-hot-toast';
interface UserMember {
id: string;
user_id: string;
role: 'owner' | 'admin' | 'manager' | 'worker';
status: 'active' | 'pending' | 'inactive';
user: {
id: string;
email: string;
full_name: string;
last_active?: string;
};
joined_at: string;
}
const UsersManagementPage: React.FC = () => {
const { currentTenant } = useSelector((state: RootState) => state.tenant);
const { user: currentUser } = useSelector((state: RootState) => state.auth);
const {
members,
getTenantMembers,
inviteUser,
removeMember,
updateMemberRole,
isLoading,
error
} = useTenant();
const [showInviteModal, setShowInviteModal] = useState(false);
const [inviteForm, setInviteForm] = useState({
email: '',
role: 'worker' as const,
message: ''
});
const [selectedMember, setSelectedMember] = useState<UserMember | null>(null);
const [showRoleModal, setShowRoleModal] = useState(false);
useEffect(() => {
if (currentTenant) {
getTenantMembers(currentTenant.id);
}
}, [currentTenant, getTenantMembers]);
const handleInviteUser = async () => {
if (!currentTenant || !inviteForm.email) return;
try {
await inviteUser(currentTenant.id, {
email: inviteForm.email,
role: inviteForm.role,
message: inviteForm.message
});
toast.success('Invitación enviada exitosamente');
setShowInviteModal(false);
setInviteForm({ email: '', role: 'worker', message: '' });
} catch (error) {
toast.error('Error al enviar la invitación');
}
};
const handleRemoveMember = async (member: UserMember) => {
if (!currentTenant) return;
if (window.confirm(`¿Estás seguro de que quieres eliminar a ${member.user.full_name}?`)) {
try {
await removeMember(currentTenant.id, member.user_id);
toast.success('Usuario eliminado exitosamente');
} catch (error) {
toast.error('Error al eliminar usuario');
}
}
};
const handleUpdateRole = async (newRole: string) => {
if (!currentTenant || !selectedMember) return;
try {
await updateMemberRole(currentTenant.id, selectedMember.user_id, newRole);
toast.success('Rol actualizado exitosamente');
setShowRoleModal(false);
setSelectedMember(null);
} catch (error) {
toast.error('Error al actualizar el rol');
}
};
const getRoleInfo = (role: string) => {
const roleMap = {
owner: { label: 'Propietario', icon: Crown, color: 'text-yellow-600 bg-yellow-100' },
admin: { label: 'Administrador', icon: Shield, color: 'text-red-600 bg-red-100' },
manager: { label: 'Gerente', icon: Briefcase, color: 'text-blue-600 bg-blue-100' },
worker: { label: 'Empleado', icon: User, color: 'text-green-600 bg-green-100' }
};
return roleMap[role as keyof typeof roleMap] || roleMap.worker;
};
const getStatusInfo = (status: string) => {
const statusMap = {
active: { label: 'Activo', icon: CheckCircle, color: 'text-green-600' },
pending: { label: 'Pendiente', icon: Clock, color: 'text-yellow-600' },
inactive: { label: 'Inactivo', icon: AlertCircle, color: 'text-gray-600' }
};
return statusMap[status as keyof typeof statusMap] || statusMap.inactive;
};
const canManageUser = (member: UserMember): boolean => {
// Owners can manage everyone except other owners
// Admins can manage managers and workers
// Managers and workers can't manage anyone
if (currentUser?.role === 'owner') {
return member.role !== 'owner' || member.user_id === currentUser.id;
}
if (currentUser?.role === 'admin') {
return ['manager', 'worker'].includes(member.role);
}
return false;
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
<p className="mt-4 text-gray-600">Cargando usuarios...</p>
</div>
</div>
);
}
return (
<div className="p-6 max-w-6xl mx-auto">
{/* Header */}
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">Gestión de Usuarios</h1>
<p className="text-gray-600 mt-1">
Administra los miembros de tu equipo en {currentTenant?.name}
</p>
</div>
<button
onClick={() => setShowInviteModal(true)}
className="inline-flex items-center px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
>
<UserPlus className="h-4 w-4 mr-2" />
Invitar Usuario
</button>
</div>
{/* Users List */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">
Miembros del Equipo ({members.length})
</h3>
</div>
<div className="divide-y divide-gray-200">
{members.map((member) => {
const roleInfo = getRoleInfo(member.role);
const statusInfo = getStatusInfo(member.status);
const RoleIcon = roleInfo.icon;
const StatusIcon = statusInfo.icon;
return (
<div key={member.id} className="px-6 py-4 flex items-center justify-between">
<div className="flex items-center flex-1 min-w-0">
{/* Avatar */}
<div className="h-10 w-10 bg-primary-100 rounded-full flex items-center justify-center">
<User className="h-5 w-5 text-primary-600" />
</div>
{/* User Info */}
<div className="ml-4 flex-1 min-w-0">
<div className="flex items-center">
<h4 className="text-sm font-medium text-gray-900 truncate">
{member.user.full_name}
</h4>
{member.user_id === currentUser?.id && (
<span className="ml-2 text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">
</span>
)}
</div>
<p className="text-sm text-gray-500 truncate">
{member.user.email}
</p>
<div className="flex items-center mt-1 space-x-4">
<div className="flex items-center">
<StatusIcon className={`h-3 w-3 mr-1 ${statusInfo.color}`} />
<span className={`text-xs ${statusInfo.color}`}>
{statusInfo.label}
</span>
</div>
{member.user.last_active && (
<span className="text-xs text-gray-400">
Último acceso: {new Date(member.user.last_active).toLocaleDateString()}
</span>
)}
</div>
</div>
</div>
{/* Role Badge */}
<div className="flex items-center ml-4">
<div className={`inline-flex items-center px-3 py-1 rounded-full text-xs font-medium ${roleInfo.color}`}>
<RoleIcon className="h-3 w-3 mr-1" />
{roleInfo.label}
</div>
</div>
{/* Actions */}
{canManageUser(member) && (
<div className="flex items-center ml-4">
<div className="relative">
<button className="p-2 text-gray-400 hover:text-gray-600 rounded-lg hover:bg-gray-100">
<MoreVertical className="h-4 w-4" />
</button>
{/* Dropdown would go here */}
</div>
</div>
)}
</div>
);
})}
</div>
</div>
{/* Invite User Modal */}
{showInviteModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl p-6 w-full max-w-md mx-4">
<h3 className="text-lg font-medium text-gray-900 mb-4">
Invitar Nuevo Usuario
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Correo electrónico
</label>
<input
type="email"
value={inviteForm.email}
onChange={(e) => setInviteForm(prev => ({ ...prev, email: e.target.value }))}
placeholder="usuario@ejemplo.com"
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Rol
</label>
<select
value={inviteForm.role}
onChange={(e) => setInviteForm(prev => ({ ...prev, role: e.target.value as any }))}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="worker">Empleado</option>
<option value="manager">Gerente</option>
{currentUser?.role === 'owner' && <option value="admin">Administrador</option>}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Mensaje personal (opcional)
</label>
<textarea
value={inviteForm.message}
onChange={(e) => setInviteForm(prev => ({ ...prev, message: e.target.value }))}
placeholder="Mensaje de bienvenida..."
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
/>
</div>
</div>
<div className="flex justify-end space-x-3 mt-6">
<button
onClick={() => setShowInviteModal(false)}
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
>
Cancelar
</button>
<button
onClick={handleInviteUser}
disabled={!inviteForm.email}
className="inline-flex items-center px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Send className="h-4 w-4 mr-2" />
Enviar Invitación
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default UsersManagementPage;