1
0
forked from baron/baron-sso

chore: snapshot local state before dev merge

This commit is contained in:
2026-06-17 21:25:42 +09:00
parent b2808759d2
commit 49560e8a8c
107 changed files with 8958 additions and 939 deletions

View File

@@ -0,0 +1,41 @@
import type { OrgChartSnapshotResponse } from "../../lib/adminApi";
export function resolveOrgChartSnapshotTimestamp(
snapshot?: Pick<
OrgChartSnapshotResponse,
"generatedAt" | "tenants" | "users"
>,
) {
if (!snapshot) return "";
if (snapshot.generatedAt?.trim()) return snapshot.generatedAt.trim();
const timestamps = [
...snapshot.tenants.map((tenant) => tenant.updatedAt),
...snapshot.users.map((user) => user.updatedAt),
]
.map((value) => Date.parse(value))
.filter((value) => Number.isFinite(value));
if (timestamps.length === 0) return "";
return new Date(Math.max(...timestamps)).toISOString();
}
export function formatOrgChartSnapshotTimestamp(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Seoul",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
}).formatToParts(date);
const part = (type: Intl.DateTimeFormatPartTypes) =>
parts.find((item) => item.type === type)?.value ?? "";
return `${part("year")}-${part("month")}-${part("day")} ${part("hour")}:${part("minute")}:${part("second")} KST`;
}

View File

