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:
@@ -1,11 +1,11 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
Download,
|
||||
FileSpreadsheet,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
@@ -53,25 +53,29 @@ import {
|
||||
importTenantsCSV,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
|
||||
import { isSeedTenant } from "../utils/protectedTenants";
|
||||
import {
|
||||
type TenantImportResolution,
|
||||
type TenantImportPreviewRow,
|
||||
type TenantImportResolution,
|
||||
buildTenantImportPreview,
|
||||
parseTenantCSV,
|
||||
serializeTenantImportCSV,
|
||||
} from "../utils/tenantCsvImport";
|
||||
import { isSeedTenant } from "../utils/protectedTenants";
|
||||
import { buildTenantFullTree, type TenantNode } from "../../../lib/tenantTree";
|
||||
|
||||
const tenantCSVTemplate =
|
||||
"name,type,parent_tenant_slug,slug,memo,email_domain\n";
|
||||
|
||||
type SortConfig = {
|
||||
key: keyof TenantSummary | "recursiveMemberCount";
|
||||
direction: "asc" | "desc";
|
||||
};
|
||||
|
||||
function TenantListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [sortKey, setSortKey] = React.useState<string>("name");
|
||||
const [sortOrder, setSortOrder] = React.useState<"asc" | "desc">("asc");
|
||||
const [sortConfig, setSortConfig] = React.useState<SortConfig | null>(null);
|
||||
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const [importMessage, setImportMessage] = React.useState("");
|
||||
const [previewRows, setPreviewRows] = React.useState<
|
||||
@@ -119,15 +123,6 @@ function TenantListPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleSort = (key: string) => {
|
||||
if (sortKey === key) {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
};
|
||||
|
||||
const deleteBulkMutation = useMutation({
|
||||
mutationFn: (ids: string[]) => deleteTenantsBulk(ids),
|
||||
onSuccess: () => {
|
||||
@@ -212,45 +207,80 @@ function TenantListPage() {
|
||||
: null;
|
||||
|
||||
const allTenants = query.data?.items ?? [];
|
||||
const tenantsWithRecursiveCount = React.useMemo(() => {
|
||||
// Build tree to calculate recursiveMemberCount, but we map it back to a flat array for the table
|
||||
const { subTree } = buildTenantFullTree(allTenants);
|
||||
const tenants = React.useMemo(() => {
|
||||
// 1. Calculate recursive counts
|
||||
// buildTenantFullTree returns subTree which represents roots, but it also mutates the mapped nodes internally.
|
||||
// However, to easily map them back to a flat list, we can just run the builder,
|
||||
// and then extract the recursive counts.
|
||||
const treeResult = buildTenantFullTree(allTenants);
|
||||
|
||||
const flatMap = new Map<string, TenantNode>();
|
||||
const flatten = (nodes: TenantNode[]) => {
|
||||
// Flatten the tree or just extract from allTenants map?
|
||||
// buildTenantFullTree does NOT mutate the objects passed in allTenants. It creates new ones.
|
||||
// Let's create a map of id -> recursiveMemberCount
|
||||
const recursiveCounts = new Map<string, number>();
|
||||
const extractCounts = (nodes: TenantNode[]) => {
|
||||
for (const node of nodes) {
|
||||
flatMap.set(node.id, node);
|
||||
flatten(node.children);
|
||||
recursiveCounts.set(node.id, node.recursiveMemberCount);
|
||||
if (node.children) extractCounts(node.children);
|
||||
}
|
||||
};
|
||||
flatten(subTree);
|
||||
extractCounts(treeResult.subTree);
|
||||
|
||||
// Map back to allTenants ensuring recursiveMemberCount is present
|
||||
return allTenants.map((t) => flatMap.get(t.id) ?? { ...t, children: [], recursiveMemberCount: t.memberCount || 0 });
|
||||
}, [allTenants]);
|
||||
let enriched = allTenants.map((t) => ({
|
||||
...t,
|
||||
recursiveMemberCount: recursiveCounts.get(t.id) ?? t.memberCount ?? 0,
|
||||
}));
|
||||
|
||||
const tenants = React.useMemo(() => {
|
||||
let filtered = tenantsWithRecursiveCount;
|
||||
if (search.trim()) {
|
||||
const term = search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
enriched = enriched.filter(
|
||||
(t) =>
|
||||
t.name.toLowerCase().includes(term) ||
|
||||
t.slug.toLowerCase().includes(term),
|
||||
);
|
||||
}
|
||||
return [...filtered].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;
|
||||
});
|
||||
}, [tenantsWithRecursiveCount, search, sortKey, sortOrder]);
|
||||
|
||||
if (sortConfig) {
|
||||
enriched.sort((a, b) => {
|
||||
const aValue = a[sortConfig.key as keyof typeof a];
|
||||
const bValue = b[sortConfig.key as keyof typeof b];
|
||||
|
||||
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 enriched;
|
||||
}, [allTenants, search, 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 deletableTenants = React.useMemo(
|
||||
() => tenants.filter((tenant) => !isSeedTenant(tenant)),
|
||||
@@ -403,6 +433,19 @@ function TenantListPage() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative w-64 mr-2">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.list.search_placeholder",
|
||||
"테넌트 이름 또는 슬러그 검색...",
|
||||
)}
|
||||
className="pl-9 h-9"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
{selectedIds.length > 0 && (
|
||||
<Button
|
||||
@@ -492,35 +535,19 @@ function TenantListPage() {
|
||||
</header>
|
||||
|
||||
<Card className="bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 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">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{t("ui.admin.tenants.registry.title", "Tenant Registry")}
|
||||
</CardTitle>
|
||||
<CardDescription className="m-0">
|
||||
<CardDescription>
|
||||
{t("msg.admin.tenants.registry.count", "총 {{count}}개 테넌트", {
|
||||
count: query.data?.total ?? 0,
|
||||
})}
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative w-[280px]">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.list.search_placeholder",
|
||||
"테넌트 이름 또는 슬러그 검색...",
|
||||
)}
|
||||
className="pl-9 h-9"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</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}
|
||||
@@ -546,87 +573,65 @@ function TenantListPage() {
|
||||
</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.tenants.table.id", "ID")}
|
||||
{sortKey === "id" &&
|
||||
(sortOrder === "asc" ? (
|
||||
<ChevronUp size={14} />
|
||||
) : (
|
||||
<ChevronDown size={14} />
|
||||
))}
|
||||
{getSortIcon("id")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => handleSort("name")}
|
||||
onClick={() => requestSort("name")}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center">
|
||||
{t("ui.admin.tenants.table.name", "NAME")}
|
||||
{sortKey === "name" &&
|
||||
(sortOrder === "asc" ? (
|
||||
<ChevronUp size={14} />
|
||||
) : (
|
||||
<ChevronDown size={14} />
|
||||
))}
|
||||
{getSortIcon("name")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead>{t("ui.admin.tenants.table.type", "TYPE")}</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => handleSort("slug")}
|
||||
onClick={() => requestSort("type")}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center">
|
||||
{t("ui.admin.tenants.table.type", "TYPE")}
|
||||
{getSortIcon("type")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => requestSort("slug")}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{t("ui.admin.tenants.table.slug", "SLUG")}
|
||||
{sortKey === "slug" &&
|
||||
(sortOrder === "asc" ? (
|
||||
<ChevronUp size={14} />
|
||||
) : (
|
||||
<ChevronDown size={14} />
|
||||
))}
|
||||
{getSortIcon("slug")}
|
||||
</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.tenants.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={() => handleSort("memberCount")}
|
||||
onClick={() => requestSort("recursiveMemberCount")}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center">
|
||||
{t("ui.admin.tenants.table.members", "MEMBERS")}
|
||||
{sortKey === "memberCount" &&
|
||||
(sortOrder === "asc" ? (
|
||||
<ChevronUp size={14} />
|
||||
) : (
|
||||
<ChevronDown size={14} />
|
||||
))}
|
||||
{getSortIcon("recursiveMemberCount")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => handleSort("updatedAt")}
|
||||
onClick={() => requestSort("updatedAt")}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center">
|
||||
{t("ui.admin.tenants.table.updated", "UPDATED")}
|
||||
{sortKey === "updatedAt" &&
|
||||
(sortOrder === "asc" ? (
|
||||
<ChevronUp size={14} />
|
||||
) : (
|
||||
<ChevronDown size={14} />
|
||||
))}
|
||||
{getSortIcon("updatedAt")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
@@ -637,11 +642,8 @@ function TenantListPage() {
|
||||
<TableBody>
|
||||
{query.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} 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>
|
||||
<TableCell colSpan={9}>
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
@@ -719,8 +721,9 @@ function TenantListPage() {
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{(tenant as unknown as TenantNode).recursiveMemberCount ?? tenant.memberCount}
|
||||
</TableCell> <TableCell className="text-xs">
|
||||
{tenant.recursiveMemberCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{tenant.updatedAt
|
||||
? new Date(tenant.updatedAt).toLocaleString("ko-KR")
|
||||
: "-"}
|
||||
|
||||
Reference in New Issue
Block a user