Files
bakery-ia/frontend/src/pages/app/settings/team/TeamPage.tsx
Urtzi Alfaro 96da9ca077 Fix team page
2025-09-12 23:58:26 +02:00

523 lines
20 KiB
TypeScript

import React, { useState, useMemo } from 'react';
import { Users, Plus, Search, Mail, Phone, Shield, Trash2, Crown, X, UserCheck } from 'lucide-react';
import { Button, Card, Badge, Input, StatusCard, getStatusColor } from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout';
import { useTeamMembers, useAddTeamMember, useRemoveTeamMember, useUpdateMemberRole } from '../../../../api/hooks/tenant';
import { useAllUsers } from '../../../../api/hooks/user';
import { useAuthUser } from '../../../../stores/auth.store';
import { useCurrentTenant, useCurrentTenantAccess } from '../../../../stores/tenant.store';
import { useToast } from '../../../../hooks/ui/useToast';
import { TENANT_ROLES } from '../../../../types/roles';
const TeamPage: React.FC = () => {
const { addToast } = useToast();
const currentTenant = useCurrentTenant();
const currentTenantAccess = useCurrentTenantAccess();
const tenantId = currentTenant?.id || '';
const { data: teamMembers = [], isLoading } = useTeamMembers(tenantId, false, { enabled: !!tenantId }); // Show all members including inactive
const { data: allUsers = [] } = useAllUsers();
// Mutations
const addMemberMutation = useAddTeamMember();
const removeMemberMutation = useRemoveTeamMember();
const updateRoleMutation = useUpdateMemberRole();
const [searchTerm, setSearchTerm] = useState('');
const [selectedRole, setSelectedRole] = useState('all');
const [showAddForm, setShowAddForm] = useState(false);
const [selectedUserToAdd, setSelectedUserToAdd] = useState('');
const [selectedRoleToAdd, setSelectedRoleToAdd] = useState<string>(TENANT_ROLES.MEMBER);
// Enhanced team members that includes owner information
const enhancedTeamMembers = useMemo(() => {
const members = [...teamMembers];
// If tenant owner is not in the members list, add them
if (currentTenant?.owner_id) {
const ownerInMembers = members.find(m => m.user_id === currentTenant.owner_id);
if (!ownerInMembers) {
// Find owner user data
const ownerUser = allUsers.find(u => u.id === currentTenant.owner_id);
if (ownerUser) {
// Add owner as a member
members.push({
id: `owner-${currentTenant.owner_id}`,
tenant_id: tenantId,
user_id: currentTenant.owner_id,
role: TENANT_ROLES.OWNER,
is_active: true,
joined_at: currentTenant.created_at,
user_email: ownerUser.email,
user_full_name: ownerUser.full_name,
user: ownerUser, // Add full user object for compatibility
} as any);
}
} else if (ownerInMembers.role !== TENANT_ROLES.OWNER) {
// Update existing member to owner role
ownerInMembers.role = TENANT_ROLES.OWNER;
}
}
return members;
}, [teamMembers, currentTenant, allUsers, tenantId]);
const roles = [
{ value: 'all', label: 'Todos los Roles', count: enhancedTeamMembers.length },
{ value: TENANT_ROLES.OWNER, label: 'Propietario', count: enhancedTeamMembers.filter(m => m.role === TENANT_ROLES.OWNER).length },
{ value: TENANT_ROLES.ADMIN, label: 'Administrador', count: enhancedTeamMembers.filter(m => m.role === TENANT_ROLES.ADMIN).length },
{ value: TENANT_ROLES.MEMBER, label: 'Miembro', count: enhancedTeamMembers.filter(m => m.role === TENANT_ROLES.MEMBER).length },
{ value: TENANT_ROLES.VIEWER, label: 'Observador', count: enhancedTeamMembers.filter(m => m.role === TENANT_ROLES.VIEWER).length }
];
// Permission checks
const isOwner = currentTenantAccess?.role === TENANT_ROLES.OWNER;
const canManageTeam = isOwner || currentTenantAccess?.role === TENANT_ROLES.ADMIN;
const teamStats = {
total: enhancedTeamMembers.length,
active: enhancedTeamMembers.filter(m => m.is_active).length,
owners: enhancedTeamMembers.filter(m => m.role === TENANT_ROLES.OWNER).length,
admins: enhancedTeamMembers.filter(m => m.role === TENANT_ROLES.ADMIN).length,
members: enhancedTeamMembers.filter(m => m.role === TENANT_ROLES.MEMBER).length,
};
const getRoleLabel = (role: string) => {
switch (role) {
case TENANT_ROLES.OWNER: return 'Propietario';
case TENANT_ROLES.ADMIN: return 'Administrador';
case TENANT_ROLES.MEMBER: return 'Miembro';
case TENANT_ROLES.VIEWER: return 'Observador';
default: return role;
}
};
// StatusCard configuration for team members
const getMemberStatusConfig = (member: any) => {
if (member.role === TENANT_ROLES.OWNER) {
return {
color: getStatusColor('completed'), // Purple/primary for owner
text: getRoleLabel(member.role),
icon: Crown,
isCritical: false,
isHighlight: true
};
}
if (member.role === TENANT_ROLES.ADMIN) {
return {
color: getStatusColor('inProgress'), // Blue for admin
text: getRoleLabel(member.role),
icon: Shield,
isCritical: false,
isHighlight: false
};
}
if (member.role === TENANT_ROLES.MEMBER) {
return {
color: getStatusColor('normal'), // Green for member
text: getRoleLabel(member.role),
icon: Users,
isCritical: false,
isHighlight: false
};
}
// VIEWER or other roles
return {
color: getStatusColor('pending'), // Yellow for viewer
text: getRoleLabel(member.role),
icon: Users,
isCritical: false,
isHighlight: false
};
};
const getMemberActions = (member: any) => {
const actions = [];
// Role change actions (only for non-owners and if user can manage team)
if (canManageTeam && member.role !== TENANT_ROLES.OWNER) {
if (member.role !== TENANT_ROLES.ADMIN) {
actions.push({
label: 'Hacer Admin',
icon: Shield,
onClick: () => handleUpdateRole(member.user_id, TENANT_ROLES.ADMIN),
priority: 'secondary' as const,
});
}
if (member.role !== TENANT_ROLES.MEMBER) {
actions.push({
label: 'Hacer Miembro',
icon: Users,
onClick: () => handleUpdateRole(member.user_id, TENANT_ROLES.MEMBER),
priority: 'secondary' as const,
});
}
if (member.role !== TENANT_ROLES.VIEWER) {
actions.push({
label: 'Hacer Observador',
onClick: () => handleUpdateRole(member.user_id, TENANT_ROLES.VIEWER),
priority: 'tertiary' as const,
});
}
}
// Remove member action (only for owners)
if (isOwner && member.role !== TENANT_ROLES.OWNER) {
actions.push({
label: 'Remover',
icon: Trash2,
onClick: () => {
if (confirm('¿Estás seguro de que deseas remover este miembro?')) {
handleRemoveMember(member.user_id);
}
},
priority: 'tertiary' as const,
destructive: true,
});
}
return actions;
};
const filteredMembers = enhancedTeamMembers.filter(member => {
const matchesRole = selectedRole === 'all' || member.role === selectedRole;
const userName = member.user?.full_name || member.user_full_name || '';
const userEmail = member.user?.email || member.user_email || '';
const matchesSearch = userName.toLowerCase().includes(searchTerm.toLowerCase()) ||
userEmail.toLowerCase().includes(searchTerm.toLowerCase());
return matchesRole && matchesSearch;
});
// Available users for adding (exclude current members)
const availableUsers = allUsers.filter(u =>
!enhancedTeamMembers.some(m => m.user_id === u.id)
);
// Member action handlers
const handleAddMember = async () => {
if (!selectedUserToAdd || !selectedRoleToAdd || !tenantId) return;
try {
await addMemberMutation.mutateAsync({
tenantId,
userId: selectedUserToAdd,
role: selectedRoleToAdd,
});
addToast('Miembro agregado exitosamente', { type: 'success' });
setShowAddForm(false);
setSelectedUserToAdd('');
setSelectedRoleToAdd(TENANT_ROLES.MEMBER);
} catch (error) {
addToast('Error al agregar miembro', { type: 'error' });
}
};
const handleRemoveMember = async (memberUserId: string) => {
if (!tenantId) return;
try {
await removeMemberMutation.mutateAsync({
tenantId,
memberUserId,
});
addToast('Miembro removido exitosamente', { type: 'success' });
} catch (error) {
addToast('Error al remover miembro', { type: 'error' });
}
};
const handleUpdateRole = async (memberUserId: string, newRole: string) => {
if (!tenantId) return;
try {
await updateRoleMutation.mutateAsync({
tenantId,
memberUserId,
newRole,
});
addToast('Rol actualizado exitosamente', { type: 'success' });
} catch (error) {
addToast('Error al actualizar rol', { type: 'error' });
}
};
if (isLoading) {
return (
<div className="p-6 space-y-6">
<PageHeader
title="Gestión de Equipo"
description="Administra los miembros del equipo, roles y permisos"
/>
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p>Cargando miembros del equipo...</p>
</div>
</div>
</div>
);
}
return (
<div className="p-6 space-y-6">
<PageHeader
title="Gestión de Equipo"
description="Administra los miembros del equipo, roles y permisos"
actions={
canManageTeam && availableUsers.length > 0 ? [{
id: 'add-member',
label: 'Agregar Miembro',
icon: Plus,
onClick: () => setShowAddForm(true)
}] : undefined
}
/>
{/* Team Stats */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[var(--text-secondary)]">Total Equipo</p>
<p className="text-3xl font-bold text-[var(--text-primary)]">{teamStats.total}</p>
</div>
<div className="h-12 w-12 bg-[var(--color-info)]/10 rounded-full flex items-center justify-center">
<Users className="h-6 w-6 text-[var(--color-info)]" />
</div>
</div>
</Card>
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[var(--text-secondary)]">Activos</p>
<p className="text-3xl font-bold text-[var(--color-success)]">{teamStats.active}</p>
</div>
<div className="h-12 w-12 bg-[var(--color-success)]/10 rounded-full flex items-center justify-center">
<UserCheck className="h-6 w-6 text-[var(--color-success)]" />
</div>
</div>
</Card>
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[var(--text-secondary)]">Administradores</p>
<p className="text-3xl font-bold text-[var(--color-primary)]">{teamStats.admins}</p>
</div>
<div className="h-12 w-12 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
<Shield className="h-6 w-6 text-[var(--color-primary)]" />
</div>
</div>
</Card>
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[var(--text-secondary)]">Propietarios</p>
<p className="text-3xl font-bold text-purple-600">{teamStats.owners}</p>
</div>
<div className="h-12 w-12 bg-purple-100 rounded-full flex items-center justify-center">
<Crown className="h-6 w-6 text-purple-600" />
</div>
</div>
</Card>
</div>
{/* Filters and Search */}
<Card className="p-6">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[var(--text-tertiary)] h-4 w-4" />
<Input
placeholder="Buscar miembros del equipo..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
<div className="flex gap-2 flex-wrap">
{roles.map((role) => (
<button
key={role.value}
onClick={() => setSelectedRole(role.value)}
className={`px-4 py-2 rounded-full text-sm font-medium transition-colors ${
selectedRole === role.value
? 'bg-blue-600 text-white'
: 'bg-[var(--bg-tertiary)] text-[var(--text-secondary)] hover:bg-[var(--bg-quaternary)]'
}`}
>
{role.label} ({role.count})
</button>
))}
</div>
</div>
</Card>
{/* Team Members List - Responsive grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4 lg:gap-6">
{filteredMembers.map((member) => (
<StatusCard
key={member.id}
id={`team-member-${member.id}`}
statusIndicator={getMemberStatusConfig(member)}
title={member.user?.full_name || member.user_full_name}
subtitle={member.user?.email || member.user_email}
primaryValue={member.is_active ? 'Activo' : 'Inactivo'}
primaryValueLabel="Estado"
secondaryInfo={{
label: 'Se unió',
value: new Date(member.joined_at).toLocaleDateString('es-ES')
}}
metadata={[
`Email: ${member.user?.email || member.user_email}`,
`Teléfono: ${member.user?.phone || 'No disponible'}`,
...(member.role === TENANT_ROLES.OWNER ? ['🏢 Propietario de la organización'] : [])
]}
actions={getMemberActions(member)}
className={`
${!member.is_active ? 'opacity-75' : ''}
transition-all duration-200 hover:scale-[1.02]
`}
/>
))}
</div>
{filteredMembers.length === 0 && (
<StatusCard
id="empty-state"
statusIndicator={{
color: getStatusColor('pending'),
text: searchTerm || selectedRole !== 'all' ? 'Sin coincidencias' : 'Equipo vacío',
icon: Users,
isCritical: false,
isHighlight: false
}}
title="No se encontraron miembros"
subtitle={searchTerm || selectedRole !== 'all'
? "No hay miembros que coincidan con los filtros seleccionados"
: "Este tenant aún no tiene miembros del equipo"
}
primaryValue="0"
primaryValueLabel="Miembros"
actions={canManageTeam && availableUsers.length > 0 ? [{
label: 'Agregar Primer Miembro',
icon: Plus,
onClick: () => setShowAddForm(true),
priority: 'primary' as const,
}] : []}
className="col-span-full"
/>
)}
{/* Add Member Modal */}
{showAddForm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<Card className="p-6 max-w-md w-full mx-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-[var(--text-primary)]">Agregar Miembro al Equipo</h3>
<Button
size="sm"
variant="ghost"
onClick={() => setShowAddForm(false)}
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="space-y-4">
{/* User Selection */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
Usuario
</label>
<select
value={selectedUserToAdd}
onChange={(e) => setSelectedUserToAdd(e.target.value)}
className="w-full px-3 py-2 border border-border-secondary rounded-lg bg-bg-primary focus:outline-none focus:ring-2 focus:ring-color-primary focus:ring-opacity-20"
required
>
<option value="">Seleccionar usuario...</option>
{availableUsers.map(user => (
<option key={user.id} value={user.id}>
{user.full_name} ({user.email})
</option>
))}
</select>
{availableUsers.length === 0 && (
<p className="text-sm text-[var(--text-tertiary)] mt-1">
No hay usuarios disponibles para agregar
</p>
)}
</div>
{/* Role Selection */}
<div>
<label className="block text-sm font-medium text-[var(--text-primary)] mb-2">
Rol
</label>
<select
value={selectedRoleToAdd}
onChange={(e) => setSelectedRoleToAdd(e.target.value)}
className="w-full px-3 py-2 border border-border-secondary rounded-lg bg-bg-primary focus:outline-none focus:ring-2 focus:ring-color-primary focus:ring-opacity-20"
>
<option value={TENANT_ROLES.MEMBER}>Miembro - Acceso estándar</option>
<option value={TENANT_ROLES.ADMIN}>Administrador - Gestión de equipo</option>
<option value={TENANT_ROLES.VIEWER}>Observador - Solo lectura</option>
</select>
</div>
{/* Role Description */}
<div className="p-3 bg-bg-secondary rounded-lg">
<p className="text-xs text-[var(--text-secondary)]">
{selectedRoleToAdd === TENANT_ROLES.ADMIN &&
'Los administradores pueden gestionar miembros del equipo y configuraciones.'}
{selectedRoleToAdd === TENANT_ROLES.MEMBER &&
'Los miembros tienen acceso completo para trabajar con datos y funcionalidades.'}
{selectedRoleToAdd === TENANT_ROLES.VIEWER &&
'Los observadores solo pueden ver datos, sin realizar cambios.'}
</p>
</div>
</div>
<div className="flex space-x-2 mt-6">
<Button
onClick={handleAddMember}
disabled={!selectedUserToAdd || addMemberMutation.isPending}
className="flex-1"
>
{addMemberMutation.isPending ? 'Agregando...' : 'Agregar Miembro'}
</Button>
<Button
variant="outline"
onClick={() => {
setShowAddForm(false);
setSelectedUserToAdd('');
setSelectedRoleToAdd(TENANT_ROLES.MEMBER);
}}
>
Cancelar
</Button>
</div>
</Card>
</div>
)}
</div>
);
};
export default TeamPage;