forked from baron/baron-sso
feat: implement sticky header and inner scrolling for user list page
This commit is contained in:
@@ -254,8 +254,8 @@ function UserListPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
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.users.list.breadcrumb.section", "Users")}</span>
|
<span>{t("ui.admin.users.list.breadcrumb.section", "Users")}</span>
|
||||||
@@ -353,8 +353,8 @@ function UserListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<Card className="bg-[var(--color-panel)]">
|
<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">
|
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
{t("ui.admin.users.list.registry.title", "User Registry")}
|
{t("ui.admin.users.list.registry.title", "User Registry")}
|
||||||
@@ -368,8 +368,8 @@ function UserListPage() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||||
<div className="mb-6 flex flex-wrap items-center gap-4">
|
<div className="mb-6 flex flex-wrap items-center gap-4 flex-shrink-0">
|
||||||
<div className="relative flex-1 min-w-[240px] max-w-sm">
|
<div className="relative flex-1 min-w-[240px] 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
|
||||||
@@ -412,167 +412,175 @@ function UserListPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(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 flex-shrink-0">
|
||||||
{errorMsg ?? fallbackError}
|
{errorMsg ?? fallbackError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="rounded-md border overflow-x-auto">
|
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||||
<Table>
|
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||||
<TableHeader>
|
<Table>
|
||||||
<TableRow>
|
<TableHeader className="sticky top-0 bg-muted/90 backdrop-blur z-10 shadow-sm">
|
||||||
<TableHead className="w-12">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
|
|
||||||
checked={
|
|
||||||
items.length > 0 &&
|
|
||||||
selectedUserIds.length === items.length
|
|
||||||
}
|
|
||||||
onChange={toggleSelectAll}
|
|
||||||
/>
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="min-w-[200px]">
|
|
||||||
{t("ui.admin.users.list.table.name_email", "NAME / EMAIL")}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead>
|
|
||||||
{t("ui.admin.users.list.table.role", "ROLE")}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead>
|
|
||||||
{t("ui.admin.users.list.table.status", "STATUS")}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead>
|
|
||||||
{t(
|
|
||||||
"ui.admin.users.list.table.tenant_dept",
|
|
||||||
"TENANT / DEPT",
|
|
||||||
)}
|
|
||||||
</TableHead>
|
|
||||||
{/* Dynamic Columns from Schema */}
|
|
||||||
{userSchema.map(
|
|
||||||
(field) =>
|
|
||||||
visibleColumns[field.key] !== false && (
|
|
||||||
<TableHead key={field.key} className="uppercase">
|
|
||||||
{field.label}
|
|
||||||
</TableHead>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
<TableHead>
|
|
||||||
{t("ui.admin.users.list.table.created", "CREATED")}
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="text-right">
|
|
||||||
{t("ui.admin.users.list.table.actions", "ACTIONS")}
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{query.isLoading && (
|
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableHead className="w-12">
|
||||||
colSpan={6 + userSchema.length}
|
|
||||||
className="h-24 text-center"
|
|
||||||
>
|
|
||||||
{t("msg.common.loading", "로딩 중...")}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
{!query.isLoading && items.length === 0 && (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell
|
|
||||||
colSpan={6 + userSchema.length}
|
|
||||||
className="h-24 text-center"
|
|
||||||
>
|
|
||||||
{t("msg.admin.users.list.empty", "검색 결과가 없습니다.")}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
{items.map((user) => (
|
|
||||||
<TableRow
|
|
||||||
key={user.id}
|
|
||||||
className={
|
|
||||||
selectedUserIds.includes(user.id) ? "bg-primary/5" : ""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<TableCell>
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
|
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
|
||||||
checked={selectedUserIds.includes(user.id)}
|
checked={
|
||||||
onChange={() => toggleSelectUser(user.id)}
|
items.length > 0 &&
|
||||||
/>
|
selectedUserIds.length === items.length
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
|
|
||||||
<User size={16} />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="font-medium">{user.name}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{user.email}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="outline">
|
|
||||||
{t(`ui.admin.role.${user.role}`, user.role)}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
user.status === "active" ? "default" : "secondary"
|
|
||||||
}
|
}
|
||||||
>
|
onChange={toggleSelectAll}
|
||||||
{t(`ui.common.status.${user.status}`, user.status)}
|
/>
|
||||||
</Badge>
|
</TableHead>
|
||||||
</TableCell>
|
<TableHead className="min-w-[200px]">
|
||||||
<TableCell>
|
{t(
|
||||||
<div className="flex flex-col text-sm">
|
"ui.admin.users.list.table.name_email",
|
||||||
<span className="font-medium text-blue-600">
|
"NAME / EMAIL",
|
||||||
{user.tenant?.name || user.companyCode || "-"}
|
)}
|
||||||
</span>
|
</TableHead>
|
||||||
<span className="text-xs text-muted-foreground">
|
<TableHead>
|
||||||
{user.department || "-"}
|
{t("ui.admin.users.list.table.role", "ROLE")}
|
||||||
</span>
|
</TableHead>
|
||||||
</div>
|
<TableHead>
|
||||||
</TableCell>
|
{t("ui.admin.users.list.table.status", "STATUS")}
|
||||||
{/* Dynamic Metadata Cells */}
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
{t(
|
||||||
|
"ui.admin.users.list.table.tenant_dept",
|
||||||
|
"TENANT / DEPT",
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
{/* Dynamic Columns from Schema */}
|
||||||
{userSchema.map(
|
{userSchema.map(
|
||||||
(field) =>
|
(field) =>
|
||||||
visibleColumns[field.key] !== false && (
|
visibleColumns[field.key] !== false && (
|
||||||
<TableCell key={field.key} className="text-sm">
|
<TableHead key={field.key} className="uppercase">
|
||||||
{String(user.metadata?.[field.key] ?? "-")}
|
{field.label}
|
||||||
</TableCell>
|
</TableHead>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
<TableHead>
|
||||||
{new Date(user.createdAt).toLocaleDateString()}
|
{t("ui.admin.users.list.table.created", "CREATED")}
|
||||||
</TableCell>
|
</TableHead>
|
||||||
<TableCell className="text-right">
|
<TableHead className="text-right">
|
||||||
<div className="flex justify-end gap-2">
|
{t("ui.admin.users.list.table.actions", "ACTIONS")}
|
||||||
<Button
|
</TableHead>
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => navigate(`/users/${user.id}`)}
|
|
||||||
>
|
|
||||||
<Pencil size={16} />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="text-destructive hover:text-destructive"
|
|
||||||
onClick={() => handleDelete(user.id, user.name)}
|
|
||||||
disabled={deleteMutation.isPending}
|
|
||||||
>
|
|
||||||
<Trash2 size={16} />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
</TableHeader>
|
||||||
</TableBody>
|
<TableBody>
|
||||||
</Table>
|
{query.isLoading && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={7 + userSchema.length}
|
||||||
|
className="h-24 text-center"
|
||||||
|
>
|
||||||
|
{t("msg.common.loading", "로딩 중...")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{!query.isLoading && items.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={7 + userSchema.length}
|
||||||
|
className="h-24 text-center"
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
"msg.admin.users.list.empty",
|
||||||
|
"검색 결과가 없습니다.",
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{items.map((user) => (
|
||||||
|
<TableRow
|
||||||
|
key={user.id}
|
||||||
|
className={
|
||||||
|
selectedUserIds.includes(user.id) ? "bg-primary/5" : ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
|
||||||
|
checked={selectedUserIds.includes(user.id)}
|
||||||
|
onChange={() => toggleSelectUser(user.id)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
|
||||||
|
<User size={16} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{user.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{user.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{t(`ui.admin.role.${user.role}`, user.role)}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
user.status === "active" ? "default" : "secondary"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t(`ui.common.status.${user.status}`, user.status)}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex flex-col text-sm">
|
||||||
|
<span className="font-medium text-blue-600">
|
||||||
|
{user.tenant?.name || user.companyCode || "-"}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{user.department || "-"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
{/* Dynamic Metadata Cells */}
|
||||||
|
{userSchema.map(
|
||||||
|
(field) =>
|
||||||
|
visibleColumns[field.key] !== false && (
|
||||||
|
<TableCell key={field.key} className="text-sm">
|
||||||
|
{String(user.metadata?.[field.key] ?? "-")}
|
||||||
|
</TableCell>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{new Date(user.createdAt).toLocaleDateString()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => navigate(`/users/${user.id}`)}
|
||||||
|
>
|
||||||
|
<Pencil size={16} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
onClick={() => handleDelete(user.id, user.name)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bulk Action Bar */}
|
{/* Bulk Action Bar */}
|
||||||
@@ -639,7 +647,7 @@ function UserListPage() {
|
|||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="mt-4 flex items-center justify-end gap-2">
|
<div className="mt-4 flex flex-shrink-0 items-center justify-end gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
Reference in New Issue
Block a user