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 (
);
};
const MemberTable: React.FC<{
members: UserSummary[];
isLoading: boolean;
onRefresh: () => void;
showTenant?: boolean;
}> = ({ members, isLoading, onRefresh, showTenant }) => (
{t("ui.admin.users.table.name", "NAME")}
{t("ui.admin.users.table.email", "EMAIL")}
{showTenant && (
{t("ui.admin.tenants.table.slug", "TENANT")}
)}
{t("ui.admin.users.table.role", "ROLE")}
{isLoading ? (
{t("msg.common.loading", "로딩 중...")}
) : members.length === 0 ? (
{t("msg.admin.users.list.empty", "멤버가 없습니다.")}
) : (
members.map((user) => (
{user.name}
{user.email}
{showTenant && (
{user.tenantSlug}
)}
{user.role}
))
)}
);
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([]);
const [selectedUserId, setSelectedUserId] = useState(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 (
);
};
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 (
<>
{hasChildren ? (
) : (
level > 0 && (
)
)}
{!isRoot && (
)}
{node.name}
{isRoot && (
Root
)}
{t(`domain.tenant_type.${node.type?.toLowerCase()}`, node.type)}
{node.slug}
{t(`ui.common.status.${node.status}`, node.status)}
{!isRoot && (
)}
{isExpanded &&
node.children.map((child) => (
))}
>
);
};
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 (
{t("msg.common.loading", "로딩 중...")}
);
if (!currentBase)
return (
{t("msg.admin.tenants.not_found", "현재 테넌트를 찾을 수 없습니다.")}{" "}
(ID: {tenantId})
);
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 (
{t("ui.admin.tenants.sub.title", "조직 계층 구조", {
count: subTree.length,
})}
{t(
"msg.admin.tenants.sub.subtitle",
"하위 조직망을 구성하고 관리합니다.",
)}
setTreeSearchTerm(e.target.value)}
/>
{treeSearchTerm && (
)}
{t("ui.admin.tenants.table.name", "NAME")}
{t("ui.admin.tenants.table.slug", "SLUG")}
{t("ui.admin.tenants.table.members", "MEMBERS")}
{t("ui.admin.tenants.table.status", "STATUS")}
{t("ui.admin.tenants.table.actions", "ACTIONS")}
);
}
export default TenantUserGroupsTab;