1
0
forked from baron/baron-sso

조직현황 구조변경. 총괄센터삼안 실 조직 삽입확인

This commit is contained in:
2026-05-11 20:13:54 +09:00
parent d3853fac2a
commit 3063450ee0
59 changed files with 5086 additions and 549 deletions

View File

@@ -34,7 +34,7 @@ Baron SSO 시스템과 연동되는 독립적인 **조직도 시각화(Organizat
| 용도 | API | 주요 사용 필드 |
| --- | --- | --- |
| 테넌트 목록 | `GET /v1/admin/tenants?limit=10000&offset=0` | `id`, `type`, `name`, `slug`, `parentId`, `memberCount`, `status` |
| 사용자 목록 | `GET /v1/admin/users?limit=5000&offset=0` | `id`, `email`, `name`, `status`, `tenantSlug`, `companyCode`, `joinedTenants`, `position`, `jobTitle` |
| 사용자 목록 | `GET /v1/admin/users?limit=5000&offset=0` | `id`, `email`, `name`, `status`, `tenantSlug`, `companyCode`, `joinedTenants`, `grade`, `position`, `jobTitle` |
테넌트는 Baron Admin에서 입력한 `parentId` 관계를 기준으로 트리로 변환합니다. 현재 루트 후보는 `type === "COMPANY_GROUP"`인 테넌트를 우선 사용하고, 없으면 최상위 테넌트를 사용합니다. 회사 필터는 루트 하위의 `type === "COMPANY"` 테넌트로 구성됩니다.
@@ -47,7 +47,7 @@ Baron SSO 시스템과 연동되는 독립적인 **조직도 시각화(Organizat
5. `joinedTenants`가 있으면 각 joined tenant의 `slug`에도 같은 사용자를 추가합니다.
6. 같은 테넌트 노드 안에서 동일 사용자 `id`는 중복 추가하지 않습니다.
각 조직도 노드는 테넌트명(`name`)을 헤더로 사용하고, 해당 테넌트 `slug`에 매핑된 사용자를 구성원 목록으로 표시합니다. 구성원은 `position`/`jobTitle` 기준으로 정렬되며, 표시 직무는 `jobTitle || position || "사원"` 순서로 결정됩니다.
각 조직도 노드는 테넌트명(`name`)을 헤더로 사용하고, 해당 테넌트 `slug`에 매핑된 사용자를 구성원 목록으로 표시합니다. 구성원은 직책(`position`) 조직장 여부와 직급(`grade`) 기준으로 정렬되며, 사용자 표시는 `이름 직급(직책)` 형식을 우선 사용하고 직책이 없으면 직무(`jobTitle`)를 보조 표시로 사용합니다.
### 공유 조직도 화면

View File

@@ -14,6 +14,7 @@
"@radix-ui/react-switch": "^1.1.2",
"@tanstack/react-query": "^5.66.8",
"@tanstack/react-query-devtools": "^5.66.8",
"@xyflow/react": "^12.10.2",
"axios": "^1.7.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -433,38 +434,35 @@
}
},
"node_modules/@emnapi/core": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
"integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.0",
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
"integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
"integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -527,9 +525,9 @@
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz",
"integrity": "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
"integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -584,9 +582,9 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.122.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz",
"integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==",
"version": "0.129.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz",
"integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==",
"dev": true,
"license": "MIT",
"funding": {
@@ -1058,9 +1056,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.12.tgz",
"integrity": "sha512-pv1y2Fv0JybcykuiiD3qBOBdz6RteYojRFY1d+b95WVuzx211CRh+ytI/+9iVyWQ6koTh5dawe4S/yRfOFjgaA==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz",
"integrity": "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==",
"cpu": [
"arm64"
],
@@ -1075,9 +1073,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.12.tgz",
"integrity": "sha512-cFYr6zTG/3PXXF3pUO+umXxt1wkRK/0AYT8lDwuqvRC+LuKYWSAQAQZjCWDQpAH172ZV6ieYrNnFzVVcnSflAg==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0.tgz",
"integrity": "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==",
"cpu": [
"arm64"
],
@@ -1092,9 +1090,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.12.tgz",
"integrity": "sha512-ZCsYknnHzeXYps0lGBz8JrF37GpE9bFVefrlmDrAQhOEi4IOIlcoU1+FwHEtyXGx2VkYAvhu7dyBf75EJQffBw==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0.tgz",
"integrity": "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==",
"cpu": [
"x64"
],
@@ -1109,9 +1107,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.12.tgz",
"integrity": "sha512-dMLeprcVsyJsKolRXyoTH3NL6qtsT0Y2xeuEA8WQJquWFXkEC4bcu1rLZZSnZRMtAqwtrF/Ib9Ddtpa/Gkge9Q==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0.tgz",
"integrity": "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==",
"cpu": [
"x64"
],
@@ -1126,9 +1124,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.12.tgz",
"integrity": "sha512-YqWjAgGC/9M1lz3GR1r1rP79nMgo3mQiiA+Hfo+pvKFK1fAJ1bCi0ZQVh8noOqNacuY1qIcfyVfP6HoyBRZ85Q==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0.tgz",
"integrity": "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==",
"cpu": [
"arm"
],
@@ -1143,13 +1141,16 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.12.tgz",
"integrity": "sha512-/I5AS4cIroLpslsmzXfwbe5OmWvSsrFuEw3mwvbQ1kDxJ822hFHIx+vsN/TAzNVyepI/j/GSzrtCIwQPeKCLIg==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0.tgz",
"integrity": "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1160,13 +1161,16 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.12.tgz",
"integrity": "sha512-V6/wZztnBqlx5hJQqNWwFdxIKN0m38p8Jas+VoSfgH54HSj9tKTt1dZvG6JRHcjh6D7TvrJPWFGaY9UBVOaWPw==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0.tgz",
"integrity": "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1177,13 +1181,16 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.12.tgz",
"integrity": "sha512-AP3E9BpcUYliZCxa3w5Kwj9OtEVDYK6sVoUzy4vTOJsjPOgdaJZKFmN4oOlX0Wp0RPV2ETfmIra9x1xuayFB7g==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0.tgz",
"integrity": "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1194,13 +1201,16 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.12.tgz",
"integrity": "sha512-nWwpvUSPkoFmZo0kQazZYOrT7J5DGOJ/+QHHzjvNlooDZED8oH82Yg67HvehPPLAg5fUff7TfWFHQS8IV1n3og==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0.tgz",
"integrity": "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1211,13 +1221,16 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.12.tgz",
"integrity": "sha512-RNrafz5bcwRy+O9e6P8Z/OCAJW/A+qtBczIqVYwTs14pf4iV1/+eKEjdOUta93q2TsT/FI0XYDP3TCky38LMAg==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0.tgz",
"integrity": "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1228,13 +1241,16 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.12.tgz",
"integrity": "sha512-Jpw/0iwoKWx3LJ2rc1yjFrj+T7iHZn2JDg1Yny1ma0luviFS4mhAIcd1LFNxK3EYu3DHWCps0ydXQ5i/rrJ2ig==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0.tgz",
"integrity": "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1245,9 +1261,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.12.tgz",
"integrity": "sha512-vRugONE4yMfVn0+7lUKdKvN4D5YusEiPilaoO2sgUWpCvrncvWgPMzK00ZFFJuiPgLwgFNP5eSiUlv2tfc+lpA==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0.tgz",
"integrity": "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==",
"cpu": [
"arm64"
],
@@ -1262,9 +1278,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.12.tgz",
"integrity": "sha512-ykGiLr/6kkiHc0XnBfmFJuCjr5ZYKKofkx+chJWDjitX+KsJuAmrzWhwyOMSHzPhzOHOy7u9HlFoa5MoAOJ/Zg==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0.tgz",
"integrity": "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==",
"cpu": [
"wasm32"
],
@@ -1272,16 +1288,18 @@
"license": "MIT",
"optional": true,
"dependencies": {
"@napi-rs/wasm-runtime": "^1.1.1"
"@emnapi/core": "1.10.0",
"@emnapi/runtime": "1.10.0",
"@napi-rs/wasm-runtime": "^1.1.4"
},
"engines": {
"node": ">=14.0.0"
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.12.tgz",
"integrity": "sha512-5eOND4duWkwx1AzCxadcOrNeighiLwMInEADT0YM7xeEOOFcovWZCq8dadXgcRHSf3Ulh1kFo/qvzoFiCLOL1Q==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0.tgz",
"integrity": "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==",
"cpu": [
"arm64"
],
@@ -1296,9 +1314,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.12.tgz",
"integrity": "sha512-PyqoipaswDLAZtot351MLhrlrh6lcZPo2LSYE+VDxbVk24LVKAGOuE4hb8xZQmrPAuEtTZW8E6D2zc5EUZX4Lw==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0.tgz",
"integrity": "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==",
"cpu": [
"x64"
],
@@ -1380,9 +1398,9 @@
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"dev": true,
"license": "MIT",
"optional": true,
@@ -1401,6 +1419,55 @@
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-drag": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
"license": "MIT",
"dependencies": {
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-zoom": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
"license": "MIT",
"dependencies": {
"@types/d3-interpolate": "*",
"@types/d3-selection": "*"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -1584,6 +1651,38 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@xyflow/react": {
"version": "12.10.2",
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz",
"integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==",
"license": "MIT",
"dependencies": {
"@xyflow/system": "0.0.76",
"classcat": "^5.0.3",
"zustand": "^4.4.0"
},
"peerDependencies": {
"react": ">=17",
"react-dom": ">=17"
}
},
"node_modules/@xyflow/system": {
"version": "0.0.76",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.76.tgz",
"integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==",
"license": "MIT",
"dependencies": {
"@types/d3-drag": "^3.0.7",
"@types/d3-interpolate": "^3.0.4",
"@types/d3-selection": "^3.0.10",
"@types/d3-transition": "^3.0.8",
"@types/d3-zoom": "^3.0.8",
"d3-drag": "^3.0.0",
"d3-interpolate": "^3.0.1",
"d3-selection": "^3.0.0",
"d3-zoom": "^3.0.0"
}
},
"node_modules/agent-base": {
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
@@ -1676,14 +1775,14 @@
}
},
"node_modules/axios": {
"version": "1.13.5",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz",
"integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
"proxy-from-env": "^2.1.0"
}
},
"node_modules/baseline-browser-mapping": {
@@ -1873,6 +1972,12 @@
"url": "https://polar.sh/cva"
}
},
"node_modules/classcat": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -1961,6 +2066,111 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-dispatch": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-drag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-selection": "3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-transition": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3",
"d3-dispatch": "1 - 3",
"d3-ease": "1 - 3",
"d3-interpolate": "1 - 3",
"d3-timer": "1 - 3"
},
"engines": {
"node": ">=12"
},
"peerDependencies": {
"d3-selection": "2 - 3"
}
},
"node_modules/d3-zoom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
"license": "ISC",
"dependencies": {
"d3-dispatch": "1 - 3",
"d3-drag": "2 - 3",
"d3-interpolate": "1 - 3",
"d3-selection": "2 - 3",
"d3-transition": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
@@ -2203,9 +2413,9 @@
}
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
@@ -3051,9 +3261,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3116,9 +3326,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
"dev": true,
"funding": [
{
@@ -3279,10 +3489,13 @@
"license": "MIT"
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/punycode": {
"version": "2.3.1",
@@ -3463,14 +3676,14 @@
}
},
"node_modules/rolldown": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.12.tgz",
"integrity": "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz",
"integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.122.0",
"@rolldown/pluginutils": "1.0.0-rc.12"
"@oxc-project/types": "=0.129.0",
"@rolldown/pluginutils": "1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
@@ -3479,27 +3692,27 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0-rc.12",
"@rolldown/binding-darwin-arm64": "1.0.0-rc.12",
"@rolldown/binding-darwin-x64": "1.0.0-rc.12",
"@rolldown/binding-freebsd-x64": "1.0.0-rc.12",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12",
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12",
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12",
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12",
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12",
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.12",
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.12",
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.12",
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12",
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12"
"@rolldown/binding-android-arm64": "1.0.0",
"@rolldown/binding-darwin-arm64": "1.0.0",
"@rolldown/binding-darwin-x64": "1.0.0",
"@rolldown/binding-freebsd-x64": "1.0.0",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0",
"@rolldown/binding-linux-arm64-gnu": "1.0.0",
"@rolldown/binding-linux-arm64-musl": "1.0.0",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0",
"@rolldown/binding-linux-s390x-gnu": "1.0.0",
"@rolldown/binding-linux-x64-gnu": "1.0.0",
"@rolldown/binding-linux-x64-musl": "1.0.0",
"@rolldown/binding-openharmony-arm64": "1.0.0",
"@rolldown/binding-wasm32-wasi": "1.0.0",
"@rolldown/binding-win32-arm64-msvc": "1.0.0",
"@rolldown/binding-win32-x64-msvc": "1.0.0"
}
},
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
"version": "1.0.0-rc.12",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.12.tgz",
"integrity": "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw==",
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz",
"integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==",
"dev": true,
"license": "MIT"
},
@@ -3719,14 +3932,14 @@
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
"integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
@@ -3754,9 +3967,9 @@
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3929,17 +4142,17 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.3.tgz",
"integrity": "sha512-B9ifbFudT1TFhfltfaIPgjo9Z3mDynBTJSUYxTjOQruf/zHH+ezCQKcoqO+h7a9Pw9Nm/OtlXAiGT1axBgwqrQ==",
"version": "8.0.12",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz",
"integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.8",
"rolldown": "1.0.0-rc.12",
"tinyglobby": "^0.2.15"
"postcss": "^8.5.14",
"rolldown": "1.0.0",
"tinyglobby": "^0.2.16"
},
"bin": {
"vite": "bin/vite.js"
@@ -3955,8 +4168,8 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.0",
"esbuild": "^0.27.0",
"@vitejs/devtools": "^0.1.18",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
@@ -4227,6 +4440,34 @@
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
}
}
}

