From 5f9a61de98a534e5ae6094445a844c02bf42ed41 Mon Sep 17 00:00:00 2001 From: chan Date: Wed, 6 May 2026 14:20:35 +0900 Subject: [PATCH] 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", "테넌트 필터")}