1
0
forked from baron/baron-sso

feat: apply sticky header and inner scroll pattern to all table pages

This commit is contained in:
2026-03-19 13:13:27 +09:00
parent 83991b13ca
commit f072d37362
9 changed files with 1007 additions and 944 deletions

View File

@@ -63,8 +63,8 @@ function ApiKeyListPage() {
}; };
return ( return (
<div className="space-y-8"> <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"> <header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]"> <div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span> <span>
@@ -103,8 +103,8 @@ function ApiKeyListPage() {
</div> </div>
</header> </header>
<Card className="bg-[var(--color-panel)]"> <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"> <CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div> <div>
<CardTitle> <CardTitle>
{t("ui.admin.api_keys.list.registry.title", "API Key Registry")} {t("ui.admin.api_keys.list.registry.title", "API Key Registry")}
@@ -119,95 +119,102 @@ function ApiKeyListPage() {
</div> </div>
<Badge variant="muted">{t("ui.common.badge.system", "System")}</Badge> <Badge variant="muted">{t("ui.common.badge.system", "System")}</Badge>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="flex-1 flex flex-col min-h-0 pt-0">
{(errorMsg || fallbackError) && ( {(errorMsg || fallbackError) && (
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive"> <div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMsg ?? fallbackError} {errorMsg ?? fallbackError}
</div> </div>
)} )}
<Table> <div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<TableHeader> <div className="flex-1 overflow-auto relative custom-scrollbar">
<TableRow> <Table>
<TableHead> <TableHeader className="sticky top-0 bg-muted/90 backdrop-blur z-10 shadow-sm">
{t("ui.admin.api_keys.list.table.name", "NAME")} <TableRow>
</TableHead> <TableHead>
<TableHead> {t("ui.admin.api_keys.list.table.name", "NAME")}
{t("ui.admin.api_keys.list.table.client_id", "CLIENT ID")} </TableHead>
</TableHead> <TableHead>
<TableHead> {t("ui.admin.api_keys.list.table.client_id", "CLIENT ID")}
{t("ui.admin.api_keys.list.table.scopes", "SCOPES")} </TableHead>
</TableHead> <TableHead>
<TableHead> {t("ui.admin.api_keys.list.table.scopes", "SCOPES")}
{t("ui.admin.api_keys.list.table.last_used", "LAST USED")} </TableHead>
</TableHead> <TableHead>
<TableHead className="text-right"> {t("ui.admin.api_keys.list.table.last_used", "LAST USED")}
{t("ui.admin.api_keys.list.table.actions", "ACTIONS")} </TableHead>
</TableHead> <TableHead className="text-right">
</TableRow> {t("ui.admin.api_keys.list.table.actions", "ACTIONS")}
</TableHeader> </TableHead>
<TableBody> </TableRow>
{query.isLoading && ( </TableHeader>
<TableRow> <TableBody>
<TableCell colSpan={5}> {query.isLoading && (
{t("msg.common.loading", "로딩 중...")} <TableRow>
</TableCell> <TableCell colSpan={5}>
</TableRow> {t("msg.common.loading", "로딩 중...")}
)} </TableCell>
{!query.isLoading && items.length === 0 && ( </TableRow>
<TableRow> )}
<TableCell colSpan={5}> {!query.isLoading && items.length === 0 && (
{t( <TableRow>
"msg.admin.api_keys.list.empty", <TableCell colSpan={5}>
"등록된 API 키가 없습니다.", {t(
)} "msg.admin.api_keys.list.empty",
</TableCell> "등록된 API 키가 없습니다.",
</TableRow> )}
)} </TableCell>
{items.map((key) => ( </TableRow>
<TableRow key={key.id}> )}
<TableCell className="font-semibold"> {items.map((key) => (
<div className="flex items-center gap-2"> <TableRow key={key.id}>
<Key size={14} className="text-[var(--color-muted)]" /> <TableCell className="font-semibold">
{key.name} <div className="flex items-center gap-2">
</div> <Key
</TableCell> size={14}
<TableCell> className="text-[var(--color-muted)]"
<code>{key.client_id}</code> />
</TableCell> {key.name}
<TableCell> </div>
<div className="flex flex-wrap gap-1"> </TableCell>
{key.scopes.map((scope) => ( <TableCell>
<Badge <code>{key.client_id}</code>
key={scope} </TableCell>
variant="muted" <TableCell>
className="text-[10px]" <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} <Trash2 size={14} />
</Badge> {t("ui.common.delete", "삭제")}
))} </Button>
</div> </TableCell>
</TableCell> </TableRow>
<TableCell> ))}
{key.lastUsedAt </TableBody>
? new Date(key.lastUsedAt).toLocaleString("ko-KR") </Table>
: t("ui.common.never", "Never")} </div>
</TableCell> </div>
<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>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -207,260 +207,266 @@ export function TenantAdminsAndOwnersTab() {
); );
return ( return (
<div className="space-y-8 mt-6"> <div className="space-y-8 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
{/* Owners Card */} <div className="flex-1 flex flex-col lg:flex-row gap-8 min-h-0">
<Card className="border-none shadow-sm bg-[var(--color-panel)]"> {/* Owners Card */}
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7"> <Card className="flex-1 flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)]">
<div className="space-y-1"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7 flex-shrink-0">
<CardTitle className="text-2xl font-bold flex items-center gap-2"> <div className="space-y-1">
<Crown className="h-6 w-6 text-yellow-500" /> <CardTitle className="text-2xl font-bold flex items-center gap-2">
{t("ui.admin.tenants.owners.title", "테넌트 소유자")} <Crown className="h-6 w-6 text-yellow-500" />
</CardTitle> {t("ui.admin.tenants.owners.title", "테넌트 소유자")}
<CardDescription className="text-muted-foreground"> </CardTitle>
{t( <CardDescription className="text-muted-foreground">
"msg.admin.tenants.owners.subtitle", {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>
))
)} )}
</TableBody> </CardDescription>
</Table> </div>
</div> <Button
</CardContent> className="bg-primary text-primary-foreground hover:bg-primary/90"
</Card> 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 */} {/* Admins Card */}
<Card className="border-none shadow-sm bg-[var(--color-panel)]"> <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"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7 flex-shrink-0">
<div className="space-y-1"> <div className="space-y-1">
<CardTitle className="text-2xl font-bold flex items-center gap-2"> <CardTitle className="text-2xl font-bold flex items-center gap-2">
<ShieldCheck className="h-6 w-6 text-primary" /> <ShieldCheck className="h-6 w-6 text-primary" />
{t("ui.admin.tenants.admins.title", "테넌트 관리자")} {t("ui.admin.tenants.admins.title", "테넌트 관리자")}
</CardTitle> </CardTitle>
<CardDescription className="text-muted-foreground"> <CardDescription className="text-muted-foreground">
{t( {t(
"msg.admin.tenants.admins.subtitle", "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>
))
)} )}
</TableBody> </CardDescription>
</Table> </div>
</div> <Button
</CardContent> className="bg-primary text-primary-foreground hover:bg-primary/90"
</Card> 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 */} {/* Common Dialog for adding users */}
<Dialog <Dialog

