Compare commits

..
Author SHA1 Message Date
swcho f002c05292 chmod: drgtSttn (가뭄지도 패키지 경로 변경) 2025-09-25 17:13:12 +09:00
swcho 0a2284138c remove: updateRainDetail, setUpdateRSwtDetail 초기화 로직 삭제 (중복) 2025-09-22 17:26:49 +09:00
swcho 536f07a13f Merge branch 'iipbs/wr/drgtPopup' into iipbs/wr/dev 2025-09-22 17:23:28 +09:00
swcho bf3ced7a00 fix: 하수재이용시설등록 페이지 계통도, 평면도 pdf만 업로드 되도록 수정 2025-09-22 17:20:19 +09:00
leedj312 0d9ebcbd55 로그인 사용자 건축물 등록 시도코드 적용/ 하수처리장 지도PopUp data 오류 슈ㅜ정/지도점검 오류 수정 2025-09-22 17:17:30 +09:00
swcho 64e5c4c0fb fix: 지도점검결과 등록 페이지 updateRainDetail 초기화 에러 수정 2025-09-22 15:39:19 +09:00
swcho 29e8223efa fix:
- 오타수정(upadateData -> updateData)
- 등록 후 셀 선택 초기화 에러 수정
2025-09-22 14:01:58 +09:00
swcho d12610db98 fix: 지도점검결과 등록 토스트 메세지 오타 수정 2025-09-22 13:49:01 +09:00
swcho ab0d787615 Merge branch 'iipbs/common/main' into iipbs/wr/drgtPopup 2025-09-22 09:54:50 +09:00
swcho 39e2734a49 fix: searchBar.tsx 하수처리시설 검색 에러 수정 2025-09-22 09:54:35 +09:00
swcho bc016c71ae refactor: drgtSttn(가뭄정보) 페이지 패키지 경로 변경 2025-09-22 09:53:56 +09:00
leedj312 77b4ba01b4 물재이용 주석 추가중 2025-09-18 14:09:55 +09:00
leedj312 eac09bde2e Merge commit '311ce14fc97c833c99b69cd6ba928150ecda80c6' 2025-09-17 18:04:03 +09:00
leedj312 ebd2a9f2c8 Merge commit '6844db4c295b7ceab1ffe49255630ad90082edb7' 2025-09-17 18:03:33 +09:00
leedj312 41b90bd7b5 수요처 등록 컴포넌트 파라미터 추가 2025-09-17 18:03:20 +09:00
leedj312 6844db4c29 대민 수요처 등록 파라미터 추가 2025-09-17 17:52:52 +09:00
leedj312 311ce14fc9 물재이용 업무 대민 주소검색 컴포넌트 사용 x 2025-09-17 17:32:03 +09:00
leedj312 3c5d37cc6c 온배수 시설 수정 , 주석 삭제 2025-09-17 17:00:02 +09:00
mind-one003 d92d4d3949 - 테이블 비율 고정 변수 추가(wideFixed)
- store headerlist 에서 비율 변수 추가(widePercent)
2025-09-17 16:15:11 +09:00
leedj312 858a2c0bac 물재이용 아이디별 지자체 코드 적용 실적 완료 2025-09-16 17:22:33 +09:00
leedj312 0bc87e2082 물재이용 로그인한 사용자의 시군구 코드에 따른 코드값 변경 (시설물 등록 부분만 완료) 2025-09-16 15:25:08 +09:00
leedj312 5588eddbc5 테이블 헤드 기호 추가 및 버튼 위치 수정 2025-09-16 10:13:35 +09:00
leedj312 682cc1297c 지도점검 엑셀 등록시 lodingbar추가 2025-09-15 17:45:22 +09:00
leedj312 e26d800449 통계 수정 2025-09-15 16:23:47 +09:00
leedj312 e6be168d0d 물재이용 공통코드 작업 수정 사항 추가 2025-09-15 15:38:25 +09:00
leedj312 ad6e4484b7 물재이용 공통코드 작업 2025-09-15 15:36:56 +09:00
leedj312 92f12c2104 공통코드 작업 완료/ 지도점검 등록/현황 페이지 수정 2025-09-12 17:29:34 +09:00
mind-one005 75d2d0c827 Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-09-11 12:42:55 +09:00
mind-one005 675a1a09fa /api/proxy 미들웨어 추가 2025-09-11 12:42:23 +09:00
mind-one003 b93efa5503 라인바, 라인차트 x축 라벨 크기 확대 2025-09-11 11:17:37 +09:00
swcho 00b0832acb Merge branch 'iipbs/sn/dev' 2025-09-10 15:30:26 +09:00
swcho 6e109e970e refactor:
- SearchModal 껐다 킬 때 페이지네이션 정보 잃어버리는 에러 수정
- useSearchModal.ts useEffect 의존성 배열을 searchModal.tsx 로 이동
- import 정리
2025-09-10 15:24:24 +09:00
leedj312 00ecc1dc72 차트 데이터 , 제거 및 년도 공통 변수 처리 2025-09-09 13:39:34 +09:00
yschoi 328787e1b2 Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-09-09 10:39:27 +09:00
yschoi 1862497bf4 api url 수정 2025-09-09 10:38:11 +09:00
leedj312 344e77eafa Merge commit '500a6b1489456e94df84b590050d84c69fe200d3' 2025-09-09 10:12:05 +09:00
leedj312 1bf8afd9ac Merge commit 'c9169772922b9ad3046b894b4375f3983779a1bd' 2025-09-09 10:10:30 +09:00
leedj312 f50de19137 gis 오버레이 수정 2025-09-09 10:10:19 +09:00
swcho f9e8748906 Merge branch 'iipbs/sn/dev' 2025-09-09 09:59:41 +09:00
swcho 2f5d76ed38 Merge branch 'iipbs/common/main' into iipbs/sn/ii 2025-09-09 09:54:55 +09:00
swcho 0b8559891a remove: Gis.js에서 SearchView 컴포넌트 props 삭제 2025-09-09 09:54:40 +09:00
leedj312 c916977292 대민 물재이용 현황 데이터 받는걸로 수정 2025-09-08 17:42:50 +09:00
swcho d898121764 refactor, add:
- searchModal Store 패턴을 사용하여 상태 관리 및 UI, 비즈니스로직 분리
- tsconfig.json에 import paths 추가
2025-09-08 14:49:23 +09:00
leedj312 500a6b1489 물재이용 지도 하수재이용 오버레이 변경 , 온배수 항목 추가 , 주소 검색 키 변수명 변경 2025-09-05 16:39:02 +09:00
mind-one003 ffa1781005 라인바 차트 좌측 여백 및 라벨 좌측으로 좀 더 이동 2025-09-05 14:21:39 +09:00
mind-one003 b47c0dff2b 라인바 차트 크기 수정, custom left 축 만드는 로직 추가 2025-09-05 13:49:22 +09:00
leedj312 7e7335530e Merge commit '2d474696e6e5e648e940cf0ba6ec9153a32316f5' 2025-09-05 11:30:02 +09:00
leedj312 2d474696e6 대민 변경 2025-09-05 11:27:57 +09:00
yschoi a89eb98191 Merge remote-tracking branch 'origin/iipbs/fo/dev' 2025-09-05 09:24:16 +09:00
yschoi 2b28bae58f Merge remote-tracking branch 'origin/iipbs/wr/dev' 2025-09-05 09:23:18 +09:00
mind-one003 9fadb172f9 오타수정 2025-09-04 17:51:11 +09:00
mind-one003 99b0580697 총인시설 여부에 따라 총인시설 row 추가 혹은 제거 2025-09-04 15:30:31 +09:00
mind-one003 d9b52ba5ef rdi 조회화면과 대시보드의 rdii 값 맞춰주는 작업 진행 2025-09-04 14:52:26 +09:00
leedj312 cbc7c4918f 물재이용 사례관리 엑셀 등록 유효성 검사 및 저장 로직 변경 2025-09-04 14:43:29 +09:00
swcho 192cb91517 fix: sn/package.json에 xlsx 의존성 추가 (build 에러 수정) 2025-09-04 11:07:03 +09:00
yschoi ca45874fe9 Merge remote-tracking branch 'origin/iipbs/fo/dev' 2025-09-04 10:18:53 +09:00
yschoi d051d3fa1d Merge remote-tracking branch 'origin/iipbs/wr/dev' 2025-09-04 10:16:42 +09:00
swcho d99dbfee36 Merge remote-tracking branch 'origin/main' into iipbs/sn/dev 2025-09-04 09:44:06 +09:00
swcho ab6279a449 fix: 하수처리구역 시군구 매핑 에러 수정 2025-09-04 09:42:26 +09:00
leedj312 c4c5e68267 하수재이용 통계 매뉴 이동 수정 2025-09-03 17:55:05 +09:00
leedj312 5ec38f1fb4 Merge commit 'a03359540a57d48b8bc1af7ca8ccc2a232e2d12a' into iipbs/wr/dev 2025-09-03 16:28:21 +09:00
leedj312 a03359540a 주소검색 특수문자, sql 입력 방지 함수 추가 2025-09-03 16:27:58 +09:00
mind-one003 9707232954 총인시설 유무확인 로직 수정 - 데이터 들어올 시 확인필요 2025-09-03 15:18:31 +09:00
leedj312 d9cd742355 지도 팝업 데이터 없는경우 메시지 표출 2025-09-03 15:07:44 +09:00
leedj312 b92351a6aa Merge commit '721dcbc634fff67e1135f391fb1b137da7adffaf' 2025-09-03 14:39:11 +09:00
leedj312 4dec379846 오버레이 생성 div 를ㄹ promise로 받을수 있도록 변경 2025-09-03 14:38:56 +09:00
leedj312 721dcbc634 물재이용 지도 오버레이 팝업 수정(드래그, 축소 기능 추가) 2025-09-03 14:34:34 +09:00
swcho a26262d3b2 refactor: cql filter 각 파일로 이동 2025-09-03 14:20:31 +09:00
mind-one003 50b5c66a7e console.log 제거 2025-09-03 14:05:03 +09:00
mind-one003 b92baf8b2a 팝업 엑셀 다운로드 추가 2025-09-03 14:02:59 +09:00
swcho 283711fce7 Merge remote-tracking branch 'origin/iipbs/sn/dev' 2025-09-03 11:43:19 +09:00
swcho 99fec24764 fix: 우수토실 조회 버튼 오른쪽 배치로 수정 2025-09-03 11:02:56 +09:00
swcho 18c46ce829 refactor: useSearchModal.tsx store 이용하여 상태관리 리팩토링 2025-09-03 10:57:28 +09:00
swcho 0506464a97 refactor: searchModal UI, 비즈니스 로직 및 상태 컴포넌트 분리 2025-09-02 17:49:22 +09:00
leedj312 1ccba8797e Merge commit '05b4a4dae547541197c9b9162aab9c3e74ec63bf' 2025-09-02 17:46:05 +09:00
leedj312 44c687d432 gis 오버레이 추가 2025-09-02 17:45:36 +09:00
leedj312 05b4a4dae5 물재이용 지도 오버레이 중복 빼고 완료 2025-09-02 17:30:05 +09:00
mind-one003 236cc9fdd2 미처리하수량 주석추가 2025-09-02 17:18:19 +09:00
mind-one003 f923297498 Merge branch 'iipbs/common/main' into iipbs/fo/dev 2025-09-02 16:46:16 +09:00
mind-one003 83f5d878e7 라인바 y축 범위 수정 2025-09-02 16:44:43 +09:00
mind-one003 2f00efadfc - 미처리하수량 컬럼 추가
- 연 평균 > 연평균
2025-09-02 16:43:38 +09:00
swcho 4052b46429 Merge branch 'main' into iipbs/sn/dev 2025-09-02 16:03:47 +09:00
swcho d172b17880 merge 2025-09-02 16:03:25 +09:00
swcho ebafff03ec merge 2025-09-02 16:02:22 +09:00
swcho bd1250762e Merge branch 'iipbs/common/main' 2025-09-02 16:01:08 +09:00
swcho e815b9662f remove: comboSearchBar index 삭제 2025-09-02 16:00:49 +09:00
swcho 90d47282fb Merge branch 'iipbs/sn/dev' 2025-09-02 15:53:28 +09:00
swcho 809deff401 Merge remote-tracking branch 'origin/iipbs/sn/ii' into iipbs/sn/dev 2025-09-02 15:53:06 +09:00
swcho 3b1c34aecb Merge branch 'iipbs/common/main' into iipbs/sn/ii 2025-09-02 15:52:50 +09:00
swcho 2e6cbad49c remove, refactor:
- comboSearchBar.tsx 삭제
- any 타입 추가
- procSearchBar 시군구, 시설 가나다순 정렬
2025-09-02 15:52:13 +09:00
leedj312 e941c1ba3e Merge commit 'a42be8059acc915ddd3ca2aeb8a734054c3c8873' 2025-09-02 14:46:53 +09:00
jcchoi 9ebd3c47a6 대시보드 레이아웃 수정 2025-09-02 14:34:52 +09:00
jcchoi 2c9580c835 footer 수정정 2025-09-02 14:33:51 +09:00
jcchoi 06e9938e84 Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-09-02 13:25:55 +09:00
jcchoi 1119f968b0 header 메뉴 추가 및 CM용 layout 추가 2025-09-02 13:25:50 +09:00
swcho a42be8059a remove: 가뭄지도 EXPRS_ORDERD_LAYERS 삭제 2025-09-02 11:16:59 +09:00
swcho 3bbf3bae3d Merge remote-tracking branch 'origin/iipbs/common/main' into iipbs/wr/drgtPopup 2025-09-02 11:11:59 +09:00
swcho a8c8d82f8e Merge branch 'iipbs/sn/dev' 2025-09-02 11:08:17 +09:00
swcho 540203d153 Merge branch 'iipbs/sn/ii' into iipbs/sn/dev 2025-09-02 11:07:26 +09:00
swcho 7879bd8c3c Merge branch 'iipbs/common/main' into iipbs/sn/ii 2025-09-02 11:06:53 +09:00
swcho ede5ef6a21 refactor: GisTree.js 주석 추가 및 최신화 2025-09-02 11:06:37 +09:00
swcho 59bd8eddfb remove: waterDepth.tsx에서 orderedLayers 삭제 2025-09-02 10:57:59 +09:00
swcho c4a42e0f7c Merge branch 'iipbs/common/main' into iipbs/sn/ii 2025-09-02 10:57:26 +09:00
swcho 621d222799 remove: GisTree, Gis 에서 orderedLayers porps 삭제 2025-09-02 10:55:16 +09:00
swcho b3658576b3 Merge remote-tracking branch 'origin/iipbs/sn/dev' 2025-09-02 09:31:03 +09:00
swcho be455992a3 Merge remote-tracking branch 'origin/iipbs/sn/ii' into iipbs/sn/dev 2025-09-02 09:29:51 +09:00
mind-one003 34e88c6b63 날짜형식 오류 수정 2025-09-02 09:17:15 +09:00
swcho fe7960ee14 refactor:
- if문 -> switch문
- log 삭제
2025-09-01 18:12:03 +09:00
mind-one003 c85147e989 Merge branch 'iipbs/common/main' into iipbs/fo/dev 2025-09-01 17:01:32 +09:00
mind-one003 01df37d0b3 라인차트 호출 시 config color에 dash붙을시 해당색상의 대시라인 생성 2025-09-01 17:01:18 +09:00
mind-one003 e434e711bf 결함조치 하는중 2025-09-01 17:00:10 +09:00
swcho af630429b3 Merge branch 'iipbs/sn/dev' 2025-09-01 13:12:45 +09:00
swcho 4b2dc7557e Merge remote-tracking branch 'origin/iipbs/sn/ii' into iipbs/sn/dev 2025-09-01 13:12:32 +09:00
swcho a6a4d7ccff remove: 주석 삭제 및 수정 2025-09-01 13:12:19 +09:00
swcho 48dd2ff40e Merge branch 'iipbs/sn/dev' 2025-09-01 13:09:18 +09:00
swcho 8774cf7207 Merge remote-tracking branch 'origin/iipbs/sn/ii' into iipbs/sn/dev 2025-09-01 13:08:25 +09:00
swcho 5be1374b47 Merge branch 'iipbs/common/main' into iipbs/sn/ii 2025-09-01 13:08:04 +09:00
swcho 072a5337a3 Merge branch 'iipbs/common/main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/common/main 2025-09-01 13:07:49 +09:00
swcho 2a0f5dce25 remove: 상류관로 보기 함수 삭제 2025-09-01 13:07:46 +09:00
swcho 27801244b0 refactor: 상류관로 보기 함수 위치 이동 (Gis.js -> popupComponent/smh.tsx) 2025-09-01 13:06:53 +09:00
swcho 5b4c102b16 Merge branch 'iipbs/sn/dev' 2025-09-01 10:24:27 +09:00
swcho b3d8dec409 refactor: 도시침수예방사업 운영관리현황 점검결과 탭 가로 스크롤 hidden 처리 2025-09-01 10:24:08 +09:00
swcho 875434239e feat:
- 도시침수예방사업 엑셀 보기 모달 구현
- 노후 하수관로 정비사업 엑셀 보기 모달 구현 (엑셀 양식이 확정 안돼서 수정 필요)
2025-09-01 10:21:05 +09:00
mind-one003 3067646ba2 운영파일 신규추가된건 추가 및 불필요한거 원복 2025-08-29 16:35:13 +09:00
mind-one003 63ccfe6b3b 운영형상에 맞춰서 세팅 2025-08-29 15:54:53 +09:00
mind-one003 03dd39b128 운영형상에 맞춰서 세팅 2025-08-29 15:32:02 +09:00
mind-one003 cfc4d70d43 Merge branch 'iipbs/common/main' into iipbs/fo/dev 2025-08-29 14:59:32 +09:00
mind-one003 26f2c60f4c 라인바 차트
- 데이터가 NaN이거나 null인 경우 툴팁에서 제외
- NULL인 경우 0으로 라인을 그리는 현상 다음 있는데이터로 라인을 그리도록 수정
2025-08-29 14:59:10 +09:00
mind-one003 ebe7d1a86c 주요그래프 총인유입tp 0이나 null인 경우 안보여지도록 수정(0으로 안나오도록..) 2025-08-29 14:55:55 +09:00
leedj312 2be2028c27 Merge commit 'cc9389b91c93f60bce2c933fcea48c8c4c98c5e4' 2025-08-29 14:00:27 +09:00
leedj312 c5973e25e7 Merge commit 'd910dcce914269169efd14079c8dc4222a807bc8' 2025-08-29 13:58:29 +09:00
leedj312 8ec548301c 주소 검색 컴포넌트 수정사항 반영 2025-08-29 13:57:13 +09:00
leedj312 cc9389b91c 대민 주소서비스반영 2025-08-29 13:40:15 +09:00
swcho c161576c77 Merge branch 'iipbs/sn/dev' 2025-08-28 17:35:25 +09:00
swcho 555b918872 Merge branch 'iipbs/common/main' into iipbs/sn/ii 2025-08-28 17:33:09 +09:00
swcho 28d0e21815 feat: UiTable에 페이지 번호 props 추가 2025-08-28 17:32:52 +09:00
swcho cf61ada719 feat: searchModal 다시 열 때 페이지 번호 상태 유지 기능 구현 2025-08-28 17:31:05 +09:00
swcho b1d4582e2a Merge branch 'iipbs/sn/dev' 2025-08-28 17:21:32 +09:00
swcho 94dceae6cd Merge remote-tracking branch 'origin/iipbs/common/main' into iipbs/sn/ii 2025-08-28 17:12:13 +09:00
swcho 49c475709f feat:
- 시설 검색, 다중 시설 검색 시 이전 검색 시설 레이어 삭제 기능 추가
- 시설 위치 정보 없을 때 alert 추가
- 시설 검색 시 해당 시설 아이콘 이미지 표출
2025-08-28 17:11:35 +09:00
leedj312 52bae1b5f7 주소검색 미들웨어 리라이트 방식으로 변경 2025-08-28 15:24:29 +09:00
leedj312 d910dcce91 물재이용 주소 검색 미들웨어 추가 2025-08-28 15:22:10 +09:00
mind-one003 155f345911 강우일 운영현황 toBeInfoRnDay가 추가됨에 따른 로직 수정 2025-08-28 14:13:04 +09:00
leedj312 abe7e51f37 주소 자동 완성 input 초기값 설정 2025-08-28 10:55:02 +09:00
mind-one003 a2a38f8937 강우영향일 로직수정
- changePart 제거
- 강우영향일 계산 후 강우일 담은 toBeInfoRnDay 변수 추가
2025-08-28 10:53:31 +09:00
leedj312 3353770ad9 건축물 등록시 디폴트 밸류 설정 2025-08-28 10:51:43 +09:00
mind-one003 1bfc8b6a19 계획하수량 산정지표현황
- 필요없는 콘솔제거
- 변동부하율 계산 수정
2025-08-28 09:34:47 +09:00
jcchoi 5ddf9fe6e0 FOOTER 배너 표시 2025-08-27 21:07:20 +09:00
mind-one003 1cdbdd00d9 다운로드 엑셀 헤더에 단위 추가 2025-08-27 17:44:38 +09:00
jcchoi 2368202fa7 Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-08-27 16:08:43 +09:00
mind-one003 eea932f43a Merge branch 'iipbs/common/main' into iipbs/fo/dev 2025-08-27 14:56:31 +09:00
mind-one003 31b8152d37 차트 좌우 axis custom 추가 2025-08-27 14:56:02 +09:00
leedj312 2611f304d9 대민 데이트 피커를 input date로 변경(향후 날짜로 인해) 2025-08-27 13:45:13 +09:00
jcchoi 5896d678e3 메뉴 클릭시 새창 기능 추가 2025-08-27 13:19:29 +09:00
jcchoi f7f954a2a6 그룹관리 메뉴권한 모달 수정 2025-08-27 12:30:20 +09:00
leedj312 b6380e9aac 데이트 피커를 그냥 일단 input date로 변경 2025-08-27 10:59:40 +09:00
mind-one003 5357a65b91 대시보드 tn,tp 불일치 수정 2025-08-27 09:55:42 +09:00
swcho 00d280d33b Merge branch 'iipbs/sn/dev' 2025-08-26 18:47:33 +09:00
swcho 3c7da38e52 feat:
- 도시침수예방사업 모달 너비 수정으로 줄바꿈 처리
- UiTable 내부 셀 줄바꿈 처리 적용
2025-08-26 18:46:32 +09:00
swcho 599a3aaf7b Merge branch 'iipbs/common/main' into iipbs/sn/ii 2025-08-26 18:43:17 +09:00
swcho b9f5eb50e5 feat: UiTable 내부 셀 줄바꿈 처리 pre-line 적용 2025-08-26 18:42:36 +09:00
mind-one003 9d8678e8e5 Merge branch 'iipbs/common/main' into iipbs/fo/dev 2025-08-26 17:51:10 +09:00
mind-one003 c7772a0a23 빌드에러 수정 2025-08-26 17:50:50 +09:00
mind-one003 03d5fc330a Merge branch 'iipbs/common/main' into iipbs/fo/dev 2025-08-26 17:48:11 +09:00
mind-one003 392ccefb16 라인바 차트property 추가 (setXAxisYearTick - 날짜 x축일경우 (년도-월)별로 만들어주는 속성) 2025-08-26 17:47:51 +09:00
mind-one003 7ddb7dda97 라인바 차트property 추가 (setXAxisYearTick - 날짜 x축일경우 (년도-월)별로 만들어주는 속성) 2025-08-26 17:47:29 +09:00
leedj312 ff6634b250 Merge commit 'b7133c9197a948475692634f3b9e20e2f7688a0c' 2025-08-26 17:47:05 +09:00
leedj312 dae440a4b4 수요처 등록시 주소 검색 서치인풋으로 변경 2025-08-26 17:46:51 +09:00
leedj312 b7133c9197 대민 수요정보 등록시 오류사항 수정 2025-08-26 17:42:30 +09:00
mind-one003 2e6679c14f Merge branch 'iipbs/common/main' into iipbs/fo/dev 2025-08-26 17:39:23 +09:00
mind-one003 aeaf507c47 차트property 추가 (setXAxisYearTick - 날짜 x축일경우 (년도-월)별로 만들어주는 속성) 2025-08-26 17:39:07 +09:00
mind-one003 14c1846fe1 차트property 추가 (setXAxisYearTick - 날짜 x축일경우 (년도-월)별로 만들어주는 속성) 2025-08-26 17:38:25 +09:00
leedj312 f2447cb1f5 Merge commit 'fa8e41909a91a659c2875979f550af8d97264b60' 2025-08-26 15:54:41 +09:00
leedj312 632f1b0fd9 주소검색 api 및 컴포넌트 생성(인터넷용) 2025-08-26 15:54:25 +09:00
leedj312 fa8e41909a 주소검색 input 박스 관련 수정 2025-08-26 15:52:18 +09:00
swcho 8ea7427264 feat: 운영정보 결과 관로도 지도에서 전체 보기 기능 추가 (이전 검색결과 관로 삭제되게끔 수정 필요) 2025-08-26 14:47:31 +09:00
mind-one003 1600927237 팝업 데이터 포맷출력방식 변경 2025-08-26 14:05:39 +09:00
mind-one003 88893eb0be 에러수정 2025-08-26 13:33:18 +09:00
mind-one003 d3fa026e22 Merge branch 'iipbs/common/main' into iipbs/fo/dev 2025-08-26 13:31:21 +09:00
mind-one003 2f4cfa0d78 바차트 툴팁 숫자의 경우 천자리 쉼포 추가 2025-08-26 13:30:24 +09:00
leedj312 94833e54d3 Merge commit 'b2bd7ce16a48f72951ada66f9fc53607e49a949b' 2025-08-26 13:07:36 +09:00
swcho 8541c72752 Merge branch 'iipbs/sn/dev' 2025-08-26 13:03:28 +09:00
swcho 2db685b533 feat:
- fileUploadForm 첨부 시가 아닌 등록 시에 업로드 되도록 수정
- rdii 분석 오수관망도 inp 파일이 마인드원 DB에 저장되도록 수정
2025-08-26 11:37:58 +09:00
mind-one003 537c895ebe qltyDataSetting common index 로 이동 2025-08-26 11:24:39 +09:00
mind-one003 1b22d55b94 대시보드 수질현황 팝업과 데이터 맞추는 작업 완료 2025-08-26 11:19:29 +09:00
leedj312 3b5fdd14b6 수요처 등록 컴포넌트 재사용(현황과 함께 사용) 2025-08-26 10:52:58 +09:00
leedj312 3aacb5bc72 Merge commit 'ebfc520d676842cc71556474359fbfd28f0ca244' 2025-08-26 10:48:18 +09:00
leedj312 b2bd7ce16a 물재이용 용어 수정(PH -> ph, m3->m2) 2025-08-26 10:37:19 +09:00
swcho 1ac7c97041 feat: 침입수 분석 오수관망도 inp 파일이 마인드원 DB에 저장되도록 수정 2025-08-26 09:59:10 +09:00
mind-one003 1fceb181b7 유량현황 데이터 맞추는 작업 완료 , 수질현황 작업진행 예정 2025-08-25 18:01:17 +09:00
swcho 26f227e85f feat: 도시침수예방사업 모달 너비 수정으로 줄바꿈 처리 2025-08-25 17:59:52 +09:00
leedj312 c8957ce0da 시설 현황 및 건축물 관련 수정 2025-08-25 14:31:16 +09:00
swcho 3235524479 feat: 운영관리현황 테이블에 일자 추가 2025-08-25 14:10:16 +09:00
swcho c53a768c94 refactor:
- any 타입 지정
- postMutiPart 함수 정리
2025-08-25 13:47:50 +09:00
swcho 5aec4b957c feat: 도시침수예방사업 엑셀 데이터 모달로 띄우기 기능 구현 (셀 세로 merge 처리 필요) 2025-08-25 13:46:53 +09:00
swcho 11cf9f91ca Merge branch 'iipbs/sn/dev' into iipbs/sn/ii 2025-08-25 09:23:32 +09:00
mind-one005 3d0dccaa35 침입수 파일업로드 2025-08-22 20:53:31 +09:00
mind-one003 aa54f32ba2 빌드에러 수정 2025-08-22 18:26:15 +09:00
mind-one003 bc5bce3171 대시보드 하절기 강우시 유입하수량 수정 완료 2025-08-22 18:23:31 +09:00
mind-one003 af8677d510 - 오수전환율 공통변수로 생성 - OSU_PERCENT
- CalcRainEffect 강우영향일만, 강우량만 계산되도록 파라미터 추가
2025-08-22 13:51:10 +09:00
mind-one003 8013483d17 대시보드 수치 맞추는 작업 진행중 (현재 유입유량 및 총유입량 맞추는 작업완료) 2025-08-21 18:27:52 +09:00
mind-one005 68c99ad6c0 파일업로드 sample 2025-08-21 17:14:50 +09:00
swcho fe57f85632 feat: RDII 분석 상수사용량 적용월 검증 로직 추가 2025-08-21 13:07:14 +09:00
mind-one003 7ca09a60d8 -CalcRainEffect 강우량 자릿수 소수점 첫째자리 표시
- 수질현황 최대 최소 BOD, COD 수치 이상수정
2025-08-21 11:25:11 +09:00
mind-one003 f425a303e5 - 페이지에 산식 추가
- 오타수정
- CalcRainEffect에 강우량 x시 0.0으로 표시
2025-08-21 11:00:02 +09:00
swcho 64813531f1 feat: 침입수분석, RDII 분석 양식 파일 추가 및 버튼 위치 변경 2025-08-21 10:24:14 +09:00
mind-one003 fc70b9c4be CalcRainEffect 수정(weather 수정) 2025-08-21 09:32:05 +09:00
mind-one005 0c0fcd5166 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2025-08-20 21:26:00 +09:00
mind-one005 1579df57c4 공단개발 sewerage 주소변경 2025-08-20 21:25:55 +09:00
swcho cbab18c53d Merge remote-tracking branch 'origin/iipbs/common/main' into iipbs/sn/ii 2025-08-20 18:51:12 +09:00
swcho ebfc520d67 feat:
- 여러 개의 검색 결과를 GeoJSON으로 표시 함수 추가
- 피쳐 ID로 지오서버에서 검색해서 여러개 피처의 벡터소스 리턴하는 함수 추가
- 여러 extent 배열을 받아 중앙값 extent를 반환하는 함수 추가
2025-08-20 18:50:08 +09:00
swcho 3fb331b4bc feat: 기본정보 조회 시설 지도에서 보기 (이전 시설 데이터 초기화 해야 함) 2025-08-20 18:42:35 +09:00
mind-one003 cc4e3ea0cf 빌드 에러 수정 2025-08-20 18:11:59 +09:00
mind-one003 1fa1a880e9 에러 수정 2025-08-20 18:01:06 +09:00
mind-one003 035f2fd4c1 rdii 산식 및 주석 추가 2025-08-20 16:58:21 +09:00
mind-one003 fda8750f14 rdii 개선 2025-08-20 16:52:19 +09:00
jcchoi 956fcb77e2 Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-08-20 13:00:08 +09:00
jcchoi 7a18924edf 저장후 목록이동처리 2025-08-20 12:59:51 +09:00
mind-one003 4e7d291ad9 강우영향일 공통화 진행 2025-08-20 11:15:47 +09:00
swcho c4732ebb8f feat:
- 운영정보 결과 관로 클릭시 해당 관로로 이동 기능 구현
- searchModal 데이터 유지되도록 변경
2025-08-20 11:07:51 +09:00
mind-one003 03f87c2042 강우량 현황 팝업수정진행중 2025-08-20 10:53:30 +09:00
swcho e75e9b9873 refactor: 수용가-맨홀 양식 파일 헤더 색칠 2025-08-20 10:50:39 +09:00
swcho 4845450ea5 feat, refactor:
- 수용가-맨홀 매칭결과 양식 파일 다운로드 기능 추가
- 파일 다운로드 버튼 색상 green으로 변경
2025-08-19 18:23:47 +09:00
yschoi ea84947b9e Merge remote-tracking branch 'origin/iipbs/fo/dev' 2025-08-19 17:43:45 +09:00
yschoi 2b3c0304e2 Merge remote-tracking branch 'origin/iipbs/sn/dev' 2025-08-19 17:43:12 +09:00
mind-one003 715d5ab433 시설별 강수현황 모달 스타일 수정 2025-08-19 17:13:38 +09:00
mind-one003 be8b354e85 강우영향일 로직 수정
to-be : 강우량 변경 로직 중 어느 날짜가 강우영향일에 포함될 경우 3보다 큰 강우량일 경우에는 강우량 update 안하고 현재일자 강우량 그대로 유지하도록 수정
2025-08-19 16:51:57 +09:00
swcho 40e0dde0e0 fix: searchModal 엑셀 다운로드 에러 수정 2025-08-19 16:31:03 +09:00
swcho 42c94e8b43 feat: 파일 다운로드안내 alert 및 toast 기능 추가 2025-08-19 16:08:24 +09:00
swcho 507618a437 Merge branch 'iipbs/common/main' into iipbs/sn/ii 2025-08-19 15:25:11 +09:00
swcho 4df286b086 feat: UiTable 컴포넌트에 headButton props 추가 2025-08-19 15:24:52 +09:00
swcho d676d726f2 feat: searchModal.tsx 기본정보, 운영정보 파일 다운로드 기능 구현 2025-08-19 15:23:36 +09:00
mind-one003 bd63b0b28f 빌드 에러 수정 2025-08-19 15:06:37 +09:00
mind-one003 3746dcf005 숫자 우측정렬, 자릿수 수정 2025-08-19 15:04:38 +09:00
swcho 443f9e32f6 fix: 하수관로, 하수맨홀 cql 필터 에러 수정 2025-08-19 13:11:50 +09:00
mind-one003 5b3bbf0204 변동부하율 % 미삭제된거 수정 2025-08-19 09:21:08 +09:00
mind-one003 081fcdd4c5 빌드에러 수정 2025-08-18 18:19:17 +09:00
mind-one003 8e43ca281f 자릿수 변경 2025-08-18 18:14:06 +09:00
yschoi ae2df551ac env 수정 2025-08-18 17:21:52 +09:00
yschoi d0324bf136 warn 로그 출력 제거 2025-08-18 16:18:23 +09:00
mind-one003 95007191ea - 2021년 이전은 ()로 cod안내
- 오수전환율 90%로 변경
2025-08-18 15:56:10 +09:00
leedj312 9c19f239c2 건축물 데이터 검증 소스 수정 2025-08-18 14:53:00 +09:00
mind-one003 d155714d51 변동부하율 소수점 둘째자리까지 반올림 2025-08-18 14:39:55 +09:00
mind-one003 be131e37c2 -시설구분 삭제
-500이하 > 500미만
-대시보드에서 총인설비 유입이 없을 경우 해당 행 삭제
-지하수유입량 모달 시설명오류 수정
2025-08-18 14:35:24 +09:00
yschoi 666307d76c Merge remote-tracking branch 'origin/iipbs/fo/dev' 2025-08-18 10:52:52 +09:00
mind-one003 09aecc38ad 화면에서 시설코드 제외(엑셀 및 팝업은 제외) 2025-08-18 10:43:05 +09:00
leedj312 6b88a8584b 콘솔로그 삭제 2025-08-14 17:44:45 +09:00
yschoi 47a833103b Merge remote-tracking branch 'origin/iipbs/dm/dev' 2025-08-14 15:41:13 +09:00
yschoi 76328f55b6 Merge remote-tracking branch 'origin/iipbs/wr/dev' 2025-08-14 15:40:40 +09:00
leedj312 0a0e89647f 공단 지적사항 수정 2025-08-14 15:23:23 +09:00
yschoi 0ba9909ddf 로딩바 2025-08-14 15:05:05 +09:00
leedj312 e6882f3900 일단 대민 수정 2025-08-14 14:18:09 +09:00
yschoi 1c64f80d9d Merge remote-tracking branch 'origin/iipbs/dm/dev' 2025-08-14 13:34:39 +09:00
yschoi 4f7bb4e17c Merge remote-tracking branch 'origin/iipbs/fo/dev' 2025-08-14 13:34:19 +09:00
yschoi ee01c88d60 Merge remote-tracking branch 'origin/iipbs/wr/dev' 2025-08-14 13:33:57 +09:00
yschoi 1a1cce0ed0 Merge remote-tracking branch 'origin/main' into iipbs/fo/dev 2025-08-14 13:30:56 +09:00
yschoi 1420d6d8ad Merge remote-tracking branch 'origin/main' into iipbs/wr/dev 2025-08-14 13:28:11 +09:00
yschoi c9e65ed001 Merge remote-tracking branch 'origin/main' into iipbs/sn/dev 2025-08-14 13:27:28 +09:00
yschoi ab3f0aa6db Merge remote-tracking branch 'origin/main' into iipbs/dm/dev 2025-08-14 11:34:24 +09:00
yschoi 39a78f475f 디버깅용 콘솔 로그 삭제 2025-08-14 11:32:11 +09:00
leedj312 ff09a773f9 대민 공단 통합테스트 수정사항 반영 2025-08-14 11:07:59 +09:00
leedj312 5b4dd9fd9f Merge commit '262cf21546fcd513704e1c6edf118722d9523fad' into iipbs/wr/dev 2025-08-14 10:45:01 +09:00
leedj312 c65b3649df 하수 재이용 등록시 조회 로직 변경 2025-08-14 09:29:28 +09:00
leedj312 a4a050a56c 재이용 시설 폼 오류 수정 2025-08-13 23:10:59 +09:00
swcho b5150c6f35 Merge branch 'iipbs/common/main' into iipbs/sn/dev 2025-08-13 17:20:57 +09:00
swcho 262cf21546 remove: 잘못 추가된 물재이용(wr)의 가뭄지도 팝업 페이지 삭제 2025-08-13 17:20:40 +09:00
leedj312 aacadcb929 물재이용 감리 지적사항 수정 2025-08-13 17:17:18 +09:00
swcho 7c3d4e8f13 feat: 재이용시설현황 지도로보기 초기 화면 전국으로 수정 2025-08-13 17:01:17 +09:00
swcho b67a859983 Merge branch 'iipbs/common/main' into iipbs/wr/dev 2025-08-13 16:31:13 +09:00
swcho 056b7bae3c fix: defaultZoom props 수정 2025-08-13 16:31:01 +09:00
swcho 1eb30cc8ba Merge branch 'iipbs/wr/drgtPopup' into iipbs/wr/dev 2025-08-13 16:26:57 +09:00
swcho 1732f5ba18 feat: 가뭄지도 시도, 시군구 Custom 정렬 2025-08-13 16:26:45 +09:00
swcho f21bd9a582 Merge branch 'iipbs/common/main' into iipbs/wr/drgtPopup 2025-08-13 16:25:07 +09:00
swcho ce94ca7282 Merge branch 'iipbs/common/main' into iipbs/wr/dev 2025-08-13 16:20:31 +09:00
swcho 428f617802 fix: defaultZoom props 수정 2025-08-13 16:20:15 +09:00
swcho 9b4609a5b3 Merge remote-tracking branch 'origin/iipbs/wr/drgtPopup' into iipbs/wr/dev 2025-08-13 16:15:00 +09:00
swcho be11f6252a feat: 가뭄지도 초기 화면을 전국으로 수정 2025-08-13 16:14:10 +09:00
swcho d831bcf1a1 Merge branch 'iipbs/common/main' into iipbs/wr/drgtPopup 2025-08-13 16:11:59 +09:00
swcho e797bdbdf7 feat: Gis.js defaultZoom, defaultLonLat props 추가 2025-08-13 16:11:33 +09:00
mind-one005 ff0fca0b4d Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2025-08-13 15:55:24 +09:00
mind-one005 49dc55e5f7 Merge branch 'iipbs/common/main' into iipbs/sn/dev 2025-08-13 15:55:12 +09:00
mind-one005 cb11ec839e UiModal <div> <> 2025-08-13 15:50:23 +09:00
swcho afbe4e9a8f Merge remote-tracking branch 'origin/iipbs/sn/ii' into iipbs/sn/dev 2025-08-13 15:49:32 +09:00
swcho 2d22426efd Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2025-08-13 15:49:08 +09:00
swcho f02a38702a fix: 시군구, 하수맨홀 용도 SelectBox 너비 수정 2025-08-13 15:48:16 +09:00
mind-one005 848e28d99e 파일modal 디자인 수정 2025-08-13 15:45:41 +09:00
swcho f9424f6ac8 fix: merge 반영 안된 사항 추가 2025-08-13 15:45:06 +09:00
swcho 1fa935752f Merge branch 'iipbs/common/main' into iipbs/sn/ii 2025-08-13 15:40:39 +09:00
swcho 772dd1c1aa refactor: 시도, 시군구 순서 Custom 정렬 2025-08-13 15:40:07 +09:00
swcho 7c9848cbc5 fix: 시도, 시군구 순서 Custom 정렬 2025-08-13 15:35:09 +09:00
mind-one005 11a3e0cb01 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2025-08-13 15:17:52 +09:00
mind-one005 5e9a49abe8 파일목록 modal 2025-08-13 15:17:45 +09:00
swcho 1da2ae59f7 fix: 설계빈도 에러 메세지 상태 초기화 추가 2025-08-13 14:57:56 +09:00
swcho 10f44bba3c fix: 빗물펌프장 자체 입력정보에서 '설계빈도(년)'에 숫자만 입력되게끔 수정 (placeholder 추가, alert, validation) 2025-08-13 14:51:25 +09:00
leedj312 5f559b4e2d 하수처리시설 팝업도 수정 2025-08-13 13:56:51 +09:00
swcho 12baddecbf fix:
- 사업유형 SelectArray에 ‘AAAAA‘ 항목 빼고 '전체' 항목 추가
- 하수관로, 맨홀에도 '전체' 항목 추가
- 맨홀에서 용도 조회 조건이 적용 안되는 에러 수정
- 맨홀 조회 결과 컬럼에서 '재질'을 '용도'로 변경
2025-08-13 13:31:07 +09:00
leedj312 c47167cc64 Merge commit 'aec7e7de3a006936c703d0d6e40c5d0c42aab84b' into iipbs/wr/dev 2025-08-13 13:21:19 +09:00
leedj312 6fef06963d 감리지적사항 수정 2025-08-13 13:17:31 +09:00
truthwing aec7e7de3a 년도 역 정렬 되도록 수정. 2025-08-13 11:40:07 +09:00
truthwing 0964eddc0e 불필요한 filter로 인한 함수 오류 해결.
jsyang product(titanume knife 준비 완료)
2025-08-13 11:18:50 +09:00
rsone001 f43e14f664 연계 리스트 검색 조건 추가 2025-08-13 11:18:41 +09:00
swcho d137438725 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2025-08-13 11:10:45 +09:00
swcho a527b15ce0 fix: searchModal UiTable 페이지네이션시 Select 초기화 에러 수정 2025-08-13 11:10:18 +09:00
mind-one005 e4df3ac86c 수용가 상수사용량 2025-08-13 09:51:04 +09:00
leedj312 a5f7fb7fe8 빗물 통계 수정 2025-08-13 09:43:43 +09:00
mind-one003 5c28d56d83 빌드에러 수정 2025-08-12 18:46:29 +09:00
mind-one003 ba81c01b06 지하수유입량 모달 평균최대최소 수정 2025-08-12 18:39:56 +09:00
swcho e597ba391c refactor: 변수 선언 삭제 2025-08-12 17:42:20 +09:00
leedj312 2edb56284e 물재이용 지도 팝업 창 수정 2025-08-12 17:31:57 +09:00
yschoi 1af498b382 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2025-08-12 17:26:35 +09:00
yschoi 3641058fd0 목록에서 시설 클릭시 화면으로 이동 기능 추가 2025-08-12 17:26:26 +09:00
mind-one003 f35c937c0f 강우일 운영현황 수정, 콘솔들 제거 2025-08-12 16:40:39 +09:00
leedj312 667dc58847 하수재이용 시설 현황 팝업 실적 없을시 표출 안함으로 수정 2025-08-12 14:38:01 +09:00
mind-one003 a6f39491ef rdii 분석 수정 2025-08-12 11:36:38 +09:00
swcho 98d4de2938 feat: 하수맨홀, 하수펌프장, 우수토실 시도, 시군구 조회 조건 및 컬럼 추가 2025-08-12 11:33:09 +09:00
leedj312 1feec308c1 중수도 지도점검 엑셀 검증 로직 수정 변경 2025-08-12 11:03:41 +09:00
swcho a3325b104d Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2025-08-12 10:59:06 +09:00
swcho 8c79a13d3a fix:
- searchModal viewPort Max값 100으로 수정.
- 운영정보 조회 건수 5개로 수정
2025-08-12 10:59:03 +09:00
mind-one005 cc1c8caeef 수용가 상수사용량 upload 2025-08-12 10:58:18 +09:00
jcchoi 8b404648de 오류 처리 2025-08-12 10:56:53 +09:00
swcho bd7c5b4d50 fix: 운영정보 조회 건수 4건으로 수정 2025-08-12 10:39:26 +09:00
jcchoi 18ebfa1c79 site ID 변경시 경고메세지지 2025-08-12 10:26:16 +09:00
leedj312 70dcc69f2d 중수도 지도점검 업데이트시 조회 기능 오류 수정 2025-08-12 10:18:54 +09:00
jcchoi 2ab1a9fe19 Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-08-12 10:13:02 +09:00
jcchoi 32ce555321 단위테스트 오류 수정. 2025-08-12 10:12:29 +09:00
swcho 7b6c2af463 fix: 기본정보 하수관로 검색 너비 수정 2025-08-12 10:10:21 +09:00
leedj312 b8cd986435 중수도 지도점검 엑셀등록 수정 2025-08-12 09:55:09 +09:00
swcho fa7a7d1bd9 fix:
- 침입수 분석, RDII 분석 실행 후 하수처리시설명 안 보이는 에러 수정
- reportTab.tsx에 불필요 log 삭제
2025-08-12 09:45:48 +09:00
swcho 6d768432f8 refactor: 처리구역 값 없을 때 '-'로 표시 2025-08-12 09:20:20 +09:00
mind-one003 eeef871a93 빌드에러 수정 2025-08-11 22:16:15 +09:00
mind-one003 4941593bb0 대시보드 연계처리수 배포완료, 대시보드 기준년도 텍스트 추가 2025-08-11 22:13:37 +09:00
leedj312 99aff0c53c 지도점검 수정사항 반영 2025-08-11 21:04:31 +09:00
leedj312 33e46e0fe8 지도점검 수정사항 반영 2025-08-11 21:04:19 +09:00
leedj312 f2dd0ae3a9 하수재이용 시설 지도 찾기 pk 변경 2025-08-11 19:46:55 +09:00
leedj312 3ed0049eee 수요처 수정사항 반영 2025-08-11 19:10:35 +09:00
swcho a91b2d2c36 fix: trim() 에러 수정 2025-08-11 18:27:57 +09:00
swcho 701cd92760 Merge branch 'iipbs/common/main' into iipbs/sn/dev 2025-08-11 18:23:24 +09:00
swcho 579e599eb1 feat searchBar 시설명 가나다 순으로 정렬 2025-08-11 18:23:05 +09:00
swcho 1886bd74e0 fix: 시도, 시군구 원앤올 -> 알애스원 변경으로 인한 수정 작업 2025-08-11 18:16:37 +09:00
leedj312 b737b04c86 Merge branch 'iipbs/wr/dev' 2025-08-11 18:05:04 +09:00
mind-one003 a6bb1bc663 콘솔제거, 대시보드 년도 추출수정 2025-08-11 17:51:32 +09:00
leedj312 0e99fb8192 대민 및 물재이용 수요처 등록 폼 readOnly 추가 2025-08-11 17:51:27 +09:00
swcho 4c76e572b2 fix: reportTab 시도, 시군구 타입 수정 2025-08-11 17:49:10 +09:00
swcho 792e38c0c5 feat:
- 운영정보 검색에 하수처리구역 컬럼 추가
- 기본정보 하수관로 검색에 시도, 시군구, 용도 컬럼 추가
- 공통 코드 시도, 시군구 API 원앤올 -> 알애스원으로 변경
2025-08-11 17:39:59 +09:00
leedj312 12b9ffe65c 수요처 팝업 추가 완료 2025-08-11 17:39:30 +09:00
leedj312 06c840951b 필요없는 코드 삭제 2025-08-11 16:25:43 +09:00
leedj312 7d137c93c5 detaile시설 입력 팝업 셀누르면 이동및 팝업 창 뜨도록 수정 2025-08-11 15:58:41 +09:00
rsone001 ad7bfc0456 연계 정포 form 수정 2025-08-11 15:21:58 +09:00
mind-one003 055d0bef16 오타수정, 처리구역정보 컬럼변경 2025-08-11 14:50:52 +09:00
swcho b87d1280ef fix: 컬럼명 변경으로 인한 cql_filter 수정 2025-08-11 14:45:28 +09:00
yschoi 959f3df7e9 Merge remote-tracking branch 'origin/iipbs/fo/dev' 2025-08-11 14:38:57 +09:00
yschoi 6c45d96473 불필요 삭제 2025-08-11 14:38:16 +09:00
yschoi 8ec4523dbf Merge remote-tracking branch 'origin/iipbs/fo/dev' 2025-08-11 14:10:30 +09:00
yschoi e299ad0854 Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-08-11 14:10:11 +09:00
yschoi 5eb80b1362 Merge branch 'iipbs/fo/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/fo/dev 2025-08-11 14:09:37 +09:00
yschoi ebd37ccea1 초기화 버튼 추가 2025-08-11 14:09:26 +09:00
mind-one003 83880cea6d - 오수발생량 추정 기간선택 문제해결
- geoserver 주소 변경
2025-08-11 14:04:31 +09:00
swcho 7373aba2cd refactor: NoFooterLayout 적용으로 인한 featureClickEventPopup의 height 조절 2025-08-11 13:45:36 +09:00
swcho e512f47045 Merge branch 'iipbs/sn/dev' into iipbs/sn/ii 2025-08-11 13:42:13 +09:00
swcho 0925c80326 Merge remote-tracking branch 'origin/iipbs/wr/dev' 2025-08-11 13:34:54 +09:00
swcho 255ec04cfb Merge branch 'iipbs/sn/dev' 2025-08-11 13:33:21 +09:00
swcho 39fe92154f fix: GEOSERVER_URL 변경 (침수위험지도 표출 에러 때문) 2025-08-11 13:28:10 +09:00
swcho d32e5ea592 fix: GEOSERVER_URL 변경 (침수위험지도 표출 에러 때문) 2025-08-11 13:19:04 +09:00
Your Name 92cdbd8a7b 오수발생량 조치 진행중 2025-08-11 13:03:43 +09:00
chanii8 10abca7d35 [common/components] fix: siteId 가져오는 방식 수정 2025-08-11 12:26:17 +09:00
chanii8 579db080a3 Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-08-11 11:08:02 +09:00
yschoi f364277ae8 로그 삭제 2025-08-11 10:54:16 +09:00
yschoi 1691829c42 Merge remote-tracking branch 'origin/iipbs/wr/dev' 2025-08-11 10:50:37 +09:00
yschoi b5f0ee5cdc geometry 정보 없을때 알랏 2025-08-11 10:32:10 +09:00
chanii8 2fd1159615 Revert "[common/features] feat: 환경 변수 URL 방식 도입"
This reverts commit e4d52611d7.
2025-08-11 10:29:13 +09:00
yschoi 1e0ef0c713 온배수 시설 지도 표출 수정 2025-08-11 10:23:47 +09:00
yschoi 3d480a40a7 로그 삭제 2025-08-11 09:47:20 +09:00
yschoi ce9c536858 가동개시일 공백제거 2025-08-11 09:45:58 +09:00
yschoi bde50388d1 시설용량 천단위 콤마 2025-08-11 09:34:05 +09:00
yschoi d86d0b0fca 피처 좌표 정보 없을때 알랏 2025-08-11 09:21:40 +09:00
yschoi 4c3d79f488 Merge remote-tracking branch 'origin/main' into iipbs/fo/dev 2025-08-10 22:39:19 +09:00
yschoi fc80078d96 Merge remote-tracking branch 'origin/main' into iipbs/fo/dev 2025-08-10 22:37:42 +09:00
yschoi 5aaf346074 Merge remote-tracking branch 'origin/iipbs/fo/dev' 2025-08-10 22:36:48 +09:00
yschoi 21af492743 로그인 처리 layout으로 이동 처리 2025-08-10 22:32:10 +09:00
yschoi 6ccbfba3b9 Merge remote-tracking branch 'origin/iipbs/wr/dev' 2025-08-10 22:29:22 +09:00
yschoi 6d9327bf00 불필요 삭제 2025-08-10 22:20:22 +09:00
yschoi 1a4fa66519 빌드 2025-08-10 21:57:58 +09:00
yschoi d85e8c0572 빌드 2025-08-10 21:57:09 +09:00
yschoi 94306f052e 망할 빌드 2025-08-10 21:55:26 +09:00
yschoi aec64ac26d 빌드 오류 2025-08-10 21:45:24 +09:00
yschoi c0ffec1492 Merge branch 'iipbs/wr/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/wr/dev 2025-08-10 21:32:28 +09:00
yschoi be43fd38a7 물재이용 지도보기 수정 2025-08-10 21:32:21 +09:00
jcchoi db441ab353 "yargs": "^17.7.2", 2025-08-10 21:08:22 +09:00
leedj312 cceb84aa99 후보지 조회 부분 기능 적용 2025-08-10 20:07:51 +09:00
jcchoi df4f1f6157 Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-08-10 19:18:34 +09:00
jcchoi 96deebdaad Elint 오류 수정 2025-08-10 19:18:17 +09:00
yschoi fbd057e248 Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-08-10 19:13:09 +09:00
yschoi 1a0c5d01ce fo 빌드 오류 수정 2025-08-10 19:13:03 +09:00
jcchoi f8bcac3d2b Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-08-10 18:28:15 +09:00
yschoi a3d9c32024 운영실태 지도 2025-08-10 18:24:57 +09:00
yschoi 061cd5aecc Merge remote-tracking branch 'origin/iipbs/fo/dev' 2025-08-10 15:56:08 +09:00
yschoi 940b3208f1 맵 레이아웃 수정 2025-08-10 15:52:56 +09:00
yschoi e91b5a5ddb Merge remote-tracking branch 'origin/main' into iipbs/fo/dev 2025-08-10 15:23:07 +09:00
yschoi 239d838aae Merge remote-tracking branch 'origin/iipbs/fo/dev' 2025-08-10 15:22:21 +09:00
yschoi c93571781d 운영실태 지도 오류 수정중 2025-08-10 15:21:47 +09:00
jcchoi 57e363e1a3 CM 회원가입 추가 2025-08-10 13:04:42 +09:00
jcchoi a2b081eb10 CM 회원가입 수정정 2025-08-10 13:04:11 +09:00
yschoi b44eac9671 Merge remote-tracking branch 'origin/iipbs/wr/dev' 2025-08-10 03:56:39 +09:00
yschoi 5aed78e228 error 제거 2025-08-10 03:50:06 +09:00
yschoi 11396a92d6 빌드 2025-08-10 03:28:04 +09:00
yschoi 7304eccf1b 빌드 오류 2025-08-10 03:23:31 +09:00
yschoi 2207d7f61b 빌드 오류 2025-08-10 03:20:15 +09:00
yschoi 0c23c99ef6 빌드 오류 2025-08-10 03:06:35 +09:00
yschoi 9dc1c88c80 임시 2025-08-10 02:59:43 +09:00
yschoi a9c3d7f0b9 Merge remote-tracking branch 'origin/iipbs/wr/dev' 2025-08-10 02:46:30 +09:00
yschoi 044c3a3cb9 타입 수정 2025-08-10 02:32:00 +09:00
yschoi a64328ef89 불필요 삭제 2025-08-10 02:18:11 +09:00
yschoi 027dd01747 Merge branch 'iipbs/wr/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/wr/dev 2025-08-10 02:05:13 +09:00
yschoi 4903684bce 공급처 선정 지도 팝업 추가 2025-08-10 02:05:04 +09:00
swcho fe524f98b4 test: 개발 서버 배포 에러로 인한 wr에 ol 패키지 추가 2025-08-10 00:59:52 +09:00
swcho b5c111fef0 fix: 개발 서버 빌드 에러로 인한 import 수정 2025-08-10 00:52:35 +09:00
swcho 5b4b8fbd80 fix: 개발 서버 빌드 에러로 인한 타입 다시 제거 2025-08-10 00:48:54 +09:00
swcho bd3015dec5 fix: 개발 서버 빌드 에러로 인한 타입 추가 2025-08-10 00:40:23 +09:00
swcho 57fc624fbc feat: WR(물재이용) 가뭄지도 팝업 구현 (잠재수요처, 수요처 클릭 이벤트 구현 필요) 2025-08-10 00:29:50 +09:00
swcho e6eec2a097 Merge branch 'iipbs/common/main' into iipbs/wr/drgtPopup 2025-08-09 23:46:40 +09:00
swcho 3cc8fcb2eb fix: 오타 수정 2025-08-09 23:46:20 +09:00
swcho f644bd6b7e refactor, remove, add: GIS main 브랜치와 동기화 2025-08-09 23:43:37 +09:00
swcho 0c2a5f5287 Merge remote-tracking branch 'origin/iipbs/common/main' into iipbs/wr/drgtPopup 2025-08-09 23:05:49 +09:00
swcho b871226f8e refactor: import 및 변수 정리 2025-08-09 22:53:58 +09:00
swcho 3116fe8bed fix: mapService import 경로 수정 2025-08-09 22:53:07 +09:00
swcho 828f46fceb Merge branch 'main' into iipbs/sn/ii 2025-08-09 22:46:10 +09:00
swcho b095ca7ed8 Merge branch 'iipbs/common/main' into iipbs/sn/ii 2025-08-09 22:43:23 +09:00
swcho ed288d3e49 Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-08-09 22:42:20 +09:00
swcho 78bc841f9a Merge branch 'iipbs/common/main' 2025-08-09 22:39:41 +09:00
swcho f685884ed7 feat: WR(물재이용) 가뭄지도 팝업 구현 을 위한 Gis 수정 2025-08-09 22:38:02 +09:00
swcho 958f6eea02 feat: WR(물재이용) 가뭄지도 팝업 구현 (잠재수요처, 수요처 클릭 이벤트 구현 필요) 2025-08-09 22:26:51 +09:00
yschoi b1c1b0bc29 Merge remote-tracking branch 'origin/iipbs/wr/dev' 2025-08-09 17:41:23 +09:00
mind-one001 fef6ac864b Delete pnpm-lock.yaml 2025-08-09 17:38:21 +09:00
yschoi 9f4e07833d Merge remote-tracking branch 'origin/iipbs/wr/dev' 2025-08-09 17:21:31 +09:00
leedj312 968bf94de2 물재이용 수요매칭 검토 후보처 선택을 위한 사전작업 2025-08-09 16:49:52 +09:00
yschoi 807822082d Merge remote-tracking branch 'origin/iipbs/wr/dev' 2025-08-09 16:19:05 +09:00
leedj312 552c9a1532 재이용시설 팝업 실적등록 수정 2025-08-08 18:40:02 +09:00
swcho 114270d8e9 Merge branch 'iipbs/sn/dev' 2025-08-08 17:37:13 +09:00
swcho 9bf66d8ca3 Merge branch 'iipbs/sn/ii' into iipbs/sn/dev 2025-08-08 17:34:55 +09:00
swcho 388315bdf6 add: swcho 로컬 백엔드 주소 env.local에 추가 2025-08-08 17:33:57 +09:00
swcho 92692c0c46 feat:
- 도시침수예방사업, 노후 하수관로 정비사업 등록자 등록 및 조회 기능 구현
- 컬럼명 수정된 거에 맞게 변수명 수정 (하수펌프장, 하수맨홀)
2025-08-08 17:32:06 +09:00
leedj312 986e7a5c3b 페이지 선택 오류 및 잘못된 api 설정 2025-08-08 17:30:22 +09:00
chanii8 e4d52611d7 [common/features] feat: 환경 변수 URL 방식 도입 2025-08-08 17:22:58 +09:00
chanii8 2b3475a626 Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-08-08 16:27:13 +09:00
swcho a21d4d3d5d Merge branch 'iipbs/sn/ii' into iipbs/sn/dev 2025-08-08 15:30:56 +09:00
swcho 32c36c770c feat: 도시침수예방사업, 노후하수관로 등록 시 등록자 정보 추가 2025-08-08 15:30:37 +09:00
leedj312 fcb39e289e 지도 팝업 수정사항 반영 2025-08-08 14:53:18 +09:00
leedj312 0e1f8f5868 풋터없는 레이아웃 적용 2025-08-08 14:43:38 +09:00
chanii8 e11c200f4c [apps/cs] feat: 모듈API관리 폼 수정 2025-08-08 14:36:56 +09:00
leedj312 e36c744ad9 Merge commit '8561b95e47c33b32114cc8ea265835f8dd8fee64' into iipbs/wr/dev 2025-08-08 14:35:49 +09:00
leedj312 8561b95e47 빌드 오류 수정 2025-08-08 14:34:45 +09:00
leedj312 21599945ac Merge commit 'c2711a7ad4513c8d4708934fa0abe3003b57baf7' 2025-08-08 14:22:24 +09:00
chanii8 6a99d1f0d8 [apps/cs] feat: 권한맵핑 다이얼로그 검색 조건 추가 2025-08-08 14:20:59 +09:00
swcho 2756abec49 fix: 침입수 분석, RDII 분석 컬럼명 수정에 따른 변수명 수정 및 로직 수정 (아직 테스트 미완료) 2025-08-08 13:59:54 +09:00
swcho c2711a7ad4 Merge branch 'iipbs/common/main' 2025-08-08 13:35:32 +09:00
swcho a2af60d52e fix: NoFooterLayout 사용으로 인한 Gis.js의 default height 수정 2025-08-08 13:34:58 +09:00
leedj312 305cea6bf5 Merge commit 'abe930c37a0e8f47fd40096e6ec28ede6d0e1349' 2025-08-08 13:34:32 +09:00
swcho 5a6a2974fb feat: SN(하수관로) Gis Page Layout NoFooterLayout으로 변경 2025-08-08 13:34:17 +09:00
leedj312 abe930c37a 운영실태 레이아웃 구조 변경 2025-08-08 13:34:08 +09:00
yschoi d9e1a988be Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-08-08 13:00:57 +09:00
yschoi 17022bc626 물재이용 지도 onOpenPopup 추가 2025-08-08 13:00:49 +09:00
leedj312 4e65b3129f 지도 nofooter 레이아웃 추가 2025-08-08 11:20:47 +09:00
rsone001 bab8edb85b 데이터 스키마 수정 2025-08-08 10:47:53 +09:00
yschoi 4f9f605597 lock 삭제 2025-08-08 10:26:32 +09:00
yschoi 0ef170b7a2 Merge remote-tracking branch 'origin/main' into iipbs/sn/dev 2025-08-08 10:26:15 +09:00
yschoi fbeb9a106a ngii 변수 추가 2025-08-08 10:18:58 +09:00
mind-one005 b019562023 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2025-08-08 09:24:40 +09:00
leedj312 effc8243cb Merge remote-tracking branch 'origin/iipbs/wr/dev' 2025-08-08 09:18:08 +09:00
leedj312 487edd4573 팝업 크기 수정/ 재이용 시설 팝업 추가 2025-08-07 19:54:17 +09:00
jcchoi bff409bebd Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-08-07 19:41:03 +09:00
jcchoi fcc8ea424d UI 수정 2025-08-07 19:40:32 +09:00
chanii8 1815ea6d0b [common/components] chore: 알림 api 수정 2025-08-07 18:48:32 +09:00
chanii8 f132a30b5c Merge branch 'iipbs_cs' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-08-07 18:35:15 +09:00
chanii8 bf07f286a9 [apps/cs] docs: cs 프로젝트 구조 문서 작성 2025-08-07 18:28:29 +09:00
chanii8 e72b01dc16 [apps/cs] 권한관리 수정사항 반영 2025-08-07 18:28:08 +09:00
jcchoi 658bb45762 Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-08-07 16:33:24 +09:00
jcchoi 2e8b0f5437 푸터없는 레이아웃 추가 2025-08-07 16:33:11 +09:00
yschoi 08283abe23 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2025-08-07 15:28:25 +09:00
yschoi ceda7ab5d1 불필요 코드 삭제 2025-08-07 15:28:16 +09:00
yschoi f9f268102b 배경지도 url 정보 추가 2025-08-07 15:27:07 +09:00
mind-one005 42cccbd0d4 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2025-08-07 15:11:20 +09:00
mind-one005 92677d1e71 뷴석결과 시도,시군구명 조회 2025-08-07 15:11:15 +09:00
swcho bd9ff4167d fix: 하수도정비 기본계획보고서 페이지네이션 시에 select 초기화 2025-08-07 14:47:52 +09:00
swcho 213ad1cb5d refactor: 중점우수토실 상세정보 text-size 일치시키게 수정 2025-08-07 14:31:33 +09:00
swcho b6d91178d3 feat: 중점우수토실 상세정보창 구분 테두리 추가 2025-08-07 14:00:38 +09:00
leedj312 c5e18c80fc 물재이용 안쓰는api삭제 2025-08-07 13:29:18 +09:00
jcchoi 59a9680200 권한관리 수정 일괄 체크되게 2025-08-07 13:07:40 +09:00
leedj312 164428e953 파일 재수정 2025-08-07 10:04:35 +09:00
chanii8 7e1f40147e [common/components] feat: 알림 기능 추가 2025-08-07 09:55:41 +09:00
leedj312 6f85078699 잘못된 파일명 수정 2025-08-07 09:48:46 +09:00
leedj312 bea6071164 츙돌사항 수정사항 반영 2025-08-07 09:38:25 +09:00
leedj312 f849067c3e Merge commit '2bfd50cff260ec1eb1eea772d1c22d06b771dfe2' 2025-08-07 09:37:43 +09:00
leedj312 2bfd50cff2 Merge commit '551c41e9643fb2aaedfb3d21135493871ed24629' into iipbs/wr/dev 2025-08-07 09:20:55 +09:00
yschoi cf976a4eff 물재이용 지도보기 수정 2 2025-08-06 21:14:16 +09:00
yschoi 0a395f89cd Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-08-06 21:09:25 +09:00
yschoi 2cfecb3189 물재이용 지도보기 수정 2025-08-06 21:09:11 +09:00
swcho 81b87ef91b Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-08-06 21:04:09 +09:00
swcho c9ed490381 Merge branch 'iipbs/common/main' into iipbs/sn/dev 2025-08-06 21:00:19 +09:00
swcho 395590bbe4 feat: gis.layer.js 최신화 2025-08-06 20:59:56 +09:00
swcho 5383bce739 fix: cmncNm All일 경우 조회가 안되는 에러 수정 2025-08-06 20:19:29 +09:00
swcho d45bb6b657 remove: searchBar.tsx 파일 삭제 (공통의 procSearchBar 사용) 2025-08-06 19:51:34 +09:00
leedj312 551c41e964 지도 수정완료 하지만 팝업 크기 조정해야함 2025-08-06 19:30:13 +09:00
yschoi 75ec3ff2d8 지도 수정 2025-08-06 19:19:15 +09:00
swcho 96dd599fa9 Merge remote-tracking branch 'origin/main' into iipbs/sn/dev 2025-08-06 18:38:38 +09:00
swcho 16570bfd45 fix: 하수관로 DB 변경된 컬럼명에 맞게 변수명 수정 2025-08-06 18:35:24 +09:00
leedj312 7ea388b30a 지도 현재 만들어진것까지 수정 완료 추가로 만들것 확인해서 해야함 2025-08-06 17:57:22 +09:00
swcho 4f1d33fa32 refactor: 하수처리구역 변수명 수정 (Cd -> Nm) 2025-08-06 17:42:18 +09:00
yschoi 2d397cc23e Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-08-06 17:41:57 +09:00
swcho 90c2ff1f65 fix: package.json에 react toast 의존성 추가 2025-08-06 17:12:30 +09:00
yschoi 9908bac1ca gis 설정 추가 2025-08-06 17:06:24 +09:00
swcho 84eebe067d Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2025-08-06 17:04:52 +09:00
swcho c8962fae76 Merge branch 'iipbs/sn/ii' into iipbs/sn/dev 2025-08-06 17:01:59 +09:00
swcho 4c256d69a3 feat:
- toast.tsx 추가
- 침입수 분석, RDII 분석 버튼 실행 시 시간 소요 안내 toast 기능 구현
2025-08-06 16:59:38 +09:00
yschoi 9fec4b7f6b build:sn 추가 2025-08-06 13:07:10 +09:00
jcchoi e533393293 Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-08-06 12:41:35 +09:00
jcchoi fa56510e52 CSP 처리 2025-08-06 12:41:00 +09:00
swcho 14ba55b4f9 Merge remote-tracking branch 'origin/main' into iipbs/sn/dev 2025-08-06 10:52:52 +09:00
swcho 1833863d6f feat: 침수심 범례 재생 버튼 상단 마진 추가 2025-08-06 10:26:22 +09:00
swcho e6fb4bebdc refactor: 침수심 범례 색상 및 문구 환경부 표준으로 수정 2025-08-06 10:22:11 +09:00
swcho 86fe10ec91 Merge branch 'iipbs/common/main' into iipbs/sn/ii 2025-08-06 09:58:40 +09:00
swcho 6c0d65a69a feat: Gis Search 버튼 hover시에 표시되는 title 추가 2025-08-06 09:50:50 +09:00
leedj312 bb6bba4497 물재이용 맵 시설 현황 팝업 검색 조건 추가 2025-08-06 09:36:13 +09:00
leedj312 24a55142fb 물재이용 지도 전반적인 수정 트리에서 클릭시 까지 완료(검색바 해야함) 2025-08-05 20:28:32 +09:00
jyha00 ba56426947 중수도지도점검 냄새, ph -로 표기 변경 2025-08-05 18:43:25 +09:00
swcho 66d02cda0a feat: 침수심 범례에 진행바(Progress Bar) 추가 2025-08-05 18:17:46 +09:00
swcho 2387200554 Merge branch 'iipbs/common/main' into iipbs/sn/ii 2025-08-05 18:13:59 +09:00
swcho a6b34780ae feat: 침수심 범례에 진행바(Progress Bar) 추가 2025-08-05 18:11:09 +09:00
swcho cb153f1c4a refactor:
- 침수심 범례 interval 1초 -> 2.5초 수정 (도시침수지도 표출 때문)
- page.tsx 타입 단언 추가
2025-08-05 17:29:01 +09:00
swcho c3d220b6af Merge branch 'iipbs/common/main' into iipbs/sn/ii 2025-08-05 16:40:04 +09:00
swcho a83ea455fb feat: GisTree 레이어 Check 함수 구현 (침수심 범례 기능) 2025-08-05 16:35:03 +09:00
swcho 71a7335de4 feat: 침수심 범례 애니메이션 기능 추가 2025-08-05 16:18:40 +09:00
rsone001 d019ed6182 연계정보관리 소스 수정 2025-08-05 14:12:26 +09:00
swcho 115fbbfc99 feat: 침수심 범례 기능 추가 2025-08-05 11:22:22 +09:00
swcho 7ad50914b3 Merge branch 'iipbs/common/main' into iipbs/sn/ii 2025-08-05 10:57:11 +09:00
swcho ab63cbe992 feat: Gis.js에 침수심 범례 추가 2025-08-05 10:55:47 +09:00
swcho 2a064ed68e fix: 시도, 시군구 조건 에러 수정 2025-08-05 10:01:52 +09:00
chanii8 a618710214 Merge branch 'iipbs_cs' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-08-05 09:55:50 +09:00
jyha00 1e650a72ba 지도점검 페이지네이션 수정, 엑셀 등록 수정, 점검일 날짜 포매터 적용 2025-08-04 19:53:36 +09:00
swcho d979c01c44 fix: 하수도정비기본계획보고서 시도, 시군구 조회 조건 에러 수정 2025-08-04 19:50:04 +09:00
swcho b494fdd1a6 feat: 하수도정비기본계획 보고서 모달 형식으로 개발 2025-08-04 18:44:48 +09:00
swcho f6885be384 Merge branch 'iipbs/common/main' into iipbs/sn/ii 2025-08-04 18:41:56 +09:00
swcho b2a6285475 feat: UiModal 컴포넌트에 headerTitleClass props 추가 2025-08-04 18:41:35 +09:00
swcho 3f039d34a9 remove: GovSidoSigunguSearchForm 컴포넌트 삭제 2025-08-04 18:38:34 +09:00
swcho f82a392ef6 feat: 자체입력정보에 관리기관코드 추가 2025-08-04 18:15:11 +09:00
swcho a062810bd0 feat: 자체입력 정보 설계빈도(년) 항목 추가 2025-08-04 15:43:21 +09:00
swcho 897d969799 fix: auth 값 누락 부분 재추가 2025-08-04 14:48:07 +09:00
leedj312 2c6327bc3a 하수재이용 시설 차수 등록 버튼 수정 및 빗물 지도점검 데이터 유형 변경 (ph) 수정 2025-08-04 13:07:52 +09:00
jcchoi 32201eb64f Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-08-04 10:46:41 +09:00
jcchoi c56a32dad6 공통 로그인 수정정 2025-08-04 10:46:35 +09:00
jcchoi 3c76b3ae0b 도움말 추가 2025-08-04 10:36:08 +09:00
yschoi 4a1cb73c50 빌드 오류 수정 2025-08-04 10:29:37 +09:00
swcho 71fa8eb13f refactor: 자체입력정보 변수명 변경 2025-08-04 10:14:26 +09:00
yschoi 7a9d568021 gis 수정 2025-08-04 08:57:13 +09:00
yschoi 837eaf181d Merge remote-tracking branch 'origin/iipbs/dm/dev' 2025-08-04 08:24:10 +09:00
yschoi d7b5e3a804 Merge remote-tracking branch 'origin/iipbs/wr/dev' 2025-08-04 08:22:17 +09:00
yschoi 937cccb224 지도 검색 기능 2025-08-04 08:16:49 +09:00
yschoi e2171843b2 병합 2025-08-03 14:35:58 +09:00
yschoi 574fbb48b4 Merge remote-tracking branch 'origin/main' into iipbs/sn/dev 2025-08-03 14:33:05 +09:00
yschoi 8eede7432b 시도시군구 정렬 삭제 2025-08-03 14:30:16 +09:00
yschoi e61a410a7f Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-08-03 14:29:16 +09:00
yschoi d45ce537a3 물재이용 지도보기 2025-08-03 14:29:11 +09:00
yschoi 71aacf4c76 물재이용 지도보기 2025-08-03 14:27:49 +09:00
jcchoi 77ec79981a Merge branch 'iipbs_cs' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs_cs 2025-08-02 20:03:43 +09:00
jcchoi 2f097e2998 메뉴 도움말 추가 2025-08-02 20:03:20 +09:00
chanii8 aabd810571 [common/features] refactor: 통합 검색 기능 공통 기능으로 변경 2025-08-02 02:42:20 +09:00
swcho f6333d0b29 refactor: 침수심 범례 주석 처리 2025-08-01 19:16:16 +09:00
swcho 2644e771da feat: 침수심 범례 UI 구현 (기능 추가 구현 필요)2 2025-08-01 19:14:38 +09:00
swcho de8276851e feat: 침수심 범례 UI 구현 (기능 추가 구현 필요) 2025-08-01 19:14:19 +09:00
swcho 7c7ce750f7 feat:
- 시도, 시군구, 하수처리장 SelectBox 컴포넌트 구현
- searchBar 컴포넌트 UI 개발 (zoom 및 이동 기능 추가 필요)
2025-08-01 19:13:37 +09:00
rsone001 5e9a9c82ff 연계 소스 수정 2025-08-01 17:54:30 +09:00
swcho 79b038657a feat: json 데이터 fid -> fcltMngNo로 변경 2025-08-01 17:50:59 +09:00
swcho 8568e10b3f fix: searchModal.tsx 에서 open props 삭제 2025-08-01 17:08:26 +09:00
swcho f9fff06f62 feat:
- SidoSigunguSelectoBox.tsx 명칭 ctpvSggPrcsFcltSelectBox.tsx로 수정 및 하수처리시설 SelectBox 컴포넌트 포함
- ctpvSggPrcsFcltSelectBox.tsx 변수명 변경
- 중점우수토실 운영정보 모달 상단 라벨 추가
2025-08-01 16:55:21 +09:00
swcho 406826a11b feat: Store SN Root 경로에서 사용하도록 수정중 (추가 작업 필요) 2025-08-01 16:44:08 +09:00
leedj312 9973b35802 Merge commit '9bdf01e7f3272aec4aed2c9031bc59851730b111' into iipbs/wr/dev 2025-08-01 16:30:58 +09:00
jyha00 9bdf01e7f3 모달창 사이즈 조정 2025-08-01 16:16:50 +09:00
chanii8 98cc45162d [apps/cs] feat: 검색 시 파라미터 업데이트 2025-08-01 16:14:01 +09:00
chanii8 25238f9729 Merge branch 'iipbs_cs' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs_cs 2025-08-01 15:53:11 +09:00
chanii8 605e0a3e3a [apps/cs] feat: 통합 검색 페이지 작업 2025-08-01 15:52:51 +09:00
jyha00 cb83b7f92b 하수재이용시설 현황 엑셀 데이터 없을 시 toast 추가 2025-08-01 15:50:44 +09:00
leedj312 8653335aa9 수요신청 사업자 비밀번호 초기 화 메시지 변경 2025-08-01 15:26:54 +09:00
jyha00 59e3dbbe97 중수도 신규등록 텍스트 수정 2025-08-01 14:59:36 +09:00
leedj312 ddd1e3b50b Merge commit 'ca9f2da83cacff3756a6ef43f85affae0cd57d30' into iipbs/dm/dev 2025-08-01 14:41:17 +09:00
leedj312 a85509e3c9 Merge commit 'def2867c289ec84a77aeeaa7fc9ec47c1c95ab0a' into iipbs/dm/dev 2025-08-01 14:39:01 +09:00
jcchoi 1704136214 Merge branch 'iipbs_cs' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs_cs 2025-08-01 14:34:30 +09:00
jyha00 def2867c28 수요처 로그인 비밀번호 요청 토스트 수정 2025-08-01 14:34:19 +09:00
jcchoi f5a49a44c4 공통 로그인 분리 및 2FA 적용 2025-08-01 14:34:19 +09:00
jcchoi 312c06be31 메뉴 INFO 및 개인정보제공동의 수정 2025-08-01 14:29:58 +09:00
yschoi 2bdc52e831 Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-08-01 14:13:29 +09:00
yschoi d4c115d7ca 지도 작업 중1 2025-08-01 14:13:19 +09:00
leedj312 75bafd5a34 Merge commit '6808430c9ae74890d0f0e84f20a0ffe5409de157' into iipbs/wr/dev 2025-08-01 09:28:04 +09:00
leedj312 8ac807a550 Merge commit '8eea3c75aa82327ae5d670640afc242933766332' into iipbs/wr/dev 2025-08-01 09:26:12 +09:00
leedj312 9d2511c6ea Merge commit 'cb0f3160230da1d5a70e0e527e62bfe0af0d6d04' into iipbs/wr/dev 2025-08-01 09:25:05 +09:00
leedj312 8eea3c75aa 수요매칭 검토 이상 이하 수정 2025-07-31 20:16:38 +09:00
jyha00 cb0f316023 수요처관리 비밀번호 초기화 추가 수정 2025-07-31 20:11:10 +09:00
jyha00 d23f928ddb 수요처관리 비밀번호 초기화 추가 2025-07-31 19:53:17 +09:00
JIeunhuh fb9dacd60c 팝업 크기 적용 2025-07-31 17:58:33 +09:00
swcho 19cae3fd03 Merge remote-tracking branch 'origin/iipbs/sn/ii' into iipbs/sn/dev 2025-07-31 17:56:59 +09:00
swcho 47faed0ff7 refactor: 스토어 fetch 실패 alert 주석 처리 2025-07-31 17:56:46 +09:00
JIeunhuh 1952bd5c2c Merge commit 'fd55d2e3cfa04fcea4ac767a7709f1b3920668cf' into iipbs/wr/gismng 2025-07-31 17:27:25 +09:00
swcho 565e5e5aec Merge remote-tracking branch 'origin/iipbs/sn/ii' into iipbs/sn/dev 2025-07-31 17:11:27 +09:00
swcho 637b1e3d84 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2025-07-31 17:08:58 +09:00
swcho 416677dcf4 feat, refactor:
- 중점우수토실 위경도 소수점 아래 네자리까지만 표시
- 중점우수토실 위경도 변수 타입 선언
2025-07-31 17:08:28 +09:00
JIeunhuh 765ca59e35 팝업별 컴포넌트 분리 및 시설 선택시 해당 api만 호출하도록 변경 2025-07-31 17:00:08 +09:00
mind-one005 ad3f404c44 자료관데이터 주소 변경 2025-07-31 16:57:57 +09:00
mind-one005 6312c80f8a Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2025-07-31 16:56:28 +09:00
mind-one005 a780c36991 하수처리시설 기본계획다운로드 2025-07-31 16:56:21 +09:00
swcho 62a7dc7451 feat, fix:
- 중점우수토실 강우정보 테이블, 측정정보 테이블 selected index, page 동기화
- 수량/수질측정 정보 페이지네이션 에러 수정
2025-07-31 16:43:59 +09:00
swcho d6cab7b35c refactor: 호출 API 컴포넌트에서 주석 제거 2025-07-31 16:13:52 +09:00
swcho 46015603c2 Merge branch 'iipbs/common/main' into iipbs/sn/ii 2025-07-31 16:09:53 +09:00
swcho 059f70cacb Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2025-07-31 16:01:03 +09:00
swcho 723eabea41 feat, remove:
- 관 용도, 관 재질 공통 코드 Store 등록
- I/I 분석 페이지 시도, 시군구 공통 코드 적용
- I/I 분석 페이지 환경청 삭제
- commonStore.ts 삭제
- Store 데이터 호출 로직을 layout.tsx로 이동
2025-07-31 15:48:38 +09:00
leedj312 fd55d2e3cf Merge commit '3a3eae84aea917edab09e0f82faaba0749921ea7' 2025-07-31 14:54:41 +09:00
leedj312 3a3eae84ae Merge commit '0c16b9d14df0f523bbdd6908536b40f9e88e6d56' into iipbs/wr/dev 2025-07-31 14:51:14 +09:00
leedj312 a24d9f42d6 Merge commit 'e5f2cb2182f407a78d25edc05f81e5cc6f49deaf' into iipbs/wr/dev 2025-07-31 14:43:08 +09:00
Your Name 96ef328fbf Merge branch 'iipbs/fo/dev' 2025-07-31 14:39:23 +09:00
JIeunhuh 7ee7a0fc69 컴포넌ㅌ트 분리중 .. 2025-07-31 14:38:14 +09:00
Your Name 4c66dacdb0 원복 2025-07-31 14:31:59 +09:00
yschoi 024a69b1fd Merge remote-tracking branch 'origin/iipbs/fo/dev' 2025-07-31 13:53:15 +09:00
Your Name 8a2bbfe220 원앤올 API URL 재수정 2025-07-31 13:52:52 +09:00
yschoi d954675824 Merge remote-tracking branch 'origin/iipbs/dm/dev' 2025-07-31 13:50:43 +09:00
Your Name 5712bcedaa 원앤올 DB바라보게 수정 2025-07-31 13:38:27 +09:00
yschoi deaf428ed9 Merge remote-tracking branch 'origin/iipbs/wr/dev' 2025-07-31 13:29:41 +09:00
leedj312 0c16b9d14d 중수도 지도점검 엑셀등록 오류 메시지 추가 2025-07-31 13:28:21 +09:00
yschoi 41686670a4 Merge remote-tracking branch 'origin/iipbs/fo/dev' 2025-07-31 13:23:08 +09:00
yschoi 82019c86bf Merge remote-tracking branch 'origin/iipbs/sn/dev' 2025-07-31 13:18:46 +09:00
yschoi 637929ea26 지도 코드 정리 2025-07-31 13:15:22 +09:00
chanii8 8bb3253aa5 [apps/cs] chore: 접속자 통계 테이블 쿼리 의존성 수정 2025-07-31 12:45:27 +09:00
chanii8 3f85aa676c [common/components] feat: 접속 로그 저장 기능 작업 2025-07-31 12:45:01 +09:00
chanii8 26f222525d [apps/cs] refactor: 접속 로그 도메인 엔티티로 분리 2025-07-31 11:48:27 +09:00
chanii8 713928e794 [apps/cs] refactor: 메뉴 미리보기 조회 조건 수정 2025-07-31 10:37:47 +09:00
JIeunhuh 80156bd349 팝업 현황 수정사항 변경 (시설물 클릭 시 api 모두 조회 되는 부분 작업 중) 2025-07-31 10:02:21 +09:00
swcho 2e7b06d970 feat: 중점우수토실 페이징 처리 (테이블 동기화 추가 작업 필요) 2025-07-30 21:37:40 +09:00
swcho c23fe68675 refactor: 시도, 시군구 호출 주석 추가 2025-07-30 21:35:16 +09:00
swcho b5c7f09bc9 feat: 시도, 시군구 zustand Store로 메인 화면에서 호출 후 SearchModal.tsx에서 사용 2025-07-30 20:55:57 +09:00
swcho 4cfa032be2 feat: 오수전환율 초깃값 0.8로 수정 2025-07-30 20:31:13 +09:00
swcho 83b63901ff Merge branch 'iipbs/sn/dev' 2025-07-30 17:51:53 +09:00
swcho 96624140e0 Merge branch 'iipbs/sn/ii' into iipbs/sn/dev 2025-07-30 17:48:28 +09:00
swcho 6c6b5e662c feat: 하수처리시설에 시도, 시군구 검색 조건 추가 2025-07-30 17:47:16 +09:00
swcho bbfd7fd201 fix: 연월 포맷 수정 (년월일 -> -로 구분) 2025-07-30 17:00:15 +09:00
swcho 340814439a fix, remove:
- featureClickEventPopup.tsx 경로 수정
- 이전 시도, 시군구 리스트 삭제
2025-07-30 16:44:11 +09:00
swcho 5cc86d81f1 refactor: featureClickEventPopup.tsx 경로 변경 (popupComponent 내부로) 2025-07-30 16:35:11 +09:00
swcho a05b0e1cfe feat, remove:
- GeoServer API 호출 함수 구현
- 하드코딩 시도, 시군구 테이블 삭제
2025-07-30 16:33:31 +09:00
swcho c89c588487 fix: 시도, 시군구 하드코딩 -> 알에스원 API로 변경 (이후 Store로 변경 필요) 2025-07-30 16:25:09 +09:00
chanii8 1f33f55079 [apps/cs] design: 게시판 통계 레이아웃 작업 2025-07-30 16:04:51 +09:00
chanii8 5a3eb041dc [apps/cs] chore: InputDateRangePicker 경고 문구 수정 2025-07-30 16:02:59 +09:00
Your Name df18a79528 물사용량 오류 및 null 관련오류 수정 2025-07-30 16:00:22 +09:00
swcho 18b8a6edab fix: searchModal.tsx 테이블 헤더 줄 바꿈 수정 2025-07-30 15:42:47 +09:00
chanii8 ea12adebc6 [apps/cs] feat: 신규 푸터 레이아웃 작업 2025-07-30 15:40:12 +09:00
swcho fcfaa23dc3 fix: 검색 창 위 아래 간격 수정 2025-07-30 15:35:45 +09:00
yschoi 5382328f94 Merge remote-tracking branch 'origin/iipbs/fo/dev' 2025-07-30 13:54:39 +09:00
Your Name 3bd22d83b7 계획정보 API 호출 공통화 및 수정 2025-07-30 11:23:16 +09:00
rsone001 5405af7ff3 연계 api 미들웨어 적용용 2025-07-30 10:09:34 +09:00
Your Name 0abc7eaaac 배수설비 정비현황 조회삭제 2025-07-30 09:49:26 +09:00
yschoi 0805fbcc26 Merge remote-tracking branch 'origin/iipbs/dm/dev' 2025-07-30 09:45:42 +09:00
swcho 2f553a0dff fix: searchModal.tsx 테이블 헤더리스트 줄 바꿈 적용 2025-07-30 09:44:09 +09:00
Your Name c713b55480 물사용량은 계획정보 없어도 대시보드에 추출되도록 수정 2025-07-30 09:34:54 +09:00
swcho 6dd48a3054 Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-07-29 20:57:37 +09:00
swcho afa129f20b Merge branch 'main' into iipbs/sn/dev 2025-07-29 20:55:53 +09:00
swcho 6dae81eb05 fix, refactor:
- 기본검색 하수관로의 Input 컴포넌트 변수 중복 에러 수정
- 운영정보 검색 테이블 개수 5개로 수정
2025-07-29 20:51:12 +09:00
jyha00 e5f2cb2182 물재이용사례관리 페이지 수정 2025-07-29 20:40:05 +09:00
swcho 0fe84dba22 feat: Input 컴포넌트의 Enter 키 입력시 조회, 검색 기능 event 구현 2025-07-29 20:32:30 +09:00
swcho b2e0780654 feat: 하수관로 상세페이지 '상류관로 보기' 버튼 오른쪽 정렬 2025-07-29 20:20:29 +09:00
swcho 81d589acb0 fix : 조건창에 텍스트와 입력창 간격 등 일정하게 처리 2025-07-29 20:15:21 +09:00
leedj312 3ba11b5508 빗물 엑셀 등록 항목 오류 점검 2025-07-29 20:11:51 +09:00
Your Name eafe131df1 계획정보 못불러 올 경우 progressbar 없어지게 수정 2025-07-29 18:11:39 +09:00
swcho 97b0a2e5bf fix, feat:
- I/I 분석 화면 CSS 수정 (간격 통일
- I/I 분석 화면 Enter 클릭 이벤트 추가
2025-07-29 16:09:49 +09:00
leedj312 65f74987f8 Merge commit 'aaed8fe971d0ccf31cb0b07f96bb51af4c125a1f' into iipbs/wr/dev 2025-07-29 15:52:08 +09:00
leedj312 aaed8fe971 재이용 시설 실적 등록 목록 테이블 수정 2025-07-29 15:51:18 +09:00
chanii8 c222e809b6 [apps/cs] feat: 접속자 통계 > 접속 로그 상세 테이블 작업 2025-07-29 15:44:47 +09:00
yschoi cba305ab9b Merge branch 'iipbs/wr/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/wr/dev 2025-07-29 15:17:51 +09:00
yschoi 913256cac4 원복 2025-07-29 15:16:45 +09:00
mind-one003 e1fc6f210f 삼항연산자 수정 2025-07-29 01:43:54 -04:00
Your Name 1bec6a3787 결함수정, 날짜2024초기화, 총인설비 tp 키값수정정 2025-07-29 14:29:37 +09:00
chanii8 3ed203c597 Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs_cs 2025-07-29 13:23:59 +09:00
chanii8 12f7fe53ad [apps/cs] feat: 접속자통계 데이터 바인딩 2025-07-29 13:23:48 +09:00
leedj312 75d14782f4 Merge commit 'c30de7f40d6b810cc19d5cd23a581d012116899f' into iipbs/wr/dev 2025-07-29 11:33:05 +09:00
chanii8 387362d42b [apps/cs] feat: 게시글 내 목록 버튼 기능 수정 2025-07-29 11:28:48 +09:00
chanii8 4e04676a57 [apps/cs] chore: 대시보드 페이지 데이터 표기 수정 2025-07-29 11:28:01 +09:00
jyha00 c30de7f40d 재이용실적 현황 엑셀 다운로드 수정, 초기화 버튼 수정 2025-07-29 11:10:22 +09:00
leedj312 ca96e55c07 Merge commit '64698fb97e39f5b35b0fd05012f94833dead1522' into iipbs/wr/dev 2025-07-29 10:47:41 +09:00
jyha00 64698fb97e 재이용실적 현황 페이지네이션 오류 수정 2025-07-29 10:33:34 +09:00
leedj312 662a461cfa 하수재이용 현황 페이지 네이션 오류 수정 2025-07-28 18:26:43 +09:00
swcho 2c5ed5f6cf Merge branch 'iipbs/sn/dev' 2025-07-28 17:58:05 +09:00
swcho 484d90e84f fix: 사업 구분 3개로 바꾸기2 2025-07-28 17:51:01 +09:00
swcho f550a62f99 refactor: searchModal.tsx 코드 정리 2025-07-28 17:32:25 +09:00
swcho 9759a5250a fix: 사업 구분 3개로 바꾸기 2025-07-28 17:03:36 +09:00
swcho 890e2af79b fix: 상세 정보 페이지 컬럼 너비 통일 2025-07-28 16:09:22 +09:00
swcho 895adeef0a fix:
- 하수관로 상세정보 컬럼 수정
- 운영정보 검색 일련번호 에러 수정
2025-07-28 15:36:54 +09:00
yschoi 2a15138014 Merge remote-tracking branch 'origin/main' into iipbs/wr/dev 2025-07-28 12:41:08 +09:00
Your Name a2b2d94581 gis.layer merge 2025-07-28 11:21:16 +09:00
Your Name 1083be517d Merge branch 'iipbs/common/main' into iipbs/fo/dev 2025-07-28 11:13:09 +09:00
Your Name aeb4f5f8ca 지하수유입량 하절기는 6,7,8월 / 동절기는 1월 2월만 선택가능하도록 파라미터 추가 2025-07-28 11:07:54 +09:00
swcho fc54f6253c Merge branch 'iipbs/common/main' into iipbs/sn/ii 2025-07-28 10:52:20 +09:00
swcho af035aa649 Merge branch 'iipbs/common/main' into iipbs/sn/ii 2025-07-28 10:35:19 +09:00
leedj312 97727908fa 수요처 nullish 처리 2025-07-28 10:32:58 +09:00
swcho 81a3e6324e fix: 침입수, RDII 분석결과 상수사용기간 시작 년도 당해 년도 1월부터로 수정 2025-07-28 09:06:56 +09:00
Your Name dfabee3e08 초기화 돌아가는 문제해결, 대시보드 calData 수정, 처리구역정보 null 오류 수정 2025-07-27 23:50:24 +09:00
mind-one005 16ebb8eb3a Merge branch 'iipbs/sn/dev' 2025-07-27 21:59:04 +09:00
mind-one005 f8c902a138 기본계획 component 분리 2025-07-27 21:55:24 +09:00
Your Name be5ffb60f6 결함수정 - 강우영향일 계산 수정, 2025-07-27 18:40:35 +09:00
mind-one005 01c01dedbb Merge branch 'iipbs/common/main' 2025-07-27 17:16:48 +09:00
mind-one005 007a39ebfa Merge branch 'iipbs/common/main' into iipbs/sn/dev 2025-07-27 17:14:21 +09:00
mind-one005 8c2bdc65eb 침입수,RDII 그래프 수정 2025-07-27 17:09:25 +09:00
Your Name befb3f1c84 빌드에러 수정 2025-07-27 14:56:32 +09:00
Your Name 87ee8c827d Merge branch 'iipbs/common/main' into iipbs/fo/dev 2025-07-27 14:44:17 +09:00
Your Name bd7debbe3e 오수발생량 추정, 지하수유입량 추정 년월 중 년 disabled 2025-07-27 14:42:30 +09:00
Your Name 1850ab2aa8 대시보드 물사용량평균 수정, 초기화오류 수정, 수질 변수 다시수정 2025-07-27 14:34:32 +09:00
Your Name c976b5d6ee 빌드에러 수정, 대시보드 물사용량 추출완료, 초기화 전체 2023년 2025-07-26 22:55:23 +09:00
Your Name ad7a633bc9 빌드에러 수정 안쓰는변수제거거 2025-07-26 20:59:20 +09:00
Your Name b91298f3d9 강우영향일 관련 문구 추가 2025-07-26 20:57:52 +09:00
Your Name 1207754092 강우영향일 기준 강우일 운영현황 나오도록 수정 2025-07-26 20:45:09 +09:00
Your Name 2fc4f9512f 결함수정중 2025-07-26 19:57:09 +09:00
Your Name 55de8cc31e 결함문제 해결 진행중 2025-07-26 19:24:32 +09:00
mind-one002 f18eaa8f01 메인 머지 이슈로 수정. 2025-07-26 16:46:59 +09:00
mind-one002 04bc7d3a3a Merge commit '0ad7d6a2c4c8d7585d6a5b053444dd6c2b35b710' into iipbs/fo/dev 2025-07-26 16:43:28 +09:00
616 changed files with 50225 additions and 11868 deletions
+1
View File
@@ -1,5 +1,6 @@
# turbo
.turbo/
.vscode/**/*
# dependencies
node_modules/
+1
View File
@@ -201,3 +201,4 @@ http://dev.onasys.co.kr:8000/webjars/swagger-ui/index.html?urls.primaryName=atta
- 사이드바 메뉴 클릭 시 "menuCd" 데이터 쿠키 저장 처리
- 쿠키명 : menu-code-key
- 해당 메뉴 쿠키 데이터 가져와 페이지 권한 및 상당 Breadcrumb 데이터 전달 처리
+7
View File
@@ -0,0 +1,7 @@
# apps/cs/src/shared/config/env.ts 참고
NEXT_PUBLIC_PORT=3000
NEXT_PUBLIC_PROXY_HOST=http://localhost:3000
NEXT_PUBLIC_SITE_ID=CS
NEXT_PUBLIC_SUB_DOMAIN=http://localhost:3000
NEXT_PUBLIC_SERVER_API_URL=http://192.168.0.102:8000
NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2
+7
View File
@@ -0,0 +1,7 @@
# apps/cs/src/shared/config/env.ts 참고
NEXT_PUBLIC_PORT=3000
NEXT_PUBLIC_PROXY_HOST=http://localhost:3000
NEXT_PUBLIC_SITE_ID=CS
NEXT_PUBLIC_SUB_DOMAIN=http://localhost:3000
NEXT_PUBLIC_SERVER_API_URL=http://192.168.0.102:8000
NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2
+8
View File
@@ -0,0 +1,8 @@
# apps/cs/src/shared/config/env.ts 참고
NEXT_PUBLIC_PORT=3000
NEXT_PUBLIC_PROXY_HOST=http://localhost:3000
NEXT_PUBLIC_SITE_ID=CS
NEXT_PUBLIC_SUB_DOMAIN=http://localhost:13000
NEXT_PUBLIC_SERVER_API_URL=http://dev.onasys.co.kr:8000
NEXT_PUBLIC_LINK_BACKEND_API_HOST=http://rsone.iptime.org:10014
NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2
+7
View File
@@ -0,0 +1,7 @@
# apps/cs/src/shared/config/env.ts 참고
NEXT_PUBLIC_PORT=3000
NEXT_PUBLIC_PROXY_HOST=http://localhost:3000
NEXT_PUBLIC_SITE_ID=CS
NEXT_PUBLIC_SUB_DOMAIN=http://localhost:3000
NEXT_PUBLIC_SERVER_API_URL=http://192.168.0.102:8000
NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2
BIN
View File
Binary file not shown.
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.env*
!.env.example
+3
View File
@@ -0,0 +1,3 @@
{
"plugins": ["prettier-plugin-tailwindcss"]
}
@@ -0,0 +1,2 @@
import { DashboardPage } from "@/pages/dashboard";
export default DashboardPage;
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { usePathname, useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { NoLNBLayout } from "@common/components";
import type { MenuItem } from "@/entities/menu";
import { useMenuStore } from "@/shared/lib";
import { PageLayout } from "@/shared/ui/page-layout";
export default function WithBaseLayout({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const pathname = usePathname();
const queryClient = useQueryClient();
const setCurrentMenuInfo = useMenuStore((state) => state.setCurrentMenuInfo);
const handleMenuClick = (item: MenuItem) => {
setCurrentMenuInfo(item);
router.push(item.urlPath);
};
return (
<NoLNBLayout
queryClient={queryClient}
currentPath={pathname ?? ""}
handleMenuClick={handleMenuClick}
>
<PageLayout>{children}</PageLayout>
</NoLNBLayout>
);
}
@@ -0,0 +1,2 @@
import { SkeletonPage } from "@/shared/ui/skeleton-page";
export default SkeletonPage;
+145
View File
@@ -0,0 +1,145 @@
import {
ACCESS_TOKEN,
AUTH_USER_ID,
CLAIM_NAME,
REFRESH_TOKEN,
SERVER_API_URL,
SITE_ID,
} from "@/shared/config";
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
async function handler(
request: Request,
params: { url: string[] },
method: "GET" | "POST" | "PUT" | "DELETE",
) {
const cookieStore = await cookies();
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-Site-Id": SITE_ID,
};
let body = {};
const isRefreshHandler = params.url.includes("refresh");
console.log(`[Proxy] ${method} ${params.url.join("/")}`);
console.log(`[Proxy] Server API URL: ${SERVER_API_URL}`);
// silent refresh
if (isRefreshHandler) {
const refreshToken = cookieStore.get(REFRESH_TOKEN);
headers[CLAIM_NAME] = refreshToken?.value ?? "";
if (!headers[CLAIM_NAME]) {
return NextResponse.json({ message: "Can't refresh" }, { status: 401 });
}
}
try {
if (!isRefreshHandler) {
body = await request.json();
console.log(`[Proxy] Request body:`, body);
}
const targetUrl = `${SERVER_API_URL}/${params.url.join("/")}`;
console.log(`[Proxy] Target URL: ${targetUrl}`);
const result = await fetch(targetUrl, {
method,
headers,
body: JSON.stringify(body),
});
console.log(`[Proxy] Response status: ${result.status}`);
console.log(`[Proxy] Response headers:`, Object.fromEntries(result.headers.entries()));
// 2FA 관련 엔드포인트인 경우 응답 본문도 확인
const is2FAEndpoint = params.url.includes("2fa") || params.url.includes("auth");
let responseData = {};
if (is2FAEndpoint) {
try {
responseData = await result.json();
console.log(`[Proxy] 2FA Response data:`, responseData);
} catch {
// JSON 파싱 실패 시 빈 객체 사용
console.log(`[Proxy] Failed to parse JSON response`);
}
}
const accessToken = result.headers.get(ACCESS_TOKEN) || result.headers.get('ss-token');
const refreshToken = result.headers.get(REFRESH_TOKEN);
const userId = result.headers.get(AUTH_USER_ID);
console.log(`[Proxy] Tokens - accessToken: ${!!accessToken}, userId: ${!!userId}, refreshToken: ${!!refreshToken}`);
console.log(`[Proxy] Available headers:`, Array.from(result.headers.keys()));
// 2FA 엔드포인트이고 실패한 경우 실제 오류 메시지 반환
if (!result.ok && is2FAEndpoint) {
console.log(`[Proxy] 2FA endpoint failed, returning actual error`);
return NextResponse.json(responseData, { status: result.status });
}
// 2FA 엔드포인트이고 성공한 경우 (토큰이 헤더에 없을 수 있음)
if (result.ok && is2FAEndpoint) {
console.log(`[Proxy] 2FA endpoint succeeded, returning response data`);
return NextResponse.json(responseData, { status: result.status });
}
// 일반적인 실패 처리
if (!result.ok) {
console.log(`[Proxy] General endpoint failed`);
return NextResponse.json(
{ message: "Invalid Credentials" },
{ status: 401 },
);
}
if (accessToken && userId) {
const response = NextResponse.json({
[ACCESS_TOKEN]: accessToken,
[AUTH_USER_ID]: userId,
...responseData, // 2FA 관련 추가 데이터 포함
});
if (refreshToken) {
cookieStore.set(REFRESH_TOKEN, refreshToken, {
httpOnly: true,
sameSite: "lax",
path: "/",
});
}
console.log(`[Proxy] Success response sent`);
return response;
}
console.log(`[Proxy] Missing tokens in response`);
return NextResponse.json(
{ message: "Invalid Credentials" },
{ status: 401 },
);
} catch (error) {
console.error(`[Proxy] Error:`, error);
return NextResponse.json(
{ message: "Server Error", error },
{ status: 500 },
);
}
}
export async function POST(
request: Request,
props: { params: Promise<{ url: string[] }> },
) {
const params = await props.params;
return handler(request, params, "POST");
}
export async function PUT(
request: Request,
props: { params: Promise<{ url: string[] }> },
) {
const params = await props.params;
return handler(request, params, "PUT");
}
+2
View File
@@ -0,0 +1,2 @@
import { LoginPage } from "@/pages/auth/login";
export default LoginPage;
+17
View File
@@ -0,0 +1,17 @@
import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import {
ACCESS_TOKEN,
AUTH_USER_ID,
CLAIM_NAME,
REFRESH_TOKEN,
} from "@/shared/config";
export async function POST() {
const cookieStore = await cookies();
cookieStore.delete(ACCESS_TOKEN);
cookieStore.delete(REFRESH_TOKEN);
cookieStore.delete(AUTH_USER_ID);
cookieStore.delete(CLAIM_NAME);
return NextResponse.json({ message: "Logged out" }, { status: 200 });
}
+8
View File
@@ -0,0 +1,8 @@
import type { Metadata } from "next";
import { App } from "@/app";
export const metadata: Metadata = {
title: "유역하수도 통합 정보플랫폼",
};
export default App;
+21
View File
@@ -0,0 +1,21 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
rules: {
"react-hooks/exhaustive-deps": "off",
},
},
];
export default eslintConfig;
+79
View File
@@ -0,0 +1,79 @@
import { NextRequest, NextResponse } from "next/server";
import {
CLAIM_NAME,
AUTH_USER_ID,
PORT,
PROXY_HOST,
SERVER_API_URL,
SITE_ID,
} from "./src/shared/config";
const serverApiUrl =
process.env.NEXT_PUBLIC_SERVER_API_URL ?? "http://localhost:3000";
export function middleware(req: NextRequest) {
const pathname = req.nextUrl.pathname;
const searchParams = req.nextUrl.searchParams;
const logMessages: string[] = [
`[Middleware Request] ${new Date().toISOString()}`,
`[Environment]\n Mode: ${process.env.NODE_ENV}\n Port: ${PORT}\n Proxy Host: ${PROXY_HOST}\n Server API URL: ${SERVER_API_URL}\n Site ID: ${SITE_ID}`,
`[Request]\n Original Pathname: ${pathname}`,
];
if (pathname.startsWith("/server")) {
const queryParams = searchParams.toString();
const newUrl = `${serverApiUrl}${pathname.replace("/server", "")}${
queryParams ? `?${queryParams}` : ""
}`;
logMessages.push(` Rewritten URL: ${newUrl}`);
logMessages.push("\n--------------------------------\n");
console.log(logMessages.join("\n"));
return NextResponse.rewrite(new URL(newUrl));
}
if (pathname.startsWith("/attach-service")) {
const requestHeaders = new Headers(req.headers);
const token = req.cookies.get(CLAIM_NAME)?.value;
const userId = req.cookies.get(AUTH_USER_ID)?.value;
const newUrl = `${serverApiUrl}${pathname}`;
if (token) requestHeaders.set(CLAIM_NAME, token);
if (userId) requestHeaders.set(AUTH_USER_ID, userId);
logMessages.push(` Rewritten URL: ${newUrl}`);
logMessages.push("\n--------------------------------\n");
console.log(logMessages.join("\n"));
return NextResponse.rewrite(new URL(newUrl), {
headers: requestHeaders,
});
}
if (pathname.startsWith("/user-service")) {
const queryParams = searchParams.toString();
const newUrl = `${serverApiUrl}${pathname}${
queryParams ? `?${queryParams}` : ""
}`;
logMessages.push(` User Service Rewritten URL: ${newUrl}`);
logMessages.push("\n--------------------------------\n");
console.log(logMessages.join("\n"));
return NextResponse.rewrite(new URL(newUrl));
}
if (pathname === "/") {
return NextResponse.redirect(new URL("/dashboard", req.url));
}
logMessages.push("\n--------------------------------\n");
console.log(logMessages.join("\n"));
return NextResponse.next();
}
export const config = {
matcher: ["/server/:path*", "/attach-service/:path*", "/user-service/:path*", "/"],
};
+6
View File
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+40
View File
@@ -0,0 +1,40 @@
import type { NextConfig } from "next";
const subDomain =
process.env.NEXT_PUBLIC_SUB_DOMAIN || "http://localhost:13000";
const backendUrl = process.env.NEXT_PUBLIC_SERVER_API_URL || "http://192.168.0.102:8000";
const nextConfig: NextConfig = {
output: "standalone",
async rewrites() {
return {
beforeFiles: [
{
source: "/_next/static/:path*",
destination: `${subDomain}/_next/static/:path*`,
has: [
{
type: "header",
key: "referer",
value: "(.*)/cms(.*)",
},
],
},
],
afterFiles: [
{
source: "/cms/:path*",
destination: `${subDomain}/cms/:path*`,
},
{
source: "/user-service/:path*",
destination: `${backendUrl}/user-service/:path*`,
},
],
fallback: [],
};
},
};
export default nextConfig;
+63
View File
@@ -0,0 +1,63 @@
{
"name": "cm",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@common/assets": "workspace:*",
"@common/components": "workspace:*",
"@common/config": "workspace:*",
"@fontsource-variable/noto-sans-kr": "^5.2.5",
"@frontend-opensource/use-react-hooks": "^1.1.4",
"@fullcalendar/core": "^6.1.17",
"@fullcalendar/daygrid": "^6.1.17",
"@fullcalendar/interaction": "^6.1.17",
"@fullcalendar/react": "^6.1.17",
"@fullcalendar/timegrid": "^6.1.17",
"@hookform/resolvers": "^4.1.3",
"@minoru/react-dnd-treeview": "3.5.1-alpha.1",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@tanstack/react-query": "^5.67.1",
"@tanstack/react-query-devtools": "^5.75.5",
"@tanstack/react-table": "^8.21.2",
"@tinymce/tinymce-react": "^6.1.0",
"axios": "^1.8.1",
"chart.js": "^4.4.8",
"dayjs": "^1.11.13",
"js-cookie": "^3.0.5",
"lucide-react": "^0.474.0",
"next": "15.3.0",
"next-qrcode": "^2.5.1",
"next-themes": "^0.4.6",
"react-chartjs-2": "^5.3.0",
"react-dnd": "^16.0.1",
"react-hook-form": "^7.54.2",
"sonner": "^2.0.1",
"tailwindcss-animate": "^1.0.7",
"tus-js-client": "^4.3.1",
"zod": "^3.24.2",
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.13.1",
"@types/webpack": "^5.28.5",
"eslint": "^9",
"eslint-config-next": "15.1.7",
"postcss": "^8",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
"steiger": "^0.5.5",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}
View File
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

