forked from baron/baron-sso
905 lines
33 KiB
TypeScript
905 lines
33 KiB
TypeScript
import {
|
|
type UseMutationResult,
|
|
useMutation,
|
|
useQuery,
|
|
useQueryClient,
|
|
} from "@tanstack/react-query";
|
|
import type { AxiosError } from "axios";
|
|
import {
|
|
ArrowRightLeft,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Plus,
|
|
RefreshCw,
|
|
Search,
|
|
Shield,
|
|
Trash2,
|
|
UserMinus,
|
|
UserPlus,
|
|
Users,
|
|
} from "lucide-react";
|
|
import type React from "react";
|
|
import { useState } from "react";
|
|
import { useParams } from "react-router-dom";
|
|
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
|
|
import { Badge } from "../../../components/ui/badge";
|
|
import { Button } from "../../../components/ui/button";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "../../../components/ui/card";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogTrigger,
|
|
} from "../../../components/ui/dialog";
|
|
import { Input } from "../../../components/ui/input";
|
|
import { Label } from "../../../components/ui/label";
|
|
import { ScrollArea } from "../../../components/ui/scroll-area";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "../../../components/ui/table";
|
|
import { toast } from "../../../components/ui/use-toast";
|
|
import {
|
|
addGroupMember,
|
|
createGroup,
|
|
deleteGroup,
|
|
fetchGroups,
|
|
fetchTenant,
|
|
fetchUsers,
|
|
type GroupSummary,
|
|
removeGroupMember,
|
|
} from "../../../lib/adminApi";
|
|
import { t } from "../../../lib/i18n";
|
|
|
|
type UserGroupNode = GroupSummary & {
|
|
children: UserGroupNode[];
|
|
isExpanded?: boolean;
|
|
};
|
|
|
|
function buildGroupTree(
|
|
groups: GroupSummary[],
|
|
parentId: string | null = null,
|
|
): UserGroupNode[] {
|
|
const nodes: UserGroupNode[] = [];
|
|
const childrenOf = new Map<string, UserGroupNode[]>();
|
|
|
|
// First pass: Initialize all groups as nodes and populate childrenOf map
|
|
for (const group of groups) {
|
|
childrenOf.set(group.id, []);
|
|
}
|
|
|
|
// Second pass: Populate children
|
|
for (const group of groups) {
|
|
const node: UserGroupNode = {
|
|
...group,
|
|
children: childrenOf.get(group.id) ?? [],
|
|
};
|
|
if (group.parentId === parentId) {
|
|
nodes.push(node);
|
|
} else {
|
|
// Check if the parent exists before adding to children
|
|
// This handles cases where a parent might not be in the current 'groups' list (e.g., filtered data)
|
|
if (group.parentId && childrenOf.has(group.parentId)) {
|
|
childrenOf.get(group.parentId)?.push(node);
|
|
} else {
|
|
// If parentId exists but parent not found, it's a root level group for this tree view
|
|
nodes.push(node);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sort children for consistent rendering (optional, but good for UI)
|
|
nodes.sort((a, b) => a.name.localeCompare(b.name));
|
|
for (const node of nodes) {
|
|
node.children.sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
|
|
return nodes;
|
|
}
|
|
|
|
interface UserGroupTreeNodeProps {
|
|
node: UserGroupNode;
|
|
level: number;
|
|
onSelect: (groupId: string) => void;
|
|
selectedGroupId: string | null;
|
|
onDelete: (groupId: string) => void;
|
|
onAddSubGroup: (parentId: string) => void;
|
|
addMemberMutation: UseMutationResult<
|
|
void,
|
|
AxiosError<{ error?: string }>,
|
|
{ groupId: string; userId: string }
|
|
>;
|
|
removeMemberMutation: UseMutationResult<
|
|
void,
|
|
AxiosError<{ error?: string }>,
|
|
{ groupId: string; userId: string }
|
|
>;
|
|
}
|
|
|
|
const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
|
|
node,
|
|
level,
|
|
onSelect,
|
|
selectedGroupId,
|
|
onDelete,
|
|
onAddSubGroup,
|
|
addMemberMutation,
|
|
removeMemberMutation,
|
|
}) => {
|
|
const [isExpanded, setIsExpanded] = useState(true);
|
|
const hasChildren = node.children.length > 0;
|
|
|
|
const handleToggleExpand = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
setIsExpanded(!isExpanded);
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<TableRow
|
|
className={`cursor-pointer ${selectedGroupId === node.id ? "bg-primary/5" : ""}`}
|
|
onClick={() => onSelect(node.id)}
|
|
>
|
|
<TableCell style={{ paddingLeft: `${1 + level * 1.5}rem` }}>
|
|
<div className="flex items-center gap-2">
|
|
{hasChildren ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleToggleExpand}
|
|
className="h-6 w-6 p-0"
|
|
>
|
|
{isExpanded ? (
|
|
<ChevronDown size={16} />
|
|
) : (
|
|
<ChevronRight size={16} />
|
|
)}
|
|
</Button>
|
|
) : (
|
|
level > 0 && (
|
|
<span className="inline-block w-6 text-center">
|
|
<ChevronRight
|
|
size={16}
|
|
className="text-muted-foreground inline-block align-middle"
|
|
/>
|
|
</span>
|
|
)
|
|
)}
|
|
<Users size={14} className="text-muted-foreground" />
|
|
<span className="font-semibold">{node.name}</span>
|
|
<Badge variant="secondary" className="text-[10px] font-mono">
|
|
{node.unitType || "Team"}
|
|
</Badge>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant="secondary">
|
|
{t("msg.admin.groups.members.count", "{{count}} 명", {
|
|
count: node.members?.length || 0,
|
|
})}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<div className="flex justify-end gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onAddSubGroup(node.id);
|
|
}}
|
|
>
|
|
<Plus size={14} />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onDelete(node.id);
|
|
}}
|
|
>
|
|
<Trash2 size={14} className="text-destructive" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
{isExpanded &&
|
|
hasChildren &&
|
|
node.children.map((child) => (
|
|
<UserGroupTreeNode
|
|
key={child.id}
|
|
node={child}
|
|
level={level + 1}
|
|
onSelect={onSelect}
|
|
selectedGroupId={selectedGroupId}
|
|
onDelete={onDelete}
|
|
onAddSubGroup={onAddSubGroup}
|
|
addMemberMutation={addMemberMutation}
|
|
removeMemberMutation={removeMemberMutation}
|
|
/>
|
|
))}
|
|
</>
|
|
);
|
|
};
|
|
|
|
function TenantGroupsPage() {
|
|
const params = useParams<{ tenantId: string }>();
|
|
const tenantId = params.tenantId ?? "";
|
|
const queryClient = useQueryClient();
|
|
|
|
const [newGroupName, setNewGroupName] = useState("");
|
|
const [newGroupDesc, setNewGroupNameDesc] = useState("");
|
|
const [newGroupUnitType, setNewGroupUnitType] = useState("Team");
|
|
const [newGroupParentId, setNewGroupParentId] = useState<string | null>(null);
|
|
|
|
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
|
|
|
// Modal States
|
|
const [isAddMemberModalOpen, setIsAddMemberModalOpen] = useState(false);
|
|
const [isMoveMemberModalOpen, setIsMoveMemberModalOpen] = useState(false);
|
|
const [memberActionTargetUserId, setMemberActionTargetUserId] = useState<
|
|
string | null
|
|
>(null);
|
|
const [userSearchTerm, setUserSearchTerm] = useState("");
|
|
const [groupSearchTerm, setGroupSearchTerm] = useState("");
|
|
|
|
// 테넌트 정보 조회 (slug 획득)
|
|
const tenantQuery = useQuery({
|
|
queryKey: ["tenant", tenantId],
|
|
queryFn: () => fetchTenant(tenantId),
|
|
enabled: tenantId.length > 0,
|
|
});
|
|
const tenantSlug = tenantQuery.data?.slug;
|
|
|
|
// 해당 테넌트의 사용자 목록 조회
|
|
const usersQuery = useQuery({
|
|
queryKey: ["users", { tenantSlug }],
|
|
queryFn: () => fetchUsers(1000, 0, undefined, tenantSlug),
|
|
enabled: !!tenantSlug,
|
|
});
|
|
const users = usersQuery.data?.items ?? [];
|
|
|
|
// 그룹 목록 조회
|
|
const groupsQuery = useQuery({
|
|
queryKey: ["groups", tenantId],
|
|
queryFn: () => fetchGroups(tenantId),
|
|
enabled: tenantId.length > 0,
|
|
});
|
|
// 그룹 생성
|
|
const createMutation = useMutation({
|
|
mutationFn: () =>
|
|
createGroup(tenantId, {
|
|
name: newGroupName,
|
|
description: newGroupDesc,
|
|
unitType: newGroupUnitType,
|
|
parentId: newGroupParentId || undefined,
|
|
}),
|
|
onSuccess: () => {
|
|
toast.success(
|
|
t(
|
|
"msg.admin.groups.list.create_success",
|
|
"그룹이 성공적으로 생성되었습니다.",
|
|
),
|
|
);
|
|
groupsQuery.refetch();
|
|
setNewGroupName("");
|
|
setNewGroupNameDesc("");
|
|
setNewGroupUnitType("Team");
|
|
setNewGroupParentId(null);
|
|
},
|
|
onError: (error: AxiosError<{ error?: string }>) => {
|
|
toast.error(t("msg.admin.groups.list.create_error", "그룹 생성 실패"), {
|
|
description: error.response?.data?.error || error.message,
|
|
});
|
|
},
|
|
});
|
|
|
|
// 그룹 삭제
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (id: string) => deleteGroup(tenantId, id),
|
|
onSuccess: () => {
|
|
toast.success(
|
|
t("msg.admin.groups.list.delete_success", "그룹이 삭제되었습니다."),
|
|
);
|
|
groupsQuery.refetch();
|
|
setSelectedGroupId(null);
|
|
},
|
|
onError: (error: AxiosError<{ error?: string }>) => {
|
|
toast.error(t("msg.admin.groups.list.delete_error", "그룹 삭제 실패"), {
|
|
description: error.response?.data?.error || error.message,
|
|
});
|
|
},
|
|
});
|
|
|
|
// 멤버 추가
|
|
const addMemberMutation = useMutation({
|
|
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
|
|
addGroupMember(tenantId, groupId, userId),
|
|
onSuccess: () => {
|
|
toast.success(
|
|
t("msg.admin.groups.members.add_success", "멤버가 추가되었습니다."),
|
|
);
|
|
groupsQuery.refetch();
|
|
},
|
|
onError: (error: AxiosError<{ error?: string }>) => {
|
|
toast.error(t("msg.common.error", "오류 발생"), {
|
|
description: error.response?.data?.error || error.message,
|
|
});
|
|
},
|
|
});
|
|
|
|
// 멤버 제거
|
|
const removeMemberMutation = useMutation({
|
|
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
|
|
removeGroupMember(tenantId, groupId, userId),
|
|
onSuccess: () => {
|
|
toast.success(
|
|
t("msg.admin.groups.members.remove_success", "멤버가 제거되었습니다."),
|
|
);
|
|
groupsQuery.refetch();
|
|
},
|
|
onError: (error: AxiosError<{ error?: string }>) => {
|
|
toast.error(t("msg.common.error", "오류 발생"), {
|
|
description: error.response?.data?.error || error.message,
|
|
});
|
|
},
|
|
});
|
|
|
|
// 멤버 이동 (Remove -> Add)
|
|
const moveMemberMutation = useMutation({
|
|
mutationFn: async ({
|
|
sourceGroupId,
|
|
targetGroupId,
|
|
userId,
|
|
}: {
|
|
sourceGroupId: string;
|
|
targetGroupId: string;
|
|
userId: string;
|
|
}) => {
|
|
await removeGroupMember(tenantId, sourceGroupId, userId);
|
|
await addGroupMember(tenantId, targetGroupId, userId);
|
|
},
|
|
onSuccess: () => {
|
|
toast.success(
|
|
t("msg.admin.groups.members.move_success", "멤버가 이동되었습니다."),
|
|
);
|
|
groupsQuery.refetch();
|
|
setIsMoveMemberModalOpen(false);
|
|
setMemberActionTargetUserId(null);
|
|
},
|
|
onError: (error: AxiosError<{ error?: string }>) => {
|
|
toast.error(t("msg.common.error", "오류 발생"), {
|
|
description: error.response?.data?.error || error.message,
|
|
});
|
|
},
|
|
});
|
|
|
|
const groupTree = groupsQuery.data
|
|
? buildGroupTree(groupsQuery.data, tenantId)
|
|
: [];
|
|
|
|
const handleAddSubGroup = (parentId: string) => {
|
|
setNewGroupParentId(parentId);
|
|
};
|
|
const currentGroup = groupsQuery.data?.find((g) => g.id === selectedGroupId);
|
|
|
|
return (
|
|
<>
|
|
<div className="space-y-6 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
|
<div className="grid gap-6 md:grid-cols-3 flex-1 min-h-0">
|
|
{/* 그룹 생성 폼 */}
|
|
<Card className="flex flex-col min-h-0 bg-[var(--color-panel)] md:col-span-1 border-primary/20">
|
|
<CardHeader className="flex-shrink-0">
|
|
<CardTitle className="text-sm flex items-center gap-2">
|
|
<Plus size={16} />{" "}
|
|
{t("ui.admin.groups.create.title", "새 그룹 생성")}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{t(
|
|
"ui.admin.groups.create.description",
|
|
"새로운 사용자 그룹을 생성하고 계층 구조를 설정합니다.",
|
|
)}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4 flex-1 overflow-auto">
|
|
<div className="space-y-1">
|
|
<Label htmlFor="name">
|
|
{t("ui.admin.groups.form.name_label", "그룹 이름")}
|
|
</Label>
|
|
<Input
|
|
id="name"
|
|
value={newGroupName}
|
|
onChange={(e) => setNewGroupName(e.target.value)}
|
|
placeholder={t(
|
|
"ui.admin.groups.form.name_placeholder",
|
|
"예: 개발팀, 인사팀",
|
|
)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label htmlFor="unitType">
|
|
{t("ui.admin.groups.form.unit_level_label", "조직 단위 레벨")}
|
|
</Label>
|
|
<Input
|
|
id="unitType"
|
|
value={newGroupUnitType}
|
|
onChange={(e) => setNewGroupUnitType(e.target.value)}
|
|
placeholder={t(
|
|
"ui.admin.groups.form.unit_level_placeholder",
|
|
"예: 본부, 팀, 셀",
|
|
)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label htmlFor="parentId">
|
|
{t("ui.admin.groups.form.parent_label", "상위 그룹 (선택)")}
|
|
</Label>
|
|
<select
|
|
id="parentId"
|
|
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
|
value={newGroupParentId || ""}
|
|
onChange={(e) => setNewGroupParentId(e.target.value || null)}
|
|
>
|
|
<option value="">{t("ui.common.none", "없음")}</option>
|
|
{groupsQuery.data?.map((group) => (
|
|
<option key={group.id} value={group.id}>
|
|
{group.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<Label htmlFor="desc">
|
|
{t("ui.admin.groups.form.desc_label", "설명")}
|
|
</Label>
|
|
<Input
|
|
id="desc"
|
|
value={newGroupDesc}
|
|
onChange={(e) => setNewGroupNameDesc(e.target.value)}
|
|
placeholder={t(
|
|
"ui.admin.groups.form.desc_placeholder",
|
|
"그룹 용도 설명",
|
|
)}
|
|
/>
|
|
</div>
|
|
<Button
|
|
className="w-full"
|
|
onClick={() => createMutation.mutate()}
|
|
disabled={!newGroupName || createMutation.isPending}
|
|
>
|
|
{t("ui.admin.groups.form.submit", "생성하기")}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 그룹 목록 (트리 뷰) */}
|
|
<Card className="flex flex-col min-h-0 bg-[var(--color-panel)] md:col-span-2">
|
|
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
|
<div>
|
|
<CardTitle>
|
|
{t("ui.admin.groups.list.title", "User Groups")}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{t(
|
|
"msg.admin.groups.list.subtitle",
|
|
"이 테넌트에 정의된 사용자 그룹 목록입니다.",
|
|
)}
|
|
</CardDescription>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => groupsQuery.refetch()}
|
|
>
|
|
<RefreshCw size={14} />
|
|
</Button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
|
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
|
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
|
<Table>
|
|
<TableHeader className={commonStickyTableHeaderClass}>
|
|
<TableRow>
|
|
<TableHead>
|
|
{t("ui.admin.groups.table.name", "NAME")}
|
|
</TableHead>
|
|
<TableHead>
|
|
{t("ui.admin.groups.table.members", "MEMBERS")}
|
|
</TableHead>
|
|
<TableHead className="text-right">
|
|
{t("ui.admin.groups.table.actions", "ACTIONS")}
|
|
</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{groupsQuery.isLoading && (
|
|
<TableRow>
|
|
<TableCell colSpan={3}>
|
|
{t("msg.admin.groups.list.loading", "로딩 중...")}
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
{!groupsQuery.isLoading && groupTree.length === 0 && (
|
|
<TableRow>
|
|
<TableCell
|
|
colSpan={3}
|
|
className="text-center py-8 text-muted-foreground"
|
|
>
|
|
{t(
|
|
"msg.admin.groups.list.empty",
|
|
"아직 등록된 그룹이 없습니다.",
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
{groupTree.map((node) => (
|
|
<UserGroupTreeNode
|
|
key={node.id}
|
|
node={node}
|
|
level={0}
|
|
onSelect={setSelectedGroupId}
|
|
selectedGroupId={selectedGroupId}
|
|
onDelete={(id) => {
|
|
if (
|
|
window.confirm(
|
|
t(
|
|
"msg.admin.groups.list.delete_confirm",
|
|
"그룹을 삭제하시겠습니까?",
|
|
),
|
|
)
|
|
) {
|
|
deleteMutation.mutate(id);
|
|
}
|
|
}}
|
|
onAddSubGroup={handleAddSubGroup}
|
|
addMemberMutation={addMemberMutation}
|
|
removeMemberMutation={removeMemberMutation}
|
|
/>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* 멤버 관리 섹션 (선택된 그룹이 있을 때) */}
|
|
{currentGroup && (
|
|
<Card className="flex flex-col min-h-0 flex-1 bg-[var(--color-panel)] border-t-4 border-t-primary">
|
|
<CardHeader className="flex-shrink-0">
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Shield size={18} className="text-primary" />
|
|
{t("msg.admin.groups.members.title", "[{{name}}] 멤버 관리", {
|
|
name: currentGroup.name,
|
|
})}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{t(
|
|
"ui.admin.groups.detail.members_subtitle",
|
|
"그룹에 속한 멤버들을 확인하고 관리합니다.",
|
|
)}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
|
<div className="flex justify-end mb-4 flex-shrink-0">
|
|
<Button
|
|
size="sm"
|
|
onClick={() => setIsAddMemberModalOpen(true)}
|
|
disabled={addMemberMutation.isPending}
|
|
>
|
|
<UserPlus size={14} className="mr-1" />
|
|
{t("ui.common.add", "멤버 추가")}
|
|
</Button>
|
|
</div>
|
|
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
|
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
|
<Table>
|
|
<TableHeader className={commonStickyTableHeaderClass}>
|
|
<TableRow>
|
|
<TableHead>
|
|
{t("ui.admin.groups.members.table.name", "이름")}
|
|
</TableHead>
|
|
<TableHead>
|
|
{t("ui.admin.groups.members.table.email", "이메일")}
|
|
</TableHead>
|
|
<TableHead className="text-right">
|
|
{t("ui.admin.groups.members.table.actions", "관리")}
|
|
</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{currentGroup.members?.length === 0 && (
|
|
<TableRow>
|
|
<TableCell
|
|
colSpan={3}
|
|
className="text-center py-4 text-muted-foreground"
|
|
>
|
|
{t(
|
|
"msg.admin.groups.members.empty",
|
|
"멤버가 없습니다.",
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
{currentGroup.members?.map((user) => (
|
|
<TableRow key={user.id}>
|
|
<TableCell className="font-medium">
|
|
{user.name}
|
|
</TableCell>
|
|
<TableCell className="text-muted-foreground">
|
|
{user.email}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<div className="flex justify-end gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
setMemberActionTargetUserId(user.id);
|
|
setIsMoveMemberModalOpen(true);
|
|
}}
|
|
disabled={moveMemberMutation.isPending}
|
|
title={t("ui.common.move", "이동")}
|
|
>
|
|
<ArrowRightLeft
|
|
size={14}
|
|
className="text-primary"
|
|
/>
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
if (
|
|
window.confirm(
|
|
t(
|
|
"msg.admin.groups.members.remove_confirm",
|
|
"'{{name}}' 님을 이 그룹에서 제외하시겠습니까?",
|
|
{ name: user.name },
|
|
),
|
|
)
|
|
) {
|
|
removeMemberMutation.mutate({
|
|
groupId: currentGroup.id,
|
|
userId: user.id,
|
|
});
|
|
}
|
|
}}
|
|
disabled={removeMemberMutation.isPending}
|
|
title={t("ui.common.remove", "제거")}
|
|
>
|
|
<UserMinus
|
|
size={14}
|
|
className="text-destructive"
|
|
/>
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
|
|
{/* Add Member Modal */}
|
|
<Dialog
|
|
open={isAddMemberModalOpen}
|
|
onOpenChange={(val) => {
|
|
setIsAddMemberModalOpen(val);
|
|
if (!val) setUserSearchTerm("");
|
|
}}
|
|
>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{t("ui.admin.groups.members.add_modal_title", "그룹에 멤버 추가")}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{t(
|
|
"msg.admin.groups.members.add_modal_desc",
|
|
"이 테넌트에 속한 사용자 중 추가할 멤버를 검색하여 선택하세요.",
|
|
)}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-4">
|
|
<div className="relative">
|
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder={t("ui.common.search", "검색...")}
|
|
className="pl-9 h-9"
|
|
value={userSearchTerm}
|
|
onChange={(e) => setUserSearchTerm(e.target.value)}
|
|
/>
|
|
</div>
|
|
<ScrollArea className="h-[250px] rounded-md border p-2">
|
|
<div className="space-y-1">
|
|
{usersQuery.isLoading ? (
|
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
|
{t("ui.common.loading", "로딩 중...")}
|
|
</div>
|
|
) : (
|
|
users
|
|
.filter((u) => {
|
|
const term = userSearchTerm.toLowerCase();
|
|
return (
|
|
u.name.toLowerCase().includes(term) ||
|
|
u.email.toLowerCase().includes(term)
|
|
);
|
|
})
|
|
.filter(
|
|
(u) => !currentGroup?.members?.some((m) => m.id === u.id),
|
|
) // Exclude existing members
|
|
.map((user) => (
|
|
<div
|
|
key={user.id}
|
|
className="flex items-center justify-between rounded-lg px-3 py-2 text-sm transition hover:bg-muted"
|
|
>
|
|
<div>
|
|
<p className="font-medium">{user.name}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{user.email}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="secondary"
|
|
onClick={() => {
|
|
if (currentGroup) {
|
|
addMemberMutation.mutate({
|
|
groupId: currentGroup.id,
|
|
userId: user.id,
|
|
});
|
|
}
|
|
}}
|
|
disabled={addMemberMutation.isPending}
|
|
>
|
|
{t("ui.common.add", "추가")}
|
|
</Button>
|
|
</div>
|
|
))
|
|
)}
|
|
{users.length > 0 &&
|
|
users.filter(
|
|
(u) => !currentGroup?.members?.some((m) => m.id === u.id),
|
|
).length === 0 && (
|
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
|
{t(
|
|
"msg.admin.groups.members.all_added",
|
|
"모든 테넌트 멤버가 이미 이 그룹에 속해 있습니다.",
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setIsAddMemberModalOpen(false)}
|
|
>
|
|
{t("ui.common.close", "닫기")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Move Member Modal */}
|
|
<Dialog
|
|
open={isMoveMemberModalOpen}
|
|
onOpenChange={(val) => {
|
|
setIsMoveMemberModalOpen(val);
|
|
if (!val) {
|
|
setMemberActionTargetUserId(null);
|
|
setGroupSearchTerm("");
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{t("ui.admin.groups.members.move_modal_title", "부서 이동")}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{t(
|
|
"msg.admin.groups.members.move_modal_desc",
|
|
"선택한 멤버를 이동할 대상 그룹을 선택하세요.",
|
|
)}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-4">
|
|
<div className="relative">
|
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder={t("ui.common.search_group", "그룹 검색...")}
|
|
className="pl-9 h-9"
|
|
value={groupSearchTerm}
|
|
onChange={(e) => setGroupSearchTerm(e.target.value)}
|
|
/>
|
|
</div>
|
|
<ScrollArea className="h-[250px] rounded-md border p-2">
|
|
<div className="space-y-1">
|
|
{groupsQuery.isLoading ? (
|
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
|
{t("ui.common.loading", "로딩 중...")}
|
|
</div>
|
|
) : groupsQuery.data && groupsQuery.data.length > 0 ? (
|
|
groupsQuery.data
|
|
.filter((g) =>
|
|
g.name
|
|
.toLowerCase()
|
|
.includes(groupSearchTerm.toLowerCase()),
|
|
)
|
|
.filter((g) => g.id !== currentGroup?.id) // Exclude current group
|
|
.map((group) => (
|
|
<div
|
|
key={group.id}
|
|
className="flex items-center justify-between rounded-lg px-3 py-2 text-sm transition hover:bg-muted"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Users size={14} className="text-muted-foreground" />
|
|
<span className="font-medium">{group.name}</span>
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => {
|
|
if (currentGroup && memberActionTargetUserId) {
|
|
moveMemberMutation.mutate({
|
|
sourceGroupId: currentGroup.id,
|
|
targetGroupId: group.id,
|
|
userId: memberActionTargetUserId,
|
|
});
|
|
}
|
|
}}
|
|
disabled={moveMemberMutation.isPending}
|
|
>
|
|
{t("ui.common.move", "이동")}
|
|
</Button>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
|
{t("msg.admin.groups.list.no_results", "그룹이 없습니다.")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setIsMoveMemberModalOpen(false)}
|
|
>
|
|
{t("ui.common.cancel", "취소")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default TenantGroupsPage;
|