From e29d056b9e7168cd061373ef1f84ed1c4477f91e Mon Sep 17 00:00:00 2001 From: Lectom Date: Mon, 18 May 2026 15:36:30 +0900 Subject: [PATCH] =?UTF-8?q?=EB=84=A4=EC=9D=B4=EB=B2=84=20=EC=9B=8D?= =?UTF-8?q?=EC=8A=A4=20=EC=97=B0=EB=8F=99=EA=B8=B0=EB=8A=A5=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NAVERWORKS_member_add_sample_English.csv | 3 - adminfront/baron-group_org.csv | 71 ++ adminfront/baron-group_org_slugged.csv | 71 ++ adminfront/playwright.config.ts | 8 +- adminfront/scripts/runtime-mode.sh | 15 +- .../components/ui/dialog.focus-scope.test.tsx | 23 + adminfront/src/components/ui/dialog.tsx | 256 +++++- adminfront/src/components/ui/switch.tsx | 82 +- .../overview/GlobalOverviewPage.test.tsx | 46 +- .../features/overview/GlobalOverviewPage.tsx | 48 +- .../ParentTenantSelector.helpers.ts | 21 + .../components/ParentTenantSelector.test.ts | 2 +- .../components/ParentTenantSelector.tsx | 21 +- .../routes/TenantDetailPage.helpers.ts | 7 + .../tenants/routes/TenantDetailPage.test.ts | 2 +- .../tenants/routes/TenantDetailPage.tsx | 9 +- .../tenants/routes/TenantListPage.test.ts | 82 ++ .../tenants/routes/TenantListPage.tsx | 228 ++++- .../tenants/routes/TenantSchemaPage.test.ts | 2 +- .../tenants/routes/TenantSchemaPage.tsx | 81 +- .../routes/TenantWorksmobilePage.test.ts | 186 ++++- .../tenants/routes/TenantWorksmobilePage.tsx | 785 +++++++++++------- .../features/tenants/routes/tenantListView.ts | 126 +++ .../tenants/routes/tenantSchemaFields.ts | 74 ++ .../tenants/routes/worksmobileComparison.ts | 359 ++++++++ adminfront/src/lib/adminApi.ts | 20 +- adminfront/src/locales/en.toml | 2 +- adminfront/src/locales/ko.toml | 2 +- adminfront/tailwind.config.ts | 3 +- adminfront/tests/shell_layout.spec.ts | 80 ++ adminfront/tests/tenants.spec.ts | 73 ++ adminfront/tests/worksmobile.spec.ts | 77 +- adminfront/vitest.config.ts | 5 + backend/cmd/server/main.go | 1 + .../internal/handler/worksmobile_handler.go | 9 + .../handler/worksmobile_handler_test.go | 4 + .../internal/service/worksmobile_client.go | 52 +- .../service/worksmobile_client_test.go | 142 +++- .../service/worksmobile_live_flow_test.go | 143 ++++ .../internal/service/worksmobile_mapper.go | 3 + .../service/worksmobile_mapper_test.go | 15 + .../service/worksmobile_relay_worker.go | 3 + .../service/worksmobile_sync_service.go | 212 ++++- .../service/worksmobile_sync_service_test.go | 386 ++++++++- common/config/vite.base.ts | 19 +- common/shell/layout.ts | 9 +- devfront/scripts/runtime-mode.sh | 15 +- devfront/tailwind.config.ts | 3 +- docker-compose.yaml | 21 +- orgfront/package.json | 2 +- orgfront/scripts/build-org-context-chart.mjs | 29 + orgfront/scripts/runtime-mode.sh | 15 +- orgfront/src/sdk/org-context-chart/index.ts | 555 ++++++++++++- .../org-context-chart/orgContextChart.test.ts | 175 ++++ orgfront/tailwind.config.ts | 3 +- orgfront/vite.org-context-chart.config.ts | 14 +- .../adminfront_dev_performance_policy_test.sh | 50 ++ test/frontend_dev_bind_mount_policy_test.sh | 47 ++ test/orgfront_integration_policy_test.sh | 8 +- ...orgfront_org_context_chart_package_test.sh | 12 +- test/shell_layout_policy_test.sh | 30 + 61 files changed, 4137 insertions(+), 710 deletions(-) delete mode 100644 adminfront/NAVERWORKS_member_add_sample_English.csv create mode 100644 adminfront/baron-group_org.csv create mode 100644 adminfront/baron-group_org_slugged.csv create mode 100644 adminfront/src/components/ui/dialog.focus-scope.test.tsx create mode 100644 adminfront/src/features/tenants/components/ParentTenantSelector.helpers.ts create mode 100644 adminfront/src/features/tenants/routes/TenantDetailPage.helpers.ts create mode 100644 adminfront/src/features/tenants/routes/TenantListPage.test.ts create mode 100644 adminfront/src/features/tenants/routes/tenantListView.ts create mode 100644 adminfront/src/features/tenants/routes/tenantSchemaFields.ts create mode 100644 adminfront/src/features/tenants/routes/worksmobileComparison.ts create mode 100644 adminfront/tests/shell_layout.spec.ts create mode 100644 orgfront/scripts/build-org-context-chart.mjs create mode 100644 test/adminfront_dev_performance_policy_test.sh create mode 100644 test/frontend_dev_bind_mount_policy_test.sh create mode 100644 test/shell_layout_policy_test.sh diff --git a/adminfront/NAVERWORKS_member_add_sample_English.csv b/adminfront/NAVERWORKS_member_add_sample_English.csv deleted file mode 100644 index aaffce7d..00000000 --- a/adminfront/NAVERWORKS_member_add_sample_English.csv +++ /dev/null @@ -1,3 +0,0 @@ -"LastName","FirstName","ID","Personal email","Sub email","Nickname","User type","Level","Organization","Position","CompanyMainPhone","Mobile/Country code","Mobile/Numbers","Language","Responsibilities","Workplace","SNS","SNS_ID","Birthday (solar, lunar)","Birthday","Entry Date","Employee number","Account activation time" -"Doe","John","john.doe","john@naver.com","john1@company.com; john2@company.com","John","Permanent Employee","Manager","org.1|org.2|org.3|myteam","Manager","02-0000-0000","+1","9144812222","English","Sales management","New York","Facebook","john","solar","19830415","20230415","AB001","20230415 08:00" -"Doe","Eric","eric.doe","eric@naver.com","eric2@company.com","Eric","Contract Employee","Manager","org.1|org.2|org.3|org.4|myteam","Manager","02-1234-0000","+1","9765412345","Japanese","General affairs","New York","Facebook","Eric","lunar","19840704","20240704","AB002","20240704 14:00" diff --git a/adminfront/baron-group_org.csv b/adminfront/baron-group_org.csv new file mode 100644 index 00000000..d17cec68 --- /dev/null +++ b/adminfront/baron-group_org.csv @@ -0,0 +1,71 @@ +"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","마스터에게 메시지방 기능 권한 부여","조직 관련 알림 보내기","조직 공개","외부 도메인 메일 수신 차단","보내는 주소로 사용 가능한 구성원","메일을 보낼 수 있는 구성원","상위 조직" +"(주)장헌","0","","","","jangheon@brsw.kr","Y","N","Y","Y","","","" +"임원실","0","","","","t_617rl@brsw.kr","Y","N","Y","Y","","","(주)장헌(jangheon@brsw.kr)" +"생산부","0","","","","t_921uz@brsw.kr","Y","N","Y","Y","","","(주)장헌(jangheon@brsw.kr)" +"공무팀","0","","","","t_733vx@brsw.kr","Y","N","Y","Y","","","생산부(t_921uz@brsw.kr)" +"철근팀","0","","","","t_334vk@brsw.kr","Y","N","Y","Y","","","생산부(t_921uz@brsw.kr)" +"제작1팀","0","","","","t_196vt@brsw.kr","Y","N","Y","Y","","","생산부(t_921uz@brsw.kr)" +"제작2팀","0","","","","t_690ka@brsw.kr","Y","N","Y","Y","","","생산부(t_921uz@brsw.kr)" +"품질팀","0","","","","t_013sr@brsw.kr","Y","N","Y","Y","","","생산부(t_921uz@brsw.kr)" +"업무지원팀","0","","","","t_601un@brsw.kr","Y","N","Y","Y","","","(주)장헌(jangheon@brsw.kr)" +"PTC","0","","","","ptc@brsw.kr","Y","N","Y","Y","","","" +"임원실","0","","","","t_771pf@brsw.kr","Y","N","Y","Y","","","PTC(ptc@brsw.kr)" +"영업팀","0","","","","t_375vv@brsw.kr","Y","N","Y","Y","","","PTC(ptc@brsw.kr)" +"사업관리팀","0","","","","t_054fx@brsw.kr","Y","N","Y","Y","","","PTC(ptc@brsw.kr)" +"시공팀","0","","","","t_871dc@brsw.kr","Y","N","Y","Y","","","PTC(ptc@brsw.kr)" +"설계팀","0","","","","t_156ss@brsw.kr","Y","N","Y","Y","","","PTC(ptc@brsw.kr)" +"네이버웍스관리용(바론그룹)","2","슈퍼관리자(su-@samaneng.com)","","","su4@brsw.kr","N","N","N","Y","","","" +"장헌산업","0","","","","jangheon-sanup@brsw.kr","Y","N","Y","Y","","","" +"임원실","0","","","","t_049ij@brsw.kr","Y","N","Y","Y","","","장헌산업(jangheon-sanup@brsw.kr)" +"경영지원부","0","","","","t_166wx@brsw.kr","Y","N","Y","Y","","","장헌산업(jangheon-sanup@brsw.kr)" +"기술영업본부","0","","","","t_444be@brsw.kr","Y","N","Y","Y","","","장헌산업(jangheon-sanup@brsw.kr)" +"영업","0","","","","t_999wg@brsw.kr","Y","N","Y","Y","","","기술영업본부(t_444be@brsw.kr)" +"기술지원","0","","","","t_512gs@brsw.kr","Y","N","Y","Y","","","기술영업본부(t_444be@brsw.kr)" +"견적","0","","","","t_917bs@brsw.kr","Y","N","Y","Y","","","기술영업본부(t_444be@brsw.kr)" +"건설본부","0","","","","t_054iq@brsw.kr","Y","N","Y","Y","","","장헌산업(jangheon-sanup@brsw.kr)" +"공무","0","","","","t_191nh@brsw.kr","Y","N","Y","Y","","","건설본부(t_054iq@brsw.kr)" +"현장","0","","","","t_995wn@brsw.kr","Y","N","Y","Y","","","건설본부(t_054iq@brsw.kr)" +"안전관리","0","","","","t_695sg@brsw.kr","Y","N","Y","Y","","","건설본부(t_054iq@brsw.kr)" +"한라산업개발","0","","","","hanlla@brsw.kr","Y","N","Y","Y","","","" +"임원실","0","","","","t_080iz@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)" +"업무총괄","0","","","","general-biz@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)" +"영업총괄","0","","","","general_sales@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)" +"경영지원본부","0","","","","t_261yp@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)" +"업무팀","0","","","","t_407sk@brsw.kr","Y","N","Y","Y","","","경영지원본부(t_261yp@brsw.kr)" +"사업지원팀","0","","","","t_265al@brsw.kr","Y","N","Y","Y","","","경영지원본부(t_261yp@brsw.kr)" +"경영지원팀","0","","","","t_681nn@brsw.kr","Y","N","Y","Y","","","경영지원본부(t_261yp@brsw.kr)" +"운영사업실","0","","","","t_174mm@brsw.kr","Y","N","Y","Y","","","경영지원본부(t_261yp@brsw.kr)" +"기반사업본부","0","","","","t_785dc@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)" +"사업관리팀","0","","","","t_422tk@brsw.kr","Y","N","Y","Y","","","기반사업본부(t_785dc@brsw.kr)" +"환경플랜트사업본부","0","","","","t_558py@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)" +"사업관리팀","0","","","","t_932wg@brsw.kr","Y","N","Y","Y","","","환경플랜트사업본부(t_558py@brsw.kr)" +"설계팀","0","","","","t_695kn@brsw.kr","Y","N","Y","Y","","","환경플랜트사업본부(t_558py@brsw.kr)" +"기술영업본부","0","","","","t_708iq@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)" +"기술영업팀","0","","","","t_026lk@brsw.kr","Y","N","Y","Y","","","기술영업본부(t_708iq@brsw.kr)" +"안전관리본부","0","","","","t_601wg@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)" +"안전관리팀","0","","","","t_885ji@brsw.kr","Y","N","Y","Y","","","안전관리본부(t_601wg@brsw.kr)" +"시공현장","0","","","","t_745dt@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)" +"부천시 굴포천","0","","","","t_579tx@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)" +"옥정 공공하수처리","0","","","","t_644eu@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)" +"여주부평천","0","","","","t_923sy@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)" +"도척 실촌간 도로","0","","","","t_583wq@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)" +"광주공공폐수처리","0","","","","t_481kp@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)" +"아포공공하수처리","0","","","","t_654ud@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)" +"장량공공하수처리","0","","","","t_007gm@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)" +"신천공공하수처리","0","","","","t_328ki@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)" +"온산하수처리","0","","","","t_742au@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)" +"수도권매립지 제2매립장","0","","","","t_850qe@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)" +"인천국제공항 화물","0","","","","t_246jb@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)" +"광탄공공하수처리","0","","","","t_256he@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)" +"성남시생활폐기물처리","0","","","","t_148dm@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)" +"제주공공하수처리","0","","","","t_317lj@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)" +"인덕원 동탄 복선전철 제3공구","0","","","","t_227xx@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)" +"인덕원 동탄 복선전철 제7공구","0","","","","t_605dg@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)" +"경산시 국도대체","0","","","","t_020pv@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)" +"수도권광역급행철도B 제4공구","0","","","","t_217mu@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)" +"부산항 신항","0","","","","t_282jo@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)" +"운영사업소","0","","","","t_993sp@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)" +"울산민자소각","0","","","","t_600hp@brsw.kr","Y","N","Y","Y","","","운영사업소(t_993sp@brsw.kr)" +"온산바이오","0","","","","t_374ak@brsw.kr","Y","N","Y","Y","","","운영사업소(t_993sp@brsw.kr)" +"안성제4차산업단지폐수처리","0","","","","t_749lk@brsw.kr","Y","N","Y","Y","","","운영사업소(t_993sp@brsw.kr)" +"서산시자원회수시설","0","","","","t_056hs@brsw.kr","Y","N","Y","Y","","","운영사업소(t_993sp@brsw.kr)" diff --git a/adminfront/baron-group_org_slugged.csv b/adminfront/baron-group_org_slugged.csv new file mode 100644 index 00000000..4cebe689 --- /dev/null +++ b/adminfront/baron-group_org_slugged.csv @@ -0,0 +1,71 @@ +"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","마스터에게 메시지방 기능 권한 부여","조직 관련 알림 보내기","조직 공개","외부 도메인 메일 수신 차단","보내는 주소로 사용 가능한 구성원","메일을 보낼 수 있는 구성원","상위 조직" +"(주)장헌","0","","","","jangheon@brsw.kr","Y","N","Y","Y","","","" +"임원실","0","","","","jangheon-executive@brsw.kr","Y","N","Y","Y","","","(주)장헌(jangheon@brsw.kr)" +"생산부","0","","","","jangheon-production@brsw.kr","Y","N","Y","Y","","","(주)장헌(jangheon@brsw.kr)" +"공무팀","0","","","","jangheon-production-admin@brsw.kr","Y","N","Y","Y","","","생산부(jangheon-production@brsw.kr)" +"철근팀","0","","","","jangheon-rebar@brsw.kr","Y","N","Y","Y","","","생산부(jangheon-production@brsw.kr)" +"제작1팀","0","","","","jangheon-fab-1@brsw.kr","Y","N","Y","Y","","","생산부(jangheon-production@brsw.kr)" +"제작2팀","0","","","","jangheon-fab-2@brsw.kr","Y","N","Y","Y","","","생산부(jangheon-production@brsw.kr)" +"품질팀","0","","","","jangheon-quality@brsw.kr","Y","N","Y","Y","","","생산부(jangheon-production@brsw.kr)" +"업무지원팀","0","","","","jangheon-business-support@brsw.kr","Y","N","Y","Y","","","(주)장헌(jangheon@brsw.kr)" +"PTC","0","","","","ptc@brsw.kr","Y","N","Y","Y","","","" +"임원실","0","","","","ptc-executive@brsw.kr","Y","N","Y","Y","","","PTC(ptc@brsw.kr)" +"영업팀","0","","","","ptc-sales@brsw.kr","Y","N","Y","Y","","","PTC(ptc@brsw.kr)" +"사업관리팀","0","","","","ptc-project-management@brsw.kr","Y","N","Y","Y","","","PTC(ptc@brsw.kr)" +"시공팀","0","","","","ptc-construction@brsw.kr","Y","N","Y","Y","","","PTC(ptc@brsw.kr)" +"설계팀","0","","","","ptc-design@brsw.kr","Y","N","Y","Y","","","PTC(ptc@brsw.kr)" +"네이버웍스관리용(바론그룹)","2","슈퍼관리자(su-@samaneng.com)","","","nw-admin-baron-group@brsw.kr","N","N","N","Y","","","" +"장헌산업","0","","","","jangheon-sanup@brsw.kr","Y","N","Y","Y","","","" +"임원실","0","","","","jangheon-sanup-executive@brsw.kr","Y","N","Y","Y","","","장헌산업(jangheon-sanup@brsw.kr)" +"경영지원부","0","","","","js-mgmt-support@brsw.kr","Y","N","Y","Y","","","장헌산업(jangheon-sanup@brsw.kr)" +"기술영업본부","0","","","","js-tech-sales-hq@brsw.kr","Y","N","Y","Y","","","장헌산업(jangheon-sanup@brsw.kr)" +"영업","0","","","","js-sales@brsw.kr","Y","N","Y","Y","","","기술영업본부(js-tech-sales-hq@brsw.kr)" +"기술지원","0","","","","js-tech-support@brsw.kr","Y","N","Y","Y","","","기술영업본부(js-tech-sales-hq@brsw.kr)" +"견적","0","","","","js-estimation@brsw.kr","Y","N","Y","Y","","","기술영업본부(js-tech-sales-hq@brsw.kr)" +"건설본부","0","","","","js-construction-hq@brsw.kr","Y","N","Y","Y","","","장헌산업(jangheon-sanup@brsw.kr)" +"공무","0","","","","js-construction-admin@brsw.kr","Y","N","Y","Y","","","건설본부(js-construction-hq@brsw.kr)" +"현장","0","","","","js-site@brsw.kr","Y","N","Y","Y","","","건설본부(js-construction-hq@brsw.kr)" +"안전관리","0","","","","js-safety-management@brsw.kr","Y","N","Y","Y","","","건설본부(js-construction-hq@brsw.kr)" +"한라산업개발","0","","","","hanlla@brsw.kr","Y","N","Y","Y","","","" +"임원실","0","","","","hanlla-executive@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)" +"업무총괄","0","","","","hanlla-general-business@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)" +"영업총괄","0","","","","hanlla-general-sales@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)" +"경영지원본부","0","","","","hanlla-mgmt-support-hq@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)" +"업무팀","0","","","","hanlla-operations@brsw.kr","Y","N","Y","Y","","","경영지원본부(hanlla-mgmt-support-hq@brsw.kr)" +"사업지원팀","0","","","","hanlla-business-support@brsw.kr","Y","N","Y","Y","","","경영지원본부(hanlla-mgmt-support-hq@brsw.kr)" +"경영지원팀","0","","","","hanlla-mgmt-support@brsw.kr","Y","N","Y","Y","","","경영지원본부(hanlla-mgmt-support-hq@brsw.kr)" +"운영사업실","0","","","","hanlla-operations-office@brsw.kr","Y","N","Y","Y","","","경영지원본부(hanlla-mgmt-support-hq@brsw.kr)" +"기반사업본부","0","","","","hanlla-infra-business-hq@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)" +"사업관리팀","0","","","","hanlla-infra-project-mgmt@brsw.kr","Y","N","Y","Y","","","기반사업본부(hanlla-infra-business-hq@brsw.kr)" +"환경플랜트사업본부","0","","","","hanlla-env-plant-hq@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)" +"사업관리팀","0","","","","hanlla-env-project-mgmt@brsw.kr","Y","N","Y","Y","","","환경플랜트사업본부(hanlla-env-plant-hq@brsw.kr)" +"설계팀","0","","","","hanlla-env-plant-design@brsw.kr","Y","N","Y","Y","","","환경플랜트사업본부(hanlla-env-plant-hq@brsw.kr)" +"기술영업본부","0","","","","hanlla-tech-sales-hq@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)" +"기술영업팀","0","","","","hanlla-tech-sales-team@brsw.kr","Y","N","Y","Y","","","기술영업본부(hanlla-tech-sales-hq@brsw.kr)" +"안전관리본부","0","","","","hanlla-safety-hq@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)" +"안전관리팀","0","","","","hanlla-safety-team@brsw.kr","Y","N","Y","Y","","","안전관리본부(hanlla-safety-hq@brsw.kr)" +"시공현장","0","","","","hanlla-construction-sites@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)" +"부천시 굴포천","0","","","","site-bucheon-gulpocheon@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)" +"옥정 공공하수처리","0","","","","site-okjeong-sewage@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)" +"여주부평천","0","","","","site-yeoju-bupyeongcheon@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)" +"도척 실촌간 도로","0","","","","site-docheok-silchon-road@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)" +"광주공공폐수처리","0","","","","site-gwangju-wastewater@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)" +"아포공공하수처리","0","","","","site-apo-sewage@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)" +"장량공공하수처리","0","","","","site-jangnyang-sewage@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)" +"신천공공하수처리","0","","","","site-sincheon-sewage@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)" +"온산하수처리","0","","","","site-onsan-sewage@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)" +"수도권매립지 제2매립장","0","","","","site-sudokwon-landfill-2@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)" +"인천국제공항 화물","0","","","","site-incheon-air-cargo@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)" +"광탄공공하수처리","0","","","","site-gwangtan-sewage@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)" +"성남시생활폐기물처리","0","","","","site-seongnam-waste@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)" +"제주공공하수처리","0","","","","site-jeju-sewage@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)" +"인덕원 동탄 복선전철 제3공구","0","","","","site-indeokwon-dongtan-3@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)" +"인덕원 동탄 복선전철 제7공구","0","","","","site-indeokwon-dongtan-7@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)" +"경산시 국도대체","0","","","","site-gyeongsan-road@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)" +"수도권광역급행철도B 제4공구","0","","","","site-gtx-b-4@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)" +"부산항 신항","0","","","","site-busan-new-port@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)" +"운영사업소","0","","","","hanlla-operation-sites@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)" +"울산민자소각","0","","","","ops-ulsan-incineration@brsw.kr","Y","N","Y","Y","","","운영사업소(hanlla-operation-sites@brsw.kr)" +"온산바이오","0","","","","ops-onsan-bio@brsw.kr","Y","N","Y","Y","","","운영사업소(hanlla-operation-sites@brsw.kr)" +"안성제4차산업단지폐수처리","0","","","","ops-anseong-wwtp@brsw.kr","Y","N","Y","Y","","","운영사업소(hanlla-operation-sites@brsw.kr)" +"서산시자원회수시설","0","","","","ops-seosan-recovery@brsw.kr","Y","N","Y","Y","","","운영사업소(hanlla-operation-sites@brsw.kr)" diff --git a/adminfront/playwright.config.ts b/adminfront/playwright.config.ts index bbfcbd3f..80df3513 100644 --- a/adminfront/playwright.config.ts +++ b/adminfront/playwright.config.ts @@ -14,6 +14,7 @@ const port = Number.parseInt(process.env.PORT ?? "5173", 10); const defaultBaseUrl = `http://127.0.0.1:${port}`; const baseURL = process.env.BASE_URL ?? defaultBaseUrl; const reuseExistingServer = !process.env.CI && !process.env.PORT; +const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH; /** * Read environment variables from file. @@ -56,7 +57,12 @@ export default defineConfig({ projects: [ { name: "chromium", - use: { ...devices["Desktop Chrome"] }, + use: { + ...devices["Desktop Chrome"], + launchOptions: chromiumExecutablePath + ? { executablePath: chromiumExecutablePath } + : undefined, + }, }, { diff --git a/adminfront/scripts/runtime-mode.sh b/adminfront/scripts/runtime-mode.sh index b38e3fa0..be6c0930 100644 --- a/adminfront/scripts/runtime-mode.sh +++ b/adminfront/scripts/runtime-mode.sh @@ -36,10 +36,12 @@ if [ "${1:-}" = "--print-mode" ]; then fi ensure_frontend_dependencies() { - # If common workspace exists, manage dependencies from there - if [ -d /common ] && [ -f /common/package.json ]; then - WORKSPACE_DIR="/common" - LOCK_FILE="/common/pnpm-lock.yaml" + APP_WORKSPACE_FILTER="../adminfront" + + # If common workspace exists, manage dependencies from the real workspace tree. + if [ -d /workspace/common ] && [ -f /workspace/common/package.json ]; then + WORKSPACE_DIR="/workspace/common" + LOCK_FILE="/workspace/common/pnpm-lock.yaml" else WORKSPACE_DIR="." LOCK_FILE="package-lock.json" @@ -59,9 +61,8 @@ ensure_frontend_dependencies() { if [ "$installed_hash" != "$deps_hash" ]; then echo "Installing frontend dependencies..." - if [ "$WORKSPACE_DIR" = "/common" ]; then - - (cd /common && rm -rf node_modules .pnpm-store package-lock.json && npm install --no-workspaces --no-fund --no-audit) + if [ "$WORKSPACE_DIR" = "/workspace/common" ]; then + (cd /workspace/common && pnpm install --filter "${APP_WORKSPACE_FILTER}..." --frozen-lockfile --ignore-scripts) else npm ci fi diff --git a/adminfront/src/components/ui/dialog.focus-scope.test.tsx b/adminfront/src/components/ui/dialog.focus-scope.test.tsx new file mode 100644 index 00000000..95a97418 --- /dev/null +++ b/adminfront/src/components/ui/dialog.focus-scope.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "./dialog"; + +describe("Dialog FocusScope integration", () => { + it("mounts an open dialog without a ref update loop", () => { + render( + + + Focus scope check + Dialog content is mounted. + + , + ); + + expect(screen.getByText("Focus scope check")).toBeInTheDocument(); + }); +}); diff --git a/adminfront/src/components/ui/dialog.tsx b/adminfront/src/components/ui/dialog.tsx index 52c8858f..7fe8c22a 100644 --- a/adminfront/src/components/ui/dialog.tsx +++ b/adminfront/src/components/ui/dialog.tsx @@ -1,55 +1,221 @@ -import * as DialogPrimitive from "@radix-ui/react-dialog"; import { X } from "lucide-react"; import * as React from "react"; +import { createPortal } from "react-dom"; import { cn } from "../../lib/utils"; -const Dialog = DialogPrimitive.Root; +type DialogContextValue = { + open: boolean; + setOpen: (open: boolean) => void; +}; -const DialogTrigger = DialogPrimitive.Trigger; +const DialogContext = React.createContext(null); -const DialogPortal = DialogPrimitive.Portal; +function useDialogContext(componentName: string) { + const context = React.useContext(DialogContext); + if (!context) { + throw new Error(`${componentName} must be used within Dialog`); + } + return context; +} -const DialogClose = DialogPrimitive.Close; +function composeEventHandlers( + theirs: ((event: E) => void) | undefined, + ours: (event: E) => void, +) { + return (event: E) => { + theirs?.(event); + if (!event.defaultPrevented) { + ours(event); + } + }; +} + +type DialogProps = { + open?: boolean; + defaultOpen?: boolean; + onOpenChange?: (open: boolean) => void; + children?: React.ReactNode; +}; + +function Dialog({ + open, + defaultOpen = false, + onOpenChange, + children, +}: DialogProps) { + const [internalOpen, setInternalOpen] = React.useState(defaultOpen); + const isControlled = open !== undefined; + const currentOpen = isControlled ? open : internalOpen; + const setOpen = React.useCallback( + (nextOpen: boolean) => { + if (!isControlled) { + setInternalOpen(nextOpen); + } + onOpenChange?.(nextOpen); + }, + [isControlled, onOpenChange], + ); + + const value = React.useMemo( + () => ({ open: currentOpen, setOpen }), + [currentOpen, setOpen], + ); + + return ( + {children} + ); +} + +type DialogTriggerProps = React.ButtonHTMLAttributes & { + asChild?: boolean; +}; + +const DialogTrigger = React.forwardRef( + ({ asChild = false, children, onClick, ...props }, ref) => { + const { setOpen } = useDialogContext("DialogTrigger"); + const handleOpen = (event: React.MouseEvent) => { + onClick?.(event); + if (!event.defaultPrevented) { + setOpen(true); + } + }; + + if (asChild && React.isValidElement(children)) { + const child = children as React.ReactElement<{ + onClick?: React.MouseEventHandler; + }>; + return React.cloneElement(child, { + ...props, + onClick: composeEventHandlers( + child.props.onClick as React.MouseEventHandler, + () => setOpen(true), + ), + }); + } + + return ( + + ); + }, +); +DialogTrigger.displayName = "DialogTrigger"; + +const DialogPortal = ({ children }: { children?: React.ReactNode }) => { + if (typeof document === "undefined") { + return null; + } + return createPortal(children, document.body); +}; +DialogPortal.displayName = "DialogPortal"; + +const DialogClose = React.forwardRef< + HTMLButtonElement, + DialogTriggerProps +>(({ asChild = false, children, onClick, ...props }, ref) => { + const { setOpen } = useDialogContext("DialogClose"); + const handleClose = (event: React.MouseEvent) => { + onClick?.(event); + if (!event.defaultPrevented) { + setOpen(false); + } + }; + + if (asChild && React.isValidElement(children)) { + const child = children as React.ReactElement<{ + onClick?: React.MouseEventHandler; + }>; + return React.cloneElement(child, { + ...props, + onClick: composeEventHandlers( + child.props.onClick as React.MouseEventHandler, + () => setOpen(false), + ), + }); + } + + return ( + + ); +}); +DialogClose.displayName = "DialogClose"; const DialogOverlay = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; - -const DialogContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - +>(({ className, onMouseDown, ...props }, ref) => { + const { setOpen } = useDialogContext("DialogOverlay"); + return ( +
{ + if (event.target === event.currentTarget) { + setOpen(false); + } + })} {...props} - > - {children} - - - Close - - - -)); -DialogContent.displayName = DialogPrimitive.Content.displayName; + /> + ); +}); +DialogOverlay.displayName = "DialogOverlay"; + +const DialogContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, children, onKeyDown, ...props }, ref) => { + const { open, setOpen } = useDialogContext("DialogContent"); + + React.useEffect(() => { + if (!open) { + return; + } + const onDocumentKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setOpen(false); + } + }; + document.addEventListener("keydown", onDocumentKeyDown); + return () => document.removeEventListener("keydown", onDocumentKeyDown); + }, [open, setOpen]); + + if (!open) { + return null; + } + + return ( + + +
+ {children} + + + Close + +
+
+ ); +}); +DialogContent.displayName = "DialogContent"; const DialogHeader = ({ className, @@ -80,10 +246,10 @@ const DialogFooter = ({ DialogFooter.displayName = "DialogFooter"; const DialogTitle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + HTMLHeadingElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( - )); -DialogTitle.displayName = DialogPrimitive.Title.displayName; +DialogTitle.displayName = "DialogTitle"; const DialogDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef + HTMLParagraphElement, + React.HTMLAttributes >(({ className, ...props }, ref) => ( - )); -DialogDescription.displayName = DialogPrimitive.Description.displayName; +DialogDescription.displayName = "DialogDescription"; export { Dialog, diff --git a/adminfront/src/components/ui/switch.tsx b/adminfront/src/components/ui/switch.tsx index dfc8f347..9491a85b 100644 --- a/adminfront/src/components/ui/switch.tsx +++ b/adminfront/src/components/ui/switch.tsx @@ -1,26 +1,68 @@ -import * as SwitchPrimitives from "@radix-ui/react-switch"; import * as React from "react"; import { cn } from "../../lib/utils"; -const Switch = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - , "onChange"> { + checked?: boolean; + defaultChecked?: boolean; + onCheckedChange?: (checked: boolean) => void; +} + +const Switch = React.forwardRef( + ( + { className, - )} - {...props} - ref={ref} - > - - -)); -Switch.displayName = SwitchPrimitives.Root.displayName; + checked, + defaultChecked = false, + disabled, + onCheckedChange, + onClick, + ...props + }, + ref, + ) => { + const isControlled = checked !== undefined; + const [internalChecked, setInternalChecked] = + React.useState(defaultChecked); + const currentChecked = isControlled ? checked : internalChecked; + + const handleClick = (event: React.MouseEvent) => { + onClick?.(event); + if (event.defaultPrevented || disabled) { + return; + } + const nextChecked = !currentChecked; + if (!isControlled) { + setInternalChecked(nextChecked); + } + onCheckedChange?.(nextChecked); + }; + + return ( + + ); + }, +); +Switch.displayName = "Switch"; export { Switch }; diff --git a/adminfront/src/features/overview/GlobalOverviewPage.test.tsx b/adminfront/src/features/overview/GlobalOverviewPage.test.tsx index e4e19203..e1c4e63c 100644 --- a/adminfront/src/features/overview/GlobalOverviewPage.test.tsx +++ b/adminfront/src/features/overview/GlobalOverviewPage.test.tsx @@ -22,6 +22,17 @@ vi.mock("../../lib/adminApi", () => ({ })), fetchAllTenants: vi.fn(async () => ({ items: [ + { + id: "group-1", + type: "COMPANY_GROUP", + name: "한맥그룹", + slug: "hanmac-group", + description: "", + status: "active", + memberCount: 0, + createdAt: "2026-05-06T00:00:00Z", + updatedAt: "2026-05-06T00:00:00Z", + }, { id: "company-1", type: "COMPANY", @@ -58,7 +69,7 @@ vi.mock("../../lib/adminApi", () => ({ ], limit: 1000, offset: 0, - total: 3, + total: 4, })), fetchAdminRPUsageDaily: vi.fn(async () => ({ days: 14, @@ -150,7 +161,7 @@ describe("admin overview and auth guard pages", () => { renderWithProviders(); expect( - await screen.findByText("회사별 앱별 로그인요청/기타 요청 현황"), + await screen.findByText("회사별 앱별 로그인 요청 현황"), ).toBeInTheDocument(); expect( await screen.findByLabelText("일 단위 RP 요청 현황"), @@ -168,7 +179,7 @@ describe("admin overview and auth guard pages", () => { expect( (await screen.findByText("전체 테넌트 수")).parentElement, - ).toHaveTextContent("3"); + ).toHaveTextContent("4"); expect(screen.getByText("OIDC 클라이언트").parentElement).toHaveTextContent( "3", ); @@ -180,17 +191,30 @@ describe("admin overview and auth guard pages", () => { ); }); - it("changes the RP usage perspective and targets a permitted organization", async () => { + it("limits the overview graph choices to company tenants", async () => { renderWithProviders(); - await screen.findByText("회사별 앱별 로그인요청/기타 요청 현황"); + await screen.findByText("회사별 앱별 로그인 요청 현황"); + + expect( + await screen.findByRole("checkbox", { name: "한맥 (hanmac)" }), + ).toBeInTheDocument(); + expect( + screen.queryByText("한맥그룹 (hanmac-group)"), + ).not.toBeInTheDocument(); + expect(screen.queryByText("개발팀 (dev-team)")).not.toBeInTheDocument(); + expect(screen.queryByText("개인 (personal)")).not.toBeInTheDocument(); + }); + + it("changes the RP usage perspective and targets a permitted company", async () => { + renderWithProviders(); + + await screen.findByText("회사별 앱별 로그인 요청 현황"); fireEvent.click(screen.getByRole("button", { name: "주" })); expect(await screen.findAllByText("19(05월1주)")).not.toHaveLength(0); expect(await screen.findAllByText("40(10월1주)")).not.toHaveLength(0); fireEvent.click(screen.getByRole("button", { name: "월" })); - fireEvent.click( - screen.getByRole("checkbox", { name: "개발팀 (dev-team)" }), - ); + fireEvent.click(screen.getByRole("checkbox", { name: "한맥 (hanmac)" })); await waitFor(() => { expect(fetchAdminRPUsageDaily).toHaveBeenLastCalledWith({ @@ -198,6 +222,10 @@ describe("admin overview and auth guard pages", () => { period: "month", }); }); + expect( + screen.queryByText("한맥그룹 (hanmac-group)"), + ).not.toBeInTheDocument(); + expect(screen.queryByText("개발팀 (dev-team)")).not.toBeInTheDocument(); expect(screen.queryByText("개인 (personal)")).not.toBeInTheDocument(); expect(await screen.findAllByText("05월")).not.toHaveLength(0); }); @@ -217,7 +245,7 @@ describe("admin overview and auth guard pages", () => { renderWithProviders(); - await screen.findByText("회사별 앱별 로그인요청/기타 요청 현황"); + await screen.findByText("회사별 앱별 로그인 요청 현황"); expect(screen.queryByText("정합성 최종 검증")).not.toBeInTheDocument(); expect(fetchDataIntegrityReport).not.toHaveBeenCalled(); }); diff --git a/adminfront/src/features/overview/GlobalOverviewPage.tsx b/adminfront/src/features/overview/GlobalOverviewPage.tsx index 58397d25..ece1bd78 100644 --- a/adminfront/src/features/overview/GlobalOverviewPage.tsx +++ b/adminfront/src/features/overview/GlobalOverviewPage.tsx @@ -9,6 +9,11 @@ import { Users, } from "lucide-react"; import { type ReactNode, useMemo, useState } from "react"; +import { + OverviewAxisNotes, + OverviewMetric, + OverviewSelectionChips, +} from "../../../../common/core/components/overview"; import { RoleGuard } from "../../components/auth/RoleGuard"; import { type DataIntegrityStatus, @@ -21,11 +26,6 @@ import { fetchDataIntegrityReport, } from "../../lib/adminApi"; import { t } from "../../lib/i18n"; -import { - OverviewAxisNotes, - OverviewMetric, - OverviewSelectionChips, -} from "../../../../common/core/components/overview"; type DailyPoint = { date: string; @@ -72,7 +72,10 @@ function summarizeSeries(rows: RPUsageDailyMetric[]): SeriesSummary[] { uniqueSubjects: 0, } satisfies SeriesSummary); current.loginRequests += row.loginRequests; - current.uniqueSubjects = Math.max(current.uniqueSubjects, row.uniqueSubjects); + current.uniqueSubjects = Math.max( + current.uniqueSubjects, + row.uniqueSubjects, + ); bySeries.set(key, current); } return Array.from(bySeries.values()) @@ -200,10 +203,7 @@ function IntegrityOverviewSummary() { )}

- {t( - "ui.admin.integrity.summary.title", - "정합성 최종 검증", - )} + {t("ui.admin.integrity.summary.title", "정합성 최종 검증")}

@@ -213,11 +213,9 @@ function IntegrityOverviewSummary() { {integrityStatusText(data.status)} - {t( - "ui.admin.integrity.summary.failures_text", - "실패 {{count}}건", - { count: data.summary.failures }, - )} + {t("ui.admin.integrity.summary.failures_text", "실패 {{count}}건", { + count: data.summary.failures, + })} {formatOverviewDateTime(data.checkedAt)} @@ -303,7 +301,7 @@ function RPUsageMixedChart({

{t( "ui.admin.overview.chart.description", - "전체 또는 선택한 조직 기준으로 그래프를 확인합니다.", + "전체 또는 선택한 회사 기준으로 그래프를 확인합니다.", )}

@@ -397,17 +395,20 @@ function RPUsageMixedChart({ ))} - + )} {series.length > 0 && (
{series.map((item) => ( -
+
{item.clientLabel} {t( @@ -423,7 +424,6 @@ function RPUsageMixedChart({ ))}
)} - ); } @@ -444,7 +444,7 @@ function GlobalOverviewPage() { }); const tenantOptions = useMemo(() => { return (tenantsQuery.data?.items ?? []).filter( - (tenant) => tenant.type === "COMPANY" || tenant.type === "ORGANIZATION", + (tenant) => tenant.type === "COMPANY", ); }, [tenantsQuery.data?.items]); const usageQuery = useQuery({ @@ -582,7 +582,7 @@ function GlobalOverviewPage() {

{t( "ui.admin.overview.chart.description", - "전체 또는 선택한 조직 기준으로 그래프를 확인합니다.", + "전체 또는 선택한 회사 기준으로 그래프를 확인합니다.", )}

diff --git a/adminfront/src/features/tenants/components/ParentTenantSelector.helpers.ts b/adminfront/src/features/tenants/components/ParentTenantSelector.helpers.ts new file mode 100644 index 00000000..0142d3b9 --- /dev/null +++ b/adminfront/src/features/tenants/components/ParentTenantSelector.helpers.ts @@ -0,0 +1,21 @@ +import type { TenantSummary } from "../../../lib/adminApi"; + +const companyParentTypes = new Set(["COMPANY", "COMPANY_GROUP"]); + +export function filterParentTenants( + tenants: TenantSummary[], + search: string, + companyOnly: boolean, + excludeTenantId = "", +) { + const normalizedSearch = search.trim().toLowerCase(); + return tenants.filter((tenant) => { + if (excludeTenantId && tenant.id === excludeTenantId) return false; + if (companyOnly && !companyParentTypes.has(tenant.type)) return false; + if (!normalizedSearch) return true; + + return [tenant.name, tenant.slug, tenant.type] + .filter(Boolean) + .some((value) => value.toLowerCase().includes(normalizedSearch)); + }); +} diff --git a/adminfront/src/features/tenants/components/ParentTenantSelector.test.ts b/adminfront/src/features/tenants/components/ParentTenantSelector.test.ts index bd99f460..8e9109b8 100644 --- a/adminfront/src/features/tenants/components/ParentTenantSelector.test.ts +++ b/adminfront/src/features/tenants/components/ParentTenantSelector.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { TenantSummary } from "../../../lib/adminApi"; -import { filterParentTenants } from "./ParentTenantSelector"; +import { filterParentTenants } from "./ParentTenantSelector.helpers"; const tenants: TenantSummary[] = [ { diff --git a/adminfront/src/features/tenants/components/ParentTenantSelector.tsx b/adminfront/src/features/tenants/components/ParentTenantSelector.tsx index 9c1d580a..a8cc3199 100644 --- a/adminfront/src/features/tenants/components/ParentTenantSelector.tsx +++ b/adminfront/src/features/tenants/components/ParentTenantSelector.tsx @@ -16,6 +16,7 @@ import { buildAuthenticatedOrgChartTenantPickerUrl, parseOrgChartTenantSelection, } from "../../users/orgChartPicker"; +import { filterParentTenants } from "./ParentTenantSelector.helpers"; type ParentTenantSelectorProps = { id: string; @@ -33,26 +34,6 @@ type ParentTenantSelectorProps = { localTenantFilter?: (tenant: TenantSummary) => boolean; }; -const companyParentTypes = new Set(["COMPANY", "COMPANY_GROUP"]); - -export function filterParentTenants( - tenants: TenantSummary[], - search: string, - companyOnly: boolean, - excludeTenantId = "", -) { - const normalizedSearch = search.trim().toLowerCase(); - return tenants.filter((tenant) => { - if (excludeTenantId && tenant.id === excludeTenantId) return false; - if (companyOnly && !companyParentTypes.has(tenant.type)) return false; - if (!normalizedSearch) return true; - - return [tenant.name, tenant.slug, tenant.type] - .filter(Boolean) - .some((value) => value.toLowerCase().includes(normalizedSearch)); - }); -} - export function ParentTenantSelector({ id, label, diff --git a/adminfront/src/features/tenants/routes/TenantDetailPage.helpers.ts b/adminfront/src/features/tenants/routes/TenantDetailPage.helpers.ts new file mode 100644 index 00000000..e3eb9e1b --- /dev/null +++ b/adminfront/src/features/tenants/routes/TenantDetailPage.helpers.ts @@ -0,0 +1,7 @@ +export function canShowWorksmobileEntry(tenant?: { + id?: string; + slug?: string; + parentId?: string | null; +}) { + return tenant?.slug === "hanmac-family" && !tenant.parentId; +} diff --git a/adminfront/src/features/tenants/routes/TenantDetailPage.test.ts b/adminfront/src/features/tenants/routes/TenantDetailPage.test.ts index 4ee15345..4c102e08 100644 --- a/adminfront/src/features/tenants/routes/TenantDetailPage.test.ts +++ b/adminfront/src/features/tenants/routes/TenantDetailPage.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { canShowWorksmobileEntry } from "./TenantDetailPage"; +import { canShowWorksmobileEntry } from "./TenantDetailPage.helpers"; describe("TenantDetailPage Worksmobile entry visibility", () => { it("shows Worksmobile entry only for hanmac-family root tenant", () => { diff --git a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx index 01ce0e0c..fb452a3c 100644 --- a/adminfront/src/features/tenants/routes/TenantDetailPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantDetailPage.tsx @@ -6,14 +6,7 @@ import { Button } from "../../../components/ui/button"; import { fetchMe, fetchTenant } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; import { normalizeAdminRole } from "../../../lib/roles"; - -export function canShowWorksmobileEntry(tenant?: { - id?: string; - slug?: string; - parentId?: string | null; -}) { - return tenant?.slug === "hanmac-family" && !tenant.parentId; -} +import { canShowWorksmobileEntry } from "./TenantDetailPage.helpers"; function TenantDetailPage() { const params = useParams<{ tenantId: string }>(); diff --git a/adminfront/src/features/tenants/routes/TenantListPage.test.ts b/adminfront/src/features/tenants/routes/TenantListPage.test.ts new file mode 100644 index 00000000..8ce57dec --- /dev/null +++ b/adminfront/src/features/tenants/routes/TenantListPage.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import type { TenantSummary } from "../../../lib/adminApi"; +import { + filterTenantsByScope, + getTenantViewRows, + resolveTenantSelectionIds, + tenantMatchesListSearch, +} from "./tenantListView"; + +function tenant( + id: string, + name: string, + slug: string, + parentId?: string, +): TenantSummary { + return { + id, + name, + slug, + parentId, + type: parentId ? "ORGANIZATION" : "COMPANY", + description: "", + status: "active", + memberCount: 0, + createdAt: "", + updatedAt: "", + }; +} + +const tenants = [ + tenant("company-1", "한맥기술", "hanmac"), + tenant("dept-1", "기술기획", "planning", "company-1"), + tenant("team-1", "플랫폼팀", "platform", "dept-1"), + tenant("company-2", "삼안", "saman"), +]; + +describe("TenantListPage tenant list helpers", () => { + it("selects a parent tenant together with every descendant", () => { + expect( + resolveTenantSelectionIds({ + currentIds: [], + tenant: tenants[0], + checked: true, + tenants, + deletableTenants: tenants, + }), + ).toEqual(["company-1", "dept-1", "team-1"]); + }); + + it("removes a parent tenant together with every descendant", () => { + expect( + resolveTenantSelectionIds({ + currentIds: ["company-1", "dept-1", "team-1", "company-2"], + tenant: tenants[0], + checked: false, + tenants, + deletableTenants: tenants, + }), + ).toEqual(["company-2"]); + }); + + it("filters to descendants of the selected scope tenant", () => { + expect( + filterTenantsByScope(tenants, "company-1").map((item) => item.id), + ).toEqual(["dept-1", "team-1"]); + }); + + it("searches tenants by name, slug, and UUID", () => { + expect(tenantMatchesListSearch(tenants[2], "team-1")).toBe(true); + expect(tenantMatchesListSearch(tenants[2], "platform")).toBe(true); + expect(tenantMatchesListSearch(tenants[2], "플랫폼")).toBe(true); + }); + + it("can return tree rows or same-level table rows", () => { + expect(getTenantViewRows(tenants, "tree").map((row) => row.depth)).toEqual([ + 0, 1, 2, 0, + ]); + expect(getTenantViewRows(tenants, "table").map((row) => row.depth)).toEqual( + [0, 0, 0, 0], + ); + }); +}); diff --git a/adminfront/src/features/tenants/routes/TenantListPage.tsx b/adminfront/src/features/tenants/routes/TenantListPage.tsx index ee163169..e8c28d2e 100644 --- a/adminfront/src/features/tenants/routes/TenantListPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantListPage.tsx @@ -104,8 +104,10 @@ import { t } from "../../../lib/i18n"; import { normalizeAdminRole } from "../../../lib/roles"; import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree"; import { + buildAuthenticatedOrgChartTenantPickerUrl, filterNonHanmacFamilyTenants, isHanmacFamilyUser, + parseOrgChartTenantSelection, } from "../../users/orgChartPicker"; import { isSeedTenant } from "../utils/protectedTenants"; import { @@ -117,6 +119,14 @@ import { parseTenantCSV, serializeTenantImportCSV, } from "../utils/tenantCsvImport"; +import { + type TenantViewMode, + type TenantViewRow, + filterTenantsByScope, + getTenantViewRows, + resolveTenantSelectionIds, + tenantMatchesListSearch, +} from "./tenantListView"; const tenantCSVTemplate = "name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type\n"; @@ -266,6 +276,9 @@ function TenantListPage() { const navigate = useNavigate(); const [selectedIds, setSelectedIds] = React.useState([]); const [search, setSearch] = React.useState(""); + const [viewMode, setViewMode] = React.useState("tree"); + const [scopeTenantId, setScopeTenantId] = React.useState(""); + const [scopePickerOpen, setScopePickerOpen] = React.useState(false); const [sortConfig, setSortConfig] = React.useState | null>({ key: "createdAt", @@ -470,8 +483,14 @@ function TenantListPage() { ? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.") : null; - const tenantPages = query.data?.pages ?? []; - const rawTenants = tenantPages.flatMap((page) => page.items); + const tenantPages = React.useMemo( + () => query.data?.pages ?? [], + [query.data?.pages], + ); + const rawTenants = React.useMemo( + () => tenantPages.flatMap((page) => page.items), + [tenantPages], + ); const tenantTotal = tenantPages[0]?.total ?? 0; const hanmacFamilyTenantId = React.useMemo(() => { const envTenantId = import.meta.env.VITE_HANMAC_FAMILY_TENANT_ID; @@ -492,6 +511,18 @@ function TenantListPage() { } return filterNonHanmacFamilyTenants(rawTenants, hanmacFamilyTenantId); }, [hanmacFamilyTenantId, profile, profileRole, rawTenants]); + const scopedTenants = React.useMemo( + () => filterTenantsByScope(allTenants, scopeTenantId), + [allTenants, scopeTenantId], + ); + const selectedScopeTenant = React.useMemo( + () => allTenants.find((tenant) => tenant.id === scopeTenantId), + [allTenants, scopeTenantId], + ); + const scopePickerUrl = buildAuthenticatedOrgChartTenantPickerUrl( + import.meta.env.ORGFRONT_URL, + hanmacFamilyTenantId ? { tenantId: hanmacFamilyTenantId } : {}, + ); const importParentOptionGroups = buildTenantImportParentOptionGroups(allTenants); @@ -511,10 +542,37 @@ function TenantListPage() { }; const deletableTenants = React.useMemo( - () => allTenants.filter((tenant) => !isSeedTenant(tenant)), - [allTenants], + () => scopedTenants.filter((tenant) => !isSeedTenant(tenant)), + [scopedTenants], ); + React.useEffect(() => { + const selectableIds = new Set(deletableTenants.map((tenant) => tenant.id)); + setSelectedIds((prev) => { + const next = prev.filter((id) => selectableIds.has(id)); + if (next.length === prev.length) { + return prev; + } + return next; + }); + }, [deletableTenants]); + + React.useEffect(() => { + if (!scopePickerOpen) return; + + const onMessage = (event: MessageEvent) => { + const selection = parseOrgChartTenantSelection(event.data); + if (!selection) return; + if (!allTenants.some((tenant) => tenant.id === selection.id)) return; + + setScopeTenantId(selection.id); + setScopePickerOpen(false); + }; + + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, [allTenants, scopePickerOpen]); + const handleSelectAll = (checked: boolean) => { if (checked) { setSelectedIds(deletableTenants.map((t) => t.id)); @@ -527,11 +585,15 @@ function TenantListPage() { if (isSeedTenant(tenant)) { return; } - if (checked) { - setSelectedIds((prev) => [...prev, tenant.id]); - } else { - setSelectedIds((prev) => prev.filter((i) => i !== tenant.id)); - } + setSelectedIds((prev) => + resolveTenantSelectionIds({ + currentIds: prev, + tenant, + checked, + tenants: allTenants, + deletableTenants, + }), + ); }; const handleDeleteBulk = () => { @@ -701,13 +763,67 @@ function TenantListPage() { setSearch(e.target.value)} />
+
+ + +
+ + {scopeTenantId && ( + + )} @@ -846,10 +962,34 @@ function TenantListPage() { sortConfig={sortConfig} requestSort={requestSort} getSortIcon={getSortIcon} + viewMode={viewMode} + scopeTenantId={scopeTenantId} /> + + + + + {t("ui.admin.tenants.scope.pick", "상위 범위 선택")} + + + {t( + "msg.admin.tenants.scope.description", + "orgfront 조직 선택기에서 상위 테넌트를 선택하면 해당 하위 테넌트만 표시합니다.", + )} + + +