From c398237c35b77017cf30dc2a9cbbe492f24e9a5f Mon Sep 17 00:00:00 2001 From: chan Date: Thu, 7 May 2026 13:50:13 +0900 Subject: [PATCH] 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 --- .../tenants/routes/TenantListPage.tsx | 241 ++++----- .../routes/TenantUserGroupsTab.tsx | 135 ++++- .../src/features/users/UserCreatePage.tsx | 2 +- .../src/features/users/UserDetailPage.tsx | 231 +++------ .../src/features/users/UserListPage.tsx | 470 ++++++++---------- adminfront/src/lib/tenantTree.ts | 4 +- .../internal/repository/user_repository.go | 22 +- 7 files changed, 534 insertions(+), 571 deletions(-) diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index 9cdbe340..06ab22b5 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -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([]); const [search, setSearch] = React.useState(""); - const [sortKey, setSortKey] = React.useState("name"); - const [sortOrder, setSortOrder] = React.useState<"asc" | "desc">("asc"); + const [sortConfig, setSortConfig] = React.useState(null); const fileInputRef = React.useRef(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(); - 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(); + 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 ; + } + return sortConfig.direction === "asc" ? ( + + ) : ( + + ); + }; const deletableTenants = React.useMemo( () => tenants.filter((tenant) => !isSeedTenant(tenant)), @@ -403,6 +433,19 @@ function TenantListPage() {

+
+ + setSearch(e.target.value)} + /> +
+ {selectedIds.length > 0 && ( + ))} + {filteredTenants.length === 0 && ( +
+ {t("msg.common.no_results", "검색 결과가 없습니다.")} +
+ )} +
+ + + + + + + + ); }; @@ -574,6 +705,7 @@ function TenantUserGroupsTab() { @@ -702,6 +834,7 @@ const UserAddDialog: React.FC<{ setIsSubmitting(true); try { await updateUser(selectedUserId, { tenantSlug }); + queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] }); toast.success(t("msg.info.saved_success", "사용자가 배정되었습니다.")); onOpenChange(false); resetFields(); diff --git a/adminfront/src/features/users/UserCreatePage.tsx b/adminfront/src/features/users/UserCreatePage.tsx index ad6e061d..8f2e1725 100644 --- a/adminfront/src/features/users/UserCreatePage.tsx +++ b/adminfront/src/features/users/UserCreatePage.tsx @@ -145,7 +145,7 @@ function UserCreatePage() { password: "", name: "", phone: "", - tenantSlug: searchParams.get("tenantSlug") ?? "", + tenantSlug: searchParams.get("tenantSlug") || "", department: "", position: "", jobTitle: "", diff --git a/adminfront/src/features/users/UserDetailPage.tsx b/adminfront/src/features/users/UserDetailPage.tsx index 791b5f33..958685cd 100644 --- a/adminfront/src/features/users/UserDetailPage.tsx +++ b/adminfront/src/features/users/UserDetailPage.tsx @@ -44,13 +44,6 @@ import { } from "../../components/ui/dialog"; import { Input } from "../../components/ui/input"; import { Label } from "../../components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "../../components/ui/select"; import { Switch } from "../../components/ui/switch"; import { Tabs, @@ -85,7 +78,6 @@ import { parseOrgChartTenantSelection, } from "./orgChartPicker"; import type { UserSchemaField } from "./userSchemaFields"; -import { userStatusLabel, userStatusValues } from "./userStatus"; type UserFormValues = Omit & { metadata: Record>; @@ -131,40 +123,12 @@ function createEmptyAppointment(): AppointmentDraft { tenantId: "", tenantName: "", tenantSlug: "", - isPrimary: false, isOwner: false, jobTitle: "", position: "", }; } -function normalizePrimaryAppointments( - appointments: AppointmentDraft[], -): AppointmentDraft[] { - const leafIndexes = appointments - .map((appointment, index) => - appointment.tenantId.trim().length > 0 ? index : -1, - ) - .filter((index) => index >= 0); - if (leafIndexes.length === 1) { - const primaryIndex = leafIndexes[0]; - return appointments.map((appointment, index) => ({ - ...appointment, - isPrimary: index === primaryIndex, - })); - } - const selectedIndex = appointments.findIndex( - (appointment) => appointment.isPrimary === true, - ); - return appointments.map((appointment, index) => ({ - ...appointment, - isPrimary: - selectedIndex >= 0 && - index === selectedIndex && - appointment.tenantId.trim().length > 0, - })); -} - function validateManualPassword( password: string, policy?: PasswordPolicyResponse, @@ -521,17 +485,15 @@ function UserDetailPage() { try { const tenant = await resolveTenantSelection(selection, tenants); setAdditionalAppointments((current) => - normalizePrimaryAppointments( - current.map((appointment, index) => - index === target.index - ? { - ...appointment, - tenantId: tenant.id, - tenantName: tenant.name, - tenantSlug: tenant.slug, - } - : appointment, - ), + current.map((appointment, index) => + index === target.index + ? { + ...appointment, + tenantId: tenant.id, + tenantName: tenant.name, + tenantSlug: tenant.slug, + } + : appointment, ), ); setPickerTarget(null); @@ -574,30 +536,15 @@ function UserDetailPage() { patch: Partial, ) => { setAdditionalAppointments((current) => - normalizePrimaryAppointments( - current.map((appointment, currentIndex) => - currentIndex === index ? { ...appointment, ...patch } : appointment, - ), - ), - ); - }; - - const setPrimaryAppointment = (index: number, checked: boolean) => { - setAdditionalAppointments((current) => - normalizePrimaryAppointments( - current.map((appointment, currentIndex) => ({ - ...appointment, - isPrimary: checked && currentIndex === index, - })), + current.map((appointment, currentIndex) => + currentIndex === index ? { ...appointment, ...patch } : appointment, ), ); }; const removeAppointment = (index: number) => { setAdditionalAppointments((current) => - normalizePrimaryAppointments( - current.filter((_, currentIndex) => currentIndex !== index), - ), + current.filter((_, currentIndex) => currentIndex !== index), ); }; @@ -655,10 +602,7 @@ function UserDetailPage() { tenantSlug: user.companyCode || user.joinedTenants?.find( - (t) => - t.type === "COMPANY" || - t.type === "COMPANY_GROUP" || - t.type === "ORGANIZATION", + (t) => t.type === "COMPANY" || t.type === "COMPANY_GROUP", )?.slug || "", department: user.department || "", @@ -692,45 +636,38 @@ function UserDetailPage() { isHanmacFamilyTenant(tenant, tenants, hanmacFamilyTenantId), ); setAdditionalAppointments( - normalizePrimaryAppointments( - Array.isArray(rawAppointments) - ? (rawAppointments as UserAppointment[]).map((appointment) => ({ - ...appointment, - isPrimary: - appointment.isPrimary === true || - appointment.tenantId === metadata.primaryTenantId, - draftId: createDraftId(), - })) - : isUserHanmacFamily - ? familyFallbackTenants.length > 0 - ? familyFallbackTenants.map((tenant) => ({ - draftId: createDraftId(), - tenantId: tenant.id, - tenantName: tenant.name, - tenantSlug: tenant.slug, - isPrimary: tenant.id === fallbackAppointment?.id, - isOwner: - metadata.primaryTenantIsOwner === true && - tenant.id === fallbackAppointment?.id, - jobTitle: user.jobTitle, - position: user.position, - })) - : fallbackAppointment - ? [ - { - draftId: createDraftId(), - tenantId: fallbackAppointment.id, - tenantName: fallbackAppointment.name, - tenantSlug: fallbackAppointment.slug, - isPrimary: true, - isOwner: metadata.primaryTenantIsOwner === true, - jobTitle: user.jobTitle, - position: user.position, - }, - ] - : [] - : [], - ), + Array.isArray(rawAppointments) + ? (rawAppointments as UserAppointment[]).map((appointment) => ({ + ...appointment, + draftId: createDraftId(), + })) + : isUserHanmacFamily + ? familyFallbackTenants.length > 0 + ? familyFallbackTenants.map((tenant) => ({ + draftId: createDraftId(), + tenantId: tenant.id, + tenantName: tenant.name, + tenantSlug: tenant.slug, + isOwner: + metadata.primaryTenantIsOwner === true && + tenant.id === fallbackAppointment?.id, + jobTitle: user.jobTitle, + position: user.position, + })) + : fallbackAppointment + ? [ + { + draftId: createDraftId(), + tenantId: fallbackAppointment.id, + tenantName: fallbackAppointment.name, + tenantSlug: fallbackAppointment.slug, + isOwner: metadata.primaryTenantIsOwner === true, + jobTitle: user.jobTitle, + position: user.position, + }, + ] + : [] + : [], ); } }, [hanmacFamilyTenantId, tenants, user, reset]); @@ -811,37 +748,19 @@ function UserDetailPage() { tenantId: appointment.tenantId, tenantSlug: appointment.tenantSlug, tenantName: appointment.tenantName, - isPrimary: appointment.isPrimary === true, isOwner: appointment.isOwner, jobTitle: appointment.jobTitle, position: appointment.position, })); - const primaryAppointment = appointments.find( - (appointment) => appointment.isPrimary, - ); payload.tenantSlug = undefined; payload.department = undefined; payload.position = undefined; payload.jobTitle = undefined; payload.additionalAppointments = appointments; - if (primaryAppointment) { - payload.tenantSlug = primaryAppointment.tenantSlug; - payload.primaryTenantId = primaryAppointment.tenantId; - payload.primaryTenantName = primaryAppointment.tenantName; - payload.primaryTenantIsOwner = primaryAppointment.isOwner; - } payload.metadata = { ...metadata, additionalAppointments: appointments, - ...(primaryAppointment - ? { - primaryTenantId: primaryAppointment.tenantId, - primaryTenantName: primaryAppointment.tenantName, - primaryTenantSlug: primaryAppointment.tenantSlug, - primaryTenantIsOwner: primaryAppointment.isOwner, - } - : {}), }; } @@ -872,9 +791,6 @@ function UserDetailPage() { filterNonHanmacFamilyTenants(userAffiliatedTenants, hanmacFamilyTenantId), [userAffiliatedTenants, hanmacFamilyTenantId], ); - const primaryAppointmentLeafCount = additionalAppointments.filter( - (appointment) => appointment.tenantId.trim().length > 0, - ).length; if (isLoading) { return ( @@ -941,10 +857,7 @@ function UserDetailPage() { {user.tenant?.name || user.companyCode || user.joinedTenants?.find( - (t) => - t.type === "COMPANY" || - t.type === "COMPANY_GROUP" || - t.type === "ORGANIZATION", + (t) => t.type === "COMPANY" || t.type === "COMPANY_GROUP", )?.name || t("ui.admin.users.detail.form.tenant_global", "시스템 전역")} @@ -1088,23 +1001,21 @@ function UserDetailPage() { > {t("ui.admin.users.detail.form.status", "상태")} - +
+ + setValue("status", checked ? "active" : "inactive") + } + /> + + {t( + `ui.common.status.${watchedStatus}`, + watchedStatus || "inactive", + )} + +
@@ -1249,26 +1160,6 @@ function UserDetailPage() { {appointment.tenantSlug} )} -