[as] feat: 대시보드 페이지 구현

This commit is contained in:
2026-08-18 16:40:21 +09:00
parent 774144846d
commit 5af4f0f27b
18 changed files with 897 additions and 15 deletions
@@ -0,0 +1,3 @@
import { DashboardPage } from "@/pages/dashboard/ui/dashboard-page";
export default DashboardPage;
@@ -1 +0,0 @@
export { MainPage as default } from "@/pages/home";
+448
View File
@@ -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
```
+4 -4
View File
@@ -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"));
@@ -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<DashboardMainResponseDto> {
const response = await client.get<DashboardMainResponseDto>(`${BASE}/main`, {
params: searchDto,
});
return response.data;
}
export const dashboardService = {
getMainDashboard,
} satisfies DashboardService;
@@ -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),
});
}
+10
View File
@@ -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";
@@ -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<DashboardMainResponseDto>;
}
+1
View File
@@ -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] });
},
});
}
+1
View File
@@ -0,0 +1 @@
export { DashboardPage as MainPage } from "./ui/dashboard-page";
@@ -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 (
<section className="card card-border mx-0 bg-background">
<div className="card-header flex items-center justify-between gap-4">
<h3 className="text-2xl font-semibold">
알림이력
{dataUpdatedAt > 0 ? (
<span className="pl-2 text-lg font-medium text-muted-foreground">
(업데이트 일시: {dayjs(dataUpdatedAt).format("YYYY-MM-DD HH:mm")})
</span>
) : null}
</h3>
<div className="flex items-center gap-4">
<Button type="button" size="sm" color="white" onClick={() => router.push("/nti-hstry")}>
전체보기
</Button>
</div>
</div>
<div className="flex flex-col gap-4 p-2">
<div className="flex flex-wrap gap-2 pt-2">
{NOTI_STAT_CHIPS.map((chip) => (
<Badge key={chip.field} color={chip.color}>
{chip.label} {isPending ? "-" : (notiStats?.[chip.field] ?? 0)}건
</Badge>
))}
</div>
{isPending ? (
<div className="flex flex-col gap-2">
<Skeleton className="h-14 w-full" />
<Skeleton className="h-14 w-full" />
<Skeleton className="h-14 w-full" />
</div>
) : null}
{isError ? (
<p className="flex min-h-[12rem] items-center justify-center text-muted-foreground">
알림이력을 불러오지 못했습니다.
</p>
) : null}
{!isPending && !isError && (notiList?.length ?? 0) === 0 ? <NoData /> : null}
<div>
{!isPending && !isError && notiList && notiList.length > 0
? notiList.map((row) => (
<DashboardNotiRow
key={row.notiId}
row={row}
isActionPending={isReadPending}
onAction={() => {
void handleAction(row);
}}
/>
))
: null}
</div>
</div>
</section>
);
}
interface DashboardNotiRowProps {
row: NotiAsUserResponseDto;
isActionPending: boolean;
onAction: () => void;
}
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">
{row.regDt ? dayjs(row.regDt).format("YYYY-MM-DD") : "-"}
</span>
<p className="min-w-0 flex-1 truncate text-xl">{row.notiTtl}</p>
<Button type="button" size="sm" color="white" disabled={isActionPending} onClick={onAction}>
처리하기
</Button>
</div>
);
}
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;
@@ -0,0 +1,16 @@
import { Skeleton } from "@common/components";
export function DashboardNoticeTable() {
return (
<section className="card card-border mx-0 bg-background">
<div className="card-header flex items-center justify-between">
<h3 className="text-2xl font-semibold">공지사항</h3>
</div>
<div className="flex flex-col gap-2 p-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
</section>
);
}
@@ -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 (
<div className="grid gap-4 px-[1.8rem] pb-6 lg:grid-cols-2">
<NationalAsMap />
<div className="flex min-h-0 flex-col gap-4">
<DocPrgrsSttsCards items={data?.docPrgrsSttsList} isPending={isPending} isError={isError} />
<DashboardNotiList
notiList={data?.notiList}
notiStats={data?.notiStats}
isPending={isPending}
isError={isError}
dataUpdatedAt={dataUpdatedAt}
/>
<DashboardNoticeTable />
</div>
</div>
);
}
@@ -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 (
<div className="grid grid-cols-2 gap-4">
{DOC_PRGRS_STTS_SLOTS.map((slot) => (
<Skeleton key={slot.docSeComCd} className="h-[14rem] rounded-xl" />
))}
</div>
);
}
if (isError) {
return (
<div className="card card-border mx-0 flex min-h-[14rem] items-center justify-center bg-background text-muted-foreground">
진행상황을 불러오지 못했습니다.
</div>
);
}
const itemByCd = new Map(items?.map((item) => [item.docSeComCd, item]));
return (
<div className="grid grid-cols-2 gap-4">
{DOC_PRGRS_STTS_SLOTS.map((slot) => {
const item = itemByCd.get(slot.docSeComCd);
return (
<div
key={slot.docSeComCd}
className="card card-border hover:bg-muted/40 mx-0 block bg-background transition-colors"
>
<div className="card-header">
<h3 className="text-2xl font-semibold">{slot.title}</h3>
</div>
<Link key={slot.docSeComCd} href={slot.href}>
<div className="flex items-center justify-between pt-2">
{STAT_METRICS.map((metric) => (
<DocPrgrsSttsMetric
key={metric.field}
label={metric.label}
count={item?.[metric.field] ?? 0}
iconClassName={metric.iconClassName}
bgColor={metric.bgColor}
Icon={metric.Icon}
/>
))}
</div>
</Link>
</div>
);
})}
</div>
);
}
interface DocPrgrsSttsMetricProps {
label: string;
count: number;
iconClassName: string;
bgColor: string;
Icon: LucideIcon;
}
function DocPrgrsSttsMetric({
label,
count,
iconClassName,
bgColor,
Icon,
}: DocPrgrsSttsMetricProps) {
return (
<div className={cn("card flex flex-1 flex-col items-center gap-2 rounded-2xl p-4", bgColor)}>
<span
className={cn(
"flex size-12 items-center justify-center rounded-full text-white",
iconClassName,
)}
>
<Icon className="size-6" />
</span>
<span className="text-xl text-muted-foreground">{label}</span>
<span className="text-3xl font-semibold tabular-nums">{count}</span>
</div>
);
}
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;
@@ -0,0 +1,14 @@
import { Skeleton } from "@common/components";
export function NationalAsMap() {
return (
<section className="card card-border mx-0 flex h-full min-h-[40rem] flex-col bg-background">
<div className="card-header">
<h3 className="text-2xl font-semibold">전국 행정지원 지도</h3>
</div>
<div className="flex-1 p-4">
<Skeleton className="h-full min-h-[36rem] w-full rounded-xl" />
</div>
</section>
);
}
-1
View File
@@ -1 +0,0 @@
export { MainPage } from "./ui/main-page";
-9
View File
@@ -1,9 +0,0 @@
"use client";
export function MainPage() {
return (
<div className="flex h-full flex-col items-center justify-center py-10">
<h1 className="mb-4 text-3xl font-bold">Home</h1>
</div>
);
}
+1
View File
@@ -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",