1
0
forked from baron/baron-sso
Files
baron-sso/adminfront/src/features/users/UserListPage.tsx

687 lines
24 KiB
TypeScript

import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
ChevronLeft,
ChevronRight,
FileDown,
Pencil,
Plus,
RefreshCw,
Search,
Settings2,
Trash2,
User,
} from "lucide-react";
import * as React from "react";
import { Link, useNavigate } from "react-router-dom";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table";
import {
bulkDeleteUsers,
bulkUpdateUsers,
deleteUser,
exportUsersCSVUrl,
fetchMe,
fetchTenant,
fetchTenants,
fetchUsers,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { UserBulkMoveGroupModal } from "./components/UserBulkMoveGroupModal";
import { UserBulkUploadModal } from "./components/UserBulkUploadModal";
type UserSchemaField = {
key: string;
label: string;
type: string;
};
function UserListPage() {
const navigate = useNavigate();
const [page, setPage] = React.useState(1);
const [search, setSearch] = React.useState("");
const [searchDraft, setSearchDraft] = React.useState("");
const [selectedCompany, setSelectedCompany] = React.useState<string>("");
const [visibleColumns, setVisibleColumns] = React.useState<
Record<string, boolean>
>({});
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
const limit = 50;
const offset = (page - 1) * limit;
const { data: profile } = useQuery({
queryKey: ["me"],
queryFn: fetchMe,
});
const { data: tenantsData } = useQuery({
queryKey: ["tenants", { limit: 100 }],
queryFn: () => fetchTenants(100, 0),
});
const tenants = tenantsData?.items ?? [];
// Lock company for tenant_admin
React.useEffect(() => {
if (profile?.role === "tenant_admin" && profile.companyCode) {
setSelectedCompany(profile.companyCode);
}
}, [profile]);
const selectedTenantId = React.useMemo(() => {
return tenants.find((t) => t.slug === selectedCompany)?.id ?? "";
}, [tenants, selectedCompany]);
const { data: tenantDetail } = useQuery({
queryKey: ["tenant", selectedTenantId],
queryFn: () => fetchTenant(selectedTenantId),
enabled: selectedTenantId.length > 0,
});
const userSchema: UserSchemaField[] = Array.isArray(
tenantDetail?.config?.userSchema,
)
? (tenantDetail?.config?.userSchema as UserSchemaField[])
: [];
// Initialize visible columns when schema changes
React.useEffect(() => {
if (userSchema.length > 0) {
const initial: Record<string, boolean> = {};
for (const field of userSchema) {
initial[field.key] = true;
}
setVisibleColumns((prev) => {
// Only set if not already set for these keys to avoid reset on every render
const next = { ...initial, ...prev };
return next;
});
}
}, [userSchema]);
const toggleColumn = (key: string) => {
setVisibleColumns((prev) => ({
...prev,
[key]: !prev[key],
}));
};
const query = useQuery({
queryKey: [
"users",
{ limit, offset, search, companyCode: selectedCompany },
],
queryFn: () => fetchUsers(limit, offset, search, selectedCompany),
placeholderData: (previousData) => previousData,
});
const deleteMutation = useMutation({
mutationFn: (userId: string) => deleteUser(userId),
onSuccess: () => {
query.refetch();
},
});
const handleSearch = () => {
setSearch(searchDraft);
setPage(1);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleSearch();
}
};
const handleExport = () => {
const url = exportUsersCSVUrl(search, selectedCompany);
window.open(url, "_blank");
};
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
?.data?.error;
const fallbackError =
!errorMsg && query.isError
? t(
"msg.admin.users.list.fetch_error",
"사용자 목록 조회에 실패했습니다.",
)
: null;
const items = query.data?.items ?? [];
const total = query.data?.total ?? 0;
const totalPages = Math.ceil(total / limit);
const toggleSelectAll = () => {
if (selectedUserIds.length === items.length) {
setSelectedUserIds([]);
} else {
setSelectedUserIds(items.map((u) => u.id));
}
};
const toggleSelectUser = (id: string) => {
setSelectedUserIds((prev) =>
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id],
);
};
const bulkDeleteMutation = useMutation({
mutationFn: bulkDeleteUsers,
onSuccess: () => {
query.refetch();
setSelectedUserIds([]);
toast.success(
t(
"msg.admin.users.bulk.delete_success",
"선택한 사용자들이 삭제되었습니다.",
),
);
},
});
const bulkUpdateMutation = useMutation({
mutationFn: bulkUpdateUsers,
onSuccess: () => {
query.refetch();
setSelectedUserIds([]);
toast.success(
t(
"msg.admin.users.bulk.update_success",
"선택한 사용자들의 정보가 수정되었습니다.",
),
);
},
});
const handleBulkStatusChange = (status: string) => {
if (selectedUserIds.length === 0) return;
bulkUpdateMutation.mutate({ userIds: selectedUserIds, status });
};
const handleBulkDelete = () => {
if (selectedUserIds.length === 0) return;
if (
window.confirm(
t(
"msg.admin.users.bulk.delete_confirm",
"{{count}}명의 사용자를 정말 삭제하시겠습니까?",
{ count: selectedUserIds.length },
),
)
) {
bulkDeleteMutation.mutate(selectedUserIds);
}
};
const handleDelete = (userId: string, userName: string) => {
if (
!window.confirm(
t(
"msg.admin.users.list.delete_confirm",
'사용자 "{{name}}"을(를) 정말 삭제하시겠습니까?',
{ name: userName },
),
)
) {
return;
}
deleteMutation.mutate(userId);
};
return (
<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">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<span>{t("ui.admin.users.list.breadcrumb.section", "Users")}</span>
<span>/</span>
<span className="text-foreground">
{t("ui.admin.users.list.breadcrumb.list", "List")}
</span>
</div>
<h2 className="text-3xl font-semibold" data-testid="page-title">
{t("ui.admin.users.list.title", "사용자 관리")}
</h2>
<p className="text-sm text-[var(--color-muted)]">
{t(
"msg.admin.users.list.subtitle",
"시스템 사용자를 조회하고 관리합니다.",
)}
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => query.refetch()}
disabled={query.isFetching}
>
<RefreshCw size={16} />
{t("ui.common.refresh", "새로고침")}
</Button>
<Button variant="outline" onClick={handleExport} className="gap-2">
<FileDown size={16} />
{t("ui.common.export", "내보내기")}
</Button>
<UserBulkUploadModal onSuccess={() => query.refetch()} />
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="icon">
<Settings2 size={16} />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("ui.admin.users.list.columns.title", "표시 컬럼 설정")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.users.list.columns.description",
"사용자 목록에 표시할 커스텀 필드를 선택하세요.",
)}
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
{userSchema.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
{t(
"msg.admin.users.list.columns.no_custom",
"현재 테넌트에 정의된 커스텀 필드가 없습니다.",
)}
</p>
)}
{userSchema.map((field) => (
<label
key={field.key}
className="flex items-center gap-3 p-2 rounded-lg hover:bg-muted/50 cursor-pointer"
>
<input
type="checkbox"
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
checked={visibleColumns[field.key] !== false}
onChange={() => toggleColumn(field.key)}
/>
<div className="flex flex-col">
<span className="text-sm font-medium">{field.label}</span>
<span className="text-xs text-muted-foreground font-mono">
{field.key}
</span>
</div>
</label>
))}
</div>
<DialogFooter>
<DialogTrigger asChild>
<Button variant="secondary">
{t("ui.common.close", "닫기")}
</Button>
</DialogTrigger>
</DialogFooter>
</DialogContent>
</Dialog>
<Button asChild>
<Link to="/users/new">
<Plus size={16} />
{t("ui.admin.users.list.add", "사용자 추가")}
</Link>
</Button>
</div>
</header>
<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">
<div>
<CardTitle>
{t("ui.admin.users.list.registry.title", "User Registry")}
</CardTitle>
<CardDescription>
{t(
"msg.admin.users.list.registry.count",
"총 {{count}}명의 사용자가 등록되어 있습니다.",
{ count: total },
)}
</CardDescription>
</div>
</CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<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">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t(
"ui.admin.users.list.search_placeholder",
"이름 또는 이메일 검색...",
)}
className="pl-9"
value={searchDraft}
onChange={(e) => setSearchDraft(e.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-muted-foreground whitespace-nowrap">
{t("ui.admin.users.list.filter.tenant", "테넌트 필터:")}
</span>
<select
className="flex h-9 w-[200px] 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>
</div>
<Button variant="secondary" onClick={handleSearch}>
{t("ui.common.search", "검색")}
</Button>
</div>
{(errorMsg || fallbackError) && (
<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}
</div>
)}
<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 z-10 bg-secondary shadow-sm">
<TableRow>
<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>
<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>
{/* Bulk Action Bar */}
{selectedUserIds.length > 0 && (
<div
className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 flex items-center gap-4 px-6 py-3 rounded-2xl bg-foreground text-background shadow-2xl animate-in slide-in-from-bottom-4 duration-300"
data-testid="bulk-action-bar"
>
<span className="text-sm font-medium border-r border-background/20 pr-4 mr-2">
{t("ui.admin.users.bulk.selected_count", "{{count}}명 선택됨", {
count: selectedUserIds.length,
})}
</span>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
className="text-background hover:bg-background/10 h-8"
onClick={() => handleBulkStatusChange("active")}
data-testid="bulk-active-btn"
>
{t("ui.common.status.active", "활성화")}
</Button>
<Button
variant="ghost"
size="sm"
className="text-background hover:bg-background/10 h-8"
onClick={() => handleBulkStatusChange("inactive")}
data-testid="bulk-inactive-btn"
>
{t("ui.common.status.inactive", "비활성화")}
</Button>
<UserBulkMoveGroupModal
userIds={selectedUserIds}
selectedUsers={items.filter((u) =>
selectedUserIds.includes(u.id),
)}
onSuccess={() => {
query.refetch();
setSelectedUserIds([]);
}}
/>
<div className="w-px h-4 bg-background/20 mx-1" />
<Button
variant="ghost"
size="sm"
className="text-destructive-foreground hover:bg-destructive/20 h-8 gap-1.5"
onClick={handleBulkDelete}
data-testid="bulk-delete-btn"
>
<Trash2 size={14} />
{t("ui.common.delete", "삭제")}
</Button>
</div>
<Button
variant="ghost"
size="icon"
className="text-background/50 hover:text-background h-8 w-8 ml-2"
onClick={() => setSelectedUserIds([])}
aria-label={t("ui.common.close", "닫기")}
data-testid="bulk-close-btn"
>
<Plus size={16} className="rotate-45" />
</Button>
</div>
)}
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-4 flex flex-shrink-0 items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1 || query.isFetching}
>
<ChevronLeft size={16} />
{t("ui.common.previous", "Previous")}
</Button>
<div className="text-sm text-muted-foreground">
{t("ui.common.page_of", "Page {{page}} of {{total}}", {
page,
total: totalPages,
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages || query.isFetching}
>
{t("ui.common.next", "Next")}
<ChevronRight size={16} />
</Button>
</div>
)}
</CardContent>
</Card>
</div>
);
}
export default UserListPage;