Fix team page
This commit is contained in:
@@ -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<TenantSwitcherProps> = ({
|
||||
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<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
@@ -37,25 +45,103 @@ export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
|
||||
|
||||
// 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<TenantSwitcherProps> = ({
|
||||
{/* Trigger Button */}
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
onClick={toggleDropdown}
|
||||
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"
|
||||
aria-expanded={isOpen}
|
||||
@@ -124,18 +210,28 @@ export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{isOpen && (
|
||||
{/* Dropdown Menu - Rendered in portal to avoid stacking context issues */}
|
||||
{isOpen && createPortal(
|
||||
<div
|
||||
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"
|
||||
aria-label="Available tenants"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="px-3 py-2 border-b border-border-primary">
|
||||
<h3 className="text-sm font-semibold text-text-primary">Switch Organization</h3>
|
||||
<p className="text-xs text-text-secondary">
|
||||
<div className={`border-b border-border-primary ${dropdownPosition.isMobile ? 'px-4 py-3' : 'px-3 py-2'}`}>
|
||||
<h3 className={`font-semibold text-text-primary ${dropdownPosition.isMobile ? 'text-base' : 'text-sm'}`}>
|
||||
Switch Organization
|
||||
</h3>
|
||||
<p className={`text-text-secondary ${dropdownPosition.isMobile ? 'text-sm mt-1' : 'text-xs'}`}>
|
||||
Select the organization you want to work with
|
||||
</p>
|
||||
</div>
|
||||
@@ -157,13 +253,15 @@ export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
|
||||
)}
|
||||
|
||||
{/* 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) => (
|
||||
<button
|
||||
key={tenant.id}
|
||||
onClick={() => handleTenantSwitch(tenant.id)}
|
||||
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"
|
||||
aria-selected={tenant.id === currentTenant?.id}
|
||||
>
|
||||
@@ -193,15 +291,20 @@ export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-3 py-2 border-t border-border-primary bg-bg-secondary rounded-b-lg">
|
||||
<p className="text-xs text-text-secondary">
|
||||
<div className={`border-t border-border-primary bg-bg-secondary rounded-b-lg ${
|
||||
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?{' '}
|
||||
<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
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Loading Overlay */}
|
||||
|
||||
@@ -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<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: 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 (
|
||||
<div className="p-6 space-y-6">
|
||||
<PageHeader
|
||||
title="Gestión de Equipo"
|
||||
description="Administra los miembros del equipo, roles y permisos"
|
||||
action={
|
||||
<Button onClick={() => setShowForm(true)}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Nuevo Miembro
|
||||
</Button>
|
||||
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 = () => {
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Producción</p>
|
||||
<p className="text-3xl font-bold text-[var(--color-primary)]">{teamStats.departments.production}</p>
|
||||
<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">
|
||||
<Users className="h-6 w-6 text-[var(--color-primary)]" />
|
||||
<Shield className="h-6 w-6 text-[var(--color-primary)]" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -156,11 +325,11 @@ const TeamPage: React.FC = () => {
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Ventas</p>
|
||||
<p className="text-3xl font-bold text-purple-600">{teamStats.departments.sales}</p>
|
||||
<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">
|
||||
<Users className="h-6 w-6 text-purple-600" />
|
||||
<Crown className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -199,107 +368,148 @@ const TeamPage: React.FC = () => {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Team Members List */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 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) => (
|
||||
<Card key={member.id} className="p-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-4 flex-1">
|
||||
<div className="w-12 h-12 bg-[var(--bg-quaternary)] rounded-full flex items-center justify-center">
|
||||
<Users className="w-6 h-6 text-[var(--text-tertiary)]" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{member.user.first_name} {member.user.last_name}</h3>
|
||||
<Badge variant={getStatusColor(member.status)}>
|
||||
{member.status === 'active' ? 'Activo' : 'Inactivo'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 mb-3">
|
||||
<div className="flex items-center text-sm text-[var(--text-secondary)]">
|
||||
<Mail className="w-4 h-4 mr-2" />
|
||||
{member.user.email}
|
||||
</div>
|
||||
<div className="flex items-center text-sm text-[var(--text-secondary)]">
|
||||
<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>
|
||||
<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 && (
|
||||
<Card className="p-12 text-center">
|
||||
<Users className="h-12 w-12 text-[var(--text-tertiary)] mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">No se encontraron miembros</h3>
|
||||
<p className="text-[var(--text-secondary)]">
|
||||
No hay miembros del equipo que coincidan con los filtros seleccionados.
|
||||
</p>
|
||||
</Card>
|
||||
<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 Placeholder */}
|
||||
{showForm && (
|
||||
{/* 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">
|
||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Nuevo Miembro del Equipo</h3>
|
||||
<p className="text-[var(--text-secondary)] mb-4">
|
||||
Formulario para agregar un nuevo miembro del equipo.
|
||||
</p>
|
||||
<div className="flex space-x-2">
|
||||
<Button size="sm" onClick={() => setShowForm(false)}>
|
||||
Guardar
|
||||
<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>
|
||||
<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
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user