From 5af4f0f27b704f33ad2fdac5234f3ffdd8ec60f3 Mon Sep 17 00:00:00 2001 From: bicochan Date: Tue, 18 Aug 2026 16:40:21 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[as]=20feat:=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(with-base-layout)/dashboard/page.tsx | 3 + .../as/app/(main)/(with-base-layout)/page.tsx | 1 - apps/as/docs/spec/dashboard/SPEC.md | 448 ++++++++++++++++++ apps/as/middleware.ts | 8 +- .../dashboard/api/dashboard-service.ts | 23 + apps/as/src/entities/dashboard/api/queries.ts | 18 + apps/as/src/entities/dashboard/index.ts | 10 + .../dashboard/model/dashboard-interface.ts | 58 +++ apps/as/src/entities/noti/api/queries.ts | 1 + apps/as/src/pages/dashboard/index.ts | 1 + .../dashboard/ui/dashboard-noti-list.tsx | 143 ++++++ .../dashboard/ui/dashboard-notice-table.tsx | 16 + .../src/pages/dashboard/ui/dashboard-page.tsx | 29 ++ .../dashboard/ui/doc-prgrs-stts-cards.tsx | 128 +++++ .../pages/dashboard/ui/national-as-map.tsx | 14 + apps/as/src/pages/home/index.ts | 1 - apps/as/src/pages/home/ui/main-page.tsx | 9 - apps/as/src/shared/config/api.ts | 1 + 18 files changed, 897 insertions(+), 15 deletions(-) create mode 100644 apps/as/app/(main)/(with-base-layout)/dashboard/page.tsx delete mode 100644 apps/as/app/(main)/(with-base-layout)/page.tsx create mode 100644 apps/as/docs/spec/dashboard/SPEC.md create mode 100644 apps/as/src/entities/dashboard/api/dashboard-service.ts create mode 100644 apps/as/src/entities/dashboard/api/queries.ts create mode 100644 apps/as/src/entities/dashboard/index.ts create mode 100644 apps/as/src/entities/dashboard/model/dashboard-interface.ts create mode 100644 apps/as/src/pages/dashboard/index.ts create mode 100644 apps/as/src/pages/dashboard/ui/dashboard-noti-list.tsx create mode 100644 apps/as/src/pages/dashboard/ui/dashboard-notice-table.tsx create mode 100644 apps/as/src/pages/dashboard/ui/dashboard-page.tsx create mode 100644 apps/as/src/pages/dashboard/ui/doc-prgrs-stts-cards.tsx create mode 100644 apps/as/src/pages/dashboard/ui/national-as-map.tsx delete mode 100644 apps/as/src/pages/home/index.ts delete mode 100644 apps/as/src/pages/home/ui/main-page.tsx diff --git a/apps/as/app/(main)/(with-base-layout)/dashboard/page.tsx b/apps/as/app/(main)/(with-base-layout)/dashboard/page.tsx new file mode 100644 index 000000000..1e20f5079 --- /dev/null +++ b/apps/as/app/(main)/(with-base-layout)/dashboard/page.tsx @@ -0,0 +1,3 @@ +import { DashboardPage } from "@/pages/dashboard/ui/dashboard-page"; + +export default DashboardPage; diff --git a/apps/as/app/(main)/(with-base-layout)/page.tsx b/apps/as/app/(main)/(with-base-layout)/page.tsx deleted file mode 100644 index 82a918b59..000000000 --- a/apps/as/app/(main)/(with-base-layout)/page.tsx +++ /dev/null @@ -1 +0,0 @@ -export { MainPage as default } from "@/pages/home"; diff --git a/apps/as/docs/spec/dashboard/SPEC.md b/apps/as/docs/spec/dashboard/SPEC.md new file mode 100644 index 000000000..4797448bc --- /dev/null +++ b/apps/as/docs/spec/dashboard/SPEC.md @@ -0,0 +1,448 @@ +# 화면설계서: 전국 행정지원 지도 (대시보드 메인) + +## 1. 기본 정보 + +| 항목 | 내용 | +| ---------- | -------------------------------------------- | +| 버전 | 1.1 | +| 프로세스명 | UI설계 | +| 화면명 | 전국 행정지원 지도 (대시보드 메인) | +| 화면 ID | TBD | +| 화면 유형 | [v] 조회 [ ] 입력 [ ] 수정 [ ] 삭제 [ ] 출력 | +| 메뉴 경로 | 메인 | + +--- + +## 2. 화면 목적 + +로그인 사용자의 **기관 권한·당해 연도** 기준으로 문서별 진행상태 통계와 행정지원 알림을 한 화면에서 조회한다. 좌측 지도에서 전국 행정지원 현황을 보고, 우측에서 진행상황 카드·알림이력·공지사항을 확인한 뒤 알림 [처리하기]로 연계 업무 화면으로 이동한다. + +--- + +## 3. 화면 구성 + +### 3-1. 전체 레이아웃 + +브레드크럼·페이지 타이틀은 스크린샷에 없음. 홈(`/`) 메인 콘텐츠로 구성한다. + +``` +┌────────────────────────────────┬─────────────────────────────────────────────────┐ +│ [영역 A] 전국 행정지원 지도 │ [영역 B] 문서별 진행상황 카드 (2×2) │ +│ 타이틀: 전국 행정지원 지도 │ ┌───────────────┐ ┌───────────────┐ │ +│ │ │ 재원협의 │ │ 설치인가 │ │ +│ (대한민국 시도·시군구 경계) │ │ 신청/진행/완료│ │ 신청/진행/완료│ │ +│ 마커: 서울 강남구, │ ├───────────────┤ ├───────────────┤ │ +│ 대전 유성구, │ │ 설계자문 │ │ 기본계획 │ │ +│ 부산 해운대구 │ │ 신청/진행/완료│ │ 신청/진행/완료│ │ +│ │ └───────────────┘ └───────────────┘ │ +│ ├─────────────────────────────────────────────────┤ +│ │ [영역 C] 알림이력 │ +│ │ 알림이력 업데이트 HH:mm [전체보기] │ +│ │ [오늘 N건] [지연 N건] [완료대기 N건] │ +│ │ [뱃지] [일자] 제목 [처리하기] │ +│ │ … (최대 5건) │ +│ ├─────────────────────────────────────────────────┤ +│ │ [영역 D] 공지사항 [더보기] │ +│ │ ┌────┬────────┬────────┬────────┬──────┐ │ +│ │ │번호│ 제목 │ 작성자 │ 작성일 │조회수│ │ +│ │ └────┴────────┴────────┴────────┴──────┘ │ +└────────────────────────────────┴─────────────────────────────────────────────────┘ +``` + +### 3-2. 화면 데이터 흐름 + +``` +BEGIN + + // 최초 진입 + // searchYear 미전달 → 서버가 당해 연도로 고정. 알림 목록은 최신 5건 + CALL getMainDashboard(DashboardMainSearchDto) // Authorization은 client 인터셉터 + DISPLAY 진행상황 카드 (영역 B) // docPrgrsSttsList, TOTAL 행 제외 + DISPLAY 알림 통계 칩 · 알림 목록 (영역 C) // notiStats, notiList + + CALL 공지사항 목록조회(…) // TBD — 대시보드 API에 없음 + DISPLAY 공지사항 그리드 (영역 D) + + CALL 지도 마커/음영 조회(…) // TBD — 대시보드 API에 없음 + DISPLAY 전국 지도 (영역 A) + + ON 영역 C [전체보기] 버튼 CLICK DO + NAVIGATE /nti-hstry + END ON + + ON 영역 C [처리하기] 버튼 CLICK DO + CALL readNoti(notiId) // entities/noti + NAVIGATE notiUrl // 비어 있으면 이동하지 않음 + CALL getMainDashboard(…) // → 영역 C 갱신 (invalidate) + END ON + + ON 영역 D [더보기] 버튼 CLICK DO + NAVIGATE 공지사항 목록 // TBD + END ON + + ON 영역 D 행 CLICK DO + NAVIGATE 공지사항 상세 // TBD + END ON + + ON 영역 B 카드 CLICK DO + NAVIGATE 해당 문서 목록 // §5-3 + END ON + + ON 영역 A 마커 CLICK DO + // TBD — 팝업/필터/이동 여부 미확정 + END ON + +END +``` + +--- + +## 4. 영역 A — 전국 행정지원 지도 + +스크린샷 타이틀: **전국 행정지원 지도**. + +`getMainDashboard` 응답에 지도·좌표·행정구역 음영 데이터는 **없다**. 마커·음영 소스 API는 **TBD**. + +### 4-1. 표시 요소 + +| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 | +| ----------- | --------- | ---- | ------ | ----------------- | ----------------------------------------------------------------- | +| 섹션 타이틀 | Text | — | — | — | 고정 문구 `전국 행정지원 지도` | +| 전국 지도 | Map | — | — | TBD | 시도·시군구 경계. 북·동부 음영(청) / 남서부 비음영(백) — 의미 TBD | +| 마커 라벨 | Marker | — | — | TBD | 스크린샷 샘플: `서울 강남구`, `대전 유성구`, `부산 해운대구` | + +### 4-2. 상호작용 + +| 동작 | 설명 | +| ---------- | ----------------------------------------- | +| 마커 클릭 | TBD (상세 팝업 / 해당 지자체 필터 / 이동) | +| 지도 줌·팬 | TBD (스크린샷에 컨트롤 없음) | + +`entities/adstdstr`는 시도·구군 **콤보**만 제공하며 좌표를 주지 않는다. 지도 라이브러리도 as 앱에 없음 — 선정 TBD. + +--- + +## 5. 영역 B — 문서별 진행상황 카드 + +`DashboardMainResponseDto.docPrgrsSttsList`를 **`docSeComCd`로 매핑**해 4장 카드를 그린다. 배열 순서에 의존하지 않는다. + +스크린샷 배치(좌→우, 상→하): + +| 화면 카드 타이틀 | `docSeComCd` | API `docSeNm` | +| ----------------- | ------------ | -------------- | +| 재원협의 진행상황 | `FNRS` | 재원협의 | +| 설치인가 진행상황 | `INSTL` | 설치인가 | +| 설계자문 진행상황 | `CNFM` | 설계자문 | +| 기본계획 진행상황 | `SEWER` | 하수도기본계획 | + +서버 SQL `ORDER BY`는 `INSTL → FNRS → SEWER → CNFM` 이며, `GROUP BY ROLLUP`으로 **`docSeComCd = TOTAL`** 합계 행이 추가된다. 카드에는 TOTAL 행을 **표시하지 않는다**. + +카드 타이틀은 스크린샷 문구(`… 진행상황`)를 쓴다. `SEWER`의 API명 `하수도기본계획`은 화면에서 `기본계획`으로 축약한다. + +### 5-1. 카드 내부 필드 + +각 카드 공통 구조. `totCnt`는 화면에 없음. + +| 표시명 | Field | 비고 | +| ------ | ---------- | ------------------ | +| 신청 | `reqCnt` | 파란 아이콘 + 건수 | +| 진행 | `prgrsCnt` | 초록 아이콘 + 건수 | +| 완료 | `cmptnCnt` | 주황 아이콘 + 건수 | + +상태 그룹(서버): 임시저장(`SV`/`CV`) 제외. 문서별 `prgrs_stts_com_cd` → `REQ`/`PRG`/`CMP`. + +### 5-2. 검색 파라미터 (화면 미표시) + +영역 B에 연도·필터 UI는 없다. 서버 기본값을 따른다. + +| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 | +| ------ | --------- | ---- | --------- | ----------------- | --------------------------------------------------------------------- | +| (없음) | — | — | 당해 연도 | `searchYear` | 미전달 시 서버 `LocalDate.now().getYear()`. 연도 셀렉터 추가 여부 TBD | + +`docSeComCd` 쿼리는 **알림 목록 필터**에만 쓰이며, 진행상황 SQL에는 전달되지 않는다. 카드 4종은 항상 전원 집계한다. + +### 5-3. 상호작용 + +카드 클릭 시 **해당 문서 목록 페이지**로 이동한다. `docSeComCd` → 라우트 고정 매핑. + +| 동작 | `docSeComCd` | 이동 경로 | +| --------- | ------------ | ------------------- | +| 카드 클릭 | `FNRS` | `/fnrs-cnsltn` | +| 카드 클릭 | `INSTL` | `/instl-sncn` | +| 카드 클릭 | `CNFM` | `/design-cnstn` | +| 카드 클릭 | `SEWER` | `/sewer-bsc-plan` | + +--- + +## 6. 영역 C — 알림이력 + +서버가 cms-service 알림을 대시보드 응답에 합친다. 목록은 **page=0, size=5** (최신 5건). 페이지네이션 없음. + +### 6-1. 헤더 + +| 표시명 | Field / 소스 | 비고 | +| ------------- | ------------ | --------------------------------------------------------------------------------- | +| 섹션 타이틀 | — | 고정 문구 `알림이력` | +| 업데이트 시각 | (client) | 스크린샷 `업데이트 10:32`. API 필드 없음. `getMainDashboard` 마지막 fetch 시각(`HH:mm`) | +| 전체보기 | Button | `/nti-hstry`로 이동 | + +### 6-2. 통계 칩 + +`NotiAsStatResponseDto`. + +| 표시명 | Field | 비고 | +| ------------ | -------------- | -------------------------------------- | +| 오늘 N건 | `todayCount` | 당일 00:00 이후 수신 | +| 지연 N건 | `delayCount` | `notReadYn=N` 이고 수신 후 3일 이상 | +| 완료대기 N건 | `pendingCount` | `notReadYn=Y` 이고 `notiPrcsCmptnYn=N` | + +### 6-3. 목록 행 + +| 표시명 | Field | 비고 | +| ------ | --------- | --------------------------------------------- | +| 일자 | `regDt` | 표시 `YYYY-MM-DD`. 응답 `yyyy-MM-dd HH:mm:ss` | +| 제목 | `notiTtl` | 알림 제목 | +| 처리 | (action) | 스크린샷 라벨 `처리하기`. 클릭 §6-4 | + +응답에 있으나 이 목록에 **표시하지 않음**: + +| Field | 비고 | +| ----------------- | ----------------------------------------- | +| `notiId` | 행 키 · `readNoti` 파라미터 | +| `recvUnqId` | 수신 회원 UUID (서버 로그인 사용자 고정) | +| `senderUnqId` | 발신 회원 UUID | +| `notiType` | `ADSS` | +| `notiCn` | 본문 | +| `notiUrl` | 처리 후 이동 URL. 컬럼 미표시 | +| `notiLnkgUnqNo` | 알림연결고유번호 | +| `docSeComCd` | 문서구분 코드 | +| `docSeComNm` | 문서구분명 (알림이력 화면에서는 연계업무) | +| `notReadYn` | 뱃지 판정 TBD에 사용 가능 | +| `notiIdntyYn` | 확인 여부 | +| `notiIdntyDt` | 확인 일시 | +| `notiPrcsCmptnYn` | 처리 완료 여부 | +| `notiPrcsCmptnDt` | 처리 완료 일시 | + +### 6-4. 행 상호작용 + +| 동작 | 설명 | +| --------------- | ----------------------------------------------------------------------------------------------------------------- | +| [전체보기] 클릭 | `/nti-hstry` (알림이력 목록)로 이동 | +| [처리하기] 클릭 | `readNoti(notiId)` 후 `notiUrl`로 이동. URL이 비면 이동하지 않음(읽음 처리는 수행). `dashboardBaseKey` invalidate | + +`DashboardController`에 읽음 API는 없다. **`entities/noti`의 `readNoti`** 를 쓴다. 알림이력 화면과 동일 (`PUT /cms-service/api/v1/noti/{notiId}/read`). + +### 6-5. 검색 파라미터 (화면 미표시) + +서버 `toNotiSearchDto()` 기본: `notiType = ADSS`. 화면 필터 없음. + +| DashboardMainSearchDto | 화면 | 비고 | +| ---------------------- | ---- | --------------------------------- | +| `notiType` | 없음 | 서버 기본 `ADSS` | +| `notReadYn` | 없음 | 통계 칩 클릭 필터 시 후보 (TBD) | +| `startDt` / `endDt` | 없음 | 대시보드 기본 미전달 | +| `docSeComCd` | 없음 | 알림만 필터. 진행상황 카드와 무관 | + +--- + +## 7. 영역 D — 공지사항 + +`getMainDashboard`에 공지 필드가 없다. 목록·더보기 API·라우트는 **TBD**. as 앱에 board-service 엔티티 없음. + +### 7-1. 헤더·버튼 + +| 버튼명 | 동작 | +| ------ | -------------------------------- | +| 더보기 | 공지사항 목록으로 이동. 경로 TBD | + +### 7-2. 그리드 컬럼 + +스크린샷 헤더 순서. + +| 표시명 | Field | 비고 | +| ------ | ----- | ------------------------------------------------------ | +| 번호 | TBD | 고정글은 `[공지]` 뱃지, 그 외 순번. 고정 여부 필드 TBD | +| 제목 | TBD | 예: `2024년 시스템 업데이트 공지사항` | +| 작성자 | TBD | 예: `관리자`, `시스템관리자` | +| 작성일 | TBD | 표시 `YYYY-MM-DD` | +| 조회수 | TBD | 숫자 | + +페이지네이션 없음 (대시보드 요약, 건수 TBD). + +### 7-3. 행 상호작용 + +| 동작 | 설명 | +| ------- | ------------------------ | +| 행 클릭 | 공지 상세 이동. 경로 TBD | + +--- + +## 8. API/DTO 명세 + +### 8-1. 공통 + +| 항목 | 값 | +| --------------- | ---------------------------------------------------------------------------------------------- | +| Base prefix | `/as-service/api/v1/dashboard` (`ENDPOINT.AS_SERVICE.DASHBOARD`) | +| 인증 | `Authorization` 헤더. 프론트는 `client` 인터셉터. 로그인 기관 권한은 서버가 사용자 캐시로 적용 | +| 클라이언트 모듈 | `@/entities/dashboard` — `dashboardService` / `useGetMainDashboard` | +| 알림 읽음 | `@/entities/noti` — `notiService.readNoti` / `useReadNoti` | + +영역 A(지도)·영역 D(공지)는 본 컨트롤러 범위 밖 → **TBD**. + +### 8-2. 대시보드 메인 조회 — `getMainDashboard` + +| 기능 | Method | URL | Request DTO | Response DTO | API | +| -------------------------------------------- | ------ | ------------- | ------------------------------- | -------------------------- | ----------------------------------------------------------- | +| 대시보드 메인 통합진행상태 및 알림 정보 조회 | GET | `{BASE}/main` | Query: `DashboardMainSearchDto` | `DashboardMainResponseDto` | `dashboardService.getMainDashboard` / `useGetMainDashboard` | + +**Request (`DashboardMainSearchDto`)** + +| 필드 | 필수 | 설명 | +| ------------ | ---- | ---------------------------------------------------------------------- | +| `searchYear` | N | 검색 연도. 미전달 시 서버 당해 연도. 영역 B 집계 기간 | +| `notiType` | N | 알림 종류. 미전달 시 서버 `ADSS` | +| `notReadYn` | N | 알림 확인 여부 `Y`/`N`. 본 화면 미사용 | +| `startDt` | N | 알림 시작일 `YYYY-MM-DD`. 본 화면 미사용 | +| `endDt` | N | 알림 종료일 `YYYY-MM-DD`. 본 화면 미사용 | +| `docSeComCd` | N | 알림 문서구분 (`INSTL`/`FNRS`/`SEWER`/`CNFM` 등). 진행상황 집계와 무관 | + +`Authorization`은 service 시그니처에 넣지 않는다. + +**Response (`DashboardMainResponseDto`)** + +| 필드 | 타입 | 설명 | +| ------------------ | --------------------------- | --------------------------- | +| `docPrgrsSttsList` | `DocPrgrsSttsResponseDto[]` | 문서별 진행상태 + TOTAL 행 | +| `notiList` | `NotiAsUserResponseDto[]` | 최신 최대 5건 | +| `notiStats` | `NotiAsStatResponseDto` | 오늘 / 지연 / 완료대기 건수 | + +**`DocPrgrsSttsResponseDto`** + +| 필드 | 타입 | 설명 | +| ------------ | ------ | --------------------------------------------- | +| `docSeNm` | string | 문서 구분 명칭 | +| `docSeComCd` | string | `INSTL` / `FNRS` / `SEWER` / `CNFM` / `TOTAL` | +| `reqCnt` | number | 신청 건수 | +| `prgrsCnt` | number | 진행 건수 | +| `cmptnCnt` | number | 완료 건수 | +| `totCnt` | number | 총 건수 (화면 미표시) | + +**`NotiAsStatResponseDto`** + +| 필드 | 타입 | 설명 | +| -------------- | ------ | --------- | +| `todayCount` | number | 오늘 알림 | +| `delayCount` | number | 지연 알림 | +| `pendingCount` | number | 완료대기 | + +**`NotiAsUserResponseDto`** — 필드 상세는 `docs/spec/nti-hstry` §6-2와 동일 (`notiId`, `notiTtl`, `regDt`, `notiUrl`, `notReadYn`, `notiPrcsCmptnYn` 등). + +### 8-3. 알림 읽음 처리 — `readNoti` + +대시보드 컨트롤러가 아니라 cms-service. 영역 C [처리하기]에서 호출. + +| 기능 | Method | URL | Request DTO | Response DTO | API | +| -------------- | ------ | ---------------------------------------- | ----------------------- | ---------------- | -------------------------------------- | +| 알림 읽음 처리 | PUT | `/cms-service/api/v1/noti/{notiId}/read` | Path: `notiId` (`Long`) | `Long` (알림 ID) | `notiService.readNoti` / `useReadNoti` | + +Body 없음. 성공 시 `dashboardBaseKey`(및 필요 시 `notiBaseKey`) invalidate. + +### 8-4. 전국 행정지원 지도 — TBD + +| 기능 | Method | URL | Request DTO | Response DTO | API | +| ------------------- | ------ | --- | ----------- | ------------ | --- | +| 지도 마커·음영 조회 | TBD | TBD | TBD | TBD | TBD | + +### 8-5. 공지사항 — TBD + +| 기능 | Method | URL | Request DTO | Response DTO | API | +| ----------------------- | ------ | --- | ----------- | ------------ | --- | +| 대시보드 공지 요약 목록 | TBD | TBD | TBD | TBD | TBD | + +--- + +## 9. UI 구현 가이드 + +### 9-1. 기술 스택·원칙 + +- 라우트: 기존 홈 `app/(main)/(with-base-layout)/page.tsx` → `pages/home` (현재 `MainPage` 플레이스홀더) +- UI: `@common/components` — Card, Badge, Button, Table(공지). 지도 라이브러리 TBD +- 데이터: React Query `useGetMainDashboard()`. 처리하기는 `useReadNoti()` +- 진행상황 카드: `docSeComCd`로 4슬롯 매핑. `TOTAL` 제외 +- 알림 목록: 테이블이 아닌 행 리스트(뱃지 + 일자 + 제목 + 버튼) +- 공지: 컬럼 확정 후 `@tanstack/react-table` 또는 단순 Table. 페이지네이션 없음 +- 검색 조건 URL 동기화: 본 화면 필터 없음. `searchYear` UI가 생기면 그때 검토 + +### 9-2. 모듈·파일 배치 + +| 구분 | 경로 | +| ------------- | ----------------------------------------------------------- | +| 라우트 | `app/(main)/(with-base-layout)/page.tsx` | +| 페이지 | `pages/home/ui/main-page.tsx` | +| 지도 | `pages/home/ui/national-as-map.tsx` (신규, API TBD) | +| 진행상황 카드 | `pages/home/ui/doc-prgrs-stts-cards.tsx` (신규) | +| 알림이력 | `pages/home/ui/dashboard-noti-list.tsx` (신규) | +| 공지사항 | `pages/home/ui/dashboard-notice-table.tsx` (신규, API TBD) | +| Entity API | `entities/dashboard/api/dashboard-service.ts` | +| Entity Hook | `entities/dashboard/api/queries.ts` → `useGetMainDashboard` | +| Entity Model | `entities/dashboard/model/dashboard-interface.ts` | +| 알림 읽음 | `entities/noti` → `useReadNoti` | + +Entity(`getMainDashboard`) 구현됨. 영역 B·C 화면 구현. 영역 A·D는 Skeleton. + +### 9-3. 영역별 UI 매핑 + +| 영역 | 컴포넌트 | 데이터 | +| ---- | ------------------------ | -------------------------------------------------------------------- | +| A | `national-as-map` | Skeleton (지도 API TBD) | +| B | `doc-prgrs-stts-cards` | `useGetMainDashboard` → `docPrgrsSttsList` (`TOTAL` 제외, 코드 매핑) | +| C | `dashboard-noti-list` | 동 훅 → `notiStats`, `notiList`. 처리 → `useReadNoti`. 전체보기 `/nti-hstry` | +| D | `dashboard-notice-table` | Skeleton (공지 API TBD) | + +### 9-4. 다이얼로그·라우팅 + +| 항목 | 내용 | +| -------------- | ------------------------------------------------------------------------------------------------------- | +| 다이얼로그 | 없음 | +| 본 화면 라우트 | `/` (홈) | +| 알림 처리 이동 | [처리하기] → `readNoti` 후 `notiUrl` (알림이력과 동일, SEWER URL은 상세 세그먼트 제거) | +| 알림 전체보기 | `/nti-hstry` | +| 공지 더보기 | TBD | +| 진행상황 카드 | §5-3 목록 라우트 (`/fnrs-cnsltn`, `/instl-sncn`, `/design-cnstn`, `/sewer-bsc-plan`) | + +### 9-5. 데이터 흐름 + +``` +BEGIN + + // 최초 진입 (홈) + CALL getMainDashboard({}) + DISPLAY 영역 B // docPrgrsSttsList — FNRS/INSTL/CNFM/SEWER 슬롯 + DISPLAY 영역 C // notiStats + notiList(≤5) + + CALL 공지 목록 // TBD → 영역 D + CALL 지도 데이터 // TBD → 영역 A + + ON 영역 C [전체보기] CLICK DO + NAVIGATE /nti-hstry + END ON + + ON 영역 C [처리하기] CLICK DO + CALL readNoti(notiId) + IF notiUrl THEN + NAVIGATE notiUrl + END IF + CALL getMainDashboard // invalidate dashboardBaseKey + END ON + + ON 영역 B 카드 CLICK DO + NAVIGATE 문서 목록 // §5-3 + END ON + + ON 영역 D [더보기] CLICK DO + NAVIGATE 공지 목록 // TBD + END ON + +END +``` diff --git a/apps/as/middleware.ts b/apps/as/middleware.ts index f5bd866f3..dbd2a53f7 100644 --- a/apps/as/middleware.ts +++ b/apps/as/middleware.ts @@ -71,10 +71,10 @@ export function middleware(req: NextRequest) { return addSecurityHeaders(response); } - // if (pathname === "/") { - // const response = NextResponse.redirect(new URL("/dashboard", req.url)); - // return addSecurityHeaders(response); - // } + if (pathname === "/") { + const response = NextResponse.redirect(new URL("/dashboard", req.url)); + return response; + } logMessages.push("\n--------------------------------\n"); console.log(logMessages.join("\n")); diff --git a/apps/as/src/entities/dashboard/api/dashboard-service.ts b/apps/as/src/entities/dashboard/api/dashboard-service.ts new file mode 100644 index 000000000..2440c423c --- /dev/null +++ b/apps/as/src/entities/dashboard/api/dashboard-service.ts @@ -0,0 +1,23 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; + +import type { + DashboardMainResponseDto, + DashboardMainSearchDto, + DashboardService, +} from "../model/dashboard-interface"; + +const BASE = ENDPOINT.AS_SERVICE.DASHBOARD; + +async function getMainDashboard( + searchDto: DashboardMainSearchDto, +): Promise { + const response = await client.get(`${BASE}/main`, { + params: searchDto, + }); + return response.data; +} + +export const dashboardService = { + getMainDashboard, +} satisfies DashboardService; diff --git a/apps/as/src/entities/dashboard/api/queries.ts b/apps/as/src/entities/dashboard/api/queries.ts new file mode 100644 index 000000000..9274d520c --- /dev/null +++ b/apps/as/src/entities/dashboard/api/queries.ts @@ -0,0 +1,18 @@ +import { useQuery } from "@tanstack/react-query"; + +import { ENDPOINT } from "@/shared/config"; + +import type { DashboardMainSearchDto } from "../model/dashboard-interface"; + +import { dashboardService } from "./dashboard-service"; + +export const dashboardBaseKey = [ENDPOINT.AS_SERVICE.DASHBOARD] as const; + +const EMPTY_SEARCH: DashboardMainSearchDto = {}; + +export function useGetMainDashboard(params: DashboardMainSearchDto = EMPTY_SEARCH) { + return useQuery({ + queryKey: [...dashboardBaseKey, "getMainDashboard", params], + queryFn: () => dashboardService.getMainDashboard(params), + }); +} diff --git a/apps/as/src/entities/dashboard/index.ts b/apps/as/src/entities/dashboard/index.ts new file mode 100644 index 000000000..19d5b2c0b --- /dev/null +++ b/apps/as/src/entities/dashboard/index.ts @@ -0,0 +1,10 @@ +export { dashboardService } from "./api/dashboard-service"; +export { dashboardBaseKey, useGetMainDashboard } from "./api/queries"; +export type { + DashboardMainResponseDto, + DashboardMainSearchDto, + DashboardService, + DocPrgrsSttsResponseDto, + NotiAsStatResponseDto, + NotiAsUserResponseDto, +} from "./model/dashboard-interface"; diff --git a/apps/as/src/entities/dashboard/model/dashboard-interface.ts b/apps/as/src/entities/dashboard/model/dashboard-interface.ts new file mode 100644 index 000000000..fb592680e --- /dev/null +++ b/apps/as/src/entities/dashboard/model/dashboard-interface.ts @@ -0,0 +1,58 @@ +/** 백엔드 DashboardMainSearchDto */ +export interface DashboardMainSearchDto { + searchYear?: number; + notiType?: string; + notReadYn?: "Y" | "N"; + startDt?: string; + endDt?: string; + docSeComCd?: string; +} + +/** 백엔드 DocPrgrsSttsResponseDto */ +export interface DocPrgrsSttsResponseDto { + docSeNm: string; + docSeComCd: string; + reqCnt: number; + prgrsCnt: number; + cmptnCnt: number; + totCnt: number; +} + +/** 백엔드 NotiAsUserResponseDto */ +export interface NotiAsUserResponseDto { + notiId: number; + recvUnqId: string; + senderUnqId: string; + notiType: string; + notiTtl: string; + notiCn?: string; + notReadYn?: "Y" | "N"; + regDt?: string; + notiUrl?: string; + notiLnkgUnqNo?: string; + docSeComCd?: string; + docSeComNm?: string; + notiIdntyDt?: string; + notiIdntyYn?: "Y" | "N"; + notiPrcsCmptnYn?: "Y" | "N"; + notiPrcsCmptnDt?: string; +} + +/** 백엔드 NotiAsStatResponseDto */ +export interface NotiAsStatResponseDto { + todayCount: number; + delayCount: number; + pendingCount: number; +} + +/** 백엔드 DashboardMainResponseDto */ +export interface DashboardMainResponseDto { + docPrgrsSttsList: DocPrgrsSttsResponseDto[]; + notiList: NotiAsUserResponseDto[]; + notiStats: NotiAsStatResponseDto; +} + +/** Controller public 메서드명·시그니처와 동일 */ +export interface DashboardService { + getMainDashboard(searchDto: DashboardMainSearchDto): Promise; +} diff --git a/apps/as/src/entities/noti/api/queries.ts b/apps/as/src/entities/noti/api/queries.ts index c314f23a7..0d55e3279 100644 --- a/apps/as/src/entities/noti/api/queries.ts +++ b/apps/as/src/entities/noti/api/queries.ts @@ -21,6 +21,7 @@ export function useReadNoti() { mutationFn: (notiId: number) => notiService.readNoti(notiId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: notiBaseKey }); + queryClient.invalidateQueries({ queryKey: [ENDPOINT.AS_SERVICE.DASHBOARD] }); }, }); } diff --git a/apps/as/src/pages/dashboard/index.ts b/apps/as/src/pages/dashboard/index.ts new file mode 100644 index 000000000..07f537f63 --- /dev/null +++ b/apps/as/src/pages/dashboard/index.ts @@ -0,0 +1 @@ +export { DashboardPage as MainPage } from "./ui/dashboard-page"; diff --git a/apps/as/src/pages/dashboard/ui/dashboard-noti-list.tsx b/apps/as/src/pages/dashboard/ui/dashboard-noti-list.tsx new file mode 100644 index 000000000..11e938f4b --- /dev/null +++ b/apps/as/src/pages/dashboard/ui/dashboard-noti-list.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useCallback } from "react"; +import { useRouter } from "next/navigation"; + +import { dayjs } from "@/shared/lib"; +import { NoData } from "@/shared/ui/no-data"; +import type { NotiAsStatResponseDto, NotiAsUserResponseDto } from "@/entities/dashboard"; +import { useReadNoti } from "@/entities/noti"; +import { Badge, Button, Skeleton } from "@common/components"; + +interface DashboardNotiListProps { + notiList: NotiAsUserResponseDto[] | undefined; + notiStats: NotiAsStatResponseDto | undefined; + isPending: boolean; + isError: boolean; + dataUpdatedAt: number; +} + +export function DashboardNotiList({ + notiList, + notiStats, + isPending, + isError, + dataUpdatedAt, +}: DashboardNotiListProps) { + const router = useRouter(); + const { mutateAsync: readNoti, isPending: isReadPending } = useReadNoti(); + + const handleAction = useCallback( + async (row: NotiAsUserResponseDto) => { + if (row.notReadYn === "N") await readNoti(row.notiId); + + const notiUrl = resolveNotiUrl(row); + if (!notiUrl) return; + + router.push(notiUrl); + }, + [readNoti, router], + ); + + return ( +
+
+

