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:
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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: "",
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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용
|
||||||
|
|||||||
@@ -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 != "" {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user