- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
1468 lines
64 KiB
TypeScript
1468 lines
64 KiB
TypeScript
import React, { useState, useMemo, useCallback } from 'react';
|
||
import { useLocation } from 'wouter';
|
||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||
import {
|
||
DndContext,
|
||
useDraggable,
|
||
useDroppable,
|
||
PointerSensor,
|
||
useSensor,
|
||
useSensors,
|
||
type DragEndEvent,
|
||
} from '@dnd-kit/core';
|
||
import { authService } from '@/services/auth.service';
|
||
import { hasPermission, isAdmin } from '@/lib/permissions';
|
||
|
||
import { useAuth } from '@/hooks/useAuth';
|
||
import Layout from '@/components/Layout';
|
||
import UserModal from '@/components/UserModal';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Badge } from '@/components/ui/badge';
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from '@/components/ui/table';
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from '@/components/ui/select';
|
||
import { UserAvatar } from '@/components/UserAvatar';
|
||
import {
|
||
DropdownMenu,
|
||
DropdownMenuContent,
|
||
DropdownMenuItem,
|
||
DropdownMenuTrigger,
|
||
} from '@/components/ui/dropdown-menu';
|
||
import {
|
||
Collapsible,
|
||
CollapsibleContent,
|
||
CollapsibleTrigger,
|
||
} from '@/components/ui/collapsible';
|
||
import {
|
||
Pagination,
|
||
PaginationContent,
|
||
PaginationEllipsis,
|
||
PaginationItem,
|
||
PaginationLink,
|
||
PaginationNext,
|
||
PaginationPrevious,
|
||
} from '@/components/ui/pagination';
|
||
import {
|
||
Users,
|
||
UserCheck,
|
||
UserX,
|
||
Plus,
|
||
Edit,
|
||
Trash2,
|
||
Mail,
|
||
MoreHorizontal,
|
||
Send,
|
||
Copy,
|
||
Check,
|
||
Clock,
|
||
Settings2,
|
||
ChevronRight,
|
||
ChevronDown,
|
||
UserPlus,
|
||
Folder,
|
||
FolderOpen,
|
||
FolderTree,
|
||
UserCircle,
|
||
Shield,
|
||
GripVertical,
|
||
} from 'lucide-react';
|
||
import { useColumnFilters } from '@/hooks/useColumnFilters';
|
||
import { ColumnFilter } from '@/components/ui/ColumnFilter';
|
||
import { ActiveFilterBadges } from '@/components/ui/ActiveFilterBadges';
|
||
import { apiRequest } from '@/lib/queryClient';
|
||
import { cn } from '@/lib/utils';
|
||
import { Checkbox } from '@/components/ui/checkbox';
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from '@/components/ui/dialog';
|
||
import { Label } from '@/components/ui/label';
|
||
import { useToast } from '@/hooks/use-toast';
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||
import { Textarea } from '@/components/ui/textarea';
|
||
|
||
interface RoleMember {
|
||
id: number;
|
||
userId: number;
|
||
user: { id: number; firstName: string; lastName: string; email: string };
|
||
}
|
||
|
||
interface Role {
|
||
id: number;
|
||
name: string;
|
||
description: string | null;
|
||
parentId: number | null;
|
||
managerId: number | null;
|
||
organizationId: number;
|
||
createdAt: string | null;
|
||
memberCount: number;
|
||
directMemberCount: number;
|
||
totalMemberCount: number;
|
||
members: RoleMember[];
|
||
}
|
||
|
||
interface RoleTreeNode extends Role {
|
||
children: RoleTreeNode[];
|
||
}
|
||
|
||
interface UserStatus {
|
||
id: number;
|
||
name: string;
|
||
color: string;
|
||
isDefault: boolean;
|
||
position: number;
|
||
createdAt: string;
|
||
}
|
||
|
||
const PARENT_NONE = 'none';
|
||
|
||
function buildRoleTree(roles: Role[]): RoleTreeNode[] {
|
||
const map = new Map<number, RoleTreeNode>();
|
||
roles.forEach((r) => map.set(r.id, { ...r, children: [] }));
|
||
const roots: RoleTreeNode[] = [];
|
||
map.forEach((node) => {
|
||
if (node.parentId && map.has(node.parentId)) {
|
||
map.get(node.parentId)!.children.push(node);
|
||
} else {
|
||
roots.push(node);
|
||
}
|
||
});
|
||
return roots;
|
||
}
|
||
|
||
function RoleNode({
|
||
node,
|
||
depth,
|
||
onEdit,
|
||
onAddChild,
|
||
onDelete,
|
||
onManageMembers,
|
||
isAdmin,
|
||
canReassign,
|
||
}: {
|
||
node: RoleTreeNode;
|
||
depth: number;
|
||
onEdit: (role: Role) => void;
|
||
onAddChild: (parentId: number) => void;
|
||
onDelete: (id: number) => void;
|
||
onManageMembers: (role: Role) => void;
|
||
isAdmin: boolean;
|
||
canReassign: boolean;
|
||
}) {
|
||
const [open, setOpen] = useState(depth < 2);
|
||
const hasChildren = node.children.length > 0;
|
||
const hasMembers = node.members.length > 0;
|
||
const isExpandable = hasChildren || hasMembers;
|
||
|
||
const indent = depth * 20;
|
||
|
||
const { setNodeRef: setDropRef, isOver } = useDroppable({
|
||
id: `role-${node.id}`,
|
||
data: { type: 'role', roleId: node.id },
|
||
disabled: !canReassign,
|
||
});
|
||
|
||
const displayCount = open ? node.directMemberCount : node.totalMemberCount;
|
||
|
||
return (
|
||
<div ref={setDropRef}>
|
||
{/* Строка роли */}
|
||
<div
|
||
className={cn(
|
||
'flex items-center gap-1 py-1 px-1 rounded group cursor-pointer select-none',
|
||
isOver ? 'bg-primary/20' : 'hover:bg-muted/40'
|
||
)}
|
||
style={{ paddingLeft: `${indent + 4}px` }}
|
||
onClick={() => isExpandable && setOpen((p) => !p)}
|
||
>
|
||
{/* Стрелка */}
|
||
<span className="w-4 h-4 flex items-center justify-center flex-shrink-0 text-muted-foreground">
|
||
{isExpandable ? (
|
||
open ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />
|
||
) : null}
|
||
</span>
|
||
|
||
{/* Иконка папки */}
|
||
{open && isExpandable ? (
|
||
<FolderOpen className="w-4 h-4 flex-shrink-0 text-amber-500" />
|
||
) : (
|
||
<Folder className="w-4 h-4 flex-shrink-0 text-amber-500" />
|
||
)}
|
||
|
||
{/* Кнопка действий (шестеренка → меню) */}
|
||
{isAdmin && (
|
||
<div onClick={(e) => e.stopPropagation()}>
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<button className="h-5 w-5 flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted">
|
||
<Settings2 className="w-3 h-3" />
|
||
</button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end">
|
||
<DropdownMenuItem className="text-xs" onClick={() => onManageMembers(node)}>
|
||
Участники ({node.totalMemberCount})
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem className="text-xs" onClick={() => onEdit(node)}>
|
||
Редактировать
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem className="text-xs" onClick={() => onAddChild(node.id)}>
|
||
Добавить подроль
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem
|
||
className="text-destructive text-xs"
|
||
onClick={() => onDelete(node.id)}
|
||
>
|
||
Удалить
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
)}
|
||
|
||
{/* Название + описание */}
|
||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||
<span className="text-sm font-medium truncate">{node.name}</span>
|
||
{displayCount > 0 && (
|
||
<Badge variant="secondary" className="text-[10px] font-normal px-1.5">
|
||
{displayCount}
|
||
{open && node.totalMemberCount > node.directMemberCount && (
|
||
<span className="text-muted-foreground ml-1">из {node.totalMemberCount}</span>
|
||
)}
|
||
</Badge>
|
||
)}
|
||
{node.description && (
|
||
<span className="text-xs text-muted-foreground truncate max-w-[160px] hidden sm:block">
|
||
{node.description}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Раскрытое содержимое */}
|
||
{open && (
|
||
<div>
|
||
{/* Описание как подзаголовок (если есть и нет детей/участников) */}
|
||
{node.description && !hasChildren && !hasMembers && (
|
||
<div
|
||
className="py-0.5 text-xs text-muted-foreground italic"
|
||
style={{ paddingLeft: `${indent + 44}px` }}
|
||
>
|
||
{node.description}
|
||
</div>
|
||
)}
|
||
|
||
{/* Участники как листовые элементы */}
|
||
{node.members.map((m) => (
|
||
<DraggableMember
|
||
key={m.id}
|
||
member={m}
|
||
sourceRoleId={node.id}
|
||
indent={indent + 44}
|
||
disabled={!canReassign}
|
||
/>
|
||
))}
|
||
|
||
{/* Дочерние роли */}
|
||
{node.children.map((child) => (
|
||
<RoleNode
|
||
key={child.id}
|
||
node={child}
|
||
depth={depth + 1}
|
||
onEdit={onEdit}
|
||
onAddChild={onAddChild}
|
||
onDelete={onDelete}
|
||
onManageMembers={onManageMembers}
|
||
isAdmin={isAdmin}
|
||
canReassign={canReassign}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function NoRoleDroppable({
|
||
count,
|
||
open,
|
||
onToggle,
|
||
disabled,
|
||
}: {
|
||
count: number;
|
||
open: boolean;
|
||
onToggle: () => void;
|
||
disabled: boolean;
|
||
}) {
|
||
const { setNodeRef, isOver } = useDroppable({
|
||
id: 'role-none',
|
||
data: { type: 'role', roleId: null },
|
||
disabled,
|
||
});
|
||
|
||
return (
|
||
<div
|
||
ref={setNodeRef}
|
||
className={cn(
|
||
'rounded px-1 py-0.5 transition-all',
|
||
isOver && 'bg-primary/25 ring-2 ring-primary/50 shadow-sm'
|
||
)}
|
||
>
|
||
<div
|
||
className={cn(
|
||
'flex items-center gap-1 py-1 px-1 rounded group cursor-pointer select-none transition-colors',
|
||
isOver ? 'bg-primary/35 text-primary-foreground' : 'hover:bg-muted/40'
|
||
)}
|
||
onClick={onToggle}
|
||
>
|
||
<span className="w-4 h-4 flex items-center justify-center flex-shrink-0 text-muted-foreground">
|
||
{open ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
|
||
</span>
|
||
{open ? (
|
||
<FolderOpen className="w-4 h-4 flex-shrink-0 text-amber-500" />
|
||
) : (
|
||
<Folder className="w-4 h-4 flex-shrink-0 text-amber-500" />
|
||
)}
|
||
<span className="text-sm font-medium truncate">Без роли</span>
|
||
<Badge variant="secondary" className="text-[10px] font-normal px-1.5">
|
||
{count}
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DraggableMember({
|
||
member,
|
||
sourceRoleId,
|
||
indent,
|
||
disabled,
|
||
}: {
|
||
member: RoleMember;
|
||
sourceRoleId: number | null;
|
||
indent: number;
|
||
disabled: boolean;
|
||
}) {
|
||
const draggableId = sourceRoleId === null
|
||
? `user-${member.userId}-no-role`
|
||
: `user-${member.userId}-role-${sourceRoleId}`;
|
||
|
||
const {
|
||
attributes,
|
||
listeners,
|
||
setNodeRef,
|
||
transform,
|
||
isDragging,
|
||
} = useDraggable({
|
||
id: draggableId,
|
||
data: { type: 'user', userId: member.userId, sourceRoleId },
|
||
disabled,
|
||
});
|
||
|
||
const style = transform
|
||
? { transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, opacity: isDragging ? 0.5 : 1 }
|
||
: undefined;
|
||
|
||
return (
|
||
<div
|
||
ref={setNodeRef}
|
||
style={{ ...style, paddingLeft: `${indent}px` }}
|
||
className="flex items-center gap-2 py-0.5 px-1 rounded hover:bg-muted/30 text-sm text-muted-foreground group"
|
||
{...attributes}
|
||
{...listeners}
|
||
>
|
||
<GripVertical className="w-3 h-3 flex-shrink-0 text-muted-foreground/40 opacity-0 group-hover:opacity-100 cursor-grab" />
|
||
<UserCircle className="w-3.5 h-3.5 flex-shrink-0 text-muted-foreground/50" />
|
||
<span>{member.user.firstName} {member.user.lastName}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const UsersPage = () => {
|
||
const { user } = useAuth();
|
||
const { toast } = useToast();
|
||
const [, setLocation] = useLocation();
|
||
const [userModalOpen, setUserModalOpen] = useState(false);
|
||
const [editingUser, setEditingUser] = useState<any>(null);
|
||
const [inviteModalOpen, setInviteModalOpen] = useState(false);
|
||
const { filters, sort, setFilter, clearFilter, clearAll, setSort, clearSort, hasActiveFilters } = useColumnFilters();
|
||
const [inviteEmail, setInviteEmail] = useState('');
|
||
const [inviteLoading, setInviteLoading] = useState(false);
|
||
const [copiedLink, setCopiedLink] = useState<string | null>(null);
|
||
const [page, setPage] = useState(1);
|
||
const [pageSize, setPageSize] = useState(25);
|
||
const [activeTab, setActiveTab] = useState('users');
|
||
|
||
|
||
const autoCodeFromName = (name: string) => {
|
||
const sanitized = name.toLowerCase().replace(/[^a-z0-9_]/g, '_').replace(/_+/g, '_').replace(/^_+|_+$/g, '');
|
||
if (!sanitized) return 'field';
|
||
if (/^[0-9]/.test(sanitized)) return 'field_' + sanitized;
|
||
return sanitized;
|
||
};
|
||
|
||
|
||
|
||
const [roleDialogOpen, setRoleDialogOpen] = useState(false);
|
||
const [editingRole, setEditingRole] = useState<Role | null>(null);
|
||
const [roleName, setRoleName] = useState('');
|
||
const [roleDescription, setRoleDescription] = useState('');
|
||
const [roleParentId, setRoleParentId] = useState<string>(PARENT_NONE);
|
||
const [roleManagerId, setRoleManagerId] = useState<string>(PARENT_NONE);
|
||
const [roleSaving, setRoleSaving] = useState(false);
|
||
|
||
const [membersDialogOpen, setMembersDialogOpen] = useState(false);
|
||
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
|
||
const [memberSearch, setMemberSearch] = useState('');
|
||
const [noRoleOpen, setNoRoleOpen] = useState(true);
|
||
|
||
const [statusDialogOpen, setStatusDialogOpen] = useState(false);
|
||
const [editingStatus, setEditingStatus] = useState<UserStatus | null>(null);
|
||
const [statusName, setStatusName] = useState('');
|
||
const [statusColor, setStatusColor] = useState('#e5e7eb');
|
||
const [statusSaving, setStatusSaving] = useState(false);
|
||
|
||
const dndSensors = useSensors(
|
||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } })
|
||
);
|
||
|
||
const handleDragEnd = (event: DragEndEvent) => {
|
||
const { active, over } = event;
|
||
if (!over || !active.data.current || active.data.current.type !== 'user') return;
|
||
const userId = active.data.current.userId as number;
|
||
const sourceRoleId = active.data.current.sourceRoleId as number | null;
|
||
let targetRoleId: number | null = null;
|
||
if (over.id === 'role-none') {
|
||
targetRoleId = null;
|
||
} else if (over.data.current && over.data.current.type === 'role') {
|
||
targetRoleId = over.data.current.roleId as number;
|
||
}
|
||
if (targetRoleId === sourceRoleId) return;
|
||
moveRoleMemberMutation.mutate({ sourceRoleId, targetRoleId, userId });
|
||
};
|
||
|
||
const { data: statsData } = useQuery({
|
||
queryKey: ['/api/organizations/stats'],
|
||
queryFn: () => authService.getOrganizationStats(),
|
||
enabled: !!user,
|
||
});
|
||
|
||
const _filtersStr = useMemo(() => {
|
||
const active = Object.fromEntries(Object.entries(filters).filter(([, v]) => v));
|
||
return Object.keys(active).length ? JSON.stringify(active) : '';
|
||
}, [filters]);
|
||
const _sortStr = sort ? `${sort.column}:${sort.direction}` : '';
|
||
|
||
const { data: usersData, refetch: refetchUsers } = useQuery<{ success: boolean; users: any[]; total: number; page: number; pageSize: number }>({
|
||
queryKey: ['/api/users/list', _filtersStr, _sortStr, page, pageSize],
|
||
queryFn: async () => {
|
||
const params = new URLSearchParams();
|
||
params.set('page', String(page));
|
||
params.set('pageSize', String(pageSize));
|
||
if (_filtersStr) params.set('filters', _filtersStr);
|
||
if (sort) { params.set('sortColumn', sort.column); params.set('sortDirection', sort.direction); }
|
||
const res = await apiRequest('GET', `/api/users/list?${params.toString()}`);
|
||
return res.json();
|
||
},
|
||
enabled: !!user,
|
||
});
|
||
|
||
// All org users for members dialog (no filters applied)
|
||
const { data: allUsersData, refetch: refetchAllUsers } = useQuery<{ success: boolean; users: any[] }>({
|
||
queryKey: ['/api/users/all'],
|
||
queryFn: async () => {
|
||
const res = await apiRequest('GET', '/api/users?pageSize=1000');
|
||
return res.json();
|
||
},
|
||
enabled: !!user && activeTab === 'roles',
|
||
});
|
||
|
||
const { data: invitationsData, refetch: refetchInvitations } = useQuery({
|
||
queryKey: ['/api/invitations'],
|
||
queryFn: async () => {
|
||
const response = await fetch('/api/invitations', {
|
||
credentials: 'include',
|
||
});
|
||
return response.json();
|
||
},
|
||
enabled: !!user && hasPermission(user, 'users.manage'),
|
||
});
|
||
|
||
const { data: rolesData, refetch: refetchRoles } = useQuery<{ success: boolean; roles: Role[] }>({
|
||
queryKey: ['/api/roles'],
|
||
queryFn: async () => {
|
||
const res = await apiRequest('GET', '/api/roles');
|
||
return res.json();
|
||
},
|
||
enabled: !!user,
|
||
});
|
||
|
||
const { data: statusesData, refetch: refetchStatuses } = useQuery<{ success: boolean; statuses: UserStatus[] }>({
|
||
queryKey: ['/api/user-statuses'],
|
||
queryFn: async () => {
|
||
const res = await apiRequest('GET', '/api/user-statuses');
|
||
return res.json();
|
||
},
|
||
enabled: !!user,
|
||
});
|
||
|
||
const stats = (statsData as any)?.stats || { totalUsers: 0, activeUsers: 0, adminUsers: 0, inactiveUsers: 0 };
|
||
const filteredUsers = usersData?.users || [];
|
||
const allOrgUsers = allUsersData?.users || filteredUsers;
|
||
const invitations = (invitationsData as any)?.invitations || [];
|
||
const roles: Role[] = rolesData?.roles || [];
|
||
const userStatuses: UserStatus[] = statusesData?.statuses || [];
|
||
const roleTree = buildRoleTree(roles);
|
||
const usersWithoutRole = allOrgUsers.filter((u: any) => !(u.organizationalRoles && u.organizationalRoles.length > 0));
|
||
const isManagerOfAnyRole = useMemo(() => roles.some((r) => r.managerId === user?.id), [roles, user?.id]);
|
||
const canReassign = hasPermission(user, 'roles.manage') || isManagerOfAnyRole;
|
||
|
||
const fetchUserColumnSuggestions = useCallback((column: string) => async (search: string): Promise<string[]> => {
|
||
const params = new URLSearchParams({ column, search });
|
||
const context = Object.fromEntries(Object.entries(filters).filter(([k, v]) => k !== column && !!v));
|
||
if (Object.keys(context).length > 0) params.set('context', JSON.stringify(context));
|
||
const res = await apiRequest('GET', `/api/users/column-values?${params.toString()}`);
|
||
const data = await res.json();
|
||
return data.values || [];
|
||
}, [filters]);
|
||
|
||
const userColumnLabels: Record<string, string> = {
|
||
name: 'Пользователь', email: 'Email', role: 'Роль', status: 'Активность', position: 'Должность',
|
||
};
|
||
|
||
|
||
|
||
function pluralize(value: number, one: string, few: string, many: string): string {
|
||
const mod10 = value % 10;
|
||
const mod100 = value % 100;
|
||
if (mod10 === 1 && mod100 !== 11) return `${value} ${one}`;
|
||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return `${value} ${few}`;
|
||
return `${value} ${many}`;
|
||
}
|
||
|
||
const formatLastActivity = (activityAt: string | null) => {
|
||
if (!activityAt) return 'Никогда';
|
||
const date = new Date(activityAt);
|
||
const now = new Date();
|
||
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||
if (diffInSeconds < 60) return 'Только что';
|
||
const diffInMinutes = Math.floor(diffInSeconds / 60);
|
||
if (diffInMinutes < 60) return pluralize(diffInMinutes, 'минуту назад', 'минуты назад', 'минут назад');
|
||
const diffInHours = Math.floor(diffInMinutes / 60);
|
||
if (diffInHours < 24) return pluralize(diffInHours, 'час назад', 'часа назад', 'часов назад');
|
||
const diffInDays = Math.floor(diffInHours / 24);
|
||
return pluralize(diffInDays, 'день назад', 'дня назад', 'дней назад');
|
||
};
|
||
|
||
const handleDeactivateUser = async (userId: number) => {
|
||
try {
|
||
const result = await authService.updateUser(userId, { isActive: false });
|
||
if (result.success) {
|
||
toast({ title: 'Пользователь деактивирован', description: result.message });
|
||
refetchUsers();
|
||
} else {
|
||
toast({ title: 'Ошибка', description: result.error, variant: 'destructive' });
|
||
}
|
||
} catch {
|
||
toast({ title: 'Ошибка', description: 'Не удалось деактивировать пользователя', variant: 'destructive' });
|
||
}
|
||
};
|
||
|
||
const handleDeleteUser = async (userId: number) => {
|
||
if (confirm('Вы уверены, что хотите удалить этого пользователя? Это действие нельзя отменить.')) {
|
||
try {
|
||
const result = await authService.deleteUser(userId);
|
||
if (result.success) {
|
||
toast({ title: 'Пользователь удален', description: result.message });
|
||
refetchUsers();
|
||
} else {
|
||
toast({ title: 'Ошибка', description: result.error, variant: 'destructive' });
|
||
}
|
||
} catch {
|
||
toast({ title: 'Ошибка', description: 'Не удалось удалить пользователя', variant: 'destructive' });
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleEditUser = (u: any) => { setEditingUser(u); setUserModalOpen(true); };
|
||
|
||
const handleCreateInvitation = async () => {
|
||
if (!inviteEmail.trim()) return;
|
||
setInviteLoading(true);
|
||
try {
|
||
const response = await fetch('/api/invitations', {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ email: inviteEmail }),
|
||
});
|
||
const result = await response.json();
|
||
if (result.success) {
|
||
toast({ title: 'Приглашение создано', description: 'Ссылка для приглашения готова' });
|
||
setInviteEmail('');
|
||
refetchInvitations();
|
||
} else {
|
||
toast({ title: 'Ошибка', description: result.error || 'Не удалось создать приглашение', variant: 'destructive' });
|
||
}
|
||
} catch {
|
||
toast({ title: 'Ошибка', description: 'Не удалось создать приглашение', variant: 'destructive' });
|
||
} finally {
|
||
setInviteLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleCopyLink = async (link: string) => {
|
||
await navigator.clipboard.writeText(link);
|
||
setCopiedLink(link);
|
||
toast({ title: 'Ссылка скопирована', description: 'Отправьте её приглашенному пользователю' });
|
||
setTimeout(() => setCopiedLink(null), 2000);
|
||
};
|
||
|
||
const handleDeleteInvitation = async (id: number) => {
|
||
try {
|
||
await fetch(`/api/invitations/${id}`, { method: 'DELETE', credentials: 'include' });
|
||
toast({ title: 'Приглашение удалено' });
|
||
refetchInvitations();
|
||
} catch {
|
||
toast({ title: 'Ошибка', description: 'Не удалось удалить приглашение', variant: 'destructive' });
|
||
}
|
||
};
|
||
|
||
const openCreateRole = (parentId?: number) => {
|
||
setEditingRole(null);
|
||
setRoleName('');
|
||
setRoleDescription('');
|
||
setRoleParentId(parentId ? String(parentId) : PARENT_NONE);
|
||
setRoleManagerId(PARENT_NONE);
|
||
setRoleDialogOpen(true);
|
||
};
|
||
|
||
const openEditRole = (role: Role) => {
|
||
setEditingRole(role);
|
||
setRoleName(role.name);
|
||
setRoleDescription(role.description || '');
|
||
setRoleParentId(role.parentId ? String(role.parentId) : PARENT_NONE);
|
||
setRoleManagerId((role as any).managerId ? String((role as any).managerId) : PARENT_NONE);
|
||
setRoleDialogOpen(true);
|
||
};
|
||
|
||
const handleSaveRole = async () => {
|
||
if (!roleName.trim()) return;
|
||
setRoleSaving(true);
|
||
try {
|
||
const body = {
|
||
name: roleName.trim(),
|
||
description: roleDescription.trim() || null,
|
||
parentId: roleParentId !== PARENT_NONE ? parseInt(roleParentId) : null,
|
||
managerId: roleManagerId !== PARENT_NONE ? parseInt(roleManagerId) : null,
|
||
};
|
||
if (editingRole) {
|
||
const res = await apiRequest('PUT', `/api/roles/${editingRole.id}`, body);
|
||
const data = await res.json();
|
||
if (data.success) { toast({ title: 'Роль обновлена' }); setRoleDialogOpen(false); refetchRoles(); }
|
||
else toast({ title: 'Ошибка', description: data.error, variant: 'destructive' });
|
||
} else {
|
||
const res = await apiRequest('POST', '/api/roles', body);
|
||
const data = await res.json();
|
||
if (data.success) { toast({ title: 'Роль создана' }); setRoleDialogOpen(false); refetchRoles(); }
|
||
else toast({ title: 'Ошибка', description: data.error, variant: 'destructive' });
|
||
}
|
||
} catch {
|
||
toast({ title: 'Ошибка', description: 'Не удалось сохранить роль', variant: 'destructive' });
|
||
} finally {
|
||
setRoleSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleDeleteRole = async (id: number) => {
|
||
if (!confirm('Удалить эту роль? Все подроли станут корневыми.')) return;
|
||
try {
|
||
const res = await apiRequest('DELETE', `/api/roles/${id}`);
|
||
const data = await res.json();
|
||
if (data.success) { toast({ title: 'Роль удалена' }); refetchRoles(); }
|
||
else toast({ title: 'Ошибка', description: data.error, variant: 'destructive' });
|
||
} catch {
|
||
toast({ title: 'Ошибка', description: 'Не удалось удалить роль', variant: 'destructive' });
|
||
}
|
||
};
|
||
|
||
const openManageMembers = (role: Role) => {
|
||
setSelectedRole(role);
|
||
setMemberSearch('');
|
||
setMembersDialogOpen(true);
|
||
};
|
||
|
||
const handleToggleMember = async (userId: number, isMember: boolean) => {
|
||
if (!selectedRole) return;
|
||
try {
|
||
if (isMember) {
|
||
const res = await apiRequest('DELETE', `/api/roles/${selectedRole.id}/members/${userId}`);
|
||
const data = await res.json();
|
||
if (data.success) { refetchRoles(); refetchAllUsers(); }
|
||
else toast({ title: 'Ошибка', description: data.error, variant: 'destructive' });
|
||
} else {
|
||
const res = await apiRequest('POST', `/api/roles/${selectedRole.id}/members`, { userId });
|
||
const data = await res.json();
|
||
if (data.success) { refetchRoles(); refetchAllUsers(); }
|
||
else toast({ title: 'Ошибка', description: data.error, variant: 'destructive' });
|
||
}
|
||
} catch {
|
||
toast({ title: 'Ошибка', description: 'Не удалось изменить состав роли', variant: 'destructive' });
|
||
}
|
||
};
|
||
|
||
const moveRoleMemberMutation = useMutation({
|
||
mutationFn: async ({ sourceRoleId, targetRoleId, userId }: { sourceRoleId: number | null; targetRoleId: number | null; userId: number }) => {
|
||
if (sourceRoleId === null && targetRoleId !== null) {
|
||
const res = await apiRequest('POST', `/api/roles/${targetRoleId}/members`, { userId });
|
||
return res.json();
|
||
}
|
||
if (sourceRoleId !== null && targetRoleId === null) {
|
||
const res = await apiRequest('DELETE', `/api/roles/${sourceRoleId}/members/${userId}`);
|
||
return res.json();
|
||
}
|
||
const res = await apiRequest('PUT', `/api/roles/${sourceRoleId}/members/${userId}/move`, { targetRoleId });
|
||
return res.json();
|
||
},
|
||
onSuccess: () => {
|
||
refetchRoles();
|
||
refetchUsers();
|
||
refetchAllUsers();
|
||
},
|
||
onError: () => {
|
||
toast({ title: 'Ошибка', description: 'Не удалось переместить пользователя', variant: 'destructive' });
|
||
},
|
||
});
|
||
|
||
const updateUserStatusMutation = useMutation({
|
||
mutationFn: async ({ userId, statusId }: { userId: number; statusId: number | null }) => {
|
||
const res = await apiRequest('PUT', `/api/users/${userId}/status`, { statusId });
|
||
return res.json();
|
||
},
|
||
onSuccess: () => {
|
||
refetchUsers();
|
||
toast({ title: 'Статус обновлён' });
|
||
},
|
||
onError: () => {
|
||
toast({ title: 'Ошибка', description: 'Не удалось изменить статус', variant: 'destructive' });
|
||
},
|
||
});
|
||
|
||
const openCreateStatus = () => {
|
||
setEditingStatus(null);
|
||
setStatusName('');
|
||
setStatusColor('#e5e7eb');
|
||
setStatusDialogOpen(true);
|
||
};
|
||
|
||
const openEditStatus = (s: UserStatus) => {
|
||
setEditingStatus(s);
|
||
setStatusName(s.name);
|
||
setStatusColor(s.color || '#e5e7eb');
|
||
setStatusDialogOpen(true);
|
||
};
|
||
|
||
const handleSaveStatus = async () => {
|
||
if (!statusName.trim()) return;
|
||
setStatusSaving(true);
|
||
try {
|
||
const body = { name: statusName.trim(), color: statusColor };
|
||
if (editingStatus) {
|
||
const res = await apiRequest('PUT', `/api/user-statuses/${editingStatus.id}`, body);
|
||
const data = await res.json();
|
||
if (data.success) { toast({ title: 'Статус обновлён' }); setStatusDialogOpen(false); refetchStatuses(); }
|
||
else toast({ title: 'Ошибка', description: data.error, variant: 'destructive' });
|
||
} else {
|
||
const res = await apiRequest('POST', '/api/user-statuses', body);
|
||
const data = await res.json();
|
||
if (data.success) { toast({ title: 'Статус создан' }); setStatusDialogOpen(false); refetchStatuses(); }
|
||
else toast({ title: 'Ошибка', description: data.error, variant: 'destructive' });
|
||
}
|
||
} catch {
|
||
toast({ title: 'Ошибка', description: 'Не удалось сохранить статус', variant: 'destructive' });
|
||
} finally {
|
||
setStatusSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleDeleteStatus = async (id: number) => {
|
||
if (!confirm('Удалить этот статус? Пользователям будет назначен статус по умолчанию.')) return;
|
||
try {
|
||
const res = await apiRequest('DELETE', `/api/user-statuses/${id}`);
|
||
const data = await res.json();
|
||
if (data.success) { toast({ title: 'Статус удалён' }); refetchStatuses(); refetchUsers(); }
|
||
else toast({ title: 'Ошибка', description: data.error, variant: 'destructive' });
|
||
} catch {
|
||
toast({ title: 'Ошибка', description: 'Не удалось удалить статус', variant: 'destructive' });
|
||
}
|
||
};
|
||
|
||
const handleSetDefaultStatus = async (id: number) => {
|
||
try {
|
||
const res = await apiRequest('PUT', `/api/user-statuses/${id}`, { isDefault: true });
|
||
const data = await res.json();
|
||
if (data.success) { toast({ title: 'Статус по умолчанию обновлён' }); refetchStatuses(); }
|
||
else toast({ title: 'Ошибка', description: data.error, variant: 'destructive' });
|
||
} catch {
|
||
toast({ title: 'Ошибка', description: 'Не удалось обновить статус по умолчанию', variant: 'destructive' });
|
||
}
|
||
};
|
||
|
||
// Get current member set from live roles data (updates after toggle)
|
||
const currentRole = roles.find(r => r.id === selectedRole?.id);
|
||
const memberUserIds = new Set((currentRole?.members ?? selectedRole?.members ?? []).map((m) => m.userId));
|
||
const currentMemberCount = currentRole?.memberCount ?? selectedRole?.memberCount ?? 0;
|
||
|
||
const searchedUsers = useMemo(() => {
|
||
if (!memberSearch) return allOrgUsers;
|
||
const q = memberSearch.toLowerCase();
|
||
return allOrgUsers.filter((u: any) =>
|
||
u.firstName?.toLowerCase().includes(q) ||
|
||
u.lastName?.toLowerCase().includes(q) ||
|
||
u.middleName?.toLowerCase().includes(q) ||
|
||
u.email?.toLowerCase().includes(q)
|
||
);
|
||
}, [allOrgUsers, memberSearch]);
|
||
|
||
return (
|
||
<Layout>
|
||
<div className="flex items-center justify-between pb-2 border-b border-border mb-3">
|
||
<div>
|
||
<h1 className="text-sm font-medium">Пользователи</h1>
|
||
<p className="text-[10px] text-muted-foreground">Управление пользователями организации</p>
|
||
</div>
|
||
{hasPermission(user, 'users.manage') && (
|
||
<div className="flex items-center gap-2">
|
||
<Button onClick={() => setLocation('/users/fields/edit')} variant="ghost" size="sm" className="h-7 px-2 text-xs">
|
||
<Settings2 className="w-3.5 h-3.5 mr-1.5" />
|
||
Поля профиля
|
||
</Button>
|
||
<Button onClick={() => setInviteModalOpen(true)} variant="outline" className="h-7 px-2.5 text-xs">
|
||
<Send className="w-3.5 h-3.5 mr-1.5" />
|
||
Пригласить
|
||
</Button>
|
||
<Button onClick={() => setUserModalOpen(true)} className="h-7 px-2.5 text-xs" data-testid="button-add-user">
|
||
<Plus className="w-3.5 h-3.5 mr-1.5" />
|
||
Пользователь
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
|
||
<div className="border border-border rounded p-3">
|
||
<div className="flex items-center justify-between mb-1">
|
||
<span className="text-[11px] text-muted-foreground">Всего</span>
|
||
<Users className="h-3.5 w-3.5 text-muted-foreground" />
|
||
</div>
|
||
<div className="text-xl font-medium" data-testid="stat-total-users">{stats.totalUsers}</div>
|
||
</div>
|
||
<div className="border border-border rounded p-3">
|
||
<div className="flex items-center justify-between mb-1">
|
||
<span className="text-[11px] text-muted-foreground">Активных</span>
|
||
<UserCheck className="h-3.5 w-3.5 text-muted-foreground" />
|
||
</div>
|
||
<div className="text-xl font-medium" data-testid="stat-active-users">{stats.activeUsers}</div>
|
||
</div>
|
||
<div className="border border-border rounded p-3">
|
||
<div className="flex items-center justify-between mb-1">
|
||
<span className="text-[11px] text-muted-foreground">Администраторов</span>
|
||
<Shield className="h-3.5 w-3.5 text-muted-foreground" />
|
||
</div>
|
||
<div className="text-xl font-medium" data-testid="stat-admin-users">{stats.adminUsers}</div>
|
||
</div>
|
||
<div className="border border-border rounded p-3">
|
||
<div className="flex items-center justify-between mb-1">
|
||
<span className="text-[11px] text-muted-foreground">Неактивных</span>
|
||
<UserX className="h-3.5 w-3.5 text-muted-foreground" />
|
||
</div>
|
||
<div className="text-xl font-medium" data-testid="stat-inactive-users">{stats.inactiveUsers}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||
<TabsList className="h-7 mb-3">
|
||
<TabsTrigger value="users" className="text-xs h-6 px-3">
|
||
<Users className="w-3 h-3 mr-1.5" />
|
||
Пользователи
|
||
</TabsTrigger>
|
||
<TabsTrigger value="roles" className="text-xs h-6 px-3">
|
||
<FolderTree className="w-3 h-3 mr-1.5" />
|
||
Роли
|
||
</TabsTrigger>
|
||
<TabsTrigger value="statuses" className="text-xs h-6 px-3">
|
||
<UserCheck className="w-3 h-3 mr-1.5" />
|
||
Статусы
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
|
||
<TabsContent value="users">
|
||
<div className="border border-border rounded">
|
||
{hasActiveFilters && (
|
||
<div className="px-3 pt-2">
|
||
<ActiveFilterBadges filters={filters} sort={sort} columnLabels={userColumnLabels} onRemoveFilter={clearFilter} onRemoveSort={clearSort} onClearAll={clearAll} />
|
||
</div>
|
||
)}
|
||
<div className="p-3">
|
||
<Table className="text-xs">
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>
|
||
<ColumnFilter column="name" label="Пользователь" filterValue={filters['name'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchUserColumnSuggestions('name')} />
|
||
</TableHead>
|
||
<TableHead className="w-[60px]">ID</TableHead>
|
||
<TableHead>
|
||
<ColumnFilter column="email" label="Email" filterValue={filters['email'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchUserColumnSuggestions('email')} />
|
||
</TableHead>
|
||
<TableHead>Телефон</TableHead>
|
||
<TableHead>
|
||
<ColumnFilter column="role" label="Роль" filterValue={filters['role'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchUserColumnSuggestions('role')} />
|
||
</TableHead>
|
||
<TableHead>Орг. роли</TableHead>
|
||
<TableHead>Статус</TableHead>
|
||
<TableHead>
|
||
<ColumnFilter column="status" label="Активность" filterValue={filters['status'] || ''} sort={sort} onFilterChange={setFilter} onSortChange={setSort} fetchSuggestions={fetchUserColumnSuggestions('status')} />
|
||
</TableHead>
|
||
<TableHead className="text-center">Действия</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{filteredUsers.map((tableUser: any, index: number) => (
|
||
<TableRow
|
||
key={tableUser.id}
|
||
data-testid={`row-user-${tableUser.id}`}
|
||
className="cursor-pointer hover:bg-muted/40"
|
||
onClick={() => setLocation(`/users/${tableUser.id}`)}
|
||
>
|
||
<TableCell>
|
||
<div className="flex items-center gap-2">
|
||
<UserAvatar
|
||
userId={tableUser.id}
|
||
firstName={tableUser.firstName}
|
||
lastName={tableUser.lastName}
|
||
middleName={tableUser.middleName}
|
||
sizeClass="w-7 h-7"
|
||
/>
|
||
<div>
|
||
<div className="font-medium text-xs" data-testid={`text-username-${tableUser.id}`}>
|
||
{tableUser.firstName} {tableUser.middleName} {tableUser.lastName}
|
||
</div>
|
||
<div className="text-[10px] text-muted-foreground">{tableUser.position || 'Не указана'}</div>
|
||
</div>
|
||
</div>
|
||
</TableCell>
|
||
<TableCell className="text-xs text-muted-foreground">{tableUser.id}</TableCell>
|
||
<TableCell>
|
||
<div className="text-xs" data-testid={`text-email-${tableUser.id}`}>{tableUser.email}</div>
|
||
</TableCell>
|
||
<TableCell>
|
||
<div className="text-xs text-muted-foreground" data-testid={`text-phone-${tableUser.id}`}>{tableUser.phone || '—'}</div>
|
||
</TableCell>
|
||
<TableCell>
|
||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${isAdmin(tableUser) ? 'bg-muted text-foreground font-medium' : 'text-muted-foreground'}`}>
|
||
{tableUser.appRole === 'admin' ? 'Администратор' : tableUser.appRole === 'accountant' ? 'Бухгалтер' : 'Пользователь'}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell>
|
||
<span className="text-[10px] text-muted-foreground">
|
||
{(tableUser.organizationalRoles || []).map((r: any) => r.name).join(', ') || '—'}
|
||
</span>
|
||
</TableCell>
|
||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||
{hasPermission(user, 'users.manage') ? (
|
||
<Select
|
||
value={tableUser.statusId ? String(tableUser.statusId) : 'none'}
|
||
onValueChange={(value) => updateUserStatusMutation.mutate({ userId: tableUser.id, statusId: value === 'none' ? null : Number(value) })}
|
||
>
|
||
<SelectTrigger className="h-6 min-w-[120px] text-[10px] px-2">
|
||
<SelectValue placeholder="—" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="none">—</SelectItem>
|
||
{userStatuses.map((s) => (
|
||
<SelectItem key={s.id} value={String(s.id)}>
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: s.color }} />
|
||
{s.name}
|
||
</div>
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
) : (
|
||
tableUser.status ? (
|
||
<Badge
|
||
className="text-[10px] font-normal px-1.5 border-0"
|
||
style={{ backgroundColor: tableUser.status.color, color: '#000' }}
|
||
>
|
||
{tableUser.status.name}
|
||
</Badge>
|
||
) : (
|
||
<span className="text-[10px] text-muted-foreground">—</span>
|
||
)
|
||
)}
|
||
</TableCell>
|
||
<TableCell className="text-[10px] text-muted-foreground" data-testid={`text-last-activity-${tableUser.id}`}>
|
||
{(() => {
|
||
const activityAt = tableUser.lastActivityAt || tableUser.lastLogin;
|
||
const isOnline = activityAt && (new Date().getTime() - new Date(activityAt).getTime()) < 15 * 60 * 1000;
|
||
return (
|
||
<div className="flex items-center gap-1">
|
||
<div className="flex items-center gap-1 mr-2">
|
||
<div className={`w-1.5 h-1.5 rounded-full ${tableUser.isActive ? 'bg-green-500' : 'bg-muted-foreground'}`} />
|
||
<span>{tableUser.isActive ? 'Активен' : 'Неактивен'}</span>
|
||
</div>
|
||
{isOnline && <div className="w-1.5 h-1.5 rounded-full bg-green-500" title="Онлайн" />}
|
||
<span>{formatLastActivity(activityAt)}</span>
|
||
</div>
|
||
);
|
||
})()}
|
||
</TableCell>
|
||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||
{hasPermission(user, 'users.manage') && (
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<button className="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-muted" data-testid={`button-actions-${tableUser.id}`}>
|
||
<MoreHorizontal className="w-3.5 h-3.5" />
|
||
</button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end">
|
||
<DropdownMenuItem onClick={() => handleEditUser(tableUser)} className="text-xs" data-testid={`button-edit-${tableUser.id}`}>
|
||
<Edit className="w-3.5 h-3.5 mr-1.5" />
|
||
Редактировать
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem onClick={() => setLocation('/users/fields/edit')} className="text-xs">
|
||
<Settings2 className="w-3.5 h-3.5 mr-1.5" />
|
||
Настроить поля профиля
|
||
</DropdownMenuItem>
|
||
{!tableUser.emailVerified && (
|
||
<DropdownMenuItem className="text-xs">
|
||
<Mail className="w-3.5 h-3.5 mr-1.5" />
|
||
Приглашение
|
||
</DropdownMenuItem>
|
||
)}
|
||
{tableUser.isActive && (
|
||
<DropdownMenuItem onClick={() => handleDeactivateUser(tableUser.id)} className="text-xs" data-testid={`button-deactivate-${tableUser.id}`}>
|
||
<UserX className="w-3.5 h-3.5 mr-1.5" />
|
||
Деактивировать
|
||
</DropdownMenuItem>
|
||
)}
|
||
{tableUser.id !== user?.id && (
|
||
<DropdownMenuItem onClick={() => handleDeleteUser(tableUser.id)} className="text-destructive text-xs" data-testid={`button-delete-${tableUser.id}`}>
|
||
<Trash2 className="w-3.5 h-3.5 mr-1.5" />
|
||
Удалить
|
||
</DropdownMenuItem>
|
||
)}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
)}
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
{filteredUsers.length === 0 && (
|
||
<div className="text-center py-6 text-xs text-muted-foreground" data-testid="empty-users">
|
||
{hasActiveFilters ? 'Пользователи не найдены' : 'Нет пользователей'}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{(usersData?.total ?? 0) > pageSize && (
|
||
<div className="py-3 border-t border-border">
|
||
<Pagination>
|
||
<PaginationContent>
|
||
<PaginationItem>
|
||
<PaginationPrevious
|
||
href="#"
|
||
onClick={(e) => { e.preventDefault(); setPage(p => Math.max(1, p - 1)); }}
|
||
className={page <= 1 ? 'pointer-events-none opacity-50' : ''}
|
||
/>
|
||
</PaginationItem>
|
||
{Array.from({ length: Math.ceil((usersData?.total ?? 0) / pageSize) }, (_, i) => i + 1)
|
||
.filter(p => p === 1 || p === Math.ceil((usersData?.total ?? 0) / pageSize) || Math.abs(p - page) <= 1)
|
||
.map((p, idx, arr) => {
|
||
const prev = arr[idx - 1];
|
||
const showEllipsis = prev && p - prev > 1;
|
||
return (
|
||
<React.Fragment key={p}>
|
||
{showEllipsis && <PaginationItem><PaginationEllipsis /></PaginationItem>}
|
||
<PaginationItem>
|
||
<PaginationLink
|
||
href="#"
|
||
isActive={p === page}
|
||
onClick={(e) => { e.preventDefault(); setPage(p); }}
|
||
>
|
||
{p}
|
||
</PaginationLink>
|
||
</PaginationItem>
|
||
</React.Fragment>
|
||
);
|
||
})}
|
||
<PaginationItem>
|
||
<PaginationNext
|
||
href="#"
|
||
onClick={(e) => { e.preventDefault(); setPage(p => Math.min(Math.ceil((usersData?.total ?? 0) / pageSize), p + 1)); }}
|
||
className={page >= Math.ceil((usersData?.total ?? 0) / pageSize) ? 'pointer-events-none opacity-50' : ''}
|
||
/>
|
||
</PaginationItem>
|
||
</PaginationContent>
|
||
</Pagination>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="roles">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<p className="text-xs text-muted-foreground">Иерархия ролей организации</p>
|
||
{hasPermission(user, 'roles.manage') && (
|
||
<Button onClick={() => openCreateRole()} className="h-7 px-2.5 text-xs">
|
||
<Plus className="w-3.5 h-3.5 mr-1.5" />
|
||
Создать роль
|
||
</Button>
|
||
)}
|
||
</div>
|
||
{roleTree.length === 0 && usersWithoutRole.length === 0 ? (
|
||
<div className="border border-border rounded p-8 text-center text-xs text-muted-foreground">
|
||
<FolderTree className="w-8 h-8 mx-auto mb-2 opacity-30" />
|
||
Ролей пока нет.{hasPermission(user, 'roles.manage') && ' Создайте первую роль.'}
|
||
</div>
|
||
) : (
|
||
<DndContext sensors={dndSensors} onDragEnd={handleDragEnd}>
|
||
<div>
|
||
<NoRoleDroppable
|
||
count={usersWithoutRole.length}
|
||
open={noRoleOpen}
|
||
onToggle={() => setNoRoleOpen((p) => !p)}
|
||
disabled={!canReassign}
|
||
/>
|
||
{noRoleOpen && usersWithoutRole.length > 0 && (
|
||
<div>
|
||
{usersWithoutRole.map((u: any) => (
|
||
<DraggableMember
|
||
key={u.id}
|
||
member={{ id: u.id, userId: u.id, user: u }}
|
||
sourceRoleId={null}
|
||
indent={44}
|
||
disabled={!canReassign}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
{roleTree.map((node) => (
|
||
<RoleNode
|
||
key={node.id}
|
||
node={node}
|
||
depth={0}
|
||
onEdit={openEditRole}
|
||
onAddChild={(parentId) => openCreateRole(parentId)}
|
||
onDelete={handleDeleteRole}
|
||
onManageMembers={openManageMembers}
|
||
isAdmin={hasPermission(user, 'roles.manage')}
|
||
canReassign={canReassign}
|
||
/>
|
||
))}
|
||
</div>
|
||
</DndContext>
|
||
)}
|
||
</TabsContent>
|
||
|
||
<TabsContent value="statuses">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<p className="text-xs text-muted-foreground">Статусы пользователей организации</p>
|
||
{hasPermission(user, 'users.manage') && (
|
||
<Button onClick={openCreateStatus} className="h-7 px-2.5 text-xs">
|
||
<Plus className="w-3.5 h-3.5 mr-1.5" />
|
||
Создать статус
|
||
</Button>
|
||
)}
|
||
</div>
|
||
{userStatuses.length === 0 ? (
|
||
<div className="border border-border rounded p-8 text-center text-xs text-muted-foreground">
|
||
<UserCheck className="w-8 h-8 mx-auto mb-2 opacity-30" />
|
||
Статусов пока нет.{hasPermission(user, 'users.manage') && ' Создайте первый статус.'}
|
||
</div>
|
||
) : (
|
||
<div className="border border-border rounded divide-y divide-border">
|
||
{userStatuses.map((s) => (
|
||
<div key={s.id} className="flex items-center justify-between px-3 py-2 hover:bg-muted/30">
|
||
<div className="flex items-center gap-2">
|
||
<span className="w-3 h-3 rounded-full border border-border" style={{ backgroundColor: s.color }} />
|
||
<span className="text-xs font-medium">{s.name}</span>
|
||
{s.isDefault && <Badge variant="secondary" className="text-[10px] font-normal">По умолчанию</Badge>}
|
||
</div>
|
||
{hasPermission(user, 'users.manage') && (
|
||
<div className="flex items-center gap-1">
|
||
{!s.isDefault && (
|
||
<Button variant="ghost" size="sm" className="h-6 px-2 text-[10px]" onClick={() => handleSetDefaultStatus(s.id)}>
|
||
По умолчанию
|
||
</Button>
|
||
)}
|
||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => openEditStatus(s)}>
|
||
<Edit className="w-3 h-3" />
|
||
</Button>
|
||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 text-destructive hover:text-destructive" onClick={() => handleDeleteStatus(s.id)}>
|
||
<Trash2 className="w-3 h-3" />
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</TabsContent>
|
||
</Tabs>
|
||
|
||
<UserModal
|
||
open={userModalOpen}
|
||
onOpenChange={(open) => { setUserModalOpen(open); if (!open) setEditingUser(null); }}
|
||
editingUser={editingUser}
|
||
/>
|
||
|
||
{/* Invite Modal */}
|
||
<Dialog open={inviteModalOpen} onOpenChange={setInviteModalOpen}>
|
||
<DialogContent className="sm:max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-sm">Пригласить пользователя</DialogTitle>
|
||
<DialogDescription className="text-xs">Создайте приглашение для нового сотрудника организации</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-4 py-2">
|
||
<div className="space-y-2">
|
||
<Label className="text-xs">Email приглашаемого</Label>
|
||
<div className="flex gap-2">
|
||
<Input type="email" placeholder="email@example.com" value={inviteEmail} onChange={(e) => setInviteEmail(e.target.value)} className="h-7 text-xs" />
|
||
<Button onClick={handleCreateInvitation} disabled={inviteLoading || !inviteEmail.trim()} className="h-7 px-2.5 text-xs">
|
||
{inviteLoading ? 'Создание...' : 'Создать'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
{invitations.length > 0 && (
|
||
<div className="space-y-2">
|
||
<Label className="text-xs">Активные приглашения</Label>
|
||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||
{invitations.filter((inv: any) => !inv.isUsed && !inv.isExpired).map((inv: any) => (
|
||
<div key={inv.id} className="flex items-center justify-between p-2 bg-muted rounded text-xs">
|
||
<div className="flex-1 min-w-0">
|
||
<div className="font-medium truncate">{inv.email}</div>
|
||
<div className="text-[10px] text-muted-foreground flex items-center gap-1">
|
||
<Clock className="w-3 h-3" />
|
||
до {new Date(inv.expiresAt).toLocaleDateString('ru-RU')}
|
||
</div>
|
||
</div>
|
||
<div className="flex items-center gap-1 ml-2">
|
||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => handleCopyLink(inv.inviteLink)}>
|
||
{copiedLink === inv.inviteLink ? <Check className="w-3 h-3 text-green-500" /> : <Copy className="w-3 h-3" />}
|
||
</Button>
|
||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 text-destructive hover:text-destructive" onClick={() => handleDeleteInvitation(inv.id)}>
|
||
<Trash2 className="w-3 h-3" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{invitations.filter((inv: any) => inv.isUsed).length > 0 && (
|
||
<div className="space-y-2">
|
||
<Label className="text-xs text-muted-foreground">Использованные приглашения</Label>
|
||
<div className="space-y-1">
|
||
{invitations.filter((inv: any) => inv.isUsed).slice(0, 3).map((inv: any) => (
|
||
<div key={inv.id} className="flex items-center gap-2 p-2 bg-muted/50 rounded text-xs text-muted-foreground">
|
||
<Check className="w-3 h-3 text-green-500" />
|
||
<span className="truncate">{inv.email}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* Role Create/Edit Dialog */}
|
||
<Dialog open={roleDialogOpen} onOpenChange={setRoleDialogOpen}>
|
||
<DialogContent className="sm:max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-sm">{editingRole ? 'Редактировать роль' : 'Создать роль'}</DialogTitle>
|
||
<DialogDescription className="text-xs">
|
||
{editingRole ? 'Измените название или описание роли' : 'Укажите название для новой роли'}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-3 py-2">
|
||
<div>
|
||
<Label className="text-xs">Название роли</Label>
|
||
<Input
|
||
placeholder="Менеджер проекта"
|
||
value={roleName}
|
||
onChange={(e) => setRoleName(e.target.value)}
|
||
className="h-7 text-xs mt-1"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label className="text-xs">Описание (необязательно)</Label>
|
||
<Textarea
|
||
placeholder="Краткое описание обязанностей роли"
|
||
value={roleDescription}
|
||
onChange={(e) => setRoleDescription(e.target.value)}
|
||
className="text-xs mt-1 min-h-[60px] resize-none"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label className="text-xs">Родительская роль (необязательно)</Label>
|
||
<Select value={roleParentId} onValueChange={setRoleParentId}>
|
||
<SelectTrigger className="h-7 text-xs mt-1">
|
||
<SelectValue placeholder="Нет (корневая роль)" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value={PARENT_NONE}>Нет (корневая роль)</SelectItem>
|
||
{roles
|
||
.filter((r) => r.id !== editingRole?.id)
|
||
.map((r) => (
|
||
<SelectItem key={r.id} value={String(r.id)}>{r.name}</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div>
|
||
<Label className="text-xs">Руководитель роли (необязательно)</Label>
|
||
<Select value={roleManagerId} onValueChange={setRoleManagerId}>
|
||
<SelectTrigger className="h-7 text-xs mt-1">
|
||
<SelectValue placeholder="Нет руководителя" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value={PARENT_NONE}>Нет руководителя</SelectItem>
|
||
{filteredUsers.map((u: any) => (
|
||
<SelectItem key={u.id} value={String(u.id)}>
|
||
{u.lastName} {u.firstName} ({u.email})
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
<div className="flex gap-2 pt-1">
|
||
<Button variant="outline" className="h-7 text-xs flex-1" onClick={() => setRoleDialogOpen(false)}>
|
||
Отмена
|
||
</Button>
|
||
<Button className="h-7 text-xs flex-1" disabled={roleSaving || !roleName.trim()} onClick={handleSaveRole}>
|
||
{roleSaving ? 'Сохранение...' : (editingRole ? 'Сохранить' : 'Создать')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* Role Members Dialog */}
|
||
<Dialog open={membersDialogOpen} onOpenChange={(open) => { setMembersDialogOpen(open); if (!open) setSelectedRole(null); }}>
|
||
<DialogContent className="sm:max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-sm">Участники роли: {selectedRole?.name}</DialogTitle>
|
||
<DialogDescription className="text-xs">
|
||
Отметьте пользователей, которые входят в эту роль
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-3 py-2">
|
||
<Input
|
||
placeholder="Поиск пользователя..."
|
||
value={memberSearch}
|
||
onChange={(e) => setMemberSearch(e.target.value)}
|
||
className="h-7 text-xs"
|
||
/>
|
||
<div className="space-y-0.5 max-h-72 overflow-y-auto border border-border rounded p-1">
|
||
{searchedUsers.length === 0 && (
|
||
<div className="text-xs text-muted-foreground text-center py-3">Нет пользователей</div>
|
||
)}
|
||
{searchedUsers.map((u: any) => {
|
||
const isMember = memberUserIds.has(u.id);
|
||
return (
|
||
<label
|
||
key={u.id}
|
||
className="flex items-center gap-2.5 px-2 py-1.5 hover:bg-muted rounded cursor-pointer"
|
||
>
|
||
<Checkbox
|
||
checked={isMember}
|
||
disabled={!hasPermission(user, 'roles.manage')}
|
||
onCheckedChange={() => handleToggleMember(u.id, isMember)}
|
||
/>
|
||
<div className="flex-1 min-w-0">
|
||
<div className="text-xs font-medium leading-tight">{u.firstName} {u.middleName} {u.lastName}</div>
|
||
<div className="text-[10px] text-muted-foreground">{u.email}</div>
|
||
</div>
|
||
{isMember && <Check className="w-3 h-3 text-green-500 flex-shrink-0" />}
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
<p className="text-[10px] text-muted-foreground">
|
||
Выбрано: {currentMemberCount} из {allOrgUsers.length} пользователей
|
||
</p>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* Status Create/Edit Dialog */}
|
||
<Dialog open={statusDialogOpen} onOpenChange={setStatusDialogOpen}>
|
||
<DialogContent className="sm:max-w-md">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-sm">{editingStatus ? 'Редактировать статус' : 'Создать статус'}</DialogTitle>
|
||
<DialogDescription className="text-xs">
|
||
{editingStatus ? 'Измените название или цвет статуса' : 'Укажите название и цвет для нового статуса'}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<div className="space-y-3 py-2">
|
||
<div>
|
||
<Label className="text-xs">Название статуса</Label>
|
||
<Input
|
||
placeholder="Например, На испытательном сроке"
|
||
value={statusName}
|
||
onChange={(e) => setStatusName(e.target.value)}
|
||
className="h-7 text-xs mt-1"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<Label className="text-xs">Цвет</Label>
|
||
<div className="flex items-center gap-2 mt-1">
|
||
<input
|
||
type="color"
|
||
value={statusColor}
|
||
onChange={(e) => setStatusColor(e.target.value)}
|
||
className="h-7 w-12 rounded border border-border cursor-pointer"
|
||
/>
|
||
<Input
|
||
value={statusColor}
|
||
onChange={(e) => setStatusColor(e.target.value)}
|
||
className="h-7 text-xs flex-1"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="flex gap-2 pt-1">
|
||
<Button variant="outline" className="h-7 text-xs flex-1" onClick={() => setStatusDialogOpen(false)}>
|
||
Отмена
|
||
</Button>
|
||
<Button className="h-7 text-xs flex-1" disabled={statusSaving || !statusName.trim()} onClick={handleSaveStatus}>
|
||
{statusSaving ? 'Сохранение...' : (editingStatus ? 'Сохранить' : 'Создать')}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</Layout>
|
||
);
|
||
};
|
||
|
||
export default UsersPage;
|