@@ -114,6 +114,32 @@ describe("buildOrgPickerTree", () => {
]);
});
it("can expose every visible top-level tenant as picker roots", () => {
const tenants = [
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
tenant("commercial-id", "COMPANY_GROUP", "Commercial", "commercial"),
tenant(
"external-company-id",
"COMPANY",
"외부기업",
"external-company",
"commercial-id",
),
];
const tree = buildOrgPickerTree({
tenants,
users: [] satisfies UserSummary[],
rootTenantId: "all",
});
expect(tree.roots.map((node) => node.id)).toEqual([
"hanmac-family-id",
"commercial-id",
]);
});
it("excludes internal and private tenants from picker choices by default", () => {
const tenants = [
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),

View File

@@ -4,12 +4,14 @@ import {
buildUsersMap,
clampScale,
filterSystemGlobalTenants,
formatOrgChartSnapshotTimestamp,
getMemberGridMetrics,
getOrgNodeHeaderFill,
getSemanticZoomMode,
layoutForest,
type OrgNode,
resolveOrgChartFamilyRoot,
resolveOrgChartSnapshotTimestamp,
} from "./OrgChartPage";
function orgNode(id: string, children: OrgNode[] = [], level = 0): OrgNode {
@@ -255,6 +257,94 @@ describe("org chart layout", () => {
]);
});
it("places organization owners before higher rank members in the same organization", () => {
const members = [
{ ...member("executive"), name: "임원", grade: "전무이사" },
{
...member("owner"),
name: "조직장",
grade: "사원",
metadata: {
additionalAppointments: [
{
tenantSlug: "root",
isOwner: true,
},
],
},
},
{ ...member("principal"), name: "수석", grade: "수석연구원" },
];
const layout = layoutForest(
[
{
...orgNode("root"),
members,
totalCount: members.length,
totalMemberIds: new Set(members.map((item) => item.id)),
},
],
new Set(),
);
const rootNode = layout.nodes.find((item) => item.node.id === "root");
expect(rootNode?.members.map((item) => item.id)).toEqual([
"owner",
"executive",
"principal",
]);
});
it("does not place representative organization members before leaders", () => {
const members = [
{ ...member("executive"), name: "임원", grade: "전무이사" },
{
...member("representative"),
name: "대표조직장",
grade: "사원",
metadata: {
additionalAppointments: [
{
tenantSlug: "root",
representative: true,
},
],
},
},
{
...member("team-head"),
name: "팀장",
grade: "선임",
metadata: {
additionalAppointments: [
{
tenantSlug: "root",
position: "팀장",
},
],
},
},
];
const layout = layoutForest(
[
{
...orgNode("root"),
members,
totalCount: members.length,
totalMemberIds: new Set(members.map((item) => item.id)),
},
],
new Set(),
);
const rootNode = layout.nodes.find((item) => item.node.id === "root");
expect(rootNode?.members.map((item) => item.id)).toEqual([
"team-head",
"executive",
"representative",
]);
});
it("expands node width for long organization names even without members", () => {
const shortLayout = layoutForest([orgNode("short")], new Set());
const longLayout = layoutForest(
@@ -272,6 +362,7 @@ describe("org chart layout", () => {
const longNode = longLayout.nodes.find((item) => item.node.id === "long");
expect(longNode?.width).toBeGreaterThan(shortNode?.width ?? 0);
expect(longNode?.width).toBeGreaterThan(640);
});
it("uses multi-column layout by default when sibling width crosses the threshold", () => {
@@ -356,7 +447,7 @@ describe("org chart layout", () => {
expect(rootEdges.filter((edge) => edge.visibleByDefault)).toHaveLength(3);
});
it("places the deepest child subtree in the first multi-column section", () => {
it("preserves source data order in multi-column sections", () => {
const children = [
orgNode("shallow-1", [], 1),
orgNode("shallow-2", [], 1),
@@ -378,11 +469,14 @@ describe("org chart layout", () => {
const layout = layoutForest([orgNode("root", children)], new Set(), {
childLayoutMode: "threeColumn",
});
const rootEdges = layout.edges.filter((edge) =>
edge.key.startsWith("root->"),
);
const directChildren = layout.nodes
.filter((node) => node.node.level === 1)
.sort((a, b) => a.y - b.y || a.x - b.x)
.map((node) => node.node.id);
expect(rootEdges.map((edge) => edge.key)).toContain("root->deep");
expect(directChildren.slice(0, children.length)).toEqual(
children.map((child) => child.id),
);
});
it("centers a parent over the full child span in multi-column mode", () => {
@@ -491,6 +585,24 @@ describe("org chart layout", () => {
expect(getSemanticZoomMode(0.8)).toBe("detail");
});
it("uses the snapshot generatedAt as the org chart data timestamp", () => {
expect(
resolveOrgChartSnapshotTimestamp({
generatedAt: "2026-06-17T07:10:11Z",
tenants: [tenantNode("tenant", "COMPANY", "Tenant", "tenant")],
users: [
{
...member("user"),
updatedAt: "2026-06-17T09:10:11Z",
},
],
}),
).toBe("2026-06-17T07:10:11Z");
expect(formatOrgChartSnapshotTimestamp("2026-06-17T07:10:11Z")).toBe(
"2026-06-17 16:10:11 KST",
);
});
it("uses distinct header fills by organization depth", () => {
expect(getOrgNodeHeaderFill(0, "family")).toBe("#000000");
expect(getOrgNodeHeaderFill(0, "saman")).toBe("#f58220");
@@ -606,13 +718,13 @@ describe("org chart layout", () => {
]);
});
it("maps legacy companyCode users to matching tenant slugs", () => {
it("does not map scalar-only tenant slugs without a membership source", () => {
const usersMap = buildUsersMap(
[
{
...member("engineering-user"),
...member("scalar-only-user"),
companyCode: "engineering",
tenantSlug: undefined,
tenantSlug: "engineering",
tenant: undefined,
joinedTenants: undefined,
},
@@ -621,8 +733,41 @@ describe("org chart layout", () => {
{ activeOnly: true },
);
expect(usersMap.get("engineering")?.map((user) => user.id)).toEqual([
"engineering-user",
expect(usersMap.get("engineering")).toBeUndefined();
});
it("maps super admin users when they have an actual org chart appointment", () => {
const usersMap = buildUsersMap(
[
{
...member("super-admin-leader"),
role: "super_admin",
companyCode: "is-1",
tenantSlug: "is-2",
tenant: undefined,
joinedTenants: undefined,
metadata: {
additionalAppointments: [
{
tenantSlug: "is-3",
isManager: true,
},
],
},
},
],
[
tenantNode("is-1", "ORGANIZATION", "IS1", "is-1"),
tenantNode("is-2", "ORGANIZATION", "IS2", "is-2"),
tenantNode("is-3", "ORGANIZATION", "IS3", "is-3"),
],
{ activeOnly: true },
);
expect(usersMap.get("is-1")).toBeUndefined();
expect(usersMap.get("is-2")).toBeUndefined();
expect(usersMap.get("is-3")?.map((user) => user.id)).toEqual([
"super-admin-leader",
]);
});

View File

@@ -18,6 +18,11 @@ import { getOrgRankWeight } from "../rankPriority";
import { filterTenantsByVisibility, getOrgUnitType } from "../tenantVisibility";
import { getOrgChartUserDisplayName, getUserOrgProfile } from "../userDisplay";
export {
formatOrgChartSnapshotTimestamp,
resolveOrgChartSnapshotTimestamp,
} from "../orgChartSnapshotTime";
export type OrgNode = {
id: string;
name: string;
@@ -105,7 +110,6 @@ const MEMBER_CARD_CHAR_WIDTH = 12;
const MEMBER_CARD_TEXT_PADDING_X = 28;
const MEMBER_COLUMN_MAX_WIDTH = 280;
const NODE_HEADER_CHAR_WIDTH = 17;
const NODE_HEADER_WRAP_THRESHOLD = 420;
const NODE_HEADER_TEXT_PADDING_X = 112;
const NODE_HEADER_TYPE_BADGE_WIDTH = 48;
const MEMBER_GRID_TARGET_ASPECT_RATIO = 2;
@@ -162,7 +166,7 @@ function getManagerWeight(
user: UserSummary,
tenant?: { id: string; slug: string },
) {
return getUserOrgProfile(user, tenant).isManager ? 0 : 1;
return getUserOrgProfile(user, tenant).leadershipWeight;
}
function compareOrgMembers(
@@ -272,14 +276,9 @@ function getNodeHeaderWidth(node: OrgNode) {
const typeBadgeWidth = node.orgUnitType ? NODE_HEADER_TYPE_BADGE_WIDTH : 0;
const titleTextWidth =
getDisplayTextWidthUnit(node.name) * NODE_HEADER_CHAR_WIDTH;
const oneLineWidth =
NODE_HEADER_TEXT_PADDING_X + typeBadgeWidth + titleTextWidth;
if (oneLineWidth <= NODE_HEADER_WRAP_THRESHOLD) {
return Math.ceil(oneLineWidth);
}
const estimatedTitleWidth =
NODE_HEADER_TEXT_PADDING_X + typeBadgeWidth + titleTextWidth / 2;
return Math.ceil(estimatedTitleWidth);
return Math.ceil(
NODE_HEADER_TEXT_PADDING_X + typeBadgeWidth + titleTextWidth,
);
}
function getNodeWidth(
@@ -562,19 +561,8 @@ function getRowOffsets(heights: number[], gap: number) {
return offsets;
}
function getLayoutMaxDepth(layout: ChartLayout) {
return Math.max(...layout.nodes.map((visualNode) => visualNode.node.level));
}
function orderChildLayoutsForMultiColumn(childLayouts: ChartLayout[]) {
return childLayouts
.map((layout, index) => ({
depth: getLayoutMaxDepth(layout),
index,
layout,
}))
.sort((a, b) => b.depth - a.depth || a.index - b.index)
.map((entry) => entry.layout);
return childLayouts;
}
function rangesOverlap(
@@ -1109,20 +1097,7 @@ function isSystemGlobalTenant(
}
function isSystemGlobalUser(user: UserSummary) {
const normalizedRole = user.role.toLowerCase().replaceAll("_", "-");
return (
normalizedRole === "super-admin" ||
normalizedRole === "superadmin" ||
normalizedRole === "system-admin" ||
isSystemGlobalTenant(user.tenant) ||
isSystemGlobalTenant({
id: user.tenantSlug || "",
slug: user.tenantSlug || "",
type: user.role,
name: user.role,
})
);
return isSystemGlobalTenant(user.tenant);
}
function findNodeByTenantId(
@@ -1390,34 +1365,6 @@ export function buildUsersMap(
if (!isVisibleOrgChartUser(user)) continue;
const tenantIds = new Set<string>();
const primarySlug = normalizeOrgSlug(user.tenantSlug);
const legacyCompanySlug = normalizeOrgSlug(user.companyCode);
if (
primarySlug &&
!isSystemGlobalTenant({
id: primarySlug,
slug: primarySlug,
type: primarySlug,
name: primarySlug,
})
) {
addTenantSlugCandidate(tenantIds, membershipTenantIndexes, primarySlug);
}
if (
legacyCompanySlug &&
!isSystemGlobalTenant({
id: legacyCompanySlug,
slug: legacyCompanySlug,
type: legacyCompanySlug,
name: legacyCompanySlug,
})
) {
addTenantSlugCandidate(
tenantIds,
membershipTenantIndexes,
legacyCompanySlug,
);
}
if (user.tenant?.slug && !isSystemGlobalTenant(user.tenant)) {
addTenantIdCandidate(tenantIds, membershipTenantIndexes, user.tenant.id);
addTenantSlugCandidate(
@@ -1607,7 +1554,6 @@ export function TenantOrgChartPage() {
() => getOrgSelectionLabel(familyRoot, selectedTenantFilter) ?? "한맥가족",
[familyRoot, selectedTenantFilter],
);
React.useEffect(() => {
if (!tenantId) return;
const searchRoots = familyRoot ? [familyRoot] : rootNodes;
@@ -1800,7 +1746,7 @@ export function TenantOrgChartPage() {
data-testid="orgchart-dashboard-shell"
>
<header className="z-20 grid shrink-0 grid-cols-1 items-center gap-3 border-b border-[#f2c484]/30 bg-[linear-gradient(145deg,rgba(10,42,34,0.98)_0%,rgba(15,58,47,0.98)_52%,rgba(26,86,69,0.98)_100%)] px-6 py-4 lg:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]">
<div className="flex min-w-0 justify-start">
<div className="flex min-w-0 flex-wrap items-start justify-start gap-2">
<OrgSelectionPicker
onChange={updateSelectedOrg}
options={orgSelectionOptions}

View File

@@ -1,5 +1,12 @@
import { GitBranch, Network, PanelTop } from "lucide-react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { GitBranch, Network, PanelTop, RefreshCw } from "lucide-react";
import * as React from "react";
import { NavLink, Outlet, useLocation } from "react-router-dom";
import { fetchOrgChartSnapshot } from "../../../lib/adminApi";
import {
formatOrgChartSnapshotTimestamp,
resolveOrgChartSnapshotTimestamp,
} from "../orgChartSnapshotTime";
const navItems = [
{ to: "/chart", label: "조직도", icon: Network },
@@ -7,6 +14,59 @@ const navItems = [
{ to: "/embed-preview", label: "임베딩 검증", icon: PanelTop },
];
function OrgFrontSnapshotStatus() {
const queryClient = useQueryClient();
const [isRefreshingSnapshot, setIsRefreshingSnapshot] = React.useState(false);
const snapshotQuery = useQuery({
queryKey: ["orgchart-snapshot", { cache: "redis" }],
queryFn: () => fetchOrgChartSnapshot(),
staleTime: 0,
refetchOnMount: "always",
refetchOnWindowFocus: true,
});
const timestamp = React.useMemo(() => {
const value = resolveOrgChartSnapshotTimestamp(snapshotQuery.data);
return value ? formatOrgChartSnapshotTimestamp(value) : "";
}, [snapshotQuery.data]);
const isRefreshing = snapshotQuery.isFetching || isRefreshingSnapshot;
const refreshSnapshot = React.useCallback(() => {
setIsRefreshingSnapshot(true);
void fetchOrgChartSnapshot({ refresh: true })
.then((snapshot) => {
queryClient.setQueryData(
["orgchart-snapshot", { cache: "redis" }],
snapshot,
);
})
.finally(() => setIsRefreshingSnapshot(false));
}, [queryClient]);
return (
<div
className="flex h-7 min-w-[190px] max-w-full shrink-0 items-center justify-end gap-1.5 bg-transparent text-muted-foreground opacity-[0.72]"
data-testid="orgchart-render-status-panel"
>
<p
className="min-w-0 flex-1 truncate text-right text-[11px] font-bold"
data-testid="orgchart-data-timestamp"
>
{timestamp || "-"}
</p>
<button
aria-label="새로고침"
className="inline-flex h-6 w-6 shrink-0 appearance-none items-center justify-center rounded border-0 bg-transparent p-0 text-muted-foreground shadow-none transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-60"
disabled={isRefreshing}
onClick={refreshSnapshot}
title="새로고침"
type="button"
>
<RefreshCw className={isRefreshing ? "animate-spin" : ""} size={14} />
</button>
</div>
);
}
export function OrgFrontLayout() {
const location = useLocation();
const isChartRoute =
@@ -24,32 +84,38 @@ export function OrgFrontLayout() {
className="sticky top-0 z-30 shrink-0 border-b border-border bg-background/95 backdrop-blur"
data-testid="orgfront-topbar"
>
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-4 md:flex-row md:items-center md:justify-between">
<div className="flex w-full flex-col gap-3 px-4 pb-1 pt-4 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Baron Orgfront
</p>
<h1 className="text-xl font-semibold"> </h1>
</div>
<nav className="flex flex-wrap gap-2" aria-label="주요 메뉴">
{navItems.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
[
"inline-flex h-10 items-center gap-2 rounded-md border px-3 text-sm font-semibold transition",
isActive
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-muted-foreground hover:text-foreground",
].join(" ")
}
>
<Icon size={16} />
{label}
</NavLink>
))}
</nav>
<div
className="flex min-w-0 flex-wrap items-end justify-end gap-2 md:self-end"
data-testid="orgfront-topbar-actions"
>
<nav className="flex flex-wrap gap-2" aria-label="주요 메뉴">
{navItems.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
[
"inline-flex h-10 items-center gap-2 rounded-md border px-3 text-sm font-semibold transition",
isActive
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-card text-muted-foreground hover:text-foreground",
].join(" ")
}
>
<Icon size={16} />
{label}
</NavLink>
))}
</nav>
<OrgFrontSnapshotStatus />
</div>
</div>
</header>

View File

@@ -274,6 +274,8 @@ describe("OrgPickerEmbedPage orgchart data source", () => {
id: "user-1",
name: "Snapshot User",
email: "user-1@example.com",
rootTenantName: "Hanmac Family",
leafTenantName: "Snapshot Company",
},
],
},

View File

@@ -101,6 +101,25 @@ describe("getOrgChartUserDisplayName", () => {
),
).toBe("홍길동 책임");
});
it("does not fall back to the user grade for a tenant-bound display", () => {
expect(
getOrgChartUserDisplayName(
user({
grade: "책임",
metadata: {
additionalAppointments: [
{
tenantId: "tenant-1",
tenantSlug: "hanmac",
},
],
},
}),
{ id: "tenant-1", slug: "hanmac" },
),
).toBe("홍길동");
});
});
describe("getUserOrgProfile", () => {
@@ -164,4 +183,24 @@ describe("getUserOrgProfile", () => {
false,
);
});
it("does not treat representative organization membership as an organization leader", () => {
const profile = getUserOrgProfile(
user({
metadata: {
additionalAppointments: [
{
tenantSlug: "hanmac",
representative: true,
position: "팀원",
},
],
},
}),
{ id: "tenant-1", slug: "hanmac" },
);
expect(profile.isLeader).toBe(false);
expect(profile.isHighlighted).toBe(false);
});
});

View File

@@ -7,6 +7,8 @@ type UserAppointment = {
isAdmin?: boolean;
isManager?: boolean;
isOwner?: boolean;
isRepresentative?: boolean;
representative?: boolean;
grade?: string;
jobTitle?: string;
position?: string;
@@ -33,12 +35,39 @@ function getUserAppointments(user: UserSummary): UserAppointment[] {
isAdmin: item.isAdmin === true,
isManager: item.isManager === true,
isOwner: item.isOwner === true,
isRepresentative: item.isRepresentative === true,
representative: item.representative === true,
grade: normalizeText(item.grade),
jobTitle: normalizeText(item.jobTitle),
position: normalizeText(item.position),
}));
}
function isOrganizationLeaderPosition(position: string) {
const normalized = position.replace(/\s+/g, "");
if (!normalized) return false;
return [
"센터장",
"그룹장",
"본부장",
"실장",
"부문장",
"부서장",
"팀장",
"파트장",
"셀장",
"디비전장",
"디비젼장",
"division장",
"divisionhead",
"manager",
"leader",
"lead",
"head",
].some((keyword) => normalized.toLowerCase().includes(keyword));
}
export function getUserOrgProfile(user: UserSummary, tenant?: TenantIdentity) {
const appointment = getUserAppointments(user).find((item) => {
if (tenant?.id && item.tenantId === tenant.id) return true;
@@ -51,16 +80,24 @@ export function getUserOrgProfile(user: UserSummary, tenant?: TenantIdentity) {
}
return false;
});
const grade =
tenant && appointment ? appointment.grade : normalizeText(user.grade);
const position = appointment?.position || normalizeText(user.position);
const hasExplicitLeaderFlag =
appointment?.isManager === true || appointment?.isOwner === true;
const hasLeaderPosition = isOrganizationLeaderPosition(position);
const isLeader = hasExplicitLeaderFlag || hasLeaderPosition;
return {
grade: appointment?.grade || normalizeText(user.grade),
isHighlighted:
appointment?.isAdmin === true ||
appointment?.isManager === true ||
appointment?.isOwner === true,
isManager: appointment?.isManager === true,
grade,
isHighlighted: appointment?.isAdmin === true || isLeader,
isLeader,
leadershipWeight: hasExplicitLeaderFlag ? 0 : hasLeaderPosition ? 1 : 2,
isManager: isLeader,
isOwner: appointment?.isOwner === true,
jobTitle: appointment?.jobTitle || normalizeText(user.jobTitle),
position: appointment?.position || normalizeText(user.position),
position,
};
}