1
0
forked from baron/baron-sso

chore: consolidate local integration changes

This commit is contained in:
2026-06-09 21:03:05 +09:00
parent aa2848c3b6
commit 1341f07ef9
158 changed files with 10995 additions and 1490 deletions

View File

@@ -25,4 +25,18 @@ describe("org chart rank priority", () => {
);
expect(compareOrgRanks("부장", "차장")).toBeLessThan(0);
});
it("orders top executive ranks before the existing vice president and lower ranks", () => {
const ranks = ["부사장", "고문", "부회장", "사장", "회장"];
expect(ranks.toSorted(compareOrgRanks)).toEqual([
"회장",
"사장",
"부회장",
"고문",
"부사장",
]);
expect(getOrgRankWeight("부사장")).toBe(10);
expect(getOrgRankWeight("전무")).toBe(20);
});
});

View File

@@ -5,7 +5,10 @@ export type OrgRankDefinition = {
};
export const ORG_RANK_DEFINITIONS: OrgRankDefinition[] = [
{ label: "회장", weight: -10, aliases: ["회장"] },
{ label: "사장", weight: 0, aliases: ["사장"] },
{ label: "부회장", weight: 2, aliases: ["부회장"] },
{ label: "고문", weight: 5, aliases: ["고문"] },
{ label: "부사장", weight: 10, aliases: ["부사장"] },
{ label: "전무", weight: 20, aliases: ["전무", "전무이사"] },
{ label: "상무", weight: 30, aliases: ["상무", "상무이사"] },

View File

@@ -8,6 +8,7 @@ import {
getOrgNodeHeaderFill,
getSemanticZoomMode,
layoutForest,
resolveOrgChartFamilyRoot,
type OrgNode,
} from "./OrgChartPage";
@@ -466,7 +467,27 @@ describe("org chart layout", () => {
).toEqual(["총괄기획&기술개발센터", "삼안", "한맥기술", "바론그룹"]);
});
it("always hides internal and private organizations from the organization status chart", () => {
it("selects hanmac family as the default root even when public sector group is listed first", () => {
const publicSector = tenantNode(
"public-sector",
"COMPANY_GROUP",
"공공기관",
"public-sector",
);
const familyRoot = tenantNode(
"family",
"COMPANY_GROUP",
"한맥가족",
"hanmac-family",
[tenantNode("saman", "COMPANY", "삼안", "saman")],
);
expect(resolveOrgChartFamilyRoot([publicSector, familyRoot])?.id).toBe(
"family",
);
});
it("hides internal organizations by default and includes them for internal mode", () => {
const visibleParent = tenantNode(
"visible-parent",
"COMPANY",
@@ -507,12 +528,25 @@ describe("org chart layout", () => {
parentId: "visible-parent",
};
const tenants = [
visibleParent,
internalOrg,
internalChild,
privateOrg,
publicOrg,
];
expect(
filterSystemGlobalTenants(
[visibleParent, internalOrg, internalChild, privateOrg, publicOrg],
"internal",
).map((tenant) => tenant.id),
filterSystemGlobalTenants(tenants, "public").map((tenant) => tenant.id),
).toEqual(["visible-parent", "public-org"]);
expect(
filterSystemGlobalTenants(tenants, "internal").map((tenant) => tenant.id),
).toEqual([
"visible-parent",
"internal-org",
"internal-child",
"public-org",
]);
});
it("maps legacy companyCode users to matching tenant slugs", () => {

View File

@@ -2,10 +2,10 @@ import { useQuery } from "@tanstack/react-query";
import type { Node as ReactFlowNode } from "@xyflow/react";
import * as React from "react";
import { useLocation, useParams } from "react-router-dom";
import { Switch } from "../../../components/ui/switch";
import {
fetchAllTenants,
fetchOrgChartSnapshot,
fetchPublicOrgChart,
fetchUsers,
type TenantSummary,
type UserSummary,
} from "../../../lib/adminApi";
@@ -1122,9 +1122,21 @@ function getOrgSelectionLabel(
?.name;
}
export function resolveOrgChartFamilyRoot(rootNodes: TenantNode[]) {
return (
rootNodes.find(
(node) =>
node.slug.toLowerCase() === "hanmac-family" || node.name === "한맥가족",
) ??
rootNodes.find((node) => node.type === "COMPANY_GROUP") ??
rootNodes[0] ??
null
);
}
export function filterSystemGlobalTenants(
tenants: TenantSummary[],
_visibilityMode: "internal" | "public" = "public",
visibilityMode: "internal" | "public" = "public",
) {
const excludedIds = new Set(
tenants.filter(isSystemGlobalTenant).map((tenant) => tenant.id),
@@ -1148,7 +1160,7 @@ export function filterSystemGlobalTenants(
const filtered = tenants.filter(
(tenant) => !excludedIds.has(tenant.id) && isOrgFrontTenantType(tenant),
);
return filterTenantsByVisibility(filtered, "public");
return filterTenantsByVisibility(filtered, visibilityMode);
}
function filterOrgChartMembershipTenants(tenants: TenantSummary[]) {
@@ -1359,8 +1371,10 @@ export function TenantOrgChartPage() {
const location = useLocation();
const searchParams = new URLSearchParams(location.search);
const shareToken = searchParams.get("token");
const visibilityMode =
searchParams.get("includeInternal") === "true" ? "internal" : "public";
const [includeInternalTenants, setIncludeInternalTenants] = React.useState(
() => !shareToken && searchParams.get("includeInternal") === "true",
);
const visibilityMode = includeInternalTenants ? "internal" : "public";
const [selectedTenantFilter, setSelectedTenantFilter] =
React.useState(FAMILY_FILTER_ID);
const [collapsedIds, setCollapsedIds] = React.useState<Set<string>>(
@@ -1387,15 +1401,9 @@ export function TenantOrgChartPage() {
enabled: !!shareToken,
});
const tenantsQuery = useQuery({
queryKey: ["tenants-full-tree-v2"],
queryFn: () => fetchAllTenants(),
enabled: !shareToken,
});
const usersQuery = useQuery({
queryKey: ["users", { limit: 5000, offset: 0 }],
queryFn: () => fetchUsers(5000, 0),
const orgChartSnapshotQuery = useQuery({
queryKey: ["orgchart-snapshot", { cache: "redis" }],
queryFn: () => fetchOrgChartSnapshot(),
enabled: !shareToken,
});
@@ -1426,7 +1434,7 @@ export function TenantOrgChartPage() {
};
}
if (!tenantsQuery.data?.items || !usersQuery.data?.items) {
if (!orgChartSnapshotQuery.data) {
return {
rootNodes: [],
usersMap: new Map<string, UserSummary[]>(),
@@ -1435,15 +1443,15 @@ export function TenantOrgChartPage() {
}
const rootNodes = buildTenantFullTree(
filterSystemGlobalTenants(tenantsQuery.data.items, visibilityMode),
filterSystemGlobalTenants(orgChartSnapshotQuery.data.tenants, visibilityMode),
).subTree;
const membershipRootNodes = buildTenantFullTree(
filterOrgChartMembershipTenants(tenantsQuery.data.items),
filterOrgChartMembershipTenants(orgChartSnapshotQuery.data.tenants),
).subTree;
return {
rootNodes,
usersMap: buildUsersMap(usersQuery.data.items, rootNodes, {
usersMap: buildUsersMap(orgChartSnapshotQuery.data.users, rootNodes, {
activeOnly: true,
membershipRootNodes,
}),
@@ -1452,17 +1460,12 @@ export function TenantOrgChartPage() {
}, [
publicQuery.data,
shareToken,
tenantsQuery.data,
usersQuery.data,
orgChartSnapshotQuery.data,
visibilityMode,
]);
const familyRoot = React.useMemo(() => {
return (
rootNodes.find((node) => node.type === "COMPANY_GROUP") ??
rootNodes[0] ??
null
);
return resolveOrgChartFamilyRoot(rootNodes);
}, [rootNodes]);
const orgSelectionOptions = React.useMemo(
@@ -1594,10 +1597,10 @@ export function TenantOrgChartPage() {
const isLoading = shareToken
? publicQuery.isLoading
: tenantsQuery.isLoading || usersQuery.isLoading;
: orgChartSnapshotQuery.isLoading;
const isError = shareToken
? publicQuery.isError
: tenantsQuery.isError || usersQuery.isError;
: orgChartSnapshotQuery.isError;
const totalUsers = React.useMemo(() => {
const ids = new Set<string>();
@@ -1635,6 +1638,14 @@ export function TenantOrgChartPage() {
setHasUserMovedCanvas(false);
};
const updateInternalTenantVisibility = (checked: boolean) => {
setIncludeInternalTenants(checked);
setSelectedTenantFilter(FAMILY_FILTER_ID);
setCollapsedIds(new Set());
setHoveredNodeId(null);
setHasUserMovedCanvas(false);
};
return (
<div
className="flex h-full min-h-0 w-full flex-col overflow-hidden border-[#e0d5c1] bg-[#f6efe6]"
@@ -1665,9 +1676,12 @@ export function TenantOrgChartPage() {
testId="orgchart-layout-mode-option"
value={childLayoutMode}
/>
<div className="ml-2 whitespace-nowrap rounded-full border border-[#f2c484]/30 bg-[#f2c484]/10 px-4 py-2 text-xs font-black text-[#f2c484] shadow-sm">
{totalUsers}
</div>
<TotalUsersControl
canToggleInternal={!shareToken}
includeInternal={includeInternalTenants}
onIncludeInternalChange={updateInternalTenantVisibility}
totalUsers={totalUsers}
/>
</div>
</header>
@@ -1750,6 +1764,53 @@ export function TenantOrgChartPage() {
);
}
function TotalUsersControl({
canToggleInternal,
includeInternal,
onIncludeInternalChange,
totalUsers,
}: {
canToggleInternal: boolean;
includeInternal: boolean;
onIncludeInternalChange: (checked: boolean) => void;
totalUsers: number;
}) {
if (!canToggleInternal) {
return (
<div
className="ml-2 whitespace-nowrap rounded-full border border-[#f2c484]/30 bg-[#f2c484]/10 px-4 py-2 text-xs font-black text-[#f2c484] shadow-sm"
data-testid="orgchart-total-users-control"
>
{totalUsers}
</div>
);
}
return (
<div className="group/total relative ml-2">
<button
aria-label={`전체 인원: 총 ${totalUsers}`}
className="whitespace-nowrap rounded-full border border-[#f2c484]/30 bg-[#f2c484]/10 px-4 py-2 text-xs font-black text-[#f2c484] shadow-sm transition-colors hover:border-[#f2c484]/60 hover:bg-[#f2c484]/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#f2c484]/70"
data-testid="orgchart-total-users-control"
type="button"
>
{totalUsers}
</button>
<div className="absolute right-0 top-full z-40 mt-2 hidden w-[220px] rounded-md border border-[#8bd3b2]/35 bg-[#0d3d33] p-3 text-[#e1fff1] shadow-xl group-hover/total:block group-focus-within/total:block">
<label className="flex items-center justify-between gap-3">
<span className="text-xs font-bold"> </span>
<Switch
aria-label="내부조직 보기"
checked={includeInternal}
className="data-[state=checked]:bg-[#f2c484]"
onCheckedChange={onIncludeInternalChange}
/>
</label>
</div>
</div>
);
}
function LayoutOptionPicker<T extends string>({
label,
onChange,

View File

@@ -65,6 +65,7 @@ describe("orgfront adminApi user tenant payloads", () => {
await adminApi.fetchRelyingParty("client-1");
await adminApi.fetchRPOwners("client-1");
await adminApi.fetchPublicOrgChart("public-token");
await adminApi.fetchOrgChartSnapshot();
expect(apiClient.get).toHaveBeenCalledWith("/v1/audit", {
params: { limit: 10, cursor: "cursor-a" },
@@ -90,6 +91,9 @@ describe("orgfront adminApi user tenant payloads", () => {
expect(apiClient.get).toHaveBeenCalledWith("/v1/public/orgchart", {
params: { token: "public-token" },
});
expect(apiClient.get).toHaveBeenCalledWith("/v1/admin/orgchart/snapshot", {
params: { cache: "redis" },
});
});
it("routes mutation APIs to their documented orgfront admin endpoints", async () => {

View File

@@ -32,6 +32,7 @@ export type TenantSummary = {
parentId?: string;
config?: Record<string, unknown>;
memberCount: number; // Added member count
totalMemberCount?: number;
createdAt: string;
updatedAt: string;
};
@@ -159,6 +160,26 @@ export async function fetchAllTenants({
}) as Promise<TenantListResponse>;
}
export type OrgChartSnapshotResponse = {
tenants: TenantSummary[];
users: UserSummary[];
cache?: {
source: "redis" | "database";
hit: boolean;
ttlSeconds?: number;
};
};
export async function fetchOrgChartSnapshot() {
const { data } = await apiClient.get<OrgChartSnapshotResponse>(
"/v1/admin/orgchart/snapshot",
{
params: { cache: "redis" },
},
);
return data;
}
export async function fetchTenant(tenantId: string) {
const { data } = await apiClient.get<TenantSummary>(
`/v1/admin/tenants/${tenantId}`,