View File

@@ -343,11 +343,11 @@ function TenantGroupsPage() {
const currentGroup = groupsQuery.data?.find((g) => g.id === selectedGroupId); const currentGroup = groupsQuery.data?.find((g) => g.id === selectedGroupId);
return ( return (
<div className="space-y-6 mt-6"> <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"> <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"> <Card className="flex flex-col min-h-0 bg-[var(--color-panel)] md:col-span-1 border-primary/20">
<CardHeader> <CardHeader className="flex-shrink-0">
<CardTitle className="text-sm flex items-center gap-2"> <CardTitle className="text-sm flex items-center gap-2">
<Plus size={16} />{" "} <Plus size={16} />{" "}
{t("ui.admin.groups.create.title", "새 그룹 생성")} {t("ui.admin.groups.create.title", "새 그룹 생성")}
@@ -359,7 +359,7 @@ function TenantGroupsPage() {
)} )}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4 flex-1 overflow-auto">
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor="name"> <Label htmlFor="name">
{t("ui.admin.groups.form.name_label", "그룹 이름")} {t("ui.admin.groups.form.name_label", "그룹 이름")}
@@ -431,8 +431,8 @@ function TenantGroupsPage() {
</Card> </Card>
{/* 그룹 목록 (트리 뷰) */} {/* 그룹 목록 (트리 뷰) */}
<Card className="bg-[var(--color-panel)] md:col-span-2"> <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"> <CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div> <div>
<CardTitle> <CardTitle>
{t("ui.admin.groups.list.title", "User Groups")} {t("ui.admin.groups.list.title", "User Groups")}
@@ -458,76 +458,80 @@ function TenantGroupsPage() {
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<Table> <div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<TableHeader> <div className="flex-1 overflow-auto relative custom-scrollbar">
<TableRow> <Table>
<TableHead> <TableHeader className="sticky top-0 bg-muted/90 backdrop-blur z-10 shadow-sm">
{t("ui.admin.groups.table.name", "NAME")} <TableRow>
</TableHead> <TableHead>
<TableHead> {t("ui.admin.groups.table.name", "NAME")}
{t("ui.admin.groups.table.members", "MEMBERS")} </TableHead>
</TableHead> <TableHead>
<TableHead className="text-right"> {t("ui.admin.groups.table.members", "MEMBERS")}
{t("ui.admin.groups.table.actions", "ACTIONS")} </TableHead>
</TableHead> <TableHead className="text-right">
</TableRow> {t("ui.admin.groups.table.actions", "ACTIONS")}
</TableHeader> </TableHead>
<TableBody> </TableRow>
{groupsQuery.isLoading && ( </TableHeader>
<TableRow> <TableBody>
<TableCell colSpan={3}> {groupsQuery.isLoading && (
{t("msg.admin.groups.list.loading", "로딩 중...")} <TableRow>
</TableCell> <TableCell colSpan={3}>
</TableRow> {t("msg.admin.groups.list.loading", "로딩 중...")}
)} </TableCell>
{!groupsQuery.isLoading && groupTree.length === 0 && ( </TableRow>
<TableRow> )}
<TableCell {!groupsQuery.isLoading && groupTree.length === 0 && (
colSpan={3} <TableRow>
className="text-center py-8 text-muted-foreground" <TableCell
> colSpan={3}
{t( className="text-center py-8 text-muted-foreground"
"msg.admin.groups.list.empty", >
"아직 등록된 그룹이 없습니다.", {t(
)} "msg.admin.groups.list.empty",
</TableCell> "아직 등록된 그룹이 없습니다.",
</TableRow> )}
)} </TableCell>
{groupTree.map((node) => ( </TableRow>
<UserGroupTreeNode )}
key={node.id} {groupTree.map((node) => (
node={node} <UserGroupTreeNode
level={0} key={node.id}
onSelect={setSelectedGroupId} node={node}
selectedGroupId={selectedGroupId} level={0}
onDelete={(id) => { onSelect={setSelectedGroupId}
if ( selectedGroupId={selectedGroupId}
window.confirm( onDelete={(id) => {
t( if (
"msg.admin.groups.list.delete_confirm", window.confirm(
"그룹을 삭제하시겠습니까?", t(
), "msg.admin.groups.list.delete_confirm",
) "그룹을 삭제하시겠습니까?",
) { ),
deleteMutation.mutate(id); )
} ) {
}} deleteMutation.mutate(id);
onAddSubGroup={handleAddSubGroup} }
addMemberMutation={addMemberMutation} }}
removeMemberMutation={removeMemberMutation} onAddSubGroup={handleAddSubGroup}
/> addMemberMutation={addMemberMutation}
))} removeMemberMutation={removeMemberMutation}
</TableBody> />
</Table> ))}
</TableBody>
</Table>
</div>
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* 멤버 관리 섹션 (선택된 그룹이 있을 때) */} {/* 멤버 관리 섹션 (선택된 그룹이 있을 때) */}
{currentGroup && ( {currentGroup && (
<Card className="bg-[var(--color-panel)] border-t-4 border-t-primary"> <Card className="flex flex-col min-h-0 flex-1 bg-[var(--color-panel)] border-t-4 border-t-primary">
<CardHeader> <CardHeader className="flex-shrink-0">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Shield size={18} className="text-primary" /> <Shield size={18} className="text-primary" />
{t("msg.admin.groups.members.title", "[{{name}}] 멤버 관리", { {t("msg.admin.groups.members.title", "[{{name}}] 멤버 관리", {
@@ -541,8 +545,8 @@ function TenantGroupsPage() {
)} )}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className="flex justify-end mb-4"> <div className="flex justify-end mb-4 flex-shrink-0">
<Button <Button
size="sm" size="sm"
onClick={() => handleAddMember(currentGroup.id)} onClick={() => handleAddMember(currentGroup.id)}
@@ -552,56 +556,65 @@ function TenantGroupsPage() {
{t("ui.common.add", "멤버 추가")} {t("ui.common.add", "멤버 추가")}
</Button> </Button>
</div> </div>
<Table> <div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<TableHeader> <div className="flex-1 overflow-auto relative custom-scrollbar">
<TableRow> <Table>
<TableHead> <TableHeader className="sticky top-0 bg-muted/90 backdrop-blur z-10 shadow-sm">
{t("ui.admin.groups.members.table.name", "이름")} <TableRow>
</TableHead> <TableHead>
<TableHead> {t("ui.admin.groups.members.table.name", "이름")}
{t("ui.admin.groups.members.table.email", "이메일")} </TableHead>
</TableHead> <TableHead>
<TableHead className="text-right"> {t("ui.admin.groups.members.table.email", "이메일")}
{t("ui.admin.groups.members.table.remove", "제거")} </TableHead>
</TableHead> <TableHead className="text-right">
</TableRow> {t("ui.admin.groups.members.table.remove", "제거")}
</TableHeader> </TableHead>
<TableBody> </TableRow>
{currentGroup.members?.length === 0 && ( </TableHeader>
<TableRow> <TableBody>
<TableCell {currentGroup.members?.length === 0 && (
colSpan={3} <TableRow>
className="text-center py-4 text-muted-foreground" <TableCell
> colSpan={3}
{t("msg.admin.groups.members.empty", "멤버가 없습니다.")} className="text-center py-4 text-muted-foreground"
</TableCell> >
</TableRow> {t(
)} "msg.admin.groups.members.empty",
{currentGroup.members?.map((user) => ( "멤버가 없습니다.",
<TableRow key={user.id}> )}
<TableCell className="font-medium">{user.name}</TableCell> </TableCell>
<TableCell className="text-muted-foreground"> </TableRow>
{user.email} )}
</TableCell> {currentGroup.members?.map((user) => (
<TableCell className="text-right"> <TableRow key={user.id}>
<Button <TableCell className="font-medium">
variant="ghost" {user.name}
size="sm" </TableCell>
onClick={() => <TableCell className="text-muted-foreground">
removeMemberMutation.mutate({ {user.email}
groupId: currentGroup.id, </TableCell>
userId: user.id, <TableCell className="text-right">
}) <Button
} variant="ghost"
disabled={removeMemberMutation.isPending} size="sm"
> onClick={() =>
<UserMinus size={14} className="text-destructive" /> removeMemberMutation.mutate({
</Button> groupId: currentGroup.id,
</TableCell> userId: user.id,
</TableRow> })
))} }
</TableBody> disabled={removeMemberMutation.isPending}
</Table> >
<UserMinus size={14} className="text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</CardContent> </CardContent>
</Card> </Card>
)} )}

View File

@@ -116,8 +116,8 @@ function TenantListPage() {
}; };
return ( return (
<div className="space-y-8"> <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"> <header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]"> <div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>{t("ui.admin.tenants.breadcrumb.section", "Tenants")}</span> <span>{t("ui.admin.tenants.breadcrumb.section", "Tenants")}</span>
@@ -156,8 +156,8 @@ function TenantListPage() {
</div> </div>
</header> </header>
<Card className="bg-[var(--color-panel)]"> <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"> <CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div> <div>
<CardTitle> <CardTitle>
{t("ui.admin.tenants.registry.title", "Tenant Registry")} {t("ui.admin.tenants.registry.title", "Tenant Registry")}
@@ -172,120 +172,132 @@ function TenantListPage() {
{t("ui.common.badge.admin_only", "Admin only")} {t("ui.common.badge.admin_only", "Admin only")}
</Badge> </Badge>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="flex-1 flex flex-col min-h-0 pt-0">
{(errorMsg || fallbackError) && ( {(errorMsg || fallbackError) && (
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive"> <div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMsg ?? fallbackError} {errorMsg ?? fallbackError}
</div> </div>
)} )}
<Table> <div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<TableHeader> <div className="flex-1 overflow-auto relative custom-scrollbar">
<TableRow> <Table>
<TableHead> <TableHeader className="sticky top-0 bg-muted/90 backdrop-blur z-10 shadow-sm">
{t("ui.admin.tenants.table.name", "NAME")} <TableRow>
</TableHead> <TableHead>
<TableHead> {t("ui.admin.tenants.table.name", "NAME")}
{t("ui.admin.tenants.table.type", "TYPE")} </TableHead>
</TableHead> <TableHead>
<TableHead> {t("ui.admin.tenants.table.type", "TYPE")}
{t("ui.admin.tenants.table.slug", "SLUG")} </TableHead>
</TableHead> <TableHead>
<TableHead> {t("ui.admin.tenants.table.slug", "SLUG")}
{t("ui.admin.tenants.table.status", "STATUS")} </TableHead>
</TableHead> <TableHead>
<TableHead> {t("ui.admin.tenants.table.status", "STATUS")}
{t("ui.admin.tenants.table.members", "MEMBERS")} </TableHead>
</TableHead> <TableHead>
<TableHead> {t("ui.admin.tenants.table.members", "MEMBERS")}
{t("ui.admin.tenants.table.updated", "UPDATED")} </TableHead>
</TableHead> <TableHead>
<TableHead className="text-right"> {t("ui.admin.tenants.table.updated", "UPDATED")}
{t("ui.admin.tenants.table.actions", "ACTIONS")} </TableHead>
</TableHead> <TableHead className="text-right">
</TableRow> {t("ui.admin.tenants.table.actions", "ACTIONS")}
</TableHeader> </TableHead>
<TableBody> </TableRow>
{query.isLoading && ( </TableHeader>
<TableRow> <TableBody>
<TableCell colSpan={7}> {query.isLoading && (
{t("msg.common.loading", "로딩 중...")} <TableRow>
</TableCell> <TableCell colSpan={7}>
</TableRow> {t("msg.common.loading", "로딩 중...")}
)} </TableCell>
{!query.isLoading && tenants.length === 0 && ( </TableRow>
<TableRow> )}
<TableCell {!query.isLoading && tenants.length === 0 && (
colSpan={7} <TableRow>
className="text-center py-8 text-muted-foreground" <TableCell
> colSpan={7}
{t( className="text-center py-8 text-muted-foreground"
"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(
{t("ui.common.edit", "편집")} "msg.admin.tenants.empty",
</Button> "아직 등록된 테넌트가 없습니다.",
<Button )}
variant="outline" </TableCell>
size="sm" </TableRow>
onClick={() => handleDelete(tenant.id, tenant.name)} )}
disabled={deleteMutation.isPending} {tenants.map((tenant) => (
> <TableRow key={tenant.id}>
<Trash2 size={14} /> <TableCell className="font-semibold">
{t("ui.common.delete", "삭제")} {tenant.name}
</Button> </TableCell>
</div> <TableCell>
</TableCell> <Badge
</TableRow> variant="outline"
))} className="text-[10px] font-mono"
</TableBody> >
</Table> {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> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -34,8 +34,8 @@ function TenantSubTenantsPage() {
const subTenants = data?.items ?? []; const subTenants = data?.items ?? [];
return ( return (
<Card className="mt-6 bg-[var(--color-panel)]"> <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"> <CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div> <div>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Building2 size={18} className="text-primary" /> <Building2 size={18} className="text-primary" />
@@ -57,64 +57,73 @@ function TenantSubTenantsPage() {
</Link> </Link>
</Button> </Button>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<Table> <div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<TableHeader> <div className="flex-1 overflow-auto relative custom-scrollbar">
<TableRow> <Table>
<TableHead> <TableHeader className="sticky top-0 bg-muted/90 backdrop-blur z-10 shadow-sm">
{t("ui.admin.tenants.sub.table.name", "NAME")} <TableRow>
</TableHead> <TableHead>
<TableHead> {t("ui.admin.tenants.sub.table.name", "NAME")}
{t("ui.admin.tenants.sub.table.slug", "SLUG")} </TableHead>
</TableHead> <TableHead>
<TableHead> {t("ui.admin.tenants.sub.table.slug", "SLUG")}
{t("ui.admin.tenants.sub.table.status", "STATUS")} </TableHead>
</TableHead> <TableHead>
<TableHead className="text-right"> {t("ui.admin.tenants.sub.table.status", "STATUS")}
{t("ui.admin.tenants.sub.table.action", "ACTION")} </TableHead>
</TableHead> <TableHead className="text-right">
</TableRow> {t("ui.admin.tenants.sub.table.action", "ACTION")}
</TableHeader> </TableHead>
<TableBody> </TableRow>
{subTenants.length === 0 && ( </TableHeader>
<TableRow> <TableBody>
<TableCell {subTenants.length === 0 && (
colSpan={4} <TableRow>
className="text-center py-8 text-muted-foreground" <TableCell
> colSpan={4}
{t("msg.admin.tenants.sub.empty", "하위 테넌트가 없습니다.")} className="text-center py-8 text-muted-foreground"
</TableCell> >
</TableRow> {t(
)} "msg.admin.tenants.sub.empty",
{subTenants.map((tenant) => ( "하위 테넌트가 없습니다.",
<TableRow key={tenant.id}> )}
<TableCell className="font-semibold">{tenant.name}</TableCell> </TableCell>
<TableCell className="text-xs font-mono"> </TableRow>
{tenant.slug} )}
</TableCell> {subTenants.map((tenant) => (
<TableCell> <TableRow key={tenant.id}>
<Badge <TableCell className="font-semibold">
variant={ {tenant.name}
tenant.status === "active" ? "default" : "secondary" </TableCell>
} <TableCell className="text-xs font-mono">
> {tenant.slug}
{t(`ui.common.status.${tenant.status}`, tenant.status)} </TableCell>
</Badge> <TableCell>
</TableCell> <Badge
<TableCell className="text-right"> variant={
<Button tenant.status === "active" ? "default" : "secondary"
variant="ghost" }
size="sm" >
onClick={() => navigate(`/tenants/${tenant.id}`)} {t(`ui.common.status.${tenant.status}`, tenant.status)}
> </Badge>
{t("ui.admin.tenants.sub.manage", "관리")}{" "} </TableCell>
<ArrowRight size={12} className="ml-1" /> <TableCell className="text-right">
</Button> <Button
</TableCell> variant="ghost"
</TableRow> size="sm"
))} onClick={() => navigate(`/tenants/${tenant.id}`)}
</TableBody> >
</Table> {t("ui.admin.tenants.sub.manage", "관리")}{" "}
<ArrowRight size={12} className="ml-1" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@@ -42,8 +42,8 @@ function TenantUsersPage() {
const users = usersQuery.data?.items ?? []; const users = usersQuery.data?.items ?? [];
return ( return (
<Card className="mt-6 bg-[var(--color-panel)]"> <Card className="mt-6 bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
<CardHeader> <CardHeader className="flex-shrink-0">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<User size={18} className="text-primary" /> <User size={18} className="text-primary" />
{t("ui.admin.tenants.members.title", "Tenant Members ({{count}})", { {t("ui.admin.tenants.members.title", "Tenant Members ({{count}})", {
@@ -51,66 +51,70 @@ function TenantUsersPage() {
})} })}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<Table> <div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<TableHeader> <div className="flex-1 overflow-auto relative custom-scrollbar">
<TableRow> <Table>
<TableHead> <TableHeader className="sticky top-0 bg-muted/90 backdrop-blur z-10 shadow-sm">
{t("ui.admin.tenants.members.table.name", "NAME")} <TableRow>
</TableHead> <TableHead>
<TableHead> {t("ui.admin.tenants.members.table.name", "NAME")}
{t("ui.admin.tenants.members.table.email", "EMAIL")} </TableHead>
</TableHead> <TableHead>
<TableHead> {t("ui.admin.tenants.members.table.email", "EMAIL")}
{t("ui.admin.tenants.members.table.role", "ROLE")} </TableHead>
</TableHead> <TableHead>
<TableHead> {t("ui.admin.tenants.members.table.role", "ROLE")}
{t("ui.admin.tenants.members.table.status", "STATUS")} </TableHead>
</TableHead> <TableHead>
</TableRow> {t("ui.admin.tenants.members.table.status", "STATUS")}
</TableHeader> </TableHead>
<TableBody> </TableRow>
{users.length === 0 && ( </TableHeader>
<TableRow> <TableBody>
<TableCell {users.length === 0 && (
colSpan={4} <TableRow>
className="text-center py-8 text-muted-foreground" <TableCell
> colSpan={4}
{t( className="text-center py-8 text-muted-foreground"
"msg.admin.tenants.members.empty", >
"소속된 사용자가 없습니다.", {t(
)} "msg.admin.tenants.members.empty",
</TableCell> "소속된 사용자가 없습니다.",
</TableRow> )}
)} </TableCell>
{users.map((user) => ( </TableRow>
<TableRow key={user.id}> )}
<TableCell className="font-semibold">{user.name}</TableCell> {users.map((user) => (
<TableCell> <TableRow key={user.id}>
<div className="flex items-center gap-1 text-xs"> <TableCell className="font-semibold">{user.name}</TableCell>
<Mail size={12} className="text-muted-foreground" /> <TableCell>
{user.email} <div className="flex items-center gap-1 text-xs">
</div> <Mail size={12} className="text-muted-foreground" />
</TableCell> {user.email}
<TableCell> </div>
<Badge variant="outline" className="capitalize"> </TableCell>
{t( <TableCell>
`ui.common.role.${user.role}`, <Badge variant="outline" className="capitalize">
user.role.replace("_", " "), {t(
)} `ui.common.role.${user.role}`,
</Badge> user.role.replace("_", " "),
</TableCell> )}
<TableCell> </Badge>
<Badge </TableCell>
variant={user.status === "active" ? "default" : "muted"} <TableCell>
> <Badge
{t(`ui.common.status.${user.status}`, user.status)} variant={user.status === "active" ? "default" : "muted"}
</Badge> >
</TableCell> {t(`ui.common.status.${user.status}`, user.status)}
</TableRow> </Badge>
))} </TableCell>
</TableBody> </TableRow>
</Table> ))}
</TableBody>
</Table>
</div>
</div>
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@@ -35,8 +35,8 @@ export default function GlobalUserGroupListPage() {
return <div className="p-8">Loading tenants and groups...</div>; return <div className="p-8">Loading tenants and groups...</div>;
return ( return (
<div className="space-y-8"> <div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<header className="flex items-start justify-between"> <header className="flex items-start justify-between flex-shrink-0">
<div className="space-y-1"> <div className="space-y-1">
<h2 className="text-3xl font-bold tracking-tight">User Groups</h2> <h2 className="text-3xl font-bold tracking-tight">User Groups</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
@@ -46,7 +46,7 @@ export default function GlobalUserGroupListPage() {
</div> </div>
</header> </header>
<div className="grid gap-6"> <div className="grid gap-6 flex-1 overflow-auto p-1">
{tenantList?.items.map((tenant) => ( {tenantList?.items.map((tenant) => (
<TenantGroupCard key={tenant.id} tenant={tenant} /> <TenantGroupCard key={tenant.id} tenant={tenant} />
))} ))}
@@ -62,8 +62,8 @@ function TenantGroupCard({ tenant }: { tenant: TenantSummary }) {
}); });
return ( return (
<Card> <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"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 flex-shrink-0">
<div className="space-y-1"> <div className="space-y-1">
<CardTitle className="text-xl flex items-center gap-2"> <CardTitle className="text-xl flex items-center gap-2">
<Building2 size={20} className="text-muted-foreground" /> <Building2 size={20} className="text-muted-foreground" />
@@ -83,62 +83,66 @@ function TenantGroupCard({ tenant }: { tenant: TenantSummary }) {
</Link> </Link>
</Button> </Button>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<Table> <div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<TableHeader> <div className="flex-1 overflow-auto relative custom-scrollbar max-h-[400px]">
<TableRow> <Table>
<TableHead className="w-[250px]"></TableHead> <TableHeader className="sticky top-0 bg-muted/90 backdrop-blur z-10 shadow-sm">
<TableHead></TableHead> <TableRow>
<TableHead className="w-[100px]"> </TableHead> <TableHead className="w-[250px]"></TableHead>
<TableHead className="text-right"></TableHead> <TableHead></TableHead>
</TableRow> <TableHead className="w-[100px]"> </TableHead>
</TableHeader> <TableHead className="text-right"></TableHead>
<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> </TableRow>
)) </TableHeader>
)} <TableBody>
</TableBody> {isLoading ? (
</Table> <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> </CardContent>
</Card> </Card>
); );

View File

@@ -929,9 +929,9 @@ function TenantUserGroupsTab() {
const BaseIcon = getTenantIcon(currentBase.type); const BaseIcon = getTenantIcon(currentBase.type);
return ( return (
<div className="space-y-6 mt-6"> <div className="space-y-6 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<Card className="bg-[var(--color-panel)] border-none shadow-sm overflow-hidden"> <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"> <CardHeader className="flex flex-row items-center justify-between border-b bg-muted/5 py-4 flex-shrink-0">
<div className="space-y-1"> <div className="space-y-1">
<CardTitle className="text-xl font-bold flex items-center gap-2"> <CardTitle className="text-xl font-bold flex items-center gap-2">
<BaseIcon size={20} className="text-primary" /> <BaseIcon size={20} className="text-primary" />
@@ -1078,7 +1078,7 @@ function TenantUserGroupsTab() {
</Dialog> </Dialog>
</div> </div>
</CardHeader> </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"> <div className="relative flex-1 max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input <Input
@@ -1102,39 +1102,43 @@ function TenantUserGroupsTab() {
</Button> </Button>
)} )}
</div> </div>
<CardContent className="p-0"> <CardContent className="flex-1 flex flex-col min-h-0 p-0">
<Table> <div className="flex-1 rounded-md border-0 overflow-hidden flex flex-col">
<TableHeader className="bg-muted/5"> <div className="flex-1 overflow-auto relative custom-scrollbar">
<TableRow> <Table>
<TableHead className="pl-6 w-[40%]"> <TableHeader className="sticky top-0 bg-muted/90 backdrop-blur z-10 shadow-sm">
{t("ui.admin.tenants.table.name", "NAME")} <TableRow>
</TableHead> <TableHead className="pl-6 w-[40%]">
<TableHead className="hidden md:table-cell"> {t("ui.admin.tenants.table.name", "NAME")}
{t("ui.admin.tenants.table.slug", "SLUG")} </TableHead>
</TableHead> <TableHead className="hidden md:table-cell">
<TableHead> {t("ui.admin.tenants.table.slug", "SLUG")}
{t("ui.admin.tenants.table.members", "MEMBERS")} </TableHead>
</TableHead> <TableHead>
<TableHead> {t("ui.admin.tenants.table.members", "MEMBERS")}
{t("ui.admin.tenants.table.status", "STATUS")} </TableHead>
</TableHead> <TableHead>
<TableHead className="text-right pr-6"> {t("ui.admin.tenants.table.status", "STATUS")}
{t("ui.admin.tenants.table.actions", "ACTIONS")} </TableHead>
</TableHead> <TableHead className="text-right pr-6">
</TableRow> {t("ui.admin.tenants.table.actions", "ACTIONS")}
</TableHeader> </TableHead>
<TableBody> </TableRow>
<TenantTreeRow </TableHeader>
node={currentBase} <TableBody>
level={0} <TenantTreeRow
isRoot={true} node={currentBase}
onRemove={handleRemove} level={0}
onMove={handleMove} isRoot={true}
isUpdating={updateParentMutation.isPending} onRemove={handleRemove}
searchTerm={treeSearchTerm} onMove={handleMove}
/> isUpdating={updateParentMutation.isPending}
</TableBody> searchTerm={treeSearchTerm}
</Table> />
</TableBody>
</Table>
</div>
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -211,8 +211,8 @@ export function UserGroupDetailPage() {
); );
return ( return (
<div className="space-y-8"> <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"> <header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link <Link
@@ -260,10 +260,10 @@ export function UserGroupDetailPage() {
</div> </div>
</header> </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 */} {/* Members Management */}
<Card className="border-none shadow-sm bg-[var(--color-panel)]"> <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"> <CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div> <div>
<CardTitle> <CardTitle>
{t("ui.admin.groups.detail.members_title", "구성원 관리")} {t("ui.admin.groups.detail.members_title", "구성원 관리")}
@@ -347,88 +347,90 @@ export function UserGroupDetailPage() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className="rounded-md border border-border overflow-hidden"> <div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<Table> <div className="flex-1 overflow-auto relative custom-scrollbar">
<TableHeader className="bg-muted/30"> <Table>
<TableRow> <TableHeader className="sticky top-0 bg-muted/90 backdrop-blur z-10 shadow-sm">
<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 ? (
<TableRow> <TableRow>
<TableCell <TableHead className="font-bold">
colSpan={2} {t("ui.admin.users.list.table.name_email", "사용자")}
className="text-center py-8 text-muted-foreground" </TableHead>
> <TableHead className="text-right font-bold">
{t( {t("ui.admin.groups.table.actions", "액션")}
"msg.admin.groups.members.empty", </TableHead>
"구성원이 없습니다.",
)}
</TableCell>
</TableRow> </TableRow>
) : ( </TableHeader>
currentGroup.members.map((member) => ( <TableBody>
<TableRow {!currentGroup.members ||
key={member.id} currentGroup.members.length === 0 ? (
className="hover:bg-muted/30 transition-colors" <TableRow>
> <TableCell
<TableCell> colSpan={2}
<div className="flex items-center gap-3"> className="text-center py-8 text-muted-foreground"
<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)} {t(
</div> "msg.admin.groups.members.empty",
<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> </TableCell>
</TableRow> </TableRow>
)) ) : (
)} currentGroup.members.map((member) => (
</TableBody> <TableRow
</Table> 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> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* Roles/Permissions Management (Keto Based) */} {/* Roles/Permissions Management (Keto Based) */}
<Card className="border-none shadow-sm bg-[var(--color-panel)]"> <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"> <CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div> <div>
<CardTitle> <CardTitle>
{t("ui.admin.groups.detail.permissions_title", "권한 관리")} {t("ui.admin.groups.detail.permissions_title", "권한 관리")}
@@ -530,86 +532,88 @@ export function UserGroupDetailPage() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className="rounded-md border border-border overflow-hidden"> <div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<Table> <div className="flex-1 overflow-auto relative custom-scrollbar">
<TableHeader className="bg-muted/30"> <Table>
<TableRow> <TableHeader className="sticky top-0 bg-muted/90 backdrop-blur z-10 shadow-sm">
<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 ? (
<TableRow> <TableRow>
<TableCell colSpan={3} className="text-center py-8"> <TableHead className="font-bold">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-primary mx-auto" /> {t("ui.admin.users.detail.form.tenant", "대상 테넌트")}
</TableCell> </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> </TableRow>
) : !groupRoles || groupRoles.length === 0 ? ( </TableHeader>
<TableRow> <TableBody>
<TableCell {isRolesLoading ? (
colSpan={3} <TableRow>
className="text-center py-8 text-muted-foreground" <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" />
{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> </TableCell>
</TableRow> </TableRow>
)) ) : !groupRoles || groupRoles.length === 0 ? (
)} <TableRow>
</TableBody> <TableCell
</Table> 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> </div>
</CardContent> </CardContent>
</Card> </Card>