View File

@@ -23,6 +23,7 @@
"@radix-ui/react-switch": "^1.1.2",
"@tanstack/react-query": "^5.66.8",
"@tanstack/react-query-devtools": "^5.66.8",
"@xyflow/react": "^12.10.2",
"axios": "^1.7.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

View File

@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import type { TenantSummary, UserSummary } from "../../lib/adminApi";
import { buildOrgPickerTree } from "./pickerTree";
function tenant(
id: string,
type: string,
name: string,
slug: string,
parentId?: string,
): TenantSummary {
return {
id,
type,
name,
slug,
description: "",
status: "active",
parentId,
memberCount: 0,
createdAt: "2026-05-11T00:00:00.000Z",
updatedAt: "2026-05-11T00:00:00.000Z",
};
}
describe("buildOrgPickerTree", () => {
it("uses the hanmac-family company-group as the default picker root", () => {
const tenants = [
tenant("wrong-group", "COMPANY_GROUP", "Wrong Group", "wrong-group"),
tenant(
"wrong-company",
"COMPANY",
"Wrong Company",
"wrong-company",
"wrong-group",
),
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
];
const tree = buildOrgPickerTree({
tenants,
users: [] satisfies UserSummary[],
});
expect(tree.companyGroupId).toBe("hanmac-family-id");
expect(tree.roots).toHaveLength(1);
expect(tree.roots[0]?.id).toBe("hanmac-family-id");
expect(tree.roots[0]?.children.map((node) => node.id)).toEqual([
"saman-id",
]);
});
it("scopes descendant filtering by tenant slug", () => {
const tenants = [
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
tenant("planning-id", "ORGANIZATION", "기획팀", "planning", "saman-id"),
tenant("hanmac-id", "COMPANY", "한맥기술", "hanmac", "hanmac-family-id"),
];
const tree = buildOrgPickerTree({
tenants,
users: [] satisfies UserSummary[],
tenantId: "saman",
});
expect(tree.roots).toHaveLength(1);
expect(tree.roots[0]?.id).toBe("saman-id");
expect(tree.roots[0]?.children.map((node) => node.id)).toEqual([
"planning-id",
]);
});
it("excludes private tenants and their descendants from picker choices", () => {
const tenants = [
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
{
...tenant(
"secret-id",
"ORGANIZATION",
"비공개 조직",
"secret",
"saman-id",
),
config: { visibility: "private" },
},
tenant(
"secret-child-id",
"USER_GROUP",
"비공개 하위",
"secret-child",
"secret-id",
),
tenant("open-id", "ORGANIZATION", "공개 조직", "open", "saman-id"),
];
const tree = buildOrgPickerTree({
tenants,
users: [] satisfies UserSummary[],
tenantId: "saman",
});
expect(tree.roots[0]?.children.map((node) => node.id)).toEqual(["open-id"]);
});
});

View File