+ 알림이력 + {dataUpdatedAt > 0 ? ( + + (업데이트 일시: {dayjs(dataUpdatedAt).format("YYYY-MM-DD HH:mm")}) + + ) : null} +

+
+ +
+
+ +
+
+ {NOTI_STAT_CHIPS.map((chip) => ( + + {chip.label} {isPending ? "-" : (notiStats?.[chip.field] ?? 0)}건 + + ))} +
+ + {isPending ? ( +
+ + + +
+ ) : null} + + {isError ? ( +

+ 알림이력을 불러오지 못했습니다. +

+ ) : null} + + {!isPending && !isError && (notiList?.length ?? 0) === 0 ? : null} + +
+ {!isPending && !isError && notiList && notiList.length > 0 + ? notiList.map((row) => ( + { + void handleAction(row); + }} + /> + )) + : null} +
+
+
+ ); +} + +interface DashboardNotiRowProps { + row: NotiAsUserResponseDto; + isActionPending: boolean; + onAction: () => void; +} + +function DashboardNotiRow({ row, isActionPending, onAction }: DashboardNotiRowProps) { + return ( +
+ + {row.regDt ? dayjs(row.regDt).format("YYYY-MM-DD") : "-"} + +

{row.notiTtl}

+ +
+ ); +} + +function resolveNotiUrl(row: NotiAsUserResponseDto) { + const notiUrl = row.notiUrl?.trim(); + if (!notiUrl) return null; + + if (row.docSeComCd === "SEWER") { + const parts = notiUrl.split("/"); + if (parts.length > 1) { + parts.pop(); + return parts.join("/"); + } + } + + return notiUrl; +} + +const NOTI_STAT_CHIPS = [ + { field: "todayCount", label: "오늘", color: "green" }, + { field: "delayCount", label: "지연", color: "orange" }, + { field: "pendingCount", label: "완료대기", color: "gray" }, +] as const; diff --git a/apps/as/src/pages/dashboard/ui/dashboard-notice-table.tsx b/apps/as/src/pages/dashboard/ui/dashboard-notice-table.tsx new file mode 100644 index 000000000..4338f3db9 --- /dev/null +++ b/apps/as/src/pages/dashboard/ui/dashboard-notice-table.tsx @@ -0,0 +1,16 @@ +import { Skeleton } from "@common/components"; + +export function DashboardNoticeTable() { + return ( +
+
+

공지사항

+
+
+ + + +
+
+ ); +} diff --git a/apps/as/src/pages/dashboard/ui/dashboard-page.tsx b/apps/as/src/pages/dashboard/ui/dashboard-page.tsx new file mode 100644 index 000000000..5e4226ffd --- /dev/null +++ b/apps/as/src/pages/dashboard/ui/dashboard-page.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { useGetMainDashboard } from "@/entities/dashboard"; + +import { DashboardNotiList } from "./dashboard-noti-list"; +import { DashboardNoticeTable } from "./dashboard-notice-table"; +import { DocPrgrsSttsCards } from "./doc-prgrs-stts-cards"; +import { NationalAsMap } from "./national-as-map"; + +export function DashboardPage() { + const { data, isPending, isError, dataUpdatedAt } = useGetMainDashboard(); + + return ( +
+ +
+ + + +
+
+ ); +} diff --git a/apps/as/src/pages/dashboard/ui/doc-prgrs-stts-cards.tsx b/apps/as/src/pages/dashboard/ui/doc-prgrs-stts-cards.tsx new file mode 100644 index 000000000..34c278f54 --- /dev/null +++ b/apps/as/src/pages/dashboard/ui/doc-prgrs-stts-cards.tsx @@ -0,0 +1,128 @@ +import Link from "next/link"; + +import { CircleCheck, FilePlus, type LucideIcon, RefreshCw } from "lucide-react"; + +import type { DocPrgrsSttsResponseDto } from "@/entities/dashboard"; +import { cn, Skeleton } from "@common/components"; + +interface DocPrgrsSttsCardsProps { + items: DocPrgrsSttsResponseDto[] | undefined; + isPending: boolean; + isError: boolean; +} + +export function DocPrgrsSttsCards({ items, isPending, isError }: DocPrgrsSttsCardsProps) { + if (isPending) { + return ( +
+ {DOC_PRGRS_STTS_SLOTS.map((slot) => ( + + ))} +
+ ); + } + + if (isError) { + return ( +
+ 진행상황을 불러오지 못했습니다. +
+ ); + } + + const itemByCd = new Map(items?.map((item) => [item.docSeComCd, item])); + + return ( +
+ {DOC_PRGRS_STTS_SLOTS.map((slot) => { + const item = itemByCd.get(slot.docSeComCd); + return ( +
+
+

{slot.title}

+
+ +
+ {STAT_METRICS.map((metric) => ( + + ))} +
+ +
+ ); + })} +
+ ); +} + +interface DocPrgrsSttsMetricProps { + label: string; + count: number; + iconClassName: string; + bgColor: string; + Icon: LucideIcon; +} + +function DocPrgrsSttsMetric({ + label, + count, + iconClassName, + bgColor, + Icon, +}: DocPrgrsSttsMetricProps) { + return ( +
+ + + + {label} + {count} +
+ ); +} + +const DOC_PRGRS_STTS_SLOTS = [ + { docSeComCd: "FNRS", title: "재원협의 진행상황", href: "/fnrs-cnsltn" }, + { docSeComCd: "INSTL", title: "설치인가 진행상황", href: "/instl-sncn" }, + { docSeComCd: "CNFM", title: "설계자문 진행상황", href: "/design-cnstn" }, + { docSeComCd: "SEWER", title: "기본계획 진행상황", href: "/sewer-bsc-plan" }, +] as const; + +const STAT_METRICS = [ + { + field: "reqCnt", + label: "신청", + iconClassName: "bg-blue-500", + bgColor: "bg-blue-100", + Icon: FilePlus, + }, + { + field: "prgrsCnt", + label: "진행", + iconClassName: "bg-emerald-500", + bgColor: "bg-emerald-100", + Icon: RefreshCw, + }, + { + field: "cmptnCnt", + label: "완료", + iconClassName: "bg-orange-500", + bgColor: "bg-orange-100", + Icon: CircleCheck, + }, +] as const; diff --git a/apps/as/src/pages/dashboard/ui/national-as-map.tsx b/apps/as/src/pages/dashboard/ui/national-as-map.tsx new file mode 100644 index 000000000..74042f5d3 --- /dev/null +++ b/apps/as/src/pages/dashboard/ui/national-as-map.tsx @@ -0,0 +1,14 @@ +import { Skeleton } from "@common/components"; + +export function NationalAsMap() { + return ( +
+
+

전국 행정지원 지도

+
+
+ +
+
+ ); +} diff --git a/apps/as/src/pages/home/index.ts b/apps/as/src/pages/home/index.ts deleted file mode 100644 index 97a463408..000000000 --- a/apps/as/src/pages/home/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { MainPage } from "./ui/main-page"; diff --git a/apps/as/src/pages/home/ui/main-page.tsx b/apps/as/src/pages/home/ui/main-page.tsx deleted file mode 100644 index f757224dc..000000000 --- a/apps/as/src/pages/home/ui/main-page.tsx +++ /dev/null @@ -1,9 +0,0 @@ -"use client"; - -export function MainPage() { - return ( -
-

Home

-
- ); -} diff --git a/apps/as/src/shared/config/api.ts b/apps/as/src/shared/config/api.ts index 594c51fdd..6bf105143 100644 --- a/apps/as/src/shared/config/api.ts +++ b/apps/as/src/shared/config/api.ts @@ -23,6 +23,7 @@ export const ENDPOINT = { DESIGN_CNSTN_ESNTL_ATCH_FILE: "/as-service/api/v1/design-cnstn-esntl-atch-file", DESIGN_CNSTN_RVW_OPNN: "/as-service/api/v1/design-cnstn-rvw-opnn", SEWER_BSC_PLAN: "/as-service/api/v1/sewer-bsc-plans", + DASHBOARD: "/as-service/api/v1/dashboard", }, USER_SERVICE: { LOGIN: "/user-service/login", From 7c88b609f7f594ebde1bee8e8fe828248ca28939 Mon Sep 17 00:00:00 2001 From: bicochan Date: Tue, 18 Aug 2026 17:35:54 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[as]=20feat:=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=EA=B3=B5=EC=A7=80=EC=82=AC=ED=95=AD=20=EC=84=B9?= =?UTF-8?q?=EC=85=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/as/docs/spec/dashboard/SPEC.md | 180 +++++++++++------- apps/as/next.config.ts | 27 +++ .../as/src/entities/board/api/post-queries.ts | 15 ++ .../as/src/entities/board/api/post-service.ts | 16 ++ apps/as/src/entities/board/index.ts | 8 + .../entities/board/model/post-interface.ts | 40 ++++ .../model/dashboard-notice-table-columns.tsx | 39 ++++ .../dashboard/ui/dashboard-notice-table.tsx | 54 +++++- apps/as/src/shared/config/api.ts | 3 + apps/as/src/shared/config/env.ts | 1 + apps/as/src/shared/lib/use-data-table.tsx | 5 + 11 files changed, 317 insertions(+), 71 deletions(-) create mode 100644 apps/as/src/entities/board/api/post-queries.ts create mode 100644 apps/as/src/entities/board/api/post-service.ts create mode 100644 apps/as/src/entities/board/index.ts create mode 100644 apps/as/src/entities/board/model/post-interface.ts create mode 100644 apps/as/src/pages/dashboard/model/dashboard-notice-table-columns.tsx diff --git a/apps/as/docs/spec/dashboard/SPEC.md b/apps/as/docs/spec/dashboard/SPEC.md index 4797448bc..b547d1ae7 100644 --- a/apps/as/docs/spec/dashboard/SPEC.md +++ b/apps/as/docs/spec/dashboard/SPEC.md @@ -4,7 +4,7 @@ | 항목 | 내용 | | ---------- | -------------------------------------------- | -| 버전 | 1.1 | +| 버전 | 1.2 | | 프로세스명 | UI설계 | | 화면명 | 전국 행정지원 지도 (대시보드 메인) | | 화면 ID | TBD | @@ -37,9 +37,9 @@ │ │ └───────────────┘ └───────────────┘ │ │ ├─────────────────────────────────────────────────┤ │ │ [영역 C] 알림이력 │ -│ │ 알림이력 업데이트 HH:mm [전체보기] │ +│ │ 알림이력 업데이트 HH:mm [전체보기]│ │ │ [오늘 N건] [지연 N건] [완료대기 N건] │ -│ │ [뱃지] [일자] 제목 [처리하기] │ +│ │ [뱃지] [일자] 제목 [처리하기]│ │ │ … (최대 5건) │ │ ├─────────────────────────────────────────────────┤ │ │ [영역 D] 공지사항 [더보기] │ @@ -60,7 +60,7 @@ BEGIN DISPLAY 진행상황 카드 (영역 B) // docPrgrsSttsList, TOTAL 행 제외 DISPLAY 알림 통계 칩 · 알림 목록 (영역 C) // notiStats, notiList - CALL 공지사항 목록조회(…) // TBD — 대시보드 API에 없음 + CALL getUserPostList(UserPostSearchParams) // board-service, AS_DASHBOARD_BBS_ID DISPLAY 공지사항 그리드 (영역 D) CALL 지도 마커/음영 조회(…) // TBD — 대시보드 API에 없음 @@ -77,11 +77,11 @@ BEGIN END ON ON 영역 D [더보기] 버튼 CLICK DO - NAVIGATE 공지사항 목록 // TBD + NAVIGATE /cms/bbsnotice/{AS_DASHBOARD_BBS_ID} // as 앱 공지 목록 라우트 미구현. st/cm 관례 END ON ON 영역 D 행 CLICK DO - NAVIGATE 공지사항 상세 // TBD + NAVIGATE /cms/bbsnotice/{AS_DASHBOARD_BBS_ID}/{pstNo} END ON ON 영역 B 카드 CLICK DO @@ -165,12 +165,12 @@ END 카드 클릭 시 **해당 문서 목록 페이지**로 이동한다. `docSeComCd` → 라우트 고정 매핑. -| 동작 | `docSeComCd` | 이동 경로 | -| --------- | ------------ | ------------------- | -| 카드 클릭 | `FNRS` | `/fnrs-cnsltn` | -| 카드 클릭 | `INSTL` | `/instl-sncn` | -| 카드 클릭 | `CNFM` | `/design-cnstn` | -| 카드 클릭 | `SEWER` | `/sewer-bsc-plan` | +| 동작 | `docSeComCd` | 이동 경로 | +| --------- | ------------ | ----------------- | +| 카드 클릭 | `FNRS` | `/fnrs-cnsltn` | +| 카드 클릭 | `INSTL` | `/instl-sncn` | +| 카드 클릭 | `CNFM` | `/design-cnstn` | +| 카드 클릭 | `SEWER` | `/sewer-bsc-plan` | --- @@ -180,11 +180,11 @@ END ### 6-1. 헤더 -| 표시명 | Field / 소스 | 비고 | -| ------------- | ------------ | --------------------------------------------------------------------------------- | -| 섹션 타이틀 | — | 고정 문구 `알림이력` | +| 표시명 | Field / 소스 | 비고 | +| ------------- | ------------ | --------------------------------------------------------------------------------------- | +| 섹션 타이틀 | — | 고정 문구 `알림이력` | | 업데이트 시각 | (client) | 스크린샷 `업데이트 10:32`. API 필드 없음. `getMainDashboard` 마지막 fetch 시각(`HH:mm`) | -| 전체보기 | Button | `/nti-hstry`로 이동 | +| 전체보기 | Button | `/nti-hstry`로 이동 | ### 6-2. 통계 칩 @@ -247,33 +247,48 @@ END ## 7. 영역 D — 공지사항 -`getMainDashboard`에 공지 필드가 없다. 목록·더보기 API·라우트는 **TBD**. as 앱에 board-service 엔티티 없음. +`getMainDashboard`에 공지 필드는 없다. **board-service** `getUserPostList`로 별도 조회한다. + +- Base: `GET /board-service/api/v1/posts/user/{AS_DASHBOARD_BBS_ID}` +- `AS_DASHBOARD_BBS_ID` = `NEXT_PUBLIC_AS_DASHBOARD_BBS_ID` (로컬 예: `7`) +- 클라이언트: `@/entities/board` — `postApi.getUserPostList` / `useUserPostList` + +페이지네이션 컨트롤은 없다. 요약 목록만 표시한다 (`page=0`, `size=3`). ### 7-1. 헤더·버튼 -| 버튼명 | 동작 | -| ------ | -------------------------------- | -| 더보기 | 공지사항 목록으로 이동. 경로 TBD | +| 버튼명 | 동작 | +| ------ | ------------------------------------------------------------------------------------------- | +| 더보기 | `/cms/bbsnotice/{AS_DASHBOARD_BBS_ID}` 로 이동. as 앱에 공지 목록 라우트는 없음(st/cm 관례) | ### 7-2. 그리드 컬럼 -스크린샷 헤더 순서. +스크린샷 헤더 순서. `UserPost`. -| 표시명 | Field | 비고 | -| ------ | ----- | ------------------------------------------------------ | -| 번호 | TBD | 고정글은 `[공지]` 뱃지, 그 외 순번. 고정 여부 필드 TBD | -| 제목 | TBD | 예: `2024년 시스템 업데이트 공지사항` | -| 작성자 | TBD | 예: `관리자`, `시스템관리자` | -| 작성일 | TBD | 표시 `YYYY-MM-DD` | -| 조회수 | TBD | 숫자 | +| 표시명 | Field | 비고 | +| ------ | --------- | ---------------------------------------------------- | +| 번호 | `rowNo` | `upndFixYn` = `Y` 이면 `[공지]` Badge, 그 외 `rowNo` | +| 제목 | `pstTtl` | truncate | +| 작성자 | `wrtrNm` | | +| 작성일 | `regDt` | 표시 `YYYY-MM-DD` (응답 datetime) | +| 조회수 | `inqNocs` | 숫자 | -페이지네이션 없음 (대시보드 요약, 건수 TBD). +조회 파라미터 (`UserPostSearchParams`): + +| 필드 | 값 | 비고 | +| ------------- | ----------- | ------------------------------------------ | +| `page` | `0` | Spring 0-based | +| `size` | `3` | 대시보드 요약. 스크린샷 3행 | +| `keywordType` | `bbsTypeCd` | st 대시보드 공지 패널과 동일 | +| `keyword` | `BBSNOTICE` | 게시판 유형. path의 bbsId와 함께 공지 한정 | + +응답에 있으나 그리드에 **표시하지 않음**: `bbsId`, `pstNo`(행 키·상세 이동), `pstCn`, `upndFixYn`(번호 열 판정만), `rgtrNm`, `atflCd` 등. ### 7-3. 행 상호작용 -| 동작 | 설명 | -| ------- | ------------------------ | -| 행 클릭 | 공지 상세 이동. 경로 TBD | +| 동작 | 설명 | +| ------- | ------------------------------------------------------------------------------ | +| 행 클릭 | `/cms/bbsnotice/{AS_DASHBOARD_BBS_ID}/{pstNo}` (as 앱 공지 상세 라우트 미구현) | --- @@ -287,8 +302,9 @@ END | 인증 | `Authorization` 헤더. 프론트는 `client` 인터셉터. 로그인 기관 권한은 서버가 사용자 캐시로 적용 | | 클라이언트 모듈 | `@/entities/dashboard` — `dashboardService` / `useGetMainDashboard` | | 알림 읽음 | `@/entities/noti` — `notiService.readNoti` / `useReadNoti` | +| 공지사항 | `@/entities/board` — `postApi.getUserPostList` / `useUserPostList` | -영역 A(지도)·영역 D(공지)는 본 컨트롤러 범위 밖 → **TBD**. +영역 A(지도)는 본 컨트롤러·board-service 범위 밖 → **TBD**. ### 8-2. 대시보드 메인 조회 — `getMainDashboard` @@ -354,11 +370,36 @@ Body 없음. 성공 시 `dashboardBaseKey`(및 필요 시 `notiBaseKey`) invalid | ------------------- | ------ | --- | ----------- | ------------ | --- | | 지도 마커·음영 조회 | TBD | TBD | TBD | TBD | TBD | -### 8-5. 공지사항 — TBD +### 8-5. 공지사항 — `getUserPostList` -| 기능 | Method | URL | Request DTO | Response DTO | API | -| ----------------------- | ------ | --- | ----------- | ------------ | --- | -| 대시보드 공지 요약 목록 | TBD | TBD | TBD | TBD | TBD | +board-service. bbsId는 환경변수 `AS_DASHBOARD_BBS_ID`로 path에 고정한다. + +| 기능 | Method | URL | Request DTO | Response DTO | API | +| ----------------------- | ------ | ------------------------------------------ | ----------------------------- | ------------------------ | --------------------------------------------- | +| 대시보드 공지 요약 목록 | GET | `/board-service/api/v1/posts/user/{bbsId}` | Query: `UserPostSearchParams` | `PageResponse` | `postApi.getUserPostList` / `useUserPostList` | + +**Request (`UserPostSearchParams`)** — 본 화면 고정값 + +| 필드 | 필수 | 설명 | +| ------------- | ---- | -------------- | +| `page` | N | `0` | +| `size` | N | `3` | +| `keywordType` | N | `bbsTypeCd` | +| `keyword` | N | `BBSNOTICE` | +| `popupYn` 등 | N | 본 화면 미사용 | + +**Response (`UserPost`) — 화면 매핑 필드** + +| 필드 | 타입 | 설명 | +| ----------- | ------ | --------------------------- | +| `rowNo` | number | 번호. 고정글이 아니면 표시 | +| `pstNo` | number | 게시 번호. 상세 이동 키 | +| `bbsId` | number | 게시판 ID | +| `pstTtl` | string | 제목 | +| `wrtrNm` | string | 작성자 | +| `regDt` | string | 작성일 | +| `inqNocs` | number | 조회수 | +| `upndFixYn` | string | 상단고정 `Y`/`N` → `[공지]` | --- @@ -368,47 +409,50 @@ Body 없음. 성공 시 `dashboardBaseKey`(및 필요 시 `notiBaseKey`) invalid - 라우트: 기존 홈 `app/(main)/(with-base-layout)/page.tsx` → `pages/home` (현재 `MainPage` 플레이스홀더) - UI: `@common/components` — Card, Badge, Button, Table(공지). 지도 라이브러리 TBD -- 데이터: React Query `useGetMainDashboard()`. 처리하기는 `useReadNoti()` +- 데이터: React Query `useGetMainDashboard()`. 처리하기는 `useReadNoti()`. 공지는 `useUserPostList()` - 진행상황 카드: `docSeComCd`로 4슬롯 매핑. `TOTAL` 제외 - 알림 목록: 테이블이 아닌 행 리스트(뱃지 + 일자 + 제목 + 버튼) -- 공지: 컬럼 확정 후 `@tanstack/react-table` 또는 단순 Table. 페이지네이션 없음 +- 공지: `@tanstack/react-table` + `useDataTable`, 페이지네이션 없음 - 검색 조건 URL 동기화: 본 화면 필터 없음. `searchYear` UI가 생기면 그때 검토 ### 9-2. 모듈·파일 배치 -| 구분 | 경로 | -| ------------- | ----------------------------------------------------------- | -| 라우트 | `app/(main)/(with-base-layout)/page.tsx` | -| 페이지 | `pages/home/ui/main-page.tsx` | -| 지도 | `pages/home/ui/national-as-map.tsx` (신규, API TBD) | -| 진행상황 카드 | `pages/home/ui/doc-prgrs-stts-cards.tsx` (신규) | -| 알림이력 | `pages/home/ui/dashboard-noti-list.tsx` (신규) | -| 공지사항 | `pages/home/ui/dashboard-notice-table.tsx` (신규, API TBD) | -| Entity API | `entities/dashboard/api/dashboard-service.ts` | -| Entity Hook | `entities/dashboard/api/queries.ts` → `useGetMainDashboard` | -| Entity Model | `entities/dashboard/model/dashboard-interface.ts` | -| 알림 읽음 | `entities/noti` → `useReadNoti` | +| 구분 | 경로 | +| ------------- | ------------------------------------------------------------ | +| 라우트 | `app/(main)/(with-base-layout)/page.tsx` | +| 페이지 | `pages/dashboard/ui/dashboard-page.tsx` | +| 지도 | `pages/dashboard/ui/national-as-map.tsx` (API TBD, Skeleton) | +| 진행상황 카드 | `pages/dashboard/ui/doc-prgrs-stts-cards.tsx` | +| 알림이력 | `pages/dashboard/ui/dashboard-noti-list.tsx` | +| 공지사항 | `pages/dashboard/ui/dashboard-notice-table.tsx` | +| 공지 컬럼 | `pages/dashboard/model/dashboard-notice-table-columns.tsx` | +| Entity API | `entities/dashboard/api/dashboard-service.ts` | +| Entity Hook | `entities/dashboard/api/queries.ts` → `useGetMainDashboard` | +| Entity Model | `entities/dashboard/model/dashboard-interface.ts` | +| 알림 읽음 | `entities/noti` → `useReadNoti` | +| 공지 Entity | `entities/board` → `useUserPostList` | -Entity(`getMainDashboard`) 구현됨. 영역 B·C 화면 구현. 영역 A·D는 Skeleton. +Entity(`getMainDashboard`, `getUserPostList`) 구현됨. 영역 B·C·D 화면 구현. 영역 A는 Skeleton. ### 9-3. 영역별 UI 매핑 -| 영역 | 컴포넌트 | 데이터 | -| ---- | ------------------------ | -------------------------------------------------------------------- | -| A | `national-as-map` | Skeleton (지도 API TBD) | -| B | `doc-prgrs-stts-cards` | `useGetMainDashboard` → `docPrgrsSttsList` (`TOTAL` 제외, 코드 매핑) | +| 영역 | 컴포넌트 | 데이터 | +| ---- | ------------------------ | ---------------------------------------------------------------------------- | +| A | `national-as-map` | Skeleton (지도 API TBD) | +| B | `doc-prgrs-stts-cards` | `useGetMainDashboard` → `docPrgrsSttsList` (`TOTAL` 제외, 코드 매핑) | | C | `dashboard-noti-list` | 동 훅 → `notiStats`, `notiList`. 처리 → `useReadNoti`. 전체보기 `/nti-hstry` | -| D | `dashboard-notice-table` | Skeleton (공지 API TBD) | +| D | `dashboard-notice-table` | `useUserPostList` → `content[]`. [더보기]·행 클릭 → `/cms/bbsnotice/...` | ### 9-4. 다이얼로그·라우팅 -| 항목 | 내용 | -| -------------- | ------------------------------------------------------------------------------------------------------- | -| 다이얼로그 | 없음 | -| 본 화면 라우트 | `/` (홈) | +| 항목 | 내용 | +| -------------- | -------------------------------------------------------------------------------------- | +| 다이얼로그 | 없음 | +| 본 화면 라우트 | `/` (홈) | | 알림 처리 이동 | [처리하기] → `readNoti` 후 `notiUrl` (알림이력과 동일, SEWER URL은 상세 세그먼트 제거) | -| 알림 전체보기 | `/nti-hstry` | -| 공지 더보기 | TBD | +| 알림 전체보기 | `/nti-hstry` | +| 공지 더보기 | `/cms/bbsnotice/{AS_DASHBOARD_BBS_ID}` (as 앱 공지 라우트 미구현, st/cm 관례) | +| 공지 행 클릭 | `/cms/bbsnotice/{AS_DASHBOARD_BBS_ID}/{pstNo}` | | 진행상황 카드 | §5-3 목록 라우트 (`/fnrs-cnsltn`, `/instl-sncn`, `/design-cnstn`, `/sewer-bsc-plan`) | ### 9-5. 데이터 흐름 @@ -421,7 +465,9 @@ BEGIN DISPLAY 영역 B // docPrgrsSttsList — FNRS/INSTL/CNFM/SEWER 슬롯 DISPLAY 영역 C // notiStats + notiList(≤5) - CALL 공지 목록 // TBD → 영역 D + CALL getUserPostList({ page: 0, size: 3, keywordType: "bbsTypeCd", keyword: "BBSNOTICE" }) + DISPLAY 영역 D + CALL 지도 데이터 // TBD → 영역 A ON 영역 C [전체보기] CLICK DO @@ -441,7 +487,11 @@ BEGIN END ON ON 영역 D [더보기] CLICK DO - NAVIGATE 공지 목록 // TBD + NAVIGATE /cms/bbsnotice/{AS_DASHBOARD_BBS_ID} + END ON + + ON 영역 D 행 CLICK DO + NAVIGATE /cms/bbsnotice/{AS_DASHBOARD_BBS_ID}/{pstNo} END ON END diff --git a/apps/as/next.config.ts b/apps/as/next.config.ts index 33da97ea4..606194380 100644 --- a/apps/as/next.config.ts +++ b/apps/as/next.config.ts @@ -1,5 +1,7 @@ import type { NextConfig } from "next"; +const subDomain = process.env.NEXT_PUBLIC_SUB_DOMAIN || "http://localhost:13000"; + const nextConfig: NextConfig = { output: "standalone", reactStrictMode: true, @@ -7,6 +9,31 @@ const nextConfig: NextConfig = { experimental: { globalNotFound: true, }, + + async rewrites() { + return { + beforeFiles: [ + { + source: "/_next/static/:path*", + destination: `${subDomain}/_next/static/:path*`, + has: [ + { + type: "header", + key: "referer", + value: "(.*)/cms(.*)", + }, + ], + }, + ], + afterFiles: [ + { + source: "/cms/:path*", + destination: `${subDomain}/cms/:path*`, + }, + ], + fallback: [], + }; + }, }; export default nextConfig; diff --git a/apps/as/src/entities/board/api/post-queries.ts b/apps/as/src/entities/board/api/post-queries.ts new file mode 100644 index 000000000..4cf801e0a --- /dev/null +++ b/apps/as/src/entities/board/api/post-queries.ts @@ -0,0 +1,15 @@ +import { useQuery } from "@tanstack/react-query"; + +import { ENDPOINT } from "@/shared/config"; + +import type { UserPostSearchParams } from "../model/post-interface"; + +import { postApi } from "./post-service"; + +export function useUserPostList(params?: UserPostSearchParams) { + return useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS, params], + queryFn: () => postApi.getUserPostList(params), + refetchOnWindowFocus: false, + }); +} diff --git a/apps/as/src/entities/board/api/post-service.ts b/apps/as/src/entities/board/api/post-service.ts new file mode 100644 index 000000000..9cd25f976 --- /dev/null +++ b/apps/as/src/entities/board/api/post-service.ts @@ -0,0 +1,16 @@ +import { client } from "@/shared/api"; +import { AS_DASHBOARD_BBS_ID, ENDPOINT } from "@/shared/config"; + +import { PostService, UserPostListResponse, UserPostSearchParams } from "../model/post-interface"; + +async function getUserPostList(params?: UserPostSearchParams): Promise { + const response = await client.get( + `${ENDPOINT.BOARD_SERVICE.POSTS}/user/${AS_DASHBOARD_BBS_ID}`, + { params }, + ); + return response.data; +} + +export const postApi: PostService = { + getUserPostList, +}; diff --git a/apps/as/src/entities/board/index.ts b/apps/as/src/entities/board/index.ts new file mode 100644 index 000000000..2bec23972 --- /dev/null +++ b/apps/as/src/entities/board/index.ts @@ -0,0 +1,8 @@ +export { useUserPostList } from "./api/post-queries"; +export { postApi } from "./api/post-service"; +export type { + PostService, + UserPost, + UserPostListResponse, + UserPostSearchParams, +} from "./model/post-interface"; diff --git a/apps/as/src/entities/board/model/post-interface.ts b/apps/as/src/entities/board/model/post-interface.ts new file mode 100644 index 000000000..31eca4e64 --- /dev/null +++ b/apps/as/src/entities/board/model/post-interface.ts @@ -0,0 +1,40 @@ +import type { PageableParams, PageResponse } from "@/shared/model"; + +export interface UserPostSearchParams extends PageableParams { + keywordType?: string; + keyword?: string; + popupYn?: string; + upndFixYn?: string; + prdPstgYn?: string; + siteId?: string; + bbsTypeCd?: string; + ansUseYn?: string; +} + +export interface UserPost { + rowNo: number; + bbsId: number; + pstNo: number; + pstTtl: string; + pstCn: string; + inqNocs: number; + rfrncUrl: string; + popupYn: string; + upndFixYn: string; + prdPstgYn: string; + rootPath: string; + pstPath: string; + ansPstn: number; + wrtrNm: string; + wrtrCplc: string; + atflCd: string; + regDt: string; + rgtrNm: string; + cmntCnt: number; +} + +export type UserPostListResponse = PageResponse; + +export interface PostService { + getUserPostList(params?: UserPostSearchParams): Promise; +} diff --git a/apps/as/src/pages/dashboard/model/dashboard-notice-table-columns.tsx b/apps/as/src/pages/dashboard/model/dashboard-notice-table-columns.tsx new file mode 100644 index 000000000..78bd1623c --- /dev/null +++ b/apps/as/src/pages/dashboard/model/dashboard-notice-table-columns.tsx @@ -0,0 +1,39 @@ +import { createColumnHelper } from "@tanstack/react-table"; + +import { dayjs } from "@/shared/lib"; +import type { UserPost } from "@/entities/board"; +import { Badge } from "@common/components"; + +const columnHelper = createColumnHelper(); + +export const dashboardNoticeColumns = [ + columnHelper.display({ + id: "no", + header: "번호", + meta: { align: "center" }, + cell: ({ row }) => { + if (row.original.upndFixYn === "Y") { + return 공지; + } + return row.original.rowNo; + }, + }), + columnHelper.accessor("pstTtl", { + header: "제목", + meta: { align: "left" }, + cell: ({ getValue }) =>
{getValue()}
, + }), + columnHelper.accessor("wrtrNm", { + header: "작성자", + meta: { align: "center" }, + }), + columnHelper.accessor("regDt", { + header: "작성일", + meta: { align: "center" }, + cell: ({ getValue }) => (getValue() ? dayjs(getValue()).format("YYYY-MM-DD") : "-"), + }), + columnHelper.accessor("inqNocs", { + header: "조회수", + meta: { align: "center" }, + }), +]; diff --git a/apps/as/src/pages/dashboard/ui/dashboard-notice-table.tsx b/apps/as/src/pages/dashboard/ui/dashboard-notice-table.tsx index 4338f3db9..737d92cd1 100644 --- a/apps/as/src/pages/dashboard/ui/dashboard-notice-table.tsx +++ b/apps/as/src/pages/dashboard/ui/dashboard-notice-table.tsx @@ -1,16 +1,58 @@ -import { Skeleton } from "@common/components"; +"use client"; + +import { useRouter } from "next/navigation"; + +import { getCoreRowModel } from "@tanstack/react-table"; + +import { AS_DASHBOARD_BBS_ID } from "@/shared/config"; +import { useDataTable } from "@/shared/lib"; +import { type UserPost, type UserPostSearchParams, useUserPostList } from "@/entities/board"; +import { Button } from "@common/components"; + +import { dashboardNoticeColumns } from "../model/dashboard-notice-table-columns"; + +const DASHBOARD_NOTICE_LIST_PARAMS: UserPostSearchParams = { + page: 0, + size: 3, + keywordType: "bbsTypeCd", + keyword: "BBSNOTICE", +}; export function DashboardNoticeTable() { + const router = useRouter(); + const userPostListQuery = useUserPostList(DASHBOARD_NOTICE_LIST_PARAMS); + + const handleRowClick = (post: UserPost) => { + if (!AS_DASHBOARD_BBS_ID) return; + router.push(`/cms/bbsnotice/${AS_DASHBOARD_BBS_ID}/${post.pstNo}`); + }; + + const handleMoreClick = () => { + if (!AS_DASHBOARD_BBS_ID) return; + router.push(`/cms/bbsnotice/${AS_DASHBOARD_BBS_ID}`); + }; + + const { tableComponent } = useDataTable({ + query: userPostListQuery, + tableOptions: { + columns: dashboardNoticeColumns, + getCoreRowModel: getCoreRowModel(), + }, + dataTableOptions: { + variant: "line", + onRowClick: handleRowClick, + }, + }); + return (

공지사항

+
-
- - - -
+ {tableComponent}
); } diff --git a/apps/as/src/shared/config/api.ts b/apps/as/src/shared/config/api.ts index 6bf105143..64eb48989 100644 --- a/apps/as/src/shared/config/api.ts +++ b/apps/as/src/shared/config/api.ts @@ -47,6 +47,9 @@ export const ENDPOINT = { ST_ANLS_MST: "/statverify-service/api/v1/st-anls-mst", }, DC_SERVICE: "/dc-service/api/v1", + BOARD_SERVICE: { + POSTS: "/board-service/api/v1/posts", + }, }; export const JWT_EXPIRATION_TIME = 1800000; diff --git a/apps/as/src/shared/config/env.ts b/apps/as/src/shared/config/env.ts index dffee3972..f06c95d2b 100644 --- a/apps/as/src/shared/config/env.ts +++ b/apps/as/src/shared/config/env.ts @@ -9,3 +9,4 @@ export const ACCESS_TOKEN = process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-toke export const REFRESH_TOKEN = process.env.NEXT_PUBLIC_REFRESH_TOKEN || "refresh-token"; export const AUTH_USER_ID = process.env.NEXT_PUBLIC_AUTH_USER_ID || "token-id"; export const COOKIE_DOMAIN = process.env.NEXT_PUBLIC_COOKIE_DOMAIN; +export const AS_DASHBOARD_BBS_ID = process.env.NEXT_PUBLIC_AS_DASHBOARD_BBS_ID; diff --git a/apps/as/src/shared/lib/use-data-table.tsx b/apps/as/src/shared/lib/use-data-table.tsx index 54caf68c7..ef473f50d 100644 --- a/apps/as/src/shared/lib/use-data-table.tsx +++ b/apps/as/src/shared/lib/use-data-table.tsx @@ -38,6 +38,11 @@ export function useDataTable({ query, tableOptions, dataTableOptions }: UseDa ), Empty: , + Error: ( +

+ 데이터를 불러오는 중 오류가 발생했습니다. +

+ ), Success: () => , }), tableInstance: table, From 5b12f81b8fb2832ed33a9869f8e689d5198568d4 Mon Sep 17 00:00:00 2001 From: rsone001 Date: Tue, 18 Aug 2026 17:44:22 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[ST]=20-=20fix:=20=ED=86=B5=EA=B3=84=20?= =?UTF-8?q?=EC=A7=91=EA=B3=84=ED=91=9C=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=EA=B0=92=20=EC=88=98=EC=A0=95,=20=EC=97=91=EC=85=80?= =?UTF-8?q?=20=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=20=EC=8A=A4=ED=83=80?= =?UTF-8?q?=EC=9D=BC=20=EC=A0=81=EC=9A=A9,=20=EC=9A=94=EC=95=BD=ED=91=9C?= =?UTF-8?q?=20=EB=A7=88=EC=9A=B0=EC=8A=A4=20=ED=88=B4=ED=8C=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20/=20=EC=9E=85=EB=A0=A5=EB=8B=A8=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EA=B2=80=ED=86=A0?= =?UTF-8?q?=EB=8B=A8=20=EC=BB=AC=EB=9F=BC=20=EC=84=A4=EC=A0=95=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/st/src/components/css/DataGrid.css | 20 ++ apps/st/src/components/module/DataGrid.tsx | 146 +++++++-- apps/st/src/components/module/SearchForm.tsx | 29 +- apps/st/src/components/module/TabDataGrid.tsx | 148 +++++++-- .../input/ExcretaTreatmentFacilityInp.tsx | 24 +- .../input/SewageExcretaRelatedInp.tsx | 26 +- .../input/SewageExcretaSlurgeInp.tsx | 30 +- .../statistics/input/SewagePipeInp.tsx | 20 +- .../input/SewagePipeMaintainInp.tsx | 1 + .../statistics/input/SewerageFinanceInp.tsx | 6 +- .../input/SewerageServiceLevelInp.tsx | 14 +- .../statistics/input/WaterReuseInp.tsx | 32 +- .../statistics/review/SewagePipeRvw.tsx | 30 +- .../total/ExcretaTreatmentFacilityTot.tsx | 3 +- .../total/PrivateSewageFacilityTot.tsx | 3 +- .../total/PublicSewageFacilityTot.tsx | 3 +- .../statistics/total/PumpingStationTot.tsx | 3 +- .../total/SewageExcretaRelatedTot.tsx | 3 +- .../total/SewageExcretaSlurgeTot.tsx | 3 +- .../total/SewagePipeMaintainTot.tsx | 3 +- .../statistics/total/SewagePipeTot.tsx | 14 +- .../total/SewageReservoirPumpTot.tsx | 3 +- .../total/SewerageAggregateSummary.tsx | 1 + .../statistics/total/SewerageFinanceTot.tsx | 3 +- .../total/SeweragePenetrationRateTot.tsx | 3 +- .../total/SewerageServiceLevelTot.tsx | 3 +- .../statistics/total/WaterReuseTot.tsx | 3 +- .../OperatorsComponents.tsx | 37 ++- apps/st/src/components/type/DataGrid.type.ts | 4 + .../st/src/components/type/SearchForm.type.ts | 4 +- .../column/input/ExcretaTreatFactInpColumn.ts | 6 +- .../PublicSewageFactColumn.ts | 6 +- .../column/input/PumpingStationInpColumn.ts | 8 +- .../input/SeweragePenetrationRateInpColumn.ts | 6 +- .../PrivateSewageDesigStatusInpColumn.ts | 4 +- .../SepticTankStatusInpColumn.ts | 4 +- .../SewageTreatFactStatusInpColumn.ts | 4 +- .../ExcretaRelatedIndustryInpColumn.ts | 4 +- ...vateSewageTreatRelatedIndustryInpColumn.ts | 4 +- ...blicInstitutionExcretaIndustryInpColumn.ts | 4 +- ...PublicInstitutionSewerIndustryInpColumn.ts | 4 +- .../DigestorOperatingStatusInpColumn.ts | 6 +- .../ExcretaSlurgeProcessingStatusInpColumn.ts | 6 +- ...ewageSlurgeProcessingFacStatusInpColumn.ts | 4 +- .../SlurgeProcessingFacStatusInpColumn.ts | 6 +- .../sewagePipe/FactInstallStatusInpColumn.ts | 8 +- .../sewagePipe/ManholeStatusInpColumn.ts | 6 +- .../sewagePipe/PipeDiameterStatusInpColumn.ts | 7 +- .../sewagePipe/PipeTypeStatusInpColumn.ts | 7 +- .../sewagePipe/RegionalFactStatusInpColumn.ts | 6 +- .../PipeDredgingStatusInpColumn.ts | 4 +- .../RepairStatusInpColumn.ts | 4 +- .../RainfallPumpStationInpColumn.ts | 6 +- .../SewageReservoirInpColumn.ts | 6 +- .../sewerageFinance/CauserBurdenInpColumn.ts | 4 +- .../SewerageChargeInpColumn.ts | 4 +- .../SewerageFinanceSettleInpColumn.ts | 4 +- .../SewerageUsageFeeInpColumn.ts | 4 +- .../ComplaintInpColumn.ts | 4 +- .../ResidentFriendlyFactInpColumn.ts | 8 +- .../PublicSewageTreatWaterReuseInpColumn.ts | 4 +- .../RainFallUtilFactStatusInpColumn.ts | 4 +- .../RecycledWaterStatusInpColumn.ts | 4 +- ...atWaterReuseBusinessApprStatusInpColumn.ts | 4 +- ...eatWaterReuseDesignConstStatusInpColumn.ts | 4 +- .../util/column/review/SewagePipeRvwColumn.ts | 310 ++++++++++-------- .../util/column/total/SewagePipeTotColumn.ts | 94 +++++- .../st/src/components/util/gridExcelExport.ts | 102 ++++++ ...statistics-sewage-pipe-review-interface.ts | 92 +----- .../statistics-sewage-pipe-total-interface.ts | 116 +------ 70 files changed, 944 insertions(+), 570 deletions(-) diff --git a/apps/st/src/components/css/DataGrid.css b/apps/st/src/components/css/DataGrid.css index 228c16703..b15945079 100644 --- a/apps/st/src/components/css/DataGrid.css +++ b/apps/st/src/components/css/DataGrid.css @@ -6,6 +6,17 @@ white-space: pre-line; } +/* RealGrid Excel export — applyDynamicStyles가 class background-color를 읽을 수 있도록 */ +.rg-cell-rowtype-biz-sum { + background-color: #ffe9cf !important; +} +.rg-cell-rowtype-summary { + background-color: #feee85 !important; +} +.rg-cell-rowtype-other { + background-color: #fff7bf !important; +} + @layer components { .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell { @apply !bg-white; @@ -14,6 +25,15 @@ .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-readonly { @apply !bg-[#f0f0f0] !text-[#666]; } + /* 고정 컬럼 — readonly와 동일한 화면 스타일 (엑셀 export 시 exportStyleName에서 제외) */ + .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-fixed, + .rg-grid .rg-body .rg-data-cell.rg-cell-fixed { + @apply !bg-[#f0f0f0] !text-[#666]; + } + .rg-grid .rg-fixed-body .rg-table .rg-data-row td.rg-cell-fixed, + .rg-grid .rg-fixed-body .rg-data-row .rg-data-cell.rg-cell-fixed { + @apply !bg-[#f0f0f0] !text-[#666]; + } .rg-grid .rg-body .rg-table .rg-data-row .rg-data-cell.rg-cell-readonly.rg-cell-rowtype-curr { @apply !bg-white !text-[#333]; } diff --git a/apps/st/src/components/module/DataGrid.tsx b/apps/st/src/components/module/DataGrid.tsx index cdc2e570b..b56d7e83d 100644 --- a/apps/st/src/components/module/DataGrid.tsx +++ b/apps/st/src/components/module/DataGrid.tsx @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useRef, useState, type MutableRefObject } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState, type MutableRefObject } from "react"; import { createPortal } from "react-dom"; import JSZip from "jszip"; @@ -32,33 +32,64 @@ import { } from "@/components/util/input/verification-result-display"; import { buildExcelExportFileBaseName, + buildFixedColumnNameSet, getExcelImportColumnFieldNames, getNonEditableColumnFieldNames, hasAdmdstCdColumn, isExcelImportAdmdstCdInvalid, mergeExcelImportWithExistingRows, prepareGridColumnsForExcelExport, + releaseFixedOptionsForExcelExport, + restoreFixedOptionsAfterExcelExport, restoreGridColumnsAfterExcelExport, + resolveColumnStyleName, shouldPrepareColumnsForExcelExport, + stripFixedStylesForExport, } from "@/components/util/gridExcelExport"; import { useTabStore } from "@/store/tab-store"; function mergeCellStyleCallbackResult( baseStyleName: string | undefined, extra: CellStyleCallbackResult | undefined, + options: { + mirrorExportStyle?: boolean; + stripFixedForExport?: boolean; + } = {}, ): CellStyleCallbackResult { + const { mirrorExportStyle = false, stripFixedForExport = false } = options; + + let styleName: string | undefined; + let extraObj: Extract | undefined; + if (!extra) { - return baseStyleName ? { styleName: baseStyleName } : {}; + styleName = baseStyleName || undefined; + } else if (typeof extra === "string") { + styleName = [baseStyleName, extra].filter(Boolean).join(" ") || undefined; + } else { + extraObj = extra; + styleName = + [baseStyleName, extra.styleName].filter(Boolean).join(" ") || + undefined; } - if (typeof extra === "string") { - const styleName = [baseStyleName, extra].filter(Boolean).join(" "); - return styleName ? { styleName } : {}; - } - const styleName = [baseStyleName, extra.styleName].filter(Boolean).join(" "); - return { - ...extra, + + if (!styleName && !extraObj) return {}; + + const result: CellStyleCallbackResult = { + ...extraObj, ...(styleName ? { styleName } : {}), }; + + if (typeof result !== "object") return result; + + if (mirrorExportStyle && styleName) { + result.exportStyleName = extraObj?.exportStyleName ?? styleName; + } else if (stripFixedForExport && styleName) { + const stripped = stripFixedStylesForExport(styleName); + if (stripped) result.exportStyleName = stripped; + else delete result.exportStyleName; + } + + return result; } /** 컬럼 footer.styleName 또는 footerVerificationResult(fieldName) → footer styleName */ @@ -159,19 +190,26 @@ function applyCellStyleCallbacksToColumns( columns: ColumnConfig[], rowDataRef: MutableRefObject[]>, cellStyleCallbackRef: MutableRefObject, + excelExportApplyStylesRef: MutableRefObject, + fixedColumnNameSet: Set, ) { - if (!cellStyleCallbackRef.current || !gridView) return; + if (!gridView) return; const gv = gridView as { columnByName?: (name: string) => GridColumnWithStyleCallback | null | undefined; }; if (typeof gv.columnByName !== "function") return; + const hasUserCellStyleCallback = Boolean(cellStyleCallbackRef.current); + columns.forEach((column) => { - const baseStyleName = column.styleName; + const isFixedColumn = fixedColumnNameSet.has(column.name); + // 고정 컬럼은 styleCallback 필수(고정 pane에 column styleName만으로는 클래스 미반영) + if (!isFixedColumn && !hasUserCellStyleCallback) return; + + const baseStyleName = resolveColumnStyleName(column, fixedColumnNameSet); let gridCol: GridColumnWithStyleCallback | null | undefined; try { - // columnByName은 GridView 인스턴스 메서드 — this 바인딩을 위해 gv에서 직접 호출 gridCol = gv.columnByName!(column.name); } catch { return; @@ -184,17 +222,32 @@ function applyCellStyleCallbacksToColumns( const fieldName = dataCell?.dataField ?? dataCell?.fieldName ?? column.fieldName; const data = rowDataRef.current; + const styleOptions = { + mirrorExportStyle: excelExportApplyStylesRef.current, + stripFixedForExport: + isFixedColumn && !excelExportApplyStylesRef.current, + }; if (itemIndex < 0 || itemIndex >= data.length) { - return mergeCellStyleCallbackResult(baseStyleName, undefined); + return mergeCellStyleCallbackResult( + baseStyleName, + undefined, + styleOptions, + ); } const row = data[itemIndex] as Record; - const extra = cellStyleCallbackRef.current?.({ - rowIndex: itemIndex, - fieldName, - row, - grid: grid as CellStyleCallbackParams["grid"], - }); - return mergeCellStyleCallbackResult(baseStyleName, extra); + const extra = hasUserCellStyleCallback + ? cellStyleCallbackRef.current?.({ + rowIndex: itemIndex, + fieldName, + row, + grid: grid as CellStyleCallbackParams["grid"], + }) + : undefined; + return mergeCellStyleCallbackResult( + baseStyleName, + extra, + styleOptions, + ); }; }); } @@ -815,6 +868,7 @@ const DataGrid = ({ rowStartNum, excelExportFileSuffix, excelExportSearchContext, + excelExportApplyStyles = false, isExcelImport = true, excelImportSkipNonEditable = false, excelImportSewerBzmnCd, @@ -844,6 +898,10 @@ const DataGrid = ({ fitStyle = GridFitStyle.EVEN, }: DataGridProps) => { const { columns, columnLayout } = columnInfo; + const fixedColumnNameSet = useMemo( + () => buildFixedColumnNameSet(columns, columnLayout, fixedColumns), + [columns, columnLayout, fixedColumns], + ); const gridRef = useRef(null); const gridHostRef = useRef(null); const fileInputRef = useRef(null); @@ -856,6 +914,7 @@ const DataGrid = ({ const cellStyleCallbackRef = useRef( cellStyleCallback, ); + const excelExportApplyStylesRef = useRef(excelExportApplyStyles); const cellTooltipCallbackRef = useRef( cellTooltipCallback, ); @@ -885,6 +944,7 @@ const DataGrid = ({ columnsRef.current = columns; rowDataRef.current = rowData; cellStyleCallbackRef.current = cellStyleCallback; + excelExportApplyStylesRef.current = excelExportApplyStyles; cellTooltipCallbackRef.current = cellTooltipCallback; footerVerificationResultRef.current = footerVerificationResult; onScrollToBottomRef.current = onScrollToBottom; @@ -1039,7 +1099,8 @@ useEffect(() => { }, [onCellDblClick]); useLayoutEffect(() => { const gv = gridRef.current?.gridView; - if (!gv || !cellStyleCallback) return; + if (!gv) return; + if (!cellStyleCallback && fixedColumnNameSet.size === 0) return; const apply = () => { applyCellStyleCallbacksToColumns( @@ -1047,6 +1108,8 @@ useEffect(() => { columns, rowDataRef, cellStyleCallbackRef, + excelExportApplyStylesRef, + fixedColumnNameSet, ); (gv as { refresh?: () => void }).refresh?.(); }; @@ -1058,7 +1121,7 @@ useEffect(() => { return () => { window.clearTimeout(timerId); }; - }, [cellStyleCallback, columns, rowData]); + }, [cellStyleCallback, columns, rowData, excelExportApplyStyles, fixedColumnNameSet, fixedColumns]); const { setMessage: alert } = useAlertStore((state) => state); const activeTabId = useTabStore((state) => state.activeTabId); @@ -1156,6 +1219,10 @@ useEffect(() => { const excelExportVisibilitySnapshot = useExcelColumnVisibilityPrep ? prepareGridColumnsForExcelExport(gridView, columns) : undefined; + const savedFixedColCount = + !excelExportApplyStyles && fixedColumns + ? releaseFixedOptionsForExcelExport(gridView, fixedColumns) + : undefined; const exportBaseName = buildExcelExportFileBaseName( menuName, @@ -1169,7 +1236,14 @@ useEffect(() => { fileName: `${exportBaseName}.xlsx`, footer: "hidden", allColumns: !useExcelColumnVisibilityPrep, + ...(excelExportApplyStyles ? { applyDynamicStyles: true } : {}), done: () => { + if (savedFixedColCount !== undefined) { + restoreFixedOptionsAfterExcelExport( + gridView, + savedFixedColCount, + ); + } if (excelExportVisibilitySnapshot) { restoreGridColumnsAfterExcelExport( gridView, @@ -1187,7 +1261,7 @@ useEffect(() => { } }, 100); } - alert("내보내기가 완료되었습니다."); + // alert("내보내기가 완료되었습니다."); }, }); }; @@ -1515,8 +1589,17 @@ useEffect(() => { gv.setColumnProperty?.( column.name, "styleName", - column.styleName ?? "", + resolveColumnStyleName(column, fixedColumnNameSet), ); + if (fixedColumnNameSet.has(column.name) && !excelExportApplyStyles) { + gv.setColumnProperty?.( + column.name, + "exportStyleName", + stripFixedStylesForExport( + resolveColumnStyleName(column, fixedColumnNameSet), + ) || "", + ); + } applyColumnListEditorProperties(gv, column); }); if (columns.some((c) => isDropDownEditorColumn(c))) { @@ -1705,6 +1788,8 @@ useEffect(() => { columns, rowDataRef, cellStyleCallbackRef, + excelExportApplyStylesRef, + fixedColumnNameSet, ); gridRef.current.gridView.refresh?.(); } catch (e) { @@ -1794,6 +1879,8 @@ useEffect(() => { columns, rowDataRef, cellStyleCallbackRef, + excelExportApplyStylesRef, + fixedColumnNameSet, ); // setColumns 호출 후 footer 배열의 valueCallback을 다시 설정 @@ -1847,6 +1934,8 @@ useEffect(() => { columns, rowDataRef, cellStyleCallbackRef, + excelExportApplyStylesRef, + fixedColumnNameSet, ); liveGridView.refresh?.(); }, 50); @@ -1930,6 +2019,8 @@ useEffect(() => { columns, columnLayout, fixedColumns, + fixedColumnNameSet, + excelExportApplyStyles, rowData, showHeaderCheckbox, getGridDataRef, @@ -2732,13 +2823,18 @@ useEffect(() => { columns, rowDataRef, cellStyleCallbackRef, + excelExportApplyStylesRef, + fixedColumnNameSet, ); gv.refresh(); }, 0); }} > {columns.map((column) => { - const baseStyleName = column.styleName; + const baseStyleName = resolveColumnStyleName( + column, + fixedColumnNameSet, + ); const numberFormat = column.dataFormat && column.dataFormat.trim() !== "" ? column.dataFormat diff --git a/apps/st/src/components/module/SearchForm.tsx b/apps/st/src/components/module/SearchForm.tsx index 46c0b50d3..0b6ec3080 100644 --- a/apps/st/src/components/module/SearchForm.tsx +++ b/apps/st/src/components/module/SearchForm.tsx @@ -73,6 +73,21 @@ function buildSearchFormValues( }; } + if (props.defaultRegionAll) { + return { + sido: "ALL", + gugun: "ALL", + year: d?.year || defaultYear.toString(), + order: d?.order || "", + fctName: d?.fctName || "", + bsnmCd: "", + isDetail: false, + isSummary: d?.isSummary || "", + clsfCd: initialClsfCd, + capacityGbn: d?.capacityGbn || "ALL", + }; + } + const first = sidoList?.data?.sidoList?.[0]; const useFirstSido = (props.sido ?? defaultSearchFormProps.sido) && first; @@ -132,6 +147,7 @@ const SearchForm = (props: SearchFormProps = {}) => { // 최초 진입만 시도 API가 늦게 오는 경우 useEffect(() => { + if (props.defaultRegionAll) return; if (props.defaultValues?.sido) return; const first = sidoList?.data?.sidoList?.[0]; if (!first || !mergedProps.sido) return; @@ -145,7 +161,7 @@ const SearchForm = (props: SearchFormProps = {}) => { bsnmCd: first.bsnmCd, }, ); - }, [sidoList, props.defaultValues?.sido, mergedProps.sido]); + }, [sidoList, props.defaultValues?.sido, props.defaultRegionAll, mergedProps.sido]); const sggKey: [string, string, string, string] | null = values.sido && values.sido !== "ALL" @@ -177,6 +193,7 @@ const SearchForm = (props: SearchFormProps = {}) => { // 시도 설정 후 시군구 목록이 오면 첫 번째 항목 자동 선택 useEffect(() => { if (!mergedProps.gugun) return; + if (props.defaultRegionAll) return; if (props.defaultValues?.gugun) return; if (!values.sido || values.sido === "ALL") return; @@ -196,6 +213,7 @@ const SearchForm = (props: SearchFormProps = {}) => { values.sido, mergedProps.gugun, props.defaultValues?.gugun, + props.defaultRegionAll, ]); const handleSubmit = (e: React.FormEvent) => { @@ -210,11 +228,13 @@ const SearchForm = (props: SearchFormProps = {}) => { const handleSidoChange = (value: string) => { const selectedBsnm = - sidoList?.data?.sidoList?.find((v) => v.sido === value)?.bsnmCd || ""; + value === "ALL" + ? "" + : sidoList?.data?.sidoList?.find((v) => v.sido === value)?.bsnmCd || ""; setValues((prev) => ({ ...prev, sido: value, - gugun: "", + gugun: props.defaultRegionAll || value === "ALL" ? "ALL" : "", bsnmCd: selectedBsnm, })); }; @@ -403,7 +423,8 @@ const SearchForm = (props: SearchFormProps = {}) => { )} {mergedProps.isSummary && (
-