1
0
forked from baron/baron-sso

페이지 헤더 공통 컴포넌트 통일

This commit is contained in:
2026-05-14 14:03:09 +09:00
parent 0a5ae51a68
commit 3a0cd1cfed
3 changed files with 195 additions and 224 deletions

View File

@@ -38,6 +38,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "../../components/ui/table"; } from "../../components/ui/table";
import { PageHeader } from "../../../../common/core/components/page";
import { commonStickyTableHeaderClass } from "../../../../common/ui/table"; import { commonStickyTableHeaderClass } from "../../../../common/ui/table";
import { import {
type ApiKeySummary, type ApiKeySummary,
@@ -160,35 +161,33 @@ function ApiKeyListPage() {
return ( return (
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]"> <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 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4"> <PageHeader
<div className="space-y-2"> sticky
<h2 className="text-3xl font-semibold"> titleAs="h2"
{t("ui.admin.api_keys.list.title", "API 키 관리 (M2M)")} title={t("ui.admin.api_keys.list.title", "API 키 관리 (M2M)")}
</h2> description={t(
<p className="text-sm text-[var(--color-muted)]"> "msg.admin.api_keys.list.subtitle",
{t( "서버 간 통신(Machine-to-Machine)을 위한 API 키를 발급하고 관리합니다.",
"msg.admin.api_keys.list.subtitle", )}
"서버 간 통신(Machine-to-Machine)을 위한 API 키를 발급하고 관리합니다.", actions={
)} <>
</p> <Button
</div> variant="outline"
<div className="flex items-center gap-2"> onClick={() => query.refetch()}
<Button disabled={query.isFetching}
variant="outline" >
onClick={() => query.refetch()} <RefreshCw size={16} />
disabled={query.isFetching} {t("ui.common.refresh", "새로고침")}
> </Button>
<RefreshCw size={16} /> <Button asChild>
{t("ui.common.refresh", "새로고침")} <Link to="/api-keys/new">
</Button> <Plus size={16} />
<Button asChild> {t("ui.admin.api_keys.list.add", "API 키 생성")}
<Link to="/api-keys/new"> </Link>
<Plus size={16} /> </Button>
{t("ui.admin.api_keys.list.add", "API 키 생성")} </>
</Link> }
</Button> />
</div>
</header>
<Card className="bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden"> <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"> <CardHeader className="flex flex-row items-center justify-between flex-shrink-0">

View File

@@ -32,6 +32,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "../../components/ui/table"; } from "../../components/ui/table";
import { PageHeader } from "../../../../common/core/components/page";
import type { AuditLog } from "../../lib/adminApi"; import type { AuditLog } from "../../lib/adminApi";
import { fetchAuditLogs } from "../../lib/adminApi"; import { fetchAuditLogs } from "../../lib/adminApi";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
@@ -164,33 +165,31 @@ function AuditLogsPage() {
return ( return (
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]"> <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 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4"> <PageHeader
<div> sticky
<h2 className="text-3xl font-semibold"> titleAs="h2"
{t("ui.admin.audit.title", "감사 로그")} title={t("ui.admin.audit.title", "감사 로그")}
</h2> description={t(
<p className="text-sm text-[var(--color-muted)]"> "msg.admin.audit.subtitle",
{t( "Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는 추후 세션 연동 시 자동 채워집니다.",
"msg.admin.audit.subtitle", )}
"Command 요청 기반 ClickHouse 로그를 조회합니다. 사용자/테넌트는 추후 세션 연동 시 자동 채워집니다.", actions={
)} <>
</p> <Button
</div> variant="outline"
<div className="flex items-center gap-2"> onClick={() => refetch()}
<Button disabled={isFetching}
variant="outline" >
onClick={() => refetch()} <RefreshCw size={16} />
disabled={isFetching} {t("ui.common.refresh", "새로고침")}
> </Button>
<RefreshCw size={16} /> <Button>
{t("ui.common.refresh", "새로고침")} <ListChecks size={16} />
</Button> {t("ui.admin.audit.export_csv", "Export CSV")}
<Button> </Button>
<ListChecks size={16} /> </>
{t("ui.admin.audit.export_csv", "Export CSV")} }
</Button> />
</div>
</header>
<Card className="glass-panel flex-1 flex flex-col min-h-0 overflow-hidden"> <Card className="glass-panel flex-1 flex flex-col min-h-0 overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0"> <CardHeader className="flex flex-row items-center justify-between flex-shrink-0">

View File

@@ -29,6 +29,7 @@ import {
sortItems, sortItems,
toggleSort, toggleSort,
} from "../../../../common/core/utils"; } from "../../../../common/core/utils";
import { PageHeader } from "../../../../common/core/components/page";
import { import {
commonTableShellClass, commonTableShellClass,
commonTableViewportClass, commonTableViewportClass,
@@ -411,184 +412,156 @@ function UserListPage() {
return ( return (
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]"> <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 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4"> <PageHeader
<div className="space-y-2"> sticky
<h2 className="text-3xl font-semibold" data-testid="page-title"> titleAs="h2"
title={
<span data-testid="page-title">
{t("ui.admin.users.list.title", "사용자 관리")} {t("ui.admin.users.list.title", "사용자 관리")}
</h2> </span>
<p className="text-sm text-[var(--color-muted)]"> }
{t( description={t(
"msg.admin.users.list.subtitle", "msg.admin.users.list.subtitle",
"시스템 사용자를 조회하고 관리합니다.", "시스템 사용자를 조회하고 관리합니다.",
)} )}
</p> actions={
</div> <>
<div className="flex items-center gap-2"> <div className="mr-2 flex items-center gap-2">
<div className="flex items-center gap-2 mr-2"> <div className="relative w-48">
<div className="relative w-48"> <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 placeholder={t(
placeholder={t( "ui.admin.users.list.search_placeholder",
"ui.admin.users.list.search_placeholder", "이름 또는 이메일 검색...",
"이름 또는 이메일 검색...", )}
)} className="h-9 pl-9"
className="pl-9 h-9" value={searchDraft}
value={searchDraft} onChange={(e) => setSearchDraft(e.target.value)}
onChange={(e) => setSearchDraft(e.target.value)} onKeyDown={handleKeyDown}
onKeyDown={handleKeyDown} />
/> </div>
<select
className="flex h-9 w-[160px] rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
value={selectedCompany}
onChange={(e) => {
setSelectedCompany(e.target.value);
setPage(1);
}}
disabled={profile?.role === "tenant_admin"}
>
<option value="">{t("ui.common.all", "전체 테넌트")}</option>
{tenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name}
</option>
))}
</select>
<Button
variant="secondary"
size="sm"
onClick={handleSearch}
className="h-9"
>
{t("ui.common.search", "검색")}
</Button>
</div> </div>
<select
className="flex h-9 w-[160px] rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
value={selectedCompany}
onChange={(e) => {
setSelectedCompany(e.target.value);
setPage(1);
}}
disabled={profile?.role === "tenant_admin"}
>
<option value="">{t("ui.common.all", "전체 테넌트")}</option>
{tenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name}
</option>
))}
</select>
<Button <Button
variant="secondary" variant="outline"
size="sm" size="sm"
onClick={handleSearch}
className="h-9" className="h-9"
onClick={() => query.refetch()}
disabled={query.isFetching}
> >
{t("ui.common.search", "검색")} <RefreshCw size={16} />
</Button>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
data-testid="user-data-mgmt-btn"
className="gap-2 h-9"
>
<LayoutDashboard size={16} />
{t("ui.admin.users.data_mgmt", "데이터 관리")}
<ChevronDown size={14} className="opacity-50" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem
onClick={() => handleExport(false)}
disabled={exportMutation.isPending}
data-testid="user-export-menu-item"
className="cursor-pointer"
>
<FileDown size={16} className="mr-2 opacity-50" />
{t("ui.common.export_without_ids", "UUID 제외 내보내기")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleExport(true)}
disabled={exportMutation.isPending}
data-testid="user-export-with-ids-menu-item"
className="cursor-pointer"
>
<FileDown size={16} className="mr-2 opacity-50" />
{t("ui.common.export_with_ids", "UUID 포함 내보내기")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="px-2 py-1.5 focus:bg-transparent cursor-default"
onSelect={(e) => e.preventDefault()}
>
<UserBulkUploadModal
onSuccess={() => query.refetch()}
variant="dropdown"
/>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant="outline"
size="sm"
className="w-9 px-0 h-9"
onClick={() => query.refetch()}
disabled={query.isFetching}
title={t("ui.common.refresh", "새로고침")}
>
<RefreshCw size={16} />
<span className="sr-only">
{t("ui.common.refresh", "새로고침")} {t("ui.common.refresh", "새로고침")}
</span> </Button>
</Button> <Button
variant="outline"
<Dialog> onClick={() => handleExport(false)}
<DialogTrigger asChild> className="gap-2"
<Button variant="outline" size="icon" className="h-9 w-9"> disabled={exportMutation.isPending}
<Settings2 size={16} /> >
</Button> <FileDown size={16} />
</DialogTrigger> {t("ui.common.export_without_ids", "UUID 제외 내보내기")}
<DialogContent> </Button>
<DialogHeader> <Button
<DialogTitle> variant="outline"
{t("ui.admin.users.list.columns.title", "표시 컬럼 설정")} onClick={() => handleExport(true)}
</DialogTitle> className="gap-2"
<DialogDescription> disabled={exportMutation.isPending}
{t( >
"msg.admin.users.list.columns.description", <FileDown size={16} />
"사용자 목록에 표시할 커스텀 필드를 선택하세요.", {t("ui.common.export_with_ids", "UUID 포함")}
)} </Button>
</DialogDescription> <UserBulkUploadModal onSuccess={() => query.refetch()} />
</DialogHeader> <Dialog>
<div className="grid gap-4 py-4"> <DialogTrigger asChild>
{userSchema.length === 0 && ( <Button variant="outline" size="icon" className="h-9 w-9">
<p className="text-sm text-muted-foreground text-center py-4"> <Settings2 size={16} />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("ui.admin.users.list.columns.title", "표시 컬럼 설정")}
</DialogTitle>
<DialogDescription>
{t( {t(
"msg.admin.users.list.columns.no_custom", "msg.admin.users.list.columns.description",
"현재 테넌트에 정의된 커스텀 필드가 없습니다.", "사용자 목록에 표시할 커스텀 필드를 선택하세요.",
)} )}
</p> </DialogDescription>
)} </DialogHeader>
{userSchema.map((field) => ( <div className="grid gap-4 py-4">
<label {userSchema.length === 0 && (
key={field.key} <p className="py-4 text-center text-sm text-muted-foreground">
className="flex items-center gap-3 p-2 rounded-lg hover:bg-muted/50 cursor-pointer" {t(
> "msg.admin.users.list.columns.no_custom",
<input "현재 테넌트에 정의된 커스텀 필드가 없습니다.",
type="checkbox" )}
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary" </p>
checked={visibleColumns[field.key] !== false} )}
onChange={() => toggleColumn(field.key)} {userSchema.map((field) => (
/> <label
<div className="flex flex-col"> key={field.key}
<span className="text-sm font-medium">{field.label}</span> className="flex cursor-pointer items-center gap-3 rounded-lg p-2 hover:bg-muted/50"
<span className="text-xs text-muted-foreground font-mono"> >
{field.key} <input
</span> type="checkbox"
</div> className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
</label> checked={visibleColumns[field.key] !== false}
))} onChange={() => toggleColumn(field.key)}
</div> />
<DialogFooter> <div className="flex flex-col">
<DialogTrigger asChild> <span className="text-sm font-medium">{field.label}</span>
<Button variant="secondary"> <span className="font-mono text-xs text-muted-foreground">
{t("ui.common.close", "닫기")} {field.key}
</Button> </span>
</DialogTrigger> </div>
</DialogFooter> </label>
</DialogContent> ))}
</Dialog> </div>
<Button asChild size="sm" className="h-9"> <DialogFooter>
<Link to="/users/new"> <DialogTrigger asChild>
<Plus size={16} /> <Button variant="secondary">
{t("ui.admin.users.list.add", "사용자 추가")} {t("ui.common.close", "닫기")}
</Link> </Button>
</Button> </DialogTrigger>
</div> </DialogFooter>
</header> </DialogContent>
</Dialog>
<Button asChild size="sm" className="h-9">
<Link to="/users/new">
<Plus size={16} />
{t("ui.admin.users.list.add", "사용자 추가")}
</Link>
</Button>
</>
}
/>
<Card className="flex-1 flex flex-col min-h-0 bg-[var(--color-panel)] overflow-hidden"> <Card className="flex-1 flex flex-col min-h-0 bg-[var(--color-panel)] overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0"> <CardHeader className="flex flex-row items-center justify-between flex-shrink-0">