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() {
-

소속별 직급/직무

+

+ 소속별 직급/직책/직무 +

- 테넌트별 조직장 여부, 직무, 직급을 입력합니다. + 테넌트별 조직장 여부, 직급, 직책, 직무를 입력합니다.

- ))} +
+
총 {totalUsers}명
@@ -773,7 +1514,8 @@ export function TenantOrgChartPage() { > ( ))} {layout.nodes.map((visualNode) => ( - + setHoveredNodeId(null)} + onHoverStart={() => setHoveredNodeId(visualNode.node.id)} + semanticZoomMode={semanticZoomMode} + visualNode={visualNode} + /> ))} @@ -802,90 +1577,259 @@ export function TenantOrgChartPage() { ); } +function LayoutOptionPicker({ + label, + onChange, + options, + testId, + value, +}: { + label: string; + onChange: (value: T) => void; + options: Array<{ id: T; label: string }>; + testId: string; + value: T; +}) { + const selectedOption = + options.find((option) => option.id === value) ?? options[0]; + const availableOptions = options.filter((option) => option.id !== value); + + return ( +
+ +
+ + {label} + + {availableOptions.map((option) => ( + + ))} +
+
+ ); +} + +function OrgSelectionPicker({ + onChange, + options, + selectedId, + selectedLabel, +}: { + onChange: (value: string) => void; + options: OrgSelectionOption[]; + selectedId: string; + selectedLabel: string; +}) { + const isFamilySelected = selectedId === FAMILY_FILTER_ID; + const selectedCompany = options.find((option) => option.id === selectedId); + const showSelectedDescendant = !isFamilySelected && !selectedCompany; + const baseButtonClass = + "shrink-0 rounded-full border px-3 py-1.5 text-xs font-bold transition-all"; + const selectedButtonClass = + "border-[#d6f4e6] bg-[#d6f4e6] text-[#073b2d] shadow-sm"; + const optionButtonClass = + "border-[#8bd3b2]/28 bg-[#0c4b3c]/45 text-[#e1fff1]/85 hover:border-[#8bd3b2]/70 hover:bg-[#0e5a48]/75 hover:text-[#f4fff8]"; + + return ( +
+ + {showSelectedDescendant ? ( + + ) : null} + {options.map((option) => { + const isSelected = selectedId === option.id; + + return ( +
+ + {option.descendants.length > 0 ? ( +
+ {option.descendants + .filter((descendant) => descendant.id !== selectedId) + .map((descendant) => ( + + ))} +
+ ) : null} +
+ ); + })} +
+ ); +} + function SvgOrgNode({ + isHovered, + onHoverEnd, + onHoverStart, + semanticZoomMode, visualNode, }: { + isHovered: boolean; + onHoverEnd: () => void; + onHoverStart: () => void; + semanticZoomMode: SemanticZoomMode; visualNode: VisualNode; }) { const { node, x, y, width, height, members, collapsed } = visualNode; - const headerFill = node.level === 0 ? "#0a2a22" : "#2f5547"; - const accent = getColorForCompany(node.companyCode); + const headerFill = getOrgNodeHeaderFill( + node.companyColorDepth ?? node.level, + node.companyColorKey, + ); + const memberColumnCount = getMemberColumnCount(members.length); + const showMemberRows = semanticZoomMode === "detail"; + const showNodeName = + semanticZoomMode === "detail" || + node.level <= 1 || + (semanticZoomMode === "compact" && node.level <= 2); + const showCompactGlyph = !showNodeName; + const titleClass = + node.level <= 1 + ? "text-[17px] font-black leading-tight" + : "text-[14px] font-extrabold leading-tight"; return ( - - - - - - {node.name} - - - - +
+
- {collapsed ? "+" : node.totalCount} - - - - {members.length > 0 ? ( - members.map((member, index) => ( - + {node.orgUnitType ? ( + + {node.orgUnitType} + + ) : null} +
+ {node.name} +
+
+ ) : ( +
+
+
+
+ )} +
+ {collapsed ? "+" : node.totalCount} +
+
+ {showCompactGlyph ? ( +
+
+ + {node.children.length > 0 + ? node.children.length + : node.totalCount} +
+
+ ) : showMemberRows && members.length > 0 ? ( +
- - - - {getOrgChartUserDisplayName(member, { - id: node.id, - slug: node.companyCode ?? "", - })} - - - )) - ) : ( - - 구성원 없음 - - )} - + {members.map((member) => ( +
+
+
+ {getOrgChartUserDisplayName(member, { + id: node.id, + slug: node.companyCode ?? "", + })} +
+
+ ))} +
+ ) : ( +
+ 구성원 없음 +
+ )} +
+ ); } diff --git a/orgfront/src/features/orgchart/routes/OrgFrontLayout.tsx b/orgfront/src/features/orgchart/routes/OrgFrontLayout.tsx index 64bc862d..406f4a5b 100644 --- a/orgfront/src/features/orgchart/routes/OrgFrontLayout.tsx +++ b/orgfront/src/features/orgchart/routes/OrgFrontLayout.tsx @@ -1,5 +1,5 @@ import { GitBranch, Network, PanelTop } from "lucide-react"; -import { NavLink, Outlet } from "react-router-dom"; +import { NavLink, Outlet, useLocation } from "react-router-dom"; const navItems = [ { to: "/chart", label: "조직도", icon: Network }, @@ -8,9 +8,22 @@ const navItems = [ ]; export function OrgFrontLayout() { + const location = useLocation(); + const isChartRoute = + location.pathname === "/chart" || location.pathname.startsWith("/chart/"); + return ( -
-
+
+

@@ -40,7 +53,14 @@ export function OrgFrontLayout() {

-
+
diff --git a/orgfront/src/features/orgchart/routes/OrgPickerEmbedPreviewPage.tsx b/orgfront/src/features/orgchart/routes/OrgPickerEmbedPreviewPage.tsx index 86c6769b..9b27e5e5 100644 --- a/orgfront/src/features/orgchart/routes/OrgPickerEmbedPreviewPage.tsx +++ b/orgfront/src/features/orgchart/routes/OrgPickerEmbedPreviewPage.tsx @@ -59,7 +59,7 @@ function PickerScenarioControls({