1
0
forked from baron/baron-sso

네이버 웍스 연동기능 개선

This commit is contained in:
2026-05-18 15:36:30 +09:00
parent c71ece84b8
commit e29d056b9e
61 changed files with 4137 additions and 710 deletions

View File

@@ -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 (