forked from baron/baron-sso
- adminfront: Update removeMutation to correctly pass 'isRemoveTenant: true' and the specific tenant slug instead of empty string - backend: Fix 'Move' operation (Normal Update) in UpdateUser to correctly remove the old primary company code from the 'companyCodes' array and sync the deletion to Keto, ensuring accurate member count aggregation
941 lines
33 KiB
TypeScript
941 lines
33 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 "ORGANIZATION":
|
|
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);
|
|
if (hasChildren) setIsExpanded(!isExpanded);
|
|
}}
|
|
>
|
|
<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 ? (
|
|
<span className="rounded p-0.5">
|
|
{isExpanded ? (
|
|
<ChevronDown size={14} />
|
|
) : (
|
|
<ChevronRight size={14} />
|
|
)}
|
|
</span>
|
|
) : (
|
|
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;
|
|
allTenants?: TenantSummary[];
|
|
}> = ({ tenantSlug, onRefreshTrigger, allTenants }) => {
|
|
const queryClient = useQueryClient();
|
|
const { data, isLoading, refetch } = useQuery({
|
|
queryKey: ["tenant-members-v2", tenantSlug, onRefreshTrigger],
|
|
queryFn: () => fetchUsers(100, 0, undefined, tenantSlug),
|
|
enabled: !!tenantSlug,
|
|
});
|
|
|
|
const members = data?.items ?? [];
|
|
|
|
const [isMoveOpen, setIsMoveOpen] = useState(false);
|
|
const [selectedUser, setSelectedUser] = useState<UserSummary | null>(null);
|
|
const [targetTenantSlug, setTargetTenantSlug] = useState("");
|
|
const [searchTenant, setSearchTenant] = useState("");
|
|
|
|
const moveMutation = useMutation({
|
|
mutationFn: (newSlug: string) => {
|
|
if (!selectedUser) throw new Error("No user selected");
|
|
return updateUser(selectedUser.id, { tenantSlug: newSlug });
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
|
|
toast.success(
|
|
t("msg.info.saved_success", "사용자 조직이 변경되었습니다."),
|
|
);
|
|
setIsMoveOpen(false);
|
|
setSelectedUser(null);
|
|
refetch();
|
|
},
|
|
onError: () => toast.error(t("msg.common.error", "오류가 발생했습니다.")),
|
|
});
|
|
|
|
const removeMutation = useMutation({
|
|
mutationFn: (userId: string) =>
|
|
updateUser(userId, { tenantSlug, isRemoveTenant: true }),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
|
|
toast.success(t("msg.info.saved_success", "조직에서 제외되었습니다."));
|
|
refetch();
|
|
},
|
|
onError: () => toast.error(t("msg.common.error", "오류가 발생했습니다.")),
|
|
});
|
|
|
|
const handleMoveClick = (user: UserSummary) => {
|
|
setSelectedUser(user);
|
|
setTargetTenantSlug("");
|
|
setIsMoveOpen(true);
|
|
};
|
|
|
|
const filteredTenants = React.useMemo(() => {
|
|
if (!allTenants) return [];
|
|
if (!searchTenant) return allTenants;
|
|
return allTenants.filter(
|
|
(t) =>
|
|
t.name.toLowerCase().includes(searchTenant.toLowerCase()) ||
|
|
t.slug.toLowerCase().includes(searchTenant.toLowerCase()),
|
|
);
|
|
}, [allTenants, searchTenant]);
|
|
|
|
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>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={() => handleMoveClick(user)}>
|
|
<ArrowRight size={14} className="mr-2" />
|
|
{t("ui.common.move_org", "타 조직으로 이동")}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() => {
|
|
if (
|
|
window.confirm(
|
|
t(
|
|
"msg.admin.users.confirm_remove_org",
|
|
"이 조직에서 사용자를 제외하시겠습니까?",
|
|
),
|
|
)
|
|
) {
|
|
removeMutation.mutate(user.id);
|
|
}
|
|
}}
|
|
>
|
|
<FolderOpen size={14} className="mr-2" />
|
|
{t("ui.common.remove_org", "조직에서 제외")}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
|
|
<Dialog open={isMoveOpen} onOpenChange={setIsMoveOpen}>
|
|
<DialogContent className="sm:max-w-[400px]">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{t("ui.common.move_org", "타 조직으로 이동")}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{selectedUser?.name} 사용자를 이동할 타 조직을 선택하세요.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-4 py-4">
|
|
<Input
|
|
placeholder={t("ui.common.search", "조직 검색...")}
|
|
value={searchTenant}
|
|
onChange={(e) => setSearchTenant(e.target.value)}
|
|
/>
|
|
<ScrollArea className="h-48 border rounded-md p-2">
|
|
<div className="space-y-1">
|
|
{filteredTenants.map((tItem) => (
|
|
<Button
|
|
key={tItem.id}
|
|
variant={
|
|
targetTenantSlug === tItem.slug ? "secondary" : "ghost"
|
|
}
|
|
className="w-full justify-start text-sm"
|
|
onClick={() => setTargetTenantSlug(tItem.slug)}
|
|
>
|
|
{React.createElement(getTenantIcon(tItem.type), {
|
|
size: 14,
|
|
className: "mr-2 opacity-70",
|
|
})}
|
|
<span>{tItem.name}</span>
|
|
<span className="ml-2 text-[10px] text-muted-foreground opacity-50">
|
|
{tItem.slug}
|
|
</span>
|
|
</Button>
|
|
))}
|
|
{filteredTenants.length === 0 && (
|
|
<div className="py-4 text-center text-sm text-muted-foreground">
|
|
{t("msg.common.no_results", "검색 결과가 없습니다.")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setIsMoveOpen(false)}>
|
|
{t("ui.common.cancel", "취소")}
|
|
</Button>
|
|
<Button
|
|
onClick={() => moveMutation.mutate(targetTenantSlug)}
|
|
disabled={!targetTenantSlug || moveMutation.isPending}
|
|
>
|
|
{moveMutation.isPending ? "..." : t("ui.common.move", "이동")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</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>
|
|
<div className="text-sm text-muted-foreground 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>
|
|
</div>
|
|
</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}
|
|
allTenants={allTenantsData?.items ?? []}
|
|
/>
|
|
</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 });
|
|
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
|
|
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;
|