Fix team page

This commit is contained in:
Urtzi Alfaro
2025-09-12 23:58:26 +02:00
parent 4c21a5e1b2
commit 96da9ca077
4 changed files with 522 additions and 204 deletions

View File

@@ -1,4 +1,5 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useTenant } from '../../stores/tenant.store'; import { useTenant } from '../../stores/tenant.store';
import { useToast } from '../../hooks/ui/useToast'; import { useToast } from '../../hooks/ui/useToast';
import { ChevronDown, Building2, Check, AlertCircle } from 'lucide-react'; import { ChevronDown, Building2, Check, AlertCircle } from 'lucide-react';
@@ -13,6 +14,13 @@ export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
showLabel = true, showLabel = true,
}) => { }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [dropdownPosition, setDropdownPosition] = useState<{
top: number;
left: number;
right?: number;
width: number;
isMobile: boolean;
}>({ top: 0, left: 0, width: 288, isMobile: false });
const dropdownRef = useRef<HTMLDivElement>(null); const dropdownRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null); const buttonRef = useRef<HTMLButtonElement>(null);
@@ -37,25 +45,103 @@ export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
// Handle click outside to close dropdown // Handle click outside to close dropdown
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent | TouchEvent) => {
const target = event.target as Node;
if ( if (
dropdownRef.current && dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) && !dropdownRef.current.contains(target) &&
!buttonRef.current?.contains(event.target as Node) !buttonRef.current?.contains(target)
) { ) {
setIsOpen(false); setIsOpen(false);
} }
}; };
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setIsOpen(false);
}
};
if (isOpen) { if (isOpen) {
document.addEventListener('mousedown', handleClickOutside); document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('touchstart', handleClickOutside);
document.addEventListener('keydown', handleEscape);
} }
return () => { return () => {
document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('touchstart', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
}; };
}, [isOpen]); }, [isOpen]);
// Recalculate position on window resize
useEffect(() => {
const handleResize = () => {
if (isOpen) {
calculateDropdownPosition();
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [isOpen]);
// Calculate dropdown position
const calculateDropdownPosition = () => {
if (!buttonRef.current) return;
const buttonRect = buttonRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const isMobile = viewportWidth < 768; // md breakpoint
if (isMobile) {
// On mobile, use full width with margins and position from top
// Check if dropdown would go off bottom of screen
const dropdownHeight = Math.min(400, viewportHeight * 0.7); // Max 70vh
const spaceBelow = viewportHeight - buttonRect.bottom - 8;
const shouldPositionAbove = spaceBelow < dropdownHeight && buttonRect.top > dropdownHeight;
setDropdownPosition({
top: shouldPositionAbove ? buttonRect.top - dropdownHeight - 8 : buttonRect.bottom + 8,
left: 16, // 1rem margin from screen edge
right: 16, // For full width calculation
width: viewportWidth - 32, // Full width minus margins
isMobile: true,
});
} else {
// Desktop positioning - align right edge of dropdown with right edge of button
const dropdownWidth = 320; // w-80 (20rem * 16px) - slightly wider for desktop
let left = buttonRect.right - dropdownWidth;
// Ensure dropdown doesn't go off the left edge of the screen
if (left < 16) {
left = 16;
}
// Ensure dropdown doesn't go off the right edge
if (left + dropdownWidth > viewportWidth - 16) {
left = viewportWidth - dropdownWidth - 16;
}
setDropdownPosition({
top: buttonRect.bottom + 8,
left: left,
width: dropdownWidth,
isMobile: false,
});
}
};
// Handle dropdown open/close
const toggleDropdown = () => {
if (!isOpen) {
calculateDropdownPosition();
}
setIsOpen(!isOpen);
};
// Handle tenant switch // Handle tenant switch
const handleTenantSwitch = async (tenantId: string) => { const handleTenantSwitch = async (tenantId: string) => {
if (tenantId === currentTenant?.id) { if (tenantId === currentTenant?.id) {
@@ -104,7 +190,7 @@ export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
{/* Trigger Button */} {/* Trigger Button */}
<button <button
ref={buttonRef} ref={buttonRef}
onClick={() => setIsOpen(!isOpen)} onClick={toggleDropdown}
disabled={isLoading} disabled={isLoading}
className="flex items-center space-x-2 px-3 py-2 text-sm font-medium text-text-primary bg-bg-secondary hover:bg-bg-tertiary border border-border-secondary rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-color-primary focus:ring-opacity-20 disabled:opacity-50 disabled:cursor-not-allowed" className="flex items-center space-x-2 px-3 py-2 text-sm font-medium text-text-primary bg-bg-secondary hover:bg-bg-tertiary border border-border-secondary rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-color-primary focus:ring-opacity-20 disabled:opacity-50 disabled:cursor-not-allowed"
aria-expanded={isOpen} aria-expanded={isOpen}
@@ -124,18 +210,28 @@ export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
/> />
</button> </button>
{/* Dropdown Menu */} {/* Dropdown Menu - Rendered in portal to avoid stacking context issues */}
{isOpen && ( {isOpen && createPortal(
<div <div
ref={dropdownRef} ref={dropdownRef}
className="absolute right-0 mt-2 w-72 bg-bg-primary border border-border-secondary rounded-lg shadow-lg z-50" className={`fixed bg-bg-primary border border-border-secondary rounded-lg shadow-lg z-[9999] ${
dropdownPosition.isMobile ? 'mx-4' : ''
}`}
style={{
top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`,
width: `${dropdownPosition.width}px`,
maxHeight: dropdownPosition.isMobile ? '70vh' : '80vh',
}}
role="listbox" role="listbox"
aria-label="Available tenants" aria-label="Available tenants"
> >
{/* Header */} {/* Header */}
<div className="px-3 py-2 border-b border-border-primary"> <div className={`border-b border-border-primary ${dropdownPosition.isMobile ? 'px-4 py-3' : 'px-3 py-2'}`}>
<h3 className="text-sm font-semibold text-text-primary">Switch Organization</h3> <h3 className={`font-semibold text-text-primary ${dropdownPosition.isMobile ? 'text-base' : 'text-sm'}`}>
<p className="text-xs text-text-secondary"> Switch Organization
</h3>
<p className={`text-text-secondary ${dropdownPosition.isMobile ? 'text-sm mt-1' : 'text-xs'}`}>
Select the organization you want to work with Select the organization you want to work with
</p> </p>
</div> </div>
@@ -157,13 +253,15 @@ export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
)} )}
{/* Tenant List */} {/* Tenant List */}
<div className="max-h-80 overflow-y-auto"> <div className={`overflow-y-auto ${dropdownPosition.isMobile ? 'max-h-[60vh]' : 'max-h-80'}`}>
{availableTenants.map((tenant) => ( {availableTenants.map((tenant) => (
<button <button
key={tenant.id} key={tenant.id}
onClick={() => handleTenantSwitch(tenant.id)} onClick={() => handleTenantSwitch(tenant.id)}
disabled={isLoading} disabled={isLoading}
className="w-full px-3 py-3 text-left hover:bg-bg-secondary focus:bg-bg-secondary focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed transition-colors" className={`w-full text-left hover:bg-bg-secondary focus:bg-bg-secondary focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed transition-colors ${
dropdownPosition.isMobile ? 'px-4 py-4 active:bg-bg-tertiary' : 'px-3 py-3'
}`}
role="option" role="option"
aria-selected={tenant.id === currentTenant?.id} aria-selected={tenant.id === currentTenant?.id}
> >
@@ -193,15 +291,20 @@ export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
</div> </div>
{/* Footer */} {/* Footer */}
<div className="px-3 py-2 border-t border-border-primary bg-bg-secondary rounded-b-lg"> <div className={`border-t border-border-primary bg-bg-secondary rounded-b-lg ${
<p className="text-xs text-text-secondary"> dropdownPosition.isMobile ? 'px-4 py-3' : 'px-3 py-2'
}`}>
<p className={`text-text-secondary ${dropdownPosition.isMobile ? 'text-sm' : 'text-xs'}`}>
Need to add a new organization?{' '} Need to add a new organization?{' '}
<button className="text-color-primary hover:text-color-primary-dark underline"> <button className={`text-color-primary hover:text-color-primary-dark underline ${
dropdownPosition.isMobile ? 'active:text-color-primary-dark' : ''
}`}>
Contact Support Contact Support
</button> </button>
</p> </p>
</div> </div>
</div> </div>,
document.body
)} )}
{/* Loading Overlay */} {/* Loading Overlay */}

View File

@@ -1,76 +1,256 @@
import React, { useState } from 'react'; import React, { useState, useMemo } from 'react';
import { Users, Plus, Search, Mail, Phone, Shield, Edit, Trash2, UserCheck, UserX } from 'lucide-react'; import { Users, Plus, Search, Mail, Phone, Shield, Trash2, Crown, X, UserCheck } from 'lucide-react';
import { Button, Card, Badge, Input } from '../../../../components/ui'; import { Button, Card, Badge, Input, StatusCard, getStatusColor } from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout'; import { PageHeader } from '../../../../components/layout';
import { useTeamMembers } from '../../../../api/hooks/tenant'; import { useTeamMembers, useAddTeamMember, useRemoveTeamMember, useUpdateMemberRole } from '../../../../api/hooks/tenant';
import { useAllUsers, useUpdateUser } from '../../../../api/hooks/user'; import { useAllUsers } from '../../../../api/hooks/user';
import { useAuthUser } from '../../../../stores/auth.store'; import { useAuthUser } from '../../../../stores/auth.store';
import { useCurrentTenant, useCurrentTenantAccess } from '../../../../stores/tenant.store';
import { useToast } from '../../../../hooks/ui/useToast'; import { useToast } from '../../../../hooks/ui/useToast';
import { TENANT_ROLES } from '../../../../types/roles';
const TeamPage: React.FC = () => { const TeamPage: React.FC = () => {
const { addToast } = useToast(); const { addToast } = useToast();
const user = useAuthUser(); const currentTenant = useCurrentTenant();
const tenantId = user?.tenant_id || ''; const currentTenantAccess = useCurrentTenantAccess();
const tenantId = currentTenant?.id || '';
const { data: teamMembers = [], isLoading, error } = useTeamMembers(tenantId, true, { enabled: !!tenantId }); const { data: teamMembers = [], isLoading } = useTeamMembers(tenantId, false, { enabled: !!tenantId }); // Show all members including inactive
const { data: allUsers = [] } = useAllUsers(); const { data: allUsers = [] } = useAllUsers();
const updateUserMutation = useUpdateUser();
// Mutations
const addMemberMutation = useAddTeamMember();
const removeMemberMutation = useRemoveTeamMember();
const updateRoleMutation = useUpdateMemberRole();
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [selectedRole, setSelectedRole] = useState('all'); const [selectedRole, setSelectedRole] = useState('all');
const [showForm, setShowForm] = useState(false); 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 = [ const roles = [
{ value: 'all', label: 'Todos los Roles', count: teamMembers.length }, { value: 'all', label: 'Todos los Roles', count: enhancedTeamMembers.length },
{ value: 'owner', label: 'Propietario', count: teamMembers.filter(m => m.role === 'owner').length }, { value: TENANT_ROLES.OWNER, label: 'Propietario', count: enhancedTeamMembers.filter(m => m.role === TENANT_ROLES.OWNER).length },
{ value: 'admin', label: 'Administrador', count: teamMembers.filter(m => m.role === 'admin').length }, { value: TENANT_ROLES.ADMIN, label: 'Administrador', count: enhancedTeamMembers.filter(m => m.role === TENANT_ROLES.ADMIN).length },
{ value: 'manager', label: 'Gerente', count: teamMembers.filter(m => m.role === 'manager').length }, { value: TENANT_ROLES.MEMBER, label: 'Miembro', count: enhancedTeamMembers.filter(m => m.role === TENANT_ROLES.MEMBER).length },
{ value: 'employee', label: 'Empleado', count: teamMembers.filter(m => m.role === 'employee').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 = { const teamStats = {
total: teamMembers.length, total: enhancedTeamMembers.length,
active: teamMembers.filter(m => m.status === 'active').length, active: enhancedTeamMembers.filter(m => m.is_active).length,
departments: { owners: enhancedTeamMembers.filter(m => m.role === TENANT_ROLES.OWNER).length,
production: teamMembers.filter(m => m.department === 'Producción').length, admins: enhancedTeamMembers.filter(m => m.role === TENANT_ROLES.ADMIN).length,
sales: teamMembers.filter(m => m.department === 'Ventas').length, members: enhancedTeamMembers.filter(m => m.role === TENANT_ROLES.MEMBER).length,
admin: teamMembers.filter(m => m.department === 'Administración').length
}
}; };
const getRoleBadgeColor = (role: string) => {
switch (role) {
case 'manager': return 'purple';
case 'baker': return 'green';
case 'cashier': return 'blue';
case 'assistant': return 'yellow';
default: return 'gray';
}
};
const getStatusColor = (status: string) => {
return status === 'active' ? 'green' : 'red';
};
const getRoleLabel = (role: string) => { const getRoleLabel = (role: string) => {
switch (role) { switch (role) {
case 'manager': return 'Gerente'; case TENANT_ROLES.OWNER: return 'Propietario';
case 'baker': return 'Panadero'; case TENANT_ROLES.ADMIN: return 'Administrador';
case 'cashier': return 'Cajero'; case TENANT_ROLES.MEMBER: return 'Miembro';
case 'assistant': return 'Asistente'; case TENANT_ROLES.VIEWER: return 'Observador';
default: return role; default: return role;
} }
}; };
const filteredMembers = teamMembers.filter(member => { // 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 matchesRole = selectedRole === 'all' || member.role === selectedRole;
const matchesSearch = member.user.first_name.toLowerCase().includes(searchTerm.toLowerCase()) || const userName = member.user?.full_name || member.user_full_name || '';
member.user.last_name.toLowerCase().includes(searchTerm.toLowerCase()) || const userEmail = member.user?.email || member.user_email || '';
member.user.email.toLowerCase().includes(searchTerm.toLowerCase()); const matchesSearch = userName.toLowerCase().includes(searchTerm.toLowerCase()) ||
userEmail.toLowerCase().includes(searchTerm.toLowerCase());
return matchesRole && matchesSearch; 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) { if (isLoading) {
return ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
@@ -88,30 +268,19 @@ const TeamPage: React.FC = () => {
); );
} }
const formatLastLogin = (timestamp: string) => {
const date = new Date(timestamp);
const now = new Date();
const diffInDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
if (diffInDays === 0) {
return 'Hoy ' + date.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' });
} else if (diffInDays === 1) {
return 'Ayer';
} else {
return `hace ${diffInDays} días`;
}
};
return ( return (
<div className="p-6 space-y-6"> <div className="p-6 space-y-6">
<PageHeader <PageHeader
title="Gestión de Equipo" title="Gestión de Equipo"
description="Administra los miembros del equipo, roles y permisos" description="Administra los miembros del equipo, roles y permisos"
action={ actions={
<Button onClick={() => setShowForm(true)}> canManageTeam && availableUsers.length > 0 ? [{
<Plus className="w-4 h-4 mr-2" /> id: 'add-member',
Nuevo Miembro label: 'Agregar Miembro',
</Button> icon: Plus,
onClick: () => setShowAddForm(true)
}] : undefined
} }
/> />
@@ -144,11 +313,11 @@ const TeamPage: React.FC = () => {
<Card className="p-6"> <Card className="p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm font-medium text-[var(--text-secondary)]">Producción</p> <p className="text-sm font-medium text-[var(--text-secondary)]">Administradores</p>
<p className="text-3xl font-bold text-[var(--color-primary)]">{teamStats.departments.production}</p> <p className="text-3xl font-bold text-[var(--color-primary)]">{teamStats.admins}</p>
</div> </div>
<div className="h-12 w-12 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center"> <div className="h-12 w-12 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
<Users className="h-6 w-6 text-[var(--color-primary)]" /> <Shield className="h-6 w-6 text-[var(--color-primary)]" />
</div> </div>
</div> </div>
</Card> </Card>
@@ -156,11 +325,11 @@ const TeamPage: React.FC = () => {
<Card className="p-6"> <Card className="p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm font-medium text-[var(--text-secondary)]">Ventas</p> <p className="text-sm font-medium text-[var(--text-secondary)]">Propietarios</p>
<p className="text-3xl font-bold text-purple-600">{teamStats.departments.sales}</p> <p className="text-3xl font-bold text-purple-600">{teamStats.owners}</p>
</div> </div>
<div className="h-12 w-12 bg-purple-100 rounded-full flex items-center justify-center"> <div className="h-12 w-12 bg-purple-100 rounded-full flex items-center justify-center">
<Users className="h-6 w-6 text-purple-600" /> <Crown className="h-6 w-6 text-purple-600" />
</div> </div>
</div> </div>
</Card> </Card>
@@ -199,107 +368,148 @@ const TeamPage: React.FC = () => {
</div> </div>
</Card> </Card>
{/* Team Members List */} {/* Team Members List - Responsive grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4 lg:gap-6">
{filteredMembers.map((member) => ( {filteredMembers.map((member) => (
<Card key={member.id} className="p-6"> <StatusCard
<div className="flex items-start justify-between"> key={member.id}
<div className="flex items-start space-x-4 flex-1"> id={`team-member-${member.id}`}
<div className="w-12 h-12 bg-[var(--bg-quaternary)] rounded-full flex items-center justify-center"> statusIndicator={getMemberStatusConfig(member)}
<Users className="w-6 h-6 text-[var(--text-tertiary)]" /> title={member.user?.full_name || member.user_full_name}
</div> subtitle={member.user?.email || member.user_email}
primaryValue={member.is_active ? 'Activo' : 'Inactivo'}
<div className="flex-1"> primaryValueLabel="Estado"
<div className="flex items-center space-x-3 mb-2"> secondaryInfo={{
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{member.user.first_name} {member.user.last_name}</h3> label: 'Se unió',
<Badge variant={getStatusColor(member.status)}> value: new Date(member.joined_at).toLocaleDateString('es-ES')
{member.status === 'active' ? 'Activo' : 'Inactivo'} }}
</Badge> metadata={[
</div> `Email: ${member.user?.email || member.user_email}`,
`Teléfono: ${member.user?.phone || 'No disponible'}`,
<div className="space-y-1 mb-3"> ...(member.role === TENANT_ROLES.OWNER ? ['🏢 Propietario de la organización'] : [])
<div className="flex items-center text-sm text-[var(--text-secondary)]"> ]}
<Mail className="w-4 h-4 mr-2" /> actions={getMemberActions(member)}
{member.user.email} className={`
</div> ${!member.is_active ? 'opacity-75' : ''}
<div className="flex items-center text-sm text-[var(--text-secondary)]"> transition-all duration-200 hover:scale-[1.02]
<Phone className="w-4 h-4 mr-2" /> `}
{member.user.phone || 'No disponible'} />
</div>
</div>
<div className="flex items-center space-x-2 mb-3">
<Badge variant={getRoleBadgeColor(member.role)}>
{getRoleLabel(member.role)}
</Badge>
<Badge variant="gray">
{member.department}
</Badge>
</div>
<div className="text-sm text-[var(--text-tertiary)] mb-3">
<p>Se unió: {new Date(member.joined_at).toLocaleDateString('es-ES')}</p>
<p>Estado: {member.is_active ? 'Activo' : 'Inactivo'}</p>
</div>
{/* Permissions */}
<div className="mb-3">
<p className="text-xs font-medium text-[var(--text-secondary)] mb-2">Permisos:</p>
<div className="flex flex-wrap gap-1">
<span className="px-2 py-1 bg-[var(--color-info)]/10 text-[var(--color-info)] text-xs rounded-full">
{getRoleLabel(member.role)}
</span>
</div>
</div>
{/* Member Info */}
<div className="text-xs text-[var(--text-tertiary)]">
<p className="font-medium mb-1">Información adicional:</p>
<p>ID: {member.id}</p>
</div>
</div>
</div>
<div className="flex space-x-2">
<Button size="sm" variant="outline">
<Edit className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="outline"
className={member.status === 'active' ? 'text-[var(--color-error)] hover:text-[var(--color-error)]' : 'text-[var(--color-success)] hover:text-[var(--color-success)]'}
>
{member.status === 'active' ? <UserX className="w-4 h-4" /> : <UserCheck className="w-4 h-4" />}
</Button>
</div>
</div>
</Card>
))} ))}
</div> </div>
{filteredMembers.length === 0 && ( {filteredMembers.length === 0 && (
<Card className="p-12 text-center"> <StatusCard
<Users className="h-12 w-12 text-[var(--text-tertiary)] mx-auto mb-4" /> id="empty-state"
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">No se encontraron miembros</h3> statusIndicator={{
<p className="text-[var(--text-secondary)]"> color: getStatusColor('pending'),
No hay miembros del equipo que coincidan con los filtros seleccionados. text: searchTerm || selectedRole !== 'all' ? 'Sin coincidencias' : 'Equipo vacío',
</p> icon: Users,
</Card> 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 Placeholder */} {/* Add Member Modal */}
{showForm && ( {showAddForm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <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"> <Card className="p-6 max-w-md w-full mx-4">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Nuevo Miembro del Equipo</h3> <div className="flex items-center justify-between mb-4">
<p className="text-[var(--text-secondary)] mb-4"> <h3 className="text-lg font-semibold text-[var(--text-primary)]">Agregar Miembro al Equipo</h3>
Formulario para agregar un nuevo miembro del equipo. <Button
</p> size="sm"
<div className="flex space-x-2"> variant="ghost"
<Button size="sm" onClick={() => setShowForm(false)}> onClick={() => setShowAddForm(false)}
Guardar >
<X className="w-4 h-4" />
</Button> </Button>
<Button size="sm" variant="outline" onClick={() => setShowForm(false)}> </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 Cancelar
</Button> </Button>
</div> </div>

View File

@@ -330,7 +330,6 @@ async def add_team_member_enhanced(
) )
@router.get("/tenants/{tenant_id}/members", response_model=List[TenantMemberResponse]) @router.get("/tenants/{tenant_id}/members", response_model=List[TenantMemberResponse])
@track_endpoint_metrics("tenant_get_members")
async def get_team_members_enhanced( async def get_team_members_enhanced(
tenant_id: UUID = Path(..., description="Tenant ID"), tenant_id: UUID = Path(..., description="Tenant ID"),
active_only: bool = Query(True, description="Only return active members"), active_only: bool = Query(True, description="Only return active members"),

View File

@@ -305,29 +305,32 @@ class EnhancedTenantService:
) )
# Create membership using repository # Create membership using repository
membership_data = { async with self.database_manager.get_session() as db_session:
"tenant_id": tenant_id, await self._init_repositories(db_session)
"user_id": user_id,
"role": role,
"invited_by": invited_by,
"is_active": True
}
member = await self.member_repo.create_membership(membership_data) membership_data = {
"tenant_id": tenant_id,
"user_id": user_id,
"role": role,
"invited_by": invited_by,
"is_active": True
}
# Publish event member = await self.member_repo.create_membership(membership_data)
try:
await publish_member_added(tenant_id, user_id, role)
except Exception as e:
logger.warning("Failed to publish member added event", error=str(e))
logger.info("Team member added successfully", # Publish event
tenant_id=tenant_id, try:
user_id=user_id, await publish_member_added(tenant_id, user_id, role)
role=role, except Exception as e:
invited_by=invited_by) logger.warning("Failed to publish member added event", error=str(e))
return TenantMemberResponse.from_orm(member) logger.info("Team member added successfully",
tenant_id=tenant_id,
user_id=user_id,
role=role,
invited_by=invited_by)
return TenantMemberResponse.from_orm(member)
except HTTPException: except HTTPException:
raise raise
@@ -359,12 +362,15 @@ class EnhancedTenantService:
"""Get all team members for a tenant""" """Get all team members for a tenant"""
try: try:
async with self.database_manager.get_session() as session:
# Initialize repositories with session
await self._init_repositories(session)
members = await self.member_repo.get_tenant_members( members = await self.member_repo.get_tenant_members(
tenant_id, active_only=active_only tenant_id, active_only=active_only
) )
return [TenantMemberResponse.from_orm(member) for member in members] return [TenantMemberResponse.from_orm(member) for member in members]
except HTTPException: except HTTPException:
raise raise