diff --git a/frontend/src/components/ui/TenantSwitcher.tsx b/frontend/src/components/ui/TenantSwitcher.tsx index 1715a325..91790723 100644 --- a/frontend/src/components/ui/TenantSwitcher.tsx +++ b/frontend/src/components/ui/TenantSwitcher.tsx @@ -1,4 +1,5 @@ import React, { useState, useRef, useEffect } from 'react'; +import { createPortal } from 'react-dom'; import { useTenant } from '../../stores/tenant.store'; import { useToast } from '../../hooks/ui/useToast'; import { ChevronDown, Building2, Check, AlertCircle } from 'lucide-react'; @@ -13,6 +14,13 @@ export const TenantSwitcher: React.FC = ({ showLabel = true, }) => { 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(null); const buttonRef = useRef(null); @@ -37,25 +45,103 @@ export const TenantSwitcher: React.FC = ({ // Handle click outside to close dropdown useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { + const handleClickOutside = (event: MouseEvent | TouchEvent) => { + const target = event.target as Node; if ( dropdownRef.current && - !dropdownRef.current.contains(event.target as Node) && - !buttonRef.current?.contains(event.target as Node) + !dropdownRef.current.contains(target) && + !buttonRef.current?.contains(target) ) { setIsOpen(false); } }; + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsOpen(false); + } + }; + if (isOpen) { document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('touchstart', handleClickOutside); + document.addEventListener('keydown', handleEscape); } return () => { document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('touchstart', handleClickOutside); + document.removeEventListener('keydown', handleEscape); }; }, [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 const handleTenantSwitch = async (tenantId: string) => { if (tenantId === currentTenant?.id) { @@ -104,7 +190,7 @@ export const TenantSwitcher: React.FC = ({ {/* Trigger Button */} - {/* Dropdown Menu */} - {isOpen && ( + {/* Dropdown Menu - Rendered in portal to avoid stacking context issues */} + {isOpen && createPortal(
{/* Header */} -
-

Switch Organization

-

+

+

+ Switch Organization +

+

Select the organization you want to work with

@@ -157,13 +253,15 @@ export const TenantSwitcher: React.FC = ({ )} {/* Tenant List */} -
+
{availableTenants.map((tenant) => (
{/* Footer */} -
-

+

+

Need to add a new organization?{' '} -

-
+
, + document.body )} {/* Loading Overlay */} diff --git a/frontend/src/pages/app/settings/team/TeamPage.tsx b/frontend/src/pages/app/settings/team/TeamPage.tsx index 73882882..b1c8b66c 100644 --- a/frontend/src/pages/app/settings/team/TeamPage.tsx +++ b/frontend/src/pages/app/settings/team/TeamPage.tsx @@ -1,75 +1,255 @@ -import React, { useState } from 'react'; -import { Users, Plus, Search, Mail, Phone, Shield, Edit, Trash2, UserCheck, UserX } from 'lucide-react'; -import { Button, Card, Badge, Input } from '../../../../components/ui'; +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 } from '../../../../api/hooks/tenant'; -import { useAllUsers, useUpdateUser } from '../../../../api/hooks/user'; +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 user = useAuthUser(); - const tenantId = user?.tenant_id || ''; + const currentTenant = useCurrentTenant(); + 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 updateUserMutation = useUpdateUser(); + + // Mutations + const addMemberMutation = useAddTeamMember(); + const removeMemberMutation = useRemoveTeamMember(); + const updateRoleMutation = useUpdateMemberRole(); const [searchTerm, setSearchTerm] = useState(''); const [selectedRole, setSelectedRole] = useState('all'); - const [showForm, setShowForm] = useState(false); + const [showAddForm, setShowAddForm] = useState(false); + const [selectedUserToAdd, setSelectedUserToAdd] = useState(''); + const [selectedRoleToAdd, setSelectedRoleToAdd] = useState(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: teamMembers.length }, - { value: 'owner', label: 'Propietario', count: teamMembers.filter(m => m.role === 'owner').length }, - { value: 'admin', label: 'Administrador', count: teamMembers.filter(m => m.role === 'admin').length }, - { value: 'manager', label: 'Gerente', count: teamMembers.filter(m => m.role === 'manager').length }, - { value: 'employee', label: 'Empleado', count: teamMembers.filter(m => m.role === 'employee').length } + { 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: teamMembers.length, - active: teamMembers.filter(m => m.status === 'active').length, - departments: { - production: teamMembers.filter(m => m.department === 'Producción').length, - sales: teamMembers.filter(m => m.department === 'Ventas').length, - admin: teamMembers.filter(m => m.department === 'Administración').length - } + 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 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) => { switch (role) { - case 'manager': return 'Gerente'; - case 'baker': return 'Panadero'; - case 'cashier': return 'Cajero'; - case 'assistant': return 'Asistente'; + 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; } }; - 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 matchesSearch = member.user.first_name.toLowerCase().includes(searchTerm.toLowerCase()) || - member.user.last_name.toLowerCase().includes(searchTerm.toLowerCase()) || - member.user.email.toLowerCase().includes(searchTerm.toLowerCase()); + 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 ( @@ -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 (
setShowForm(true)}> - - Nuevo Miembro - + actions={ + canManageTeam && availableUsers.length > 0 ? [{ + id: 'add-member', + label: 'Agregar Miembro', + icon: Plus, + onClick: () => setShowAddForm(true) + }] : undefined } /> @@ -144,11 +313,11 @@ const TeamPage: React.FC = () => {
-

Producción

-

{teamStats.departments.production}

+

Administradores

+

{teamStats.admins}

- +
@@ -156,11 +325,11 @@ const TeamPage: React.FC = () => {
-

Ventas

-

{teamStats.departments.sales}

+

Propietarios

+

{teamStats.owners}

- +
@@ -199,107 +368,148 @@ const TeamPage: React.FC = () => {
- {/* Team Members List */} -
+ {/* Team Members List - Responsive grid */} +
{filteredMembers.map((member) => ( - -
-
-
- -
- -
-
-

{member.user.first_name} {member.user.last_name}

- - {member.status === 'active' ? 'Activo' : 'Inactivo'} - -
- -
-
- - {member.user.email} -
-
- - {member.user.phone || 'No disponible'} -
-
- -
- - {getRoleLabel(member.role)} - - - {member.department} - -
- -
-

Se unió: {new Date(member.joined_at).toLocaleDateString('es-ES')}

-

Estado: {member.is_active ? 'Activo' : 'Inactivo'}

-
- - {/* Permissions */} -
-

Permisos:

-
- - {getRoleLabel(member.role)} - -
-
- - {/* Member Info */} -
-

Información adicional:

-

ID: {member.id}

-
-
-
- -
- - -
-
-
+ ))}
{filteredMembers.length === 0 && ( - - -

No se encontraron miembros

-

- No hay miembros del equipo que coincidan con los filtros seleccionados. -

-
+ 0 ? [{ + label: 'Agregar Primer Miembro', + icon: Plus, + onClick: () => setShowAddForm(true), + priority: 'primary' as const, + }] : []} + className="col-span-full" + /> )} - {/* Add Member Modal Placeholder */} - {showForm && ( + {/* Add Member Modal */} + {showAddForm && (
-

Nuevo Miembro del Equipo

-

- Formulario para agregar un nuevo miembro del equipo. -

-
- -
+ +
+ {/* User Selection */} +
+ + + {availableUsers.length === 0 && ( +

+ No hay usuarios disponibles para agregar +

+ )} +
+ + {/* Role Selection */} +
+ + +
+ + {/* Role Description */} +
+

+ {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.'} +

+
+
+ +
+ +
diff --git a/services/tenant/app/api/tenants.py b/services/tenant/app/api/tenants.py index 3e6732d6..dc4831bb 100644 --- a/services/tenant/app/api/tenants.py +++ b/services/tenant/app/api/tenants.py @@ -330,7 +330,6 @@ async def add_team_member_enhanced( ) @router.get("/tenants/{tenant_id}/members", response_model=List[TenantMemberResponse]) -@track_endpoint_metrics("tenant_get_members") async def get_team_members_enhanced( tenant_id: UUID = Path(..., description="Tenant ID"), active_only: bool = Query(True, description="Only return active members"), diff --git a/services/tenant/app/services/tenant_service.py b/services/tenant/app/services/tenant_service.py index 45f75e6a..d5f932ce 100644 --- a/services/tenant/app/services/tenant_service.py +++ b/services/tenant/app/services/tenant_service.py @@ -305,29 +305,32 @@ class EnhancedTenantService: ) # Create membership using repository - membership_data = { - "tenant_id": tenant_id, - "user_id": user_id, - "role": role, - "invited_by": invited_by, - "is_active": True - } - - member = await self.member_repo.create_membership(membership_data) - - # Publish event - 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", - tenant_id=tenant_id, - user_id=user_id, - role=role, - invited_by=invited_by) - - return TenantMemberResponse.from_orm(member) + async with self.database_manager.get_session() as db_session: + await self._init_repositories(db_session) + + membership_data = { + "tenant_id": tenant_id, + "user_id": user_id, + "role": role, + "invited_by": invited_by, + "is_active": True + } + + member = await self.member_repo.create_membership(membership_data) + + # Publish event + 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", + tenant_id=tenant_id, + user_id=user_id, + role=role, + invited_by=invited_by) + + return TenantMemberResponse.from_orm(member) except HTTPException: raise @@ -359,12 +362,15 @@ class EnhancedTenantService: """Get all team members for a tenant""" try: - - members = await self.member_repo.get_tenant_members( - tenant_id, active_only=active_only - ) - - return [TenantMemberResponse.from_orm(member) for member in members] + 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( + tenant_id, active_only=active_only + ) + + return [TenantMemberResponse.from_orm(member) for member in members] except HTTPException: raise