forked from baron/baron-sso
feat: enhance multi-tenant UI and fix member aggregation
- adminfront: Update TenantListPage and UserListPage sorting logic to support all columns dynamically - adminfront: Add inline 'Move', 'Remove', and 'Delete' actions directly inside the Tenant Org Chart member table - adminfront: Remove redundant 'ACTIONS' column and profile icon from UserListPage, making the name clickable - adminfront: Remove 'New Member' creation link from Tenant Org Chart 'Add Member' dropdown - backend: Fix CountByCompanyCodes query to accurately aggregate user counts using both primary company_code and company_codes array
This commit is contained in:
@@ -1,11 +1,11 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
Download,
|
||||
FileSpreadsheet,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
@@ -53,25 +53,29 @@ import {
|
||||
importTenantsCSV,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
|
||||
import { isSeedTenant } from "../utils/protectedTenants";
|
||||
import {
|
||||
type TenantImportResolution,
|
||||
type TenantImportPreviewRow,
|
||||
type TenantImportResolution,
|
||||
buildTenantImportPreview,
|
||||
parseTenantCSV,
|
||||
serializeTenantImportCSV,
|
||||
} from "../utils/tenantCsvImport";
|
||||
import { isSeedTenant } from "../utils/protectedTenants";
|
||||
import { buildTenantFullTree, type TenantNode } from "../../../lib/tenantTree";
|
||||
|
||||
const tenantCSVTemplate =
|
||||
"name,type,parent_tenant_slug,slug,memo,email_domain\n";
|
||||
|
||||
type SortConfig = {
|
||||
key: keyof TenantSummary | "recursiveMemberCount";
|
||||
direction: "asc" | "desc";
|
||||
};
|
||||
|
||||
function TenantListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [sortKey, setSortKey] = React.useState<string>("name");
|
||||
const [sortOrder, setSortOrder] = React.useState<"asc" | "desc">("asc");
|
||||
const [sortConfig, setSortConfig] = React.useState<SortConfig | null>(null);
|
||||
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const [importMessage, setImportMessage] = React.useState("");
|
||||
const [previewRows, setPreviewRows] = React.useState<
|
||||
@@ -119,15 +123,6 @@ function TenantListPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleSort = (key: string) => {
|
||||
if (sortKey === key) {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
};
|
||||
|
||||
const deleteBulkMutation = useMutation({
|
||||
mutationFn: (ids: string[]) => deleteTenantsBulk(ids),
|
||||
onSuccess: () => {
|
||||
@@ -212,45 +207,80 @@ function TenantListPage() {
|
||||
: null;
|
||||
|
||||
const allTenants = query.data?.items ?? [];
|
||||
const tenantsWithRecursiveCount = React.useMemo(() => {
|
||||
// Build tree to calculate recursiveMemberCount, but we map it back to a flat array for the table
|
||||
const { subTree } = buildTenantFullTree(allTenants);
|
||||
const tenants = React.useMemo(() => {
|
||||
// 1. Calculate recursive counts
|
||||
// buildTenantFullTree returns subTree which represents roots, but it also mutates the mapped nodes internally.
|
||||
// However, to easily map them back to a flat list, we can just run the builder,
|
||||
// and then extract the recursive counts.
|
||||
const treeResult = buildTenantFullTree(allTenants);
|
||||
|
||||
const flatMap = new Map<string, TenantNode>();
|
||||
const flatten = (nodes: TenantNode[]) => {
|
||||
// Flatten the tree or just extract from allTenants map?
|
||||
// buildTenantFullTree does NOT mutate the objects passed in allTenants. It creates new ones.
|
||||
// Let's create a map of id -> recursiveMemberCount
|
||||
const recursiveCounts = new Map<string, number>();
|
||||
const extractCounts = (nodes: TenantNode[]) => {
|
||||
for (const node of nodes) {
|
||||
flatMap.set(node.id, node);
|
||||
flatten(node.children);
|
||||
recursiveCounts.set(node.id, node.recursiveMemberCount);
|
||||
if (node.children) extractCounts(node.children);
|
||||
}
|
||||
};
|
||||
flatten(subTree);
|
||||
extractCounts(treeResult.subTree);
|
||||
|
||||
// Map back to allTenants ensuring recursiveMemberCount is present
|
||||
return allTenants.map((t) => flatMap.get(t.id) ?? { ...t, children: [], recursiveMemberCount: t.memberCount || 0 });
|
||||
}, [allTenants]);
|
||||
let enriched = allTenants.map((t) => ({
|
||||
...t,
|
||||
recursiveMemberCount: recursiveCounts.get(t.id) ?? t.memberCount ?? 0,
|
||||
}));
|
||||
|
||||
const tenants = React.useMemo(() => {
|
||||
let filtered = tenantsWithRecursiveCount;
|
||||
if (search.trim()) {
|
||||
const term = search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
enriched = enriched.filter(
|
||||
(t) =>
|
||||
t.name.toLowerCase().includes(term) ||
|
||||
t.slug.toLowerCase().includes(term),
|
||||
);
|
||||
}
|
||||
return [...filtered].sort((a, b) => {
|
||||
const valA = (a[sortKey as keyof typeof a] || "")
|
||||
.toString()
|
||||
.toLowerCase();
|
||||
const valB = (b[sortKey as keyof typeof b] || "")
|
||||
.toString()
|
||||
.toLowerCase();
|
||||
if (valA < valB) return sortOrder === "asc" ? -1 : 1;
|
||||
if (valA > valB) return sortOrder === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}, [tenantsWithRecursiveCount, search, sortKey, sortOrder]);
|
||||
|
||||
if (sortConfig) {
|
||||
enriched.sort((a, b) => {
|
||||
const aValue = a[sortConfig.key as keyof typeof a];
|
||||
const bValue = b[sortConfig.key as keyof typeof b];
|
||||
|
||||
if (aValue === bValue) return 0;
|
||||
if (aValue === null || aValue === undefined) return 1;
|
||||
if (bValue === null || bValue === undefined) return -1;
|
||||
|
||||
if (sortConfig.direction === "asc") {
|
||||
return aValue < bValue ? -1 : 1;
|
||||
}
|
||||
return aValue > bValue ? -1 : 1;
|
||||
});
|
||||
}
|
||||
|
||||
return enriched;
|
||||
}, [allTenants, search, sortConfig]);
|
||||
|
||||
const requestSort = (key: SortConfig["key"]) => {
|
||||
let direction: "asc" | "desc" = "asc";
|
||||
if (
|
||||
sortConfig &&
|
||||
sortConfig.key === key &&
|
||||
sortConfig.direction === "asc"
|
||||
) {
|
||||
direction = "desc";
|
||||
}
|
||||
setSortConfig({ key, direction });
|
||||
};
|
||||
|
||||
const getSortIcon = (key: SortConfig["key"]) => {
|
||||
if (!sortConfig || sortConfig.key !== key) {
|
||||
return <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(
|
||||
() => tenants.filter((tenant) => !isSeedTenant(tenant)),
|
||||
@@ -403,6 +433,19 @@ function TenantListPage() {
|
||||
</p>
|
||||
</div>
|
||||
<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"]}>
|
||||
{selectedIds.length > 0 && (
|
||||
<Button
|
||||
@@ -492,35 +535,19 @@ function TenantListPage() {
|
||||
</header>
|
||||
|
||||
<Card className="bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center gap-4 py-4 flex-wrap flex-shrink-0">
|
||||
<div className="flex items-baseline gap-3 flex-shrink-0 mr-auto">
|
||||
<CardTitle className="text-lg m-0">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{t("ui.admin.tenants.registry.title", "Tenant Registry")}
|
||||
</CardTitle>
|
||||
<CardDescription className="m-0">
|
||||
<CardDescription>
|
||||
{t("msg.admin.tenants.registry.count", "총 {{count}}개 테넌트", {
|
||||
count: query.data?.total ?? 0,
|
||||
})}
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative w-[280px]">
|
||||
<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>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
|
||||
{(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">
|
||||
{errorMsg ?? fallbackError}
|
||||
@@ -546,87 +573,65 @@ function TenantListPage() {
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="min-w-[220px] cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => handleSort("id")}
|
||||
onClick={() => requestSort("id")}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center">
|
||||
{t("ui.admin.tenants.table.id", "ID")}
|
||||
{sortKey === "id" &&
|
||||
(sortOrder === "asc" ? (
|
||||
<ChevronUp size={14} />
|
||||
) : (
|
||||
<ChevronDown size={14} />
|
||||
))}
|
||||
{getSortIcon("id")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => handleSort("name")}
|
||||
onClick={() => requestSort("name")}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center">
|
||||
{t("ui.admin.tenants.table.name", "NAME")}
|
||||
{sortKey === "name" &&
|
||||
(sortOrder === "asc" ? (
|
||||
<ChevronUp size={14} />
|
||||
) : (
|
||||
<ChevronDown size={14} />
|
||||
))}
|
||||
{getSortIcon("name")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead>{t("ui.admin.tenants.table.type", "TYPE")}</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => handleSort("slug")}
|
||||
onClick={() => requestSort("type")}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center">
|
||||
{t("ui.admin.tenants.table.type", "TYPE")}
|
||||
{getSortIcon("type")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<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")}
|
||||
{sortKey === "slug" &&
|
||||
(sortOrder === "asc" ? (
|
||||
<ChevronUp size={14} />
|
||||
) : (
|
||||
<ChevronDown size={14} />
|
||||
))}
|
||||
{getSortIcon("slug")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => handleSort("status")}
|
||||
onClick={() => requestSort("status")}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center">
|
||||
{t("ui.admin.tenants.table.status", "STATUS")}
|
||||
{sortKey === "status" &&
|
||||
(sortOrder === "asc" ? (
|
||||
<ChevronUp size={14} />
|
||||
) : (
|
||||
<ChevronDown size={14} />
|
||||
))}
|
||||
{getSortIcon("status")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => handleSort("memberCount")}
|
||||
onClick={() => requestSort("recursiveMemberCount")}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center">
|
||||
{t("ui.admin.tenants.table.members", "MEMBERS")}
|
||||
{sortKey === "memberCount" &&
|
||||
(sortOrder === "asc" ? (
|
||||
<ChevronUp size={14} />
|
||||
) : (
|
||||
<ChevronDown size={14} />
|
||||
))}
|
||||
{getSortIcon("recursiveMemberCount")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => handleSort("updatedAt")}
|
||||
onClick={() => requestSort("updatedAt")}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center">
|
||||
{t("ui.admin.tenants.table.updated", "UPDATED")}
|
||||
{sortKey === "updatedAt" &&
|
||||
(sortOrder === "asc" ? (
|
||||
<ChevronUp size={14} />
|
||||
) : (
|
||||
<ChevronDown size={14} />
|
||||
))}
|
||||
{getSortIcon("updatedAt")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
@@ -637,11 +642,8 @@ function TenantListPage() {
|
||||
<TableBody>
|
||||
{query.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="h-24 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="animate-spin" size={20} />
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</div>
|
||||
<TableCell colSpan={9}>
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
@@ -719,8 +721,9 @@ function TenantListPage() {
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{(tenant as unknown as TenantNode).recursiveMemberCount ?? tenant.memberCount}
|
||||
</TableCell> <TableCell className="text-xs">
|
||||
{tenant.recursiveMemberCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{tenant.updatedAt
|
||||
? new Date(tenant.updatedAt).toLocaleString("ko-KR")
|
||||
: "-"}
|
||||
|
||||
@@ -195,7 +195,9 @@ const SidebarNode: React.FC<{
|
||||
const MemberTable: React.FC<{
|
||||
tenantSlug: string;
|
||||
onRefreshTrigger?: number;
|
||||
}> = ({ tenantSlug, onRefreshTrigger }) => {
|
||||
allTenants?: TenantSummary[];
|
||||
}> = ({ tenantSlug, onRefreshTrigger, allTenants }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ["tenant-members-v2", tenantSlug, onRefreshTrigger],
|
||||
queryFn: () => fetchUsers(100, 0, undefined, tenantSlug),
|
||||
@@ -204,6 +206,54 @@ const MemberTable: React.FC<{
|
||||
|
||||
const members = data?.items ?? [];
|
||||
|
||||
const [isMoveOpen, setIsMoveOpen] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<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)
|
||||
return (
|
||||
<div className="py-20 text-center text-muted-foreground animate-pulse">
|
||||
@@ -264,6 +314,28 @@ const MemberTable: React.FC<{
|
||||
{t("ui.common.detail", "상세보기")}
|
||||
</Link>
|
||||
</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>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
@@ -271,6 +343,65 @@ const MemberTable: React.FC<{
|
||||
))}
|
||||
</TableBody>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -574,6 +705,7 @@ function TenantUserGroupsTab() {
|
||||
<MemberTable
|
||||
tenantSlug={selectedNode.slug}
|
||||
onRefreshTrigger={refreshMembersCount}
|
||||
allTenants={allTenantsData?.items ?? []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -702,6 +834,7 @@ const UserAddDialog: React.FC<{
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await updateUser(selectedUserId, { tenantSlug });
|
||||
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
|
||||
toast.success(t("msg.info.saved_success", "사용자가 배정되었습니다."));
|
||||
onOpenChange(false);
|
||||
resetFields();
|
||||
|
||||
@@ -145,7 +145,7 @@ function UserCreatePage() {
|
||||
password: "",
|
||||
name: "",
|
||||
phone: "",
|
||||
tenantSlug: searchParams.get("tenantSlug") ?? "",
|
||||
tenantSlug: searchParams.get("tenantSlug") || "",
|
||||
department: "",
|
||||
position: "",
|
||||
jobTitle: "",
|
||||
|
||||
@@ -44,13 +44,6 @@ import {
|
||||
} from "../../components/ui/dialog";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Label } from "../../components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../../components/ui/select";
|
||||
import { Switch } from "../../components/ui/switch";
|
||||
import {
|
||||
Tabs,
|
||||
@@ -85,7 +78,6 @@ import {
|
||||
parseOrgChartTenantSelection,
|
||||
} from "./orgChartPicker";
|
||||
import type { UserSchemaField } from "./userSchemaFields";
|
||||
import { userStatusLabel, userStatusValues } from "./userStatus";
|
||||
|
||||
type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
|
||||
metadata: Record<string, Record<string, string | number | boolean>>;
|
||||
@@ -131,40 +123,12 @@ function createEmptyAppointment(): AppointmentDraft {
|
||||
tenantId: "",
|
||||
tenantName: "",
|
||||
tenantSlug: "",
|
||||
isPrimary: false,
|
||||
isOwner: false,
|
||||
jobTitle: "",
|
||||
position: "",
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePrimaryAppointments(
|
||||
appointments: AppointmentDraft[],
|
||||
): AppointmentDraft[] {
|
||||
const leafIndexes = appointments
|
||||
.map((appointment, index) =>
|
||||
appointment.tenantId.trim().length > 0 ? index : -1,
|
||||
)
|
||||
.filter((index) => index >= 0);
|
||||
if (leafIndexes.length === 1) {
|
||||
const primaryIndex = leafIndexes[0];
|
||||
return appointments.map((appointment, index) => ({
|
||||
...appointment,
|
||||
isPrimary: index === primaryIndex,
|
||||
}));
|
||||
}
|
||||
const selectedIndex = appointments.findIndex(
|
||||
(appointment) => appointment.isPrimary === true,
|
||||
);
|
||||
return appointments.map((appointment, index) => ({
|
||||
...appointment,
|
||||
isPrimary:
|
||||
selectedIndex >= 0 &&
|
||||
index === selectedIndex &&
|
||||
appointment.tenantId.trim().length > 0,
|
||||
}));
|
||||
}
|
||||
|
||||
function validateManualPassword(
|
||||
password: string,
|
||||
policy?: PasswordPolicyResponse,
|
||||
@@ -521,17 +485,15 @@ function UserDetailPage() {
|
||||
try {
|
||||
const tenant = await resolveTenantSelection(selection, tenants);
|
||||
setAdditionalAppointments((current) =>
|
||||
normalizePrimaryAppointments(
|
||||
current.map((appointment, index) =>
|
||||
index === target.index
|
||||
? {
|
||||
...appointment,
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.name,
|
||||
tenantSlug: tenant.slug,
|
||||
}
|
||||
: appointment,
|
||||
),
|
||||
current.map((appointment, index) =>
|
||||
index === target.index
|
||||
? {
|
||||
...appointment,
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.name,
|
||||
tenantSlug: tenant.slug,
|
||||
}
|
||||
: appointment,
|
||||
),
|
||||
);
|
||||
setPickerTarget(null);
|
||||
@@ -574,30 +536,15 @@ function UserDetailPage() {
|
||||
patch: Partial<UserAppointment>,
|
||||
) => {
|
||||
setAdditionalAppointments((current) =>
|
||||
normalizePrimaryAppointments(
|
||||
current.map((appointment, currentIndex) =>
|
||||
currentIndex === index ? { ...appointment, ...patch } : appointment,
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const setPrimaryAppointment = (index: number, checked: boolean) => {
|
||||
setAdditionalAppointments((current) =>
|
||||
normalizePrimaryAppointments(
|
||||
current.map((appointment, currentIndex) => ({
|
||||
...appointment,
|
||||
isPrimary: checked && currentIndex === index,
|
||||
})),
|
||||
current.map((appointment, currentIndex) =>
|
||||
currentIndex === index ? { ...appointment, ...patch } : appointment,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const removeAppointment = (index: number) => {
|
||||
setAdditionalAppointments((current) =>
|
||||
normalizePrimaryAppointments(
|
||||
current.filter((_, currentIndex) => currentIndex !== index),
|
||||
),
|
||||
current.filter((_, currentIndex) => currentIndex !== index),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -655,10 +602,7 @@ function UserDetailPage() {
|
||||
tenantSlug:
|
||||
user.companyCode ||
|
||||
user.joinedTenants?.find(
|
||||
(t) =>
|
||||
t.type === "COMPANY" ||
|
||||
t.type === "COMPANY_GROUP" ||
|
||||
t.type === "ORGANIZATION",
|
||||
(t) => t.type === "COMPANY" || t.type === "COMPANY_GROUP",
|
||||
)?.slug ||
|
||||
"",
|
||||
department: user.department || "",
|
||||
@@ -692,45 +636,38 @@ function UserDetailPage() {
|
||||
isHanmacFamilyTenant(tenant, tenants, hanmacFamilyTenantId),
|
||||
);
|
||||
setAdditionalAppointments(
|
||||
normalizePrimaryAppointments(
|
||||
Array.isArray(rawAppointments)
|
||||
? (rawAppointments as UserAppointment[]).map((appointment) => ({
|
||||
...appointment,
|
||||
isPrimary:
|
||||
appointment.isPrimary === true ||
|
||||
appointment.tenantId === metadata.primaryTenantId,
|
||||
draftId: createDraftId(),
|
||||
}))
|
||||
: isUserHanmacFamily
|
||||
? familyFallbackTenants.length > 0
|
||||
? familyFallbackTenants.map((tenant) => ({
|
||||
draftId: createDraftId(),
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.name,
|
||||
tenantSlug: tenant.slug,
|
||||
isPrimary: tenant.id === fallbackAppointment?.id,
|
||||
isOwner:
|
||||
metadata.primaryTenantIsOwner === true &&
|
||||
tenant.id === fallbackAppointment?.id,
|
||||
jobTitle: user.jobTitle,
|
||||
position: user.position,
|
||||
}))
|
||||
: fallbackAppointment
|
||||
? [
|
||||
{
|
||||
draftId: createDraftId(),
|
||||
tenantId: fallbackAppointment.id,
|
||||
tenantName: fallbackAppointment.name,
|
||||
tenantSlug: fallbackAppointment.slug,
|
||||
isPrimary: true,
|
||||
isOwner: metadata.primaryTenantIsOwner === true,
|
||||
jobTitle: user.jobTitle,
|
||||
position: user.position,
|
||||
},
|
||||
]
|
||||
: []
|
||||
: [],
|
||||
),
|
||||
Array.isArray(rawAppointments)
|
||||
? (rawAppointments as UserAppointment[]).map((appointment) => ({
|
||||
...appointment,
|
||||
draftId: createDraftId(),
|
||||
}))
|
||||
: isUserHanmacFamily
|
||||
? familyFallbackTenants.length > 0
|
||||
? familyFallbackTenants.map((tenant) => ({
|
||||
draftId: createDraftId(),
|
||||
tenantId: tenant.id,
|
||||
tenantName: tenant.name,
|
||||
tenantSlug: tenant.slug,
|
||||
isOwner:
|
||||
metadata.primaryTenantIsOwner === true &&
|
||||
tenant.id === fallbackAppointment?.id,
|
||||
jobTitle: user.jobTitle,
|
||||
position: user.position,
|
||||
}))
|
||||
: fallbackAppointment
|
||||
? [
|
||||
{
|
||||
draftId: createDraftId(),
|
||||
tenantId: fallbackAppointment.id,
|
||||
tenantName: fallbackAppointment.name,
|
||||
tenantSlug: fallbackAppointment.slug,
|
||||
isOwner: metadata.primaryTenantIsOwner === true,
|
||||
jobTitle: user.jobTitle,
|
||||
position: user.position,
|
||||
},
|
||||
]
|
||||
: []
|
||||
: [],
|
||||
);
|
||||
}
|
||||
}, [hanmacFamilyTenantId, tenants, user, reset]);
|
||||
@@ -811,37 +748,19 @@ function UserDetailPage() {
|
||||
tenantId: appointment.tenantId,
|
||||
tenantSlug: appointment.tenantSlug,
|
||||
tenantName: appointment.tenantName,
|
||||
isPrimary: appointment.isPrimary === true,
|
||||
isOwner: appointment.isOwner,
|
||||
jobTitle: appointment.jobTitle,
|
||||
position: appointment.position,
|
||||
}));
|
||||
const primaryAppointment = appointments.find(
|
||||
(appointment) => appointment.isPrimary,
|
||||
);
|
||||
|
||||
payload.tenantSlug = undefined;
|
||||
payload.department = undefined;
|
||||
payload.position = undefined;
|
||||
payload.jobTitle = undefined;
|
||||
payload.additionalAppointments = appointments;
|
||||
if (primaryAppointment) {
|
||||
payload.tenantSlug = primaryAppointment.tenantSlug;
|
||||
payload.primaryTenantId = primaryAppointment.tenantId;
|
||||
payload.primaryTenantName = primaryAppointment.tenantName;
|
||||
payload.primaryTenantIsOwner = primaryAppointment.isOwner;
|
||||
}
|
||||
payload.metadata = {
|
||||
...metadata,
|
||||
additionalAppointments: appointments,
|
||||
...(primaryAppointment
|
||||
? {
|
||||
primaryTenantId: primaryAppointment.tenantId,
|
||||
primaryTenantName: primaryAppointment.tenantName,
|
||||
primaryTenantSlug: primaryAppointment.tenantSlug,
|
||||
primaryTenantIsOwner: primaryAppointment.isOwner,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -872,9 +791,6 @@ function UserDetailPage() {
|
||||
filterNonHanmacFamilyTenants(userAffiliatedTenants, hanmacFamilyTenantId),
|
||||
[userAffiliatedTenants, hanmacFamilyTenantId],
|
||||
);
|
||||
const primaryAppointmentLeafCount = additionalAppointments.filter(
|
||||
(appointment) => appointment.tenantId.trim().length > 0,
|
||||
).length;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -941,10 +857,7 @@ function UserDetailPage() {
|
||||
{user.tenant?.name ||
|
||||
user.companyCode ||
|
||||
user.joinedTenants?.find(
|
||||
(t) =>
|
||||
t.type === "COMPANY" ||
|
||||
t.type === "COMPANY_GROUP" ||
|
||||
t.type === "ORGANIZATION",
|
||||
(t) => t.type === "COMPANY" || t.type === "COMPANY_GROUP",
|
||||
)?.name ||
|
||||
t("ui.admin.users.detail.form.tenant_global", "시스템 전역")}
|
||||
</Badge>
|
||||
@@ -1088,23 +1001,21 @@ function UserDetailPage() {
|
||||
>
|
||||
{t("ui.admin.users.detail.form.status", "상태")}
|
||||
</Label>
|
||||
<Select
|
||||
value={watchedStatus || "inactive"}
|
||||
onValueChange={(status) => setValue("status", status)}
|
||||
>
|
||||
<SelectTrigger id="status" className="h-11">
|
||||
<SelectValue>
|
||||
{userStatusLabel(watchedStatus || "inactive")}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{userStatusValues.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{userStatusLabel(status)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex h-11 items-center gap-3 rounded-md border border-input bg-background px-3">
|
||||
<Switch
|
||||
id="status"
|
||||
checked={watchedStatus === "active"}
|
||||
onCheckedChange={(checked) =>
|
||||
setValue("status", checked ? "active" : "inactive")
|
||||
}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
`ui.common.status.${watchedStatus}`,
|
||||
watchedStatus || "inactive",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1249,26 +1160,6 @@ function UserDetailPage() {
|
||||
{appointment.tenantSlug}
|
||||
</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">
|
||||
<Checkbox
|
||||
checked={appointment.isOwner}
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
ChevronDown,
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
FileDown,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings2,
|
||||
Trash2,
|
||||
User,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -36,13 +33,7 @@ import {
|
||||
DialogTrigger,
|
||||
} from "../../components/ui/dialog";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../../components/ui/select";
|
||||
import { Switch } from "../../components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -53,6 +44,7 @@ import {
|
||||
} from "../../components/ui/table";
|
||||
import { toast } from "../../components/ui/use-toast";
|
||||
import {
|
||||
type UserSummary,
|
||||
bulkDeleteUsers,
|
||||
bulkUpdateUsers,
|
||||
deleteUser,
|
||||
@@ -65,7 +57,6 @@ import {
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { UserBulkUploadModal } from "./components/UserBulkUploadModal";
|
||||
import { userStatusLabel, userStatusValues } from "./userStatus";
|
||||
|
||||
type UserSchemaField = {
|
||||
key: string;
|
||||
@@ -73,10 +64,13 @@ type UserSchemaField = {
|
||||
type: string;
|
||||
};
|
||||
|
||||
type SortConfig = {
|
||||
key: string;
|
||||
direction: "asc" | "desc";
|
||||
};
|
||||
|
||||
function UserListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const addTenantSlug = searchParams.get("addTenant");
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [searchDraft, setSearchDraft] = React.useState("");
|
||||
@@ -85,8 +79,8 @@ function UserListPage() {
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
|
||||
const [sortKey, setSortKey] = React.useState<string>("name");
|
||||
const [sortOrder, setSortOrder] = React.useState<"asc" | "desc">("asc");
|
||||
const [sortConfig, setSortConfig] = React.useState<SortConfig | null>(null);
|
||||
|
||||
const limit = 1000;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
@@ -159,15 +153,6 @@ function UserListPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleSort = (key: string) => {
|
||||
if (sortKey === key) {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortOrder("asc");
|
||||
}
|
||||
};
|
||||
|
||||
const exportMutation = useMutation({
|
||||
mutationFn: (includeIds: boolean) =>
|
||||
exportUsersCSV(search, selectedCompany, includeIds),
|
||||
@@ -201,41 +186,6 @@ function UserListPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const addTenantMutation = useMutation({
|
||||
mutationFn: ({ userId, slug }: { userId: string; slug: string }) =>
|
||||
updateUser(userId, { tenantSlug: slug, isAddTenant: true }),
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
t(
|
||||
"msg.admin.users.add_tenant_success",
|
||||
"해당 테넌트에 추가되었습니다.",
|
||||
),
|
||||
);
|
||||
query.refetch();
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(
|
||||
err.response?.data?.error ||
|
||||
t("msg.admin.users.add_tenant_error", "추가 실패"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const handleAddTenant = (userId: string, userName: string) => {
|
||||
if (!addTenantSlug) return;
|
||||
if (
|
||||
window.confirm(
|
||||
t(
|
||||
"msg.admin.users.add_tenant_confirm",
|
||||
"'{{name}}'님을 '{{tenant}}' 테넌트에 추가하시겠습니까?",
|
||||
{ name: userName, tenant: addTenantSlug },
|
||||
),
|
||||
)
|
||||
) {
|
||||
addTenantMutation.mutate({ userId, slug: addTenantSlug });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setSearch(searchDraft);
|
||||
setPage(1);
|
||||
@@ -261,20 +211,70 @@ function UserListPage() {
|
||||
)
|
||||
: null;
|
||||
|
||||
const items = query.data?.items ?? [];
|
||||
const sortedItems = React.useMemo(() => {
|
||||
return [...items].sort((a, b) => {
|
||||
const valA = (a[sortKey as keyof typeof a] || "")
|
||||
.toString()
|
||||
.toLowerCase();
|
||||
const valB = (b[sortKey as keyof typeof b] || "")
|
||||
.toString()
|
||||
.toLowerCase();
|
||||
if (valA < valB) return sortOrder === "asc" ? -1 : 1;
|
||||
if (valA > valB) return sortOrder === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}, [items, sortKey, sortOrder]);
|
||||
const 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 totalPages = Math.ceil(total / limit);
|
||||
@@ -372,8 +372,52 @@ function UserListPage() {
|
||||
</p>
|
||||
</div>
|
||||
<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
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9"
|
||||
onClick={() => query.refetch()}
|
||||
disabled={query.isFetching}
|
||||
>
|
||||
@@ -401,7 +445,7 @@ function UserListPage() {
|
||||
<UserBulkUploadModal onSuccess={() => query.refetch()} />
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||
<Settings2 size={16} />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
@@ -455,7 +499,7 @@ function UserListPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Button asChild>
|
||||
<Button asChild size="sm" className="h-9">
|
||||
<Link to="/users/new">
|
||||
<Plus size={16} />
|
||||
{t("ui.admin.users.list.add", "사용자 추가")}
|
||||
@@ -465,12 +509,12 @@ function UserListPage() {
|
||||
</header>
|
||||
|
||||
<Card className="flex-1 flex flex-col min-h-0 bg-[var(--color-panel)] overflow-hidden">
|
||||
<CardHeader className="flex flex-row items-center gap-4 py-4 flex-wrap flex-shrink-0">
|
||||
<div className="flex items-baseline gap-3 flex-shrink-0 mr-auto">
|
||||
<CardTitle className="text-lg m-0">
|
||||
{t("ui.admin.users.list.registry.title", "사용자 레지스트리")}
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{t("ui.admin.users.list.registry.title", "User Registry")}
|
||||
</CardTitle>
|
||||
<CardDescription className="m-0">
|
||||
<CardDescription>
|
||||
{t(
|
||||
"msg.admin.users.list.registry.count",
|
||||
"총 {{count}}명의 사용자가 등록되어 있습니다.",
|
||||
@@ -478,55 +522,8 @@ function UserListPage() {
|
||||
)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative w-[240px]">
|
||||
<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>
|
||||
|
||||
<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-[180px] 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}
|
||||
className="h-9"
|
||||
>
|
||||
{t("ui.common.search", "검색")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
|
||||
{(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">
|
||||
{errorMsg ?? fallbackError}
|
||||
@@ -551,75 +548,71 @@ function UserListPage() {
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="min-w-[220px] cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => handleSort("id")}
|
||||
onClick={() => requestSort("id")}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center">
|
||||
{t("ui.admin.users.list.table.id", "ID")}
|
||||
{sortKey === "id" &&
|
||||
(sortOrder === "asc" ? (
|
||||
<ChevronUp size={14} />
|
||||
) : (
|
||||
<ChevronDown size={14} />
|
||||
))}
|
||||
{getSortIcon("id")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="min-w-[200px] cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => handleSort("name")}
|
||||
onClick={() => requestSort("name_email")}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center">
|
||||
{t(
|
||||
"ui.admin.users.list.table.name_email",
|
||||
"이름 / 이메일 / 전화번호",
|
||||
)}
|
||||
{sortKey === "name" &&
|
||||
(sortOrder === "asc" ? (
|
||||
<ChevronUp size={14} />
|
||||
) : (
|
||||
<ChevronDown size={14} />
|
||||
))}
|
||||
{getSortIcon("name_email")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => handleSort("status")}
|
||||
onClick={() => requestSort("status")}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center">
|
||||
{t("ui.admin.users.list.table.status", "STATUS")}
|
||||
{sortKey === "status" &&
|
||||
(sortOrder === "asc" ? (
|
||||
<ChevronUp size={14} />
|
||||
) : (
|
||||
<ChevronDown size={14} />
|
||||
))}
|
||||
{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>
|
||||
{/* Dynamic Columns from Schema */}
|
||||
{userSchema.map(
|
||||
(field) =>
|
||||
visibleColumns[field.key] !== false && (
|
||||
<TableHead key={field.key} className="uppercase">
|
||||
{field.label}
|
||||
<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}
|
||||
{getSortIcon(field.key)}
|
||||
</div>
|
||||
</TableHead>
|
||||
),
|
||||
)}
|
||||
<TableHead
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => handleSort("createdAt")}
|
||||
onClick={() => requestSort("createdAt")}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center">
|
||||
{t("ui.admin.users.list.table.created", "CREATED")}
|
||||
{sortKey === "createdAt" &&
|
||||
(sortOrder === "asc" ? (
|
||||
<ChevronUp size={14} />
|
||||
) : (
|
||||
<ChevronDown size={14} />
|
||||
))}
|
||||
{getSortIcon("createdAt")}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("ui.admin.users.list.table.actions", "ACTIONS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -629,14 +622,11 @@ function UserListPage() {
|
||||
colSpan={6 + userSchema.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="animate-spin" size={20} />
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</div>
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!query.isLoading && sortedItems.length === 0 && (
|
||||
{!query.isLoading && items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6 + userSchema.length}
|
||||
@@ -649,7 +639,7 @@ function UserListPage() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{sortedItems.map((user) => (
|
||||
{items.map((user) => (
|
||||
<TableRow
|
||||
key={user.id}
|
||||
className={
|
||||
@@ -681,14 +671,16 @@ function UserListPage() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<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
|
||||
className="truncate text-sm"
|
||||
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">
|
||||
{" "}
|
||||
{user.email}
|
||||
@@ -701,43 +693,45 @@ function UserListPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableCell>{" "}
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={user.status}
|
||||
onValueChange={(status) =>
|
||||
<Switch
|
||||
checked={user.status === "active"}
|
||||
onCheckedChange={(checked) =>
|
||||
statusMutation.mutate({
|
||||
userId: user.id,
|
||||
status,
|
||||
status: checked ? "active" : "inactive",
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
statusMutation.isPending ||
|
||||
user.id === profile?.id
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="h-8 w-36"
|
||||
aria-label={t(
|
||||
"ui.admin.users.list.status_select",
|
||||
"{{name}} 상태",
|
||||
{ name: user.name },
|
||||
)}
|
||||
data-testid={`user-status-select-${user.id}`}
|
||||
>
|
||||
<SelectValue>
|
||||
{userStatusLabel(user.status)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{userStatusValues.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{userStatusLabel(status)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
aria-label={t(
|
||||
"ui.admin.users.list.toggle_status",
|
||||
"{{name}} 활성 상태",
|
||||
{ name: user.name },
|
||||
)}
|
||||
data-testid={`user-status-toggle-${user.id}`}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t(`ui.common.status.${user.status}`, user.status)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-medium">
|
||||
{user.tenant?.name ||
|
||||
user.companyCode ||
|
||||
t("ui.common.unassigned", "미배정")}
|
||||
</span>
|
||||
{user.department && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{user.department}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* Dynamic Metadata Cells */}
|
||||
@@ -752,54 +746,6 @@ function UserListPage() {
|
||||
<TableCell className="text-sm text-muted-foreground">
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
{addTenantSlug && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-primary"
|
||||
onClick={() =>
|
||||
handleAddTenant(user.id, user.name)
|
||||
}
|
||||
title={t(
|
||||
"ui.admin.users.list.add_to_tenant",
|
||||
"테넌트에 추가",
|
||||
)}
|
||||
disabled={addTenantMutation.isPending}
|
||||
>
|
||||
<UserPlus size={16} />
|
||||
</Button>
|
||||
)}
|
||||
<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>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -837,24 +783,6 @@ function UserListPage() {
|
||||
>
|
||||
{t("ui.common.status.inactive", "비활성화")}
|
||||
</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" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -20,7 +20,7 @@ export function buildTenantFullTree(
|
||||
tenantMap.set(t.id, {
|
||||
...t,
|
||||
children: [],
|
||||
recursiveMemberCount: t.memberCount || 0,
|
||||
recursiveMemberCount: Number(t.memberCount) || 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export function buildTenantFullTree(
|
||||
}
|
||||
visitedForCalc.add(node.id);
|
||||
|
||||
let total = node.memberCount || 0;
|
||||
let total = Number(node.memberCount) || 0;
|
||||
for (const child of node.children) {
|
||||
total += calculateRecursive(child);
|
||||
}
|
||||
|
||||
@@ -157,13 +157,21 @@ func (r *userRepository) CountByCompanyCodes(ctx context.Context, codes []string
|
||||
}
|
||||
var results []result
|
||||
|
||||
// Search by company_codes array using unnest and overlap.
|
||||
// This ensures users with multiple memberships are counted in each tenant they belong to.
|
||||
err := r.db.WithContext(ctx).Table("users").
|
||||
Select("unnest(company_codes) as company_code, count(*) as count").
|
||||
Where("company_codes && ?", pq.Array(lowerStrings(codes))).
|
||||
Group("company_code").
|
||||
Scan(&results).Error
|
||||
lowerCodes := lowerStrings(codes)
|
||||
|
||||
// Combine singular company_code and array company_codes using a subquery
|
||||
// to ensure we count each user accurately per company code they belong to.
|
||||
query := `
|
||||
SELECT LOWER(comp_code) as company_code, count(DISTINCT id) as count
|
||||
FROM (
|
||||
SELECT id, company_code as comp_code FROM users WHERE LOWER(company_code) = ANY($1)
|
||||
UNION ALL
|
||||
SELECT id, unnest(company_codes) as comp_code FROM users WHERE company_codes && $1
|
||||
) as combined
|
||||
WHERE LOWER(comp_code) = ANY($1)
|
||||
GROUP BY LOWER(comp_code)
|
||||
`
|
||||
err := r.db.WithContext(ctx).Raw(query, pq.Array(lowerCodes)).Scan(&results).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user