1
0
forked from baron/baron-sso

Merge pull request 'feature/multi-tenant-and-ui-improvements' (#718) from feature/multi-tenant-and-ui-improvements into dev

Reviewed-on: baron/baron-sso#718
This commit is contained in:
2026-05-07 13:53:45 +09:00
14 changed files with 1189 additions and 477 deletions

View File

@@ -2,13 +2,16 @@ import {
type UseMutationResult, type UseMutationResult,
useMutation, useMutation,
useQuery, useQuery,
useQueryClient,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import type { AxiosError } from "axios"; import type { AxiosError } from "axios";
import { import {
ArrowRightLeft,
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
Plus, Plus,
RefreshCw, RefreshCw,
Search,
Shield, Shield,
Trash2, Trash2,
UserMinus, UserMinus,
@@ -27,8 +30,18 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "../../../components/ui/card"; } from "../../../components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input"; import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label"; import { Label } from "../../../components/ui/label";
import { ScrollArea } from "../../../components/ui/scroll-area";
import { import {
Table, Table,
TableBody, TableBody,
@@ -44,6 +57,8 @@ import {
createGroup, createGroup,
deleteGroup, deleteGroup,
fetchGroups, fetchGroups,
fetchTenant,
fetchUsers,
removeGroupMember, removeGroupMember,
} from "../../../lib/adminApi"; } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";
@@ -223,6 +238,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
function TenantGroupsPage() { function TenantGroupsPage() {
const params = useParams<{ tenantId: string }>(); const params = useParams<{ tenantId: string }>();
const tenantId = params.tenantId ?? ""; const tenantId = params.tenantId ?? "";
const queryClient = useQueryClient();
const [newGroupName, setNewGroupName] = useState(""); const [newGroupName, setNewGroupName] = useState("");
const [newGroupDesc, setNewGroupNameDesc] = useState(""); const [newGroupDesc, setNewGroupNameDesc] = useState("");
@@ -231,13 +247,35 @@ function TenantGroupsPage() {
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null); const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
// Modal States
const [isAddMemberModalOpen, setIsAddMemberModalOpen] = useState(false);
const [isMoveMemberModalOpen, setIsMoveMemberModalOpen] = useState(false);
const [memberActionTargetUserId, setMemberActionTargetUserId] = useState<string | null>(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({ const groupsQuery = useQuery({
queryKey: ["groups", tenantId], queryKey: ["groups", tenantId],
queryFn: () => fetchGroups(tenantId), queryFn: () => fetchGroups(tenantId),
enabled: tenantId.length > 0, enabled: tenantId.length > 0,
}); });
// 그룹 생성 // 그룹 생성
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: () => mutationFn: () =>
@@ -318,30 +356,46 @@ 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 const groupTree = groupsQuery.data
? buildGroupTree(groupsQuery.data, tenantId) ? buildGroupTree(groupsQuery.data, tenantId)
: []; : [];
const handleAddSubGroup = (parentId: string) => { const handleAddSubGroup = (parentId: string) => {
setNewGroupParentId(parentId); 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); const currentGroup = groupsQuery.data?.find((g) => g.id === selectedGroupId);
return ( return (
<>
<div className="space-y-6 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]"> <div className="space-y-6 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<div className="grid gap-6 md:grid-cols-3 flex-1 min-h-0"> <div className="grid gap-6 md:grid-cols-3 flex-1 min-h-0">
{/* 그룹 생성 폼 */} {/* 그룹 생성 폼 */}
@@ -544,7 +598,7 @@ function TenantGroupsPage() {
<div className="flex justify-end mb-4 flex-shrink-0"> <div className="flex justify-end mb-4 flex-shrink-0">
<Button <Button
size="sm" size="sm"
onClick={() => handleAddMember(currentGroup.id)} onClick={() => setIsAddMemberModalOpen(true)}
disabled={addMemberMutation.isPending} disabled={addMemberMutation.isPending}
> >
<UserPlus size={14} className="mr-1" /> <UserPlus size={14} className="mr-1" />
@@ -563,7 +617,7 @@ function TenantGroupsPage() {
{t("ui.admin.groups.members.table.email", "이메일")} {t("ui.admin.groups.members.table.email", "이메일")}
</TableHead> </TableHead>
<TableHead className="text-right"> <TableHead className="text-right">
{t("ui.admin.groups.members.table.remove", "제거")} {t("ui.admin.groups.members.table.actions", "관리")}
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -590,19 +644,44 @@ function TenantGroupsPage() {
{user.email} {user.email}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => onClick={() => {
setMemberActionTargetUserId(user.id);
setIsMoveMemberModalOpen(true);
}}
disabled={moveMemberMutation.isPending}
title={t("ui.common.move", "이동")}
>
<ArrowRightLeft size={14} className="text-primary" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (
window.confirm(
t(
"msg.admin.groups.members.remove_confirm",
"'{{name}}' 님을 이 그룹에서 제외하시겠습니까?",
{ name: user.name },
),
)
) {
removeMemberMutation.mutate({ removeMemberMutation.mutate({
groupId: currentGroup.id, groupId: currentGroup.id,
userId: user.id, userId: user.id,
}) });
} }
}}
disabled={removeMemberMutation.isPending} disabled={removeMemberMutation.isPending}
title={t("ui.common.remove", "제거")}
> >
<UserMinus size={14} className="text-destructive" /> <UserMinus size={14} className="text-destructive" />
</Button> </Button>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
@@ -614,7 +693,201 @@ function TenantGroupsPage() {
</Card> </Card>
)} )}
</div> </div>
);
}
export default TenantGroupsPage; {/* Add Member Modal */}
<Dialog
open={isAddMemberModalOpen}
onOpenChange={(val) => {
setIsAddMemberModalOpen(val);
if (!val) setUserSearchTerm("");
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{t("ui.admin.groups.members.add_modal_title", "그룹에 멤버 추가")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.groups.members.add_modal_desc",
"이 테넌트에 속한 사용자 중 추가할 멤버를 검색하여 선택하세요.",
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t("ui.common.search", "검색...")}
className="pl-9 h-9"
value={userSearchTerm}
onChange={(e) => setUserSearchTerm(e.target.value)}
/>
</div>
<ScrollArea className="h-[250px] rounded-md border p-2">
<div className="space-y-1">
{usersQuery.isLoading ? (
<div className="p-4 text-center text-sm text-muted-foreground">
{t("ui.common.loading", "로딩 중...")}
</div>
) : (
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) => (
<div
key={user.id}
className="flex items-center justify-between rounded-lg px-3 py-2 text-sm transition hover:bg-muted"
>
<div>
<p className="font-medium">{user.name}</p>
<p className="text-xs text-muted-foreground">
{user.email}
</p>
</div>
<Button
size="sm"
variant="secondary"
onClick={() => {
if (currentGroup) {
addMemberMutation.mutate({
groupId: currentGroup.id,
userId: user.id,
});
}
}}
disabled={addMemberMutation.isPending}
>
{t("ui.common.add", "추가")}
</Button>
</div>
))
)}
{users.length > 0 &&
users.filter(
(u) => !currentGroup?.members?.some((m) => m.id === u.id),
).length === 0 && (
<div className="p-4 text-center text-sm text-muted-foreground">
{t("msg.admin.groups.members.all_added", "모든 테넌트 멤버가 이미 이 그룹에 속해 있습니다.")}
</div>
)}
</div>
</ScrollArea>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsAddMemberModalOpen(false)}
>
{t("ui.common.close", "닫기")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Move Member Modal */}
<Dialog
open={isMoveMemberModalOpen}
onOpenChange={(val) => {
setIsMoveMemberModalOpen(val);
if (!val) {
setMemberActionTargetUserId(null);
setGroupSearchTerm("");
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{t("ui.admin.groups.members.move_modal_title", "부서 이동")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.groups.members.move_modal_desc",
"선택한 멤버를 이동할 대상 그룹을 선택하세요.",
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t("ui.common.search_group", "그룹 검색...")}
className="pl-9 h-9"
value={groupSearchTerm}
onChange={(e) => setGroupSearchTerm(e.target.value)}
/>
</div>
<ScrollArea className="h-[250px] rounded-md border p-2">
<div className="space-y-1">
{groupsQuery.isLoading ? (
<div className="p-4 text-center text-sm text-muted-foreground">
{t("ui.common.loading", "로딩 중...")}
</div>
) : 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) => (
<div
key={group.id}
className="flex items-center justify-between rounded-lg px-3 py-2 text-sm transition hover:bg-muted"
>
<div className="flex items-center gap-2">
<Users size={14} className="text-muted-foreground" />
<span className="font-medium">{group.name}</span>
</div>
<Button
size="sm"
variant="outline"
onClick={() => {
if (currentGroup && memberActionTargetUserId) {
moveMemberMutation.mutate({
sourceGroupId: currentGroup.id,
targetGroupId: group.id,
userId: memberActionTargetUserId,
});
}
}}
disabled={moveMemberMutation.isPending}
>
{t("ui.common.move", "이동")}
</Button>
</div>
))
) : (
<div className="p-4 text-center text-sm text-muted-foreground">
{t("msg.admin.groups.list.no_results", "그룹이 없습니다.")}
</div>
)}
</div>
</ScrollArea>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsMoveMemberModalOpen(false)}
>
{t("ui.common.cancel", "취소")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
export default TenantGroupsPage;

View File

@@ -1,6 +1,9 @@
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios"; import type { AxiosError } from "axios";
import { import {
ArrowDown,
ArrowUp,
ArrowUpDown,
Download, Download,
FileSpreadsheet, FileSpreadsheet,
Pencil, Pencil,
@@ -50,22 +53,29 @@ import {
importTenantsCSV, importTenantsCSV,
} from "../../../lib/adminApi"; } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
import { isSeedTenant } from "../utils/protectedTenants";
import { import {
type TenantImportResolution,
type TenantImportPreviewRow, type TenantImportPreviewRow,
type TenantImportResolution,
buildTenantImportPreview, buildTenantImportPreview,
parseTenantCSV, parseTenantCSV,
serializeTenantImportCSV, serializeTenantImportCSV,
} from "../utils/tenantCsvImport"; } from "../utils/tenantCsvImport";
import { isSeedTenant } from "../utils/protectedTenants";
const tenantCSVTemplate = const tenantCSVTemplate =
"name,type,parent_tenant_slug,slug,memo,email_domain\n"; "name,type,parent_tenant_slug,slug,memo,email_domain\n";
type SortConfig = {
key: keyof TenantSummary | "recursiveMemberCount";
direction: "asc" | "desc";
};
function TenantListPage() { function TenantListPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [selectedIds, setSelectedIds] = React.useState<string[]>([]); const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
const [search, setSearch] = React.useState(""); const [search, setSearch] = React.useState("");
const [sortConfig, setSortConfig] = React.useState<SortConfig | null>(null);
const fileInputRef = React.useRef<HTMLInputElement | null>(null); const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const [importMessage, setImportMessage] = React.useState(""); const [importMessage, setImportMessage] = React.useState("");
const [previewRows, setPreviewRows] = React.useState< const [previewRows, setPreviewRows] = React.useState<
@@ -198,14 +208,79 @@ function TenantListPage() {
const allTenants = query.data?.items ?? []; const allTenants = query.data?.items ?? [];
const tenants = React.useMemo(() => { const tenants = React.useMemo(() => {
if (!search.trim()) return allTenants; // 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<string, number>();
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(); const term = search.toLowerCase();
return allTenants.filter( enriched = enriched.filter(
(t) => (t) =>
t.name.toLowerCase().includes(term) || t.name.toLowerCase().includes(term) ||
t.slug.toLowerCase().includes(term), t.slug.toLowerCase().includes(term),
); );
}, [allTenants, search]); }
if (sortConfig) {
enriched.sort((a, b) => {
const aValue = a[sortConfig.key as keyof typeof a];
const bValue = b[sortConfig.key as keyof typeof b];
if (aValue === bValue) return 0;
if (aValue === null || aValue === undefined) return 1;
if (bValue === null || bValue === undefined) return -1;
if (sortConfig.direction === "asc") {
return aValue < bValue ? -1 : 1;
}
return aValue > bValue ? -1 : 1;
});
}
return enriched;
}, [allTenants, search, sortConfig]);
const requestSort = (key: SortConfig["key"]) => {
let direction: "asc" | "desc" = "asc";
if (
sortConfig &&
sortConfig.key === key &&
sortConfig.direction === "asc"
) {
direction = "desc";
}
setSortConfig({ key, direction });
};
const getSortIcon = (key: SortConfig["key"]) => {
if (!sortConfig || sortConfig.key !== key) {
return <ArrowUpDown size={14} className="ml-1 opacity-50" />;
}
return sortConfig.direction === "asc" ? (
<ArrowUp size={14} className="ml-1" />
) : (
<ArrowDown size={14} className="ml-1" />
);
};
const deletableTenants = React.useMemo( const deletableTenants = React.useMemo(
() => tenants.filter((tenant) => !isSeedTenant(tenant)), () => tenants.filter((tenant) => !isSeedTenant(tenant)),
@@ -358,6 +433,19 @@ function TenantListPage() {
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative w-64 mr-2">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t(
"ui.admin.tenants.list.search_placeholder",
"테넌트 이름 또는 슬러그 검색...",
)}
className="pl-9 h-9"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<RoleGuard roles={["super_admin"]}> <RoleGuard roles={["super_admin"]}>
{selectedIds.length > 0 && ( {selectedIds.length > 0 && (
<Button <Button
@@ -460,21 +548,6 @@ function TenantListPage() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0"> <CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className="mb-6 flex items-center gap-4 flex-shrink-0">
<div className="relative flex-1 min-w-[240px] max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t(
"ui.admin.tenants.list.search_placeholder",
"테넌트 이름 또는 슬러그 검색...",
)}
className="pl-9"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
</div>
{(errorMsg || fallbackError) && ( {(errorMsg || fallbackError) && (
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive flex-shrink-0"> <div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive flex-shrink-0">
{errorMsg ?? fallbackError} {errorMsg ?? fallbackError}
@@ -498,26 +571,68 @@ function TenantListPage() {
} }
/> />
</TableHead> </TableHead>
<TableHead className="w-[280px] whitespace-nowrap"> <TableHead
className="min-w-[220px] cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort("id")}
>
<div className="flex items-center">
{t("ui.admin.tenants.table.id", "ID")} {t("ui.admin.tenants.table.id", "ID")}
{getSortIcon("id")}
</div>
</TableHead> </TableHead>
<TableHead className="w-[220px] whitespace-nowrap"> <TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort("name")}
>
<div className="flex items-center">
{t("ui.admin.tenants.table.name", "NAME")} {t("ui.admin.tenants.table.name", "NAME")}
{getSortIcon("name")}
</div>
</TableHead> </TableHead>
<TableHead className="w-[140px] whitespace-nowrap"> <TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort("type")}
>
<div className="flex items-center">
{t("ui.admin.tenants.table.type", "TYPE")} {t("ui.admin.tenants.table.type", "TYPE")}
{getSortIcon("type")}
</div>
</TableHead> </TableHead>
<TableHead className="w-[180px] whitespace-nowrap"> <TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort("slug")}
>
<div className="flex items-center">
{t("ui.admin.tenants.table.slug", "SLUG")} {t("ui.admin.tenants.table.slug", "SLUG")}
{getSortIcon("slug")}
</div>
</TableHead> </TableHead>
<TableHead className="w-[120px] whitespace-nowrap"> <TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort("status")}
>
<div className="flex items-center">
{t("ui.admin.tenants.table.status", "STATUS")} {t("ui.admin.tenants.table.status", "STATUS")}
{getSortIcon("status")}
</div>
</TableHead> </TableHead>
<TableHead className="w-[120px] whitespace-nowrap"> <TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort("recursiveMemberCount")}
>
<div className="flex items-center">
{t("ui.admin.tenants.table.members", "MEMBERS")} {t("ui.admin.tenants.table.members", "MEMBERS")}
{getSortIcon("recursiveMemberCount")}
</div>
</TableHead> </TableHead>
<TableHead className="w-[180px] whitespace-nowrap"> <TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort("updatedAt")}
>
<div className="flex items-center">
{t("ui.admin.tenants.table.updated", "UPDATED")} {t("ui.admin.tenants.table.updated", "UPDATED")}
{getSortIcon("updatedAt")}
</div>
</TableHead> </TableHead>
<TableHead className="w-[160px] whitespace-nowrap text-right"> <TableHead className="w-[160px] whitespace-nowrap text-right">
{t("ui.admin.tenants.table.actions", "ACTIONS")} {t("ui.admin.tenants.table.actions", "ACTIONS")}
@@ -602,8 +717,8 @@ function TenantListPage() {
)} )}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="whitespace-nowrap font-medium"> <TableCell className="font-medium">
{tenant.memberCount} {tenant.recursiveMemberCount}
</TableCell> </TableCell>
<TableCell className="whitespace-nowrap text-xs"> <TableCell className="whitespace-nowrap text-xs">
{tenant.updatedAt {tenant.updatedAt

View File

@@ -1,13 +1,20 @@
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Mail, User } from "lucide-react"; import { Mail, MoreHorizontal, Plus, User, UserPlus, UserMinus, Loader2 } from "lucide-react";
import { useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { Badge } from "../../../components/ui/badge"; import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import { import {
Card, Card,
CardContent, CardContent,
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "../../../components/ui/card"; } from "../../../components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../../../components/ui/dropdown-menu";
import { import {
Table, Table,
TableBody, TableBody,
@@ -16,12 +23,14 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "../../../components/ui/table"; } 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"; import { t } from "../../../lib/i18n";
function TenantUsersPage() { function TenantUsersPage() {
const params = useParams<{ tenantId: string }>(); const params = useParams<{ tenantId: string }>();
const tenantId = params.tenantId ?? ""; const tenantId = params.tenantId ?? "";
const queryClient = useQueryClient();
// 테넌트의 슬러그(tenantSlug)를 먼저 가져옴 // 테넌트의 슬러그(tenantSlug)를 먼저 가져옴
const tenantQuery = useQuery({ const tenantQuery = useQuery({
@@ -39,17 +48,51 @@ function TenantUsersPage() {
enabled: !!tenantSlug, 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 ?? []; const users = usersQuery.data?.items ?? [];
return ( return (
<Card className="mt-6 bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden"> <Card className="mt-6 bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
<CardHeader className="flex-shrink-0"> <CardHeader className="flex-shrink-0 flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<User size={18} className="text-primary" /> <User size={18} className="text-primary" />
{t("ui.admin.tenants.members.title", "Tenant Members ({{count}})", { {t("ui.admin.tenants.members.title", "Tenant Members ({{count}})", {
count: users.length, count: users.length,
})} })}
</CardTitle> </CardTitle>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" asChild className="gap-2">
<Link to={`/users?addTenant=${tenantSlug}`}>
<UserPlus size={16} />
{t("ui.admin.tenants.members.add_existing", "기존 멤버 배정")}
</Link>
</Button>
<Button size="sm" asChild className="gap-2">
<Link to={`/users/new?tenantSlug=${tenantSlug}`}>
<Plus size={16} />
{t("ui.admin.tenants.members.create_new", "신규 멤버 생성")}
</Link>
</Button>
</div>
</CardHeader> </CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0"> <CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className="flex-1 rounded-md border overflow-hidden flex flex-col"> <div className="flex-1 rounded-md border overflow-hidden flex flex-col">
@@ -69,13 +112,25 @@ function TenantUsersPage() {
<TableHead> <TableHead>
{t("ui.admin.tenants.members.table.status", "STATUS")} {t("ui.admin.tenants.members.table.status", "STATUS")}
</TableHead> </TableHead>
<TableHead className="w-[80px] text-right">
{t("ui.admin.tenants.members.table.actions", "ACTIONS")}
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{users.length === 0 && ( {usersQuery.isLoading ? (
<TableRow>
<TableCell colSpan={5} className="text-center py-20">
<div className="flex flex-col items-center gap-2">
<Loader2 className="animate-spin text-muted-foreground" size={24} />
<span className="text-sm text-muted-foreground">{t("ui.common.loading", "Loading...")}</span>
</div>
</TableCell>
</TableRow>
) : users.length === 0 ? (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={4} colSpan={5}
className="text-center py-8 text-muted-foreground" className="text-center py-8 text-muted-foreground"
> >
{t( {t(
@@ -84,8 +139,8 @@ function TenantUsersPage() {
)} )}
</TableCell> </TableCell>
</TableRow> </TableRow>
)} ) : (
{users.map((user) => ( users.map((user) => (
<TableRow key={user.id}> <TableRow key={user.id}>
<TableCell className="font-semibold">{user.name}</TableCell> <TableCell className="font-semibold">{user.name}</TableCell>
<TableCell> <TableCell>
@@ -109,8 +164,34 @@ function TenantUsersPage() {
{t(`ui.common.status.${user.status}`, user.status)} {t(`ui.common.status.${user.status}`, user.status)}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal size={16} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link to={`/users/${user.id}`}>
<User size={14} className="mr-2" />
{t("ui.admin.tenants.members.view_profile", "상세 정보")}
</Link>
</DropdownMenuItem>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={() => handleRemoveMember(user.id, user.name)}
disabled={removeTenantMutation.isPending}
>
<UserMinus size={14} className="mr-2" />
{t("ui.admin.tenants.members.remove", "조직에서 제외")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow> </TableRow>
))} ))
)}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>

View File

@@ -195,7 +195,9 @@ const SidebarNode: React.FC<{
const MemberTable: React.FC<{ const MemberTable: React.FC<{
tenantSlug: string; tenantSlug: string;
onRefreshTrigger?: number; onRefreshTrigger?: number;
}> = ({ tenantSlug, onRefreshTrigger }) => { allTenants?: TenantSummary[];
}> = ({ tenantSlug, onRefreshTrigger, allTenants }) => {
const queryClient = useQueryClient();
const { data, isLoading, refetch } = useQuery({ const { data, isLoading, refetch } = useQuery({
queryKey: ["tenant-members-v2", tenantSlug, onRefreshTrigger], queryKey: ["tenant-members-v2", tenantSlug, onRefreshTrigger],
queryFn: () => fetchUsers(100, 0, undefined, tenantSlug), queryFn: () => fetchUsers(100, 0, undefined, tenantSlug),
@@ -204,6 +206,54 @@ const MemberTable: React.FC<{
const members = data?.items ?? []; const members = data?.items ?? [];
const [isMoveOpen, setIsMoveOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState<UserSummary | null>(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) if (isLoading)
return ( return (
<div className="py-20 text-center text-muted-foreground animate-pulse"> <div className="py-20 text-center text-muted-foreground animate-pulse">
@@ -264,6 +314,28 @@ const MemberTable: React.FC<{
{t("ui.common.detail", "상세보기")} {t("ui.common.detail", "상세보기")}
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleMoveClick(user)}>
<ArrowRight size={14} className="mr-2" />
{t("ui.common.move_org", "타 조직으로 이동")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
if (
window.confirm(
t(
"msg.admin.users.confirm_remove_org",
"이 조직에서 사용자를 제외하시겠습니까?",
),
)
) {
removeMutation.mutate(user.id);
}
}}
>
<FolderOpen size={14} className="mr-2" />
{t("ui.common.remove_org", "조직에서 제외")}
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</TableCell> </TableCell>
@@ -271,6 +343,65 @@ const MemberTable: React.FC<{
))} ))}
</TableBody> </TableBody>
</Table> </Table>
<Dialog open={isMoveOpen} onOpenChange={setIsMoveOpen}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>
{t("ui.common.move_org", "타 조직으로 이동")}
</DialogTitle>
<DialogDescription>
{selectedUser?.name} .
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<Input
placeholder={t("ui.common.search", "조직 검색...")}
value={searchTenant}
onChange={(e) => setSearchTenant(e.target.value)}
/>
<ScrollArea className="h-48 border rounded-md p-2">
<div className="space-y-1">
{filteredTenants.map((tItem) => (
<Button
key={tItem.id}
variant={
targetTenantSlug === tItem.slug ? "secondary" : "ghost"
}
className="w-full justify-start text-sm"
onClick={() => setTargetTenantSlug(tItem.slug)}
>
{React.createElement(getTenantIcon(tItem.type), {
size: 14,
className: "mr-2 opacity-70",
})}
<span>{tItem.name}</span>
<span className="ml-2 text-[10px] text-muted-foreground opacity-50">
{tItem.slug}
</span>
</Button>
))}
{filteredTenants.length === 0 && (
<div className="py-4 text-center text-sm text-muted-foreground">
{t("msg.common.no_results", "검색 결과가 없습니다.")}
</div>
)}
</div>
</ScrollArea>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsMoveOpen(false)}>
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={() => moveMutation.mutate(targetTenantSlug)}
disabled={!targetTenantSlug || moveMutation.isPending}
>
{moveMutation.isPending ? "..." : t("ui.common.move", "이동")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
}; };
@@ -574,6 +705,7 @@ function TenantUserGroupsTab() {
<MemberTable <MemberTable
tenantSlug={selectedNode.slug} tenantSlug={selectedNode.slug}
onRefreshTrigger={refreshMembersCount} onRefreshTrigger={refreshMembersCount}
allTenants={allTenantsData?.items ?? []}
/> />
</div> </div>
</div> </div>
@@ -702,6 +834,7 @@ const UserAddDialog: React.FC<{
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await updateUser(selectedUserId, { tenantSlug }); await updateUser(selectedUserId, { tenantSlug });
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
toast.success(t("msg.info.saved_success", "사용자가 배정되었습니다.")); toast.success(t("msg.info.saved_success", "사용자가 배정되었습니다."));
onOpenChange(false); onOpenChange(false);
resetFields(); resetFields();

View File

@@ -11,7 +11,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { useForm } from "react-hook-form"; 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 { Button } from "../../components/ui/button";
import { import {
Card, Card,
@@ -104,6 +104,7 @@ function createEmptyAppointment(): AppointmentDraft {
function UserCreatePage() { function UserCreatePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
const [generatedPassword, setGeneratedPassword] = React.useState< const [generatedPassword, setGeneratedPassword] = React.useState<
@@ -144,7 +145,7 @@ function UserCreatePage() {
password: "", password: "",
name: "", name: "",
phone: "", phone: "",
tenantSlug: "", tenantSlug: searchParams.get("tenantSlug") || "",
department: "", department: "",
position: "", position: "",
jobTitle: "", jobTitle: "",

View File

@@ -44,13 +44,6 @@ import {
} from "../../components/ui/dialog"; } from "../../components/ui/dialog";
import { Input } from "../../components/ui/input"; import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label"; import { Label } from "../../components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../../components/ui/select";
import { Switch } from "../../components/ui/switch"; import { Switch } from "../../components/ui/switch";
import { import {
Tabs, Tabs,
@@ -85,7 +78,6 @@ import {
parseOrgChartTenantSelection, parseOrgChartTenantSelection,
} from "./orgChartPicker"; } from "./orgChartPicker";
import type { UserSchemaField } from "./userSchemaFields"; import type { UserSchemaField } from "./userSchemaFields";
import { userStatusLabel, userStatusValues } from "./userStatus";
type UserFormValues = Omit<UserUpdateRequest, "metadata"> & { type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
metadata: Record<string, Record<string, string | number | boolean>>; metadata: Record<string, Record<string, string | number | boolean>>;
@@ -131,40 +123,12 @@ function createEmptyAppointment(): AppointmentDraft {
tenantId: "", tenantId: "",
tenantName: "", tenantName: "",
tenantSlug: "", tenantSlug: "",
isPrimary: false,
isOwner: false, isOwner: false,
jobTitle: "", jobTitle: "",
position: "", 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( function validateManualPassword(
password: string, password: string,
policy?: PasswordPolicyResponse, policy?: PasswordPolicyResponse,
@@ -521,7 +485,6 @@ function UserDetailPage() {
try { try {
const tenant = await resolveTenantSelection(selection, tenants); const tenant = await resolveTenantSelection(selection, tenants);
setAdditionalAppointments((current) => setAdditionalAppointments((current) =>
normalizePrimaryAppointments(
current.map((appointment, index) => current.map((appointment, index) =>
index === target.index index === target.index
? { ? {
@@ -532,7 +495,6 @@ function UserDetailPage() {
} }
: appointment, : appointment,
), ),
),
); );
setPickerTarget(null); setPickerTarget(null);
} catch (_) { } catch (_) {
@@ -574,30 +536,15 @@ function UserDetailPage() {
patch: Partial<UserAppointment>, patch: Partial<UserAppointment>,
) => { ) => {
setAdditionalAppointments((current) => setAdditionalAppointments((current) =>
normalizePrimaryAppointments(
current.map((appointment, currentIndex) => current.map((appointment, currentIndex) =>
currentIndex === index ? { ...appointment, ...patch } : appointment, currentIndex === index ? { ...appointment, ...patch } : appointment,
), ),
),
);
};
const setPrimaryAppointment = (index: number, checked: boolean) => {
setAdditionalAppointments((current) =>
normalizePrimaryAppointments(
current.map((appointment, currentIndex) => ({
...appointment,
isPrimary: checked && currentIndex === index,
})),
),
); );
}; };
const removeAppointment = (index: number) => { const removeAppointment = (index: number) => {
setAdditionalAppointments((current) => setAdditionalAppointments((current) =>
normalizePrimaryAppointments(
current.filter((_, currentIndex) => currentIndex !== index), current.filter((_, currentIndex) => currentIndex !== index),
),
); );
}; };
@@ -655,10 +602,7 @@ function UserDetailPage() {
tenantSlug: tenantSlug:
user.companyCode || user.companyCode ||
user.joinedTenants?.find( user.joinedTenants?.find(
(t) => (t) => t.type === "COMPANY" || t.type === "COMPANY_GROUP",
t.type === "COMPANY" ||
t.type === "COMPANY_GROUP" ||
t.type === "ORGANIZATION",
)?.slug || )?.slug ||
"", "",
department: user.department || "", department: user.department || "",
@@ -692,13 +636,9 @@ function UserDetailPage() {
isHanmacFamilyTenant(tenant, tenants, hanmacFamilyTenantId), isHanmacFamilyTenant(tenant, tenants, hanmacFamilyTenantId),
); );
setAdditionalAppointments( setAdditionalAppointments(
normalizePrimaryAppointments(
Array.isArray(rawAppointments) Array.isArray(rawAppointments)
? (rawAppointments as UserAppointment[]).map((appointment) => ({ ? (rawAppointments as UserAppointment[]).map((appointment) => ({
...appointment, ...appointment,
isPrimary:
appointment.isPrimary === true ||
appointment.tenantId === metadata.primaryTenantId,
draftId: createDraftId(), draftId: createDraftId(),
})) }))
: isUserHanmacFamily : isUserHanmacFamily
@@ -708,7 +648,6 @@ function UserDetailPage() {
tenantId: tenant.id, tenantId: tenant.id,
tenantName: tenant.name, tenantName: tenant.name,
tenantSlug: tenant.slug, tenantSlug: tenant.slug,
isPrimary: tenant.id === fallbackAppointment?.id,
isOwner: isOwner:
metadata.primaryTenantIsOwner === true && metadata.primaryTenantIsOwner === true &&
tenant.id === fallbackAppointment?.id, tenant.id === fallbackAppointment?.id,
@@ -722,7 +661,6 @@ function UserDetailPage() {
tenantId: fallbackAppointment.id, tenantId: fallbackAppointment.id,
tenantName: fallbackAppointment.name, tenantName: fallbackAppointment.name,
tenantSlug: fallbackAppointment.slug, tenantSlug: fallbackAppointment.slug,
isPrimary: true,
isOwner: metadata.primaryTenantIsOwner === true, isOwner: metadata.primaryTenantIsOwner === true,
jobTitle: user.jobTitle, jobTitle: user.jobTitle,
position: user.position, position: user.position,
@@ -730,7 +668,6 @@ function UserDetailPage() {
] ]
: [] : []
: [], : [],
),
); );
} }
}, [hanmacFamilyTenantId, tenants, user, reset]); }, [hanmacFamilyTenantId, tenants, user, reset]);
@@ -811,37 +748,19 @@ function UserDetailPage() {
tenantId: appointment.tenantId, tenantId: appointment.tenantId,
tenantSlug: appointment.tenantSlug, tenantSlug: appointment.tenantSlug,
tenantName: appointment.tenantName, tenantName: appointment.tenantName,
isPrimary: appointment.isPrimary === true,
isOwner: appointment.isOwner, isOwner: appointment.isOwner,
jobTitle: appointment.jobTitle, jobTitle: appointment.jobTitle,
position: appointment.position, position: appointment.position,
})); }));
const primaryAppointment = appointments.find(
(appointment) => appointment.isPrimary,
);
payload.tenantSlug = undefined; payload.tenantSlug = undefined;
payload.department = undefined; payload.department = undefined;
payload.position = undefined; payload.position = undefined;
payload.jobTitle = undefined; payload.jobTitle = undefined;
payload.additionalAppointments = appointments; payload.additionalAppointments = appointments;
if (primaryAppointment) {
payload.tenantSlug = primaryAppointment.tenantSlug;
payload.primaryTenantId = primaryAppointment.tenantId;
payload.primaryTenantName = primaryAppointment.tenantName;
payload.primaryTenantIsOwner = primaryAppointment.isOwner;
}
payload.metadata = { payload.metadata = {
...metadata, ...metadata,
additionalAppointments: appointments, additionalAppointments: appointments,
...(primaryAppointment
? {
primaryTenantId: primaryAppointment.tenantId,
primaryTenantName: primaryAppointment.tenantName,
primaryTenantSlug: primaryAppointment.tenantSlug,
primaryTenantIsOwner: primaryAppointment.isOwner,
}
: {}),
}; };
} }
@@ -872,9 +791,6 @@ function UserDetailPage() {
filterNonHanmacFamilyTenants(userAffiliatedTenants, hanmacFamilyTenantId), filterNonHanmacFamilyTenants(userAffiliatedTenants, hanmacFamilyTenantId),
[userAffiliatedTenants, hanmacFamilyTenantId], [userAffiliatedTenants, hanmacFamilyTenantId],
); );
const primaryAppointmentLeafCount = additionalAppointments.filter(
(appointment) => appointment.tenantId.trim().length > 0,
).length;
if (isLoading) { if (isLoading) {
return ( return (
@@ -941,10 +857,7 @@ function UserDetailPage() {
{user.tenant?.name || {user.tenant?.name ||
user.companyCode || user.companyCode ||
user.joinedTenants?.find( user.joinedTenants?.find(
(t) => (t) => t.type === "COMPANY" || t.type === "COMPANY_GROUP",
t.type === "COMPANY" ||
t.type === "COMPANY_GROUP" ||
t.type === "ORGANIZATION",
)?.name || )?.name ||
t("ui.admin.users.detail.form.tenant_global", "시스템 전역")} t("ui.admin.users.detail.form.tenant_global", "시스템 전역")}
</Badge> </Badge>
@@ -1088,23 +1001,21 @@ function UserDetailPage() {
> >
{t("ui.admin.users.detail.form.status", "상태")} {t("ui.admin.users.detail.form.status", "상태")}
</Label> </Label>
<Select <div className="flex h-11 items-center gap-3 rounded-md border border-input bg-background px-3">
value={watchedStatus || "inactive"} <Switch
onValueChange={(status) => setValue("status", status)} id="status"
> checked={watchedStatus === "active"}
<SelectTrigger id="status" className="h-11"> onCheckedChange={(checked) =>
<SelectValue> setValue("status", checked ? "active" : "inactive")
{userStatusLabel(watchedStatus || "inactive")} }
</SelectValue> />
</SelectTrigger> <span className="text-sm text-muted-foreground">
<SelectContent> {t(
{userStatusValues.map((status) => ( `ui.common.status.${watchedStatus}`,
<SelectItem key={status} value={status}> watchedStatus || "inactive",
{userStatusLabel(status)} )}
</SelectItem> </span>
))} </div>
</SelectContent>
</Select>
</div> </div>
</div> </div>
@@ -1249,26 +1160,6 @@ function UserDetailPage() {
{appointment.tenantSlug} {appointment.tenantSlug}
</span> </span>
)} )}
<label className="flex items-center gap-3 text-sm">
<Switch
aria-label={t(
"ui.admin.users.detail.form.appointment_primary",
"대표 조직",
)}
checked={appointment.isPrimary === true}
disabled={
!appointment.tenantId ||
primaryAppointmentLeafCount <= 1
}
onCheckedChange={(checked) =>
setPrimaryAppointment(index, checked)
}
/>
{t(
"ui.admin.users.detail.form.appointment_primary",
"대표 조직",
)}
</label>
<label className="flex items-center gap-3 text-sm"> <label className="flex items-center gap-3 text-sm">
<Checkbox <Checkbox
checked={appointment.isOwner} checked={appointment.isOwner}

View File

@@ -1,16 +1,17 @@
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios"; import type { AxiosError } from "axios";
import { import {
ArrowDown,
ArrowUp,
ArrowUpDown,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
FileDown, FileDown,
Pencil,
Plus, Plus,
RefreshCw, RefreshCw,
Search, Search,
Settings2, Settings2,
Trash2, Trash2,
User,
} from "lucide-react"; } from "lucide-react";
import * as React from "react"; import * as React from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
@@ -32,13 +33,7 @@ import {
DialogTrigger, DialogTrigger,
} from "../../components/ui/dialog"; } from "../../components/ui/dialog";
import { Input } from "../../components/ui/input"; import { Input } from "../../components/ui/input";
import { import { Switch } from "../../components/ui/switch";
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../../components/ui/select";
import { import {
Table, Table,
TableBody, TableBody,
@@ -49,6 +44,7 @@ import {
} from "../../components/ui/table"; } from "../../components/ui/table";
import { toast } from "../../components/ui/use-toast"; import { toast } from "../../components/ui/use-toast";
import { import {
type UserSummary,
bulkDeleteUsers, bulkDeleteUsers,
bulkUpdateUsers, bulkUpdateUsers,
deleteUser, deleteUser,
@@ -61,7 +57,6 @@ import {
} from "../../lib/adminApi"; } from "../../lib/adminApi";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
import { UserBulkUploadModal } from "./components/UserBulkUploadModal"; import { UserBulkUploadModal } from "./components/UserBulkUploadModal";
import { userStatusLabel, userStatusValues } from "./userStatus";
type UserSchemaField = { type UserSchemaField = {
key: string; key: string;
@@ -69,6 +64,11 @@ type UserSchemaField = {
type: string; type: string;
}; };
type SortConfig = {
key: string;
direction: "asc" | "desc";
};
function UserListPage() { function UserListPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [page, setPage] = React.useState(1); const [page, setPage] = React.useState(1);
@@ -79,6 +79,8 @@ function UserListPage() {
Record<string, boolean> Record<string, boolean>
>({}); >({});
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]); const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
const [sortConfig, setSortConfig] = React.useState<SortConfig | null>(null);
const limit = 1000; const limit = 1000;
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
@@ -209,7 +211,71 @@ function UserListPage() {
) )
: null; : null;
const items = query.data?.items ?? []; const rawItems = query.data?.items ?? [];
const items = React.useMemo(() => {
const sorted = [...rawItems];
if (sortConfig) {
sorted.sort((a, b) => {
let aValue: string | number | boolean | null | undefined;
let bValue: string | number | boolean | null | undefined;
if (sortConfig.key === "name_email") {
aValue = a.name?.toLowerCase() || "";
bValue = b.name?.toLowerCase() || "";
} else if (sortConfig.key === "tenant_dept") {
aValue =
(a.tenant?.name || a.tenantSlug || "").toLowerCase() +
(a.department || "").toLowerCase();
bValue =
(b.tenant?.name || b.tenantSlug || "").toLowerCase() +
(b.department || "").toLowerCase();
} else {
aValue = (a as Record<string, unknown>)[sortConfig.key] as
| string
| number
| boolean;
bValue = (b as Record<string, unknown>)[sortConfig.key] as
| string
| number
| boolean;
}
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 sorted;
}, [rawItems, sortConfig]);
const requestSort = (key: SortConfig["key"]) => {
let direction: "asc" | "desc" = "asc";
if (
sortConfig &&
sortConfig.key === key &&
sortConfig.direction === "asc"
) {
direction = "desc";
}
setSortConfig({ key, direction });
};
const getSortIcon = (key: SortConfig["key"]) => {
if (!sortConfig || sortConfig.key !== key) {
return <ArrowUpDown size={14} className="ml-1 opacity-50" />;
}
return sortConfig.direction === "asc" ? (
<ArrowUp size={14} className="ml-1" />
) : (
<ArrowDown size={14} className="ml-1" />
);
};
const total = query.data?.total ?? 0; const total = query.data?.total ?? 0;
const totalPages = Math.ceil(total / limit); const totalPages = Math.ceil(total / limit);
@@ -306,8 +372,52 @@ function UserListPage() {
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex items-center gap-2 mr-2">
<div className="relative w-48">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t(
"ui.admin.users.list.search_placeholder",
"이름 또는 이메일 검색...",
)}
className="pl-9 h-9"
value={searchDraft}
onChange={(e) => setSearchDraft(e.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
<select
className="flex h-9 w-[160px] rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
value={selectedCompany}
onChange={(e) => {
setSelectedCompany(e.target.value);
setPage(1);
}}
disabled={profile?.role === "tenant_admin"}
>
<option value="">{t("ui.common.all", "전체 테넌트")}</option>
{tenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name}
</option>
))}
</select>
<Button
variant="secondary"
size="sm"
onClick={handleSearch}
className="h-9"
>
{t("ui.common.search", "검색")}
</Button>
</div>
<Button <Button
variant="outline" variant="outline"
size="sm"
className="h-9"
onClick={() => query.refetch()} onClick={() => query.refetch()}
disabled={query.isFetching} disabled={query.isFetching}
> >
@@ -335,7 +445,7 @@ function UserListPage() {
<UserBulkUploadModal onSuccess={() => query.refetch()} /> <UserBulkUploadModal onSuccess={() => query.refetch()} />
<Dialog> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="outline" size="icon"> <Button variant="outline" size="icon" className="h-9 w-9">
<Settings2 size={16} /> <Settings2 size={16} />
</Button> </Button>
</DialogTrigger> </DialogTrigger>
@@ -389,7 +499,7 @@ function UserListPage() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<Button asChild> <Button asChild size="sm" className="h-9">
<Link to="/users/new"> <Link to="/users/new">
<Plus size={16} /> <Plus size={16} />
{t("ui.admin.users.list.add", "사용자 추가")} {t("ui.admin.users.list.add", "사용자 추가")}
@@ -414,48 +524,6 @@ function UserListPage() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0"> <CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className="mb-6 flex flex-wrap items-center gap-4 flex-shrink-0">
<div className="relative flex-1 min-w-[240px] max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t(
"ui.admin.users.list.search_placeholder",
"이름 또는 이메일 검색...",
)}
className="pl-9"
value={searchDraft}
onChange={(e) => setSearchDraft(e.target.value)}
onKeyDown={handleKeyDown}
/>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-muted-foreground whitespace-nowrap">
{t("ui.admin.users.list.filter.tenant", "테넌트 필터:")}
</span>
<select
className="flex h-9 w-[200px] rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
value={selectedCompany}
onChange={(e) => {
setSelectedCompany(e.target.value);
setPage(1);
}}
disabled={profile?.role === "tenant_admin"}
>
<option value="">{t("ui.common.all", "전체")}</option>
{tenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name}
</option>
))}
</select>
</div>
<Button variant="secondary" onClick={handleSearch}>
{t("ui.common.search", "검색")}
</Button>
</div>
{(errorMsg || fallbackError) && ( {(errorMsg || fallbackError) && (
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive flex-shrink-0"> <div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive flex-shrink-0">
{errorMsg ?? fallbackError} {errorMsg ?? fallbackError}
@@ -478,32 +546,72 @@ function UserListPage() {
onChange={toggleSelectAll} onChange={toggleSelectAll}
/> />
</TableHead> </TableHead>
<TableHead className="min-w-[220px]"> <TableHead
className="min-w-[220px] cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort("id")}
>
<div className="flex items-center">
{t("ui.admin.users.list.table.id", "ID")} {t("ui.admin.users.list.table.id", "ID")}
{getSortIcon("id")}
</div>
</TableHead> </TableHead>
<TableHead className="min-w-[200px]"> <TableHead
className="min-w-[200px] cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort("name_email")}
>
<div className="flex items-center">
{t( {t(
"ui.admin.users.list.table.name_email", "ui.admin.users.list.table.name_email",
"이름 / 이메일 / 전화번호", "이름 / 이메일 / 전화번호",
)} )}
{getSortIcon("name_email")}
</div>
</TableHead> </TableHead>
<TableHead> <TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort("status")}
>
<div className="flex items-center">
{t("ui.admin.users.list.table.status", "STATUS")} {t("ui.admin.users.list.table.status", "STATUS")}
{getSortIcon("status")}
</div>
</TableHead>
<TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort("tenant_dept")}
>
<div className="flex items-center">
{t(
"ui.admin.users.list.table.tenant_dept",
"TENANT / DEPT",
)}
{getSortIcon("tenant_dept")}
</div>
</TableHead> </TableHead>
{/* Dynamic Columns from Schema */} {/* Dynamic Columns from Schema */}
{userSchema.map( {userSchema.map(
(field) => (field) =>
visibleColumns[field.key] !== false && ( visibleColumns[field.key] !== false && (
<TableHead key={field.key} className="uppercase"> <TableHead
key={field.key}
className="uppercase cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort(field.key)}
>
<div className="flex items-center">
{field.label} {field.label}
{getSortIcon(field.key)}
</div>
</TableHead> </TableHead>
), ),
)} )}
<TableHead> <TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => requestSort("createdAt")}
>
<div className="flex items-center">
{t("ui.admin.users.list.table.created", "CREATED")} {t("ui.admin.users.list.table.created", "CREATED")}
</TableHead> {getSortIcon("createdAt")}
<TableHead className="text-right"> </div>
{t("ui.admin.users.list.table.actions", "ACTIONS")}
</TableHead> </TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -563,14 +671,16 @@ function UserListPage() {
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
<User size={16} />
</div>
<div <div
className="truncate text-sm" className="truncate text-sm"
data-testid={`user-contact-${user.id}`} data-testid={`user-contact-${user.id}`}
> >
<span className="font-medium">{user.name}</span> <Link
to={`/users/${user.id}`}
className="font-medium hover:underline text-primary"
>
{user.name}
</Link>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{" "} {" "}
{user.email} {user.email}
@@ -583,43 +693,45 @@ function UserListPage() {
)} )}
</div> </div>
</div> </div>
</TableCell> </TableCell>{" "}
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Select <Switch
value={user.status} checked={user.status === "active"}
onValueChange={(status) => onCheckedChange={(checked) =>
statusMutation.mutate({ statusMutation.mutate({
userId: user.id, userId: user.id,
status, status: checked ? "active" : "inactive",
}) })
} }
disabled={ disabled={
statusMutation.isPending || statusMutation.isPending ||
user.id === profile?.id user.id === profile?.id
} }
>
<SelectTrigger
className="h-8 w-36"
aria-label={t( aria-label={t(
"ui.admin.users.list.status_select", "ui.admin.users.list.toggle_status",
"{{name}} 상태", "{{name}} 활성 상태",
{ name: user.name }, { name: user.name },
)} )}
data-testid={`user-status-select-${user.id}`} data-testid={`user-status-toggle-${user.id}`}
> />
<SelectValue> <span className="text-sm text-muted-foreground">
{userStatusLabel(user.status)} {t(`ui.common.status.${user.status}`, user.status)}
</SelectValue> </span>
</SelectTrigger> </div>
<SelectContent> </TableCell>
{userStatusValues.map((status) => ( <TableCell>
<SelectItem key={status} value={status}> <div className="flex flex-col gap-1">
{userStatusLabel(status)} <span className="text-sm font-medium">
</SelectItem> {user.tenant?.name ||
))} user.companyCode ||
</SelectContent> t("ui.common.unassigned", "미배정")}
</Select> </span>
{user.department && (
<span className="text-xs text-muted-foreground">
{user.department}
</span>
)}
</div> </div>
</TableCell> </TableCell>
{/* Dynamic Metadata Cells */} {/* Dynamic Metadata Cells */}
@@ -634,37 +746,6 @@ function UserListPage() {
<TableCell className="text-sm text-muted-foreground"> <TableCell className="text-sm text-muted-foreground">
{new Date(user.createdAt).toLocaleDateString()} {new Date(user.createdAt).toLocaleDateString()}
</TableCell> </TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="icon"
onClick={() => navigate(`/users/${user.id}`)}
>
<Pencil size={16} />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive disabled:opacity-30 disabled:cursor-not-allowed"
onClick={() => 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
}
>
<Trash2 size={16} />
</Button>
</div>
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
@@ -702,24 +783,6 @@ function UserListPage() {
> >
{t("ui.common.status.inactive", "비활성화")} {t("ui.common.status.inactive", "비활성화")}
</Button> </Button>
<Button
variant="ghost"
size="sm"
className="text-background hover:bg-background/10 h-8"
onClick={() => handleBulkStatusChange("suspended")}
data-testid="bulk-suspended-btn"
>
{t("ui.common.status.suspended", "정지")}
</Button>
<Button
variant="ghost"
size="sm"
className="text-background hover:bg-background/10 h-8"
onClick={() => handleBulkStatusChange("leave_of_absence")}
data-testid="bulk-leave-of-absence-btn"
>
{t("ui.common.status.leave_of_absence", "휴직")}
</Button>
<div className="w-px h-4 bg-background/20 mx-1" /> <div className="w-px h-4 bg-background/20 mx-1" />
<Button <Button
variant="ghost" variant="ghost"

View File

@@ -483,6 +483,8 @@ export type UserUpdateRequest = {
role?: string; role?: string;
status?: string; status?: string;
tenantSlug?: string; tenantSlug?: string;
isAddTenant?: boolean;
isRemoveTenant?: boolean;
department?: string; department?: string;
position?: string; position?: string;
jobTitle?: string; jobTitle?: string;

View File

@@ -20,7 +20,7 @@ export function buildTenantFullTree(
tenantMap.set(t.id, { tenantMap.set(t.id, {
...t, ...t,
children: [], children: [],
recursiveMemberCount: t.memberCount || 0, recursiveMemberCount: Number(t.memberCount) || 0,
}); });
} }
@@ -48,7 +48,7 @@ export function buildTenantFullTree(
} }
visitedForCalc.add(node.id); visitedForCalc.add(node.id);
let total = node.memberCount || 0; let total = Number(node.memberCount) || 0;
for (const child of node.children) { for (const child of node.children) {
total += calculateRecursive(child); total += calculateRecursive(child);
} }

View File

@@ -1176,14 +1176,49 @@ primary = "Representative Affiliated Tenant"
title = "Affiliation & Organization Info" title = "Affiliation & Organization Info"
[ui.admin.users.list] [ui.admin.users.list]
add = "User Add" add = "Add User"
add_to_tenant = "Add to Tenant"
bulk_import = "Bulk Import" bulk_import = "Bulk Import"
empty = "Empty" empty = "No users found."
fetch_error = "Fetch Error" fetch_error = "Failed to fetch user list."
search_placeholder = "Search Placeholder" search_label = "Search Users"
subtitle = "Subtitle" search_placeholder = "Search by name or email..."
subtitle = "View and manage system users."
toggle_status = "{{name}} active status" toggle_status = "{{name}} active status"
title = "User Manage" title = "User Management"
[msg.admin.users]
add_tenant_confirm = "Are you sure you want to add '{{name}}' to '{{tenant}}' tenant?"
add_tenant_error = "An error occurred while adding the tenant."
add_tenant_success = "Successfully added to the tenant."
export_error = "Failed to export users."
status_error = "Failed to change user status."
self_delete_blocked = "You cannot delete your own account."
[ui.admin.tenants.members]
add_existing = "Assign Existing Member"
create_new = "Create New Member"
remove = "Exclude from Organization"
title = "Tenant Members ({{count}})"
view_profile = "View Profile"
[ui.admin.tenants.members.table]
actions = "ACTIONS"
email = "EMAIL"
name = "NAME"
role = "ROLE"
status = "STATUS"
[msg.admin.tenants.members]
empty = "No members in this organization."
remove_confirm = "Are you sure you want to exclude '{{name}}' from this organization?"
remove_error = "An error occurred while excluding from organization."
remove_success = "Successfully excluded from organization."
[ui.admin.tenants.list]
search_label = "Search Tenants"
search_placeholder = "Search by name or slug..."
title = "Tenant List"
[ui.admin.users.list.breadcrumb] [ui.admin.users.list.breadcrumb]
list = "List" list = "List"

View File

@@ -1179,14 +1179,49 @@ title = "소속 및 조직 정보"
[ui.admin.users.list] [ui.admin.users.list]
add = "사용자 추가" add = "사용자 추가"
add_to_tenant = "테넌트에 추가"
bulk_import = "일괄 임포트" bulk_import = "일괄 임포트"
empty = "검색 결과가 없습니다." empty = "검색 결과가 없습니다."
fetch_error = "사용자 목록 조회에 실패했습니다." fetch_error = "사용자 목록 조회에 실패했습니다."
search_label = "사용자 검색"
search_placeholder = "이름 또는 이메일 검색..." search_placeholder = "이름 또는 이메일 검색..."
subtitle = "시스템 사용자를 조회하고 관리합니다." subtitle = "시스템 사용자를 조회하고 관리합니다."
toggle_status = "{{name}} 활성 상태" toggle_status = "{{name}} 활성 상태"
title = "사용자 관리" title = "사용자 관리"
[msg.admin.users]
add_tenant_confirm = "'{{name}}'님을 '{{tenant}}' 테넌트에 추가하시겠습니까?"
add_tenant_error = "테넌트 추가 중 오류가 발생했습니다."
add_tenant_success = "해당 테넌트에 추가되었습니다."
export_error = "사용자 내보내기에 실패했습니다."
status_error = "사용자 상태 변경에 실패했습니다."
self_delete_blocked = "본인 계정은 삭제할 수 없습니다."
[ui.admin.tenants.members]
add_existing = "기존 멤버 배정"
create_new = "신규 멤버 생성"
remove = "조직에서 제외"
title = "테넌트 멤버 ({{count}})"
view_profile = "상세 정보"
[ui.admin.tenants.members.table]
actions = "ACTIONS"
email = "EMAIL"
name = "NAME"
role = "ROLE"
status = "STATUS"
[msg.admin.tenants.members]
empty = "소속된 사용자가 없습니다."
remove_confirm = "'{{name}}'님을 이 조직에서 제외하시겠습니까?"
remove_error = "조직에서 제외하는 중 오류가 발생했습니다."
remove_success = "조직에서 제외되었습니다."
[ui.admin.tenants.list]
search_label = "테넌트 검색"
search_placeholder = "테넌트 이름 또는 슬러그 검색..."
title = "테넌트 목록"
[ui.admin.users.list.breadcrumb] [ui.admin.users.list.breadcrumb]
list = "List" list = "List"
section = "Users" section = "Users"

View File

@@ -6,6 +6,7 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/lib/pq"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -54,6 +55,7 @@ type User struct {
Role string `gorm:"column:role;default:'user';not null" json:"role"` // super_admin, tenant_admin, rp_admin, user Role string `gorm:"column:role;default:'user';not null" json:"role"` // super_admin, tenant_admin, rp_admin, user
AffiliationType string `gorm:"column:affiliation_type" json:"affiliationType"` AffiliationType string `gorm:"column:affiliation_type" json:"affiliationType"`
CompanyCode string `gorm:"column:company_code;index" json:"companyCode"` CompanyCode string `gorm:"column:company_code;index" json:"companyCode"`
CompanyCodes pq.StringArray `gorm:"column:company_codes;type:text[]" json:"companyCodes"`
TenantID *string `gorm:"column:tenant_id;type:uuid;index" json:"tenantId,omitempty"` TenantID *string `gorm:"column:tenant_id;type:uuid;index" json:"tenantId,omitempty"`
Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"` Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"`
RelyingPartyID *string `gorm:"column:relying_party_id;type:uuid;index" json:"relyingPartyId,omitempty"` // RP Admin용 RelyingPartyID *string `gorm:"column:relying_party_id;type:uuid;index" json:"relyingPartyId,omitempty"` // RP Admin용

View File

@@ -1432,6 +1432,8 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
Role *string `json:"role"` Role *string `json:"role"`
Status *string `json:"status"` Status *string `json:"status"`
CompanyCode *string `json:"companyCode"` CompanyCode *string `json:"companyCode"`
IsAddTenant bool `json:"isAddTenant"`
IsRemoveTenant bool `json:"isRemoveTenant"`
Department *string `json:"department"` Department *string `json:"department"`
Position *string `json:"position"` Position *string `json:"position"`
JobTitle *string `json:"jobTitle"` JobTitle *string `json:"jobTitle"`
@@ -1446,9 +1448,9 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
} }
req.Metadata = mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner) req.Metadata = mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner)
// [New] Tenant Admin restriction: Cannot change companyCode // [New] Tenant Admin restriction: Cannot change companyCode (except when adding/removing secondary membership)
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin { if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin {
if req.CompanyCode != nil && *req.CompanyCode != requester.CompanyCode { if !req.IsAddTenant && !req.IsRemoveTenant && req.CompanyCode != nil && *req.CompanyCode != requester.CompanyCode {
return errorJSON(c, fiber.StatusForbidden, "forbidden: tenant admins cannot change user's tenant") return errorJSON(c, fiber.StatusForbidden, "forbidden: tenant admins cannot change user's tenant")
} }
} }
@@ -1531,14 +1533,8 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
} }
if req.CompanyCode != nil { if req.CompanyCode != nil {
code := strings.TrimSpace(*req.CompanyCode) code := strings.TrimSpace(*req.CompanyCode)
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
}
}
if req.IsAddTenant {
// Add to existingCodes if not present // Add to existingCodes if not present
found := false found := false
for _, existing := range existingCodes { for _, existing := range existingCodes {
@@ -1550,19 +1546,83 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
if !found && code != "" { if !found && code != "" {
existingCodes = append(existingCodes, code) existingCodes = append(existingCodes, code)
} }
} else if req.IsRemoveTenant {
// Remove from existingCodes
var newCodes []string
for _, existing := range existingCodes {
if existing != code {
newCodes = append(newCodes, existing)
}
}
existingCodes = newCodes
// [Keto Sync] Remove membership for the target tenant
if h.TenantService != nil && h.KetoOutboxRepo != nil && code != "" {
go func(removedSlug string) {
bgCtx := context.Background()
if t, err := h.TenantService.GetTenantBySlug(bgCtx, removedSlug); err == nil && t != nil {
_ = h.KetoOutboxRepo.Create(bgCtx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: t.ID,
Relation: "members",
Subject: "User:" + userID,
Action: domain.KetoOutboxActionDelete,
})
}
}(code)
}
// If removing the primary company code, pick another one as primary if available
currentPrimary := extractTraitString(traits, "companyCode")
if currentPrimary == code {
if len(existingCodes) > 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)
}
}
} }
// Deduplicate and save back companyCodes // Deduplicate and save back companyCodes
var uniqueCodes []string var codesToSave []string
seenCodes := map[string]bool{} seenCodes := map[string]bool{}
for _, c := range existingCodes { for _, c := range existingCodes {
if !seenCodes[c] && c != "" { if !seenCodes[c] && c != "" {
seenCodes[c] = true seenCodes[c] = true
uniqueCodes = append(uniqueCodes, c) codesToSave = append(codesToSave, c)
} }
} }
if len(uniqueCodes) > 0 { if len(codesToSave) > 0 {
traits["companyCodes"] = uniqueCodes traits["companyCodes"] = codesToSave
} else {
delete(traits, "companyCodes")
} }
if req.Department != nil { if req.Department != nil {
@@ -1927,6 +1987,17 @@ func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.Us
UpdatedAt: identity.UpdatedAt, 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) // 1. Try to get tenant_id directly from Kratos traits first (Fastest & most reliable)
tID := extractTraitString(traits, "tenant_id") tID := extractTraitString(traits, "tenant_id")
if tID != "" { if tID != "" {

View File

@@ -5,6 +5,7 @@ import (
"context" "context"
"strings" "strings"
"github.com/lib/pq"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
) )
@@ -156,19 +157,28 @@ func (r *userRepository) CountByCompanyCodes(ctx context.Context, codes []string
} }
var results []result var results []result
// Search by company_code directly. Normalize inputs using LOWER for robust matching. lowerCodes := lowerStrings(codes)
err := r.db.WithContext(ctx).Model(&domain.User{}).
Select("LOWER(company_code) as company_code, count(*) as count"). // Combine singular company_code and array company_codes using a subquery
Where("LOWER(company_code) IN ?", lowerStrings(codes)). // to ensure we count each user accurately per company code they belong to.
Group("LOWER(company_code)"). query := `
Scan(&results).Error 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 { if err != nil {
return nil, err return nil, err
} }
counts := make(map[string]int64) counts := make(map[string]int64)
for _, res := range results { 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) // 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{}) db := r.db.WithContext(ctx).Model(&domain.User{})
if companyCode != "" { 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"). 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 != "" { if search != "" {
searchTerm := "%" + search + "%" searchTerm := "%" + search + "%"
db = db.Where("(users.email LIKE ? OR users.name LIKE ? OR users.company_code LIKE ? OR users.metadata::text LIKE ?)", 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, searchTerm) searchTerm, searchTerm, searchTerm, search, searchTerm)
} }
if err := db.Count(&total).Error; err != nil { if err := db.Count(&total).Error; err != nil {