1
0
forked from baron/baron-sso

feat: enhance multi-tenant UI and fix member aggregation

- adminfront: Update TenantListPage and UserListPage sorting logic to support all columns dynamically
- adminfront: Add inline 'Move', 'Remove', and 'Delete' actions directly inside the Tenant Org Chart member table
- adminfront: Remove redundant 'ACTIONS' column and profile icon from UserListPage, making the name clickable
- adminfront: Remove 'New Member' creation link from Tenant Org Chart 'Add Member' dropdown
- backend: Fix CountByCompanyCodes query to accurately aggregate user counts using both primary company_code and company_codes array
This commit is contained in:
2026-05-07 13:50:13 +09:00
parent 5096930d68
commit c398237c35
7 changed files with 534 additions and 571 deletions

View File

@@ -1,23 +1,20 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
ChevronDown,
ArrowDown,
ArrowUp,
ArrowUpDown,
ChevronLeft,
ChevronRight,
ChevronUp,
FileDown,
Loader2,
Pencil,
Plus,
RefreshCw,
Search,
Settings2,
Trash2,
User,
UserPlus,
} from "lucide-react";
import * as React from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { Link, useNavigate } from "react-router-dom";
import { Button } from "../../components/ui/button";
import {
Card,
@@ -36,13 +33,7 @@ import {
DialogTrigger,
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../../components/ui/select";
import { Switch } from "../../components/ui/switch";
import {
Table,
TableBody,
@@ -53,6 +44,7 @@ import {
} from "../../components/ui/table";
import { toast } from "../../components/ui/use-toast";
import {
type UserSummary,
bulkDeleteUsers,
bulkUpdateUsers,
deleteUser,
@@ -65,7 +57,6 @@ import {
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { UserBulkUploadModal } from "./components/UserBulkUploadModal";
import { userStatusLabel, userStatusValues } from "./userStatus";
type UserSchemaField = {
key: string;
@@ -73,10 +64,13 @@ type UserSchemaField = {
type: string;
};
type SortConfig = {
key: string;
direction: "asc" | "desc";
};
function UserListPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const addTenantSlug = searchParams.get("addTenant");
const [page, setPage] = React.useState(1);
const [search, setSearch] = React.useState("");
const [searchDraft, setSearchDraft] = React.useState("");
@@ -85,8 +79,8 @@ function UserListPage() {
Record<string, boolean>
>({});
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
const [sortKey, setSortKey] = React.useState<string>("name");
const [sortOrder, setSortOrder] = React.useState<"asc" | "desc">("asc");
const [sortConfig, setSortConfig] = React.useState<SortConfig | null>(null);
const limit = 1000;
const offset = (page - 1) * limit;
@@ -159,15 +153,6 @@ function UserListPage() {
},
});
const handleSort = (key: string) => {
if (sortKey === key) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortKey(key);
setSortOrder("asc");
}
};
const exportMutation = useMutation({
mutationFn: (includeIds: boolean) =>
exportUsersCSV(search, selectedCompany, includeIds),
@@ -201,41 +186,6 @@ function UserListPage() {
},
});
const addTenantMutation = useMutation({
mutationFn: ({ userId, slug }: { userId: string; slug: string }) =>
updateUser(userId, { tenantSlug: slug, isAddTenant: true }),
onSuccess: () => {
toast.success(
t(
"msg.admin.users.add_tenant_success",
"해당 테넌트에 추가되었습니다.",
),
);
query.refetch();
},
onError: (err: any) => {
toast.error(
err.response?.data?.error ||
t("msg.admin.users.add_tenant_error", "추가 실패"),
);
},
});
const handleAddTenant = (userId: string, userName: string) => {
if (!addTenantSlug) return;
if (
window.confirm(
t(
"msg.admin.users.add_tenant_confirm",
"'{{name}}'님을 '{{tenant}}' 테넌트에 추가하시겠습니까?",
{ name: userName, tenant: addTenantSlug },
),
)
) {
addTenantMutation.mutate({ userId, slug: addTenantSlug });
}
};
const handleSearch = () => {
setSearch(searchDraft);
setPage(1);
@@ -261,20 +211,70 @@ function UserListPage() {
)
: null;
const items = query.data?.items ?? [];
const sortedItems = React.useMemo(() => {
return [...items].sort((a, b) => {
const valA = (a[sortKey as keyof typeof a] || "")
.toString()
.toLowerCase();
const valB = (b[sortKey as keyof typeof b] || "")
.toString()
.toLowerCase();
if (valA < valB) return sortOrder === "asc" ? -1 : 1;
if (valA > valB) return sortOrder === "asc" ? 1 : -1;
return 0;
});
}, [items, sortKey, sortOrder]);
const rawItems = query.data?.items ?? [];
const items = React.useMemo(() => {
const sorted = [...rawItems];
if (sortConfig) {
sorted.sort((a, b) => {
let aValue: string | number | boolean | null | undefined;
let bValue: string | number | boolean | null | undefined;
if (sortConfig.key === "name_email") {
aValue = a.name?.toLowerCase() || "";
bValue = b.name?.toLowerCase() || "";
} else if (sortConfig.key === "tenant_dept") {
aValue =
(a.tenant?.name || a.tenantSlug || "").toLowerCase() +
(a.department || "").toLowerCase();
bValue =
(b.tenant?.name || b.tenantSlug || "").toLowerCase() +
(b.department || "").toLowerCase();
} else {
aValue = (a as Record<string, unknown>)[sortConfig.key] as
| string
| number
| boolean;
bValue = (b as Record<string, unknown>)[sortConfig.key] as
| string
| number
| boolean;
}
if (aValue === bValue) return 0;
if (aValue === null || aValue === undefined) return 1;
if (bValue === null || bValue === undefined) return -1;
if (sortConfig.direction === "asc") {
return aValue < bValue ? -1 : 1;
}
return aValue > bValue ? -1 : 1;
});
}
return sorted;
}, [rawItems, sortConfig]);
const requestSort = (key: SortConfig["key"]) => {
let direction: "asc" | "desc" = "asc";
if (
sortConfig &&
sortConfig.key === key &&
sortConfig.direction === "asc"
) {
direction = "desc";
}
setSortConfig({ key, direction });
};
const getSortIcon = (key: SortConfig["key"]) => {
if (!sortConfig || sortConfig.key !== key) {
return <ArrowUpDown size={14} className="ml-1 opacity-50" />;
}
return sortConfig.direction === "asc" ? (
<ArrowUp size={14} className="ml-1" />
) : (
<ArrowDown size={14} className="ml-1" />
);
};
const total = query.data?.total ?? 0;
const totalPages = Math.ceil(total / limit);
@@ -372,8 +372,52 @@ function UserListPage() {
</p>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 mr-2">
<div className="relative w-48">
<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 h-9"
value={searchDraft}
onChange={(e) => setSearchDraft(e.target.value)}
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>
<Button
variant="outline"
size="sm"
className="h-9"
onClick={() => query.refetch()}
disabled={query.isFetching}
>
@@ -401,7 +445,7 @@ function UserListPage() {
<UserBulkUploadModal onSuccess={() => query.refetch()} />
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="icon">
<Button variant="outline" size="icon" className="h-9 w-9">
<Settings2 size={16} />
</Button>
</DialogTrigger>
@@ -455,7 +499,7 @@ function UserListPage() {
</DialogFooter>
</DialogContent>
</Dialog>
<Button asChild>
<Button asChild size="sm" className="h-9">
<Link to="/users/new">
<Plus size={16} />
{t("ui.admin.users.list.add", "사용자 추가")}
@@ -465,12 +509,12 @@ function UserListPage() {
</header>
<Card className="flex-1 flex flex-col min-h-0 bg-[var(--color-panel)] overflow-hidden">
<CardHeader className="flex flex-row items-center gap-4 py-4 flex-wrap flex-shrink-0">
<div className="flex items-baseline gap-3 flex-shrink-0 mr-auto">
<CardTitle className="text-lg m-0">
{t("ui.admin.users.list.registry.title", "사용자 레지스트리")}
<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 className="m-0">
<CardDescription>
{t(
"msg.admin.users.list.registry.count",
"총 {{count}}명의 사용자가 등록되어 있습니다.",
@@ -478,55 +522,8 @@ function UserListPage() {
)}
</CardDescription>
</div>
<div className="flex items-center gap-3">
<div className="relative w-[240px]">
<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 h-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-[180px] 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}
className="h-9"
>
{t("ui.common.search", "검색")}
</Button>
</div>
</CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
{(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}
@@ -551,75 +548,71 @@ function UserListPage() {
</TableHead>
<TableHead
className="min-w-[220px] cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => handleSort("id")}
onClick={() => requestSort("id")}
>
<div className="flex items-center gap-1">
<div className="flex items-center">
{t("ui.admin.users.list.table.id", "ID")}
{sortKey === "id" &&
(sortOrder === "asc" ? (
<ChevronUp size={14} />
) : (
<ChevronDown size={14} />
))}
{getSortIcon("id")}
</div>
</TableHead>
<TableHead
className="min-w-[200px] cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => handleSort("name")}
onClick={() => requestSort("name_email")}
>
<div className="flex items-center gap-1">
<div className="flex items-center">
{t(
"ui.admin.users.list.table.name_email",
"이름 / 이메일 / 전화번호",
)}
{sortKey === "name" &&
(sortOrder === "asc" ? (
<ChevronUp size={14} />
) : (
<ChevronDown size={14} />
))}
{getSortIcon("name_email")}
</div>
</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => handleSort("status")}
onClick={() => requestSort("status")}
>
<div className="flex items-center gap-1">
<div className="flex items-center">
{t("ui.admin.users.list.table.status", "STATUS")}
{sortKey === "status" &&
(sortOrder === "asc" ? (
<ChevronUp size={14} />
) : (
<ChevronDown size={14} />
))}
{getSortIcon("status")}
</div>
</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort("tenant_dept")}
>
<div className="flex items-center">
{t(
"ui.admin.users.list.table.tenant_dept",
"TENANT / DEPT",
)}
{getSortIcon("tenant_dept")}
</div>
</TableHead>
{/* Dynamic Columns from Schema */}
{userSchema.map(
(field) =>
visibleColumns[field.key] !== false && (
<TableHead key={field.key} className="uppercase">
{field.label}
<TableHead
key={field.key}
className="uppercase cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort(field.key)}
>
<div className="flex items-center">
{field.label}
{getSortIcon(field.key)}
</div>
</TableHead>
),
)}
<TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => handleSort("createdAt")}
onClick={() => requestSort("createdAt")}
>
<div className="flex items-center gap-1">
<div className="flex items-center">
{t("ui.admin.users.list.table.created", "CREATED")}
{sortKey === "createdAt" &&
(sortOrder === "asc" ? (
<ChevronUp size={14} />
) : (
<ChevronDown size={14} />
))}
{getSortIcon("createdAt")}
</div>
</TableHead>
<TableHead className="text-right">
{t("ui.admin.users.list.table.actions", "ACTIONS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -629,14 +622,11 @@ function UserListPage() {
colSpan={6 + userSchema.length}
className="h-24 text-center"
>
<div className="flex flex-col items-center gap-2">
<Loader2 className="animate-spin" size={20} />
{t("msg.common.loading", "로딩 중...")}
</div>
{t("msg.common.loading", "로딩 중...")}
</TableCell>
</TableRow>
)}
{!query.isLoading && sortedItems.length === 0 && (
{!query.isLoading && items.length === 0 && (
<TableRow>
<TableCell
colSpan={6 + userSchema.length}
@@ -649,7 +639,7 @@ function UserListPage() {
</TableCell>
</TableRow>
)}
{sortedItems.map((user) => (
{items.map((user) => (
<TableRow
key={user.id}
className={
@@ -681,14 +671,16 @@ function UserListPage() {
</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="truncate text-sm"
data-testid={`user-contact-${user.id}`}
>
<span className="font-medium">{user.name}</span>
<Link
to={`/users/${user.id}`}
className="font-medium hover:underline text-primary"
>
{user.name}
</Link>
<span className="text-muted-foreground">
{" "}
{user.email}
@@ -701,43 +693,45 @@ function UserListPage() {
)}
</div>
</div>
</TableCell>
</TableCell>{" "}
<TableCell>
<div className="flex items-center gap-2">
<Select
value={user.status}
onValueChange={(status) =>
<Switch
checked={user.status === "active"}
onCheckedChange={(checked) =>
statusMutation.mutate({
userId: user.id,
status,
status: checked ? "active" : "inactive",
})
}
disabled={
statusMutation.isPending ||
user.id === profile?.id
}
>
<SelectTrigger
className="h-8 w-36"
aria-label={t(
"ui.admin.users.list.status_select",
"{{name}} 상태",
{ name: user.name },
)}
data-testid={`user-status-select-${user.id}`}
>
<SelectValue>
{userStatusLabel(user.status)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{userStatusValues.map((status) => (
<SelectItem key={status} value={status}>
{userStatusLabel(status)}
</SelectItem>
))}
</SelectContent>
</Select>
aria-label={t(
"ui.admin.users.list.toggle_status",
"{{name}} 활성 상태",
{ name: user.name },
)}
data-testid={`user-status-toggle-${user.id}`}
/>
<span className="text-sm text-muted-foreground">
{t(`ui.common.status.${user.status}`, user.status)}
</span>
</div>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
<span className="text-sm font-medium">
{user.tenant?.name ||
user.companyCode ||
t("ui.common.unassigned", "미배정")}
</span>
{user.department && (
<span className="text-xs text-muted-foreground">
{user.department}
</span>
)}
</div>
</TableCell>
{/* Dynamic Metadata Cells */}
@@ -752,54 +746,6 @@ function UserListPage() {
<TableCell className="text-sm text-muted-foreground">
{new Date(user.createdAt).toLocaleDateString()}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
{addTenantSlug && (
<Button
variant="outline"
size="icon"
className="h-8 w-8 text-primary"
onClick={() =>
handleAddTenant(user.id, user.name)
}
title={t(
"ui.admin.users.list.add_to_tenant",
"테넌트에 추가",
)}
disabled={addTenantMutation.isPending}
>
<UserPlus size={16} />
</Button>
)}
<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 disabled:opacity-30 disabled:cursor-not-allowed"
onClick={() => handleDelete(user.id, user.name)}
disabled={
deleteMutation.isPending ||
user.id === profile?.id
}
title={
user.id === profile?.id
? t(
"msg.admin.users.self_delete_blocked",
"본인 계정은 삭제할 수 없습니다.",
)
: undefined
}
>
<Trash2 size={16} />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
@@ -837,24 +783,6 @@ function UserListPage() {
>
{t("ui.common.status.inactive", "비활성화")}
</Button>
<Button
variant="ghost"
size="sm"
className="text-background hover:bg-background/10 h-8"
onClick={() => handleBulkStatusChange("suspended")}
data-testid="bulk-suspended-btn"
>
{t("ui.common.status.suspended", "정지")}
</Button>
<Button
variant="ghost"
size="sm"
className="text-background hover:bg-background/10 h-8"
onClick={() => handleBulkStatusChange("leave_of_absence")}
data-testid="bulk-leave-of-absence-btn"
>
{t("ui.common.status.leave_of_absence", "휴직")}
</Button>
<div className="w-px h-4 bg-background/20 mx-1" />
<Button
variant="ghost"