forked from baron/baron-sso
네이버 웍스 연동기능 개선
This commit is contained in:
@@ -104,8 +104,10 @@ import { t } from "../../../lib/i18n";
|
||||
import { normalizeAdminRole } from "../../../lib/roles";
|
||||
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
|
||||
import {
|
||||
buildAuthenticatedOrgChartTenantPickerUrl,
|
||||
filterNonHanmacFamilyTenants,
|
||||
isHanmacFamilyUser,
|
||||
parseOrgChartTenantSelection,
|
||||
} from "../../users/orgChartPicker";
|
||||
import { isSeedTenant } from "../utils/protectedTenants";
|
||||
import {
|
||||
@@ -117,6 +119,14 @@ import {
|
||||
parseTenantCSV,
|
||||
serializeTenantImportCSV,
|
||||
} from "../utils/tenantCsvImport";
|
||||
import {
|
||||
type TenantViewMode,
|
||||
type TenantViewRow,
|
||||
filterTenantsByScope,
|
||||
getTenantViewRows,
|
||||
resolveTenantSelectionIds,
|
||||
tenantMatchesListSearch,
|
||||
} from "./tenantListView";
|
||||
|
||||
const tenantCSVTemplate =
|
||||
"name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type\n";
|
||||
@@ -266,6 +276,9 @@ function TenantListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [viewMode, setViewMode] = React.useState<TenantViewMode>("tree");
|
||||
const [scopeTenantId, setScopeTenantId] = React.useState("");
|
||||
const [scopePickerOpen, setScopePickerOpen] = React.useState(false);
|
||||
const [sortConfig, setSortConfig] =
|
||||
React.useState<SortConfig<TenantSortKey> | null>({
|
||||
key: "createdAt",
|
||||
@@ -470,8 +483,14 @@ function TenantListPage() {
|
||||
? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.")
|
||||
: null;
|
||||
|
||||
const tenantPages = query.data?.pages ?? [];
|
||||
const rawTenants = tenantPages.flatMap((page) => page.items);
|
||||
const tenantPages = React.useMemo(
|
||||
() => query.data?.pages ?? [],
|
||||
[query.data?.pages],
|
||||
);
|
||||
const rawTenants = React.useMemo(
|
||||
() => tenantPages.flatMap((page) => page.items),
|
||||
[tenantPages],
|
||||
);
|
||||
const tenantTotal = tenantPages[0]?.total ?? 0;
|
||||
const hanmacFamilyTenantId = React.useMemo(() => {
|
||||
const envTenantId = import.meta.env.VITE_HANMAC_FAMILY_TENANT_ID;
|
||||
@@ -492,6 +511,18 @@ function TenantListPage() {
|
||||
}
|
||||
return filterNonHanmacFamilyTenants(rawTenants, hanmacFamilyTenantId);
|
||||
}, [hanmacFamilyTenantId, profile, profileRole, rawTenants]);
|
||||
const scopedTenants = React.useMemo(
|
||||
() => filterTenantsByScope(allTenants, scopeTenantId),
|
||||
[allTenants, scopeTenantId],
|
||||
);
|
||||
const selectedScopeTenant = React.useMemo(
|
||||
() => allTenants.find((tenant) => tenant.id === scopeTenantId),
|
||||
[allTenants, scopeTenantId],
|
||||
);
|
||||
const scopePickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
|
||||
import.meta.env.ORGFRONT_URL,
|
||||
hanmacFamilyTenantId ? { tenantId: hanmacFamilyTenantId } : {},
|
||||
);
|
||||
const importParentOptionGroups =
|
||||
buildTenantImportParentOptionGroups(allTenants);
|
||||
|
||||
@@ -511,10 +542,37 @@ function TenantListPage() {
|
||||
};
|
||||
|
||||
const deletableTenants = React.useMemo(
|
||||
() => allTenants.filter((tenant) => !isSeedTenant(tenant)),
|
||||
[allTenants],
|
||||
() => scopedTenants.filter((tenant) => !isSeedTenant(tenant)),
|
||||
[scopedTenants],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const selectableIds = new Set(deletableTenants.map((tenant) => tenant.id));
|
||||
setSelectedIds((prev) => {
|
||||
const next = prev.filter((id) => selectableIds.has(id));
|
||||
if (next.length === prev.length) {
|
||||
return prev;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [deletableTenants]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!scopePickerOpen) return;
|
||||
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
const selection = parseOrgChartTenantSelection(event.data);
|
||||
if (!selection) return;
|
||||
if (!allTenants.some((tenant) => tenant.id === selection.id)) return;
|
||||
|
||||
setScopeTenantId(selection.id);
|
||||
setScopePickerOpen(false);
|
||||
};
|
||||
|
||||
window.addEventListener("message", onMessage);
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, [allTenants, scopePickerOpen]);
|
||||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedIds(deletableTenants.map((t) => t.id));
|
||||
@@ -527,11 +585,15 @@ function TenantListPage() {
|
||||
if (isSeedTenant(tenant)) {
|
||||
return;
|
||||
}
|
||||
if (checked) {
|
||||
setSelectedIds((prev) => [...prev, tenant.id]);
|
||||
} else {
|
||||
setSelectedIds((prev) => prev.filter((i) => i !== tenant.id));
|
||||
}
|
||||
setSelectedIds((prev) =>
|
||||
resolveTenantSelectionIds({
|
||||
currentIds: prev,
|
||||
tenant,
|
||||
checked,
|
||||
tenants: allTenants,
|
||||
deletableTenants,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteBulk = () => {
|
||||
@@ -701,13 +763,67 @@ function TenantListPage() {
|
||||
<Input
|
||||
placeholder={t(
|
||||
"ui.admin.tenants.list.search_placeholder",
|
||||
"테넌트 이름 또는 슬러그 검색...",
|
||||
"테넌트 이름, 슬러그, UUID 검색...",
|
||||
)}
|
||||
className="pl-9 h-9"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex rounded-md border bg-background p-0.5"
|
||||
data-testid="tenant-view-mode-toggle"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant={viewMode === "tree" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="h-8 gap-1.5"
|
||||
onClick={() => setViewMode("tree")}
|
||||
data-testid="tenant-view-tree-btn"
|
||||
>
|
||||
<Network size={14} />
|
||||
{t("ui.admin.tenants.view.tree", "트리")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={viewMode === "table" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="h-8 gap-1.5"
|
||||
onClick={() => setViewMode("table")}
|
||||
data-testid="tenant-view-table-btn"
|
||||
>
|
||||
<List size={14} />
|
||||
{t("ui.admin.tenants.view.table", "평면")}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant={scopeTenantId ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="h-9 gap-2"
|
||||
onClick={() => setScopePickerOpen(true)}
|
||||
data-testid="tenant-scope-picker-btn"
|
||||
>
|
||||
<Network size={16} />
|
||||
{selectedScopeTenant
|
||||
? t("ui.admin.tenants.scope.active", "{{name}} 하위", {
|
||||
name: selectedScopeTenant.name,
|
||||
})
|
||||
: t("ui.admin.tenants.scope.pick", "상위 범위 선택")}
|
||||
</Button>
|
||||
{scopeTenantId && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9"
|
||||
onClick={() => setScopeTenantId("")}
|
||||
data-testid="tenant-scope-clear-btn"
|
||||
>
|
||||
{t("ui.common.clear", "초기화")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<RoleGuard roles={["super_admin"]}>
|
||||
<input
|
||||
@@ -818,7 +934,7 @@ function TenantListPage() {
|
||||
"msg.admin.tenants.registry.count",
|
||||
"총 {{count}}개 테넌트",
|
||||
{
|
||||
count: tenantTotal,
|
||||
count: scopeTenantId ? scopedTenants.length : tenantTotal,
|
||||
},
|
||||
)}
|
||||
</CardDescription>
|
||||
@@ -846,10 +962,34 @@ function TenantListPage() {
|
||||
sortConfig={sortConfig}
|
||||
requestSort={requestSort}
|
||||
getSortIcon={getSortIcon}
|
||||
viewMode={viewMode}
|
||||
scopeTenantId={scopeTenantId}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={scopePickerOpen} onOpenChange={setScopePickerOpen}>
|
||||
<DialogContent className="max-w-[480px] p-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.tenants.scope.pick", "상위 범위 선택")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.tenants.scope.description",
|
||||
"orgfront 조직 선택기에서 상위 테넌트를 선택하면 해당 하위 테넌트만 표시합니다.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<iframe
|
||||
title={t("ui.admin.tenants.scope.pick", "상위 범위 선택")}
|
||||
src={scopePickerUrl}
|
||||
className="h-[600px] w-full rounded-md border"
|
||||
data-testid="tenant-scope-picker-frame"
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Bulk Action Bar */}
|
||||
{selectedIds.length > 0 && (
|
||||
<div
|
||||
@@ -1212,6 +1352,8 @@ const TenantHierarchyView: React.FC<{
|
||||
sortConfig: SortConfig<TenantSortKey> | null;
|
||||
requestSort: (key: TenantSortKey) => void;
|
||||
getSortIcon: (key: TenantSortKey) => React.ReactNode;
|
||||
viewMode: TenantViewMode;
|
||||
scopeTenantId: string;
|
||||
}> = ({
|
||||
tenants,
|
||||
selectedIds,
|
||||
@@ -1226,10 +1368,12 @@ const TenantHierarchyView: React.FC<{
|
||||
sortConfig,
|
||||
requestSort,
|
||||
getSortIcon,
|
||||
viewMode,
|
||||
scopeTenantId,
|
||||
}) => {
|
||||
const { subTree } = React.useMemo(
|
||||
() => buildTenantFullTree(tenants),
|
||||
[tenants],
|
||||
() => buildTenantFullTree(tenants, scopeTenantId || undefined),
|
||||
[scopeTenantId, tenants],
|
||||
);
|
||||
|
||||
// Initial expanded state: everything open
|
||||
@@ -1245,6 +1389,18 @@ const TenantHierarchyView: React.FC<{
|
||||
return ids;
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const ids = new Set<string>();
|
||||
const collect = (nodes: TenantNode[]) => {
|
||||
for (const n of nodes) {
|
||||
ids.add(n.id);
|
||||
if (n.children) collect(n.children);
|
||||
}
|
||||
};
|
||||
collect(subTree);
|
||||
setExpandedIds((prev) => new Set([...prev, ...ids]));
|
||||
}, [subTree]);
|
||||
|
||||
const toggleExpand = (id: string) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
@@ -1267,7 +1423,17 @@ const TenantHierarchyView: React.FC<{
|
||||
);
|
||||
|
||||
const flattenedRows = React.useMemo(() => {
|
||||
const result: (TenantNode & { depth: number })[] = [];
|
||||
if (viewMode === "table") {
|
||||
return sortItems(
|
||||
getTenantViewRows(tenants, "table", scopeTenantId).filter((tenant) =>
|
||||
tenantMatchesListSearch(tenant, search),
|
||||
),
|
||||
sortConfig,
|
||||
tenantSortResolvers,
|
||||
);
|
||||
}
|
||||
|
||||
const result: TenantViewRow[] = [];
|
||||
const term = search.toLowerCase().trim();
|
||||
|
||||
// When searching, we show matched nodes and all their ancestors.
|
||||
@@ -1275,10 +1441,7 @@ const TenantHierarchyView: React.FC<{
|
||||
if (term) {
|
||||
const findMatches = (nodes: TenantNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if (
|
||||
node.name.toLowerCase().includes(term) ||
|
||||
node.slug.toLowerCase().includes(term)
|
||||
) {
|
||||
if (tenantMatchesListSearch(node, term)) {
|
||||
matchedIds.add(node.id);
|
||||
}
|
||||
if (node.children) findMatches(node.children);
|
||||
@@ -1312,7 +1475,24 @@ const TenantHierarchyView: React.FC<{
|
||||
};
|
||||
collect(subTree, 0);
|
||||
return result;
|
||||
}, [subTree, expandedIds, search, sortConfig, tenantSortResolvers]);
|
||||
}, [
|
||||
expandedIds,
|
||||
scopeTenantId,
|
||||
search,
|
||||
sortConfig,
|
||||
subTree,
|
||||
tenantSortResolvers,
|
||||
tenants,
|
||||
viewMode,
|
||||
]);
|
||||
|
||||
const visibleSelectableIds = React.useMemo(
|
||||
() => new Set(deletableTenants.map((tenant) => tenant.id)),
|
||||
[deletableTenants],
|
||||
);
|
||||
const visibleSelectedCount = selectedIds.filter((id) =>
|
||||
visibleSelectableIds.has(id),
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col mt-4">
|
||||
@@ -1324,7 +1504,7 @@ const TenantHierarchyView: React.FC<{
|
||||
<Checkbox
|
||||
checked={
|
||||
deletableTenants.length > 0 &&
|
||||
selectedIds.length === deletableTenants.length
|
||||
visibleSelectedCount === deletableTenants.length
|
||||
}
|
||||
onCheckedChange={(checked) => onSelectAll(!!checked)}
|
||||
/>
|
||||
@@ -1409,8 +1589,12 @@ const TenantHierarchyView: React.FC<{
|
||||
</TableRow>
|
||||
)}
|
||||
{flattenedRows.map((node) => {
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
const isExpanded = expandedIds.has(node.id) || !!search;
|
||||
const hasChildren =
|
||||
viewMode === "tree" &&
|
||||
node.children &&
|
||||
node.children.length > 0;
|
||||
const isExpanded =
|
||||
viewMode === "tree" && (expandedIds.has(node.id) || !!search);
|
||||
const TypeIcon = getTenantIcon(node.type);
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user