Compare commits

...
147 Commits
Author SHA1 Message Date
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
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 c5dbea334d Merge branch 'iipbs/as/dev' 2026-10-02 15:15:36 +09:00
chanii8 85f535eb5d [as] chore: 행정지원 숫자 폼 음수 입력 제한 2026-10-02 15:14:22 +09:00
chanii8 9334514c71 [as] chore: 행정지원 폼 사업기간 스키마 수정 2026-10-02 14:55:42 +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 54595fb7b6 Merge remote-tracking branch 'origin/iipbs/st/dev' 2026-10-02 13:07:41 +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
chanii8 abe5fae4c4 Merge branch 'iipbs/as/dev' 2026-10-02 13:03:56 +09:00
chanii8 aeb48661b1 [as] chore: 보완 요청 사항 적용
- 사업기간 폼 포매팅
2026-10-02 13:03:24 +09:00
jcchoi 4bdf68c2b6 좌측메뉴 탭 이동 처리 2026-10-02 11:39:49 +09:00
chanii8 c5a0e378fd [as] feat: 보완 요청 사항 적용
- 실무협의서 내 보완요청 버튼 추가
- 대시보드 수정
2026-10-02 11:37:45 +09:00
jcchoi a323c42acf 테이블에 scope="col" 및 캡션 추가
Deploy Frontend / deploy (push) Successful in 4m16s
2026-10-01 22:15:11 +09:00
jcchoi 095101ad2f 필수항목 tooltip 2026-10-01 22:14:28 +09:00
jcchoi 7ee5ebd506 글자 확대 축소 2026-10-01 20:26:45 +09:00
jcchoi f90ef6297d 상단메뉴 클릭 표준 2026-10-01 18:03:03 +09:00
chanii8 4167107a91 [as] feat: 파일 업로드 에러 로직 수정 2026-10-01 16:41:10 +09:00
kiminchan 90c1cb1f52 Merge branch 'iipbs/sn/dev' 2026-10-01 16:40:10 +09:00
mind-one001 8c614b84a7 merge(sn): preserve modular map entry with remote import cleanup 2026-10-01 16:22:02 +09:00
kiminchan 7d1a501ad8 관망도 페이지 import 병합 충돌 해소 2026-10-01 16:03:12 +09:00
mind-one001 48589274c8 merge(sn): integrate latest survey layers and upload guidance 2026-10-01 15:59:43 +09:00
kiminchan 83f7676db4 조사 레이어 표출 및 조사결과 업로드 안내 개선 2026-10-01 15:55:29 +09:00
mind-one001 c8b30810f6 style(sn): format integrated map modules 2026-10-01 15:54:43 +09:00
mind-one001 f71fa7d97a merge(sn): retain remote layer and flood updates after refactoring 2026-10-01 15:53:30 +09:00
mind-one001 4c7c4c00ab refactor(sn): modularize map and features and standardize file naming 2026-10-01 15:47:57 +09:00
chanii8 d2645a9c08 [as] feat: 파일 업로드 확장자 제한 정책 적용 2026-10-01 15:36:02 +09:00
mind-one005 ae341d66ba sn: 침수예측 오버레이 수정 2026-10-01 15:02:53 +09:00
chanii8 fce4def951 [as] feat: 행정지원 숫자 폼 소숫점 자리 제한 2026-10-01 14:26:22 +09:00
chanii8 ba0964f772 [as] feat: useTus > handleError 수정 2026-10-01 13:44:46 +09:00
kiminchan e346417c29 Merge branch 'iipbs/sn/dev' 2026-10-01 10:15:58 +09:00
kiminchan 5fcc184a88 노후관로 레이어를 DB 설정 기반으로 전환 2026-10-01 09:46:16 +09:00
mind-one005 f6d3f89b5b Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-10-01 00:26:53 +09:00
mind-one005 106e09a7a2 sn: 도시침수예방사업 지정일 수정 2026-10-01 00:26:36 +09:00
mind-one005 d1b4600392 sn: 도시침수예방사업 수정화면 사업순번 단계 수정 불가 2026-10-01 00:16:07 +09:00
kiminchan 4204dfa624 Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-09-30 18:15:15 +09:00
kiminchan d7fd5be068 관로분석 및 통수위험 기본 조회조건 개선 2026-09-30 18:13:31 +09:00
mind-one005 735bb397ec sn: 침수예측현황 분석시각 조건 초기화 2026-09-30 18:09:20 +09:00
mind-one005 8b5a2b713c sn: 도시침수지도 자동선택 2026-09-30 17:53:50 +09:00
rsone001 f2ec9327fd feat: [ST] - eval 제거 2026-09-30 17:17:01 +09:00
yangsein 5331e10c96 [refactor] 대시보드 - 통계처리현황 반려되어 제출중인 상태 구분 처리 포함 2026-09-30 16:59:05 +09:00
chanii8 7090f02a52 [as] feat: 보완 요청 사항 반영 (260930_1128) 2026-09-30 11:28:28 +09:00
chanii8 12152a6810 [as] chore: 파일보완 다이얼로그 컨텐츠 최대 높이 지정 2026-09-29 17:41:37 +09:00
chanii8 c0c2d3d1c9 Merge remote-tracking branch 'origin/iipbs/as/dev' 2026-09-29 17:32:18 +09:00
chanii8 858844e81a [as] chore: 설계자문 진행상태 모델 수정 2026-09-29 16:58:01 +09:00
chanii8 1ff2731121 Merge branch 'iipbs/cs/dev' 2026-09-29 15:37:15 +09:00
chanii8 70db7662bb [cs] chore: 모듈API관리 폼 수정 2026-09-29 15:36:37 +09:00
chanii8 bb57ff72e7 [cs] feat: 모듈API관리 "API URL" 검색 조건 추가 2026-09-29 15:19:42 +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 7946c08c72 Merge branch 'iipbs/as/dev' 2026-09-29 14:29:32 +09:00
chanii8 6f20ef7273 [as] feat: 보완 요청 사항 반영 (260929_1429) 2026-09-29 14:29:13 +09:00
mind-one005 657ae77dde Merge branch 'iipbs/sn/dev' 2026-09-29 14:03:36 +09:00
mind-one005 4ff80dd257 cm: 유역별 정보 수정 2026-09-29 11:38:47 +09:00
mind-one005 9bb1069d26 cm: 노후 하수관로 현황 수정 2026-09-29 11:17:52 +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 78d8fea7f5 Merge branch 'iipbs/as/dev' 2026-09-28 16:44:05 +09:00
chanii8 22ed78c87b [as] feat: 행정지원 페이지 useSearchParamsState 적용 2026-09-28 16:37:20 +09:00
mind-one005 f5331d4c50 cm: 유역별 정보 div 수정 2026-09-28 16:32:16 +09:00
mind-one005 f32fe0ec5b cm: 유역별 정보 2026-09-28 16:07:52 +09:00
chanii8 3b914fbc2e [as] feat: 보완 요청 사항 반영 (20260928_1521) 2026-09-28 15:21:19 +09:00
kiminchan 533df0eb16 Merge branch 'iipbs/sn/dev' 2026-09-28 13:23:36 +09:00
chanii8 9c38165afb Merge branch 'main' into deploy
Deploy Frontend / deploy (push) Successful in 7m25s
2026-09-28 11:24:12 +09:00
mind-one001 7b506320b4 서버페이징 변경 2026-09-28 11:22:22 +09:00
chanii8 2b7c497c3e Merge branch 'iipbs/as/dev' 2026-09-28 11:20:57 +09:00
chanii8 4dee1309eb [as] feat: 보완 요청 사항 반영 (260928_1120) 2026-09-28 11:20:31 +09:00
kiminchan f3f8909e45 Merge branch 'iipbs/sn/dev' 2026-09-28 10:00:24 +09:00
mind-one001 d45cd79422 테스트코드 삭제 2026-09-23 16:00:28 +09:00
mind-one001 7cbb58ff32 노후관로 조회 기능 보완 2026-09-23 14:58:14 +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 f5ce74d0ee Merge remote-tracking branch 'origin/iipbs/as/dev' 2026-09-23 14:39:57 +09:00
chanii8 3114280e83 [as] chore: 알림이력 배지 크기 조정 2026-09-23 14:39:24 +09:00
chanii8 c7b8b36f4a Merge branch 'iipbs/common/dev' 2026-09-23 14:17:15 +09:00
mind-one001 4f50593eac Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-09-23 13:50:59 +09:00
mind-one001 8818dd21c6 관망도 레이어 전체켜기 기능 추가 2026-09-23 13:50:50 +09:00
chanii8 db8ee298d9 Merge branch 'iipbs/cs/dev' 2026-09-23 11:40:04 +09:00
chanii8 9eef99a435 [cs] chore: 게시판 댓글 사용 여부 적용 2026-09-23 11:39:32 +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
mind-one005 b73b83a49f Merge branch 'iipbs/sn/dev' of https://www.onasys.co.kr:3000/ONAANDALL/frontend into iipbs/sn/dev 2026-09-23 09:08:05 +09:00
mind-one005 549debf8bf sn: 노후하수관로정비사업 검색조건 추가 2026-09-23 09:07:56 +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
497 changed files with 20203 additions and 13825 deletions
+1
View File
@@ -27,6 +27,7 @@ settings.json
.npmrc
.cursor
.agent
CLAUDE.MD
apps/**/tsconfig.tsbuildinfo
packages/**/tsconfig.tsbuildinfo
@@ -1,3 +1,11 @@
import { Suspense } from "react";
import { DesignListPage } from "@/pages/design-cnstn";
export default DesignListPage;
export default function Page() {
return (
<Suspense>
<DesignListPage />
</Suspense>
);
}
@@ -1,3 +1,11 @@
import { Suspense } from "react";
import { FnrsListPage } from "@/pages/fnrs-cnsltn";
export default FnrsListPage;
export default function Page() {
return (
<Suspense>
<FnrsListPage />
</Suspense>
);
}
@@ -1,3 +1,11 @@
import { Suspense } from "react";
import { InstlListPage } from "@/pages/instl-sncn";
export default InstlListPage;
export default function Page() {
return (
<Suspense>
<InstlListPage />
</Suspense>
);
}
@@ -0,0 +1,3 @@
import { NtitlkSndngPage } from "@/pages/ntitlk-sndng";
export default NtitlkSndngPage;
@@ -1,3 +1,11 @@
import { Suspense } from "react";
import { SewerBscPlanListPage } from "@/pages/sewer-bsc-plan";
export default SewerBscPlanListPage;
export default function Page() {
return (
<Suspense>
<SewerBscPlanListPage />
</Suspense>
);
}
+1
View File
@@ -5,6 +5,7 @@ export {
useFindUserGugunCombo,
useFindUserSidoCombo,
} from "./api/queries";
export { useFindUserSidoGugunCombo } from "./lib/use-find-user-sido-gugun-combo";
export type {
CmCdAdstDstrComboResponseDto,
CmCdAdstDstrService,
@@ -0,0 +1,26 @@
import { useUserStore } from "@/shared/lib";
import { useFindUserGugunCombo, useFindUserSidoCombo } from "../api/queries";
import type { CmCdAdstDstrComboResponseDto } from "../model/adstdstr-interface";
const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = [];
export function useFindUserSidoGugunCombo(sidoComCd: string) {
const userInfo = useUserStore((state) => state.userInfo);
const sidoFilterUserInfo =
userInfo?.dtlInstCd === "ROLE_USER04" || userInfo?.dtlInstCd === "ROLE_USER05"
? userInfo
: undefined;
const { data: userSidoData, isPending: isUserSidoPending } =
useFindUserSidoCombo(sidoFilterUserInfo);
const { data: userGugunData, isPending: isUserGugunPending } =
useFindUserGugunCombo(sidoComCd);
return {
userSidoData: userSidoData ?? EMPTY_ADST_LIST,
userGugunData: userGugunData ?? EMPTY_ADST_LIST,
isUserSidoPending,
isUserGugunPending,
};
}
@@ -14,6 +14,8 @@ export interface CnstpSeMngResponseDto {
cnstpSeCdNm: string;
upCnstpCd?: string;
cdLvNo: number;
numYn: string;
dcptUnitCnt: number;
useYn: string;
expln?: string;
sortSeq?: number;
@@ -41,6 +43,8 @@ export interface CnstpSeMngSaveRequestDto {
useYn?: string;
expln?: string;
sortSeq?: number;
numYn?: string;
dcptUnitCnt?: number;
}
/** PUT /{cnstpSeCd} 요청 (mode는 서비스에서 U로 설정) */
@@ -51,6 +55,8 @@ export interface CnstpSeMngPutRequestDto {
cdLvNo?: number;
useYn?: string;
expln?: string;
numYn?: string;
dcptUnitCnt?: number;
}
/** 백엔드 CnstpSeMngTreeDto */
@@ -60,8 +66,11 @@ export interface CnstpSeMngTreeDto {
cnstpSeCdNm: string;
upCnstpCd?: string;
useYn: string;
numYn: string;
dcptUnitCnt?: number;
treeLevel: number;
treePath: string;
sortPath: string;
}
/** 백엔드 BizSclTreeDto */
@@ -70,8 +79,11 @@ export interface BizSclTreeDto {
cnstpSeCd: string;
cnstpSeCdNm: string;
useYn: string;
numYn: string;
dcptUnitCnt?: number;
treeLevel: number;
treePath: string;
sortPath: string;
bscPlanQty?: number;
bgtRfltQty?: number;
tstmDesignQty?: number;
@@ -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,
});
}
+3 -1
View File
@@ -1,10 +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";
@@ -18,6 +18,18 @@ export interface DocPrgrsSttsResponseDto {
totCnt: number;
}
/** 백엔드 BasinDocPrgrsResponseDto */
export interface BasinDocPrgrsResponseDto {
basinKey: string;
basinNm: string;
docSeComCd: string;
docSeNm: string;
reqCnt: number;
prgrsCnt: number;
cmptnCnt: number;
totCnt: number;
}
/** 백엔드 NotiAsUserResponseDto */
export interface NotiAsUserResponseDto {
notiId: number;
@@ -48,11 +60,27 @@ export interface NotiAsStatResponseDto {
/** 백엔드 DashboardMainResponseDto */
export interface DashboardMainResponseDto {
docPrgrsSttsList: DocPrgrsSttsResponseDto[];
basinDocPrgrsList: BasinDocPrgrsResponseDto[];
notiList: NotiAsUserResponseDto[];
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[]>;
}
@@ -103,6 +103,8 @@ function ElaprApprovalDialog({
const currentUserPrgrs = findCurrentUserPendingApprovalRequest(cyclDocPrgrsList, currentUserUuid);
const canApprove = Boolean(currentUserPrgrs?.elaprPrgrsId);
const canReject =
currentUserPrgrs?.atrzSeComCd === "ATRZ03" || currentUserPrgrs?.atrzSeComCd === "ATRZ99";
const pickingRow = pickingRowKey ? rows.find((r) => r.rowKey === pickingRowKey) : undefined;
const isBusy = isProcessing || savePrgrsSttsMutation.isPending;
@@ -392,9 +394,11 @@ function ElaprApprovalDialog({
<Button onClick={handleApprove} disabled={isBusy}>
결재
</Button>
<Button color="red" onClick={handleReject} disabled={isBusy}>
반려
</Button>
{canReject && (
<Button color="red" onClick={handleReject} disabled={isBusy}>
반려
</Button>
)}
</div>
)}
</div>
@@ -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;
+1 -1
View File
@@ -1 +1 @@
export { handleTusUpload, type TusUploadOptions } from "./lib/utils";
export { handleTusUpload, type TusErrorResponse, type TusUploadOptions } from "./lib/utils";
+35 -4
View File
@@ -1,15 +1,29 @@
import { Upload } from "tus-js-client";
import { type DetailedError, Upload } from "tus-js-client";
import { ENDPOINT, PROXY_HOST } from "@/shared/config";
import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config/env";
import { getCookie } from "@/shared/lib";
interface TusFieldError {
message: string;
field: string;
rejectedValue: string;
}
interface TusErrorResponse {
timestamp: number[];
message: string;
status: number;
code: string;
errors: TusFieldError[];
}
interface TusUploadOptions {
metadata?: Record<string, string>;
handleUploadStart?: (upload: Upload) => void;
handleProgress?: (percentage: number) => void;
handleSuccess?: (upload: Upload, atflCd: string, atflId: string) => void;
handleError?: (error: Error) => void;
handleError?: (error: Error, response: TusErrorResponse | null) => void;
}
function handleTusUpload(file: File, options?: TusUploadOptions) {
@@ -32,7 +46,7 @@ function handleTusUpload(file: File, options?: TusUploadOptions) {
metadata,
onError(error) {
console.error("TUS upload failed:", error);
options?.handleError?.(error);
options?.handleError?.(error, parseTusErrorResponse(error));
},
onProgress(bytesUploaded, bytesTotal) {
const percentage = (bytesUploaded / bytesTotal) * 100;
@@ -57,5 +71,22 @@ function handleTusUpload(file: File, options?: TusUploadOptions) {
return upload;
}
function parseTusErrorResponse(error: Error | DetailedError): TusErrorResponse | null {
if (!("originalResponse" in error)) return null;
const body = error.originalResponse?.getBody();
if (!body) return null;
try {
const parsed: unknown = JSON.parse(body);
if (typeof parsed !== "object" || parsed === null) return null;
if (typeof (parsed as TusErrorResponse).message !== "string") return null;
return parsed as TusErrorResponse;
} catch {
return null;
}
}
export { handleTusUpload };
export type { TusUploadOptions };
export type { TusErrorResponse, TusUploadOptions };
@@ -21,6 +21,8 @@ const buildSavePayload = createSavePayloadBuilder<CnstpSeRow, CnstpSeMngSaveRequ
useYn: row.useYn,
expln: row.expln?.trim() || undefined,
sortSeq: row.sortSeq,
numYn: row.numYn,
dcptUnitCnt: row.dcptUnitCnt,
}),
toCreateDto: (row) => ({
mode: CNSTP_SE_SAVE_MODE.CREATE,
@@ -31,6 +33,8 @@ const buildSavePayload = createSavePayloadBuilder<CnstpSeRow, CnstpSeMngSaveRequ
useYn: row.useYn,
expln: row.expln?.trim() || undefined,
sortSeq: row.sortSeq,
numYn: row.numYn,
dcptUnitCnt: row.dcptUnitCnt,
}),
toUpdateDto: (row) => ({
mode: CNSTP_SE_SAVE_MODE.UPDATE,
@@ -41,6 +45,8 @@ const buildSavePayload = createSavePayloadBuilder<CnstpSeRow, CnstpSeMngSaveRequ
useYn: row.useYn,
expln: row.expln?.trim() || undefined,
sortSeq: row.sortSeq,
numYn: row.numYn,
dcptUnitCnt: row.dcptUnitCnt,
}),
});
@@ -62,6 +68,8 @@ export const cnstpGridConfig: SeGridConfig<
cnstpSeCd: "",
cnstpSeCdNm: "",
expln: "",
numYn: "Y",
dcptUnitCnt: 0,
useYn: "Y",
}),
applyDraftPatch: (draft, patch) => ({
@@ -69,6 +77,8 @@ export const cnstpGridConfig: SeGridConfig<
...(patch.cnstpSeCd !== undefined ? { cnstpSeCd: patch.cnstpSeCd } : {}),
...(patch.cnstpSeCdNm !== undefined ? { cnstpSeCdNm: patch.cnstpSeCdNm ?? "" } : {}),
...(patch.expln !== undefined ? { expln: patch.expln ?? "" } : {}),
...(patch.numYn !== undefined ? { numYn: patch.numYn } : {}),
...(patch.dcptUnitCnt !== undefined ? { dcptUnitCnt: patch.dcptUnitCnt } : {}),
...(patch.useYn !== undefined ? { useYn: patch.useYn } : {}),
...(patch.sortSeq !== undefined ? { sortSeq: patch.sortSeq } : {}),
}),
@@ -77,6 +87,8 @@ export const cnstpGridConfig: SeGridConfig<
cnstpSeCd: draft.cnstpSeCd,
cnstpSeCdNm: draft.cnstpSeCdNm,
expln: draft.expln,
numYn: draft.numYn,
dcptUnitCnt: draft.dcptUnitCnt,
useYn: draft.useYn,
cdLvNo: draft.cdLvNo,
upCnstpCd: parentCode,
@@ -13,6 +13,8 @@ export interface CnstpSeDraft {
cnstpSeCd: string;
cnstpSeCdNm: string;
expln: string;
numYn: string;
dcptUnitCnt: number;
useYn: string;
sortSeq?: number;
}
@@ -118,6 +118,8 @@ function CnstpCodeTabPanel() {
<TableHead>코드</TableHead>
<TableHead>코드명</TableHead>
<TableHead>설명</TableHead>
<TableHead>숫자 여부</TableHead>
<TableHead>소숫점 단위</TableHead>
<TableHead>사용 여부</TableHead>
<TableHead>정렬순서</TableHead>
<TableHead>구분</TableHead>
@@ -62,6 +62,33 @@ function CnstpSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Cns
maxLength={1000}
/>
</TableCell>
<TableCell align="center">
<RadioGroup
value={row.numYn === "N" ? "N" : "Y"}
onValueChange={(v) => onChange({ numYn: v })}
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>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="N" id={`${row.rowKey}-num-n`} />
<Label htmlFor={`${row.rowKey}-use-n`}>아니오</Label>
</div>
</RadioGroup>
</TableCell>
<TableCell align="center">
<Input
type="number"
min={0}
value={row.dcptUnitCnt ?? ""}
onChange={(e) =>
onChange({ dcptUnitCnt: e.target.value ? Number(e.target.value) : undefined })
}
className="w-20"
/>
</TableCell>
<TableCell align="center">
<RadioGroup
value={row.useYn === "N" ? "N" : "Y"}
@@ -89,7 +116,9 @@ function CnstpSeRowComponent({ row, depth, onChange, onAddChild, onDelete }: Cns
className="w-20"
/>
</TableCell>
<TableCell align="center">{levelLabel}</TableCell>
<TableCell align="center" className="whitespace-nowrap">
{levelLabel}
</TableCell>
<TableCell align="center">
<div className="flex justify-center gap-2">
<Button
@@ -1,7 +1,10 @@
import { type CSSProperties, useCallback, useEffect, useRef, useState } from "react";
import type { BasinDocPrgrsResponseDto } from "@/entities/dashboard";
import {
BASIN_DATA,
BASIN_DOC_SE_SLOTS,
BASIN_IDS,
type BasinKey,
MANUAL_LABEL_POS,
@@ -15,8 +18,6 @@ const POPUP_EDGE_PADDING_PX = 12;
const LABEL_OFFSET_Y_PX = 18;
const DEFAULT_VIEWBOX = "0 0 100 100";
export const PLACEHOLDER_POPUP_ITEMS = [{ header: "현황", data: "-" }] as const;
export interface MapPopupItem {
header: string;
data: string;
@@ -27,10 +28,28 @@ export interface MapPopupContent {
items: MapPopupItem[];
}
const INITIAL_POPUP_CONTENT: MapPopupContent = {
title: "",
items: [...PLACEHOLDER_POPUP_ITEMS],
};
interface ActiveBasin {
key: string | undefined;
title: string;
}
const INITIAL_ACTIVE_BASIN: ActiveBasin = { key: undefined, title: "" };
function buildPopupItems(
basinDocPrgrsList: BasinDocPrgrsResponseDto[] | undefined,
basinKey: string | undefined,
): MapPopupItem[] {
const itemByCd = new Map(
basinDocPrgrsList
?.filter((item) => item.basinKey === basinKey)
.map((item) => [item.docSeComCd, item]),
);
return BASIN_DOC_SE_SLOTS.map((slot) => {
const item = itemByCd.get(slot.docSeComCd);
return { header: slot.label, data: item ? String(item.totCnt) : "-" };
});
}
function isBasinKey(key: string | undefined): key is BasinKey {
return Boolean(key && key in BASIN_DATA);
@@ -172,22 +191,25 @@ function positionLabels(
});
}
export function useNationalAsMap() {
export function useNationalAsMap(basinDocPrgrsList: BasinDocPrgrsResponseDto[] | undefined) {
const stageRef = useRef<HTMLDivElement>(null);
const svgLayerRef = useRef<HTMLDivElement>(null);
const labelsLayerRef = useRef<HTMLDivElement>(null);
const popupRef = useRef<HTMLDivElement>(null);
const [popupContent, setPopupContent] = useState<MapPopupContent>(INITIAL_POPUP_CONTENT);
const [activeBasin, setActiveBasin] = useState<ActiveBasin>(INITIAL_ACTIVE_BASIN);
const popupContent: MapPopupContent = {
title: activeBasin.title,
items: buildPopupItems(basinDocPrgrsList, activeBasin.key),
};
const showFor = useCallback((group: SVGGElement) => {
const stage = stageRef.current;
const popup = popupRef.current;
if (!stage || !popup) return;
setPopupContent({
title: resolveBasinName(group),
items: [...PLACEHOLDER_POPUP_ITEMS],
});
const key = group.dataset.key;
const title = resolveBasinName(group);
setActiveBasin((prev) => (prev.key === key && prev.title === title ? prev : { key, title }));
showPopupAtGroup(stage, popup, group);
}, []);
@@ -19,6 +19,13 @@ export const BASIN_IDS = [
{ key: "yeongsan", labels: ["영산강", "yeongsan"] },
] as const;
export const BASIN_DOC_SE_SLOTS = [
{ docSeComCd: "FNRS", label: "재원협의" },
{ docSeComCd: "INSTL", label: "설치인가" },
{ docSeComCd: "CNFM", label: "설계자문" },
{ docSeComCd: "SEWER", label: "기본계획" },
] as const;
export type BasinKey = keyof typeof BASIN_DATA;
export interface ManualLabelPos {
@@ -109,11 +109,11 @@ interface DashboardNotiRowProps {
function DashboardNotiRow({ row, isActionPending, onAction }: DashboardNotiRowProps) {
return (
<div className="flex items-center gap-4 border-b px-2 py-4 last:border-b-0">
<span className="shrink-0 text-lg text-muted-foreground">
<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">
{row.regDt ? dayjs(row.regDt).format("YYYY-MM-DD") : "-"}
</span>
<p className="min-w-0 flex-1 truncate text-xl">{row.notiTtl}</p>
<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>
@@ -12,8 +12,8 @@ export function DashboardPage() {
return (
<div className="mt-4 flex gap-4 px-[1.8rem] pb-6">
<div className="flex-[0.5]">
<NationalAsMap />
<div className="hidden flex-[0.5] sm:block">
<NationalAsMap basinDocPrgrsList={data?.basinDocPrgrsList} />
</div>
<div className="flex min-h-0 flex-1 flex-col gap-4">
<DocPrgrsSttsCards items={data?.docPrgrsSttsList} isPending={isPending} isError={isError} />
@@ -33,7 +33,7 @@ export function DocPrgrsSttsCards({ items, isPending, isError }: DocPrgrsSttsCar
const itemByCd = new Map(items?.map((item) => [item.docSeComCd, item]));
return (
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{DOC_PRGRS_STTS_SLOTS.map((slot) => {
const item = itemByCd.get(slot.docSeComCd);
return (
@@ -97,8 +97,8 @@ function DocPrgrsSttsMetric({
}
const DOC_PRGRS_STTS_SLOTS = [
{ docSeComCd: "FNRS", title: "재원협의 진행상황", href: "/fnrs-cnsltn" },
{ docSeComCd: "INSTL", title: "설치인가 진행상황", href: "/instl-sncn" },
{ docSeComCd: "FNRS", title: "재원협의 진행상황", href: "/fnrs-cnsltn" },
{ docSeComCd: "CNFM", title: "설계자문 진행상황", href: "/design-cnstn" },
{ docSeComCd: "SEWER", title: "기본계획 진행상황", href: "/sewer-bsc-plan" },
] as const;
@@ -2,13 +2,19 @@
import type { CSSProperties } from "react";
import type { BasinDocPrgrsResponseDto } from "@/entities/dashboard";
import { useNationalAsMap } from "../lib/use-national-as-map";
import styles from "../lib/national-as-map.module.css";
export function NationalAsMap() {
interface NationalAsMapProps {
basinDocPrgrsList: BasinDocPrgrsResponseDto[] | undefined;
}
export function NationalAsMap({ basinDocPrgrsList }: NationalAsMapProps) {
const { stageRef, svgLayerRef, labelsLayerRef, popupRef, popupContent, popupGridStyle } =
useNationalAsMap();
useNationalAsMap(basinDocPrgrsList);
const popupColumnSpan = Math.max(1, popupContent.items.length);
return (
@@ -9,8 +9,14 @@ import {
export const designCnstnRegistSchema = z.object({
designCnstnMng: designCnstnMngSaveSchema.extend({
bizNm: z.string().min(1, "사업명은 필수입니다"),
bizBgngYr: z.string().min(1, "사업시작연도는 필수입니다"),
bizEndYr: z.string().min(1, "사업종료연도는 필수입니다"),
bizBgngYr: z
.string()
.min(1, "사업시작연도는 필수입니다")
.regex(/^\d{4}$/, "연도는 4자리 숫자여야 합니다"),
bizEndYr: z
.string()
.min(1, "사업종료연도는 필수입니다")
.regex(/^\d{4}$/, "연도는 4자리 숫자여야 합니다"),
bizCn: z.string().min(1, "사업내용은 필수입니다"),
taskSeComCd: z.array(z.string()).min(1, "구분은 필수입니다"),
dsgnrNm: z.string().min(1, "설계자 이름은 필수입니다"),
@@ -17,8 +17,8 @@ const MAIN_STEP_LABELS = {
} as const;
export const PROGRESS_STATUS_COLUMNS = [
{ label: "설계자문 신청 (시군)", sttsComCd: "ER" },
{ label: "기술검토", sttsComCd: "TE" },
{ label: "설계자문 신청 (시군)", sttsComCd: "CT" },
{ label: "기술검토", sttsComCd: "CS" },
{ label: "기술검토 회신", sttsComCd: "CP" },
];
@@ -38,7 +38,7 @@ function buildProgressBadges(prgrsSttsComCd: string): ProgressBadge[] {
];
}
if (prgrsSttsComCd === "ER" || prgrsSttsComCd === "CS" || prgrsSttsComCd === "SL") {
if (prgrsSttsComCd === "CT" || prgrsSttsComCd === "CS" || prgrsSttsComCd === "SL") {
return [
{ label: MAIN_STEP_LABELS.apply, state: "completed" },
{ label: MAIN_STEP_LABELS.techReview, state: "current" },
@@ -61,6 +61,13 @@ function buildProgressBadges(prgrsSttsComCd: string): ProgressBadge[] {
];
}
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
if (prgrsSttsComCd === "SL") {
return <Badge color="orange">보완요청</Badge>;
}
return null;
}
function getBadgeColor(state: BadgeState) {
switch (state) {
case "completed":
@@ -110,7 +117,11 @@ export function createDesignCnstnColumns() {
columnHelper.accessor("bizNm", {
header: "사업명",
meta: { align: "left" },
cell: ({ getValue }) => formatOptional(getValue()),
cell: ({ row, getValue }) => (
<>
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
</>
),
}),
columnHelper.accessor("taskSeCdNm", {
header: "업무구분",
@@ -8,7 +8,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useUserStore } from "@/shared/lib";
import { getListHref, useAlertStore, useUserStore, withListQs } from "@/shared/lib";
import {
type DesignCnstnApplySaveRequestDto,
designCnstnKeys,
@@ -103,11 +103,11 @@ function FloatingActionButtons({
isRegister && actionButtons.some((button) => button === DESIGN_CNSTN_VIEW_BUTTON_ACTION.strg);
function handleGoToList() {
router.push("/design-cnstn");
router.push(getListHref("/design-cnstn"));
}
function handleEdit() {
router.push(`/design-cnstn/${designCnstnDocNo}/edit`);
router.push(withListQs(`/design-cnstn/${designCnstnDocNo}/edit`));
}
function shouldShowButton(action: DesignCnstnViewButtonAction) {
@@ -177,7 +177,7 @@ function FloatingActionButtons({
action: DESIGN_CNSTN_VIEW_BUTTON_ACTION.del,
successMessage: "삭제되었습니다.",
});
router.push("/design-cnstn");
router.push(getListHref("/design-cnstn"));
}
function getReviewOpinionFormData() {
@@ -8,8 +8,8 @@ import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQueryClient } from "@tanstack/react-query";
import { useAlertStore, useUserStore } from "@/shared/lib";
import { RhfDevTool, SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
import { getListHref, useAlertStore, useUserStore, withListQs } from "@/shared/lib";
import { SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
import {
designCnstnKeys,
type DesignCnstnMngDetResponseDto,
@@ -26,7 +26,10 @@ import {
} from "@/widgets/pbadms-sprt";
import { Button } from "@common/components";
import { type DesignCnstnRegistFormData, designCnstnRegistSchema } from "../lib/design-cnstn-regist-schema";
import {
type DesignCnstnRegistFormData,
designCnstnRegistSchema,
} from "../lib/design-cnstn-regist-schema";
import {
buildFormPayload,
getCreateDefaultValues,
@@ -91,7 +94,7 @@ function DesignFormPage({ mode, initialData, createDefaults }: DesignFormPagePro
alert("저장되었습니다.");
queryClient.invalidateQueries({ queryKey: designCnstnKeys.all });
router.push(`/design-cnstn/${designCnstnDocNo}/edit`);
router.push(withListQs(`/design-cnstn/${designCnstnDocNo}/edit`));
} catch (error) {
console.error("저장 실패:", error);
alert("저장 중 오류가 발생했습니다.");
@@ -103,6 +106,11 @@ function DesignFormPage({ mode, initialData, createDefaults }: DesignFormPagePro
async function onApply(data: DesignCnstnRegistFormData) {
if (isSubmitting) return;
if (data.privacyConsent !== "Y") {
alert("개인정보 수집/이용에 동의해야 신청이 가능합니다.");
return;
}
if (!isFormEsntlAtchComplete(essentialFiles, data.esntlAtchFileList)) {
alert("필수 첨부파일을 모두 등록해야 신청이 가능합니다.");
return;
@@ -115,7 +123,7 @@ function DesignFormPage({ mode, initialData, createDefaults }: DesignFormPagePro
alert("신청 완료되었습니다.");
queryClient.invalidateQueries({ queryKey: designCnstnKeys.all });
router.push(`/design-cnstn/${designCnstnDocNo}`);
router.push(withListQs(`/design-cnstn/${designCnstnDocNo}`));
} catch (error) {
console.error("신청 실패:", error);
alert("신청 중 오류가 발생했습니다.");
@@ -144,7 +152,7 @@ function DesignFormPage({ mode, initialData, createDefaults }: DesignFormPagePro
await designCnstnMngService.delete(docNo);
alert("삭제되었습니다.");
queryClient.invalidateQueries({ queryKey: designCnstnKeys.list() });
router.push("/design-cnstn");
router.push(getListHref("/design-cnstn"));
} catch (error) {
console.error("삭제 실패:", error);
alert("삭제 중 오류가 발생했습니다.");
@@ -184,7 +192,7 @@ function DesignFormPage({ mode, initialData, createDefaults }: DesignFormPagePro
<Button
color="white"
type="button"
onClick={() => router.push("/design-cnstn")}
onClick={() => router.push(getListHref("/design-cnstn"))}
disabled={isSubmitting}
>
목록
@@ -217,7 +225,6 @@ function DesignFormPage({ mode, initialData, createDefaults }: DesignFormPagePro
</div>
</SectionCollapsibleGroup>
</form>
<RhfDevTool control={form.control} />
</FormProvider>
);
}
@@ -1,14 +1,11 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import type { PaginationState } from "@tanstack/react-table";
import {
type CmCdAdstDstrComboResponseDto,
useFindGugunCombo,
useFindSidoCombo,
} from "@/entities/adstdstr";
import { useSearchParamsState } from "@/shared/lib";
import { useFindUserSidoGugunCombo } from "@/entities/adstdstr";
import { CodeSelect } from "@/entities/code";
import type { DesignCnstnMngSearchDto } from "@/entities/design-cnstn";
import {
@@ -27,48 +24,58 @@ import { DesignList } from "./design-list";
const DEFAULT_PAGE_INDEX = 0;
const DEFAULT_PAGE_SIZE = 10;
const DEFAULT_SORT = "designCnstnDocNo,desc";
const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = [];
const DEFAULT_SEARCH_PARAMS: DesignCnstnMngSearchDto = {
bizNm: "",
taskSeComCd: "",
admdstCd: "",
page: DEFAULT_PAGE_INDEX,
size: DEFAULT_PAGE_SIZE,
sort: DEFAULT_SORT,
};
const ALL_SELECT_VALUE = "__ALL__";
function DesignListPage() {
const [bizNm, setBizNm] = useState("");
const [taskSeComCd, setTaskSeComCd] = useState("");
const [sidoComCd, setSidoComCd] = useState("");
const [sggComCd, setSggComCd] = useState("");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: DEFAULT_PAGE_INDEX,
pageSize: DEFAULT_PAGE_SIZE,
});
const [searchParams, setSearchParams] = useState<DesignCnstnMngSearchDto>(DEFAULT_SEARCH_PARAMS);
const DEFAULT_PARAMS = {
sidoComCd: "",
sggComCd: "",
taskSeComCd: "",
bizNm: "",
page: DEFAULT_PAGE_INDEX,
size: DEFAULT_PAGE_SIZE,
};
const { data: sidoData } = useFindSidoCombo();
const { data: gugunData } = useFindGugunCombo(sidoComCd);
const sidoOptions = sidoData ?? EMPTY_ADST_LIST;
const gugunOptions = gugunData ?? EMPTY_ADST_LIST;
function DesignListPage() {
const { params, setParams } = useSearchParamsState(DEFAULT_PARAMS);
const [bizNm, setBizNm] = useState(params.bizNm);
const [taskSeComCd, setTaskSeComCd] = useState(params.taskSeComCd);
const [sidoComCd, setSidoComCd] = useState(params.sidoComCd);
const [sggComCd, setSggComCd] = useState(params.sggComCd);
const { userSidoData, userGugunData } = useFindUserSidoGugunCombo(sidoComCd);
const pagination: PaginationState = {
pageIndex: params.page,
pageSize: params.size,
};
const searchParams: DesignCnstnMngSearchDto = useMemo(
() => ({
bizNm: params.bizNm,
taskSeComCd: params.taskSeComCd,
admdstCd: params.sggComCd || params.sidoComCd || "",
page: params.page,
size: params.size,
sort: DEFAULT_SORT,
}),
[params],
);
const handleSearch = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setPagination((prev) => ({ ...prev, pageIndex: DEFAULT_PAGE_INDEX }));
setSearchParams({
...DEFAULT_SEARCH_PARAMS,
setParams({
bizNm,
taskSeComCd,
admdstCd: sggComCd || sidoComCd || "",
sidoComCd,
sggComCd,
page: DEFAULT_PAGE_INDEX,
size: pagination.pageSize,
});
};
const handlePaginationChange = (next: PaginationState) => {
setParams({ page: next.pageIndex, size: next.pageSize });
};
return (
<div className="space-y-4 px-[1.8rem] pb-6">
<section className="card card-border mx-0 bg-background">
@@ -89,7 +96,7 @@ function DesignListPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_SELECT_VALUE}>전체</SelectItem>
{sidoOptions.map((option) => (
{userSidoData.map((option) => (
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
{option.adstDstrNm}
</SelectItem>
@@ -111,7 +118,7 @@ function DesignListPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_SELECT_VALUE}>전체</SelectItem>
{gugunOptions.map((option) => (
{userGugunData.map((option) => (
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
{option.adstDstrNm}
</SelectItem>
@@ -143,7 +150,7 @@ function DesignListPage() {
<DesignList
searchParams={searchParams}
pagination={pagination}
onPaginationChange={setPagination}
onPaginationChange={handlePaginationChange}
/>
</div>
);
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { useDataTable } from "@/shared/lib";
import { useDataTable, withListQs } from "@/shared/lib";
import {
type DesignCnstnMngResponseDto,
type DesignCnstnMngSearchDto,
@@ -43,7 +43,7 @@ function DesignList({ searchParams, pagination, onPaginationChange }: DesignList
const columns = useMemo(() => createDesignCnstnColumns(), []);
const handleRowClick = (row: DesignCnstnMngResponseDto) => {
router.push(`/design-cnstn/${row.designCnstnDocNo}`);
router.push(withListQs(`/design-cnstn/${row.designCnstnDocNo}`));
};
const { tableComponent } = useDataTable({
@@ -83,7 +83,9 @@ function DesignViewPage({ id }: DesignViewPageProps) {
return (
<div className="mt-4">
<SectionCollapsibleGroup>
<SectionCollapsibleToggle />
<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}
@@ -3,19 +3,12 @@
import { useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { NON_NEGATIVE_INPUT_PROPS } from "@/shared/lib";
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 {
Button,
Input,
Table,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
import { Input, Table, TableCell, TableHead, TableHeader, TableRow } from "@common/components";
import type { DesignCnstnRegistFormData } from "../../lib/design-cnstn-regist-schema";
@@ -69,9 +62,9 @@ function BasicInfoFormSection() {
aria-invalid={fieldState.invalid}
className="w-[40rem]"
/>
<Button type="button" onClick={() => setIsPickerOpen(true)}>
{/* <Button type="button" onClick={() => setIsPickerOpen(true)}>
사업찾기
</Button>
</Button> */}
</div>
<FieldError errors={[fieldState.error]} />
</Field>
@@ -87,9 +80,11 @@ function BasicInfoFormSection() {
name="designCnstnMng.bizBgngYr"
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid} className="w-auto">
<Field data-invalid={fieldState.invalid} className="w-auto !gap-1">
<Input
{...field}
{...NON_NEGATIVE_INPUT_PROPS}
type="number"
id={field.name}
placeholder="시작연도"
className="max-w-[12rem]"
@@ -104,9 +99,11 @@ function BasicInfoFormSection() {
name="designCnstnMng.bizEndYr"
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid} className="w-auto">
<Field data-invalid={fieldState.invalid} className="w-auto !gap-1">
<Input
{...field}
{...NON_NEGATIVE_INPUT_PROPS}
type="number"
id={field.name}
placeholder="종료연도"
className="max-w-[12rem]"
@@ -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],
@@ -13,8 +13,14 @@ import {
export const fnrsCnsltnRegistSchema = z.object({
fnrsCnsltnMng: fnrsCnsltnMngSaveSchema.extend({
bizNm: z.string().min(1, "사업명은 필수입니다"),
bizBgngYr: z.string().min(1, "사업시작연도는 필수입니다"),
bizEndYr: z.string().min(1, "사업종료연도는 필수입니다"),
bizBgngYr: z
.string()
.min(1, "사업시작연도는 필수입니다")
.regex(/^\d{4}$/, "연도는 4자리 숫자여야 합니다"),
bizEndYr: z
.string()
.min(1, "사업종료연도는 필수입니다")
.regex(/^\d{4}$/, "연도는 4자리 숫자여야 합니다"),
bizCn: z.string().min(1, "사업내용은 필수입니다"),
taskSeComCd: z.array(z.string()).min(1, "구분은 필수입니다"),
dsgnrNm: z.string().min(1, "설계자 이름은 필수입니다"),
@@ -102,6 +102,13 @@ function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N")
];
}
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
if (prgrsSttsComCd === "SL") {
return <Badge color="orange">보완요청</Badge>;
}
return null;
}
function getBadgeColor(state: BadgeState) {
switch (state) {
case "completed":
@@ -152,7 +159,11 @@ export function createFnrsCnsltnColumns() {
columnHelper.accessor("bizNm", {
header: "사업명",
meta: { align: "left" },
cell: ({ getValue }) => formatOptional(getValue()),
cell: ({ row, getValue }) => (
<>
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
</>
),
}),
columnHelper.accessor("taskSeCdNm", {
header: "업무구분",
@@ -48,8 +48,8 @@ export const REVIEW_STEP_CODES: ReviewStepCode[] = ["LOCAL", "ENV", "KECO"];
export const CHECKLIST_TAB_LABELS: Record<ReviewStepCode, string> = {
LOCAL: "자가체크",
ENV: "기술검토",
KECO: "환경청검토",
ENV: "환경청검토",
KECO: "기술검토",
};
export const REVIEW_STEP_LABELS: Record<ReviewStepCode, string> = {
@@ -145,7 +145,7 @@ function FileVrfcSplmntDialog({
<DialogTitle>파일보완</DialogTitle>
</DialogHeader>
<div className="space-y-3">{tableComponent}</div>
<div className="max-h-[70rem] space-y-3 overflow-y-auto">{tableComponent}</div>
<DialogFooter>
<Button type="button" color="white" onClick={onClose} disabled={isSubmitting}>
@@ -8,7 +8,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useUserStore } from "@/shared/lib";
import { getListHref, useAlertStore, useUserStore, withListQs } from "@/shared/lib";
import {
AI_AUTO_SE,
type AiAutoPrcsLatestResponseDto,
@@ -97,7 +97,6 @@ type ActiveDialog =
| "elaprTmplt"
| "elaprDocPrgrs"
| "rjct"
| "dmndSplmnt"
| "fileSplmnt"
| "elaprApproval"
| "pwrkCnsltnDocMng"
@@ -124,6 +123,7 @@ function FloatingActionButtons({
const alert = useAlertStore((state) => state.setMessage);
const [isProcessing, setIsProcessing] = useState(false);
const [activeDialog, setActiveDialog] = useState<ActiveDialog>(null);
const [isDmndSplmntDialogOpen, setIsDmndSplmntDialogOpen] = useState(false);
const docNo = data.detDto?.fnrsCnsltnDocNo ?? fnrsCnsltnDocNo;
const isRegister = data.detDto?.rgtr === userInfo?.userUnqId;
@@ -217,13 +217,17 @@ function FloatingActionButtons({
const canEdit =
isRegister && actionButtons.some((button) => button === FNRS_CNSLTN_VIEW_BUTTON_ACTION.strg);
const canDmndSplmntInPwrkCnsltnDoc =
prgrsSttsComCd === "RK" &&
atrzSeComCdList.includes("ATRZ02") &&
actionButtons.some((button) => button === FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt);
function handleGoToList() {
router.push("/fnrs-cnsltn");
router.push(getListHref("/fnrs-cnsltn"));
}
function handleEdit() {
router.push(`/fnrs-cnsltn/${fnrsCnsltnDocNo}/edit`);
router.push(withListQs(`/fnrs-cnsltn/${fnrsCnsltnDocNo}/edit`));
}
function shouldShowButton(action: FnrsCnsltnViewButtonAction) {
@@ -314,7 +318,7 @@ function FloatingActionButtons({
action: FNRS_CNSLTN_VIEW_BUTTON_ACTION.del,
successMessage: "삭제되었습니다.",
});
router.push("/fnrs-cnsltn");
router.push(getListHref("/fnrs-cnsltn"));
}
async function handleEnvoSbmsnClick() {
@@ -533,6 +537,7 @@ function FloatingActionButtons({
}
async function handleDmndSplmntConfirm(payload: SplmntDmndDialogConfirmPayload) {
setIsDmndSplmntDialogOpen(false);
setActiveDialog(null);
const requestPayload = buildSupplementReqPayload(data, payload, checklistFormState);
await executeAction({
@@ -763,7 +768,7 @@ function FloatingActionButtons({
{shouldShowButton(FNRS_CNSLTN_VIEW_BUTTON_ACTION.dmndSplmnt) && (
<Button
color="gray"
onClick={() => setActiveDialog("dmndSplmnt")}
onClick={() => setIsDmndSplmntDialogOpen(true)}
disabled={isBusy}
>
보완 요청
@@ -802,8 +807,8 @@ function FloatingActionButtons({
<SplmntDmndDialog
splmntArtclUpCd="SPLMNTARTCL"
open={activeDialog === "dmndSplmnt"}
onOpenChange={() => setActiveDialog(null)}
open={isDmndSplmntDialogOpen}
onOpenChange={() => setIsDmndSplmntDialogOpen(false)}
isSubmitting={isBusy}
onConfirm={handleDmndSplmntConfirm}
/>
@@ -839,6 +844,9 @@ function FloatingActionButtons({
? false
: null
}
onDmndSplmnt={
canDmndSplmntInPwrkCnsltnDoc ? () => setIsDmndSplmntDialogOpen(true) : undefined
}
onClose={() => setActiveDialog(null)}
/>
@@ -121,7 +121,7 @@ function PwrkCnsltnDocEvlDialog({ open, pwrkCnsltnDocNo, onClose }: PwrkCnsltnDo
<DialogFooter>
<Button type="button" color="white" onClick={onClose} disabled={isPending}>
닫기
취소
</Button>
<Button type="submit" disabled={isPending}>
제출
@@ -58,6 +58,7 @@ interface PwrkCnsltnDocMngDialogProps {
atrzSeComCdList?: string[];
prgrsSttsComCd?: string;
canSave?: boolean | null;
onDmndSplmnt?: () => void;
onClose: () => void;
}
@@ -78,6 +79,7 @@ function PwrkCnsltnDocMngDialog({
atrzSeComCdList,
prgrsSttsComCd,
canSave: canSaveProp,
onDmndSplmnt,
onClose,
}: PwrkCnsltnDocMngDialogProps) {
const queryClient = useQueryClient();
@@ -309,6 +311,7 @@ function PwrkCnsltnDocMngDialog({
canSave={canSave}
onSaveDocument={handleSaveDocument}
onDownload={handleDownload}
onDmndSplmnt={onDmndSplmnt}
/>
</div>
{pjtcoAjmtPage?.content?.length ? (
@@ -8,8 +8,8 @@ import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQueryClient } from "@tanstack/react-query";
import { useAlertStore, useUserStore } from "@/shared/lib";
import { RhfDevTool, SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
import { getListHref, useAlertStore, useUserStore, withListQs } from "@/shared/lib";
import { SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
import { useAcntgSeMngTree } from "@/entities/acntg";
import { useEsntlAtchFileMngTree } from "@/entities/esntl";
import {
@@ -153,7 +153,7 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
alert("보완 완료 처리되었습니다.");
await queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
router.push(`/fnrs-cnsltn/${docNo}`);
router.push(withListQs(`/fnrs-cnsltn/${docNo}`));
} catch (error) {
console.error("보완 완료 실패:", error);
alert("보완 완료 처리 중 오류가 발생했습니다.");
@@ -172,7 +172,7 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
alert("저장되었습니다.");
await queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
router.push(`/fnrs-cnsltn/${fnrsCnsltnDocNo}/edit`);
router.push(withListQs(`/fnrs-cnsltn/${fnrsCnsltnDocNo}/edit`));
} catch (error) {
console.error("저장 실패:", error);
alert("저장 중 오류가 발생했습니다.");
@@ -206,7 +206,7 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
alert("신청 완료되었습니다.");
await queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
router.push(`/fnrs-cnsltn/${fnrsCnsltnDocNo}`);
router.push(withListQs(`/fnrs-cnsltn/${fnrsCnsltnDocNo}`));
} catch (error) {
console.error("신청 실패:", error);
alert("신청 중 오류가 발생했습니다.");
@@ -235,7 +235,7 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
await fnrsCnsltnMngService.remove(docNo);
alert("삭제되었습니다.");
await queryClient.invalidateQueries({ queryKey: fnrsCnsltnKeys.all });
router.push("/fnrs-cnsltn");
router.push(getListHref("/fnrs-cnsltn"));
} catch (error) {
console.error("삭제 실패:", error);
alert("삭제 중 오류가 발생했습니다.");
@@ -285,7 +285,7 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
<Button
color="white"
type="button"
onClick={() => router.push("/fnrs-cnsltn")}
onClick={() => router.push(getListHref("/fnrs-cnsltn"))}
disabled={isSubmitting}
>
목록
@@ -303,7 +303,7 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
onClick={handleApplyClick}
disabled={isSubmitting}
>
신청완료
신청 완료
</Button>
<Button
color="red"
@@ -350,7 +350,6 @@ function FnrsFormPage({ mode, initialData, createDefaults }: FnrsFormPageProps)
onClose={() => setIsPwrkCnsltnDocMngOpen(false)}
/>
) : null}
<RhfDevTool control={form.control} />
</FormProvider>
);
}
@@ -1,14 +1,11 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import type { PaginationState } from "@tanstack/react-table";
import {
type CmCdAdstDstrComboResponseDto,
useFindGugunCombo,
useFindSidoCombo,
} from "@/entities/adstdstr";
import { useSearchParamsState } from "@/shared/lib";
import { useFindUserSidoGugunCombo } from "@/entities/adstdstr";
import { CodeSelect } from "@/entities/code";
import type { FnrsCnsltnMngSearchDto } from "@/entities/fnrs-cnsltn";
import {
@@ -27,48 +24,58 @@ import { FnrsList } from "./fnrs-list";
const DEFAULT_PAGE_INDEX = 0;
const DEFAULT_PAGE_SIZE = 10;
const DEFAULT_SORT = "fnrsCnsltnDocNo,desc";
const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = [];
const DEFAULT_SEARCH_PARAMS: FnrsCnsltnMngSearchDto = {
bizNm: "",
taskSeComCd: "",
admdstCd: "",
page: DEFAULT_PAGE_INDEX,
size: DEFAULT_PAGE_SIZE,
sort: DEFAULT_SORT,
};
const ALL_SELECT_VALUE = "__ALL__";
function FnrsListPage() {
const [bizNm, setBizNm] = useState("");
const [taskSeComCd, setTaskSeComCd] = useState("");
const [sidoComCd, setSidoComCd] = useState("");
const [sggComCd, setSggComCd] = useState("");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: DEFAULT_PAGE_INDEX,
pageSize: DEFAULT_PAGE_SIZE,
});
const [searchParams, setSearchParams] = useState<FnrsCnsltnMngSearchDto>(DEFAULT_SEARCH_PARAMS);
const DEFAULT_PARAMS = {
sidoComCd: "",
sggComCd: "",
taskSeComCd: "",
bizNm: "",
page: DEFAULT_PAGE_INDEX,
size: DEFAULT_PAGE_SIZE,
};
const { data: sidoData } = useFindSidoCombo();
const { data: gugunData } = useFindGugunCombo(sidoComCd);
const sidoOptions = sidoData ?? EMPTY_ADST_LIST;
const gugunOptions = gugunData ?? EMPTY_ADST_LIST;
function FnrsListPage() {
const { params, setParams } = useSearchParamsState(DEFAULT_PARAMS);
const [bizNm, setBizNm] = useState(params.bizNm);
const [taskSeComCd, setTaskSeComCd] = useState(params.taskSeComCd);
const [sidoComCd, setSidoComCd] = useState(params.sidoComCd);
const [sggComCd, setSggComCd] = useState(params.sggComCd);
const { userSidoData, userGugunData } = useFindUserSidoGugunCombo(sidoComCd);
const pagination: PaginationState = {
pageIndex: params.page,
pageSize: params.size,
};
const searchParams: FnrsCnsltnMngSearchDto = useMemo(
() => ({
bizNm: params.bizNm,
taskSeComCd: params.taskSeComCd,
admdstCd: params.sggComCd || params.sidoComCd || "",
page: params.page,
size: params.size,
sort: DEFAULT_SORT,
}),
[params],
);
const handleSearch = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setPagination((prev) => ({ ...prev, pageIndex: DEFAULT_PAGE_INDEX }));
setSearchParams({
...DEFAULT_SEARCH_PARAMS,
setParams({
bizNm,
taskSeComCd,
admdstCd: sggComCd || sidoComCd || "",
sidoComCd,
sggComCd,
page: DEFAULT_PAGE_INDEX,
size: pagination.pageSize,
});
};
const handlePaginationChange = (next: PaginationState) => {
setParams({ page: next.pageIndex, size: next.pageSize });
};
return (
<div className="space-y-4 px-[1.8rem] pb-6">
<section className="card card-border mx-0 bg-background">
@@ -89,7 +96,7 @@ function FnrsListPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_SELECT_VALUE}>전체</SelectItem>
{sidoOptions.map((option) => (
{userSidoData.map((option) => (
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
{option.adstDstrNm}
</SelectItem>
@@ -111,7 +118,7 @@ function FnrsListPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_SELECT_VALUE}>전체</SelectItem>
{gugunOptions.map((option) => (
{userGugunData.map((option) => (
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
{option.adstDstrNm}
</SelectItem>
@@ -143,7 +150,7 @@ function FnrsListPage() {
<FnrsList
searchParams={searchParams}
pagination={pagination}
onPaginationChange={setPagination}
onPaginationChange={handlePaginationChange}
/>
</div>
);
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { useDataTable } from "@/shared/lib";
import { useDataTable, withListQs } from "@/shared/lib";
import {
FnrsCnsltnMngResponseDto,
type FnrsCnsltnMngSearchDto,
@@ -43,7 +43,7 @@ function FnrsList({ searchParams, pagination, onPaginationChange }: FnrsListProp
const columns = useMemo(() => createFnrsCnsltnColumns(), []);
const handleRowClick = (row: FnrsCnsltnMngResponseDto) => {
router.push(`/fnrs-cnsltn/${row.fnrsCnsltnDocNo}`);
router.push(withListQs(`/fnrs-cnsltn/${row.fnrsCnsltnDocNo}`));
};
const { tableComponent } = useDataTable({
@@ -3,6 +3,7 @@
import { useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { NON_NEGATIVE_INPUT_PROPS } from "@/shared/lib";
import { SectionCollapsible } from "@/shared/ui";
import { Field, FieldError, FieldGroup, FieldLabel, FieldSet } from "@/shared/ui/field";
import { findCodeLabel, useCodeList } from "@/entities/code";
@@ -92,9 +93,11 @@ function BasicInfoFormSection() {
name="fnrsCnsltnMng.bizBgngYr"
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid} className="w-auto">
<Field data-invalid={fieldState.invalid} className="w-auto !gap-1">
<Input
{...field}
{...NON_NEGATIVE_INPUT_PROPS}
type="number"
id={field.name}
placeholder="시작연도"
className="max-w-[12rem]"
@@ -109,9 +112,11 @@ function BasicInfoFormSection() {
name="fnrsCnsltnMng.bizEndYr"
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid} className="w-auto">
<Field data-invalid={fieldState.invalid} className="w-auto !gap-1">
<Input
{...field}
{...NON_NEGATIVE_INPUT_PROPS}
type="number"
id={field.name}
placeholder="종료연도"
className="max-w-[12rem]"
@@ -12,8 +12,14 @@ import {
export const instlSncnRegistSchema = z.object({
instlSncnMng: instlSncnMngSaveSchema.extend({
bizNm: z.string().min(1, "사업명은 필수입니다"),
bizBgngYr: z.string().min(1, "사업시작연도는 필수입니다"),
bizEndYr: z.string().min(1, "사업종료연도는 필수입니다"),
bizBgngYr: z
.string()
.min(1, "사업시작연도는 필수입니다")
.regex(/^\d{4}$/, "연도는 4자리 숫자여야 합니다"),
bizEndYr: z
.string()
.min(1, "사업종료연도는 필수입니다")
.regex(/^\d{4}$/, "연도는 4자리 숫자여야 합니다"),
bizCn: z.string().min(1, "사업내용은 필수입니다"),
taskSeComCd: z.array(z.string()).min(1, "구분은 필수입니다"),
sncnSeComCd: z.string().min(1, "사업구분은 필수입니다"),
+12 -1
View File
@@ -103,6 +103,13 @@ function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N")
];
}
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
if (prgrsSttsComCd === "SL") {
return <Badge color="orange">보완요청</Badge>;
}
return null;
}
function getBadgeColor(state: BadgeState) {
switch (state) {
case "completed":
@@ -153,7 +160,11 @@ export function createInstlSncnColumns() {
columnHelper.accessor("bizNm", {
header: "사업명",
meta: { align: "left" },
cell: ({ getValue }) => formatOptional(getValue()),
cell: ({ row, getValue }) => (
<>
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
</>
),
}),
columnHelper.accessor("taskSeCdNm", {
header: "업무구분",
@@ -50,8 +50,8 @@ export const REVIEW_STEP_CODES: ReviewStepCode[] = ["LOCAL", "ENV", "KECO"];
export const CHECKLIST_TAB_LABELS: Record<ReviewStepCode, string> = {
LOCAL: "자가체크",
ENV: "기술검토",
KECO: "환경청검토",
ENV: "환경청검토",
KECO: "기술검토",
};
export const REVIEW_STEP_LABELS: Record<ReviewStepCode, string> = {
@@ -8,7 +8,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useUserStore } from "@/shared/lib";
import { getListHref, useAlertStore, useUserStore, withListQs } from "@/shared/lib";
import {
buildElaprPrgrsSttsListFromAplnRows,
ElaprApprovalDialog,
@@ -114,11 +114,11 @@ function FloatingActionButtons({
isRegister && actionButtons.some((button) => button === INSTL_SNCN_VIEW_BUTTON_ACTION.strg);
function handleGoToList() {
router.push("/instl-sncn");
router.push(getListHref("/instl-sncn"));
}
function handleEdit() {
router.push(`/instl-sncn/${instlSncnDocNo}/edit`);
router.push(withListQs(`/instl-sncn/${instlSncnDocNo}/edit`));
}
function shouldShowButton(action: InstlSncnViewButtonAction) {
@@ -212,7 +212,7 @@ function FloatingActionButtons({
action: INSTL_SNCN_VIEW_BUTTON_ACTION.del,
successMessage: "삭제되었습니다.",
});
router.push("/instl-sncn");
router.push(getListHref("/instl-sncn"));
}
async function handleInstlSncnAncmntClick() {
@@ -8,8 +8,8 @@ import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQueryClient } from "@tanstack/react-query";
import { useAlertStore, useUserStore } from "@/shared/lib";
import { RhfDevTool, SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
import { getListHref, useAlertStore, useUserStore, withListQs } from "@/shared/lib";
import { SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
import { useEsntlAtchFileMngTree } from "@/entities/esntl";
import {
instlSncnKeys,
@@ -30,8 +30,15 @@ import {
} from "@/widgets/pbadms-sprt";
import { Button } from "@common/components";
import { buildFormPayload, getCreateDefaultValues, mapDetailToFormData } from "../lib/form-data-mapper";
import { type InstlSncnRegistFormData, instlSncnRegistSchema } from "../lib/instl-sncn-regist-schema";
import {
buildFormPayload,
getCreateDefaultValues,
mapDetailToFormData,
} from "../lib/form-data-mapper";
import {
type InstlSncnRegistFormData,
instlSncnRegistSchema,
} from "../lib/instl-sncn-regist-schema";
import { PROGRESS_STATUS_COLUMNS } from "../model/columns";
import { BasicInfoFormSection } from "./sections/basic-info-form-section";
@@ -94,7 +101,7 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
(item) => !item.splmntCn || !item.splmntCn.trim(),
);
if (hasEmptySplmntCn) {
alert("보완요청내용이 있습니다. 보완완료내용을 입력해 주세요.");
alert("보완 요청 내용이 있습니다.\n보완 완료 내용을 입력해 주세요.");
return;
}
@@ -109,12 +116,12 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
const result = await submitSupplementForm(data);
if (!result) return;
alert("보완완료 처리되었습니다.");
router.push(`/instl-sncn/${docNo}`);
queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
alert("보완 완료 처리되었습니다.");
await queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
router.push(withListQs(`/instl-sncn/${docNo}`));
} catch (error) {
console.error("보완완료 실패:", error);
alert("보완완료 처리 중 오류가 발생했습니다.");
console.error("보완 완료 실패:", error);
alert("보완 완료 처리 중 오류가 발생했습니다.");
} finally {
setIsSubmitting(false);
}
@@ -129,7 +136,7 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
if (!instlSncnDocNo) return;
alert("저장되었습니다.");
router.push(`/instl-sncn/${instlSncnDocNo}/edit`);
router.push(withListQs(`/instl-sncn/${instlSncnDocNo}/edit`));
queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
} catch (error) {
console.error("저장 실패:", error);
@@ -142,6 +149,11 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
async function onApply(data: InstlSncnRegistFormData) {
if (isSubmitting) return;
if (data.privacyConsent !== "Y") {
alert("개인정보 수집/이용에 동의해야 신청이 가능합니다.");
return;
}
if (!isFormChecklistComplete(data.cklstResultList)) {
alert("모든 체크리스트 항목을 체크해야 신청이 가능합니다.");
return;
@@ -158,7 +170,7 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
if (!instlSncnDocNo) return;
alert("신청 완료되었습니다.");
router.push(`/instl-sncn/${instlSncnDocNo}`);
router.push(withListQs(`/instl-sncn/${instlSncnDocNo}`));
queryClient.invalidateQueries({ queryKey: instlSncnKeys.all });
} catch (error) {
console.error("신청 실패:", error);
@@ -184,7 +196,7 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
await instlSncnService.remove(docNo);
alert("삭제되었습니다.");
queryClient.invalidateQueries({ queryKey: instlSncnKeys.list() });
router.push("/instl-sncn");
router.push(getListHref("/instl-sncn"));
} catch (error) {
console.error("삭제 실패:", error);
alert("삭제 중 오류가 발생했습니다.");
@@ -227,7 +239,7 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
<Button
color="white"
type="button"
onClick={() => router.push("/instl-sncn")}
onClick={() => router.push(getListHref("/instl-sncn"))}
disabled={isSubmitting}
>
목록
@@ -245,7 +257,7 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
onClick={form.handleSubmit(onApply)}
disabled={isSubmitting}
>
신청완료
신청 완료
</Button>
<Button
color="red"
@@ -264,7 +276,7 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
onClick={form.handleSubmit(onSplmntCmptn)}
disabled={isSubmitting}
>
보완완료
보완 완료
</Button>
)}
</div>
@@ -272,7 +284,6 @@ function InstlFormPage({ mode, initialData, createDefaults }: InstlFormPageProps
</div>
</SectionCollapsibleGroup>
</form>
<RhfDevTool control={form.control} />
</FormProvider>
);
}
@@ -1,11 +1,11 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import type { PaginationState } from "@tanstack/react-table";
import type { CmCdAdstDstrComboResponseDto } from "@/entities/adstdstr";
import { useFindGugunCombo, useFindSidoCombo } from "@/entities/adstdstr";
import { useSearchParamsState } from "@/shared/lib";
import { useFindUserSidoGugunCombo } from "@/entities/adstdstr";
import { CodeSelect } from "@/entities/code";
import type { InstlSncnMngSearchParams } from "@/entities/instl-sncn";
import {
@@ -24,46 +24,58 @@ import { InstlList } from "./instl-list";
const DEFAULT_PAGE_INDEX = 0;
const DEFAULT_PAGE_SIZE = 10;
const DEFAULT_SORT = "instlSncnDocNo,desc";
const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = [];
const ALL_SELECT_VALUE = "__ALL__";
const DEFAULT_SEARCH_PARAMS: InstlSncnMngSearchParams = {
bizNm: "",
const DEFAULT_PARAMS = {
sidoComCd: "",
sggComCd: "",
taskSeComCd: "",
admdstCd: "",
bizNm: "",
page: DEFAULT_PAGE_INDEX,
size: DEFAULT_PAGE_SIZE,
sort: DEFAULT_SORT,
};
function InstlListPage() {
const [bizNm, setBizNm] = useState("");
const [taskSeComCd, setTaskSeComCd] = useState("");
const [sidoComCd, setSidoComCd] = useState("");
const [sggComCd, setSggComCd] = useState("");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: DEFAULT_PAGE_INDEX,
pageSize: DEFAULT_PAGE_SIZE,
});
const [searchParams, setSearchParams] = useState<InstlSncnMngSearchParams>(DEFAULT_SEARCH_PARAMS);
const { params, setParams } = useSearchParamsState(DEFAULT_PARAMS);
const [bizNm, setBizNm] = useState(params.bizNm);
const [taskSeComCd, setTaskSeComCd] = useState(params.taskSeComCd);
const [sidoComCd, setSidoComCd] = useState(params.sidoComCd);
const [sggComCd, setSggComCd] = useState(params.sggComCd);
const { data: sidoData } = useFindSidoCombo();
const { data: gugunData } = useFindGugunCombo(sidoComCd);
const sidoOptions = sidoData ?? EMPTY_ADST_LIST;
const gugunOptions = gugunData ?? EMPTY_ADST_LIST;
const { userSidoData, userGugunData } = useFindUserSidoGugunCombo(sidoComCd);
const pagination: PaginationState = {
pageIndex: params.page,
pageSize: params.size,
};
const searchParams: InstlSncnMngSearchParams = useMemo(
() => ({
bizNm: params.bizNm,
taskSeComCd: params.taskSeComCd,
admdstCd: params.sggComCd || params.sidoComCd || "",
page: params.page,
size: params.size,
sort: DEFAULT_SORT,
}),
[params],
);
const handleSearch = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setPagination((prev) => ({ ...prev, pageIndex: DEFAULT_PAGE_INDEX }));
setSearchParams({
...DEFAULT_SEARCH_PARAMS,
setParams({
bizNm,
taskSeComCd,
admdstCd: sggComCd || sidoComCd || "",
sidoComCd,
sggComCd,
page: DEFAULT_PAGE_INDEX,
size: pagination.pageSize,
});
};
const handlePaginationChange = (next: PaginationState) => {
setParams({ page: next.pageIndex, size: next.pageSize });
};
return (
<div className="space-y-4 px-[1.8rem] pb-6">
<section className="card card-border mx-0 bg-background">
@@ -75,7 +87,7 @@ function InstlListPage() {
<Select
value={sidoComCd}
onValueChange={(value) => {
setSidoComCd(value);
setSidoComCd(value === ALL_SELECT_VALUE ? "" : value);
setSggComCd("");
}}
>
@@ -83,7 +95,8 @@ function InstlListPage() {
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
{sidoOptions.map((option) => (
<SelectItem value={ALL_SELECT_VALUE}>전체</SelectItem>
{userSidoData.map((option) => (
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
{option.adstDstrNm}
</SelectItem>
@@ -95,12 +108,17 @@ function InstlListPage() {
<Label htmlFor="sggComCd" className="whitespace-nowrap">
시군구
</Label>
<Select value={sggComCd} onValueChange={setSggComCd} disabled={!sidoComCd}>
<Select
value={sggComCd}
onValueChange={(value) => setSggComCd(value === ALL_SELECT_VALUE ? "" : value)}
disabled={!sidoComCd}
>
<SelectTrigger id="sggComCd" className="w-[12rem]">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
{gugunOptions.map((option) => (
<SelectItem value={ALL_SELECT_VALUE}>전체</SelectItem>
{userGugunData.map((option) => (
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
{option.adstDstrNm}
</SelectItem>
@@ -132,7 +150,7 @@ function InstlListPage() {
<InstlList
searchParams={searchParams}
pagination={pagination}
onPaginationChange={setPagination}
onPaginationChange={handlePaginationChange}
/>
</div>
);
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { useDataTable } from "@/shared/lib";
import { useDataTable, withListQs } from "@/shared/lib";
import {
type InstlSncnMngResponseDto,
type InstlSncnMngSearchParams,
@@ -43,7 +43,7 @@ function InstlList({ searchParams, pagination, onPaginationChange }: InstlListPr
const columns = useMemo(() => createInstlSncnColumns(), []);
const handleRowClick = (row: InstlSncnMngResponseDto) => {
router.push(`/instl-sncn/${row.instlSncnDocNo}`);
router.push(withListQs(`/instl-sncn/${row.instlSncnDocNo}`));
};
const { tableComponent } = useDataTable({
@@ -95,7 +95,9 @@ function InstlViewPage({ id }: InstlViewPageProps) {
return (
<div className="mt-4">
<SectionCollapsibleGroup>
<SectionCollapsibleToggle />
<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}
@@ -3,6 +3,7 @@
import { useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { NON_NEGATIVE_INPUT_PROPS } from "@/shared/lib";
import { SectionCollapsible } from "@/shared/ui";
import { Field, FieldError, FieldGroup, FieldLabel, FieldSet } from "@/shared/ui/field";
import { findCodeLabel, useCodeList } from "@/entities/code";
@@ -96,9 +97,11 @@ function BasicInfoFormSection() {
name="instlSncnMng.bizBgngYr"
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid} className="w-auto">
<Field data-invalid={fieldState.invalid} className="w-auto !gap-1">
<Input
{...field}
{...NON_NEGATIVE_INPUT_PROPS}
type="number"
id={field.name}
placeholder="시작연도"
className="max-w-[12rem]"
@@ -113,9 +116,11 @@ function BasicInfoFormSection() {
name="instlSncnMng.bizEndYr"
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid} className="w-auto">
<Field data-invalid={fieldState.invalid} className="w-auto !gap-1">
<Input
{...field}
{...NON_NEGATIVE_INPUT_PROPS}
type="number"
id={field.name}
placeholder="종료연도"
className="max-w-[12rem]"
@@ -55,7 +55,7 @@ export function resolveNotiStatus(
return {
label: "미조회",
badgeProps: { color: "yellow" },
badgeProps: { color: "yellow", size: "lg" },
actionLabel: "처리하기",
};
}
+1
View File
@@ -0,0 +1 @@
export { NtitlkSndngPage } from "./ui/ntitlk-sndng-page";
@@ -0,0 +1,20 @@
import type { SelectedNtitlkRcvr } from "../model/ntitlk-rcvr";
/** 템플릿 본문의 `#{변수}`를 값으로 치환한다. 값이 없는 변수는 빈 문자열이 된다. */
export function buildTemplateMessage(
templateContent: string,
variables: Record<string, string>,
): string {
return templateContent.replace(/#?\{([^}]+)\}/g, (_, key: string) => variables[key.trim()] ?? "");
}
export function getMergedVariables(
recipient: SelectedNtitlkRcvr,
commonValues: Record<string, string>,
): Record<string, string> {
return {
...commonValues,
...recipient.variableOverrides,
TEL_NUM: recipient.telNo,
};
}
@@ -0,0 +1,36 @@
import type { PageResponse } from "@/shared/model";
import type { InPrgrsDocPicResponseDto } from "@/entities/dashboard";
import type { RvwMnpwMngResponseDto } from "@/entities/rvw-mnpw";
import type { NtitlkRcvr } from "../model/ntitlk-rcvr";
/** 검토인력은 결재구분별로 행이 나뉘어 내려오므로 사용자 기준으로 한 명씩만 남긴다. */
export function selectRvwMnpwRcvrList(page: PageResponse<RvwMnpwMngResponseDto>): NtitlkRcvr[] {
const rcvrByUserUuid = new Map<string, NtitlkRcvr>();
page.content.forEach((row) => {
if (rcvrByUserUuid.has(row.userUuid)) return;
rcvrByUserUuid.set(row.userUuid, {
rcvrKey: `rvw:${row.userUuid}`,
userNm: row.userUuidNm ?? "",
telNo: row.mblTelno ?? "",
ognzNm: row.ognzNm,
});
});
return [...rcvrByUserUuid.values()];
}
export function selectInPrgrsDocRcvrList(rows: InPrgrsDocPicResponseDto[]): NtitlkRcvr[] {
return rows.map((row) => ({
rcvrKey: `doc:${row.docSeComCd}:${row.docNo}`,
userNm: row.aplcntNm ?? "",
telNo: row.aplcntMblTelno ?? "",
docNo: row.docNo,
docNm: row.docNm,
docSeNm: row.docSeNm,
prgrsSttsNm: row.prgrsSttsNm,
prcsDdln: row.prcsDdln?.slice(0, 10),
}));
}
@@ -0,0 +1,52 @@
import { useMemo } from "react";
import type { OnChangeFn, Row, RowSelectionState } from "@tanstack/react-table";
import type { NtitlkRcvr, RcvrSelectionChange } from "../model/ntitlk-rcvr";
interface UseRcvrRowSelectionOptions {
rcvrList: NtitlkRcvr[];
selectedRcvrKeys: Set<string>;
onRcvrSelectionChange: (change: RcvrSelectionChange) => void;
}
/**
* 발송 목록(페이지 상태)을 수신자 테이블의 행 선택 상태로 연결한다.
* 선택 상태를 테이블이 따로 들고 있지 않아, 발송 목록에서 제거하면 체크도 함께 풀린다.
*/
export function useRcvrRowSelection({
rcvrList,
selectedRcvrKeys,
onRcvrSelectionChange,
}: UseRcvrRowSelectionOptions) {
const rowSelection = useMemo(() => {
const selection: RowSelectionState = {};
rcvrList.forEach((rcvr) => {
if (selectedRcvrKeys.has(rcvr.rcvrKey)) selection[rcvr.rcvrKey] = true;
});
return selection;
}, [rcvrList, selectedRcvrKeys]);
const handleRowSelectionChange: OnChangeFn<RowSelectionState> = (updater) => {
const next = typeof updater === "function" ? updater(rowSelection) : updater;
onRcvrSelectionChange({
added: rcvrList.filter((rcvr) => next[rcvr.rcvrKey] && !rowSelection[rcvr.rcvrKey]),
removedKeys: rcvrList
.filter((rcvr) => !next[rcvr.rcvrKey] && rowSelection[rcvr.rcvrKey])
.map((rcvr) => rcvr.rcvrKey),
});
};
return {
selectedCount: Object.keys(rowSelection).length,
rowSelectionOptions: {
state: { rowSelection },
// 전화번호가 없으면 발송할 수 없으므로 선택 대상에서 제외한다.
enableRowSelection: (row: Row<NtitlkRcvr>) => Boolean(row.original.telNo),
enableMultiRowSelection: true,
getRowId: (rcvr: NtitlkRcvr) => rcvr.rcvrKey,
onRowSelectionChange: handleRowSelectionChange,
},
};
}
@@ -0,0 +1,23 @@
/** 검토인력·문서 담당자 목록을 발송 목록 하나로 합치기 위한 수신자 모델 */
export interface NtitlkRcvr {
/** 행 식별자: 검토인력 `rvw:{userUuid}`, 문서 담당자 `doc:{docSeComCd}:{docNo}` */
rcvrKey: string;
userNm: string;
telNo: string;
ognzNm?: string;
docNo?: string;
docNm?: string;
docSeNm?: string;
prgrsSttsNm?: string;
prcsDdln?: string;
}
export interface SelectedNtitlkRcvr extends NtitlkRcvr {
variableOverrides: Record<string, string>;
}
/** 수신자 목록에서 선택 상태가 바뀐 행 */
export interface RcvrSelectionChange {
added: NtitlkRcvr[];
removedKeys: string[];
}
@@ -0,0 +1,77 @@
"use client";
import { createColumnHelper } from "@tanstack/react-table";
import { Checkbox } from "@common/components";
import type { NtitlkRcvr } from "./ntitlk-rcvr";
const columnHelper = createColumnHelper<NtitlkRcvr>();
function formatOptional(value: string | undefined) {
return value?.trim() ? value : "-";
}
const selectColumn = columnHelper.display({
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllRowsSelected()
? true
: table.getIsSomeRowsSelected()
? "indeterminate"
: false
}
onCheckedChange={(checked) => table.toggleAllRowsSelected(!!checked)}
aria-label="전체 선택"
/>
),
meta: { align: "center", width: 48 },
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
onCheckedChange={(checked) => row.toggleSelected(!!checked)}
aria-label="행 선택"
/>
),
});
export const rvwMnpwRcvrColumns = [
selectColumn,
columnHelper.accessor("userNm", {
header: "이름",
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("ognzNm", {
header: "소속",
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("telNo", {
header: "전화번호",
cell: ({ getValue }) => formatOptional(getValue()),
}),
];
export const inPrgrsDocRcvrColumns = [
selectColumn,
columnHelper.accessor("docNo", {
header: "문서번호",
meta: { align: "left" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("docNm", {
header: "문서명",
meta: { align: "left" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("userNm", {
header: "신청자",
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("prgrsSttsNm", {
header: "상태",
cell: ({ getValue }) => formatOptional(getValue()),
}),
];
@@ -0,0 +1,23 @@
import type { NtitlkRcvr } from "./ntitlk-rcvr";
export const CUSTOM_VRBL_MAPPING = "custom";
export type VrblMappingKey = Exclude<keyof NtitlkRcvr, "rcvrKey"> | typeof CUSTOM_VRBL_MAPPING;
interface VrblMappingOption {
value: VrblMappingKey;
label: string;
}
/** 변수 일괄 적용 시 템플릿 변수에 채울 수신자 필드 */
export const VRBL_MAPPING_OPTIONS: readonly VrblMappingOption[] = [
{ value: "userNm", label: "이름" },
{ value: "telNo", label: "전화번호" },
{ value: "ognzNm", label: "소속" },
{ value: "docNo", label: "문서번호" },
{ value: "docNm", label: "문서명" },
{ value: "docSeNm", label: "문서구분" },
{ value: "prgrsSttsNm", label: "진행상태" },
{ value: "prcsDdln", label: "처리기한" },
{ value: CUSTOM_VRBL_MAPPING, label: "직접입력" },
];
@@ -0,0 +1,45 @@
import { getCoreRowModel } from "@tanstack/react-table";
import { useDataTable } from "@/shared/lib";
import { useGetInPrgrsDocPicList } from "@/entities/dashboard";
import { selectInPrgrsDocRcvrList } from "../lib/to-ntitlk-rcvr";
import { useRcvrRowSelection } from "../lib/use-rcvr-row-selection";
import type { NtitlkRcvr, RcvrSelectionChange } from "../model/ntitlk-rcvr";
import { inPrgrsDocRcvrColumns } from "../model/rcvr-table-columns";
import { RcvrListCard } from "./rcvr-list-card";
const EMPTY_RCVR_LIST: NtitlkRcvr[] = [];
interface InPrgrsDocRcvrListProps {
selectedRcvrKeys: Set<string>;
onRcvrSelectionChange: (change: RcvrSelectionChange) => void;
}
function InPrgrsDocRcvrList({ selectedRcvrKeys, onRcvrSelectionChange }: InPrgrsDocRcvrListProps) {
const rcvrQuery = useGetInPrgrsDocPicList(undefined, { select: selectInPrgrsDocRcvrList });
const { selectedCount, rowSelectionOptions } = useRcvrRowSelection({
rcvrList: rcvrQuery.data ?? EMPTY_RCVR_LIST,
selectedRcvrKeys,
onRcvrSelectionChange,
});
const { tableComponent } = useDataTable({
query: rcvrQuery,
tableOptions: {
columns: inPrgrsDocRcvrColumns,
getCoreRowModel: getCoreRowModel(),
...rowSelectionOptions,
},
});
return (
<RcvrListCard title="현재 진행중 문서 담당자(신청자)" selectedCount={selectedCount}>
{tableComponent}
</RcvrListCard>
);
}
export { InPrgrsDocRcvrList };
@@ -0,0 +1,46 @@
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@common/components";
import type { SelectedNtitlkRcvr } from "../model/ntitlk-rcvr";
import { NtitlkPrvw } from "./ntitlk-prvw";
interface NtitlkPrvwDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
recipient: SelectedNtitlkRcvr | null;
message: string;
}
function NtitlkPrvwDialog({ open, onOpenChange, recipient, message }: NtitlkPrvwDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[36rem]" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>알림톡 미리보기</DialogTitle>
<DialogDescription className="text-[1.1rem] tracking-tight">
수신자: {recipient?.userNm || "-"} / {recipient?.telNo || "-"}
</DialogDescription>
</DialogHeader>
<NtitlkPrvw message={message || "메시지를 생성할 수 없습니다."} />
<DialogFooter>
<DialogClose asChild>
<Button type="button" color="white">
닫기
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { NtitlkPrvwDialog };
@@ -0,0 +1,25 @@
const SNDR_NM = "디지털 행정지원 시스템";
interface NtitlkPrvwProps {
message: string;
}
function NtitlkPrvw({ message }: NtitlkPrvwProps) {
return (
<div className="relative">
<div className="h-[32rem] overflow-auto rounded-[20px] rounded-b-none bg-[rgb(176,192,207)] p-8 pb-0 before:absolute before:left-0 before:top-0 before:block before:h-full before:w-full before:rounded-[20px] before:rounded-b-none before:border-[10px] before:border-b-0 before:border-black before:content-['']">
<div className="w-[calc(100%-2rem)] pb-5">
<span className="text-[1.1rem] text-black">{SNDR_NM}</span>
<div className="mt-2 rounded-t-[12px] bg-[rgb(247,228,54)] p-4 text-[1.2rem] font-medium text-black">
알림톡 도착
</div>
<div className="whitespace-pre-wrap rounded-b-[12px] bg-white p-4 text-[1.2rem] leading-normal tracking-tight text-black">
{message}
</div>
</div>
</div>
</div>
);
}
export { NtitlkPrvw };
@@ -0,0 +1,266 @@
"use client";
import { useMemo, useState } from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useAlertStore } from "@/shared/lib";
import {
type NtftalkTemplate,
useNtftalkTemplateList,
useNtftalkTemplateVariables,
useSendNtftalkBusiness,
} from "@/entities/ntftalk";
import { Button } from "@common/components";
import { buildTemplateMessage, getMergedVariables } from "../lib/build-tmplt-msg";
import type { RcvrSelectionChange, SelectedNtitlkRcvr } from "../model/ntitlk-rcvr";
import { CUSTOM_VRBL_MAPPING, type VrblMappingKey } from "../model/vrbl-mapping-options";
import { InPrgrsDocRcvrList } from "./in-prgrs-doc-rcvr-list";
import { NtitlkPrvwDialog } from "./ntitlk-prvw-dialog";
import { RvwMnpwRcvrList } from "./rvw-mnpw-rcvr-list";
import { SndngList } from "./sndng-list";
import { TmpltSelectPanel } from "./tmplt-select-panel";
import { VrblBulkApply } from "./vrbl-bulk-apply";
const SNDNG_CALLBACK_TELNO = "032-590-4000";
const EMPTY_TEMPLATES: NtftalkTemplate[] = [];
const EMPTY_VARIABLE_NAMES: string[] = [];
function NtitlkSndngPage() {
const alert = useAlertStore((state) => state.setMessage);
const { confirm } = useConfirm();
const [selectedTemplateCode, setSelectedTemplateCode] = useState("");
const [selectedRecipients, setSelectedRecipients] = useState<SelectedNtitlkRcvr[]>([]);
const [commonVariableValues, setCommonVariableValues] = useState<Record<string, string>>({});
const [commonVariableMapping, setCommonVariableMapping] = useState<
Record<string, VrblMappingKey>
>({});
const [previewRecipient, setPreviewRecipient] = useState<SelectedNtitlkRcvr | null>(null);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const { data: templatePage } = useNtftalkTemplateList();
const templates = templatePage?.content ?? EMPTY_TEMPLATES;
const selectedTemplate = templates.find(
(template) => template.templateCode === selectedTemplateCode,
);
const { data: templateVariables } = useNtftalkTemplateVariables(selectedTemplateCode);
const variableNames = templateVariables?.variables ?? EMPTY_VARIABLE_NAMES;
const { mutate: sendNtftalk, isPending: isSending } = useSendNtftalkBusiness();
const selectedRcvrKeys = useMemo(
() => new Set(selectedRecipients.map((recipient) => recipient.rcvrKey)),
[selectedRecipients],
);
const previewMessage =
previewRecipient && selectedTemplate?.templateContent
? buildTemplateMessage(
selectedTemplate.templateContent,
getMergedVariables(previewRecipient, commonVariableValues),
)
: "";
const handleTemplateChange = (templateCode: string) => {
setSelectedTemplateCode(templateCode);
setCommonVariableValues({});
setCommonVariableMapping({});
setSelectedRecipients((prev) =>
prev.map((recipient) => ({ ...recipient, variableOverrides: {} })),
);
};
const handleRcvrSelectionChange = ({ added, removedKeys }: RcvrSelectionChange) => {
setSelectedRecipients((prev) => {
const removedKeySet = new Set(removedKeys);
const existingKeySet = new Set(prev.map((recipient) => recipient.rcvrKey));
return [
...prev.filter((recipient) => !removedKeySet.has(recipient.rcvrKey)),
...added
.filter((rcvr) => !existingKeySet.has(rcvr.rcvrKey))
.map((rcvr) => ({ ...rcvr, variableOverrides: {} })),
];
});
};
const handleRemoveRecipient = (rcvrKey: string) => {
setSelectedRecipients((prev) => prev.filter((recipient) => recipient.rcvrKey !== rcvrKey));
};
const handleApplyCommonValues = () => {
setSelectedRecipients((prev) =>
prev.map((recipient) => {
const newOverrides: Record<string, string> = {};
variableNames.forEach((variableName) => {
const mappingKey = commonVariableMapping[variableName];
if (!mappingKey) return;
newOverrides[variableName] =
mappingKey === CUSTOM_VRBL_MAPPING
? (commonVariableValues[variableName] ?? "")
: (recipient[mappingKey] ?? "");
});
return {
...recipient,
variableOverrides: { ...recipient.variableOverrides, ...newOverrides },
};
}),
);
setCommonVariableMapping({});
};
const handleResetCommonValues = () => {
setCommonVariableMapping({});
setCommonVariableValues({});
};
const handleRecipientVariableChange = (rcvrKey: string, variableName: string, value: string) => {
setSelectedRecipients((prev) =>
prev.map((recipient) =>
recipient.rcvrKey === rcvrKey
? {
...recipient,
variableOverrides: { ...recipient.variableOverrides, [variableName]: value },
}
: recipient,
),
);
};
const handlePreview = (recipient: SelectedNtitlkRcvr) => {
if (!selectedTemplate) {
alert("알림톡 템플릿을 먼저 선택해주세요.");
return;
}
setPreviewRecipient(recipient);
setIsPreviewOpen(true);
};
const handleSend = async () => {
if (!selectedTemplateCode) {
alert("알림톡 템플릿을 선택해주세요.");
return;
}
const hasMissingVariable = selectedRecipients.some((recipient) =>
variableNames.some((variableName) => {
const mergedValue =
recipient.variableOverrides[variableName] ?? commonVariableValues[variableName] ?? "";
return mergedValue.trim() === "";
}),
);
if (hasMissingVariable) {
alert("변수값이 입력되지 않은 발송 대상이 있습니다.");
return;
}
if (!(await confirm(`${selectedRecipients.length}명에게 발송하시겠습니까?`))) return;
sendNtftalk(
{
templateCode: selectedTemplateCode,
callback: SNDNG_CALLBACK_TELNO,
recipients: selectedRecipients.map((recipient) =>
getMergedVariables(recipient, commonVariableValues),
),
},
{
onSuccess: (data) => {
if (!data.success) {
alert(data.message || "알림톡 발송 요청에 실패했습니다.");
return;
}
alert(data.message || "알림톡 발송이 요청되었습니다.");
setSelectedRecipients([]);
setCommonVariableValues({});
},
},
);
};
return (
<div className="space-y-4 px-[1.8rem] pb-6">
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-[32rem_minmax(0,1fr)]">
<TmpltSelectPanel
templates={templates}
selectedTemplate={selectedTemplate}
onTemplateChange={handleTemplateChange}
/>
<div className="space-y-4">
<RvwMnpwRcvrList
selectedRcvrKeys={selectedRcvrKeys}
onRcvrSelectionChange={handleRcvrSelectionChange}
/>
<InPrgrsDocRcvrList
selectedRcvrKeys={selectedRcvrKeys}
onRcvrSelectionChange={handleRcvrSelectionChange}
/>
</div>
</div>
<section className="card card-border m-0 bg-background p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-xl font-semibold">발송 목록</h3>
<div className="flex items-center gap-2">
<Button
type="button"
color="white"
onClick={() => setSelectedRecipients([])}
disabled={selectedRecipients.length === 0}
>
초기화
</Button>
<Button
type="button"
color="green"
onClick={handleSend}
disabled={isSending || selectedRecipients.length === 0}
>
발송
</Button>
</div>
</div>
{variableNames.length > 0 && (
<VrblBulkApply
variableNames={variableNames}
mapping={commonVariableMapping}
customValues={commonVariableValues}
onMappingChange={(variableName, mappingKey) =>
setCommonVariableMapping((prev) => ({ ...prev, [variableName]: mappingKey }))
}
onCustomValueChange={(variableName, value) =>
setCommonVariableValues((prev) => ({ ...prev, [variableName]: value }))
}
onApply={handleApplyCommonValues}
onReset={handleResetCommonValues}
/>
)}
<SndngList
recipients={selectedRecipients}
variableNames={variableNames}
onVariableChange={handleRecipientVariableChange}
onPreview={handlePreview}
onRemove={handleRemoveRecipient}
/>
</section>
<NtitlkPrvwDialog
open={isPreviewOpen}
onOpenChange={setIsPreviewOpen}
recipient={previewRecipient}
message={previewMessage}
/>
</div>
);
}
export { NtitlkSndngPage };
@@ -0,0 +1,23 @@
import type { ReactNode } from "react";
interface RcvrListCardProps {
title: string;
selectedCount: number;
children: ReactNode;
}
function RcvrListCard({ title, selectedCount, children }: RcvrListCardProps) {
return (
<section className="card card-border m-0 bg-background p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-xl font-semibold">{title}</h3>
<p className="text-muted-foreground">
<span className="font-semibold text-foreground">{selectedCount}명</span> 선택
</p>
</div>
<div className="h-[16rem] overflow-auto rounded-md border">{children}</div>
</section>
);
}
export { RcvrListCard };
@@ -0,0 +1,46 @@
import { getCoreRowModel } from "@tanstack/react-table";
import { useDataTable } from "@/shared/lib";
import { useRvwMnpwMngPage } from "@/entities/rvw-mnpw";
import { selectRvwMnpwRcvrList } from "../lib/to-ntitlk-rcvr";
import { useRcvrRowSelection } from "../lib/use-rcvr-row-selection";
import type { NtitlkRcvr, RcvrSelectionChange } from "../model/ntitlk-rcvr";
import { rvwMnpwRcvrColumns } from "../model/rcvr-table-columns";
import { RcvrListCard } from "./rcvr-list-card";
const RVW_MNPW_SEARCH = { page: 0, size: 1000, sort: "userUuidNm,asc" };
const EMPTY_RCVR_LIST: NtitlkRcvr[] = [];
interface RvwMnpwRcvrListProps {
selectedRcvrKeys: Set<string>;
onRcvrSelectionChange: (change: RcvrSelectionChange) => void;
}
function RvwMnpwRcvrList({ selectedRcvrKeys, onRcvrSelectionChange }: RvwMnpwRcvrListProps) {
const rcvrQuery = useRvwMnpwMngPage(RVW_MNPW_SEARCH, { select: selectRvwMnpwRcvrList });
const { selectedCount, rowSelectionOptions } = useRcvrRowSelection({
rcvrList: rcvrQuery.data ?? EMPTY_RCVR_LIST,
selectedRcvrKeys,
onRcvrSelectionChange,
});
const { tableComponent } = useDataTable({
query: rcvrQuery,
tableOptions: {
columns: rvwMnpwRcvrColumns,
getCoreRowModel: getCoreRowModel(),
...rowSelectionOptions,
},
});
return (
<RcvrListCard title="검토인력 목록" selectedCount={selectedCount}>
{tableComponent}
</RcvrListCard>
);
}
export { RvwMnpwRcvrList };
@@ -0,0 +1,90 @@
import {
Button,
Input,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
import type { SelectedNtitlkRcvr } from "../model/ntitlk-rcvr";
interface SndngListProps {
recipients: SelectedNtitlkRcvr[];
variableNames: string[];
onVariableChange: (rcvrKey: string, variableName: string, value: string) => void;
onPreview: (recipient: SelectedNtitlkRcvr) => void;
onRemove: (rcvrKey: string) => void;
}
function SndngList({
recipients,
variableNames,
onVariableChange,
onPreview,
onRemove,
}: SndngListProps) {
if (recipients.length === 0)
return (
<p className="rounded-md border p-6 text-center text-muted-foreground">
선택된 발송 대상이 없습니다.
</p>
);
return (
<div className="max-h-[40rem] overflow-auto rounded-md border">
<Table variant="line" className="text-sm">
<TableHeader className="top-0">
<TableRow>
<TableHead className="min-w-[100px]">이름</TableHead>
<TableHead className="min-w-[120px]">전화번호</TableHead>
{variableNames.map((variableName) => (
<TableHead key={variableName} className="min-w-[180px]">
#{variableName}
</TableHead>
))}
<TableHead className="min-w-[160px] text-center">버튼</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recipients.map((recipient) => (
<TableRow key={recipient.rcvrKey}>
<TableCell align="center">{recipient.userNm}</TableCell>
<TableCell align="center">{recipient.telNo}</TableCell>
{variableNames.map((variableName) => (
<TableCell key={variableName}>
<Input
value={recipient.variableOverrides[variableName] ?? ""}
onChange={(event) =>
onVariableChange(recipient.rcvrKey, variableName, event.target.value)
}
className="w-full min-w-[150px]"
/>
</TableCell>
))}
<TableCell align="center">
<div className="flex justify-center gap-2">
<Button type="button" size="sm" color="gray" onClick={() => onPreview(recipient)}>
미리보기
</Button>
<Button
type="button"
size="sm"
color="red"
onClick={() => onRemove(recipient.rcvrKey)}
>
제거
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
export { SndngList };
@@ -0,0 +1,48 @@
import type { NtftalkTemplate } from "@/entities/ntftalk";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@common/components";
import { NtitlkPrvw } from "./ntitlk-prvw";
interface TmpltSelectPanelProps {
templates: NtftalkTemplate[];
selectedTemplate: NtftalkTemplate | undefined;
onTemplateChange: (templateCode: string) => void;
}
function TmpltSelectPanel({
templates,
selectedTemplate,
onTemplateChange,
}: TmpltSelectPanelProps) {
return (
<section className="card card-border m-0 space-y-6 bg-background p-4 pb-0">
<div className="space-y-2">
<h3 className="text-xl font-semibold">알림톡 템플릿 선택</h3>
<Select value={selectedTemplate?.templateCode ?? ""} onValueChange={onTemplateChange}>
<SelectTrigger className="w-full">
<SelectValue placeholder="템플릿 선택" />
</SelectTrigger>
<SelectContent>
{templates.map((template) => (
<SelectItem key={template.templateCode} value={template.templateCode}>
{template.templateName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-4">
<p className="text-lg font-medium">미리보기</p>
<NtitlkPrvw
message={
selectedTemplate?.templateContent ??
"알림톡 템플릿을 선택하면 미리보기 메시지가 표시됩니다."
}
/>
</div>
</section>
);
}
export { TmpltSelectPanel };
@@ -0,0 +1,81 @@
import {
Button,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
import {
CUSTOM_VRBL_MAPPING,
VRBL_MAPPING_OPTIONS,
type VrblMappingKey,
} from "../model/vrbl-mapping-options";
interface VrblBulkApplyProps {
variableNames: string[];
mapping: Record<string, VrblMappingKey>;
customValues: Record<string, string>;
onMappingChange: (variableName: string, mappingKey: VrblMappingKey) => void;
onCustomValueChange: (variableName: string, value: string) => void;
onApply: () => void;
onReset: () => void;
}
function VrblBulkApply({
variableNames,
mapping,
customValues,
onMappingChange,
onCustomValueChange,
onApply,
onReset,
}: VrblBulkApplyProps) {
return (
<div className="mb-4 space-y-4 rounded-md border p-3">
<p className="text-lg font-medium">변수 일괄 적용</p>
<div className="grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-6">
{variableNames.map((variableName) => (
<div key={variableName} className="space-y-2">
<Label className="whitespace-nowrap">{`#{${variableName}}`}</Label>
<Select
value={mapping[variableName] ?? ""}
onValueChange={(value) => onMappingChange(variableName, value as VrblMappingKey)}
>
<SelectTrigger>
<SelectValue placeholder="선택하세요" />
</SelectTrigger>
<SelectContent>
{VRBL_MAPPING_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{mapping[variableName] === CUSTOM_VRBL_MAPPING && (
<Input
placeholder="값을 입력하세요"
value={customValues[variableName] ?? ""}
onChange={(event) => onCustomValueChange(variableName, event.target.value)}
/>
)}
</div>
))}
<div className="flex items-end gap-2">
<Button type="button" color="white" onClick={onReset}>
초기화
</Button>
<Button type="button" onClick={onApply}>
적용하기
</Button>
</div>
</div>
</div>
);
}
export { VrblBulkApply };
@@ -101,6 +101,13 @@ function buildProgressBadges(prgrsSttsComCd: string, techRplyHstryYn: "Y" | "N")
];
}
function getProgressStatusBadge(prgrsSttsComCd?: string | null) {
if (prgrsSttsComCd === "SL") {
return <Badge color="orange">보완요청</Badge>;
}
return null;
}
function getBadgeColor(state: BadgeState) {
switch (state) {
case "completed":
@@ -151,7 +158,11 @@ export function createSewerBscPlanColumns() {
columnHelper.accessor("sewerBscPlanNm", {
header: "기본계획명",
meta: { align: "left" },
cell: ({ getValue }) => formatOptional(getValue()),
cell: ({ row, getValue }) => (
<>
{getProgressStatusBadge(row.original.prgrsSttsComCd)} {formatOptional(getValue())}
</>
),
}),
columnHelper.accessor("sewerBscPlanSeCdNm", {
header: "계획구분",
@@ -44,8 +44,8 @@ export const REVIEW_STEP_CODES: ReviewStepCode[] = ["LOCAL", "ENV", "KECO"];
export const CHECKLIST_TAB_LABELS: Record<ReviewStepCode, string> = {
LOCAL: "자가체크",
ENV: "기술검토",
KECO: "환경청검토",
ENV: "환경청검토",
KECO: "기술검토",
};
export const REVIEW_STEP_LABELS: Record<ReviewStepCode, string> = {
@@ -8,7 +8,7 @@ import { type QueryClient, useQueryClient } from "@tanstack/react-query";
import { AxiosError } from "axios";
import { ENDPOINT } from "@/shared/config";
import { useAlertStore, useUserStore } from "@/shared/lib";
import { getListHref, useAlertStore, useUserStore, withListQs } from "@/shared/lib";
import {
buildElaprPrgrsSttsListFromAplnRows,
ElaprApprovalDialog,
@@ -116,11 +116,11 @@ function FloatingActionButtons({
isRegister && actionButtons.some((button) => button === SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.strg);
function handleGoToList() {
router.push("/sewer-bsc-plan");
router.push(getListHref("/sewer-bsc-plan"));
}
function handleEdit() {
router.push(`/sewer-bsc-plan/${sewerBscPlanDocNo}/edit?chgCycl=${chgCycl}`);
router.push(withListQs(`/sewer-bsc-plan/${sewerBscPlanDocNo}/edit?chgCycl=${chgCycl}`));
}
function shouldShowButton(action: SewerBscPlanViewButtonAction) {
@@ -201,7 +201,7 @@ function FloatingActionButtons({
action: SEWER_BSC_PLAN_VIEW_BUTTON_ACTION.del,
successMessage: "삭제되었습니다.",
});
router.push("/sewer-bsc-plan");
router.push(getListHref("/sewer-bsc-plan"));
}
function getReviewOpinionFormData() {
@@ -502,7 +502,9 @@ async function handleDmndSplmnt(
context.request,
);
await context.queryClient.invalidateQueries({ queryKey: sewerBscPlanKeys.all });
context.router.replace(`/sewer-bsc-plan/${result.sewerBscPlanDocNo}?chgCycl=${result.chgCycl}`);
context.router.replace(
withListQs(`/sewer-bsc-plan/${result.sewerBscPlanDocNo}?chgCycl=${result.chgCycl}`),
);
context.onSuccess?.();
} catch (error) {
context.onError?.(error as Error);
@@ -3,10 +3,11 @@
import { useEffect, useMemo } from "react";
import { useFormContext, useWatch } from "react-hook-form";
import { NON_NEGATIVE_INPUT_PROPS, sanitizeNumberInput } from "@/shared/lib";
import { SectionCollapsible } from "@/shared/ui";
import { Field } from "@/shared/ui/field";
import { useAcntgSeMngTree } from "@/entities/acntg";
import { buildAcntgCostItems } from "@/widgets/pbadms-sprt";
import { buildAcntgCostItems, truncateDecimals } from "@/widgets/pbadms-sprt";
import {
Input,
Skeleton,
@@ -50,7 +51,8 @@ function BizCstMatrixFormSection() {
];
function handleChange(acntgSeCd: string, planStpNo: number, value: string) {
const numValue = Number(value) || 0;
const normalized = truncateDecimals(sanitizeNumberInput(value), 3);
const numValue = Number(normalized.replace(/,/g, "")) || 0;
const next = recalculateBizCstMatrix(
setAmt(getValues("bizCstList"), acntgSeCd, planStpNo, numValue),
costItems,
@@ -112,7 +114,9 @@ function BizCstMatrixFormSection() {
<TableCell key={step.planStpNo}>
<Field>
<Input
{...NON_NEGATIVE_INPUT_PROPS}
type="number"
step={0.001}
value={getAmt(bizCstList, item.code, step.planStpNo) || ""}
onChange={(e) => handleChange(item.code, step.planStpNo, e.target.value)}
readOnly={item.readonly}
@@ -3,6 +3,7 @@
import { useMemo } from "react";
import { useFormContext, useWatch } from "react-hook-form";
import { NON_NEGATIVE_INPUT_PROPS, sanitizeNumberInput } from "@/shared/lib";
import { SectionCollapsible } from "@/shared/ui";
import { Field } from "@/shared/ui/field";
import { useAcntgSeMngTree } from "@/entities/acntg";
@@ -58,7 +59,7 @@ function FnrsCstFormSection() {
);
function handleChange(planStpNo: number, fieldKey: EditableFnrsKey, value: string) {
const numValue = Number(value.replace(/,/g, "")) || 0;
const numValue = Number(sanitizeNumberInput(value).replace(/,/g, "")) || 0;
const draft = [...getValues("fnrsCstList")];
const index = draft.findIndex((item) => item.planStpNo === planStpNo);
if (index < 0) return;
@@ -154,6 +155,7 @@ function FnrsCstFormSection() {
<TableCell key={planStpNo}>
<Field>
<Input
{...NON_NEGATIVE_INPUT_PROPS}
type="number"
className={cn("text-right", row.readonly && "bg-muted")}
readOnly={row.readonly}
@@ -4,8 +4,10 @@ import { useState } from "react";
import { useFormContext, useWatch } from "react-hook-form";
import dynamic from "next/dynamic";
import { NON_NEGATIVE_INPUT_PROPS, sanitizeNumberInput } from "@/shared/lib";
import { SectionCollapsible } from "@/shared/ui";
import { Field } from "@/shared/ui/field";
import { truncateDecimals } from "@/widgets/pbadms-sprt";
import {
Button,
Input,
@@ -28,9 +30,9 @@ const PreviousPlanLookupDialog = dynamic(
);
const OTLN_ROWS = [
{ key: "sewerPlanPpltnCnt" as const, label: "계획인구" },
{ key: "sewerPrcsPpltnCnt" as const, label: "처리인구" },
{ key: "sewerPsplyRt" as const, label: "하수도보급율" },
{ key: "sewerPlanPpltnCnt" as const, label: "계획인구", decimals: 0 },
{ key: "sewerPrcsPpltnCnt" as const, label: "처리인구", decimals: 0 },
{ key: "sewerPsplyRt" as const, label: "하수도보급율", step: 0.1, decimals: 1 },
];
function OutlineFormSection() {
@@ -58,8 +60,11 @@ function OutlineFormSection() {
planStpNo: number,
fieldKey: (typeof OTLN_ROWS)[number]["key"],
value: string,
decimals?: number,
) {
const numValue = Number(value.replace(/,/g, "")) || 0;
const sanitized = sanitizeNumberInput(value);
const normalized = decimals === undefined ? sanitized : truncateDecimals(sanitized, decimals);
const numValue = Number(normalized.replace(/,/g, "")) || 0;
const draft = [...getValues("sewerBscPlanOtlnList")];
const index = draft.findIndex((item) => item.planStpNo === planStpNo);
if (index < 0) return;
@@ -104,11 +109,14 @@ function OutlineFormSection() {
<TableCell key={planStpNo}>
<Field>
<Input
type="text"
inputMode="decimal"
{...NON_NEGATIVE_INPUT_PROPS}
type="number"
step={row.step}
className="text-right"
value={item?.[row.key] ?? ""}
onChange={(e) => handleChange(planStpNo, row.key, e.target.value)}
onChange={(e) =>
handleChange(planStpNo, row.key, e.target.value, row.decimals)
}
/>
</Field>
</TableCell>
@@ -5,13 +5,19 @@ import { Controller, useFieldArray, useFormContext, useWatch } from "react-hook-
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { getDefaultFileKey, useTus } from "@/shared/lib";
import {
getDefaultFileKey,
NON_NEGATIVE_INPUT_PROPS,
sanitizeNumberInput,
useTus,
} from "@/shared/lib";
import { TextShimmer } from "@/shared/ui";
import { SectionCollapsible } from "@/shared/ui";
import { Field } from "@/shared/ui/field";
import { attachService } from "@/entities/attach";
import { useFacilityList } from "@/entities/bns-if";
import { useNowDmgMtncGnsvy } from "@/entities/dc";
import { truncateDecimals } from "@/widgets/pbadms-sprt";
import {
Button,
Checkbox,
@@ -57,15 +63,17 @@ interface UploadedTmplntFile {
const TMPLNT_ATCH_ACCEPT = ".pdf,.png,.jpg,.jpeg";
const TMPLNT_ATCH_FORMAT_LABEL = ".pdf, .png, .jpg, .jpeg";
const TMPLNT_FIELD_ROWS: { key: TmplntFieldKey; label: string }[] = [
{ key: "prcsZoneArea", label: "처리구역면적(km²)" },
const TMPLNT_DECIMAL_PLACES = 3;
const TMPLNT_FIELD_ROWS: { key: TmplntFieldKey; label: string; decimals?: number }[] = [
{ key: "prcsZoneArea", label: "처리구역면적(km²)", decimals: TMPLNT_DECIMAL_PLACES },
{ key: "swtrmtPpltnCnt", label: "하수처리인구(명)" },
{ key: "planSwqty", label: "계획하수량(㎥/일)" },
{ key: "fcltCpct", label: "시설용량(㎥/일)" },
{ key: "sprsysDrtwPplLen", label: "분류식 오수(km)" },
{ key: "sprsysExlnPplLen", label: "분류식 우수(km)" },
{ key: "cbsysPplLen", label: "합류식(km)" },
{ key: "icptgPplLen", label: "차집관로(km)" },
{ key: "sprsysDrtwPplLen", label: "분류식 오수(km)", decimals: TMPLNT_DECIMAL_PLACES },
{ key: "sprsysExlnPplLen", label: "분류식 우수(km)", decimals: TMPLNT_DECIMAL_PLACES },
{ key: "cbsysPplLen", label: "합류식(km)", decimals: TMPLNT_DECIMAL_PLACES },
{ key: "icptgPplLen", label: "차집관로(km)", decimals: TMPLNT_DECIMAL_PLACES },
{ key: "drgCnt", label: "배수설비(개소)" },
{ key: "drtwRlyPumpCnt", label: "오수 중계 펌프(개소)" },
{ key: "drtwPumpCnt", label: "오수 일반 펌프(개소)" },
@@ -76,12 +84,14 @@ interface TmplntDtlInputProps {
facilityIndex: number;
planStpNo: number;
fieldKey: TmplntFieldKey;
decimals?: number;
}
function TmplntDtlInput({ facilityIndex, planStpNo, fieldKey }: TmplntDtlInputProps) {
function TmplntDtlInput({ facilityIndex, planStpNo, fieldKey, decimals }: TmplntDtlInputProps) {
const { control } = useFormContext<SewerBscPlanRegistFormData>();
const fieldName =
`sewerBscPlanTmplntList.${facilityIndex}.sewerBscPlanTmplntDtlList.${planStpNo}.${fieldKey}` as const;
const hasDecimals = decimals !== undefined;
return (
<Controller
@@ -90,13 +100,16 @@ function TmplntDtlInput({ facilityIndex, planStpNo, fieldKey }: TmplntDtlInputPr
render={({ field }) => (
<Field>
<Input
type="text"
{...NON_NEGATIVE_INPUT_PROPS}
type="number"
inputMode="decimal"
step={hasDecimals ? Number(`1e-${decimals}`) : undefined}
className="text-right"
value={field.value ?? ""}
onChange={(e) => {
const numValue = Number(e.target.value.replace(/,/g, "")) || 0;
field.onChange(numValue);
const value = sanitizeNumberInput(e.target.value.replace(/,/g, ""));
const normalized = hasDecimals ? truncateDecimals(value, decimals) : value;
field.onChange(Number(normalized) || 0);
}}
/>
</Field>
@@ -193,7 +206,12 @@ function TmplntFacilityMatrix({
</TableCell>
))}
<TableCell key={`current-${row.key}`}>
<TmplntDtlInput facilityIndex={facilityIndex} planStpNo={0} fieldKey={row.key} />
<TmplntDtlInput
facilityIndex={facilityIndex}
planStpNo={0}
fieldKey={row.key}
decimals={row.decimals}
/>
</TableCell>
{changeStepHeaders.map((step) => (
<TableCell key={`change-${row.key}-${step.planStpNo}`}>
@@ -201,6 +219,7 @@ function TmplntFacilityMatrix({
facilityIndex={facilityIndex}
planStpNo={step.planStpNo}
fieldKey={row.key}
decimals={row.decimals}
/>
</TableCell>
))}
@@ -4,11 +4,7 @@ import { useEffect, useState } from "react";
import { useUserStore } from "@/shared/lib";
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
import {
type CmCdAdstDstrComboResponseDto,
useFindUserGugunCombo,
useFindUserSidoCombo,
} from "@/entities/adstdstr";
import { useFindUserSidoGugunCombo } from "@/entities/adstdstr";
import { useCodeList } from "@/entities/code";
import {
Button,
@@ -48,8 +44,6 @@ interface SewerBscPlanApplyDialogProps {
onConfirm: (selection: SewerBscPlanApplySelection) => void;
}
const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = [];
function SewerBscPlanApplyDialog({ open, onOpenChange, onConfirm }: SewerBscPlanApplyDialogProps) {
const userInfo = useUserStore((state) => state.userInfo);
const [sidoComCd, setSidoComCd] = useState("");
@@ -57,37 +51,48 @@ function SewerBscPlanApplyDialog({ open, onOpenChange, onConfirm }: SewerBscPlan
const [sewerBscPlanSeComCd, setSewerBscPlanSeComCd] = useState("");
const [taskSeComCd, setTaskSeComCd] = useState("");
const sidoFilterUserInfo =
userInfo?.dtlInstCd === "ROLE_USER04" || userInfo?.dtlInstCd === "ROLE_USER05"
? userInfo
: undefined;
const { data: sidoData, isPending: isSidoPending } = useFindUserSidoCombo(sidoFilterUserInfo);
const { data: gugunData, isPending: isGugunPending } = useFindUserGugunCombo(sidoComCd);
const canSidoGugunSelectable = userInfo?.dtlInstCd !== "ROLE_USER05";
const {
userSidoData,
userGugunData,
isUserSidoPending,
isUserGugunPending,
} = useFindUserSidoGugunCombo(sidoComCd);
const { data: planSeOptions, isPending: isPlanSePending } = useCodeList("SEWERBSCPLANSE");
const { data: taskSeOptions, isPending: isTaskSePending } = useCodeList("SEWER");
const sidoOptions = sidoData ?? EMPTY_ADST_LIST;
const gugunOptions = gugunData ?? EMPTY_ADST_LIST;
const isPending = isSidoPending || isPlanSePending || isTaskSePending;
const isPending = isUserSidoPending || isPlanSePending || isTaskSePending;
useEffect(() => {
if (!open) return;
setSidoComCd("");
setSggComCd("");
setSewerBscPlanSeComCd("");
setTaskSeComCd("");
}, [open]);
const canSidoGugunSelectable = userInfo?.dtlInstCd !== "ROLE_USER05";
if (!canSidoGugunSelectable) return;
setSidoComCd("");
setSggComCd("");
}, [open, canSidoGugunSelectable]);
useEffect(() => {
if (canSidoGugunSelectable || userSidoData.length === 0) return;
const nextSidoComCd = userSidoData[0].adstDstrCd;
setSidoComCd((current) => (current === nextSidoComCd ? current : nextSidoComCd));
}, [canSidoGugunSelectable, userSidoData]);
useEffect(() => {
if (canSidoGugunSelectable || !sidoComCd || userGugunData.length === 0) return;
const nextSggComCd = userGugunData[0].adstDstrCd;
setSggComCd((current) => current || nextSggComCd);
}, [canSidoGugunSelectable, sidoComCd, userGugunData]);
const canConfirm =
!!sewerBscPlanSeComCd && !!taskSeComCd && (!canSidoGugunSelectable || !!sidoComCd);
function handleConfirm() {
if (!canConfirm) return;
const sidoOption = sidoOptions.find((option) => option.adstDstrCd === sidoComCd);
const gugunOption = gugunOptions.find((option) => option.adstDstrCd === sggComCd);
const sidoOption = userSidoData.find((option) => option.adstDstrCd === sidoComCd);
const gugunOption = userGugunData.find((option) => option.adstDstrCd === sggComCd);
const planSeOption = planSeOptions?.find((option) => option.lwpoCd === sewerBscPlanSeComCd);
const taskSeOption = taskSeOptions?.find((option) => option.lwpoCd === taskSeComCd);
@@ -135,7 +140,7 @@ function SewerBscPlanApplyDialog({ open, onOpenChange, onConfirm }: SewerBscPlan
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{sidoOptions.map((option) => (
{userSidoData.map((option) => (
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
{option.adstDstrNm}
</SelectItem>
@@ -148,13 +153,13 @@ function SewerBscPlanApplyDialog({ open, onOpenChange, onConfirm }: SewerBscPlan
<Select
value={sggComCd || undefined}
onValueChange={setSggComCd}
disabled={!sidoComCd || isGugunPending}
disabled={!sidoComCd || isUserGugunPending}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{gugunOptions.map((option) => (
{userGugunData.map((option) => (
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
{option.adstDstrNm}
</SelectItem>
@@ -8,8 +8,8 @@ import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQueryClient } from "@tanstack/react-query";
import { useAlertStore, useUserStore } from "@/shared/lib";
import { RhfDevTool, SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
import { getListHref, useAlertStore, useUserStore, withListQs } from "@/shared/lib";
import { SectionCollapsibleGroup, SectionCollapsibleToggle } from "@/shared/ui";
import { useAcntgSeMngTree } from "@/entities/acntg";
import { useEsntlAtchFileMngTree } from "@/entities/esntl";
import {
@@ -123,7 +123,9 @@ function SewerBscPlanFormPage({
if (!result?.sewerBscPlanDocNo) return;
alert("저장되었습니다.");
router.push(`/sewer-bsc-plan/${result.sewerBscPlanDocNo}/edit?chgCycl=${result.chgCycl}`);
router.push(
withListQs(`/sewer-bsc-plan/${result.sewerBscPlanDocNo}/edit?chgCycl=${result.chgCycl}`),
);
queryClient.invalidateQueries({ queryKey: sewerBscPlanKeys.all });
} catch (error) {
console.error("저장 실패:", error);
@@ -162,7 +164,9 @@ function SewerBscPlanFormPage({
if (!result?.sewerBscPlanDocNo) return;
alert("신청 완료되었습니다.");
router.push(`/sewer-bsc-plan/${result.sewerBscPlanDocNo}?chgCycl=${result.chgCycl ?? 1}`);
router.push(
withListQs(`/sewer-bsc-plan/${result.sewerBscPlanDocNo}?chgCycl=${result.chgCycl ?? 1}`),
);
queryClient.invalidateQueries({ queryKey: sewerBscPlanKeys.all });
} catch (error) {
console.error("신청 실패:", error);
@@ -179,7 +183,7 @@ function SewerBscPlanFormPage({
(item) => item.cmptnYn === "N" && (!item.splmntCn || !item.splmntCn.trim()),
);
if (hasEmptySplmntCn) {
alert("보완요청 항목이 있습니다.\n보완완료 내용을 입력해 주세요.");
alert("보완 요청 항목이 있습니다.\n보완 완료 내용을 입력해 주세요.");
return;
}
@@ -197,13 +201,13 @@ function SewerBscPlanFormPage({
const result = await sewerBscPlanService.submitSupplement(docNo, payload);
if (!result) return;
alert("보완완료 처리되었습니다.");
alert("보완 완료 처리되었습니다.");
await queryClient.invalidateQueries({ queryKey: sewerBscPlanKeys.all });
const chgCycl = data.sewerBscPlanDtl.chgCycl ?? initialData?.detDto?.chgCycl ?? 1;
router.push(`/sewer-bsc-plan/${docNo}?chgCycl=${chgCycl}`);
queryClient.invalidateQueries({ queryKey: sewerBscPlanKeys.all });
router.push(withListQs(`/sewer-bsc-plan/${docNo}?chgCycl=${chgCycl}`));
} catch (error) {
console.error("보완완료 실패:", error);
alert("보완완료 처리 중 오류가 발생했습니다.");
console.error("보완 완료 실패:", error);
alert("보완 완료 처리 중 오류가 발생했습니다.");
} finally {
setIsSubmitting(false);
}
@@ -228,7 +232,7 @@ function SewerBscPlanFormPage({
setIsSubmitting(true);
await sewerBscPlanService.delete(docNo);
alert("삭제되었습니다.");
router.push("/sewer-bsc-plan");
router.push(getListHref("/sewer-bsc-plan"));
queryClient.invalidateQueries({ queryKey: sewerBscPlanKeys.list() });
} catch (error) {
console.error("삭제 실패:", error);
@@ -240,7 +244,7 @@ function SewerBscPlanFormPage({
return (
<FormProvider {...form}>
<form onSubmit={form.handleSubmit(onSave)} className="mt-6 space-y-4">
<form onSubmit={form.handleSubmit(onSave)} className="mt-4">
<SectionCollapsibleGroup>
<div className="sticky top-[6rem] z-30 bg-container px-4 py-2">
<SectionCollapsibleToggle />
@@ -274,7 +278,7 @@ function SewerBscPlanFormPage({
<Button
color="white"
type="button"
onClick={() => router.push("/sewer-bsc-plan")}
onClick={() => router.push(getListHref("/sewer-bsc-plan"))}
disabled={isSubmitting}
>
목록
@@ -292,7 +296,7 @@ function SewerBscPlanFormPage({
onClick={handleApplyClick}
disabled={isSubmitting}
>
신청완료
신청 완료
</Button>
<Button
color="red"
@@ -311,7 +315,7 @@ function SewerBscPlanFormPage({
onClick={form.handleSubmit(onSplmntCmptn)}
disabled={isSubmitting}
>
보완완료
보완 완료
</Button>
)}
</div>
@@ -319,7 +323,6 @@ function SewerBscPlanFormPage({
</div>
</SectionCollapsibleGroup>
</form>
<RhfDevTool control={form.control} />
</FormProvider>
);
}
@@ -1,14 +1,11 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import type { PaginationState } from "@tanstack/react-table";
import {
type CmCdAdstDstrComboResponseDto,
useFindGugunCombo,
useFindSidoCombo,
} from "@/entities/adstdstr";
import { useSearchParamsState } from "@/shared/lib";
import { useFindUserSidoGugunCombo } from "@/entities/adstdstr";
import { CodeSelect } from "@/entities/code";
import type { SewerBscPlanSearchRequestDto } from "@/entities/sewer-bsc-plan";
import {
@@ -27,52 +24,62 @@ import { SewerBscPlanList } from "./sewer-bsc-plan-list";
const DEFAULT_PAGE_INDEX = 0;
const DEFAULT_PAGE_SIZE = 10;
const DEFAULT_SORT = "sewerBscPlanDocNo,desc";
const EMPTY_ADST_LIST: CmCdAdstDstrComboResponseDto[] = [];
const DEFAULT_SEARCH_PARAMS: SewerBscPlanSearchRequestDto = {
sewerBscPlanNm: "",
sewerBscPlanSeComCd: "",
taskSeComCd: "",
admdstCd: "",
page: DEFAULT_PAGE_INDEX,
size: DEFAULT_PAGE_SIZE,
sort: DEFAULT_SORT,
};
const ALL_SELECT_VALUE = "__ALL__";
function SewerBscPlanListPage() {
const [sewerBscPlanNm, setSewerBscPlanNm] = useState("");
const [sewerBscPlanSeComCd, setSewerBscPlanSeComCd] = useState("");
const [taskSeComCd, setTaskSeComCd] = useState("");
const [sidoComCd, setSidoComCd] = useState("");
const [sggComCd, setSggComCd] = useState("");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: DEFAULT_PAGE_INDEX,
pageSize: DEFAULT_PAGE_SIZE,
});
const [searchParams, setSearchParams] =
useState<SewerBscPlanSearchRequestDto>(DEFAULT_SEARCH_PARAMS);
const DEFAULT_PARAMS = {
sidoComCd: "",
sggComCd: "",
sewerBscPlanSeComCd: "",
taskSeComCd: "",
sewerBscPlanNm: "",
page: DEFAULT_PAGE_INDEX,
size: DEFAULT_PAGE_SIZE,
};
const { data: sidoData } = useFindSidoCombo();
const { data: gugunData } = useFindGugunCombo(sidoComCd);
const sidoOptions = sidoData ?? EMPTY_ADST_LIST;
const gugunOptions = gugunData ?? EMPTY_ADST_LIST;
function SewerBscPlanListPage() {
const { params, setParams } = useSearchParamsState(DEFAULT_PARAMS);
const [sewerBscPlanNm, setSewerBscPlanNm] = useState(params.sewerBscPlanNm);
const [sewerBscPlanSeComCd, setSewerBscPlanSeComCd] = useState(params.sewerBscPlanSeComCd);
const [taskSeComCd, setTaskSeComCd] = useState(params.taskSeComCd);
const [sidoComCd, setSidoComCd] = useState(params.sidoComCd);
const [sggComCd, setSggComCd] = useState(params.sggComCd);
const { userSidoData, userGugunData } = useFindUserSidoGugunCombo(sidoComCd);
const pagination: PaginationState = {
pageIndex: params.page,
pageSize: params.size,
};
const searchParams: SewerBscPlanSearchRequestDto = useMemo(
() => ({
sewerBscPlanNm: params.sewerBscPlanNm,
sewerBscPlanSeComCd: params.sewerBscPlanSeComCd,
taskSeComCd: params.taskSeComCd,
admdstCd: params.sggComCd || params.sidoComCd || "",
page: params.page,
size: params.size,
sort: DEFAULT_SORT,
}),
[params],
);
const handleSearch = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setPagination((prev) => ({ ...prev, pageIndex: DEFAULT_PAGE_INDEX }));
setSearchParams({
...DEFAULT_SEARCH_PARAMS,
setParams({
sewerBscPlanNm,
sewerBscPlanSeComCd,
taskSeComCd,
admdstCd: sggComCd || sidoComCd || "",
sidoComCd,
sggComCd,
page: DEFAULT_PAGE_INDEX,
size: pagination.pageSize,
});
};
const handlePaginationChange = (next: PaginationState) => {
setParams({ page: next.pageIndex, size: next.pageSize });
};
return (
<div className="space-y-4 px-[1.8rem] pb-6">
<section className="card card-border mx-0 bg-background">
@@ -93,7 +100,7 @@ function SewerBscPlanListPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_SELECT_VALUE}>전체</SelectItem>
{sidoOptions.map((option) => (
{userSidoData.map((option) => (
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
{option.adstDstrNm}
</SelectItem>
@@ -115,7 +122,7 @@ function SewerBscPlanListPage() {
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_SELECT_VALUE}>전체</SelectItem>
{gugunOptions.map((option) => (
{userGugunData.map((option) => (
<SelectItem key={option.adstDstrCd} value={option.adstDstrCd}>
{option.adstDstrNm}
</SelectItem>
@@ -163,7 +170,7 @@ function SewerBscPlanListPage() {
<SewerBscPlanList
searchParams={searchParams}
pagination={pagination}
onPaginationChange={setPagination}
onPaginationChange={handlePaginationChange}
/>
</div>
);
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { useDataTable } from "@/shared/lib";
import { useDataTable, withListQs } from "@/shared/lib";
import {
type SewerBscPlanResponseDto,
type SewerBscPlanSearchRequestDto,
@@ -47,7 +47,7 @@ function SewerBscPlanList({ searchParams, pagination, onPaginationChange }: Sewe
const columns = useMemo(() => createSewerBscPlanColumns(), []);
const handleRowClick = (row: SewerBscPlanResponseDto) => {
router.push(`/sewer-bsc-plan/${row.sewerBscPlanDocNo}?chgCycl=${row.chgCycl}`);
router.push(withListQs(`/sewer-bsc-plan/${row.sewerBscPlanDocNo}?chgCycl=${row.chgCycl}`));
};
const { tableComponent } = useDataTable({
@@ -102,7 +102,9 @@ function SewerBscPlanViewPage({ id, propChgCycl }: SewerBscPlanViewPageProps) {
return (
<div className="mt-4">
<SectionCollapsibleGroup>
<SectionCollapsibleToggle />
<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}
+4
View File
@@ -53,6 +53,10 @@ export const ENDPOINT = {
STATVERIFY_SERVICE: {
ST_ANLS_MST: "/statverify-service/api/v1/st-anls-mst",
},
NTFTALK_SERVICE: {
TEMP: "/ntftalk-service/api/v1/ntftalk-temp",
ATA_SEND: "/ntftalk-service/api/v1/ata/send",
},
DC_SERVICE: "/dc-service/api/v1",
BOARD_SERVICE: {
POSTS: "/board-service/api/v1/posts",
+3
View File
@@ -1,10 +1,13 @@
export { getCookie, removeCookie, setCookie } from "./cookie";
export { dayjs } from "./dayjs";
export { getListHref, withListQs } from "./list-return-href";
export { matchQueryStatus } from "./match-query-status";
export { NON_NEGATIVE_INPUT_PROPS, sanitizeNumberInput } from "./number-input";
export { queryClient } from "./query-client";
export { useAlertStore } from "./use-alert-store";
export { useDataTable } from "./use-data-table";
export { useMenuStore } from "./use-menu-store";
export { useMutationWrapper } from "./use-mutation-wrapper";
export { useSearchParamsState } from "./use-search-params-state";
export { getDefaultFileKey, useTus } from "./use-tus";
export { useUserStore } from "./use-user-store";
@@ -0,0 +1,50 @@
/**
* 목록 조회 조건을 sessionStorage에 저장하고, 목록 복귀 시 복원하기 위한 헬퍼.
*
* - 목록 → 상세: 현재 목록 쿼리를 `list-qs-{listPath}` 키로 저장
* - 상세 → 수정·저장 후 이동: storage를 건드리지 않음 (같은 탭에서 유지)
* - 목록/삭제 후 이동: storage에서 읽어 `/listPath?bizNm=...` 로 복원
*/
function toListSlug(listPath: string) {
return listPath.replace(/^\/+|\/+$/g, "").replaceAll("/", "-");
}
function storageKey(listPath: string) {
return `list-qs-${toListSlug(listPath)}`;
}
/** 상세/수정 href에서 목록 slug를 추출한다. */
function inferListPath(href: string) {
const path = href.split("?")[0] ?? "";
const segments = path.split("/").filter(Boolean);
return segments[0] ?? "";
}
function isOnListPage(slug: string) {
if (typeof window === "undefined" || !slug) return false;
const { pathname } = window.location;
return pathname === `/${slug}` || pathname === `/${slug}/`;
}
/** 목록에 있을 때 현재 쿼리를 저장하고, href는 그대로 반환한다. */
export function withListQs(href: string) {
if (typeof window === "undefined") return href;
const slug = inferListPath(href);
if (!isOnListPage(slug)) return href;
const search = window.location.search.replace(/^\?/, "");
if (search) sessionStorage.setItem(storageKey(slug), search);
else sessionStorage.removeItem(storageKey(slug));
return href;
}
/** sessionStorage에 저장된 목록 쿼리로 href를 만든다. */
export function getListHref(listPath: string) {
if (typeof window === "undefined") return listPath;
const listQs = sessionStorage.getItem(storageKey(listPath));
return listQs ? `${listPath}?${listQs}` : listPath;
}
+22
View File
@@ -0,0 +1,22 @@
import type { KeyboardEvent } from "react";
const MAX_INTEGER_DIGITS = 10;
function preventMinusKey(e: KeyboardEvent<HTMLInputElement>) {
if (e.key === "-") e.preventDefault();
}
/** type="number" Input에 스프레드해 음수 입력(키·스피너)을 막는다 */
export const NON_NEGATIVE_INPUT_PROPS = { min: 0, onKeyDown: preventMinusKey } as const;
/** 정수부를 maxDigits 자리까지 잘라낸다 (소수부는 유지) */
function truncateIntegerDigits(value: string, maxDigits: number): string {
const dotIndex = value.indexOf(".");
if (dotIndex < 0) return value.slice(0, maxDigits);
return value.slice(0, Math.min(dotIndex, maxDigits)) + value.slice(dotIndex);
}
/** 숫자 입력 문자열에서 음수 부호를 제거하고 정수부를 최대 10자리로 제한한다 */
export function sanitizeNumberInput(value: string): string {
return truncateIntegerDigits(value.replace(/-/g, ""), MAX_INTEGER_DIGITS);
}
+10 -3
View File
@@ -19,16 +19,23 @@ interface DataTableOptions<T> {
}
interface UseDataTableOptions<T> {
query: UseQueryResult<PageResponse<T>>;
/** 페이징 응답(PageResponse) 또는 페이징 없는 목록(T[]) */
query: UseQueryResult<PageResponse<T> | T[]>;
tableOptions: Omit<TableOptions<T>, "data" | "rowCount">;
dataTableOptions?: DataTableOptions<T>;
}
const EMPTY_ROWS: never[] = [];
export function useDataTable<T>({ query, tableOptions, dataTableOptions }: UseDataTableOptions<T>) {
const { data } = query;
const rows = Array.isArray(data) ? data : (data?.content ?? EMPTY_ROWS);
const rowCount = Array.isArray(data) ? data.length : (data?.totalElements ?? 0);
const table = useReactTable({
...tableOptions,
data: query.data?.content ?? [],
rowCount: query.data?.totalElements ?? 0,
data: rows,
rowCount,
});
return {
@@ -0,0 +1,90 @@
"use client";
import { useCallback, useMemo, useRef } from "react";
import { useSearchParams } from "next/navigation";
/**
* 조회 조건을 URL searchParams와 동기화하는 훅.
*
* - URL이 단일 소스 오브 트루스(SSOT)이며, 새로고침·뒤로가기 시에도 조건이 유지된다.
* - 기본값과 동일한 값은 URL에서 제거해 쿼리스트링을 깨끗하게 유지한다.
* - `window.history.replaceState` 기반 shallow 갱신으로 서버 라운드트립 없이 동작한다.
* - 숫자 기본값이 주어진 키는 자동으로 `Number` 파싱한다.
*
* @example
* const { params, setParams, resetParams } = useSearchParamsState({
* keyword: "",
* page: 0,
* size: 10,
* });
*/
export function useSearchParamsState<T extends Record<string, ParamValue>>(
defaults: T,
): UseSearchParamsStateReturn<T> {
const searchParams = useSearchParams();
const defaultsRef = useRef(defaults);
const params = useMemo(
() => parseParams(searchParams, defaultsRef.current),
[searchParams],
);
const setParams = useCallback((partial: Partial<T>) => {
const next = new URLSearchParams(window.location.search);
for (const [key, value] of Object.entries(partial)) {
if (value === undefined || value === defaultsRef.current[key]) {
next.delete(key);
} else {
next.set(key, String(value));
}
}
replaceUrl(next);
}, []);
const resetParams = useCallback(() => {
const next = new URLSearchParams(window.location.search);
for (const key of Object.keys(defaultsRef.current)) {
next.delete(key);
}
replaceUrl(next);
}, []);
return { params, setParams, resetParams };
}
function parseParams<T extends Record<string, ParamValue>>(
searchParams: ReturnType<typeof useSearchParams>,
defaults: T,
): T {
const result = { ...defaults };
for (const key of Object.keys(defaults) as (keyof T)[]) {
const raw = searchParams?.get(key as string) ?? null;
if (raw === null) continue;
if (typeof defaults[key] === "number") {
const parsed = Number(raw);
if (!Number.isNaN(parsed)) result[key] = parsed as T[keyof T];
} else {
result[key] = raw as T[keyof T];
}
}
return result;
}
function replaceUrl(searchParams: URLSearchParams) {
const qs = searchParams.toString();
const url = qs
? `${window.location.pathname}?${qs}`
: window.location.pathname;
window.history.replaceState(null, "", url);
}
type ParamValue = string | number | null;
interface UseSearchParamsStateReturn<T> {
params: T;
setParams: (partial: Partial<T>) => void;
resetParams: () => void;
}
+46 -13
View File
@@ -4,7 +4,7 @@ import { useCallback, useState } from "react";
import { type Upload } from "tus-js-client";
import { handleTusUpload } from "@/entities/tus";
import { handleTusUpload, type TusErrorResponse } from "@/entities/tus";
import { useAlertStore } from "./use-alert-store";
@@ -15,7 +15,12 @@ interface UploadFileOptions {
interface UseTusOptions {
onSuccess?: (atflCd: string, atflId: string, file: File, fileKey: string) => void;
onError?: (error: Error, file: File, fileKey: string) => void;
onError?: (
error: Error,
file: File,
fileKey: string,
response: TusErrorResponse | null,
) => void;
accept?: string;
}
@@ -27,6 +32,28 @@ interface UseTusReturn {
uploads: Record<string, Upload>;
}
const ALLOWED_EXTENSIONS: readonly string[] = [
// 문서
".pdf",
".txt",
".hwp",
".hwpx",
".doc",
".docx",
".xls",
".xlsx",
".ppt",
".pptx",
// 이미지
".jpg",
".jpeg",
".png",
".gif",
".bmp",
// 압축
".zip",
];
function getDefaultFileKey(file: File) {
return `${file.name}::${file.lastModified}::${file.size}`;
}
@@ -39,12 +66,16 @@ function useTus(options: UseTusOptions = {}): UseTusReturn {
const validateFileType = useCallback(
(file: File): boolean => {
if (!options.accept) return true;
const allowedExtensions = options.accept.split(",");
const fileExtension = file.name.substring(file.name.lastIndexOf(".")).toLowerCase();
const acceptExtensions = options.accept
?.split(",")
.map((extension) => extension.trim().toLowerCase());
if (!allowedExtensions.includes(fileExtension)) {
const isAllowed =
ALLOWED_EXTENSIONS.includes(fileExtension) &&
(!acceptExtensions || acceptExtensions.includes(fileExtension));
if (!isAllowed) {
alert(`허용되지 않은 파일 형식입니다. (${fileExtension})`);
return false;
}
@@ -56,12 +87,14 @@ function useTus(options: UseTusOptions = {}): UseTusReturn {
const uploadFileAsync = useCallback(
(file: File, uploadOptions?: UploadFileOptions): Promise<string> => {
if (!validateFileType(file)) {
return Promise.reject(new Error("Invalid file type"));
}
const fileKey = uploadOptions?.fileKey ?? getDefaultFileKey(file);
if (!validateFileType(file)) {
const error = new Error("Invalid file type");
options.onError?.(error, file, fileKey, null);
return Promise.reject(error);
}
setIsUploading((prev) => ({ ...prev, [fileKey]: true }));
setProgress((prev) => ({ ...prev, [fileKey]: 0 }));
@@ -80,10 +113,10 @@ function useTus(options: UseTusOptions = {}): UseTusReturn {
options.onSuccess?.(atflCd, atflId, file, fileKey);
resolve(atflCd);
},
handleError: (error) => {
handleError: (error, response) => {
setIsUploading((prev) => ({ ...prev, [fileKey]: false }));
alert("파일 업로드 중 오류가 발생했습니다.");
options.onError?.(error, file, fileKey);
alert(response?.message ?? "파일 업로드 중 오류가 발생했습니다.");
options.onError?.(error, file, fileKey, response);
reject(error);
},
});
@@ -40,7 +40,6 @@ function FileAddButton({
<Button
type="button"
size="sm"
variant="outline"
disabled={disabled}
className={cn(
"peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2",
@@ -2,7 +2,7 @@
import { useId } from "react";
import { Download, FolderOpen, Fullscreen, Save } from "lucide-react";
import { Download, FilePen, FolderOpen, Fullscreen, Save } from "lucide-react";
import { Button, cn } from "@common/components";
@@ -15,6 +15,7 @@ interface HangulGiangiToolbarProps {
onSave: () => void;
onDownload: () => void;
onFullscreen: () => void;
onDmndSplmnt?: () => void;
}
function HangulGiangiToolbar({
@@ -24,6 +25,7 @@ function HangulGiangiToolbar({
onSave,
onDownload,
onFullscreen,
onDmndSplmnt,
}: HangulGiangiToolbarProps) {
const openInputId = useId();
@@ -49,7 +51,7 @@ function HangulGiangiToolbar({
/>
<Button
type="button"
color="white"
variant="outline"
size="sm"
disabled={isBusy}
className={cn(
@@ -58,7 +60,7 @@ function HangulGiangiToolbar({
)}
asChild
>
<span>
<span className="text-[#666]">
<FolderOpen className="!size-5" />
열기
</span>
@@ -79,6 +81,12 @@ function HangulGiangiToolbar({
<Fullscreen className="!size-5" />
전체화면
</Button>
{onDmndSplmnt && (
<Button type="button" color="white" size="sm" disabled={isBusy} onClick={onDmndSplmnt}>
<FilePen className="!size-5" />
보완요청
</Button>
)}
</div>
);
}
@@ -21,6 +21,7 @@ function HangulGiangi({
documentFileName,
onSaveDocument,
onDownload,
onDmndSplmnt,
canSave = true,
bridgeUrl = DEFAULT_BRIDGE_URL,
ctrlBaseUrl = WEBHYP_CTRL_URL,
@@ -206,6 +207,7 @@ function HangulGiangi({
onSave={handleSave}
onDownload={handleDownload}
onFullscreen={handleFullscreen}
onDmndSplmnt={onDmndSplmnt}
/>
<iframe
@@ -316,6 +318,7 @@ interface HangulGiangiProps {
documentFileName: string;
onSaveDocument: (file: Blob) => Promise<void>;
onDownload: () => Promise<void>;
onDmndSplmnt?: () => void;
canSave?: boolean;
bridgeUrl?: string;
ctrlBaseUrl?: string;

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