+20
View File
@@ -0,0 +1,20 @@
import "./styles/base.css";
import "./styles/components.css";
import "@fontsource-variable/noto-sans-kr";
import { AppProvider } from "./providers";
import { cookies } from "next/headers";
const App = async ({ children }: Readonly<{ children: React.ReactNode }>) => {
const theme = (await cookies()).get("site-theme-key");
return (
<html lang="ko" data-theme={theme?.value}>
<body>
<AppProvider>{children}</AppProvider>
</body>
</html>
);
};
export { App };
@@ -0,0 +1,49 @@
"use client";
import { DndProvider } from "react-dnd";
import { MultiBackend, getBackendOptions } from "@minoru/react-dnd-treeview";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ConfirmProvider } from "@frontend-opensource/use-react-hooks";
import { AuthWrapper } from "@/entities/auth";
import { MenuWrapper } from "@/entities/menu";
import { Toaster } from "@/shared/ui/sonner";
import { AlertDialog } from "@/shared/ui/alert-dialog";
import { PromptDialog } from "@/shared/ui/prompt-dialog";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { SelectPromptDialog } from "@/shared/ui/select-prompt-dialog";
import { PromptProvider, StoreProvider } from "@/shared/providers";
const AppProvider = ({ children }: Readonly<{ children: React.ReactNode }>) => {
const queryClient = new QueryClient();
return (
<QueryClientProvider client={queryClient}>
<StoreProvider>
<PromptProvider>
<ConfirmProvider>
<AuthWrapper>
<MenuWrapper>
<DndProvider
backend={MultiBackend}
options={getBackendOptions()}
>
{children}
</DndProvider>
</MenuWrapper>
<AlertDialog />
<PromptDialog />
<ConfirmDialog />
<SelectPromptDialog />
<Toaster position="top-center" />
</AuthWrapper>
</ConfirmProvider>
</PromptProvider>
</StoreProvider>
<div className="text-2xl">
<ReactQueryDevtools initialIsOpen={false} />
</div>
</QueryClientProvider>
);
};
export { AppProvider };
+21
View File
@@ -0,0 +1,21 @@
@import "@common/assets/styles/base.css";
@import "@common/assets/styles/diveicon.css";
@import "@common/components/index.css";
@tailwind base;
@tailwind utilities;
@layer base {
:root {
--secondary-background: rgba(0, 0, 0, 0.04);
}
[data-theme="black"] {
--secondary-background: rgba(255, 255, 255, 0.09);
}
html,
body {
font-family: "Noto Sans KR", sans-serif;
font-size: 10px;
}
}
+129
View File
@@ -0,0 +1,129 @@
.card {
position: relative;
margin: 0.6rem;
box-sizing: border-box;
min-height: 2rem;
border-radius: 0.5rem;
padding: 0.6rem;
font-size: 1.3rem;
color: #666;
}
.card_width10 {
width: calc(10% - 12px);
}
.card_width20 {
width: calc(20% - 12px);
}
.card_width25 {
width: calc(25% - 12px);
}
.card_width30 {
width: calc(30% - 12px);
}
.card_width33 {
width: calc(33.333% - 12px);
}
.card_width40 {
width: calc(40% - 12px);
}
.card_width45 {
width: calc(45% - 12px);
}
.card_width50 {
width: calc(50% - 12px);
}
.card_width60 {
width: calc(60% - 12px);
}
.card_width66 {
width: calc(66.666% - 12px);
}
.card_width70 {
width: calc(70% - 12px);
}
.card_width75 {
width: calc(75% - 12px);
}
.card_width80 {
width: calc(80% - 12px);
}
.card_width90 {
width: calc(90% - 12px);
}
.card_width100 {
width: calc(100% - 12px);
}
.card_nopadding {
padding: 0 !important;
}
.card_nomargin {
width: 100%;
margin: 0 !important;
padding: 0 !important;
}
.topline {
border-top: 1px solid #ccc;
}
.bottomline {
border-bottom: 1px solid #e6eef3;
background: #fff;
}
.card-border {
border: 1px solid #dbe2e6;
background-color: rgba(255, 255, 255, 0.6);
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.card-header {
margin-top: -0.6rem;
margin-left: -0.6rem;
margin-right: -0.6rem;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #e1e7eb;
padding: 1rem;
}
.card-footer {
margin-left: -0.6rem;
margin-right: -0.6rem;
margin-top: 0.5rem;
display: flex;
align-items: center;
justify-content: center;
border-top: 1px solid #e1e7eb;
padding-top: 1rem;
padding-bottom: 1rem;
}
/* 블랙 테마 */
[data-theme="black"] {
.card-border {
background-color: rgba(255, 255, 255, 0.065);
border-color: #38383a;
}
.card-header {
border-bottom-color: #3f4042;
}
}
@@ -0,0 +1,85 @@
import { AxiosError } from "axios";
import {
ENDPOINT,
JWT_EXPIRATION_TIME,
ACCESS_TOKEN,
AUTH_USER_ID,
CLAIM_NAME,
} from "@/shared/config";
import type { LoginService, ILogin } from "../model/login-interface";
import { tokenManager } from "../lib/token-manager";
export class LoginApiService implements LoginService {
private static instance: LoginApiService;
private constructor() {}
public static getInstance(): LoginApiService {
if (!LoginApiService.instance) {
LoginApiService.instance = new LoginApiService();
}
return LoginApiService.instance;
}
public async login(payload: ILogin): Promise<void> {
try {
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
method: "POST",
body: JSON.stringify({
userId: payload.userId,
password: payload.password,
}),
});
const data = await response.json();
const accessToken = data[ACCESS_TOKEN];
const tokenId = data[AUTH_USER_ID];
if (!accessToken || !tokenId) {
throw new Error("Missing required authentication tokens");
}
tokenManager.setToken(CLAIM_NAME, accessToken);
tokenManager.setToken(AUTH_USER_ID, tokenId);
tokenManager.setupRefreshTimer(
() => this.silentRefresh(),
JWT_EXPIRATION_TIME,
);
} catch (error) {
if (error instanceof AxiosError) {
throw new Error(`Login failed: ${error.response?.data?.message}`);
}
throw new Error(`Login failed: ${error}`);
}
}
public async silentRefresh(): Promise<void> {
try {
const response = await fetch(
`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`,
{ method: "PUT" },
);
const data = await response.json();
const accessToken = data[ACCESS_TOKEN];
const tokenId = data[AUTH_USER_ID];
if (!accessToken || !tokenId) {
throw new Error("Missing required authentication tokens");
}
tokenManager.setToken(CLAIM_NAME, accessToken);
tokenManager.setToken(AUTH_USER_ID, tokenId);
} catch (error) {
tokenManager.clearAuthToken();
if (error instanceof AxiosError) {
throw new Error(
`Silent refresh failed: ${error.response?.data?.message}`,
);
}
throw new Error(`Silent refresh failed: ${error}`);
}
}
}
export const LoginApi = LoginApiService.getInstance();
+2
View File
@@ -0,0 +1,2 @@
export { LoginApi } from "./api/login-service";
export { AuthWrapper } from "./lib/auth-wrapper";
@@ -0,0 +1,54 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { client } from "@/shared/api";
import type { IUserInfo } from "@/shared/store";
import { getCookie, useUserStore } from "@/shared/lib";
import { ENDPOINT, AUTH_USER_ID } from "@/shared/config";
import { LoginApi } from "../api/login-service";
const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
const router = useRouter();
const { userInfo, setUserInfo } = useUserStore((state) => state);
async function authCheck() {
// 로그인 페이지에서는 인증 체크를 건너뜀
if (window.location.pathname === '/auth/login') {
return;
}
const userId = getCookie(AUTH_USER_ID);
if (!userId) {
throw new Error("Missing required authentication tokens");
}
const response = await client.get<IUserInfo>(
`${ENDPOINT.USER_SERVICE.USERS}/info/me`,
);
if (!userInfo) setUserInfo(response.data);
}
useEffect(() => {
(async () => {
try {
await authCheck();
} catch (error) {
console.error(error);
try {
await LoginApi.silentRefresh();
await authCheck();
} catch (refreshError) {
console.error("Token refresh failed:", refreshError);
setUserInfo(null);
router.push("/auth/login");
}
}
})();
}, []);
return <>{children}</>;
};
export { AuthWrapper };
@@ -0,0 +1,59 @@
import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config";
import { getCookie, setCookie, removeCookie } from "@/shared/lib";
export class TokenManager {
private static instance: TokenManager;
private refreshTimer: NodeJS.Timeout | null = null;
private constructor() {}
public static getInstance(): TokenManager {
if (!TokenManager.instance) {
TokenManager.instance = new TokenManager();
}
return TokenManager.instance;
}
public getToken(key: string): string | undefined {
return getCookie(key);
}
public setToken(key: string, value: string): void {
setCookie(key, value, {
secure: window.location.protocol === "https:",
sameSite: "lax",
});
}
public removeToken(key: string): void {
removeCookie(key);
}
public setupRefreshTimer(
callback: () => Promise<void>,
expirationTime: number,
): void {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
}
this.refreshTimer = setTimeout(() => {
callback().catch((error) => {
console.error("Silent refresh failed:", error);
this.clearAuthToken();
});
}, expirationTime - 60000);
}
public clearAuthToken(): void {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
this.removeToken(CLAIM_NAME);
this.removeToken(AUTH_USER_ID);
}
}
export const tokenManager = TokenManager.getInstance();
@@ -0,0 +1,9 @@
export interface ILogin {
userId: string;
password: string;
}
export interface LoginService {
login(payload: ILogin): Promise<void>;
silentRefresh(): Promise<void>;
}
+1
View File
@@ -0,0 +1 @@
export { CodeSelect } from "../ui/code-select";
@@ -0,0 +1,14 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { CodeService, ICode } from "../model/code-interface";
export class CodeApiService implements CodeService {
public async getCode(upCd: string): Promise<ICode[]> {
const response = await client.get<ICode[]>(
`${ENDPOINT.CMS_SERVICE.CODE}/${upCd}`,
);
return response.data;
}
}
export const CodeApi = new CodeApiService();
@@ -0,0 +1,73 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import {
LwpoCodeFormSchema,
type LwpoCodeService,
type LwpoCodeList,
type LwpoCodeDetail,
type LwpoCodeParams,
type LwpoCodeFormData,
} from "../model/lwpocode-interface";
export class LwpoCodeApiService implements LwpoCodeService {
public async getLwpoCodeList(params?: LwpoCodeParams): Promise<LwpoCodeList> {
const response = await client.get<LwpoCodeList>(
`${ENDPOINT.CMS_SERVICE.LWPO_CODE}`,
{ params },
);
return response.data;
}
public async getUpCodeLwpoCodeList(upCd: string): Promise<LwpoCodeList> {
const response = await client.get<LwpoCodeList>(
`${ENDPOINT.CMS_SERVICE.LWPO_CODE}/up-cd/${upCd}`,
);
return response.data;
}
public async lwpoCodeDetail(
upCd: string,
lwpoCd: string,
): Promise<LwpoCodeDetail> {
const response = await client.get<LwpoCodeDetail>(
`${ENDPOINT.CMS_SERVICE.LWPO_CODE}/${lwpoCd}/up-cd/${upCd}`,
);
return response.data;
}
public async createLwpoCode(data: LwpoCodeFormData): Promise<void> {
const validatedData = LwpoCodeFormSchema.parse(data);
const response = await client.post<void>(
`${ENDPOINT.CMS_SERVICE.LWPO_CODE}`,
validatedData,
);
return response.data;
}
public async updateLwpoCode(
upCd: string,
lwpoCd: string,
data: LwpoCodeFormData,
): Promise<void> {
const validatedData = LwpoCodeFormSchema.parse(data);
const response = await client.put<void>(
`${ENDPOINT.CMS_SERVICE.LWPO_CODE}/${lwpoCd}/up-cd/${upCd}`,
validatedData,
);
return response.data;
}
public async deleteLwpoCode(upCd: string, lwpoCd: string): Promise<void> {
await client.delete<void>(
`${ENDPOINT.CMS_SERVICE.LWPO_CODE}/${lwpoCd}/up-cd/${upCd}`,
);
}
public async codeRefreshAll(): Promise<void> {
await client.post<void>(
`${ENDPOINT.CMS_SERVICE.LWPO_CODE}/cache/refresh-all`,
);
}
}
export const LwpoCodeApi = new LwpoCodeApiService();
@@ -0,0 +1,47 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import {
upCodeFormSchema,
type UpCodeService,
type UpCodeList,
type UpCodeDetail,
type UpCodeParams,
type UpCodeFormData,
} from "../model/upcode-interface";
export class UpCodeApiService implements UpCodeService {
public async getUpCodeList(params?: UpCodeParams): Promise<UpCodeList> {
const response = await client.get<UpCodeList>(
`${ENDPOINT.CMS_SERVICE.UP_CODE}`,
{ params },
);
return response.data;
}
public async upCodeDetail(upCd: string): Promise<UpCodeDetail> {
const response = await client.get<UpCodeDetail>(
`${ENDPOINT.CMS_SERVICE.UP_CODE}/${upCd}`,
);
return response.data;
}
public async createUpCode(data: UpCodeFormData): Promise<void> {
const validatedData = upCodeFormSchema.parse(data);
const response = await client.post<void>(
`${ENDPOINT.CMS_SERVICE.UP_CODE}`,
validatedData,
);
return response.data;
}
public async updateUpCode(upCd: string, data: UpCodeFormData): Promise<void> {
const validatedData = upCodeFormSchema.parse(data);
const response = await client.put<void>(
`${ENDPOINT.CMS_SERVICE.UP_CODE}/${upCd}`,
validatedData,
);
return response.data;
}
}
export const UpCodeApi = new UpCodeApiService();
+19
View File
@@ -0,0 +1,19 @@
export { CodeApi } from "./api/code-service";
export type { ICode } from "./model/code-interface";
export { CodeSelect } from "./ui/code-select";
export { UpCodeApi } from "./api/upcode-service";
export {
upCodeFormSchema,
type UpCode,
type UpCodeFormData,
type UpCodeParams,
} from "./model/upcode-interface";
export { LwpoCodeApi } from "./api/lwpocode-service";
export {
LwpoCodeFormSchema,
type LwpoCode,
type LwpoCodeFormData,
type LwpoCodeParams,
} from "./model/lwpocode-interface";
@@ -0,0 +1,10 @@
export interface ICode {
lwpoCd: string;
upCd: string;
lwpoCdNm: string;
unit: string;
}
export interface CodeService {
getCode(upCd: string): Promise<ICode[]>;
}
@@ -0,0 +1,69 @@
import { z } from "zod";
import type { PageableParams, PageableList } from "@/shared/model";
export interface LwpoCodeParams extends PageableParams {
upCd?: string;
lwpoCd?: string;
lwpoCdNm?: string;
useYn?: string;
}
export interface LwpoCode {
lwpoCd: string;
upCd: string;
lwpoCdNm: string;
lwpoCdExpln: string;
unit: string;
prntLwpoCd: string;
useYn: string;
sortSeq: number;
regDt: string;
rgTr: string;
rgTrNm: string;
mdfcnDt: string;
mdFr: string;
}
export interface LwpoCodeDetail extends LwpoCodeFormData {
lwpoCd: string;
upCd: string;
lwpoCdNm: string;
lwpoCdExpln: string;
unit: string;
prntLwpoCd: string;
useYn: string;
sortSeq: 0;
}
export const LwpoCodeFormSchema = z.object({
lwpoCd: z.string().max(10).nonempty(),
upCd: z.string().max(10).nonempty(),
lwpoCdNm: z.string().max(100).nonempty(),
lwpoCdExpln: z.string().max(1000).nullish(),
unit: z.string().max(20).nullish(),
prntLwpoCd: z.string().max(10).nullish(),
useYn: z.string(),
sortSeq: z.number(),
delYn: z.string(),
});
export type LwpoCodeFormData = z.infer<typeof LwpoCodeFormSchema>;
export type LwpoCodeList = PageableList<LwpoCode>;
export interface LwpoCodeService {
getLwpoCodeList(params: LwpoCodeParams): Promise<LwpoCodeList>;
getUpCodeLwpoCodeList(upCd: string): Promise<LwpoCodeList>;
lwpoCodeDetail(upCd: string, lwpoCd: string): Promise<LwpoCodeDetail>;
updateLwpoCode(
upCd: string,
lwpoCd: string,
data: LwpoCodeFormData,
): Promise<void>;
createLwpoCode(data: LwpoCodeFormData): Promise<void>;
deleteLwpoCode(
upCd: string,
lwpoCd: string,
data: LwpoCodeFormData,
): Promise<void>;
codeRefreshAll(): Promise<void>;
}
@@ -0,0 +1,52 @@
import { z } from "zod";
import type { PageableParams, PageableList } from "@/shared/model";
export interface UpCodeParams extends PageableParams {
keyword?: string;
upCd?: string;
cdSe?: string;
useYn?: string;
siteId?: string;
}
export interface UpCode {
upCd: string;
upCdNm: string;
upCdExpln: string;
cdSe: string;
siteId: string;
useYn: string;
regDt: string;
}
export interface UpCodeDetail extends UpCodeFormData {
upCd: string;
upCdNm: string;
upCdExpln: string;
cdSe: string;
siteId: string;
useYn: string;
delYn: string;
regDt: string;
rgTrNm: string;
}
export const upCodeFormSchema = z.object({
upCd: z.string().max(10).nonempty(),
upCdNm: z.string().max(100).nonempty(),
upCdExpln: z.string().max(1000).nullish(),
cdSe: z.string().max(10).nonempty(),
siteId: z.string().max(2).nullish(),
useYn: z.string(),
delYn: z.string(),
});
export type UpCodeFormData = z.infer<typeof upCodeFormSchema>;
export type UpCodeList = PageableList<UpCode>;
export interface UpCodeService {
getUpCodeList(params: UpCodeParams): Promise<UpCodeList>;
upCodeDetail(upCodeId: string): Promise<UpCodeDetail>;
updateUpCode(upCodeId: string, data: UpCodeFormData): Promise<void>;
createUpCode(data: UpCodeFormData): Promise<void>;
}
@@ -0,0 +1,47 @@
import { useQuery } from "@tanstack/react-query";
import {
cn,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
import { ENDPOINT } from "@/shared/config";
import { CodeApi } from "../api/code-service";
type CodeSelectProps = {
upCd: string;
placeholder?: string;
className?: string;
} & React.ComponentPropsWithoutRef<typeof Select>;
function CodeSelect({
upCd,
placeholder = "선택",
className,
...props
}: CodeSelectProps) {
const { data: code } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.CODE, upCd],
queryFn: () => CodeApi.getCode(upCd),
enabled: !!upCd,
});
return (
<Select {...props}>
<SelectTrigger className={cn("w-[12rem]", className)}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{code?.map((item) => (
<SelectItem key={item.lwpoCd} value={item.lwpoCd}>
{item.lwpoCdNm}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export { CodeSelect };
@@ -0,0 +1 @@
export { MenuAuthorityApi } from "../api/menu-authority-service";
@@ -0,0 +1 @@
export { AuthorityButton } from "../ui/authority-button";
@@ -0,0 +1,50 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
MenuAuthorityList,
MenuAuthorityParams,
MenuAuthorityPayload,
MenuAuthorityService,
PageMenuAuthority,
} from "../model/menu-authority-interface";
export class MenuAuthorityApiService implements MenuAuthorityService {
public async getMenuAuthorityList(
params: MenuAuthorityParams,
): Promise<MenuAuthorityList> {
const response = await client.get<MenuAuthorityList>(
`${ENDPOINT.CMS_SERVICE.MENU_AUTHRTS}`,
{ params },
);
return response.data;
}
public async getPageMenuAuthority(
menuCd: number,
): Promise<PageMenuAuthority[]> {
const response = await client.get<PageMenuAuthority[]>(
`${ENDPOINT.CMS_SERVICE.MENU_AUTHRTS}/pageinfo/${menuCd}`,
);
return response.data;
}
public async saveMenuAuthority(data: MenuAuthorityPayload[]): Promise<void> {
const response = await client.post<void>(
`${ENDPOINT.CMS_SERVICE.MENU_AUTHRTS}`,
data,
);
return response.data;
}
public async getGroupMenuAuthorityList(
params: MenuAuthorityParams,
): Promise<MenuAuthorityList> {
const response = await client.get<MenuAuthorityList>(
`${ENDPOINT.CMS_SERVICE.MENU_AUTHRTS}/group`,
{ params },
);
return response.data;
}
}
export const MenuAuthorityApi = new MenuAuthorityApiService();
@@ -0,0 +1,9 @@
export { MenuAuthorityApi } from "./api/menu-authority-service";
export {
menuAuthorityFormSchema,
type MenuAuthority,
type MenuAuthorityPayload,
type MenuAuthorityFormData,
} from "./model/menu-authority-interface";
export { AuthorityButton } from "./ui/authority-button";
export { ManagementAuthorityDialog } from "./ui/management-authority-dialog";
@@ -0,0 +1,51 @@
import { z } from "zod";
import type { PageableList, PageableParams } from "@/shared/model";
export interface MenuAuthorityParams extends PageableParams {
groupId?: string;
menuCd?: number;
useYn?: string;
}
export interface PageMenuAuthority {
mngAuthrtYn: string;
inqAuthrtYn: string;
regAuthrtYn: string;
mdfcnAuthrtYn: string;
delAuthrtYn: string;
}
export const menuAuthoritySchema = z.object({
groupId: z.string(),
groupNm: z.string(),
menuCd: z.number(),
mngAuthrtYn: z.string(),
inqAuthrtYn: z.string(),
regAuthrtYn: z.string(),
mdfcnAuthrtYn: z.string(),
delAuthrtYn: z.string(),
useYn: z.string(),
mode: z.string(),
});
export const menuAuthorityPayloadSchema = menuAuthoritySchema.omit({
groupNm: true,
});
export const menuAuthorityFormSchema = z.object({
menuAuthority: z.array(menuAuthorityPayloadSchema),
});
export type MenuAuthority = z.infer<typeof menuAuthoritySchema>;
export type MenuAuthorityList = PageableList<MenuAuthority>;
export type MenuAuthorityPayload = z.infer<typeof menuAuthorityPayloadSchema>;
export type MenuAuthorityFormData = z.infer<typeof menuAuthorityFormSchema>;
export interface MenuAuthorityService {
getMenuAuthorityList(params: MenuAuthorityParams): Promise<MenuAuthorityList>;
getGroupMenuAuthorityList(
params: MenuAuthorityParams,
): Promise<MenuAuthorityList>;
getPageMenuAuthority(menuCd: number): Promise<PageMenuAuthority[]>;
saveMenuAuthority(data: MenuAuthorityPayload[]): Promise<void>;
}
@@ -0,0 +1,117 @@
import type { ColumnDef } from "@tanstack/react-table";
import type { UseFormReturn } from "react-hook-form";
import { Checkbox } from "@common/components";
import type {
MenuAuthority,
MenuAuthorityFormData,
MenuAuthorityPayload,
} from "./menu-authority-interface";
export const createColumns = (
form: UseFormReturn<MenuAuthorityFormData>,
): ColumnDef<MenuAuthority>[] => {
const getCurrentRow = (i: number) => form.watch("menuAuthority")[i];
const handleCheckedChange =
(i: number, key: keyof MenuAuthorityPayload) => (checked: boolean) => {
if (getCurrentRow(i)?.mode === "R") {
form.setValue(`menuAuthority.${i}.mode`, "U");
}
form.setValue(`menuAuthority.${i}.${key}`, checked ? "Y" : "N");
};
return [
{
accessorKey: "groupNm",
header: "그룹명",
},
{
accessorKey: "mngAuthrtYn",
header: "관리권한",
cell: ({ row }) => {
return (
<Checkbox
checked={getCurrentRow(row.index)?.mngAuthrtYn === "Y"}
onCheckedChange={(checked) => {
if (checked) {
handleCheckedChange(
row.index,
"regAuthrtYn",
)(checked as boolean);
handleCheckedChange(
row.index,
"mdfcnAuthrtYn",
)(checked as boolean);
handleCheckedChange(
row.index,
"delAuthrtYn",
)(checked as boolean);
handleCheckedChange(
row.index,
"inqAuthrtYn",
)(checked as boolean);
}
handleCheckedChange(row.index, "mngAuthrtYn")(checked as boolean);
}}
/>
);
},
},
{
accessorKey: "regAuthrtYn",
header: "등록권한",
cell: ({ row }) => {
return (
<Checkbox
checked={getCurrentRow(row.index)?.regAuthrtYn === "Y"}
onCheckedChange={(checked) => {
if (checked) {
handleCheckedChange(
row.index,
"inqAuthrtYn",
)(checked as boolean);
}
handleCheckedChange(row.index, "regAuthrtYn")(checked as boolean);
}}
/>
);
},
},
{
accessorKey: "mdfcnAuthrtYn",
header: "수정권한 ",
cell: ({ row }) => {
return (
<Checkbox
checked={getCurrentRow(row.index)?.mdfcnAuthrtYn === "Y"}
onCheckedChange={handleCheckedChange(row.index, "mdfcnAuthrtYn")}
/>
);
},
},
{
accessorKey: "delAuthrtYn",
header: "삭제권한",
cell: ({ row }) => {
return (
<Checkbox
checked={getCurrentRow(row.index)?.delAuthrtYn === "Y"}
onCheckedChange={handleCheckedChange(row.index, "delAuthrtYn")}
/>
);
},
},
{
accessorKey: "inqAuthrtYn",
header: "조회권한",
cell: ({ row }) => {
return (
<Checkbox
checked={getCurrentRow(row.index)?.inqAuthrtYn === "Y"}
onCheckedChange={handleCheckedChange(row.index, "inqAuthrtYn")}
/>
);
},
},
];
};
@@ -0,0 +1,26 @@
import { useMenuStore } from "@/shared/lib";
import { Button, cn, type ButtonProps } from "@common/components";
import type { PageMenuAuthority } from "../model/menu-authority-interface";
type AuthorityButtonProps = {
authority: keyof PageMenuAuthority;
} & ButtonProps;
function AuthorityButton({
authority,
className,
children,
...props
}: AuthorityButtonProps) {
const pageMenuAuthority = useMenuStore((state) => state.pageMenuAuthority);
const isAuthority = pageMenuAuthority?.[authority] === "Y";
return (
<Button className={cn(className, !isAuthority && "hidden")} {...props}>
{children}
</Button>
);
}
export { AuthorityButton };
@@ -0,0 +1,101 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { getCoreRowModel } from "@tanstack/react-table";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@common/components";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useDataTable, useMutationWrapper } from "@/shared/lib";
import {
menuAuthorityFormSchema,
type MenuAuthorityFormData,
type MenuAuthorityPayload,
} from "../model/menu-authority-interface";
import { createColumns } from "../model/menu-authority-table-columns";
import { MenuAuthorityApi } from "../api/menu-authority-service";
type ManagementAuthorityDialogProps = {
menuCd?: number;
} & React.ComponentProps<typeof Dialog>;
function ManagementAuthorityDialog({ menuCd }: ManagementAuthorityDialogProps) {
const { confirm } = useConfirm();
const alert = useAlertStore((state) => state.setMessage);
const [open, setOpen] = useState(false);
const query = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.MENU_AUTHRTS, menuCd],
queryFn: () => MenuAuthorityApi.getMenuAuthorityList({ menuCd }),
enabled: !!menuCd,
});
const { mutateAsync: saveMenuAuthority } = useMutationWrapper({
mutationFn: (data: MenuAuthorityPayload[]) =>
MenuAuthorityApi.saveMenuAuthority(data),
onSuccess: () => {
alert("저장되었습니다.", () => setOpen(false));
},
});
const form = useForm<MenuAuthorityFormData>({
resolver: zodResolver(menuAuthorityFormSchema),
values: {
menuAuthority: query.data?.content ?? [],
},
});
const { tableComponent } = useDataTable({
query,
tableOptions: {
columns: createColumns(form),
getCoreRowModel: getCoreRowModel(),
},
});
const handleSave = async () => {
if (await confirm("저장하시겠습니까?")) {
const data = form.getValues("menuAuthority");
const filteredData = data.filter((item) => item.mode !== "R");
await saveMenuAuthority(filteredData);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="gap-3" disabled={!menuCd}>
<i className="diveicon di-key" />
권한관리
</Button>
</DialogTrigger>
<DialogContent className="max-w-[60rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>그룹별 메뉴 권한</DialogTitle>
</DialogHeader>
<div className="max-h-[40rem] overflow-auto">{tableComponent}</div>
<DialogFooter className="!justify-center">
<Button size="lg" onClick={handleSave}>
저장
</Button>
<DialogClose asChild>
<Button size="lg" color="white">
취소
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { ManagementAuthorityDialog };
@@ -0,0 +1,79 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
MenuService,
Menu,
MenuItem,
MenuFormData,
MenuTemporarilyStoredData,
SiteMapFormData,
} from "../model/menu-interface";
export class MenuApiService implements MenuService {
async getMenuRoles(siteId: string): Promise<MenuItem[]> {
const response = await client.get<MenuItem[]>(
`${ENDPOINT.CMS_SERVICE.MENU_ROLES}/${siteId}`,
);
return response.data;
}
public async getMenuInfo(menuCd: number): Promise<Menu> {
const response = await client.get<Menu>(
`${ENDPOINT.CMS_SERVICE.MENUS}/${menuCd}`,
);
return response.data;
}
public async createMenu(data: MenuFormData): Promise<void> {
const response = await client.post<void>(
`${ENDPOINT.CMS_SERVICE.MENUS}`,
data,
);
return response.data;
}
public async updateMenu(
menuCd: number,
data: Omit<MenuFormData, "siteId">,
): Promise<void> {
const response = await client.put<void>(
`${ENDPOINT.CMS_SERVICE.MENUS}/${menuCd}`,
data,
);
return response.data;
}
public async deleteMenu(menuCd: number): Promise<void> {
await client.delete(`${ENDPOINT.CMS_SERVICE.MENUS}/${menuCd}`);
}
public async menuTemporaryStorage(
menuCd: number,
data: MenuFormData,
): Promise<void> {
const response = await client.post<void>(
`${ENDPOINT.CMS_SERVICE.MENU_TEMPORARY_STORAGE}/${menuCd}`,
data,
);
return response.data;
}
public async getMenuTemporarilyStoredData(
menuCd: number,
): Promise<MenuTemporarilyStoredData[]> {
const response = await client.get<MenuTemporarilyStoredData[]>(
`${ENDPOINT.CMS_SERVICE.MENU_TEMPORARY_STORAGE}/menu/${menuCd}/user`,
);
return response.data;
}
public async publishSiteMap(data: SiteMapFormData["siteMap"]): Promise<void> {
const response = await client.post<void>(
`${ENDPOINT.CMS_SERVICE.MENUS}/sitemap/publish`,
data,
);
return response.data;
}
}
export const MenuApi = new MenuApiService();
+12
View File
@@ -0,0 +1,12 @@
export { MenuApi } from "./api/menu-service";
export {
MENU_TYPE,
menuFormSchema,
siteMapFormSchema,
type MenuType,
type MenuItem,
type MenuFormData,
type SiteMapFormData,
type SiteMapPayloadData,
} from "./model/menu-interface";
export { MenuWrapper } from "./lib/menu-wrapper";
@@ -0,0 +1,58 @@
"use client";
import { useEffect } from "react";
import { usePathname, useRouter } from "next/navigation";
import { MenuAuthorityApi } from "@/entities/menu-authority/@x/menu";
import { getCookie, useAlertStore, useMenuStore } from "@/shared/lib";
import { SITE_ID } from "@/shared/config";
import { MenuApi } from "../api/menu-service";
import { getCurrentMenu } from "./utils";
const MenuWrapper = ({ children }: { children: React.ReactNode }) => {
const router = useRouter();
const pathname = usePathname();
const alert = useAlertStore((state) => state.setMessage);
const { pageMenuAuthority, setCurrentMenuInfo, setPageMenuAuthority } =
useMenuStore((state) => state);
const getCurrentMenuInfo = async () => {
const menuRoles = await MenuApi.getMenuRoles(SITE_ID);
if (!menuRoles) return;
return getCurrentMenu(menuRoles, pathname ?? "");
};
const getPageMenuAuthority = async () => {
const menuCd = getCookie("menu-code-key");
if (!menuCd) return;
const response = await MenuAuthorityApi.getPageMenuAuthority(
parseInt(menuCd),
);
return response;
};
const checkInquiryAuthority = () => {
return pageMenuAuthority?.inqAuthrtYn === "Y";
};
useEffect(() => {
(async () => {
const currentMenu = await getCurrentMenuInfo();
if (currentMenu) setCurrentMenuInfo(currentMenu);
const response = await getPageMenuAuthority();
if (response?.length) setPageMenuAuthority(response[0]!);
})();
}, [pathname]);
useEffect(() => {
if (!pageMenuAuthority) return;
const inquiryAuthority = checkInquiryAuthority();
if (!inquiryAuthority)
alert("페이지에 대한 조회 권한이 없습니다.", () => router.push("/"));
}, [pageMenuAuthority]);
return <>{children}</>;
};
export { MenuWrapper };
+20
View File
@@ -0,0 +1,20 @@
import type { MenuItem } from "../model/menu-interface";
export const getCurrentMenu = (
menus: MenuItem[],
pathname: string,
): MenuItem | null => {
for (const menu of menus) {
if (
menu.urlPath !== "" &&
(menu.urlPath === pathname || pathname.startsWith(menu.urlPath))
)
return menu;
if (menu.children) {
const children = getCurrentMenu(menu.children, pathname);
if (children) return children;
}
}
return null;
};
@@ -0,0 +1,107 @@
import { z } from "zod";
export const MENU_TYPE = {
EMPTY: "EMPTY",
CONTENTS: "CONTENTS",
BOARD: "BOARD",
INSIDE: "INSIDE",
OUTSIDE: "OUTSIDE",
BOTTOM: "BOTTOM",
} as const;
export type MenuType = (typeof MENU_TYPE)[keyof typeof MENU_TYPE];
export interface MenuItem {
menuCd: number;
menuKornNm: string;
menuEngNm: string;
parentId: number | null;
sortSeq: number;
iconNm?: string;
children: MenuItem[] | null;
levelNo: number;
urlPath: string;
menuLnkgTypeCd: string;
menuLnkgSn: number;
menuPath: string;
qrcd: string;
useYn: string;
otptYn: string;
newWndwYn: string;
requiredUrlPath: boolean;
}
export interface MenuTemporarilyStoredData {
tmprStrgSn: number;
menuCd: number;
menuTmprStrg: string;
regDt: string;
rgtr: string;
mdfcnDt: string;
mdfr: string;
}
export const menuFormSchema = z.object({
siteId: z.string(),
menuKornNm: z.string(),
menuSubKornNm: z.string().nullish(),
menuEngNm: z.string().nullish(),
menuSubEngNm: z.string().nullish(),
levelNo: z.number(),
urlPath: z.string().nullish(),
iconNm: z.string().nullish(),
menuLnkgTypeCd: z.enum([
MENU_TYPE.EMPTY,
MENU_TYPE.CONTENTS,
MENU_TYPE.BOARD,
MENU_TYPE.INSIDE,
MENU_TYPE.OUTSIDE,
MENU_TYPE.BOTTOM,
]),
menuLnkgSn: z.number().nullish(),
otptYn: z.string().default("Y"),
prntMenuCd: z.number().nullish(),
sortSeq: z.number(),
evlUseYn: z.string().default("Y"),
qrcd: z.string().nullish(),
useYn: z.string().default("Y"),
newWndwYn: z.string().nullish(),
menuLnkgSnNm: z.string().nullish(),
menuPicUserUnqId: z.string().nullish(),
menuPicUserUnqIdNm: z.string().nullish(),
menuPicDsgnYmd: z.string().nullish(),
});
export const siteMapPayloadSchema = z.object({
menuCd: z.number(),
menuSubKornNm: z.string(),
otptYn: z.string(),
});
export const siteMapFormSchema = z.object({
siteMap: z.array(siteMapPayloadSchema),
});
export type MenuFormData = z.infer<typeof menuFormSchema>;
export type SiteMapFormData = z.infer<typeof siteMapFormSchema>;
export type SiteMapPayloadData = z.infer<typeof siteMapPayloadSchema>;
export type Menu = Omit<MenuFormData, "iconNm"> & {
menuCd: number;
iconName?: string;
};
export interface MenuService {
getMenuRoles(siteId: string): Promise<MenuItem[]>;
getMenuInfo: (menuCd: number) => Promise<Menu>;
createMenu: (data: MenuFormData) => Promise<void>;
updateMenu: (
menuCd: number,
data: Omit<MenuFormData, "siteId">,
) => Promise<void>;
deleteMenu: (menuCd: number) => Promise<void>;
menuTemporaryStorage: (menuCd: number, data: MenuFormData) => Promise<void>;
getMenuTemporarilyStoredData: (
menuCd: number,
) => Promise<MenuTemporarilyStoredData[]>;
publishSiteMap: (data: SiteMapFormData["siteMap"]) => Promise<void>;
}
@@ -0,0 +1,225 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type {
UserService,
UserSearchParams,
UserList,
UserDetailResponse,
UserSttsStatsList,
UserUpdateData,
UserPasswordUpdateData,
UserFindPasswordUpdateData,
UserPasswordMatchData,
UserUpdateInfoData,
UserSaveData,
UserFindPasswordSaveData,
UserVerifyData,
UserEmailData,
UserIdData,
AdminUserList,
} from "../model/user-interface";
export class UserApiService implements UserService {
// 사용자 목록 조회
public async findPageUser(params: UserSearchParams): Promise<UserList> {
const response = await client.get<UserList>(
`${ENDPOINT.USER_SERVICE.USERS}`,
{ params },
);
return response.data;
}
// 사용자 단건 조회
public async findByUserUnqId(userUnqId: string): Promise<UserDetailResponse> {
const response = await client.get<UserDetailResponse>(
`${ENDPOINT.USER_SERVICE.USERS}/${userUnqId}`,
);
return response.data;
}
// 로그인한 사용자 정보 조회
public async findByUserUnqIdByLogin(): Promise<UserDetailResponse> {
const response = await client.get<UserDetailResponse>(
`${ENDPOINT.USER_SERVICE.USERS}/info/me`,
);
return response.data;
}
// 사용자 회원상태 통계 조회
public async selectUserSttsStats(): Promise<UserSttsStatsList> {
const response = await client.get<UserSttsStatsList>(
`${ENDPOINT.USER_SERVICE.USERS}/stts/stats`,
);
return response.data;
}
// 사용자 저장
public async saveUser(data: UserSaveData): Promise<string> {
const response = await client.post<string>(
`${ENDPOINT.USER_SERVICE.USERS}`,
data,
);
return response.data;
}
// 사용자 수정
public async updateUser(
userUnqId: string,
data: UserUpdateData,
): Promise<string> {
const response = await client.put<string>(
`${ENDPOINT.USER_SERVICE.USERS}/${userUnqId}`,
data,
);
return response.data;
}
// 사용자 삭제
public async deleteUser(userUnqId: string): Promise<boolean> {
const response = await client.delete<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/delete/${userUnqId}`,
);
return response.data;
}
// 사용자 계정잠금 처리
public async lockUser(userUnqId: string): Promise<boolean> {
const response = await client.put<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/lock/${userUnqId}`,
);
return response.data;
}
// 사용자 계정잠금 해제처리
public async unlockUser(userUnqId: string): Promise<boolean> {
const response = await client.put<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/unlock/${userUnqId}`,
);
return response.data;
}
// 리프레시 토큰 정보 재발급
public async refreshToken(): Promise<void> {
await client.put(`${ENDPOINT.USER_SERVICE.USERS}/token/refresh`);
}
// 사용자 비밀번호 변경
public async updatePassword(data: UserPasswordUpdateData): Promise<boolean> {
const response = await client.put<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/password/update`,
data,
);
return response.data;
}
// 사용자 비밀번호 초기화(관리자용)
public async initializePassword(
userUnqId: string,
data: UserPasswordUpdateData,
): Promise<boolean> {
const response = await client.put<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/password/initialize/${userUnqId}`,
data,
);
return response.data;
}
// 사용자 비밀번호 찾기 변경
public async changePassword(
data: UserFindPasswordUpdateData,
): Promise<boolean> {
const response = await client.put<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/password/change`,
data,
);
return response.data;
}
// 사용자 비밀번호 확인
public async matchPassword(data: UserPasswordMatchData): Promise<boolean> {
const response = await client.post<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/password/match`,
data,
);
return response.data;
}
// 사용자 비밀번호 찾기
public async findPasswordUser(
data: UserFindPasswordSaveData,
): Promise<boolean> {
const response = await client.post<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/password/find`,
data,
);
return response.data;
}
// 사용자 비밀번호 찾기 유효성 확인
public async validPassword(token: string): Promise<boolean> {
const response = await client.get<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/password/valid/${token}`,
);
return response.data;
}
// 사용자 회원정보 변경
public async updateInfo(
userUnqId: string,
data: UserUpdateInfoData,
): Promise<string> {
const response = await client.put<string>(
`${ENDPOINT.USER_SERVICE.USERS}/info/${userUnqId}`,
data,
);
return response.data;
}
// 사용자 승인처리
public async applyUser(userUnqId: string): Promise<boolean> {
const response = await client.put<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/apply/${userUnqId}`,
);
return response.data;
}
// 사용자 회원탈퇴
public async leaveUser(data: UserVerifyData): Promise<boolean> {
const response = await client.post<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/leave`,
data,
);
return response.data;
}
// 사용자 이메일 중복 체크
public async existsEmail(data: UserEmailData): Promise<boolean> {
const response = await client.post<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/exists`,
data,
);
return response.data;
}
// 사용자 아이디 중복 체크
public async existsUserId(data: UserIdData): Promise<boolean> {
const response = await client.post<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/existsUserId`,
data,
);
return response.data;
}
// 관리자 목록 조회
public async findPageAdminUser(
params: UserSearchParams,
): Promise<AdminUserList> {
const response = await client.get<AdminUserList>(
`${ENDPOINT.USER_SERVICE.USERS}/admin/list`,
{ params },
);
return response.data;
}
}
export const UserApi = new UserApiService();
+17
View File
@@ -0,0 +1,17 @@
export { UserApi } from "./api/user-service";
export {
USER_STATE_CODE,
userSaveSchema,
userPasswordUpdateSchema,
userUpdateSchema,
type UserStateCode,
type UserSearchParams,
type UserListResponse,
type UserSaveData,
type UserDetailResponse,
type UserPasswordUpdateData,
type UserUpdateData,
type AdminUserList,
type AdminUserListResponse,
} from "./model/user-interface";
export { PasswordResetDialog } from "./ui/password-reset-dialog";
@@ -0,0 +1,362 @@
import { z } from "zod";
import type { PageableList, PageableParams } from "@/shared/model";
export const USER_STATE_CODE = {
대기: "00",
정상: "01",
정지: "07",
탈퇴: "08",
삭제: "09",
} as const;
export type UserStateCode =
(typeof USER_STATE_CODE)[keyof typeof USER_STATE_CODE];
// 사용자 검색 파라미터
export interface UserSearchParams extends PageableParams {
keywordType?: string;
keyword?: string;
userStateCode?: string;
aprvYn?: string;
grpId?: string;
frDateYmd?: string;
toDateYmd?: string;
userName?: string;
}
// 사용자 목록 응답 인터페이스
export interface UserListResponse {
userId: string;
userName: string;
email: string;
roleId: string;
roleName: string;
userStateCode: UserStateCode;
userStateCodeName: string;
lastLoginDate: string;
loginFailCount: number;
userUnqId: string;
mphn: string;
telno: string;
aprvYn: string;
regDt: string;
laeSeCd: string;
laeSeNm: string;
ogdpSeCd: string;
ogdpSeNm: string;
instSeCd: string;
instSeNm: string;
aprvYmd: string;
}
// 사용자 상세 응답 인터페이스
export interface UserDetailResponse {
regDt: string;
mdfcnDt: string;
rgtr: string;
mdfr: string;
userUnqId: string;
userId: string;
userName: string;
email: string;
encryptedPassword: string;
refreshToken: string;
userStateCode: UserStateCode;
lastLoginDate: string;
loginFailCount: number;
pswdHnt: string;
pswdAns: string;
mphn: string;
telno: string;
zip: string;
addr: string;
daddr: string;
laeSeCd: string;
ogdpSeCd: string;
aprvYn: string;
autzrId: string;
expryYmd: string;
lastPswdChgDt: string;
googleId: string;
kakaoId: string;
naverId: string;
isSocialUser: boolean;
hasPassword: boolean;
twoFactorEnabled: boolean;
adminRoleYn: string;
instSeCd: string;
aprvYmd: string;
enoCd: string;
dtlInstCd: string;
laeSeNm: string;
ogdpSeNm: string;
instSeNm: string;
userStateNm: string;
pswdHntNm: string;
ecoNm: string;
dtlInstNm: string;
}
// 사용자 상태 통계 응답 인터페이스
export interface UserSttsStatsResponse {
sttsCd: string;
sttsNm: string;
cnt: number;
}
// 관리자 목록 응답 인터페이스
export interface AdminUserListResponse {
userUnqId: string;
userId: string;
userName: string;
groupId: string;
groupNm: string;
userStateCode: string;
userStateCodeName: string;
laeSeCd: string;
laeSeNm: string;
ogdpSeCd: string;
ogdpSeNm: string;
instSeCd: string;
instSeNm: string;
dtlInstCd: string;
dtlInstNm: string;
mdfr: string;
mdfrNm: string;
mdfcnDt: string;
}
// 사용자 수정 요청 스키마
export const userUpdateSchema = z.object({
userId: z.string().optional(),
userName: z.string(),
email: z.string().email().optional(),
password: z
.string()
.regex(
/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/,
"비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.",
)
.optional(),
roleId: z.string().optional(),
userStateCode: z.string(),
pswdHnt: z.string().max(10),
pswdAns: z.string().max(100).optional(),
mphn: z.string().max(11).optional(),
telno: z.string().max(11).optional(),
zip: z.string().max(5).optional(),
addr: z.string().max(200).optional(),
daddr: z.string().max(200).optional(),
laeSeCd: z.string().max(10),
ogdpSeCd: z.string().max(10),
aprvYn: z.string(),
autzrId: z.string().uuid().optional(),
expryYmd: z.string().max(8),
instSeCd: z.string().optional(),
dtlInstCd: z.string().optional(),
aprvYmd: z.string().max(8).optional(),
enoCd: z.string().optional(),
});
// 사용자 비밀번호 변경 스키마
export const userPasswordUpdateSchema = z
.object({
password: z.string().optional(),
provider: z.string().optional(),
token: z.string().optional(),
newPassword: z
.string()
.regex(
/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/,
"비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.",
),
newPasswordConfirm: z.string(),
})
.refine((data) => data.newPassword === data.newPasswordConfirm, {
message: "비밀번호가 일치하지 않습니다.",
path: ["newPasswordConfirm"],
});
// 사용자 비밀번호 찾기 변경 스키마
export const userFindPasswordUpdateSchema = z.object({
tokenValue: z.string(),
password: z
.string()
.regex(
/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/,
"비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.",
),
});
// 사용자 비밀번호 확인 스키마
export const userPasswordMatchSchema = z.object({
password: z
.string()
.regex(
/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/,
"비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.",
),
});
// 사용자 회원정보 변경 스키마
export const userUpdateInfoSchema = z.object({
password: z
.string()
.regex(
/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/,
"비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.",
)
.optional(),
provider: z.string().optional(),
token: z.string().optional(),
userName: z.string(),
email: z.string().email().optional(),
});
// 사용자 저장 스키마
export const userSaveSchema = z.object({
userId: z.string(),
userName: z.string(),
email: z.string().email().optional(),
password: z
.string()
.regex(
/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/,
"비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.",
),
pswdHnt: z.string().max(10),
pswdAns: z.string().max(100).optional(),
mphn: z.string().max(11).optional(),
telno: z.string().max(11).optional(),
zip: z.string().max(5).optional(),
addr: z.string().max(200).optional(),
daddr: z.string().max(200).optional(),
laeSeCd: z.string().max(10),
ogdpSeCd: z.string().max(10),
expryYmd: z.string().max(8),
instSeCd: z.string().optional(),
dtlInstCd: z.string().optional(),
enoCd: z.string().optional(),
});
// 사용자 비밀번호 찾기 저장 스키마
export const userFindPasswordSaveSchema = z.object({
userName: z.string(),
emailAddr: z.string().email(),
mainUrl: z.string(),
changePasswordUrl: z.string(),
});
// 사용자 인증 요청 스키마
export const userVerifySchema = z.object({
password: z
.string()
.regex(/((?=.*[0-9])(?=.*[a-zA-Z])(?=.*\W)(?=\S+$).{8,20})|()/)
.optional(),
provider: z.string().optional(),
token: z.string().optional(),
});
// 사용자 이메일 요청 스키마
export const userEmailSchema = z.object({
userId: z.string().optional(),
email: z.string().email(),
});
// 사용자 ID 요청 스키마
export const userIdSchema = z.object({
userId: z.string(),
});
// Zod 스키마 타입 정의
export type UserUpdateData = z.infer<typeof userUpdateSchema>;
export type UserPasswordUpdateData = z.infer<typeof userPasswordUpdateSchema>;
export type UserFindPasswordUpdateData = z.infer<
typeof userFindPasswordUpdateSchema
>;
export type UserPasswordMatchData = z.infer<typeof userPasswordMatchSchema>;
export type UserUpdateInfoData = z.infer<typeof userUpdateInfoSchema>;
export type UserSaveData = z.infer<typeof userSaveSchema>;
export type UserFindPasswordSaveData = z.infer<
typeof userFindPasswordSaveSchema
>;
export type UserVerifyData = z.infer<typeof userVerifySchema>;
export type UserEmailData = z.infer<typeof userEmailSchema>;
export type UserIdData = z.infer<typeof userIdSchema>;
// 페이지네이션 타입 정의
export type UserList = PageableList<UserListResponse>;
export type UserSttsStatsList = UserSttsStatsResponse[];
export type AdminUserList = PageableList<AdminUserListResponse>;
// 사용자 서비스 인터페이스
export interface UserService {
// 사용자 목록 조회
findPageUser: (params: UserSearchParams) => Promise<UserList>;
// 사용자 단건 조회
findByUserUnqId: (userUnqId: string) => Promise<UserDetailResponse>;
// 로그인한 사용자 정보 조회
findByUserUnqIdByLogin: () => Promise<UserDetailResponse>;
// 사용자 회원상태 통계 조회
selectUserSttsStats: () => Promise<UserSttsStatsList>;
// 사용자 저장
saveUser: (data: UserSaveData) => Promise<string>;
// 사용자 수정
updateUser: (userUnqId: string, data: UserUpdateData) => Promise<string>;
// 사용자 삭제
deleteUser: (userUnqId: string) => Promise<boolean>;
// 사용자 계정잠금 처리
lockUser: (userUnqId: string) => Promise<boolean>;
// 사용자 계정잠금 해제처리
unlockUser: (userUnqId: string) => Promise<boolean>;
// 리프레시 토큰 정보 재발급
refreshToken: () => Promise<void>;
// 사용자 비밀번호 변경
updatePassword: (data: UserPasswordUpdateData) => Promise<boolean>;
// 사용자 비밀번호 초기화(관리자용)
initializePassword: (
userUnqId: string,
data: UserPasswordUpdateData,
) => Promise<boolean>;
// 사용자 비밀번호 찾기 변경
changePassword: (data: UserFindPasswordUpdateData) => Promise<boolean>;
// 사용자 비밀번호 확인
matchPassword: (data: UserPasswordMatchData) => Promise<boolean>;
// 사용자 비밀번호 찾기
findPasswordUser: (data: UserFindPasswordSaveData) => Promise<boolean>;
// 사용자 비밀번호 찾기 유효성 확인
validPassword: (token: string) => Promise<boolean>;
// 사용자 회원정보 변경
updateInfo: (userUnqId: string, data: UserUpdateInfoData) => Promise<string>;
// 사용자 승인처리
applyUser: (userUnqId: string) => Promise<boolean>;
// 사용자 회원탈퇴
leaveUser: (data: UserVerifyData) => Promise<boolean>;
// 사용자 이메일 중복 체크
existsEmail: (data: UserEmailData) => Promise<boolean>;
// 사용자 아이디 중복 체크
existsUserId: (data: UserIdData) => Promise<boolean>;
// 관리자 목록 조회
findPageAdminUser: (params: UserSearchParams) => Promise<AdminUserList>;
}
@@ -0,0 +1,124 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
Button,
Input,
DialogTrigger,
} from "@common/components";
import {
UserApi,
userPasswordUpdateSchema,
type UserPasswordUpdateData,
} from "@/entities/user";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/shared/ui/form";
import { useAlertStore, useMutationWrapper } from "@/shared/lib";
interface PasswordResetDialogProps {
userUnqId: string;
children: React.ReactNode;
}
export function PasswordResetDialog({
userUnqId,
children,
}: PasswordResetDialogProps) {
const alert = useAlertStore((state) => state.setMessage);
const [open, setOpen] = useState(false);
const form = useForm<UserPasswordUpdateData>({
resolver: zodResolver(userPasswordUpdateSchema),
defaultValues: {
newPassword: "",
newPasswordConfirm: "",
provider: "password",
},
});
const { mutate: onPasswordReset } = useMutationWrapper({
mutationFn: (data: UserPasswordUpdateData) =>
UserApi.initializePassword(userUnqId, data),
onSuccess: () => {
alert("비밀번호가 초기화되었습니다.", () => {
setOpen(false);
form.reset();
});
},
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>비밀번호 초기화</DialogTitle>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit((data) => onPasswordReset(data))}
className="space-y-6"
>
<FormField
control={form.control}
name="newPassword"
render={({ field }) => (
<FormItem>
<FormLabel>새 비밀번호</FormLabel>
<FormControl>
<Input
{...field}
type="password"
className="!text-[1.3rem]"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="newPasswordConfirm"
render={({ field }) => (
<FormItem>
<FormLabel>새 비밀번호 확인</FormLabel>
<FormControl>
<Input
{...field}
type="password"
className="!text-[1.3rem]"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<DialogClose asChild>
<Button type="button" className="white">
취소
</Button>
</DialogClose>
<Button type="submit">확인</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
+1
View File
@@ -0,0 +1 @@
export { LoginPage } from "./ui/login-page";
@@ -0,0 +1,153 @@
import { useState } from "react";
import { ACCESS_TOKEN, AUTH_USER_ID, REFRESH_TOKEN, CLAIM_NAME } from "@/shared/config";
import { tokenManager } from "@/entities/auth/lib/token-manager";
import {
Button,
Dialog,
DialogContent,
} from "@common/components";
interface OtpModalProps {
onClose: () => void;
onSuccess: () => void;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export default function OtpModal({ onClose, onSuccess, open, onOpenChange }: OtpModalProps) {
const [otp, setOtp] = useState("");
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async () => {
try {
setIsLoading(true);
setError("");
const tempToken = localStorage.getItem("tempToken");
console.log("tempToken from localStorage:", tempToken);
if (!tempToken) {
setError("임시 토큰이 없습니다. 로그인을 다시 시도해주세요.");
return;
}
// Proxy를 통해 OTP 인증 처리
const response = await fetch(`/api/proxy/user-service/api/v1/auth/2fa/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
otpCode: otp,
tempToken
}),
});
if (!response.ok) {
const errorData = await response.json();
let errorMsg = errorData.message || 'OTP 인증 실패';
if (Array.isArray(errorData.errors) && errorData.errors.length > 0) {
errorMsg = errorData.errors.map((e: { message: string }) => e.message).join(', ');
}
throw new Error(errorMsg);
}
const data = await response.json();
console.log("OTP verification response:", data);
console.log("Response headers:", Object.fromEntries(response.headers.entries()));
console.log("Response data keys:", Object.keys(data));
console.log("Full response data:", JSON.stringify(data, null, 2));
// 2FA 응답에서는 access-token만 있고 token-id는 없을 수 있음
const accessToken = data[ACCESS_TOKEN] || data.accessToken || data.access_token || data.token || data['ss-token'];
console.log("Extracted accessToken:", !!accessToken);
console.log("Token value - accessToken:", accessToken?.substring(0, 20) + "...");
// access-token이 있는 경우 저장
if (accessToken) {
console.log("Saving access token from OTP verification");
tokenManager.setToken(CLAIM_NAME, accessToken); // Authorization으로 저장
// 리프레시 토큰도 확인
const refreshToken = data[REFRESH_TOKEN] || data.refreshToken || data.refresh_token;
if (refreshToken) {
console.log("Saving refresh token");
tokenManager.setToken(REFRESH_TOKEN, refreshToken);
}
// userId는 원래 로그인 응답에서 가져온 값 사용
const originalUserId = data.userId;
if (originalUserId) {
console.log("Saving userId from original login response");
tokenManager.setToken(AUTH_USER_ID, originalUserId);
}
console.log("All tokens saved successfully");
} else {
console.log("No access token in OTP response");
console.log("Available data keys:", Object.keys(data));
console.log("Data values:", Object.entries(data).map(([k, v]) => `${k}: ${typeof v === 'string' ? v.substring(0, 20) + '...' : v}`));
}
onSuccess();
} catch (error: unknown) {
console.error("OTP verification error:", error);
if (error instanceof Error) {
setError(error.message);
} else {
setError("OTP 인증 실패");
}
} finally {
setIsLoading(false);
}
};
const handleClose = () => {
onOpenChange(false);
onClose();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<div className="pt-8 text-center">
<h2 className="text-[1.3rem] font-semibold mb-6">OTP 인증</h2>
<div className="space-y-4">
<input
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-lg"
placeholder="OTP 코드를 입력하세요"
value={otp}
onChange={(e) => setOtp(e.target.value)}
disabled={isLoading}
/>
{error && (
<div className="text-red-500 text-sm text-center">
{error}
</div>
)}
<div className="flex space-x-2 pt-4">
<Button
onClick={handleSubmit}
disabled={isLoading || !otp.trim()}
className="flex-1"
>
{isLoading ? "인증 중..." : "인증"}
</Button>
<Button
variant="outline"
onClick={handleClose}
disabled={isLoading}
className="flex-1"
>
닫기
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,701 @@
"use client";
import { useState, useEffect } from "react";
import type { UserSaveData } from "@/entities/user";
import { ENDPOINT, BASE_URL } from "@/shared/config";
import { SignupClient } from "@/shared/api/signup-client";
import "./signup-modal.css";
interface SignupModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onClose: () => void;
onSuccess: () => void;
}
function SignupModal({ open, onClose, onSuccess }: SignupModalProps) {
const [currentStep, setCurrentStep] = useState<'agreement' | 'signup'>('agreement');
const [agreed, setAgreed] = useState(false);
const [formData, setFormData] = useState({
userId: "",
userName: "",
email: "",
password: "",
confirmPassword: "",
pswdHnt: "",
pswdAns: "",
mphn: "",
telno: "",
zip: "",
addr: "",
daddr: "",
laeSeCd: "",
ogdpSeCd: "",
expryYmd: "",
instSeCd: "",
dtlInstCd: "",
enoCd: "",
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [isLoading, setIsLoading] = useState(false);
const [isCheckingId, setIsCheckingId] = useState(false);
const [isCheckingEmail, setIsCheckingEmail] = useState(false);
const [idAvailable, setIdAvailable] = useState<boolean | null>(null);
const [emailAvailable, setEmailAvailable] = useState<boolean | null>(null);
// 모달이 닫힐 때 초기화
useEffect(() => {
if (!open) {
setCurrentStep('agreement');
setAgreed(false);
setFormData({
userId: "",
userName: "",
email: "",
password: "",
confirmPassword: "",
pswdHnt: "",
pswdAns: "",
mphn: "",
telno: "",
zip: "",
addr: "",
daddr: "",
laeSeCd: "",
ogdpSeCd: "",
expryYmd: "",
instSeCd: "",
dtlInstCd: "",
enoCd: "",
});
setErrors({});
setIdAvailable(null);
setEmailAvailable(null);
}
}, [open]);
const handleAgreementChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setAgreed(e.target.checked);
};
const handleNextStep = () => {
if (agreed) {
setCurrentStep('signup');
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
// 에러 초기화
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: "" }));
}
};
const validateForm = () => {
const newErrors: Record<string, string> = {};
// 필수 필드 검증
if (!formData.userId) newErrors.userId = "아이디를 입력해주세요.";
if (!formData.userName) newErrors.userName = "이름을 입력해주세요.";
if (!formData.password) newErrors.password = "비밀번호를 입력해주세요.";
if (!formData.confirmPassword) newErrors.confirmPassword = "비밀번호 확인을 입력해주세요.";
if (!formData.pswdHnt) newErrors.pswdHnt = "비밀번호 힌트를 입력해주세요.";
if (!formData.laeSeCd) newErrors.laeSeCd = "법인구분을 선택해주세요.";
if (!formData.ogdpSeCd) newErrors.ogdpSeCd = "기관구분을 선택해주세요.";
// 비밀번호 확인
if (formData.password && formData.confirmPassword && formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = "비밀번호가 일치하지 않습니다.";
}
// 비밀번호 정규식 검증
const passwordRegex = /^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/;
if (formData.password && !passwordRegex.test(formData.password)) {
newErrors.password = "비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.";
}
// 이메일 형식 검증
if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = "올바른 이메일 형식을 입력해주세요.";
}
// 중복 체크 검증
if (idAvailable === false) newErrors.userId = "이미 사용 중인 아이디입니다.";
if (emailAvailable === false) newErrors.email = "이미 사용 중인 이메일입니다.";
setErrors(newErrors);
const isValid = Object.keys(newErrors).length === 0;
console.log("폼 검증 결과:", { isValid, errors: newErrors, formData });
return isValid;
};
const checkUserIdDuplicate = async () => {
if (!formData.userId) {
setErrors(prev => ({ ...prev, userId: "아이디를 입력해주세요." }));
return;
}
setIsCheckingId(true);
try {
// SignupClient를 통해 중복 체크 API 호출
const signupClient = SignupClient.getInstance({
baseURL: BASE_URL,
});
const response = await signupClient.post<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/existsUserId`,
{ userId: formData.userId }
);
const exists = response.data;
setIdAvailable(!exists);
if (exists) {
setErrors(prev => ({ ...prev, userId: "이미 사용 중인 아이디입니다." }));
} else {
setErrors(prev => ({ ...prev, userId: "" }));
}
} catch (error) {
console.error("아이디 중복 체크 오류:", error);
setErrors(prev => ({ ...prev, userId: "아이디 중복 체크 중 오류가 발생했습니다." }));
} finally {
setIsCheckingId(false);
}
};
const checkEmailDuplicate = async () => {
if (!formData.email) {
setErrors(prev => ({ ...prev, email: "이메일을 입력해주세요." }));
return;
}
setIsCheckingEmail(true);
try {
// SignupClient를 통해 중복 체크 API 호출
const signupClient = SignupClient.getInstance({
baseURL: BASE_URL,
});
const response = await signupClient.post<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/exists`,
{ email: formData.email }
);
const exists = response.data;
setEmailAvailable(!exists);
if (exists) {
setErrors(prev => ({ ...prev, email: "이미 사용 중인 이메일입니다." }));
} else {
setErrors(prev => ({ ...prev, email: "" }));
}
} catch (error) {
console.error("이메일 중복 체크 오류:", error);
setErrors(prev => ({ ...prev, email: "이메일 중복 체크 중 오류가 발생했습니다." }));
} finally {
setIsCheckingEmail(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
console.log("회원가입 버튼 클릭됨");
if (!validateForm()) {
console.log("폼 검증 실패");
return;
}
setIsLoading(true);
try {
const signupData: UserSaveData = {
userId: formData.userId,
userName: formData.userName,
email: formData.email || undefined,
password: formData.password,
pswdHnt: formData.pswdHnt,
pswdAns: formData.pswdAns || undefined,
mphn: formData.mphn || undefined,
telno: formData.telno || undefined,
zip: formData.zip || undefined,
addr: formData.addr || undefined,
daddr: formData.daddr || undefined,
laeSeCd: formData.laeSeCd,
ogdpSeCd: formData.ogdpSeCd,
expryYmd: "",
instSeCd: formData.instSeCd || undefined,
dtlInstCd: formData.dtlInstCd || undefined,
enoCd: formData.enoCd || undefined,
};
// SignupClient를 통해 익명사용자 회원가입 API 호출
const signupClient = SignupClient.getInstance({
baseURL: BASE_URL
});
const response = await signupClient.post(
`${ENDPOINT.USER_SERVICE.USERS}/anonymouse/users/join`,
signupData
);
console.log("회원가입 성공:", response.data);
// 성공 시 모달 닫기 및 콜백 호출
onSuccess();
onClose();
// 폼 초기화
setFormData({
userId: "",
userName: "",
email: "",
password: "",
confirmPassword: "",
pswdHnt: "",
pswdAns: "",
mphn: "",
telno: "",
zip: "",
addr: "",
daddr: "",
laeSeCd: "",
ogdpSeCd: "",
expryYmd: "",
instSeCd: "",
dtlInstCd: "",
enoCd: "",
});
setErrors({});
setIdAvailable(null);
setEmailAvailable(null);
} catch (error: unknown) {
console.error("회원가입 오류:", error);
if (error instanceof Error) {
setErrors(prev => ({ ...prev, submit: error.message }));
} else {
setErrors(prev => ({ ...prev, submit: "회원가입에 실패했습니다." }));
}
} finally {
setIsLoading(false);
}
};
if (!open) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h2>{currentStep === 'agreement' ? '개인정보제공동의' : '회원가입'}</h2>
<button className="close-button" onClick={onClose}>×</button>
</div>
{currentStep === 'agreement' ? (
<div className="agreement-content">
<div className="agreement-text">
<h3>개인정보 수집 및 이용 동의</h3>
<p>회원가입을 위해 다음과 같이 개인정보를 수집 및 이용합니다.</p>
<h4>1. 수집하는 개인정보 항목</h4>
<ul>
<li>필수항목: 아이디, 이름, 비밀번호, 비밀번호 힌트, 법인구분, 기관구분</li>
<li>선택항목: 이메일, 휴대폰번호, 전화번호, 주소, 기관코드, 상세기관코드, 사업자등록번호</li>
</ul>
<h4>2. 개인정보의 수집 및 이용목적</h4>
<ul>
<li>회원가입 및 관리</li>
<li>서비스 제공 및 운영</li>
<li>고객상담 및 문의응답</li>
</ul>
<h4>3. 개인정보의 보유 및 이용기간</h4>
<p>회원탈퇴 시까지 (단, 관련 법령에 따라 보존이 필요한 경우 해당 기간까지)</p>
<h4>4. 동의 거부권 및 거부 시 불이익</h4>
<p>개인정보 수집 및 이용에 대한 동의를 거부할 수 있으며, 동의 거부 시 회원가입이 제한됩니다.</p>
</div>
<div className="agreement-checkbox">
<label>
<input
type="checkbox"
checked={agreed}
onChange={handleAgreementChange}
/>
<span>개인정보 수집 및 이용에 동의합니다.</span>
</label>
</div>
<div className="agreement-actions">
<button
type="button"
onClick={onClose}
className="cancel-button"
>
취소
</button>
<button
type="button"
onClick={handleNextStep}
className="next-button"
disabled={!agreed}
>
다음
</button>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="signup-form">
<div className="form-section">
<h3>기본 정보</h3>
<div className="form-row">
<div className="form-group">
<label htmlFor="userId">아이디 *</label>
<div className="input-with-button">
<input
type="text"
id="userId"
name="userId"
value={formData.userId}
onChange={handleInputChange}
placeholder="아이디를 입력하세요"
maxLength={12}
disabled={isLoading}
/>
<button
type="button"
onClick={checkUserIdDuplicate}
disabled={isCheckingId || isLoading || !formData.userId}
className="check-button"
>
{isCheckingId ? "확인중..." : "중복확인"}
</button>
</div>
{idAvailable === true && <span className="success-message">사용 가능한 아이디입니다.</span>}
{errors.userId && <span className="error-message">{errors.userId}</span>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="userName">이름 *</label>
<input
type="text"
id="userName"
name="userName"
value={formData.userName}
onChange={handleInputChange}
placeholder="이름을 입력하세요"
disabled={isLoading}
/>
{errors.userName && <span className="error-message">{errors.userName}</span>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="email">이메일</label>
<div className="input-with-button">
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleInputChange}
placeholder="이메일을 입력하세요"
disabled={isLoading}
/>
<button
type="button"
onClick={checkEmailDuplicate}
disabled={isCheckingEmail || isLoading || !formData.email}
className="check-button"
>
{isCheckingEmail ? "확인중..." : "중복확인"}
</button>
</div>
{emailAvailable === true && <span className="success-message">사용 가능한 이메일입니다.</span>}
{errors.email && <span className="error-message">{errors.email}</span>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="password">비밀번호 *</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleInputChange}
placeholder="8~20자, 영문/숫자/특수기호 포함"
maxLength={20}
disabled={isLoading}
/>
{errors.password && <span className="error-message">{errors.password}</span>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="confirmPassword">비밀번호 확인 *</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleInputChange}
placeholder="비밀번호를 다시 입력하세요"
maxLength={20}
disabled={isLoading}
/>
{errors.confirmPassword && <span className="error-message">{errors.confirmPassword}</span>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="pswdHnt">비밀번호 힌트 *</label>
<input
type="text"
id="pswdHnt"
name="pswdHnt"
value={formData.pswdHnt}
onChange={handleInputChange}
placeholder="비밀번호 힌트를 입력하세요"
maxLength={10}
disabled={isLoading}
/>
{errors.pswdHnt && <span className="error-message">{errors.pswdHnt}</span>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="pswdAns">비밀번호 답변</label>
<input
type="text"
id="pswdAns"
name="pswdAns"
value={formData.pswdAns}
onChange={handleInputChange}
placeholder="비밀번호 답변을 입력하세요"
maxLength={100}
disabled={isLoading}
/>
{errors.pswdAns && <span className="error-message">{errors.pswdAns}</span>}
</div>
</div>
</div>
<div className="form-section">
<h3>연락처 정보</h3>
<div className="form-row">
<div className="form-group">
<label htmlFor="mphn">휴대폰 번호</label>
<input
type="tel"
id="mphn"
name="mphn"
value={formData.mphn}
onChange={handleInputChange}
placeholder="휴대폰 번호를 입력하세요"
maxLength={11}
disabled={isLoading}
/>
{errors.mphn && <span className="error-message">{errors.mphn}</span>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="telno">전화번호</label>
<input
type="tel"
id="telno"
name="telno"
value={formData.telno}
onChange={handleInputChange}
placeholder="전화번호를 입력하세요"
maxLength={11}
disabled={isLoading}
/>
{errors.telno && <span className="error-message">{errors.telno}</span>}
</div>
</div>
</div>
<div className="form-section">
<h3>주소 정보</h3>
<div className="form-row">
<div className="form-group">
<label htmlFor="zip">우편번호</label>
<input
type="text"
id="zip"
name="zip"
value={formData.zip}
onChange={handleInputChange}
placeholder="우편번호를 입력하세요"
maxLength={5}
disabled={isLoading}
/>
{errors.zip && <span className="error-message">{errors.zip}</span>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="addr">주소</label>
<input
type="text"
id="addr"
name="addr"
value={formData.addr}
onChange={handleInputChange}
placeholder="주소를 입력하세요"
maxLength={200}
disabled={isLoading}
/>
{errors.addr && <span className="error-message">{errors.addr}</span>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="daddr">상세주소</label>
<input
type="text"
id="daddr"
name="daddr"
value={formData.daddr}
onChange={handleInputChange}
placeholder="상세주소를 입력하세요"
maxLength={200}
disabled={isLoading}
/>
{errors.daddr && <span className="error-message">{errors.daddr}</span>}
</div>
</div>
</div>
<div className="form-section">
<h3>기관 정보</h3>
<div className="form-row">
<div className="form-group">
<label htmlFor="laeSeCd">법인구분 *</label>
<select
id="laeSeCd"
name="laeSeCd"
value={formData.laeSeCd}
onChange={handleInputChange}
disabled={isLoading}
>
<option value="">법인구분을 선택하세요</option>
<option value="01">공공기관</option>
<option value="02">민간기업</option>
<option value="03">개인</option>
</select>
{errors.laeSeCd && <span className="error-message">{errors.laeSeCd}</span>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="ogdpSeCd">기관구분 *</label>
<select
id="ogdpSeCd"
name="ogdpSeCd"
value={formData.ogdpSeCd}
onChange={handleInputChange}
disabled={isLoading}
>
<option value="">기관구분을 선택하세요</option>
<option value="01">환경부</option>
<option value="02">지방자치단체</option>
<option value="03">공공기관</option>
<option value="04">민간기업</option>
</select>
{errors.ogdpSeCd && <span className="error-message">{errors.ogdpSeCd}</span>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="instSeCd">기관코드</label>
<input
type="text"
id="instSeCd"
name="instSeCd"
value={formData.instSeCd}
onChange={handleInputChange}
placeholder="기관코드를 입력하세요"
maxLength={10}
disabled={isLoading}
/>
{errors.instSeCd && <span className="error-message">{errors.instSeCd}</span>}
</div>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="dtlInstCd">상세기관코드</label>
<input
type="text"
id="dtlInstCd"
name="dtlInstCd"
value={formData.dtlInstCd}
onChange={handleInputChange}
placeholder="상세기관코드를 입력하세요"
maxLength={10}
disabled={isLoading}
/>
{errors.dtlInstCd && <span className="error-message">{errors.dtlInstCd}</span>}
</div>
</div>
</div>
{errors.submit && (
<div className="form-row">
<div className="error-message submit-error">{errors.submit}</div>
</div>
)}
<div className="form-actions">
<button
type="button"
onClick={() => setCurrentStep('agreement')}
className="back-button"
disabled={isLoading}
>
이전
</button>
<button
type="button"
onClick={onClose}
className="cancel-button"
disabled={isLoading}
>
취소
</button>
<button
type="submit"
className="submit-button"
disabled={isLoading}
>
{isLoading ? "가입 중..." : "회원가입"}
</button>
</div>
</form>
)}
</div>
</div>
);
}
export default SignupModal;
@@ -0,0 +1,198 @@
import { useEffect, useState } from "react";
import Image from "next/image";
import {
Button,
Dialog,
DialogContent,
} from "@common/components";
interface TwoFAModalProps {
onClose: () => void;
onSuccess: () => void;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export default function TwoFAModal({ onClose, onSuccess, open, onOpenChange }: TwoFAModalProps) {
const [qrImage, setQrImage] = useState("");
const [secret, setSecret] = useState("");
const [otp, setOtp] = useState("");
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isSetupLoading, setIsSetupLoading] = useState(true);
useEffect(() => {
const fetchQR = async () => {
try {
setIsSetupLoading(true);
setError("");
const tempToken = localStorage.getItem("tempToken");
if (!tempToken) {
setError("임시 토큰이 없습니다");
return;
}
// Proxy를 통해 2FA setup 처리
const response = await fetch(`/api/proxy/user-service/api/v1/auth/2fa/setup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tempToken }),
});
if (!response.ok) {
const errorData = await response.json();
let errorMsg = errorData.message || 'QR코드 발급 실패';
if (Array.isArray(errorData.errors) && errorData.errors.length > 0) {
errorMsg = errorData.errors.map((e: { message: string }) => e.message).join(', ');
}
throw new Error(errorMsg);
}
const data = await response.json();
console.log("2FA setup response:", data);
console.log("Response headers:", Object.fromEntries(response.headers.entries()));
// Proxy 응답에서 QR 데이터 추출
setQrImage(data.qrImage);
setSecret(data.secret);
} catch (error: unknown) {
console.error("2FA setup error:", error);
if (error instanceof Error) {
setError(error.message);
} else {
setError("QR코드 발급 실패");
}
} finally {
setIsSetupLoading(false);
}
};
if (open) {
fetchQR();
}
}, [open]);
const handleEnable2FA = async () => {
try {
setIsLoading(true);
setError("");
const tempToken = localStorage.getItem("tempToken");
if (!tempToken) {
setError("임시 토큰이 없습니다");
return;
}
// Proxy를 통해 2FA enable 처리
const response = await fetch(`/api/proxy/user-service/api/v1/auth/2fa/enable`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': tempToken,
},
body: JSON.stringify({
otpCode: otp,
tempToken: tempToken,
secret: secret,
}),
});
if (!response.ok) {
const errorData = await response.json();
let errorMsg = errorData.message || '2FA 등록 실패';
if (Array.isArray(errorData.errors) && errorData.errors.length > 0) {
errorMsg = errorData.errors.map((e: { message: string }) => e.message).join(', ');
}
throw new Error(errorMsg);
}
const data = await response.json();
console.log("2FA enable response:", data);
console.log("Response headers:", Object.fromEntries(response.headers.entries()));
console.log("Response data keys:", Object.keys(data));
// 2FA 설정 완료 후에는 토큰을 저장하지 않음 (실제 로그인은 OTP에서 처리)
console.log("2FA setup completed successfully");
onSuccess();
} catch (error: unknown) {
console.error("2FA enable error:", error);
if (error instanceof Error) {
setError(error.message);
} else {
setError("2FA 등록 실패");
}
} finally {
setIsLoading(false);
}
};
const handleClose = () => {
onOpenChange(false);
onClose();
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<div className="pt-8 text-center">
<h2 className="text-[1.3rem] font-semibold mb-6">2FA 등록</h2>
{isSetupLoading ? (
<div className="py-8">
<div className="text-gray-500">QR코드 로딩 중...</div>
</div>
) : (
<>
{qrImage && (
<div className="flex justify-center mb-4">
<Image
src={`data:image/png;base64,${qrImage}`}
alt="QR코드"
width={200}
height={200}
/>
</div>
)}
<div className="space-y-4">
<input
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-lg"
placeholder="OTP 코드를 입력하세요"
value={otp}
onChange={(e) => setOtp(e.target.value)}
disabled={isLoading}
/>
{error && (
<div className="text-red-500 text-sm text-center">
{error}
</div>
)}
<div className="flex space-x-2 pt-4">
<Button
onClick={handleEnable2FA}
disabled={isLoading || !otp.trim()}
className="flex-1"
>
{isLoading ? "2FA 활성화 중..." : "2FA 활성화"}
</Button>
<Button
variant="outline"
onClick={handleClose}
disabled={isLoading}
className="flex-1"
>
닫기
</Button>
</div>
</div>
</>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,901 @@
"use client";
import React, { useState, useEffect } from "react";
import { SignupClient } from "@/shared/api/signup-client";
import { BASE_URL, ENDPOINT } from "@/shared/config";
import "./user-signup-modal.css";
interface UserSignupModalProps {
open: boolean;
onClose: () => void;
onSuccess: () => void;
}
interface UserSignupData {
userId: string;
userName: string;
email?: string;
password: string;
pswdHnt: string;
pswdAns?: string;
mphn?: string;
telno?: string;
zip?: string;
addr?: string;
daddr?: string;
laeSeCd: string;
ogdpSeCd: string;
instSeCd?: string;
dtlInstCd?: string;
enoCd?: string;
}
// 익명 사용자용 CodeSelect 컴포넌트
interface AnonymousCodeSelectProps {
upCd: string;
value: string;
onValueChange: (value: string) => void;
disabled?: boolean;
placeholder?: string;
}
function AnonymousCodeSelect({
upCd,
value,
onValueChange,
disabled,
placeholder = "선택",
}: AnonymousCodeSelectProps) {
const [codes, setCodes] = useState<
Array<{ lwpoCd: string; lwpoCdNm: string }>
>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const fetchCodes = async () => {
if (!upCd) return;
setIsLoading(true);
try {
const signupClient = SignupClient.getInstance({
baseURL: BASE_URL,
});
const response = await signupClient.get(
`${ENDPOINT.CMS_SERVICE.CODE}/${upCd}`,
);
if (response.data && Array.isArray(response.data)) {
setCodes(response.data);
} else {
setCodes([]);
}
} catch (error) {
console.error(`공통 코드 로드 실패 (${upCd}):`, error);
setCodes([]);
} finally {
setIsLoading(false);
}
};
fetchCodes();
}, [upCd]);
return (
<select
value={value}
onChange={(e) => onValueChange(e.target.value)}
disabled={disabled || isLoading}
className="user-signup-select"
>
<option value="">{isLoading ? "로딩 중..." : placeholder}</option>
{codes.map((code) => (
<option key={code.lwpoCd} value={code.lwpoCd}>
{code.lwpoCdNm}
</option>
))}
</select>
);
}
function UserSignupModal({ open, onClose, onSuccess }: UserSignupModalProps) {
const [currentStep, setCurrentStep] = useState<"agreement" | "signup">(
"agreement",
);
const [agreed, setAgreed] = useState(false);
const [formData, setFormData] = useState({
userId: "",
userName: "",
email: "",
password: "",
confirmPassword: "",
pswdHnt: "",
pswdAns: "",
mphn: "",
telno: "",
zip: "",
addr: "",
daddr: "",
laeSeCd: "",
ogdpSeCd: "",
instSeCd: "",
dtlInstCd: "",
enoCd: "",
});
const [errors, setErrors] = useState<Record<string, string>>({});
const [isLoading, setIsLoading] = useState(false);
const [isCheckingId, setIsCheckingId] = useState(false);
const [isCheckingEmail, setIsCheckingEmail] = useState(false);
const [idAvailable, setIdAvailable] = useState<boolean | null>(null);
const [emailAvailable, setEmailAvailable] = useState<boolean | null>(null);
// 모달이 닫힐 때 초기화
useEffect(() => {
if (!open) {
setCurrentStep("agreement");
setAgreed(false);
setFormData({
userId: "",
userName: "",
email: "",
password: "",
confirmPassword: "",
pswdHnt: "",
pswdAns: "",
mphn: "",
telno: "",
zip: "",
addr: "",
daddr: "",
laeSeCd: "",
ogdpSeCd: "",
instSeCd: "",
dtlInstCd: "",
enoCd: "",
});
setErrors({});
setIdAvailable(null);
setEmailAvailable(null);
}
}, [open]);
const handleAgreementChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setAgreed(e.target.checked);
};
const handleNextStep = () => {
if (agreed) {
setCurrentStep("signup");
}
};
const handleInputChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>,
) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
// 에러 초기화
if (errors[name]) {
setErrors((prev) => ({ ...prev, [name]: "" }));
}
};
const validateForm = () => {
const newErrors: Record<string, string> = {};
console.log("폼 검증 시작:", formData);
// 필수 필드 검증
if (!formData.userId) newErrors.userId = "아이디를 입력해주세요.";
if (!formData.userName) newErrors.userName = "이름을 입력해주세요.";
if (!formData.password) newErrors.password = "비밀번호를 입력해주세요.";
if (!formData.confirmPassword)
newErrors.confirmPassword = "비밀번호 확인을 입력해주세요.";
if (!formData.pswdHnt) newErrors.pswdHnt = "비밀번호 힌트를 선택해주세요.";
if (!formData.laeSeCd) newErrors.laeSeCd = "법인구분을 선택해주세요.";
if (!formData.ogdpSeCd) newErrors.ogdpSeCd = "기관구분을 선택해주세요.";
// 비밀번호 확인
if (
formData.password &&
formData.confirmPassword &&
formData.password !== formData.confirmPassword
) {
newErrors.confirmPassword = "비밀번호가 일치하지 않습니다.";
}
// 비밀번호 정규식 검증
const passwordRegex =
/^(?=.*[a-zA-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,20}$/;
if (formData.password && !passwordRegex.test(formData.password)) {
newErrors.password =
"비밀번호는 8~20자, 영문/숫자/특수기호를 모두 포함해야 합니다.";
}
// 이메일 형식 검증
if (formData.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = "올바른 이메일 형식을 입력해주세요.";
}
// 중복 체크 검증
if (idAvailable === false)
newErrors.userId = "이미 사용 중인 아이디입니다.";
if (emailAvailable === false)
newErrors.email = "이미 사용 중인 이메일입니다.";
setErrors(newErrors);
const isValid = Object.keys(newErrors).length === 0;
console.log("폼 검증 결과:", { isValid, errors: newErrors, formData });
return isValid;
};
const checkUserIdDuplicate = async () => {
if (!formData.userId) {
setErrors((prev) => ({ ...prev, userId: "아이디를 입력해주세요." }));
return;
}
setIsCheckingId(true);
try {
const signupClient = SignupClient.getInstance({
baseURL: BASE_URL,
});
const response = await signupClient.post<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/existsUserId`,
{ userId: formData.userId },
);
const exists = response.data;
setIdAvailable(!exists);
if (exists) {
setErrors((prev) => ({
...prev,
userId: "이미 사용 중인 아이디입니다.",
}));
} else {
setErrors((prev) => ({ ...prev, userId: "" }));
}
} catch (error) {
console.error("아이디 중복 체크 오류:", error);
setErrors((prev) => ({
...prev,
userId: "아이디 중복 체크 중 오류가 발생했습니다.",
}));
} finally {
setIsCheckingId(false);
}
};
const checkEmailDuplicate = async () => {
if (!formData.email) {
setErrors((prev) => ({ ...prev, email: "이메일을 입력해주세요." }));
return;
}
setIsCheckingEmail(true);
try {
const signupClient = SignupClient.getInstance({
baseURL: BASE_URL,
});
const response = await signupClient.post<boolean>(
`${ENDPOINT.USER_SERVICE.USERS}/exists`,
{ email: formData.email },
);
const exists = response.data;
setEmailAvailable(!exists);
if (exists) {
setErrors((prev) => ({
...prev,
email: "이미 사용 중인 이메일입니다.",
}));
} else {
setErrors((prev) => ({ ...prev, email: "" }));
}
} catch (error) {
console.error("이메일 중복 체크 오류:", error);
setErrors((prev) => ({
...prev,
email: "이메일 중복 체크 중 오류가 발생했습니다.",
}));
} finally {
setIsCheckingEmail(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
console.log("회원가입 버튼 클릭됨 - handleSubmit");
console.log("현재 폼 데이터:", formData);
if (!validateForm()) {
console.log("폼 검증 실패");
return;
}
console.log("폼 검증 성공, 회원가입 API 호출 시작");
setIsLoading(true);
try {
const signupData: UserSignupData = {
userId: formData.userId,
userName: formData.userName,
email: formData.email || undefined,
password: formData.password,
pswdHnt: formData.pswdHnt,
pswdAns: formData.pswdAns || undefined,
mphn: formData.mphn || undefined,
telno: formData.telno || undefined,
zip: formData.zip || undefined,
addr: formData.addr || undefined,
daddr: formData.daddr || undefined,
laeSeCd: formData.laeSeCd,
ogdpSeCd: formData.ogdpSeCd,
instSeCd: formData.instSeCd || undefined,
dtlInstCd: formData.dtlInstCd || undefined,
enoCd: formData.enoCd || undefined,
};
// SignupClient를 통해 익명사용자 회원가입 API 호출
console.log("SignupClient 생성 및 API 호출:", {
baseURL: BASE_URL,
endpoint: ENDPOINT.USER_SERVICE.USER_JOIN,
data: signupData,
});
const signupClient = SignupClient.getInstance({
baseURL: BASE_URL,
});
const response = await signupClient.post(
ENDPOINT.USER_SERVICE.USER_JOIN,
signupData,
);
console.log("회원가입 성공:", response.data);
// 성공 시 모달 닫기 및 콜백 호출
onSuccess();
onClose();
// 폼 초기화
setFormData({
userId: "",
userName: "",
email: "",
password: "",
confirmPassword: "",
pswdHnt: "",
pswdAns: "",
mphn: "",
telno: "",
zip: "",
addr: "",
daddr: "",
laeSeCd: "",
ogdpSeCd: "",
instSeCd: "",
dtlInstCd: "",
enoCd: "",
});
setErrors({});
setIdAvailable(null);
setEmailAvailable(null);
} catch (error: unknown) {
console.error("회원가입 오류:", error);
if (error instanceof Error) {
setErrors((prev) => ({ ...prev, submit: error.message }));
} else {
setErrors((prev) => ({ ...prev, submit: "회원가입에 실패했습니다." }));
}
} finally {
setIsLoading(false);
}
};
if (!open) return null;
return (
<div className="user-signup-modal-overlay">
<div className="user-signup-modal-content">
<div className="user-signup-modal-header">
<h2>
{currentStep === "agreement" ? "개인정보제공동의" : "회원가입"}
</h2>
<button className="user-signup-close-button" onClick={onClose}>
×
</button>
</div>
{currentStep === "agreement" ? (
<div className="user-signup-agreement-content">
<div className="user-signup-agreement-text">
<h3>개인정보 수집 및 이용 동의</h3>
<p>회원가입을 위해 다음과 같이 개인정보를 수집 및 이용합니다.</p>
<h4>1. 수집하는 개인정보 항목</h4>
<ul>
<li>
필수항목: 아이디, 이름, 비밀번호, 비밀번호 힌트, 법인구분,
기관구분
</li>
<li>
선택항목: 이메일, 휴대폰번호, 전화번호, 주소, 기관코드,
상세기관코드, 사업자등록번호
</li>
</ul>
<h4>2. 개인정보의 수집 및 이용목적</h4>
<ul>
<li>회원가입 및 관리</li>
<li>서비스 제공 및 운영</li>
<li>고객상담 및 문의응답</li>
</ul>
<h4>3. 개인정보의 보유 및 이용기간</h4>
<p>
회원탈퇴 시까지 (단, 관련 법령에 따라 보존이 필요한 경우 해당
기간까지)
</p>
<h4>4. 동의 거부권 및 거부 시 불이익</h4>
<p>
개인정보 수집 및 이용에 대한 동의를 거부할 수 있으며, 동의 거부
시 회원가입이 제한됩니다.
</p>
</div>
<div className="user-signup-agreement-checkbox">
<label>
<input
type="checkbox"
checked={agreed}
onChange={handleAgreementChange}
/>
<span>개인정보 수집 및 이용에 동의합니다.</span>
</label>
</div>
<div className="user-signup-agreement-actions">
<button
type="button"
onClick={onClose}
className="user-signup-cancel-button"
>
취소
</button>
<button
type="button"
onClick={handleNextStep}
className="user-signup-next-button"
disabled={!agreed}
>
다음
</button>
</div>
</div>
) : (
<form onSubmit={handleSubmit} className="user-signup-form">
<div className="user-signup-form-section">
<h3>기본 정보</h3>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="userId">아이디 *</label>
<div className="user-signup-input-with-button">
<input
type="text"
id="userId"
name="userId"
value={formData.userId}
onChange={handleInputChange}
placeholder="아이디를 입력하세요"
maxLength={12}
disabled={isLoading}
/>
<button
type="button"
onClick={checkUserIdDuplicate}
disabled={isCheckingId || isLoading || !formData.userId}
className="user-signup-check-button"
>
{isCheckingId ? "확인중..." : "중복확인"}
</button>
</div>
{idAvailable === true && (
<span className="user-signup-success-message">
사용 가능한 아이디입니다.
</span>
)}
{errors.userId && (
<span className="user-signup-error-message">
{errors.userId}
</span>
)}
</div>
</div>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="userName">이름 *</label>
<input
type="text"
id="userName"
name="userName"
value={formData.userName}
onChange={handleInputChange}
placeholder="이름을 입력하세요"
disabled={isLoading}
/>
{errors.userName && (
<span className="user-signup-error-message">
{errors.userName}
</span>
)}
</div>
</div>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="email">이메일</label>
<div className="user-signup-input-with-button">
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleInputChange}
placeholder="이메일을 입력하세요"
disabled={isLoading}
/>
<button
type="button"
onClick={checkEmailDuplicate}
disabled={isCheckingEmail || isLoading || !formData.email}
className="user-signup-check-button"
>
{isCheckingEmail ? "확인중..." : "중복확인"}
</button>
</div>
{emailAvailable === true && (
<span className="user-signup-success-message">
사용 가능한 이메일입니다.
</span>
)}
{errors.email && (
<span className="user-signup-error-message">
{errors.email}
</span>
)}
</div>
</div>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="password">비밀번호 *</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleInputChange}
placeholder="8~20자, 영문/숫자/특수기호 포함"
maxLength={20}
disabled={isLoading}
/>
{errors.password && (
<span className="user-signup-error-message">
{errors.password}
</span>
)}
</div>
</div>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="confirmPassword">비밀번호 확인 *</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleInputChange}
placeholder="비밀번호를 다시 입력하세요"
maxLength={20}
disabled={isLoading}
/>
{errors.confirmPassword && (
<span className="user-signup-error-message">
{errors.confirmPassword}
</span>
)}
</div>
</div>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="pswdHnt">비밀번호 힌트 *</label>
<AnonymousCodeSelect
upCd="PSWDHNT"
value={formData.pswdHnt}
onValueChange={(value: string) =>
setFormData((prev) => ({ ...prev, pswdHnt: value }))
}
disabled={isLoading}
placeholder="비밀번호 힌트를 선택하세요"
/>
{errors.pswdHnt && (
<span className="user-signup-error-message">
{errors.pswdHnt}
</span>
)}
</div>
</div>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="pswdAns">비밀번호 답변</label>
<input
type="text"
id="pswdAns"
name="pswdAns"
value={formData.pswdAns}
onChange={handleInputChange}
placeholder="비밀번호 답변을 입력하세요"
maxLength={100}
disabled={isLoading}
/>
{errors.pswdAns && (
<span className="user-signup-error-message">
{errors.pswdAns}
</span>
)}
</div>
</div>
</div>
<div className="user-signup-form-section">
<h3>연락처 정보</h3>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="mphn">휴대폰 번호</label>
<input
type="tel"
id="mphn"
name="mphn"
value={formData.mphn}
onChange={handleInputChange}
placeholder="휴대폰 번호를 입력하세요"
maxLength={11}
disabled={isLoading}
/>
{errors.mphn && (
<span className="user-signup-error-message">
{errors.mphn}
</span>
)}
</div>
</div>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="telno">전화번호</label>
<input
type="tel"
id="telno"
name="telno"
value={formData.telno}
onChange={handleInputChange}
placeholder="전화번호를 입력하세요"
maxLength={11}
disabled={isLoading}
/>
{errors.telno && (
<span className="user-signup-error-message">
{errors.telno}
</span>
)}
</div>
</div>
</div>
<div className="user-signup-form-section">
<h3>주소 정보</h3>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="zip">우편번호</label>
<input
type="text"
id="zip"
name="zip"
value={formData.zip}
onChange={handleInputChange}
placeholder="우편번호를 입력하세요"
maxLength={5}
disabled={isLoading}
/>
{errors.zip && (
<span className="user-signup-error-message">
{errors.zip}
</span>
)}
</div>
</div>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="addr">주소</label>
<input
type="text"
id="addr"
name="addr"
value={formData.addr}
onChange={handleInputChange}
placeholder="주소를 입력하세요"
maxLength={200}
disabled={isLoading}
/>
{errors.addr && (
<span className="user-signup-error-message">
{errors.addr}
</span>
)}
</div>
</div>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="daddr">상세주소</label>
<input
type="text"
id="daddr"
name="daddr"
value={formData.daddr}
onChange={handleInputChange}
placeholder="상세주소를 입력하세요"
maxLength={200}
disabled={isLoading}
/>
{errors.daddr && (
<span className="user-signup-error-message">
{errors.daddr}
</span>
)}
</div>
</div>
</div>
<div className="user-signup-form-section">
<h3>기관 정보</h3>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="laeSeCd">법인구분 *</label>
<AnonymousCodeSelect
upCd="LAESECD"
value={formData.laeSeCd}
onValueChange={(value: string) =>
setFormData((prev) => ({ ...prev, laeSeCd: value }))
}
disabled={isLoading}
placeholder="법인구분을 선택하세요"
/>
{errors.laeSeCd && (
<span className="user-signup-error-message">
{errors.laeSeCd}
</span>
)}
</div>
</div>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="ogdpSeCd">기관구분 *</label>
<AnonymousCodeSelect
upCd="OGDPSECD"
value={formData.ogdpSeCd}
onValueChange={(value: string) =>
setFormData((prev) => ({ ...prev, ogdpSeCd: value }))
}
disabled={isLoading}
placeholder="기관구분을 선택하세요"
/>
{errors.ogdpSeCd && (
<span className="user-signup-error-message">
{errors.ogdpSeCd}
</span>
)}
</div>
</div>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="instSeCd">기관코드</label>
<input
type="text"
id="instSeCd"
name="instSeCd"
value={formData.instSeCd}
onChange={handleInputChange}
placeholder="기관코드를 입력하세요"
maxLength={10}
disabled={isLoading}
/>
{errors.instSeCd && (
<span className="user-signup-error-message">
{errors.instSeCd}
</span>
)}
</div>
</div>
<div className="user-signup-form-row">
<div className="user-signup-form-group">
<label htmlFor="dtlInstCd">상세기관코드</label>
<input
type="text"
id="dtlInstCd"
name="dtlInstCd"
value={formData.dtlInstCd}
onChange={handleInputChange}
placeholder="상세기관코드를 입력하세요"
maxLength={10}
disabled={isLoading}
/>
{errors.dtlInstCd && (
<span className="user-signup-error-message">
{errors.dtlInstCd}
</span>
)}
</div>
</div>
</div>
{errors.submit && (
<div className="user-signup-form-row">
<div className="user-signup-error-message user-signup-submit-error">
{errors.submit}
</div>
</div>
)}
<div className="user-signup-form-actions">
<button
type="button"
onClick={() => setCurrentStep("agreement")}
className="user-signup-back-button"
disabled={isLoading}
>
이전
</button>
<button
type="button"
onClick={onClose}
className="user-signup-cancel-button"
disabled={isLoading}
>
취소
</button>
<button
type="submit"
className="user-signup-submit-button"
disabled={isLoading}
onClick={(e) => {
e.preventDefault();
console.log("회원가입 버튼 클릭됨 - onClick");
handleSubmit(e);
}}
>
{isLoading ? "가입 중..." : "회원가입"}
</button>
</div>
</form>
)}
</div>
</div>
);
}
export default UserSignupModal;
@@ -0,0 +1,321 @@
"use client";
import { useState, useEffect } from "react";
import Image from "next/image";
import { images } from "@common/assets";
import { LoginApi } from "@/entities/auth";
import { ENDPOINT } from "@/shared/config";
import OtpModal from "./OtpModal";
import TwoFAModal from "./TwoFAModal";
import UserSignupModal from "./UserSignupModal";
import "./login.css";
import { useRouter } from "next/navigation";
function LoginPage() {
const [isOtpEnabled, setIsOtpEnabled] = useState(false);
const [showOtpModal, setShowOtpModal] = useState(false);
const [showTwoFAModal, setShowTwoFAModal] = useState(false);
const [showSignupModal, setShowSignupModal] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [successMessage, setSuccessMessage] = useState("");
const router = useRouter();
// 모달 상태 디버깅
useEffect(() => {
console.log("Modal states - OTP:", showOtpModal, "2FA:", showTwoFAModal);
}, [showOtpModal, showTwoFAModal]);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setError("");
setSuccessMessage("");
try {
const formData = new FormData(e.target as HTMLFormElement);
const userId = formData.get("id") as string;
const password = formData.get("password") as string;
console.log("Login attempt - OTP enabled:", isOtpEnabled);
console.log("User ID:", userId);
if (isOtpEnabled) {
// 2FA 로그인 - Proxy를 통해 처리 (2FA 전용 엔드포인트 사용)
console.log("Starting 2FA login...");
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN_2FA}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId,
password,
provider: 'password'
}),
});
if (!response.ok) {
const errorData = await response.json();
let errorMsg = errorData.message || '로그인 실패';
if (Array.isArray(errorData.errors) && errorData.errors.length > 0) {
errorMsg = errorData.errors.map((e: { message: string }) => e.message).join(', ');
}
throw new Error(errorMsg);
}
const data = await response.json();
console.log("2FA login response:", data);
console.log("Response headers:", Object.fromEntries(response.headers.entries()));
// 2FA 로그인 응답에는 tempToken과 registered2FA만 포함됨
if (!data.tempToken) {
throw new Error("Missing tempToken in 2FA login response");
}
// 임시 토큰을 localStorage에 저장 (OTP 인증용)
localStorage.setItem('tempToken', data.tempToken);
console.log("tempToken saved:", data.tempToken);
if (data.registered2FA) {
console.log("User has 2FA registered, showing OTP modal");
setShowOtpModal(true);
} else {
console.log("User doesn't have 2FA registered, showing 2FA setup modal");
setShowTwoFAModal(true);
}
} else {
// 기본 로그인 - proxy를 통해 처리
console.log("Starting basic login...");
await LoginApi.login({ userId, password });
window.location.href = "/dashboard";
}
} catch (error: unknown) {
console.error("Login error:", error);
// 에러 처리
if (error instanceof Error) {
setError(error.message);
} else {
setError("로그인에 실패했습니다. 아이디와 비밀번호를 확인해주세요.");
}
} finally {
setIsLoading(false);
}
};
const handleOtpSuccess = () => {
console.log("OTP authentication successful, redirecting to dashboard");
localStorage.removeItem("tempToken"); // 임시 토큰 제거
router.push("/dashboard");
};
const handleTwoFASuccess = () => {
console.log("2FA setup successful, redirecting to dashboard");
localStorage.removeItem("tempToken"); // 임시 토큰 제거
router.push("/dashboard");
};
const handleOtpClose = () => {
console.log("OTP modal closed");
setShowOtpModal(false);
localStorage.removeItem("tempToken");
};
const handleTwoFAClose = () => {
console.log("2FA modal closed");
setShowTwoFAModal(false);
localStorage.removeItem("tempToken");
};
const handleSignupSuccess = () => {
console.log("회원가입 성공");
setError(""); // 에러 메시지 초기화
setSuccessMessage("회원가입이 완료되었습니다. 로그인해주세요.");
};
const handleSignupClose = () => {
console.log("회원가입 모달 닫기");
setShowSignupModal(false);
setSuccessMessage(""); // 성공 메시지 초기화
};
return (
<>
<div className="login fx">
<div className="left_box fx">
<div className="text">
<h1>
유역하수도 통합플랫폼
<br />
<span>통합관리시스템</span>
</h1>
<p>유역하수도 통합플랫폼에 오신 것을 환영합니다.</p>
</div>
<div className="img">
<Image src={images.logo} width={229} height={46} alt="logo" />
</div>
</div>
<form
id="loginForm"
name="loginForm"
method="post"
onSubmit={handleSubmit}
>
<div className="login_box fx">
<ul className="fx">
<li>
<h2>ID</h2>
<div className="input_st st1">
<i className="diveicon di-account-box" />
<input
type="text"
className="id"
title="아이디를 입력하세요."
id="id"
name="id"
maxLength={12}
disabled={isLoading}
/>
</div>
</li>
<li>
<h2>PASSWORD</h2>
<div className="input_st st2">
<i className="diveicon di-key" />
<input
type="password"
className="input_style"
maxLength={25}
title="비밀번호를 입력하세요."
id="password"
name="password"
disabled={isLoading}
/>
</div>
</li>
</ul>
<div className="bottom_box fx">
<div className="checks fx">
<input
type="checkbox"
name="checkId"
title="로그인ID 저장여부"
id="checkId"
disabled={isLoading}
/>
<label htmlFor="checkId">아이디저장</label>
</div>
<div className="checks fx">
<input
type="checkbox"
name="checkTwoFA"
title="OTP 인증 여부"
id="checkTwoFA"
checked={isOtpEnabled}
onChange={(e) => setIsOtpEnabled(e.target.checked)}
disabled={isLoading}
/>
<label htmlFor="checkTwoFA">OTP 인증</label>
</div>
</div>
<button
type="submit"
className="login_btn fx"
disabled={isLoading}
>
<span>{isLoading ? "로그인 중..." : "LOGIN"}</span>
</button>
{error && (
<div className="error-message" style={{ color: "red", textAlign: "center", marginTop: "10px" }}>
{error}
</div>
)}
{successMessage && (
<div className="success-message" style={{ color: "green", textAlign: "center", marginTop: "10px" }}>
{successMessage}
</div>
)}
<div className="signup-link" style={{ textAlign: "center", marginTop: "15px" }}>
<span style={{ color: "#666", fontSize: "14px" }}>계정이 없으신가요? </span>
<button
type="button"
onClick={() => setShowSignupModal(true)}
style={{
background: "none",
border: "none",
color: "#3b82f6",
textDecoration: "underline",
cursor: "pointer",
fontSize: "14px"
}}
>
회원가입
</button>
</div>
<div className="footer">
<div className="copy fx">
<p>copyright © 2023 KECO. All rights reserved.</p>
</div>
<div className="fot_btn fx">
<a
href="https://keco.or.kr/web/lay1/S1T6C1348/contents.do"
target="_blank"
>
개인정보처리방침
</a>
<a
href="https://keco.or.kr/web/lay1/S1T6C1349/contents.do"
target="_blank"
>
저작권정책안내
</a>
<a
href="https://keco.or.kr/web/lay1/S1T6C1350/contents.do"
target="_blank"
>
이메일무단수집거부
</a>
</div>
<p className="addr fx">
<span>
주소 : (22689) 인천광역시 서구 환경로 42(경서동
종합환경연구단지)
</span>
<span>
전화 : (시스템) 032-590-4321, 3752, 4314 / (통 계) 032-590-3723,
3722
</span>
<span>팩스 :032-590-3729</span>
</p>
</div>
</div>
</form>
</div>
{/* OTP 인증 모달 */}
<OtpModal
onClose={handleOtpClose}
onSuccess={handleOtpSuccess}
open={showOtpModal}
onOpenChange={setShowOtpModal}
/>
{/* 2FA 등록 모달 */}
<TwoFAModal
onClose={handleTwoFAClose}
onSuccess={handleTwoFASuccess}
open={showTwoFAModal}
onOpenChange={setShowTwoFAModal}
/>
{/* 회원가입 모달 */}
<UserSignupModal
onClose={handleSignupClose}
onSuccess={handleSignupSuccess}
open={showSignupModal}
/>
</>
);
}
export { LoginPage };
+311
View File
@@ -0,0 +1,311 @@
/**
* OLYM Cascading Style Sheets
* File Name : login.css
* Description : 로그인 스타일시트
* Author : Web business Team / Choi Hyun Mi
* Email : desingtj@olym.co.kr
* Date : 2023.07.20
* Update : 2023.07.26
* Copyright(c) 2023 OLYM Communications. All Rights Reserved.
*/
html,
body {
background: #fff;
}
.fx {
display: flex;
-ms-display: flex;
-webkit-display: flex;
box-sizing: border-box;
}
.login {
width: 100%;
height: 100vh;
}
.login .left_box {
width: 100%;
background: url(../../../../../public/images/bg_login.jpg) no-repeat
center/cover;
flex-direction: column;
justify-content: space-between;
padding: 7% 5% 5% 5%;
}
.login .left_box .text h1 {
font-size: 53px;
color: #fff;
}
.login .left_box .text h1 span {
font-weight: 800;
}
.login .left_box .text p {
margin-top: 40px;
font-size: 18px;
color: #fff;
}
.login .login_box {
width: 100%;
flex-direction: column;
justify-content: center;
padding: 4% 12% 4% 6%;
}
.login .login_box > ul {
flex-direction: column;
gap: 40px;
}
.login .login_box > ul > li h2 {
font-size: 22px;
font-weight: 600;
color: #2640a5;
margin-bottom: 14px;
}
.login .login_box > ul > li .input_st {
position: relative;
}
.login .login_box > ul > li .input_st .diveicon {
position: absolute;
top: 50%;
left: 28px;
transform: translateY(-50%);
font-family: "Xeicon";
font-size: 25px;
color: #003288;
}
.login .login_box > ul > li input {
width: 100%;
padding: 20px 20px 20px 70px;
background: #f7f7f7;
box-sizing: border-box;
border: none;
border-radius: 5px;
font-size: 18px;
font-weight: 700;
color: #000;
height: auto;
}
.login .login_box > ul > li input:focus {
outline: 1px solid #3c5fe5;
}
.login .login_box > ul > li input::placeholder {
color: #afabab;
font-weight: 500;
}
.login .login_box .bottom_box {
justify-content: space-between;
gap: 20px;
padding: 50px 0 45px 0;
}
.login .login_box .bottom_box .checks {
display: flex;
align-items: center;
gap: 8px;
}
.login .login_box .bottom_box .checks input[type="checkbox"] {
width: 18px;
height: 18px;
}
.login .login_box .bottom_box .checks label {
font-size: 14px;
}
.login .login_box .bottom_box .pw_message {
align-items: center;
gap: 5px;
color: #636363;
font-weight: 500;
border-bottom: 1px solid #ccc;
padding-bottom: 5px;
}
.login .login_box .bottom_box .pw_message i {
color: #327af5;
font-size: 20px;
}
.login .login_box .login_btn {
position: relative;
border-radius: 5px;
padding: 24px 15px;
background: linear-gradient(
to left,
#1d3969 0%,
#264078 10%,
#172558 40%,
#162254 50%,
#111c43 60%,
#111c42 100%
);
overflow: hidden;
justify-content: center;
align-items: center;
gap: 10px;
color: #fff;
cursor: pointer;
}
.login .login_box .login_btn span {
font-family: "Montserrat";
font-size: 19px;
font-weight: 700;
position: relative;
}
.login .login_box .login_btn i {
font-size: 22px;
transform: translateY(-2px);
}
.login .login_box .login_btn:before {
display: block;
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
background: linear-gradient(
to right,
#1d3969 0%,
#264078 10%,
#172558 40%,
#162254 50%,
#111c43 60%,
#111c42 100%
);
transition: opacity 0.5s;
}
.login .login_box .login_btn:hover::before {
opacity: 1;
}
.login .footer .copy {
margin-top: 36px;
justify-content: space-between;
align-items: center;
gap: 3%;
}
.login .footer .copy p {
font-size: 13px;
color: #d4d4d4;
width: 44%;
text-align: center;
}
.login .footer .copy::before,
.login .login_box .copy::after {
content: "";
width: 20%;
height: 1px;
background: #d4d4d4;
}
.login .footer .fot_btn {
gap: 20px;
margin: 30px 0 10px 0;
}
.login .footer .fot_btn a {
color: #333;
font-weight: 700;
font-size: 13px;
}
.login .footer .fot_btn a:hover {
color: #003288;
}
.login .footer .addr {
color: #666;
font-size: 13px;
gap: 3px 20px;
flex-wrap: wrap;
}
/* 비밀번호 변경 팝업창 */
.login .modal .modal_container {
width: 750px;
}
.login .pw_desc {
padding: 10px 10px 30px 10px;
color: #666;
}
.login .pw_desc p {
font-size: 1.25rem;
color: #000;
font-weight: 700;
margin-bottom: 10px;
}
.login .pw_desc span {
color: #f60;
font-weight: 700;
}
.login .modal .form_write {
border-top: 1px solid #ddd;
border-bottom: 1px solid #ddd;
padding: 20px 50px;
}
@media (max-width: 1024px) {
.login {
flex-direction: column;
height: auto;
}
.login .left_box {
padding: 30px;
flex-direction: column-reverse;
gap: 20px;
}
.login .left_box .text h1 {
font-size: 30px;
line-height: 1.2em;
}
.login .left_box .text p {
margin-top: 20px;
font-size: 14px;
}
.login .left_box .img img {
height: 40px;
}
.login .login_box {
padding: 30px;
}
.login .login_box > ul {
gap: 20px;
}
.login .login_box > ul > li h2 {
margin-bottom: 7px;
}
.login .login_box > ul > li .input_st::after {
left: 15px;
font-size: 20px;
}
.login .login_box > ul > li input {
padding: 15px 10px 15px 50px;
font-size: 16px;
}
.login .login_box .bottom_box {
flex-direction: column;
align-items: flex-start;
padding: 30px 0;
}
.login .login_box .bottom_box .id_save label {
font-size: 14px;
}
.login .login_box .login_btn {
padding: 15px;
}
.login .login_box .login_btn span {
font-size: 16px;
}
.login .login_box .login_btn i {
font-size: 18px;
transform: translateY(-1px);
}
.login .login_box .copy p {
width: 100%;
}
.login .login_box .copy::before,
.login .login_box .copy::after {
display: none;
}
.login .footer .addr {
font-size: 11px;
gap: 3px 10px;
}
.login .modal .modal_container {
width: 90%;
}
.login .pw_desc {
padding: 0 0 20px 0;
}
.login .modal .form_write {
padding: 10px;
}
}
/* // login */
@@ -0,0 +1,277 @@
/* 모달 오버레이 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
padding: 20px;
}
/* 모달 컨텐츠 */
.modal-content {
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
max-width: 800px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
position: relative;
}
/* 모달 헤더 */
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid #e5e7eb;
background-color: #f9fafb;
border-radius: 8px 8px 0 0;
}
.modal-header h2 {
margin: 0;
font-size: 1.8rem;
font-weight: 600;
color: #1f2937;
}
.close-button {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #6b7280;
padding: 4px;
border-radius: 4px;
transition: color 0.2s;
}
.close-button:hover {
color: #374151;
}
/* 폼 스타일 */
.signup-form {
padding: 24px;
}
.form-section {
margin-bottom: 32px;
}
.form-section h3 {
margin: 0 0 16px 0;
font-size: 1.4rem;
font-weight: 600;
color: #374151;
padding-bottom: 8px;
border-bottom: 2px solid #e5e7eb;
}
.form-row {
margin-bottom: 16px;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-group label {
margin-bottom: 6px;
font-weight: 500;
color: #374151;
font-size: 1.1rem;
}
.form-group input,
.form-group select {
padding: 12px 14px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 1.1rem;
transition: border-color 0.2s, box-shadow 0.2s;
background-color: white;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.form-group input:disabled,
.form-group select:disabled {
background-color: #f3f4f6;
color: #6b7280;
cursor: not-allowed;
}
/* 입력 필드와 버튼이 함께 있는 경우 */
.input-with-button {
display: flex;
gap: 8px;
align-items: flex-end;
}
.input-with-button input {
flex: 1;
}
.check-button {
padding: 10px 16px;
background-color: #6b7280;
color: white;
border: none;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
white-space: nowrap;
min-width: 80px;
}
.check-button:hover:not(:disabled) {
background-color: #4b5563;
}
.check-button:disabled {
background-color: #d1d5db;
cursor: not-allowed;
}
/* 메시지 스타일 */
.error-message {
color: #dc2626;
font-size: 0.8rem;
margin-top: 4px;
display: block;
}
.success-message {
color: #059669;
font-size: 0.8rem;
margin-top: 4px;
display: block;
}
.submit-error {
background-color: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
padding: 12px;
border-radius: 6px;
margin-bottom: 16px;
text-align: center;
}
/* 폼 액션 버튼 */
.form-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
padding-top: 20px;
border-top: 1px solid #e5e7eb;
margin-top: 24px;
}
.cancel-button,
.submit-button {
padding: 12px 24px;
border-radius: 6px;
font-weight: 500;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
border: none;
min-width: 100px;
}
.cancel-button {
background-color: #f3f4f6;
color: #374151;
border: 1px solid #d1d5db;
}
.cancel-button:hover:not(:disabled) {
background-color: #e5e7eb;
}
.submit-button {
background-color: #3b82f6;
color: white;
}
.submit-button:hover:not(:disabled) {
background-color: #2563eb;
}
.cancel-button:disabled,
.submit-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 반응형 디자인 */
@media (max-width: 768px) {
.modal-content {
margin: 10px;
max-height: 95vh;
}
.modal-header {
padding: 16px 20px;
}
.signup-form {
padding: 20px;
}
.form-actions {
flex-direction: column;
}
.cancel-button,
.submit-button {
width: 100%;
}
.input-with-button {
flex-direction: column;
align-items: stretch;
}
.check-button {
width: 100%;
margin-top: 8px;
}
}
/* 스크롤바 스타일 */
.modal-content::-webkit-scrollbar {
width: 6px;
}
.modal-content::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.modal-content::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.modal-content::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
@@ -0,0 +1,436 @@
/* 신규 사용자 가입 모달 오버레이 */
.user-signup-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
padding: 20px;
}
/* 신규 사용자 가입 모달 컨텐츠 */
.user-signup-modal-content {
background: white;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
max-width: 800px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
position: relative;
}
/* 신규 사용자 가입 모달 헤더 */
.user-signup-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid #e5e7eb;
background-color: #f9fafb;
border-radius: 8px 8px 0 0;
}
.user-signup-modal-header h2 {
margin: 0;
font-size: 1.8rem;
font-weight: 600;
color: #1f2937;
}
.user-signup-close-button {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #6b7280;
padding: 4px;
border-radius: 4px;
transition: color 0.2s;
}
.user-signup-close-button:hover {
color: #374151;
}
/* 신규 사용자 가입 폼 스타일 */
.user-signup-form {
padding: 24px;
}
.user-signup-form-section {
margin-bottom: 32px;
}
.user-signup-form-section h3 {
margin: 0 0 16px 0;
font-size: 1.4rem;
font-weight: 600;
color: #374151;
padding-bottom: 8px;
border-bottom: 2px solid #e5e7eb;
}
.user-signup-form-row {
margin-bottom: 16px;
}
.user-signup-form-group {
display: flex;
flex-direction: column;
}
.user-signup-form-group label {
margin-bottom: 6px;
font-weight: 500;
color: #374151;
font-size: 1.1rem;
}
.user-signup-form-group input,
.user-signup-form-group select {
padding: 12px 14px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 1.1rem;
transition: border-color 0.2s, box-shadow 0.2s;
background-color: white;
}
/* CodeSelect 컴포넌트 스타일링 */
.user-signup-form-group .CodeSelect {
width: 100%;
}
.user-signup-form-group .CodeSelect select {
width: 100%;
padding: 12px 14px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 1.1rem;
transition: border-color 0.2s, box-shadow 0.2s;
background-color: white;
}
.user-signup-form-group .CodeSelect select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.user-signup-form-group .CodeSelect select:disabled {
background-color: #f3f4f6;
color: #6b7280;
cursor: not-allowed;
}
/* Select 요소 스타일링 */
.user-signup-select {
width: 100%;
padding: 12px 14px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 1.1rem;
transition: border-color 0.2s, box-shadow 0.2s;
background-color: white;
cursor: pointer;
}
.user-signup-select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.user-signup-select:disabled {
background-color: #f3f4f6;
color: #6b7280;
cursor: not-allowed;
}
.user-signup-select option {
padding: 8px;
font-size: 1rem;
}
.user-signup-form-group input:focus,
.user-signup-form-group select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.user-signup-form-group input:disabled,
.user-signup-form-group select:disabled {
background-color: #f3f4f6;
color: #6b7280;
cursor: not-allowed;
}
/* 입력 필드와 버튼이 함께 있는 경우 */
.user-signup-input-with-button {
display: flex;
gap: 8px;
align-items: flex-end;
}
.user-signup-input-with-button input {
flex: 1;
}
.user-signup-check-button {
padding: 10px 16px;
background-color: #6b7280;
color: white;
border: none;
border-radius: 6px;
font-size: 0.8rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s;
white-space: nowrap;
min-width: 80px;
}
.user-signup-check-button:hover:not(:disabled) {
background-color: #4b5563;
}
.user-signup-check-button:disabled {
background-color: #d1d5db;
cursor: not-allowed;
}
/* 메시지 스타일 */
.user-signup-error-message {
color: #dc2626;
font-size: 0.8rem;
margin-top: 4px;
display: block;
}
.user-signup-success-message {
color: #059669;
font-size: 0.8rem;
margin-top: 4px;
display: block;
}
.user-signup-submit-error {
background-color: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
padding: 12px;
border-radius: 6px;
margin-bottom: 16px;
text-align: center;
}
/* 폼 액션 버튼 */
.user-signup-form-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
padding-top: 20px;
border-top: 1px solid #e5e7eb;
margin-top: 24px;
}
.user-signup-cancel-button,
.user-signup-submit-button,
.user-signup-back-button {
padding: 12px 24px;
border-radius: 6px;
font-weight: 500;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
border: none;
min-width: 100px;
}
.user-signup-cancel-button {
background-color: #f3f4f6;
color: #374151;
border: 1px solid #d1d5db;
}
.user-signup-cancel-button:hover:not(:disabled) {
background-color: #e5e7eb;
}
.user-signup-submit-button {
background-color: #3b82f6;
color: white;
}
.user-signup-submit-button:hover:not(:disabled) {
background-color: #2563eb;
}
.user-signup-back-button {
background-color: #6b7280;
color: white;
}
.user-signup-back-button:hover:not(:disabled) {
background-color: #4b5563;
}
.user-signup-cancel-button:disabled,
.user-signup-submit-button:disabled,
.user-signup-back-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 개인정보제공동의 스타일 */
.user-signup-agreement-content {
padding: 24px;
}
.user-signup-agreement-text {
margin-bottom: 24px;
}
.user-signup-agreement-text h3 {
margin: 0 0 16px 0;
font-size: 1.4rem;
font-weight: 600;
color: #374151;
}
.user-signup-agreement-text h4 {
margin: 20px 0 8px 0;
font-size: 1.1rem;
font-weight: 600;
color: #374151;
}
.user-signup-agreement-text p {
margin: 8px 0;
color: #6b7280;
line-height: 1.5;
}
.user-signup-agreement-text ul {
margin: 8px 0;
padding-left: 20px;
color: #6b7280;
}
.user-signup-agreement-text li {
margin: 4px 0;
line-height: 1.5;
}
.user-signup-agreement-checkbox {
margin-bottom: 24px;
padding: 16px;
background-color: #f9fafb;
border-radius: 6px;
border: 1px solid #e5e7eb;
}
.user-signup-agreement-checkbox label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-weight: 500;
color: #374151;
}
.user-signup-agreement-checkbox input[type="checkbox"] {
width: 18px;
height: 18px;
margin: 0;
}
.user-signup-agreement-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.user-signup-next-button {
padding: 12px 24px;
background-color: #3b82f6;
color: white;
border: none;
border-radius: 6px;
font-weight: 500;
font-size: 0.9rem;
cursor: pointer;
transition: background-color 0.2s;
min-width: 100px;
}
.user-signup-next-button:hover:not(:disabled) {
background-color: #2563eb;
}
.user-signup-next-button:disabled {
background-color: #d1d5db;
cursor: not-allowed;
}
/* 반응형 디자인 */
@media (max-width: 768px) {
.user-signup-modal-content {
margin: 10px;
max-height: 95vh;
}
.user-signup-modal-header {
padding: 16px 20px;
}
.user-signup-form {
padding: 20px;
}
.user-signup-form-actions {
flex-direction: column;
}
.user-signup-cancel-button,
.user-signup-submit-button,
.user-signup-back-button {
width: 100%;
}
.user-signup-input-with-button {
flex-direction: column;
align-items: stretch;
}
.user-signup-check-button {
width: 100%;
margin-top: 8px;
}
}
/* 스크롤바 스타일 */
.user-signup-modal-content::-webkit-scrollbar {
width: 6px;
}
.user-signup-modal-content::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
.user-signup-modal-content::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
.user-signup-modal-content::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
+1
View File
@@ -0,0 +1 @@
export { DashboardPage } from "./ui/dashboard-page";
@@ -0,0 +1,142 @@
"use client";
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { tokenManager } from '@/entities/auth/lib/token-manager';
import { client } from '@/shared/api';
import { ENDPOINT } from '@/shared/config';
interface UserInfo {
email?: string;
userId?: string;
userName?: string;
twoFactorEnabled?: boolean;
}
function DashboardPage() {
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
const [error, setError] = useState('');
const router = useRouter();
useEffect(() => {
const fetchUserInfo = async () => {
console.log('fetchUserInfo called');
try {
// tokenManager를 통해 토큰 확인
const accessToken = tokenManager.getToken('Authorization');
const userId = tokenManager.getToken('token-id');
if (!accessToken || !userId) {
console.log('No tokens found, redirecting to login');
setError('로그인이 필요합니다.');
router.push('/auth/login');
return;
}
console.log('Fetching user info with userId:', userId);
// client를 사용하여 API 호출 (자동 토큰 처리)
const response = await client.get(`${ENDPOINT.USER_SERVICE.USERS}/${userId}`);
if (response.data) {
console.log('User info received:', response.data);
setUserInfo(response.data);
} else {
setError('사용자 정보를 불러올 수 없습니다.');
}
} catch (error) {
console.error('Error fetching user info:', error);
if (error instanceof Error && error.message.includes('401')) {
setError('인증이 만료되었습니다. 다시 로그인해주세요.');
tokenManager.clearAuthToken();
router.push('/auth/login');
} else {
setError('네트워크 오류가 발생했습니다.');
}
}
};
fetchUserInfo();
}, [router]);
const handleLogout = () => {
console.log('Logging out...');
// localStorage 정리
localStorage.removeItem('tempToken');
// tokenManager를 통해 쿠키 정리
tokenManager.clearAuthToken();
router.push('/auth/logout');
};
const handleChangePassword = () => {
router.push('/otp?next=change-password');
};
return (
<div className="p-6">
<div className="flex flex-col gap-1">
{/* 첫번째 라인 */}
<div className="grid grid-cols-7 gap-1">
{/* 총 회원수 카드 */}
<div className="card card-border h-80 !p-0 col-span-2">
</div>
{/* 전체 게시물 카드 */}
<div className="card card-border h-80 !p-0 col-span-2">
</div>
{/* 오늘 접속자 수 카드 */}
<div className="card card-border h-80 !p-0 col-span-2">
</div>
{/* 사용자 정보 카드 */}
<div className="card card-border h-80 p-10 col-span-1">
<h2 className="text-2xl mb-6">사용자 정보</h2>
{error && <div className="text-red-500 mb-4">{error}</div>}
{userInfo ? (
<div className="mb-4">
<div><b>아이디:</b> {userInfo.userId}</div>
<div><b>이메일:</b> {userInfo.email || userInfo.userId}</div>
<div><b>이름:</b> {userInfo.userName || '-'}</div>
<div><b>2FA 활성화:</b> {userInfo.twoFactorEnabled ? '예' : '아니오'}</div>
</div>
) : (
!error && <div>사용자 정보를 불러오는 중...</div>
)}
<button className="btn w-full mb-2" onClick={handleLogout}>로그아웃</button>
<button className="btn w-full" onClick={handleChangePassword}>패스워드 변경</button>
</div>
</div>
{/* 두번째 라인 */}
<div className="grid grid-cols-2 gap-1">
<div className="card card-border h-80 p-10 col-span-1">
</div>
<div className="card card-border h-80 p-10 col-span-1">
</div>
</div>
{/* 세번째 라인 */}
<div className="grid grid-cols-2 gap-1">
<div className="card card-border h-80 p-10 col-span-1">
</div>
<div className="card card-border h-80 p-10 col-span-1">
</div>
</div>
{/* 네번째 라인 */}
<div className="grid grid-cols-2 gap-1">
<div className="card card-border h-80 p-10 col-span-1">
</div>
<div className="card card-border h-80 p-10 col-span-1">
</div>
</div>
</div>
</div>
);
}
export { DashboardPage };
@@ -0,0 +1,41 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageableList, PageableParams } from "@/shared/model";
export interface ZipSearchParams extends PageableParams {
zoneNo?: string; // 구역번호
ctpvNm?: string; // 시도명
sggNm?: string; // 시군구명
emdNm?: string; // 읍면동명
roadNm?: string; // 도로명
stdgNm?: string; // 법정동명
dongNm?: string; // 행정동명
lotnoMno?: number | null; // 지번본번
lotnoSno?: number | null; // 지번부번
}
export interface Zip {
zipId: number;
zoneNo: string;
ctpvNm: string;
sggNm: string;
emdNm: string;
roadNm: string;
lotnoMno: number;
lotnoSno: number;
lotno: string;
zipAddr: string;
}
type ZipList = PageableList<Zip>;
export class ZipApiService {
public async searchZip(params: ZipSearchParams): Promise<ZipList> {
const response = await client.get<ZipList>(`${ENDPOINT.CMS_SERVICE.ZIPS}`, {
params,
});
return response.data;
}
}
export const ZipApi = new ZipApiService();
+1
View File
@@ -0,0 +1 @@
export { CreatePage } from "./ui/create-page";
@@ -0,0 +1,88 @@
import dayjs from "dayjs";
import type { ColumnDef } from "@tanstack/react-table";
import {
USER_STATE_CODE,
type UserListResponse,
type UserStateCode,
} from "@/entities/user";
import { Badge } from "@common/components";
export const columns: ColumnDef<UserListResponse>[] = [
{
id: "no",
header: "No",
cell: ({ row, table }) => {
const totalRows = table.getRowCount();
const currentPage = table.getState().pagination.pageIndex;
const pageSize = table.getState().pagination.pageSize;
return totalRows - (currentPage * pageSize + row.index);
},
},
{
accessorKey: "userId",
header: "아이디",
},
{
accessorKey: "userName",
header: "이름",
},
{
accessorKey: "telno",
header: "전화번호",
},
{
accessorKey: "email",
header: "이메일",
},
{
accessorKey: "laeSeNm",
header: "구분",
},
{
accessorKey: "ogdpSeNm",
header: "소속",
},
{
accessorKey: "instSeNm",
header: "기관/부서",
},
{
accessorKey: "userStateCode",
header: "회원상태",
cell: ({ row }) => {
return getUserStateBadge(row.original.userStateCode);
},
},
{
accessorKey: "aprvYmd",
header: "승인일자",
},
{
accessorKey: "loginFailCount",
header: "로그인실패횟수",
},
{
accessorKey: "lastLoginDate",
header: "최종접속일",
cell: ({ row }) => {
return row.original.lastLoginDate
? dayjs(row.original.lastLoginDate).format("YYYY-MM-DD")
: "";
},
},
];
const getUserStateBadge = (userStateCode: UserStateCode) => {
switch (userStateCode) {
case USER_STATE_CODE.대기:
return <Badge color="gray">대기</Badge>;
case USER_STATE_CODE.정상:
return <Badge color="green">정상</Badge>;
case USER_STATE_CODE.정지:
return <Badge color="yellow">정지</Badge>;
case USER_STATE_CODE.탈퇴:
return <Badge color="purple">탈퇴</Badge>;
case USER_STATE_CODE.삭제:
return <Badge color="red">삭제</Badge>;
}
};
@@ -0,0 +1,179 @@
import { useState } from "react";
import {
keepPreviousData,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
getCoreRowModel,
getPaginationRowModel,
type PaginationState,
} from "@tanstack/react-table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogTrigger,
DialogClose,
Button,
Input,
Label,
} from "@common/components";
import {
LwpoCodeApi,
type LwpoCode,
type LwpoCodeParams,
} from "@/entities/code";
import { ENDPOINT } from "@/shared/config";
import { useDataTable } from "@/shared/lib";
interface AgencySearchDialogProps {
onSelectAgency: (data: { lwpoCd: string; lwpoCdNm: string }) => void;
}
function AgencySearchDialog({ onSelectAgency }: AgencySearchDialogProps) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [selectedAgency, setSelectedAgency] = useState<LwpoCode | null>(null);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [searchQuery, setSearchQuery] = useState<LwpoCodeParams>({
lwpoCdNm: "",
});
const query = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.LWPO_CODE, pagination],
queryFn: () =>
LwpoCodeApi.getLwpoCodeList({
...searchQuery,
upCd: "AGENCYCD",
page: pagination.pageIndex,
size: pagination.pageSize,
}),
placeholderData: keepPreviousData,
});
const handleRowClick = (row: LwpoCode) => {
table.setRowSelection({
[row.lwpoCd.toString()]: true,
});
setSelectedAgency(row);
};
const { tableComponent, tableInstance: table } = useDataTable({
query,
tableOptions: {
columns: [
{
accessorKey: "lwpoCd",
header: "기관코드",
meta: {
width: 200,
},
},
{
accessorKey: "lwpoCdNm",
header: "기관명",
},
],
state: {
pagination,
},
manualPagination: true,
enableMultiRowSelection: false,
getRowId: (row) => row.lwpoCd.toString(),
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
},
dataTableOptions: {
onRowClick: handleRowClick,
viewPagination: true,
variant: "secondary",
},
});
const handleSearch = async () => {
await query.refetch();
queryClient.removeQueries({
queryKey: [ENDPOINT.CMS_SERVICE.LWPO_CODE],
});
setPagination({
pageIndex: 0,
pageSize: 10,
});
table.setRowSelection({});
setSelectedAgency(null);
};
const handleSelectAgency = () => {
if (!selectedAgency) return;
onSelectAgency({
lwpoCd: selectedAgency.lwpoCd,
lwpoCdNm: selectedAgency.lwpoCdNm,
});
setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="px-6">기관 검색</Button>
</DialogTrigger>
<DialogContent
className="max-h-[80vh] max-w-[60rem] overflow-auto"
aria-describedby={undefined}
>
<DialogHeader>
<DialogTitle>기관 검색</DialogTitle>
</DialogHeader>
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
handleSearch();
}}
>
<div className="card card-border !mx-0 flex flex-col gap-2">
<div className="flex items-center gap-2">
<Label className="mr-2 whitespace-nowrap">기관명</Label>
<Input
value={searchQuery.lwpoCdNm ?? ""}
onChange={(e) =>
setSearchQuery({
...searchQuery,
lwpoCdNm: e.target.value,
})
}
placeholder="기관명을 입력하세요"
className="!text-[1.3rem]"
/>
<Button type="submit">검색</Button>
</div>
</div>
</form>
{tableComponent}
<DialogFooter className="!justify-center">
<Button
size="lg"
onClick={handleSelectAgency}
disabled={!selectedAgency}
>
선택
</Button>
<DialogClose asChild>
<Button size="lg" color="white">
취소
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { AgencySearchDialog };
@@ -0,0 +1,45 @@
"use client";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutationWrapper, useAlertStore } from "@/shared/lib";
import { UserApi, userSaveSchema, type UserSaveData } from "@/entities/user";
import { UsersForm } from "./users-form";
function CreatePage() {
const router = useRouter();
const alert = useAlertStore((state) => state.setMessage);
const form = useForm<UserSaveData>({
resolver: zodResolver(userSaveSchema),
defaultValues: {
userId: "",
userName: "",
password: "",
pswdHnt: "",
laeSeCd: "",
ogdpSeCd: "",
expryYmd: "",
},
});
// 사용자 등록 mutation
const { mutateAsync: saveUser } = useMutationWrapper({
mutationFn: (data: UserSaveData) => UserApi.saveUser(data),
onSuccess: () => {
alert("회원이 등록되었습니다.", router.back);
},
});
return (
<div className="card card-border !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">회원 등록</h3>
</div>
<UsersForm form={form} handleSave={saveUser} />
</div>
);
}
export { CreatePage };
@@ -0,0 +1,75 @@
"use client";
import React from "react";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import {
UserApi,
userUpdateSchema,
type UserUpdateData,
} from "@/entities/user";
import { ENDPOINT } from "@/shared/config";
import { useMutationWrapper, useAlertStore } from "@/shared/lib";
import { UsersForm } from "./users-form";
interface ModifyPageProps {
userUnqId: string;
}
export function ModifyPage({ userUnqId }: ModifyPageProps) {
const router = useRouter();
const alert = useAlertStore((state) => state.setMessage);
const { data: userData } = useQuery({
queryKey: [ENDPOINT.USER_SERVICE.USERS, userUnqId],
queryFn: () => UserApi.findByUserUnqId(userUnqId),
enabled: !!userUnqId,
});
const form = useForm<UserUpdateData>({
resolver: zodResolver(userUpdateSchema),
defaultValues: {
userId: userData?.userId ?? "",
userName: userData?.userName ?? "",
email: userData?.email ?? "",
userStateCode: userData?.userStateCode ?? "",
pswdHnt: userData?.pswdHnt ?? "",
pswdAns: userData?.pswdAns ?? "",
mphn: userData?.mphn ?? "",
telno: userData?.telno ?? "",
zip: userData?.zip ?? "",
addr: userData?.addr ?? "",
daddr: userData?.daddr ?? "",
laeSeCd: userData?.laeSeCd ?? "",
ogdpSeCd: userData?.ogdpSeCd ?? "",
aprvYn: userData?.aprvYn ?? "",
expryYmd: userData?.expryYmd ?? "",
instSeCd: userData?.instSeCd ?? "",
dtlInstCd: userData?.dtlInstCd ?? "",
aprvYmd: userData?.aprvYmd ?? "",
enoCd: userData?.enoCd ?? "",
},
});
const { mutateAsync: updateUser } = useMutationWrapper({
mutationFn: (data: UserUpdateData) => UserApi.updateUser(userUnqId, data),
onSuccess: () => {
alert("회원 정보가 수정되었습니다.", router.back);
},
});
return (
<div className="card card-border !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">회원 정보 수정</h3>
</div>
<UsersForm
userId={userData?.userId}
form={form}
handleSave={updateUser}
/>
</div>
);
}
+187
View File
@@ -0,0 +1,187 @@
"use client";
import dayjs from "dayjs";
import { usePathname, useRouter } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import {
Button,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from "@common/components";
import { USER_STATE_CODE, UserApi, PasswordResetDialog } from "@/entities/user";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useMutationWrapper } from "@/shared/lib";
function UserDetail({ userUnqId }: { userUnqId: string }) {
const router = useRouter();
const pathname = usePathname();
const { confirm } = useConfirm();
const queryClient = useQueryClient();
const alert = useAlertStore((state) => state.setMessage);
const { data, refetch } = useQuery({
queryKey: [ENDPOINT.USER_SERVICE.USERS, userUnqId],
queryFn: () => UserApi.findByUserUnqId(userUnqId),
enabled: !!userUnqId,
});
const { mutate: onLockUser } = useMutationWrapper({
mutationFn: () => UserApi.lockUser(userUnqId),
onSuccess: () => {
alert("계정 잠금이 활성화 되었습니다.");
refetch();
},
});
const { mutate: onUnlockUser } = useMutationWrapper({
mutationFn: () => UserApi.unlockUser(userUnqId),
onSuccess: () => {
alert("계정 잠금이 해제되었습니다.");
refetch();
},
});
const { mutate: onApplyUser } = useMutationWrapper({
mutationFn: () => UserApi.applyUser(userUnqId),
onSuccess: () => {
alert("사용자 승인이 완료되었습니다.");
refetch();
},
});
const { mutateAsync: deleteUser } = useMutationWrapper({
mutationFn: () => UserApi.deleteUser(userUnqId),
onSuccess: () => {
alert("회원이 삭제되었습니다.", router.back);
queryClient.invalidateQueries({
queryKey: [ENDPOINT.USER_SERVICE.USERS],
});
},
});
const handleDelete = async () => {
if (await confirm("삭제하시겠습니까?")) {
await deleteUser();
}
};
return (
<div className="card card-border !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">회원 정보</h3>
</div>
<div className="card">
<div className="mb-4 flex justify-end gap-2">
{data?.userStateCode === USER_STATE_CODE.정지 ? (
<Button color="orange" onClick={() => onUnlockUser()}>
<i className="diveicon di-lock-open" />
잠금해제
</Button>
) : (
<Button color="orange" onClick={() => onLockUser()}>
<i className="diveicon di-lock" />
계정잠금
</Button>
)}
<PasswordResetDialog userUnqId={userUnqId}>
<Button color="gray">
<i className="diveicon di-arrow-reset" /> 비밀번호 초기화
</Button>
</PasswordResetDialog>
{data?.userStateCode === USER_STATE_CODE.대기 && (
<Button className="emerald" onClick={() => onApplyUser()}>
<i className="diveicon di-check-circle-line" /> 가입승인
</Button>
)}
</div>
<Table variant="secondary">
<colgroup>
<col width="20%" />
<col width="80%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead>아이디</TableHead>
<TableCell className="!px-4">{data?.userId}</TableCell>
</TableRow>
<TableRow>
<TableHead>이름</TableHead>
<TableCell className="!px-4">{data?.userName}</TableCell>
</TableRow>
<TableRow>
<TableHead rowSpan={2}>연락처</TableHead>
<TableCell className="!px-4">휴대폰: {data?.mphn}</TableCell>
</TableRow>
<TableRow>
<TableCell className="!px-4">전화번호: {data?.telno}</TableCell>
</TableRow>
<TableRow>
<TableHead>이메일</TableHead>
<TableCell className="!px-4">{data?.email}</TableCell>
</TableRow>
<TableRow>
<TableHead rowSpan={2}>주소</TableHead>
<TableCell className="!px-4">
{data?.zip && `(${data?.zip})`} {data?.addr}
</TableCell>
</TableRow>
<TableRow>
<TableCell className="!px-4">{data?.daddr}</TableCell>
</TableRow>
<TableRow>
<TableHead>기관구분</TableHead>
<TableCell className="!px-4">{data?.ogdpSeNm}</TableCell>
</TableRow>
<TableRow>
<TableHead>세부기관</TableHead>
<TableCell className="!px-4">{data?.dtlInstNm}</TableCell>
</TableRow>
<TableRow>
<TableHead>소속</TableHead>
<TableCell className="!px-4">{data?.laeSeNm}</TableCell>
</TableRow>
<TableRow>
<TableHead>환경청</TableHead>
<TableCell className="!px-4">{data?.ecoNm}</TableCell>
</TableRow>
<TableRow>
<TableHead>가입일</TableHead>
<TableCell className="!px-4">
{data?.regDt ? dayjs(data?.regDt).format("YYYY-MM-DD") : ""}
</TableCell>
</TableRow>
<TableRow>
<TableHead>최근로그인</TableHead>
<TableCell className="!px-4">
{data?.lastLoginDate
? dayjs(data?.lastLoginDate).format("YYYY-MM-DD HH:mm:ss")
: ""}
</TableCell>
</TableRow>
<TableRow>
<TableHead>계정상태</TableHead>
<TableCell className="!px-4">{data?.userStateNm}</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<div className="flex justify-center gap-4 pb-12 pt-10">
<Button size="lg" className="white" onClick={router.back}>
목록
</Button>
<Button size="lg" onClick={() => router.push(`${pathname}/modify`)}>
수정
</Button>
<Button size="lg" color="red" onClick={handleDelete}>
삭제
</Button>
</div>
</div>
);
}
export { UserDetail };
+669
View File
@@ -0,0 +1,669 @@
import { useState, useMemo, useCallback } from "react";
import {
useWatch,
type Path,
type PathValue,
type UseFormReturn,
} from "react-hook-form";
import { useQuery } from "@tanstack/react-query";
import {
Button,
Input,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
import { FormWrapper } from "@/widgets/form-wrapper";
import { CodeApi, CodeSelect } from "@/entities/code";
import {
UserApi,
type UserSaveData,
type UserUpdateData,
} from "@/entities/user";
import {
FormField,
FormItem,
FormControl,
FormMessage,
} from "@/shared/ui/form";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useMutationWrapper } from "@/shared/lib";
import { ZipSearchDialog } from "./zip-search-dialog";
import { AgencySearchDialog } from "./agency-search-dialog";
type UsersFormProps<T extends UserSaveData | UserUpdateData> = {
userId?: string;
form: UseFormReturn<T>;
handleSave: (data: T) => Promise<string>;
handleDelete?: () => Promise<void>;
};
function UsersForm<T extends UserSaveData | UserUpdateData>({
userId,
form,
...props
}: UsersFormProps<T>) {
const isModifyMode = !!userId;
const [watchOgdpSeCd, watchDtlInstCd] = useWatch({
control: form.control,
name: ["ogdpSeCd" as Path<T>, "dtlInstCd" as Path<T>],
});
const alert = useAlertStore((state) => state.setMessage);
const [isValidUserId, setIsValidUserId] = useState(false);
const [isValidEmail, setIsValidEmail] = useState(false);
const [passwordMatch, setPasswordMatch] = useState(true);
const [instSeCdNm, setInstSeCdNm] = useState("");
const { data: authSeCd } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.CODE, "AUTHSECD"],
queryFn: () => CodeApi.getCode("AUTHSECD"),
});
const { data: laeSeCd } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.CODE, "LAESECD"],
queryFn: () => CodeApi.getCode("LAESECD"),
});
const { mutateAsync: checkDuplicateUserId } = useMutationWrapper({
mutationFn: (userId: string) => UserApi.existsUserId({ userId }),
onSuccess: (duplicate) => {
if (!duplicate) {
alert("사용가능한 아이디입니다.");
setIsValidUserId(true);
return;
}
alert("중복된 아이디입니다.");
},
});
const { mutateAsync: checkDuplicateEmail } = useMutationWrapper({
mutationFn: (email: string) => UserApi.existsEmail({ email }),
onSuccess: (duplicate) => {
if (!duplicate) {
alert("사용가능한 이메일입니다.");
setIsValidEmail(true);
return;
}
alert("중복된 이메일입니다.");
},
});
const authSeCdSelectData = useMemo(() => {
if (watchOgdpSeCd === "1")
return (
authSeCd?.filter(
(code) =>
code.lwpoCd === "ROLE_USER1" ||
code.lwpoCd === "ROLE_USER2" ||
code.lwpoCd === "ROLE_USER3",
) ?? []
);
if (watchOgdpSeCd === "3" || watchOgdpSeCd === "4")
return (
authSeCd?.filter(
(code) =>
code.lwpoCd === "ROLE_USER4" || code.lwpoCd === "ROLE_USER5",
) ?? []
);
return [];
}, [authSeCd, watchOgdpSeCd]);
const laeSeCdSelectData = useMemo(() => {
if (watchDtlInstCd === "ROLE_USER1")
return laeSeCd?.filter((code) => code.lwpoCd === "G102") ?? [];
if (watchDtlInstCd === "ROLE_USER2")
return laeSeCd?.filter((code) => code.lwpoCd === "G101") ?? [];
if (watchDtlInstCd === "ROLE_USER3")
return laeSeCd?.filter((code) => code.lwpoCd.startsWith("G2")) ?? [];
if (watchDtlInstCd === "ROLE_USER4")
return (
laeSeCd?.filter(
(code) => code.lwpoCd.startsWith("G3") && code.lwpoCd.length === 4,
) ?? []
);
if (watchDtlInstCd === "ROLE_USER5")
return (
laeSeCd?.filter(
(code) => code.lwpoCd.startsWith("G3") && code.lwpoCd.length === 7,
) ?? []
);
return [];
}, [laeSeCd, watchDtlInstCd]);
const enoCdSelectData = useMemo(
() => laeSeCd?.filter((code) => code.lwpoCd?.startsWith("G2")) ?? [],
[laeSeCd],
);
const handlePasswordConfirmChange = useCallback((value: string) => {
const password = form.getValues("password" as Path<T>);
setPasswordMatch(!password || value === password);
}, []);
const handleAddressSelect = useCallback(
(data: { zip: string; addr: string }) => {
form.setValue("zip" as Path<T>, data.zip as PathValue<T, Path<T>>);
form.setValue("addr" as Path<T>, data.addr as PathValue<T, Path<T>>);
},
[form],
);
const handleAgencySelect = useCallback(
(data: { lwpoCd: string; lwpoCdNm: string }) => {
form.setValue(
"instSeCd" as Path<T>,
data.lwpoCd as PathValue<T, Path<T>>,
);
setInstSeCdNm(data.lwpoCdNm);
},
[form],
);
return (
<FormWrapper form={form} isModifyMode={isModifyMode} {...props}>
<div className="card">
<Table variant="secondary">
<colgroup>
<col width="20%" />
<col width="80%" />
</colgroup>
<TableBody>
{/* 아이디 */}
<TableRow>
<TableHead data-required>아이디</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name={"userId" as Path<T>}
render={({ field }) => (
<div className="flex items-center gap-2">
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
onChange={(e) => {
field.onChange(e);
setIsValidUserId(false);
}}
className="w-[24rem] !text-[1.3rem]"
placeholder="아이디를 입력하세요"
disabled={isModifyMode}
/>
</FormControl>
</FormItem>
{!isModifyMode && (
<Button
color="green"
className="px-6"
onClick={(e) => {
e.preventDefault();
checkDuplicateUserId(field.value as string);
}}
disabled={!field.value || isValidUserId}
>
{isValidUserId && <i className="diveicon di-check" />}
중복확인
</Button>
)}
</div>
)}
/>
</TableCell>
</TableRow>
{/* 비밀번호 */}
<TableRow>
<TableHead data-required>비밀번호</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name={"password" as Path<T>}
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
type="password"
value={field.value ?? ""}
className="w-[24rem] !text-[1.3rem]"
placeholder="비밀번호를 입력하세요"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
{/* 비밀번호 확인 */}
<TableRow>
<TableHead>비밀번호 확인</TableHead>
<TableCell className="border">
<div className="flex flex-col">
<Input
type="password"
onChange={(e) =>
handlePasswordConfirmChange(e.target.value)
}
className="w-[24rem] !text-[1.3rem]"
placeholder="비밀번호를 다시 입력하세요"
/>
{!passwordMatch && (
<p className="mt-2 text-base text-red-500">
비밀번호가 일치하지 않습니다.
</p>
)}
</div>
</TableCell>
</TableRow>
{/* 비밀번호 힌트 */}
<TableRow>
<TableHead data-required>비밀번호 힌트</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name={"pswdHnt" as Path<T>}
render={({ field }) => (
<FormItem>
<FormControl>
<CodeSelect
{...field}
upCd="PSWDHNT"
className="w-[24rem]"
onValueChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
{/* 비밀번호 정답 */}
<TableRow>
<TableHead>비밀번호 정답</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name={"pswdAns" as Path<T>}
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="w-[24rem] !text-[1.3rem]"
placeholder="비밀번호 정답을 입력하세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
{/* 이름 */}
<TableRow>
<TableHead data-required>이름</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name={"userName" as Path<T>}
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="w-[24rem] !text-[1.3rem]"
placeholder="이름을 입력하세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
{/* 연락처 */}
<TableRow>
<TableHead rowSpan={2}>연락처</TableHead>
<TableCell>
<FormField
control={form.control}
name={"mphn" as Path<T>}
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="w-[24rem] !text-[1.3rem]"
placeholder="휴대폰 번호(-를 제외하고 입력)"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<FormField
control={form.control}
name={"telno" as Path<T>}
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="w-[24rem] !text-[1.3rem]"
placeholder="전화번호(-를 제외하고 입력)"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
{/* 이메일 */}
<TableRow>
<TableHead>이메일</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name={"email" as Path<T>}
render={({ field }) => (
<div className="flex items-center gap-2">
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
onChange={(e) => {
field.onChange(e);
setIsValidEmail(false);
}}
className="w-[24rem] !text-[1.3rem]"
placeholder="이메일을 입력하세요"
/>
</FormControl>
</FormItem>
<Button
color="green"
className="px-6"
onClick={(e) => {
e.preventDefault();
if (field.value) {
checkDuplicateEmail(field.value as string);
}
}}
disabled={!field.value || isValidEmail}
>
{isValidEmail && <i className="diveicon di-check" />}
중복확인
</Button>
</div>
)}
/>
</TableCell>
</TableRow>
{/* 주소 */}
<TableRow>
<TableHead rowSpan={2}>주소</TableHead>
<TableCell className="border">
<div className="flex flex-col gap-2">
<div className="mb-2 flex items-center gap-2">
<FormField
control={form.control}
name={"zip" as Path<T>}
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="w-[10rem] !text-[1.3rem]"
disabled
/>
</FormControl>
</FormItem>
)}
/>
<ZipSearchDialog onSelectAddress={handleAddressSelect} />
</div>
<FormField
control={form.control}
name={"addr" as Path<T>}
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="w-[36rem] !text-[1.3rem]"
disabled
/>
</FormControl>
</FormItem>
)}
/>
</div>
</TableCell>
</TableRow>
<TableRow>
<TableCell>
<FormField
control={form.control}
name={"daddr" as Path<T>}
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="w-[36rem] !text-[1.3rem]"
placeholder="상세 주소를 입력하세요"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
{/* 기관구분 */}
<TableRow>
<TableHead data-required>기관구분</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name={"ogdpSeCd" as Path<T>}
render={({ field }) => (
<FormItem>
<FormControl>
<CodeSelect
{...field}
upCd="OGDPSECD"
value={field.value ?? ""}
onValueChange={(value) => {
field.onChange(value);
form.setValue(
"dtlInstCd" as Path<T>,
"" as PathValue<T, Path<T>>,
);
form.setValue(
"laeSeCd" as Path<T>,
"" as PathValue<T, Path<T>>,
);
form.setValue(
"enoCd" as Path<T>,
"" as PathValue<T, Path<T>>,
);
}}
className="w-[16rem]"
placeholder="기관 선택"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
{/* 세부기관 */}
<TableRow>
<TableHead>세부기관</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name={"dtlInstCd" as Path<T>}
render={({ field }) => (
<FormItem>
<FormControl>
<Select
value={field.value ?? ""}
onValueChange={(value) => {
field.onChange(value);
form.setValue(
"laeSeCd" as Path<T>,
"" as PathValue<T, Path<T>>,
);
form.setValue(
"enoCd" as Path<T>,
"" as PathValue<T, Path<T>>,
);
}}
>
<SelectTrigger className="w-[16rem]">
<SelectValue placeholder="세부기관 선택" />
</SelectTrigger>
<SelectContent>
{authSeCdSelectData?.map((code) => (
<SelectItem key={code.lwpoCd} value={code.lwpoCd}>
{code.lwpoCdNm}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
{/* 소속 */}
<TableRow>
<TableHead data-required>소속</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name={"laeSeCd" as Path<T>}
render={({ field }) => (
<FormItem>
<FormControl>
<Select
value={field.value ?? ""}
onValueChange={(value) => {
field.onChange(value);
form.setValue(
"enoCd" as Path<T>,
"" as PathValue<T, Path<T>>,
);
}}
>
<SelectTrigger className="w-[16rem]">
<SelectValue placeholder="소속 선택" />
</SelectTrigger>
<SelectContent>
{laeSeCdSelectData?.map((code) => (
<SelectItem key={code.lwpoCd} value={code.lwpoCd}>
{code.lwpoCdNm}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
{/* 환경청 */}
<TableRow>
<TableHead>환경청</TableHead>
<TableCell className="border">
<FormField
control={form.control}
name={"enoCd" as Path<T>}
render={({ field }) => (
<FormItem>
<FormControl>
<Select
value={field.value ?? ""}
onValueChange={field.onChange}
disabled={
!(
watchDtlInstCd === "ROLE_USER4" ||
watchDtlInstCd === "ROLE_USER5"
)
}
>
<SelectTrigger className="w-[16rem]">
<SelectValue placeholder="환경청 선택" />
</SelectTrigger>
<SelectContent>
{enoCdSelectData?.map((code) => (
<SelectItem key={code.lwpoCd} value={code.lwpoCd}>
{code.lwpoCdNm}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
{/* 환경청 기관/업체명 */}
<TableRow>
<TableHead>기관/업체명</TableHead>
<TableCell className="border">
<div className="flex items-center gap-2">
<Input
value={instSeCdNm}
className="w-[24rem] !text-[1.3rem]"
disabled
/>
<AgencySearchDialog onSelectAgency={handleAgencySelect} />
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</FormWrapper>
);
}
export { UsersForm };
+396
View File
@@ -0,0 +1,396 @@
"use client";
import dayjs from "dayjs";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import {
Button,
Checkbox,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
import { CodeApi } from "@/entities/code";
import {
UserApi,
type UserListResponse,
type UserSearchParams,
} from "@/entities/user";
import { ENDPOINT } from "@/shared/config";
import { useDataTable } from "@/shared/lib";
import { InputDateRangePicker } from "@/shared/ui/input-date-range-picker";
import { columns } from "../model/table-columns";
type DateRange = {
from?: Date | undefined;
to?: Date | undefined;
};
function UsersPage() {
const router = useRouter();
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [presetDateRange, setPresetDateRange] = useState("");
const [dateRange, setDateRange] = useState<DateRange>({
from: undefined,
to: undefined,
});
const [keywordType, setKeywordType] = useState("all");
const [keyword, setKeyword] = useState("");
const [userStateCode, setUserStateCode] = useState("");
const [aprvYn, setAprvYn] = useState("");
const [grpId, setGrpId] = useState("");
const [searchParams, setSearchParams] = useState<UserSearchParams>({
keywordType: "all",
keyword: "",
userStateCode: "",
aprvYn: "",
grpId: "",
frDateYmd: "",
toDateYmd: "",
sort: "regDt,desc",
});
const { data: userSttsStats } = useQuery({
queryKey: [ENDPOINT.USER_SERVICE.USERS, "userStatusStatistics"],
queryFn: () => UserApi.selectUserSttsStats(),
});
const query = useQuery({
queryKey: [ENDPOINT.USER_SERVICE.USERS, searchParams, pagination],
queryFn: () =>
UserApi.findPageUser({
...searchParams,
page: pagination.pageIndex,
size: pagination.pageSize,
}),
});
const { data: userStateCodes } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.CODE],
queryFn: () => CodeApi.getCode("USERSTTS"),
});
const handleRowClick = (row: UserListResponse) => {
router.push(`/users/${row.userUnqId}`);
};
const { tableComponent, tableInstance: table } = useDataTable({
query,
tableOptions: {
columns,
state: {
pagination,
},
manualPagination: true,
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setPagination,
},
dataTableOptions: {
onRowClick: handleRowClick,
viewPagination: true,
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setPagination({
...pagination,
pageIndex: 0,
});
setSearchParams({
...searchParams,
keywordType,
keyword,
userStateCode,
aprvYn,
grpId,
frDateYmd: dateRange?.from
? dayjs(dateRange.from).format("YYYYMMDD")
: "",
toDateYmd: dateRange?.to ? dayjs(dateRange.to).format("YYYYMMDD") : "",
});
};
const handleReset = () => {
setKeywordType("all");
setKeyword("");
setUserStateCode("");
setAprvYn("");
setGrpId("");
setDateRange({
from: undefined,
to: undefined,
});
setSearchParams({
...searchParams,
keywordType: "all",
keyword: "",
userStateCode: "",
aprvYn: "",
grpId: "",
frDateYmd: "",
toDateYmd: "",
});
};
const handlePresetDateRange = (value: string) => {
let presetDateRange: DateRange;
switch (value) {
case "1M":
presetDateRange = {
from: dayjs().subtract(1, "month").toDate(),
to: dayjs().toDate(),
};
break;
case "3M":
presetDateRange = {
from: dayjs().subtract(3, "month").toDate(),
to: dayjs().toDate(),
};
break;
case "6M":
presetDateRange = {
from: dayjs().subtract(6, "month").toDate(),
to: dayjs().toDate(),
};
break;
default:
presetDateRange = {
from: undefined,
to: undefined,
};
}
setPresetDateRange(value);
setDateRange(presetDateRange);
};
const handleDateRangeChange = (dateRange: DateRange) => {
setDateRange(dateRange);
setPresetDateRange("");
};
const getUserSttsStatsCnt = (code: string): string => {
if (!Array.isArray(userSttsStats)) return "0";
const count = userSttsStats.find((item) => item.sttsCd === code)?.cnt ?? 0;
return count.toLocaleString();
};
const handleUserStateCodeChange = (code: string, checked: boolean) => {
let newUserStateCode = userStateCode;
if (checked) {
newUserStateCode = userStateCode ? `${userStateCode},${code}` : code;
} else {
newUserStateCode = userStateCode
.split(",")
.filter((c) => c !== code)
.join(",");
}
setUserStateCode(newUserStateCode);
};
return (
<div className="card card-border !mt-6">
<div className="card card-border flex items-center justify-center gap-10 !bg-accent !py-6">
<div className="flex items-center gap-4">
<div className="flex size-12 items-center justify-center rounded-full bg-[rgba(153,153,153,1)]">
<i className="diveicon di-person text-xl text-white" />
</div>
<span className="text-2xl font-medium text-label">
승인대기{" "}
<em className="not-italic text-primary">
{getUserSttsStatsCnt("00")}
</em>
명
</span>
</div>
<div className="flex items-center gap-4">
<div className="flex size-12 items-center justify-center rounded-full bg-primary">
<i className="diveicon di-person-fill text-xl text-white" />
</div>
<span className="text-2xl font-medium text-label">
전체{" "}
<em className="not-italic text-primary">
{getUserSttsStatsCnt("TOTAL")}
</em>
명
</span>
</div>
<div className="flex items-center gap-4">
<div className="flex size-12 items-center justify-center rounded-full bg-[rgba(255,149,0,1)]">
<i className="diveicon di-lock text-xl text-white" />
</div>
<span className="text-2xl font-medium text-label">
잠금{" "}
<em className="not-italic text-primary">
{getUserSttsStatsCnt("07")}
</em>
명
</span>
</div>
<div className="flex items-center gap-4">
<div className="flex size-12 items-center justify-center rounded-full bg-destructive">
<i className="diveicon di-exclamation-triangle-fill text-xl text-white" />
</div>
<span className="text-2xl font-medium text-label">
탈퇴{" "}
<em className="not-italic text-primary">
{getUserSttsStatsCnt("08")}
</em>
명
</span>
</div>
</div>
<div className="card card-border">
<form onSubmit={handleSubmit} className="flex flex-col gap-6 p-2">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<Label className="mr-2 whitespace-nowrap text-label">
회원정보
</Label>
<Select
value={keywordType}
onValueChange={(value) => setKeywordType(value)}
>
<SelectTrigger className="w-[18rem]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">전체</SelectItem>
<SelectItem value="userId">아이디</SelectItem>
<SelectItem value="userName">이름</SelectItem>
<SelectItem value="email">이메일</SelectItem>
<SelectItem value="mphn">휴대폰번호</SelectItem>
</SelectContent>
</Select>
<Input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
className="!text-[1.3rem]"
placeholder="검색어를 입력하세요"
/>
</div>
<div className="flex items-center gap-2">
<Label className="mr-2 text-label">그룹</Label>
{/* Select */}
</div>
<div className="flex items-center gap-2">
<Label className="mr-2 text-label">기간</Label>
<Select
value={presetDateRange}
onValueChange={handlePresetDateRange}
>
<SelectTrigger className="w-[12rem]">
<SelectValue placeholder="기간 선택" className="w-[24rem]" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1M">1개월</SelectItem>
<SelectItem value="3M">3개월</SelectItem>
<SelectItem value="6M">6개월</SelectItem>
</SelectContent>
</Select>
<InputDateRangePicker
dateRange={dateRange}
setDateRange={handleDateRangeChange}
/>
</div>
</div>
<div className="flex items-center gap-2">
<Label className="mr-2 text-label">회원상태</Label>
<div className="flex gap-4">
{userStateCodes?.map((item) => (
<div key={item.lwpoCd} className="flex items-center gap-2">
<Checkbox
id={item.lwpoCd}
value={item.lwpoCd}
checked={userStateCode?.includes(item.lwpoCd)}
onCheckedChange={(checked) => {
handleUserStateCodeChange(item.lwpoCd, !!checked);
}}
/>
<Label htmlFor={item.lwpoCd}>{item.lwpoCdNm}</Label>
</div>
))}
</div>
</div>
<hr />
<div className="flex justify-center gap-2">
<Button type="submit" className="w-[16rem]">
<i className="diveicon di-search" />
검색
</Button>
<Button
type="button"
color="white"
className="w-[16rem]"
onClick={handleReset}
>
초기화
</Button>
</div>
</form>
</div>
<div className="card card-border">
<div className="card-header">
<div className="flex w-full items-center justify-between">
<h3 className="text-2xl font-medium text-label">회원 목록</h3>
<div className="flex gap-2">
<Select
defaultValue="regDt,desc"
onValueChange={(value) => {
setSearchParams({
...searchParams,
sort: value,
});
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="aprvYmd,desc">승인일순</SelectItem>
<SelectItem value="regDt,desc">등록일순</SelectItem>
</SelectContent>
</Select>
<Select
value={table.getState().pagination.pageSize.toString()}
onValueChange={(value) => {
table.setPageSize(parseInt(value));
}}
>
<SelectTrigger className="h-[3.2rem] w-[12rem]">
<SelectValue placeholder="페이지 크기" />
</SelectTrigger>
<SelectContent>
{[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={pageSize.toString()}>
{pageSize}개씩 보기
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={() => router.push("/users/register")}>
등록
</Button>
</div>
</div>
</div>
<div className="card !m-[1.2rem] !p-0">{tableComponent}</div>
</div>
</div>
);
}
export { UsersPage };
@@ -0,0 +1,224 @@
import { useState, useRef, useEffect } from "react";
import {
keepPreviousData,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
getCoreRowModel,
getPaginationRowModel,
type PaginationState,
} from "@tanstack/react-table";
import {
Dialog,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogTrigger,
DialogClose,
Button,
Input,
Label,
} from "@common/components";
import { ENDPOINT } from "@/shared/config";
import { useDataTable } from "@/shared/lib";
import { ZipApi, type Zip, type ZipSearchParams } from "../api/zip-service";
interface ZipSearchDialogProps {
onSelectAddress: (data: { zip: string; addr: string }) => void;
}
function ZipSearchDialog({ onSelectAddress }: ZipSearchDialogProps) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [selectedAddress, setSelectedAddress] = useState<Zip | null>(null);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [searchQuery, setSearchQuery] = useState<ZipSearchParams>({
roadNm: "",
lotnoMno: null,
lotnoSno: null,
});
const hasFetchedOnce = useRef(false);
const query = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.ZIPS, pagination],
queryFn: () =>
ZipApi.searchZip({
...searchQuery,
page: pagination.pageIndex,
size: pagination.pageSize,
}),
placeholderData: keepPreviousData,
enabled: false,
});
const handleRowClick = (row: Zip) => {
table.setRowSelection({
[row.zipId.toString()]: true,
});
setSelectedAddress(row);
};
const { tableComponent, tableInstance: table } = useDataTable({
query,
tableOptions: {
columns: [
{
accessorKey: "zoneNo",
header: "우편번호",
},
{
accessorKey: "zipAddr",
header: "주소",
},
],
state: {
pagination,
},
manualPagination: true,
enableMultiRowSelection: false,
getRowId: (row) => row.zipId.toString(),
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
},
dataTableOptions: {
onRowClick: handleRowClick,
viewPagination: true,
variant: "secondary",
},
});
const handleSearch = async () => {
await query.refetch();
queryClient.removeQueries({
queryKey: [ENDPOINT.CMS_SERVICE.ZIPS],
});
setPagination({
pageIndex: 0,
pageSize: 10,
});
table.setRowSelection({});
setSelectedAddress(null);
hasFetchedOnce.current = true;
};
const handleSelectAddress = () => {
if (!selectedAddress) return;
onSelectAddress({
zip: selectedAddress.zoneNo,
addr: selectedAddress.zipAddr,
});
setOpen(false);
};
useEffect(() => {
if (hasFetchedOnce.current) query.refetch();
}, [pagination]);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="px-6">우편번호 검색</Button>
</DialogTrigger>
<DialogContent className="max-w-[60rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>주소 검색</DialogTitle>
</DialogHeader>
<form
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
handleSearch();
}}
className="mb-4 space-y-2"
>
<div className="card card-border !m-0 flex flex-col gap-4 p-4">
<div className="grid grid-cols-3 gap-4">
<div className="flex flex-col">
<Label className="mb-1 text-base">도로명</Label>
<Input
value={searchQuery.roadNm || ""}
onChange={(e) =>
setSearchQuery({
...searchQuery,
roadNm: e.target.value,
})
}
placeholder="예: 테헤란로"
className="!text-[1.3rem]"
/>
</div>
<div className="flex flex-col">
<Label className="mb-1 text-base">지번본번</Label>
<Input
type="number"
value={searchQuery.lotnoMno ?? ""}
onChange={(e) => {
const value = e.target.value
? parseInt(e.target.value)
: null;
setSearchQuery({
...searchQuery,
lotnoMno: value,
});
}}
placeholder="예: 123"
className="!text-[1.3rem]"
/>
</div>
<div className="flex flex-col">
<Label className="mb-1 text-base">지번부번</Label>
<Input
type="number"
value={searchQuery.lotnoSno ?? ""}
onChange={(e) => {
const value = e.target.value
? parseInt(e.target.value)
: null;
setSearchQuery({
...searchQuery,
lotnoSno: value,
});
}}
placeholder="예: 45"
className="!text-[1.3rem]"
/>
</div>
</div>
<div className="flex justify-end">
<Button type="submit">검색</Button>
</div>
</div>
</form>
{query.data?.content.length && tableComponent}
<DialogFooter className="!justify-center">
<Button
size="lg"
onClick={handleSelectAddress}
disabled={!selectedAddress}
>
선택
</Button>
<DialogClose asChild>
<Button size="lg" color="white">
취소
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { ZipSearchDialog };
+11
View File
@@ -0,0 +1,11 @@
import { Client } from "@common/network";
import { BASE_URL } from "../config/api";
import { SITE_ID } from "../config/env";
import { setCookie } from "../lib";
setCookie("site-id-key", SITE_ID);
export const client = Client.getInstance({
baseURL: BASE_URL,
siteId: SITE_ID,
});
+1
View File
@@ -0,0 +1 @@
export { client } from "./client";
+71
View File
@@ -0,0 +1,71 @@
import axios from "axios";
import type { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
interface SignupClientConfig {
baseURL: string;
}
class SignupClient {
private static instance: SignupClient;
private axiosInstance: AxiosInstance;
private constructor(config: SignupClientConfig) {
this.axiosInstance = axios.create({
baseURL: config.baseURL,
headers: {
"Content-Type": "application/json",
},
withCredentials: true,
});
// 인증 토큰 없이도 요청할 수 있도록 인터셉터 설정
this.axiosInstance.interceptors.request.use((config) => {
// 인증 토큰을 자동으로 추가하지 않음
return config;
});
}
public static getInstance(config: SignupClientConfig): SignupClient {
if (!SignupClient.instance) {
SignupClient.instance = new SignupClient(config);
}
return SignupClient.instance;
}
public async get<T = unknown, R = AxiosResponse<T>>(
url: string,
config?: AxiosRequestConfig
): Promise<R> {
const response = await this.axiosInstance.get<T, R>(url, config);
return response;
}
public async post<T = unknown, R = AxiosResponse<T>, D = unknown>(
url: string,
data?: D,
config?: AxiosRequestConfig<D>
): Promise<R> {
const response = await this.axiosInstance.post<T, R>(url, data, config);
return response;
}
public async put<T = unknown, R = AxiosResponse<T>, D = unknown>(
url: string,
data?: D,
config?: AxiosRequestConfig<D>
): Promise<R> {
const response = await this.axiosInstance.put<T, R>(url, data, config);
return response;
}
public async delete<T = unknown, R = AxiosResponse<T>>(
url: string,
config?: AxiosRequestConfig
): Promise<R> {
const response = await this.axiosInstance.delete<T, R>(url, config);
return response;
}
}
export type { SignupClientConfig };
export { SignupClient };
+74
View File
@@ -0,0 +1,74 @@
import { PROXY_HOST } from "./env";
export const BASE_URL = `${PROXY_HOST}/server`;
export const ENDPOINT = {
ATTACH_SERVICE: {
TUS_UPLOAD: "/attach-service/api/v1/tus/upload",
EDITOR_UPLOAD: "/attach-service/api/v1/upload/editor",
IMAGES: "/attach-service/api/v1/images",
MOVIE_THUMBNAIL: "/attach-service/api/v1/movie/thumbnail",
ATTACHMENTS: "/attach-service/api/v1/atfl/attachments",
DOWNLOAD: "/attach-service/api/v1/atflcd/download",
STREAM_VIDEO: "/attach-service/api/v1/stream/video",
},
USER_SERVICE: {
LOGIN: "/user-service/login",
LOGIN_2FA: "/user-service/api/v1/auth/login",
USERS: "/user-service/api/v1/users",
SILENT_REFRESH: "/user-service/api/v1/users/token/refresh",
AUTHORIZATIONS: "/user-service/api/v1/authorizations",
ROLE_AUTHORIZATIONS: "/user-service/api/v1/role-authorizations",
ROLES: "/user-service/api/v1/roles",
LOGINLOG: "/user-service/api/v1/login-logs",
PRIVACYLOG: "/user-service//api/v1/prvc-inq-logs",
OTP_VERIFY: "/user-service/api/v1/auth/2fa/verify",
TWOFA_SETUP: "/user-service/api/v1/auth/2fa/setup",
TWOFA_ENABLE: "/user-service/api/v1/auth/2fa/enable",
USER_JOIN: "/user-service/api/v1/anonymouse/users/join",
},
CMS_SERVICE: {
DASHBOARD: "/cms-service/api/v1/dashboard",
SITES: "/cms-service/api/v1/sites",
IPS: "/cms-service/api/v1/site-ips",
DOMAINS: "/cms-service/api/v1/site-dmns",
POSTINGS: "/cms-service/api/v1/site-pstgs",
CODE: "/cms-service/api/v1/code/cache",
MENUS: "/cms-service/api/v1/menus",
MENU_ROLES: "/cms-service/api/v1/menu-roles",
MENU_AUTHRTS: "/cms-service/api/v1/menu-authrts",
MENU_TEMPORARY_STORAGE: "/cms-service/api/v1/menu-tmpr-strg",
MENU_HISTORY: "/cms-service/api/v1/menu-hstry",
CONTENTS: "/cms-service/api/v1/contents",
CONTENTS_DETAILS: "/cms-service/api/v1/contents-details",
ORGANIZATIONS: "/cms-service/api/v1/ognzs",
USER_ORGANIZATION: "/cms-service/api/v1/user-ognz",
IMAGES: "/cms-service/api/v1/images",
BANNERS: "/cms-service/api/v1/bnrs",
TEMPLATE: "/cms-service/api/v1/tmplt",
TERMS: "/cms-service/api/v1/trms",
LWPO_CODE: "/cms-service/api/v1/lwpo-cds",
UP_CODE: "/cms-service/api/v1/up-codes",
POPUPS: "/cms-service/api/v1/popups",
SCHEDULES: "/cms-service/api/v1/schdls",
SURVEY: "/cms-service/api/v1/srvy",
SURVEY_QUESTION: "/cms-service/api/v1/srvy-qitem",
SURVEY_ARTICLE: "/cms-service/api/v1/srvy-artcl",
ZIPS: "/cms-service/api/v1/zips",
GROUPS: "/cms-service/api/v1/groups",
GROUP_USERS: "/cms-service/api/v1/group-users",
MANAGER_IP: "/cms-service/api/v1/mgrip",
APILOG: "/cms-service/api/v1/api-logs",
SITE_AUTHORITY: "/cms-service/api/v1/site-authrt",
CONNECTION_LOG: "/cms-service/api/v1/cntnlog",
},
BOARD_SERVICE: {
BBS: "/board-service/api/v1/bbs",
BBS_TEMPLATES: "/board-service/api/v1/bbsTmplt",
POSTS: "/board-service/api/v1/posts",
POSTS_COMMENTS: "/board-service/api/v1/posts-cmnts",
POST_EVALUATION: "/board-service/api/v1/pstEvl",
},
};
export const JWT_EXPIRATION_TIME = 1800000;
+11
View File
@@ -0,0 +1,11 @@
export const PORT = process.env.NEXT_PUBLIC_PORT || "3000";
export const PROXY_HOST =
process.env.NEXT_PUBLIC_PROXY_HOST || `http://localhost:${PORT}`;
export const SERVER_API_URL = process.env.NEXT_PUBLIC_SERVER_API_URL;
export const SITE_ID = process.env.NEXT_PUBLIC_SITE_ID || "CS";
export const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
export const ACCESS_TOKEN =
process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token";
export const REFRESH_TOKEN =
process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token";
export const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
+2
View File
@@ -0,0 +1,2 @@
export * from "./api";
export * from "./env";
+29
View File
@@ -0,0 +1,29 @@
import Cookies from "js-cookie";
export function getCookie(name: string): string | undefined {
if (typeof window === "undefined") {
return undefined;
}
return Cookies.get(name);
}
export function setCookie(
name: string,
value: string,
options?: Cookies.CookieAttributes,
): void {
if (typeof window === "undefined") {
return;
}
Cookies.set(name, value, options);
}
export function removeCookie(
name: string,
options?: Cookies.CookieAttributes,
): void {
if (typeof window === "undefined") {
return;
}
Cookies.remove(name, options);
}
+8
View File
@@ -0,0 +1,8 @@
export { getCookie, setCookie, removeCookie } from "./cookie";
export { useMutationWrapper } from "./use-mutation-wrapper";
export { useAlertStore } from "./use-alert-store";
export { useDataTable } from "./use-data-table";
export { useMenuStore } from "./use-menu-store";
export { useUserStore } from "./use-user-store";
export { usePrompt } from "./use-prompt";
export { useSelectPrompt } from "./use-select-prompt";

Some files were not shown because too many files have changed in this diff Show More