feat(org-chart): add core cards and fixed-layout integration

This commit is contained in:
shkim4 committed 2026-10-12 17:58:30 +09:00
1 parent d18e3c3380
commit 61c6f8d1f1
161 files changed
+13044 -1

No files matched your search

+81
View File
@@ -0,0 +1,81 @@
/// <reference path="../../packages/org-chart/src/core/vendor.d.ts" />
import { useLayoutEffect, useRef } from 'react';
import { calculateSummary, normalizeOrgChartDataset, validateOrgChartDataset } from '@hm-design/org-chart';
import engineUrl from '@hm-design/org-chart/engine.css?url';
import coreUrl from '@hm-design/org-chart/style.css?url';
import { createEmployeeCard, type EmployeeCardOptions } from '../../packages/org-chart/src/core/employee-card';
import { decorateNode, engineTree, type RenderContext } from '../../packages/org-chart/src/core/render';
import { applyOrganizationStyle } from '../../packages/org-chart/src/core/style-presets';
import { createPositionSummary } from '../../packages/org-chart/src/core/position-summary';
import { createAffiliationSummary } from '../../packages/org-chart/src/core/affiliation-summary';
import { createStatusSummary } from '../../packages/org-chart/src/core/status-summary';
import { createLogoLegend } from '../../packages/org-chart/src/core/logo-legend';
import { createDepartmentColors, updateDepartmentColorValues } from '../../packages/org-chart/src/core/department-colors';
import fixture from '../../examples/org-chart/data-p1.json';
type PreviewKind = 'Logo' | 'DepartmentColors' | 'PositionSummary' | 'AffiliationSummary' | 'StatusSummary' | 'EmployeeCard' | 'OrganizationCards';
interface Props { kind: PreviewKind; data?: unknown; variant?: number; cardKind?: EmployeeCardOptions['kind'] }
/** Story host only. Every pixel/card/count is rendered by the same Core modules
* used by Vanilla HTML and the production React Wrapper, not JSX copies. */
export function CoreComponentPreview({ kind, data = fixture, variant = 0, cardKind = 'employee' }: Props) {
const ref = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const validation = validateOrgChartDataset(data);
const host = ref.current!;
if (!validation.valid) { host.textContent = validation.issues.map(issue => issue.code).join(', '); return; }
const dataset = validation.data;
const normalized = normalizeOrgChartDataset(dataset);
const context: RenderContext = {
data: dataset, summary: calculateSummary(dataset), orderedOrganizations: [], employeesByOrganization: normalized.employeesByOrganization,
topLeaderIds: new Set(normalized.roots.filter(node => node.organization.type === 'root' && node.organization.leaderId).map(node => node.organization.leaderId!)),
};
engineTree(normalized.roots, 'component-preview', context);
const controller = new AbortController();
const root = document.createElement('div');
root.className = 'hm-orgchart-component-host';
const shadow = root.attachShadow({ mode: 'open' });
for (const href of [engineUrl, coreUrl]) {
const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = href; shadow.append(link);
link.addEventListener('load', () => updateDepartmentColorValues(shell), { signal: controller.signal });
}
const shell = document.createElement('section');
shell.className = 'hm-orgchart-shell';
let component: HTMLElement;
if (kind === 'Logo') component = createLogoLegend(document, context, controller.signal);
else if (kind === 'DepartmentColors') component = createDepartmentColors(document, context, controller.signal);
else if (kind === 'PositionSummary') component = createPositionSummary(document, context);
else if (kind === 'AffiliationSummary') component = createAffiliationSummary(document, context);
else if (kind === 'StatusSummary') component = createStatusSummary(document, context, controller.signal);
else if (kind === 'OrganizationCards') {
const organization = normalized.roots[0]?.children[variant]?.organization ?? normalized.roots[0]!.organization;
const wrapper = document.createElement('div');
wrapper.className = 'hm-orgchart-engine orgchart hm-orgchart-component-cards';
component = document.createElement('div'); component.className = 'node';
const card = document.createElement('article'); card.className = 'hm-orgchart-organization'; component.append(card);
decorateNode(component, { id: 'preview-org', name: '', organizationIndex: context.orderedOrganizations.indexOf(organization), depth: 1, groupVariant: variant }, context, controller.signal, () => undefined);
// Branch members stay in their actual fixture organizations; previews use
// the shared EmployeeCard renderer without modifying primary membership.
const roster = document.createElement('div'); roster.className = 'hm-orgchart-preview-grid';
const descendants = new Set<string>([organization.id]);
for (const item of context.orderedOrganizations) if (item.parentId && descendants.has(item.parentId)) descendants.add(item.id);
for (const employee of dataset.employees.filter(item => descendants.has(item.organizationId) && item.id !== organization.leaderId).slice(0, 3)) {
roster.append(createEmployeeCard(document, employee, { kind: 'employee', affiliationName: dataset.affiliations.find(item => item.id === employee.affiliationId)?.name }, controller.signal));
}
component.append(roster); wrapper.append(component); component = wrapper;
} else {
const organization = normalized.roots[0]?.children[variant]?.organization ?? normalized.roots[0]!.organization;
const id = cardKind === 'topLeader' ? normalized.roots[0]!.organization.leaderId : organization.leaderId;
const employee = cardKind !== 'employee' ? dataset.employees.find(item => item.id === id) : dataset.employees.find(item => item.id !== id && item.organizationId === organization.id) ?? dataset.employees.find(item => !item.jobTitle);
component = createEmployeeCard(document, employee ?? dataset.employees[0]!, { kind: cardKind, affiliationName: dataset.affiliations.find(item => item.id === employee?.affiliationId)?.name }, controller.signal);
applyOrganizationStyle(component, variant);
}
component.dataset.component = kind;
shell.append(component); shadow.append(shell); host.replaceChildren(root);
updateDepartmentColorValues(shell);
const observer = kind === 'DepartmentColors' ? new ResizeObserver(() => updateDepartmentColorValues(shell)) : undefined;
observer?.observe(root);
return () => { observer?.disconnect(); controller.abort(); root.remove(); };
}, [kind, data, variant, cardKind]);
return <div ref={ref} className="hm-orgchart-story-chart" />;
}
+28
View File
@@ -0,0 +1,28 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { CoreComponentPreview } from './core-component-preview';
import { organizationStylePresets } from '../../packages/org-chart/src/core/style-presets';
const meta = { title: 'OrgChart/Components/Cards', tags: ['internal'], component: CoreComponentPreview, parameters: { layout: 'padded', controls: { disable: true } } } satisfies Meta<typeof CoreComponentPreview>;
export default meta;
type Story = StoryObj<typeof meta>;
function organization(variant: number): Story {
const preset = organizationStylePresets[variant]!;
return {
name: preset.label,
args: { kind: 'OrganizationCards', variant },
render: () => <section className="hm-orgchart-story">
<p>{preset.label} · 실측 헤더 컬러 {preset.accentColor} · {preset.backgroundImage}</p>
<p>OrgChart 전용 --hm-orgchart-accent / --hm-orgchart-group-background. Workdesk {preset.version}, {preset.observedOn}, {preset.selector}. 직원 정보와 통계는 가상 데이터입니다. 카드 배경은 OrgChart 독립 기본값을 사용합니다.</p>
<CoreComponentPreview kind="OrganizationCards" variant={variant} />
</section>,
};
}
export const BusinessManagement = organization(0);
export const BusinessPlanning = organization(1);
export const TalentGrowth = organization(2);
export const IntegratedSystem = organization(3);
export const DesignPlanning = organization(4);
export const SolutionSales = organization(5);
export const TopLeader: Story = { name: '최상위 리더', args: { kind: 'EmployeeCard', cardKind: 'topLeader' } };
export const OrganizationLeader: Story = { name: '조직장', args: { kind: 'EmployeeCard', cardKind: 'leader' } };
export const Employee: Story = { name: '일반 직원', args: { kind: 'EmployeeCard', cardKind: 'employee' } };
+56
View File
@@ -0,0 +1,56 @@
import { StrictMode, useMemo, useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Button } from '@hm-design/ui';
import { OrgChart } from '@hm-design/org-chart-react';
import { JsonOrgChartDataSource } from '@hm-design/org-chart';
import engineUrl from '@hm-design/org-chart/engine.css?url';
import coreUrl from '@hm-design/org-chart/style.css?url';
import fixture from '../../examples/org-chart/data-p1.json';
import './org-chart-stories.css';
import { CoreComponentPreview } from './core-component-preview';
const compositionSource = new JsonOrgChartDataSource(fixture);
const compositionStylesheets = { engine: engineUrl, core: coreUrl };
/** View-only Workdesk composition: the same Core/React Wrapper with
* displayMode="readOnly". No Story notes, test controls or collapse buttons;
* JSON update/lifecycle and collapse checks stay in the internal Content Stories. */
function CompositionPreview() {
return <section className="hm-orgchart-story hm-orgchart-story-full" aria-label="Full Composition">
<OrgChart dataSource={compositionSource} stylesheetUrls={compositionStylesheets} displayMode="readOnly" className="hm-orgchart-story-chart" />
</section>;
}
function DepartmentTypographyPreview() {
const [longNames, setLongNames] = useState(false);
const data = useMemo(() => {
const data = structuredClone(fixture);
if (longNames) for (const id of ['mock-leader-00', 'mock-leader-01', 'mock-employee-01']) {
data.employees.find(item => item.id === id)!.name = '아주긴가상이름표시검증사례';
}
return data;
}, [longNames]);
return <section className="hm-orgchart-story">
<p className="hm-orgchart-story-note">기본 이름은 2–3음절의 가상 이름입니다. 긴 이름 검증에서는 폰트·사진·카드 크기를 유지하며 말줄임된 이름에 Hover 또는 Tab Focus로 전체 이름을 확인하고 Escape로 닫을 수 있습니다.</p>
<div className="hm-orgchart-story-controls"><Button appearance="outline" aria-pressed={longNames} onClick={() => setLongNames(value => !value)}>긴 이름 검증 {longNames ? '종료' : '시작'}</Button></div>
<CoreComponentPreview kind="DepartmentColors" data={data} />
</section>;
}
const meta = { title: 'OrgChart', component: CompositionPreview, parameters: { layout: 'padded', controls: { disable: true } }, decorators: [(Story) => <StrictMode><Story /></StrictMode>] } satisfies Meta<typeof CompositionPreview>;
export default meta;
type Story = StoryObj<typeof meta>;
export const LogoPersonnelSummary: Story = {
name: 'Logo · Personnel Summary',
render: () => <section className="hm-orgchart-story" aria-label="Logo · Personnel Summary">
<CoreComponentPreview kind="Logo" />
<CoreComponentPreview kind="PositionSummary" />
<CoreComponentPreview kind="AffiliationSummary" />
</section>,
};
export const DepartmentColors: Story = { name: 'Department Colors', render: () => <DepartmentTypographyPreview /> };
export const FullComposition: Story = { name: 'Full Composition' };
// Preserve existing standalone Story IDs for verification and direct links.
// The default manager excludes "internal"; user-facing navigation has three pages.
export const Logo: Story = { name: 'Logo', tags: ['internal'], render: () => <CoreComponentPreview kind="Logo" /> };
export const PositionSummary: Story = { name: 'Position Summary', tags: ['internal'], render: () => <><p className="hm-orgchart-story-note">가상 직원 데이터 자동 집계 · 실제 운영 인원수가 아닙니다.</p><CoreComponentPreview kind="PositionSummary" /></> };
export const AffiliationSummary: Story = { name: 'Affiliation Summary', tags: ['internal'], render: () => <><p className="hm-orgchart-story-note">가상 직원 데이터 자동 집계 · 실제 운영 인원수가 아닙니다.</p><CoreComponentPreview kind="AffiliationSummary" /></> };
+61
View File
@@ -0,0 +1,61 @@
/* Story host layout only. Core nodes/styles remain inside their Shadow DOM. */
.hm-orgchart-story {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1rem;
width: 100%;
min-width: 0;
max-width: 1880px;
}
/* Full Composition scales itself to the available width; no host cap. */
.hm-orgchart-story-full { max-width: none; }
body:has(.hm-orgchart-story-full) { background: #F5F8FB; }
.hm-orgchart-story-full { background: #F5F8FB; }
.hm-orgchart-story-note {
margin: 0;
overflow-wrap: anywhere;
}
.hm-orgchart-story-controls {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
min-width: 0;
}
.hm-orgchart-story-state {
display: block;
overflow-wrap: anywhere;
}
.hm-orgchart-story-chart,
.hm-orgchart-story-responsive,
.hm-orgchart-story-instance {
min-width: 0;
max-width: 100%;
}
.hm-orgchart-story-chart {
width: 100%;
}
.hm-orgchart-story-instances {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
min-width: 0;
}
.hm-orgchart-story-instance {
display: grid;
align-content: start;
gap: 0.5rem;
}
@media (max-width: 767px) {
.hm-orgchart-story-instances {
grid-template-columns: minmax(0, 1fr);
}
}
@@ -0,0 +1,30 @@
import type { Meta, StoryObj } from "@storybook/react-vite"
import { CoreComponentPreview } from "./core-component-preview"
// The shared P1 fixture uses real organization/company names, virtual employees,
// and automatic Raw Count. Acquired logo mapping stays in the shared Core/asset
// layer; Stories do not duplicate summary UI or add employee data fields.
const meta = {
title: "OrgChart/Components/Summaries",
tags: ["internal"],
component: CoreComponentPreview,
args: { kind: "PositionSummary" },
parameters: { layout: "padded", controls: { disable: true } },
} satisfies Meta<typeof CoreComponentPreview>
export default meta
type Story = StoryObj<typeof meta>
export const PositionSummary: Story = {
render: () => <CoreComponentPreview kind="PositionSummary" />,
}
export const AffiliationSummary: Story = {
args: { kind: "AffiliationSummary" },
render: () => <CoreComponentPreview kind="AffiliationSummary" />,
}
export const StatusSummary: Story = {
args: { kind: "StatusSummary" },
render: () => <CoreComponentPreview kind="StatusSummary" />,
}
+217
View File
@@ -0,0 +1,217 @@
import { StrictMode, useCallback, useState, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import { Button } from "@hm-design/ui"
import { OrgChart } from "@hm-design/org-chart-react"
import { JsonOrgChartDataSource, type OrgChartDataSource, type OrgChartState } from "@hm-design/org-chart"
import engineCssUrl from "@hm-design/org-chart/engine.css?url"
import coreCssUrl from "@hm-design/org-chart/style.css?url"
import dataV1 from "../../examples/org-chart/data-v1.json"
import dataV2 from "../../examples/org-chart/data-v2.json"
import "./org-chart-stories.css"
// These are the same source files consumed by the Vanilla examples. The Core
// validates the unknown JSON, rather than a Story casting it to a trusted type.
const sourceV1 = new JsonOrgChartDataSource(dataV1)
const sourceV2 = new JsonOrgChartDataSource(dataV2)
const stylesheetUrls = { engine: engineCssUrl, core: coreCssUrl }
const hierarchyData = structuredClone(dataV1)
hierarchyData.presentation.groups.showEmployees = false
hierarchyData.presentation.groups.showSummary = false
const hierarchySource = new JsonOrgChartDataSource(hierarchyData)
const employeeData = structuredClone(dataV1)
employeeData.presentation.groups.showSummary = false
employeeData.organizations[1].name = "가상 기획팀 · 긴 조직명을 가진 콘텐츠 표시 확인"
employeeData.employees[2].name = "가상직원 긴 이름 표시 확인"
employeeData.employees[2].position = "가상 장기 프로젝트 기획 및 운영 담당 선임"
employeeData.employees[2].jobTitle = "가상 콘텐츠 및 협업 담당"
const employeeSource = new JsonOrgChartDataSource(employeeData)
const noEmployeeData = structuredClone(dataV1)
noEmployeeData.employees = []
for (const organization of noEmployeeData.organizations) organization.leaderId = null
noEmployeeData.metadata.dataVersion = "story-zero-employees"
const noEmployeeSource = new JsonOrgChartDataSource(noEmployeeData)
const noOrganizationData = structuredClone(noEmployeeData)
noOrganizationData.organizations = []
noOrganizationData.relations = []
noOrganizationData.presentation.organizationOrder = []
noOrganizationData.metadata.dataVersion = "story-zero-organizations"
const noOrganizationSource = new JsonOrgChartDataSource(noOrganizationData)
/** Test transport only: chart DOM, validation and errors remain owned by Core. */
function delayedSource(payload: unknown, delayMs: number, fail = false, ignoreAbort = false): OrgChartDataSource {
return {
load({ signal } = {}) {
return new Promise((resolve, reject) => {
const abort = () => {
clearTimeout(timer)
signal?.removeEventListener("abort", abort)
reject(new DOMException("Story source cancelled", "AbortError"))
}
const timer = setTimeout(() => {
signal?.removeEventListener("abort", abort)
if (fail) reject(new Error("Fictitious Story source failure"))
else resolve(structuredClone(payload))
}, delayMs)
if (!ignoreAbort) {
if (signal?.aborted) abort()
else signal?.addEventListener("abort", abort, { once: true })
}
})
},
}
}
function Content({ note, children }: { note: string; children: ReactNode }) {
return <section className="hm-orgchart-story"><p className="hm-orgchart-story-note text-sm text-muted-foreground">{note}</p>{children}</section>
}
function StateOutput({ state, updates, label = "현재 상태" }: { state: OrgChartState; updates: number; label?: string }) {
return (
<output className="hm-orgchart-story-state text-sm" aria-live="polite" aria-label={label}>
{label}: {state.status}
{state.status === "ready" && ` · ${state.dataVersion} · 원본 직원 ${state.summary.raw.total}명`}
{state.status === "error" && ` · ${state.issues.map(issue => issue.code).join(", ")}`}
{` · 상태 알림 ${updates}회`}
</output>
)
}
function useChartState() {
const [state, setState] = useState<OrgChartState>({ status: "idle" })
const [updates, setUpdates] = useState(0)
const onStateChange = useCallback((next: OrgChartState) => {
setState(next)
setUpdates(count => count + 1)
}, [])
return { state, updates, onStateChange }
}
function DataUpdateContent() {
const [source, setSource] = useState<OrgChartDataSource>(sourceV1)
const [mounted, setMounted] = useState(true)
const status = useChartState()
return (
<Content note="HTML 예제와 같은 JSON v1(4명) / v2(5명)를 사용합니다. Source 객체 교체로 Core reload를 실행하며 모든 Story는 React Strict Mode에서 렌더링됩니다.">
<div className="hm-orgchart-story-controls">
<Button appearance="outline" onClick={() => setSource(sourceV1)}>JSON v1</Button>
<Button appearance="outline" onClick={() => setSource(sourceV2)}>JSON v2</Button>
<Button variant="cancel" appearance="outline" onClick={() => setMounted(value => !value)}>{mounted ? "Unmount" : "Mount"}</Button>
</div>
<StateOutput {...status} />
{mounted && <OrgChart dataSource={source} stylesheetUrls={stylesheetUrls} onStateChange={status.onStateChange} className="hm-orgchart-story-chart" />}
</Content>
)
}
function LoadingContent() {
const [source, setSource] = useState<OrgChartDataSource>(() => delayedSource(dataV1, 1800))
const status = useChartState()
return (
<Content note="직원 0명은 ready, 조직 0개는 error입니다. 실제 empty 상태 UI는 현재 Core에서 미지원(TBD)이며 지원한 것으로 표시하지 않습니다. 느린 v1 요청 중 Ready v2를 선택하면 늦은 v1 응답이 무시되어야 합니다.">
<div className="hm-orgchart-story-controls">
<Button appearance="outline" onClick={() => setSource(delayedSource(dataV1, 1800))}>Loading v1</Button>
<Button appearance="outline" onClick={() => setSource(delayedSource(dataV1, 4000, false, true))}>느린 v1 (취소 무시)</Button>
<Button variant="destructive" appearance="outline" onClick={() => setSource(delayedSource(dataV1, 350, true))}>Source Error</Button>
<Button appearance="outline" onClick={() => setSource(sourceV2)}>Ready v2 / 복구</Button>
<Button appearance="outline" onClick={() => setSource(noEmployeeSource)}>직원 0명 (Ready)</Button>
<Button variant="destructive" appearance="outline" onClick={() => setSource(noOrganizationSource)}>조직 0개 (Error)</Button>
</div>
<StateOutput {...status} />
<OrgChart dataSource={source} stylesheetUrls={stylesheetUrls} onStateChange={status.onStateChange} className="hm-orgchart-story-chart" />
</Content>
)
}
function ResponsiveContent() {
const [width, setWidth] = useState(390)
return (
<Content note="고급 모바일 재구성 없이 원래 노드 크기와 Core 내부 가로 스크롤을 유지합니다. 아래 폭은 현재 Storybook viewport보다 넓어지지 않습니다. 실제 390 / 768 / 1280 / 1440px 검증은 각 viewport에서 수행합니다. Zoom/Pan은 미지원입니다.">
<div className="hm-orgchart-story-controls">
{[390, 768, 1280, 1440].map(value => <Button key={value} appearance="outline" aria-pressed={width === value} onClick={() => setWidth(value)}>{value}px</Button>)}
</div>
<div className="hm-orgchart-story-responsive" style={{ width }}>
<OrgChart dataSource={sourceV1} stylesheetUrls={stylesheetUrls} className="hm-orgchart-story-chart" />
</div>
</Content>
)
}
function MultipleContent() {
const [sourceA, setSourceA] = useState<OrgChartDataSource>(sourceV1)
const [mountedA, setMountedA] = useState(true)
const a = useChartState()
const b = useChartState()
return (
<Content note="A는 v1, B는 v2로 시작합니다. A의 Source 변경·Unmount·Mount 후에도 B의 데이터와 상태 알림 수가 유지되어야 합니다. 두 차트는 동일한 호스트 HM Theme를 상속하며 각 Shadow DOM에 공개 CSS Asset을 연결합니다.">
<div className="hm-orgchart-story-controls">
<Button appearance="outline" onClick={() => setSourceA(sourceV1)}>A → JSON v1</Button>
<Button appearance="outline" onClick={() => setSourceA(sourceV2)}>A → JSON v2</Button>
<Button variant="cancel" appearance="outline" onClick={() => setMountedA(value => !value)}>{mountedA ? "A Unmount" : "A Mount"}</Button>
</div>
<div className="hm-orgchart-story-instances">
<section className="hm-orgchart-story-instance" aria-label="OrgChart A">
<StateOutput {...a} label="A 상태" />
{mountedA && <OrgChart dataSource={sourceA} stylesheetUrls={stylesheetUrls} onStateChange={a.onStateChange} className="hm-orgchart-story-chart" />}
</section>
<section className="hm-orgchart-story-instance" aria-label="OrgChart B">
<StateOutput {...b} label="B 상태" />
<OrgChart dataSource={sourceV2} stylesheetUrls={stylesheetUrls} onStateChange={b.onStateChange} className="hm-orgchart-story-chart" />
</section>
</div>
</Content>
)
}
const meta = {
title: "OrgChart/Content",
tags: ["internal"],
component: OrgChart,
parameters: {
layout: "padded",
docs: { description: { component: "OrgChart v0.1 콘텐츠: React Wrapper → 동일 Vanilla Core → dabeng 엔진 / HM Theme / 노드 UI. 기존 HTML JSON을 공유하며 Navigation·Zoom/Pan·실제 인사 데이터는 포함하지 않습니다." } },
},
args: { dataSource: sourceV1, stylesheetUrls },
argTypes: {
dataSource: { control: false },
stylesheetUrls: { control: false },
aggregationPolicy: { control: false },
onStateChange: { control: false },
onLifecycleError: { control: false },
className: { control: false },
style: { control: false },
},
decorators: [(Story) => <StrictMode><Story /></StrictMode>],
} satisfies Meta<typeof OrgChart>
export default meta
type Story = StoryObj<typeof meta>
export const BasicOrganization: Story = {
name: "Basic Organization",
render: args => <Content note="HTML 공통 v1: 조직 3개, 고유 직원 4명 (Root 1 / Team A 2 / Team B 1). 정책 미주입 시 원본 집계만 표시하고 업무상 총원은 미확정입니다."><OrgChart {...args} className="hm-orgchart-story-chart" /></Content>,
}
export const OrganizationHierarchy: Story = {
name: "Organization Hierarchy",
args: { dataSource: hierarchySource },
render: args => <Content note="공통 v1의 parentId 계층·조직장과 연결선을 확인합니다. showEmployees/showSummary만 숨기며 원본 소속과 집계는 유지합니다. 하위 조직 접기/펼치기는 Core 버튼의 키보드 Enter/Space로 실행합니다."><OrgChart {...args} className="hm-orgchart-story-chart" /></Content>,
}
export const EmployeeCards: Story = {
name: "Employee Cards",
args: { dataSource: employeeSource },
render: args => <Content note="공통 v1에서 긴 조직명·직원명·직위·직책만 파생한 가상 데이터입니다. null 프로필 이미지는 Core의 첫 글자 대체 표시를 사용합니다. 재직·파견·휴직 상태와 조직장 구분은 동일 Core 노드 UI입니다."><OrgChart {...args} className="hm-orgchart-story-chart" /></Content>,
}
export const OrganizationSummary: Story = {
name: "Organization Summary",
render: args => <Content note="공통 v1 원본 직원 전체 4명, 직접 인원 Root 1 / Team A 2 / Team B 1. 하위 포함 인원은 서로 합산하지 않습니다. 승인 집계 정책을 임의 생성하지 않으며 업무상 총원은 정책 승인 필요로 표시합니다."><OrgChart {...args} className="hm-orgchart-story-chart" /></Content>,
}
export const JSONDataUpdate: Story = { name: "JSON Data Update", render: () => <DataUpdateContent /> }
export const LoadingEmptyError: Story = { name: "Loading / Empty / Error", render: () => <LoadingContent /> }
export const ResponsiveContainer: Story = { name: "Responsive Container", render: () => <ResponsiveContent /> }
export const MultipleInstances: Story = { name: "Multiple Instances", render: () => <MultipleContent /> }