[as] feat: 필수첨부파일관리 페이지 초안 작업

This commit is contained in:
2026-05-28 13:37:36 +09:00
parent 215d742270
commit f7c1cf0718
15 changed files with 1084 additions and 350 deletions
@@ -0,0 +1 @@
export { EsntlAtchFileMngPage as default } from "@/pages/esntl-atch-file-mng";
-311
View File
@@ -1,311 +0,0 @@
# 화면설계서: 필수첨부파일관리
## 1. 기본 정보
| 항목 | 내용 |
| ---------- | ---------------------------------------------- |
| 버전 | 0.1 |
| 프로세스명 | UI설계 |
| 화면명 | 필수첨부파일관리 |
| 화면 유형 | [v] 조회 [v] 입력 [v] 수정 [v] 삭제 [ ] 출력 |
| 메뉴 경로 | 디지털행정지원 > 사이트관리 > 필수첨부파일관리 |
---
## 2. 화면 목적
문서(업무) 구분별로 **필수 첨부파일** 항목을 등록·조회·수정·삭제한다. 상단 검색 영역에서 **문서구분**으로 목록을 필터링하고, **등록** 또는 행별 **수정**·**삭제**로 마스터 데이터를 유지한다. 등록·수정 시 **필수첨부파일아이디**는 서버에서 자동 발급된다.
---
## 3. 화면 구성
### 3-1. 전체 레이아웃
```
┌───────────────────────────────────────────────────────────────────────────────────────────────┐
│ 페이지 타이틀: 필수첨부파일관리 브레드크럼: 디지털행정지원 > … > │
├───────────────────────────────────────────────────────────────────────────────────────────────┤
│ [영역 A] 검색 / 액션 │
│ ┌─────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ 문서구분 [전체 ▼] [조회] [등록] │ │
│ └─────────────────────────────────────────────────────────────────────────────────────────┘ │
├───────────────────────────────────────────────────────────────────────────────────────────────┤
│ [영역 B] 필수첨부파일 목록 │
│ ┌─────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ 필수첨부파일아이디 | 문서구분 | 필수첨부파일명 | 필수첨부여부 | 비고 | 등록일시 | 수정일시 | 관리 │
│ ├─────────────────────────────────────────────────────────────────────────────────────────┤ │
│ │ EAF-0001 | 설치인가 | 사업계획서 | 예 | 서명본 첨부 | 2026-04-22 10:10:00 | — | [수정][삭제] │
│ │ EAF-0002 | 기본계획 | 변경사유서 | 예 | — | 2026-04-22 11:00:00 | — | [수정][삭제] │
│ └─────────────────────────────────────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────────────────────────────────┘
(모달) [다이얼로그 C] 필수첨부파일 등록 / 수정
┌──────────────────────────────────────────────────────────────┐
│ 필수첨부파일 등록 [닫기] │
├──────────────────────────────────────────────────────────────┤
│ 필수첨부파일아이디(자동발급) [EAF-0004] (읽기 전용) │
│ 문서구분 [설치인가 ▼] │
│ 필수첨부파일명 [예: 사업계획서] │
│ 필수첨부여부 [예 ▼] │
│ 비고 [비고를 입력하세요] │
├──────────────────────────────────────────────────────────────┤
│ [취소] [저장] │
└──────────────────────────────────────────────────────────────┘
```
### 3-2. 화면 데이터 흐름
```
BEGIN
// 최초 진입
CALL 필수첨부파일 목록 조회 (TBD)
DISPLAY 필수첨부파일 그리드 (영역 B)
// 영역 A: 검색
ON [조회] 버튼 CLICK DO
CALL 필수첨부파일 목록 조회 (문서구분 = 선택값, TBD)
DISPLAY 필수첨부파일 그리드 (영역 B)
END ON
// 영역 A: 등록
ON [등록] 버튼 CLICK DO
OPEN 필수첨부파일 등록 다이얼로그 (다이얼로그 C)
DISPLAY 필수첨부파일아이디 = 자동발급 미리보기 또는 "저장 시 발급" (TBD)
ON [저장] DO
VALIDATE 문서구분, 필수첨부파일명, 필수첨부여부
CALL 필수첨부파일 등록 (TBD)
CALL 필수첨부파일 목록 조회
CLOSE 다이얼로그
TOAST "저장되었습니다."
END ON
ON [취소] / [닫기] DO
CLOSE 다이얼로그
END ON
END ON
// 영역 B: 수정
ON 행 [수정] CLICK DO
OPEN 필수첨부파일 수정 다이얼로그 (기존 값 초기화)
ON [저장] DO
VALIDATE 문서구분, 필수첨부파일명, 필수첨부여부
CALL 필수첨부파일 수정 (TBD)
CALL 필수첨부파일 목록 조회
CLOSE 다이얼로그
END ON
END ON
// 영역 B: 삭제
ON 행 [삭제] CLICK DO
CONFIRM "선택한 필수첨부파일을 삭제하시겠습니까?"
CALL 필수첨부파일 삭제 (TBD)
CALL 필수첨부파일 목록 조회
END ON
END
```
---
## 4. 영역 A: 검색 / 액션
문서구분 조건으로 목록을 조회하고, 신규 등록 다이얼로그를 연다.
### 4-1. 검색 필드
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
| -------- | ---------------- | ---- | ------ | ----------------- | ------------------------------------ |
| 문서구분 | Select(공통코드) | N | 전체 | `docSeComCd?` | "전체" 선택 시 파라미터 미전송 (TBD) |
### 4-2. 버튼
| 버튼명 | 위치 | 동작 |
| ------ | -------------- | ----------------------------------------------- |
| 조회 | 검색 필드 우측 | 현재 검색 조건으로 영역 B 목록 갱신 |
| 등록 | 영역 우측 끝 | 필수첨부파일 등록 다이얼로그(다이얼로그 C) 오픈 |
---
## 5. 영역 B: 필수첨부파일 목록
필수 첨부파일 마스터를 테이블로 표시한다. 페이지네이션 UI는 스크린샷에 없음 — **TBD**(백엔드 Pageable 정책 확인).
### 5-1. 헤더
| 항목 | 내용 |
| ----------- | ----------------- |
| 섹션 타이틀 | 필수첨부파일 목록 |
### 5-2. 그리드 컬럼
| 컬럼명 | Field (제안) | 타입 | 비고 |
| ------------------ | ----------------- | ------ | ------------------------------------- |
| 필수첨부파일아이디 | `esntlAtchFileId` | string | 예: `EAF-0001`, 서버 자동 발급 |
| 문서구분 | `docSeComCd` | string | 코드명 표시 — 공통코드 (TBD 상위코드) |
| 필수첨부파일명 | `esntlAtchFileNm` | string | |
| 필수첨부여부 | `esntlAtchYn` | string | `Y`→예, `N`→아니오 |
| 비고 | `rmk` | string | 없으면 `—` 표시 |
| 등록일시 | `regDt` | string | `yyyy-MM-dd HH:mm:ss` |
| 수정일시 | `mdfcnDt` | string | 미수정 시 빈 값 또는 `—` |
| 관리 | — | — | 행별 **[수정] · [삭제]** |
- 행 클릭 시 별도 상세 화면 없음. 편집은 **[수정]** 버튼으로 다이얼로그 C를 연다.
### 5-3. 버튼
| 버튼명 | 위치 | 동작 |
| ------ | ---- | -------------------------------------- |
| 수정 | 행별 | 필수첨부파일 수정 다이얼로그 (기존 값) |
| 삭제 | 행별 | 확인 후 삭제 API 호출 → 목록 갱신 |
---
## 6. 다이얼로그 C: 필수첨부파일 등록 / 수정
등록·수정 공용 폼 다이얼로그. 등록 시 제목 **「필수첨부파일 등록」**, 수정 시 **「필수첨부파일 수정」**.
### 6-1. 폼 필드
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
| ---------------------------- | ---------------- | ---- | ------ | ----------------- | --------------------------------------------- |
| 필수첨부파일아이디(자동발급) | Text (읽기 전용) | — | — | — | 등록: 저장 전 미리보기 또는 placeholder (TBD) |
| 문서구분 | Select(공통코드) | Y | — | `docSeComCd` | 예: 설치인가, 기본계획 |
| 필수첨부파일명 | Text | Y | — | `esntlAtchFileNm` | placeholder: "예: 사업계획서" |
| 필수첨부여부 | Select | Y | Y | `esntlAtchYn` | Y/N, UI 표시: 예/아니오 |
| 비고 | Textarea | N | — | `rmk` | placeholder: "비고를 입력하세요" |
> **TBD**: 등록 다이얼로그의 `EAF-0004` 표시가 저장 전 ID 미리보기인지, mock 데이터인지 백엔드 정책 확인.
### 6-2. 버튼
| 버튼명 | 동작 |
| ------ | -------------------------------------- |
| 저장 | POST(등록) 또는 PUT(수정) 후 목록 갱신 |
| 취소 | 다이얼로그 닫기, 입력값 폐기 |
| 닫기 | 다이얼로그 닫기 (헤더 우측) |
---
## 7. API/DTO 명세
> API 미확정. 백엔드 명세 확정 전까지 아래는 **TBD** placeholder이다.
### 7-1. 공통
| 항목 | 내용 |
| ----------- | ------------------------------------- |
| 서비스 | TBD |
| Base prefix | TBD |
| 인증 | JWT (`Authorization`), `X-Site-Id` 등 |
| 페이징 | TBD — Spring `Pageable` 여부 확인 |
| 엔티티 | TBD — `@/entities/esntl-atch-file` 등 |
### 7-2. 필수첨부파일 — TBD
| 기능 | Method | URL | Request DTO | Response DTO | API (클라이언트 함수명) |
| --------- | ------ | --- | ----------- | ------------ | ----------------------- |
| 목록 조회 | GET | TBD | TBD | TBD | TBD |
| 단건 조회 | GET | TBD | TBD | TBD | TBD |
| 등록 | POST | TBD | TBD | TBD | TBD |
| 수정 | PUT | TBD | TBD | TBD | TBD |
| 삭제 | DELETE | TBD | TBD | TBD | TBD |
**응답 DTO (제안 필드)**
| Field | 타입 | 설명 |
| ----------------- | ------- | ------------------ |
| `esntlAtchFileId` | string | 필수첨부파일아이디 |
| `docSeComCd` | string | 문서구분공통코드 |
| `esntlAtchFileNm` | string | 필수첨부파일명 |
| `esntlAtchYn` | string? | 필수첨부여부 (Y/N) |
| `rmk` | string? | 비고 |
| `regDt` | string? | 등록일시 |
| `mdfcnDt` | string? | 수정일시 |
**저장 Request (제안)**
| Field | 필수 | 설명 |
| ----------------- | ---- | -------------- |
| `docSeComCd` | Y | 문서구분 |
| `esntlAtchFileNm` | Y | 필수첨부파일명 |
| `esntlAtchYn` | Y | 필수첨부여부 |
| `rmk` | N | 비고 |
**검색 Query (제안)**
| Field | 필수 | 설명 |
| ------------ | ---- | ------------------------- |
| `docSeComCd` | N | 문서구분 (전체 시 미전송) |
| `page` | N | TBD |
| `size` | N | TBD |
| `sort` | N | TBD — 예: `regDt,desc` |
### 7-3. 공통코드 (보조)
| UI 필드 | 코드 그룹 (TBD) | 예시 값 (스크린샷) |
| ------------ | --------------- | ------------------ |
| 문서구분 | TBD | 설치인가, 기본계획 |
| 필수첨부여부 | 고정 | Y(예), N(아니오) |
공통코드 조회: `@/entities/code` — `codeService`, `CodeSelect`
---
## 8. UI 구현 가이드
### 8-1. 기술 스택·원칙
- **공통 컴포넌트**: `@common/components` (`Dialog`, `Button`, `Input`, `Select`, `Textarea`, `Label` 등)
- **테이블**: `@tanstack/react-table` + `useDataTable`
- **폼**: `react-hook-form`
- **데이터**: React Query — TBD 엔티티 hooks
- **공통코드**: `@/entities/code` — `CodeSelect`, `findCodeLabel`
- **확인**: `@frontend-opensource/use-react-hooks` — `useConfirm` (삭제 확인)
- **모의 데이터**: API 연동 전 `@faker-js/faker` (ID 형식 `EAF-####`)
### 8-2. 모듈·파일 배치(제안)
| 구분 | 경로 |
| ------ | ------------------------------------------------------------------------------------------------------ |
| 라우트 | `apps/as/app/(with-base-layout)/attachment-management/page.tsx` |
| 페이지 | `apps/as/src/pages/attachment-management/` (Public API: page만 export) |
| UI | `.../ui/attachment-management-page.tsx`, `esntl-atch-file-list.tsx`, `esntl-atch-file-form-dialog.tsx` |
| model | `.../model/esntl-atch-file-columns.tsx`, `esntl-atch-file-search-params.ts` |
| 엔티티 | `apps/as/src/entities/esntl-atch-file/` (TBD — service·hooks) |
| 문서 | `apps/as/docs/attachment-management/SPEC.md` |
### 8-3. 영역별 UI 매핑
| 영역 | 주요 UI |
| ---- | --------------------------------------------- |
| A | `CodeSelect`(문서구분) + [조회] + [등록] |
| B | `DataTable` + 행별 [수정][삭제] |
| C | `Dialog` + `react-hook-form` (등록/수정 공용) |
### 8-4. 다이얼로그·라우팅
| 다이얼로그명 | 트리거 | API 연동 (TBD) |
| ----------------- | --------- | --------------- |
| 필수첨부파일 등록 | [등록] | create mutation |
| 필수첨부파일 수정 | 행 [수정] | update mutation |
| 삭제 확인 | 행 [삭제] | delete mutation |
라우트: `/attachment-management` (메뉴 URL은 CMS 등록값과 일치시킴)
### 8-5. 데이터 흐름(요약)
- **진입**: 검색 params 기본값(`docSeComCd` = 전체) → 목록 query → 영역 B DataTable.
- **조회**: [조회] 클릭 시 search state 반영 → 목록 query refetch.
- **등록**: [등록] → Dialog `mode=create` → [저장] → POST → 목록 invalidate.
- **수정**: [수정] → Dialog `mode=edit` + row 초기값 → [저장] → PUT → 목록 invalidate.
- **삭제**: confirm → DELETE → 목록 invalidate.
### 8-6. 미확정 / 검증 필요
- [ ] 백엔드 서비스명·Base URL·리소스 path
- [ ] `esntlAtchFileId` 자동발급 시점 (등록 API 응답 vs 저장 전 미리보기 API)
- [ ] 문서구분 공통코드 상위코드 ID
- [ ] 목록 페이지네이션·정렬 UI 노출 여부
- [ ] 동일 문서구분 내 필수첨부파일명 중복 허용 여부
- [ ] 권한(역할별 CRUD)
@@ -0,0 +1,284 @@
# 화면설계서: 필수첨부파일관리
## 1. 기본 정보
| 항목 | 내용 |
| ---------- | ---------------------------------------------- |
| 버전 | 0.1 |
| 프로세스명 | UI설계 |
| 화면명 | 필수첨부파일관리 |
| 화면 유형 | [v] 조회 [v] 입력 [v] 수정 [v] 삭제 [ ] 출력 |
| 메뉴 경로 | 디지털행정지원 > 사이트관리 > 필수첨부파일관리 |
---
## 2. 화면 목적
문서(업무) 구분별로 **필수 첨부파일** 항목을 등록·조회·수정·삭제한다. 상단 검색 영역에서 **문서구분**으로 목록을 필터링하고, **등록** 또는 행별 **수정**·**삭제**로 마스터 데이터를 유지한다. 등록·수정 시 **필수첨부파일아이디**는 클라이언트에서 입력(또는 생성)하여 요청 본문에 포함한다.
---
## 3. 화면 구성
### 3-1. 전체 레이아웃
```
┌──────────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 페이지 타이틀: 필수첨부파일관리 브레드크럼: 디지털행정지원 > … > │
├──────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ [영역 A] 검색 / 액션 │
│ ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ 문서구분 [전체 ▼] [조회] [등록] │ │
│ └────────────────────────────────────────────────────────────────────────────────────────────────┘ │
├──────────────────────────────────────────────────────────────────────────────────────────────────────┤
│ [영역 B] 필수첨부파일 목록 │
│ ┌────────────────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ 필수첨부파일아이디 | 문서구분 | 필수첨부파일명 | 필수첨부여부 | 비고 | 등록일시 | 수정일시 | 관리 │
│ ├────────────────────────────────────────────────────────────────────────────────────────────────┤ │
│ │ EAF-0001 | 설치인가 | 사업계획서 | 예 | 서명본 첨부 | 2026-04-22 10:10:00 | — | [수정][삭제] │ │
│ │ EAF-0002 | 기본계획 | 변경사유서 | 예 | — | 2026-04-22 11:00:00 | — | [수정][삭제] │ │
│ └────────────────────────────────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────────────────────────┘
(모달) [다이얼로그 C] 필수첨부파일 등록 / 수정
┌──────────────────────────────────────────────────────┐
│ 필수첨부파일 등록 [닫기] │
├──────────────────────────────────────────────────────┤
│ 필수첨부파일아이디(자동발급) [EAF-0004] │
│ 문서구분 [설치인가 ▼] │
│ 필수첨부파일명 [예: 사업계획서] │
│ 필수첨부여부 [ ] 예 [ ] 아니오 │
│ 비고 [비고를 입력하세요] │
├──────────────────────────────────────────────────────┤
│ [취소] [저장] │
└──────────────────────────────────────────────────────┘
```
### 3-2. 화면 데이터 흐름
```
BEGIN
// 최초 진입
CALL 필수첨부파일 목록 조회 (findPage)
DISPLAY 필수첨부파일 그리드 (영역 B)
// 영역 A: 검색
ON [조회] 버튼 CLICK DO
CALL 필수첨부파일 목록 조회 (문서구분 = 선택값, findPage)
DISPLAY 필수첨부파일 그리드 (영역 B)
END ON
// 영역 A: 등록
ON [등록] 버튼 CLICK DO
OPEN 필수첨부파일 등록 다이얼로그 (다이얼로그 C)
ON [저장] DO
CONFIRM "등록하시겠습니까?"
VALIDATE 문서구분, 필수첨부파일명, 필수첨부여부
CALL 필수첨부파일 등록 (create)
CALL 필수첨부파일 목록 조회
CLOSE 다이얼로그
TOAST "저장되었습니다."
END ON
ON [취소] / [닫기] DO
CLOSE 다이얼로그
END ON
END ON
// 영역 B: 수정
ON 행 [수정] CLICK DO
OPEN 필수첨부파일 수정 다이얼로그 (기존 값 초기화)
ON [저장] DO
VALIDATE 문서구분, 필수첨부파일명, 필수첨부여부
CALL 필수첨부파일 수정 (update)
CALL 필수첨부파일 목록 조회
CLOSE 다이얼로그
END ON
END ON
// 영역 B: 삭제
ON 행 [삭제] CLICK DO
CONFIRM "삭제하시겠습니까?"
CALL 필수첨부파일 삭제 (remove)
CALL 필수첨부파일 목록 조회
END ON
END
```
---
## 4. 영역 A: 검색 / 액션
문서구분 조건으로 목록을 조회하고, 신규 등록 다이얼로그를 연다.
### 4-1. 검색 필드
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
| -------- | ---------- | ---- | ------ | ----------------- | ------------------------------ |
| 문서구분 | CodeSelect | N | 전체 | `docSeComCd?` | "전체" 선택 시 파라미터 미전송 |
### 4-2. 버튼
| 버튼명 | 위치 | 동작 |
| ------ | -------------- | ----------------------------------------------- |
| 조회 | 검색 필드 우측 | 현재 검색 조건으로 영역 B 목록 갱신 |
| 등록 | 영역 우측 끝 | 필수첨부파일 등록 다이얼로그(다이얼로그 C) 오픈 |
---
## 5. 영역 B: 필수첨부파일 목록
필수 첨부파일 마스터를 테이블로 표시한다. 페이지네이션 UI 적용.
### 5-1. 헤더
| 항목 | 내용 |
| ----------- | ----------------- |
| 섹션 타이틀 | 필수첨부파일 목록 |
### 5-2. 그리드 컬럼
| 컬럼명 | Field | 타입 | 비고 |
| ------------------ | ------------------- | ------ | ------------------------------------- |
| 필수첨부파일아이디 | `esntlAtchFileId` | string | 예: `EAF-0001`, 클라이언트 생성/입력 |
| 문서구분 | `docSeComCd` | string | 코드명 표시 — 공통코드 (TBD 상위코드) |
| 필수첨부파일명 | `esntlAtchFileNm` | string | |
| 필수첨부여부 | `esntlAtchYn` | string | `Y`→예, `N`→아니오 |
| 비고 | `esntlAtchFileRmrk` | string | 없으면 `—` 표시 |
| 등록일시 | `regDt` | string | `yyyy-MM-dd HH:mm:ss` |
| 수정일시 | `mdfcnDt` | string | 미수정 시 빈 값 또는 `—` |
| 관리 | — | — | 행별 **[수정] · [삭제]** |
- 행 클릭 시 별도 상세 화면 없음. 편집은 **[수정]** 버튼으로 다이얼로그 C를 연다.
### 5-3. 버튼
| 버튼명 | 위치 | 동작 |
| ------ | ---- | -------------------------------------- |
| 수정 | 행별 | 필수첨부파일 수정 다이얼로그 (기존 값) |
| 삭제 | 행별 | 확인 후 삭제 API 호출 → 목록 갱신 |
---
## 6. 다이얼로그 C: 필수첨부파일 등록 / 수정
등록·수정 공용 폼 다이얼로그. 등록 시 제목 **「필수첨부파일 등록」**, 수정 시 **「필수첨부파일 수정」**.
### 6-1. 폼 필드
| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 |
| ------------------ | ---------- | ---- | ------ | ------------------- | -------------------------------- |
| 필수첨부파일아이디 | (Readonly) | Y | — | `esntlAtchFileId` | 등록 시 미노출 |
| 문서구분 | CodeSelect | Y | — | `docSeComCd` | 예: 설치인가, 기본계획 |
| 필수첨부파일명 | Text | Y | — | `esntlAtchFileNm` | placeholder: "예: 사업계획서" |
| 필수첨부여부 | RadioGroup | Y | Y | `esntlAtchYn` | Y/N, UI 표시: 예/아니오 |
| 비고 | Textarea | N | — | `esntlAtchFileRmrk` | placeholder: "비고를 입력하세요" |
### 6-2. 버튼
| 버튼명 | 동작 |
| ------ | -------------------------------------- |
| 저장 | POST(등록) 또는 PUT(수정) 후 목록 갱신 |
| 취소 | 다이얼로그 닫기, 입력값 폐기 |
| 닫기 | 다이얼로그 닫기 (헤더 우측) |
---
## 7. API/DTO 명세
### 7-1. 공통
| 항목 | 내용 |
| ----------- | ----------------------------------------------------- |
| 서비스 | AS Common Service |
| Base prefix | `/as-service/api/v1/esntl-atch-file` |
| 인증 | JWT (`Authorization`), `X-Site-Id` 등 |
| 페이징 | Spring `Pageable` 사용 (`page`, `size`, `sort`) |
| 엔티티 | `@/entities/esntl` (`esntl-service.ts`, `queries.ts`) |
### 7-2. 필수첨부파일 API
| 기능 | Method | URL | Request DTO | Response DTO | API (클라이언트 함수명) |
| --------- | ------ | -------------------- | ----------------------------- | ----------------------------------- | ----------------------- |
| 트리 조회 | GET | `/list/tree` | Query: `docSeComCd`(필수) | `EsntlAtchFileMngTreeResponseDto[]` | `findTree` |
| 목록 조회 | GET | `/` | Query: Search + Pageable | `PageResponse<EsntlAtchFileMng>` | `findPage` |
| 단건 조회 | GET | `/{esntlAtchFileId}` | Path: `esntlAtchFileId` | `EsntlAtchFileMng` | `findById` |
| 등록 | POST | `/` | `EsntlAtchFileMngSaveRequest` | `string` (생성된 ID) | `create` |
| 수정 | PUT | `/{esntlAtchFileId}` | `EsntlAtchFileMngSaveRequest` | `string` (수정된 ID) | `update` |
| 삭제 | DELETE | `/{esntlAtchFileId}` | Path: `esntlAtchFileId` | `string` (삭제된 ID) | `remove` |
**응답 DTO**
| Field | 타입 | 설명 |
| ------------------- | ------- | -------------------------------- |
| `esntlAtchFileId` | string | 필수첨부파일아이디 |
| `docSeComCd` | string | 문서구분공통코드 |
| `esntlAtchFileNm` | string | 필수첨부파일명 |
| `esntlAtchYn` | string | 필수첨부여부 (Y/N) |
| `esntlAtchFileRmrk` | string? | 필수첨부파일비고 |
| `rgtr` | string? | 등록자 UUID |
| `rgtrNm` | string? | 등록자명 |
| `mdfr` | string? | 수정자 UUID |
| `regDt` | string? | 등록일시 (`yyyy-MM-dd HH:mm:ss`) |
| `mdfcnDt` | string? | 수정일시 |
**저장 Request**
| Field | 필수 | 설명 |
| ------------------- | ---- | ------------------ |
| `esntlAtchFileId` | Y | 필수첨부파일아이디 |
| `docSeComCd` | N | 문서구분 |
| `esntlAtchFileNm` | N | 필수첨부파일명 |
| `esntlAtchYn` | N | 필수첨부여부 |
| `esntlAtchFileRmrk` | N | 비고 |
**검색 Query**
| Field | 필수 | 설명 |
| ----------------- | ---- | ----------------------------- |
| `docSeComCd` | N | 문서구분 (전체 시 미전송) |
| `esntlAtchFileNm` | N | 필수첨부파일명 부분/일치 검색 |
| `esntlAtchYn` | N | 필수첨부여부 (`Y`/`N`) |
| `page` | N | 페이지 번호 (0-base) |
| `size` | N | 페이지 크기 (기본값: 100) |
| `sort` | N | 정렬 조건 (예: `regDt,desc`) |
### 7-3. 공통코드
| UI 필드 | 코드 그룹 | 예시 값 |
| ------------ | --------- | ------------------ |
| 문서구분 | ASDOCSECD | 설치인가, 기본계획 |
| 필수첨부여부 | 고정 | Y(예), N(아니오) |
---
## 8. UI 구현 가이드
### 8-1. 기술 스택·원칙
- **공통 컴포넌트**: `@common/components` (`Dialog`, `Button`, `Input`, `Select`, `Textarea`, `Label` 등)
- **테이블**: `@tanstack/react-table` + `useDataTable`
- **폼**: `react-hook-form`
- **데이터**: React Query — `@/entities/esntl/api/queries.ts` 훅 사용
- **공통코드**: `@/entities/code` — `CodeSelect`, `findCodeLabel`
- **확인**: `@frontend-opensource/use-react-hooks` — `useConfirm` (등록·삭제 확인)
### 8-2. 모듈·파일 배치
| 구분 | 경로 |
| ------ | ---------------------------------------------------------------------------------------------------- |
| 라우트 | `apps/as/app/(with-base-layout)/esntl-atch-file-mng/page.tsx` |
| 페이지 | `apps/as/src/pages/esntl-atch-file-mng/` (Public API: page만 export) |
| UI | `.../ui/esntl-atch-file-mng-page.tsx`, `esntl-atch-file-list.tsx`, `esntl-atch-file-form-dialog.tsx` |
| model | `.../model/esntl-atch-file-columns.tsx`, `esntl-atch-file-search-params.ts` |
| 엔티티 | `apps/as/src/entities/esntl/` (`esntl-service.ts`, `queries.ts`, `esntl-interface.ts`) |
| 문서 | `apps/as/docs/essential-attachment-file-management/SPEC.md` |
### 8-3. 데이터 흐름(요약)
- **진입**: 검색 params 기본값(`docSeComCd` = 전체) → 목록 query → 영역 B DataTable.
- **조회**: [조회] 클릭 시 search state 반영 → 목록 query refetch.
- **등록**: [등록] → Dialog `mode=create` → [저장] → POST → 목록 invalidate.
- **수정**: [수정] → Dialog `mode=edit` + row 초기값 → [저장] → PUT → 목록 invalidate.
- **삭제**: confirm → DELETE → 목록 invalidate.
@@ -1,14 +1,18 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { PageResponse } from "@/shared/model";
import type {
EsntlAtchFileMng,
EsntlAtchFileMngSaveRequest,
EsntlAtchFileMngSearchParams,
EsntlAtchFileMngService,
EsntlAtchFileMngTreeResponseDto,
EsntlAtchFileMngTreeSearchParams,
} from "../model/esntl-interface";
async function findTree(
params: EsntlAtchFileMngSearchParams,
params: EsntlAtchFileMngTreeSearchParams,
): Promise<EsntlAtchFileMngTreeResponseDto[]> {
const response = await client.get<EsntlAtchFileMngTreeResponseDto[]>(
`${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/list/tree`,
@@ -17,6 +21,48 @@ async function findTree(
return response.data;
}
async function findPage(
params: EsntlAtchFileMngSearchParams,
): Promise<PageResponse<EsntlAtchFileMng>> {
const response = await client.get<PageResponse<EsntlAtchFileMng>>(
ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE,
{ params },
);
return response.data;
}
async function findById(esntlAtchFileId: string): Promise<EsntlAtchFileMng> {
const response = await client.get<EsntlAtchFileMng>(
`${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/${esntlAtchFileId}`,
);
return response.data;
}
async function create(body: EsntlAtchFileMngSaveRequest): Promise<string> {
const response = await client.post<string>(ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE, body);
return response.data;
}
async function update(esntlAtchFileId: string, body: EsntlAtchFileMngSaveRequest): Promise<string> {
const response = await client.put<string>(
`${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/${esntlAtchFileId}`,
body,
);
return response.data;
}
async function remove(esntlAtchFileId: string): Promise<string> {
const response = await client.delete<string>(
`${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/${esntlAtchFileId}`,
);
return response.data;
}
export const esntlAtchFileMngService = {
findTree,
findPage,
findById,
create,
update,
remove,
} satisfies EsntlAtchFileMngService;
+79
View File
@@ -0,0 +1,79 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ENDPOINT } from "@/shared/config";
import type {
EsntlAtchFileMngSaveRequest,
EsntlAtchFileMngSearchParams,
EsntlAtchFileMngTreeSearchParams,
} from "../model/esntl-interface";
import { esntlAtchFileMngService } from "./esntl-service";
const esntlAtchFileMngBaseKey = [ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE] as const;
export function useEsntlAtchFileMngTree(params: EsntlAtchFileMngTreeSearchParams) {
return useQuery({
queryKey: [...esntlAtchFileMngBaseKey, "tree", params],
queryFn: () => esntlAtchFileMngService.findTree(params),
enabled: Boolean(params.docSeComCd),
staleTime: Infinity,
});
}
export function useEsntlAtchFileMngPage(params: EsntlAtchFileMngSearchParams) {
return useQuery({
queryKey: [...esntlAtchFileMngBaseKey, "page", params],
queryFn: () => esntlAtchFileMngService.findPage(params),
});
}
export function useEsntlAtchFileMng(esntlAtchFileId: string | null | undefined) {
return useQuery({
queryKey: [...esntlAtchFileMngBaseKey, esntlAtchFileId],
queryFn: () => esntlAtchFileMngService.findById(esntlAtchFileId!),
enabled: Boolean(esntlAtchFileId),
});
}
export function useCreateEsntlAtchFileMng() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (body: EsntlAtchFileMngSaveRequest) => esntlAtchFileMngService.create(body),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: esntlAtchFileMngBaseKey });
},
});
}
export function useUpdateEsntlAtchFileMng() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
esntlAtchFileId,
body,
}: {
esntlAtchFileId: string;
body: EsntlAtchFileMngSaveRequest;
}) => esntlAtchFileMngService.update(esntlAtchFileId, body),
onSuccess: (_data, { esntlAtchFileId }) => {
queryClient.invalidateQueries({ queryKey: esntlAtchFileMngBaseKey });
queryClient.invalidateQueries({
queryKey: [...esntlAtchFileMngBaseKey, esntlAtchFileId],
});
},
});
}
export function useDeleteEsntlAtchFileMng() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (esntlAtchFileId: string) => esntlAtchFileMngService.remove(esntlAtchFileId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: esntlAtchFileMngBaseKey });
},
});
}
+12 -1
View File
@@ -1,6 +1,17 @@
export { esntlAtchFileMngService } from "./api/esntl-service";
export {
useCreateEsntlAtchFileMng,
useDeleteEsntlAtchFileMng,
useEsntlAtchFileMng,
useEsntlAtchFileMngPage,
useEsntlAtchFileMngTree,
useUpdateEsntlAtchFileMng,
} from "./api/queries";
export type {
EsntlAtchFileMng,
EsntlAtchFileMngSaveRequest,
EsntlAtchFileMngSearchParams,
EsntlAtchFileMngService,
EsntlAtchFileMngTreeResponseDto,
EsntlAtchFileMngTreeSearchParams,
} from "./model/esntl-interface";
export { esntlAtchFileMngService } from "./api/esntl-service";
@@ -1,3 +1,5 @@
import type { PageableParams, PageResponse } from "@/shared/model";
/** 필수첨부파일관리 트리 조회 응답 DTO */
export interface EsntlAtchFileMngTreeResponseDto {
/** 필수첨부파일아이디 */
@@ -12,14 +14,72 @@ export interface EsntlAtchFileMngTreeResponseDto {
esntlAtchFileRmrk: string;
}
/** 필수첨부파일관리 조회 파라미터 */
export interface EsntlAtchFileMngSearchParams {
/** 필수첨부파일관리 엔티티 (목록/상세 공용 응답 DTO) */
export interface EsntlAtchFileMng {
/** 필수첨부파일아이디 */
esntlAtchFileId: string;
/** 문서구분공통코드 */
docSeComCd: string;
/** 필수첨부파일명 */
esntlAtchFileNm: string;
/** 필수첨부여부 ("Y" | "N") */
esntlAtchYn: "Y" | "N";
/** 필수첨부파일비고 */
esntlAtchFileRmrk?: string;
/** 등록자UUID */
rgtr?: string;
/** 등록자명 */
rgtrNm?: string;
/** 수정자UUID */
mdfr?: string;
/** 등록일시 (yyyy-MM-dd HH:mm:ss) */
regDt?: string;
/** 수정일시 */
mdfcnDt?: string;
}
/** 필수첨부파일관리 트리 조회 파라미터 */
export interface EsntlAtchFileMngTreeSearchParams {
/** 문서구분공통코드 (예: "INSTL") */
docSeComCd: string;
}
/** 필수첨부파일관리 목록 조회 파라미터 (페이징 포함) */
export interface EsntlAtchFileMngSearchParams extends PageableParams {
/** 문서구분공통코드 (예: "INSTL") */
docSeComCd?: string;
/** 필수첨부파일명 */
esntlAtchFileNm?: string;
/** 필수첨부여부 ("Y" | "N") */
esntlAtchYn?: "Y" | "N";
}
/** 필수첨부파일관리 서비스 인터페이스 */
export interface EsntlAtchFileMngService {
/** 필수첨부파일 트리 조회 */
findTree(params: EsntlAtchFileMngSearchParams): Promise<EsntlAtchFileMngTreeResponseDto[]>;
findTree(params: EsntlAtchFileMngTreeSearchParams): Promise<EsntlAtchFileMngTreeResponseDto[]>;
/** 필수첨부파일관리 목록 조회 (페이징) */
findPage(params: EsntlAtchFileMngSearchParams): Promise<PageResponse<EsntlAtchFileMng>>;
/** 필수첨부파일관리 상세 조회 */
findById(esntlAtchFileId: string): Promise<EsntlAtchFileMng>;
/** 필수첨부파일관리 등록 */
create(body: EsntlAtchFileMngSaveRequest): Promise<string>;
/** 필수첨부파일관리 수정 */
update(esntlAtchFileId: string, body: EsntlAtchFileMngSaveRequest): Promise<string>;
/** 필수첨부파일관리 삭제 */
remove(esntlAtchFileId: string): Promise<string>;
}
/** 필수첨부파일관리 저장 요청 DTO */
export interface EsntlAtchFileMngSaveRequest {
/** 필수첨부파일아이디 */
esntlAtchFileId: string;
/** 문서구분공통코드 */
docSeComCd?: string;
/** 필수첨부파일명 */
esntlAtchFileNm?: string;
/** 필수첨부여부 ("Y" | "N") */
esntlAtchYn?: "Y" | "N";
/** 필수첨부파일비고 */
esntlAtchFileRmrk?: string;
}
@@ -0,0 +1 @@
export { EsntlAtchFileMngPage } from "./ui/esntl-atch-file-mng-page";
@@ -0,0 +1,81 @@
"use client";
import { createColumnHelper } from "@tanstack/react-table";
import type { Code } from "@/entities/code";
import { findCodeLabel } from "@/entities/code";
import type { EsntlAtchFileMng } from "@/entities/esntl";
import { Button } from "@common/components";
const columnHelper = createColumnHelper<EsntlAtchFileMng>();
interface CreateEsntlAtchFileColumnsOptions {
docSeCodes: Code[] | undefined;
onEdit: (row: EsntlAtchFileMng) => void;
onDelete: (row: EsntlAtchFileMng) => void;
}
function formatYn(value: string | undefined) {
if (value === "Y") return "예";
if (value === "N") return "아니오";
return "—";
}
function formatOptional(value: string | undefined) {
return value?.trim() ? value : "—";
}
export function createEsntlAtchFileColumns({
docSeCodes,
onEdit,
onDelete,
}: CreateEsntlAtchFileColumnsOptions) {
return [
columnHelper.accessor("esntlAtchFileId", {
header: "필수첨부파일아이디",
meta: { align: "center" },
}),
columnHelper.accessor("docSeComCd", {
header: "문서구분",
cell: ({ getValue }) => findCodeLabel(docSeCodes, getValue()) || getValue() || "—",
}),
columnHelper.accessor("esntlAtchFileNm", {
header: "필수첨부파일명",
meta: { align: "left" },
}),
columnHelper.accessor("esntlAtchYn", {
header: "필수첨부여부",
meta: { align: "center" },
cell: ({ getValue }) => formatYn(getValue()),
}),
columnHelper.accessor("esntlAtchFileRmrk", {
header: "비고",
meta: { align: "left" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("regDt", {
header: "등록일시",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.accessor("mdfcnDt", {
header: "수정일시",
meta: { align: "center" },
cell: ({ getValue }) => formatOptional(getValue()),
}),
columnHelper.display({
id: "actions",
header: "관리",
cell: ({ row }) => (
<div className="flex justify-center gap-2" onClick={(e) => e.stopPropagation()}>
<Button type="button" size="sm" onClick={() => onEdit(row.original)}>
수정
</Button>
<Button type="button" size="sm" color="red" onClick={() => onDelete(row.original)}>
삭제
</Button>
</div>
),
}),
];
}
@@ -0,0 +1,30 @@
import type { EsntlAtchFileMngSearchParams } from "@/entities/esntl";
export const ESNTL_ATCH_FILE_ALL_DOC_SE = "";
export const DEFAULT_ESNTL_ATCH_FILE_PAGE_SIZE = 100;
export const DEFAULT_ESNTL_ATCH_FILE_SORT = "regDt,desc";
export function createDefaultEsntlAtchFileSearchParams(): EsntlAtchFileMngSearchParams {
return {
page: 0,
size: DEFAULT_ESNTL_ATCH_FILE_PAGE_SIZE,
sort: DEFAULT_ESNTL_ATCH_FILE_SORT,
};
}
export function toEsntlAtchFileQueryParams(
params: EsntlAtchFileMngSearchParams,
): EsntlAtchFileMngSearchParams {
const { docSeComCd, esntlAtchFileNm, esntlAtchYn, page, size, sort } = params;
return {
...(docSeComCd ? { docSeComCd } : {}),
...(esntlAtchFileNm ? { esntlAtchFileNm } : {}),
...(esntlAtchYn ? { esntlAtchYn } : {}),
page: page ?? 0,
size: size ?? DEFAULT_ESNTL_ATCH_FILE_PAGE_SIZE,
sort: sort ?? DEFAULT_ESNTL_ATCH_FILE_SORT,
};
}
@@ -0,0 +1,12 @@
const EAF_ID_PREFIX = "EAF-";
const EAF_ID_PATTERN = /^EAF-(\d+)$/;
export function generateEsntlAtchFileId(existingIds: string[]): string {
const maxSeq = existingIds.reduce((max, id) => {
const match = id.match(EAF_ID_PATTERN);
if (!match) return max;
return Math.max(max, Number(match[1]));
}, 0);
return `${EAF_ID_PREFIX}${String(maxSeq + 1).padStart(4, "0")}`;
}
@@ -0,0 +1,161 @@
"use client";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { CodeSelect } from "@/entities/code";
import type { EsntlAtchFileMng } from "@/entities/esntl";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
RadioGroup,
RadioGroupItem,
Textarea,
} from "@common/components";
export interface EsntlAtchFileFormValues {
esntlAtchFileId: string;
docSeComCd: string;
esntlAtchFileNm: string;
esntlAtchYn: "Y" | "N";
esntlAtchFileRmrk: string;
}
interface EsntlAtchFileFormDialogProps {
mode: "create" | "edit";
open: boolean;
onOpenChange: (open: boolean) => void;
initial?: EsntlAtchFileMng | null;
generatedId?: string;
isSubmitting?: boolean;
onSubmit: (values: EsntlAtchFileFormValues) => void;
}
function EsntlAtchFileFormDialog({
mode,
open,
onOpenChange,
initial,
generatedId,
isSubmitting,
onSubmit,
}: EsntlAtchFileFormDialogProps) {
const { register, handleSubmit, reset, setValue, watch } = useForm<EsntlAtchFileFormValues>({
defaultValues: {
esntlAtchFileId: "",
docSeComCd: "",
esntlAtchFileNm: "",
esntlAtchYn: "Y",
esntlAtchFileRmrk: "",
},
});
const docSeComCd = watch("docSeComCd");
const esntlAtchYn = watch("esntlAtchYn");
useEffect(() => {
if (!open) return;
if (mode === "edit" && initial) {
reset({
esntlAtchFileId: initial.esntlAtchFileId,
docSeComCd: initial.docSeComCd ?? "",
esntlAtchFileNm: initial.esntlAtchFileNm ?? "",
esntlAtchYn: initial.esntlAtchYn ?? "Y",
esntlAtchFileRmrk: initial.esntlAtchFileRmrk ?? "",
});
return;
}
reset({
esntlAtchFileId: generatedId ?? "",
docSeComCd: "",
esntlAtchFileNm: "",
esntlAtchYn: "Y",
esntlAtchFileRmrk: "",
});
}, [open, mode, initial, generatedId, reset]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[52rem]">
<DialogHeader>
<DialogTitle>{mode === "create" ? "필수첨부파일 등록" : "필수첨부파일 수정"}</DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit(onSubmit)}>
{mode === "edit" ? (
<div className="space-y-2">
<Label htmlFor="esntlAtchFileId">필수첨부파일아이디</Label>
<p className="p-2 text-xl">{watch("esntlAtchFileId")}</p>
</div>
) : (
<input type="hidden" {...register("esntlAtchFileId", { required: true })} />
)}
<div className="space-y-2">
<Label>문서구분</Label>
<CodeSelect
upCd="ASDOCSECD"
value={docSeComCd}
onValueChange={(v) => setValue("docSeComCd", v, { shouldValidate: true })}
placeholder="문서구분 선택"
className="w-full"
/>
</div>
<div className="space-y-2">
<Label htmlFor="esntlAtchFileNm">필수첨부파일명</Label>
<Input
id="esntlAtchFileNm"
placeholder="예: 사업계획서"
{...register("esntlAtchFileNm", { required: true })}
/>
</div>
<div className="space-y-2">
<Label>필수첨부여부</Label>
<RadioGroup
value={esntlAtchYn}
onValueChange={(v) =>
setValue("esntlAtchYn", v as "Y" | "N", { shouldValidate: true })
}
className="flex gap-6"
>
<div className="flex items-center gap-2">
<RadioGroupItem value="Y" id="esntlAtchYn-y" />
<Label htmlFor="esntlAtchYn-y">예</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="N" id="esntlAtchYn-n" />
<Label htmlFor="esntlAtchYn-n">아니오</Label>
</div>
</RadioGroup>
</div>
<div className="space-y-2">
<Label htmlFor="esntlAtchFileRmrk">비고</Label>
<Textarea
id="esntlAtchFileRmrk"
rows={3}
placeholder="비고를 입력하세요"
{...register("esntlAtchFileRmrk")}
className="!text-xl"
/>
</div>
<DialogFooter>
<Button type="button" color="white" onClick={() => onOpenChange(false)}>
취소
</Button>
<Button type="submit" disabled={isSubmitting}>
저장
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export { EsntlAtchFileFormDialog };
@@ -0,0 +1,84 @@
"use client";
import { useMemo } from "react";
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
import { useDataTable } from "@/shared/lib";
import { useCodeList } from "@/entities/code";
import {
type EsntlAtchFileMng,
type EsntlAtchFileMngSearchParams,
useEsntlAtchFileMngPage,
} from "@/entities/esntl";
import { createEsntlAtchFileColumns } from "../model/esntl-atch-file-columns";
import { toEsntlAtchFileQueryParams } from "../model/esntl-atch-file-search-params";
interface EsntlAtchFileListProps {
searchParams: EsntlAtchFileMngSearchParams;
pagination: PaginationState;
onPaginationChange: (pagination: PaginationState) => void;
onEdit: (row: EsntlAtchFileMng) => void;
onDelete: (row: EsntlAtchFileMng) => void;
}
function EsntlAtchFileList({
searchParams,
pagination,
onPaginationChange,
onEdit,
onDelete,
}: EsntlAtchFileListProps) {
const { data: docSeCodes } = useCodeList("ASDOCSECD");
const queryParams = useMemo(
() =>
toEsntlAtchFileQueryParams({
...searchParams,
page: pagination.pageIndex,
size: pagination.pageSize,
}),
[pagination.pageIndex, pagination.pageSize, searchParams],
);
const pageQuery = useEsntlAtchFileMngPage(queryParams);
const columns = useMemo(
() =>
createEsntlAtchFileColumns({
docSeCodes,
onEdit,
onDelete,
}),
[docSeCodes, onDelete, onEdit],
);
const { tableComponent } = useDataTable({
query: pageQuery,
tableOptions: {
columns,
state: { pagination },
onPaginationChange: (updater) => {
const next = typeof updater === "function" ? updater(pagination) : updater;
onPaginationChange(next);
},
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
},
dataTableOptions: {
viewPagination: true,
},
});
return (
<section className="card card-border mx-0 bg-background pt-0">
<div className="card-header">
<h3 className="text-2xl font-semibold">필수첨부파일 목록</h3>
</div>
{tableComponent}
</section>
);
}
export { EsntlAtchFileList };
@@ -0,0 +1,190 @@
"use client";
import { useCallback, useState } from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import type { PaginationState } from "@tanstack/react-table";
import { useCodeList } from "@/entities/code";
import {
type EsntlAtchFileMng,
type EsntlAtchFileMngSearchParams,
esntlAtchFileMngService,
useCreateEsntlAtchFileMng,
useDeleteEsntlAtchFileMng,
useUpdateEsntlAtchFileMng,
} from "@/entities/esntl";
import {
Button,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@common/components";
import {
createDefaultEsntlAtchFileSearchParams,
DEFAULT_ESNTL_ATCH_FILE_PAGE_SIZE,
ESNTL_ATCH_FILE_ALL_DOC_SE,
} from "../model/esntl-atch-file-search-params";
import { generateEsntlAtchFileId } from "../model/generate-esntl-atch-file-id";
import {
EsntlAtchFileFormDialog,
type EsntlAtchFileFormValues,
} from "./esntl-atch-file-form-dialog";
import { EsntlAtchFileList } from "./esntl-atch-file-list";
const ALL_DOC_SE_VALUE = "__ALL__";
function EsntlAtchFileMngPage() {
const { confirm } = useConfirm();
const { data: docSeCodes, isPending: isDocSeLoading } = useCodeList("ASDOCSECD");
const [draftDocSeComCd, setDraftDocSeComCd] = useState(ESNTL_ATCH_FILE_ALL_DOC_SE);
const [searchParams, setSearchParams] = useState<EsntlAtchFileMngSearchParams>(
createDefaultEsntlAtchFileSearchParams(),
);
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: DEFAULT_ESNTL_ATCH_FILE_PAGE_SIZE,
});
const [dialogMode, setDialogMode] = useState<"create" | "edit" | null>(null);
const [editingRow, setEditingRow] = useState<EsntlAtchFileMng | null>(null);
const [generatedId, setGeneratedId] = useState("");
const createMutation = useCreateEsntlAtchFileMng();
const updateMutation = useUpdateEsntlAtchFileMng();
const deleteMutation = useDeleteEsntlAtchFileMng();
const handleSearch = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
setSearchParams({
...createDefaultEsntlAtchFileSearchParams(),
...(draftDocSeComCd ? { docSeComCd: draftDocSeComCd } : {}),
});
};
const handleOpenCreate = async () => {
const page = await esntlAtchFileMngService.findPage({
page: 0,
size: DEFAULT_ESNTL_ATCH_FILE_PAGE_SIZE,
sort: "esntlAtchFileId,desc",
});
const existingIds = page.content.map((row) => row.esntlAtchFileId);
setGeneratedId(generateEsntlAtchFileId(existingIds));
setEditingRow(null);
setDialogMode("create");
};
const handleEdit = useCallback((row: EsntlAtchFileMng) => {
setEditingRow(row);
setDialogMode("edit");
}, []);
const handleDelete = useCallback(
async (row: EsntlAtchFileMng) => {
if (!(await confirm("삭제하시겠습니까?"))) return;
await deleteMutation.mutateAsync(row.esntlAtchFileId);
},
[confirm, deleteMutation],
);
const handleFormSubmit = async (values: EsntlAtchFileFormValues) => {
if (!values.docSeComCd || !values.esntlAtchFileNm || !values.esntlAtchYn) return;
const body = {
esntlAtchFileId: values.esntlAtchFileId,
docSeComCd: values.docSeComCd,
esntlAtchFileNm: values.esntlAtchFileNm,
esntlAtchYn: values.esntlAtchYn,
esntlAtchFileRmrk: values.esntlAtchFileRmrk || undefined,
};
if (dialogMode === "create") {
if (!(await confirm("등록하시겠습니까?"))) return;
await createMutation.mutateAsync(body);
alert("저장되었습니다.");
} else if (dialogMode === "edit" && editingRow) {
await updateMutation.mutateAsync({
esntlAtchFileId: editingRow.esntlAtchFileId,
body,
});
}
setDialogMode(null);
setEditingRow(null);
setGeneratedId("");
};
const closeDialog = () => {
setDialogMode(null);
setEditingRow(null);
setGeneratedId("");
};
return (
<div className="space-y-6 px-[1.8rem] pb-6">
<section className="card card-border mx-0 bg-background">
<form
className="flex flex-wrap items-end justify-between gap-4 p-4"
onSubmit={handleSearch}
>
<div className="flex flex-wrap items-center gap-3">
<Label htmlFor="docSeComCd">문서구분</Label>
<Select
value={draftDocSeComCd || ALL_DOC_SE_VALUE}
onValueChange={(value) =>
setDraftDocSeComCd(value === ALL_DOC_SE_VALUE ? ESNTL_ATCH_FILE_ALL_DOC_SE : value)
}
disabled={isDocSeLoading}
>
<SelectTrigger id="docSeComCd" className="w-[20rem]">
<SelectValue placeholder={isDocSeLoading ? "로딩 중…" : "전체"} />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_DOC_SE_VALUE}>전체</SelectItem>
{docSeCodes?.map((item) => (
<SelectItem key={item.lwpoCd} value={item.lwpoCd}>
{item.lwpoCdNm}
</SelectItem>
))}
</SelectContent>
</Select>
<Button type="submit">조회</Button>
</div>
<Button type="button" onClick={handleOpenCreate}>
등록
</Button>
</form>
</section>
<EsntlAtchFileList
searchParams={searchParams}
pagination={pagination}
onPaginationChange={setPagination}
onEdit={handleEdit}
onDelete={handleDelete}
/>
{dialogMode ? (
<EsntlAtchFileFormDialog
mode={dialogMode}
open={Boolean(dialogMode)}
onOpenChange={(open) => {
if (!open) closeDialog();
}}
initial={dialogMode === "edit" ? editingRow : null}
generatedId={generatedId}
isSubmitting={createMutation.isPending || updateMutation.isPending}
onSubmit={handleFormSubmit}
/>
) : null}
</div>
);
}
export { EsntlAtchFileMngPage };
@@ -15,10 +15,7 @@ import type { CklstTreeNode } from "@/entities/cklst";
import { cklstMngService } from "@/entities/cklst";
import type { CnstpSeMngTreeDto } from "@/entities/cnstp";
import { cnstpService } from "@/entities/cnstp";
import {
esntlAtchFileMngService,
type EsntlAtchFileMngTreeResponseDto,
} from "@/entities/esntl";
import { useEsntlAtchFileMngTree } from "@/entities/esntl";
import {
INSTL_SNCN_REGISTER_SELECTION_KEY,
InstlSncnApi,
@@ -140,14 +137,13 @@ function CnfmRegistPage() {
const etcFileGroupIdRef = useRef("");
/* ── 필수 첨부파일 ── */
const { data: requiredAttachmentDefs = [] } = useQuery<EsntlAtchFileMngTreeResponseDto[]>({
queryKey: ["esntlAtchFileTree", docSeComCd],
queryFn: () => esntlAtchFileMngService.findTree({ docSeComCd }),
enabled: Boolean(docSeComCd),
staleTime: Infinity,
const { data: requiredAttachmentDefs = [] } = useEsntlAtchFileMngTree({
docSeComCd,
});
const [requiredFiles, setRequiredFiles] = useState<Record<RequiredAttachKey, RequiredAttachItem>>({});
const [requiredFiles, setRequiredFiles] = useState<Record<RequiredAttachKey, RequiredAttachItem>>(
{},
);
const requiredFileInputRefs = useRef<Record<RequiredAttachKey, HTMLInputElement | null>>({});
useEffect(() => {
@@ -231,7 +227,10 @@ function CnfmRegistPage() {
upload.start();
};
const handleRequiredFileChange = (key: RequiredAttachKey, e: React.ChangeEvent<HTMLInputElement>) => {
const handleRequiredFileChange = (
key: RequiredAttachKey,
e: React.ChangeEvent<HTMLInputElement>,
) => {
const file = e.target.files?.[0];
if (!file) return;
uploadRequiredFile(file, key);
@@ -275,7 +274,7 @@ function CnfmRegistPage() {
}
},
onSuccess() {
const ext = file.name.includes(".") ? file.name.split(".").pop() ?? "" : "";
const ext = file.name.includes(".") ? (file.name.split(".").pop() ?? "") : "";
resolve({
id: uploadedAtflId || `${Date.now()}_${Math.random()}`,
name: file.name,
@@ -375,8 +374,7 @@ function CnfmRegistPage() {
return ordered;
}, [cklstTree]);
const cklstKey = (row: CklstTreeNode) =>
`${row.cklstSn}_${row.cklstArtclSn}_${row.cklstRvwSn}`;
const cklstKey = (row: CklstTreeNode) => `${row.cklstSn}_${row.cklstArtclSn}_${row.cklstRvwSn}`;
useEffect(() => {
setCheckResults((prev) => {
@@ -423,13 +421,12 @@ function CnfmRegistPage() {
}, [cklstRows, checkResults]);
const toCklstSaveDtoList = () =>
cklstRows
.map((row) => ({
cklstSn: row.cklstSn,
cklstArtclSn: row.cklstArtclSn,
cklstRvwSn: row.cklstRvwSn,
selected: checkResults[cklstKey(row)] ?? null,
}));
cklstRows.map((row) => ({
cklstSn: row.cklstSn,
cklstArtclSn: row.cklstArtclSn,
cklstRvwSn: row.cklstRvwSn,
selected: checkResults[cklstKey(row)] ?? null,
}));
const toEsntlAtchFileSaveDtoList = () =>
Object.values(requiredFiles)
@@ -458,7 +455,9 @@ function CnfmRegistPage() {
const subtotalScale = (node: CnstpSeMngTreeDto, col: keyof BzScaleRow): number =>
cnstpTree
.filter((n) => cnstpLeafSet.has(n.cnstpSeCd) && n.treePath.split("|").includes(node.cnstpSeCd))
.filter(
(n) => cnstpLeafSet.has(n.cnstpSeCd) && n.treePath.split("|").includes(node.cnstpSeCd),
)
.reduce((sum, n) => sum + num((bzScaleMap[n.cnstpSeCd] ?? emptyScale())[col]), 0);
const scaleNodeVal = (node: CnstpSeMngTreeDto, col: keyof BzScaleRow): number =>
@@ -574,7 +573,6 @@ function CnfmRegistPage() {
/* ── 검증 ── */
function fnValidation(mode: "SAVE" | "PRESENTN") {
if (!bzNm.trim()) {
alert("사업명을 입력하세요.");
return false;
@@ -591,16 +589,13 @@ function CnfmRegistPage() {
alert("개인정보 제공에 동의하셔야 제출이 가능합니다.");
return false;
}
// 체크리스트 사용 항목(Y) 중 미선택 항목이 있으면 진행 불가
const hasUncheckedChecklist = cklstRows.some(
(row) => !checkResults[cklstKey(row)],
);
const hasUncheckedChecklist = cklstRows.some((row) => !checkResults[cklstKey(row)]);
if (hasUncheckedChecklist) {
alert("체크리스트를 모두 체크하세요.");
return false;
}
}
// 저장 시: 필수 첨부파일(esntlAtchYn=Y) 업로드 여부 검증
if (mode === "SAVE") {
const missingRequiredFile = requiredAttachmentDefs.find((item) => {
@@ -692,7 +687,7 @@ function CnfmRegistPage() {
function handleSubmit() {
if (!fnValidation("PRESENTN")) return;
const payload = {
instlSncnMng: toInstlSncnMngPayload("ST"),
instlSncnMng: toInstlSncnMngPayload("ST"),
bzScaleList: toBzScaleSaveDtoList(),
bizCstList: toBizCstSaveDtoList(),
cklstRvwRsltList: toCklstSaveDtoList(),
@@ -1107,7 +1102,8 @@ function CnfmRegistPage() {
<div className="mb-3 flex items-center justify-between">
<h3 className="text-[1.5rem] font-bold text-gray-700">체크리스트</h3>
<button className="cnfm-btn-search text-[1.2rem]">
{(cklstRows[0]?.cklstNm ?? taskSeComCdNm) + `(${cklstStats.completed}/${cklstStats.total})`}
{(cklstRows[0]?.cklstNm ?? taskSeComCdNm) +
`(${cklstStats.completed}/${cklstStats.total})`}
</button>
</div>
<div className="mb-2 text-[1.25rem] text-gray-500">
@@ -1173,7 +1169,10 @@ function CnfmRegistPage() {
}}
>
<td className="text-center text-[1.2rem]">{row.cklstArtclNm}</td>
<td className="!text-left pl-2 text-[1.2rem] text-gray-600" style={{ paddingLeft: rvwPaddingLeft }}>
<td
className="pl-2 !text-left text-[1.2rem] text-gray-600"
style={{ paddingLeft: rvwPaddingLeft }}
>
{row.treeLevel > 1 ? "└ " : ""}
{row.rvwCn}
</td>
@@ -1238,7 +1237,9 @@ function CnfmRegistPage() {
<td className="pl-2">
<div className="w-full text-left">
{item.esntlAtchFileNm}
{item.esntlAtchYn === "Y" ? <span className="ml-1 text-red-500">*</span> : null}
{item.esntlAtchYn === "Y" ? (
<span className="ml-1 text-red-500">*</span>
) : null}
</div>
</td>
<td>{fileFormat}</td>
@@ -1248,7 +1249,11 @@ function CnfmRegistPage() {
type="text"
placeholder="첨부문서를 올려주세요"
className="cnfm-input flex-1"
value={requiredFile?.uploading ? "업로드 중..." : requiredFile?.fileName || ""}
value={
requiredFile?.uploading
? "업로드 중..."
: requiredFile?.fileName || ""
}
readOnly
/>
<input