Compare commits

..
162 Commits
Author SHA1 Message Date
jcchoi 74934d4559 수정
Deploy Frontend / deploy (push) Successful in 4m0s
2026-10-22 14:28:31 +09:00
jcchoi 027f88da6c 대시보드 ㅁ이상치리포트 명칭 변경 2026-10-22 14:24:21 +09:00
changwng 6dc2965209 [as fr] 필수첨부 파일에 필수파일검토내용에 CRUD 처리 및 AI 요청시 같이 전송처리
Deploy Frontend / deploy (push) Successful in 4m5s
2026-10-21 16:14:39 +09:00
jcchoi c69c1aa855 Merge branch 'iipbs/st/dev'
Deploy Frontend / deploy (push) Successful in 9m0s
2026-10-21 13:15:44 +09:00
jcchoi 9f0e03c151 이상치 선별보고서 2026-10-21 11:31:50 +09:00
jcchoi 471a431af5 대시보드 진행상태 변경 2026-10-21 10:37:32 +09:00
jcchoi 26d8410649 Merge remote-tracking branch 'origin/iipbs/st/dev'
Deploy Frontend / deploy (push) Successful in 5m0s
2026-10-20 21:52:31 +09:00
jcchoi 8403ad556c 설치인가 버튼 이벤트 수정 2026-10-20 21:50:41 +09:00
rsone001 d26c15f857 Merge branch 'iipbs/st/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/st/dev 2026-10-20 17:49:42 +09:00
rsone001 86ed664e3e [input] - 오류 로직 수정 2026-10-20 17:49:09 +09:00
jcchoi a105f395dd 설치인가 프로세스 변경 2026-10-20 15:50:05 +09:00
jcchoi a5ebc12580 검토의견 수정 오류 처리 2026-10-20 13:48:39 +09:00
jcchoi 270f6e366f Merge branch 'iipbs/st/dev' 2026-10-20 10:53:41 +09:00
jcchoi acf215c1bf 대시보드 이상치선별보고서 수정 2026-10-20 10:45:29 +09:00
jcchoi 8a98cd7d19 행정지원 검토의견 수정 2026-10-20 10:04:42 +09:00
jcchoi 3a5b48b608 통계 도움말 오류 수정 2026-10-20 10:04:26 +09:00
jcchoi d735ad7dd7 통계 파라미터 변경
Deploy Frontend / deploy (push) Successful in 1m57s
2026-10-19 21:28:01 +09:00
jcchoi 25dd0f1504 getVerifyResultList 파라미터 변경
Deploy Frontend / deploy (push) Successful in 2m2s
2026-10-19 19:17:01 +09:00
jcchoi 8e14c6cef5 Merge remote-tracking branch 'origin/iipbs/st/dev' 2026-10-19 19:07:14 +09:00
jcchoi 9f7abe4413 필수첨부파일 위치 업데이트 2026-10-19 18:41:58 +09:00
rsone001 de730205af [ST] - 일반 검증 등록 및 수정 다이얼로그 css 수정 2026-10-19 17:50:49 +09:00
rsone001 a873f0e0dc [ST] - api 표준화 누락 수정 2026-10-19 15:14:58 +09:00
jcchoi 32e3765b52 초안 평가 단계 수정
Deploy Frontend / deploy (push) Successful in 1m53s
2026-10-15 19:56:54 +09:00
rsone001 4f62095e8e [ST] - fix: input 2-3 검증 cell 스타일 로직 수정 2026-10-15 16:03:40 +09:00
jcchoi 5234448ae5 기본계획 조회 전체 적용
Deploy Frontend / deploy (push) Successful in 1m40s
2026-10-15 15:53:38 +09:00
jcchoi f284c67988 hwp 서버 도메인 연결 2026-10-15 10:43:15 +09:00
jcchoi 2d77813dab Merge remote-tracking branch 'origin/iipbs/st/dev'
Deploy Frontend / deploy (push) Successful in 4m31s
2026-10-14 17:36:08 +09:00
rsone001 d22a255143 [ST] - fix: type 오류 수정 2026-10-14 17:35:32 +09:00
jcchoi 6703bbeeec Merge remote-tracking branch 'origin/iipbs/st/dev' 2026-10-14 17:25:16 +09:00
rsone001 d444c591ec [ST] - flx: input 표준화에 맞춰 소스 수정 2026-10-14 17:05:45 +09:00
rsone001 f7ddb6cae0 Merge remote-tracking branch 'origin/feature/sein' into iipbs/st/dev 2026-10-14 15:39:21 +09:00
yangsein 7ffc02e887 refactor: 대시보드 통계처리현황 표준화 수정 2026-10-14 15:17:25 +09:00
yangsein cbacb27bb1 refactor: 행정동 갱신 표준화 수정 2026-10-14 15:16:40 +09:00
100JM 03a08a320a [ST] - fix: DB 표준화에 따른 소스 수정 2026-10-14 14:24:03 +09:00
jcchoi 45223be64a 포탈 수정
Deploy Frontend / deploy (push) Successful in 3m45s
2026-10-13 11:44:05 +09:00
jcchoi b22dfdfa4c 웹기안기 도메인 적용 2026-10-13 11:22:08 +09:00
jcchoi ce219f0e40 https 적용
Deploy Frontend / deploy (push) Successful in 4m5s
2026-10-13 10:53:39 +09:00
jcchoi a0f26d38b2 행정지원 리스트 디자인 cs 와 통일
Deploy Frontend / deploy (push) Successful in 3m42s
2026-10-12 19:19:13 +09:00
jcchoi 4efdcfe0fc 공통 테마 적용
Deploy Frontend / deploy (push) Successful in 7m55s
2026-10-12 18:47:26 +09:00
jcchoi 806ccf950f 행정지원 대시보드 테마 적용 2026-10-12 18:38:00 +09:00
jcchoi b96b78fa0b skin 및 레이아웃 작업 2026-10-12 17:22:37 +09:00
jcchoi f816bf005e 파일보안 간격 조절
Deploy Frontend / deploy (push) Successful in 9m15s
2026-10-12 11:54:55 +09:00
jcchoi 791ad6ce37 파일보안 줄바꿈 2026-10-12 11:50:50 +09:00
jcchoi 4bc802cee2 파일보완 내용 자동 줄빠굼 2026-10-12 10:55:19 +09:00
jcchoi 4662c8c896 선형 구조 수정 2026-10-12 10:50:09 +09:00
jcchoi 7058560a03 메뉴 배치 수정 2026-10-12 10:09:52 +09:00
jcchoi 79a5999ad5 콘텐츠의 선형 구조 2026-10-12 10:09:39 +09:00
jcchoi 1aeded5da2 페이지 타이틀 2026-10-12 10:09:16 +09:00
jcchoi e8d1d882c2 페이지 타이틀 수정 2026-10-12 10:01:22 +09:00
jcchoi 39dca810a5 css 오류 수정
Deploy Frontend / deploy (push) Successful in 7m50s
2026-10-11 17:58:09 +09:00
jcchoi 97f94688f1 Html 문법 오류
Deploy Frontend / deploy (push) Successful in 7m57s
2026-10-11 17:36:43 +09:00
jcchoi 0b502cbc99 접근성 체크 박스 (적절한 링크 텍스트)
Deploy Frontend / deploy (push) Successful in 7m28s
2026-10-11 17:09:37 +09:00
jcchoi a8eb8f9ab1 로고 alt 작업
Deploy Frontend / deploy (push) Successful in 7m43s
2026-10-11 16:16:14 +09:00
jcchoi 6471f4365a 접근성 2026-10-11 16:14:08 +09:00
jcchoi 48999f5699 웹 접근성 조치
Deploy Frontend / deploy (push) Successful in 9m6s
2026-10-11 15:32:56 +09:00
jcchoi 5ad64a596b 담당자 정보 caption 수정 2026-10-11 14:27:59 +09:00
jcchoi df60bd0b40 Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend
Deploy Frontend / deploy (push) Successful in 7m13s
2026-10-11 13:27:16 +09:00
jcchoi 41e6f1cf48 Table Cpation 처리 2026-10-11 13:16:56 +09:00
jcchoi 0802ba191a DataTable Caption 추가 2026-10-11 12:58:04 +09:00
mind-one005 ad57ed241d Merge branch 'iipbs/sn/dev' 2026-10-08 13:29:37 +09:00
jcchoi f17cb420e3 LNB GNB 탭 포커스 2026-10-07 21:42:43 +09:00
jcchoi c2396f093b 필수입력 안내 메세지
Deploy Frontend / deploy (push) Successful in 8m31s
2026-10-07 19:43:06 +09:00
jcchoi bdf65dcfd1 설치인가 사업구분 팝업 사이즈 조정 2026-10-07 19:42:42 +09:00
jcchoi 2aefbe17ee 웹접근성 2차 수정 2026-10-07 19:42:20 +09:00
mind-one005 5e43d69d8e sn: null 인경우, 공백으로 보이도록 2026-10-07 15:05:40 +09:00
jcchoi 4482b92ccd 메뉴간 탭이동
Deploy Frontend / deploy (push) Successful in 7m33s
2026-10-06 17:41:39 +09:00
jcchoi d3c39caefb 웹 접근성 조치 2026-10-06 14:32:26 +09:00
jcchoi 24c5dce24d 대체 텍스트 적용 2026-10-06 13:18:58 +09:00
mind-one005 9971e512c8 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-10-06 13:15:48 +09:00
mind-one005 0c77ed9bba sn: 도시침수예방사업 사업명 필수 2026-10-06 13:15:32 +09:00
kiminchan e759c96ade Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-10-06 13:06:20 +09:00
kiminchan f602b75268 페이지별 도움말 조회를 위한 메뉴 코드 전달 누락 수정 2026-10-06 13:06:01 +09:00
jcchoi d4d9eaf773 CM 로그인
Deploy Frontend / deploy (push) Successful in 5m32s
하단 메뉴 링크
2026-10-06 11:13:46 +09:00
jcchoi 68a800cdfc 메인페이지 탭이동
Deploy Frontend / deploy (push) Successful in 2m54s
2026-10-02 21:52:10 +09:00
jcchoi ff45d1a875 스킵 네비게이션 2026-10-02 21:51:56 +09:00
mind-one005 11d0378060 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-10-02 21:26:43 +09:00
jcchoi 49000c6140 FAQ 웹접근성(툴팁) 2026-10-02 21:25:44 +09:00
jcchoi 24bba484ca Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 3m49s
2026-10-02 19:43:52 +09:00
jcchoi c8d0d85937 Merge branch 'deploy' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into deploy 2026-10-02 19:42:49 +09:00
mind-one005 8ed4f959cb sn: 확률강우량 모달 추가 2026-10-02 18:12:03 +09:00
mind-one005 e5f7778861 sn: 확률강우량 모달 추가 2026-10-02 18:07:00 +09:00
chanii8 ad1e517472 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 3m56s
2026-10-02 17:55:30 +09:00
chanii8 17ed5eb46b Merge branch 'iipbs/as/dev' 2026-10-02 17:54:53 +09:00
chanii8 1a2faf02da [as] feat: 알림톡발송 페이지 구현 2026-10-02 17:54:25 +09:00
kiminchan 2d086a05b2 조사사업·준설 등록 필수 표시 및 숫자 입력 제한 보완 2026-10-02 17:11:04 +09:00
kiminchan 6ef4603a7c 지도 대상 선택 이동 시 지역·시설 검색조건 초기화 2026-10-02 16:51:47 +09:00
chanii8 d6522338aa [as] chore: 행정지원 숫자 폼 최대자릿수 제한 2026-10-02 16:37:26 +09:00
chanii8 df154a45b6 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 4m3s
2026-10-02 15:16:46 +09:00
chanii8 d438755616 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 7m37s
2026-10-02 13:12:44 +09:00
chanii8 4ee8d5a099 Merge branch 'deploy' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into deploy 2026-10-02 13:05:57 +09:00
jcchoi 538f656640 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Canceled after 6m27s
2026-10-01 22:25:22 +09:00
chanii8 8df79fd624 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 3m47s
2026-09-29 14:40:09 +09:00
chanii8 730be25948 Merge remote-tracking branch 'origin/main' into deploy
Deploy Frontend / deploy (push) Successful in 5m4s
2026-09-28 17:17:08 +09:00
chanii8 9c38165afb Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 7m25s
2026-09-28 11:24:12 +09:00
chanii8 9242e1fb47 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 12m36s
2026-09-23 14:44:23 +09:00
chanii8 271b5d445d [as] chore: 재원협의 버튼 수정
Deploy Frontend / deploy (push) Successful in 3m57s
2026-09-23 11:12:26 +09:00
chanii8 2457d14ae9 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 6m32s
2026-09-23 09:51:39 +09:00
chanii8 9739609de4 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 4m15s
2026-09-21 17:51:47 +09:00
chanii8 c799c199c5 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 8m28s
2026-09-21 16:58:49 +09:00
chanii8 5b3aaa463d Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 3m27s
2026-09-21 11:03:48 +09:00
chanii8 947dba5934 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 3m43s
2026-09-18 17:38:37 +09:00
chanii8 4816a512bf Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 3m40s
2026-09-17 17:08:09 +09:00
chanii8 4fad163b14 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 4m4s
2026-09-14 17:56:23 +09:00
chanii8 da2191f365 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 3m43s
2026-09-11 10:38:12 +09:00
chanii8 55ab2b8562 Merge remote-tracking branch 'origin/main' into deploy
Deploy Frontend / deploy (push) Successful in 3m33s
2026-09-10 17:53:02 +09:00
chanii8 526392c370 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 3m39s
2026-09-10 15:44:08 +09:00
chanii8 6020c80a7a Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 10m6s
2026-09-09 18:09:33 +09:00
chanii8 32b778f961 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 3m47s
2026-09-08 17:14:51 +09:00
chanii8 3946fda013 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 4m42s
2026-09-07 17:27:45 +09:00
chanii8 db30b45267 Merge branch 'deploy' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into deploy 2026-09-07 17:27:38 +09:00
jcchoi 6cc2a02df4 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 1m9s
2026-09-03 18:31:48 +09:00
chanii8 0f107ff29d Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 6m16s
2026-09-02 16:32:42 +09:00
chanii8 5e69a9b6f0 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 3m44s
2026-09-01 17:20:52 +09:00
chanii8 ec9311c76b Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 3m36s
2026-08-31 17:23:27 +09:00
chanii8 4c85a7d249 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 3m37s
2026-08-28 17:45:20 +09:00
chanii8 09d4c2d26e Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 4m6s
2026-08-28 17:13:45 +09:00
chanii8 b8dac5dc72 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 3m47s
2026-08-28 17:08:08 +09:00
chanii8 73cafc9786 Merge remote-tracking branch 'origin/main' into deploy
Deploy Frontend / deploy (push) Successful in 3m40s
2026-08-28 14:34:02 +09:00
chanii8 c276ae0c5e Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 4m2s
2026-08-27 17:25:06 +09:00
chanii8 b4ed061a53 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 3m40s
2026-08-27 11:36:49 +09:00
chanii8 ddaaa8fc92 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 3m55s
2026-08-26 17:14:14 +09:00
chanii8 3a253df58a Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 7m10s
2026-08-25 17:11:23 +09:00
chanii8 21debe2f77 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 3m22s
2026-08-25 15:45:10 +09:00
chanii8 0e4f662dfd Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 4m19s
2026-08-25 10:34:34 +09:00
chanii8 4a797808c1 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 4m1s
2026-08-24 17:18:45 +09:00
chanii8 3a81b822f2 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 3m54s
2026-08-21 17:29:09 +09:00
chanii8 f5a7efb324 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 4m2s
2026-08-20 13:12:39 +09:00
chanii8 abc3ca184d Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 4m49s
2026-08-18 18:03:01 +09:00
chanii8 267c022d58 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 3m36s
2026-08-13 17:52:39 +09:00
chanii8 0fc2844928 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 3m56s
2026-08-13 17:22:37 +09:00
chanii8 7f1fe42ace Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into deploy
Deploy Frontend / deploy (push) Successful in 3m36s
2026-08-11 17:46:50 +09:00
chanii8 c83a13d680 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 4m7s
2026-08-10 18:06:03 +09:00
chanii8 3d7dcb6d0e Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 4m17s
2026-08-07 17:03:11 +09:00
chanii8 a46f24d6d7 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 3m44s
2026-08-06 17:12:47 +09:00
chanii8 2e4f2428f0 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 4m41s
2026-08-06 16:50:33 +09:00
chanii8 706cebfeae Merge branch 'main' into deploy 2026-08-06 16:22:09 +09:00
chanii8 ee7f5e3a07 Merge branch 'main' into deploy 2026-08-06 13:42:33 +09:00
chanii8 3d0160f21f Merge branch 'main' into deploy 2026-08-05 17:23:37 +09:00
chanii8 0aa0126caf Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 5m43s
2026-08-03 17:47:39 +09:00
chanii8 5ecdd37187 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 3m52s
2026-08-03 17:28:33 +09:00
chanii8 20cc2ca248 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 3m48s
2026-08-03 11:45:43 +09:00
chanii8 5cfd7d65ec Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 7m45s
2026-07-31 18:00:11 +09:00
chanii8 3b8307b674 Merge branch 'main' into deploy 2026-07-30 11:37:16 +09:00
chanii8 2f800c960d Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 10m24s
2026-07-24 16:59:38 +09:00
chanii8 2728b4058b Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 4m8s
2026-07-24 10:57:06 +09:00
chanii8 b842905572 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 4m28s
2026-07-23 11:44:35 +09:00
chanii8 85fa9a8658 Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into deploy
Deploy Frontend / deploy (push) Successful in 10m37s
2026-07-22 14:42:42 +09:00
chanii8 5ae56481c2 Merge remote-tracking branch 'origin/main' into deploy
Deploy Frontend / deploy (push) Successful in 7m23s
2026-07-22 11:44:50 +09:00
chanii8 8079d7d6af Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 7m20s
2026-07-21 11:51:55 +09:00
chanii8 7c8eef1715 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 6m51s
2026-07-20 18:44:05 +09:00
chanii8 f10b3179c2 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 10m25s
2026-07-20 17:11:46 +09:00
chanii8 2e0bb5aa84 Merge branch 'main' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into deploy
Deploy Frontend / deploy (push) Successful in 10m10s
2026-07-20 13:52:10 +09:00
chanii8 7ae8d42731 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 9m2s
2026-07-16 18:39:59 +09:00
chanii8 a9e65af77b Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 3m25s
2026-07-13 11:14:34 +09:00
chanii8 d21ecc975f Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 2m14s
2026-07-09 17:54:09 +09:00
chanii8 452509281a Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 5m8s
2026-07-09 17:40:25 +09:00
chanii8 2e3602eee6 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 11m12s
2026-07-08 18:55:17 +09:00
jcchoi 7dfd535841 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 2m54s
2026-07-06 16:54:57 +09:00
jcchoi 39d928b5b1 Merge remote-tracking branch 'origin/iipbs/st/dev' into deploy 2026-07-06 16:51:29 +09:00
jcchoi 525695072a Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 2m56s
2026-07-05 11:07:27 +09:00
jcchoi 972a4ce789 Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 5m30s
2026-07-02 18:34:53 +09:00
jcchoi 879aadc068 Merge remote-tracking branch 'origin/iipbs/st/dev' into deploy
Deploy Frontend / deploy (push) Successful in 3m2s
2026-07-02 18:29:55 +09:00
505 changed files with 12975 additions and 9540 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ import { Providers } from "@/app/providers";
import "@/app/styles/globals.css";
export const metadata: Metadata = {
title: "행정지원시스템",
title: "유역하수도통합플랫폼 Hasuon",
description: "행정지원시스템",
};
+1 -1
View File
@@ -1,7 +1,7 @@
import "@/app/styles/globals.css";
export const metadata = {
title: "디지털 행정지원 시스템",
title: "유역하수도통합플랫폼 Hasuon",
};
export default function AuthLayout({ children }: { children: React.ReactNode }) {
@@ -0,0 +1,3 @@
import { NtitlkSndngPage } from "@/pages/ntitlk-sndng";
export default NtitlkSndngPage;
+1 -1
View File
@@ -6,7 +6,7 @@ import { Providers } from "@/app/providers";
import "@/app/styles/globals.css";
export const metadata: Metadata = {
title: "디지털 행정지원 시스템",
title: "유역하수도통합플랫폼 Hasuon",
description: "디지털 행정지원 시스템",
};
+1 -1
View File
@@ -4,7 +4,7 @@ import Image from "next/image";
import "@/app/styles/globals.css";
export const metadata: Metadata = {
title: "디지털 행정지원 시스템",
title: "유역하수도통합플랫폼 Hasuon",
description: "페이지가 존재하지 않습니다. 관리자에게 문의하여 주십시오.",
};
@@ -33,10 +33,10 @@
├──────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ [영역 B] 필수첨부파일 목록 │
│ ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ 필수첨부파일아이디 | 문서구분 | 필수첨부파일명 | 필수첨부여부 | 비고 | 등록일시 | 수정일시 | 관리 │
│ │ 필수첨부파일아이디 | 문서구분 | 필수첨부파일명 | 필수첨부여부 | 필수파일검토내용 | 비고 | 관리 │ │
│ ├────────────────────────────────────────────────────────────────────────────────────────────────┤ │
│ │ EAF-0001 | 설치인가 | 사업계획서 | 예 | 서명본 첨부 | 2026-04-22 10:10:00 | — | [수정][삭제] │ │
│ │ EAF-0002 | 기본계획 | 변경사유서 | 예 | — | 2026-04-22 11:00:00 | — | [수정][삭제] │ │
│ │ EAF-0001 | 설치인가 | 사업계획서 | 예 | 사업비 내역 검토 | 서명본 첨부 | [수정][삭제] │ │
│ │ EAF-0002 | 기본계획 | 변경사유서 | 예 | 변경 전후 비교표 | — | [수정][삭제] │ │
│ └────────────────────────────────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────────────────────────┘
@@ -47,7 +47,9 @@
│ 필수첨부파일아이디(자동발급) [EAF-0004] │
│ 문서구분 [설치인가 ▼] │
│ 필수첨부파일명 [예: 사업계획서] │
│ 양식파일 [파일 선택] │
│ 필수첨부여부 [ ] 예 [ ] 아니오 │
│ 필수파일검토내용 [검토내용을 입력하세요]│
│ 비고 [비고를 입력하세요] │
├──────────────────────────────────────────────────────┤
│ [취소] [저장] │
@@ -139,16 +141,16 @@ END
### 5-2. 그리드 컬럼
| 컬럼명 | Field | 타입 | 비고 |
| ------------------ | ------------------- | ------ | ------------------------------------- |
| 필수첨부파일아이디 | `esntlAtchFileId` | string | 예: `EAF-0001`, 클라이언트 생성/입력 |
| 문서구분 | `docSeComCd` | string | 코드명 표시 — 공통코드 (TBD 상위코드) |
| 필수첨부파일명 | `esntlAtchFileNm` | string | |
| 필수첨부여부 | `esntlAtchYn` | string | `Y`→예, `N`→아니오 |
| 비고 | `esntlAtchFileRmrk` | string | 없으면 `—` 표시 |
| 등록일시 | `regDt` | string | `yyyy-MM-dd HH:mm:ss` |
| 수정일시 | `mdfcnDt` | string | 미수정 시 빈 값 또는 `—` |
| 관리 | — | — | 행별 **[수정] · [삭제]** |
| 컬럼명 | Field | 타입 | 비고 |
| ------------------ | -------------------- | ------ | ------------------------------------- |
| 필수첨부파일아이디 | `esntlAtchFileId` | string | 예: `EAF-0001`, 클라이언트 생성/입력 |
| 문서구분 | `docSeComCd` | string | 코드명 표시 — 공통코드 (TBD 상위코드) |
| 필수첨부파일명 | `esntlAtchFileNm` | string | |
| 필수첨부여부 | `esntlAtchYn` | string | `Y`→예, `N`→아니오 |
| 필수파일검토내용 | `esntlAtchFileRvwCn` | string | 없으면 `—` 표시, 최대 2줄 clamp |
| 비고 | `esntlAtchFileRmrk` | string | 없으면 `—` 표시 |
| 양식 내려받기 | `atchFileGroupId` | string | 양식파일 다운로드 버튼 |
| 관리 | — | — | 행별 **[수정] · [삭제]** |
- 행 클릭 시 별도 상세 화면 없음. 편집은 **[수정]** 버튼으로 다이얼로그 C를 연다.
@@ -167,13 +169,15 @@ END
### 6-1. 폼 필드
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
| ------------------ | ---------- | ---- | ------ | ------------------- | -------------------------------- |
| 필수첨부파일아이디 | (Readonly) | Y | — | `esntlAtchFileId` | 등록 시 미노출 |
| 문서구분 | CodeSelect | Y | — | `docSeComCd` | 예: 설치인가, 기본계획 |
| 필수첨부파일명 | Text | Y | — | `esntlAtchFileNm` | placeholder: "예: 사업계획서" |
| 필수첨부여부 | RadioGroup | Y | Y | `esntlAtchYn` | Y/N, UI 표시: 예/아니오 |
| 비고 | Textarea | N | — | `esntlAtchFileRmrk` | placeholder: "비고를 입력하세요" |
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
| ------------------ | ------------------- | ---- | ------ | -------------------- | ---------------------------------- |
| 필수첨부파일아이디 | (Readonly) | Y | — | `esntlAtchFileId` | 등록 시 미노출 |
| 문서구분 | CodeSelect | Y | — | `docSeComCd` | 예: 설치인가, 기본계획 |
| 필수첨부파일명 | Text | Y | — | `esntlAtchFileNm` | placeholder: "예: 사업계획서" |
| 양식파일 | AttachmentFileField | N | — | `atchFileGroupId` | TUS 파일 업로드 |
| 필수첨부여부 | RadioGroup | Y | Y | `esntlAtchYn` | Y/N, UI 표시: 예/아니오 |
| 필수파일검토내용 | Textarea | N | — | `esntlAtchFileRvwCn` | placeholder: "검토내용을 입력하세요"|
| 비고 | Textarea | N | — | `esntlAtchFileRmrk` | placeholder: "비고를 입력하세요" |
### 6-2. 버튼
@@ -210,28 +214,32 @@ END
**응답 DTO**
| Field | 타입 | 설명 |
| ------------------- | ------- | -------------------------------- |
| `esntlAtchFileId` | string | 필수첨부파일아이디 |
| `docSeComCd` | string | 문서구분공통코드 |
| `esntlAtchFileNm` | string | 필수첨부파일명 |
| `esntlAtchYn` | string | 필수첨부여부 (Y/N) |
| `esntlAtchFileRmrk` | string? | 필수첨부파일비고 |
| `rgtr` | string? | 등록자 UUID |
| `rgtrNm` | string? | 등록자명 |
| `mdfr` | string? | 수정자 UUID |
| `regDt` | string? | 등록일시 (`yyyy-MM-dd HH:mm:ss`) |
| `mdfcnDt` | string? | 수정일시 |
| Field | 타입 | 설명 |
| -------------------- | -------------------------- | -------------------------------- |
| `esntlAtchFileId` | string | 필수첨부파일아이디 |
| `docSeComCd` | string | 문서구분공통코드 |
| `esntlAtchFileNm` | string | 필수첨부파일명 |
| `esntlAtchYn` | string | 필수첨부여부 (Y/N) |
| `esntlAtchFileRvwCn` | string? | 필수첨부파일검토내용 |
| `esntlAtchFileRmrk` | string? | 필수첨부파일비고 |
| `atchFileGroupId` | string? | 첨부파일그룹아이디 (양식파일) |
| `atchFileList` | AtflMngClientResponseDto[]?| 첨부파일 목록 |
| `rgtr` | string? | 등록자 UUID |
| `rgtrNm` | string? | 등록자명 |
| `mdfr` | string? | 수정자 UUID |
| `regDt` | string? | 등록일시 (`yyyy-MM-dd HH:mm:ss`) |
| `mdfcnDt` | string? | 수정일시 |
**저장 Request**
| Field | 필수 | 설명 |
| ------------------- | ---- | ------------------ |
| `esntlAtchFileId` | Y | 필수첨부파일아이디 |
| `docSeComCd` | N | 문서구분 |
| `esntlAtchFileNm` | N | 필수첨부파일명 |
| `esntlAtchYn` | N | 필수첨부여부 |
| `esntlAtchFileRmrk` | N | 비고 |
| Field | 필수 | 설명 |
| -------------------- | ---- | -------------------- |
| `docSeComCd` | Y | 문서구분 |
| `esntlAtchFileNm` | N | 필수첨부파일명 |
| `esntlAtchYn` | N | 필수첨부여부 |
| `esntlAtchFileRvwCn` | N | 필수첨부파일검토내용 |
| `esntlAtchFileRmrk` | N | 비고 |
| `atchFileGroupId` | N | 첨부파일그룹아이디 |
**검색 Query**
+1 -1
View File
@@ -3,7 +3,7 @@ import type { NextConfig } from "next";
const subDomain = process.env.NEXT_PUBLIC_SUB_DOMAIN || "http://localhost:13000";
const webhwpCtrlBase = (
process.env.NEXT_PUBLIC_WEBHYP_CTRL_URL || "http://hwp.onasys.co.kr/webhwpctrl"
process.env.NEXT_PUBLIC_WEBHYP_CTRL_URL || "https://hwp.onasys.co.kr/webhwpctrl"
).replace(/\/$/, "");
const webhwpAssetPrefixes = ["js", "hwpsdk", "dist", "commonFrame", "wasm"] as const;
+44 -27
View File
@@ -5,18 +5,6 @@
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>웹한글 기안기 브리지</title>
<script
type="text/javascript"
src="http://hwp.onasys.co.kr/webhwpctrl/js/hwpctrlapp/utils/util.js"
></script>
<script
type="text/javascript"
src="http://hwp.onasys.co.kr/webhwpctrl/js/hwpctrlapp/hwpCtrlApp.js"
></script>
<script
type="text/javascript"
src="http://hwp.onasys.co.kr/webhwpctrl/js/webhwpctrl.js"
></script>
<style>
html,
body,
@@ -39,27 +27,56 @@
<div id="hwpctrl"></div>
<script type="text/javascript">
(function () {
// baseurl: 부모(HangulGiangi)가 NEXT_PUBLIC_WEBHYP_CTRL_URL 값으로 전달.
// 없으면 same-origin 프록시 경로(/webhwpctrl/ → next.config rewrites)를 사용한다.
var params = new URLSearchParams(window.location.search);
var baseUrl = params.get("baseurl") || "http://hwp.onasys.co.kr/webhwpctrl";
var baseUrl = params.get("baseurl") || new URL("./", window.location.href).href;
if (baseUrl.charAt(baseUrl.length - 1) !== "/") {
baseUrl += "/";
}
var HwpCtrl = BuildWebHwpCtrl(
"hwpctrl",
baseUrl,
function () {
try {
window.parent.postMessage({ type: "hangulgiangi-ready" }, "*");
} catch (e) {
console.warn(e);
}
},
2,
);
var scripts = [
"js/hwpctrlapp/utils/util.js",
"js/hwpctrlapp/hwpCtrlApp.js",
"js/webhwpctrl.js",
];
// 부모(HangulGiangi)가 contentWindow.HwpCtrl 로 접근
window.HwpCtrl = HwpCtrl;
function loadScript(index) {
if (index >= scripts.length) {
init();
return;
}
var script = document.createElement("script");
script.type = "text/javascript";
script.src = baseUrl + scripts[index];
script.onload = function () {
loadScript(index + 1);
};
script.onerror = function () {
console.error("웹한글 기안기 스크립트 로드 실패: " + script.src);
};
document.head.appendChild(script);
}
function init() {
var HwpCtrl = BuildWebHwpCtrl(
"hwpctrl",
baseUrl,
function () {
try {
window.parent.postMessage({ type: "hangulgiangi-ready" }, "*");
} catch (e) {
console.warn(e);
}
},
2,
);
// 부모(HangulGiangi)가 contentWindow.HwpCtrl 로 접근
window.HwpCtrl = HwpCtrl;
}
loadScript(0);
})();
</script>
</body>
+1
View File
@@ -36,6 +36,7 @@ export function BaseLayoutWrapper({ children }: BaseLayoutWrapperProps) {
authService={authService}
currentPath={pathname ?? ""}
handleMenuClick={handleMenuClick}
sidebarTriggerPlacement="lnb"
>
<PageLayout>{children}</PageLayout>
</BaseLayout>
+13 -2
View File
@@ -16,9 +16,20 @@ interface CodeSelectProps extends React.ComponentPropsWithoutRef<typeof Select>
useYn?: string;
placeholder?: string;
className?: string;
/** 레이블(label htmlFor) 연결용 트리거 id */
id?: string;
title?: string;
}
function CodeSelect({ upCd, useYn, placeholder = "선택", className, ...props }: CodeSelectProps) {
function CodeSelect({
upCd,
useYn,
placeholder = "선택",
className,
id,
title,
...props
}: CodeSelectProps) {
const listQuery = useCodeList(useYn ? undefined : upCd);
const listByUseYnQuery = useCodeListByUseYn(useYn ? upCd : undefined, useYn ?? "");
@@ -27,7 +38,7 @@ function CodeSelect({ upCd, useYn, placeholder = "선택", className, ...props }
return (
<Select {...props} disabled={props.disabled || isLoading}>
<SelectTrigger className={cn("w-[12rem]", className)}>
<SelectTrigger id={id} title={title} className={cn("w-[12rem]", className)}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
@@ -5,6 +5,7 @@ import type {
DashboardMainResponseDto,
DashboardMainSearchDto,
DashboardService,
InPrgrsDocPicResponseDto,
} from "../model/dashboard-interface";
const BASE = ENDPOINT.AS_SERVICE.DASHBOARD;
@@ -18,6 +19,14 @@ async function getMainDashboard(
return response.data;
}
async function getInPrgrsDocPicList(searchYear?: number): Promise<InPrgrsDocPicResponseDto[]> {
const response = await client.get<InPrgrsDocPicResponseDto[]>(`${BASE}/in-progress-docs`, {
params: { searchYear },
});
return response.data;
}
export const dashboardService = {
getMainDashboard,
getInPrgrsDocPicList,
} satisfies DashboardService;
+15 -1
View File
@@ -2,7 +2,10 @@ import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import type { DashboardMainSearchDto } from "../model/dashboard-interface";
import type {
DashboardMainSearchDto,
InPrgrsDocPicResponseDto,
} from "../model/dashboard-interface";
import { dashboardService } from "./dashboard-service";
@@ -16,3 +19,14 @@ export function useGetMainDashboard(params: DashboardMainSearchDto = EMPTY_SEARC
queryFn: () => dashboardService.getMainDashboard(params),
});
}
export function useGetInPrgrsDocPicList<TData = InPrgrsDocPicResponseDto[]>(
searchYear?: number,
options?: { select?: (data: InPrgrsDocPicResponseDto[]) => TData },
) {
return useQuery({
queryKey: [...dashboardBaseKey, "getInPrgrsDocPicList", searchYear],
queryFn: () => dashboardService.getInPrgrsDocPicList(searchYear),
select: options?.select,
});
}
+2 -1
View File
@@ -1,11 +1,12 @@
export { dashboardService } from "./api/dashboard-service";
export { dashboardBaseKey, useGetMainDashboard } from "./api/queries";
export { dashboardBaseKey, useGetInPrgrsDocPicList, useGetMainDashboard } from "./api/queries";
export type {
BasinDocPrgrsResponseDto,
DashboardMainResponseDto,
DashboardMainSearchDto,
DashboardService,
DocPrgrsSttsResponseDto,
InPrgrsDocPicResponseDto,
NotiAsStatResponseDto,
NotiAsUserResponseDto,
} from "./model/dashboard-interface";
@@ -65,7 +65,22 @@ export interface DashboardMainResponseDto {
notiStats: NotiAsStatResponseDto;
}
/** 백엔드 InPrgrsDocPicResponseDto */
export interface InPrgrsDocPicResponseDto {
docNo: string;
docNm?: string;
docSeComCd: string;
docSeNm?: string;
aplcntNm?: string;
aplcntMblTelno?: string;
prgrsSttsComCd?: string;
prgrsSttsNm?: string;
regDt?: string;
prcsDdln?: string;
}
/** Controller public 메서드명·시그니처와 동일 */
export interface DashboardService {
getMainDashboard(searchDto: DashboardMainSearchDto): Promise<DashboardMainResponseDto>;
getInPrgrsDocPicList(searchYear?: number): Promise<InPrgrsDocPicResponseDto[]>;
}
+5 -1
View File
@@ -13,12 +13,16 @@ import type {
NowDmgMtncGnsvySearchDto,
} from "../model/dc-interface";
/** 빈 문자열 파라미터를 보내면 서버 오류가 나므로 값이 있는 항목만 전송한다. */
async function getNowDmgMtncGnsvy(
params: NowDmgMtncGnsvySearchDto,
): Promise<NowDmgMtncGnsvyResponseDto[]> {
const filledParams = Object.fromEntries(
Object.entries(params).filter(([, value]) => value !== undefined && value !== ""),
);
const response = await client.get<NowDmgMtncGnsvyResponseDto[]>(
`${ENDPOINT.DC_SERVICE}/nowDmgMtncGnsvy`,
{ params },
{ params: filledParams },
);
return response.data;
}
@@ -76,7 +76,7 @@ function AprvrPickerDialog({ open, onOpenChange, atrzSeComCd, onSelect }: AprvrP
배치된 검토인력이 없습니다.
</p>
) : (
<Table className="w-full">
<Table caption="검토인력 선택 목록으로 성명, 부서, 직위, 직급, 선택 정보 제공" className="w-full">
<colgroup>
<col width="25%" />
<col width="25%" />
@@ -162,7 +162,7 @@ function ElaprAplnPickerGrid({
</p>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<Table className="w-full">
<Table caption="결재선 관리 목록으로 결재순서, 결재구분, 결재자, 관리 정보 제공" className="w-full">
<colgroup>
<col width="10%" />
<col width="10%" />
@@ -315,7 +315,7 @@ function ElaprApprovalDialog({
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<Table className="w-full">
<Table caption="결재 진행 단계 목록으로 결재순서, 결재구분, 결재자, 전결자 지정, 결재상태, 관리 정보 제공" className="w-full">
<colgroup>
<col width="8%" />
<col width="8%" />
@@ -66,6 +66,7 @@ function ElaprTmpltSelectTable({
getCoreRowModel: getCoreRowModel(),
},
dataTableOptions: {
caption: "전자결재서식 목록으로 결재서식명 정보 제공",
onRowClick: (row) => {
setRowSelection({ [row.elaprTmpltId]: true });
onSelectTmplt(row);
@@ -19,6 +19,8 @@ export interface EsntlAtchFileMng {
esntlAtchYn: "Y" | "N";
/** 필수첨부파일비고 */
esntlAtchFileRmrk?: string;
/** 필수첨부파일검토내용 */
esntlAtchFileRvwCn?: string;
/** 첨부파일그룹아이디 (양식파일 그룹) */
atchFileGroupId?: string;
/** 첨부파일 목록 (양식파일) */
@@ -77,6 +79,8 @@ export interface EsntlAtchFileMngSaveRequest {
esntlAtchYn?: "Y" | "N";
/** 필수첨부파일비고 */
esntlAtchFileRmrk?: string;
/** 필수첨부파일검토내용 */
esntlAtchFileRvwCn?: string;
/** 첨부파일그룹아이디 (양식파일 그룹) */
atchFileGroupId?: string;
}
@@ -0,0 +1,22 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { RvwOpnnUpdateRequestDto } from "@/shared/model";
const BASE = ENDPOINT.AS_SERVICE.FNRS_CNSLTN_RVW_OPNN;
async function update(
fnrsCnsltnDocNo: string,
rvwOpnnSn: number,
body: RvwOpnnUpdateRequestDto,
): Promise<void> {
await client.put<void>(`${BASE}/${fnrsCnsltnDocNo}/${rvwOpnnSn}`, body);
}
async function deleteFn(fnrsCnsltnDocNo: string, rvwOpnnSn: number): Promise<void> {
await client.delete<void>(`${BASE}/${fnrsCnsltnDocNo}/${rvwOpnnSn}`);
}
export const fnrsCnsltnRvwOpnnService = {
update,
delete: deleteFn,
};
+66 -65
View File
@@ -1,65 +1,66 @@
export { fnrsCnsltnMngService } from "./api/fnrs-cnsltn-service";
export { fnrsCnsltnKeys } from "./api/queries";
export {
useChangePrgrsSttsComCd,
useDeleteFnrsCnsltnMng,
useFnrsCnsltnFileVrfcResults,
useFnrsCnsltnMng,
useFnrsCnsltnMngPage,
useRequestActnReviewFnrsCnsltnMng,
useRequestExamineFnrsCnsltnMng,
useSaveApplyFnrsCnsltnMng,
useSaveFnrsCnsltnMng,
useSubmitReqSupplementFnrsCnsltnMng,
useSubmitSupplementFnrsCnsltnMng,
useTechReviewFnrsCnsltnMng,
useUpdateFnrsCnsltnMng,
} from "./api/queries";
export type {
BizCstItem,
BzScaleItem,
CklstResultItem,
EsntlAtchFileItem,
FnrsCnsltnApplySaveRequestDto,
FnrsCnsltnMngSaveRequestDto,
FnrsCnsltnRvwOpnnSaveRequestDto,
FnrsCnsltnSplmntDmndItem,
FnrsSharItem,
} from "./model/fnrs-cnsltn-apply-payload-schema";
export {
fnrsCnsltnApplySaveSchema,
fnrsCnsltnBizCstItemSchema,
fnrsCnsltnBzScaleItemSchema,
fnrsCnsltnCklstResultItemSchema,
fnrsCnsltnEsntlAtchFileItemSchema,
fnrsCnsltnMngSaveSchema,
fnrsCnsltnRvwOpnnSaveSchema,
fnrsCnsltnSplmntDmndItemSchema,
fnrsSharItemSchema,
} from "./model/fnrs-cnsltn-apply-payload-schema";
export type {
AtflMngClientResponseDto,
BizCstTreeDto,
BizSclTreeDto,
CklstRvwRsltTreeDto,
DocPrgrsUpdateRequestDto,
ElaprDocPrgrsClientResponseDto,
EsntlAtchFileMngTreeResponseDto,
FnrsCnsltnFileVrfcRsltResponseDto,
FnrsCnsltnMngDetResponseDto,
FnrsCnsltnMngResponseDto,
FnrsCnsltnMngSearchDto,
FnrsCnsltnMngService,
FnrsCnsltnMngUpdateRequestDto,
FnrsCnsltnRvwOpnnResponseDto,
FnrsCnsltnSplmntDmndResponseDto,
RagIndexingInfoDto,
} from "./model/fnrs-cnsltn-interface";
export type {
EnvoActnReviewReqDto,
FnrsExamineRequestDto,
} from "./model/fnrs-cnsltn-payload-schema";
export {
envoActnReviewReqSchema,
fnrsExamineRequestSchema,
} from "./model/fnrs-cnsltn-payload-schema";
export { fnrsCnsltnRvwOpnnService } from "./api/fnrs-cnsltn-rvw-opnn-service";
export { fnrsCnsltnMngService } from "./api/fnrs-cnsltn-service";
export { fnrsCnsltnKeys } from "./api/queries";
export {
useChangePrgrsSttsComCd,
useDeleteFnrsCnsltnMng,
useFnrsCnsltnFileVrfcResults,
useFnrsCnsltnMng,
useFnrsCnsltnMngPage,
useRequestActnReviewFnrsCnsltnMng,
useRequestExamineFnrsCnsltnMng,
useSaveApplyFnrsCnsltnMng,
useSaveFnrsCnsltnMng,
useSubmitReqSupplementFnrsCnsltnMng,
useSubmitSupplementFnrsCnsltnMng,
useTechReviewFnrsCnsltnMng,
useUpdateFnrsCnsltnMng,
} from "./api/queries";
export type {
BizCstItem,
BzScaleItem,
CklstResultItem,
EsntlAtchFileItem,
FnrsCnsltnApplySaveRequestDto,
FnrsCnsltnMngSaveRequestDto,
FnrsCnsltnRvwOpnnSaveRequestDto,
FnrsCnsltnSplmntDmndItem,
FnrsSharItem,
} from "./model/fnrs-cnsltn-apply-payload-schema";
export {
fnrsCnsltnApplySaveSchema,
fnrsCnsltnBizCstItemSchema,
fnrsCnsltnBzScaleItemSchema,
fnrsCnsltnCklstResultItemSchema,
fnrsCnsltnEsntlAtchFileItemSchema,
fnrsCnsltnMngSaveSchema,
fnrsCnsltnRvwOpnnSaveSchema,
fnrsCnsltnSplmntDmndItemSchema,
fnrsSharItemSchema,
} from "./model/fnrs-cnsltn-apply-payload-schema";
export type {
AtflMngClientResponseDto,
BizCstTreeDto,
BizSclTreeDto,
CklstRvwRsltTreeDto,
DocPrgrsUpdateRequestDto,
ElaprDocPrgrsClientResponseDto,
EsntlAtchFileMngTreeResponseDto,
FnrsCnsltnFileVrfcRsltResponseDto,
FnrsCnsltnMngDetResponseDto,
FnrsCnsltnMngResponseDto,
FnrsCnsltnMngSearchDto,
FnrsCnsltnMngService,
FnrsCnsltnMngUpdateRequestDto,
FnrsCnsltnRvwOpnnResponseDto,
FnrsCnsltnSplmntDmndResponseDto,
RagIndexingInfoDto,
} from "./model/fnrs-cnsltn-interface";
export type {
EnvoActnReviewReqDto,
FnrsExamineRequestDto,
} from "./model/fnrs-cnsltn-payload-schema";
export {
envoActnReviewReqSchema,
fnrsExamineRequestSchema,
} from "./model/fnrs-cnsltn-payload-schema";
@@ -0,0 +1,22 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { RvwOpnnUpdateRequestDto } from "@/shared/model";
const BASE = ENDPOINT.AS_SERVICE.INSTL_SNCN_RVW_OPNN;
async function update(
instlSncnDocNo: string,
rvwOpnnSn: number,
body: RvwOpnnUpdateRequestDto,
): Promise<void> {
await client.put<void>(`${BASE}/${instlSncnDocNo}/${rvwOpnnSn}`, body);
}
async function deleteFn(instlSncnDocNo: string, rvwOpnnSn: number): Promise<void> {
await client.delete<void>(`${BASE}/${instlSncnDocNo}/${rvwOpnnSn}`);
}
export const instlSncnRvwOpnnService = {
update,
delete: deleteFn,
};
+1
View File
@@ -1,3 +1,4 @@
export { instlSncnRvwOpnnService } from "./api/instl-sncn-rvw-opnn-service";
export { instlSncnRegionService, instlSncnService } from "./api/instl-sncn-service";
export {
instlSncnKeys,
@@ -0,0 +1,39 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageResponse } from "@/shared/model";
import type {
BusinessSendRequest,
BusinessSendResponse,
NtftalkService,
NtftalkTemplate,
NtftalkTemplateSearchDto,
TemplateVariablesResponse,
} from "../model/ntftalk-interface";
const { TEMP, ATA_SEND } = ENDPOINT.NTFTALK_SERVICE;
async function findTemplatePage(
params: NtftalkTemplateSearchDto,
): Promise<PageResponse<NtftalkTemplate>> {
const response = await client.get<PageResponse<NtftalkTemplate>>(TEMP, { params });
return response.data;
}
async function findTemplateVariables(templateCode: string): Promise<TemplateVariablesResponse> {
const response = await client.get<TemplateVariablesResponse>(
`${ATA_SEND}/template-variables/${templateCode}`,
);
return response.data;
}
async function sendBusiness(body: BusinessSendRequest): Promise<BusinessSendResponse> {
const response = await client.post<BusinessSendResponse>(`${ATA_SEND}/business`, body);
return response.data;
}
export const ntftalkService = {
findTemplatePage,
findTemplateVariables,
sendBusiness,
} satisfies NtftalkService;
@@ -0,0 +1,39 @@
import { useQuery } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import { useMutationWrapper } from "@/shared/lib";
import type { BusinessSendRequest, NtftalkTemplateSearchDto } from "../model/ntftalk-interface";
import { ntftalkService } from "./ntftalk-service";
/** 발송 화면에서 선택 가능한 템플릿: 검수 승인(APR)된 기본형(BA) */
const SNDNG_TEMPLATE_SEARCH: NtftalkTemplateSearchDto = {
sort: "createdAt,desc",
senderKey: "",
inspectionStatus: "APR",
templateMessageType: "BA",
page: 0,
size: 10,
};
export function useNtftalkTemplateList() {
return useQuery({
queryKey: [ENDPOINT.NTFTALK_SERVICE.TEMP, "findTemplatePage", SNDNG_TEMPLATE_SEARCH],
queryFn: () => ntftalkService.findTemplatePage(SNDNG_TEMPLATE_SEARCH),
});
}
export function useNtftalkTemplateVariables(templateCode: string) {
return useQuery({
queryKey: [ENDPOINT.NTFTALK_SERVICE.ATA_SEND, "findTemplateVariables", templateCode],
queryFn: () => ntftalkService.findTemplateVariables(templateCode),
enabled: Boolean(templateCode),
});
}
export function useSendNtftalkBusiness() {
return useMutationWrapper({
mutationFn: (body: BusinessSendRequest) => ntftalkService.sendBusiness(body),
});
}
+14
View File
@@ -0,0 +1,14 @@
export { ntftalkService } from "./api/ntftalk-service";
export {
useNtftalkTemplateList,
useNtftalkTemplateVariables,
useSendNtftalkBusiness,
} from "./api/queries";
export type {
BusinessSendRequest,
BusinessSendResponse,
NtftalkService,
NtftalkTemplate,
NtftalkTemplateSearchDto,
TemplateVariablesResponse,
} from "./model/ntftalk-interface";
@@ -0,0 +1,41 @@
import type { PageableParams, PageResponse } from "@/shared/model";
/** 백엔드 알림톡 템플릿 응답 */
export interface NtftalkTemplate {
templateCode: string;
templateName: string;
templateContent: string;
}
/** 템플릿 목록 검색 조건 + Pageable */
export interface NtftalkTemplateSearchDto extends PageableParams {
senderKey?: string;
inspectionStatus?: string;
templateMessageType?: string;
}
export interface TemplateVariablesResponse {
templateCode: string;
variables: string[];
count: number;
}
export interface BusinessSendRequest {
templateCode: string;
callback?: string;
fixedRecipientNum?: string;
recipients: Record<string, string>[];
}
export interface BusinessSendResponse {
success: boolean;
sentCount: number;
message: string;
}
/** Controller public 메서드명·시그니처와 동일 */
export interface NtftalkService {
findTemplatePage(params: NtftalkTemplateSearchDto): Promise<PageResponse<NtftalkTemplate>>;
findTemplateVariables(templateCode: string): Promise<TemplateVariablesResponse>;
sendBusiness(body: BusinessSendRequest): Promise<BusinessSendResponse>;
}
+8 -2
View File
@@ -1,9 +1,11 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import type { PageResponse } from "@/shared/model";
import type {
RvwMnpwMngPageSearchDto,
RvwMnpwMngResponseDto,
RvwMnpwMngSaveRequestDto,
RvwMnpwMngSearchDto,
RvwMnpwMngUpdateRequestDto,
@@ -13,14 +15,18 @@ import { rvwMnpwMngService } from "./rvw-mnpw-service";
const rvwMnpwMngBaseKey = [ENDPOINT.AS_SERVICE.RVW_MNPW] as const;
export function useRvwMnpwMngPage(
export function useRvwMnpwMngPage<TData = PageResponse<RvwMnpwMngResponseDto>>(
params: RvwMnpwMngPageSearchDto,
options?: { enabled?: boolean },
options?: {
enabled?: boolean;
select?: (data: PageResponse<RvwMnpwMngResponseDto>) => TData;
},
) {
return useQuery({
queryKey: [...rvwMnpwMngBaseKey, "findPage", params],
queryFn: () => rvwMnpwMngService.findPage(params),
enabled: options?.enabled,
select: options?.select,
});
}
@@ -7,6 +7,7 @@ export interface RvwMnpwMngResponseDto {
ognzSn: number;
userUuid: string;
userUuidNm?: string;
mblTelno?: string;
ognzNm?: string;
jbpsSeNm?: string;
jbgdSeNm?: string;
@@ -0,0 +1,23 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { RvwOpnnUpdateRequestDto } from "@/shared/model";
const BASE = ENDPOINT.AS_SERVICE.SEWER_BSC_PLAN_RVW_OPNNS;
async function update(
sewerBscPlanDocNo: string,
chgCycl: number,
rvwOpnnSn: number,
body: RvwOpnnUpdateRequestDto,
): Promise<void> {
await client.put<void>(`${BASE}/${sewerBscPlanDocNo}/${chgCycl}/${rvwOpnnSn}`, body);
}
async function deleteFn(sewerBscPlanDocNo: string, chgCycl: number, rvwOpnnSn: number): Promise<void> {
await client.delete<void>(`${BASE}/${sewerBscPlanDocNo}/${chgCycl}/${rvwOpnnSn}`);
}
export const sewerBscPlanRvwOpnnService = {
update,
delete: deleteFn,
};
+65 -64
View File
@@ -1,64 +1,65 @@
export { sewerBscPlanKeys } from "./api/queries";
export {
useDeleteSewerBscPlan,
useSaveApplySewerBscPlan,
useSaveSewerBscPlan,
useSewerBscPlan,
useSewerBscPlanDetails,
useSewerBscPlanPage,
useSubmitReqSupplementSewerBscPlan,
useSubmitSupplementSewerBscPlan,
useTechReviewSewerBscPlan,
useUpdateSewerBscPlan,
} from "./api/queries";
export { sewerBscPlanService } from "./api/sewer-bsc-plan-service";
export type {
CklstResultItem,
EsntlAtchFileItem,
SewerBscPlanApplySaveRequestDto,
SewerBscPlanBizCstItem,
SewerBscPlanDtlSaveRequestDto,
SewerBscPlanFnrsCstItem,
SewerBscPlanOtlnItem,
SewerBscPlanRvwOpnnItem,
SewerBscPlanSaveRequestDto,
SewerBscPlanSplmntDmndItem,
SewerBscPlanTmplntDtlItem,
SewerBscPlanTmplntItem,
SewerBscPlanTmplntTmprStrgItem,
} from "./model/sewer-bsc-plan-apply-payload-schema";
export {
sewerBscPlanApplySaveSchema,
sewerBscPlanBizCstItemSchema,
sewerBscPlanCklstResultItemSchema,
sewerBscPlanDtlSaveSchema,
sewerBscPlanEsntlAtchFileItemSchema,
sewerBscPlanFnrsCstItemSchema,
sewerBscPlanOtlnItemSchema,
sewerBscPlanRvwOpnnItemSchema,
sewerBscPlanSaveSchema,
sewerBscPlanSplmntDmndItemSchema,
sewerBscPlanTmplntDtlItemSchema,
sewerBscPlanTmplntItemSchema,
sewerBscPlanTmplntTmprStrgItemSchema,
} from "./model/sewer-bsc-plan-apply-payload-schema";
export type {
AtflMngClientResponseDto,
CklstRvwRsltTreeDto,
EsntlAtchFileMngTreeResponseDto,
SewerBscPlanApplySaveResponseDto,
SewerBscPlanBizCstResponseDto,
SewerBscPlanDetResponseDto,
SewerBscPlanDtlResponseDto,
SewerBscPlanFnrsCstResponseDto,
SewerBscPlanOtlnResponseDto,
SewerBscPlanResponseDto,
SewerBscPlanRvwOpnnResponseDto,
SewerBscPlanSearchRequestDto,
SewerBscPlanService,
SewerBscPlanSplmntDmndResponseDto,
SewerBscPlanTmplntDtlResponseDto,
SewerBscPlanTmplntResponseDto,
SewerBscPlanTmplntTmprStrgResponseDto,
SewerBscPlanUpdateRequestDto,
} from "./model/sewer-bsc-plan-interface";
export { sewerBscPlanKeys } from "./api/queries";
export {
useDeleteSewerBscPlan,
useSaveApplySewerBscPlan,
useSaveSewerBscPlan,
useSewerBscPlan,
useSewerBscPlanDetails,
useSewerBscPlanPage,
useSubmitReqSupplementSewerBscPlan,
useSubmitSupplementSewerBscPlan,
useTechReviewSewerBscPlan,
useUpdateSewerBscPlan,
} from "./api/queries";
export { sewerBscPlanRvwOpnnService } from "./api/sewer-bsc-plan-rvw-opnn-service";
export { sewerBscPlanService } from "./api/sewer-bsc-plan-service";
export type {
CklstResultItem,
EsntlAtchFileItem,
SewerBscPlanApplySaveRequestDto,
SewerBscPlanBizCstItem,
SewerBscPlanDtlSaveRequestDto,
SewerBscPlanFnrsCstItem,
SewerBscPlanOtlnItem,
SewerBscPlanRvwOpnnItem,
SewerBscPlanSaveRequestDto,
SewerBscPlanSplmntDmndItem,
SewerBscPlanTmplntDtlItem,
SewerBscPlanTmplntItem,
SewerBscPlanTmplntTmprStrgItem,
} from "./model/sewer-bsc-plan-apply-payload-schema";
export {
sewerBscPlanApplySaveSchema,
sewerBscPlanBizCstItemSchema,
sewerBscPlanCklstResultItemSchema,
sewerBscPlanDtlSaveSchema,
sewerBscPlanEsntlAtchFileItemSchema,
sewerBscPlanFnrsCstItemSchema,
sewerBscPlanOtlnItemSchema,
sewerBscPlanRvwOpnnItemSchema,
sewerBscPlanSaveSchema,
sewerBscPlanSplmntDmndItemSchema,
sewerBscPlanTmplntDtlItemSchema,
sewerBscPlanTmplntItemSchema,
sewerBscPlanTmplntTmprStrgItemSchema,
} from "./model/sewer-bsc-plan-apply-payload-schema";
export type {
AtflMngClientResponseDto,
CklstRvwRsltTreeDto,
EsntlAtchFileMngTreeResponseDto,
SewerBscPlanApplySaveResponseDto,
SewerBscPlanBizCstResponseDto,
SewerBscPlanDetResponseDto,
SewerBscPlanDtlResponseDto,
SewerBscPlanFnrsCstResponseDto,
SewerBscPlanOtlnResponseDto,
SewerBscPlanResponseDto,
SewerBscPlanRvwOpnnResponseDto,
SewerBscPlanSearchRequestDto,
SewerBscPlanService,
SewerBscPlanSplmntDmndResponseDto,
SewerBscPlanTmplntDtlResponseDto,
SewerBscPlanTmplntResponseDto,
SewerBscPlanTmplntTmprStrgResponseDto,
SewerBscPlanUpdateRequestDto,
} from "./model/sewer-bsc-plan-interface";
@@ -73,7 +73,7 @@ function AprvrPickerDialog({
배치된 검토인력이 없습니다.
</p>
) : (
<Table className="w-full">
<Table caption="검토인력 선택 목록으로 성명, 부서, 직위, 직급, 선택 정보 제공" className="w-full">
<colgroup>
<col width="25%" />
<col width="25%" />
@@ -185,7 +185,7 @@ function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
</p>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<Table className="w-full">
<Table caption="결재선 관리 목록으로 결재순서, 결재구분, 결재자 ID, 결재자, 등록일시, 관리 정보 제공" className="w-full">
<colgroup>
<col width="5%" />
<col width="10%" />
@@ -90,6 +90,7 @@ function ElaprTmpltList({ selectedTmpltId, onSelectTmplt }: ElaprTmpltListProps)
getCoreRowModel: getCoreRowModel(),
},
dataTableOptions: {
caption: "전자결재선 관리 목록으로 결재서식명, 문서구분, 등록일시, 관리 정보 제공",
onRowClick: (row) => {
setRowSelection({ [row.elaprTmpltId]: true });
onSelectTmplt(row);
@@ -84,9 +84,9 @@ function CklstArtclFormDialog({
/>
</div>
<div className="space-y-2">
<Label>사용여부</Label>
<Label htmlFor="cklstArtclUseYn">사용여부</Label>
<Select value={useYn} onValueChange={(v) => setValue("useYn", v as "Y" | "N")}>
<SelectTrigger className="w-full">
<SelectTrigger id="cklstArtclUseYn" className="w-full">
<SelectValue placeholder="사용여부" />
</SelectTrigger>
<SelectContent>
@@ -67,7 +67,12 @@ function CklstArtclList({ cklstSn }: CklstArtclListProps) {
</div>
),
Empty: <NoData />,
Success: () => <DataTable table={table} />,
Success: () => (
<DataTable
table={table}
caption="점검항목 관리 목록으로 점검항목코드, 점검항목명, 사용여부, 등록일시, 관리 정보 제공"
/>
),
});
const handleFormSubmit = async (values: CklstArtclFormValues) => {
@@ -100,8 +100,9 @@ function CklstMngFormDialog({
/>
</div>
<div className="space-y-2">
<Label>문서구분</Label>
<Label htmlFor="cklstDocSeComCd">문서구분</Label>
<CodeSelect
id="cklstDocSeComCd"
upCd="ASDOCSE"
value={docSeComCd}
onValueChange={(v) => setValue("docSeComCd", v)}
@@ -110,8 +111,9 @@ function CklstMngFormDialog({
/>
</div>
<div className="space-y-2">
<Label>업무구분</Label>
<Label htmlFor="cklstTaskSeComCd">업무구분</Label>
<CodeSelect
id="cklstTaskSeComCd"
upCd={docSeComCd || "ASDOCSE"}
value={watch("taskSeComCd")}
onValueChange={(v) => setValue("taskSeComCd", v)}
@@ -121,9 +123,9 @@ function CklstMngFormDialog({
/>
</div>
<div className="space-y-2">
<Label>사용여부</Label>
<Label htmlFor="cklstUseYn">사용여부</Label>
<Select value={useYn} onValueChange={(v) => setValue("useYn", v as "Y" | "N")}>
<SelectTrigger className="w-full">
<SelectTrigger id="cklstUseYn" className="w-full">
<SelectValue placeholder="사용여부" />
</SelectTrigger>
<SelectContent>
@@ -83,6 +83,7 @@ function CklstMngList({ selectedCklstSn, onSelectCklst }: CklstMngListProps) {
getCoreRowModel: getCoreRowModel(),
},
dataTableOptions: {
caption: "점검표 관리 목록으로 점검표일련번호, 점검표명, 문서구분, 업무구분, 사용여부, 등록일시, 관리 정보 제공",
onRowClick: (row) => {
setRowSelection({ [String(row.cklstSn)]: true });
onSelectCklst(row);
@@ -44,65 +44,78 @@ function CklstPreviewDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[64rem]">
<DialogContent className="flex max-h-[90vh] max-w-[64rem] flex-col">
<DialogHeader>
<DialogTitle>체크리스트 미리보기 — {cklstNm}</DialogTitle>
</DialogHeader>
{displayRows.length === 0 ? (
<p className="py-6 text-center text-muted-foreground">표시할 항목이 없습니다.</p>
) : (
<Table>
<colgroup>
<col width="25%" />
<col width="50%" />
<col width="25%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead align="left">점검항목</TableHead>
<TableHead align="left">주요 검토내용</TableHead>
<TableHead>검토 여부</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{displayRows.map(({ row, depth }) => {
const branchPrefix = formatTreeBranchPrefix(depth);
const showRadios = row.bchckUseYn !== "N";
// 항목이 많아 창 높이를 넘으면 표 영역만 스크롤 (초점을 받아 키보드로도 스크롤 가능)
<div
tabIndex={0}
role="region"
aria-label="체크리스트 미리보기 목록"
className="min-h-0 flex-1 overflow-y-auto rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
<Table
caption={`체크리스트 미리보기 ${cklstNm} 목록으로 점검항목, 주요 검토내용, 검토 여부 정보 제공`}
>
<colgroup>
<col width="25%" />
<col width="50%" />
<col width="25%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead align="left">점검항목</TableHead>
<TableHead align="left">주요 검토내용</TableHead>
<TableHead>검토 여부</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{displayRows.map(({ row, depth }) => {
const branchPrefix = formatTreeBranchPrefix(depth);
const showRadios = row.bchckUseYn !== "N";
if (row.useYn === "N") return null;
if (row.useYn === "N") return null;
return (
<TableRow key={row.rowKey}>
<TableCell align="left">
<span className="inline-flex items-center gap-1">
{branchPrefix ? (
<span className="font-mono text-muted">{branchPrefix}</span>
) : null}
<span>{getArtclLabel(row.cklstArtclSn)}</span>
</span>
</TableCell>
<TableCell>{row.rvwCn ?? "—"}</TableCell>
<TableCell align="center">
{showRadios ? (
<RadioGroup className="flex items-center justify-center">
<div className="flex items-center gap-1">
<RadioGroupItem value="Y" id={`${row.rowKey}-preview-y`} />
<Label htmlFor={`${row.rowKey}-preview-y`}>예</Label>
</div>
<div className="flex items-center gap-1">
<RadioGroupItem value="N" id={`${row.rowKey}-preview-n`} />
<Label htmlFor={`${row.rowKey}-preview-n`}>아니오</Label>
</div>
</RadioGroup>
) : (
"—"
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
return (
<TableRow key={row.rowKey}>
<TableCell align="left">
<span className="inline-flex items-center gap-1">
{branchPrefix ? (
<span className="font-mono text-muted">{branchPrefix}</span>
) : null}
<span>{getArtclLabel(row.cklstArtclSn)}</span>
</span>
</TableCell>
<TableCell>{row.rvwCn ?? "—"}</TableCell>
<TableCell align="center">
{showRadios ? (
<RadioGroup
aria-label="검토 여부"
className="flex items-center justify-center"
>
<div className="flex items-center gap-1">
<RadioGroupItem value="Y" id={`${row.rowKey}-preview-y`} />
<Label htmlFor={`${row.rowKey}-preview-y`}>예</Label>
</div>
<div className="flex items-center gap-1">
<RadioGroupItem value="N" id={`${row.rowKey}-preview-n`} />
<Label htmlFor={`${row.rowKey}-preview-n`}>아니오</Label>
</div>
</RadioGroup>
) : (
"—"
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</DialogContent>
</Dialog>
@@ -277,7 +277,7 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) {
등록된 항목이 없습니다. [추가]로 1단계 항목을 등록하세요.
</p>
) : (
<Table className="w-full">
<Table caption="체크리스트 항목 목록으로 점검항목, 주요 검토내용, 사용 여부, 체크 사용, 정렬 순서, 구분, 관리 정보 제공" className="w-full">
<colgroup>
<col width="16%" />
<col width="36%" />
@@ -289,13 +289,13 @@ function CklstRvwGrid({ selectedCklst }: CklstRvwGridProps) {
</colgroup>
<TableHeader>
<TableRow>
<TableHead>점검항목</TableHead>
<TableHead>주요 검토내용</TableHead>
<TableHead>사용 여부</TableHead>
<TableHead>체크 사용</TableHead>
<TableHead>정렬 순서</TableHead>
<TableHead>구분</TableHead>
<TableHead>관리</TableHead>
<TableHead scope="col">점검항목</TableHead>
<TableHead scope="col">주요 검토내용</TableHead>
<TableHead scope="col">사용 여부</TableHead>
<TableHead scope="col">체크 사용</TableHead>
<TableHead scope="col">정렬 순서</TableHead>
<TableHead scope="col">구분</TableHead>
<TableHead scope="col">관리</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -55,7 +55,11 @@ function CklstRvwRowComponent({
onValueChange={(v) => onChange({ cklstArtclSn: Number(v), isDirty: true })}
disabled={!row.isNew}
>
<SelectTrigger className="w-full flex-1">
{/* 비활성(기존 항목) 상태에서도 텍스트 명도 대비 4.5:1 이상 유지 */}
<SelectTrigger
title="점검항목 선택"
className="w-full flex-1 disabled:opacity-100"
>
<SelectValue placeholder="점검항목 선택" />
</SelectTrigger>
<SelectContent>
@@ -73,12 +77,14 @@ function CklstRvwRowComponent({
value={row.rvwCn ?? ""}
onChange={(e) => onChange({ rvwCn: e.target.value, isDirty: true })}
placeholder="주요 검토내용"
title="주요 검토내용"
/>
</TableCell>
<TableCell align="center">
<RadioGroup
value={row.useYn ?? "Y"}
onValueChange={(v) => onChange({ useYn: v as "Y" | "N", isDirty: true })}
aria-label="사용 여부"
className="flex justify-center gap-4"
>
<div className="flex items-center gap-2">
@@ -95,6 +101,7 @@ function CklstRvwRowComponent({
<RadioGroup
value={row.bchckUseYn ?? "Y"}
onValueChange={(v) => onChange({ bchckUseYn: v as "Y" | "N", isDirty: true })}
aria-label="체크 사용"
className="flex justify-center gap-4"
>
<div className="flex items-center gap-2">
@@ -111,6 +118,7 @@ function CklstRvwRowComponent({
<Input
type="number"
min={0}
title="정렬 순서"
value={row.sortSeq ?? ""}
onChange={(e) =>
onChange({
@@ -103,7 +103,7 @@ function AcntgCodeTabPanel() {
</p>
) : (
<div className="max-h-[60rem] overflow-y-auto px-4 pb-4 pt-1">
<Table className="w-full">
<Table caption="회계코드 목록으로 코드, 코드명, 설명, 사용 여부, 정렬순서, 구분, 관리 정보 제공" className="w-full">
<colgroup>
<col width="14%" />
<col width="16%" />
@@ -115,13 +115,13 @@ function AcntgCodeTabPanel() {
</colgroup>
<TableHeader>
<TableRow>
<TableHead>코드</TableHead>
<TableHead>코드명</TableHead>
<TableHead>설명</TableHead>
<TableHead>사용 여부</TableHead>
<TableHead>정렬순서</TableHead>
<TableHead>구분</TableHead>
<TableHead>관리</TableHead>
<TableHead scope="col">코드</TableHead>
<TableHead scope="col">코드명</TableHead>
<TableHead scope="col">설명</TableHead>
<TableHead scope="col">사용 여부</TableHead>
<TableHead scope="col">정렬순서</TableHead>
<TableHead scope="col">구분</TableHead>
<TableHead scope="col">관리</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -40,9 +40,10 @@ function AcntgSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Acn
value={row.acntgSeCd}
onChange={(e) => onChange({ acntgSeCd: e.target.value })}
placeholder="코드"
title="코드"
maxLength={10}
disabled={!row.isNew}
className="flex-1"
className="flex-1 disabled:opacity-100"
/>
</div>
</TableCell>
@@ -51,6 +52,7 @@ function AcntgSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Acn
value={row.acntgSeCdNm ?? ""}
onChange={(e) => onChange({ acntgSeCdNm: e.target.value })}
placeholder="코드명"
title="코드명"
maxLength={100}
/>
</TableCell>
@@ -59,6 +61,7 @@ function AcntgSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Acn
value={row.expln ?? ""}
onChange={(e) => onChange({ expln: e.target.value })}
placeholder="설명"
title="설명"
maxLength={1000}
/>
</TableCell>
@@ -66,6 +69,7 @@ function AcntgSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Acn
<RadioGroup
value={row.useYn === "N" ? "N" : "Y"}
onValueChange={(v) => onChange({ useYn: v })}
aria-label="사용 여부"
className="flex justify-center gap-4"
>
<div className="flex items-center gap-2">
@@ -82,6 +86,7 @@ function AcntgSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Acn
<Input
type="number"
min={0}
title="정렬순서"
value={row.sortSeq ?? ""}
onChange={(e) =>
onChange({ sortSeq: e.target.value ? Number(e.target.value) : undefined })
@@ -103,7 +103,7 @@ function CnstpCodeTabPanel() {
</p>
) : (
<div className="max-h-[60rem] overflow-y-auto px-4 pb-4 pt-1">
<Table className="w-full">
<Table caption="공사코드 목록으로 코드, 코드명, 설명, 숫자 여부, 소숫점 단위, 사용 여부, 정렬순서, 구분, 관리 정보 제공" className="w-full">
<colgroup>
<col width="14%" />
<col width="16%" />
@@ -115,15 +115,15 @@ function CnstpCodeTabPanel() {
</colgroup>
<TableHeader>
<TableRow>
<TableHead>코드</TableHead>
<TableHead>코드명</TableHead>
<TableHead>설명</TableHead>
<TableHead>숫자 여부</TableHead>
<TableHead>소숫점 단위</TableHead>
<TableHead>사용 여부</TableHead>
<TableHead>정렬순서</TableHead>
<TableHead>구분</TableHead>
<TableHead>관리</TableHead>
<TableHead scope="col">코드</TableHead>
<TableHead scope="col">코드명</TableHead>
<TableHead scope="col">설명</TableHead>
<TableHead scope="col">숫자 여부</TableHead>
<TableHead scope="col">소숫점 단위</TableHead>
<TableHead scope="col">사용 여부</TableHead>
<TableHead scope="col">정렬순서</TableHead>
<TableHead scope="col">구분</TableHead>
<TableHead scope="col">관리</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -40,9 +40,10 @@ function CnstpSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Cns
value={row.cnstpSeCd}
onChange={(e) => onChange({ cnstpSeCd: e.target.value })}
placeholder="코드"
title="코드"
maxLength={10}
disabled={!row.isNew}
className="flex-1"
className="flex-1 disabled:opacity-100"
/>
</div>
</TableCell>
@@ -51,6 +52,7 @@ function CnstpSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Cns
value={row.cnstpSeCdNm ?? ""}
onChange={(e) => onChange({ cnstpSeCdNm: e.target.value })}
placeholder="코드명"
title="코드명"
maxLength={100}
/>
</TableCell>
@@ -59,6 +61,7 @@ function CnstpSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Cns
value={row.expln ?? ""}
onChange={(e) => onChange({ expln: e.target.value })}
placeholder="설명"
title="설명"
maxLength={1000}
/>
</TableCell>
@@ -66,15 +69,16 @@ function CnstpSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Cns
<RadioGroup
value={row.numYn === "N" ? "N" : "Y"}
onValueChange={(v) => onChange({ numYn: v })}
aria-label="숫자 여부"
className="flex justify-center gap-4"
>
<div className="flex items-center gap-2">
<RadioGroupItem value="Y" id={`${row.rowKey}-num-y`} />
<Label htmlFor={`${row.rowKey}-use-y`}>예</Label>
<Label htmlFor={`${row.rowKey}-num-y`}>예</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="N" id={`${row.rowKey}-num-n`} />
<Label htmlFor={`${row.rowKey}-use-n`}>아니오</Label>
<Label htmlFor={`${row.rowKey}-num-n`}>아니오</Label>
</div>
</RadioGroup>
</TableCell>
@@ -82,6 +86,7 @@ function CnstpSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Cns
<Input
type="number"
min={0}
title="소숫점 단위"
value={row.dcptUnitCnt ?? ""}
onChange={(e) =>
onChange({ dcptUnitCnt: e.target.value ? Number(e.target.value) : undefined })
@@ -93,6 +98,7 @@ function CnstpSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Cns
<RadioGroup
value={row.useYn === "N" ? "N" : "Y"}
onValueChange={(v) => onChange({ useYn: v })}
aria-label="사용 여부"
className="flex justify-center gap-4"
>
<div className="flex items-center gap-2">
@@ -109,6 +115,7 @@ function CnstpSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Cns
<Input
type="number"
min={0}
title="정렬순서"
value={row.sortSeq ?? ""}
onChange={(e) =>
onChange({ sortSeq: e.target.value ? Number(e.target.value) : undefined })
@@ -103,7 +103,7 @@ function FnrsSharCodeTabPanel() {
</p>
) : (
<div className="max-h-[60rem] overflow-y-auto px-4 pb-4 pt-1">
<Table className="w-full">
<Table caption="재원분담구분코드 목록으로 코드, 코드명, 설명, 사용 여부, 정렬순서, 구분, 관리 정보 제공" className="w-full">
<colgroup>
<col width="14%" />
<col width="16%" />
@@ -115,13 +115,13 @@ function FnrsSharCodeTabPanel() {
</colgroup>
<TableHeader>
<TableRow>
<TableHead>코드</TableHead>
<TableHead>코드명</TableHead>
<TableHead>설명</TableHead>
<TableHead>사용 여부</TableHead>
<TableHead>정렬순서</TableHead>
<TableHead>구분</TableHead>
<TableHead>관리</TableHead>
<TableHead scope="col">코드</TableHead>
<TableHead scope="col">코드명</TableHead>
<TableHead scope="col">설명</TableHead>
<TableHead scope="col">사용 여부</TableHead>
<TableHead scope="col">정렬순서</TableHead>
<TableHead scope="col">구분</TableHead>
<TableHead scope="col">관리</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -40,9 +40,10 @@ function FnrsSharSeRowComponent({ row, depth, onChange, onAddChild, onDelete }:
value={row.fnrsSharSeCd}
onChange={(e) => onChange({ fnrsSharSeCd: e.target.value })}
placeholder="코드"
title="코드"
maxLength={10}
disabled={!row.isNew}
className="flex-1"
className="flex-1 disabled:opacity-100"
/>
</div>
</TableCell>
@@ -51,6 +52,7 @@ function FnrsSharSeRowComponent({ row, depth, onChange, onAddChild, onDelete }:
value={row.fnrsSharSeCdNm ?? ""}
onChange={(e) => onChange({ fnrsSharSeCdNm: e.target.value })}
placeholder="코드명"
title="코드명"
maxLength={100}
/>
</TableCell>
@@ -59,6 +61,7 @@ function FnrsSharSeRowComponent({ row, depth, onChange, onAddChild, onDelete }:
value={row.expln ?? ""}
onChange={(e) => onChange({ expln: e.target.value })}
placeholder="설명"
title="설명"
maxLength={1000}
/>
</TableCell>
@@ -66,6 +69,7 @@ function FnrsSharSeRowComponent({ row, depth, onChange, onAddChild, onDelete }:
<RadioGroup
value={row.useYn === "N" ? "N" : "Y"}
onValueChange={(v) => onChange({ useYn: v })}
aria-label="사용 여부"
className="flex justify-center gap-4"
>
<div className="flex items-center gap-2">
@@ -82,6 +86,7 @@ function FnrsSharSeRowComponent({ row, depth, onChange, onAddChild, onDelete }:
<Input
type="number"
min={0}
title="정렬순서"
value={row.sortSeq ?? ""}
onChange={(e) =>
onChange({ sortSeq: e.target.value ? Number(e.target.value) : undefined })
@@ -56,14 +56,12 @@
fill: rgba(0, 150, 255, 0.2) !important;
stroke: #0066cc;
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.svgContainer :global(svg) :global(.cls-1) {
fill: rgba(0, 150, 255, 0.2) !important;
stroke: #0066cc;
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.svgContainer :global(svg) :global(.basin) {
@@ -78,13 +76,21 @@
opacity: 1;
}
.svgContainer :global(svg) :global(.basin:focus-visible) {
opacity: 1;
}
.svgContainer :global(svg) :global(.basin:focus-visible) :global(path) {
stroke: #111827;
stroke-width: 2.5;
}
.svgContainer :global(svg) :global(.basin) :global(path),
.svgContainer :global(svg) :global(.basin) :global(polygon),
.svgContainer :global(svg) :global(.basin) :global(polyline) {
fill: rgba(0, 209, 209, 0.5);
stroke: #399ac3;
stroke-width: 1.3;
vector-effect: non-scaling-stroke;
}
.popup {
@@ -146,8 +152,38 @@
}
.basinLabel.active {
background: #006bf8;
background: #0056b3;
color: #fff;
border-color: #96c3ff;
box-shadow: 0 4px 12px rgba(0, 107, 248, 0.3);
}
/* 블랙 스킨 (onasys 디자인 design/c 색상 기준) */
:global([data-theme="black"]) .basinLabel {
background: #404244ee;
border-color: #5e6165;
color: #f1f1f1;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}
:global([data-theme="black"]) .basinLabel.active {
background: #0056b3;
border-color: #96c3ff;
color: #fff;
}
:global([data-theme="black"]) .popup {
background: #404244;
border-color: #4e5154;
color: #f1f1f1;
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.5);
}
:global([data-theme="black"]) .popup > div {
border-color: #4e5154;
}
:global([data-theme="black"]) .popup > div:first-child {
background: #4e5154;
color: #fff;
}
@@ -247,6 +247,11 @@ export function useNationalAsMap(basinDocPrgrsList: BasinDocPrgrsResponseDto[] |
svg = svgLayerRef.current.querySelector("svg");
if (!svg) throw new Error("SVG element not found in loaded content");
// 확대·축소해도 선 두께 유지. CSS 의 vector-effect 는 W3C CSS 검사 오류로 처리되므로 SVG 속성으로 지정
svg.querySelectorAll("path, polygon, polyline").forEach((shape) => {
shape.setAttribute("vector-effect", "non-scaling-stroke");
});
applyViewBox(svg, stageRef.current);
groups = collectBasinGroups(svg);
labels = labelsLayerRef.current ? createLabels(labelsLayerRef.current, groups) : [];
@@ -265,20 +270,44 @@ export function useNationalAsMap(basinDocPrgrsList: BasinDocPrgrsResponseDto[] |
};
groups.forEach((group) => {
addListener(group, "mouseenter", () => {
group.setAttribute("role", "button");
group.setAttribute("tabindex", "0");
group.setAttribute(
"aria-label",
`${resolveBasinName(group)} 행정지원 현황 보기`,
);
const activateGroup = () => {
clearActive?.();
group.classList.add("active");
revealGroup(group);
};
const clearGroupIfInactive = () => {
if (!group.matches(":hover") && document.activeElement !== group) {
clearActive?.();
}
};
addListener(group, "mouseenter", () => {
activateGroup();
});
addListener(group, "mousemove", () => {
if (group.classList.contains("active")) revealGroup(group);
});
addListener(group, "mouseleave", () => clearActive?.());
addListener(group, "mouseleave", clearGroupIfInactive);
addListener(group, "focus", activateGroup);
addListener(group, "blur", clearGroupIfInactive);
addListener(group, "click", (event) => {
event.stopPropagation();
clearActive?.();
group.classList.add("active");
revealGroup(group);
activateGroup();
});
addListener(group, "keydown", (event) => {
const keyboardEvent = event as KeyboardEvent;
if (keyboardEvent.key !== "Enter" && keyboardEvent.key !== " ") {
return;
}
keyboardEvent.preventDefault();
activateGroup();
});
});
@@ -40,18 +40,18 @@ export function DashboardNotiList({
);
return (
<section className="card card-border m-0 bg-background">
<section className="card card-border m-0">
<div className="card-header flex items-center justify-between gap-4">
<h3 className="text-2xl font-semibold">
알림이력
{dataUpdatedAt > 0 ? (
<span className="pl-2 text-lg font-medium text-muted-foreground">
<span className="pl-2 text-lg font-medium text-label">
(업데이트 일시: {dayjs(dataUpdatedAt).format("YYYY-MM-DD HH:mm")})
</span>
) : null}
</h3>
<div className="flex items-center gap-4">
<Button type="button" size="sm" color="white" onClick={() => router.push("/nti-hstry")}>
<Button type="button" size="sm" color="white" className="!text-[#0056B3]" onClick={() => router.push("/nti-hstry")}>
전체보기
</Button>
</div>
@@ -60,7 +60,7 @@ export function DashboardNotiList({
<div className="flex flex-col gap-4 p-2">
<div className="flex flex-wrap gap-2 pt-2">
{NOTI_STAT_CHIPS.map((chip) => (
<Badge key={chip.field} color={chip.color}>
<Badge key={chip.field} color={chip.color} className={chip.className}>
{chip.label} {isPending ? "-" : (notiStats?.[chip.field] ?? 0)}건
</Badge>
))}
@@ -75,7 +75,7 @@ export function DashboardNotiList({
) : null}
{isError ? (
<p className="flex min-h-[12rem] items-center justify-center text-muted-foreground">
<p className="flex min-h-[12rem] items-center justify-center text-label">
알림이력을 불러오지 못했습니다.
</p>
) : null}
@@ -110,11 +110,11 @@ interface DashboardNotiRowProps {
function DashboardNotiRow({ row, isActionPending, onAction }: DashboardNotiRowProps) {
return (
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-4 border-b px-2 py-4 last:border-b-0">
<span className="text-lg text-muted-foreground">
<span className="text-lg text-label">
{row.regDt ? dayjs(row.regDt).format("YYYY-MM-DD") : "-"}
</span>
<p className="min-w-0 line-clamp-2 text-xl sm:line-clamp-1">{row.notiTtl}</p>
<Button type="button" size="sm" color="white" disabled={isActionPending} onClick={onAction}>
<Button type="button" size="sm" color="white" className="!text-[#0056B3]" disabled={isActionPending} onClick={onAction}>
처리하기
</Button>
</div>
@@ -137,7 +137,7 @@ function resolveNotiUrl(row: NotiAsUserResponseDto) {
}
const NOTI_STAT_CHIPS = [
{ field: "todayCount", label: "오늘", color: "green" },
{ field: "delayCount", label: "지연", color: "orange" },
{ field: "pendingCount", label: "완료대기", color: "gray" },
{ field: "todayCount", label: "오늘", color: "green", className: "!bg-[#188038]" },
{ field: "delayCount", label: "지연", color: "orange", className: "!bg-[#C5221F]" },
{ field: "pendingCount", label: "완료대기", color: "gray", className: "!bg-[#5F6368]" },
] as const;
@@ -39,16 +39,17 @@ export function DashboardNoticeTable() {
getCoreRowModel: getCoreRowModel(),
},
dataTableOptions: {
caption: "공지사항 목록으로 번호, 제목, 작성자, 작성일, 조회수 정보 제공",
variant: "line",
onRowClick: handleRowClick,
},
});
return (
<section className="card card-border m-0 bg-background">
<section className="card card-border m-0">
<div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-semibold">공지사항</h3>
<Button type="button" size="sm" color="white" onClick={handleMoreClick}>
<Button type="button" size="sm" color="white" className="!text-[#0056B3]" onClick={handleMoreClick}>
더보기
</Button>
</div>
@@ -11,7 +11,8 @@ export function DashboardPage() {
const { data, isPending, isError, dataUpdatedAt } = useGetMainDashboard();
return (
<div className="mt-4 flex gap-4 px-[1.8rem] pb-6">
// 배경·글자색은 테마 변수를 따름 (배경 --container, 글자 --foreground)
<div className="flex min-h-full gap-4 bg-container px-[1.8rem] pb-6 pt-4 text-foreground">
<div className="hidden flex-[0.5] sm:block">
<NationalAsMap basinDocPrgrsList={data?.basinDocPrgrsList} />
</div>
@@ -24,7 +24,7 @@ export function DocPrgrsSttsCards({ items, isPending, isError }: DocPrgrsSttsCar
if (isError) {
return (
<div className="card card-border m-0 flex min-h-[14rem] items-center justify-center bg-background text-muted-foreground">
<div className="card card-border m-0 flex min-h-[14rem] items-center justify-center text-label">
진행상황을 불러오지 못했습니다.
</div>
);
@@ -39,7 +39,7 @@ export function DocPrgrsSttsCards({ items, isPending, isError }: DocPrgrsSttsCar
return (
<div
key={slot.docSeComCd}
className="card card-border hover:bg-muted/40 m-0 block bg-background transition-colors"
className="card card-border hover:bg-muted/40 m-0 block transition-colors"
>
<div className="card-header">
<h3 className="text-2xl font-semibold">{slot.title}</h3>
@@ -90,7 +90,7 @@ function DocPrgrsSttsMetric({
>
<Icon className="size-6" />
</span>
<span className="text-xl text-muted-foreground">{label}</span>
<span className="text-xl text-label">{label}</span>
<span className="text-3xl font-semibold tabular-nums">{count}</span>
</div>
);
@@ -107,22 +107,23 @@ const STAT_METRICS = [
{
field: "reqCnt",
label: "신청",
iconClassName: "bg-blue-500",
bgColor: "bg-blue-100",
iconClassName: "bg-[#4285F4]",
// 블랙 스킨: 아이콘 색 반투명 배경
bgColor: "bg-blue-100 [[data-theme=black]_&]:bg-[rgba(66,133,244,0.18)]",
Icon: FilePlus,
},
{
field: "prgrsCnt",
label: "진행",
iconClassName: "bg-emerald-500",
bgColor: "bg-emerald-100",
iconClassName: "bg-[#34A853]",
bgColor: "bg-emerald-100 [[data-theme=black]_&]:bg-[rgba(52,168,83,0.18)]",
Icon: RefreshCw,
},
{
field: "cmptnCnt",
label: "완료",
iconClassName: "bg-orange-500",
bgColor: "bg-orange-100",
iconClassName: "bg-[#EA4335]",
bgColor: "bg-orange-100 [[data-theme=black]_&]:bg-[rgba(234,67,53,0.18)]",
Icon: CircleCheck,
},
] as const;
@@ -18,7 +18,7 @@ export function NationalAsMap({ basinDocPrgrsList }: NationalAsMapProps) {
const popupColumnSpan = Math.max(1, popupContent.items.length);
return (
<section className="card card-border m-0 flex h-full min-h-[40rem] flex-col bg-background">
<section className="card card-border m-0 flex h-full min-h-[40rem] flex-col">
<div className="card-header">
<h3 className="text-2xl font-semibold">전국 행정지원 지도</h3>
</div>
+183 -156
View File
@@ -1,156 +1,183 @@
"use client";
import { Fragment } from "react";
import { createColumnHelper } from "@tanstack/react-table";
import { dayjs } from "@/shared/lib";
import type { DesignCnstnMngResponseDto } from "@/entities/design-cnstn";
import { Badge } from "@common/components";
const columnHelper = createColumnHelper<DesignCnstnMngResponseDto>();
const MAIN_STEP_LABELS = {
apply: "설계자문 신청",
techReview: "기술검토",
reply: "검토결과 회신",
} as const;
export const PROGRESS_STATUS_COLUMNS = [
{ label: "설계자문 신청 (시군)", sttsComCd: "CT" },
{ label: "기술검토", sttsComCd: "CS" },
{ label: "기술검토 회신", sttsComCd: "CP" },
];
type BadgeState = "completed" | "current" | "pending";
interface ProgressBadge {
label: string;
state: BadgeState;
}
function buildProgressBadges(prgrsSttsComCd: string): ProgressBadge[] {
if (prgrsSttsComCd === "CV") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "CT" || prgrsSttsComCd === "CS" || prgrsSttsComCd === "SL") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.techReview, state: "current" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "CP") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.techReview, state: "completed" },
{ label: MAIN_STEP_LABELS.reply, state: "completed" },
];
}
return [
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
if (prgrsSttsComCd === "SL") {
return <Badge color="orange">보완요청</Badge>;
}
return null;
}
function getBadgeColor(state: BadgeState) {
switch (state) {
case "completed":
return "green";
case "current":
return "blue";
case "pending":
return "lightGray";
}
}
function formatOptional(value: string | null | undefined) {
return value?.trim() ? value : "-";
}
interface ProgressStepBadgeProps {
prgrsSttsComCd: string;
}
function ProgressStepBadge({ prgrsSttsComCd }: ProgressStepBadgeProps) {
const badges = buildProgressBadges(prgrsSttsComCd);
return (
<div className="flex flex-wrap items-center justify-start">
{badges.map((badge, index) => (
<Fragment key={`${badge.label}-${index}`}>
{index > 0 && <span className="mx-2">&gt;</span>}
<Badge color={getBadgeColor(badge.state)}>{badge.label}</Badge>
</Fragment>
))}
</div>
);
}
export function createDesignCnstnColumns() {
return [
columnHelper.accessor("sido", {
header: "시도",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("gugun", {
header: "시군구",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("bizNm", {
header: "사업명",
meta: { align: "left" },
cell: ({ row, getValue }) => (
<>
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
</>
),
}),
columnHelper.accessor("taskSeCdNm", {
header: "업무구분",
meta: { align: "center" },
cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd),
}),
columnHelper.accessor("aplcntNm", {
header: "담당자",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("envoSeCdNm", {
header: "해당기관",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.display({
id: "progress",
header: "진행상황",
meta: { align: "center" },
cell: ({ row }) => <ProgressStepBadge prgrsSttsComCd={row.original.prgrsSttsComCd || ""} />,
}),
columnHelper.accessor("regDt", {
header: "신청일",
meta: { align: "center" },
cell: ({ getValue }) => {
const value = getValue();
return value ? dayjs(value).format("YYYY-MM-DD") : "-";
},
}),
];
}
"use client";
import { Fragment } from "react";
import { createColumnHelper } from "@tanstack/react-table";
import { dayjs } from "@/shared/lib";
import type { DesignCnstnMngResponseDto } from "@/entities/design-cnstn";
import { Badge } from "@common/components";
const columnHelper = createColumnHelper<DesignCnstnMngResponseDto>();
const MAIN_STEP_LABELS = {
apply: "설계자문 신청",
techReview: "기술검토",
reply: "검토결과 회신",
} as const;
export const PROGRESS_STATUS_COLUMNS = [
{ label: "설계자문 신청 (시군)", sttsComCd: "CT" },
{ label: "기술검토", sttsComCd: "CS" },
{ label: "기술검토 회신", sttsComCd: "CP" },
];
type BadgeState = "completed" | "current" | "pending";
interface ProgressBadge {
label: string;
state: BadgeState;
}
function buildProgressBadges(prgrsSttsComCd: string): ProgressBadge[] {
if (prgrsSttsComCd === "CV") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "CT" || prgrsSttsComCd === "CS" || prgrsSttsComCd === "SL") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.techReview, state: "current" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "CP") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.techReview, state: "completed" },
{ label: MAIN_STEP_LABELS.reply, state: "completed" },
];
}
return [
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
if (prgrsSttsComCd === "SL") {
return <Badge color="orange">보완요청</Badge>;
}
return null;
}
function getBadgeColor(state: BadgeState) {
switch (state) {
case "completed":
return "green";
case "current":
return "blue";
case "pending":
return "lightGray";
}
}
function getBadgeStateText(state: BadgeState) {
switch (state) {
case "completed":
return "완료";
case "current":
return "진행중";
case "pending":
return "대기";
}
}
/** 단계명을 2줄로 분리: "설치인가 신청" → ["설치인가", "신청"], "기술검토" → ["기술검토", ""] */
function splitStepLabel(label: string) {
const [title = "", ...rest] = label.split(" ");
return { title, detail: rest.join(" ") };
}
function formatOptional(value: string | null | undefined) {
return value?.trim() ? value : "-";
}
interface ProgressStepBadgeProps {
prgrsSttsComCd: string;
}
function ProgressStepBadge({ prgrsSttsComCd }: ProgressStepBadgeProps) {
const badges = buildProgressBadges(prgrsSttsComCd);
return (
<div className="flex flex-wrap items-center justify-center">
{badges.map((badge, index) => (
<Fragment key={`${badge.label}-${index}`}>
{index > 0 && <span className="mx-2">&gt;</span>}
{/* 1줄: 단계명 / 2줄: 세부단계 + 상태 (예: 설치인가 / 신청완료, 기술검토 / 완료) */}
<Badge
color={getBadgeColor(badge.state)}
className="w-[100px] flex-col justify-center !px-0 py-1 text-center leading-tight"
>
<span>{splitStepLabel(badge.label).title}</span>
<span>
{splitStepLabel(badge.label).detail}
{getBadgeStateText(badge.state)}
</span>
</Badge>
</Fragment>
))}
</div>
);
}
export function createDesignCnstnColumns() {
return [
columnHelper.accessor("sido", {
header: "시도",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("gugun", {
header: "시군구",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("bizNm", {
header: "사업명",
meta: { align: "left" },
cell: ({ row, getValue }) => (
<>
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
</>
),
}),
columnHelper.accessor("taskSeCdNm", {
header: "업무구분",
meta: { align: "center" },
cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd),
}),
columnHelper.accessor("aplcntNm", {
header: "담당자",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("envoSeCdNm", {
header: "해당기관",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.display({
id: "progress",
header: "진행상황",
meta: { align: "center" },
cell: ({ row }) => <ProgressStepBadge prgrsSttsComCd={row.original.prgrsSttsComCd || ""} />,
}),
columnHelper.accessor("regDt", {
header: "신청일",
meta: { align: "center" },
cell: ({ getValue }) => {
const value = getValue();
return value ? dayjs(value).format("YYYY-MM-DD") : "-";
},
}),
];
}
@@ -165,8 +165,11 @@ function DesignFormPage({ mode, initialData, createDefaults }: DesignFormPagePro
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSave)} className="mt-6 space-y-4">
<SectionCollapsibleGroup>
<div className="sticky top-[6rem] z-30 bg-container px-4 py-2">
<div className="sticky top-[6rem] z-30 flex items-center justify-between bg-container px-4 py-2">
<SectionCollapsibleToggle />
<p className="text-sm text-muted-foreground">
<span className="text-red-600">*</span>은 필수입력 항목입니다.
</p>
</div>
<div className="space-y-4">
{initialData?.detDto?.designCnstnDocNo && (
@@ -77,8 +77,8 @@ function DesignListPage() {
};
return (
<div className="space-y-4 px-[1.8rem] pb-6">
<section className="card card-border mx-0 bg-background">
<div className="card card-border !mt-6">
<div className="card card-border">
<form className="flex flex-wrap items-center gap-4 p-2" onSubmit={handleSearch}>
<div className="flex items-center gap-2">
<Label htmlFor="sidoComCd" className="whitespace-nowrap">
@@ -145,7 +145,7 @@ function DesignListPage() {
</div>
<Button type="submit">조회</Button>
</form>
</section>
</div>
<DesignList
searchParams={searchParams}
@@ -59,6 +59,7 @@ function DesignList({ searchParams, pagination, onPaginationChange }: DesignList
manualPagination: true,
},
dataTableOptions: {
caption: "설계자문 목록으로 시도, 시군구, 사업명, 업무구분, 담당자, 해당기관, 진행상황, 신청일 정보 제공",
viewPagination: true,
onRowClick: handleRowClick,
},
@@ -77,15 +78,15 @@ function DesignList({ searchParams, pagination, onPaginationChange }: DesignList
return (
<>
<section className="card card-border mx-0 bg-background pb-8 pt-0">
<div className="card card-border pt-0">
<div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-semibold">설계자문 목록</h3>
<h3 className="text-2xl font-medium text-label">설계자문 목록</h3>
<Button type="button" color="green" onClick={() => setIsTaskSeDialogOpen(true)}>
신청
</Button>
</div>
{tableComponent}
</section>
<div className="card !m-[1.2rem] !p-0">{tableComponent}</div>
</div>
<PbadmsTaskApplyDialog
open={isTaskSeDialogOpen}
onOpenChange={setIsTaskSeDialogOpen}
@@ -8,6 +8,7 @@ import { SectionCollapsible } from "@/shared/ui";
import { Field, FieldError, FieldGroup } from "@/shared/ui/field";
import { findCodeLabel, useCodeList } from "@/entities/code";
import { type BnsApplyData, BnsPickerDialog } from "@/widgets/bns-picker";
import { PreviousPlanLookupButton } from "@/widgets/previous-plan-lookup";
import { Input, Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
import type { DesignCnstnRegistFormData } from "../../lib/design-cnstn-regist-schema";
@@ -16,9 +17,9 @@ function BasicInfoFormSection() {
const { control, watch, setValue } = useFormContext<DesignCnstnRegistFormData>();
const [isPickerOpen, setIsPickerOpen] = useState(false);
const taskSeComCd = watch("designCnstnMng.taskSeComCd");
const localGovLabel = [watch("designCnstnMng.sido"), watch("designCnstnMng.gugun")]
.filter(Boolean)
.join(" ");
const sido = watch("designCnstnMng.sido");
const gugun = watch("designCnstnMng.gugun");
const localGovLabel = [sido, gugun].filter(Boolean).join(" ");
const { data: taskOptions } = useCodeList("CNFM");
function handleBnsSelect(data: BnsApplyData) {
@@ -36,7 +37,8 @@ function BasicInfoFormSection() {
return (
<SectionCollapsible title="기본 정보">
<Table variant="secondary">
<PreviousPlanLookupButton sidoNm={sido} gugunNm={gugun} />
<Table caption="기본 정보 입력으로 사업명, 사업기간, 사업내용, 구분, 신청 지자체 정보 제공" variant="secondary">
<colgroup>
<col width="15%" />
<col width="35%" />
@@ -45,7 +47,7 @@ function BasicInfoFormSection() {
</colgroup>
<TableHeader>
<TableRow>
<TableHead className="w-[20rem]" data-required>
<TableHead scope="row" className="w-[20rem]" data-required>
사업명
</TableHead>
<TableCell colSpan={3}>
@@ -73,7 +75,7 @@ function BasicInfoFormSection() {
</TableCell>
</TableRow>
<TableRow>
<TableHead data-required>사업기간</TableHead>
<TableHead scope="row" data-required>사업기간</TableHead>
<TableCell colSpan={3}>
<FieldGroup className="flex-row items-center gap-2">
<Controller
@@ -117,7 +119,7 @@ function BasicInfoFormSection() {
</TableCell>
</TableRow>
<TableRow>
<TableHead data-required>사업내용</TableHead>
<TableHead scope="row" data-required>사업내용</TableHead>
<TableCell colSpan={3}>
<Controller
name="designCnstnMng.bizCn"
@@ -137,9 +139,9 @@ function BasicInfoFormSection() {
</TableCell>
</TableRow>
<TableRow>
<TableHead data-required>구분</TableHead>
<TableHead scope="row" data-required>구분</TableHead>
<TableCell>{taskSeComCdLabel}</TableCell>
<TableHead>신청 지자체</TableHead>
<TableHead scope="row">신청 지자체</TableHead>
<TableCell>{localGovLabel}</TableCell>
</TableRow>
</TableHeader>
@@ -2,6 +2,7 @@
import { SectionCollapsible } from "@/shared/ui";
import type { DesignCnstnMngResponseDto } from "@/entities/design-cnstn";
import { PreviousPlanLookupButton } from "@/widgets/previous-plan-lookup";
import { Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
interface BasicInfoSectionProps {
@@ -11,7 +12,8 @@ interface BasicInfoSectionProps {
function BasicInfoSection({ det }: BasicInfoSectionProps) {
return (
<SectionCollapsible title="기본 정보">
<Table variant="secondary">
<PreviousPlanLookupButton sidoNm={det.sido} gugunNm={det.gugun} />
<Table caption="기본 정보로 사업명, 사업기간, 사업내용, 환경청구분, 구분, 신청지자체 정보 제공" variant="secondary">
<colgroup>
<col width="15%" />
<col width="35%" />
@@ -13,7 +13,7 @@ function BusinessScaleSection({ bizSclList }: BusinessScaleSectionProps) {
return (
<SectionCollapsible title="사업규모">
<div className="overflow-x-auto">
<Table variant="secondary">
<Table caption="사업규모 목록으로 구분, 기본계획(A), 예산반영(B), 금회설계시(C), 증감(C-B) 정보 제공" variant="secondary">
<TableHeader>
<TableRow>
<TableHead className="w-[30rem]">구분</TableHead>
@@ -36,7 +36,7 @@ function BusinessScaleSection({ bizSclList }: BusinessScaleSectionProps) {
return (
<TableRow key={node.cnstpSeCd}>
<TableHead className="text-left" style={{ paddingLeft: `${pl + 8}px` }}>
<TableHead scope="row" className="text-left" style={{ paddingLeft: `${pl + 8}px` }}>
{node.cnstpSeCdNm}
</TableHead>
<TableCell>
@@ -11,21 +11,21 @@ interface ContactInfoSectionProps {
function ContactInfoSection({ det }: ContactInfoSectionProps) {
return (
<SectionCollapsible title="담당자 정보">
<Table variant="secondary">
<Table caption="담당자 정보로 신청자(이름, 연락처, 소속, 이메일)과 설계자(이름, 연락처, 소속, 이메일) 정보 제공" variant="secondary">
<TableHeader>
{det.aplcntNm && (
<>
<TableRow>
<TableHead rowSpan={2}>신청자</TableHead>
<TableHead>이름</TableHead>
<TableHead scope="row" rowSpan={2}>신청자</TableHead>
<TableHead scope="row">이름</TableHead>
<TableCell>{det.aplcntNm || "-"}</TableCell>
<TableHead>연락처</TableHead>
<TableHead scope="row">연락처</TableHead>
<TableCell>{det.aplcntMblTelno || "-"}</TableCell>
</TableRow>
<TableRow>
<TableHead>소속</TableHead>
<TableHead scope="row">소속</TableHead>
<TableCell>{det.aplcntOgdpNm || "-"}</TableCell>
<TableHead>이메일</TableHead>
<TableHead scope="row">이메일</TableHead>
<TableCell>{det.aplcntEmlAddr || "-"}</TableCell>
</TableRow>
</>
@@ -33,16 +33,16 @@ function ContactInfoSection({ det }: ContactInfoSectionProps) {
{det.dsgnrNm && (
<>
<TableRow>
<TableHead rowSpan={2}>설계자</TableHead>
<TableHead>이름</TableHead>
<TableHead scope="row" rowSpan={2}>설계자</TableHead>
<TableHead scope="row">이름</TableHead>
<TableCell>{det.dsgnrNm || "-"}</TableCell>
<TableHead>연락처</TableHead>
<TableHead scope="row">연락처</TableHead>
<TableCell>{det.dsgnrTelno || "-"}</TableCell>
</TableRow>
<TableRow>
<TableHead>소속</TableHead>
<TableHead scope="row">소속</TableHead>
<TableCell>{det.dsgnrOgdpNm || "-"}</TableCell>
<TableHead>이메일</TableHead>
<TableHead scope="row">이메일</TableHead>
<TableCell>{det.dsgnrEmlAddr || "-"}</TableCell>
</TableRow>
</>
@@ -1,338 +1,334 @@
"use client";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
import { TextShimmer } from "@/shared/ui";
import { SectionCollapsible } from "@/shared/ui";
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
import { AttachmentDownloadLink, attachService } from "@/entities/attach";
import type { DesignCnstnRvwOpnnResponseDto } from "@/entities/design-cnstn";
import {
Button,
RadioGroup,
RadioGroupItem,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Textarea,
} from "@common/components";
import type {
ReviewOpinionFormData,
ReviewOpinionGetFormDataResult,
} from "../../lib/review-opinion-utils";
import type { ReviewStepCode } from "../../model/view-types";
import { REVIEW_STEP_LABELS } from "../../model/view-types";
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
interface TechReviewOpinionSectionProps {
rvwList: DesignCnstnRvwOpnnResponseDto[];
editableReviewStep?: ReviewStepCode;
atrzSeComCdList?: string[];
isAtrzLoading?: boolean;
}
export interface TechReviewOpinionSectionHandle {
getFormData: () => ReviewOpinionGetFormDataResult | null;
resetForm: () => void;
}
interface TechReviewOpinionTableProps {
items: DesignCnstnRvwOpnnResponseDto[];
}
interface UploadedOpinionFile {
fileName: string;
fileKey: string;
atflId?: string;
atflCd?: string;
}
const EMPTY_FORM_DATA: ReviewOpinionFormData = {
rvwRsltCn: "",
rvwRsltAnmlyYn: "",
atchFileGroupId: "",
opnrSeComCd: "",
};
function createRvwOpnnUploadKey(file: File) {
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
}
function hasFormContent(formData: ReviewOpinionFormData) {
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
}
const TechReviewOpinionSection = forwardRef<
TechReviewOpinionSectionHandle,
TechReviewOpinionSectionProps
>(function TechReviewOpinionSection(
{ rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
ref,
) {
const canWriteOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
const [formData, setFormData] = useState<ReviewOpinionFormData>({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null);
useEffect(() => {
setFormData((prev) => ({
...prev,
opnrSeComCd: editableReviewStep ?? "",
}));
}, [editableReviewStep]);
const { uploadFileAsync, isUploading, uploads } = useTus({
onSuccess: (atflCd, atflId, _file, fileKey) => {
if (!atflCd || !atflId) return;
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
},
onError: (_error, _file, fileKey) => {
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
},
});
useImperativeHandle(
ref,
() => ({
getFormData: () => {
if (!canWriteOpinion || !editableReviewStep || !hasFormContent(formData)) {
return null;
}
return {
formData: {
...formData,
opnrSeComCd: editableReviewStep,
},
rvwList,
};
},
resetForm: () => {
setFormData({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
setUploadedFile(null);
},
}),
[canWriteOpinion, editableReviewStep, formData, rvwList],
);
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
const hasKecoSection =
kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteOpinion);
if (!hasKecoSection) {
return null;
}
async function handleFileUpload(files: FileList | null) {
if (!files || files.length === 0) return;
const file = files[0];
const fileKey = createRvwOpnnUploadKey(file);
const existingAtflCd = formData.atchFileGroupId;
setUploadedFile({ fileName: file.name, fileKey });
try {
await uploadFileAsync(file, {
fileKey,
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
});
} catch {
// onError callback handles cleanup
}
}
async function handleFileRemove() {
if (!uploadedFile) return;
uploads[uploadedFile.fileKey]?.abort();
if (uploadedFile.atflId) {
try {
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
} catch (error) {
console.error("검토의견 첨부파일 삭제 실패:", error);
}
}
setUploadedFile(null);
setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
}
function renderOpinionForm(reviewStep: ReviewStepCode) {
if (editableReviewStep !== reviewStep || !canWriteOpinion) return null;
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
return (
<div className="space-y-2">
<h4 className="text-xl font-semibold">검토의견 작성</h4>
<Table variant="secondary">
<TableHeader>
<TableRow>
<TableHead className="w-[12rem]">의견내용</TableHead>
<TableCell>
<Textarea
value={formData.rvwRsltCn}
onChange={(event) =>
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
}
placeholder="의견내용을 입력하세요"
rows={4}
className="min-h-[8rem] !text-xl"
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>검토결과</TableHead>
<TableCell>
<FieldSet>
<RadioGroup
value={formData.rvwRsltAnmlyYn}
onValueChange={(value) =>
setFormData((prev) => ({
...prev,
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
}))
}
>
<div className="flex flex-wrap gap-4">
<FieldLabel
htmlFor={`rvw-opnn-normal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
<span className="font-normal">정상</span>
</Field>
</FieldLabel>
<FieldLabel
htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
<span className="font-normal">이상</span>
</Field>
</FieldLabel>
</div>
</RadioGroup>
</FieldSet>
</TableCell>
</TableRow>
<TableRow>
<TableHead>첨부파일</TableHead>
<TableCell>
{uploadedFile ? (
<div className="flex items-center gap-2">
<TextShimmer className="text-xl" active={uploading}>
{uploadedFile.fileName}
</TextShimmer>
<Button
size="icon"
onClick={(e) => {
e.preventDefault();
void handleFileRemove();
}}
disabled={uploading}
className="size-8 !bg-destructive"
>
<i className="diveicon di-x text-xs" aria-label="삭제" />
</Button>
</div>
) : (
<label>
<input
type="file"
className="hidden"
onChange={(event) => {
void handleFileUpload(event.target.files);
event.target.value = "";
}}
/>
<Button size="sm" variant="outline" type="button" asChild>
<span>파일 선택</span>
</Button>
</label>
)}
</TableCell>
</TableRow>
</TableHeader>
</Table>
</div>
);
}
return (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
<div className="space-y-6">
{kecoRvwList.length > 0 ? <TechReviewOpinionTable items={kecoRvwList} /> : null}
{renderOpinionForm("KECO")}
</div>
</SectionCollapsible>
);
});
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
return (
<div className="overflow-x-auto">
<Table variant="secondary">
<colgroup>
<col width="10%" />
<col width="55%" />
<col width="5%" />
<col width="20%" />
<col width="10%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>의견자명</TableHead>
<TableHead>의견내용</TableHead>
<TableHead>검토결과</TableHead>
<TableHead>첨부파일</TableHead>
<TableHead>등록일시</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={`${item.designCnstnDocNo}_${item.rvwOpnnSn}`}>
<TableCell align="center">{item.opnrNm}</TableCell>
<TableCell align="left">{item.rvwRsltCn || "-"}</TableCell>
<TableCell align="center">
{item.rvwRsltAnmlyYn === "Y" ? "이상" : item.rvwRsltAnmlyYn === "N" ? "정상" : "-"}
</TableCell>
<TableCell align="center">
{item.atchFileInfo?.atflCd ? (
<AttachmentDownloadLink
atflCd={item.atchFileInfo.atflCd}
fileName={item.atchFileInfo.orgnlFileNm || "다운로드"}
/>
) : (
"-"
)}
</TableCell>
<TableCell align="center">
{item.regDt ? dayjs(item.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
export { TechReviewOpinionSection };
"use client";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { getDefaultFileKey, useTus, useUserStore } from "@/shared/lib";
import { TextShimmer } from "@/shared/ui";
import { SectionCollapsible } from "@/shared/ui";
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
import { attachService } from "@/entities/attach";
import {
designCnstnKeys,
type DesignCnstnRvwOpnnResponseDto,
designCnstnRvwOpnnService,
} from "@/entities/design-cnstn";
import { RvwOpnnListTable } from "@/widgets/pbadms-sprt";
import {
Button,
RadioGroup,
RadioGroupItem,
Table,
TableCell,
TableHead,
TableHeader,
TableRow,
Textarea,
} from "@common/components";
import type {
ReviewOpinionFormData,
ReviewOpinionGetFormDataResult,
} from "../../lib/review-opinion-utils";
import type { ReviewStepCode } from "../../model/view-types";
import { REVIEW_STEP_LABELS } from "../../model/view-types";
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
interface TechReviewOpinionSectionProps {
rvwList: DesignCnstnRvwOpnnResponseDto[];
editableReviewStep?: ReviewStepCode;
atrzSeComCdList?: string[];
isAtrzLoading?: boolean;
}
export interface TechReviewOpinionSectionHandle {
getFormData: () => ReviewOpinionGetFormDataResult | null;
resetForm: () => void;
}
interface TechReviewOpinionTableProps {
items: DesignCnstnRvwOpnnResponseDto[];
}
interface UploadedOpinionFile {
fileName: string;
fileKey: string;
atflId?: string;
atflCd?: string;
}
const EMPTY_FORM_DATA: ReviewOpinionFormData = {
rvwRsltCn: "",
rvwRsltAnmlyYn: "",
atchFileGroupId: "",
opnrSeComCd: "",
};
function createRvwOpnnUploadKey(file: File) {
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
}
function hasFormContent(formData: ReviewOpinionFormData) {
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
}
const TechReviewOpinionSection = forwardRef<
TechReviewOpinionSectionHandle,
TechReviewOpinionSectionProps
>(function TechReviewOpinionSection(
{ rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
ref,
) {
const canWriteOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
const [formData, setFormData] = useState<ReviewOpinionFormData>({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null);
useEffect(() => {
setFormData((prev) => ({
...prev,
opnrSeComCd: editableReviewStep ?? "",
}));
}, [editableReviewStep]);
const { uploadFileAsync, isUploading, uploads } = useTus({
onSuccess: (atflCd, atflId, _file, fileKey) => {
if (!atflCd || !atflId) return;
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
},
onError: (_error, _file, fileKey) => {
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
},
});
useImperativeHandle(
ref,
() => ({
getFormData: () => {
if (!canWriteOpinion || !editableReviewStep || !hasFormContent(formData)) {
return null;
}
return {
formData: {
...formData,
opnrSeComCd: editableReviewStep,
},
rvwList,
};
},
resetForm: () => {
setFormData({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
setUploadedFile(null);
},
}),
[canWriteOpinion, editableReviewStep, formData, rvwList],
);
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
const hasKecoSection =
kecoRvwList.length > 0 || (editableReviewStep === "KECO" && canWriteOpinion);
if (!hasKecoSection) {
return null;
}
async function handleFileUpload(files: FileList | null) {
if (!files || files.length === 0) return;
const file = files[0];
const fileKey = createRvwOpnnUploadKey(file);
const existingAtflCd = formData.atchFileGroupId;
setUploadedFile({ fileName: file.name, fileKey });
try {
await uploadFileAsync(file, {
fileKey,
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
});
} catch {
// onError callback handles cleanup
}
}
async function handleFileRemove() {
if (!uploadedFile) return;
uploads[uploadedFile.fileKey]?.abort();
if (uploadedFile.atflId) {
try {
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
} catch (error) {
console.error("검토의견 첨부파일 삭제 실패:", error);
}
}
setUploadedFile(null);
setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
}
function isReviewTurn(reviewStep: ReviewStepCode) {
return editableReviewStep === reviewStep && canWriteOpinion;
}
function renderOpinionForm(reviewStep: ReviewStepCode) {
if (!isReviewTurn(reviewStep)) return null;
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
return (
<div className="space-y-2">
<h4 className="text-xl font-semibold">검토의견 작성</h4>
<Table
caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공"
variant="secondary"
>
<TableHeader>
<TableRow>
<TableHead scope="row" className="w-[12rem]">
의견내용
</TableHead>
<TableCell>
<Textarea
value={formData.rvwRsltCn}
onChange={(event) =>
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
}
placeholder="의견내용을 입력하세요"
rows={4}
className="min-h-[8rem] !text-xl"
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead scope="row">검토결과</TableHead>
<TableCell>
<FieldSet>
<RadioGroup
value={formData.rvwRsltAnmlyYn}
onValueChange={(value) =>
setFormData((prev) => ({
...prev,
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
}))
}
>
<div className="flex flex-wrap gap-4">
<FieldLabel
htmlFor={`rvw-opnn-normal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
<span className="font-normal">정상</span>
</Field>
</FieldLabel>
<FieldLabel
htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
<span className="font-normal">이상</span>
</Field>
</FieldLabel>
</div>
</RadioGroup>
</FieldSet>
</TableCell>
</TableRow>
<TableRow>
<TableHead scope="row">첨부파일</TableHead>
<TableCell>
{uploadedFile ? (
<div className="flex items-center gap-2">
<TextShimmer className="text-xl" active={uploading}>
{uploadedFile.fileName}
</TextShimmer>
<Button
size="icon"
onClick={(e) => {
e.preventDefault();
void handleFileRemove();
}}
disabled={uploading}
className="size-8 !bg-destructive"
>
<i className="diveicon di-x text-xs" aria-label="삭제" />
</Button>
</div>
) : (
<label>
<input
type="file"
className="hidden"
onChange={(event) => {
void handleFileUpload(event.target.files);
event.target.value = "";
}}
/>
<Button size="sm" variant="outline" type="button" asChild>
<span>파일 선택</span>
</Button>
</label>
)}
</TableCell>
</TableRow>
</TableHeader>
</Table>
</div>
);
}
return (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
<div className="space-y-6">
{kecoRvwList.length > 0 ? (
<TechReviewOpinionTable items={kecoRvwList} />
) : null}
{renderOpinionForm("KECO")}
</div>
</SectionCollapsible>
);
});
function getRvwOpnnIds(item: DesignCnstnRvwOpnnResponseDto) {
if (!item.designCnstnDocNo || item.rvwOpnnSn == null) {
throw new Error("검토의견 식별값이 없습니다.");
}
return [item.designCnstnDocNo, item.rvwOpnnSn] as const;
}
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
const queryClient = useQueryClient();
const currentUserUuid = useUserStore((state) => state.userInfo?.userUnqId);
return (
<RvwOpnnListTable
items={items}
getRowKey={(item) => `${item.designCnstnDocNo}_${item.rvwOpnnSn}`}
currentUserUuid={currentUserUuid}
onUpdate={async (item, body) => {
await designCnstnRvwOpnnService.update(...getRvwOpnnIds(item), body);
await queryClient.invalidateQueries({ queryKey: designCnstnKeys.all });
}}
onDelete={async (item) => {
await designCnstnRvwOpnnService.delete(...getRvwOpnnIds(item));
await queryClient.invalidateQueries({ queryKey: designCnstnKeys.all });
}}
/>
);
}
export { TechReviewOpinionSection };
@@ -51,6 +51,19 @@ export function createEsntlAtchFileColumns({
meta: { align: "center" },
cell: ({ getValue }) => formatYn(getValue()),
}),
columnHelper.accessor("esntlAtchFileRvwCn", {
header: "필수파일검토내용",
meta: { align: "left" },
cell: ({ getValue }) => {
const val = getValue();
if (!val?.trim()) return "-";
return (
<span className="line-clamp-2 max-w-[20rem] text-left" title={val}>
{val}
</span>
);
},
}),
columnHelper.accessor("esntlAtchFileRmrk", {
header: "비고",
meta: { align: "center" },
@@ -26,6 +26,7 @@ export interface EsntlAtchFileFormValues {
docSeComCd: string;
esntlAtchFileNm: string;
esntlAtchYn: "Y" | "N";
esntlAtchFileRvwCn: string;
esntlAtchFileRmrk: string;
atchFileGroupId: string;
}
@@ -68,6 +69,7 @@ function EsntlAtchFileFormDialog({
docSeComCd: "",
esntlAtchFileNm: "",
esntlAtchYn: "Y",
esntlAtchFileRvwCn: "",
esntlAtchFileRmrk: "",
atchFileGroupId: "",
},
@@ -115,6 +117,7 @@ function EsntlAtchFileFormDialog({
docSeComCd: initial.docSeComCd ?? "",
esntlAtchFileNm: initial.esntlAtchFileNm ?? "",
esntlAtchYn: initial.esntlAtchYn ?? "Y",
esntlAtchFileRvwCn: initial.esntlAtchFileRvwCn ?? "",
esntlAtchFileRmrk: initial.esntlAtchFileRmrk ?? "",
atchFileGroupId: initial.atchFileGroupId ?? "",
});
@@ -127,6 +130,7 @@ function EsntlAtchFileFormDialog({
docSeComCd: "",
esntlAtchFileNm: "",
esntlAtchYn: "Y",
esntlAtchFileRvwCn: "",
esntlAtchFileRmrk: "",
atchFileGroupId: "",
});
@@ -249,6 +253,17 @@ function EsntlAtchFileFormDialog({
</div>
</RadioGroup>
</div>
<div className="space-y-2">
<Label htmlFor="esntlAtchFileRvwCn">필수파일검토내용</Label>
<Textarea
id="esntlAtchFileRvwCn"
rows={3}
placeholder="검토내용을 입력하세요"
maxLength={4000}
{...register("esntlAtchFileRvwCn")}
className="!text-xl"
/>
</div>
<div className="space-y-2">
<Label htmlFor="esntlAtchFileRmrk">비고</Label>
<Textarea
@@ -100,6 +100,7 @@ function EsntlAtchFileList({
manualPagination: true,
},
dataTableOptions: {
caption: "필수첨부파일 목록으로 필수첨부파일아이디, 문서구분, 필수첨부파일명, 필수첨부여부, 필수파일검토내용, 비고, 양식 내려받기, 관리 정보 제공",
viewPagination: true,
},
});
@@ -111,6 +112,7 @@ function EsntlAtchFileList({
docSeComCd: values.docSeComCd,
esntlAtchFileNm: values.esntlAtchFileNm,
esntlAtchYn: values.esntlAtchYn,
esntlAtchFileRvwCn: values.esntlAtchFileRvwCn || undefined,
esntlAtchFileRmrk: values.esntlAtchFileRmrk || undefined,
atchFileGroupId: values.atchFileGroupId || undefined,
};
@@ -135,14 +137,14 @@ function EsntlAtchFileList({
};
return (
<section className="card card-border mx-0 bg-background pt-0">
<div className="card card-border pt-0">
<div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-semibold">필수첨부파일 목록</h3>
<h3 className="text-2xl font-medium text-label">필수첨부파일 목록</h3>
<Button type="button" color="green" onClick={handleOpenCreate}>
등록
</Button>
</div>
{tableComponent}
<div className="card !m-[1.2rem] !p-0">{tableComponent}</div>
{dialogMode ? (
<EsntlAtchFileFormDialog
mode={dialogMode}
@@ -158,7 +160,7 @@ function EsntlAtchFileList({
onSubmit={handleFormSubmit}
/>
) : null}
</section>
</div>
);
}
@@ -52,8 +52,8 @@ function EsntlAtchFileMngPage() {
};
return (
<div className="space-y-6 px-[1.8rem] pb-6">
<section className="card card-border mx-0 bg-background">
<div className="card card-border !mt-6">
<div className="card card-border">
<form className="flex flex-wrap items-end gap-4 p-4" onSubmit={handleSearch}>
<div className="flex flex-wrap items-center gap-3">
<Label htmlFor="docSeComCd">문서구분</Label>
@@ -79,7 +79,7 @@ function EsntlAtchFileMngPage() {
<Button type="submit">조회</Button>
</div>
</form>
</section>
</div>
<EsntlAtchFileList
searchParams={searchParams}
@@ -45,7 +45,7 @@ const ROLE_STATUS_BUTTON_ACTIONS: Record<string, Record<string, FnrsCnsltnViewBu
},
ROLE_USER04: {
ST: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.envoSbmsn, FNRS_CNSLTN_VIEW_BUTTON_ACTION.rjct],
EP: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.sncnAprv, FNRS_CNSLTN_VIEW_BUTTON_ACTION.pwrkCnsltnDocMng],
EP: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.sncnAprv],
},
ROLE_USER03: {
LE: [FNRS_CNSLTN_VIEW_BUTTON_ACTION.rvwRply, FNRS_CNSLTN_VIEW_BUTTON_ACTION.rqstTechRvw],
+230 -203
View File
@@ -1,203 +1,230 @@
"use client";
import { Fragment } from "react";
import { createColumnHelper } from "@tanstack/react-table";
import { dayjs } from "@/shared/lib";
import type { FnrsCnsltnMngResponseDto } from "@/entities/fnrs-cnsltn";
import { Badge } from "@common/components";
const columnHelper = createColumnHelper<FnrsCnsltnMngResponseDto>();
const MAIN_STEP_LABELS = {
apply: "재원협의 신청",
envoReview: "재원협의 검토",
techReview: "기술검토",
reply: "검토결과 회신",
} as const;
export const PROGRESS_STATUS_COLUMNS = [
{ label: "재원협의 신청 (시군)", sttsComCd: "ST" },
{ label: "재원협의 신청 (시도)", sttsComCd: "LE" },
{ label: "재원협의 검토", sttsComCd: "ER" },
{ label: "기술검토", sttsComCd: "TE" },
{ label: "기술검토 회신", sttsComCd: "TP" },
{ label: "환경청 검토 결과 회신", sttsComCd: "EP" },
];
type BadgeState = "completed" | "current" | "pending";
interface ProgressBadge {
label: string;
state: BadgeState;
}
function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N"): ProgressBadge[] {
if (prgrsSttsComCd === "SV") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "ST") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "current" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "LE") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "current" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (["FV", "ER", "AC", "TE", "RB", "RA", "AV", "EV", "RK", "RR", "SL"].includes(prgrsSttsComCd)) {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{ label: MAIN_STEP_LABELS.techReview, state: "current" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "TP") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{
label: MAIN_STEP_LABELS.techReview,
state: techRplyHstryYn === "Y" ? "completed" : "pending",
},
{ label: MAIN_STEP_LABELS.reply, state: "current" },
];
}
if (prgrsSttsComCd === "EP" || prgrsSttsComCd === "TA") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{
label: MAIN_STEP_LABELS.techReview,
state: techRplyHstryYn === "Y" ? "completed" : "pending",
},
{ label: MAIN_STEP_LABELS.reply, state: "completed" },
];
}
return [
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
if (prgrsSttsComCd === "SL") {
return <Badge color="orange">보완요청</Badge>;
}
return null;
}
function getBadgeColor(state: BadgeState) {
switch (state) {
case "completed":
return "green";
case "current":
return "blue";
case "pending":
return "lightGray";
}
}
function formatOptional(value: string | null | undefined) {
return value?.trim() ? value : "-";
}
interface ProgressStepBadgeProps {
prgrsSttsComCd: string;
techRplyHstryYn: "Y" | "N";
}
function ProgressStepBadge({ prgrsSttsComCd, techRplyHstryYn }: ProgressStepBadgeProps) {
const badges = buildProgressBadges(prgrsSttsComCd, techRplyHstryYn);
return (
<div className="flex flex-wrap items-center justify-start">
{badges.map((badge, index) => (
<Fragment key={badge.label}>
{index > 0 && <span className="mx-2">&gt;</span>}
<Badge color={getBadgeColor(badge.state)}>{badge.label}</Badge>
</Fragment>
))}
</div>
);
}
export function createFnrsCnsltnColumns() {
return [
columnHelper.accessor("sido", {
header: "시도",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("gugun", {
header: "시군구",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("bizNm", {
header: "사업명",
meta: { align: "left" },
cell: ({ row, getValue }) => (
<>
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
</>
),
}),
columnHelper.accessor("taskSeCdNm", {
header: "업무구분",
meta: { align: "center" },
cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd),
}),
columnHelper.accessor("aplcntNm", {
header: "담당자",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("envoSeCdNm", {
header: "해당기관",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.display({
id: "progress",
header: "진행상황",
meta: { align: "center" },
cell: ({ row }) => (
<ProgressStepBadge
prgrsSttsComCd={row.original.prgrsSttsComCd || ""}
techRplyHstryYn={row.original.techRplyHstryYn || "N"}
/>
),
}),
columnHelper.accessor("regDt", {
header: "신청일",
meta: { align: "center" },
cell: ({ getValue }) => {
const value = getValue();
return value ? dayjs(value).format("YYYY-MM-DD") : "-";
},
}),
];
}
"use client";
import { Fragment } from "react";
import { createColumnHelper } from "@tanstack/react-table";
import { dayjs } from "@/shared/lib";
import type { FnrsCnsltnMngResponseDto } from "@/entities/fnrs-cnsltn";
import { Badge } from "@common/components";
const columnHelper = createColumnHelper<FnrsCnsltnMngResponseDto>();
const MAIN_STEP_LABELS = {
apply: "재원협의 신청",
envoReview: "재원협의 검토",
techReview: "기술검토",
reply: "검토결과 회신",
} as const;
export const PROGRESS_STATUS_COLUMNS = [
{ label: "재원협의 신청 (시군)", sttsComCd: "ST" },
{ label: "재원협의 신청 (시도)", sttsComCd: "LE" },
{ label: "재원협의 검토", sttsComCd: "ER" },
{ label: "기술검토", sttsComCd: "TE" },
{ label: "기술검토 회신", sttsComCd: "TP" },
{ label: "환경청 검토 결과 회신", sttsComCd: "EP" },
];
type BadgeState = "completed" | "current" | "pending";
interface ProgressBadge {
label: string;
state: BadgeState;
}
function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N"): ProgressBadge[] {
if (prgrsSttsComCd === "SV") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "ST") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "current" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "LE") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "current" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (["FV", "ER", "AC", "TE", "RB", "RA", "AV", "EV", "RK", "RR", "SL"].includes(prgrsSttsComCd)) {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{ label: MAIN_STEP_LABELS.techReview, state: "current" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "TP") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{
label: MAIN_STEP_LABELS.techReview,
state: techRplyHstryYn === "Y" ? "completed" : "pending",
},
{ label: MAIN_STEP_LABELS.reply, state: "current" },
];
}
if (prgrsSttsComCd === "EP" || prgrsSttsComCd === "TA") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{
label: MAIN_STEP_LABELS.techReview,
state: techRplyHstryYn === "Y" ? "completed" : "pending",
},
{ label: MAIN_STEP_LABELS.reply, state: "completed" },
];
}
return [
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
if (prgrsSttsComCd === "SL") {
return <Badge color="orange">보완요청</Badge>;
}
return null;
}
function getBadgeColor(state: BadgeState) {
switch (state) {
case "completed":
return "green";
case "current":
return "blue";
case "pending":
return "lightGray";
}
}
function getBadgeStateText(state: BadgeState) {
switch (state) {
case "completed":
return "완료";
case "current":
return "진행중";
case "pending":
return "대기";
}
}
/** 단계명을 2줄로 분리: "설치인가 신청" → ["설치인가", "신청"], "기술검토" → ["기술검토", ""] */
function splitStepLabel(label: string) {
const [title = "", ...rest] = label.split(" ");
return { title, detail: rest.join(" ") };
}
function formatOptional(value: string | null | undefined) {
return value?.trim() ? value : "-";
}
interface ProgressStepBadgeProps {
prgrsSttsComCd: string;
techRplyHstryYn: "Y" | "N";
}
function ProgressStepBadge({ prgrsSttsComCd, techRplyHstryYn }: ProgressStepBadgeProps) {
const badges = buildProgressBadges(prgrsSttsComCd, techRplyHstryYn);
return (
<div className="flex flex-wrap items-center justify-center">
{badges.map((badge, index) => (
<Fragment key={badge.label}>
{index > 0 && <span className="mx-2">&gt;</span>}
{/* 1줄: 단계명 / 2줄: 세부단계 + 상태 (예: 설치인가 / 신청완료, 기술검토 / 완료) */}
<Badge
color={getBadgeColor(badge.state)}
className="w-[100px] flex-col justify-center !px-0 py-1 text-center leading-tight"
>
<span>{splitStepLabel(badge.label).title}</span>
<span>
{splitStepLabel(badge.label).detail}
{getBadgeStateText(badge.state)}
</span>
</Badge>
</Fragment>
))}
</div>
);
}
export function createFnrsCnsltnColumns() {
return [
columnHelper.accessor("sido", {
header: "시도",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("gugun", {
header: "시군구",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("bizNm", {
header: "사업명",
meta: { align: "left" },
cell: ({ row, getValue }) => (
<>
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
</>
),
}),
columnHelper.accessor("taskSeCdNm", {
header: "업무구분",
meta: { align: "center" },
cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd),
}),
columnHelper.accessor("aplcntNm", {
header: "담당자",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("envoSeCdNm", {
header: "해당기관",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.display({
id: "progress",
header: "진행상황",
meta: { align: "center" },
cell: ({ row }) => (
<ProgressStepBadge
prgrsSttsComCd={row.original.prgrsSttsComCd || ""}
techRplyHstryYn={row.original.techRplyHstryYn || "N"}
/>
),
}),
columnHelper.accessor("regDt", {
header: "신청일",
meta: { align: "center" },
cell: ({ getValue }) => {
const value = getValue();
return value ? dayjs(value).format("YYYY-MM-DD") : "-";
},
}),
];
}
@@ -53,21 +53,52 @@ export function createFileVrfcSplmntColumns() {
),
}),
columnHelper.accessor("fileSeComCd", {
header: () => <p className="whitespace-nowrap">파일유형</p>,
meta: { align: "left" },
header: "파일유형",
// 고정 너비 + 자동 줄바꿈 (긴 텍스트로 표가 가로로 늘어나지 않도록)
meta: { align: "left", width: 100 },
cell: ({ getValue }) => (
<p>{formatOptional(FILE_SE_COM_CD_MAP[getValue() as keyof typeof FILE_SE_COM_CD_MAP])}</p>
<p className="whitespace-normal break-words">
{formatOptional(FILE_SE_COM_CD_MAP[getValue() as keyof typeof FILE_SE_COM_CD_MAP])}
</p>
),
}),
columnHelper.accessor("fileSeNm", {
header: () => <p className="whitespace-nowrap">파일구분</p>,
meta: { align: "left" },
cell: ({ getValue }) => <p className="whitespace-nowrap">{formatOptional(getValue())}</p>,
header: "파일구분",
meta: { align: "left", width: 320 },
// 표 레이아웃과 무관하게 셀 내용 너비를 320px 로 고정하고 넘치는 텍스트는 자동 줄바꿈
cell: ({ getValue }) => (
<p
style={{
width: 320,
maxWidth: 320,
// 검증내역과 글자가 붙어 보이지 않도록 오른쪽 여백
paddingRight: 12,
whiteSpace: "normal",
wordBreak: "normal",
overflowWrap: "anywhere",
}}
>
{formatOptional(getValue())}
</p>
),
}),
columnHelper.accessor("vrfcRsltCn", {
header: "검증내역",
// 너비 미지정: 나머지 영역을 사용, 넘치는 텍스트는 자동 줄바꿈
meta: { align: "left" },
cell: ({ getValue }) => formatOptional(getValue()),
cell: ({ getValue }) => (
<p
style={{
// 파일구분과 글자가 붙어 보이지 않도록 왼쪽 여백
paddingLeft: 12,
whiteSpace: "normal",
wordBreak: "normal",
overflowWrap: "anywhere",
}}
>
{formatOptional(getValue())}
</p>
),
}),
];
}
@@ -53,7 +53,7 @@ function ChecklistTaskPanel({
return (
<>
<div className="overflow-x-auto">
<Table variant="secondary">
<Table caption={`체크리스트 목록으로 검토항목, 주요 검토내용, 검토 결과${selectedReviewStep !== "LOCAL" ? ", 기술검토 의견" : ""} 정보 제공`} variant="secondary">
<TableHeader>
<TableRow>
<TableHead className="w-[20rem]" rowSpan={2}>
@@ -115,6 +115,9 @@ function FileVrfcSplmntDialog({
getCoreRowModel: getCoreRowModel(),
},
dataTableOptions: {
caption: "파일보완 목록으로 선택, 파일유형, 파일구분, 검증내역 정보 제공",
// 열 너비를 고정해 긴 텍스트는 셀 안에서 줄바꿈
className: "table-fixed",
viewPagination: false,
},
});
@@ -33,6 +33,9 @@ import {
interface PwrkCnsltnDocEvlDialogProps {
open: boolean;
pwrkCnsltnDocNo: string;
/** 평가 저장 성공 시 호출 (이후 웹기안기 문서 저장이 진행된다) */
onSaved: () => void;
/** 평가 없이 닫을 때 호출 (웹기안기 문서는 저장되지 않는다) */
onClose: () => void;
}
@@ -41,7 +44,12 @@ const DEFAULT_VALUES: Partial<PwrkCnsltnDocEvlFormValues> = {
pwrkCnsltnDocEvlCn: "",
};
function PwrkCnsltnDocEvlDialog({ open, pwrkCnsltnDocNo, onClose }: PwrkCnsltnDocEvlDialogProps) {
function PwrkCnsltnDocEvlDialog({
open,
pwrkCnsltnDocNo,
onSaved,
onClose,
}: PwrkCnsltnDocEvlDialogProps) {
const alert = useAlertStore((state) => state.setMessage);
const { mutateAsync: saveEvl, isPending } = useSavePwrkCnsltnDocEvl();
@@ -65,8 +73,7 @@ function PwrkCnsltnDocEvlDialog({ open, pwrkCnsltnDocNo, onClose }: PwrkCnsltnDo
try {
const payload = buildPwrkCnsltnDocEvlSavePayload(pwrkCnsltnDocNo, values);
await saveEvl(payload);
alert("AI 초안 만족도 평가를 등록했습니다.");
onClose();
onSaved();
} catch (error) {
console.error("AI 초안 만족도 평가 등록 실패:", error);
alert("AI 초안 만족도 평가 등록에 실패했습니다.");
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import dynamic from "next/dynamic";
import { useQuery, useQueryClient } from "@tanstack/react-query";
@@ -46,6 +46,7 @@ const HangulGiangi = dynamic(
const PWRK_ATCH_FILE_KEY_PREFIX = "pwrk-cnsltn-doc::";
/** 현재 버전이 이 값보다 작으면(AI 초안 상태) 저장 전에 만족도 평가를 먼저 받는다. */
const AI_DRAFT_EVL_MAX_VER_SN = 2;
const APPROVER_ATRZ_SE_COM_CDS = new Set(["ATRZ03", "ATRZ99"]);
@@ -88,6 +89,8 @@ function PwrkCnsltnDocMngDialog({
const [uploadedFiles, setUploadedFiles] = useState<UploadedAtchFile[]>([]);
const [isPjtcoAjmtTableOpen, setIsPjtcoAjmtTableOpen] = useState(false);
const [isEvlDialogOpen, setIsEvlDialogOpen] = useState(false);
// 평가 모달 결과(평가 저장 여부)를 저장 흐름으로 돌려주는 resolver
const evlResolverRef = useRef<((isEvlSaved: boolean) => void) | null>(null);
const { mutate: downloadByAtflId } = useDownloadAttachmentByAtflId();
const {
@@ -145,6 +148,8 @@ function PwrkCnsltnDocMngDialog({
setAtchFileGroupId("");
setUploadedFiles([]);
setIsEvlDialogOpen(false);
evlResolverRef.current?.(false);
evlResolverRef.current = null;
return;
}
@@ -178,9 +183,32 @@ function PwrkCnsltnDocMngDialog({
if (!nextOpen) onClose();
}
function requestEvl() {
return new Promise<boolean>((resolve) => {
evlResolverRef.current = resolve;
setIsEvlDialogOpen(true);
});
}
function resolveEvl(isEvlSaved: boolean) {
setIsEvlDialogOpen(false);
evlResolverRef.current?.(isEvlSaved);
evlResolverRef.current = null;
}
async function handleSaveDocument(file: Blob) {
if (!completedDraft) throw new Error("저장할 실무협의서 초안이 없습니다.");
// AI 초안(버전 < 2) 저장 시에는 만족도 평가를 먼저 저장해야 웹기안기 문서가 저장된다.
const currentVerSn = completedDraft.crtRslt?.verSn ?? 0;
if (currentVerSn < AI_DRAFT_EVL_MAX_VER_SN) {
const isEvlSaved = await requestEvl();
if (!isEvlSaved) {
alert("AI 초안 만족도 평가를 등록해야 실무협의서가 저장됩니다.");
return;
}
}
const saveDto = pwrkCnsltnDocCrtRsltSaveRequestSchema.parse({
crtDmndUuid: completedDraft.crtDmndUuid,
pwrkCnsltnDocNo: completedDraft.pwrkCnsltnDocNo,
@@ -201,10 +229,6 @@ function PwrkCnsltnDocMngDialog({
}),
]);
alert(`실무협의서를 저장했습니다. (버전 ${savedDocument.verSn})`);
if (savedDocument.verSn <= AI_DRAFT_EVL_MAX_VER_SN) {
setIsEvlDialogOpen(true);
}
}
async function handleDownload() {
@@ -386,7 +410,8 @@ function PwrkCnsltnDocMngDialog({
<PwrkCnsltnDocEvlDialog
open={isEvlDialogOpen}
pwrkCnsltnDocNo={completedDraft.pwrkCnsltnDocNo}
onClose={() => setIsEvlDialogOpen(false)}
onSaved={() => resolveEvl(true)}
onClose={() => resolveEvl(false)}
/>
) : null}
</>
@@ -42,7 +42,7 @@ function PwrkCnsltnDocPjtcoAjmtTable({
}
return (
<Table variant="secondary">
<Table caption="금액검증결과 목록으로 구분, 검증결과, 검증금액 정보 제공" variant="secondary">
<colgroup>
<col width="30%" />
<col width="50%" />
@@ -157,7 +157,7 @@ function CnsltnListTable({
const isSomeSelected = selectedCount > 0 && !isAllSelected;
return (
<Table variant="secondary">
<Table caption="검토 결과 조회 목록으로 선택, 구분, 검토의견 정보 제공" variant="secondary">
<colgroup>
<col width="4%" />
<col width="4%" />
@@ -248,8 +248,11 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSave)} className="mt-4">
<SectionCollapsibleGroup>
<div className="sticky top-[6rem] z-30 bg-container px-4 py-2">
<div className="sticky top-[6rem] z-30 flex items-center justify-between bg-container px-4 py-2">
<SectionCollapsibleToggle />
<p className="text-sm text-muted-foreground">
<span className="text-red-600">*</span>은 필수입력 항목입니다.
</p>
</div>
<div className="space-y-4">
{initialData?.detDto?.fnrsCnsltnDocNo && (
@@ -77,8 +77,8 @@ function FnrsListPage() {
};
return (
<div className="space-y-4 px-[1.8rem] pb-6">
<section className="card card-border mx-0 bg-background">
<div className="card card-border !mt-6">
<div className="card card-border">
<form className="flex flex-wrap items-center gap-4 p-2" onSubmit={handleSearch}>
<div className="flex items-center gap-2">
<Label htmlFor="sidoComCd" className="whitespace-nowrap">
@@ -145,7 +145,7 @@ function FnrsListPage() {
</div>
<Button type="submit">조회</Button>
</form>
</section>
</div>
<FnrsList
searchParams={searchParams}
@@ -59,6 +59,7 @@ function FnrsList({ searchParams, pagination, onPaginationChange }: FnrsListProp
manualPagination: true,
},
dataTableOptions: {
caption: "재원협의 목록으로 시도, 시군구, 사업명, 업무구분, 담당자, 해당기관, 진행상황, 신청일 정보 제공",
viewPagination: true,
onRowClick: handleRowClick,
},
@@ -77,15 +78,15 @@ function FnrsList({ searchParams, pagination, onPaginationChange }: FnrsListProp
return (
<>
<section className="card card-border mx-0 bg-background pb-8 pt-0">
<div className="card card-border pt-0">
<div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-semibold">재원협의 목록</h3>
<h3 className="text-2xl font-medium text-label">재원협의 목록</h3>
<Button type="button" color="green" onClick={() => setIsTaskSeDialogOpen(true)}>
신청
</Button>
</div>
{tableComponent}
</section>
<div className="card !m-[1.2rem] !p-0">{tableComponent}</div>
</div>
<PbadmsTaskApplyDialog
open={isTaskSeDialogOpen}
onOpenChange={setIsTaskSeDialogOpen}
@@ -8,6 +8,7 @@ import { SectionCollapsible } from "@/shared/ui";
import { Field, FieldError, FieldGroup, FieldLabel, FieldSet } from "@/shared/ui/field";
import { findCodeLabel, useCodeList } from "@/entities/code";
import { type BnsApplyData, BnsPickerDialog } from "@/widgets/bns-picker";
import { PreviousPlanLookupButton } from "@/widgets/previous-plan-lookup";
import {
Button,
Checkbox,
@@ -27,9 +28,9 @@ function BasicInfoFormSection() {
const { control, watch, setValue } = useFormContext<FnrsCnsltnRegistFormData>();
const [isPickerOpen, setIsPickerOpen] = useState(false);
const taskSeComCd = watch("fnrsCnsltnMng.taskSeComCd");
const localGovLabel = [watch("fnrsCnsltnMng.sido"), watch("fnrsCnsltnMng.gugun")]
.filter(Boolean)
.join(" ");
const sido = watch("fnrsCnsltnMng.sido");
const gugun = watch("fnrsCnsltnMng.gugun");
const localGovLabel = [sido, gugun].filter(Boolean).join(" ");
const { data: taskOptions } = useCodeList("FNRS");
const { data: rtlztnFcltOptions } = useCodeList("RTLZTNFCLTSE");
const { data: rtlztnUsgOptions } = useCodeList("RTLZTNUSGSE");
@@ -49,7 +50,8 @@ function BasicInfoFormSection() {
return (
<SectionCollapsible title="기본 정보">
<Table variant="secondary">
<PreviousPlanLookupButton sidoNm={sido} gugunNm={gugun} />
<Table caption="기본 정보 입력으로 사업명, 사업기간, 사업내용, 구분, 신청 지자체, 시설종류, 재이용용도 정보 제공" variant="secondary">
<colgroup>
<col width="15%" />
<col width="35%" />
@@ -58,7 +60,7 @@ function BasicInfoFormSection() {
</colgroup>
<TableHeader>
<TableRow>
<TableHead className="w-[20rem]" data-required>
<TableHead scope="row" className="w-[20rem]" data-required>
사업명
</TableHead>
<TableCell colSpan={3}>
@@ -86,7 +88,7 @@ function BasicInfoFormSection() {
</TableCell>
</TableRow>
<TableRow>
<TableHead data-required>사업기간</TableHead>
<TableHead scope="row" data-required>사업기간</TableHead>
<TableCell colSpan={3}>
<FieldGroup className="flex-row items-center gap-2">
<Controller
@@ -130,7 +132,7 @@ function BasicInfoFormSection() {
</TableCell>
</TableRow>
<TableRow>
<TableHead data-required>사업내용</TableHead>
<TableHead scope="row" data-required>사업내용</TableHead>
<TableCell colSpan={3}>
<Controller
name="fnrsCnsltnMng.bizCn"
@@ -150,15 +152,15 @@ function BasicInfoFormSection() {
</TableCell>
</TableRow>
<TableRow>
<TableHead data-required>구분</TableHead>
<TableHead scope="row" data-required>구분</TableHead>
<TableCell>{taskSeComCdLabel}</TableCell>
<TableHead>신청 지자체</TableHead>
<TableHead scope="row">신청 지자체</TableHead>
<TableCell>{localGovLabel}</TableCell>
</TableRow>
{taskSeComCd.includes("RE") && (
<>
<TableRow>
<TableHead>시설종류</TableHead>
<TableHead scope="row">시설종류</TableHead>
<TableCell colSpan={3}>
<Controller
name="fnrsCnsltnMng.rtlztnFcltSeComCd"
@@ -198,7 +200,7 @@ function BasicInfoFormSection() {
</TableCell>
</TableRow>
<TableRow>
<TableHead>재이용용도</TableHead>
<TableHead scope="row">재이용용도</TableHead>
<TableCell colSpan={3}>
<Controller
name="fnrsCnsltnMng.rtlztnUsgSeComCd"
@@ -2,6 +2,7 @@
import { SectionCollapsible } from "@/shared/ui";
import type { FnrsCnsltnMngResponseDto } from "@/entities/fnrs-cnsltn";
import { PreviousPlanLookupButton } from "@/widgets/previous-plan-lookup";
import { Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
interface BasicInfoSectionProps {
@@ -11,7 +12,8 @@ interface BasicInfoSectionProps {
function BasicInfoSection({ det }: BasicInfoSectionProps) {
return (
<SectionCollapsible title="기본 정보">
<Table variant="secondary">
<PreviousPlanLookupButton sidoNm={det.sido} gugunNm={det.gugun} />
<Table caption="기본 정보로 사업명, 사업기간, 사업내용, 환경청구분, 구분, 신청지자체, 확정예산사업번호, 예산신청연도 정보 제공" variant="secondary">
<colgroup>
<col width="15%" />
<col width="35%" />
@@ -20,31 +22,31 @@ function BasicInfoSection({ det }: BasicInfoSectionProps) {
</colgroup>
<TableHeader>
<TableRow>
<TableHead>사업명</TableHead>
<TableHead scope="row">사업명</TableHead>
<TableCell>{det.bizNm || "-"}</TableCell>
<TableHead>사업기간</TableHead>
<TableHead scope="row">사업기간</TableHead>
<TableCell>{(det.bizBgngYr || "-") + " ~ " + (det.bizEndYr || "-")}</TableCell>
</TableRow>
<TableRow>
<TableHead>사업내용</TableHead>
<TableHead scope="row">사업내용</TableHead>
<TableCell colSpan={3}>{det.bizCn || "-"}</TableCell>
</TableRow>
<TableRow>
<TableHead>환경청구분</TableHead>
<TableHead scope="row">환경청구분</TableHead>
<TableCell>{det.envoSeCdNm || "-"}</TableCell>
<TableHead>구분</TableHead>
<TableHead scope="row">구분</TableHead>
<TableCell>{det.taskSeCdNm || "-"}</TableCell>
</TableRow>
<TableRow>
<TableHead>신청지자체</TableHead>
<TableHead scope="row">신청지자체</TableHead>
<TableCell>
{det.sido || "-"} {det.gugun || "-"}
</TableCell>
<TableHead>확정예산사업번호</TableHead>
<TableHead scope="row">확정예산사업번호</TableHead>
<TableCell>{det.cfmtnBgtBizNo || "-"}</TableCell>
</TableRow>
<TableRow>
<TableHead>예산신청연도</TableHead>
<TableHead scope="row">예산신청연도</TableHead>
<TableCell colSpan={3}>{det.bgtAplyYr || "-"}</TableCell>
</TableRow>
</TableHeader>
@@ -20,7 +20,7 @@ function BusinessCostSection({ bizCstList }: BusinessCostSectionProps) {
<SectionCollapsible title="총사업비">
<div className="overflow-x-auto">
<p className="mb-2 text-right font-normal text-muted-foreground">(단위: 백만원)</p>
<Table variant="secondary" className="table-fixed">
<Table caption="총사업비 목록으로 구분, 예산반영(A), 표준사업비(B), 금회설계시(C), 증감 정보 제공" variant="secondary" className="table-fixed">
<TableHeader>
<TableRow>
<TableHead className="w-[30rem]" rowSpan={2}>
@@ -51,9 +51,9 @@ function BusinessCostSection({ bizCstList }: BusinessCostSectionProps) {
return (
<TableRow key={node.acntgSeCd}>
{node.treeLevel <= 1 ? (
<TableHead className="!px-[0.8rem] text-left">{node.acntgSeCdNm}</TableHead>
<TableHead scope="row" className="!px-[0.8rem] text-left">{node.acntgSeCdNm}</TableHead>
) : (
<TableHead className="text-left" style={{ paddingLeft: `${pl + 8}px` }}>
<TableHead scope="row" className="text-left" style={{ paddingLeft: `${pl + 8}px` }}>
{showSubIcon && <span className="mr-1 text-gray-400">└</span>}
{node.acntgSeCdNm}
</TableHead>
@@ -13,7 +13,7 @@ function BusinessScaleSection({ bizSclList }: BusinessScaleSectionProps) {
return (
<SectionCollapsible title="사업규모">
<div className="overflow-x-auto">
<Table variant="secondary">
<Table caption="사업규모 목록으로 구분, 기본계획(A), 예산반영(B), 금회설계시(C), 증감(C-B) 정보 제공" variant="secondary">
<TableHeader>
<TableRow>
<TableHead className="w-[30rem]">구분</TableHead>
@@ -36,7 +36,7 @@ function BusinessScaleSection({ bizSclList }: BusinessScaleSectionProps) {
return (
<TableRow key={node.cnstpSeCd}>
<TableHead className="text-left" style={{ paddingLeft: `${pl + 8}px` }}>
<TableHead scope="row" className="text-left" style={{ paddingLeft: `${pl + 8}px` }}>
{node.cnstpSeCdNm}
</TableHead>
<TableCell>
@@ -86,7 +86,13 @@ function ChecklistSection({
>
<TabsList>
{visibleReviewSteps.map((stepCode) => (
<TabsTrigger key={stepCode} value={stepCode} className="!px-6 !py-3 text-xl">
<TabsTrigger
key={stepCode}
value={stepCode}
id={`fnrs-cklst-step-tab-${stepCode}`}
aria-controls="fnrs-cklst-task-tabs"
className="!px-6 !py-3 text-xl"
>
{CHECKLIST_TAB_LABELS[stepCode]}
</TabsTrigger>
))}
@@ -96,6 +102,7 @@ function ChecklistSection({
{taskCodes.length > 0 ? (
<Tabs
id="fnrs-cklst-task-tabs"
value={selectedTaskCode ?? taskCodes[0]}
onValueChange={setSelectedTaskCode}
className="w-full"
@@ -107,6 +114,8 @@ function ChecklistSection({
<TabsTrigger
key={code}
value={code}
id={`fnrs-cklst-tab-${code}`}
aria-controls={`fnrs-cklst-panel-${code}`}
className="!px-6 !py-3 text-xl [&:after]:hidden"
>
{TASK_CODE_LABELS[code] ?? code}
@@ -120,7 +129,15 @@ function ChecklistSection({
</div>
{taskCodes.map((code) => (
<TabsContent key={code} value={code} className="mt-0 border-0 p-0 shadow-none ring-0">
<TabsContent
key={code}
value={code}
id={`fnrs-cklst-panel-${code}`}
aria-labelledby={`fnrs-cklst-tab-${code}`}
className="mt-0 border-0 p-0 shadow-none ring-0"
>
{/* 선택된 탭(검토단계·업무구분)의 패널임을 알 수 있도록 숨김 제목 제공 */}
<h3 className="sr-only">{CHECKLIST_TAB_LABELS[selectedReviewStep]} {TASK_CODE_LABELS[code] ?? code} 체크리스트</h3>
<ChecklistTaskPanel
selectedReviewStep={selectedReviewStep}
selectedRows={getChecklistDisplayRows(cklstResultList, code)}
@@ -11,21 +11,21 @@ interface ContactInfoSectionProps {
function ContactInfoSection({ det }: ContactInfoSectionProps) {
return (
<SectionCollapsible title="담당자 정보">
<Table variant="secondary">
<Table caption="담당자 정보로 신청자(이름, 연락처, 소속, 이메일)과 설계자(이름, 연락처, 소속, 이메일) 정보 제공" variant="secondary">
<TableHeader>
{det.aplcntNm && (
<>
<TableRow>
<TableHead rowSpan={2}>신청자</TableHead>
<TableHead>이름</TableHead>
<TableHead scope="row" rowSpan={2}>신청자</TableHead>
<TableHead scope="row">이름</TableHead>
<TableCell>{det.aplcntNm || "-"}</TableCell>
<TableHead>연락처</TableHead>
<TableHead scope="row">연락처</TableHead>
<TableCell>{det.aplcntMblTelno || "-"}</TableCell>
</TableRow>
<TableRow>
<TableHead>소속</TableHead>
<TableHead scope="row">소속</TableHead>
<TableCell>{det.aplcntOgdpNm || "-"}</TableCell>
<TableHead>이메일</TableHead>
<TableHead scope="row">이메일</TableHead>
<TableCell>{det.aplcntEmlAddr || "-"}</TableCell>
</TableRow>
</>
@@ -33,16 +33,16 @@ function ContactInfoSection({ det }: ContactInfoSectionProps) {
{det.dsgnrNm && (
<>
<TableRow>
<TableHead rowSpan={2}>설계자</TableHead>
<TableHead>이름</TableHead>
<TableHead scope="row" rowSpan={2}>설계자</TableHead>
<TableHead scope="row">이름</TableHead>
<TableCell>{det.dsgnrNm || "-"}</TableCell>
<TableHead>연락처</TableHead>
<TableHead scope="row">연락처</TableHead>
<TableCell>{det.dsgnrTelno || "-"}</TableCell>
</TableRow>
<TableRow>
<TableHead>소속</TableHead>
<TableHead scope="row">소속</TableHead>
<TableCell>{det.dsgnrOgdpNm || "-"}</TableCell>
<TableHead>이메일</TableHead>
<TableHead scope="row">이메일</TableHead>
<TableCell>{det.dsgnrEmlAddr || "-"}</TableCell>
</TableRow>
</>
@@ -21,7 +21,7 @@ function FnrsSharSection({ fnrsSharList }: FnrsSharSectionProps) {
<SectionCollapsible title="재원분담">
<div className="overflow-x-auto">
<p className="mb-2 text-right font-normal text-muted-foreground">(단위: 백만원)</p>
<Table variant="secondary" className="table-fixed">
<Table caption="재원분담 목록으로 구분, 현행(A), 변경요구(B), 증감, 비고 정보 제공" variant="secondary" className="table-fixed">
<TableHeader>
<TableRow>
<TableHead className="w-[24rem]" rowSpan={2}>
@@ -51,7 +51,7 @@ function FnrsSharSection({ fnrsSharList }: FnrsSharSectionProps) {
return (
<TableRow key={node.fnrsSharSeCd}>
<TableHead className="text-left" style={{ paddingLeft: `${pl + 8}px` }}>
<TableHead scope="row" className="text-left" style={{ paddingLeft: `${pl + 8}px` }}>
{showSubIcon && <span className="mr-1 text-gray-400">└</span>}
{node.fnrsSharSeCdNm}
</TableHead>
@@ -1,352 +1,345 @@
"use client";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
import { AttachmentFileField, SectionCollapsible } from "@/shared/ui";
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
import {
AttachmentDownloadLink,
attachService,
useDownloadAttachmentByAtflId,
} from "@/entities/attach";
import type { FnrsCnsltnRvwOpnnResponseDto } from "@/entities/fnrs-cnsltn";
import {
RadioGroup,
RadioGroupItem,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Textarea,
} from "@common/components";
import type {
ReviewOpinionFormData,
ReviewOpinionGetFormDataResult,
} from "../../lib/review-opinion-utils";
import type { ReviewStepCode } from "../../model/view-types";
import { REVIEW_STEP_LABELS } from "../../model/view-types";
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
interface TechReviewOpinionSectionProps {
rvwList: FnrsCnsltnRvwOpnnResponseDto[];
editableReviewStep?: ReviewStepCode;
atrzSeComCdList?: string[];
isAtrzLoading?: boolean;
}
export interface TechReviewOpinionSectionHandle {
getFormData: () => ReviewOpinionGetFormDataResult | null;
resetForm: () => void;
}
interface TechReviewOpinionTableProps {
items: FnrsCnsltnRvwOpnnResponseDto[];
}
interface UploadedOpinionFile {
fileName: string;
fileKey: string;
atflId?: string;
atflCd?: string;
}
const EMPTY_FORM_DATA: ReviewOpinionFormData = {
rvwRsltCn: "",
rvwRsltAnmlyYn: "",
atchFileGroupId: "",
opnrSeComCd: "",
};
function createRvwOpnnUploadKey(file: File) {
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
}
function hasFormContent(formData: ReviewOpinionFormData) {
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
}
const TechReviewOpinionSection = forwardRef<
TechReviewOpinionSectionHandle,
TechReviewOpinionSectionProps
>(function TechReviewOpinionSection(
{ rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
ref,
) {
const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
const [formData, setFormData] = useState<ReviewOpinionFormData>({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null);
const { mutate: downloadByAtflId } = useDownloadAttachmentByAtflId();
useEffect(() => {
setFormData((prev) => ({
...prev,
opnrSeComCd: editableReviewStep ?? "",
}));
}, [editableReviewStep]);
const { uploadFileAsync, isUploading, uploads } = useTus({
onSuccess: (atflCd, atflId, _file, fileKey) => {
if (!atflCd || !atflId) return;
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
},
onError: (_error, _file, fileKey) => {
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
},
});
useImperativeHandle(
ref,
() => ({
getFormData: () => {
if (!editableReviewStep || !hasFormContent(formData)) {
return null;
}
if (editableReviewStep === "KECO" && !canWriteKecoOpinion) {
return null;
}
return {
formData: {
...formData,
opnrSeComCd: editableReviewStep,
},
rvwList,
};
},
resetForm: () => {
setFormData({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
setUploadedFile(null);
},
}),
[canWriteKecoOpinion, editableReviewStep, formData, rvwList],
);
const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV");
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV";
const hasKecoSection = kecoRvwList.length > 0 || editableReviewStep === "KECO";
if (!hasEnvSection && !hasKecoSection) {
return null;
}
async function handleFileUpload(files: FileList | null) {
if (!files || files.length === 0) return;
const file = files[0];
const fileKey = createRvwOpnnUploadKey(file);
const existingAtflCd = formData.atchFileGroupId;
setUploadedFile({ fileName: file.name, fileKey });
try {
await uploadFileAsync(file, {
fileKey,
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
});
} catch {
// onError callback handles cleanup
}
}
async function handleFileRemove() {
if (!uploadedFile) return;
uploads[uploadedFile.fileKey]?.abort();
if (uploadedFile.atflId) {
try {
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
} catch (error) {
console.error("검토의견 첨부파일 삭제 실패:", error);
}
}
setUploadedFile(null);
setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
}
function renderOpinionForm(reviewStep: ReviewStepCode) {
if (editableReviewStep !== reviewStep) return null;
if (reviewStep === "KECO" && !canWriteKecoOpinion) return null;
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
return (
<div className="space-y-2">
<h4 className="text-xl font-semibold">검토의견 작성</h4>
<Table variant="secondary">
<TableHeader>
<TableRow>
<TableHead className="w-[12rem]">의견내용</TableHead>
<TableCell>
<Textarea
value={formData.rvwRsltCn}
onChange={(event) =>
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
}
placeholder="의견내용을 입력하세요"
rows={4}
className="min-h-[8rem] !text-xl"
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>검토결과</TableHead>
<TableCell>
<FieldSet>
<RadioGroup
value={formData.rvwRsltAnmlyYn}
onValueChange={(value) =>
setFormData((prev) => ({
...prev,
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
}))
}
>
<div className="flex flex-wrap gap-4">
<FieldLabel
htmlFor={`rvw-opnn-normal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
<span className="font-normal">정상</span>
</Field>
</FieldLabel>
<FieldLabel
htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
<span className="font-normal">이상</span>
</Field>
</FieldLabel>
</div>
</RadioGroup>
</FieldSet>
</TableCell>
</TableRow>
<TableRow>
<TableHead>첨부파일</TableHead>
<TableCell>
<AttachmentFileField
files={
uploadedFile
? [
{
fileKey: uploadedFile.fileKey,
fileName: uploadedFile.fileName,
onDownload: uploadedFile.atflId
? () => downloadByAtflId(uploadedFile.atflId!)
: undefined,
},
]
: []
}
inputId={`rvw-opnn-${reviewStep}-file-input`}
isUploadingByKey={isUploading}
multiple={false}
showAddButton={!uploadedFile}
addDisabled={uploading}
onAdd={(fileList) => void handleFileUpload(fileList)}
onRemove={() => void handleFileRemove()}
/>
</TableCell>
</TableRow>
</TableHeader>
</Table>
</div>
);
}
return (
<>
{hasEnvSection ? (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
<div className="space-y-6">
{envRvwList.length > 0 ? <TechReviewOpinionTable items={envRvwList} /> : null}
{renderOpinionForm("ENV")}
</div>
</SectionCollapsible>
) : null}
{hasKecoSection ? (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
<div className="space-y-6">
{kecoRvwList.length > 0 ? <TechReviewOpinionTable items={kecoRvwList} /> : null}
{/*
재원협의에서 환경공단 검토의견은 자동 생성
{renderOpinionForm("KECO")}
*/}
</div>
</SectionCollapsible>
) : null}
</>
);
});
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
return (
<div className="overflow-x-auto">
<Table variant="secondary">
<colgroup>
<col width="10%" />
<col width="55%" />
<col width="5%" />
<col width="20%" />
<col width="10%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>의견자명</TableHead>
<TableHead>의견내용</TableHead>
<TableHead>검토결과</TableHead>
<TableHead>첨부파일</TableHead>
<TableHead>등록일시</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => (
<TableRow key={`${item.fnrsCnsltnDocNo}_${item.rvwOpnnSn}`}>
<TableCell align="center">{item.opnrNm}</TableCell>
<TableCell align="left">{item.rvwRsltCn || "-"}</TableCell>
<TableCell align="center">
{item.rvwRsltAnmlyYn === "Y" ? "이상" : item.rvwRsltAnmlyYn === "N" ? "정상" : "-"}
</TableCell>
<TableCell align="center">
{item.atchFileInfo?.atflCd ? (
<AttachmentDownloadLink
atflCd={item.atchFileInfo.atflCd}
fileName={item.atchFileInfo.orgnlFileNm || "다운로드"}
/>
) : (
"-"
)}
</TableCell>
<TableCell align="center">
{item.regDt ? dayjs(item.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
export { TechReviewOpinionSection };
"use client";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { getDefaultFileKey, useTus, useUserStore } from "@/shared/lib";
import { AttachmentFileField, SectionCollapsible } from "@/shared/ui";
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
import { attachService, useDownloadAttachmentByAtflId } from "@/entities/attach";
import {
fnrsCnsltnKeys,
type FnrsCnsltnRvwOpnnResponseDto,
fnrsCnsltnRvwOpnnService,
} from "@/entities/fnrs-cnsltn";
import { RvwOpnnListTable } from "@/widgets/pbadms-sprt";
import {
RadioGroup,
RadioGroupItem,
Table,
TableCell,
TableHead,
TableHeader,
TableRow,
Textarea,
} from "@common/components";
import type {
ReviewOpinionFormData,
ReviewOpinionGetFormDataResult,
} from "../../lib/review-opinion-utils";
import type { ReviewStepCode } from "../../model/view-types";
import { REVIEW_STEP_LABELS } from "../../model/view-types";
const RVW_OPNN_FILE_KEY_PREFIX = "rvw-opnn::";
interface TechReviewOpinionSectionProps {
rvwList: FnrsCnsltnRvwOpnnResponseDto[];
editableReviewStep?: ReviewStepCode;
atrzSeComCdList?: string[];
isAtrzLoading?: boolean;
}
export interface TechReviewOpinionSectionHandle {
getFormData: () => ReviewOpinionGetFormDataResult | null;
resetForm: () => void;
}
interface TechReviewOpinionTableProps {
items: FnrsCnsltnRvwOpnnResponseDto[];
}
interface UploadedOpinionFile {
fileName: string;
fileKey: string;
atflId?: string;
atflCd?: string;
}
const EMPTY_FORM_DATA: ReviewOpinionFormData = {
rvwRsltCn: "",
rvwRsltAnmlyYn: "",
atchFileGroupId: "",
opnrSeComCd: "",
};
function createRvwOpnnUploadKey(file: File) {
return `${RVW_OPNN_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
}
function hasFormContent(formData: ReviewOpinionFormData) {
return Boolean(formData.rvwRsltCn.trim() || formData.rvwRsltAnmlyYn || formData.atchFileGroupId);
}
const TechReviewOpinionSection = forwardRef<
TechReviewOpinionSectionHandle,
TechReviewOpinionSectionProps
>(function TechReviewOpinionSection(
{ rvwList, editableReviewStep, atrzSeComCdList = [], isAtrzLoading = false },
ref,
) {
const canWriteKecoOpinion = !isAtrzLoading && atrzSeComCdList.includes("ATRZ02");
const [formData, setFormData] = useState<ReviewOpinionFormData>({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
const [uploadedFile, setUploadedFile] = useState<UploadedOpinionFile | null>(null);
const { mutate: downloadByAtflId } = useDownloadAttachmentByAtflId();
useEffect(() => {
setFormData((prev) => ({
...prev,
opnrSeComCd: editableReviewStep ?? "",
}));
}, [editableReviewStep]);
const { uploadFileAsync, isUploading, uploads } = useTus({
onSuccess: (atflCd, atflId, _file, fileKey) => {
if (!atflCd || !atflId) return;
setFormData((prev) => ({ ...prev, atchFileGroupId: atflCd }));
setUploadedFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
},
onError: (_error, _file, fileKey) => {
setUploadedFile((prev) => (prev?.fileKey === fileKey ? null : prev));
},
});
useImperativeHandle(
ref,
() => ({
getFormData: () => {
if (!editableReviewStep || !hasFormContent(formData)) {
return null;
}
if (editableReviewStep === "KECO" && !canWriteKecoOpinion) {
return null;
}
return {
formData: {
...formData,
opnrSeComCd: editableReviewStep,
},
rvwList,
};
},
resetForm: () => {
setFormData({
...EMPTY_FORM_DATA,
opnrSeComCd: editableReviewStep ?? "",
});
setUploadedFile(null);
},
}),
[canWriteKecoOpinion, editableReviewStep, formData, rvwList],
);
const envRvwList = rvwList.filter((item) => item.opnrSeComCd === "ENV");
const kecoRvwList = rvwList.filter((item) => item.opnrSeComCd === "KECO");
const hasEnvSection = envRvwList.length > 0 || editableReviewStep === "ENV";
const hasKecoSection = kecoRvwList.length > 0 || editableReviewStep === "KECO";
if (!hasEnvSection && !hasKecoSection) {
return null;
}
async function handleFileUpload(files: FileList | null) {
if (!files || files.length === 0) return;
const file = files[0];
const fileKey = createRvwOpnnUploadKey(file);
const existingAtflCd = formData.atchFileGroupId;
setUploadedFile({ fileName: file.name, fileKey });
try {
await uploadFileAsync(file, {
fileKey,
metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
});
} catch {
// onError callback handles cleanup
}
}
async function handleFileRemove() {
if (!uploadedFile) return;
uploads[uploadedFile.fileKey]?.abort();
if (uploadedFile.atflId) {
try {
await attachService.deleteAttachmentByAtflId(uploadedFile.atflId);
} catch (error) {
console.error("검토의견 첨부파일 삭제 실패:", error);
}
}
setUploadedFile(null);
setFormData((prev) => ({ ...prev, atchFileGroupId: "" }));
}
function isReviewTurn(reviewStep: ReviewStepCode) {
if (editableReviewStep !== reviewStep) return false;
if (reviewStep === "KECO" && !canWriteKecoOpinion) return false;
return true;
}
function renderOpinionForm(reviewStep: ReviewStepCode) {
if (!isReviewTurn(reviewStep)) return null;
const uploading = uploadedFile ? (isUploading[uploadedFile.fileKey] ?? false) : false;
return (
<div className="space-y-2">
<h4 className="text-xl font-semibold">검토의견 작성</h4>
<Table
caption="검토의견 작성으로 의견내용, 검토결과, 첨부파일 정보 제공"
variant="secondary"
>
<TableHeader>
<TableRow>
<TableHead className="w-[12rem]">의견내용</TableHead>
<TableCell>
<Textarea
value={formData.rvwRsltCn}
onChange={(event) =>
setFormData((prev) => ({ ...prev, rvwRsltCn: event.target.value }))
}
placeholder="의견내용을 입력하세요"
rows={4}
className="min-h-[8rem] !text-xl"
/>
</TableCell>
</TableRow>
<TableRow>
<TableHead>검토결과</TableHead>
<TableCell>
<FieldSet>
<RadioGroup
value={formData.rvwRsltAnmlyYn}
onValueChange={(value) =>
setFormData((prev) => ({
...prev,
rvwRsltAnmlyYn: value as ReviewOpinionFormData["rvwRsltAnmlyYn"],
}))
}
>
<div className="flex flex-wrap gap-4">
<FieldLabel
htmlFor={`rvw-opnn-normal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="N" id={`rvw-opnn-normal-${reviewStep}`} />
<span className="font-normal">정상</span>
</Field>
</FieldLabel>
<FieldLabel
htmlFor={`rvw-opnn-abnormal-${reviewStep}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="Y" id={`rvw-opnn-abnormal-${reviewStep}`} />
<span className="font-normal">이상</span>
</Field>
</FieldLabel>
</div>
</RadioGroup>
</FieldSet>
</TableCell>
</TableRow>
<TableRow>
<TableHead>첨부파일</TableHead>
<TableCell>
<AttachmentFileField
files={
uploadedFile
? [
{
fileKey: uploadedFile.fileKey,
fileName: uploadedFile.fileName,
onDownload: uploadedFile.atflId
? () => downloadByAtflId(uploadedFile.atflId!)
: undefined,
},
]
: []
}
inputId={`rvw-opnn-${reviewStep}-file-input`}
isUploadingByKey={isUploading}
multiple={false}
showAddButton={!uploadedFile}
addDisabled={uploading}
onAdd={(fileList) => void handleFileUpload(fileList)}
onRemove={() => void handleFileRemove()}
/>
</TableCell>
</TableRow>
</TableHeader>
</Table>
</div>
);
}
return (
<>
{hasEnvSection ? (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.ENV} 검토의견`}>
<div className="space-y-6">
{envRvwList.length > 0 ? (
<TechReviewOpinionTable items={envRvwList} />
) : null}
{renderOpinionForm("ENV")}
</div>
</SectionCollapsible>
) : null}
{hasKecoSection ? (
<SectionCollapsible title={`${REVIEW_STEP_LABELS.KECO} 검토의견`}>
<div className="space-y-6">
{kecoRvwList.length > 0 ? (
<TechReviewOpinionTable items={kecoRvwList} />
) : null}
{/*
재원협의에서 환경공단 검토의견은 자동 생성
{renderOpinionForm("KECO")}
*/}
</div>
</SectionCollapsible>
) : null}
</>
);
});
function getRvwOpnnIds(item: FnrsCnsltnRvwOpnnResponseDto) {
if (!item.fnrsCnsltnDocNo || item.rvwOpnnSn == null) {
throw new Error("검토의견 식별값이 없습니다.");
}
return [item.fnrsCnsltnDocNo, item.rvwOpnnSn] as const;
}
function TechReviewOpinionTable({ items }: TechReviewOpinionTableProps) {
const queryClient = useQueryClient();
const currentUserUuid = useUserStore((state) => state.userInfo?.userUnqId);
return (
<RvwOpnnListTable
items={items}
getRowKey={(item) => `${item.fnrsCnsltnDocNo}_${item.rvwOpnnSn}`}
currentUserUuid={currentUserUuid}
onUpdate={async (item, body) => {
await fnrsCnsltnRvwOpnnService.update(...getRvwOpnnIds(item), body);
await queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
}}
onDelete={async (item) => {
await fnrsCnsltnRvwOpnnService.delete(...getRvwOpnnIds(item));
await queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
}}
/>
);
}
export { TechReviewOpinionSection };
@@ -3,7 +3,6 @@ export const INSTL_SNCN_VIEW_BUTTON_ACTION = {
aply: "aply",
del: "del",
instlSncnAncmnt: "instlSncnAncmnt",
envoSbmsn: "envoSbmsn",
rjct: "rjct",
sncnAprv: "sncnAprv",
rvwRply: "rvwRply",
@@ -33,14 +32,16 @@ const ROLE_STATUS_BUTTON_ACTIONS: Record<string, Record<string, InstlSncnViewBut
],
TA: [INSTL_SNCN_VIEW_BUTTON_ACTION.instlSncnAncmnt],
},
ROLE_USER04: {
ST: [INSTL_SNCN_VIEW_BUTTON_ACTION.envoSbmsn, INSTL_SNCN_VIEW_BUTTON_ACTION.rjct],
EP: [INSTL_SNCN_VIEW_BUTTON_ACTION.sncnAprv],
},
ROLE_USER03: {
LE: [INSTL_SNCN_VIEW_BUTTON_ACTION.rvwRply, INSTL_SNCN_VIEW_BUTTON_ACTION.rqstTechRvw],
TP: [INSTL_SNCN_VIEW_BUTTON_ACTION.rvwRply],
},
// 환경청 단계 제거: 기존 환경청(LE·TP) 기능을 광역자치단체가 수행
ROLE_USER04: {
ST: [
INSTL_SNCN_VIEW_BUTTON_ACTION.rvwRply,
INSTL_SNCN_VIEW_BUTTON_ACTION.rqstTechRvw,
INSTL_SNCN_VIEW_BUTTON_ACTION.rjct,
],
TP: [INSTL_SNCN_VIEW_BUTTON_ACTION.rvwRply],
EP: [INSTL_SNCN_VIEW_BUTTON_ACTION.sncnAprv],
},
};
const ROLE_USER01_ATRZ_STATUS_CODES = ["ER", "TE"] as const;
@@ -55,16 +55,8 @@ export const instlSncnRegistSchema = z.object({
tstmDesignAmt: z.number(),
}),
),
cklstResultList: z.array(
instlSncnCklstResultItemSchema.extend({
cklstSn: z.number(),
cklstArtclSn: z.number(),
cklstRvwSn: z.number(),
rvwStpComCd: z.string(),
selected: z.enum(["Y", "N"]).nullish(),
picRvwCn: z.string().nullish(),
}),
),
// 설치인가 화면에서 체크리스트 미사용: 숨겨진 값 때문에 신청·저장 검증이 막히지 않도록 필수값을 두지 않음
cklstResultList: z.array(instlSncnCklstResultItemSchema),
esntlAtchFileList: z.array(
instlSncnEsntlAtchFileItemSchema.extend({
esntlAtchFileId: z.string(),
@@ -50,7 +50,7 @@ export function getEditableReviewStep(
dtlInstCd?: string,
prgrsSttsComCd?: string,
): ReviewStepCode | undefined {
if (dtlInstCd === "ROLE_USER03" && (prgrsSttsComCd === "LE" || prgrsSttsComCd === "TP")) {
if (dtlInstCd === "ROLE_USER04" && (prgrsSttsComCd === "ST" || prgrsSttsComCd === "TP")) {
return "ENV";
}
+231 -204
View File
@@ -1,204 +1,231 @@
"use client";
import { Fragment } from "react";
import { createColumnHelper } from "@tanstack/react-table";
import { dayjs } from "@/shared/lib";
import type { InstlSncnMngResponseDto } from "@/entities/instl-sncn";
import { Badge } from "@common/components";
const columnHelper = createColumnHelper<InstlSncnMngResponseDto>();
const MAIN_STEP_LABELS = {
apply: "설치인가 신청",
envoReview: "설치인가 검토",
techReview: "기술검토",
reply: "검토결과 회신",
} as const;
export const PROGRESS_STATUS_COLUMNS = [
{ label: "설치인가 신청 (시군)", sttsComCd: "ST" },
{ label: "설치인가 신청 (시도)", sttsComCd: "LE" },
{ label: "설치인가 검토", sttsComCd: "ER" },
{ label: "기술검토", sttsComCd: "TE" },
{ label: "기술검토 회신", sttsComCd: "TP" },
{ label: "검토결과 회신", sttsComCd: "EP" },
{ label: "설치인가 승인", sttsComCd: "TA" },
];
type BadgeState = "completed" | "current" | "pending";
interface ProgressBadge {
label: string;
state: BadgeState;
}
function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N"): ProgressBadge[] {
if (prgrsSttsComCd === "SV") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "ST") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "current" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "LE") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "current" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "ER" || prgrsSttsComCd === "TE" || prgrsSttsComCd === "SL") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{ label: MAIN_STEP_LABELS.techReview, state: "current" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "TP" || prgrsSttsComCd === "EP") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{
label: MAIN_STEP_LABELS.techReview,
state: techRplyHstryYn === "Y" ? "completed" : "pending",
},
{ label: MAIN_STEP_LABELS.reply, state: "current" },
];
}
if (prgrsSttsComCd === "TA" || prgrsSttsComCd === "TB") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{
label: MAIN_STEP_LABELS.techReview,
state: techRplyHstryYn === "Y" ? "completed" : "pending",
},
{ label: MAIN_STEP_LABELS.reply, state: "completed" },
];
}
return [
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
if (prgrsSttsComCd === "SL") {
return <Badge color="orange">보완요청</Badge>;
}
return null;
}
function getBadgeColor(state: BadgeState) {
switch (state) {
case "completed":
return "green";
case "current":
return "blue";
case "pending":
return "lightGray";
}
}
function formatOptional(value: string | null | undefined) {
return value?.trim() ? value : "-";
}
interface ProgressStepBadgeProps {
prgrsSttsComCd: string;
techRplyHstryYn: "Y" | "N";
}
function ProgressStepBadge({ prgrsSttsComCd, techRplyHstryYn }: ProgressStepBadgeProps) {
const badges = buildProgressBadges(prgrsSttsComCd, techRplyHstryYn);
return (
<div className="flex flex-wrap items-center justify-start">
{badges.map((badge, index) => (
<Fragment key={badge.label}>
{index > 0 && <span className="mx-2">&gt;</span>}
<Badge color={getBadgeColor(badge.state)}>{badge.label}</Badge>
</Fragment>
))}
</div>
);
}
export function createInstlSncnColumns() {
return [
columnHelper.accessor("sido", {
header: "시도",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("gugun", {
header: "시군구",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("bizNm", {
header: "사업명",
meta: { align: "left" },
cell: ({ row, getValue }) => (
<>
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
</>
),
}),
columnHelper.accessor("taskSeCdNm", {
header: "업무구분",
meta: { align: "center" },
cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd),
}),
columnHelper.accessor("aplcntNm", {
header: "담당자",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("envoSeCdNm", {
header: "해당기관",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.display({
id: "progress",
header: "진행상황",
meta: { align: "center" },
cell: ({ row }) => (
<ProgressStepBadge
prgrsSttsComCd={row.original.prgrsSttsComCd || ""}
techRplyHstryYn={row.original.techRplyHstryYn || "N"}
/>
),
}),
columnHelper.accessor("regDt", {
header: "신청일",
meta: { align: "center" },
cell: ({ getValue }) => {
const value = getValue();
return value ? dayjs(value).format("YYYY-MM-DD") : "-";
},
}),
];
}
"use client";
import { Fragment } from "react";
import { createColumnHelper } from "@tanstack/react-table";
import { dayjs } from "@/shared/lib";
import type { InstlSncnMngResponseDto } from "@/entities/instl-sncn";
import { Badge } from "@common/components";
const columnHelper = createColumnHelper<InstlSncnMngResponseDto>();
const MAIN_STEP_LABELS = {
apply: "설치인가 신청",
envoReview: "설치인가 검토",
techReview: "기술검토",
reply: "검토결과 회신",
} as const;
export const PROGRESS_STATUS_COLUMNS = [
{ label: "설치인가 신청 (시군)", sttsComCd: "ST" },
{ label: "설치인가 신청 (시도)", sttsComCd: "LE" },
{ label: "설치인가 검토", sttsComCd: "ER" },
{ label: "기술검토", sttsComCd: "TE" },
{ label: "기술검토 회신", sttsComCd: "TP" },
{ label: "검토결과 회신", sttsComCd: "EP" },
{ label: "설치인가 승인", sttsComCd: "TA" },
];
type BadgeState = "completed" | "current" | "pending";
interface ProgressBadge {
label: string;
state: BadgeState;
}
function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N"): ProgressBadge[] {
if (prgrsSttsComCd === "SV") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "ST") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "current" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "LE") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "current" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "ER" || prgrsSttsComCd === "TE" || prgrsSttsComCd === "SL") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{ label: MAIN_STEP_LABELS.techReview, state: "current" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
if (prgrsSttsComCd === "TP" || prgrsSttsComCd === "EP") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{
label: MAIN_STEP_LABELS.techReview,
state: techRplyHstryYn === "Y" ? "completed" : "pending",
},
{ label: MAIN_STEP_LABELS.reply, state: "current" },
];
}
if (prgrsSttsComCd === "TA" || prgrsSttsComCd === "TB") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.envoReview, state: "completed" },
{
label: MAIN_STEP_LABELS.techReview,
state: techRplyHstryYn === "Y" ? "completed" : "pending",
},
{ label: MAIN_STEP_LABELS.reply, state: "completed" },
];
}
return [
{ label: MAIN_STEP_LABELS.apply, state: "pending" },
{ label: MAIN_STEP_LABELS.envoReview, state: "pending" },
{ label: MAIN_STEP_LABELS.techReview, state: "pending" },
{ label: MAIN_STEP_LABELS.reply, state: "pending" },
];
}
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
if (prgrsSttsComCd === "SL") {
return <Badge color="orange">보완요청</Badge>;
}
return null;
}
function getBadgeColor(state: BadgeState) {
switch (state) {
case "completed":
return "green";
case "current":
return "blue";
case "pending":
return "lightGray";
}
}
function getBadgeStateText(state: BadgeState) {
switch (state) {
case "completed":
return "완료";
case "current":
return "진행중";
case "pending":
return "대기";
}
}
/** 단계명을 2줄로 분리: "설치인가 신청" → ["설치인가", "신청"], "기술검토" → ["기술검토", ""] */
function splitStepLabel(label: string) {
const [title = "", ...rest] = label.split(" ");
return { title, detail: rest.join(" ") };
}
function formatOptional(value: string | null | undefined) {
return value?.trim() ? value : "-";
}
interface ProgressStepBadgeProps {
prgrsSttsComCd: string;
techRplyHstryYn: "Y" | "N";
}
function ProgressStepBadge({ prgrsSttsComCd, techRplyHstryYn }: ProgressStepBadgeProps) {
const badges = buildProgressBadges(prgrsSttsComCd, techRplyHstryYn);
return (
<div className="flex flex-wrap items-center justify-center">
{badges.map((badge, index) => (
<Fragment key={badge.label}>
{index > 0 && <span className="mx-2">&gt;</span>}
{/* 1줄: 단계명 / 2줄: 세부단계 + 상태 (예: 설치인가 / 신청완료, 기술검토 / 완료) */}
<Badge
color={getBadgeColor(badge.state)}
className="w-[100px] flex-col justify-center !px-0 py-1 text-center leading-tight"
>
<span>{splitStepLabel(badge.label).title}</span>
<span>
{splitStepLabel(badge.label).detail}
{getBadgeStateText(badge.state)}
</span>
</Badge>
</Fragment>
))}
</div>
);
}
export function createInstlSncnColumns() {
return [
columnHelper.accessor("sido", {
header: "시도",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("gugun", {
header: "시군구",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("bizNm", {
header: "사업명",
meta: { align: "left" },
cell: ({ row, getValue }) => (
<>
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
</>
),
}),
columnHelper.accessor("taskSeCdNm", {
header: "업무구분",
meta: { align: "center" },
cell: ({ row }) => formatOptional(row.original.taskSeCdNm || row.original.taskSeComCd),
}),
columnHelper.accessor("aplcntNm", {
header: "담당자",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("envoSeCdNm", {
header: "해당기관",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.display({
id: "progress",
header: "진행상황",
meta: { align: "center" },
cell: ({ row }) => (
<ProgressStepBadge
prgrsSttsComCd={row.original.prgrsSttsComCd || ""}
techRplyHstryYn={row.original.techRplyHstryYn || "N"}
/>
),
}),
columnHelper.accessor("regDt", {
header: "신청일",
meta: { align: "center" },
cell: ({ getValue }) => {
const value = getValue();
return value ? dayjs(value).format("YYYY-MM-DD") : "-";
},
}),
];
}
@@ -50,13 +50,13 @@ export const REVIEW_STEP_CODES: ReviewStepCode[] = ["LOCAL", "ENV", "KECO"];
export const CHECKLIST_TAB_LABELS: Record<ReviewStepCode, string> = {
LOCAL: "자가체크",
ENV: "환경청검토",
ENV: "광역자치단체검토",
KECO: "기술검토",
};
export const REVIEW_STEP_LABELS: Record<ReviewStepCode, string> = {
LOCAL: "지자체",
ENV: "환경청",
ENV: "광역자치단체",
KECO: "환경공단",
};
@@ -89,7 +89,7 @@ export const INSTL_PROGRESS_ORDER = [
export const ROLE_TO_REVIEW_STEP: Record<string, ReviewStepCode> = {
ROLE_USER05: "LOCAL",
ROLE_USER03: "ENV",
ROLE_USER04: "ENV",
ROLE_USER01: "KECO",
};
@@ -52,7 +52,7 @@ function ChecklistTaskPanel({
return (
<>
<div className="overflow-x-auto">
<Table variant="secondary">
<Table caption={`체크리스트 목록으로 검토항목, 주요 검토내용, 검토 결과${selectedReviewStep !== "LOCAL" ? ", 기술검토 의견" : ""} 정보 제공`} variant="secondary">
<TableHeader>
<TableRow>
<TableHead className="w-[20rem]" rowSpan={2}>
@@ -35,7 +35,6 @@ import {
type InstlSncnViewButtonAction,
needsRoleUser01AtrzResolve,
} from "../../lib/button-config";
import { computeReviewStepChecklistProgress } from "../../lib/checklist-utils";
import {
buildSavePayload,
buildSupplementReqPayload,
@@ -179,11 +178,6 @@ function FloatingActionButtons({
}
async function handleAplyClick() {
const { percent } = computeReviewStepChecklistProgress(
data.cklstResultList ?? [],
checklistFormState.results,
"LOCAL",
);
const payload: InstlSncnApplySaveRequestDto = buildSavePayload(
data,
checklistFormState,
@@ -191,11 +185,6 @@ function FloatingActionButtons({
"ST",
);
if (percent !== 100) {
alert("모든 체크리스트 항목을 체크해야 신청이 가능합니다.");
return;
}
await executeAction({
action: INSTL_SNCN_VIEW_BUTTON_ACTION.aply,
request: payload,
@@ -231,22 +220,6 @@ function FloatingActionButtons({
});
}
async function handleEnvoSbmsnClick() {
const payload: DocPrgrsUpdateRequestDto = {
docNo,
docSeComCd,
prgrsSttsComCd: "LE",
};
await executeAction({
action: INSTL_SNCN_VIEW_BUTTON_ACTION.envoSbmsn,
request: payload,
successMessage: "제출되었습니다.",
});
queryClient.invalidateQueries({
queryKey: instlSncnKeys.list(),
});
}
async function handleRjctClick() {
const payload: DocPrgrsUpdateRequestDto = {
docNo,
@@ -284,16 +257,6 @@ function FloatingActionButtons({
}
async function handleRvwRplyClick() {
const { percent } = computeReviewStepChecklistProgress(
data.cklstResultList ?? [],
checklistFormState.results,
"ENV",
);
if (percent !== 100) {
alert("모든 체크리스트 항목을 체크해야 검토결과 회신이 가능합니다.");
return;
}
const payload = buildTechReviewReplyPayload(
data,
@@ -369,16 +332,6 @@ function FloatingActionButtons({
}
async function handleRvwCmptnClick() {
const { percent } = computeReviewStepChecklistProgress(
data.cklstResultList ?? [],
checklistFormState.results,
"KECO",
);
if (percent !== 100) {
alert("모든 체크리스트 항목을 체크해야 검토완료가 가능합니다.");
return;
}
const payload = {
...buildTechReviewReplyPayload(
@@ -447,11 +400,6 @@ function FloatingActionButtons({
설치인가 고시
</Button>
)}
{shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.envoSbmsn) && (
<Button onClick={handleEnvoSbmsnClick} disabled={isProcessing}>
환경청제출
</Button>
)}
{shouldShowButton(INSTL_SNCN_VIEW_BUTTON_ACTION.rjct) && (
<Button color="red" onClick={handleRjctClick} disabled={isProcessing}>
반려
@@ -618,7 +566,6 @@ const buttonActionHandlers: Record<InstlSncnViewButtonAction, ActionHandler> = {
aply: handleSaveApply,
del: handleDel,
instlSncnAncmnt: handleChangePrgrsSttsComCd,
envoSbmsn: handleChangePrgrsSttsComCd,
rjct: handleChangePrgrsSttsComCd,
sncnAprv: handleChangePrgrsSttsComCd,
rvwRply: handleTechReview,
@@ -20,9 +20,7 @@ import {
AttachmentsFormSection,
BusinessCostFormSection,
BusinessScaleFormSection,
ChecklistFormSection,
ContactInfoFormSection,
isFormChecklistComplete,
isFormEsntlAtchComplete,
PrivacyConsentSection,
ProgressStatusSection,
@@ -154,11 +152,6 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
return;
}
if (!isFormChecklistComplete(data.cklstResultList)) {
alert("모든 체크리스트 항목을 체크해야 신청이 가능합니다.");
return;
}
if (!isFormEsntlAtchComplete(essentialFiles, data.esntlAtchFileList)) {
alert("필수 첨부파일을 모두 등록해야 신청이 가능합니다.");
return;
@@ -209,8 +202,11 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSave)} className="mt-4">
<SectionCollapsibleGroup>
<div className="sticky top-[6rem] z-30 bg-container px-4 py-2">
<div className="sticky top-[6rem] z-30 flex items-center justify-between bg-container px-4 py-2">
<SectionCollapsibleToggle />
<p className="text-sm text-muted-foreground">
<span className="text-red-600">*</span>은 필수입력 항목입니다.
</p>
</div>
<div className="space-y-4">
{initialData?.detDto.instlSncnDocNo && (
@@ -225,7 +221,6 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
<PrivacyConsentSection />
<BusinessScaleFormSection />
<BusinessCostFormSection />
<ChecklistFormSection docSeComCd="INSTL" fieldPrefix="instlSncnMng" />
<AttachmentsFormSection
docSeComCd="INSTL"
fieldPrefix="instlSncnMng"
@@ -77,8 +77,8 @@ function InstlListPage() {
};
return (
<div className="space-y-4 px-[1.8rem] pb-6">
<section className="card card-border mx-0 bg-background">
<div className="card card-border !mt-6">
<div className="card card-border">
<form className="flex flex-wrap items-center gap-4 p-2" onSubmit={handleSearch}>
<div className="flex items-center gap-2">
<Label htmlFor="sidoComCd" className="whitespace-nowrap">
@@ -145,7 +145,7 @@ function InstlListPage() {
</div>
<Button type="submit">조회</Button>
</form>
</section>
</div>
<InstlList
searchParams={searchParams}
@@ -59,6 +59,7 @@ function InstlList({ searchParams, pagination, onPaginationChange }: InstlListPr
manualPagination: true,
},
dataTableOptions: {
caption: "설치인가 목록으로 시도, 시군구, 사업명, 업무구분, 담당자, 해당기관, 진행상황, 신청일 정보 제공",
viewPagination: true,
onRowClick: handleRowClick,
},
@@ -81,15 +82,15 @@ function InstlList({ searchParams, pagination, onPaginationChange }: InstlListPr
return (
<>
<section className="card card-border mx-0 bg-background pb-8 pt-0">
<div className="card card-border pt-0">
<div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-semibold">설치인가 목록</h3>
<h3 className="text-2xl font-medium text-label">설치인가 목록</h3>
<Button type="button" color="green" onClick={handleRegisterClick}>
신청
</Button>
</div>
{tableComponent}
</section>
<div className="card !m-[1.2rem] !p-0">{tableComponent}</div>
</div>
<PbadmsTaskApplyDialog
open={isTaskSeDialogOpen}
onOpenChange={setIsTaskSeDialogOpen}
@@ -1,149 +1,141 @@
"use client";
import { useMemo, useRef } from "react";
import { useUserStore } from "@/shared/lib";
import { SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
import { ElaprDocPrgrsStatus } from "@/entities/elapr";
import { useInstlSncnDetail } from "@/entities/instl-sncn";
import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
import {
AttachmentsViewSection,
ProgressStatusSection,
SupplementDemandSection,
} from "@/widgets/pbadms-sprt";
import { Skeleton } from "@common/components";
import { needsRoleUser01AtrzResolve } from "../lib/button-config";
import { getEditableReviewStep } from "../lib/review-step-utils";
import { EMPTY_CKLST_RESULT_LIST, useChecklistFormState } from "../lib/use-checklist-form-state";
import { PROGRESS_STATUS_COLUMNS } from "../model/columns";
import { FloatingActionButtons } from "./components/floating-action-buttons";
import { BasicInfoSection } from "./sections/basic-info-section";
import { BusinessCostSection } from "./sections/business-cost-section";
import { BusinessScaleSection } from "./sections/business-scale-section";
import { ChecklistSection } from "./sections/checklist-section";
import { ContactInfoSection } from "./sections/contact-info-section";
import {
TechReviewOpinionSection,
type TechReviewOpinionSectionHandle,
} from "./sections/tech-review-opinion-section";
interface InstlViewPageProps {
id: string;
}
function InstlViewPage({ id }: InstlViewPageProps) {
const userInfo = useUserStore((state) => state.userInfo);
const techReviewOpinionRef = useRef<TechReviewOpinionSectionHandle>(null);
const { data, isLoading, isError } = useInstlSncnDetail(id);
const { formState: checklistFormState, setFormState: setChecklistFormState } =
useChecklistFormState(data?.cklstResultList ?? EMPTY_CKLST_RESULT_LIST);
const prgrsSttsComCd = data?.detDto?.prgrsSttsComCd || "";
const needsAtrzResolve = needsRoleUser01AtrzResolve(userInfo?.dtlInstCd, prgrsSttsComCd);
const { data: rvwLoginUserData, isLoading: isRvwLoginUserLoading } = useRvwMnpwMngLoginUserList(
{},
{ enabled: needsAtrzResolve },
);
const atrzSeComCdList = useMemo(
() => rvwLoginUserData?.content?.map((item) => item.atrzSeComCd) ?? [],
[rvwLoginUserData],
);
if (isLoading) {
return (
<div className="mt-6 flex flex-col gap-4 px-6">
<div className="flex flex-col gap-2">
<Skeleton className="h-[4rem] rounded-xl" />
<Skeleton className="h-[16rem] rounded-xl" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-[4rem] rounded-xl" />
<Skeleton className="h-[16rem] rounded-xl" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-[4rem] rounded-xl" />
<Skeleton className="h-[32rem] rounded-xl" />
</div>
</div>
);
}
if (isError || !data) {
return <div className="mt-6 text-[1.4rem]">상세 정보를 찾을 수 없습니다.</div>;
}
const {
detDto,
bizSclList,
bizCstList,
cklstResultList = [],
esntlFileList,
etcFileList,
rvwList = [],
splmntDmndList = [],
sttsChgHstryList = [],
} = data;
const editableReviewStep = getEditableReviewStep(
userInfo?.dtlInstCd,
detDto.prgrsSttsComCd || "",
);
return (
<div className="mt-4">
<SectionCollapsibleGroup>
<div className="sticky top-[6rem] z-30 bg-container px-4 py-2">
<SectionCollapsibleToggle />
</div>
<div className="space-y-4">
<ProgressStatusSection
columns={PROGRESS_STATUS_COLUMNS}
sttsChgHstryList={sttsChgHstryList}
elaprDocNo={detDto.instlSncnDocNo}
/>
<BasicInfoSection det={detDto} />
<ContactInfoSection det={detDto} />
<ElaprDocPrgrsStatus elaprDocNo={detDto.instlSncnDocNo} />
<BusinessScaleSection bizSclList={bizSclList} />
<BusinessCostSection bizCstList={bizCstList} />
<ChecklistSection
cklstResultList={cklstResultList}
prgrsSttsComCd={detDto.prgrsSttsComCd || ""}
userRoleCode={userInfo?.dtlInstCd}
formState={checklistFormState}
setFormState={setChecklistFormState}
/>
<AttachmentsViewSection
esntlFileList={esntlFileList ?? []}
etcFileList={etcFileList ?? []}
/>
<TechReviewOpinionSection
ref={techReviewOpinionRef}
rvwList={rvwList}
editableReviewStep={editableReviewStep}
atrzSeComCdList={atrzSeComCdList}
isAtrzLoading={needsAtrzResolve && isRvwLoginUserLoading}
/>
<SupplementDemandSection splmntDmndList={splmntDmndList} />
<FloatingActionButtons
data={data}
docSeComCd="INSTL"
instlSncnDocNo={detDto.instlSncnDocNo}
prgrsSttsComCd={detDto.prgrsSttsComCd || ""}
dtlInstCd={userInfo?.dtlInstCd}
atrzSeComCdList={atrzSeComCdList}
isRvwLoginUserLoading={needsAtrzResolve && isRvwLoginUserLoading}
checklistFormState={checklistFormState}
techReviewOpinionRef={techReviewOpinionRef}
opnrNm={userInfo?.userName}
/>
</div>
</SectionCollapsibleGroup>
</div>
);
}
export { InstlViewPage };
"use client";
import { useMemo, useRef } from "react";
import { useUserStore } from "@/shared/lib";
import { SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
import { ElaprDocPrgrsStatus } from "@/entities/elapr";
import { useInstlSncnDetail } from "@/entities/instl-sncn";
import { useRvwMnpwMngLoginUserList } from "@/entities/rvw-mnpw";
import {
AttachmentsViewSection,
ProgressStatusSection,
SupplementDemandSection,
} from "@/widgets/pbadms-sprt";
import { Skeleton } from "@common/components";
import { needsRoleUser01AtrzResolve } from "../lib/button-config";
import { getEditableReviewStep } from "../lib/review-step-utils";
import { EMPTY_CKLST_RESULT_LIST, useChecklistFormState } from "../lib/use-checklist-form-state";
import { PROGRESS_STATUS_COLUMNS } from "../model/columns";
import { FloatingActionButtons } from "./components/floating-action-buttons";
import { BasicInfoSection } from "./sections/basic-info-section";
import { BusinessCostSection } from "./sections/business-cost-section";
import { BusinessScaleSection } from "./sections/business-scale-section";
import { ContactInfoSection } from "./sections/contact-info-section";
import {
TechReviewOpinionSection,
type TechReviewOpinionSectionHandle,
} from "./sections/tech-review-opinion-section";
interface InstlViewPageProps {
id: string;
}
function InstlViewPage({ id }: InstlViewPageProps) {
const userInfo = useUserStore((state) => state.userInfo);
const techReviewOpinionRef = useRef<TechReviewOpinionSectionHandle>(null);
const { data, isLoading, isError } = useInstlSncnDetail(id);
const { formState: checklistFormState } = useChecklistFormState(
data?.cklstResultList ?? EMPTY_CKLST_RESULT_LIST,
);
const prgrsSttsComCd = data?.detDto?.prgrsSttsComCd || "";
const needsAtrzResolve = needsRoleUser01AtrzResolve(userInfo?.dtlInstCd, prgrsSttsComCd);
const { data: rvwLoginUserData, isLoading: isRvwLoginUserLoading } = useRvwMnpwMngLoginUserList(
{},
{ enabled: needsAtrzResolve },
);
const atrzSeComCdList = useMemo(
() => rvwLoginUserData?.content?.map((item) => item.atrzSeComCd) ?? [],
[rvwLoginUserData],
);
if (isLoading) {
return (
<div className="mt-6 flex flex-col gap-4 px-6">
<div className="flex flex-col gap-2">
<Skeleton className="h-[4rem] rounded-xl" />
<Skeleton className="h-[16rem] rounded-xl" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-[4rem] rounded-xl" />
<Skeleton className="h-[16rem] rounded-xl" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-[4rem] rounded-xl" />
<Skeleton className="h-[32rem] rounded-xl" />
</div>
</div>
);
}
if (isError || !data) {
return <div className="mt-6 text-[1.4rem]">상세 정보를 찾을 수 없습니다.</div>;
}
const {
detDto,
bizSclList,
bizCstList,
esntlFileList,
etcFileList,
rvwList = [],
splmntDmndList = [],
sttsChgHstryList = [],
} = data;
const editableReviewStep = getEditableReviewStep(
userInfo?.dtlInstCd,
detDto.prgrsSttsComCd || "",
);
return (
<div className="mt-4">
<SectionCollapsibleGroup>
<div className="sticky top-[6rem] z-30 bg-container px-4 py-2">
<SectionCollapsibleToggle />
</div>
<div className="space-y-4">
<ProgressStatusSection
columns={PROGRESS_STATUS_COLUMNS}
sttsChgHstryList={sttsChgHstryList}
elaprDocNo={detDto.instlSncnDocNo}
/>
<BasicInfoSection det={detDto} />
<ContactInfoSection det={detDto} />
<ElaprDocPrgrsStatus elaprDocNo={detDto.instlSncnDocNo} />
<BusinessScaleSection bizSclList={bizSclList} />
<BusinessCostSection bizCstList={bizCstList} />
<AttachmentsViewSection
esntlFileList={esntlFileList ?? []}
etcFileList={etcFileList ?? []}
/>
<TechReviewOpinionSection
ref={techReviewOpinionRef}
rvwList={rvwList}
editableReviewStep={editableReviewStep}
atrzSeComCdList={atrzSeComCdList}
isAtrzLoading={needsAtrzResolve && isRvwLoginUserLoading}
/>
<SupplementDemandSection splmntDmndList={splmntDmndList} />
<FloatingActionButtons
data={data}
docSeComCd="INSTL"
instlSncnDocNo={detDto.instlSncnDocNo}
prgrsSttsComCd={detDto.prgrsSttsComCd || ""}
dtlInstCd={userInfo?.dtlInstCd}
atrzSeComCdList={atrzSeComCdList}
isRvwLoginUserLoading={needsAtrzResolve && isRvwLoginUserLoading}
checklistFormState={checklistFormState}
techReviewOpinionRef={techReviewOpinionRef}
opnrNm={userInfo?.userName}
/>
</div>
</SectionCollapsibleGroup>
</div>
);
}
export { InstlViewPage };
@@ -8,6 +8,7 @@ import { SectionCollapsible } from "@/shared/ui";
import { Field, FieldError, FieldGroup, FieldLabel, FieldSet } from "@/shared/ui/field";
import { findCodeLabel, useCodeList } from "@/entities/code";
import { type BnsApplyData, BnsPickerDialog } from "@/widgets/bns-picker";
import { PreviousPlanLookupButton } from "@/widgets/previous-plan-lookup";
import {
Button,
Checkbox,
@@ -27,9 +28,9 @@ function BasicInfoFormSection() {
const { control, watch, setValue } = useFormContext<InstlSncnRegistFormData>();
const [isPickerOpen, setIsPickerOpen] = useState(false);
const taskSeComCd = watch("instlSncnMng.taskSeComCd");
const localGovLabel = [watch("instlSncnMng.sido"), watch("instlSncnMng.gugun")]
.filter(Boolean)
.join(" ");
const sido = watch("instlSncnMng.sido");
const gugun = watch("instlSncnMng.gugun");
const localGovLabel = [sido, gugun].filter(Boolean).join(" ");
function handleBnsSelect(data: BnsApplyData) {
setValue("instlSncnMng.bizNm", data.bizNm);
@@ -53,7 +54,8 @@ function BasicInfoFormSection() {
return (
<SectionCollapsible title="기본 정보">
<Table variant="secondary">
<PreviousPlanLookupButton sidoNm={sido} gugunNm={gugun} />
<Table caption="기본 정보 입력으로 사업명, 사업기간, 사업내용, 구분, 신청 지자체, 사업구분, 시설종류, 재이용용도 정보 제공" variant="secondary">
<colgroup>
<col width="15%" />
<col width="35%" />
@@ -62,7 +64,7 @@ function BasicInfoFormSection() {
</colgroup>
<TableHeader>
<TableRow>
<TableHead className="w-[20rem]" data-required>
<TableHead scope="row" className="w-[20rem]" data-required>
사업명
</TableHead>
<TableCell colSpan={3}>
@@ -90,7 +92,7 @@ function BasicInfoFormSection() {
</TableCell>
</TableRow>
<TableRow>
<TableHead data-required>사업기간</TableHead>
<TableHead scope="row" data-required>사업기간</TableHead>
<TableCell colSpan={3}>
<FieldGroup className="flex-row items-center gap-2">
<Controller
@@ -134,7 +136,7 @@ function BasicInfoFormSection() {
</TableCell>
</TableRow>
<TableRow>
<TableHead data-required>사업내용</TableHead>
<TableHead scope="row" data-required>사업내용</TableHead>
<TableCell colSpan={3}>
<Controller
name="instlSncnMng.bizCn"
@@ -154,13 +156,13 @@ function BasicInfoFormSection() {
</TableCell>
</TableRow>
<TableRow>
<TableHead data-required>구분</TableHead>
<TableHead scope="row" data-required>구분</TableHead>
<TableCell>{taskSeComCdLabel}</TableCell>
<TableHead>신청 지자체</TableHead>
<TableHead scope="row">신청 지자체</TableHead>
<TableCell>{localGovLabel}</TableCell>
</TableRow>
<TableRow>
<TableHead data-required>사업구분</TableHead>
<TableHead scope="row" data-required>사업구분</TableHead>
<TableCell colSpan={3}>
<Controller
name="instlSncnMng.sncnSeComCd"
@@ -202,7 +204,7 @@ function BasicInfoFormSection() {
{taskSeComCd.includes("RE") && (
<>
<TableRow>
<TableHead>시설종류</TableHead>
<TableHead scope="row">시설종류</TableHead>
<TableCell colSpan={3}>
<Controller
name="instlSncnMng.rtlztnFcltSeComCd"
@@ -242,7 +244,7 @@ function BasicInfoFormSection() {
</TableCell>
</TableRow>
<TableRow>
<TableHead>재이용용도</TableHead>
<TableHead scope="row">재이용용도</TableHead>
<TableCell colSpan={3}>
<Controller
name="instlSncnMng.rtlztnUsgSeComCd"

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