forked from baron/baron-sso
feat: apply sticky header and inner scroll pattern to all table pages
This commit is contained in:
@@ -63,8 +63,8 @@ function ApiKeyListPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||
<span>
|
||||
@@ -103,8 +103,8 @@ function ApiKeyListPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Card className="bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<Card className="bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{t("ui.admin.api_keys.list.registry.title", "API Key Registry")}
|
||||
@@ -119,95 +119,102 @@ function ApiKeyListPage() {
|
||||
</div>
|
||||
<Badge variant="muted">{t("ui.common.badge.system", "System")}</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
{(errorMsg || fallbackError) && (
|
||||
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{errorMsg ?? fallbackError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.api_keys.list.table.name", "NAME")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.api_keys.list.table.client_id", "CLIENT ID")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.api_keys.list.table.scopes", "SCOPES")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.api_keys.list.table.last_used", "LAST USED")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.api_keys.list.table.actions", "ACTIONS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{query.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!query.isLoading && items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>
|
||||
{t(
|
||||
"msg.admin.api_keys.list.empty",
|
||||
"등록된 API 키가 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{items.map((key) => (
|
||||
<TableRow key={key.id}>
|
||||
<TableCell className="font-semibold">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key size={14} className="text-[var(--color-muted)]" />
|
||||
{key.name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code>{key.client_id}</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{key.scopes.map((scope) => (
|
||||
<Badge
|
||||
key={scope}
|
||||
variant="muted"
|
||||
className="text-[10px]"
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-muted/90 backdrop-blur z-10 shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.api_keys.list.table.name", "NAME")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.api_keys.list.table.client_id", "CLIENT ID")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.api_keys.list.table.scopes", "SCOPES")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.api_keys.list.table.last_used", "LAST USED")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.api_keys.list.table.actions", "ACTIONS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{query.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!query.isLoading && items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>
|
||||
{t(
|
||||
"msg.admin.api_keys.list.empty",
|
||||
"등록된 API 키가 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{items.map((key) => (
|
||||
<TableRow key={key.id}>
|
||||
<TableCell className="font-semibold">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key
|
||||
size={14}
|
||||
className="text-[var(--color-muted)]"
|
||||
/>
|
||||
{key.name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code>{key.client_id}</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{key.scopes.map((scope) => (
|
||||
<Badge
|
||||
key={scope}
|
||||
variant="muted"
|
||||
className="text-[10px]"
|
||||
>
|
||||
{scope}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{key.lastUsedAt
|
||||
? new Date(key.lastUsedAt).toLocaleString("ko-KR")
|
||||
: t("ui.common.never", "Never")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(key.id, key.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{scope}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{key.lastUsedAt
|
||||
? new Date(key.lastUsedAt).toLocaleString("ko-KR")
|
||||
: t("ui.common.never", "Never")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(key.id, key.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t("ui.common.delete", "삭제")}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<Trash2 size={14} />
|
||||
{t("ui.common.delete", "삭제")}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -207,260 +207,266 @@ export function TenantAdminsAndOwnersTab() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8 mt-6">
|
||||
{/* Owners Card */}
|
||||
<Card className="border-none shadow-sm bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
||||
<Crown className="h-6 w-6 text-yellow-500" />
|
||||
{t("ui.admin.tenants.owners.title", "테넌트 소유자")}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.tenants.owners.subtitle",
|
||||
"이 테넌트의 최상위 권한을 가진 소유자(조직장) 목록입니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
onClick={() => setDialogMode("owner")}
|
||||
>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
{t("ui.admin.tenants.owners.add_button", "소유자 추가")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-xl border border-border overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/30">
|
||||
<TableRow>
|
||||
<TableHead className="w-[250px] font-bold">
|
||||
{t("ui.admin.tenants.owners.table_name", "이름")}
|
||||
</TableHead>
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.tenants.owners.table_email", "이메일")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right font-bold w-[100px]">
|
||||
{t("ui.admin.tenants.owners.table_actions", "액션")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ownersQuery.isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="h-32 text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : currentOwners.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="h-32 text-center text-muted-foreground"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Users className="h-8 w-8 opacity-20" />
|
||||
<p>
|
||||
{t(
|
||||
"msg.admin.tenants.owners.empty",
|
||||
"등록된 소유자가 없습니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
currentOwners.map((owner) => (
|
||||
<TableRow
|
||||
key={owner.id}
|
||||
className="hover:bg-muted/30 transition-colors group"
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-lg bg-secondary flex items-center justify-center text-secondary-foreground font-bold text-xs">
|
||||
{owner.name.charAt(0)}
|
||||
</div>
|
||||
<span>{owner.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground italic">
|
||||
{owner.email}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`opacity-0 group-hover:opacity-100 transition-all ${
|
||||
owner.id === currentUserId ||
|
||||
currentOwners.length <= 1
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
}`}
|
||||
onClick={() =>
|
||||
handleRemoveOwner(owner.id, owner.name)
|
||||
}
|
||||
disabled={
|
||||
removeOwnerMutation.isPending ||
|
||||
owner.id === currentUserId ||
|
||||
currentOwners.length <= 1
|
||||
}
|
||||
title={
|
||||
owner.id === currentUserId
|
||||
? t(
|
||||
"msg.admin.tenants.owners.remove_self",
|
||||
"본인의 권한은 회수할 수 없습니다.",
|
||||
)
|
||||
: currentOwners.length <= 1
|
||||
? t(
|
||||
"msg.admin.tenants.owners.remove_last",
|
||||
"마지막 소유자는 회수할 수 없습니다.",
|
||||
)
|
||||
: t(
|
||||
"ui.admin.tenants.owners.remove_title",
|
||||
"소유자 권한 회수",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
<div className="space-y-8 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<div className="flex-1 flex flex-col lg:flex-row gap-8 min-h-0">
|
||||
{/* Owners Card */}
|
||||
<Card className="flex-1 flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7 flex-shrink-0">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
||||
<Crown className="h-6 w-6 text-yellow-500" />
|
||||
{t("ui.admin.tenants.owners.title", "테넌트 소유자")}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.tenants.owners.subtitle",
|
||||
"이 테넌트의 최상위 권한을 가진 소유자(조직장) 목록입니다.",
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
onClick={() => setDialogMode("owner")}
|
||||
>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
{t("ui.admin.tenants.owners.add_button", "소유자 추가")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-muted/90 backdrop-blur z-10 shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead className="w-[250px] font-bold">
|
||||
{t("ui.admin.tenants.owners.table_name", "이름")}
|
||||
</TableHead>
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.tenants.owners.table_email", "이메일")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right font-bold w-[100px]">
|
||||
{t("ui.admin.tenants.owners.table_actions", "액션")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ownersQuery.isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="h-32 text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : currentOwners.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="h-32 text-center text-muted-foreground"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Users className="h-8 w-8 opacity-20" />
|
||||
<p>
|
||||
{t(
|
||||
"msg.admin.tenants.owners.empty",
|
||||
"등록된 소유자가 없습니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
currentOwners.map((owner) => (
|
||||
<TableRow
|
||||
key={owner.id}
|
||||
className="hover:bg-muted/30 transition-colors group"
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-lg bg-secondary flex items-center justify-center text-secondary-foreground font-bold text-xs">
|
||||
{owner.name.charAt(0)}
|
||||
</div>
|
||||
<span>{owner.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground italic">
|
||||
{owner.email}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`opacity-0 group-hover:opacity-100 transition-all ${
|
||||
owner.id === currentUserId ||
|
||||
currentOwners.length <= 1
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
}`}
|
||||
onClick={() =>
|
||||
handleRemoveOwner(owner.id, owner.name)
|
||||
}
|
||||
disabled={
|
||||
removeOwnerMutation.isPending ||
|
||||
owner.id === currentUserId ||
|
||||
currentOwners.length <= 1
|
||||
}
|
||||
title={
|
||||
owner.id === currentUserId
|
||||
? t(
|
||||
"msg.admin.tenants.owners.remove_self",
|
||||
"본인의 권한은 회수할 수 없습니다.",
|
||||
)
|
||||
: currentOwners.length <= 1
|
||||
? t(
|
||||
"msg.admin.tenants.owners.remove_last",
|
||||
"마지막 소유자는 회수할 수 없습니다.",
|
||||
)
|
||||
: t(
|
||||
"ui.admin.tenants.owners.remove_title",
|
||||
"소유자 권한 회수",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Admins Card */}
|
||||
<Card className="border-none shadow-sm bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
||||
<ShieldCheck className="h-6 w-6 text-primary" />
|
||||
{t("ui.admin.tenants.admins.title", "테넌트 관리자")}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.tenants.admins.subtitle",
|
||||
"이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
onClick={() => setDialogMode("admin")}
|
||||
>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
{t("ui.admin.tenants.admins.add_button", "관리자 추가")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-xl border border-border overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/30">
|
||||
<TableRow>
|
||||
<TableHead className="w-[250px] font-bold">
|
||||
{t("ui.admin.tenants.admins.table_name", "이름")}
|
||||
</TableHead>
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.tenants.admins.table_email", "이메일")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right font-bold w-[100px]">
|
||||
{t("ui.admin.tenants.admins.table_actions", "액션")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{adminsQuery.isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="h-32 text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : currentAdmins.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="h-32 text-center text-muted-foreground"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Users className="h-8 w-8 opacity-20" />
|
||||
<p>
|
||||
{t(
|
||||
"msg.admin.tenants.admins.empty",
|
||||
"등록된 관리자가 없습니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
currentAdmins.map((admin) => (
|
||||
<TableRow
|
||||
key={admin.id}
|
||||
className="hover:bg-muted/30 transition-colors group"
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-lg bg-secondary flex items-center justify-center text-secondary-foreground font-bold text-xs">
|
||||
{admin.name.charAt(0)}
|
||||
</div>
|
||||
<span>{admin.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground italic">
|
||||
{admin.email}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`opacity-0 group-hover:opacity-100 transition-all ${
|
||||
admin.id === currentUserId ||
|
||||
currentAdmins.length <= 1
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
}`}
|
||||
onClick={() =>
|
||||
handleRemoveAdmin(admin.id, admin.name)
|
||||
}
|
||||
disabled={
|
||||
removeAdminMutation.isPending ||
|
||||
admin.id === currentUserId ||
|
||||
currentAdmins.length <= 1
|
||||
}
|
||||
title={
|
||||
admin.id === currentUserId
|
||||
? t(
|
||||
"msg.admin.tenants.admins.remove_self",
|
||||
"본인의 권한은 회수할 수 없습니다.",
|
||||
)
|
||||
: currentAdmins.length <= 1
|
||||
? t(
|
||||
"msg.admin.tenants.admins.remove_last",
|
||||
"마지막 관리자는 회수할 수 없습니다.",
|
||||
)
|
||||
: t(
|
||||
"ui.admin.tenants.admins.remove_title",
|
||||
"관리자 권한 회수",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
{/* Admins Card */}
|
||||
<Card className="flex-1 flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7 flex-shrink-0">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold flex items-center gap-2">
|
||||
<ShieldCheck className="h-6 w-6 text-primary" />
|
||||
{t("ui.admin.tenants.admins.title", "테넌트 관리자")}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.tenants.admins.subtitle",
|
||||
"이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다.",
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
onClick={() => setDialogMode("admin")}
|
||||
>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
{t("ui.admin.tenants.admins.add_button", "관리자 추가")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-muted/90 backdrop-blur z-10 shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead className="w-[250px] font-bold">
|
||||
{t("ui.admin.tenants.admins.table_name", "이름")}
|
||||
</TableHead>
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.tenants.admins.table_email", "이메일")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right font-bold w-[100px]">
|
||||
{t("ui.admin.tenants.admins.table_actions", "액션")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{adminsQuery.isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="h-32 text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : currentAdmins.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="h-32 text-center text-muted-foreground"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Users className="h-8 w-8 opacity-20" />
|
||||
<p>
|
||||
{t(
|
||||
"msg.admin.tenants.admins.empty",
|
||||
"등록된 관리자가 없습니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
currentAdmins.map((admin) => (
|
||||
<TableRow
|
||||
key={admin.id}
|
||||
className="hover:bg-muted/30 transition-colors group"
|
||||
>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-lg bg-secondary flex items-center justify-center text-secondary-foreground font-bold text-xs">
|
||||
{admin.name.charAt(0)}
|
||||
</div>
|
||||
<span>{admin.name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground italic">
|
||||
{admin.email}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`opacity-0 group-hover:opacity-100 transition-all ${
|
||||
admin.id === currentUserId ||
|
||||
currentAdmins.length <= 1
|
||||
? "opacity-50 cursor-not-allowed"
|
||||
: "text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
}`}
|
||||
onClick={() =>
|
||||
handleRemoveAdmin(admin.id, admin.name)
|
||||
}
|
||||
disabled={
|
||||
removeAdminMutation.isPending ||
|
||||
admin.id === currentUserId ||
|
||||
currentAdmins.length <= 1
|
||||
}
|
||||
title={
|
||||
admin.id === currentUserId
|
||||
? t(
|
||||
"msg.admin.tenants.admins.remove_self",
|
||||
"본인의 권한은 회수할 수 없습니다.",
|
||||
)
|
||||
: currentAdmins.length <= 1
|
||||
? t(
|
||||
"msg.admin.tenants.admins.remove_last",
|
||||
"마지막 관리자는 회수할 수 없습니다.",
|
||||
)
|
||||
: t(
|
||||
"ui.admin.tenants.admins.remove_title",
|
||||
"관리자 권한 회수",
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Common Dialog for adding users */}
|
||||
<Dialog
|
||||
|
||||
@@ -343,11 +343,11 @@ function TenantGroupsPage() {
|
||||
const currentGroup = groupsQuery.data?.find((g) => g.id === selectedGroupId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 mt-6">
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
<div className="space-y-6 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<div className="grid gap-6 md:grid-cols-3 flex-1 min-h-0">
|
||||
{/* 그룹 생성 폼 */}
|
||||
<Card className="bg-[var(--color-panel)] md:col-span-1 border-primary/20">
|
||||
<CardHeader>
|
||||
<Card className="flex flex-col min-h-0 bg-[var(--color-panel)] md:col-span-1 border-primary/20">
|
||||
<CardHeader className="flex-shrink-0">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Plus size={16} />{" "}
|
||||
{t("ui.admin.groups.create.title", "새 그룹 생성")}
|
||||
@@ -359,7 +359,7 @@ function TenantGroupsPage() {
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardContent className="space-y-4 flex-1 overflow-auto">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="name">
|
||||
{t("ui.admin.groups.form.name_label", "그룹 이름")}
|
||||
@@ -431,8 +431,8 @@ function TenantGroupsPage() {
|
||||
</Card>
|
||||
|
||||
{/* 그룹 목록 (트리 뷰) */}
|
||||
<Card className="bg-[var(--color-panel)] md:col-span-2">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<Card className="flex flex-col min-h-0 bg-[var(--color-panel)] md:col-span-2">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{t("ui.admin.groups.list.title", "User Groups")}
|
||||
@@ -458,76 +458,80 @@ function TenantGroupsPage() {
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.groups.table.name", "NAME")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.groups.table.members", "MEMBERS")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.groups.table.actions", "ACTIONS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groupsQuery.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3}>
|
||||
{t("msg.admin.groups.list.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!groupsQuery.isLoading && groupTree.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.groups.list.empty",
|
||||
"아직 등록된 그룹이 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{groupTree.map((node) => (
|
||||
<UserGroupTreeNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
level={0}
|
||||
onSelect={setSelectedGroupId}
|
||||
selectedGroupId={selectedGroupId}
|
||||
onDelete={(id) => {
|
||||
if (
|
||||
window.confirm(
|
||||
t(
|
||||
"msg.admin.groups.list.delete_confirm",
|
||||
"그룹을 삭제하시겠습니까?",
|
||||
),
|
||||
)
|
||||
) {
|
||||
deleteMutation.mutate(id);
|
||||
}
|
||||
}}
|
||||
onAddSubGroup={handleAddSubGroup}
|
||||
addMemberMutation={addMemberMutation}
|
||||
removeMemberMutation={removeMemberMutation}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-muted/90 backdrop-blur z-10 shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.groups.table.name", "NAME")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.groups.table.members", "MEMBERS")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.groups.table.actions", "ACTIONS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groupsQuery.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3}>
|
||||
{t("msg.admin.groups.list.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!groupsQuery.isLoading && groupTree.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.groups.list.empty",
|
||||
"아직 등록된 그룹이 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{groupTree.map((node) => (
|
||||
<UserGroupTreeNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
level={0}
|
||||
onSelect={setSelectedGroupId}
|
||||
selectedGroupId={selectedGroupId}
|
||||
onDelete={(id) => {
|
||||
if (
|
||||
window.confirm(
|
||||
t(
|
||||
"msg.admin.groups.list.delete_confirm",
|
||||
"그룹을 삭제하시겠습니까?",
|
||||
),
|
||||
)
|
||||
) {
|
||||
deleteMutation.mutate(id);
|
||||
}
|
||||
}}
|
||||
onAddSubGroup={handleAddSubGroup}
|
||||
addMemberMutation={addMemberMutation}
|
||||
removeMemberMutation={removeMemberMutation}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 멤버 관리 섹션 (선택된 그룹이 있을 때) */}
|
||||
{currentGroup && (
|
||||
<Card className="bg-[var(--color-panel)] border-t-4 border-t-primary">
|
||||
<CardHeader>
|
||||
<Card className="flex flex-col min-h-0 flex-1 bg-[var(--color-panel)] border-t-4 border-t-primary">
|
||||
<CardHeader className="flex-shrink-0">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield size={18} className="text-primary" />
|
||||
{t("msg.admin.groups.members.title", "[{{name}}] 멤버 관리", {
|
||||
@@ -541,8 +545,8 @@ function TenantGroupsPage() {
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex justify-end mb-4">
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="flex justify-end mb-4 flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleAddMember(currentGroup.id)}
|
||||
@@ -552,56 +556,65 @@ function TenantGroupsPage() {
|
||||
{t("ui.common.add", "멤버 추가")}
|
||||
</Button>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.groups.members.table.name", "이름")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.groups.members.table.email", "이메일")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.groups.members.table.remove", "제거")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{currentGroup.members?.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="text-center py-4 text-muted-foreground"
|
||||
>
|
||||
{t("msg.admin.groups.members.empty", "멤버가 없습니다.")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{currentGroup.members?.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-medium">{user.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{user.email}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
removeMemberMutation.mutate({
|
||||
groupId: currentGroup.id,
|
||||
userId: user.id,
|
||||
})
|
||||
}
|
||||
disabled={removeMemberMutation.isPending}
|
||||
>
|
||||
<UserMinus size={14} className="text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-muted/90 backdrop-blur z-10 shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.groups.members.table.name", "이름")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.groups.members.table.email", "이메일")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.groups.members.table.remove", "제거")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{currentGroup.members?.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="text-center py-4 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.groups.members.empty",
|
||||
"멤버가 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{currentGroup.members?.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-medium">
|
||||
{user.name}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{user.email}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
removeMemberMutation.mutate({
|
||||
groupId: currentGroup.id,
|
||||
userId: user.id,
|
||||
})
|
||||
}
|
||||
disabled={removeMemberMutation.isPending}
|
||||
>
|
||||
<UserMinus size={14} className="text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -116,8 +116,8 @@ function TenantListPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
||||
<span>{t("ui.admin.tenants.breadcrumb.section", "Tenants")}</span>
|
||||
@@ -156,8 +156,8 @@ function TenantListPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Card className="bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<Card className="bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{t("ui.admin.tenants.registry.title", "Tenant Registry")}
|
||||
@@ -172,120 +172,132 @@ function TenantListPage() {
|
||||
{t("ui.common.badge.admin_only", "Admin only")}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
{(errorMsg || fallbackError) && (
|
||||
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{errorMsg ?? fallbackError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.name", "NAME")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.type", "TYPE")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.slug", "SLUG")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.members", "MEMBERS")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.updated", "UPDATED")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.tenants.table.actions", "ACTIONS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{query.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!query.isLoading && tenants.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={7}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.tenants.empty",
|
||||
"아직 등록된 테넌트가 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{tenants.map((tenant) => (
|
||||
<TableRow key={tenant.id}>
|
||||
<TableCell className="font-semibold">{tenant.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-[10px] font-mono">
|
||||
{t(
|
||||
`domain.tenant_type.${tenant.type?.toLowerCase()}`,
|
||||
tenant.type,
|
||||
)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{tenant.slug}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
tenant.status === "active"
|
||||
? "default"
|
||||
: tenant.status === "pending"
|
||||
? "secondary"
|
||||
: "muted"
|
||||
}
|
||||
>
|
||||
{t(`ui.common.status.${tenant.status}`, tenant.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{tenant.memberCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{tenant.updatedAt
|
||||
? new Date(tenant.updatedAt).toLocaleString("ko-KR")
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/tenants/${tenant.id}`)}
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-muted/90 backdrop-blur z-10 shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.name", "NAME")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.type", "TYPE")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.slug", "SLUG")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.members", "MEMBERS")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.updated", "UPDATED")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.tenants.table.actions", "ACTIONS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{query.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!query.isLoading && tenants.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={7}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
{t("ui.common.edit", "편집")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(tenant.id, tenant.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t("ui.common.delete", "삭제")}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{t(
|
||||
"msg.admin.tenants.empty",
|
||||
"아직 등록된 테넌트가 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{tenants.map((tenant) => (
|
||||
<TableRow key={tenant.id}>
|
||||
<TableCell className="font-semibold">
|
||||
{tenant.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] font-mono"
|
||||
>
|
||||
{t(
|
||||
`domain.tenant_type.${tenant.type?.toLowerCase()}`,
|
||||
tenant.type,
|
||||
)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{tenant.slug}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
tenant.status === "active"
|
||||
? "default"
|
||||
: tenant.status === "pending"
|
||||
? "secondary"
|
||||
: "muted"
|
||||
}
|
||||
>
|
||||
{t(
|
||||
`ui.common.status.${tenant.status}`,
|
||||
tenant.status,
|
||||
)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{tenant.memberCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{tenant.updatedAt
|
||||
? new Date(tenant.updatedAt).toLocaleString("ko-KR")
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/tenants/${tenant.id}`)}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
{t("ui.common.edit", "편집")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(tenant.id, tenant.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t("ui.common.delete", "삭제")}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -34,8 +34,8 @@ function TenantSubTenantsPage() {
|
||||
const subTenants = data?.items ?? [];
|
||||
|
||||
return (
|
||||
<Card className="mt-6 bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<Card className="mt-6 bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building2 size={18} className="text-primary" />
|
||||
@@ -57,64 +57,73 @@ function TenantSubTenantsPage() {
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.sub.table.name", "NAME")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.sub.table.slug", "SLUG")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.sub.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.tenants.sub.table.action", "ACTION")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{subTenants.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={4}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t("msg.admin.tenants.sub.empty", "하위 테넌트가 없습니다.")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{subTenants.map((tenant) => (
|
||||
<TableRow key={tenant.id}>
|
||||
<TableCell className="font-semibold">{tenant.name}</TableCell>
|
||||
<TableCell className="text-xs font-mono">
|
||||
{tenant.slug}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
tenant.status === "active" ? "default" : "secondary"
|
||||
}
|
||||
>
|
||||
{t(`ui.common.status.${tenant.status}`, tenant.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/tenants/${tenant.id}`)}
|
||||
>
|
||||
{t("ui.admin.tenants.sub.manage", "관리")}{" "}
|
||||
<ArrowRight size={12} className="ml-1" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-muted/90 backdrop-blur z-10 shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.sub.table.name", "NAME")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.sub.table.slug", "SLUG")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.sub.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.tenants.sub.table.action", "ACTION")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{subTenants.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={4}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.tenants.sub.empty",
|
||||
"하위 테넌트가 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{subTenants.map((tenant) => (
|
||||
<TableRow key={tenant.id}>
|
||||
<TableCell className="font-semibold">
|
||||
{tenant.name}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-mono">
|
||||
{tenant.slug}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
tenant.status === "active" ? "default" : "secondary"
|
||||
}
|
||||
>
|
||||
{t(`ui.common.status.${tenant.status}`, tenant.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate(`/tenants/${tenant.id}`)}
|
||||
>
|
||||
{t("ui.admin.tenants.sub.manage", "관리")}{" "}
|
||||
<ArrowRight size={12} className="ml-1" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -42,8 +42,8 @@ function TenantUsersPage() {
|
||||
const users = usersQuery.data?.items ?? [];
|
||||
|
||||
return (
|
||||
<Card className="mt-6 bg-[var(--color-panel)]">
|
||||
<CardHeader>
|
||||
<Card className="mt-6 bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<CardHeader className="flex-shrink-0">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User size={18} className="text-primary" />
|
||||
{t("ui.admin.tenants.members.title", "Tenant Members ({{count}})", {
|
||||
@@ -51,66 +51,70 @@ function TenantUsersPage() {
|
||||
})}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.members.table.name", "NAME")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.members.table.email", "EMAIL")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.members.table.role", "ROLE")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.members.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={4}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.tenants.members.empty",
|
||||
"소속된 사용자가 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-semibold">{user.name}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<Mail size={12} className="text-muted-foreground" />
|
||||
{user.email}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{t(
|
||||
`ui.common.role.${user.role}`,
|
||||
user.role.replace("_", " "),
|
||||
)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={user.status === "active" ? "default" : "muted"}
|
||||
>
|
||||
{t(`ui.common.status.${user.status}`, user.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-muted/90 backdrop-blur z-10 shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.members.table.name", "NAME")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.members.table.email", "EMAIL")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.members.table.role", "ROLE")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.members.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={4}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.tenants.members.empty",
|
||||
"소속된 사용자가 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-semibold">{user.name}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<Mail size={12} className="text-muted-foreground" />
|
||||
{user.email}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{t(
|
||||
`ui.common.role.${user.role}`,
|
||||
user.role.replace("_", " "),
|
||||
)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={user.status === "active" ? "default" : "muted"}
|
||||
>
|
||||
{t(`ui.common.status.${user.status}`, user.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -35,8 +35,8 @@ export default function GlobalUserGroupListPage() {
|
||||
return <div className="p-8">Loading tenants and groups...</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="flex items-start justify-between">
|
||||
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<header className="flex items-start justify-between flex-shrink-0">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-3xl font-bold tracking-tight">User Groups</h2>
|
||||
<p className="text-muted-foreground">
|
||||
@@ -46,7 +46,7 @@ export default function GlobalUserGroupListPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-6 flex-1 overflow-auto p-1">
|
||||
{tenantList?.items.map((tenant) => (
|
||||
<TenantGroupCard key={tenant.id} tenant={tenant} />
|
||||
))}
|
||||
@@ -62,8 +62,8 @@ function TenantGroupCard({ tenant }: { tenant: TenantSummary }) {
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<Card className="flex flex-col min-h-0 bg-[var(--color-panel)] overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 flex-shrink-0">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<Building2 size={20} className="text-muted-foreground" />
|
||||
@@ -83,62 +83,66 @@ function TenantGroupCard({ tenant }: { tenant: TenantSummary }) {
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[250px]">그룹명</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
<TableHead className="w-[100px]">멤버 수</TableHead>
|
||||
<TableHead className="text-right">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center">
|
||||
Loading...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : groups?.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={4}
|
||||
className="text-center text-muted-foreground py-4"
|
||||
>
|
||||
등록된 유저 그룹이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
groups?.map((group) => (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users size={14} className="text-primary" />
|
||||
<Link
|
||||
to={`/tenants/${tenant.id}/user-groups/${group.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{group.name}
|
||||
</Link>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{group.description || "-"}</TableCell>
|
||||
<TableCell>{group.members?.length || 0} 명</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link
|
||||
to={`/tenants/${tenant.id}/user-groups/${group.id}`}
|
||||
>
|
||||
상세보기
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar max-h-[400px]">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-muted/90 backdrop-blur z-10 shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead className="w-[250px]">그룹명</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
<TableHead className="w-[100px]">멤버 수</TableHead>
|
||||
<TableHead className="text-right">작업</TableHead>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center">
|
||||
Loading...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : groups?.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={4}
|
||||
className="text-center text-muted-foreground py-4"
|
||||
>
|
||||
등록된 유저 그룹이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
groups?.map((group) => (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users size={14} className="text-primary" />
|
||||
<Link
|
||||
to={`/tenants/${tenant.id}/user-groups/${group.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{group.name}
|
||||
</Link>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{group.description || "-"}</TableCell>
|
||||
<TableCell>{group.members?.length || 0} 명</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link
|
||||
to={`/tenants/${tenant.id}/user-groups/${group.id}`}
|
||||
>
|
||||
상세보기
|
||||
</Link>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -929,9 +929,9 @@ function TenantUserGroupsTab() {
|
||||
const BaseIcon = getTenantIcon(currentBase.type);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 mt-6">
|
||||
<Card className="bg-[var(--color-panel)] border-none shadow-sm overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between border-b bg-muted/5 py-4">
|
||||
<div className="space-y-6 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<Card className="flex-1 flex flex-col min-h-0 bg-[var(--color-panel)] border-none shadow-sm overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between border-b bg-muted/5 py-4 flex-shrink-0">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl font-bold flex items-center gap-2">
|
||||
<BaseIcon size={20} className="text-primary" />
|
||||
@@ -1078,7 +1078,7 @@ function TenantUserGroupsTab() {
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<div className="px-6 py-3 bg-muted/5 border-b flex items-center gap-4">
|
||||
<div className="px-6 py-3 bg-muted/5 border-b flex items-center gap-4 flex-shrink-0">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
@@ -1102,39 +1102,43 @@ function TenantUserGroupsTab() {
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/5">
|
||||
<TableRow>
|
||||
<TableHead className="pl-6 w-[40%]">
|
||||
{t("ui.admin.tenants.table.name", "NAME")}
|
||||
</TableHead>
|
||||
<TableHead className="hidden md:table-cell">
|
||||
{t("ui.admin.tenants.table.slug", "SLUG")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.members", "MEMBERS")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right pr-6">
|
||||
{t("ui.admin.tenants.table.actions", "ACTIONS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TenantTreeRow
|
||||
node={currentBase}
|
||||
level={0}
|
||||
isRoot={true}
|
||||
onRemove={handleRemove}
|
||||
onMove={handleMove}
|
||||
isUpdating={updateParentMutation.isPending}
|
||||
searchTerm={treeSearchTerm}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 p-0">
|
||||
<div className="flex-1 rounded-md border-0 overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-muted/90 backdrop-blur z-10 shadow-sm">
|
||||
<TableRow>
|
||||
<TableHead className="pl-6 w-[40%]">
|
||||
{t("ui.admin.tenants.table.name", "NAME")}
|
||||
</TableHead>
|
||||
<TableHead className="hidden md:table-cell">
|
||||
{t("ui.admin.tenants.table.slug", "SLUG")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.members", "MEMBERS")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right pr-6">
|
||||
{t("ui.admin.tenants.table.actions", "ACTIONS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TenantTreeRow
|
||||
node={currentBase}
|
||||
level={0}
|
||||
isRoot={true}
|
||||
onRemove={handleRemove}
|
||||
onMove={handleMove}
|
||||
isUpdating={updateParentMutation.isPending}
|
||||
searchTerm={treeSearchTerm}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -211,8 +211,8 @@ export function UserGroupDetailPage() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-8 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Link
|
||||
@@ -260,10 +260,10 @@ export function UserGroupDetailPage() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 flex-1 min-h-0">
|
||||
{/* Members Management */}
|
||||
<Card className="border-none shadow-sm bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<Card className="flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)] overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{t("ui.admin.groups.detail.members_title", "구성원 관리")}
|
||||
@@ -347,88 +347,90 @@ export function UserGroupDetailPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border border-border overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/30">
|
||||
<TableRow>
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.users.list.table.name_email", "사용자")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right font-bold">
|
||||
{t("ui.admin.groups.table.actions", "액션")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{!currentGroup.members ||
|
||||
currentGroup.members.length === 0 ? (
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-muted/90 backdrop-blur z-10 shadow-sm">
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={2}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.groups.members.empty",
|
||||
"구성원이 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.users.list.table.name_email", "사용자")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right font-bold">
|
||||
{t("ui.admin.groups.table.actions", "액션")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
) : (
|
||||
currentGroup.members.map((member) => (
|
||||
<TableRow
|
||||
key={member.id}
|
||||
className="hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
|
||||
{member.name.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
{member.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{member.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
t(
|
||||
"msg.admin.groups.members.remove_confirm",
|
||||
"제거하시겠습니까?",
|
||||
{ name: member.name },
|
||||
),
|
||||
)
|
||||
) {
|
||||
removeMemberMutation.mutate(member.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{!currentGroup.members ||
|
||||
currentGroup.members.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={2}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.groups.members.empty",
|
||||
"구성원이 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
currentGroup.members.map((member) => (
|
||||
<TableRow
|
||||
key={member.id}
|
||||
className="hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
|
||||
{member.name.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
{member.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{member.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
t(
|
||||
"msg.admin.groups.members.remove_confirm",
|
||||
"제거하시겠습니까?",
|
||||
{ name: member.name },
|
||||
),
|
||||
)
|
||||
) {
|
||||
removeMemberMutation.mutate(member.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Roles/Permissions Management (Keto Based) */}
|
||||
<Card className="border-none shadow-sm bg-[var(--color-panel)]">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<Card className="flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)] overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{t("ui.admin.groups.detail.permissions_title", "권한 관리")}
|
||||
@@ -530,86 +532,88 @@ export function UserGroupDetailPage() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border border-border overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/30">
|
||||
<TableRow>
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.users.detail.form.tenant", "대상 테넌트")}
|
||||
</TableHead>
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.users.detail.form.role", "역할")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right font-bold">
|
||||
{t("ui.admin.groups.table.actions", "액션")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isRolesLoading ? (
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-muted/90 backdrop-blur z-10 shadow-sm">
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-primary mx-auto" />
|
||||
</TableCell>
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.users.detail.form.tenant", "대상 테넌트")}
|
||||
</TableHead>
|
||||
<TableHead className="font-bold">
|
||||
{t("ui.admin.users.detail.form.role", "역할")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right font-bold">
|
||||
{t("ui.admin.groups.table.actions", "액션")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
) : !groupRoles || groupRoles.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.groups.roles.empty",
|
||||
"할당된 역할이 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
groupRoles.map((role, idx) => (
|
||||
<TableRow
|
||||
key={`${role.tenantId}-${role.relation}-${idx}`}
|
||||
className="hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<TableCell>
|
||||
<div className="font-medium text-sm">
|
||||
{role.tenantName || role.tenantId}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="capitalize font-normal"
|
||||
>
|
||||
{role.relation}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
t("msg.admin.groups.roles.remove_confirm"),
|
||||
)
|
||||
) {
|
||||
removeRoleMutation.mutate({
|
||||
targetTenantId: role.tenantId,
|
||||
relation: role.relation,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isRolesLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-primary mx-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : !groupRoles || groupRoles.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={3}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
"msg.admin.groups.roles.empty",
|
||||
"할당된 역할이 없습니다.",
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
groupRoles.map((role, idx) => (
|
||||
<TableRow
|
||||
key={`${role.tenantId}-${role.relation}-${idx}`}
|
||||
className="hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<TableCell>
|
||||
<div className="font-medium text-sm">
|
||||
{role.tenantName || role.tenantId}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="capitalize font-normal"
|
||||
>
|
||||
{role.relation}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
t("msg.admin.groups.roles.remove_confirm"),
|
||||
)
|
||||
) {
|
||||
removeRoleMutation.mutate({
|
||||
targetTenantId: role.tenantId,
|
||||
relation: role.relation,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
Reference in New Issue
Block a user