Files
iistwin/client/src/pages/Users.tsx
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

1468 lines
64 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;