Compare commits

...
161 Commits
Author SHA1 Message Date
mind-one001 35b9f813c4 로그삭제 2026-09-21 15:08:07 +09:00
kiminchan bf0aef59cd Merge remote-tracking branch 'origin/iipbs/sn/dev' into iipbs/sn/dev_kic 2026-09-21 10:06:26 +09:00
kiminchan b2ddcd0cf3 sn : 통수위험 결과 안내문에 위험 판정기준 및 상태 색상 표시 2026-09-21 09:55:55 +09:00
mind-one001 49ace44c9b 테스트 파일 삭제 2026-09-18 16:35:21 +09:00
mind-one005 91576f401b sn: 침수예측오버레이 수정 2026-09-17 15:41:08 +09:00
mind-one005 febcd18eda Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-09-17 14:05:11 +09:00
mind-one005 8cc9f0d291 sn: 침수예측현황 침수위험도 수정 2026-09-17 14:04:51 +09:00
kiminchan a8f2c8f41a GIS 편집 신규 관로 미리보기 표시 보완 2026-09-16 17:32:02 +09:00
mind-one001 4d685d5a67 Merge remote-tracking branch 'origin/iipbs/sn/dev' into iipbs/sn/dev 2026-09-16 11:15:30 +09:00
mind-one001 96196229f4 fix(sn): align business form limits with standardized database 2026-09-16 11:15:18 +09:00
mind-one005 94b2e84c10 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-09-15 16:28:23 +09:00
mind-one001 80d2230c9a 불필요 파일 삭제 2026-09-15 10:00:42 +09:00
mind-one005 d5f0c3bc95 sn: 침수발생이력, 침수예측현황 팝업크기 변경 외 2026-09-11 11:01:55 +09:00
mind-one001 b7134cb3e4 노후관로현황 그리드 수정 2026-09-10 14:06:24 +09:00
mind-one001 ec78658e46 시도별 노후관로현황 그리드 수정 2026-09-10 14:01:58 +09:00
mind-one001 44361af392 그리드 css 통합 2026-09-10 13:51:42 +09:00
mind-one001 76130caa6d Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-09-10 11:30:58 +09:00
mind-one001 352a78a0c5 gis 업로드 오류 기능 수정 2026-09-10 11:30:52 +09:00
kiminchan 65bee9586d 도시침수지도 목록 이동 운영 보완 기록 2026-09-09 16:45:12 +09:00
mind-one001 31cda8579a Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-09-09 15:42:53 +09:00
mind-one001 1dc40b9efc 빈 레이어 트리로 지도만 재사용하는 화면에서는 패널 외곽과 제목도 표시하지 않는다. 2026-09-09 15:42:31 +09:00
mind-one005 6cb3d8ad65 Merge branch 'iipbs/wr/dev' into iipbs/sn/dev 2026-09-09 14:06:31 +09:00
LEEKYUHYUN 225e2732d1 사이드패널 패딩 조정 및 스크롤바 수정 2026-09-09 11:35:59 +09:00
mind-one005 d82ae99b6a sn: 오류 수정 2026-09-09 09:09:09 +09:00
mind-one005 23341b93c7 sn: 침수흔적심선/위선 화살표 연속 scroll 추가 2026-09-08 18:15:40 +09:00
mind-one005 b99f4904b0 sn: featureClickEventPopup에서 침수흔적도 관련 분리 2026-09-08 17:13:36 +09:00
mind-one005 f742feca4b sn: 상단 주석 외 침수흔적도 관련 수정 2026-09-08 15:12:40 +09:00
mind-one005 569e003827 sn: 팝업 화면 여백 조정 2026-09-08 11:01:53 +09:00
mind-one005 2db5dd59af Merge branch 'iipbs/wr/dev' into iipbs/sn/dev 2026-09-07 18:15:04 +09:00
mind-one005 8a8504dca9 sn: 운영정보 버튼 위치 수정 2026-09-07 17:51:36 +09:00
LEEKYUHYUN 7e82c6a802 "디지털하수관로" UI 수정 2026-09-07 17:44:46 +09:00
mind-one005 bdc6251474 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-09-07 17:01:46 +09:00
mind-one005 479cf5c4a0 sn: 다단header 묶음 처리 외 2026-09-07 16:56:35 +09:00
mind-one001 2b3502161c 시설물 상세창 깨짐 수정 2026-09-04 17:16:32 +09:00
mind-one001 0544a72011 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-09-04 15:27:18 +09:00
mind-one001 34ba09ce1b 준설관리 기능 보완 2026-09-04 15:24:17 +09:00
mind-one005 aa5a71ae68 sn: 침입수, RDII 제목란 조금 넓게 외 2026-09-04 14:52:11 +09:00
mind-one005 9067e645b3 sn: 정보검색 기본정보 구분란 화면이 줄었을 때, 디자인 외 2026-09-04 14:33:25 +09:00
mind-one005 10d3fc096a sn: 시도 전체일 때, 시군구 목록 비우기 2026-09-04 11:00:38 +09:00
mind-one005 7fd95ba6f9 sn: src/shared/util/util.ts 적용 외 2026-09-04 10:32:36 +09:00
mind-one005 e707637c88 수정 2026-09-03 17:10:11 +09:00
mind-one005 5da89e8ee7 sn: appendApiSearchParams() 함수 적용 2026-09-03 17:09:58 +09:00
mind-one005 f9ff279895 Merge branch 'iipbs/sn/dev' into iipbs/wr/dev 2026-09-02 18:18:01 +09:00
mind-one005 4cde9b67c1 sn: util.ts 추가 정리 2026-09-02 18:10:50 +09:00
mind-one005 80d93c1631 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-09-01 18:14:16 +09:00
mind-one005 872b3d08c0 sn: 선택 레이어 전체 디자인 수정 2026-09-01 18:10:31 +09:00
kiminchan cb191817a8 Merge branch 'iipbs/sn/dev' into iipbs/sn/dev_kic 2026-09-01 16:01:37 +09:00
kiminchan 8198fda45b 공통코드 조회 기반 업무 화면 표시 적용 2026-09-01 15:55:02 +09:00
mind-one005 37374cb754 sn: 선택 레이어 설정 - 침수흔적도 2026-09-01 15:38:25 +09:00
mind-one005 9e36edec7f sn: 침수흔적위선/심선 정보 닫을 때 지도처리 2026-09-01 11:10:27 +09:00
mind-one005 3204bba71b Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-08-31 23:17:20 +09:00
mind-one005 3cdcf6a6f7 sn: 침수흔적위선 심선/위선 모두 나오도록 2026-08-31 23:16:57 +09:00
kiminchan 922861c872 통수위험 행 선택 시 초단기예보격자망 자동 활성화 2026-08-31 13:42:57 +09:00
mind-one005 4a3945f1e4 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-08-31 11:36:15 +09:00
mind-one005 a1caa1b860 sn: .env.local 원복 2026-08-31 11:36:05 +09:00
mind-one005 166dea2b86 sn: 침수흔적도 layer 수정 2026-08-31 11:34:19 +09:00
kiminchan 3f7262e213 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-08-31 11:06:24 +09:00
kiminchan 231ee10ff8 조사사업 양식 버튼 병합 충돌 오류 수정 2026-08-31 11:05:22 +09:00
mind-one005 5aa9ab4f39 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-08-31 11:00:02 +09:00
kiminchan 8c46f7ca06 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-08-31 10:46:38 +09:00
kiminchan 23e15daa37 조사사업 등록·수정 양식 다운로드 흐름 보완 2026-08-31 10:35:05 +09:00
mind-one005 dd56ed60ad sn: 용어 변경 - 수위, 깊이 > 위선,심선 2026-08-31 10:23:37 +09:00
mind-one005 18129905d8 sn: 연도버튼 및 침수흔적깊이 2026-08-29 11:21:26 +09:00
mind-one005 d03263a3c6 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-08-28 16:48:17 +09:00
mind-one005 be46bcbd17 sn: 침수흔적깊이 정보 추가 2026-08-28 16:08:47 +09:00
mind-one001 201dbf577f 관망도 업로드 수정 2026-08-28 15:45:43 +09:00
mind-one001 884dd71ca7 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev
# Conflicts:
#	apps/sn/app/(map-layout)/map/components/popupComponent/featureClickEventPopup.tsx
2026-08-28 10:00:36 +09:00
mind-one001 70efdc9db5 유하방향 표출 수정 2026-08-28 09:52:41 +09:00
mind-one005 36549e7281 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-08-27 22:34:15 +09:00
mind-one005 c1ffc33834 sn: 침수예측지도 오버레이 & 침수흔적깊이 2026-08-27 22:33:37 +09:00
mind-one005 2c3961644c sn: 침수예측지도 오버레이 표시1 2026-08-27 14:10:44 +09:00
mind-one001 2fe3631a0d Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-08-26 16:26:19 +09:00
mind-one001 e0cdba00be 그리드 정렬기능 2026-08-26 16:25:29 +09:00
kiminchan 5e206dd210 Merge branch 'iipbs/sn/dev' into iipbs/sn/dev_kic 2026-08-26 14:44:57 +09:00
kiminchan 2008a5a9ee 통수위험 지도 안내 및 과거이력 조회 동작 보완 2026-08-26 14:43:27 +09:00
mind-one005 53aeeccc2a Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-08-26 13:11:52 +09:00
mind-one005 c7738a6d6c sn: 침수예측현황 지도이동시 도시침수지도 layer점검 2026-08-26 13:11:40 +09:00
kiminchan baf29df950 통수위험 매핑 시설 지도 표출 및 과거이력 행 식별 보완 2026-08-26 13:07:01 +09:00
mind-one005 0a13f34092 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-08-26 11:11:30 +09:00
mind-one005 0b45361135 sn: 침수예측현황 팝업에서 지도이동 2026-08-26 11:11:18 +09:00
kiminchan 83420c76b8 Merge remote-tracking branch 'origin/iipbs/sn/dev' into iipbs/sn/dev_kic 2026-08-26 10:38:51 +09:00
mind-one005 42f83bcd24 sn: 침수예측현황 목록 항목 순서 변경 2026-08-26 10:06:55 +09:00
mind-one001 ccfbe6ae32 팝업 병합 그리드 기능 추가 2026-08-25 16:43:07 +09:00
mind-one001 8126fb844a Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-08-25 15:30:01 +09:00
mind-one001 23c9ddf4ce ui 버튼 동작 통일 2026-08-25 15:29:55 +09:00
mind-one005 63388ccc90 dm: env 수정 2026-08-24 17:45:53 +09:00
LGS bda3228d0f 물 재이용 소개 / 물 재이용 현황 [이미지 수정 및 텍스트 수정] 2026-08-24 16:57:15 +09:00
LGS 95ca40913f 물 재이용 소개[우리나라의 수자원 현황 및 물이용 여건] 이미지 교체 2026-08-24 16:16:41 +09:00
kiminchan 8bfe1c9ab2 강우관측소 지도 클릭 비활성화 2026-08-24 11:23:40 +09:00
kiminchan 0dc981819f 강우 레이어 프록시와 관측소 아이콘 추가 2026-08-21 17:22:25 +09:00
mind-one001 4b7ada9362 Merge remote-tracking branch 'origin/iipbs/sn/dev' into iipbs/sn/dev
# Conflicts:
#	apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx
#	apps/sn/src/features/windowPopup/model/popupDefinitions.ts
2026-08-20 17:16:33 +09:00
mind-one001 71c5dfb8dd fix(sn): 조사관리 팝업 레이아웃과 등록 흐름 개선 2026-08-20 17:14:07 +09:00
mind-one001 7e3911dbd6 feat(sn): 침수예측 및 통수위험 팝업 UI 보완 2026-08-20 17:13:54 +09:00
kiminchan 839d31b9ca 통수위험 결과조회 공통 그리드 및 지도 표출 보완 2026-08-20 17:09:33 +09:00
mind-one001 386fdade59 Merge remote-tracking branch 'origin/iipbs/sn/dev' into iipbs/sn/dev 2026-08-20 15:10:36 +09:00
mind-one001 0cbbc3b934 feat(sn): CS 기준 UI 통일 및 지도 팝업 표준화 2026-08-20 14:57:20 +09:00
kiminchan f5051225ea 통수위험 결과조회 및 지도 시각화 프로토타입 구현 2026-08-18 17:28:48 +09:00
mind-one005 f7218fc98a sn: 침수예측현황-단위 엑셀다운로드 수정 2026-08-18 13:31:22 +09:00
mind-one001 c9e3ff0d91 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-08-12 11:14:05 +09:00
mind-one001 474ad1ab26 GIS관리 기능 보완 2026-08-12 11:13:49 +09:00
mind-one005 099e000f88 sn: 분석도구 - 침수예측현황 2026-08-11 15:09:30 +09:00
mind-one005 7fa4538667 sn: 도시침수예방사업 다운로드 header 수정 2026-08-11 14:34:47 +09:00
mind-one005 91e11a7857 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-08-11 14:33:20 +09:00
mind-one001 d582293e5f 보수이력 목록 조회 항목 추가 2026-08-11 11:28:52 +09:00
mind-one001 d8c5344451 관망도 분석도구 위치 조정 2026-08-11 11:11:09 +09:00
mind-one001 d23a16905d Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-08-11 10:32:45 +09:00
mind-one001 2649ce286b 보수이력 기능 개발 2026-08-11 10:32:35 +09:00
mind-one005 2f9e97b65b sn: 도시침수예방사업01 2026-08-10 22:04:36 +09:00
mind-one005 e1940043e9 sn: 노후 하수관로 정비사업 등록/수정 입력란 수정 2026-08-07 22:49:44 +09:00
mind-one005 7f1c804a0c dm: LoginModal 사업자등록번호 입력 수정 2026-08-07 20:59:05 +09:00
mind-one005 d07bb6074d sn: 노후하수관로정비사업 시군구 필수 아님 2026-08-07 18:22:08 +09:00
mind-one005 4109691eea Merge branch 'iipbs/sn/dev_ljp' into iipbs/sn/dev 2026-08-07 15:58:28 +09:00
mind-one005 4eaac5dbf1 sn: 노후하수관로정비사업 - 팝업 2026-08-07 15:57:14 +09:00
mind-one001 b99fe8daf1 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-08-07 15:28:49 +09:00
mind-one001 3f4e3581dd 준설관리 개발완료 2026-08-07 15:28:33 +09:00
mind-one005 f00627270f sn: 최종확정예산사업 popup 2026-08-06 18:40:19 +09:00
mind-one005 1858739bb9 sn: 노후하수관로정비사업 조회조건 유지 2026-08-06 10:46:47 +09:00
kiminchan 3d320d0e19 맨홀 선택 시 기존 심볼이 유지되도록 보완 2026-08-06 09:42:41 +09:00
kiminchan 9d2c0afb98 조사 지도 조건과 노후관로 범례 보완 2026-08-06 09:14:56 +09:00
mind-one001 29f2a9169e Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev
# Conflicts:
#	apps/sn/src/features/windowPopup/model/popupDefinitions.ts
#	apps/sn/src/features/windowPopup/registry.tsx
2026-08-05 16:24:52 +09:00
mind-one001 493acc9ebd 통수능/준설관리 2026-08-05 16:05:07 +09:00
mind-one005 d3bdc58994 sn: 순번 삭제 외 2026-08-05 15:49:55 +09:00
kiminchan 20efccb4d9 Merge branch 'iipbs/sn/dev_kic' into iipbs/sn/dev 2026-08-05 13:58:16 +09:00
kiminchan f58daf3bac 침수지도 목록보기 및 WMS 표출 구현 2026-08-05 13:55:22 +09:00
mind-one005 b42810d29a sn: 노후하수관로정비사업1 2026-08-04 20:30:26 +09:00
mind-one005 2e6a75141b sn: 노후 하수관로 정비사업 2026-08-03 18:16:42 +09:00
kiminchan ce60e5f5fb Merge branch 'iipbs/sn/dev' into iipbs/sn/dev_kic 2026-08-03 10:12:47 +09:00
kiminchan c9d85a8ce4 조사결과 관로·맨홀 지도 레이어와 보고서 조회 연동 2026-08-03 10:11:48 +09:00
mind-one001 0f8b6cd6d0 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev
# Conflicts:
#	apps/sn/app/(map-layout)/map/components/gisManagement/GisManagementPopupPage.tsx
#	apps/sn/app/(map-layout)/map/components/waterRisk/WaterRiskPopupPage.tsx
2026-07-30 17:26:41 +09:00
mind-one001 b11658e1d6 준설관리 초안 2026-07-30 17:18:59 +09:00
kiminchan bcf5d3caec 조사관리 팝업 UI와 관로 상세 등급 표시 보정 2026-07-30 16:53:30 +09:00
kiminchan 474676af17 조사 상세현황 배수방식·관종 명칭 표시 2026-07-30 13:17:16 +09:00
kiminchan 3cc0e9de14 조사 상세조회 및 집계 엑셀 다운로드 연동 2026-07-30 10:53:32 +09:00
kiminchan ff7be74437 Merge branch 'iipbs/sn/dev' into iipbs/sn/dev_kic 2026-07-29 13:31:01 +09:00
kiminchan f52f22e80f 조사결과 검토·저장 및 사업보고서 목록 표시 개선 2026-07-29 13:22:59 +09:00
mind-one005 86e00d48dc sn: 지도 침수흔적도 우측 정보에 강우분석 추가 2026-07-29 11:43:51 +09:00
mind-one005 bf61f6f76a sn: 침수발생이력 팝업 수정 2026-07-28 17:51:47 +09:00
kiminchan 62c41297c4 조사사업 수정 및 조사양식 다운로드 연동 2026-07-28 13:28:15 +09:00
mind-one005 09b5cc3798 sn: 시도별 노후관로현황 2026-07-27 17:51:33 +09:00
kiminchan 155a727a77 처리분구 인라인 편집 및 저장 후 목록 동기화 2026-07-27 14:18:02 +09:00
kiminchan a52dacf500 조사사업 등록의 사업차수 입력과 행정구역 배치 보완 2026-07-27 11:29:48 +09:00
mind-one005 e8e38310ee Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-07-27 10:32:25 +09:00
mind-one005 2652e2cfe0 sn: components.css 통합 2026-07-27 10:29:45 +09:00
kiminchan 5a76ae8a88 Merge branch 'iipbs/sn/dev' into iipbs/sn/dev_kic 2026-07-27 10:20:27 +09:00
kiminchan 7d9b97aac9 조사상세 본단위구간 모드 및 결함별 엑셀 다운로드 구현 2026-07-27 10:15:06 +09:00
mind-one005 69c3183e35 sn: 침수흔적도 지도표출 2026-07-24 18:13:54 +09:00
mind-one005 5e6477cddd Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-07-24 15:23:41 +09:00
mind-one005 c13cab394a sn: 침수발생이력 변수명 수정 2026-07-24 15:23:35 +09:00
kiminchan 73f1940cb0 Merge branch 'iipbs/sn/dev' into iipbs/sn/dev_kic 2026-07-24 15:12:08 +09:00
mind-one005 8a35d232ef sn: 침수발생이력 팝업 api 적용 2026-07-24 14:22:41 +09:00
kiminchan 728981cfe6 조사 결함 분류별 상세보기 및 등록 팝업 개선 2026-07-24 14:08:25 +09:00
kiminchan 11cb2b9044 조사사업 관로·맨홀 상세보기 팝업 구현 2026-07-24 13:05:26 +09:00
kiminchan c1d6cd7b8e 조사사업 보고서 다운로드 및 상세현황 그리드 보정 2026-07-23 17:26:08 +09:00
kiminchan 477426c384 조사사업 등록·결과 업로드 및 관로·맨홀 종합집계 화면 구현 2026-07-23 17:06:01 +09:00
kiminchan ec60a05b01 처리분구관리 화면 구현 2026-07-22 13:15:06 +09:00
mind-one005 90f0e4691b Merge branch 'iipbs/wr/dev' into iipbs/sn/dev 2026-07-21 21:03:19 +09:00
kiminchan 5e0693e057 Merge branch 'iipbs/sn/dev_kic' into iipbs/sn/dev 2026-07-20 15:33:27 +09:00
kiminchan 1708555133 시설물 레이어 체크 시 스크롤 위치 유지 2026-07-20 15:29:23 +09:00
mind-one005 c38d95fd60 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-07-20 15:03:52 +09:00
kiminchan 667f7b2094 Merge remote-tracking branch 'origin/iipbs/sn/dev' into iipbs/sn/dev_kic 2026-07-20 14:57:39 +09:00
kiminchan d17909df41 그리드 공통 정렬 기능 확대 적용 2026-07-20 14:56:23 +09:00
488 changed files with 56662 additions and 27432 deletions
+2 -2
View File
@@ -8,8 +8,8 @@ NEXT_PUBLIC_PROXY_HOST = http://con.mind-one.co.kr:11315
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
SERVER_API_URL = http://dev.onasys.co.kr:8000
CS_SERVER_API_URL = http://dev.onasys.co.kr:8000
FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service/api/v1
WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service/api/v1
FO_SERVER_API_URL = http://con.mind-one.co.kr:11083/operation-service
WR_SERVER_API_URL = http://con.mind-one.co.kr:11083/reuse-service
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
+2 -2
View File
@@ -9,8 +9,8 @@ NEXT_PUBLIC_REFRESH_TOKEN = refresh-token
#NEXT_PUBLIC_CM_LOGIN_PAGE_URL = https://devqueen.hasuon.or.kr/auth/login
SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
CS_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000
FO_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/operation-service/api/v1
WR_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/reuse-service/api/v1
FO_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/operation-service
WR_SERVER_API_URL = http://apigateway.iipbs-was.svc.cluster.local:8000/reuse-service
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_CS_TUS_UPLOAD = /attach-service/api/v1/tus/upload
@@ -1,133 +1,99 @@
"use client";
'use client';
import React from "react";
import React from 'react';
// 공통 섹션 타이틀 컴포넌트
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
<div className='flex items-center gap-3'>
<span className='h-5 w-[3px] bg-sky-500' />
<h3 className='text-[24px] font-bold text-neutral-900'>{children}</h3>
</div>
);
}
// 구분선
function Hr({ className = "" }: { className?: string }) {
function Hr({ className = '' }: { className?: string }) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
// 출처 텍스트
function SourceText() {
return (
<p className="mt-7 text-center text-[13px] text-neutral-500">
자료: 환경부(2024), 「2023 하수도 통계」
</p>
);
return <p className='mt-7 text-center text-[13px] text-neutral-500'>자료: 환경부(2024), 「2023 하수도 통계」</p>;
}
const tableStyles = {
container: "w-[1000px] mx-auto overflow-hidden",
table: "w-full table-fixed border-collapse text-center text-[16px]",
th: "border border-[#858F98] py-3 font-bold text-neutral-800",
td: "border border-[#858F98] py-3 text-neutral-700 font-medium break-keep",
headerBg: "bg-[#E3E7EA]",
container: 'w-[1000px] mx-auto overflow-hidden',
table: 'w-full table-fixed border-collapse text-center text-[16px]',
th: 'border border-[#858F98] py-3 font-bold text-neutral-800',
td: 'border border-[#858F98] py-3 text-neutral-700 font-medium break-keep',
headerBg: 'bg-[#E3E7EA]',
};
export default function RainStatusSection() {
const title = "빗물이용시설(현황)";
const title = '빗물이용시설(현황)';
return (
<div className="bg-white">
<div className='bg-white'>
{/* 상단 타이틀 */}
<div className="inline-block">
<h2 className="relative inline-block px-2 text-[28px] font-extrabold tracking-tight leading-none text-slate-900">
<span className="relative z-10">{title}</span>
<span className="absolute left-0 bottom-[-4px] z-0 h-3 w-full bg-gradient-to-r from-sky-300 to-green-300" />
<div className='inline-block'>
<h2 className='relative inline-block px-2 text-[28px] font-extrabold tracking-tight leading-none text-slate-900'>
<span className='relative z-10'>{title}</span>
<span className='absolute left-0 bottom-[-4px] z-0 h-3 w-full bg-gradient-to-r from-sky-300 to-green-300' />
</h2>
</div>
<div className="mt-10 pb-[120px]">
<div className='mt-10 pb-[120px]'>
{/* 1) 빗물이용시설의 건축물별 빗물이용량 현황 */}
<section id="status-rain-building" className="mt-10 scroll-mt-[120px]">
<section id='status-rain-building' className='mt-10 scroll-mt-[120px]'>
<SectionTitle>빗물이용시설의 건축물별 빗물이용량 현황</SectionTitle>
<div className="mt-10">
<div className='mt-10'>
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<thead>
<tr>
<th
rowSpan={2}
className={`${tableStyles.th} ${tableStyles.headerBg}`}
style={{ width: "160px" }}
>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: '160px' }}>
구분
</th>
<th
colSpan={6}
className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}
>
<th colSpan={6} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}>
빗물이용량 (천m³/년)
</th>
</tr>
<tr>
<th
className={`${tableStyles.th} bg-[#01ACE5] text-white`}
style={{ width: "140px" }}
>
<th className={`${tableStyles.th} bg-[#01ACE5] text-white`} style={{ width: '140px' }}>
2018
</th>
<th
className={`${tableStyles.th} bg-[#17AFC7] text-white`}
style={{ width: "140px" }}
>
<th className={`${tableStyles.th} bg-[#17AFC7] text-white`} style={{ width: '140px' }}>
2019
</th>
<th
className={`${tableStyles.th} bg-[#2CB2A9] text-white`}
style={{ width: "140px" }}
>
<th className={`${tableStyles.th} bg-[#2CB2A9] text-white`} style={{ width: '140px' }}>
2020
</th>
<th
className={`${tableStyles.th} bg-[#42B58B] text-white`}
style={{ width: "140px" }}
>
<th className={`${tableStyles.th} bg-[#42B58B] text-white`} style={{ width: '140px' }}>
2021
</th>
<th
className={`${tableStyles.th} bg-[#57B86D] text-white`}
style={{ width: "140px" }}
>
<th className={`${tableStyles.th} bg-[#57B86D] text-white`} style={{ width: '140px' }}>
2022
</th>
<th
className={`${tableStyles.th} bg-[#81C784] text-white`}
style={{ width: "140px" }}
>
<th className={`${tableStyles.th} bg-[#81C784] text-white`} style={{ width: '140px' }}>
2023
</th>
</tr>
</thead>
<tbody>
{[
["골프장", "6,771.8", "6,417.4", "8,371.6", "10,215.4", "11,360", "10,191.6"],
["공공시설", "364", "325.2", "201.9", "244.6", "209.9", "299.6"],
["공동주택", "291", "840.5", "804.2", "2,393.0", "2,386", "2,428.4"],
["대규모점포", "169.9", "214.4", "58.4", "427.2", "371", "293.3"],
["체육시설", "52.6", "44.7", "48.6", "75.3", "71.8", "77.6"],
["학교", "33", "25.5", "22", "155.8", "165.4", "32.6"],
["기타", "311.6", "901.2", "344.5", "537.8", "564", "621.2"],
['골프장', '6,771.8', '6,417.4', '8,371.6', '10,215.4', '11,360', '10,191.6'],
['공공시설', '364', '325.2', '201.9', '244.6', '209.9', '299.6'],
['공동주택', '291', '840.5', '804.2', '2,393.0', '2,386', '2,428.4'],
['대규모점포', '169.9', '214.4', '58.4', '427.2', '371', '293.3'],
['체육시설', '52.6', '44.7', '48.6', '75.3', '71.8', '77.6'],
['학교', '33', '25.5', '22', '155.8', '165.4', '32.6'],
['기타', '311.6', '901.2', '344.5', '537.8', '564', '621.2'],
].map((row, idx) => (
<tr key={idx} className="bg-white">
<tr key={idx} className='bg-white'>
{row.map((val, i) => (
<td
key={i}
className={`${tableStyles.td} ${
i === 0 ? "!font-bold !text-neutral-900" : ""
} ${[1, 3, 5].includes(i) ? "bg-[#EAF5F5]" : ""}`}
>
<td key={i} className={`${tableStyles.td} ${i === 0 ? '!font-bold !text-neutral-900' : ''} ${[1, 3, 5].includes(i) ? 'bg-[#EAF5F5]' : ''}`}>
{val}
</td>
))}
@@ -141,129 +107,79 @@ export default function RainStatusSection() {
</div>
</section>
<Hr className="my-11" />
<Hr className='my-11' />
{/* 2) 빗물이용시설의 용도별 분포 (2023년 기준) */}
<section
id="status-rain-purpose-distribution"
className="scroll-mt-[120px]"
>
<section id='status-rain-purpose-distribution' className='scroll-mt-[120px]'>
<SectionTitle>빗물이용시설의 용도별 분포 (2023년 기준)</SectionTitle>
<div className="mt-8">
<div className='mt-8'>
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<thead>
<tr>
<th
rowSpan={2}
className={`${tableStyles.th} ${tableStyles.headerBg}`}
style={{ width: "160px" }}
>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: '160px' }}>
용도
</th>
<th
rowSpan={2}
className={`${tableStyles.th} ${tableStyles.headerBg}`}
style={{ width: "84px" }}
>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: '84px' }}>
개소수
<br />
(개소)
</th>
<th
rowSpan={2}
className={`${tableStyles.th} ${tableStyles.headerBg}`}
style={{ width: "84px" }}
>
저류용량
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: '84px' }}>
저류조용량
<br />
(천m³)
</th>
<th
colSpan={8}
className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}
>
<th colSpan={8} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}>
빗물이용량 (천m³/년)
</th>
</tr>
<tr>
<th
className={`${tableStyles.th} bg-[#01ACE5] py-2 text-[13px] text-white`}
style={{ width: "84px" }}
>
<th className={`${tableStyles.th} bg-[#01ACE5] py-2 text-[13px] text-white`} style={{ width: '84px' }}>
총사용량
</th>
<th
className={`${tableStyles.th} bg-[#10AED0] py-2 text-[13px] text-white`}
style={{ width: "84px" }}
>
<th className={`${tableStyles.th} bg-[#10AED0] py-2 text-[13px] text-white`} style={{ width: '84px' }}>
공공시설
</th>
<th
className={`${tableStyles.th} bg-[#20B0BA] py-2 text-[13px] text-white`}
style={{ width: "84px" }}
>
<th className={`${tableStyles.th} bg-[#20B0BA] py-2 text-[13px] text-white`} style={{ width: '84px' }}>
공동주택
</th>
<th
className={`${tableStyles.th} bg-[#2FB2A5] py-2 text-[13px] text-white`}
style={{ width: "84px" }}
>
<th className={`${tableStyles.th} bg-[#2FB2A5] py-2 text-[13px] text-white`} style={{ width: '84px' }}>
대규모점포
</th>
<th
className={`${tableStyles.th} bg-[#3FB58F] py-2 text-[13px] text-white`}
style={{ width: "84px" }}
>
<th className={`${tableStyles.th} bg-[#3FB58F] py-2 text-[13px] text-white`} style={{ width: '84px' }}>
체육시설
</th>
<th
className={`${tableStyles.th} bg-[#4EB77A] py-2 text-[13px] text-white`}
style={{ width: "84px" }}
>
<th className={`${tableStyles.th} bg-[#4EB77A] py-2 text-[13px] text-white`} style={{ width: '84px' }}>
학교
</th>
<th
className={`${tableStyles.th} bg-[#5EB964] py-2 text-[13px] text-white`}
style={{ width: "84px" }}
>
<th className={`${tableStyles.th} bg-[#5EB964] py-2 text-[13px] text-white`} style={{ width: '84px' }}>
골프장
</th>
<th
className={`${tableStyles.th} bg-[#6DBB4F] py-2 text-[13px] text-white`}
style={{ width: "84px" }}
>
<th className={`${tableStyles.th} bg-[#6DBB4F] py-2 text-[13px] text-white`} style={{ width: '84px' }}>
기타
</th>
</tr>
</thead>
<tbody>
{[
["청소·환경정화용수", "195", "32.7", "108.8", "24.9", "5.8", "2.6", "4.4", "2.9", "-", "68.1"],
["세척·살수용수", "12", "1.4", "37", "35", "-", "-", "1.9", "-", "-", "0.2"],
["조경용수", "3,165", "7,440.8", "13,554.1", "168.1", "2,421.9", "290.7", "66.2", "29", "10,044", "534.3"],
["냉·난방수", "8", "1.6", "0.1", "-", "-", "-", "-", "0.1", "-", "-"],
["공업용수", "6", "14.9", "53.2", "52.8", "-", "-", "-", "-", "-", "0.4"],
["농업용수", "6.0", "0.5", "0.6", "-", "-", "-", "-", "-", "-", "0.6"],
["기타", "48", "306.1", "155.6", "2.8", "-", "-", "0.6", "0.5", "147.5", "4.2"],
["미기재", "103", "22.9", "34.8", "16.0", "0.6", "-", "4.6", "0.2", "-", "13.5"],
["합계", "3,543", "7,820.8", "13,944.7", "299.6", "2,428.4", "293.3", "77.6", "32.6", "10,191.6", "621.2"],
['청소·환경정화용수', '195', '32.7', '108.8', '24.9', '5.8', '2.6', '4.4', '2.9', '-', '68.1'],
['세척·살수용수', '12', '1.4', '37', '35', '-', '-', '1.9', '-', '-', '0.2'],
['조경용수', '3,165', '7,440.8', '13,554.1', '168.1', '2,421.9', '290.7', '66.2', '29', '10,044', '534.3'],
['냉·난방수', '8', '1.6', '0.1', '-', '-', '-', '-', '0.1', '-', '-'],
['공업용수', '6', '14.9', '53.2', '52.8', '-', '-', '-', '-', '-', '0.4'],
['농업용수', '6.0', '0.5', '0.6', '-', '-', '-', '-', '-', '-', '0.6'],
['기타', '48', '306.1', '155.6', '2.8', '-', '-', '0.6', '0.5', '147.5', '4.2'],
['미기재', '103', '22.9', '34.8', '16.0', '0.6', '-', '4.6', '0.2', '-', '13.5'],
['합계', '3,543', '7,820.8', '13,944.7', '299.6', '2,428.4', '293.3', '77.6', '32.6', '10,191.6', '621.2'],
].map((row, idx) => (
<tr
key={idx}
className={idx === 8 ? "bg-white font-bold" : "bg-white"}
>
<tr key={idx} className={idx === 8 ? 'bg-white font-bold' : 'bg-white'}>
{row.map((val, i) => {
const isHighlightedCol = [1, 2].includes(i);
return (
<td
key={i}
className={`${tableStyles.td} ${
i === 0 ? "!font-bold !text-neutral-900" : ""
} ${isHighlightedCol ? "bg-[#EAF5F5]" : ""}`}
>
<td key={i} className={`${tableStyles.td} ${i === 0 ? '!font-bold !text-neutral-900' : ''} ${isHighlightedCol ? 'bg-[#EAF5F5]' : ''}`}>
{val}
</td>
);
@@ -278,91 +194,58 @@ export default function RainStatusSection() {
</div>
</section>
<Hr className="my-11" />
<Hr className='my-11' />
{/* 3) 빗물이용시설의 용도별 빗물이용량 현황 */}
<section
id="status-rain-purpose-usage"
className="scroll-mt-[120px]"
>
<section id='status-rain-purpose-usage' className='scroll-mt-[120px]'>
<SectionTitle>빗물이용시설의 용도별 빗물이용량 현황</SectionTitle>
<div className="mt-8">
<div className='mt-8'>
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<thead>
<tr>
<th
rowSpan={2}
className={`${tableStyles.th} ${tableStyles.headerBg}`}
style={{ width: "160px" }}
>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: '160px' }}>
용도
</th>
<th
colSpan={6}
className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}
>
<th colSpan={6} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}>
빗물이용량 (천m³/년)
</th>
</tr>
<tr>
<th
className={`${tableStyles.th} bg-[#01ACE5] text-white`}
style={{ width: "140px" }}
>
<th className={`${tableStyles.th} bg-[#01ACE5] text-white`} style={{ width: '140px' }}>
2018
</th>
<th
className={`${tableStyles.th} bg-[#14AFCD] text-white`}
style={{ width: "140px" }}
>
<th className={`${tableStyles.th} bg-[#14AFCD] text-white`} style={{ width: '140px' }}>
2019
</th>
<th
className={`${tableStyles.th} bg-[#28B2B4] text-white`}
style={{ width: "140px" }}
>
<th className={`${tableStyles.th} bg-[#28B2B4] text-white`} style={{ width: '140px' }}>
2020
</th>
<th
className={`${tableStyles.th} bg-[#3CB59A] text-white`}
style={{ width: "140px" }}
>
<th className={`${tableStyles.th} bg-[#3CB59A] text-white`} style={{ width: '140px' }}>
2021
</th>
<th
className={`${tableStyles.th} bg-[#50B880] text-white`}
style={{ width: "140px" }}
>
<th className={`${tableStyles.th} bg-[#50B880] text-white`} style={{ width: '140px' }}>
2022
</th>
<th
className={`${tableStyles.th} bg-[#6DBB4F] text-white`}
style={{ width: "140px" }}
>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white`} style={{ width: '140px' }}>
2023
</th>
</tr>
</thead>
<tbody>
{[
["청소·화장실용수", "126.5", "134.1", "96.5", "153.9", "140.5", "108.8"],
["세척·살수용수", "14.8", "14.6", "9.5", "49.8", "49.7", "37.0"],
["조경용수", "7,629.8", "8,496.2", "9,415.4", "13,360.4", "14,466.3", "13,554.1"],
["친수용수", "1.0", "0.1", "0.1", "0.1", "0.1", "0.1"],
["공업용수", "152.0", "77.0", "77.0", "75.4", "0.4", "53.2"],
["농업용수", "4.4", "4.3", "4.3", "4.3", "0.6", "0.6"],
["기타", "37.7", "42.6", "230.7", "394.3", "463.2", "155.6"],
['청소·화장실용수', '126.5', '134.1', '96.5', '153.9', '140.5', '108.8'],
['세척·살수용수', '14.8', '14.6', '9.5', '49.8', '49.7', '37.0'],
['조경용수', '7,629.8', '8,496.2', '9,415.4', '13,360.4', '14,466.3', '13,554.1'],
['친수용수', '1.0', '0.1', '0.1', '0.1', '0.1', '0.1'],
['공업용수', '152.0', '77.0', '77.0', '75.4', '0.4', '53.2'],
['농업용수', '4.4', '4.3', '4.3', '4.3', '0.6', '0.6'],
['기타', '37.7', '42.6', '230.7', '394.3', '463.2', '155.6'],
].map((row, idx) => (
<tr key={idx} className="bg-white">
<tr key={idx} className='bg-white'>
{row.map((val, i) => (
<td
key={i}
className={`${tableStyles.td} ${
i === 0 ? "!font-bold !text-neutral-900" : ""
} ${[1, 3, 5].includes(i) ? "bg-[#EAF5F5]" : ""}`}
>
<td key={i} className={`${tableStyles.td} ${i === 0 ? '!font-bold !text-neutral-900' : ''} ${[1, 3, 5].includes(i) ? 'bg-[#EAF5F5]' : ''}`}>
{val}
</td>
))}
@@ -378,4 +261,4 @@ export default function RainStatusSection() {
</div>
</div>
);
}
}
@@ -1,60 +1,56 @@
"use client";
'use client';
import React from "react";
import React from 'react';
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
<div className='flex items-center gap-3'>
<span className='h-5 w-[3px] bg-sky-500' />
<h3 className='text-[24px] font-bold text-neutral-900'>{children}</h3>
</div>
);
}
function Hr({ className = "" }: { className?: string }) {
function Hr({ className = '' }: { className?: string }) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
function SourceText() {
return (
<p className="mt-7 text-center text-[13px] text-neutral-500">
자료: 환경부(2024), 「2023 하수도 통계」
</p>
);
return <p className='mt-7 text-center text-[13px] text-neutral-500'>자료: 환경부(2024), 「2023 하수도 통계」</p>;
}
const tableStyles = {
container: "w-[1000px] mx-auto overflow-hidden",
table: "w-full table-fixed border-collapse text-center text-[16px]",
th: "border border-[#858F98] py-3 font-bold text-neutral-800",
td: "border border-[#858F98] py-3 text-neutral-700 font-medium break-keep",
headerBg: "bg-[#E3E7EA]",
container: 'w-[1000px] mx-auto overflow-hidden',
table: 'w-full table-fixed border-collapse text-center text-[16px]',
th: 'border border-[#858F98] py-3 font-bold text-neutral-800',
td: 'border border-[#858F98] py-3 text-neutral-700 font-medium break-keep',
headerBg: 'bg-[#E3E7EA]',
};
export default function SewageStatusSection() {
const title = "하수처리수 재이용시설(현황)";
const title = '하수처리수 재이용시설(현황)';
return (
<div className="relative bg-white">
<div className='relative bg-white'>
{/* 상단 타이틀 */}
<div className="inline-block">
<h2 className="relative inline-block px-2 text-[28px] font-extrabold tracking-tight leading-none text-slate-900">
<span className="relative z-10">{title}</span>
<span className="absolute left-0 bottom-[-4px] z-0 h-3 w-full bg-gradient-to-r from-sky-300 to-green-300" />
<div className='inline-block'>
<h2 className='relative inline-block px-2 text-[28px] font-extrabold tracking-tight leading-none text-slate-900'>
<span className='relative z-10'>{title}</span>
<span className='absolute left-0 bottom-[-4px] z-0 h-3 w-full bg-gradient-to-r from-sky-300 to-green-300' />
</h2>
</div>
<div className="mt-10">
<div className='mt-10'>
{/* 1) 연도별 하수처리수 재이용 현황 */}
<section id="status-treated-year" className="mt-10 scroll-mt-[120px]">
<section id='status-treated-year' className='mt-10 scroll-mt-[120px]'>
<SectionTitle>연도별 하수처리수 재이용 현황</SectionTitle>
<div className="mt-10">
<div className='mt-10'>
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<colgroup>
<col style={{ width: "130px" }} />
<col style={{ width: "180px" }} />
<col style={{ width: '130px' }} />
<col style={{ width: '180px' }} />
<col />
<col />
<col />
@@ -64,166 +60,52 @@ export default function SewageStatusSection() {
</colgroup>
<thead>
<tr>
<th
colSpan={2}
className={`${tableStyles.th} ${tableStyles.headerBg}`}
>
<th colSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
구분
</th>
<th className={`${tableStyles.th} bg-[#01ACE5] text-white`}>
2018
</th>
<th className={`${tableStyles.th} bg-[#14AFCD] text-white`}>
2019
</th>
<th className={`${tableStyles.th} bg-[#28B2B4] text-white`}>
2020
</th>
<th className={`${tableStyles.th} bg-[#3CB59A] text-white`}>
2021
</th>
<th className={`${tableStyles.th} bg-[#50B880] text-white`}>
2022
</th>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white`}>
2023
</th>
<th className={`${tableStyles.th} bg-[#01ACE5] text-white`}>2018</th>
<th className={`${tableStyles.th} bg-[#14AFCD] text-white`}>2019</th>
<th className={`${tableStyles.th} bg-[#28B2B4] text-white`}>2020</th>
<th className={`${tableStyles.th} bg-[#3CB59A] text-white`}>2021</th>
<th className={`${tableStyles.th} bg-[#50B880] text-white`}>2022</th>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white`}>2023</th>
</tr>
</thead>
<tbody>
{[
{
group: "전체",
group: '전체',
rows: [
[
"개소수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>",
"671",
"681",
"698",
"704",
"714",
"717",
],
[
"하수처리량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>",
"7,163.7",
"7,140.4",
"7,375.6",
"7,335.8",
"7,378.9",
"7,537.2",
],
[
"재이용량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>",
"1,112.7",
"1,148.7",
"1,148.7",
"1,127.0",
"1,136.8",
"1,137.5",
],
[
"재이용률 <span class='text-[13px] font-normal text-[#767676]'>(%)</span>",
"17.4",
"16.5",
"15.5",
"14.1",
"14.3",
"14.4",
],
["개소수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>", '671', '681', '698', '704', '714', '717'],
["하수처리량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>", '7,163.7', '7,140.4', '7,375.6', '7,335.8', '7,378.9', '7,537.2'],
["재이용량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>", '1,112.7', '1,148.7', '1,148.7', '1,127.0', '1,136.8', '1,137.5'],
["재이용률 <span class='text-[13px] font-normal text-[#767676]'>(%)</span>", '17.4', '16.5', '15.5', '14.1', '14.3', '14.4'],
],
},
{
group: "법적 대상",
group: '법적 대상',
rows: [
[
"개소수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>",
"68",
"82",
"94",
"97",
"101",
"118",
],
[
"하수처리량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>",
"761.2",
"839.0",
"935.3",
"1,158.7",
"1,220.6",
"1,473.6",
],
[
"재이용량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>",
"162.2",
"190.6",
"218.5",
"244.6",
"247.2",
"324.9",
],
[
"재이용률 <span class='text-[13px] font-normal text-[#767676]'>(%)</span>",
"24.7",
"25.4",
"27.8",
"25.7",
"24.2",
"24.1",
],
["개소수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>", '68', '82', '94', '97', '101', '118'],
["하수처리량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>", '761.2', '839.0', '935.3', '1,158.7', '1,220.6', '1,473.6'],
["재이용량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>", '162.2', '190.6', '218.5', '244.6', '247.2', '324.9'],
["재이용률 <span class='text-[13px] font-normal text-[#767676]'>(%)</span>", '24.7', '25.4', '27.8', '25.7', '24.2', '24.1'],
],
},
{
group: "법적 미대상",
group: '법적 미대상',
rows: [
[
"개소수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>",
"603",
"599",
"604",
"607",
"613",
"599",
],
[
"하수처리량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>",
"6,402.5",
"6,301.4",
"6,440.3",
"6,177.1",
"6,158.3",
"6,063.5",
],
[
"재이용량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>",
"950.6",
"958.1",
"926.2",
"882.4",
"889.5",
"812.7",
],
[
"재이용률 <span class='text-[13px] font-normal text-[#767676]'>(%)</span>",
"16.5",
"15.3",
"13.6",
"12.2",
"12.6",
"12.5",
],
["개소수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>", '603', '599', '604', '607', '613', '599'],
["하수처리량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>", '6,402.5', '6,301.4', '6,440.3', '6,177.1', '6,158.3', '6,063.5'],
["재이용량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>", '950.6', '958.1', '926.2', '882.4', '889.5', '812.7'],
["재이용률 <span class='text-[13px] font-normal text-[#767676]'>(%)</span>", '16.5', '15.3', '13.6', '12.2', '12.6', '12.5'],
],
},
].map((section, sIdx) => (
<React.Fragment key={sIdx}>
{section.rows.map((row, rIdx) => (
<tr key={`${sIdx}-${rIdx}`} className="bg-white">
<tr key={`${sIdx}-${rIdx}`} className='bg-white'>
{rIdx === 0 && (
<td
rowSpan={section.rows.length}
className={`${tableStyles.td} ${tableStyles.headerBg} !text-[18px] !font-bold !text-neutral-900`}
>
<td rowSpan={section.rows.length} className={`${tableStyles.td} ${tableStyles.headerBg} !text-[18px] !font-bold !text-neutral-900`}>
{section.group}
</td>
)}
@@ -234,9 +116,7 @@ export default function SewageStatusSection() {
return (
<td
key={i}
className={`${tableStyles.td} ${
isFirstCol ? "!font-bold !text-neutral-900" : ""
} ${isHighlightedCol ? "bg-[#EAF5F5]" : ""}`}
className={`${tableStyles.td} ${isFirstCol ? '!font-bold !text-neutral-900' : ''} ${isHighlightedCol ? 'bg-[#EAF5F5]' : ''}`}
dangerouslySetInnerHTML={{ __html: val }}
/>
);
@@ -253,17 +133,17 @@ export default function SewageStatusSection() {
</div>
</section>
<Hr className="my-11" />
<Hr className='my-11' />
{/* 2) 용도별 하수처리수 재이용 현황 */}
<section id="status-treated-usage" className="scroll-mt-[120px]">
<section id='status-treated-usage' className='scroll-mt-[120px]'>
<SectionTitle>용도별 하수처리수 재이용 현황</SectionTitle>
<div className="mt-8">
<div className='mt-8'>
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<colgroup>
<col style={{ width: "180px" }} />
<col style={{ width: '180px' }} />
<col />
<col />
<col />
@@ -273,88 +153,59 @@ export default function SewageStatusSection() {
</colgroup>
<thead>
<tr>
<th className={`${tableStyles.th} ${tableStyles.headerBg}`}>
구분
</th>
<th className={`${tableStyles.th} bg-[#01ACE5] text-white`}>
2018
</th>
<th className={`${tableStyles.th} bg-[#14AFCD] text-white`}>
2019
</th>
<th className={`${tableStyles.th} bg-[#28B2B4] text-white`}>
2020
</th>
<th className={`${tableStyles.th} bg-[#3CB59A] text-white`}>
2021
</th>
<th className={`${tableStyles.th} bg-[#50B880] text-white`}>
2022
</th>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white`}>
2023
</th>
<th className={`${tableStyles.th} ${tableStyles.headerBg}`}>구분</th>
<th className={`${tableStyles.th} bg-[#01ACE5] text-white`}>2018</th>
<th className={`${tableStyles.th} bg-[#14AFCD] text-white`}>2019</th>
<th className={`${tableStyles.th} bg-[#28B2B4] text-white`}>2020</th>
<th className={`${tableStyles.th} bg-[#3CB59A] text-white`}>2021</th>
<th className={`${tableStyles.th} bg-[#50B880] text-white`}>2022</th>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white`}>2023</th>
</tr>
</thead>
<tbody>
{[
{ label: "총 재이용량", type: "total", values: ["1,112.7", "1,148.7", "1,144.7", "1,127.0", "1,136.8", "1,137.5"] },
{ label: "장내 재이용", type: "category", values: ["520.6", "519.8", "513.9", "520.3", "502.2", "490.0"] },
{ label: "세척수", type: "red-item", values: ["177.9", "174.4", "169.4", "180.9", "196.8", "191.2"] },
{ label: "냉각수", type: "red-item", values: ["45.5", "46.8", "50.4", "40.6", "38.3", "41.7"] },
{ label: "청소수", type: "red-item", values: ["17.6", "21.2", "21.2", "21.7", "20.8", "20.5"] },
{ label: "조경용수", type: "red-item", values: ["0.1", "0.1", "-", "-", "-", "-"] },
{ label: "회생용수", type: "red-item", values: ["15.8", "16.7", "17.8", "15.7", "14.4", "14.0"] },
{ label: "장내급수도", type: "red-item", values: ["2.4", "-", "-", "-", "-", "-"] },
{ label: "장내기타용수", type: "red-item", values: ["261.3", "260.6", "255.0", "261.1", "231.8", "222.6"] },
{ label: "장외 재이용", type: "category", values: ["592.1", "628.9", "630.8", "606.7", "634.6", "647.5"] },
{ label: "청소·화장실용수", type: "blue-item", values: ["0.6", "0.4", "0.4", "0.4", "0.4", "0.4"] },
{ label: "세척·살수용수", type: "blue-item", values: ["5.6", "9.9", "2.9", "2.1", "3.0", "3.6"] },
{ label: "조경용수", type: "blue-item", values: ["10.3", "16.4", "5.9", "4.5", "8.9", "9.5"] },
{ label: "친수용수", type: "blue-item", values: ["7.8", "3.1", "3.1", "3.0", "10.9", "10.5"] },
{ label: "하천유지용수", type: "blue-item", values: ["480.5", "482.5", "496.2", "442.7", "471.7", "484.0"] },
{ label: "농업용수", type: "blue-item", values: ["12.2", "11.9", "13.4", "10.7", "10.6", "11.4"] },
{ label: "지하수 충전", type: "blue-item", values: ["0.0", "0.0", "0.0", "13.4", "0.0", "0.0"] },
{ label: "공업용수", type: "blue-item", values: ["75.2", "104.6", "108.9", "129.9", "129.0", "128.1"] },
{ label: '총 재이용량', type: 'total', values: ['1,112.7', '1,148.7', '1,144.7', '1,127.0', '1,136.8', '1,137.5'] },
{ label: '장내 재이용', type: 'category', values: ['520.6', '519.8', '513.9', '520.3', '502.2', '490.0'] },
{ label: '세척수', type: 'red-item', values: ['177.9', '174.4', '169.4', '180.9', '196.8', '191.2'] },
{ label: '냉각수', type: 'red-item', values: ['45.5', '46.8', '50.4', '40.6', '38.3', '41.7'] },
{ label: '청소수', type: 'red-item', values: ['17.6', '21.2', '21.2', '21.7', '20.8', '20.5'] },
{ label: '조경용수', type: 'red-item', values: ['0.1', '0.1', '-', '-', '-', '-'] },
{ label: '회생용수', type: 'red-item', values: ['15.8', '16.7', '17.8', '15.7', '14.4', '14.0'] },
{ label: '장내급수도', type: 'red-item', values: ['2.4', '-', '-', '-', '-', '-'] },
{ label: '장내기타용수', type: 'red-item', values: ['261.3', '260.6', '255.0', '261.1', '231.8', '222.6'] },
{ label: '장외 재이용', type: 'category', values: ['592.1', '628.9', '630.8', '606.7', '634.6', '647.5'] },
{ label: '청소·화장실용수', type: 'blue-item', values: ['0.6', '0.4', '0.4', '0.4', '0.4', '0.4'] },
{ label: '세척·살수용수', type: 'blue-item', values: ['5.6', '9.9', '2.9', '2.1', '3.0', '3.6'] },
{ label: '조경용수', type: 'blue-item', values: ['10.3', '16.4', '5.9', '4.5', '8.9', '9.5'] },
{ label: '친수용수', type: 'blue-item', values: ['7.8', '3.1', '3.1', '3.0', '10.9', '10.5'] },
{ label: '하천유지용수', type: 'blue-item', values: ['480.5', '482.5', '496.2', '442.7', '471.7', '484.0'] },
{ label: '농업용수', type: 'blue-item', values: ['12.2', '11.9', '13.4', '10.7', '10.6', '11.4'] },
{ label: '지하수 충전', type: 'blue-item', values: ['0.0', '0.0', '0.0', '13.4', '0.0', '0.0'] },
{ label: '공업용수', type: 'blue-item', values: ['75.2', '104.6', '108.9', '129.9', '129.0', '128.1'] },
].map((row, idx) => {
const isSubItem = row.type === "red-item" || row.type === "blue-item";
const isSubItem = row.type === 'red-item' || row.type === 'blue-item';
const isLastInGroup = idx === 8 || idx === 17;
return (
<tr key={idx} className="bg-white">
<tr key={idx} className='bg-white'>
<td className={`${tableStyles.td} relative`}>
{isSubItem ? (
<>
<div className={`absolute left-[1px] w-[16px] bg-white z-10 top-[-2px] ${isLastInGroup ? 'bottom-[0px]' : 'bottom-[-2px]'}`} />
<div
className={`absolute left-[1px] w-[16px] bg-white z-10 top-[-2px] ${
isLastInGroup ? "bottom-[0px]" : "bottom-[-2px]"
className={`absolute left-[16px] w-[4px] z-20 ${row.type === 'red-item' ? 'bg-[#FF8B8B]' : 'bg-[#4ECBFF]'} top-[-2px] ${
isLastInGroup ? 'bottom-[0px]' : 'bottom-[-2px]'
}`}
/>
<div
className={`absolute left-[16px] w-[4px] z-20 ${
row.type === "red-item" ? "bg-[#FF8B8B]" : "bg-[#4ECBFF]"
} top-[-2px] ${
isLastInGroup ? "bottom-[0px]" : "bottom-[-2px]"
}`}
/>
<span className="text-[#4f4f4f] font-bold block w-full text-center relative z-30 translate-x-[8px]">
{row.label}
</span>
<span className='text-[#4f4f4f] font-bold block w-full text-center relative z-30 translate-x-[8px]'>{row.label}</span>
</>
) : (
<span className="font-bold text-neutral-900 block w-full text-center">
{row.label}
</span>
<span className='font-bold text-neutral-900 block w-full text-center'>{row.label}</span>
)}
</td>
{row.values.map((val, i) => (
<td
key={i}
className={`${tableStyles.td} ${
[0, 2, 4].includes(i) ? "bg-[#EAF5F5]" : ""
}`}
>
<td key={i} className={`${tableStyles.td} ${[0, 2, 4].includes(i) ? 'bg-[#EAF5F5]' : ''}`}>
{val}
</td>
))}
@@ -369,18 +220,18 @@ export default function SewageStatusSection() {
</div>
</section>
<Hr className="my-11" />
<Hr className='my-11' />
{/* 3) 하수처리시설 설치 및 이용 현황 */}
<section id="status-treated-facility" className="scroll-mt-[120px]">
<section id='status-treated-facility' className='scroll-mt-[120px]'>
<SectionTitle>하수처리시설 설치 및 이용 현황</SectionTitle>
<div className="mt-8">
<div className='mt-8'>
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<colgroup>
<col style={{ width: "130px" }} />
<col style={{ width: "160px" }} />
<col style={{ width: '130px' }} />
<col style={{ width: '160px' }} />
<col />
<col />
<col />
@@ -390,139 +241,49 @@ export default function SewageStatusSection() {
</colgroup>
<thead>
<tr>
<th
colSpan={2}
className={`${tableStyles.th} ${tableStyles.headerBg}`}
>
<th colSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
구분
</th>
<th className={`${tableStyles.th} bg-[#01ACE5] text-white`}>
2018
</th>
<th className={`${tableStyles.th} bg-[#14AFCD] text-white`}>
2019
</th>
<th className={`${tableStyles.th} bg-[#28B2B4] text-white`}>
2020
</th>
<th className={`${tableStyles.th} bg-[#3CB59A] text-white`}>
2021
</th>
<th className={`${tableStyles.th} bg-[#50B880] text-white`}>
2022
</th>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white`}>
2023
</th>
<th className={`${tableStyles.th} bg-[#01ACE5] text-white`}>2018</th>
<th className={`${tableStyles.th} bg-[#14AFCD] text-white`}>2019</th>
<th className={`${tableStyles.th} bg-[#28B2B4] text-white`}>2020</th>
<th className={`${tableStyles.th} bg-[#3CB59A] text-white`}>2021</th>
<th className={`${tableStyles.th} bg-[#50B880] text-white`}>2022</th>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white`}>2023</th>
</tr>
</thead>
<tbody>
{[
{
group: "전체",
group: '전체',
rows: [
[
"시설수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>",
"671",
"681",
"698",
"704",
"714",
"717",
],
[
"시설용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>",
"25,846",
"26,067",
"26,244",
"26,492",
"26,586",
"26,766",
],
[
"처리용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>",
"1,112.7",
"1,148.7",
"1,144.7",
"1,127.0",
"1,136.8",
"1,137.5",
],
["시설수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>", '671', '681', '698', '704', '714', '717'],
["시설용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>", '25,846', '26,067', '26,244', '26,492', '26,586', '26,766'],
["처리용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>", '1,112.7', '1,148.7', '1,144.7', '1,127.0', '1,136.8', '1,137.5'],
],
},
{
group: "법적 대상",
group: '법적 대상',
rows: [
[
"시설수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>",
"68",
"82",
"94",
"97",
"101",
"118",
],
[
"시설용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>",
"3,088.9",
"3,359.2",
"3,612.6",
"4,412.2",
"4,692.3",
"5,503.8",
],
[
"처리용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>",
"162.2",
"190.6",
"218.5",
"244.6",
"247.2",
"324.9",
],
["시설수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>", '68', '82', '94', '97', '101', '118'],
["시설용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>", '3,088.9', '3,359.2', '3,612.6', '4,412.2', '4,692.3', '5,503.8'],
["처리용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>", '162.2', '190.6', '218.5', '244.6', '247.2', '324.9'],
],
},
{
group: "법적 미대상",
group: '법적 미대상',
rows: [
[
"시설수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>",
"603",
"599",
"604",
"607",
"613",
"599",
],
[
"시설용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>",
"22,757",
"22,708",
"22,631",
"22,080",
"21,894",
"21,262",
],
[
"처리용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>",
"950.6",
"958.1",
"926.2",
"882.4",
"889.5",
"812.7",
],
["시설수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>", '603', '599', '604', '607', '613', '599'],
["시설용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>", '22,757', '22,708', '22,631', '22,080', '21,894', '21,262'],
["처리용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>", '950.6', '958.1', '926.2', '882.4', '889.5', '812.7'],
],
},
].map((section, sIdx) => (
<React.Fragment key={sIdx}>
{section.rows.map((row, rIdx) => (
<tr key={`${sIdx}-${rIdx}`} className="bg-white">
<tr key={`${sIdx}-${rIdx}`} className='bg-white'>
{rIdx === 0 && (
<td
rowSpan={section.rows.length}
className={`${tableStyles.td} ${tableStyles.headerBg} !text-[18px] !font-bold !text-neutral-900`}
>
<td rowSpan={section.rows.length} className={`${tableStyles.td} ${tableStyles.headerBg} !text-[18px] !font-bold !text-neutral-900`}>
{section.group}
</td>
)}
@@ -533,9 +294,7 @@ export default function SewageStatusSection() {
return (
<td
key={i}
className={`${tableStyles.td} ${
isFirstCol ? "!font-bold !text-neutral-900" : ""
} ${isHighlightedCol ? "bg-[#EAF5F5]" : ""}`}
className={`${tableStyles.td} ${isFirstCol ? '!font-bold !text-neutral-900' : ''} ${isHighlightedCol ? 'bg-[#EAF5F5]' : ''}`}
dangerouslySetInnerHTML={{ __html: val }}
/>
);
@@ -552,24 +311,19 @@ export default function SewageStatusSection() {
</div>
</section>
<Hr className="my-11" />
<Hr className='my-11' />
{/* 4) 시도별 하수처리수 재이용 현황 */}
<section
id="status-treated-region"
className="scroll-mt-[120px]"
>
<section id='status-treated-region' className='scroll-mt-[120px]'>
<SectionTitle>시도별 하수처리수 재이용 현황</SectionTitle>
<div className="mt-8">
<div className='mt-8'>
<div className={tableStyles.container}>
<div className="mb-2 text-right text-[13px] text-neutral-600">
(단위: 백만m³/일)
</div>
<div className='mb-2 text-right text-[13px] text-neutral-600'>(단위: 백만m³/년)</div>
<table className={tableStyles.table}>
<colgroup>
<col style={{ width: "160px" }} />
<col style={{ width: '160px' }} />
<col />
<col />
<col />
@@ -583,110 +337,71 @@ export default function SewageStatusSection() {
</colgroup>
<thead>
<tr>
<th
rowSpan={3}
className={`${tableStyles.th} ${tableStyles.headerBg}`}
>
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
시도
</th>
<th
rowSpan={3}
className={`${tableStyles.th} ${tableStyles.headerBg}`}
>
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
연간
<br />
하수
<br />
처리량
</th>
<th
colSpan={7}
className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}
>
<th colSpan={7} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}>
재이용현황
</th>
<th
rowSpan={3}
className={`${tableStyles.th} ${tableStyles.headerBg}`}
>
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
재이용률
<br />
(%)
</th>
<th
rowSpan={3}
className={`${tableStyles.th} ${tableStyles.headerBg}`}
>
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
비고
</th>
</tr>
<tr>
<th
rowSpan={2}
className={`${tableStyles.th} ${tableStyles.headerBg}`}
>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
계
</th>
<th
rowSpan={2}
className={`${tableStyles.th} ${tableStyles.headerBg}`}
>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
장내
</th>
<th
colSpan={5}
className={`${tableStyles.th} ${tableStyles.headerBg} py-1`}
>
<th colSpan={5} className={`${tableStyles.th} ${tableStyles.headerBg} py-1`}>
장외
</th>
</tr>
<tr>
<th className={`${tableStyles.th} bg-[#01ACE5] text-white py-2 text-[16px]`}>
소계
</th>
<th className={`${tableStyles.th} bg-[#1FB1C2] text-white py-2 text-[13px]`}>
하천유지용수
</th>
<th className={`${tableStyles.th} bg-[#3EB59F] text-white py-2 text-[16px]`}>
공업용수
</th>
<th className={`${tableStyles.th} bg-[#5DB97C] text-white py-2 text-[16px]`}>
농업용수
</th>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white py-2 text-[16px]`}>
기타용수
</th>
<th className={`${tableStyles.th} bg-[#01ACE5] text-white py-2 text-[16px]`}>소계</th>
<th className={`${tableStyles.th} bg-[#1FB1C2] text-white py-2 text-[13px]`}>하천유지용수</th>
<th className={`${tableStyles.th} bg-[#3EB59F] text-white py-2 text-[16px]`}>공업용수</th>
<th className={`${tableStyles.th} bg-[#5DB97C] text-white py-2 text-[16px]`}>농업용수</th>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white py-2 text-[16px]`}>기타용수</th>
</tr>
</thead>
<tbody>
{[
["합계", "7,537.2", "1,137.5", "490.0", "647.5", "484.0", "128.1", "11.4", "24.0", "15.1%", ""],
["서울특별시", "1,567.0", "114.9", "59.3", "55.6", "55.1", "-", "-", "0.5", "7.3%", ""],
["부산광역시", "491.5", "146.2", "120.7", "25.5", "25.3", "-", "-", "0.2", "29.7%", ""],
["대구광역시", "432.7", "102.4", "38.2", "64.2", "64.1", "-", "-", "0.1", "23.7%", ""],
["인천광역시", "302.3", "57.7", "11.6", "46.1", "29.2", "12.1", "0.5", "4.3", "19.1%", ""],
["광주광역시", "211.1", "28.7", "6.5", "22.3", "22.2", "-", "-", "0.1", "13.6%", ""],
["대전광역시", "244.6", "11.9", "11.9", "0.68", "0.0", "-", "-", "0.1", "4.9%", ""],
["울산광역시", "235.1", "36.1", "6.1", "29.9", "29.7", "0.2", "-", "-", "15.3%", ""],
["세종특별자치시", "38.5", "2.5", "2.0", "0.5", "0.4", "-", "0.2", "-", "6.6%", ""],
["경기도", "1,926.8", "313.3", "122.2", "191.0", "153.0", "33.3", "0.4", "4.3", "16.3%", ""],
["강원도", "230.4", "19.6", "15.4", "4.2", "2.1", "-", "-", "2.1", "8.5%", ""],
["충청북도", "190.9", "15.6", "6.3", "9.2", "7.4", "0.6", "-", "1.2", "8.2%", ""],
["충청남도", "241.5", "41.4", "9.8", "31.5", "17.7", "8.6", "4.5", "0.8", "17.1%", ""],
["전라북도", "292.4", "42.7", "7.5", "35.1", "33.5", "0.2", "1.0", "0.5", "14.6%", ""],
["전라남도", "254.0", "23.6", "12.3", "11.3", "8.7", "0.0", "1.0", "1.6", "9.3%", ""],
["경상북도", "378.4", "129.1", "33.7", "95.3", "20.8", "72.5", "0.8", "1.3", "34.1%", ""],
["경상남도", "411.3", "50.1", "25.0", "25.0", "14.7", "0.5", "3.0", "6.9", "12.2%", ""],
["제주특별자치도", "88.6", "1.7", "1.5", "0.12", "-", "-", "0.1", "-", "1.9%", ""],
['합계', '7,537.2', '1,137.5', '490.0', '647.5', '484.0', '128.1', '11.4', '24.0', '15.1%', ''],
['서울특별시', '1,567.0', '114.9', '59.3', '55.6', '55.1', '-', '-', '0.5', '7.3%', ''],
['부산광역시', '491.5', '146.2', '120.7', '25.5', '25.3', '-', '-', '0.2', '29.7%', ''],
['대구광역시', '432.7', '102.4', '38.2', '64.2', '64.1', '-', '-', '0.1', '23.7%', ''],
['인천광역시', '302.3', '57.7', '11.6', '46.1', '29.2', '12.1', '0.5', '4.3', '19.1%', ''],
['광주광역시', '211.1', '28.7', '6.5', '22.3', '22.2', '-', '-', '0.1', '13.6%', ''],
['대전광역시', '244.6', '11.9', '11.9', '0.68', '0.0', '-', '-', '0.1', '4.9%', ''],
['울산광역시', '235.1', '36.1', '6.1', '29.9', '29.7', '0.2', '-', '-', '15.3%', ''],
['세종특별자치시', '38.5', '2.5', '2.0', '0.5', '0.4', '-', '0.2', '-', '6.6%', ''],
['경기도', '1,926.8', '313.3', '122.2', '191.0', '153.0', '33.3', '0.4', '4.3', '16.3%', ''],
['강원도', '230.4', '19.6', '15.4', '4.2', '2.1', '-', '-', '2.1', '8.5%', ''],
['충청북도', '190.9', '15.6', '6.3', '9.2', '7.4', '0.6', '-', '1.2', '8.2%', ''],
['충청남도', '241.5', '41.4', '9.8', '31.5', '17.7', '8.6', '4.5', '0.8', '17.1%', ''],
['전라북도', '292.4', '42.7', '7.5', '35.1', '33.5', '0.2', '1.0', '0.5', '14.6%', ''],
['전라남도', '254.0', '23.6', '12.3', '11.3', '8.7', '0.0', '1.0', '1.6', '9.3%', ''],
['경상북도', '378.4', '129.1', '33.7', '95.3', '20.8', '72.5', '0.8', '1.3', '34.1%', ''],
['경상남도', '411.3', '50.1', '25.0', '25.0', '14.7', '0.5', '3.0', '6.9', '12.2%', ''],
['제주특별자치도', '88.6', '1.7', '1.5', '0.12', '-', '-', '0.1', '-', '1.9%', ''],
].map((row, idx) => (
<tr key={idx} className="bg-white">
<tr key={idx} className='bg-white'>
{row.map((val, i) => (
<td
key={i}
className={`${tableStyles.td} ${
i === 0 ? "!font-bold !text-neutral-900" : ""
} ${[1, 3, 9].includes(i) ? "bg-[#EAF5F5]" : ""}`}
>
<td key={i} className={`${tableStyles.td} ${i === 0 ? '!font-bold !text-neutral-900' : ''} ${[1, 3, 9].includes(i) ? 'bg-[#EAF5F5]' : ''}`}>
{val}
</td>
))}
@@ -702,4 +417,4 @@ export default function SewageStatusSection() {
</div>
</div>
);
}
}
@@ -1,123 +1,92 @@
"use client";
'use client';
import React from "react";
import Image, { type StaticImageData } from "next/image";
import React from 'react';
import Image, { type StaticImageData } from 'next/image';
import lawPlan from "@/public/images/landing/intro/law_timeline.png";
import planPerformance from "@/public/images/landing/intro/performance_left.png";
import domesticChange from "@/public/images/landing/intro/domestic_textbox.png";
import policyHistory from "@/public/images/landing/intro/policy_timeline.png";
import lawRelation from "@/public/images/landing/intro/rel_note.png";
import domesticLinkage from "@/public/images/landing/intro/linkage.png";
import lawPlan from '@/public/images/landing/intro/law_timeline.png';
import planPerformance from '@/public/images/landing/intro/performance_left.png';
import domesticChange from '@/public/images/landing/intro/domestic_textbox.png';
import policyHistory from '@/public/images/landing/intro/policy_timeline.png';
import lawRelation from '@/public/images/landing/intro/rel_note.png';
import domesticLinkage from '@/public/images/landing/intro/linkage.png';
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
<div className='flex items-center gap-3'>
<span className='h-5 w-[3px] bg-sky-500' />
<h3 className='text-[24px] font-bold text-neutral-900'>{children}</h3>
</div>
);
}
function Hr({ className = "" }: { className?: string }) {
function Hr({ className = '' }: { className?: string }) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
function ImgCard({ src, alt }: { src: StaticImageData; alt: string }) {
return (
<div className="w-full overflow-x-auto">
<Image
src={src}
alt={alt}
className="mx-auto block max-w-none"
style={{ width: "auto", height: "auto" }}
/>
<div className='w-full overflow-x-auto'>
<Image src={src} alt={alt} className='mx-auto block max-w-none' style={{ width: 'auto', height: 'auto' }} />
</div>
);
}
function SectionShell({
id,
title,
children,
className = "",
}: {
id: string;
title: string;
children: React.ReactNode;
className?: string;
}) {
function SectionShell({ id, title, children, className = '' }: { id: string; title: string; children: React.ReactNode; className?: string }) {
return (
<section id={id} className={`scroll-mt-[120px] ${className}`}>
<SectionTitle>{title}</SectionTitle>
<div className="mt-8">{children}</div>
<div className='mt-8'>{children}</div>
</section>
);
}
export default function LawIntroSection() {
const title = "물 재이용 법";
const title = '물 재이용 법';
return (
<div className="bg-white">
<div className="inline-block">
<h2 className="relative inline-block px-2 text-[28px] font-extrabold leading-none tracking-tight text-slate-900">
<span className="relative z-10">{title}</span>
<span className="absolute bottom-[-4px] left-0 z-0 h-3 w-full bg-gradient-to-r from-sky-300 to-green-300" />
<div className='bg-white'>
<div className='inline-block'>
<h2 className='relative inline-block px-2 text-[28px] font-extrabold leading-none tracking-tight text-slate-900'>
<span className='relative z-10'>{title}</span>
<span className='absolute bottom-[-4px] left-0 z-0 h-3 w-full bg-gradient-to-r from-sky-300 to-green-300' />
</h2>
</div>
<div className="mt-10">
<SectionShell
id="law-plan"
title="법률 제·개정 및 기본계획 수립"
className="mt-10"
>
<ImgCard src={lawPlan} alt="법률 제·개정 및 기본계획 수립" />
<div className='mt-10'>
<SectionShell id='law-plan' title='법률 제·개정 및 기본계획 수립' className='mt-10'>
<ImgCard src={lawPlan} alt='법률 제·개정 및 기본계획 수립' />
</SectionShell>
<Hr className="my-11" />
<Hr className='my-11' />
<SectionShell
id="plan-performance"
title="제2차 계획 대비 실적 현황"
>
<ImgCard src={planPerformance} alt="제2차 계획 대비 실적 현황" />
<SectionShell id='plan-performance' title='제2차 계획 대비 실적 현황'>
<ImgCard src={planPerformance} alt='제2차 계획 대비 실적 현황' />
</SectionShell>
<Hr className="my-11" />
<Hr className='my-11' />
<SectionShell id="domestic-change" title="우리나라의 수자원 현황 및 물이용 여건">
<ImgCard src={domesticChange} alt="우리나라의 수자원 현황 및 물이용 여건" />
<SectionShell id='domestic-change' title='우리나라의 수자원 현황 및 물이용 여건'>
<ImgCard src={domesticChange} alt='우리나라의 수자원 현황 및 물이용 여건' />
</SectionShell>
<Hr className="my-11" />
<Hr className='my-11' />
<SectionShell
id="policy-history"
title="물 재이용 정책 변화 추이"
>
<ImgCard src={policyHistory} alt="물 재이용 정책 변화 추이" />
<SectionShell id='policy-history' title='물 재이용 정책 변화 추이'>
<ImgCard src={policyHistory} alt='물 재이용 정책 변화 추이' />
</SectionShell>
<Hr className="my-11" />
<Hr className='my-11' />
<SectionShell
id="law-relation"
title="타법령과의 관계 검토"
>
<ImgCard src={lawRelation} alt="타법령과의 관계 검토" />
<SectionShell id='law-relation' title='타법령과의 관계 검토'>
<ImgCard src={lawRelation} alt='타법령과의 관계 검토' />
</SectionShell>
<Hr className="my-11" />
<Hr className='my-11' />
<SectionShell
id="domestic-linkage"
title="주요 국가계획의 정책방향과 연계한 물 재이용 정책 필요"
>
<ImgCard src={domesticLinkage} alt="주요 국가계획의 정책방향과 연계한 물 재이용 정책 필요" />
<SectionShell id='domestic-linkage' title='주요 국가계획의 정책방향과 연계한 물 재이용 정책 필요'>
<ImgCard src={domesticLinkage} alt='주요 국가계획의 정책방향과 연계한 물 재이용 정책 필요' />
</SectionShell>
</div>
</div>
);
}
}
+44 -52
View File
@@ -1,20 +1,20 @@
// apps/dm/app/(landing)/reuse/intro/law/page.tsx
"use client";
'use client';
import Image, { type StaticImageData } from "next/image";
import SubPageHeader from "../../_components/SubPageHeader";
import lawTimeline from "@/public/images/landing/intro/law_timeline.png";
import performanceLeft from "@/public/images/landing/intro/performance_left.png";
import domesticTextbox from "@/public/images/landing/intro/domestic_textbox.png";
import policyTimeline from "@/public/images/landing/intro/policy_timeline.png";
import relNote from "@/public/images/landing/intro/rel_note.png";
import linkage from "@/public/images/landing/intro/linkage.png";
import Image, { type StaticImageData } from 'next/image';
import SubPageHeader from '../../_components/SubPageHeader';
import lawTimeline from '@/public/images/landing/intro/law_timeline.png';
import performanceLeft from '@/public/images/landing/intro/performance_left.png';
import domesticTextbox from '@/public/images/landing/intro/domestic_textbox.png';
import policyTimeline from '@/public/images/landing/intro/policy_timeline.png';
import relNote from '@/public/images/landing/intro/rel_note.png';
import linkage from '@/public/images/landing/intro/linkage.png';
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
<div className='flex items-center gap-3'>
<span className='h-5 w-[3px] bg-sky-500' />
<h3 className='text-[24px] font-bold text-neutral-900'>{children}</h3>
</div>
);
}
@@ -23,7 +23,7 @@ type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
function Hr({ className = '' }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
@@ -34,95 +34,87 @@ type ImgCardProps = {
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<Image
src={src}
alt={alt}
className="block mx-auto max-w-none"
style={{ width: "auto", height: "auto" }}
/>
<div className='w-full overflow-x-auto'>
<Image src={src} alt={alt} className='block mx-auto max-w-none' style={{ width: 'auto', height: 'auto' }} />
</div>
);
}
export default function WaterReuseLawPage() {
return (
<div className="bg-white">
<div className='bg-white'>
<SubPageHeader
title="물 재이용 법"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 소개", href: "/reuse/intro/law" },
{ label: "물 재이용 법" },
]}
title='물 재이용 법'
crumbs={[{ label: 'Home', href: '/reuse' }, { label: '물 재이용 소개', href: '/reuse/intro/law' }, { label: '물 재이용 법' }]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
wrapClassName='!pb-0'
/>
<Hr className="my-7" />
<Hr className='my-7' />
{/* 1) 법제도/계획 수립 타임라인 */}
<section className="mt-6">
<section className='mt-6'>
<SectionTitle>법제도 · 계획 및 기본계획 수립</SectionTitle>
<div className="mt-8">
<ImgCard src={lawTimeline} alt="법제도 타임라인" />
<div className='mt-8'>
<ImgCard src={lawTimeline} alt='법제도 타임라인' />
</div>
</section>
<Hr className="my-11" />
<Hr className='my-11' />
{/* 2) 제2차 계획 대비 실적 현황 (2컬럼) */}
<section>
<SectionTitle>제 2차 계획 대비 실적 현황</SectionTitle>
<div className="mt-8">
<ImgCard src={performanceLeft} alt="실적 현황 요약" />
<div className='mt-8'>
<ImgCard src={performanceLeft} alt='실적 현황 요약' />
</div>
</section>
<Hr className="my-11" />
<Hr className='my-11' />
{/* 3) 국내 관련 현황 */}
<section>
<SectionTitle>국내 관련 현황</SectionTitle>
<div className="mt-8">
<ImgCard src={domesticTextbox} alt="국내 관련 현황 설명" />
<div className='mt-8'>
<ImgCard src={domesticTextbox} alt='국내 관련 현황 설명' />
</div>
</section>
<Hr className="my-11" />
<Hr className='my-11' />
{/* 4) 정책 변천 추이 */}
<section>
<SectionTitle>물 재이용 정책 변천 추이</SectionTitle>
<div className="mt-8">
<ImgCard src={policyTimeline} alt="정책 변천 타임라인" />
<div className='mt-8'>
<ImgCard src={policyTimeline} alt='정책 변천 타임라인' />
</div>
</section>
<Hr className="my-11" />
<Hr className='my-11' />
{/* 5) 타 법령과의 관계도 */}
<section>
<SectionTitle>타법령과의 관계도</SectionTitle>
<div className="mt-8">
<ImgCard src={relNote} alt="관계도 설명" />
<div className='mt-8'>
<ImgCard src={relNote} alt='관계도 설명' />
</div>
</section>
<Hr className="my-11" />
<Hr className='my-11' />
{/* 6) 국내 연계 현황 */}
<section className="pb-[200px]">
<section className='pb-[200px]'>
<SectionTitle>국내 연계 현황</SectionTitle>
<div className="mt-8">
<div className="mx-[20px] mb-8">
<p className="text-[24px] font-semibold mb-2">주요 국가계획의 정책방향과 연계한 물 재이용 정책 필요</p>
<p className="text-[#4F4F4F]">국가 상위계획의 정책 방향이 기후위기 대응 등 수자원의 공급 안전성을 위한 물 재이용 활성화로 정책 실효성 제고 필요<br />
• 물관리 계획(국가수도기본계획, 국가 및 유역하수도종합계획, 하천유역수자원관리계획 등)과의 연계성 확보 → 통합적인 관점에서 물이용 체계 구축 필요
<div className='mt-8'>
<div className='mx-[20px] mb-8'>
<p className='text-[24px] font-semibold mb-2'>주요 국가계획의 정책방향과 연계한 물 재이용 정책 필요</p>
<p className='text-[#4F4F4F]'>
국가 상위계획의 정책 방향이 기후위기 대응 등 수자원의 공급 안전성을 위한 물 재이용 활성화로 정책 실효성 제고 필요
<br />• 물관리 계획(국가수도기본계획, 국가 및 유역하수도종합계획, 하천유역수자원관리계획 등)과의 연계성 확보 → 통합적인 관점에서 물이용 체계 구축 필요
</p>
</div>
<ImgCard src={linkage} alt="국내 연계 현황" />
<ImgCard src={linkage} alt='국내 연계 현황' />
</div>
</section>
</div>
@@ -1,4 +1,4 @@
"use client";
'use client';
export default function LawIntroSection() {
const contentsHtml = `
@@ -84,10 +84,5 @@ export default function LawIntroSection() {
</div>
`;
return (
<div
className="bg-white"
dangerouslySetInnerHTML={{ __html: contentsHtml }}
/>
);
return <div className='bg-white' dangerouslySetInnerHTML={{ __html: contentsHtml }} />;
}
+47 -89
View File
@@ -1,22 +1,22 @@
// DM 물 재이용 소개(물 재이용 법) 페이지
"use client";
'use client';
import Image, { type StaticImageData } from "next/image";
import SubPageHeader from "@/components/SubPageHeader";
import Image, { type StaticImageData } from 'next/image';
import SubPageHeader from '@/components/SubPageHeader';
// import BusinessSubNav from "@/components/BusinessSubNav";
import lawTimeline from "@/public/images/landing/intro/law_timeline.png";
import performanceLeft from "@/public/images/landing/intro/performance_left.png";
import domesticTextbox from "@/public/images/landing/intro/domestic_textbox.png";
import policyTimeline from "@/public/images/landing/intro/policy_timeline.png";
import relNote from "@/public/images/landing/intro/rel_note.png";
import linkage from "@/public/images/landing/intro/linkage.png";
import lawTimeline from '@/public/images/landing/intro/law_timeline.png';
import performanceLeft from '@/public/images/landing/intro/performance_left.png';
import domesticTextbox from '@/public/images/landing/intro/domestic_textbox.png';
import policyTimeline from '@/public/images/landing/intro/policy_timeline.png';
import relNote from '@/public/images/landing/intro/rel_note.png';
import linkage from '@/public/images/landing/intro/linkage.png';
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
<div className='flex items-center gap-3'>
<span className='h-5 w-[3px] bg-sky-500' />
<h3 className='text-[24px] font-bold text-neutral-900'>{children}</h3>
</div>
);
}
@@ -25,7 +25,7 @@ type HrProps = {
className?: string;
};
function Hr({ className = "" }: HrProps) {
function Hr({ className = '' }: HrProps) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
@@ -36,14 +36,8 @@ type ImgCardProps = {
function ImgCard({ src, alt }: ImgCardProps) {
return (
<div className="w-full overflow-x-auto">
<Image
src={src}
alt={alt}
className="mx-auto block max-w-none"
style={{ width: "auto", height: "auto" }}
priority={false}
/>
<div className='w-full overflow-x-auto'>
<Image src={src} alt={alt} className='mx-auto block max-w-none' style={{ width: 'auto', height: 'auto' }} priority={false} />
</div>
);
}
@@ -55,109 +49,73 @@ type SectionShellProps = {
className?: string;
};
function SectionShell({ id, title, children, className = "" }: SectionShellProps) {
function SectionShell({ id, title, children, className = '' }: SectionShellProps) {
return (
<section
id={id}
className={[
"scroll-mt-[140px]",
className,
].join(" ")}
>
<section id={id} className={['scroll-mt-[140px]', className].join(' ')}>
<SectionTitle>{title}</SectionTitle>
<div className="mt-8">{children}</div>
<div className='mt-8'>{children}</div>
</section>
);
}
export default function WaterReuseLawPage() {
return (
<div className="relative bg-white">
<div className='relative bg-white'>
{/* <BusinessSubNav /> */}
<SubPageHeader
title="물 재이용 법"
crumbs={[
{ label: "Home", href: "/main" },
{ label: "물 재이용 소개", href: "/intro/law" },
{ label: "물 재이용 법" },
]}
title='물 재이용 법'
crumbs={[{ label: 'Home', href: '/main' }, { label: '물 재이용 소개', href: '/intro/law' }, { label: '물 재이용 법' }]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
wrapClassName='!pb-0'
/>
<div className="pr-0">
<Hr className="my-7" />
<div className='pr-0'>
<Hr className='my-7' />
<SectionShell
id="law-plan"
title="법률 제·개정 및 기본계획 수립"
className="mt-6"
>
<ImgCard src={lawTimeline} alt="법률 제·개정 및 기본계획 수립" />
<SectionShell id='law-plan' title='법률 제·개정 및 기본계획 수립' className='mt-6'>
<ImgCard src={lawTimeline} alt='법률 제·개정 및 기본계획 수립' />
</SectionShell>
<Hr className="my-11" />
<Hr className='my-11' />
<SectionShell
id="plan-performance"
title="제2차 계획 대비 실적 현황"
>
<ImgCard src={performanceLeft} alt="제2차 계획 대비 실적 현황" />
<SectionShell id='plan-performance' title='제2차 계획 대비 실적 현황'>
<ImgCard src={performanceLeft} alt='제2차 계획 대비 실적 현황' />
</SectionShell>
<Hr className="my-11" />
<Hr className='my-11' />
<SectionShell
id="domestic-change"
title="국내 여건 변화"
>
<ImgCard src={domesticTextbox} alt="국내 여건 변화" />
<SectionShell id='domestic-change' title='국내 여건 변화'>
<ImgCard src={domesticTextbox} alt='국내 여건 변화' />
</SectionShell>
<Hr className="my-11" />
<Hr className='my-11' />
<SectionShell
id="policy-history"
title="물 재이용 정책 변화 추이"
>
<ImgCard src={policyTimeline} alt="물 재이용 정책 변화 추이" />
<SectionShell id='policy-history' title='물 재이용 정책 변화 추이'>
<ImgCard src={policyTimeline} alt='물 재이용 정책 변화 추이' />
</SectionShell>
<Hr className="my-11" />
<Hr className='my-11' />
<SectionShell
id="law-relation"
title="타법령과의 관계 검토"
>
<ImgCard src={relNote} alt="타법령과의 관계 검토" />
<SectionShell id='law-relation' title='타법령과의 관계 검토'>
<ImgCard src={relNote} alt='타법령과의 관계 검토' />
</SectionShell>
<Hr className="my-11" />
<Hr className='my-11' />
<SectionShell
id="domestic-linkage"
title="국내 연계 현황"
className="pb-[250px]"
>
<div className="mx-[20px] mb-8">
<p className="mb-2 text-[24px] font-semibold">
주요 국가계획의 정책방향과 연계한 물 재이용 정책 필요
</p>
<p className="text-[#4F4F4F]">
국가 상위계획의 정책 방향이 기후위기 대응 등 수자원의 공급 안전성을 위한
물 재이용 활성화로 정책 실효성 제고 필요
<br />
• 물관리 계획(국가수도기본계획, 국가 및 유역하수도종합계획,
하천유역수자원관리계획 등)과의 연계성 확보 → 통합적인 관점에서
물이용 체계 구축 필요
<SectionShell id='domestic-linkage' title='국내 연계 현황' className='pb-[250px]'>
<div className='mx-[20px] mb-8'>
<p className='mb-2 text-[24px] font-semibold'>주요 국가계획의 정책방향과 연계한 물 재이용 정책 필요</p>
<p className='text-[#4F4F4F]'>
국가 상위계획의 정책 방향이 기후위기 대응 등 수자원의 공급 안전성을 위한 물 재이용 활성화로 정책 실효성 제고 필요
<br />• 물관리 계획(국가수도기본계획, 국가 및 유역하수도종합계획, 하천유역수자원관리계획 등)과의 연계성 확보 → 통합적인 관점에서 물이용 체계 구축 필요
</p>
</div>
<ImgCard src={linkage} alt="국내 연계 현황" />
<ImgCard src={linkage} alt='국내 연계 현황' />
</SectionShell>
</div>
</div>
);
}
}
@@ -1,30 +1,24 @@
"use client";
'use client';
import { useEffect, useMemo } from "react";
import { useEffect, useMemo } from 'react';
// import SubPageHeader from "@/components/SubPageHeader";
import Image from "next/image";
import { useDmStore } from "@/app/store";
import { formatUsage } from "@/app/common/transValueFormat";
import type {
DmStatusBildTpYearsType,
DmStatusUsgBildTpType,
DmStatusUsgYearsType,
TableHeaderType,
} from "@/app/store/dm-status/type";
import Image from 'next/image';
import { useDmStore } from '@/app/store';
import { formatUsage } from '@/app/common/transValueFormat';
import type { DmStatusBildTpYearsType, DmStatusUsgBildTpType, DmStatusUsgYearsType, TableHeaderType } from '@/app/store/dm-status/type';
// 공통 섹션 타이틀 컴포넌트
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
<div className='flex items-center gap-3'>
<span className='h-5 w-[3px] bg-sky-500' />
<h3 className='text-[24px] font-bold text-neutral-900'>{children}</h3>
</div>
);
}
// 구분선
function Hr({ className = "" }: { className?: string }) {
function Hr({ className = '' }: { className?: string }) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
@@ -32,62 +26,49 @@ function Hr({ className = "" }: { className?: string }) {
function RainIntroSummary() {
const items = [
{
icon: "/images/landing/icon_chart.png",
title: "지속적인 이용량 증가",
desc: "2018년 약 797만m³이었던 빗물 이용량은 5년 사이 약 75% 증가하여 수자원 절약에 기여하고 있습니다.",
icon: '/images/landing/icon_chart.png',
title: '지속적인 이용량 증가',
desc: '2018년 약 797만m³이었던 빗물 이용량은 5년 사이 약 75% 증가하여 수자원 절약에 기여하고 있습니다.',
},
{
icon: "/images/landing/icon_hand.png",
title: "조경용수의 핵심 자원",
desc: "재이용되는 빗물의 약 97%가 조경용수로 활용되고 있으며 이는 공원, 아파트 단지 등의 녹지 관리에 중요한 역할을 합니다.",
icon: '/images/landing/icon_hand.png',
title: '조경용수의 핵심 자원',
desc: '재이용되는 빗물의 약 97%가 조경용수로 활용되고 있으며 이는 공원, 아파트 단지 등의 녹지 관리에 중요한 역할을 합니다.',
},
{
icon: "/images/landing/icon_building.png",
title: "민간·공공의 협력",
desc: "골프장과 같은 대규모 스포츠 시설은 물론, 최근에는 공동주택·아파트 등 내 이용량이 급증하며 일상생활 속 빗물 활용이 확산되는 추세입니다.",
icon: '/images/landing/icon_building.png',
title: '민간·공공의 협력',
desc: '골프장과 같은 대규모 스포츠 시설은 물론, 최근에는 공동주택·아파트 등 내 이용량이 급증하며 일상생활 속 빗물 활용이 확산되는 추세입니다.',
},
];
return (
<div className="mb-[70px] px-[160px]">
<div className="grid grid-cols-[1fr_400px] items-center gap-[60px]">
<div className='mb-[70px] px-[160px]'>
<div className='grid grid-cols-[1fr_400px] items-center gap-[60px]'>
{/* 좌측 */}
<div>
<h3 className="text-[40px] font-extrabold leading-[1.35] tracking-[-0.04em] text-neutral-900">
<h3 className='text-[40px] font-extrabold leading-[1.35] tracking-[-0.04em] text-neutral-900'>
기후위기 시대의 수자원 확보,
<br />
<span className="inline-block bg-gradient-to-b from-[#6DBB4F] to-[#01ACE5] bg-clip-text text-transparent">
빗물 재이용
</span>
이 앞장섭니다.
<span className='inline-block bg-gradient-to-b from-[#6DBB4F] to-[#01ACE5] bg-clip-text text-transparent'>빗물 재이용</span>이 앞장섭니다.
</h3>
<p className="mt-5 text-[16px] leading-[1.8] tracking-[-0.04em] text-neutral-600">
<p className='mt-5 text-[16px] leading-[1.8] tracking-[-0.04em] text-neutral-600'>
2023년 기준 전국 3,500여 개 시설에서
<br />
연간 약 1,394만m³의 빗물을 소중한 자원으로 다시 활용하고 있습니다.
</p>
<div className="mt-10 space-y-8">
<div className='mt-10 space-y-8'>
{items.map((item) => (
<div key={item.title} className="flex gap-3 items-center">
<div className="flex h-12 w-12 shrink-0 items-center justify-center">
<Image
src={item.icon}
alt=""
width={48}
height={48}
className="object-contain"
/>
<div key={item.title} className='flex gap-3 items-center'>
<div className='flex h-12 w-12 shrink-0 items-center justify-center'>
<Image src={item.icon} alt='' width={48} height={48} className='object-contain' />
</div>
<div>
<strong className="block text-[18px] font-extrabold text-neutral-900">
{item.title}
</strong>
<p className="mt-2 text-[16px] leading-[1.7] text-neutral-600 tracking-tighter">
{item.desc}
</p>
<strong className='block text-[18px] font-extrabold text-neutral-900'>{item.title}</strong>
<p className='mt-2 text-[16px] leading-[1.7] text-neutral-600 tracking-tighter'>{item.desc}</p>
</div>
</div>
))}
@@ -95,15 +76,8 @@ function RainIntroSummary() {
</div>
{/* 우측 이미지 */}
<div className="flex items-center justify-center">
<Image
src="/images/landing/rain_status_img.png"
alt="빗물 재이용 개념도"
width={400}
height={343}
className="h-auto w-full max-w-[400px] object-contain"
priority
/>
<div className='flex items-center justify-center'>
<Image src='/images/landing/rain_status_img.png' alt='빗물 재이용 개념도' width={400} height={343} className='h-auto w-full max-w-[400px] object-contain' priority />
</div>
</div>
</div>
@@ -111,31 +85,15 @@ function RainIntroSummary() {
}
const tableStyles = {
container: "w-[1000px] mx-auto overflow-hidden",
table: "w-full table-fixed border-collapse text-center text-[16px]",
th: "border border-[#858F98] py-3 font-bold text-neutral-800",
td: "border border-[#858F98] py-3 text-neutral-700 font-medium break-keep",
headerBg: "bg-[#E3E7EA]",
container: 'w-[1000px] mx-auto overflow-hidden',
table: 'w-full table-fixed border-collapse text-center text-[16px]',
th: 'border border-[#858F98] py-3 font-bold text-neutral-800',
td: 'border border-[#858F98] py-3 text-neutral-700 font-medium break-keep',
headerBg: 'bg-[#E3E7EA]',
};
const headerColors6 = [
"bg-[#01ACE5]",
"bg-[#17AFC7]",
"bg-[#2CB2A9]",
"bg-[#42B58B]",
"bg-[#57B86D]",
"bg-[#81C784]",
];
const headerColors8 = [
"bg-[#01ACE5]",
"bg-[#10AED0]",
"bg-[#20B0BA]",
"bg-[#2FB2A5]",
"bg-[#3FB58F]",
"bg-[#4EB77A]",
"bg-[#5EB964]",
"bg-[#6DBB4F]",
];
const headerColors6 = ['bg-[#01ACE5]', 'bg-[#17AFC7]', 'bg-[#2CB2A9]', 'bg-[#42B58B]', 'bg-[#57B86D]', 'bg-[#81C784]'];
const headerColors8 = ['bg-[#01ACE5]', 'bg-[#10AED0]', 'bg-[#20B0BA]', 'bg-[#2FB2A5]', 'bg-[#3FB58F]', 'bg-[#4EB77A]', 'bg-[#5EB964]', 'bg-[#6DBB4F]'];
export default function RainStatusSection() {
const getRainyByBildTpYears = useDmStore((state) => state.getRainyByBildTpYears);
const getRainyByUsgBildTp = useDmStore((state) => state.getRainyByUsgBildTp);
@@ -148,19 +106,11 @@ export default function RainStatusSection() {
const rainyByUsgBildTp = useDmStore((state) => state.rainy.rainyByUsgBildTp);
const rainyByUsgYearsHeader = useDmStore((state) => state.rainy.rainyByUsgYearsHeader);
const rainyByUsgYears = useDmStore((state) => state.rainy.rainyByUsgYears);
const partBildTpHeader = useMemo(
() => rainyByUsgBildTpHeader.slice(3).map((header) => header.title),
[rainyByUsgBildTpHeader]
);
const partBildTpHeader = useMemo(() => rainyByUsgBildTpHeader.slice(3).map((header) => header.title), [rainyByUsgBildTpHeader]);
// 출처 텍스트
function SourceText() {
return (
<p className="mt-7 text-center text-[13px] text-neutral-500">
자료: 하수도정보시스템 {selectYear} 하수도 통계
</p>
);
return <p className='mt-7 text-center text-[13px] text-neutral-500'>자료: 하수도정보시스템 {selectYear} 하수도 통계</p>;
}
useEffect(() => {
@@ -171,32 +121,22 @@ export default function RainStatusSection() {
const renderPartHeaders = (headers: string[], headerColors: string[]) => {
return headers.map((header, index) => (
<th
key={header}
className={`${tableStyles.th} ${headerColors[index] ?? "bg-[#81C784]"} text-white`}
style={{ width: "140px" }}
>
<th key={header} className={`${tableStyles.th} ${headerColors[index] ?? 'bg-[#81C784]'} text-white`} style={{ width: '140px' }}>
{header}
</th>
));
};
const renderBuildingUsageCells = (
row: DmStatusBildTpYearsType | DmStatusUsgYearsType | DmStatusUsgYearsType,
headers: TableHeaderType[]
) => {
const renderBuildingUsageCells = (row: DmStatusBildTpYearsType | DmStatusUsgYearsType | DmStatusUsgYearsType, headers: TableHeaderType[]) => {
const rowData = row as Record<string, string | number>;
const firstHeaderId = headers[0]?.id;
const rowKey = firstHeaderId ? String(rowData[firstHeaderId] ?? "") : "";
const rowKey = firstHeaderId ? String(rowData[firstHeaderId] ?? '') : '';
return headers.map((header, index) => {
const value = rowData[header.id];
return (
<td
key={`${rowKey}-${header.id}`}
className={`${tableStyles.td} ${index === 0 ? "!font-bold !text-neutral-900" : ""} ${[1, 3, 5].includes(index) ? "bg-[#EAF5F5]" : ""}`}
>
<td key={`${rowKey}-${header.id}`} className={`${tableStyles.td} ${index === 0 ? '!font-bold !text-neutral-900' : ''} ${[1, 3, 5].includes(index) ? 'bg-[#EAF5F5]' : ''}`}>
{index === 0 ? value : formatUsage(value as string | number, 1000, 1)}
</td>
);
@@ -206,102 +146,99 @@ export default function RainStatusSection() {
const renderRainyByUsgBildTpCells = (row: DmStatusUsgBildTpType) => {
return rainyByUsgBildTpHeader.map((header, index) => {
const value = row[header.id as keyof DmStatusUsgBildTpType];
const displayValue =
index === 0
? value
: header.id === "CNT"
? Number(value).toLocaleString("ko-KR")
: formatUsage(value as string | number, 1000, 1);
const displayValue = index === 0 ? value : header.id === 'CNT' ? Number(value).toLocaleString('ko-KR') : formatUsage(value as string | number, 1000, 1);
return (
<td
key={`${row.USG_NM}-${header.id}`}
className={`${tableStyles.td} ${index === 0 ? "!font-bold !text-neutral-900" : ""} ${[1, 2].includes(index) ? "bg-[#EAF5F5]" : ""}`}
>
{displayValue == 0.0 ? "-" : displayValue}
<td key={`${row.USG_NM}-${header.id}`} className={`${tableStyles.td} ${index === 0 ? '!font-bold !text-neutral-900' : ''} ${[1, 2].includes(index) ? 'bg-[#EAF5F5]' : ''}`}>
{displayValue == 0.0 ? '-' : displayValue}
</td>
);
});
};
const title = "빗물이용시설(현황)";
const title = '빗물이용시설(현황)';
return (
<div className="bg-white">
<div className='bg-white'>
{/* 상단 타이틀 */}
<div className="inline-block">
<h2 className="relative inline-block px-2 text-[28px] font-extrabold tracking-tight leading-none text-slate-900">
<span className="relative z-10">{title}</span>
<span className="absolute left-0 bottom-[-4px] z-0 h-3 w-full bg-gradient-to-r from-sky-300 to-green-300" />
<div className='inline-block'>
<h2 className='relative inline-block px-2 text-[28px] font-extrabold tracking-tight leading-none text-slate-900'>
<span className='relative z-10'>{title}</span>
<span className='absolute left-0 bottom-[-4px] z-0 h-3 w-full bg-gradient-to-r from-sky-300 to-green-300' />
</h2>
</div>
<div className="mt-[70px] pb-[120px]">
<div className='mt-[70px] pb-[120px]'>
{/* 1) 빗물이용시설의 건축물별 빗물이용량 현황 */}
<section>
{/* 인트로 영역 */}
<RainIntroSummary />
<div id="status-rain-building" className="scroll-mt-[120px]">
<div id='status-rain-building' className='scroll-mt-[120px]'>
<SectionTitle>빗물이용시설의 건축물별 빗물이용량 현황</SectionTitle>
<div className="mt-10">
<div className={tableStyles.container}>
<div className='mt-10'>
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<thead>
<thead>
<tr>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: "160px" }}>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: '160px' }}>
구분
</th>
<th colSpan={rainyYears.length || 6} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}>
</th>
<th colSpan={rainyYears.length || 6} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}>
빗물이용량 (천m³/년)
</th>
</th>
</tr>
<tr>
{renderPartHeaders(rainyYears, headerColors6)}
</tr>
</thead>
<tbody>
<tr>{renderPartHeaders(rainyYears, headerColors6)}</tr>
</thead>
<tbody>
{rainyByBildTpYears.map((row, idx) => (
<tr key={idx} className="bg-white">
<tr key={idx} className='bg-white'>
{renderBuildingUsageCells(row, rainyByBildTpYearsHeader)}
</tr>
</tr>
))}
</tbody>
</tbody>
</table>
</div>
</div>
<SourceText />
<SourceText />
</div>
</div>
</section>
<Hr className="my-11" />
<Hr className='my-11' />
{/* 2) 빗물이용시설의 용도별 분포 (xxxx년 기준) */}
<section
id="status-rain-purpose-distribution"
className="scroll-mt-[120px]"
>
<section id='status-rain-purpose-distribution' className='scroll-mt-[120px]'>
<SectionTitle>빗물이용시설의 용도별 분포 ({selectYear}년 기준)</SectionTitle>
<div className="mt-8">
<div className='mt-8'>
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<thead>
<tr>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: "160px" }}>용도</th>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: "84px" }}>개소수<br />(개소)</th>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: "84px" }}>저류용량<br />(천m³)</th>
<th colSpan={8} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}>빗물이용량 (천m³/년)</th>
</tr>
<tr>
{renderPartHeaders(partBildTpHeader, headerColors8)}
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: '160px' }}>
용도
</th>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: '84px' }}>
개소수
<br />
(개소)
</th>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: '84px' }}>
저류조용량
<br />
(천m³)
</th>
<th colSpan={8} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}>
빗물이용량 (천m³/년)
</th>
</tr>
<tr>{renderPartHeaders(partBildTpHeader, headerColors8)}</tr>
</thead>
<tbody>
{rainyByUsgBildTp.map((row, idx) => (
<tr key={`${row.USG_NM}-${idx}`} className="bg-white">
<tr key={`${row.USG_NM}-${idx}`} className='bg-white'>
{renderRainyByUsgBildTpCells(row)}
</tr>
))}
@@ -313,30 +250,29 @@ export default function RainStatusSection() {
</div>
</section>
<Hr className="my-11" />
<Hr className='my-11' />
{/* 3) 빗물이용시설의 용도별 빗물이용량 현황 */}
<section
id="status-rain-purpose-usage"
className="scroll-mt-[120px]"
>
<section id='status-rain-purpose-usage' className='scroll-mt-[120px]'>
<SectionTitle>빗물이용시설의 용도별 빗물이용량 현황</SectionTitle>
<div className="mt-8">
<div className='mt-8'>
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<thead>
<tr>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: "160px" }}>용도</th>
<th colSpan={6} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}>빗물이용량 (천m³/년)</th>
</tr>
<tr>
{renderPartHeaders(rainyYears, headerColors6)}
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: '160px' }}>
용도
</th>
<th colSpan={6} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}>
빗물이용량 (천m³/년)
</th>
</tr>
<tr>{renderPartHeaders(rainyYears, headerColors6)}</tr>
</thead>
<tbody>
{rainyByUsgYears.map((row, idx) => (
<tr key={idx} className="bg-white">
<tr key={idx} className='bg-white'>
{renderBuildingUsageCells(row, rainyByUsgYearsHeader)}
</tr>
))}
@@ -350,4 +286,4 @@ export default function RainStatusSection() {
</div>
</div>
);
}
}
@@ -1,116 +1,97 @@
/**
* 하수처리수
*/
"use client";
'use client';
import React from "react";
import Image from "next/image";
import { useEffect } from "react";
import { useDmStore } from "@/app/store";
import { formatUsage } from "@/app/common/transValueFormat";
import type {
DmStatusLawYearsType,
DmStatusBildTpYearsType,
DmStatusUsgYearsType,
DmStatusSidoUsgType,
TableHeaderType,
} from "@/app/store/dm-status/type";
import { Col2Name } from "@/app/store/dm-status/type";
import React from 'react';
import Image from 'next/image';
import { useEffect } from 'react';
import { useDmStore } from '@/app/store';
import { formatUsage } from '@/app/common/transValueFormat';
import type { DmStatusLawYearsType, DmStatusBildTpYearsType, DmStatusUsgYearsType, DmStatusSidoUsgType, TableHeaderType } from '@/app/store/dm-status/type';
import { Col2Name } from '@/app/store/dm-status/type';
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
<div className='flex items-center gap-3'>
<span className='h-5 w-[3px] bg-sky-500' />
<h3 className='text-[24px] font-bold text-neutral-900'>{children}</h3>
</div>
);
}
function Hr({ className = "" }: { className?: string }) {
function Hr({ className = '' }: { className?: string }) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
function SewageIntroSummary() {
const items = [
{
icon: "/images/landing/icon_cycle_water.png",
title: "안정적인 재이용률 유지",
icon: '/images/landing/icon_cycle_water.png',
title: '안정적인 재이용률 유지',
desc: (
<>
<>
연간 약 1,136백만m³의 하수처리수가 재이용되고 있으며,
<br/>
<br />
약 15%에 달하는 높은 재이용률을 통해
<br/>
<br />
수자원 선순환 체계를 구축하고 있습니다.
</>
</>
),
},
{
icon: "/images/landing/icon_water_flow.png",
title: "메마른 하천을 살리는 물",
icon: '/images/landing/icon_water_flow.png',
title: '메마른 하천을 살리는 물',
desc: (
<>
<>
재이용되는 물의 가장 많은 부분이 하천유지용수로 사용되어
<br/>
<br />
도심 속 하천의 생태계를 보호하고 쾌적한 수변 환경을 조성합니다.
</>
</>
),
},
{
icon: "/images/landing/icon_location.png",
title: "지역별 균형 있는 활용",
icon: '/images/landing/icon_location.png',
title: '지역별 균형 있는 활용',
desc: (
<>
<>
전국적으로 고르게 시설이 확충되고 있으며, 특히 경기 지역을 중심으로
<br/>
<br />
대규모 공업용수 공급망을 구축하여
<br/>
<br />
지역 경제와 환경 보호를 동시에 실현하고 있습니다.
</>
</>
),
},
];
return (
<div className="mb-[70px] px-[160px]">
<div className="grid grid-cols-[1fr_400px] items-center gap-0">
<div className='mb-[70px] px-[160px]'>
<div className='grid grid-cols-[1fr_400px] items-center gap-0'>
{/* 좌측 */}
<div>
<h3 className="text-[40px] font-extrabold leading-[1.35] tracking-[-0.14em] text-neutral-900">
<h3 className='text-[40px] font-extrabold leading-[1.35] tracking-[-0.14em] text-neutral-900'>
버려지는 하수에서 찾는 새로운 가치,
<br />
<span className="inline-block bg-gradient-to-b from-[#6DBB4F] to-[#01ACE5] bg-clip-text text-transparent">
하수처리수 재이용
</span>
이 도시를 숨쉬게 합니다.
<span className='inline-block bg-gradient-to-b from-[#6DBB4F] to-[#01ACE5] bg-clip-text text-transparent'>하수처리수 재이용</span>이 도시를 숨쉬게 합니다.
</h3>
<p className="mt-5 text-[16px] leading-[1.8] text-neutral-600">
<p className='mt-5 text-[16px] leading-[1.8] text-neutral-600'>
하수처리장에서 정화된 물은 하천의 수량을 조절하고
<br />
산업 현장에 용수를 공급하는 소중한 자원입니다.
</p>
<div className="mt-10 space-y-8">
<div className='mt-10 space-y-8'>
{items.map((item) => (
<div key={item.title} className="flex gap-4 items-center">
<div className="mt-1 flex h-12 w-12 shrink-0 items-center justify-center">
<Image
src={item.icon}
alt=""
width={48}
height={48}
className="object-contain"
/>
<div key={item.title} className='flex gap-4 items-center'>
<div className='mt-1 flex h-12 w-12 shrink-0 items-center justify-center'>
<Image src={item.icon} alt='' width={48} height={48} className='object-contain' />
</div>
<div>
<strong className="block text-[18px] font-extrabold text-neutral-900">
{item.title}
</strong>
<p className="mt-2 text-[16px] leading-[1.7] text-neutral-600">
{item.desc}
</p>
<strong className='block text-[18px] font-extrabold text-neutral-900'>{item.title}</strong>
<p className='mt-2 text-[16px] leading-[1.7] text-neutral-600'>{item.desc}</p>
</div>
</div>
))}
@@ -118,15 +99,8 @@ function SewageIntroSummary() {
</div>
{/* 우측 이미지 */}
<div className="flex items-center justify-center">
<Image
src="/images/landing/sewage_status_img.png"
alt="하수처리수 재이용 개념도"
width={420}
height={360}
className="h-auto w-full max-w-[420px] object-contain"
priority
/>
<div className='flex items-center justify-center'>
<Image src='/images/landing/sewage_status_img.png' alt='하수처리수 재이용 개념도' width={420} height={360} className='h-auto w-full max-w-[420px] object-contain' priority />
</div>
</div>
</div>
@@ -134,31 +108,24 @@ function SewageIntroSummary() {
}
const tableStyles = {
container: "w-[1000px] mx-auto overflow-hidden",
table: "w-full table-fixed border-collapse text-center text-[16px]",
th: "border border-[#858F98] py-3 font-bold text-neutral-800",
td: "border border-[#858F98] py-3 text-neutral-700 font-medium break-keep",
headerBg: "bg-[#E3E7EA]",
container: 'w-[1000px] mx-auto overflow-hidden',
table: 'w-full table-fixed border-collapse text-center text-[16px]',
th: 'border border-[#858F98] py-3 font-bold text-neutral-800',
td: 'border border-[#858F98] py-3 text-neutral-700 font-medium break-keep',
headerBg: 'bg-[#E3E7EA]',
};
const headerColors6 = [
"bg-[#01ACE5]",
"bg-[#17AFC7]",
"bg-[#2CB2A9]",
"bg-[#42B58B]",
"bg-[#57B86D]",
"bg-[#81C784]",
];
const headerColors6 = ['bg-[#01ACE5]', 'bg-[#17AFC7]', 'bg-[#2CB2A9]', 'bg-[#42B58B]', 'bg-[#57B86D]', 'bg-[#81C784]'];
export default function SewageStatusSection() {
const getPbnstByLawYears = useDmStore((state) => state.getPbnstByLawYears);
const getPbnstByUsgYears = useDmStore((state) => state.getPbnstByUsgYears);
const getPbnstByCpcYears = useDmStore((state) => state.getPbnstByCpcYears);
const getPbnstBySido = useDmStore((state) => state.getPbnstBySido);
const getPbnstByUsgYears = useDmStore((state) => state.getPbnstByUsgYears);
const getPbnstByCpcYears = useDmStore((state) => state.getPbnstByCpcYears);
const getPbnstBySido = useDmStore((state) => state.getPbnstBySido);
const pbnstYears = useDmStore((state) => state.pbnst.pbnstYears);
const selectYear = pbnstYears.slice(-1)[0];
const pbnstByLawYearsHeader = useDmStore((state) => state.pbnst.pbnstByLawYearsHeader);
const pbnstByLawYears = useDmStore((state) => state.pbnst.pbnstByLawYears);
const pbnstByLawYears = useDmStore((state) => state.pbnst.pbnstByLawYears);
const pbnstByUsgYearsHeader = useDmStore((state) => state.pbnst.pbnstByUsgYearsHeader);
const pbnstByUsgYears = useDmStore((state) => state.pbnst.pbnstByUsgYears);
const pbnstByCpcYearsHeader = useDmStore((state) => state.pbnst.pbnstByCpcYearsHeader);
@@ -168,15 +135,11 @@ export default function SewageStatusSection() {
// 출처 텍스트
function SourceText() {
return (
<p className="mt-7 text-center text-[13px] text-neutral-500">
자료: 하수도정보시스템 {selectYear} 하수도 통계
</p>
);
return <p className='mt-7 text-center text-[13px] text-neutral-500'>자료: 하수도정보시스템 {selectYear} 하수도 통계</p>;
}
useEffect(() => {
void getPbnstByLawYears();
void getPbnstByLawYears();
void getPbnstByUsgYears();
void getPbnstByCpcYears();
void getPbnstBySido();
@@ -184,10 +147,7 @@ export default function SewageStatusSection() {
const renderPartHeaders = (headers: string[], headerColors: string[]) => {
return headers.map((header, index) => (
<th
key={header}
className={`${tableStyles.th} ${headerColors[index] ?? "bg-[#81C784]"} text-white`}
>
<th key={header} className={`${tableStyles.th} ${headerColors[index] ?? 'bg-[#81C784]'} text-white`}>
{header}
</th>
));
@@ -198,10 +158,10 @@ export default function SewageStatusSection() {
row: DmStatusLawYearsType,
data: DmStatusLawYearsType[],
headers: TableHeaderType[],
unitSize: number = 1, // 단위 변경 1000 인 경우, 100,000 -> 100 으로 변경
unitSize: number = 1 // 단위 변경 1000 인 경우, 100,000 -> 100 으로 변경
) => {
const rowData = row as Record<string, string | number>;
const currentCol1 = String(rowData["COL1"] ?? "");
const currentCol1 = String(rowData['COL1'] ?? '');
// 이전 값
const prevCol1 = rowIdx > 0 ? data[rowIdx - 1]?.COL1 : null;
@@ -220,56 +180,38 @@ export default function SewageStatusSection() {
const value = rowData[header.id];
// COL1 처리 (rowspan 핵심)
if (header.id === "COL1") {
if (header.id === 'COL1') {
// 이전과 같으면 렌더링 안함
if (currentCol1 === prevCol1) return null;
return (
<td
key={`${rowIdx}-${header.id}`}
rowSpan={getRowSpan()}
className={`${tableStyles.td} !font-bold !text-neutral-900`}
>
<td key={`${rowIdx}-${header.id}`} rowSpan={getRowSpan()} className={`${tableStyles.td} !font-bold !text-neutral-900`}>
{value}
</td>
);
} else if (header.id === "COL2") {
return (
<td
key={`${rowIdx}-${header.id}`}
className={`${tableStyles.td} !font-bold !text-neutral-900`}
dangerouslySetInnerHTML={{ __html: Col2Name[value as string] ?? "" }}
/>
);
} else if (header.id === 'COL2') {
return <td key={`${rowIdx}-${header.id}`} className={`${tableStyles.td} !font-bold !text-neutral-900`} dangerouslySetInnerHTML={{ __html: Col2Name[value as string] ?? '' }} />;
}
return (
<td
key={`${rowIdx}-${header.id}`}
className={`${tableStyles.td} ${[0, 2, 4].includes(index) ? "bg-[#EAF5F5]" : ""}`}
>
{(String(rowData["COL2"]).indexOf("CNT") > -1)
? Number(value).toLocaleString("ko-KR")
: ((String(rowData["COL2"]).indexOf("REUSERT") > -1)
<td key={`${rowIdx}-${header.id}`} className={`${tableStyles.td} ${[0, 2, 4].includes(index) ? 'bg-[#EAF5F5]' : ''}`}>
{String(rowData['COL2']).indexOf('CNT') > -1
? Number(value).toLocaleString('ko-KR')
: String(rowData['COL2']).indexOf('REUSERT') > -1
? formatUsage(value as string | number, 1, 1)
: formatUsage(value as string | number, unitSize, 1))
}
: formatUsage(value as string | number, unitSize, 1)}
</td>
);
});
};
const renderPbnstUsgCells = (
rowIdx: number,
row: DmStatusBildTpYearsType | DmStatusUsgYearsType,
headers: TableHeaderType[]
) => {
const renderPbnstUsgCells = (rowIdx: number, row: DmStatusBildTpYearsType | DmStatusUsgYearsType, headers: TableHeaderType[]) => {
const rowData = row as Record<string, string | number>;
const isLastInGroup = rowIdx === 8 || rowIdx === 17;
const getType = (colNm:string) => {
const getType = (colNm: string) => {
const last3 = colNm.slice(-3); // 끝 3자리
const prefix = last3[0]; // S 또는 D
const prefix = last3[0]; // S 또는 D
if (prefix === 'S') {
const num = parseInt(last3.slice(1), 10); // 숫자
@@ -282,149 +224,117 @@ export default function SewageStatusSection() {
return headers.map((header, index) => {
const value = rowData[header.id];
const colNm = String(rowData["COL_NM"] ?? "");
const colNm = String(rowData['COL_NM'] ?? '');
const type = getType(colNm);
const isSubItem = type === "red-item" || type === "blue-item";
const isSubItem = type === 'red-item' || type === 'blue-item';
if (index === 0) {
// console.log(header.id, value);
return (
<td key={`${rowIdx}-${header.id}`} className={`${tableStyles.td} relative`}>
{isSubItem ? (
<>
<div
className={`absolute left-[1px] w-[16px] bg-white z-10 top-[-2px] ${
isLastInGroup ? "bottom-[0px]" : "bottom-[-2px]"
}`}
/>
<div
className={`absolute left-[16px] w-[4px] z-20 ${
type === "red-item" ? "bg-[#FF8B8B]" : "bg-[#4ECBFF]"
} top-[-2px] ${
isLastInGroup ? "bottom-[0px]" : "bottom-[-2px]"
}`}
/>
<span className="text-[#4f4f4f] font-bold block w-full text-center relative z-30 translate-x-[8px]">
{value}
</span>
<div className={`absolute left-[1px] w-[16px] bg-white z-10 top-[-2px] ${isLastInGroup ? 'bottom-[0px]' : 'bottom-[-2px]'}`} />
<div className={`absolute left-[16px] w-[4px] z-20 ${type === 'red-item' ? 'bg-[#FF8B8B]' : 'bg-[#4ECBFF]'} top-[-2px] ${isLastInGroup ? 'bottom-[0px]' : 'bottom-[-2px]'}`} />
<span className='text-[#4f4f4f] font-bold block w-full text-center relative z-30 translate-x-[8px]'>{value}</span>
</>
) : (
<span className="font-bold text-neutral-900 block w-full text-center">
{value}
</span>
<span className='font-bold text-neutral-900 block w-full text-center'>{value}</span>
)}
</td>
);
}
);
}
return (
<td
key={`${rowIdx}-${header.id}`}
className={`${tableStyles.td} ${index === 0 ? "!font-bold !text-neutral-900" : ""} ${[0, 2, 4].includes(index) ? "bg-[#EAF5F5]" : ""}`}
>
<td key={`${rowIdx}-${header.id}`} className={`${tableStyles.td} ${index === 0 ? '!font-bold !text-neutral-900' : ''} ${[0, 2, 4].includes(index) ? 'bg-[#EAF5F5]' : ''}`}>
{formatUsage(value as string | number, 1000, 1)}
</td>
);
});
};
const renderSidoUsgCells = (
rowIdx: number,
row: DmStatusSidoUsgType,
headers: TableHeaderType[]
) => {
const renderSidoUsgCells = (rowIdx: number, row: DmStatusSidoUsgType, headers: TableHeaderType[]) => {
const rowData = row as Record<string, string | number>;
return headers.map((header, index) => {
let value = rowData[header.id];
value = (index === 0||index ===9) ? value : formatUsage(value as string | number, 1000, 1);
value = value === "0.0"? "-" : value;
value = index === 0 || index === 9 ? value : formatUsage(value as string | number, 1000, 1);
value = value === '0.0' ? '-' : value;
return (
<td
key={`${rowIdx}-${header.id}`}
className={`${tableStyles.td} ${index === 0 ? "!font-bold !text-neutral-900" : ""} ${[1, 9].includes(index) ? "bg-[#EAF5F5]" : ""}`}
>
<td key={`${rowIdx}-${header.id}`} className={`${tableStyles.td} ${index === 0 ? '!font-bold !text-neutral-900' : ''} ${[1, 9].includes(index) ? 'bg-[#EAF5F5]' : ''}`}>
{value}
</td>
);
});
};
const title = "하수처리수 재이용시설(현황)";
const title = '하수처리수 재이용시설(현황)';
return (
<div className="bg-white">
<div className='bg-white'>
{/* 상단 타이틀 */}
<div className="inline-block">
<h2 className="relative inline-block px-2 text-[28px] font-extrabold tracking-tight leading-none text-slate-900">
<span className="relative z-10">{title}</span>
<span className="absolute left-0 bottom-[-4px] z-0 h-3 w-full bg-gradient-to-r from-sky-300 to-green-300" />
<div className='inline-block'>
<h2 className='relative inline-block px-2 text-[28px] font-extrabold tracking-tight leading-none text-slate-900'>
<span className='relative z-10'>{title}</span>
<span className='absolute left-0 bottom-[-4px] z-0 h-3 w-full bg-gradient-to-r from-sky-300 to-green-300' />
</h2>
</div>
<div className="mt-[70px] pb-[120px]">
<div className='mt-[70px] pb-[120px]'>
{/* 1) 연도별 하수처리수 재이용 현황 */}
<section>
{/* 인트로 */}
<SewageIntroSummary />
{/* 인트로 */}
<SewageIntroSummary />
<div id="status-treated-year" className="scroll-mt-[120px]">
<SectionTitle>연도별 하수처리수 재이용 현황</SectionTitle>
<div className="mt-10">
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<colgroup>
<col style={{ width: "130px" }} />
<col style={{ width: "180px" }} />
<col />
<col />
<col />
<col />
<col />
<col />
</colgroup>
<thead>
<tr>
<th
colSpan={2}
className={`${tableStyles.th} ${tableStyles.headerBg}`}
>
구분
</th>
{renderPartHeaders(pbnstYears, headerColors6)}
</tr>
</thead>
<tbody>
{pbnstByLawYears.map((row, idx, arr) => (
<tr key={idx}>
{renderCells(idx, row, arr, pbnstByLawYearsHeader, 1000)}
</tr>
))}
</tbody>
</table>
</div>
<div id='status-treated-year' className='scroll-mt-[120px]'>
<SectionTitle>연도별 하수처리수 재이용 현황</SectionTitle>
<div className='mt-10'>
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<colgroup>
<col style={{ width: '130px' }} />
<col style={{ width: '180px' }} />
<col />
<col />
<col />
<col />
<col />
<col />
</colgroup>
<thead>
<tr>
<th colSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
구분
</th>
{renderPartHeaders(pbnstYears, headerColors6)}
</tr>
</thead>
<tbody>
{pbnstByLawYears.map((row, idx, arr) => (
<tr key={idx}>{renderCells(idx, row, arr, pbnstByLawYearsHeader, 1000)}</tr>
))}
</tbody>
</table>
</div>
<SourceText />
</div>
<SourceText />
</div>
</div>
</section>
<Hr className="my-11" />
<Hr className='my-11' />
{/* 2) 용도별 하수처리수 재이용 현황 */}
<section id="status-treated-usage" className="scroll-mt-[120px]">
<section id='status-treated-usage' className='scroll-mt-[120px]'>
<SectionTitle>용도별 하수처리수 재이용 현황</SectionTitle>
<div className="mt-8">
<div className='mt-8'>
<div className={tableStyles.container}>
<div className="mb-2 text-right text-[13px] text-neutral-600">
(단위: 백만m³/일)
</div>
<div className='mb-2 text-right text-[13px] text-neutral-600'>(단위: 백만m³/년)</div>
<table className={tableStyles.table}>
<colgroup>
<col style={{ width: "180px" }} />
<col style={{ width: '180px' }} />
<col />
<col />
<col />
@@ -434,17 +344,15 @@ export default function SewageStatusSection() {
</colgroup>
<thead>
<tr>
<th className={`${tableStyles.th} ${tableStyles.headerBg}`}>
구분
</th>
<th className={`${tableStyles.th} ${tableStyles.headerBg}`}>구분</th>
{renderPartHeaders(pbnstYears, headerColors6)}
</tr>
</thead>
<tbody>
{pbnstByUsgYears.map((row, idx) => (
<tr key={idx} className="bg-white">
<tr key={idx} className='bg-white'>
{renderPbnstUsgCells(idx, row, pbnstByUsgYearsHeader)}
</tr>
</tr>
))}
</tbody>
</table>
@@ -454,18 +362,18 @@ export default function SewageStatusSection() {
</div>
</section>
<Hr className="my-11" />
<Hr className='my-11' />
{/* 3) 하수처리시설 설치 및 이용 현황 */}
<section id="status-treated-facility" className="scroll-mt-[120px]">
<section id='status-treated-facility' className='scroll-mt-[120px]'>
<SectionTitle>하수처리시설 설치 및 이용 현황</SectionTitle>
<div className="mt-8">
<div className='mt-8'>
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<colgroup>
<col style={{ width: "130px" }} />
<col style={{ width: "160px" }} />
<col style={{ width: '130px' }} />
<col style={{ width: '160px' }} />
<col />
<col />
<col />
@@ -475,10 +383,7 @@ export default function SewageStatusSection() {
</colgroup>
<thead>
<tr>
<th
colSpan={2}
className={`${tableStyles.th} ${tableStyles.headerBg}`}
>
<th colSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
구분
</th>
{renderPartHeaders(pbnstYears, headerColors6)}
@@ -486,9 +391,7 @@ export default function SewageStatusSection() {
</thead>
<tbody>
{pbnstByCpcYears.map((row, idx, arr) => (
<tr key={idx}>
{renderCells(idx, row, arr, pbnstByCpcYearsHeader, 10000)}
</tr>
<tr key={idx}>{renderCells(idx, row, arr, pbnstByCpcYearsHeader, 10000)}</tr>
))}
</tbody>
</table>
@@ -498,24 +401,19 @@ export default function SewageStatusSection() {
</div>
</section>
<Hr className="my-11" />
<Hr className='my-11' />
{/* 4) 시도별 하수처리수 재이용 현황 */}
<section
id="status-treated-region"
className="scroll-mt-[120px]"
>
<section id='status-treated-region' className='scroll-mt-[120px]'>
<SectionTitle>시도별 하수처리수 재이용 현황</SectionTitle>
<div className="mt-8">
<div className='mt-8'>
<div className={tableStyles.container}>
<div className="mb-2 text-right text-[13px] text-neutral-600">
(단위: 백만m³/일)
</div>
<div className='mb-2 text-right text-[13px] text-neutral-600'>(단위: 백만m³/년)</div>
<table className={tableStyles.table}>
<colgroup>
<col style={{ width: "160px" }} />
<col style={{ width: '160px' }} />
<col />
<col />
<col />
@@ -529,54 +427,53 @@ export default function SewageStatusSection() {
</colgroup>
<thead>
<tr>
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`}> 시도 </th>
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`} >
연간<br />하수<br />처리량
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
{' '}
시도{' '}
</th>
<th colSpan={7} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`} >
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
연간
<br />
하수
<br />
처리량
</th>
<th colSpan={7} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}>
재이용현황
</th>
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`} >
재이용률<br />(%)
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
재이용률
<br />
(%)
</th>
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`} >
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
비고
</th>
</tr>
<tr>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} >
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
계
</th>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} >
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
장내
</th>
<th colSpan={5} className={`${tableStyles.th} ${tableStyles.headerBg} py-1`} >
<th colSpan={5} className={`${tableStyles.th} ${tableStyles.headerBg} py-1`}>
장외
</th>
</tr>
<tr>
<th className={`${tableStyles.th} bg-[#01ACE5] text-white py-2 text-[16px]`}>
소계
</th>
<th className={`${tableStyles.th} bg-[#1FB1C2] text-white py-2 text-[13px]`}>
하천유지용수
</th>
<th className={`${tableStyles.th} bg-[#3EB59F] text-white py-2 text-[16px]`}>
공업용수
</th>
<th className={`${tableStyles.th} bg-[#5DB97C] text-white py-2 text-[16px]`}>
농업용수
</th>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white py-2 text-[16px]`}>
기타용수
</th>
<th className={`${tableStyles.th} bg-[#01ACE5] text-white py-2 text-[16px]`}>소계</th>
<th className={`${tableStyles.th} bg-[#1FB1C2] text-white py-2 text-[13px]`}>하천유지용수</th>
<th className={`${tableStyles.th} bg-[#3EB59F] text-white py-2 text-[16px]`}>공업용수</th>
<th className={`${tableStyles.th} bg-[#5DB97C] text-white py-2 text-[16px]`}>농업용수</th>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white py-2 text-[16px]`}>기타용수</th>
</tr>
</thead>
<tbody>
{pbnstBySido.map((row, idx) => (
<tr key={idx} className="bg-white">
<tr key={idx} className='bg-white'>
{renderSidoUsgCells(idx, row, pbnstBySidoHeader)}
</tr>
</tr>
))}
</tbody>
</table>
+69 -113
View File
@@ -1,60 +1,36 @@
"use client";
'use client';
import { useEffect, useMemo } from "react";
import SubPageHeader from "@/components/SubPageHeader";
import { useDmStore } from "@/app/store";
import { formatUsage } from "@/app/common/transValueFormat";
import type {
DmStatusBildTpYearsType,
DmStatusUsgBildTpType,
DmStatusUsgYearsType,
TableHeaderType,
} from "@/app/store/dm-status/type";
import { useEffect, useMemo } from 'react';
import SubPageHeader from '@/components/SubPageHeader';
import { useDmStore } from '@/app/store';
import { formatUsage } from '@/app/common/transValueFormat';
import type { DmStatusBildTpYearsType, DmStatusUsgBildTpType, DmStatusUsgYearsType, TableHeaderType } from '@/app/store/dm-status/type';
// 공통 섹션 타이틀 컴포넌트
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
<div className='flex items-center gap-3'>
<span className='h-5 w-[3px] bg-sky-500' />
<h3 className='text-[24px] font-bold text-neutral-900'>{children}</h3>
</div>
);
}
// 구분선
function Hr({ className = "" }: { className?: string }) {
function Hr({ className = '' }: { className?: string }) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
const tableStyles = {
container: "w-[1000px] mx-auto overflow-hidden",
table: "w-full table-fixed border-collapse text-center text-[16px]",
th: "border border-[#858F98] py-3 font-bold text-neutral-800",
td: "border border-[#858F98] py-3 text-neutral-700 font-medium break-keep",
headerBg: "bg-[#E3E7EA]",
container: 'w-[1000px] mx-auto overflow-hidden',
table: 'w-full table-fixed border-collapse text-center text-[16px]',
th: 'border border-[#858F98] py-3 font-bold text-neutral-800',
td: 'border border-[#858F98] py-3 text-neutral-700 font-medium break-keep',
headerBg: 'bg-[#E3E7EA]',
};
const headerColors6 = [
"bg-[#01ACE5]",
"bg-[#17AFC7]",
"bg-[#2CB2A9]",
"bg-[#42B58B]",
"bg-[#57B86D]",
"bg-[#81C784]",
];
const headerColors8 = [
"bg-[#01ACE5]",
"bg-[#10AED0]",
"bg-[#20B0BA]",
"bg-[#2FB2A5]",
"bg-[#3FB58F]",
"bg-[#4EB77A]",
"bg-[#5EB964]",
"bg-[#6DBB4F]",
];
const headerColors6 = ['bg-[#01ACE5]', 'bg-[#17AFC7]', 'bg-[#2CB2A9]', 'bg-[#42B58B]', 'bg-[#57B86D]', 'bg-[#81C784]'];
const headerColors8 = ['bg-[#01ACE5]', 'bg-[#10AED0]', 'bg-[#20B0BA]', 'bg-[#2FB2A5]', 'bg-[#3FB58F]', 'bg-[#4EB77A]', 'bg-[#5EB964]', 'bg-[#6DBB4F]'];
export default function RainwaterStatusPage() {
const getRainyByBildTpYears = useDmStore((state) => state.getRainyByBildTpYears);
@@ -68,19 +44,11 @@ export default function RainwaterStatusPage() {
const rainyByUsgBildTp = useDmStore((state) => state.rainy.rainyByUsgBildTp);
const rainyByUsgYearsHeader = useDmStore((state) => state.rainy.rainyByUsgYearsHeader);
const rainyByUsgYears = useDmStore((state) => state.rainy.rainyByUsgYears);
const partBildTpHeader = useMemo(
() => rainyByUsgBildTpHeader.slice(3).map((header) => header.title),
[rainyByUsgBildTpHeader]
);
const partBildTpHeader = useMemo(() => rainyByUsgBildTpHeader.slice(3).map((header) => header.title), [rainyByUsgBildTpHeader]);
// 출처 텍스트
function SourceText() {
return (
<p className="mt-7 text-center text-[13px] text-neutral-500">
자료: 환경부(2024), 「{selectYear} 하수도 통계」
</p>
);
return <p className='mt-7 text-center text-[13px] text-neutral-500'>자료: 환경부(2024), 「{selectYear} 하수도 통계」</p>;
}
useEffect(() => {
@@ -91,32 +59,22 @@ export default function RainwaterStatusPage() {
const renderPartHeaders = (headers: string[], headerColors: string[]) => {
return headers.map((header, index) => (
<th
key={header}
className={`${tableStyles.th} ${headerColors[index] ?? "bg-[#81C784]"} text-white`}
style={{ width: "140px" }}
>
<th key={header} className={`${tableStyles.th} ${headerColors[index] ?? 'bg-[#81C784]'} text-white`} style={{ width: '140px' }}>
{header}
</th>
));
};
const renderBuildingUsageCells = (
row: DmStatusBildTpYearsType | DmStatusUsgYearsType | DmStatusUsgYearsType,
headers: TableHeaderType[]
) => {
const renderBuildingUsageCells = (row: DmStatusBildTpYearsType | DmStatusUsgYearsType | DmStatusUsgYearsType, headers: TableHeaderType[]) => {
const rowData = row as Record<string, string | number>;
const firstHeaderId = headers[0]?.id;
const rowKey = firstHeaderId ? String(rowData[firstHeaderId] ?? "") : "";
const rowKey = firstHeaderId ? String(rowData[firstHeaderId] ?? '') : '';
return headers.map((header, index) => {
const value = rowData[header.id];
return (
<td
key={`${rowKey}-${header.id}`}
className={`${tableStyles.td} ${index === 0 ? "!font-bold !text-neutral-900" : ""} ${[1, 3, 5].includes(index) ? "bg-[#EAF5F5]" : ""}`}
>
<td key={`${rowKey}-${header.id}`} className={`${tableStyles.td} ${index === 0 ? '!font-bold !text-neutral-900' : ''} ${[1, 3, 5].includes(index) ? 'bg-[#EAF5F5]' : ''}`}>
{index === 0 ? value : formatUsage(value as string | number, 1000, 1)}
</td>
);
@@ -126,63 +84,49 @@ export default function RainwaterStatusPage() {
const renderRainyByUsgBildTpCells = (row: DmStatusUsgBildTpType) => {
return rainyByUsgBildTpHeader.map((header, index) => {
const value = row[header.id as keyof DmStatusUsgBildTpType];
const displayValue =
index === 0
? value
: header.id === "CNT"
? Number(value).toLocaleString("ko-KR")
: formatUsage(value as string | number, 1000, 1);
const displayValue = index === 0 ? value : header.id === 'CNT' ? Number(value).toLocaleString('ko-KR') : formatUsage(value as string | number, 1000, 1);
return (
<td
key={`${row.USG_NM}-${header.id}`}
className={`${tableStyles.td} ${index === 0 ? "!font-bold !text-neutral-900" : ""} ${[1, 2].includes(index) ? "bg-[#EAF5F5]" : ""}`}
>
{displayValue == 0.0 ? "-" : displayValue}
<td key={`${row.USG_NM}-${header.id}`} className={`${tableStyles.td} ${index === 0 ? '!font-bold !text-neutral-900' : ''} ${[1, 2].includes(index) ? 'bg-[#EAF5F5]' : ''}`}>
{displayValue == 0.0 ? '-' : displayValue}
</td>
);
});
};
return (
<div className="bg-white">
<div className='bg-white'>
<SubPageHeader
title="빗물이용시설(현황)"
crumbs={[
{ label: "Home", href: "/reuse" },
{ label: "물 재이용 현황", href: "/reuse/cases/rain" },
{ label: "빗물이용시설(현황)" },
]}
title='빗물이용시설(현황)'
crumbs={[{ label: 'Home', href: '/reuse' }, { label: '물 재이용 현황', href: '/reuse/cases/rain' }, { label: '빗물이용시설(현황)' }]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
wrapClassName='!pb-0'
/>
<div className="pr-[100px]">
<Hr className="my-7" />
<div className='pr-[100px]'>
<Hr className='my-7' />
{/* 1) 빗물이용시설의 건축물별 빗물이용량 현황 */}
<section id="status-rain-building" className="mt-10 scroll-mt-[120px]">
<section id='status-rain-building' className='mt-10 scroll-mt-[120px]'>
<SectionTitle>빗물이용시설의 건축물별 빗물이용량 현황</SectionTitle>
<div className="mt-10">
<div className='mt-10'>
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<thead>
<tr>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: "160px" }}>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: '160px' }}>
구분
</th>
<th colSpan={rainyYears.length || 6} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}>
빗물이용량 (천m³/년)
</th>
</tr>
<tr>
{renderPartHeaders(rainyYears, headerColors6)}
</tr>
<tr>{renderPartHeaders(rainyYears, headerColors6)}</tr>
</thead>
<tbody>
{rainyByBildTpYears.map((row, idx) => (
<tr key={idx} className="bg-white">
<tr key={idx} className='bg-white'>
{renderBuildingUsageCells(row, rainyByBildTpYearsHeader)}
</tr>
))}
@@ -193,28 +137,38 @@ export default function RainwaterStatusPage() {
</div>
</section>
<Hr className="my-11" />
<Hr className='my-11' />
{/* 2) 빗물이용시설의 용도별 분포 (2023년 기준) */}
<section id="status-rain-purpose-distribution" className="scroll-mt-[120px]">
<section id='status-rain-purpose-distribution' className='scroll-mt-[120px]'>
<SectionTitle>빗물이용시설의 용도별 분포 ({selectYear}년 기준)</SectionTitle>
<div className="mt-8">
<div className='mt-8'>
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<thead>
<tr>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: "160px" }}>용도</th>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: "84px" }}>개소수<br />(개소)</th>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: "84px" }}>저류용량<br />(천m³)</th>
<th colSpan={8} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}>빗물이용량 (천m³/년)</th>
</tr>
<tr>
{renderPartHeaders(partBildTpHeader, headerColors8)}
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: '160px' }}>
용도
</th>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: '84px' }}>
개소수
<br />
(개소)
</th>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: '84px' }}>
저류조용량
<br />
(천m³)
</th>
<th colSpan={8} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}>
빗물이용량 (천m³/년)
</th>
</tr>
<tr>{renderPartHeaders(partBildTpHeader, headerColors8)}</tr>
</thead>
<tbody>
{rainyByUsgBildTp.map((row, idx) => (
<tr key={`${row.USG_NM}-${idx}`} className="bg-white">
<tr key={`${row.USG_NM}-${idx}`} className='bg-white'>
{renderRainyByUsgBildTpCells(row)}
</tr>
))}
@@ -225,26 +179,28 @@ export default function RainwaterStatusPage() {
</div>
</section>
<Hr className="my-11" />
<Hr className='my-11' />
{/* 3) 빗물이용시설의 용도별 빗물이용량 현황 */}
<section id="status-rain-purpose-usage" className="pb-[200px] scroll-mt-[120px]">
<section id='status-rain-purpose-usage' className='pb-[200px] scroll-mt-[120px]'>
<SectionTitle>빗물이용시설의 용도별 빗물이용량 현황</SectionTitle>
<div className="mt-8">
<div className='mt-8'>
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<thead>
<tr>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: "160px" }}>용도</th>
<th colSpan={6} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}>빗물이용량 (천m³/년)</th>
</tr>
<tr>
{renderPartHeaders(rainyYears, headerColors6)}
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`} style={{ width: '160px' }}>
용도
</th>
<th colSpan={6} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}>
빗물이용량 (천m³/년)
</th>
</tr>
<tr>{renderPartHeaders(rainyYears, headerColors6)}</tr>
</thead>
<tbody>
{rainyByUsgYears.map((row, idx) => (
<tr key={idx} className="bg-white">
<tr key={idx} className='bg-white'>
{renderBuildingUsageCells(row, rainyByUsgYearsHeader)}
</tr>
))}
+372 -362
View File
@@ -1,30 +1,26 @@
"use client";
'use client';
import React from "react";
import SubPageHeader from "@/components/SubPageHeader";
import React from 'react';
import SubPageHeader from '@/components/SubPageHeader';
// 공통 섹션 타이틀 컴포넌트
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-center gap-3">
<span className="h-5 w-[3px] bg-sky-500" />
<h3 className="text-[24px] font-bold text-neutral-900">{children}</h3>
<div className='flex items-center gap-3'>
<span className='h-5 w-[3px] bg-sky-500' />
<h3 className='text-[24px] font-bold text-neutral-900'>{children}</h3>
</div>
);
}
// 구분선
function Hr({ className = "" }: { className?: string }) {
function Hr({ className = '' }: { className?: string }) {
return <div className={`h-[2px] w-full bg-black/10 ${className}`} />;
}
// 출처 텍스트
function SourceText() {
return (
<p className="mt-7 text-center text-[13px] text-neutral-500">
자료: 환경부(2024), 「2023 하수도 통계」
</p>
);
return <p className='mt-7 text-center text-[13px] text-neutral-500'>자료: 환경부(2024), 「2023 하수도 통계」</p>;
}
/**
@@ -33,11 +29,11 @@ function SourceText() {
* - 폰트 사이즈: 16px로 수정
*/
const tableStyles = {
container: "w-[1000px] mx-auto overflow-hidden",
table: "w-full table-fixed border-collapse text-center text-[16px]",
th: "border border-[#858F98] py-3 font-bold text-neutral-800",
td: "border border-[#858F98] py-3 text-neutral-700 font-medium break-keep",
headerBg: "bg-[#E3E7EA]",
container: 'w-[1000px] mx-auto overflow-hidden',
table: 'w-full table-fixed border-collapse text-center text-[16px]',
th: 'border border-[#858F98] py-3 font-bold text-neutral-800',
td: 'border border-[#858F98] py-3 text-neutral-700 font-medium break-keep',
headerBg: 'bg-[#E3E7EA]',
};
// export default function TreatedWaterPage() {
@@ -51,368 +47,382 @@ const tableStyles = {
export default function TreatedStatusPage() {
return (
<div className="relative bg-white">
<div className='relative bg-white'>
{/* <BusinessSubNav /> */}
<SubPageHeader
title="하수처리수 재이용시설(현황)"
crumbs={[
{ label: "Home", href: "/main" },
{ label: "물 재이용 현황", href: "/status/rain" },
{ label: "하수처리수 재이용시설(현황)" },
]}
title='하수처리수 재이용시설(현황)'
crumbs={[{ label: 'Home', href: '/main' }, { label: '물 재이용 현황', href: '/status/rain' }, { label: '하수처리수 재이용시설(현황)' }]}
hideFooterLine
hideGuideText
wrapClassName="!pb-0"
wrapClassName='!pb-0'
/>
<div className="pr-0">
<Hr className="my-7" />
<div className='pr-0'>
<Hr className='my-7' />
{/* 1) 연도별 하수처리수 재이용 현황 */}
<section className="mt-10">
<SectionTitle>연도별 하수처리수 재이용 현황</SectionTitle>
<div className="mt-10">
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<colgroup>
<col style={{ width: '130px' }} />
<col style={{ width: '180px' }} />
<col />
<col />
<col />
<col />
<col />
<col />
</colgroup>
<thead>
<tr>
<th colSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`}>구분</th>
<th className={`${tableStyles.th} bg-[#01ACE5] text-white`}>2018</th>
<th className={`${tableStyles.th} bg-[#14AFCD] text-white`}>2019</th>
<th className={`${tableStyles.th} bg-[#28B2B4] text-white`}>2020</th>
<th className={`${tableStyles.th} bg-[#3CB59A] text-white`}>2021</th>
<th className={`${tableStyles.th} bg-[#50B880] text-white`}>2022</th>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white`}>2023</th>
</tr>
</thead>
<tbody>
{[
{
group: "전체",
rows: [
["개소수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>", "671", "681", "698", "704", "714", "717"],
["하수처리량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>", "7,163.7", "7,140.4", "7,375.6", "7,335.8", "7,378.9", "7,537.2"],
["재이용량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>", "1,112.7", "1,148.7", "1,148.7", "1,127.0", "1,136.8", "1,137.5"],
["재이용률 <span class='text-[13px] font-normal text-[#767676]'>(%)</span>", "17.4", "16.5", "15.5", "14.1", "14.3", "14.4"],
]
},
{
group: "법적 대상",
rows: [
["개소수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>", "68", "82", "94", "97", "101", "118"],
["하수처리량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>", "761.2", "839.0", "935.3", "1,158.7", "1,220.6", "1,473.6"],
["재이용량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>", "162.2", "190.6", "218.5", "244.6", "247.2", "324.9"],
["재이용률 <span class='text-[13px] font-normal text-[#767676]'>(%)</span>", "24.7", "25.4", "27.8", "25.7", "24.2", "24.1"],
]
},
{
group: "법적 미대상",
rows: [
["개소수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>", "603", "599", "604", "607", "613", "599"],
["하수처리량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>", "6,402.5", "6,301.4", "6,440.3", "6,177.1", "6,158.3", "6,063.5"],
["재이용량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>", "950.6", "958.1", "926.2", "882.4", "889.5", "812.7"],
["재이용률 <span class='text-[13px] font-normal text-[#767676]'>(%)</span>", "16.5", "15.3", "13.6", "12.2", "12.6", "12.5"],
]
}
].map((section, sIdx) => (
<React.Fragment key={sIdx}>
{section.rows.map((row, rIdx) => (
<tr key={`${sIdx}-${rIdx}`} className="bg-white">
{rIdx === 0 && (
<td
rowSpan={section.rows.length}
className={`${tableStyles.td} ${tableStyles.headerBg} !text-[18px] !font-bold !text-neutral-900`}
>
{section.group}
</td>
)}
{row.map((val, i) => {
const isFirstCol = i === 0;
const isHighlightedCol = [1, 3, 5].includes(i);
return (
<td
key={i}
className={`${tableStyles.td} ${isFirstCol ? "!font-bold !text-neutral-900" : ""} ${isHighlightedCol ? "bg-[#EAF5F5]" : ""}`}
dangerouslySetInnerHTML={{ __html: val }}
/>
);
})}
</tr>
))}
</React.Fragment>
))}
</tbody>
</table>
{/* 1) 연도별 하수처리수 재이용 현황 */}
<section className='mt-10'>
<SectionTitle>연도별 하수처리수 재이용 현황</SectionTitle>
<div className='mt-10'>
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<colgroup>
<col style={{ width: '130px' }} />
<col style={{ width: '180px' }} />
<col />
<col />
<col />
<col />
<col />
<col />
</colgroup>
<thead>
<tr>
<th colSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
구분
</th>
<th className={`${tableStyles.th} bg-[#01ACE5] text-white`}>2018</th>
<th className={`${tableStyles.th} bg-[#14AFCD] text-white`}>2019</th>
<th className={`${tableStyles.th} bg-[#28B2B4] text-white`}>2020</th>
<th className={`${tableStyles.th} bg-[#3CB59A] text-white`}>2021</th>
<th className={`${tableStyles.th} bg-[#50B880] text-white`}>2022</th>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white`}>2023</th>
</tr>
</thead>
<tbody>
{[
{
group: '전체',
rows: [
["개소수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>", '671', '681', '698', '704', '714', '717'],
["하수처리량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>", '7,163.7', '7,140.4', '7,375.6', '7,335.8', '7,378.9', '7,537.2'],
["재이용량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>", '1,112.7', '1,148.7', '1,148.7', '1,127.0', '1,136.8', '1,137.5'],
["재이용률 <span class='text-[13px] font-normal text-[#767676]'>(%)</span>", '17.4', '16.5', '15.5', '14.1', '14.3', '14.4'],
],
},
{
group: '법적 대상',
rows: [
["개소수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>", '68', '82', '94', '97', '101', '118'],
["하수처리량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>", '761.2', '839.0', '935.3', '1,158.7', '1,220.6', '1,473.6'],
["재이용량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>", '162.2', '190.6', '218.5', '244.6', '247.2', '324.9'],
["재이용률 <span class='text-[13px] font-normal text-[#767676]'>(%)</span>", '24.7', '25.4', '27.8', '25.7', '24.2', '24.1'],
],
},
{
group: '법적 미대상',
rows: [
["개소수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>", '603', '599', '604', '607', '613', '599'],
["하수처리량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>", '6,402.5', '6,301.4', '6,440.3', '6,177.1', '6,158.3', '6,063.5'],
["재이용량 <span class='text-[13px] font-normal text-[#767676]'>(백만m³/년)</span>", '950.6', '958.1', '926.2', '882.4', '889.5', '812.7'],
["재이용률 <span class='text-[13px] font-normal text-[#767676]'>(%)</span>", '16.5', '15.3', '13.6', '12.2', '12.6', '12.5'],
],
},
].map((section, sIdx) => (
<React.Fragment key={sIdx}>
{section.rows.map((row, rIdx) => (
<tr key={`${sIdx}-${rIdx}`} className='bg-white'>
{rIdx === 0 && (
<td rowSpan={section.rows.length} className={`${tableStyles.td} ${tableStyles.headerBg} !text-[18px] !font-bold !text-neutral-900`}>
{section.group}
</td>
)}
{row.map((val, i) => {
const isFirstCol = i === 0;
const isHighlightedCol = [1, 3, 5].includes(i);
return (
<td
key={i}
className={`${tableStyles.td} ${isFirstCol ? '!font-bold !text-neutral-900' : ''} ${isHighlightedCol ? 'bg-[#EAF5F5]' : ''}`}
dangerouslySetInnerHTML={{ __html: val }}
/>
);
})}
</tr>
))}
</React.Fragment>
))}
</tbody>
</table>
</div>
<SourceText />
</div>
<SourceText />
</div>
</section>
</section>
<Hr className="my-11" />
<Hr className='my-11' />
{/* 2) 용도별 하수처리수 재이용 현황 */}
<section>
<SectionTitle>용도별 하수처리수 재이용 현황</SectionTitle>
<div className="mt-8">
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<colgroup>
<col style={{ width: '180px' }} />
<col />
<col />
<col />
<col />
<col />
<col />
</colgroup>
<thead>
<tr>
<th className={`${tableStyles.th} ${tableStyles.headerBg}`}>구분</th>
<th className={`${tableStyles.th} bg-[#01ACE5] text-white`}>2018</th>
<th className={`${tableStyles.th} bg-[#14AFCD] text-white`}>2019</th>
<th className={`${tableStyles.th} bg-[#28B2B4] text-white`}>2020</th>
<th className={`${tableStyles.th} bg-[#3CB59A] text-white`}>2021</th>
<th className={`${tableStyles.th} bg-[#50B880] text-white`}>2022</th>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white`}>2023</th>
</tr>
</thead>
<tbody>
{[
{ label: "총 재이용량", type: "total", values: ["1,112.7", "1,148.7", "1,144.7", "1,127.0", "1,136.8", "1,137.5"] },
{ label: "장내 재이용", type: "category", values: ["520.6", "519.8", "513.9", "520.3", "502.2", "490.0"] },
{ label: "세척수", type: "red-item", values: ["177.9", "174.4", "169.4", "180.9", "196.8", "191.2"] },
{ label: "냉각수", type: "red-item", values: ["45.5", "46.8", "50.4", "40.6", "38.3", "41.7"] },
{ label: "청소수", type: "red-item", values: ["17.6", "21.2", "21.2", "21.7", "20.8", "20.5"] },
{ label: "조경용수", type: "red-item", values: ["0.1", "0.1", "-", "-", "-", "-"] },
{ label: "회생용수", type: "red-item", values: ["15.8", "16.7", "17.8", "15.7", "14.4", "14.0"] },
{ label: "장내급수도", type: "red-item", values: ["2.4", "-", "-", "-", "-", "-"] },
{ label: "장내기타용수", type: "red-item", values: ["261.3", "260.6", "255.0", "261.1", "231.8", "222.6"] },
{ label: "장외 재이용", type: "category", values: ["592.1", "628.9", "630.8", "606.7", "634.6", "647.5"] },
{ label: "청소·화장실용수", type: "blue-item", values: ["0.6", "0.4", "0.4", "0.4", "0.4", "0.4"] },
{ label: "세척·살수용수", type: "blue-item", values: ["5.6", "9.9", "2.9", "2.1", "3.0", "3.6"] },
{ label: "조경용수", type: "blue-item", values: ["10.3", "16.4", "5.9", "4.5", "8.9", "9.5"] },
{ label: "친수용수", type: "blue-item", values: ["7.8", "3.1", "3.1", "3.0", "10.9", "10.5"] },
{ label: "하천유지용수", type: "blue-item", values: ["480.5", "482.5", "496.2", "442.7", "471.7", "484.0"] },
{ label: "농업용수", type: "blue-item", values: ["12.2", "11.9", "13.4", "10.7", "10.6", "11.4"] },
{ label: "지하수 충전", type: "blue-item", values: ["0.0", "0.0", "0.0", "13.4", "0.0", "0.0"] },
{ label: "공업용수", type: "blue-item", values: ["75.2", "104.6", "108.9", "129.9", "129.0", "128.1"] },
].map((row, idx) => {
const isSubItem = row.type === "red-item" || row.type === "blue-item";
const isLastInGroup = idx === 8 || idx === 17;
{/* 2) 용도별 하수처리수 재이용 현황 */}
<section>
<SectionTitle>용도별 하수처리수 재이용 현황</SectionTitle>
<div className='mt-8'>
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<colgroup>
<col style={{ width: '180px' }} />
<col />
<col />
<col />
<col />
<col />
<col />
</colgroup>
<thead>
<tr>
<th className={`${tableStyles.th} ${tableStyles.headerBg}`}>구분</th>
<th className={`${tableStyles.th} bg-[#01ACE5] text-white`}>2018</th>
<th className={`${tableStyles.th} bg-[#14AFCD] text-white`}>2019</th>
<th className={`${tableStyles.th} bg-[#28B2B4] text-white`}>2020</th>
<th className={`${tableStyles.th} bg-[#3CB59A] text-white`}>2021</th>
<th className={`${tableStyles.th} bg-[#50B880] text-white`}>2022</th>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white`}>2023</th>
</tr>
</thead>
<tbody>
{[
{ label: '총 재이용량', type: 'total', values: ['1,112.7', '1,148.7', '1,144.7', '1,127.0', '1,136.8', '1,137.5'] },
{ label: '장내 재이용', type: 'category', values: ['520.6', '519.8', '513.9', '520.3', '502.2', '490.0'] },
{ label: '세척수', type: 'red-item', values: ['177.9', '174.4', '169.4', '180.9', '196.8', '191.2'] },
{ label: '냉각수', type: 'red-item', values: ['45.5', '46.8', '50.4', '40.6', '38.3', '41.7'] },
{ label: '청소수', type: 'red-item', values: ['17.6', '21.2', '21.2', '21.7', '20.8', '20.5'] },
{ label: '조경용수', type: 'red-item', values: ['0.1', '0.1', '-', '-', '-', '-'] },
{ label: '회생용수', type: 'red-item', values: ['15.8', '16.7', '17.8', '15.7', '14.4', '14.0'] },
{ label: '장내급수도', type: 'red-item', values: ['2.4', '-', '-', '-', '-', '-'] },
{ label: '장내기타용수', type: 'red-item', values: ['261.3', '260.6', '255.0', '261.1', '231.8', '222.6'] },
{ label: '장외 재이용', type: 'category', values: ['592.1', '628.9', '630.8', '606.7', '634.6', '647.5'] },
{ label: '청소·화장실용수', type: 'blue-item', values: ['0.6', '0.4', '0.4', '0.4', '0.4', '0.4'] },
{ label: '세척·살수용수', type: 'blue-item', values: ['5.6', '9.9', '2.9', '2.1', '3.0', '3.6'] },
{ label: '조경용수', type: 'blue-item', values: ['10.3', '16.4', '5.9', '4.5', '8.9', '9.5'] },
{ label: '친수용수', type: 'blue-item', values: ['7.8', '3.1', '3.1', '3.0', '10.9', '10.5'] },
{ label: '하천유지용수', type: 'blue-item', values: ['480.5', '482.5', '496.2', '442.7', '471.7', '484.0'] },
{ label: '농업용수', type: 'blue-item', values: ['12.2', '11.9', '13.4', '10.7', '10.6', '11.4'] },
{ label: '지하수 충전', type: 'blue-item', values: ['0.0', '0.0', '0.0', '13.4', '0.0', '0.0'] },
{ label: '공업용수', type: 'blue-item', values: ['75.2', '104.6', '108.9', '129.9', '129.0', '128.1'] },
].map((row, idx) => {
const isSubItem = row.type === 'red-item' || row.type === 'blue-item';
const isLastInGroup = idx === 8 || idx === 17;
return (
<tr key={idx} className="bg-white">
<td className={`${tableStyles.td} relative`}>
{isSubItem ? (
<>
<div className={`absolute left-[1px] w-[16px] bg-white z-10 top-[-2px] ${isLastInGroup ? 'bottom-[0px]' : 'bottom-[-2px]'}`} />
<div className={`absolute left-[16px] w-[4px] z-20 ${row.type === 'red-item' ? 'bg-[#FF8B8B]' : 'bg-[#4ECBFF]'} top-[-2px] ${isLastInGroup ? 'bottom-[0px]' : 'bottom-[-2px]'}`} />
<span className="text-[#4f4f4f] font-bold block w-full text-center relative z-30 translate-x-[8px]">{row.label}</span>
</>
) : (
<span className="font-bold text-neutral-900 block w-full text-center">{row.label}</span>
)}
</td>
{row.values.map((val, i) => (
<td key={i} className={`${tableStyles.td} ${[0, 2, 4].includes(i) ? "bg-[#EAF5F5]" : ""}`}>
return (
<tr key={idx} className='bg-white'>
<td className={`${tableStyles.td} relative`}>
{isSubItem ? (
<>
<div className={`absolute left-[1px] w-[16px] bg-white z-10 top-[-2px] ${isLastInGroup ? 'bottom-[0px]' : 'bottom-[-2px]'}`} />
<div
className={`absolute left-[16px] w-[4px] z-20 ${row.type === 'red-item' ? 'bg-[#FF8B8B]' : 'bg-[#4ECBFF]'} top-[-2px] ${isLastInGroup ? 'bottom-[0px]' : 'bottom-[-2px]'}`}
/>
<span className='text-[#4f4f4f] font-bold block w-full text-center relative z-30 translate-x-[8px]'>{row.label}</span>
</>
) : (
<span className='font-bold text-neutral-900 block w-full text-center'>{row.label}</span>
)}
</td>
{row.values.map((val, i) => (
<td key={i} className={`${tableStyles.td} ${[0, 2, 4].includes(i) ? 'bg-[#EAF5F5]' : ''}`}>
{val}
</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
<SourceText />
</div>
</section>
<Hr className='my-11' />
{/* 3) 하수처리시설 설치 및 이용 현황 */}
<section>
<SectionTitle>하수처리시설 설치 및 이용 현황</SectionTitle>
<div className='mt-8'>
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<colgroup>
<col style={{ width: '130px' }} />
<col style={{ width: '160px' }} />
<col />
<col />
<col />
<col />
<col />
<col />
</colgroup>
<thead>
<tr>
<th colSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
구분
</th>
<th className={`${tableStyles.th} bg-[#01ACE5] text-white`}>2018</th>
<th className={`${tableStyles.th} bg-[#14AFCD] text-white`}>2019</th>
<th className={`${tableStyles.th} bg-[#28B2B4] text-white`}>2020</th>
<th className={`${tableStyles.th} bg-[#3CB59A] text-white`}>2021</th>
<th className={`${tableStyles.th} bg-[#50B880] text-white`}>2022</th>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white`}>2023</th>
</tr>
</thead>
<tbody>
{[
{
group: '전체',
rows: [
["시설수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>", '671', '681', '698', '704', '714', '717'],
["시설용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>", '25,846', '26,067', '26,244', '26,492', '26,586', '26,766'],
["처리용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>", '1,112.7', '1,148.7', '1,144.7', '1,127.0', '1,136.8', '1,137.5'],
],
},
{
group: '법적 대상',
rows: [
["시설수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>", '68', '82', '94', '97', '101', '118'],
["시설용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>", '3,088.9', '3,359.2', '3,612.6', '4,412.2', '4,692.3', '5,503.8'],
["처리용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>", '162.2', '190.6', '218.5', '244.6', '247.2', '324.9'],
],
},
{
group: '법적 미대상',
rows: [
["시설수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>", '603', '599', '604', '607', '613', '599'],
["시설용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>", '22,757', '22,708', '22,631', '22,080', '21,894', '21,262'],
["처리용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>", '950.6', '958.1', '926.2', '882.4', '889.5', '812.7'],
],
},
].map((section, sIdx) => (
<React.Fragment key={sIdx}>
{section.rows.map((row, rIdx) => (
<tr key={`${sIdx}-${rIdx}`} className='bg-white'>
{rIdx === 0 && (
<td rowSpan={section.rows.length} className={`${tableStyles.td} ${tableStyles.headerBg} !text-[18px] !font-bold !text-neutral-900`}>
{section.group}
</td>
)}
{row.map((val, i) => {
const isFirstCol = i === 0;
const isHighlightedCol = [1, 3, 5].includes(i);
return (
<td
key={i}
className={`${tableStyles.td} ${isFirstCol ? '!font-bold !text-neutral-900' : ''} ${isHighlightedCol ? 'bg-[#EAF5F5]' : ''}`}
dangerouslySetInnerHTML={{ __html: val }}
/>
);
})}
</tr>
))}
</React.Fragment>
))}
</tbody>
</table>
</div>
<SourceText />
</div>
</section>
<Hr className='my-11' />
{/* 4) 시도별 하수처리수 재이용 현황 */}
<section className='pb-[200px]'>
<SectionTitle>시도별 하수처리수 재이용 현황</SectionTitle>
<div className='mt-8'>
<div className={tableStyles.container}>
<div className='text-right text-[13px] mb-2 text-neutral-600'>(단위: 백만m³/년)</div>
<table className={tableStyles.table}>
<colgroup>
<col style={{ width: '160px' }} />
<col />
<col />
<col />
<col />
<col />
<col />
<col />
<col />
<col />
<col />
</colgroup>
<thead>
<tr>
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
시도
</th>
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
연간
<br />
하수
<br />
처리량
</th>
<th colSpan={7} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}>
재이용현황
</th>
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
재이용률
<br />
(%)
</th>
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
비고
</th>
</tr>
<tr>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
계
</th>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`}>
장내
</th>
<th colSpan={5} className={`${tableStyles.th} ${tableStyles.headerBg} py-1`}>
장외
</th>
</tr>
<tr>
{/* 💡 하천유지용수는 14px, 나머지 하위 헤더는 16px로 적용 */}
<th className={`${tableStyles.th} bg-[#01ACE5] text-white py-2 text-[16px]`}>소계</th>
<th className={`${tableStyles.th} bg-[#1FB1C2] text-white py-2 text-[13px]`}>하천유지용수</th>
<th className={`${tableStyles.th} bg-[#3EB59F] text-white py-2 text-[16px]`}>공업용수</th>
<th className={`${tableStyles.th} bg-[#5DB97C] text-white py-2 text-[16px]`}>농업용수</th>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white py-2 text-[16px]`}>기타용수</th>
</tr>
</thead>
<tbody>
{[
['합계', '7,537.2', '1,137.5', '490.0', '647.5', '484.0', '128.1', '11.4', '24.0', '15.1%', ''],
['서울특별시', '1,567.0', '114.9', '59.3', '55.6', '55.1', '-', '-', '0.5', '7.3%', ''],
['부산광역시', '491.5', '146.2', '120.7', '25.5', '25.3', '-', '-', '0.2', '29.7%', ''],
['대구광역시', '432.7', '102.4', '38.2', '64.2', '64.1', '-', '-', '0.1', '23.7%', ''],
['인천광역시', '302.3', '57.7', '11.6', '46.1', '29.2', '12.1', '0.5', '4.3', '19.1%', ''],
['광주광역시', '211.1', '28.7', '6.5', '22.3', '22.2', '-', '-', '0.1', '13.6%', ''],
['대전광역시', '244.6', '11.9', '11.9', '0.68', '0.0', '-', '-', '0.1', '4.9%', ''],
['울산광역시', '235.1', '36.1', '6.1', '29.9', '29.7', '0.2', '-', '-', '15.3%', ''],
['세종특별자치시', '38.5', '2.5', '2.0', '0.5', '0.4', '-', '0.2', '-', '6.6%', ''],
['경기도', '1,926.8', '313.3', '122.2', '191.0', '153.0', '33.3', '0.4', '4.3', '16.3%', ''],
['강원도', '230.4', '19.6', '15.4', '4.2', '2.1', '-', '-', '2.1', '8.5%', ''],
['충청북도', '190.9', '15.6', '6.3', '9.2', '7.4', '0.6', '-', '1.2', '8.2%', ''],
['충청남도', '241.5', '41.4', '9.8', '31.5', '17.7', '8.6', '4.5', '0.8', '17.1%', ''],
['전라북도', '292.4', '42.7', '7.5', '35.1', '33.5', '0.2', '1.0', '0.5', '14.6%', ''],
['전라남도', '254.0', '23.6', '12.3', '11.3', '8.7', '0.0', '1.0', '1.6', '9.3%', ''],
['경상북도', '378.4', '129.1', '33.7', '95.3', '20.8', '72.5', '0.8', '1.3', '34.1%', ''],
['경상남도', '411.3', '50.1', '25.0', '25.0', '14.7', '0.5', '3.0', '6.9', '12.2%', ''],
['제주특별자치도', '88.6', '1.7', '1.5', '0.12', '-', '-', '0.1', '-', '1.9%', ''],
].map((row, idx) => (
<tr key={idx} className='bg-white'>
{/* 💡 연간하수처리량(index 1), 장내(index 3), 재이용률(index 9) 컬러 하이라이트 적용 */}
{row.map((val, i) => (
<td key={i} className={`${tableStyles.td} ${i === 0 ? '!font-bold !text-neutral-900' : ''} ${[1, 3, 9].includes(i) ? 'bg-[#EAF5F5]' : ''}`}>
{val}
</td>
))}
</tr>
);
})}
</tbody>
</table>
))}
</tbody>
</table>
</div>
<SourceText />
</div>
<SourceText />
</div>
</section>
<Hr className="my-11" />
{/* 3) 하수처리시설 설치 및 이용 현황 */}
<section>
<SectionTitle>하수처리시설 설치 및 이용 현황</SectionTitle>
<div className="mt-8">
<div className={tableStyles.container}>
<table className={tableStyles.table}>
<colgroup>
<col style={{ width: '130px' }} />
<col style={{ width: '160px' }} />
<col />
<col />
<col />
<col />
<col />
<col />
</colgroup>
<thead>
<tr>
<th colSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`}>구분</th>
<th className={`${tableStyles.th} bg-[#01ACE5] text-white`}>2018</th>
<th className={`${tableStyles.th} bg-[#14AFCD] text-white`}>2019</th>
<th className={`${tableStyles.th} bg-[#28B2B4] text-white`}>2020</th>
<th className={`${tableStyles.th} bg-[#3CB59A] text-white`}>2021</th>
<th className={`${tableStyles.th} bg-[#50B880] text-white`}>2022</th>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white`}>2023</th>
</tr>
</thead>
<tbody>
{[
{
group: "전체",
rows: [
["시설수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>", "671", "681", "698", "704", "714", "717"],
["시설용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>", "25,846", "26,067", "26,244", "26,492", "26,586", "26,766"],
["처리용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>", "1,112.7", "1,148.7", "1,144.7", "1,127.0", "1,136.8", "1,137.5"],
]
},
{
group: "법적 대상",
rows: [
["시설수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>", "68", "82", "94", "97", "101", "118"],
["시설용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>", "3,088.9", "3,359.2", "3,612.6", "4,412.2", "4,692.3", "5,503.8"],
["처리용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>", "162.2", "190.6", "218.5", "244.6", "247.2", "324.9"],
]
},
{
group: "법적 미대상",
rows: [
["시설수 <span class='text-[13px] font-normal text-[#767676]'>(개소)</span>", "603", "599", "604", "607", "613", "599"],
["시설용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>", "22,757", "22,708", "22,631", "22,080", "21,894", "21,262"],
["처리용량 <span class='text-[13px] font-normal text-[#767676]'>(만m³/일)</span>", "950.6", "958.1", "926.2", "882.4", "889.5", "812.7"],
]
}
].map((section, sIdx) => (
<React.Fragment key={sIdx}>
{section.rows.map((row, rIdx) => (
<tr key={`${sIdx}-${rIdx}`} className="bg-white">
{rIdx === 0 && (
<td
rowSpan={section.rows.length}
className={`${tableStyles.td} ${tableStyles.headerBg} !text-[18px] !font-bold !text-neutral-900`}
>
{section.group}
</td>
)}
{row.map((val, i) => {
const isFirstCol = i === 0;
const isHighlightedCol = [1, 3, 5].includes(i);
return (
<td
key={i}
className={`${tableStyles.td} ${isFirstCol ? "!font-bold !text-neutral-900" : ""} ${isHighlightedCol ? "bg-[#EAF5F5]" : ""}`}
dangerouslySetInnerHTML={{ __html: val }}
/>
);
})}
</tr>
))}
</React.Fragment>
))}
</tbody>
</table>
</div>
<SourceText />
</div>
</section>
<Hr className="my-11" />
{/* 4) 시도별 하수처리수 재이용 현황 */}
<section className="pb-[200px]">
<SectionTitle>시도별 하수처리수 재이용 현황</SectionTitle>
<div className="mt-8">
<div className={tableStyles.container}>
<div className="text-right text-[13px] mb-2 text-neutral-600">(단위: 백만m³/일)</div>
<table className={tableStyles.table}>
<colgroup>
<col style={{ width: '160px' }} />
<col />
<col />
<col />
<col />
<col />
<col />
<col />
<col />
<col />
<col />
</colgroup>
<thead>
<tr>
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`}>시도</th>
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`}>연간<br/>하수<br/>처리량</th>
<th colSpan={7} className={`${tableStyles.th} ${tableStyles.headerBg} py-2`}>재이용현황</th>
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`}>재이용률<br/>(%)</th>
<th rowSpan={3} className={`${tableStyles.th} ${tableStyles.headerBg}`}>비고</th>
</tr>
<tr>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`}>계</th>
<th rowSpan={2} className={`${tableStyles.th} ${tableStyles.headerBg}`}>장내</th>
<th colSpan={5} className={`${tableStyles.th} ${tableStyles.headerBg} py-1`}>장외</th>
</tr>
<tr>
{/* 💡 하천유지용수는 14px, 나머지 하위 헤더는 16px로 적용 */}
<th className={`${tableStyles.th} bg-[#01ACE5] text-white py-2 text-[16px]`}>소계</th>
<th className={`${tableStyles.th} bg-[#1FB1C2] text-white py-2 text-[13px]`}>하천유지용수</th>
<th className={`${tableStyles.th} bg-[#3EB59F] text-white py-2 text-[16px]`}>공업용수</th>
<th className={`${tableStyles.th} bg-[#5DB97C] text-white py-2 text-[16px]`}>농업용수</th>
<th className={`${tableStyles.th} bg-[#6DBB4F] text-white py-2 text-[16px]`}>기타용수</th>
</tr>
</thead>
<tbody>
{[
["합계", "7,537.2", "1,137.5", "490.0", "647.5", "484.0", "128.1", "11.4", "24.0", "15.1%", ""],
["서울특별시", "1,567.0", "114.9", "59.3", "55.6", "55.1", "-", "-", "0.5", "7.3%", ""],
["부산광역시", "491.5", "146.2", "120.7", "25.5", "25.3", "-", "-", "0.2", "29.7%", ""],
["대구광역시", "432.7", "102.4", "38.2", "64.2", "64.1", "-", "-", "0.1", "23.7%", ""],
["인천광역시", "302.3", "57.7", "11.6", "46.1", "29.2", "12.1", "0.5", "4.3", "19.1%", ""],
["광주광역시", "211.1", "28.7", "6.5", "22.3", "22.2", "-", "-", "0.1", "13.6%", ""],
["대전광역시", "244.6", "11.9", "11.9", "0.68", "0.0", "-", "-", "0.1", "4.9%", ""],
["울산광역시", "235.1", "36.1", "6.1", "29.9", "29.7", "0.2", "-", "-", "15.3%", ""],
["세종특별자치시", "38.5", "2.5", "2.0", "0.5", "0.4", "-", "0.2", "-", "6.6%", ""],
["경기도", "1,926.8", "313.3", "122.2", "191.0", "153.0", "33.3", "0.4", "4.3", "16.3%", ""],
["강원도", "230.4", "19.6", "15.4", "4.2", "2.1", "-", "-", "2.1", "8.5%", ""],
["충청북도", "190.9", "15.6", "6.3", "9.2", "7.4", "0.6", "-", "1.2", "8.2%", ""],
["충청남도", "241.5", "41.4", "9.8", "31.5", "17.7", "8.6", "4.5", "0.8", "17.1%", ""],
["전라북도", "292.4", "42.7", "7.5", "35.1", "33.5", "0.2", "1.0", "0.5", "14.6%", ""],
["전라남도", "254.0", "23.6", "12.3", "11.3", "8.7", "0.0", "1.0", "1.6", "9.3%", ""],
["경상북도", "378.4", "129.1", "33.7", "95.3", "20.8", "72.5", "0.8", "1.3", "34.1%", ""],
["경상남도", "411.3", "50.1", "25.0", "25.0", "14.7", "0.5", "3.0", "6.9", "12.2%", ""],
["제주특별자치도", "88.6", "1.7", "1.5", "0.12", "-", "-", "0.1", "-", "1.9%", ""],
].map((row, idx) => (
<tr key={idx} className="bg-white">
{/* 💡 연간하수처리량(index 1), 장내(index 3), 재이용률(index 9) 컬러 하이라이트 적용 */}
{row.map((val, i) => (
<td
key={i}
className={`${tableStyles.td} ${i === 0 ? "!font-bold !text-neutral-900" : ""} ${[1, 3, 9].includes(i) ? "bg-[#EAF5F5]" : ""}`}
>
{val}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<SourceText />
</div>
</section>
</section>
</div>
</div>
);
}
}
+21 -22
View File
@@ -20,9 +20,24 @@ type FieldKey = "bizNo" | "workerNo" | "pw";
const REGISTER_HREF = "/apply/dmdco";
function isBizNoValid(v: string) {
return /^\d{10}$/.test(v);
return /^\d{10}$/.test(v.replace(/\D/g, ""));
}
const getBusinessNumberDigits = (value: string) =>
value.replace(/\D/g, "").slice(0, 10);
const formatBusinessNumber = (value: string) => {
const normalized = value.replace(/[^\d-]/g, "").slice(0, 12);
const digits = getBusinessNumberDigits(normalized);
if (normalized.endsWith("-") && digits.length === 3) return `${digits}-`;
if (normalized.endsWith("-") && digits.length === 5)
return `${digits.slice(0, 3)}-${digits.slice(3)}-`;
if (digits.length <= 3) return digits;
if (digits.length <= 5) return `${digits.slice(0, 3)}-${digits.slice(3)}`;
return `${digits.slice(0, 3)}-${digits.slice(3, 5)}-${digits.slice(5)}`;
};
function inputCls(opts: { hasError?: boolean; disabled?: boolean }) {
const base =
"h-[48px] w-full rounded-[4px] border px-5 text-[18px] outline-none";
@@ -102,7 +117,7 @@ export default function LoginModal({
const validate = () => {
const next: Partial<Record<FieldKey, string>> = {};
const b = bizNo.trim();
const b = getBusinessNumberDigits(bizNo);
const w = workerNo.trim();
const p = pw.trim();
@@ -122,23 +137,6 @@ export default function LoginModal({
return next;
};
const formatBusinessNumber = (value: string) => {
// 숫자만 추출
const digitsOnly = value.replace(/\D/g, "");
// 10자리까지만 처리
const limitedDigits = digitsOnly.slice(0, 10);
// 3-2-5 형식으로 포맷팅
if (limitedDigits.length <= 3) {
return limitedDigits;
} else if (limitedDigits.length <= 5) {
return `${limitedDigits.slice(0, 3)}-${limitedDigits.slice(3)}`;
} else {
return `${limitedDigits.slice(0, 3)}-${limitedDigits.slice(3, 5)}-${limitedDigits.slice(5)}`;
}
};
const markTouched = (key: FieldKey) =>
setTouched((p) => ({ ...p, [key]: true }));
@@ -154,8 +152,9 @@ export default function LoginModal({
try {
setSubmitting(true);
const businessNumber = getBusinessNumberDigits(bizNo);
const param = {
dmdInfoId: bizNo + (noWorker ? "0000" : workerNo), // 종사업자번호 해당없음 "0000"으로 채움
dmdInfoId: businessNumber + (noWorker ? "0000" : workerNo), // 종사업자번호 해당없음 "0000"으로 채움
dmdAplyPswd: sha512.hex(pw),
};
await actionLogin(param as DemandCompType);
@@ -227,9 +226,9 @@ export default function LoginModal({
사업자 등록번호
</label>
<input
value={formatBusinessNumber(bizNo)}
value={bizNo}
onChange={(e) => {
setBizNo(e.target.value.replace(/\D/g, "").slice(0, 10));
setBizNo(formatBusinessNumber(e.target.value));
setErrors((p) => ({ ...p, bizNo: undefined }));
}}
onBlur={() => {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 KiB

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 24 KiB

+2 -1
View File
@@ -18,4 +18,5 @@ DC_URL = https://apigateway.apps.rsonesoft.com
# NEXT_PUBLIC_CM_LOGIN_PAGE_URL = http://dev.onasys.co.kr:3000/auth/login
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login
# NEXT_PUBLIC_SEWERAGE_API_URL=http://192.168.1.66:8883
NEXT_PUBLIC_COOKIE_COMAIN=.mind-one.co.kr
NEXT_PUBLIC_COOKIE_COMAIN=.mind-one.co.kr
NEXT_PUBLIC_NODE_ENV = "development"
+2 -1
View File
@@ -14,4 +14,5 @@ NEXT_PUBLIC_NGII_PARAM = 04trYP9_xwLAfALjwZ-B8g&service=WMTS&request=GetTile&ver
SEWERAGE_API_URL = http://con.mind-one.co.kr:11083
DC_URL = https://apigateway.apps.rsonesoft.com
NEXT_PUBLIC_CM_LOGIN_PAGE_URL = /auth/login
NEXT_PUBLIC_COOKIE_COMAIN=.mind-one.co.kr
NEXT_PUBLIC_COOKIE_COMAIN=.mind-one.co.kr
NEXT_PUBLIC_NODE_ENV = "development"
+57 -1
View File
@@ -6,6 +6,39 @@
현재 목표는 리팩토링 전용 작업이 아니라, 하수관로 운영관리 시스템 2차사업 고도화를 수행하는 것이다.
즉, 기존 `sn` 화면과 흐름을 안정적으로 유지하면서 2차 요구사항에 해당하는 화면 개발, 기능 확장, 백엔드 연동 보완을 수행해야 한다.
## 사용자 작업 범위: UI/UX 및 퍼블리싱
현재 사용자와 진행하는 작업의 기본 범위는 UI/UX 및 프론트엔드 퍼블리싱으로 제한한다.
사용자가 별도로 명시하지 않는 한 아래 기준을 모든 후속 작업에 적용한다.
### 허용 범위
- 화면 레이아웃과 시각적 위계 조정
- JSX 마크업, Tailwind CSS, CSS 스타일 수정
- 색상, 여백, 간격, 크기, 타이포그래피, 아이콘, 테두리, 배경 조정
- 반응형 화면, 접근성, 포커스와 시각적 상태 개선
- 표시 전용 컴포넌트 구성과 퍼블리싱 품질 개선
- 기존 화면 내용과 동작을 유지하는 범위의 UI 구조 정리
### 기본 금지 범위
- 백엔드 코드, API 계약, 요청·응답 데이터 구조 변경
- `.env*`, middleware, 프록시, 인증과 권한 설정 변경
- 업무 로직, 상태 모델, 데이터 가공 및 저장 흐름 변경
- 데이터베이스, 서버 프로세스와 배포 설정 변경
- 패키지 설치·업데이트와 의존성 구조 변경
- 사용자 요청 없이 화면의 문구, 데이터 항목, 기능 동작 변경
금지 범위의 변경이 필요해 보이면 직접 수정하지 않고 원인과 필요한 작업만 사용자에게 설명한다.
### UI 검증 안전 기준
- 사용자가 실행한 개발 서버가 있는지 실제 앱 포트까지 먼저 확인한다.
- 개발 서버 실행 중에는 같은 앱의 `next build`를 실행하지 않는다.
- UI 작업의 기본 검증은 실행 중인 개발 서버의 HMR, 브라우저 화면 확인, `git diff --check`로 제한한다.
- 개발 서버 종료, `.next` 정리, 전체 빌드와 프로세스 재시작은 사용자가 명시적으로 승인한 경우에만 수행한다.
- UI 변경 전후에 기존 문구, 데이터, 이벤트와 업무 동작이 유지되는지 확인한다.
---
## 공통 시작 기준
@@ -101,7 +134,7 @@ Codex 작업 루트는 가능하면 `../../../../../`로 잡고, 공통 시작
## 작업 원칙
1. 모든 변경은 2차 요구사항, 화면설계, 기능 명세 또는 승인된 작업 문서를 근거로 한다.
1. 모든 변경은 원본 요구사항정의서와 화면설계서, 2차 요구사항, 기능 명세 또는 승인된 작업 문서를 근거로 한다.
2. `apps/sn`과 `packages/common/business_components` 범위를 넘는 수정은 하지 않는다.
3. 공통 패키지 수정은 `sn` 요구사항을 충족하기 위한 최소 범위로 제한한다.
4. 다른 앱 전용 로직을 `business_components`에 넣지 않는다.
@@ -117,6 +150,18 @@ Codex 작업 루트는 가능하면 `../../../../../`로 잡고, 공통 시작
14. `BaseLayout`, `NoFooterLayout`, `PageLayout`, `SearchDiv`, `SearchFormLeft/Right`, `ListDiv`, `UiTable`, `UiModal`, `Gis` 조합을 우선 재사용한다.
15. 새 UI 구조가 꼭 필요하면 기존 패턴으로 해결되지 않는 이유를 먼저 설명한다.
## 원본 요구사항 사전 확인
- 모든 `sn` 화면 설계, 구현, 수정, 검증과 문서 작업은 시작 전에 아래 원본 파일에서 관련 범위를 직접 확인한다.
- `COMMON_DOCS_ROOT/기준문서/화면설계서.pptx`
- `COMMON_DOCS_ROOT/기준문서/요구사항정의서.xlsx`
- 화면설계서의 관련 슬라이드, 화면 ID, 입력항목, 버튼, 표 컬럼과 사용자 흐름을 확인한다.
- 요구사항정의서의 관련 시트, 요구사항 ID, 기능 목적과 완료 조건을 함께 대조한다.
- `de`나 메인 에이전트의 요약, 기존 작업계획 또는 현재 화면만으로 원본 확인을 대체하지 않는다.
- 확인한 요구사항 ID, 화면 ID와 슬라이드를 작업계획 또는 결과 보고에 남긴다.
- 원본에 없는 화면 동작이나 데이터 항목은 임의로 추가하지 않고 `설계 제안` 또는 `확인 필요`로 구분한다.
- 상세 절차는 `COMMON_DOCS_ROOT/기준문서/012-원본요구사항사전확인.md`를 따른다.
---
## 허용 작업
@@ -181,6 +226,17 @@ Codex 작업 루트는 가능하면 `../../../../../`로 잡고, 공통 시작
VSCode나 별도 터미널에서 `sn` Next 개발 서버가 실행 중이면 `next build`, `pnpm --filter sn run build`, `pnpm turbo run build --filter=sn` 같은 빌드 명령을 실행하지 않는다.
개발 서버와 빌드가 동시에 `.next` 산출물을 갱신하면 개발 서버가 종료되거나 꼬일 수 있으므로, 빌드 검증은 사용자에게 확인한 뒤 개발 서버를 끄고 수행한다.
### 프론트엔드 실행 프로세스 관리
- 모든 세션·역할·문서/검증 섹션에 공통 D:/workspace/AGENTS.md의 테스트 임시 파일·결과 파일 정리 기준을 적용한다. 일회성 검증 스크립트·캡처·Network/서버 로그·원시 결과 파일은 최종 보고서에 요약 후 삭제한다. 정식 회귀 테스트·최종 산출물·사용자 원본/복구 백업은 유지한다. 사용자 dev server가 쓰는 .next·공유 캐시·node_modules는 임시 검증물이라는 이유로 포괄 삭제하지 않는다.
- Codex 에이전트가 작업 또는 검증을 위해 직접 기동한 `next dev`, `dev:sn`, Turborepo dev와 기타 프론트엔드 장시간 실행 프로세스는 작업 종료 전에 반드시 종료한다.
- 셸, 백그라운드, 숨김 창 등 VSCode 밖에서 에이전트가 기동한 프로세스도 종료 대상이다.
- 사용자가 VSCode 또는 별도 터미널에서 직접 실행한 프론트엔드 개발 서버는 임의로 종료·재시작·교체하지 않는다. 종료가 필요하면 먼저 사용자에게 확인한다.
- 기동 시 PID와 포트를 기록하고, 종료 시에는 에이전트가 기동한 정확한 PID와 하위 프로세스 트리만 종료한다.
- 사용자가 계속 실행해 두라고 명시한 경우에는 종료하지 않고 최종 보고에 PID, 포트와 종료 방법을 남긴다.
- 최종 보고 전에 에이전트가 사용한 프론트엔드 포트 해제와 임시 로그 파일 핸들 종료를 확인한다.
이 저장소는 `main` 브랜치 push 전에 `.husky/pre-push`에서 아래 빌드를 수행한다.
```sh
+1 -5
View File
@@ -9,9 +9,5 @@ export default function AuthLayout({
}: {
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body>{children}</body>
</html>
);
return children;
}
@@ -1,49 +1,79 @@
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@common/components/ui";
/** 시설 상세정보 화면의 운영정보 입력 영역을 공통 형식으로 표시한다. */
import {
Table,
TableHeader,
TableRow,
TableHead,
TableBody,
TableCell,
} from "@common/components/ui";
import React from "react";
interface InputSectionProps {
title: string;
data: { year: string; type: string, bizInfoNm: string, bizType: string }[];
data: { year: string; type: string; bizInfoNm: string; bizType: string }[];
sectionKey: string;
onInputChange: (section: string, index: number, field: string, value: string) => void;
onInputChange: (
section: string,
index: number,
field: string,
value: string,
) => void;
}
const InputSection: React.FC<InputSectionProps> = ({ title, data, sectionKey, onInputChange }) => {
const InputSection: React.FC<InputSectionProps> = ({
title,
data,
sectionKey,
onInputChange,
}) => {
return (
<div>
{/* 섹션 제목 */}
<div className="card-header">
<h3 className="text-2xl mx-3 font-medium text-label whitespace-nowrap">{title}</h3>
<h3 className="mx-3 whitespace-nowrap text-2xl font-medium text-label">
{title}
</h3>
</div>
{/* 테이블 */}
<div className="card !m-[1.2rem] !p-0r">
<div className="card !p-0r !m-[1.2rem]">
<Table variant="secondary" className="w-full">
<TableHeader>
<TableRow>
<TableHead className="border border-gray-300 px-2 py-2 text-sm w-auto whitespace-nowrap">구분</TableHead>
<TableHead className="border border-gray-300 px-2 py-2 text-sm w-[80px] whitespace-nowrap">진단연도</TableHead>
<TableHead className="border border-gray-300 px-2 py-2 text-sm w-auto whitespace-nowrap">사업명</TableHead>
<TableHead className="border border-gray-300 px-2 py-2 text-sm w-auto whitespace-nowrap">유형</TableHead>
<TableHead className="w-auto whitespace-nowrap border border-border px-2 py-2 text-sm">
구분
</TableHead>
<TableHead className="w-[80px] whitespace-nowrap border border-border px-2 py-2 text-sm">
진단연도
</TableHead>
<TableHead className="w-auto whitespace-nowrap border border-border px-2 py-2 text-sm">
사업명
</TableHead>
<TableHead className="w-auto whitespace-nowrap border border-border px-2 py-2 text-sm">
유형
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((row, index) => (
<TableRow key={index}>
<TableCell className="border border-gray-300 px-2 py-2 text-sm text-center leading-normal w-auto whitespace-nowrap">
<TableCell className="w-auto whitespace-nowrap border border-border px-2 py-2 text-center text-sm leading-normal">
{row.type}
</TableCell>
<TableCell className="border border-gray-300 px-2 py-2 text-sm w-[80px] whitespace-nowrap">
<TableCell className="w-[80px] whitespace-nowrap border border-border px-2 py-2 text-sm">
<input
type="text"
value={row.year}
onChange={(e) => onInputChange(sectionKey, index, "year", e.target.value)}
className="w-full text-xl border-radius-0 px-3 py-2 text-base text-center leading-normal"
onChange={(e) =>
onInputChange(sectionKey, index, "year", e.target.value)
}
className="border-radius-0 w-full px-3 py-2 text-center text-base text-xl leading-normal"
/>
</TableCell>
<TableCell className="border border-gray-300 px-2 py-2 text-sm text-center leading-normal w-auto">
<TableCell className="w-auto border border-border px-2 py-2 text-center text-sm leading-normal">
{row.bizInfoNm}
</TableCell>
<TableCell className="border border-gray-300 px-2 py-2 text-sm text-center leading-normal w-auto whitespace-nowrap">
<TableCell className="w-auto whitespace-nowrap border border-border px-2 py-2 text-center text-sm leading-normal">
{row.bizType}
</TableCell>
</TableRow>
@@ -55,4 +85,4 @@ const InputSection: React.FC<InputSectionProps> = ({ title, data, sectionKey, on
);
};
export default InputSection;
export default InputSection;
@@ -1,7 +1,13 @@
/**
* 화면명: 노후관로 레이어 설정
* 사업차수와 구조적·운영적 상태등급별 지도 표시 조건을 설정한다.
*/
"use client";
import { useEffect, useState } from "react";
import { useAlertStore } from "@/shared/lib";
import { validateElapsedYears, type AgedPipeFilters } from "./agedPipeUtils";
import LayerSettingCard from "../layerSetting/LayerSettingCard";
type MaybePromise<T = void> = T | Promise<T>;
@@ -10,6 +16,9 @@ type Props = {
onFiltersChange: (filters: AgedPipeFilters) => void;
onApply: (filters?: AgedPipeFilters) => MaybePromise;
compact?: boolean;
enabled?: boolean;
onEnabledChange?: (enabled: boolean) => void;
onClose?: () => void;
};
export default function AgedPipeLayerSettingPanel({
@@ -17,16 +26,23 @@ export default function AgedPipeLayerSettingPanel({
onFiltersChange,
onApply,
compact = false,
enabled = true,
onEnabledChange = () => {},
onClose = () => {},
}: Props) {
const alert = useAlertStore((state) => state.setMessage);
const [isCollapsed, setIsCollapsed] = useState(false);
const [elapsedYearsText, setElapsedYearsText] = useState(String(filters.elapsedYears ?? ""));
const [elapsedYearsText, setElapsedYearsText] = useState(
String(filters.elapsedYears ?? ""),
);
useEffect(() => {
setElapsedYearsText(String(filters.elapsedYears ?? ""));
}, [filters.elapsedYears]);
const commitElapsedYears = async () => {
const elapsedYears = elapsedYearsText === "" ? NaN : Number(elapsedYearsText);
const elapsedYears =
elapsedYearsText === "" ? NaN : Number(elapsedYearsText);
if (!validateElapsedYears(elapsedYears)) {
alert("경과년도는 1년~100년 범위 내에서 입력해주세요.");
return;
@@ -41,18 +57,19 @@ export default function AgedPipeLayerSettingPanel({
alert(`노후관로 경과년도 ${elapsedYears}년으로 반영되었습니다.`);
};
const inputClass = "h-[22px] border border-[#ccd6df] px-[2px] text-center text-[1rem] leading-none text-[#222]";
const numberInputClass = inputClass + " w-[40px]";
const applyButtonClass = "grid h-[20px] w-[20px] shrink-0 place-items-center rounded-full bg-[#31a83b] text-[0.95rem] font-bold leading-none text-white";
const indicator = <span className="h-2 w-2 shrink-0 rounded-full bg-[#243b93]" />;
const inputClass =
"h-[26px] !rounded border border-input bg-background px-2 py-0 text-center text-[12px] leading-none text-foreground";
const numberInputClass = inputClass + " min-w-0 flex-1";
const applyButtonClass =
"h-[26px] shrink-0 rounded border border-[#A9C5E5] bg-[#EDF4FC] px-2 py-0 text-[12px] font-medium leading-none text-[#2463A6]";
const settingRow = (
<div className="flex min-h-[25px] items-center gap-1 whitespace-nowrap text-[1.05rem] font-medium text-slate-700">
<div className="flex min-w-0 items-center gap-1">
{indicator}
<label htmlFor="aged-pipe-elapsed-years" className="shrink-0">노후관로 경과년도(년)</label>
<div className="flex min-h-[26px] flex-col items-stretch gap-1.5 whitespace-nowrap text-[1.15rem] font-medium text-label">
<div className="flex min-w-0 items-center">
<label htmlFor="aged-pipe-elapsed-years" className="shrink-0">
노후관로 경과년도(년)
</label>
</div>
<div className="ml-auto flex shrink-0 items-center gap-1">
<div className="flex w-full items-center gap-1">
<input
id="aged-pipe-elapsed-years"
className={numberInputClass}
@@ -77,7 +94,7 @@ export default function AgedPipeLayerSettingPanel({
title="적용"
aria-label="경과년도 적용"
>
✓
확인
</button>
</div>
</div>
@@ -85,31 +102,34 @@ export default function AgedPipeLayerSettingPanel({
if (compact) {
return (
<div className="w-full rounded-[6px] border border-[#dbe7f5] bg-white px-[6px] py-2 shadow-[0_2px_8px_rgba(15,23,42,0.05)]">
<LayerSettingCard
title="노후관로"
enabled={enabled}
onEnabledChange={onEnabledChange}
onClose={onClose}
>
{settingRow}
</div>
</LayerSettingCard>
);
}
return (
<div className="w-full overflow-hidden rounded-sm border border-red-100 bg-red-50/35">
<div className="relative flex min-h-[30px] items-center justify-center border-b border-red-100 bg-white px-8">
<div className="relative flex min-h-[30px] items-center justify-center border-b border-red-100 bg-background px-8">
<button
type="button"
className="absolute left-2 top-1/2 h-[18px] w-[18px] -translate-y-1/2 bg-transparent text-[1.2rem] leading-none text-[#555]"
className="absolute left-2 top-1/2 h-[18px] w-[18px] -translate-y-1/2 bg-transparent text-[1.2rem] leading-none text-muted-foreground"
onClick={() => setIsCollapsed((value) => !value)}
title={isCollapsed ? "펼치기" : "접기"}
>
{isCollapsed ? "+" : "-"}
</button>
<span className="text-[1.2rem] font-semibold text-red-700">노후관로 설정</span>
<span className="text-[1.2rem] font-semibold text-red-700">
노후관로 설정
</span>
</div>
{!isCollapsed && (
<div className="px-2 py-2">
{settingRow}
</div>
)}
{!isCollapsed && <div className="px-2 py-2">{settingRow}</div>}
</div>
);
}
@@ -1,10 +1,15 @@
/**
* 화면명: 노후관로 목록
* 노후관로를 검색하고 목록·엑셀 다운로드·지도 위치 확인 기능을 제공한다.
*/
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Button, Input } from "@common/components";
import { UiTable } from "@common/business_components/ui";
import { StandardPopupLayout } from "@common/business_components";
import { getData } from "@/components/lib/api";
import { useAlertStore } from "@/shared/lib";
import useStore from "@/store/useStore";
import LoadingOverlay from "@/components/ui/LoadingBar";
import {
@@ -19,34 +24,47 @@ import {
type AgedPipeFilters,
type AgedPipeRow,
} from "./agedPipeUtils";
import { downloadAgedPipeCsv } from "./agedPipeExport";
import { downloadAgedPipeExcel } from "./agedPipeExport";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
const POPUP_ROW_SELECT_MESSAGE = "SN_AGED_PIPE_ROW_SELECT";
const POPUP_MINIMIZE_MESSAGE = "SN_AGED_PIPE_POPUP_MINIMIZE";
const POPUP_FILTER_SYNC_MESSAGE = "SN_AGED_PIPE_FILTER_SYNC";
export default function AgedPipePopupPage() {
const alert = useAlertStore((state) => state.setMessage);
const searchParams = useSearchParams();
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const [filters, setFilters] = useState<AgedPipeFilters>(() =>
normalizeAgedPipeFilters({
elapsedYears: Number(searchParams.get("elapsedYears") ?? AGED_PIPE_DEFAULT_FILTERS.elapsedYears),
ctpvCd: searchParams.get("ctpvCd") ?? AGED_PIPE_DEFAULT_FILTERS.ctpvCd,
sggCd: searchParams.get("sggCd") ?? AGED_PIPE_DEFAULT_FILTERS.sggCd,
}),
const initialFilters = useMemo(
() =>
normalizeAgedPipeFilters({
elapsedYears: Number(
searchParams.get("elapsedYears") ??
AGED_PIPE_DEFAULT_FILTERS.elapsedYears,
),
ctpvCd: searchParams.get("ctpvCd") ?? AGED_PIPE_DEFAULT_FILTERS.ctpvCd,
sggCd: searchParams.get("sggCd") ?? AGED_PIPE_DEFAULT_FILTERS.sggCd,
}),
[searchParams],
);
const [filters, setFilters] = useState<AgedPipeFilters>(initialFilters);
// 화면 목록을 마지막으로 조회한 조건이며 페이지 이동·Excel이 동일하게 사용한다.
const [appliedFilters, setAppliedFilters] =
useState<AgedPipeFilters>(initialFilters);
const [rows, setRows] = useState<AgedPipeRow[]>([]);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [isLoading, setIsLoading] = useState(false);
const [downloadProgressText, setDownloadProgressText] = useState("");
const sggOptions = useMemo(
() =>
filters.ctpvCd === "0"
? sgg
: sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2))),
? []
: sgg.filter((item) =>
String(item.val).startsWith(filters.ctpvCd.slice(0, 2)),
),
[filters.ctpvCd, sgg],
);
@@ -62,7 +80,6 @@ export default function AgedPipePopupPage() {
lyrTypeNm: AGED_PIPE_LAYER_TYPE,
page: String(page - 1),
size: String(size),
sort: "string",
});
const cqlFilters = buildAgedPipeCqlFilters(normalized);
if (cqlFilters.length > 0) {
@@ -72,12 +89,13 @@ export default function AgedPipePopupPage() {
const response = await getData(`v1/fclts?${queryParams.toString()}`);
const responseRows = Array.isArray(response?.data) ? response.data : [];
return {
rows: responseRows.map((item: Record<string, unknown>, index: number) =>
const total = Number(response?.count ?? 0);
const rows = responseRows.map(
(item: Record<string, unknown>, index: number) =>
toDisplayPipeRow(item, index, page, ctpv, sgg),
),
total: Number(response?.count ?? 0),
};
);
return { rows, total };
},
[ctpv, sgg],
);
@@ -86,10 +104,15 @@ export default function AgedPipePopupPage() {
async (page = 1, nextFilters = filters) => {
setIsLoading(true);
try {
const response = await fetchRows(page, nextFilters, AGED_PIPE_PAGE_SIZE);
const response = await fetchRows(
page,
nextFilters,
AGED_PIPE_PAGE_SIZE,
);
setRows(response.rows);
setTotal(response.total);
setCurrentPage(page);
setAppliedFilters(normalizeAgedPipeFilters(nextFilters));
} catch (error) {
console.error("노후관로 목록 조회 중 오류:", error);
setRows([]);
@@ -113,9 +136,10 @@ export default function AgedPipePopupPage() {
if (event.data?.type !== POPUP_FILTER_SYNC_MESSAGE) return;
if (!event.data?.filters) return;
const normalized = normalizeAgedPipeFilters(event.data.filters as AgedPipeFilters);
const normalized = normalizeAgedPipeFilters(
event.data.filters as AgedPipeFilters,
);
setFilters(normalized);
setSelectedIndex(-1);
loadRows(1, normalized);
};
@@ -134,12 +158,7 @@ export default function AgedPipePopupPage() {
await loadRows(page, normalized);
};
const handleRowClick = (index?: number) => {
if (typeof index !== "number") return;
const row = rows[index];
if (!row) return;
setSelectedIndex(index);
const handleRowClick = (row: AgedPipeRow) => {
window.opener?.postMessage(
{
type: POPUP_ROW_SELECT_MESSAGE,
@@ -154,22 +173,36 @@ export default function AgedPipePopupPage() {
alert("다운로드할 데이터가 없습니다.");
return;
}
if (!validateElapsedYears(Number(filters.elapsedYears))) {
alert("경과년도는 1년~100년 범위 내에서 입력해주세요.");
return;
}
const normalizedDraft = normalizeAgedPipeFilters(filters);
if (JSON.stringify(normalizedDraft) !== JSON.stringify(appliedFilters)) {
alert("조회조건이 변경되었습니다. 조회 후 Excel을 다운로드해주세요.");
return;
}
try {
setIsLoading(true);
setDownloadProgressText("CSV export 작업을 준비하고 있습니다.");
await downloadAgedPipeCsv(filters, (job) => {
if (job.status === "QUEUED") {
setDownloadProgressText("CSV export 작업이 대기 중입니다.");
return;
}
setDownloadProgressText("Excel 파일을 준비하고 있습니다.");
await downloadAgedPipeExcel(
appliedFilters,
(job) => {
if (job.status === "QUEUED") {
setDownloadProgressText("Excel 다운로드 작업이 대기 중입니다.");
return;
}
if (job.status === "RUNNING") {
setDownloadProgressText(
`CSV export 생성 중입니다. (${job.progressPercent ?? 0}%)`,
);
}
});
if (job.status === "RUNNING") {
setDownloadProgressText(
`Excel 파일 생성 중입니다. (${job.progressPercent ?? 0}%)`,
);
}
},
total,
);
setDownloadProgressText("");
} catch (error) {
console.error("노후관로 목록 다운로드 중 오류:", error);
@@ -177,7 +210,7 @@ export default function AgedPipePopupPage() {
alert(
error instanceof Error
? error.message
: "노후관로 CSV 다운로드 중 오류가 발생했습니다.",
: "노후관로 Excel 다운로드 중 오류가 발생했습니다.",
);
} finally {
setIsLoading(false);
@@ -196,181 +229,133 @@ export default function AgedPipePopupPage() {
window.close();
};
const getCellClassName = (_row: AgedPipeRow, headId: string) => {
if (headId === "mqltCd") {
return "break-all whitespace-normal";
}
return "whitespace-normal";
};
return (
<main className="sn-popup-page w-full max-w-full overflow-x-hidden overflow-y-hidden">
<StandardPopupLayout
title="노후관로 목록"
actions={
<Button type="button" color="white" onClick={handleMinimize}>
최소화
</Button>
}
>
<LoadingOverlay isLoading={isLoading} />
<header className="sn-popup-header">
<h1 className="sn-popup-title">노후관로 현황</h1>
<div className="flex items-center gap-2">
<Button
type="button"
color="white"
size="xs"
className="sn-popup-header-button"
onClick={handleMinimize}
>
최소화
</Button>
</div>
</header>
{downloadProgressText ? (
<div className="sn-popup-success-bar">
<div className="rounded-sm border border-primary bg-primary-transparent px-4 py-3 text-[1.3rem] text-primary">
{downloadProgressText}
</div>
) : null}
<section className="flex min-h-0 flex-1 flex-col overflow-x-hidden px-4 pb-3 pt-3">
<div className="sn-popup-filter-panel-compact grid grid-cols-1 gap-3 md:grid-cols-[140px_minmax(0,1fr)_minmax(0,1fr)_auto] md:items-end">
<label className="sn-popup-field">
<div className="sn-popup-field-label">경과년도</div>
<Input
className="sn-popup-input !text-[1.1rem]"
type="text"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="off"
value={String(filters.elapsedYears ?? "")}
onChange={(event) => {
const digits = event.target.value.replace(/[^0-9]/g, "");
setFilters({
...filters,
elapsedYears: digits === "" ? ("" as unknown as number) : Number(digits),
});
}}
onKeyDown={(event) => {
if (event.key === "Enter") void handleSearch(1);
}}
/>
</label>
<label className="sn-popup-field">
<div className="sn-popup-field-label">시도</div>
<select
className="sn-popup-input !text-[1.1rem]"
value={filters.ctpvCd}
onChange={(event) =>
setFilters({
...filters,
ctpvCd: event.target.value,
sggCd: "0",
})
}
>
<option value="0">전체</option>
{ctpv.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="sn-popup-field">
<div className="sn-popup-field-label">시군구</div>
<select
className="sn-popup-input !text-[1.1rem]"
value={filters.sggCd}
onChange={(event) => setFilters({ ...filters, sggCd: event.target.value })}
>
<option value="0">전체</option>
{sggOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<div className="sn-popup-actions">
<Button
type="button"
size="sm"
color="primary"
className="sn-popup-primary-button !text-[1.1rem]"
onClick={() => void handleSearch(1)}
>
조회
</Button>
<Button
type="button"
size="sm"
color="primary"
className="sn-popup-primary-button !text-[1.1rem]"
onClick={handleDownload}
>
다운로드
</Button>
</div>
</div>
<div className="aged-pipe-popup-table min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto pt-2">
<UiTable<AgedPipeRow>
headName={<>노후관로 조회 결과 총 {total.toLocaleString()}건</>}
tableData={rows}
headlist={AGED_PIPE_LIST_HEADLIST}
pageSize={10}
total={total}
cellClick={(index) => handleRowClick(index)}
onPageChange={(page) => void handleSearch(page)}
cellClassName={getCellClassName}
curPage={currentPage}
index={selectedIndex}
widthFixed={true}
isHideCount={true}
className="!m-0 overflow-x-hidden"
<PopupSearchPanel contentClassName="flex flex-wrap items-end gap-x-5 gap-y-3">
<label className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] text-label">
경과년도
</span>
<Input
className="w-[12rem]"
type="text"
inputMode="numeric"
pattern="[0-9]*"
autoComplete="off"
value={String(filters.elapsedYears ?? "")}
onChange={(event) => {
const digits = event.target.value.replace(/[^0-9]/g, "");
setFilters({
...filters,
elapsedYears:
digits === "" ? ("" as unknown as number) : Number(digits),
});
}}
onKeyDown={(event) => {
if (event.key === "Enter") void handleSearch(1);
}}
/>
</label>
<label className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] text-label">
시도
</span>
<select
className="h-[3.2rem] w-[18rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
value={filters.ctpvCd}
onChange={(event) =>
setFilters({
...filters,
ctpvCd: event.target.value,
sggCd: "0",
})
}
>
<option value="0">전체</option>
{ctpv.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] text-label">
시군구
</span>
<select
className="h-[3.2rem] w-[18rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
value={filters.sggCd}
onChange={(event) =>
setFilters({ ...filters, sggCd: event.target.value })
}
>
<option value="0">전체</option>
{sggOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<div className="ml-auto flex items-center gap-2">
<Button
type="button"
color="primary"
onClick={() => void handleSearch(1)}
>
조회
</Button>
<Button
type="button"
color="white"
onClick={() => {
const resetFilters = normalizeAgedPipeFilters(
AGED_PIPE_DEFAULT_FILTERS,
);
setFilters(resetFilters);
void loadRows(1, resetFilters);
}}
>
초기화
</Button>
</div>
</section>
<style jsx global>{`
.aged-pipe-popup-table .card-header {
margin-bottom: 0.6rem;
}
</PopupSearchPanel>
.aged-pipe-popup-table .card-header h3 {
font-size: 1.7rem;
line-height: 1.25;
<MapPopupDataTable
rows={rows as Array<AgedPipeRow & Record<string, unknown>>}
headlist={AGED_PIPE_LIST_HEADLIST}
title="노후관로 목록"
total={total}
page={currentPage}
pageSize={AGED_PIPE_PAGE_SIZE}
onPageChange={(page) => void loadRows(page, appliedFilters)}
onRowClick={handleRowClick}
rowId={(row, index) => String(row.fid ?? row.fcltMngNo ?? index)}
actions={
<Button type="button" onClick={handleDownload}>
엑셀다운로드
</Button>
}
.aged-pipe-popup-table .card {
overflow-x: hidden;
}
.aged-pipe-popup-table .table-ui {
width: 100%;
max-width: 100%;
table-layout: fixed;
}
.aged-pipe-popup-table .table-ui th {
padding: 0.8rem 0.45rem;
font-size: 1.1rem;
line-height: 1.2;
word-break: keep-all;
}
.aged-pipe-popup-table .table-ui td {
padding: 0.85rem 0.4rem;
font-size: 1.1rem;
line-height: 1.25;
overflow-wrap: anywhere;
word-break: break-word;
}
.aged-pipe-popup-table .table-ui td:nth-child(2),
.aged-pipe-popup-table .table-ui td:nth-child(3),
.aged-pipe-popup-table .table-ui td:nth-child(8) {
word-break: keep-all;
}
`}</style>
</main>
/>
</StandardPopupLayout>
);
}
@@ -1,3 +1,4 @@
/** 노후관로 목록의 엑셀 다운로드 데이터와 서식을 생성한다. */
"use client";
import { postBlobData } from "@/components/lib/api";
@@ -14,26 +15,35 @@ type AgedPipeExportProgress = {
status: "QUEUED" | "RUNNING" | "COMPLETED";
};
/** 노후관로 CSV 다운로드를 요청한다. */
export const downloadAgedPipeCsv = async (
/** 노후관로 XLSX 다운로드를 요청한다. */
export const downloadAgedPipeExcel = async (
filters: AgedPipeFilters,
onProgress?: (progress: AgedPipeExportProgress) => void,
expectedTotal?: number,
) => {
const normalizedFilters = normalizeAgedPipeFilters(filters);
onProgress?.({ status: "RUNNING", progressPercent: 0 });
const { blob, fileName } = await postBlobData("v1/fclts/agedPipes/exportJobs/download", {
elapsedYears: normalizedFilters.elapsedYears,
ctpvCd: normalizedFilters.ctpvCd,
sggCd: normalizedFilters.sggCd,
format: "csv",
columns: [...AGED_PIPE_EXCEL_FIELDS],
});
const { blob, fileName } = await postBlobData(
"v1/fclts/agedPipes/exportJobs/download",
{
elapsedYears: normalizedFilters.elapsedYears,
ctpvCd: normalizedFilters.ctpvCd,
sggCd: normalizedFilters.sggCd,
format: "xlsx",
columns: [...AGED_PIPE_EXCEL_FIELDS],
expectedTotal,
},
);
const resolvedFileName = fileName || "노후관로_현황.csv";
const resolvedFileName = fileName || "노후관로_현황.xlsx";
fileDownload(blob, resolvedFileName);
onProgress?.({ status: "COMPLETED", fileName: resolvedFileName, progressPercent: 100 });
onProgress?.({
status: "COMPLETED",
fileName: resolvedFileName,
progressPercent: 100,
});
return {
status: "COMPLETED" as const,
@@ -1,3 +1,4 @@
/** 노후관로 검색 조건, 표시값 변환 및 지도 연계 유틸을 제공한다. */
"use client";
import type { SelectDataType } from "@common/business_components";
@@ -54,7 +55,7 @@ const normalizeRegionFilterCode = (value: unknown, prefixLength: number) => {
};
export const AGED_PIPE_LIST_HEADLIST = [
{ id: "rowNumber", title: "NO", widthPercent: 6 },
{ id: "rowNumber", title: "순번", widthPercent: 6 },
{ id: "fcltMngNo", title: "시설관리번호", widthPercent: 18 },
{ id: "instlYmd", title: "설치일자", widthPercent: 12 },
{ id: "pdmtVl", title: "관경(mm)", widthPercent: 10 },
@@ -0,0 +1,315 @@
/**
* 화면명: 시도별 노후관로현황
* 기준연도별 시도 단위 노후관로 현황을 조회하고 엑셀로 내려받는다.
*/
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Input } from "@common/components";
import {
getStandardRowNumber,
StandardDataTable,
StandardPopupLayout,
} from "@common/business_components";
import {
getCoreRowModel,
getPaginationRowModel,
useReactTable,
type ColumnDef,
type PaginationState,
} from "@tanstack/react-table";
import { getRawJson } from "@/components/lib/api";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { useAlertStore } from "@/shared/lib";
import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions";
import {
AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
buildAgedPipeStatusYearOptions,
getAgedPipeStatusByCtpvValidationMessage,
normalizeAgedPipeStatusByCtpvResponse,
normalizeAgedPipeStatusByCtpvFilters,
toAgedPipeStatusByCtpvRows,
type AgedPipeStatusByCtpvFilters,
type AgedPipeStatusByCtpvResponse,
type AgedPipeStatusByCtpvRow,
} from "./agedPipeStatusByCtpvUtils";
import { downloadAgedPipeStatusByCtpvExcel } from "./agedPipeStatusByCtpvExport";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
// CS 목록과 동일하게 한 페이지에는 최대 10개 시도만 표시한다.
const AGED_PIPE_STATUS_PAGE_SIZE = 10;
export default function AgedPipeStatusByCtpvPopupPage() {
const alert = useAlertStore((state) => state.setMessage);
// 조회조건 입력 상태다. 기본값 30년, 최근 연도 기준 단년 조회로 시작한다.
const [filters, setFilters] = useState<AgedPipeStatusByCtpvFilters>(() =>
normalizeAgedPipeStatusByCtpvFilters({
elapsedYears: AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
}),
);
// 화면 목록을 마지막으로 조회한 조건이며 페이지 이동·Excel이 동일하게 사용한다.
const [appliedFilters, setAppliedFilters] =
useState<AgedPipeStatusByCtpvFilters>(() =>
normalizeAgedPipeStatusByCtpvFilters({
elapsedYears: AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
}),
);
// 서버 응답을 화면 그리드에 맞게 평탄화한 row 목록이다.
const [rows, setRows] = useState<AgedPipeStatusByCtpvRow[]>([]);
// 동적 컬럼 생성을 위한 기준년도 목록이다.
const [years, setYears] = useState<number[]>([]);
// 조회/다운로드 중 로딩 overlay를 제어한다.
const [isLoading, setIsLoading] = useState(false);
// 사용자에게 현재 다운로드 상태를 보여주는 진행 문구다.
const [downloadProgressText, setDownloadProgressText] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const yearOptions = useMemo(() => buildAgedPipeStatusYearOptions(), []);
const columns = useMemo<ColumnDef<AgedPipeStatusByCtpvRow>[]>(
() => [
{
id: "rowNumber",
header: "순번",
cell: ({ row }) =>
getStandardRowNumber({
total: rows.length,
rowIndex: row.index,
}),
},
{ accessorKey: "ctpvNm", header: "시도" },
...years.map((year) => ({
id: `year_${year}`,
header: `${year}년`,
columns: [
{
accessorKey: `year${year}TotalLengthKm`,
header: "전체관로연장(km)",
},
{
accessorKey: `year${year}AgedLengthKm`,
header: "노후관로연장(km)",
},
{
accessorKey: `year${year}AgedRate`,
header: "노후화율(%)",
},
],
})),
],
[rows.length, years],
);
const pagination: PaginationState = {
pageIndex: currentPage - 1,
pageSize: AGED_PIPE_STATUS_PAGE_SIZE,
};
const table = useReactTable({
data: rows,
columns,
state: { pagination },
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: (updater) => {
const next =
typeof updater === "function" ? updater(pagination) : updater;
setCurrentPage(next.pageIndex + 1);
},
});
const loadRows = useCallback(
async (nextFilters: AgedPipeStatusByCtpvFilters) => {
setIsLoading(true);
try {
const normalized = normalizeAgedPipeStatusByCtpvFilters(nextFilters);
const queryParams = new URLSearchParams({
startYear: String(normalized.startYear),
endYear: String(normalized.endYear),
elapsedYears: String(normalized.elapsedYears),
});
const rawResponse = await getRawJson(
`v1/swagePpl/agedStatusByCtpv?${queryParams.toString()}`,
);
const response = normalizeAgedPipeStatusByCtpvResponse(
rawResponse,
) as AgedPipeStatusByCtpvResponse;
setYears(Array.isArray(response?.years) ? response.years : []);
setRows(toAgedPipeStatusByCtpvRows(response));
setCurrentPage(1);
setAppliedFilters(normalized);
} catch (error) {
console.error("시도별 노후관로현황 조회 중 오류:", error);
setYears([]);
setRows([]);
} finally {
setIsLoading(false);
}
},
[],
);
useEffect(() => {
const initialFilters = normalizeAgedPipeStatusByCtpvFilters({
elapsedYears: AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
});
void loadRows(initialFilters);
}, [loadRows]);
const handleSearch = async () => {
const validationMessage = getAgedPipeStatusByCtpvValidationMessage(filters);
if (validationMessage) {
alert(validationMessage);
return;
}
const normalized = normalizeAgedPipeStatusByCtpvFilters(filters);
setFilters(normalized);
await loadRows(normalized);
};
const handleDownload = async () => {
const validationMessage = getAgedPipeStatusByCtpvValidationMessage(filters);
if (validationMessage) {
alert(validationMessage);
return;
}
const normalizedDraft = normalizeAgedPipeStatusByCtpvFilters(filters);
if (JSON.stringify(normalizedDraft) !== JSON.stringify(appliedFilters)) {
alert("조회조건이 변경되었습니다. 조회 후 Excel을 다운로드해주세요.");
return;
}
try {
setIsLoading(true);
setDownloadProgressText(
"시도별 노후관로현황 Excel 파일을 준비하고 있습니다.",
);
await downloadAgedPipeStatusByCtpvExcel(appliedFilters);
setDownloadProgressText("");
} catch (error) {
console.error("시도별 노후관로현황 다운로드 중 오류:", error);
setDownloadProgressText("");
alert(
error instanceof Error
? error.message
: "시도별 노후관로현황 Excel 다운로드 중 오류가 발생했습니다.",
);
} finally {
setIsLoading(false);
}
};
return (
<StandardPopupLayout
title={windowPopupDefinitions.agedPipeStatusByCtpvPopup.title}
bodyClassName="overflow-x-hidden"
>
<LoadingOverlay isLoading={isLoading} />
{downloadProgressText ? (
<div className="rounded-sm border border-primary bg-primary-transparent px-4 py-3 text-[1.3rem] text-primary">
{downloadProgressText}
</div>
) : null}
<PopupSearchPanel contentClassName="flex flex-wrap items-end gap-x-5 gap-y-3">
<label className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-xl font-medium text-label">
기준년도 시작
</span>
<select
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl"
value={filters.startYear}
onChange={(event) =>
setFilters((current) => ({
...current,
startYear: Number(event.target.value),
}))
}
>
{yearOptions.map((year) => (
<option key={year} value={year}>
{year}
</option>
))}
</select>
</label>
<label className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-xl font-medium text-label">
기준년도 종료
</span>
<select
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl"
value={filters.endYear}
onChange={(event) =>
setFilters((current) => ({
...current,
endYear: Number(event.target.value),
}))
}
>
{yearOptions.map((year) => (
<option key={year} value={year}>
{year}
</option>
))}
</select>
</label>
<label className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-xl font-medium text-label">
노후관로 기준(년)
</span>
<Input
type="text"
inputMode="numeric"
value={String(filters.elapsedYears)}
onChange={(event) =>
setFilters((current) => ({
...current,
elapsedYears: Number(event.target.value.replace(/[^0-9]/g, "")),
}))
}
className="w-[12rem]"
onKeyDown={(event) => {
if (event.key === "Enter") void handleSearch();
}}
/>
</label>
<div className="flex min-w-fit flex-1 items-center justify-end gap-2">
<Button onClick={handleSearch} color="primary">
조회
</Button>
<Button
color="white"
onClick={() => {
const resetFilters = normalizeAgedPipeStatusByCtpvFilters({
elapsedYears: AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
});
setFilters(resetFilters);
void loadRows(resetFilters);
}}
>
초기화
</Button>
</div>
</PopupSearchPanel>
<StandardDataTable
table={table}
className="!w-full !max-w-full"
title="시도별 노후관로 목록"
scrollMode="horizontal"
minTableWidth={years.length > 0 ? "120rem" : "100%"}
actions={
<Button type="button" onClick={handleDownload}>
엑셀다운로드
</Button>
}
flushOuterMargin
/>
</StandardPopupLayout>
);
}
@@ -0,0 +1,81 @@
/** 시도별 노후관로 현황의 연도 그룹형 엑셀 데이터를 생성한다. */
"use client";
import * as XLSX from "xlsx";
import { getRawJson } from "@/components/lib/api";
import {
getAgedPipeStatusByCtpvValidationMessage,
normalizeAgedPipeStatusByCtpvFilters,
type AgedPipeStatusByCtpvFilters,
type AgedPipeStatusByCtpvResponse,
} from "./agedPipeStatusByCtpvUtils";
/** 적용조건의 시도별 노후관로현황 전체를 조회해 연도 그룹 Excel로 생성한다. */
export const downloadAgedPipeStatusByCtpvExcel = async (
filters: AgedPipeStatusByCtpvFilters,
) => {
const validationMessage = getAgedPipeStatusByCtpvValidationMessage(filters);
if (validationMessage) throw new Error(validationMessage);
const normalized = normalizeAgedPipeStatusByCtpvFilters(filters);
const params = new URLSearchParams({
startYear: String(normalized.startYear),
endYear: String(normalized.endYear),
elapsedYears: String(normalized.elapsedYears),
});
const response: any = await getRawJson(
`v1/swagePpl/agedStatusByCtpv?${params.toString()}`,
);
const payload = (response?.data ?? response) as AgedPipeStatusByCtpvResponse;
const years = Array.isArray(payload?.years)
? payload.years.map(Number).filter(Number.isFinite)
: [];
const rows = Array.isArray(payload?.rows) ? payload.rows : [];
if (rows.length === 0) throw new Error("다운로드할 데이터가 없습니다.");
const headers = ["순번", "시도"];
years.forEach((year) => headers.push(`${year}년`, "", ""));
const subHeaders = ["", ""];
years.forEach(() =>
subHeaders.push("전체관로연장(km)", "노후관로연장(km)", "노후화율(%)"),
);
const data = rows.map((row, rowIndex) => {
const statsByYear = new Map(
(row.yearStats ?? []).map((stat) => [Number(stat.year), stat]),
);
const values: Array<string | number> = [rowIndex + 1, row.ctpvNm || "-"];
years.forEach((year) => {
const stat = statsByYear.get(year);
values.push(
stat ? Number(stat.totalLengthKm ?? 0) : "-",
stat ? Number(stat.agedLengthKm ?? 0) : "-",
stat ? Number(stat.agedRate ?? 0) : "-",
);
});
return values;
});
const worksheet = XLSX.utils.aoa_to_sheet([headers, subHeaders, ...data]);
worksheet["!merges"] = [
{ s: { r: 0, c: 0 }, e: { r: 1, c: 0 } },
{ s: { r: 0, c: 1 }, e: { r: 1, c: 1 } },
...years.map((_, index) => ({
s: { r: 0, c: 2 + index * 3 },
e: { r: 0, c: 4 + index * 3 },
})),
];
worksheet["!cols"] = [
{ wch: 8 },
{ wch: 18 },
...years.flatMap(() => [{ wch: 18 }, { wch: 18 }, { wch: 14 }]),
];
worksheet["!autofilter"] = {
ref: `A2:${XLSX.utils.encode_col(headers.length - 1)}${data.length + 2}`,
};
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, worksheet, "시도별 노후관로현황");
XLSX.writeFile(workbook, "시도별_노후관로현황.xlsx", {
bookType: "xlsx",
compression: true,
cellStyles: true,
});
};
@@ -0,0 +1,185 @@
/** 시도별 노후관로 현황의 조회 연도와 그룹 컬럼을 구성한다. */
"use client";
import type { UiHeadProps } from "@common/business_components/ui";
export const AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS = 30;
export const AGED_PIPE_STATUS_BY_CTPV_MAX_YEAR_SPAN = 3;
export const AGED_PIPE_STATUS_BY_CTPV_PAGE_SIZE = 50;
export type AgedPipeStatusByCtpvFilters = {
startYear: number;
endYear: number;
elapsedYears: number;
};
export type AgedPipeStatusByCtpvYearStat = {
year: number;
totalLengthKm: number;
agedLengthKm: number;
agedRate: number;
};
export type AgedPipeStatusByCtpvRow = {
rowNumber: number;
ctpvCd: string;
ctpvNm: string;
yearStats: AgedPipeStatusByCtpvYearStat[];
[key: string]: string | number | AgedPipeStatusByCtpvYearStat[];
};
export type AgedPipeStatusByCtpvResponse = {
startYear: number;
endYear: number;
elapsedYears: number;
years: number[];
rows: Array<{
ctpvCd: string;
ctpvNm: string;
yearStats: AgedPipeStatusByCtpvYearStat[];
}>;
};
export const normalizeAgedPipeStatusByCtpvResponse = (
response: Partial<AgedPipeStatusByCtpvResponse> | null | undefined,
): AgedPipeStatusByCtpvResponse => ({
startYear: Number(response?.startYear ?? 0),
endYear: Number(response?.endYear ?? 0),
elapsedYears: Number(
response?.elapsedYears ?? AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
),
years: Array.isArray(response?.years)
? response.years.map((year) => Number(year))
: [],
rows: Array.isArray(response?.rows) ? response.rows : [],
});
export const buildAgedPipeStatusYearOptions = () => {
const currentYear = new Date().getFullYear();
return Array.from(
{ length: currentYear - 1900 + 1 },
(_, index) => currentYear - index,
);
};
export const normalizeAgedPipeStatusByCtpvFilters = (
filters: Partial<AgedPipeStatusByCtpvFilters>,
): AgedPipeStatusByCtpvFilters => {
const currentYear = new Date().getFullYear();
let startYear = Math.trunc(Number(filters.startYear ?? currentYear - 2));
let endYear = Math.trunc(Number(filters.endYear ?? currentYear));
let elapsedYears = Math.trunc(
Number(
filters.elapsedYears ?? AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS,
),
);
if (!Number.isFinite(startYear)) startYear = currentYear - 2;
if (!Number.isFinite(endYear)) endYear = currentYear;
if (
!Number.isFinite(elapsedYears) ||
elapsedYears < 1 ||
elapsedYears > 100
) {
elapsedYears = AGED_PIPE_STATUS_BY_CTPV_DEFAULT_ELAPSED_YEARS;
}
if (startYear > endYear) {
const swap = startYear;
startYear = endYear;
endYear = swap;
}
return {
startYear,
endYear,
elapsedYears,
};
};
/** 입력한 기준년도·경과년도가 조회 가능한 범위인지 사용자 안내 문구로 반환한다. */
export const getAgedPipeStatusByCtpvValidationMessage = (
filters: AgedPipeStatusByCtpvFilters,
) => {
if (filters.startYear > filters.endYear) {
return "기준년도 시작은 종료보다 늦을 수 없습니다.";
}
if (
filters.endYear - filters.startYear + 1 >
AGED_PIPE_STATUS_BY_CTPV_MAX_YEAR_SPAN
) {
return `기준년도 조회 범위는 최대 ${AGED_PIPE_STATUS_BY_CTPV_MAX_YEAR_SPAN}년까지 가능합니다.`;
}
if (filters.elapsedYears < 1 || filters.elapsedYears > 100) {
return "노후관로 기준은 1년부터 100년까지 입력할 수 있습니다.";
}
return null;
};
export const validateAgedPipeStatusByCtpvFilters = (
filters: AgedPipeStatusByCtpvFilters,
) => getAgedPipeStatusByCtpvValidationMessage(filters) === null;
const formatMetric = (value: number) =>
Number(value ?? 0).toLocaleString("ko-KR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
export const toAgedPipeStatusByCtpvRows = (
response: Partial<AgedPipeStatusByCtpvResponse> | null | undefined,
): AgedPipeStatusByCtpvRow[] =>
normalizeAgedPipeStatusByCtpvResponse(response).rows.map((row, index) => {
const displayRow: AgedPipeStatusByCtpvRow = {
rowNumber: index + 1,
ctpvCd: String(row.ctpvCd ?? "").trim(),
ctpvNm: String(row.ctpvNm ?? "").trim(),
yearStats: row.yearStats ?? [],
};
(row.yearStats ?? []).forEach((yearStat) => {
displayRow[`year${yearStat.year}TotalLengthKm`] = formatMetric(
Number(yearStat.totalLengthKm ?? 0),
);
displayRow[`year${yearStat.year}AgedLengthKm`] = formatMetric(
Number(yearStat.agedLengthKm ?? 0),
);
displayRow[`year${yearStat.year}AgedRate`] = formatMetric(
Number(yearStat.agedRate ?? 0),
);
});
return displayRow;
});
export const buildAgedPipeStatusByCtpvHeadList = (
years: number[],
): UiHeadProps[] => {
const heads: UiHeadProps[] = [
{ id: "ctpvNm", title: "시도", widthPercent: 14 },
];
years.forEach((year) => {
heads.push(
{
id: `year${year}TotalLengthKm`,
title: `${year} 전체관로연장(km)`,
widthPercent: 13,
align: "right",
},
{
id: `year${year}AgedLengthKm`,
title: `${year} 노후관로연장(km)`,
widthPercent: 13,
align: "right",
},
{
id: `year${year}AgedRate`,
title: `${year} 노후화율(%)`,
widthPercent: 11,
align: "right",
},
);
});
return heads;
};
@@ -1,10 +1,14 @@
/**
* 화면명: 분석도구 메뉴
* 지도에서 실행할 분석·현황 팝업 메뉴를 펼치고 선택한다.
*/
"use client";
import { useEffect, useState, type ComponentType, type ReactNode } from "react";
import { type ComponentType, type ReactNode,useEffect, useState } from "react";
import {
ChartColumnBig,
ChevronLeft,
ChevronRight,
ChevronUp,
PanelsTopLeft,
PencilRuler,
Search,
@@ -12,7 +16,14 @@ import {
TriangleAlert,
Waves,
} from "lucide-react";
import type { WindowPopupKey } from "@/features/windowPopup/model/popupDefinitions";
import { createPredictionAnalysisTimeRange } from "@/features/fldngPredcPrst/model/analysisTimeRange";
import {
windowPopupDefinitions,
type WindowPopupKey,
} from "@/features/windowPopup/model/popupDefinitions";
import { getRawJson } from "@/components/lib/api";
type Props = {
onOpenPopup: (popupKey: WindowPopupKey) => Window | null;
@@ -27,10 +38,10 @@ type MenuIconProps = {
isActive: boolean;
};
const InftwtMenuIcon = ({ isActive }: MenuIconProps) => (
const InftwtMenuIcon = () => (
<svg
viewBox="0 0 24 24"
className={`h-[15px] w-[15px] shrink-0 ${isActive ? "text-primary" : "text-label"}`}
className="h-[15px] w-[15px] shrink-0 text-current"
aria-hidden="true"
fill="none"
>
@@ -58,14 +69,51 @@ const ANALYSIS_POPUP_ITEMS: Array<{
icon?: ComponentType<{ className?: string }>;
renderIcon?: (props: MenuIconProps) => ReactNode;
}> = [
{ key: "infoSearch", label: "정보검색", icon: Search },
{ key: "pipeAnalysisPopup", label: "관로분석", icon: ChartColumnBig },
{ key: "floodPredictionMapPopup", label: "침수예측지도", icon: Waves },
{ key: "gisEditPopup", label: "GIS 편집", icon: PencilRuler },
{ key: "gisManagementPopup", label: "GIS 관리", icon: Settings2 },
{ key: "flowRiskPopup", label: "통수 위험", icon: TriangleAlert },
{ key: "inftwtResultPopup", label: "침입수 분석결과", renderIcon: InftwtMenuIcon },
{ key: "rdiiResultPopup", label: "RDII 분석결과", icon: ChartColumnBig },
{
key: "infoSearch",
label: windowPopupDefinitions.mapSearch.title,
icon: Search,
},
{
key: "pipeAnalysisPopup",
label: windowPopupDefinitions.pipeAnalysisPopup.title,
icon: ChartColumnBig,
},
{
key: "fldngPredcPrst",
label: windowPopupDefinitions.fldngPredcPrst.title,
icon: Waves,
},
{
key: "agedPipeStatusByCtpvPopup",
label: windowPopupDefinitions.agedPipeStatusByCtpvPopup.title,
icon: ChartColumnBig,
},
{
key: "gisEditPopup",
label: windowPopupDefinitions.gisEditPopup.title,
icon: PencilRuler,
},
{
key: "gisManagementPopup",
label: windowPopupDefinitions.gisManagementPopup.title,
icon: Settings2,
},
{
key: "flowRiskPopup",
label: windowPopupDefinitions.flowRiskPopup.title,
icon: TriangleAlert,
},
{
key: "inftwtResultPopup",
label: windowPopupDefinitions.inftwtResultPopup.title,
renderIcon: InftwtMenuIcon,
},
{
key: "rdiiResultPopup",
label: windowPopupDefinitions.rdiiResultPopup.title,
icon: ChartColumnBig,
},
];
export default function AnalysisPopupMenu({
@@ -77,7 +125,35 @@ export default function AnalysisPopupMenu({
initialActiveKey = null,
}: Props) {
const [isOpen, setIsOpen] = useState(true);
const [activeKey, setActiveKey] = useState<WindowPopupKey | "infoSearch" | null>(initialActiveKey);
const [activeKey, setActiveKey] = useState<
WindowPopupKey | "infoSearch" | null
>(initialActiveKey);
const [seriousPredictionCount, setSeriousPredictionCount] = useState(0);
// 침수예측현황 팝업의 기본 분석시각 범위와 동일한 범위로 메뉴 건수를 조회한다.
useEffect(() => {
const range = createPredictionAnalysisTimeRange();
const params = new URLSearchParams({
analysisFrom: range.analysisFrom,
analysisTo: range.analysisTo,
});
let cancelled = false;
void getRawJson(
`/sewerage-service/api/v1/fldng/predc/prst/count?${params.toString()}`,
)
.then((response) => {
if (cancelled) return;
const payload = response?.data ?? response;
const count = Number(payload);
setSeriousPredictionCount(Number.isFinite(count) ? count : 0);
})
.catch(() => {
if (!cancelled) setSeriousPredictionCount(0);
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!isGisEditActive && activeKey === "gisEditPopup") {
@@ -97,88 +173,107 @@ export default function AnalysisPopupMenu({
}, [initialActiveKey]);
return (
<div className="absolute right-[0.55rem] top-[20px] z-[45]">
<div className="relative">
{isOpen ? (
<div className="absolute right-[28px] w-[154px] overflow-hidden rounded-[6px] border border-border bg-background shadow-[0_8px_18px_rgba(15,23,42,0.14)]">
<div className="flex h-[36px] items-center gap-2 border-b border-border bg-secondary px-[10px] text-[13px] font-bold text-foreground">
<div className="flex items-center gap-2">
<PanelsTopLeft className="h-4 w-4 text-primary" />
<span>분석도구</span>
</div>
</div>
<div className="pointer-events-none absolute right-[68px] top-[16px] z-[45] flex flex-col items-end gap-[8px]">
<button
type="button"
className={`pointer-events-auto flex h-[40px] w-[40px] shrink-0 items-center justify-center overflow-hidden rounded-[3px] p-0 shadow-[0_1px_2px_rgba(60,64,67,0.3),0_1px_3px_1px_rgba(60,64,67,0.15)] ${
isOpen
? "bg-primary text-primary-foreground"
: "bg-background text-label hover:bg-primary hover:text-primary-foreground"
}`}
onClick={() => setIsOpen((value) => !value)}
title={isOpen ? "분석 메뉴 숨기기" : "분석도구"}
aria-label={isOpen ? "분석 메뉴 숨기기" : "분석 메뉴 보이기"}
aria-expanded={isOpen}
aria-controls="analysis-popup-menu"
>
<PanelsTopLeft className="h-[20px] w-[20px]" aria-hidden="true" />
</button>
<div className="flex flex-col gap-1 p-[6px]">
{ANALYSIS_POPUP_ITEMS.map((item) => {
const Icon = item.icon;
const isActive = item.key === "gisEditPopup"
{isOpen ? (
<div
id="analysis-popup-menu"
className="pointer-events-auto flex w-max min-w-[154px] flex-col overflow-hidden rounded-[6px] bg-background shadow-[0_1px_2px_rgba(60,64,67,0.3),0_1px_3px_1px_rgba(60,64,67,0.15)]"
>
<div className="flex h-[36px] shrink-0 items-center justify-between gap-2 border-b border-border bg-background pl-[10px] pr-[4px] text-[13px] font-bold text-foreground">
<div className="flex items-center gap-2">
<PanelsTopLeft className="h-4 w-4 text-primary" />
<span>분석도구</span>
</div>
<button
type="button"
className="flex h-[28px] w-[28px] shrink-0 items-center justify-center rounded-[4px] text-label hover:bg-accent hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary"
onClick={() => setIsOpen(false)}
title="분석 메뉴 숨기기"
aria-label="분석 메뉴 숨기기"
>
<ChevronUp className="h-4 w-4" aria-hidden="true" />
</button>
</div>
<div className="flex flex-col gap-[3px] p-[6px]">
{ANALYSIS_POPUP_ITEMS.map((item) => {
const Icon = item.icon;
const isActive =
item.key === "gisEditPopup"
? isGisEditActive
: activeKey === item.key;
const label =
item.key === "fldngPredcPrst"
? `침수예측현황(${seriousPredictionCount}건)`
: item.label;
return (
<button
key={item.key}
type="button"
className={`relative flex h-[34px] items-center gap-[7px] rounded-[5px] border px-[8px] pl-[10px] text-left text-[12px] font-semibold transition ${
isActive
? "border-primary bg-secondary font-bold text-primary before:absolute before:left-0 before:top-[7px] before:bottom-[7px] before:w-[3px] before:rounded-r-[2px] before:bg-primary"
: "border-transparent bg-background text-foreground hover:border-border hover:bg-accent hover:text-primary"
}`}
onClick={() => {
if (item.key === "gisEditPopup" && onToggleGisEdit) {
setActiveKey(item.key);
onToggleGisEdit();
return;
}
return (
<button
key={item.key}
type="button"
className={`relative flex h-[33px] items-center gap-[7px] rounded-[5px] border px-[8px] pl-[10px] text-left text-[12px] font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary ${
isActive
? "border-primary bg-secondary font-bold text-primary before:absolute before:bottom-[7px] before:left-0 before:top-[7px] before:w-[3px] before:rounded-r-[2px] before:bg-primary"
: "border-transparent bg-background text-foreground hover:border-border hover:bg-accent hover:text-primary"
}`}
onClick={() => {
if (item.key === "gisEditPopup" && onToggleGisEdit) {
setActiveKey(item.key);
onToggleGisEdit();
return;
}
if (item.key === "infoSearch") {
onOpenInfoSearch();
return;
}
setActiveKey(item.key);
const popupWindow = onOpenPopup(item.key);
if (!popupWindow) {
setActiveKey(null);
return;
}
if (item.key === "infoSearch") {
onOpenInfoSearch();
return;
}
const closeWatcher = window.setInterval(() => {
if (!popupWindow.closed) return;
window.clearInterval(closeWatcher);
setActiveKey((currentKey) =>
currentKey === item.key ? null : currentKey,
);
}, 300);
}}
>
{item.renderIcon ? item.renderIcon({ isActive }) : null}
{Icon ? (
<Icon
className={`h-[15px] w-[15px] shrink-0 ${
isActive ? "text-primary" : "text-label"
}`}
/>
) : null}
<span className="whitespace-nowrap">{item.label}</span>
</button>
);
})}
</div>
const popupWindow = onOpenPopup(item.key);
if (!popupWindow) {
setActiveKey(null);
return;
}
const closeWatcher = window.setInterval(() => {
if (!popupWindow.closed) return;
window.clearInterval(closeWatcher);
setActiveKey((currentKey) =>
currentKey === item.key ? null : currentKey,
);
}, 300);
}}
>
{item.renderIcon ? item.renderIcon({ isActive }) : null}
{Icon ? (
<Icon className="h-[15px] w-[15px] shrink-0 text-current" />
) : null}
<span className="whitespace-nowrap" title={label}>
{label}
</span>
</button>
);
})}
</div>
) : null}
<button
type="button"
className="absolute right-0 top-0 flex h-[36px] w-[20px] items-center justify-center rounded-[4px] border border-border bg-background text-label shadow-[0_8px_18px_rgba(15,23,42,0.14)] transition hover:bg-accent hover:text-primary"
onClick={() => setIsOpen((value) => !value)}
title={isOpen ? "분석 메뉴 접기" : "분석 메뉴 펼치기"}
aria-label={isOpen ? "분석 메뉴 접기" : "분석 메뉴 펼치기"}
>
{isOpen ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
</button>
</div>
</div>
) : null}
</div>
);
}
@@ -1,3 +1,4 @@
/** 지도 시설 상세정보의 코드값을 업무 표시명으로 변환한다. */
import { MS_SWAGE_PPL_USE_CODES, MS_SWAGE_PPL_CODES, MS_SWAGE_MHL_CODES, MS_SWAGE_MHL_USE_CODES, DRTW_SEWAGE_PUMP_USE_CODES } from './table';
interface CodeMapperProps {
@@ -159,4 +160,4 @@ export const reverseCodeMapper = ({ layerName, data }: CodeMapperProps): any =>
};
// 기본 내보내기
export default { codeMapper, reverseCodeMapper };
export default { codeMapper, reverseCodeMapper };
@@ -1,3 +1,4 @@
/** 선택한 지도 레이어 종류에 맞는 시설 상세 컴포넌트를 반환한다. */
import MsSwagePpl from "../popupComponent/MsSwagePpl"; // 하수관로
import sewagePump from "../popupComponent/sewagePump"; // 하수펌프
import smh from "../popupComponent/smh";
@@ -17,10 +18,11 @@ type LayerComponentProps = {
chmbrYdnts: string;
gisObject?: Record<string, any>; // GIS 객체 추가
rawFeatureData?: any;
surveyResultContent?: React.ReactNode;
};
type LayerContentMap = {
[key: string]: {
component?: React.FC<LayerComponentProps>;
component?: React.ComponentType<any>;
props?: any;
};
};
@@ -45,7 +47,7 @@ export const getLayerContentComponent = (layerNm: string): React.FC<LayerCompone
// component가 없으면 null 반환
return () => null;
}
return component.component
return component.component as React.FC<LayerComponentProps>
};
// 사용자 입력 필드 (보수 및 갱신, 청소 및 준설, 점검 및 진단)
export const handleInputChange = (section: string,
@@ -1,3 +1,4 @@
/** 지도 시설 상세 및 목록에서 사용하는 컬럼·코드 정의를 제공한다. */
// 하수관로
export const MsSwagePplCol = [
{ id: 'fclt_mng_no', title: '시설관리번호' },
@@ -32,7 +33,7 @@ export const MsSwagePplCol = [
]
// 하수관로 searchModal 기본정보tab
export const MsSwagePplCol2 = [
{ id: 'rowNumber', title: '일련\n번호' },
{ id: 'rowNumber', title: '순번' },
{ id: 'fcltMngNo', title: '시설\n관리번호' },
{ id: 'instMngNo', title: '기관\n관리번호' },
{ id: 'flowDirectionBasis', title: '유하방향\n근거' },
@@ -398,7 +399,7 @@ export const SwtrmtFcltCol = [
// { id: 'obsrvnSptCd', title: '관측지점코드' },
];
export const SwtrmtFcltCol2 = [
{ id: 'rowNumber', title: '일련\n번호' },
{ id: 'rowNumber', title: '순번' },
{ id: 'swtrmtFcltNm', title: '하수처리\n시설명' },
{ id: 'fcltCpct', title: '시설\n용량\n(㎥/d)' },
{ id: 'rgnExpln', title: '지역설명' },
@@ -461,7 +462,7 @@ export const SwtrmtPrcsZoneCol = [
{ id: 'swtrmt_fclt_nm', title: '하수처리시설명' },
];
export const SwtrmtPrcsZoneCol2 = [
{ id: 'rowNumber', title: '일련번호' },
{ id: 'rowNumber', title: '순번' },
// { id: 'swtrmtZoneSn', title: '하수처리구역 일련번호' },
{ id: 'swtrmtZoneNm', title: '하수처리구역명' },
// { id: 'swtrmt_fclt_cd', title: '하수처리시설코드' },
@@ -478,6 +479,74 @@ export const SwtrmtPrcsZoneExcelCol = {
'swtrmtFcltNm': '하수처리시설명'
};
// 침수흔적도
export const FldngWtlvMapCol = [
{ id: 'fldng_yr', title: '침수년도' },
{ id: 'fldng_dstr_nm', title: '침수재해명' },
{ id: 'ctpv_nm', title: '시도명' },
{ id: 'sgg_nm', title: '시군구명' },
{ id: 'fldng_bgng_dtm', title: '침수시작일시' },
{ id: 'fldng_end_dtm', title: '침수종료일시' },
{ id: 'fldng_hr', title: '침수시간' },
{ id: 'fldng_dstrct_nm', title: '침수지구명' },
{ id: 'fldng_cs_dtl_cd', title: '침수원인상세코드' },
{ id: 'dtl_ocrn_cs_cn', title: '침수발생원인' },
{ id: 'avg_fldng_wtlv', title: '평균침수위' },
{ id: 'fldng_area', title: '침수면적' },
{ id: 'exmn_yr', title: '조사년도' },
{ id: 'dam_amt', title: '피해금액' },
{ id: 'rain_bgng_tm', title: '강우시작일시' },
{ id: 'rain_end_tm', title: '강우종료일시' },
{ id: 'rain_tot', title: '총강우량(mm)' },
{ id: 'rain_str', title: '강우강도(mm/hr)' },
];
export const FldngWtlvMapCol2 = [
{ id: 'rowNumber', title: '순번' },
{ id: 'fldngYr', title: '침수년도' },
{ id: 'fldngDstrNm', title: '침수재해명' },
{ id: 'ctpvNm', title: '시도' },
{ id: 'sggNm', title: '시군구' },
{ id: 'fldngBgngDtm', title: '침수시작일시' },
{ id: 'fldngEndDtm', title: '침수종료일시' },
{ id: 'fldngHr', title: '침수시간' },
{ id: 'fldngDstrctNm', title: '침수지구명' },
{ id: 'fldngCsDtlCd', title: '침수원인상세코드' },
{ id: 'avgFldngWtlv', title: '평균침수위' },
{ id: 'fldngArea', title: '침수면적' },
{ id: 'exmnYr', title: '조사년도' },
{ id: 'damAmt', title: '피해금액' },
{ id: 'rainBgngTm', title: '강우시작일시' },
{ id: 'rainEndTm', title: '강우종료일시' },
{ id: 'rainTot', title: '총강우량(mm)' },
{ id: 'rainStr', title: '강우강도(mm/hr)' },
];
export const FldngDpthMapCol = [
{ id: 'fldng_yr', title: '침수년도' },
{ id: 'ctpv_nm', title: '시도명' },
{ id: 'sgg_nm', title: '시군구명' },
{ id: 'fldng_dpth', title: '침수깊이(m)' },
{ id: 'fldng_area', title: '침수면적(㎡)' },
// { id: 'shape_area', title: '형상면적(㎡)' },
// { id: 'shape_len', title: '형상길이' },
];
export const FldngWtlvMapExcelCol = {
'fldngYr': '침수년도',
'fldngDstrNm': '침수재해명',
'ctpvNm': '시도',
'sggNm': '시군구',
'bgngYmd': '침수시작일자',
'fldngBgngHm': '침수시작시분',
'endYmd': '침수종료일자',
'fldngEndHm': '침수종료시분',
'fldngHr': '침수시간',
'fldngDstrctNm': '침수지구명',
'fldngCsDtlCd': '침수원인상세코드',
'avgFldngWtlv': '평균침수위',
'fldngArea': '침수면적',
'exmnYr': '조사년도',
'damAmt': '피해금액'
};
// 하수맨홀
export const MsSwageMhlCol = [
// { id: 'fid', title: 'FID' },
@@ -502,7 +571,7 @@ export const MsSwageMhlCol = [
// { id: 'inst_mng_no', title: '기관관리번호' }
];
export const MsSwageMhlCol2 = [
{ id: 'rowNumber', title: '일련번호' },
{ id: 'rowNumber', title: '순번' },
// { id: 'fid', title: 'FID' },
// { id: 'geom', title: 'geometry' },
// { id: 'ftreSmbolCd', title: '지형지물부호코드' },
@@ -624,7 +693,7 @@ export const ExclnSewagePumpCol = [
// { id: 'instMngNo', title: '기관관리번호' },
]
export const ExclnSewagePumpCol2 = [
{ id: 'rowNumber', title: '일련번호' },
{ id: 'rowNumber', title: '순번' },
// { id: 'fid', title: 'FID' },
// { id: 'geom', title: 'geometry' },
// { id: 'ftreSmbolCd', title: '지형지물부호코드' },
@@ -680,7 +749,7 @@ export const DrtwSewagePumpCol = [
// { id: 'instMngNo', title: '기관관리번호' },
]
export const DrtwSewagePumpCol2 = [
{ id: 'rowNumber', title: '일련번호' },
{ id: 'rowNumber', title: '순번' },
// { id: 'fid', title: 'FID' },
// { id: 'geom', title: 'geometry' },
// { id: 'ftreSmbolCd', title: '지형지물부호코드' },
@@ -729,7 +798,7 @@ export const stofcbrCol = [
{ id: 'drtn_angl', title: '방향각도(°)' },
]
export const stofcbrCol2 = [
{ id: 'rowNumber', title: '일련번호' },
{ id: 'rowNumber', title: '순번' },
// { id: 'ftreSmbolCd', title: '지형지물부호코드' },
{ id: 'fcltMngNo', title: '시설관리번호' },
// { id: 'pbadmsEmdCd', title: '행정읍면동코드' },
@@ -819,7 +888,8 @@ export const dateFields = [
'instlYmd', 'lastDrdgnYmd', 'cmcnYmd', 'oprtngStrtYmd', 'clsgYmd',
'mesureBeginDt', 'mesureEndDt', 'qltwtrBeginDt', 'qltwtrEndDt',
'rainBeginDt', 'rainEndDt',
'flfmtBgngYmd', 'flfmtEndYmd', 'flfmtYr'
'flfmtBgngYmd', 'flfmtEndYmd', 'flfmtYr',
'bgng_ymd', 'end_ymd', 'bgngYmd', 'endYmd'
];
// 🎯 연월 필드들 (yyyymm -> yyyy년 m월)
@@ -848,5 +918,7 @@ export const numberFields = [
'precdngDry', 'rainFall', 'cntncTime', 'rainIn',
'mesureIntrvl', 'qltwtrCo', 'rnysnFvlc', 'clarFvlc',
'bizAmt', 'count',
'chmbr_xcnts', 'chmbr_ydnts'
'chmbr_xcnts', 'chmbr_ydnts',
'fldng_hr', 'avg_fldng_wtlv', 'fldng_area', 'dam_amt',
'fldngHr', 'avgFldngWtlv', 'fldngArea', 'damAmt'
];
@@ -0,0 +1,101 @@
/**
* 화면명: 관로조사 레이어 설정
* 사업차수와 조사 종류·상태등급별 지도 표시 조건을 설정한다.
*/
"use client";
import type { ExmnMapFilters, ExmnMapJobType } from "./exmnMapTypes";
import LayerSettingCard from "../layerSetting/LayerSettingCard";
type Props = {
jobType: ExmnMapJobType;
filters: ExmnMapFilters;
onFiltersChange: (filters: ExmnMapFilters) => void;
enabled?: boolean;
onEnabledChange?: (enabled: boolean) => void;
onClose?: () => void;
};
const GRADE_OPTIONS = ["", "1", "2", "3", "4", "5"];
/** 조사결과 레이어의 세 가지 자동 적용 필터다. */
export default function ExmnLayerSettingPanel({
jobType,
filters,
onFiltersChange,
enabled = true,
onEnabledChange = () => {},
onClose = () => {},
}: Props) {
const updateFilter = (key: keyof ExmnMapFilters, value: string) => {
onFiltersChange({
...filters,
[key]: value.replace(/[^0-9]/g, ""),
});
};
const selectClass =
"h-[26px] w-full rounded border border-input bg-background px-2 py-0 text-center text-[12px] leading-none text-foreground outline-none focus:border-primary";
const labelClass = "shrink-0 text-[1.15rem] font-medium text-label";
const title = jobType === "PPL" ? "관로조사" : "맨홀조사";
return (
<LayerSettingCard
title={title}
enabled={enabled}
onEnabledChange={onEnabledChange}
onClose={onClose}
>
<div className="space-y-[6px]">
<label className="flex min-h-[24px] flex-col items-stretch gap-1">
<span className={`flex items-center gap-1 ${labelClass}`}>
<span
className={`h-2 w-2 rounded-full ${jobType === "PPL" ? "bg-[#facc15]" : "bg-[#8b5cf6]"}`}
/>
사업차수
</span>
<input
className={selectClass}
type="text"
inputMode="numeric"
maxLength={10}
placeholder="전체"
value={filters.bizCycl}
onChange={(event) => updateFilter("bizCycl", event.target.value)}
/>
</label>
<label className="flex min-h-[24px] flex-col items-stretch gap-1">
<span className={labelClass}>구조적 상태등급</span>
<select
className={selectClass}
value={filters.structuralGrade}
onChange={(event) =>
updateFilter("structuralGrade", event.target.value)
}
>
{GRADE_OPTIONS.map((grade) => (
<option key={grade || "all"} value={grade}>
{grade || "전체"}
</option>
))}
</select>
</label>
<label className="flex min-h-[24px] flex-col items-stretch gap-1">
<span className={labelClass}>운영적 상태등급</span>
<select
className={selectClass}
value={filters.operationalGrade}
onChange={(event) =>
updateFilter("operationalGrade", event.target.value)
}
>
{GRADE_OPTIONS.map((grade) => (
<option key={grade || "all"} value={grade}>
{grade || "전체"}
</option>
))}
</select>
</label>
</div>
</LayerSettingCard>
);
}
@@ -0,0 +1,148 @@
/**
* 화면명: 관로조사 결과
* 선택 시설의 조사 결과와 상세 판정 정보를 표시한다.
*/
"use client";
import {
Button,
Table,
TableBody,
TableCell,
TableRow,
} from "@common/components";
import { getExmnPipeReportAvailability } from "@/features/exmn";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import { useAlertStore } from "@/shared/lib";
type ExmnSurveyResultSectionProps = {
featureData: any;
};
const readRawFeatureProperty = (featureData: any, keys: string[]) => {
for (const key of keys) {
const value =
featureData?.get?.(key) ??
featureData?.values_?.[key] ??
featureData?.[key];
if (value !== undefined && value !== null) return value;
}
return undefined;
};
const readFeatureProperty = (featureData: any, keys: string[]) =>
String(readRawFeatureProperty(featureData, keys) ?? "").trim();
const readBooleanFeatureProperty = (featureData: any, keys: string[]) => {
const value = readRawFeatureProperty(featureData, keys);
return (
value === true ||
String(value ?? "")
.trim()
.toLowerCase() === "true"
);
};
const displayValue = (value: string) => value || "-";
export default function ExmnSurveyResultSection({
featureData,
}: ExmnSurveyResultSectionProps) {
const alert = useAlertStore((state) => state.setMessage);
const exmnBizId = readFeatureProperty(featureData, ["exmnBizId"]);
const jobType = readFeatureProperty(featureData, ["jobType"]);
const pipeNumber = readFeatureProperty(featureData, [
"gisManagementNo",
"fclt_mng_no",
"fcltMngNo",
]);
const reportAvailable = readBooleanFeatureProperty(featureData, [
"reportAvailable",
]);
const canOpenPipeReport =
jobType === "PPL" && Boolean(exmnBizId && pipeNumber);
const openPipeReport = async () => {
if (!canOpenPipeReport) return;
if (!reportAvailable) {
alert("등록된 관로 조사보고서가 없습니다.");
return;
}
try {
const availability = await getExmnPipeReportAvailability(
exmnBizId,
pipeNumber,
);
if (!availability.available) {
alert(
availability.message?.trim() ||
`선택한 관로번호의 조사보고서가 등록된 PDF에 없습니다: ${pipeNumber}`,
);
return;
}
const reportWindow = openWindowPopup("exmnPipeReport", {
exmnBizId,
pipeNumber,
reportNo: readFeatureProperty(featureData, ["reportNo"]),
});
if (!reportWindow) {
alert(
"팝업이 차단되었습니다. 브라우저의 팝업 허용 설정을 확인해주세요.",
);
}
} catch (error) {
alert(
error instanceof Error && error.message
? error.message
: "관로 조사보고서 열람 가능 여부를 확인하지 못했습니다.",
);
}
};
const rows = [
["사업명", readFeatureProperty(featureData, ["bizNm"])],
["사업차수", readFeatureProperty(featureData, ["bizCycl"])],
["처리분구", readFeatureProperty(featureData, ["trtdstNm"])],
["조사유형", jobType === "MNHL" ? "맨홀조사" : "관로조사"],
["보고서번호", readFeatureProperty(featureData, ["reportNo"])],
["구조적결함 등급", readFeatureProperty(featureData, ["structuralGrade"])],
["운영적결함 등급", readFeatureProperty(featureData, ["operationalGrade"])],
];
return (
<section className="border-b border-border bg-background">
<div className="card-header flex items-center justify-between">
<h3 className="mx-3 text-2xl font-medium text-label">조사 결과</h3>
{jobType === "PPL" && (
<Button
onClick={openPipeReport}
disabled={!canOpenPipeReport}
className="hover:bg-primary/90 mr-3 rounded-sm bg-primary px-4 py-2 text-sm font-medium text-primary-foreground disabled:cursor-not-allowed disabled:opacity-50"
>
조사보고서 보기
</Button>
)}
</div>
<div className="card !m-[1.2rem] !p-0">
<Table variant="secondary">
<TableBody className="text-center">
{rows.map(([label, value]) => (
<TableRow key={label}>
<TableCell className="w-[120px] whitespace-nowrap bg-secondary text-xs font-bold text-label">
{label}
</TableCell>
<TableCell className="break-all text-left text-xs">
{displayValue(value ?? "")}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</section>
);
}
@@ -0,0 +1,51 @@
/** 관로조사 지도 데이터 조회 API와 응답 변환 함수를 제공한다. */
import { getRawJson } from "../../../../components/lib/api";
import type { ExmnMapFilters, ExmnMapJobType } from "./exmnMapTypes";
type ExmnLayerScope = {
sggCd?: string;
ctpvNm?: string;
sggNm?: string;
};
/** 구버전 응답이 하류맨홀을 포함해도 업로드 관로조사의 상류맨홀 피처만 지도에 전달한다. */
export const retainUpstreamManholeFeatures = (response: any) => {
const featureCollection = response?.data?.type === "FeatureCollection"
? response.data
: response?.type === "FeatureCollection"
? response
: null;
if (!featureCollection || !Array.isArray(featureCollection.features)) return response;
const features = featureCollection.features.filter(
(feature: any) => String(feature?.properties?.manholePosition ?? "").toUpperCase() === "UPSTREAM",
);
const upstreamCollection = {
...featureCollection,
features,
totalFeatures: features.length,
numberReturned: features.length,
};
return response?.data === featureCollection
? { ...response, data: upstreamCollection }
: upstreamCollection;
};
/** 사업차수·구조등급·운영등급 AND 조건으로 조사결과 GeoJSON을 조회한다. */
export const getExmnMapLayer = async (
jobType: ExmnMapJobType,
filters: ExmnMapFilters,
scope: ExmnLayerScope,
) => {
const query = new URLSearchParams({ jobType });
if (scope.sggCd?.trim()) query.set("sggCd", scope.sggCd.trim());
if (scope.ctpvNm?.trim()) query.set("ctpvNm", scope.ctpvNm.trim());
if (scope.sggNm?.trim()) query.set("sggNm", scope.sggNm.trim());
if (filters.bizCycl) query.set("bizCycl", filters.bizCycl);
if (filters.structuralGrade) query.set("structuralGrade", filters.structuralGrade);
if (filters.operationalGrade) query.set("operationalGrade", filters.operationalGrade);
const response = await getRawJson(`/sewerage-service/api/v1/exmnMap/layers?${query.toString()}`);
return jobType === "MNHL" ? retainUpstreamManholeFeatures(response) : response;
};
@@ -0,0 +1,299 @@
/** 관로조사 지도 화면에서 사용하는 타입·상수와 표시 조건을 정의한다. */
export const EXMN_RESULT_ROOT_NODE_ID = "SN_EXMN_RESULT";
export const EXMN_RESULT_PPL_NODE_ID = "SN_EXMN_RESULT_PPL";
export const EXMN_RESULT_MNHL_NODE_ID = "SN_EXMN_RESULT_MNHL";
export const EXMN_RESULT_PPL_LAYER_ID = "SN_EXMN_RESULT_PPL_LAYER";
export const EXMN_RESULT_MNHL_LAYER_ID = "SN_EXMN_RESULT_MNHL_LAYER";
export const EXMN_RESULT_ROOT_LAYER_TYPE = "sn_exmn_result_group";
export const EXMN_RESULT_PPL_LAYER_TYPE = "sn_exmn_result_ppl";
export const EXMN_RESULT_MNHL_LAYER_TYPE = "sn_exmn_result_mnhl";
// 레이어 트리에서 조사 관로와 기존 맨홀 분류 심볼을 즉시 구분하기 위한 전용 범례 스타일이다.
export const EXMN_RESULT_PPL_LEGEND_COLOR = "#facc15";
export const EXMN_RESULT_MNHL_LEGEND_ICON = "SN_EXMN_MANHOLE_PURPLE.png?v=3";
export type ExmnMapJobType = "PPL" | "MNHL";
export type ExmnMapFilters = {
bizCycl: string;
structuralGrade: string;
operationalGrade: string;
};
export type ExmnMapEntryContext = {
exmnBizId: string;
ctpvNm: string;
sggNm: string;
bizCycl: string;
jobType: ExmnMapJobType | null;
};
export const EMPTY_EXMN_MAP_FILTERS: ExmnMapFilters = {
bizCycl: "",
structuralGrade: "",
operationalGrade: "",
};
const buildControlNode = (
id: string,
name: string,
upperId: string | number | null,
layerType: string,
initialVisible: boolean,
) => {
const isPipeNode = layerType === EXMN_RESULT_PPL_LAYER_TYPE;
const isManholeNode = layerType === EXMN_RESULT_MNHL_LAYER_TYPE;
return {
lyrSn: id,
id,
lyrNm: name,
upperLyrSn: upperId,
upId: upperId,
sortOrdr: upperId ? (id === EXMN_RESULT_PPL_NODE_ID ? "1" : "2") : "5",
drawOrdr: null,
flterSql: null,
gobjTy: isPipeNode ? "line" : "",
lyrTyNm: layerType,
useYn: "Y",
minViewSize: null,
iconNm: isManholeNode ? EXMN_RESULT_MNHL_LEGEND_ICON : null,
iconSize: null,
lblAtrbNm: null,
fontNm: null,
fontIntnColor: null,
fontExtnColor: null,
fontExtnWdth: null,
lnWdth: null,
lnColor: isPipeNode ? EXMN_RESULT_PPL_LEGEND_COLOR : null,
polgnColor: null,
initEprssYn: initialVisible ? "Y" : "N",
clickPsblYn: "N",
exprsYn: "Y",
cmncNm: "all",
srvcNm: "api",
actlLyrYn: "N",
clckZoom: null,
children: null,
};
};
const resolveNodeData = (node: any) => node?.data ?? node ?? {};
const resolveNodeId = (node: any) => {
const nodeData = resolveNodeData(node);
return nodeData?.lyrSn ?? nodeData?.id ?? "";
};
const resolveNodeParentId = (node: any) => {
const nodeData = resolveNodeData(node);
return nodeData?.upperLyrSn ?? nodeData?.upId ?? "";
};
const resolveNodeLayerType = (node: any) => {
const nodeData = resolveNodeData(node);
return String(
nodeData?.lyrTyNm ??
nodeData?.lyrTypeNm ??
nodeData?.lyr_type_nm ??
"",
).trim();
};
const resolveNodeName = (node: any) => {
const nodeData = resolveNodeData(node);
return String(nodeData?.lyrNm ?? nodeData?.name ?? "").trim();
};
const sameNodeId = (left: unknown, right: unknown) =>
String(left ?? "") === String(right ?? "");
const isNodeType = (node: any, layerType: string) =>
resolveNodeLayerType(node) === layerType;
const normalizeControlNode = (
node: any,
fallbackId: string,
fallbackName: string,
upperId: string | number | null,
layerType: string,
initialVisible: boolean,
) => {
const nodeId = resolveNodeId(node) || fallbackId;
const isPipeNode = layerType === EXMN_RESULT_PPL_LAYER_TYPE;
const isManholeNode = layerType === EXMN_RESULT_MNHL_LAYER_TYPE;
return {
...node,
lyrSn: nodeId,
id: nodeId,
// DB에 저장된 과거 명칭과 무관하게 조사관리 표준 화면 명칭으로 표시한다.
lyrNm: fallbackName,
upperLyrSn: upperId,
upId: upperId,
lyrTyNm: layerType,
gobjTy: isPipeNode ? "line" : "",
iconNm: isManholeNode ? EXMN_RESULT_MNHL_LEGEND_ICON : null,
lnColor: isPipeNode ? EXMN_RESULT_PPL_LEGEND_COLOR : null,
initEprssYn: initialVisible ? "Y" : "N",
clickPsblYn: "N",
srvcNm: "api",
actlLyrYn: "N",
};
};
/**
* 서버 레이어 설정의 조사결과 노드를 우선 사용한다.
* DB 설정이 아직 배포되지 않았거나 일부만 등록된 환경에서는 누락 노드만 가상 노드로 보완한다.
*/
export const appendExmnResultControlNodes = (
data: unknown[],
initialJobType: ExmnMapJobType | null,
) => {
if (!Array.isArray(data)) return data;
const rootNode =
data.find((item: any) => isNodeType(item, EXMN_RESULT_ROOT_LAYER_TYPE)) ??
data.find((item: any) =>
sameNodeId(resolveNodeId(item), EXMN_RESULT_ROOT_NODE_ID),
) ??
data.find(
(item: any) =>
resolveNodeName(item) === "조사결과" && !resolveNodeParentId(item),
);
const rootId = resolveNodeId(rootNode) || EXMN_RESULT_ROOT_NODE_ID;
const pplNode =
data.find((item: any) => isNodeType(item, EXMN_RESULT_PPL_LAYER_TYPE)) ??
data.find((item: any) =>
sameNodeId(resolveNodeId(item), EXMN_RESULT_PPL_NODE_ID),
) ??
data.find(
(item: any) =>
["관로조사", "CCTV조사"].includes(resolveNodeName(item)) &&
sameNodeId(resolveNodeParentId(item), rootId),
);
const mnhlNode =
data.find((item: any) => isNodeType(item, EXMN_RESULT_MNHL_LAYER_TYPE)) ??
data.find((item: any) =>
sameNodeId(resolveNodeId(item), EXMN_RESULT_MNHL_NODE_ID),
) ??
data.find(
(item: any) =>
resolveNodeName(item) === "맨홀조사" &&
sameNodeId(resolveNodeParentId(item), rootId),
);
const normalizedPplNode = pplNode
? normalizeControlNode(
pplNode,
EXMN_RESULT_PPL_NODE_ID,
"관로조사",
rootId,
EXMN_RESULT_PPL_LAYER_TYPE,
initialJobType === "PPL",
)
: buildControlNode(
EXMN_RESULT_PPL_NODE_ID,
"관로조사",
rootId,
EXMN_RESULT_PPL_LAYER_TYPE,
initialJobType === "PPL",
);
const normalizedMnhlNode = mnhlNode
? normalizeControlNode(
mnhlNode,
EXMN_RESULT_MNHL_NODE_ID,
"맨홀조사",
rootId,
EXMN_RESULT_MNHL_LAYER_TYPE,
initialJobType === "MNHL",
)
: buildControlNode(
EXMN_RESULT_MNHL_NODE_ID,
"맨홀조사",
rootId,
EXMN_RESULT_MNHL_LAYER_TYPE,
initialJobType === "MNHL",
);
const childIds = [
resolveNodeId(normalizedPplNode),
resolveNodeId(normalizedMnhlNode),
];
const previousChildren = Array.isArray((rootNode as any)?.children)
? (rootNode as any).children
: [];
const normalizedRootNode = {
...(rootNode
? normalizeControlNode(
rootNode,
EXMN_RESULT_ROOT_NODE_ID,
"조사결과",
null,
EXMN_RESULT_ROOT_LAYER_TYPE,
false,
)
: buildControlNode(
EXMN_RESULT_ROOT_NODE_ID,
"조사결과",
null,
EXMN_RESULT_ROOT_LAYER_TYPE,
false,
)),
children: [...previousChildren, ...childIds].filter(
(childId, index, children) =>
childId !== "" &&
children.findIndex((candidate) => sameNodeId(candidate, childId)) ===
index,
),
};
const recognizedIds = new Set(
[
EXMN_RESULT_ROOT_NODE_ID,
EXMN_RESULT_PPL_NODE_ID,
EXMN_RESULT_MNHL_NODE_ID,
resolveNodeId(rootNode),
resolveNodeId(pplNode),
resolveNodeId(mnhlNode),
]
.filter((id) => id !== "")
.map((id) => String(id)),
);
const filtered = data.filter((item: any) => {
const nodeId = String(resolveNodeId(item));
return (
!recognizedIds.has(nodeId) &&
!isNodeType(item, EXMN_RESULT_ROOT_LAYER_TYPE) &&
!isNodeType(item, EXMN_RESULT_PPL_LAYER_TYPE) &&
!isNodeType(item, EXMN_RESULT_MNHL_LAYER_TYPE)
);
});
return [
...filtered,
normalizedRootNode,
normalizedPplNode,
normalizedMnhlNode,
];
};
export const isExmnResultPplNode = (node: any) =>
sameNodeId(resolveNodeId(node), EXMN_RESULT_PPL_NODE_ID) ||
isNodeType(node, EXMN_RESULT_PPL_LAYER_TYPE);
export const isExmnResultMnhlNode = (node: any) =>
sameNodeId(resolveNodeId(node), EXMN_RESULT_MNHL_NODE_ID) ||
isNodeType(node, EXMN_RESULT_MNHL_LAYER_TYPE);
export const buildExmnMapEntryContext = (
searchParams: Pick<URLSearchParams, "get">,
): ExmnMapEntryContext => {
const rawJobType = String(searchParams.get("exmnJobType") ?? "").toUpperCase();
return {
exmnBizId: String(searchParams.get("exmnBizId") ?? "").trim(),
ctpvNm: String(searchParams.get("ctpvNm") ?? "").trim(),
sggNm: String(searchParams.get("sggNm") ?? "").trim(),
bizCycl: String(searchParams.get("bizCycl") ?? "").replace(/[^0-9]/g, ""),
jobType: rawJobType === "PPL" || rawJobType === "MNHL" ? rawJobType : null,
};
};
@@ -0,0 +1,157 @@
/**
* 화면명: 침수흔적도 상세정보
* 지도에서 선택한 침수흔적의 기본정보·강우정보·피해정보를 표시한다.
*/
"use client";
import { useMemo, useState } from "react";
import type { FldngHstryItem } from "./fldngHstryTypes";
import { formatFldngHstryArea, formatFldngHstryDepth } from "./fldngHstryUtils";
type Props = {
detail: FldngHstryItem;
onClose: () => void;
position: {
left: number;
top: number;
};
};
type DetailTab = "year" | "disaster" | "rainfall";
const renderRows = (rows: Array<[string, string]>) => (
<table className="w-full table-fixed border-collapse">
<colgroup>
<col className="w-[42%]" />
<col className="w-[58%]" />
</colgroup>
<tbody>
{rows.map(([label, value], index) => (
<tr key={`${label}-${index}`}>
<th className="border border-border bg-secondary px-3 py-[7px] text-left text-[1.02rem] font-semibold text-label">
{label}
</th>
<td className="border border-border bg-background px-3 py-[7px] text-[1.02rem] text-foreground">
{value}
</td>
</tr>
))}
</tbody>
</table>
);
export default function FldngHstryDetailPanel({
detail,
onClose,
position,
}: Props) {
// 지도에서 침수흔적도 polygon을 클릭했을 때, 상세정보는 업무 의미 기준으로 3개 탭에 나눠 보여준다.
const [activeTab, setActiveTab] = useState<DetailTab>("year");
const yearRows = useMemo(
() =>
[
["침수심", `${formatFldngHstryDepth(detail.floodDepthM)} m`],
["침수면적", formatFldngHstryArea(detail.floodAreaHa)],
["침수년도", `${detail.floodYear}년`],
["등급", detail.gradeLabel],
["침수구역", detail.districtName],
] as Array<[string, string]>,
[detail],
);
const disasterRows = useMemo(
() =>
[
["침수재해명", detail.disasterName],
["침수면적", formatFldngHstryArea(detail.floodAreaHa)],
["침수원인상세명", detail.causeDetailName],
["침수시작년월일", detail.startDate],
["침수종료년월일", detail.endDate],
["침수시작시간", detail.startTime],
] as Array<[string, string]>,
[detail],
);
const rainfallRows = useMemo(
() =>
[
["침수시작일자", detail.startDate],
["침수종료일자", detail.endDate],
["침수시작시간", detail.startTime],
["침수종료시간", detail.endTime],
["침수시간", detail.floodHour],
["강우시작시간", detail.rainStartTime],
["강우종료시간", detail.rainEndTime],
["총강우량(mm)", `${detail.totalRainfallMm.toFixed(0)}`],
["강우강도(mm/hr)", `${detail.rainfallIntensityMmHr.toFixed(0)}`],
["침수시간", detail.floodHour],
["침수원인상세코드", detail.causeDetailCode],
["피해금액", detail.damageAmount],
] as Array<[string, string]>,
[detail],
);
const currentRows =
activeTab === "year"
? yearRows
: activeTab === "disaster"
? disasterRows
: rainfallRows;
return (
<div
className="absolute z-[70] w-[248px] overflow-hidden rounded-[8px] border border-border bg-background shadow-blur"
style={{
left: `${position.left}px`,
top: `${position.top}px`,
}}
>
<div className="flex h-[28px] items-center justify-between border-b border-border bg-secondary px-[10px]">
<div className="text-[10px] font-bold tracking-[-0.01em] text-label">
상세정보
</div>
<button
type="button"
className="rounded-[4px] px-[4px] text-[13px] leading-none text-muted-foreground transition hover:bg-accent hover:text-accent-foreground"
onClick={onClose}
>
×
</button>
</div>
<div className="border-b border-border bg-secondary px-[6px] pt-[6px]">
<div className="flex gap-[4px]">
{[
["year", "년도탭"],
["disaster", "재해명탭"],
["rainfall", "침수발생강우탭"],
].map(([tabId, label]) => {
const isActive = activeTab === tabId;
return (
<button
key={tabId}
type="button"
className={`h-[24px] flex-1 rounded-t-[6px] border px-[6px] text-[9px] font-semibold leading-none transition ${
isActive
? "border-primary border-b-background bg-background text-primary"
: "border-border bg-secondary text-muted-foreground hover:bg-accent"
}`}
onClick={() => setActiveTab(tabId as DetailTab)}
>
{label}
</button>
);
})}
</div>
</div>
<div className="bg-background p-[6px]">
<div className="overflow-hidden rounded-[6px] border border-border bg-background">
{renderRows(currentRows)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,397 @@
/**
* 화면명: 침수흔적위선 연계정보
* 우측 정보 화면의 심선·위선 목록, 선택 상태와 화면 생명주기 캐시를 관리한다.
*/
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { RefObject } from "react";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import type Feature from "ol/Feature.js";
import VectorSource from "ol/source/Vector.js";
import { loadFldngHstryDetail } from "./fldngHstryClient";
import {
FLDNG_HSTRY_DEPTH_LAYER_TYPE,
FLDNG_HSTRY_LAYER_TYPE,
type FldngHstryDetailCacheValue,
} from "./fldngHstryTypes";
type FldngHstryGisLayer = {
searchLayer?: { getSource?: () => VectorSource | null } | null;
clearSearchLayer: (option: unknown) => void;
ensureSearchLayer: (option: unknown) => {
set: (key: string, value: unknown) => void;
setSource: (source: VectorSource) => void;
};
showSearchResultsFromGeoJSON: (
layerTypeName: string,
featureId: string,
) => void | Promise<void>;
};
type FldngHstryScrollButtonProps = {
targetRef: RefObject<HTMLDivElement | null>;
direction: -1 | 1;
label: string;
children: string;
};
const FLDNG_HSTRY_SCROLL_CLICK_DISTANCE = 120;
const FLDNG_HSTRY_SCROLL_HOLD_DELAY = 300;
const FLDNG_HSTRY_SCROLL_HOLD_SPEED = 4;
/** 짧게 누르면 한 칸, 길게 누르면 목록을 해당 방향으로 계속 이동한다. */
const FldngHstryScrollButton = ({
targetRef,
direction,
label,
children,
}: FldngHstryScrollButtonProps) => {
const holdTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const animationFrameRef = useRef<number | null>(null);
const pointerClickHandledRef = useRef(false);
const stopContinuousScroll = useCallback(() => {
if (holdTimerRef.current !== null) {
clearTimeout(holdTimerRef.current);
holdTimerRef.current = null;
}
if (animationFrameRef.current !== null) {
cancelAnimationFrame(animationFrameRef.current);
animationFrameRef.current = null;
}
}, []);
const scrollOnce = useCallback(() => {
targetRef.current?.scrollBy({
left: direction * FLDNG_HSTRY_SCROLL_CLICK_DISTANCE,
behavior: "smooth",
});
}, [direction, targetRef]);
const startContinuousScroll = useCallback(() => {
const scroll = () => {
const target = targetRef.current;
if (!target) {
stopContinuousScroll();
return;
}
target.scrollLeft += direction * FLDNG_HSTRY_SCROLL_HOLD_SPEED;
animationFrameRef.current = requestAnimationFrame(scroll);
};
animationFrameRef.current = requestAnimationFrame(scroll);
}, [direction, stopContinuousScroll, targetRef]);
useEffect(() => stopContinuousScroll, [stopContinuousScroll]);
return (
<button
type="button"
aria-label={label}
className="shrink-0 select-none px-1 text-lg leading-none text-foreground"
onPointerDown={(event) => {
if (event.button !== 0) return;
stopContinuousScroll();
pointerClickHandledRef.current = true;
event.currentTarget.setPointerCapture(event.pointerId);
scrollOnce();
holdTimerRef.current = setTimeout(
startContinuousScroll,
FLDNG_HSTRY_SCROLL_HOLD_DELAY,
);
}}
onPointerUp={stopContinuousScroll}
onPointerCancel={() => {
stopContinuousScroll();
pointerClickHandledRef.current = false;
}}
onLostPointerCapture={stopContinuousScroll}
onClick={() => {
if (pointerClickHandledRef.current) {
pointerClickHandledRef.current = false;
return;
}
scrollOnce();
}}
>
{children}
</button>
);
};
const cloneFldngHstryFeature = (feature: Feature) => {
const clonedFeature = feature.clone();
const featureId = feature.getId();
if (featureId !== undefined) clonedFeature.setId(featureId);
return clonedFeature;
};
const displayCachedFldngHstryFeatures = (
gisLayer: FldngHstryGisLayer,
layerTypeName: string,
features: Feature[],
) => {
const matched = Object.values(GisApp.layerCode ?? {}).find(
(item: any) => item?.lyrTyNm === layerTypeName,
) as any;
if (!matched || features.length === 0) return;
gisLayer.clearSearchLayer(matched);
const layer = gisLayer.ensureSearchLayer(matched);
layer.set("gobjTy", matched.gobjTy);
const clonedFeatures = features.map(cloneFldngHstryFeature);
layer.setSource(new VectorSource({ features: clonedFeatures }));
(GisApp.Module.select as any)?.selectFeature(clonedFeatures[0], false);
};
const showFldngHstryFeature = async (
cache: Map<string, Promise<Feature[]>>,
gisLayer: FldngHstryGisLayer,
layerTypeName: string,
featureId: string,
) => {
const cacheKey = `${layerTypeName}:${featureId}`;
const cachedRequest = cache.get(cacheKey);
if (cachedRequest) {
displayCachedFldngHstryFeatures(
gisLayer,
layerTypeName,
await cachedRequest,
);
return;
}
await gisLayer.showSearchResultsFromGeoJSON(layerTypeName, featureId);
const source = gisLayer.searchLayer?.getSource?.();
if (!source) return;
const featureRequest = new Promise<Feature[]>((resolve, reject) => {
const resolveFeatures = () => {
const features = source.getFeatures();
if (features.length > 0) {
resolve(features.map(cloneFldngHstryFeature));
} else {
reject(new Error("침수흔적 위치 정보가 없습니다."));
}
};
const rejectFeatures = () =>
reject(new Error("침수흔적 위치 조회에 실패했습니다."));
if (source.getFeatures().length > 0) {
resolveFeatures();
return;
}
source.once("featuresloadend", resolveFeatures);
source.once("featuresloaderror", rejectFeatures);
});
cache.set(cacheKey, featureRequest);
try {
await featureRequest;
} catch (error) {
cache.delete(cacheKey);
console.warn(error);
}
};
export const useFldngHstryFeatureClick = () => {
const fldngHstryDetailCacheRef = useRef(
new Map<string, Promise<FldngHstryDetailCacheValue>>(),
);
const fldngHstryFeatureCacheRef = useRef(
new Map<string, Promise<Feature[]>>(),
);
const [fldngHstryDepthRows, setFldngHstryDepthRows] = useState<
Record<string, unknown>[]
>([]);
const [fldngHstryWtlvRows, setFldngHstryWtlvRows] = useState<
Record<string, unknown>[]
>([]);
const [selectedFldngHstryDepthFid, setSelectedFldngHstryDepthFid] = useState<
string | null
>(null);
const [selectedFldngHstryWtlvFid, setSelectedFldngHstryWtlvFid] = useState<
string | null
>(null);
const fldngHstryDepthYearListRef = useRef<HTMLDivElement>(null);
const fldngHstryWtlvListRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const detailCache = fldngHstryDetailCacheRef.current;
const featureCache = fldngHstryFeatureCacheRef.current;
return () => {
detailCache.clear();
featureCache.clear();
};
}, []);
const resetFldngHstryFeatureClick = useCallback(() => {
setFldngHstryDepthRows([]);
setFldngHstryWtlvRows([]);
setSelectedFldngHstryDepthFid(null);
setSelectedFldngHstryWtlvFid(null);
}, []);
const loadFldngHstryFeatureClickDetail = useCallback(
async (fldngHstryFid: string) => {
const cacheKey = `${FLDNG_HSTRY_LAYER_TYPE}:${fldngHstryFid}`;
let request = fldngHstryDetailCacheRef.current.get(cacheKey);
if (!request) {
request = loadFldngHstryDetail(fldngHstryFid);
fldngHstryDetailCacheRef.current.set(cacheKey, request);
}
const detail = await request;
if (!detail.complete) fldngHstryDetailCacheRef.current.delete(cacheKey);
setFldngHstryDepthRows(detail.fldngHstryDepthRows);
setFldngHstryWtlvRows(detail.fldngHstryWtlvRows);
setSelectedFldngHstryDepthFid(null);
setSelectedFldngHstryWtlvFid(null);
return detail;
},
[],
);
const headName = useMemo(
() => (
<div className="flex w-full min-w-0 flex-col items-start gap-2">
<span>기본정보</span>
{fldngHstryDepthRows.length > 0 && (
<div className="flex w-full items-center gap-1">
<span className="w-9 shrink-0 text-[1.1rem] font-semibold">
심선
</span>
<FldngHstryScrollButton
targetRef={fldngHstryDepthYearListRef}
direction={-1}
label="이전 연도"
>
‹
</FldngHstryScrollButton>
<div
ref={fldngHstryDepthYearListRef}
className="flex min-w-0 flex-1 gap-2 overflow-x-auto whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{[...fldngHstryDepthRows]
.map((row, index) => ({
row,
index,
year: String(
row.fldng_yr ?? row.fldngYr ?? row.yr ?? "",
).trim(),
}))
.filter(({ year }) => Boolean(year))
.sort((left, right) => {
const yearOrder = left.year.localeCompare(
right.year,
undefined,
{ numeric: true },
);
return yearOrder !== 0 ? yearOrder : left.index - right.index;
})
.map(({ row, index, year }) => {
const depthFid = String(
row.fldng_dpth_map_sn ?? row.fid ?? "",
).trim();
return (
<button
key={`${depthFid || "row"}-${index}`}
type="button"
onClick={async () => {
if (!depthFid) return;
setSelectedFldngHstryDepthFid(depthFid);
setSelectedFldngHstryWtlvFid(null);
await showFldngHstryFeature(
fldngHstryFeatureCacheRef.current,
GisApp.Module.layer as any,
FLDNG_HSTRY_DEPTH_LAYER_TYPE,
`${FLDNG_HSTRY_DEPTH_LAYER_TYPE}.${depthFid.replace(new RegExp(`^${FLDNG_HSTRY_DEPTH_LAYER_TYPE}\\.`), "")}`,
);
}}
className={`shrink-0 rounded border px-3 py-1 text-[1.2rem] font-medium leading-5 ${selectedFldngHstryDepthFid === depthFid ? "border-blue-600 bg-white text-blue-700" : "border-border bg-muted text-muted-foreground"}`}
>
{year}년
</button>
);
})}
</div>
<FldngHstryScrollButton
targetRef={fldngHstryDepthYearListRef}
direction={1}
label="다음 연도"
>
›
</FldngHstryScrollButton>
</div>
)}
{fldngHstryWtlvRows.length > 0 && (
<div className="flex w-full items-center gap-1">
<span className="w-9 shrink-0 text-[1.1rem] font-semibold">
위선
</span>
<FldngHstryScrollButton
targetRef={fldngHstryWtlvListRef}
direction={-1}
label="이전 위선"
>
‹
</FldngHstryScrollButton>
<div
ref={fldngHstryWtlvListRef}
className="flex min-w-0 flex-1 gap-2 overflow-x-auto whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
>
{fldngHstryWtlvRows.map((row, index) => {
const waterLevelFid = String(row.fid ?? "").trim();
const disasterName = String(
row.fldng_dstr_nm ?? row.fldngDstrNm ?? "",
).trim();
return (
<button
key={`${waterLevelFid || "wtlv"}-${index}`}
type="button"
onClick={async () => {
if (!waterLevelFid) return;
setSelectedFldngHstryWtlvFid(waterLevelFid);
setSelectedFldngHstryDepthFid(null);
await showFldngHstryFeature(
fldngHstryFeatureCacheRef.current,
GisApp.Module.layer as any,
FLDNG_HSTRY_LAYER_TYPE,
`${FLDNG_HSTRY_LAYER_TYPE}.${waterLevelFid.replace(new RegExp(`^${FLDNG_HSTRY_LAYER_TYPE}\\.`), "")}`,
);
}}
className={`shrink-0 rounded border px-3 py-1 text-[1.2rem] font-medium leading-5 ${selectedFldngHstryWtlvFid === waterLevelFid ? "border-blue-600 bg-white text-blue-700" : "border-border bg-muted text-muted-foreground"}`}
>
{disasterName || "재해명 없음"}
</button>
);
})}
</div>
<FldngHstryScrollButton
targetRef={fldngHstryWtlvListRef}
direction={1}
label="다음 위선"
>
›
</FldngHstryScrollButton>
</div>
)}
</div>
),
[
fldngHstryDepthRows,
fldngHstryWtlvRows,
selectedFldngHstryDepthFid,
selectedFldngHstryWtlvFid,
],
);
return {
hasFldngHstryLinkedFeatures:
fldngHstryDepthRows.length > 0 || fldngHstryWtlvRows.length > 0,
fldngHstryFeatureClickHeadName: headName,
loadFldngHstryFeatureClickDetail,
resetFldngHstryFeatureClick,
};
};
@@ -0,0 +1,96 @@
/**
* 화면명: 침수흔적도 레이어 설정
* 침수흔적위선·심선의 표시 여부와 조회 연도 범위를 설정한다.
*/
"use client";
import { SelectBox } from "@common/business_components/ui";
import {
FLDNG_HSTRY_MIN_YEAR,
type FldngHstryYearRange,
} from "./fldngHstryTypes";
import { normalizeFldngHstryYearRange } from "./fldngHstryUtils";
import LayerSettingCard from "../layerSetting/LayerSettingCard";
type Props = {
yearRange: FldngHstryYearRange;
enabled: boolean;
onYearRangeChange: (yearRange: FldngHstryYearRange) => void;
onEnabledChange: (enabled: boolean) => void;
onClose: () => void;
};
/** 침수흔적위선·심선에 공통 적용되는 즉시 반영 침수년도 설정 카드다. */
export default function FldngHstryLayerSettingPanel({
yearRange,
enabled,
onYearRangeChange,
onEnabledChange,
onClose,
}: Props) {
const normalizedRange = normalizeFldngHstryYearRange(yearRange);
const currentYear = new Date().getFullYear();
const yearOptions = Array.from(
{ length: currentYear - FLDNG_HSTRY_MIN_YEAR + 1 },
(_, index) => String(currentYear - index),
);
const startYearOptions = yearOptions
.filter((year) => Number(year) <= Number(normalizedRange.endYear))
.map((year) => ({ val: year, text: year }));
const endYearOptions = yearOptions
.filter((year) => Number(year) >= Number(normalizedRange.startYear))
.map((year) => ({ val: year, text: year }));
const yearSelectClass =
"!h-[26px] w-full !rounded border border-input bg-background px-2 py-0 text-center text-[12px] leading-none text-foreground";
const changeYear = (key: keyof FldngHstryYearRange, value: string) => {
onYearRangeChange(
normalizeFldngHstryYearRange({
...normalizedRange,
[key]: value,
}),
);
};
return (
<LayerSettingCard
title="침수흔적도"
enabled={enabled}
onEnabledChange={onEnabledChange}
onClose={onClose}
>
<div className="flex flex-col gap-1.5 font-medium text-label">
<span className="shrink-0">침수년도</span>
<div className="flex w-full items-center gap-1.5">
<div className="min-w-0 flex-1">
<SelectBox
className="w-full"
selectClass={yearSelectClass}
label=""
selectArray={startYearOptions}
value={normalizedRange.startYear}
onSelectValue={(value) => changeYear("startYear", String(value))}
selectContentClass="z-[101]"
labelClass="hidden"
/>
</div>
<span className="px-[1px] text-[0.95rem] font-normal text-muted-foreground">
~
</span>
<div className="min-w-0 flex-1">
<SelectBox
className="w-full"
selectClass={yearSelectClass}
label=""
selectArray={endYearOptions}
value={normalizedRange.endYear}
onSelectValue={(value) => changeYear("endYear", String(value))}
selectContentClass="z-[101]"
labelClass="hidden"
/>
</div>
</div>
</div>
</LayerSettingCard>
);
}
@@ -0,0 +1,473 @@
/**
* 화면명: 침수발생이력 목록
* 침수발생이력을 검색하고 엑셀 다운로드 및 지도 위치 확인 기능을 제공한다.
*/
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Input } from "@common/components";
import { StandardPopupLayout } from "@common/business_components";
import { getRawJson } from "@/components/lib/api";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import { useAlertStore } from "@/shared/lib";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
import useStore from "@/store/useStore";
import LoadingOverlay from "@/components/ui/LoadingBar";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import {
FLDNG_HSTRY_LAYER_TYPE,
FLDNG_HSTRY_DEFAULT_FILTERS,
FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE,
FLDNG_HSTRY_DOWNLOAD_HEADERS,
FLDNG_HSTRY_LIST_HEADLIST,
FLDNG_HSTRY_PAGE_SIZE,
FLDNG_HSTRY_ROW_SELECT_MESSAGE,
type FldngHstryApiItem,
type FldngHstryFilters,
type FldngHstryTableRow,
} from "./fldngHstryTypes";
import {
attachFldngHstryGeometry,
buildFldngHstryListQueryString,
buildFldngHstryYearOptions,
normalizeFldngHstryFilters,
toFldngHstryTableRows,
} from "./fldngHstryUtils";
const unwrapPayload = <T,>(response: any): T =>
(response?.data ?? response) as T;
const resolveOptionText = (
code: string,
options: Array<{ text: string; val: string | number }>,
prefixLength?: number,
) => {
const normalized = String(code ?? "").trim();
if (!normalized || normalized === "0") return "";
const lookup = prefixLength ? normalized.slice(0, prefixLength) : normalized;
return options.find((item) => String(item.val).trim() === lookup)?.text ?? "";
};
const resolveFldngHstryMapFeatureFid = (row: FldngHstryTableRow) => {
const fid = String(row.fid ?? "").trim();
if (fid.startsWith(`${FLDNG_HSTRY_LAYER_TYPE}.`)) return fid;
if (fid) return `${FLDNG_HSTRY_LAYER_TYPE}.${fid}`;
return "";
};
const buildDownloadRows = (rows: FldngHstryTableRow[]) =>
rows.map((row) => ({
rowNo: row.rowNo,
floodYearDisplay: row.floodYearDisplay,
ctpvNm: row.ctpvNm,
sggNm: row.sggNm,
disasterName: row.disasterName,
rainBgngTmDisplay: row.rainBgngTmDisplay,
rainEndTmDisplay: row.rainEndTmDisplay,
rainTotDisplay: row.rainTotDisplay,
rainStrDisplay: row.rainStrDisplay,
floodHourDisplay: row.floodHourDisplay,
districtName: row.districtName,
causeDetailCode: row.causeDetailCode,
causeDetailName: row.causeDetailName,
avgFloodDepthDisplay: row.avgFloodDepthDisplay,
floodAreaDisplay: row.floodAreaDisplay,
exmnYearDisplay: row.exmnYearDisplay,
damageAmountDisplay: row.damageAmountDisplay,
}));
const resolveRegionNames = (
row: FldngHstryTableRow,
ctpvOptions: Array<{ text: string; val: string | number }>,
sggOptions: Array<{ text: string; val: string | number }>,
): FldngHstryTableRow => ({
...row,
ctpvNm:
resolveOptionText(row.ctpvCd ?? "", ctpvOptions, 2) ||
row.ctpvNm ||
String((row as any).ctpv_nm ?? row.ctpvCd ?? "").trim(),
sggNm:
resolveOptionText(row.sggCd ?? "", sggOptions, 5) ||
row.sggNm ||
String((row as any).sgg_nm ?? row.sggCd ?? "").trim(),
});
export default function FldngHstryPopupPage() {
const alert = useAlertStore((state) => state.setMessage);
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const [filters, setFilters] = useState<FldngHstryFilters>(
FLDNG_HSTRY_DEFAULT_FILTERS,
);
const [appliedFilters, setAppliedFilters] = useState<FldngHstryFilters>(
FLDNG_HSTRY_DEFAULT_FILTERS,
);
const [rows, setRows] = useState<FldngHstryTableRow[]>([]);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (ctpv.length === 0) void fetchCtpv();
if (sgg.length === 0) void fetchSgg();
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
useEffect(() => {
if (rows.length === 0) return;
if (ctpv.length === 0 && sgg.length === 0) return;
setRows((current) =>
current.map((row) => resolveRegionNames(row, ctpv as any, sgg as any)),
);
}, [ctpv, rows.length, sgg]);
const fldngYearOptions = useMemo(() => buildFldngHstryYearOptions(), []);
const sggOptions = useMemo(
() =>
filters.ctpvCd === "0"
? []
: sgg.filter((item) =>
String(item.val).startsWith(filters.ctpvCd.slice(0, 2)),
),
[filters.ctpvCd, sgg],
);
const fetchRows = useCallback(
async (page: number, nextFilters: FldngHstryFilters, size: number) => {
const normalized = normalizeFldngHstryFilters(nextFilters);
const queryString = buildFldngHstryListQueryString(
normalized,
page,
size,
);
const response = await getRawJson(
`/sewerage-service/api/v1/fldng/hstry?${queryString}`,
);
const payload = unwrapPayload<{
items?: FldngHstryApiItem[];
total?: number;
}>(response);
const items = Array.isArray(payload?.items) ? payload.items : [];
const displayItems = items.map((item) => {
const hydrated = attachFldngHstryGeometry(item);
return {
...hydrated,
ctpvNm:
resolveOptionText(hydrated.ctpvCd ?? "", ctpv as any, 2) ||
String(
(hydrated as any).ctpvNm ?? (hydrated as any).ctpv_nm ?? "",
).trim() ||
String(hydrated.ctpvCd ?? "").trim(),
sggNm:
resolveOptionText(hydrated.sggCd ?? "", sgg as any, 5) ||
String(
(hydrated as any).sggNm ?? (hydrated as any).sgg_nm ?? "",
).trim() ||
String(hydrated.sggCd ?? "").trim(),
};
});
return {
rows: toFldngHstryTableRows(displayItems, page, size),
total: Number(payload?.total ?? 0),
};
},
[ctpv, sgg],
);
const loadRows = useCallback(
async (page: number, nextFilters: FldngHstryFilters) => {
setIsLoading(true);
try {
const normalized = normalizeFldngHstryFilters(nextFilters);
const response = await fetchRows(
page,
normalized,
FLDNG_HSTRY_PAGE_SIZE,
);
setRows(response.rows);
setTotal(response.total);
setCurrentPage(page);
setAppliedFilters(normalized);
} catch (error) {
console.error("침수발생이력 목록 조회 중 오류:", error);
setRows([]);
setTotal(0);
alert(
error instanceof Error
? error.message
: "침수발생이력 목록 조회 중 오류가 발생했습니다.",
);
} finally {
setIsLoading(false);
}
},
[alert, fetchRows],
);
useEffect(() => {
void loadRows(1, FLDNG_HSTRY_DEFAULT_FILTERS);
// 최초 로딩은 한 번만 수행한다.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSearch = async (page = 1) => {
const normalized = normalizeFldngHstryFilters(filters);
if (normalized.sggCd !== "0" && normalized.ctpvCd === "0") {
alert("시도를 먼저 선택하세요.");
return;
}
setFilters(normalized);
await loadRows(page, normalized);
};
const handlePageChange = async (page: number) => {
await loadRows(page, appliedFilters);
};
const handleRowClick = (row: FldngHstryTableRow) => {
const mapFeatureFid = resolveFldngHstryMapFeatureFid(row);
if (mapFeatureFid) {
window.opener?.postMessage(
{
type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SELECT_FEATURE,
payload: {
layerNm: FLDNG_HSTRY_LAYER_TYPE,
fid: mapFeatureFid,
},
},
window.location.origin,
);
return;
}
window.opener?.postMessage(
{
type: FLDNG_HSTRY_ROW_SELECT_MESSAGE,
row,
openDetail: false,
},
window.location.origin,
);
};
const handleDownload = async () => {
if (total === 0) {
alert("다운로드할 침수발생이력 데이터가 없습니다.");
return;
}
setIsLoading(true);
try {
const response = await fetchAllPages({
batchSize: FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE,
fetchPage: (page, size) => fetchRows(page + 1, appliedFilters, size),
getItems: (page) => page.rows,
getTotal: (page) => page.total,
});
await downloadExcelWithKoreanHeaders({
data: buildDownloadRows(response.items) as unknown as JSON[],
fileName: `침수발생이력_${new Date().toISOString().slice(0, 10)}.xlsx`,
sheetNameArray: ["침수발생이력"],
headerRange: "A1:Q1",
headerMapping: FLDNG_HSTRY_DOWNLOAD_HEADERS,
});
} catch (error) {
console.error("침수발생이력 다운로드 중 오류:", error);
alert(
error instanceof Error
? error.message
: "침수발생이력 다운로드 중 오류가 발생했습니다.",
);
} finally {
setIsLoading(false);
}
};
return (
<StandardPopupLayout title="침수발생이력 목록">
<LoadingOverlay isLoading={isLoading} />
<PopupSearchPanel contentClassName="flex flex-wrap items-end gap-x-5 gap-y-3">
<label className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] text-label">
시도
</span>
<select
className="h-[3.2rem] w-[12rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
value={filters.ctpvCd}
onChange={(event) =>
setFilters((current) => ({
...current,
ctpvCd: event.target.value,
sggCd: "0",
}))
}
>
<option value="0">전체</option>
{ctpv.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] text-label">
시군구
</span>
<select
className="h-[3.2rem] w-[16rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
value={filters.sggCd}
onChange={(event) =>
setFilters((current) => ({
...current,
sggCd: event.target.value,
}))
}
>
<option value="0">전체</option>
{sggOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] text-label">
침수발생년도
</span>
<select
className="h-[3.2rem] w-[8rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
value={filters.floodYear}
onChange={(event) =>
setFilters((current) => ({
...current,
floodYear: event.target.value,
}))
}
>
<option value="">전체</option>
{fldngYearOptions.map((year) => (
<option key={year} value={year}>
{year}년
</option>
))}
</select>
</label>
<label className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] text-label">
강우시작일
</span>
<Input
className="w-[11rem]"
type="date"
value={filters.rainStartDate}
min={filters.floodYear ? `${filters.floodYear}-01-01` : undefined}
max={filters.floodYear ? `${filters.floodYear}-12-31` : undefined}
disabled={!filters.floodYear}
onChange={(event) =>
setFilters((current) => ({
...current,
rainStartDate: event.target.value,
}))
}
/>
</label>
<label className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] text-label">
침수지구명
</span>
<Input
type="text"
value={filters.districtName}
onChange={(event) =>
setFilters((current) => ({
...current,
districtName: event.target.value,
}))
}
onKeyDown={(event) => {
if (event.key === "Enter") void handleSearch(1);
}}
/>
</label>
<label className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] text-label">
침수재해명
</span>
<Input
className="w-[20rem]"
type="text"
autoComplete="off"
value={filters.disasterName}
onChange={(event) =>
setFilters((current) => ({
...current,
disasterName: event.target.value,
}))
}
onKeyDown={(event) => {
if (event.key === "Enter") void handleSearch(1);
}}
/>
</label>
<div className="ml-auto flex items-center gap-2">
<Button
type="button"
color="primary"
onClick={() => void handleSearch(1)}
>
조회
</Button>
<Button
type="button"
color="white"
onClick={() => {
setFilters(FLDNG_HSTRY_DEFAULT_FILTERS);
void loadRows(1, FLDNG_HSTRY_DEFAULT_FILTERS);
}}
>
초기화
</Button>
</div>
</PopupSearchPanel>
<MapPopupDataTable
rows={rows as Array<FldngHstryTableRow & Record<string, unknown>>}
headlist={FLDNG_HSTRY_LIST_HEADLIST}
title="침수발생이력 목록"
total={total}
page={currentPage}
pageSize={FLDNG_HSTRY_PAGE_SIZE}
onPageChange={(page) => void handlePageChange(page)}
onRowClick={handleRowClick}
rowId={(row, index) => String(row.fid ?? index)}
scrollMode="horizontal"
minTableWidth="140rem"
actions={
<Button type="button" onClick={() => void handleDownload()}>
엑셀다운로드
</Button>
}
/>
<div className="rounded-sm bg-secondary px-4 py-3 text-[1.2rem] text-muted-foreground">
행을 클릭하면 메인 지도에서 해당 침수흔적을 표시합니다.
</div>
</StandardPopupLayout>
);
}
@@ -0,0 +1,61 @@
/** 침수흔적 강우분석과 연결된 심선·위선 목록 조회를 담당한다. */
import { getData } from "@/components/lib/api";
import type { FldngHstryDetailCacheValue } from "./fldngHstryTypes";
const normalizeFldngHstryRainAnalysisPayload = (data: any) => {
if (!data || typeof data !== "object") return {};
return {
rain_bgng_tm: data.rainBgngTm ?? data.rain_bgng_tm ?? null,
rainBgngTm: data.rainBgngTm ?? data.rain_bgng_tm ?? null,
rain_end_tm: data.rainEndTm ?? data.rain_end_tm ?? null,
rainEndTm: data.rainEndTm ?? data.rain_end_tm ?? null,
rain_tot: data.rainTot ?? data.rain_tot ?? null,
rainTot: data.rainTot ?? data.rain_tot ?? null,
rain_str: data.rainStr ?? data.rain_str ?? null,
rainStr: data.rainStr ?? data.rain_str ?? null,
};
};
export const loadFldngHstryDetail = async (
fldngHstryFid: string,
): Promise<FldngHstryDetailCacheValue> => {
const [rainResult, depthResult] = await Promise.allSettled([
getData(`v1/fldng/hstry/rainAnls/${encodeURIComponent(fldngHstryFid)}`),
getData(`v1/fldng/hstry/depthMap/${encodeURIComponent(fldngHstryFid)}`),
]);
if (rainResult.status === "rejected") {
console.warn("침수강우분석 조회 실패:", rainResult.reason);
}
if (depthResult.status === "rejected") {
console.warn("침수깊이지도 조회 실패:", depthResult.reason);
}
const depthPayload =
depthResult.status === "fulfilled"
? (depthResult.value?.data ?? depthResult.value)
: null;
const depthItems = Array.isArray(depthPayload)
? depthPayload
: depthPayload?.depthMaps;
const waterLevelItems = Array.isArray(depthPayload?.waterLevelMaps)
? depthPayload.waterLevelMaps
: [];
return {
rainAnalysis:
rainResult.status === "fulfilled"
? normalizeFldngHstryRainAnalysisPayload(rainResult.value)
: {},
fldngHstryDepthRows: Array.isArray(depthItems)
? depthItems.filter((row) => row && typeof row === "object")
: [],
fldngHstryWtlvRows: waterLevelItems.filter(
(row: unknown) => row && typeof row === "object",
),
complete:
rainResult.status === "fulfilled" && depthResult.status === "fulfilled",
};
};
@@ -0,0 +1,164 @@
/** 침수흔적 화면에서 사용하는 타입·상수와 화면 설정값을 정의한다. */
import type { UiHeadProps } from "@common/business_components/ui";
export const FLDNG_HSTRY_POPUP_KEY = "fldngHstryPopup";
export const FLDNG_HSTRY_ROW_SELECT_MESSAGE = "SN_FLOOD_HISTORY_ROW_SELECT";
export const FLDNG_HSTRY_LAYER_TYPE = "tb_ms_fldng_wtlv_map";
export const FLDNG_HSTRY_DEPTH_LAYER_TYPE = "tb_ms_fldng_dpth_map";
export const FLDNG_HSTRY_PREVIEW_LAYER_TYPE = "tb_sn_flood_trace_mock";
export const FLDNG_HSTRY_LAYER_NAME = "침수흔적위선";
export const FLDNG_HSTRY_DEPTH_LAYER_NAME = "침수흔적심선";
export const FLDNG_HSTRY_ROOT_NAME = "침수흔적도";
export const FLDNG_HSTRY_AREA_LAYER_ID = "SN_FLOOD_HISTORY_AREA";
export const FLDNG_HSTRY_DEPTH_LAYER_ID = "SN_FLOOD_HISTORY_DEPTH";
export const FLDNG_HSTRY_PAGE_SIZE = 10;
export const FLDNG_HSTRY_DOWNLOAD_BATCH_SIZE = 2000;
export const FLDNG_HSTRY_MIN_YEAR = 2002;
export type FldngHstryDetailCacheValue = {
rainAnalysis: Record<string, unknown>;
fldngHstryDepthRows: Record<string, unknown>[];
fldngHstryWtlvRows: Record<string, unknown>[];
complete: boolean;
};
export type FldngHstryYearRange = { startYear: string; endYear: string };
export type FldngHstryFilters = {
ctpvCd: string;
sggCd: string;
floodYear: string;
disasterName: string;
rainStartDate: string;
districtName: string;
};
export type FldngHstryDepthZone = {
zoneId: string;
depthLabel: string;
color: string;
ring: [number, number][];
};
export type FldngHstryApiItem = {
fid: string;
fldngMapFid?: string;
fldngWtlvMapSn?: number | null;
taskClCd?: string;
fldngDstrNm?: string;
exmnYr?: string;
avgFldngWtlv?: number | null;
fldngArea?: number | null;
dtlOcrnCsCn?: string;
stdgCd?: string;
ctpvCd?: string;
sggCd?: string;
dataSn?: number | null;
bgngYmd?: string;
endYmd?: string;
fldngBgngHm?: string;
fldngEndHm?: string;
fldngCsDtlCd?: string;
fldngDstrctNm?: string;
damAmt?: number | null;
fldngHr?: number | null;
fldngYr?: string;
cstrnFbctnYn?: string;
atchFileSn?: number | null;
confArea?: number | null;
confLen?: number | null;
rainBgngTm?: string | null;
rainEndTm?: string | null;
rainTot?: number | null;
rainStr?: number | null;
};
export type FldngHstryItem = FldngHstryApiItem & {
historyId: string;
ctpvNm: string;
sggNm: string;
floodYear: string;
exmnYear: string;
disasterName: string;
districtName: string;
floodAreaHa: number;
avgFloodDepthM: number;
floodDepthM: number;
gradeLabel: string;
startDate: string;
endDate: string;
startTime: string;
endTime: string;
floodHour: string;
causeDetailCode: string;
causeDetailName: string;
damageAmount: string;
rainStartDateTime: string;
rainEndDateTime: string;
rainStartTime: string;
rainEndTime: string;
totalRainfallMm: number;
rainfallIntensityMmHr: number;
areaRing: [number, number][];
depthZones: FldngHstryDepthZone[];
};
export type FldngHstryTableRow = FldngHstryItem & {
rowNo: number;
floodYearDisplay: string;
floodHourDisplay: string;
avgFloodDepthDisplay: string;
floodAreaDisplay: string;
exmnYearDisplay: string;
damageAmountDisplay: string;
rainBgngTmDisplay: string;
rainEndTmDisplay: string;
rainTotDisplay: string;
rainStrDisplay: string;
};
export const FLDNG_HSTRY_DEFAULT_FILTERS: FldngHstryFilters = {
ctpvCd: "0",
sggCd: "0",
floodYear: "",
disasterName: "",
rainStartDate: "",
districtName: "",
};
export const FLDNG_HSTRY_DEPTH_LEGEND_ITEMS = [
{ color: "#fff27a", label: "0.0~0.5m" },
{ color: "#c2f13d", label: "0.5~1.0m" },
{ color: "#67d8ff", label: "1.0~2.0m" },
{ color: "#8f8bff", label: "2.0~5.0m" },
{ color: "#f2528b", label: "5.0m 이상" },
] as const;
export const FLDNG_HSTRY_LIST_HEADLIST: UiHeadProps[] = [
{ id: "rowNo", title: "순번" },
{ id: "floodYearDisplay", title: "침수\n년도" },
{ id: "ctpvNm", title: "시도" },
{ id: "sggNm", title: "시군구" },
{ id: "disasterName", title: "침수재해명" },
{ id: "rainBgngTmDisplay", title: "강우\n시작일시" },
{ id: "rainEndTmDisplay", title: "강우\n종료일시" },
{ id: "rainTotDisplay", title: "총강우량(mm)" },
{ id: "rainStrDisplay", title: "강우강도(mm/hr)" },
{ id: "floodHourDisplay", title: "침수시간" },
{ id: "districtName", title: "침수지구명" },
{ id: "avgFloodDepthDisplay", title: "평균\n침수위" },
{ id: "floodAreaDisplay", title: "침수\n면적(ha)" },
{ id: "exmnYearDisplay", title: "조사년도" },
{ id: "damageAmountDisplay", title: "피해금액" },
];
export const FLDNG_HSTRY_DOWNLOAD_HEADERS: Record<string, string> = {
rowNo: "순번",
floodYearDisplay: "침수년도",
ctpvNm: "시도",
sggNm: "시군구",
disasterName: "침수재해명",
rainBgngTmDisplay: "강우시작일시",
rainEndTmDisplay: "강우종료일시",
rainTotDisplay: "총강우량(mm)",
rainStrDisplay: "강우강도(mm/hr)",
floodHourDisplay: "침수시간",
districtName: "침수지구명",
causeDetailCode: "침수원인상세코드",
causeDetailName: "침수원인상세",
avgFloodDepthDisplay: "평균침수위",
floodAreaDisplay: "침수면적",
exmnYearDisplay: "조사년도",
damageAmountDisplay: "피해금액",
};
@@ -0,0 +1,440 @@
/** 침수흔적 검색조건·표시값·geometry·레이어 판별 변환을 제공한다. */
import { appendApiSearchParams } from "@/shared/util/util";
import {
FLDNG_HSTRY_PAGE_SIZE,
FLDNG_HSTRY_DEPTH_LAYER_NAME,
FLDNG_HSTRY_DEPTH_LAYER_TYPE,
FLDNG_HSTRY_LAYER_NAME,
FLDNG_HSTRY_LAYER_TYPE,
FLDNG_HSTRY_MIN_YEAR,
FLDNG_HSTRY_PREVIEW_LAYER_TYPE,
FLDNG_HSTRY_ROOT_NAME,
type FldngHstryApiItem,
type FldngHstryDepthZone,
type FldngHstryFilters,
type FldngHstryItem,
type FldngHstryTableRow,
type FldngHstryYearRange,
} from "./fldngHstryTypes";
export const normalizeFldngHstryWtlvDisplayId = (value: unknown) => {
const normalized = String(value ?? "").trim();
const prefix = `${FLDNG_HSTRY_LAYER_TYPE}.`;
return normalized.startsWith(prefix)
? normalized.slice(prefix.length)
: normalized;
};
export const normalizeFldngHstryWtlvApiFid = (value: unknown) => {
const normalized = String(value ?? "").trim();
return normalized ? normalizeFldngHstryWtlvDisplayId(normalized) : "";
};
export const formatFldngHstryDateTime = (
dateValue: unknown,
timeValue: unknown,
) => {
const rawDate = String(dateValue ?? "").trim();
const rawTime = String(timeValue ?? "").trim();
const normalizedDate = /^\d{8}$/.test(rawDate)
? `${rawDate.slice(0, 4)}-${rawDate.slice(4, 6)}-${rawDate.slice(6, 8)}`
: rawDate;
const timeDigits = rawTime.replace(/[^0-9]/g, "");
const normalizedTime = !timeDigits
? ""
: timeDigits.length <= 2
? `${timeDigits.padStart(2, "0")}:00`
: `${timeDigits.padStart(4, "0").slice(0, 2)}:${timeDigits.padStart(4, "0").slice(2, 4)}`;
return [normalizedDate, normalizedTime].filter(Boolean).join(" ");
};
export const buildFldngHstryDefaultYearRange = (
currentYear = new Date().getFullYear(),
): FldngHstryYearRange => ({
startYear: String(FLDNG_HSTRY_MIN_YEAR),
endYear: String(currentYear),
});
export const normalizeFldngHstryYearRange = (
range?: Partial<FldngHstryYearRange> | null,
fallback: FldngHstryYearRange = buildFldngHstryDefaultYearRange(),
): FldngHstryYearRange => {
const normalizeYear = (value: unknown) =>
String(value ?? "")
.replace(/[^0-9]/g, "")
.slice(0, 4);
const startYear = normalizeYear(range?.startYear);
const endYear = normalizeYear(range?.endYear);
return {
startYear: /^\d{4}$/.test(startYear) ? startYear : fallback.startYear,
endYear: /^\d{4}$/.test(endYear) ? endYear : fallback.endYear,
};
};
export const buildFldngHstryYearCql = (
range: FldngHstryYearRange,
enabled: boolean,
) => {
if (!enabled) return "";
const normalized = normalizeFldngHstryYearRange(range);
return `(fldng_yr >= '${normalized.startYear}' AND fldng_yr <= '${normalized.endYear}')`;
};
const toRadian = (degree: number) => (degree * Math.PI) / 180;
const createIrregularRing = ({
centerX,
centerY,
radiusX,
radiusY,
scales,
rotationDeg = 0,
}: {
centerX: number;
centerY: number;
radiusX: number;
radiusY: number;
scales: number[];
rotationDeg?: number;
}): [number, number][] => {
const rotation = toRadian(rotationDeg);
const points = scales.map((scale, index) => {
const angle = rotation + (Math.PI * 2 * index) / scales.length;
return [
centerX + radiusX * scale * Math.cos(angle),
centerY + radiusY * scale * Math.sin(angle),
] as [number, number];
});
return points.length > 0 ? [...points, points[0]!] : [];
};
const toNumericSeed = (value: string) =>
Array.from(value).reduce(
(sum, char, index) => sum + char.charCodeAt(0) * (index + 1),
0,
);
const buildGeometrySeed = (fid: string) => {
const seed = toNumericSeed(fid);
return {
centerX: 1050000 + (seed % 120000),
centerY: 1680000 + (seed % 150000),
width: 14000 + (seed % 9000),
height: 11000 + (seed % 7000),
};
};
const createFldngHstryDepthZones = (
historyId: string,
centerX: number,
centerY: number,
): FldngHstryDepthZone[] => [
{
zoneId: `${historyId}-depth-1`,
depthLabel: "0.0~0.5m",
color: "#fff27a",
ring: createIrregularRing({
centerX: centerX - 2600,
centerY: centerY - 1200,
radiusX: 2900,
radiusY: 2300,
rotationDeg: -18,
scales: [1.02, 0.78, 0.9, 1.08, 0.86, 0.72, 0.94, 1.04],
}),
},
{
zoneId: `${historyId}-depth-2`,
depthLabel: "0.5~1.0m",
color: "#c2f13d",
ring: createIrregularRing({
centerX: centerX + 1800,
centerY: centerY - 600,
radiusX: 3500,
radiusY: 2800,
rotationDeg: 12,
scales: [1.04, 0.88, 1.02, 0.8, 0.92, 1.08, 0.84, 0.95],
}),
},
{
zoneId: `${historyId}-depth-3`,
depthLabel: "1.0~2.0m",
color: "#67d8ff",
ring: createIrregularRing({
centerX,
centerY: centerY + 1800,
radiusX: 4700,
radiusY: 3900,
rotationDeg: 8,
scales: [1.12, 0.86, 0.94, 1.06, 0.82, 0.97, 1.04, 0.9, 1.08, 0.84],
}),
},
{
zoneId: `${historyId}-depth-4`,
depthLabel: "2.0~5.0m",
color: "#8f8bff",
ring: createIrregularRing({
centerX: centerX + 3200,
centerY: centerY + 3200,
radiusX: 2600,
radiusY: 2000,
rotationDeg: 26,
scales: [1.05, 0.82, 1.08, 0.88, 0.96, 0.78, 1.02, 0.9],
}),
},
{
zoneId: `${historyId}-depth-5`,
depthLabel: "5.0m 이상",
color: "#f2528b",
ring: createIrregularRing({
centerX: centerX + 400,
centerY: centerY - 3600,
radiusX: 1200,
radiusY: 1050,
rotationDeg: -10,
scales: [1, 0.8, 1.06, 0.84, 0.94, 0.76, 0.98],
}),
},
];
const normalizeDate = (value?: string | null) => {
const source = String(value ?? "").trim();
return /^\d{8}$/.test(source)
? `${source.slice(0, 4)}-${source.slice(4, 6)}-${source.slice(6, 8)}`
: "";
};
const normalizeHm = (value?: string | null) => {
const digits = String(value ?? "").replace(/[^0-9]/g, "");
if (!digits) return "";
if (digits.length === 1) return `0${digits}:00`;
if (digits.length === 2) return `${digits}:00`;
if (digits.length === 3)
return `0${digits.slice(0, 1)}:${digits.slice(1, 3)}`;
return `${digits.slice(0, 2)}:${digits.slice(2, 4)}`;
};
const normalizeDateTime = (value?: string | null) => {
const source = String(value ?? "").trim();
if (!source) return "";
if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(source)) return source;
const date = new Date(source);
if (Number.isNaN(date.getTime()))
return source.slice(0, 16).replace("T", " ");
const yyyy = date.getFullYear();
const mm = `${date.getMonth() + 1}`.padStart(2, "0");
const dd = `${date.getDate()}`.padStart(2, "0");
const hh = `${date.getHours()}`.padStart(2, "0");
const mi = `${date.getMinutes()}`.padStart(2, "0");
return `${yyyy}-${mm}-${dd} ${hh}:${mi}`;
};
const formatNumber = (value?: number | null, digits = 1, suffix = "") => {
const numeric = Number(value ?? 0);
return Number.isFinite(numeric)
? `${numeric.toFixed(digits)}${suffix}`
: `0${suffix}`;
};
const formatInteger = (value?: number | null, suffix = "") => {
const numeric = Number(value ?? 0);
return Number.isFinite(numeric)
? `${numeric.toLocaleString("ko-KR")}${suffix}`
: `0${suffix}`;
};
const formatOptionalNumber = (
value?: number | null,
digits = 1,
suffix = "",
) => {
if (value === null || value === undefined) return "-";
const numeric = Number(value);
return Number.isFinite(numeric) ? `${numeric.toFixed(digits)}${suffix}` : "-";
};
const buildCauseDetailName = (item: FldngHstryApiItem) => {
const direct = String(item.dtlOcrnCsCn ?? "").trim();
if (direct) return direct;
const code = String(item.fldngCsDtlCd ?? "").trim();
return code ? `원인코드 ${code}` : "-";
};
const buildGradeLabel = (avgFloodDepthM: number) => {
if (avgFloodDepthM >= 2) return "3등급";
if (avgFloodDepthM >= 1) return "2등급";
return "1등급";
};
export const normalizeFldngHstryFilters = (
filters: FldngHstryFilters,
): FldngHstryFilters => ({
ctpvCd: String(filters.ctpvCd ?? "0") || "0",
sggCd: String(filters.sggCd ?? "0") || "0",
floodYear: String(filters.floodYear ?? "")
.replace(/[^0-9]/g, "")
.slice(0, 4),
disasterName: String(filters.disasterName ?? "").trim(),
rainStartDate: String(filters.rainStartDate ?? "").trim(),
districtName: String(filters.districtName ?? "").trim(),
});
export const buildFldngHstryYearOptions = () => {
const currentYear = new Date().getFullYear();
return Array.from({ length: 30 }, (_, index) => String(currentYear - index));
};
export const buildFldngHstryListQueryString = (
filters: FldngHstryFilters,
page: number,
size: number,
) => {
const normalized = normalizeFldngHstryFilters(filters);
const params = new URLSearchParams({
page: String(page - 1),
size: String(size),
});
appendApiSearchParams(params, {
ctpvCd: normalized.ctpvCd === "0" ? "0" : normalized.ctpvCd.slice(0, 2),
sggCd: normalized.sggCd === "0" ? "0" : normalized.sggCd.slice(0, 5),
fldngYr: normalized.floodYear,
fldngDstrNm: normalized.disasterName,
rainBgngTm: normalized.rainStartDate,
fldngDstrctNm: normalized.districtName,
});
return params.toString();
};
export const formatFldngHstryArea = (value: number) =>
Number(value ?? 0).toLocaleString("ko-KR", {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
});
export const formatFldngHstryDepth = (value: number) => value.toFixed(2);
export const attachFldngHstryGeometry = (
item: FldngHstryApiItem,
): FldngHstryItem => {
const historyId = String(item.fid ?? "");
const geometrySeed = buildGeometrySeed(historyId);
const startDate = normalizeDate(item.bgngYmd);
const endDate = normalizeDate(item.endYmd);
const startTime = normalizeHm(item.fldngBgngHm);
const endTime = normalizeHm(item.fldngEndHm);
const avgFloodDepthM = Number(item.avgFldngWtlv ?? 0);
const floodAreaHa = Number(item.fldngArea ?? 0);
return {
...item,
historyId,
ctpvNm: "",
sggNm: "",
floodYear: String(item.fldngYr ?? "").trim(),
exmnYear: String(item.exmnYr ?? "").trim(),
disasterName: String(item.fldngDstrNm ?? "").trim(),
districtName: String(item.fldngDstrctNm ?? "").trim(),
floodAreaHa,
avgFloodDepthM,
floodDepthM: avgFloodDepthM,
gradeLabel: buildGradeLabel(avgFloodDepthM),
startDate,
endDate,
startTime,
endTime,
floodHour: formatNumber(Number(item.fldngHr ?? 0), 1),
causeDetailCode: String(item.fldngCsDtlCd ?? "").trim(),
causeDetailName: buildCauseDetailName(item),
damageAmount: formatInteger(Number(item.damAmt ?? 0)),
rainStartDateTime: normalizeDateTime(item.rainBgngTm),
rainEndDateTime: normalizeDateTime(item.rainEndTm),
rainStartTime: normalizeDateTime(item.rainBgngTm).slice(11, 16),
rainEndTime: normalizeDateTime(item.rainEndTm).slice(11, 16),
totalRainfallMm: Number(item.rainTot ?? 0),
rainfallIntensityMmHr: Number(item.rainStr ?? 0),
areaRing: createIrregularRing({
centerX: geometrySeed.centerX,
centerY: geometrySeed.centerY,
radiusX: geometrySeed.width / 2,
radiusY: geometrySeed.height / 2,
rotationDeg: 9,
scales: [1.08, 0.9, 1.04, 0.82, 0.98, 1.12, 0.88, 0.96, 1.03, 0.86],
}),
depthZones: createFldngHstryDepthZones(
historyId,
geometrySeed.centerX,
geometrySeed.centerY,
),
};
};
export const toFldngHstryTableRows = (
items: FldngHstryItem[],
page: number,
pageSize = FLDNG_HSTRY_PAGE_SIZE,
): FldngHstryTableRow[] =>
items.map((item, index) => ({
...item,
rowNo: (page - 1) * pageSize + index + 1,
floodYearDisplay: item.floodYear || "-",
floodHourDisplay: item.floodHour || "-",
avgFloodDepthDisplay: formatFldngHstryDepth(item.avgFloodDepthM),
floodAreaDisplay: formatFldngHstryArea(item.floodAreaHa),
exmnYearDisplay: item.exmnYear || "-",
damageAmountDisplay: item.damageAmount || "-",
rainBgngTmDisplay: item.rainStartDateTime || "-",
rainEndTmDisplay: item.rainEndDateTime || "-",
rainTotDisplay: formatOptionalNumber(item.rainTot, 1),
rainStrDisplay: formatOptionalNumber(item.rainStr, 1),
}));
const resolveFldngHstryNodeName = (node: any) =>
String(
node?.text ??
node?.data?.lyrNm ??
node?.data?.name ??
node?.lyrNm ??
node?.name ??
"",
).trim();
const resolveFldngHstryLayerType = (node: any) =>
String(
node?.data?.lyrTyNm ??
node?.data?.lyrTypeNm ??
node?.lyrTyNm ??
node?.lyrTypeNm ??
"",
).trim();
export const isFldngHstryNode = (node: any) => {
const name = resolveFldngHstryNodeName(node);
const layerType = resolveFldngHstryLayerType(node);
return (
name === FLDNG_HSTRY_LAYER_NAME ||
name === FLDNG_HSTRY_DEPTH_LAYER_NAME ||
layerType === FLDNG_HSTRY_LAYER_TYPE ||
layerType === FLDNG_HSTRY_DEPTH_LAYER_TYPE ||
layerType === FLDNG_HSTRY_PREVIEW_LAYER_TYPE
);
};
export const isFldngHstryWtlvNode = (node: any) => {
const name = resolveFldngHstryNodeName(node);
const layerType = resolveFldngHstryLayerType(node);
return (
name === FLDNG_HSTRY_LAYER_NAME || layerType === FLDNG_HSTRY_LAYER_TYPE
);
};
export const isFldngHstryDepthNode = (node: any) => {
const name = resolveFldngHstryNodeName(node);
const layerType = resolveFldngHstryLayerType(node);
return (
name === FLDNG_HSTRY_DEPTH_LAYER_NAME ||
layerType === FLDNG_HSTRY_DEPTH_LAYER_TYPE
);
};
export const isFldngHstryRootNode = (node: any) =>
resolveFldngHstryNodeName(node) === FLDNG_HSTRY_ROOT_NAME;
@@ -0,0 +1,400 @@
/**
* 화면명: 도시침수지도 목록
* 빈도별 도시침수지도를 검색하고 목록·엑셀·지도 보기 기능을 제공한다.
*/
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@common/components";
import { StandardPopupLayout } from "@common/business_components";
import { getData } from "@/components/lib/api";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import useStore from "@/store/useStore";
import {
FLOOD_DNGR_MAP_DEFAULT_FILTERS,
FLOOD_DNGR_MAP_DEPTH_OPTIONS,
FLOOD_DNGR_MAP_FREQUENCY_OPTIONS,
FLOOD_DNGR_MAP_HEADLIST,
FLOOD_DNGR_MAP_PAGE_SIZE,
buildFloodDngrMapListQueryString,
toFloodDngrMapTableRows,
type FloodDngrMapApiItem,
type FloodDngrMapExtent,
type FloodDngrMapFilters,
type FloodDngrMapTableRow,
} from "./floodDngrMapTypes";
const FLOOD_DNGR_MAP_REQUEST_TIMEOUT_MS = 10_000;
const FLOOD_DNGR_MAP_LOADING_WATCHDOG_MS = 12_000;
/** Abort 처리 지연 여부와 무관하게 제한 시간이 지나면 팝업 요청 Promise를 종료한다. */
const getFloodDngrMapJson = async (url: string) => {
let timeoutId: number | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = window.setTimeout(() => {
reject(
new Error(
"도시침수지도 조회 시간이 초과되었습니다. 검색조건을 좁혀 다시 조회해주세요.",
),
);
}, FLOOD_DNGR_MAP_REQUEST_TIMEOUT_MS);
});
try {
return await Promise.race([getData(url), timeoutPromise]);
} finally {
if (timeoutId !== undefined) window.clearTimeout(timeoutId);
}
};
/** 시설물 레이어의 정보제공포털 도시침수지도 피처를 조건별 목록으로 제공한다. */
export default function FloodDngrMapPopupPage() {
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const [filters, setFilters] = useState<FloodDngrMapFilters>(
FLOOD_DNGR_MAP_DEFAULT_FILTERS,
);
const [appliedFilters, setAppliedFilters] = useState<FloodDngrMapFilters>(
FLOOD_DNGR_MAP_DEFAULT_FILTERS,
);
const [items, setItems] = useState<FloodDngrMapApiItem[]>([]);
const [administrativeScopeCodes, setAdministrativeScopeCodes] = useState<
string[] | null
>(null);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
const availableCtpvCodes = useMemo(
() =>
new Set(
(administrativeScopeCodes ?? [])
.map((code) => code.slice(0, 2))
.filter(Boolean),
),
[administrativeScopeCodes],
);
const availableSggCodes = useMemo(
() =>
new Set(
(administrativeScopeCodes ?? [])
.map((code) => code.slice(0, 5))
.filter(Boolean),
),
[administrativeScopeCodes],
);
const ctpvOptions = useMemo(
() =>
administrativeScopeCodes === null
? ctpv
: ctpv.filter((item) =>
availableCtpvCodes.has(String(item.val).trim().slice(0, 2)),
),
[administrativeScopeCodes, availableCtpvCodes, ctpv],
);
const sggOptions = useMemo(() => {
const scopedOptions =
administrativeScopeCodes === null
? sgg
: sgg.filter((item) =>
availableSggCodes.has(String(item.val).trim().slice(0, 5)),
);
return filters.ctpvCd === "0"
? []
: scopedOptions.filter((item) =>
String(item.val).startsWith(filters.ctpvCd.slice(0, 2)),
);
}, [administrativeScopeCodes, availableSggCodes, filters.ctpvCd, sgg]);
const rows = useMemo(
() =>
toFloodDngrMapTableRows(
items,
currentPage - 1,
FLOOD_DNGR_MAP_PAGE_SIZE,
ctpv,
sgg,
total,
),
[ctpv, currentPage, items, sgg, total],
);
useEffect(() => {
if (ctpv.length === 0) void fetchCtpv();
if (sgg.length === 0) void fetchSgg();
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
/** 요청 Promise가 예상과 다르게 정리되지 않아도 로딩 오버레이를 최대 12초 안에 해제한다. */
useEffect(() => {
if (!isLoading) return;
const watchdogId = window.setTimeout(() => {
setIsLoading(false);
setErrorMessage(
(current) =>
current ||
"도시침수지도 조회가 지연되어 로딩을 종료했습니다. 검색조건을 좁혀 다시 조회해주세요.",
);
}, FLOOD_DNGR_MAP_LOADING_WATCHDOG_MS);
return () => window.clearTimeout(watchdogId);
}, [isLoading]);
/** 마지막 적용 조건으로 서버 페이지를 조회한다. */
const loadRows = useCallback(
async (page: number, nextFilters: FloodDngrMapFilters) => {
setIsLoading(true);
setErrorMessage("");
try {
const queryString = buildFloodDngrMapListQueryString(
nextFilters,
page - 1,
FLOOD_DNGR_MAP_PAGE_SIZE,
);
const response = await getFloodDngrMapJson(
`/sewerage-service/api/v1/fldng/floodDngrMap?${queryString}`,
);
const payload = response as {
items?: FloodDngrMapApiItem[];
total?: number;
} | null;
if (!payload || !Array.isArray(payload.items)) {
throw new Error(
"도시침수지도 목록 API 응답 형식이 올바르지 않습니다.",
);
}
setItems(payload.items);
setTotal(Number(payload?.total ?? 0));
setCurrentPage(page);
setAppliedFilters(nextFilters);
} catch (error) {
console.error("도시침수지도 목록 조회 중 오류:", error);
setItems([]);
setTotal(0);
setErrorMessage(
error instanceof Error
? error.message
: "도시침수지도 목록 조회 중 오류가 발생했습니다.",
);
} finally {
setIsLoading(false);
}
},
[],
);
/** 실제 도시침수지도 데이터가 존재하는 행정구역 코드만 검색 선택지로 제한한다. */
const loadAdministrativeScope = useCallback(async () => {
try {
const response = await getFloodDngrMapJson(
"/sewerage-service/api/v1/fldng/floodDngrMap/scope",
);
if (!Array.isArray(response)) {
throw new Error(
"도시침수지도 행정구역 API 응답 형식이 올바르지 않습니다.",
);
}
setAdministrativeScopeCodes(response);
} catch (error) {
console.error("도시침수지도 행정구역 범위 조회 중 오류:", error);
setAdministrativeScopeCodes(null);
}
}, []);
useEffect(() => {
void loadRows(1, FLOOD_DNGR_MAP_DEFAULT_FILTERS);
void loadAdministrativeScope();
}, [loadAdministrativeScope, loadRows]);
const handleSearch = () => {
void loadRows(1, filters);
};
/** 시군구·침수빈도 그룹의 범위와 선택 조건을 부모 지도에 전달한다. */
const handleRowClick = async (row: FloodDngrMapTableRow) => {
if (!row?.sggCd || !row?.sbmrgFrqExpln) return;
setIsLoading(true);
setErrorMessage("");
try {
const params = new URLSearchParams({
sggCd: row.sggCd,
sbmrgFrqExpln: row.sbmrgFrqExpln,
});
const response = await getFloodDngrMapJson(
`/sewerage-service/api/v1/fldng/floodDngrMap/group/extent?${params.toString()}`,
);
const payload = response as FloodDngrMapExtent;
const extent = [
payload?.minX,
payload?.minY,
payload?.maxX,
payload?.maxY,
].map(Number);
if (
extent.length !== 4 ||
extent.some((value) => !Number.isFinite(value))
) {
throw new Error("해당 도시침수지도 피처의 위치 정보가 없습니다.");
}
window.opener?.postMessage(
{
type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_FLOOD_GROUP,
payload: {
extent,
sggCd: row.sggCd,
sbmrgFrqExpln: row.sbmrgFrqExpln,
},
},
window.location.origin,
);
} catch (error) {
console.error("도시침수지도 위치 이동 중 오류:", error);
const message =
error instanceof Error && error.message.includes("404")
? "해당 도시침수지도 데이터에 위치 정보가 없어 이동할 수 없습니다."
: error instanceof Error
? error.message
: "도시침수지도 위치 이동 중 오류가 발생했습니다.";
setErrorMessage(message);
} finally {
setIsLoading(false);
}
};
return (
<StandardPopupLayout title="도시침수지도 목록">
<LoadingOverlay isLoading={isLoading} />
<PopupSearchPanel contentClassName="flex flex-wrap items-center gap-x-5 gap-y-3">
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
시도
</div>
<select
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground"
value={filters.ctpvCd}
onChange={(event) =>
setFilters({
...filters,
ctpvCd: event.target.value,
sggCd: "0",
})
}
>
<option value="0">전체</option>
{ctpvOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
시군구
</div>
<select
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground"
value={filters.sggCd}
onChange={(event) =>
setFilters({ ...filters, sggCd: event.target.value })
}
>
<option value="0">전체</option>
{sggOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
침수심도
</div>
<select
className="h-[3.2rem] w-[18rem] rounded-md border border-input bg-field px-3 text-xl text-foreground"
value={filters.sbmrgDpthlCd}
onChange={(event) =>
setFilters({ ...filters, sbmrgDpthlCd: event.target.value })
}
>
<option value="0">전체</option>
{FLOOD_DNGR_MAP_DEPTH_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
침수빈도
</div>
<select
className="h-[3.2rem] w-[14rem] rounded-md border border-input bg-field px-3 text-xl text-foreground"
value={filters.sbmrgFrqExpln}
onChange={(event) =>
setFilters({ ...filters, sbmrgFrqExpln: event.target.value })
}
>
<option value="0">전체</option>
{FLOOD_DNGR_MAP_FREQUENCY_OPTIONS.map((value) => (
<option key={value} value={value}>
{Number(value)}년
</option>
))}
</select>
</label>
<div className="flex min-w-fit flex-1 justify-end gap-2">
<Button
type="button"
size="default"
color="primary"
onClick={handleSearch}
>
조회
</Button>
<Button
type="button"
size="default"
color="white"
onClick={() => {
setFilters(FLOOD_DNGR_MAP_DEFAULT_FILTERS);
void loadRows(1, FLOOD_DNGR_MAP_DEFAULT_FILTERS);
}}
>
초기화
</Button>
</div>
</PopupSearchPanel>
{errorMessage ? (
<div
role="alert"
className="mt-2 rounded border border-red-200 bg-red-50 px-3 py-2 text-[1rem] text-red-700"
>
{errorMessage}
</div>
) : null}
<MapPopupDataTable
rows={rows as Array<FloodDngrMapTableRow & Record<string, unknown>>}
headlist={FLOOD_DNGR_MAP_HEADLIST}
title="도시침수지도 목록"
total={total}
page={currentPage}
pageSize={FLOOD_DNGR_MAP_PAGE_SIZE}
onPageChange={(page) => void loadRows(page, appliedFilters)}
onRowClick={(row) => void handleRowClick(row)}
rowId={(row, index) => `${row.sggCd}-${row.sbmrgFrqExpln}-${index}`}
/>
</StandardPopupLayout>
);
}
@@ -0,0 +1,185 @@
/** 도시침수지도 화면의 검색 조건, 범례, 컬럼과 응답 변환을 정의한다. */
"use client";
import type { UiHeadProps } from "@common/business_components/ui";
import { appendApiSearchParams } from "@/shared/util/util";
export const FLOOD_DNGR_MAP_POPUP_KEY = "floodDngrMapPopup";
export const FLOOD_DNGR_MAP_LAYER_TYPE = "tb_ms_flood_dngr_map";
export const FLOOD_DNGR_MAP_LAYER_NAME = "도시침수지도(정보제공포털)";
export const FLOOD_DNGR_MAP_PAGE_SIZE = 10;
export type FloodDngrMapFilters = {
ctpvCd: string;
sggCd: string;
sbmrgDpthlCd: string;
sbmrgFrqExpln: string;
};
export type FloodDngrMapApiItem = {
ctpvCd: string;
sggCd: string;
minSbmrgDpthlCd: string;
maxSbmrgDpthlCd: string;
sbmrgFrqExpln: string;
};
export type FloodDngrMapExtent = {
minX: number;
minY: number;
maxX: number;
maxY: number;
};
export type FloodDngrMapTableRow = FloodDngrMapApiItem & {
rowNo: number;
ctpvNm: string;
sggNm: string;
sbmrgDpthlDisplay: string;
sbmrgFrqDisplay: string;
};
type AdministrativeOption = {
val: unknown;
text: unknown;
};
export const FLOOD_DNGR_MAP_DEFAULT_FILTERS: FloodDngrMapFilters = {
ctpvCd: "0",
sggCd: "0",
sbmrgDpthlCd: "0",
sbmrgFrqExpln: "0",
};
export const FLOOD_DNGR_MAP_FREQUENCY_OPTIONS = ["030", "050", "080", "100"];
export const FLOOD_DNGR_MAP_DEPTH_LABELS: Record<string, string> = {
N330: "0.0~0.5m 미만",
N331: "0.5~1.0m 미만",
N332: "1.0~2.0m 미만",
N333: "2.0~5.0m 미만",
N334: "5.0m 이상",
};
export const FLOOD_DNGR_MAP_DEPTH_OPTIONS = Object.entries(
FLOOD_DNGR_MAP_DEPTH_LABELS,
).map(([value, label]) => ({ value, label }));
export const FLOOD_DNGR_MAP_HEADLIST: UiHeadProps[] = [
{ id: "rowNo", title: "순번", widthPercent: 8 },
{ id: "ctpvNm", title: "시도", widthPercent: 18 },
{ id: "sggNm", title: "시군구", widthPercent: 20 },
{
id: "sbmrgDpthlDisplay",
title: "침수심도(최소~최대)",
widthPercent: 31,
},
{
id: "sbmrgFrqDisplay",
title: "침수빈도",
widthPercent: 23,
},
];
/** API가 허용하는 검색 조건과 페이징 파라미터만 쿼리 문자열로 만든다. */
export const buildFloodDngrMapListQueryString = (
filters: FloodDngrMapFilters,
page: number,
size: number,
) => {
const params = new URLSearchParams({
page: String(page),
size: String(size),
});
appendApiSearchParams(params, {
ctpvCd: filters.ctpvCd === "0" ? "0" : filters.ctpvCd.slice(0, 2),
sggCd: filters.sggCd === "0" ? "0" : filters.sggCd.slice(0, 5),
sbmrgDpthlCd: filters.sbmrgDpthlCd,
sbmrgFrqExpln: filters.sbmrgFrqExpln,
});
return params.toString();
};
const resolveAdministrativeName = (
value: unknown,
options: AdministrativeOption[],
codeLength: number,
) => {
const code = String(value ?? "")
.trim()
.slice(0, codeLength);
const matched = options.find(
(option) =>
String(option.val ?? "")
.trim()
.slice(0, codeLength) === code,
);
return String(matched?.text ?? "").trim() || "-";
};
const toFrequencyDisplay = (value: unknown) => {
const normalized = String(value ?? "").trim();
if (!normalized) return "-";
const numericValue = Number(normalized);
return Number.isFinite(numericValue) ? `${numericValue}년` : normalized;
};
const FLOOD_DNGR_MAP_DEPTH_BOUNDS: Record<
string,
{ min: string; max: string; openEnded?: boolean }
> = {
N330: { min: "0.0", max: "0.5" },
N331: { min: "0.5", max: "1.0" },
N332: { min: "1.0", max: "2.0" },
N333: { min: "2.0", max: "5.0" },
N334: { min: "5.0", max: "5.0", openEnded: true },
};
/** 그룹의 최소·최대 침수심도 코드를 하나의 범위 문구로 표시한다. */
const toDepthRangeDisplay = (minCode: string, maxCode: string) => {
if (!minCode && !maxCode) return "-";
if (minCode === maxCode)
return FLOOD_DNGR_MAP_DEPTH_LABELS[minCode] || minCode || "-";
const minDepth = FLOOD_DNGR_MAP_DEPTH_BOUNDS[minCode];
const maxDepth = FLOOD_DNGR_MAP_DEPTH_BOUNDS[maxCode];
if (!minDepth || !maxDepth) return `${minCode || "-"} ~ ${maxCode || "-"}`;
return maxDepth.openEnded
? `${minDepth.min}m ~ ${maxDepth.min}m 이상`
: `${minDepth.min}m ~ ${maxDepth.max}m 미만`;
};
/** 서버 페이지 번호와 코드 표시값을 화면 행으로 변환한다. */
export const toFloodDngrMapTableRows = (
items: FloodDngrMapApiItem[],
page: number,
pageSize = FLOOD_DNGR_MAP_PAGE_SIZE,
ctpvOptions: AdministrativeOption[] = [],
sggOptions: AdministrativeOption[] = [],
total = items.length,
): FloodDngrMapTableRow[] =>
items.map((item, index) => ({
...item,
rowNo: total - page * pageSize - index,
ctpvNm: resolveAdministrativeName(item.ctpvCd, ctpvOptions, 2),
sggNm: resolveAdministrativeName(item.sggCd, sggOptions, 5),
sbmrgDpthlDisplay: toDepthRangeDisplay(
item.minSbmrgDpthlCd,
item.maxSbmrgDpthlCd,
),
sbmrgFrqDisplay: toFrequencyDisplay(item.sbmrgFrqExpln),
}));
const resolveNodeName = (node: any) =>
String(
node?.text ??
node?.data?.lyrNm ??
node?.data?.name ??
node?.lyrNm ??
node?.name ??
"",
).trim();
/** 빈도·침수심 자식 노드가 아니라 도시침수지도 루트만 목록보기 대상으로 판별한다. */
export const isFloodDngrMapRootNode = (node: any) =>
resolveNodeName(node).replace(/\s/g, "") ===
FLOOD_DNGR_MAP_LAYER_NAME.replace(/\s/g, "");
@@ -1,142 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import { formatFloodArea, formatFloodDepth, type FloodHistoryItem } from "./floodHistoryTypes";
type Props = {
detail: FloodHistoryItem;
onClose: () => void;
position: {
left: number;
top: number;
};
};
type DetailTab = "year" | "disaster" | "rainfall";
const renderRows = (rows: Array<[string, string]>) => (
<table className="w-full table-fixed border-collapse">
<colgroup>
<col className="w-[42%]" />
<col className="w-[58%]" />
</colgroup>
<tbody>
{rows.map(([label, value], index) => (
<tr key={`${label}-${index}`}>
<th className="border border-[#d8e3ee] bg-[#f8fbfe] px-3 py-[7px] text-left text-[1.02rem] font-semibold text-[#36506b]">
{label}
</th>
<td className="border border-[#d8e3ee] bg-white px-3 py-[7px] text-[1.02rem] text-[#1f2937]">
{value}
</td>
</tr>
))}
</tbody>
</table>
);
export default function FloodHistoryDetailPanel({ detail, onClose, position }: Props) {
// 지도에서 침수흔적도 polygon을 클릭했을 때, 상세정보는 업무 의미 기준으로 3개 탭에 나눠 보여준다.
const [activeTab, setActiveTab] = useState<DetailTab>("year");
const yearRows = useMemo(
() => [
["침수심", `${detail.floodDepthM.toFixed(2)} m`],
["침수면적", formatFloodArea(detail.floodAreaHa)],
["침수년도", `${detail.floodYear}년`],
["등급", detail.gradeLabel],
["침수구역", detail.districtName],
] as Array<[string, string]>,
[detail],
);
const disasterRows = useMemo(
() => [
["침수재해명", detail.disasterName],
["침수면적", formatFloodArea(detail.floodAreaHa)],
["침수원인상세명", detail.causeDetailName],
["침수시작년월일", detail.startDate],
["침수종료년월일", detail.endDate],
["침수시작시간", detail.startTime],
] as Array<[string, string]>,
[detail],
);
const rainfallRows = useMemo(
() => [
["침수시작일자", detail.startDate],
["침수종료일자", detail.endDate],
["침수시작시간", detail.startTime],
["침수종료시간", detail.endTime],
["침수시간", detail.floodHour],
["강우시작시간", detail.rainStartTime],
["강우종료시간", detail.rainEndTime],
["총강우량(mm)", `${detail.totalRainfallMm.toFixed(0)}`],
["강우강도(mm/hr)", `${detail.rainfallIntensityMmHr.toFixed(0)}`],
["침수시간", detail.floodHour],
["침수원인상세코드", detail.causeDetailCode],
["피해금액", detail.damageAmount],
] as Array<[string, string]>,
[detail],
);
const currentRows = activeTab === "year"
? yearRows
: activeTab === "disaster"
? disasterRows
: rainfallRows;
return (
<div
className="absolute z-[70] w-[248px] overflow-hidden rounded-[8px] border border-[#cfdbe8] bg-[rgba(255,255,255,0.98)] shadow-[0_14px_28px_rgba(15,23,42,0.18)]"
style={{
left: `${position.left}px`,
top: `${position.top}px`,
}}
>
<div className="flex h-[28px] items-center justify-between border-b border-[#d8e3ee] bg-[linear-gradient(180deg,#ffffff_0%,#f6faff_100%)] px-[10px]">
<div className="text-[10px] font-bold tracking-[-0.01em] text-[#243b53]">상세정보</div>
<button
type="button"
className="rounded-[4px] px-[4px] text-[13px] leading-none text-[#7b8ba1] transition hover:bg-[#eef5fb] hover:text-[#35597d]"
onClick={onClose}
>
×
</button>
</div>
<div className="border-b border-[#d8e3ee] bg-[#f8fbfe] px-[6px] pt-[6px]">
<div className="flex gap-[4px]">
{[
["year", "년도탭"],
["disaster", "재해명탭"],
["rainfall", "침수발생강우탭"],
].map(([tabId, label]) => {
const isActive = activeTab === tabId;
return (
<button
key={tabId}
type="button"
className={`h-[24px] flex-1 rounded-t-[6px] border px-[6px] text-[9px] font-semibold leading-none transition ${
isActive
? "border-[#b8d4f0] border-b-white bg-white text-[#1f5d99]"
: "border-[#d8e3ee] bg-[#f3f7fb] text-[#5f7388] hover:bg-[#eef5fb]"
}`}
onClick={() => setActiveTab(tabId as DetailTab)}
>
{label}
</button>
);
})}
</div>
</div>
<div className="bg-white p-[6px]">
<div className="overflow-hidden rounded-[6px] border border-[#d8e3ee] bg-white">
{renderRows(currentRows)}
</div>
</div>
</div>
);
}
@@ -1,408 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Button, Input, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
import { downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
import UiPageNations from "@common/business_components/ui/table/uiPageNations";
import useStore from "@/store/useStore";
import {
buildFloodHistoryYearOptions,
buildFloodHistoryTableRows,
FLOOD_HISTORY_DEFAULT_FILTERS,
FLOOD_HISTORY_DOWNLOAD_HEADERS,
FLOOD_HISTORY_ITEMS,
FLOOD_HISTORY_PAGE_SIZE,
FLOOD_HISTORY_ROW_SELECT_MESSAGE,
formatFloodArea,
formatFloodDepth,
normalizeFloodHistoryFilters,
type FloodHistoryFilters,
type FloodHistoryItem,
} from "./floodHistoryTypes";
const resolveOptionText = (
code: string,
options: Array<{ text: string; val: string | number }>,
prefixLength?: number,
) => {
const normalized = String(code ?? "").trim();
if (!normalized || normalized === "0") return "";
const lookup = prefixLength ? normalized.slice(0, prefixLength) : normalized;
return options.find((item) => String(item.val).trim() === lookup)?.text ?? "";
};
const GRID_COLUMNS = [
{ id: "rowNo", title: "NO", width: "64px", align: "center" },
{ id: "ctpvNm", title: "시도", width: "90px", align: "center" },
{ id: "sggNm", title: "시군구", width: "100px", align: "center" },
{ id: "startDateDisplay", title: "침수시작일자", width: "118px", align: "center" },
{ id: "endDateDisplay", title: "침수종료일자", width: "118px", align: "center" },
{ id: "startTimeDisplay", title: "침수시작시간", width: "98px", align: "center" },
{ id: "endTimeDisplay", title: "침수종료시간", width: "98px", align: "center" },
{ id: "floodYearDisplay", title: "침수년도", width: "96px", align: "center" },
{ id: "floodHourDisplay", title: "침수시간", width: "100px", align: "center" },
{ id: "disasterName", title: "침수재해명", width: "190px", align: "left" },
{ id: "districtNameDisplay", title: "침수지구명", width: "150px", align: "left" },
{ id: "causeCodeDisplay", title: "침수원인상세코드", width: "132px", align: "center" },
{ id: "causeDetailDisplay", title: "침수원인상세", width: "190px", align: "left" },
{ id: "avgFloodDepthDisplay", title: "평균침수위", width: "104px", align: "right" },
{ id: "floodAreaDisplay", title: "침수면적", width: "104px", align: "right" },
{ id: "exmnYearDisplay", title: "조사년도", width: "96px", align: "center" },
{ id: "damageAmountDisplay", title: "피해금액", width: "132px", align: "right" },
] as const;
const buildDownloadRows = (rows: FloodHistoryItem[]) =>
rows.map((row, index) => ({
rowNo: index + 1,
ctpvNm: row.ctpvNm,
sggNm: row.sggNm,
disasterName: row.disasterName,
exmnYearDisplay: `${row.exmnYear}년`,
avgFloodDepthDisplay: formatFloodDepth(row.avgFloodDepthM),
floodAreaDisplay: formatFloodArea(row.floodAreaHa),
causeDetailDisplay: row.causeDetailName,
startDateDisplay: row.startDate,
endDateDisplay: row.endDate,
startTimeDisplay: row.startTime,
endTimeDisplay: row.endTime,
causeCodeDisplay: row.causeDetailCode,
districtNameDisplay: row.districtName,
damageAmountDisplay: row.damageAmount,
floodHourDisplay: row.floodHour,
floodYearDisplay: `${row.floodYear}년`,
}));
export default function FloodHistoryPopupPage() {
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
// 조회조건 draft는 popup 내부에서 사용자가 편집 중인 값이다.
const [filters, setFilters] = useState<FloodHistoryFilters>(FLOOD_HISTORY_DEFAULT_FILTERS);
// 적용된 조회조건과 선택행은 다운로드/페이지 이동에도 같은 기준을 유지하기 위해 분리한다.
const [appliedFilters, setAppliedFilters] = useState<FloodHistoryFilters>(FLOOD_HISTORY_DEFAULT_FILTERS);
const [currentPage, setCurrentPage] = useState(1);
const [selectedHistoryId, setSelectedHistoryId] = useState("");
useEffect(() => {
if (ctpv.length === 0) void fetchCtpv();
if (sgg.length === 0) void fetchSgg();
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
const floodYearOptions = useMemo(
() => buildFloodHistoryYearOptions(FLOOD_HISTORY_ITEMS),
[],
);
const sggOptions = useMemo(
() =>
filters.ctpvCd === "0"
? sgg
: sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2))),
[filters.ctpvCd, sgg],
);
const filteredRows = useMemo(() => {
const normalized = normalizeFloodHistoryFilters(appliedFilters);
const selectedCtpvName = resolveOptionText(normalized.ctpvCd, ctpv as any, 2);
const selectedSggName = resolveOptionText(normalized.sggCd, sgg as any, 5);
const disasterToken = normalized.disasterName.toLocaleLowerCase("ko-KR");
return FLOOD_HISTORY_ITEMS.filter((item) => {
if (selectedCtpvName && item.ctpvNm !== selectedCtpvName) return false;
if (selectedSggName && item.sggNm !== selectedSggName) return false;
if (normalized.floodYear && item.floodYear !== normalized.floodYear) return false;
if (
disasterToken &&
!item.disasterName.toLocaleLowerCase("ko-KR").includes(disasterToken) &&
!item.districtName.toLocaleLowerCase("ko-KR").includes(disasterToken)
) {
return false;
}
return true;
}).sort((left, right) => {
if (left.floodYear !== right.floodYear) {
return Number(right.floodYear) - Number(left.floodYear);
}
return left.disasterName.localeCompare(right.disasterName, "ko");
});
}, [appliedFilters, ctpv, sgg]);
const pagedRows = useMemo(() => {
const start = (currentPage - 1) * FLOOD_HISTORY_PAGE_SIZE;
const end = start + FLOOD_HISTORY_PAGE_SIZE;
return buildFloodHistoryTableRows(
filteredRows.slice(start, end),
currentPage,
FLOOD_HISTORY_PAGE_SIZE,
);
}, [currentPage, filteredRows]);
const handleSearch = () => {
const normalized = normalizeFloodHistoryFilters(filters);
if (normalized.sggCd !== "0" && normalized.ctpvCd === "0") {
alert("시도를 먼저 선택하세요.");
return;
}
if (normalized.floodYear && normalized.floodYear.length !== 4) {
alert("침수발생년도는 4자리 연도로 입력하세요.");
return;
}
setAppliedFilters(normalized);
setCurrentPage(1);
setSelectedHistoryId("");
};
const handleDownload = async () => {
if (filteredRows.length === 0) {
alert("다운로드할 침수발생이력 데이터가 없습니다.");
return;
}
await downloadExcelWithKoreanHeaders({
data: buildDownloadRows(filteredRows) as unknown as JSON[],
fileName: `침수흔적도_${new Date().toISOString().slice(0, 10)}.xlsx`,
sheetNameArray: ["침수흔적도"],
headerRange: "A1:Q1",
headerMapping: FLOOD_HISTORY_DOWNLOAD_HEADERS,
});
};
const handleRowDoubleClick = (row: FloodHistoryItem) => {
window.opener?.postMessage(
{
type: FLOOD_HISTORY_ROW_SELECT_MESSAGE,
row,
openDetail: false,
},
window.location.origin,
);
window.close();
};
const handleRowClick = (row: FloodHistoryItem) => {
setSelectedHistoryId(row.historyId);
// popup을 유지한 채 메인 지도에 mock 침수흔적/침수심 preview를 먼저 보여준다.
window.opener?.postMessage(
{
type: FLOOD_HISTORY_ROW_SELECT_MESSAGE,
row,
openDetail: false,
},
window.location.origin,
);
};
return (
<main className="sn-popup-page">
<header className="sn-popup-header">
<h1 className="sn-popup-title">침수흔적도 목록</h1>
<div />
</header>
<section className="sn-popup-section">
<div className="sn-popup-filter-panel-compact">
<div className="grid grid-cols-1 gap-3 md:grid-cols-[180px_180px_180px_minmax(0,1fr)_auto] md:items-end">
<label className="sn-popup-field">
<div className="sn-popup-field-label">시도</div>
<select
className="sn-popup-input !text-[1.1rem]"
value={filters.ctpvCd}
onChange={(event) =>
setFilters((current) => ({
...current,
ctpvCd: event.target.value,
sggCd: "0",
}))
}
>
<option value="0">전체</option>
{ctpv.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="sn-popup-field">
<div className="sn-popup-field-label">시군구</div>
<select
className="sn-popup-input !text-[1.1rem]"
value={filters.sggCd}
onChange={(event) =>
setFilters((current) => ({
...current,
sggCd: event.target.value,
}))
}
>
<option value="0">전체</option>
{sggOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="sn-popup-field">
<div className="sn-popup-field-label">침수발생년도</div>
<select
className="sn-popup-input !text-[1.1rem]"
value={filters.floodYear}
onChange={(event) =>
setFilters((current) => ({
...current,
floodYear: event.target.value,
}))
}
>
<option value="">전체</option>
{floodYearOptions.map((year) => (
<option key={year} value={year}>
{year}년
</option>
))}
</select>
</label>
<label className="sn-popup-field">
<div className="sn-popup-field-label">침수재해명</div>
<Input
className="sn-popup-input !text-[1.1rem]"
type="text"
autoComplete="off"
value={filters.disasterName}
onChange={(event) =>
setFilters((current) => ({
...current,
disasterName: event.target.value,
}))
}
onKeyDown={(event) => {
if (event.key === "Enter") handleSearch();
}}
/>
</label>
<div className="sn-popup-actions md:pr-3">
<Button
type="button"
size="sm"
color="primary"
className="sn-popup-primary-button !text-[1.1rem]"
onClick={handleSearch}
>
조회
</Button>
<Button
type="button"
size="sm"
color="primary"
className="sn-popup-primary-button !text-[1.1rem]"
onClick={() => void handleDownload()}
>
다운로드
</Button>
</div>
</div>
</div>
<div className="mt-3 flex min-h-0 flex-1 flex-col overflow-hidden">
<div className="card-header">
<h3 className="text-2xl font-medium text-label">
침수흔적도 조회 결과 {filteredRows.length.toLocaleString("ko-KR")}건
</h3>
</div>
<div className="card !m-[1.2rem] !mt-0 min-h-0 flex-1 overflow-x-auto overflow-y-auto">
<Table variant="secondary" className="w-max min-w-full">
<colgroup>
{GRID_COLUMNS.map((head) => (
<col key={head.id} style={{ width: head.width }} />
))}
</colgroup>
<TableHeader className="font-bold">
<TableRow>
<TableHead rowSpan={2} className="whitespace-nowrap">NO</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">시도</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">시군구</TableHead>
<TableHead colSpan={4} className="whitespace-nowrap">강우 분석정보</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">침수재해명</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">침수년도</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">침수시간</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">침수지구명</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">침수원인상세코드</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">침수원인상세</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">평균침수위</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">침수면적</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">조사년도</TableHead>
<TableHead rowSpan={2} className="whitespace-nowrap">피해금액</TableHead>
</TableRow>
<TableRow>
<TableHead className="whitespace-nowrap">침수시작일자</TableHead>
<TableHead className="whitespace-nowrap">침수종료일자</TableHead>
<TableHead className="whitespace-nowrap">침수시작시간</TableHead>
<TableHead className="whitespace-nowrap">침수종료시간</TableHead>
</TableRow>
</TableHeader>
<TableBody className="text-center">
{pagedRows.length > 0 ? (
pagedRows.map((row) => {
const isSelected = selectedHistoryId === row.historyId;
return (
<TableRow
key={row.historyId}
className={`cursor-pointer ${isSelected ? "sn-popup-grid-row-selected" : "sn-popup-grid-row-hover"}`}
onClick={() => handleRowClick(row)}
onDoubleClick={() => handleRowDoubleClick(row)}
>
<TableCell>{row.rowNo}</TableCell>
<TableCell>{row.ctpvNm}</TableCell>
<TableCell>{row.sggNm}</TableCell>
<TableCell>{row.startDateDisplay}</TableCell>
<TableCell>{row.endDateDisplay}</TableCell>
<TableCell>{row.startTimeDisplay}</TableCell>
<TableCell>{row.endTimeDisplay}</TableCell>
<TableCell className="text-left">{row.disasterName}</TableCell>
<TableCell>{row.floodYearDisplay}</TableCell>
<TableCell>{row.floodHourDisplay}</TableCell>
<TableCell className="text-left">{row.districtNameDisplay}</TableCell>
<TableCell>{row.causeCodeDisplay}</TableCell>
<TableCell className="text-left">{row.causeDetailDisplay}</TableCell>
<TableCell className="text-right">{row.avgFloodDepthDisplay}</TableCell>
<TableCell className="text-right">{row.floodAreaDisplay}</TableCell>
<TableCell>{row.exmnYearDisplay}</TableCell>
<TableCell className="text-right">{row.damageAmountDisplay}</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell colSpan={GRID_COLUMNS.length} className="sn-popup-empty-cell h-24 text-center">
조회된 데이터가 없습니다.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{filteredRows.length > 0 ? (
<UiPageNations
total={filteredRows.length}
cur={currentPage}
pageSize={FLOOD_HISTORY_PAGE_SIZE}
onPageChange={setCurrentPage}
/>
) : null}
<div className="sn-popup-help-text">
행 클릭 시 메인 지도에 미리 표시되고, 더블클릭하면 팝업을 닫습니다. 상세정보는 지도 위 침수흔적 영역을 클릭해 확인합니다.
</div>
</div>
</section>
</main>
);
}
@@ -1,458 +0,0 @@
"use client";
export const FLOOD_HISTORY_POPUP_KEY = "floodHistoryPopup";
export const FLOOD_HISTORY_ROW_SELECT_MESSAGE = "SN_FLOOD_HISTORY_ROW_SELECT";
export const FLOOD_HISTORY_LAYER_TYPE = "tb_sn_flood_trace_mock";
export const FLOOD_HISTORY_LAYER_NAME = "침수흔적도";
export const FLOOD_HISTORY_AREA_LAYER_ID = "SN_FLOOD_HISTORY_AREA";
export const FLOOD_HISTORY_DEPTH_LAYER_ID = "SN_FLOOD_HISTORY_DEPTH";
export const FLOOD_HISTORY_PAGE_SIZE = 10;
export type FloodHistoryFilters = {
ctpvCd: string;
sggCd: string;
floodYear: string;
disasterName: string;
};
export type FloodHistoryDepthZone = {
zoneId: string;
depthLabel: string;
color: string;
ring: [number, number][];
};
export type FloodHistoryItem = {
historyId: string;
ctpvNm: string;
sggNm: string;
floodYear: string;
exmnYear: string;
disasterName: string;
districtName: string;
floodAreaHa: number;
avgFloodDepthM: number;
floodDepthM: number;
gradeLabel: string;
startDate: string;
endDate: string;
startTime: string;
endTime: string;
floodHour: string;
causeDetailCode: string;
causeDetailName: string;
damageAmount: string;
rainStartTime: string;
rainEndTime: string;
totalRainfallMm: number;
rainfallIntensityMmHr: number;
areaRing: [number, number][];
depthZones: FloodHistoryDepthZone[];
};
export type FloodHistoryTableRow = FloodHistoryItem & {
rowNo: number;
exmnYearDisplay: string;
avgFloodDepthDisplay: string;
floodAreaDisplay: string;
causeDetailDisplay: string;
startDateDisplay: string;
endDateDisplay: string;
startTimeDisplay: string;
endTimeDisplay: string;
causeCodeDisplay: string;
districtNameDisplay: string;
damageAmountDisplay: string;
floodHourDisplay: string;
floodYearDisplay: string;
};
export const FLOOD_HISTORY_DEFAULT_FILTERS: FloodHistoryFilters = {
ctpvCd: "0",
sggCd: "0",
floodYear: "",
disasterName: "",
};
export const FLOOD_HISTORY_DOWNLOAD_HEADERS: Record<string, string> = {
rowNo: "NO",
ctpvNm: "시도",
sggNm: "시군구",
disasterName: "침수재해명",
exmnYearDisplay: "조사년도",
avgFloodDepthDisplay: "평균침수위",
floodAreaDisplay: "침수면적",
causeDetailDisplay: "침수원인상세",
startDateDisplay: "침수시작일자",
endDateDisplay: "침수종료일자",
startTimeDisplay: "침수시작시간",
endTimeDisplay: "침수종료시간",
causeCodeDisplay: "침수원인상세코드",
districtNameDisplay: "침수지구명",
damageAmountDisplay: "피해금액",
floodHourDisplay: "침수시간",
floodYearDisplay: "침수년도",
};
export const FLOOD_HISTORY_DEPTH_LEGEND_ITEMS = [
{ color: "#fff27a", label: "0.0~0.5m" },
{ color: "#c2f13d", label: "0.5~1.0m" },
{ color: "#67d8ff", label: "1.0~2.0m" },
{ color: "#8f8bff", label: "2.0~5.0m" },
{ color: "#f2528b", label: "5.0m 이상" },
] as const;
const toRadian = (degree: number) => (degree * Math.PI) / 180;
const createIrregularRing = ({
centerX,
centerY,
radiusX,
radiusY,
scales,
rotationDeg = 0,
}: {
centerX: number;
centerY: number;
radiusX: number;
radiusY: number;
scales: number[];
rotationDeg?: number;
}): [number, number][] => {
const rotation = toRadian(rotationDeg);
const points = scales.map((scale, index) => {
const angle = rotation + (Math.PI * 2 * index) / scales.length;
const jitterX = radiusX * scale * Math.cos(angle);
const jitterY = radiusY * scale * Math.sin(angle);
return [centerX + jitterX, centerY + jitterY] as [number, number];
});
return [...points, points[0]];
};
const createDepthZones = (
historyId: string,
centerX: number,
centerY: number,
): FloodHistoryDepthZone[] => {
return [
{
zoneId: `${historyId}-depth-1`,
depthLabel: "0.0~0.5m",
color: "#fff27a",
ring: createIrregularRing({
centerX: centerX - 5200,
centerY: centerY - 2600,
radiusX: 3600,
radiusY: 2900,
rotationDeg: -18,
scales: [1.02, 0.78, 0.9, 1.08, 0.86, 0.72, 0.94, 1.04],
}),
},
{
zoneId: `${historyId}-depth-2`,
depthLabel: "0.5~1.0m",
color: "#c2f13d",
ring: createIrregularRing({
centerX: centerX + 2600,
centerY: centerY - 1200,
radiusX: 4300,
radiusY: 3600,
rotationDeg: 12,
scales: [1.04, 0.88, 1.02, 0.8, 0.92, 1.08, 0.84, 0.95],
}),
},
{
zoneId: `${historyId}-depth-3`,
depthLabel: "1.0~2.0m",
color: "#67d8ff",
ring: createIrregularRing({
centerX: centerX - 600,
centerY: centerY + 2600,
radiusX: 5600,
radiusY: 4700,
rotationDeg: 8,
scales: [1.12, 0.86, 0.94, 1.06, 0.82, 0.97, 1.04, 0.9, 1.08, 0.84],
}),
},
{
zoneId: `${historyId}-depth-4`,
depthLabel: "2.0~5.0m",
color: "#8f8bff",
ring: createIrregularRing({
centerX: centerX + 4200,
centerY: centerY + 5200,
radiusX: 3200,
radiusY: 2500,
rotationDeg: 26,
scales: [1.05, 0.82, 1.08, 0.88, 0.96, 0.78, 1.02, 0.9],
}),
},
{
zoneId: `${historyId}-depth-5`,
depthLabel: "5.0m 이상",
color: "#f2528b",
ring: createIrregularRing({
centerX: centerX + 600,
centerY: centerY - 5200,
radiusX: 1500,
radiusY: 1350,
rotationDeg: -10,
scales: [1.0, 0.8, 1.06, 0.84, 0.94, 0.76, 0.98],
}),
},
];
};
const createHistory = (
item: Omit<FloodHistoryItem, "areaRing" | "depthZones"> & {
centerX: number;
centerY: number;
width: number;
height: number;
},
): FloodHistoryItem => {
const { centerX, centerY, width, height, ...detail } = item;
return {
...detail,
areaRing: createIrregularRing({
centerX,
centerY,
radiusX: width / 2,
radiusY: height / 2,
rotationDeg: 9,
scales: [1.08, 0.9, 1.04, 0.82, 0.98, 1.12, 0.88, 0.96, 1.03, 0.86],
}),
depthZones: createDepthZones(detail.historyId, centerX, centerY),
};
};
export const FLOOD_HISTORY_ITEMS: FloodHistoryItem[] = [
createHistory({
historyId: "FH-2024-001",
ctpvNm: "부산광역시",
sggNm: "중구",
floodYear: "2024",
exmnYear: "2025",
disasterName: "중앙동 저지대 침수",
districtName: "중앙동 저지대",
floodAreaHa: 1.8,
avgFloodDepthM: 0.62,
floodDepthM: 0.62,
gradeLabel: "1등급",
startDate: "2024-07-18",
endDate: "2024-07-18",
startTime: "14:10",
endTime: "18:20",
floodHour: "4.2시간",
causeDetailCode: "FC-01",
causeDetailName: "집중강우",
damageAmount: "120,000,000원",
rainStartTime: "13:50",
rainEndTime: "18:20",
totalRainfallMm: 91,
rainfallIntensityMmHr: 31,
centerX: 1122000,
centerY: 1705000,
width: 18000,
height: 14000,
}),
createHistory({
historyId: "FH-2023-002",
ctpvNm: "경상남도",
sggNm: "창원시",
floodYear: "2023",
exmnYear: "2024",
disasterName: "팔용동 공단 배수지연 침수",
districtName: "팔용동 공단",
floodAreaHa: 4.8,
avgFloodDepthM: 1.92,
floodDepthM: 1.92,
gradeLabel: "2등급",
startDate: "2023-08-11",
endDate: "2023-08-11",
startTime: "09:40",
endTime: "15:10",
floodHour: "5.5시간",
causeDetailCode: "FC-04",
causeDetailName: "배수시설 처리능력 초과",
damageAmount: "340,000,000원",
rainStartTime: "08:55",
rainEndTime: "15:05",
totalRainfallMm: 138,
rainfallIntensityMmHr: 42,
centerX: 1081000,
centerY: 1778000,
width: 25000,
height: 17000,
}),
createHistory({
historyId: "FH-2022-003",
ctpvNm: "울산광역시",
sggNm: "남구",
floodYear: "2022",
exmnYear: "2023",
disasterName: "태화강 제내지 도로 침수",
districtName: "여천동 하류부",
floodAreaHa: 3.1,
avgFloodDepthM: 1.15,
floodDepthM: 1.15,
gradeLabel: "2등급",
startDate: "2022-09-06",
endDate: "2022-09-06",
startTime: "07:20",
endTime: "12:40",
floodHour: "5.3시간",
causeDetailCode: "FC-02",
causeDetailName: "하천 수위 상승",
damageAmount: "210,000,000원",
rainStartTime: "06:30",
rainEndTime: "11:50",
totalRainfallMm: 117,
rainfallIntensityMmHr: 37,
centerX: 1168000,
centerY: 1811000,
width: 22000,
height: 16000,
}),
createHistory({
historyId: "FH-2021-004",
ctpvNm: "부산광역시",
sggNm: "서구",
floodYear: "2021",
exmnYear: "2022",
disasterName: "서대신동 급경사지 유출 침수",
districtName: "서대신동 일원",
floodAreaHa: 2.4,
avgFloodDepthM: 0.85,
floodDepthM: 0.85,
gradeLabel: "1등급",
startDate: "2021-07-07",
endDate: "2021-07-07",
startTime: "16:00",
endTime: "20:10",
floodHour: "4.1시간",
causeDetailCode: "FC-03",
causeDetailName: "우수관로 월류",
damageAmount: "175,000,000원",
rainStartTime: "15:20",
rainEndTime: "19:50",
totalRainfallMm: 104,
rainfallIntensityMmHr: 29,
centerX: 1111000,
centerY: 1709000,
width: 20000,
height: 14500,
}),
createHistory({
historyId: "FH-2020-005",
ctpvNm: "부산광역시",
sggNm: "영도구",
floodYear: "2020",
exmnYear: "2021",
disasterName: "청학동 해안도로 월파 침수",
districtName: "청학동 해안부",
floodAreaHa: 1.2,
avgFloodDepthM: 0.48,
floodDepthM: 0.48,
gradeLabel: "1등급",
startDate: "2020-10-22",
endDate: "2020-10-22",
startTime: "03:10",
endTime: "06:30",
floodHour: "3.3시간",
causeDetailCode: "FC-06",
causeDetailName: "해안 월파",
damageAmount: "82,000,000원",
rainStartTime: "02:40",
rainEndTime: "05:50",
totalRainfallMm: 55,
rainfallIntensityMmHr: 18,
centerX: 1129000,
centerY: 1695000,
width: 15000,
height: 12000,
}),
createHistory({
historyId: "FH-2019-006",
ctpvNm: "경상남도",
sggNm: "김해시",
floodYear: "2019",
exmnYear: "2020",
disasterName: "장유 배수분구 내수 침수",
districtName: "장유 배수분구",
floodAreaHa: 2.9,
avgFloodDepthM: 0.96,
floodDepthM: 0.96,
gradeLabel: "1등급",
startDate: "2019-09-21",
endDate: "2019-09-21",
startTime: "11:30",
endTime: "16:15",
floodHour: "4.8시간",
causeDetailCode: "FC-05",
causeDetailName: "배수펌프 지연 가동",
damageAmount: "143,000,000원",
rainStartTime: "10:45",
rainEndTime: "15:50",
totalRainfallMm: 88,
rainfallIntensityMmHr: 24,
centerX: 1098000,
centerY: 1734000,
width: 21000,
height: 15000,
}),
];
export const normalizeFloodHistoryFilters = (
filters: FloodHistoryFilters,
): FloodHistoryFilters => ({
ctpvCd: String(filters.ctpvCd ?? "0") || "0",
sggCd: String(filters.sggCd ?? "0") || "0",
floodYear: String(filters.floodYear ?? "").replace(/[^0-9]/g, "").slice(0, 4),
disasterName: String(filters.disasterName ?? "").trim(),
});
export const formatFloodArea = (value: number) => `${value.toFixed(1)}ha`;
export const formatFloodDepth = (value: number) => `${value.toFixed(2)}m`;
export const buildFloodHistoryTableRows = (
items: FloodHistoryItem[],
page: number,
pageSize = FLOOD_HISTORY_PAGE_SIZE,
): FloodHistoryTableRow[] =>
items.map((item, index) => ({
...item,
rowNo: (page - 1) * pageSize + index + 1,
exmnYearDisplay: `${item.exmnYear}년`,
avgFloodDepthDisplay: formatFloodDepth(item.avgFloodDepthM),
floodAreaDisplay: formatFloodArea(item.floodAreaHa),
causeDetailDisplay: item.causeDetailName,
startDateDisplay: item.startDate,
endDateDisplay: item.endDate,
startTimeDisplay: item.startTime,
endTimeDisplay: item.endTime,
causeCodeDisplay: item.causeDetailCode,
districtNameDisplay: item.districtName,
damageAmountDisplay: item.damageAmount,
floodHourDisplay: item.floodHour,
floodYearDisplay: `${item.floodYear}년`,
}));
export const buildFloodHistoryYearOptions = (items: FloodHistoryItem[]) =>
Array.from(new Set(items.map((item) => item.floodYear)))
.sort((left, right) => Number(right) - Number(left));
const resolveFloodHistoryNodeName = (node: any) =>
String(node?.text ?? node?.data?.lyrNm ?? node?.data?.name ?? node?.lyrNm ?? node?.name ?? "").trim();
const resolveFloodHistoryLayerType = (node: any) =>
String(node?.data?.lyrTyNm ?? node?.data?.lyrTypeNm ?? node?.lyrTyNm ?? node?.lyrTypeNm ?? "").trim();
export const isFloodHistoryNode = (node: any) => {
const name = resolveFloodHistoryNodeName(node);
const layerType = resolveFloodHistoryLayerType(node);
return name === FLOOD_HISTORY_LAYER_NAME || layerType === FLOOD_HISTORY_LAYER_TYPE;
};
@@ -1,3 +1,7 @@
/**
* 화면명: 하수관로 편집정보
* 편집 중인 하수관로의 기본정보와 입력정보를 표시하고 수정한다.
*/
"use client";
import { useEffect, useMemo, useState } from "react";
@@ -25,9 +29,27 @@ type MngInstOption = {
const EMPTY_SELECT_OPTION = { value: "", label: "선택" };
const PIPE_BASIC_INFO_FIELDS = [
{ id: "mngInstCd", sourceId: "mng_inst_cd", title: "관리기관", type: "select", required: true },
{ id: "fcltMngNo", sourceId: "fclt_mng_no", title: "시설관리번호", type: "input", required: true },
{ id: "instMngNo", sourceId: "inst_mng_no", title: "기관관리번호", type: "input", required: true },
{
id: "mngInstCd",
sourceId: "mng_inst_cd",
title: "관리기관",
type: "select",
required: true,
},
{
id: "fcltMngNo",
sourceId: "fclt_mng_no",
title: "시설관리번호",
type: "input",
required: true,
},
{
id: "instMngNo",
sourceId: "inst_mng_no",
title: "기관관리번호",
type: "input",
required: true,
},
{ id: "instlYmd", sourceId: "instl_ymd", title: "설치일자", type: "date" },
{ id: "mapshtNo", sourceId: "mapsht_no", title: "도엽번호", type: "input" },
{ id: "pdmtVl", sourceId: "pdmt_vl", title: "관경(mm)", type: "input" },
@@ -40,7 +62,10 @@ const PIPE_BASIC_INFO_FIELDS = [
required: true,
options: [
EMPTY_SELECT_OPTION,
...MS_SWAGE_PPL_USE_CODES.map((item) => ({ value: item.val.trim(), label: item.text })),
...MS_SWAGE_PPL_USE_CODES.map((item) => ({
value: item.val.trim(),
label: item.text,
})),
],
},
{
@@ -50,33 +75,95 @@ const PIPE_BASIC_INFO_FIELDS = [
type: "select",
options: [
EMPTY_SELECT_OPTION,
...MS_SWAGE_PPL_CODES.map((item) => ({ value: item.val.trim(), label: item.text })),
...MS_SWAGE_PPL_CODES.map((item) => ({
value: item.val.trim(),
label: item.text,
})),
],
},
{ id: "avgGrdn", sourceId: "avg_grdn", title: "평균구배(m/m)", type: "input" },
{ id: "drtwMltvArea", sourceId: "drtw_mltv_area", title: "오수배수면적(m²)", type: "input" },
{ id: "exclnMltvArea", sourceId: "excln_mltv_area", title: "우수배수면적(m²)", type: "input" },
{ id: "tmptDpth", sourceId: "tmpt_dpth", title: "시점깊이(m)", type: "input" },
{ id: "tmptMhlMngNo", sourceId: "tmpt_mhl_mng_no", title: "시점맨홀관리번호", type: "input" },
{ id: "scendDpth", sourceId: "scend_dpth", title: "종점깊이(m)", type: "input" },
{ id: "scendMhlMngNo", sourceId: "scend_mhl_mng_no", title: "종점맨홀관리번호", type: "input" },
{
id: "avgGrdn",
sourceId: "avg_grdn",
title: "평균구배(m/m)",
type: "input",
},
{
id: "drtwMltvArea",
sourceId: "drtw_mltv_area",
title: "오수배수면적(m²)",
type: "input",
},
{
id: "exclnMltvArea",
sourceId: "excln_mltv_area",
title: "우수배수면적(m²)",
type: "input",
},
{
id: "tmptDpth",
sourceId: "tmpt_dpth",
title: "시점깊이(m)",
type: "input",
},
{
id: "tmptMhlMngNo",
sourceId: "tmpt_mhl_mng_no",
title: "시점맨홀관리번호",
type: "input",
},
{
id: "scendDpth",
sourceId: "scend_dpth",
title: "종점깊이(m)",
type: "input",
},
{
id: "scendMhlMngNo",
sourceId: "scend_mhl_mng_no",
title: "종점맨홀관리번호",
type: "input",
},
{ id: "trflnCnt", sourceId: "trfln_cnt", title: "차선수", type: "input" },
{ id: "vrtcLen", sourceId: "vrtc_len", title: "세로길이(m)", type: "input" },
{ id: "wdthLen", sourceId: "wdth_len", title: "가로길이(m)", type: "input" },
];
const PIPE_INPUT_INFO_FIELDS = [
{ id: "instlMbdyCd", title: "설치주체", type: "select", options: [EMPTY_SELECT_OPTION, "BTL", "재정사업"] },
{
id: "instlMbdyCd",
title: "설치주체",
type: "select",
options: [EMPTY_SELECT_OPTION, "BTL", "재정사업"],
},
{ id: "swtrmtZoneNm", title: "처리구역", type: "input" },
{ id: "mltvZoneNm", title: "배수구역", type: "input" },
{ id: "mltvDtdvNm", title: "배수분구", type: "input" },
{ id: "lineCd", title: "지선/간선", type: "select", options: [EMPTY_SELECT_OPTION, "지선", "간선"] },
{ id: "floCd", title: "흐름방식", type: "select", options: [EMPTY_SELECT_OPTION, "자연유하", "압송"] },
{ id: "layCd", title: "매설위치", type: "select", options: [EMPTY_SELECT_OPTION, "도로", "하상"] },
{
id: "lineCd",
title: "지선/간선",
type: "select",
options: [EMPTY_SELECT_OPTION, "지선", "간선"],
},
{
id: "floCd",
title: "흐름방식",
type: "select",
options: [EMPTY_SELECT_OPTION, "자연유하", "압송"],
},
{
id: "layCd",
title: "매설위치",
type: "select",
options: [EMPTY_SELECT_OPTION, "도로", "하상"],
},
];
const PIPE_VISIBLE_BASIC_FIELD_IDS = PIPE_BASIC_INFO_FIELDS.map((field) => field.id);
const PIPE_VISIBLE_INPUT_FIELD_IDS = PIPE_INPUT_INFO_FIELDS.map((field) => field.id);
const PIPE_VISIBLE_BASIC_FIELD_IDS = PIPE_BASIC_INFO_FIELDS.map(
(field) => field.id,
);
const PIPE_VISIBLE_INPUT_FIELD_IDS = PIPE_INPUT_INFO_FIELDS.map(
(field) => field.id,
);
const toEditableText = (value: unknown) => {
const text = String(value ?? "").trim();
@@ -84,7 +171,10 @@ const toEditableText = (value: unknown) => {
};
const isInvalidPipeLookupKey = (value: string) =>
!value || value === "-" || /^NEW-/i.test(value) || /^(null|undefined)$/i.test(value);
!value ||
value === "-" ||
/^NEW-/i.test(value) ||
/^(null|undefined)$/i.test(value);
const toDateInputValue = (value: unknown) => {
const text = toEditableText(value).replaceAll(".", "").replaceAll("-", "");
@@ -92,10 +182,13 @@ const toDateInputValue = (value: unknown) => {
return `${text.slice(0, 4)}-${text.slice(4, 6)}-${text.slice(6, 8)}`;
};
const toDbYmdValue = (value: unknown) => toEditableText(value).replaceAll("-", "");
const toDbYmdValue = (value: unknown) =>
toEditableText(value).replaceAll("-", "");
const getObjectValue = (values: Record<string, any>, field: { id: string; sourceId?: string }) =>
values[field.id] ?? (field.sourceId ? values[field.sourceId] : undefined);
const getObjectValue = (
values: Record<string, any>,
field: { id: string; sourceId?: string },
) => values[field.id] ?? (field.sourceId ? values[field.sourceId] : undefined);
const pickFields = (values: Record<string, any>, fieldIds: string[]) =>
fieldIds.reduce<Record<string, any>>((acc, fieldId) => {
@@ -116,13 +209,17 @@ const normalizeBasicFormValues = (values: Record<string, any>) => {
const buildBaseFeatureValues = (featureData: any) => {
const resolveUsageCode = (value: unknown) => {
const raw = String(value ?? "").trim();
const matched = MS_SWAGE_PPL_USE_CODES.find((item) => item.val === raw || item.text === raw);
const matched = MS_SWAGE_PPL_USE_CODES.find(
(item) => item.val === raw || item.text === raw,
);
return matched?.val.trim() ?? raw;
};
const resolveMaterialCode = (value: unknown) => {
const raw = String(value ?? "").trim();
const matched = MS_SWAGE_PPL_CODES.find((item) => item.val === raw || item.text === raw);
const matched = MS_SWAGE_PPL_CODES.find(
(item) => item.val === raw || item.text === raw,
);
return matched?.val.trim() ?? raw;
};
@@ -149,7 +246,11 @@ const buildBaseFeatureValues = (featureData: any) => {
const getPipeUsageLabel = (value: unknown) => {
const raw = String(value ?? "").trim();
if (!raw) return "";
return MS_SWAGE_PPL_USE_CODES.find((item) => item.val.trim() === raw || item.text === raw)?.text ?? raw;
return (
MS_SWAGE_PPL_USE_CODES.find(
(item) => item.val.trim() === raw || item.text === raw,
)?.text ?? raw
);
};
const buildInitialInputDraft = (change: GisEditChange) => ({
@@ -169,11 +270,16 @@ export default function GisEditPipeSessionPopup({
onSave,
}: Props) {
const isNewPipe = change.editType === "ADD";
const baseFeatureValues = useMemo(() => buildBaseFeatureValues(featureData), [featureData]);
const [basicInfoTableData, setBasicInfoTableData] = useState<Record<string, any>>({});
const [inputInfoTableData, setInputInfoTableData] = useState<Record<string, any>>(
buildInitialInputDraft(change),
const baseFeatureValues = useMemo(
() => buildBaseFeatureValues(featureData),
[featureData],
);
const [basicInfoTableData, setBasicInfoTableData] = useState<
Record<string, any>
>({});
const [inputInfoTableData, setInputInfoTableData] = useState<
Record<string, any>
>(buildInitialInputDraft(change));
const [mngInstOptions, setMngInstOptions] = useState<MngInstOption[]>([]);
useEffect(() => {
@@ -181,7 +287,7 @@ export default function GisEditPipeSessionPopup({
const fetchMngInstOptions = async () => {
try {
const result = await getData("v1/fcltppl/mngInsts");
const result = await getData("v1/fcltPpl/mngInsts");
if (!isMounted) return;
setMngInstOptions(Array.isArray(result) ? result : []);
} catch {
@@ -195,24 +301,21 @@ export default function GisEditPipeSessionPopup({
};
}, []);
const mngInstSelectOptions = useMemo(
() => {
const sortedOptions = [...mngInstOptions].sort((a, b) =>
String(a.lwrInstNm ?? a.mngInstCd ?? "").localeCompare(
String(b.lwrInstNm ?? b.mngInstCd ?? ""),
"ko",
),
);
return [
EMPTY_SELECT_OPTION,
...sortedOptions.map((option) => ({
value: String(option.mngInstCd ?? "").trim(),
label: option.lwrInstNm || String(option.mngInstCd ?? "").trim(),
})),
];
},
[mngInstOptions],
);
const mngInstSelectOptions = useMemo(() => {
const sortedOptions = [...mngInstOptions].sort((a, b) =>
String(a.lwrInstNm ?? a.mngInstCd ?? "").localeCompare(
String(b.lwrInstNm ?? b.mngInstCd ?? ""),
"ko",
),
);
return [
EMPTY_SELECT_OPTION,
...sortedOptions.map((option) => ({
value: String(option.mngInstCd ?? "").trim(),
label: option.lwrInstNm || String(option.mngInstCd ?? "").trim(),
})),
];
}, [mngInstOptions]);
const basicInfoFields = useMemo(
() =>
@@ -239,14 +342,17 @@ export default function GisEditPipeSessionPopup({
const fcltMngNo = String(change.featureRef?.fcltMngNo ?? "").trim();
const mngInstCd = String(change.featureRef?.mngInstCd ?? "").trim();
if (isInvalidPipeLookupKey(fcltMngNo) || isInvalidPipeLookupKey(mngInstCd)) return;
if (isInvalidPipeLookupKey(fcltMngNo) || isInvalidPipeLookupKey(mngInstCd))
return;
let isMounted = true;
const fetchPipeInputInfo = async () => {
try {
const safeFcltMngNo = encodeURIComponent(fcltMngNo);
const safeMngInstCd = encodeURIComponent(mngInstCd);
const result = await getData(`v1/fcltppl/${safeFcltMngNo}/${safeMngInstCd}`);
const result = await getData(
`v1/fcltPpl/${safeFcltMngNo}/${safeMngInstCd}`,
);
if (!isMounted || !result) return;
setInputInfoTableData((previous) => ({
@@ -263,14 +369,25 @@ export default function GisEditPipeSessionPopup({
return () => {
isMounted = false;
};
}, [change.changeId, change.featureRef?.fcltMngNo, change.featureRef?.mngInstCd, isNewPipe]);
}, [
change.changeId,
change.featureRef?.fcltMngNo,
change.featureRef?.mngInstCd,
isNewPipe,
]);
const buildMergedDraft = (
nextBasicInfoTableData: Record<string, any>,
nextInputInfoTableData: Record<string, any>,
): GisEditPipeBasicInfo => {
const basicDraft = pickFields(nextBasicInfoTableData, PIPE_VISIBLE_BASIC_FIELD_IDS);
const inputDraft = pickFields(nextInputInfoTableData, PIPE_VISIBLE_INPUT_FIELD_IDS);
const basicDraft = pickFields(
nextBasicInfoTableData,
PIPE_VISIBLE_BASIC_FIELD_IDS,
);
const inputDraft = pickFields(
nextInputInfoTableData,
PIPE_VISIBLE_INPUT_FIELD_IDS,
);
if (basicDraft.instlYmd) {
basicDraft.instlYmd = toDbYmdValue(basicDraft.instlYmd);
}
@@ -290,29 +407,43 @@ export default function GisEditPipeSessionPopup({
onSave(change.changeId, buildMergedDraft(basicInfoTableData, nextFormData));
};
const basicInfoTableRows = useMemo(() => [basicInfoTableData], [basicInfoTableData]);
const inputInfoTableRows = useMemo(() => [inputInfoTableData], [inputInfoTableData]);
const displayManagementNo = toEditableText(change.featureRef?.fcltMngNo ?? change.objectId ?? "");
const basicInfoTableRows = useMemo(
() => [basicInfoTableData],
[basicInfoTableData],
);
const inputInfoTableRows = useMemo(
() => [inputInfoTableData],
[inputInfoTableData],
);
const displayManagementNo = toEditableText(
change.featureRef?.fcltMngNo ?? change.objectId ?? "",
);
return (
<div id="popup" className="absolute top-0 right-0 w-[350px] h-[calc(100vh-5rem)] bg-white border-r border-gray-300 flex flex-col overflow-hidden transition-all duration-300 z-[50]">
<div className="card-header bg-primary flex items-center justify-between relative p-4">
<div className="flex items-center space-x-4 flex-1">
<h3 className="text-2xl font-medium text-white text-label whitespace-nowrap">
<div
id="popup"
className="absolute right-0 top-0 z-[50] flex h-[calc(100vh-5rem)] w-[350px] flex-col overflow-hidden border-r border-border bg-background transition-all duration-300"
>
<div className="card-header relative flex items-center justify-between bg-primary p-4">
<div className="flex flex-1 items-center space-x-4">
<h3 className="whitespace-nowrap text-2xl font-medium text-primary-foreground">
하수관로
{basicInfoTableData?.usgCd ? `(${getPipeUsageLabel(basicInfoTableData.usgCd)})` : ""}
{" "}정보 <br />관리번호 : {displayManagementNo || "신규"}
{basicInfoTableData?.usgCd
? `(${getPipeUsageLabel(basicInfoTableData.usgCd)})`
: ""}{" "}
정보 <br />
관리번호 : {displayManagementNo || "신규"}
</h3>
</div>
<Button
onClick={onClose}
className="flex items-center space-x-4 text-white hover:text-gray-300 focus:outline-none"
className="text-primary-foreground/80 flex items-center space-x-4 hover:text-primary-foreground focus:outline-none"
size="xs"
>
✕
</Button>
</div>
<div className="overflow-y-auto overflow-x-hidden flex-1">
<div className="flex-1 overflow-y-auto overflow-x-hidden">
<VerticalTable
headName="기본정보"
tableData={basicInfoTableRows}
@@ -1,6 +1,13 @@
/**
* 화면명: 하수관로 편집 세션
* 현재 편집 대상과 변경 상태를 표시하고 저장·취소 동작을 제공한다.
*/
"use client";
import { Button } from "@common/components";
import type { GisEditSessionSnapshot, GisEditTool } from "./gisEditTypes";
import { useGisEditOperationLabels } from "./useGisEditCommonCodes";
type Props = GisEditSessionSnapshot & {
onRemoveFocusedChange: () => void;
@@ -12,64 +19,76 @@ type Props = GisEditSessionSnapshot & {
className?: string;
};
const TOOL_LABELS: Record<GisEditTool, string> = {
ADD: "추가",
MOVE: "이동",
VERTEX_EDIT: "꼭지점편집",
DELETE: "삭제",
};
const buildChangeItemClassName = (isFocused: boolean) =>
`block w-full rounded border px-2 py-2 text-left text-[12px] transition ${
`block w-full rounded-md border px-3 py-2.5 text-left text-[1.1rem] transition ${
isFocused
? "border-[#5a9bd5] bg-[#eef7ff] shadow-[inset_0_0_0_1px_rgba(90,155,213,0.2)]"
: "border-[#d8dee8] bg-[#fbfcfe] hover:border-[#bfd2e5] hover:bg-white"
? "border-primary bg-primary-transparent shadow-[inset_0_0_0_1px_var(--primary-transparent)]"
: "border-border bg-background hover:border-primary hover:bg-background"
}`;
const getChangeLabel = (change: GisEditSessionSnapshot["changes"][number]) =>
change.attributeOnly ? "상세정보수정" : TOOL_LABELS[change.editType];
const getChangeLabel = (
change: GisEditSessionSnapshot["changes"][number],
operationLabels: Record<GisEditTool, string>,
) =>
change.attributeOnly
? "상세정보수정"
: operationLabels[change.editType];
const getChangeSummary = (change: GisEditSessionSnapshot["changes"][number]) => {
const getChangeSummary = (
change: GisEditSessionSnapshot["changes"][number],
) => {
if (change.attributeOnly) return "변경한 상세정보를 확인하세요.";
if (change.editType === "ADD") return "추가한 객체 위치를 확인하세요.";
if (change.editType === "MOVE") return "객체를 드래그해 위치를 조정하세요.";
if (change.editType === "VERTEX_EDIT") return "점을 옮기거나 선분을 끌어 추가하세요.";
if (change.editType === "VERTEX_EDIT")
return "점을 옮기거나 선분을 끌어 추가하세요.";
return "삭제할 객체가 맞는지 확인하세요.";
};
type ChangeListProps = {
changes: GisEditSessionSnapshot["changes"];
focusedChangeId: string | null;
operationLabels: Record<GisEditTool, string>;
onFocusChange: (changeId: string) => void;
};
const ChangeList = ({ changes, focusedChangeId, onFocusChange }: ChangeListProps) => (
const ChangeList = ({
changes,
focusedChangeId,
operationLabels,
onFocusChange,
}: ChangeListProps) => (
<>
{changes.map((change, index) => (
<button
key={change.changeId}
type="button"
className={buildChangeItemClassName(focusedChangeId === change.changeId)}
className={buildChangeItemClassName(
focusedChangeId === change.changeId,
)}
onClick={() => onFocusChange(change.changeId)}
>
<div className="flex items-center gap-1">
<div className="shrink-0 whitespace-nowrap text-[11px] font-semibold leading-4 text-[#173f67]">
{index + 1}. {getChangeLabel(change)} / {change.objectLabel}
<div className="shrink-0 whitespace-nowrap text-[1.1rem] font-semibold leading-4 text-label">
{index + 1}. {getChangeLabel(change, operationLabels)} /{" "}
{change.objectLabel}
</div>
<div className="ml-auto flex shrink-0 items-center gap-1">
{change.basicInfoSavedAt && focusedChangeId !== change.changeId ? (
<span className="whitespace-nowrap rounded-full border border-[#a9d9bd] bg-white px-1.5 py-0.5 text-[9px] font-semibold text-[#16834f]">
<span className="whitespace-nowrap rounded-full border border-emerald-200 bg-background px-1.5 py-0.5 text-[0.9rem] font-semibold text-emerald-700">
정보저장
</span>
) : null}
{focusedChangeId === change.changeId ? (
<span className="whitespace-nowrap rounded-full border border-[#b8d3ef] bg-white px-1.5 py-0.5 text-[9px] font-semibold text-[#176db8]">
지도 미리보기 선택
<span className="whitespace-nowrap rounded-full border border-primary bg-background px-1.5 py-0.5 text-[0.9rem] font-semibold text-primary">
선택됨
</span>
) : null}
</div>
</div>
<div className="mt-1 truncate text-[11px] text-[#526173]">{getChangeSummary(change)}</div>
<div className="mt-1 truncate text-[1rem] text-muted-foreground">
{getChangeSummary(change)}
</div>
</button>
))}
</>
@@ -90,67 +109,87 @@ export default function GisEditSessionPanel({
variant = "inline",
className = "",
}: Props) {
// 인라인·윈도우 팝업 세션 모두 동일한 CMS 공통코드 라벨을 공유한다.
const operationLabels = useGisEditOperationLabels();
const isCompactInline = variant === "inline" && changes.length === 0;
return (
<div
className={`flex min-h-0 flex-col overflow-hidden rounded-lg border border-[#d8dee8] bg-[rgba(255,255,255,0.98)] text-sm text-[#304255] shadow-[0_10px_26px_rgba(23,42,70,0.14)] ${isCompactInline ? "h-[58px] p-2" : "p-4"} ${className}`.trim()}
className={`flex min-h-0 flex-col overflow-hidden rounded-lg border border-border bg-[rgba(255,255,255,0.98)] text-label shadow-[0_10px_26px_rgba(23,42,70,0.16)] ${isCompactInline ? "min-h-[68px]" : "p-3"} ${className}`.trim()}
>
<div className={`flex shrink-0 justify-between gap-3 ${isCompactInline ? "mb-0 items-center" : "mb-3 items-start"}`}>
<div
className={`flex shrink-0 justify-between gap-2 ${isCompactInline ? "items-center bg-container px-3 py-2.5" : "mb-3 items-start rounded-md bg-container px-3 py-2.5"}`}
>
<div className="min-w-0">
{variant === "inline" ? (
<div className="mb-1 text-[14px] font-bold text-[#173f67]">편집 세션</div>
<div className="mb-1 flex items-center gap-2 text-[1.3rem] font-semibold text-label">
<span>편집 세션</span>
<span className="rounded-full bg-primary px-2 py-0.5 text-[0.9rem] font-semibold text-primary-foreground">
{changes.length}건
</span>
</div>
) : null}
<div className="truncate text-[12px] text-[#667485]">
레이어: {selectedLayerName || "미선택"} / 모드: {isEditMode ? (activeTool ? TOOL_LABELS[activeTool] : "대기") : "편집 모드 종료"}
<div className="truncate text-[1.05rem] text-muted-foreground">
{selectedLayerName || "레이어 미선택"} ·{" "}
{isEditMode
? activeTool
? operationLabels[activeTool]
: "대기"
: "편집 모드 종료"}
</div>
</div>
{onOpenPopup ? (
<button
<Button
type="button"
className="flex shrink-0 items-center gap-1 rounded border border-[#b8d3ef] bg-white px-2.5 py-1.5 text-[11px] font-semibold text-[#176db8] transition hover:bg-[#eef7ff]"
color="white"
onClick={onOpenPopup}
title="편집 세션을 기존 윈도우 팝업으로 열기"
>
<span aria-hidden="true" className="text-[12px] leading-none">↗</span>
<span aria-hidden="true" className="text-[1.1rem] leading-none">
↗
</span>
<span>팝업</span>
</button>
</Button>
) : null}
</div>
<div className={`${isCompactInline ? "hidden" : "mb-3 flex"} shrink-0 flex-wrap items-center gap-2`}>
<button
<div
className={`${isCompactInline ? "hidden" : "mb-3 flex"} shrink-0 flex-wrap items-center gap-2 rounded-md border border-border bg-background p-2`}
>
<Button
type="button"
className="rounded border border-[#d8dee8] bg-white px-3 py-1.5 text-[12px] font-semibold text-[#304255] transition hover:bg-[#f8fafc] disabled:cursor-not-allowed disabled:text-[#9aa8b7]"
color="white"
disabled={!focusedChangeId}
onClick={onRemoveFocusedChange}
>
취소
</button>
<button
선택 취소
</Button>
<Button
type="button"
className="rounded border border-[#d8dee8] bg-white px-3 py-1.5 text-[12px] font-semibold text-[#304255] transition hover:bg-[#f8fafc] disabled:cursor-not-allowed disabled:text-[#9aa8b7]"
color="white"
disabled={!canUndo}
onClick={onRemoveLastChange}
>
뒤로가기
</button>
<button
최근 취소
</Button>
<Button
type="button"
className="rounded border border-[#f3d0d0] bg-white px-3 py-1.5 text-[12px] font-semibold text-[#b42318] transition hover:bg-[#fff5f5] disabled:cursor-not-allowed disabled:text-[#d0a5a5]"
color="white"
disabled={!canUndo}
onClick={onClearChanges}
>
초기화
</button>
<div className="ml-auto rounded-full border border-[#d8dee8] bg-white px-2.5 py-1 text-[11px] font-semibold text-[#526173]">
전체 초기화
</Button>
<div className="ml-auto rounded-full border border-border bg-background px-2.5 py-1 text-[1rem] font-semibold text-muted-foreground">
총 {changes.length}건
</div>
</div>
<div className={`${isCompactInline ? "hidden" : "min-h-0 flex-1"} overflow-hidden`}>
<div
className={`${isCompactInline ? "hidden" : "min-h-0 flex-1"} overflow-hidden`}
>
{changes.length === 0 ? (
<div className="rounded border border-dashed border-[#d8dee8] bg-[#fbfcfe] px-3 py-4 text-[12px] text-[#667485]">
<div className="rounded-md border border-dashed border-border bg-background px-3 py-5 text-center text-[1.1rem] text-muted-foreground">
저장할 편집 변경분이 없습니다.
</div>
) : (
@@ -159,6 +198,7 @@ export default function GisEditSessionPanel({
<ChangeList
changes={changes}
focusedChangeId={focusedChangeId}
operationLabels={operationLabels}
onFocusChange={onFocusChange}
/>
</div>
@@ -1,16 +1,14 @@
/**
* 화면명: 하수관로 편집 도구
* 관로 추가·이동·꼭지점 편집·삭제 도구를 선택하고 제어한다.
*/
"use client";
import {
Check,
Move,
PenTool,
Plus,
Trash2,
X,
} from "lucide-react";
import { Check, Move, PenTool, Plus, Trash2, X } from "lucide-react";
import { Button } from "@common/components/ui";
import type { GisEditTool } from "./gisEditTypes";
import { useGisEditOperationLabels } from "./useGisEditCommonCodes";
type Props = {
isEditMode: boolean;
@@ -29,13 +27,12 @@ type Props = {
const TOOL_BUTTONS: Array<{
tool: GisEditTool;
label: string;
icon: typeof Plus;
}> = [
{ tool: "ADD", label: "추가", icon: Plus },
{ tool: "MOVE", label: "이동", icon: Move },
{ tool: "VERTEX_EDIT", label: "꼭지점편집", icon: PenTool },
{ tool: "DELETE", label: "삭제", icon: Trash2 },
{ tool: "ADD", icon: Plus },
{ tool: "MOVE", icon: Move },
{ tool: "VERTEX_EDIT", icon: PenTool },
{ tool: "DELETE", icon: Trash2 },
];
export default function GisEditToolbar({
@@ -45,36 +42,55 @@ export default function GisEditToolbar({
canUseTools,
canSave,
isSaving = false,
statusMessage: _statusMessage,
statusMessage,
allowedTools = [],
onClose,
onToggleSnap,
onSelectTool,
onSave,
}: Props) {
// 버튼의 업무 코드값은 그대로 유지하고 CMS 공통코드명만 화면 라벨로 사용한다.
const operationLabels = useGisEditOperationLabels();
if (!isEditMode) return null;
return (
<div data-testid="gis-edit-toolbar" className="absolute bottom-12 left-1/2 z-[60] flex w-[min(960px,calc(100%-2rem))] -translate-x-1/2 flex-col">
<div className="card card-border flex !bg-white py-[1px]">
<div className="flex w-full items-center gap-2 overflow-hidden">
<div className="flex w-[120px] shrink-0 items-center justify-start">
<span className="whitespace-nowrap text-[1.2rem] font-bold leading-5 text-[#123b62]">하수관로 편집</span>
<div
data-testid="gis-edit-toolbar"
className="absolute bottom-4 left-1/2 z-[60] flex w-[min(980px,calc(100%-2rem))] -translate-x-1/2 flex-col"
>
<div className="card !m-0 flex !border-0 !bg-background !p-[0.8rem] shadow-[0_1px_2px_rgba(60,64,67,0.3),0_1px_3px_1px_rgba(60,64,67,0.15)]">
<div className="flex w-full items-center gap-3 overflow-hidden">
<div className="flex w-[168px] shrink-0 flex-col justify-center border-r border-border pr-3">
<span className="whitespace-nowrap text-[1.3rem] font-semibold leading-5 text-label">
하수관로 편집
</span>
<span
className="mt-0.5 truncate text-[1.05rem] text-muted-foreground"
title={statusMessage}
>
{statusMessage || "편집 도구를 선택하세요."}
</span>
</div>
<div className="flex min-w-0 flex-1 justify-center">
<div className="flex items-center gap-1">
{TOOL_BUTTONS.map(({ tool, label, icon: Icon }) => {
<div className="flex min-w-0 flex-1 items-center justify-center gap-3">
<div className="flex items-center gap-1 rounded-md bg-container p-1">
{TOOL_BUTTONS.map(({ tool, icon: Icon }) => {
const isActive = activeTool === tool;
const isToolEnabled =
canUseTools && allowedTools.includes(tool);
const label = operationLabels[tool];
if (tool === "VERTEX_EDIT") {
return (
<div key="vertex-edit-with-snap" className="flex shrink-0 items-center">
<div
key="vertex-edit-with-snap"
className="flex shrink-0 items-center"
>
<Button
type="button"
color={activeTool === "VERTEX_EDIT" ? "primary" : "white"}
size="sm"
className="mx-0.5 mt-1 min-w-[98px] px-2"
color={
activeTool === "VERTEX_EDIT" ? "primary" : "white"
}
className="min-w-[98px] px-2"
disabled={!isToolEnabled}
onClick={() => onSelectTool("VERTEX_EDIT")}
>
@@ -83,20 +99,6 @@ export default function GisEditToolbar({
<span>{label}</span>
</span>
</Button>
<Button
type="button"
color={isSnapEnabled ? "primary" : "white"}
size="sm"
className="mx-0.5 mt-1 min-w-[82px] px-2"
disabled={!canUseTools}
onClick={onToggleSnap}
title="이동과 꼭지점편집에 스냅핑 적용"
>
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
<Check className="h-4 w-4" />
<span>스냅핑</span>
</span>
</Button>
</div>
);
}
@@ -105,8 +107,7 @@ export default function GisEditToolbar({
key={tool}
type="button"
color={isActive ? "primary" : "white"}
size="sm"
className="mx-0.5 mt-1 min-w-[74px] shrink-0 px-2"
className="min-w-[74px] shrink-0 px-2"
disabled={!isToolEnabled}
onClick={() => onSelectTool(tool)}
>
@@ -118,14 +119,27 @@ export default function GisEditToolbar({
);
})}
</div>
<div className="h-8 w-px shrink-0 bg-border" />
<Button
type="button"
color={isSnapEnabled ? "primary" : "white"}
className="min-w-[82px] shrink-0 px-2"
disabled={!canUseTools}
onClick={onToggleSnap}
title="이동과 꼭지점편집에 스냅핑 적용"
>
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
<Check className="h-4 w-4" />
<span>스냅핑</span>
</span>
</Button>
</div>
<div className="flex w-[132px] shrink-0 items-center justify-end gap-1">
<div className="flex w-[140px] shrink-0 items-center justify-end gap-2 border-l border-border pl-3">
<Button
type="button"
color="primary"
size="sm"
className="mt-1 min-w-[58px] shrink-0 px-2"
className="min-w-[64px] shrink-0 px-2"
disabled={!canSave || isSaving}
onClick={onSave}
>
@@ -135,14 +149,13 @@ export default function GisEditToolbar({
<Button
type="button"
color="white"
size="sm"
className="mt-1 min-w-[58px] shrink-0 px-2"
className="min-w-[64px] shrink-0 px-2"
onClick={onClose}
title="하수관로 편집 닫기"
title="하수관로 편집 취소"
>
<span className="flex items-center justify-center gap-2 whitespace-nowrap">
<X className="h-4 w-4" />
<span>닫기</span>
<span>취소</span>
</span>
</Button>
</div>
@@ -1,111 +1,2 @@
export type GisEditTool = "ADD" | "MOVE" | "VERTEX_EDIT" | "DELETE";
export type GisEditGeometryType = "Point" | "LineString" | "Polygon";
export type GisEditPreviewColors = {
pointColor: string;
lineColor: string;
fillColor: string;
};
export type GisEditFeatureRef = {
fcltMngNo?: string;
mngInstCd?: string;
fid?: string;
};
export type GisEditPipeBasicInfo = {
[key: string]: string | null | undefined;
fcltMngNo?: string | null;
mngInstCd?: string | null;
instMngNo?: string | null;
instlYmd?: string | null;
mapshtNo?: string | null;
pdmtVl?: string | null;
prlgVl?: string | null;
usgCd?: string | null;
mqltCd?: string | null;
avgGrdn?: string | null;
drtwMltvArea?: string | null;
exclnMltvArea?: string | null;
tmptDpth?: string | null;
tmptMhlMngNo?: string | null;
scendDpth?: string | null;
scendMhlMngNo?: string | null;
trflnCnt?: string | null;
vrtcLen?: string | null;
wdthLen?: string | null;
instlMbdyCd?: string | null;
swtrmtZoneNm?: string | null;
mltvZoneNm?: string | null;
mltvDtdvNm?: string | null;
lineCd?: string | null;
floCd?: string | null;
layCd?: string | null;
};
export type GisEditLayerInfo = {
layerId: string;
layerName: string;
geometryType: GisEditGeometryType;
editableYn: "Y" | "N";
allowedTools: GisEditTool[];
previewColors: GisEditPreviewColors;
disabledReason?: string;
};
export type GisEditChange = {
changeId: string;
editType: GisEditTool;
layerId: string;
layerName: string;
objectId?: string;
fid?: string;
baseEditSn?: string;
featureRef?: GisEditFeatureRef;
objectLabel: string;
beforeGeometry?: Record<string, unknown> | null;
afterGeometry?: Record<string, unknown> | null;
featureProperties?: Record<string, unknown> | null;
basicInfo?: GisEditPipeBasicInfo | null;
basicInfoSavedAt?: string | null;
attributeOnly?: boolean;
summary: string;
createdAt: string;
source: "MAP_SELECTION" | "MAP_CENTER" | "MAP_DRAW";
};
export type GisEditSavePayload = {
screenId: string;
layerId: string;
layerName: string;
saveTargetType: "VERSION" | "FINAL";
view: {
srs: string;
bbox: string | null;
};
changes: Array<{
changeId: string;
editType: GisEditTool;
objectId?: string;
clientTempId?: string;
fid?: string;
baseEditSn?: string;
featureRef?: GisEditFeatureRef;
attributes?: GisEditPipeBasicInfo | null;
beforeGeometry?: Record<string, unknown> | null;
afterGeometry?: Record<string, unknown> | null;
geometry?: Record<string, unknown> | null;
}>;
};
export type GisEditSessionSnapshot = {
isEditMode: boolean;
selectedLayerName: string;
activeTool: GisEditTool | null;
isSnapEnabled: boolean;
changes: GisEditChange[];
lastSavedPayload: GisEditSavePayload | null;
canUndo: boolean;
focusedChangeId: string | null;
};
/** GIS 편집 모델은 기능이 소유하고 기존 화면 import 경로는 유지한다. */
export * from "@/features/gisEdit/model/types";
@@ -1,484 +1,37 @@
/** 하수관로 GIS 편집 도구의 선택 상태와 변경 내역을 관리하는 훅이다. */
"use client";
import { useCallback, useMemo, useRef, useState } from "react";
import GeoJSON from "ol/format/GeoJSON.js";
import {
type GisEditInteractionRequest,
isActionableChange,
isGeometryChange,
buildPreviewFocusMessage,
FLOW_DIRECTION_SRS,
SCREEN_ID,
buildPipeSaveAttributes,
EMPTY_GIS_EDIT_PIPE_BASIC_INFO,
normalizeGeometryType,
collectEditableLayers,
toGeometryObject,
buildBbox,
getFeatureLayerId,
getFeatureObjectId,
getFeatureObjectLabel,
getFeatureRef,
getFeatureProperties,
normalizePipeGeometry,
createChangeId,
} from "@/features/gisEdit";
export { EMPTY_GIS_EDIT_PIPE_BASIC_INFO } from "@/features/gisEdit";
import type {
GisEditChange,
GisEditFeatureRef,
GisEditGeometryType,
GisEditLayerInfo,
GisEditPipeBasicInfo,
GisEditPreviewColors,
GisEditSavePayload,
GisEditTool,
} from "./gisEditTypes";
type GisEditInteractionRequest = {
changeId: string;
editType: "MOVE" | "VERTEX_EDIT";
};
const isActionableChange = (change: GisEditChange) =>
!change.attributeOnly || Boolean(change.basicInfoSavedAt);
const isGeometryChange = (change: GisEditChange) =>
!change.attributeOnly && change.editType !== "ADD";
const buildPreviewFocusMessage = (
change: Pick<GisEditChange, "editType" | "layerName" | "objectLabel">,
) => {
if (change.editType === "MOVE") {
return `${change.layerName} ${change.objectLabel} 미리보기 객체를 드래그해 위치를 조정하세요.`;
}
if (change.editType === "VERTEX_EDIT") {
return `${change.layerName} ${change.objectLabel}의 주황색 실꼭지점을 드래그하거나, 선분 위 흰색 midpoint를 끌어 새 꼭지점을 추가하세요.`;
}
if (change.editType === "DELETE") {
return `${change.layerName} ${change.objectLabel} 삭제 미리보기를 유지 중입니다.`;
}
return `${change.layerName} ${change.objectLabel} 신규 객체 미리보기를 확인하세요.`;
};
const EDITABLE_LAYER_RULES: Record<
string,
Omit<GisEditLayerInfo, "layerId" | "editableYn">
> = {
tb_ms_swage_ppl: {
layerName: "하수관로",
geometryType: "LineString",
allowedTools: ["ADD", "MOVE", "VERTEX_EDIT", "DELETE"],
previewColors: {
pointColor: "rgba(22, 163, 74, 0.92)",
lineColor: "rgba(22, 163, 74, 0.95)",
fillColor: "rgba(22, 163, 74, 0.18)",
},
},
tb_ms_swage_mhl: {
layerName: "하수맨홀",
geometryType: "Point",
allowedTools: ["ADD", "MOVE", "DELETE"],
previewColors: {
pointColor: "rgba(5, 150, 105, 0.92)",
lineColor: "rgba(5, 150, 105, 0.95)",
fillColor: "rgba(5, 150, 105, 0.18)",
},
},
tb_ms_swage_pmstn: {
layerName: "하수펌프장",
geometryType: "Point",
allowedTools: ["ADD", "MOVE", "DELETE"],
previewColors: {
pointColor: "rgba(59, 130, 246, 0.92)",
lineColor: "rgba(59, 130, 246, 0.95)",
fillColor: "rgba(59, 130, 246, 0.18)",
},
},
tb_il_swage_prcs_fclt: {
layerName: "하수처리시설",
geometryType: "Point",
allowedTools: ["ADD", "MOVE", "DELETE"],
previewColors: {
pointColor: "rgba(168, 85, 247, 0.92)",
lineColor: "rgba(168, 85, 247, 0.95)",
fillColor: "rgba(168, 85, 247, 0.18)",
},
},
tb_ms_swage_prcs_zone: {
layerName: "하수처리구역",
geometryType: "Polygon",
allowedTools: ["ADD", "MOVE", "VERTEX_EDIT", "DELETE"],
previewColors: {
pointColor: "rgba(249, 115, 22, 0.92)",
lineColor: "rgba(249, 115, 22, 0.95)",
fillColor: "rgba(249, 115, 22, 0.22)",
},
},
tb_ms_stofcbr: {
layerName: "우수토실",
geometryType: "Point",
allowedTools: ["ADD", "MOVE", "DELETE"],
previewColors: {
pointColor: "rgba(234, 88, 12, 0.92)",
lineColor: "rgba(234, 88, 12, 0.95)",
fillColor: "rgba(234, 88, 12, 0.18)",
},
},
om_dt_untrt_swg_rain: {
layerName: "중점우수토실",
geometryType: "Point",
allowedTools: ["ADD", "MOVE", "DELETE"],
previewColors: {
pointColor: "rgba(220, 38, 38, 0.92)",
lineColor: "rgba(220, 38, 38, 0.95)",
fillColor: "rgba(220, 38, 38, 0.18)",
},
},
};
const FLOW_DIRECTION_SRS = "EPSG:5179";
const SCREEN_ID = "SN_MN010301";
const GIS_EDIT_ALLOWED_LAYER_IDS = new Set(["tb_ms_swage_ppl"]);
const GIS_EDIT_PIPE_ATTRIBUTE_FIELD_IDS = [
"mngInstCd",
"fcltMngNo",
"instMngNo",
"instlYmd",
"mapshtNo",
"pdmtVl",
"prlgVl",
"usgCd",
"mqltCd",
"avgGrdn",
"drtwMltvArea",
"exclnMltvArea",
"tmptDpth",
"tmptMhlMngNo",
"scendDpth",
"scendMhlMngNo",
"trflnCnt",
"vrtcLen",
"wdthLen",
"instlMbdyCd",
"swtrmtZoneNm",
"mltvZoneNm",
"mltvDtdvNm",
"lineCd",
"floCd",
"layCd",
] as const;
const buildPipeSaveAttributes = (basicInfo: GisEditPipeBasicInfo | null | undefined) => {
if (!basicInfo) return undefined;
return GIS_EDIT_PIPE_ATTRIBUTE_FIELD_IDS.reduce<GisEditPipeBasicInfo>((acc, fieldId) => {
if (!Object.prototype.hasOwnProperty.call(basicInfo, fieldId)) return acc;
const value = basicInfo[fieldId];
const normalizedValue = fieldId === "instlYmd" ? String(value ?? "").replaceAll("-", "") : value;
acc[fieldId] =
typeof normalizedValue === "string" && normalizedValue.trim() === ""
? null
: normalizedValue;
return acc;
}, {});
};
export const EMPTY_GIS_EDIT_PIPE_BASIC_INFO: GisEditPipeBasicInfo = {
mngInstCd: "",
fcltMngNo: "",
instMngNo: "",
instlYmd: "",
usgCd: "",
instlMbdyCd: "",
swtrmtZoneNm: "",
mltvZoneNm: "",
mltvDtdvNm: "",
lineCd: "",
floCd: "",
layCd: "",
};
const normalizeLayerIdCandidate = (value: unknown) => {
const raw = String(value ?? "").trim();
if (!raw) return "";
const withoutNamespace = raw.includes(":") ? raw.split(":").pop() ?? raw : raw;
return withoutNamespace.split(".")[0] ?? withoutNamespace;
};
// 현재 레이어 트리 구조 안에서 실제 편집 대상으로 풀 수 있는 레이어만 추출한다.
const getLayerTypeName = (layerInfo: any) =>
String(layerInfo?.lyrTyNm ?? layerInfo?.lyrTypeNm ?? layerInfo?.lyr_type_nm ?? "").trim();
const getLayerName = (layerInfo: any) =>
String(layerInfo?.lyrNm ?? layerInfo?.name ?? "").trim();
const normalizeGeometryType = (value: unknown): GisEditGeometryType => {
const normalized = String(value ?? "").toLowerCase();
if (normalized.includes("polygon")) return "Polygon";
if (normalized.includes("line")) return "LineString";
return "Point";
};
const getColorString = (value: unknown) => {
const resolved = String(value ?? "").trim();
return resolved || null;
};
const withAlpha = (color: string | null | undefined, alpha: number) => {
const resolvedColor = String(color ?? "").trim();
if (!resolvedColor) return null;
const rgbaMatch = resolvedColor.match(/^rgba?\(([^)]+)\)$/i);
if (rgbaMatch) {
const [red = "40", green = "90", blue = "180"] = (rgbaMatch[1] ?? "")
.split(",")
.map((part) => part.trim());
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}
const hexMatch = resolvedColor.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
if (hexMatch) {
const hex = hexMatch[1] ?? "";
const normalized = hex.length === 3
? hex.split("").map((char) => char + char).join("")
: hex;
const red = parseInt(normalized.slice(0, 2), 16);
const green = parseInt(normalized.slice(2, 4), 16);
const blue = parseInt(normalized.slice(4, 6), 16);
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
}
return resolvedColor;
};
const resolvePreviewColors = (
layerInfo: any,
fallback: GisEditPreviewColors,
): GisEditPreviewColors => {
const lineColor =
withAlpha(
getColorString(
layerInfo?.lineColr ??
layerInfo?.line_colr ??
layerInfo?.lineColor ??
layerInfo?.fontInsdColr ??
layerInfo?.font_insd_colr,
),
0.95,
) ?? fallback.lineColor;
const pointColor =
withAlpha(
getColorString(
layerInfo?.lineColr ??
layerInfo?.line_colr ??
layerInfo?.fontInsdColr ??
layerInfo?.font_insd_colr ??
layerInfo?.fontExtnlColr ??
layerInfo?.font_extnl_colr,
),
0.92,
) ?? fallback.pointColor;
const fillColor =
withAlpha(
getColorString(
layerInfo?.spacColr ??
layerInfo?.spac_colr ??
layerInfo?.spaceColor ??
layerInfo?.lineColr ??
layerInfo?.line_colr,
),
0.22,
) ?? fallback.fillColor;
return {
pointColor,
lineColor,
fillColor,
};
};
// 현행 운영 기준상 GIS 편집은 하수관로만 다루므로 treeData에서도 해당 레이어만 추린다.
const collectEditableLayers = (treeData: any[]): GisEditLayerInfo[] => {
const results = new Map<string, GisEditLayerInfo>();
const visit = (items: any[]) => {
items.forEach((item) => {
const data = item?.data ?? item;
const layerTypeName = getLayerTypeName(data);
const layerRule = EDITABLE_LAYER_RULES[layerTypeName];
if (
layerRule &&
GIS_EDIT_ALLOWED_LAYER_IDS.has(layerTypeName) &&
!results.has(layerTypeName)
) {
results.set(layerTypeName, {
layerId: layerTypeName,
layerName: layerRule.layerName,
geometryType: layerRule.geometryType,
editableYn: "Y",
allowedTools: layerRule.allowedTools,
previewColors: resolvePreviewColors(data, layerRule.previewColors),
});
}
if (Array.isArray(item?.children)) {
visit(item.children);
}
if (Array.isArray(data?.children)) {
visit(data.children);
}
});
};
visit(Array.isArray(treeData) ? treeData : []);
if (results.size === 0) {
Object.entries(EDITABLE_LAYER_RULES).forEach(([layerId, layerRule]) => {
if (!GIS_EDIT_ALLOWED_LAYER_IDS.has(layerId)) return;
results.set(layerId, {
layerId,
layerName: layerRule.layerName,
geometryType: layerRule.geometryType,
editableYn: "Y",
allowedTools: layerRule.allowedTools,
previewColors: layerRule.previewColors,
});
});
}
return Array.from(results.values());
};
// 지도에서 선택한 OpenLayers Feature의 현재 geometry를 저장 payload 형태로 스냅샷한다.
const toGeometryObject = (featureData: any) => {
if (!featureData || typeof featureData.getGeometry !== "function") return null;
try {
return new GeoJSON().writeGeometryObject(featureData.getGeometry(), {
featureProjection: FLOW_DIRECTION_SRS,
dataProjection: FLOW_DIRECTION_SRS,
}) as Record<string, unknown>;
} catch (error) {
console.error("GIS 편집 geometry 변환 실패:", error);
return null;
}
};
// 저장 시점의 지도 범위를 함께 남겨 BE가 편집 세션 컨텍스트를 해석할 수 있게 한다.
const buildBbox = (map: any) => {
try {
const size = map?.getSize?.();
const view = map?.getView?.();
if (!size || !view?.calculateExtent) return null;
return view.calculateExtent(size).map((value: number) => value.toFixed(3)).join(",");
} catch (error) {
console.error("GIS 편집 BBOX 계산 실패:", error);
return null;
}
};
const getFeatureLayerId = (featureData: any) => {
if (typeof featureData?.get === "function") {
const previewLayerId = normalizeLayerIdCandidate(featureData.get("layerId"));
if (previewLayerId) return previewLayerId;
}
if (typeof featureData?.getId === "function") {
return normalizeLayerIdCandidate(featureData.getId());
}
if (typeof featureData?.id_ === "string") {
return normalizeLayerIdCandidate(featureData.id_);
}
return normalizeLayerIdCandidate(featureData?.lyrTypeNm ?? featureData?.lyrTyNm ?? "");
};
const stripLayerPrefix = (value: unknown) => {
const raw = String(value ?? "").trim();
if (!raw) return "";
const withoutNamespace = raw.includes(":") ? raw.split(":").pop() ?? raw : raw;
if (withoutNamespace.startsWith("tb_ms_swage_ppl.")) {
return withoutNamespace.slice("tb_ms_swage_ppl.".length);
}
return withoutNamespace;
};
const getFeatureFid = (featureData: any) =>
stripLayerPrefix(
featureData?.get?.("fid") ??
featureData?.values_?.fid ??
featureData?.fid ??
featureData?.getId?.() ??
featureData?.id_ ??
"",
);
const getFeatureObjectId = (featureData: any) =>
String(
getFeatureFid(featureData) ||
String(
featureData?.get?.("objectId") ??
featureData?.values_?.fclt_mng_no ??
featureData?.values_?.swtrmt_fclt_cd ??
featureData?.fcltMngNo ??
featureData?.fid ??
"",
),
).trim();
const getFeatureObjectLabel = (featureData: any) => {
const values = featureData?.values_ ?? {};
return String(
featureData?.get?.("fclt_mng_no") ??
values?.fclt_mng_no ??
featureData?.fcltMngNo ??
featureData?.get?.("objectId") ??
values?.swtrmt_fclt_cd ??
getFeatureFid(featureData) ??
"",
).trim();
};
const getFeatureRef = (featureData: any): GisEditFeatureRef | undefined => {
const mapValues = featureData?.values_ ?? {};
const featureRef: GisEditFeatureRef = {
fcltMngNo: String(
featureData?.get?.("fclt_mng_no") ??
mapValues?.fclt_mng_no ??
featureData?.fcltMngNo ??
"",
).trim() || undefined,
mngInstCd: String(
featureData?.get?.("mng_inst_cd") ??
mapValues?.mng_inst_cd ??
featureData?.mngInstCd ??
"",
).trim() || undefined,
fid: getFeatureFid(featureData) || undefined,
};
if (!featureRef.fcltMngNo && !featureRef.mngInstCd && !featureRef.fid) {
return undefined;
}
return featureRef;
};
// 선택한 원본 관로의 속성 snapshot을 보관해 preview 재선택 시 상세정보가 비지 않게 한다.
const getFeatureProperties = (featureData: any): Record<string, unknown> | null => {
const values = featureData?.values_ ?? featureData?.getProperties?.() ?? featureData;
if (!values || typeof values !== "object") return null;
const { geometry, ...properties } = values;
return properties;
};
const normalizePipeGeometry = (
layerId: string,
geometry: Record<string, unknown> | null,
): Record<string, unknown> | null => {
if (layerId !== "tb_ms_swage_ppl" || !geometry) return geometry;
if (geometry.type !== "LineString") return geometry;
const coordinates = geometry.coordinates;
if (!Array.isArray(coordinates)) return geometry;
return {
...geometry,
type: "MultiLineString",
coordinates: [coordinates],
};
};
const createChangeId = (prefix: string, sequence: number) => {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return `${prefix}-${crypto.randomUUID()}`;
}
return `${prefix}-${Date.now()}-${sequence}`;
};
export const useGisEdit = (treeData: any[]) => {
const editableLayers = useMemo(() => collectEditableLayers(treeData), [treeData]);
// GIS 편집 모드 on/off는 우측 분석 메뉴의 GIS 편집 버튼과 직접 연결된다.
@@ -0,0 +1,76 @@
/** 하수관로 GIS 편집에 필요한 공통코드를 조회하는 훅이다. */
"use client";
import { useEffect, useState } from "react";
import { getCommonCodes } from "@/components/lib/common/apiHelpers";
import type { GisEditTool } from "./gisEditTypes";
const GIS_EDIT_OPERATION_UP_CD = "GISEDITOPRT";
/** CMS 조회 실패 시 기존 GIS 편집 화면 동작과 문구를 유지하기 위한 최소 fallback이다. */
export const DEFAULT_GIS_EDIT_OPERATION_LABELS: Record<GisEditTool, string> = {
ADD: "추가",
MOVE: "이동",
VERTEX_EDIT: "꼭지점편집",
DELETE: "삭제",
};
let cachedOperationLabels: Record<GisEditTool, string> | null = null;
let operationLabelsPromise: Promise<Record<GisEditTool, string>> | null = null;
const isGisEditTool = (value: string): value is GisEditTool =>
value === "ADD" ||
value === "MOVE" ||
value === "VERTEX_EDIT" ||
value === "DELETE";
/** 같은 브라우저 세션에서는 GIS 편집작업코드를 한 번만 조회해 툴바와 세션이 공유한다. */
const loadGisEditOperationLabels = async () => {
if (cachedOperationLabels) return cachedOperationLabels;
if (!operationLabelsPromise) {
operationLabelsPromise = getCommonCodes(GIS_EDIT_OPERATION_UP_CD)
.then((codes) => {
const labels = { ...DEFAULT_GIS_EDIT_OPERATION_LABELS };
codes.forEach((code) => {
if (isGisEditTool(code.lwpoCd) && code.lwpoCdNm) {
labels[code.lwpoCd] = code.lwpoCdNm;
}
});
cachedOperationLabels = labels;
return labels;
})
.finally(() => {
operationLabelsPromise = null;
});
}
return operationLabelsPromise;
};
/** GIS 편집 도구 코드값은 유지하고 사용자 표시명만 CMS 공통코드로 교체한다. */
export const useGisEditOperationLabels = () => {
const [operationLabels, setOperationLabels] = useState<
Record<GisEditTool, string>
>(cachedOperationLabels ?? DEFAULT_GIS_EDIT_OPERATION_LABELS);
useEffect(() => {
let cancelled = false;
loadGisEditOperationLabels()
.then((labels) => {
if (!cancelled) setOperationLabels(labels);
})
.catch((error) => {
console.error("GIS 편집작업 공통코드 조회에 실패했습니다.", error);
});
return () => {
cancelled = true;
};
}, []);
return operationLabels;
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,577 @@
/**
* 화면명: GIS 업로드 미리보기 지도
* 업로드한 공간정보를 지도에 미리 표시하고 검증 결과를 확인한다.
*/
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { FeatureLike } from "ol/Feature";
import GeoJSON from "ol/format/GeoJSON";
import Map from "ol/Map";
import View from "ol/View";
import { Stroke, Style } from "ol/style";
import TileLayer from "ol/layer/Tile";
import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector";
import TileWMTS from "ol/source/WMTS";
import WMTSTileGrid from "ol/tilegrid/WMTS";
import { getTopLeft } from "ol/extent";
import { get as getProjection } from "ol/proj";
import { register } from "ol/proj/proj4";
import proj4 from "proj4";
import { getRawJson } from "@/components/lib/api";
type GisUploadPreviewMapProps = {
uploadId: string;
mngInstCd?: string | null;
};
type LayerSetting = {
id?: string;
lyrSn?: string;
lyrNm?: string;
lyrTyNm?: string;
lyrTypeNm?: string;
lyr_type_nm?: string;
flterSql?: string | null;
minViewSize?: string | null;
minZoom?: string | null;
lnColor?: string | null;
lineColr?: string | null;
lnWdth?: string | null;
lineWdth?: string | null;
drawOrdr?: string | null;
exprsYn?: string | null;
useYn?: string | null;
actlLyrYn?: string | null;
children?: LayerSetting[];
};
const MAP_SRS = "EPSG:5179";
const INITIAL_CENTER_5179 = [965000, 1900000];
const INITIAL_ZOOM = 8;
const FALLBACK_PIPE_MIN_ZOOM = 13;
// OpenLayers는 minZoom 경계값에서 레이어를 숨기므로 자동 포커스는 임계값보다 소폭 크게 설정한다.
const MIN_ZOOM_VISIBILITY_OFFSET = 0.01;
const NETWORK_LAYER_GROUP = "SN_NETWORK";
const PIPE_LAYER_TYPE = "tb_ms_swage_ppl";
const NGII_WMTS_URL = `/ngii-server/openapi/Gettile.do?apikey=${process.env.NEXT_PUBLIC_NGII_PARAM ?? ""}`;
const NGII_RESOLUTIONS = [
1044.48, 522.24, 261.12, 130.56, 65.28, 32.64, 16.32, 8.16, 4.08, 2.04, 1.02,
0.51, 0.255,
];
const NGII_MATRIX_IDS = [
"L06",
"L07",
"L08",
"L09",
"L10",
"L11",
"L12",
"L13",
"L14",
"L15",
"L16",
"L17",
"L18",
];
let isProjectionRegistered = false;
const registerMapProjection = () => {
if (isProjectionRegistered) return;
proj4.defs(
MAP_SRS,
"+proj=tmerc +lat_0=38 +lon_0=127.5 +k=0.9996 +x_0=1000000 +y_0=2000000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs",
);
register(proj4);
const projection = getProjection(MAP_SRS);
projection?.setExtent([-200000.0, -28024123.62, 31824123.62, 4000000.0]);
isProjectionRegistered = true;
};
const createNgiiBaseLayer = () => {
registerMapProjection();
const projection = getProjection(MAP_SRS);
const tileGrid = new WMTSTileGrid({
origin: getTopLeft(
projection?.getExtent() ?? [
-200000.0, -28024123.62, 31824123.62, 4000000.0,
],
),
resolutions: NGII_RESOLUTIONS,
matrixIds: NGII_MATRIX_IDS,
tileSize: [256, 256],
});
const source = new TileWMTS({
projection: MAP_SRS,
tileGrid,
crossOrigin: "anonymous",
requestEncoding: "KVP",
layer: "korean_map",
matrixSet: MAP_SRS,
format: "image/png",
style: "korean",
});
source.setTileUrlFunction((tileCoord) => {
const [z, x, y] = tileCoord;
return NGII_WMTS_URL.replace(
"{z}",
NGII_MATRIX_IDS[z ?? 0] ?? NGII_MATRIX_IDS[0] ?? "L06",
)
.replace("{x}", String(x))
.replace("{y}", String(y));
});
return new TileLayer({
source,
zIndex: 0,
});
};
const flattenLayerSettings = (items: LayerSetting[] = []): LayerSetting[] =>
items.flatMap((item) => [item, ...flattenLayerSettings(item.children ?? [])]);
const getLayerTypeName = (layer: LayerSetting) =>
String(layer.lyrTyNm ?? layer.lyrTypeNm ?? layer.lyr_type_nm ?? "").trim();
const toFiniteNumber = (value: unknown, fallback: number) => {
if (value === null || value === undefined || String(value).trim() === "")
return fallback;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
};
const normalizeColor = (value: unknown, fallback: string) => {
const color = String(value ?? "").trim();
return color || fallback;
};
const getLineColor = (layer?: LayerSetting | null) =>
normalizeColor(layer?.lnColor ?? layer?.lineColr, "#f97316");
const getLayerMinZoom = (
layer: LayerSetting | null | undefined,
fallback: number,
) => toFiniteNumber(layer?.minViewSize ?? layer?.minZoom, fallback);
const getNetworkPipeLineWidth = (layer?: LayerSetting | null) =>
Math.max(1, toFiniteNumber(layer?.lnWdth ?? layer?.lineWdth, 1));
const getHighlightedPipeLineWidth = (layer?: LayerSetting | null) =>
Math.max(3, getNetworkPipeLineWidth(layer) + 2) + 3;
const pipePreviewStyle =
(layerSetting?: LayerSetting | null) => (feature: FeatureLike) => {
const status = feature.get("previewStatus");
if (status !== "CREATED" && status !== "UPDATED") {
return new Style({
stroke: new Stroke({
color: getLineColor(layerSetting),
width: getNetworkPipeLineWidth(layerSetting),
}),
});
}
const baseColor = status === "CREATED" ? "#16a34a" : "#f97316";
const width = getHighlightedPipeLineWidth(layerSetting);
return [
new Style({
stroke: new Stroke({
color: "#ffffff",
width: width + 3,
}),
}),
new Style({
stroke: new Stroke({
color: baseColor,
width,
}),
}),
];
};
const unwrapFeatureCollection = (response: any) => response?.data ?? response;
const refreshMapSize = (map: Map) => {
window.requestAnimationFrame(() => {
map.updateSize();
});
};
const getFeatureExtent = (features: ReturnType<GeoJSON["readFeatures"]>) => {
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
features.forEach((feature) => {
const geometry = feature.getGeometry();
if (!geometry) return;
const extent = geometry.getExtent();
minX = Math.min(minX, extent[0]!);
minY = Math.min(minY, extent[1]!);
maxX = Math.max(maxX, extent[2]!);
maxY = Math.max(maxY, extent[3]!);
});
if (![minX, minY, maxX, maxY].every(Number.isFinite)) {
return null;
}
return [minX, minY, maxX, maxY] as [number, number, number, number];
};
const getFeatureExtentCenter = (
features: ReturnType<GeoJSON["readFeatures"]>,
) => {
const extent = getFeatureExtent(features);
if (!extent) return null;
const targetCenter: [number, number] = [
(extent[0] + extent[2]) / 2,
(extent[1] + extent[3]) / 2,
];
return features.reduce<[number, number] | null>((nearestCenter, feature) => {
const featureExtent = feature.getGeometry()?.getExtent();
if (!featureExtent) return nearestCenter;
const featureCenter: [number, number] = [
(featureExtent[0]! + featureExtent[2]!) / 2,
(featureExtent[1]! + featureExtent[3]!) / 2,
];
if (!nearestCenter) return featureCenter;
const nearestDistance =
(nearestCenter[0] - targetCenter[0]) ** 2 +
(nearestCenter[1] - targetCenter[1]) ** 2;
const featureDistance =
(featureCenter[0] - targetCenter[0]) ** 2 +
(featureCenter[1] - targetCenter[1]) ** 2;
return featureDistance < nearestDistance ? featureCenter : nearestCenter;
}, null);
};
export default function GisUploadPreviewMap({
uploadId,
mngInstCd,
}: GisUploadPreviewMapProps) {
const mapElementRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<Map | null>(null);
const pipeSourceRef = useRef(new VectorSource());
const pipeLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
const abortControllerRef = useRef<AbortController | null>(null);
const loadTimerRef = useRef<number | null>(null);
const didFitPreviewRef = useRef(false);
const [layerSettings, setLayerSettings] = useState<LayerSetting[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [featureCount, setFeatureCount] = useState(0);
const [deletedCount, setDeletedCount] = useState(0);
const [truncated, setTruncated] = useState(false);
const [message, setMessage] = useState("");
const flatLayerSettings = useMemo(
() => flattenLayerSettings(layerSettings),
[layerSettings],
);
const pipeLayerSetting = useMemo(
() =>
flatLayerSettings.find(
(layer) => getLayerTypeName(layer) === PIPE_LAYER_TYPE,
) ?? null,
[flatLayerSettings],
);
const pipeMinZoom = getLayerMinZoom(pipeLayerSetting, FALLBACK_PIPE_MIN_ZOOM);
useEffect(() => {
let mounted = true;
const loadLayerSettings = async () => {
try {
const networkSettings = await getRawJson(
`/sewerage-service/api/v1/map/lyrStngs/${NETWORK_LAYER_GROUP}`,
);
const filtered = flattenLayerSettings(
Array.isArray(networkSettings) ? networkSettings : [],
)
.filter((layer) => getLayerTypeName(layer) === PIPE_LAYER_TYPE)
.filter(
(layer) =>
(layer.useYn ?? "Y") === "Y" &&
(layer.exprsYn ?? "Y") === "Y" &&
(layer.actlLyrYn ?? "Y") === "Y",
);
if (mounted) {
setLayerSettings(filtered);
}
} catch (error) {
console.error("GIS 업로드 미리보기 레이어 설정 조회 실패:", error);
if (mounted) {
setLayerSettings([]);
}
}
};
void loadLayerSettings();
return () => {
mounted = false;
};
}, []);
useEffect(() => {
let disposed = false;
let initializeTimer: number | null = null;
const initializeMap = () => {
const mapElement = mapElementRef.current;
if (disposed || mapRef.current) return;
if (
!mapElement ||
mapElement.clientWidth <= 0 ||
mapElement.clientHeight <= 0
) {
initializeTimer = window.setTimeout(initializeMap, 100);
return;
}
registerMapProjection();
const pipeLayer = new VectorLayer({
source: pipeSourceRef.current,
style: pipePreviewStyle(pipeLayerSetting),
minZoom: pipeMinZoom,
zIndex: toFiniteNumber(pipeLayerSetting?.drawOrdr, 300),
});
const map = new Map({
target: mapElement,
layers: [createNgiiBaseLayer(), pipeLayer],
view: new View({
center: INITIAL_CENTER_5179,
zoom: INITIAL_ZOOM,
projection: MAP_SRS,
}),
controls: [],
});
pipeLayerRef.current = pipeLayer;
mapRef.current = map;
refreshMapSize(map);
};
initializeMap();
return () => {
disposed = true;
if (initializeTimer) {
window.clearTimeout(initializeTimer);
}
abortControllerRef.current?.abort();
if (loadTimerRef.current) {
window.clearTimeout(loadTimerRef.current);
}
mapRef.current?.setTarget(undefined);
mapRef.current = null;
pipeLayerRef.current = null;
pipeSourceRef.current.clear();
};
}, []);
useEffect(() => {
pipeLayerRef.current?.setStyle(pipePreviewStyle(pipeLayerSetting));
pipeLayerRef.current?.setMinZoom(pipeMinZoom);
}, [pipeLayerSetting, pipeMinZoom]);
useEffect(() => {
const map = mapRef.current;
const mapElement = mapElementRef.current;
if (!map || !mapElement) return;
const resizeObserver = new ResizeObserver(() => {
refreshMapSize(map);
});
resizeObserver.observe(mapElement);
const updateTimer = window.setTimeout(() => {
map.updateSize();
}, 0);
return () => {
window.clearTimeout(updateTimer);
resizeObserver.disconnect();
};
}, []);
useEffect(() => {
didFitPreviewRef.current = false;
}, [mngInstCd, uploadId]);
const loadPreview = useCallback(
async function loadPreviewRequest() {
const map = mapRef.current;
if (!map || !uploadId) return;
map.updateSize();
let size = map.getSize();
if (!size) {
map.updateSize();
size = map.getSize();
}
if (!size || size[0]! <= 0 || size[1]! <= 0) {
if (loadTimerRef.current) {
window.clearTimeout(loadTimerRef.current);
}
loadTimerRef.current = window.setTimeout(() => {
void loadPreviewRequest();
}, 150);
return;
}
const extent = map.getView().calculateExtent(size);
const previewParams = new URLSearchParams({
bbox: extent.join(","),
srs: MAP_SRS,
});
if (mngInstCd) {
previewParams.set("mngInstCd", mngInstCd);
}
abortControllerRef.current?.abort();
const abortController = new AbortController();
abortControllerRef.current = abortController;
setIsLoading(true);
setMessage("");
pipeSourceRef.current.clear();
setFeatureCount(0);
setDeletedCount(0);
setTruncated(false);
try {
const previewResponse = await getRawJson(
`v1/map/gisUploads/${encodeURIComponent(uploadId)}/preview?${previewParams.toString()}`,
{ signal: abortController.signal },
);
const previewCollection = unwrapFeatureCollection(previewResponse);
const pipeFeatures = new GeoJSON({
dataProjection: MAP_SRS,
featureProjection: MAP_SRS,
}).readFeatures(previewCollection);
pipeSourceRef.current.addFeatures(pipeFeatures);
if (!didFitPreviewRef.current && pipeFeatures.length > 0) {
const changedFeatures = pipeFeatures.filter(
(feature) => feature.get("previewStatus") !== "LATEST",
);
const center = getFeatureExtentCenter(
changedFeatures.length > 0 ? changedFeatures : pipeFeatures,
);
if (center) {
map.getView().setCenter(center);
map.getView().setZoom(pipeMinZoom + MIN_ZOOM_VISIBILITY_OFFSET);
didFitPreviewRef.current = true;
}
}
setFeatureCount(pipeFeatures.length);
setDeletedCount(Number(previewCollection?.meta?.deletedCount ?? 0));
setTruncated(Boolean(previewCollection?.meta?.truncated));
} catch (error: any) {
if (error?.name === "AbortError") return;
console.error("GIS 업로드 미리보기 조회 실패:", error);
setMessage("미리보기 데이터를 불러오지 못했습니다.");
} finally {
if (!abortController.signal.aborted) {
setIsLoading(false);
}
}
},
[mngInstCd, pipeMinZoom, uploadId],
);
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const scheduleLoad = () => {
if (loadTimerRef.current) {
window.clearTimeout(loadTimerRef.current);
}
loadTimerRef.current = window.setTimeout(() => {
void loadPreview();
}, 250);
};
map.on("moveend", scheduleLoad);
scheduleLoad();
return () => {
map.un("moveend", scheduleLoad);
};
}, [loadPreview]);
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-2xl font-medium text-label">
업로드 이력 미리보기
</h3>
<p className="mt-1 text-[1rem] text-label">
변경 관로는 업로드 이력 기준으로 강조하고, 변경 없는 관로는 관망도와
동일한 스타일로 표시합니다.
</p>
</div>
</div>
<div className="mb-2 flex flex-wrap gap-3 text-[0.95rem] text-label">
<span>
<b className="text-green-600">신규</b> /{" "}
<b className="text-orange-500">수정</b> /{" "}
<b style={{ color: getLineColor(pipeLayerSetting) }}>관망도 기본</b>
</span>
<span>관로 표출 줌 {pipeMinZoom} 이상</span>
<span>관로 {featureCount.toLocaleString("ko-KR")}건</span>
<span>삭제 미표시 {deletedCount.toLocaleString("ko-KR")}건</span>
{truncated ? (
<span className="text-red-600">조회 상한 초과</span>
) : null}
{isLoading ? <span>불러오는 중...</span> : null}
</div>
{message ? (
<div className="mb-2 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-[1rem] text-red-700">
{message}
</div>
) : null}
<div
ref={mapElementRef}
className="gis-upload-preview-map min-h-0 flex-1 overflow-hidden rounded-md border border-border bg-secondary"
/>
<style jsx global>{`
.gis-upload-preview-map {
position: relative;
}
.gis-upload-preview-map .ol-viewport {
position: relative;
overflow: hidden;
width: 100%;
height: 100%;
touch-action: none;
}
.gis-upload-preview-map .ol-unselectable {
user-select: none;
}
.gis-upload-preview-map .ol-layers,
.gis-upload-preview-map .ol-layer {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.gis-upload-preview-map canvas {
display: block;
}
`}</style>
</div>
);
}
@@ -0,0 +1,128 @@
/** GIS 관리 화면에 필요한 공통코드를 조회하고 선택 항목으로 제공한다. */
"use client";
import { useEffect, useState } from "react";
import { getCommonCodes } from "@/components/lib/common/apiHelpers";
type CodeLabelMap = Record<string, string>;
const COMMON_CODE_GROUPS = {
uploadStatus: "GISULDSTTS",
downloadStatus: "GISDWNLDSTTS",
facilityType: "SNFCLTSE",
} as const;
const DEFAULT_UPLOAD_STATUS_LABELS: CodeLabelMap = {
COMPLETED: "업로드완료",
VALIDATION_FAILED: "검증실패",
FAILED: "업로드실패",
};
const DEFAULT_DOWNLOAD_STATUS_LABELS: CodeLabelMap = {
QUEUED: "작업대기",
RUNNING: "파일생성중",
READY: "다운로드준비완료",
FAILED: "파일생성실패",
};
const DEFAULT_FACILITY_TYPE_LABELS: CodeLabelMap = {
PPL: "하수관로",
MNHL: "맨홀",
};
const cachedLabelMaps = new Map<string, CodeLabelMap>();
const pendingLabelMaps = new Map<string, Promise<CodeLabelMap>>();
/** 같은 브라우저 세션에서 각 GIS 관리 코드그룹을 한 번만 조회한다. */
const loadCodeLabelMap = async (
upCd: string,
fallback: CodeLabelMap,
): Promise<CodeLabelMap> => {
const cached = cachedLabelMaps.get(upCd);
if (cached) return cached;
const pending = pendingLabelMaps.get(upCd);
if (pending) return pending;
const request = getCommonCodes(upCd)
.then((codes) => {
const labels = { ...fallback };
codes.forEach((code) => {
if (code.lwpoCd && code.lwpoCdNm) {
labels[code.lwpoCd] = code.lwpoCdNm;
}
});
cachedLabelMaps.set(upCd, labels);
return labels;
})
.finally(() => {
pendingLabelMaps.delete(upCd);
});
pendingLabelMaps.set(upCd, request);
return request;
};
/** GIS 관리 화면의 코드값은 유지하고 상태·시설구분 표시명만 CMS 기준으로 제공한다. */
export const useGisManagementCommonCodes = () => {
const [uploadStatusLabels, setUploadStatusLabels] =
useState<CodeLabelMap>(
cachedLabelMaps.get(COMMON_CODE_GROUPS.uploadStatus) ??
DEFAULT_UPLOAD_STATUS_LABELS,
);
const [downloadStatusLabels, setDownloadStatusLabels] =
useState<CodeLabelMap>(
cachedLabelMaps.get(COMMON_CODE_GROUPS.downloadStatus) ??
DEFAULT_DOWNLOAD_STATUS_LABELS,
);
const [facilityTypeLabels, setFacilityTypeLabels] =
useState<CodeLabelMap>(
cachedLabelMaps.get(COMMON_CODE_GROUPS.facilityType) ??
DEFAULT_FACILITY_TYPE_LABELS,
);
useEffect(() => {
let cancelled = false;
const applyLabels = (
upCd: string,
fallback: CodeLabelMap,
setter: (labels: CodeLabelMap) => void,
) => {
loadCodeLabelMap(upCd, fallback)
.then((labels) => {
if (!cancelled) setter(labels);
})
.catch((error) => {
console.error(`${upCd} 공통코드 조회에 실패했습니다.`, error);
});
};
applyLabels(
COMMON_CODE_GROUPS.uploadStatus,
DEFAULT_UPLOAD_STATUS_LABELS,
setUploadStatusLabels,
);
applyLabels(
COMMON_CODE_GROUPS.downloadStatus,
DEFAULT_DOWNLOAD_STATUS_LABELS,
setDownloadStatusLabels,
);
applyLabels(
COMMON_CODE_GROUPS.facilityType,
DEFAULT_FACILITY_TYPE_LABELS,
setFacilityTypeLabels,
);
return () => {
cancelled = true;
};
}, []);
return {
uploadStatusLabels,
downloadStatusLabels,
facilityTypeLabels,
};
};
@@ -1,14 +1,30 @@
/**
* 화면명: 침입수 분석결과 목록
* 침입수 분석결과를 검색하고 목록·엑셀·지도 보기 기능을 제공한다.
*/
"use client";
import { useCallback, useEffect, useState } from "react";
import { DateMonthPicker, UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
import { DateMonthPicker } from "@common/business_components/ui/date_month_picker";
import {
StandardPopupLayout,
type UiHeadProps,
type UiTableSort,
} from "@common/business_components";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
import { useAlertStore } from "@/shared/lib";
import { Button } from "@common/components";
import { getData } from "@/components/lib/api";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { appendApiSearchParams } from "@/shared/util/util";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
import useStore from "@/store/useStore";
import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import LoadingOverlay from "@/components/ui/LoadingBar";
import {
INFTWT_RESULT_ROW_SELECT_MESSAGE,
@@ -18,18 +34,23 @@ import {
const INFTWT_RESULT_PAGE_SIZE = 10;
const INFTWT_RESULT_HEADLIST = [
{ id: "rowNumber", title: "일련번호" },
const INFTWT_RESULT_HEADLIST: UiHeadProps[] = [
{
id: "rowNumber",
title: "순번",
sortable: true,
sortKey: "inftwtAnlsSn",
},
{ id: "ctpvNm", title: "시도" },
{ id: "sggNm", title: "시군구" },
{ id: "swtrmtFcltNm", title: "하수처리시설" },
{ id: "aplcnYm", title: "적용연월" },
{ id: "drtwSwtcRt", title: "오수 전환율" },
{ id: "anlsInfo", title: "침입수 분석정보" },
{ id: "regDt", title: "분석일자" },
{ id: "swtrmtFcltNm", title: "하수처리시설", sortable: true },
{ id: "aplcnYm", title: "적용연월", sortable: true },
{ id: "drtwSwtcRt", title: "오수 전환율", sortable: true },
{ id: "anlsInfo", title: "침입수 분석정보", sortable: true },
{ id: "regDt", title: "분석일자", sortable: true },
{ id: "rgtrNm", title: "분석자" },
{ id: "scsYn", title: "분석성공여부" },
] as const;
{ id: "scsYn", title: "분석성공여부", sortable: true },
];
type InftwtResultFilters = {
ctpvCd: string;
@@ -82,6 +103,7 @@ const postRowSelectMessage = (row: InftwtResultRow) => {
};
export default function InftwtResultPopupPage() {
const alert = useAlertStore((state) => state.setMessage);
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const [filters, setFilters] = useState<InftwtResultFilters>(DEFAULT_FILTERS);
const [selectedSwagePrcsFclt, setSelectedSwagePrcsFclt] = useState("0");
@@ -89,7 +111,7 @@ export default function InftwtResultPopupPage() {
const [rows, setRows] = useState<InftwtResultRow[]>([]);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [sort, setSort] = useState<UiTableSort>();
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
@@ -98,31 +120,49 @@ export default function InftwtResultPopupPage() {
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
const fetchRows = useCallback(
async (page: number, nextFilters: InftwtResultFilters) => {
async (
page: number,
nextFilters: InftwtResultFilters,
nextSort: UiTableSort | undefined = sort,
) => {
const queryParams = new URLSearchParams({
page: String(page - 1),
size: String(INFTWT_RESULT_PAGE_SIZE),
sort: "inftwtAnlsSn,desc",
scsYn: "Y",
});
queryParams.append(
"sort",
nextSort
? `${nextSort.key},${nextSort.direction}`
: "inftwtAnlsSn,desc",
);
if (nextFilters.aplcnYmFrom) queryParams.set("aplcnYmFrom", nextFilters.aplcnYmFrom);
if (nextFilters.aplcnYmTo) queryParams.set("aplcnYmTo", nextFilters.aplcnYmTo);
if (nextFilters.ctpvCd !== "0") queryParams.set("ctpvCd", nextFilters.ctpvCd);
if (nextFilters.sggCd !== "0") queryParams.set("sggCd", nextFilters.sggCd);
if (nextFilters.swtrmtFcltNm.trim()) queryParams.set("swtrmtFcltNm", nextFilters.swtrmtFcltNm.trim());
appendApiSearchParams(queryParams, {
aplcnYmFrom: nextFilters.aplcnYmFrom,
aplcnYmTo: nextFilters.aplcnYmTo,
ctpvCd: nextFilters.ctpvCd,
sggCd: nextFilters.sggCd,
swtrmtFcltNm: nextFilters.swtrmtFcltNm,
});
const response = await getData(`v1/inftwtAnls?${queryParams.toString()}`);
const content = Array.isArray(response?.content) ? response.content : [];
const totalElements = Number(response?.totalElements ?? content.length);
const nextRows = content
.map((item: Record<string, unknown>, index: number) => {
const formattedItem = formatDisplayData(item);
const ctpvOption = ctpv.find((code) => code.val.trim() === String(item?.ctpvCd ?? "").trim());
const ctpvOption = ctpv.find(
(code) => code.val.trim() === String(item?.ctpvCd ?? "").trim(),
);
const sggOption = sgg.find((code) => code.val === item?.sggCd);
return {
...(formattedItem as Record<string, unknown>),
rowNumber: (page - 1) * INFTWT_RESULT_PAGE_SIZE + index + 1,
rowNumber: Math.max(
totalElements - ((page - 1) * INFTWT_RESULT_PAGE_SIZE + index),
0,
),
ctpvNm: ctpvOption ? ctpvOption.text : String(item?.ctpvCd ?? "-"),
sggNm: sggOption ? sggOption.text : String(item?.sggCd ?? "-"),
scsYn: String(item?.scsYn ?? "N"),
@@ -131,17 +171,21 @@ export default function InftwtResultPopupPage() {
.filter((item: InftwtResultRow) => item.scsYn === "Y");
setRows(nextRows);
setTotal(Number(response?.totalElements ?? nextRows.length));
setTotal(totalElements);
setCurrentPage(page);
},
[ctpv, sgg],
[ctpv, sgg, sort],
);
const loadRows = useCallback(
async (page = 1, nextFilters = filters) => {
async (
page = 1,
nextFilters = filters,
nextSort: UiTableSort | undefined = sort,
) => {
setIsLoading(true);
try {
await fetchRows(page, nextFilters);
await fetchRows(page, nextFilters, nextSort);
} catch (error) {
console.error("침입수 분석결과 목록 조회 중 오류:", error);
alert("침입수 분석결과 목록을 불러오지 못했습니다.");
@@ -151,26 +195,40 @@ export default function InftwtResultPopupPage() {
setIsLoading(false);
}
},
[fetchRows, filters],
[alert, fetchRows, filters, sort],
);
useEffect(() => {
void loadRows(1, DEFAULT_FILTERS);
void loadRows(1, DEFAULT_FILTERS, undefined);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSearch = async (page = 1) => {
setSelectedIndex(-1);
setSelectedRow(null);
await loadRows(page, filters);
};
/** CS 검색 카드와 동일하게 조건과 선택 결과를 기본값으로 되돌린다. */
const handleReset = async () => {
setFilters(DEFAULT_FILTERS);
setSelectedSwagePrcsFclt("0");
setSelectedRow(null);
setSort(undefined);
await loadRows(1, DEFAULT_FILTERS, undefined);
};
/** 정렬 변경 시 선택 결과를 해제하고 서버 첫 페이지를 조회한다. */
const handleSortChange = (nextSort?: UiTableSort) => {
setSort(nextSort);
setSelectedRow(null);
void loadRows(1, filters, nextSort);
};
const handleRowClick = (index?: number) => {
if (typeof index !== "number") return;
const row = rows[index];
if (!row) return;
setSelectedIndex(index);
setSelectedRow(row);
postRowSelectMessage(row);
};
@@ -183,16 +241,24 @@ export default function InftwtResultPopupPage() {
try {
setIsLoading(true);
const response = await getData(`v1/inftwtAnlsRslt/ppl/${selectedRow.inftwtAnlsSn}`);
const excelRows = Array.isArray(response?.content) ? response.content : [];
const response = await fetchAllPages<any, Record<string, unknown>>({
fetchPage: (page, size) =>
getData(
`v1/inftwtAnlsRslt/ppl/${selectedRow.inftwtAnlsSn}?page=${page}&size=${size}`,
),
getItems: (page) =>
Array.isArray(page?.content) ? page.content : [],
getTotal: (page) => Number(page?.totalElements ?? 0),
});
const excelRows = response.items;
await downloadExcelWithKoreanHeaders({
data: excelRows as JSON[],
data: excelRows as unknown as JSON[],
fileName: `침입수분석결과_${selectedRow.swtrmtFcltNm}_${selectedRow.aplcnYm}_${selectedRow.inftwtAnlsSn}`,
sheetNameArray: ["침입수 분석결과"],
headerRange: "A1:D1",
headerMapping: {
inftwtAnlsSn: "침입수분석\n일련번호",
inftwtAnlsSn: "침입수분석\n분석번호",
pplMngNo: "관로\n관리번호",
anlsTm: "분석시각",
predcOilq: "예측유량\n(m³/h)",
@@ -207,139 +273,140 @@ export default function InftwtResultPopupPage() {
};
return (
<main className="sn-popup-page">
<StandardPopupLayout
title={windowPopupDefinitions.inftwtResultPopup.title}
bodyClassName="overflow-x-hidden"
>
<LoadingOverlay isLoading={isLoading} />
<header className="sn-popup-header">
<h1 className="sn-popup-title">침입수 분석결과</h1>
</header>
<section className="sn-popup-section overflow-y-auto">
<div className="rounded-md border border-border bg-secondary p-4">
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.05rem] font-medium text-label">상수사용기간</span>
<DateMonthPicker
field={{
value: filters.aplcnYmFrom,
onChange: (value: string) => {
setFilters((current) => ({
...current,
aplcnYmFrom: value,
}));
},
}}
selectClass="w-[145px]"
startYear={1900}
/>
<span className="text-[1.1rem] font-semibold text-muted-foreground">~</span>
<DateMonthPicker
field={{
value: filters.aplcnYmTo,
onChange: (value: string) => {
setFilters((current) => ({
...current,
aplcnYmTo: value,
}));
},
}}
selectClass="w-[145px]"
startYear={1900}
/>
</div>
<div className="min-w-0 flex-1 overflow-x-auto">
<CtpvSggPrcsFcltSelectBox
sido={filters.ctpvCd}
sigungu={filters.sggCd}
sggName={filters.sggName}
onSidoChange={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
ctpvCd: value,
sggCd: "0",
sggName: "",
swtrmtFcltNm: "",
}));
}}
onSigunguChange={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
sggCd: value,
swtrmtFcltNm: "",
}));
}}
setSggName={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
sggName: value ?? "",
swtrmtFcltNm: "",
}));
}}
selectedSwagePrcsFclt={selectedSwagePrcsFclt}
onSwagePrcsFcltChange={setSelectedSwagePrcsFclt}
setSwtrmtFcltNm={(value) =>
setFilters((current) => ({
...current,
swtrmtFcltNm: value ?? "",
}))
}
setIsLoading={setIsLoading}
/>
</div>
<div className="ml-auto flex items-center gap-2">
<Button
type="button"
size="sm"
color="primary"
className="h-10 px-5 !text-[1.1rem]"
onClick={() => void handleSearch(1)}
>
조회
</Button>
<Button
type="button"
color="green"
size="sm"
className="h-10 px-5 !text-[1.1rem]"
onClick={() => void handleDownload()}
>
다운로드
</Button>
</div>
</div>
</div>
<div className="mt-4">
<UiTable<InftwtResultRow>
headName={<>{`분석이력 총 ${total.toLocaleString()}건`}</>}
tableData={rows}
headlist={INFTWT_RESULT_HEADLIST as unknown as { id: string; title: string }[]}
pageSize={INFTWT_RESULT_PAGE_SIZE}
total={total}
cellClick={(index) => handleRowClick(index)}
cellClassName={(_, headId) =>
headId === "anlsInfo"
? "whitespace-normal break-all leading-[1.35]"
: ""
}
onPageChange={(page) => void handleSearch(page)}
curPage={currentPage}
index={selectedIndex}
widthFixed={true}
<PopupSearchPanel contentClassName="flex flex-wrap items-center gap-x-5 gap-y-3">
<div className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] font-medium text-label">
상수사용기간
</span>
<DateMonthPicker
field={{
value: filters.aplcnYmFrom,
onChange: (value: string) => {
setFilters((current) => ({
...current,
aplcnYmFrom: value,
}));
},
}}
selectClass="w-[145px] !text-[1.3rem]"
startYear={1900}
/>
<span className="text-[1.1rem] font-semibold text-muted-foreground">
~
</span>
<DateMonthPicker
field={{
value: filters.aplcnYmTo,
onChange: (value: string) => {
setFilters((current) => ({
...current,
aplcnYmTo: value,
}));
},
}}
selectClass="w-[145px] !text-[1.3rem]"
startYear={1900}
/>
</div>
{selectedRow ? (
<div className="mt-3 rounded-md border border-border bg-secondary px-4 py-3 text-[1rem] text-label">
{"선택 분석: "}
<span className="font-semibold text-foreground">{selectedRow.anlsInfo}</span>
</div>
) : null}
</section>
</main>
<div className="min-w-0 shrink-0">
<CtpvSggPrcsFcltSelectBox
compact
className="!justify-start"
selectValueClassName="!text-[1.3rem]"
sido={filters.ctpvCd}
sigungu={filters.sggCd}
sggName={filters.sggName}
onSidoChange={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
ctpvCd: value,
sggCd: "0",
sggName: "",
swtrmtFcltNm: "",
}));
}}
onSigunguChange={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
sggCd: value,
swtrmtFcltNm: "",
}));
}}
setSggName={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
sggName: value ?? "",
swtrmtFcltNm: "",
}));
}}
selectedSwagePrcsFclt={selectedSwagePrcsFclt}
onSwagePrcsFcltChange={setSelectedSwagePrcsFclt}
setSwtrmtFcltNm={(value) =>
setFilters((current) => ({
...current,
swtrmtFcltNm: value ?? "",
}))
}
setIsLoading={setIsLoading}
/>
</div>
<div className="ml-auto flex shrink-0 items-center justify-end gap-2">
<Button
type="button"
color="primary"
onClick={() => void handleSearch(1)}
>
조회
</Button>
<Button
type="button"
color="white"
onClick={() => void handleReset()}
>
초기화
</Button>
</div>
</PopupSearchPanel>
<MapPopupDataTable
rows={rows as Array<InftwtResultRow & Record<string, unknown>>}
headlist={INFTWT_RESULT_HEADLIST}
title="침입수 분석결과 목록"
total={total}
page={currentPage}
pageSize={INFTWT_RESULT_PAGE_SIZE}
onPageChange={(page) => void handleSearch(page)}
onRowClick={(_, index) => handleRowClick(index)}
rowId={(row) => String(row.inftwtAnlsSn)}
scrollMode="horizontal"
minTableWidth="110rem"
sort={sort}
onSortChange={handleSortChange}
actions={
<Button type="button" onClick={() => void handleDownload()}>
엑셀다운로드
</Button>
}
/>
{selectedRow ? (
<div className="rounded-sm border border-border bg-secondary px-4 py-3 text-[1rem] text-label">
{"선택 분석: "}
<span className="font-semibold text-foreground">
{selectedRow.anlsInfo}
</span>
</div>
) : null}
</StandardPopupLayout>
);
}
@@ -1,3 +1,4 @@
/** 침입수 분석결과 화면의 타입·검색 조건·컬럼과 응답 변환을 정의한다. */
"use client";
export const INFTWT_RESULT_ROW_SELECT_MESSAGE = "SN_INFTWT_RESULT_ROW_SELECT";
@@ -1,5 +1,6 @@
"use client";
/** 침입수 분석결과 선택 시 부모 지도에 결과 레이어를 표시하는 훅이다. */
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
import GeoJSON from "ol/format/GeoJSON.js";
import VectorLayer from "ol/layer/Vector.js";
@@ -11,6 +12,7 @@ import { intersects as intersectsExtent } from "ol/extent";
import { getRawJson } from "@/components/lib/api";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import { useAlertStore } from "@/shared/lib";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import { getSigunguCenter, moveMapToCenter, sidoCenter } from "@common/business_components/ui/map/searchBar/utils";
@@ -194,6 +196,7 @@ export const useInftwtResultMap = ({
setIsOpen,
setIsSearchModalOpen,
}: UseInftwtResultMapParams) => {
const alert = useAlertStore((state) => state.setMessage);
const activeResultRowRef = useRef<InftwtResultRow | null>(null);
const zoneLayerRef = useRef<VectorLayer<any> | null>(null);
const zoneSourceRef = useRef<VectorSource | null>(null);
@@ -387,13 +390,6 @@ export const useInftwtResultMap = ({
pipeLayerRef.current?.setVisible?.(true);
pipeLayerRef.current?.changed?.();
gisMaps.current?.renderSync?.();
console.info("移⑥엯??FE overlay ?뚮뜑 寃곌낵", {
focusTargetType: mapView.focusTargetType,
zoneFeatureCount: zoneFeatures.length,
pipeFeatureCount: pipeFeatures.length,
});
await moveToStableFocus(mapView);
},
[clearLayers, ensurePipeLayer, ensureZoneLayer, gisMaps, moveToStableFocus],
@@ -433,10 +429,10 @@ export const useInftwtResultMap = ({
void renderMapView(mapView);
} catch (error) {
console.error("移⑥엯??遺꾩꽍寃곌낵 吏€???곌퀎 以??ㅻ쪟:", error);
alert("移⑥엯??遺꾩꽍寃곌낵瑜?吏€?꾩뿉 ?곌퀎?섏? 紐삵뻽?듬땲??");
alert("침입수 분석결과를 지도에 연계하지 못했습니다.");
}
},
[renderMapView, setFeatureData, setIsOpen, setIsSearchModalOpen],
[alert, renderMapView, setFeatureData, setIsOpen, setIsSearchModalOpen],
);
useEffect(() => {
@@ -494,11 +490,11 @@ export const useInftwtResultMap = ({
});
if (!popupWindow) {
alert("?앹뾽??李⑤떒?섏뿀?듬땲?? 釉뚮씪?곗? ?앹뾽 ?덉슜 ?ㅼ젙???뺤씤?댁<?몄슂.");
alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.");
}
return true;
}, [activeMapView]);
}, [activeMapView, alert]);
return {
activeMapView,
@@ -0,0 +1,73 @@
/**
* 화면명: 선택 레이어 설정 카드
* 레이어별 설정 제목과 활성화·닫기 영역을 공통 레이아웃으로 제공한다.
*/
"use client";
import type { ReactNode } from "react";
type Props = {
title: string;
enabled: boolean;
onEnabledChange: (enabled: boolean) => void;
onClose: () => void;
children?: ReactNode;
showToggle?: boolean;
};
/** 침수흔적도 설정과 같은 제목·토글·닫기·본문 여백을 제공한다. */
export default function LayerSettingCard({
title,
enabled,
onEnabledChange,
onClose,
children,
showToggle = true,
}: Props) {
return (
<div className="w-full overflow-visible rounded-[6px] border border-border bg-background shadow-blur">
<div className="flex min-h-[28px] items-center justify-between border-b border-border pl-[0.8rem] pr-2">
<div className="flex min-w-0 items-center gap-2">
<span
aria-hidden="true"
className="h-[7px] w-[7px] shrink-0 rotate-45 bg-[#7CCE5C]"
/>
<span className="truncate text-[12px] font-semibold text-label">
{title}
</span>
</div>
<div className="flex shrink-0 items-center gap-2">
{showToggle ? (
<button
type="button"
role="switch"
aria-checked={enabled}
aria-label={`${title} 적용 여부`}
title={enabled ? `${title} 적용 중` : `${title} 미적용`}
className={`relative h-[16px] w-[28px] rounded-full border transition-colors ${enabled ? "border-primary bg-primary" : "border-[#94a3b8] bg-[#cbd5e1]"}`}
onClick={() => onEnabledChange(!enabled)}
>
<span
className={`absolute top-1/2 h-[12px] w-[12px] -translate-y-1/2 rounded-full bg-white shadow-sm transition-[left] ${enabled ? "left-[14px]" : "left-[2px]"}`}
/>
</button>
) : null}
<button
type="button"
aria-label={`${title} 설정 닫기`}
title="닫기"
className="grid h-[20px] w-[20px] place-items-center bg-transparent text-[1.3rem] leading-none text-muted-foreground hover:text-foreground"
onClick={onClose}
>
×
</button>
</div>
</div>
{children ? (
<div className="flex flex-col gap-[6px] p-[0.8rem] text-[1.15rem] text-label">
{children}
</div>
) : null}
</div>
);
}
@@ -1,13 +1,24 @@
/**
* 화면명: 관로현황 목록
* 관로분석 집계 항목에 해당하는 개별 관로 목록을 표시한다.
*/
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Button, Input } from "@common/components";
import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
import { StandardPopupLayout } from "@common/business_components";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { getData } from "@/components/lib/api";
import { appendApiSearchParams } from "@/shared/util/util";
import LoadingOverlay from "@/components/ui/LoadingBar";
import useStore from "@/store/useStore";
import { useAlertStore } from "@/shared/lib";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
import { useUserStore } from "@/shared/lib/use-user-store";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import { MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_USE_CODES } from "../common/table";
import {
PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS,
PIPE_ANALYSIS_DETAIL_EXCEL_HEADERS,
@@ -28,6 +39,8 @@ import {
} from "./pipeAnalysisUtils";
type DetailViewRow = ReturnType<typeof toPipeAnalysisDetailViewRows>[number];
const PIPE_ANALYSIS_DETAIL_PAGE_SIZE = 10;
const PIPE_ANALYSIS_MAP_WINDOW_NAME = "sn-sewerage-map-root";
const PIPE_ANALYSIS_DETAIL_EXPORT_KEYS = [
"rowNo",
"ctpvNm",
@@ -41,11 +54,15 @@ const PIPE_ANALYSIS_DETAIL_EXPORT_KEYS = [
"prlgVl",
"materialName",
] as const;
const PIPE_ANALYSIS_DETAIL_STANDARD_HEADLIST = PIPE_ANALYSIS_DETAIL_HEADLIST;
const resolveErrorMessage = (error: unknown, fallbackMessage: string) =>
error instanceof Error && error.message.trim() ? error.message : fallbackMessage;
error instanceof Error && error.message.trim()
? error.message
: fallbackMessage;
export default function PipeAnalysisDetailPopupPage() {
const alert = useAlertStore((state) => state.setMessage);
const searchParams = useSearchParams();
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const userInfo = useUserStore((state) => state.userInfo);
@@ -58,18 +75,42 @@ export default function PipeAnalysisDetailPopupPage() {
applyScopeToDetailFilters(
{
...PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS,
ctpvCd: searchParams.get("ctpvCd") ?? PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.ctpvCd,
sggCd: searchParams.get("sggCd") ?? PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.sggCd,
swtrmtZoneNm: searchParams.get("swtrmtZoneNm") ?? PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.swtrmtZoneNm,
ctpvCd:
searchParams.get("ctpvCd") ??
PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.ctpvCd,
sggCd:
searchParams.get("sggCd") ??
PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.sggCd,
swtrmtZoneNm:
searchParams.get("swtrmtZoneNm") ??
PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.swtrmtZoneNm,
materialCode: baseMaterialCode,
},
scope,
),
);
const [appliedFilters, setAppliedFilters] =
useState<PipeAnalysisDetailFilters>(() =>
applyScopeToDetailFilters(
{
...PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS,
ctpvCd:
searchParams.get("ctpvCd") ??
PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.ctpvCd,
sggCd:
searchParams.get("sggCd") ??
PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.sggCd,
swtrmtZoneNm:
searchParams.get("swtrmtZoneNm") ??
PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.swtrmtZoneNm,
materialCode: baseMaterialCode,
},
scope,
),
);
const [rows, setRows] = useState<DetailViewRow[]>([]);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [isLoading, setIsLoading] = useState(false);
const initializedRef = useRef(false);
@@ -80,31 +121,44 @@ export default function PipeAnalysisDetailPopupPage() {
useEffect(() => {
setFilters((current) => applyScopeToDetailFilters(current, scope));
setAppliedFilters((current) => applyScopeToDetailFilters(current, scope));
}, [scope]);
const fetchRows = useCallback(
async (page: number, nextFilters: PipeAnalysisDetailFilters, size: number) => {
async (
page: number,
nextFilters: PipeAnalysisDetailFilters,
size: number,
) => {
const params = new URLSearchParams({
analysisType: baseAnalysisType,
page: String(page - 1),
size: String(size),
});
if (nextFilters.ctpvCd !== "0") params.set("ctpvCd", nextFilters.ctpvCd);
if (nextFilters.sggCd !== "0") params.set("sggCd", nextFilters.sggCd);
if (nextFilters.swtrmtZoneNm !== "0") params.set("swtrmtZoneNm", nextFilters.swtrmtZoneNm);
if (baseElapsedYearBucket) params.set("elapsedYearBucket", baseElapsedYearBucket);
if (nextFilters.materialCode !== "0") params.set("materialCode", nextFilters.materialCode);
if (nextFilters.useCd !== "0") params.set("useCd", nextFilters.useCd);
if (nextFilters.diameterFrom) params.set("diameterFrom", nextFilters.diameterFrom);
if (nextFilters.diameterTo) params.set("diameterTo", nextFilters.diameterTo);
appendApiSearchParams(params, {
ctpvCd: nextFilters.ctpvCd,
sggCd: nextFilters.sggCd,
swtrmtZoneNm: nextFilters.swtrmtZoneNm,
elapsedYearBucket: baseElapsedYearBucket,
materialCode: nextFilters.materialCode,
useCd: nextFilters.useCd,
diameterFrom: nextFilters.diameterFrom,
diameterTo: nextFilters.diameterTo,
});
if (scope.regionCode) params.set("scopeRegionCode", scope.regionCode);
const response = await getData(`v1/fclts/ppl-analysis/details?${params.toString()}`);
const response = await getData(
`v1/fclts/pplAnalysis/details?${params.toString()}`,
);
const responseRows = Array.isArray(response?.data) ? response.data : [];
return {
rows: toPipeAnalysisDetailViewRows(responseRows as PipeAnalysisDetailRow[], ctpv, sgg),
rows: toPipeAnalysisDetailViewRows(
responseRows as PipeAnalysisDetailRow[],
ctpv,
sgg,
),
total: Number(response?.count ?? 0),
};
},
@@ -115,42 +169,61 @@ export default function PipeAnalysisDetailPopupPage() {
async (page = 1, nextFilters = filters) => {
setIsLoading(true);
try {
const response = await fetchRows(page, nextFilters, 20);
const response = await fetchRows(
page,
nextFilters,
PIPE_ANALYSIS_DETAIL_PAGE_SIZE,
);
setRows(response.rows);
setTotal(response.total);
setCurrentPage(page);
setAppliedFilters(nextFilters);
} catch (error) {
console.error("관로현황 조회 중 오류:", error);
alert(resolveErrorMessage(error, "관로현황 조회 중 오류가 발생했습니다."));
alert(
resolveErrorMessage(error, "관로현황 조회 중 오류가 발생했습니다."),
);
setRows([]);
setTotal(0);
} finally {
setIsLoading(false);
}
},
[fetchRows, filters],
[alert, fetchRows, filters],
);
useEffect(() => {
if (initializedRef.current) return;
if (ctpv.length === 0 || sgg.length === 0) return;
initializedRef.current = true;
void loadRows(1, applyScopeToDetailFilters(filters, scope));
const initialFilters = applyScopeToDetailFilters(filters, scope);
void loadRows(1, initialFilters);
}, [ctpv.length, filters, loadRows, scope, sgg.length]);
const handleSearch = (page = 1) => {
setSelectedIndex(-1);
void loadRows(page, applyScopeToDetailFilters(filters, scope));
const handleSearch = () => {
const nextFilters = applyScopeToDetailFilters(filters, scope);
void loadRows(1, nextFilters);
};
const handleRowClick = (index?: number) => {
if (typeof index !== "number") return;
const row = rows[index];
if (!row) return;
/** Keeps the last searched filters when the user moves pages. */
const handlePageChange = (page: number) => {
void loadRows(page, appliedFilters);
};
setSelectedIndex(index);
/** 선택 관로를 지도에 표시하고 상세창의 최상위 하수관망도 부모창을 앞으로 가져온다. */
const handleRowClick = (row: DetailViewRow) => {
const targetWindow = window.opener?.opener ?? window.opener;
targetWindow?.postMessage(
if (!targetWindow || targetWindow.closed) return;
// Chrome가 다른 탭/창의 focus()만 무시하는 경우를 대비해 사용자 클릭 안에서
// 기존 최상위 지도창을 고정 이름으로 다시 선택한 뒤 포커스를 요청한다.
targetWindow.name = targetWindow.name || PIPE_ANALYSIS_MAP_WINDOW_NAME;
const namedTargetWindow =
window.open("", targetWindow.name) ?? targetWindow;
window.opener?.blur();
window.blur();
namedTargetWindow.focus();
targetWindow.postMessage(
{
type: PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE,
row,
@@ -167,8 +240,12 @@ export default function PipeAnalysisDetailPopupPage() {
try {
setIsLoading(true);
const response = await fetchRows(1, filters, Math.max(total, 1000));
const exportRows = response.rows.map((row) => ({
const response = await fetchAllPages({
fetchPage: (page, size) => fetchRows(page + 1, appliedFilters, size),
getItems: (page) => page.rows,
getTotal: (page) => page.total,
});
const exportRows = response.items.map((row) => ({
rowNo: row.rowNo,
ctpvNm: row.ctpvNm,
sggNm: row.sggNm,
@@ -179,7 +256,8 @@ export default function PipeAnalysisDetailPopupPage() {
useNm: row.useNm,
pdmtVl: row.pdmtVl,
prlgVl: row.prlgVl,
materialName: resolveMaterialKoreanName(row.materialCode) || row.materialName,
materialName:
resolveMaterialKoreanName(row.materialCode) || row.materialName,
}));
const detailHeaderRange = `A1:${String.fromCharCode(64 + PIPE_ANALYSIS_DETAIL_EXPORT_KEYS.length)}1`;
await downloadExcelWithKoreanHeaders({
@@ -191,7 +269,9 @@ export default function PipeAnalysisDetailPopupPage() {
});
} catch (error) {
console.error("관로현황 다운로드 중 오류:", error);
alert(resolveErrorMessage(error, "관로현황 다운로드 중 오류가 발생했습니다."));
alert(
resolveErrorMessage(error, "관로현황 다운로드 중 오류가 발생했습니다."),
);
} finally {
setIsLoading(false);
}
@@ -200,15 +280,9 @@ export default function PipeAnalysisDetailPopupPage() {
const materialOptions = useMemo(() => {
const options = [
{ value: "0", label: "전체" },
...Array.from(
new Set(
rows
.map((row) => String(row.materialCode ?? "").trim())
.filter((value) => value && value !== "0"),
),
).map((value) => ({
value,
label: resolveMaterialName(value),
...MS_SWAGE_PPL_CODES.map((item) => ({
value: item.val.trim(),
label: item.text,
})),
];
@@ -224,146 +298,191 @@ export default function PipeAnalysisDetailPopupPage() {
}
return options;
}, [baseMaterialCode, rows]);
}, [baseMaterialCode]);
const titleSuffix = useMemo(() => {
const locationParts: string[] = [];
const resolvedCtpvName = rows[0]?.ctpvNm || resolveRegionName(filters.ctpvCd, ctpv, 2);
const resolvedSggName = rows[0]?.sggNm || resolveRegionName(filters.sggCd, sgg, 5);
const resolvedCtpvName =
rows[0]?.ctpvNm || resolveRegionName(filters.ctpvCd, ctpv, 2);
const resolvedSggName =
rows[0]?.sggNm || resolveRegionName(filters.sggCd, sgg, 5);
const resolvedZoneName =
(filters.swtrmtZoneNm && filters.swtrmtZoneNm !== "0" ? filters.swtrmtZoneNm : rows[0]?.swtrmtZoneNm) || "";
(filters.swtrmtZoneNm && filters.swtrmtZoneNm !== "0"
? filters.swtrmtZoneNm
: rows[0]?.swtrmtZoneNm) || "";
if (resolvedCtpvName && resolvedCtpvName !== "-") locationParts.push(resolvedCtpvName);
if (resolvedSggName && resolvedSggName !== "-") locationParts.push(resolvedSggName);
if (resolvedZoneName && resolvedZoneName !== "-" && resolvedZoneName !== "0") locationParts.push(resolvedZoneName);
if (resolvedCtpvName && resolvedCtpvName !== "-")
locationParts.push(resolvedCtpvName);
if (resolvedSggName && resolvedSggName !== "-")
locationParts.push(resolvedSggName);
if (
resolvedZoneName &&
resolvedZoneName !== "-" &&
resolvedZoneName !== "0"
)
locationParts.push(resolvedZoneName);
const parts = [];
if (locationParts.length > 0) parts.push(locationParts.join(" "));
const analysisTypeLabel = resolvePipeAnalysisTypeLabel(baseAnalysisType);
if (analysisTypeLabel) parts.push(analysisTypeLabel);
if (baseElapsedYearBucket) parts.push(resolveElapsedYearBucketLabel(baseElapsedYearBucket));
if (baseMaterialCode && baseMaterialCode !== "0") parts.push(resolveMaterialName(baseMaterialCode));
if (baseElapsedYearBucket)
parts.push(resolveElapsedYearBucketLabel(baseElapsedYearBucket));
if (baseMaterialCode && baseMaterialCode !== "0")
parts.push(resolveMaterialName(baseMaterialCode));
return parts.length > 0 ? ` (${parts.join(" / ")})` : "";
}, [baseAnalysisType, baseElapsedYearBucket, baseMaterialCode, ctpv, filters.ctpvCd, filters.sggCd, filters.swtrmtZoneNm, rows, sgg]);
}, [
baseAnalysisType,
baseElapsedYearBucket,
baseMaterialCode,
ctpv,
filters.ctpvCd,
filters.sggCd,
filters.swtrmtZoneNm,
rows,
sgg,
]);
return (
<main className="sn-popup-page">
<StandardPopupLayout
title={`관로현황 목록${titleSuffix}`}
bodyClassName="overflow-x-hidden"
>
<LoadingOverlay isLoading={isLoading} />
<header className="sn-popup-header">
<h1 className="sn-popup-title">관로현황{titleSuffix}</h1>
</header>
<section className="sn-popup-section overflow-y-auto">
<div className="grid grid-cols-1 gap-3 border-b border-border bg-secondary pb-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_110px_110px_auto] md:items-end">
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-label">용도</div>
<select
className="sn-popup-input !text-[1.1rem]"
value={filters.useCd}
onChange={(event) =>
setFilters((current) => ({
...current,
useCd: event.target.value,
}))
}
>
<option value="0">전체</option>
<option value="SBA001">합류관</option>
<option value="SBA002">차집관</option>
<option value="SBA003">우수관</option>
<option value="SBA004">오수관</option>
</select>
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-label">관재질</div>
<select
className="sn-popup-input !text-[1.1rem]"
value={filters.materialCode}
onChange={(event) =>
setFilters((current) => ({
...current,
materialCode: event.target.value,
}))
}
>
{materialOptions.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-label">관경 시작</div>
<Input
className="sn-popup-input !text-[1.1rem]"
value={filters.diameterFrom}
onChange={(event) =>
setFilters((current) => ({
...current,
diameterFrom: normalizeNumberInput(event.target.value),
}))
}
onKeyDown={(event) => {
if (event.key === "Enter") handleSearch(1);
}}
/>
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-label">관경 종료</div>
<Input
className="sn-popup-input !text-[1.1rem]"
value={filters.diameterTo}
onChange={(event) =>
setFilters((current) => ({
...current,
diameterTo: normalizeNumberInput(event.target.value),
}))
}
onKeyDown={(event) => {
if (event.key === "Enter") handleSearch(1);
}}
/>
</label>
<div className="flex flex-wrap justify-end gap-2 md:pr-3">
<Button
type="button"
size="sm"
color="primary"
className="h-10 px-5 !text-[1.1rem]"
onClick={() => handleSearch(1)}
>
조회
</Button>
<Button
type="button"
size="sm"
color="green"
className="h-10 px-5 !text-[1.1rem]"
onClick={() => void handleDownload()}
>
다운로드
</Button>
<PopupSearchPanel contentClassName="flex flex-wrap items-center gap-x-5 gap-y-3">
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
용도
</div>
<select
className="sn-popup-input !h-[3.2rem] w-[14rem] !text-xl"
value={filters.useCd}
onChange={(event) =>
setFilters((current) => ({
...current,
useCd: event.target.value,
}))
}
>
<option value="0">전체</option>
{MS_SWAGE_PPL_USE_CODES.map((item) => (
<option key={item.val} value={item.val}>
{item.text}
</option>
))}
</select>
</label>
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
관재질
</div>
<select
className="sn-popup-input !h-[3.2rem] w-[14rem] !text-xl"
value={filters.materialCode}
onChange={(event) =>
setFilters((current) => ({
...current,
materialCode: event.target.value,
}))
}
>
{materialOptions.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
<div className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
관경(mm)
</div>
<Input
className="sn-popup-input !h-[3.2rem] w-[12rem] !text-xl"
aria-label="관경 최소값"
placeholder="입력"
value={filters.diameterFrom}
onChange={(event) =>
setFilters((current) => ({
...current,
diameterFrom: normalizeNumberInput(event.target.value),
}))
}
onKeyDown={(event) => {
if (event.key === "Enter") handleSearch();
}}
/>
<span className="shrink-0 text-xl font-semibold text-foreground">~</span>
<Input
className="sn-popup-input !h-[3.2rem] w-[12rem] !text-xl"
aria-label="관경 최대값"
placeholder="입력"
value={filters.diameterTo}
onChange={(event) =>
setFilters((current) => ({
...current,
diameterTo: normalizeNumberInput(event.target.value),
}))
}
onKeyDown={(event) => {
if (event.key === "Enter") handleSearch();
}}
/>
</div>
<UiTable<DetailViewRow>
headName={<>관로현황 조회 결과 총 {total.toLocaleString()}건</>}
tableData={rows}
headlist={PIPE_ANALYSIS_DETAIL_HEADLIST}
pageSize={20}
total={total}
cellClick={(index) => handleRowClick(index)}
onPageChange={(page) => handleSearch(page)}
curPage={currentPage}
index={selectedIndex}
widthFixed={true}
/>
</section>
</main>
<div className="flex min-w-fit flex-1 items-center justify-end gap-2">
<Button type="button" color="primary" onClick={handleSearch}>
조회
</Button>
<Button
type="button"
color="white"
onClick={() => {
const resetFilters = applyScopeToDetailFilters(
{
...PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS,
ctpvCd:
searchParams.get("ctpvCd") ??
PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.ctpvCd,
sggCd:
searchParams.get("sggCd") ??
PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.sggCd,
swtrmtZoneNm:
searchParams.get("swtrmtZoneNm") ??
PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS.swtrmtZoneNm,
materialCode: baseMaterialCode,
},
scope,
);
setFilters(resetFilters);
void loadRows(1, resetFilters);
}}
>
초기화
</Button>
</div>
</PopupSearchPanel>
<MapPopupDataTable
rows={rows as Array<DetailViewRow & Record<string, unknown>>}
headlist={PIPE_ANALYSIS_DETAIL_STANDARD_HEADLIST}
title="관로현황 목록"
total={total}
page={currentPage}
pageSize={PIPE_ANALYSIS_DETAIL_PAGE_SIZE}
onPageChange={handlePageChange}
onRowClick={handleRowClick}
rowId={(row, index) => String(row.fid ?? row.fcltMngNo ?? index)}
scrollMode="horizontal"
minTableWidth="150rem"
actions={
<Button type="button" onClick={() => void handleDownload()}>
엑셀다운로드
</Button>
}
/>
</StandardPopupLayout>
);
}
@@ -1,14 +1,23 @@
/**
* 화면명: 관로분석
* 행정구역·처리구역과 분석구분별 관로 집계 결과를 조회한다.
*/
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Button, Table, TableBody, TableCell, TableHeader, TableHead, TableRow } from "@common/components";
import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
import UiPageNations from "@common/business_components/ui/table/uiPageNations";
import { Button } from "@common/components";
import { StandardPopupLayout } from "@common/business_components";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { getData } from "@/components/lib/api";
import { appendApiSearchParams } from "@/shared/util/util";
import LoadingOverlay from "@/components/ui/LoadingBar";
import useStore from "@/store/useStore";
import { useAlertStore } from "@/shared/lib";
import { useUserStore } from "@/shared/lib/use-user-store";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions";
import PipeAnalysisSummaryTable from "./PipeAnalysisSummaryTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import {
PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS,
PIPE_ANALYSIS_TYPE_OPTIONS,
@@ -48,33 +57,31 @@ const toExcelColumnName = (index: number) => {
return result || "A";
};
const formatGridNumber = (value: unknown) => {
const num = Number(value ?? 0);
if (!Number.isFinite(num)) {
return String(value ?? "");
}
return num.toLocaleString("ko-KR", {
minimumFractionDigits: 0,
maximumFractionDigits: 3,
});
};
const resolveErrorMessage = (error: unknown, fallbackMessage: string) =>
error instanceof Error && error.message.trim() ? error.message : fallbackMessage;
error instanceof Error && error.message.trim()
? error.message
: fallbackMessage;
export default function PipeAnalysisPopupPage() {
const alert = useAlertStore((state) => state.setMessage);
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const userInfo = useUserStore((state) => state.userInfo);
const scope = useMemo(() => buildPipeAnalysisScope(userInfo), [userInfo]);
const [draftFilters, setDraftFilters] = useState<PipeAnalysisSummaryFilters>(() =>
applyScopeToSummaryFilters(PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS, scope),
const [draftFilters, setDraftFilters] = useState<PipeAnalysisSummaryFilters>(
() =>
applyScopeToSummaryFilters(PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS, scope),
);
const [appliedFilters, setAppliedFilters] = useState<PipeAnalysisSummaryFilters>(() =>
applyScopeToSummaryFilters(PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS, scope),
);
const [materialColumns, setMaterialColumns] = useState<PipeAnalysisMaterialColumn[]>([]);
const [appliedFilters, setAppliedFilters] =
useState<PipeAnalysisSummaryFilters>(() =>
applyScopeToSummaryFilters(PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS, scope),
);
const [materialColumns, setMaterialColumns] = useState<
PipeAnalysisMaterialColumn[]
>([]);
const [rows, setRows] = useState<PipeAnalysisSummaryViewRow[]>([]);
const [zoneOptions, setZoneOptions] = useState<Array<{ value: string; label: string }>>([]);
const [zoneOptions, setZoneOptions] = useState<
Array<{ value: string; label: string }>
>([]);
const [summaryCurrentPage, setSummaryCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const initializedRef = useRef(false);
@@ -97,6 +104,9 @@ export default function PipeAnalysisPopupPage() {
const zones = await fetchPipeAnalysisZoneOptions({
ctpvCd: draftFilters.ctpvCd,
sggCd: draftFilters.sggCd,
sggNm: sgg.find(
(option) => String(option.val).trim() === draftFilters.sggCd,
)?.text,
scopeRegionCode: scope.regionCode,
});
if (!mounted) return;
@@ -111,7 +121,7 @@ export default function PipeAnalysisPopupPage() {
return () => {
mounted = false;
};
}, [draftFilters.ctpvCd, draftFilters.sggCd, scope.regionCode]);
}, [draftFilters.ctpvCd, draftFilters.sggCd, scope.regionCode, sgg]);
const fetchSummary = useCallback(
async (nextFilters: PipeAnalysisSummaryFilters) => {
@@ -119,13 +129,16 @@ export default function PipeAnalysisPopupPage() {
analysisType: nextFilters.analysisType,
});
if (nextFilters.ctpvCd !== "0") params.set("ctpvCd", nextFilters.ctpvCd);
if (nextFilters.sggCd !== "0") params.set("sggCd", nextFilters.sggCd);
if (nextFilters.swtrmtZoneNm !== "0") params.set("swtrmtZoneNm", nextFilters.swtrmtZoneNm);
if (nextFilters.scopeRegionCode) params.set("scopeRegionCode", nextFilters.scopeRegionCode);
appendApiSearchParams(params, {
ctpvCd: nextFilters.ctpvCd,
sggCd: nextFilters.sggCd,
swtrmtZoneNm: nextFilters.swtrmtZoneNm,
});
if (nextFilters.scopeRegionCode)
params.set("scopeRegionCode", nextFilters.scopeRegionCode);
const response = (await getData(
`v1/fclts/ppl-analysis/summaries?${params.toString()}`,
`v1/fclts/pplAnalysis/summaries?${params.toString()}`,
)) as PipeAnalysisSummaryResponse;
const nextMaterialColumns = Array.isArray(response?.materialColumns)
@@ -148,14 +161,19 @@ export default function PipeAnalysisPopupPage() {
await fetchSummary(scopedFilters);
} catch (error) {
console.error("관로분석 집계 조회 중 오류:", error);
alert(resolveErrorMessage(error, "관로분석 집계 조회 중 오류가 발생했습니다."));
alert(
resolveErrorMessage(
error,
"관로분석 집계 조회 중 오류가 발생했습니다.",
),
);
setRows([]);
setMaterialColumns([]);
} finally {
setIsLoading(false);
}
},
[draftFilters, fetchSummary, scope],
[alert, draftFilters, fetchSummary, scope],
);
useEffect(() => {
@@ -178,81 +196,98 @@ export default function PipeAnalysisPopupPage() {
materialColumns,
);
const headerRange = `A1:${toExcelColumnName(exportHeadList.length)}1`;
const mergeRanges = appliedFilters.analysisType === "YEAR_MATERIAL"
? (() => {
const ranges: string[] = [];
let currentRow = 2;
const mergeRanges =
appliedFilters.analysisType === "YEAR_MATERIAL"
? (() => {
const ranges: string[] = [];
let currentRow = 2;
yearMaterialGroups.forEach((group) => {
const rowSpan = group.rows.length;
if (rowSpan > 1) {
["A", "B", "C", "D", "E"].forEach((column) => {
ranges.push(`${column}${currentRow}:${column}${currentRow + rowSpan - 1}`);
orderedYearMaterialGroups.forEach((group) => {
const rowSpan = group.rows.length;
if (rowSpan > 1) {
["A", "B", "C", "D", "E"].forEach((column) => {
ranges.push(
`${column}${currentRow}:${column}${currentRow + rowSpan - 1}`,
);
});
}
currentRow += rowSpan;
});
}
currentRow += rowSpan;
});
return ranges;
})()
: [];
const exportRows = appliedFilters.analysisType === "YEAR_MATERIAL"
? yearMaterialGroups.flatMap((group) =>
group.rows.map((yearRow, rowIndex) => {
const exportRow: Record<string, unknown> = {
rowNo: rowIndex === 0 ? group.rowNo : "",
ctpvNm: rowIndex === 0 ? group.ctpvNm : "",
sggNm: rowIndex === 0 ? group.sggNm : "",
swtrmtZoneNm: rowIndex === 0 ? group.swtrmtZoneNm : "",
totalLengthKm: rowIndex === 0 ? group.totalLengthKm : "",
elapsedYearLabel: yearRow.elapsedYearLabel,
};
return ranges;
})()
: [];
const exportRows =
appliedFilters.analysisType === "YEAR_MATERIAL"
? orderedYearMaterialGroups.flatMap((group) =>
group.rows.map((yearRow, rowIndex) => {
const exportRow: Record<string, unknown> = {
rowNo: rowIndex === 0 ? group.rowNo : "",
ctpvNm: rowIndex === 0 ? group.ctpvNm : "",
sggNm: rowIndex === 0 ? group.sggNm : "",
swtrmtZoneNm: rowIndex === 0 ? group.swtrmtZoneNm : "",
totalLengthKm: rowIndex === 0 ? group.totalLengthKm : "",
elapsedYearLabel: yearRow.elapsedYearLabel,
};
materialColumns.forEach((column) => {
exportRow[column.key] = yearRow.metrics[column.key] ?? 0;
materialColumns.forEach((column) => {
exportRow[column.key] = yearRow.metrics[column.key] ?? 0;
});
return exportRow;
}),
)
: rows.map((row) => {
const exportRow: Record<string, unknown> = {};
exportHeadList.forEach((head) => {
exportRow[head.id] = row[head.id];
});
return exportRow;
});
return exportRow;
}),
)
: rows.map((row) => {
const exportRow: Record<string, unknown> = {};
exportHeadList.forEach((head) => {
exportRow[head.id] = row[head.id];
});
return exportRow;
});
await downloadExcelWithKoreanHeaders({
data: exportRows as unknown as JSON[],
fileName: `${SUMMARY_FILE_PREFIX[appliedFilters.analysisType]}_${buildPipeAnalysisFileDate()}`,
sheetNameArray: ["관로분석 현황"],
headerRange,
headerMapping: buildPipeAnalysisSummaryDownloadHeaders(appliedFilters.analysisType, materialColumns),
headerMapping: buildPipeAnalysisSummaryDownloadHeaders(
appliedFilters.analysisType,
materialColumns,
),
mergeRanges,
});
} catch (error) {
console.error("관로분석 집계 다운로드 중 오류:", error);
alert(resolveErrorMessage(error, "관로분석 집계 다운로드 중 오류가 발생했습니다."));
alert(
resolveErrorMessage(
error,
"관로분석 집계 다운로드 중 오류가 발생했습니다.",
),
);
}
};
const handleCellClick = (index?: number, columnId?: string) => {
if (typeof index !== "number" || !columnId) return;
const row = rows[index];
if (!row) return;
const handleCellClick = (
row: PipeAnalysisSummaryViewRow,
columnId: string,
) => {
if (row.__isNoZone || row.__isSummaryTotal) return;
const cellFilter = row.__cellFilters[columnId];
if (!cellFilter) return;
openWindowPopup("pipeAnalysisDetailPopup", {
const popup = openWindowPopup("pipeAnalysisDetailPopup", {
analysisType: appliedFilters.analysisType,
ctpvCd: String(row.__base.ctpvCd || appliedFilters.ctpvCd),
sggCd: String(row.__base.sggCd || appliedFilters.sggCd || "0"),
swtrmtZoneNm: String(row.__base.swtrmtZoneNm || appliedFilters.swtrmtZoneNm),
swtrmtZoneNm: String(
row.__base.swtrmtZoneNm || appliedFilters.swtrmtZoneNm,
),
elapsedYearBucket: cellFilter.elapsedYearBucket ?? "",
materialCode: cellFilter.materialCode ?? "",
scopeRegionCode: scope.regionCode,
});
if (!popup) {
alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.");
}
};
const handleYearMaterialCellClick = (
@@ -263,302 +298,227 @@ export default function PipeAnalysisPopupPage() {
const cellFilter = row.cellFilters[columnId];
if (!cellFilter) return;
openWindowPopup("pipeAnalysisDetailPopup", {
const popup = openWindowPopup("pipeAnalysisDetailPopup", {
analysisType: appliedFilters.analysisType,
ctpvCd: String(group.base.ctpvCd || appliedFilters.ctpvCd),
sggCd: String(group.base.sggCd || appliedFilters.sggCd || "0"),
swtrmtZoneNm: String(group.base.swtrmtZoneNm || appliedFilters.swtrmtZoneNm),
swtrmtZoneNm: String(
group.base.swtrmtZoneNm || appliedFilters.swtrmtZoneNm,
),
elapsedYearBucket: cellFilter.elapsedYearBucket ?? "",
materialCode: cellFilter.materialCode ?? "",
scopeRegionCode: scope.regionCode,
});
if (!popup) {
alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.");
}
};
const headList = useMemo(
() => buildPipeAnalysisSummaryHeadList(appliedFilters.analysisType, materialColumns),
() =>
buildPipeAnalysisSummaryHeadList(
appliedFilters.analysisType,
materialColumns,
),
[appliedFilters.analysisType, materialColumns],
);
const yearMaterialGroups = useMemo(
() => appliedFilters.analysisType === "YEAR_MATERIAL"
? buildPipeAnalysisYearMaterialGroups(rows, materialColumns)
: [],
() =>
appliedFilters.analysisType === "YEAR_MATERIAL"
? buildPipeAnalysisYearMaterialGroups(rows, materialColumns)
: [],
[appliedFilters.analysisType, materialColumns, rows],
);
const orderedYearMaterialGroups = useMemo(
() =>
yearMaterialGroups.map((group, index) => ({
...group,
rowNo: yearMaterialGroups.length - index,
})),
[yearMaterialGroups],
);
const useHorizontalScrollTable = appliedFilters.analysisType !== "YEAR";
const summaryPageOffset = (summaryCurrentPage - 1) * PIPE_ANALYSIS_SUMMARY_PAGE_SIZE;
const summaryPageOffset =
(summaryCurrentPage - 1) * PIPE_ANALYSIS_SUMMARY_PAGE_SIZE;
const pagedRows = useMemo(
() =>
appliedFilters.analysisType === "YEAR_MATERIAL"
? rows
: rows.slice(summaryPageOffset, summaryPageOffset + PIPE_ANALYSIS_SUMMARY_PAGE_SIZE),
: rows.slice(
summaryPageOffset,
summaryPageOffset + PIPE_ANALYSIS_SUMMARY_PAGE_SIZE,
),
[appliedFilters.analysisType, rows, summaryPageOffset],
);
const visibleYearMaterialGroups = useMemo(
() =>
yearMaterialGroups.slice(
orderedYearMaterialGroups.slice(
summaryPageOffset,
summaryPageOffset + PIPE_ANALYSIS_SUMMARY_PAGE_SIZE,
),
[summaryPageOffset, yearMaterialGroups],
[orderedYearMaterialGroups, summaryPageOffset],
);
const summaryTotalCount = appliedFilters.analysisType === "YEAR_MATERIAL"
? yearMaterialGroups.length
: rows.length;
const sggOptions = useMemo(
() =>
draftFilters.ctpvCd === "0"
? sgg
: sgg.filter((item) => String(item.val).startsWith(draftFilters.ctpvCd.slice(0, 2))),
? []
: sgg.filter((item) =>
String(item.val).startsWith(draftFilters.ctpvCd.slice(0, 2)),
),
[draftFilters.ctpvCd, sgg],
);
return (
<main className="sn-popup-page">
<StandardPopupLayout
title={windowPopupDefinitions.pipeAnalysisPopup.title}
bodyClassName="overflow-x-hidden"
>
<LoadingOverlay isLoading={isLoading} />
<header className="sn-popup-header">
<h1 className="sn-popup-title">관로분석</h1>
</header>
<section className="sn-popup-section overflow-y-auto">
<div className="grid grid-cols-1 gap-3 border-b border-border bg-secondary pb-4 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_auto] md:items-end">
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-label">시도</div>
<select
className="sn-popup-input !text-[1.1rem]"
value={draftFilters.ctpvCd}
disabled={scope.ctpvLocked}
onChange={(event) =>
setDraftFilters((current) => ({
...current,
ctpvCd: event.target.value,
sggCd: "0",
swtrmtZoneNm: "0",
}))
}
>
<option value="0">전체</option>
{ctpv.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-label">시군구</div>
<select
className="sn-popup-input !text-[1.1rem]"
value={draftFilters.sggCd}
disabled={scope.sggLocked}
onChange={(event) =>
setDraftFilters((current) => ({
...current,
sggCd: event.target.value,
swtrmtZoneNm: "0",
}))
}
>
<option value="0">전체</option>
{sggOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-label">처리구역</div>
<select
className="sn-popup-input !text-[1.1rem]"
value={draftFilters.swtrmtZoneNm}
onChange={(event) =>
setDraftFilters((current) => ({
...current,
swtrmtZoneNm: event.target.value,
}))
}
>
{zoneOptions.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
<label className="block">
<div className="mb-1 text-[1.05rem] font-medium text-label">분석구분</div>
<select
className="sn-popup-input !text-[1.1rem]"
value={draftFilters.analysisType}
onChange={(event) =>
setDraftFilters((current) => ({
...current,
analysisType: event.target.value as PipeAnalysisSummaryFilters["analysisType"],
}))
}
>
{PIPE_ANALYSIS_TYPE_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
<div className="flex flex-wrap justify-end gap-2 md:pr-3">
<Button
type="button"
size="sm"
color="primary"
className="h-10 px-5 !text-[1.1rem]"
onClick={() => void handleSearch()}
>
조회
</Button>
<Button
type="button"
size="sm"
color="green"
className="h-10 px-5 !text-[1.1rem]"
onClick={() => void handleDownload()}
>
다운로드
</Button>
<PopupSearchPanel contentClassName="flex flex-wrap items-center gap-x-5 gap-y-3">
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
시도
</div>
<select
className="sn-popup-input !h-[3.2rem] w-[14rem] !text-xl"
value={draftFilters.ctpvCd}
disabled={scope.ctpvLocked}
onChange={(event) =>
setDraftFilters((current) => ({
...current,
ctpvCd: event.target.value,
sggCd: "0",
swtrmtZoneNm: "0",
}))
}
>
<option value="0">전체</option>
{ctpv.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
시군구
</div>
<select
className="sn-popup-input !h-[3.2rem] w-[14rem] !text-xl"
value={draftFilters.sggCd}
disabled={scope.sggLocked}
onChange={(event) =>
setDraftFilters((current) => ({
...current,
sggCd: event.target.value,
swtrmtZoneNm: "0",
}))
}
>
<option value="0">전체</option>
{sggOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
처리구역
</div>
<select
className="sn-popup-input !h-[3.2rem] w-[14rem] !text-xl"
value={draftFilters.swtrmtZoneNm}
onChange={(event) =>
setDraftFilters((current) => ({
...current,
swtrmtZoneNm: event.target.value,
}))
}
>
{zoneOptions.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
<label className="flex shrink-0 items-center gap-2">
<div className="shrink-0 whitespace-nowrap text-xl font-medium text-label">
분석구분
</div>
<select
className="sn-popup-input !h-[3.2rem] w-[18rem] !text-xl"
value={draftFilters.analysisType}
onChange={(event) =>
setDraftFilters((current) => ({
...current,
analysisType: event.target
.value as PipeAnalysisSummaryFilters["analysisType"],
}))
}
>
{PIPE_ANALYSIS_TYPE_OPTIONS.map((item) => (
<option key={item.value} value={item.value}>
{item.label}
</option>
))}
</select>
</label>
<div className="flex min-w-fit flex-1 items-center justify-end gap-2">
<Button
type="button"
color="primary"
onClick={() => void handleSearch()}
>
조회
</Button>
<Button
type="button"
color="white"
onClick={() => {
const resetFilters = applyScopeToSummaryFilters(
PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS,
scope,
);
setDraftFilters(resetFilters);
void handleSearch(resetFilters);
}}
>
초기화
</Button>
</div>
</PopupSearchPanel>
{appliedFilters.analysisType === "YEAR_MATERIAL" ? (
<>
<div className="card-header">
<h3 className="text-2xl font-medium text-label">
{resolvePipeAnalysisTypeLabel(appliedFilters.analysisType)} 관로현황 {summaryTotalCount.toLocaleString("ko-KR")}건
</h3>
</div>
<div className="card !p-0r !m-[1.2rem] overflow-x-auto">
<Table variant="secondary" className="w-max min-w-full caption-bottom text-sm">
<TableHeader className="font-bold">
<TableRow>
{headList.map((head) => (
<TableHead
key={head.id}
className={`border border-border bg-secondary px-4 py-[1.2rem] text-[1.3rem] font-medium text-label ${
head.align === "right" ? "text-right" : "text-center"
}`}
>
{head.title}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody className="text-center">
{visibleYearMaterialGroups.length > 0 ? (
visibleYearMaterialGroups.flatMap((group) =>
group.rows.map((yearRow, rowIndex) => (
<TableRow key={`${group.base.ctpvCd}-${group.base.sggCd}-${group.base.swtrmtZoneNm}-${yearRow.elapsedYearBucket}`}>
{rowIndex === 0 ? (
<>
<TableCell rowSpan={group.rows.length} className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]">
{group.rowNo}
</TableCell>
<TableCell rowSpan={group.rows.length} className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]">
{group.ctpvNm}
</TableCell>
<TableCell rowSpan={group.rows.length} className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]">
{group.sggNm}
</TableCell>
<TableCell rowSpan={group.rows.length} className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]">
{group.swtrmtZoneNm}
</TableCell>
<TableCell rowSpan={group.rows.length} className="border border-border px-2 py-[1.2rem] text-right text-[1.3rem]">
{formatGridNumber(group.totalLengthKm)}
</TableCell>
</>
) : null}
<TableCell className="border border-border px-2 py-[1.2rem] text-center text-[1.3rem]">
{yearRow.elapsedYearLabel}
</TableCell>
{materialColumns.map((column) => {
const value = Number(yearRow.metrics[column.key] ?? 0);
const isClickable = value > 0 && Boolean(yearRow.cellFilters[column.key]);
return (
<TableCell
key={`${group.base.ctpvCd}-${group.base.sggCd}-${group.base.swtrmtZoneNm}-${yearRow.elapsedYearBucket}-${column.key}`}
className={`border border-border px-2 py-[1.2rem] text-right text-[1.3rem] ${
isClickable ? "cursor-pointer font-semibold text-primary underline underline-offset-2" : ""
}`}
onClick={() => {
if (isClickable) {
handleYearMaterialCellClick(group, yearRow, column.key);
}
}}
>
{formatGridNumber(value)}
</TableCell>
);
})}
</TableRow>
))
)
) : (
<TableRow>
<TableCell colSpan={headList.length} className="h-24 border border-border text-center text-[1.3rem] text-label">
조회 결과가 없습니다.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{yearMaterialGroups.length > PIPE_ANALYSIS_SUMMARY_PAGE_SIZE ? (
<div className="flex justify-center">
<UiPageNations
total={yearMaterialGroups.length}
cur={summaryCurrentPage}
pageSize={PIPE_ANALYSIS_SUMMARY_PAGE_SIZE}
onPageChange={setSummaryCurrentPage}
/>
</div>
) : null}
</>
) : null}
<UiTable<PipeAnalysisSummaryViewRow>
className={appliedFilters.analysisType === "YEAR_MATERIAL"
? "hidden"
: useHorizontalScrollTable
? "overflow-x-auto overflow-y-visible [&_table]:min-w-full [&_table]:w-max"
: ""}
headName={
appliedFilters.analysisType === "YEAR_MATERIAL"
? `경과년도&재질 관로현황 ${summaryTotalCount.toLocaleString("ko-KR")}건`
: `관로분석현황 ${summaryTotalCount.toLocaleString("ko-KR")}건`
}
tableData={pagedRows}
headlist={headList}
pageSize={PIPE_ANALYSIS_SUMMARY_PAGE_SIZE}
total={rows.length}
cellClick={(index, columnId) =>
handleCellClick(
typeof index === "number" ? index + summaryPageOffset : index,
columnId,
)}
onPageChange={setSummaryCurrentPage}
curPage={summaryCurrentPage}
widthFixed={!useHorizontalScrollTable}
isHideCount={true}
cellClassName={(row, columnId) =>
!row.__isNoZone && row.__cellFilters[columnId]
? "cursor-pointer font-semibold text-blue-700 underline underline-offset-2"
: ""
}
/>
</section>
</main>
<PipeAnalysisSummaryTable
rows={pagedRows}
groups={visibleYearMaterialGroups}
grouped={appliedFilters.analysisType === "YEAR_MATERIAL"}
heads={headList}
title={`${resolvePipeAnalysisTypeLabel(appliedFilters.analysisType)} 목록`}
total={
appliedFilters.analysisType === "YEAR_MATERIAL"
? orderedYearMaterialGroups.length
: rows.length
}
page={summaryCurrentPage}
pageSize={PIPE_ANALYSIS_SUMMARY_PAGE_SIZE}
onPageChange={setSummaryCurrentPage}
onCellClick={handleCellClick}
onGroupedCellClick={handleYearMaterialCellClick}
actions={
<Button type="button" onClick={() => void handleDownload()}>
엑셀다운로드
</Button>
}
/>
</StandardPopupLayout>
);
}
@@ -0,0 +1,249 @@
"use client";
import { useMemo, type ReactNode } from "react";
import {
getCoreRowModel,
useReactTable,
type ColumnDef,
} from "@tanstack/react-table";
import {
StandardDataTable,
type StandardDataTableLayout,
type UiHeadProps,
} from "@common/business_components";
import type {
PipeAnalysisSummaryViewRow,
PipeAnalysisYearMaterialGroup,
PipeAnalysisYearMaterialGroupRow,
} from "./pipeAnalysisUtils";
type SummaryRow =
| {
kind: "flat";
value: PipeAnalysisSummaryViewRow;
sourceIndex: number;
}
| {
kind: "grouped";
group: PipeAnalysisYearMaterialGroup;
value: PipeAnalysisYearMaterialGroupRow;
groupIndex: number;
groupRowIndex: number;
};
type Props = {
rows: PipeAnalysisSummaryViewRow[];
groups: PipeAnalysisYearMaterialGroup[];
grouped: boolean;
heads: UiHeadProps[];
title: string;
total: number;
page: number;
pageSize: number;
actions: ReactNode;
onPageChange: (page: number) => void;
onCellClick: (row: PipeAnalysisSummaryViewRow, columnId: string) => void;
onGroupedCellClick: (
group: PipeAnalysisYearMaterialGroup,
row: PipeAnalysisYearMaterialGroupRow,
columnId: string,
) => void;
};
/** 지역 식별 열은 가로 스크롤해도 남겨 두고, 모든 집계 방식에 같은 너비를 사용한다. */
const FIXED_WIDTHS: Record<string, number> = {
rowNo: 56,
ctpvNm: 120,
sggNm: 120,
swtrmtZoneNm: 160,
totalLengthKm: 100,
};
/** 공통 그리드가 같은 순서로 왼쪽 고정 위치를 계산할 관로 식별 열이다. */
const PINNED_COLUMN_IDS = Object.keys(FIXED_WIDTHS);
/** 관로분석에서 선택하는 공통 표 레이아웃이며 화면 전용 CSS를 대체한다. */
const PIPE_SUMMARY_LAYOUT: StandardDataTableLayout = {
tableLayout: "fixed",
stickyHeader: true,
headerHeight: "64px",
headerWrap: true,
cellOverflow: "ellipsis",
};
/** 기존 집계와 같은 천 단위 구분 및 소수점 세 자리 표시를 유지한다. */
const formatValue = (value: unknown) => {
if (value === null || value === undefined || value === "") return "-";
return typeof value === "number"
? value.toLocaleString("ko-KR", { maximumFractionDigits: 3 })
: String(value);
};
/** 결과가 있는 셀만 링크형 버튼으로 표시하고 나머지는 전체값 title을 유지한다. */
const renderValue = (value: unknown, onClick?: () => void) => {
const formatted = formatValue(value);
return onClick ? (
<button
type="button"
className="w-full text-inherit text-primary underline underline-offset-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
onClick={onClick}
>
{formatted}
</button>
) : (
<span className="block overflow-hidden text-ellipsis" title={formatted}>
{formatted}
</span>
);
};
/** 표준 카드·페이징 안에서 관로 집계의 병합 셀과 고정 열을 렌더링한다. */
export default function PipeAnalysisSummaryTable({
rows,
groups,
grouped,
heads,
title,
total,
page,
pageSize,
actions,
onPageChange,
onCellClick,
onGroupedCellClick,
}: Props) {
// 병합 표도 공통 렌더러가 행을 순회할 수 있도록 처리구역의 4개 경과년도 행만 펼친다.
const data = useMemo<SummaryRow[]>(
() =>
grouped
? groups.flatMap((group, groupIndex) =>
group.rows.map((value, groupRowIndex) => ({
kind: "grouped" as const,
group,
value,
groupIndex,
groupRowIndex,
})),
)
: rows.map((value, sourceIndex) => ({
kind: "flat" as const,
value,
sourceIndex,
})),
[grouped, groups, rows],
);
// 공통 렌더러가 colgroup과 고정 위치에 사용할 열별 픽셀 너비를 한곳에서 계산한다.
const layout = useMemo(() => {
return heads.map((head) => {
const width =
FIXED_WIDTHS[head.id] ??
(head.id === "elapsedYearLabel"
? 112
: String(head.title).length > 10
? 160
: 120);
const fixed = head.id in FIXED_WIDTHS;
return { ...head, width, fixed };
});
}, [heads]);
// 모든 재질 열의 너비 합계를 최소 폭으로 사용해 좁은 팝업에서 열 압축을 막는다.
const tableWidth = layout.reduce((sum, head) => sum + head.width, 0);
// 열은 값·업무 클릭만 정의하고 실제 th/td, 고정 열과 overflow는 공통 렌더러가 소유한다.
const columns = useMemo<ColumnDef<SummaryRow>[]>(
() =>
layout.map((head) => ({
id: head.id,
size: head.width,
minSize: head.width,
maxSize: head.width,
header: () => <span title={String(head.title)}>{head.title}</span>,
cell: ({ row }) => {
const summaryRow = row.original;
if (summaryRow.kind === "flat") {
const value =
head.id === "rowNo"
? total - ((page - 1) * pageSize + summaryRow.sourceIndex)
: summaryRow.value[head.id];
const onClick =
summaryRow.value.__cellFilters[head.id] && Number(value) > 0
? () => onCellClick(summaryRow.value, head.id)
: undefined;
return renderValue(value, onClick);
}
if (head.fixed) {
return renderValue(
summaryRow.group[head.id as keyof PipeAnalysisYearMaterialGroup],
);
}
if (head.id === "elapsedYearLabel") {
return renderValue(summaryRow.value.elapsedYearLabel);
}
const value = Number(summaryRow.value.metrics[head.id] ?? 0);
const onClick =
value > 0 && summaryRow.value.cellFilters[head.id]
? () =>
onGroupedCellClick(
summaryRow.group,
summaryRow.value,
head.id,
)
: undefined;
return renderValue(value, onClick);
},
meta: {
align: head.align === "right" ? "right" : "center",
},
})),
[layout, onCellClick, onGroupedCellClick, page, pageSize, total],
);
// 페이지 단위는 기존과 동일하게 일반 표는 행, 병합 표는 처리구역 그룹이다.
const pagination = { pageIndex: Math.max(0, page - 1), pageSize };
const table = useReactTable({
data,
columns,
state: { pagination },
rowCount: total,
manualPagination: true,
getCoreRowModel: getCoreRowModel(),
getRowId: (row) =>
row.kind === "flat"
? `flat-${row.sourceIndex}`
: `group-${row.groupIndex}-${row.value.elapsedYearBucket}`,
onPaginationChange: (updater) => {
const next =
typeof updater === "function" ? updater(pagination) : updater;
if (next.pageIndex !== pagination.pageIndex)
onPageChange(next.pageIndex + 1);
},
});
return (
<StandardDataTable
key={`${title}-${page}`}
table={table}
title={title}
flushOuterMargin
scrollMode="both"
maxHeight="max(240px, calc(100dvh - 34rem))"
minTableWidth={`${tableWidth}px`}
layout={PIPE_SUMMARY_LAYOUT}
pinnedColumns={PINNED_COLUMN_IDS}
getCellProps={({ row, columnId }) => {
const summaryRow = row.original;
const isPinnedColumn = PINNED_COLUMN_IDS.includes(columnId);
const isGrouped = summaryRow.kind === "grouped";
return {
hidden: isGrouped && isPinnedColumn && summaryRow.groupRowIndex > 0,
rowSpan:
isGrouped && isPinnedColumn && summaryRow.groupRowIndex === 0
? summaryRow.group.rows.length
: undefined,
className:
isGrouped && summaryRow.groupRowIndex === 0
? "border-t border-border"
: undefined,
};
}}
actions={actions}
/>
);
}
@@ -1,13 +1,12 @@
/** 관로분석 검색 조건, 집계 컬럼과 표시값 변환 유틸을 제공한다. */
"use client";
import type { SelectDataType } from "@common/business_components";
import type { IUserInfo } from "@/shared/store";
import {
MS_SWAGE_PPL_CODES,
MS_SWAGE_PPL_USE_CODES,
} from "../common/table";
import { MS_SWAGE_PPL_CODES, MS_SWAGE_PPL_USE_CODES } from "../common/table";
export const PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE = "SN_PIPE_ANALYSIS_DETAIL_ROW_SELECT";
export const PIPE_ANALYSIS_DETAIL_ROW_SELECT_MESSAGE =
"SN_PIPE_ANALYSIS_DETAIL_ROW_SELECT";
export type PipeAnalysisType = "YEAR" | "MATERIAL" | "YEAR_MATERIAL";
@@ -126,22 +125,30 @@ export type PipeAnalysisYearMaterialGroup = {
rows: PipeAnalysisYearMaterialGroupRow[];
};
const materialMap = new Map(MS_SWAGE_PPL_CODES.map((item) => [item.val.trim(), item.text]));
const useMap = new Map(MS_SWAGE_PPL_USE_CODES.map((item) => [item.val.trim(), item.text]));
const materialMap = new Map(
MS_SWAGE_PPL_CODES.map((item) => [item.val.trim(), item.text]),
);
const useMap = new Map(
MS_SWAGE_PPL_USE_CODES.map((item) => [item.val.trim(), item.text]),
);
export const PIPE_ANALYSIS_TYPE_OPTIONS: Array<{ value: PipeAnalysisType; label: string }> = [
export const PIPE_ANALYSIS_TYPE_OPTIONS: Array<{
value: PipeAnalysisType;
label: string;
}> = [
{ value: "YEAR", label: "경과년도별" },
{ value: "MATERIAL", label: "재질별" },
{ value: "YEAR_MATERIAL", label: "경과년도&재질" },
];
export const PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS: PipeAnalysisSummaryFilters = {
analysisType: "YEAR",
ctpvCd: "0",
sggCd: "0",
swtrmtZoneNm: "0",
scopeRegionCode: "",
};
export const PIPE_ANALYSIS_SUMMARY_DEFAULT_FILTERS: PipeAnalysisSummaryFilters =
{
analysisType: "YEAR",
ctpvCd: "0",
sggCd: "0",
swtrmtZoneNm: "0",
scopeRegionCode: "",
};
export const PIPE_ANALYSIS_DETAIL_DEFAULT_FILTERS: PipeAnalysisDetailFilters = {
ctpvCd: "0",
@@ -172,7 +179,7 @@ const elapsedYearBucketLabelMap: Record<string, string> = {
const YEAR_MATERIAL_BASE_BUCKETS = ["LT10", "Y10_20", "Y20_30", "GE30"];
export const PIPE_ANALYSIS_DETAIL_HEADLIST = [
{ id: "rowNo", title: "NO", widthPercent: 6 },
{ id: "rowNo", title: "순번", widthPercent: 6 },
{ id: "ctpvNm", title: "시도", widthPercent: 9 },
{ id: "sggNm", title: "시군구", widthPercent: 10 },
{ id: "fcltMngNo", title: "시설관리번호", widthPercent: 15 },
@@ -185,7 +192,9 @@ export const PIPE_ANALYSIS_DETAIL_HEADLIST = [
{ id: "materialName", title: "관재질", widthPercent: 12 },
];
export const buildPipeAnalysisScope = (userInfo: IUserInfo | null): PipeAnalysisScope => {
export const buildPipeAnalysisScope = (
userInfo: IUserInfo | null,
): PipeAnalysisScope => {
const admin = userInfo?.adminRoleYn === "Y";
if (admin) {
return {
@@ -263,11 +272,16 @@ export const buildPipeAnalysisSummaryHeadList = (
materialColumns: PipeAnalysisMaterialColumn[],
) => {
const common = [
{ id: "rowNo", title: "NO", widthPercent: 6 },
{ id: "rowNo", title: "순번", widthPercent: 6 },
{ id: "ctpvNm", title: "시도", widthPercent: 10 },
{ id: "sggNm", title: "시군구", widthPercent: 10 },
{ id: "swtrmtZoneNm", title: "처리구역", widthPercent: 14 },
{ id: "totalLengthKm", title: "합계(km)", widthPercent: 10, align: "right" },
{
id: "totalLengthKm",
title: "합계(km)",
widthPercent: 10,
align: "right",
},
];
if (analysisType === "YEAR") {
@@ -309,12 +323,17 @@ export const buildPipeAnalysisSummaryViewRows = (
return response.data.map((row) => {
const isNoZone = String(row.swtrmtZoneNm ?? "").trim() === "NO_ZONE";
const isSummaryTotal = !String(row.ctpvCd ?? "").trim()
&& String(row.swtrmtZoneNm ?? "").trim() === "-";
const isSummaryTotal =
!String(row.ctpvCd ?? "").trim() &&
String(row.swtrmtZoneNm ?? "").trim() === "-";
const viewRow: PipeAnalysisSummaryViewRow = {
rowNo: row.rowNo,
ctpvNm: isNoZone ? "전체" : row.ctpvNm || resolveRegionName(row.ctpvCd, ctpvOptions, 2),
sggNm: isNoZone ? "-" : row.sggNm || resolveRegionName(row.sggCd, sggOptions, 5),
ctpvNm: isNoZone
? "전체"
: row.ctpvNm || resolveRegionName(row.ctpvCd, ctpvOptions, 2),
sggNm: isNoZone
? "-"
: row.sggNm || resolveRegionName(row.sggCd, sggOptions, 5),
swtrmtZoneNm: isNoZone ? "-" : row.swtrmtZoneNm || "-",
totalLengthKm: row.totalLengthKm ?? 0,
elapsedYearBucket: row.elapsedYearBucket ?? "",
@@ -357,16 +376,18 @@ export const buildPipeAnalysisSummaryViewRows = (
return viewRow;
}
const columns = response.analysisType === "YEAR_MATERIAL"
? materialCodes
: materialCodes;
const columns =
response.analysisType === "YEAR_MATERIAL" ? materialCodes : materialCodes;
columns.forEach((columnKey) => {
const value = Number(row.metrics?.[columnKey] ?? 0);
viewRow[columnKey] = value;
if (!isNoZone && !isSummaryTotal && value > 0) {
viewRow.__cellFilters[columnKey] = {
elapsedYearBucket: response.analysisType === "YEAR_MATERIAL" ? row.elapsedYearBucket : undefined,
elapsedYearBucket:
response.analysisType === "YEAR_MATERIAL"
? row.elapsedYearBucket
: undefined,
materialCode: columnKey === "OTHER" ? "" : columnKey,
};
}
@@ -416,14 +437,19 @@ export const buildPipeAnalysisYearMaterialGroups = (
group.rows.push({
elapsedYearBucket,
elapsedYearLabel: String(row.elapsedYearLabel ?? resolveElapsedYearBucketLabel(elapsedYearBucket)),
elapsedYearLabel: String(
row.elapsedYearLabel ??
resolveElapsedYearBucketLabel(elapsedYearBucket),
),
metrics,
cellFilters: row.__cellFilters,
});
});
return Array.from(groupMap.values()).map((group) => {
const rowByBucket = new Map(group.rows.map((row) => [row.elapsedYearBucket, row]));
const rowByBucket = new Map(
group.rows.map((row) => [row.elapsedYearBucket, row]),
);
const orderedBuckets = [...YEAR_MATERIAL_BASE_BUCKETS];
if (rowByBucket.has("UNKNOWN")) {
@@ -474,7 +500,7 @@ export const buildPipeAnalysisSummaryDownloadHeaders = (
materialColumns: PipeAnalysisMaterialColumn[],
) => {
const headers: Record<string, string> = {
rowNo: "NO",
rowNo: "순번",
ctpvNm: "시도",
sggNm: "시군구",
swtrmtZoneNm: "처리구역",
@@ -499,7 +525,7 @@ export const buildPipeAnalysisSummaryDownloadHeaders = (
};
export const PIPE_ANALYSIS_DETAIL_EXCEL_HEADERS = {
rowNo: "NO",
rowNo: "순번",
ctpvNm: "시도",
sggNm: "시군구",
fcltMngNo: "시설관리번호",
@@ -548,7 +574,9 @@ const toKoreanMaterialLabel = (value?: string) => {
const parenthesisMatches = Array.from(normalized.matchAll(/\(([^)]*)\)/g))
.map((match) => String(match[1] ?? "").trim())
.filter(Boolean);
const koreanParenthesisText = parenthesisMatches.find((text) => /[가-힣]/.test(text));
const koreanParenthesisText = parenthesisMatches.find((text) =>
/[가-힣]/.test(text),
);
if (koreanParenthesisText) {
const simplifiedParenthesisText = koreanParenthesisText
@@ -561,7 +589,9 @@ const toKoreanMaterialLabel = (value?: string) => {
return normalized;
};
export const resolveMaterialColumnName = (column: PipeAnalysisMaterialColumn) => {
export const resolveMaterialColumnName = (
column: PipeAnalysisMaterialColumn,
) => {
const rawName = String(column.materialName ?? "").trim();
const rawCode = String(column.materialCode ?? "").trim();
const rawKey = String(column.key ?? "").trim();
@@ -592,8 +622,8 @@ export const resolveElapsedYearBucketLabel = (bucket?: string) => {
export const resolvePipeAnalysisTypeLabel = (analysisType?: string) => {
const normalized = String(analysisType ?? "").trim();
return (
PIPE_ANALYSIS_TYPE_OPTIONS.find((option) => option.value === normalized)?.label
?? normalized
PIPE_ANALYSIS_TYPE_OPTIONS.find((option) => option.value === normalized)
?.label ?? normalized
);
};
@@ -611,25 +641,37 @@ export const resolveRegionName = (
const normalized = String(code ?? "").trim();
if (!normalized) return "-";
const prefix = normalized.slice(0, prefixLength);
return options.find((item) => String(item.val).trim() === prefix)?.text ?? prefix;
return (
options.find((item) => String(item.val).trim() === prefix)?.text ?? prefix
);
};
export const normalizeNumberInput = (value: string) => value.replace(/[^0-9]/g, "");
export const normalizeNumberInput = (value: string) =>
value.replace(/[^0-9]/g, "");
export const buildPipeAnalysisFileDate = () => new Date().toISOString().slice(0, 10);
export const buildPipeAnalysisFileDate = () =>
new Date().toISOString().slice(0, 10);
export const fetchPipeAnalysisZoneOptions = async ({
ctpvCd,
sggCd,
sggNm,
scopeRegionCode,
}: {
ctpvCd: string;
sggCd: string;
sggNm?: string;
scopeRegionCode?: string;
}): Promise<PipeAnalysisZoneOption[]> => {
const filters: string[] = [];
const selectedSggCd = String(sggCd ?? "").trim();
const selectedSggNm = String(sggNm ?? "").trim();
if (sggCd && sggCd !== "0") {
filters.push(`sgg_cd='${sggCd}'`);
// 처리구역 원천의 시군구코드가 잘못된 경우도 있어 시도 범위로 받은 뒤
// 기준코드 또는 시군구명 중 하나가 일치하는 처리구역만 남긴다.
const selectedCtpvCd =
ctpvCd && ctpvCd !== "0" ? ctpvCd : selectedSggCd.slice(0, 2);
filters.push(`ctpv_cd='${selectedCtpvCd}'`);
} else if (ctpvCd && ctpvCd !== "0") {
filters.push(`ctpv_cd='${ctpvCd}'`);
} else if (scopeRegionCode) {
@@ -646,7 +688,7 @@ export const fetchPipeAnalysisZoneOptions = async ({
request: "GetFeature",
typeName: "hasudo:tb_ms_swage_prcs_zone",
outputFormat: "application/json",
propertyName: "ctpv_cd,sgg_cd,swtrmt_zone_nm",
propertyName: "ctpv_cd,sgg_cd,sgg_nm,swtrmt_zone_nm",
});
if (filters.length > 0) {
@@ -665,12 +707,22 @@ export const fetchPipeAnalysisZoneOptions = async ({
features.forEach((feature: any) => {
const properties = feature?.properties ?? {};
const zoneName = String(properties.swtrmt_zone_nm ?? "").trim();
const featureSggCd = String(properties.sgg_cd ?? "").trim();
const featureSggNm = String(properties.sgg_nm ?? "").trim();
if (!zoneName) return;
if (
selectedSggCd &&
selectedSggCd !== "0" &&
featureSggCd !== selectedSggCd &&
(!selectedSggNm || featureSggNm !== selectedSggNm)
) {
return;
}
dedupMap.set(zoneName, {
value: zoneName,
label: zoneName,
ctpvCd: String(properties.ctpv_cd ?? "").trim(),
sggCd: String(properties.sgg_cd ?? "").trim(),
sggCd: selectedSggCd !== "0" ? selectedSggCd : featureSggCd,
});
});
@@ -1,4 +1,5 @@
/**
* 화면명: 하수관로 정보
* ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (하수관로)
*
* 📄 MsSwagePpl
@@ -34,7 +35,15 @@ const inputColumn = [
type operationDataType = { year: string; type: string, bizInfoNm: string, bizType: string };
const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClick?: () => void }) => {
const MsSwagePpl = ({
featureData,
onSaveClick,
surveyResultContent,
}: {
featureData: any;
onSaveClick?: () => void;
surveyResultContent?: React.ReactNode;
}) => {
const [formData, setFormData] = useState<any>({});
const [repairData, setRepairData] = useState<operationDataType[]>([]);
const [cleaningData, setCleaningData] = useState<operationDataType[]>([]);
@@ -48,6 +57,8 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic
const { BIZ_SE_CD, BIZ_TY_CD } = useStore();
const requestMngInstCd = String(featureData?.mng_inst_cd ?? featureData?.mngInstCd ?? "").trim();
const requestFcltMngNo = String(featureData?.fclt_mng_no ?? featureData?.fcltMngNo ?? "").trim();
// 조사관리 전용 탭은 기본·입력정보와 조사결과만 표시하고 일반 시설 유지관리 이력은 숨긴다.
const showOperationSections = !surveyResultContent;
// 마운트 및 업데이트 시 API 호출
@@ -58,7 +69,7 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic
}, [featureData]);
useEffect(() => {
if (!requestMngInstCd || !requestFcltMngNo) {
if (!showOperationSections || !requestMngInstCd || !requestFcltMngNo) {
setInspectionData([]);
setCleaningData([]);
setRepairData([]);
@@ -121,7 +132,7 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic
setCleaningData([]);
setRepairData([]);
});
}, [BIZ_SE_CD, BIZ_TY_CD, requestFcltMngNo, requestMngInstCd]);
}, [BIZ_SE_CD, BIZ_TY_CD, requestFcltMngNo, requestMngInstCd, showOperationSections]);
// 폼 데이터가 변경될 때 호출될 핸들러
const handleFormDataChange = (newFormData: any) => {
@@ -154,24 +165,29 @@ const MsSwagePpl = ({ featureData, onSaveClick }: { featureData: any, onSaveClic
onSaveClick={handleSaveClick}
onFormDataChange={handleFormDataChange} // 폼 데이터 변경 콜백 추가
/>
<InputSection
title="보수 및 갱신"
data={repairData}
sectionKey="repair"
onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)}
/>
<InputSection
title="청소 및 준설"
data={cleaningData}
sectionKey="cleaning"
onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)}
/>
<InputSection
title="점검 및 진단"
data={inspectionData}
sectionKey="inspection"
onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)}
/>
{surveyResultContent}
{showOperationSections && (
<>
<InputSection
title="보수 및 갱신"
data={repairData}
sectionKey="repair"
onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)}
/>
<InputSection
title="청소 및 준설"
data={cleaningData}
sectionKey="cleaning"
onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)}
/>
<InputSection
title="점검 및 진단"
data={inspectionData}
sectionKey="inspection"
onInputChange={(section, index, field, value) => handleInputChange(section, index, field, value, dataMap)}
/>
</>
)}
</>
)
}
@@ -1,6 +1,7 @@
/**
* 화면명: 우수토실 정보
* ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (우수토실)
*
*
* 📄 Ssc
* 우수토실 정보 컴포넌트
*
@@ -8,53 +9,78 @@
* - 중점우수토실 운영정보 모달 표시 (강우/측정 정보, 수량/수질측정 정보)
* - VerticalTable을 통한 입력 폼 렌더링
* - 저장 버튼을 통한 데이터 저장 기능
*
*
* 🔗 사용 API
* - 위경도로 강우시 미처리 하수 목록 조회: GET /api/v1/onsemiro/getSwgRainsByGeomety (sewerage-service)
* - 강우시 월류 하수 관리 모니터링 조회: GET /api/v1/onsemiro/swgRain/{adstDstrCd}/{manageNo}/mntrngs (sewerage-service)
*/
'use client';
import { UiTable } from '@common/business_components/ui';
import { Button, TableHead, TableRow } from '@common/components/ui';
import React, { useState, useEffect } from 'react';
import { untrtSwgRainCol3, untrtSwgRainCol4, untrtSwgRainCol5 } from '../common/table';
import { VerticalTable } from '@/components/ui/verticalTable';
"use client";
import { StandardPopupLayout } from "@common/business_components/ui";
import { Button } from "@common/components/ui";
import React, { useState, useEffect } from "react";
import {
untrtSwgRainCol3,
untrtSwgRainCol4,
untrtSwgRainCol5,
} from "../common/table";
import { VerticalTable } from "@/components/ui/verticalTable";
import { getRawJson } from "@/components/lib/api";
import { formatDisplayData } from '@/components/lib/utils/formatDisplayValue';
import { Label } from '@radix-ui/react-label';
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import { useAlertStore } from "@/shared/lib";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
const inputColumn = [
{ id: "prrtymngYn", title: "중점여부", type: "select", options: ["우수토실", "중점우수토실"] },
{
id: "prrtymngYn",
title: "중점여부",
type: "select",
options: ["우수토실", "중점우수토실"],
},
{ id: "swtrmtZoneNm", title: "처리구역", type: "input" },
];
const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick, renderMode = 'inline' }: {
featureData: any,
isMainSsc?: boolean,
chmbrXcnts?: string,
chmbrYdnts?: string,
onSaveClick?: () => void,
renderMode?: 'inline' | 'popup'
const toStandardHeadList = (headlist: typeof untrtSwgRainCol3) =>
headlist.map((head) => ({
...head,
title: ["no", "rowNo", "rowNumber"].includes(head.id) ? "순번" : head.title,
}));
const Ssc = ({
featureData,
isMainSsc,
chmbrXcnts,
chmbrYdnts,
onSaveClick,
renderMode = "inline",
}: {
featureData: any;
isMainSsc?: boolean;
chmbrXcnts?: string;
chmbrYdnts?: string;
onSaveClick?: () => void;
renderMode?: "inline" | "popup";
}) => {
const alert = useAlertStore((state) => state.setMessage);
const [formData, setFormData] = useState<any>({});
const [rainMeasureData, setRainMeasureData] = useState<any>({}); // 강우, 측정 정보
const [rainMeasureData, setRainMeasureData] = useState<any[]>([]); // 강우, 측정 정보
const [mntrngsData, setMntrngsData] = useState<any[]>([]); // 수량/수질측정 정보
const [totalCount, setTotalCount] = useState<number>(0);
const [totalDetailCount, setTotalDetailCount] = useState<number>(0);
const [selectedRowIndex, setSelectedRowIndex] = useState<number>(-1);
const [curPage, setCurPage] = useState<number>(1);
const [curDetailPage, setCurDetailPage] = useState<number>(1);
const [isResetPage, setIsResetPage] = useState<boolean>(true);
const [isResetDetailPage, setIsResetDetailPage] = useState<boolean>(true);
const [isResetSelection, setIsResetSelection] = useState<boolean>(false);
// 강우, 측정 정보 API 호출 함수
const fetchRainMeasureData = async (chmbrXcnts: string, chmbrYdnts: string) => {
setIsResetDetailPage(true);
setIsResetSelection(true);
const fetchRainMeasureData = async (
chmbrXcnts: string,
chmbrYdnts: string,
) => {
if (!chmbrXcnts || !chmbrYdnts) {
console.warn('추가 데이터 호출 실패: 필수 매개변수 누락', { chmbrXcnts, chmbrYdnts });
console.warn("추가 데이터 호출 실패: 필수 매개변수 누락", {
chmbrXcnts,
chmbrYdnts,
});
return [];
}
@@ -63,17 +89,21 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick, rend
chmbrXcnts: chmbrXcnts,
chmbrYdnts: chmbrYdnts,
page: `${curPage - 1}`,
size: '3',
size: "3",
});
const result = await getRawJson(`/sewerage-service/api/v1/onsemiro/getSwgRainsByGeomety?${queryParams.toString()}`);
const result = await getRawJson(
`/sewerage-service/api/v1/onsemiro/getSwgRainsByGeomety?${queryParams.toString()}`,
);
// helper: "YYYY-MM-DD HH:MM:SS" -> "MM-DD HH:MM"
const formatNoYearNoSec = (val: any) => {
if (!val && val !== 0) return "-";
const s = String(val);
// 정규식으로 안전하게 매칭 후 변환
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})(?::\d{2})?$/);
const m = s.match(
/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})(?::\d{2})?$/,
);
if (m) {
const [, , mm, dd, hh, min] = m;
return `${mm}-${dd} ${hh}:${min}`;
@@ -86,45 +116,70 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick, rend
const processedDataArray: any[] = result.content.map((data: any) => {
// 🎯 formatDisplayData로 기본 포맷팅 적용
const formattedItem = formatDisplayData(data);
const flwmtrInstTp = data.flwmtrInstTp === 'R' ? '이동식' : data.flwmtrInstTp === 'S' ? '고정식' : '-';
const flwmtrMesureTp = data.flwmtrMesureTp === 'R' ? '레이더' : data.flwmtrMesureTp === 'P' ? '플륨'
: data.flwmtrMesureTp === 'U' ? '초음파' : data.flwmtrMesureTp === 'E' ? '기타' : '-';
const flwmtrInstTp =
data.flwmtrInstTp === "R"
? "이동식"
: data.flwmtrInstTp === "S"
? "고정식"
: "-";
const flwmtrMesureTp =
data.flwmtrMesureTp === "R"
? "레이더"
: data.flwmtrMesureTp === "P"
? "플륨"
: data.flwmtrMesureTp === "U"
? "초음파"
: data.flwmtrMesureTp === "E"
? "기타"
: "-";
const flwmtrCombined = `${flwmtrInstTp}, ${flwmtrMesureTp}`;
// 🎯 추가 데이터 조합
return {
...formattedItem,
rainInfo: `${data.rainInfo === '0' ? '강우량계' : data.observatory || '-'}`, // 강우정보 0: 강우량계,1: 관측기관 이면 OBSERVATORY 이값사용
mesureIntrvl: `${data.mesureIntrvlGbn === '1' ? '1분' : (data.mesureIntrvlGbn === '0' ? data.mesureIntrvl + '분' : '-')}`, // 측정간격
flwmtrCombined: `${flwmtrCombined} / ${data.flwmtrKnd || '-'}`, // 유량계종류 및 모델명
mesureBeginDt: data.mesureBeginDt ? formatNoYearNoSec(data.mesureBeginDt) : '-', // 수량 측정시작일
mesureEndDt: data.mesureEndDt ? formatNoYearNoSec(data.mesureEndDt) : '-', // 수량 측정종료일
qltwtrBeginDt: data.qltwtrBeginDt ? formatNoYearNoSec(data.qltwtrBeginDt) : '-', // 수질 측정시작일(채수시작일)
qltwtrEndDt: data.qltwtrEndDt ? formatNoYearNoSec(data.qltwtrEndDt) : '-', // 수질 측정종료일(채수종료일)
rainInfo: `${data.rainInfo === "0" ? "강우량계" : data.observatory || "-"}`, // 강우정보 0: 강우량계,1: 관측기관 이면 OBSERVATORY 이값사용
mesureIntrvl: `${data.mesureIntrvlGbn === "1" ? "1분" : data.mesureIntrvlGbn === "0" ? data.mesureIntrvl + "분" : "-"}`, // 측정간격
flwmtrCombined: `${flwmtrCombined} / ${data.flwmtrKnd || "-"}`, // 유량계종류 및 모델명
mesureBeginDt: data.mesureBeginDt
? formatNoYearNoSec(data.mesureBeginDt)
: "-", // 수량 측정시작일
mesureEndDt: data.mesureEndDt
? formatNoYearNoSec(data.mesureEndDt)
: "-", // 수량 측정종료일
qltwtrBeginDt: data.qltwtrBeginDt
? formatNoYearNoSec(data.qltwtrBeginDt)
: "-", // 수질 측정시작일(채수시작일)
qltwtrEndDt: data.qltwtrEndDt
? formatNoYearNoSec(data.qltwtrEndDt)
: "-", // 수질 측정종료일(채수종료일)
};
});
setRainMeasureData(processedDataArray);
setTotalCount(result?.totalElements || 0);
} catch (error) {
console.error('추가 데이터 로딩 실패:', error);
console.error("추가 데이터 로딩 실패:", error);
return [];
}
};
// 수량/수질측정 정보 API 호출 함수
const fetchMntrngsData = async (adstDstrCd: string, manageNo: string) => {
setIsResetDetailPage(true);
if (!adstDstrCd || !manageNo) {
console.warn('추가 데이터 호출 실패: 필수 매개변수 누락', { adstDstrCd, manageNo });
console.warn("추가 데이터 호출 실패: 필수 매개변수 누락", {
adstDstrCd,
manageNo,
});
return [];
}
try {
const queryParams = new URLSearchParams({
page: `${curDetailPage - 1}`,
size: '3',
size: "3",
});
const result = await getRawJson(`/sewerage-service/api/v1/onsemiro/swgRain/${adstDstrCd}/${manageNo}/mntrngs?${queryParams.toString()}`);
const result = await getRawJson(
`/sewerage-service/api/v1/onsemiro/swgRain/${adstDstrCd}/${manageNo}/mntrngs?${queryParams.toString()}`,
);
// 각 아이템 전처리
const processedDataArray = result.content.map((data: any) => {
@@ -138,17 +193,16 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick, rend
setMntrngsData(processedDataArray);
setTotalDetailCount(result?.totalElements || 0);
} catch (error) {
console.error('추가 데이터 로딩 실패:', error);
console.error("추가 데이터 로딩 실패:", error);
return [];
}
};
// 🎯 추가 데이터는 별도 useEffect로 비동기 로딩
useEffect(() => {
// 강우/측정 정보 데이터 호출
if (isMainSsc && chmbrXcnts && chmbrYdnts) {
fetchRainMeasureData(chmbrXcnts, chmbrYdnts)
fetchRainMeasureData(chmbrXcnts, chmbrYdnts);
}
}, [chmbrXcnts, chmbrYdnts]);
@@ -160,7 +214,7 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick, rend
});
if (!popupWindow) {
alert('팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.');
alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.");
}
};
@@ -175,14 +229,17 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick, rend
// featureData가 변경될 때 formData 초기화
useEffect(() => {
// showTable이 true일 때만 fetch (최초 렌더 방지)
setIsResetDetailPage(false);
setSelectedRowIndex(-1);
if (chmbrXcnts && chmbrYdnts) fetchRainMeasureData(chmbrXcnts, chmbrYdnts);
}, [curPage]);
// 페이지 변경 시마다 데이터 fetch
useEffect(() => {
if (chmbrXcnts && chmbrYdnts && selectedRowIndex !== -1) fetchMntrngsData(rainMeasureData[selectedRowIndex].adstDstrCd, rainMeasureData[selectedRowIndex].manageNo);
if (chmbrXcnts && chmbrYdnts && selectedRowIndex !== -1)
fetchMntrngsData(
rainMeasureData[selectedRowIndex].adstDstrCd,
rainMeasureData[selectedRowIndex].manageNo,
);
}, [curDetailPage]);
// 폼 데이터가 변경될 때 호출될 핸들러
@@ -199,86 +256,72 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick, rend
};
// 테이블 셀 클릭 핸들러 추가
const handleCellClick = async (index: number | undefined, headId: string) => {
if (index !== undefined && rainMeasureData) {
await fetchMntrngsData(rainMeasureData[index].adstDstrCd, rainMeasureData[index].manageNo);
const handleCellClick = async (
_row: Record<string, unknown>,
index: number,
) => {
if (rainMeasureData[index]) {
await fetchMntrngsData(
rainMeasureData[index].adstDstrCd,
rainMeasureData[index].manageNo,
);
setSelectedRowIndex(index);
}
};
const renderModalBody = () => (
<div className='grid grid-cols-1 gap-4 p-4 mr-4 mt-4'>
<div className='border rounded-lg pt-4 bg-white'>
<Label className='text-2xl font-bold text-label ml-4'>강우/측정 정보</Label>
<hr className="my-4 ml-4 mr-4 border-t border-gray-300" />
<div className="grid grid-cols-1 gap-4">
<div className="space-y-4">
{/* 강우정보 테이블 */}
<UiTable
headName=''
tableData={rainMeasureData}
headlist={untrtSwgRainCol3}
<MapPopupDataTable
title="강우정보 목록"
rows={rainMeasureData}
headlist={toStandardHeadList(untrtSwgRainCol3)}
pageSize={3}
total={0}
cellClick={handleCellClick}
total={totalCount}
page={curPage}
onCellClick={handleCellClick}
onPageChange={setCurPage}
resetPageNo={isResetPage}
resetSelection={isResetSelection}
index={selectedRowIndex}
viewPagination={false}
scrollMode="horizontal"
minTableWidth="72rem"
/>
{/* 측정정보 테이블 */}
<UiTable
headName=''
tableData={rainMeasureData}
headlist={untrtSwgRainCol4}
<MapPopupDataTable
title="측정정보 목록"
rows={rainMeasureData}
headlist={toStandardHeadList(untrtSwgRainCol4)}
pageSize={3}
total={totalCount}
cellClick={handleCellClick}
page={curPage}
onCellClick={handleCellClick}
onPageChange={setCurPage}
resetPageNo={isResetPage}
resetSelection={isResetSelection}
index={selectedRowIndex}
>
{/* 커스텀 헤더 */}
<TableRow>
<TableHead colSpan={5} className="text-center bg-blue-100 font-bold">수량</TableHead>
<TableHead colSpan={3} className="text-center bg-blue-100 font-bold">수질</TableHead>
</TableRow>
<TableRow>
<TableHead className="text-center">측정시작일시</TableHead>
<TableHead className="text-center">측정종료일시</TableHead>
<TableHead className="text-center">측정간격(분)</TableHead>
<TableHead className="text-center">측정기기설치위치</TableHead>
<TableHead className="text-center">유량계 종류 및 모델명</TableHead>
<TableHead className="text-center">채수시작일시</TableHead>
<TableHead className="text-center">채수종료일시</TableHead>
<TableHead className="text-center">채수횟수</TableHead>
</TableRow>
</UiTable>
scrollMode="horizontal"
minTableWidth="88rem"
/>
</div>
<div className='border rounded-lg pt-4 bg-white'>
<div>
{/* 수량/수질측정 정보 테이블 */}
<Label className='text-2xl font-bold text-label ml-4'>수량/수질측정 정보</Label>
<hr className="my-4 ml-4 mr-4 border-t border-gray-300" />
<UiTable
headName=''
tableData={mntrngsData}
headlist={untrtSwgRainCol5}
<MapPopupDataTable
title="수량·수질측정 목록"
rows={mntrngsData}
headlist={toStandardHeadList(untrtSwgRainCol5)}
pageSize={3}
total={totalDetailCount}
cellClick={undefined}
page={curDetailPage}
onPageChange={setCurDetailPage}
resetPageNo={isResetDetailPage}
/>
</div>
</div>
)
);
return (
<div>
{!isMainSsc && (
<VerticalTable
headName='입력정보'
headName="입력정보"
tableData={featureData ? [formData] : []}
headlist={inputColumn}
pageSize={0}
@@ -286,7 +329,7 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick, rend
cellClick={undefined}
isEditable={true}
onPageChange={function (): void {
throw new Error('Function not implemented.');
throw new Error("Function not implemented.");
}}
showSaveButton={true}
onSaveClick={handleSaveClick}
@@ -294,13 +337,16 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick, rend
/>
)}
{isMainSsc && renderMode !== 'popup' && (
{isMainSsc && renderMode !== "popup" && (
<div>
<div className="card-header">
<h3 className="text-2xl mx-3 font-medium text-label">중점우수토실 운영정보</h3>
<h3 className="text-2xl mx-3 font-medium text-label">
중점우수토실 운영정보
</h3>
</div>
<Button
className='mx-auto block w-64 mt-3 py-2 text-white rounded-sm text-sm font-medium hover:bg-blue-700 bg-blue-600'
className="mx-auto mt-3 block w-64"
color="white"
onClick={openDetailPopup}
>
상세보기
@@ -308,14 +354,13 @@ const Ssc = ({ featureData, isMainSsc, chmbrXcnts, chmbrYdnts, onSaveClick, rend
</div>
)}
{isMainSsc && renderMode === 'popup' && (
<div className="min-h-dvh bg-white p-4">
<h1 className="mb-3 text-2xl font-semibold text-gray-900">중점우수토실 운영정보</h1>
{isMainSsc && renderMode === "popup" && (
<StandardPopupLayout title="중점우수토실 운영정보">
{renderModalBody()}
</div>
</StandardPopupLayout>
)}
</div>
)
}
);
};
export default Ssc
export default Ssc;
@@ -1,4 +1,5 @@
/**
* 화면명: 하수처리시설 정보
* ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (하수처리시설)
*
* 📄 SwtrmtFclt
@@ -13,6 +14,7 @@ import React from 'react'
import { VerticalTable } from '@/components/ui/verticalTable';
import { Button } from '@common/components/ui';
import { openWindowPopup } from '@/features/windowPopup/lib/openWindowPopup';
import { useAlertStore } from '@/shared/lib';
// 하수처리시설
const headList = [
@@ -20,6 +22,7 @@ const headList = [
];
const SwtrmtFclt = ({ gisObject }: { gisObject: any }) => {
const alert = useAlertStore((state) => state.setMessage);
const ctpvNm = gisObject?.ctpv_nm;
const sggNm = gisObject?.sgg_nm;
@@ -1,23 +1,76 @@
/**
* 화면명: 처리구역내 관로분석정보
* 선택한 하수처리구역의 관로 분석 현황과 보수이력 연계 기능을 제공한다.
*/
"use client";
import React, { useEffect, useMemo, useState } from "react";
import { Table, TableBody, TableCell, TableRow } from "@common/components";
import { fetchSwtrmtPrcsZoneAnalysis } from "./swtrmtPrcsZoneAnalysisApi";
import type { SwtrmtPrcsZoneAnalysisMetric, SwtrmtPrcsZoneAnalysisResponse } from "./swtrmtPrcsZoneAnalysisApi";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
Button,
Table,
TableBody,
TableCell,
TableRow,
} from "@common/components";
import { useGisContext } from "@common/business_components/ui/map/Gis";
import Feature from "ol/Feature.js";
import GeoJSON from "ol/format/GeoJSON.js";
import VectorLayer from "ol/layer/Vector.js";
import VectorSource from "ol/source/Vector.js";
import Stroke from "ol/style/Stroke.js";
import Style from "ol/style/Style.js";
const analysisRows: { key: keyof NonNullable<SwtrmtPrcsZoneAnalysisResponse["analysis"]>; title: string }[] = [
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import { useAlertStore } from "@/shared/lib";
import {
fetchSwtrmtPrcsZoneAnalysis,
getSwtrmtPrcsZoneId,
} from "./swtrmtPrcsZoneAnalysisApi";
import type {
RepairHistoryRow,
SwtrmtPrcsZoneAnalysisMetric,
SwtrmtPrcsZoneAnalysisResponse,
} from "./swtrmtPrcsZoneAnalysisApi";
const analysisRows: {
key: keyof NonNullable<SwtrmtPrcsZoneAnalysisResponse["analysis"]>;
title: string;
}[] = [
{ key: "total", title: "전체" },
{ key: "combined", title: "합류관" },
{ key: "rain", title: "우수관" },
{ key: "sewage", title: "오수관" },
];
const formatValue = (value: unknown, unit = "") => {
if (value === null || value === undefined || value === "") return "데이터 없음";
const repairHistoryStyle = new Style({
stroke: new Stroke({ color: "rgba(0, 229, 255, 0.95)", width: 5 }),
zIndex: 10020,
});
const numberValue = typeof value === "number" ? value : Number(String(value).replaceAll(",", ""));
const selectedRepairHistoryStyle = new Style({
stroke: new Stroke({ color: "rgba(255, 82, 82, 1)", width: 8 }),
zIndex: 10021,
});
const formatValue = (value: unknown, unit = "") => {
if (value === null || value === undefined || value === "")
return "데이터 없음";
const numberValue =
typeof value === "number"
? value
: Number(String(value).replaceAll(",", ""));
if (!Number.isNaN(numberValue) && Number.isFinite(numberValue)) {
const formatted = numberValue.toLocaleString("ko-KR", { maximumFractionDigits: 2 });
const formatted = numberValue.toLocaleString("ko-KR", {
maximumFractionDigits: 2,
});
return unit ? `${formatted}${unit}` : formatted;
}
@@ -26,7 +79,7 @@ const formatValue = (value: unknown, unit = "") => {
const getMetric = (
analysis: SwtrmtPrcsZoneAnalysisResponse["analysis"] | undefined,
key: keyof NonNullable<SwtrmtPrcsZoneAnalysisResponse["analysis"]>
key: keyof NonNullable<SwtrmtPrcsZoneAnalysisResponse["analysis"]>,
): SwtrmtPrcsZoneAnalysisMetric => analysis?.[key] ?? {};
const metricGroups: {
@@ -42,25 +95,25 @@ const MetricVerticalGrid = ({
title,
field,
analysis,
isLoading,
}: {
title: string;
field: keyof SwtrmtPrcsZoneAnalysisMetric;
analysis: SwtrmtPrcsZoneAnalysisResponse["analysis"] | undefined;
isLoading?: boolean;
}) => (
<div className="mb-4 last:mb-0">
<div className="text-xl mx-3 mb-2 mt-1 font-medium text-label">{title}</div>
<div className="mx-3 mb-2 mt-1 text-xl font-medium text-label">{title}</div>
<Table variant="secondary">
<TableBody className="text-center">
{analysisRows.map((row) => {
const metric = getMetric(analysis, row.key);
return (
<TableRow key={`${title}-${row.key}`}>
<TableCell className="font-bold bg-blue-100 text-[13px] w-[120px] whitespace-nowrap">
<TableCell className="w-[120px] whitespace-nowrap bg-secondary text-[13px] font-bold text-label">
{row.title}
</TableCell>
<TableCell className="text-[13px]">{isLoading ? "" : formatValue(metric[field])}</TableCell>
<TableCell className="text-[13px]">
{formatValue(metric[field])}
</TableCell>
</TableRow>
);
})}
@@ -69,6 +122,7 @@ const MetricVerticalGrid = ({
</div>
);
/** 처리구역 분석 패널과 보수이력 윈도우 팝업의 부모 지도 연계를 담당한다. */
const SwtrmtPrcsZoneAnalysis = ({
featureData,
rawFeatureData,
@@ -76,9 +130,16 @@ const SwtrmtPrcsZoneAnalysis = ({
featureData: any;
rawFeatureData?: any;
}) => {
const [analysisData, setAnalysisData] = useState<SwtrmtPrcsZoneAnalysisResponse | null>(null);
const alert = useAlertStore((state) => state.setMessage);
const { gisMap } = useGisContext() as unknown as { gisMap: any };
// 기존 처리구역 관로분석 정보의 조회 상태다.
const [analysisData, setAnalysisData] =
useState<SwtrmtPrcsZoneAnalysisResponse | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
// 보수이력 지도 레이어는 React 렌더링 대상이 아니므로 ref로 보관해 팝업 종료·구역 전환 때 제거한다.
const repairLayerRef = useRef<VectorLayer<VectorSource> | null>(null);
const repairPopupRef = useRef<Window | null>(null);
const requestKey = useMemo(() => {
const rawValues = rawFeatureData?.values_ ?? rawFeatureData ?? {};
@@ -87,68 +148,237 @@ const SwtrmtPrcsZoneAnalysis = ({
rawFeatureData?.id_,
rawFeatureData?.fid,
rawValues?.fid,
rawValues?.swtrmt_zone_sn,
rawValues?.swtrmt_fclt_cd,
featureData?.fid,
featureData?.swtrmtZoneSn,
featureData?.swtrmt_zone_sn,
featureData?.swtrmtFcltCd,
rawValues?.swtrmt_fclt_cd,
rawValues?.swtrmtFcltCd,
featureData?.swtrmt_fclt_cd,
].filter(Boolean).join("|");
featureData?.swtrmtFcltCd,
]
.filter(Boolean)
.join("|");
}, [featureData, rawFeatureData]);
const zoneId = useMemo(
() => getSwtrmtPrcsZoneId(rawFeatureData, featureData),
[featureData, rawFeatureData, requestKey],
);
/** 이전 보수이력 조회에서 만든 임시 벡터 레이어를 제거한다. */
const clearRepairLayer = useCallback(() => {
const layer = repairLayerRef.current;
if (layer && gisMap) gisMap.removeLayer(layer);
layer?.getSource()?.clear();
repairLayerRef.current = null;
}, [gisMap]);
/** 윈도우 팝업 조회 결과 전체를 청록색 관로 레이어로 교체한다. */
const replaceRepairLayer = useCallback(
(rows: RepairHistoryRow[]) => {
if (!gisMap) return;
clearRepairLayer();
const format = new GeoJSON();
const projection = gisMap.getView()?.getProjection();
const features = rows.flatMap((row) => {
if (!row.geometryJson) return [];
try {
const geometry = format.readGeometry(JSON.parse(row.geometryJson), {
dataProjection: "EPSG:3857",
featureProjection: projection,
});
const feature = new Feature({ geometry, repairFid: row.fid });
feature.setId(`repair-history-${row.fid}`);
return [feature];
} catch (error) {
console.warn("보수이력 관로 도형을 읽지 못했습니다.", row.fid, error);
return [];
}
});
if (features.length === 0) return;
const layer = new VectorLayer({
source: new VectorSource({ features }),
style: (feature) =>
feature.get("repairSelected")
? selectedRepairHistoryStyle
: repairHistoryStyle,
});
layer.set("repairHistoryLayer", true);
layer.setZIndex(10020);
gisMap.addLayer(layer);
repairLayerRef.current = layer;
},
[clearRepairLayer, gisMap],
);
/** 팝업에서 클릭한 관로를 붉게 선택하고 부모 지도를 해당 범위로 이동한다. */
const focusRepairFeature = useCallback(
(fid: string) => {
const features = repairLayerRef.current?.getSource()?.getFeatures() ?? [];
const selectedFeature = features.find(
(feature) => String(feature.get("repairFid")) === String(fid),
);
if (!selectedFeature || !gisMap) return;
features.forEach((feature) =>
feature.set("repairSelected", feature === selectedFeature),
);
repairLayerRef.current?.changed();
const extent = selectedFeature.getGeometry()?.getExtent();
if (extent) {
gisMap.getView().fit(extent, {
padding: [100, 100, 100, 460],
duration: 350,
maxZoom: 18,
});
}
},
[gisMap],
);
useEffect(() => {
let isMounted = true;
setIsLoading(true);
setErrorMessage("");
const fetchAnalysis = async () => {
setIsLoading(true);
setErrorMessage("");
setAnalysisData(null);
const loadAnalysis = async () => {
try {
const data = await fetchSwtrmtPrcsZoneAnalysis(rawFeatureData, featureData);
const data = await fetchSwtrmtPrcsZoneAnalysis(
rawFeatureData,
featureData,
);
if (!isMounted) return;
if (!data) {
setErrorMessage("분석정보 조회에 필요한 처리구역 식별값이 없습니다.");
setAnalysisData(null);
setErrorMessage(
"처리구역 식별값이 없어 관로분석정보를 조회하지 못했습니다.",
);
return;
}
setAnalysisData(data);
} catch (error) {
if (!isMounted) return;
console.error("하수처리구역 분석정보 로딩 중 오류:", error);
setErrorMessage("분석정보를 불러오지 못했습니다.");
setAnalysisData(null);
setErrorMessage(
error instanceof Error
? error.message
: "관로분석정보를 불러오지 못했습니다.",
);
} finally {
if (isMounted) setIsLoading(false);
}
};
fetchAnalysis();
void loadAnalysis();
return () => {
isMounted = false;
};
}, [featureData, rawFeatureData, requestKey]);
useEffect(() => {
const handleRepairPopupMessage = (event: MessageEvent) => {
if (event.origin !== window.location.origin) return;
const data = event.data;
const messageZoneId = String(data?.payload?.zoneId ?? "").trim();
if (!messageZoneId || messageZoneId !== zoneId) return;
if (
data?.type === WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SHOW_RESULTS
) {
replaceRepairLayer(
Array.isArray(data.payload?.rows) ? data.payload.rows : [],
);
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SELECT_PIPE) {
focusRepairFeature(String(data.payload?.fid ?? ""));
return;
}
if (data?.type === WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_CLEAR) {
clearRepairLayer();
}
};
window.addEventListener("message", handleRepairPopupMessage);
return () =>
window.removeEventListener("message", handleRepairPopupMessage);
}, [clearRepairLayer, focusRepairFeature, replaceRepairLayer, zoneId]);
useEffect(() => {
clearRepairLayer();
}, [clearRepairLayer, requestKey]);
useEffect(() => () => clearRepairLayer(), [clearRepairLayer]);
useEffect(() => {
const timerId = window.setInterval(() => {
if (repairPopupRef.current?.closed) {
repairPopupRef.current = null;
clearRepairLayer();
}
}, 500);
return () => window.clearInterval(timerId);
}, [clearRepairLayer]);
/** 선택 처리구역을 고정한 기존 공통 윈도우 팝업을 연다. */
const handleOpenRepairHistory = () => {
if (!zoneId) {
setErrorMessage("처리구역 식별값이 없어 보수이력을 열 수 없습니다.");
return;
}
clearRepairLayer();
const popup = openWindowPopup("repairHistoryPopup", {
zoneId,
zoneName:
analysisData?.zone?.swtrmtZoneNm ??
featureData?.swtrmt_zone_nm ??
featureData?.swtrmtZoneNm,
});
if (!popup) {
alert("팝업이 차단되었습니다. 브라우저 팝업 허용 설정을 확인해주세요.");
return;
}
repairPopupRef.current = popup;
};
return (
<>
<div className="card-header flex items-center justify-between">
<h3 className="text-2xl mx-3 font-medium text-label">처리구역내 관로분석정보</h3>
<h3 className="mx-3 text-2xl font-medium text-label">
처리구역내 관로분석정보
</h3>
<Button
type="button"
className="hover:bg-primary/90 mr-3 bg-primary text-primary-foreground"
onClick={handleOpenRepairHistory}
data-testid="repair-history-open"
>
보수이력
</Button>
</div>
<div className="card !mx-[1.2rem] !mb-[1.2rem] !mt-1 !p-0">
<div className="px-3 pb-3 pt-1">
{!isLoading && errorMessage && (
<div className="py-6 text-center text-[13px] text-gray-500">{errorMessage}</div>
)}
{(isLoading || !errorMessage) && (
{errorMessage ? (
<div className="mb-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-[13px] text-red-700">
{errorMessage}
</div>
) : null}
{isLoading ? (
<div
role="status"
className="flex min-h-[12rem] items-center justify-center rounded-md border border-border bg-background text-[1.2rem] text-muted-foreground"
>
관로분석정보를 조회하고 있습니다.
</div>
) : (
metricGroups.map((group) => (
<MetricVerticalGrid
key={group.field}
key={group.title}
title={group.title}
field={group.field}
analysis={analysisData?.analysis}
isLoading={isLoading}
/>
))
)}
@@ -1,3 +1,4 @@
/** 침입수·RDII 분석의 적용연월 선택 상태와 월 목록을 제공한다. */
import { useState, useEffect } from "react";
import moment from "moment";
import { getData } from "@/components/lib/api";
@@ -295,4 +296,4 @@ export const getChartDataRainData = (arr: TRainData[]) => {
lineVal0: 0,
barVal0: roundTo(data.amrf, 2),
}));
};
};
@@ -1,18 +1,25 @@
'use client'
/**
* 화면명: 침입수 분석
* 침입수 분석 조건 입력, 실행 상태와 분석결과를 표시한다.
*/
"use client";
import React, { useEffect, useMemo, useState } from 'react';
import moment from 'moment';
import React, { useEffect, useMemo, useState } from "react";
import moment from "moment";
import {
CHART_FORM,
type ChartConfig,
MainChart,
} from '@common/business_components';
import { SelectBox } from '@common/business_components/ui';
import { Button, Input, Label } from '@common/components';
StandardFormField,
StandardFormSection,
StandardPopupLayout,
} from "@common/business_components";
import { SelectBox } from "@common/business_components/ui";
import { Button, Input } from "@common/components";
import type { TInftwtAnls, TInftwtAnlsRslt, TOilqData } from './aplcnYms';
import * as fn from './aplcnYms';
import type { TInftwtAnls, TInftwtAnlsRslt, TOilqData } from "./aplcnYms";
import * as fn from "./aplcnYms";
type ModalOpenProp = {
pplMngNo?: string;
@@ -27,43 +34,46 @@ type DataType = {
val: number;
};
const INITIAL_ANALYSIS_OPTIONS = [{ val: 0, text: '선택' }];
const INITIAL_ANALYSIS_OPTIONS = [{ val: 0, text: "선택" }];
const InfilChart = ({
pplMngNo = '',
pplMngNo = "",
inftwtAnlsSn = 0,
aplcnYm = '',
analysisInfo = '',
aplcnYm = "",
analysisInfo = "",
resultOnly = false,
}: ModalOpenProp) => {
const [selectedAplcnYm, setSelectedAplcnYm] = useState('');
const [selectedAplcnYm, setSelectedAplcnYm] = useState("");
const [inftwtAnlss, setInftwtAnlss] = useState<TInftwtAnls[]>([]);
const [inftwtAnlsInfos, setInftwtAnlsInfos] = useState<{ text: string; val: number; hidden?: boolean }[]>(
INITIAL_ANALYSIS_OPTIONS,
);
const [inftwtAnlsInfos, setInftwtAnlsInfos] = useState<
{ text: string; val: number; hidden?: boolean }[]
>(INITIAL_ANALYSIS_OPTIONS);
const [inftwtAnls, setInftwtAnls] = useState<TInftwtAnls | null>(null);
const [selectedInftwtAnls, setSelectedInftwtAnls] = useState<number>(0);
const [chartData, setChartData] = useState<{ chartData1: DataType[]; chartData2: DataType[] }>({
const [chartData, setChartData] = useState<{
chartData1: DataType[];
chartData2: DataType[];
}>({
chartData1: [],
chartData2: [],
});
const [inftwtQty, setInftwtQty] = useState<string>('0');
const [inftwtQty, setInftwtQty] = useState<string>("0");
const aplcnYms = fn.useAplcnYms('inftwtAnlsRslt');
const normalizedPplMngNo = pplMngNo ? `L${pplMngNo}` : '';
const isResultOnlyMode = resultOnly && inftwtAnlsSn > 0 && Boolean(normalizedPplMngNo);
const aplcnYms = fn.useAplcnYms("inftwtAnlsRslt");
const normalizedPplMngNo = pplMngNo ? `L${pplMngNo}` : "";
const isResultOnlyMode =
resultOnly && inftwtAnlsSn > 0 && Boolean(normalizedPplMngNo);
const chartForm = CHART_FORM.LINE;
const chartConfig = {
chartData1: { label: '순오수량', color: 'red' },
chartData2: { label: '실측하수량', color: 'orange' },
chartData1: { label: "순오수량", color: "red" },
chartData2: { label: "실측하수량", color: "orange" },
} as ChartConfig;
const hasChartData = useMemo(() => {
return chartData.chartData1.length > 0 || chartData.chartData2.length > 0;
}, [chartData]);
const calculateFallbackInftwtQty = (
inftwtAnlsRslt: TInftwtAnlsRslt[],
oilqRows: TOilqData[],
@@ -79,26 +89,28 @@ const InfilChart = ({
return 0;
}
const avgPredc = predcValues.reduce((sum, value) => sum + value, 0) / predcValues.length;
const avgOilq = oilqValues.reduce((sum, value) => sum + value, 0) / oilqValues.length;
const avgPredc =
predcValues.reduce((sum, value) => sum + value, 0) / predcValues.length;
const avgOilq =
oilqValues.reduce((sum, value) => sum + value, 0) / oilqValues.length;
const dailyQty = (avgOilq - avgPredc) * 24;
return Math.round(dailyQty / 10) * 10;
};
const resetState = () => {
setSelectedAplcnYm('');
setSelectedAplcnYm("");
setInftwtAnlss([]);
setInftwtAnlsInfos(INITIAL_ANALYSIS_OPTIONS);
setInftwtAnls(null);
setSelectedInftwtAnls(0);
setChartData({ chartData1: [], chartData2: [] });
setInftwtQty('0');
setInftwtQty("0");
};
const loadResultChart = async (targetInftwtAnlsSn: number) => {
if (!targetInftwtAnlsSn || !normalizedPplMngNo) {
setChartData({ chartData1: [], chartData2: [] });
setInftwtQty('0');
setInftwtQty("0");
return;
}
@@ -106,13 +118,15 @@ const InfilChart = ({
const [inftwtAnlsRslt, oilq, qty] = await Promise.all([
fn.selectInftwtAnlsRslt(targetInftwtAnlsSn, normalizedPplMngNo),
fn.selectOilq(targetInftwtAnlsSn, normalizedPplMngNo).catch((err) => {
console.error('selectOilq 에러:', err);
console.error("selectOilq 에러:", err);
return [];
}),
fn.selectPplInftwtQty(targetInftwtAnlsSn, normalizedPplMngNo).catch((err) => {
console.error('selectPplInftwtQty 에러:', err);
return 0;
}),
fn
.selectPplInftwtQty(targetInftwtAnlsSn, normalizedPplMngNo)
.catch((err) => {
console.error("selectPplInftwtQty 에러:", err);
return 0;
}),
]);
setChartData({
@@ -120,26 +134,37 @@ const InfilChart = ({
chartData2: fn.getChartDataOilq(oilq ?? []),
});
const fallbackQty = calculateFallbackInftwtQty(inftwtAnlsRslt ?? [], oilq ?? []);
const fallbackQty = calculateFallbackInftwtQty(
inftwtAnlsRslt ?? [],
oilq ?? [],
);
const normalizedQty = Number(qty ?? Number.NaN);
const resolvedQty = Number.isFinite(normalizedQty) && !(normalizedQty === 0 && fallbackQty !== 0)
? normalizedQty
: fallbackQty;
const resolvedQty =
Number.isFinite(normalizedQty) &&
!(normalizedQty === 0 && fallbackQty !== 0)
? normalizedQty
: fallbackQty;
if (!Number.isFinite(normalizedQty) || (normalizedQty === 0 && fallbackQty !== 0)) {
console.warn('Inftwt qty API returned empty or zero; using chart-data fallback qty.', {
apiQty: qty,
fallbackQty,
resultCount: inftwtAnlsRslt?.length ?? 0,
oilqCount: oilq?.length ?? 0,
});
if (
!Number.isFinite(normalizedQty) ||
(normalizedQty === 0 && fallbackQty !== 0)
) {
console.warn(
"Inftwt qty API returned empty or zero; using chart-data fallback qty.",
{
apiQty: qty,
fallbackQty,
resultCount: inftwtAnlsRslt?.length ?? 0,
oilqCount: oilq?.length ?? 0,
},
);
}
setInftwtQty(resolvedQty.toLocaleString('ko-KR'));
setInftwtQty(resolvedQty.toLocaleString("ko-KR"));
} catch (error) {
console.error('침입수 분석결과 차트 조회 중 오류:', error);
console.error("침입수 분석결과 차트 조회 중 오류:", error);
setChartData({ chartData1: [], chartData2: [] });
setInftwtQty('0');
setInftwtQty("0");
}
};
@@ -159,10 +184,10 @@ const InfilChart = ({
setInftwtAnlss([]);
setInftwtAnls(null);
setSelectedInftwtAnls(0);
setInftwtQty('0');
setInftwtQty("0");
setChartData({ chartData1: [], chartData2: [] });
if (!selectedValue || selectedValue === '0') {
if (!selectedValue || selectedValue === "0") {
setInftwtAnlsInfos(INITIAL_ANALYSIS_OPTIONS);
return;
}
@@ -173,7 +198,7 @@ const InfilChart = ({
setInftwtAnlss(list);
setInftwtAnlsInfos(fn.getOptionsInftwtAnlsInfo(list));
} catch (error) {
console.error('selectInftwtAnls 에러:', error);
console.error("selectInftwtAnls 에러:", error);
setInftwtAnlss([]);
setInftwtAnlsInfos(INITIAL_ANALYSIS_OPTIONS);
}
@@ -184,37 +209,37 @@ const InfilChart = ({
setSelectedInftwtAnls(selectedValue);
setChartData({ chartData1: [], chartData2: [] });
setInftwtQty('0');
setInftwtQty("0");
if (selectedValue === 0) {
setInftwtAnls(null);
return;
}
const selectedAnls = inftwtAnlss.find((anls) => anls.inftwtAnlsSn === selectedValue) ?? null;
const selectedAnls =
inftwtAnlss.find((anls) => anls.inftwtAnlsSn === selectedValue) ?? null;
setInftwtAnls(selectedAnls);
};
const infilRsltChart = async () => {
if (!selectedAplcnYm || selectedInftwtAnls === 0 || !normalizedPplMngNo) {
setChartData({ chartData1: [], chartData2: [] });
setInftwtQty('0');
setInftwtQty("0");
return;
}
await loadResultChart(selectedInftwtAnls);
};
const renderInfoInput = (label: string, value?: string | number | null) => (
<div className="grid grid-cols-[minmax(0,32%)_minmax(0,68%)] items-center gap-3">
<Label className="w-auto text-[14px] font-semibold text-[#5c6470] whitespace-nowrap">{label}</Label>
<Input
className="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white px-3 text-xl md:text-xl text-[#2b3440]"
placeholder=""
value={value ?? ''}
readOnly
/>
</div>
/** 분석 메타데이터를 공통 표형 폼의 읽기 전용 셀로 표시한다. */
const renderInfoField = (
label: string,
value?: string | number | null,
span: 1 | 2 = 1,
) => (
<StandardFormField label={label} columns={2} span={span}>
<Input className="w-full" value={value ?? ""} readOnly />
</StandardFormField>
);
const handleReset = () => {
@@ -222,116 +247,122 @@ const InfilChart = ({
};
const renderResultPanel = () => (
<section className="relative rounded-[12px] border border-[#b1b1b1] bg-white p-4">
<h3 className="mb-4 text-[20px] font-extrabold tracking-[-0.02em] text-black">침입수 분석 결과</h3>
<div className="mb-4 grid grid-cols-2 gap-4">
<div className="flex min-h-[98px] items-center justify-between rounded-[14px] border border-[#4f88c6] bg-white px-6 py-4">
<div className="flex items-center gap-2 text-[16px] font-extrabold text-[#ef4444]">
<span>상수사용량 적용연월</span>
<section className="card card-border !m-0 min-w-0 overflow-hidden !p-[1.2rem]">
<StandardFormSection title="침입수 분석결과" columns={2} wideLabels>
<StandardFormField label="상수사용량 적용연월" columns={2}>
<div className="flex min-h-[3.2rem] items-center justify-end text-[1.35rem] font-semibold tabular-nums text-foreground">
{selectedAplcnYm
? moment(selectedAplcnYm, "YYYYMM").format("YYYY-MM")
: "-"}
</div>
<div className="text-[20px] font-extrabold text-[#222]">
{selectedAplcnYm ? moment(selectedAplcnYm, 'YYYYMM').format('YYYY-MM') : '-'}
</div>
</div>
<div className="flex min-h-[98px] items-center justify-between rounded-[14px] border border-[#4f88c6] bg-white px-6 py-4">
<div className="flex items-center gap-2 text-[16px] font-extrabold text-[#ef4444]">
<span>침입수량 산정결과</span>
</div>
<div className="flex items-end gap-3 whitespace-nowrap text-[#222]">
<span className="text-[16px] text-[#666]">침입수량</span>
<div className="flex items-end justify-start gap-2 whitespace-nowrap">
<span className="text-[20px] font-extrabold">{inftwtQty}</span>
<span className="pb-[2px] text-[16px]">m³/d</span>
</StandardFormField>
<StandardFormField label="침입수량 산정결과" columns={2}>
<div className="flex min-h-[3.2rem] items-center justify-end gap-3 whitespace-nowrap text-foreground">
<span className="text-[1.15rem] text-muted-foreground">
침입수량
</span>
<div className="flex items-baseline gap-2">
<strong className="text-[1.5rem] font-semibold tabular-nums">
{inftwtQty}
</strong>
<span className="text-[1.1rem]">m³/d</span>
</div>
</div>
</div>
</div>
</StandardFormField>
</StandardFormSection>
<div className="rounded-[10px] bg-white px-2 pb-2 pt-3">
{hasChartData ? (
<MainChart
chartConfig={chartConfig}
chartForm={chartForm}
chartData={chartData}
chartTitle={'침입수량 그래프'}
yFieldText="유량(m³/h)"
yFieldText2=""
/>
) : (
<div className="flex h-[464px] items-center justify-center text-[18px] text-[#2c2f33]">
데이터가 없습니다.
</div>
)}
</div>
<section className="mt-[1.2rem] overflow-hidden rounded-sm border border-border bg-background">
<h4 className="border-b border-border bg-container px-4 py-3 text-[1.4rem] font-semibold text-label">
침입수량 그래프
</h4>
<div className="min-h-[30rem] p-3">
{hasChartData ? (
<MainChart
chartConfig={chartConfig}
chartForm={chartForm}
chartData={chartData}
chartTitle=""
yFieldText="유량(m³/h)"
yFieldText2=""
/>
) : (
<div
className="flex min-h-[30rem] items-center justify-center text-[1.3rem] text-muted-foreground"
role="status"
>
조회된 데이터가 없습니다.
</div>
)}
</div>
</section>
</section>
);
const renderDefaultBody = () => (
<div className="text-black">
<div className="grid min-h-[68vh] grid-cols-[minmax(0,38%)_minmax(0,62%)] gap-5">
<section className="relative rounded-[12px] border border-[#b1b1b1] bg-white p-5">
<h3 className="mb-6 text-[20px] font-extrabold tracking-[-0.02em] text-black">
침입수 분석 선택 조건
</h3>
<div className="flex min-h-0 flex-1 flex-col gap-[1.2rem]">
<StandardFormSection title="침입수 분석 조건" columns={2}>
<StandardFormField label="상수사용월" columns={2}>
<SelectBox
className="w-full"
selectClass="w-full"
label=""
selectArray={aplcnYms}
value={selectedAplcnYm}
onSelectValue={handleAplcnYmChange}
/>
</StandardFormField>
<StandardFormField label="분석정보" columns={2}>
<SelectBox
className="w-full"
selectClass="w-full"
label=""
selectArray={inftwtAnlsInfos}
value={selectedInftwtAnls}
onSelectValue={handleInftwtAnlsSnChange}
/>
</StandardFormField>
{renderInfoField("오수전환율", inftwtAnls?.drtwSwtcRt ?? "")}
{renderInfoField(
"시간패턴 파일명",
inftwtAnls?.atflHrPttnNm ?? "",
)}
{renderInfoField("수용가-맨홀 매칭", inftwtAnls?.atflMpngNm ?? "")}
{renderInfoField("관망도 파일명", inftwtAnls?.atflInpNm ?? "")}
{renderInfoField("유량 파일명", inftwtAnls?.atflOilqNm ?? "", 2)}
</StandardFormSection>
<div className="rounded-[6px] border border-[#d9dde3] bg-[#eff1f4] px-4 py-6">
<div className="space-y-3">
<div className="grid grid-cols-[minmax(0,32%)_minmax(0,68%)] items-center gap-3">
<Label className="w-auto text-[14px] font-semibold text-[#5c6470] whitespace-nowrap">
상수사용월:
</Label>
<SelectBox
className="w-full"
selectClass="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white text-xl"
label=""
selectArray={aplcnYms}
value={selectedAplcnYm}
onSelectValue={handleAplcnYmChange}
/>
</div>
{renderResultPanel()}
</div>
);
<div className="grid grid-cols-[minmax(0,32%)_minmax(0,68%)] items-center gap-3">
<Label className="w-auto text-[14px] font-semibold text-[#5c6470] whitespace-nowrap">
분석정보:
</Label>
<SelectBox
className="w-full"
selectClass="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white text-xl"
label=""
selectArray={inftwtAnlsInfos}
value={selectedInftwtAnls}
onSelectValue={handleInftwtAnlsSnChange}
/>
</div>
{renderInfoInput('오수전환율:', inftwtAnls?.drtwSwtcRt ?? '')}
{renderInfoInput('시간패턴 파일명:', inftwtAnls?.atflHrPttnNm ?? '')}
{renderInfoInput('수용가-맨홀 매칭:', inftwtAnls?.atflMpngNm ?? '')}
{renderInfoInput('관망도 파일명:', inftwtAnls?.atflInpNm ?? '')}
{renderInfoInput('유량 파일명:', inftwtAnls?.atflOilqNm ?? '')}
</div>
</div>
<div className="absolute inset-x-0 bottom-0 mb-[40px] flex justify-center">
<Button size="lg" onClick={infilRsltChart} >
조회
</Button>&nbsp;&nbsp;
<Button size="lg" color="gray" onClick={handleReset} >
초기화
</Button>
</div>
</section>
{renderResultPanel()}
</div>
/** 조회·초기화 뒤에 창 닫기 성격의 취소 액션을 항상 마지막에 배치한다. */
const renderFooterActions = () => (
<div className="flex w-full items-center justify-center gap-2">
{!isResultOnlyMode ? (
<>
<Button type="button" onClick={infilRsltChart}>
조회
</Button>
<Button type="button" color="white" onClick={handleReset}>
초기화
</Button>
</>
) : null}
<Button type="button" color="white" onClick={() => window.close()}>
취소
</Button>
</div>
);
return (
<div className="min-h-dvh bg-white p-3">
<StandardPopupLayout
title={isResultOnlyMode ? "침입수 분석결과" : "침입수 분석"}
bodyClassName="overflow-x-hidden"
footer={renderFooterActions()}
showDefaultCancelAction={false}
>
{isResultOnlyMode ? renderResultPanel() : renderDefaultBody()}
</div>
</StandardPopupLayout>
);
};
@@ -1,3 +1,7 @@
/**
* 화면명: RDII 분석
* RDII 분석 조건 입력, 실행 상태와 분석결과를 표시한다.
*/
"use client";
import React, { useEffect, useMemo, useState } from "react";
@@ -7,9 +11,12 @@ import {
CHART_FORM,
type ChartConfig,
MainChart,
StandardFormField,
StandardFormSection,
StandardPopupLayout,
} from "@common/business_components";
import { SelectBox } from "@common/business_components/ui";
import { Button, Input, Label } from "@common/components";
import { Button, Input } from "@common/components";
import type { TRdiiAnls } from "./aplcnYms";
import * as fn from "./aplcnYms";
@@ -45,18 +52,20 @@ const RdiiChart = ({
}: ModalOpenProp) => {
const [selectedAplcnYm, setSelectedAplcnYm] = useState("");
const [rdiiAnlss, setRdiiAnlss] = useState<TRdiiAnls[]>([]);
const [rdiiAnlsInfos, setRdiiAnlsInfos] = useState<{ text: string; val: number; hidden?: boolean }[]>(
INITIAL_ANALYSIS_OPTIONS,
);
const [rdiiAnlsInfos, setRdiiAnlsInfos] = useState<
{ text: string; val: number; hidden?: boolean }[]
>(INITIAL_ANALYSIS_OPTIONS);
const [rdiiAnls, setRdiiAnls] = useState<TRdiiAnls | null>(null);
const [selectedRainInfwtAnlsSn, setSelectedRainInfwtAnlsSn] = useState<number>(0);
const [selectedRainInfwtAnlsSn, setSelectedRainInfwtAnlsSn] =
useState<number>(0);
const [chartData, setChartData] = useState<LineBarChartData[]>([]);
const [rdiiQty, setRdiiQty] = useState<string>("0");
const [rainTotal, setRainTotal] = useState<string>("0");
const [totalVolume, setTotalVolume] = useState<string>("0");
const normalizedPplMngNo = normalizePplMngNo(pplMngNo);
const isResultOnlyMode = resultOnly && rainInfwtAnlsSn > 0 && Boolean(normalizedPplMngNo);
const isResultOnlyMode =
resultOnly && rainInfwtAnlsSn > 0 && Boolean(normalizedPplMngNo);
const aplcnYms = fn.useAplcnYms(isResultOnlyMode ? "" : "rdiiAnlsRslt");
const chartForm = CHART_FORM.LINEBAR2;
@@ -103,7 +112,9 @@ const RdiiChart = ({
const rsltDataFormatted = fn.getChartDataRdiiAnlsRslt(rsltData ?? []);
const rainDataFormatted = fn.getChartDataRainData(rainData ?? []);
const rainMap = new Map(rainDataFormatted.map((item) => [item.label, item.barVal0]));
const rainMap = new Map(
rainDataFormatted.map((item) => [item.label, item.barVal0]),
);
rsltDataFormatted.forEach((item) => {
const matchedVal = rainMap.get(item.label);
@@ -118,7 +129,9 @@ const RdiiChart = ({
const appliedRainfall = Number(qtyData?.raintotal ?? 0);
const rdiiPerRainfall =
qtyData?.raintotal && qtyData?.raintotal !== 0
? Math.round(avgRdii * Number(qtyData?.sustaintime ?? 0) / qtyData.raintotal)
? Math.round(
(avgRdii * Number(qtyData?.sustaintime ?? 0)) / qtyData.raintotal,
)
: 0;
setRdiiQty(Math.round(avgRdii * 24).toLocaleString("ko-KR"));
@@ -179,7 +192,8 @@ const RdiiChart = ({
return;
}
const selectedAnls = rdiiAnlss.find((anls) => anls.rainInfwtAnlsSn === selectedValue) ?? null;
const selectedAnls =
rdiiAnlss.find((anls) => anls.rainInfwtAnlsSn === selectedValue) ?? null;
setRdiiAnls(selectedAnls);
};
@@ -195,136 +209,140 @@ const RdiiChart = ({
await loadResultChart(selectedRainInfwtAnlsSn);
};
const renderInfoInput = (label: string, value?: string | number | null) => (
<div className="grid grid-cols-[minmax(0,32%)_minmax(0,68%)] items-center gap-3">
<Label className="w-auto whitespace-nowrap text-[14px] font-semibold text-[#5c6470]">
{label}
</Label>
<Input
className="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white px-3 text-xl text-[#2b3440]"
placeholder=""
value={value ?? ""}
readOnly
/>
</div>
/** 분석 메타데이터를 공통 표형 폼의 읽기 전용 셀로 표시한다. */
const renderInfoField = (
label: string,
value?: string | number | null,
span: 1 | 2 = 1,
) => (
<StandardFormField label={label} columns={2} span={span}>
<Input className="w-full" value={value ?? ""} readOnly />
</StandardFormField>
);
const renderResultPanel = () => (
<section className="relative rounded-[12px] border border-[#b1b1b1] bg-white p-4">
<h3 className="mb-4 text-[20px] font-extrabold tracking-[-0.02em] text-black">RDII 분석 결과</h3>
<div className="mb-4 grid grid-cols-2 gap-4">
<div className="flex min-h-[98px] items-center justify-between rounded-[14px] border border-[#4f88c6] bg-white px-6 py-4">
<div className="flex items-center gap-2 text-[16px] font-extrabold text-[#ef4444]">
<span>상수사용 적용연월</span>
<section className="card card-border !m-0 min-w-0 overflow-hidden !p-[1.2rem]">
<StandardFormSection title="RDII 분석결과" columns={2} wideLabels>
<StandardFormField label="상수사용 적용연월" columns={2}>
<div className="flex min-h-[3.2rem] items-center justify-end text-[1.35rem] font-semibold tabular-nums text-foreground">
{selectedAplcnYm
? moment(selectedAplcnYm, "YYYYMM").format("YYYY-MM")
: "-"}
</div>
<div className="text-[20px] font-extrabold text-[#222]">
{selectedAplcnYm ? moment(selectedAplcnYm, "YYYYMM").format("YYYY-MM") : "-"}
</StandardFormField>
<StandardFormField label="강우유입수량 분석결과" columns={2}>
<div className="grid min-h-[3.2rem] grid-cols-[minmax(0,1fr)_7rem_5rem] items-center gap-x-3 gap-y-1 text-[1.1rem] text-foreground">
<span className="text-right text-muted-foreground">평균 RDII</span>
<strong className="text-right text-[1.35rem] font-semibold tabular-nums">
{rdiiQty}
</strong>
<span>m³/d</span>
<span className="text-right text-muted-foreground">적용 강우량</span>
<strong className="text-right text-[1.35rem] font-semibold tabular-nums">
{rainTotal}
</strong>
<span>mm</span>
<span className="text-right text-muted-foreground">
강우량 대비 RDII
</span>
<strong className="text-right text-[1.35rem] font-semibold tabular-nums">
{totalVolume}
</strong>
<span>m³/mm</span>
</div>
</div>
</StandardFormField>
</StandardFormSection>
<div className="flex min-h-[98px] items-center justify-between rounded-[14px] border border-[#4f88c6] bg-white px-6 py-4">
<div className="shrink-0 whitespace-nowrap text-[14px] font-extrabold text-[#ef4444]">
<span>강우유입수량 분석결과</span>
</div>
<div className="ml-4 flex min-w-0 basis-[60%] flex-col gap-[2px]">
<div className="flex min-w-0 items-center gap-3">
<div className="min-w-0 basis-[48%] text-right text-[15px] text-[#555]">평균RDII</div>
<div className="min-w-0 basis-[32%] text-right text-[18px] font-extrabold text-[#111] tabular-nums">{rdiiQty}</div>
<div className="min-w-0 basis-[20%] text-left text-[14px] text-[#111]">m³/d</div>
<section className="mt-[1.2rem] overflow-hidden rounded-sm border border-border bg-background">
<h4 className="border-b border-border bg-container px-4 py-3 text-[1.4rem] font-semibold text-label">
RDII 그래프
</h4>
<div className="min-h-[30rem] p-3">
{hasChartData ? (
<MainChart
chartConfig={chartConfig}
chartForm={chartForm}
chartData={chartData}
chartTitle=""
yFieldText2="강수량(mm)"
yFieldText="유량(m³/h)"
/>
) : (
<div
className="flex min-h-[30rem] items-center justify-center text-[1.3rem] text-muted-foreground"
role="status"
>
조회된 데이터가 없습니다.
</div>
<div className="flex min-w-0 items-center gap-3">
<div className="min-w-0 basis-[48%] text-right text-[15px] text-[#555]">적용 강우량</div>
<div className="min-w-0 basis-[32%] text-right text-[18px] font-extrabold text-[#111] tabular-nums">{rainTotal}</div>
<div className="min-w-0 basis-[20%] text-left text-[14px] text-[#111]">mm</div>
</div>
<div className="flex min-w-0 items-center gap-3">
<div className="min-w-0 basis-[48%] text-right text-[15px] text-[#555]">강우량 대비 RDII</div>
<div className="min-w-0 basis-[32%] text-right text-[18px] font-extrabold text-[#111] tabular-nums">{totalVolume}</div>
<div className="min-w-0 basis-[20%] text-left text-[14px] text-[#111]">m³/mm</div>
</div>
</div>
)}
</div>
</div>
<div className="rounded-[10px] bg-white px-2 pb-2 pt-3">
{hasChartData ? (
<MainChart
chartConfig={chartConfig}
chartForm={chartForm}
chartData={chartData}
chartTitle={"RDII 그래프"}
yFieldText2="강수량(mm)"
yFieldText="유량(m³/h)"
/>
) : (
<div className="flex h-[464px] items-center justify-center text-[18px] text-[#2c2f33]">데이터가 없습니다.</div>
)}
</div>
</section>
</section>
);
const renderDefaultBody = () => (
<div className="text-black">
<div className="grid min-h-[68vh] grid-cols-[minmax(0,36%)_minmax(0,64%)] gap-5">
<section className="relative rounded-[12px] border border-[#b1b1b1] bg-white p-5">
<h3 className="mb-6 text-[20px] font-extrabold tracking-[-0.02em] text-black">강우유입수량 선택 조건</h3>
<div className="flex min-h-0 flex-1 flex-col gap-[1.2rem]">
<StandardFormSection title="RDII 분석 조건" columns={2}>
<StandardFormField label="상수사용월" columns={2}>
<SelectBox
className="w-full"
selectClass="w-full"
label=""
selectArray={aplcnYms}
value={selectedAplcnYm}
onSelectValue={handleAplcnYmChange}
/>
</StandardFormField>
<StandardFormField label="분석정보" columns={2}>
<SelectBox
className="w-full"
selectClass="w-full"
label=""
selectArray={rdiiAnlsInfos}
value={selectedRainInfwtAnlsSn}
onSelectValue={handleRdiiAnlsSnChange}
/>
</StandardFormField>
{renderInfoField("오수전환율", rdiiAnls?.drtwSwtcRt ?? "")}
{renderInfoField("수용가-맨홀 매칭", rdiiAnls?.atflMpngNm ?? "")}
{renderInfoField("관망도 파일명", rdiiAnls?.atflInpNm ?? "")}
{renderInfoField("강우 파일명", rdiiAnls?.atflRainNm ?? "")}
{renderInfoField("RTK계수 출처", rdiiAnls?.srcNm ?? "", 2)}
</StandardFormSection>
<div className="rounded-[6px] border border-[#d9dde3] bg-[#eff1f4] px-4 py-6">
<div className="space-y-3">
<div className="grid grid-cols-[minmax(0,32%)_minmax(0,68%)] items-center gap-3">
<Label className="w-auto whitespace-nowrap text-[14px] font-semibold text-[#5c6470]">상수사용월</Label>
<SelectBox
className="w-full"
selectClass="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white text-xl"
label=""
selectArray={aplcnYms}
value={selectedAplcnYm}
onSelectValue={handleAplcnYmChange}
/>
</div>
<div className="grid grid-cols-[minmax(0,32%)_minmax(0,68%)] items-center gap-3">
<Label className="w-auto whitespace-nowrap text-[14px] font-semibold text-[#5c6470]">분석정보:</Label>
<SelectBox
className="w-full"
selectClass="h-[42px] rounded-[5px] border border-[#d7dde5] bg-white text-xl"
label=""
selectArray={rdiiAnlsInfos}
value={selectedRainInfwtAnlsSn}
onSelectValue={handleRdiiAnlsSnChange}
/>
</div>
{renderInfoInput("오수전환율", rdiiAnls?.drtwSwtcRt ?? "")}
{renderInfoInput("수용가-맨홀 매칭:", rdiiAnls?.atflMpngNm ?? "")}
{renderInfoInput("관망도 파일명", rdiiAnls?.atflInpNm ?? "")}
{renderInfoInput("강우 파일명", rdiiAnls?.atflRainNm ?? "")}
{renderInfoInput("RTK계수 출처:", rdiiAnls?.srcNm ?? "")}
</div>
</div>
<div className="absolute inset-x-0 bottom-0 mb-[40px] flex justify-center">
<Button size="lg" onClick={fetchFilteredData}>조회</Button>
&nbsp;&nbsp;
<Button size="lg" color="gray" onClick={resetState}>초기화</Button>
</div>
</section>
{renderResultPanel()}
</div>
{renderResultPanel()}
</div>
);
if (isResultOnlyMode) {
return <div className="min-h-dvh bg-white p-3">{renderResultPanel()}</div>;
}
/** 조회·초기화 뒤에 창 닫기 성격의 취소 액션을 항상 마지막에 배치한다. */
const renderFooterActions = () => (
<div className="flex w-full items-center justify-center gap-2">
{!isResultOnlyMode ? (
<>
<Button type="button" onClick={fetchFilteredData}>
조회
</Button>
<Button type="button" color="white" onClick={resetState}>
초기화
</Button>
</>
) : null}
<Button type="button" color="white" onClick={() => window.close()}>
취소
</Button>
</div>
);
return (
<div className="min-h-dvh bg-white p-4">
<h1 className="mb-3 text-2xl font-semibold text-gray-900">RDII 분석</h1>
{renderDefaultBody()}
</div>
<StandardPopupLayout
title={isResultOnlyMode ? "RDII 분석결과" : "RDII 분석"}
bodyClassName="overflow-x-hidden"
footer={renderFooterActions()}
showDefaultCancelAction={false}
>
{isResultOnlyMode ? renderResultPanel() : renderDefaultBody()}
</StandardPopupLayout>
);
};
export default React.memo(RdiiChart);
export default React.memo(RdiiChart);
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,5 @@
/**
* 화면명: 하수펌프장 정보
* ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (하수펌프장)
*
* 📄 sewagePump
@@ -1,4 +1,5 @@
/**
* 화면명: 하수맨홀 정보
* ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (하수맨홀)
*
* 📄 Smh
@@ -10,26 +11,30 @@
* - 저장 버튼을 통한 데이터 저장 기능
*
* 🔗 사용 API
* - 상류관로 조회: GET /api/v1/fcltmhl/upperPpls/{fcltMngNo} (sewerage-service)
* - 상류관로 조회: GET /api/v1/fcltMhl/upperPpls/{fcltMngNo} (sewerage-service)
*/
import { VerticalTable } from '@/components/ui/verticalTable';
import { useEffect, useState } from 'react';
import { type ReactNode, useEffect, useState } from 'react';
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import { getData } from '@/components/lib/api';
// 상류관로 보기 함수 (FeatureClickEventPopup에서 사용)
export async function searchUpperPpls(gisMap: any, fcltMngNo: any) {
export async function searchUpperPpls(
gisMap: any,
fcltMngNo: any,
notify: (message: string) => void,
) {
let data: any = null;
try {
data = await getData(`v1/fcltmhl/upperPpls/${fcltMngNo}`);
data = await getData(`v1/fcltMhl/upperPpls/${fcltMngNo}`);
} catch (error) {
console.error("JSON parsing error:", error);
alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`);
notify(`해당 맨홀의 상류관로를 찾을 수 없습니다.`);
return;
}
if (!data) {
alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`);
notify(`해당 맨홀의 상류관로를 찾을 수 없습니다.`);
return;
}
@@ -59,7 +64,7 @@ export async function searchUpperPpls(gisMap: any, fcltMngNo: any) {
if (foundFeature) select?.selectMultiFeature?.(foundFeature, true);
});
if (!foundFeature) alert(`해당 맨홀의 상류관로를 찾을 수 없습니다.`);
if (!foundFeature) notify(`해당 맨홀의 상류관로를 찾을 수 없습니다.`);
const geometry = foundFeature?.getGeometry();
if (geometry) {
@@ -80,7 +85,15 @@ const inputColumn = [
{ id: "mltvDtdvNm", title: "배수분구", type: "input" },
{ id: "layCd", title: "매설위치", type: "select", options: ["도로", "하상"] },
];
const Smh = ({ featureData, onSaveClick }: { featureData: any, onSaveClick?: () => void }) => {
const Smh = ({
featureData,
onSaveClick,
surveyResultContent,
}: {
featureData: any;
onSaveClick?: () => void;
surveyResultContent?: ReactNode;
}) => {
const [formData, setFormData] = useState<any>({});
// featureData가 변경될 때 formData 초기화
@@ -104,6 +117,7 @@ const Smh = ({ featureData, onSaveClick }: { featureData: any, onSaveClick?: ()
};
return (
<>
<VerticalTable
headName='입력정보'
tableData={featureData ? [formData] : []}
@@ -120,6 +134,8 @@ const Smh = ({ featureData, onSaveClick }: { featureData: any, onSaveClick?: ()
onSaveClick={handleSaveClick}
onFormDataChange={handleFormDataChange} // 폼 데이터 변경 콜백 추가
/>
{surveyResultContent}
</>
)
}
@@ -1,4 +1,5 @@
/**
* 화면명: 빗물펌프 정보
* ➡️ 경로 : 하수관망도 메인 페이지 (시설물 클릭) - 오른쪽 팝업창 (빗물펌프)
*
* 📄 StormWaterPump
@@ -34,4 +35,4 @@ const StormWaterPump = ({ featureData }: { featureData: Record<string, any> }) =
)
}
export default StormWaterPump
export default StormWaterPump
@@ -1,4 +1,5 @@
import { getData } from "@/components/lib/api";
/** 처리구역 관로분석정보와 보수이력을 조회·다운로드하는 API를 제공한다. */
import { getBlobDataWithHeaders, getData } from "@/components/lib/api";
export type SwtrmtPrcsZoneAnalysisMetric = {
avgInstlYear?: number | string | null;
@@ -32,7 +33,43 @@ export type SwtrmtPrcsZoneAnalysisResponse = {
type ZoneAnalysisSource = Record<string, any> | null | undefined;
export type RepairHistoryFilters = {
useCd?: string;
materialCode?: string;
diameterFrom?: string;
diameterTo?: string;
};
export type RepairHistoryRow = {
rowNo: number;
fid: string;
lyrTypeNm?: string | null;
fcltMngNo?: string | null;
repairCount: number;
latestRepairYmd?: string | null;
latestRepairYear?: number | null;
elapsedYearsSinceRepair?: number | null;
useCd?: string | null;
useNm?: string | null;
installationYear?: number | null;
burialDepth?: number | null;
materialCode?: string | null;
materialName?: string | null;
pdmtVl?: number | null;
prlgVl?: number | null;
geometryJson?: string | null;
};
export type RepairHistoryResponse = {
totalElements: number;
totalPages: number;
page: number;
size: number;
data: RepairHistoryRow[];
};
const ANALYSIS_ENDPOINT_PREFIX = "v1/swagePrcsZones";
const REPAIR_HISTORY_PAGE_SIZE = 200;
const firstValue = (...values: unknown[]) => {
const value = values.find((item) => {
@@ -97,3 +134,71 @@ export const fetchSwtrmtPrcsZoneAnalysis = async (
return (await getData(path)) ?? {};
};
const appendRepairHistoryFilters = (params: URLSearchParams, filters: RepairHistoryFilters) => {
Object.entries(filters).forEach(([key, value]) => {
const normalized = String(value ?? "").trim();
if (normalized) params.append(key, normalized);
});
};
const getRepairHistoryPath = (
zoneId: string,
filters: RepairHistoryFilters,
suffix = "",
extraParams: Record<string, string> = {},
) => {
const params = new URLSearchParams();
appendRepairHistoryFilters(params, filters);
Object.entries(extraParams).forEach(([key, value]) => {
const normalized = value.trim();
if (normalized) params.append(key, normalized);
});
const query = params.toString();
return `${ANALYSIS_ENDPOINT_PREFIX}/${encodeURIComponent(zoneId)}/repairHistories${suffix}${query ? `?${query}` : ""}`;
};
/** 윈도우 팝업과 부모 지도가 같은 전체 결과를 사용하도록 서버 페이지를 끝까지 합친다. */
export const fetchAllRepairHistoriesByZoneId = async (
zoneId: string,
filters: RepairHistoryFilters,
): Promise<RepairHistoryResponse> => {
if (!zoneId.trim()) throw new Error("처리구역 식별값이 없습니다.");
const firstParams = new URLSearchParams();
appendRepairHistoryFilters(firstParams, filters);
firstParams.set("page", "0");
firstParams.set("size", String(REPAIR_HISTORY_PAGE_SIZE));
const first = (await getData(
`${ANALYSIS_ENDPOINT_PREFIX}/${encodeURIComponent(zoneId)}/repairHistories?${firstParams.toString()}`,
)) as RepairHistoryResponse;
const pages: RepairHistoryResponse[] = [first];
for (let page = 1; page < (first.totalPages ?? 0); page += 1) {
const params = new URLSearchParams(firstParams);
params.set("page", String(page));
pages.push((await getData(
`${ANALYSIS_ENDPOINT_PREFIX}/${encodeURIComponent(zoneId)}/repairHistories?${params.toString()}`,
)) as RepairHistoryResponse);
}
const data = pages.flatMap((page) => page?.data ?? []);
return {
...first,
totalElements: data.length,
totalPages: 1,
page: 0,
size: data.length,
data,
};
};
/** 현재 조회 조건의 화면 목록 항목 파일을 내려받는다. */
export const downloadRepairHistoriesByZoneId = async (
zoneId: string,
filters: RepairHistoryFilters,
zoneName: string,
) => {
if (!zoneId.trim()) throw new Error("처리구역 식별값이 없습니다.");
return getBlobDataWithHeaders(getRepairHistoryPath(zoneId, filters, "/download", { zoneName }));
};
@@ -1,14 +1,30 @@
/**
* 화면명: RDII 분석결과 목록
* RDII 분석결과를 검색하고 목록·엑셀·지도 보기 기능을 제공한다.
*/
"use client";
import { useCallback, useEffect, useState } from "react";
import { DateMonthPicker, UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
import { DateMonthPicker } from "@common/business_components/ui/date_month_picker";
import {
StandardPopupLayout,
type UiHeadProps,
type UiTableSort,
} from "@common/business_components";
import { Button } from "@common/components";
import { getData } from "@/components/lib/api";
import LoadingOverlay from "@/components/ui/LoadingBar";
import { appendApiSearchParams } from "@/shared/util/util";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
import { useAlertStore } from "@/shared/lib";
import { CtpvSggPrcsFcltSelectBox } from "@/components/ui/ctpvSggPrcsFcltSelectBox";
import { formatDisplayData } from "@/components/lib/utils/formatDisplayValue";
import useStore from "@/store/useStore";
import { windowPopupDefinitions } from "@/features/windowPopup/model/popupDefinitions";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import LoadingOverlay from "@/components/ui/LoadingBar";
import {
RDII_RESULT_ROW_SELECT_MESSAGE,
@@ -18,20 +34,25 @@ import {
const RDII_RESULT_PAGE_SIZE = 10;
const RDII_RESULT_HEADLIST = [
{ id: "rowNumber", title: "일련번호" },
const RDII_RESULT_HEADLIST: UiHeadProps[] = [
{
id: "rowNumber",
title: "순번",
sortable: true,
sortKey: "rainInfwtAnlsSn",
},
{ id: "ctpvNm", title: "시도" },
{ id: "sggNm", title: "시군구" },
{ id: "swtrmtFcltNm", title: "하수처리시설" },
{ id: "aplcnYm", title: "상수사용연월" },
{ id: "drtwSwtcRt", title: "오수 전환율" },
{ id: "srcNm", title: "RTK 계수 출처" },
{ id: "cntbArea", title: "RDII 기여면적" },
{ id: "anlsInfo", title: "RDII\n분석정보" },
{ id: "regDt", title: "분석일자" },
{ id: "swtrmtFcltNm", title: "하수처리시설", sortable: true },
{ id: "aplcnYm", title: "상수사용연월", sortable: true },
{ id: "drtwSwtcRt", title: "오수 전환율", sortable: true },
{ id: "srcNm", title: "RTK 계수 출처", sortable: true },
{ id: "cntbArea", title: "RDII 기여면적", sortable: true },
{ id: "anlsInfo", title: "RDII\n분석정보", sortable: true },
{ id: "regDt", title: "분석일자", sortable: true },
{ id: "rgtrNm", title: "분석자" },
{ id: "scsYn", title: "분석성공여부" },
] as const;
{ id: "scsYn", title: "분석성공여부", sortable: true },
];
type RdiiResultFilters = {
ctpvCd: string;
@@ -84,6 +105,7 @@ const postRowSelectMessage = (row: RdiiResultRow) => {
};
export default function RdiiResultPopupPage() {
const alert = useAlertStore((state) => state.setMessage);
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
const [filters, setFilters] = useState<RdiiResultFilters>(DEFAULT_FILTERS);
const [selectedSwagePrcsFclt, setSelectedSwagePrcsFclt] = useState("0");
@@ -91,7 +113,7 @@ export default function RdiiResultPopupPage() {
const [rows, setRows] = useState<RdiiResultRow[]>([]);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [sort, setSort] = useState<UiTableSort>();
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
@@ -100,31 +122,49 @@ export default function RdiiResultPopupPage() {
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
const fetchRows = useCallback(
async (page: number, nextFilters: RdiiResultFilters) => {
async (
page: number,
nextFilters: RdiiResultFilters,
nextSort: UiTableSort | undefined = sort,
) => {
const queryParams = new URLSearchParams({
page: String(page - 1),
size: String(RDII_RESULT_PAGE_SIZE),
sort: "rainInfwtAnlsSn,desc",
scsYn: "Y",
});
queryParams.append(
"sort",
nextSort
? `${nextSort.key},${nextSort.direction}`
: "rainInfwtAnlsSn,desc",
);
if (nextFilters.aplcnYmFrom) queryParams.set("aplcnYmFrom", nextFilters.aplcnYmFrom);
if (nextFilters.aplcnYmTo) queryParams.set("aplcnYmTo", nextFilters.aplcnYmTo);
if (nextFilters.ctpvCd !== "0") queryParams.set("ctpvCd", nextFilters.ctpvCd);
if (nextFilters.sggCd !== "0") queryParams.set("sggCd", nextFilters.sggCd);
if (nextFilters.swtrmtFcltNm.trim()) queryParams.set("swtrmtFcltNm", nextFilters.swtrmtFcltNm.trim());
appendApiSearchParams(queryParams, {
aplcnYmFrom: nextFilters.aplcnYmFrom,
aplcnYmTo: nextFilters.aplcnYmTo,
ctpvCd: nextFilters.ctpvCd,
sggCd: nextFilters.sggCd,
swtrmtFcltNm: nextFilters.swtrmtFcltNm,
});
const response = await getData(`v1/rdiiAnls?${queryParams.toString()}`);
const content = Array.isArray(response?.content) ? response.content : [];
const totalElements = Number(response?.totalElements ?? content.length);
const nextRows = content
.map((item: Record<string, unknown>, index: number) => {
const formattedItem = formatDisplayData(item);
const ctpvOption = ctpv.find((code) => code.val.trim() === String(item?.ctpvCd ?? "").trim());
const ctpvOption = ctpv.find(
(code) => code.val.trim() === String(item?.ctpvCd ?? "").trim(),
);
const sggOption = sgg.find((code) => code.val === item?.sggCd);
return {
...(formattedItem as Record<string, unknown>),
rowNumber: (page - 1) * RDII_RESULT_PAGE_SIZE + index + 1,
rowNumber: Math.max(
totalElements - ((page - 1) * RDII_RESULT_PAGE_SIZE + index),
0,
),
ctpvNm: ctpvOption ? ctpvOption.text : String(item?.ctpvCd ?? "-"),
sggNm: sggOption ? sggOption.text : String(item?.sggCd ?? "-"),
scsYn: String(item?.scsYn ?? "N"),
@@ -133,17 +173,21 @@ export default function RdiiResultPopupPage() {
.filter((item: RdiiResultRow) => item.scsYn === "Y");
setRows(nextRows);
setTotal(Number(response?.totalElements ?? nextRows.length));
setTotal(totalElements);
setCurrentPage(page);
},
[ctpv, sgg],
[ctpv, sgg, sort],
);
const loadRows = useCallback(
async (page = 1, nextFilters = filters) => {
async (
page = 1,
nextFilters = filters,
nextSort: UiTableSort | undefined = sort,
) => {
setIsLoading(true);
try {
await fetchRows(page, nextFilters);
await fetchRows(page, nextFilters, nextSort);
} catch (error) {
console.error("RDII 분석결과 목록 조회 중 오류:", error);
alert("RDII 분석결과 목록을 불러오지 못했습니다.");
@@ -153,26 +197,40 @@ export default function RdiiResultPopupPage() {
setIsLoading(false);
}
},
[fetchRows, filters],
[alert, fetchRows, filters, sort],
);
useEffect(() => {
void loadRows(1, DEFAULT_FILTERS);
void loadRows(1, DEFAULT_FILTERS, undefined);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleSearch = async (page = 1) => {
setSelectedIndex(-1);
setSelectedRow(null);
await loadRows(page, filters);
};
/** CS 검색 카드와 동일하게 조건과 선택 결과를 기본값으로 되돌린다. */
const handleReset = async () => {
setFilters(DEFAULT_FILTERS);
setSelectedSwagePrcsFclt("0");
setSelectedRow(null);
setSort(undefined);
await loadRows(1, DEFAULT_FILTERS, undefined);
};
/** 정렬 변경 시 선택 결과를 해제하고 서버 첫 페이지를 조회한다. */
const handleSortChange = (nextSort?: UiTableSort) => {
setSort(nextSort);
setSelectedRow(null);
void loadRows(1, filters, nextSort);
};
const handleRowClick = (index?: number) => {
if (typeof index !== "number") return;
const row = rows[index];
if (!row) return;
setSelectedIndex(index);
setSelectedRow(row);
postRowSelectMessage(row);
};
@@ -185,16 +243,24 @@ export default function RdiiResultPopupPage() {
try {
setIsLoading(true);
const response = await getData(`v1/rdiiAnlsRslt/ppl/${selectedRow.rainInfwtAnlsSn}`);
const excelRows = Array.isArray(response?.content) ? response.content : [];
const response = await fetchAllPages<any, Record<string, unknown>>({
fetchPage: (page, size) =>
getData(
`v1/rdiiAnlsRslt/ppl/${selectedRow.rainInfwtAnlsSn}?page=${page}&size=${size}`,
),
getItems: (page) =>
Array.isArray(page?.content) ? page.content : [],
getTotal: (page) => Number(page?.totalElements ?? 0),
});
const excelRows = response.items;
await downloadExcelWithKoreanHeaders({
data: excelRows as JSON[],
data: excelRows as unknown as JSON[],
fileName: `RDII분석결과_${selectedRow.swtrmtFcltNm}_${selectedRow.aplcnYm}_${selectedRow.rainInfwtAnlsSn}`,
sheetNameArray: ["RDII 분석결과"],
headerRange: "A1:D1",
headerMapping: {
rainInfwtAnlsSn: "RDII분석\n일련번호",
rainInfwtAnlsSn: "RDII분석\n분석번호",
pplMngNo: "관로\n관리번호",
anlsTm: "분석시각",
predcOilq: "예측유량\n(m³/h)",
@@ -209,139 +275,140 @@ export default function RdiiResultPopupPage() {
};
return (
<main className="sn-popup-page">
<StandardPopupLayout
title={windowPopupDefinitions.rdiiResultPopup.title}
bodyClassName="overflow-x-hidden"
>
<LoadingOverlay isLoading={isLoading} />
<header className="sn-popup-header">
<h1 className="sn-popup-title">RDII 분석결과</h1>
</header>
<section className="sn-popup-section overflow-y-auto">
<div className="rounded-md border border-border bg-secondary p-4">
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.05rem] font-medium text-label">상수사용기간</span>
<DateMonthPicker
field={{
value: filters.aplcnYmFrom,
onChange: (value: string) => {
setFilters((current) => ({
...current,
aplcnYmFrom: value,
}));
},
}}
selectClass="w-[145px]"
startYear={1900}
/>
<span className="text-[1.1rem] font-semibold text-muted-foreground">~</span>
<DateMonthPicker
field={{
value: filters.aplcnYmTo,
onChange: (value: string) => {
setFilters((current) => ({
...current,
aplcnYmTo: value,
}));
},
}}
selectClass="w-[145px]"
startYear={1900}
/>
</div>
<div className="min-w-0 flex-1 overflow-x-auto">
<CtpvSggPrcsFcltSelectBox
sido={filters.ctpvCd}
sigungu={filters.sggCd}
sggName={filters.sggName}
onSidoChange={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
ctpvCd: value,
sggCd: "0",
sggName: "",
swtrmtFcltNm: "",
}));
}}
onSigunguChange={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
sggCd: value,
swtrmtFcltNm: "",
}));
}}
setSggName={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
sggName: value ?? "",
swtrmtFcltNm: "",
}));
}}
selectedSwagePrcsFclt={selectedSwagePrcsFclt}
onSwagePrcsFcltChange={setSelectedSwagePrcsFclt}
setSwtrmtFcltNm={(value) =>
setFilters((current) => ({
...current,
swtrmtFcltNm: value ?? "",
}))
}
setIsLoading={setIsLoading}
/>
</div>
<div className="ml-auto flex items-center gap-2">
<Button
type="button"
size="sm"
color="primary"
className="h-10 px-5 !text-[1.1rem]"
onClick={() => void handleSearch(1)}
>
조회
</Button>
<Button
type="button"
color="green"
size="sm"
className="h-10 px-5 !text-[1.1rem]"
onClick={() => void handleDownload()}
>
다운로드
</Button>
</div>
</div>
</div>
<div className="mt-4">
<UiTable<RdiiResultRow>
headName={<>{`분석이력 총 ${total.toLocaleString()}건`}</>}
tableData={rows}
headlist={RDII_RESULT_HEADLIST as unknown as { id: string; title: string }[]}
pageSize={RDII_RESULT_PAGE_SIZE}
total={total}
cellClick={(index) => handleRowClick(index)}
cellClassName={(_, headId) =>
headId === "anlsInfo"
? "whitespace-normal break-all leading-[1.35]"
: ""
}
onPageChange={(page) => void handleSearch(page)}
curPage={currentPage}
index={selectedIndex}
widthFixed={true}
<PopupSearchPanel contentClassName="flex flex-wrap items-center gap-x-5 gap-y-3">
<div className="flex shrink-0 items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] font-medium text-label">
상수사용기간
</span>
<DateMonthPicker
field={{
value: filters.aplcnYmFrom,
onChange: (value: string) => {
setFilters((current) => ({
...current,
aplcnYmFrom: value,
}));
},
}}
selectClass="w-[145px] !text-[1.3rem]"
startYear={1900}
/>
<span className="text-[1.1rem] font-semibold text-muted-foreground">
~
</span>
<DateMonthPicker
field={{
value: filters.aplcnYmTo,
onChange: (value: string) => {
setFilters((current) => ({
...current,
aplcnYmTo: value,
}));
},
}}
selectClass="w-[145px] !text-[1.3rem]"
startYear={1900}
/>
</div>
{selectedRow ? (
<div className="mt-3 rounded-md border border-border bg-secondary px-4 py-3 text-[1rem] text-label">
{"선택 분석: "}
<span className="font-semibold text-foreground">{selectedRow.anlsInfo}</span>
</div>
) : null}
</section>
</main>
<div className="min-w-0 shrink-0">
<CtpvSggPrcsFcltSelectBox
compact
className="!justify-start"
selectValueClassName="!text-[1.3rem]"
sido={filters.ctpvCd}
sigungu={filters.sggCd}
sggName={filters.sggName}
onSidoChange={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
ctpvCd: value,
sggCd: "0",
sggName: "",
swtrmtFcltNm: "",
}));
}}
onSigunguChange={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
sggCd: value,
swtrmtFcltNm: "",
}));
}}
setSggName={(value) => {
setSelectedSwagePrcsFclt("0");
setFilters((current) => ({
...current,
sggName: value ?? "",
swtrmtFcltNm: "",
}));
}}
selectedSwagePrcsFclt={selectedSwagePrcsFclt}
onSwagePrcsFcltChange={setSelectedSwagePrcsFclt}
setSwtrmtFcltNm={(value) =>
setFilters((current) => ({
...current,
swtrmtFcltNm: value ?? "",
}))
}
setIsLoading={setIsLoading}
/>
</div>
<div className="ml-auto flex shrink-0 items-center justify-end gap-2">
<Button
type="button"
color="primary"
onClick={() => void handleSearch(1)}
>
조회
</Button>
<Button
type="button"
color="white"
onClick={() => void handleReset()}
>
초기화
</Button>
</div>
</PopupSearchPanel>
<MapPopupDataTable
rows={rows as Array<RdiiResultRow & Record<string, unknown>>}
headlist={RDII_RESULT_HEADLIST}
title="RDII 분석결과 목록"
total={total}
page={currentPage}
pageSize={RDII_RESULT_PAGE_SIZE}
onPageChange={(page) => void handleSearch(page)}
onRowClick={(_, index) => handleRowClick(index)}
rowId={(row) => String(row.rainInfwtAnlsSn)}
scrollMode="horizontal"
minTableWidth="125rem"
sort={sort}
onSortChange={handleSortChange}
actions={
<Button type="button" onClick={() => void handleDownload()}>
엑셀다운로드
</Button>
}
/>
{selectedRow ? (
<div className="rounded-sm border border-border bg-secondary px-4 py-3 text-[1rem] text-label">
{"선택 분석: "}
<span className="font-semibold text-foreground">
{selectedRow.anlsInfo}
</span>
</div>
) : null}
</StandardPopupLayout>
);
}
@@ -1,3 +1,4 @@
/** RDII 분석결과 화면의 타입·검색 조건·컬럼과 응답 변환을 정의한다. */
"use client";
export const RDII_RESULT_ROW_SELECT_MESSAGE = "SN_RDII_RESULT_ROW_SELECT";
@@ -44,4 +45,4 @@ export type RdiiResultMapViewResponse = {
pipeGeometry: Record<string, unknown>;
pipes: RdiiResultMapViewPipe[];
pipeCount: number;
};
};
@@ -1,3 +1,4 @@
/** RDII 분석결과 선택 시 부모 지도에 결과 레이어를 표시하는 훅이다. */
"use client";
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
@@ -9,6 +10,7 @@ import Style from "ol/style/Style.js";
import { getRawJson } from "@/components/lib/api";
import { openWindowPopup } from "@/features/windowPopup/lib/openWindowPopup";
import { useAlertStore } from "@/shared/lib";
import GisApp from "@common/business_components/ui/map/lib/gis.core";
import { getSigunguCenter, moveMapToCenter, sidoCenter } from "@common/business_components/ui/map/searchBar/utils";
@@ -138,6 +140,7 @@ export const useRdiiResultMap = ({
setIsOpen,
setIsSearchModalOpen,
}: UseRdiiResultMapParams) => {
const alert = useAlertStore((state) => state.setMessage);
const activeResultRowRef = useRef<RdiiResultRow | null>(null);
const pipeLayerRef = useRef<VectorLayer<any> | null>(null);
const pipeSourceRef = useRef<VectorSource | null>(null);
@@ -294,7 +297,7 @@ export const useRdiiResultMap = ({
alert("RDII 분석결과를 지도에 연계하지 못했습니다.");
}
},
[renderMapView, setFeatureData, setIsOpen, setIsSearchModalOpen],
[alert, renderMapView, setFeatureData, setIsOpen, setIsSearchModalOpen],
);
useEffect(() => {
@@ -356,11 +359,11 @@ export const useRdiiResultMap = ({
}
return true;
}, [activeMapView]);
}, [activeMapView, alert]);
return {
activeMapView,
clearLayers,
handleOverlayFeatureSelect,
};
};
};
@@ -0,0 +1,488 @@
/**
* 화면명: 보수이력 목록
* 선택 시설의 보수이력을 조회하고 목록으로 표시한다.
*/
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Input } from "@common/components";
import {
StandardPopupLayout,
type UiHeadProps,
} from "@common/business_components";
import LoadingOverlay from "@/components/ui/LoadingBar";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import { getRawJson } from "@/components/lib/api";
import {
WINDOW_POPUP_MESSAGE_TYPE,
type WindowPopupContentProps,
type WindowPopupMessageType,
} from "@/features/windowPopup/model/types";
import { MS_SWAGE_PPL_CODES } from "../common/table";
import {
downloadRepairHistoriesByZoneId,
fetchAllRepairHistoriesByZoneId,
type RepairHistoryFilters,
type RepairHistoryRow,
} from "../popupComponent/swtrmtPrcsZoneAnalysisApi";
const REPAIR_HISTORY_PAGE_SIZE = 10;
const MAP_LAYER_SETTING_PATH =
"/sewerage-service/api/v1/map/lyrStngs/SN_NETWORK";
type PipeUseOption = {
val: string;
text: string;
};
type MapLayerSettingRow = {
lyrTyNm?: string | null;
lyrNm?: string | null;
sortOrdr?: string | number | null;
flterSql?: string | null;
};
/** 관망도 트리의 하수관로 노드에서 용도코드·명칭·표시순서를 그대로 추출한다. */
const resolvePipeUseOptions = (rows: MapLayerSettingRow[]): PipeUseOption[] =>
rows
.filter((row) => row.lyrTyNm === "tb_ms_swage_ppl")
.sort(
(left, right) => Number(left.sortOrdr ?? 0) - Number(right.sortOrdr ?? 0),
)
.flatMap((row) => {
const code = String(row.flterSql ?? "")
.match(/\busg_cd\s*=\s*['\"]([^'\"]+)['\"]/i)?.[1]
?.trim();
const text = String(row.lyrNm ?? "").trim();
return code && text ? [{ val: code, text }] : [];
});
type RepairHistoryTableRow = {
fid: string;
fcltMngNo: string;
useName: string;
repairCount: string;
latestRepairYear: string;
elapsedYearsSinceRepair: string;
materialName: string;
diameter: string;
installationYear: string;
burialDepth: string;
};
/** 부모 업무화면과 동일한 공통 표 헤더·열 규격이다. */
const REPAIR_HISTORY_HEADLIST: UiHeadProps[] = [
{ id: "rowNo", title: "순번", align: "center", widthPercent: 7 },
{ id: "fcltMngNo", title: "시설관리번호", align: "center", widthPercent: 17 },
{ id: "useName", title: "용도", align: "center", widthPercent: 10 },
{
id: "repairCount",
title: "보수이력(건)",
align: "right",
widthPercent: 10,
},
{
id: "latestRepairYear",
title: "최근보수년도",
align: "center",
widthPercent: 11,
},
{
id: "elapsedYearsSinceRepair",
title: "보수후 경과년도",
align: "right",
widthPercent: 12,
},
{ id: "materialName", title: "재질", align: "center", widthPercent: 13 },
{ id: "diameter", title: "관경(mm)", align: "right", widthPercent: 9 },
{
id: "installationYear",
title: "설치년도",
align: "center",
widthPercent: 8,
},
{ id: "burialDepth", title: "매설심도(m)", align: "right", widthPercent: 10 },
];
/** 화면 입력조건은 조회 버튼을 누른 시점의 적용조건과 분리해 다운로드 계약을 고정한다. */
const buildEmptyFilters = (): RepairHistoryFilters => ({
useCd: "",
materialCode: "",
diameterFrom: "",
diameterTo: "",
});
/** Next searchParams의 단일 값을 안전하게 읽는다. */
const firstSearchParam = (value: string | string[] | undefined) =>
Array.isArray(value)
? String(value[0] ?? "").trim()
: String(value ?? "").trim();
/** 팝업의 조회·선택·정리 이벤트를 동일 출처의 부모 지도에 전달한다. */
const postRepairMessage = (
type: WindowPopupMessageType,
payload: Record<string, unknown>,
) => {
if (typeof window === "undefined" || !window.opener) return;
window.opener.postMessage({ type, payload }, window.location.origin);
};
/** 지정한 파일명으로 Blob을 다운로드한다. */
const saveBlob = (blob: Blob, fileName: string) => {
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = objectUrl;
anchor.download = fileName;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
};
const buildRepairHistoryFileName = (zoneName: string) => {
const sanitizedZoneName = zoneName.trim().replace(/[\\/:*?"<>|\r\n]+/g, "_");
return `${sanitizedZoneName || "처리구역"}_보수이력.xlsx`;
};
const formatCellValue = (value: unknown) => {
if (value === null || value === undefined || value === "") return "-";
return typeof value === "number"
? value.toLocaleString("ko-KR", { maximumFractionDigits: 2 })
: String(value);
};
const formatYear = (value: unknown) => {
if (value === null || value === undefined || value === "") return "-";
return String(value);
};
/** SFR-011-006 관로별 보수이력 조회·다운로드와 부모 지도 연계를 제공한다. */
export default function RepairHistoryPopupPage({
searchParams,
}: WindowPopupContentProps) {
const zoneId = useMemo(
() => firstSearchParam(searchParams.zoneId),
[searchParams.zoneId],
);
const zoneName = useMemo(
() => firstSearchParam(searchParams.zoneName),
[searchParams.zoneName],
);
// 입력조건과 마지막 조회조건을 분리해 다운로드가 현재 표와 동일한 범위를 사용한다.
const [filters, setFilters] =
useState<RepairHistoryFilters>(buildEmptyFilters);
const [appliedFilters, setAppliedFilters] =
useState<RepairHistoryFilters>(buildEmptyFilters);
// 전체 조회 결과는 부모 지도 강조와 팝업 내 10건 페이지 표가 함께 사용한다.
const [rows, setRows] = useState<RepairHistoryRow[]>([]);
// 용도 선택지는 별도 하드코딩하지 않고 관망도 트리 설정 API의 sortOrdr를 따른다.
const [pipeUseOptions, setPipeUseOptions] = useState<PipeUseOption[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [errorMessage, setErrorMessage] = useState("");
/** 조회 결과 전체를 부모 지도 레이어와 팝업 표에 동시에 반영한다. */
const searchRepairHistories = useCallback(
async (nextFilters: RepairHistoryFilters) => {
if (!zoneId) {
setErrorMessage("처리구역 식별값이 없습니다.");
return;
}
setIsLoading(true);
setErrorMessage("");
try {
const response = await fetchAllRepairHistoriesByZoneId(
zoneId,
nextFilters,
);
const nextRows = response.data ?? [];
setRows(nextRows);
setAppliedFilters({ ...nextFilters });
setCurrentPage(1);
postRepairMessage(
WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SHOW_RESULTS,
{
zoneId,
rows: nextRows,
},
);
} catch (error) {
setRows([]);
postRepairMessage(
WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SHOW_RESULTS,
{
zoneId,
rows: [],
},
);
setErrorMessage(
error instanceof Error
? error.message
: "보수이력을 불러오지 못했습니다.",
);
} finally {
setIsLoading(false);
}
},
[zoneId],
);
useEffect(() => {
const initialFilters = buildEmptyFilters();
setFilters(initialFilters);
setAppliedFilters(initialFilters);
void searchRepairHistories(initialFilters);
}, [searchRepairHistories]);
useEffect(() => {
let isMounted = true;
const loadPipeUseOptions = async () => {
try {
const layerSettings = (await getRawJson(
MAP_LAYER_SETTING_PATH,
)) as MapLayerSettingRow[];
if (isMounted)
setPipeUseOptions(
resolvePipeUseOptions(
Array.isArray(layerSettings) ? layerSettings : [],
),
);
} catch {
if (isMounted) setPipeUseOptions([]);
}
};
void loadPipeUseOptions();
return () => {
isMounted = false;
};
}, []);
useEffect(() => {
const clearParentLayer = () => {
postRepairMessage(WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_CLEAR, {
zoneId,
});
};
window.addEventListener("beforeunload", clearParentLayer);
return () => {
window.removeEventListener("beforeunload", clearParentLayer);
clearParentLayer();
};
}, [zoneId]);
/** 화면에서 적용한 조건과 동일한 9개 목록 항목의 XLSX를 내려받는다. */
const handleDownload = async () => {
if (!zoneId || rows.length === 0) return;
setIsLoading(true);
setErrorMessage("");
try {
const { blob } = await downloadRepairHistoriesByZoneId(
zoneId,
appliedFilters,
zoneName,
);
saveBlob(blob, buildRepairHistoryFileName(zoneName));
} catch (error) {
setErrorMessage(
error instanceof Error
? error.message
: "보수이력 Excel 다운로드에 실패했습니다.",
);
} finally {
setIsLoading(false);
}
};
/** 행을 한 번 클릭하면 부모 지도에서 해당 관로를 선택하고 범위로 이동한다. */
const handleRowClick = (row: RepairHistoryRow) => {
postRepairMessage(WINDOW_POPUP_MESSAGE_TYPE.REPAIR_HISTORY_SELECT_PIPE, {
zoneId,
fid: row.fid,
});
};
const visibleRows = useMemo(
() =>
rows.slice(
(currentPage - 1) * REPAIR_HISTORY_PAGE_SIZE,
currentPage * REPAIR_HISTORY_PAGE_SIZE,
),
[currentPage, rows],
);
// 공통 표에 전달할 표시값은 원본 API 행과 분리해 지도 연계 식별값을 보존한다.
const tableRows = useMemo<RepairHistoryTableRow[]>(
() =>
visibleRows.map((row) => ({
fid: row.fid,
fcltMngNo: formatCellValue(row.fcltMngNo),
useName: formatCellValue(row.useNm),
repairCount: formatCellValue(row.repairCount),
latestRepairYear: formatYear(row.latestRepairYear),
elapsedYearsSinceRepair: formatCellValue(row.elapsedYearsSinceRepair),
materialName: formatCellValue(row.materialName),
diameter: formatCellValue(row.pdmtVl),
installationYear: formatYear(row.installationYear),
burialDepth: formatCellValue(row.burialDepth),
})),
[visibleRows],
);
return (
<StandardPopupLayout title="보수이력 목록">
<LoadingOverlay isLoading={isLoading} />
<PopupSearchPanel contentClassName="flex w-full flex-wrap items-center gap-x-5 gap-y-3">
<label className="flex items-center gap-2 whitespace-nowrap text-[1.3rem] text-label">
<span>용도</span>
<select
className="h-[3.2rem] w-[15rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
value={filters.useCd}
onChange={(event) =>
setFilters((current) => ({
...current,
useCd: event.target.value,
}))
}
data-testid="repair-use-filter"
>
<option value="">전체</option>
{pipeUseOptions.map((code) => (
<option key={code.val} value={code.val}>
{code.text}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2 whitespace-nowrap text-[1.3rem] text-label">
<span>재질</span>
<select
className="h-[3.2rem] w-[18rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
value={filters.materialCode}
onChange={(event) =>
setFilters((current) => ({
...current,
materialCode: event.target.value,
}))
}
data-testid="repair-material-filter"
>
<option value="">전체</option>
{MS_SWAGE_PPL_CODES.map((code) => (
<option key={code.val} value={code.val}>
{code.text}
</option>
))}
</select>
</label>
<div className="flex items-center gap-2 whitespace-nowrap text-[1.3rem] text-label">
<span>관경(mm)</span>
<Input
type="number"
min="0"
aria-label="최소 관경(mm)"
className="w-[10rem]"
value={filters.diameterFrom}
onChange={(event) =>
setFilters((current) => ({
...current,
diameterFrom: event.target.value,
}))
}
data-testid="repair-diameter-from"
/>
<span aria-hidden="true">~</span>
<Input
type="number"
min="0"
aria-label="최대 관경(mm)"
className="w-[10rem]"
value={filters.diameterTo}
onChange={(event) =>
setFilters((current) => ({
...current,
diameterTo: event.target.value,
}))
}
data-testid="repair-diameter-to"
/>
</div>
<div className="ml-auto flex shrink-0 items-center gap-2">
<Button
type="button"
color="primary"
onClick={() => void searchRepairHistories({ ...filters })}
disabled={isLoading}
data-testid="repair-history-search"
>
조회
</Button>
<Button
type="button"
color="white"
onClick={() => {
const resetFilters: RepairHistoryFilters = {
useCd: "",
materialCode: "",
diameterFrom: "",
diameterTo: "",
};
setFilters(resetFilters);
void searchRepairHistories(resetFilters);
}}
>
초기화
</Button>
</div>
</PopupSearchPanel>
{errorMessage ? (
<div
role="alert"
className="mb-2 mt-2 border border-destructive bg-secondary px-4 py-2 text-base text-destructive"
>
{errorMessage}
</div>
) : null}
<MapPopupDataTable
rows={
(isLoading ? [] : tableRows) as Array<
RepairHistoryTableRow & Record<string, unknown>
>
}
headlist={REPAIR_HISTORY_HEADLIST}
title={`${zoneName || "처리구역"} 보수이력 목록`}
total={rows.length}
page={currentPage}
pageSize={REPAIR_HISTORY_PAGE_SIZE}
onPageChange={setCurrentPage}
onRowClick={(_, index) => {
const row = visibleRows[index];
if (row) handleRowClick(row);
}}
rowId={(row, index) => String(row.fid ?? index)}
actions={
<Button
type="button"
onClick={() => void handleDownload()}
disabled={isLoading || rows.length === 0}
data-testid="repair-history-download"
>
엑셀다운로드
</Button>
}
/>
<div className="rounded-sm bg-secondary px-4 py-3 text-[1.2rem] text-muted-foreground">
행을 클릭하면 부모 지도의 해당 관로로 이동합니다.
</div>
</StandardPopupLayout>
);
}
@@ -0,0 +1,163 @@
/** 우측 상세 탭의 표시·분류 모델을 지도 조회 수명에서 분리한다. */
import type { ExmnMapJobType } from "../exmn/exmnMapTypes";
import type { SinkholeDetail, SinkholeNearbyPipe } from "../sinkhole/sinkholeTypes";
export type RightPanelTabKind = "feature" | "sinkhole" | "gisEdit";
export type RightPanelTabCategory =
| "sinkhole"
| "pipe"
| "exmnSurvey"
| "treatmentFacility"
| "treatmentZone"
| "defaultFeature"
| "gisEdit";
export type RightPanelTab = {
id: string;
kind: RightPanelTabKind;
category: RightPanelTabCategory;
displayLabel: string;
surveyJobType?: ExmnMapJobType;
parentTabId?: string | null;
featureData: any;
sinkholeDetail?: SinkholeDetail | null;
nearbyPipes?: SinkholeNearbyPipe[];
gisEditChangeId?: string | null;
lastActiveAt: number;
};
export type RightPanelTabStyle = {
active: string;
inactive: string;
};
export type RightPanelBookmarkTabsProps = {
tabs: RightPanelTab[];
activeTabId: string | null;
onActivate: (tabId: string) => void;
onBack: (tabId: string) => void;
};
// 지도 객체 유형과 조사구분을 식별하는 범례 의미색이므로 테마와 무관한 정적 class를 유지한다.
export const RIGHT_PANEL_TAB_STYLE_BY_CATEGORY: Record<
RightPanelTabCategory,
RightPanelTabStyle
> = {
sinkhole: {
active: "border-[#d97706] bg-[#d97706] text-white",
inactive: "border-[#fdba74] bg-[#fff7ed] text-[#9a3412] hover:bg-[#ffedd5]",
},
pipe: {
active: "border-[#2563eb] bg-[#2563eb] text-white",
inactive: "border-[#93c5fd] bg-[#eff6ff] text-[#1e40af] hover:bg-[#dbeafe]",
},
exmnSurvey: {
active: "border-[#0f766e] bg-[#0f766e] text-white",
inactive: "border-[#5eead4] bg-[#f0fdfa] text-[#115e59] hover:bg-[#ccfbf1]",
},
treatmentFacility: {
active: "border-[#15803d] bg-[#15803d] text-white",
inactive: "border-[#86efac] bg-[#f0fdf4] text-[#166534] hover:bg-[#dcfce7]",
},
treatmentZone: {
active: "border-[#7e22ce] bg-[#7e22ce] text-white",
inactive: "border-[#d8b4fe] bg-[#faf5ff] text-[#6b21a8] hover:bg-[#f3e8ff]",
},
gisEdit: {
active: "border-[#0f766e] bg-[#0f766e] text-white",
inactive: "border-[#5eead4] bg-[#f0fdfa] text-[#115e59] hover:bg-[#ccfbf1]",
},
defaultFeature: {
active: "border-[#475569] bg-[#475569] text-white",
inactive: "border-[#cbd5e1] bg-[#f8fafc] text-[#334155] hover:bg-[#f1f5f9]",
},
};
export const RIGHT_PANEL_EXMN_TAB_STYLE_BY_JOB_TYPE: Record<
ExmnMapJobType,
RightPanelTabStyle
> = {
PPL: {
active: "border-[#eab308] bg-[#facc15] text-[#713f12]",
inactive: "border-[#fde68a] bg-[#fefce8] text-[#854d0e] hover:bg-[#fef9c3]",
},
MNHL: {
active: "border-[#8b5cf6] bg-[#8b5cf6] text-white",
inactive: "border-[#c4b5fd] bg-[#f5f3ff] text-[#5b21b6] hover:bg-[#ede9fe]",
},
};
// 공용 우측 탭은 유형과 활성 상태에 관계없이 먼저 열린 탭부터 제거한다.
export const findRightPanelTabOverflowCandidate = (
tabs: RightPanelTab[],
nextTabId: string,
) => {
return tabs.find((tab) => tab.id !== nextTabId) ?? null;
};
// 부모 지반침하가 제거되면 그 부모에서 열린 연계 관로도 같은 탭 그룹으로 제거한다.
export const removeRightPanelTabGroup = (
tabs: RightPanelTab[],
removedTabId: string,
) =>
tabs.filter(
(tab) => tab.id !== removedTabId && tab.parentTabId !== removedTabId,
);
// 오른쪽 상세 패널에 상세가 2개 이상 쌓였을 때만 책갈피형 전환 버튼을 보여준다.
export const RightPanelBookmarkTabs = ({
tabs,
activeTabId,
onActivate,
onBack,
}: RightPanelBookmarkTabsProps) => {
if (tabs.length < 2) return null;
return (
<div className="pointer-events-auto absolute right-[350px] top-[92px] z-[70] flex w-[112px] flex-col items-end gap-2">
{tabs.map((tab) => {
const isActive = tab.id === activeTabId;
const style =
tab.category === "exmnSurvey" && tab.surveyJobType
? RIGHT_PANEL_EXMN_TAB_STYLE_BY_JOB_TYPE[tab.surveyJobType]
: RIGHT_PANEL_TAB_STYLE_BY_CATEGORY[tab.category];
const parentTab = tab.parentTabId
? tabs.find((candidate) => candidate.id === tab.parentTabId)
: null;
return (
<div
key={tab.id}
className={[
"flex min-h-[42px] w-[112px] items-center border border-r-0 px-1 py-1 shadow-sm transition-colors",
isActive ? style.active : style.inactive,
].join(" ")}
>
{tab.category === "pipe" && parentTab ? (
<button
type="button"
className="mr-1 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded text-sm font-bold transition-colors hover:bg-accent focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1"
title={parentTab.displayLabel + "로 돌아가기"}
aria-label={parentTab.displayLabel + "로 돌아가기"}
onClick={() => onBack(parentTab.id)}
>
←
</button>
) : null}
<button
type="button"
className="inline-flex min-w-0 flex-1 items-center justify-center self-stretch px-1 text-center text-[0.82rem] font-semibold leading-tight"
title={tab.displayLabel}
aria-pressed={isActive}
onClick={() => onActivate(tab.id)}
>
<span className="block max-h-[2.5rem] overflow-hidden break-all">
{tab.displayLabel}
</span>
</button>
</div>
);
})}
</div>
);
};
@@ -0,0 +1,314 @@
/**
* 화면명: 침수위험지도 목록
* 침수위험지도를 검색하고 목록·엑셀·지도 보기 기능을 제공한다.
*/
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button } from "@common/components";
import { StandardPopupLayout } from "@common/business_components";
import { getRawJson } from "@/components/lib/api";
import LoadingOverlay from "@/components/ui/LoadingBar";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel from "@/features/windowPopup/ui/PopupSearchPanel";
import { WINDOW_POPUP_MESSAGE_TYPE } from "@/features/windowPopup/model/types";
import { useAlertStore } from "@/shared/lib";
import useStore from "@/store/useStore";
import {
SBMRG_DNGR_MAP_DEFAULT_FILTERS,
SBMRG_DNGR_MAP_FREQUENCY_OPTIONS,
SBMRG_DNGR_MAP_HEADLIST,
SBMRG_DNGR_MAP_LAYER_TYPE,
SBMRG_DNGR_MAP_PAGE_SIZE,
SBMRG_DNGR_MAP_SCOPE_SIZE,
buildSbmrgDngrMapListQueryString,
extractAdministrativeCodes,
toSbmrgDngrMapTableRows,
type SbmrgDngrMapApiItem,
type SbmrgDngrMapFilters,
type SbmrgDngrMapTableRow,
} from "./sbmrgDngrMapTypes";
const unwrapPayload = <T,>(response: any): T =>
(response?.data ?? response) as T;
/** 시설물 레이어의 중점관리지역 침수위험지도 피처를 조건별 목록으로 제공한다. */
export default function SbmrgDngrMapPopupPage() {
const alert = useAlertStore((state) => state.setMessage);
const { ctpv, sgg, fetchCtpv, fetchSgg } = useStore();
// 입력 중인 조건과 실제 조회에 적용된 조건을 분리해 페이지 이동 시 조건이 흔들리지 않게 한다.
const [filters, setFilters] = useState<SbmrgDngrMapFilters>(
SBMRG_DNGR_MAP_DEFAULT_FILTERS,
);
const [appliedFilters, setAppliedFilters] = useState<SbmrgDngrMapFilters>(
SBMRG_DNGR_MAP_DEFAULT_FILTERS,
);
const [items, setItems] = useState<SbmrgDngrMapApiItem[]>([]);
const [administrativeScopeItems, setAdministrativeScopeItems] = useState<
SbmrgDngrMapApiItem[] | null
>(null);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const availableCtpvCodes = useMemo(
() =>
new Set(
(administrativeScopeItems ?? []).flatMap((item) =>
extractAdministrativeCodes(item.ctpvCd, 2),
),
),
[administrativeScopeItems],
);
const availableSggCodes = useMemo(
() =>
new Set(
(administrativeScopeItems ?? []).flatMap((item) =>
extractAdministrativeCodes(item.sggCd, 5),
),
),
[administrativeScopeItems],
);
const ctpvOptions = useMemo(
() =>
administrativeScopeItems === null
? ctpv
: ctpv.filter((item) =>
availableCtpvCodes.has(String(item.val).trim().slice(0, 2)),
),
[administrativeScopeItems, availableCtpvCodes, ctpv],
);
const sggOptions = useMemo(() => {
const scopedOptions =
administrativeScopeItems === null
? sgg
: sgg.filter((item) =>
availableSggCodes.has(String(item.val).trim().slice(0, 5)),
);
return filters.ctpvCd === "0"
? []
: scopedOptions.filter((item) =>
String(item.val).startsWith(filters.ctpvCd.slice(0, 2)),
);
}, [administrativeScopeItems, availableSggCodes, filters.ctpvCd, sgg]);
const rows = useMemo(
() =>
toSbmrgDngrMapTableRows(
items,
currentPage - 1,
SBMRG_DNGR_MAP_PAGE_SIZE,
ctpv,
sgg,
total,
),
[ctpv, currentPage, items, sgg, total],
);
useEffect(() => {
if (ctpv.length === 0) void fetchCtpv();
if (sgg.length === 0) void fetchSgg();
}, [ctpv.length, fetchCtpv, fetchSgg, sgg.length]);
/** 마지막 적용 조건으로 서버 페이지를 조회한다. */
const loadRows = useCallback(
async (page: number, nextFilters: SbmrgDngrMapFilters) => {
setIsLoading(true);
try {
const queryString = buildSbmrgDngrMapListQueryString(
nextFilters,
page - 1,
SBMRG_DNGR_MAP_PAGE_SIZE,
);
const response = await getRawJson(
`/sewerage-service/api/v1/fldng/sbmrgDngrMap?${queryString}`,
);
const payload = unwrapPayload<{
items?: SbmrgDngrMapApiItem[];
total?: number;
}>(response);
const items = Array.isArray(payload?.items) ? payload.items : [];
setItems(items);
setTotal(Number(payload?.total ?? 0));
setCurrentPage(page);
setAppliedFilters(nextFilters);
} catch (error) {
console.error("침수위험지도 목록 조회 중 오류:", error);
setItems([]);
setTotal(0);
alert(
error instanceof Error
? error.message
: "침수위험지도 목록 조회 중 오류가 발생했습니다.",
);
} finally {
setIsLoading(false);
}
},
[alert],
);
/** 전체 50개 그룹에서 실제 존재하는 행정구역 코드만 검색 선택지로 제한한다. */
const loadAdministrativeScope = useCallback(async () => {
try {
const queryString = buildSbmrgDngrMapListQueryString(
SBMRG_DNGR_MAP_DEFAULT_FILTERS,
0,
SBMRG_DNGR_MAP_SCOPE_SIZE,
);
const response = await getRawJson(
`/sewerage-service/api/v1/fldng/sbmrgDngrMap?${queryString}`,
);
const payload = unwrapPayload<{ items?: SbmrgDngrMapApiItem[] }>(
response,
);
setAdministrativeScopeItems(
Array.isArray(payload?.items) ? payload.items : [],
);
} catch (error) {
console.error("침수위험지도 행정구역 범위 조회 중 오류:", error);
setAdministrativeScopeItems(null);
}
}, []);
useEffect(() => {
void loadRows(1, SBMRG_DNGR_MAP_DEFAULT_FILTERS);
void loadAdministrativeScope();
}, [loadAdministrativeScope, loadRows]);
/** 현재 입력 조건을 적용하고 첫 페이지부터 다시 조회한다. */
const handleSearch = () => {
void loadRows(1, filters);
};
/** 선택한 지역·빈도 그룹의 전체 fid를 부모 지도에 전달해 전체 범위 이동·강조를 실행한다. */
const handleRowClick = (row: SbmrgDngrMapTableRow) => {
if (!Array.isArray(row.fids) || row.fids.length === 0) return;
const featureIds = row.fids.map((fid) =>
String(fid).startsWith(`${SBMRG_DNGR_MAP_LAYER_TYPE}.`)
? String(fid)
: `${SBMRG_DNGR_MAP_LAYER_TYPE}.${fid}`,
);
window.opener?.postMessage(
{
type: WINDOW_POPUP_MESSAGE_TYPE.MAP_SEARCH_SHOW_MULTIPLE,
payload: {
layerNm: SBMRG_DNGR_MAP_LAYER_TYPE,
fids: JSON.stringify(featureIds),
},
},
window.location.origin,
);
};
return (
<StandardPopupLayout title="침수위험지도 목록">
<LoadingOverlay isLoading={isLoading} />
<PopupSearchPanel contentClassName="flex flex-wrap items-end gap-x-5 gap-y-3">
<label className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] text-label">
시도
</span>
<select
className="h-[3.2rem] w-[18rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
value={filters.ctpvCd}
onChange={(event) =>
setFilters({
...filters,
ctpvCd: event.target.value,
sggCd: "0",
})
}
>
<option value="0">전체</option>
{ctpvOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] text-label">
시군구
</span>
<select
className="h-[3.2rem] w-[18rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
value={filters.sggCd}
onChange={(event) =>
setFilters({ ...filters, sggCd: event.target.value })
}
>
<option value="0">전체</option>
{sggOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2">
<span className="whitespace-nowrap text-[1.3rem] text-label">
홍수수위빈도값
</span>
<select
className="h-[3.2rem] w-[16rem] rounded-md border border-input bg-field px-3 text-[1.3rem]"
value={filters.floodWtlvFrqVl}
onChange={(event) =>
setFilters({ ...filters, floodWtlvFrqVl: event.target.value })
}
>
<option value="0">전체</option>
{SBMRG_DNGR_MAP_FREQUENCY_OPTIONS.map((value) => (
<option key={value} value={String(value)}>
{value}년
</option>
))}
</select>
</label>
<div className="ml-auto flex justify-end gap-2">
<Button type="button" color="primary" onClick={handleSearch}>
조회
</Button>
<Button
type="button"
color="white"
onClick={() => {
setFilters(SBMRG_DNGR_MAP_DEFAULT_FILTERS);
void loadRows(1, SBMRG_DNGR_MAP_DEFAULT_FILTERS);
}}
>
초기화
</Button>
</div>
</PopupSearchPanel>
<MapPopupDataTable
rows={rows as Array<SbmrgDngrMapTableRow & Record<string, unknown>>}
headlist={SBMRG_DNGR_MAP_HEADLIST}
title="침수위험지도 목록"
total={total}
page={currentPage}
pageSize={SBMRG_DNGR_MAP_PAGE_SIZE}
onPageChange={(page) => void loadRows(page, appliedFilters)}
onRowClick={handleRowClick}
rowId={(row, index) =>
[
row.emphsMngRgnMngNo ?? "unknown",
row.floodWtlvFrqVl ?? "unknown",
row.ctpvCd ?? "unknown",
row.sggCd ?? "unknown",
index,
].join(":")
}
/>
</StandardPopupLayout>
);
}
@@ -0,0 +1,162 @@
/** 침수위험지도 화면의 검색 조건, 컬럼과 응답 변환을 정의한다. */
"use client";
import type { UiHeadProps } from "@common/business_components/ui";
import { appendApiSearchParams } from "@/shared/util/util";
export const SBMRG_DNGR_MAP_POPUP_KEY = "sbmrgDngrMapPopup";
export const SBMRG_DNGR_MAP_LAYER_TYPE = "tb_ms_sbmrg_dngr_map";
export const SBMRG_DNGR_MAP_LAYER_NAME = "침수위험지도(중점관리지역)";
export const SBMRG_DNGR_MAP_PAGE_SIZE = 10;
export const SBMRG_DNGR_MAP_SCOPE_SIZE = 2000;
export type SbmrgDngrMapFilters = {
ctpvCd: string;
sggCd: string;
floodWtlvFrqVl: string;
};
export type SbmrgDngrMapApiItem = {
fids: string[];
ctpvCd: string;
sggCd: string;
emphsMngRgnNm: string;
emphsMngRgnMngNo: string;
floodWtlvFrqVl: number | string | null;
minElvtVl: number | string | null;
maxElvtVl: number | string | null;
};
export type SbmrgDngrMapTableRow = SbmrgDngrMapApiItem & {
rowNo: number;
ctpvNm: string;
sggNm: string;
floodWtlvFrqVlDisplay: string;
elvtVlDisplay: string;
};
type AdministrativeOption = {
val: unknown;
text: unknown;
};
export const SBMRG_DNGR_MAP_DEFAULT_FILTERS: SbmrgDngrMapFilters = {
ctpvCd: "0",
sggCd: "0",
floodWtlvFrqVl: "0",
};
export const SBMRG_DNGR_MAP_FREQUENCY_OPTIONS = [30, 50, 80, 100];
export const SBMRG_DNGR_MAP_HEADLIST: UiHeadProps[] = [
{ id: "rowNo", title: "순번", widthPercent: 7 },
{ id: "ctpvNm", title: "시도", widthPercent: 11 },
{ id: "sggNm", title: "시군구", widthPercent: 12 },
{ id: "emphsMngRgnNm", title: "중점관리지역명", widthPercent: 23 },
{ id: "emphsMngRgnMngNo", title: "중점관리지역번호", widthPercent: 15 },
{
id: "floodWtlvFrqVlDisplay",
title: "홍수수위빈도값",
widthPercent: 17,
},
{
id: "elvtVlDisplay",
title: "해발값(최소~최대)",
widthPercent: 15,
},
];
/** API가 허용하는 검색 조건과 페이징 파라미터만 쿼리 문자열로 만든다. */
export const buildSbmrgDngrMapListQueryString = (
filters: SbmrgDngrMapFilters,
page: number,
size: number,
) => {
const params = new URLSearchParams({
page: String(page),
size: String(size),
});
appendApiSearchParams(params, {
ctpvCd: filters.ctpvCd === "0" ? "0" : filters.ctpvCd.slice(0, 2),
sggCd: filters.sggCd === "0" ? "0" : filters.sggCd.slice(0, 5),
floodWtlvFrqVl: filters.floodWtlvFrqVl,
});
return params.toString();
};
const toDisplay = (value: unknown) => {
const normalized = String(value ?? "").trim();
return normalized || "-";
};
const toRangeDisplay = (minValue: unknown, maxValue: unknown) => {
const minDisplay = toDisplay(minValue);
const maxDisplay = toDisplay(maxValue);
if (minDisplay === "-" && maxDisplay === "-") return "-";
if (minDisplay === maxDisplay) return minDisplay;
return `${minDisplay} ~ ${maxDisplay}`;
};
/** 집계 응답에서 쉼표로 묶일 수 있는 행정구역 코드를 정규화한다. */
export const extractAdministrativeCodes = (
value: unknown,
codeLength: number,
) =>
String(value ?? "")
.split(",")
.map((code) => code.trim().slice(0, codeLength))
.filter(Boolean);
/** 쉼표로 묶인 행정구역 코드도 각각 한글명으로 변환하고 중복 명칭은 제거한다. */
const resolveAdministrativeNames = (
value: unknown,
options: AdministrativeOption[],
codeLength: number,
) => {
const names = extractAdministrativeCodes(value, codeLength)
.map((code) => {
const matched = options.find(
(option) =>
String(option.val ?? "")
.trim()
.slice(0, codeLength) === code,
);
return String(matched?.text ?? "").trim();
})
.filter(Boolean);
return names.length > 0 ? [...new Set(names)].join(", ") : "-";
};
/** 서버 페이지 번호를 화면 순번에 반영하고 숫자 표시값을 보정한다. */
export const toSbmrgDngrMapTableRows = (
items: SbmrgDngrMapApiItem[],
page: number,
pageSize = SBMRG_DNGR_MAP_PAGE_SIZE,
ctpvOptions: AdministrativeOption[] = [],
sggOptions: AdministrativeOption[] = [],
total = items.length,
): SbmrgDngrMapTableRow[] =>
items.map((item, index) => ({
...item,
rowNo: total - page * pageSize - index,
ctpvNm: resolveAdministrativeNames(item.ctpvCd, ctpvOptions, 2),
sggNm: resolveAdministrativeNames(item.sggCd, sggOptions, 5),
floodWtlvFrqVlDisplay: toDisplay(item.floodWtlvFrqVl),
elvtVlDisplay: toRangeDisplay(item.minElvtVl, item.maxElvtVl),
}));
const resolveNodeName = (node: any) =>
String(
node?.text ??
node?.data?.lyrNm ??
node?.data?.name ??
node?.lyrNm ??
node?.name ??
"",
).trim();
/** 빈도·침수심 자식 노드가 아니라 중점관리지역 위험지도 루트만 목록보기 대상으로 판별한다. */
export const isSbmrgDngrMapRootNode = (node: any) =>
resolveNodeName(node).replace(/\s/g, "") ===
SBMRG_DNGR_MAP_LAYER_NAME.replace(/\s/g, "");
@@ -1,3 +1,7 @@
/**
* 화면명: 지반침하 상세정보
* 선택한 지반침하 정보와 주변 관로 조회 결과를 표시한다.
*/
"use client";
import { useEffect, useMemo, useState } from "react";
@@ -5,6 +9,7 @@ import { Button } from "@common/components";
import { VerticalTable } from "@/components/ui/verticalTable";
import { getCmsData } from "@/components/lib/api";
import type { SinkholeDetail, SinkholeNearbyPipe } from "./sinkholeTypes";
import { useSinkholeCommonCodes } from "./useSinkholeCommonCodes";
type Props = {
detail: SinkholeDetail;
@@ -74,7 +79,11 @@ export default function SinkholeDetailPanel({
onClose,
onSelectPipe,
}: Props) {
const [pipeUseCodeMap, setPipeUseCodeMap] = useState<Map<string, string>>(new Map());
// 상세의 원천 복구상태 값은 유지하고 CMS에 대응하는 코드명만 표시한다.
const { restoreStatusLabels } = useSinkholeCommonCodes();
const [pipeUseCodeMap, setPipeUseCodeMap] = useState<Map<string, string>>(
new Map(),
);
const canShowNearby = String(detail.coordStatus ?? "").trim() === "SUCCESS";
useEffect(() => {
@@ -83,7 +92,9 @@ export default function SinkholeDetailPanel({
loadPipeUseCodes()
.then((codes) => {
if (cancelled) return;
setPipeUseCodeMap(new Map(codes.map((item) => [item.lwpoCd, item.lwpoCdNm])));
setPipeUseCodeMap(
new Map(codes.map((item) => [item.lwpoCd, item.lwpoCdNm])),
);
})
.catch((error) => {
console.error("Failed to load OGC011 pipe use codes:", error);
@@ -117,20 +128,25 @@ export default function SinkholeDetailPanel({
}, [nearbyPipes]);
const summaryTableData = useMemo(
() => [
{
acdntNo: display(detail.acdntNo),
ocrnYmd: display(detail.ocrnYmd),
fullAddress: display(detail.fullAddress || detail.daddr),
sizeWidth: display(detail.sizeWidth),
sizeLength: display(detail.sizeLength),
sizeDepth: display(detail.sizeDepth),
detailCause: display(detail.detailCause),
restoreStatus: display(detail.restoreStatus),
restoreCompletedYmd: display(detail.restoreCompletedYmd),
},
],
[detail],
() => {
const restoreStatus = String(detail.restoreStatus ?? "").trim();
return [
{
acdntNo: display(detail.acdntNo),
ocrnYmd: display(detail.ocrnYmd),
fullAddress: display(detail.fullAddress || detail.daddr),
sizeWidth: display(detail.sizeWidth),
sizeLength: display(detail.sizeLength),
sizeDepth: display(detail.sizeDepth),
detailCause: display(detail.detailCause),
restoreStatus: display(
restoreStatusLabels[restoreStatus] || restoreStatus,
),
restoreCompletedYmd: display(detail.restoreCompletedYmd),
},
];
},
[detail, restoreStatusLabels],
);
const summaryHeadList = [
@@ -148,11 +164,11 @@ export default function SinkholeDetailPanel({
return (
<div
id="popup"
className="absolute top-0 right-0 z-[50] flex h-[calc(100vh-5rem)] w-[350px] flex-col overflow-hidden border-r border-gray-300 bg-white transition-all duration-300"
className="absolute right-0 top-0 z-[50] flex h-[calc(100vh-5rem)] w-[350px] flex-col overflow-hidden border-r border-border bg-background transition-all duration-300"
>
<div className="card-header relative flex items-center justify-between bg-primary p-4">
<div className="flex-1">
<h3 className="text-2xl font-medium text-white">
<h3 className="text-2xl font-medium text-primary-foreground">
지반침하정보
<br />
사고번호 : {display(detail.acdntNo)}
@@ -160,7 +176,7 @@ export default function SinkholeDetailPanel({
</div>
<Button
onClick={onClose}
className="flex items-center space-x-4 text-white hover:text-gray-300 focus:outline-none"
className="text-primary-foreground/80 flex items-center space-x-4 hover:text-primary-foreground focus:outline-none"
size="xs"
>
✕
@@ -186,15 +202,17 @@ export default function SinkholeDetailPanel({
침하발생 반경내 관로정보
</h3>
{canShowNearby ? (
<div className="mx-3 mt-2 whitespace-nowrap text-left text-[0.95rem] leading-5 text-slate-500">
<div className="mx-3 mt-2 whitespace-nowrap text-left text-[0.95rem] leading-5 text-muted-foreground">
<span>반경 {radiusMeters}m · </span>
<span className="font-bold text-[#1d4ed8]">{nearbyPipes.length.toLocaleString()}건</span>
<span className="font-bold text-primary">
{nearbyPipes.length.toLocaleString()}건
</span>
<span>, 평균경과 </span>
<span className="font-bold text-[#1d4ed8]">
<span className="font-bold text-primary">
{displayElapsedYears(nearbyPipeElapsedSummary.average)}
</span>
<span>, 최장경과 </span>
<span className="font-bold text-[#1d4ed8]">
<span className="font-bold text-primary">
{displayElapsedYears(nearbyPipeElapsedSummary.oldest)}
</span>
</div>
@@ -210,7 +228,7 @@ export default function SinkholeDetailPanel({
) : null}
</div>
) : nearbyPipes.length === 0 ? (
<div className="card !m-[1.2rem] !mt-0 rounded-sm border border-slate-200 bg-slate-50 px-3 py-3 text-[1.05rem] text-slate-600">
<div className="card !m-[1.2rem] !mt-0 rounded-sm border border-border bg-secondary px-3 py-3 text-[1.05rem] text-label">
반경 내 하수관로 정보가 없습니다.
</div>
) : (
@@ -225,19 +243,19 @@ export default function SinkholeDetailPanel({
</colgroup>
<thead>
<tr>
<th className="border border-gray-300 bg-blue-100 px-1 py-2 text-center text-[0.95rem] font-bold text-slate-900 whitespace-nowrap">
<th className="whitespace-nowrap border border-border bg-secondary px-1 py-2 text-center text-[0.95rem] font-bold text-label">
NO
</th>
<th className="border border-gray-300 bg-blue-100 px-1 py-2 text-center text-[0.95rem] font-bold text-slate-900 whitespace-nowrap">
<th className="whitespace-nowrap border border-border bg-secondary px-1 py-2 text-center text-[0.95rem] font-bold text-label">
관로 ID
</th>
<th className="border border-gray-300 bg-blue-100 px-1 py-2 text-center text-[0.95rem] font-bold text-slate-900 whitespace-nowrap">
<th className="whitespace-nowrap border border-border bg-secondary px-1 py-2 text-center text-[0.95rem] font-bold text-label">
용도
</th>
<th className="border border-gray-300 bg-blue-100 px-1 py-2 text-center text-[0.95rem] font-bold text-slate-900 whitespace-nowrap">
<th className="whitespace-nowrap border border-border bg-secondary px-1 py-2 text-center text-[0.95rem] font-bold text-label">
경과년도
</th>
<th className="border border-gray-300 bg-blue-100 px-1 py-2 text-center text-[0.95rem] font-bold text-slate-900 whitespace-nowrap">
<th className="whitespace-nowrap border border-border bg-secondary px-1 py-2 text-center text-[0.95rem] font-bold text-label">
거리(m)
</th>
</tr>
@@ -246,22 +264,22 @@ export default function SinkholeDetailPanel({
{nearbyPipes.map((pipe, index) => (
<tr
key={`${pipe.fid}-${index}`}
className="cursor-pointer hover:bg-blue-50"
className="cursor-pointer hover:bg-accent"
onClick={() => onSelectPipe(pipe)}
>
<td className="border border-gray-300 px-1 py-2 text-center text-[0.95rem] text-slate-900">
<td className="border border-border px-1 py-2 text-center text-[0.95rem] text-foreground">
{index + 1}
</td>
<td className="border border-gray-300 px-1 py-2 text-left text-[0.95rem] font-semibold text-[#1d4ed8] underline underline-offset-2">
<td className="border border-border px-1 py-2 text-left text-[0.95rem] font-semibold text-primary underline underline-offset-2">
{display(pipe.pipeId)}
</td>
<td className="border border-gray-300 px-1 py-2 text-center text-[0.95rem] text-slate-900 whitespace-nowrap">
<td className="whitespace-nowrap border border-border px-1 py-2 text-center text-[0.95rem] text-foreground">
{getPipeUseName(pipe)}
</td>
<td className="border border-gray-300 px-1 py-2 text-right text-[0.95rem] text-slate-900">
<td className="border border-border px-1 py-2 text-right text-[0.95rem] text-foreground">
{displayElapsedYears(pipe.elapsedYears)}
</td>
<td className="border border-gray-300 px-1 py-2 text-right text-[0.95rem] text-slate-900">
<td className="border border-border px-1 py-2 text-right text-[0.95rem] text-foreground">
{displayNumber(pipe.distanceMeters)}
</td>
</tr>
@@ -1,7 +1,12 @@
/**
* 화면명: 지반침하정보 레이어 설정
* 발생연도와 위험도 등 지도 표시 조건을 설정한다.
*/
"use client";
import { useEffect, useState } from "react";
import { SelectBox } from "@common/business_components/ui";
import { useAlertStore } from "@/shared/lib";
import {
normalizeSinkholeYearRange,
SINKHOLE_MIN_SEARCH_YEAR,
@@ -9,6 +14,7 @@ import {
type SinkholeSyncSummary,
type SinkholeYearRange,
} from "./sinkholeTypes";
import LayerSettingCard from "../layerSetting/LayerSettingCard";
type MaybePromise<T = void> = T | Promise<T>;
@@ -20,6 +26,9 @@ type Props = {
lastSyncSummary?: SinkholeSyncSummary | null;
isAdmin?: boolean;
compact?: boolean;
enabled?: boolean;
onEnabledChange?: (enabled: boolean) => void;
onClose?: () => void;
};
export default function SinkholeLayerSettingPanel({
@@ -30,7 +39,11 @@ export default function SinkholeLayerSettingPanel({
lastSyncSummary,
isAdmin = false,
compact = false,
enabled = true,
onEnabledChange = () => {},
onClose = () => {},
}: Props) {
const alert = useAlertStore((state) => state.setMessage);
const [isCollapsed, setIsCollapsed] = useState(false);
const [radiusText, setRadiusText] = useState(String(radiusMeters));
const [yearRangeDraft, setYearRangeDraft] = useState<SinkholeYearRange>(() =>
@@ -70,47 +83,64 @@ export default function SinkholeLayerSettingPanel({
try {
await onApplyYearRange(normalized);
alert(`시작년도 ${normalized.startYear} ~ 종료년도 ${normalized.endYear} 반영되었습니다.`);
alert(
`시작년도 ${normalized.startYear} ~ 종료년도 ${normalized.endYear} 반영되었습니다.`,
);
} catch {
alert("반영 중 오류가 발생했습니다.");
}
};
const updateYearRangeDraft = (key: keyof SinkholeYearRange, value: string) => {
const updateYearRangeDraft = (
key: keyof SinkholeYearRange,
value: string,
) => {
setYearRangeDraft((current) => ({
...current,
[key]: String(value).replace(/[^0-9]/g, "").slice(0, 4),
[key]: String(value)
.replace(/[^0-9]/g, "")
.slice(0, 4),
}));
};
const currentYear = new Date().getFullYear();
const yearSelectClass = "h-[22px] w-[62px] rounded-none border border-[#ccd6df] bg-white px-[4px] text-center text-[1rem] leading-none text-[#222]";
const yearSelectClass =
"!h-[26px] !w-full !rounded border border-input bg-background px-2 py-0 text-center text-[12px] leading-none text-foreground";
const startYearOptions = Array.from(
{ length: currentYear - SINKHOLE_MIN_SEARCH_YEAR + 1 },
(_, index) => String(currentYear - index),
)
.filter((year) => !yearRangeDraft.endYear || Number(year) <= Number(yearRangeDraft.endYear))
.filter(
(year) =>
!yearRangeDraft.endYear ||
Number(year) <= Number(yearRangeDraft.endYear),
)
.map((year) => ({ val: year, text: year }));
const endYearOptions = Array.from(
{ length: currentYear - SINKHOLE_MIN_SEARCH_YEAR + 1 },
(_, index) => String(currentYear - index),
)
.filter((year) => !yearRangeDraft.startYear || Number(year) >= Number(yearRangeDraft.startYear))
.filter(
(year) =>
!yearRangeDraft.startYear ||
Number(year) >= Number(yearRangeDraft.startYear),
)
.map((year) => ({ val: year, text: year }));
const inputClass = "h-[22px] border border-[#ccd6df] px-[2px] text-center text-[1rem] leading-none text-[#222]";
const numberInputClass = inputClass + " w-[40px]";
const applyButtonClass = "grid h-[20px] w-[20px] shrink-0 place-items-center rounded-full bg-[#31a83b] text-[0.95rem] font-bold leading-none text-white";
const indicator = <span className="h-2 w-2 shrink-0 rounded-full bg-[#243b93]" />;
const inputClass =
"h-[26px] !rounded border border-input bg-background px-2 py-0 text-center text-[12px] leading-none text-foreground";
const numberInputClass = inputClass + " min-w-0 flex-1";
const applyButtonClass =
"h-[26px] shrink-0 rounded border border-[#A9C5E5] bg-[#EDF4FC] px-2 py-0 text-[12px] font-medium leading-none text-[#2463A6]";
const settingRows = (
<div className="space-y-1.5 text-[1.05rem] font-medium text-slate-700">
<div className="flex min-h-[25px] items-center gap-1.5 whitespace-nowrap">
<div className="flex min-w-0 items-center gap-1">
{indicator}
<label htmlFor="sinkhole-radius" className="shrink-0">지반침하반경(m)</label>
<div className="space-y-1.5 text-[1.15rem] font-medium text-label">
<div className="flex min-h-[26px] flex-col items-stretch gap-1.5 whitespace-nowrap">
<div className="flex min-w-0 items-center">
<label htmlFor="sinkhole-radius" className="shrink-0">
지반침하반경(m)
</label>
</div>
<div className="ml-auto flex shrink-0 items-center gap-1">
<div className="flex w-full items-center gap-1">
<input
id="sinkhole-radius"
className={numberInputClass}
@@ -120,7 +150,9 @@ export default function SinkholeLayerSettingPanel({
autoComplete="off"
value={radiusText}
onFocus={(event) => event.currentTarget.select()}
onChange={(event) => setRadiusText(event.target.value.replace(/[^0-9]/g, ""))}
onChange={(event) =>
setRadiusText(event.target.value.replace(/[^0-9]/g, ""))
}
onKeyDown={(event) => {
if (event.key === "Enter") void commitRadius();
}}
@@ -132,37 +164,44 @@ export default function SinkholeLayerSettingPanel({
title="적용"
aria-label="반경 적용"
>
✓
확인
</button>
</div>
</div>
<div className="flex min-h-[25px] items-center gap-1.5 whitespace-nowrap">
<div className="flex min-w-0 items-center gap-1">
{indicator}
<label htmlFor="sinkhole-start-year" className="shrink-0">년도설정</label>
<div className="flex min-h-[26px] flex-col items-stretch gap-1.5 whitespace-nowrap">
<div className="flex min-w-0 items-center">
<label htmlFor="sinkhole-start-year" className="shrink-0">
발생년도
</label>
</div>
<div className="ml-auto flex shrink-0 items-center gap-1.5">
<div id="sinkhole-start-year" className="w-[62px]">
<div className="flex w-full items-center gap-1.5">
<div id="sinkhole-start-year" className="min-w-0 flex-1">
<SelectBox
className="w-full"
selectClass={yearSelectClass}
label=""
selectArray={startYearOptions}
value={yearRangeDraft.startYear}
onSelectValue={(value) => updateYearRangeDraft("startYear", String(value))}
onSelectValue={(value) =>
updateYearRangeDraft("startYear", String(value))
}
selectContentClass="z-[101]"
labelClass="hidden"
/>
</div>
<span className="px-[1px] text-[0.95rem] font-normal text-slate-500">~</span>
<div id="sinkhole-end-year" className="w-[62px]">
<span className="px-[1px] text-[0.95rem] font-normal text-muted-foreground">
~
</span>
<div id="sinkhole-end-year" className="min-w-0 flex-1">
<SelectBox
className="w-full"
selectClass={yearSelectClass}
label=""
selectArray={endYearOptions}
value={yearRangeDraft.endYear}
onSelectValue={(value) => updateYearRangeDraft("endYear", String(value))}
onSelectValue={(value) =>
updateYearRangeDraft("endYear", String(value))
}
selectContentClass="z-[101]"
labelClass="hidden"
/>
@@ -174,7 +213,7 @@ export default function SinkholeLayerSettingPanel({
title="적용"
aria-label="연도범위 적용"
>
✓
확인
</button>
</div>
</div>
@@ -183,24 +222,32 @@ export default function SinkholeLayerSettingPanel({
if (compact) {
return (
<div className="w-full rounded-[6px] border border-[#dbe7f5] bg-white px-2 py-[10px] shadow-[0_2px_8px_rgba(15,23,42,0.05)]">
<LayerSettingCard
title="지반침하정보"
enabled={enabled}
onEnabledChange={onEnabledChange}
onClose={onClose}
>
<div className="overflow-visible">{settingRows}</div>
</div>
</LayerSettingCard>
);
}
return (
<div className="w-full overflow-hidden rounded-sm border border-[#f0d7b0] bg-[#fff8ef]">
<div className="relative flex min-h-[30px] items-center justify-center border-b border-[#f2dfc3] bg-white px-8">
{/* 지반침하 상세는 지도 위험정보 경고영역이므로 주황 의미색을 유지한다. */}
<div className="relative flex min-h-[30px] items-center justify-center border-b border-[#f2dfc3] bg-background px-8">
<button
type="button"
className="absolute left-2 top-1/2 h-[18px] w-[18px] -translate-y-1/2 bg-transparent text-[1.2rem] leading-none text-[#555]"
className="absolute left-2 top-1/2 h-[18px] w-[18px] -translate-y-1/2 bg-transparent text-[1.2rem] leading-none text-muted-foreground"
onClick={() => setIsCollapsed((value) => !value)}
title={isCollapsed ? "펼치기" : "접기"}
>
{isCollapsed ? "+" : "-"}
</button>
<span className="text-[1.2rem] font-semibold text-[#d97706]">지반침하정보</span>
<span className="text-[1.2rem] font-semibold text-[#d97706]">
지반침하정보
</span>
</div>
{!isCollapsed && (
@@ -208,11 +255,14 @@ export default function SinkholeLayerSettingPanel({
<div className="overflow-visible">{settingRows}</div>
{isAdmin && lastSyncSummary ? (
<div className="mt-2 rounded-sm border border-[#f2dfc3] bg-white px-2 py-2 text-[1rem] text-[#7c5b2f]">
<div className="mt-2 rounded-sm border border-[#f2dfc3] bg-background px-2 py-2 text-[1rem] text-[#7c5b2f]">
<div className="font-semibold">최근 동기화</div>
<div className="mt-1">완료시각: {lastSyncSummary.finishedAt || "-"}</div>
<div className="mt-1">
성공 {lastSyncSummary.successCount}건 / 실패 {lastSyncSummary.failedCount}건
완료시각: {lastSyncSummary.finishedAt || "-"}
</div>
<div className="mt-1">
성공 {lastSyncSummary.successCount}건 / 실패{" "}
{lastSyncSummary.failedCount}건
</div>
</div>
) : null}
@@ -1,12 +1,27 @@
/**
* 화면명: 지반침하 목록
* 지반침하 정보를 검색하고 목록·엑셀·지도 보기 기능을 제공한다.
*/
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import { Button, Input } from "@common/components";
import { UiTable, downloadExcelWithKoreanHeaders } from "@common/business_components/ui";
import {
StandardPopupLayout,
type UiHeadProps,
} from "@common/business_components";
import { getRawJson } from "@/components/lib/api";
import { downloadExcelWithKoreanHeaders } from "@/shared/lib/download-excel";
import { useAlertStore } from "@/shared/lib";
import { fetchAllPages } from "@/shared/lib/fetch-all-pages";
import useStore from "@/store/useStore";
import LoadingOverlay from "@/components/ui/LoadingBar";
import MapPopupDataTable from "@/features/windowPopup/ui/MapPopupDataTable";
import PopupSearchPanel, {
PopupSearchActions,
PopupSearchField,
} from "@/features/windowPopup/ui/PopupSearchPanel";
import type { SelectDataType } from "@common/business_components";
import {
buildSinkholeDefaultFilters,
@@ -14,17 +29,16 @@ import {
formatDateInputToYmd,
formatYmdToDateInput,
normalizeSinkholeFilters,
SINKHOLE_COORD_STATUS_OPTIONS,
SINKHOLE_DOWNLOAD_EXCEL_HEADERS,
SINKHOLE_FILTER_SYNC_MESSAGE,
SINKHOLE_MIN_SEARCH_YMD,
SINKHOLE_RESTORE_STATUS_OPTIONS,
SINKHOLE_ROW_SELECT_MESSAGE,
type SinkholeFilters,
type SinkholeListItem,
} from "./sinkholeTypes";
import { useSinkholeCommonCodes } from "./useSinkholeCommonCodes";
const PAGE_SIZE = 20;
const PAGE_SIZE = 10;
const DOWNLOAD_BATCH_SIZE = 2000;
type SinkholeTableRow = SinkholeListItem & {
@@ -33,37 +47,49 @@ type SinkholeTableRow = SinkholeListItem & {
sizeLengthDisplay: string;
sizeDepthDisplay: string;
detailCauseDisplay: string;
restoreStatusDisplay: string;
restoreCompletedYmdDisplay: string;
coordStatusDisplay: string;
};
const HEADLIST = [
{ id: "acdntNo", title: "사고번호", widthPercent: 17 },
const HEADLIST: UiHeadProps[] = [
{ id: "rowNo", title: "순번", widthPercent: 6 },
{ id: "acdntNo", title: "사고번호", widthPercent: 15 },
{ id: "ocrnYmd", title: "발생일자", widthPercent: 10 },
{ id: "fullAddress", title: "주소", widthPercent: 20 },
{ id: "sizeWidthDisplay", title: "발생규모폭", widthPercent: 8 },
{ id: "sizeLengthDisplay", title: "발생규모연장", widthPercent: 8 },
{ id: "sizeDepthDisplay", title: "발생규모깊이", widthPercent: 8 },
{ id: "sizeLengthDisplay", title: "발생규모연장", widthPercent: 9 },
{ id: "sizeDepthDisplay", title: "발생규모깊이", widthPercent: 9 },
{ id: "detailCauseDisplay", title: "상세발생원인", widthPercent: 11 },
{ id: "restoreStatus", title: "복구상태", widthPercent: 8 },
{ id: "restoreStatusDisplay", title: "복구상태", widthPercent: 8 },
{ id: "coordStatusDisplay", title: "좌표보정상태", widthPercent: 9 },
{ id: "restoreCompletedYmdDisplay", title: "복구완료일자", widthPercent: 11 },
] as const;
];
const toDisplay = (value: unknown) => {
const normalized = String(value ?? "").trim();
return normalized ? normalized : "-";
};
const toCoordStatusDisplay = (value: unknown) => {
const normalized = String(value ?? "").trim().toUpperCase();
const toCoordStatusDisplay = (
value: unknown,
labels: Record<string, string> = {},
) => {
const normalized = String(value ?? "")
.trim()
.toUpperCase();
if (labels[normalized]) return labels[normalized];
if (normalized === "SUCCESS") return "성공";
if (normalized === "NO_ADDRESS") return "주소없음";
if (normalized === "FAILED") return "실패";
return normalized || "-";
};
const toTableRows = (items: SinkholeListItem[], page: number, pageSize = PAGE_SIZE) =>
const toTableRows = (
items: SinkholeListItem[],
page: number,
pageSize = PAGE_SIZE,
) =>
items.map((item, index) => ({
...item,
rowNo: page * pageSize + index + 1,
@@ -71,10 +97,29 @@ const toTableRows = (items: SinkholeListItem[], page: number, pageSize = PAGE_SI
sizeLengthDisplay: toDisplay(item.sizeLength),
sizeDepthDisplay: toDisplay(item.sizeDepth),
detailCauseDisplay: toDisplay(item.detailCause),
restoreStatusDisplay: toDisplay(item.restoreStatus),
coordStatusDisplay: toCoordStatusDisplay(item.coordStatus),
restoreCompletedYmdDisplay: toDisplay(item.restoreCompletedYmd),
}));
const applyStatusDisplayNames = (
rows: SinkholeTableRow[],
restoreStatusLabels: Record<string, string>,
coordStatusLabels: Record<string, string>,
) =>
rows.map((row) => {
const restoreStatus = String(row.restoreStatus ?? "").trim();
return {
...row,
restoreStatusDisplay:
restoreStatusLabels[restoreStatus] || restoreStatus || "-",
coordStatusDisplay: toCoordStatusDisplay(
row.coordStatus,
coordStatusLabels,
),
};
});
const buildDownloadRows = (rows: SinkholeTableRow[]) =>
rows.map((row) => ({
rowNo: row.rowNo,
@@ -84,7 +129,7 @@ const buildDownloadRows = (rows: SinkholeTableRow[]) =>
sizeLength: row.sizeLengthDisplay,
sizeDepth: row.sizeDepthDisplay,
detailCause: row.detailCauseDisplay,
restoreStatus: row.restoreStatus || "-",
restoreStatus: row.restoreStatusDisplay,
restoreCompletedYmd: row.restoreCompletedYmdDisplay,
}));
@@ -92,6 +137,14 @@ const unwrapPayload = <T,>(response: any): T =>
(response?.data ?? response) as T;
export default function SinkholePopupPage() {
const alert = useAlertStore((state) => state.setMessage);
// 검색 파라미터 계약은 유지하고 선택지·목록 표시명만 CMS 공통코드로 구성한다.
const {
restoreStatusOptions,
coordStatusOptions,
restoreStatusLabels,
coordStatusLabels,
} = useSinkholeCommonCodes();
const searchParams = useSearchParams();
const hasInitialFilterParams =
searchParams.has("startYmd") ||
@@ -140,13 +193,18 @@ export default function SinkholePopupPage() {
const [rows, setRows] = useState<SinkholeTableRow[]>([]);
const [total, setTotal] = useState(0);
const [currentPage, setCurrentPage] = useState(1);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [isLoading, setIsLoading] = useState(false);
const displayRows = useMemo(
() => applyStatusDisplayNames(rows, restoreStatusLabels, coordStatusLabels),
[coordStatusLabels, restoreStatusLabels, rows],
);
const sggOptions = useMemo(
() =>
filters.ctpvCd === "0"
? sgg
: sgg.filter((item) => String(item.val).startsWith(filters.ctpvCd.slice(0, 2))),
? []
: sgg.filter((item) =>
String(item.val).startsWith(filters.ctpvCd.slice(0, 2)),
),
[filters.ctpvCd, sgg],
);
@@ -172,8 +230,13 @@ export default function SinkholePopupPage() {
pageSize,
options,
);
const response = await getRawJson(`/sewerage-service/api/v1/sinkholes?${queryString}`);
const payload = unwrapPayload<{ items?: SinkholeListItem[]; total?: number }>(response);
const response = await getRawJson(
`/sewerage-service/api/v1/sinkholes?${queryString}`,
);
const payload = unwrapPayload<{
items?: SinkholeListItem[];
total?: number;
}>(response);
const items = Array.isArray(payload?.items) ? payload.items : [];
return {
rows: toTableRows(items, page - 1, pageSize),
@@ -185,11 +248,11 @@ export default function SinkholePopupPage() {
);
const loadRows = useCallback(
async (page = 1, nextFilters = filters) => {
async (page: number, nextFilters: SinkholeFilters) => {
setIsLoading(true);
try {
const normalized = normalizeSinkholeFilters(nextFilters);
const response = await fetchRows(page, normalized);
const response = await fetchRows(page, normalized, PAGE_SIZE);
setRows(response.rows);
setTotal(response.total);
setCurrentPage(page);
@@ -205,12 +268,16 @@ export default function SinkholePopupPage() {
console.error("지반침하 목록 조회 중 오류:", error);
setRows([]);
setTotal(0);
alert(error instanceof Error ? error.message : "지반침하 목록 조회 중 오류가 발생했습니다.");
alert(
error instanceof Error
? error.message
: "지반침하 목록 조회 중 오류가 발생했습니다.",
);
} finally {
setIsLoading(false);
}
},
[fetchRows, filters],
[alert, fetchRows],
);
useEffect(() => {
@@ -225,12 +292,12 @@ export default function SinkholePopupPage() {
await loadRows(page, normalized);
};
const handleRowClick = (index?: number) => {
if (typeof index !== "number") return;
const row = rows[index];
if (!row) return;
/** 페이지 이동은 입력 중인 조건이 아니라 마지막 적용 조건을 사용한다. */
const handlePageChange = async (page: number) => {
await loadRows(page, appliedFilters);
};
setSelectedIndex(index);
const handleRowClick = (row: SinkholeTableRow) => {
window.opener?.postMessage(
{
type: SINKHOLE_ROW_SELECT_MESSAGE,
@@ -249,22 +316,27 @@ export default function SinkholePopupPage() {
setIsLoading(true);
try {
const normalized = normalizeSinkholeFilters(appliedFilters);
const downloadItems: SinkholeListItem[] = [];
const totalPages = Math.max(1, Math.ceil(total / DOWNLOAD_BATCH_SIZE));
for (let page = 1; page <= totalPages; page += 1) {
const response = await fetchRows(page, normalized, DOWNLOAD_BATCH_SIZE, {
includeCoordStatus: false,
});
downloadItems.push(...response.items);
}
const response = await fetchAllPages({
batchSize: DOWNLOAD_BATCH_SIZE,
fetchPage: (page, size) =>
fetchRows(page + 1, normalized, size, {
includeCoordStatus: false,
}),
getItems: (page) => page.items,
getTotal: (page) => page.total,
});
const downloadItems: SinkholeListItem[] = response.items;
if (downloadItems.length === 0) {
alert("다운로드할 데이터가 없습니다.");
return;
}
const downloadRows = toTableRows(downloadItems, 0, 1);
const downloadRows = applyStatusDisplayNames(
toTableRows(downloadItems, 0, 1),
restoreStatusLabels,
coordStatusLabels,
);
await downloadExcelWithKoreanHeaders({
data: buildDownloadRows(downloadRows) as unknown as JSON[],
fileName: `지반침하정보_${new Date().toISOString().slice(0, 10)}.xlsx`,
@@ -274,148 +346,174 @@ export default function SinkholePopupPage() {
});
} catch (error) {
console.error("지반침하 다운로드 중 오류:", error);
alert(error instanceof Error ? error.message : "지반침하 다운로드 중 오류가 발생했습니다.");
alert(
error instanceof Error
? error.message
: "지반침하 다운로드 중 오류가 발생했습니다.",
);
} finally {
setIsLoading(false);
}
};
return (
<main className="flex h-screen flex-col overflow-hidden bg-white">
<StandardPopupLayout title="지반침하 목록">
<LoadingOverlay isLoading={isLoading} />
<header className="flex min-h-[44px] items-center justify-between border-b border-[#dbe2e6] bg-primary px-5 py-3">
<h1 className="text-[1.45rem] font-semibold text-white">지반침하발생현황</h1>
<div />
</header>
<section className="overflow-y-auto p-4">
<div className="grid grid-cols-1 gap-3 border-b border-slate-200 bg-slate-50 pb-4 md:grid-cols-[170px_170px_220px_220px_180px_180px_auto] md:items-end md:justify-between">
<label className="block md:max-w-[170px]">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">발생기간(시작)</div>
<Input
className="h-10 !text-[1.1rem]"
type="date"
min={formatYmdToDateInput(SINKHOLE_MIN_SEARCH_YMD)}
value={formatYmdToDateInput(filters.startYmd)}
onChange={(event) => setFilters({ ...filters, startYmd: formatDateInputToYmd(event.target.value) })}
/>
</label>
<PopupSearchPanel>
<PopupSearchField label="발생기간(시작)">
<Input
className="h-[3.2rem] w-[12rem] !text-[1.3rem]"
type="date"
min={formatYmdToDateInput(SINKHOLE_MIN_SEARCH_YMD)}
value={formatYmdToDateInput(filters.startYmd)}
onChange={(event) =>
setFilters({
...filters,
startYmd: formatDateInputToYmd(event.target.value),
})
}
/>
</PopupSearchField>
<label className="block md:max-w-[170px]">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">발생기간(종료)</div>
<Input
className="h-10 !text-[1.1rem]"
type="date"
min={formatYmdToDateInput(SINKHOLE_MIN_SEARCH_YMD)}
value={formatYmdToDateInput(filters.endYmd)}
onChange={(event) => setFilters({ ...filters, endYmd: formatDateInputToYmd(event.target.value) })}
/>
</label>
<PopupSearchField label="발생기간(종료)">
<Input
className="h-[3.2rem] w-[12rem] !text-[1.3rem]"
type="date"
min={formatYmdToDateInput(SINKHOLE_MIN_SEARCH_YMD)}
value={formatYmdToDateInput(filters.endYmd)}
onChange={(event) =>
setFilters({
...filters,
endYmd: formatDateInputToYmd(event.target.value),
})
}
/>
</PopupSearchField>
<label className="block md:max-w-[220px]">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시도</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
value={filters.ctpvCd}
onChange={(event) =>
setFilters({
...filters,
ctpvCd: event.target.value,
sggCd: "0",
})
}
<PopupSearchField label="시도">
<select
className="h-[3.2rem] w-[12rem] rounded-md border border-input bg-field px-3 text-[1.3rem] text-foreground"
value={filters.ctpvCd}
onChange={(event) =>
setFilters({
...filters,
ctpvCd: event.target.value,
sggCd: "0",
})
}
>
<option value="0">전체</option>
{ctpv.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</PopupSearchField>
<PopupSearchField label="시군구">
<select
className="h-[3.2rem] w-[12rem] rounded-md border border-input bg-field px-3 text-[1.3rem] text-foreground"
value={filters.sggCd}
onChange={(event) =>
setFilters({ ...filters, sggCd: event.target.value })
}
>
<option value="0">전체</option>
{sggOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</PopupSearchField>
<PopupSearchField label="복구상태">
<select
className="h-[3.2rem] w-[10rem] rounded-md border border-input bg-field px-3 text-[1.3rem] text-foreground"
value={filters.restoreStatus}
onChange={(event) =>
setFilters({ ...filters, restoreStatus: event.target.value })
}
>
{restoreStatusOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</PopupSearchField>
<PopupSearchField label="좌표보정상태">
<select
className="h-[3.2rem] w-[10rem] rounded-md border border-input bg-field px-3 text-[1.3rem] text-foreground"
value={filters.coordStatus}
onChange={(event) =>
setFilters({ ...filters, coordStatus: event.target.value })
}
>
{coordStatusOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</PopupSearchField>
<PopupSearchActions>
<Button
type="button"
color="primary"
onClick={() => void handleSearch(1)}
>
조회
</Button>
<Button
type="button"
color="white"
onClick={() => {
const resetFilters = normalizeSinkholeFilters(
buildSinkholeDefaultFilters(),
undefined,
);
setFilters(resetFilters);
void loadRows(1, resetFilters);
}}
>
초기화
</Button>
</PopupSearchActions>
</PopupSearchPanel>
<MapPopupDataTable
rows={displayRows as Array<SinkholeTableRow & Record<string, unknown>>}
headlist={HEADLIST}
title="지반침하 목록"
total={total}
page={currentPage}
pageSize={PAGE_SIZE}
onPageChange={(page) => void handlePageChange(page)}
onRowClick={handleRowClick}
rowId={(row, index) => String(row.fid ?? row.acdntNo ?? index)}
renderCell={(row, headId) =>
headId === "fullAddress" ? (
<span
className="block whitespace-nowrap"
title={toDisplay(row.fullAddress)}
>
<option value="0">전체</option>
{ctpv.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="block md:max-w-[220px]">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">시군구</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
value={filters.sggCd}
onChange={(event) => setFilters({ ...filters, sggCd: event.target.value })}
>
<option value="0">전체</option>
{sggOptions.map((item) => (
<option key={item.val} value={String(item.val)}>
{item.text}
</option>
))}
</select>
</label>
<label className="block md:max-w-[180px]">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">복구상태</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
value={filters.restoreStatus}
onChange={(event) => setFilters({ ...filters, restoreStatus: event.target.value })}
>
{SINKHOLE_RESTORE_STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="block md:max-w-[180px]">
<div className="mb-1 text-[1.05rem] font-medium text-slate-700">좌표보정상태</div>
<select
className="h-10 w-full rounded-md border border-slate-300 px-3 text-[1.1rem]"
value={filters.coordStatus}
onChange={(event) => setFilters({ ...filters, coordStatus: event.target.value })}
>
{SINKHOLE_COORD_STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<div className="flex flex-wrap justify-end gap-2 md:pr-3">
<Button
type="button"
size="sm"
color="primary"
className="h-10 px-5 !text-[1.1rem]"
onClick={() => void handleSearch(1)}
>
조회
</Button>
<Button
type="button"
size="sm"
color="green"
className="h-10 px-5 !text-[1.1rem]"
onClick={handleDownload}
>
다운로드
</Button>
</div>
</div>
<UiTable<SinkholeTableRow>
headName={<>지반침하 조회 결과 총 {total.toLocaleString()}건</>}
tableData={rows}
headlist={HEADLIST as any}
pageSize={PAGE_SIZE}
total={total}
cellClick={(index) => handleRowClick(index)}
onPageChange={(page) => void handleSearch(page)}
curPage={currentPage}
index={selectedIndex}
widthFixed={true}
isHideCount={true}
/>
</section>
</main>
{toDisplay(row.fullAddress)}
</span>
) : undefined
}
scrollMode="horizontal"
minTableWidth="150rem"
actions={
<Button type="button" onClick={handleDownload}>
엑셀다운로드
</Button>
}
/>
</StandardPopupLayout>
);
}
@@ -1,7 +1,9 @@
/** 지반침하 화면에서 사용하는 타입·상수·검색 조건을 정의한다. */
"use client";
import type { SelectDataType } from "@common/business_components";
import type { IUserInfo } from "@/shared/store";
import { appendApiSearchParams } from "@/shared/util/util";
export const SINKHOLE_CONTROL_NODE_ID = "SN_SINKHOLE";
export const SINKHOLE_LAYER_TYPE = "tb_sn_sinkhole_info";
@@ -14,7 +16,9 @@ export const SINKHOLE_DEFAULT_RADIUS = 100;
export const SINKHOLE_LAYER_ID = "SN_SINKHOLE_LAYER";
export const SINKHOLE_SELECTED_LAYER_ID = "SN_SINKHOLE_SELECTED_LAYER";
export const SINKHOLE_MIN_SEARCH_YMD = "20160101";
export const SINKHOLE_MIN_SEARCH_YEAR = Number(SINKHOLE_MIN_SEARCH_YMD.slice(0, 4));
export const SINKHOLE_MIN_SEARCH_YEAR = Number(
SINKHOLE_MIN_SEARCH_YMD.slice(0, 4),
);
export type SinkholeFilters = {
startYmd: string;
@@ -93,7 +97,9 @@ export type SinkholeYearRange = {
const toYmd = (date: Date) =>
`${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, "0")}${String(date.getDate()).padStart(2, "0")}`;
export const buildSinkholeDefaultYearRange = (baseDate = new Date()): SinkholeYearRange => {
export const buildSinkholeDefaultYearRange = (
baseDate = new Date(),
): SinkholeYearRange => {
const currentYear = baseDate.getFullYear();
return {
startYear: String(currentYear - 1),
@@ -105,8 +111,12 @@ export const normalizeSinkholeYearRange = (
range?: Partial<SinkholeYearRange> | null,
fallback: SinkholeYearRange = buildSinkholeDefaultYearRange(),
): SinkholeYearRange => ({
startYear: String(range?.startYear ?? fallback.startYear).replace(/[^0-9]/g, "").slice(0, 4),
endYear: String(range?.endYear ?? fallback.endYear).replace(/[^0-9]/g, "").slice(0, 4),
startYear: String(range?.startYear ?? fallback.startYear)
.replace(/[^0-9]/g, "")
.slice(0, 4),
endYear: String(range?.endYear ?? fallback.endYear)
.replace(/[^0-9]/g, "")
.slice(0, 4),
});
export const validateSinkholeYearRange = (
@@ -119,7 +129,10 @@ export const validateSinkholeYearRange = (
if (!/^\d{4}$/.test(range.startYear) || !/^\d{4}$/.test(range.endYear)) {
return "시작년도와 종료년도는 4자리 숫자로 입력해야 합니다.";
}
if (startYear < SINKHOLE_MIN_SEARCH_YEAR || endYear < SINKHOLE_MIN_SEARCH_YEAR) {
if (
startYear < SINKHOLE_MIN_SEARCH_YEAR ||
endYear < SINKHOLE_MIN_SEARCH_YEAR
) {
return `${SINKHOLE_MIN_SEARCH_YEAR}년 이후 범위로 입력해야 합니다.`;
}
if (startYear > endYear) {
@@ -147,7 +160,9 @@ export const buildSinkholeFiltersFromYearRange = (
};
};
export const buildSinkholeDefaultFilters = (scope?: SinkholeScope): SinkholeFilters => {
export const buildSinkholeDefaultFilters = (
scope?: SinkholeScope,
): SinkholeFilters => {
const today = new Date();
const oneYearAgo = new Date(today);
oneYearAgo.setFullYear(today.getFullYear() - 1);
@@ -162,7 +177,9 @@ export const buildSinkholeDefaultFilters = (scope?: SinkholeScope): SinkholeFilt
};
};
export const buildSinkholeScope = (userInfo: IUserInfo | null): SinkholeScope => {
export const buildSinkholeScope = (
userInfo: IUserInfo | null,
): SinkholeScope => {
const admin = userInfo?.adminRoleYn === "Y";
if (admin) {
return {
@@ -204,8 +221,12 @@ export const normalizeSinkholeFilters = (
scope?: SinkholeScope,
): SinkholeFilters => {
const next = {
startYmd: String(filters.startYmd ?? "").replace(/[^0-9]/g, "").slice(0, 8),
endYmd: String(filters.endYmd ?? "").replace(/[^0-9]/g, "").slice(0, 8),
startYmd: String(filters.startYmd ?? "")
.replace(/[^0-9]/g, "")
.slice(0, 8),
endYmd: String(filters.endYmd ?? "")
.replace(/[^0-9]/g, "")
.slice(0, 8),
ctpvCd: String(filters.ctpvCd ?? "0") || "0",
sggCd: String(filters.sggCd ?? "0") || "0",
restoreStatus: String(filters.restoreStatus ?? "0") || "0",
@@ -217,7 +238,11 @@ export const normalizeSinkholeFilters = (
}
if (scope?.sggCd && scope.sggCd !== "0") {
next.sggCd = scope.sggCd;
} else if (next.ctpvCd !== "0" && next.sggCd !== "0" && !next.sggCd.startsWith(next.ctpvCd)) {
} else if (
next.ctpvCd !== "0" &&
next.sggCd !== "0" &&
!next.sggCd.startsWith(next.ctpvCd)
) {
next.sggCd = "0";
}
@@ -230,7 +255,10 @@ export const formatYmdToDateInput = (value?: string) => {
return `${normalized.slice(0, 4)}-${normalized.slice(4, 6)}-${normalized.slice(6, 8)}`;
};
export const formatDateInputToYmd = (value?: string) => String(value ?? "").replace(/[^0-9]/g, "").slice(0, 8);
export const formatDateInputToYmd = (value?: string) =>
String(value ?? "")
.replace(/[^0-9]/g, "")
.slice(0, 8);
export const resolveRegionNameByCode = (
code: string,
@@ -258,23 +286,19 @@ export const buildSinkholeListQueryString = (
size: String(size),
});
if (filters.startYmd) params.set("startYmd", filters.startYmd);
if (filters.endYmd) params.set("endYmd", filters.endYmd);
const ctpvNm = resolveRegionNameByCode(filters.ctpvCd, ctpvOptions, 2);
const sggNm = resolveRegionNameByCode(filters.sggCd, sggOptions, 5);
if (ctpvNm) params.set("ctpvNm", ctpvNm);
if (sggNm) params.set("sggNm", sggNm);
if (filters.restoreStatus && filters.restoreStatus !== "0") {
params.set("restoreStatus", filters.restoreStatus);
}
if (filters.coordStatus && filters.coordStatus !== "0") {
params.set("coordStatus", filters.coordStatus);
}
appendApiSearchParams(params, {
startYmd: filters.startYmd,
endYmd: filters.endYmd,
ctpvNm,
sggNm,
restoreStatus: filters.restoreStatus,
coordStatus: filters.coordStatus,
});
if (options?.includeCoordStatus === false) {
params.set("includeCoordStatus", "false");
}
return params.toString();
};
@@ -282,7 +306,9 @@ export const isSinkholeLayerNode = (node: any) => {
const data = node?.data ?? node;
const nodeId = String(node?.id ?? data?.lyrSn ?? "");
const layerName = String(data?.lyrNm ?? data?.name ?? "").trim();
const layerTypeName = String(data?.lyrTyNm ?? data?.lyrTypeNm ?? data?.lyr_type_nm ?? "").trim();
const layerTypeName = String(
data?.lyrTyNm ?? data?.lyrTypeNm ?? data?.lyr_type_nm ?? "",
).trim();
return (
nodeId === SINKHOLE_CONTROL_NODE_ID ||
@@ -295,10 +321,11 @@ export const isSinkholeRootNode = (node: any) => {
const data = node?.data ?? node;
const nodeId = String(node?.id ?? data?.lyrSn ?? "");
const layerName = String(data?.lyrNm ?? data?.name ?? "").trim();
return nodeId === SINKHOLE_CONTROL_NODE_ID || layerName === SINKHOLE_LAYER_NAME;
return (
nodeId === SINKHOLE_CONTROL_NODE_ID || layerName === SINKHOLE_LAYER_NAME
);
};
export const SINKHOLE_RESTORE_STATUS_OPTIONS = [
{ value: "0", label: "전체" },
{ value: "복구완료", label: "복구완료" },
@@ -313,7 +340,7 @@ export const SINKHOLE_COORD_STATUS_OPTIONS = [
] as const;
export const SINKHOLE_EXCEL_HEADERS: Record<string, string> = {
rowNo: "NO",
rowNo: "순번",
ocrnYmd: "발생일자",
fullAddress: "주소",
sizeWidth: "발생규모폭",
@@ -326,7 +353,7 @@ export const SINKHOLE_EXCEL_HEADERS: Record<string, string> = {
};
export const SINKHOLE_DOWNLOAD_EXCEL_HEADERS: Record<string, string> = {
rowNo: "NO",
rowNo: "순번",
ocrnYmd: "발생일자",
fullAddress: "주소",
sizeWidth: "발생규모폭",
@@ -0,0 +1,139 @@
/** 지반침하 화면에 필요한 공통코드를 조회하고 선택 항목으로 제공한다. */
"use client";
import { useEffect, useMemo, useState } from "react";
import { getCommonCodes } from "@/components/lib/common/apiHelpers";
import {
SINKHOLE_COORD_STATUS_OPTIONS,
SINKHOLE_RESTORE_STATUS_OPTIONS,
} from "./sinkholeTypes";
type CodeLabelMap = Record<string, string>;
type StatusOption = { value: string; label: string };
const COMMON_CODE_GROUPS = {
restoreStatus: "SNKHLRSTRSTTS",
coordStatus: "SNKHLCRDSTTS",
} as const;
const cachedCodeLists = new Map<
string,
Array<{ lwpoCd: string; lwpoCdNm: string }>
>();
const pendingCodeLists = new Map<
string,
Promise<Array<{ lwpoCd: string; lwpoCdNm: string }>>
>();
/** 같은 브라우저 세션에서는 지반침하 상태 코드그룹별 CMS 요청을 한 번만 수행한다. */
const loadCodeList = async (upCd: string) => {
const cached = cachedCodeLists.get(upCd);
if (cached) return cached;
const pending = pendingCodeLists.get(upCd);
if (pending) return pending;
const request = getCommonCodes(upCd)
.then((codes) => {
const codeList = codes.map(({ lwpoCd, lwpoCdNm }) => ({
lwpoCd,
lwpoCdNm,
}));
cachedCodeLists.set(upCd, codeList);
return codeList;
})
.finally(() => {
pendingCodeLists.delete(upCd);
});
pendingCodeLists.set(upCd, request);
return request;
};
/** 지반침하 상태의 검색값 계약은 유지하고 선택지와 표시명만 CMS 기준으로 제공한다. */
export const useSinkholeCommonCodes = () => {
const [restoreCodes, setRestoreCodes] = useState(
cachedCodeLists.get(COMMON_CODE_GROUPS.restoreStatus) ?? [],
);
const [coordCodes, setCoordCodes] = useState(
cachedCodeLists.get(COMMON_CODE_GROUPS.coordStatus) ?? [],
);
useEffect(() => {
let cancelled = false;
loadCodeList(COMMON_CODE_GROUPS.restoreStatus)
.then((codes) => {
if (!cancelled) setRestoreCodes(codes);
})
.catch((error) => {
console.error("SNKHLRSTRSTTS 공통코드 조회에 실패했습니다.", error);
});
loadCodeList(COMMON_CODE_GROUPS.coordStatus)
.then((codes) => {
if (!cancelled) setCoordCodes(codes);
})
.catch((error) => {
console.error("SNKHLCRDSTTS 공통코드 조회에 실패했습니다.", error);
});
return () => {
cancelled = true;
};
}, []);
const restoreStatusOptions = useMemo<StatusOption[]>(
() =>
restoreCodes.length > 0
? [
{ value: "0", label: "전체" },
...restoreCodes.map((code) => ({
// 백엔드는 복구상태 한글 설명값을 검색조건으로 사용하므로 코드명을 전송한다.
value: code.lwpoCdNm,
label: code.lwpoCdNm,
})),
]
: [...SINKHOLE_RESTORE_STATUS_OPTIONS],
[restoreCodes],
);
const coordStatusOptions = useMemo<StatusOption[]>(
() =>
coordCodes.length > 0
? [
{ value: "0", label: "전체" },
...coordCodes.map((code) => ({
value: code.lwpoCd,
label: code.lwpoCdNm,
})),
]
: [...SINKHOLE_COORD_STATUS_OPTIONS],
[coordCodes],
);
const restoreStatusLabels = useMemo<CodeLabelMap>(() => {
const labels: CodeLabelMap = {};
restoreCodes.forEach((code) => {
labels[code.lwpoCd] = code.lwpoCdNm;
labels[code.lwpoCdNm] = code.lwpoCdNm;
});
return labels;
}, [restoreCodes]);
const coordStatusLabels = useMemo<CodeLabelMap>(() => {
const labels: CodeLabelMap = {};
coordCodes.forEach((code) => {
labels[code.lwpoCd] = code.lwpoCdNm;
});
return labels;
}, [coordCodes]);
return {
restoreStatusOptions,
coordStatusOptions,
restoreStatusLabels,
coordStatusLabels,
};
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,96 @@
/** 통수위험 분석 대상과 결과를 조회하는 API 함수를 제공한다. */
import { getData } from "@/app/components/lib/api";
import type { UiTableSort } from "@common/business_components/ui/table";
import type {
WaterRiskAreaOption,
WaterRiskMapView,
WaterRiskResultPage,
WaterRiskResultRow,
WaterRiskStatus,
} from "./waterRiskTypes";
export type WaterRiskResultQuery = {
startDate: string;
endDate: string;
analysisBaseAt?: string;
ctpvCd?: string;
sggCd?: string;
areaId?: string;
riskStatus?: WaterRiskStatus;
rainEventStatus?: "IN_PROGRESS" | "ENDED";
sort?: UiTableSort;
page: number;
size: number;
};
type WaterRiskApiResultRow = Omit<WaterRiskResultRow, "sidoName"> & {
ctpvName: string;
};
/** 통수위험 결과 API 응답을 기존 화면 표시 모델로 정규화한다. */
const normalizeResultRow = (row: WaterRiskApiResultRow): WaterRiskResultRow => ({
...row,
sidoName: row.ctpvName,
currentAccumRainQty: row.currentAccumRainQty ?? null,
pipeSummaries: Array.isArray(row.pipeSummaries) ? row.pipeSummaries : [],
});
/** 등록된 중점관리지역 검색조건을 조회한다. */
export const fetchWaterRiskAreas = async (): Promise<WaterRiskAreaOption[]> => {
const response = await getData("v1/waterRisk/areas");
return Array.isArray(response) ? response : [];
};
/** 기간·행정구역·지역·위험상태에 맞는 결과 페이지를 조회한다. */
export const fetchWaterRiskResults = async (
query: WaterRiskResultQuery,
): Promise<WaterRiskResultPage> => {
const params = new URLSearchParams({
startDate: query.startDate,
endDate: query.endDate,
page: String(query.page),
size: String(query.size),
});
if (query.ctpvCd) params.set("ctpvCd", query.ctpvCd);
if (query.analysisBaseAt) params.set("analysisBaseAt", query.analysisBaseAt);
if (query.sggCd) params.set("sggCd", query.sggCd);
if (query.areaId) params.set("areaId", query.areaId);
if (query.riskStatus) params.set("riskStatus", query.riskStatus);
if (query.rainEventStatus) params.set("rainEventStatus", query.rainEventStatus);
if (query.sort) params.set("sort", `${query.sort.key},${query.sort.direction}`);
const response = await getData(`v1/waterRisk/results?${params.toString()}`);
const items = Array.isArray(response?.items)
? response.items.map((row: WaterRiskApiResultRow) => normalizeResultRow(row))
: [];
return {
items,
analysisBaseAt: response?.analysisBaseAt ?? null,
total: Number(response?.total ?? 0),
page: Number(response?.page ?? query.page),
size: Number(response?.size ?? query.size),
totalPages: Number(response?.totalPages ?? 0),
};
};
/** 결과 행과 연결된 중점관리지역·유출관로 도형을 조회한다. */
export const fetchWaterRiskMapView = async (
row: WaterRiskResultRow,
): Promise<WaterRiskMapView> => {
if (!row.analysisDate || !row.ctpvCd || !row.sggCd) {
throw new Error("지도 조회에 필요한 분석일·행정구역 정보가 없습니다.");
}
const params = new URLSearchParams({
ctpvCd: row.ctpvCd,
sggCd: row.sggCd,
});
const response = await getData(
`v1/waterRisk/results/${encodeURIComponent(row.analysisDate)}/areas/${encodeURIComponent(row.areaId)}/mapView?${params.toString()}`,
);
return {
...response,
pipes: Array.isArray(response?.pipes) ? response.pipes : [],
manholes: Array.isArray(response?.manholes) ? response.manholes : [],
};
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff

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