@@ -1,6 +1,7 @@
import type { TenantSummary, UserSummary } from "../../lib/adminApi";
import { type TenantNode, buildTenantFullTree } from "../../lib/tenantTree";
import type { OrgPickerTreeNode } from "./pickerTypes";
import { filterTenantsByVisibility } from "./tenantVisibility";
import { getOrgChartUserDisplayName } from "./userDisplay";
function getUserTenantSlug(user: UserSummary) {
@@ -28,6 +29,23 @@ function getCompanyGroupId(node: TenantNode, allTenants: TenantSummary[]) {
return cursor?.type === "COMPANY_GROUP" ? cursor.id : node.id;
}
function isHanmacFamilyCompanyGroup(tenant: TenantSummary) {
return (
tenant.type.toUpperCase() === "COMPANY_GROUP" &&
tenant.slug.toLowerCase() === "hanmac-family"
);
}
function findTenantByRef(tenants: TenantSummary[], ref?: string) {
const normalizedRef = ref?.trim().toLowerCase();
if (!normalizedRef) return undefined;
return (
tenants.find((tenant) => tenant.slug.toLowerCase() === normalizedRef) ??
tenants.find((tenant) => tenant.id === ref)
);
}
function tenantToPickerNode(
tenant: TenantNode,
usersBySlug: Map<string, UserSummary[]>,
@@ -58,12 +76,34 @@ function tenantToPickerNode(
function findTenantNode(
roots: TenantNode[],
tenantId: string,
tenantRef: string,
): TenantNode | undefined {
const findBySlug = (node: TenantNode): TenantNode | undefined => {
if (node.slug.toLowerCase() === tenantRef.trim().toLowerCase()) {
return node;
}
for (const child of node.children) {
const match = findBySlug(child);
if (match) return match;
}
return undefined;
};
const findById = (node: TenantNode): TenantNode | undefined => {
if (node.id === tenantRef) return node;
for (const child of node.children) {
const match = findById(child);
if (match) return match;
}
return undefined;
};
for (const root of roots) {
if (root.id === tenantId) return root;
const child = findTenantNode(root.children, tenantId);
if (child) return child;
const slugMatch = findBySlug(root);
if (slugMatch) return slugMatch;
}
for (const root of roots) {
const idMatch = findById(root);
if (idMatch) return idMatch;
}
return undefined;
}
@@ -79,7 +119,10 @@ export function buildOrgPickerTree({
rootTenantId?: string;
tenantId?: string;
}) {
const visibleTenants = tenants.filter(isOrgFrontTenantType);
const visibleTenants = filterTenantsByVisibility(
tenants.filter(isOrgFrontTenantType),
"internal",
);
const usersBySlug = new Map<string, UserSummary[]>();
for (const user of users) {
if (user.status !== "active") continue;
@@ -91,7 +134,8 @@ export function buildOrgPickerTree({
}
const companyGroup =
visibleTenants.find((tenant) => tenant.id === rootTenantId) ??
findTenantByRef(visibleTenants, rootTenantId) ??
visibleTenants.find(isHanmacFamilyCompanyGroup) ??
visibleTenants.find((tenant) => tenant.type === "COMPANY_GROUP") ??
visibleTenants.find((tenant) => !tenant.parentId);

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import {
buildOrgPickerEmbedSrc,
parseOrgPickerEmbedOptions,
} from "./pickerTypes";
describe("org picker embed options", () => {
it("builds slug-based tenant scope urls", () => {
expect(
buildOrgPickerEmbedSrc({
mode: "single",
select: "tenant",
includeDescendants: true,
showDescendantToggle: true,
tenantId: "saman",
width: 400,
height: 600,
}),
).toBe(
"/embed/picker?mode=single&select=tenant&width=400&height=600&tenantSlug=saman",
);
});
it("parses tenantSlug first and keeps legacy tenantId compatibility", () => {
expect(
parseOrgPickerEmbedOptions(
"?tenantId=legacy-id&tenantSlug=saman&companyTenantId=legacy-company",
).tenantId,
).toBe("saman");
expect(parseOrgPickerEmbedOptions("?tenantId=legacy-id").tenantId).toBe(
"legacy-id",
);
});
});

View File

@@ -70,7 +70,11 @@ export function parseOrgPickerEmbedOptions(search: string) {
select: parseOrgPickerSelectableType(params.get("select")),
includeDescendants: params.get("includeDescendants") !== "false",
showDescendantToggle: params.get("showDescendantToggle") !== "false",
tenantId: params.get("tenantId") ?? params.get("companyTenantId") ?? "",
tenantId:
params.get("tenantSlug") ??
params.get("tenantId") ??
params.get("companyTenantId") ??
"",
width: parseEmbedDimension(params.get("width"), 400),
height: parseEmbedDimension(params.get("height"), 600),
};
@@ -84,9 +88,9 @@ export function buildOrgPickerEmbedSrc(options: OrgPickerEmbedOptions) {
height: String(options.height),
});
const tenantId = options.tenantId.trim();
if (tenantId) {
params.set("tenantId", tenantId);
const tenantSlug = options.tenantId.trim();
if (tenantSlug) {
params.set("tenantSlug", tenantSlug);
}
if (options.mode === "multiple") {

View File

@@ -0,0 +1,388 @@
import { describe, expect, it } from "vitest";
import {
type OrgNode,
buildOrgSelectionOptions,
clampScale,
getOrgNodeHeaderFill,
getSemanticZoomMode,
layoutForest,
} from "./OrgChartPage";
function orgNode(id: string, children: OrgNode[] = [], level = 0): OrgNode {
return {
id,
name: id,
level,
members: [],
children,
totalCount: 0,
totalMemberIds: new Set<string>(),
companyCode: id,
type: level === 0 ? "COMPANY" : "USER_GROUP",
};
}
function member(id: string) {
return {
id,
email: `${id}@example.com`,
name: id,
role: "user",
status: "active",
companyCode: "root",
grade: "사원",
createdAt: "2026-05-11T00:00:00.000Z",
updatedAt: "2026-05-11T00:00:00.000Z",
};
}
function tenantNode(
id: string,
type: string,
name: string,
slug: string,
children = [],
) {
return {
id,
type,
name,
slug,
children,
description: "",
status: "active",
memberCount: 0,
recursiveMemberCount: 0,
createdAt: "2026-05-11T00:00:00.000Z",
updatedAt: "2026-05-11T00:00:00.000Z",
};
}
function getNodeBoundsAspectRatio(
nodes: ReturnType<typeof layoutForest>["nodes"],
) {
const minX = Math.min(...nodes.map((node) => node.x));
const maxX = Math.max(...nodes.map((node) => node.x + node.width));
const minY = Math.min(...nodes.map((node) => node.y));
const maxY = Math.max(...nodes.map((node) => node.y + node.height));
return (maxX - minX) / (maxY - minY);
}
describe("org chart layout", () => {
it("keeps small sibling groups horizontal in automatic mode", () => {
const children = Array.from({ length: 4 }, (_, index) =>
orgNode(`child-${index + 1}`, [], 1),
);
const layout = layoutForest([orgNode("root", children)], new Set());
const childNodes = layout.nodes.filter((node) =>
node.node.id.startsWith("child-"),
);
expect(new Set(childNodes.map((node) => node.y)).size).toBe(1);
});
it("uses member columns in node bounds when member count exceeds five", () => {
const compactMembers = Array.from({ length: 6 }, (_, index) =>
member(`member-${index + 1}`),
);
const node = {
...orgNode("root"),
members: compactMembers,
totalCount: compactMembers.length,
totalMemberIds: new Set(compactMembers.map((item) => item.id)),
};
const layout = layoutForest([node], new Set());
const rootNode = layout.nodes.find((item) => item.node.id === "root");
expect(rootNode).toBeDefined();
expect(rootNode?.width).toBeGreaterThan(340);
expect(rootNode?.height).toBeLessThan(42 + 24 + 6 * 24);
expect(layout.width).toBeGreaterThan((rootNode?.width ?? 0) + 72 * 2 - 1);
});
it("adds one member column per five-member quotient", () => {
const tenMembers = Array.from({ length: 10 }, (_, index) =>
member(`member-${index + 1}`),
);
const sixMembers = tenMembers.slice(0, 6);
const sixLayout = layoutForest(
[
{
...orgNode("six"),
members: sixMembers,
totalCount: sixMembers.length,
totalMemberIds: new Set(sixMembers.map((item) => item.id)),
},
],
new Set(),
);
const tenLayout = layoutForest(
[
{
...orgNode("ten"),
members: tenMembers,
totalCount: tenMembers.length,
totalMemberIds: new Set(tenMembers.map((item) => item.id)),
},
],
new Set(),
);
const sixNode = sixLayout.nodes.find((item) => item.node.id === "six");
const tenNode = tenLayout.nodes.find((item) => item.node.id === "ten");
expect(sixNode?.width).toBeGreaterThan(340);
expect(tenNode?.width).toBeGreaterThan(sixNode?.width ?? 0);
expect(tenNode?.height).toBeLessThan(42 + 24 + 10 * 24);
expect(tenLayout.width).toBeGreaterThan(sixLayout.width);
});
it("uses multi-column layout by default when sibling width crosses the threshold", () => {
const children = Array.from({ length: 13 }, (_, index) =>
orgNode(`child-${index + 1}`, [], 1),
);
const layout = layoutForest([orgNode("root", children)], new Set());
const childNodes = layout.nodes.filter((node) =>
node.node.id.startsWith("child-"),
);
const uniqueChildRows = new Set(childNodes.map((node) => node.y));
const childSpan =
Math.max(...childNodes.map((node) => node.x + node.width)) -
Math.min(...childNodes.map((node) => node.x));
const aspectRatio = getNodeBoundsAspectRatio(layout.nodes);
expect(childNodes).toHaveLength(13);
expect(uniqueChildRows.size).toBeGreaterThan(1);
expect(aspectRatio).toBeGreaterThanOrEqual(1.41);
expect(aspectRatio).toBeLessThanOrEqual(1.61);
expect(childSpan).toBeLessThan(13 * 340 + 12 * 80);
expect(
layout.edges.filter((edge) => edge.key.startsWith("root->")),
).toHaveLength(13);
expect(
layout.edges.filter(
(edge) => edge.key.startsWith("root->") && edge.visibleByDefault,
),
).toHaveLength(new Set(childNodes.map((node) => node.x)).size);
});
it("tunes column and row gaps after column selection to keep auto layout near the target aspect ratio", () => {
const children = Array.from({ length: 5 }, (_, index) =>
orgNode(`child-${index + 1}`, [], 1),
);
const layout = layoutForest([orgNode("root", children)], new Set());
const childNodes = layout.nodes.filter((node) =>
node.node.id.startsWith("child-"),
);
const aspectRatio = getNodeBoundsAspectRatio(layout.nodes);
expect(new Set(childNodes.map((node) => node.x)).size).toBe(2);
expect(aspectRatio).toBeGreaterThanOrEqual(1.41);
expect(aspectRatio).toBeLessThanOrEqual(1.61);
});
it("keeps direct siblings on one level in top-down mode", () => {
const children = Array.from({ length: 13 }, (_, index) =>
orgNode(`child-${index + 1}`, [], 1),
);
const layout = layoutForest([orgNode("root", children)], new Set(), {
childLayoutMode: "topDown",
});
const childNodes = layout.nodes.filter((node) =>
node.node.id.startsWith("child-"),
);
const uniqueChildRows = new Set(childNodes.map((node) => node.y));
expect(childNodes).toHaveLength(13);
expect(uniqueChildRows.size).toBe(1);
});
it("places children in three fixed columns with centered parent edges", () => {
const children = Array.from({ length: 10 }, (_, index) =>
orgNode(`child-${index + 1}`, [], 1),
);
const layout = layoutForest([orgNode("root", children)], new Set(), {
childLayoutMode: "threeColumn",
});
const childNodes = layout.nodes.filter((node) =>
node.node.id.startsWith("child-"),
);
const uniqueChildColumns = new Set(childNodes.map((node) => node.x));
const uniqueChildRows = new Set(childNodes.map((node) => node.y));
const rootEdges = layout.edges.filter((edge) =>
edge.key.startsWith("root->"),
);
expect(uniqueChildColumns.size).toBe(3);
expect(uniqueChildRows.size).toBe(4);
expect(rootEdges).toHaveLength(10);
expect(rootEdges.filter((edge) => edge.visibleByDefault)).toHaveLength(3);
});
it("places the deepest child subtree in the first multi-column section", () => {
const children = [
orgNode("shallow-1", [], 1),
orgNode("shallow-2", [], 1),
orgNode("shallow-3", [], 1),
orgNode(
"deep",
[
orgNode(
"deep-branch",
[orgNode("deep-leaf", [orgNode("deep-tail", [], 4)], 3)],
2,
),
],
1,
),
orgNode("shallow-4", [], 1),
orgNode("shallow-5", [], 1),
];
const layout = layoutForest([orgNode("root", children)], new Set(), {
childLayoutMode: "threeColumn",
});
const rootEdges = layout.edges.filter((edge) =>
edge.key.startsWith("root->"),
);
expect(rootEdges.map((edge) => edge.key)).toContain("root->deep");
});
it("centers a parent over the full child span in multi-column mode", () => {
const children = [
orgNode(
"deep",
[
orgNode(
"deep-branch",
[orgNode("deep-leaf", [orgNode("deep-tail", [], 4)], 3)],
2,
),
],
1,
),
...Array.from({ length: 9 }, (_, index) =>
orgNode(`shallow-${index + 1}`, [], 1),
),
];
const layout = layoutForest([orgNode("root", children)], new Set(), {
childLayoutMode: "threeColumn",
});
const rootNode = layout.nodes.find((node) => node.node.id === "root");
const directChildren = layout.nodes.filter((node) => node.node.level === 1);
const childSpanCenter =
(Math.min(...directChildren.map((node) => node.x + node.width / 2)) +
Math.max(...directChildren.map((node) => node.x + node.width / 2))) /
2;
const rootCenter = rootNode ? rootNode.x + rootNode.width / 2 : 0;
expect(rootNode).toBeDefined();
expect(rootCenter).toBeCloseTo(childSpanCenter, 5);
});
it("centers parents above the tidy child span", () => {
const children = [
orgNode("left", [orgNode("left-a", [], 2), orgNode("left-b", [], 2)], 1),
orgNode("middle", [], 1),
orgNode(
"right",
[orgNode("right-a", [], 2), orgNode("right-b", [], 2)],
1,
),
];
const layout = layoutForest([orgNode("root", children)], new Set(), {
childLayoutMode: "topDown",
});
const rootNode = layout.nodes.find((node) => node.node.id === "root");
const directChildren = layout.nodes.filter((node) =>
["left", "middle", "right"].includes(node.node.id),
);
const childSpanCenter =
(Math.min(...directChildren.map((node) => node.x + node.width / 2)) +
Math.max(...directChildren.map((node) => node.x + node.width / 2))) /
2;
const rootCenter = rootNode ? rootNode.x + rootNode.width / 2 : 0;
expect(rootNode).toBeDefined();
expect(rootCenter).toBeCloseTo(childSpanCenter, 5);
});
it("keeps compressed subtrees from overlapping on shared vertical bands", () => {
const layout = layoutForest(
[
orgNode("root", [
orgNode(
"left",
[orgNode("left-a", [], 2), orgNode("left-b", [], 2)],
1,
),
orgNode(
"right",
[orgNode("right-a", [], 2), orgNode("right-b", [], 2)],
1,
),
]),
],
new Set(),
);
for (const node of layout.nodes) {
for (const other of layout.nodes) {
if (node.node.id >= other.node.id) continue;
const verticalOverlap =
node.y < other.y + other.height && other.y < node.y + node.height;
const horizontalOverlap =
node.x < other.x + other.width && other.x < node.x + node.width;
expect(
verticalOverlap && horizontalOverlap,
`${node.node.id} overlaps ${other.node.id}`,
).toBe(false);
}
}
});
it("keeps zoom limits wide enough for large SVG organization charts", () => {
expect(clampScale(0.08)).toBe(0.08);
expect(clampScale(5)).toBe(5);
});
it("switches semantic zoom modes from overview to detail", () => {
expect(getSemanticZoomMode(0.12)).toBe("overview");
expect(getSemanticZoomMode(0.4)).toBe("compact");
expect(getSemanticZoomMode(0.8)).toBe("detail");
});
it("uses distinct header fills by organization depth", () => {
expect(getOrgNodeHeaderFill(0, "family")).toBe("#000000");
expect(getOrgNodeHeaderFill(0, "saman")).toBe("#f58220");
expect(getOrgNodeHeaderFill(0, "hanmac")).toBe("#1e489d");
expect(getOrgNodeHeaderFill(0, "gpdtdc")).toBe("#4b746d");
expect(getOrgNodeHeaderFill(0, "baron")).toBe("#004cbf");
expect(getOrgNodeHeaderFill(1, "saman")).not.toBe(
getOrgNodeHeaderFill(0, "saman"),
);
expect(getOrgNodeHeaderFill(2, "saman")).not.toBe(
getOrgNodeHeaderFill(1, "saman"),
);
});
it("orders top organization choices by the hanmac family policy", () => {
const familyRoot = tenantNode(
"family",
"COMPANY_GROUP",
"한맥가족",
"hanmac-family",
[
tenantNode("saman", "COMPANY", "삼안", "saman"),
tenantNode("baron", "COMPANY_GROUP", "바론그룹", "baron-group"),
tenantNode("hanmac", "COMPANY", "한맥기술", "hanmac"),
tenantNode("gpdtdc", "ORGANIZATION", "총괄기획&기술개발센터", "gpdtdc"),
],
);
expect(
buildOrgSelectionOptions(familyRoot).map((option) => option.label),
).toEqual(["총괄기획&기술개발센터", "삼안", "한맥기술", "바론그룹"]);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import { GitBranch, Network, PanelTop } from "lucide-react";
import { NavLink, Outlet } from "react-router-dom";
import { NavLink, Outlet, useLocation } from "react-router-dom";
const navItems = [
{ to: "/chart", label: "조직도", icon: Network },
@@ -8,9 +8,22 @@ const navItems = [
];
export function OrgFrontLayout() {
const location = useLocation();
const isChartRoute =
location.pathname === "/chart" || location.pathname.startsWith("/chart/");
return (
<div className="min-h-screen bg-background text-foreground">
<header className="sticky top-0 z-30 border-b border-border bg-background/95 backdrop-blur">
<div
className={
isChartRoute
? "flex h-screen flex-col overflow-hidden bg-background text-foreground"
: "min-h-screen bg-background text-foreground"
}
>
<header
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>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground">
@@ -40,7 +53,14 @@ export function OrgFrontLayout() {
</div>
</header>
<main className="mx-auto max-w-7xl px-4 py-5">
<main
className={
isChartRoute
? "min-h-0 flex-1 overflow-hidden"
: "mx-auto max-w-7xl px-4 py-5"
}
data-testid="orgfront-main"
>
<Outlet />
</main>
</div>

View File

@@ -59,7 +59,7 @@ function PickerScenarioControls({
</label>
<label className="space-y-1 text-sm font-medium">
<span className="block text-muted-foreground">tenant ID</span>
<span className="block text-muted-foreground">tenant slug</span>
<input
className="h-10 w-full rounded-md border border-input bg-background px-3"
onChange={(event) =>
@@ -68,7 +68,7 @@ function PickerScenarioControls({
tenantId: event.target.value,
})
}
placeholder="company-baron"
placeholder="saman"
type="text"
value={options.tenantId}
/>

View File

@@ -334,6 +334,7 @@ export function OrgPickerEmbedPage() {
const select = parseOrgPickerSelectableType(searchParams.get("select"));
const rootTenantId = searchParams.get("rootTenantId") || undefined;
const tenantId =
searchParams.get("tenantSlug") ||
searchParams.get("tenantId") ||
searchParams.get("companyTenantId") ||
undefined;
@@ -615,7 +616,7 @@ export function OrgPickerPage() {
</label>
<label className="space-y-1 text-sm font-medium">
<span className="block text-muted-foreground">tenant ID</span>
<span className="block text-muted-foreground">tenant slug</span>
<input
className="h-10 w-full rounded-md border border-input bg-background px-3"
onChange={(event) =>
@@ -624,7 +625,7 @@ export function OrgPickerPage() {
tenantId: event.target.value,
}))
}
placeholder="company-baron"
placeholder="saman"
type="text"
value={options.tenantId}
/>

View File

@@ -0,0 +1,45 @@
import type { TenantSummary } from "../../lib/adminApi";
export function getTenantVisibility(tenant: Pick<TenantSummary, "config">) {
const raw = String(tenant.config?.visibility ?? "public").toLowerCase();
if (raw === "internal" || raw === "private") return raw;
return "public";
}
export function filterTenantsByVisibility(
tenants: TenantSummary[],
mode: "internal" | "public",
) {
const excludedIds = new Set<string>();
for (const tenant of tenants) {
const visibility = getTenantVisibility(tenant);
if (
visibility === "private" ||
(mode === "public" && visibility === "internal")
) {
excludedIds.add(tenant.id);
}
}
let changed = true;
while (changed) {
changed = false;
for (const tenant of tenants) {
if (
tenant.parentId &&
excludedIds.has(tenant.parentId) &&
!excludedIds.has(tenant.id)
) {
excludedIds.add(tenant.id);
changed = true;
}
}
}
return tenants.filter((tenant) => !excludedIds.has(tenant.id));
}
export function getOrgUnitType(config: Record<string, unknown> | undefined) {
const value = config?.orgUnitType;
return typeof value === "string" ? value.trim() : "";
}

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import type { UserSummary } from "../../lib/adminApi";
import { getOrgChartUserDisplayName } from "./userDisplay";
function user(overrides: Partial<UserSummary>): UserSummary {
return {
id: "user-1",
email: "user@example.com",
name: "홍길동",
role: "user",
status: "active",
createdAt: "",
updatedAt: "",
...overrides,
};
}
describe("getOrgChartUserDisplayName", () => {
it("renders name with grade and optional position", () => {
expect(
getOrgChartUserDisplayName(
user({
grade: "수석",
position: "팀장",
}),
),
).toBe("홍길동 수석(팀장)");
});
it("uses tenant appointment grade before the user grade", () => {
expect(
getOrgChartUserDisplayName(
user({
grade: "책임",
metadata: {
additionalAppointments: [
{
tenantSlug: "hanmac",
grade: "수석",
position: "센터장",
},
],
},
}),
{ id: "tenant-1", slug: "hanmac" },
),
).toBe("홍길동 수석(센터장)");
});
});

View File

@@ -3,6 +3,7 @@ import type { TenantSummary, UserSummary } from "../../lib/adminApi";
type UserAppointment = {
tenantId?: string;
tenantSlug?: string;
grade?: string;
jobTitle?: string;
position?: string;
};
@@ -25,6 +26,7 @@ function getUserAppointments(user: UserSummary): UserAppointment[] {
.map((item) => ({
tenantId: normalizeText(item.tenantId),
tenantSlug: normalizeText(item.tenantSlug),
grade: normalizeText(item.grade),
jobTitle: normalizeText(item.jobTitle),
position: normalizeText(item.position),
}));
@@ -44,6 +46,7 @@ export function getUserOrgProfile(user: UserSummary, tenant?: TenantIdentity) {
});
return {
grade: appointment?.grade || normalizeText(user.grade),
jobTitle: appointment?.jobTitle || normalizeText(user.jobTitle),
position: appointment?.position || normalizeText(user.position),
};
@@ -53,11 +56,12 @@ export function getOrgChartUserDisplayName(
user: UserSummary,
tenant?: TenantIdentity,
) {
const { jobTitle, position } = getUserOrgProfile(user, tenant);
const { grade, jobTitle, position } = getUserOrgProfile(user, tenant);
const baseName = user.name.trim();
const detail = position || jobTitle;
if (jobTitle && position) return `${baseName}(${jobTitle}) ${position}`;
if (jobTitle) return `${baseName}(${jobTitle})`;
if (position) return `${baseName} ${position}`;
if (grade && detail) return `${baseName} ${grade}(${detail})`;
if (grade) return `${baseName} ${grade}`;
if (detail) return `${baseName}(${detail})`;
return baseName;
}

View File

@@ -388,6 +388,7 @@ export type UserSummary = {
joinedTenants?: TenantSummary[]; // [New] 다중 소속 테넌트 목록
metadata?: Record<string, unknown>;
department?: string;
grade?: string;
position?: string;
jobTitle?: string;
createdAt: string;
@@ -410,6 +411,7 @@ export type UserCreateRequest = {
role?: string;
tenantSlug?: string;
department?: string;
grade?: string;
position?: string;
jobTitle?: string;
metadata?: Record<string, unknown>;
@@ -428,6 +430,7 @@ export type UserUpdateRequest = {
status?: string;
tenantSlug?: string;
department?: string;
grade?: string;
position?: string;
jobTitle?: string;
metadata?: Record<string, unknown>;
@@ -441,6 +444,7 @@ export type BulkUserItem = {
role?: string;
tenantSlug?: string;
department?: string;
grade?: string;
position?: string;
jobTitle?: string;
metadata: Record<string, string>;

View File

@@ -1091,14 +1091,16 @@ email = "이메일"
email_placeholder = "user@example.com"
job_title = "직무"
job_title_placeholder = "프론트엔드 개발"
grade = "직급"
grade_placeholder = "수석/책임/선임"
name = "이름"
name_placeholder = "홍길동"
password = "비밀번호"
password_placeholder = "********"
phone = "전화번호"
phone_placeholder = "010-1234-5678"
position = "직"
position_placeholder = "수석/책임/선임"
position = "직"
position_placeholder = "팀장/센터장"
role = "역할"
tenant = "테넌트"
tenant_global = "시스템 전역"

View File

@@ -8,6 +8,7 @@ type TenantFixture = {
description: string;
status: string;
parentId?: string;
config?: Record<string, unknown>;
memberCount: number;
createdAt: string;
updatedAt: string;
@@ -41,7 +42,7 @@ function user(id: string, name: string, companyCode: string) {
role: "user",
status: "active",
companyCode,
position: "사원",
grade: "사원",
createdAt: "2026-04-01T00:00:00.000Z",
updatedAt: "2026-04-01T00:00:00.000Z",
};
@@ -125,3 +126,554 @@ test("org chart viewport pans with drag and zooms with the mouse wheel", async (
);
expect(scale).toBeGreaterThan(1);
});
test("org chart dashboard uses the full screen below the orgfront topbar", async ({
page,
}) => {
await page.route("**/api/v1/public/orgchart**", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
sharedWith: "Playwright",
tenants: [
tenant("root", "Baron Group", "baron"),
tenant("engineering", "Engineering", "engineering", "root"),
],
users: [
user("u-root", "Root User", "baron"),
user("u-eng", "Engineering User", "engineering"),
],
}),
});
});
await page.goto("/chart?token=full-screen");
await expect(page.getByRole("heading", { name: "조직 현황" })).toBeVisible();
const metrics = await page.evaluate(() => {
const topbar = document
.querySelector('[data-testid="orgfront-topbar"]')
?.getBoundingClientRect();
const main = document
.querySelector('[data-testid="orgfront-main"]')
?.getBoundingClientRect();
const shell = document
.querySelector('[data-testid="orgchart-dashboard-shell"]')
?.getBoundingClientRect();
if (!topbar || !main || !shell) {
throw new Error("Missing org chart layout elements");
}
return {
innerHeight: window.innerHeight,
innerWidth: window.innerWidth,
mainTop: main.top,
shellBottom: shell.bottom,
shellLeft: shell.left,
shellRight: shell.right,
shellTop: shell.top,
topbarBottom: topbar.bottom,
};
});
expect(Math.abs(metrics.mainTop - metrics.topbarBottom)).toBeLessThanOrEqual(
1,
);
expect(metrics.shellTop).toBe(metrics.topbarBottom);
expect(metrics.shellLeft).toBeLessThanOrEqual(1);
expect(metrics.shellRight).toBeGreaterThanOrEqual(metrics.innerWidth - 1);
expect(metrics.shellBottom).toBeGreaterThanOrEqual(metrics.innerHeight - 1);
});
test("org chart non-shared title does not render the MH Dashboard eyebrow", async ({
page,
}) => {
await page.addInitScript(() => {
window.localStorage.setItem("playwright_auth_bypass", "1");
window.localStorage.setItem("dev_tenant_id", "group");
});
await page.route("**/api/v1/admin/tenants**", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
items: [
{
...tenant("group", "Baron Group", "baron"),
type: "COMPANY_GROUP",
},
tenant("engineering", "Engineering", "engineering", "group"),
],
limit: 10000,
offset: 0,
total: 2,
}),
});
});
await page.route("**/api/v1/admin/users**", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
items: [user("u-eng", "Engineering User", "engineering")],
limit: 5000,
offset: 0,
total: 1,
}),
});
});
await page.goto("/chart");
await expect(page.getByRole("heading", { name: "조직 현황" })).toBeVisible();
await expect(page.getByText("MH Dashboard", { exact: true })).toHaveCount(0);
});
test("org chart renders dense member nodes with calculated member columns", async ({
page,
}) => {
const denseUsers = Array.from({ length: 6 }, (_, index) =>
user(`u-dense-${index + 1}`, `Dense User ${index + 1}`, "baron"),
);
await page.route("**/api/v1/public/orgchart**", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
sharedWith: "Playwright",
tenants: [tenant("root", "Baron Group", "baron")],
users: denseUsers,
}),
});
});
await page.goto("/chart?token=dense-members");
await expect(page.getByRole("heading", { name: "조직 현황" })).toBeVisible();
const rootNode = page.locator('[data-testid="orgchart-node-root"]');
await expect(rootNode).toHaveAttribute("width", /[4-9]\d{2,}/);
await expect(rootNode.locator('[data-member-columns="2"]')).toBeVisible();
await expect(rootNode.getByText("Dense User 6")).toBeVisible();
});
test("public org chart hides internal and private tenants and renders org unit type", async ({
page,
}) => {
await page.route("**/api/v1/public/orgchart**", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
sharedWith: "Playwright",
tenants: [
{
...tenant("group", "한맥가족", "hanmac-family"),
type: "COMPANY_GROUP",
},
tenant("company", "삼안", "saman", "group"),
{
...tenant("open-team", "공개 팀", "open-team", "company"),
config: { orgUnitType: "팀", visibility: "public" },
},
{
...tenant("internal-team", "내부 팀", "internal-team", "company"),
config: { visibility: "internal" },
},
{
...tenant("private-team", "비공개 팀", "private-team", "company"),
config: { visibility: "private" },
},
tenant(
"private-child",
"비공개 하위",
"private-child",
"private-team",
),
],
users: [
user("u-open", "Open User", "open-team"),
user("u-internal", "Internal User", "internal-team"),
user("u-private", "Private User", "private-team"),
user("u-private-child", "Private Child User", "private-child"),
],
}),
});
});
await page.goto("/chart?token=tenant-visibility");
await expect(page.getByRole("heading", { name: "조직 현황" })).toBeVisible();
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(/Open User/)).toBeVisible();
await expect(svg.getByText("내부 팀", { exact: true })).toHaveCount(0);
await expect(svg.getByText("Internal User", { exact: true })).toHaveCount(0);
await expect(svg.getByText("비공개 팀", { exact: true })).toHaveCount(0);
await expect(svg.getByText("Private User", { exact: true })).toHaveCount(0);
await expect(svg.getByText("비공개 하위", { exact: true })).toHaveCount(0);
await expect(
svg.getByText("Private Child User", { exact: true }),
).toHaveCount(0);
});
test("org chart colors hanmac family and nested baron company group separately", async ({
page,
}) => {
await page.route("**/api/v1/public/orgchart**", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
sharedWith: "Playwright",
tenants: [
{
...tenant("family", "한맥가족", "hanmac-family"),
type: "COMPANY_GROUP",
},
{
...tenant("baron-group", "Baron Group", "baron-group", "family"),
type: "COMPANY_GROUP",
},
{
...tenant("baron-company", "Baron Company", "baron", "baron-group"),
type: "COMPANY",
},
],
users: [user("u-baron", "Baron User", "baron")],
}),
});
});
await page.goto("/chart?token=baron-group-color");
await expect(page.getByRole("heading", { name: "조직 현황" })).toBeVisible();
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
await expect(svg.getByText("Baron Group", { exact: true })).toBeVisible();
const colors = await page.evaluate(() => {
function headerColor(nodeId: string) {
const node = document.querySelector(
`[data-testid="orgchart-node-${nodeId}"]`,
);
const header = node?.querySelector("div > div");
return header ? window.getComputedStyle(header).backgroundColor : "";
}
return {
baronCompany: headerColor("baron-company"),
baronGroup: headerColor("baron-group"),
family: headerColor("family"),
};
});
expect(colors.family).toBe("rgb(0, 0, 0)");
expect(colors.baronGroup).toBe("rgb(0, 76, 191)");
expect(colors.baronCompany).toBe("rgb(0, 76, 191)");
});
test("org chart orders top organization choices by the hanmac family policy", async ({
page,
}) => {
await page.route("**/api/v1/public/orgchart**", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
sharedWith: "Playwright",
tenants: [
{
...tenant("family", "한맥가족", "hanmac-family"),
type: "COMPANY_GROUP",
},
{
...tenant("saman", "삼안", "saman", "family"),
type: "COMPANY",
},
{
...tenant("baron-group", "바론그룹", "baron-group", "family"),
type: "COMPANY_GROUP",
},
{
...tenant("hanmac", "한맥기술", "hanmac", "family"),
type: "COMPANY",
},
{
...tenant("gpdtdc", "총괄기획&기술개발센터", "gpdtdc", "family"),
type: "ORGANIZATION",
},
],
users: [],
}),
});
});
await page.goto("/chart?token=org-selection-order");
await expect(page.getByRole("heading", { name: "조직 현황" })).toBeVisible();
const labels = await page
.getByTestId("orgchart-org-selector")
.locator("button")
.evaluateAll((buttons) =>
buttons.map((button) => button.textContent?.trim() ?? ""),
);
expect(labels.slice(0, 5)).toEqual([
"한맥가족",
"총괄기획&기술개발센터",
"삼안",
"한맥기술",
"바론그룹",
]);
});
test("org chart compresses many sibling organizations and allows wide zoom out", async ({
page,
}) => {
const childTenants = Array.from({ length: 13 }, (_, index) =>
tenant(
`team-${index + 1}`,
`Team ${index + 1}`,
`team-${index + 1}`,
"root",
),
);
await page.route("**/api/v1/public/orgchart**", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
sharedWith: "Playwright",
tenants: [tenant("root", "Baron Group", "baron"), ...childTenants],
users: childTenants.map((child, index) =>
user(`u-team-${index + 1}`, `Team ${index + 1} User`, child.slug),
),
}),
});
});
await page.goto("/chart?token=wide-siblings");
await expect(page.getByRole("heading", { name: "조직 현황" })).toBeVisible();
const viewport = page.locator('[data-testid="orgchart-viewport"]');
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
await expect(svg).toBeVisible();
await expect(svg.getByText("Team 13", { exact: true })).toBeVisible();
await expect(svg.locator('foreignObject[data-node-id^="team-"]')).toHaveCount(
13,
);
await expect(
page.getByRole("button", { name: "조직: 한맥가족" }),
).toBeVisible();
await expect(page.getByText("배치", { exact: true })).toBeHidden();
await expect(page.getByRole("button", { name: "배치: 자동" })).toBeVisible();
await expect(page.getByText("연결", { exact: true })).toHaveCount(0);
await expect(page.getByText("상위연결", { exact: true })).toHaveCount(0);
const autoChildYPositions = await svg
.locator('foreignObject[data-node-id^="team-"]')
.evaluateAll((nodes) =>
nodes
.map((node) => node.getAttribute("y") ?? "")
.filter((value) => value.length > 0),
);
expect(new Set(autoChildYPositions).size).toBeGreaterThan(1);
await expect(svg.locator("path")).toHaveCount(13);
await expect(
svg.locator('path:not([data-hidden-default="true"])'),
).toHaveCount(4);
await expect(svg.locator('path[data-hidden-default="true"]')).toHaveCount(9);
await svg.locator('foreignObject[data-node-id="team-13"]').hover();
await expect(svg.locator('path[data-highlighted="true"]')).toHaveCount(1);
await expect(svg.locator('path[data-muted="true"]')).toHaveCount(4);
await page.getByTestId("orgchart-layout-mode-option").hover();
await expect(page.getByText("배치", { exact: true })).toBeVisible();
await expect(
page.getByRole("button", { exact: true, name: "자동" }),
).toHaveCount(0);
await page.getByRole("button", { name: "Top-down" }).click();
await expect
.poll(async () =>
svg
.locator('foreignObject[data-node-id^="team-"]')
.evaluateAll(
(nodes) =>
new Set(
nodes
.map((node) => node.getAttribute("y") ?? "")
.filter((value) => value.length > 0),
).size,
),
)
.toBe(1);
await page.getByTestId("orgchart-layout-mode-option").hover();
await page.getByRole("button", { name: "3열" }).click();
const threeColumnPositions = await svg
.locator('foreignObject[data-node-id^="team-"]')
.evaluateAll((nodes) =>
nodes.map((node) => ({
x: node.getAttribute("x") ?? "",
y: node.getAttribute("y") ?? "",
})),
);
expect(new Set(threeColumnPositions.map((position) => position.x)).size).toBe(
3,
);
expect(new Set(threeColumnPositions.map((position) => position.y)).size).toBe(
5,
);
await expect(svg.locator("path")).toHaveCount(13);
await expect(
svg.locator('path:not([data-hidden-default="true"])'),
).toHaveCount(3);
const box = await viewport.boundingBox();
expect(box).not.toBeNull();
if (!box) return;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.wheel(0, 2500);
await expect
.poll(async () =>
svg.evaluate((element) =>
Number.parseFloat(element.getAttribute("data-scale") ?? "1"),
),
)
.toBeLessThan(0.45);
});
test("org chart selects first and second depth organizations from company hover choices", async ({
page,
}) => {
await page.route("**/api/v1/public/orgchart**", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
sharedWith: "Playwright",
tenants: [
{
...tenant("group", "Baron Group", "baron"),
type: "COMPANY_GROUP",
},
{
...tenant("company", "Company A", "company-a", "group"),
type: "COMPANY",
},
tenant("department", "Department A", "department-a", "company"),
tenant("squad", "Squad A", "squad-a", "department"),
tenant("team", "Team A", "team-a", "squad"),
],
users: [
user("u-company", "Company User", "company-a"),
user("u-department", "Department User", "department-a"),
user("u-squad", "Squad User", "squad-a"),
user("u-team", "Team User", "team-a"),
],
}),
});
});
await page.goto("/chart?token=company-depth-filter");
await expect(page.getByRole("heading", { name: "조직 현황" })).toBeVisible();
await expect(
page.getByRole("button", { name: "조직: 한맥가족" }),
).toBeVisible();
await expect(page.getByRole("button", { name: "Company A" })).toBeVisible();
await expect(page.getByText("하위범위", { exact: true })).toHaveCount(0);
await expect(page.getByText("조직", { exact: true })).toHaveCount(0);
await page.getByRole("button", { name: "Company A" }).click();
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
await expect(svg.getByText("Team A", { exact: true })).toBeVisible();
await expect(
page.getByRole("button", { name: "조직: Company A" }),
).toBeVisible();
await expect(
page
.getByTestId("orgchart-company-option-company")
.getByRole("button", { name: "Company A" }),
).toBeVisible();
const orgButtonColor = await page
.getByRole("button", { name: "조직: Company A" })
.evaluate((element) => window.getComputedStyle(element).backgroundColor);
const layoutButtonColor = await page
.getByRole("button", { name: "배치: 자동" })
.evaluate((element) => window.getComputedStyle(element).backgroundColor);
expect(orgButtonColor).not.toBe(layoutButtonColor);
await page.getByTestId("orgchart-company-option-company").hover();
await expect(svg.getByText("Department A", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "1뎁스 Department A" }).click();
await expect(
page.getByRole("button", { name: "조직: Department A" }),
).toBeVisible();
await expect(svg.getByText("Squad A", { exact: true })).toBeVisible();
await page.getByTestId("orgchart-company-option-company").hover();
await page.getByRole("button", { name: "2뎁스 Squad A" }).click();
await expect(
page.getByRole("button", { name: "조직: Squad A" }),
).toBeVisible();
await expect(svg.getByText("Squad A", { exact: true })).toBeVisible();
await expect(svg.getByText("Team A", { exact: true })).toBeVisible();
});
test("org chart uses semantic zoom to simplify deep nodes and restore labels on zoom in", async ({
page,
}) => {
await page.route("**/api/v1/public/orgchart**", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
sharedWith: "Playwright",
tenants: [
tenant("root", "Baron Group", "baron"),
tenant("department", "Archive Department", "department", "root"),
tenant("division", "Archive Division", "division", "department"),
tenant("deep", "Archive Deep Team", "deep", "division"),
],
users: [
user("u-root", "Root User", "baron"),
user("u-department", "Department User", "department"),
user("u-division", "Division User", "division"),
user("u-deep", "Deep User", "deep"),
],
}),
});
});
await page.goto("/chart?token=semantic-zoom");
await expect(page.getByRole("heading", { name: "조직 현황" })).toBeVisible();
const viewport = page.locator('[data-testid="orgchart-viewport"]');
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
const deepNode = svg.locator('foreignObject[data-node-id="deep"]');
await expect(svg).toHaveAttribute("data-semantic-zoom", "detail");
await expect(deepNode.getByText("Archive Deep Team")).toBeVisible();
const box = await viewport.boundingBox();
expect(box).not.toBeNull();
if (!box) return;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.wheel(0, 4000);
await expect
.poll(async () => svg.getAttribute("data-semantic-zoom"))
.toBe("overview");
await expect(deepNode.getByText("Archive Deep Team")).toHaveCount(0);
await page.mouse.wheel(0, -4000);
await expect
.poll(async () => svg.getAttribute("data-semantic-zoom"))
.toBe("detail");
await expect(deepNode.getByText("Archive Deep Team")).toBeVisible();
});

