Files
bakery-ia/frontend/src/pages/app/settings/team/TeamPage.tsx
2025-10-24 13:05:04 +02:00

510 lines
18 KiB
TypeScript

import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Users, Plus, Search, Shield, Trash2, Crown, UserCheck } from 'lucide-react';
import { Button, StatusCard, getStatusColor, StatsGrid, SearchAndFilter, type FilterConfig } from '../../../../components/ui';
import AddTeamMemberModal from '../../../../components/domain/team/AddTeamMemberModal';
import { PageHeader } from '../../../../components/layout';
import { useTeamMembers, useAddTeamMember, useAddTeamMemberWithUserCreation, useRemoveTeamMember, useUpdateMemberRole, useTenantAccess } from '../../../../api/hooks/tenant';
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';
import { subscriptionService } from '../../../../api/services/subscription';
const TeamPage: React.FC = () => {
const { t } = useTranslation(['settings']);
const { addToast } = useToast();
const currentUser = useAuthUser();
const currentTenant = useCurrentTenant();
const currentTenantAccess = useCurrentTenantAccess();
const tenantId = currentTenant?.id || '';
// Try to get tenant access directly via hook as fallback
const { data: directTenantAccess } = useTenantAccess(
tenantId,
currentUser?.id || '',
{ enabled: !!tenantId && !!currentUser?.id && !currentTenantAccess }
);
const { data: teamMembers = [], isLoading } = useTeamMembers(tenantId, false, { enabled: !!tenantId }); // Show all members including inactive
// Mutations
const addMemberMutation = useAddTeamMember();
const addMemberWithUserMutation = useAddTeamMemberWithUserCreation();
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
// Note: Backend now enriches members with user info, so we just need to ensure owner is present
const enhancedTeamMembers = useMemo(() => {
const members = [...teamMembers];
// If tenant owner is not in the members list, add them as a placeholder
if (currentTenant?.owner_id) {
const ownerInMembers = members.find(m => m.user_id === currentTenant.owner_id);
if (!ownerInMembers) {
// Add owner as a member with basic info
// Note: The backend should ideally include the owner in the members list
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: null, // Backend will enrich this
user_full_name: null, // Backend will enrich this
user: null,
} as any);
} else if (ownerInMembers.role !== TENANT_ROLES.OWNER) {
// Update existing member to owner role
ownerInMembers.role = TENANT_ROLES.OWNER;
}
}
return members;
}, [teamMembers, currentTenant, 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 }
];
// Use direct tenant access as fallback
const effectiveTenantAccess = currentTenantAccess || directTenantAccess;
// Check if current user is the tenant owner (fallback when access endpoint fails)
const isCurrentUserOwner = currentUser?.id === currentTenant?.owner_id;
// Permission checks
const isOwner = effectiveTenantAccess?.role === TENANT_ROLES.OWNER || isCurrentUserOwner;
const canManageTeam = isOwner || effectiveTenantAccess?.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 = [];
// Primary action - View details (always available)
// This will be implemented in the future to show detailed member info modal
// For now, we can comment it out as there's no modal yet
// actions.push({
// label: 'Ver Detalles',
// icon: Eye,
// priority: 'primary' as const,
// onClick: () => {
// // TODO: Implement member details modal
// console.log('View member details:', member.user_id);
// },
// });
// Contextual role change actions (only for non-owners and if user can manage team)
if (canManageTeam && member.role !== TENANT_ROLES.OWNER) {
// Promote/demote to most logical next role
if (member.role === TENANT_ROLES.VIEWER) {
// Viewer -> Member (promote)
actions.push({
label: 'Promover a Miembro',
icon: UserCheck,
onClick: () => handleUpdateRole(member.user_id, TENANT_ROLES.MEMBER),
priority: 'secondary' as const,
});
} else if (member.role === TENANT_ROLES.MEMBER) {
// Member -> Admin (promote) or Member -> Viewer (demote)
if (isOwner) {
actions.push({
label: 'Promover a Admin',
icon: Shield,
onClick: () => handleUpdateRole(member.user_id, TENANT_ROLES.ADMIN),
priority: 'secondary' as const,
});
}
} else if (member.role === TENANT_ROLES.ADMIN) {
// Admin -> Member (demote) - only owner can do this
if (isOwner) {
actions.push({
label: 'Cambiar a Miembro',
icon: Users,
onClick: () => handleUpdateRole(member.user_id, TENANT_ROLES.MEMBER),
priority: 'secondary' as const,
});
}
}
}
// Remove member action (only for owners and non-owner members)
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: 'secondary' 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;
});
// Force reload tenant access if missing
React.useEffect(() => {
if (currentTenant?.id && !currentTenantAccess) {
console.log('Forcing tenant access reload for tenant:', currentTenant.id);
// You can trigger a manual reload here if needed
}
}, [currentTenant?.id, currentTenantAccess]);
// Debug logging
console.log('TeamPage Debug:', {
canManageTeam,
isOwner,
isCurrentUserOwner,
currentUser: currentUser?.id,
currentTenant: currentTenant?.id,
tenantOwner: currentTenant?.owner_id,
currentTenantAccess,
directTenantAccess,
effectiveTenantAccess,
tenantAccess: effectiveTenantAccess?.role,
enhancedTeamMembers: enhancedTeamMembers.length
});
// Member action handlers - removed unused handleAddMember since modal handles it directly
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="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="space-y-6">
<PageHeader
title={t('settings:team.title', 'Gestión de Equipo')}
description={t('settings:team.description', 'Administra los miembros del equipo, roles y permisos')}
actions={
canManageTeam ? [{
id: 'add-member',
label: 'Agregar Miembro',
icon: Plus,
onClick: () => setShowAddForm(true)
}] : undefined
}
/>
{/* Team Stats */}
<StatsGrid
stats={[
{
title: "Total Equipo",
value: teamStats.total,
icon: Users,
variant: "info"
},
{
title: "Activos",
value: teamStats.active,
icon: UserCheck,
variant: "success"
},
{
title: "Administradores",
value: teamStats.admins,
icon: Shield,
variant: "info"
},
{
title: "Propietarios",
value: teamStats.owners,
icon: Crown,
variant: "purple"
}
]}
columns={4}
gap="md"
/>
{/* Search and Filter Controls */}
<SearchAndFilter
searchValue={searchTerm}
onSearchChange={setSearchTerm}
searchPlaceholder="Buscar miembros del equipo..."
filters={[
{
key: 'role',
label: 'Rol',
type: 'buttons',
value: selectedRole,
onChange: (value) => setSelectedRole(value as string),
multiple: false,
options: roles.map(role => ({
value: role.value,
label: role.label,
count: role.count
}))
}
] as FilterConfig[]}
/>
{/* 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: any) => {
const user = member.user;
const daysInTeam = Math.floor((Date.now() - new Date(member.joined_at).getTime()) / (1000 * 60 * 60 * 24));
const lastLogin = user?.last_login ? new Date(user.last_login).toLocaleDateString('es-ES', {
year: 'numeric',
month: 'short',
day: 'numeric'
}) : 'Nunca';
return (
<StatusCard
key={member.id}
id={`team-member-${member.id}`}
statusIndicator={getMemberStatusConfig(member)}
title={user?.full_name || member.user_full_name || 'Usuario'}
subtitle={user?.email || member.user_email || ''}
primaryValue={daysInTeam}
primaryValueLabel="días en el equipo"
secondaryInfo={{
label: 'Último acceso',
value: lastLogin
}}
metadata={[
`Email: ${user?.email || member.user_email || 'No disponible'}`,
`Teléfono: ${user?.phone || 'No disponible'}`,
`Idioma: ${user?.language?.toUpperCase() || 'No especificado'}`,
`Unido: ${new Date(member.joined_at).toLocaleDateString('es-ES', { year: 'numeric', month: 'short', day: 'numeric' })}`,
...(member.role === TENANT_ROLES.OWNER ? ['🏢 Propietario de la organización'] : []),
...(user?.timezone ? [`Zona horaria: ${user.timezone}`] : [])
]}
actions={getMemberActions(member)}
className={`
${!member.is_active ? 'opacity-75' : ''}
transition-all duration-200 hover:scale-[1.02]
`}
/>
);
})}
</div>
{filteredMembers.length === 0 && (
<div className="text-center py-12">
<Users className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
No se encontraron miembros
</h3>
<p className="text-[var(--text-secondary)] mb-4">
{searchTerm || selectedRole !== 'all'
? "No hay miembros que coincidan con los filtros seleccionados"
: "Este tenant aún no tiene miembros del equipo"
}
</p>
{canManageTeam && (
<Button
onClick={() => setShowAddForm(true)}
variant="primary"
size="md"
className="font-medium px-6 py-3 shadow-sm hover:shadow-md transition-all duration-200"
>
<Plus className="w-4 h-4 mr-2 flex-shrink-0" />
<span>Agregar Primer Miembro</span>
</Button>
)}
</div>
)}
{/* Add Member Modal - Using StatusModal */}
<AddTeamMemberModal
isOpen={showAddForm}
onClose={() => {
setShowAddForm(false);
setSelectedUserToAdd('');
setSelectedRoleToAdd(TENANT_ROLES.MEMBER);
}}
onAddMember={async (userData) => {
if (!tenantId) return Promise.reject('No tenant ID available');
try {
// Check subscription limits before adding member
const usageCheck = await subscriptionService.checkUsageLimit(tenantId, 'users', 1);
if (!usageCheck.allowed) {
const errorMessage = usageCheck.message ||
`Has alcanzado el límite de ${usageCheck.limit} usuarios para tu plan. Actualiza tu suscripción para agregar más miembros.`;
addToast(errorMessage, { type: 'error' });
throw new Error(errorMessage);
}
// Use appropriate mutation based on whether we're creating a user
if (userData.createUser) {
await addMemberWithUserMutation.mutateAsync({
tenantId,
memberData: {
create_user: true,
email: userData.email!,
full_name: userData.fullName!,
password: userData.password!,
phone: userData.phone,
role: userData.role,
language: 'es',
timezone: 'Europe/Madrid'
}
});
addToast('Usuario creado y agregado exitosamente', { type: 'success' });
} else {
await addMemberMutation.mutateAsync({
tenantId,
userId: userData.userId!,
role: userData.role,
});
addToast('Miembro agregado exitosamente', { type: 'success' });
}
setShowAddForm(false);
setSelectedUserToAdd('');
setSelectedRoleToAdd(TENANT_ROLES.MEMBER);
} catch (error) {
if ((error as Error).message.includes('límite')) {
// Limit error already toasted above
throw error;
}
addToast(
userData.createUser ? 'Error al crear usuario' : 'Error al agregar miembro',
{ type: 'error' }
);
throw error;
}
}}
availableUsers={[]}
/>
</div>
);
};
export default TeamPage;