1
0
forked from baron/baron-sso

네이버 웍스 연동기능 개선

This commit is contained in:
2026-05-18 15:36:30 +09:00
parent c71ece84b8
commit e29d056b9e
61 changed files with 4137 additions and 710 deletions

View File

@@ -1,3 +0,0 @@
"LastName","FirstName","ID","Personal email","Sub email","Nickname","User type","Level","Organization","Position","CompanyMainPhone","Mobile/Country code","Mobile/Numbers","Language","Responsibilities","Workplace","SNS","SNS_ID","Birthday (solar, lunar)","Birthday","Entry Date","Employee number","Account activation time"
"Doe","John","john.doe","john@naver.com","john1@company.com; john2@company.com","John","Permanent Employee","Manager","org.1|org.2|org.3|myteam","Manager","02-0000-0000","+1","9144812222","English","Sales management","New York","Facebook","john","solar","19830415","20230415","AB001","20230415 08:00"
"Doe","Eric","eric.doe","eric@naver.com","eric2@company.com","Eric","Contract Employee","Manager","org.1|org.2|org.3|org.4|myteam","Manager","02-1234-0000","+1","9765412345","Japanese","General affairs","New York","Facebook","Eric","lunar","19840704","20240704","AB002","20240704 14:00"
1 LastName FirstName ID Personal email Sub email Nickname User type Level Organization Position CompanyMainPhone Mobile/Country code Mobile/Numbers Language Responsibilities Workplace SNS SNS_ID Birthday (solar, lunar) Birthday Entry Date Employee number Account activation time
2 Doe John john.doe john@naver.com john1@company.com; john2@company.com John Permanent Employee Manager org.1|org.2|org.3|myteam Manager 02-0000-0000 +1 9144812222 English Sales management New York Facebook john solar 19830415 20230415 AB001 20230415 08:00
3 Doe Eric eric.doe eric@naver.com eric2@company.com Eric Contract Employee Manager org.1|org.2|org.3|org.4|myteam Manager 02-1234-0000 +1 9765412345 Japanese General affairs New York Facebook Eric lunar 19840704 20240704 AB002 20240704 14:00

View File

@@ -0,0 +1,71 @@
"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","마스터에게 메시지방 기능 권한 부여","조직 관련 알림 보내기","조직 공개","외부 도메인 메일 수신 차단","보내는 주소로 사용 가능한 구성원","메일을 보낼 수 있는 구성원","상위 조직"
"(주)장헌","0","","","","jangheon@brsw.kr","Y","N","Y","Y","","",""
"임원실","0","","","","t_617rl@brsw.kr","Y","N","Y","Y","","","(주)장헌(jangheon@brsw.kr)"
"생산부","0","","","","t_921uz@brsw.kr","Y","N","Y","Y","","","(주)장헌(jangheon@brsw.kr)"
"공무팀","0","","","","t_733vx@brsw.kr","Y","N","Y","Y","","","생산부(t_921uz@brsw.kr)"
"철근팀","0","","","","t_334vk@brsw.kr","Y","N","Y","Y","","","생산부(t_921uz@brsw.kr)"
"제작1팀","0","","","","t_196vt@brsw.kr","Y","N","Y","Y","","","생산부(t_921uz@brsw.kr)"
"제작2팀","0","","","","t_690ka@brsw.kr","Y","N","Y","Y","","","생산부(t_921uz@brsw.kr)"
"품질팀","0","","","","t_013sr@brsw.kr","Y","N","Y","Y","","","생산부(t_921uz@brsw.kr)"
"업무지원팀","0","","","","t_601un@brsw.kr","Y","N","Y","Y","","","(주)장헌(jangheon@brsw.kr)"
"PTC","0","","","","ptc@brsw.kr","Y","N","Y","Y","","",""
"임원실","0","","","","t_771pf@brsw.kr","Y","N","Y","Y","","","PTC(ptc@brsw.kr)"
"영업팀","0","","","","t_375vv@brsw.kr","Y","N","Y","Y","","","PTC(ptc@brsw.kr)"
"사업관리팀","0","","","","t_054fx@brsw.kr","Y","N","Y","Y","","","PTC(ptc@brsw.kr)"
"시공팀","0","","","","t_871dc@brsw.kr","Y","N","Y","Y","","","PTC(ptc@brsw.kr)"
"설계팀","0","","","","t_156ss@brsw.kr","Y","N","Y","Y","","","PTC(ptc@brsw.kr)"
"네이버웍스관리용(바론그룹)","2","슈퍼관리자(su-@samaneng.com)","","","su4@brsw.kr","N","N","N","Y","","",""
"장헌산업","0","","","","jangheon-sanup@brsw.kr","Y","N","Y","Y","","",""
"임원실","0","","","","t_049ij@brsw.kr","Y","N","Y","Y","","","장헌산업(jangheon-sanup@brsw.kr)"
"경영지원부","0","","","","t_166wx@brsw.kr","Y","N","Y","Y","","","장헌산업(jangheon-sanup@brsw.kr)"
"기술영업본부","0","","","","t_444be@brsw.kr","Y","N","Y","Y","","","장헌산업(jangheon-sanup@brsw.kr)"
"영업","0","","","","t_999wg@brsw.kr","Y","N","Y","Y","","","기술영업본부(t_444be@brsw.kr)"
"기술지원","0","","","","t_512gs@brsw.kr","Y","N","Y","Y","","","기술영업본부(t_444be@brsw.kr)"
"견적","0","","","","t_917bs@brsw.kr","Y","N","Y","Y","","","기술영업본부(t_444be@brsw.kr)"
"건설본부","0","","","","t_054iq@brsw.kr","Y","N","Y","Y","","","장헌산업(jangheon-sanup@brsw.kr)"
"공무","0","","","","t_191nh@brsw.kr","Y","N","Y","Y","","","건설본부(t_054iq@brsw.kr)"
"현장","0","","","","t_995wn@brsw.kr","Y","N","Y","Y","","","건설본부(t_054iq@brsw.kr)"
"안전관리","0","","","","t_695sg@brsw.kr","Y","N","Y","Y","","","건설본부(t_054iq@brsw.kr)"
"한라산업개발","0","","","","hanlla@brsw.kr","Y","N","Y","Y","","",""
"임원실","0","","","","t_080iz@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
"업무총괄","0","","","","general-biz@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
"영업총괄","0","","","","general_sales@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
"경영지원본부","0","","","","t_261yp@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
"업무팀","0","","","","t_407sk@brsw.kr","Y","N","Y","Y","","","경영지원본부(t_261yp@brsw.kr)"
"사업지원팀","0","","","","t_265al@brsw.kr","Y","N","Y","Y","","","경영지원본부(t_261yp@brsw.kr)"
"경영지원팀","0","","","","t_681nn@brsw.kr","Y","N","Y","Y","","","경영지원본부(t_261yp@brsw.kr)"
"운영사업실","0","","","","t_174mm@brsw.kr","Y","N","Y","Y","","","경영지원본부(t_261yp@brsw.kr)"
"기반사업본부","0","","","","t_785dc@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
"사업관리팀","0","","","","t_422tk@brsw.kr","Y","N","Y","Y","","","기반사업본부(t_785dc@brsw.kr)"
"환경플랜트사업본부","0","","","","t_558py@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
"사업관리팀","0","","","","t_932wg@brsw.kr","Y","N","Y","Y","","","환경플랜트사업본부(t_558py@brsw.kr)"
"설계팀","0","","","","t_695kn@brsw.kr","Y","N","Y","Y","","","환경플랜트사업본부(t_558py@brsw.kr)"
"기술영업본부","0","","","","t_708iq@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
"기술영업팀","0","","","","t_026lk@brsw.kr","Y","N","Y","Y","","","기술영업본부(t_708iq@brsw.kr)"
"안전관리본부","0","","","","t_601wg@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
"안전관리팀","0","","","","t_885ji@brsw.kr","Y","N","Y","Y","","","안전관리본부(t_601wg@brsw.kr)"
"시공현장","0","","","","t_745dt@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
"부천시 굴포천","0","","","","t_579tx@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
"옥정 공공하수처리","0","","","","t_644eu@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
"여주부평천","0","","","","t_923sy@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
"도척 실촌간 도로","0","","","","t_583wq@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
"광주공공폐수처리","0","","","","t_481kp@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
"아포공공하수처리","0","","","","t_654ud@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
"장량공공하수처리","0","","","","t_007gm@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
"신천공공하수처리","0","","","","t_328ki@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
"온산하수처리","0","","","","t_742au@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
"수도권매립지 제2매립장","0","","","","t_850qe@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
"인천국제공항 화물","0","","","","t_246jb@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
"광탄공공하수처리","0","","","","t_256he@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
"성남시생활폐기물처리","0","","","","t_148dm@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
"제주공공하수처리","0","","","","t_317lj@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
"인덕원 동탄 복선전철 제3공구","0","","","","t_227xx@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
"인덕원 동탄 복선전철 제7공구","0","","","","t_605dg@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
"경산시 국도대체","0","","","","t_020pv@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
"수도권광역급행철도B 제4공구","0","","","","t_217mu@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
"부산항 신항","0","","","","t_282jo@brsw.kr","Y","N","Y","Y","","","시공현장(t_745dt@brsw.kr)"
"운영사업소","0","","","","t_993sp@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
"울산민자소각","0","","","","t_600hp@brsw.kr","Y","N","Y","Y","","","운영사업소(t_993sp@brsw.kr)"
"온산바이오","0","","","","t_374ak@brsw.kr","Y","N","Y","Y","","","운영사업소(t_993sp@brsw.kr)"
"안성제4차산업단지폐수처리","0","","","","t_749lk@brsw.kr","Y","N","Y","Y","","","운영사업소(t_993sp@brsw.kr)"
"서산시자원회수시설","0","","","","t_056hs@brsw.kr","Y","N","Y","Y","","","운영사업소(t_993sp@brsw.kr)"
1 조직명 멤버 수 조직장 조직 다국어명 설명 메일링 리스트 마스터에게 메시지방 기능 권한 부여 조직 관련 알림 보내기 조직 공개 외부 도메인 메일 수신 차단 보내는 주소로 사용 가능한 구성원 메일을 보낼 수 있는 구성원 상위 조직
2 (주)장헌 0 jangheon@brsw.kr Y N Y Y
3 임원실 0 t_617rl@brsw.kr Y N Y Y (주)장헌(jangheon@brsw.kr)
4 생산부 0 t_921uz@brsw.kr Y N Y Y (주)장헌(jangheon@brsw.kr)
5 공무팀 0 t_733vx@brsw.kr Y N Y Y 생산부(t_921uz@brsw.kr)
6 철근팀 0 t_334vk@brsw.kr Y N Y Y 생산부(t_921uz@brsw.kr)
7 제작1팀 0 t_196vt@brsw.kr Y N Y Y 생산부(t_921uz@brsw.kr)
8 제작2팀 0 t_690ka@brsw.kr Y N Y Y 생산부(t_921uz@brsw.kr)
9 품질팀 0 t_013sr@brsw.kr Y N Y Y 생산부(t_921uz@brsw.kr)
10 업무지원팀 0 t_601un@brsw.kr Y N Y Y (주)장헌(jangheon@brsw.kr)
11 PTC 0 ptc@brsw.kr Y N Y Y
12 임원실 0 t_771pf@brsw.kr Y N Y Y PTC(ptc@brsw.kr)
13 영업팀 0 t_375vv@brsw.kr Y N Y Y PTC(ptc@brsw.kr)
14 사업관리팀 0 t_054fx@brsw.kr Y N Y Y PTC(ptc@brsw.kr)
15 시공팀 0 t_871dc@brsw.kr Y N Y Y PTC(ptc@brsw.kr)
16 설계팀 0 t_156ss@brsw.kr Y N Y Y PTC(ptc@brsw.kr)
17 네이버웍스관리용(바론그룹) 2 슈퍼관리자(su-@samaneng.com) su4@brsw.kr N N N Y
18 장헌산업 0 jangheon-sanup@brsw.kr Y N Y Y
19 임원실 0 t_049ij@brsw.kr Y N Y Y 장헌산업(jangheon-sanup@brsw.kr)
20 경영지원부 0 t_166wx@brsw.kr Y N Y Y 장헌산업(jangheon-sanup@brsw.kr)
21 기술영업본부 0 t_444be@brsw.kr Y N Y Y 장헌산업(jangheon-sanup@brsw.kr)
22 영업 0 t_999wg@brsw.kr Y N Y Y 기술영업본부(t_444be@brsw.kr)
23 기술지원 0 t_512gs@brsw.kr Y N Y Y 기술영업본부(t_444be@brsw.kr)
24 견적 0 t_917bs@brsw.kr Y N Y Y 기술영업본부(t_444be@brsw.kr)
25 건설본부 0 t_054iq@brsw.kr Y N Y Y 장헌산업(jangheon-sanup@brsw.kr)
26 공무 0 t_191nh@brsw.kr Y N Y Y 건설본부(t_054iq@brsw.kr)
27 현장 0 t_995wn@brsw.kr Y N Y Y 건설본부(t_054iq@brsw.kr)
28 안전관리 0 t_695sg@brsw.kr Y N Y Y 건설본부(t_054iq@brsw.kr)
29 한라산업개발 0 hanlla@brsw.kr Y N Y Y
30 임원실 0 t_080iz@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
31 업무총괄 0 general-biz@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
32 영업총괄 0 general_sales@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
33 경영지원본부 0 t_261yp@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
34 업무팀 0 t_407sk@brsw.kr Y N Y Y 경영지원본부(t_261yp@brsw.kr)
35 사업지원팀 0 t_265al@brsw.kr Y N Y Y 경영지원본부(t_261yp@brsw.kr)
36 경영지원팀 0 t_681nn@brsw.kr Y N Y Y 경영지원본부(t_261yp@brsw.kr)
37 운영사업실 0 t_174mm@brsw.kr Y N Y Y 경영지원본부(t_261yp@brsw.kr)
38 기반사업본부 0 t_785dc@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
39 사업관리팀 0 t_422tk@brsw.kr Y N Y Y 기반사업본부(t_785dc@brsw.kr)
40 환경플랜트사업본부 0 t_558py@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
41 사업관리팀 0 t_932wg@brsw.kr Y N Y Y 환경플랜트사업본부(t_558py@brsw.kr)
42 설계팀 0 t_695kn@brsw.kr Y N Y Y 환경플랜트사업본부(t_558py@brsw.kr)
43 기술영업본부 0 t_708iq@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
44 기술영업팀 0 t_026lk@brsw.kr Y N Y Y 기술영업본부(t_708iq@brsw.kr)
45 안전관리본부 0 t_601wg@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
46 안전관리팀 0 t_885ji@brsw.kr Y N Y Y 안전관리본부(t_601wg@brsw.kr)
47 시공현장 0 t_745dt@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
48 부천시 굴포천 0 t_579tx@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
49 옥정 공공하수처리 0 t_644eu@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
50 여주부평천 0 t_923sy@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
51 도척 실촌간 도로 0 t_583wq@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
52 광주공공폐수처리 0 t_481kp@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
53 아포공공하수처리 0 t_654ud@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
54 장량공공하수처리 0 t_007gm@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
55 신천공공하수처리 0 t_328ki@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
56 온산하수처리 0 t_742au@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
57 수도권매립지 제2매립장 0 t_850qe@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
58 인천국제공항 화물 0 t_246jb@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
59 광탄공공하수처리 0 t_256he@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
60 성남시생활폐기물처리 0 t_148dm@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
61 제주공공하수처리 0 t_317lj@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
62 인덕원 동탄 복선전철 제3공구 0 t_227xx@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
63 인덕원 동탄 복선전철 제7공구 0 t_605dg@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
64 경산시 국도대체 0 t_020pv@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
65 수도권광역급행철도B 제4공구 0 t_217mu@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
66 부산항 신항 0 t_282jo@brsw.kr Y N Y Y 시공현장(t_745dt@brsw.kr)
67 운영사업소 0 t_993sp@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
68 울산민자소각 0 t_600hp@brsw.kr Y N Y Y 운영사업소(t_993sp@brsw.kr)
69 온산바이오 0 t_374ak@brsw.kr Y N Y Y 운영사업소(t_993sp@brsw.kr)
70 안성제4차산업단지폐수처리 0 t_749lk@brsw.kr Y N Y Y 운영사업소(t_993sp@brsw.kr)
71 서산시자원회수시설 0 t_056hs@brsw.kr Y N Y Y 운영사업소(t_993sp@brsw.kr)

