forked from baron/baron-sso
chore: snapshot local state before dev merge
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
FROM node:lts AS build
|
||||
FROM node:lts AS deps
|
||||
|
||||
WORKDIR /workspace
|
||||
|
||||
@@ -20,6 +20,17 @@ ENV VITE_OIDC_CLIENT_ID=$VITE_OIDC_CLIENT_ID
|
||||
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
FROM deps AS dev
|
||||
|
||||
WORKDIR /workspace/orgfront
|
||||
ENV NODE_ENV=development
|
||||
|
||||
EXPOSE 5175
|
||||
|
||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5175"]
|
||||
|
||||
FROM deps AS build
|
||||
|
||||
WORKDIR /workspace/orgfront
|
||||
RUN npm run build
|
||||
|
||||
|
||||
BIN
orgfront/e2e-evidence/orgchart-is3-hanchiyoung-20260616.png
Normal file
BIN
orgfront/e2e-evidence/orgchart-is3-hanchiyoung-20260616.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 183 KiB |
BIN
orgfront/e2e-evidence/orgchart-leader-long-name-20260616.png
Normal file
BIN
orgfront/e2e-evidence/orgchart-leader-long-name-20260616.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
@@ -10,11 +10,13 @@ const { shouldIncludeWebKit } =
|
||||
const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
|
||||
? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10)
|
||||
: undefined;
|
||||
const port = Number.parseInt(process.env.PORT ?? "4175", 10);
|
||||
const port = Number.parseInt(process.env.PORT ?? "5175", 10);
|
||||
const defaultBaseUrl = `http://127.0.0.1:${port}`;
|
||||
const baseURL = process.env.BASE_URL ?? defaultBaseUrl;
|
||||
const reuseExistingServer = !process.env.CI && !process.env.PORT;
|
||||
const testOidcAuthority = "http://localhost:5000/oidc";
|
||||
const usePreviewServer =
|
||||
process.env.CI === "true" || process.env.PLAYWRIGHT_USE_PREVIEW === "true";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
@@ -79,7 +81,7 @@ export default defineConfig({
|
||||
webServer: process.env.BASE_URL
|
||||
? undefined
|
||||
: {
|
||||
command: process.env.CI
|
||||
command: usePreviewServer
|
||||
? `VITE_OIDC_AUTHORITY=${testOidcAuthority} npm run build && VITE_OIDC_AUTHORITY=${testOidcAuthority} npm run preview -- --host 127.0.0.1 --port ${port}`
|
||||
: `VITE_OIDC_AUTHORITY=${testOidcAuthority} npm run dev -- --host 127.0.0.1 --port ${port}`,
|
||||
url: defaultBaseUrl,
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -163,6 +163,7 @@ export async function fetchAllTenants({
|
||||
export type OrgChartSnapshotResponse = {
|
||||
tenants: TenantSummary[];
|
||||
users: UserSummary[];
|
||||
generatedAt?: string;
|
||||
cache?: {
|
||||
source: "redis" | "database";
|
||||
hit: boolean;
|
||||
@@ -170,11 +171,15 @@ export type OrgChartSnapshotResponse = {
|
||||
};
|
||||
};
|
||||
|
||||
export async function fetchOrgChartSnapshot() {
|
||||
export async function fetchOrgChartSnapshot({
|
||||
refresh = false,
|
||||
}: {
|
||||
refresh?: boolean;
|
||||
} = {}) {
|
||||
const { data } = await apiClient.get<OrgChartSnapshotResponse>(
|
||||
"/v1/admin/orgchart/snapshot",
|
||||
{
|
||||
params: { cache: "redis" },
|
||||
params: { cache: "redis", ...(refresh ? { refresh: "true" } : {}) },
|
||||
},
|
||||
);
|
||||
return data;
|
||||
|
||||
@@ -179,6 +179,7 @@ async function installOrgPickerApiMock(
|
||||
user("user-sales", "Sales User", "sales"),
|
||||
];
|
||||
const orgChartSnapshot = {
|
||||
generatedAt: "2026-06-17T07:10:11Z",
|
||||
tenants,
|
||||
users,
|
||||
};
|
||||
@@ -230,16 +231,49 @@ test.beforeEach(async ({ page }) => {
|
||||
test("developer navigation exposes chart, picker, and embed preview", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
await page.goto(withShareToken("/chart"));
|
||||
|
||||
await expect(page.getByRole("link", { name: "조직도" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "조직 선택기" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "임베딩 검증" })).toBeVisible();
|
||||
await expect(page.getByTestId("orgchart-render-status-panel")).toHaveText(
|
||||
"2026-06-17 16:10:11 KST",
|
||||
);
|
||||
let statusPanelBox = await page
|
||||
.getByTestId("orgchart-render-status-panel")
|
||||
.boundingBox();
|
||||
expect(
|
||||
(page.viewportSize()?.width ?? 1600) -
|
||||
((statusPanelBox?.x ?? 0) + (statusPanelBox?.width ?? 0)),
|
||||
).toBeLessThanOrEqual(20);
|
||||
|
||||
await page.getByRole("link", { name: "조직 선택기" }).click();
|
||||
await expect(page.getByTestId("orgchart-render-status-panel")).toHaveText(
|
||||
"2026-06-17 16:10:11 KST",
|
||||
);
|
||||
statusPanelBox = await page
|
||||
.getByTestId("orgchart-render-status-panel")
|
||||
.boundingBox();
|
||||
expect(
|
||||
(page.viewportSize()?.width ?? 1600) -
|
||||
((statusPanelBox?.x ?? 0) + (statusPanelBox?.width ?? 0)),
|
||||
).toBeLessThanOrEqual(20);
|
||||
|
||||
await page.getByRole("link", { name: "임베딩 검증" }).click();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "임베딩 검증" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("orgchart-render-status-panel")).toHaveText(
|
||||
"2026-06-17 16:10:11 KST",
|
||||
);
|
||||
statusPanelBox = await page
|
||||
.getByTestId("orgchart-render-status-panel")
|
||||
.boundingBox();
|
||||
expect(
|
||||
(page.viewportSize()?.width ?? 1600) -
|
||||
((statusPanelBox?.x ?? 0) + (statusPanelBox?.width ?? 0)),
|
||||
).toBeLessThanOrEqual(20);
|
||||
await expect(
|
||||
page
|
||||
.frameLocator("iframe")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { captureEvidence } from "./helpers/evidence";
|
||||
|
||||
function tenant(
|
||||
id: string,
|
||||
@@ -31,6 +32,12 @@ function user(id: string, name: string, companyCode: string) {
|
||||
role: "user",
|
||||
status: "active",
|
||||
companyCode,
|
||||
tenant: {
|
||||
id: companyCode,
|
||||
slug: companyCode,
|
||||
type: "USER_GROUP",
|
||||
name: companyCode,
|
||||
},
|
||||
grade: "사원",
|
||||
createdAt: "2026-04-01T00:00:00.000Z",
|
||||
updatedAt: "2026-04-01T00:00:00.000Z",
|
||||
@@ -333,6 +340,139 @@ test("org chart orders managers before top executive members by rank priority",
|
||||
]);
|
||||
});
|
||||
|
||||
test("org chart renders an IS3 super admin when an org appointment exists", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route("**/api/v1/public/orgchart**", async (route) => {
|
||||
await route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
sharedWith: "Playwright",
|
||||
tenants: [
|
||||
tenant("hanmac-family", "한맥가족", "hanmac-family"),
|
||||
tenant(
|
||||
"gpdtdc",
|
||||
"총괄기획&기술개발센터",
|
||||
"gpdtdc",
|
||||
"hanmac-family",
|
||||
"COMPANY",
|
||||
),
|
||||
tenant("gpd", "총괄기획실", "gpd", "gpdtdc", "ORGANIZATION"),
|
||||
tenant(
|
||||
"intigrated-system",
|
||||
"통합시스템",
|
||||
"intigrated-system",
|
||||
"gpd",
|
||||
"ORGANIZATION",
|
||||
{ visibility: "public", orgUnitType: "디비전" },
|
||||
),
|
||||
tenant("is-3", "IS3", "is-3", "intigrated-system", "ORGANIZATION", {
|
||||
visibility: "public",
|
||||
orgUnitType: "팀",
|
||||
}),
|
||||
],
|
||||
users: [
|
||||
{
|
||||
id: "675a3d46-45ad-4e8c-8c22-959a38302826",
|
||||
email: "cyhan@samaneng.com",
|
||||
name: "한치영",
|
||||
role: "super_admin",
|
||||
status: "active",
|
||||
tenantSlug: "is-3",
|
||||
companyCode: "is-3",
|
||||
tenant: undefined,
|
||||
grade: "",
|
||||
metadata: {
|
||||
additionalAppointments: [
|
||||
{
|
||||
grade: "책임",
|
||||
isManager: true,
|
||||
isPrimary: true,
|
||||
tenantId: "is-3",
|
||||
tenantName: "IS3",
|
||||
tenantSlug: "is-3",
|
||||
},
|
||||
],
|
||||
},
|
||||
createdAt: "2026-06-16T00:00:00.000Z",
|
||||
updatedAt: "2026-06-16T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
...user("is3-executive", "상위직급", "is-3"),
|
||||
grade: "전무이사",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/chart?token=is3-manager");
|
||||
|
||||
const is3Node = page.locator('[data-testid="orgchart-node-is-3"]');
|
||||
await expect(is3Node).toBeVisible();
|
||||
await expect(
|
||||
is3Node.locator(
|
||||
'[data-testid="orgchart-member-675a3d46-45ad-4e8c-8c22-959a38302826"]',
|
||||
),
|
||||
).toContainText("한치영 책임");
|
||||
await expect(
|
||||
is3Node.locator('[data-testid="orgchart-member-is3-executive"]'),
|
||||
).toContainText("상위직급 전무");
|
||||
|
||||
const orderedMemberIds = await is3Node
|
||||
.locator('[data-testid^="orgchart-member-"]')
|
||||
.evaluateAll((elements) =>
|
||||
elements.map((element) => element.getAttribute("data-testid")),
|
||||
);
|
||||
|
||||
expect(orderedMemberIds).toEqual([
|
||||
"orgchart-member-675a3d46-45ad-4e8c-8c22-959a38302826",
|
||||
"orgchart-member-is3-executive",
|
||||
]);
|
||||
});
|
||||
|
||||
test("org chart ignores stale scalar org fields without a membership source", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route("**/api/v1/public/orgchart**", async (route) => {
|
||||
await route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
sharedWith: "Playwright",
|
||||
tenants: [
|
||||
tenant("hanmac-family", "한맥가족", "hanmac-family"),
|
||||
tenant("is-1", "IS1", "is-1", "hanmac-family", "ORGANIZATION"),
|
||||
tenant("is-2", "IS2", "is-2", "hanmac-family", "ORGANIZATION"),
|
||||
tenant("is-3", "IS3", "is-3", "hanmac-family", "ORGANIZATION"),
|
||||
],
|
||||
users: [
|
||||
{
|
||||
id: "stale-system-admin",
|
||||
email: "stale-system-admin@example.com",
|
||||
name: "Stale System Admin",
|
||||
role: "system_admin",
|
||||
status: "active",
|
||||
tenantSlug: "is-2",
|
||||
companyCode: "is-1",
|
||||
tenant: undefined,
|
||||
joinedTenants: undefined,
|
||||
metadata: { additionalAppointments: [] },
|
||||
grade: "사원",
|
||||
createdAt: "2026-06-16T00:00:00.000Z",
|
||||
updatedAt: "2026-06-16T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/chart?token=stale-scalar");
|
||||
|
||||
await expect(
|
||||
page.locator('[data-testid="orgchart-member-stale-system-admin"]'),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("org chart expands organization node width so long names are not clipped", async ({
|
||||
page,
|
||||
}) => {
|
||||
@@ -551,17 +691,31 @@ test("org chart places multi-tenant users only on leaf memberships without dupli
|
||||
|
||||
test("org chart allows a user in a hanmac-family descendant tenant", async ({
|
||||
page,
|
||||
}) => {
|
||||
}, testInfo) => {
|
||||
await page.setViewportSize({ width: 1600, height: 900 });
|
||||
let snapshotRequests = 0;
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("playwright_auth_bypass", "1");
|
||||
window.localStorage.setItem("dev_tenant_id", "saman-id");
|
||||
});
|
||||
|
||||
await page.route("**/api/v1/admin/orgchart/snapshot**", async (route) => {
|
||||
snapshotRequests += 1;
|
||||
const url = new URL(route.request().url());
|
||||
expect(route.request().headers()["x-tenant-id"]).toBe("saman-id");
|
||||
expect(url.searchParams.get("cache")).toBe("redis");
|
||||
if (snapshotRequests === 1) {
|
||||
expect(url.searchParams.get("refresh")).toBeNull();
|
||||
} else {
|
||||
expect(url.searchParams.get("refresh")).toBe("true");
|
||||
}
|
||||
await route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
generatedAt:
|
||||
snapshotRequests === 1
|
||||
? "2026-06-17T07:10:11Z"
|
||||
: "2026-06-17T08:20:21Z",
|
||||
tenants: [
|
||||
tenant(
|
||||
"hanmac-family-id",
|
||||
@@ -607,10 +761,63 @@ test("org chart allows a user in a hanmac-family descendant tenant", async ({
|
||||
await expect(
|
||||
page.getByRole("button", { name: "조직: 한맥가족" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("orgchart-selection-status-panel")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(page.getByTestId("orgchart-render-status-panel")).toHaveText(
|
||||
"2026-06-17 16:10:11 KST",
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId("orgchart-render-status-panel"),
|
||||
).not.toContainText("데이터 기준");
|
||||
const headerBox = await page.getByTestId("orgfront-topbar").boundingBox();
|
||||
const viewportWidth = page.viewportSize()?.width ?? 1600;
|
||||
const statusPanelBox = await page
|
||||
.getByTestId("orgchart-render-status-panel")
|
||||
.boundingBox();
|
||||
expect(headerBox).not.toBeNull();
|
||||
expect(statusPanelBox).not.toBeNull();
|
||||
expect(
|
||||
viewportWidth - ((statusPanelBox?.x ?? 0) + (statusPanelBox?.width ?? 0)),
|
||||
).toBeLessThanOrEqual(20);
|
||||
expect(
|
||||
(headerBox?.y ?? 0) +
|
||||
(headerBox?.height ?? 0) -
|
||||
((statusPanelBox?.y ?? 0) + (statusPanelBox?.height ?? 0)),
|
||||
).toBeLessThanOrEqual(6);
|
||||
await expect(page.getByTestId("orgchart-render-status-panel")).toHaveCSS(
|
||||
"border-top-width",
|
||||
"0px",
|
||||
);
|
||||
await expect(page.getByTestId("orgchart-render-status-panel")).toHaveCSS(
|
||||
"box-shadow",
|
||||
"none",
|
||||
);
|
||||
await expect(page.getByTestId("orgchart-render-status-panel")).toHaveCSS(
|
||||
"background-color",
|
||||
"rgba(0, 0, 0, 0)",
|
||||
);
|
||||
await expect(page.getByTestId("orgchart-render-status-panel")).toHaveCSS(
|
||||
"opacity",
|
||||
"0.72",
|
||||
);
|
||||
await expect(page.getByRole("button", { name: "새로고침" })).toHaveCSS(
|
||||
"background-color",
|
||||
"rgba(0, 0, 0, 0)",
|
||||
);
|
||||
await expect(page.getByRole("button", { name: "새로고침" })).toHaveCSS(
|
||||
"border-top-width",
|
||||
"0px",
|
||||
);
|
||||
await page.getByRole("button", { name: "새로고침" }).click();
|
||||
await expect(page.getByTestId("orgchart-render-status-panel")).toHaveText(
|
||||
"2026-06-17 17:20:21 KST",
|
||||
);
|
||||
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
|
||||
await expect(svg.getByText("한맥가족", { exact: true })).toBeVisible();
|
||||
await expect(svg.getByText("삼안", { exact: true })).toBeVisible();
|
||||
await expect(svg.getByText(/Saman Descendant User/)).toBeVisible();
|
||||
await captureEvidence(page, testInfo, "orgchart-topbar-data-refresh-aligned");
|
||||
});
|
||||
|
||||
test("org chart logs authenticated snapshot failures with actionable diagnostics", async ({
|
||||
|
||||
Reference in New Issue
Block a user