import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
ArrowRight,
Briefcase,
Building2,
ChevronDown,
ChevronRight,
Download,
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,
} from "../../../components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../../../components/ui/dropdown-menu";
import { Input } from "../../../components/ui/input";
import { ScrollArea } from "../../../components/ui/scroll-area";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { toast } from "../../../components/ui/use-toast";
import {
bulkUpdateUsers,
exportTenantsCSV,
exportUsersCSV,
fetchAllTenants,
fetchUsers,
type TenantSummary,
type UserSummary,
updateTenant,
updateUser,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { buildTenantFullTree, type TenantNode } from "../../../lib/tenantTree";
import {
buildAuthenticatedOrgChartUserMultiPickerUrl,
parseOrgChartUserSelections,
} from "../../users/orgChartPicker";
// --- 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 (
{isExpanded && hasChildren && (
{node.children.map((child) => (
))}
)}
);
};
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(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: (_result, userId) => {
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
queryClient.invalidateQueries({ queryKey: ["users"] });
queryClient.invalidateQueries({ queryKey: ["user", userId] });
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 (
{t("msg.common.loading", "멤버 정보를 불러오는 중...")}
);
if (members.length === 0)
return (
{t("msg.admin.users.list.empty", "이 조직에 소속된 멤버가 없습니다.")}
);
return (
{t("ui.admin.users.table.name", "이름")}
{t("ui.admin.users.table.email", "이메일")}
{t("ui.admin.users.table.role", "역할")}
{members.map((user) => (
{user.name}
{user.email}
{user.role}
{t("ui.common.detail", "상세보기")}
handleMoveClick(user)}>
{t("ui.common.move_org", "타 조직으로 이동")}
{
if (
window.confirm(
t(
"msg.admin.users.confirm_remove_org",
"이 조직에서 사용자를 제외하시겠습니까?",
),
)
) {
removeMutation.mutate(user.id);
}
}}
>
{t("ui.common.remove_org", "조직에서 제외")}
))}
);
};
// --- Main Component ---
function TenantUserGroupsTab() {
const { tenantId } = useParams<{ tenantId: string }>();
const _navigate = useNavigate();
const queryClient = useQueryClient();
const [selectedNodeId, setSelectedNodeId] = useState(tenantId || "");
const [treeSearch, setTreeSearch] = useState("");
const [refreshMembersCount, setRefreshMembersCount] = useState(0);
const [isUserAddOpen, setIsUserAddOpen] = useState(false);
const [isAddExistingOpen, setIsAddExistingOpen] = useState(false);
const [existingSearch, setExistingSearch] = useState("");
const exportChildrenMutation = useMutation({
mutationFn: (parentId: string) => exportTenantsCSV(true, parentId),
onSuccess: ({ blob, filename }) => {
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
},
onError: () =>
toast.error(
t("msg.admin.tenants.export_error", "테넌트 내보내기에 실패했습니다."),
),
});
const exportCurrentMembersMutation = useMutation({
mutationFn: (tenantSlug: string) => exportUsersCSV("", tenantSlug, false),
onSuccess: ({ blob, filename }) => {
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
},
onError: () =>
toast.error(
t("msg.admin.users.export_error", "사용자 내보내기에 실패했습니다."),
),
});
// Data Fetching
const {
data: allTenantsData,
isLoading: isTenantsLoading,
refetch: refetchTree,
} = useQuery({
queryKey: ["tenants-full-tree-v2"],
queryFn: () => fetchAllTenants(),
});
const { currentBase } = 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 (
{t("msg.common.loading", "조직 정보를 불러오는 중...")}
);
if (!currentBase)
return (
테넌트를 찾을 수 없습니다.
);
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 (
{/* --- Left Panel: Sidebar Tree --- */}
조직도
setTreeSearch(e.target.value)}
/>
{/* --- Right Panel: Selected Node Content --- */}
{selectedNode ? (
{React.createElement(getTenantIcon(selectedNode.type), {
size: 24,
})}
{selectedNode.name}
{selectedNode.slug}
{selectedNode.recursiveMemberCount}{" "}
{t("ui.admin.tenants.table.members", "명")}
|
{t(
`domain.tenant_type.${selectedNode.type.toLowerCase()}`,
selectedNode.type,
)}
{t("ui.common.manage", "조직 관리")}
상세 프로필로 이동
{!selectedNode.parentId && (
{t("ui.admin.tenants.sub.add", "하위 부서 생성")}
)}
{selectedNode.id !== tenantId && (
handleRemoveNode(selectedNode.id, selectedNode.name)
}
>
{t("ui.common.remove", "조직 계층에서 제외")}
)}
{selectedNode.children.length > 0 && (
하위 조직 ({selectedNode.children.length})
{selectedNode.children?.map((child) => (
setSelectedNodeId(child.id)}
>
{React.createElement(
getTenantIcon(child.type),
{
size: 14,
className: "text-primary",
},
)}
{child.recursiveMemberCount}{" "}
{t("ui.admin.tenants.table.members", "명")}
{child.name}
{child.slug}
))}
)}
{t("ui.admin.tenants.members.list_title", "소속 멤버")}
) : (
)}
{/* --- Dialogs --- */}
{
setIsUserAddOpen(open);
if (!open) setRefreshMembersCount((prev) => prev + 1);
}}
/>
);
}
// --- 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([]);
const [queuedUsers, setQueuedUsers] = useState([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const orgChartMemberPickerUrl = React.useMemo(
() =>
buildAuthenticatedOrgChartUserMultiPickerUrl(
import.meta.env.ORGFRONT_URL,
),
[],
);
const queuedUserIds = React.useMemo(
() => new Set(queuedUsers.map((user) => user.id)),
[queuedUsers],
);
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 (queuedUsers.length === 0) return;
setIsSubmitting(true);
try {
await bulkUpdateUsers({
userIds: queuedUsers.map((user) => user.id),
tenantSlug,
isAddTenant: true,
});
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
toast.success(
t(
"msg.admin.tenants.members.add_success",
"{{count}}명의 구성원이 추가되었습니다.",
{ count: queuedUsers.length },
),
);
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([]);
setQueuedUsers([]);
};
const queueUsers = React.useCallback((users: UserSummary[]) => {
setQueuedUsers((current) => {
const blockedIds = new Set(current.map((user) => user.id));
const next = [...current];
for (const user of users) {
if (blockedIds.has(user.id)) continue;
blockedIds.add(user.id);
next.push(user);
}
return next;
});
}, []);
const queueUser = (user: UserSummary) => {
queueUsers([user]);
};
const removeQueuedUser = (userId: string) => {
setQueuedUsers((current) => current.filter((user) => user.id !== userId));
};
React.useEffect(() => {
if (!open) return;
const onMessage = (event: MessageEvent) => {
const selections = parseOrgChartUserSelections(event.data);
if (selections.length === 0) return;
queueUsers(
selections.map((selection) => ({
id: selection.id,
name: selection.name,
email: selection.email,
role: "user",
status: "active",
createdAt: "",
updatedAt: "",
})),
);
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [open, queueUsers]);
return (
);
};
export default TenantUserGroupsTab;