New Frontend
This commit is contained in:
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