View File

@@ -0,0 +1,71 @@
"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","마스터에게 메시지방 기능 권한 부여","조직 관련 알림 보내기","조직 공개","외부 도메인 메일 수신 차단","보내는 주소로 사용 가능한 구성원","메일을 보낼 수 있는 구성원","상위 조직"
"(주)장헌","0","","","","jangheon@brsw.kr","Y","N","Y","Y","","",""
"임원실","0","","","","jangheon-executive@brsw.kr","Y","N","Y","Y","","","(주)장헌(jangheon@brsw.kr)"
"생산부","0","","","","jangheon-production@brsw.kr","Y","N","Y","Y","","","(주)장헌(jangheon@brsw.kr)"
"공무팀","0","","","","jangheon-production-admin@brsw.kr","Y","N","Y","Y","","","생산부(jangheon-production@brsw.kr)"
"철근팀","0","","","","jangheon-rebar@brsw.kr","Y","N","Y","Y","","","생산부(jangheon-production@brsw.kr)"
"제작1팀","0","","","","jangheon-fab-1@brsw.kr","Y","N","Y","Y","","","생산부(jangheon-production@brsw.kr)"
"제작2팀","0","","","","jangheon-fab-2@brsw.kr","Y","N","Y","Y","","","생산부(jangheon-production@brsw.kr)"
"품질팀","0","","","","jangheon-quality@brsw.kr","Y","N","Y","Y","","","생산부(jangheon-production@brsw.kr)"
"업무지원팀","0","","","","jangheon-business-support@brsw.kr","Y","N","Y","Y","","","(주)장헌(jangheon@brsw.kr)"
"PTC","0","","","","ptc@brsw.kr","Y","N","Y","Y","","",""
"임원실","0","","","","ptc-executive@brsw.kr","Y","N","Y","Y","","","PTC(ptc@brsw.kr)"
"영업팀","0","","","","ptc-sales@brsw.kr","Y","N","Y","Y","","","PTC(ptc@brsw.kr)"
"사업관리팀","0","","","","ptc-project-management@brsw.kr","Y","N","Y","Y","","","PTC(ptc@brsw.kr)"
"시공팀","0","","","","ptc-construction@brsw.kr","Y","N","Y","Y","","","PTC(ptc@brsw.kr)"
"설계팀","0","","","","ptc-design@brsw.kr","Y","N","Y","Y","","","PTC(ptc@brsw.kr)"
"네이버웍스관리용(바론그룹)","2","슈퍼관리자(su-@samaneng.com)","","","nw-admin-baron-group@brsw.kr","N","N","N","Y","","",""
"장헌산업","0","","","","jangheon-sanup@brsw.kr","Y","N","Y","Y","","",""
"임원실","0","","","","jangheon-sanup-executive@brsw.kr","Y","N","Y","Y","","","장헌산업(jangheon-sanup@brsw.kr)"
"경영지원부","0","","","","js-mgmt-support@brsw.kr","Y","N","Y","Y","","","장헌산업(jangheon-sanup@brsw.kr)"
"기술영업본부","0","","","","js-tech-sales-hq@brsw.kr","Y","N","Y","Y","","","장헌산업(jangheon-sanup@brsw.kr)"
"영업","0","","","","js-sales@brsw.kr","Y","N","Y","Y","","","기술영업본부(js-tech-sales-hq@brsw.kr)"
"기술지원","0","","","","js-tech-support@brsw.kr","Y","N","Y","Y","","","기술영업본부(js-tech-sales-hq@brsw.kr)"
"견적","0","","","","js-estimation@brsw.kr","Y","N","Y","Y","","","기술영업본부(js-tech-sales-hq@brsw.kr)"
"건설본부","0","","","","js-construction-hq@brsw.kr","Y","N","Y","Y","","","장헌산업(jangheon-sanup@brsw.kr)"
"공무","0","","","","js-construction-admin@brsw.kr","Y","N","Y","Y","","","건설본부(js-construction-hq@brsw.kr)"
"현장","0","","","","js-site@brsw.kr","Y","N","Y","Y","","","건설본부(js-construction-hq@brsw.kr)"
"안전관리","0","","","","js-safety-management@brsw.kr","Y","N","Y","Y","","","건설본부(js-construction-hq@brsw.kr)"
"한라산업개발","0","","","","hanlla@brsw.kr","Y","N","Y","Y","","",""
"임원실","0","","","","hanlla-executive@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
"업무총괄","0","","","","hanlla-general-business@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
"영업총괄","0","","","","hanlla-general-sales@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
"경영지원본부","0","","","","hanlla-mgmt-support-hq@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
"업무팀","0","","","","hanlla-operations@brsw.kr","Y","N","Y","Y","","","경영지원본부(hanlla-mgmt-support-hq@brsw.kr)"
"사업지원팀","0","","","","hanlla-business-support@brsw.kr","Y","N","Y","Y","","","경영지원본부(hanlla-mgmt-support-hq@brsw.kr)"
"경영지원팀","0","","","","hanlla-mgmt-support@brsw.kr","Y","N","Y","Y","","","경영지원본부(hanlla-mgmt-support-hq@brsw.kr)"
"운영사업실","0","","","","hanlla-operations-office@brsw.kr","Y","N","Y","Y","","","경영지원본부(hanlla-mgmt-support-hq@brsw.kr)"
"기반사업본부","0","","","","hanlla-infra-business-hq@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
"사업관리팀","0","","","","hanlla-infra-project-mgmt@brsw.kr","Y","N","Y","Y","","","기반사업본부(hanlla-infra-business-hq@brsw.kr)"
"환경플랜트사업본부","0","","","","hanlla-env-plant-hq@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
"사업관리팀","0","","","","hanlla-env-project-mgmt@brsw.kr","Y","N","Y","Y","","","환경플랜트사업본부(hanlla-env-plant-hq@brsw.kr)"
"설계팀","0","","","","hanlla-env-plant-design@brsw.kr","Y","N","Y","Y","","","환경플랜트사업본부(hanlla-env-plant-hq@brsw.kr)"
"기술영업본부","0","","","","hanlla-tech-sales-hq@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
"기술영업팀","0","","","","hanlla-tech-sales-team@brsw.kr","Y","N","Y","Y","","","기술영업본부(hanlla-tech-sales-hq@brsw.kr)"
"안전관리본부","0","","","","hanlla-safety-hq@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
"안전관리팀","0","","","","hanlla-safety-team@brsw.kr","Y","N","Y","Y","","","안전관리본부(hanlla-safety-hq@brsw.kr)"
"시공현장","0","","","","hanlla-construction-sites@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
"부천시 굴포천","0","","","","site-bucheon-gulpocheon@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
"옥정 공공하수처리","0","","","","site-okjeong-sewage@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
"여주부평천","0","","","","site-yeoju-bupyeongcheon@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
"도척 실촌간 도로","0","","","","site-docheok-silchon-road@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
"광주공공폐수처리","0","","","","site-gwangju-wastewater@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
"아포공공하수처리","0","","","","site-apo-sewage@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
"장량공공하수처리","0","","","","site-jangnyang-sewage@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
"신천공공하수처리","0","","","","site-sincheon-sewage@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
"온산하수처리","0","","","","site-onsan-sewage@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
"수도권매립지 제2매립장","0","","","","site-sudokwon-landfill-2@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
"인천국제공항 화물","0","","","","site-incheon-air-cargo@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
"광탄공공하수처리","0","","","","site-gwangtan-sewage@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
"성남시생활폐기물처리","0","","","","site-seongnam-waste@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
"제주공공하수처리","0","","","","site-jeju-sewage@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
"인덕원 동탄 복선전철 제3공구","0","","","","site-indeokwon-dongtan-3@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
"인덕원 동탄 복선전철 제7공구","0","","","","site-indeokwon-dongtan-7@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
"경산시 국도대체","0","","","","site-gyeongsan-road@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
"수도권광역급행철도B 제4공구","0","","","","site-gtx-b-4@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
"부산항 신항","0","","","","site-busan-new-port@brsw.kr","Y","N","Y","Y","","","시공현장(hanlla-construction-sites@brsw.kr)"
"운영사업소","0","","","","hanlla-operation-sites@brsw.kr","Y","N","Y","Y","","","한라산업개발(hanlla@brsw.kr)"
"울산민자소각","0","","","","ops-ulsan-incineration@brsw.kr","Y","N","Y","Y","","","운영사업소(hanlla-operation-sites@brsw.kr)"
"온산바이오","0","","","","ops-onsan-bio@brsw.kr","Y","N","Y","Y","","","운영사업소(hanlla-operation-sites@brsw.kr)"
"안성제4차산업단지폐수처리","0","","","","ops-anseong-wwtp@brsw.kr","Y","N","Y","Y","","","운영사업소(hanlla-operation-sites@brsw.kr)"
"서산시자원회수시설","0","","","","ops-seosan-recovery@brsw.kr","Y","N","Y","Y","","","운영사업소(hanlla-operation-sites@brsw.kr)"
1 조직명 멤버 수 조직장 조직 다국어명 설명 메일링 리스트 마스터에게 메시지방 기능 권한 부여 조직 관련 알림 보내기 조직 공개 외부 도메인 메일 수신 차단 보내는 주소로 사용 가능한 구성원 메일을 보낼 수 있는 구성원 상위 조직
2 (주)장헌 0 jangheon@brsw.kr Y N Y Y
3 임원실 0 jangheon-executive@brsw.kr Y N Y Y (주)장헌(jangheon@brsw.kr)
4 생산부 0 jangheon-production@brsw.kr Y N Y Y (주)장헌(jangheon@brsw.kr)
5 공무팀 0 jangheon-production-admin@brsw.kr Y N Y Y 생산부(jangheon-production@brsw.kr)
6 철근팀 0 jangheon-rebar@brsw.kr Y N Y Y 생산부(jangheon-production@brsw.kr)
7 제작1팀 0 jangheon-fab-1@brsw.kr Y N Y Y 생산부(jangheon-production@brsw.kr)
8 제작2팀 0 jangheon-fab-2@brsw.kr Y N Y Y 생산부(jangheon-production@brsw.kr)
9 품질팀 0 jangheon-quality@brsw.kr Y N Y Y 생산부(jangheon-production@brsw.kr)
10 업무지원팀 0 jangheon-business-support@brsw.kr Y N Y Y (주)장헌(jangheon@brsw.kr)
11 PTC 0 ptc@brsw.kr Y N Y Y
12 임원실 0 ptc-executive@brsw.kr Y N Y Y PTC(ptc@brsw.kr)
13 영업팀 0 ptc-sales@brsw.kr Y N Y Y PTC(ptc@brsw.kr)
14 사업관리팀 0 ptc-project-management@brsw.kr Y N Y Y PTC(ptc@brsw.kr)
15 시공팀 0 ptc-construction@brsw.kr Y N Y Y PTC(ptc@brsw.kr)
16 설계팀 0 ptc-design@brsw.kr Y N Y Y PTC(ptc@brsw.kr)
17 네이버웍스관리용(바론그룹) 2 슈퍼관리자(su-@samaneng.com) nw-admin-baron-group@brsw.kr N N N Y
18 장헌산업 0 jangheon-sanup@brsw.kr Y N Y Y
19 임원실 0 jangheon-sanup-executive@brsw.kr Y N Y Y 장헌산업(jangheon-sanup@brsw.kr)
20 경영지원부 0 js-mgmt-support@brsw.kr Y N Y Y 장헌산업(jangheon-sanup@brsw.kr)
21 기술영업본부 0 js-tech-sales-hq@brsw.kr Y N Y Y 장헌산업(jangheon-sanup@brsw.kr)
22 영업 0 js-sales@brsw.kr Y N Y Y 기술영업본부(js-tech-sales-hq@brsw.kr)
23 기술지원 0 js-tech-support@brsw.kr Y N Y Y 기술영업본부(js-tech-sales-hq@brsw.kr)
24 견적 0 js-estimation@brsw.kr Y N Y Y 기술영업본부(js-tech-sales-hq@brsw.kr)
25 건설본부 0 js-construction-hq@brsw.kr Y N Y Y 장헌산업(jangheon-sanup@brsw.kr)
26 공무 0 js-construction-admin@brsw.kr Y N Y Y 건설본부(js-construction-hq@brsw.kr)
27 현장 0 js-site@brsw.kr Y N Y Y 건설본부(js-construction-hq@brsw.kr)
28 안전관리 0 js-safety-management@brsw.kr Y N Y Y 건설본부(js-construction-hq@brsw.kr)
29 한라산업개발 0 hanlla@brsw.kr Y N Y Y
30 임원실 0 hanlla-executive@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
31 업무총괄 0 hanlla-general-business@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
32 영업총괄 0 hanlla-general-sales@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
33 경영지원본부 0 hanlla-mgmt-support-hq@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
34 업무팀 0 hanlla-operations@brsw.kr Y N Y Y 경영지원본부(hanlla-mgmt-support-hq@brsw.kr)
35 사업지원팀 0 hanlla-business-support@brsw.kr Y N Y Y 경영지원본부(hanlla-mgmt-support-hq@brsw.kr)
36 경영지원팀 0 hanlla-mgmt-support@brsw.kr Y N Y Y 경영지원본부(hanlla-mgmt-support-hq@brsw.kr)
37 운영사업실 0 hanlla-operations-office@brsw.kr Y N Y Y 경영지원본부(hanlla-mgmt-support-hq@brsw.kr)
38 기반사업본부 0 hanlla-infra-business-hq@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
39 사업관리팀 0 hanlla-infra-project-mgmt@brsw.kr Y N Y Y 기반사업본부(hanlla-infra-business-hq@brsw.kr)
40 환경플랜트사업본부 0 hanlla-env-plant-hq@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
41 사업관리팀 0 hanlla-env-project-mgmt@brsw.kr Y N Y Y 환경플랜트사업본부(hanlla-env-plant-hq@brsw.kr)
42 설계팀 0 hanlla-env-plant-design@brsw.kr Y N Y Y 환경플랜트사업본부(hanlla-env-plant-hq@brsw.kr)
43 기술영업본부 0 hanlla-tech-sales-hq@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
44 기술영업팀 0 hanlla-tech-sales-team@brsw.kr Y N Y Y 기술영업본부(hanlla-tech-sales-hq@brsw.kr)
45 안전관리본부 0 hanlla-safety-hq@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
46 안전관리팀 0 hanlla-safety-team@brsw.kr Y N Y Y 안전관리본부(hanlla-safety-hq@brsw.kr)
47 시공현장 0 hanlla-construction-sites@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
48 부천시 굴포천 0 site-bucheon-gulpocheon@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
49 옥정 공공하수처리 0 site-okjeong-sewage@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
50 여주부평천 0 site-yeoju-bupyeongcheon@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
51 도척 실촌간 도로 0 site-docheok-silchon-road@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
52 광주공공폐수처리 0 site-gwangju-wastewater@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
53 아포공공하수처리 0 site-apo-sewage@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
54 장량공공하수처리 0 site-jangnyang-sewage@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
55 신천공공하수처리 0 site-sincheon-sewage@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
56 온산하수처리 0 site-onsan-sewage@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
57 수도권매립지 제2매립장 0 site-sudokwon-landfill-2@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
58 인천국제공항 화물 0 site-incheon-air-cargo@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
59 광탄공공하수처리 0 site-gwangtan-sewage@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
60 성남시생활폐기물처리 0 site-seongnam-waste@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
61 제주공공하수처리 0 site-jeju-sewage@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
62 인덕원 동탄 복선전철 제3공구 0 site-indeokwon-dongtan-3@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
63 인덕원 동탄 복선전철 제7공구 0 site-indeokwon-dongtan-7@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
64 경산시 국도대체 0 site-gyeongsan-road@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
65 수도권광역급행철도B 제4공구 0 site-gtx-b-4@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
66 부산항 신항 0 site-busan-new-port@brsw.kr Y N Y Y 시공현장(hanlla-construction-sites@brsw.kr)
67 운영사업소 0 hanlla-operation-sites@brsw.kr Y N Y Y 한라산업개발(hanlla@brsw.kr)
68 울산민자소각 0 ops-ulsan-incineration@brsw.kr Y N Y Y 운영사업소(hanlla-operation-sites@brsw.kr)
69 온산바이오 0 ops-onsan-bio@brsw.kr Y N Y Y 운영사업소(hanlla-operation-sites@brsw.kr)
70 안성제4차산업단지폐수처리 0 ops-anseong-wwtp@brsw.kr Y N Y Y 운영사업소(hanlla-operation-sites@brsw.kr)
71 서산시자원회수시설 0 ops-seosan-recovery@brsw.kr Y N Y Y 운영사업소(hanlla-operation-sites@brsw.kr)

