diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx
index 8cd13cf1..96bde96f 100644
--- a/adminfront/src/app/routes.tsx
+++ b/adminfront/src/app/routes.tsx
@@ -6,7 +6,11 @@ import AuditLogsPage from "../features/audit/AuditLogsPage";
import AuthPage from "../features/auth/AuthPage";
import DashboardPage from "../features/dashboard/DashboardPage";
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
+import TenantGroupCreatePage from "../features/tenant-groups/routes/TenantGroupCreatePage";
+import TenantGroupDetailPage from "../features/tenant-groups/routes/TenantGroupDetailPage";
import TenantGroupListPage from "../features/tenant-groups/routes/TenantGroupListPage";
+import TenantGroupProfileTab from "../features/tenant-groups/routes/TenantGroupProfileTab";
+import TenantGroupTenantsTab from "../features/tenant-groups/routes/TenantGroupTenantsTab";
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
import TenantListPage from "../features/tenants/routes/TenantListPage";
@@ -32,6 +36,15 @@ export const router = createBrowserRouter(
{ path: "tenants", element: },
{ path: "tenants/new", element: },
{ path: "tenant-groups", element: },
+ { path: "tenant-groups/new", element: },
+ {
+ path: "tenant-groups/:id",
+ element: ,
+ children: [
+ { index: true, element: },
+ { path: "tenants", element: },
+ ],
+ },
{
path: "tenants/:tenantId",
element: ,
diff --git a/adminfront/src/features/tenant-groups/routes/TenantGroupCreatePage.tsx b/adminfront/src/features/tenant-groups/routes/TenantGroupCreatePage.tsx
new file mode 100644
index 00000000..ccbd63e5
--- /dev/null
+++ b/adminfront/src/features/tenant-groups/routes/TenantGroupCreatePage.tsx
@@ -0,0 +1,142 @@
+import { useMutation } from "@tanstack/react-query";
+import type { AxiosError } from "axios";
+import { LayoutGrid, Sparkles } from "lucide-react";
+import { useState } from "react";
+import { useNavigate } 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 { Input } from "../../../components/ui/input";
+import { Label } from "../../../components/ui/label";
+import { Textarea } from "../../../components/ui/textarea";
+import { createTenantGroup } from "../../../lib/adminApi";
+
+function TenantGroupCreatePage() {
+ const navigate = useNavigate();
+ const [name, setName] = useState("");
+ const [slug, setSlug] = useState("");
+ const [description, setDescription] = useState("");
+
+ const mutation = useMutation({
+ mutationFn: () =>
+ createTenantGroup({
+ name,
+ slug: slug || name.toLowerCase().replace(/ /g, "-"),
+ description: description || undefined,
+ }),
+ onSuccess: () => {
+ navigate("/tenant-groups");
+ },
+ });
+
+ const errorMsg = (mutation.error as AxiosError<{ error?: string }>)?.response
+ ?.data?.error;
+
+ return (
+
+
+
+
+
+
+
+ Group Profile
+
+
+ 그룹 이름과 식별자(Slug)를 입력합니다.
+
+
+
+
+
+ setName(e.target.value)}
+ placeholder="예: 바론소프트웨어 통합그룹"
+ />
+
+
+
+
setSlug(e.target.value)}
+ placeholder="baron-group"
+ />
+
+ URL이나 API에서 사용될 고유 식별자입니다. 비워두면 이름 기반으로 자동 생성됩니다.
+
+
+
+
+
+
+ {errorMsg && (
+
+ {errorMsg}
+
+ )}
+
+
+
+
+
+
+
+ 권한 상속 안내
+
+
+ 테넌트 그룹의 관리자는 소속된 모든 테넌트에 대한 관리 권한을 자동으로 가집니다.
+
+
+
+ 생성 후 상세 페이지에서 테넌트를 이 그룹에 할당할 수 있습니다.
+
+
+
+
+
+
+
+
+ );
+}
+
+export default TenantGroupCreatePage;
diff --git a/adminfront/src/features/tenant-groups/routes/TenantGroupDetailPage.tsx b/adminfront/src/features/tenant-groups/routes/TenantGroupDetailPage.tsx
new file mode 100644
index 00000000..83b22ae7
--- /dev/null
+++ b/adminfront/src/features/tenant-groups/routes/TenantGroupDetailPage.tsx
@@ -0,0 +1,77 @@
+import { useQuery } from "@tanstack/react-query";
+import { ArrowLeft, LayoutGrid } from "lucide-react";
+import { Link, Outlet, useLocation, useParams } from "react-router-dom";
+import { Badge } from "../../../components/ui/badge";
+import { fetchTenantGroup } from "../../../lib/adminApi";
+
+function TenantGroupDetailPage() {
+ const { id } = useParams<{ id: string }>();
+ const location = useLocation();
+
+ const groupQuery = useQuery({
+ queryKey: ["tenant-group", id],
+ queryFn: () => fetchTenantGroup(id!),
+ enabled: !!id,
+ });
+
+ const isTenantsTab = location.pathname.endsWith("/tenants");
+
+ return (
+
+
+
+ {/* Tabs */}
+
+
+ 기본 정보
+
+
+ 소속 테넌트 ({groupQuery.data?.tenants?.length ?? 0})
+
+
+
+
+
+
+
+ );
+}
+
+export default TenantGroupDetailPage;
diff --git a/adminfront/src/features/tenant-groups/routes/TenantGroupProfileTab.tsx b/adminfront/src/features/tenant-groups/routes/TenantGroupProfileTab.tsx
new file mode 100644
index 00000000..6ca74fb0
--- /dev/null
+++ b/adminfront/src/features/tenant-groups/routes/TenantGroupProfileTab.tsx
@@ -0,0 +1,97 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import type { AxiosError } from "axios";
+import { useState } from "react";
+import { useOutletContext } from "react-router-dom";
+import { Button } from "../../../components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "../../../components/ui/card";
+import { Input } from "../../../components/ui/input";
+import { Label } from "../../../components/ui/label";
+import { Textarea } from "../../../components/ui/textarea";
+import { updateTenantGroup, type TenantGroupSummary } from "../../../lib/adminApi";
+
+function TenantGroupProfileTab() {
+ const { group, refetch } = useOutletContext<{
+ group: TenantGroupSummary;
+ refetch: () => void
+ }>();
+ const queryClient = useQueryClient();
+
+ const [name, setName] = useState(group?.name ?? "");
+ const [description, setDescription] = useState(group?.description ?? "");
+
+ const mutation = useMutation({
+ mutationFn: () => updateTenantGroup(group.id, { name, description }),
+ onSuccess: () => {
+ refetch();
+ queryClient.invalidateQueries({ queryKey: ["tenant-groups"] });
+ },
+ });
+
+ const errorMsg = (mutation.error as AxiosError<{ error?: string }>)?.response
+ ?.data?.error;
+
+ if (!group) return null;
+
+ return (
+
+ );
+}
+
+export default TenantGroupProfileTab;
diff --git a/adminfront/src/features/tenant-groups/routes/TenantGroupTenantsTab.tsx b/adminfront/src/features/tenant-groups/routes/TenantGroupTenantsTab.tsx
new file mode 100644
index 00000000..7dee5408
--- /dev/null
+++ b/adminfront/src/features/tenant-groups/routes/TenantGroupTenantsTab.tsx
@@ -0,0 +1,200 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Plus, Trash2, Building2, Search } from "lucide-react";
+import { useState } from "react";
+import { useOutletContext } from "react-router-dom";
+import { Button } from "../../../components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "../../../components/ui/card";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "../../../components/ui/table";
+import { Input } from "../../../components/ui/input";
+import { Badge } from "../../../components/ui/badge";
+import {
+ addTenantToGroup,
+ removeTenantFromGroup,
+ fetchTenants,
+ type TenantGroupSummary
+} from "../../../lib/adminApi";
+
+function TenantGroupTenantsTab() {
+ const { group, refetch } = useOutletContext<{
+ group: TenantGroupSummary;
+ refetch: () => void
+ }>();
+ const queryClient = useQueryClient();
+ const [searchTerm, setSearchTerm] = useState("");
+
+ // 전체 테넌트 목록 (할당용)
+ const tenantsQuery = useQuery({
+ queryKey: ["tenants", { limit: 100 }],
+ queryFn: () => fetchTenants(100, 0),
+ });
+
+ const addMutation = useMutation({
+ mutationFn: (tenantId: string) => addTenantToGroup(group.id, tenantId),
+ onSuccess: () => {
+ refetch();
+ queryClient.invalidateQueries({ queryKey: ["tenant-group", group.id] });
+ },
+ });
+
+ const removeMutation = useMutation({
+ mutationFn: (tenantId: string) => removeTenantFromGroup(group.id, tenantId),
+ onSuccess: () => {
+ refetch();
+ queryClient.invalidateQueries({ queryKey: ["tenant-group", group.id] });
+ },
+ });
+
+ const handleAddTenant = (tenantId: string) => {
+ addMutation.mutate(tenantId);
+ };
+
+ const handleRemoveTenant = (tenantId: string) => {
+ if (window.confirm("이 테넌트를 그룹에서 제외할까요?")) {
+ removeMutation.mutate(tenantId);
+ }
+ };
+
+ const availableTenants = tenantsQuery.data?.items.filter(
+ (t) => !group.tenants?.some((gt) => gt.id === t.id)
+ ) || [];
+
+ const filteredAvailable = availableTenants.filter(
+ (t) => t.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ t.slug.toLowerCase().includes(searchTerm.toLowerCase())
+ );
+
+ return (
+
+ {/* 현재 소속 테넌트 */}
+
+
+
+
+ 소속 테넌트
+
+
+ 현재 이 그룹에 포함된 테넌트 목록입니다.
+
+
+
+
+
+
+ 이름
+ Slug
+ 제외
+
+
+
+ {group.tenants?.length === 0 && (
+
+
+ 소속된 테넌트가 없습니다.
+
+
+ )}
+ {group.tenants?.map((t) => (
+
+ {t.name}
+ {t.slug}
+
+
+
+
+ ))}
+
+
+
+
+
+ {/* 추가 가능한 테넌트 */}
+
+
+
+
+
+ 테넌트 추가
+
+
+
+ setSearchTerm(e.target.value)}
+ />
+
+
+
+ 다른 그룹에 속하지 않았거나 이동이 필요한 테넌트를 선택하세요.
+
+
+
+
+
+
+ 이름
+ 상태
+ 추가
+
+
+
+ {filteredAvailable.length === 0 && (
+
+
+ 추가할 수 있는 테넌트가 없습니다.
+
+
+ )}
+ {filteredAvailable.map((t) => (
+
+
+ {t.name}
+ {t.slug}
+
+
+
+ {t.status}
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+ );
+}
+
+export default TenantGroupTenantsTab;
diff --git a/adminfront/src/features/tenants/routes/TenantProfilePage.tsx b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx
index f75a3f39..48fed2b0 100644
--- a/adminfront/src/features/tenants/routes/TenantProfilePage.tsx
+++ b/adminfront/src/features/tenants/routes/TenantProfilePage.tsx
@@ -18,6 +18,7 @@ import {
approveTenant,
deleteTenant,
fetchTenant,
+ fetchTenantGroups,
updateTenant,
} from "../../../lib/adminApi";
@@ -35,11 +36,17 @@ export function TenantProfilePage() {
queryFn: () => fetchTenant(tenantId),
});
+ const groupsQuery = useQuery({
+ queryKey: ["tenant-groups", { limit: 100 }],
+ queryFn: () => fetchTenantGroups(100, 0),
+ });
+
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState("active");
const [domains, setDomains] = useState("");
+ const [tenantGroupId, setTenantGroupId] = useState("");
useEffect(() => {
if (tenantQuery.data) {
@@ -48,6 +55,7 @@ export function TenantProfilePage() {
setDescription(tenantQuery.data.description ?? "");
setStatus(tenantQuery.data.status);
setDomains(tenantQuery.data.domains?.join(", ") ?? "");
+ setTenantGroupId(tenantQuery.data.tenantGroupId ?? "");
}
}, [tenantQuery.data]);
@@ -58,6 +66,7 @@ export function TenantProfilePage() {
slug,
description: description || undefined,
status,
+ tenantGroupId: tenantGroupId || undefined,
domains: domains
.split(",")
.map((d) => d.trim())
@@ -136,6 +145,24 @@ export function TenantProfilePage() {
onChange={(e) => setDescription(e.target.value)}
/>
+
+
+
+
+ 테넌트가 속할 그룹을 지정합니다. 그룹 관리자는 소속 테넌트에 대한 접근 권한을 가집니다.
+
+