diff --git a/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx b/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx
index bc2c22fa..da214dc2 100644
--- a/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx
+++ b/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx
@@ -2,13 +2,16 @@ import {
type UseMutationResult,
useMutation,
useQuery,
+ useQueryClient,
} from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
+ ArrowRightLeft,
ChevronDown,
ChevronRight,
Plus,
RefreshCw,
+ Search,
Shield,
Trash2,
UserMinus,
@@ -27,8 +30,18 @@ import {
CardHeader,
CardTitle,
} from "../../../components/ui/card";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
+import { ScrollArea } from "../../../components/ui/scroll-area";
import {
Table,
TableBody,
@@ -44,6 +57,8 @@ import {
createGroup,
deleteGroup,
fetchGroups,
+ fetchTenant,
+ fetchUsers,
removeGroupMember,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
@@ -223,6 +238,7 @@ const UserGroupTreeNode: React.FC = ({
function TenantGroupsPage() {
const params = useParams<{ tenantId: string }>();
const tenantId = params.tenantId ?? "";
+ const queryClient = useQueryClient();
const [newGroupName, setNewGroupName] = useState("");
const [newGroupDesc, setNewGroupNameDesc] = useState("");
@@ -231,13 +247,35 @@ function TenantGroupsPage() {
const [selectedGroupId, setSelectedGroupId] = useState(null);
+ // Modal States
+ const [isAddMemberModalOpen, setIsAddMemberModalOpen] = useState(false);
+ const [isMoveMemberModalOpen, setIsMoveMemberModalOpen] = useState(false);
+ const [memberActionTargetUserId, setMemberActionTargetUserId] = useState(null);
+ const [userSearchTerm, setUserSearchTerm] = useState("");
+ const [groupSearchTerm, setGroupSearchTerm] = useState("");
+
+ // 테넌트 정보 조회 (slug 획득)
+ const tenantQuery = useQuery({
+ queryKey: ["tenant", tenantId],
+ queryFn: () => fetchTenant(tenantId),
+ enabled: tenantId.length > 0,
+ });
+ const tenantSlug = tenantQuery.data?.slug;
+
+ // 해당 테넌트의 사용자 목록 조회
+ const usersQuery = useQuery({
+ queryKey: ["users", { tenantSlug }],
+ queryFn: () => fetchUsers(1000, 0, undefined, tenantSlug),
+ enabled: !!tenantSlug,
+ });
+ const users = usersQuery.data?.items ?? [];
+
// 그룹 목록 조회
const groupsQuery = useQuery({
queryKey: ["groups", tenantId],
queryFn: () => fetchGroups(tenantId),
enabled: tenantId.length > 0,
});
-
// 그룹 생성
const createMutation = useMutation({
mutationFn: () =>
@@ -318,32 +356,48 @@ function TenantGroupsPage() {
},
});
+ // 멤버 이동 (Remove -> Add)
+ const moveMemberMutation = useMutation({
+ mutationFn: async ({
+ sourceGroupId,
+ targetGroupId,
+ userId,
+ }: {
+ sourceGroupId: string;
+ targetGroupId: string;
+ userId: string;
+ }) => {
+ await removeGroupMember(tenantId, sourceGroupId, userId);
+ await addGroupMember(tenantId, targetGroupId, userId);
+ },
+ onSuccess: () => {
+ toast.success(
+ t("msg.admin.groups.members.move_success", "멤버가 이동되었습니다."),
+ );
+ groupsQuery.refetch();
+ setIsMoveMemberModalOpen(false);
+ setMemberActionTargetUserId(null);
+ },
+ onError: (error: AxiosError<{ error?: string }>) => {
+ toast.error(t("msg.common.error", "오류 발생"), {
+ description: error.response?.data?.error || error.message,
+ });
+ },
+ });
+
const groupTree = groupsQuery.data
? buildGroupTree(groupsQuery.data, tenantId)
: [];
const handleAddSubGroup = (parentId: string) => {
setNewGroupParentId(parentId);
- // Optionally scroll to the create form or highlight it
};
-
- const handleAddMember = (groupId: string) => {
- const userId = window.prompt(
- t(
- "msg.admin.groups.prompt.user_id",
- "추가할 사용자의 UUID를 입력하세요:",
- ),
- );
- if (userId) {
- addMemberMutation.mutate({ groupId, userId });
- }
- };
-
const currentGroup = groupsQuery.data?.find((g) => g.id === selectedGroupId);
return (
-
-
+ <>
+
+
{/* 그룹 생성 폼 */}
@@ -544,7 +598,7 @@ function TenantGroupsPage() {
-
-
- )}
-
- );
-}
+
+
+ )}
+
-export default TenantGroupsPage;
+ {/* Add Member Modal */}
+
+
+ {/* Move Member Modal */}
+
+ >
+ );
+ }
+
+ export default TenantGroupsPage;
diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx
index 666ffb6f..e3459b49 100644
--- a/adminfront/src/features/tenants/routes/TenantListPage.tsx
+++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx
@@ -1,6 +1,9 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
+ ArrowDown,
+ ArrowUp,
+ ArrowUpDown,
Download,
FileSpreadsheet,
Pencil,
@@ -50,22 +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";
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 [sortConfig, setSortConfig] = React.useState(null);
const fileInputRef = React.useRef(null);
const [importMessage, setImportMessage] = React.useState("");
const [previewRows, setPreviewRows] = React.useState<
@@ -198,14 +208,79 @@ 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),
+ // 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);
+
+ // 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) {
+ recursiveCounts.set(node.id, node.recursiveMemberCount);
+ if (node.children) extractCounts(node.children);
+ }
+ };
+ extractCounts(treeResult.subTree);
+
+ let enriched = allTenants.map((t) => ({
+ ...t,
+ recursiveMemberCount: recursiveCounts.get(t.id) ?? t.memberCount ?? 0,
+ }));
+
+ if (search.trim()) {
+ const term = search.toLowerCase();
+ enriched = enriched.filter(
+ (t) =>
+ t.name.toLowerCase().includes(term) ||
+ t.slug.toLowerCase().includes(term),
+ );
+ }
+
+ 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" ? (
+
+ ) : (
+
);
- }, [allTenants, search]);
+ };
const deletableTenants = React.useMemo(
() => tenants.filter((tenant) => !isSeedTenant(tenant)),
@@ -358,6 +433,19 @@ function TenantListPage() {
+
+
+ setSearch(e.target.value)}
+ />
+
+
{selectedIds.length > 0 && (
-
-
-
- setSearch(e.target.value)}
- />
-
-
-
{(errorMsg || fallbackError) && (
{errorMsg ?? fallbackError}
@@ -498,26 +571,68 @@ function TenantListPage() {
}
/>
-
- {t("ui.admin.tenants.table.id", "ID")}
+ requestSort("id")}
+ >
+
+ {t("ui.admin.tenants.table.id", "ID")}
+ {getSortIcon("id")}
+
-
- {t("ui.admin.tenants.table.name", "NAME")}
+ requestSort("name")}
+ >
+
+ {t("ui.admin.tenants.table.name", "NAME")}
+ {getSortIcon("name")}
+
-
- {t("ui.admin.tenants.table.type", "TYPE")}
+ requestSort("type")}
+ >
+
+ {t("ui.admin.tenants.table.type", "TYPE")}
+ {getSortIcon("type")}
+
-
- {t("ui.admin.tenants.table.slug", "SLUG")}
+ requestSort("slug")}
+ >
+
+ {t("ui.admin.tenants.table.slug", "SLUG")}
+ {getSortIcon("slug")}
+
-
- {t("ui.admin.tenants.table.status", "STATUS")}
+ requestSort("status")}
+ >
+
+ {t("ui.admin.tenants.table.status", "STATUS")}
+ {getSortIcon("status")}
+
-
- {t("ui.admin.tenants.table.members", "MEMBERS")}
+ requestSort("recursiveMemberCount")}
+ >
+
+ {t("ui.admin.tenants.table.members", "MEMBERS")}
+ {getSortIcon("recursiveMemberCount")}
+
-
- {t("ui.admin.tenants.table.updated", "UPDATED")}
+ requestSort("updatedAt")}
+ >
+
+ {t("ui.admin.tenants.table.updated", "UPDATED")}
+ {getSortIcon("updatedAt")}
+
{t("ui.admin.tenants.table.actions", "ACTIONS")}
@@ -602,8 +717,8 @@ function TenantListPage() {
)}
-
- {tenant.memberCount}
+
+ {tenant.recursiveMemberCount}
{tenant.updatedAt
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,
})}
+
+
+
+
+ {t("ui.admin.tenants.members.add_existing", "기존 멤버 배정")}
+
+
+
+
+
+ {t("ui.admin.tenants.members.create_new", "신규 멤버 생성")}
+
+
+
@@ -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/user-groups/routes/TenantUserGroupsTab.tsx b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx
index 42f88448..f6aa4060 100644
--- a/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx
+++ b/adminfront/src/features/user-groups/routes/TenantUserGroupsTab.tsx
@@ -195,7 +195,9 @@ const SidebarNode: React.FC<{
const MemberTable: React.FC<{
tenantSlug: string;
onRefreshTrigger?: number;
-}> = ({ tenantSlug, onRefreshTrigger }) => {
+ allTenants?: TenantSummary[];
+}> = ({ tenantSlug, onRefreshTrigger, allTenants }) => {
+ const queryClient = useQueryClient();
const { data, isLoading, refetch } = useQuery({
queryKey: ["tenant-members-v2", tenantSlug, onRefreshTrigger],
queryFn: () => fetchUsers(100, 0, undefined, tenantSlug),
@@ -204,6 +206,54 @@ const MemberTable: React.FC<{
const members = data?.items ?? [];
+ const [isMoveOpen, setIsMoveOpen] = useState(false);
+ const [selectedUser, setSelectedUser] = useState(null);
+ const [targetTenantSlug, setTargetTenantSlug] = useState("");
+ const [searchTenant, setSearchTenant] = useState("");
+
+ const moveMutation = useMutation({
+ mutationFn: (newSlug: string) => {
+ if (!selectedUser) throw new Error("No user selected");
+ return updateUser(selectedUser.id, { tenantSlug: newSlug });
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
+ toast.success(
+ t("msg.info.saved_success", "사용자 조직이 변경되었습니다."),
+ );
+ setIsMoveOpen(false);
+ setSelectedUser(null);
+ refetch();
+ },
+ onError: () => toast.error(t("msg.common.error", "오류가 발생했습니다.")),
+ });
+
+ const removeMutation = useMutation({
+ mutationFn: (userId: string) => updateUser(userId, { tenantSlug: "" }),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
+ toast.success(t("msg.info.saved_success", "조직에서 제외되었습니다."));
+ refetch();
+ },
+ onError: () => toast.error(t("msg.common.error", "오류가 발생했습니다.")),
+ });
+
+ const handleMoveClick = (user: UserSummary) => {
+ setSelectedUser(user);
+ setTargetTenantSlug("");
+ setIsMoveOpen(true);
+ };
+
+ const filteredTenants = React.useMemo(() => {
+ if (!allTenants) return [];
+ if (!searchTenant) return allTenants;
+ return allTenants.filter(
+ (t) =>
+ t.name.toLowerCase().includes(searchTenant.toLowerCase()) ||
+ t.slug.toLowerCase().includes(searchTenant.toLowerCase()),
+ );
+ }, [allTenants, searchTenant]);
+
if (isLoading)
return (
@@ -264,6 +314,28 @@ const MemberTable: React.FC<{
{t("ui.common.detail", "상세보기")}
+
+
handleMoveClick(user)}>
+
+ {t("ui.common.move_org", "타 조직으로 이동")}
+
+
{
+ if (
+ window.confirm(
+ t(
+ "msg.admin.users.confirm_remove_org",
+ "이 조직에서 사용자를 제외하시겠습니까?",
+ ),
+ )
+ ) {
+ removeMutation.mutate(user.id);
+ }
+ }}
+ >
+
+ {t("ui.common.remove_org", "조직에서 제외")}
+
@@ -271,6 +343,65 @@ const MemberTable: React.FC<{
))}
+
+
);
};
@@ -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 5900db79..8f2e1725 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/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}
)}
-
+
+
+
+ setSearchDraft(e.target.value)}
+ onKeyDown={handleKeyDown}
+ />
+
+
+
+
+
+ {t("ui.common.search", "검색")}
+
+
+
query.refetch()}
disabled={query.isFetching}
>
@@ -335,7 +445,7 @@ function UserListPage() {
query.refetch()} />
-
+
{t("ui.admin.users.list.add", "사용자 추가")}
@@ -414,48 +524,6 @@ function UserListPage() {
-
-
-
- setSearchDraft(e.target.value)}
- onKeyDown={handleKeyDown}
- />
-
-
-
-
- {t("ui.admin.users.list.filter.tenant", "테넌트 필터:")}
-
-
-
-
-
- {t("ui.common.search", "검색")}
-
-
-
{(errorMsg || fallbackError) && (
{errorMsg ?? fallbackError}
@@ -478,32 +546,72 @@ function UserListPage() {
onChange={toggleSelectAll}
/>
-
- {t("ui.admin.users.list.table.id", "ID")}
+ requestSort("id")}
+ >
+
+ {t("ui.admin.users.list.table.id", "ID")}
+ {getSortIcon("id")}
+
-
- {t(
- "ui.admin.users.list.table.name_email",
- "이름 / 이메일 / 전화번호",
- )}
+ requestSort("name_email")}
+ >
+
+ {t(
+ "ui.admin.users.list.table.name_email",
+ "이름 / 이메일 / 전화번호",
+ )}
+ {getSortIcon("name_email")}
+
-
- {t("ui.admin.users.list.table.status", "STATUS")}
+ requestSort("status")}
+ >
+
+ {t("ui.admin.users.list.table.status", "STATUS")}
+ {getSortIcon("status")}
+
+
+ requestSort("tenant_dept")}
+ >
+
+ {t(
+ "ui.admin.users.list.table.tenant_dept",
+ "TENANT / DEPT",
+ )}
+ {getSortIcon("tenant_dept")}
+
{/* Dynamic Columns from Schema */}
{userSchema.map(
(field) =>
visibleColumns[field.key] !== false && (
-
- {field.label}
+ requestSort(field.key)}
+ >
+
+ {field.label}
+ {getSortIcon(field.key)}
+
),
)}
-
- {t("ui.admin.users.list.table.created", "CREATED")}
-
-
- {t("ui.admin.users.list.table.actions", "ACTIONS")}
+ requestSort("createdAt")}
+ >
+
+ {t("ui.admin.users.list.table.created", "CREATED")}
+ {getSortIcon("createdAt")}
+
@@ -563,14 +671,16 @@ function UserListPage() {
-
-
-
- {user.name}
+
+ {user.name}
+
{" "}
{user.email}
@@ -583,43 +693,45 @@ function UserListPage() {
)}
-
+ {" "}
-
+ aria-label={t(
+ "ui.admin.users.list.toggle_status",
+ "{{name}} 활성 상태",
+ { name: user.name },
+ )}
+ data-testid={`user-status-toggle-${user.id}`}
+ />
+
+ {t(`ui.common.status.${user.status}`, user.status)}
+
+
+
+
+
+
+ {user.tenant?.name ||
+ user.companyCode ||
+ t("ui.common.unassigned", "미배정")}
+
+ {user.department && (
+
+ {user.department}
+
+ )}
{/* Dynamic Metadata Cells */}
@@ -634,37 +746,6 @@ function UserListPage() {
{new Date(user.createdAt).toLocaleDateString()}
-
-
-
navigate(`/users/${user.id}`)}
- >
-
-
-
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
- }
- >
-
-
-
-
))}
@@ -702,24 +783,6 @@ function UserListPage() {
>
{t("ui.common.status.inactive", "비활성화")}
- handleBulkStatusChange("suspended")}
- data-testid="bulk-suspended-btn"
- >
- {t("ui.common.status.suspended", "정지")}
-
- handleBulkStatusChange("leave_of_absence")}
- data-testid="bulk-leave-of-absence-btn"
- >
- {t("ui.common.status.leave_of_absence", "휴직")}
-
0 {
+ traits["companyCode"] = existingCodes[0]
+ if h.TenantService != nil {
+ if t, err := h.TenantService.GetTenantBySlug(c.Context(), existingCodes[0]); err == nil && t != nil {
+ traits["tenant_id"] = t.ID
+ }
+ }
+ } else {
+ traits["companyCode"] = ""
+ traits["tenant_id"] = ""
+ }
+ }
+ } else {
+ // Normal update: replace primary company code and ensure it's in existingCodes
+ traits["companyCode"] = code
+ // Resolve TenantID for Kratos Trait
+ if h.TenantService != nil && code != "" {
+ if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), code); err == nil && tenant != nil {
+ traits["tenant_id"] = tenant.ID
+ }
+ }
+
+ found := false
+ for _, existing := range existingCodes {
+ if existing == code {
+ found = true
+ break
+ }
+ }
+ if !found && code != "" {
+ existingCodes = append(existingCodes, code)
}
- }
- if !found && code != "" {
- existingCodes = append(existingCodes, code)
}
}
// Deduplicate and save back companyCodes
- var uniqueCodes []string
+ var codesToSave []string
seenCodes := map[string]bool{}
for _, c := range existingCodes {
if !seenCodes[c] && c != "" {
seenCodes[c] = true
- uniqueCodes = append(uniqueCodes, c)
+ codesToSave = append(codesToSave, c)
}
}
- if len(uniqueCodes) > 0 {
- traits["companyCodes"] = uniqueCodes
+ if len(codesToSave) > 0 {
+ traits["companyCodes"] = codesToSave
+ } else {
+ delete(traits, "companyCodes")
}
if req.Department != nil {
@@ -1927,6 +1987,17 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
UpdatedAt: identity.UpdatedAt,
}
+ // [New] Sync multi-tenant codes
+ if codes, ok := traits["companyCodes"].([]interface{}); ok {
+ for _, v := range codes {
+ if str, ok := v.(string); ok && str != "" {
+ user.CompanyCodes = append(user.CompanyCodes, str)
+ }
+ }
+ } else if codes, ok := traits["companyCodes"].([]string); ok {
+ user.CompanyCodes = codes
+ }
+
// 1. Try to get tenant_id directly from Kratos traits first (Fastest & most reliable)
tID := extractTraitString(traits, "tenant_id")
if tID != "" {
diff --git a/backend/internal/repository/user_repository.go b/backend/internal/repository/user_repository.go
index 7e562367..6e0af6ea 100644
--- a/backend/internal/repository/user_repository.go
+++ b/backend/internal/repository/user_repository.go
@@ -5,6 +5,7 @@ import (
"context"
"strings"
+ "github.com/lib/pq"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
@@ -156,19 +157,28 @@ func (r *userRepository) CountByCompanyCodes(ctx context.Context, codes []string
}
var results []result
- // Search by company_code directly. Normalize inputs using LOWER for robust matching.
- err := r.db.WithContext(ctx).Model(&domain.User{}).
- Select("LOWER(company_code) as company_code, count(*) as count").
- Where("LOWER(company_code) IN ?", lowerStrings(codes)).
- Group("LOWER(company_code)").
- Scan(&results).Error
+ lowerCodes := lowerStrings(codes)
+
+ // Combine singular company_code and array company_codes using a subquery
+ // to ensure we count each user accurately per company code they belong to.
+ query := `
+ SELECT LOWER(comp_code) as company_code, count(DISTINCT id) as count
+ FROM (
+ SELECT id, company_code as comp_code FROM users WHERE LOWER(company_code) = ANY($1)
+ UNION ALL
+ SELECT id, unnest(company_codes) as comp_code FROM users WHERE company_codes && $1
+ ) as combined
+ WHERE LOWER(comp_code) = ANY($1)
+ GROUP BY LOWER(comp_code)
+ `
+ err := r.db.WithContext(ctx).Raw(query, pq.Array(lowerCodes)).Scan(&results).Error
if err != nil {
return nil, err
}
counts := make(map[string]int64)
for _, res := range results {
- counts[res.CompanyCode] = res.Count
+ counts[strings.ToLower(res.CompanyCode)] = res.Count
}
// Ensure all requested codes are present in results (even if count is 0)
@@ -196,15 +206,15 @@ func (r *userRepository) List(ctx context.Context, offset, limit int, search str
db := r.db.WithContext(ctx).Model(&domain.User{})
if companyCode != "" {
- // [Matrix Fix] Match users either by their primary company code OR by the slug of the department they are attached to
+ // [Matrix Fix] Match users either by their primary company code OR by being in the company_codes array OR by tenant slug
db = db.Joins("LEFT JOIN tenants ON users.tenant_id = tenants.id").
- Where("users.company_code = ? OR tenants.slug = ?", companyCode, companyCode)
+ Where("users.company_code = ? OR ? = ANY(users.company_codes) OR tenants.slug = ?", companyCode, companyCode, companyCode)
}
if search != "" {
searchTerm := "%" + search + "%"
- db = db.Where("(users.email LIKE ? OR users.name LIKE ? OR users.company_code LIKE ? OR users.metadata::text LIKE ?)",
- searchTerm, searchTerm, searchTerm, searchTerm)
+ db = db.Where("(users.email LIKE ? OR users.name LIKE ? OR users.company_code LIKE ? OR ? = ANY(users.company_codes) OR users.metadata::text LIKE ?)",
+ searchTerm, searchTerm, searchTerm, search, searchTerm)
}
if err := db.Count(&total).Error; err != nil {