View File

@@ -56,7 +56,7 @@ function user(
status: "active",
tenantSlug,
companyCode: tenantSlug,
position: "사원",
grade: "사원",
createdAt: "2026-04-01T00:00:00.000Z",
updatedAt: "2026-04-01T00:00:00.000Z",
...overrides,
@@ -173,7 +173,8 @@ async function installOrgPickerApiMock(
user("user-platform", "Platform User", "platform", {
metadata: { employeeNumber: "EMP-9001", skill: "Kubernetes" },
jobTitle: "Platform Engineer",
position: "책임",
grade: "책임",
position: "팀장",
}),
user("user-sales", "Sales User", "sales"),
];
@@ -252,14 +253,64 @@ test("picker menu lets developers switch selection mode and selectable type", as
).toBeVisible();
});
test("picker displays user names with job title and position", async ({
test("picker defaults to the hanmac-family company-group when no tenant id is supplied", async ({
page,
}) => {
await page.unroute("**/api/v1/admin/tenants**");
await page.unroute("**/api/v1/admin/users**");
const tenants = [
tenant("wrong-group", "COMPANY_GROUP", "Wrong Group", "wrong-group"),
tenant(
"wrong-company",
"COMPANY",
"Wrong Company",
"wrong-company",
"wrong-group",
),
tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
];
await page.route("**/api/v1/admin/tenants**", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
items: tenants,
total: tenants.length,
limit: 10000,
offset: 0,
}),
});
});
await page.route("**/api/v1/admin/users**", async (route) => {
await route.fulfill({
contentType: "application/json",
body: JSON.stringify({
items: [],
total: 0,
limit: 5000,
offset: 0,
}),
});
});
await page.goto(withShareToken("/picker"));
const picker = page.frameLocator("iframe");
await expect(picker.getByText("한맥가족", { exact: true })).toBeVisible();
await expect(picker.getByText("삼안", { exact: true })).toBeVisible();
await expect(picker.getByText("Wrong Group", { exact: true })).toHaveCount(0);
});
test("picker displays user names with grade and optional position", async ({
page,
}) => {
await page.goto(withShareToken("/embed/picker?mode=single&select=user"));
await expect(
page.getByRole("button", {
name: "Platform User(Platform Engineer) 책임",
name: "Platform User 책임(팀장)",
}),
).toBeVisible();
});
@@ -319,17 +370,17 @@ test("embed preview menu updates the iframe picker source", async ({
).toBeVisible();
});
test("embed preview passes tenant id and custom dimensions through the picker url", async ({
test("embed preview passes tenant slug and custom dimensions through the picker url", async ({
page,
}) => {
await page.goto(withShareToken("/embed-preview"));
await page.getByLabel("tenant ID").fill("company-baron");
await page.getByLabel("tenant slug").fill("baron");
await page.getByLabel("임베딩 너비").fill("520");
await page.getByLabel("임베딩 높이").fill("480");
await expect(page.getByTestId("embed-preview-src")).toContainText(
"tenantId=company-baron",
"tenantSlug=baron",
);
await expect(page.getByTestId("embed-preview-src")).toContainText(
"width=520",
@@ -347,16 +398,16 @@ test("embed preview passes tenant id and custom dimensions through the picker ur
await expect(picker.getByText("Sales User")).toHaveCount(0);
});
test("embed picker scopes the tree by tenant id, hides users for tenant selection, and keeps direct members before child tenants", async ({
test("embed picker scopes the tree by tenant slug, hides users for tenant selection, and keeps direct members before child tenants", async ({
page,
}) => {
await page.goto(
withShareToken("/embed-preview?tenantId=company-baron&select=tenant"),
withShareToken("/embed-preview?tenantSlug=baron&select=tenant"),
);
await expect(page.getByLabel("tenant ID")).toHaveValue("company-baron");
await expect(page.getByLabel("tenant slug")).toHaveValue("baron");
await expect(page.getByTestId("embed-preview-src")).toContainText(
"tenantId=company-baron",
"tenantSlug=baron",
);
const picker = page.frameLocator("iframe");
@@ -599,7 +650,7 @@ test("embed picker includes descendants by default and can disable descendant in
await picker.getByLabel("Engineering 선택").check();
await expect(picker.getByLabel("Platform 선택")).toBeChecked();
await expect(
picker.getByLabel("Platform User(Platform Engineer) 책임 선택"),
picker.getByLabel("Platform User 책임(팀장) 선택"),
).toBeChecked();
await picker.getByRole("button", { name: "선택 완료" }).click();
@@ -617,7 +668,7 @@ test("embed picker includes descendants by default and can disable descendant in
await picker.getByLabel("Engineering 선택").check();
await expect(picker.getByLabel("Platform 선택")).not.toBeChecked();
await expect(
picker.getByLabel("Platform User(Platform Engineer) 책임 선택"),
picker.getByLabel("Platform User 책임(팀장) 선택"),
).not.toBeChecked();
await picker.getByRole("button", { name: "선택 완료" }).click();

View File

@@ -29,7 +29,7 @@ function user(id: string, name: string, companyCode: string) {
role: "user",
status: "active",
companyCode,
position: "사원",
grade: "사원",
createdAt: "2026-04-01T00:00:00.000Z",
updatedAt: "2026-04-01T00:00:00.000Z",
};
@@ -84,9 +84,7 @@ test("org chart uses svg viewBox zoom for sharp vector rendering", async ({
const viewport = page.locator('[data-testid="orgchart-viewport"]');
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
await expect(svg).toBeVisible();
await expect(
svg.locator("text", { hasText: "Engineering User" }),
).toBeVisible();
await expect(svg.getByText("Engineering User 사원")).toBeVisible();
const initialViewBox = await svg.getAttribute("viewBox");
const transform = await page
@@ -142,24 +140,18 @@ test("org chart filters by Hanmac family and company while excluding hanmac.kr a
await expect(page.getByText("총 4명")).toBeVisible();
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
await expect(
svg.locator("text", { hasText: "Hidden Hanmac User" }),
).toHaveCount(0);
await expect(
svg.locator("text", { hasText: "Engineering User" }),
).toBeVisible();
await expect(svg.locator("text", { hasText: "Sales User" })).toBeVisible();
await expect(svg.getByText(/Hidden Hanmac User/)).toHaveCount(0);
await expect(svg.getByText("Engineering User 사원")).toBeVisible();
await expect(svg.getByText("Sales User 사원")).toBeVisible();
await page.getByRole("button", { name: "Baron" }).click();
await expect(page.getByText("총 2명")).toBeVisible();
await expect(page.getByText("총 4명")).toHaveCount(0);
await expect(
svg.locator("text", { hasText: "Engineering User" }),
).toBeVisible();
await expect(svg.locator("text", { hasText: "Sales User" })).toHaveCount(0);
await expect(svg.getByText("Engineering User 사원")).toBeVisible();
await expect(svg.getByText(/Sales User/)).toHaveCount(0);
});
test("org chart displays user names with job title and position", async ({
test("org chart displays user names with grade and optional position", async ({
page,
}) => {
await page.route("**/api/v1/public/orgchart**", async (route) => {
@@ -176,7 +168,8 @@ test("org chart displays user names with job title and position", async ({
{
...user("u-eng", "Engineering User", "engineering"),
jobTitle: "Platform Engineer",
position: "책임",
grade: "책임",
position: "팀장",
},
],
}),
@@ -186,11 +179,7 @@ test("org chart displays user names with job title and position", async ({
await page.goto("/chart?token=display-name");
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
await expect(
svg.locator("text", {
hasText: "Engineering User(Platform Engineer) 책임",
}),
).toBeVisible();
await expect(svg.getByText("Engineering User 책임(팀장)")).toBeVisible();
});
test("org chart places multi-tenant users only on leaf memberships without duplicate rendering", async ({
@@ -313,8 +302,8 @@ test("org chart places multi-tenant users only on leaf memberships without dupli
await expect(page.getByText("총 1명")).toBeVisible();
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
await expect(svg).toBeVisible();
await expect(svg.locator("text", { hasText: "Shared User" })).toHaveCount(1);
await expect(svg.locator("text").filter({ hasText: /^1$/ })).toHaveCount(4);
await expect(svg.getByText(/Shared User/)).toHaveCount(1);
await expect(svg.getByText(/^1$/)).toHaveCount(4);
});
test("org chart counts multi-leaf tenant users once in ancestor totals", async ({
@@ -355,8 +344,8 @@ test("org chart counts multi-leaf tenant users once in ancestor totals", async (
await expect(page.getByText("총 1명")).toBeVisible();
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
await expect(svg).toBeVisible();
await expect(svg.locator("text", { hasText: "Shared User" })).toHaveCount(2);
await expect(svg.locator("text").filter({ hasText: /^1$/ })).toHaveCount(5);
await expect(svg.getByText(/Shared User/)).toHaveCount(2);
await expect(svg.getByText(/^1$/)).toHaveCount(5);
});
test("org chart hides system global tenant members", async ({ page }) => {
@@ -389,8 +378,8 @@ test("org chart hides system global tenant members", async ({ page }) => {
await expect(page.getByText("총 1명")).toBeVisible();
const svg = page.locator('[data-testid="orgchart-vector-svg"]');
await expect(svg).toBeVisible();
await expect(svg.locator("text", { hasText: "시스템 전역" })).toHaveCount(0);
await expect(svg.locator("text", { hasText: "Global Admin" })).toHaveCount(0);
await expect(svg.locator("text", { hasText: "System Admin" })).toHaveCount(0);
await expect(svg.locator("text", { hasText: "Baron User" })).toBeVisible();
await expect(svg.getByText(/시스템 전역/)).toHaveCount(0);
await expect(svg.getByText(/Global Admin/)).toHaveCount(0);
await expect(svg.getByText(/System Admin/)).toHaveCount(0);
await expect(svg.getByText("Baron User 사원")).toBeVisible();
});