View File

@@ -14,6 +14,7 @@ const port = Number.parseInt(process.env.PORT ?? "5173", 10);
const defaultBaseUrl = `http://127.0.0.1:${port}`;
const baseURL = process.env.BASE_URL ?? defaultBaseUrl;
const reuseExistingServer = !process.env.CI && !process.env.PORT;
const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
/**
* Read environment variables from file.
@@ -56,7 +57,12 @@ export default defineConfig({
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
use: {
...devices["Desktop Chrome"],
launchOptions: chromiumExecutablePath
? { executablePath: chromiumExecutablePath }
: undefined,
},
},
{

View File

@@ -36,10 +36,12 @@ if [ "${1:-}" = "--print-mode" ]; then
fi
ensure_frontend_dependencies() {
# If common workspace exists, manage dependencies from there
if [ -d /common ] && [ -f /common/package.json ]; then
WORKSPACE_DIR="/common"
LOCK_FILE="/common/pnpm-lock.yaml"
APP_WORKSPACE_FILTER="../adminfront"
# If common workspace exists, manage dependencies from the real workspace tree.
if [ -d /workspace/common ] && [ -f /workspace/common/package.json ]; then
WORKSPACE_DIR="/workspace/common"
LOCK_FILE="/workspace/common/pnpm-lock.yaml"
else
WORKSPACE_DIR="."
LOCK_FILE="package-lock.json"
@@ -59,9 +61,8 @@ ensure_frontend_dependencies() {
if [ "$installed_hash" != "$deps_hash" ]; then
echo "Installing frontend dependencies..."
if [ "$WORKSPACE_DIR" = "/common" ]; then
(cd /common && rm -rf node_modules .pnpm-store package-lock.json && npm install --no-workspaces --no-fund --no-audit)
if [ "$WORKSPACE_DIR" = "/workspace/common" ]; then
(cd /workspace/common && pnpm install --filter "${APP_WORKSPACE_FILTER}..." --frozen-lockfile --ignore-scripts)
else
npm ci
fi

View File

@@ -0,0 +1,23 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from "./dialog";
describe("Dialog FocusScope integration", () => {
it("mounts an open dialog without a ref update loop", () => {
render(
<Dialog open>
<DialogContent>
<DialogTitle>Focus scope check</DialogTitle>
<DialogDescription>Dialog content is mounted.</DialogDescription>
</DialogContent>
</Dialog>,
);
expect(screen.getByText("Focus scope check")).toBeInTheDocument();
});
});

View File

@@ -1,55 +1,221 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import * as React from "react";
import { createPortal } from "react-dom";
import { cn } from "../../lib/utils";
const Dialog = DialogPrimitive.Root;
type DialogContextValue = {
open: boolean;
setOpen: (open: boolean) => void;
};
const DialogTrigger = DialogPrimitive.Trigger;
const DialogContext = React.createContext<DialogContextValue | null>(null);
const DialogPortal = DialogPrimitive.Portal;
function useDialogContext(componentName: string) {
const context = React.useContext(DialogContext);
if (!context) {
throw new Error(`${componentName} must be used within Dialog`);
}
return context;
}
const DialogClose = DialogPrimitive.Close;
function composeEventHandlers<E extends React.SyntheticEvent>(
theirs: ((event: E) => void) | undefined,
ours: (event: E) => void,
) {
return (event: E) => {
theirs?.(event);
if (!event.defaultPrevented) {
ours(event);
}
};
}
type DialogProps = {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
children?: React.ReactNode;
};
function Dialog({
open,
defaultOpen = false,
onOpenChange,
children,
}: DialogProps) {
const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
const isControlled = open !== undefined;
const currentOpen = isControlled ? open : internalOpen;
const setOpen = React.useCallback(
(nextOpen: boolean) => {
if (!isControlled) {
setInternalOpen(nextOpen);
}
onOpenChange?.(nextOpen);
},
[isControlled, onOpenChange],
);
const value = React.useMemo(
() => ({ open: currentOpen, setOpen }),
[currentOpen, setOpen],
);
return (
<DialogContext.Provider value={value}>{children}</DialogContext.Provider>
);
}
type DialogTriggerProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
asChild?: boolean;
};
const DialogTrigger = React.forwardRef<HTMLButtonElement, DialogTriggerProps>(
({ asChild = false, children, onClick, ...props }, ref) => {
const { setOpen } = useDialogContext("DialogTrigger");
const handleOpen = (event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (!event.defaultPrevented) {
setOpen(true);
}
};
if (asChild && React.isValidElement(children)) {
const child = children as React.ReactElement<{
onClick?: React.MouseEventHandler<HTMLElement>;
}>;
return React.cloneElement(child, {
...props,
onClick: composeEventHandlers(
child.props.onClick as React.MouseEventHandler<HTMLButtonElement>,
() => setOpen(true),
),
});
}
return (
<button type="button" ref={ref} onClick={handleOpen} {...props}>
{children}
</button>
);
},
);
DialogTrigger.displayName = "DialogTrigger";
const DialogPortal = ({ children }: { children?: React.ReactNode }) => {
if (typeof document === "undefined") {
return null;
}
return createPortal(children, document.body);
};
DialogPortal.displayName = "DialogPortal";
const DialogClose = React.forwardRef<
HTMLButtonElement,
DialogTriggerProps
>(({ asChild = false, children, onClick, ...props }, ref) => {
const { setOpen } = useDialogContext("DialogClose");
const handleClose = (event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (!event.defaultPrevented) {
setOpen(false);
}
};
if (asChild && React.isValidElement(children)) {
const child = children as React.ReactElement<{
onClick?: React.MouseEventHandler<HTMLElement>;
}>;
return React.cloneElement(child, {
...props,
onClick: composeEventHandlers(
child.props.onClick as React.MouseEventHandler<HTMLButtonElement>,
() => setOpen(false),
),
});
}
return (
<button type="button" ref={ref} onClick={handleClose} {...props}>
{children}
</button>
);
});
DialogClose.displayName = "DialogClose";
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, onMouseDown, ...props }, ref) => {
const { setOpen } = useDialogContext("DialogOverlay");
return (
<div
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className,
)}
data-state="open"
onMouseDown={composeEventHandlers(onMouseDown, (event) => {
if (event.target === event.currentTarget) {
setOpen(false);
}
})}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
/>
);
});
DialogOverlay.displayName = "DialogOverlay";
const DialogContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, children, onKeyDown, ...props }, ref) => {
const { open, setOpen } = useDialogContext("DialogContent");
React.useEffect(() => {
if (!open) {
return;
}
const onDocumentKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setOpen(false);
}
};
document.addEventListener("keydown", onDocumentKeyDown);
return () => document.removeEventListener("keydown", onDocumentKeyDown);
}, [open, setOpen]);
if (!open) {
return null;
}
return (
<DialogPortal>
<DialogOverlay />
<div
ref={ref}
role="dialog"
aria-modal="true"
data-state="open"
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className,
)}
onKeyDown={onKeyDown}
{...props}
>
{children}
<DialogClose className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogClose>
</div>
</DialogPortal>
);
});
DialogContent.displayName = "DialogContent";
const DialogHeader = ({
className,
@@ -80,10 +246,10 @@ const DialogFooter = ({
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
<h2
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
@@ -92,19 +258,19 @@ const DialogTitle = React.forwardRef<
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
DialogTitle.displayName = "DialogTitle";
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
DialogDescription.displayName = "DialogDescription";
export {
Dialog,

View File

@@ -1,26 +1,68 @@
import * as SwitchPrimitives from "@radix-ui/react-switch";
import * as React from "react";
import { cn } from "../../lib/utils";
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-10 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent bg-input transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted/50",
interface SwitchProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onChange"> {
checked?: boolean;
defaultChecked?: boolean;
onCheckedChange?: (checked: boolean) => void;
}
const Switch = React.forwardRef<HTMLButtonElement, SwitchProps>(
(
{
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
)}
/>
</SwitchPrimitives.Root>
));
Switch.displayName = SwitchPrimitives.Root.displayName;
checked,
defaultChecked = false,
disabled,
onCheckedChange,
onClick,
...props
},
ref,
) => {
const isControlled = checked !== undefined;
const [internalChecked, setInternalChecked] =
React.useState(defaultChecked);
const currentChecked = isControlled ? checked : internalChecked;
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (event.defaultPrevented || disabled) {
return;
}
const nextChecked = !currentChecked;
if (!isControlled) {
setInternalChecked(nextChecked);
}
onCheckedChange?.(nextChecked);
};
return (
<button
type="button"
role="switch"
aria-checked={currentChecked}
data-state={currentChecked ? "checked" : "unchecked"}
className={cn(
"peer inline-flex h-5 w-10 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent bg-input transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-muted/50",
className,
)}
disabled={disabled}
onClick={handleClick}
ref={ref}
{...props}
>
<span
data-state={currentChecked ? "checked" : "unchecked"}
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0",
)}
/>
</button>
);
},
);
Switch.displayName = "Switch";
export { Switch };

View File

@@ -22,6 +22,17 @@ vi.mock("../../lib/adminApi", () => ({
})),
fetchAllTenants: vi.fn(async () => ({
items: [
{
id: "group-1",
type: "COMPANY_GROUP",
name: "한맥그룹",
slug: "hanmac-group",
description: "",
status: "active",
memberCount: 0,
createdAt: "2026-05-06T00:00:00Z",
updatedAt: "2026-05-06T00:00:00Z",
},
{
id: "company-1",
type: "COMPANY",
@@ -58,7 +69,7 @@ vi.mock("../../lib/adminApi", () => ({
],
limit: 1000,
offset: 0,
total: 3,
total: 4,
})),
fetchAdminRPUsageDaily: vi.fn(async () => ({
days: 14,
@@ -150,7 +161,7 @@ describe("admin overview and auth guard pages", () => {
renderWithProviders(<GlobalOverviewPage />);
expect(
await screen.findByText("회사별 앱별 로그인요청/기타 요청 현황"),
await screen.findByText("회사별 앱별 로그인 요청 현황"),
).toBeInTheDocument();
expect(
await screen.findByLabelText("일 단위 RP 요청 현황"),
@@ -168,7 +179,7 @@ describe("admin overview and auth guard pages", () => {
expect(
(await screen.findByText("전체 테넌트 수")).parentElement,
).toHaveTextContent("3");
).toHaveTextContent("4");
expect(screen.getByText("OIDC 클라이언트").parentElement).toHaveTextContent(
"3",
);
@@ -180,17 +191,30 @@ describe("admin overview and auth guard pages", () => {
);
});
it("changes the RP usage perspective and targets a permitted organization", async () => {
it("limits the overview graph choices to company tenants", async () => {
renderWithProviders(<GlobalOverviewPage />);
await screen.findByText("회사별 앱별 로그인요청/기타 요청 현황");
await screen.findByText("회사별 앱별 로그인 요청 현황");
expect(
await screen.findByRole("checkbox", { name: "한맥 (hanmac)" }),
).toBeInTheDocument();
expect(
screen.queryByText("한맥그룹 (hanmac-group)"),
).not.toBeInTheDocument();
expect(screen.queryByText("개발팀 (dev-team)")).not.toBeInTheDocument();
expect(screen.queryByText("개인 (personal)")).not.toBeInTheDocument();
});
it("changes the RP usage perspective and targets a permitted company", async () => {
renderWithProviders(<GlobalOverviewPage />);
await screen.findByText("회사별 앱별 로그인 요청 현황");
fireEvent.click(screen.getByRole("button", { name: "주" }));
expect(await screen.findAllByText("19(05월1주)")).not.toHaveLength(0);
expect(await screen.findAllByText("40(10월1주)")).not.toHaveLength(0);
fireEvent.click(screen.getByRole("button", { name: "월" }));
fireEvent.click(
screen.getByRole("checkbox", { name: "개발팀 (dev-team)" }),
);
fireEvent.click(screen.getByRole("checkbox", { name: "한맥 (hanmac)" }));
await waitFor(() => {
expect(fetchAdminRPUsageDaily).toHaveBeenLastCalledWith({
@@ -198,6 +222,10 @@ describe("admin overview and auth guard pages", () => {
period: "month",
});
});
expect(
screen.queryByText("한맥그룹 (hanmac-group)"),
).not.toBeInTheDocument();
expect(screen.queryByText("개발팀 (dev-team)")).not.toBeInTheDocument();
expect(screen.queryByText("개인 (personal)")).not.toBeInTheDocument();
expect(await screen.findAllByText("05월")).not.toHaveLength(0);
});
@@ -217,7 +245,7 @@ describe("admin overview and auth guard pages", () => {
renderWithProviders(<GlobalOverviewPage />);
await screen.findByText("회사별 앱별 로그인요청/기타 요청 현황");
await screen.findByText("회사별 앱별 로그인 요청 현황");
expect(screen.queryByText("정합성 최종 검증")).not.toBeInTheDocument();
expect(fetchDataIntegrityReport).not.toHaveBeenCalled();
});

View File

@@ -9,6 +9,11 @@ import {
Users,
} from "lucide-react";
import { type ReactNode, useMemo, useState } from "react";
import {
OverviewAxisNotes,
OverviewMetric,
OverviewSelectionChips,
} from "../../../../common/core/components/overview";
import { RoleGuard } from "../../components/auth/RoleGuard";
import {
type DataIntegrityStatus,
@@ -21,11 +26,6 @@ import {
fetchDataIntegrityReport,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import {
OverviewAxisNotes,
OverviewMetric,
OverviewSelectionChips,
} from "../../../../common/core/components/overview";
type DailyPoint = {
date: string;
@@ -72,7 +72,10 @@ function summarizeSeries(rows: RPUsageDailyMetric[]): SeriesSummary[] {
uniqueSubjects: 0,
} satisfies SeriesSummary);
current.loginRequests += row.loginRequests;
current.uniqueSubjects = Math.max(current.uniqueSubjects, row.uniqueSubjects);
current.uniqueSubjects = Math.max(
current.uniqueSubjects,
row.uniqueSubjects,
);
bySeries.set(key, current);
}
return Array.from(bySeries.values())
@@ -200,10 +203,7 @@ function IntegrityOverviewSummary() {
<AlertTriangle size={18} className="text-amber-600" />
)}
<h3 className="text-base font-semibold">
{t(
"ui.admin.integrity.summary.title",
"정합성 최종 검증",
)}
{t("ui.admin.integrity.summary.title", "정합성 최종 검증")}
</h3>
</div>
<div className="flex flex-wrap items-center gap-3 text-sm">
@@ -213,11 +213,9 @@ function IntegrityOverviewSummary() {
{integrityStatusText(data.status)}
</span>
<span className="tabular-nums">
{t(
"ui.admin.integrity.summary.failures_text",
"실패 {{count}}건",
{ count: data.summary.failures },
)}
{t("ui.admin.integrity.summary.failures_text", "실패 {{count}}건", {
count: data.summary.failures,
})}
</span>
<span className="text-muted-foreground">
{formatOverviewDateTime(data.checkedAt)}
@@ -303,7 +301,7 @@ function RPUsageMixedChart({
<p className="text-sm text-muted-foreground">
{t(
"ui.admin.overview.chart.description",
"전체 또는 선택한 조직 기준으로 그래프를 확인합니다.",
"전체 또는 선택한 회사 기준으로 그래프를 확인합니다.",
)}
</p>
</div>
@@ -397,17 +395,20 @@ function RPUsageMixedChart({
))}
</svg>
</div>
<OverviewAxisNotes
xAxisLabel={t("ui.common.chart.axis.x", "X축: 기간")}
yAxisLabel={t("ui.common.chart.axis.y", "Y축: 로그인 요청 수")}
/>
<OverviewAxisNotes
xAxisLabel={t("ui.common.chart.axis.x", "X축: 기간")}
yAxisLabel={t("ui.common.chart.axis.y", "Y축: 로그인 요청 수")}
/>
</div>
)}
{series.length > 0 && (
<div className="grid gap-x-6 gap-y-2 border-t border-border/60 pt-2 text-xs md:grid-cols-2 xl:grid-cols-3">
{series.map((item) => (
<div key={item.key} className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1">
<div
key={item.key}
className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1"
>
<span className="font-medium">{item.clientLabel}</span>
<span className="whitespace-nowrap tabular-nums text-muted-foreground">
{t(
@@ -423,7 +424,6 @@ function RPUsageMixedChart({
))}
</div>
)}
</section>
);
}
@@ -444,7 +444,7 @@ function GlobalOverviewPage() {
});
const tenantOptions = useMemo(() => {
return (tenantsQuery.data?.items ?? []).filter(
(tenant) => tenant.type === "COMPANY" || tenant.type === "ORGANIZATION",
(tenant) => tenant.type === "COMPANY",
);
}, [tenantsQuery.data?.items]);
const usageQuery = useQuery({
@@ -582,7 +582,7 @@ function GlobalOverviewPage() {
<p className="text-sm text-muted-foreground">
{t(
"ui.admin.overview.chart.description",
"전체 또는 선택한 조직 기준으로 그래프를 확인합니다.",
"전체 또는 선택한 회사 기준으로 그래프를 확인합니다.",
)}
</p>
</div>

View File

@@ -0,0 +1,21 @@
import type { TenantSummary } from "../../../lib/adminApi";
const companyParentTypes = new Set(["COMPANY", "COMPANY_GROUP"]);
export function filterParentTenants(
tenants: TenantSummary[],
search: string,
companyOnly: boolean,
excludeTenantId = "",
) {
const normalizedSearch = search.trim().toLowerCase();
return tenants.filter((tenant) => {
if (excludeTenantId && tenant.id === excludeTenantId) return false;
if (companyOnly && !companyParentTypes.has(tenant.type)) return false;
if (!normalizedSearch) return true;
return [tenant.name, tenant.slug, tenant.type]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(normalizedSearch));
});
}

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { TenantSummary } from "../../../lib/adminApi";
import { filterParentTenants } from "./ParentTenantSelector";
import { filterParentTenants } from "./ParentTenantSelector.helpers";
const tenants: TenantSummary[] = [
{

View File

@@ -16,6 +16,7 @@ import {
buildAuthenticatedOrgChartTenantPickerUrl,
parseOrgChartTenantSelection,
} from "../../users/orgChartPicker";
import { filterParentTenants } from "./ParentTenantSelector.helpers";
type ParentTenantSelectorProps = {
id: string;
@@ -33,26 +34,6 @@ type ParentTenantSelectorProps = {
localTenantFilter?: (tenant: TenantSummary) => boolean;
};
const companyParentTypes = new Set(["COMPANY", "COMPANY_GROUP"]);
export function filterParentTenants(
tenants: TenantSummary[],
search: string,
companyOnly: boolean,
excludeTenantId = "",
) {
const normalizedSearch = search.trim().toLowerCase();
return tenants.filter((tenant) => {
if (excludeTenantId && tenant.id === excludeTenantId) return false;
if (companyOnly && !companyParentTypes.has(tenant.type)) return false;
if (!normalizedSearch) return true;
return [tenant.name, tenant.slug, tenant.type]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(normalizedSearch));
});
}
export function ParentTenantSelector({
id,
label,

View File

@@ -0,0 +1,7 @@
export function canShowWorksmobileEntry(tenant?: {
id?: string;
slug?: string;
parentId?: string | null;
}) {
return tenant?.slug === "hanmac-family" && !tenant.parentId;
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { canShowWorksmobileEntry } from "./TenantDetailPage";
import { canShowWorksmobileEntry } from "./TenantDetailPage.helpers";
describe("TenantDetailPage Worksmobile entry visibility", () => {
it("shows Worksmobile entry only for hanmac-family root tenant", () => {

View File

@@ -6,14 +6,7 @@ import { Button } from "../../../components/ui/button";
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { normalizeAdminRole } from "../../../lib/roles";
export function canShowWorksmobileEntry(tenant?: {
id?: string;
slug?: string;
parentId?: string | null;
}) {
return tenant?.slug === "hanmac-family" && !tenant.parentId;
}
import { canShowWorksmobileEntry } from "./TenantDetailPage.helpers";
function TenantDetailPage() {
const params = useParams<{ tenantId: string }>();

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import type { TenantSummary } from "../../../lib/adminApi";
import {
filterTenantsByScope,
getTenantViewRows,
resolveTenantSelectionIds,
tenantMatchesListSearch,
} from "./tenantListView";
function tenant(
id: string,
name: string,
slug: string,
parentId?: string,
): TenantSummary {
return {
id,
name,
slug,
parentId,
type: parentId ? "ORGANIZATION" : "COMPANY",
description: "",
status: "active",
memberCount: 0,
createdAt: "",
updatedAt: "",
};
}
const tenants = [
tenant("company-1", "한맥기술", "hanmac"),
tenant("dept-1", "기술기획", "planning", "company-1"),
tenant("team-1", "플랫폼팀", "platform", "dept-1"),
tenant("company-2", "삼안", "saman"),
];
describe("TenantListPage tenant list helpers", () => {
it("selects a parent tenant together with every descendant", () => {
expect(
resolveTenantSelectionIds({
currentIds: [],
tenant: tenants[0],
checked: true,
tenants,
deletableTenants: tenants,
}),
).toEqual(["company-1", "dept-1", "team-1"]);
});
it("removes a parent tenant together with every descendant", () => {
expect(
resolveTenantSelectionIds({
currentIds: ["company-1", "dept-1", "team-1", "company-2"],
tenant: tenants[0],
checked: false,
tenants,
deletableTenants: tenants,
}),
).toEqual(["company-2"]);
});
it("filters to descendants of the selected scope tenant", () => {
expect(
filterTenantsByScope(tenants, "company-1").map((item) => item.id),
).toEqual(["dept-1", "team-1"]);
});
it("searches tenants by name, slug, and UUID", () => {
expect(tenantMatchesListSearch(tenants[2], "team-1")).toBe(true);
expect(tenantMatchesListSearch(tenants[2], "platform")).toBe(true);
expect(tenantMatchesListSearch(tenants[2], "플랫폼")).toBe(true);
});
it("can return tree rows or same-level table rows", () => {
expect(getTenantViewRows(tenants, "tree").map((row) => row.depth)).toEqual([
0, 1, 2, 0,
]);
expect(getTenantViewRows(tenants, "table").map((row) => row.depth)).toEqual(
[0, 0, 0, 0],
);
});
});

View File

@@ -104,8 +104,10 @@ import { t } from "../../../lib/i18n";
import { normalizeAdminRole } from "../../../lib/roles";
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
import {
buildAuthenticatedOrgChartTenantPickerUrl,
filterNonHanmacFamilyTenants,
isHanmacFamilyUser,
parseOrgChartTenantSelection,
} from "../../users/orgChartPicker";
import { isSeedTenant } from "../utils/protectedTenants";
import {
@@ -117,6 +119,14 @@ import {
parseTenantCSV,
serializeTenantImportCSV,
} from "../utils/tenantCsvImport";
import {
type TenantViewMode,
type TenantViewRow,
filterTenantsByScope,
getTenantViewRows,
resolveTenantSelectionIds,
tenantMatchesListSearch,
} from "./tenantListView";
const tenantCSVTemplate =
"name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type\n";
@@ -266,6 +276,9 @@ function TenantListPage() {
const navigate = useNavigate();
const [selectedIds, setSelectedIds] = React.useState<string[]>([]);
const [search, setSearch] = React.useState("");
const [viewMode, setViewMode] = React.useState<TenantViewMode>("tree");
const [scopeTenantId, setScopeTenantId] = React.useState("");
const [scopePickerOpen, setScopePickerOpen] = React.useState(false);
const [sortConfig, setSortConfig] =
React.useState<SortConfig<TenantSortKey> | null>({
key: "createdAt",
@@ -470,8 +483,14 @@ function TenantListPage() {
? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.")
: null;
const tenantPages = query.data?.pages ?? [];
const rawTenants = tenantPages.flatMap((page) => page.items);
const tenantPages = React.useMemo(
() => query.data?.pages ?? [],
[query.data?.pages],
);
const rawTenants = React.useMemo(
() => tenantPages.flatMap((page) => page.items),
[tenantPages],
);
const tenantTotal = tenantPages[0]?.total ?? 0;
const hanmacFamilyTenantId = React.useMemo(() => {
const envTenantId = import.meta.env.VITE_HANMAC_FAMILY_TENANT_ID;
@@ -492,6 +511,18 @@ function TenantListPage() {
}
return filterNonHanmacFamilyTenants(rawTenants, hanmacFamilyTenantId);
}, [hanmacFamilyTenantId, profile, profileRole, rawTenants]);
const scopedTenants = React.useMemo(
() => filterTenantsByScope(allTenants, scopeTenantId),
[allTenants, scopeTenantId],
);
const selectedScopeTenant = React.useMemo(
() => allTenants.find((tenant) => tenant.id === scopeTenantId),
[allTenants, scopeTenantId],
);
const scopePickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
import.meta.env.ORGFRONT_URL,
hanmacFamilyTenantId ? { tenantId: hanmacFamilyTenantId } : {},
);
const importParentOptionGroups =
buildTenantImportParentOptionGroups(allTenants);
@@ -511,10 +542,37 @@ function TenantListPage() {
};
const deletableTenants = React.useMemo(
() => allTenants.filter((tenant) => !isSeedTenant(tenant)),
[allTenants],
() => scopedTenants.filter((tenant) => !isSeedTenant(tenant)),
[scopedTenants],
);
React.useEffect(() => {
const selectableIds = new Set(deletableTenants.map((tenant) => tenant.id));
setSelectedIds((prev) => {
const next = prev.filter((id) => selectableIds.has(id));
if (next.length === prev.length) {
return prev;
}
return next;
});
}, [deletableTenants]);
React.useEffect(() => {
if (!scopePickerOpen) return;
const onMessage = (event: MessageEvent) => {
const selection = parseOrgChartTenantSelection(event.data);
if (!selection) return;
if (!allTenants.some((tenant) => tenant.id === selection.id)) return;
setScopeTenantId(selection.id);
setScopePickerOpen(false);
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [allTenants, scopePickerOpen]);
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedIds(deletableTenants.map((t) => t.id));
@@ -527,11 +585,15 @@ function TenantListPage() {
if (isSeedTenant(tenant)) {
return;
}
if (checked) {
setSelectedIds((prev) => [...prev, tenant.id]);
} else {
setSelectedIds((prev) => prev.filter((i) => i !== tenant.id));
}
setSelectedIds((prev) =>
resolveTenantSelectionIds({
currentIds: prev,
tenant,
checked,
tenants: allTenants,
deletableTenants,
}),
);
};
const handleDeleteBulk = () => {
@@ -701,13 +763,67 @@ function TenantListPage() {
<Input
placeholder={t(
"ui.admin.tenants.list.search_placeholder",
"테넌트 이름 또는 슬러그 검색...",
"테넌트 이름, 슬러그, UUID 검색...",
)}
className="pl-9 h-9"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div
className="flex rounded-md border bg-background p-0.5"
data-testid="tenant-view-mode-toggle"
>
<Button
type="button"
variant={viewMode === "tree" ? "default" : "ghost"}
size="sm"
className="h-8 gap-1.5"
onClick={() => setViewMode("tree")}
data-testid="tenant-view-tree-btn"
>
<Network size={14} />
{t("ui.admin.tenants.view.tree", "트리")}
</Button>
<Button
type="button"
variant={viewMode === "table" ? "default" : "ghost"}
size="sm"
className="h-8 gap-1.5"
onClick={() => setViewMode("table")}
data-testid="tenant-view-table-btn"
>
<List size={14} />
{t("ui.admin.tenants.view.table", "평면")}
</Button>
</div>
<Button
type="button"
variant={scopeTenantId ? "default" : "outline"}
size="sm"
className="h-9 gap-2"
onClick={() => setScopePickerOpen(true)}
data-testid="tenant-scope-picker-btn"
>
<Network size={16} />
{selectedScopeTenant
? t("ui.admin.tenants.scope.active", "{{name}} 하위", {
name: selectedScopeTenant.name,
})
: t("ui.admin.tenants.scope.pick", "상위 범위 선택")}
</Button>
{scopeTenantId && (
<Button
type="button"
variant="ghost"
size="sm"
className="h-9"
onClick={() => setScopeTenantId("")}
data-testid="tenant-scope-clear-btn"
>
{t("ui.common.clear", "초기화")}
</Button>
)}
<RoleGuard roles={["super_admin"]}>
<input
@@ -818,7 +934,7 @@ function TenantListPage() {
"msg.admin.tenants.registry.count",
"총 {{count}}개 테넌트",
{
count: tenantTotal,
count: scopeTenantId ? scopedTenants.length : tenantTotal,
},
)}
</CardDescription>
@@ -846,10 +962,34 @@ function TenantListPage() {
sortConfig={sortConfig}
requestSort={requestSort}
getSortIcon={getSortIcon}
viewMode={viewMode}
scopeTenantId={scopeTenantId}
/>
</CardContent>
</Card>
<Dialog open={scopePickerOpen} onOpenChange={setScopePickerOpen}>
<DialogContent className="max-w-[480px] p-4">
<DialogHeader>
<DialogTitle>
{t("ui.admin.tenants.scope.pick", "상위 범위 선택")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.tenants.scope.description",
"orgfront 조직 선택기에서 상위 테넌트를 선택하면 해당 하위 테넌트만 표시합니다.",
)}
</DialogDescription>
</DialogHeader>
<iframe
title={t("ui.admin.tenants.scope.pick", "상위 범위 선택")}
src={scopePickerUrl}
className="h-[600px] w-full rounded-md border"
data-testid="tenant-scope-picker-frame"
/>
</DialogContent>
</Dialog>
{/* Bulk Action Bar */}
{selectedIds.length > 0 && (
<div
@@ -1212,6 +1352,8 @@ const TenantHierarchyView: React.FC<{
sortConfig: SortConfig<TenantSortKey> | null;
requestSort: (key: TenantSortKey) => void;
getSortIcon: (key: TenantSortKey) => React.ReactNode;
viewMode: TenantViewMode;
scopeTenantId: string;
}> = ({
tenants,
selectedIds,
@@ -1226,10 +1368,12 @@ const TenantHierarchyView: React.FC<{
sortConfig,
requestSort,
getSortIcon,
viewMode,
scopeTenantId,
}) => {
const { subTree } = React.useMemo(
() => buildTenantFullTree(tenants),
[tenants],
() => buildTenantFullTree(tenants, scopeTenantId || undefined),
[scopeTenantId, tenants],
);
// Initial expanded state: everything open
@@ -1245,6 +1389,18 @@ const TenantHierarchyView: React.FC<{
return ids;
});
React.useEffect(() => {
const ids = new Set<string>();
const collect = (nodes: TenantNode[]) => {
for (const n of nodes) {
ids.add(n.id);
if (n.children) collect(n.children);
}
};
collect(subTree);
setExpandedIds((prev) => new Set([...prev, ...ids]));
}, [subTree]);
const toggleExpand = (id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
@@ -1267,7 +1423,17 @@ const TenantHierarchyView: React.FC<{
);
const flattenedRows = React.useMemo(() => {
const result: (TenantNode & { depth: number })[] = [];
if (viewMode === "table") {
return sortItems(
getTenantViewRows(tenants, "table", scopeTenantId).filter((tenant) =>
tenantMatchesListSearch(tenant, search),
),
sortConfig,
tenantSortResolvers,
);
}
const result: TenantViewRow[] = [];
const term = search.toLowerCase().trim();
// When searching, we show matched nodes and all their ancestors.
@@ -1275,10 +1441,7 @@ const TenantHierarchyView: React.FC<{
if (term) {
const findMatches = (nodes: TenantNode[]) => {
for (const node of nodes) {
if (
node.name.toLowerCase().includes(term) ||
node.slug.toLowerCase().includes(term)
) {
if (tenantMatchesListSearch(node, term)) {
matchedIds.add(node.id);
}
if (node.children) findMatches(node.children);
@@ -1312,7 +1475,24 @@ const TenantHierarchyView: React.FC<{
};
collect(subTree, 0);
return result;
}, [subTree, expandedIds, search, sortConfig, tenantSortResolvers]);
}, [
expandedIds,
scopeTenantId,
search,
sortConfig,
subTree,
tenantSortResolvers,
tenants,
viewMode,
]);
const visibleSelectableIds = React.useMemo(
() => new Set(deletableTenants.map((tenant) => tenant.id)),
[deletableTenants],
);
const visibleSelectedCount = selectedIds.filter((id) =>
visibleSelectableIds.has(id),
).length;
return (
<div className="flex-1 rounded-md border overflow-hidden flex flex-col mt-4">
@@ -1324,7 +1504,7 @@ const TenantHierarchyView: React.FC<{
<Checkbox
checked={
deletableTenants.length > 0 &&
selectedIds.length === deletableTenants.length
visibleSelectedCount === deletableTenants.length
}
onCheckedChange={(checked) => onSelectAll(!!checked)}
/>
@@ -1409,8 +1589,12 @@ const TenantHierarchyView: React.FC<{
</TableRow>
)}
{flattenedRows.map((node) => {
const hasChildren = node.children && node.children.length > 0;
const isExpanded = expandedIds.has(node.id) || !!search;
const hasChildren =
viewMode === "tree" &&
node.children &&
node.children.length > 0;
const isExpanded =
viewMode === "tree" && (expandedIds.has(node.id) || !!search);
const TypeIcon = getTenantIcon(node.type);
return (

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { createSchemaField, normalizeSchemaField } from "./TenantSchemaPage";
import { createSchemaField, normalizeSchemaField } from "./tenantSchemaFields";
describe("TenantSchemaPage schema field helpers", () => {
it("creates text fields without varchar maxLength policy", () => {

View File

@@ -17,81 +17,12 @@ import { toast } from "../../../components/ui/use-toast";
import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { normalizeAdminRole } from "../../../lib/roles";
export type SchemaFieldType =
| "text"
| "number"
| "boolean"
| "date"
| "float"
| "datetime";
export type SchemaField = {
id: string;
key: string;
label: string;
type: SchemaFieldType;
required: boolean;
adminOnly: boolean;
validation?: string;
unsigned?: boolean;
isLoginId?: boolean;
indexed?: boolean;
};
function createFieldId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function isSchemaFieldType(value: unknown): value is SchemaFieldType {
return (
value === "text" ||
value === "number" ||
value === "boolean" ||
value === "date" ||
value === "float" ||
value === "datetime"
);
}
export function normalizeSchemaField(field: unknown): SchemaField {
const source =
typeof field === "object" && field !== null
? (field as Record<string, unknown>)
: {};
const type = isSchemaFieldType(source.type) ? source.type : "text";
const isLoginId = Boolean(source.isLoginId);
return {
id: typeof source.id === "string" ? source.id : createFieldId(),
key: typeof source.key === "string" ? source.key : "",
label: typeof source.label === "string" ? source.label : "",
type,
required: Boolean(source.required),
adminOnly: Boolean(source.adminOnly),
validation: typeof source.validation === "string" ? source.validation : "",
unsigned: Boolean(source.unsigned),
isLoginId,
indexed: isLoginId || Boolean(source.indexed),
};
}
export function createSchemaField(): SchemaField {
return {
id: createFieldId(),
key: "",
label: "",
type: "text",
required: false,
adminOnly: false,
validation: "",
unsigned: false,
indexed: false,
};
}
import {
type SchemaField,
createSchemaField,
isSchemaFieldType,
normalizeSchemaField,
} from "./tenantSchemaFields";
export function TenantSchemaPage() {
const { tenantId } = useParams<{ tenantId: string }>();

View File

@@ -4,17 +4,21 @@ import {
canCreateWorksmobileRow,
canOpenWorksmobilePasswordManage,
canSelectWorksmobileRow,
filterVisibleWorksmobileComparisonRows,
filterWorksmobileComparisonRows,
filterWorksmobileComparisonRowsBySearch,
formatWorksmobileOrgDetails,
formatWorksmobilePersonName,
getDefaultWorksmobileComparisonColumns,
getWorksmobileComparisonStatusLabel,
getWorksmobileRowSelectionKey,
getWorksmobileSelectedActionIds,
getWorksmobileSelectedMissingExternalKeyOrgUnitIds,
getWorksmobileSelectedWorksOnlyOrgUnitIds,
isImmutableWorksmobileAccount,
summarizeWorksmobileComparison,
userFilterOptions,
} from "./TenantWorksmobilePage";
} from "./worksmobileComparison";
describe("TenantWorksmobilePage comparison helpers", () => {
it("summarizes comparison rows by status", () => {
@@ -143,6 +147,42 @@ describe("TenantWorksmobilePage comparison helpers", () => {
).toBe(false);
});
it("hides protected WORKS member accounts from comparison lists", () => {
const rows = [
{
resourceType: "USER",
status: "missing_in_baron",
worksmobileEmail: "su-@samaneng.com",
worksmobileId: "works-su",
},
{
resourceType: "USER",
status: "matched",
baronEmail: "CYHAN1@HANMACENG.CO.KR",
baronId: "baron-cyhan1",
worksmobileEmail: "cyhan1@hanmaceng.co.kr",
worksmobileId: "works-cyhan1",
},
{
resourceType: "USER",
status: "missing_in_baron",
worksmobileEmail: "normal@samaneng.com",
worksmobileId: "works-normal",
},
{
resourceType: "GROUP",
status: "missing_in_baron",
worksmobileEmail: "su-@samaneng.com",
worksmobileId: "works-group",
},
];
expect(filterVisibleWorksmobileComparisonRows(rows)).toEqual([
rows[2],
rows[3],
]);
});
it("keeps row selection keys separate from Baron action ids", () => {
const rows = [
{
@@ -231,7 +271,8 @@ describe("TenantWorksmobilePage comparison helpers", () => {
expect(
filterWorksmobileComparisonRows(rows, ["baron_only", "works_only"]),
).toEqual([rows[0], rows[1], rows[3]]);
expect(filterWorksmobileComparisonRows(rows, [])).toEqual(rows);
expect(filterWorksmobileComparisonRows(rows, [], true)).toEqual([]);
expect(filterWorksmobileComparisonRows(rows, [])).toEqual([]);
expect(
filterWorksmobileComparisonRows(rows, [
"baron_only",
@@ -239,6 +280,147 @@ describe("TenantWorksmobilePage comparison helpers", () => {
"matched",
]),
).toEqual(rows);
expect(
filterWorksmobileComparisonRows(
rows,
["baron_only", "works_only", "matched"],
true,
),
).toEqual([rows[0], rows[2], rows[3]]);
});
it("narrows works-only rows to missing external key rows from the detail filter", () => {
const rows = [
{
resourceType: "GROUP",
status: "missing_in_worksmobile",
baronId: "baron-only",
baronName: "Baron only",
},
{
resourceType: "GROUP",
status: "missing_in_baron",
worksmobileId: "works-only",
worksmobileName: "WORKS only",
},
{
resourceType: "GROUP",
status: "missing_external_key",
worksmobileId: "missing-external-key",
},
{
resourceType: "GROUP",
status: "matched",
baronId: "matched",
worksmobileId: "works-matched",
},
];
expect(
filterWorksmobileComparisonRows(rows, ["works_only"], false),
).toEqual([rows[1], rows[2]]);
expect(filterWorksmobileComparisonRows(rows, ["works_only"], true)).toEqual(
[rows[2]],
);
expect(filterWorksmobileComparisonRows(rows, [], true)).toEqual([]);
expect(filterWorksmobileComparisonRows(rows, ["baron_only"], true)).toEqual(
[rows[0]],
);
});
it("filters comparison rows by names and identifiers in real time", () => {
const rows = [
{
resourceType: "USER",
status: "matched",
baronId: "baron-user-uuid",
baronName: "홍길동",
worksmobileName: "Hong Gildong",
},
{
resourceType: "GROUP",
status: "missing_external_key",
worksmobileId: "works-org-uuid",
worksmobileName: "기술연구소",
worksmobileParentName: "한맥가족",
},
{
resourceType: "GROUP",
status: "missing_in_worksmobile",
baronId: "baron-org-uuid",
baronSlug: "baron-group-design",
baronName: "디자인팀",
},
];
expect(filterWorksmobileComparisonRowsBySearch(rows, "")).toEqual(rows);
expect(filterWorksmobileComparisonRowsBySearch(rows, "홍길동")).toEqual([
rows[0],
]);
expect(filterWorksmobileComparisonRowsBySearch(rows, "WORKS-ORG")).toEqual([
rows[1],
]);
expect(filterWorksmobileComparisonRowsBySearch(rows, "design")).toEqual([
rows[2],
]);
expect(filterWorksmobileComparisonRowsBySearch(rows, "없음")).toEqual([]);
});
it("returns only selected missing-external-key WORKS orgunit ids for delete", () => {
const rows = [
{
resourceType: "GROUP",
status: "missing_external_key",
worksmobileId: "works-missing-key",
},
{
resourceType: "GROUP",
status: "missing_in_baron",
worksmobileId: "works-only",
},
{
resourceType: "USER",
status: "missing_external_key",
worksmobileId: "works-user-missing-key",
},
];
expect(
getWorksmobileSelectedMissingExternalKeyOrgUnitIds(rows, [
getWorksmobileRowSelectionKey(rows[0]),
getWorksmobileRowSelectionKey(rows[1]),
getWorksmobileRowSelectionKey(rows[2]),
]),
).toEqual(["works-missing-key"]);
});
it("returns selected WORKS-only orgunit ids for Baron SSOT cleanup", () => {
const rows = [
{
resourceType: "GROUP",
status: "missing_external_key",
worksmobileId: "works-missing-key",
},
{
resourceType: "GROUP",
status: "missing_in_baron",
worksmobileId: "works-only",
externalKey: "legacy-external-key",
},
{
resourceType: "GROUP",
status: "matched",
baronId: "baron-matched",
worksmobileId: "works-matched",
},
];
expect(
getWorksmobileSelectedWorksOnlyOrgUnitIds(
rows,
rows.map(getWorksmobileRowSelectionKey),
),
).toEqual(["works-missing-key", "works-only"]);
});
it("orders user comparison filter options from Baron-only first", () => {

View File

@@ -0,0 +1,126 @@
import type { TenantSummary } from "../../../lib/adminApi";
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
export type TenantViewMode = "tree" | "table";
export type TenantViewRow = TenantNode & { depth: number };
export function tenantMatchesListSearch(
tenant: Pick<TenantSummary, "id" | "name" | "slug" | "type">,
search: string,
) {
const normalizedSearch = search.trim().toLowerCase();
if (!normalizedSearch) return true;
return [tenant.name, tenant.slug, tenant.id, tenant.type]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(normalizedSearch));
}
function collectTenantTreeRows(
nodes: TenantNode[],
depth: number,
rows: TenantViewRow[],
) {
for (const node of nodes) {
rows.push({ ...node, depth });
collectTenantTreeRows(node.children, depth + 1, rows);
}
}
function collectTenantDescendantIds(
tenantId: string,
tenants: TenantSummary[],
) {
const childrenByParent = new Map<string, TenantSummary[]>();
for (const tenant of tenants) {
if (!tenant.parentId) continue;
const children = childrenByParent.get(tenant.parentId) ?? [];
children.push(tenant);
childrenByParent.set(tenant.parentId, children);
}
const ids: string[] = [];
const visitedIds = new Set<string>();
const visit = (parentId: string) => {
for (const child of childrenByParent.get(parentId) ?? []) {
if (visitedIds.has(child.id)) continue;
visitedIds.add(child.id);
ids.push(child.id);
visit(child.id);
}
};
visit(tenantId);
return ids;
}
export function filterTenantsByScope(
tenants: TenantSummary[],
scopeTenantId: string,
) {
if (!scopeTenantId) return tenants;
const descendantIds = new Set(
collectTenantDescendantIds(scopeTenantId, tenants),
);
return tenants.filter((tenant) => descendantIds.has(tenant.id));
}
export function getTenantViewRows(
tenants: TenantSummary[],
viewMode: TenantViewMode,
scopeTenantId = "",
): TenantViewRow[] {
const { subTree } = buildTenantFullTree(tenants, scopeTenantId || undefined);
const treeRows: TenantViewRow[] = [];
collectTenantTreeRows(subTree, 0, treeRows);
if (viewMode === "tree") {
return treeRows;
}
const rowsById = new Map(treeRows.map((row) => [row.id, row]));
const flatSource = scopeTenantId
? filterTenantsByScope(tenants, scopeTenantId)
: tenants;
return flatSource.map((tenant) => ({
...(rowsById.get(tenant.id) ?? {
...tenant,
children: [],
recursiveMemberCount: Number(tenant.memberCount) || 0,
}),
depth: 0,
}));
}
export function resolveTenantSelectionIds({
currentIds,
tenant,
checked,
tenants,
deletableTenants,
}: {
currentIds: string[];
tenant: TenantSummary;
checked: boolean;
tenants: TenantSummary[];
deletableTenants: TenantSummary[];
}) {
const allowedIds = new Set(deletableTenants.map((item) => item.id));
const targetIds = [
tenant.id,
...collectTenantDescendantIds(tenant.id, tenants),
].filter((id) => allowedIds.has(id));
const next = new Set(currentIds.filter((id) => allowedIds.has(id)));
if (checked) {
for (const id of targetIds) {
next.add(id);
}
} else {
for (const id of targetIds) {
next.delete(id);
}
}
return Array.from(next);
}

View File

@@ -0,0 +1,74 @@
export type SchemaFieldType =
| "text"
| "number"
| "boolean"
| "date"
| "float"
| "datetime";
export type SchemaField = {
id: string;
key: string;
label: string;
type: SchemaFieldType;
required: boolean;
adminOnly: boolean;
validation?: string;
unsigned?: boolean;
isLoginId?: boolean;
indexed?: boolean;
};
function createFieldId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export function isSchemaFieldType(value: unknown): value is SchemaFieldType {
return (
value === "text" ||
value === "number" ||
value === "boolean" ||
value === "date" ||
value === "float" ||
value === "datetime"
);
}
export function normalizeSchemaField(field: unknown): SchemaField {
const source =
typeof field === "object" && field !== null
? (field as Record<string, unknown>)
: {};
const type = isSchemaFieldType(source.type) ? source.type : "text";
const isLoginId = Boolean(source.isLoginId);
return {
id: typeof source.id === "string" ? source.id : createFieldId(),
key: typeof source.key === "string" ? source.key : "",
label: typeof source.label === "string" ? source.label : "",
type,
required: Boolean(source.required),
adminOnly: Boolean(source.adminOnly),
validation: typeof source.validation === "string" ? source.validation : "",
unsigned: Boolean(source.unsigned),
isLoginId,
indexed: isLoginId || Boolean(source.indexed),
};
}
export function createSchemaField(): SchemaField {
return {
id: createFieldId(),
key: "",
label: "",
type: "text",
required: false,
adminOnly: false,
validation: "",
unsigned: false,
indexed: false,
};
}

View File

@@ -0,0 +1,359 @@
import type { WorksmobileComparisonItem } from "../../../lib/adminApi";
export type WorksmobileComparisonFilter =
| "works_only"
| "baron_only"
| "matched";
export type WorksmobileComparisonSummary = {
total: number;
matched: number;
missingInWorksmobile: number;
missingInBaron: number;
missingExternalKey: number;
};
export type WorksmobileComparisonColumnKey =
| "status"
| "baronId"
| "baron"
| "baronOrg"
| "worksmobileId"
| "externalKey"
| "worksmobileDomain"
| "worksmobile"
| "worksmobileOrg"
| "manage";
export type WorksmobileComparisonColumnVisibility = Record<
WorksmobileComparisonColumnKey,
boolean
>;
export function getDefaultWorksmobileComparisonColumns(): WorksmobileComparisonColumnVisibility {
return {
status: true,
baronId: false,
baron: true,
baronOrg: true,
worksmobileId: false,
externalKey: false,
worksmobileDomain: true,
worksmobile: true,
worksmobileOrg: true,
manage: true,
};
}
export function summarizeWorksmobileComparison(
rows: WorksmobileComparisonItem[],
): WorksmobileComparisonSummary {
return rows.reduce<WorksmobileComparisonSummary>(
(summary, row) => {
if (row.status === "matched") {
summary.matched += 1;
} else if (row.status === "missing_in_worksmobile") {
summary.missingInWorksmobile += 1;
} else if (row.status === "missing_in_baron") {
summary.missingInBaron += 1;
} else if (row.status === "missing_external_key") {
summary.missingExternalKey += 1;
}
return summary;
},
{
total: rows.length,
matched: 0,
missingInWorksmobile: 0,
missingInBaron: 0,
missingExternalKey: 0,
},
);
}
export function getWorksmobileComparisonStatusLabel(status: string) {
switch (status) {
case "matched":
return "일치";
case "missing_in_worksmobile":
return "WORKS 없음";
case "missing_in_baron":
return "Baron 없음";
case "missing_external_key":
return "ex_key 없음";
default:
return status;
}
}
export function canCreateWorksmobileRow(row: WorksmobileComparisonItem) {
return row.status === "missing_in_worksmobile" && Boolean(row.baronId);
}
const immutableWorksmobileAccountEmails = new Set([
"cyhan@samaneng.com",
"cyhan1@hanmaceng.co.kr",
"cyhan2@baroncs.co.kr",
"cyhan3@brsw.kr",
"su-@samaneng.com",
]);
const hiddenWorksmobileMemberEmails = new Set([
"su-@samaneng.com",
"cyhan1@hanmaceng.co.kr",
"cyhan2@baroncs.co.kr",
"cyhan3@brsw.kr",
]);
function normalizeWorksmobileEmail(email?: string) {
return email?.trim().toLowerCase() ?? "";
}
export function isImmutableWorksmobileAccount(row: WorksmobileComparisonItem) {
return (
row.resourceType === "USER" &&
immutableWorksmobileAccountEmails.has(
normalizeWorksmobileEmail(row.worksmobileEmail),
)
);
}
export function isHiddenWorksmobileMember(row: WorksmobileComparisonItem) {
if (row.resourceType !== "USER") {
return false;
}
return [row.worksmobileEmail, row.baronEmail].some((email) =>
hiddenWorksmobileMemberEmails.has(normalizeWorksmobileEmail(email)),
);
}
export function filterVisibleWorksmobileComparisonRows(
rows: WorksmobileComparisonItem[],
) {
return rows.filter((row) => !isHiddenWorksmobileMember(row));
}
export function getWorksmobileRowSelectionKey(row: WorksmobileComparisonItem) {
if (row.baronId) {
return `${row.resourceType}:baron:${row.baronId}`;
}
if (row.worksmobileId) {
return `${row.resourceType}:works:${row.worksmobileId}`;
}
if (row.externalKey) {
return `${row.resourceType}:external:${row.externalKey}`;
}
return "";
}
export function canSelectWorksmobileRow(row: WorksmobileComparisonItem) {
return (
Boolean(getWorksmobileRowSelectionKey(row)) &&
!isImmutableWorksmobileAccount(row)
);
}
export function getWorksmobileSelectedActionIds(
rows: WorksmobileComparisonItem[],
selectedKeys: string[],
) {
const selected = new Set(selectedKeys);
return rows
.filter((row) => selected.has(getWorksmobileRowSelectionKey(row)))
.map((row) => row.baronId)
.filter((id): id is string => Boolean(id));
}
export function getWorksmobileSelectedMissingExternalKeyOrgUnitIds(
rows: WorksmobileComparisonItem[],
selectedKeys: string[],
) {
return getWorksmobileSelectedWorksOnlyOrgUnitIds(rows, selectedKeys).filter(
(id) =>
rows.some(
(row) =>
row.worksmobileId === id && row.status === "missing_external_key",
),
);
}
export function getWorksmobileSelectedWorksOnlyOrgUnitIds(
rows: WorksmobileComparisonItem[],
selectedKeys: string[],
) {
const selected = new Set(selectedKeys);
return rows
.filter(
(row) =>
row.resourceType === "GROUP" &&
(row.status === "missing_external_key" ||
row.status === "missing_in_baron") &&
selected.has(getWorksmobileRowSelectionKey(row)),
)
.map((row) => row.worksmobileId)
.filter((id): id is string => Boolean(id));
}
const worksmobileComparisonSearchFields: Array<
keyof WorksmobileComparisonItem
> = [
"baronId",
"baronSlug",
"baronName",
"baronEmail",
"baronPrimaryOrgId",
"baronPrimaryOrgSlug",
"baronPrimaryOrgName",
"baronParentId",
"baronParentSlug",
"baronParentName",
"worksmobileId",
"externalKey",
"worksmobileName",
"worksmobileEmail",
"worksmobileLevelId",
"worksmobileLevelName",
"worksmobileTask",
"worksmobileDomainId",
"worksmobileDomainName",
"worksmobilePrimaryOrgId",
"worksmobilePrimaryOrgName",
"worksmobilePrimaryOrgPositionId",
"worksmobilePrimaryOrgPositionName",
"baronParentWorksmobileId",
"baronParentWorksmobileName",
"baronParentWorksmobileEmail",
"worksmobileParentId",
"worksmobileParentName",
"worksmobileParentEmail",
"worksmobileParentExternalKey",
];
export function filterWorksmobileComparisonRowsBySearch(
rows: WorksmobileComparisonItem[],
search: string,
) {
const keyword = search.trim().toLowerCase();
if (!keyword) {
return rows;
}
return rows.filter((row) =>
worksmobileComparisonSearchFields.some((field) => {
const value = row[field];
if (value === undefined || value === null) {
return false;
}
return String(value).toLowerCase().includes(keyword);
}),
);
}
export function filterWorksmobileComparisonRows(
rows: WorksmobileComparisonItem[],
filters: WorksmobileComparisonFilter[],
onlyMissingExternalKey = false,
) {
const allowedStatuses = new Set(
filters.flatMap((filter) => worksmobileFilterStatuses[filter]),
);
if (filters.includes("works_only")) {
if (onlyMissingExternalKey) {
allowedStatuses.delete("missing_in_baron");
}
allowedStatuses.add("missing_external_key");
}
return rows.filter((row) => allowedStatuses.has(row.status));
}
export function formatWorksmobilePersonName(row: WorksmobileComparisonItem) {
return [
row.worksmobileName,
row.worksmobileLevelName ?? row.worksmobileLevelId,
]
.filter(Boolean)
.join(" ");
}
export function formatWorksmobileOrgDetails(row: WorksmobileComparisonItem) {
const details: string[] = [];
const position =
row.worksmobilePrimaryOrgPositionName ??
row.worksmobilePrimaryOrgPositionId;
if (position) {
details.push(`직책 ${position}`);
}
if (row.worksmobileTask) {
details.push(`직무 ${row.worksmobileTask}`);
}
if (typeof row.worksmobilePrimaryOrgIsManager === "boolean") {
details.push(row.worksmobilePrimaryOrgIsManager ? "조직장" : "조직장 아님");
}
return details;
}
export function buildWorksmobilePasswordManageUrl({
tenantId,
domainId,
userIdNo,
}: {
tenantId?: string;
domainId?: number;
userIdNo?: string;
}) {
const normalizedTenantId = tenantId?.trim();
const normalizedUserIdNo = userIdNo?.trim();
if (
!normalizedTenantId ||
!domainId ||
domainId <= 0 ||
!normalizedUserIdNo
) {
return "";
}
const url = new URL("https://auth.worksmobile.com/integrate/password/manage");
url.searchParams.set("usage", "admin");
url.searchParams.set("targetUserTenantId", normalizedTenantId);
url.searchParams.set("targetUserDomainId", String(domainId));
url.searchParams.set("targetUserIdNo", normalizedUserIdNo);
url.searchParams.set(
"accessUrl",
"https://admin.worksmobile.com/assets/self-close.html",
);
return url.toString();
}
export function canOpenWorksmobilePasswordManage(
row: WorksmobileComparisonItem,
tenantId?: string,
) {
return (
row.resourceType === "USER" &&
!isImmutableWorksmobileAccount(row) &&
Boolean(
buildWorksmobilePasswordManageUrl({
tenantId,
domainId: row.worksmobileDomainId,
userIdNo: row.worksmobileId,
}),
)
);
}
export const comparisonFilterOptions: Array<{
value: WorksmobileComparisonFilter;
label: string;
}> = [
{ value: "baron_only", label: "바론에만 있음" },
{ value: "works_only", label: "웍스에만 있음" },
{ value: "matched", label: "양쪽 다 있음" },
];
export const userFilterOptions = comparisonFilterOptions;
const worksmobileFilterStatuses: Record<WorksmobileComparisonFilter, string[]> =
{
baron_only: ["missing_in_worksmobile"],
works_only: ["missing_in_baron"],
matched: ["matched"],
};

View File

@@ -789,11 +789,14 @@ export type WorksmobileOverview = {
export type WorksmobileComparisonItem = {
resourceType: string;
baronId?: string;
baronSlug?: string;
baronName?: string;
baronEmail?: string;
baronPrimaryOrgId?: string;
baronPrimaryOrgSlug?: string;
baronPrimaryOrgName?: string;
baronParentId?: string;
baronParentSlug?: string;
baronParentName?: string;
worksmobileId?: string;
externalKey?: string;
@@ -809,8 +812,13 @@ export type WorksmobileComparisonItem = {
worksmobilePrimaryOrgPositionId?: string;
worksmobilePrimaryOrgPositionName?: string;
worksmobilePrimaryOrgIsManager?: boolean;
baronParentWorksmobileId?: string;
baronParentWorksmobileName?: string;
baronParentWorksmobileEmail?: string;
worksmobileParentId?: string;
worksmobileParentName?: string;
worksmobileParentEmail?: string;
worksmobileParentExternalKey?: string;
status: string;
};
@@ -924,7 +932,17 @@ export async function enqueueWorksmobileOrgUnitSync(
orgUnitId: string,
) {
const { data } = await apiClient.post<WorksmobileOutboxItem>(
`/v1/admin/tenants/${tenantId}/worksmobile/orgunits/${orgUnitId}/sync`,
`/v1/admin/tenants/${tenantId}/worksmobile/orgunits/${encodeURIComponent(orgUnitId)}/sync`,
);
return data;
}
export async function enqueueWorksmobileOrgUnitDelete(
tenantId: string,
orgUnitId: string,
) {
const { data } = await apiClient.post<WorksmobileOutboxItem>(
`/v1/admin/tenants/${tenantId}/worksmobile/orgunits/${encodeURIComponent(orgUnitId)}/delete`,
);
return data;
}

View File

@@ -935,7 +935,7 @@ start_import = "Start Import"
kicker = "Global Overview"
[ui.admin.overview.chart]
description = "Check the graph by all or selected organizations."
description = "Check the graph by all or selected companies."
title = "Login request status by company and app"
[ui.admin.overview.playbook]

View File

@@ -937,7 +937,7 @@ start_import = "임포트 시작"
kicker = "Global Overview"
[ui.admin.overview.chart]
description = "전체 또는 선택한 조직 기준으로 그래프를 확인합니다."
description = "전체 또는 선택한 회사 기준으로 그래프를 확인합니다."
title = "회사별 앱별 로그인 요청 현황"
[ui.admin.overview.playbook]

View File

@@ -6,7 +6,8 @@ const config: Config = {
content: [
"./index.html",
"./src/**/*.{ts,tsx}",
"../common/**/*.{ts,tsx,css}",
"../common/core/**/*.{ts,tsx}",
"../common/shell/**/*.{ts,tsx}",
],
};

View File

@@ -0,0 +1,80 @@
import { expect, test } from "@playwright/test";
test.describe("Admin shell layout", () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem("locale", "ko");
window.localStorage.setItem("admin_session", "fake-token");
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
const authority = "http://localhost:5000/oidc";
const client_id = "adminfront";
const key = `oidc.user:${authority}:${client_id}`;
window.localStorage.setItem(
key,
JSON.stringify({
access_token: "fake-token",
token_type: "Bearer",
profile: { sub: "admin-user", name: "Admin", role: "super_admin" },
expires_at: Math.floor(Date.now() / 1000) + 36000,
}),
);
});
await page.route("**/api/v1/**", async (route) => {
const url = route.request().url();
const headers = { "Access-Control-Allow-Origin": "*" };
if (url.includes("/user/me")) {
return route.fulfill({
json: {
id: "admin-user",
name: "Admin",
role: "super_admin",
manageableTenants: [],
},
headers,
});
}
if (url.includes("/admin/tenants")) {
return route.fulfill({
json: { items: [], total: 0, limit: 1000, offset: 0 },
headers,
});
}
return route.fulfill({ json: { items: [], total: 0 }, headers });
});
await page.route("**/oidc/**", async (route) => {
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
});
});
test("keeps navigation in the left sidebar without covering content", async ({
page,
}) => {
await page.setViewportSize({ width: 900, height: 700 });
await page.goto("/tenants");
const sidebar = page.locator("aside").first();
const main = page.locator("main").first();
await expect(sidebar).toBeVisible();
await expect(main).toBeVisible();
const sidebarBox = await sidebar.boundingBox();
const mainBox = await main.boundingBox();
expect(sidebarBox).not.toBeNull();
expect(mainBox).not.toBeNull();
expect(sidebarBox?.x).toBeLessThanOrEqual(1);
expect(sidebarBox?.width).toBeLessThanOrEqual(260);
expect(mainBox?.x).toBeGreaterThanOrEqual(
(sidebarBox?.x ?? 0) + (sidebarBox?.width ?? 0) - 1,
);
});
});

View File

@@ -105,6 +105,79 @@ test.describe("Tenants Management", () => {
expect(headerWhiteSpace.every((value) => value === "nowrap")).toBe(true);
});
test("switches tree and flat views, searches UUID, and selects descendants", async ({
page,
}) => {
await page.setViewportSize({ width: 1100, height: 760 });
await page.route("**/api/v1/admin/tenants**", async (route) => {
if (route.request().method() !== "GET") {
return route.continue();
}
await route.fulfill({
json: {
items: [
{
id: "company-1",
name: "Hanmac",
slug: "hanmac",
status: "active",
type: "COMPANY",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
{
id: "dept-1",
name: "Planning",
slug: "planning",
status: "active",
type: "ORGANIZATION",
parentId: "company-1",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
{
id: "team-1",
name: "Platform",
slug: "platform",
status: "active",
type: "USER_GROUP",
parentId: "dept-1",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
],
total: 3,
limit: 500,
offset: 0,
},
headers: { "Access-Control-Allow-Origin": "*" },
});
});
await page.goto("/tenants");
await expect(page.getByTestId("tenant-view-tree-btn")).toBeVisible();
await page.getByTestId("tenant-view-table-btn").click();
await expect(page.getByTestId("tenant-view-table-btn")).toBeVisible();
await page.getByPlaceholder(/UUID|슬러그|slug/i).fill("team-1");
await expect(page.locator("table")).toContainText("Platform");
await expect(page.locator("table")).not.toContainText("Hanmac");
await page.getByPlaceholder(/UUID|슬러그|slug/i).fill("");
await page
.locator("tbody tr")
.filter({ hasText: "Hanmac" })
.getByRole("checkbox")
.click();
await expect(page.getByTestId("tenant-bulk-action-bar")).toContainText(
"3개 선택됨",
);
});
test("should virtualize large tenant lists and load next pages automatically", async ({
page,
}) => {

View File

@@ -133,6 +133,24 @@ test.describe("Worksmobile tenant management", () => {
worksmobileName: "박웍스",
status: "missing_in_baron",
},
{
resourceType: "USER",
worksmobileId: "works-hidden-su",
externalKey: "works-hidden-su",
worksmobileName: "숨김 SU",
worksmobileEmail: "su-@samaneng.com",
status: "missing_in_baron",
},
{
resourceType: "USER",
baronId: "user-hidden-cyhan1",
baronName: "숨김 CYHAN1",
baronEmail: "cyhan1@hanmaceng.co.kr",
worksmobileId: "works-hidden-cyhan1",
worksmobileName: "숨김 CYHAN1",
worksmobileEmail: "cyhan1@hanmaceng.co.kr",
status: "matched",
},
]
: [
{
@@ -148,6 +166,14 @@ test.describe("Worksmobile tenant management", () => {
worksmobileName: "박웍스",
status: "missing_in_baron",
},
{
resourceType: "USER",
worksmobileId: "works-hidden-su",
externalKey: "works-hidden-su",
worksmobileName: "숨김 SU",
worksmobileEmail: "su-@samaneng.com",
status: "missing_in_baron",
},
],
groups: [
{
@@ -198,6 +224,10 @@ test.describe("Worksmobile tenant management", () => {
await expect(page.getByText("SCIM token")).not.toBeVisible();
await expect(page.getByText("김누락")).toBeVisible();
await expect(page.getByText("박웍스")).toBeVisible();
await expect(page.getByText("숨김 SU")).not.toBeVisible();
await expect(page.getByText("숨김 CYHAN1")).not.toBeVisible();
await expect(page.getByText("su-@samaneng.com")).not.toBeVisible();
await expect(page.getByText("cyhan1@hanmaceng.co.kr")).not.toBeVisible();
await expect(page.getByText("WORKS 전용 조직")).toBeVisible();
await expect(page.getByText("기술본부", { exact: true })).toBeVisible();
await expect(page.getByText("parent-tech", { exact: true })).toBeVisible();
@@ -206,7 +236,16 @@ test.describe("Worksmobile tenant management", () => {
await expect(page.getByText("홍길동")).not.toBeVisible();
expect(comparisonRequests[0]).toBe(true);
const filterButtons = page
await page
.getByPlaceholder("구성원 이름 또는 UUID 검색")
.fill("su-@samaneng.com");
await expect(page.getByText("숨김 SU")).not.toBeVisible();
await page.getByPlaceholder("구성원 이름 또는 UUID 검색").fill("");
const userComparisonSection = page
.getByRole("heading", { name: "구성원" })
.locator("xpath=ancestor::div[contains(@class, 'space-y-2')][1]");
const filterButtons = userComparisonSection
.getByRole("button", {
name: /바론에만 있음|웍스에만 있음|양쪽 다 있음/,
})
@@ -215,12 +254,16 @@ test.describe("Worksmobile tenant management", () => {
.poll(() => filterButtons)
.toEqual(["바론에만 있음", "웍스에만 있음", "양쪽 다 있음"]);
await page.getByRole("button", { name: "웍스에만 있음" }).click();
await userComparisonSection
.getByRole("button", { name: "웍스에만 있음" })
.click();
await expect(page.getByText("박웍스")).not.toBeVisible();
await expect(page.getByText("김누락")).toBeVisible();
await expect(page.getByText("홍길동")).not.toBeVisible();
await page.getByRole("button", { name: "양쪽 다 있음" }).click();
await userComparisonSection
.getByRole("button", { name: "양쪽 다 있음" })
.click();
await expect(page.getByText("홍길동")).toHaveCount(2);
await expect(page.getByText("기술기획", { exact: true })).toBeVisible();
await expect(page.getByText("team-tech", { exact: true })).toBeVisible();
@@ -229,22 +272,30 @@ test.describe("Worksmobile tenant management", () => {
await expect(page.getByText("김누락")).toBeVisible();
await expect(page.getByText("박웍스")).not.toBeVisible();
await page.getByRole("button", { name: "바론에만 있음" }).click();
await userComparisonSection
.getByRole("button", { name: "바론에만 있음" })
.click();
await expect(page.getByText("홍길동")).toHaveCount(2);
await expect(page.getByText("김누락")).not.toBeVisible();
await expect(page.getByText("박웍스")).not.toBeVisible();
await page.getByRole("button", { name: "웍스에만 있음" }).click();
await userComparisonSection
.getByRole("button", { name: "웍스에만 있음" })
.click();
await expect(page.getByText("홍길동")).toHaveCount(2);
await expect(page.getByText("김누락")).not.toBeVisible();
await expect(page.getByText("박웍스")).toBeVisible();
await page.getByRole("button", { name: "양쪽 다 있음" }).click();
await userComparisonSection
.getByRole("button", { name: "양쪽 다 있음" })
.click();
await expect(page.getByText("김누락")).not.toBeVisible();
await expect(page.getByText("박웍스")).toBeVisible();
await expect(page.getByText("홍길동")).not.toBeVisible();
await page.getByRole("button", { name: "바론에만 있음" }).click();
await userComparisonSection
.getByRole("button", { name: "바론에만 있음" })
.click();
await expect(page.getByText("김누락")).toBeVisible();
await expect(page.getByText("박웍스")).toBeVisible();
await expect(page.getByText("홍길동")).not.toBeVisible();
@@ -464,11 +515,13 @@ test.describe("Worksmobile tenant management", () => {
.getByRole("button", { name: "컬럼 설정" });
await userColumnButton.click();
const dialog = page.getByRole("dialog", { name: "구성원 컬럼 설정" });
await dialog.getByLabel("Baron ID").check();
await dialog.getByLabel("WORKS ID").check();
await dialog.getByLabel("external_key").check();
await dialog.getByRole("button", { name: "닫기" }).click();
const settingsPanel = page
.getByText("구성원 컬럼 설정")
.locator("xpath=ancestor::*[@role='dialog'][1]");
await settingsPanel.getByLabel("Baron ID").check();
await settingsPanel.getByLabel("WORKS", { exact: true }).check();
await settingsPanel.getByLabel("external_key").check();
await settingsPanel.getByRole("button", { name: "닫기" }).click();
const pageOverflow = await page.evaluate(() => ({
documentScrollWidth: document.documentElement.scrollWidth,

View File

@@ -12,4 +12,9 @@ export default defineConfig({
setupFiles: "./src/test/setup.ts",
include: ["src/**/*.{test,spec}.{ts,tsx}"],
},
server: {
fs: {
allow: [".."],
},
},
});