Compare commits

...
Author SHA1 Message Date
leedj312 ca9f2da83c 수요정보 등록 수질 입력 부분 validation check 2025-08-01 14:38:38 +09:00
leedj312 e54a0d0ef8 수요신청현황 이상/이하 작업 완료 2025-08-01 11:37:23 +09:00
leedj312 82e29ec4e2 Merge commit '6808430c9ae74890d0f0e84f20a0ffe5409de157' into iipbs/dm/dev 2025-08-01 09:43:41 +09:00
jyha00 96a50e4f5a 수요처 로그인 비밀번호 초기화 요청 cursor-pointer 추가 2025-07-31 20:01:25 +09:00
jyha00 96448b6427 수요처 로그인 시 비밀번호 초기화 요청 추가 2025-07-31 17:50:05 +09:00
swcho 6808430c9a feat: UITable 컴포넌트의 index props를 통해 외부에서 내부 select idx를 제어할 수 있도록 변경 2025-07-31 16:09:36 +09:00
leedj312 0ef6aea502 Merge commit '33ad044ed34fe78bfe832c09c1128385a3fc285e' into iipbs/dm/dev 2025-07-31 15:42:36 +09:00
jyha00 33ad044ed3 대민 수정사항 반영 2025-07-31 15:01:36 +09:00
leedj312 c94b33b1b8 대민 신청현황 매칭된 공급처 보이도록 수정 2025-07-31 11:05:05 +09:00
leedj312 886561535e 대민 수요신청 정보 변경 2025-07-30 17:57:03 +09:00
Your Name dad8b9925e monthpicker enableMonth string[] 파라미터 추가
(선택가능한 월 배열 추가하면됨 ex.['4월', '5월'])
2025-07-28 11:12:27 +09:00
swcho 07c7947da1 fix: gis.layer.js 하수펌프장 CQL 필터 적용 안되는 에러 수정 2025-07-28 10:51:37 +09:00
swcho 35e1203168 fix: monthPicker.tsx 현재 월에 hover 효과가 지속되는 코드 주석 처리 2025-07-28 10:34:35 +09:00
leedj312 ebde6d433a 대민 수요처 등록후 매인으로 이동 2025-07-28 10:26:40 +09:00
yschoi 556d3f33b6 Merge branch 'iipbs/dm/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/dm/dev 2025-07-28 10:19:05 +09:00
yschoi f826f0fb3f env 2025-07-28 10:18:49 +09:00
leedj312 11010099b4 저장시 원래 화면으로 이동 2025-07-28 10:11:55 +09:00
yschoi 17886b0f9d Merge branch 'iipbs/dm/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/dm/dev 2025-07-28 09:46:53 +09:00
yschoi 46c166a87c 개발 서버 2025-07-28 09:46:00 +09:00
yschoi 5b96245e7d 빌드오류 수정 2025-07-28 09:25:54 +09:00
yschoi 6ba78d0923 빌드 오류 수정 2025-07-28 08:56:16 +09:00
yschoi 15b6520ee1 if (typeof window !== "undefined") {
tokenManager.setToken("site-id-key", 'DM');
      tokenManager.setToken("site-theme-key", 'green');
    }
2025-07-28 08:04:58 +09:00
yschoi ed53ad249c 후보지 선택 버튼 삭제 2025-07-28 07:52:40 +09:00
yschoi b67d1261ff 로컬 환경변수 2025-07-28 07:50:19 +09:00
yschoi cfde5d695f 환경변수 2025-07-28 07:35:02 +09:00
yschoi 13ce985fe5 1 2025-07-28 06:38:50 +09:00
yschoi 3e634fa575 개발 환경변수 수정 2025-07-28 06:29:44 +09:00
yschoi 16b204f83b 후보지 선택 2025-07-28 04:38:09 +09:00
mind-one005 bec3132417 SelectBox labelClass props 추가 2025-07-27 17:13:43 +09:00
Your Name e7a46ac3a2 년월 캘린더 년 readonly 옵션추가 (not necessary) 2025-07-27 14:43:42 +09:00
yschoi 0f9d7a115f Merge remote-tracking branch 'origin/main' into iipbs/dm/dev 2025-07-27 14:29:31 +09:00
yschoi 05802896a8 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2025-07-27 14:27:32 +09:00
yschoi a44e8722c6 gis 수정 2025-07-27 14:27:23 +09:00
swcho 690e671a25 Merge remote-tracking branch 'origin/iipbs/sn/ii' into iipbs/sn/dev 2025-07-27 14:02:14 +09:00
swcho 46b98b99e2 fix: 운영정보 검색 결과 창에서 설치일자 형식 수정 2025-07-27 13:47:18 +09:00
swcho 9e339cd451 fix, feat:
- 빗물펌프 자체 입력 정보 '처리구역', '처리분구' -> '배수구역', '배수분구'으로 수정
- 운영정보 검색 화면에 일련번호 추가
2025-07-27 13:41:00 +09:00
leedj312 95085cbdeb 초기화 함수만 추가 2025-07-27 08:34:38 +09:00
yschoi c1b0f32609 화면 크기 조정 2025-07-27 00:19:12 +09:00
yschoi f9b328278a Merge remote-tracking branch 'origin/iipbs/wr/dev' 2025-07-27 00:06:24 +09:00
yschoi 8a5b9ba2c3 gis 목록 에서 피처 선택시 오류 수정 2025-07-27 00:00:54 +09:00
yschoi 763e0ff5d8 Merge remote-tracking branch 'origin/iipbs/wr/dev' 2025-07-26 20:57:02 +09:00
yschoi 93e8ec1df3 시설물 bbox 전략 수정 2025-07-26 20:51:56 +09:00
leedj312 f087e55c3c 중복되는 스토어 함수 2025-07-26 20:14:37 +09:00
yschoi 53b3cc9817 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2025-07-26 20:09:49 +09:00
yschoi b2bbdb8ad4 검색 화면 화면 조정 2025-07-26 20:09:41 +09:00
leedj312 417a24bb16 Merge commit '6fb39d638495b9510302ffd819cbd8ecd7a16dab' into iipbs/dm/dev 2025-07-26 19:40:20 +09:00
leedj312 72c1c01e47 Merge commit '6fb39d638495b9510302ffd819cbd8ecd7a16dab' into iipbs/wr/dev 2025-07-26 19:27:04 +09:00
leedj312 aea6924015 Merge commit '4be7354b36082993c69f3fd4690cb94eb1c3ed29' into iipbs/wr/dev 2025-07-26 19:26:04 +09:00
jyha00 6fb39d6384 UiTable index 수정 2025-07-26 19:25:19 +09:00
jyha00 4be7354b36 시설명 input 변경 2025-07-26 19:24:38 +09:00
mind-one005 ab23e4ba2c 하수관로 자체저장정보 .. 2025-07-26 18:55:14 +09:00
leedj312 e688a703ca Merge commit '49961db9a9904476a396f6efdfcbb092f7f111cf' into iipbs/wr/dev 2025-07-26 18:17:04 +09:00
jyha00 49961db9a9 차트별 조회된 데이터 없을 시 안내문구 추가 2025-07-26 18:16:46 +09:00
leedj312 56f8d3700a Merge commit 'a09e7fc709995b7ce29058d45dec4a614671d145' into iipbs/wr/dev 2025-07-26 17:32:14 +09:00
leedj312 c68e1d017e dm 현황 수정 및 env 수정 2025-07-26 17:31:32 +09:00
jyha00 a09e7fc709 재이용실적 현황 테이블 데이터 오류 수정 2025-07-26 17:20:54 +09:00
leedj312 024f25f003 수요처 검토 및 env 수정 2025-07-26 17:01:44 +09:00
mind-one002 9d101666ab main 머지 이슈로 수정. 2025-07-26 16:46:19 +09:00
mind-one002 f5ae14de9e Merge remote-tracking branch 'origin/iipbs/wr/prfSttn' into iipbs/wr/dev 2025-07-26 16:42:59 +09:00
jcchoi 4917084bbf Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-07-26 16:07:26 +09:00
jcchoi 5c7a8df55f currentPath 추가 2025-07-26 16:07:19 +09:00
yschoi 8656f6fb10 dm 추가 2025-07-26 16:04:47 +09:00
yschoi 4f4d823c2a Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-07-26 16:00:31 +09:00
yschoi 1d77247bcd Merge remote-tracking branch 'origin/iipbs/dm/dev' 2025-07-26 15:59:51 +09:00
jyha00 fe6293eb28 Merge branch 'iipbs/common/main' into iipbs/wr/prfSttn 2025-07-26 15:59:08 +09:00
jyha00 0ad7d6a2c4 searchBaseForm 버튼 순서 변경 파라미터 추가 2025-07-26 15:58:47 +09:00
jyha00 662fb390bc 재이용실적 현황 데이터 조회 수정 2025-07-26 15:55:37 +09:00
yschoi c037be3456 Merge remote-tracking branch 'origin/iipbs/sn/dev' 2025-07-26 15:55:31 +09:00
yschoi 4b54b798a3 Merge remote-tracking branch 'origin/iipbs/fo/dev' 2025-07-26 15:53:15 +09:00
leedj312 63cfc16ba5 대민 변경사항 수정 사항 반영완료 2025-07-26 15:39:07 +09:00
yschoi b28edb5a83 지도 스타일 수정 2025-07-26 15:36:02 +09:00
yschoi 719052d777 Merge remote-tracking branch 'origin/main' into iipbs/sn/dev 2025-07-26 15:34:39 +09:00
mind-one002 22d71c30f9 Merge branch 'iipbs/wr/dev' 2025-07-26 15:15:01 +09:00
mind-one005 562a562d15 Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-07-26 15:14:03 +09:00
mind-one005 f94cb832a0 Merge branch 'iipbs/common/main' into iipbs/sn/dev 2025-07-26 15:04:05 +09:00
mind-one005 8fbecc41a5 LineBarChart2 비율대로 보이도록 2025-07-26 15:03:09 +09:00
leedj312 87be84f620 실적등록 수정 2025-07-26 13:23:11 +09:00
leedj312 7661823c8b 스토어 아이디 env 프로덕션 수정 2025-07-26 10:30:00 +09:00
rsone001 2aba7bb16e ESlint 문법 에러 수정 2025-07-25 22:49:06 +09:00
mind-one005 735304cee0 Merge branch 'iipbs/sn/dev' 2025-07-25 20:43:15 +09:00
mind-one005 1ca2900a63 조회click시 paging초기화 2025-07-25 20:41:38 +09:00
100JM 53c966fc11 Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-07-25 20:09:05 +09:00
Your Name 9da2e0054c 대시보드 수정 - 지하수유입량파트 2025-07-25 19:55:54 +09:00
chanii8 03efe1dfb5 Merge branch 'iipbs_cs' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-07-25 19:52:17 +09:00
chanii8 4ee840fa82 chore: pnpm-lock 업데이트 2025-07-25 18:56:54 +09:00
swcho 64a8ebbdbb Merge branch 'iipbs/sn/ii' into iipbs/sn/dev 2025-07-25 18:50:37 +09:00
swcho e8da4ac531 fix: 하수관망도, I/I 분석, 운영정보 페이지 페이지네이션 에러 수정 2025-07-25 18:50:12 +09:00
leedj312 bf77775cd6 Merge commit '711adc2e170ddc84dc81638ff6b85e8aa416df43' into iipbs/wr/dev 2025-07-25 18:47:15 +09:00
leedj312 711adc2e17 폼 오류 수정 2025-07-25 18:45:31 +09:00
chanii8 9a62ef6276 [apps/cs] chore: 숫자에 toLocaleString() 메서드 적용 2025-07-25 18:44:30 +09:00
JIeunhuh 552ae18feb Merge commit '4920b4c1a26d81c9078e22c49bff1adda3eb834f' into iipbs/wr/dev 2025-07-25 18:39:31 +09:00
chanii8 bd999eb0f3 [apps/cs] chore: 수정 요청 사항 반영 2025-07-25 18:38:56 +09:00
JIeunhuh bd0c22c129 Merge commit 'd389390b0c25db4a9b229a2d76e99ff922d951c6' into iipbs/wr/dev 2025-07-25 18:37:36 +09:00
JIeunhuh 4920b4c1a2 수요처 추가 2025-07-25 18:35:57 +09:00
Your Name 83cb4ec7fa 빌드에러 수정 2025-07-25 18:34:03 +09:00
swcho ed4279d321 fix: SearchModal 페이지네이션 초기화 2025-07-25 18:31:09 +09:00
Your Name c43b5521ae 대시보드 계획정보 최신날짜의 계획정보 불러오도록 수정 2025-07-25 18:30:37 +09:00
JIeunhuh d389390b0c 수요처 상세, 리스트 현황 구현 완료 2025-07-25 18:30:14 +09:00
JIeunhuh 60ae8c9e72 수요처 작업중 2025-07-25 18:08:28 +09:00
mind-one003 b543ff6334 build 오류 해결. 2025-07-25 05:06:47 -04:00
mind-one003 f744957447 지하수 유입량 모달 부분 추가. 2025-07-25 05:03:08 -04:00
swcho e3de7ec112 Merge branch 'iipbs/sn/ii' into iipbs/sn/dev 2025-07-25 18:02:18 +09:00
swcho 61ed66d4f0 feat: SearchModal 데이터 포매팅 2025-07-25 18:00:05 +09:00
100JM 393e174a6d 연계 모듈 관리 2025-07-25 17:39:13 +09:00
leedj312 c4f2af0bfe rgtr nullish 작업 2025-07-25 16:58:52 +09:00
mind-one005 294da35347 오류 표시 수정 2025-07-25 16:32:13 +09:00
yschoi 44103a8dc4 미니맵 지도 기능 제거 2025-07-25 16:15:53 +09:00
Your Name ea36df1c29 조회버튼 기능완료, 초기화 시 수영으로 초기화 완료 2025-07-25 15:49:06 +09:00
leedj312 814dac672c Merge commit '5ce1c571ed75c09675361421ac9947412ac8e57c' into iipbs/dm/dev 2025-07-25 15:47:11 +09:00
leedj312 88838dda0b 수요처 저장 함수 추가 2025-07-25 15:46:34 +09:00
yschoi 5ce1c571ed 후보지 추가 지도 진행시켜~~~ 2 2025-07-25 15:43:57 +09:00
yschoi f2881f3a3a 후보지 추가 지도 진행시켜~~~ 2025-07-25 15:29:20 +09:00
chanii8 78eb2d8fcd [apps/cs] chore: 이미지 등록 폼 수정 2025-07-25 15:11:41 +09:00
swcho 89ca295b69 fix:
- 침입수, RDII 분석결과 페이지 페이징 처리, 삭제 후 조회 로직 에러 수정
2025-07-25 15:08:39 +09:00
mind-one005 9a3378d07b 반올림 & , 적용 2025-07-25 15:02:04 +09:00
leedj312 c0eb6b94cc 가몸관련 수정 완료 2025-07-25 14:46:28 +09:00
Your Name 12f81754a0 Merge branch 'iipbs/common/main' into iipbs/fo/dev 2025-07-25 14:31:45 +09:00
Your Name 190adb6bc1 테이블 헤더 위치 좀더 위로 수정 2025-07-25 14:30:59 +09:00
chanii8 d7233992fe [apps/cs] feat: 접속자 통계 페이지 레이아웃 작업 2025-07-25 14:30:33 +09:00
leedj312 c29d191a5e 계산식에서 *365 빼기 2025-07-25 14:27:15 +09:00
swcho b980321557 fix:
- 운영정보 검색 한 페이지에 4개씩으로 변경 (화면 비율 고려)
- 상세페이지 X 버튼 위치 고정
2025-07-25 14:24:24 +09:00
leedj312 3a37192782 Merge remote-tracking branch 'origin/iipbs/wr/login-test' into iipbs/wr/dev 2025-07-25 14:07:37 +09:00
leedj312 f3ca55d183 빌드 수정 2025-07-25 14:07:25 +09:00
yschoi 6955164f71 Merge branch 'iipbs/dm/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/dm/dev 2025-07-25 14:07:09 +09:00
yschoi d20bfb1bf1 후보지 선택 지도 작업중 2025-07-25 14:07:00 +09:00
Your Name fa8db521d5 Merge branch 'iipbs/common/main' into iipbs/fo/dev 2025-07-25 14:04:36 +09:00
Your Name 59bccfe1d6 테이블 및 굵은영역 뒷쪽에 글자보이는 문제해결 2025-07-25 14:03:52 +09:00
leedj312 506eb78a80 Merge commit '32b16055fb34e758084b993b8e786140048f5645' into iipbs/wr/dev 2025-07-25 13:45:47 +09:00
leedj312 ce0ba4ff40 버튼 추가 2025-07-25 13:40:25 +09:00
JIeunhuh 42795e517b x축 스크롤 제거 2025-07-25 13:06:19 +09:00
swcho 590adb5b4e fix: 오수펌프장, 우수펌프장 명칭 바뀐 것 수정 2025-07-25 11:42:51 +09:00
mind-one005 c204d90d14 rdii 그래프 수정 2025-07-25 11:27:57 +09:00
JIeunhuh e9bfaa865a 지도 팝업 사이즈 조정 및 상세클릭시에 화면 가운데로 오도록 조정 2025-07-25 11:12:07 +09:00
mind-one005 e0bc5869d6 Merge branch 'iipbs/common/main' into iipbs/sn/dev 2025-07-25 10:54:00 +09:00
mind-one005 84ff24c5b5 LineBarChart2 추가 2025-07-25 10:52:47 +09:00
mind-one005 b8da4d7d42 Merge branch 'iipbs/common/main' into iipbs/sn/dev 2025-07-25 10:45:01 +09:00
mind-one005 e1b48ab7c8 Merge branch 'iipbs/common/main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/common/main 2025-07-25 10:44:19 +09:00
leedj312 70f250f7bb Merge commit 'd465719c6f53de5c5b27ccfe09f643db502f7f7b' into iipbs/dm/dev 2025-07-25 10:42:58 +09:00
mind-one005 4c3be448f0 LineBarChar2 추가 2025-07-25 10:42:36 +09:00
jyha00 8ede32f4c4 수요처폼 글씨 크기 변경 2025-07-25 10:41:41 +09:00
jyha00 d465719c6f 대민 화면 수정 2025-07-25 10:40:08 +09:00
leedj312 bc3049c255 url 수정 2025-07-25 10:22:47 +09:00
Your Name 8624b36e89 Merge branch 'iipbs/common/main' into iipbs/fo/dev 2025-07-25 10:14:39 +09:00
leedj312 e4b1c78102 Merge commit '6fcc97caf18a9ce628e18503eb89ece739dc8890' into iipbs/dm/dev 2025-07-25 10:06:28 +09:00
swcho be47ac0edb fix:
- 하수처리시설 정보에서 디지털 자료관 연계 정보 기능 주석 처리
- 운영정보 검색 시 페이지 리셋 추가
- 하수관로 연계정보에서 사업명 줄바꿈 처리
2025-07-25 10:06:19 +09:00
leedj312 6fcc97caf1 초기화면 으로 이동 수정 토큰 적용 2025-07-25 10:04:58 +09:00
yschoi 6bdef28532 후보지 조회 지도 팝업 생성 2025-07-25 10:03:28 +09:00
Your Name 314e6ed9ad 조치진행중
- 강우량 숨김
- 수질조회 2023년으로 세팅
2025-07-25 09:57:47 +09:00
Your Name 99ffa1d6d4 대시보드 2021년 부산 수영으로 default 세팅 2025-07-25 09:38:50 +09:00
mind-one002 d23c9ca4b1 2023년도 기준으로 재 수정. 2025-07-25 04:53:53 +09:00
mind-one002 cc67d707d9 default 값이 2023으로 우선 고정 처리. 2025-07-25 04:49:18 +09:00
mind-one002 583559ec60 오수발생량에 기준년도 2023으로 수정. 2025-07-25 04:35:24 +09:00
mind-one002 e7195f7db0 오수발생량 나오도록 수정. 2025-07-25 04:26:17 +09:00
mind-one002 04bb3657bb idx 불필요로 삭제. 2025-07-25 04:16:27 +09:00
mind-one002 f1563aa1fc 하수사용량 수치 오류 부분 수정. 2025-07-25 04:03:03 +09:00
swcho 0795299b48 feat: 상류관로 보기 기능에 로딩바 추가 2025-07-25 00:58:55 +09:00
swcho 3380addd78 Merge branch 'iipbs/common/main' into iipbs/sn/ii 2025-07-25 00:50:40 +09:00
swcho 2862fdc9fd fix: Gis.js fetch 로직 에러 수정 2025-07-25 00:50:03 +09:00
swcho c9e413a3d3 Merge branch 'iipbs/sn/dev' into iipbs/sn/ii 2025-07-25 00:28:27 +09:00
swcho 6413bc6b18 feat: featureClickEventPopup 하수맨홀 선택 시 상류관로 보기 기능 구현 2025-07-25 00:28:01 +09:00
swcho fbb47c2593 Merge branch 'iipbs/common/main' into iipbs/sn/ii 2025-07-25 00:25:24 +09:00
swcho a446739432 feat:
- gis.select.js 피처 다중 셀렉트 기능 추가
- Gis.js 상류관로 조회 메서드 추가
- GisLonLat 주석 처리(위경도 표시X)
2025-07-25 00:23:02 +09:00
truthwing ec7655a71d Merge commit 'c8d2c4e17ed3b3f42ac32860f8851e5f1e472387' into iipbs/fo/dev 2025-07-24 23:38:48 +09:00
jsyang c8d2c4e17e 조치 진행 중, pnpm build any제외 작업진행 2025-07-24 10:17:58 -04:00
mind-one005 b513fddbd5 LineBarchart 1차 2025-07-24 22:13:37 +09:00
mind-one005 c0c7bdfa5b Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2025-07-24 22:06:34 +09:00
mind-one005 55b8e91298 Merge branch 'iipbs/common/main' into iipbs/sn/dev 2025-07-24 22:04:25 +09:00
mind-one005 ea125605d1 Merge branch 'iipbs/common/main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/common/main 2025-07-24 22:01:01 +09:00
mind-one005 8353800dac feat: LineBarChar.tsx Null 체크 추가 2025-07-24 22:00:58 +09:00
Your Name cd08b5dad8 조치 진행중 2025-07-24 21:58:50 +09:00
yschoi 3a73c8bf1c lock 삭제 2025-07-24 21:21:10 +09:00
yschoi 4389a6ddb7 Merge remote-tracking branch 'origin/main' into iipbs/dm/dev 2025-07-24 21:20:28 +09:00
yschoi 8a2ed7df58 하수처리시설 팝업 사이즈 조정 2025-07-24 21:16:26 +09:00
yschoi 00d6ff90c6 Merge remote-tracking branch 'origin/main' into iipbs/wr/dev 2025-07-24 20:56:28 +09:00
yschoi 9bfd32b5c2 Merge remote-tracking branch 'origin/main' into iipbs/sn/dev 2025-07-24 20:51:46 +09:00
yschoi f57dcc9788 Merge branch 'iipbs/dm/map' into iipbs/dm/dev 2025-07-24 20:50:38 +09:00
yschoi f7849f317c Merge branch 'iipbs/dm/map' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/dm/map 2025-07-24 20:47:42 +09:00
yschoi 7cd1ae2625 gis 이미지 추가 2025-07-24 20:47:35 +09:00
swcho 159fad40ef Merge branch 'iipbs/sn/ii' into iipbs/sn/dev 2025-07-24 20:39:04 +09:00
swcho 4aa4089ddd fix: featureClickEventPopup.tsx 클릭 이벤트 신에 관리번호 안 나오는 에러 수정 2025-07-24 20:38:28 +09:00
swcho f6cb75ff1c Merge remote-tracking branch 'origin/iipbs/sn/ii' into iipbs/sn/dev 2025-07-24 20:06:18 +09:00
swcho 61e4643503 Merge remote-tracking branch 'origin/iipbs/common/main' into iipbs/sn/ii 2025-07-24 20:01:59 +09:00
swcho 6492ad5500 fix: Gis.js featureId 찾는 로직 수정 2025-07-24 20:01:41 +09:00
swcho 8ed0a86cf5 fix: SearchModal 검색 로직 에러 수정 (상세 정보 표시, zoom 이동은 아직) 2025-07-24 19:58:20 +09:00
leedj312 52170fc12f Merge commit '4c8ecb05e094965df93dcf474bffa79c5135e61e' into iipbs/dm/dev 2025-07-24 19:57:10 +09:00
leedj312 4c8ecb05e0 서치바 컴포넌트 추가 완료 2025-07-24 19:51:48 +09:00
jyha00 9e95ad3fad Merge branch 'iipbs/common/main' into iipbs/dm/design 2025-07-24 19:41:31 +09:00
jyha00 523925459c 바차트 포매터 적용 2025-07-24 19:28:20 +09:00
yschoi a11f760162 gis 기능 수정 2025-07-24 19:08:14 +09:00
chanii8 e35b73d067 Merge branch 'iipbs_cs' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-07-24 18:16:00 +09:00
chanii8 2620e40872 [common/components] feat: Header, LNB 컴포넌트 비로그인 상태 분기처리 2025-07-24 17:57:46 +09:00
jyha00 79bc10cdce 대민 디자인 수정 2025-07-24 17:37:46 +09:00
Your Name 5a43b33a1d 강우량 및 단위 수정 2025-07-24 15:08:07 +09:00
leedj312 c9f1d05126 서치바 컴포넌트 일단 추가 2025-07-24 15:01:29 +09:00
leedj312 19bee65195 메인페이지에서 지도팝업(버튼) 추가 2025-07-24 15:01:08 +09:00
leedj312 26c01ede4e 대민 지도 팝업 페이지 생성 2025-07-24 14:51:04 +09:00
leedj312 c5f4f6ddd0 물재이용 화면 건축물 재이용여부 수정 2025-07-24 14:09:43 +09:00
leedj312 f332d64aa6 물재이용 화면별 등록/수정 권한 추가 2025-07-24 14:06:20 +09:00
yschoi bb4d7b9d56 물재이용시설 팝업 2025-07-24 13:38:40 +09:00
swcho f6cefd6230 fix: API 조회 시 빈 데이터일 경우 에러 처리 2025-07-24 13:22:21 +09:00
swcho c6b45499c4 fix: SearchModal 컴포넌트 내의 SelectBox들 z-index 수정 2025-07-24 13:11:45 +09:00
swcho 30bbf51e77 refactor: basicTab 구분 별로 UI 컴포넌트 분리 2025-07-24 13:01:11 +09:00
yschoi bb6dbfa617 Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-07-24 11:13:44 +09:00
swcho 1778c07ef9 refactor: searchModal '하수도정비기본계획보고서' 탭 UI 컴포넌트 분리 2025-07-24 11:12:12 +09:00
yschoi 075e3711a9 운영 설정 추가 2025-07-24 11:08:46 +09:00
jyha00 32b16055fb 하수재이용시설 현황 모달 오류 수정 및 필요없는 주석 삭제 2025-07-24 11:05:19 +09:00
jyha00 1daa925019 잠재수요처 권한 추가 2025-07-24 11:04:41 +09:00
chanii8 f58f9c3fe3 Merge branch 'iipbs_cs' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-07-24 10:54:18 +09:00
chanii8 af98524806 [apps/cs] feat: 대시보드 페이지 작업 2025-07-24 10:53:48 +09:00
chanii8 ecf430dd33 Merge branch 'iipbs_cs' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-07-24 10:03:32 +09:00
chanii8 892e329207 chore: lock 파일 업데이트 2025-07-24 10:03:24 +09:00
leedj312 543fbb70cc 로그인 아이디 변경 2025-07-24 09:33:52 +09:00
swcho 3a191e8d06 refactor: searchModal '운영정보' 탭 UI 컴포넌트 분리 2025-07-24 09:10:30 +09:00
swcho 402b9a5539 refactor: searchModal '기본정보' 탭 UI 컴포넌트 분리 2025-07-23 19:33:19 +09:00
swcho ef80915519 refactor: SearchModal 한글 변수명 수정 2025-07-23 19:11:17 +09:00
swcho 2e83898777 feat: SearchModal 한글 변수명 수정 2025-07-23 18:58:16 +09:00
swcho 3d988b4644 feat: SelectBox2 컴포넌트 -> SelectBox 컴포넌트로 변경 2025-07-23 18:13:11 +09:00
jyha00 bcdefaa828 재이용실적 등록, 지도점검 등록 권한 설정 추가 2025-07-23 17:58:07 +09:00
leedj312 0d48a0b579 페이지별 이벤트 권한체크, 시설 등록,현황, 수요처 관리 까지 완료 2025-07-23 17:51:47 +09:00
swcho c59c17f9f0 Merge branch 'iipbs/common/main' into iipbs/sn/ii 2025-07-23 17:45:54 +09:00
swcho d5c967e9ad feat, remove:
- SelectBox 컴포넌트에 selectContentClass props 추가 (드롭다운에 z index 값을 추가 하기 위함)
- SelectBox2 컴포넌트 삭제
2025-07-23 17:45:06 +09:00
chanii8 55793d8463 [common/components] refactor: 메뉴 활성화 조건 변경(menuCd) 2025-07-23 16:46:47 +09:00
Your Name c4750d9652 수요처명 입력오류 수정 2025-07-23 13:48:38 +09:00
leedj312 fc3ef2c59e Merge branch 'iipbs/wr/dev' into iipbs/wr/login-test 2025-07-23 13:16:03 +09:00
jyha00 accc3e0ef1 빗물 엑셀 다운로드 데이터 오류 수정 2025-07-23 13:13:33 +09:00
swcho ba657e8311 Merge branch 'iipbs/sn/dev' 2025-07-23 12:15:19 +09:00
swcho 53fef3bdbf fix: 운영정보 조회 사업명 Input 컴포넌트 숫자만 입력되는 에러 수정 2025-07-23 12:15:06 +09:00
swcho 21afb058cf Merge branch 'iipbs/sn/dev' 2025-07-23 12:10:56 +09:00
swcho 5232703dd9 fix: fetchFilteredData bizTyCd 조건문 수정 2025-07-23 12:08:00 +09:00
swcho 406ce4056a feat, fix:
- 운영정보 업무구분으로 조회 기능 구현
- 용도, 재질 코드 -> 이름으로 수정
- DetailTable 페이지네이션 수정
2025-07-23 12:01:41 +09:00
truthwing 21e0ea086e Merge commit 'f50a9aaf3a41fb542c18f6f655ee5adeb69b4258' 2025-07-23 10:46:32 +09:00
YangJinSoo94 142929510f 포트변경 2025-07-23 10:16:11 +09:00
YangJinSoo94 f50a9aaf3a 임시로 개발서버로 바라보고 수정 2025-07-23 10:08:47 +09:00
YangJinSoo94 60e73212e8 hostm port 변경경 2025-07-23 09:19:15 +09:00
swcho 6660c85150 Merge remote-tracking branch 'origin/iipbs/sn/dev' 2025-07-22 22:24:16 +09:00
swcho 2cfd57fdc3 fix: 하수관로 상세페이지 useEffect 의존성 배열 수정 2025-07-22 22:23:00 +09:00
swcho 5e5411a34f Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2025-07-22 22:19:09 +09:00
swcho 01852c1528 refactor:
- MsSwagePpl.tsx 리팩토링
- index.ts Merge
2025-07-22 22:16:34 +09:00
mind-one005 4488bd23c7 Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-07-22 21:49:36 +09:00
mind-one005 c2696c6eeb Merge branch 'iipbs/sn/dev' 2025-07-22 21:49:21 +09:00
mind-one005 4ef86a3e6a 기본계획 download 수정 2025-07-22 21:48:37 +09:00
truthwing badea83e9e Merge commit '268c45ed05efa45f966d33556a6ea974472b4b81' 2025-07-22 21:26:32 +09:00
truthwing fc2f119584 Merge commit '4e2fe7aaa1433c22886685d802662872df6e3c11' 2025-07-22 21:26:21 +09:00
truthwing 23a92ec38c Merge commit 'fe2f3faee4bc2250684b494c14c692aa45f08b28' 2025-07-22 21:25:58 +09:00
truthwing 268c45ed05 jsyang product 2025-07-22 21:24:43 +09:00
truthwing fe2f3faee4 js cookie 재설치 2025-07-22 21:15:33 +09:00
swcho 3e5a14fbc8 Merge branch 'iipbs/sn/ii' into iipbs/sn/dev 2025-07-22 21:08:17 +09:00
truthwing e891702da2 build 오류 해결 jscookies 문제 확인 필요 2025-07-22 21:06:50 +09:00
swcho 527b4293c4 feat, refactor:
- 하수관로 상세 페이지  운영정보 연계 기능 구현
- 운영정보 탭 검색 기능 구현
- UITab2, UITable2 삭제 후 공통 컴포넌트 적용 (UITab, UITable)
- SearchModal.tsx UI 수정
- featureClickEvent에 isClicked state 추가
2025-07-22 21:06:09 +09:00
leedj312 4e2fe7aaa1 Merge commit '0a109111cbf2cae77a348bddb2116a56950852e8' into iipbs/wr/dev 2025-07-22 20:58:02 +09:00
jyha00 0a109111cb 중수도통계 상세 엑셀 다운로드 수정 2025-07-22 20:55:14 +09:00
jyha00 99229a8453 불필요한 출력창 삭제 2025-07-22 20:41:37 +09:00
jyha00 383e5f8382 중수도통계 테이블, 엑셀 수정 2025-07-22 20:38:42 +09:00
mind-one005 a11570b563 Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend 2025-07-22 20:17:58 +09:00
mind-one005 af1feb3920 Merge branch 'iipbs/sn/dev' 2025-07-22 20:17:52 +09:00
mind-one005 a6df8d261a RSON 제거 2025-07-22 20:15:14 +09:00
truthwing f96942beee LOCAL_URL 경로 오류로 수정. 2025-07-22 20:08:32 +09:00
truthwing 7994698349 js-cookie 추가. 2025-07-22 19:48:04 +09:00
swcho ab495c6572 Merge remote-tracking branch 'origin/iipbs/common/main' into iipbs/sn/ii 2025-07-22 16:30:23 +09:00
swcho 804688b3c1 remove: UITable2 컴포넌트 삭제 2025-07-22 16:28:48 +09:00
swcho 47c986d517 Merge remote-tracking branch 'origin/iipbs/common/main' into iipbs/sn/ii 2025-07-22 16:17:45 +09:00
swcho 8435096e5a remove: UITab2 컴포넌트 삭제 2025-07-22 16:16:42 +09:00
YangJinSoo94 425eb39a86 middleware 작업진행중 2025-07-22 16:01:48 +09:00
leedj312 e30a7d184b 권한 체크 작업중 작업해야함 2025-07-22 15:57:20 +09:00
YangJinSoo94 1ec1c42e9f middleware 작업진행중중 2025-07-22 15:43:54 +09:00
swcho 9565931042 Merge remote-tracking branch 'origin/iipbs/common/main' into iipbs/sn/ii 2025-07-22 15:28:36 +09:00
yschoi 1042391702 Merge remote-tracking branch 'origin/iipbs/dm/dev' 2025-07-22 15:12:49 +09:00
yschoi 68f468fa2c output: "standalone", 추가 2025-07-22 15:12:06 +09:00
YangJinSoo94 fb9f2a9d4a middleware 작업진행중중 2025-07-22 15:03:24 +09:00
yschoi 7c2ff7c037 build": "turbo run build 2025-07-22 14:48:33 +09:00
yschoi 0bbb27e3d3 불필요 삭제 2025-07-22 13:58:24 +09:00
YangJinSoo94 d641866cc4 -대시보드 메뉴 및 로그인 기능완료, 권한 완료
-대시보드 운영현황 데이터 수정, 시설명 div z-index 수정정
2025-07-22 13:21:39 +09:00
truthwing e4de937f6e Merge commit 'baabc72f5840bc882c1d7178129707a9ca1ea2ae' 2025-07-22 10:15:36 +09:00
chanii8 ba6d7f4177 [apps/cs] chore: 메뉴 삭제 시 폼 초기화 2025-07-22 10:13:53 +09:00
chanii8 56e9218646 [apps/cs] refactor: 기간 선택 컴포넌트 수정 2025-07-21 21:46:11 +09:00
chanii8 2d5534b3a6 [common/components] feat: InputDatePicker 컴포넌트 추가 2025-07-21 21:45:34 +09:00
mind-one005 05331db008 Merge branch 'iipbs/sn/dev' 2025-07-21 21:21:27 +09:00
mind-one005 f0cb735d69 그래프 명 수정 2025-07-21 21:05:44 +09:00
yschoi 588e4984b3 잘못 머지 된듯 2025-07-21 15:34:08 +09:00
YangJinSoo94 baabc72f58 빌드에러수정 2025-07-21 15:29:08 +09:00
yschoi 1dab522bd2 운영 env 추가 2025-07-21 15:17:15 +09:00
YangJinSoo94 7a24c10f3d 이미지 수정, 빌드 오류 수정 2025-07-21 14:44:01 +09:00
YangJinSoo94 7525fa01b6 dm merge 2025-07-21 13:44:28 +09:00
chanii8 6d967497ba refactor: 게시 기간 선택 로직 수정 2025-07-20 23:15:36 +09:00
yschoi 9c69ba2122 output: "standalone", 2025-07-19 19:01:24 +09:00
yschoi 978a049277 개발서버 설정중 2025-07-19 18:49:09 +09:00
yschoi 538065e29e 개발 배포 설정 2025-07-19 18:40:37 +09:00
yschoi 99a8f874e8 test 2025-07-19 18:16:56 +09:00
mind-one005 71dd649624 developement 환경변수 자료관 주소 추가 2025-07-19 15:24:50 +09:00
mind-one005 63a0cc70af development 환경변수 추가 2025-07-19 15:22:33 +09:00
swcho 7816b4e450 Merge remote-tracking branch 'origin/main' into iipbs/sn/ii 2025-07-17 17:27:17 +09:00
swcho 240c2f847c Merge remote-tracking branch 'origin/iipbs_cs' into iipbs/sn/ii 2025-07-17 17:10:34 +09:00
swcho e39a102c14 Merge remote-tracking branch 'origin/iipbs/common/main' into iipbs/sn/ii 2025-07-17 17:08:36 +09:00
475 changed files with 19019 additions and 4230 deletions
-8
View File
@@ -1,8 +0,0 @@
# 디폴트 무시된 파일
/shelf/
/workspace.xml
# 에디터 기반 HTTP 클라이언트 요청
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
View File
@@ -0,0 +1,2 @@
import { LinkPage } from "@/pages/link";
export default LinkPage;
@@ -0,0 +1,10 @@
import { ModifyLinkPage as VModifyLinkPage } from "@/pages/link";
export default async function ModifyLinkPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <VModifyLinkPage linkSn={id} />;
};
@@ -0,0 +1,2 @@
import { RegistLinkPage } from "@/pages/link";
export default RegistLinkPage;
@@ -0,0 +1,2 @@
import { LinkLogPage } from "@/pages/linkLog";
export default LinkLogPage;
@@ -0,0 +1,2 @@
import { LinkParamsPage } from "@/pages/linkParams";
export default LinkParamsPage;
@@ -0,0 +1,2 @@
import { LinkStatsPage } from "@/pages/linkStats";
export default LinkStatsPage;
@@ -0,0 +1,2 @@
import { LinkTablePage } from "@/pages/linkTable";
export default LinkTablePage;
@@ -0,0 +1,2 @@
import { UserStatisticsPage } from "@/pages/user-statistics";
export default UserStatisticsPage;
@@ -0,0 +1,191 @@
import axios from "axios";
import {
type LinkService,
type LinkSearchParam,
type LinkList,
type LinkFormData,
type LinkInfo,
type LinkLogList,
type LinkStatsList,
type LinkVrblList,
type LinkVrblFormData,
type LinkVrbl,
type LinkTable,
type LinkTableColumnsList,
type LinkTableColumns,
type LinkTableColData,
type LinkTableFormData,
} from "../model/link-interface";
export class LinkApiService implements LinkService {
public async getLinkList(params: LinkSearchParam): Promise<LinkList> {
const response = await axios.get<LinkList>(
`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkList`,
{ params }
);
return response.data;
}
public async getLinkInfo(linkSn: string): Promise<LinkInfo> {
const response = await axios.get<LinkInfo>(
`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkInfo/${linkSn}`,
);
return response.data;
}
public async linkQuickStart(linkSn: string): Promise<string> {
const response = await axios.patch<string>(
`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/linkQuickStart/${linkSn}`,
);
return response.data;
}
public async updateLinkInfo(data: LinkFormData): Promise<void> {
await axios.put<void>(
`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/updateLinkInfo`,
data,
);
}
public async insertLinkInfo(data: LinkFormData): Promise<void> {
await axios.post<void>(
`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/insertLinkInfo`,
data,
);
}
public async deleteLink(linkSn: string): Promise<void> {
await axios.delete<void>(
`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/deleteLink/${linkSn}`,
);
}
public async getLinkLogList(params: LinkSearchParam): Promise<LinkLogList> {
const response = await axios.get<LinkLogList>(
`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/selectLinkLoglist`,
{ params }
);
return response.data;
}
public async getLinkStatsList(params: LinkSearchParam): Promise<LinkStatsList> {
const response = await axios.get<LinkStatsList>(
`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/selectLinkStats`,
{ params }
);
return response.data;
}
public async getLinkVrblList(linkSn: string): Promise<LinkVrblList> {
const response = await axios.get<LinkVrblList>(
`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkVrblList/${linkSn}`,
);
return response.data;
}
public async insertLinkVrbl(data: LinkVrblFormData): Promise<number> {
const response = await axios.post<number>(
`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/insertLinkVrbl`,
data,
);
return response.data;
}
public async deleteLinkVrbl(data: string[]): Promise<void> {
await axios.delete<void>(
`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/deleteLinkVrbl`,
{
data
}
);
}
public async getLinkVrblInfo(linkVrblSn: string): Promise<LinkVrbl> {
const response = await axios.get<LinkVrbl>(
`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkVrblInfo/${linkVrblSn}`,
);
return response.data;
}
public async updateLinkVrblInfo(data: LinkVrblFormData): Promise<void> {
await axios.put<void>(
`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/updateLinkVrblInfo`,
data,
);
}
public async getLinkTableInfo(linkSn: string): Promise<LinkTable> {
const response = await axios.get<LinkTable>(
`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkTableInfo/${linkSn}`,
);
return response.data;
}
public async getLinkTableColList(linkSn: string): Promise<LinkTableColumnsList> {
const response = await axios.get<LinkTableColumnsList>(
`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkTableColList/${linkSn}`,
);
return response.data;
}
public async getLinkTableColInfo(params: { colSn: string; tblSn:string; }): Promise<LinkTableColumns> {
const response = await axios.get<LinkTableColumns>(
`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkTableColInfo`,
{ params }
);
return response.data;
}
public async insertLinkTableCol(data: LinkTableColData): Promise<void> {
await axios.post<void>(
`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/insertLinkTableCol`,
data
);
}
public async deleteLinkTableCol(data: string[]): Promise<void> {
await axios.delete<void>(
`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/deleteLinkTableCol`,
{
data
},
);
}
public async updateLinkTableCol(data: LinkTableColData): Promise<void> {
axios.put<void>(
`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/updateLinkTableCol`,
data,
);
}
public async insertLinkTableInfo(data: LinkTableFormData): Promise<void> {
await axios.post<void>(
`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/insertLinkTableInfo`,
data
);
}
public async updateLinkTableInfo(data: LinkTableFormData): Promise<void> {
await axios.put<void>(
`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/updateLinkTableInfo`,
data
);
}
public async countLinkTblNm(tblNm: string): Promise<{count: number, tbl_sn: string}> {
const response = await axios.get<{count: number, tbl_sn: string}>(
`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/countLinkTblNm/${tblNm}`,
);
return response.data;
}
public async updateLinkResultCode(linkSn: string): Promise<void> {
await axios.patch<void>(
`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/updateLinkResultCode/${linkSn}`,
);
}
}
export const LinkApi = new LinkApiService();
+18
View File
@@ -0,0 +1,18 @@
export { LinkApi } from "./api/link-service";
export {
type LinkSearchParam,
type Link,
type LinkFormData,
type LinkLog,
type LinkStats,
type LinkVrbl,
type LinkVrblFormData,
type LinkTable,
type LinkTableColumns,
type LinkTableColData,
type LinkTableFormData,
linkFormSchema,
linkVrblFormSchema,
LinkTableColumnsSchema,
LinkTableSchema,
} from "./model/link-interface"
@@ -0,0 +1,235 @@
import { z } from "zod";
import type { PageableParams, PageableList } from "@/shared/model";
export interface Link {
link_sn: string;
link_nm: string;
link_clsf_cd: string;
link_url: string;
link_srvr_ip: string;
link_db_cd: string;
link_key: string;
link_id: string;
link_pswd: string;
link_cycle_stng: string;
srvc_usg: string;
last_link_rslt_cd: string;
last_link_excn_dt: string;
next_link_excn_dt: string;
last_link_err_cd: string;
mdfcn_dt: string;
mdfr: string;
link_batch_time: string;
}
export interface LinkLog {
rownum: number;
link_sn: string;
link_nm: string;
link_rslt_cd: string;
err_cn: string;
link_err_cd: string;
log_crt_dt: string;
}
export interface LinkInfo extends LinkFormData {
link_sn: string;
link_nm: string;
link_clsf_cd: string;
link_url: string;
link_srvr_ip: string;
link_db_cd: string;
link_key: string;
link_id: string;
link_pswd: string;
srvc_usg: string;
last_link_rslt_cd: string;
last_link_excn_dt: string;
next_link_excn_dt: string;
last_link_err_cd: string;
mdfcn_dt: string;
mdfr: string;
link_batch_time: number;
}
export interface LinkStats {
link_sn: string;
link_nm: string;
count: number;
}
export interface LinkVrbl {
link_vrbl_sn: string;
link_vrbl_nm: string;
link_vrbl_vl: string;
link_vrbl_cd: string;
link_vrbl_expln: string;
mdfcn_dt: string;
mdfr: string;
}
export interface LinkTable {
link_nm: string;
link_sn: string;
tbl_sn: string;
sou_tbl_nm: string;
sou_tbl_korn_nm: string;
sou_tbl_expln: string;
trsf_tbl_nm: string;
trsf_tbl_korn_nm: string;
trsf_tbl_expln: string;
}
export interface LinkTableColumns {
tbl_sn: string;
col_sn: string;
sou_col_nm: string;
sou_col_korn_nm: string;
sou_col_data_frm: string;
sou_col_sz: number;
trsf_col_nm: string;
trsf_col_korn_nm: string;
trsf_col_data_frm: string;
trsf_col_sz: number
pk_yn: string;
col_seq: number;
mdfcn_dt: string;
mdfr: string;
}
export const linkFormSchema = z.object({
link_clsf_cd: z.string().max(8).nullable().refine((val) => {
// 빈 값이나 "none" 값이면 false 반환
return val && val !== "none";
}, {
message: "연계 분류를 선택해주세요"
}),
link_sn: z.string().max(50).nullable().optional(),
link_nm: z.string().max(40, { message: '연계명은 40자 이하로 입력해 주세요.' }).nonempty({ message: '연계명은 필수입니다.' }),
link_url: z.string().max(1000, { message: '연계 URL은 1000자 이하로 입력해 주세요.' }).nonempty({ message: '연계 URL은 필수입니다.' }),
link_srvr_ip: z.string().max(20).nullable().optional(),
link_db_cd: z.string().max(10).nullable().optional(),
link_key: z.string().max(100, { message: '연계 KEY는 100자 이하로 입력해 주세요.' }).nonempty({ message: '연계 KEY는 필수입니다.' }),
link_id: z.string().max(20).nullable().optional(),
link_pswd: z.string().max(128).nullable().optional(),
srvc_usg: z.string().max(40).nullable().optional(),
last_link_rslt_cd: z.string().max(4).nullable().optional(),
last_link_excn_dt: z.string().max(40).nullable().optional(),
next_link_excn_dt: z.string().max(40).nullable().optional(),
last_link_err_cd: z.string().max(10).nullable().optional(),
mdfcn_dt: z.string().nullable().optional(),
mdfr: z.string().nullable().optional(),
link_batch_time: z.coerce.number().nullable().optional().refine((val) => {
if (val == null) return true;
return val >= 5;
}, {
message: "연계 주기는 5분 이상이어야 합니다."
}),
});
export const linkVrblFormSchema = z.object({
link_sn: z.string().max(50).nullable().optional(),
link_vrbl_sn: z.string().max(50).nullable().optional(),
link_vrbl_nm: z.string().max(40, { message: '변수명은 40자 이하로 입력해 주세요.' }).nonempty({ message: '변수명은 필수입니다.' }),
link_vrbl_expln: z.string().max(4000, { message: '설명은 400자 이하로 입력해 주세요.' }).nullable().optional(),
link_vrbl_cd: z.string().max(10, { message: '변수코드는 10자 이하로 입력해 주세요.' }).nullable().optional(),
link_vrbl_vl: z.string().max(1000, { message: '변수값은 1000자 이하로 입력해 주세요.' }).nonempty({ message: '변수값은 필수입니다.' }),
mdfcn_dt: z.string().nullable().optional(),
mdfr: z.string().nullable().optional(),
});
export const LinkTableColumnsSchema = z.object({
link_sn: z.string().max(50).nullable().optional(),
tbl_sn: z.string().max(50).nullable().optional(),
col_sn: z.string().max(50).nullable().optional(),
sou_col_nm: z.string().max(40, { message: '40자 이하로 입력해 주세요.' }).nullable().optional(),
sou_col_korn_nm: z.string().max(40, { message: '40자 이하로 입력해 주세요.' }).nullable().optional(),
sou_col_data_frm: z.string().max(40, { message: '40자 이하로 입력해 주세요.' }).nullable().optional(),
sou_col_sz: z.coerce.number().nullable().optional().refine((val) => {
if (val == null) return true;
return val.toString().replace('.', '').length <= 14;
}, {
message: '최대 14자리까지 입력 가능합니다.',
}),
pk_yn: z.string().max(1).nonempty(),
trsf_col_nm: z.string().max(40, { message: '40자 이하로 입력해 주세요.' }).nonempty({ message: '변환컬럼명은 필수입니다.' }),
trsf_col_korn_nm: z.string().max(40, { message: '40자 이하로 입력해 주세요.' }).nonempty({ message: '변환컬럼 한글명은 필수입니다.' }),
trsf_col_data_frm: z.string().max(40, { message: '40자 이하로 입력해 주세요.' }).nonempty({ message: '변환컬럼 데이터 형식은 필수입니다.' }),
trsf_col_sz: z.coerce.number().nullable().optional().refine((val) => {
if (val == null) return true;
return val.toString().replace('.', '').length <= 14;
}, {
message: '최대 14자리까지 입력 가능합니다.',
}),
col_seq: z.coerce.number().nullable().optional().refine((val) => {
if (val == null) return true;
return val > 0;
}, {
message: "컬럼 매칭 순서는 0 이상이어야 합니다."
}),
mdfcn_dt: z.string().nullable().optional(),
mdfr: z.string().nullable().optional(),
});
export const LinkTableSchema = z.object({
link_sn: z.string().max(50).nullable().optional(),
tbl_sn: z.string().max(50).nullable().optional(),
link_nm: z.string().max(50).nullable().optional(),
sou_tbl_nm: z.string().max(40, { message: '40자 이하로 입력해 주세요.' }).nullable().optional(),
sou_tbl_korn_nm: z.string().max(40, { message: '40자 이하로 입력해 주세요.' }).nullable().optional(),
sou_tbl_expln: z.string().max(2000, { message: '설명은 2000자 이하로 입력해 주세요.' }).nullable().optional(),
trsf_tbl_nm: z.string().max(40, { message: '40자 이하로 입력해 주세요.' }).nonempty({ message: '변환테이블명은 필수입니다.' }),
trsf_tbl_korn_nm: z.string().max(40, { message: '40자 이하로 입력해 주세요.' }).nonempty({ message: '변환테이블 한글명은 필수입니다.' }),
trsf_tbl_expln: z.string().max(2000, { message: '설명은 2000자 이하로 입력해 주세요.' }).nullable().optional(),
mdfcn_dt: z.string().nullable().optional(),
mdfr: z.string().nullable().optional(),
});
interface LinkStatsListObject {
linkSuccStats: LinkStats[] | null;
linkFailStats: LinkStats[] | null;
}
export interface LinkSearchParam extends PageableParams {
linkSn?: string;
linkNm?: string;
startDate?: string;
endDate?: string;
isSucc?: string;
}
export type LinkFormData = z.infer<typeof linkFormSchema>;
export type LinkVrblFormData = z.infer<typeof linkVrblFormSchema>;
export type LinkTableColData = z.infer<typeof LinkTableColumnsSchema>;
export type LinkTableFormData = z.infer<typeof LinkTableSchema>;
export type LinkList = PageableList<Link>;
export type LinkLogList = PageableList<LinkLog>;
export type LinkStatsList = LinkStatsListObject;
export type LinkVrblList = PageableList<LinkVrbl>;
export type LinkTableColumnsList = PageableList<LinkTableColumns>;
export interface LinkService {
getLinkList(params: LinkSearchParam): Promise<LinkList>;
getLinkInfo(params: string): Promise<LinkInfo>;
linkQuickStart(params: string): Promise<string>;
updateLinkInfo(data: LinkFormData): Promise<void>;
deleteLink(params: string): Promise<void>;
getLinkLogList(params: LinkSearchParam): Promise<LinkLogList>;
getLinkStatsList(params: LinkSearchParam): Promise<LinkStatsList>;
getLinkVrblList(params: string): Promise<LinkVrblList>;
insertLinkVrbl(data: LinkVrblFormData): Promise<number>;
deleteLinkVrbl(data: string[]): Promise<void>;
getLinkVrblInfo(params: string): Promise<LinkVrbl>;
updateLinkVrblInfo(data: LinkVrblFormData): Promise<void>;
getLinkTableInfo(params: string): Promise<LinkTable>;
getLinkTableColList(params: string): Promise<LinkTableColumnsList>;
getLinkTableColInfo(params: { colSn: string; tblSn: string; }): Promise<LinkTableColumns>;
insertLinkTableCol(data: LinkTableColData): Promise<void>;
deleteLinkTableCol(data: string[]): Promise<void>;
updateLinkTableCol(data: LinkTableColData): Promise<void>;
insertLinkTableInfo(data: LinkTableFormData): Promise<void>;
updateLinkTableInfo(data: LinkTableFormData): Promise<void>;
insertLinkInfo(data: LinkFormData): Promise<void>;
countLinkTblNm(tblNm: string): Promise<{ count: number, tbl_sn: string }>;
updateLinkResultCode(linkSn: string): Promise<void>;
}
@@ -47,7 +47,7 @@ function AddAdminDialog() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [userSearchQuery, setUserSearchQuery] = useState<UserSearchParams>({ const [userSearchQuery, setUserSearchQuery] = useState<UserSearchParams>({
keywordType: "", keywordType: "userName",
keyword: "", keyword: "",
}); });
const [selectedGroupId, setSelectedGroupId] = useState(""); const [selectedGroupId, setSelectedGroupId] = useState("");
@@ -193,6 +193,7 @@ function AddAdminDialog() {
<form onSubmit={handleSearch} className="flex items-center gap-2"> <form onSubmit={handleSearch} className="flex items-center gap-2">
<Select <Select
value={userSearchQuery.keywordType}
onValueChange={(value) => onValueChange={(value) =>
setUserSearchQuery({ ...userSearchQuery, keywordType: value }) setUserSearchQuery({ ...userSearchQuery, keywordType: value })
} }
@@ -201,8 +202,8 @@ function AddAdminDialog() {
<SelectValue placeholder="선택" /> <SelectValue placeholder="선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="userId">아이디</SelectItem>
<SelectItem value="userName">이름</SelectItem> <SelectItem value="userName">이름</SelectItem>
<SelectItem value="userId">아이디</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Input <Input
+28 -22
View File
@@ -6,7 +6,6 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table"; import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { import {
Button, Button,
DateRangePicker,
Input, Input,
Label, Label,
Select, Select,
@@ -15,13 +14,14 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@common/components"; } from "@common/components";
import { columns } from "@/pages/apilog/model/table-columns";
import { ApiLogApi, type ApiLogParams } from "@/entities/apilog"; import { ApiLogApi, type ApiLogParams } from "@/entities/apilog";
import { useDataTable } from "@/shared/lib"; import { useDataTable } from "@/shared/lib";
import { ENDPOINT } from "@/shared/config"; import { ENDPOINT } from "@/shared/config";
import { columns } from "@/pages/apilog/model/table-columns"; import { InputDateRangePicker } from "@/shared/ui/input-date-range-picker";
type DateRange = { type DateRange = {
from: Date | undefined; from?: Date | undefined;
to?: Date | undefined; to?: Date | undefined;
}; };
@@ -35,6 +35,7 @@ function ApiLogPage() {
pageIndex: 0, pageIndex: 0,
pageSize: 10, pageSize: 10,
}); });
const [presetDateRange, setPresetDateRange] = useState("");
const [dateRange, setDateRange] = useState<DateRange>(defaultDateRange); const [dateRange, setDateRange] = useState<DateRange>(defaultDateRange);
const [userId, setUserId] = useState(""); const [userId, setUserId] = useState("");
const [apiHttpPmhd, setApiHttpPmhd] = useState(""); const [apiHttpPmhd, setApiHttpPmhd] = useState("");
@@ -84,8 +85,10 @@ function ApiLogPage() {
...searchParams, ...searchParams,
userId, userId,
apiHttpPmhd, apiHttpPmhd,
dmndDtStart: dayjs(dateRange.from).format("YYYY-MM-DD"), dmndDtStart: dateRange.from
dmndDtEnd: dayjs(dateRange.to).format("YYYY-MM-DD"), ? dayjs(dateRange.from).format("YYYY-MM-DD")
: "",
dmndDtEnd: dateRange.to ? dayjs(dateRange.to).format("YYYY-MM-DD") : "",
}); });
}; };
@@ -126,31 +129,34 @@ function ApiLogPage() {
default: default:
presetDateRange = undefined; presetDateRange = undefined;
} }
setPresetDateRange(value);
setDateRange(presetDateRange || defaultDateRange); setDateRange(presetDateRange || defaultDateRange);
}; };
const handleDateRangeChange = (dateRange: DateRange) => {
setDateRange(dateRange);
setPresetDateRange("");
};
return ( return (
<div className="flex-1"> <div className="flex-1">
<div className="card card-border"> <div className="card card-border">
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2"> <form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
<Label className="mr-2">조회기간</Label> <Label className="mr-2">조회기간</Label>
<DateRangePicker <Select value={presetDateRange} onValueChange={handlePresetDateRange}>
value={dateRange} <SelectTrigger className="w-[12rem]">
onValueChange={(value) => setDateRange(value || defaultDateRange)} <SelectValue placeholder="기간 선택" className="w-[24rem]" />
> </SelectTrigger>
<div className="card card-border flex items-center gap-2"> <SelectContent>
<Select onValueChange={handlePresetDateRange}> <SelectItem value="1W">일주일</SelectItem>
<SelectTrigger> <SelectItem value="1M">1개월</SelectItem>
<SelectValue placeholder="기간 선택" className="w-[24rem]" /> <SelectItem value="3M">3개월</SelectItem>
</SelectTrigger> </SelectContent>
<SelectContent> </Select>
<SelectItem value="1W">일주일</SelectItem> <InputDateRangePicker
<SelectItem value="1M">1개월</SelectItem> dateRange={dateRange}
<SelectItem value="3M">3개월</SelectItem> setDateRange={handleDateRangeChange}
</SelectContent> />
</Select>
</div>
</DateRangePicker>
<Label className="ml-4 mr-2 text-label">사용자ID</Label> <Label className="ml-4 mr-2 text-label">사용자ID</Label>
<Input <Input
value={userId} value={userId}
@@ -0,0 +1,14 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { Dashboard, DashboardService } from "../model/dashboard-interface";
export class DashboardApiService implements DashboardService {
public async getDashboard(): Promise<Dashboard> {
const response = await client.get<Dashboard>(
ENDPOINT.CMS_SERVICE.DASHBOARD,
);
return response.data;
}
}
export const DashboardApi = new DashboardApiService();
+49
View File
@@ -0,0 +1,49 @@
import type { ChartData, ChartDataset } from "chart.js";
import { getCssVariableValue } from "@common/components";
import type {
MenuStatisticsContent,
SiteStatisticsContent,
} from "../model/dashboard-interface";
export function processSiteAccessData(
siteAccessList: SiteStatisticsContent[],
): ChartData<"line">["datasets"] {
const siteGroups: Record<string, ChartDataset> = siteAccessList.reduce(
(acc, item) => {
if (!acc[item.siteNm]) {
acc[item.siteNm] = {
label: item.siteNm,
data: Array(24).fill(0), // 24시간 데이터 초기화
borderColor: getCssVariableValue(
`--chart-${Math.floor(Math.random() * 5) + 1}`,
),
};
}
// 시간대별 접속 카운트 누적
const hourIndex = parseInt(item.accessHour, 10);
if (!isNaN(hourIndex) && hourIndex >= 0 && hourIndex < 24) {
acc[item.siteNm]!.data[hourIndex] = item.accessCnt;
}
return acc;
},
{} as Record<string, ChartDataset>,
);
return Object.values(siteGroups) as ChartData<"line">["datasets"];
}
export function processMenuAccessData(
menuAccessList: MenuStatisticsContent[],
): ChartData<"bar">["datasets"] {
const data = menuAccessList.map((item) => item.accessCnt);
return [
{
label: "메뉴별 접속 횟수",
data,
backgroundColor: "rgb(32, 148, 250)",
},
] as ChartData<"bar">["datasets"];
}
@@ -0,0 +1,172 @@
import { type ChartData, type ChartOptions } from "chart.js";
const commonData = {
labels: ["1", "2", "3", "4", "5", "6", "7"],
datasets: [
{
fill: true,
pointRadius: 0,
tension: 0.5,
},
],
};
const commonOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
},
scales: {
x: {
display: false,
},
y: {
display: false,
},
},
};
export const chartData: Record<
string,
{ data: ChartData<"line" | "bar">; options: ChartOptions<"line" | "bar"> }
> = {
memberChartData: {
data: {
...commonData,
datasets: [
{
...commonData.datasets[0],
data: [8, 10, 7, 12, 9, 13, 12],
borderColor: "rgba(255, 255, 255, 0.6)",
backgroundColor: "rgba(255, 255, 255, 0.1)",
},
],
},
options: {
...commonOptions,
scales: {
...commonOptions.scales,
y: {
...commonOptions.scales.y,
min: 5,
max: 15,
},
},
},
},
postChartData: {
data: {
...commonData,
datasets: [
{
...commonData.datasets[0],
data: [5200, 5350, 5450, 5520, 5600, 5650, 5678],
borderColor: "rgba(86, 138, 255, 1)",
backgroundColor: "rgba(86, 138, 255, 0.1)",
},
],
},
options: {
...commonOptions,
},
},
visitorChartData: {
data: {
...commonData,
datasets: [
{
...commonData.datasets[0],
data: [1800, 2100, 1950, 2300, 2150, 2250, 2345],
borderColor: "rgba(149, 131, 252)",
backgroundColor: "rgba(149, 131, 252, 0.1)",
},
],
},
options: {
...commonOptions,
},
},
apiChartData: {
data: {
...commonData,
datasets: [
{
...commonData.datasets[0],
data: [41000, 42500, 43200, 44100, 44800, 45200, 45678],
borderColor: "rgba(220, 103, 206)",
backgroundColor: "rgba(220, 103, 206, 0.1)",
},
],
},
options: {
...commonOptions,
},
},
siteAccessChartData: {
data: {
labels: Array.from({ length: 24 }, (_, i) =>
i.toString().padStart(2, "0"),
),
datasets: [],
},
options: {
...commonOptions,
plugins: {
legend: {
position: "top",
},
tooltip: {
mode: "index",
intersect: false,
},
},
scales: {
x: {
title: {
text: "시간대",
},
},
y: {
title: {
text: "접속자 수",
},
beginAtZero: true,
},
},
},
},
menuAccessChartData: {
data: {
labels: [],
datasets: [],
},
options: {
...commonOptions,
plugins: {
legend: {
position: "top",
},
tooltip: {
mode: "index",
intersect: false,
},
},
scales: {
x: {
title: {
text: "메뉴명",
},
},
y: {
title: {
text: "접속 횟수",
},
beginAtZero: true,
},
},
},
},
};
@@ -0,0 +1,32 @@
export interface SiteStatisticsContent {
siteId: string;
siteNm: string;
accessHour: string;
accessCnt: number;
}
export interface MenuStatisticsContent {
menuCd: number;
menuNm: string;
accessCnt: number;
}
export interface Dashboard {
memberTotCnt: number;
memberTodayCnt: number;
pstTotCnt: number;
pstTodayCnt: number;
accessTodayCnt: number;
accessBefDayPer: number;
apiTodayCnt: number;
waitMemberCnt: number;
waitCntnCnt: number;
notiList: [];
schdlList: [];
siteAccessList: SiteStatisticsContent[];
menuAccessList: MenuStatisticsContent[];
}
export interface DashboardService {
getDashboard(): Promise<Dashboard>;
}
@@ -0,0 +1,74 @@
import dayjs from "dayjs";
import type { ColumnDef } from "@tanstack/react-table";
import { Badge } from "@common/components";
import type { RecentPost } from "@/entities/post";
const noticePostTableColumns: ColumnDef<RecentPost>[] = [
{
header: "번호",
cell: ({ row, table }) => {
if (row.original.upndFixYn === "Y") {
return (
<Badge
color="orange"
className="flex size-8 items-center justify-center"
>
<i className="diveicon di-volume-up-fill" />
</Badge>
);
}
const totalRows = table.getRowCount();
const currentPage = table.getState().pagination.pageIndex;
const pageSize = table.getState().pagination.pageSize;
return totalRows - (currentPage * pageSize + row.index);
},
},
{
accessorKey: "pstTtl",
header: "제목",
},
{
accessorKey: "wrtrNm",
header: "작성자",
},
{
accessorKey: "regDt",
header: "작성일",
cell: ({ row }) => dayjs(row.original.regDt).format("YYYY-MM-DD"),
},
{
accessorKey: "inqNocs",
header: "조회수",
},
];
const recentPostTableColumns: ColumnDef<RecentPost>[] = [
{
header: "번호",
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: "pstTtl",
header: "제목",
},
{
accessorKey: "wrtrNm",
header: "작성자",
},
{
accessorKey: "regDt",
header: "작성일",
cell: ({ row }) => dayjs(row.original.regDt).format("YYYY-MM-DD"),
},
{
accessorKey: "inqNocs",
header: "조회수",
},
];
export { noticePostTableColumns, recentPostTableColumns };
@@ -1,5 +1,433 @@
"use client";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { getCoreRowModel } from "@tanstack/react-table";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Filler,
Legend,
Tooltip,
type ChartData,
} from "chart.js";
import { Bar, Line } from "react-chartjs-2";
import { PostApi, type RecentPost } from "@/entities/post";
import { ENDPOINT } from "@/shared/config";
import { useDataTable } from "@/shared/lib";
import { DashboardApi } from "../api/dashboard-service";
import { chartData } from "../model/chart-data";
import {
noticePostTableColumns,
recentPostTableColumns,
} from "../model/table-columns";
import { processMenuAccessData, processSiteAccessData } from "../lib/utils";
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
Filler,
Legend,
Tooltip,
);
function DashboardPage() { function DashboardPage() {
return <></>; const router = useRouter();
const { data } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.DASHBOARD],
queryFn: () => DashboardApi.getDashboard(),
});
const noticePostQuery = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS],
queryFn: () =>
PostApi.getRecentPostList({
page: 0,
size: 5,
keywordType: "bbsTypeCd",
keyword: "BBSNOTICE",
}),
});
const recentPostQuery = useQuery({
queryKey: [ENDPOINT.BOARD_SERVICE.POSTS],
queryFn: () =>
PostApi.getRecentPostList({
page: 0,
size: 5,
}),
});
const handleNoticePostRowClick = (row: RecentPost) => {
router.push(`/bbs/bbsnotice/${row.bbsId}/${row.pstNo}`);
};
const handleRecentPostRowClick = (row: RecentPost) => {
router.push(
`/bbs/${row.bbsTypeCd.toLowerCase()}/${row.bbsId}/${row.pstNo}`,
);
};
const { tableComponent: noticePostTableComponent } = useDataTable({
query: noticePostQuery,
tableOptions: {
columns: noticePostTableColumns,
getCoreRowModel: getCoreRowModel(),
},
dataTableOptions: {
variant: "line",
onRowClick: handleNoticePostRowClick,
},
});
const { tableComponent: recentPostTableComponent } = useDataTable({
query: recentPostQuery,
tableOptions: {
columns: recentPostTableColumns,
getCoreRowModel: getCoreRowModel(),
},
dataTableOptions: {
variant: "line",
onRowClick: handleRecentPostRowClick,
},
});
return (
<div className="p-6">
<div className="flex flex-col gap-1">
{/* 첫번째 라인 */}
<div className="grid grid-cols-4 gap-1">
{/* 총 회원수 카드 */}
<div className="card card-border h-80 !bg-primary !p-0">
<div className="relative h-full">
<div className="absolute left-0 top-0 p-6 tracking-tighter text-white">
<h3 className="text-2xl font-medium">총 회원수</h3>
<div className="mt-2 text-6xl font-bold">
{data?.memberTotCnt
? data.memberTotCnt.toLocaleString()
: "-"}
</div>
<p className="mt-1 text-2xl opacity-80">
금일 신규 가입자
{data?.memberTodayCnt
? data.memberTodayCnt.toLocaleString()
: "-"}
명
</p>
</div>
{/* 차트 영역 */}
<div className="h-full">
<Line
data={chartData.memberChartData!.data as ChartData<"line">}
options={chartData.memberChartData!.options}
/>
</div>
</div>
</div>
{/* 전체 게시물 카드 */}
<div className="card card-border h-80 !p-0">
<div className="relative h-full">
<div className="absolute left-0 top-0 p-6 tracking-tighter">
<h3 className="text-2xl font-medium">전체 게시물</h3>
<div className="mt-2 text-6xl font-bold">
{data?.pstTotCnt ? data.pstTotCnt.toLocaleString() : "-"}
</div>
<p className="mt-1 text-2xl opacity-80">
금일 +
{data?.pstTodayCnt ? data.pstTodayCnt.toLocaleString() : "-"}{" "}
등록
</p>
</div>
{/* 차트 영역 */}
<div className="h-full">
<Line
data={chartData.postChartData!.data as ChartData<"line">}
options={chartData.postChartData!.options}
/>
</div>
</div>
</div>
{/* 오늘 접속자 수 카드 */}
<div className="card card-border h-80 !p-0">
<div className="relative h-full">
<div className="absolute left-0 top-0 p-6 tracking-tighter">
<h3 className="text-2xl font-medium">오늘 접속자 수</h3>
<div className="mt-2 text-6xl font-bold">
{data?.accessTodayCnt
? data.accessTodayCnt.toLocaleString()
: "-"}
</div>
<p className="mt-1 text-2xl opacity-80">
전일 대비 +
{data?.accessBefDayPer
? data.accessBefDayPer.toLocaleString()
: "-"}
%
</p>
</div>
{/* 차트 영역 */}
<div className="h-full">
<Line
data={chartData.visitorChartData!.data as ChartData<"line">}
options={chartData.visitorChartData!.options}
/>
</div>
</div>
</div>
{/* API 호출 건수 카드 */}
<div className="card card-border h-80 !p-0">
<div className="relative h-full">
<div className="absolute left-0 top-0 p-6 tracking-tighter">
<h3 className="text-2xl font-medium">API 호출 건수</h3>
<div className="mt-2 text-6xl font-bold">
{data?.apiTodayCnt ? data.apiTodayCnt.toLocaleString() : "-"}
</div>
<p className="mt-1 text-2xl opacity-80">금일 누적</p>
</div>
{/* 차트 영역 */}
<div className="h-full">
<Line
data={chartData.apiChartData!.data as ChartData<"line">}
options={chartData.apiChartData!.options}
/>
</div>
</div>
</div>
</div>
{/* 두번째 라인 */}
<div className="grid grid-cols-2 gap-1">
{/* 승인 대기 알림 */}
<div className="card card-border">
<div className="card-header">
<h3 className="text-2xl font-medium">승인 대기 알림</h3>
</div>
<div className="p-4">
<div className="flex gap-4">
{/* 사용자 승인 */}
<div className="flex flex-1 flex-col items-start rounded-md bg-accent p-6">
<div className="flex items-center">
<div className="mr-4 flex h-10 w-10 items-center justify-center rounded-lg bg-red-100">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-red-500"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path>
<circle cx="12" cy="7" r="4"></circle>
</svg>
</div>
<span className="text-2xl font-medium">사용자 승인</span>
</div>
<div className="mt-5">
<div className="text-5xl font-bold">
{data?.waitMemberCnt
? data.waitMemberCnt.toLocaleString()
: "0"}
건
</div>
<div className="mt-2 text-2xl text-gray-500">
신규 가입 승인 대기
</div>
</div>
</div>
{/* 매뉴 승인 */}
<div className="flex flex-1 flex-col items-start rounded-md bg-accent p-6">
<div className="flex items-center">
<div className="mr-4 flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-blue-500"
>
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
</div>
<span className="text-2xl font-medium">메뉴 승인</span>
</div>
<div className="mt-5">
<div className="text-5xl font-bold">
{(3).toLocaleString()}건
</div>
<div className="mt-2 text-2xl text-gray-500">
매뉴 수정 승인 대기
</div>
</div>
</div>
{/* 컨텐츠 승인 */}
<div className="flex flex-1 flex-col items-start rounded-md bg-accent p-6">
<div className="flex items-center">
<div className="mr-4 flex h-10 w-10 items-center justify-center rounded-lg bg-yellow-100">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-yellow-500"
>
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
</svg>
</div>
<span className="text-2xl font-medium">컨텐츠 승인</span>
</div>
<div className="mt-5">
<div className="text-5xl font-bold">
{data?.waitCntnCnt
? data.waitCntnCnt.toLocaleString()
: "0"}
건
</div>
<div className="mt-2 text-2xl text-gray-500">
컨텐츠 수정 승인 대기
</div>
</div>
</div>
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-1">
{/* 실시간 알림 */}
<div className="card card-border">
<div className="card-header">
<h3 className="text-2xl font-medium">실시간 알림</h3>
</div>
<div className="p-4">
<div className="space-y-4">
<div className="flex items-center justify-between border-b border-border pb-4">
<span className="font-medium">신규 사용자 가입</span>
<span>{(1).toLocaleString()}분 전</span>
</div>
<div className="flex items-center justify-between border-b border-border pb-4">
<span className="font-medium">메뉴 수정 요청</span>
<span>{(5).toLocaleString()}분 전</span>
</div>
<div className="flex items-center justify-between border-b border-border pb-4">
<span className="font-medium">게시물 신고</span>
<span>{(10).toLocaleString()}분 전</span>
</div>
<div className="flex items-center justify-between">
<span className="font-medium">컨텐츠 승인 요청</span>
<span>{(15).toLocaleString()}분 전</span>
</div>
</div>
</div>
</div>
{/* 일정 알림 */}
<div className="card card-border">
<div className="card-header">
<h3 className="text-2xl font-medium">일정 알림</h3>
</div>
<div className="p-4">
<div className="space-y-4">
<div className="flex items-center justify-between border-b border-border pb-4">
<span className="font-medium">
{(3).toLocaleString()}월 정기 시스템 점검
</span>
<span>내일</span>
</div>
<div className="flex items-center justify-between border-b border-border pb-4">
<span className="font-medium">서버 업데이트 예정</span>
<span>{(3).toLocaleString()}일 후</span>
</div>
<div className="flex items-center justify-between border-b border-border pb-4">
<span className="font-medium">백업 스케줄</span>
<span>{(5).toLocaleString()}일 후</span>
</div>
<div className="flex items-center justify-between">
<span className="font-medium">정기 보안 점검</span>
<span>{(7).toLocaleString()}일 후</span>
</div>
</div>
</div>
</div>
</div>
</div>
{/* 세번째 라인 */}
<div className="grid grid-cols-2 gap-1">
{/* 접속자 통계 */}
<div className="card card-border">
<div className="card-header">
<h3 className="text-2xl font-medium">접속자 통계</h3>
</div>
<div className="h-80 p-4">
<Line
data={{
...chartData.siteAccessChartData!.data,
datasets: processSiteAccessData(data?.siteAccessList ?? []),
}}
options={chartData.siteAccessChartData!.options}
/>
</div>
</div>
{/* 메뉴별 접속 통계 */}
<div className="card card-border">
<div className="card-header">
<h3 className="text-2xl font-medium">메뉴별 접속 통계</h3>
</div>
<div className="h-80 p-4">
<Bar
data={{
labels:
data?.menuAccessList?.map((item) => item.menuNm) || [],
datasets: processMenuAccessData(data?.menuAccessList || []),
}}
options={chartData.menuAccessChartData!.options}
/>
</div>
</div>
</div>
{/* 네번째 라인 */}
<div className="grid grid-cols-2 gap-1">
{/* 공지사항 */}
<div className="card card-border !p-0">
<div className="card-header !m-0 !border-none">
<h3 className="text-2xl font-medium">공지사항</h3>
</div>
{noticePostTableComponent}
</div>
{/* 신규 게시글 */}
<div className="card card-border !p-0">
<div className="card-header !m-0 !border-none">
<h3 className="text-2xl font-medium">신규 게시글</h3>
</div>
{recentPostTableComponent}
</div>
</div>
</div>
</div>
);
} }
export { DashboardPage }; export { DashboardPage };
+2 -2
View File
@@ -161,11 +161,11 @@ function GroupForm({ form }: GroupFormProps) {
> >
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RadioGroupItem value="Y" id="mngrY" /> <RadioGroupItem value="Y" id="mngrY" />
<Label htmlFor="mngrY">Y</Label> <Label htmlFor="mngrY">예</Label>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RadioGroupItem value="N" id="mngrN" /> <RadioGroupItem value="N" id="mngrN" />
<Label htmlFor="mngrN">N</Label> <Label htmlFor="mngrN">아니오</Label>
</div> </div>
</RadioGroup> </RadioGroup>
</FormControl> </FormControl>
@@ -1,6 +1,6 @@
"use client"; "use client";
import { memo, useState } from "react"; import { memo, useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useConfirm } from "@frontend-opensource/use-react-hooks"; import { useConfirm } from "@frontend-opensource/use-react-hooks";
@@ -43,7 +43,7 @@ const GroupsDialog = memo(
const { data: group } = useQuery({ const { data: group } = useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.GROUPS, groupId], queryKey: [ENDPOINT.CMS_SERVICE.GROUPS, groupId],
queryFn: () => GroupApi.findGroupById(groupId!), queryFn: () => GroupApi.findGroupById(groupId!),
enabled: !!groupId && open, // open 시점 groupId가 있을 때만 상세 조회 API 호출 enabled: !!groupId && open, // open 시점 groupId가 있을 때만 상세 조회 API 호출
}); });
const { mutateAsync: createGroup } = useMutationWrapper({ const { mutateAsync: createGroup } = useMutationWrapper({
@@ -77,7 +77,7 @@ const GroupsDialog = memo(
delYn: group?.delYn ?? "", delYn: group?.delYn ?? "",
authrtId: group?.authrtId ?? "", authrtId: group?.authrtId ?? "",
siteId: group?.siteId ?? "", siteId: group?.siteId ?? "",
mngrYn: group?.mngrYn ?? "", mngrYn: group?.mngrYn ?? "Y",
}, },
}); });
@@ -91,6 +91,10 @@ const GroupsDialog = memo(
if (await confirm(confirmMessage)) callback(data); if (await confirm(confirmMessage)) callback(data);
}; };
useEffect(() => {
if (open) form.reset();
}, [open]);
return ( return (
<Dialog open={open} onOpenChange={setOpen} {...props}> <Dialog open={open} onOpenChange={setOpen} {...props}>
<DialogTrigger asChild>{children}</DialogTrigger> <DialogTrigger asChild>{children}</DialogTrigger>
@@ -23,6 +23,7 @@ function CreatePage() {
resolver: zodResolver(imageFormSchema), resolver: zodResolver(imageFormSchema),
defaultValues: { defaultValues: {
atflYn: "Y", atflYn: "Y",
useYn: "Y",
}, },
}); });
@@ -124,6 +124,7 @@ function ImageForm({ form, imgSn, ...props }: ImageFormProps) {
{...field} {...field}
type="number" type="number"
min={0} min={0}
max={9999}
value={field.value ?? ""} value={field.value ?? ""}
onChange={(e) => onChange={(e) =>
field.onChange(parseInt(e.target.value)) field.onChange(parseInt(e.target.value))
+3
View File
@@ -0,0 +1,3 @@
export { ModifyLinkPage } from "./ui/modify-page";
export { RegistLinkPage } from "./ui/regist-page";
export { LinkPage } from "./ui/link-page";
@@ -0,0 +1,88 @@
import dayjs from "dayjs";
import { createColumnHelper } from "@tanstack/react-table";
import { type Link } from "@/entities/link";
import QuickStartButton from "../ui/QuickStartButton";
const columnHelper = createColumnHelper<Link>();
const convertMinutesToPeriod = (minutes: number): string => {
if (!minutes) return '';
const year = Math.floor(minutes / (525600)); // 365일 * 24시간 * 60분
minutes = minutes % 525600;
const month = Math.floor(minutes / (43200)); // 30일 * 24시간 * 60분
minutes = minutes % 43200;
const day = Math.floor(minutes / 1440); // 24시간 * 60분
minutes = minutes % 1440;
const hour = Math.floor(minutes / 60);
const minute = minutes % 60;
const parts = [];
if (year > 0) parts.push(`${year}년`);
if (month > 0) parts.push(`${month}개월`);
if (day > 0) parts.push(`${day}일`);
if (hour > 0) parts.push(`${hour}시간`);
if (minute > 0) parts.push(`${minute}분`);
return parts.join(' ');
};
const columns = [
columnHelper.accessor((_, index) => index + 1, {
id: 'no',
header: 'NO',
}),
columnHelper.accessor('link_sn', {
id: 'link_sn',
header: '연계일련번호',
}),
columnHelper.accessor('link_nm', {
id: 'link_nm',
header: '연계 명',
}),
columnHelper.accessor('link_url', {
id: 'link_url',
header: '연계 URL',
cell: (info) => (info.getValue() ? info.getValue() : '-'),
}),
columnHelper.accessor('link_srvr_ip', {
id: 'link_srvr_ip',
header: '연계서버 IP',
cell: (info) => (info.getValue() ? info.getValue() : '-'),
}),
columnHelper.accessor('link_batch_time', {
id: 'link_batch_time',
header: '연계 주기',
cell: (info) => convertMinutesToPeriod(Number(info.getValue())),
}),
columnHelper.accessor('srvc_usg', {
id: 'srvc_usg',
header: '서비스용도',
cell: (info) => (info.getValue() ? info.getValue() : '-'),
}),
columnHelper.display({
id: 'last_link_rslt_cd',
header: '연계상태',
cell: (info) => {
const resultCd = info.row.original.last_link_rslt_cd;
return <span className={`${(resultCd === 'SUCC') ? 'text-[rgb(53,162,235)]' : (resultCd === 'FAIL' ? 'text-[rgb(255,99,132)]' : 'text-[rgba(31,208,120,1)]')}`}>{resultCd === 'SUCC' ? '성공' : (resultCd === 'FAIL' ? '실패' : '대기')}</span>
},
}),
columnHelper.accessor('last_link_excn_dt', {
id: 'last_link_excn_dt',
header: '최종연계일',
cell: (info) => (info.getValue() ? dayjs(info.getValue()).format('YYYY-MM-DD HH:mm') : '-'),
}),
columnHelper.display({
id: 'quick_start_btn',
header: '즉시 실행',
cell: (info) => {
return <QuickStartButton linkSn={info.row.original.link_sn} />
}
})
];
export { columns };
@@ -0,0 +1,36 @@
import { useMutation } from "@tanstack/react-query";
import { LinkApi } from "@/entities/link";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import { Button } from "@common/components";
const QuickStartButton: React.FC<{ linkSn: string }> = ({ linkSn }) => {
const { setMessage: alert } = useAlertStore((state) => state);
const { mutateAsync: updateQuickStart } = useMutation({
mutationFn: (linkSn: string) => LinkApi.linkQuickStart(linkSn),
onSuccess: (result) => {
if (result === 'succ') {
alert('즉시 실행 요청이 완료되었습니다.');
} else {
alert('기존 요청이 실행 대기중입니다.');
}
},
onError: () => {
alert('즉시 실행 요청을 실패했습니다.');
},
});
return (
<Button
color="sky"
onClick={(e) => {
e.stopPropagation();
updateQuickStart(linkSn);
}}
>
즉시 실행
</Button>
);
};
export default QuickStartButton;
+590
View File
@@ -0,0 +1,590 @@
import {
Button,
Input,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "@common/components";
import { FormItem, FormControl, FormField, Form } from "@/shared/ui/form";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import type { UseFormReturn } from "react-hook-form";
import { type LinkFormData } from "@/entities/link";
import dayjs from "dayjs";
import { useRouter } from "next/navigation";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useState } from "react";
type LinkFormProps = {
form: UseFormReturn<LinkFormData>;
linkSn?: string;
updateLinkInfo?: (data: LinkFormData) => void;
handleInsertLink?: (data: LinkFormData) => void;
deleteLink?: () => void;
updateLinkResultCode?: () => void;
}
const LinkForm = ({ form, linkSn, updateLinkInfo, handleInsertLink, deleteLink, updateLinkResultCode }: LinkFormProps) => {
const isModifyMode = !!linkSn;
const router = useRouter();
const { confirm } = useConfirm();
const [onConfirm, setOnConfirm] = useState(false);
const handleConfirm = async (callback: () => Promise<void>) => {
setOnConfirm(true);
await callback();
setOnConfirm(false);
};
const handleSubmit = async (data: LinkFormData) => {
const confirmMsg = !!linkSn ? '수정 하시겠습니까?' : '저장 하시겠습니까?';
await handleConfirm(async () => {
if (await confirm(confirmMsg)) {
if (linkSn) {
updateLinkInfo?.(data);
} else {
handleInsertLink?.(data);
}
}
});
};
const handleDelete = async () => {
await handleConfirm(async () => {
if (await confirm('삭제하시겠습니까?')) {
deleteLink?.();
}
})
};
const convertMinutesToPeriod = (minutes: number) => {
if (!minutes) return {
year: 0,
month: 0,
day: 0,
hour: 0,
minute: 0
};
const year = Math.floor(minutes / (525600)); // 365일 * 24시간 * 60분
minutes = minutes % 525600;
const month = Math.floor(minutes / (43200)); // 30일 * 24시간 * 60분
minutes = minutes % 43200;
const day = Math.floor(minutes / 1440); // 24시간 * 60분
minutes = minutes % 1440;
const hour = Math.floor(minutes / 60);
const minute = minutes % 60;
const timeParts = {
year: year,
month: month,
day: day,
hour: hour,
minute: minute,
};
return timeParts;
};
return (
<>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)}>
<div className="card">
<Table variant="secondary">
<colgroup>
<col width="20%" />
<col width="80%" />
</colgroup>
<TableBody>
{isModifyMode &&
<TableRow>
<TableHead>연계 일련번호</TableHead>
<TableCell>
<FormField
control={form.control}
name="link_sn"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="w-[12rem] !text-[1.3rem]"
disabled={true}
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
}
<TableRow>
<TableHead>* 연계 명</TableHead>
<TableCell>
<FormField
control={form.control}
name="link_nm"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
autoComplete="off"
/>
</FormControl>
{form.formState.errors.link_nm && (
<p className="text-red-500 text-base pt-2">
{form.formState.errors.link_nm.message}
</p>
)}
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>* 연계 분류</TableHead>
<TableCell>
<FormField
control={form.control}
name="link_clsf_cd"
render={({ field }) => (
<FormItem>
<FormControl>
<Select value={field.value ?? "none"} onValueChange={(value) => field.onChange(value)}>
<SelectTrigger id="link_clsf_cd" className="w-[20rem]">
<SelectValue placeholder="전체"></SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="none">선택</SelectItem>
<SelectItem value="link0001">TIBERO 데이터베이스</SelectItem>
<SelectItem value="link0002">ORACLE 데이터베이스</SelectItem>
<SelectItem value="link0003">공공데이터 포털 API</SelectItem>
<SelectItem value="link0004">한강홍수통제소 API</SelectItem>
<SelectItem value="link0005">기상청 API</SelectItem>
<SelectItem value="link0006">국가지하수정보센터 API</SelectItem>
<SelectItem value="link0007">행정정보공동이용센터 연계</SelectItem>
</SelectContent>
</Select>
</FormControl>
{form.formState.errors.link_clsf_cd && (
<p className="text-red-500 text-base pt-2">
{form.formState.errors.link_clsf_cd.message}
</p>
)}
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>* 연계 URL</TableHead>
<TableCell>
<FormField
control={form.control}
name="link_url"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
autoComplete="off"
/>
</FormControl>
{form.formState.errors.link_url && (
<p className="text-red-500 text-base pt-2">
{form.formState.errors.link_url.message}
</p>
)}
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>연계서버 IP</TableHead>
<TableCell>
<FormField
control={form.control}
name="link_srvr_ip"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
autoComplete="off"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>* 연계 KEY</TableHead>
<TableCell>
<FormField
control={form.control}
name="link_key"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
autoComplete="off"
/>
</FormControl>
{form.formState.errors.link_key && (
<p className="text-red-500 text-base pt-2">
{form.formState.errors.link_key.message}
</p>
)}
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>연계 ID</TableHead>
<TableCell>
<FormField
control={form.control}
name="link_id"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
autoComplete="off"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>연계 비밀번호</TableHead>
<TableCell>
<FormField
control={form.control}
name="link_pswd"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
type="password"
value={field.value ?? ""}
className="!text-[1.3rem]"
autoComplete="off"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>서비스 용도</TableHead>
<TableCell>
<FormField
control={form.control}
name="srvc_usg"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
autoComplete="off"
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>연계 주기</TableHead>
<TableCell>
<FormField
control={form.control}
name="link_batch_time"
render={({ field }) => {
const timeParts = convertMinutesToPeriod(Number(field.value));
return (
<FormItem>
<FormControl>
<div className="flex items-center gap-6">
<div className="flex items-center">
<Input
value={timeParts.year}
className="w-20 !text-[1.3rem]"
type="number"
min={0}
onChange={(e) => {
const total = (Number(e.target.value) * 525600) +
(timeParts.month * 43200) +
(timeParts.day * 1440) +
(timeParts.hour * 60) +
timeParts.minute;
field.onChange(total);
}}
/>
<span className="ml-2">년</span>
</div>
<div className="flex items-center">
<Input
value={timeParts.month}
className="w-20 !text-[1.3rem]"
type="number"
min={0}
max={11}
onChange={(e) => {
const total = (timeParts.year * 525600) +
(Number(e.target.value) * 43200) +
(timeParts.day * 1440) +
(timeParts.hour * 60) +
timeParts.minute;
field.onChange(total);
}}
/>
<span className="ml-2">월</span>
</div>
<div className="flex items-center">
<Input
value={timeParts.day}
className="w-20 !text-[1.3rem]"
type="number"
min={0}
max={30}
onChange={(e) => {
const total = (timeParts.year * 525600) +
(timeParts.month * 43200) +
(Number(e.target.value) * 1440) +
(timeParts.hour * 60) +
timeParts.minute;
field.onChange(total);
}}
/>
<span className="ml-2">일</span>
</div>
<div className="flex items-center">
<Input
value={timeParts.hour}
className="w-20 !text-[1.3rem]"
type="number"
min={0}
max={23}
onChange={(e) => {
const total = (timeParts.year * 525600) +
(timeParts.month * 43200) +
(timeParts.day * 1440) +
(Number(e.target.value) * 60) +
timeParts.minute;
field.onChange(total);
}}
/>
<span className="ml-2">시간</span>
</div>
<div className="flex items-center">
<Input
value={timeParts.minute}
className="w-20 !text-[1.3rem]"
type="number"
min={0}
max={59}
onChange={(e) => {
const total = (timeParts.year * 525600) +
(timeParts.month * 43200) +
(timeParts.day * 1440) +
(timeParts.hour * 60) +
Number(e.target.value);
field.onChange(total);
}}
/>
<span className="ml-2">분</span>
</div>
</div>
</FormControl>
{form.formState.errors.link_batch_time && (
<p className="text-red-500 text-base pt-2">
{form.formState.errors.link_batch_time.message}
</p>
)}
</FormItem>
);
}}
/>
</TableCell>
</TableRow>
{isModifyMode &&
<>
<TableRow>
<TableHead>연계 상태</TableHead>
<TableCell>
<FormField
control={form.control}
name="last_link_rslt_cd"
render={({ field }) => {
return (
<FormItem>
<FormControl>
<div className="flex items-center gap-6">
<Input
{...field}
value={field.value ? (field.value === 'SUCC' ? '성공' : (field.value === 'FAIL' ? '실패' : '대기')) : ''}
className="!text-[1.3rem] w-[15rem]"
disabled
/>
<Button color="green" type="button" onClick={() => updateLinkResultCode?.()}>대기로 전환</Button>
</div>
</FormControl>
</FormItem>
);
}}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>최종 연계 실행일</TableHead>
<TableCell>
<FormField
control={form.control}
name="last_link_excn_dt"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ? dayjs(field.value).format('YYYY-MM-DD HH:mm:ss') : ""}
className="!text-[1.3rem]"
disabled
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>다음 연계 실행일</TableHead>
<TableCell>
<FormField
control={form.control}
name="next_link_excn_dt"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ? dayjs(field.value).format('YYYY-MM-DD HH:mm:ss') : ""}
className="!text-[1.3rem]"
disabled
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>수정자</TableHead>
<TableCell>
<FormField
control={form.control}
name="mdfr"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
disabled
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>수정일시</TableHead>
<TableCell>
<FormField
control={form.control}
name="mdfcn_dt"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ? dayjs(field.value).format('YYYY-MM-DD') : ""}
className="!text-[1.3rem]"
disabled
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
</>
}
</TableBody>
</Table>
</div>
<div className="flex justify-center gap-4 pb-12 pt-10">
<Button
size="lg"
color="white"
type="button"
onClick={() => router.back()}
>
목록
</Button>
<Button size="lg" type="submit">
저장
</Button>
{isModifyMode && (
<Button size="lg" color="red" type="button" onClick={handleDelete}>
삭제
</Button>
)}
</div>
</form>
</Form>
<ConfirmDialog isOpen={onConfirm} />
</>
)
};
export default LinkForm;
+134
View File
@@ -0,0 +1,134 @@
"use client";
import "@common/assets/styles/grid.css";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button, Input, Label, LoadingWithAnimation } from "@common/components";
import { getCoreRowModel } from "@tanstack/react-table";
import { LinkApi, type Link, type LinkSearchParam } from "@/entities/link";
import { useQuery } from "@tanstack/react-query";
import { columns } from "../model/table-columns";
import { useQueryClient } from "@tanstack/react-query";
import { useDataTable } from "@/shared/lib";
function LinkPage() {
const router = useRouter();
const queryClient = useQueryClient();
const [linkSn, setLinkSn] = useState<string>('');
const [linkNm, setLinkNm] = useState<string>('');
const [linkSearchParam, setLinkSearchParam] = useState<LinkSearchParam>({
linkSn: '',
linkNm: ''
});
const query = useQuery({
queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkList`, linkSearchParam],
queryFn: () => LinkApi.getLinkList(linkSearchParam),
});
const handleResetSearchParam = () => {
setLinkSn('');
setLinkNm('');
setLinkSearchParam({
...linkSearchParam,
linkSn: '',
linkNm: '',
});
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLinkSearchParam({
...linkSearchParam,
linkSn,
linkNm
});
};
const handleRowClick = (link: Link) => {
queryClient.removeQueries({
queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkList`],
});
queryClient.removeQueries({
queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkInfo`],
});
router.push(`link/register/${link.link_sn}`);
};
const handleClickRegist = () => {
router.push('link/register');
};
const { tableComponent } = useDataTable({
query,
tableOptions: {
columns,
manualPagination: true,
enableMultiRowSelection: true,
getCoreRowModel: getCoreRowModel(),
},
dataTableOptions: {
onRowClick: handleRowClick,
viewPagination: true,
},
});
return (
<div className="card card-border !mt-6">
<div className="card card-border">
<form className="flex items-center gap-2 p-2" onSubmit={handleSubmit}>
<Label htmlFor="liSnSearchInput" className="mr-2 text-label">
일련번호
</Label>
<Input
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem] mr-4"
id="liSnSearchInput"
value={linkSn}
onChange={(e) => setLinkSn(e.target.value)}
autoComplete="off"
/>
<Label htmlFor="liNmSearchInput" className="mr-2 text-label">
연계 명
</Label>
<Input
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem] mr-2"
id="liNmSearchInput"
value={linkNm}
onChange={(e) => setLinkNm(e.target.value)}
autoComplete="off"
/>
<Button color="green" className="h-[3.2rem]" type="button" onClick={handleResetSearchParam}>
초기화
</Button>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
</form>
</div>
<div className="card card-border flex flex-col h-[calc(100vh-28rem)]">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">연계 목록</h3>
<div>
<Button onClick={handleClickRegist}>등록</Button>
</div>
</div>
<div className="card !m-[1.2rem] overflow-auto h-full !p-0">
{!query.isLoading ?
<div>{tableComponent}</div>
:
<div className="[data-theme='black']:bg-transparent rounded-lg p-10 flex justify-center items-center h-full">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
}
</div>
</div>
</div>
);
}
export { LinkPage };
+111
View File
@@ -0,0 +1,111 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { LinkApi, linkFormSchema, type LinkFormData } from "@/entities/link";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import LinkForm from "./link-form";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import { useEffect } from "react";
import { getUserInfo } from "../../../../../../packages/common/components/src/api";
import { AUTH_USER_ID } from "@/shared/config";
import { getCookie } from "@common/components";
function ModifyLinkPage({ linkSn }: { linkSn: string }) {
const router = useRouter();
const queryClient = useQueryClient();
const { setMessage: alert } = useAlertStore((state) => state);
const userUniqId = getCookie(AUTH_USER_ID) ?? "";
// 컴포넌트 마운트 시 캐시 초기화
useEffect(() => {
queryClient.removeQueries({
queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkInfo`],
});
}, [queryClient]);
const { data: linkInfo, refetch } = useQuery({
queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkInfo`],
queryFn: () => LinkApi.getLinkInfo(linkSn),
enabled: !!linkSn,
});
const { mutateAsync: updateLinkInfo } = useMutation({
mutationFn: (data: LinkFormData) => LinkApi.updateLinkInfo({
...data,
link_clsf_cd: data.link_clsf_cd !== 'none' ? data.link_clsf_cd : null,
mdfr: userUniqId
}),
onSuccess: () => {
alert('수정되었습니다.');
router.push('/link');
}
});
const { mutateAsync: deleteLink } = useMutation({
mutationFn: () => LinkApi.deleteLink(linkSn),
onSuccess: () => {
alert('삭제되었습니다.', router.back);
}
});
const { mutateAsync: updateLinkResultCode } = useMutation({
mutationFn: () => LinkApi.updateLinkResultCode(linkSn),
onSuccess: () => {
alert('대기로 전환되었습니다.');
refetch();
}
});
const fetchUserInfo = async () => {
const userData = await getUserInfo();
return userData.userName;
};
const form = useForm<LinkFormData>({
resolver: zodResolver(linkFormSchema),
defaultValues: {},
});
useEffect(() => {
const applyFormData = async () => {
if (linkInfo) {
let newData = { ...linkInfo };
if (linkInfo.mdfr) {
const userName = await fetchUserInfo();
newData = {
...linkInfo,
mdfr: userName,
};
}
form.reset(newData);
}
};
applyFormData();
}, [linkInfo]);
return (
<>
<div className="card card-border !mb-0 !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">연계정보 수정</h3>
</div>
<LinkForm
form={form}
linkSn={linkSn}
updateLinkInfo={updateLinkInfo}
deleteLink={deleteLink}
updateLinkResultCode={updateLinkResultCode}
/>
</div>
</>
);
};
export { ModifyLinkPage };
+64
View File
@@ -0,0 +1,64 @@
"use client";
import { LinkApi, type LinkFormData, linkFormSchema } from "@/entities/link";
import LinkForm from "./link-form";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import dayjs from "dayjs";
import { useMutation } from "@tanstack/react-query";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import { useRouter } from "next/navigation";
import { AUTH_USER_ID } from "@/shared/config";
import { getCookie } from "@common/components";
function RegistLinkPage() {
const { setMessage: alert } = useAlertStore((state) => state);
const router = useRouter();
const userUniqId = getCookie(AUTH_USER_ID) ?? "";
const form = useForm<LinkFormData>({
resolver: zodResolver(linkFormSchema),
defaultValues: {
link_nm: '',
link_url: '',
link_srvr_ip: '',
link_db_cd: '',
link_key: '',
link_id: '',
link_pswd: '',
srvc_usg: '',
link_batch_time: 5,
link_clsf_cd: 'none',
},
});
const { mutate: insertLinkInfo } = useMutation({
mutationFn: (data: LinkFormData) => LinkApi.insertLinkInfo(data),
onSuccess: () => {
alert('저장되었습니다.');
router.push('/link');
}
});
const handleInsertLink = (data: LinkFormData) => {
const insertData = {
...data,
link_clsf_cd: data.link_clsf_cd !== 'none' ? data.link_clsf_cd : null,
next_link_excn_dt: dayjs().add(Number(data.link_batch_time), 'minute').format('YYYY-MM-DD HH:mm:ss'),
mdfr: userUniqId,
};
insertLinkInfo(insertData);
};
return (
<div className="card card-border !mb-0 !mt-6">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">연계 등록</h3>
</div>
<LinkForm form={form} handleInsertLink={handleInsertLink} />
</div>
);
};
export { RegistLinkPage };
+1
View File
@@ -0,0 +1 @@
export { LinkLogPage } from './ui/link-log-page';
@@ -0,0 +1,55 @@
import dayjs from "dayjs";
import { createColumnHelper } from "@tanstack/react-table";
import { type LinkLog } from "@/entities/link";
const columnHelper = createColumnHelper<LinkLog>();
const columns = [
columnHelper.accessor('rownum', {
id: 'rownum',
header: 'NO',
}),
columnHelper.accessor('link_sn', {
id: 'link_sn',
header: '연계일련번호',
}),
columnHelper.accessor('link_nm', {
id: 'link_nm',
header: '연계 명',
}),
columnHelper.display({
id: 'link_rslt_cd',
header: '연계실행결과',
cell: (info) => {
const isSucc = info.row.original.link_rslt_cd.trim() === 'SUCC';
return <span className={`${isSucc ? 'text-[rgb(53,162,235)]' : 'text-[rgb(255,99,132)]'}`}>{isSucc ? '성공' : '실패'}</span>
},
}),
columnHelper.accessor('link_err_cd', {
id: 'link_err_cd',
header: '연계오류코드',
cell: (info) => (info.getValue() ? info.getValue() : '-'),
}),
columnHelper.accessor('err_cn', {
id: 'err_cn',
header: '연계오류메세지',
cell: (info) => (
<div
className="max-w-[500px] truncate"
title={info.getValue() ?? ''}
>
{info.getValue() || '-'}
</div>
),
}),
columnHelper.accessor('log_crt_dt', {
id: 'log_crt_dt',
header: '연계실행일자',
cell: (info) => {
const date = dayjs(info.getValue());
return date.format('YYYY-MM-DD HH:mm');
}
}),
];
export { columns };
@@ -0,0 +1,189 @@
"use client";
import "@common/assets/styles/grid.css";
import { useState } from "react";
import { Button, Input, Label, DatePicker, Select, SelectTrigger, SelectContent, SelectItem, SelectValue, LoadingWithAnimation } from "@common/components";
import { useQuery } from "@tanstack/react-query";
import { getCoreRowModel, type PaginationState, type RowSelectionState, } from "@tanstack/react-table";
import { LinkApi, type LinkSearchParam } from "@/entities/link";
import { columns } from "../model/table-columns";
import dayjs from "dayjs";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import { useDataTable } from "@/shared/lib";
function LinkLogPage() {
const { setMessage: alert } = useAlertStore((state) => state);
const [linkSn, setLinkSn] = useState<string>('');
const [linkNm, setLinkNm] = useState<string>('');
const [isSucc, setIsSucc] = useState<string>('ALL');
const [startDate, setStartDate] = useState<string>(dayjs().subtract(7, "day").format('YYYY-MM-DD'));
const [endDate, setEndDate] = useState<string>(dayjs().format('YYYY-MM-DD'));
const [linkSearchParam, setLinkSearchParam] = useState<LinkSearchParam>({
linkSn: '',
linkNm: '',
isSucc: '',
startDate: '',
endDate: '',
});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 100,
});
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const query = useQuery({
queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkList`, linkSearchParam, pagination],
queryFn: () =>
LinkApi.getLinkLogList({
...linkSearchParam,
page: pagination.pageIndex,
size: pagination.pageSize,
isSucc,
startDate,
endDate,
}),
});
const { tableComponent } = useDataTable({
query,
tableOptions: {
columns,
state: {
pagination,
rowSelection,
},
manualPagination: true,
enableMultiRowSelection: true,
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
},
dataTableOptions: {
viewPagination: true,
},
});
const handleResetSearchParam = () => {
setIsSucc('ALL');
setLinkNm('');
setLinkSn('');
setStartDate(dayjs().subtract(7, "day").format('YYYY-MM-DD'));
setEndDate(dayjs().format('YYYY-MM-DD'));
setLinkSearchParam({
...linkSearchParam,
linkSn: '',
linkNm: '',
isSucc: 'ALL',
startDate: dayjs().subtract(7, "day").format('YYYY-MM-DD'),
endDate: dayjs().format('YYYY-MM-DD'),
});
};
const handleSubmit = async (e?: React.FormEvent<HTMLFormElement>) => {
e?.preventDefault();
if (startDate && endDate) {
if (startDate > endDate) {
alert('종료일이 시작일보다 이전일 수 없습니다.');
return;
} else {
setPagination({
...pagination,
pageIndex: 0,
});
setLinkSearchParam({
...linkSearchParam,
linkSn,
linkNm,
isSucc,
startDate,
endDate,
});
}
} else {
alert('조회 날짜를 선택해주세요.');
return;
}
};
return (
<div className="card card-border !mt-6">
<div className="card card-border">
<form className="flex items-center gap-2 p-2" onSubmit={handleSubmit}>
<Label htmlFor="liSnSearchInput" className="mr-2 text-label">
일련번호
</Label>
<Input
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem] mr-4"
id="liSnSearchInput"
value={linkSn}
onChange={(e) => setLinkSn(e.target.value)}
autoComplete="off"
/>
<Label htmlFor="liNmSearchInput" className="mr-2 text-label">
연계 명
</Label>
<Input
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem] mr-4"
id="liNmSearchInput"
value={linkNm}
onChange={(e) => setLinkNm(e.target.value)}
autoComplete="off"
/>
<Label htmlFor="liResultCode" className="mr-2 text-label">
실행결과
</Label>
<Select onValueChange={(e) => setIsSucc(e)} value={isSucc}>
<SelectTrigger id="liResultCode" className="w-[10rem] mr-4">
<SelectValue placeholder="전체"></SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="ALL">전체</SelectItem>
<SelectItem value="SUCC">성공</SelectItem>
<SelectItem value="FAIL">실패</SelectItem>
</SelectContent>
</Select>
<Label htmlFor="sDateSearchInput" className="mx-2 text-label">
시작일
</Label>
<DatePicker id="sDateSearchInput" mode={"single"} selected={dayjs(startDate).toDate()} onSelect={(e) => setStartDate(dayjs(e).format('YYYY-MM-DD'))} />
<Label htmlFor="eDateSearchInput" className="mx-2 text-label">
종료일
</Label>
<DatePicker id="eDateSearchInput" mode={"single"} className="mr-2" selected={dayjs(endDate).toDate()} onSelect={(e) => setEndDate(dayjs(e).format('YYYY-MM-DD'))} />
<Button color="green" className="h-[3.2rem]" type="button" onClick={handleResetSearchParam}>
초기화
</Button>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
</form>
</div>
<div className="card card-border flex flex-col h-[calc(100vh-28rem)]">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">로그 목록 (검색 조건에 따른 건수 : {query.data?.totalElements.toLocaleString('ko-KR') ?? 0} )</h3>
</div>
<div className="card !m-[1.2rem] overflow-auto h-full !p-0">
{query.isLoading ?
<div className="[data-theme='black']:bg-transparent rounded-lg p-10 h-full flex justify-center items-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
:
<div>{tableComponent}</div>
}
</div>
</div>
</div>
)
};
export { LinkLogPage };
+1
View File
@@ -0,0 +1 @@
export { LinkParamsPage } from './ui/link-params-page';
@@ -0,0 +1,26 @@
import { createColumnHelper } from "@tanstack/react-table";
import { type Link } from "@/entities/link";
const columnHelper = createColumnHelper<Link>();
const columns = [
columnHelper.accessor((_, index) => index + 1, {
id: 'no',
header: 'NO',
}),
columnHelper.accessor('link_sn', {
id: 'link_sn',
header: '연계 일련번호',
}),
columnHelper.accessor('link_nm', {
id: 'link_nm',
header: '연계 명',
}),
columnHelper.accessor('srvc_usg', {
id: 'srvc_usg',
header: '서비스용도',
cell: (info) => (info.getValue() ? info.getValue() : '-'),
}),
];
export { columns };
@@ -0,0 +1,98 @@
import { createColumnHelper } from "@tanstack/react-table";
import { type LinkVrbl } from "@/entities/link";
import { Checkbox } from "@common/components";
const columnHelper = createColumnHelper<LinkVrbl>();
function vrblColumns({
selectedVrblIds,
setSelectedVrblIds,
}: {
selectedVrblIds: string[];
setSelectedVrblIds: React.Dispatch<React.SetStateAction<string[]>>;
}) {
return [
columnHelper.display({
id: "select",
header: ({ table }) => {
const allRows = table.getRowModel().rows;
const allSelected =
allRows.length > 0 &&
allRows.every((row) =>
selectedVrblIds.includes(row.original.link_vrbl_sn)
);
return (
<div className="flex justify-center items-center">
<Checkbox
className="bg-white"
checked={
allSelected
? true
: false
}
onCheckedChange={(checked) => {
if (checked) {
setSelectedVrblIds(allRows.map((row) => row.original.link_vrbl_sn));
} else {
setSelectedVrblIds([]);
}
}}
/>
</div>
);
},
cell: ({ row }) => {
const id = row.original.link_vrbl_sn;
const checked = selectedVrblIds.includes(id);
return (
<div className="flex justify-center items-center">
<Checkbox
checked={checked}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) => {
setSelectedVrblIds((prev) =>
checked ? [...prev, id] : prev.filter((x) => x !== id)
);
}}
/>
</div>
);
},
}),
columnHelper.accessor("link_vrbl_sn", {
id: "link_vrbl_sn",
header: "연계 변수 일련번호",
}),
columnHelper.accessor("link_vrbl_nm", {
id: "link_vrbl_nm",
header: "변수 명",
}),
columnHelper.accessor("link_vrbl_cd", {
id: "link_vrbl_cd",
header: "변수코드",
cell: (info) => (info.getValue() ? info.getValue() : '-'),
}),
columnHelper.accessor("link_vrbl_vl", {
id: "link_vrbl_vl",
header: "변수 값",
cell: (info) => (info.getValue() === 'now' ? '요청일자' : info.getValue() === 'nowMonth' ? '요청월' : info.getValue()),
}),
columnHelper.accessor("link_vrbl_expln", {
id: "link_vrbl_expln",
header: "설명",
cell: (info) => (
<div
className="max-w-72 truncate"
title={info.getValue() ?? ''}
>
{info.getValue() || '-'}
</div>
),
}),
];
}
export { vrblColumns };
@@ -0,0 +1,351 @@
'use client';
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
Button,
Input,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Checkbox,
RadioGroup,
RadioGroupItem,
Label
} from "@common/components";
import { FormItem, FormControl, FormField, Form } from "@/shared/ui/form";
import { type LinkVrblFormData } from "@/entities/link";
import dayjs from "dayjs";
import type { UseFormReturn } from "react-hook-form";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useState, useEffect } from "react";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
type LinkParamsDialogProps = {
selectedLink: {
linkSn: string;
linkNm: string;
};
form: UseFormReturn<LinkVrblFormData>;
insertLinkVrbl: (data: LinkVrblFormData) => void;
dialogOpen: boolean;
onOpenChange: (val: boolean) => void;
updateLinkVrbl: (data: LinkVrblFormData) => void;
}
function LinkParamsDialog({ selectedLink, form, insertLinkVrbl, dialogOpen, onOpenChange, updateLinkVrbl }: LinkParamsDialogProps) {
const isModifyMode = !!form.getValues('link_vrbl_sn');
const { confirm } = useConfirm();
const [onConfirm, setOnConfirm] = useState<boolean>(false);
const [isToday, setIsToday] = useState<boolean>(false);
const [checkNowMonth, setCheckNowMonth] = useState<string>('now');
const handleConfirm = async (callback: () => Promise<void>) => {
setOnConfirm(true);
await callback();
setOnConfirm(false);
};
function replaceEmptyWithNull<T extends Record<string, unknown>>(data: T): T {
const result = {} as T;
for (const [key, value] of Object.entries(data)) {
(result as Record<string, unknown>)[key] = value === '' ? null : value;
}
return result as T;
};
const handleSubmit = async (data: LinkVrblFormData) => {
const confirmMsg = isModifyMode ? '수정 하시겠습니까?' : '저장 하시겠습니까?';
await handleConfirm(async () => {
const cleanData = replaceEmptyWithNull(data);
if (await confirm(confirmMsg)) {
if (isModifyMode) {
updateLinkVrbl(isToday ? { ...cleanData, link_vrbl_vl: checkNowMonth } : cleanData);
} else {
insertLinkVrbl(isToday ? { ...cleanData, link_vrbl_vl: checkNowMonth } : cleanData);
}
}
});
};
const handleCheckTodayValue = (check: boolean | string) => {
if (check) {
setIsToday(true);
form.setValue('link_vrbl_vl', (checkNowMonth === 'now' ? '요청일자' : '요청월'));
} else {
setIsToday(false);
form.setValue('link_vrbl_vl', '');
}
};
useEffect(() => {
if (!dialogOpen) {
setIsToday(false);
setCheckNowMonth('now');
form.reset({
link_vrbl_nm: '',
link_vrbl_expln: '',
link_vrbl_cd: '',
link_vrbl_vl: '',
});
}
if (isModifyMode && (form.getValues('link_vrbl_vl') === 'now' || form.getValues('link_vrbl_vl') === 'nowMonth')) {
setIsToday(true);
setCheckNowMonth(form.getValues('link_vrbl_vl'));
}
}, [dialogOpen]);
return (
<Dialog
open={dialogOpen}
onOpenChange={(open) => {
onOpenChange(open);
}}
>
<DialogTrigger asChild>
<Button color="green" className="h-[3.2rem]" onClick={() => onOpenChange(true)}>행 추가</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[55rem]">
<DialogHeader>
<DialogTitle>
{selectedLink.linkNm} ({selectedLink.linkSn})
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)}>
<div className="card">
<Table variant="secondary">
<colgroup>
<col width="30%" />
<col width="70%" />
</colgroup>
<TableBody>
{isModifyMode &&
<TableRow>
<TableHead>변수 일련번호</TableHead>
<TableCell>
<FormField
control={form.control}
name="link_vrbl_sn"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
disabled
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
}
<TableRow>
<TableHead>* 변수 명</TableHead>
<TableCell>
<FormField
control={form.control}
name="link_vrbl_nm"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
autoComplete="off"
/>
</FormControl>
{form.formState.errors.link_vrbl_nm && (
<p className="text-red-500 text-base pt-2">
{form.formState.errors.link_vrbl_nm.message}
</p>
)}
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>변수 설명</TableHead>
<TableCell>
<FormField
control={form.control}
name="link_vrbl_expln"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
autoComplete="off"
/>
</FormControl>
{form.formState.errors.link_vrbl_expln && (
<p className="text-red-500 text-base pt-2">
{form.formState.errors.link_vrbl_expln.message}
</p>
)}
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>변수 코드</TableHead>
<TableCell>
<FormField
control={form.control}
name="link_vrbl_cd"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
autoComplete="off"
/>
</FormControl>
{form.formState.errors.link_vrbl_cd && (
<p className="text-red-500 text-base pt-2">
{form.formState.errors.link_vrbl_cd.message}
</p>
)}
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>* 변수 값</TableHead>
<TableCell>
<FormField
control={form.control}
name="link_vrbl_vl"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-x-2 mb-4">
<Checkbox id="isToday" className="!border-gray-600" checked={isToday} onCheckedChange={(check) => handleCheckTodayValue(check)} />
<label htmlFor="isToday" className="text-gray-600">변수 값이 오늘 날짜 일시 체크해 주세요.</label>
</div>
{isToday &&
<div className="flex items-center gap-x-2 mb-4">
<RadioGroup
name="checkNowMonth"
onValueChange={(value) => {
setCheckNowMonth(value);
form.setValue('link_vrbl_vl', (value === 'now' ? '요청일자' : '요청월'));
}}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="now" id="now" checked={checkNowMonth === 'now'} />
<Label htmlFor="now">요청일자(YYYY-MM-DD)</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="nowMonth" id="nowMonth" checked={checkNowMonth === 'nowMonth'} />
<Label htmlFor="nowMonth">요청월(YYYY-MM)</Label>
</div>
</RadioGroup>
</div>
}
<FormControl>
<Input
{...field}
value={field.value ? (field.value === 'now' ? '요청일자' : field.value === 'nowMonth' ? '요청월' : field.value) : ''}
className={`!text-[1.3rem] ${isToday ? 'bg-gray-100' : ''}`}
autoComplete="off"
disabled={isToday}
/>
</FormControl>
{form.formState.errors.link_vrbl_vl && (
<p className="text-red-500 text-base pt-2">
{form.formState.errors.link_vrbl_vl.message}
</p>
)}
</FormItem>
)}
/>
</TableCell>
</TableRow>
{isModifyMode &&
<>
<TableRow>
<TableHead>수정자</TableHead>
<TableCell>
<FormField
control={form.control}
name="mdfr"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
disabled
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>수정일시</TableHead>
<TableCell>
<FormField
control={form.control}
name="mdfcn_dt"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ? dayjs(field.value).format('YYYY-MM-DD') : ""}
className="!text-[1.3rem]"
disabled
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
</>
}
</TableBody>
</Table>
</div>
<DialogFooter>
<Button type="submit">저장</Button>
<DialogClose asChild>
<Button color="white">취소</Button>
</DialogClose>
</DialogFooter>
</form>
</Form>
<ConfirmDialog isOpen={onConfirm} />
</DialogContent>
</Dialog>
)
};
export { LinkParamsDialog };
@@ -0,0 +1,405 @@
'use client';
import { useState, useMemo } from "react";
import {
Button,
DataTable,
Input,
Label,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
LoadingWithAnimation,
} from "@common/components";
import { getCoreRowModel, useReactTable, type PaginationState, type RowSelectionState, } from "@tanstack/react-table";
import { LinkApi, type Link, type LinkSearchParam, type LinkVrblFormData, linkVrblFormSchema } from "@/entities/link";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { columns } from "../model/table-columns";
import { vrblColumns } from "../model/vrbl-table-columns";
import { type LinkVrbl } from "@/entities/link";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import { LinkParamsDialog } from "./link-params-dialog";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { getUserInfo } from "../../../../../../packages/common/components/src/api";
import { AUTH_USER_ID } from "@/shared/config";
import { getCookie } from "@common/components";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { useDataTable } from "@/shared/lib";
function LinkParamsPage() {
const queryClient = useQueryClient();
const [linkSn, setLinkSn] = useState<string>('');
const [linkNm, setLinkNm] = useState<string>('');
const [linkSearchParam, setLinkSearchParam] = useState<LinkSearchParam>({
linkSn: '',
linkNm: ''
});
const [selectedLink, setSelectedLink] = useState<{
linkSn: string;
linkNm: string;
}>({
linkSn: '',
linkNm: ''
});
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 100,
});
const { confirm } = useConfirm();
const [selectedVrblIds, setSelectedVrblIds] = useState<string[]>([]);
const [isDBdata, setIsDBData] = useState<boolean>(false);
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
const [onConfirm, setOnConfirm] = useState<boolean>(false);
const userUniqId = getCookie(AUTH_USER_ID) ?? "";
const { setMessage: alert } = useAlertStore((state) => state);
const dbIdList = ['link0001', 'link0002'];
// Link 목록 쿼리
const query = useQuery({
queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkList`, linkSearchParam],
queryFn: () => LinkApi.getLinkList({
...linkSearchParam,
page: pagination.pageIndex,
size: pagination.pageSize,
}),
});
// LinkVrbl 목록 쿼리 - 별도 관리
const linkVrblQuery = useQuery({
queryKey: ['linkVrblList', selectedLink.linkSn],
queryFn: () => LinkApi.getLinkVrblList(selectedLink.linkSn),
enabled: !!selectedLink.linkSn && !dbIdList.includes(selectedLink.linkSn),
staleTime: 0, // 항상 최신 데이터를 가져오도록 설정
});
const handleResetSearchParam = () => {
setSelectedLink({
linkSn: '',
linkNm: ''
});
setLinkSn('');
setLinkNm('');
setLinkSearchParam({
...linkSearchParam,
linkSn: '',
linkNm: ''
});
};
const handleConfirm = async (callback: () => Promise<void>) => {
setOnConfirm(true);
await callback();
setOnConfirm(false);
};
const { mutateAsync: insertLinkVrbl } = useMutation({
mutationFn: async (data: LinkVrblFormData) =>
await LinkApi.insertLinkVrbl({
...data,
link_sn: selectedLink.linkSn,
mdfr: userUniqId,
}),
onSuccess: async () => {
// 쿼리 무효화를 통한 최신 데이터 가져오기
await queryClient.invalidateQueries({
queryKey: ['linkVrblList', selectedLink.linkSn]
});
setDialogOpen(false);
alert('저장되었습니다.');
}
});
const { mutateAsync: updateLinkVrbl } = useMutation({
mutationFn: async (data: LinkVrblFormData) => await LinkApi.updateLinkVrblInfo({
...data,
link_sn: selectedLink.linkSn,
mdfr: userUniqId,
}),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ['linkVrblList', selectedLink.linkSn]
});
setDialogOpen(false);
alert('저장되었습니다.');
}
});
const { mutateAsync: deleteLinkVrbl } = useMutation({
mutationFn: async (data: string[]) => await LinkApi.deleteLinkVrbl(data),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ['linkVrblList', selectedLink.linkSn]
});
setSelectedVrblIds([]);
alert('삭제되었습니다.');
}
});
// linkVrblQuery의 데이터를 사용
const tableData = useMemo(() => {
return linkVrblQuery.data?.content ?? [];
}, [linkVrblQuery.data]);
const vrblCols = useMemo(
() =>
vrblColumns({
selectedVrblIds,
setSelectedVrblIds,
}),
[selectedVrblIds]
);
const vrblTable = useReactTable({
data: tableData,
columns: vrblCols,
getCoreRowModel: getCoreRowModel(),
});
const form = useForm<LinkVrblFormData>({
resolver: zodResolver(linkVrblFormSchema),
defaultValues: {},
});
const handleRowClick = async (link: Link) => {
setSelectedLink({
linkSn: link.link_sn,
linkNm: link.link_nm,
});
setSelectedVrblIds([]);
if (dbIdList.includes(link.link_clsf_cd)) {
setIsDBData(true);
} else {
setIsDBData(false);
}
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setSelectedLink({
linkSn: '',
linkNm: ''
});
setLinkSearchParam({
...linkSearchParam,
linkSn,
linkNm
});
};
const fetchUserInfo = async () => {
const userData = await getUserInfo();
return userData.userName;
};
const handleDeleteParam = async () => {
if (selectedVrblIds.length === 0) {
alert('삭제할 파라미터를 선택해 주세요.');
} else {
await handleConfirm(async () => {
if (await confirm('선택한 파라미터 정보를 삭제하시겠습니까?')) {
deleteLinkVrbl(selectedVrblIds);
}
});
}
};
const handleVrblRowClick = async (data: LinkVrbl) => {
const linkVrblInfo = await LinkApi.getLinkVrblInfo(data.link_vrbl_sn);
if (linkVrblInfo) {
let newData = { ...linkVrblInfo };
if (linkVrblInfo.mdfr) {
const userName = await fetchUserInfo();
newData = {
...linkVrblInfo,
mdfr: userName,
};
}
form.reset(newData);
}
setDialogOpen(true);
};
const { tableComponent } = useDataTable({
query,
tableOptions: {
columns,
state: {
pagination,
rowSelection,
},
manualPagination: true,
enableMultiRowSelection: true,
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
},
dataTableOptions: {
onRowClick: handleRowClick,
viewPagination: true,
},
});
// LinkVrbl 데이터 표시 로직
const renderLinkVrblContent = () => {
if (isDBdata) {
return (
<div className="flex items-center justify-center mt-14 text-gray-500 text-3xl flex-1">
변수를 지원하지 않는 연계 데이터입니다.
</div>
);
}
if (!selectedLink.linkSn) {
return (
<div className="flex items-center justify-center mt-14 text-gray-500 text-3xl flex-1">
연계 데이터를 선택해주세요.
</div>
);
}
if (linkVrblQuery.isLoading) {
return (
<div className="[data-theme='black']:bg-transparent rounded-lg p-10 flex justify-center items-center h-full">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
);
}
return (
<>
<div className="mb-2 mt-14 flex justify-end items-center flex-none">
{tableData.length > 0 && (
<Button color="red" className="h-[3.2rem] mr-2" type="button" onClick={handleDeleteParam}>
행 삭제
</Button>
)}
<LinkParamsDialog
selectedLink={selectedLink}
form={form}
insertLinkVrbl={insertLinkVrbl}
dialogOpen={dialogOpen}
onOpenChange={setDialogOpen}
updateLinkVrbl={updateLinkVrbl}
/>
</div>
{tableData.length > 0 ? (
<div className="overflow-auto flex-1 min-h-[300px]">
<DataTable table={vrblTable} onRowClick={handleVrblRowClick} />
</div>
) : (
<div className="flex items-center justify-center h-20 text-gray-500 text-3xl">
등록된 연계 변수가 없습니다.
</div>
)}
</>
);
};
return (
<div className="card card-border !mt-6">
<div className="card card-border">
<form className="flex items-center gap-2 p-2" onSubmit={handleSubmit}>
<Label htmlFor="liSnSearchInput" className="mr-2 text-label">
일련번호
</Label>
<Input
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem] mr-4"
id="liSnSearchInput"
value={linkSn}
onChange={(e) => setLinkSn(e.target.value)}
autoComplete="off"
/>
<Label htmlFor="liNmSearchInput" className="mr-2 text-label">
연계 명
</Label>
<Input
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem] mr-2"
id="liNmSearchInput"
value={linkNm}
onChange={(e) => setLinkNm(e.target.value)}
autoComplete="off"
/>
<Button color="green" className="h-[3.2rem]" type="button" onClick={handleResetSearchParam}>
초기화
</Button>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
</form>
</div>
<div className="card !mb-0 flex h-[calc(100vh-28rem)] gap-5 !p-0">
<div className="card card-border w-1/2 !mx-0 flex flex-col">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">연계 목록</h3>
</div>
<div className="card !m-[1.2rem] overflow-auto !p-0 h-full">
{!query.isLoading ? (
<div>{tableComponent}</div>
) : (
<div className="[data-theme='black']:bg-transparent rounded-lg p-10 flex justify-center items-center h-full">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
)}
</div>
</div>
<div className="card card-border w-1/2 !mx-0 flex flex-col">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">연계 변수 정보</h3>
</div>
<div className="card !m-[1.2rem] !p-0 overflow-auto h-full flex flex-col">
<Table variant="secondary" className="flex-none">
<colgroup>
<col width="20%" />
<col width="80%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead>연계 일련번호</TableHead>
<TableCell>
{selectedLink.linkSn ? selectedLink.linkSn : '-'}
</TableCell>
</TableRow>
<TableRow>
<TableHead>연계 명</TableHead>
<TableCell>
{selectedLink.linkNm ? selectedLink.linkNm : '-'}
</TableCell>
</TableRow>
</TableBody>
</Table>
{renderLinkVrblContent()}
</div>
</div>
</div>
<ConfirmDialog isOpen={onConfirm} />
</div>
);
}
export { LinkParamsPage };
+1
View File
@@ -0,0 +1 @@
export { LinkStatsPage } from './ui/link-stats-page';
@@ -0,0 +1,378 @@
"use client";
import { useState, useEffect, useRef } from "react";
import dayjs from "dayjs";
import { LinkApi, type LinkStats, type LinkSearchParam } from "@/entities/link";
import { Button, Label, DatePicker, LoadingWithAnimation } from "@common/components";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Tooltip,
Legend,
type ChartOptions,
type Chart,
} from "chart.js";
import { Line } from "react-chartjs-2";
import { getCoreRowModel, type PaginationState, type RowSelectionState, } from "@tanstack/react-table";
import { columns } from "@/pages/linkLog/model/table-columns";
import { useDataTable } from "@/shared/lib";
import { useQuery } from "@tanstack/react-query";
interface LinkStatsListObject {
linkSuccStats: LinkStats[] | null;
linkFailStats: LinkStats[] | null;
}
function LinkStatsPage() {
const succChartRef = useRef<Chart<"line"> | null>(null);
const failChartRef = useRef<Chart<"line"> | null>(null);
const [startDate, setStartDate] = useState<string>(dayjs().subtract(7, "day").format('YYYY-MM-DD'));
const [endDate, setEndDate] = useState<string>(dayjs().format('YYYY-MM-DD'));
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 100,
});
const [linkStatsList, setLinkStatsList] = useState<LinkStatsListObject | null>(null);
const [logQueryParam, setLogQueryParam] = useState<{
isSucc: 'SUCC' | 'FAIL';
linkSn?: string;
linkNm?: string;
} | null>(null);
const query = useQuery({
queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkList`, logQueryParam, pagination],
queryFn: () =>
LinkApi.getLinkLogList({
...logQueryParam,
page: pagination.pageIndex,
size: pagination.pageSize,
startDate,
endDate,
}),
enabled: !!logQueryParam,
});
const { tableComponent } = useDataTable({
query,
tableOptions: {
columns,
state: {
pagination,
rowSelection,
},
manualPagination: true,
enableMultiRowSelection: true,
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
},
dataTableOptions: {
viewPagination: true,
},
});
const [linkSearchParam, setLinkSearchParam] = useState<LinkSearchParam>({
linkSn: '',
linkNm: '',
startDate: '',
endDate: '',
});
ChartJS.register(
LineElement,
CategoryScale,
LinearScale,
PointElement,
Tooltip,
Legend
);
const succData = {
labels: linkStatsList?.linkSuccStats?.map((e) => e.link_nm),
datasets: [
{
label: '연계 성공',
data: linkStatsList?.linkSuccStats?.map((e) => e.count),
fill: false,
borderColor: 'rgb(53, 162, 235)',
backgroundColor: 'rgb(53, 162, 235)',
tension: 0.1,
pointRadius: 6,
pointHoverRadius: 8,
},
],
};
const succOptions: ChartOptions<'line'> = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
},
},
scales: {
y: {
min: 0,
},
},
};
const failData = {
labels: linkStatsList?.linkFailStats?.map((e) => e.link_nm),
datasets: [
{
label: '연계 실패',
data: linkStatsList?.linkFailStats?.map((e) => e.count),
fill: false,
borderColor: 'rgb(255, 99, 132)',
backgroundColor: 'rgb(255, 99, 132)',
tension: 0.1,
pointRadius: 6,
pointHoverRadius: 8,
},
],
};
const failOptions: ChartOptions<'line'> = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
},
},
scales: {
y: {
min: 0,
},
},
};
const handleResetSearchParam = async () => {
setLinkSearchParam({
linkSn: '',
linkNm: '',
startDate: dayjs().subtract(7, "day").format('YYYY-MM-DD'),
endDate: dayjs().format('YYYY-MM-DD'),
});
setLogQueryParam({
isSucc: 'SUCC',
linkNm: '',
linkSn: '',
});
setStartDate(dayjs().subtract(7, "day").format('YYYY-MM-DD'));
setEndDate(dayjs().format('YYYY-MM-DD'));
const data = await LinkApi.getLinkStatsList({
...linkSearchParam,
linkSn: '',
linkNm: '',
startDate: dayjs().subtract(7, "day").format('YYYY-MM-DD'),
endDate: dayjs().format('YYYY-MM-DD'),
});
setLinkStatsList(data);
};
const handleSubmit = async (e?: React.FormEvent<HTMLFormElement>) => {
e?.preventDefault();
if (startDate && endDate) {
if (startDate > endDate) {
alert('종료일이 시작일보다 이전일 수 없습니다.');
return;
} else {
const data = await LinkApi.getLinkStatsList({
...linkSearchParam,
startDate,
endDate
});
setLinkStatsList(data);
setLogQueryParam({
isSucc: 'SUCC',
linkNm: '',
linkSn: '',
});
setLinkSearchParam({
...linkSearchParam,
startDate,
endDate
});
}
} else {
alert('조회 날짜를 선택해주세요.');
return;
}
};
const handleSuccChartClick = async (event: React.MouseEvent<HTMLCanvasElement>) => {
if (!succChartRef.current) return;
const chart = succChartRef.current;
const points = chart.getElementsAtEventForMode(
event.nativeEvent,
"nearest",
{ intersect: true },
false
);
if (points[0] && linkStatsList?.linkSuccStats) {
const index = points[0].index;
setPagination({
...pagination,
pageIndex: 0,
pageSize: 100,
});
setLogQueryParam({
isSucc: 'SUCC',
linkNm: linkStatsList.linkSuccStats[index]?.link_nm,
linkSn: linkStatsList.linkSuccStats[index]?.link_sn,
});
}
};
const handleFailChartClick = async (event: React.MouseEvent<HTMLCanvasElement>) => {
if (!failChartRef.current) return;
const chart = failChartRef.current;
const points = chart.getElementsAtEventForMode(
event.nativeEvent,
"nearest",
{ intersect: true },
false
);
if (points[0] && linkStatsList?.linkFailStats) {
const index = points[0].index;
setPagination({
...pagination,
pageIndex: 0,
pageSize: 100,
});
setLogQueryParam({
isSucc: 'FAIL',
linkNm: linkStatsList.linkFailStats[index]?.link_nm,
linkSn: linkStatsList.linkFailStats[index]?.link_sn,
});
}
};
const handleSubmitToday = async () => {
const data = await LinkApi.getLinkStatsList({
...linkSearchParam,
startDate: dayjs().format('YYYY-MM-DD'),
endDate: dayjs().format('YYYY-MM-DD'),
});
setEndDate(dayjs().format('YYYY-MM-DD'));
setStartDate(dayjs().format('YYYY-MM-DD'));
setLinkStatsList(data);
setLinkSearchParam({
...linkSearchParam,
startDate: dayjs().format('YYYY-MM-DD'),
endDate: dayjs().format('YYYY-MM-DD'),
});
setLogQueryParam({
isSucc: 'SUCC',
linkNm: '',
linkSn: '',
});
};
useEffect(() => {
const fetchData = async () => {
const data = await LinkApi.getLinkStatsList({
...linkSearchParam,
startDate,
endDate
});
setLinkStatsList(data);
};
setLinkSearchParam({
...linkSearchParam,
startDate,
endDate
});
fetchData();
}, []);
return (
<div className="card !mb-0 !min-h-[calc(100vh-16.8rem)]">
<div className="card card-border">
<form className="flex items-center gap-2 p-2" onSubmit={handleSubmit}>
<Label htmlFor="sDateSearchInput" className="mx-2 text-label">
시작일
</Label>
<DatePicker id="sDateSearchInput" mode={"single"} selected={dayjs(startDate).toDate()} onSelect={(e) => setStartDate(dayjs(e).format('YYYY-MM-DD'))} />
<Label htmlFor="eDateSearchInput" className="mx-2 text-label">
종료일
</Label>
<DatePicker id="eDateSearchInput" mode={"single"} className="mr-2" selected={dayjs(endDate).toDate()} onSelect={(e) => setEndDate(dayjs(e).format('YYYY-MM-DD'))} />
<Button color="green" className="h-[3.2rem]" type="button" onClick={handleResetSearchParam}>
초기화
</Button>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
<Button className="h-[3.2rem]" type="button" onClick={handleSubmitToday}>
오늘 날짜로 조회
</Button>
</form>
</div>
<div className="card card-border">
<div className="card !m-[1.2rem] !p-0 flex flex-col gap-y-8">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">조회 일자 : {`${linkSearchParam.startDate} ~ ${linkSearchParam.endDate}`}</h3>
</div>
<div className="h-[32rem] w-full">
<Line ref={succChartRef} data={succData} options={succOptions} onClick={handleSuccChartClick} />
</div>
<div className="h-[32rem]">
<Line ref={failChartRef} data={failData} options={failOptions} onClick={handleFailChartClick} />
</div>
</div>
<div className="card !m-[1.2rem] !p-0">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">
{logQueryParam?.linkNm ? `${logQueryParam.linkNm} 연계 ${logQueryParam.isSucc === 'FAIL' ? '실패' : '성공'} 로그 목록 (${linkSearchParam.startDate} ~ ${linkSearchParam.endDate}) - ${query.data?.totalElements.toLocaleString('ko-KR') ?? 0}건` : '로그 목록'}
</h3>
</div>
<div className="card !m-[1.2rem] h-[40rem] overflow-auto !p-0">
{logQueryParam?.linkNm ?
query.isLoading ?
<div className="[data-theme='black']:bg-transparent rounded-lg p-10 h-full flex justify-center items-center">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
:
<div>{tableComponent}</div>
:
<div className="flex items-center justify-center h-full text-gray-500 text-3xl">
차트 데이터를 선택해주세요.
</div>
}
</div>
</div>
</div>
</div>
)
};
export { LinkStatsPage };
+1
View File
@@ -0,0 +1 @@
export { LinkTablePage} from "./ui/link-table-page";
@@ -0,0 +1,103 @@
import { createColumnHelper } from "@tanstack/react-table";
import { type LinkTableColumns } from "@/entities/link";
import { Checkbox } from "@common/components";
const columnHelper = createColumnHelper<LinkTableColumns>();
function tableColumns({ selectedColIds, setSelectedColIds }: {
selectedColIds: string[];
setSelectedColIds: React.Dispatch<React.SetStateAction<string[]>>;
}) {
return [
columnHelper.display({
id: "select",
header: ({ table }) => {
const allRows = table.getRowModel().rows;
const allSelected =
allRows.length > 0 &&
allRows.every((row) =>
selectedColIds.includes(row.original.col_sn)
);
return (
<div className="flex justify-center items-center">
<Checkbox
className="bg-white"
checked={
allSelected
? true
: false
}
onCheckedChange={(checked) => {
if (checked) {
setSelectedColIds(allRows.map((row) => row.original.col_sn));
} else {
setSelectedColIds([]);
}
}}
/>
</div>
);
},
cell: ({ row }) => {
const id = row.original.col_sn;
const checked = selectedColIds.includes(id);
return (
<div className="flex justify-center items-center">
<Checkbox
checked={checked}
onClick={(e) => e.stopPropagation()}
onCheckedChange={(checked) => {
setSelectedColIds((prev) =>
checked ? [...prev, id] : prev.filter((x) => x !== id)
);
}}
/>
</div>
);
},
}),
columnHelper.accessor('col_sn', {
id: 'col_sn',
header: '컬럼 일련번호'
}),
columnHelper.accessor('sou_col_nm', {
id: 'sou_col_nm',
header: '원천컬럼명',
cell: (info) => info.getValue() ? info.getValue() : '-',
}),
columnHelper.accessor('sou_col_korn_nm', {
id: 'sou_col_korn_nm',
header: '원천컬럼 한글명',
cell: (info) => info.getValue() ? info.getValue() : '-',
}),
columnHelper.accessor('sou_col_sz', {
id: 'sou_col_sz',
header: '원천컬럼 크기',
cell: (info) => info.getValue() ? info.getValue() : '-',
}),
columnHelper.accessor('trsf_col_nm', {
id: 'trsf_col_nm',
header: '변환컬럼명',
cell: (info) => info.getValue() ? info.getValue() : '-',
}),
columnHelper.accessor('trsf_col_korn_nm', {
id: 'trsf_col_korn_nm',
header: '변환컬럼 한글명',
cell: (info) => info.getValue() ? info.getValue() : '-',
}),
columnHelper.accessor('trsf_col_sz', {
id: 'trsf_col_sz',
header: '변환컬럼 크기',
cell: (info) => info.getValue() ? info.getValue() : '-',
}),
columnHelper.accessor('pk_yn', {
id: 'pk_yn',
header: 'PK 여부',
cell: (info) => info.getValue() === 'Y' ? '예' : '아니오',
}),
]
}
export { tableColumns };
@@ -0,0 +1,503 @@
'use client';
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogClose,
Button,
Input,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
Checkbox
} from "@common/components";
import { FormItem, FormControl, FormField, Form } from "@/shared/ui/form";
import { type LinkTableColData, type LinkTable } from "@/entities/link";
import dayjs from "dayjs";
import type { UseFormReturn } from "react-hook-form";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useState, useEffect } from "react";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
interface LinkTableDialogProps {
dialogOpen: boolean;
onOpenChange: (val: boolean) => void;
form: UseFormReturn<LinkTableColData>;
tableInfo: LinkTable;
insertLinkTableCol: (data: LinkTableColData) => void;
updateLinkTableCol: (data: LinkTableColData) => void;
}
function LinkTableDialog({ dialogOpen, onOpenChange, form, tableInfo, insertLinkTableCol, updateLinkTableCol }: LinkTableDialogProps) {
const isModifyMode = !!form.getValues('col_sn');
const { confirm } = useConfirm();
const [onConfirm, setOnConfirm] = useState<boolean>(false);
const [useColSeq, setUseColSeq] = useState<boolean>(false);
const handleConfirm = async (callback: () => Promise<void>) => {
setOnConfirm(true);
await callback();
setOnConfirm(false);
};
const handleSubmit = async (data: LinkTableColData) => {
const confirmMsg = isModifyMode ? '수정 하시겠습니까?' : '저장 하시겠습니까?';
await handleConfirm(async () => {
if (await confirm(confirmMsg)) {
if (!isModifyMode) {
insertLinkTableCol({
...data,
link_sn: tableInfo.link_sn,
tbl_sn: tableInfo.tbl_sn,
});
} else {
updateLinkTableCol({
...data,
link_sn: tableInfo.link_sn,
tbl_sn: tableInfo.tbl_sn,
});
}
}
});
};
const handleCheckColSeqValue = (check: boolean | string) => {
if (check) {
setUseColSeq(false);
form.setValue('col_seq', null);
} else {
setUseColSeq(true);
}
};
useEffect(() => {
if (!dialogOpen) {
setUseColSeq(false);
form.reset({
tbl_sn: '',
col_sn: '',
sou_col_nm: '',
sou_col_korn_nm: '',
sou_col_data_frm: '',
sou_col_sz: null,
trsf_col_nm: '',
trsf_col_korn_nm: '',
trsf_col_data_frm: '',
trsf_col_sz: null,
col_seq: null,
pk_yn: 'N',
mdfcn_dt: '',
mdfr: '',
});
}
if (isModifyMode && form.getValues('col_seq')) {
setUseColSeq(true);
}
}, [dialogOpen]);
return (
<Dialog
open={dialogOpen}
onOpenChange={(open) => {
onOpenChange(open);
}}
>
<DialogTrigger asChild>
<Button color="green" className="h-[3.2rem]" onClick={() => onOpenChange(true)}>행 추가</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[55rem]">
<DialogHeader>
<DialogTitle>
{tableInfo.trsf_tbl_korn_nm} ({tableInfo.tbl_sn})
</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)}>
<div className="card">
<Table variant="secondary">
<colgroup>
<col width="30%" />
<col width="70%" />
</colgroup>
<TableBody>
{isModifyMode &&
<TableRow>
<TableHead>컬럼 일련번호</TableHead>
<TableCell>
<FormField
control={form.control}
name="col_sn"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
disabled
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
}
<TableRow>
<TableHead>* 원천컬럼명</TableHead>
<TableCell>
<FormField
control={form.control}
name="sou_col_nm"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
autoComplete="off"
/>
</FormControl>
{form.formState.errors.sou_col_nm && (
<p className="text-red-500 text-base pt-2">
{form.formState.errors.sou_col_nm.message}
</p>
)}
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>* 원천컬럼 한글명</TableHead>
<TableCell>
<FormField
control={form.control}
name="sou_col_korn_nm"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
autoComplete="off"
/>
</FormControl>
{form.formState.errors.sou_col_korn_nm && (
<p className="text-red-500 text-base pt-2">
{form.formState.errors.sou_col_korn_nm.message}
</p>
)}
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>* 원천컬럼 데이터 형식</TableHead>
<TableCell>
<FormField
control={form.control}
name="sou_col_data_frm"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
autoComplete="off"
/>
</FormControl>
{form.formState.errors.sou_col_data_frm && (
<p className="text-red-500 text-base pt-2">
{form.formState.errors.sou_col_data_frm.message}
</p>
)}
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>원천컬럼 크기</TableHead>
<TableCell>
<FormField
control={form.control}
name="sou_col_sz"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
autoComplete="off"
type="number"
min={0}
max={99999999999999}
onChange={(e) => {
const onlyDigits = e.target.value.replace(/\D/g, "").slice(0, 14);
field.onChange(onlyDigits); // react-hook-form에 반영
}}
/>
</FormControl>
<p className="text-gray-500 text-base pt-2">
※ 14자리까지 입력 가능합니다.
</p>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>PK 여부</TableHead>
<TableCell>
<FormField
control={form.control}
name="pk_yn"
render={({ field }) => (
<FormItem>
<FormControl>
<Select value={field.value ?? "N"} onValueChange={field.onChange}>
<SelectTrigger id="liResultCode" className="w-[10rem] mr-4">
<SelectValue placeholder="아니오"></SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="Y">예</SelectItem>
<SelectItem value="N">아니오</SelectItem>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>* 변환컬럼명</TableHead>
<TableCell>
<FormField
control={form.control}
name="trsf_col_nm"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
autoComplete="off"
/>
</FormControl>
{form.formState.errors.trsf_col_nm && (
<p className="text-red-500 text-base pt-2">
{form.formState.errors.trsf_col_nm.message}
</p>
)}
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>* 변환컬럼 한글명</TableHead>
<TableCell>
<FormField
control={form.control}
name="trsf_col_korn_nm"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
autoComplete="off"
/>
</FormControl>
{form.formState.errors.trsf_col_korn_nm && (
<p className="text-red-500 text-base pt-2">
{form.formState.errors.trsf_col_korn_nm.message}
</p>
)}
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>* 변환컬럼 데이터 형식</TableHead>
<TableCell>
<FormField
control={form.control}
name="trsf_col_data_frm"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
autoComplete="off"
/>
</FormControl>
{form.formState.errors.trsf_col_data_frm && (
<p className="text-red-500 text-base pt-2">
{form.formState.errors.trsf_col_data_frm.message}
</p>
)}
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>변환컬럼 크기</TableHead>
<TableCell>
<FormField
control={form.control}
name="trsf_col_sz"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
autoComplete="off"
type="number"
min={0}
max={99999999999999}
onChange={(e) => {
const onlyDigits = e.target.value.replace(/\D/g, "").slice(0, 14);
field.onChange(onlyDigits); // react-hook-form에 반영
}}
/>
</FormControl>
<p className="text-gray-500 text-base pt-2">
※ 14자리까지 입력 가능합니다.
</p>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>컬럼 매칭 순서</TableHead>
<TableCell>
<FormField
control={form.control}
name="col_seq"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-x-2 mb-4">
<Checkbox id="useColSeq" className="!border-gray-600" checked={!useColSeq} onCheckedChange={(check) => handleCheckColSeqValue(check)} />
<label htmlFor="useColSeq" className="text-gray-600">사용안함</label>
</div>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
autoComplete="off"
type="number"
min={0}
disabled={!useColSeq}
max={999}
onChange={(e) => {
const onlyDigits = e.target.value.replace(/\D/g, "").slice(0, 3);
field.onChange(onlyDigits); // react-hook-form에 반영
}}
/>
</FormControl>
<p className="text-gray-500 text-base pt-2">
※ 3자리까지 입력 가능합니다.
</p>
</FormItem>
)}
/>
</TableCell>
</TableRow>
{isModifyMode &&
<>
<TableRow>
<TableHead>수정자</TableHead>
<TableCell>
<FormField
control={form.control}
name="mdfr"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem]"
disabled
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>수정일시</TableHead>
<TableCell>
<FormField
control={form.control}
name="mdfcn_dt"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ? dayjs(field.value).format('YYYY-MM-DD') : ""}
className="!text-[1.3rem]"
disabled
/>
</FormControl>
</FormItem>
)}
/>
</TableCell>
</TableRow>
</>
}
</TableBody>
</Table>
</div>
<DialogFooter>
<Button type="submit">저장</Button>
<DialogClose asChild>
<Button color="white">취소</Button>
</DialogClose>
</DialogFooter>
</form>
</Form>
<ConfirmDialog isOpen={onConfirm} />
</DialogContent>
</Dialog>
)
}
export { LinkTableDialog };
@@ -0,0 +1,710 @@
'use client';
import { useState, useMemo } from "react";
import {
Button,
DataTable,
Input,
Label,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
LoadingWithAnimation,
} from "@common/components";
import { getCoreRowModel, type PaginationState, type RowSelectionState, useReactTable } from "@tanstack/react-table";
import { LinkApi, type Link, type LinkSearchParam, type LinkTable, type LinkTableColumns, type LinkTableColData, type LinkTableFormData, LinkTableColumnsSchema, LinkTableSchema } from "@/entities/link";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { columns } from "../../linkParams/model/table-columns";
import { tableColumns } from "../model/table-columns";
import { LinkTableDialog } from "./link-table-dialog";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { getUserInfo } from "../../../../../../packages/common/components/src/api";
import { AUTH_USER_ID } from "@/shared/config";
import { getCookie } from "@common/components";
import { useAlertStore } from "@/shared/lib/use-alert-store";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { FormItem, FormControl, FormField, Form } from "@/shared/ui/form";
import { useDataTable } from "@/shared/lib";
function LinkTablePage() {
const queryClient = useQueryClient();
const userUniqId = getCookie(AUTH_USER_ID) ?? "";
const [linkSn, setLinkSn] = useState<string>('');
const [linkNm, setLinkNm] = useState<string>('');
const [linkSearchParam, setLinkSearchParam] = useState<LinkSearchParam>({
linkSn: '',
linkNm: ''
});
const [selectedTableInfo, setSelectedTableInfo] = useState<LinkTable | null>(null);
const [tableColList, setTableColList] = useState<LinkTableColumns[]>([]);
const [dialogOpen, setDialogOpen] = useState<boolean>(false);
const [selectedColIds, setSelectedColIds] = useState<string[]>([]);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 100,
});
const { setMessage: alert } = useAlertStore((state) => state);
const { confirm } = useConfirm();
const [onConfirm, setOnConfirm] = useState<boolean>(false);
const handleConfirm = async (callback: () => Promise<void>) => {
setOnConfirm(true);
await callback();
setOnConfirm(false);
};
const query = useQuery({
queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkList`, linkSearchParam],
queryFn: () => LinkApi.getLinkList({
...linkSearchParam,
page: pagination.pageIndex,
size: pagination.pageSize,
}),
});
const { mutateAsync: insertLinkTableCol } = useMutation({
mutationFn: async (data: LinkTableColData) => await LinkApi.insertLinkTableCol({
...data,
mdfr: userUniqId,
}),
onSuccess: async () => {
if (selectedTableInfo) {
await queryClient.invalidateQueries({
queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkTableInfo`]
});
await queryClient.invalidateQueries({
queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkTableColList`]
});
const tableData = await LinkApi.getLinkTableInfo(selectedTableInfo.link_sn);
const tableColList = await LinkApi.getLinkTableColList(selectedTableInfo.link_sn);
setSelectedTableInfo(tableData);
setTableColList(tableColList.content);
setSelectedColIds([]);
setDialogOpen(false);
alert('저장되었습니다.');
}
}
});
const { mutateAsync: updateLinkTableCol } = useMutation({
mutationFn: async (data: LinkTableColData) => await LinkApi.updateLinkTableCol({
...data,
mdfr: userUniqId,
}),
onSuccess: async () => {
if (selectedTableInfo) {
await queryClient.invalidateQueries({
queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkTableInfo`]
});
await queryClient.invalidateQueries({
queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkTableColList`]
});
const tableData = await LinkApi.getLinkTableInfo(selectedTableInfo.link_sn);
const tableColList = await LinkApi.getLinkTableColList(selectedTableInfo.link_sn);
setSelectedTableInfo(tableData);
setTableColList(tableColList.content);
setSelectedColIds([]);
setDialogOpen(false);
alert('수정되었습니다.');
}
}
});
const { mutateAsync: deleteLinkTableCol } = useMutation({
mutationFn: async (data: string[]) => await LinkApi.deleteLinkTableCol(data),
onSuccess: async () => {
if (selectedTableInfo) {
await queryClient.invalidateQueries({
queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkTableInfo`]
});
await queryClient.invalidateQueries({
queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkTableColList`]
});
const tableData = await LinkApi.getLinkTableInfo(selectedTableInfo.link_sn);
const tableColList = await LinkApi.getLinkTableColList(selectedTableInfo.link_sn);
setSelectedTableInfo(tableData);
setTableColList(tableColList.content);
setSelectedColIds([]);
setDialogOpen(false);
alert('삭제되었습니다.');
}
}
});
const { mutateAsync: insertLinkTableInfo } = useMutation({
mutationFn: async (data: LinkTableFormData) => await LinkApi.insertLinkTableInfo(data),
onSuccess: async () => {
if (selectedTableInfo) {
await queryClient.invalidateQueries({
queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkTableInfo`]
});
await queryClient.invalidateQueries({
queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkTableColList`]
});
const tableData = await LinkApi.getLinkTableInfo(selectedTableInfo.link_sn);
const tableColList = await LinkApi.getLinkTableColList(selectedTableInfo.link_sn);
tableForm.reset({
link_sn: tableData.link_sn,
link_nm: tableData.link_nm,
tbl_sn: tableData.tbl_sn ?? "",
sou_tbl_nm: tableData.sou_tbl_nm ?? "",
sou_tbl_korn_nm: tableData.sou_tbl_korn_nm ?? "",
sou_tbl_expln: tableData.sou_tbl_expln ?? "",
trsf_tbl_expln: tableData.trsf_tbl_expln ?? "",
trsf_tbl_nm: tableData.trsf_tbl_nm ?? "",
trsf_tbl_korn_nm: tableData.trsf_tbl_korn_nm ?? "",
});
setSelectedTableInfo(tableData);
setTableColList(tableColList.content);
setSelectedColIds([]);
alert('저장되었습니다.');
}
}
});
const { mutateAsync: updateLinkTableInfo } = useMutation({
mutationFn: async (data: LinkTableFormData) => await LinkApi.updateLinkTableInfo(data),
onSuccess: async () => {
if (selectedTableInfo) {
await queryClient.invalidateQueries({
queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkTableInfo`]
});
await queryClient.invalidateQueries({
queryKey: [`${process.env.NEXT_PUBLIC_LINK_BACKEND_API_HOST}/getLinkTableColList`]
});
const tableData = await LinkApi.getLinkTableInfo(selectedTableInfo.link_sn);
const tableColList = await LinkApi.getLinkTableColList(selectedTableInfo.link_sn);
setSelectedTableInfo(tableData);
setTableColList(tableColList.content);
setSelectedColIds([]);
alert('수정되었습니다.');
}
}
});
const tableCol = useMemo(
() =>
tableColumns({ selectedColIds, setSelectedColIds }),
[selectedColIds]
);
const colTable = useReactTable({
data: tableColList,
columns: tableCol,
getCoreRowModel: getCoreRowModel(),
});
const form = useForm<LinkTableColData>({
resolver: zodResolver(LinkTableColumnsSchema),
defaultValues: {
pk_yn: "N"
}
});
const tableForm = useForm<LinkTableFormData>({
resolver: zodResolver(LinkTableSchema),
defaultValues: {},
});
const handleResetSearchParam = async () => {
setSelectedTableInfo(null);
setTableColList([]);
setLinkSn('');
setLinkNm('');
setLinkSearchParam({
...linkSearchParam,
linkSn: '',
linkNm: '',
});
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setSelectedTableInfo(null);
setTableColList([]);
setLinkSearchParam({
...linkSearchParam,
linkSn,
linkNm
});
};
const handleRowClick = async (data: Link) => {
const tableData = await LinkApi.getLinkTableInfo(data.link_sn);
const tableColList = await LinkApi.getLinkTableColList(data.link_sn);
setSelectedTableInfo(tableData);
setTableColList(tableColList.content);
setSelectedColIds([]);
tableForm.reset({
link_sn: tableData.link_sn,
link_nm: tableData.link_nm,
tbl_sn: tableData.tbl_sn ?? "",
sou_tbl_nm: tableData.sou_tbl_nm ?? "",
sou_tbl_korn_nm: tableData.sou_tbl_korn_nm ?? "",
sou_tbl_expln: tableData.sou_tbl_expln ?? "",
trsf_tbl_expln: tableData.trsf_tbl_expln ?? "",
trsf_tbl_nm: tableData.trsf_tbl_nm ?? "",
trsf_tbl_korn_nm: tableData.trsf_tbl_korn_nm ?? "",
});
};
const handleColumnRowClick = async (data: LinkTableColumns) => {
const colInfo = await LinkApi.getLinkTableColInfo({ colSn: data.col_sn, tblSn: data.tbl_sn });
if (colInfo) {
let newData = { ...colInfo };
if (colInfo.mdfr) {
const userName = await fetchUserInfo();
newData = {
...colInfo,
mdfr: userName,
};
}
form.reset(newData);
}
setDialogOpen(true)
};
const fetchUserInfo = async () => {
const userData = await getUserInfo();
return userData.userName;
};
const handleDeleteParam = async () => {
if (selectedColIds.length === 0) {
alert('삭제할 컬럼을 선택해 주세요.');
} else {
await handleConfirm(async () => {
if (await confirm('선택한 컬럼 정보를 삭제하시겠습니까?')) {
deleteLinkTableCol(selectedColIds);
}
});
}
};
const handleTableSubmit = async (data: LinkTableFormData) => {
const regTblInfo = await LinkApi.countLinkTblNm(data.trsf_tbl_nm);
const confirmMsg = (regTblInfo.count > 0 && data.trsf_tbl_nm !== selectedTableInfo?.trsf_tbl_nm) ? `이미 사용중인 테이블 명입니다. 해당 테이블 명으로 ${!data.tbl_sn ? '저장' : '수정'}하시겠습니까?` : `${!data.tbl_sn ? '저장' : '수정'}하시겠습니까?`;
if (!data.tbl_sn) {
await handleConfirm(async () => {
if (await confirm(confirmMsg)) {
insertLinkTableInfo({
...data,
mdfr: userUniqId,
tbl_sn: (regTblInfo.count > 0) ? regTblInfo.tbl_sn : "",
});
}
});
} else {
await handleConfirm(async () => {
if (await confirm(confirmMsg)) {
updateLinkTableInfo({
...data,
tbl_sn: (regTblInfo.count > 0) ? regTblInfo.tbl_sn : "",
mdfr: userUniqId,
});
}
});
}
};
const { tableComponent } = useDataTable({
query,
tableOptions: {
columns,
state: {
pagination,
rowSelection,
},
manualPagination: true,
enableMultiRowSelection: true,
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
},
dataTableOptions: {
onRowClick: handleRowClick,
viewPagination: true,
},
});
return (
<div className="card card-border !mt-6">
<div className="card card-border">
<form className="flex items-center gap-2 p-2" onSubmit={handleSubmit}>
<Label htmlFor="liSnSearchInput" className="mr-2 text-label">
일련번호
</Label>
<Input
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem] mr-4"
id="liSnSearchInput"
value={linkSn}
onChange={(e) => setLinkSn(e.target.value)}
autoComplete="off"
/>
<Label htmlFor="liNmSearchInput" className="mr-2 text-label">
연계 명
</Label>
<Input
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem] mr-2"
id="liNmSearchInput"
value={linkNm}
onChange={(e) => setLinkNm(e.target.value)}
autoComplete="off"
/>
<Button color="green" className="h-[3.2rem]" type="button" onClick={handleResetSearchParam}>
초기화
</Button>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
</form>
</div>
<div className="card !mb-0 flex h-[calc(100vh-28rem)] gap-5 !p-0">
<div className="card card-border w-1/2 !mx-0 flex flex-col">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">연계 목록</h3>
</div>
<div className="card !m-[1.2rem] overflow-auto !p-0 h-full">
{!query.isLoading ?
<div>{tableComponent}</div>
:
<div className="[data-theme='black']:bg-transparent rounded-lg p-10 flex justify-center items-center h-full">
<div className="relative size-[5rem]">
<LoadingWithAnimation color="primary" />
</div>
</div>
}
</div>
</div>
<div className="card card-border w-1/2 !mx-0 flex flex-col">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">연계 테이블 정보</h3>
</div>
<div className="card !m-[1.2rem] !p-0 h-full overflow-auto flex flex-col">
<Form {...tableForm}>
<form onSubmit={tableForm.handleSubmit(handleTableSubmit)}>
{selectedTableInfo &&
<div className="flex justify-end mb-2">
<Button className="h-[3.2rem]" type="submit" color={selectedTableInfo.tbl_sn ? "green" : "primary"}>
{selectedTableInfo.tbl_sn ? "수정" : "저장"}
</Button>
</div>
}
<Table variant="secondary" className="flex-none">
<colgroup>
<col width="20%" />
<col width="80%" />
</colgroup>
<TableBody>
<TableRow>
<TableHead>연계 일련번호</TableHead>
<TableCell className="!p-2">
{selectedTableInfo ?
<FormField
control={tableForm.control}
name="link_sn"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem] w-[400px]"
disabled
/>
</FormControl>
</FormItem>
)}
/>
:
<span className="px-2 py-[1.2rem]">-</span>
}
</TableCell>
</TableRow>
<TableRow>
<TableHead>연계 명</TableHead>
<TableCell className="!p-2">
{selectedTableInfo ?
<FormField
control={tableForm.control}
name="link_nm"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem] w-[400px]"
disabled
/>
</FormControl>
</FormItem>
)}
/>
:
<span className="px-2 py-[1.2rem]">-</span>
}
</TableCell>
</TableRow>
<TableRow>
<TableHead>원천 테이블 명</TableHead>
<TableCell className="!p-2">
{selectedTableInfo ?
<FormField
control={tableForm.control}
name="sou_tbl_nm"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem] w-[400px]"
placeholder={!field.value ? "-" : ""}
autoComplete="off"
/>
</FormControl>
{tableForm.formState.errors.sou_tbl_nm && (
<p className="text-red-500 text-base pt-2">
{tableForm.formState.errors.sou_tbl_nm.message}
</p>
)}
</FormItem>
)}
/>
:
<span className="px-2 py-[1.2rem]">-</span>
}
</TableCell>
</TableRow>
<TableRow>
<TableHead>원천 테이블 한글명</TableHead>
<TableCell className="!p-2">
{selectedTableInfo ?
<FormField
control={tableForm.control}
name="sou_tbl_korn_nm"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem] w-[400px]"
placeholder={!field.value ? "-" : ""}
autoComplete="off"
/>
</FormControl>
{tableForm.formState.errors.sou_tbl_korn_nm && (
<p className="text-red-500 text-base pt-2">
{tableForm.formState.errors.sou_tbl_korn_nm.message}
</p>
)}
</FormItem>
)}
/>
:
<span className="px-2 py-[1.2rem]">-</span>
}
</TableCell>
</TableRow>
<TableRow>
<TableHead>원천 테이블 설명</TableHead>
<TableCell className="!p-2">
{selectedTableInfo ?
<FormField
control={tableForm.control}
name="sou_tbl_expln"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem] w-[400px]"
placeholder={!field.value ? "-" : ""}
autoComplete="off"
/>
</FormControl>
{tableForm.formState.errors.sou_tbl_expln && (
<p className="text-red-500 text-base pt-2">
{tableForm.formState.errors.sou_tbl_expln.message}
</p>
)}
</FormItem>
)}
/>
:
<span className="px-2 py-[1.2rem]">-</span>
}
</TableCell>
</TableRow>
<TableRow>
<TableHead>* 변환 테이블 명</TableHead>
<TableCell className="!p-2">
{selectedTableInfo ?
<FormField
control={tableForm.control}
name="trsf_tbl_nm"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem] w-[400px]"
placeholder={!field.value ? "-" : ""}
autoComplete="off"
/>
</FormControl>
{tableForm.formState.errors.trsf_tbl_nm && (
<p className="text-red-500 text-base pt-2">
{tableForm.formState.errors.trsf_tbl_nm.message}
</p>
)}
</FormItem>
)}
/>
:
<span className="px-2 py-[1.2rem]">-</span>
}
</TableCell>
</TableRow>
<TableRow>
<TableHead>* 변환 테이블 한글명</TableHead>
<TableCell className="!p-2">
{selectedTableInfo ?
<FormField
control={tableForm.control}
name="trsf_tbl_korn_nm"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem] w-[400px]"
placeholder={!field.value ? "-" : ""}
autoComplete="off"
/>
</FormControl>
{tableForm.formState.errors.trsf_tbl_korn_nm && (
<p className="text-red-500 text-base pt-2">
{tableForm.formState.errors.trsf_tbl_korn_nm.message}
</p>
)}
</FormItem>
)}
/>
:
<span className="px-2 py-[1.2rem]">-</span>
}
</TableCell>
</TableRow>
<TableRow>
<TableHead>변환 테이블 설명</TableHead>
<TableCell className="!p-2">
{selectedTableInfo ?
<FormField
control={tableForm.control}
name="trsf_tbl_expln"
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
className="!text-[1.3rem] w-[400px]"
placeholder={!field.value ? "-" : ""}
autoComplete="off"
/>
</FormControl>
{tableForm.formState.errors.trsf_tbl_expln && (
<p className="text-red-500 text-base pt-2">
{tableForm.formState.errors.trsf_tbl_expln.message}
</p>
)}
</FormItem>
)}
/>
:
<span className="px-2 py-[1.2rem]">-</span>
}
</TableCell>
</TableRow>
</TableBody>
</Table>
</form>
</Form>
{selectedTableInfo ?
<>
<div className="mb-2 mt-14 flex justify-end items-center flex-none">
{tableColList.length > 0 &&
<Button color="red" className="h-[3.2rem] mr-2" type="button" onClick={handleDeleteParam}>
행 삭제
</Button>
}
{selectedTableInfo.tbl_sn &&
<LinkTableDialog dialogOpen={dialogOpen} onOpenChange={setDialogOpen} form={form} tableInfo={selectedTableInfo} insertLinkTableCol={insertLinkTableCol} updateLinkTableCol={updateLinkTableCol} />
}
</div>
{selectedTableInfo.tbl_sn ?
(tableColList.length > 0 ?
<div className="flex-1 overflow-auto min-h-[300px]">
<DataTable table={colTable} onRowClick={handleColumnRowClick} />
</div>
:
<div className="flex items-center justify-center h-20 text-gray-500 text-3xl">
등록된 테이블 컬럼이 없습니다.
</div>)
:
<div className="flex items-center justify-center h-20 text-gray-500 text-3xl">
테이블 정보를 등록해주세요.
</div>
}
</>
:
<div className="flex items-center justify-center text-gray-500 text-3xl mt-14 flex-1">
연계 데이터를 선택해주세요.
</div>
}
</div>
</div>
</div>
<ConfirmDialog isOpen={onConfirm} />
</div >
)
};
export { LinkTablePage };
+32 -25
View File
@@ -6,7 +6,6 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table"; import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { import {
Button, Button,
DateRangePicker,
Input, Input,
Label, Label,
Select, Select,
@@ -16,13 +15,14 @@ import {
SelectValue, SelectValue,
LoadingWithImage, LoadingWithImage,
} from "@common/components"; } from "@common/components";
import { columns } from "@/pages/loginlog/model/table-columns";
import { LoginlogApi, type LoginlogParams } from "@/entities/loginlog"; import { LoginlogApi, type LoginlogParams } from "@/entities/loginlog";
import { useDataTable } from "@/shared/lib"; import { useDataTable } from "@/shared/lib";
import { ENDPOINT } from "@/shared/config"; import { ENDPOINT } from "@/shared/config";
import { columns } from "@/pages/loginlog/model/table-columns"; import { InputDateRangePicker } from "@/shared/ui/input-date-range-picker";
type DateRange = { type DateRange = {
from: Date | undefined; from?: Date | undefined;
to?: Date | undefined; to?: Date | undefined;
}; };
@@ -32,13 +32,12 @@ function LoginlogPage() {
to: dayjs().toDate(), to: dayjs().toDate(),
}; };
const [dateRange, setDateRange] = useState<DateRange>(defaultDateRange);
const [isManualLoading, setIsManualLoading] = useState(false);
const [pagination, setPagination] = useState<PaginationState>({ const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0, pageIndex: 0,
pageSize: 10, pageSize: 10,
}); });
const [presetDateRange, setPresetDateRange] = useState("");
const [dateRange, setDateRange] = useState<DateRange>(defaultDateRange);
const [userId, setUserId] = useState(""); const [userId, setUserId] = useState("");
const [errOcrnYn, setErrOcrnYn] = useState("N"); const [errOcrnYn, setErrOcrnYn] = useState("N");
const [searchParams, setSearchParams] = useState<LoginlogParams>({ const [searchParams, setSearchParams] = useState<LoginlogParams>({
@@ -48,6 +47,7 @@ function LoginlogPage() {
searchDateTo: dayjs().format("YYYY-MM-DD"), searchDateTo: dayjs().format("YYYY-MM-DD"),
sort: "lgnDt,desc", sort: "lgnDt,desc",
}); });
const [isManualLoading, setIsManualLoading] = useState(false);
const query = useQuery({ const query = useQuery({
queryKey: [ENDPOINT.USER_SERVICE.LOGINLOG, searchParams, pagination], queryKey: [ENDPOINT.USER_SERVICE.LOGINLOG, searchParams, pagination],
@@ -88,8 +88,12 @@ function LoginlogPage() {
...searchParams, ...searchParams,
userId, userId,
errOcrnYn, errOcrnYn,
searchDateFrom: dayjs(dateRange.from).format("YYYY-MM-DD"), searchDateFrom: dateRange.from
searchDateTo: dayjs(dateRange.to).format("YYYY-MM-DD"), ? dayjs(dateRange.from).format("YYYY-MM-DD")
: "",
searchDateTo: dateRange.to
? dayjs(dateRange.to).format("YYYY-MM-DD")
: "",
}); });
query.refetch().finally(() => { query.refetch().finally(() => {
setIsManualLoading(false); setIsManualLoading(false);
@@ -133,31 +137,34 @@ function LoginlogPage() {
default: default:
presetDateRange = undefined; presetDateRange = undefined;
} }
setPresetDateRange(value);
setDateRange(presetDateRange || defaultDateRange); setDateRange(presetDateRange || defaultDateRange);
}; };
const handleDateRangeChange = (dateRange: DateRange) => {
setDateRange(dateRange);
setPresetDateRange("");
};
return ( return (
<div className="flex-1"> <div className="flex-1">
<div className="card card-border"> <div className="card card-border">
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2"> <form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
<Label className="mr-2">조회기간</Label> <Label className="mr-2">조회기간</Label>
<DateRangePicker <Select value={presetDateRange} onValueChange={handlePresetDateRange}>
value={dateRange} <SelectTrigger className="w-[12rem]">
onValueChange={(value) => setDateRange(value || defaultDateRange)} <SelectValue placeholder="기간 선택" className="w-[24rem]" />
> </SelectTrigger>
<div className="card card-border flex items-center gap-2"> <SelectContent>
<Select onValueChange={handlePresetDateRange}> <SelectItem value="1W">일주일</SelectItem>
<SelectTrigger> <SelectItem value="1M">1개월</SelectItem>
<SelectValue placeholder="기간 선택" className="w-[24rem]" /> <SelectItem value="3M">3개월</SelectItem>
</SelectTrigger> </SelectContent>
<SelectContent> </Select>
<SelectItem value="1W">일주일</SelectItem> <InputDateRangePicker
<SelectItem value="1M">1개월</SelectItem> dateRange={dateRange}
<SelectItem value="3M">3개월</SelectItem> setDateRange={handleDateRangeChange}
</SelectContent> />
</Select>
</div>
</DateRangePicker>
<Label className="ml-4 mr-2 text-label">사용자ID</Label> <Label className="ml-4 mr-2 text-label">사용자ID</Label>
<Input <Input
className="h-[3.2rem] w-[20rem] rounded-sm !text-[1.3rem]" className="h-[3.2rem] w-[20rem] rounded-sm !text-[1.3rem]"
+1
View File
@@ -110,6 +110,7 @@ function MenuPage() {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: [ENDPOINT.CMS_SERVICE.MENU_ROLES], queryKey: [ENDPOINT.CMS_SERVICE.MENU_ROLES],
}); });
form.reset();
}, },
}); });
@@ -6,7 +6,6 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table"; import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { import {
Button, Button,
DateRangePicker,
Input, Input,
Label, Label,
Select, Select,
@@ -15,16 +14,17 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@common/components"; } from "@common/components";
import { columns } from "@/pages/privacylog/model/table-columns";
import { import {
PrivacyLogPrivacy, PrivacyLogPrivacy,
type PrivacyLogParams, type PrivacyLogParams,
} from "@/entities/privacylog"; } from "@/entities/privacylog";
import { useDataTable } from "@/shared/lib"; import { useDataTable } from "@/shared/lib";
import { ENDPOINT } from "@/shared/config"; import { ENDPOINT } from "@/shared/config";
import { columns } from "@/pages/privacylog/model/table-columns"; import { InputDateRangePicker } from "@/shared/ui/input-date-range-picker";
type DateRange = { type DateRange = {
from: Date | undefined; from?: Date | undefined;
to?: Date | undefined; to?: Date | undefined;
}; };
@@ -38,6 +38,7 @@ function PrivacyLogPage() {
pageIndex: 0, pageIndex: 0,
pageSize: 10, pageSize: 10,
}); });
const [presetDateRange, setPresetDateRange] = useState("");
const [dateRange, setDateRange] = useState<DateRange>(defaultDateRange); const [dateRange, setDateRange] = useState<DateRange>(defaultDateRange);
const [inqInfoKeyword, setInqInfoKeyword] = useState(""); const [inqInfoKeyword, setInqInfoKeyword] = useState("");
const [searchParams, setSearchParams] = useState<PrivacyLogParams>({ const [searchParams, setSearchParams] = useState<PrivacyLogParams>({
@@ -85,8 +86,10 @@ function PrivacyLogPage() {
setSearchParams({ setSearchParams({
...searchParams, ...searchParams,
inqInfoKeyword, inqInfoKeyword,
inqDtStart: dayjs(dateRange.from).format("YYYY-MM-DD"), inqDtStart: dateRange.from
inqDtEnd: dayjs(dateRange.to).format("YYYY-MM-DD"), ? dayjs(dateRange.from).format("YYYY-MM-DD")
: "",
inqDtEnd: dateRange.to ? dayjs(dateRange.to).format("YYYY-MM-DD") : "",
}); });
}; };
@@ -125,38 +128,40 @@ function PrivacyLogPage() {
default: default:
presetDateRange = undefined; presetDateRange = undefined;
} }
setPresetDateRange(value);
setDateRange(presetDateRange || defaultDateRange); setDateRange(presetDateRange || defaultDateRange);
}; };
const handleDateRangeChange = (dateRange: DateRange) => {
setDateRange(dateRange);
setPresetDateRange("");
};
return ( return (
<div className="flex-1"> <div className="flex-1">
<div className="card card-border"> <div className="card card-border">
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2"> <form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
<Label className="mr-2">조회기간</Label> <Label className="mr-2">조회기간</Label>
<DateRangePicker <Select value={presetDateRange} onValueChange={handlePresetDateRange}>
value={dateRange} <SelectTrigger className="w-[12rem]">
onValueChange={(value) => setDateRange(value || defaultDateRange)} <SelectValue placeholder="기간 선택" className="w-[24rem]" />
> </SelectTrigger>
<div className="card card-border flex items-center gap-2"> <SelectContent>
<Select onValueChange={handlePresetDateRange}> <SelectItem value="1W">일주일</SelectItem>
<SelectTrigger> <SelectItem value="1M">1개월</SelectItem>
<SelectValue placeholder="기간 선택" className="w-[24rem]" /> <SelectItem value="3M">3개월</SelectItem>
</SelectTrigger> </SelectContent>
<SelectContent> </Select>
<SelectItem value="1W">일주일</SelectItem> <InputDateRangePicker
<SelectItem value="1M">1개월</SelectItem> dateRange={dateRange}
<SelectItem value="3M">3개월</SelectItem> setDateRange={handleDateRangeChange}
</SelectContent> />
</Select>
</div>
</DateRangePicker>
<Label className="ml-4 mr-2 text-label">조회정보</Label> <Label className="ml-4 mr-2 text-label">조회정보</Label>
<Input <Input
className="h-[3.2rem] w-[20rem] rounded-sm !text-[1.3rem]" className="h-[3.2rem] w-[20rem] rounded-sm !text-[1.3rem]"
value={inqInfoKeyword} value={inqInfoKeyword}
onChange={(e) => setInqInfoKeyword(e.target.value)} onChange={(e) => setInqInfoKeyword(e.target.value)}
/> />
<Button className="h-[3.2rem]" type="submit"> <Button className="h-[3.2rem]" type="submit">
조회 조회
</Button> </Button>
+33 -28
View File
@@ -12,7 +12,6 @@ import {
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import { import {
Button, Button,
DateRangePicker,
Input, Input,
Label, Label,
Select, Select,
@@ -31,13 +30,14 @@ import {
} from "@/entities/post"; } from "@/entities/post";
import { BoardApi } from "@/entities/board"; import { BoardApi } from "@/entities/board";
import { AuthorityButton } from "@/entities/menu-authority"; import { AuthorityButton } from "@/entities/menu-authority";
import { useAlertStore, useSelectPrompt, useDataTable } from "@/shared/lib";
import { ENDPOINT } from "@/shared/config"; import { ENDPOINT } from "@/shared/config";
import { InputDateRangePicker } from "@/shared/ui/input-date-range-picker";
import { useAlertStore, useSelectPrompt, useDataTable } from "@/shared/lib";
import { columns } from "../model/table-columns"; import { columns } from "../model/table-columns";
import { MovePostDialog } from "./move-post-dialog"; import { MovePostDialog } from "./move-post-dialog";
type DateRange = { type DateRange = {
from: Date | undefined; from?: Date | undefined;
to?: Date | undefined; to?: Date | undefined;
}; };
@@ -51,22 +51,21 @@ function RecentPage() {
const { confirm } = useConfirm(); const { confirm } = useConfirm();
const alert = useAlertStore((state) => state.setMessage); const alert = useAlertStore((state) => state.setMessage);
const [dateRange, setDateRange] = useState<DateRange | undefined>({
from: undefined,
to: undefined,
});
const [tableSort, setTableSort] = useState("regDt,desc"); const [tableSort, setTableSort] = useState("regDt,desc");
const [pagination, setPagination] = useState<PaginationState>({ const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0, pageIndex: 0,
pageSize: 10, pageSize: 10,
}); });
const [rowSelection, setRowSelection] = useState<RowSelectionState>({}); const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [presetDateRange, setPresetDateRange] = useState("");
const [dateRange, setDateRange] = useState<DateRange>({
from: undefined,
to: undefined,
});
const [keywordType, setKeywordType] = useState(""); const [keywordType, setKeywordType] = useState("");
const [keyword, setKeyword] = useState(""); const [keyword, setKeyword] = useState("");
const [bbsId, setBbsId] = useState<string | null>(null); const [bbsId, setBbsId] = useState<string | null>(null);
const [delYn, setDelYn] = useState("N"); const [delYn, setDelYn] = useState("N");
const [searchParams, setSearchParams] = useState<RecentPostParams>({ const [searchParams, setSearchParams] = useState<RecentPostParams>({
keywordType: "", keywordType: "",
keyword: "", keyword: "",
@@ -170,7 +169,7 @@ function RecentPage() {
}; };
const handlePresetDateRange = (value: string) => { const handlePresetDateRange = (value: string) => {
let presetDateRange: DateRange | undefined; let presetDateRange: DateRange;
switch (value) { switch (value) {
case "1W": case "1W":
presetDateRange = { presetDateRange = {
@@ -191,11 +190,20 @@ function RecentPage() {
}; };
break; break;
default: default:
presetDateRange = undefined; presetDateRange = {
from: undefined,
to: undefined,
};
} }
setPresetDateRange(value);
setDateRange(presetDateRange); setDateRange(presetDateRange);
}; };
const handleDateRangeChange = (dateRange: DateRange) => {
setDateRange(dateRange);
setPresetDateRange("");
};
const onSoftDeletePost = async () => { const onSoftDeletePost = async () => {
const { resolved, value: delRsn } = const { resolved, value: delRsn } =
await prompt("삭제 사유를 입력해주세요."); await prompt("삭제 사유를 입력해주세요.");
@@ -250,21 +258,20 @@ function RecentPage() {
<div className="card card-border"> <div className="card card-border">
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2"> <form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
<Label className="mr-2">날짜</Label> <Label className="mr-2">날짜</Label>
<DateRangePicker value={dateRange} onValueChange={setDateRange}> <Select value={presetDateRange} onValueChange={handlePresetDateRange}>
<div className="card card-border flex items-center gap-2"> <SelectTrigger className="w-[12rem]">
<Select onValueChange={handlePresetDateRange}> <SelectValue placeholder="기간 선택" className="w-[24rem]" />
<SelectTrigger> </SelectTrigger>
<SelectValue placeholder="기간 선택" className="w-[24rem]" /> <SelectContent>
</SelectTrigger> <SelectItem value="1W">일주일</SelectItem>
<SelectContent> <SelectItem value="1M">1개월</SelectItem>
<SelectItem value="1W">일주일</SelectItem> <SelectItem value="3M">3개월</SelectItem>
<SelectItem value="1M">1개월</SelectItem> </SelectContent>
<SelectItem value="3M">3개월</SelectItem> </Select>
</SelectContent> <InputDateRangePicker
</Select> dateRange={dateRange}
</div> setDateRange={handleDateRangeChange}
</DateRangePicker> />
<Label className="ml-4 mr-2 text-label">게시판</Label> <Label className="ml-4 mr-2 text-label">게시판</Label>
<Select <Select
value={bbsId?.toString() || ""} value={bbsId?.toString() || ""}
@@ -281,7 +288,6 @@ function RecentPage() {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Label className="ml-4 mr-2 text-label">상태</Label> <Label className="ml-4 mr-2 text-label">상태</Label>
<Select value={delYn} onValueChange={(value) => setDelYn(value)}> <Select value={delYn} onValueChange={(value) => setDelYn(value)}>
<SelectTrigger className="w-[10rem]"> <SelectTrigger className="w-[10rem]">
@@ -292,7 +298,6 @@ function RecentPage() {
<SelectItem value="Y">삭제</SelectItem> <SelectItem value="Y">삭제</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Label className="ml-4 mr-2 text-label">검색</Label> <Label className="ml-4 mr-2 text-label">검색</Label>
<Select <Select
value={keywordType} value={keywordType}
@@ -0,0 +1 @@
export { UserStatisticsApi } from "./user-statistics-service";
@@ -0,0 +1,138 @@
import {
type AccessLog,
type AccessLogParams,
type AccessLogService,
type PageableList,
} from "../model/user-statistics-interface";
// 목 데이터
const mockAccessLogs: AccessLog[] = [
{
no: 1,
accessTime: "2024-01-15 14:30:25",
accessIp: "192.168.1.100",
accessRegion: "서울특별시",
urlPath: "/index",
browser: "Chrome",
site: "SITE1",
accessType: "메인",
},
{
no: 2,
accessTime: "2024-01-15 14:28:15",
accessIp: "203.241.50.123",
accessRegion: "부산광역시",
urlPath: "/board/list",
browser: "Edge",
site: "SITE2",
accessType: "게시판",
},
{
no: 3,
accessTime: "2024-01-15 14:25:42",
accessIp: "175.223.45.67",
accessRegion: "대구광역시",
urlPath: "/statistics",
browser: "Safari",
site: "SITE1",
accessType: "통계",
},
{
no: 4,
accessTime: "2024-01-15 14:22:18",
accessIp: "211.252.87.45",
accessRegion: "인천광역시",
urlPath: "/contents/view",
browser: "Firefox",
site: "SITE3",
accessType: "컨텐츠",
},
{
no: 5,
accessTime: "2024-01-15 14:20:33",
accessIp: "121.254.12.89",
accessRegion: "광주광역시",
urlPath: "/search",
browser: "Chrome",
site: "SITE1",
accessType: "검색",
},
];
// 목 API 서비스
class UserStatisticsApiImpl implements AccessLogService {
async getAccessLogList(
params: AccessLogParams,
): Promise<PageableList<AccessLog>> {
// 실제 API 호출 대신 목 데이터 반환
const { page = 0, size = 10 } = params;
// 필터링 로직 (실제로는 서버에서 처리)
let filteredLogs = [...mockAccessLogs];
if (params.urlPath) {
filteredLogs = filteredLogs.filter((log) =>
log.urlPath.toLowerCase().includes(params.urlPath!.toLowerCase()),
);
}
if (params.accessIp) {
filteredLogs = filteredLogs.filter((log) =>
log.accessIp.includes(params.accessIp!),
);
}
if (params.accessRegion) {
filteredLogs = filteredLogs.filter((log) =>
log.accessRegion.includes(params.accessRegion!),
);
}
if (params.browser) {
filteredLogs = filteredLogs.filter(
(log) => log.browser.toLowerCase() === params.browser!.toLowerCase(),
);
}
if (params.siteId) {
filteredLogs = filteredLogs.filter((log) => log.site === params.siteId);
}
// 페이지네이션
const start = page * size;
const end = start + size;
const paginatedLogs = filteredLogs.slice(start, end);
// 목 응답 생성
return {
content: paginatedLogs,
pageable: {
pageNumber: page,
pageSize: size,
sort: {
empty: true,
sorted: false,
unsorted: true,
},
offset: page * size,
paged: true,
unpaged: false,
},
last: (page + 1) * size >= filteredLogs.length,
totalPages: Math.ceil(filteredLogs.length / size),
totalElements: filteredLogs.length,
size,
number: page,
sort: {
empty: true,
sorted: false,
unsorted: true,
},
first: page === 0,
numberOfElements: paginatedLogs.length,
empty: paginatedLogs.length === 0,
};
}
}
export const UserStatisticsApi = new UserStatisticsApiImpl();
@@ -0,0 +1 @@
export { UserStatisticsPage } from "./ui/user-statistics-page";
@@ -0,0 +1,148 @@
import { type ChartData, type ChartOptions } from "chart.js";
// 일별 접속 현황 차트 데이터
export const monthlyStatisticsData = {
labels: [
"1일",
"2일",
"3일",
"4일",
"5일",
"6일",
"7일",
"8일",
"9일",
"10일",
"11일",
"12일",
"13일",
"14일",
"15일",
],
datasets: [
{
type: "bar" as const,
label: "페이지뷰",
data: [
850, 1000, 750, 750, 700, 750, 780, 800, 850, 900, 1200, 1700, 2300,
2100, 2600,
],
backgroundColor: "rgba(54, 162, 235, 0.8)",
order: 2,
},
{
type: "line" as const,
label: "신규 접속자",
data: [
700, 600, 500, 550, 500, 550, 500, 550, 600, 850, 1200, 1500, 1700,
2300, 2200,
],
borderColor: "rgba(255, 99, 132, 0.8)",
borderWidth: 2,
tension: 0.4,
pointRadius: 3,
order: 0,
},
{
type: "line" as const,
label: "총 접속자",
data: [
200, 300, 400, 500, 600, 500, 550, 500, 400, 500, 350, 800, 900, 1200,
1300,
],
borderColor: "rgba(75, 192, 192, 0.8)",
borderWidth: 2,
tension: 0.4,
pointRadius: 3,
order: 1,
},
],
};
export const monthlyStatisticsOptions: ChartOptions<"bar"> = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "top" as const,
},
tooltip: {
mode: "index" as const,
intersect: false,
},
},
};
// 사이트별 접속자 차트 데이터
export const siteVisitorsData: ChartData<"bar"> = {
labels: ["SITE1", "SITE2", "SITE3", "SITE4", "SITE5"],
datasets: [
{
label: "사이트별 접속자",
data: [2500, 2000, 1500, 1200, 800],
backgroundColor: [
"rgba(54, 162, 235, 0.8)",
"rgba(153, 102, 255, 0.8)",
"rgba(255, 99, 132, 0.8)",
"rgba(255, 159, 64, 0.8)",
"rgba(255, 205, 86, 0.8)",
],
borderWidth: 1,
},
],
};
export const siteVisitorsOptions: ChartOptions<"bar"> = {
responsive: true,
maintainAspectRatio: false,
indexAxis: "y" as const,
plugins: {
legend: {
display: false,
},
},
};
// 브라우저별 접속자 차트 데이터
export const browserStatisticsData: ChartData<"doughnut"> = {
labels: ["Chrome", "Edge", "Safari", "Firefox", "Internet Explorer"],
datasets: [
{
data: [45, 25, 15, 10, 5],
backgroundColor: [
"rgba(54, 162, 235, 0.8)",
"rgba(153, 102, 255, 0.8)",
"rgba(255, 99, 132, 0.8)",
"rgba(255, 159, 64, 0.8)",
"rgba(255, 205, 86, 0.8)",
],
borderColor: [
"rgba(54, 162, 235, 1)",
"rgba(153, 102, 255, 1)",
"rgba(255, 99, 132, 1)",
"rgba(255, 159, 64, 1)",
"rgba(255, 205, 86, 1)",
],
borderWidth: 1,
},
],
};
export const browserStatisticsOptions: ChartOptions<"doughnut"> = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "bottom" as const,
},
},
};
export const chartData = {
monthlyStatisticsData,
monthlyStatisticsOptions,
siteVisitorsData,
siteVisitorsOptions,
browserStatisticsData,
browserStatisticsOptions,
};
@@ -0,0 +1,39 @@
import { createColumnHelper } from "@tanstack/react-table";
import { type AccessLog } from "./user-statistics-interface";
const columnHelper = createColumnHelper<AccessLog>();
export const columns = [
columnHelper.accessor("no", {
header: "No",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("accessTime", {
header: "접속일시",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("accessIp", {
header: "접속IP",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("accessRegion", {
header: "접속지역",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("urlPath", {
header: "URL경로",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("browser", {
header: "브라우저",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("site", {
header: "사이트",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("accessType", {
header: "메뉴명",
cell: (info) => info.getValue(),
}),
];
@@ -0,0 +1,56 @@
export interface AccessLog {
no: number;
accessTime: string;
accessIp: string;
accessRegion: string;
urlPath: string;
browser: string;
site: string;
accessType: string;
}
export interface AccessLogParams {
page?: number;
size?: number;
urlPath?: string;
accessIp?: string;
accessRegion?: string;
browser?: string;
accessDateStart?: string;
accessDateEnd?: string;
siteId?: string;
sort?: string;
}
export interface AccessLogService {
getAccessLogList(params: AccessLogParams): Promise<PageableList<AccessLog>>;
}
export interface PageableList<T> {
content: T[];
pageable: {
pageNumber: number;
pageSize: number;
sort: {
empty: boolean;
sorted: boolean;
unsorted: boolean;
};
offset: number;
paged: boolean;
unpaged: boolean;
};
last: boolean;
totalPages: number;
totalElements: number;
size: number;
number: number;
sort: {
empty: boolean;
sorted: boolean;
unsorted: boolean;
};
first: boolean;
numberOfElements: number;
empty: boolean;
}
@@ -0,0 +1,324 @@
"use client";
import dayjs from "dayjs";
import { useState } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
ArcElement,
Filler,
Legend,
Tooltip,
type ChartData,
} from "chart.js";
import { Bar, Doughnut } from "react-chartjs-2";
import {
Button,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
import { SiteSelect } from "@/entities/site";
import { useDataTable } from "@/shared/lib";
import { InputDateRangePicker } from "@/shared/ui/input-date-range-picker";
import { UserStatisticsApi } from "../api";
import { chartData } from "../model/chart-data";
import { columns } from "../model/table-columns";
import { type AccessLogParams } from "../model/user-statistics-interface";
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
BarElement,
ArcElement,
Filler,
Legend,
Tooltip,
);
type DateRange = {
from?: Date | undefined;
to?: Date | undefined;
};
function UserStatisticsPage() {
const defaultDateRange: DateRange = {
from: dayjs().subtract(7, "day").toDate(),
to: dayjs().toDate(),
};
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const [keywordType, setKeywordType] = useState("");
const [keyword, setKeyword] = useState("");
const [presetDateRange, setPresetDateRange] = useState("1W");
const [dateRange, setDateRange] = useState<DateRange>(defaultDateRange);
const [searchParams, setSearchParams] = useState<AccessLogParams>({
urlPath: "",
accessIp: "",
accessRegion: "",
browser: "",
accessDateStart: dayjs().subtract(7, "day").format("YYYY-MM-DD"),
accessDateEnd: dayjs().format("YYYY-MM-DD"),
sort: "accessTime,desc",
});
const query = useQuery({
queryKey: ["user-statistics", searchParams, pagination],
queryFn: () =>
UserStatisticsApi.getAccessLogList({
...searchParams,
page: pagination.pageIndex,
size: pagination.pageSize,
}),
placeholderData: keepPreviousData,
});
const { tableComponent, tableInstance: table } = useDataTable({
query,
tableOptions: {
columns,
state: {
pagination,
},
manualPagination: true,
getCoreRowModel: getCoreRowModel(),
onPaginationChange: setPagination,
},
dataTableOptions: {
viewPagination: true,
},
});
const handlePresetDateRange = (value: string) => {
let presetDateRange: DateRange;
switch (value) {
case "1W":
presetDateRange = {
from: dayjs().subtract(7, "day").toDate(),
to: dayjs().toDate(),
};
break;
case "1M":
presetDateRange = {
from: dayjs().subtract(30, "day").toDate(),
to: dayjs().toDate(),
};
break;
case "3M":
presetDateRange = {
from: dayjs().subtract(90, "day").toDate(),
to: dayjs().toDate(),
};
break;
default:
presetDateRange = defaultDateRange;
}
setPresetDateRange(value);
setDateRange(presetDateRange);
};
const handleDateRangeChange = (dateRange: DateRange) => {
setDateRange(dateRange);
setPresetDateRange("");
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setPagination({
...pagination,
pageIndex: 0,
});
// 검색 조건에 따라 검색 파라미터 설정
const params: AccessLogParams = {
sort: "accessTime,desc",
accessDateStart: dateRange.from
? dayjs(dateRange.from).format("YYYY-MM-DD")
: "",
accessDateEnd: dateRange.to
? dayjs(dateRange.to).format("YYYY-MM-DD")
: "",
};
// 검색어 타입에 따라 파라미터 설정
switch (keywordType) {
case "urlPath":
params.urlPath = keyword;
break;
case "ip":
params.accessIp = keyword;
break;
case "region":
params.accessRegion = keyword;
break;
case "browser":
params.browser = keyword;
break;
}
setSearchParams(params);
};
const handleReset = () => {
setKeywordType("");
setKeyword("");
setDateRange(defaultDateRange);
setPresetDateRange("1W");
setSearchParams({
urlPath: "",
accessIp: "",
accessRegion: "",
browser: "",
accessDateStart: dayjs().subtract(7, "day").format("YYYY-MM-DD"),
accessDateEnd: dayjs().format("YYYY-MM-DD"),
sort: "accessTime,desc",
});
};
return (
<div className="card card-border !mt-6">
<div className="card card-border">
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
<Label className="mr-2">날짜</Label>
<Select value={presetDateRange} onValueChange={handlePresetDateRange}>
<SelectTrigger className="w-[12rem]">
<SelectValue placeholder="기간 선택" className="w-[24rem]" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1W">일주일</SelectItem>
<SelectItem value="1M">1개월</SelectItem>
<SelectItem value="3M">3개월</SelectItem>
</SelectContent>
</Select>
<InputDateRangePicker
dateRange={dateRange}
setDateRange={handleDateRangeChange}
/>
<Label className="ml-4 mr-2 text-label">사이트</Label>
<SiteSelect enabledAllSite className="w-[16rem]" />
<Label className="ml-4 mr-2 text-label">검색</Label>
<Select
value={keywordType}
onValueChange={(value) => setKeywordType(value)}
>
<SelectTrigger className="h-[3.2rem] w-[10rem]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="urlPath">URL경로</SelectItem>
<SelectItem value="ip">접속IP</SelectItem>
<SelectItem value="region">접속지역</SelectItem>
<SelectItem value="browser">브라우저</SelectItem>
</SelectContent>
</Select>
<Input
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]"
/>
<Button className="h-[3.2rem]" type="submit">
조회
</Button>
<Button
type="button"
color="white"
className="h-[3.2rem]"
onClick={handleReset}
>
초기화
</Button>
</form>
</div>
{/* 차트 영역 */}
<div className="grid grid-cols-2">
{/* 일별 접속 현황 */}
<div className="card card-border">
<div className="card-header">
<h3 className="text-xl font-medium">일별 접속 현황</h3>
</div>
<div className="h-96 p-4">
<Bar
data={chartData.monthlyStatisticsData as ChartData<"bar">}
options={chartData.monthlyStatisticsOptions}
/>
</div>
</div>
<div className="grid grid-cols-2">
{/* 사이트별 접속자 */}
<div className="card card-border">
<div className="card-header">
<h3 className="text-xl font-medium">사이트별 접속자</h3>
</div>
<div className="h-96 p-4">
<Bar
data={chartData.siteVisitorsData}
options={chartData.siteVisitorsOptions}
/>
</div>
</div>
{/* 브라우저별 접속자 */}
<div className="card card-border">
<div className="card-header">
<h3 className="text-xl font-medium">브라우저별 접속자</h3>
</div>
<div className="h-96 p-4">
<Doughnut
data={chartData.browserStatisticsData}
options={chartData.browserStatisticsOptions}
/>
</div>
</div>
</div>
</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
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>
</div>
</div>
</div>
<div className="card !m-[1.2rem] !p-0">{tableComponent}</div>
</div>
</div>
);
}
export { UserStatisticsPage };
+37 -26
View File
@@ -8,7 +8,6 @@ import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { import {
Button, Button,
Checkbox, Checkbox,
DateRangePicker,
Input, Input,
Label, Label,
Select, Select,
@@ -25,10 +24,11 @@ import {
} from "@/entities/user"; } from "@/entities/user";
import { ENDPOINT } from "@/shared/config"; import { ENDPOINT } from "@/shared/config";
import { useDataTable } from "@/shared/lib"; import { useDataTable } from "@/shared/lib";
import { InputDateRangePicker } from "@/shared/ui/input-date-range-picker";
import { columns } from "../model/table-columns"; import { columns } from "../model/table-columns";
type DateRange = { type DateRange = {
from: Date | undefined; from?: Date | undefined;
to?: Date | undefined; to?: Date | undefined;
}; };
@@ -39,7 +39,8 @@ function UsersPage() {
pageIndex: 0, pageIndex: 0,
pageSize: 10, pageSize: 10,
}); });
const [dateRange, setDateRange] = useState<DateRange | undefined>({ const [presetDateRange, setPresetDateRange] = useState("");
const [dateRange, setDateRange] = useState<DateRange>({
from: undefined, from: undefined,
to: undefined, to: undefined,
}); });
@@ -61,7 +62,7 @@ function UsersPage() {
}); });
const { data: userSttsStats } = useQuery({ const { data: userSttsStats } = useQuery({
queryKey: [ENDPOINT.USER_SERVICE.USERS], queryKey: [ENDPOINT.USER_SERVICE.USERS, "userStatusStatistics"],
queryFn: () => UserApi.selectUserSttsStats(), queryFn: () => UserApi.selectUserSttsStats(),
}); });
@@ -144,7 +145,7 @@ function UsersPage() {
}; };
const handlePresetDateRange = (value: string) => { const handlePresetDateRange = (value: string) => {
let presetDateRange: DateRange | undefined; let presetDateRange: DateRange;
switch (value) { switch (value) {
case "1M": case "1M":
presetDateRange = { presetDateRange = {
@@ -165,14 +166,24 @@ function UsersPage() {
}; };
break; break;
default: default:
presetDateRange = undefined; presetDateRange = {
from: undefined,
to: undefined,
};
} }
setPresetDateRange(value);
setDateRange(presetDateRange); setDateRange(presetDateRange);
}; };
const getUserSttsStatsCnt = (code: string): number => { const handleDateRangeChange = (dateRange: DateRange) => {
if (!Array.isArray(userSttsStats)) return 0; setDateRange(dateRange);
return userSttsStats.find((item) => item.sttsCd === code)?.cnt ?? 0; 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) => { const handleUserStateCodeChange = (code: string, checked: boolean) => {
@@ -277,23 +288,23 @@ function UsersPage() {
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Label className="mr-2 text-label">기간</Label> <Label className="mr-2 text-label">기간</Label>
<DateRangePicker value={dateRange} onValueChange={setDateRange}> <Select
<div className="card card-border flex items-center gap-2"> value={presetDateRange}
<Select onValueChange={handlePresetDateRange}> onValueChange={handlePresetDateRange}
<SelectTrigger> >
<SelectValue <SelectTrigger className="w-[12rem]">
placeholder="기간 선택" <SelectValue placeholder="기간 선택" className="w-[24rem]" />
className="w-[24rem]" </SelectTrigger>
/> <SelectContent>
</SelectTrigger> <SelectItem value="1M">1개월</SelectItem>
<SelectContent> <SelectItem value="3M">3개월</SelectItem>
<SelectItem value="1M">1개월</SelectItem> <SelectItem value="6M">6개월</SelectItem>
<SelectItem value="3M">3개월</SelectItem> </SelectContent>
<SelectItem value="6M">6개월</SelectItem> </Select>
</SelectContent> <InputDateRangePicker
</Select> dateRange={dateRange}
</div> setDateRange={handleDateRangeChange}
</DateRangePicker> />
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
+1
View File
@@ -23,6 +23,7 @@ export const ENDPOINT = {
PRIVACYLOG: "/user-service//api/v1/prvc-inq-logs", PRIVACYLOG: "/user-service//api/v1/prvc-inq-logs",
}, },
CMS_SERVICE: { CMS_SERVICE: {
DASHBOARD: "/cms-service/api/v1/dashboard",
SITES: "/cms-service/api/v1/sites", SITES: "/cms-service/api/v1/sites",
IPS: "/cms-service/api/v1/site-ips", IPS: "/cms-service/api/v1/site-ips",
DOMAINS: "/cms-service/api/v1/site-dmns", DOMAINS: "/cms-service/api/v1/site-dmns",
@@ -0,0 +1,47 @@
import { InputDatePicker } from "@common/components";
import { useEffect } from "react";
import { useAlertStore } from "../lib";
type DateRange = {
from?: Date;
to?: Date;
};
type InputDateRangePickerProps = {
dateRange: DateRange;
setDateRange: (dateRange: DateRange) => void;
};
function InputDateRangePicker({
dateRange,
setDateRange,
}: InputDateRangePickerProps) {
const alert = useAlertStore((state) => state.setMessage);
useEffect(() => {
if (dateRange.from && dateRange.to && dateRange.from > dateRange.to) {
alert("시작일보다 종료일이 커야 합니다.");
setDateRange({ ...dateRange, to: dateRange.from });
}
}, [dateRange]);
return (
<>
<InputDatePicker
value={dateRange.from}
onChange={(date) => {
setDateRange({ ...dateRange, from: date });
}}
/>
~
<InputDatePicker
value={dateRange.to}
onChange={(date) => {
setDateRange({ ...dateRange, to: date });
}}
/>
</>
);
}
export { InputDateRangePicker };
+18
View File
@@ -0,0 +1,18 @@
NEXT_PUBLIC_SITE_ID = DM
NEXT_PUBLIC_PORT = 11035
NEXT_PUBLIC_PROXY_HOST = http://con.mind-one.co.kr:11035
NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_DM_SERVER_API_URL = http://con.mind-one.co.kr:11080/api/v1
NEXT_PUBLIC_FO_SERVER_API_URL = http://con.mind-one.co.kr:11082/api/v1
NEXT_PUBLIC_CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_CS_TUS_UPLOAD = /attach-service/api/v1/tus/upload
NEXT_PUBLIC_CLAIM_NAME = Authorization
NEXT_PUBLIC_AUTH_USER_ID = token-id
NEXT_PUBLIC_ACCESS_TOKEN = access-token
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
NEXT_PUBLIC_REUSE_API_URL=http://con.mind-one.co.kr:11080/api/v1
NEXT_PUBLIC_SEWERAGE_API_URL=http://con.mind-one.co.kr:11083/sewerage-service/api # 개발 서버 API
# NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.128:8883/sewerage-service/api # 최용성
#NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.66:8883/sewerage-service/api # 박이정
+21
View File
@@ -0,0 +1,21 @@
NEXT_PUBLIC_SITE_ID = DM
NEXT_PUBLIC_CLAIM_NAME = Authorization
NEXT_PUBLIC_AUTH_USER_ID = token-id
NEXT_PUBLIC_ACCESS_TOKEN = access-token
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
NEXT_PUBLIC_PORT = 3005
#NEXT_PUBLIC_PROXY_HOST = http://con.mind-one.co.kr:11035
NEXT_PUBLIC_PROXY_HOST = http://localhost:3005/
NEXT_PUBLIC_DM_SERVER_API_URL = http://con.mind-one.co.kr:11080/api/v1
NEXT_PUBLIC_SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_FO_SERVER_API_URL = http://con.mind-one.co.kr:11082/api/v1
NEXT_PUBLIC_CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
NEXT_PUBLIC_GEOSERVER_URL = http://rsone.iptime.org:10010/geoserver
NEXT_PUBLIC_LX_GEOSERVER_URL = http://con.mind-one.co.kr:11306/geoserver
NEXT_PUBLIC_CS_TUS_UPLOAD = /attach-service/api/v1/tus/upload
NEXT_PUBLIC_REUSE_API_URL=http://con.mind-one.co.kr:11080/api/v1
NEXT_PUBLIC_SEWERAGE_API_URL=http://con.mind-one.co.kr:11083/sewerage-service/api # 개발 서버 API
#NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.128:8883/sewerage-service/api # 최용성
#NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.66:8883/sewerage-service/api # 박이정
+17
View File
@@ -0,0 +1,17 @@
NEXT_PUBLIC_SITE_ID = DM
NEXT_PUBLIC_PORT = 11035
NEXT_PUBLIC_PROXY_HOST = http://con.mind-one.co.kr:11035
NEXT_PUBLIC_SERVER_API_URL = http://apigateway.iipbs-dev.svc.cluster.local:8000/api/v1
NEXT_PUBLIC_FO_SERVER_API_URL = http://apigateway.iipbs-dev.svc.cluster.local:8000/operation-service/api/v1
NEXT_PUBLIC_CS_SERVER_API_URL = http://apigateway.iipbs-dev.svc.cluster.local:8000
NEXT_PUBLIC_CS_TUS_UPLOAD = /attach-service/api/v1/tus/upload
NEXT_PUBLIC_GEOSERVER_URL = http://172.29.143.87:8080/geoserver
NEXT_PUBLIC_LX_GEOSERVER_URL = http://172.29.143.87:8080/geoserver_swl
NEXT_PUBLIC_CLAIM_NAME = Authorization
NEXT_PUBLIC_AUTH_USER_ID = token-id
NEXT_PUBLIC_ACCESS_TOKEN = access-token
NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
NEXT_PUBLIC_REUSE_API_URL=http://con.mind-one.co.kr:11080/api/v1
NEXT_PUBLIC_SEWERAGE_API_URL = http://apigateway.iipbs-dev.svc.cluster.local:8000/sewerage-service/api
+39
View File
@@ -0,0 +1,39 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/.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*
# env files (can opt-in for committing if needed)
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+16
View File
@@ -0,0 +1,16 @@
"use client";
import Base from "../base";
import "../globals.css";
import { StoreProvider } from "../store";
export default function BaseLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<StoreProvider>
<Base>{children}</Base>
</StoreProvider>
);
}
@@ -0,0 +1,207 @@
"use client"
import { Table, TableBody, TableCell, TableRow } from '@common/components/ui/table';
import { useDmStore } from '@/app/store';
import { ContentsDiv, Title, UiModal, UiTable } from '@common/business_components/ui';
import ListDiv from '@common/business_components/ui/list_div';
import { useEffect, useState } from 'react';
import { Toaster } from 'react-hot-toast';
import SdmLogin from '../sdmLogin/page';
import type { DemandCompBizType } from '@/app/store/sdmMng';
import TransValueFormat from '@/app/common/transValueFormat';
import { useRouter } from 'next/navigation';
const TABLE_STYLES = {
header: "text-center border border-[var(--border)] bg-[rgb(234,243,251)] text-[rgb(100,119,132)] whitespace-pre-line",
cell: "text-right whitespace-pre-line",
fullWidth: "text-center border border-[var(--border)] bg-[rgb(234,243,251)] text-[rgb(100,119,132)] whitespace-pre-line",
table: "fixed w-full"
} as const;
type SdmApplyProps = {
sdmNo : string;
}
export default function SdmApplyList({}: SdmApplyProps) {
const router = useRouter();
const {getSdmInfoStatusList, Sdm, Common} = useDmStore((state) => state);
const {successLogin, sdmInfoStatusList, upHeadSdmInfoStatus} = Sdm;
const {selectSidoData, selectSigunData} = Common;
const [isLogin, setIsLogin] = useState(false);
const [isLoginStorageInfo, setIsLoginStorageInfo] = useState(false);
const [sdmApplyDetail, setSdmApplyDetail] = useState<DemandCompBizType>({} as DemandCompBizType);
const [transformedList, setTransformedList] = useState<DemandCompBizType[]>([]);
useEffect(() => {
console.log('sessionStorage : ',sessionStorage.getItem('user'));
const user = sessionStorage.getItem('user');
if (user) {
setIsLoginStorageInfo(true);
}
}, []);
useEffect(() => {
if (successLogin || isLoginStorageInfo) {
setIsLogin(true);
const user = sessionStorage.getItem('user');
if (user) {
try {
const userData = JSON.parse(user);
getSdmInfoStatusList(userData.dmdInfoId);
} catch (error) {
console.error('Error parsing user data:', error);
}
}
}
}, [successLogin, getSdmInfoStatusList, isLoginStorageInfo]);
// 데이터 변환을 위한 별도의 useEffect
useEffect(() => {
if (sdmInfoStatusList.length > 0) {
const newTransformedList = sdmInfoStatusList.map((item: DemandCompBizType) => ({
...item,
ctpvCd: selectSidoData.find(sido => sido.val === item.ctpvCd?.trim())?.text || '',
sggCd: selectSigunData.find(sigun => sigun.val === item.sggCd?.trim())?.text || '',
}));
setTransformedList(newTransformedList);
}
}, [sdmInfoStatusList, selectSidoData, selectSigunData]);
const onClickSdmStatus = (index: number | undefined) => {
if (index !== undefined) {
const selectedStatus = transformedList[index];
if (selectedStatus) {
setSdmApplyDetail(selectedStatus);
}
}
}
return (
<>
<Title title="수요신청현황" />
<ContentsDiv>
<ListDiv>
<div className="overflow-y-scroll h-[250px]">
<div className="h-[110%] w-[99%]">
<UiTable
headName='수요정보 신청현황'
tableData={transformedList}
headlist={upHeadSdmInfoStatus}
pageSize={0}
total={0}
cellClick={(index) => onClickSdmStatus(index)}
onPageChange={() => {}}
/>
</div>
</div>
</ListDiv>
<ListDiv>
<div className="card-header">
<h3 className="text-2xl font-medium text-label">수요신청 상세현황</h3>
</div>
<div className={`card !m-[1.2rem] !p-0r`}>
<Table variant="secondary" style={{ tableLayout: 'fixed', width: '100%' }}>
<TableBody>
<TableRow>
<TableCell colSpan={1} className={TABLE_STYLES.header}>요구시기</TableCell>
<TableCell colSpan={2} className={TABLE_STYLES.cell}>{sdmApplyDetail?.dmndTmng?.toString()}</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.header}>요구수량(m³/일)</TableCell>
<TableCell colSpan={2} className={TABLE_STYLES.cell}>{<TransValueFormat value={sdmApplyDetail?.dmndQty} format="number"/>}</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={1} className={TABLE_STYLES.header}>사용용도</TableCell>
<TableCell colSpan={2} className={TABLE_STYLES.cell}>{sdmApplyDetail?.useUsgCd}</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.header}>희망단가(원/m³)</TableCell>
<TableCell colSpan={2} className={TABLE_STYLES.cell}>{<TransValueFormat value={sdmApplyDetail?.wntUntprc} format="number"/>}</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={6} className={TABLE_STYLES.fullWidth}>요구수량</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={1} className={TABLE_STYLES.header}>총대장균군수(개/100ml)</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.cell}>{ sdmApplyDetail?.grssClfg!=-1?<TransValueFormat value={sdmApplyDetail?.grssClfg ?? ''} format="number"/>:''} {sdmApplyDetail?.grssClfg?sdmApplyDetail.grssClfg!=-1? '이하':'불검출':'-'} </TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.header}>결합잔류염소(mg/L)​</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.cell}>{<TransValueFormat value={sdmApplyDetail?.combResdch ?? ''} format="number"/>} {sdmApplyDetail?.combResdch?'이하':'-'}</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.header}>탁도(NTU)</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.cell}>{<TransValueFormat value={sdmApplyDetail?.trbt ?? ''} format="number"/>} {sdmApplyDetail?.trbt?'이하':'-'}</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={1} className={TABLE_STYLES.header}>생물학적 산소요구량(BOD)(mg/L)</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.cell}>{<TransValueFormat value={sdmApplyDetail?.blgcOxygDmndQnt ?? ''} format="number"/>} {sdmApplyDetail?.blgcOxygDmndQnt?'이하':'-'}</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.header}>냄새</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.cell}>{<TransValueFormat value={sdmApplyDetail?.smll} format="number"/>} {sdmApplyDetail?.smll?'이하':'-'}</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.header}>색도(도)</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.cell}>{<TransValueFormat value={sdmApplyDetail?.crmty ?? ''} format="number"/>} {sdmApplyDetail?.crmty?'이하':'-'}</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={1} className={TABLE_STYLES.header}>총질소(T-N)(mg/L)</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.cell}>{<TransValueFormat value={sdmApplyDetail?.tn ?? ''} format="number"/>} {sdmApplyDetail?.tn?'이하':'-'}</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.header}>총인(T-P)(mg/L)​</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.cell}>{<TransValueFormat value={sdmApplyDetail?.tph ?? ''} format="number"/>} {sdmApplyDetail?.tph?'이하':'-'}</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.header}>수소이온농도(pH)​</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.cell}>{<TransValueFormat value={sdmApplyDetail?.ph} format="number"/>}</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={1} className={TABLE_STYLES.header}>염화물(mgCL/L)​</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.cell}>{<TransValueFormat value={sdmApplyDetail?.clIonQnt ?? ''} format="number"/>} {sdmApplyDetail?.clIonQnt?'이하':'-'}</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.header}>전기전도도(㎲/cm)​</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.cell}>{<TransValueFormat value={sdmApplyDetail?.cdtnv ?? ''} format="number"/>} {sdmApplyDetail?.cdtnv?'이하':'-'}</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.header}>비고</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.cell}>{sdmApplyDetail?.rmrk}</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={6} className={TABLE_STYLES.fullWidth}>공급처 후보지​</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={1} className={TABLE_STYLES.header}>후보지1</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.cell}>{ sdmApplyDetail?.splyCndtList?.[0]?.ruseUtztnFcltNm ?? ""}</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.header}>후보지2</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.cell}>{sdmApplyDetail?.splyCndtList?.[1]?.ruseUtztnFcltNm ?? ""}</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.header}>후보지3</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.cell}>{sdmApplyDetail?.splyCndtList?.[2]?.ruseUtztnFcltNm ?? ""}</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</ListDiv>
<ListDiv>
<div className="card-header">
<h3 className="text-2xl font-medium text-label">수요신청 진행상태</h3>
</div>
<div className={`card !m-[1.2rem] !p-0r`}>
<Table variant="secondary" style={{ tableLayout: 'fixed', width: '100%' }}>
<TableBody>
<TableRow>
<TableCell colSpan={1} className={TABLE_STYLES.header}>진행상태</TableCell>
<TableCell colSpan={2} className={TABLE_STYLES.cell}>{sdmApplyDetail?.status?.toString()}</TableCell>
<TableCell colSpan={1} className={TABLE_STYLES.header}>공급처매칭결과</TableCell>
<TableCell colSpan={2} className={TABLE_STYLES.cell}>{sdmApplyDetail?.ruseUtztnFcltNm??''}</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</ListDiv>
</ContentsDiv>
{!isLogin?
<UiModal
body={<SdmLogin/>}
bodyClass="sm:max-w-[90vw] w-[530px] h-[430px]"
open={!isLogin}
onOpenChange={(open) => {
if(!open){
setIsLogin(false)
router.push('/');
}
}}
noHeaderYn="Y"
noFooterYn="Y"
/>
:null}
<Toaster position="top-center" />
</>
);
}
@@ -0,0 +1,166 @@
"use client"
import { useDmStore } from '@/app/store';
import type { MoneyDetailType, SimilarCaseType } from '@/app/store/sdmMng';
import { SelectBox, Title, UiTable } from '@common/business_components/ui';
import { HeadMakeColSpan } from '@common/business_components/ui/table/HeadMakeColSpan';
import { Table, TableBody, TableCell, TableHeader, TableRow } from '@common/components/ui/table';
import { useEffect, useMemo, useState } from 'react';
type modalType = {
sply: number | 0;
usageCd: string | '';
money: number | 0;
};
const searchUseTypeList = [
{ text: '공급량', val: 'S' },
{ text: '사용용도', val: 'U' },
];
export default function ModalSimilarPage({ sply = 0, usageCd = '', money = 0 }: modalType) {
const { Sdm, getSimilarCaseList } = useDmStore((state) => state);
const [searchUseType, setSearchUseType] = useState<{text: string, value: string}>({text: '공급량', value: 'S'});
const [moneyDetailList, setMoneyDetailList] = useState<MoneyDetailType[]>([]);
const {
upHeadSimilarCaseList,
similarCaseList,
upHeadMoneyDetailList,
} = Sdm;
const rowSpans = useMemo(() => {
const spans: { [key: number]: number } = {};
let currentValue = '';
let currentSpan = 0;
let startIndex = 0;
similarCaseList.forEach((item: SimilarCaseType, index: number) => {
if (item.ruseBizNm === currentValue
|| item.bizBgngDate === currentValue
|| item.bizEndDate === currentValue
|| item.reqBizReuseFcltCst === currentValue) {
currentSpan++;
spans[startIndex] = currentSpan;
} else {
currentValue = item.ruseBizNm;
currentSpan = 1;
startIndex = index;
spans[index] = 1;
}
});
return spans;
}, [similarCaseList]);
const handleCellClick = (index: number) => {
setMoneyDetailList([{
reqBizGrssCst: similarCaseList[index]?.reqBizGrssCst ?? '',
reqBizReuseFcltCst: similarCaseList[index]?.reqBizReuseFcltCst ?? '',
reqBizPplCst: similarCaseList[index]?.reqBizPplCst ?? '',
reqBizArchCst: similarCaseList[index]?.reqBizArchCst ?? '',
reqBizMchnCst: similarCaseList[index]?.reqBizMchnCst ?? '',
reqBizMtrngCst: similarCaseList[index]?.reqBizMtrngCst ?? '',
reqBizGisCst: similarCaseList[index]?.reqBizGisCst ?? '',
reqBizCctvCst: similarCaseList[index]?.reqBizCctvCst ?? '',
reqBizCmcnCst: similarCaseList[index]?.reqBizCmcnCst ?? '',
reqBizWstCst: similarCaseList[index]?.reqBizWstCst ?? '',
reqBizDesignCst: similarCaseList[index]?.reqBizDesignCst ?? '',
reqBizSpvsCst: similarCaseList[index]?.reqBizSpvsCst ?? '',
reqBizSbsdCst: similarCaseList[index]?.reqBizSbsdCst ?? '',
reqBizEtc: similarCaseList[index]?.reqBizEtc ?? '',
prcmPlanNlep: similarCaseList[index]?.prcmPlanNlep ?? '',
prcmPlanLcep: similarCaseList[index]?.prcmPlanLcep ?? '',
prcmPlanBrdnCst: similarCaseList[index]?.prcmPlanBrdnCst ?? '',
prcmPlanEcqrRt: similarCaseList[index]?.prcmPlanEcqrRt ?? '',
prcmPlanGrssCst: similarCaseList[index]?.prcmPlanGrssCst ?? '',
clclnNlep: similarCaseList[index]?.clclnNlep ?? '',
clclnLcep: similarCaseList[index]?.clclnLcep ?? '',
clclnBrdnCst: similarCaseList[index]?.clclnBrdnCst ?? '',
clclnEcqrRt: similarCaseList[index]?.clclnEcqrRt ?? '',
clclnGrssCst: similarCaseList[index]?.clclnGrssCst ?? ''
}]);
}
useEffect(() => {
const type = searchUseType.value;
getSimilarCaseList(type, type === 'S' ? sply : type === 'U' ? usageCd : money);
}, [getSimilarCaseList, money, searchUseType, sply, usageCd]);
return (
<div className="align-top w-[1360px] overflow-x-auto">
<Title title="유사사례 조회" />
<div className="overflow-x-auto h-[450px] w-full mt-8" tabIndex={0} aria-label="유사사례 테이블 영역">
<div className="w-[150%]">
<div className="card-header justify-self-start">
<h3 className="text-2xl font-medium text-label">유사사례</h3>
<SelectBox
tabIndex={0}
aria-label="검색 기준 선택"
selectArray={searchUseTypeList}
value={searchUseType.value}
onSelectValue={(val) => {
setSearchUseType({text: searchUseTypeList.find(item => item.val === val)?.text ?? '', value: val.toString()});
}}
className="w-[140px] ml-20"
label=""
selectClass="w-[140px]"
/>
</div>
<div className={`card !m-[1.2rem] !p-0r`}>
<Table variant="secondary">
<TableHeader className="font-bold">
<HeadMakeColSpan upHeadList={upHeadSimilarCaseList} />
</TableHeader>
<TableBody className='text-center'>
{similarCaseList.length > 0 ? similarCaseList?.map((el: SimilarCaseType, index) => (
<TableRow key={`similar-case-row-${index}-${el.ruseBizNm}-${el.bizBgngDate}`}>
{upHeadSimilarCaseList.map((head) => (
head.id && head.id.length > 1 && (
<TableCell
tabIndex={head.id === 'someClickableId' ? 0 : undefined}
role="button"
aria-label={`유사사례 ${head.id} 클릭 시 상세 정보 표시`}
className={`${head.align === 'right' ? 'text-right' : head.align === 'center' ? 'text-center' : typeof el[head.id as keyof SimilarCaseType] === 'number' ? 'text-right' : 'text-center'} cursor-pointer`}
key={`similar-case-cell-${head.id}-${index}-${el.ruseBizNm}`}
rowSpan={(head.id === 'businessName' || head.id ==='businessDuration' || head.id === 'money') ? rowSpans[index] : undefined}
style={(head.id === 'businessName' || head.id === 'businessDuration' || head.id === 'money') && index > 0 && similarCaseList[index - 1]?.ruseBizNm === el.ruseBizNm ? { display: 'none' } : undefined}
onClick={() => handleCellClick(index)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleCellClick(index);
}
}}
>
{typeof el[head.id as keyof SimilarCaseType] !== 'number'
? String(el[head.id as keyof SimilarCaseType] ?? '')
: String(el[head.id as keyof SimilarCaseType]).includes('.')
? String(el[head.id as keyof SimilarCaseType]).split('.').map((part, index) =>
index === 0 ? part.replace(/\B(?=(\d{3})+(?!\d))/g, ",") : part
).join('.')
: String(el[head.id as keyof SimilarCaseType] ?? '').replace(/\B(?=(\d{3})+(?!\d))/g, ",")}
</TableCell>
)
))}
</TableRow>
)) : <></>}
</TableBody>
</Table>
</div>
</div>
</div>
<div className="overflow-x-auto h-[270px] w-full mt-8">
<div className="w-[130%]">
<UiTable
headName={"사업비 상세정보"}
tableData={moneyDetailList}
headlist={upHeadMoneyDetailList}
pageSize={0}
total={0}
cellClick={undefined}
onPageChange={() => {}}
/>
</div>
</div>
</div>
)
}
@@ -0,0 +1,273 @@
"use client";
import { useDmStore } from "@/app/store";
import { sdmInfoFormSchema, type DemandCompBizType, type SdmInfoFormData } from "@/app/store/sdmMng";
import { SdmInfoRegForm } from "@/components/ui/sdmInfoInsertForm/component";
import { UiModal } from "@common/business_components/ui/dialog_ui";
import { Button } from "@common/components/ui";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useState } from "react";
import { FormProvider, useForm, type SubmitHandler } from "react-hook-form";
import { Toaster } from "react-hot-toast";
import SdmLogin from "../sdmLogin/page";
function SdmInfoInsertContent() {
const searchParams = useSearchParams();
const { insertSdmInfoData, Sdm, setSubsInfo } = useDmStore((state) => state);
const { successLogin } = Sdm;
const [ismoneyDisabled, setIsmoneyDisabled] = useState(false);
const formSchema = sdmInfoFormSchema;
const [isLogin, setIsLogin] = useState(false);
const [facilityName, setFacilityName] = useState<string>("");
const [defaultValues, setDefaultValues] = useState<SdmInfoFormData>({
sdmNm: "",
demandYmd: new Date(),
demandQnty: "",
usePurpose: "",
money: "",
/**요구수질 */
reqWaterQlty: "",
grssClfg: null,
tn: null,
combResdch: null,
tph: null,
trbt: null,
ph: null,
bod: null,
/**컬럼확인필요 */
clIonQnt: null,
smll: "",
cdtnv: null,
crmty: null,
rmrk: "",
/** 후보지 제목 */
candidateTitle: [
{ ruseUtztnFcltNm: "", ruseUtztnFcltCd: "" },
{ ruseUtztnFcltNm: "", ruseUtztnFcltCd: "" },
{ ruseUtztnFcltNm: "", ruseUtztnFcltCd: "" },
],
});
useEffect(() => {
const user = sessionStorage.getItem("user");
setDefaultValues((prev) => ({
...prev,
sdmNm: searchParams.get("dmdCoNm") || "",
}));
sessionStorage.setItem("user", searchParams.get("dmdInfoId") || "");
setIsLogin(user || searchParams.get("dmdInfoId") ? true : false);
}, [searchParams]);
useEffect(() => {
const local = localStorage.getItem("user");
if (local) {
setIsLogin(true);
}
setSubsInfo([]); // subsInfo 초기화 후보지 처음 화면 들어올때 초기화(스토어내 객체만)
}, [setSubsInfo]);
const form = useForm<SdmInfoFormData>({
resolver: zodResolver(formSchema),
defaultValues,
mode: "onSubmit",
});
const subsInfo = useDmStore((state) => state.map.subsInfo);
useEffect(() => {
//map.subsInfo 스토어 객체 값이 변경되면 해당 값이 폼에 자동으로 들어가도록 수정 대신 값이 있어야함
if (subsInfo.length > 0) {
form.setValue("candidateTitle", subsInfo);
}
}, [subsInfo, form]);
//후보지 팝업 값 셋팅
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
if (event.data?.type === "SET_SUBS_INFO") {
setSubsInfo(event.data.payload); // zustand로 값 설정
}
};
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, [setSubsInfo]);
// 로그인 성공 시 모달 닫기 및 폼 업데이트
useEffect(() => {
if (successLogin) {
setIsLogin(true);
const user = sessionStorage.getItem("user");
if (user) {
try {
const userData = JSON.parse(user);
if (userData.dmdCoNm) {
setFacilityName(userData.dmdCoNm);
form.setValue("sdmNm", userData.dmdCoNm);
}
} catch (error) {
console.error("Error parsing user data:", error);
}
}
}
}, [successLogin, form]);
useEffect(() => {
console.log("Form state changed:", form.formState.errors);
}, [form.formState.errors]);
// 체크박스 상태가 변경될 때마다 money 필드의 에러를 초기화
useEffect(() => {
if (ismoneyDisabled) {
form.clearErrors("money");
form.setValue("money", "");
}
}, [ismoneyDisabled, form]);
const router = useRouter();
const onSubmit: SubmitHandler<SdmInfoFormData> = async (data) => {
try {
const dmSubsInfo = data.candidateTitle?.filter(
(item) => item.ruseUtztnFcltNm && item.ruseUtztnFcltCd
);
// Transform form data to madata.candidateTitletch API schema
const apiData: DemandCompBizType = {
dmdInfoId:
JSON.parse(
sessionStorage.getItem("user") || "{}"
)?.dmdInfoId?.toString() ||
searchParams.get("dmdInfoId") + (searchParams.get("bznmTotNo") || ""),
dmdInfoFk:
JSON.parse(
sessionStorage.getItem("user") || "{}"
)?.dmdInfoId?.toString() ||
searchParams.get("dmdInfoId") + (searchParams.get("bznmTotNo") || ""),
dmdCoNm:
JSON.parse(
sessionStorage.getItem("user") || "{}"
)?.dmdCoNm?.toString() || data.sdmNm,
ctpvCd:
JSON.parse(
sessionStorage.getItem("user") || "{}"
)?.ctpvCd?.toString() ||
searchParams.get("ctpvCd") ||
"",
sggCd:
JSON.parse(
sessionStorage.getItem("user") || "{}"
)?.sggCd?.toString() ||
searchParams.get("sggCd") ||
"",
daddr:
JSON.parse(
sessionStorage.getItem("user") || "{}"
)?.daddr?.toString() ||
searchParams.get("daddr") ||
"",
telno:
JSON.parse(
sessionStorage.getItem("user") || "{}"
)?.telno?.toString() ||
searchParams.get("telno") ||
"",
rgtr:
JSON.parse(
sessionStorage.getItem("user") || "{}"
)?.rgtr?.toString() ||
searchParams.get("rgtr") ||
"",
useUsgCd: data.usePurpose || "",
wntUntprc: Number(data.money) || "",
dmndTmng: data.demandYmd || new Date(),
dmndQty: Number(data.demandQnty) || "",
grssClfg: data.grssClfg === null ? -1 : Number(data.grssClfg),
tn: Number(data.tn) || null,
combResdch: Number(data.combResdch) || null,
tph: Number(data.tph) || null,
trbt: Number(data.trbt) || null,
ph: data.ph || "",
blgcOxygDmndQnt: Number(data.bod) ||null,
clIonQnt: Number(data.clIonQnt) || null,
smll: data.smll || "",
cdtnv: Number(data.cdtnv) ||null,
crmty: Number(data.crmty) || null,
rmrk: data.rmrk || "",
bzmnNo: "",
dmdAplyPswd: "",
index: 0,
reqDt: "",
dmdInfoSn: 0,
status: "",
splyCndtList: dmSubsInfo || [],
};
await insertSdmInfoData(apiData);
setTimeout(() => {
router.push("/");
}, 1000); // 0.8초 후 이동
} catch (error) {
console.error("Error submitting form:", error);
}
};
return (
<div className="min-h-screen bg-[#F2F2F2]">
<div className="container mx-auto px-4 py-8">
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-900 underline">
수요정보 등록
</h1>
</div>
<div className="max-w-3xl mx-auto bg-white rounded-lg shadow-md p-6">
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<SdmInfoRegForm
form={form}
onCheckboxChange={setIsmoneyDisabled}
facilityName={facilityName}
/>
<div className="flex justify-center pt-4">
<Button
color="sky"
size={"sm"}
type="submit"
className="w-40 font-semibold py-2 px-4 rounded-md "
>
수요정보 등록
</Button>
</div>
</form>
</FormProvider>
</div>
</div>
{!isLogin ? (
<UiModal
body={<SdmLogin />}
bodyClass="sm:max-w-[90vw] w-[530px] h-[430px]"
open={!isLogin}
onOpenChange={(open) => {
if (!open) {
router.push("/");
}
}}
noHeaderYn="Y"
noFooterYn="Y"
/>
) : null}
<Toaster position="top-center" />
</div>
);
}
export default function MyForm() {
return (
<Suspense fallback={<div>Loading...</div>}>
<SdmInfoInsertContent />
</Suspense>
);
}
@@ -0,0 +1,66 @@
"use client";
import { useDmStore } from "@/app/store";
import SdmInsertForm from '@common/business_components/ui/sdmInsertForm';
import type { DemandCompType } from "@common/business_components/ui/sdmInsertForm/form";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef } from "react";
import { Toaster } from "react-hot-toast";
export default function MyForm() {
const { Common, initializeCommonData, getDemandCompList, insertSdmData, Sdm } = useDmStore((state) => state);
const { selectSidoData, selectSigunData } = Common;
const { demandCompList, successNextPage } = Sdm;
const router = useRouter();
const formDataRef = useRef<DemandCompType | null>(null);
useEffect(() => {
initializeCommonData();
}, [initializeCommonData]);
useEffect(() => {
if (successNextPage && formDataRef.current) {
const data = formDataRef.current;
console.log(data);
const queryParams = new URLSearchParams({
dmdCoNm: data.dmdCoNm || '',
ctpvCd: data.ctpvCd || '',
sggCd: data.sggCd || '',
dmdInfoId: data.dmdInfoId || '',
daddr: data.daddr || '',
telno: data.telno || '',
});
router.push(`/pages/sdmMng/sdmInfoInsert?${queryParams.toString()}`);
}
}, [successNextPage, router]);
const callback = useCallback((searchText: string, pageCnt: number) => {
searchText = searchText.trim();
if (searchText.length > 0) {
getDemandCompList(searchText, pageCnt);
}
}, [getDemandCompList]);
const handleInsertSdmData = useCallback((data: import('@common/business_components/ui/sdmInsertForm/form').DemandCompType) => {
const convertedData: DemandCompType = {
...data,
};
formDataRef.current = convertedData;
insertSdmData(convertedData)
}, [insertSdmData]);
return (
<div className="min-h-screen bg-[#F2F2F2]">
<div className="pt-12">
<SdmInsertForm
selectSidoData={selectSidoData}
selectSigunData={selectSigunData}
dataList={demandCompList}
rtnValue={callback}
rtnInputData={handleInsertSdmData}
/>
<Toaster position="top-center" />
</div>
</div>
);
}
@@ -0,0 +1,18 @@
'use client'
import { SdmLoginForm } from '@/components/ui/sdmLoginForm/component';
export default function SdmLogin() {
return (
<div className="container mx-auto px-4 py-8">
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-gray-900 underline">
수요처 로그인
</h1>
</div>
<div className="max-w-3xl mx-auto bg-white rounded-lg shadow-md p-6">
<SdmLoginForm />
</div>
</div>
);
}
@@ -0,0 +1,339 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import Image from 'next/image';
const NAVS = [
{ id: 'card1', label: '물재이용이란' },
{ id: 'card2', label: '물재이용 필요성' },
{ id: 'card3', label: '빗물 재이용' },
{ id: 'card4', label: '중수 재이용' },
{ id: 'card5', label: '하수 재이용' },
{ id: 'card6', label: '물재이용 목표' },
];
export default function WrAboutPage() {
const [activeIdx, setActiveIdx] = useState(0);
const navRefs = useRef<(HTMLLIElement | null)[]>([]);
const cardRefs = useRef<(HTMLElement | null)[]>([]);
// 클릭 시 이동
const handleNavClick = (idx: number) => {
setActiveIdx(idx);
const targetCard = cardRefs.current[idx];
if (targetCard) {
targetCard.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
};
// 키보드 이벤트 처리
const handleKeyDown = (e: React.KeyboardEvent, idx: number) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleNavClick(idx);
}
};
useEffect(() => {
const observerOptions = {
root: null,
rootMargin: '-200px 0px 0px 0px', // 상단에서 200px 지점을 기준으로
threshold: 0
};
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const idx = cardRefs.current.findIndex(ref => ref === entry.target);
if (idx !== -1) {
setActiveIdx(idx);
}
}
});
}, observerOptions);
// 각 카드 요소를 관찰
const cardsSnapshot = [...cardRefs.current];
cardsSnapshot.forEach((card) => {
if (card) {
observer.observe(card);
}
});
return () => {
cardsSnapshot.forEach((card) => {
if (card) {
observer.unobserve(card);
}
});
};
}, []);
return (
<div className='bg-[#F2F2F2]'>
<main className="relative w-[950px] mx-auto pt-[100px] font-sans">
{/* 사이드바 */}
<nav className="fixed left-[290px] top-[195px] w-[210px] z-50" aria-label="물재이용 정보 섹션 내비게이션">
<ul role="list" className="relative space-y-6">
{NAVS.map((nav, idx) => (
<li
key={nav.id}
ref={(el) => { navRefs.current[idx] = el; }}
role="listitem"
tabIndex={0}
aria-current={idx === activeIdx ? 'true' : 'false'}
aria-label={`현재 위치: ${nav.label}`}
onClick={() => handleNavClick(idx)}
onKeyDown={(e) => handleKeyDown(e, idx)}
className={`font-NotoSansKR nav-item relative pl-6 text-2xl font-bold text-[#383838] cursor-pointer transition-all duration-300
after:content-[''] after:absolute after:top-6 after:left-[0.5rem] after:w-[2px] after:h-[24px] after:bg-[#C5C5C5] last:after:hidden
${idx === activeIdx ? 'text-4xl font-bold text-[#383838]' : ''}`}
>
<span className={`absolute left-[0.5rem] top-1 w-4 h-4 rounded-full -translate-x-1/2 transition-all duration-300
${idx === activeIdx ? 'bg-blue-500 border-blue-500' : 'bg-white border-gray-400 border'}`}></span>
{nav.label}
</li>
))}
</ul>
</nav>
<article ref={(el) => { cardRefs.current[0] = el; }} className="card-wrap pl-[100px] mb-24 !pt-0" id="card1" aria-labelledby="card1-title">
<h2 id="card1-title" className="font-NotoSansKR relative text-center text-4xl font-medium text-gray-900 mb-6">
<span className="absolute left-1/2 top-[-15px] -translate-x-1/2 w-24 h-2 bg-blue-500 rounded-full"></span>
물재이용이란
</h2>
<section aria-labelledby="card1-desc" className="bg-white border border-gray-300 rounded-xl p-[60px] pt-[70px] pb-[70px] ">
<h1 id="card1-desc" className="font-NotoSansKR text-[#269DDA] text-5xl font-bold mb-8">물 재이용이 무엇인가요?</h1>
<p className="font-NotoSansKR text-[#231815] text-2xl mb-10 leading-relaxed">
물 재이용은 빗물, 오수, 하수처리수, 폐수처리수 및 발전소 온배수를<br />
물 재이용시설을 이용하여 처리하고, 그 처리된 물을 생활, 공업, 농업, 조경,<br />
하천 유지 등의 용도로 이용하는 것을 말합니다.
</p>
<Image
src="/img/wrAbout/img_01_1.png"
alt="이 이미지는 물 재이용 시스템의 개요를 나타냅니다. 가정, 공공기관, 하수처리장 등에서 발생한 생활하수, 빗물, 하수처리수를 물 재이용 시설에서 처리한 후, 청소나 화장실, 농업, 살수차, 하천 유지, 공장, 공원 및 골프장 등 다양한 용도로 활용하는 과정을 도식화한 것입니다."
width={800}
height={400}
className="w-[90%] mx-auto mb-8 rounded"
/>
<p className="font-NotoSansKR text-center font-normal text-2xl">
환경부는 2011년에 &apos;물의 재이용 촉진 및 지원에 관한 법률&apos;을 제정하여<br />
한국환경공단과 함께 물 재이용 확대 및 촉진을 위해 노력하고 있습니다.
</p>
</section>
</article>
{/* 카드2 */}
<article ref={(el) => { cardRefs.current[1] = el; }} className="card-wrap pl-[100px] mb-24" id="card2" aria-labelledby="card2-title">
<h2 id="card2-title" className="font-NotoSansKR relative text-center text-4xl font-medium text-gray-900 mb-6">
<span className="absolute left-1/2 top-[-15px] -translate-x-1/2 w-24 h-2 bg-blue-500 rounded-full"></span>
물재이용 필요성
</h2>
<section aria-labelledby="card2-desc" className="bg-white border border-gray-300 rounded-xl p-[60px] pt-[70px] pb-[70px] ">
<h1 id="card2-desc" className="font-NotoSansKR text-[#D63F1F] text-5xl font-semibold mb-8">물 재이용, 왜 필요한가요?</h1>
<p className="font-NotoSansKR text-2xl font-normal mb-10 leading-relaxed">
빗물, 중수, 하수처리수는 지속적으로 이용 가능한 순환자원입니다.<br />
물 재이용을 통한 효율적인 수자원 활용은 물 스트레스국가인 우리나라에서<br />가뭄대응능력 향상 및 하천수질보전을 위해 반드시 필요한 일입니다.
</p>
<Image
src="/img/wrAbout/img_02_1.png"
alt="이 이미지는 기후변화로 인한 온도 상승 추이를 설명한다. 왼쪽에는 지구 그림과 온도계가 함께 표시되어 있으며, 지구 평균 기온은 지난 100년간 약 0.9℃ 상승했음을 나타낸다. 오른쪽에는 대한민국 지도와 함께 온도계가 있으며, 지난 100년간 약 1.8℃ 상승했음을 보여준다. 이는 지구 평균보다 2배 높은 수치이며, 자료 출처는 「기후변화 평가보고서 2020, 환경부·기상청」이다."
width={800}
height={400}
className="w-[90%] mx-auto mb-8 w-[80%]"
/>
<p className="font-NotoSansKR text-2xl mb-10 leading-relaxed">
- 우리나라의 기후변화 진행속도는 세계 평균을 상회하고 있습니다.<br />
- 기후변화로 인한 국지적인 가뭄은 더욱 자주, 더욱 심하게 일어나고 있으며, 우리의 물 안보를 위협하고 있습니다.
</p>
<Image
src="/img/wrAbout/img_02_2.png"
alt="세계 평균 1인당 강수량은 15,044m³이며, 대한민국은 2,546m³로 약 1/6 수준입니다. 계절별 강수량은 봄 10%, 여름 70%, 가을 15%, 겨울 5%입니다. 이는 여름철에 강수량이 집중되는 문제를 나타냅니다."
width={800}
height={400}
className="w-[90%] mx-auto mb-8"
/>
<p className="font-NotoSansKR text-2xl mb-10 leading-relaxed">
- 우리나라의 1인당 강수량은 2,546㎥ 으로 세계 평균 15,044㎥의 약 1/6 수준입니다.<br />
- 우리나라 강수량의 70%는 여름철에 집중되어 있어 이용 가능한 수자원확보가 어렵습니다.
</p>
<Image
src="/img/wrAbout/img_02_3.png"
alt="물 재이용은 환경을 보호하며 지속 가능한 발전이 가능하게 하고, 안정적인 수자원 확보, 깨끗하고 경제적인 공업용수 공급, 하천 수질 개선을 통한 생태계 회복에 기여합니다."
width={800}
height={400}
className="w-[93%] mx-auto"
/>
</section>
</article>
{/* 카드3 */}
<article ref={(el) => { cardRefs.current[2] = el; }} className="card-wrap pl-[100px] mb-24" id="card3" aria-labelledby="card3-title">
<h2 id="card3-title" className="font-NotoSansKR relative text-center text-4xl font-medium text-gray-900 mb-6">
<span className="absolute left-1/2 top-[-15px] -translate-x-1/2 w-24 h-2 bg-blue-500 rounded-full"></span>
빗물 재이용
</h2>
<section aria-labelledby="card3-desc" className="bg-white border border-gray-300 rounded-xl p-[60px] pt-[70px] pb-[70px] ">
<h1 id="card3-desc" className="font-NotoSansKR leading-snug text-[#269DDA] text-5xl font-bold mb-6">친환경 수자원 빗물,<br />편리하게 사용할 수 있어요!</h1>
<p className="font-NotoSansKR text-2xl mb-10 leading-relaxed">가뭄을 극복하고 홍수를 예방하기 위해<br />비가 내릴 때 빗물을 모아두었다가 사용합니다.</p>
<Image
src="/img/wrAbout/img_03_1.png"
alt="주택 지붕에 내린 빗물을 저장조에 모아 조경용수, 화장실용수, 세척·살수용수로 재활용하는 과정을 보여줍니다."
width={800}
height={400}
className="w-[90%] mx-auto mb-6"
/>
<Image
src="/img/wrAbout/img_03_2.png"
alt="빗물이용"
width={200}
height={100}
className="mb-5 w-[20%]"
/>
<p className="font-NotoSansKR text-2xl leading-relaxed">
- 의무 설치대상: 지붕면적 1,000㎡ 이상의 운동장, 체육관, 공공업무시설 등<br />
- 설 치 기 준: 집수시설, 처리시설, 빗물 저류조(지붕 집수면적 x 0.05m 이상)<br />
- 활 용 용 도: 골프장 등의 조경용수, 화장실용수, 세척·살수용수 등
</p>
</section>
</article>
{/* 카드4 */}
<article ref={(el) => { cardRefs.current[3] = el; }} className="card-wrap pl-[100px] mb-24" id="card4" aria-labelledby="card4-title">
<h2 id="card4-title" className="font-NotoSansKR relative text-center text-4xl font-medium text-gray-900 mb-6">
<span className="absolute left-1/2 top-[-15px] -translate-x-1/2 w-24 h-2 bg-blue-500 rounded-full"></span>
중수 재이용
</h2>
<section aria-labelledby="card4-desc" className="bg-white border border-gray-300 rounded-xl p-[60px] pt-[70px] pb-[70px] ">
<h1 id="card4-desc" className="font-NotoSansKR leading-snug text-[#9771B0] text-5xl font-bold mb-6">요모조모 쓸모 많은 중수,<br />알뜰하게 재이용할 수 있어요!</h1>
<p className="font-NotoSansKR text-2xl mb-10 leading-relaxed">한번 사용한 물을 중수도 시설을 통해 재생하여<br />개별적 또는 지역적으로 재이용할 수 있습니다.</p>
<Image
src="/img/wrAbout/img_04_1.png"
alt="욕실, 세면대, 주방, 세탁기 등에서 발생한 생활용수를 중수도 시설에서 처리하여 화장실 변기용수와 조경용수로 재이용하는 시스템 흐름도입니다."
width={800}
height={400}
className="w-[90%] mx-auto mb-6"
/>
<Image
src="/img/wrAbout/img_04_2.png"
alt="중수재이용"
width={200}
height={100}
className="mb-5 w-[20%]"
/>
<p className="font-NotoSansKR text-2xl leading-relaxed">
- 의무 설치대상: 일정규모 이상의 대규모점포, 숙박/목욕장, 공장, 각종 개발사업 등<br />
- 의무 재이용량: 물 사용량의 10% 이상<br />
- 활 용 용 도: 청소·화장실용수, 세척·살수용수, 조경용수, 공업용수 등
</p>
</section>
</article>
{/* 카드5 */}
<article ref={(el) => { cardRefs.current[4] = el; }} className="card-wrap pl-[100px] mb-24" id="card5" aria-labelledby="card5-title">
<h2 id="card5-title" className="font-NotoSansKR relative text-center text-4xl font-medium text-gray-900 mb-6">
<span className="absolute left-1/2 top-[-15px] -translate-x-1/2 w-24 h-2 bg-blue-500 rounded-full"></span>
하수 재이용
</h2>
<section aria-labelledby="card5-desc" className="bg-white border border-gray-300 rounded-xl p-[60px] pt-[70px] pb-[70px] ">
<h1 id="card5-desc" className="font-NotoSansKR leading-snug text-[#00818A] text-5xl font-bold mb-6">아직 할 일이 많은 하수처리수,<br />풍족하게 재이용할 수 있어요!</h1>
<p className="font-NotoSansKR text-2xl mb-10 leading-relaxed">사용하고 버려져 공공하수처리장으로 모여든 물은<br />용도에 맞는 처리 후 재이용 할 수 있습니다.</p>
<Image
src="/img/wrAbout/img_05_1.png"
alt="주택과 건물에서 발생한 하수가 공공하수처리장을 거쳐 처리된 후, 물 재이용 시설에서 정화되어 공업용수, 하천유지용수, 농업용수, 세척·살수용수로 재활용됩니다."
width={800}
height={400}
className="w-[90%] mx-auto mb-6"
/>
<Image
src="/img/wrAbout/img_05_2.png"
alt="하수처리 재이용"
width={200}
height={100}
className="mb-5 w-[20%]"
/>
<p className="font-NotoSansKR text-2xl leading-relaxed">
- 의무 이용대상: 공공하수처리장(시설용량 5,000㎥/일 이상)<br />
- 의무 재이용량: 일 하수처리량의 10% 이상<br />
- 활 용 용 도: 공업용수, 하천유지용수, 농업용수 등
</p>
</section>
</article>
{/* 카드6 */}
<article ref={(el) => { cardRefs.current[5] = el; }} className="card-wrap pl-[100px] mb-24" id="card6" aria-labelledby="card6-title">
<h2 id="card6-title" className="font-NotoSansKR relative text-center text-4xl font-medium text-gray-900 mb-6">
<span className="absolute left-1/2 top-[-15px] -translate-x-1/2 w-24 h-2 bg-blue-500 rounded-full"></span>
물재이용 목표
</h2>
<section aria-labelledby="card6-desc" className="bg-white border border-gray-300 rounded-xl p-[60px] pt-[70px] pb-[70px] ">
<h1 id="card6-desc" className="font-NotoSansKR leading-snug text-[#269DDA] text-5xl font-bold mb-6">2030년 물 재이용 목표를 위해<br />모두의 관심과 노력이 필요합니다.</h1>
<p className="font-NotoSansKR text-2xl mb-6 leading-relaxed">
환경부는 2030년까지의 제2차 물 재이용 기본계획을 수립하여<br />한국환경공단과 함께 물 재이용 확대 및 촉진을 위해 노력하고 있습니다.
</p>
<Image
src="/img/wrAbout/img_06_1.png"
alt="지속가능한 물 재이용 정착으로 건전한 물순환 확산"
width={800}
height={400}
className="w-[80%] mx-auto mb-6"
/>
<div className="flex gap-3 flex-wrap justify-between bg-gray-100 p-6 rounded-lg mb-8">
<div className="flex-1 text-center">
<Image
src="/img/wrAbout/icon_01.png"
alt=""
width={70}
height={70}
className="mx-auto mb-4 w-[auto] h-[70px]"
/>
<h3 className="font-NotoSansKR text-center font-medium text-nowrap mb-2 text-2xl">도시 물 순환이용<br />체계로의 전환</h3>
<ul className="font-NotoSansKR text-xl text-gray-600 leading-relaxed space-y-1">
<li className="text-nowrap">- 지속가능한 물 순환이용체계 구축</li>
<li>- 물 재이용시설 관리체계 강화</li>
</ul>
</div>
<div className="flex-1 text-center">
<Image
src="/img/wrAbout/icon_02.png"
alt=""
width={70}
height={70}
className="mx-auto mb-4 w-[auto] h-[70px]"
/>
<h3 className="font-NotoSansKR text-center font-medium text-nowrap mb-2 text-2xl">유역기반 하수처리수 재이용<br />수요확대 및 공급체계 구축</h3>
<ul className="font-NotoSansKR text-xl text-gray-600 leading-relaxed space-y-1">
<li>- 유역기반 통합물관리 연계 강화</li>
<li>- 하수처리수 재이용 공업용수 공급 활성화</li>
<li>- 하수처리수 재이용을 활용한 물순환 촉진</li>
</ul>
</div>
<div className="flex-1 text-center">
<Image
src="/img/wrAbout/icon_03.png"
alt=""
width={70}
height={70}
className="mx-auto mb-4 w-[auto] h-[70px]"
/>
<h3 className="font-NotoSansKR text-center font-medium text-nowrap mb-2 text-2xl">물 재이용 인식제고<br />및 산업발전 기반 마련</h3>
<ul className="font-NotoSansKR text-xl text-gray-600 leading-relaxed space-y-1">
<li>- 물 재이용 교육 및 대국민 홍보 강화</li>
<li>- 핵심기술 육성 및 산업발전 지원 강화</li>
</ul>
</div>
</div>
<Image
src="/img/wrAbout/img_06_2.png"
alt="물 재이용량은 2014년 13.3억톤에서 2030년 25.9억톤까지 증가할 것으로 예상됩니다. 그에 따른 기대 효과로는 연간 상수대체 17.9억톤, 물산업 시장성장 약 2조3,541억 원, 건천화된 73개 하천 해소, 약 20,550명의 고용 효과가 포함됩니다."
width={800}
height={400}
className="w-[90%] mx-auto"
/>
</section>
</article>
</main>
</div>
);
}
@@ -0,0 +1,364 @@
import Image from "next/image";
export default function WrExamplePage() {
return (
<div className="bg-[#F2F2F2]">
<div className="relative w-[950px] mx-auto pt-[50px] font-sans">
<h2 className="font-NotoSansKR relative text-center text-4xl font-bold text-gray-900 mb-6">
<span className="absolute left-1/2 top-[-15px] -translate-x-1/2 w-24 h-2 rounded-full"></span>
물 재이용사례
</h2>
</div>
<div className="bg-[#F2F2F2] text-gray-800">
<div className="relative w-[950px] mx-auto pt-[50px] font-sans">
{/* 카드 1 */}
<div className="card-wrap mb-24" id="card1">
<h2 className="font-NotoSansKR relative text-center text-4xl font-bold text-gray-900 mb-6">
<span className="absolute left-1/2 top-[-15px] -translate-x-1/2 w-24 h-2 bg-blue-500 rounded-full"></span>
사업사례 1
</h2>
<div className="bg-white border border-gray-300 rounded-xl p-[60px] pt-[70px] pb-[70px]">
{/* 사업명 */}
<section>
<h2 className="font-NotoSansKR text-2xl text-[#657D91] font-bold bg-[#EAEFF3] p-3 rounded mb-4">사업명</h2>
<p className="font-NotoSansKR text-2xl font-semibold text-gray-800 leading-relaxed">
경기권역 반도체 사업장 물 재이용 사업<br />
: 삼성전자 반도체 사업장 하수재이용수 공급(재이용시설 설치, 공급망 구축 등)
</p>
</section>
{/* 사업 배경 및 목적 */}
<section className="mt-8">
<h2 className="font-NotoSansKR text-2xl text-[#657D91] font-bold bg-[#EAEFF3] p-3 rounded mb-4">사업 배경 및 목적</h2>
<p className="font-NotoSansKR text-2xl font-semibold text-gray-800 leading-relaxed">
삼성전자는 환경경영전략 실현과 반도체 공장의 안정적 용수 공급을 위해 하수 재이용수를 공업용수로 도입하기로 결정
</p>
</section>
{/* 사업 주요 내용 */}
<section className="mt-8">
<h2 className="font-NotoSansKR text-2xl text-[#657D91] font-bold bg-[#EAEFF3] p-3 rounded mb-4">사업 주요 내용</h2>
<p className="font-NotoSansKR text-2xl font-semibold text-gray-800 leading-relaxed">
1단계 사업[화성·오산(12만㎥/일) → 삼성 화성·기흥(29년 공급)]<br />
2단계 사업[수원(21만㎥/일) → 삼성 평택(30년 공급)]
</p>
</section>
{/* 특이사항 */}
<section className="mt-8">
<h2 className="font-NotoSansKR text-2xl text-[#657D91] font-bold bg-[#EAEFF3] p-3 rounded mb-4">특이사항</h2>
<p className="font-NotoSansKR text-2xl font-semibold text-gray-800 leading-relaxed">
본 사업은 광역 하수 재이용 공급망 구축의 첫 사업으로 초기 민관협의체를 구성·운영 후 관계기관(환경부, 경기도, 지자체, 공단, 수공, 삼성전자) 간 MOU 체결을 통해 사업을 추진하였으며, 반도체 사업장에 하수 재이용수를 공업용수로 공급하는 국내 첫 사업으로 위협부담 해소를 위해 사업을 단계별로 시행
</p>
</section>
{/* 단계별 시각 정보 */}
<section className="bg-white px-4 mt-8">
<Image
src="/img/wrExample/img_ex_01_1.png"
alt="사업단계 설명"
width={800}
height={400}
className="w-[100%] mx-auto mb-8"
/>
</section>
</div>
</div>
{/* 카드2 */}
<div className="card-wrap mb-24" id="card2">
<h2 className="font-NotoSansKR relative text-center text-4xl font-bold text-gray-900 mb-6">
<span className="absolute left-1/2 top-[-15px] -translate-x-1/2 w-24 h-2 bg-blue-500 rounded-full"></span>
사업사례 2
</h2>
<div className="bg-white border border-gray-300 rounded-xl p-[60px] pt-[70px] pb-[70px]">
{/* 사업명 */}
<section>
<h2 className="font-NotoSansKR text-2xl text-[#657D91] font-bold bg-[#EAEFF3] p-3 rounded mb-4">사업명</h2>
<p className="font-NotoSansKR text-2xl font-semibold text-gray-800 leading-relaxed">
경기도 파주시 하수재이용<br />
: 운정·금촌하수처리시설의 하수처리수를 막공법(U/F, R/O)으로 재처리하여 파주 LCD 산업단지내 3개 업체*에 공업용수 공급<br /><br />
*LG디스플레이(P10공장), 희성전자, 엔비텍 등
</p>
</section>
{/* 사업 방식 */}
<section className="mt-8">
<h2 className="font-NotoSansKR text-2xl text-[#657D91] font-bold bg-[#EAEFF3] p-3 rounded mb-4">사업 방식</h2>
<p className="font-NotoSansKR text-2xl font-semibold text-gray-800 leading-relaxed">
민간투자사업(시행사 : 파주그린허브(주), 운영사 : 테크로스)
</p>
</section>
{/* 사업 주요 내용 */}
<section className="mt-8">
<h2 className="font-NotoSansKR text-2xl text-[#657D91] font-bold bg-[#EAEFF3] p-3 rounded mb-4">사업 주요 내용</h2>
<p className="font-NotoSansKR text-2xl font-semibold text-gray-800 leading-relaxed">
처리공정 : 전처리분리막(U/F) + 역삼투서비(R/O)<br />
공급량 : 4만m³/일<br />
관로 : 13.6km<br />
사업비 : 651억원
</p>
</section>
{/* 특이사항 */}
<section className="mt-8">
<h2 className="font-NotoSansKR text-2xl text-[#657D91] font-bold bg-[#EAEFF3] p-3 rounded mb-4">특이사항</h2>
<p className="font-NotoSansKR text-2xl font-semibold text-gray-800 leading-relaxed">
하수처리수 재이용시설 자체 농축폐수 처리공정 개발 및 적용 성공사례
</p>
<div className="grid grid-cols-1 gap-0 items-start">
{/* 좌측 이미지 */}
<div>
<Image
src="/img/wrExample/img_ex_02_1.png"
alt="적용 성공사례 지도"
width={1000}
height={1200}
className="w-[100%] mx-auto"
/>
</div>
{/* 우측 표 */}
<div className="space-y-3">
{/* 하수처리수 공급 */}
<div>
<table className="min-w-full h-[500px]text-center border border-black">
<colgroup>
<col style={{ width: "20%" }} />
<col style={{ width: "40%" }} />
<col style={{ width: "40%" }} />
</colgroup>
<tbody>
<tr>
<td colSpan={3} className="font-NotoSansKR bg-[#ffc000] h-[30px] text-xl text-white font-semibold text-center pt-[6px] pb-[6px] px-0">하수처리수 공급</td>
</tr>
<tr className="text-xl h-[30px]">
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">위치</td>
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">금촌하수</td>
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">운정하수</td>
</tr>
<tr className="text-sm">
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">시설용량</td>
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">44,000㎥/일</td>
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">60,000㎥/일</td>
</tr>
<tr className="text-sm">
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">유입량</td>
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">27,000㎥/일<br />자연유하</td>
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">30,000㎥/일<br />이송펌프장</td>
</tr>
</tbody>
</table>
{/* 첫 화살표 (자연유하 / 관로이송) */}
<div className="flex h-[30px] justify-between items-center my-3 px-4">
<div className="flex text-xl items-center gap-1 text-orange-600 font-bold pl-6">
<span className="font-NotoSansKR">자연유하: 161m</span>
<Image
src="/img/wrExample/img_arrow_down_1.png"
alt="오른쪽 화살표"
width={24}
height={24}
className="h-6 w-6"
/>
</div>
<div className="flex text-xl items-center gap-1 text-orange-600 font-bold">
<Image
src="/img/wrExample/img_arrow_down_1.png"
alt="왼쪽 화살표"
width={24}
height={24}
className="h-6 w-6"
/>
<span className="font-NotoSansKR">관로이송: 3.2km</span>
</div>
</div>
</div>
{/* 물재이용 처리시설 */}
<div>
<table className="min-w-full text-center border border-black">
<colgroup>
<col style={{ width: "50%" }} />
<col style={{ width: "50%" }} />
</colgroup>
<tbody>
<tr>
<td colSpan={2} className="font-NotoSansKR bg-[#00b050] text-xl h-[30px] text-white font-semibold text-center pt-[6px] pb-[6px] px-0">물재이용 처리시설</td>
</tr>
<tr className="text-sm">
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">유입유량</td>
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">57,000㎥/일</td>
</tr>
<tr className="text-sm">
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">재이용시설</td>
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">40,000㎥/일</td>
</tr>
<tr className="text-sm">
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">농축폐수처리</td>
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">17,000㎥/일</td>
</tr>
</tbody>
</table>
{/* 두 번째 화살표 (공급관로) */}
<div className="flex flex-col items-center my-3">
<div className="flex h-[30px] items-center gap-1 text-xl text-blue-600 font-semibold">
<span className="font-NotoSansKR">공급관로 L=10.4km</span>
<Image
src="/img/wrExample/img_arrow_down_2.png"
alt="아래 화살표"
width={24}
height={24}
className="h-6 w-6"
/>
</div>
</div>
</div>
{/* 수요처 공급 */}
<div>
<table className="min-w-full text-center border border-black">
<colgroup>
<col style={{ width: "50%" }} />
<col style={{ width: "50%" }} />
</colgroup>
<tbody>
<tr>
<td colSpan={2} className="font-NotoSansKR bg-[#3399ff] h-[30px] text-xl text-white font-semibold text-center pt-[6px] pb-[6px] px-0">수요처 공급</td>
</tr>
<tr className="text-sm">
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">수요처</td>
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">LG디스플레이 외 2개사</td>
</tr>
<tr className="text-sm">
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">수요량</td>
<td className="font-NotoSansKR text-xl h-[30px] border-black border p-1 font-bold">40,000㎥/일</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
</div>
</div>
{/* 카드3 */}
<div className="card-wrap mb-24" id="card3">
<h2 className="font-NotoSansKR relative text-center text-4xl font-bold text-gray-900 mb-6">
<span className="absolute left-1/2 top-[-15px] -translate-x-1/2 w-24 h-2 bg-blue-500 rounded-full"></span>
사업사례 3
</h2>
<div className="bg-white border border-gray-300 rounded-xl p-[60px] pt-[70px] pb-[70px]">
{/* 사업명 */}
<section>
<h2 className="font-NotoSansKR text-2xl text-[#657D91] font-bold bg-[#EAEFF3] p-3 rounded mb-4">사업명</h2>
<p className="font-NotoSansKR text-2xl font-semibold text-gray-800 leading-relaxed">
충남 보령 하수재이용<br />
: 보령·신보령화력발전소에 하루 1만톤의 탈황용수 공급을 위해 보령하수처리시설의 하수처리수 재이용 사업 추진
</p>
</section>
{/* 사업 방식 */}
<section className="mt-8">
<h2 className="font-NotoSansKR text-2xl text-[#657D91] font-bold bg-[#EAEFF3] p-3 rounded mb-4">사업 방식</h2>
<p className="font-NotoSansKR text-2xl font-semibold text-gray-800 leading-relaxed">
2017년 한국환경공단과 사업대행 위 · 수탁 협약 체결
</p>
</section>
{/* 사업 주요 내용 */}
<section className="mt-8">
<h2 className="font-NotoSansKR text-2xl text-[#657D91] font-bold bg-[#EAEFF3] p-3 rounded mb-4">사업 주요 내용</h2>
<p className="font-NotoSansKR text-2xl font-semibold text-gray-800 leading-relaxed">
처리공정 : 전처리분리막(U/F) + 역삼투서비(R/O)<br />
공급량 : 1만m3/일<br />
관로 : 8.7km<br />
사업비 : 315억원(국비 216억원, 재원협의(안))
</p>
</section>
{/* 특이사항 */}
<section className="mt-8">
<h2 className="font-NotoSansKR text-2xl text-[#657D91] font-bold bg-[#EAEFF3] p-3 rounded mb-4">특이사항</h2>
<p className="font-NotoSansKR text-2xl font-semibold text-gray-800 leading-relaxed">
국내 유일 하수재이용 공업용수 공급 재정사업으로 2016년 충남 서부권 가뭄 시 지역사회 기여 차원에서 기관간 MOU (환경부, 충청남도, 보령시, 한국중부발전㈜)를 바탕
으로 추진 되어 경제성보다 공익성을 바탕으로 국가전력 기반시설의 안정적 운영 도모
</p>
<div className="p-2">
<Image
src="/img/wrExample/img_ex_03_1.png"
alt="국가전력 기반시설 설명"
width={700}
height={350}
className="w-[100%] mx-auto"
/>
</div>
</section>
</div>
</div>
{/* 카드4 */}
<div className="card-wrap mb-24" id="card4">
<h2 className="font-NotoSansKR relative text-center text-4xl font-bold text-gray-900 mb-6">
<span className="absolute left-1/2 top-[-15px] -translate-x-1/2 w-24 h-2 bg-blue-500 rounded-full"></span>
사업사례 4
</h2>
<div className="bg-white border border-gray-300 rounded-xl p-[60px] pt-[70px] pb-[70px]">
{/* 사업명 */}
<section>
<h2 className="font-NotoSansKR text-2xl text-[#657D91] font-bold bg-[#EAEFF3] p-3 rounded mb-4">사업명</h2>
<p className="font-NotoSansKR text-2xl font-semibold text-gray-800 leading-relaxed">
경기도 수원시 빗물이용과 중수도
</p>
</section>
{/* 사업 내용 */}
<section className="mt-8">
<h2 className="font-NotoSansKR text-2xl text-[#657D91] font-bold bg-[#EAEFF3] p-3 rounded mb-4">사업 내용</h2>
<p className="font-NotoSansKR text-2xl font-semibold text-gray-800 leading-relaxed">
(빗물) 월드컵경기장 등 4개소에 노면 빗물분사시스템 및 빗물 공급기를 설치하여 혹서기 도시 열섬효과 및 도로 비산먼지 저감<br />
&nbsp;&nbsp;※ 시설 가동중 도로 노면 온도의 7.2 ~ 26.3℃ 감소 효과 확인(21.7월, 수원시청).<br />
&nbsp;&nbsp;다만, 가뭄기나 강우량이 적을 경우 빗물을 원활히 공급하기 다소 곤란<br /><br />
- (중수도) 수원시청, 장안구청, 월드컵경기장에 빗물 연계 중수도를 화장실·조경용수 등으로 사용하여 수도요금 절감(연 7백~30백만원)
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 items-start p-2">
<div>
<Image
src="/img/wrExample/img_ex_04_1.png"
alt="월드컵경기장 인근도로 노면 살수 이미지"
width={350}
height={250}
className="w-[100%] mx-auto"
/>
<span className="font-NotoSansKR text-xl block text-center font-semibold my-2"><월드컵경기장 인근도로 노면 살수></span>
</div>
<div>
<Image
src="/img/wrExample/img_ex_04_2.png"
alt="주유기 모양의 빗물 공급기 이미지"
width={350}
height={250}
className="w-[100%] mx-auto"
/>
<span className="font-NotoSansKR text-xl block text-center font-semibold my-2"><주유기 모양의 빗물 공급기></span>
</div>
<div>
<Image
src="/img/wrExample/img_ex_04_3.png"
alt="빗물주유기에서 살수용수 공급 이미지"
width={350}
height={250}
className="w-[100%] mx-auto"
/>
<span className="font-NotoSansKR text-xl block text-center font-semibold my-2"><빗물주유기에서 살수용수 공급></span>
</div>
<div>
<Image
src="/img/wrExample/img_ex_04_4.png"
alt="빗물 공급기 및 빗물분사장치 위치"
width={350}
height={250}
className="w-[100%] mx-auto"
/>
<span className="font-NotoSansKR text-xl block text-center font-semibold my-2"><빗물 공급기 및 빗물분사장치 위치></span>
</div>
</div>
</section>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,113 @@
import Image from "next/image";
export default function WrOverviewPage() {
return (
<div className="bg-[#F2F2F2]">
<div className="relative w-[950px] mx-auto pt-[50px] font-sans">
{/* 카드 1 - 물재이용 사업 추진 절차 */}
<div className="card-wrap mb-24" id="card1">
<h2 className="font-NotoSansKR relative text-center text-4xl font-bold text-gray-900 mb-6">
<span className="absolute left-1/2 top-[-15px] -translate-x-1/2 w-24 h-2 bg-blue-500 rounded-full"></span>
물재이용 사업 추진 절차
</h2>
<div className="bg-white border border-gray-300 rounded-xl p-[60px] py-[50px]">
<table className="table-fixed w-full text-center text-sm">
<caption className="sr-only">물 재이용 추진 절차 상세 표</caption>
<colgroup>
<col style={{ width: "28%" }} />
<col style={{ width: "2%" }} />
<col style={{ width: "35%" }} />
<col style={{ width: "5%" }} />
<col style={{ width: "30%" }} />
</colgroup>
<tbody>
{/* 1단계 */}
<tr><td></td><td></td><td className="font-NotoSansKR text-[17px] bg-[#EAEFF3] font-bold py-5">타당성 조사</td><td></td><td></td></tr>
<tr><td></td><td></td><td className="font-NotoSansKR text-2xl font-bold text-center">↓</td><td></td><td></td></tr>
{/* 2단계 */}
<tr><td className="font-NotoSansKR text-2xl bg-[#F4D62D] text-black font-bold py-5">매칭시스템 신청</td><td></td><td className="font-NotoSansKR text-[17px] bg-[#EAEFF3] font-bold py-5">사업계획(안) 수립</td><td></td><td></td></tr>
<tr><td></td><td></td><td className="font-NotoSansKR text-2xl font-bold text-center">↓</td><td></td><td></td></tr>
{/* 3단계 */}
<tr><td className="font-NotoSansKR text-2xl bg-[#F4D62D] text-black font-bold py-5">하수도정보시스템 신청</td><td></td><td className="font-NotoSansKR text-[17px] bg-[#EAEFF3] font-bold py-5">국고지원 신청</td><td className="font-NotoSansKR text-2xl font-bold">→</td><td className="font-NotoSansKR text-2xl text-[#fff] bg-[#00B076] font-bold py-5">기술검토(한국환경공단)</td></tr>
<tr><td></td><td></td><td className="font-NotoSansKR text-2xl font-bold text-center">↓</td><td></td><td></td></tr>
{/* 4단계 */}
<tr><td></td><td></td><td className="font-NotoSansKR text-[17px] bg-[#EAEFF3] font-bold py-5">국고지원 확정 통보</td><td></td><td></td></tr>
<tr><td></td><td></td><td className="font-NotoSansKR text-2xl font-bold text-center">↓</td><td></td><td></td></tr>
{/* 5단계 */}
<tr><td></td><td></td><td className="font-NotoSansKR text-[17px] bg-[#EAEFF3] font-bold py-5">사업계획 수립(실시설계)</td><td></td><td></td></tr>
<tr><td></td><td></td><td className="font-NotoSansKR text-2xl font-bold text-center">↓</td><td></td><td></td></tr>
{/* 6단계 */}
<tr><td className="font-NotoSansKR text-2xl bg-[#F4D62D] text-black font-bold py-5">(행정관) 기술검토 신청</td><td></td><td className="font-NotoSansKR text-[17px] bg-[#EAEFF3] font-bold py-5">재원협의(지방환경관서장)</td><td className="font-NotoSansKR text-2xl font-bold">→</td><td className="font-NotoSansKR text-2xl text-[#fff] bg-[#00B076] font-bold py-5">기술검토(한국환경공단)</td></tr>
<tr><td></td><td></td><td className="font-NotoSansKR text-2xl font-bold text-center">↓</td><td></td><td></td></tr>
{/* 7단계 */}
<tr><td className="font-NotoSansKR text-2xl bg-[#F4D62D] text-black font-bold py-5">(행정관) 기술검토 신청</td><td></td><td className="font-NotoSansKR text-[17px] bg-[#EAEFF3] font-bold py-5">설치승인(시도지사)</td><td className="font-NotoSansKR text-2xl font-bold">→</td><td className="font-NotoSansKR text-2xl text-[#fff] bg-[#00B076] font-bold py-5">기술검토(한국환경공단)</td></tr>
<tr><td></td><td></td><td className="font-NotoSansKR text-2xl font-bold text-center">↓</td><td></td><td></td></tr>
{/* 8단계 */}
<tr><td></td><td></td><td className="font-NotoSansKR text-[17px] bg-[#EAEFF3] font-bold py-5">공사시행</td><td></td><td></td></tr>
<tr><td></td><td></td><td className="font-NotoSansKR text-2xl font-bold text-center">↓</td><td></td><td></td></tr>
{/* 9단계 */}
<tr><td className="font-NotoSansKR text-2xl bg-[#F4D62D] text-black font-bold py-5">(행정관) 기술검토 신청</td><td></td><td className="font-NotoSansKR text-[17px] bg-[#EAEFF3] font-bold py-5">준공(국고정산)</td><td className="font-NotoSansKR text-2xl font-bold">→</td><td className="font-NotoSansKR text-2xl text-[#fff] bg-[#00B076] font-bold py-5">기술검토(한국환경공단)</td></tr>
</tbody>
</table>
</div>
</div>
{/* 카드 2 - 물재이용 매칭 시스템 */}
<div className="mt-36 card-wrap mb-24" id="card2">
<h2 className="font-NotoSansKR relative text-center text-4xl font-bold text-gray-900 mb-6">
<span className="absolute left-1/2 top-[-15px] -translate-x-1/2 w-24 h-2 bg-blue-500 rounded-full"></span>
물재이용 매칭 시스템이란?
</h2>
<div className="bg-white border border-gray-300 rounded-xl p-[60px] pt-[70px] pb-[70px]">
{/* 개요 */}
<section>
<h2 className="font-NotoSansKR text-2xl text-[#657D91] font-bold bg-[#EAEFF3] p-3 rounded mb-4">개요</h2>
<p className="font-NotoSansKR text-2xl font-bold text-gray-800 leading-relaxed">
기후변화 및 지자체의 개발속도 차이에 따라 물 자원의 수요-공급 불균형이 발생하여 빗물, 중수도, 하수처리 재처리수 등의 공급 여유가 있는 지자체의 재이용수를 부족한 지자체 수요처에 공급하여 불균형 해소를 통한 수자원 절약 및 물 자원 순환
</p>
</section>
{/* 구성 */}
<section className="mt-8">
<h2 className="font-NotoSansKR text-2xl text-[#657D91] font-bold bg-[#EAEFF3] p-3 rounded mb-4">구성</h2>
<ul className=" font-NotoSansKR list-[square] pl-6 text-2xl font-bold text-gray-800 leading-relaxed space-y-2">
<li>공급자: 하수처리장, 빗물이용시설, 중수도 등</li>
<li>수요자: 산업체(냉각수), 공공시설(조경, 도로청소), 농민(농업용수) 등</li>
<li>시스템: 수질, 수량, 거리 기반 매칭, 중개</li>
<li>운영주체: 한국환경공단</li>
</ul>
</section>
{/* 절차 */}
<section className="mt-8">
<h2 className="font-NotoSansKR text-2xl text-[#657D91] font-bold bg-[#EAEFF3] p-3 rounded mb-4">사업 주요 내용</h2>
<p className="font-NotoSansKR text-2xl font-bold text-gray-800 leading-relaxed">
① 공급자 등록 → ② 수요자 조건 입력 → ③ 매칭 검토 → ④ 시스템 매칭 → ⑤ 계약·공급 이행
</p>
</section>
{/* 기대효과 */}
<section className="mt-8">
<h2 className="font-NotoSansKR text-2xl text-[#657D91] font-bold bg-[#EAEFF3] p-3 rounded mb-4">기대효과</h2>
<ul className="font-NotoSansKR list-[square] pl-6 text-2xl font-bold text-gray-800 leading-relaxed space-y-2">
<li>물 자원 수요-공급 조정을 통한 효율적 분배</li>
<li>물 재이용시설 데이터 수집으로 체계적 관리</li>
<li>지자체, 사업시행자 등 재이용 정보 획득 용이</li>
</ul>
<div className="text-center my-6">
<Image
src="/img/wrOverview/img_ov_01_1.png"
alt="하수 재이용 시스템 흐름도. 저장탱크에서 재처리된 물이 손 씻기, 수도, 세정 등에 공급되며 재이용 활성화를 통해 최적의 물공급 방안이 마련되는 과정을 시각화함."
width={650}
height={500}
className="h-[500px] mx-auto mb-4 rounded"
/>
<p className="font-NotoSansKR text-2xl font-bold text-gray-800 mt-10">(하수 재이용 시스템 개요도)</p>
</div>
</section>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,549 @@
'use client';
import { CHART_FORM, MainChart, type ChartConfig } from '@common/business_components';
import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react';
interface RegionData {
name: string;
x: number;
y: number;
values: number[];
}
interface TableData {
region: string;
sewage: string;
rainwater: string;
reclaimed: string;
thermal: string;
}
const WrStatusPage: React.FC = () => {
const [selectedRegion, setSelectedRegion] = useState<string | null>(null);
const [hoveredRegion, setHoveredRegion] = useState<string | null>(null);
const [tooltipPosition, setTooltipPosition] = useState<{ x: number; y: number } | null>(null);
const svgContainerRef = useRef<HTMLDivElement>(null);
const [chartData] = useState<any[]>([
{
label : '서울',
하수 : 4,
빗물 : 816,
중수도 : 371,
온배수 : 0
},
{
label : '부산',
하수 : 13,
빗물 : 68,
중수도 : 20,
온배수 : 1
},
{
label : '대구',
하수 : 9,
빗물 : 294,
중수도 : 14,
온배수 : 2
},
{
label : '인천',
하수 : 17,
빗물 : 165,
중수도 : 35,
온배수 : 15
},
{
label : '광주',
하수 : 3,
빗물 : 74,
중수도 : 15,
온배수 : 15
},
{
label : '대전',
하수 : 2,
빗물 : 52,
중수도 : 11,
온배수 : 20
},
{
label : '울산',
하수 : 8,
빗물 : 111,
중수도 : 19,
온배수 : 30
},
{
label : '세종',
하수 : 9,
빗물 : 65,
중수도 : 0,
온배수 : 0
},
{
label : '경기',
하수 : 176,
빗물 : 1009,
중수도 : 252,
온배수 : 40
},
{
label : '강원',
하수 : 69,
빗물 : 69,
중수도 : 22,
온배수 : 0
},
{
label : '충북',
하수 : 38,
빗물 : 17,
중수도 : 91,
온배수 : 0
},
{
label : '충남',
하수 : 67,
빗물 : 54,
중수도 : 188,
온배수 : 0
},
{
label : '전북',
하수 : 55,
빗물 : 33,
중수도 : 220,
온배수 : 0
},
{
label : '전남',
하수 : 83,
빗물 : 16,
중수도 : 75,
온배수 : 0
},
{
label : '경북',
하수 : 81,
빗물 : 37,
중수도 : 70,
온배수 : 0
},
{
label : '경남',
하수 : 75,
빗물 : 27,
중수도 : 140,
온배수 : 0
},
{
label : '제주',
하수 : 8,
빗물 : 47,
중수도 : 64,
온배수 : 0
}
]);
const chartConfig = chartData.reduce((config, element) => {
config[element.label] = {
label: element.label,
color : "hsl(var(--chart-1))"
};
return config;
}, {} as Record<string, { label: string; color: string; }>) satisfies ChartConfig;
const chartOption = {
chartForm : CHART_FORM.BAR,
chartConfig : chartConfig,
chartData : chartData,
chartTitle : '',
chartClassName : "h-[300px] w-full",
graphSize : 40,
yFieldText : '시설(개소)',
showLegend : true
}
const colorLegend = [
{ label: '하수', color: '#FF6B6B', key: 'sewage' },
{ label: '빗물', color: '#4ECDC4', key: 'rainwater' },
{ label: '중수도', color: '#45B7D1', key: 'reclaimed' },
{ label: '온배수', color: '#FED16A', key: 'thermal' },
];
const regionIdMap: Record<string, string> = {
서울: '서울특별시',
부산: '부산광역시',
대구: '대구광역시',
인천: '인천광역시',
광주: '광주광역시',
대전: '대전광역시',
울산: '울산광역시',
세종: '세종특별자치시',
경기: '경기도',
강원: '강원특별자치도',
충북: '충청북도',
충남: '충청남도',
전북: '전라북도',
전남: '전라남도',
경북: '경상북도',
경남: '경상남도',
제주: '제주특별자치도'
};
const tableData: TableData[] = [
{ region: '전국', sewage: '717(1,137,538)', rainwater: '3544(13,945)', reclaimed: '1018(222,476)', thermal: '123(492)' },
{ region: '서울특별시', sewage: '4(114,908)', rainwater: '816(430)', reclaimed: '371(341)', thermal: '0' },
{ region: '부산광역시', sewage: '13(146,197)', rainwater: '68(335)', reclaimed: '20(512)', thermal: '1(18)' },
{ region: '대구광역시', sewage: '9(102,380)', rainwater: '294(133)', reclaimed: '14(44)', thermal: '2(12)' },
{ region: '인천광역시', sewage: '17(57,691)', rainwater: '165(642)', reclaimed: '35(7,859)', thermal: '15(32)' },
{ region: '광주광역시', sewage: '3(28,738)', rainwater: '74(23)', reclaimed: '15(450)', thermal: '15(6)' },
{ region: '대전광역시', sewage: '2(11,966)', rainwater: '52(2)', reclaimed: '11(492)', thermal: '20(100)' },
{ region: '울산광역시', sewage: '8(36,076)', rainwater: '111(39)', reclaimed: '19(28,904)', thermal: '30(130)' },
{ region: '세종특별자치시', sewage: '9(2,555)', rainwater: '65(16)', reclaimed: '0(0)', thermal: '0' },
{ region: '경기도', sewage: '176(313,298)', rainwater: '1009(4,735)', reclaimed: '252(77,096)', thermal: '40(143)' },
{ region: '강원특별자치도', sewage: '69(19,677)', rainwater: '69(4)', reclaimed: '22(447)', thermal: '0' },
{ region: '충청북도', sewage: '38(15,607)', rainwater: '17(91)', reclaimed: '91(2,211)', thermal: '0' },
{ region: '충청남도', sewage: '67(41,368)', rainwater: '54(77)', reclaimed: '188(50,434)', thermal: '0' },
{ region: '전라북도', sewage: '55(42,671)', rainwater: '33(199)', reclaimed: '220(9,453)', thermal: '0' },
{ region: '전라남도', sewage: '83(23,560)', rainwater: '16(885)', reclaimed: '75(15,412)', thermal: '0' },
{ region: '경상북도', sewage: '81(129,081)', rainwater: '37(341)', reclaimed: '70(27,818)', thermal: '0' },
{ region: '경상남도', sewage: '75(50,101)', rainwater: '27(244)', reclaimed: '140(343)', thermal: '0' },
{ region: '제주특별자치도', sewage: '8(1,666)', rainwater: '47(5,746)', reclaimed: '64(660)', thermal: '0' },
];
const regionData: RegionData[] = useMemo(() => [
{ name: '서울', x: 170, y: 140, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '인천', x: 120, y: 150, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '경기', x: 210, y: 210, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '강원', x: 330, y: 120, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '충북', x: 260, y: 280, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '충남', x: 100, y: 320, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '세종', x: 180, y: 305, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '대전', x: 195, y: 360, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '전북', x: 170, y: 450, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '전남', x: 160, y: 600, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '광주', x: 120, y: 530, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '경북', x: 400, y: 320, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '경남', x: 310, y: 530, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '대구', x: 350, y: 450, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '울산', x: 450, y: 500, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '부산', x: 420, y: 550, values: [0.3, 0.3, 0.2, 0.2] },
{ name: '제주', x: 60, y: 750, values: [0.3, 0.3, 0.2, 0.2] },
], []);
const handleMarkerClick = (regionId: string) => {
if (selectedRegion === regionId) {
setSelectedRegion(null);
} else {
setSelectedRegion(regionId);
}
};
const createDonutMarker = useCallback((svg: SVGSVGElement, x: number, y: number, name: string, values: number[]) => {
const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#FED16A'];
const radius = 26;
const innerRadius = 18;
// const regionId = regionIdMap[name];
const regionId = regionIdMap[name];
if (!regionId) {
console.warn(`Invalid region name: ${name}`);
return;
}
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
g.setAttribute('transform', `translate(${x}, ${y})`);
g.setAttribute('id', `marker-${name}`);
g.style.cursor = 'pointer';
g.setAttribute('role', 'button');
g.setAttribute('tabindex', '0');
g.setAttribute('aria-label', `${name} 지역 선택`);
const bg = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
bg.setAttribute('r', radius.toString());
bg.setAttribute('fill', 'white');
bg.setAttribute('pointer-events', 'all');
g.appendChild(bg);
let startAngle = 0;
values.forEach((value, idx) => {
const endAngle = startAngle + value * 2 * Math.PI;
const x1 = radius * Math.sin(startAngle);
const y1 = -radius * Math.cos(startAngle);
const x2 = radius * Math.sin(endAngle);
const y2 = -radius * Math.cos(endAngle);
const largeArc = endAngle - startAngle > Math.PI ? 1 : 0;
const d = `M ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2} L 0 0 Z`;
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', d);
path.setAttribute('fill', colors[idx % colors.length] as string);
g.appendChild(path);
startAngle = endAngle;
});
const center = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
center.setAttribute('r', innerRadius.toString());
center.setAttribute('fill', 'white');
g.appendChild(center);
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
text.setAttribute('x', '0');
text.setAttribute('y', '2');
text.setAttribute('text-anchor', 'middle');
text.setAttribute('font-size', '13');
text.setAttribute('font-weight', 'bold');
text.setAttribute('dominant-baseline', 'middle');
text.setAttribute('fill', 'black');
text.textContent = name || '';
g.appendChild(text);
g.addEventListener('mouseenter', (e) => {
setHoveredRegion(regionId);
setTooltipPosition({ x: e.clientX, y: e.clientY });
});
g.addEventListener('mouseleave', () => {
setHoveredRegion(null);
setTooltipPosition(null);
});
g.addEventListener('click', () => {
handleMarkerClick(regionId);
});
g.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const matrix = g.getScreenCTM();
if (!matrix) return; // 또는 console.warn('matrix is null'); return;
const point = svg.createSVGPoint();
point.x = 0;
point.y = 0;
const transformed = point.matrixTransform(matrix);
setTooltipPosition({ x: transformed.x, y: transformed.y });
setHoveredRegion(regionId);
handleMarkerClick(regionId);
}
});
svg.appendChild(g);
}, [handleMarkerClick, regionIdMap]);
useEffect(() => {
const loadSVG = async () => {
try {
const response = await fetch('/img/wrStatus/southKoreaMap.svg');
const svgText = await response.text();
if (svgContainerRef.current) {
svgContainerRef.current.innerHTML = svgText;
const svg = svgContainerRef.current.querySelector('svg') as SVGSVGElement;
if (svg) {
svg.style.width = '58%';
svg.style.height = 'auto';
svg.style.objectFit = 'contain';
// Set default fills
const paths = svg.querySelectorAll('path');
paths.forEach(path => {
if (!path.getAttribute('fill') || path.getAttribute('fill') === 'none') {
path.setAttribute('fill', '#ffffff');
}
});
// Create markers
regionData.forEach(region => {
createDonutMarker(svg, region.x, region.y, region.name, region.values);
});
}
}
} catch (error) {
console.error('SVG 로딩 실패:', error);
if (svgContainerRef.current) {
svgContainerRef.current.innerHTML = "<p className='text-red-500'>SVG 로딩 실패</p>";
}
}
};
loadSVG();
}, [createDonutMarker, regionData]);
useEffect(() => {
const svg = svgContainerRef.current?.querySelector('svg');
if (svg) {
const paths = svg.querySelectorAll('path');
paths.forEach(path => {
const pathId = path.getAttribute('id');
if (pathId) {
if (selectedRegion === pathId) {
path.setAttribute('fill', '#2563eb');
} else {
const original = '#ffffff';
path.setAttribute('fill', original);
}
}
});
}
}, [selectedRegion]);
return (
<div className="flex h-screen bg-[#F2F2F2] text-gray-800">
{/* 우측 콘텐츠 영역 */}
<main className="flex-1 p-6 overflow-auto">
{/* 상단 제목 */}
<div className="mb-6 flex justify-between items-center">
<h2 className="text-2xl font-bold">물재이용 현황</h2>
<div className="text-sm text-gray-500">관리자 ▼</div>
</div>
{/* 콘텐츠 본문 */}
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
{/* 지도 카드 */}
<div className="bg-white rounded-xl shadow border border-[#DBE2E6] overflow-hidden max-h-[900px]">
{/* 타이틀 영역 */}
<div className="border-b border-[#DBE2E6] px-4 py-3">
<h3 className="text-[12px] font-semibold">재이용시설현황(2023)</h3>
<p className="text-[12px] text-[#999] font-medium">행정구역별 재이용시설현황(개소)</p>
</div>
{/* 본문 콘텐츠 */}
<div className="p-4">
<div className="bg-[#E2EDF3] p-4 rounded shadow relative">
<div className="flex justify-center">
<div
ref={svgContainerRef}
className="w-full max-w-5xl max-h-[680px] overflow-y-auto block mx-auto"
aria-label="대한민국 지역별 재이용시설 분포도"
role="region"
tabIndex={0}
/>
</div>
{typeof window !== 'undefined' && hoveredRegion && tooltipPosition && (
<div
className="fixed bg-white text-[12px] text-gray-700 border border-gray-300 shadow-lg rounded px-3 py-2 z-50"
style={{
top: tooltipPosition.y + 10,
left: tooltipPosition.x + 10,
pointerEvents: 'none',
}}
>
<div className="font-semibold mb-1">{hoveredRegion}</div>
<div className="space-y-1">
{colorLegend.map(({ label, color, key }) => (
<div key={key} className="flex items-center space-x-2">
<span className="text-[12px] inline-block w-3 h-3 rounded-sm" style={{ backgroundColor: color }}></span>
<span className="text-[12px]">
{label}:{' '}
{tableData.find(d => d.region === hoveredRegion)?.[key as keyof TableData] ?? '-'}
</span>
</div>
))}
</div>
</div>
)}
{/* 범례 */}
<div className="absolute bottom-2 right-2 bg-white rounded-lg shadow-md p-4 text-sm space-y-2">
<div className="flex items-center space-x-2">
<span className="inline-block w-4 h-4 rounded-sm" style={{backgroundColor: '#FF6B6B'}}></span>
<span className="text-[12px] text-gray-900">하수재이용시설 (총 717개소)</span>
</div>
<div className="flex items-center space-x-2">
<span className="inline-block w-4 h-4 rounded-sm" style={{backgroundColor: '#4ECDC4'}}></span>
<span className="text-[12px] text-gray-900">빗물이용시설 (총 3,730개소)</span>
</div>
<div className="flex items-center space-x-2">
<span className="inline-block w-4 h-4 rounded-sm" style={{backgroundColor: '#45B7D1'}}></span>
<span className="text-[12px] text-gray-900">중수도시설 (총 1,074개소)</span>
</div>
<div className="flex items-center space-x-2">
<span className="inline-block w-4 h-4 rounded-sm" style={{backgroundColor: '#FED16A'}}></span>
<span className="text-[12px] text-gray-900">온배수시설 (총 201개소)</span>
</div>
</div>
</div>
</div>
</div>
{/* 우측 콘텐츠 (표 + 차트) */}
<div className="col-span-1 flex flex-col space-y-4">
{/* 재이용시설현황 표 */}
<div className="bg-white rounded-xl shadow border border-[#DBE2E6] overflow-hidden">
{/* 타이틀 + 버튼 영역 */}
<div className="flex justify-between items-center border-b border-[#DBE2E6] px-4 py-3">
<div>
<h3 className="text-[12px] font-semibold">재이용시설현황(2023)</h3>
<p className="text-[12px] text-[#999] font-medium">행정구역별 재이용시설현황(개소)</p>
</div>
</div>
<div className="p-4">
<div className="rounded border border-[#DBE2E6] shadow overflow-auto max-h-[292px] p-4">
<table className="min-w-full text-sm text-center">
<caption className="sr-only text-[12px]">행정구역별 하수, 빗물, 중수도, 온배수 재이용시설 수</caption>
<thead className="bg-gray-50 text-xs text-gray-600 uppercase border-b">
<tr>
<th className="text-[12px] font-semibold px-4 py-2">번호</th>
<th className="text-[12px] font-semibold px-4 py-2">시도</th>
<th className="text-[12px] font-semibold px-4 py-2">하수(이용량,천𝓂³/년)</th>
<th className="text-[12px] font-semibold px-4 py-2">빗물(이용량,천𝓂³/년)</th>
<th className="text-[12px] font-semibold px-4 py-2">중수도(이용량,천𝓂³/년)</th>
<th className="text-[12px] font-semibold px-4 py-2">온배수(이용량,천𝓂³/년)</th>
</tr>
</thead>
<tbody className="divide-y">
{tableData.map((row, index) => (
<tr key={index} className="hover:bg-gray-50">
<td className="text-[12px] px-4 py-2 text-center">
{index+1}
</td>
<td className="text-[12px]">{row.region}</td>
<td className="text-[12px]">{row.sewage}</td>
<td className="text-[12px]">{row.rainwater}</td>
<td className="text-[12px]">{row.reclaimed}</td>
<td className="text-[12px]">{row.thermal}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
{/* 재이용통계 차트 */}
<div className="bg-white rounded-xl shadow border border-[#DBE2E6] overflow-hidden">
<div className="border-b border-[#DBE2E6] px-4 py-3">
<h3 className="text-[12px] font-semibold">재이용통계(2023)</h3>
<p className="text-[12px] text-[#999] font-medium">행정구역별 재이용시설 현황 개소수 그래프</p>
</div>
<div className="p-4 bg-[#f9fbfd]">
<div className="h-[305px] text-gray-500">
<MainChart
chartForm={chartOption.chartForm}
chartConfig={chartOption.chartConfig}
chartData={chartOption.chartData}
chartTitle={chartOption.chartTitle}
chartClassName={chartOption.chartClassName}
graphSize={chartOption.graphSize}
yFieldText={chartOption.yFieldText}
showLegend={chartOption.showLegend}
/>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
);
};
export default WrStatusPage;
+11
View File
@@ -0,0 +1,11 @@
"use client";
import "../globals.css";
import { StoreProvider } from "../store";
export default function PopupLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return <StoreProvider>{children}</StoreProvider>;
}
@@ -0,0 +1,270 @@
"use client";
import Gis from "@common/business_components/ui/map/Gis";
import { useEffect, useRef, useState } from "react";
import { mapService } from "../service";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import { SearchBar } from "@/components/ui/map/searchBar/searchBar";
import SelectedFcltList from "./selectedFcltList";
import type { SubsInfo } from "@/app/store/map/type";
const Map = () => {
const [treeData, setTreeData] = useState([]); // 초기값을 객체 또는 null로 설정
const gisMaps = useRef<any | null>(null);
const [isOpen, setIsOpen] = useState(false); // 팝업 열기/닫기 상태
const [isSearchModalOpen, setIsSearchModalOpen] = useState(true); // SearchModal 열림 상태
const [featureData, setFeatureData] = useState(null); // 선택된 피쳐 데이터
const [selectDataPk, setSelectDataPk] = useState<Record<string, any>>({});
const [selectedFclts, setSelectedFclts] = useState<any[]>([]);
//적용 버튼 클릭 이벤트
const onApply = () => {
if (selectedFclts.length > 3) {
alert("재이용시설 후보지는 최대 3개까지만 선택할 수 있습니다.");
return;
}
const subsInfoList: SubsInfo[] = selectedFclts.map((fclt) => ({
ruseUtztnFcltNm: fclt.ruseUtztnFcltNm,
ruseUtztnFcltCd: fclt.pbnstRuseFcltId,
}));
window.opener.postMessage(
{
type: "SET_SUBS_INFO",
payload: subsInfoList, // 또는 필요한 데이터
},
"*"
);
window.close();
};
//트리 레이어 데이터 조회
const fetchData = async () => {
try {
const response = await mapService.getLyrStngs();
setTreeData(response); // API 응답 저장
} catch (error) {
console.error("데이터 불러오기 실패:", error);
}
};
//하수처리시설코드로 재이용시설 목록 조회
const reuseData = async (ruseFcltPk: any) => {
try {
const response = await mapService.getPbnstRuseFclts(ruseFcltPk);
const data = response.data || [];
setSelectedFclts((prev) => {
const newItems = data.filter(
(item: any) =>
item &&
Object.keys(item).length > 0 &&
!prev.some((f) => f.pbnstRuseFcltId === item.pbnstRuseFcltId)
);
console.log(newItems)
return [...prev, ...newItems];
});
} catch (error) {
console.error("데이터 불러오기 실패:", error);
}
};
// 컴포넌트 마운트 시 1회 실행
useEffect(() => {
fetchData();
}, []);
// 레이어 생성 후 초기화 처리
const onCheckedLayersChange = () => {
//수요처 ID
const urlParams = new URLSearchParams(window.location.search);
const dmdInfoId = urlParams.get("dmdInfoId");
//수요처 위치로 화면 이동
const maps = gisMaps.current;
const layer = GisApp.Module.layer as any;
const layerNm = "tb_wr_ruse_fclt_dmd_co";
const featureId = `${layerNm}.${dmdInfoId}`;
let foundFeature: any = null;
maps
.getLayers()
.getArray()
.forEach((layer: any) => {
if (layer.get("lyrTyNm") == layerNm) {
const source = layer.getSource?.();
if (source?.getFeatureById) {
const feature = source.getFeatureById(String(featureId).trim());
if (feature) {
foundFeature = feature;
return;
}
}
}
});
if (foundFeature) {
const geometry = foundFeature.getGeometry();
if (geometry) {
const extent = geometry.getExtent();
if (geometry.getType() === "Point") {
maps.getView().setCenter(geometry.getCoordinates());
maps.getView().setZoom(11);
} else {
maps.getView().fit(extent, {
duration: 300,
padding: [30, 30, 30, 30],
maxZoom: 16,
});
}
}
layer.highLightFeature(
[foundFeature],
{
id: GisApp.hlLayerId,
zIndex: 10,
opacity: 1,
selectable: true,
},
{ width: 30 }
);
}
const formatNumber = (val: any) => {
const num = Number(val);
return isNaN(num) ? "-" : num.toLocaleString();
};
// 재이용시설 오버레이
maps
.getLayers()
.getArray()
.forEach((layer: any) => {
if (layer.get("lyrTyNm") === "tb_wr_bdst_ruse_fclt") {
const source = layer.getSource?.();
if (source?.getFeatures) {
const features = source.getFeatures();
features.forEach((feature: any) => {
// 필요한 고유 ID 또는 속성 가져오기 (예: ftrIdn 또는 feature.getId())
const featureId =
feature.getId() || feature.get("ftrIdn") || Math.random();
// HTML 또는 React 오버레이 컴포넌트 구성
const overlayContent = `
<div class="rounded-md border border-gray-300 bg-white shadow-md text-sm w-[120px]">
<!-- 헤더 -->
<div class="bg-cyan-700 text-white px-3 py-2 text-[11px] font-semibold text-center rounded-t-md">
${feature.get("swtrmt_fclt_nm") || "-"}
</div>
<!-- 테이블 -->
<table class="w-full text-sm text-gray-800 border-t border-gray-200">
<tbody>
<tr class="border-b border-gray-200">
<th class="bg-gray-100 text-gray-600 font-semibold text-[10px] text-left">용량(m³/일)</th>
<td class="text-right text-[10px] font-bold">${formatNumber(feature.get("fclt_cpct"))}</td>
</tr>
</tbody>
</table>
</div>
`;
feature.set("html", overlayContent);
feature.set("overLayId", `overLay.${featureId}`);
});
}
}
});
};
// 검색해서 재이용시설 선택하면 해당 시설로 화면 이동 처리
useEffect(() => {
if (!selectDataPk || Object.keys(selectDataPk).length === 0) return;
const maps = gisMaps.current;
let foundFeature: any = null;
const layerNm = "tb_wr_bdst_ruse_fclt";
const featureId = `${layerNm}.${selectDataPk.bscFctCd}`;
maps
.getLayers()
.getArray()
.forEach((layer: any) => {
if (layer.get("lyrTyNm") == layerNm) {
const source = layer.getSource?.();
if (source?.getFeatureById) {
const feature = source.getFeatureById(String(featureId).trim());
if (feature) {
foundFeature = feature;
return;
}
}
}
});
if (foundFeature) {
const geometry = foundFeature.getGeometry();
if (geometry) {
const extent = geometry.getExtent();
maps.getView().fit(extent, {
duration: 300,
padding: [50, 50, 50, 50],
maxZoom: 16,
constrainResolution: true,
nearest: true,
});
}
} else {
console.warn("OpenLayers Feature를 찾을 수 없습니다:", selectDataPk);
}
}, [selectDataPk]);
// 재이용시설 클릭 시 선택 시설목록에 추가
const onFeatureSelect = (data: any) => {
// 처리시설코드
const swtrmtFcltCd = data.get("swtrmt_fclt_cd");
reuseData(swtrmtFcltCd);
};
return (
<>
<SearchBar
setHasuPk={(hasuPk: any) => {
if (hasuPk) {
setSelectDataPk(hasuPk);
}
}}
onApply={onApply}
/>
<div className="bg-container">
<Gis
treeData={treeData}
isSearch={false}
gisMaps={gisMaps}
isMiniMap={false}
searchView={undefined}
detailView={undefined}
onFeatureSelect={onFeatureSelect}
setIsOpen={setIsOpen}
setFeatureData={setFeatureData}
featureData={featureData}
setIsSearchModalOpen={setIsSearchModalOpen}
isOpen={isOpen}
isSearchModalOpen={isSearchModalOpen}
onCheckedLayersChange={onCheckedLayersChange}
imgVisble={false}
/>
</div>
<SelectedFcltList
data={selectedFclts}
onRemove={(updatedList) => setSelectedFclts(updatedList)}
/>
</>
);
};
export default Map;
@@ -0,0 +1,81 @@
import React from "react";
type FcltItem = {
pbnstRuseFcltId: string;
ruseUtztnFcltNm: string;
splyExtraCpct?: number;
ruseCpct?: number;
[key: string]: any;
};
export default function SelectedFcltList({
data = [],
onRemove,
}: {
data: FcltItem[];
onRemove: (updatedList: FcltItem[]) => void;
}) {
const handleDelete = (code: string) => {
const updatedList = data.filter((item) => item.pbnstRuseFcltId !== code);
onRemove(updatedList);
};
const TableContent = (
<>
<thead className="bg-gray-100 text-gray-700 text-[13px]">
<tr>
<th className="px-3 py-2 text-left border-b">시설명</th>
<th className="px-3 py-2 text-right border-b">
하수처리량
<br />
(㎥/일)
</th>
<th className="px-3 py-2 text-right border-b">
재이용량
<br />
(㎥/일)
</th>
<th className="px-3 py-2 border-b">삭제</th>
</tr>
</thead>
<tbody>
{data.map((item) => (
<tr key={item.pbnstRuseFcltId} className="border-b last:border-none">
<td className="px-3 py-2 text-[12px]">{item.ruseUtztnFcltNm}</td>
<td className="px-3 py-2 text-right text-[12px]">
{Number(item.splyExtraCpct || 0).toLocaleString()}
</td>
<td className="px-3 py-2 text-right text-[12px]">
{Number(item.ruseCpct || 0).toLocaleString()}
</td>
<td className="px-3 py-2 text-center">
<button
onClick={() => handleDelete(item.pbnstRuseFcltId)}
className="text-red-500 hover:text-red-700 text-sm font-bold"
aria-label="삭제"
title="삭제"
>
✕
</button>
</td>
</tr>
))}
</tbody>
</>
);
if (data.length === 0) return null;
return (
<div className="fixed right-40 top-[8rem] w-[40rem] max-h-[130rem] overflow-auto bg-white shadow-lg rounded p-4 border border-gray-300 z-50">
<h3 className="bg-cyan-700 text-white text-xl font-bold mb-2 p-2">
선택된 시설
</h3>
<div className="overflow-x-auto">
<table className="w-full text-sm text-gray-800 border border-gray-200">
{TableContent}
</table>
</div>
</div>
);
}
@@ -0,0 +1 @@
export * from './map'
@@ -0,0 +1,61 @@
/**
* 관망도 API
*/
const API_URL = `${process.env.NEXT_PUBLIC_SEWERAGE_API_URL}/v1/map`
const REUSE_API_URL = `${process.env.NEXT_PUBLIC_REUSE_API_URL}`
export type LyrStngProps = {
lyr_sn: string
lyr_nm: string
upper_lyr_sn: string
drawOrdr: string
flterSql: string
gobjTy: string
lyrTpNm: string
useYn: string
minViewSize: string
iconNm: string
lblAtrbNm: string
fontNm: string
fontIntnColor: string
fontExtnColor: string
fontExtnWdth: string
lnWdth: string
lnColor: string
polgnColor: string
initEprssYn: string
clickPsblYn: string
}
export const mapService = {
//레이어 설정 조회
getLyrStngs: async () => {
const response = await fetch(`${API_URL}/lyrStngs/DM_NETWORK`, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
},
getPbnstRuseFclts: async (ruseFcltPk:any) => {
const response = await fetch(`${REUSE_API_URL}/reuseFc/pubFc/${ruseFcltPk}`, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
}
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { BaseLayout } from "@common/components";
import type { MenuItem } from "./store/common";
import { useDmStore } from "./store";
import { usePathname, useRouter } from "next/navigation";
export default function Base({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const { getMenuAuth } = useDmStore((state) => state);
const router = useRouter();
const pathname = usePathname();
const handleClick = (id: MenuItem) => {
getMenuAuth(id.menuCd);
router.push(id.urlPath);
};
return (
<BaseLayout currentPath={pathname ?? ""} handleMenuClick={handleClick}>
{children}
</BaseLayout>
);
}
+26
View File
@@ -0,0 +1,26 @@
import React from 'react'
type CommonProps = {
value: string | number | '';
format: string;
}
export default function TransValueFormat({value='', format=''}: CommonProps) {
return (
format === 'yyyyMMdd' ?
<>{value.toString().substring(0,4)+'-'+value.toString().substring(4,6)+'-'+value.toString().substring(6,8)}</>
: format === 'number' ?
<>{String(value ?? '').includes('.')
? String(value).split('.').map((part, index) =>
index === 0 ? part.replace(/\B(?=(\d{3})+(?!\d))/g, ",") : part
).join('.')
: String(value ?? '').replace(/\B(?=(\d{3})+(?!\d))/g, ",")}</>
: format === 'float1' ?
<>{String(value ?? '').includes('.')
? String(value).split('.').map((part, index) =>
index === 0 ? part.replace(/\B(?=(\d{3})+(?!\d))/g, ",") : part
).join('.')
: String(value ?? '').replace(/\B(?=(\d{3})+(?!\d))/g, ",")}</>
: <>{value}</>
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+70
View File
@@ -0,0 +1,70 @@
@import "../../../packages/common/components/src/base.css";
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--radius: 0.5rem
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
+20
View File
@@ -0,0 +1,20 @@
"use client"
import "./globals.css";
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="ko">
<head>
<title>물재이용 대민</title>
</head>
<body>
{children}
</body>
</html>
);
}
+129
View File
@@ -0,0 +1,129 @@
import { NextRequest, NextResponse } from "next/server";
const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id";
const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
const SERVER_API_URL = process.env.NEXT_PUBLIC_DM_SERVER_API_URL || "";
const SEWERAGE_API_URL = process.env.NEXT_PUBLIC_SEWERAGE_API_URL || "";
const GEOSERVER_URL = process.env.NEXT_PUBLIC_GEOSERVER_URL || "";
const LX_GEOSERVER_URL = process.env.NEXT_PUBLIC_LX_GEOSERVER_URL || "";
const DC_URL = process.env.NEXT_PUBLIC_DC_URL || "";
export function middleware(req: NextRequest) {
console.log("Middleware 진입:", req.nextUrl.pathname);
const pathname = req.nextUrl.pathname;
const searchParams = req.nextUrl.searchParams;
if (pathname.startsWith("/server")) {
const decodedParams = Array.from(searchParams.entries())
.map(([key, value]) => `${key}=${decodeURIComponent(value)}`)
.join("&");
const newUrl = `${SERVER_API_URL}${pathname.replace("/server", "")}${
decodedParams ? `?${decodedParams}` : ""
}`;
return NextResponse.rewrite(new URL(newUrl));
}
if (pathname.startsWith("/geoserver")) {
let queryParams = searchParams.toString();
const typename = searchParams.get("TYPENAME");
let newUrl = `${GEOSERVER_URL}${pathname.replace("/geoserver", "")}${queryParams ? `?${queryParams}` : ""}`;
if (
typename == "hasudo:tb_il_swage_prcs_fclt" //하수처리시설
|| typename == "hasudo:tb_ms_swage_prcs_zone" //하수처리구역
|| typename == "hasudo:tb_ms_sbmrg_dngr_map" //침수위험지도
|| typename == "hasudo:tb_ms_flood_dngr_map" //도시침수지도
) {
newUrl = `${GEOSERVER_URL}${pathname.replace("/geoserver", "")}${queryParams ? `?${queryParams}` : ""}`;
}
else if (
typename == "hasudo:tb_ms_swage_ppl" //관로
|| typename == "hasudo:tb_ms_swage_mhl" //맨홀
|| typename == "hasudo:tb_ms_swage_pmstn" //펌프장
|| typename == "hasudo:tb_ms_stofcbr" //우수토실
) {
newUrl = `${LX_GEOSERVER_URL}${pathname.replace("/geoserver", "")}${queryParams ? `?${queryParams}` : ""}`;
}
else if (
typename == "hasudo:om_dt_untrt_swg_rain" //중점우수토실
|| typename == "hasudo:om_wqlt_station" //관측소
|| typename == "hasudo:tb_wr_bdst_ruse_fclt" //재이용시설
|| typename == "hasudo:tb_wr_rnwtr_utztn_fclt" //빗물이용시설
|| typename == "hasudo:tb_wr_rswtr_fclt" //중수도시설
|| typename == "hasudo:tb_wr_htdfl_fclt" //온배수시설
|| typename == "hasudo:tb_wr_ruse_fclt_dmd_co" //수요처
|| typename == "hasudo:tb_wr_tmpr_dmd_co" //잠재수요처
){
const url = req.nextUrl.clone();
url.searchParams.set('TYPENAME', typename.replace("hasudo:",""));
queryParams = url.searchParams.toString();
newUrl = `${SEWERAGE_API_URL}/v1/map/geometry${queryParams ? `?${queryParams}` : ""}`;
}
console.log(`[Middleware] Rewriting from '/geoserver' path to: ${newUrl}`);
return NextResponse.rewrite(new URL(newUrl));
}
if (
pathname.startsWith("/attach-service") ||
pathname.startsWith("/cms-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 = `${SERVER_API_URL}${pathname}`;
if (token) requestHeaders.set(CLAIM_NAME, token);
if (userId) requestHeaders.set(AUTH_USER_ID, userId);
console.log(
`[Middleware] Rewriting from '/attach-service' path to: ${newUrl}`,
);
return NextResponse.rewrite(new URL(newUrl), {
headers: requestHeaders,
});
}
if (pathname.startsWith("/user-service")) {
const requestHeaders = new Headers(req.headers);
const newUrl = `${SERVER_API_URL}${pathname}`;
console.log(
`[Middleware] Rewriting from '/user-service' path to: ${newUrl}`,
);
return NextResponse.rewrite(new URL(newUrl), {
headers: requestHeaders,
});
}
if (pathname.startsWith("/dc-service")) {
const requestHeaders = new Headers(req.headers);
const decodedParams = Array.from(searchParams.entries())
.map(([key, value]) => `${key}=${decodeURIComponent(value)}`)
.join("&");
const newUrl = `${DC_URL}${pathname}${decodedParams ? `?${decodedParams}` : ""}`;
console.log(
`[Middleware] Rewriting from '/dc-service' path to: ${newUrl}`,
);
return NextResponse.rewrite(new URL(newUrl), {
headers: requestHeaders,
});
}
return NextResponse.next();
}
export const config = {
matcher: [
"/server/:path*",
"/geoserver/:path*",
"/attach-service/:path*",
"/user-service/:path*",
"/cms-service/:path*",
"/dc-service/:path*",
],
};
+36
View File
@@ -0,0 +1,36 @@
"use client"
import Link from 'next/link';
import { Button } from '@common/components/ui';
export default function Home() {
return (
<>
<div className='flex'>
<Button className='ml-5 place-self-auto'>
<Link href="/pages/wrAbout">물재이용이란</Link>
</Button>
<Button className='ml-5 place-self-auto'>
<Link href="/pages/wrExample">물재이용 사례</Link>
</Button>
<Button className='ml-5 place-self-auto'>
<Link href="/pages/wrStatus">물재이용 현황</Link>
</Button>
<Button className='ml-5 place-self-auto'>
<Link href="/pages/wrOverview">물재이용 사업추진절차</Link>
</Button>
<Button className='ml-5 place-self-auto'>
<Link href="/pages/sdmMng/sdmInsert">수요처 등록</Link>
</Button>
<Button className='ml-5 place-self-auto'>
<Link href="/pages/sdmMng/sdmInfoInsert">수요처 정보 등록</Link>
</Button>
</div>
<div className='flex'>
<Button className='ml-5 place-self-auto'>
<Link href="/pages/sdmMng/sdmApplyList">수요처 신청 현황(개발중)</Link>
</Button>
</div>
</>
);
}
+419
View File
@@ -0,0 +1,419 @@
/**====================================
* 보편적으로 쓰는 store 공간
====================================**/
import { fetchType, getFetch } from '@/components/ui/api';
/**
* STORE - TYPE
*/
// selectBox
export type SelectDataType = {
text: string;
val: string;
}
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;
}
// grid header
export type TableUpperProps = {
id: string;
title: string;
upName?: string;
upSequnce?: number;
align? : string;
row? : number;
rowSpan?: number;
colSpan?: number;
}
// 시도 데이터
export type sidoDataType = {
adstDstrCd: number,
sido: string,
}
// 시군구 데이터
export type sigunDataType = {
adstDstrCd: number,
gugun: string
}
// 권역 데이터
export type regionDataType = {
rvbsnCd: string,
rvbsn: string,
}
// 단위유역 데이터
export type unitWaterShedDataType = {
rvbsnCd: string,
lgbsn: string,
}
// 중권역 데이터
export type middleRegionDataType = {
rvbsnCd: string,
mdmbsn: string,
}
// 환경청 데이터
export type envPartDataType = {
lclgvrnmntCd: string,
lclgvrnmntNm: string,
adstDliStrCd: string,
adstDstrCd: string,
}
export type envPartSelectDataType = SelectDataType & {
adstDliStrCd: string,
}
/**
* STORE - DATA
*/
// 시도 데이터
let selectSidoData: SelectDataType[] = [
{ text: "전체", val: '0' }
];
// 시군구 데이터
let selectSigunData: SelectDataType[] = [
{ text: "전체", val: '0' }
];
// 권역 데이터
const selectRegionData: SelectDataType[] = [
{ text: "전체", val: '0' }
];
// 단위유역 데이터
const selectUnitWaterShedData: SelectDataType[] = [
];
// 환경청 데이터
let selectEnvPartData: envPartSelectDataType[] = [
{ text: "전체", val: '0', adstDliStrCd: '' }
];
// 환경청 내 시도 데이터
const selectEnvPartSidoData: SelectDataType[] = [
{ text: "전체", val: '0' }
];
// 환경청 내 시군구 데이터
const selectEnvPartSigunData: SelectDataType[] = [
{ text: "전체", val: '0' }
];
// 구분 데이터
// const selectPartData:SelectDataType[] = [
// { text: "행정별", val: '0' },
// { text: "유역별", val: '01' },
// { text: "환경청별", val: '02' },
// ];
// 상향 하향 데이터
const selectUpdownData:SelectDataType[] = [
{ text: "전체", val: '0' },
{ text: "500이하", val: '499' },
{ text: "500이상", val: '500' },
];
// 운영 상태 데이터
const selectOperationData:SelectDataType[] = [
{ text: "전체", val: '0' },
{ text: "가동중", val: '1' },
{ text: "재가동", val: '2' },
{ text: "가동중지", val: '3' },
{ text: "시설폐쇄", val: '4' },
];
// 시설 구분 데이터
const selectFacilityPartData:SelectDataType[] = [
{ text: "전체", val: '0' },
{ text: "공공하수처리시설", val: 'SW' },
{ text: "오수처리장", val: 'SS' },
{ text: "개인하수처리시설", val: 'PB' },
{ text: "분뇨처리장", val: 'NS' },
{ text: "폐수처리장", val: 'LW' },
{ text: "침출수처리장", val: 'LC' },
{ text: "기타기초시설", val: 'ET' },
];
// 년도 데이터
const getYearData = () => {
const year = new Date().getFullYear();
const year1 = year - 1;
const year2 = year - 2;
const year3 = year - 3;
const year4 = year - 4;
const year5 = year - 5;
return [
{ text: year1 + "년", val: year1.toString() },
{ text: year2 + "년", val: year2.toString() },
{ text: year3 + "년", val: year3.toString() },
{ text: year4 + "년", val: year4.toString() },
{ text: year5 + "년", val: year5.toString() },
]
}
const selectSearchYear:SelectDataType[] = getYearData();
// 유량 기간 구분
const selectFlowTerm : SelectDataType[] = [
{ text: "연간", val: '0' },
{ text: "하절기", val: '01' },
{ text: "동절기", val: '02' },
{ text: "청천시", val: '03' },
{ text: "강우시", val: '04' },
];
// 유량 기간 구분
const selectFlowPanel : SelectDataType[] = [
{ text: "전체", val: '0'},
{ text: "3mm미만", val: '1'},
{ text: "3~10mm미만", val: '2'},
{ text: "10~20mm미만", val: '3'},
{ text: "20~30mm미만", val: '4'},
{ text: "30~40mm미만", val: '5'},
{ text: "40~50mm미만", val: '6'},
{ text: "50~60mm미만", val: '7'},
{ text: "60mm이상", val: '8'},
];
// 중권역 데이터
const selectMiddleRegionData: SelectDataType[] = [
{ text: "전체", val: '0' }
];
/**
* STORE - API
*/
// 시도 API 호출 함수 추가
const fetchSidoData = async (): Promise<SelectDataType[]> => {
try {
const res = await getFetch(fetchType.COMMON, '/addr/city');
const data = res.data;
selectSidoData = [
{ text: "전체", val: '0'},
...data.map((item: sidoDataType) => ({
text: item.sido || '',
val: String(item.adstDstrCd) || ''
}))
];
return selectSidoData;
} catch (error) {
console.error('시도 데이터를 가져오는데 실패했습니다:', error);
return selectSidoData;
}
};
// 시군구 API 호출 함수 추가
const fetchSigunData = async (): Promise<SelectDataType[]> => {
try {
const res = await getFetch(fetchType.COMMON, '/addr/sgg');
const data = res.data;
selectSigunData = [
{ text: "전체", val: '0' },
...data
.map((item: sigunDataType) => ({
text: item.gugun || '',
val: String(item.adstDstrCd) || ''
}))
];
return selectSigunData;
} catch (error) {
console.error('시군구 데이터를 가져오는데 실패했습니다:', error);
return selectSigunData;
}
};
// 권역 API 호출 함수 추가
const fetchRegionData = async (): Promise<SelectDataType[]> => {
try {
const res = await getFetch(fetchType.COMMON, '/addr/sarea');
const data = res.data;
return [
{ text: "전체", val: '0'},
...data.map((item: regionDataType) => ({
text: item.rvbsn || '',
val: String(item.rvbsnCd) || ''
}))
];
} catch (error) {
console.error('권역 데이터를 가져오는데 실패했습니다:', error);
return selectRegionData;
}
};
// 중권역 API 호출 함수 추가
const fetchMiddleRegionData = async (): Promise<SelectDataType[]> => {
try {
const res = await getFetch(fetchType.COMMON, '/addr/midArea');
const data = res.data;
return [
{ text: "전체", val: '0' },
...data
.map((item: middleRegionDataType) => ({
text: item.mdmbsn || '',
val: String(item.rvbsnCd) || ''
}))
];
} catch (error) {
console.error('중권역 데이터를 가져오는데 실패했습니다:', error);
return selectMiddleRegionData;
}
};
// 단위유역 API 호출 함수 추가
const fetchUnitWaterShedData = async (): Promise<SelectDataType[]> => {
try {
const res = await getFetch(fetchType.COMMON, '/addr/largeArea');
const data = res.data;
return [
{ text: "전체", val: '0' },
...data
.map((item: unitWaterShedDataType) => ({
text: item.lgbsn || '',
val: String(item.rvbsnCd) || ''
}))
];
} catch (error) {
console.error('단위유역 데이터를 가져오는데 실패했습니다:', error);
return selectUnitWaterShedData;
}
};
// 환경청 데이터
const fetchEnvPartData = async (): Promise<envPartSelectDataType[]> => {
try {
const res = await getFetch(fetchType.COMMON, '/addr/govArea');
const data = res.data;
selectEnvPartData = [
{ text: "전체", val: '0', adstDliStrCd: '' },
...data
.map((item: envPartDataType) => ({
text: item.lclgvrnmntNm || '',
val: String(item.lclgvrnmntCd) || '',
adstDliStrCd: item.adstDliStrCd || ''
}))
];
return selectEnvPartData;
} catch (error) {
console.error('환경청 데이터를 가져오는데 실패했습니다:', error);
return selectEnvPartData;
}
};
/**====================================
* Store - expaort
====================================*/
export type CommonAction = {
initializeCommonData: () => Promise<void>;
getMenuAuth: (menuCd:number) => Promise<void>;
}
export type CommonType = {
Common: {
isInit: boolean;
selectSidoData: SelectDataType[];
selectSigunData: SelectDataType[];
selectUpdownData: SelectDataType[];
selectOperationData: SelectDataType[];
selectFacilityPartData: SelectDataType[];
selectSearchYear: SelectDataType[];
selectUnitWaterShedData: SelectDataType[];
selectRegionData: SelectDataType[];
selectMiddleRegionData: SelectDataType[];
selectEnvPartSidoData: SelectDataType[];
selectEnvPartSigunData: SelectDataType[];
selectFlowTerm: SelectDataType[];
selectFlowPanel: SelectDataType[];
selectEnvPartData: envPartSelectDataType[];
btnClass: string;
}
}
export const CommonState:CommonType = {
Common: {
isInit: false,
selectSidoData: selectSidoData,
selectSigunData: selectSigunData,
selectUpdownData: selectUpdownData,
selectOperationData: selectOperationData,
selectFacilityPartData: selectFacilityPartData,
selectSearchYear: selectSearchYear,
selectRegionData: selectRegionData,
selectUnitWaterShedData: selectUnitWaterShedData,
selectMiddleRegionData: selectMiddleRegionData,
selectEnvPartSidoData : selectEnvPartSidoData,
selectEnvPartSigunData : selectEnvPartSigunData,
selectFlowTerm: selectFlowTerm,
selectFlowPanel: selectFlowPanel,
selectEnvPartData : selectEnvPartData,
btnClass: "p-4 m-2 text-white rounded-sm text-lg font-medium"
}
}
export const CommonReducer:(set:any)=>CommonAction=(set: any) => {
return {
getMenuAuth : async (menuCd : number) =>{
const response = await getFetch(
"local",
`/cms-service/api/v1/menu-authrts/pageinfo/${menuCd}`
);
set((state: CommonType) => {
return {
...state,
auth: {
mngAuthrtYn: response[0].mngAuthrtYn,
inqAuthrtYn: response[0].mngAuthrtYn,
regAuthrtYn: response[0].mngAuthrtYn,
mdfcnAuthrtYn: response[0].mngAuthrtYn,
delAuthrtYn: response[0].mngAuthrtYn,
},
};
});
},
// 시도 변경 이벤트
initializeCommonData: async () => {
const promiseSet = await Promise.all([fetchSidoData(), fetchSigunData(), fetchRegionData(), fetchUnitWaterShedData(), fetchMiddleRegionData(), fetchEnvPartData()]);
const [sidoData, sggData, regionData, unitData, midData, envData] = promiseSet;
set((state: CommonType) => ({
Common: {
...state.Common,
selectSidoData: sidoData,
selectSigunData: sggData,
selectRegionData: regionData,
selectMiddleRegionData: midData,
selectUnitWaterShedData: unitData,
selectEnvPartData: envData
}
})
)},
}
}
export type CommonStore = CommonType & CommonAction;
+42
View File
@@ -0,0 +1,42 @@
'use client'
import { type ReactNode, createContext, useContext, useRef } from 'react'
import { useStore } from 'zustand'
import { type DmStore, StoreZus } from './store'
export type StoreApi = ReturnType<typeof StoreZus>
export const StoreContext = createContext<StoreApi | undefined>(
undefined,
)
export interface StoreProviderProps {
children: ReactNode
}
export const StoreProvider = ({
children,
}: StoreProviderProps) => {
const storeRef = useRef<StoreApi | null>(null)
if (!storeRef.current) {
// Create store instance if not initialized
storeRef.current = StoreZus() as StoreApi
}
return (
<StoreContext.Provider value={storeRef.current}>
{children}
</StoreContext.Provider>
)
}
export const useDmStore = <T,>(
selector: (store: DmStore) => T,
): T => {
const storeContext = useContext(StoreContext)
if (!storeContext) {
throw new Error(`useStore must be used within StoreProvider`)
}
return useStore(storeContext, selector)
}
+107
View File
@@ -0,0 +1,107 @@
import { getFetch } from "@/components/ui/api";
import { DEV_URL_FO } from "@/components/ui/config";
import type { FacilityInfo, SubsInfo } from "./type";
const checkWrRegList = async () => {
const wrRegList = await getFetch("wr", `/reuseFc/pubFc`);
return wrRegList;
};
const getRuseFclt = async (
page: string,
sggOrCity: string,
searchTxt?: string
) => {
const checkWrRegListRes = await checkWrRegList();
let url = `/prcsFclt/ruseInfo/${page ? page : 1}/5/A/${sggOrCity ? sggOrCity : "0"}/0/1/SW`;
const ruseList = checkWrRegListRes.data.filter((item: string) => item !== "");
if (searchTxt) {
url = url + `/${searchTxt}`;
}
const response = await fetch(DEV_URL_FO + url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(ruseList),
});
const hasuList = await response.json().then((res: any) => {
const data = res.data
.map((item: any) => {
return {
...item,
fctCpc: Number(item.fctCpc) || 0,
searchNm: item.fctNm,
searchPk: item.bscFctNm,
};
})
.filter((hasu) => ruseList.find((ruse) => hasu.bscFctCd === ruse));
return { data: data, count: res.count };
});
return hasuList;
};
export type DmMapActions = {
findRuseFclt(sggOrCity: string, searchTxt: string): void;
setSubsInfo(subsInfo: SubsInfo[]): void;
};
export type DmMapType = {
map: {
ruseFcltList: FacilityInfo[];
subsInfo: SubsInfo[];
};
};
export const dmMapState: DmMapType = {
map: {
ruseFcltList: [],
// subsInfo: [
// {
// ruseUtztnFcltNm: "수원제1,2 재이용1", // 재이용시설명
// ruseUtztnFcltCd: "41110PB001R1",
// },
// {
// ruseUtztnFcltNm: "낙양 재이용1", // 재이용시설명
// ruseUtztnFcltCd: "41150SW001R1",
// },
// {
// ruseUtztnFcltNm: "서남 재이용1", // 재이용시설명
// ruseUtztnFcltCd: "11500PB001R1",
// },
// ],
subsInfo:[]
},
};
export type DmMapStore = DmMapType & DmMapActions;
export const DmMapReducer = (set: any): DmMapActions => {
return {
findRuseFclt: async (sggOrCity: string, searchTxt: string) => {
const ruseFcltList = await getRuseFclt("1", sggOrCity, searchTxt);
set((state: DmMapType) => ({
...state,
map: {
...state.map,
ruseFcltList: ruseFcltList.data,
},
}));
},
setSubsInfo: async (subsInfo: SubsInfo[]) => {
set((state: DmMapType) => ({
...state,
map: {
...state.map,
subsInfo: subsInfo,
},
}));
},
};
};
+39
View File
@@ -0,0 +1,39 @@
export type FacilityInfo= {
bscFctCd: string; // 기본시설 코드
bscFctNm: string; // 기본시설명
rvbsnCd: string; // 유역 코드
rvbsn: string; // 유역명
lgbsn: string; // 대권역명
mdmbsn: string; // 중권역명
rgnCd: string; // 관할청 코드
rgnNm: string; // 관할청 명칭
adstDstrCd: string; // 행정구역 코드
sido: string; // 시도
gugun: string; // 구군
addr: string; // 주소
fctCpc: string; // 시설용량
efflWqltStndrdCd: string; // 방류수 수질기준 코드
efflWqltStndrdNm: string; // 방류수 수질기준명
trtFrfNm: string; // 처리공법명
trtDstr: string; // 처리구역
rnDt: string; // 사용개시일 (예: YYYYMMDD)
cmplDt: string; // 준공일자 (예: YYYYMMDD)
trstOprEtpNm: string; // 위탁운영기관명
bscFctClsNm: string; // 기본시설 구분명
sitArea: string; // 부지면적
trtDstrArea: string; // 처리구역 면적
swrTrtPopl: string; // 하수처리 인구
mnstr: string; // 총괄 유역
efflWtrrgn: string; // 방류수역
rvNm: string; // 하천명
bscFctClsCd: string; // 기본시설 구분 코드
rvbsnNm: string; // 유역명
tel: string; // 전화번호
rswtrYn: string; // 재이용수 여부 ("Y" 또는 "N")
ruse: boolean; // 재이용 여부 (boolean)
}
export type SubsInfo={
ruseUtztnFcltNm: string; // 재이용시설명
ruseUtztnFcltCd: string; // 재이용시설 코드
}
+121
View File
@@ -0,0 +1,121 @@
# Zustand store
## Zustand 등록방법
**-- index.tsx --**
### context 등록
기본적으로 createContext를 통해 react에 context를 생성해서 react에서 사용할 수 있도록 해야함.
``` js
//StoreZus는 내부적으로 사용하기 위한 store파일에서 따로 생성함.
export type StoreApi = ReturnType<typeof StoreZus>
//react에서 사용하기 위한 context 생성.
export const StoreContext = createContext<StoreApi | undefined>(
undefined,
)
```
### component로 작성
등록된 context를 component화 시키기 위한 작업
```js
//외부에서 호출시 <StoreProvider> 형태로 호출.
//app이나 home에서 전체적인 사용이 되도록 작성해야함.
export const StoreProvider = ({
children,
}: StoreProviderProps) => {
const storeRef = useRef<StoreApi>(null)
if (!storeRef.current) {
//StoreZus는 store.tsx를 봐야함.
storeRef.current = StoreZus()
}
return (
<StoreContext.Provider value={storeRef.current}>
{children}
</StoreContext.Provider>
)
}
```
### store 등록
react에서 사용하는 store에 현재 context 등록
```js
export const useWrStore = <T,>(
selector: (store: WrStore) => T,
): T => {
const storeContext = useContext(StoreContext)
if (!storeContext) {
throw new Error(`useStore must be used within StoreProvider`)
}
return useStore(storeContext, selector)
}
```
## Zustand 사용방법
**-- store.tsx, 폴더별 index.tsx --**
### 기본 store 생성
index.tsx의 context에 사용되는 StoreZus를 추가하여 사용됨.
외부에서 생성된 state, actions를 사용 가능한 구조
```js
import { createStore } from 'zustand/vanilla';
import { tableActionsExport, tableStateExport, type tableState, type tableType } from './publicReuseFaciltyInsert/index';//외부 파일 위치.
export type WrStore = tableType;//typescript이므로 사용되는 스크립트를 type1 & type2 형식으로 넣어야함.
//index.tsx에서 사용하는 store 생성.
export const StoreZus = (initState: tableState = tableStateExport) => {
return createStore<WrStore>()((set) => {
return {
//아래에서 사용하는 state & actions은 외부 폴더에서 가져다가 사용.
...initState,//외부에 있는 기본 초기값 state
...tableActionsExport(set)//외부에 있는 actions
}
})
}
```
### 외부 스토어 작성 방법
외부 스토어는 export를 어떻게 할건지만 정하면됨.
사용하는 store.tsx에서 import를 받기만 하면 사용가능.
#### 타입을 외부로 빼는 방법
```js
export type tableState = {
isInit: boolean;
upHeadList: TableUpperProps[];
publicReuseFacilityUper: PublicReuseFacility[];
}
type tableActions = {
decrementList: () => void
}
export type tableType = tableState & tableActions;
```
#### 초기값 state를 외부로 빼는 방법
```js
export const tableStateExport:tableState= {
isInit:false,
upHeadList: upHeadList,
publicReuseFacilityUper: publicReuseFacilityUper,
}
```
#### actions를 외부로 빼는 방법
```js
//tableActionsExport를 import 받아서 실행하여 return 된 값을 사용
export const tableActionsExport:(set:any)=>tableActions=
//set을 통해 react store에 등록
(set: any) => {
return {
//decrementList Object 형태로 전달
decrementList:
//set 함수를 실행해서 react store에 등록
()=>set(
// store에 등록되는 함수 구조를 가지고 있음.
(state:tableState) => {
//actions 실질적인 로직 작성하는 곳.
return (
{
upHeadList: state.upHeadList,
publicReuseFacilityUper:
state.publicReuseFacilityUper.slice(0, state.publicReuseFacilityUper.length - 1)
})}
)
}
}
```
+772
View File
@@ -0,0 +1,772 @@
import { fetchType, getFetch, postFetch } from "@/components/ui/api";
import type { SelectDataType } from "@common/business_components/ui";
import type { TableUpperProps } from "../common";
import toast from "react-hot-toast";
import type { DemandCompType } from "@common/business_components/ui/sdmInsertForm/form";
import { z } from "zod";
/**====================================
* Store - 타입
=====================================*/
export const sdmInfoFormSchema = z.object({
sdmNm: z.string().min(1, "수요처를 선택해주세요"),
demandYmd: z.date().optional(),
demandQnty: z
.string()
.min(1, "요구수량을 입력해주세요")
.regex(/^[0-9]+$/, "요구수량은 숫자만 입력 가능합니다"),
usePurpose: z.string().optional(),
money: z.string().regex(/^[0-9]+$/, "희망단가는 숫자만 입력 가능합니다"),
/**요구수질 */
reqWaterQlty: z.string().optional(),
grssClfg: z.number().nullish(),
tn: z.number().nullish(),
combResdch: z.number().nullish(),
tph: z.number().nullish(),
trbt: z.number().nullish(),
ph: z.string().nullish(),
bod: z.number().nullish(),
/**컬럼확인필요 */
clIonQnt: z.number().nullish(),
smll: z.string(),
cdtnv: z.number().nullish(),
crmty: z.number().nullish(),
rmrk: z.string().optional(),
/* telno: z.string()
.min(8, "전화번호는 8자 이상 입력해주세요")
.max(12, "전화번호는 12자 이내로 입력해주세요")
.regex(/^[0-9-]+$/, "전화번호는 숫자와 하이픈(-)만 입력 가능합니다"), */
candidateTitle: z
.array(
z.object({
ruseUtztnFcltNm: z.string(),
ruseUtztnFcltCd: z.string(),
})
)
.optional(),
}).superRefine((data, ctx) => {
const disabledFields = disableRules[data.usePurpose ?? ""] || [];
// 모든 수질 관련 필드
// 총대장균군수는 null 값을 불검출로 표시하기때문에 해당 검사에서 제외함.
const allFields: (keyof typeof data)[] = [
"tn", "combResdch", "tph",
"trbt", "ph", "bod", "clIonQnt",
"smll", "cdtnv", "crmty",
];
const allFieldsName: Record<string, string> = {
tn: "총질소(T-N)(mg/L)",
combResdch: "결합잔류염소(mg/L)",
tph: "총인(T-P)(mg/L)",
trbt: "탁도(NTU)",
ph: "수소이온농도(pH)",
bod: "생물학적 산소요구량(BOD)(mg/L)",
clIonQnt: "염화물(mgCL/L)",
smll: "냄새",
cdtnv: "전기전도도(㎲/cm)",
crmty: "색도(도)",
};
allFields.forEach((field) => {
const isDisabled = disabledFields.includes(field);
if (!isDisabled) {
// disabled가 아닌 필드는 반드시 값이 있어야 함
const value = data[field];
if (typeof value === "string") {
if (value.trim().length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [field],
message: `${allFieldsName[field]} 을 입력해주세요.`,
});
}
} else if (typeof value === "number") {
if (value <= 0 || Number.isNaN(value)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [field],
message: `${allFieldsName[field]} 을 입력해주세요.`,
});
}
} else if (value == null) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: [field],
message: `${allFieldsName[field]} 을 입력해주세요.`,
});
}
}
});
});
export type SdmInfoFormData = z.infer<typeof sdmInfoFormSchema>;
// 수요처 정보
export type DemandCompBizType = {
index : number | '';
dmdInfoId : string | '';
dmdInfoFk : string | '';
bzmnNo : string | '';
dmdCoNm : string | '';
daddr : string | '';
telno : string | '';
rgtr : string | '';
useUsgCd : string | '';
ctpvCd : string | '';
sggCd : string | '';
dmdAplyPswd : string | '';
wntUntprc : number | '';
dmndTmng : Date | '';
dmndQty : number | '';
grssClfg : number | null //총대장균군수
tn : number | null //총질소
combResdch : number | null //결합잔류염소
tph : number | null //총인
trbt : number | null //탁도
ph : string | ''; //수소이온농
blgcOxygDmndQnt : number | null //생물학적산소요구량
clIonQnt : number | null //염화물
smll : string | ''; //냄새
cdtnv : number | null; //전기전도도
crmty : number | null //색도
rmrk : string | '';
reqDt : string | '';
dmdInfoSn : number | '';
status : string | '';
splyCndtList:SupplyCandidate[]|[],
ruseUtztnFcltNm?:string|''
}
// 공급 후보자 타입 정의
export type SupplyCandidate = {
ruseUtztnFcltNm: string;
ruseUtztnFcltCd: string;
};
export type DemandMatchings = {
splyFcltFk : string | '';
splyFcltFlg : string | '';
pbnstRuseFcltBizFk : number | '';
splyBgngDay : string | '';
splyEndDay : string | '';
splyCpct : number | '';
splyFmYn : string | '';
useUsgCd : string | '';
dmdInfoSn : number | '';
splyCndtList : string | '';
mtchnCmptnYmd : string | '';
rvwCmptnYmd : string | '';
regDt : string | '';
rgtr : string | '';
mdfcnDt : string | '';
mdfr : string | '';
}
// 유사사례
export type SimilarCaseType = {
ruseBizNm : string | '';
bizBgngDate : string | '';
bizEndDate : string | '';
splyFcltKnd : string | '';
splyFcltNm : string | '';
splyFcltCpct : string | '';
dmdFcltNm : string | '';
dmdFcltAddr : string | '';
ruseFcltCpct : string | '';
ruseFcltPplLen : string | '';
ruseFcltUsg : string | '';
ruseFcltSplyCpct : string | '';
ruseFcltPrcsMsrc : string | '';
grnteWqUsg : string | '';
grnteWqGrssClfg : string | '';
grnteWqCombResdch : string | '';
grnteWqTrbt : string | '';
grnteWqBod : string | '';
grnteWqSmll : string | '';
grnteWqCrmty : string | '';
grnteWqTn : string | '';
grnteWqTp : string | '';
grnteWqPh : string | '';
grnteWqClIonQnt : string | '';
grnteWqCndctStat : string | '';
grnteWqRmrk : string | '';
reqBizReuseFcltCst : string | '';
reqBizPplCst : string | '';
reqBizArchCst : string | '';
reqBizMchnCst : string | '';
reqBizMtrngCst : string | '';
reqBizGisCst : string | '';
reqBizCctvCst : string | '';
reqBizCmcnCst : string | '';
reqBizWstCst : string | '';
reqBizDesignCst : string | '';
reqBizSpvsCst : string | '';
reqBizSbsdCst : string | '';
reqBizEtc : string | '';
reqBizGrssCst : string | '';
prcmPlanNlep : string | '';
prcmPlanLcep : string | '';
prcmPlanBrdnCst : string | '';
prcmPlanEcqrRt : string | '';
prcmPlanGrssCst : string | '';
clclnNlep : string | '';
clclnLcep : string | '';
clclnBrdnCst : string | '';
clclnEcqrRt : string | '';
clclnGrssCst : string | '';
}
// 사업비 상세정보
export type MoneyDetailType = {
reqBizGrssCst : string | '';
reqBizReuseFcltCst : string | '';
reqBizPplCst : string | '';
reqBizArchCst : string | '';
reqBizMchnCst : string | '';
reqBizMtrngCst : string | '';
reqBizGisCst : string | '';
reqBizCctvCst : string | '';
reqBizCmcnCst : string | '';
reqBizWstCst : string | '';
reqBizDesignCst : string | '';
reqBizSpvsCst : string | '';
reqBizSbsdCst : string | '';
reqBizEtc : string | '';
/** 재원조달계획(백만원원) */
prcmPlanGrssCst : string | '';
prcmPlanNlep : string | '';
prcmPlanLcep : string | '';
prcmPlanBrdnCst : string | '';
prcmPlanEcqrRt : string | '';
/** 사업비 정산(백만원) */
clclnGrssCst : string | '';
clclnNlep : string | '';
clclnLcep : string | '';
clclnBrdnCst : string | '';
clclnEcqrRt : string | '';
}
// 규칙 타입 선언
export type DisableRulesType = Record<string, string[]>;
/**====================================
* Store - 데이터
====================================*/
// 수요처 정보
const demandCompList : DemandCompType[] = [
]
const disableRules: DisableRulesType = {
"청소": ["tn", 'tph',"clIonQnt" ,'cdtnv'],
"화장실용수": ["tn", 'tph',"clIonQnt" ,'cdtnv'],
"세척": ["combResdch", "crmty",'tph','tn',"clIonQnt",'cdtnv'],
"살수": ["combResdch", "crmty",'tph','tn',"clIonQnt",'cdtnv'],
"조경": ["combResdch", "crmty",'tph','cdtnv'],
"농업_간접용수": ["combResdch", "crmty",'tn','tph','clIonQnt'],
"농업_직접용수": ["combResdch", "crmty",'tn','tph','clIonQnt'],
"하천": ["combResdch", "trbt", "crmty" ,'clIonQnt','cdtnv'],
'공업':[]
};
const defaultRules: Record<string, object> = {
"청소": {
grssClfg: null,
combResdch:0.2,
trbt: 2,
bod: 5,
crmty: 20,
ph: "5.8~8.5",
smll:'불쾌하지않을것',
tn:null,
tph: null,
clIonQnt: null,
cdtnv: null,
},
"화장실용수": {
grssClfg: null,
combResdch:0.2,
trbt: 2,
bod: 5,
crmty: 20,
smll:'불쾌하지않을것',
ph: "5.8~8.5",
tn:null,
tph: null,
clIonQnt: null,
cdtnv: null,
},
"세척": {
grssClfg: null,
trbt: 2,
bod: 5,
ph: "5.8~8.5",
combResdch:null,
smll:'불쾌하지않을것',
crmty:null,
tn:null,
tph: null,
clIonQnt: null,
cdtnv: null,
},
"살수": {
grssClfg: null,
trbt: 2,
bod: 5,
ph: "5.8~8.5",
smll:'불쾌하지않을것',
combResdch:null,
crmty:null,
tn:null,
tph: null,
clIonQnt: null,
cdtnv: null,
},
"조경": {
grssClfg: null,
trbt: 2,
bod: 5,
ph: "5.8~8.5",
smll:'불쾌하지않을것',
clIonQnt:250,
combResdch:null,
crmty:null,
tn:null,
tph: null,
cdtnv: null,
},
"농업_간접용수": {
grssClfg: null,
trbt: 5,
bod: 8,
ph: "5.8~8.5",
cdtnv:2000,
combResdch:null,
crmty:null,
tn:null,
tph: null,
clIonQnt: null,
},
"농업_직접용수": {
grssClfg: null,
trbt: 2,
bod: 8,
smll:'불쾌하지않을것',
ph: "5.8~8.5",
cdtnv:700,
combResdch:null,
crmty:null,
tn:null,
tph: null,
clIonQnt: null,
},
"하천": {
grssClfg: 1000,
bod: 5,
tn: 10,
tph: 0.5,
smll:'불쾌하지않을것',
ph: "5.8~8.5",
combResdch:null,
trbt:null,
crmty:null,
clIonQnt: null,
cdtnv:null
},
'공업':{
grssClfg: null,
bod: null,
tn: null,
tph: null,
ph: "~",
combResdch:null,
smll:'불쾌하지않을것',
trbt:null,
crmty:null,
clIonQnt: null,
cdtnv:null
}
};
// 사용용도
const useTypesData : SelectDataType[] = [
{ text: '청소', val: "청소" },
{ text: "화장실용수", val: "화장실용수" },
{ text: "세척", val: "세척" },
{ text: "살수", val: "살수" },
{ text: "조경", val: "조경" },
{ text: "농업_간접용수", val: "농업_간접용수" },
{ text: "농업_직접용수", val: "농업_직접용수" },
{ text: "하천", val: "하천" },
{ text: "공업", val: "공업" },
];
// 유사사례 모달 헤더
export const similarCaseModalHeader: TableUpperProps[] = [
{ id: "ruseBizNm", title: "사업명", row: 0, rowSpan: 2 },
{ id: "bizBgngDate", title: "사업시작일", row: 0, rowSpan: 2 },
{ id: "bizEndDate", title: "사업종료일", row: 0, rowSpan: 2 },
{ id: "A", title: "공급처", row: 0, colSpan: 3 },
{ id: "splyFcltKnd", title: "시설종류", row: 1 },
{ id: "splyFcltNm", title: "공급처명", row: 1 },
{ id: "splyFcltCpct", title: "시설용량\n(㎥/일)", row: 1 },
{ id: "B", title: "수요처", row: 0, colSpan: 2 },
{ id: "dmdFcltNm", title: "수요처명", row: 1 },
{ id: "dmdFcltAddr", title: "주소", row: 1 },
{ id: "C", title: "재이용시설", row: 0, colSpan: 5 },
{ id: "ruseFcltCpct", title: "시설용량\n(㎥/일)", row: 1 },
{ id: "ruseFcltPplLen", title: "관로\n(km)", row: 1 },
{ id: "ruseFcltUsg", title: "용도", row: 1 },
{ id: "ruseFcltSplyCpct", title: "공급량\n(㎥/일)", row: 1 },
{ id: "ruseFcltPrcsMsrc", title: "처리공법", row: 1 },
{ id: "D", title: "보증수질", row: 0, colSpan: 13 },
{ id: "grnteWqUsg", title: "용도별", row: 1 },
{ id: "grnteWqGrssClfg", title: "총대장균군수\n(개/100ml)", row: 1 },
{ id: "grnteWqCombResdch", title: "결합잔류염소\n(mg/L)", row: 1 },
{ id: "grnteWqTrbt", title: "탁도\n(NTU)", row: 1 },
{ id: "grnteWqBod", title: "생물학적산소요구량\n(BOD)\n(mg/L)", row: 1 },
{ id: "grnteWqSmll", title: "냄새\n(개/100ml)", row: 1 },
{ id: "grnteWqCrmty", title: "색도\n(도)", row: 1 },
{ id: "grnteWqTn", title: "총질소\n(T-N)\n(mg/L)", row: 1 },
{ id: "grnteWqTp", title: "총인\n(T-P)\n(mg/L)", row: 1 },
{ id: "grnteWqPh", title: "수소이온농도\n(pH)", row: 1 },
{ id: "grnteWqClIonQnt", title: "염화물\n(mgCL/L)", row: 1 },
{ id: "grnteWqCndctStat", title: "전기전도도\n(μS/cm)", row: 1 },
{ id: "grnteWqRmrk", title: "비고", row: 1 },
{ id: "reqBizGrssCst", title: "소요사업비\n(백만원)", row: 0, rowSpan: 2 },
]
// 유사사례 데이터
const similarCaseList : SimilarCaseType[] = [
]
// 사업비 상세정보 모달 헤더
export const moneyDetailModalHeader: TableUpperProps[] = [
{ id: "A", title: "소요사업비(백만원)", row: 0, colSpan: 14 },
{ id: "reqBizGrssCst", title: "총", row: 1, rowSpan: 2 },
{ id: "B", title: "토목", row: 1, colSpan: 2 },
{ id: "reqBizReuseFcltCst", title: "재이용시설", row: 2 },
{ id: "reqBizPplCst", title: "관로", row: 2 },
{ id: "reqBizArchCst", title: "건축", row: 1, rowSpan: 2 },
{ id: "reqBizMchnCst", title: "기계", row: 1, rowSpan: 2 },
{ id: "reqBizMtrngCst", title: "전기,계측", row: 1, rowSpan: 2 },
{ id: "reqBizGisCst", title: "GIS/DB", row: 1, rowSpan: 2 },
{ id: "reqBizCctvCst", title: "CCTV", row: 1, rowSpan: 2 },
{ id: "reqBizCmcnCst", title: "시공 및 준공", row: 1, rowSpan: 2 },
{ id: "reqBizWstCst", title: "폐기물", row: 1, rowSpan: 2 },
{ id: "reqBizDesignCst", title: "설계비", row: 1, rowSpan: 2 },
{ id: "reqBizSpvsCst", title: "감리비", row: 1, rowSpan: 2 },
{ id: "reqBizSbsdCst", title: "시설 부대비", row: 1, rowSpan: 2 },
{ id: "reqBizEtc", title: "기타", row: 1, rowSpan: 2 },
{ id: "C", title: "재원조달계획(백만원)", row: 0, colSpan: 5 },
{ id: "prcmPlanGrssCst", title: "총", row: 1, rowSpan: 2 },
{ id: "prcmPlanNlep", title: "국비", row: 1, rowSpan: 2 },
{ id: "prcmPlanLcep", title: "지방비", row: 1, rowSpan: 2 },
{ id: "prcmPlanBrdnCst", title: "원인자부담", row: 1, rowSpan: 2 },
{ id: "prcmPlanEcqrRt", title: "국고비율", row: 1, rowSpan: 2 },
{ id: "D", title: "사업비 정산(백만원)", row: 0, colSpan: 5 },
{ id: "clclnGrssCst", title: "총", row: 1, rowSpan: 2 },
{ id: "clclnNlep", title: "국비", row: 1, rowSpan: 2 },
{ id: "clclnLcep", title: "지방비", row: 1, rowSpan: 2 },
{ id: "clclnBrdnCst", title: "원인자부담", row: 1, rowSpan: 2 },
{ id: "clclnEcqrRt", title: "국고비율", row: 1, rowSpan: 2 },
]
// 사업비 상세정보
const moneyDetailList : MoneyDetailType[] = [
]
// 수요정보 신청현황 헤더
export const upHeadSdmInfoStatus: TableUpperProps[] = [
{ id: "index", title: "No."},
{ id: "ctpvCd", title: "시도", row: 0, rowSpan: 2 },
{ id: "sggCd", title: "시군구", row: 0, rowSpan: 2 },
{ id: "dmdCoNm", title: "회사명", row: 0, rowSpan: 2 },
{ id: "daddr", title: "공장주소", row: 0, rowSpan: 2 },
{ id: "telno", title: "전화번호", row: 0, rowSpan: 2 },
{ id: "dmndTmng", title: "신청일자", row: 0, rowSpan: 2 },
{ id: "status", title: "진행상태", row: 0, rowSpan: 2 },
]
// 수요정보 신청현황 데이터
/**====================================
* Store - Api
=====================================*/
// 수요처 등록
const postSdmData = async (data: DemandCompType) => {
try {
await postFetch("/reuseFc/request", data);
toast.success("수요처 등록에 성공하였습니다.");
return true;
} catch (error) {
console.log(error);
toast.error("수요처 등록에 실패하였습니다.");
return false;
}
}
// 수요정보 등록
const postSdmInfoData = async (data: DemandCompBizType) => {
try {
await postFetch("/reuseFc/request/demandInfo", data);
toast.success('수요 정보 등록에 성공하였습니다.');
} catch (error) {
console.log(error);
toast.error('수요 정보 등록에 실패하였습니다.');
}
}
const postSdmLoginData = async (data: DemandCompType) => {
try {
const res = await postFetch("/reuseFc/request/login", data);
const jsonData = await res.json();
const status = jsonData.status;
const loginData = jsonData.data;
let loginSuccess = false;
if(status === 200 && loginData != null) {
alert("수요처 로그인에 성공하였습니다.");
sessionStorage.setItem("user", JSON.stringify(loginData));
loginSuccess = true;
} else if(status === 401) {
alert("패스워드가 틀렸습니다.");
loginSuccess = false;
} else {
alert("등록된 수요처가 없습니다.");
loginSuccess = false;
}
return loginSuccess;
} catch (error) {
console.log(error);
alert("수요처 로그인에 실패하였습니다.");
}
}
const getDemandCompList = async (searchText: string, pageCnt: number) => {
try {
const url = '/reuseFc/request/' + (searchText ? searchText : '') + '/' + pageCnt;
const res = await getFetch(fetchType.WR, url);
return {
data: res?.data || [],
count: res?.count || 0
}
} catch (error) {
console.error(error+' 수요처 정보를 불러오지 못했습니다.');
return { data: [], count: 0 };
}
}
const getSdmInfoStatusList = async (dmdInfoId : string) => {
try {
const url = '/reuseFc/request/' + dmdInfoId;
const res = await getFetch(fetchType.WR, url);
return res?.data || [];
} catch (error) {
console.error(error+' 수요정보 신청현황을 불러오지 못했습니다.');
return [];
}
}
// 유사사례 조회
const fetchGetSimilarCaseList = async (part: string, value: number|string) => {
try {
const url = '/reuseFc/pubFcRuseCase/dm/' + part + '/' + (value ? value : 0);
const res = await getFetch(fetchType.WR, url);
return res?.data || [];
} catch (error) {
console.error(error+' 유사사례 조회에 실패하였습니다.');
return [];
}
}
/**====================================
* Store - expaort
====================================*/
export type SdmActions = {
initializeSdmData: () => void;
insertSdmData: (data: DemandCompType) => Promise<void>;
insertSdmInfoData: (data: DemandCompBizType) => Promise<void>;
getDemandCompList: (searchText: string, pageCnt: number) => Promise<void>;
actionLogin: (data: DemandCompType) => Promise<void>;
getSdmInfoStatusList: (dmdInfoId: string) => Promise<void>;
getSimilarCaseList: (part: string, value: number|string) => Promise<void>;
}
export type SdmType ={
Sdm :{
isInit: boolean;
useTypesData: SelectDataType[];
demandCompList: DemandCompType[];
upHeadSimilarCaseList: TableUpperProps[];
similarCaseList: SimilarCaseType[];
upHeadMoneyDetailList : TableUpperProps[];
moneyDetailList: MoneyDetailType[];
// 수요정보 신청현황
sdmInfoStatusList: DemandCompBizType[];
upHeadSdmInfoStatus: TableUpperProps[];
defaultRules: Record<string, object>
disableRules: DisableRulesType;
// 로그인관련
successNextPage : boolean;
successLogin: boolean;
}
}
export const SdmState: SdmType = {
Sdm:{
isInit:false,
useTypesData: useTypesData,
demandCompList: demandCompList,
upHeadSimilarCaseList: similarCaseModalHeader,
similarCaseList: similarCaseList,
upHeadMoneyDetailList : moneyDetailModalHeader,
moneyDetailList: moneyDetailList,
// 수요정보 신청현황
sdmInfoStatusList: [],
defaultRules:defaultRules,
disableRules:disableRules,
upHeadSdmInfoStatus: upHeadSdmInfoStatus,
// 로그인관련
successNextPage : false,
successLogin: false,
}
}
export const SdmReducer = (set: any): SdmActions => {
return {
initializeSdmData: async () => {
},
insertSdmData: async (data: DemandCompType) => {
const preRes = await getSdmInfoStatusList(data.dmdInfoId);
if(preRes?.dmdInfoId) {
toast.error('이미 등록된 수요처입니다.');
return;
}
const res = await postSdmData(data);
if(res) {
set((state: SdmType) => ({
Sdm: {
...state.Sdm,
successNextPage: true,
}
}))
}
},
insertSdmInfoData: async (data: DemandCompBizType) => {
postSdmInfoData(data);
},
// 수요처 정보
getDemandCompList: async (searchText: string, pageCnt: number) => {
const res = await getDemandCompList(searchText, pageCnt);
if(res.data.length === 0) {
set((state: SdmType) => ({
Sdm: {
...state.Sdm,
demandCompList: [],
}
}))
return;
} else {
set((state: SdmType) => ({
Sdm: {
...state.Sdm,
demandCompList: res.data,
}
}))
}
},
// 수요정보 신청현황
getSdmInfoStatusList: async (dmdInfoId: string) => {
const res = await getSdmInfoStatusList(dmdInfoId);
if(res.length === 0) {
set((state: SdmType) => ({
Sdm: {
...state.Sdm,
sdmInfoStatusList: [],
}
}))
return;
}
const sdmInfoStatusList : DemandCompBizType[] = [];
res.demandInfo.map((el: DemandCompBizType, index: number) => {
const matchingInfo = res.demandMatchings.find((matching: DemandMatchings) => matching.dmdInfoSn === el.dmdInfoSn);
const status = matchingInfo?.splyFcltFlg?.trim() === 'Sub' ? '검토' : matchingInfo?.splyFcltFlg?.trim() === 'matched' ? '매칭완료' : '신청';
const ruseUtztnFcltNm = matchingInfo?.ruseUtztnFcltNm ? matchingInfo.ruseUtztnFcltNm : '';
const tmpData = {
...el,
index: ++index,
bzmnNo: res.bzmnNo,
dmdCoNm: res.dmdCoNm,
dmdInfoId: res.dmdInfoId,
daddr: res.daddr,
telno: res.telno.replace(/(\d{3})(\d{4})(\d{4})/, '$1-$2-$3'),
rgtr: res.rgtr,
ctpvCd: res.ctpvCd,
sggCd: res.sggCd,
useUsgCd: el.useUsgCd,
dmndTmng: el.dmndTmng,
dmdInfoSn : el.dmdInfoSn,
status : status,
splyCndtList: el.splyCndtList,
ruseUtztnFcltNm: ruseUtztnFcltNm || ''
}
sdmInfoStatusList.push(tmpData);
})
set((state: SdmType) => ({
Sdm: {
...state.Sdm,
sdmInfoStatusList: sdmInfoStatusList,
}
}))
},
actionLogin: async (data: DemandCompType) => {
const loginSuccess = await postSdmLoginData(data);
set((state: SdmType) => ({
Sdm: {
...state.Sdm,
successLogin: loginSuccess,
}
}))
},
// 유사사례 조회
getSimilarCaseList: async (part: string, value: number|string) => {
const res = await fetchGetSimilarCaseList(part, value);
if(res && res.length !== 0) {
set((state: SdmType) => ({
Sdm: {
...state.Sdm,
similarCaseList: res,
}
}))
return;
}else{
set((state: SdmType) => ({
Sdm: {
...state.Sdm,
similarCaseList: [],
}
}))
return;
}
}
}
}
export type SdmStore = SdmType & SdmActions;
+29
View File
@@ -0,0 +1,29 @@
import { createStore } from 'zustand/vanilla';
import { CommonReducer, CommonState, type CommonStore, type CommonType } from './common';
import { SdmReducer, SdmState, type SdmStore, type SdmType } from './sdmMng';
import { dmMapState, type DmMapType, type DmMapStore, DmMapReducer } from './map';
import { tokenManager } from '@/src/entities/auth/lib/token-manager';
export type DmStore = CommonStore & SdmStore &DmMapStore ;
export type DmState = CommonType & SdmType &DmMapType;
export const StoreZus = (initState: DmState= {
...CommonState,
...SdmState,
...dmMapState,
}) => {
return createStore<DmStore>()((set) => {
if (typeof window !== "undefined") {
tokenManager.setToken("site-id-key", 'DM');
tokenManager.setToken("site-theme-key", 'green');
}
return {
...initState,
...CommonReducer(set),
...SdmReducer(set),
...DmMapReducer(set)
}
})
}
@@ -0,0 +1,9 @@
.formInsideTitle{
background-color: gray;
text-align: center;
}
.smallTitle{
}
+21
View File
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
+53
View File
@@ -0,0 +1,53 @@
import { Button } from "@common/components/ui";
import Link from "next/link";
import toast from "react-hot-toast";
export function ToastFormUi(body:string,title:string,url:string,buttonName:string){
//const router = useRouter();
return(
toast.custom((t) => (
<div
className={`${
t.visible ? 'animate-enter' : 'animate-leave'
} w-4/12 h-40 bg-white shadow-lg rounded-lg pointer-events-auto flex ring-1 ring-black ring-opacity-5`}
>
<div className="w-full flex-1 py-10">
<div className="flex items-start">
<div className="flex-shrink-0 pt-0.5">
</div>
<div className="ml-3 flex-1">
<p className="text-xl font-medium underline">
{title}
</p>
<p className="mt-1 text-2xl">
{body}
</p>
</div>
</div>
</div>
<div className="flex py-20">
<Button
size='sm'
onClick={() => toast.dismiss(t.id)}
>
<Link href={url}> {buttonName}</Link>
</Button>
</div>
<div className="flex pl-4 pr-4 py-20">
<Button
onClick={() => toast.dismiss(t.id)}
size='sm'
>
닫기
</Button>
</div>
</div>
))
)
}
+72
View File
@@ -0,0 +1,72 @@
//8880
import { DEV_URL_FO, DEV_URL_WR } from "../config";
export const fetchType = {
COMMON: "common",
WR: "wr"
};
interface RequestParams {
[key: string]: unknown;
}
export const getFetch = async (type: string, url: string) => {
const baseUrl = type === fetchType.COMMON ? DEV_URL_FO : DEV_URL_WR;
const response = await fetch(baseUrl + url, {
method: "GET",
headers: {
accept: "application/json",
},
});
if (!response.ok) {
throw new Error(`오류 발생: ${response.status}`);
}
const res = await response.json();
return res;
};
export const postFetch = async (url: string, params: RequestParams) => {
const response = await fetch(DEV_URL_WR + url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(params),
});
if (!response.ok) {
throw new Error(`오류 발생: ${response.status}`);
}
return response;
};
export const putFetch = async (url: string, params: RequestParams) => {
const response = await fetch(DEV_URL_WR + url, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(params),
});
if (!response.ok) {
throw new Error(`오류 발생: ${response.status}`);
}
return response;
};
export const deleteFetch = async (url: string, params: RequestParams) => {
const response = await fetch(DEV_URL_WR + url, {
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(params),
});
if (!response.ok) {
throw new Error(`오류 발생: ${response.status}`);
}
return response;
};
@@ -0,0 +1,103 @@
import { useDmStore } from '@/app/store';
import type { DemandCompType } from '@common/business_components/ui/sdmInsertForm/form';
import React, { useCallback, useEffect, useRef, useState } from 'react';
type thisProps = {
label: string | undefined;
initValue: string | undefined;
setValue : (dmdCoNm?: string) => void;
setRtnData : (rtnData: DemandCompType[]) => void;
}
export default function SearchInputText({label=undefined, initValue=undefined, setValue=()=>{}, setRtnData=()=>{}} :thisProps) {
const { getDemandCompList, Sdm } = useDmStore((state) => state);
const {
demandCompList
} = Sdm;
const [isOpen, setIsOpen] = useState(false);
const [textValue, setTextValue] = useState(initValue || '');
const wrapperRef = useRef<HTMLDivElement>(null);
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setTextValue(value);
setValue(value);
if (value) {
setIsOpen(true);
getDemandCompList(value, 20);
} else {
setIsOpen(false);
}
}, [getDemandCompList, setValue]);
const handleSuggestionClick = useCallback((dmdCoNm: string) => {
setTextValue(dmdCoNm);
setValue(dmdCoNm);
setIsOpen(false);
setRtnData(demandCompList);
}, [demandCompList, setRtnData, setValue]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, []);
return (
<div ref={wrapperRef}>
<input
type="text"
value={textValue}
onChange={handleInputChange}
/* onFocus={() => setIsOpen(true)} */
className="w-full h-[3.2rem] !text-xl pl-3 text-gray-600 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={`${label}을(를) 입력하세요`}
/>
{isOpen && (
<div className=" z-1 w-full mt-2 bg-white border border-gray-300 rounded-md shadow-lg h-90 overflow-auto">
<div className="max-h-72">
{demandCompList && demandCompList.length > 0 ? demandCompList.map((item : DemandCompType, index : number) => (
<div
key={index}
className="!text-xl w-full text-left px-5 py-3 hover:bg-gray-100 cursor-pointer overflow-auto"
onClick={() => handleSuggestionClick(item.dmdCoNm)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleSuggestionClick(item.dmdCoNm);
}
}}
>
{item.dmdInfoId.substring(10,14) !== '0000' ? (
<>
<span className="text-gray-500">사업자번호 : </span> {item.dmdInfoId.substring(0,10)}&nbsp;
<span className="text-gray-500">종사업자번호 : </span> {item.dmdInfoId.substring(10,14)}&nbsp;
</>
) : (
<>
<span className="text-gray-500">사업자번호 : </span> {item.dmdInfoId.substring(0,10)}&nbsp;
</>
)}
<span className="float-right">{item.dmdCoNm}</span>
</div>
)) : (
<div className="!text-xl w-full px-5 py-3 hover:bg-gray-100 cursor-pointer overflow-auto">
검색 결과가 없습니다.
</div>
)}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,18 @@
export const sanitizeInput = (val: string, allowDecimal = false) => {
if (val === "") return ""; // 빈 값 허용
// 소숫점 허용
if (allowDecimal) {
// 숫자와 점만 허용
let cleaned = val.replace(/[^0-9.]/g, "");
// 소숫점 여러 개일 경우 첫 번째만 유지
const parts = cleaned.split(".");
if (parts.length > 2) {
cleaned = parts[0] + "." + parts.slice(1).join("");
}
return cleaned;
}
// 정수만 허용
return val.replace(/[^0-9]/g, "");
};
+5
View File
@@ -0,0 +1,5 @@
import { PROXY_HOST, SERVER_API_FO_URL, SERVER_API_WR_URL } from "@/src/shared/config/env";
export const LOCAL_URL = PROXY_HOST;
export const DEV_URL_FO = SERVER_API_FO_URL;
export const DEV_URL_WR = SERVER_API_WR_URL;

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