forked from baron/baron-sso
1156 lines
40 KiB
TypeScript
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;
|