From 5f9a61de98a534e5ae6094445a844c02bf42ed41 Mon Sep 17 00:00:00 2001 From: chan Date: Wed, 6 May 2026 14:20:35 +0900 Subject: [PATCH 1/8] feat: implement multi-tenant member management and UI improvements - Add multi-tenant support (isAddTenant, isRemoveTenant) to backend UpdateUser API. - Update UserRepository to support searching in company_codes array. - Implement table sorting and align search bar layout in adminfront. - Add 'Assign Existing Member' and 'Exclude from Organization' features to TenantUsersPage. - Auto-populate tenantSlug in UserCreatePage via query parameters. - Add necessary localization keys for new UI elements. Resolves #644, #639, #642, #641 --- .../tenants/routes/TenantListPage.tsx | 171 +++++++++++---- .../tenants/routes/TenantUsersPage.tsx | 147 ++++++++++--- .../src/features/users/UserCreatePage.tsx | 5 +- .../src/features/users/UserListPage.tsx | 201 +++++++++++++++--- adminfront/src/lib/adminApi.ts | 2 + adminfront/src/locales/en.toml | 47 +++- adminfront/src/locales/ko.toml | 35 +++ backend/internal/domain/user.go | 2 + backend/internal/handler/user_handler.go | 99 +++++++-- .../internal/repository/user_repository.go | 22 +- 10 files changed, 591 insertions(+), 140 deletions(-) diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index e86c2db8..f2a4bab4 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -1,8 +1,11 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { + ChevronDown, + ChevronUp, Download, FileSpreadsheet, + Loader2, Pencil, Plus, RefreshCw, @@ -66,6 +69,8 @@ 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 fileInputRef = React.useRef(null); const [importMessage, setImportMessage] = React.useState(""); const [previewRows, setPreviewRows] = React.useState< @@ -113,6 +118,15 @@ 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: () => { @@ -198,14 +212,27 @@ function TenantListPage() { const allTenants = query.data?.items ?? []; const tenants = React.useMemo(() => { - if (!search.trim()) return allTenants; - const term = search.toLowerCase(); - return allTenants.filter( - (t) => - t.name.toLowerCase().includes(term) || - t.slug.toLowerCase().includes(term), - ); - }, [allTenants, search]); + let filtered = allTenants; + if (search.trim()) { + const term = search.toLowerCase(); + filtered = filtered.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; + }); + }, [allTenants, search, sortKey, sortOrder]); const deletableTenants = React.useMemo( () => tenants.filter((tenant) => !isSeedTenant(tenant)), @@ -460,18 +487,23 @@ function TenantListPage() { -
+
- - setSearch(e.target.value)} - /> + +
+ + setSearch(e.target.value)} + /> +
@@ -498,26 +530,90 @@ function TenantListPage() { } /> - - {t("ui.admin.tenants.table.id", "ID")} + handleSort("id")} + > +
+ {t("ui.admin.tenants.table.id", "ID")} + {sortKey === "id" && + (sortOrder === "asc" ? ( + + ) : ( + + ))} +
- - {t("ui.admin.tenants.table.name", "NAME")} + handleSort("name")} + > +
+ {t("ui.admin.tenants.table.name", "NAME")} + {sortKey === "name" && + (sortOrder === "asc" ? ( + + ) : ( + + ))} +
- - {t("ui.admin.tenants.table.type", "TYPE")} + {t("ui.admin.tenants.table.type", "TYPE")} + handleSort("slug")} + > +
+ {t("ui.admin.tenants.table.slug", "SLUG")} + {sortKey === "slug" && + (sortOrder === "asc" ? ( + + ) : ( + + ))} +
- - {t("ui.admin.tenants.table.slug", "SLUG")} + handleSort("status")} + > +
+ {t("ui.admin.tenants.table.status", "STATUS")} + {sortKey === "status" && + (sortOrder === "asc" ? ( + + ) : ( + + ))} +
- - {t("ui.admin.tenants.table.status", "STATUS")} + handleSort("memberCount")} + > +
+ {t("ui.admin.tenants.table.members", "MEMBERS")} + {sortKey === "memberCount" && + (sortOrder === "asc" ? ( + + ) : ( + + ))} +
- - {t("ui.admin.tenants.table.members", "MEMBERS")} - - - {t("ui.admin.tenants.table.updated", "UPDATED")} + handleSort("updatedAt")} + > +
+ {t("ui.admin.tenants.table.updated", "UPDATED")} + {sortKey === "updatedAt" && + (sortOrder === "asc" ? ( + + ) : ( + + ))} +
{t("ui.admin.tenants.table.actions", "ACTIONS")} @@ -527,8 +623,11 @@ function TenantListPage() { {query.isLoading && ( - - {t("msg.common.loading", "로딩 중...")} + +
+ + {t("msg.common.loading", "로딩 중...")} +
)} diff --git a/adminfront/src/features/tenants/routes/TenantUsersPage.tsx b/adminfront/src/features/tenants/routes/TenantUsersPage.tsx index 853a82ea..e1013a06 100644 --- a/adminfront/src/features/tenants/routes/TenantUsersPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantUsersPage.tsx @@ -1,13 +1,20 @@ -import { useQuery } from "@tanstack/react-query"; -import { Mail, User } from "lucide-react"; -import { useParams } from "react-router-dom"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Mail, MoreHorizontal, Plus, User, UserPlus, UserMinus, Loader2 } from "lucide-react"; +import { Link, useParams } from "react-router-dom"; import { Badge } from "../../../components/ui/badge"; +import { Button } from "../../../components/ui/button"; import { Card, CardContent, CardHeader, CardTitle, } from "../../../components/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "../../../components/ui/dropdown-menu"; import { Table, TableBody, @@ -16,12 +23,14 @@ import { TableHeader, TableRow, } from "../../../components/ui/table"; -import { fetchTenant, fetchUsers } from "../../../lib/adminApi"; +import { toast } from "../../../components/ui/use-toast"; +import { fetchTenant, fetchUsers, updateUser } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; function TenantUsersPage() { const params = useParams<{ tenantId: string }>(); const tenantId = params.tenantId ?? ""; + const queryClient = useQueryClient(); // 테넌트의 슬러그(tenantSlug)를 먼저 가져옴 const tenantQuery = useQuery({ @@ -39,17 +48,51 @@ function TenantUsersPage() { enabled: !!tenantSlug, }); + const removeTenantMutation = useMutation({ + mutationFn: ({ userId, slug }: { userId: string; slug: string }) => + updateUser(userId, { tenantSlug: slug, isRemoveTenant: true }), + onSuccess: () => { + toast.success(t("msg.admin.tenants.members.remove_success", "조직에서 제외되었습니다.")); + usersQuery.refetch(); + queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] }); + }, + onError: (err: any) => { + toast.error(err.response?.data?.error || t("msg.admin.tenants.members.remove_error", "제외 실패")); + }, + }); + + const handleRemoveMember = (userId: string, userName: string) => { + if (!tenantSlug) return; + if (window.confirm(t("msg.admin.tenants.members.remove_confirm", "'{{name}}'님을 이 조직에서 제외하시겠습니까?", { name: userName }))) { + removeTenantMutation.mutate({ userId, slug: tenantSlug }); + } + }; + const users = usersQuery.data?.items ?? []; return ( - + {t("ui.admin.tenants.members.title", "Tenant Members ({{count}})", { count: users.length, })} +
+ + +
@@ -69,13 +112,25 @@ function TenantUsersPage() { {t("ui.admin.tenants.members.table.status", "STATUS")} + + {t("ui.admin.tenants.members.table.actions", "ACTIONS")} + - {users.length === 0 && ( + {usersQuery.isLoading ? ( + + +
+ + {t("ui.common.loading", "Loading...")} +
+
+
+ ) : users.length === 0 ? ( {t( @@ -84,33 +139,59 @@ function TenantUsersPage() { )} + ) : ( + users.map((user) => ( + + {user.name} + +
+ + {user.email} +
+
+ + + {t( + `ui.common.role.${user.role}`, + user.role.replace("_", " "), + )} + + + + + {t(`ui.common.status.${user.status}`, user.status)} + + + + + + + + + + + + {t("ui.admin.tenants.members.view_profile", "상세 정보")} + + + handleRemoveMember(user.id, user.name)} + disabled={removeTenantMutation.isPending} + > + + {t("ui.admin.tenants.members.remove", "조직에서 제외")} + + + + +
+ )) )} - {users.map((user) => ( - - {user.name} - -
- - {user.email} -
-
- - - {t( - `ui.common.role.${user.role}`, - user.role.replace("_", " "), - )} - - - - - {t(`ui.common.status.${user.status}`, user.status)} - - -
- ))}
diff --git a/adminfront/src/features/users/UserCreatePage.tsx b/adminfront/src/features/users/UserCreatePage.tsx index 5900db79..ad6e061d 100644 --- a/adminfront/src/features/users/UserCreatePage.tsx +++ b/adminfront/src/features/users/UserCreatePage.tsx @@ -11,7 +11,7 @@ import { } from "lucide-react"; import * as React from "react"; import { useForm } from "react-hook-form"; -import { Link, useNavigate } from "react-router-dom"; +import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { Button } from "../../components/ui/button"; import { Card, @@ -104,6 +104,7 @@ function createEmptyAppointment(): AppointmentDraft { function UserCreatePage() { const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const queryClient = useQueryClient(); const [error, setError] = React.useState(null); const [generatedPassword, setGeneratedPassword] = React.useState< @@ -144,7 +145,7 @@ function UserCreatePage() { password: "", name: "", phone: "", - tenantSlug: "", + tenantSlug: searchParams.get("tenantSlug") ?? "", department: "", position: "", jobTitle: "", diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx index 877b28cb..066352f8 100644 --- a/adminfront/src/features/users/UserListPage.tsx +++ b/adminfront/src/features/users/UserListPage.tsx @@ -1,9 +1,12 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { + ChevronDown, ChevronLeft, ChevronRight, + ChevronUp, FileDown, + Loader2, Pencil, Plus, RefreshCw, @@ -11,9 +14,10 @@ import { Settings2, Trash2, User, + UserPlus, } from "lucide-react"; import * as React from "react"; -import { Link, useNavigate } from "react-router-dom"; +import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { Button } from "../../components/ui/button"; import { Card, @@ -71,6 +75,8 @@ type UserSchemaField = { 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(""); @@ -79,6 +85,8 @@ function UserListPage() { Record >({}); const [selectedUserIds, setSelectedUserIds] = React.useState([]); + const [sortKey, setSortKey] = React.useState("name"); + const [sortOrder, setSortOrder] = React.useState<"asc" | "desc">("asc"); const limit = 1000; const offset = (page - 1) * limit; @@ -151,6 +159,15 @@ 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), @@ -184,6 +201,41 @@ 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); @@ -210,6 +262,20 @@ 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 total = query.data?.total ?? 0; const totalPages = Math.ceil(total / limit); @@ -414,24 +480,29 @@ function UserListPage() {
-
+
- - setSearchDraft(e.target.value)} - onKeyDown={handleKeyDown} - /> + +
+ + setSearchDraft(e.target.value)} + onKeyDown={handleKeyDown} + /> +
-
- - {t("ui.admin.users.list.filter.tenant", "테넌트 필터:")} +
+ + {t("ui.admin.users.list.filter.tenant", "테넌트 필터")} setSearch(e.target.value)} - /> -
+
+
+ + setSearch(e.target.value)} + />
diff --git a/adminfront/src/features/users/UserListPage.tsx b/adminfront/src/features/users/UserListPage.tsx index 105ca53f..6dde20e9 100644 --- a/adminfront/src/features/users/UserListPage.tsx +++ b/adminfront/src/features/users/UserListPage.tsx @@ -465,12 +465,12 @@ function UserListPage() { - -
- + +
+ {t("ui.admin.users.list.registry.title", "사용자 레지스트리")} - + {t( "msg.admin.users.list.registry.count", "총 {{count}}명의 사용자가 등록되어 있습니다.", @@ -479,32 +479,27 @@ function UserListPage() {
-
-
- -
- - setSearchDraft(e.target.value)} - onKeyDown={handleKeyDown} - /> -
+
+
+ + setSearchDraft(e.target.value)} + onKeyDown={handleKeyDown} + />
-
- +
+ {t("ui.admin.users.list.filter.tenant", "테넌트 필터")} setUserSearchTerm(e.target.value)} + /> +
+ +
+ {usersQuery.isLoading ? ( +
+ {t("ui.common.loading", "로딩 중...")} +
+ ) : ( + users + .filter((u) => { + const term = userSearchTerm.toLowerCase(); + return ( + u.name.toLowerCase().includes(term) || + u.email.toLowerCase().includes(term) + ); + }) + .filter( + (u) => + !currentGroup?.members?.some((m) => m.id === u.id), + ) // Exclude existing members + .map((user) => ( +
+
+

{user.name}

+

+ {user.email} +

+
+ +
+ )) + )} + {users.length > 0 && + users.filter( + (u) => !currentGroup?.members?.some((m) => m.id === u.id), + ).length === 0 && ( +
+ {t("msg.admin.groups.members.all_added", "모든 테넌트 멤버가 이미 이 그룹에 속해 있습니다.")} +
+ )} +
+
+
+ + + + + + + {/* Move Member Modal */} + { + setIsMoveMemberModalOpen(val); + if (!val) { + setMemberActionTargetUserId(null); + setGroupSearchTerm(""); + } + }} + > + + + + {t("ui.admin.groups.members.move_modal_title", "부서 이동")} + + + {t( + "msg.admin.groups.members.move_modal_desc", + "선택한 멤버를 이동할 대상 그룹을 선택하세요.", + )} + + +
+
+ + setGroupSearchTerm(e.target.value)} + /> +
+ +
+ {groupsQuery.isLoading ? ( +
+ {t("ui.common.loading", "로딩 중...")} +
+ ) : groupsQuery.data && groupsQuery.data.length > 0 ? ( + groupsQuery.data + .filter((g) => + g.name + .toLowerCase() + .includes(groupSearchTerm.toLowerCase()), + ) + .filter((g) => g.id !== currentGroup?.id) // Exclude current group + .map((group) => ( +
+
+ + {group.name} +
+ +
+ )) + ) : ( +
+ {t("msg.admin.groups.list.no_results", "그룹이 없습니다.")} +
+ )} +
+
+
+ + + +
+
+ + ); + } + + export default TenantGroupsPage; From 5096930d682adfe10406173a002400d94bf21ab3 Mon Sep 17 00:00:00 2001 From: chan Date: Wed, 6 May 2026 17:47:33 +0900 Subject: [PATCH 7/8] fix: display recursive member count in TenantListPage - Replace raw memberCount with recursiveMemberCount calculated via buildTenantFullTree to correctly aggregate member counts for parent tenants (e.g., 'hanmac-family') that only contain members in their sub-tenants. --- .../tenants/routes/TenantListPage.tsx | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index 43cec0d5..9cdbe340 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -61,6 +61,7 @@ import { 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"; @@ -211,8 +212,25 @@ 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 flatMap = new Map(); + const flatten = (nodes: TenantNode[]) => { + for (const node of nodes) { + flatMap.set(node.id, node); + flatten(node.children); + } + }; + flatten(subTree); + + // Map back to allTenants ensuring recursiveMemberCount is present + return allTenants.map((t) => flatMap.get(t.id) ?? { ...t, children: [], recursiveMemberCount: t.memberCount || 0 }); + }, [allTenants]); + const tenants = React.useMemo(() => { - let filtered = allTenants; + let filtered = tenantsWithRecursiveCount; if (search.trim()) { const term = search.toLowerCase(); filtered = filtered.filter( @@ -232,7 +250,7 @@ function TenantListPage() { if (valA > valB) return sortOrder === "asc" ? 1 : -1; return 0; }); - }, [allTenants, search, sortKey, sortOrder]); + }, [tenantsWithRecursiveCount, search, sortKey, sortOrder]); const deletableTenants = React.useMemo( () => tenants.filter((tenant) => !isSeedTenant(tenant)), @@ -701,9 +719,8 @@ function TenantListPage() { - {tenant.memberCount} - - + {(tenant as unknown as TenantNode).recursiveMemberCount ?? tenant.memberCount} + {tenant.updatedAt ? new Date(tenant.updatedAt).toLocaleString("ko-KR") : "-"} From c398237c35b77017cf30dc2a9cbbe492f24e9a5f Mon Sep 17 00:00:00 2001 From: chan Date: Thu, 7 May 2026 13:50:13 +0900 Subject: [PATCH 8/8] 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} )} -