1
0
forked from baron/baron-sso

feat: 테넌트 그룹 관리 UI 및 상세 멤버십 관리 기능 구현 #239

This commit is contained in:
2026-02-11 10:47:44 +09:00
parent 1548e60361
commit 4ef7ab78e2
9 changed files with 644 additions and 56 deletions

View File

@@ -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 (
<div className="space-y-8">
<header className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>Tenants</span>
<span>/</span>
<span>Groups</span>
<span>/</span>
<span className="text-foreground">Create</span>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-3xl font-semibold"> </h2>
<p className="text-sm text-[var(--color-muted)]">
.
</p>
</div>
<Badge variant="muted">Super Admin only</Badge>
</div>
</header>
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<LayoutGrid size={18} className="text-primary" />
Group Profile
</CardTitle>
<CardDescription>
(Slug) .
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label className="text-sm font-semibold">
Group Name <span className="text-destructive">*</span>
</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="예: 바론소프트웨어 통합그룹"
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Slug</Label>
<Input
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder="baron-group"
/>
<p className="text-xs text-muted-foreground">
URL이나 API에서 . .
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Description</Label>
<Textarea
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="그룹에 대한 설명을 입력하세요."
/>
</div>
{errorMsg && (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMsg}
</div>
)}
</CardContent>
</Card>
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles size={18} />
</CardTitle>
<CardDescription>
.
</CardDescription>
</CardHeader>
<CardContent className="text-sm text-[var(--color-muted)]">
.
</CardContent>
</Card>
<div className="flex items-center justify-end gap-3">
<Button variant="outline" onClick={() => navigate("/tenant-groups")}>
</Button>
<Button
onClick={() => mutation.mutate()}
disabled={mutation.isPending || name.trim() === ""}
>
</Button>
</div>
</div>
);
}
export default TenantGroupCreatePage;

View File

@@ -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 (
<div className="space-y-8">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<Link to="/tenant-groups" className="inline-flex items-center gap-2 hover:text-foreground">
<ArrowLeft size={14} />
Groups
</Link>
<span>/</span>
<span className="text-foreground">Detail</span>
</div>
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
<LayoutGrid size={24} className="text-primary" />
</div>
<h2 className="text-3xl font-semibold">
{groupQuery.data?.name ?? "Loading Group..."}
</h2>
</div>
<p className="text-sm text-[var(--color-muted)]">
{groupQuery.data?.description || "그룹 정보를 관리하고 소속 테넌트를 구성합니다."}
</p>
</div>
<Badge variant="muted">Super Admin only</Badge>
</header>
{/* Tabs */}
<div className="flex border-b border-border">
<Link
to={`/tenant-groups/${id}`}
className={`px-6 py-3 text-sm font-medium transition-colors ${
!isTenantsTab
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
</Link>
<Link
to={`/tenant-groups/${id}/tenants`}
className={`px-6 py-3 text-sm font-medium transition-colors ${
isTenantsTab
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
({groupQuery.data?.tenants?.length ?? 0})
</Link>
</div>
<div className="mt-6">
<Outlet context={{ group: groupQuery.data, refetch: groupQuery.refetch }} />
</div>
</div>
);
}
export default TenantGroupDetailPage;

View File

@@ -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 (
<div className="max-w-2xl space-y-6">
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription>
. (Slug) .
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label> ID ( )</Label>
<Input value={group.id} disabled className="bg-muted" />
</div>
<div className="space-y-2">
<Label>Slug</Label>
<Input value={group.slug} disabled className="bg-muted" />
</div>
<div className="space-y-2">
<Label htmlFor="groupName">Group Name</Label>
<Input
id="groupName"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="groupDesc">Description</Label>
<Textarea
id="groupDesc"
rows={4}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
{errorMsg && (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMsg}
</div>
)}
<div className="flex justify-end pt-4">
<Button
onClick={() => mutation.mutate()}
disabled={mutation.isPending || (name === group.name && description === group.description)}
>
{mutation.isPending ? "저장 중..." : "변경사항 저장"}
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
export default TenantGroupProfileTab;

View File

@@ -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 (
<div className="grid gap-6 lg:grid-cols-2">
{/* 현재 소속 테넌트 */}
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 size={18} className="text-primary" />
</CardTitle>
<CardDescription>
.
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead>Slug</TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{group.tenants?.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="text-center py-8 text-muted-foreground">
.
</TableCell>
</TableRow>
)}
{group.tenants?.map((t) => (
<TableRow key={t.id}>
<TableCell className="font-medium">{t.name}</TableCell>
<TableCell className="text-xs">{t.slug}</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveTenant(t.id)}
disabled={removeMutation.isPending}
>
<Trash2 size={14} className="text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* 추가 가능한 테넌트 */}
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Plus size={18} className="text-primary" />
</CardTitle>
<div className="relative w-48">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="검색..."
className="pl-8 h-9"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
/>
</div>
</div>
<CardDescription>
.
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredAvailable.length === 0 && (
<TableRow>
<TableCell colSpan={3} className="text-center py-8 text-muted-foreground">
.
</TableCell>
</TableRow>
)}
{filteredAvailable.map((t) => (
<TableRow key={t.id}>
<TableCell>
<div className="font-medium">{t.name}</div>
<div className="text-[10px] text-muted-foreground">{t.slug}</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="text-[10px]">
{t.status}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={() => handleAddTenant(t.id)}
disabled={addMutation.isPending}
>
<Plus size={14} />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}
export default TenantGroupTenantsTab;

View File

@@ -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)}
/>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">Tenant Group</Label>
<select
value={tenantGroupId}
onChange={(e) => setTenantGroupId(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value=""> </option>
{groupsQuery.data?.items.map((group) => (
<option key={group.id} value={group.id}>
{group.name} ({group.slug})
</option>
))}
</select>
<p className="text-xs text-muted-foreground">
. .
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
Allowed Domains (Comma separated)