1
0
forked from baron/baron-sso
Files
baron-sso/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx

1156 lines
40 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
ArrowRight,
Briefcase,
Building2,
Check,
ChevronDown,
ChevronRight,
CornerDownRight,
Network,
Plus,
RefreshCw,
Search,
Trash2,
UserCircle,
UserPlus,
Users,
} from "lucide-react";
import * as React from "react";
import { useMemo, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "../../../components/ui/tabs";
import { toast } from "../../../components/ui/use-toast";
import {
type GroupSummary,
type TenantSummary,
type UserSummary,
createUser,
fetchGroups,
fetchTenants,
fetchUsers,
updateTenant,
updateUser,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
const getTenantIcon = (type?: string) => {
switch (type?.toUpperCase()) {
case "COMPANY_GROUP":
return Briefcase;
case "PERSONAL":
return UserCircle;
case "USER_GROUP":
return Network;
default:
return Building2;
}
};
const MemberListDialog: React.FC<{
node: TenantNode;
trigger?: React.ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}> = ({ node, trigger, open, onOpenChange }) => {
const [activeTab, setActiveTab] = useState("direct");
const {
data: directData,
isLoading: isDirectLoading,
refetch: refetchDirect,
} = useQuery({
queryKey: ["tenant-members", node.slug],
queryFn: () => fetchUsers(100, 0, undefined, node.slug),
enabled: open && activeTab === "direct",
});
const descendantSlugs = useMemo(() => {
const slugs: string[] = [];
const collect = (n: TenantNode) => {
for (const child of n.children) {
slugs.push(child.slug);
collect(child);
}
};
collect(node);
return slugs;
}, [node]);
const {
data: descendantData,
isLoading: isDescendantLoading,
refetch: refetchDescendant,
} = useQuery({
queryKey: ["tenant-descendant-members", node.id],
queryFn: async () => {
if (descendantSlugs.length === 0) return [];
// Fetch users for all descendant slugs in parallel
const results = await Promise.all(
descendantSlugs
.slice(0, 10)
.map((slug) => fetchUsers(50, 0, undefined, slug)),
);
return results.flatMap((res) => res.items);
},
enabled: open && activeTab === "descendants" && descendantSlugs.length > 0,
});
const directMembers = directData?.items ?? [];
const descendantMembers = descendantData ?? [];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
<DialogContent className="sm:max-w-[700px] max-h-[90vh] flex flex-col p-0 overflow-hidden">
<DialogHeader className="p-6 pb-2">
<DialogTitle className="flex items-center gap-2 text-xl">
<Users size={24} className="text-primary" />
{node.name}{" "}
{t("ui.admin.tenants.members.list_title", "구성원 관리")}
<span className="text-sm font-normal text-muted-foreground ml-1">
({isDirectLoading ? "..." : (directData?.total ?? 0)})
</span>
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.tenants.members.desc",
"조직에 소속된 사용자 목록을 확인합니다.",
)}
</DialogDescription>
</DialogHeader>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex-1 flex flex-col overflow-hidden"
>
<div className="px-6 border-b">
<TabsList className="bg-transparent h-auto p-0 gap-6">
<TabsTrigger
value="direct"
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-0 py-2"
>
{t("ui.admin.tenants.members.direct", "소속 멤버")} (
{isDirectLoading ? "..." : (directData?.total ?? 0)})
</TabsTrigger>
<TabsTrigger
value="descendants"
disabled={node.children.length === 0}
className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-0 py-2"
>
{t("ui.admin.tenants.members.descendants", "하위 조직 멤버")} (
{node.recursiveMemberCount - (node.memberCount || 0)})
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="direct" className="flex-1 overflow-auto p-6 m-0">
<MemberTable
members={directMembers}
isLoading={isDirectLoading}
onRefresh={refetchDirect}
/>
</TabsContent>
<TabsContent
value="descendants"
className="flex-1 overflow-auto p-6 m-0"
>
<MemberTable
members={descendantMembers}
isLoading={isDescendantLoading}
onRefresh={refetchDescendant}
showTenant
/>
{descendantSlugs.length > 10 && (
<p className="text-[10px] text-muted-foreground mt-2 text-center">
*{" "}
{t(
"msg.admin.tenants.members.limit_notice",
"하위 조직이 많아 상위 10개 조직의 멤버만 표시됩니다.",
)}
</p>
)}
</TabsContent>
</Tabs>
<DialogFooter className="p-6 pt-2 bg-muted/5 border-t">
<Button variant="outline" onClick={() => onOpenChange?.(false)}>
{t("ui.common.close", "닫기")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
const MemberTable: React.FC<{
members: UserSummary[];
isLoading: boolean;
onRefresh: () => void;
showTenant?: boolean;
}> = ({ members, isLoading, onRefresh, showTenant }) => (
<div className="border rounded-md overflow-hidden">
<Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow>
<TableHead className="h-9">
{t("ui.admin.users.table.name", "NAME")}
</TableHead>
<TableHead className="h-9">
{t("ui.admin.users.table.email", "EMAIL")}
</TableHead>
{showTenant && (
<TableHead className="h-9">
{t("ui.admin.tenants.table.slug", "TENANT")}
</TableHead>
)}
<TableHead className="h-9 text-right">
{t("ui.admin.users.table.role", "ROLE")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell
colSpan={showTenant ? 4 : 3}
className="text-center py-12"
>
{t("msg.common.loading", "로딩 중...")}
</TableCell>
</TableRow>
) : members.length === 0 ? (
<TableRow>
<TableCell
colSpan={showTenant ? 4 : 3}
className="text-center py-12"
>
<div className="flex flex-col items-center gap-2 opacity-40">
<Users size={40} />
<p>{t("msg.admin.users.list.empty", "멤버가 없습니다.")}</p>
<Button
variant="outline"
size="sm"
onClick={onRefresh}
className="mt-2"
>
<RefreshCw size={14} className="mr-1" />{" "}
{t("ui.common.refresh", "다시 불러오기")}
</Button>
</div>
</TableCell>
</TableRow>
) : (
members.map((user) => (
<TableRow
key={user.id}
className="hover:bg-muted/50 transition-colors"
>
<TableCell className="font-medium py-2">{user.name}</TableCell>
<TableCell className="text-xs font-mono py-2">
{user.email}
</TableCell>
{showTenant && (
<TableCell className="py-2">
<Badge variant="outline" className="text-[10px] h-5">
{user.tenantSlug}
</Badge>
</TableCell>
)}
<TableCell className="text-right py-2">
<Badge
variant="secondary"
className="text-[10px] uppercase h-5 font-bold"
>
{user.role}
</Badge>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
);
const UserAddDialog: React.FC<{
tenantSlug: string;
tenantName: string;
trigger?: React.ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}> = ({ tenantSlug, tenantName, trigger, open, onOpenChange }) => {
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState("select");
// Create state
const [email, setEmail] = useState("");
const [name, setName] = useState("");
// Select state
const [userSearch, setUserSearch] = useState("");
const [isSearching, setIsSearching] = useState(false);
const [searchResults, setSearchResults] = useState<UserSummary[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSearch = async () => {
if (!userSearch) return;
setIsSearching(true);
try {
const res = await fetchUsers(20, 0, userSearch);
setSearchResults(res.items);
} catch (err) {
toast.error(t("msg.admin.users.list.fetch_error", "사용자 검색 실패"));
} finally {
setIsSearching(false);
}
};
const handleCreate = async () => {
if (!email || !name) {
toast.error(
t(
"msg.admin.users.create.form.email_required",
"이메일과 이름은 필수입니다.",
),
);
return;
}
setIsSubmitting(true);
try {
const res = await createUser({
email,
name,
tenantSlug: tenantSlug,
role: "user",
});
toast.success(
t("msg.admin.users.create.success", "사용자가 생성되었습니다."),
{
description: res.initialPassword
? `초기 비밀번호: ${res.initialPassword}`
: undefined,
},
);
// Refresh tenant tree to update member counts
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
}, 1000); // Wait 1s for backend async sync
onOpenChange?.(false);
resetFields();
} catch (err: unknown) {
const error = err as { response?: { data?: { error?: string } } };
toast.error(
error.response?.data?.error ||
t("msg.admin.users.create.error", "사용자 생성 실패"),
);
} finally {
setIsSubmitting(false);
}
};
const handleAssign = async () => {
if (!selectedUserId) return;
setIsSubmitting(true);
try {
await updateUser(selectedUserId, { tenantSlug: tenantSlug });
toast.success(
t("msg.info.saved_success", "사용자가 테넌트에 배정되었습니다."),
);
// Refresh tenant tree to update member counts
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
}, 1000); // Wait 1s for backend async sync
onOpenChange?.(false);
resetFields();
} catch (err: unknown) {
const error = err as { response?: { data?: { error?: string } } };
toast.error(
error.response?.data?.error ||
t("msg.admin.users.detail.update_error", "배정 실패"),
);
} finally {
setIsSubmitting(false);
}
};
const resetFields = () => {
setEmail("");
setName("");
setUserSearch("");
setSearchResults([]);
setSelectedUserId(null);
};
return (
<Dialog
open={open}
onOpenChange={(v) => {
onOpenChange?.(v);
if (!v) resetFields();
}}
>
{trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
{t("ui.admin.users.create.title", "사용자 추가")}
</DialogTitle>
<DialogDescription>
[{tenantName}]
.
</DialogDescription>
</DialogHeader>
<Tabs value={activeTab} onValueChange={setActiveTab} className="mt-4">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="select">
{t("ui.common.select", "기존 사용자 선택")}
</TabsTrigger>
<TabsTrigger value="create">
{t("ui.common.create", "신규 생성")}
</TabsTrigger>
</TabsList>
<TabsContent value="select" className="space-y-4 py-4">
<div className="flex gap-2">
<Input
placeholder={t(
"ui.admin.users.list.search_placeholder",
"이메일 또는 이름으로 검색...",
)}
value={userSearch}
onChange={(e) => setUserSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
<Button
variant="secondary"
onClick={handleSearch}
disabled={isSearching}
>
<Search size={16} />
</Button>
</div>
<div className="max-h-[250px] overflow-y-auto border rounded-md">
<Table>
<TableBody>
{searchResults.length === 0 ? (
<TableRow>
<TableCell className="text-center py-8 text-muted-foreground">
{isSearching
? t("msg.common.loading", "검색 중...")
: t(
"msg.admin.users.list.empty",
"사용자를 검색해 주세요.",
)}
</TableCell>
</TableRow>
) : (
searchResults.map((user) => (
<TableRow
key={user.id}
className={`cursor-pointer hover:bg-muted/50 ${selectedUserId === user.id ? "bg-primary/5" : ""}`}
onClick={() => setSelectedUserId(user.id)}
>
<TableCell className="flex items-center justify-between">
<div>
<div className="font-medium text-sm">
{user.name}
</div>
<div className="text-xs text-muted-foreground">
{user.email}
</div>
{user.tenantSlug && (
<Badge
variant="outline"
className="text-[10px] mt-1"
>
{user.tenantSlug}
</Badge>
)}
</div>
{selectedUserId === user.id && (
<Check size={16} className="text-primary" />
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</TabsContent>
<TabsContent value="create" className="space-y-4 py-4">
<div className="grid gap-2">
<Label htmlFor="email">
{t("ui.admin.users.create.form.email", "이메일")}
</Label>
<Input
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="user@example.com"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="name">
{t("ui.admin.users.create.form.name", "이름")}
</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="홍길동"
/>
</div>
</TabsContent>
</Tabs>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange?.(false)}>
{t("ui.common.cancel", "취소")}
</Button>
{activeTab === "create" ? (
<Button onClick={handleCreate} disabled={isSubmitting}>
{isSubmitting
? t("msg.common.saving", "저장 중...")
: t("ui.common.create", "생성 및 추가")}
</Button>
) : (
<Button
onClick={handleAssign}
disabled={isSubmitting || !selectedUserId}
>
{isSubmitting
? t("msg.common.saving", "저장 중...")
: t("ui.common.add", "선택 사용자 배정")}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
};
const TenantTreeRow: React.FC<{
node: TenantNode;
level: number;
isRoot: boolean;
onRemove: (id: string, name: string) => void;
onMove: (id: string, newParentId: string) => void;
isUpdating: boolean;
searchTerm?: string;
}> = ({ node, level, isRoot, onRemove, onMove, isUpdating, searchTerm }) => {
const navigate = useNavigate();
const [isExpanded, setIsExpanded] = useState(true);
const [isUserAddOpen, setIsUserAddOpen] = useState(false);
const [isMemberListOpen, setIsMemberListOpen] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const hasChildren = node.children && node.children.length > 0;
// Auto expand if search matches children
React.useEffect(() => {
if (searchTerm) {
const hasMatchingChild = (n: TenantNode): boolean => {
return n.children.some(
(c) =>
c.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
c.slug.toLowerCase().includes(searchTerm.toLowerCase()) ||
hasMatchingChild(c),
);
};
if (hasMatchingChild(node)) {
setIsExpanded(true);
}
}
}, [searchTerm, node]);
const isMatching =
searchTerm &&
(node.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
node.slug.toLowerCase().includes(searchTerm.toLowerCase()));
const TypeIcon = getTenantIcon(node.type);
// DnD Handlers
const handleDragStart = (e: React.DragEvent) => {
if (isRoot) return;
e.dataTransfer.setData("nodeId", node.id);
e.dataTransfer.setData("nodeName", node.name);
e.dataTransfer.effectAllowed = "move";
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
if (isUpdating) return;
setIsDragOver(true);
};
const handleDragLeave = () => {
setIsDragOver(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setIsDragOver(false);
const draggedId = e.dataTransfer.getData("nodeId");
if (!draggedId || draggedId === node.id) return;
onMove(draggedId, node.id);
};
const hoverTitle = `${node.name} (${node.type})\n${t("ui.admin.tenants.members.direct", "소속 멤버")}: ${node.memberCount || 0}\n${t("ui.admin.tenants.members.total", "총 멤버")}: ${node.recursiveMemberCount || 0}`;
return (
<>
<TableRow
draggable={!isRoot}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`group hover:bg-muted/30 transition-colors ${isRoot ? "bg-primary/5 font-bold" : ""} ${isMatching ? "bg-primary/10 ring-1 ring-primary/20" : ""} ${isDragOver ? "bg-primary/20 border-2 border-dashed border-primary" : ""}`}
title={hoverTitle}
>
<TableCell style={{ paddingLeft: `${1 + level * 2}rem` }}>
<div className="flex items-center gap-2">
{hasChildren ? (
<Button
variant="ghost"
size="icon"
className="h-6 w-6 p-0 hover:bg-primary/10"
onClick={(e) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
}}
>
{isExpanded ? (
<ChevronDown size={14} className="text-primary" />
) : (
<ChevronRight size={14} />
)}
</Button>
) : (
level > 0 && (
<div className="w-6 flex justify-center">
<div className="w-px h-full bg-border" />
</div>
)
)}
{!isRoot && (
<CornerDownRight size={14} className="text-muted-foreground/50" />
)}
<div
className={`p-1 rounded ${isRoot ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
<TypeIcon size={14} />
</div>
<span className="truncate max-w-[200px]">{node.name}</span>
{isRoot && (
<Badge
variant="secondary"
className="ml-2 text-[10px] uppercase font-bold px-1.5 h-5"
>
Root
</Badge>
)}
<Badge
variant="outline"
className="text-[10px] font-mono opacity-60 ml-auto hidden sm:inline-flex"
>
{t(`domain.tenant_type.${node.type?.toLowerCase()}`, node.type)}
</Badge>
</div>
</TableCell>
<TableCell className="text-xs font-mono opacity-60 hidden md:table-cell">
{node.slug}
</TableCell>
<TableCell>
<button
type="button"
className="flex items-center gap-2 cursor-pointer hover:bg-muted p-1.5 rounded-md transition-all group/members w-full text-left"
onClick={() => setIsMemberListOpen(true)}
title={t(
"msg.admin.org.hover_member_info",
"클릭하여 멤버 상세 조회",
)}
>
<div className="bg-primary/10 p-1.5 rounded text-primary">
<Users size={16} />
</div>
<div className="flex flex-col">
<div className="flex items-baseline gap-1">
<span className="text-sm font-bold leading-none">
{node.memberCount || 0}
</span>
<span className="text-[9px] text-muted-foreground font-medium uppercase">
{t("ui.admin.tenants.members.direct_label", "Direct")}
</span>
</div>
{node.recursiveMemberCount > (node.memberCount || 0) && (
<div className="flex items-baseline gap-1 mt-0.5">
<span className="text-[11px] font-semibold text-muted-foreground leading-none">
{node.recursiveMemberCount}
</span>
<span className="text-[9px] text-muted-foreground/60 font-medium uppercase">
{t("ui.admin.tenants.members.total_label", "Total")}
</span>
</div>
)}
</div>
</button>
<MemberListDialog
node={node}
open={isMemberListOpen}
onOpenChange={setIsMemberListOpen}
/>
</TableCell>
<TableCell>
<Badge
variant={node.status === "active" ? "default" : "secondary"}
className="text-[10px] h-5"
>
{t(`ui.common.status.${node.status}`, node.status)}
</Badge>
</TableCell>
<TableCell className="text-right pr-6">
<div className="flex justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-primary hover:bg-primary/10"
onClick={() => setIsUserAddOpen(true)}
title={t("ui.admin.users.list.add", "사용자 추가")}
>
<UserPlus size={14} />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => {
if (node.type === "USER_GROUP") {
// User groups have a different detail path
const baseTenantId =
(node as unknown as { tenantId?: string }).tenantId ||
node.parentId ||
"";
navigate(`/tenants/${baseTenantId}/organization/${node.id}`);
} else {
navigate(`/tenants/${node.id}`);
}
}}
title={t("ui.common.manage", "관리")}
>
<ArrowRight size={14} />
</Button>
{!isRoot && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => onRemove(node.id, node.name)}
disabled={isUpdating}
title={t("ui.common.remove", "제외")}
>
<Trash2 size={14} />
</Button>
)}
</div>
<UserAddDialog
tenantSlug={node.slug}
tenantName={node.name}
open={isUserAddOpen}
onOpenChange={setIsUserAddOpen}
/>
</TableCell>
</TableRow>
{isExpanded &&
node.children.map((child) => (
<TenantTreeRow
key={child.id}
node={child}
level={level + 1}
isRoot={false}
onRemove={onRemove}
onMove={onMove}
isUpdating={isUpdating}
searchTerm={searchTerm}
/>
))}
</>
);
};
function TenantUserGroupsTab() {
const { tenantId } = useParams<{ tenantId: string }>();
const queryClient = useQueryClient();
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [isHeaderUserAddOpen, setIsHeaderUserAddOpen] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const [treeSearchTerm, setTreeSearchTerm] = useState("");
if (!tenantId) return null;
const { data, isLoading, refetch } = useQuery({
queryKey: ["tenants-full-tree-v2"],
queryFn: () => fetchTenants(1000, 0),
});
const { data: groupsData, isLoading: isGroupsLoading } = useQuery({
queryKey: ["tenant-groups", tenantId],
queryFn: () => fetchGroups(tenantId),
enabled: !!tenantId,
});
const groupNodes = useMemo(() => {
if (!groupsData) return [];
return groupsData.map((g) => ({
...g,
type: "USER_GROUP",
children: [], // Simplified for now, just a list or separate tree
memberCount: g.members?.length || 0,
recursiveMemberCount: g.members?.length || 0,
})) as unknown as TenantNode[];
}, [groupsData]);
const updateParentMutation = useMutation({
mutationFn: ({
id,
parentId,
}: { id: string; parentId: string | undefined }) =>
updateTenant(id, { parentId: parentId || "" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
toast.success(t("msg.info.saved_success", "저장되었습니다."));
setIsAddDialogOpen(false);
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(t("msg.common.error", "오류 발생"), {
description: error.response?.data?.error || error.message,
});
},
});
const allTenants = data?.items ?? [];
const { currentBase, subTree } = useMemo(() => {
const tree = buildTenantFullTree(allTenants, tenantId);
if (tree.currentBase) {
// Merge backend-provided UserGroups into the tree as virtual children
tree.currentBase.children = [...tree.currentBase.children, ...groupNodes];
}
return tree;
}, [allTenants, tenantId, groupNodes]);
const handleAdd = (id: string) =>
updateParentMutation.mutate({ id, parentId: tenantId });
const handleMove = (id: string, newParentId: string) => {
if (id === newParentId) return;
updateParentMutation.mutate({ id, parentId: newParentId });
};
const handleRemove = (id: string, name: string) => {
if (
window.confirm(
t(
"msg.admin.tenants.remove_sub_confirm",
`${name} 테넌트를 하위 조직에서 제외할까요?`,
{ name },
),
)
) {
updateParentMutation.mutate({ id, parentId: undefined });
}
};
if (isLoading)
return (
<div className="p-12 text-center text-muted-foreground animate-pulse">
{t("msg.common.loading", "로딩 중...")}
</div>
);
if (!currentBase)
return (
<div className="p-12 text-center text-muted-foreground">
{t("msg.admin.tenants.not_found", "현재 테넌트를 찾을 수 없습니다.")}{" "}
(ID: {tenantId})
</div>
);
const candidates = allTenants.filter((t) => {
if (t.id === tenantId) return false;
// Check if it's already a child
if (t.parentId === tenantId) return false;
// Basic search
if (searchTerm === "") return true;
return (
t.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
t.slug.toLowerCase().includes(searchTerm.toLowerCase())
);
});
const BaseIcon = getTenantIcon(currentBase.type);
return (
<div className="space-y-6 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<Card className="flex-1 flex flex-col min-h-0 bg-[var(--color-panel)] border-none shadow-sm overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between border-b bg-muted/5 py-4 flex-shrink-0">
<div className="space-y-1">
<CardTitle className="text-xl font-bold flex items-center gap-2">
<BaseIcon size={20} className="text-primary" />
{t("ui.admin.tenants.sub.title", "조직 계층 구조", {
count: subTree.length,
})}
</CardTitle>
<CardDescription>
{t(
"msg.admin.tenants.sub.subtitle",
"하위 조직망을 구성하고 관리합니다.",
)}
</CardDescription>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw size={14} className="mr-1" />{" "}
{t("ui.common.refresh", "새로고침")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setIsHeaderUserAddOpen(true)}
>
<UserPlus size={14} className="mr-1" />
{t("ui.admin.users.list.add", "사용자 추가")}
</Button>
<UserAddDialog
tenantSlug={currentBase.slug}
tenantName={currentBase.name}
open={isHeaderUserAddOpen}
onOpenChange={setIsHeaderUserAddOpen}
/>
<Button variant="outline" size="sm" asChild>
<Link to={`/tenants/new?parentId=${tenantId}`}>
<Plus size={14} className="mr-1" />
{t("ui.admin.tenants.sub.add", "신규 생성")}
</Link>
</Button>
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogTrigger asChild>
<Button size="sm">
<Plus size={14} className="mr-1" />{" "}
{t("ui.admin.tenants.sub.add_existing", "기존 테넌트 추가")}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[600px] p-0 overflow-hidden">
<DialogHeader className="p-6 pb-2">
<DialogTitle>
{t(
"ui.admin.tenants.sub.add_dialog_title",
"하위 테넌트 추가",
)}
</DialogTitle>
<DialogDescription>
{t(
"ui.admin.tenants.sub.add_dialog_desc",
"기존에 등록된 테넌트를 검색하여 하위 조직으로 추가합니다.",
)}
</DialogDescription>
</DialogHeader>
<div className="p-6 pt-2 space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
className="pl-9"
placeholder={t(
"ui.admin.tenants.sub.search_placeholder",
"검색...",
)}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="max-h-[300px] overflow-y-auto border rounded-md">
<Table>
<TableBody>
{candidates.length === 0 ? (
<TableRow>
<TableCell className="text-center py-8 text-muted-foreground">
{t(
"ui.admin.tenants.sub.no_candidates",
"검색 결과 없음",
)}
</TableCell>
</TableRow>
) : (
candidates.map((tenant) => {
const CandidateIcon = getTenantIcon(tenant.type);
return (
<TableRow
key={tenant.id}
className="hover:bg-muted/50"
>
<TableCell>
<div className="flex items-center gap-2">
<div className="p-1 rounded bg-muted text-muted-foreground">
<CandidateIcon size={12} />
</div>
<div>
<div className="font-medium text-sm">
{tenant.name}
</div>
<div className="text-[10px] text-muted-foreground font-mono">
{tenant.slug}
</div>
</div>
</div>
</TableCell>
<TableCell>
<Badge
variant="outline"
className="text-[10px]"
>
{t(
`domain.tenant_type.${tenant.type?.toLowerCase()}`,
tenant.type,
)}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button
size="sm"
variant="outline"
className="h-8"
onClick={() => handleAdd(tenant.id)}
disabled={updateParentMutation.isPending}
>
{t("ui.common.add", "추가")}
</Button>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</div>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<div className="px-6 py-3 bg-muted/5 border-b flex items-center gap-4 flex-shrink-0">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t(
"ui.admin.tenants.sub.tree_search_placeholder",
"조직도 내 검색...",
)}
className="pl-9 h-9"
value={treeSearchTerm}
onChange={(e) => setTreeSearchTerm(e.target.value)}
/>
</div>
{treeSearchTerm && (
<Button
variant="ghost"
size="sm"
onClick={() => setTreeSearchTerm("")}
className="text-xs"
>
{t("ui.common.clear_search", "검색 초기화")}
</Button>
)}
</div>
<CardContent className="flex-1 flex flex-col min-h-0 p-0">
<div className="flex-1 rounded-md border-0 overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<Table>
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow>
<TableHead className="pl-6 w-[40%]">
{t("ui.admin.tenants.table.name", "NAME")}
</TableHead>
<TableHead className="hidden md:table-cell">
{t("ui.admin.tenants.table.slug", "SLUG")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.members", "MEMBERS")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.table.status", "STATUS")}
</TableHead>
<TableHead className="text-right pr-6">
{t("ui.admin.tenants.table.actions", "ACTIONS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TenantTreeRow
node={currentBase}
level={0}
isRoot={true}
onRemove={handleRemove}
onMove={handleMove}
isUpdating={updateParentMutation.isPending}
searchTerm={treeSearchTerm}
/>
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
export default TenantUserGroupsTab;