forked from baron/baron-sso
chore: snapshot local state before dev merge
This commit is contained in:
41
orgfront/src/features/orgchart/orgChartSnapshotTime.ts
Normal file
41
orgfront/src/features/orgchart/orgChartSnapshotTime.ts
Normal 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`;
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user