forked from baron/baron-sso
feat: optimize tenant admin view and enhance user list with dynamic columns and metadata search
This commit is contained in:
@@ -30,21 +30,68 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { deleteUser, fetchUsers } from "../../lib/adminApi";
|
||||
import {
|
||||
deleteUser,
|
||||
fetchMe,
|
||||
fetchTenant,
|
||||
fetchTenants,
|
||||
fetchUsers,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
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 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[])
|
||||
: [];
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["users", { limit, offset, search }],
|
||||
queryFn: () => fetchUsers(limit, offset, search),
|
||||
queryKey: ["users", { limit, offset, search, companyCode: selectedCompany }],
|
||||
queryFn: () => fetchUsers(limit, offset, search, selectedCompany),
|
||||
placeholderData: (previousData) => previousData,
|
||||
});
|
||||
|
||||
@@ -80,12 +127,6 @@ function UserListPage() {
|
||||
const total = query.data?.total ?? 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (items.length > 0) {
|
||||
console.log("User items:", items);
|
||||
}
|
||||
}, [items]);
|
||||
|
||||
const handleDelete = (userId: string, userName: string) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
@@ -118,7 +159,7 @@ function UserListPage() {
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
{t(
|
||||
"msg.admin.users.list.subtitle",
|
||||
"시스템 사용자를 조회하고 관리합니다. (Local DB)",
|
||||
"시스템 사용자를 조회하고 관리합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -157,8 +198,8 @@ function UserListPage() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<div className="mb-6 flex flex-wrap items-center gap-4">
|
||||
<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(
|
||||
@@ -171,6 +212,29 @@ function UserListPage() {
|
||||
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>
|
||||
@@ -182,11 +246,11 @@ function UserListPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-md border">
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<TableHead className="min-w-[200px]">
|
||||
{t("ui.admin.users.list.table.name_email", "NAME / EMAIL")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
@@ -201,12 +265,12 @@ function UserListPage() {
|
||||
"TENANT / DEPT",
|
||||
)}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t(
|
||||
"ui.admin.users.list.table.position_job",
|
||||
"POSITION / JOB",
|
||||
)}
|
||||
</TableHead>
|
||||
{/* Dynamic Columns from Schema */}
|
||||
{userSchema.map((field) => (
|
||||
<TableHead key={field.key} className="uppercase">
|
||||
{field.label}
|
||||
</TableHead>
|
||||
))}
|
||||
<TableHead>
|
||||
{t("ui.admin.users.list.table.created", "CREATED")}
|
||||
</TableHead>
|
||||
@@ -218,14 +282,20 @@ function UserListPage() {
|
||||
<TableBody>
|
||||
{query.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-24 text-center">
|
||||
<TableCell
|
||||
colSpan={6 + userSchema.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!query.isLoading && items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-24 text-center">
|
||||
<TableCell
|
||||
colSpan={6 + userSchema.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{t("msg.admin.users.list.empty", "검색 결과가 없습니다.")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -264,32 +334,17 @@ function UserListPage() {
|
||||
<span className="font-medium text-blue-600">
|
||||
{user.tenant?.name || user.companyCode || "-"}
|
||||
</span>
|
||||
{user.tenant && (
|
||||
<span className="text-[10px] text-muted-foreground uppercase">
|
||||
{t(
|
||||
"ui.admin.users.list.tenant_slug",
|
||||
"Slug: {{slug}}",
|
||||
{
|
||||
slug: user.tenant.slug,
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{user.department || "-"}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col text-sm">
|
||||
<span className="font-medium">
|
||||
{user.position || "-"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{user.jobTitle || "-"}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* Dynamic Metadata Cells */}
|
||||
{userSchema.map((field) => (
|
||||
<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>
|
||||
@@ -299,11 +354,6 @@ function UserListPage() {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => navigate(`/users/${user.id}`)}
|
||||
aria-label={t(
|
||||
"ui.admin.users.list.edit_aria",
|
||||
"사용자 수정: {{name}}",
|
||||
{ name: user.name },
|
||||
)}
|
||||
>
|
||||
<Pencil size={16} />
|
||||
</Button>
|
||||
@@ -313,11 +363,6 @@ function UserListPage() {
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => handleDelete(user.id, user.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
aria-label={t(
|
||||
"ui.admin.users.list.delete_aria",
|
||||
"사용자 삭제: {{name}}",
|
||||
{ name: user.name },
|
||||
)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user