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

810 lines
28 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
ArrowRight,
Briefcase,
Building2,
ChevronDown,
ChevronRight,
CornerDownRight,
ExternalLink,
FolderOpen,
LayoutDashboard,
MoreHorizontal,
Network,
Plus,
RefreshCw,
Search,
Settings,
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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../../../components/ui/dropdown-menu";
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 {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "../../../components/ui/tabs";
import { toast } from "../../../components/ui/use-toast";
import {
type TenantSummary,
type UserSummary,
createUser,
fetchTenants,
fetchUsers,
updateTenant,
updateUser,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
// --- Icons & Helpers ---
const getTenantIcon = (type?: string) => {
switch (type?.toUpperCase()) {
case "COMPANY_GROUP":
return Briefcase;
case "PERSONAL":
return UserCircle;
case "USER_GROUP":
return Network;
default:
return Building2;
}
};
// --- Components ---
const SidebarNode: React.FC<{
node: TenantNode;
level: number;
selectedId: string;
onSelect: (id: string) => void;
searchTerm: string;
}> = ({ node, level, selectedId, onSelect, searchTerm }) => {
const [isExpanded, setIsExpanded] = useState(true);
const hasChildren = node.children && node.children.length > 0;
const isSelected = selectedId === node.id;
const TypeIcon = getTenantIcon(node.type);
// Auto-expand on search
React.useEffect(() => {
if (searchTerm) {
const matchInDescendants = (n: TenantNode): boolean => {
return n.children.some(
(c) =>
c.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
matchInDescendants(c),
);
};
if (matchInDescendants(node)) setIsExpanded(true);
}
}, [searchTerm, node]);
const isMatching =
searchTerm && node.name.toLowerCase().includes(searchTerm.toLowerCase());
return (
<div className="flex flex-col">
<button
type="button"
className={`w-full text-left flex items-center group px-2 py-1.5 rounded-md cursor-pointer transition-colors ${
isSelected
? "bg-primary text-primary-foreground font-semibold"
: "hover:bg-muted/60 text-muted-foreground hover:text-foreground"
} ${isMatching ? "ring-1 ring-primary/30 bg-primary/5" : ""}`}
onClick={() => onSelect(node.id)}
>
<div className="flex items-center flex-1 min-w-0">
{/* Indent & Expander */}
<div style={{ width: `${level * 1.2}rem` }} className="shrink-0" />
<div className="w-5 h-5 flex items-center justify-center mr-1">
{hasChildren ? (
<button
type="button"
className="hover:bg-primary/20 rounded p-0.5"
onClick={(e) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
}}
>
{isExpanded ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
)}
</button>
) : (
level > 0 && <div className="w-1 h-1 rounded-full bg-border" />
)}
</div>
<TypeIcon
size={16}
className={`shrink-0 mr-2 ${isSelected ? "text-primary-foreground" : "text-primary/70"}`}
/>
<span className="text-sm truncate">{node.name}</span>
</div>
<Badge
variant={isSelected ? "secondary" : "outline"}
className={`ml-2 text-[10px] px-1 h-4 min-w-[1.5rem] justify-center ${
isSelected ? "" : "opacity-60"
}`}
>
{node.recursiveMemberCount}
</Badge>
</button>
{isExpanded && hasChildren && (
<div className="flex flex-col">
{node.children.map((child) => (
<SidebarNode
key={child.id}
node={child}
level={level + 1}
selectedId={selectedId}
onSelect={onSelect}
searchTerm={searchTerm}
/>
))}
</div>
)}
</div>
);
};
const MemberTable: React.FC<{
tenantSlug: string;
onRefreshTrigger?: number;
}> = ({ tenantSlug, onRefreshTrigger }) => {
const { data, isLoading, refetch } = useQuery({
queryKey: ["tenant-members-v2", tenantSlug, onRefreshTrigger],
queryFn: () => fetchUsers(100, 0, undefined, tenantSlug),
enabled: !!tenantSlug,
});
const members = data?.items ?? [];
if (isLoading)
return (
<div className="py-20 text-center text-muted-foreground animate-pulse">
{t("msg.common.loading", "멤버 정보를 불러오는 중...")}
</div>
);
if (members.length === 0)
return (
<div className="py-20 flex flex-col items-center justify-center text-muted-foreground opacity-50 border-2 border-dashed rounded-lg">
<Users size={48} className="mb-4" />
<p>
{t("msg.admin.users.list.empty", "이 조직에 소속된 멤버가 없습니다.")}
</p>
</div>
);
return (
<div className="border rounded-md overflow-hidden bg-[var(--color-panel)]">
<Table>
<TableHeader className="bg-muted/30">
<TableRow>
<TableHead>{t("ui.admin.users.table.name", "이름")}</TableHead>
<TableHead>{t("ui.admin.users.table.email", "이메일")}</TableHead>
<TableHead className="text-right">
{t("ui.admin.users.table.role", "역할")}
</TableHead>
<TableHead className="w-[50px]" />
</TableRow>
</TableHeader>
<TableBody>
{members.map((user) => (
<TableRow
key={user.id}
className="hover:bg-muted/30 transition-colors"
>
<TableCell className="font-medium">{user.name}</TableCell>
<TableCell className="text-xs font-mono">{user.email}</TableCell>
<TableCell className="text-right">
<Badge
variant="secondary"
className="text-[10px] font-bold uppercase"
>
{user.role}
</Badge>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal size={14} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link to={`/users/${user.id}`}>
<ExternalLink size={14} className="mr-2" />
{t("ui.common.detail", "상세보기")}
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
};
// --- Main Component ---
function TenantUserGroupsTab() {
const { tenantId } = useParams<{ tenantId: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [selectedNodeId, setSelectedNodeId] = useState<string>(tenantId || "");
const [treeSearch, setTreeSearch] = useState("");
const [refreshMembersCount, setRefreshMembersCount] = useState(0);
const [isUserAddOpen, setIsUserAddOpen] = useState(false);
const [isAddExistingOpen, setIsAddExistingOpen] = useState(false);
const [existingSearch, setExistingSearch] = useState("");
// Data Fetching
const {
data: allTenantsData,
isLoading: isTenantsLoading,
refetch: refetchTree,
} = useQuery({
queryKey: ["tenants-full-tree-v2"],
queryFn: () => fetchTenants(1000, 0),
});
const { currentBase, subTree } = useMemo(() => {
const allItems = allTenantsData?.items ?? [];
return buildTenantFullTree(allItems, tenantId);
}, [allTenantsData, tenantId]);
const selectedNode = useMemo(() => {
// Find selected node in the built tree
const findNode = (nodes: TenantNode[], id: string): TenantNode | null => {
if (!currentBase) return null;
if (currentBase.id === id) return currentBase;
for (const node of nodes) {
if (node.id === id) return node;
if (node.children.length > 0) {
const found = findNode(node.children, id);
if (found) return found;
}
}
return null;
};
if (!currentBase) return null;
return findNode(currentBase.children, selectedNodeId) || currentBase;
}, [currentBase, selectedNodeId]);
// Mutations
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", "조직 구조가 업데이트되었습니다."),
);
setIsAddExistingOpen(false);
},
});
const handleRemoveNode = (id: string, name: string) => {
if (
window.confirm(
t(
"msg.admin.tenants.remove_sub_confirm",
`${name} 조직을 하위에서 제외할까요?`,
{ name },
),
)
) {
updateParentMutation.mutate({ id, parentId: undefined });
if (selectedNodeId === id) setSelectedNodeId(tenantId || "");
}
};
if (isTenantsLoading)
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">
.
</div>
);
const candidates = (allTenantsData?.items ?? []).filter(
(t) =>
t.id !== tenantId &&
t.parentId !== tenantId &&
(existingSearch === "" ||
t.name.toLowerCase().includes(existingSearch.toLowerCase()) ||
t.slug.toLowerCase().includes(existingSearch.toLowerCase())),
);
return (
<div className="flex h-[calc(100vh-theme(spacing.32))] gap-6 mt-6 overflow-hidden">
{/* --- Left Panel: Sidebar Tree --- */}
<Card className="w-80 flex flex-col shrink-0 border-none shadow-sm bg-[var(--color-panel)] overflow-hidden">
<CardHeader className="p-4 pb-2 space-y-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-bold flex items-center gap-2">
<Network size={16} className="text-primary" />
</CardTitle>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => refetchTree()}
>
<RefreshCw size={14} />
</Button>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder={t("ui.common.search", "조직 검색...")}
className="pl-8 h-8 text-xs bg-muted/40"
value={treeSearch}
onChange={(e) => setTreeSearch(e.target.value)}
/>
</div>
</CardHeader>
<CardContent className="flex-1 overflow-hidden p-2">
<ScrollArea className="h-full pr-2">
<SidebarNode
node={currentBase}
level={0}
selectedId={selectedNodeId}
onSelect={setSelectedNodeId}
searchTerm={treeSearch}
/>
</ScrollArea>
</CardContent>
<div className="p-3 border-t bg-muted/5">
<Button
variant="outline"
size="sm"
className="w-full text-xs h-8"
onClick={() => setIsAddExistingOpen(true)}
>
<Plus size={14} className="mr-1" />
{t("ui.admin.tenants.sub.add_existing", "기존 테넌트 연결")}
</Button>
</div>
</Card>
{/* --- Right Panel: Selected Node Content --- */}
<div className="flex-1 flex flex-col min-w-0">
{selectedNode ? (
<Card className="flex-1 flex flex-col border-none shadow-sm bg-[var(--color-panel)] overflow-hidden">
<CardHeader className="border-b bg-muted/5 py-4 flex flex-row items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary/10 text-primary">
{React.createElement(getTenantIcon(selectedNode.type), {
size: 24,
})}
</div>
<div>
<div className="flex items-center gap-2">
<CardTitle className="text-xl font-bold">
{selectedNode.name}
</CardTitle>
<Badge
variant="outline"
className="text-[10px] font-mono opacity-60"
>
{selectedNode.slug}
</Badge>
</div>
<CardDescription className="flex items-center gap-2 mt-0.5">
<span className="flex items-center gap-1">
<Users size={12} /> {selectedNode.recursiveMemberCount}{" "}
{t("ui.admin.tenants.table.members", "명")}
</span>
<span className="opacity-30">|</span>
<Badge variant="secondary" className="text-[10px] h-4">
{t(
`domain.tenant_type.${selectedNode.type.toLowerCase()}`,
selectedNode.type,
)}
</Badge>
</CardDescription>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setIsUserAddOpen(true)}
>
<UserPlus size={16} className="mr-2" />
{t("ui.admin.users.list.add", "멤버 추가")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<Settings size={16} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
{t("ui.common.manage", "조직 관리")}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to={`/tenants/${selectedNode.id}`}>
<LayoutDashboard size={14} className="mr-2" />
</Link>
</DropdownMenuItem>
{!selectedNode.parentId && (
<DropdownMenuItem asChild>
<Link to={`/tenants/new?parentId=${selectedNode.id}`}>
<Plus size={14} className="mr-2" />
{t("ui.admin.tenants.sub.add", "하위 부서 생성")}
</Link>
</DropdownMenuItem>
)}
{selectedNode.id !== tenantId && (
<DropdownMenuItem
className="text-destructive"
onClick={() =>
handleRemoveNode(selectedNode.id, selectedNode.name)
}
>
<Trash2 size={14} className="mr-2" />
{t("ui.common.remove", "조직 계층에서 제외")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent className="flex-1 overflow-hidden p-0 flex flex-col">
<ScrollArea className="flex-1">
<div className="p-6 space-y-8">
{selectedNode.children.length > 0 && (
<div className="space-y-4">
<h3 className="text-sm font-bold flex items-center gap-2">
<Network size={16} className="text-muted-foreground" />
({selectedNode.children.length})
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{selectedNode.children?.map((child) => (
<Card
key={child.id}
className="cursor-pointer hover:border-primary/50 transition-colors bg-muted/20"
onClick={() => setSelectedNodeId(child.id)}
>
<CardHeader className="p-4 space-y-1">
<div className="flex items-center justify-between">
<div className="p-1.5 rounded bg-background">
{React.createElement(
getTenantIcon(child.type),
{
size: 14,
className: "text-primary",
},
)}
</div>
<Badge variant="outline" className="text-[9px]">
{child.recursiveMemberCount}{" "}
{t("ui.admin.tenants.table.members", "명")}
</Badge>
</div>
<CardTitle className="text-sm">
{child.name}
</CardTitle>
<CardDescription className="text-[10px] truncate">
{child.slug}
</CardDescription>
</CardHeader>
</Card>
))}
</div>
</div>
)}
<div className="space-y-4">
<h3 className="text-sm font-bold flex items-center gap-2">
<Users size={16} className="text-muted-foreground" />
{t("ui.admin.tenants.members.list_title", "소속 멤버")}
</h3>
<MemberTable
tenantSlug={selectedNode.slug}
onRefreshTrigger={refreshMembersCount}
/>
</div>
</div>
</ScrollArea>
</CardContent>
</Card>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground opacity-30 border-2 border-dashed rounded-lg">
<div className="text-center">
<FolderOpen size={64} className="mx-auto mb-4" />
<p> .</p>
</div>
</div>
)}
</div>
{/* --- Dialogs --- */}
<UserAddDialog
tenantSlug={selectedNode?.slug || ""}
tenantName={selectedNode?.name || ""}
open={isUserAddOpen}
onOpenChange={(open) => {
setIsUserAddOpen(open);
if (!open) setRefreshMembersCount((prev) => prev + 1);
}}
/>
<Dialog open={isAddExistingOpen} onOpenChange={setIsAddExistingOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
{t("ui.admin.tenants.sub.add_existing", "기존 테넌트 연결")}
</DialogTitle>
<DialogDescription>
[{currentBase.name}] .
</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="검색..."
className="pl-9"
value={existingSearch}
onChange={(e) => setExistingSearch(e.target.value)}
/>
</div>
<ScrollArea className="h-60 border rounded-md">
<Table>
<TableBody>
{candidates?.map((tenantItem) => (
<TableRow
key={tenantItem.id}
className="hover:bg-muted/50 cursor-pointer"
onClick={() =>
updateParentMutation.mutate({
id: tenantItem.id,
parentId: tenantId,
})
}
>
<TableCell>
<div className="flex items-center gap-2">
{React.createElement(getTenantIcon(tenantItem.type), {
size: 14,
className: "text-muted-foreground",
})}
<div>
<p className="text-sm font-medium">
{tenantItem.name}
</p>
<p className="text-[10px] font-mono text-muted-foreground">
{tenantItem.slug}
</p>
</div>
</div>
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" className="h-7 px-2">
<Plus size={14} className="mr-1" />{" "}
{t("ui.common.add", "추가")}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
</div>
</DialogContent>
</Dialog>
</div>
);
}
// --- Internal Support Components ---
const UserAddDialog: React.FC<{
tenantSlug: string;
tenantName: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}> = ({ tenantSlug, tenantName, open, onOpenChange }) => {
const queryClient = useQueryClient();
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 handleAssign = async () => {
if (!selectedUserId) return;
setIsSubmitting(true);
try {
await updateUser(selectedUserId, { tenantSlug });
toast.success(t("msg.info.saved_success", "사용자가 배정되었습니다."));
onOpenChange(false);
resetFields();
} catch (err) {
const error = err as AxiosError<{ error?: string }>;
toast.error(
error.response?.data?.error ||
t("msg.admin.users.detail.update_error", "배정 실패"),
);
} finally {
setIsSubmitting(false);
}
};
const resetFields = () => {
setUserSearch("");
setSearchResults([]);
setSelectedUserId(null);
};
return (
<Dialog
open={open}
onOpenChange={(v) => {
onOpenChange(v);
if (!v) resetFields();
}}
>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
{t("ui.admin.users.create.title", "멤버 추가")}
</DialogTitle>
<DialogDescription>
[{tenantName}] .
</DialogDescription>
</DialogHeader>
<div 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>
<ScrollArea className="h-60 border rounded-md">
<Table>
<TableBody>
{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>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">{user.name}</p>
<p className="text-[10px] text-muted-foreground">
{user.email}
</p>
</div>
{selectedUserId === user.id && (
<ChevronRight size={16} className="text-primary" />
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={handleAssign}
disabled={isSubmitting || !selectedUserId}
>
{t("ui.common.add", "배정")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default TenantUserGroupsTab;