diff --git a/.playwright-mcp/page-2026-05-11T09-39-57-342Z.yml b/.playwright-mcp/page-2026-05-11T09-39-57-342Z.yml
new file mode 100644
index 00000000..fda3359b
--- /dev/null
+++ b/.playwright-mcp/page-2026-05-11T09-39-57-342Z.yml
@@ -0,0 +1,23 @@
+- generic [ref=e4]:
+ - generic [ref=e5]:
+ - img [ref=e7]
+ - generic [ref=e9]:
+ - heading "Baron SSO" [level=1] [ref=e10]
+ - paragraph [ref=e11]: Developer Control Plane
+ - generic [ref=e12]:
+ - generic [ref=e13]:
+ - heading "개발자 포털 로그인" [level=3] [ref=e14]:
+ - img [ref=e15]
+ - text: 개발자 포털 로그인
+ - paragraph [ref=e18]: Baron 통합 인증(SSO)을 통해 개발자 포털에 접속합니다.
+ - generic [ref=e19]:
+ - button "SSO 계정으로 로그인" [ref=e20] [cursor=pointer]:
+ - img [ref=e21]
+ - text: SSO 계정으로 로그인
+ - img [ref=e23]
+ - paragraph [ref=e27]:
+ - text: 개발자 포털 세션은 브라우저 정책에 따라 유지됩니다.
+ - text: 민감한 작업 시 재인증을 요구할 수 있습니다.
+ - paragraph [ref=e32]:
+ - text: 인증 정보가 없거나 로그인이 되지 않는 경우
+ - text: 시스템 관리자에게 문의하세요.
\ No newline at end of file
diff --git a/adminfront/gpdtdc_org.csv b/adminfront/gpdtdc_org.csv
new file mode 100644
index 00000000..e17988ad
--- /dev/null
+++ b/adminfront/gpdtdc_org.csv
@@ -0,0 +1,50 @@
+"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","마스터에게 메시지방 기능 권한 부여","조직 관련 알림 보내기","조직 공개","외부 도메인 메일 수신 차단","보내는 주소로 사용 가능한 구성원","메일을 보낼 수 있는 구성원","상위 조직"
+"총괄기획실","0","","","","gpd@baroncs.co.kr","Y","N","Y","Y","","",""
+"인재성장","2","","","","hr@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
+"전산관리TF","4","한치영(cyhan@samaneng.com)","","","it@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
+"기술기획","8","김원기(ba.56669@baroncs.co.kr)","","","tech-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
+"경영기획","0","","","","t_266py@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
+"ERP기획","0","","","","t_136ud@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
+"디자인기획","0","","","","t_618gm@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
+"협업증진","0","","","","t_752rp@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
+"솔루션통합","0","","","","t_683tq@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(gpd@baroncs.co.kr)"
+"네이버웍스관리용","2","슈퍼관리자(su-@samaneng.com)","","","su3@baroncs.co.kr","N","N","N","Y","","",""
+"기술개발센터","0","","","","t_536fc@baroncs.co.kr","Y","N","Y","Y","","",""
+"일반구조물 div","0","","","","t_568cz@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
+"DfMA","0","","","","t_538ub@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(t_568cz@baroncs.co.kr)"
+"일반구조물","0","","","","t_601cu@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(t_568cz@baroncs.co.kr)"
+"구조물계획","0","","","","t_388gh@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(t_568cz@baroncs.co.kr)"
+"하부구조","0","","","","t_131xd@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(t_568cz@baroncs.co.kr)"
+"CM기획","0","","","","t_349dy@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(t_568cz@baroncs.co.kr)"
+"터널","0","","","","t_068jk@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(t_568cz@baroncs.co.kr)"
+"CC","0","","","","t_116me@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
+"공정관리","0","","","","t_628of@baroncs.co.kr","Y","N","Y","Y","","","CC(t_116me@baroncs.co.kr)"
+"단가산출","0","","","","t_002sq@baroncs.co.kr","Y","N","Y","Y","","","CC(t_116me@baroncs.co.kr)"
+"상하수도","0","","","","t_323pd@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
+"천지인","0","","","","t_859sx@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
+"천지인셀","0","","","","t_827ax@baroncs.co.kr","Y","N","Y","Y","","","천지인(t_859sx@baroncs.co.kr)"
+"용지도셀","0","","","","t_896yy@baroncs.co.kr","Y","N","Y","Y","","","천지인(t_859sx@baroncs.co.kr)"
+"단지설계 개발","0","","","","t_602uo@baroncs.co.kr","Y","N","Y","Y","","","천지인(t_859sx@baroncs.co.kr)"
+"인프라솔루션 개발","0","","","","t_566mk@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
+"비탈면/구조물","0","","","","t_726dh@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(t_566mk@baroncs.co.kr)"
+"Way Draw","0","","","","t_504jn@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(t_566mk@baroncs.co.kr)"
+"Primal 평면","0","","","","t_284vk@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(t_566mk@baroncs.co.kr)"
+"Watch BIM","0","","","","t_170el@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(t_566mk@baroncs.co.kr)"
+"구조물S/W","0","","","","t_019ge@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
+"Strana","0","","","","t_595rj@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
+"그래픽스","0","","","","t_934zk@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
+"Modeler","0","","","","t_932vs@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(t_934zk@baroncs.co.kr)"
+"HmEG","0","","","","t_614xb@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(t_934zk@baroncs.co.kr)"
+"EG-BIM Draw","0","","","","t_563cv@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(t_934zk@baroncs.co.kr)"
+"Abut&시공통합관제","0","","","","t_762fs@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(t_934zk@baroncs.co.kr)"
+"웹솔루션","0","","","","t_797wn@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
+"솔루션개발","0","","","","t_923oe@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(t_797wn@baroncs.co.kr)"
+"ERP","0","","","","t_481sa@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(t_797wn@baroncs.co.kr)"
+"웹디자인","0","","","","t_587ef@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(t_797wn@baroncs.co.kr)"
+"GSIM개발","0","","","","t_929kx@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
+"bCMf","0","","","","t_833jy@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(t_929kx@baroncs.co.kr)"
+"GSIM","0","","","","t_263tv@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(t_929kx@baroncs.co.kr)"
+"PM","0","","","","t_335nb@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(t_929kx@baroncs.co.kr)"
+"수자원","0","","","","t_233cs@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
+"스마트건설","0","","","","t_842me@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
+"시공BIM","0","","","","t_942jh@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(t_536fc@baroncs.co.kr)"
diff --git a/adminfront/gpdtdc_org_slugged.csv b/adminfront/gpdtdc_org_slugged.csv
new file mode 100644
index 00000000..9534a9b2
--- /dev/null
+++ b/adminfront/gpdtdc_org_slugged.csv
@@ -0,0 +1,50 @@
+"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","마스터에게 메시지방 기능 권한 부여","조직 관련 알림 보내기","조직 공개","외부 도메인 메일 수신 차단","보내는 주소로 사용 가능한 구성원","메일을 보낼 수 있는 구성원","상위 조직"
+"총괄기획실","0","","","","general-planning@baroncs.co.kr","Y","N","Y","Y","","",""
+"인재성장","2","","","","talent-growth@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(general-planning@baroncs.co.kr)"
+"전산관리TF","4","","","","it-admin-tf@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(general-planning@baroncs.co.kr)"
+"기술기획","8","","","","tech-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(general-planning@baroncs.co.kr)"
+"경영기획","0","","","","management-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(general-planning@baroncs.co.kr)"
+"ERP기획","0","","","","erp-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(general-planning@baroncs.co.kr)"
+"디자인기획","0","","","","design-planning@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(general-planning@baroncs.co.kr)"
+"협업증진","0","","","","collaboration@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(general-planning@baroncs.co.kr)"
+"솔루션통합","0","","","","solution-integration@baroncs.co.kr","Y","N","Y","Y","","","총괄기획실(general-planning@baroncs.co.kr)"
+"네이버웍스관리용","2","","","","nw-admin-gpd@baroncs.co.kr","N","N","N","Y","","",""
+"기술개발센터","0","","","","rnd-center@baroncs.co.kr","Y","N","Y","Y","","",""
+"일반구조물 div","0","","","","structural-division@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
+"DfMA","0","","","","dfma@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
+"일반구조물","0","","","","structural-design@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
+"구조물계획","0","","","","structure-planning@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
+"하부구조","0","","","","substructure@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
+"CM기획","0","","","","cm-planning@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
+"터널","0","","","","tunnel@baroncs.co.kr","Y","N","Y","Y","","","일반구조물 div(structural-division@baroncs.co.kr)"
+"CC","0","","","","cost-control@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
+"공정관리","0","","","","schedule-control@baroncs.co.kr","Y","N","Y","Y","","","CC(cost-control@baroncs.co.kr)"
+"단가산출","0","","","","cost-estimate@baroncs.co.kr","Y","N","Y","Y","","","CC(cost-control@baroncs.co.kr)"
+"상하수도","0","","","","water-sewer@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
+"천지인","0","","","","cheonjijin@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
+"천지인셀","0","","","","cheonjijin-cell@baroncs.co.kr","Y","N","Y","Y","","","천지인(cheonjijin@baroncs.co.kr)"
+"용지도셀","0","","","","land-map-cell@baroncs.co.kr","Y","N","Y","Y","","","천지인(cheonjijin@baroncs.co.kr)"
+"단지설계 개발","0","","","","site-design-dev@baroncs.co.kr","Y","N","Y","Y","","","천지인(cheonjijin@baroncs.co.kr)"
+"인프라솔루션 개발","0","","","","infra-solutions@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
+"비탈면/구조물","0","","","","slope-structures@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(infra-solutions@baroncs.co.kr)"
+"Way Draw","0","","","","way-draw@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(infra-solutions@baroncs.co.kr)"
+"Primal 평면","0","","","","primal-plan@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(infra-solutions@baroncs.co.kr)"
+"Watch BIM","0","","","","watch-bim@baroncs.co.kr","Y","N","Y","Y","","","인프라솔루션 개발(infra-solutions@baroncs.co.kr)"
+"구조물S/W","0","","","","structural-software@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
+"Strana","0","","","","strana@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
+"그래픽스","0","","","","graphics@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
+"Modeler","0","","","","modeler@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(graphics@baroncs.co.kr)"
+"HmEG","0","","","","hmeg@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(graphics@baroncs.co.kr)"
+"EG-BIM Draw","0","","","","eg-bim-draw@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(graphics@baroncs.co.kr)"
+"Abut&시공통합관제","0","","","","abut-control@baroncs.co.kr","Y","N","Y","Y","","","그래픽스(graphics@baroncs.co.kr)"
+"웹솔루션","0","","","","web-solutions@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
+"솔루션개발","0","","","","solution-dev@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(web-solutions@baroncs.co.kr)"
+"ERP","0","","","","erp@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(web-solutions@baroncs.co.kr)"
+"웹디자인","0","","","","web-design@baroncs.co.kr","Y","N","Y","Y","","","웹솔루션(web-solutions@baroncs.co.kr)"
+"GSIM개발","0","","","","gsim-dev@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
+"bCMf","0","","","","bcmf@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(gsim-dev@baroncs.co.kr)"
+"GSIM","0","","","","gsim@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(gsim-dev@baroncs.co.kr)"
+"PM","0","","","","project-management@baroncs.co.kr","Y","N","Y","Y","","","GSIM개발(gsim-dev@baroncs.co.kr)"
+"수자원","0","","","","water-resources@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
+"스마트건설","0","","","","smart-construction@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
+"시공BIM","0","","","","construction-bim@baroncs.co.kr","Y","N","Y","Y","","","기술개발센터(rnd-center@baroncs.co.kr)"
diff --git a/adminfront/saman_org.csv b/adminfront/saman_org.csv
new file mode 100644
index 00000000..779fcce6
--- /dev/null
+++ b/adminfront/saman_org.csv
@@ -0,0 +1,44 @@
+"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","마스터에게 메시지방 기능 권한 부여","조직 관련 알림 보내기","조직 공개","외부 도메인 메일 수신 차단","보내는 주소로 사용 가능한 구성원","메일을 보낼 수 있는 구성원","상위 조직"
+"기술개발센터","1","","","","tdc@samaneng.com","N","N","N","Y","","",""
+"경영전략본부","0","","","","business-strategy@samaneng.com","Y","N","Y","Y","","",""
+"기획부","1","변역근(ykbyun@samaneng.com)","","","planning@samaneng.com","Y","N","Y","Y","","","경영전략본부(business-strategy@samaneng.com)"
+"업무팀","0","","","","t_226wn@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
+"PQ팀","0","","","","t_978bl@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
+"재무회계팀","0","","","","t_186qz@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
+"대외협력팀","0","","","","t_466et@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
+"인사총무부","0","","","","t_784bn@samaneng.com","Y","N","Y","Y","","","경영전략본부(business-strategy@samaneng.com)"
+"네이버웍스관리용","1","슈퍼관리자(su-@samaneng.com)","","","su1@samaneng.com","N","N","N","Y","","",""
+"자산경영실","0","","","","t_563wl@samaneng.com","Y","N","Y","Y","","",""
+"안전품질관리실","0","","","","t_793co@samaneng.com","Y","N","Y","Y","","",""
+"사업개발실","0","","","","t_468yk@samaneng.com","Y","N","Y","Y","","",""
+"CM본부","0","","","","t_838vr@samaneng.com","Y","N","Y","Y","","",""
+"CM사업부","0","","","","t_205ud@samaneng.com","Y","N","Y","Y","","","CM본부(t_838vr@samaneng.com)"
+"호남지역총괄본부","0","","","","t_143ep@samaneng.com","Y","N","Y","Y","","","CM사업부(t_205ud@samaneng.com)"
+"플랜트본부","0","","","","t_009bl@samaneng.com","Y","N","Y","Y","","",""
+"플랜트1부","0","","","","t_595bv@samaneng.com","Y","N","Y","Y","","","플랜트본부(t_009bl@samaneng.com)"
+"플랜트2부","0","","","","t_677ei@samaneng.com","Y","N","Y","Y","","","플랜트본부(t_009bl@samaneng.com)"
+"항만부","0","","","","t_446wi@samaneng.com","Y","N","Y","Y","","","플랜트본부(t_009bl@samaneng.com)"
+"국토개발본부","0","","","","t_405cl@samaneng.com","Y","N","Y","Y","","",""
+"도시계획부","0","","","","t_403or@samaneng.com","Y","N","Y","Y","","","국토개발본부(t_405cl@samaneng.com)"
+"도시개발부","0","","","","t_733kg@samaneng.com","Y","N","Y","Y","","","국토개발본부(t_405cl@samaneng.com)"
+"조경레저부","0","","","","t_931rr@samaneng.com","Y","N","Y","Y","","","국토개발본부(t_405cl@samaneng.com)"
+"도로본부","0","","","","t_402qv@samaneng.com","Y","N","Y","Y","","",""
+"도로부","0","","","","t_560mk@samaneng.com","Y","N","Y","Y","","","도로본부(t_402qv@samaneng.com)"
+"지반터널부","0","","","","t_918nd@samaneng.com","Y","N","Y","Y","","","도로본부(t_402qv@samaneng.com)"
+"교통계획부","0","","","","t_879qs@samaneng.com","Y","N","Y","Y","","","도로본부(t_402qv@samaneng.com)"
+"구조부","0","","","","t_772wv@samaneng.com","Y","N","Y","Y","","","도로본부(t_402qv@samaneng.com)"
+"안전진단팀","0","","","","t_875hr@samaneng.com","Y","N","Y","Y","","","구조부(t_772wv@samaneng.com)"
+"철도본부","0","","","","t_772tf@samaneng.com","Y","N","Y","Y","","",""
+"철도1부","0","","","","t_879yn@samaneng.com","Y","N","Y","Y","","","철도본부(t_772tf@samaneng.com)"
+"철도2부","0","","","","t_025sm@samaneng.com","Y","N","Y","Y","","","철도본부(t_772tf@samaneng.com)"
+"환경평가부","0","","","","t_974cd@samaneng.com","Y","N","Y","Y","","","철도본부(t_772tf@samaneng.com)"
+"물환경본부","0","","","","t_857zu@samaneng.com","Y","N","Y","Y","","",""
+"물환경1부","0","","","","t_881eq@samaneng.com","Y","N","Y","Y","","","물환경본부(t_857zu@samaneng.com)"
+"물환경2부","0","","","","t_308je@samaneng.com","Y","N","Y","Y","","","물환경본부(t_857zu@samaneng.com)"
+"물환경3부","0","","","","t_187qk@samaneng.com","Y","N","Y","Y","","","물환경본부(t_857zu@samaneng.com)"
+"수자원본부","0","","","","t_415tw@samaneng.com","Y","N","Y","Y","","",""
+"수자원1부","0","","","","t_237op@samaneng.com","Y","N","Y","Y","","","수자원본부(t_415tw@samaneng.com)"
+"수자원2부","0","","","","t_989os@samaneng.com","Y","N","Y","Y","","","수자원본부(t_415tw@samaneng.com)"
+"수력부","0","","","","t_175zq@samaneng.com","Y","N","Y","Y","","","수자원본부(t_415tw@samaneng.com)"
+"해외사업본부","0","","","","t_436jd@samaneng.com","Y","N","Y","Y","","",""
+"해외사업부","0","","","","t_099um@samaneng.com","Y","N","Y","Y","","","해외사업본부(t_436jd@samaneng.com)"
diff --git a/adminfront/saman_org_slugged.csv b/adminfront/saman_org_slugged.csv
new file mode 100644
index 00000000..84ccd699
--- /dev/null
+++ b/adminfront/saman_org_slugged.csv
@@ -0,0 +1,44 @@
+"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","마스터에게 메시지방 기능 권한 부여","조직 관련 알림 보내기","조직 공개","외부 도메인 메일 수신 차단","보내는 주소로 사용 가능한 구성원","메일을 보낼 수 있는 구성원","상위 조직"
+"기술개발센터","1","","","","tech-dev-center@samaneng.com","N","N","N","Y","","",""
+"경영전략본부","0","","","","business-strategy@samaneng.com","Y","N","Y","Y","","",""
+"기획부","1","","","","planning@samaneng.com","Y","N","Y","Y","","","경영전략본부(business-strategy@samaneng.com)"
+"업무팀","0","","","","operations@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
+"PQ팀","0","","","","pq-team@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
+"재무회계팀","0","","","","finance@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
+"대외협력팀","0","","","","external-relations@samaneng.com","Y","N","Y","Y","","","기획부(planning@samaneng.com)"
+"인사총무부","0","","","","hr-admin@samaneng.com","Y","N","Y","Y","","","경영전략본부(business-strategy@samaneng.com)"
+"네이버웍스관리용","1","","","","nw-admin-saman@samaneng.com","N","N","N","Y","","",""
+"자산경영실","0","","","","asset-management@samaneng.com","Y","N","Y","Y","","",""
+"안전품질관리실","0","","","","safety-quality@samaneng.com","Y","N","Y","Y","","",""
+"사업개발실","0","","","","business-development@samaneng.com","Y","N","Y","Y","","",""
+"CM본부","0","","","","cm-headquarters@samaneng.com","Y","N","Y","Y","","",""
+"CM사업부","0","","","","cm-division@samaneng.com","Y","N","Y","Y","","","CM본부(cm-headquarters@samaneng.com)"
+"호남지역총괄본부","0","","","","honam-headquarters@samaneng.com","Y","N","Y","Y","","","CM사업부(cm-division@samaneng.com)"
+"플랜트본부","0","","","","plant-headquarters@samaneng.com","Y","N","Y","Y","","",""
+"플랜트1부","0","","","","plant-1@samaneng.com","Y","N","Y","Y","","","플랜트본부(plant-headquarters@samaneng.com)"
+"플랜트2부","0","","","","plant-2@samaneng.com","Y","N","Y","Y","","","플랜트본부(plant-headquarters@samaneng.com)"
+"항만부","0","","","","harbor@samaneng.com","Y","N","Y","Y","","","플랜트본부(plant-headquarters@samaneng.com)"
+"국토개발본부","0","","","","land-development@samaneng.com","Y","N","Y","Y","","",""
+"도시계획부","0","","","","urban-planning@samaneng.com","Y","N","Y","Y","","","국토개발본부(land-development@samaneng.com)"
+"도시개발부","0","","","","urban-development@samaneng.com","Y","N","Y","Y","","","국토개발본부(land-development@samaneng.com)"
+"조경레저부","0","","","","landscape-leisure@samaneng.com","Y","N","Y","Y","","","국토개발본부(land-development@samaneng.com)"
+"도로본부","0","","","","road-headquarters@samaneng.com","Y","N","Y","Y","","",""
+"도로부","0","","","","road@samaneng.com","Y","N","Y","Y","","","도로본부(road-headquarters@samaneng.com)"
+"지반터널부","0","","","","geotech-tunnel@samaneng.com","Y","N","Y","Y","","","도로본부(road-headquarters@samaneng.com)"
+"교통계획부","0","","","","transport-planning@samaneng.com","Y","N","Y","Y","","","도로본부(road-headquarters@samaneng.com)"
+"구조부","0","","","","structures@samaneng.com","Y","N","Y","Y","","","도로본부(road-headquarters@samaneng.com)"
+"안전진단팀","0","","","","safety-inspection@samaneng.com","Y","N","Y","Y","","","구조부(structures@samaneng.com)"
+"철도본부","0","","","","railway-headquarters@samaneng.com","Y","N","Y","Y","","",""
+"철도1부","0","","","","railway-1@samaneng.com","Y","N","Y","Y","","","철도본부(railway-headquarters@samaneng.com)"
+"철도2부","0","","","","railway-2@samaneng.com","Y","N","Y","Y","","","철도본부(railway-headquarters@samaneng.com)"
+"환경평가부","0","","","","environment-assessment@samaneng.com","Y","N","Y","Y","","","철도본부(railway-headquarters@samaneng.com)"
+"물환경본부","0","","","","water-environment-hq@samaneng.com","Y","N","Y","Y","","",""
+"물환경1부","0","","","","water-environment-1@samaneng.com","Y","N","Y","Y","","","물환경본부(water-environment-hq@samaneng.com)"
+"물환경2부","0","","","","water-environment-2@samaneng.com","Y","N","Y","Y","","","물환경본부(water-environment-hq@samaneng.com)"
+"물환경3부","0","","","","water-environment-3@samaneng.com","Y","N","Y","Y","","","물환경본부(water-environment-hq@samaneng.com)"
+"수자원본부","0","","","","water-resources-hq@samaneng.com","Y","N","Y","Y","","",""
+"수자원1부","0","","","","water-resources-1@samaneng.com","Y","N","Y","Y","","","수자원본부(water-resources-hq@samaneng.com)"
+"수자원2부","0","","","","water-resources-2@samaneng.com","Y","N","Y","Y","","","수자원본부(water-resources-hq@samaneng.com)"
+"수력부","0","","","","hydropower@samaneng.com","Y","N","Y","Y","","","수자원본부(water-resources-hq@samaneng.com)"
+"해외사업본부","0","","","","overseas-headquarters@samaneng.com","Y","N","Y","Y","","",""
+"해외사업부","0","","","","overseas-business@samaneng.com","Y","N","Y","Y","","","해외사업본부(overseas-headquarters@samaneng.com)"
diff --git a/adminfront/src/features/tenants/components/ParentTenantSelector.test.ts b/adminfront/src/features/tenants/components/ParentTenantSelector.test.ts
new file mode 100644
index 00000000..bd99f460
--- /dev/null
+++ b/adminfront/src/features/tenants/components/ParentTenantSelector.test.ts
@@ -0,0 +1,57 @@
+import { describe, expect, it } from "vitest";
+import type { TenantSummary } from "../../../lib/adminApi";
+import { filterParentTenants } from "./ParentTenantSelector";
+
+const tenants: TenantSummary[] = [
+ {
+ id: "company-1",
+ type: "COMPANY",
+ name: "Saman Engineering",
+ slug: "saman",
+ description: "",
+ status: "active",
+ memberCount: 0,
+ createdAt: "",
+ updatedAt: "",
+ },
+ {
+ id: "group-1",
+ type: "COMPANY_GROUP",
+ name: "Hanmac Family",
+ slug: "hanmac-family",
+ description: "",
+ status: "active",
+ memberCount: 0,
+ createdAt: "",
+ updatedAt: "",
+ },
+ {
+ id: "org-1",
+ type: "ORGANIZATION",
+ name: "기획부",
+ slug: "planning",
+ description: "",
+ status: "active",
+ memberCount: 0,
+ createdAt: "",
+ updatedAt: "",
+ },
+];
+
+describe("filterParentTenants", () => {
+ it("searches parent candidates by name and slug", () => {
+ expect(
+ filterParentTenants(tenants, "saman", false).map((t) => t.id),
+ ).toEqual(["company-1"]);
+ expect(
+ filterParentTenants(tenants, "family", false).map((t) => t.id),
+ ).toEqual(["group-1"]);
+ });
+
+ it("can limit parent candidates to company and company group tenants", () => {
+ expect(filterParentTenants(tenants, "", true).map((t) => t.id)).toEqual([
+ "company-1",
+ "group-1",
+ ]);
+ });
+});
diff --git a/adminfront/src/features/tenants/components/ParentTenantSelector.tsx b/adminfront/src/features/tenants/components/ParentTenantSelector.tsx
new file mode 100644
index 00000000..0e352553
--- /dev/null
+++ b/adminfront/src/features/tenants/components/ParentTenantSelector.tsx
@@ -0,0 +1,114 @@
+import { Search } from "lucide-react";
+import { useMemo, useState } from "react";
+import { Input } from "../../../components/ui/input";
+import { Label } from "../../../components/ui/label";
+import type { TenantSummary } from "../../../lib/adminApi";
+import { t } from "../../../lib/i18n";
+
+type ParentTenantSelectorProps = {
+ id: string;
+ label: string;
+ value: string;
+ onChange: (value: string) => void;
+ tenants: TenantSummary[];
+ noneLabel: string;
+ helpText?: string;
+ excludeTenantId?: string;
+};
+
+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,
+ value,
+ onChange,
+ tenants,
+ noneLabel,
+ helpText,
+ excludeTenantId,
+}: ParentTenantSelectorProps) {
+ const [search, setSearch] = useState("");
+ const [companyOnly, setCompanyOnly] = useState(false);
+ const filteredTenants = useMemo(
+ () => filterParentTenants(tenants, search, companyOnly, excludeTenantId),
+ [tenants, search, companyOnly, excludeTenantId],
+ );
+ const selectedTenant = tenants.find((tenant) => tenant.id === value);
+ const optionTenants =
+ selectedTenant &&
+ !filteredTenants.some((tenant) => tenant.id === selectedTenant.id)
+ ? [selectedTenant, ...filteredTenants]
+ : filteredTenants;
+
+ return (
+
+
+
+
+
+
+
+ {helpText && (
+
{helpText}
+ )}
+
+ );
+}
diff --git a/adminfront/src/features/tenants/routes/TenantCreatePage.tsx b/adminfront/src/features/tenants/routes/TenantCreatePage.tsx
index b95fc44a..a9c53987 100644
--- a/adminfront/src/features/tenants/routes/TenantCreatePage.tsx
+++ b/adminfront/src/features/tenants/routes/TenantCreatePage.tsx
@@ -17,6 +17,7 @@ import { Textarea } from "../../../components/ui/textarea";
import { createTenant, fetchTenants } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { DomainTagInput } from "../components/DomainTagInput";
+import { ParentTenantSelector } from "../components/ParentTenantSelector";
import {
type ServerDomainConflict,
formatDomainConflictMessage,
@@ -171,25 +172,17 @@ function TenantCreatePage() {
-
-
-
-
+
)}
+
+
+
-
-
-
-
- {t(
- "ui.admin.tenants.profile.form.parent_help",
- "하위 조직을 종속시킬 경우 상위 테넌트를 선택해주세요.",
- )}
-
-
+
+ {canEditOrgConfig && (
+
+
+
+
+
+
+
+
+
+
+ )}
{errorMsg && (
{errorMsg}
diff --git a/adminfront/src/features/tenants/utils/orgConfig.test.ts b/adminfront/src/features/tenants/utils/orgConfig.test.ts
new file mode 100644
index 00000000..b6a89c0e
--- /dev/null
+++ b/adminfront/src/features/tenants/utils/orgConfig.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from "vitest";
+import type { TenantSummary } from "../../../lib/adminApi";
+import {
+ mergeTenantOrgConfig,
+ readTenantOrgConfig,
+ shouldAllowHanmacOrgConfig,
+} from "./orgConfig";
+
+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("tenant org config", () => {
+ it("allows org config only for hanmac-family descendants", () => {
+ const family = tenant(
+ "family",
+ "COMPANY_GROUP",
+ "한맥가족",
+ "hanmac-family",
+ );
+ const saman = tenant("saman", "COMPANY", "삼안", "saman", "family");
+ const team = tenant("team", "USER_GROUP", "기획팀", "planning", "saman");
+ const outsider = tenant("outsider", "COMPANY", "외부", "outsider");
+ const tenants = [family, saman, team, outsider];
+
+ expect(shouldAllowHanmacOrgConfig(team, tenants)).toBe(true);
+ expect(shouldAllowHanmacOrgConfig(family, tenants)).toBe(false);
+ expect(shouldAllowHanmacOrgConfig(outsider, tenants)).toBe(false);
+ });
+
+ it("reads and writes tenant visibility and org unit type", () => {
+ expect(
+ readTenantOrgConfig({ visibility: "private", orgUnitType: "팀" }),
+ ).toEqual({ orgUnitType: "팀", visibility: "private" });
+
+ expect(
+ mergeTenantOrgConfig(
+ { userSchema: [], visibility: "private", orgUnitType: "팀" },
+ { orgUnitType: "", visibility: "internal" },
+ ),
+ ).toEqual({ userSchema: [], visibility: "internal" });
+ });
+});
diff --git a/adminfront/src/features/tenants/utils/orgConfig.ts b/adminfront/src/features/tenants/utils/orgConfig.ts
new file mode 100644
index 00000000..f77ceec6
--- /dev/null
+++ b/adminfront/src/features/tenants/utils/orgConfig.ts
@@ -0,0 +1,92 @@
+import type { TenantSummary } from "../../../lib/adminApi";
+
+export const ORG_UNIT_TYPE_OPTIONS = [
+ "실",
+ "팀",
+ "디비전",
+ "셀",
+ "본부",
+ "지역본부",
+ "부",
+] as const;
+
+export const TENANT_VISIBILITY_OPTIONS = [
+ { label: "공개", value: "public" },
+ { label: "내부", value: "internal" },
+ { label: "비공개", value: "private" },
+] as const;
+
+export type TenantVisibility =
+ (typeof TENANT_VISIBILITY_OPTIONS)[number]["value"];
+
+export type TenantOrgConfig = {
+ orgUnitType: string;
+ visibility: TenantVisibility;
+};
+
+const ORG_UNIT_TYPE_SET = new Set
(ORG_UNIT_TYPE_OPTIONS);
+const TENANT_VISIBILITY_SET = new Set(
+ TENANT_VISIBILITY_OPTIONS.map((option) => option.value),
+);
+
+export function shouldAllowHanmacOrgConfig(
+ tenant: Pick,
+ tenants: Array>,
+) {
+ if (tenant.slug.toLowerCase() === "hanmac-family") return false;
+
+ const byId = new Map(tenants.map((item) => [item.id, item]));
+ let parentId = tenant.parentId;
+ const visited = new Set();
+
+ while (parentId) {
+ if (visited.has(parentId)) return false;
+ visited.add(parentId);
+ const parent = byId.get(parentId);
+ if (!parent) return false;
+ if (parent.slug.toLowerCase() === "hanmac-family") return true;
+ parentId = parent.parentId;
+ }
+
+ return false;
+}
+
+export function readTenantOrgConfig(
+ config: Record | undefined,
+): TenantOrgConfig {
+ const rawVisibility = String(config?.visibility ?? "public").toLowerCase();
+ const rawOrgUnitType = String(config?.orgUnitType ?? "");
+
+ return {
+ orgUnitType: ORG_UNIT_TYPE_SET.has(rawOrgUnitType) ? rawOrgUnitType : "",
+ visibility: TENANT_VISIBILITY_SET.has(rawVisibility)
+ ? (rawVisibility as TenantVisibility)
+ : "public",
+ };
+}
+
+export function mergeTenantOrgConfig(
+ config: Record | undefined,
+ next: TenantOrgConfig,
+) {
+ const { orgUnitType: _orgUnitType, ...rest } = config ?? {};
+ const merged = { ...rest };
+ merged.visibility = next.visibility;
+
+ if (next.orgUnitType) {
+ merged.orgUnitType = next.orgUnitType;
+ }
+
+ return merged;
+}
+
+export function removeTenantOrgConfig(
+ config: Record | undefined,
+) {
+ const {
+ orgUnitType: _orgUnitType,
+ visibility: _visibility,
+ ...rest
+ } = config ?? {};
+ return rest;
+}
diff --git a/adminfront/src/features/tenants/utils/tenantCsvImport.test.ts b/adminfront/src/features/tenants/utils/tenantCsvImport.test.ts
index 4cb3c3a4..9f470834 100644
--- a/adminfront/src/features/tenants/utils/tenantCsvImport.test.ts
+++ b/adminfront/src/features/tenants/utils/tenantCsvImport.test.ts
@@ -1,7 +1,9 @@
-import { describe, expect, it } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";
import type { TenantSummary } from "../../../lib/adminApi";
import {
+ buildTenantImportParentOptionGroups,
buildTenantImportPreview,
+ inferTenantImportRootParentSlug,
parseTenantCSV,
serializeTenantImportCSV,
} from "./tenantCsvImport";
@@ -31,9 +33,37 @@ const tenants: TenantSummary[] = [
createdAt: "",
updatedAt: "",
},
+ {
+ id: "tenant-3",
+ type: "COMPANY_GROUP",
+ name: "Hanmac Family",
+ slug: "hanmac-family",
+ description: "",
+ status: "active",
+ domains: [],
+ memberCount: 0,
+ createdAt: "",
+ updatedAt: "",
+ },
+ {
+ id: "tenant-4",
+ type: "ORGANIZATION",
+ name: "기획부",
+ slug: "planning",
+ description: "",
+ status: "active",
+ domains: [],
+ memberCount: 0,
+ createdAt: "",
+ updatedAt: "",
+ },
];
describe("tenantCsvImport", () => {
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
it("parses tenant CSV rows with the supported import columns", () => {
const rows = parseTenantCSV(
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com\n",
@@ -87,7 +117,7 @@ describe("tenantCsvImport", () => {
});
expect(csv).toContain(
- "tenant-1,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com",
+ "tenant-1,Hanmac Tech,COMPANY,,,hanmac-tech,Memo,hanmac-tech.example.com",
);
});
@@ -110,7 +140,7 @@ describe("tenantCsvImport", () => {
});
expect(csv).toContain(
- "staging-new-tenant-id,Hanmac Technology,COMPANY,,hanmac-imported,Memo,hanmac.example.com",
+ "staging-new-tenant-id,Hanmac Technology,COMPANY,,,hanmac-imported,Memo,hanmac.example.com",
);
expect(csv).not.toContain("local-tenant-id");
});
@@ -138,10 +168,10 @@ describe("tenantCsvImport", () => {
});
expect(csv).toContain(
- "staging-parent-id,Parent Tenant,COMPANY,,parent-staging,,",
+ "staging-parent-id,Parent Tenant,COMPANY,,,parent-staging,,",
);
expect(csv).toContain(
- "staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,child-staging,,",
+ "staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,,child-staging,,",
);
expect(csv).not.toContain("local-parent-id");
expect(csv).not.toContain("local-child-id");
@@ -171,7 +201,157 @@ describe("tenantCsvImport", () => {
expect(rows[1].parentTenantSlug).toBe("parent-slug");
expect(csv).toContain(
- "staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,child-slug,,",
+ "staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,parent-slug,child-slug,,",
);
});
+
+ it("keeps parent_tenant_slug in the serialized CSV as a fallback for hierarchy import", () => {
+ const rows = parseTenantCSV(
+ [
+ "name,type,parent_tenant_slug,slug,memo,email_domain",
+ "Parent Tenant,COMPANY,,parent-slug,,",
+ "Child Tenant,ORGANIZATION,parent-slug,child-slug,,",
+ ].join("\n"),
+ );
+ const preview = buildTenantImportPreview(rows, tenants);
+ const csv = serializeTenantImportCSV(preview, {
+ 2: {
+ mode: "create",
+ tenantId: "staging-parent-id",
+ slug: "parent-slug",
+ },
+ 3: {
+ mode: "create",
+ tenantId: "staging-child-id",
+ slug: "child-slug",
+ },
+ });
+
+ expect(csv.split("\n")[0]).toBe(
+ "tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain",
+ );
+ expect(csv).toContain(
+ "staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,parent-slug,child-slug,,",
+ );
+ });
+
+ it("parses Naver Works organization CSV columns into tenant import rows", () => {
+ const rows = parseTenantCSV(
+ [
+ '"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","상위 조직"',
+ '"기술개발센터","1","","","","tdc@samaneng.com",""',
+ '"기획부","1","","","","planning@samaneng.com","기술개발센터(tdc@samaneng.com)"',
+ '"업무팀","0","","","","t_226wn@samaneng.com","기획부(planning@samaneng.com)"',
+ ].join("\n"),
+ { rootParentSlug: "saman" },
+ );
+
+ expect(rows).toMatchObject([
+ {
+ name: "기술개발센터",
+ type: "ORGANIZATION",
+ slug: "tdc",
+ parentTenantSlug: "saman",
+ },
+ {
+ name: "기획부",
+ type: "ORGANIZATION",
+ slug: "planning",
+ parentTenantSlug: "tdc",
+ },
+ {
+ name: "업무팀",
+ type: "ORGANIZATION",
+ slug: "t-226wn",
+ parentTenantSlug: "planning",
+ },
+ ]);
+ });
+
+ it("infers root parent slug from an organization CSV file prefix that matches an existing slug", () => {
+ expect(inferTenantImportRootParentSlug("saman_org.csv", tenants)).toBe(
+ "saman",
+ );
+ expect(
+ inferTenantImportRootParentSlug("/tmp/hanmac-family_org.csv", tenants),
+ ).toBe("hanmac-family");
+ expect(
+ inferTenantImportRootParentSlug("saman_org_slugged.csv", tenants),
+ ).toBe("saman");
+ expect(inferTenantImportRootParentSlug("unknown_org.csv", tenants)).toBe(
+ "",
+ );
+ expect(inferTenantImportRootParentSlug("tenant-import.csv", tenants)).toBe(
+ "",
+ );
+ });
+
+ it("groups existing parent candidates by company group, company, and organization", () => {
+ const groups = buildTenantImportParentOptionGroups(tenants);
+
+ expect(groups.map((group) => group.type)).toEqual([
+ "COMPANY_GROUP",
+ "COMPANY",
+ "ORGANIZATION",
+ ]);
+ expect(
+ groups.map((group) => group.tenants.map((tenant) => tenant.id)),
+ ).toEqual([["tenant-3"], ["tenant-1", "tenant-2"], ["tenant-4"]]);
+ });
+
+ it("keeps generated ids stable and follows edited parent slugs for child rows", () => {
+ const randomUUID = vi
+ .fn()
+ .mockReturnValueOnce("parent-generated-id")
+ .mockReturnValueOnce("child-generated-id");
+ vi.stubGlobal("crypto", { randomUUID });
+
+ const rows = parseTenantCSV(
+ [
+ "name,type,parent_tenant_slug,slug,memo,email_domain",
+ "기술개발센터,ORGANIZATION,saman,t-536fc,,",
+ "일반구조물 div,ORGANIZATION,t-536fc,t-568cz,,",
+ ].join("\n"),
+ );
+ const preview = buildTenantImportPreview(rows, tenants);
+ const csv = serializeTenantImportCSV(preview, {
+ 2: { mode: "create", slug: "tech-center" },
+ 3: { mode: "create", slug: "structure-div" },
+ });
+
+ expect(csv).toContain(
+ "parent-generated-id,기술개발센터,ORGANIZATION,,saman,tech-center,,",
+ );
+ expect(csv).toContain(
+ "child-generated-id,일반구조물 div,ORGANIZATION,parent-generated-id,tech-center,structure-div,,",
+ );
+ });
+
+ it("serializes explicit parent tenant selections from the import preview", () => {
+ const rows = parseTenantCSV(
+ [
+ "name,type,parent_tenant_slug,slug,memo,email_domain",
+ "기술개발센터,ORGANIZATION,saman,t-536fc,,",
+ "일반구조물 div,ORGANIZATION,t-536fc,t-568cz,,",
+ ].join("\n"),
+ );
+ const preview = buildTenantImportPreview(rows, tenants);
+ const csv = serializeTenantImportCSV(preview, {
+ 2: {
+ mode: "create",
+ slug: "tech-center",
+ parentTenantId: "tenant-2",
+ parentTenantSlug: "",
+ },
+ 3: {
+ mode: "create",
+ slug: "structure-div",
+ parentTenantSlug: "tech-center",
+ },
+ });
+
+ expect(csv).toContain("기술개발센터,ORGANIZATION,tenant-2,,tech-center,,");
+ expect(csv).toContain(",일반구조물 div,ORGANIZATION,");
+ expect(csv).toContain(",tech-center,structure-div,,");
+ });
});
diff --git a/adminfront/src/features/tenants/utils/tenantCsvImport.ts b/adminfront/src/features/tenants/utils/tenantCsvImport.ts
index 4f155b7c..b8fb64c7 100644
--- a/adminfront/src/features/tenants/utils/tenantCsvImport.ts
+++ b/adminfront/src/features/tenants/utils/tenantCsvImport.ts
@@ -12,6 +12,12 @@ export type TenantCSVRow = {
emailDomain: string;
};
+export type TenantCSVParseOptions = {
+ rootParentSlug?: string;
+};
+
+type TenantCSVSourceKey = keyof TenantCSVRow | "mailingList" | "parentOrg";
+
export type TenantImportCandidate = {
tenantId: string;
name: string;
@@ -28,6 +34,16 @@ export type TenantImportPreviewRow = {
conflicts: TenantImportConflict[];
};
+export type TenantImportParentOptionGroupType =
+ | "COMPANY_GROUP"
+ | "COMPANY"
+ | "ORGANIZATION";
+
+export type TenantImportParentOptionGroup = {
+ type: TenantImportParentOptionGroupType;
+ tenants: TenantSummary[];
+};
+
export type TenantImportConflict =
| "external_tenant_id"
| "slug_exists"
@@ -37,12 +53,15 @@ export type TenantImportResolution =
| {
mode: "existing";
tenantId: string;
+ parentTenantId?: string;
+ parentTenantSlug?: string;
}
| {
mode: "create";
tenantId?: string;
slug?: string;
parentTenantId?: string;
+ parentTenantSlug?: string;
}
| {
mode: "skip";
@@ -53,16 +72,18 @@ const importHeaders = [
"name",
"type",
"parent_tenant_id",
+ "parent_tenant_slug",
"slug",
"memo",
"email_domain",
];
-const headerAliases: Record = {
+const headerAliases: Record = {
id: "tenantId",
tenantid: "tenantId",
tenant_id: "tenantId",
name: "name",
+ 조직명: "name",
type: "type",
parentid: "parentTenantId",
parent_id: "parentTenantId",
@@ -70,9 +91,12 @@ const headerAliases: Record = {
parent_tenant_id: "parentTenantId",
parenttenantslug: "parentTenantSlug",
parent_tenant_slug: "parentTenantSlug",
+ 상위_조직: "parentOrg",
slug: "slug",
memo: "memo",
description: "memo",
+ 설명: "memo",
+ 메일링_리스트: "mailingList",
"email-domain": "emailDomain",
emaildomain: "emailDomain",
email_domain: "emailDomain",
@@ -80,39 +104,96 @@ const headerAliases: Record = {
domains: "emailDomain",
};
-export function parseTenantCSV(text: string): TenantCSVRow[] {
+export function parseTenantCSV(
+ text: string,
+ options: TenantCSVParseOptions = {},
+): TenantCSVRow[] {
const records = parseCSV(text.replace(/^\uFEFF/, ""));
if (records.length === 0) return [];
- const header = new Map();
+ const header = new Map();
records[0].forEach((column, index) => {
const normalized = normalizeHeader(column);
const key = headerAliases[normalized];
if (key) header.set(key, index);
});
- return records.slice(1).flatMap((record, index) => {
+ const isOrgChartCSV = header.has("mailingList") || header.has("parentOrg");
+ const sourceRows = records.slice(1).flatMap((record, index) => {
if (record.every((value) => value.trim() === "")) return [];
- const value = (key: keyof TenantCSVRow) => {
+ const value = (key: TenantCSVSourceKey) => {
const columnIndex = header.get(key);
if (columnIndex === undefined) return "";
return (record[columnIndex] ?? "").trim();
};
return {
+ raw: record,
rowNumber: index + 2,
- tenantId: value("tenantId"),
name: value("name"),
- type: value("type"),
+ slug: value("slug") || slugFromMailingList(value("mailingList")),
+ mailingList: value("mailingList"),
+ parentOrg: value("parentOrg"),
+ value,
+ };
+ });
+ const slugByName = new Map(
+ sourceRows
+ .filter((row) => row.name && row.slug)
+ .map((row) => [row.name, row.slug] as const),
+ );
+
+ return sourceRows.map(({ rowNumber, name, slug, parentOrg, value }) => {
+ const parentTenantSlug =
+ value("parentTenantSlug") ||
+ slugFromParentOrg(parentOrg, slugByName) ||
+ (isOrgChartCSV ? options.rootParentSlug || "" : "");
+
+ return {
+ rowNumber,
+ tenantId: value("tenantId"),
+ name,
+ type: value("type") || (isOrgChartCSV ? "ORGANIZATION" : ""),
parentTenantId: value("parentTenantId"),
- parentTenantSlug: value("parentTenantSlug"),
- slug: value("slug"),
+ parentTenantSlug,
+ slug,
memo: value("memo"),
emailDomain: value("emailDomain"),
};
});
}
+export function inferTenantImportRootParentSlug(
+ fileName: string,
+ tenants: TenantSummary[] = [],
+) {
+ const baseName = fileName.trim().split(/[\\/]/).pop()?.toLowerCase() ?? "";
+ const [prefix = ""] = baseName.split("_");
+ if (!prefix) return "";
+
+ const existingTenant = tenants.find(
+ (tenant) => tenant.slug.toLowerCase() === prefix,
+ );
+ return existingTenant ? prefix : "";
+}
+
+export function buildTenantImportParentOptionGroups(
+ tenants: TenantSummary[],
+): TenantImportParentOptionGroup[] {
+ const orderedTypes: TenantImportParentOptionGroupType[] = [
+ "COMPANY_GROUP",
+ "COMPANY",
+ "ORGANIZATION",
+ ];
+
+ return orderedTypes
+ .map((type) => ({
+ type,
+ tenants: tenants.filter((tenant) => tenant.type?.toUpperCase() === type),
+ }))
+ .filter((group) => group.tenants.length > 0);
+}
+
export function buildTenantImportPreview(
rows: TenantCSVRow[],
tenants: TenantSummary[],
@@ -169,27 +250,40 @@ export function serializeTenantImportCSV(
typeof resolution === "object" && resolution.mode === "create"
? resolution.slug || preview.defaultCreateSlug
: preview.row.slug;
+ const hasParentTenantIdOverride =
+ typeof resolution === "object" &&
+ Object.hasOwn(resolution, "parentTenantId");
+ const hasParentTenantSlugOverride =
+ typeof resolution === "object" &&
+ Object.hasOwn(resolution, "parentTenantSlug");
+ const sourceParentTenantSlug = hasParentTenantSlugOverride
+ ? resolution.parentTenantSlug || ""
+ : preview.row.parentTenantSlug;
const parentTenantId =
- typeof resolution === "object" && resolution.mode === "create"
- ? (resolution.parentTenantId ??
- remapParentTenantId(
- preview.row.parentTenantId,
- preview.row.parentTenantSlug,
- targetTenantIds,
- ))
+ typeof resolution === "object"
+ ? hasParentTenantIdOverride
+ ? resolution.parentTenantId || ""
+ : remapParentTenantId(
+ preview.row.parentTenantId,
+ sourceParentTenantSlug,
+ targetTenantIds,
+ )
: preview.row.parentTenantId;
+ const parentTenantSlug = remapParentTenantSlug(
+ sourceParentTenantSlug,
+ targetTenantIds,
+ );
const tenantId =
- typeof resolution === "object" && resolution.mode === "create"
- ? (resolution.tenantId ??
- targetTenantIds.bySourceId.get(preview.row.tenantId) ??
- createTenantImportId())
- : selectedTenantId || preview.row.tenantId;
+ targetTenantIds.byRowNumber.get(preview.row.rowNumber) ??
+ selectedTenantId ??
+ preview.row.tenantId;
lines.push([
tenantId,
preview.row.name,
preview.row.type,
parentTenantId,
+ parentTenantSlug,
slug,
preview.row.memo,
preview.row.emailDomain,
@@ -202,8 +296,10 @@ function buildTargetTenantIds(
previewRows: TenantImportPreviewRow[],
selectedTenantIds: Record,
) {
+ const byRowNumber = new Map();
const bySourceId = new Map();
const bySourceSlug = new Map();
+ const bySourceSlugToTargetSlug = new Map();
for (const preview of previewRows) {
const resolution = selectedTenantIds[preview.row.rowNumber] ?? "";
@@ -217,24 +313,38 @@ function buildTargetTenantIds(
: resolution.mode === "existing"
? resolution.tenantId
: resolution.tenantId || createTenantImportId();
+ const targetSlug =
+ typeof resolution === "object" && resolution.mode === "create"
+ ? resolution.slug || preview.defaultCreateSlug
+ : preview.row.slug;
+ if (targetTenantId) {
+ byRowNumber.set(preview.row.rowNumber, targetTenantId);
+ }
if (preview.row.tenantId) {
bySourceId.set(preview.row.tenantId, targetTenantId);
}
if (preview.row.slug) {
bySourceSlug.set(preview.row.slug.toLowerCase(), targetTenantId);
+ bySourceSlugToTargetSlug.set(preview.row.slug.toLowerCase(), targetSlug);
+ }
+ if (targetSlug) {
+ bySourceSlug.set(targetSlug.toLowerCase(), targetTenantId);
+ bySourceSlugToTargetSlug.set(targetSlug.toLowerCase(), targetSlug);
}
}
- return { bySourceId, bySourceSlug };
+ return { byRowNumber, bySourceId, bySourceSlug, bySourceSlugToTargetSlug };
}
function remapParentTenantId(
parentTenantId: string,
parentTenantSlug: string,
targetTenantIds: {
+ byRowNumber: Map;
bySourceId: Map;
bySourceSlug: Map;
+ bySourceSlugToTargetSlug: Map;
},
) {
if (parentTenantId) {
@@ -248,6 +358,20 @@ function remapParentTenantId(
return "";
}
+function remapParentTenantSlug(
+ parentTenantSlug: string,
+ targetTenantIds: {
+ bySourceSlugToTargetSlug: Map;
+ },
+) {
+ if (!parentTenantSlug) return "";
+ return (
+ targetTenantIds.bySourceSlugToTargetSlug.get(
+ parentTenantSlug.toLowerCase(),
+ ) ?? parentTenantSlug
+ );
+}
+
function createTenantImportId() {
if (globalThis.crypto?.randomUUID) {
return globalThis.crypto.randomUUID();
@@ -377,6 +501,33 @@ function normalizeHeader(value: string) {
return value.trim().toLowerCase().replaceAll(" ", "_");
}
+function slugFromMailingList(value: string) {
+ if (!value) return "";
+ return normalizeTenantSlug(value.split("@")[0] ?? value);
+}
+
+function slugFromParentOrg(value: string, slugByName: Map) {
+ const trimmed = value.trim();
+ if (!trimmed) return "";
+ const match = trimmed.match(/\(([^)]+)\)/);
+ if (match?.[1]) {
+ return slugFromMailingList(match[1]);
+ }
+ return slugByName.get(trimmed) ?? normalizeTenantSlug(trimmed);
+}
+
+function normalizeTenantSlug(value: string) {
+ let slug = value
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, "-");
+ slug = slug.replace(/^-+|-+$/g, "");
+ if (slug.length > 25) {
+ slug = slug.slice(0, 25).replace(/-+$/g, "");
+ }
+ return slug;
+}
+
function normalizeToken(value: string) {
return value
.trim()
diff --git a/adminfront/src/features/users/UserCreatePage.tsx b/adminfront/src/features/users/UserCreatePage.tsx
index 9a832991..56d4d8e4 100644
--- a/adminfront/src/features/users/UserCreatePage.tsx
+++ b/adminfront/src/features/users/UserCreatePage.tsx
@@ -98,6 +98,7 @@ function createEmptyAppointment(): AppointmentDraft {
tenantName: "",
tenantSlug: "",
isOwner: false,
+ grade: "",
jobTitle: "",
position: "",
};
@@ -148,6 +149,7 @@ function UserCreatePage() {
phone: "",
tenantSlug: searchParams.get("tenantSlug") || "",
department: "",
+ grade: "",
position: "",
jobTitle: "",
metadata: {},
@@ -379,6 +381,7 @@ function UserCreatePage() {
}
payload.tenantSlug = data.tenantSlug;
payload.department = data.department;
+ payload.grade = data.grade;
payload.position = data.position;
payload.jobTitle = data.jobTitle;
}
@@ -411,6 +414,7 @@ function UserCreatePage() {
tenantName: appointment.tenantName,
isPrimary: appointment.isOwner,
isOwner: appointment.isOwner,
+ grade: appointment.grade,
jobTitle: appointment.jobTitle,
position: appointment.position,
}));
@@ -685,12 +689,20 @@ function UserCreatePage() {
-
+
-
+
+
+
+
+
@@ -709,9 +721,11 @@ function UserCreatePage() {
-
소속별 직급/직무
+
+ 소속별 직급/직책/직무
+
- 테넌트별 조직장 여부, 직무, 직급을 입력합니다.
+ 테넌트별 조직장 여부, 직급, 직책, 직무를 입력합니다.