This commit is contained in:
@@ -0,0 +1 @@
|
||||
export { EsntlAtchFileMngPage as default } from "@/pages/esntl-atch-file-mng";
|
||||
@@ -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.
|
||||
+24
-2
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
import { PORT, PROXY_HOST, SERVER_API_URL, SITE_ID } from "@/shared/config";
|
||||
import { AUTH_USER_ID, CLAIM_NAME, PORT, PROXY_HOST, SERVER_API_URL, SITE_ID } from "@/shared/config";
|
||||
|
||||
const serverApiUrl = process.env.SERVER_API_URL;
|
||||
|
||||
@@ -34,6 +34,28 @@ export function middleware(req: NextRequest) {
|
||||
return addSecurityHeaders(response);
|
||||
}
|
||||
|
||||
|
||||
// tus-js-client PATCH 요청: Location 헤더로 /server 없이 반환된 경로 직접 처리
|
||||
// (예: /attach-service/api/v1/tus/upload/{id})
|
||||
if (pathname.startsWith("/attach-service")) {
|
||||
const requestHeaders = new Headers(req.headers);
|
||||
const token = req.cookies.get(CLAIM_NAME)?.value;
|
||||
const userId = req.cookies.get(AUTH_USER_ID)?.value;
|
||||
const newUrl = `${serverApiUrl}${pathname}`;
|
||||
|
||||
if (token) requestHeaders.set(CLAIM_NAME, token);
|
||||
if (userId) requestHeaders.set(AUTH_USER_ID, userId);
|
||||
|
||||
logMessages.push(` [TUS Proxy] Rewritten URL: ${newUrl}`);
|
||||
logMessages.push("\n--------------------------------\n");
|
||||
console.log(logMessages.join("\n"));
|
||||
|
||||
const response = NextResponse.rewrite(new URL(newUrl), {
|
||||
headers: requestHeaders,
|
||||
});
|
||||
return addSecurityHeaders(response);
|
||||
}
|
||||
|
||||
// if (pathname === "/") {
|
||||
// const response = NextResponse.redirect(new URL("/dashboard", req.url));
|
||||
// return addSecurityHeaders(response);
|
||||
@@ -47,5 +69,5 @@ export function middleware(req: NextRequest) {
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/server/:path*", "/"],
|
||||
matcher: ["/server/:path*", "/attach-service/:path*", "/"],
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@common/assets": "workspace:*",
|
||||
"@common/auth": "workspace:*",
|
||||
"@common/components": "workspace:*",
|
||||
"@common/network": "workspace:*",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { AcntgSeMngTreeDto, AcntgService } from "../model/acntg-interface";
|
||||
|
||||
async function findTree(): Promise<AcntgSeMngTreeDto[]> {
|
||||
const response = await client.get<AcntgSeMngTreeDto[]>(
|
||||
`${ENDPOINT.AS_COMMON_SERVICE.ACNTG_SE}/tree`,
|
||||
`${ENDPOINT.AS_COMMON_SERVICE.ACNTG_SE}/list/tree`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { ACCESS_TOKEN, AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config";
|
||||
import { getRemainingMs } from "@common/auth";
|
||||
|
||||
import { clearAuthToken, setToken, setupRefreshTimer } from "../lib/token-manager";
|
||||
import type { AuthPayload, AuthService } from "../model/auth-interface";
|
||||
|
||||
async function login(payload: AuthPayload): Promise<void> {
|
||||
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const accessToken = data[ACCESS_TOKEN];
|
||||
const tokenId = data[AUTH_USER_ID];
|
||||
|
||||
if (!accessToken || !tokenId) {
|
||||
throw new Error("인증 토큰을 받지 못했습니다.");
|
||||
}
|
||||
|
||||
setToken(CLAIM_NAME, accessToken);
|
||||
setToken(AUTH_USER_ID, tokenId);
|
||||
|
||||
const remainingMs = getRemainingMs(accessToken);
|
||||
if (remainingMs > 0) {
|
||||
setupRefreshTimer(silentRefresh, remainingMs);
|
||||
}
|
||||
}
|
||||
|
||||
async function silentRefresh(): Promise<void> {
|
||||
console.log(`[silentRefresh] Called at ${new Date().toISOString()}`);
|
||||
try {
|
||||
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`, {
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data?.message ?? "토큰 갱신에 실패했습니다.");
|
||||
}
|
||||
|
||||
const accessToken = data[ACCESS_TOKEN];
|
||||
const tokenId = data[AUTH_USER_ID];
|
||||
|
||||
if (!accessToken || !tokenId) {
|
||||
throw new Error("갱신된 인증 토큰을 받지 못했습니다.");
|
||||
}
|
||||
|
||||
setToken(CLAIM_NAME, accessToken);
|
||||
setToken(AUTH_USER_ID, tokenId);
|
||||
|
||||
const remainingMs = getRemainingMs(accessToken);
|
||||
if (remainingMs > 0) {
|
||||
setupRefreshTimer(silentRefresh, remainingMs);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[silentRefresh] Failed at ${new Date().toISOString()}`, error);
|
||||
clearAuthToken();
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("auth:logout"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function logout(): Promise<void> {
|
||||
clearAuthToken();
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent("auth:logout"));
|
||||
}
|
||||
}
|
||||
|
||||
export const authService: AuthService = {
|
||||
login,
|
||||
silentRefresh,
|
||||
logout,
|
||||
};
|
||||
@@ -1,92 +0,0 @@
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
import {
|
||||
ACCESS_TOKEN,
|
||||
AUTH_USER_ID,
|
||||
CLAIM_NAME,
|
||||
ENDPOINT,
|
||||
JWT_EXPIRATION_TIME,
|
||||
} from "@/shared/config";
|
||||
|
||||
import { tokenManager } from "../lib/token-manager";
|
||||
import type { ILogin, LoginService } from "../model/login-interface";
|
||||
|
||||
export class LoginApiService implements LoginService {
|
||||
private static instance: LoginApiService;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): LoginApiService {
|
||||
if (!LoginApiService.instance) {
|
||||
LoginApiService.instance = new LoginApiService();
|
||||
}
|
||||
return LoginApiService.instance;
|
||||
}
|
||||
|
||||
public async login(payload: ILogin): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.LOGIN}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
userId: payload.userId,
|
||||
password: payload.password,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
console.log("data ", data);
|
||||
const accessToken = data[ACCESS_TOKEN];
|
||||
const tokenId = data[AUTH_USER_ID];
|
||||
|
||||
if (!accessToken || !tokenId) {
|
||||
if (data.message == "Invalid Credentials") {
|
||||
throw new Error("아이디와 비밀번호가 일치하지 않습니다.");
|
||||
} else if (data.message == "User not found") {
|
||||
throw new Error("존재하지 않는 아이디입니다.");
|
||||
} else if (data.message == "User is not active") {
|
||||
throw new Error("활성화되지 않은 계정입니다.");
|
||||
} else if (data.message == "User is not verified") {
|
||||
throw new Error("이메일 인증이 필요합니다.");
|
||||
} else {
|
||||
throw new Error(data.message);
|
||||
}
|
||||
}
|
||||
|
||||
tokenManager.setToken(CLAIM_NAME, accessToken);
|
||||
tokenManager.setToken(AUTH_USER_ID, tokenId);
|
||||
tokenManager.setupRefreshTimer(() => this.silentRefresh(), JWT_EXPIRATION_TIME);
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
throw new Error(`Login 실패 ${error.response?.data?.message}`);
|
||||
}
|
||||
throw new Error(`Login 실패 ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
public async silentRefresh(): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`/api/proxy${ENDPOINT.USER_SERVICE.SILENT_REFRESH}`, {
|
||||
method: "PUT",
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
const accessToken = data[ACCESS_TOKEN];
|
||||
const tokenId = data[AUTH_USER_ID];
|
||||
|
||||
if (!accessToken || !tokenId) {
|
||||
throw new Error("Missing required authentication tokens");
|
||||
}
|
||||
|
||||
tokenManager.setToken(CLAIM_NAME, accessToken);
|
||||
tokenManager.setToken(AUTH_USER_ID, tokenId);
|
||||
} catch (error) {
|
||||
tokenManager.clearAuthToken();
|
||||
if (error instanceof AxiosError) {
|
||||
throw new Error(`Silent refresh failed: ${error.response?.data?.message}`);
|
||||
}
|
||||
throw new Error(`Silent refresh failed: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const LoginApi = LoginApiService.getInstance();
|
||||
@@ -1,2 +1,3 @@
|
||||
export { LoginApi } from "./api/login-service";
|
||||
export { authService } from "./api/auth-service";
|
||||
export { AuthWrapper } from "./lib/auth-wrapper";
|
||||
export { tokenManager } from "./lib/token-manager";
|
||||
|
||||
@@ -1,42 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { isAxiosError } from "axios";
|
||||
|
||||
import { client } from "@/shared/api";
|
||||
import { AUTH_LOGIN_REDIRECT_URL, AUTH_USER_ID, ENDPOINT } from "@/shared/config";
|
||||
import { getCookie, useUserStore } from "@/shared/lib";
|
||||
import { AUTH_LOGIN_REDIRECT_URL, AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config";
|
||||
import { useUserStore } from "@/shared/lib";
|
||||
import type { IUserInfo } from "@/shared/store";
|
||||
import { getRemainingMs } from "@common/auth";
|
||||
import { isAtLoginRedirectUrl } from "@common/network";
|
||||
|
||||
import { authService } from "../api/auth-service";
|
||||
|
||||
import { getToken, setupRefreshTimer } from "./token-manager";
|
||||
|
||||
const AuthWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
const { userInfo, setUserInfo } = useUserStore((state) => state);
|
||||
|
||||
async function authCheck() {
|
||||
if (isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = getCookie(AUTH_USER_ID);
|
||||
if (!userId) {
|
||||
throw new Error("Missing required authentication tokens");
|
||||
}
|
||||
|
||||
const response = await client.get<IUserInfo>(`${ENDPOINT.USER_SERVICE.USERS}/info/me`);
|
||||
if (!userInfo) setUserInfo(response.data);
|
||||
}
|
||||
const router = useRouter();
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
const setUserInfo = useUserStore((state) => state.setUserInfo);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
await authCheck();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setUserInfo(null);
|
||||
if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
|
||||
window.location.assign(AUTH_LOGIN_REDIRECT_URL);
|
||||
}
|
||||
const token = getToken(CLAIM_NAME);
|
||||
if (token) {
|
||||
const remainingMs = getRemainingMs(token);
|
||||
if (remainingMs > 0) {
|
||||
console.log(`[AuthWrapper] Refresh timer set: ${remainingMs}ms (${remainingMs / 1000}s)`);
|
||||
setupRefreshTimer(authService.silentRefresh, remainingMs);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
if (isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) return;
|
||||
|
||||
async function authCheck() {
|
||||
const userId = getToken(AUTH_USER_ID);
|
||||
if (!userId) {
|
||||
router.replace(AUTH_LOGIN_REDIRECT_URL);
|
||||
return;
|
||||
}
|
||||
|
||||
if (userInfo) return;
|
||||
|
||||
const response = await client.get<IUserInfo>(`${ENDPOINT.USER_SERVICE.USERS}/info/me`);
|
||||
setUserInfo(response.data);
|
||||
}
|
||||
|
||||
authCheck().catch((error) => {
|
||||
if (isAxiosError(error) && error.response?.status === 401) return;
|
||||
console.error(error);
|
||||
setUserInfo(null);
|
||||
if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
|
||||
router.replace(AUTH_LOGIN_REDIRECT_URL);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handleAuthLogout() {
|
||||
setUserInfo(null);
|
||||
if (!isAtLoginRedirectUrl(AUTH_LOGIN_REDIRECT_URL)) {
|
||||
router.replace(AUTH_LOGIN_REDIRECT_URL);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("auth:logout", handleAuthLogout);
|
||||
return () => window.removeEventListener("auth:logout", handleAuthLogout);
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
|
||||
@@ -1,82 +1,15 @@
|
||||
import { AUTH_USER_ID, CLAIM_NAME } from "@/shared/config";
|
||||
import { getCookie, removeCookie,setCookie } from "@/shared/lib";
|
||||
import { authConfig } from "@/shared/config/auth";
|
||||
import { createTokenManager } from "@common/auth";
|
||||
|
||||
export class TokenManager {
|
||||
private static instance: TokenManager;
|
||||
private refreshTimer: NodeJS.Timeout | null = null;
|
||||
export const tokenManager = createTokenManager(authConfig);
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): TokenManager {
|
||||
if (!TokenManager.instance) {
|
||||
TokenManager.instance = new TokenManager();
|
||||
}
|
||||
return TokenManager.instance;
|
||||
}
|
||||
|
||||
public getToken(key: string): string | undefined {
|
||||
return getCookie(key);
|
||||
}
|
||||
|
||||
public setToken(key: string, value: string): void {
|
||||
const domain = this.getCookieDomain();
|
||||
|
||||
setCookie(key, value, {
|
||||
secure: window.location.protocol === "https:",
|
||||
sameSite: "lax",
|
||||
domain: domain,
|
||||
path: "/", // 모든 경로에서 접근 가능
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
private getCookieDomain(): string | undefined {
|
||||
const hostname = window.location.hostname;
|
||||
|
||||
// 개발 환경에서는 localhost 도메인 사용
|
||||
if (hostname === "localhost" || hostname === "127.0.0.1") {
|
||||
return undefined; // localhost에서는 도메인 설정하지 않음
|
||||
}
|
||||
|
||||
// 운영 환경에서는 서브도메인을 포함한 최상위 도메인 사용
|
||||
if (hostname.includes("hasuon.or.kr")) {
|
||||
return ".hasuon.or.kr";
|
||||
}
|
||||
|
||||
// 기타 환경에서는 현재 호스트명 사용
|
||||
return hostname;
|
||||
}
|
||||
|
||||
public removeToken(key: string): void {
|
||||
const domain = this.getCookieDomain();
|
||||
removeCookie(key, { domain, path: "/" });
|
||||
}
|
||||
|
||||
public setupRefreshTimer(
|
||||
callback: () => Promise<void>,
|
||||
expirationTime: number,
|
||||
): void {
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
}
|
||||
|
||||
this.refreshTimer = setTimeout(() => {
|
||||
callback().catch((error) => {
|
||||
console.error("Silent refresh failed:", error);
|
||||
this.clearAuthToken();
|
||||
});
|
||||
}, expirationTime - 60000);
|
||||
}
|
||||
|
||||
public clearAuthToken(): void {
|
||||
if (this.refreshTimer) {
|
||||
clearTimeout(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
}
|
||||
|
||||
this.removeToken(CLAIM_NAME);
|
||||
this.removeToken(AUTH_USER_ID);
|
||||
}
|
||||
}
|
||||
|
||||
export const tokenManager = TokenManager.getInstance();
|
||||
export const {
|
||||
getToken,
|
||||
setToken,
|
||||
removeToken,
|
||||
setupRefreshTimer,
|
||||
clearAuthToken,
|
||||
getAccessToken,
|
||||
getRefreshToken,
|
||||
getUserId,
|
||||
} = tokenManager;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface AuthPayload {
|
||||
userId: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface AuthService {
|
||||
login(payload: AuthPayload): Promise<void>;
|
||||
silentRefresh(): Promise<void>;
|
||||
logout(): Promise<void>;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export interface ILogin {
|
||||
userId: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginService {
|
||||
login(payload: ILogin): Promise<void>;
|
||||
silentRefresh(): Promise<void>;
|
||||
}
|
||||
@@ -24,7 +24,7 @@ async function findById(cklstSn: number): Promise<CklstMng> {
|
||||
}
|
||||
|
||||
async function findTree(params: CklstTreeSearchParams): Promise<CklstTreeNode[]> {
|
||||
const response = await client.get<CklstTreeNode[]>(`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST}/tree`, {
|
||||
const response = await client.get<CklstTreeNode[]>(`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST}/list/tree`, {
|
||||
params,
|
||||
});
|
||||
return response.data;
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { CnstpSeMngTreeDto, CnstpService } from "../model/cnstp-interface";
|
||||
|
||||
async function findTree(): Promise<CnstpSeMngTreeDto[]> {
|
||||
const response = await client.get<CnstpSeMngTreeDto[]>(
|
||||
`${ENDPOINT.AS_COMMON_SERVICE.CNSTP_SE}/tree`,
|
||||
`${ENDPOINT.AS_COMMON_SERVICE.CNSTP_SE}/list/tree`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
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: EsntlAtchFileMngTreeSearchParams,
|
||||
): Promise<EsntlAtchFileMngTreeResponseDto[]> {
|
||||
const response = await client.get<EsntlAtchFileMngTreeResponseDto[]>(
|
||||
`${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/list/tree`,
|
||||
{ params },
|
||||
);
|
||||
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;
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +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";
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { PageableParams, PageResponse } from "@/shared/model";
|
||||
|
||||
/** 필수첨부파일관리 트리 조회 응답 DTO */
|
||||
export interface EsntlAtchFileMngTreeResponseDto {
|
||||
/** 필수첨부파일아이디 */
|
||||
esntlAtchFileId: string;
|
||||
/** 문서구분공통코드 */
|
||||
docSeComCd: string;
|
||||
/** 필수첨부파일명 */
|
||||
esntlAtchFileNm: string;
|
||||
/** 필수첨부여부 ("Y" | "N") */
|
||||
esntlAtchYn: "Y" | "N";
|
||||
/** 필수첨부파일비고 (파일형식 등) */
|
||||
esntlAtchFileRmrk: string;
|
||||
}
|
||||
|
||||
/** 필수첨부파일관리 엔티티 (목록/상세 공용 응답 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: 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 {
|
||||
/** 문서구분공통코드 */
|
||||
docSeComCd: string;
|
||||
/** 필수첨부파일명 */
|
||||
esntlAtchFileNm?: string;
|
||||
/** 필수첨부여부 ("Y" | "N") */
|
||||
esntlAtchYn?: "Y" | "N";
|
||||
/** 필수첨부파일비고 */
|
||||
esntlAtchFileRmrk?: string;
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export interface InstlSncnMngSearchParams {
|
||||
|
||||
export interface InstlSncnMng {
|
||||
instlSncnDocNo: string;
|
||||
dongId: string;
|
||||
admdstCd: string;
|
||||
sidoNm?: string;
|
||||
sggNm?: string;
|
||||
chrgrNm?: string;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
import { LoginApi } from "@/entities/auth";
|
||||
import { authService } from "@/entities/auth";
|
||||
import { images } from "@common/assets";
|
||||
|
||||
import "./login.css";
|
||||
@@ -20,7 +20,7 @@ function LoginPage() {
|
||||
const password = formData.get("password") as string;
|
||||
|
||||
try {
|
||||
await LoginApi.login({ userId, password });
|
||||
await authService.login({ userId, password });
|
||||
window.location.href = "/";
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "로그인에 실패했습니다.";
|
||||
|
||||
@@ -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,155 @@
|
||||
"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 {
|
||||
docSeComCd: string;
|
||||
esntlAtchFileNm: string;
|
||||
esntlAtchYn: "Y" | "N";
|
||||
esntlAtchFileRmrk: string;
|
||||
}
|
||||
|
||||
interface EsntlAtchFileFormDialogProps {
|
||||
mode: "create" | "edit";
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
initial?: EsntlAtchFileMng | null;
|
||||
isSubmitting?: boolean;
|
||||
onSubmit: (values: EsntlAtchFileFormValues) => void;
|
||||
}
|
||||
|
||||
function EsntlAtchFileFormDialog({
|
||||
mode,
|
||||
open,
|
||||
onOpenChange,
|
||||
initial,
|
||||
isSubmitting,
|
||||
onSubmit,
|
||||
}: EsntlAtchFileFormDialogProps) {
|
||||
const { register, handleSubmit, reset, setValue, watch } = useForm<EsntlAtchFileFormValues>({
|
||||
defaultValues: {
|
||||
docSeComCd: "",
|
||||
esntlAtchFileNm: "",
|
||||
esntlAtchYn: "Y",
|
||||
esntlAtchFileRmrk: "",
|
||||
},
|
||||
});
|
||||
|
||||
const docSeComCd = watch("docSeComCd");
|
||||
const esntlAtchYn = watch("esntlAtchYn");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
if (mode === "edit" && initial) {
|
||||
reset({
|
||||
docSeComCd: initial.docSeComCd ?? "",
|
||||
esntlAtchFileNm: initial.esntlAtchFileNm ?? "",
|
||||
esntlAtchYn: initial.esntlAtchYn ?? "Y",
|
||||
esntlAtchFileRmrk: initial.esntlAtchFileRmrk ?? "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
reset({
|
||||
docSeComCd: "",
|
||||
esntlAtchFileNm: "",
|
||||
esntlAtchYn: "Y",
|
||||
esntlAtchFileRmrk: "",
|
||||
});
|
||||
}, [open, mode, initial, reset]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[52rem]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{mode === "create" ? "필수첨부파일 등록" : "필수첨부파일 수정"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="space-y-6" onSubmit={handleSubmit(onSubmit)}>
|
||||
{mode === "edit" && initial ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="esntlAtchFileId">필수첨부파일아이디</Label>
|
||||
<p id="esntlAtchFileId" className="p-1 text-[1.3rem]">
|
||||
{initial.esntlAtchFileId}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<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,154 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
|
||||
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
import { useCodeList } from "@/entities/code";
|
||||
import {
|
||||
type EsntlAtchFileMng,
|
||||
type EsntlAtchFileMngSearchParams,
|
||||
useCreateEsntlAtchFileMng,
|
||||
useDeleteEsntlAtchFileMng,
|
||||
useEsntlAtchFileMngPage,
|
||||
useUpdateEsntlAtchFileMng,
|
||||
} from "@/entities/esntl";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import { createEsntlAtchFileColumns } from "../model/esntl-atch-file-columns";
|
||||
|
||||
import {
|
||||
EsntlAtchFileFormDialog,
|
||||
type EsntlAtchFileFormValues,
|
||||
} from "./esntl-atch-file-form-dialog";
|
||||
|
||||
interface EsntlAtchFileListProps {
|
||||
searchParams: EsntlAtchFileMngSearchParams;
|
||||
pagination: PaginationState;
|
||||
onPaginationChange: (pagination: PaginationState) => void;
|
||||
}
|
||||
|
||||
function EsntlAtchFileList({
|
||||
searchParams,
|
||||
pagination,
|
||||
onPaginationChange,
|
||||
}: EsntlAtchFileListProps) {
|
||||
const { confirm } = useConfirm();
|
||||
const { data: docSeCodes } = useCodeList("ASDOCSECD");
|
||||
|
||||
const [dialogMode, setDialogMode] = useState<"create" | "edit" | null>(null);
|
||||
const [editingRow, setEditingRow] = useState<EsntlAtchFileMng | null>(null);
|
||||
|
||||
const createMutation = useCreateEsntlAtchFileMng();
|
||||
const updateMutation = useUpdateEsntlAtchFileMng();
|
||||
const deleteMutation = useDeleteEsntlAtchFileMng();
|
||||
|
||||
const queryParams = useMemo(() => {
|
||||
return {
|
||||
docSeComCd: searchParams.docSeComCd,
|
||||
page: pagination.pageIndex,
|
||||
size: pagination.pageSize,
|
||||
sort: searchParams.sort,
|
||||
};
|
||||
}, [searchParams, pagination]);
|
||||
|
||||
const pageQuery = useEsntlAtchFileMngPage(queryParams);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (row: EsntlAtchFileMng) => {
|
||||
if (!(await confirm("삭제하시겠습니까?"))) return;
|
||||
await deleteMutation.mutateAsync(row.esntlAtchFileId);
|
||||
},
|
||||
[confirm, deleteMutation],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
createEsntlAtchFileColumns({
|
||||
docSeCodes,
|
||||
onEdit: (row) => {
|
||||
setEditingRow(row);
|
||||
setDialogMode("edit");
|
||||
},
|
||||
onDelete: handleDelete,
|
||||
}),
|
||||
[docSeCodes, handleDelete],
|
||||
);
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
const handleFormSubmit = async (values: EsntlAtchFileFormValues) => {
|
||||
if (!values.docSeComCd || !values.esntlAtchFileNm || !values.esntlAtchYn) return;
|
||||
|
||||
const body = {
|
||||
docSeComCd: values.docSeComCd,
|
||||
esntlAtchFileNm: values.esntlAtchFileNm,
|
||||
esntlAtchYn: values.esntlAtchYn,
|
||||
esntlAtchFileRmrk: values.esntlAtchFileRmrk || undefined,
|
||||
};
|
||||
|
||||
if (dialogMode === "create") {
|
||||
if (!(await confirm("등록하시겠습니까?"))) return;
|
||||
await createMutation.mutateAsync(body);
|
||||
} else if (dialogMode === "edit" && editingRow) {
|
||||
await updateMutation.mutateAsync({
|
||||
esntlAtchFileId: editingRow.esntlAtchFileId,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
setDialogMode(null);
|
||||
setEditingRow(null);
|
||||
};
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
setEditingRow(null);
|
||||
setDialogMode("create");
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="card card-border mx-0 bg-background pt-0">
|
||||
<div className="card-header flex items-center justify-between">
|
||||
<h3 className="text-2xl font-semibold">필수첨부파일 목록</h3>
|
||||
<Button type="button" color="green" onClick={handleOpenCreate}>
|
||||
등록
|
||||
</Button>
|
||||
</div>
|
||||
{tableComponent}
|
||||
{dialogMode ? (
|
||||
<EsntlAtchFileFormDialog
|
||||
mode={dialogMode}
|
||||
open={Boolean(dialogMode)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDialogMode(null);
|
||||
setEditingRow(null);
|
||||
}
|
||||
}}
|
||||
initial={dialogMode === "edit" ? editingRow : null}
|
||||
isSubmitting={createMutation.isPending || updateMutation.isPending}
|
||||
onSubmit={handleFormSubmit}
|
||||
/>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export { EsntlAtchFileList };
|
||||
@@ -0,0 +1,93 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
|
||||
import { useCodeList } from "@/entities/code";
|
||||
import type { EsntlAtchFileMngSearchParams } from "@/entities/esntl";
|
||||
import {
|
||||
Button,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@common/components";
|
||||
|
||||
import { EsntlAtchFileList } from "./esntl-atch-file-list";
|
||||
|
||||
const ALL_DOC_SE_VALUE = "__ALL__";
|
||||
const ESNTL_ATCH_FILE_ALL_DOC_SE = "";
|
||||
|
||||
const DEFAULT_ESNTL_ATCH_FILE_PAGE_INDEX = 0;
|
||||
const DEFAULT_ESNTL_ATCH_FILE_PAGE_SIZE = 10;
|
||||
const DEFAULT_ESNTL_ATCH_FILE_SORT = "regDt,desc";
|
||||
|
||||
const DEFAULT_SEARCH_PARAMS = {
|
||||
page: DEFAULT_ESNTL_ATCH_FILE_PAGE_INDEX,
|
||||
size: DEFAULT_ESNTL_ATCH_FILE_PAGE_SIZE,
|
||||
sort: DEFAULT_ESNTL_ATCH_FILE_SORT,
|
||||
};
|
||||
|
||||
function EsntlAtchFileMngPage() {
|
||||
const { data: docSeCodes, isPending: isDocSeLoading } = useCodeList("ASDOCSECD");
|
||||
|
||||
const [docSeComCd, setDocSeComCd] = useState(ESNTL_ATCH_FILE_ALL_DOC_SE);
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: DEFAULT_ESNTL_ATCH_FILE_PAGE_INDEX,
|
||||
pageSize: DEFAULT_ESNTL_ATCH_FILE_PAGE_SIZE,
|
||||
});
|
||||
const [searchParams, setSearchParams] =
|
||||
useState<EsntlAtchFileMngSearchParams>(DEFAULT_SEARCH_PARAMS);
|
||||
|
||||
const handleSearch = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setPagination((prev) => ({ ...prev, pageIndex: DEFAULT_ESNTL_ATCH_FILE_PAGE_INDEX }));
|
||||
setSearchParams({
|
||||
...DEFAULT_SEARCH_PARAMS,
|
||||
docSeComCd,
|
||||
});
|
||||
};
|
||||
|
||||
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 gap-4 p-4" onSubmit={handleSearch}>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Label htmlFor="docSeComCd">문서구분</Label>
|
||||
<Select
|
||||
value={docSeComCd || ALL_DOC_SE_VALUE}
|
||||
onValueChange={(value) =>
|
||||
setDocSeComCd(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>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<EsntlAtchFileList
|
||||
searchParams={searchParams}
|
||||
pagination={pagination}
|
||||
onPaginationChange={setPagination}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { EsntlAtchFileMngPage };
|
||||
@@ -4,16 +4,23 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { type HttpResponse,Upload } from "tus-js-client";
|
||||
import axios from "axios";
|
||||
import { type HttpResponse, Upload } from "tus-js-client";
|
||||
|
||||
import { AUTH_USER_ID, CLAIM_NAME, ENDPOINT, PROXY_HOST } from "@/shared/config";
|
||||
import { deleteAttachment } from "@/shared/lib/attach";
|
||||
import { getCookie, setCookie, useAlertStore, useUserStore } from "@/shared/lib";
|
||||
|
||||
import { AUTH_USER_ID, CLAIM_NAME, ENDPOINT } from "@/shared/config";
|
||||
import { getCookie, setCookie } from "@/shared/lib";
|
||||
import type { AcntgSeMngTreeDto } from "@/entities/acntg";
|
||||
import { acntgService } from "@/entities/acntg";
|
||||
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 {
|
||||
INSTL_SNCN_REGISTER_SELECTION_KEY,
|
||||
InstlSncnApi,
|
||||
@@ -22,6 +29,7 @@ import {
|
||||
type InstlSncnBzScaleSaveDto,
|
||||
type InstlSncnMng,
|
||||
} from "@/entities/instl-sncn";
|
||||
|
||||
import { Button, Input } from "@common/components";
|
||||
|
||||
import "./cnfm-page.css";
|
||||
@@ -41,34 +49,19 @@ type BzAmtRow = {
|
||||
thtiDsgn: string;
|
||||
};
|
||||
|
||||
type RequiredAttachKey =
|
||||
| "applyForm"
|
||||
| "planGoalYear"
|
||||
| "adequacyReview"
|
||||
| "supervisionCost"
|
||||
| "costBreakdown"
|
||||
| "designDrawing"
|
||||
| "etc";
|
||||
type RequiredAttachKey = string;
|
||||
|
||||
type RequiredAttachItem = {
|
||||
key: RequiredAttachKey;
|
||||
label: string;
|
||||
esntlAtchFileNm: string;
|
||||
esntlAtchYn: "Y" | "N";
|
||||
esntlAtchFileRmrk: string;
|
||||
atflCd: string;
|
||||
atflId: string;
|
||||
fileName: string;
|
||||
uploading: boolean;
|
||||
};
|
||||
|
||||
const REQUIRED_ATTACHMENTS: Array<{ key: RequiredAttachKey; label: string }> = [
|
||||
{ key: "applyForm", label: "인가신청서" },
|
||||
{ key: "planGoalYear", label: "계획수립 기준 및 목표연도 계획" },
|
||||
{ key: "adequacyReview", label: "하수도 시설계획 적정성 검토 결과" },
|
||||
{ key: "supervisionCost", label: "관로설계시설물 감리비용 산출 계획서" },
|
||||
{ key: "costBreakdown", label: "사업비 내역서" },
|
||||
{ key: "designDrawing", label: "설계도면" },
|
||||
{ key: "etc", label: "기타 첨부자료" },
|
||||
];
|
||||
|
||||
const emptyScale = (): BzScaleRow => ({ bscPl: "", budgetReflct: "", thtiDsgn: "" });
|
||||
const emptyAmt = (): BzAmtRow => ({ bscPl: "", budgetReflct: "", thtiDsgn: "" });
|
||||
|
||||
@@ -76,28 +69,19 @@ const num = (v: string) => parseFloat(v.replace(/,/g, "") || "0");
|
||||
const fmt = (v: number) => (isNaN(v) ? "" : v.toLocaleString());
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 공통 셀 컴포넌트 (컴포넌트 외부에 선언해야 re-mount 방지) */
|
||||
/* 공통 셀 컴포넌트 분리: widgets/cnfm/ui/cells.tsx에서 import */
|
||||
/* ------------------------------------------------------------------ */
|
||||
const NumCell = ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
className="cnfm-input w-full min-w-0 text-right"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
|
||||
const ReadCell = ({ value }: { value: string | number }) => (
|
||||
<td className="bg-gray-50 pr-2 text-right text-[1.25rem] font-semibold">{value}</td>
|
||||
);
|
||||
import { NumCell, ReadCell, PrivacyConsent, EtcAttachments } from '@/widgets/cnfm';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 메인 컴포넌트 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function CnfmRegistPage() {
|
||||
const router = useRouter();
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
|
||||
const [docSeComCd] = useState("INSTL");
|
||||
const [taskSeComCdNm, setTaskSeComCdNm] = useState("재이용수");
|
||||
const [taskSeComCd, setTaskSeComCd] = useState("RE");
|
||||
|
||||
@@ -115,7 +99,6 @@ function CnfmRegistPage() {
|
||||
sessionStorage.removeItem(INSTL_SNCN_REGISTER_SELECTION_KEY);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/* ── 기본 정보 ── */
|
||||
const [bzNm, setBzNm] = useState("");
|
||||
const [bzBgngYr, setBzBgngYr] = useState("");
|
||||
@@ -132,38 +115,39 @@ function CnfmRegistPage() {
|
||||
/* ── 개인정보 동의 ── */
|
||||
const [privacyAgree, setPrivacyAgree] = useState<"Y" | "N">("N");
|
||||
|
||||
/* ── 기타 첨부파일 ── */
|
||||
type EtcFile = { id: number; name: string; ext: string; size: string; uploader: string };
|
||||
const [etcFiles, setEtcFiles] = useState<EtcFile[]>([]);
|
||||
const [etcNoticeOpen, setEtcNoticeOpen] = useState(true);
|
||||
|
||||
/* ── 필수 첨부파일 ── */
|
||||
const [requiredFiles, setRequiredFiles] = useState<Record<RequiredAttachKey, RequiredAttachItem>>(() =>
|
||||
REQUIRED_ATTACHMENTS.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.key] = {
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
atflCd: "",
|
||||
atflId: "",
|
||||
fileName: "",
|
||||
uploading: false,
|
||||
};
|
||||
return acc;
|
||||
},
|
||||
{} as Record<RequiredAttachKey, RequiredAttachItem>,
|
||||
),
|
||||
);
|
||||
const requiredFileInputRefs = useRef<Record<RequiredAttachKey, HTMLInputElement | null>>({
|
||||
applyForm: null,
|
||||
planGoalYear: null,
|
||||
adequacyReview: null,
|
||||
supervisionCost: null,
|
||||
costBreakdown: null,
|
||||
designDrawing: null,
|
||||
etc: null,
|
||||
const { data: requiredAttachmentDefs = [] } = useQuery<EsntlAtchFileMngTreeResponseDto[]>({
|
||||
queryKey: ["esntlAtchFileTree", docSeComCd],
|
||||
queryFn: () => esntlAtchFileMngService.findTree({ docSeComCd }),
|
||||
enabled: Boolean(docSeComCd),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const [requiredFiles, setRequiredFiles] = useState<Record<RequiredAttachKey, RequiredAttachItem>>({});
|
||||
const requiredFileInputRefs = useRef<Record<RequiredAttachKey, HTMLInputElement | null>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (requiredAttachmentDefs.length === 0) return;
|
||||
|
||||
setRequiredFiles((prev) => {
|
||||
const next: Record<RequiredAttachKey, RequiredAttachItem> = {};
|
||||
for (const item of requiredAttachmentDefs) {
|
||||
const key = item.esntlAtchFileId;
|
||||
const prevItem = prev[key];
|
||||
next[key] = {
|
||||
key,
|
||||
esntlAtchFileNm: item.esntlAtchFileNm,
|
||||
esntlAtchYn: item.esntlAtchYn,
|
||||
esntlAtchFileRmrk: item.esntlAtchFileRmrk,
|
||||
atflCd: prevItem?.atflCd ?? "",
|
||||
atflId: prevItem?.atflId ?? "",
|
||||
fileName: prevItem?.fileName ?? "",
|
||||
uploading: prevItem?.uploading ?? false,
|
||||
};
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [requiredAttachmentDefs]);
|
||||
|
||||
const uploadRequiredFile = (file: File, key: RequiredAttachKey) => {
|
||||
setRequiredFiles((prev) => ({
|
||||
...prev,
|
||||
@@ -173,7 +157,7 @@ function CnfmRegistPage() {
|
||||
setCookie("atfl-cd-key", "");
|
||||
|
||||
const upload = new Upload(file, {
|
||||
endpoint: ENDPOINT.ATTACH_SERVICE.TUS_UPLOAD,
|
||||
endpoint: `${PROXY_HOST}${ENDPOINT.ATTACH_SERVICE.TUS_UPLOAD}`,
|
||||
chunkSize: 10 * 1024 * 1024,
|
||||
headers: {
|
||||
[CLAIM_NAME]: getCookie(CLAIM_NAME) ?? "",
|
||||
@@ -222,6 +206,44 @@ function CnfmRegistPage() {
|
||||
upload.start();
|
||||
};
|
||||
|
||||
/* ── 기타 첨부파일 ── */
|
||||
type EtcFile = {
|
||||
id: string;
|
||||
name: string;
|
||||
ext: string;
|
||||
size: string;
|
||||
uploader: string;
|
||||
atflCd: string;
|
||||
atflId: string;
|
||||
};
|
||||
const [etcFiles, setEtcFiles] = useState<EtcFile[]>([]);
|
||||
const [etcNoticeOpen, setEtcNoticeOpen] = useState(true);
|
||||
const [etcFileGroupId, setEtcFileGroupId] = useState("");
|
||||
const [etcUploading, setEtcUploading] = useState(false);
|
||||
const etcFileGroupIdRef = useRef("");
|
||||
|
||||
/** 기타첨부파일 삭제 핸들러: 백엔드에 DELETE 요청을 보내 소프트삭제 처리 후 상태 갱신 */
|
||||
const handleDeleteEtcFile = async (fileId: string, atflId: string) => {
|
||||
try {
|
||||
if (atflId) {
|
||||
await deleteAttachment(atflId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("기타 첨부파일 삭제 실패", error);
|
||||
alert("파일 삭제 중 오류가 발생했습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
setEtcFiles((prev) => {
|
||||
const next = prev.filter((x) => x.id !== fileId);
|
||||
if (next.length === 0) {
|
||||
setEtcFileGroupId("");
|
||||
etcFileGroupIdRef.current = "";
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleRequiredFileChange = (key: RequiredAttachKey, e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
@@ -229,15 +251,102 @@ function CnfmRegistPage() {
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
const toFileSizeText = (size: number): string =>
|
||||
size > 1024 * 1024 ? `${(size / 1024 / 1024).toFixed(1)}MB` : `${(size / 1024).toFixed(0)}KB`;
|
||||
|
||||
const uploadEtcFile = (file: File): Promise<EtcFile> =>
|
||||
new Promise((resolve, reject) => {
|
||||
let uploadedAtflCd = "";
|
||||
let uploadedAtflId = "";
|
||||
|
||||
const upload = new Upload(file, {
|
||||
endpoint: `${PROXY_HOST}${ENDPOINT.ATTACH_SERVICE.TUS_UPLOAD}`,
|
||||
chunkSize: 10 * 1024 * 1024,
|
||||
headers: {
|
||||
[CLAIM_NAME]: getCookie(CLAIM_NAME) ?? "",
|
||||
[AUTH_USER_ID]: getCookie(AUTH_USER_ID) ?? "",
|
||||
},
|
||||
metadata: {
|
||||
filename: file.name,
|
||||
filetype: file.type,
|
||||
atflCd: etcFileGroupIdRef.current || "",
|
||||
},
|
||||
onError(error: Error) {
|
||||
reject(error);
|
||||
},
|
||||
onAfterResponse(_req: unknown, res: HttpResponse) {
|
||||
const fileInfo = res.getHeader("X-File-Info");
|
||||
if (typeof fileInfo !== "string") return;
|
||||
|
||||
const parsed = JSON.parse(fileInfo) as { atflCd?: string; atflId?: string };
|
||||
uploadedAtflCd = parsed.atflCd ?? "";
|
||||
uploadedAtflId = parsed.atflId ?? "";
|
||||
|
||||
if (uploadedAtflCd) {
|
||||
etcFileGroupIdRef.current = uploadedAtflCd;
|
||||
setEtcFileGroupId((prev) => prev || uploadedAtflCd);
|
||||
}
|
||||
},
|
||||
onSuccess() {
|
||||
const ext = file.name.includes(".") ? file.name.split(".").pop() ?? "" : "";
|
||||
resolve({
|
||||
id: uploadedAtflId || `${Date.now()}_${Math.random()}`,
|
||||
name: file.name,
|
||||
ext,
|
||||
size: toFileSizeText(file.size),
|
||||
uploader: userInfo?.userName ?? "알 수 없음",
|
||||
atflCd: uploadedAtflCd,
|
||||
atflId: uploadedAtflId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
upload.start();
|
||||
});
|
||||
|
||||
const handleEtcFilesChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
if (files.length === 0) return;
|
||||
|
||||
setEtcUploading(true);
|
||||
const uploadedRows: EtcFile[] = [];
|
||||
const failedNames: string[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const uploaded = await uploadEtcFile(file);
|
||||
uploadedRows.push(uploaded);
|
||||
} catch (error) {
|
||||
console.error("기타 첨부파일 업로드 실패", error);
|
||||
failedNames.push(file.name);
|
||||
}
|
||||
}
|
||||
|
||||
if (uploadedRows.length > 0) {
|
||||
setEtcFiles((prev) => [...prev, ...uploadedRows]);
|
||||
if (!etcFileGroupId && uploadedRows[0].atflCd) {
|
||||
setEtcFileGroupId(uploadedRows[0].atflCd);
|
||||
etcFileGroupIdRef.current = uploadedRows[0].atflCd;
|
||||
}
|
||||
}
|
||||
|
||||
if (failedNames.length > 0) {
|
||||
alert(`일부 파일 업로드에 실패했습니다. (${failedNames.join(", ")})`);
|
||||
}
|
||||
|
||||
setEtcUploading(false);
|
||||
e.target.value = "";
|
||||
};
|
||||
|
||||
/* ── 체크리스트 ── */
|
||||
type CheckResultVal = "Y" | "N" | null;
|
||||
const [checkResults, setCheckResults] = useState<Record<string, CheckResultVal>>({});
|
||||
const cklstRowRefs = useRef<Record<string, HTMLTableRowElement | null>>({});
|
||||
|
||||
const { data: cklstTree = [] } = useQuery<CklstTreeNode[]>({
|
||||
queryKey: ["cklstTree", "INSTL", taskSeComCd],
|
||||
queryFn: () => cklstMngService.findTree({ docSeComCd: "INSTL", taskSeComCd }),
|
||||
enabled: Boolean(taskSeComCd),
|
||||
queryKey: ["cklstTree", docSeComCd, taskSeComCd],
|
||||
queryFn: () => cklstMngService.findTree({ docSeComCd, taskSeComCd }),
|
||||
enabled: Boolean(docSeComCd) && Boolean(taskSeComCd),
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
@@ -478,7 +587,7 @@ function CnfmRegistPage() {
|
||||
|
||||
/* ── 검증 ── */
|
||||
function fnValidation(mode: "SAVE" | "PRESENTN") {
|
||||
/*
|
||||
|
||||
if (!bzNm.trim()) {
|
||||
alert("사업명을 입력하세요.");
|
||||
return false;
|
||||
@@ -505,9 +614,19 @@ function CnfmRegistPage() {
|
||||
alert("체크리스트를 모두 체크하세요.");
|
||||
return false;
|
||||
}
|
||||
// 저장 시: 사용 항목(Y)은 검토 결과가 반드시 예(Y)여야 함
|
||||
*/
|
||||
// 저장 시: 필수 첨부파일(esntlAtchYn=Y) 업로드 여부 검증
|
||||
if (mode === "SAVE") {
|
||||
const missingRequiredFile = requiredAttachmentDefs.find((item) => {
|
||||
if (item.esntlAtchYn !== "Y") return false;
|
||||
const uploaded = requiredFiles[item.esntlAtchFileId];
|
||||
return !(uploaded?.atflCd && uploaded?.atflId);
|
||||
});
|
||||
if (missingRequiredFile) {
|
||||
alert(`${missingRequiredFile.esntlAtchFileNm} 파일을 첨부하세요.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 저장 시: 사용 항목(Y)은 검토 결과가 반드시 예(Y)여야 함
|
||||
const invalidYesRow = cklstRows.find(
|
||||
(row) => row.bchckUseYn === "Y" && checkResults[cklstKey(row)] !== "Y",
|
||||
);
|
||||
@@ -529,7 +648,7 @@ function CnfmRegistPage() {
|
||||
|
||||
const toInstlSncnMngPayload = (prgrsSttsComCd: "SV" | "ST"): InstlSncnMng => ({
|
||||
instlSncnDocNo: "",
|
||||
dongId: "",
|
||||
admdstCd: "",
|
||||
sidoNm: "",
|
||||
sggNm: "",
|
||||
chrgrNm: "",
|
||||
@@ -552,7 +671,7 @@ function CnfmRegistPage() {
|
||||
dsgnrTelno,
|
||||
dsgnrOgdpNm: dsgnrPsitn,
|
||||
dsgnrEmlAddr,
|
||||
atchFileGroupId: "",
|
||||
atchFileGroupId: etcFileGroupId,
|
||||
prgrsSttsComCd,
|
||||
prgrsSttsCdNm: "",
|
||||
regDt: "",
|
||||
@@ -575,7 +694,11 @@ function CnfmRegistPage() {
|
||||
alert(`저장되었습니다. 문서번호: ${issuedDocNo}`);
|
||||
} catch (error) {
|
||||
console.error("저장 API 호출 실패", error);
|
||||
alert("저장 중 오류가 발생했습니다.");
|
||||
const serverMessage =
|
||||
axios.isAxiosError(error) && error.response?.data?.message
|
||||
? (error.response.data as { message: string }).message
|
||||
: "저장 중 오류가 발생했습니다.";
|
||||
alert(serverMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -632,10 +755,10 @@ function CnfmRegistPage() {
|
||||
<table className="cnfm-form-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>
|
||||
<th style={{ textAlign: "center" }}>
|
||||
<span className="text-red-500">*</span> 사업명
|
||||
</th>
|
||||
<td colSpan={3}>
|
||||
<td>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="사업명을 입력하거나 사업찾기를 이용하세요"
|
||||
@@ -646,12 +769,10 @@ function CnfmRegistPage() {
|
||||
<button className="cnfm-btn-search whitespace-nowrap">🔍 사업찾기</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>
|
||||
<th style={{ textAlign: "center" }}>
|
||||
<span className="text-red-500">*</span> 사업기간
|
||||
</th>
|
||||
<td colSpan={3}>
|
||||
<td>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
placeholder="시작연도(YYYY)"
|
||||
@@ -670,7 +791,7 @@ function CnfmRegistPage() {
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>
|
||||
<th style={{ textAlign: "center" }}>
|
||||
<span className="text-red-500">*</span> 사업내용
|
||||
</th>
|
||||
<td colSpan={3}>
|
||||
@@ -683,19 +804,21 @@ function CnfmRegistPage() {
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>구분</th>
|
||||
<th style={{ textAlign: "center" }}>구분</th>
|
||||
<td>
|
||||
<div className="flex gap-4 text-[1.3rem]">{taskSeComCdNm}</div>
|
||||
<input type="hidden" name="taskSeComCd" value={taskSeComCd} />
|
||||
</td>
|
||||
<th>신청 지자체</th>
|
||||
<th style={{ textAlign: "center" }}>신청 지자체</th>
|
||||
<td>
|
||||
<span className="text-[1.3rem] text-gray-500">(로그인 정보 자동 세팅)</span>
|
||||
<span className="inline-block min-w-[16rem] whitespace-nowrap text-[1.3rem] text-gray-500">
|
||||
{userInfo?.enoNm ?? "\u00A0"}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>사업구분</th>
|
||||
<td>
|
||||
<th style={{ textAlign: "center" }}><span className="text-red-500">*</span>사업구분</th>
|
||||
<td colSpan={3}>
|
||||
<div className="flex gap-4 text-[1.3rem]">
|
||||
{[
|
||||
["CNFM", "설치인가"],
|
||||
@@ -726,27 +849,31 @@ function CnfmRegistPage() {
|
||||
<table className="cnfm-form-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>신청자 이름</th>
|
||||
<th rowSpan={2} style={{ textAlign: "center" }} className="text-center align-middle w-[6.5rem]">신청자</th>
|
||||
<th style={{ textAlign: "center" }}>이름</th>
|
||||
<td>
|
||||
<span className="text-[1.3rem] text-gray-500">(로그인 사용자 자동 세팅)</span>
|
||||
<span className="inline-block min-w-[16rem] whitespace-nowrap text-[1.3rem] text-gray-500">{userInfo?.userName ?? "\u00A0"}</span>
|
||||
</td>
|
||||
<th>전화번호</th>
|
||||
<th style={{ textAlign: "center" }}>전화번호</th>
|
||||
<td>
|
||||
<span className="text-[1.3rem] text-gray-500">(로그인 사용자 자동 세팅)</span>
|
||||
<span className="inline-block min-w-[16rem] whitespace-nowrap text-[1.3rem] text-gray-500">{userInfo?.mphn ?? userInfo?.telno ?? "\u00A0"}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>부서</th>
|
||||
<th style={{ textAlign: "center" }}>부서</th>
|
||||
<td>
|
||||
<span className="text-[1.3rem] text-gray-500">(로그인 사용자 자동 세팅)</span>
|
||||
<span className="text-[1.3rem] text-gray-500">{userInfo?.instSeNm ?? "\u00A0"}</span>
|
||||
</td>
|
||||
<th>이메일</th>
|
||||
|
||||
<th style={{ textAlign: "center" }}>이메일</th>
|
||||
<td>
|
||||
<span className="text-[1.3rem] text-gray-500">(로그인 사용자 자동 세팅)</span>
|
||||
<span className="text-[1.3rem] text-gray-500">{userInfo?.email ?? "\u00A0"}</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th>설계자 이름</th>
|
||||
<th rowSpan={2} style={{ textAlign: "center" }}>설계자</th>
|
||||
<th style={{ textAlign: "center" }}>이름</th>
|
||||
<td>
|
||||
<Input
|
||||
placeholder="설계자 성명"
|
||||
@@ -755,7 +882,7 @@ function CnfmRegistPage() {
|
||||
onChange={(e) => setDsgnrNm(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<th>전화번호</th>
|
||||
<th style={{ textAlign: "center" }}>전화번호</th>
|
||||
<td>
|
||||
<Input
|
||||
placeholder="설계자 전화번호"
|
||||
@@ -766,16 +893,17 @@ function CnfmRegistPage() {
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>소속(용역사)</th>
|
||||
<th style={{ textAlign: "center" }}>소속</th>
|
||||
<td>
|
||||
<Input
|
||||
placeholder="용역사명"
|
||||
placeholder="소속명"
|
||||
className="!text-[1.3rem]"
|
||||
value={dsgnrPsitn}
|
||||
onChange={(e) => setDsgnrPsitn(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
<th>이메일</th>
|
||||
|
||||
<th style={{ textAlign: "center" }}>이메일</th>
|
||||
<td>
|
||||
<Input
|
||||
placeholder="설계자 이메일"
|
||||
@@ -789,43 +917,35 @@ function CnfmRegistPage() {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* ── 개인정보 동의 ── */}
|
||||
<div className="cnfm-card p-4">
|
||||
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">개인정보 동의</h3>
|
||||
<div className="mb-3 rounded border bg-gray-50 p-3 text-[1.25rem] leading-relaxed text-gray-600">
|
||||
<p className="mb-1 font-semibold">[개인정보 수집·이용 목적]</p>
|
||||
<ul className="ml-4 list-disc space-y-1">
|
||||
<li>수집항목: 소속기관, 성명, 전화번호, 이메일, IP Address, 접속 기기 정보</li>
|
||||
<li>수집목적: 하수도 시설물 설치인가 신청 및 처리 목적</li>
|
||||
<li>보유기간: 관련 법령에 따른 의무 보유 기간 준수</li>
|
||||
</ul>
|
||||
<p className="mt-2 text-[1.2rem] text-gray-500">
|
||||
※ 개인정보 제공에 동의하지 않을 수 있으나, 미동의 시 서비스 이용이 제한될 수 있습니다.
|
||||
</p>
|
||||
{/* ── 개인정보 동의 (위젯으로 분리) ── */}
|
||||
<PrivacyConsent value={privacyAgree} onChange={setPrivacyAgree}>
|
||||
<p className="mb-1 font-semibold">[ 개인정보 수집항목 ]</p>
|
||||
<div>
|
||||
* 필수항목 : 소속기관, 소속부서, 담당자, 직급, 사무실 번호, FAX 번호<br />
|
||||
․ 선택항목 : 휴대폰 번호, 이메일 주소<br />
|
||||
․ 기타항목 : IP Address, 쿠키, 시스템 접속 및 이용 기록<br />
|
||||
※ 시스템 이용과정에서 기타 항목의 정보들이 자동으로 생성되어 수집될 수 있습니다.
|
||||
</div>
|
||||
<div className="flex gap-6 text-[1.3rem]">
|
||||
<label className="flex cursor-pointer items-center gap-2 font-semibold text-blue-600">
|
||||
<input
|
||||
type="radio"
|
||||
name="privacyAgree"
|
||||
value="Y"
|
||||
checked={privacyAgree === "Y"}
|
||||
onChange={() => setPrivacyAgree("Y")}
|
||||
/>
|
||||
예 (동의)
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<input
|
||||
type="radio"
|
||||
name="privacyAgree"
|
||||
value="N"
|
||||
checked={privacyAgree === "N"}
|
||||
onChange={() => setPrivacyAgree("N")}
|
||||
/>
|
||||
아니오 (미동의)
|
||||
</label>
|
||||
|
||||
<p className="mt-4 font-semibold">[ 개인정보 이용목적 ]</p>
|
||||
<div>
|
||||
․ 국가하수도정보시스템 웹서비스 이용에 따른 본인 확인 절차에 이용<br />
|
||||
․ 국가하수도정보시스템 사용에 필요한 권한 부여<br />
|
||||
․ 사용자별 맞춤 컨텐츠 (처리시설의 운영 데이터 조회, 수질현황 데이터 조회, 예산 편성 / 예산편성 등) 제공에 이용
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="tit mt-4">● 개인정보의 보유 및 이용기간</div>
|
||||
<div>
|
||||
․ 수집된 고객의 개인정보는 2년 동안 보유하며 그 이후에는 문서를 파쇄합니다.<br />
|
||||
․ 정보제공자가 개인정보 파쇄를 요청할 경우 즉시 파쇄합니다.
|
||||
</div>
|
||||
|
||||
<br />
|
||||
<div className="tit">● 개인정보 제공 거부에 따른 제한사항</div>
|
||||
<div>
|
||||
․ 귀하는 개인정보 제공 동의를 거부할 권리가 있으나, 동의 거부 시 국가하수도정보시스템 이용 서비스에 제한이 있을 수 있습니다.
|
||||
</div>
|
||||
</PrivacyConsent>
|
||||
|
||||
{/* ── 사업규모 ── */}
|
||||
<div className="cnfm-card p-4">
|
||||
@@ -1100,7 +1220,7 @@ function CnfmRegistPage() {
|
||||
|
||||
{/* ── 필수 첨부파일 ── */}
|
||||
<div className="cnfm-card p-4">
|
||||
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">첨부파일</h3>
|
||||
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">필수 첨부파일</h3>
|
||||
<div className="cnfm-table-wrap">
|
||||
<table className="cnfm-table">
|
||||
<thead>
|
||||
@@ -1111,39 +1231,58 @@ function CnfmRegistPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{REQUIRED_ATTACHMENTS.map(({ key, label }) => (
|
||||
<tr key={key}>
|
||||
<td className="pl-2 text-left">{label}</td>
|
||||
<td>hwp/pdf</td>
|
||||
<td>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="첨부문서를 올려주세요"
|
||||
className="cnfm-input flex-1"
|
||||
value={requiredFiles[key]?.uploading ? "업로드 중..." : requiredFiles[key]?.fileName || ""}
|
||||
readOnly
|
||||
/>
|
||||
<input
|
||||
ref={(el) => {
|
||||
requiredFileInputRefs.current[key] = el;
|
||||
}}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(e) => handleRequiredFileChange(key, e)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="cnfm-btn-search text-[1.2rem]"
|
||||
onClick={() => requiredFileInputRefs.current[key]?.click()}
|
||||
disabled={requiredFiles[key]?.uploading}
|
||||
>
|
||||
{requiredFiles[key]?.uploading ? "업로드중" : "첨부하기"}
|
||||
</button>
|
||||
</div>
|
||||
{requiredAttachmentDefs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="cnfm-td-empty">
|
||||
조회된 필수 첨부파일 항목이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
) : (
|
||||
requiredAttachmentDefs.map((item) => {
|
||||
const key = item.esntlAtchFileId;
|
||||
const requiredFile = requiredFiles[key];
|
||||
const fileFormat = (item.esntlAtchFileRmrk || "").replaceAll(",", "/") || "-";
|
||||
|
||||
return (
|
||||
<tr key={key}>
|
||||
<td className="pl-2">
|
||||
<div className="w-full text-left">
|
||||
{item.esntlAtchFileNm}
|
||||
{item.esntlAtchYn === "Y" ? <span className="ml-1 text-red-500">*</span> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td>{fileFormat}</td>
|
||||
<td>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="첨부문서를 올려주세요"
|
||||
className="cnfm-input flex-1"
|
||||
value={requiredFile?.uploading ? "업로드 중..." : requiredFile?.fileName || ""}
|
||||
readOnly
|
||||
/>
|
||||
<input
|
||||
ref={(el) => {
|
||||
requiredFileInputRefs.current[key] = el;
|
||||
}}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(e) => handleRequiredFileChange(key, e)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="cnfm-btn-search text-[1.2rem]"
|
||||
onClick={() => requiredFileInputRefs.current[key]?.click()}
|
||||
disabled={requiredFile?.uploading}
|
||||
>
|
||||
{requiredFile?.uploading ? "업로드중" : "첨부하기"}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -1165,80 +1304,12 @@ function CnfmRegistPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 파일 업로드 영역 */}
|
||||
<div className="mb-3 flex flex-nowrap items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
className="cnfm-input min-w-0 flex-1"
|
||||
placeholder="첨부문서를 등록해주세요"
|
||||
readOnly
|
||||
/>
|
||||
<label className="cnfm-btn-search shrink-0 cursor-pointer whitespace-nowrap text-[1.2rem]">
|
||||
첨부파일
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
multiple
|
||||
onChange={(e) => {
|
||||
const files = Array.from(e.target.files ?? []);
|
||||
const mapped: EtcFile[] = files.map((f, i) => ({
|
||||
id: Date.now() + i,
|
||||
name: f.name.replace(/\.[^.]+$/, ""),
|
||||
ext: f.name.split(".").pop() ?? "",
|
||||
size:
|
||||
f.size > 1024 * 1024
|
||||
? `${(f.size / 1024 / 1024).toFixed(1)}MB`
|
||||
: `${(f.size / 1024).toFixed(0)}KB`,
|
||||
uploader: "(로그인 사용자)",
|
||||
}));
|
||||
setEtcFiles((prev) => [...prev, ...mapped]);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 첨부된 파일 목록 테이블 */}
|
||||
<div className="cnfm-table-wrap">
|
||||
<table className="cnfm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>파일명</th>
|
||||
<th className="w-[8rem]">확장자</th>
|
||||
<th className="w-[8rem]">크기</th>
|
||||
<th className="w-[12rem]">등록자</th>
|
||||
<th className="w-[6rem]">삭제</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{etcFiles.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="cnfm-td-empty">
|
||||
첨부된 파일이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
etcFiles.map((f) => (
|
||||
<tr key={f.id}>
|
||||
<td className="pl-2 text-left">{f.name}</td>
|
||||
<td>{f.ext}</td>
|
||||
<td>{f.size}</td>
|
||||
<td>{f.uploader}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[1.2rem] text-red-500 hover:text-red-700"
|
||||
onClick={() => setEtcFiles((prev) => prev.filter((x) => x.id !== f.id))}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<EtcAttachments
|
||||
etcFiles={etcFiles}
|
||||
etcUploading={etcUploading}
|
||||
onFilesChange={handleEtcFilesChange}
|
||||
onDelete={(fileId, atflId) => void handleDeleteEtcFile(fileId, atflId)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── 액션 버튼 ── */}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
"use client";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any, import/order */
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Button } from "@common/components";
|
||||
|
||||
import { InstlSncnApi } from "@/entities/instl-sncn";
|
||||
import { dayjs } from "@/shared/lib";
|
||||
import { PROXY_HOST } from "@/shared/config";
|
||||
import { formatFileSize } from "@/shared/lib/file-utils";
|
||||
|
||||
import "./cnfm-page.css";
|
||||
|
||||
@@ -25,15 +29,32 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
return <div className="mt-6 text-[1.4rem]">상세 정보를 찾을 수 없습니다.</div>;
|
||||
}
|
||||
|
||||
const data = query.data;
|
||||
const data = query.data as any;
|
||||
|
||||
// support API shape where payload includes detDto + lists
|
||||
const det = data.detDto ?? data;
|
||||
const bizSclList = data.bizSclList ?? [];
|
||||
const bizCstList = data.bizCstList ?? [];
|
||||
const cklstResultList = data.cklstResultList ?? [];
|
||||
const esntlFileList = data.esntlFileList ?? [];
|
||||
const etcFileList = data.etcFileList ?? [];
|
||||
|
||||
const fmt = (v: number | null | undefined) =>
|
||||
typeof v === "number" ? v.toLocaleString() : v === null || v === undefined ? "" : String(v);
|
||||
|
||||
const indentPx = (indNm: string | undefined) => {
|
||||
if (!indNm) return 0;
|
||||
const spaces = indNm.length - indNm.trimStart().length;
|
||||
return (spaces / 4) * 16;
|
||||
};
|
||||
|
||||
/* formatFileSize moved to shared lib: @/shared/lib/file-utils */
|
||||
|
||||
return (
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="cnfm-card p-4">
|
||||
<h2 className="cnfm-section-title">설치인가 상세보기</h2>
|
||||
<p className="mt-2 text-[1.3rem] text-gray-600">
|
||||
문서번호: <strong>{data.instlSncnDocNo}</strong>
|
||||
</p>
|
||||
<p className="mt-2 text-[1.3rem] text-gray-600">문서번호: <strong>{det.instlSncnDocNo}</strong></p>
|
||||
</div>
|
||||
|
||||
<div className="cnfm-card p-4">
|
||||
@@ -42,28 +63,27 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>사업명</th>
|
||||
<td>{data.bizNm || "-"}</td>
|
||||
<th>업무구분</th>
|
||||
<td>{data.taskSeCdNm || data.taskSeComCd || "-"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>사업기간</th>
|
||||
<td>
|
||||
{(data.bizBgngYr || "-") + " ~ " + (data.bizEndYr || "-")}
|
||||
</td>
|
||||
<th>진행상태</th>
|
||||
<td>{data.prgrsSttsCdNm || data.prgrsSttsComCd || "-"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>행정동ID</th>
|
||||
<td>{data.dongId || "-"}</td>
|
||||
<th>예산사업코드</th>
|
||||
<td>{data.bgtBizCd || "-"}</td>
|
||||
<td>{det.bizNm || "-"}</td>
|
||||
<th>사업기간</th>
|
||||
<td>{(det.bizBgngYr || "-") + " ~ " + (det.bizEndYr || "-")}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>사업내용</th>
|
||||
<td colSpan={3}>{data.bizCn || "-"}</td>
|
||||
<td colSpan={3}>{det.bizCn || "-"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>업무구분</th>
|
||||
<td>{det.taskSeCdNm || det.taskSeComCd || "-"}</td>
|
||||
<th>행정동ID</th>
|
||||
<td>{det.dongId || "-"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>진행상태</th>
|
||||
<td>{det.prgrsSttsCdNm || det.prgrsSttsComCd || "-"}</td>
|
||||
<th>예산사업코드</th>
|
||||
<td>{det.bgtBizCd || "-"}</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -74,27 +94,27 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>신청자명</th>
|
||||
<td>{data.aplcntNm || "-"}</td>
|
||||
<td>{det.aplcntNm || "-"}</td>
|
||||
<th>신청자 연락처</th>
|
||||
<td>{data.aplcntMblTelno || "-"}</td>
|
||||
<td>{det.aplcntMblTelno || "-"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>신청자 소속</th>
|
||||
<td>{data.aplcntOgdpNm || "-"}</td>
|
||||
<td>{det.aplcntOgdpNm || "-"}</td>
|
||||
<th>신청자 이메일</th>
|
||||
<td>{data.aplcntEmlAddr || "-"}</td>
|
||||
<td>{det.aplcntEmlAddr || "-"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>설계자명</th>
|
||||
<td>{data.dsgnrNm || "-"}</td>
|
||||
<td>{det.dsgnrNm || "-"}</td>
|
||||
<th>설계자 연락처</th>
|
||||
<td>{data.dsgnrTelno || "-"}</td>
|
||||
<td>{det.dsgnrTelno || "-"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>설계자 소속</th>
|
||||
<td>{data.dsgnrOgdpNm || "-"}</td>
|
||||
<td>{det.dsgnrOgdpNm || "-"}</td>
|
||||
<th>설계자 이메일</th>
|
||||
<td>{data.dsgnrEmlAddr || "-"}</td>
|
||||
<td>{det.dsgnrEmlAddr || "-"}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -106,20 +126,287 @@ function CnfmViewPage({ id }: { id: string }) {
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>등록일시</th>
|
||||
<td>{data.regDt ? dayjs(data.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"}</td>
|
||||
<td>{det.regDt ? dayjs(det.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"}</td>
|
||||
<th>수정일시</th>
|
||||
<td>{data.mdfcnDt ? dayjs(data.mdfcnDt).format("YYYY-MM-DD HH:mm:ss") : "-"}</td>
|
||||
<td>{det.mdfcnDt ? dayjs(det.mdfcnDt).format("YYYY-MM-DD HH:mm:ss") : "-"}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>등록자명</th>
|
||||
<td>{data.rgtrNm || "-"}</td>
|
||||
<td>{det.rgtrNm || "-"}</td>
|
||||
<th>첨부파일그룹ID</th>
|
||||
<td>{data.atchFileGroupId || "-"}</td>
|
||||
<td>{det.atchFileGroupId || "-"}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 사업규모 */}
|
||||
<div className="cnfm-card p-4">
|
||||
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">사업규모</h3>
|
||||
<div className="cnfm-table-wrap">
|
||||
<table className="cnfm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-[30rem]">구분</th>
|
||||
<th>기본계획(A)</th>
|
||||
<th>예산반영(B)</th>
|
||||
<th>금회설계시(C)</th>
|
||||
<th>증감(C-B)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bizSclList.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="cnfm-td-empty">조회된 사업규모 항목이 없습니다.</td>
|
||||
</tr>
|
||||
) : (
|
||||
bizSclList.map((node: any) => {
|
||||
const pl = indentPx(node.indCnstpSeCdNm);
|
||||
const bscPlanQty = Number(node.bscPlanQty) || 0;
|
||||
const budgetReflct = Number(node.bgtRfltQty) || 0;
|
||||
const tstmDesignQty = Number(node.tstmDesignQty) || 0;
|
||||
const diff = tstmDesignQty - budgetReflct;
|
||||
return (
|
||||
<tr key={node.cnstpSeCd} className={node.treeLevel === 1 ? "bg-gray-50 font-semibold" : ""}>
|
||||
<td className="bg-gray-50 pl-2 text-left" style={{ paddingLeft: `${pl + 8}px` }}>{node.cnstpSeCdNm}</td>
|
||||
<td><div className="text-right">{fmt(bscPlanQty)}</div></td>
|
||||
<td><div className="text-right">{fmt(budgetReflct)}</div></td>
|
||||
<td><div className="text-right">{fmt(tstmDesignQty)}</div></td>
|
||||
<td><div className="text-right">{diff > 0 ? `+${fmt(diff)}` : fmt(diff)}</div></td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 총사업비 */}
|
||||
<div className="cnfm-card p-4">
|
||||
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">총사업비 <span className="text-[1.2rem] font-normal text-gray-400">(단위: 백만원)</span></h3>
|
||||
<div className="cnfm-table-wrap">
|
||||
<table className="cnfm-table w-full table-fixed">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colSpan={2} className="w-[30rem]" rowSpan={2}>
|
||||
구분
|
||||
</th>
|
||||
<th rowSpan={2}>예산반영(A)</th>
|
||||
<th rowSpan={2}>표준사업비(B)</th>
|
||||
<th rowSpan={2}>금회설계시(C)</th>
|
||||
<th colSpan={2}>증감</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="w-[12rem]">(C-B)</th>
|
||||
<th className="w-[6rem]">%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{bizCstList.map((node: any) => {
|
||||
|
||||
const pl = indentPx(node.indAcntgSeCdNm);
|
||||
const bscPlanAmt = Number(node.bscPlanAmt) || 0;
|
||||
const budgetReflct = Number(node.bgtRfltAmt) || 0;
|
||||
const tstmDesignAmt = Number(node.tstmDesignAmt) || 0;
|
||||
const diff = tstmDesignAmt - budgetReflct;
|
||||
const showSubIcon = node.treeLevel >= 4;
|
||||
const isLeaf = !bizCstList.some((n: any) => n.upAcntgSeCd === node.acntgSeCd);
|
||||
return (
|
||||
<tr
|
||||
key={node.acntgSeCd}
|
||||
className={
|
||||
node.treeLevel === 1
|
||||
? "bg-blue-50 font-bold"
|
||||
: node.treeLevel === 2
|
||||
? "bg-gray-100 font-semibold"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{node.treeLevel <= 2 ? (
|
||||
<td colSpan={2} className="pl-2 text-left">{node.acntgSeCdNm}</td>
|
||||
) : (
|
||||
<>
|
||||
<td className="w-[4rem]"></td>
|
||||
<td
|
||||
className={`bg-gray-50 text-left ${isLeaf ? "text-gray-700" : ""}`}
|
||||
style={{ paddingLeft: `${pl + 8}px` }}
|
||||
>
|
||||
{showSubIcon && <span className="mr-1 text-gray-400">└</span>}
|
||||
{node.acntgSeCdNm}
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
|
||||
<td>
|
||||
<div className="text-right">{fmt(bscPlanAmt)}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="text-right">{fmt(budgetReflct)}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="text-right">{fmt(tstmDesignAmt)}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="text-right">{diff > 0 ? `+${fmt(diff)}` : fmt(diff)}</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="text-right">{budgetReflct !== 0 ? `${Math.round((diff / budgetReflct) * 100)}%` : "-"}</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 체크리스트 */}
|
||||
<div className="cnfm-card p-4">
|
||||
<h3 className="text-[1.5rem] font-bold text-gray-700">체크리스트</h3>
|
||||
<div className="cnfm-table-wrap mt-3">
|
||||
<table className="cnfm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-[20rem]">검토항목</th>
|
||||
<th>주요 검토내용</th>
|
||||
<th colSpan={2}>검토 결과(-)</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th />
|
||||
<th />
|
||||
<th className="w-[9rem]">
|
||||
<button
|
||||
type="button"
|
||||
>
|
||||
예
|
||||
</button>
|
||||
</th>
|
||||
<th className="w-[9rem]">
|
||||
<button
|
||||
type="button"
|
||||
>
|
||||
아니오
|
||||
</button>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cklstResultList.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="cnfm-td-empty">조회된 체크리스트 항목이 없습니다.</td>
|
||||
</tr>
|
||||
) : (
|
||||
cklstResultList.map((row: any) => {
|
||||
const key = `${row.cklstSn}_${row.cklstRvwSn}`;
|
||||
const hasResult = row.rvwRsltAnmlyYn === "Y" || row.rvwRsltAnmlyYn === "N";
|
||||
const showRadios = row.bchckUseYn === "Y" || hasResult;
|
||||
|
||||
return (
|
||||
<tr key={key}>
|
||||
<td className="text-center text-[1.2rem]">{row.cklstArtclNm}</td>
|
||||
<td className="!text-left pl-2 text-[1.2rem] text-gray-600" style={{ paddingLeft: `${Math.max(0, row.treeLevel - 1) * 16 + 8}px` }}>{row.treeLevel > 1 ? "└ " : ""}{row.rvwCn}</td>
|
||||
{!showRadios ? (
|
||||
<>
|
||||
<td className="text-center">-</td>
|
||||
<td className="text-center">-</td>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<td className="text-center">
|
||||
<input type="radio" name={`chk_${key}`} value="Y" checked={row.rvwRsltAnmlyYn === "Y"} disabled />
|
||||
</td>
|
||||
<td className="text-center">
|
||||
<input type="radio" name={`chk_${key}`} value="N" checked={row.rvwRsltAnmlyYn === "N"} disabled />
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필수 첨부파일 */}
|
||||
<div className="cnfm-card p-4">
|
||||
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">필수 첨부파일</h3>
|
||||
<div className="cnfm-table-wrap">
|
||||
<table className="cnfm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>필수 자료명</th>
|
||||
<th>파일명</th>
|
||||
<th className="w-[8rem] text-right">크기</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{esntlFileList.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="cnfm-td-empty">조회된 필수 첨부파일 항목이 없습니다.</td>
|
||||
</tr>
|
||||
) : (
|
||||
esntlFileList.map((f: any) => (
|
||||
<tr key={f.esntlAtchFileId}>
|
||||
<td className="pl-2">
|
||||
<div className="w-full text-left">
|
||||
{f.esntlAtchFileNm}
|
||||
{f.esntlAtchYn === "Y" ? <span className="ml-1 text-red-500">*</span> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{f.atflCd ? (
|
||||
<a className="text-blue-600" href={`${PROXY_HOST}/attach-service/api/v1/atflcd/download/${f.atflCd}`} target="_blank" rel="noreferrer">{f.orgnlFileNm || "다운로드"}</a>
|
||||
) : (
|
||||
<span className="text-gray-500">첨부없음</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="w-[8rem] text-right"><div className="text-right">{formatFileSize(f.atflFlieSz)}</div></td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기타 첨부파일 */}
|
||||
<div className="cnfm-card p-4">
|
||||
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">기타 첨부파일</h3>
|
||||
<div className="cnfm-table-wrap">
|
||||
<table className="cnfm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>파일명</th>
|
||||
<th className="w-[8rem] text-right">크기</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{etcFileList.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={2} className="cnfm-td-empty">등록된 기타첨부파일이 없습니다.</td>
|
||||
</tr>
|
||||
) : (
|
||||
etcFileList.map((f: any) => (
|
||||
<tr key={f.atflId}>
|
||||
<td>
|
||||
{f.orgnlFileNm ? (
|
||||
<a className="text-blue-600" href={`${PROXY_HOST}/attach-service/api/v1/atflcd/download/${f.atflCd}`} target="_blank" rel="noreferrer">{f.orgnlFileNm}</a>
|
||||
) : (
|
||||
<span className="text-gray-700">{f.physFileNm || f.atflId}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="w-[8rem] text-right text-gray-500">{formatFileSize(f.atflFlieSz)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="cnfm-action-row">
|
||||
<Button color="white" onClick={() => router.push("/instl-sncn")}>목록</Button>
|
||||
<Button onClick={() => router.push(`/instl-sncn/${id}/modify`)}>수정</Button>
|
||||
|
||||
@@ -4,11 +4,12 @@ export const ENDPOINT = {
|
||||
ATTACHMENTS: "/attach-service/api/v1/atfl/attachments",
|
||||
},
|
||||
AS_COMMON_SERVICE: {
|
||||
ACNTG_SE: "/as-common-service/api/v1/acntg-se",
|
||||
CNSTP_SE: "/as-common-service/api/v1/cnstp-se",
|
||||
ACNTG_SE: "/as-service/api/v1/acntg-se",
|
||||
CNSTP_SE: "/as-service/api/v1/cnstp-se",
|
||||
ESNTL_ATCH_FILE: "/as-service/api/v1/esntl-atch-file",
|
||||
},
|
||||
AS_INSTL_SERVICE: {
|
||||
INSTL_SNCN: "/as-instl-service/api/v1/instl-sncn",
|
||||
INSTL_SNCN: "/as-service/api/v1/instl-sncn",
|
||||
},
|
||||
USER_SERVICE: {
|
||||
LOGIN: "/user-service/login",
|
||||
@@ -20,9 +21,9 @@ export const ENDPOINT = {
|
||||
ELAPR_APLN: "/as-elapr-service/api/v1/elapr-apln",
|
||||
},
|
||||
AS_CHECKLIST_SERVICE: {
|
||||
CKLST: "/as-checklist-service/api/v1/cklst",
|
||||
CKLST_ARTCL: "/as-checklist-service/api/v1/cklst-artcl",
|
||||
CKLST_RVW: "/as-checklist-service/api/v1/cklst-rvw",
|
||||
CKLST: "/as-service/api/v1/cklst",
|
||||
CKLST_ARTCL: "/as-service/api/v1/cklst-artcl",
|
||||
CKLST_RVW: "/as-service/api/v1/cklst-rvw",
|
||||
},
|
||||
CMS_SERVICE: {
|
||||
CODE: "/cms-service/api/v1/code/cache",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { AuthConfig } from "@common/auth";
|
||||
|
||||
import { ACCESS_TOKEN, AUTH_USER_ID, CLAIM_NAME, COOKIE_DOMAIN, REFRESH_TOKEN } from "./env";
|
||||
|
||||
export const authConfig: AuthConfig = {
|
||||
authUserId: AUTH_USER_ID || "token-id",
|
||||
claimName: CLAIM_NAME || "Authorization",
|
||||
cookieDomain: COOKIE_DOMAIN,
|
||||
accessToken: ACCESS_TOKEN || "access-token",
|
||||
refreshToken: REFRESH_TOKEN || "refresh-token",
|
||||
};
|
||||
@@ -8,3 +8,4 @@ export const CLAIM_NAME = process.env.NEXT_PUBLIC_CLAIM_NAME || "Authorization";
|
||||
export const ACCESS_TOKEN = process.env.NEXT_PUBLIC_ACCESS_TOKEN || "access-token";
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { getCookie } from "@/shared/lib";
|
||||
import { CLAIM_NAME, AUTH_USER_ID } from "@/shared/config/env";
|
||||
import axios from "axios";
|
||||
import { PROXY_HOST, ENDPOINT } from "@/shared/config";
|
||||
|
||||
/**
|
||||
* Common helper to delete attachment using cookies for auth headers.
|
||||
*/
|
||||
export async function deleteAttachment(atflId: string): Promise<void> {
|
||||
if (!atflId) return;
|
||||
const headers: Record<string, string> = {
|
||||
[CLAIM_NAME]: getCookie(CLAIM_NAME) ?? "",
|
||||
[AUTH_USER_ID]: getCookie(AUTH_USER_ID) ?? "",
|
||||
};
|
||||
const url = `${PROXY_HOST}${ENDPOINT.ATTACH_SERVICE.ATTACHMENTS}/${atflId}`;
|
||||
await axios.delete(url, { headers });
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/* Shared file utilities for apps/as */
|
||||
|
||||
/** 파일 크기는 byte 단위로 제공된다고 가정하고 크기에 따라서 KB, MB 단위로 표시 */
|
||||
export const formatFileSize = (bytes?: number | null): string => {
|
||||
if (bytes === null || bytes === undefined) return "-";
|
||||
const b = Number(bytes) || 0;
|
||||
if (b >= 1024 * 1024) return `${(b / 1024 / 1024).toFixed(1)}MB`;
|
||||
if (b >= 1024) return `${Math.round(b / 1024)}KB`;
|
||||
return `${b}B`;
|
||||
};
|
||||
|
||||
export default { formatFileSize };
|
||||
@@ -33,6 +33,16 @@ export interface IUserInfo {
|
||||
naverId: string;
|
||||
isSocialUser: boolean;
|
||||
hasPassword: boolean;
|
||||
aprvYmd: string;
|
||||
enoCd: string;
|
||||
instSeCd: string;
|
||||
dtlInstCd: string;
|
||||
laeSeNm: string;
|
||||
ogdpSeNm: string;
|
||||
instSeNm: string;
|
||||
userStateNm: string;
|
||||
enoNm: string;
|
||||
dtlInstNm: string;
|
||||
}
|
||||
|
||||
export type UserState = {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './ui';
|
||||
@@ -0,0 +1,71 @@
|
||||
import React from "react";
|
||||
|
||||
type EtcFile = {
|
||||
id: string;
|
||||
name: string;
|
||||
ext: string;
|
||||
size: string;
|
||||
uploader: string;
|
||||
atflCd: string;
|
||||
atflId: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
etcFiles: EtcFile[];
|
||||
etcUploading: boolean;
|
||||
onFilesChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onDelete: (fileId: string, atflId: string) => void;
|
||||
};
|
||||
|
||||
export function EtcAttachments({ etcFiles, etcUploading, onFilesChange, onDelete }: Props) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 flex flex-nowrap items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
className="cnfm-input min-w-0 flex-1"
|
||||
placeholder="첨부문서를 등록해주세요"
|
||||
value={
|
||||
etcUploading ? "업로드 중..." : etcFiles.length > 0 ? `${etcFiles.length}개 파일 등록됨` : ""
|
||||
}
|
||||
readOnly
|
||||
/>
|
||||
<label className="cnfm-btn-search shrink-0 cursor-pointer whitespace-nowrap text-[1.2rem] flex items-center">
|
||||
{etcUploading ? "업로드중" : "첨부파일"}
|
||||
<input type="file" className="hidden" multiple onChange={onFilesChange} disabled={etcUploading} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="cnfm-table-wrap">
|
||||
<table className="cnfm-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>파일명</th>
|
||||
<th>확장자</th>
|
||||
<th>크기</th>
|
||||
<th>등록자</th>
|
||||
<th>액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{etcFiles.map((f) => (
|
||||
<tr key={f.id}>
|
||||
<td className="pr-2">{f.name}</td>
|
||||
<td className="text-center">{f.ext}</td>
|
||||
<td className="text-right">{f.size}</td>
|
||||
<td className="text-center">{f.uploader}</td>
|
||||
<td className="text-center">
|
||||
<button className="cnfm-btn-delete" onClick={() => onDelete(f.id, f.atflId)}>
|
||||
삭제
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EtcAttachments;
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
|
||||
export const NumCell = ({ value, onChange }: { value: string; onChange: (v: string) => void }) => (
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
className="cnfm-input w-full min-w-0 text-right"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
|
||||
export const ReadCell = ({ value }: { value: string | number }) => (
|
||||
<td className="bg-gray-50 pr-2 text-right text-[1.25rem] font-semibold">{value}</td>
|
||||
);
|
||||
|
||||
export default { NumCell, ReadCell };
|
||||
@@ -0,0 +1,3 @@
|
||||
export { NumCell, ReadCell } from './cells';
|
||||
export { PrivacyConsent } from './privacy-consent';
|
||||
export { EtcAttachments } from './attachments';
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
value: "Y" | "N";
|
||||
onChange: (v: "Y" | "N") => void;
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function PrivacyConsent({ value, onChange, children }: Props) {
|
||||
return (
|
||||
<div className="cnfm-card p-4">
|
||||
<h3 className="mb-3 border-b pb-2 text-[1.5rem] font-bold text-gray-700">개인정보 동의</h3>
|
||||
<div className="mb-3 rounded border bg-gray-50 p-3 text-[1.25rem] leading-relaxed text-gray-600 max-h-80 overflow-auto whitespace-pre-wrap pr-2">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6 text-[1.3rem]">
|
||||
<label className="flex cursor-pointer items-center gap-2 font-semibold">
|
||||
<input type="radio" name="privacyAgree" value="Y" checked={value === "Y"} onChange={() => onChange("Y")} />
|
||||
예 (동의)
|
||||
</label>
|
||||
<label className="flex cursor-pointer items-center gap-2">
|
||||
<input type="radio" name="privacyAgree" value="N" checked={value === "N"} onChange={() => onChange("N")} />
|
||||
아니오 (미동의)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PrivacyConsent;
|
||||
@@ -0,0 +1,60 @@
|
||||
# ST 앱 총평 (Architecture Overview)
|
||||
|
||||
> 하수도 통계 관리 시스템 (ST: Statistics) 프론트엔드 앱의 전체 구조 및 기술 평가 리포트
|
||||
|
||||
---
|
||||
|
||||
## 1. 앱 개요
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **앱 이름** | `st` (Statistics) |
|
||||
| **목적** | 하수도 통계 관리 시스템 - 통계 입력, 검토, 승인, 관리 기능 제공 |
|
||||
| **프레임워크** | Next.js 15 (App Router) |
|
||||
| **언어** | TypeScript |
|
||||
| **패키지 관리** | pnpm Workspace (모노레포 구조) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 전체 평가 요약
|
||||
|
||||
### ✅ 강점
|
||||
|
||||
1. **FSD(Feature-Sliced Design) 아키텍처 도입**
|
||||
`shared → entities → components → pages → app` 레이어 계층 구조를 적용하여 관심사 분리가 명확합니다.
|
||||
|
||||
2. **서버 API 프록시 패턴 (Middleware)**
|
||||
Next.js Middleware를 활용해 백엔드 API 호출을 프록시 처리하므로, 클라이언트에서 직접 API 서버를 노출하지 않는 보안 구조를 갖추고 있습니다.
|
||||
|
||||
3. **멀티 백엔드 분리 구조**
|
||||
일반 서버 API(`/server/*`)와 ST 전용 서비스(`st-management-service`, `st-input-service`) 경로를 분리하여 URL 기반 라우팅으로 독립성을 확보하였습니다.
|
||||
|
||||
4. **글로벌 상태 관리 일관성**
|
||||
Zustand를 통한 전역 상태(사용자 정보, 메뉴, 알림, 탭)와 TanStack Query/SWR을 활용한 서버 상태 관리를 목적에 맞게 분리하였습니다.
|
||||
|
||||
5. **재사용 가능한 공통 컴포넌트**
|
||||
`DataGrid`, `SearchForm`, `ApproveButton`, `RejectButton` 등 업무 공통 컴포넌트를 `components/module`에 집중하여 중복 개발을 최소화하였습니다.
|
||||
|
||||
### ⚠️ 개선 권고 사항
|
||||
|
||||
1. **데이터 페칭 라이브러리 혼용**
|
||||
SWR과 TanStack Query를 동시에 사용하고 있습니다. 용도 기준이 명확하지 않으면 유지보수 시 혼란이 생길 수 있습니다. 가급적 하나의 라이브러리로 통일하거나, 사용 기준을 문서화하는 것을 권장합니다.
|
||||
|
||||
2. **미구현 업무 함수**
|
||||
일부 컴포넌트에서 `rejectFunc`, `approveFunc` 등의 함수가 빈 상태로 남아 있습니다. 서비스 커버리지 확인이 필요합니다.
|
||||
|
||||
3. **`src/store` 와 `src/shared/store` 이중 스토어 경로**
|
||||
탭 관련 스토어(`tab-store`)가 `src/store`에, 그 외 공통 스토어는 `src/shared/store`에 나뉘어 있습니다. FSD 원칙상 `src/shared/store`로 통일하는 것을 권장합니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 문서 목차
|
||||
|
||||
| 문서 | 내용 |
|
||||
|------|------|
|
||||
| [02.디렉토리구조.md](./02.디렉토리구조.md) | 전체 소스 폴더 구조 설명 |
|
||||
| [03.기술스택.md](./03.기술스택.md) | 사용 라이브러리 및 기술 상세 |
|
||||
| [04.아키텍처패턴.md](./04.아키텍처패턴.md) | FSD 아키텍처 구조 및 데이터 흐름 |
|
||||
| [05.상태관리.md](./05.상태관리.md) | Zustand 전역 상태 관리 패턴 |
|
||||
| [06.데이터페칭.md](./06.데이터페칭.md) | SWR / TanStack Query 데이터 페칭 패턴 |
|
||||
| [07.용어정리.md](./07.용어정리.md) | 프로젝트 주요 용어 정의 |
|
||||
@@ -0,0 +1,157 @@
|
||||
# 02. 디렉토리 구조
|
||||
|
||||
ST 앱의 전체 폴더 구조와 각 디렉토리의 역할을 설명합니다.
|
||||
|
||||
---
|
||||
|
||||
## 루트 디렉토리 구조
|
||||
|
||||
```
|
||||
apps/st/
|
||||
├── app/ # Next.js App Router 라우팅 루트
|
||||
├── pages/ # Next.js Pages Router (일부 페이지)
|
||||
├── src/ # 소스 코드 메인 디렉토리
|
||||
├── public/ # 정적 파일 (이미지, 아이콘 등)
|
||||
├── docs/ # 앱 내부 문서
|
||||
├── middleware.ts # Next.js 미들웨어 (API 프록시, 인증)
|
||||
├── next.config.ts # Next.js 설정 파일
|
||||
├── tailwind.config.ts # Tailwind CSS 설정
|
||||
├── tsconfig.json # TypeScript 설정
|
||||
└── package.json # 의존성 관리
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `app/` - App Router 라우팅
|
||||
|
||||
```
|
||||
app/
|
||||
├── (with-base-layout)/ # 공통 레이아웃을 공유하는 라우트 그룹
|
||||
├── api/ # Next.js API Route Handler
|
||||
├── auth/ # 인증 관련 페이지 (로그인 등)
|
||||
└── layout.tsx # 앱 전체 루트 레이아웃
|
||||
```
|
||||
|
||||
> **참고**: Next.js의 라우트 그룹 `(with-base-layout)`은 URL에는 영향을 주지 않고 레이아웃만 공유하는 방식입니다.
|
||||
|
||||
---
|
||||
|
||||
## `src/` - 소스 코드 (FSD 구조)
|
||||
|
||||
FSD(Feature-Sliced Design) 아키텍처 원칙에 따라 레이어 계층이 구분되어 있습니다.
|
||||
|
||||
```
|
||||
src/
|
||||
├── shared/ # 레이어 1: 공통 기반 (어디서나 재사용 가능)
|
||||
├── entities/ # 레이어 2: 비즈니스 도메인 엔티티
|
||||
├── components/ # 레이어 3: UI 컴포넌트 (비즈니스 UI)
|
||||
├── pages/ # 레이어 4: 페이지 컴포넌트
|
||||
├── store/ # 앱 레벨 전역 상태 (tab 관리)
|
||||
└── app/ # 앱 초기화 / 프로바이더 구성
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `src/shared/` - 공통 기반 레이어
|
||||
|
||||
```
|
||||
shared/
|
||||
├── api/
|
||||
│ └── client.ts # Axios HTTP 클라이언트 인스턴스 (일반/ST서비스 분리)
|
||||
├── config/
|
||||
│ ├── api.ts # API 엔드포인트 URL 상수
|
||||
│ └── env.ts # 환경변수 로드 및 타입 관리
|
||||
├── lib/
|
||||
│ ├── use-mutation-wrapper.tsx # TanStack Query useMutation 래퍼 (에러 공통 처리)
|
||||
│ ├── use-alert-store.ts # Alert 전역 상태 훅
|
||||
│ ├── use-user-store.ts # 사용자 정보 전역 상태 훅
|
||||
│ ├── use-menu-store.ts # 메뉴 전역 상태 훅
|
||||
│ ├── use-attachment-store.ts # 첨부파일 전역 상태 훅
|
||||
│ ├── use-mutation-wrapper.tsx # Mutation 공통 에러 처리 래퍼
|
||||
│ ├── use-tus.ts # 대용량 파일 업로드 (TUS 프로토콜)
|
||||
│ ├── cookie.ts # 쿠키 유틸리티
|
||||
│ ├── match-query-status.ts # SWR/Query 상태 매핑 유틸
|
||||
│ └── csp-monitor.ts # CSP 보안 정책 모니터링
|
||||
├── model/ # 공통 타입 정의
|
||||
├── providers/
|
||||
│ ├── store-provider.tsx # Zustand 스토어 Provider
|
||||
│ └── prompt-provider.tsx # 모달 프롬프트 Provider
|
||||
├── store/
|
||||
│ ├── alert-store.ts # 알림(Alert) 전역 상태
|
||||
│ ├── user-store.ts # 사용자 정보 전역 상태
|
||||
│ ├── menu-store.ts # 메뉴/권한 전역 상태
|
||||
│ └── attachment-store.ts # 첨부파일 전역 상태
|
||||
└── ui/ # 공통 UI 원시 컴포넌트
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `src/entities/` - 비즈니스 도메인 레이어
|
||||
|
||||
각 도메인 별로 `api/`(서비스 호출)과 `model/`(타입 정의)로 구성됩니다.
|
||||
|
||||
```
|
||||
entities/
|
||||
├── auth/ # 인증 도메인
|
||||
├── code/ # 공통 코드(코드 관리) 도메인
|
||||
├── menu/ # 메뉴 도메인
|
||||
├── menu-authority/ # 메뉴 권한 도메인
|
||||
├── statistics/ # 통계 도메인
|
||||
│ ├── api/
|
||||
│ │ └── statistics-input-status-service.ts # 통계 입력 현황 API 서비스
|
||||
│ └── model/
|
||||
│ └── statistics-input-status-interface.ts # 요청/응답 타입 정의
|
||||
└── user/ # 사용자 도메인
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `src/components/` - UI 컴포넌트 레이어
|
||||
|
||||
업무 화면을 구성하는 UI 컴포넌트들이 위치합니다.
|
||||
|
||||
```
|
||||
components/
|
||||
├── module/ # 재사용 공통 업무 컴포넌트
|
||||
│ ├── DataGrid.tsx # RealGrid 기반 메인 데이터 그리드 (57KB, 핵심 컴포넌트)
|
||||
│ ├── TabDataGrid.tsx # 탭 형태 데이터 그리드
|
||||
│ ├── CompactDataGrid.tsx # 압축형 데이터 그리드
|
||||
│ ├── SearchForm.tsx # 공통 검색 폼
|
||||
│ └── SplitDataGrid.tsx # 분할형 데이터 그리드
|
||||
├── button/ # 공통 버튼 컴포넌트
|
||||
│ ├── ApproveButton.tsx # 승인 버튼
|
||||
│ └── RejectButton.tsx # 반려 버튼
|
||||
├── statistics/ # 하수도 통계 관련 화면 컴포넌트
|
||||
│ ├── input/ # 통계 입력 관련 화면
|
||||
│ ├── management/ # 통계 관리 화면
|
||||
│ ├── review/ # 통계 검토 화면
|
||||
│ ├── total/ # 통계 합계 화면
|
||||
│ └── verification/ # 통계 검증 화면
|
||||
├── type/ # 컴포넌트 타입 정의
|
||||
├── util/ # 컴포넌트 유틸리티
|
||||
│ ├── column/ # DataGrid 컬럼 정의 모음
|
||||
│ └── grid/ # 그리드 관련 유틸 함수
|
||||
├── css/ # 컴포넌트 전용 CSS
|
||||
└── Dashboard.tsx # 대시보드 컴포넌트
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `src/store/` - 앱 레벨 전역 상태
|
||||
|
||||
```
|
||||
store/
|
||||
├── tab-store.ts # 탭 UI 관리 (탭 목록, 현재 활성 탭)
|
||||
└── admin-store.ts # 관리자 관련 상태
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 주요 개별 파일
|
||||
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `middleware.ts` | API 프록시, JWT 토큰 헤더 주입, CSP 보안 헤더 설정 |
|
||||
| `next.config.ts` | 서브 도메인 리라이트, CSP 헤더 설정 |
|
||||
| `tailwind.config.ts` | Tailwind CSS 커스텀 테마 |
|
||||
| `.env.example` | 환경변수 예시 파일 |
|
||||
@@ -0,0 +1,134 @@
|
||||
# 03. 기술 스택
|
||||
|
||||
ST 앱에서 사용하는 라이브러리 및 기술 목록과 각각의 역할을 정리합니다.
|
||||
|
||||
---
|
||||
|
||||
## 핵심 프레임워크
|
||||
|
||||
| 라이브러리 | 버전 | 역할 |
|
||||
|-----------|------|------|
|
||||
| **Next.js** | 15.3.8 | React 기반 풀스택 프레임워크. App Router + Pages Router 혼용. SSR, 라우팅, API Route, Middleware 제공 |
|
||||
| **React** | (Next.js 내장) | UI 컴포넌트 렌더링 기반 |
|
||||
| **TypeScript** | ^5 | 정적 타입 시스템으로 코드 안정성 확보 |
|
||||
|
||||
---
|
||||
|
||||
## 상태 관리
|
||||
|
||||
| 라이브러리 | 버전 | 역할 |
|
||||
|-----------|------|------|
|
||||
| **Zustand** | ^5.0.3 | 전역 클라이언트 상태 관리 (사용자 정보, 메뉴, 탭, 알림 등) |
|
||||
| **SWR** | ^2.3.7 | 서버 상태 데이터 페칭 및 캐싱 (주로 조회성 데이터) |
|
||||
| **@tanstack/react-query** | ^5.67.1 | 서버 상태 mutation(생성/수정/삭제) 처리, devtools 포함 |
|
||||
|
||||
---
|
||||
|
||||
## 폼 및 유효성 검증
|
||||
|
||||
| 라이브러리 | 버전 | 역할 |
|
||||
|-----------|------|------|
|
||||
| **react-hook-form** | ^7.54.2 | 성능 최적화된 폼 상태 관리 |
|
||||
| **@hookform/resolvers** | ^4.1.3 | react-hook-form과 Zod 연결 어댑터 |
|
||||
| **zod** | ^3.24.2 | 스키마 기반 런타임 유효성 검증 및 타입 추론 |
|
||||
|
||||
---
|
||||
|
||||
## HTTP 통신
|
||||
|
||||
| 라이브러리 | 버전 | 역할 |
|
||||
|-----------|------|------|
|
||||
| **axios** | ^1.8.1 | Promise 기반 HTTP 클라이언트. 인터셉터를 통한 토큰 자동 주입 |
|
||||
| **tus-js-client** | ^4.3.1 | 대용량 파일 업로드 (TUS 프로토콜 - 재개 가능한 업로드) |
|
||||
|
||||
---
|
||||
|
||||
## UI 컴포넌트 & 스타일링
|
||||
|
||||
| 라이브러리 | 버전 | 역할 |
|
||||
|-----------|------|------|
|
||||
| **Tailwind CSS** | ^3.4.1 | 유틸리티 기반 CSS 프레임워크 |
|
||||
| **tailwindcss-animate** | ^1.0.7 | Tailwind CSS 애니메이션 플러그인 |
|
||||
| **@radix-ui/react-label** | ^2.1.2 | 접근성을 고려한 Label 원시 컴포넌트 |
|
||||
| **@radix-ui/react-slot** | ^1.1.2 | Slot 기반 컴포넌트 합성 패턴 |
|
||||
| **@radix-ui/react-tooltip** | ^1.1.8 | 접근성 있는 툴팁 컴포넌트 |
|
||||
| **@radix-ui/react-icons** | ^1.3.2 | Radix UI 아이콘 세트 |
|
||||
| **lucide-react** | ^0.474.0 | 오픈소스 SVG 아이콘 라이브러리 |
|
||||
| **remixicon** | ^4.7.0 | 리믹스 아이콘 세트 |
|
||||
| **next-themes** | ^0.4.6 | 다크모드/라이트모드 테마 전환 |
|
||||
| **sonner** | ^2.0.1 | 경량 토스트(Toast) 알림 컴포넌트 |
|
||||
|
||||
---
|
||||
|
||||
## 데이터 그리드
|
||||
|
||||
| 라이브러리 | 버전 | 역할 |
|
||||
|-----------|------|------|
|
||||
| **realgrid** | ^2.9.5 | 대용량 데이터 처리에 특화된 엔터프라이즈 그리드 라이브러리 |
|
||||
| **realgrid-react** | ^0.9.7 | RealGrid React 바인딩 |
|
||||
| **@tanstack/react-table** | ^8.21.2 | 헤드리스 테이블 라이브러리 (일부 보조 테이블에서 사용) |
|
||||
|
||||
---
|
||||
|
||||
## 차트 & 시각화
|
||||
|
||||
| 라이브러리 | 버전 | 역할 |
|
||||
|-----------|------|------|
|
||||
| **chart.js** | ^4.4.8 | 기반 차트 라이브러리 |
|
||||
| **react-chartjs-2** | ^5.3.0 | Chart.js의 React 컴포넌트 래퍼 |
|
||||
| **chartjs-plugin-zoom** | ^2.2.0 | 차트 줌/팬 기능 플러그인 |
|
||||
|
||||
---
|
||||
|
||||
## 날짜 & 유틸리티
|
||||
|
||||
| 라이브러리 | 버전 | 역할 |
|
||||
|-----------|------|------|
|
||||
| **dayjs** | ^1.11.13 | 경량 날짜 처리 라이브러리 |
|
||||
| **js-cookie** | ^3.0.5 | 쿠키 읽기/쓰기 유틸리티 |
|
||||
| **jszip** | ^3.10.1 | ZIP 파일 생성/압축 |
|
||||
| **xlsx** | ^0.18.5 | 엑셀 파일 읽기/쓰기 |
|
||||
|
||||
---
|
||||
|
||||
## 캘린더 & 드래그앤드롭
|
||||
|
||||
| 라이브러리 | 버전 | 역할 |
|
||||
|-----------|------|------|
|
||||
| **@fullcalendar/react** | ^6.1.17 | 풀칼린더 React 컴포넌트 (일정/달력 UI) |
|
||||
| **@fullcalendar/daygrid** | ^6.1.17 | 월별 달력 뷰 |
|
||||
| **@fullcalendar/timegrid** | ^6.1.17 | 시간별 달력 뷰 |
|
||||
| **react-dnd** | ^16.0.1 | 드래그 앤 드롭 기능 |
|
||||
| **@minoru/react-dnd-treeview** | 3.5.1-alpha.1 | 트리 뷰 DnD 컴포넌트 |
|
||||
|
||||
---
|
||||
|
||||
## 에디터 & 기타
|
||||
|
||||
| 라이브러리 | 버전 | 역할 |
|
||||
|-----------|------|------|
|
||||
| **@tinymce/tinymce-react** | ^5.1.1 | 리치 텍스트 에디터 (게시판 등) |
|
||||
| **next-qrcode** | ^2.5.1 | QR 코드 생성 |
|
||||
| **@faker-js/faker** | ^9.9.0 | 테스트용 가짜 데이터 생성 |
|
||||
|
||||
---
|
||||
|
||||
## 공유 패키지 (모노레포 내부)
|
||||
|
||||
| 패키지 | 설명 |
|
||||
|-------|------|
|
||||
| `@common/assets` | 공통 이미지/아이콘 에셋 |
|
||||
| `@common/components` | 공통 React 컴포넌트 (LoadingWithAnimation 등) |
|
||||
| `@common/config` | 공통 설정값 |
|
||||
| `@rsone/components` | 내부 공통 DS(Design System) 컴포넌트 |
|
||||
|
||||
---
|
||||
|
||||
## 개발 도구
|
||||
|
||||
| 도구 | 역할 |
|
||||
|------|------|
|
||||
| **steiger** | FSD 아키텍처 규칙 검사 도구 |
|
||||
| **prettier** | 코드 포맷터 |
|
||||
| **ESLint** | 코드 품질 검사 |
|
||||
| **@tanstack/react-query-devtools** | TanStack Query 개발자 도구 |
|
||||
@@ -0,0 +1,155 @@
|
||||
# 04. 아키텍처 패턴
|
||||
|
||||
ST 앱이 채택한 FSD(Feature-Sliced Design) 아키텍처 및 전체 데이터 흐름을 설명합니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. FSD(Feature-Sliced Design) 아키텍처
|
||||
|
||||
FSD는 레이어(Layer) 계층 간 단방향 의존성을 강제하는 프론트엔드 아키텍처 방법론입니다.
|
||||
**"하위 레이어는 상위 레이어를 참조할 수 없다"** 는 규칙이 핵심입니다.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ app / pages (라우팅) │ ← 최상위 (조합)
|
||||
├─────────────────────────────────────────┤
|
||||
│ components (업무 UI) │
|
||||
├─────────────────────────────────────────┤
|
||||
│ entities (도메인 엔티티) │ ← API 서비스 + 타입
|
||||
├─────────────────────────────────────────┤
|
||||
│ shared (공통 기반) │ ← 최하위 (재사용)
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
각 레이어는 내부적으로 슬라이스(Slice)와 세그먼트(Segment)로 세분화됩니다.
|
||||
|
||||
```
|
||||
레이어/슬라이스/세그먼트
|
||||
예시: entities/statistics/api/
|
||||
entities/statistics/model/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 레이어별 역할과 의존성 방향
|
||||
|
||||
### 🔷 `shared` - 공통 기반 레이어
|
||||
- **역할**: 모든 레이어에서 공유 가능한 순수 유틸리티, API 클라이언트, 타입, 훅, 스토어 정의
|
||||
- **의존성**: 다른 레이어를 참조하지 않음 (최하위)
|
||||
- **주요 내용**: `client.ts`, `use-mutation-wrapper`, Zustand 스토어 정의, 환경변수 설정
|
||||
|
||||
### 🔶 `entities` - 비즈니스 도메인 레이어
|
||||
- **역할**: 하나의 비즈니스 도메인(auth, menu, statistics 등)을 API 서비스와 타입으로 캡슐화
|
||||
- **의존성**: `shared`만 참조 가능
|
||||
- **주요 내용**: API 서비스 함수, 요청/응답 인터페이스 타입
|
||||
|
||||
### 🟩 `components` - UI 컴포넌트 레이어
|
||||
- **역할**: 업무 화면을 구성하는 React 컴포넌트. 서버 데이터 페칭 로직 포함
|
||||
- **의존성**: `shared`, `entities` 참조 가능
|
||||
- **주요 내용**: 화면별 컴포넌트, module 공통 컴포넌트(DataGrid, SearchForm 등)
|
||||
|
||||
### 🟦 `app / pages` - 라우팅 레이어
|
||||
- **역할**: Next.js 라우팅 연결. 컴포넌트를 조합하여 최종 페이지 구성
|
||||
- **의존성**: 모든 하위 레이어 참조 가능
|
||||
|
||||
---
|
||||
|
||||
## 3. 미들웨어 기반 API 프록시 아키텍처
|
||||
|
||||
클라이언트가 백엔드 API를 직접 호출하지 않고 **Next.js Middleware를 통해 프록시**합니다.
|
||||
|
||||
```
|
||||
Browser
|
||||
│
|
||||
│ 요청: /server/st-management-service/...
|
||||
▼
|
||||
Next.js Middleware (middleware.ts)
|
||||
│
|
||||
├─ /server/st-management-service/* → ST 전용 백엔드 서버
|
||||
├─ /server/st-input-service/* → ST 입력 서비스 서버
|
||||
├─ /server/* → 일반 백엔드 서버 (Spring Boot 등)
|
||||
├─ /attach-service/* → 파일 첨부 서비스
|
||||
└─ /api/link/* → 외부 링크 백엔드 API
|
||||
```
|
||||
|
||||
**핵심 동작:**
|
||||
1. 요청 URL 경로(`pathname`)를 패턴 매칭
|
||||
2. 쿠키에서 JWT 토큰(`CLAIM_NAME`)과 사용자 ID(`AUTH_USER_ID`) 추출
|
||||
3. 해당 값을 요청 헤더에 주입 후 실제 백엔드로 URL 재작성(`NextResponse.rewrite`)
|
||||
4. 응답에 CSP 보안 헤더 추가
|
||||
|
||||
---
|
||||
|
||||
## 4. 멀티 백엔드 분리 구조
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ ST 프론트엔드 앱 │
|
||||
│ │
|
||||
│ src/shared/api/client.ts │
|
||||
│ ┌──────────────────┐ ┌──────────────────────────┐ │
|
||||
│ │ client │ │ stClient │ │
|
||||
│ │ (일반 API) │ │ (ST 서비스 전용 API) │ │
|
||||
│ └──────────────────┘ └──────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
일반 백엔드 (Spring) ST 전용 백엔드 서비스
|
||||
BASE_URL ST_SERVICE_BASE_URL
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 컴포넌트 데이터 흐름
|
||||
|
||||
통계 화면을 예시로 데이터 흐름을 설명합니다.
|
||||
|
||||
```
|
||||
app/(with-base-layout)/... ← 라우팅 진입
|
||||
│
|
||||
▼
|
||||
components/statistics/input/
|
||||
StatsInputStatusInp.tsx ← 페이지 컴포넌트
|
||||
│
|
||||
├── [검색 이벤트] handleSearch()
|
||||
│ │
|
||||
│ ▼
|
||||
│ entities/statistics/api/
|
||||
│ statistics-input-status-service.ts ← API 서비스
|
||||
│ │
|
||||
│ ▼
|
||||
│ shared/api/client.ts (stClient) ← Axios 인스턴스
|
||||
│ │
|
||||
│ ▼
|
||||
│ middleware.ts ← Next.js 프록시
|
||||
│ │
|
||||
│ ▼
|
||||
│ ST 백엔드 API 서버
|
||||
│
|
||||
├── [상태 관리] useSWR / useTabStore / useAlertStore
|
||||
│
|
||||
└── [렌더링] SearchForm + DataGrid(RealGrid) + 버튼
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 탭 기반 네비게이션 패턴
|
||||
|
||||
ST 앱은 메뉴를 클릭하면 새 페이지로 이동하는 것이 아니라, **탭(Tab)을 추가하는 방식**으로 내비게이션이 동작합니다.
|
||||
|
||||
```
|
||||
메뉴 클릭
|
||||
│
|
||||
▼
|
||||
useTabStore.addTab()
|
||||
│
|
||||
├── 이미 열린 탭이면 → 해당 탭 활성화 전환만
|
||||
└── 새 탭이면 → 탭 목록에 추가 (최대 10개 제한)
|
||||
│
|
||||
▼
|
||||
activeTabId 변경
|
||||
│
|
||||
▼
|
||||
각 화면 컴포넌트:
|
||||
useEffect([activeTabId]) → 탭 포커스 감지 → mutate() 데이터 갱신
|
||||
```
|
||||
@@ -0,0 +1,138 @@
|
||||
# 05. 상태 관리
|
||||
|
||||
ST 앱의 상태 관리 전략과 Zustand 기반 전역 상태 설계를 설명합니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 상태 관리 전략 개요
|
||||
|
||||
ST 앱은 상태의 **성격(클라이언트 vs 서버)**에 따라 다른 도구를 사용합니다.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 상태 관리 분리 │
|
||||
├────────────────────────┬────────────────────────────────┤
|
||||
│ 클라이언트 상태 │ 서버 상태 │
|
||||
│ (UI, 세션 데이터) │ (API 응답 데이터) │
|
||||
│ │ │
|
||||
│ ✅ Zustand │ ✅ SWR (조회) │
|
||||
│ │ ✅ TanStack Query (변경) │
|
||||
└────────────────────────┴────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Zustand - 전역 클라이언트 상태
|
||||
|
||||
Zustand는 보일러플레이트가 없는 경량 전역 상태 관리 라이브러리입니다.
|
||||
|
||||
### 스토어 목록
|
||||
|
||||
| 스토어 파일 | 관리 상태 | 주요 위치 |
|
||||
|------------|---------|---------|
|
||||
| `user-store.ts` | 로그인 사용자 정보 (이름, ID 등) | `shared/store/` |
|
||||
| `menu-store.ts` | 현재 메뉴 정보, BBS 설정, 페이지 권한 | `shared/store/` |
|
||||
| `alert-store.ts` | 전역 알림(Alert) 메시지 | `shared/store/` |
|
||||
| `attachment-store.ts` | 첨부파일 상태 | `shared/store/` |
|
||||
| `tab-store.ts` | 탭 목록 및 현재 활성 탭 | `store/` |
|
||||
| `admin-store.ts` | 관리자 관련 상태 | `store/` |
|
||||
|
||||
---
|
||||
|
||||
### 2-1. 스토어 생성 패턴
|
||||
|
||||
**Vanilla 스토어 패턴** (`shared/store/` 내 스토어들):
|
||||
|
||||
```typescript
|
||||
// shared/store/menu-store.ts
|
||||
import { createStore } from "zustand/vanilla";
|
||||
|
||||
export const createMenuStore = (initState = defaultInitState) => {
|
||||
return createStore<MenuStore>()((set) => ({
|
||||
...initState,
|
||||
setCurrentMenuInfo: (menu) => set({ currentMenuInfo: menu }),
|
||||
// ...
|
||||
}));
|
||||
};
|
||||
```
|
||||
|
||||
- `zustand/vanilla`를 사용하여 React에 종속되지 않는 순수 스토어를 생성
|
||||
- `store-provider.tsx`에서 React Context와 연결하여 컴포넌트에서 사용
|
||||
|
||||
**React 스토어 패턴** (`store/tab-store.ts`):
|
||||
|
||||
```typescript
|
||||
// store/tab-store.ts
|
||||
import { create } from "zustand";
|
||||
|
||||
const useTabStoreHook = create<TabStore>((set, get) => ({
|
||||
tabs: [],
|
||||
activeTabId: null,
|
||||
addTab: (menuItem) => { /* ... */ },
|
||||
removeTab: (tabId) => { /* ... */ },
|
||||
}));
|
||||
|
||||
export const useTabStore = useTabStoreHook;
|
||||
```
|
||||
|
||||
- `create`를 사용하여 직접 React Hook 형태로 사용
|
||||
|
||||
|
||||
---
|
||||
|
||||
### 2-2. 스토어 사용 패턴
|
||||
|
||||
```typescript
|
||||
// 컴포넌트에서 사용
|
||||
const { setMessage: alert } = useAlertStore((state) => state);
|
||||
const activeTabId = useTabStore((state) => state.activeTabId);
|
||||
const userInfo = useUserStore((state) => state.userInfo);
|
||||
```
|
||||
|
||||
> `(state) => state.xxx` 셀렉터 패턴을 사용하여 필요한 상태만 구독하고, 불필요한 리렌더링을 방지합니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. tab-store 설계 - 탭 UI 관리
|
||||
|
||||
탭 기반 내비게이션의 핵심 스토어입니다.
|
||||
|
||||
### 주요 상태
|
||||
|
||||
| 상태 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| `tabs` | `Tab[]` | 현재 열린 탭 목록 |
|
||||
| `activeTabId` | `string \| null` | 현재 활성화된 탭 ID |
|
||||
| `menuList` | `MenuItem[] \| null` | 전체 메뉴 목록 |
|
||||
|
||||
### 주요 액션
|
||||
|
||||
| 액션 | 설명 |
|
||||
|------|------|
|
||||
| `addTab(menuItem)` | 탭 추가 (이미 있으면 활성화, 최대 10개 제한) |
|
||||
| `removeTab(tabId)` | 탭 닫기 (이전/다음 탭으로 자동 전환) |
|
||||
| `setActiveTab(tabId)` | 탭 활성화 전환 |
|
||||
| `closeAllTabs()` | 모든 탭 닫기 |
|
||||
| `closeOtherTabs(tabId)` | 현재 탭 외 모두 닫기 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 화면 탭 포커스 감지 패턴
|
||||
|
||||
각 화면 컴포넌트에서 탭 전환을 감지하여 데이터를 자동 갱신하는 패턴입니다.
|
||||
|
||||
```typescript
|
||||
const activeTabId = useTabStore((state) => state.activeTabId);
|
||||
|
||||
useEffect(() => {
|
||||
// 이 컴포넌트의 탭이 활성화되면 데이터 새로고침
|
||||
if (activeTabId === 'StatsInputStatusInp' && swrKey) {
|
||||
mutate(swrKey);
|
||||
}
|
||||
}, [activeTabId]);
|
||||
```
|
||||
|
||||
**동작 원리:**
|
||||
1. `activeTabId`가 변경될 때마다 `useEffect`가 실행됨
|
||||
2. 현재 컴포넌트의 탭 ID와 일치하는지 확인
|
||||
3. 일치하면 SWR의 `mutate()`로 최신 데이터를 서버에서 다시 가져옴
|
||||
@@ -0,0 +1,152 @@
|
||||
# 06. 데이터 페칭
|
||||
|
||||
ST 앱에서 서버 데이터를 가져오고 변경하는 패턴(SWR, TanStack Query)을 설명합니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 개요 - 두 가지 라이브러리 사용 이유
|
||||
|
||||
| 구분 | 라이브러리 | 사용 목적 |
|
||||
|------|-----------|---------|
|
||||
| **조회 (Read)** | SWR | 데이터 조회, 캐싱, 자동 갱신 |
|
||||
| **변경 (Write)** | TanStack Query (`useMutation`) | 생성, 수정, 삭제 후 성공/실패 처리 |
|
||||
|
||||
---
|
||||
|
||||
## 2. SWR - 서버 데이터 조회 패턴
|
||||
|
||||
SWR은 `stale-while-revalidate` 전략을 구현한 데이터 페칭 라이브러리입니다.
|
||||
캐시된 데이터를 즉시 반환하면서 백그라운드에서 최신 데이터로 갱신합니다.
|
||||
|
||||
### 기본 사용 패턴
|
||||
|
||||
```typescript
|
||||
// 1. Fetcher 함수 정의 (key 배열을 인자로 받음)
|
||||
const getStatsInputStatusFetcher = async (
|
||||
key: [string, StatsInputStatusListRequestParams]
|
||||
): Promise<StatsInputStatusListApiResponse> => {
|
||||
return StatisticsInputStatusApi.getStatisticsInputStatusList(key[1]);
|
||||
};
|
||||
|
||||
// 2. SWR Key 정의 (null이면 데이터 페칭 중지)
|
||||
const swrKey = searchParams
|
||||
? (["stats-input-status", searchParams] as const)
|
||||
: null;
|
||||
|
||||
// 3. useSWR 훅 사용
|
||||
const { isLoading } = useSWR<StatsInputStatusListApiResponse>(
|
||||
swrKey, // 키 (null이면 페칭 안 함)
|
||||
getStatsInputStatusFetcher,
|
||||
{
|
||||
revalidateOnFocus: true, // 탭 포커스 시 자동 갱신
|
||||
onSuccess: (data) => {
|
||||
if (data.status === "success" && data.data) {
|
||||
setRowData(toGridRows(data.data));
|
||||
} else {
|
||||
alert(data.message ?? "조회에 실패했습니다.");
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
alert("데이터 조회 중 오류가 발생했습니다.");
|
||||
},
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### SWR Key 전략
|
||||
|
||||
SWR Key는 데이터의 고유 식별자입니다.
|
||||
|
||||
```typescript
|
||||
// 문자열 + 파라미터 배열 조합
|
||||
["stats-input-status", { ctpvNm: "서울", statsYr: "2024" }]
|
||||
```
|
||||
|
||||
- **배열 형태**: Key의 첫 번째 요소는 식별 문자열, 두 번째는 파라미터 객체
|
||||
- **null Key**: 페칭 일시 정지 (조건부 페칭)
|
||||
- **객체가 달라지면**: 새로운 API 호출 트리거
|
||||
|
||||
### `mutate` - 수동 캐시 무효화
|
||||
|
||||
```typescript
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
// 특정 key의 데이터를 서버에서 다시 가져옴
|
||||
mutate(["stats-input-status", params]);
|
||||
```
|
||||
|
||||
주로 두 가지 상황에서 사용합니다.
|
||||
1. **검색 버튼 클릭 시**: 새로운 파라미터로 즉시 조회
|
||||
2. **탭 포커스 시**: 화면으로 돌아왔을 때 데이터 갱신
|
||||
|
||||
---
|
||||
|
||||
## 3. TanStack Query - 데이터 변경 패턴
|
||||
|
||||
생성, 수정, 삭제 등 서버 상태를 변경하는 작업에는 `useMutation`을 사용합니다.
|
||||
|
||||
### `useMutationWrapper` - 공통 에러 처리 래퍼
|
||||
|
||||
```typescript
|
||||
// shared/lib/use-mutation-wrapper.tsx
|
||||
export const useMutationWrapper = <TData, TError, TVariables, TContext>(
|
||||
mutationOptions: UseMutationOptions<TData, TError, TVariables, TContext>
|
||||
) => {
|
||||
const alert = useAlertStore((state) => state.setMessage);
|
||||
|
||||
return useMutation({
|
||||
...mutationOptions,
|
||||
onError: (error) => {
|
||||
// AxiosError 공통 에러 메시지 처리
|
||||
if (error instanceof AxiosError) {
|
||||
if (error.response?.data?.message) errorMessage += `...`;
|
||||
}
|
||||
alert(`${errorMessage}`);
|
||||
},
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
**설계 목적**: `onError` 공통 처리를 래퍼에서 중앙화하여, 각 컴포넌트에서 Axios 에러를 반복해서 처리하지 않아도 됩니다.
|
||||
|
||||
### `mutateAsync` vs `mutate` 비교
|
||||
|
||||
```typescript
|
||||
const { mutateAsync: createPost } = useMutationWrapper({
|
||||
mutationFn: (data: PostFormData) => PostApi.createPost(data),
|
||||
onSuccess: () => {
|
||||
alert("등록되었습니다.", router.back);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
| 구분 | `mutate` | `mutateAsync` |
|
||||
|------|---------|---------------|
|
||||
| **반환값** | `void` | `Promise<TData>` |
|
||||
| **에러 처리** | `onError` 콜백만 가능 | `try/catch` 또는 `.catch()` 가능 |
|
||||
| **await 사용** | ❌ 불가 | ✅ 가능 |
|
||||
| **사용 시나리오** | 단순 저장 후 콜백 처리 | 자식 컴포넌트에 전달하거나 순서 제어 필요 시 |
|
||||
|
||||
> `mutateAsync`를 쓰는 주된 이유: `<PostForm handleSave={createPost} />`처럼 하위 컴포넌트에 실행 함수를 전달할 때, 하위 컴포넌트가 완료 시점을 `await`로 기다릴 수 있어야 하기 때문입니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 패턴 요약 - 어떤 상황에서 무엇을 쓰나?
|
||||
|
||||
```
|
||||
사용자 인터랙션 흐름
|
||||
│
|
||||
├── [데이터 조회] 검색 버튼 클릭
|
||||
│ └── setSearchParams() → SWR Key 변경 → useSWR 자동 재조회
|
||||
│ 또는
|
||||
│ mutate(swrKey) → 강제 재조회
|
||||
│
|
||||
├── [데이터 저장/수정/삭제]
|
||||
│ └── useMutationWrapper({ mutationFn: ... })
|
||||
│ └── mutateAsync() / mutate() 호출
|
||||
│ ├── onSuccess: 성공 후처리 (Alert, 화면 이동 등)
|
||||
│ └── onError: 자동 공통 에러 Alert 표시
|
||||
│
|
||||
└── [탭 전환 시 데이터 갱신]
|
||||
└── useEffect([activeTabId]) → mutate(swrKey)
|
||||
```
|
||||
@@ -0,0 +1,107 @@
|
||||
# 07. 용어 정리
|
||||
|
||||
ST 앱 소스코드 및 문서에서 자주 등장하는 주요 용어를 알파벳·가나다 순으로 정리합니다.
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처 & 패턴 용어
|
||||
|
||||
| 용어 | 설명 |
|
||||
|------|------|
|
||||
| **FSD** (Feature-Sliced Design) | 레이어(Layer) 간 단방향 의존성을 강제하는 프론트엔드 아키텍처 방법론. `shared → entities → components → pages → app` 순으로 하위 레이어만 참조 가능 |
|
||||
| **App Router** | Next.js 13+에서 도입된 파일시스템 기반 라우팅 방식. `app/` 디렉토리를 사용하며 Server Component, Layout 등 최신 기능을 지원 |
|
||||
| **Pages Router** | Next.js의 기존 라우팅 방식. `pages/` 디렉토리를 사용 |
|
||||
| **Route Group** | Next.js App Router에서 `(폴더명)` 형태로 URL 경로에는 영향 없이 레이아웃만 공유하는 폴더. 예: `(with-base-layout)` |
|
||||
| **Middleware** | Next.js에서 요청이 실제 페이지/API에 도달하기 전에 실행되는 함수. `middleware.ts` 파일에 정의. 프록시, 인증 처리 등에 활용 |
|
||||
| **CSP** (Content Security Policy) | XSS 공격을 방지하기 위한 HTTP 보안 헤더. 어떤 리소스(이미지, 스크립트 등)를 로드할 수 있는지 정의 |
|
||||
| **Monorepo** | 여러 앱과 패키지를 하나의 저장소에서 관리하는 방식. pnpm Workspace를 사용하여 `@common/*`, `@rsone/*` 등 내부 패키지를 공유 |
|
||||
|
||||
---
|
||||
|
||||
## 상태 관리 용어
|
||||
|
||||
| 용어 | 설명 |
|
||||
|------|------|
|
||||
| **Zustand** | 경량 전역 상태 관리 라이브러리. Redux보다 보일러플레이트가 적고, 훅 기반으로 사용이 간편함 |
|
||||
| **Store** | Zustand에서 상태(State)와 액션(Action)을 담고 있는 단위. `create()` 또는 `createStore()`로 생성 |
|
||||
| **Selector** | `useStore((state) => state.xxx)` 패턴으로 스토어에서 필요한 값만 구독하는 함수. 불필요한 리렌더링을 방지 |
|
||||
| **Vanilla Store** | `zustand/vanilla`의 `createStore()`로 생성한 React에 무관한 순수 스토어. Provider 패턴과 함께 사용 |
|
||||
| **activeTabId** | 현재 활성화(포커스)된 탭의 고유 ID. 각 화면 컴포넌트는 이 값을 감지하여 데이터를 자동 갱신함 |
|
||||
| **Tab** | 탭 UI의 단위 객체. `id`(탭 ID), `label`(탭명), `urlPath`(URL), `menuItem`(메뉴 정보) 를 포함 |
|
||||
|
||||
---
|
||||
|
||||
## 데이터 페칭 용어
|
||||
|
||||
| 용어 | 설명 |
|
||||
|------|------|
|
||||
| **SWR** (Stale-While-Revalidate) | 캐시된 데이터를 즉시 반환하면서 백그라운드로 서버 데이터를 갱신하는 전략. 데이터 조회에 사용 |
|
||||
| **SWR Key** | SWR에서 데이터를 식별하고 캐싱하는 고유 키. `null`이면 페칭이 일시 중지됨. 보통 `[식별문자열, 파라미터]` 배열 형태 사용 |
|
||||
| **mutate** (SWR) | SWR 캐시를 수동으로 무효화하고 서버에서 최신 데이터를 즉시 가져오는 함수 |
|
||||
| **TanStack Query** | 서버 상태(비동기 데이터) 관리 라이브러리. 구 이름은 React Query. 캐싱, 리트라이, 데이터 동기화 등을 지원 |
|
||||
| **useMutation** | TanStack Query에서 데이터 생성/수정/삭제(Write) 작업에 사용하는 훅. `mutate`, `mutateAsync` 함수를 반환 |
|
||||
| **mutate** (TanStack Query) | `useMutation`이 반환하는 실행 함수. 반환값이 `void`이며, 결과는 콜백(`onSuccess`, `onError`)으로만 처리 가능 |
|
||||
| **mutateAsync** | `useMutation`이 반환하는 비동기 실행 함수. `Promise`를 반환하므로 `await`로 결과를 기다리거나 `try/catch`로 에러 처리 가능 |
|
||||
| **Fetcher** | SWR에서 데이터를 가져오는 비동기 함수. SWR Key를 인자로 받아 API를 호출하고 데이터를 반환 |
|
||||
| **Revalidation** | 캐시가 오래된 경우 서버에서 최신 데이터를 다시 가져오는 과정. SWR의 핵심 동작 원리 |
|
||||
| **invalidateQueries** | TanStack Query에서 특정 쿼리의 캐시를 무효화하여 자동 재조회를 유발하는 함수 |
|
||||
|
||||
---
|
||||
|
||||
## HTTP & API 용어
|
||||
|
||||
| 용어 | 설명 |
|
||||
|------|------|
|
||||
| **Axios** | Promise 기반 HTTP 클라이언트. 인터셉터(Interceptor)를 통해 토큰 자동 주입, 에러 핸들링 등을 설정 가능 |
|
||||
| **AxiosError** | Axios에서 HTTP 에러(4xx, 5xx 응답 등) 발생 시 던지는 에러 객체. `error.response.data`로 서버 에러 메시지에 접근 가능 |
|
||||
| **API 프록시** | 클라이언트가 `/server/*` 경로로 요청하면 Middleware가 실제 백엔드 URL로 재작성(Rewrite)하는 패턴 |
|
||||
| **Rewrite** | Next.js Middleware 또는 `next.config.ts`에서 요청 URL을 다른 URL로 투명하게 변환하는 기능 |
|
||||
| **TUS 프로토콜** | 대용량 파일 업로드를 지원하는 개방형 프로토콜. 업로드 중 네트워크 오류가 발생해도 중단된 지점부터 재개(Resume) 가능 |
|
||||
| **stClient** | ST 전용 백엔드 서비스(`st-management-service`, `st-input-service`)와 통신하는 Axios 인스턴스 |
|
||||
| **client** | 일반 백엔드 API 서버와 통신하는 기본 Axios 인스턴스 |
|
||||
|
||||
---
|
||||
|
||||
## UI 컴포넌트 용어
|
||||
|
||||
| 용어 | 설명 |
|
||||
|------|------|
|
||||
| **RealGrid** | 대용량 데이터 처리에 특화된 상용 엔터프라이즈 그리드 라이브러리. 인메모리 데이터 처리로 수십만 행도 빠르게 렌더링 가능 |
|
||||
| **DataGrid** | RealGrid를 기반으로 프로젝트 사양에 맞게 구현한 공통 그리드 컴포넌트 (`components/module/DataGrid.tsx`) |
|
||||
| **SearchForm** | 시도, 시군구, 연도, 사업장 등 공통 검색 조건을 가진 공유 폼 컴포넌트 |
|
||||
| **FullCalendar** | 월별·주별·시간별 달력 UI를 제공하는 오픈소스 캘린더 라이브러리 |
|
||||
| **TinyMCE** | 웹 기반 WYSIWYG 리치 텍스트 에디터. 게시판 본문 입력 등에 활용 |
|
||||
| **Radix UI** | 접근성(a11y)을 준수하는 헤드리스(스타일이 없는) UI 원시 컴포넌트 라이브러리 |
|
||||
| **Sonner** | 경량 토스트(Toast) 알림 컴포넌트 라이브러리 |
|
||||
|
||||
---
|
||||
|
||||
## 폼 & 유효성 검증 용어
|
||||
|
||||
| 용어 | 설명 |
|
||||
|------|------|
|
||||
| **react-hook-form** | 비제어 컴포넌트(Uncontrolled Component) 방식으로 폼 상태를 관리하여 불필요한 리렌더링을 최소화하는 라이브러리 |
|
||||
| **Zod** | TypeScript 우선 스키마 선언 및 런타임 유효성 검증 라이브러리. 스키마 정의에서 TypeScript 타입을 자동 추론 |
|
||||
| **zodResolver** | react-hook-form과 Zod를 연결해주는 어댑터. 폼 제출 시 Zod 스키마로 자동 유효성 검증 |
|
||||
| **Schema** | Zod에서 데이터 구조와 유효성 규칙을 정의한 것. `postFormSchema`, `loginSchema` 등 |
|
||||
|
||||
---
|
||||
|
||||
## 업무 도메인 용어
|
||||
|
||||
| 약어/영문 | 설명 |
|
||||
|---------|------|
|
||||
| **ST** | Statistics - 통계 관리 시스템 |
|
||||
| **ctpvNm** | 특별/광역시도명 (시도명) |
|
||||
| **sggNm** | 시군구명 |
|
||||
| **sewerBzmnCd** | 하수도 사업자 코드 |
|
||||
| **statsYr** | 통계 연도 |
|
||||
| **vrfcMthdCd** | 검증 방법 코드 |
|
||||
| **mngAuthrtYn** | 관리 권한 여부 |
|
||||
| **inqAuthrtYn** | 조회 권한 여부 |
|
||||
| **regAuthrtYn** | 등록 권한 여부 |
|
||||
| **mdfcnAuthrtYn** | 수정 권한 여부 |
|
||||
| **delAuthrtYn** | 삭제 권한 여부 |
|
||||
| **CLAIM_NAME** | JWT 토큰을 저장하는 쿠키/헤더 키 이름 |
|
||||
| **AUTH_USER_ID** | 사용자 ID를 저장하는 쿠키/헤더 키 이름 |
|
||||
| **SITE_ID** | 멀티 사이트 구분을 위한 사이트 식별자 |
|
||||
@@ -0,0 +1,169 @@
|
||||
# 08. 리얼그리드(RealGrid) 활용
|
||||
|
||||
ST 앱에서 사용하는 엔터프라이즈급 그리드 라이브러리인 **RealGrid 2**의 구현 방식과 주요 함수, 속성을 정리합니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 개요 및 구조
|
||||
|
||||
ST 앱은 `realgrid-react`를 사용하여 리액트 환경에서 그리드를 구현하고 있으며, 공통 모듈인 `DataGrid.tsx`에서 복잡한 설정을 추상화하여 제공합니다.
|
||||
|
||||
### 주요 컴포넌트
|
||||
- **`DataGrid.tsx`**: 가장 핵심이 되는 공통 그리드 컴포넌트.
|
||||
- **`TabDataGrid.tsx`**: 탭별로 독립적인 `DataGrid`를 렌더링.
|
||||
- **`SplitDataGrid.tsx`**: 좌우 5:5 분할 화면에 두 개의 그리드를 배치.
|
||||
- **`CompactDataGrid.tsx`**: 간소화된 형태의 그리드.
|
||||
|
||||
---
|
||||
|
||||
## 2. 주요 설정 및 속성 (Props)
|
||||
|
||||
`DataGridProps` 인터페이스를 통해 그리드의 동작을 제어합니다.
|
||||
|
||||
| 속성명 | 타입 | 설명 |
|
||||
|-------|------|------|
|
||||
| `columnInfo` | `ColumnInfo` | 컬럼 정의(`columns`)와 레이아웃(`columnLayout`) 정보 |
|
||||
| `rowData` | `Record[]` | 그리드에 표시할 실제 데이터 배열 |
|
||||
| `fixedColumns` | `number` | 좌측에 고정할 컬럼 수 |
|
||||
| `rowStartNum` | `number` | 엑셀 임포트 시 헤더가 시작되는 행 번호 |
|
||||
| `isExcelImport` | `boolean` | 엑셀 가져오기 버튼 표시 여부 |
|
||||
| `isExcelExport` | `boolean` | 엑셀 내보내기 버튼 표시 여부 |
|
||||
| `showHeaderCheckbox`| `boolean` | 헤더 체크박스(CheckBar) 표시 여부 |
|
||||
| `cellStyleCallback` | `Function` | 실시간 조건부 셀 스타일 적용 콜백 |
|
||||
| `cellTooltipCallback`| `Function` | 셀 호버 시 표시할 툴팁 메시지 콜백 |
|
||||
| `onRowClick` | `Function` | 행 클릭 시 실행될 이벤트 핸들러 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 컬럼 정의 (`ColumnConfig`)
|
||||
|
||||
`src/components/type/DataGrid.type.ts`에 정의된 주요 컬럼 속성입니다.
|
||||
|
||||
```typescript
|
||||
export type ColumnConfig = {
|
||||
name: string; // 컬럼 고유 이름
|
||||
fieldName: string; // 데이터 필드명
|
||||
header: { text: string }; // 헤더 표시 텍스트
|
||||
width?: number; // 컬럼 너비 (px)
|
||||
editable?: boolean; // 편집 가능 여부
|
||||
visible?: boolean; // 숨김 여부
|
||||
styleName?: string; // 적용할 CSS 클래스
|
||||
mergeCells?: boolean; // 셀 병합 여부 (내용이 같을 때)
|
||||
valueType?: ValueType; // 데이터 타입 (TEXT, NUMBER 등)
|
||||
footer?: FooterConfig; // 푸터 설정 (합계 등)
|
||||
computed?: Computed; // 계산 필드 설정 (합계/평균/비율 등)
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 핵심 기능 구현 상세
|
||||
|
||||
### 4-1. 커스텀 정렬 (`onSorting`)
|
||||
기본 정렬 사용 시 "시군구" 등 병합된 셀의 그룹 순서가 깨지는 문제를 해결하기 위해 전용 핸들러를 사용합니다.
|
||||
- **`createOnSortingHandler`**: 정렬 시 그룹(시군구) 내의 순서를 유지하며 그룹 간 정렬을 수행합니다.
|
||||
- **`createNumberComparer`**: 숫자에 포함된 쉼표(,)를 제거하고 순수 숫자 값으로 비교하여 정확한 정렬을 보장합니다.
|
||||
|
||||
### 4-2. 계산 필드 (`Computed Field`)
|
||||
`dataProvider.setFields` 시 `valueCallback`을 통해 실시간 계산 값을 생성합니다.
|
||||
- **지원 유형**: 단순 합계(`sum`), 평균(`avg`), 보급률(`pblswrgPopltnSuprtRate`) 등 특수 비즈니스 로직.
|
||||
- **매커니즘**: `getComputedValueCallback` 유틸을 통해 여러 필드의 값을 조합하여 결과를 반환합니다.
|
||||
|
||||
### 4-3. 엑셀 임포트/익스포트 (Smart Mapping)
|
||||
일반적인 그리드와 달리, 엑셀 파일과 그리드 간의 정확한 필드 매칭을 위해 특수한 기법을 사용합니다.
|
||||
- **익스포트 시**: 엑셀 헤더 텍스트 뒤에 `(fieldName)`을 임시로 붙여서 내보냅니다. (예: `인구수 (popltnCnt)`)
|
||||
- **임포트 시**: 정규표현식 `/\(([a-zA-Z0-9_]+)\)$/`을 사용하여 헤더에서 `fieldName`을 추출, 데이터 파싱 시 정확한 객체 키로 매핑합니다.
|
||||
|
||||
### 4-4. 푸터(Footer) 및 레이아웃 합병
|
||||
- **`footerUserSpans`**: `columnLayout` 설정을 통해 푸터 영역에서 여러 셀을 하나로 합치는 기능을 지원합니다.
|
||||
- **멀티 푸터**: `gridView.setFooters`를 통해 2행 이상의 푸터를 설정하고, 각 행마다 다른 통계(합계, 평균 등)를 보여줍니다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 자주 사용하는 유틸리티 함수
|
||||
|
||||
### `toGridRows<T>`
|
||||
API 응답 객체 배열을 RealGrid가 처리하기 좋은 형태로 변환합니다.
|
||||
- `null` 또는 `undefined` 값을 빈 문자열(`""`)로 치환하여 그리드 상에서 `null` 문구가 노출되지 않도록 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 스타일 가이드 (`styleName`)
|
||||
|
||||
그리드 셀에 적용되는 주요 CSS 클래스입니다 (프로젝트 관례).
|
||||
- `rg-cell-readonly`: 읽기 전용 셀 배경색 처리
|
||||
- `rg-cell-total`: 합계 행/열 강조
|
||||
- `rg-cell-error`: 검증 오류 발생 셀 (빨간색 테두리 등)
|
||||
- `rg-cell-required`: 필수 입력 셀 표시
|
||||
|
||||
---
|
||||
|
||||
## 7. RealGrid 원천 API 상세 (Native API)
|
||||
|
||||
프로젝트 코드(`DataGrid.tsx`, `CompactDataGrid.tsx`)에서 실제로 호출하고 있는 RealGrid의 원천 함수와 속성 목록입니다.
|
||||
|
||||
### 7-1. 전역 설정 (`realgrid`)
|
||||
- **`RealGrid.setLicenseKey(key: string)`**: 라이선스 키를 등록합니다.
|
||||
- **`ValueType`**: 필드 데이터 타입을 지정하는 열거형 (예: `ValueType.TEXT`, `ValueType.NUMBER`).
|
||||
- **`GridFitStyle`**: 그리드 너비 맞춤 스타일 (예: `GridFitStyle.EVEN`).
|
||||
|
||||
### 7-2. 그리드 뷰 (`GridView`)
|
||||
그리드의 시각적 요소와 이벤트를 제어합니다.
|
||||
|
||||
| API | 설명 |
|
||||
|-----|-----|
|
||||
| `setOptions(options)` | 그리드 전역 옵션 설정 (`checkBar.visible`, `summaryMode` 등) |
|
||||
| `setDisplayOptions(options)` | 화면 표시 상세 설정 (`fitStyle`, `rowHeight` 등) |
|
||||
| `setFixedOptions(options)` | 고정 컬럼 설정 (`colCount`) |
|
||||
| `setFooters(heights)` | 멀티 푸터 행들의 높이를 배열로 설정 |
|
||||
| `setColumns(columns)` | 컬럼 구성을 동적으로 재설정 |
|
||||
| `setColumnLayout(layout)` | 컬럼의 시각적 배치(그룹 헤더 등) 적용 |
|
||||
| `setColumnProperty(col, prop, val)` | 특정 컬럼의 속성(예: `renderer`)을 개별 변경 |
|
||||
| `exportGrid(options)` | 엑셀 파일 내보내기 실행 |
|
||||
| `setCurrent(index)` | 특정 행/셀로 포커스 이동 (`itemIndex` 사용) |
|
||||
| `setFocus()` | 그리드 자체에 포커스 부여 |
|
||||
| `refresh()` | 그리드 화면 강제 다시 그리기 (스타일 변경 시 유용) |
|
||||
| `columnByName(name)` | 이름으로 특정 컬럼 객체 반환 |
|
||||
|
||||
**주요 이벤트 핸들러:**
|
||||
- `onSorting`: 정렬 시점 발생. (커스텀 그룹 정렬 구현 시 사용)
|
||||
- `onCellClicked`: 셀 클릭 시 발생. (행 선택 로직 구현)
|
||||
- `onShowTooltip`: 툴팁 표시 직전 발생. (동적 툴팁 내용 생성)
|
||||
|
||||
### 7-3. 데이터 프로바이더 (`LocalDataProvider`)
|
||||
그리드 내부의 실제 데이터를 관리합니다.
|
||||
|
||||
| API | 설명 |
|
||||
|-----|-----|
|
||||
| `setFields(fields)` | 데이터 필드(데이터 모델) 구조 정의 |
|
||||
| `setRows(rows)` | 전체 데이터 로드 |
|
||||
| `getRowCount()` | 전체 데이터 행 수 반환 |
|
||||
| `getValue(row, field)` | 특정 행/필드의 데이터 값 조회 |
|
||||
| `setDataComparer(field, fn)` | 특정 필드의 정렬 로직을 사용자 정의 함수로 위임 |
|
||||
|
||||
---
|
||||
|
||||
## 8. RealGrid-React 컴포넌트 (`realgrid-react`)
|
||||
|
||||
### 8-1. `<RealGridReact />` (메인 컨테이너)
|
||||
- **`ref`**: 내부 `gridView`와 `dataProvider` 객체에 접근하기 위해 사용.
|
||||
- **`rows`**: 리액트 상태(`rowData`)를 그리드 데이터와 바인딩.
|
||||
- **`layout`**: 정의된 `columnLayout`을 적용.
|
||||
- **`autoGenerateField`**: `true` 설정 시 행 데이터를 바탕으로 필드를 자동 생성.
|
||||
- **`onInitialized(grid)`**: 그리드 초기화 완료 후 실행되는 콜백.
|
||||
|
||||
### 8-2. `<RGDataColumn />` (컬럼 속성 정의)
|
||||
JSX 형태로 각 컬럼의 세부 속성을 설정합니다.
|
||||
|
||||
| 주요 Props | 설명 |
|
||||
|-----------|-----|
|
||||
| `name` / `fieldName` | 컬럼/필드 식별자 |
|
||||
| `valueType` | 데이터 타입 (`TEXT`, `NUMBER`) |
|
||||
| `numberFormat` | 숫자 표시 형식 (예: `#,##0.000`) |
|
||||
| `header` | 헤더 설정 객체 (`{ text: "제목" }`) |
|
||||
| `editable` | 해당 컬럼의 편집 가능 여부 |
|
||||
| `styleName` | 해당 컬럼에 적용할 CSS 클래스명 |
|
||||
| `renderer` | 셀 렌더러 설정 (예: `{ showTooltip: true }`) |
|
||||
| `footer` | 푸터 합계/수식 설정 |
|
||||
| `mergeRule` | 셀 병합 규칙 (`{ criteria: "value" }`) |
|
||||
| `sortable` | 정렬 가능 여부 |
|
||||
@@ -0,0 +1,700 @@
|
||||
# 10. FSD 구조하에 CS 프로그램
|
||||
|
||||
> **FSD(Feature-Sliced Design)**: 레이어(Layer) 간 단방향 의존성을 강제하는 프론트엔드 아키텍처 방법론.
|
||||
> `shared → entities → widgets → pages → app` 순서로 **하위 레이어만 참조 가능**.
|
||||
|
||||
---
|
||||
|
||||
## 1. FSD란 무엇인가
|
||||
|
||||
FSD(Feature-Sliced Design)는 기능 중심으로 코드를 조직화하는 프론트엔드 아키텍처입니다.
|
||||
가장 중요한 원칙은 **"상위 레이어가 하위 레이어를 참조하고, 하위 레이어는 절대 상위 레이어를 참조할 수 없다"** 입니다.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ app (애플리케이션 진입점) │ ← 최상위: 모든 레이어 조합
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ pages (Next.js 라우트 페이지) │ ← 라우팅 연결 + 페이지 컴포넌트
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ widgets (독립 UI 블록) │ ← 여러 entities 조합한 복합 UI
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ entities (도메인 엔티티) │ ← 비즈니스 도메인 단위 캡슐화
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ shared (공통 기반 인프라) │ ← 최하위: 재사용 가능한 순수 기능
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
|
||||
↑ 상위 레이어만 아래를 참조 (단방향)
|
||||
```
|
||||
|
||||
### 레이어 내부 구조: 슬라이스(Slice) + 세그먼트(Segment)
|
||||
|
||||
각 레이어는 `슬라이스(도메인/기능 단위)` → `세그먼트(역할 단위)`로 세분화됩니다.
|
||||
|
||||
```
|
||||
레이어/슬라이스/세그먼트
|
||||
예시: entities/user/api/
|
||||
entities/user/model/
|
||||
entities/user/ui/
|
||||
entities/user/index.ts ← 공개 API (Public API)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. CS 앱 실제 디렉터리 구조
|
||||
|
||||
### 전체 구조
|
||||
|
||||
```
|
||||
apps/cs/
|
||||
├── app/ # Next.js App Router (라우팅 레이어)
|
||||
│ ├── (auth)/ # 인증 없는 공개 라우트
|
||||
│ │ └── login/
|
||||
│ ├── (main)/ # 인증이 필요한 메인 라우트
|
||||
│ │ ├── layout.tsx # 메인 레이아웃 래퍼
|
||||
│ │ ├── (with-base-layout)/ # 헤더/LNB 포함 레이아웃
|
||||
│ │ │ ├── layout.tsx # BaseLayout + PageLayout 조합
|
||||
│ │ │ ├── users/ # 사용자 관리 라우트
|
||||
│ │ │ │ ├── page.tsx # → UsersPage 컴포넌트 연결
|
||||
│ │ │ │ ├── register/
|
||||
│ │ │ │ └── [userUnqId]/
|
||||
│ │ │ ├── bbs/ # 게시판 관리 라우트
|
||||
│ │ │ ├── dashboard/
|
||||
│ │ │ ├── menu/
|
||||
│ │ │ └── ...
|
||||
│ │ └── (without-base-layout)/ # LNB 없는 전체화면
|
||||
│ └── api/ # Next.js API Routes
|
||||
│
|
||||
├── src/ # FSD 소스 레이어
|
||||
│ ├── app/ # App 레이어 (글로벌 설정)
|
||||
│ │ ├── index.tsx # App 루트 컴포넌트
|
||||
│ │ ├── providers/
|
||||
│ │ │ └── app-provider.tsx # 전역 Provider 조합
|
||||
│ │ └── styles/ # 글로벌 CSS
|
||||
│ │
|
||||
│ ├── pages/ # Pages 레이어 (페이지 컴포넌트)
|
||||
│ │ ├── users/
|
||||
│ │ │ ├── model/ # 테이블 컬럼 정의 등
|
||||
│ │ │ ├── ui/ # 페이지 컴포넌트
|
||||
│ │ │ └── index.ts # Public API
|
||||
│ │ ├── bbs/
|
||||
│ │ ├── dashboard/
|
||||
│ │ ├── menu/
|
||||
│ │ ├── role/
|
||||
│ │ ├── site/
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ ├── widgets/ # Widgets 레이어 (복합 UI 블록)
|
||||
│ │ ├── dnd-tree/ # 드래그앤드롭 트리 위젯
|
||||
│ │ ├── form-wrapper/ # 폼 래퍼 위젯
|
||||
│ │ └── post/ # 게시물 관련 복합 위젯
|
||||
│ │
|
||||
│ ├── entities/ # Entities 레이어 (도메인 엔티티)
|
||||
│ │ ├── auth/ # 인증 도메인
|
||||
│ │ ├── user/ # 사용자 도메인
|
||||
│ │ ├── board/ # 게시판 도메인
|
||||
│ │ ├── code/ # 공통코드 도메인
|
||||
│ │ ├── menu/ # 메뉴 도메인
|
||||
│ │ ├── role/ # 권한 도메인
|
||||
│ │ ├── site/ # 사이트 도메인
|
||||
│ │ ├── group/ # 그룹 도메인
|
||||
│ │ ├── organization/ # 조직 도메인
|
||||
│ │ ├── attachment/ # 파일첨부 도메인
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ └── shared/ # Shared 레이어 (공통 기반)
|
||||
│ ├── api/ # API 클라이언트
|
||||
│ ├── config/ # 환경변수, 엔드포인트 상수
|
||||
│ ├── lib/ # 공통 커스텀 훅
|
||||
│ ├── model/ # 공통 타입 (PageableList 등)
|
||||
│ ├── providers/ # 공통 Context Provider
|
||||
│ ├── store/ # Zustand 스토어 정의
|
||||
│ └── ui/ # CS 앱 전용 공통 UI 컴포넌트
|
||||
│
|
||||
├── packages/common/ # 모노레포 공통 패키지
|
||||
│ ├── components/ # @common/components - UI 컴포넌트 라이브러리
|
||||
│ ├── business_components/ # 업무용 공통 컴포넌트
|
||||
│ ├── auth/ # @common/auth - 인증 유틸리티
|
||||
│ ├── network/ # @common/network - 네트워크 유틸리티
|
||||
│ └── config/ # 공통 설정
|
||||
│
|
||||
└── packages/rsone/ # 추가 공통 컴포넌트 패키지
|
||||
└── components/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 레이어별 상세 역할
|
||||
|
||||
### 🔵 `shared` - 공통 기반 레이어
|
||||
|
||||
**위치**: `src/shared/`
|
||||
**의존성**: 다른 어떤 레이어도 참조하지 않음 (최하위)
|
||||
**역할**: 앱 전체에서 재사용 가능한 순수 유틸리티, 인프라, 타입 정의
|
||||
|
||||
| 세그먼트 | 경로 | 내용 |
|
||||
|--------|------|------|
|
||||
| `api` | `src/shared/api/` | Axios 인스턴스 (`client.ts`), 공통 request 설정 |
|
||||
| `config` | `src/shared/config/` | 환경변수(`env.ts`), API 엔드포인트 상수(`api.ts`), 인증 설정 |
|
||||
| `lib` | `src/shared/lib/` | 커스텀 훅 (`useDataTable`, `useUserStore`, `useMutationWrapper` 등) |
|
||||
| `model` | `src/shared/model/` | 공통 인터페이스 (`PageableList`, `PageableParams`) |
|
||||
| `store` | `src/shared/store/` | Zustand 스토어 정의 (`userStore`, `menuStore`, `alertStore`) |
|
||||
| `providers` | `src/shared/providers/` | Context Provider (`StoreProvider`, `PromptProvider`) |
|
||||
| `ui` | `src/shared/ui/` | 앱 전용 공통 UI (`AlertDialog`, `ConfirmDialog`, `PageLayout`, `GridViewDataTable` 등) |
|
||||
|
||||
**코드 예시 (`src/shared/api/client.ts`)**:
|
||||
```typescript
|
||||
// @/shared/api 로 임포트
|
||||
import { client } from "@/shared/api";
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🟢 `entities` - 비즈니스 도메인 레이어
|
||||
|
||||
**위치**: `src/entities/`
|
||||
**의존성**: `shared`만 참조 가능
|
||||
**역할**: 하나의 비즈니스 도메인(user, board, auth 등)을 API 서비스와 타입으로 캡슐화
|
||||
|
||||
각 엔티티는 다음 세그먼트로 구성됩니다:
|
||||
|
||||
```
|
||||
entities/user/
|
||||
├── api/
|
||||
│ └── user-service.ts # API 서비스 클래스 (CRUD 메서드)
|
||||
├── model/
|
||||
│ └── user-interface.ts # TypeScript 타입, Zod 스키마, 상수
|
||||
├── ui/
|
||||
│ └── password-reset-dialog.tsx # 해당 도메인 전용 UI (선택적)
|
||||
└── index.ts # Public API - 외부에서 사용할 것만 export
|
||||
```
|
||||
|
||||
**코드 예시 (`src/entities/user/api/user-service.ts`)**:
|
||||
```typescript
|
||||
import { client } from "@/shared/api"; // shared 참조 OK
|
||||
import { ENDPOINT } from "@/shared/config"; // shared 참조 OK
|
||||
import type { UserService, UserSearchParams } from "../model/user-interface";
|
||||
|
||||
export class UserApiService implements UserService {
|
||||
public async findPageUser(params: UserSearchParams): Promise<UserList> {
|
||||
const response = await client.get<UserList>(
|
||||
`${ENDPOINT.USER_SERVICE.USERS}`,
|
||||
{ params },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**엔티티 간 참조 (`@x` 패턴)**:
|
||||
동일 레이어(entities) 내 다른 슬라이스를 참조할 때는 `@x` 폴더를 통해 명시적으로 허용합니다.
|
||||
|
||||
```
|
||||
entities/board/@x/
|
||||
└── post.ts # board 엔티티가 post 엔티티에서 필요한 것만 re-export
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🟡 `widgets` - 독립 UI 블록 레이어
|
||||
|
||||
**위치**: `src/widgets/`
|
||||
**의존성**: `shared`, `entities` 참조 가능
|
||||
**역할**: 여러 entities를 조합하여 독립적으로 동작하는 복합 UI 블록
|
||||
|
||||
| 슬라이스 | 내용 |
|
||||
|--------|------|
|
||||
| `dnd-tree` | 드래그앤드롭 트리 컴포넌트 (메뉴 관리 등에 활용) |
|
||||
| `form-wrapper` | 폼 공통 래퍼 (저장/취소 버튼 패턴 표준화) |
|
||||
| `post` | 게시물 관련 복합 UI 블록 |
|
||||
|
||||
---
|
||||
|
||||
### 🟠 `pages` - 페이지 컴포넌트 레이어
|
||||
|
||||
**위치**: `src/pages/`
|
||||
**의존성**: `shared`, `entities`, `widgets` 참조 가능
|
||||
**역할**: 실제 화면 UI 구현. Next.js App Router의 `page.tsx`에서 이 컴포넌트를 import하여 연결
|
||||
|
||||
각 페이지 슬라이스 구조:
|
||||
```
|
||||
pages/users/
|
||||
├── model/
|
||||
│ └── table-columns.tsx # TanStack Table 컬럼 정의
|
||||
├── ui/
|
||||
│ ├── users-page.tsx # 목록 페이지 메인 컴포넌트
|
||||
│ ├── create-page.tsx # 등록 페이지
|
||||
│ ├── modify-page.tsx # 수정 페이지
|
||||
│ ├── user-detail.tsx # 상세 컴포넌트
|
||||
│ └── users-form.tsx # 등록/수정 공통 폼
|
||||
└── index.ts # Public API
|
||||
```
|
||||
|
||||
**코드 예시 (`src/pages/users/ui/users-page.tsx`)**:
|
||||
```typescript
|
||||
import { UserApi } from "@/entities/user"; // entities 참조 OK
|
||||
import { CodeApi } from "@/entities/code"; // entities 참조 OK
|
||||
import { ENDPOINT } from "@/shared/config"; // shared 참조 OK
|
||||
import { useDataTable } from "@/shared/lib"; // shared 참조 OK
|
||||
import { Button } from "@common/components"; // 공통 패키지 참조 OK
|
||||
// ❌ import { something } from "@/app/..."; // 상위 레이어 참조 불가
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 🔴 `app` - 애플리케이션 레이어
|
||||
|
||||
**위치**: `src/app/`
|
||||
**의존성**: 모든 하위 레이어 참조 가능
|
||||
**역할**: 앱 초기화, 전역 Provider 설정, 글로벌 스타일 적용
|
||||
|
||||
```typescript
|
||||
// src/app/providers/app-provider.tsx
|
||||
const AppProvider = ({ children }) => {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}> // React Query 설정
|
||||
<StoreProvider> // Zustand Store 초기화
|
||||
<PromptProvider>
|
||||
<ConfirmProvider>
|
||||
<AuthWrapper> // entities/auth - 인증 처리
|
||||
<MenuWrapper> // entities/menu - 메뉴 초기화
|
||||
<DndProvider ...>
|
||||
{children}
|
||||
</DndProvider>
|
||||
</MenuWrapper>
|
||||
<AlertDialog /> // shared/ui - 전역 다이얼로그
|
||||
<PromptDialog />
|
||||
<ConfirmDialog />
|
||||
<Toaster />
|
||||
</AuthWrapper>
|
||||
</ConfirmProvider>
|
||||
</PromptProvider>
|
||||
</StoreProvider>
|
||||
<ReactQueryDevtools />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Next.js App Router와 FSD 통합 방식
|
||||
|
||||
Next.js App Router의 라우팅 파일(`app/` 디렉토리)과 FSD의 `pages` 레이어는 **별도로 분리**됩니다.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Next.js App Router (app/) │
|
||||
│ │
|
||||
│ app/(main)/(with-base-layout)/users/page.tsx │
|
||||
│ ┌─────────────────────────────────────────────────┐ │
|
||||
│ │ import { UsersPage } from "@/pages/users"; │ │
|
||||
│ │ export default UsersPage; │ │
|
||||
│ └─────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ │ (딱 1줄 연결) │
|
||||
└────────────────────┼────────────────────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ FSD pages 레이어 (src/pages/) │
|
||||
│ │
|
||||
│ src/pages/users/ui/users-page.tsx │
|
||||
│ → 실제 화면 로직과 UI 구현 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**핵심 패턴**: `app/` 폴더의 `page.tsx`는 단순히 FSD `pages` 레이어 컴포넌트를 **re-export**만 합니다.
|
||||
|
||||
```typescript
|
||||
// app/(main)/(with-base-layout)/users/page.tsx
|
||||
import { UsersPage } from "@/pages/users";
|
||||
export default UsersPage; // 딱 1줄!
|
||||
```
|
||||
|
||||
### 레이아웃 계층 구조
|
||||
|
||||
```
|
||||
app/(main)/layout.tsx
|
||||
└── App (src/app/index.tsx)
|
||||
└── AppProvider (QueryClient, Auth, Menu, Store 등)
|
||||
|
||||
app/(main)/(with-base-layout)/layout.tsx
|
||||
└── BaseLayout (@common/components) ← 헤더 + LNB
|
||||
└── PageLayout (src/shared/ui) ← 콘텐츠 영역
|
||||
└── {children} ← 각 page.tsx 컴포넌트
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 모노레포 패키지와의 관계
|
||||
|
||||
CS 앱은 `packages/` 아래의 공통 패키지를 `shared` 레이어의 하위 개념으로 활용합니다.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ apps/cs │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────┐ │
|
||||
│ │ src/shared (앱 전용 공통) │ │
|
||||
│ │ - @/shared/api → Axios 클라이언트 │ │
|
||||
│ │ - @/shared/config → 엔드포인트 상수 │ │
|
||||
│ │ - @/shared/lib → 커스텀 훅 │ │
|
||||
│ │ - @/shared/ui → 앱 전용 공통 UI │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
│ ↑ 참조 │
|
||||
│ ┌──────────────────────────────────────────────────┐ │
|
||||
│ │ packages/common (모노레포 공통 패키지) │ │
|
||||
│ │ - @common/components → 기반 UI 컴포넌트 라이브러리 │ │
|
||||
│ │ - @common/auth → JWT, 쿠키 유틸리티 │ │
|
||||
│ │ - @common/network → 에러 인터셉터, 리다이렉트 │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 패키지 | Import 경로 | 역할 |
|
||||
|--------|------------|------|
|
||||
| `packages/common/components` | `@common/components` | Button, Input, Table, Dialog 등 기반 UI |
|
||||
| `packages/common/auth` | `@common/auth` | JWT 파싱, getRemainingMs, 쿠키 관리 |
|
||||
| `packages/common/network` | `@common/network` | 401 에러 인터셉터, isAtLoginRedirectUrl |
|
||||
| `packages/rsone/components` | `@rsone/components` | 추가 UI 컴포넌트 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 미들웨어 기반 API 프록시 아키텍처
|
||||
|
||||
CS 앱은 클라이언트가 백엔드를 직접 호출하지 않고 **Next.js Middleware를 통해 프록시**합니다.
|
||||
|
||||
```
|
||||
Browser (React 컴포넌트)
|
||||
│
|
||||
│ axios.get('/server/user-service/users')
|
||||
▼
|
||||
Next.js Middleware (middleware.ts)
|
||||
│ - 쿠키에서 JWT 토큰 추출 → 요청 헤더에 주입
|
||||
│ - URL 패턴 매칭 → 실제 백엔드 URL로 rewrite
|
||||
│ - CSP 보안 헤더 추가
|
||||
▼
|
||||
실제 백엔드 API 서버 (Spring Boot)
|
||||
│
|
||||
└── 응답 반환
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 의존성 규칙 요약
|
||||
|
||||
```
|
||||
❌ 금지: 하위 레이어 → 상위 레이어 참조
|
||||
✅ 허용: 상위 레이어 → 하위 레이어 참조
|
||||
|
||||
shared ─────────────────────────── (다른 레이어 참조 불가)
|
||||
entities → shared
|
||||
widgets → shared, entities
|
||||
pages → shared, entities, widgets
|
||||
app → shared, entities, widgets, pages
|
||||
```
|
||||
|
||||
**동일 레이어 내 참조 규칙**:
|
||||
- `entities` 내 다른 슬라이스 참조 시: `@x` 폴더를 통해 명시적으로 허용
|
||||
- `pages` 내 다른 슬라이스 참조: 원칙적으로 금지 (widgets로 추출해야 함)
|
||||
|
||||
---
|
||||
|
||||
## 8. UI 추가 시 프로그램 UI 및 액션 흐름
|
||||
|
||||
### 8-1. 신규 도메인 UI 추가 순서
|
||||
|
||||
```
|
||||
Step 1: entities 레이어에 도메인 추가
|
||||
src/entities/{도메인}/
|
||||
├── api/{도메인}-service.ts ← API CRUD 클래스 작성
|
||||
├── model/{도메인}-interface.ts ← TypeScript 타입/인터페이스/Zod 스키마 정의
|
||||
├── ui/ (선택) ← 도메인 전용 UI (예: 검색 다이얼로그)
|
||||
└── index.ts ← Public API export
|
||||
|
||||
Step 2: pages 레이어에 페이지 컴포넌트 추가
|
||||
src/pages/{도메인}/
|
||||
├── model/table-columns.tsx ← 테이블 컬럼 정의 (TanStack Table)
|
||||
├── ui/{도메인}-page.tsx ← 목록 페이지
|
||||
├── ui/create-page.tsx ← 등록 페이지
|
||||
├── ui/modify-page.tsx ← 수정 페이지
|
||||
└── index.ts ← Public API export
|
||||
|
||||
Step 3: Next.js 라우트 연결
|
||||
app/(main)/(with-base-layout)/{도메인}/
|
||||
├── page.tsx ← import { XxxPage } from "@/pages/{도메인}"; export default XxxPage;
|
||||
├── register/page.tsx
|
||||
└── [id]/page.tsx
|
||||
```
|
||||
|
||||
### 8-2. 목록 페이지 UI 액션 흐름
|
||||
|
||||
사용자 관리(`/users`) 페이지를 예시로 전체 액션 흐름을 설명합니다.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ UsersPage (src/pages/users/ui/users-page.tsx) │
|
||||
│ │
|
||||
│ [초기 렌더링] │
|
||||
│ useQuery({ queryKey: [ENDPOINT.USER_SERVICE.USERS, ...] }) │
|
||||
│ └── UserApi.findPageUser(params) → 사용자 목록 API 호출 │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ 검색 폼 영역 │ │
|
||||
│ │ - 기간 선택: InputDateRangePicker (shared/ui) │ │
|
||||
│ │ - 상태 선택: Select + CodeApi.getCodeList() (entities) │ │
|
||||
│ │ - 키워드: Input (공통 컴포넌트) │ │
|
||||
│ │ - [검색] 버튼 → handleSubmit() → setSearchParams() │ │
|
||||
│ │ - [초기화] 버튼 → handleReset() → 상태 초기화 │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ 통계 현황 영역 │ │
|
||||
│ │ - useQuery(userSttsStats) → UserApi.selectUserSttsStats() │ │
|
||||
│ │ - 상태별 카운트 표시 (대기/정상/정지/탈퇴/삭제) │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ 데이터 테이블 영역 (useDataTable hook - shared/lib) │ │
|
||||
│ │ - DataTable (@common/components) 렌더링 │ │
|
||||
│ │ - 컬럼 정의: table-columns.tsx (pages/users/model/) │ │
|
||||
│ │ - 행 클릭 → router.push('/users/{userUnqId}') │ │
|
||||
│ │ - 페이지네이션 → setPagination() → 재쿼리 │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [우상단 버튼] │
|
||||
│ - [등록] 버튼 → router.push('/users/register') │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**액션 흐름 상세**:
|
||||
|
||||
```
|
||||
[검색 버튼 클릭]
|
||||
│
|
||||
▼
|
||||
handleSubmit(e)
|
||||
e.preventDefault()
|
||||
setPagination({ pageIndex: 0 }) ← 1페이지로 리셋
|
||||
setSearchParams({ ← 검색 파라미터 업데이트
|
||||
keywordType, keyword, userStateCode, ...
|
||||
})
|
||||
│
|
||||
▼ (React 상태 변경 → 리렌더링)
|
||||
useQuery 재실행 (queryKey에 searchParams 포함)
|
||||
│
|
||||
▼
|
||||
UserApi.findPageUser(params)
|
||||
│
|
||||
▼
|
||||
axios.get('/server/user-service/users?page=0&size=10&...')
|
||||
│
|
||||
▼
|
||||
middleware.ts → JWT 헤더 주입 → 백엔드로 rewrite
|
||||
│
|
||||
▼
|
||||
Spring Boot 백엔드 API
|
||||
│
|
||||
▼ 응답
|
||||
DataTable 리렌더링
|
||||
```
|
||||
|
||||
### 8-3. 등록/수정 페이지 UI 액션 흐름
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ CreatePage / ModifyPage │
|
||||
│ │
|
||||
│ [폼 초기화] │
|
||||
│ - react-hook-form useForm() 초기화 │
|
||||
│ - Zod resolver로 유효성 검사 스키마 연결 │
|
||||
│ - (수정 시) useQuery로 상세 데이터 로드 → form.reset() │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────┐ │
|
||||
│ │ 입력 폼 (FormWrapper 위젯 or 직접 구성) │ │
|
||||
│ │ - FormField + Input, Select, DatePicker 등 │ │
|
||||
│ │ - 유효성 에러 메시지 자동 표시 │ │
|
||||
│ └─────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [저장 버튼 클릭] │
|
||||
│ form.handleSubmit(onSubmit) │
|
||||
│ └── Zod 유효성 검사 통과 여부 확인 │
|
||||
│ │ │
|
||||
│ ├── 실패 → 에러 메시지 표시 │
|
||||
│ └── 성공 → useMutationWrapper 실행 │
|
||||
│ → UserApi.saveUser(data) 호출 │
|
||||
│ → 성공 시 목록 페이지 이동 │
|
||||
│ → 실패 시 AlertDialog 표시 │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**액션 흐름 상세**:
|
||||
|
||||
```
|
||||
[저장 버튼 클릭]
|
||||
│
|
||||
▼
|
||||
form.handleSubmit(onSubmit)
|
||||
│
|
||||
├── Zod 유효성 검사 실패
|
||||
│ └── 필드별 에러 메시지 표시 (react-hook-form)
|
||||
│
|
||||
└── Zod 유효성 검사 성공
|
||||
│
|
||||
▼
|
||||
useMutationWrapper / useMutation
|
||||
│
|
||||
▼
|
||||
UserApi.saveUser(data) [entities/user/api]
|
||||
│
|
||||
▼
|
||||
axios.post('/server/user-service/users', data)
|
||||
│
|
||||
▼
|
||||
middleware.ts → JWT 주입 → 백엔드로 rewrite
|
||||
│
|
||||
├── 성공 응답
|
||||
│ ├── useAlertStore.showAlert('저장되었습니다')
|
||||
│ ├── queryClient.invalidateQueries(...) ← 캐시 무효화
|
||||
│ └── router.push('/users') ← 목록으로 이동
|
||||
│
|
||||
└── 실패 응답
|
||||
└── useAlertStore.showAlert(에러 메시지)
|
||||
```
|
||||
|
||||
### 8-4. 전체 데이터 흐름 다이어그램
|
||||
|
||||
```
|
||||
사용자 인터랙션 (브라우저 UI)
|
||||
│
|
||||
▼
|
||||
app/(main)/.../(with-base-layout)/users/page.tsx
|
||||
[Next.js App Router - 라우팅 진입점]
|
||||
│ (re-export)
|
||||
▼
|
||||
src/pages/users/ui/users-page.tsx
|
||||
[FSD pages 레이어 - 화면 컴포넌트]
|
||||
- useState: 검색 파라미터, 페이지네이션 상태
|
||||
- useQuery: 데이터 조회 (React Query)
|
||||
│
|
||||
├──(UI 조합)──────────────────────────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
@common/components src/shared/ui/
|
||||
Button, Input, Select, DataTable GridViewDataTable, InputDateRangePicker
|
||||
│ │
|
||||
└──────────────┬──────────────────────────┘
|
||||
│
|
||||
▼
|
||||
src/entities/user/api/user-service.ts
|
||||
src/entities/code/api/code-service.ts
|
||||
[FSD entities 레이어 - API 서비스]
|
||||
│
|
||||
▼
|
||||
src/shared/api/client.ts
|
||||
[Axios 인스턴스]
|
||||
│
|
||||
▼
|
||||
middleware.ts
|
||||
[Next.js Middleware - 프록시 + JWT 주입]
|
||||
│
|
||||
▼
|
||||
Spring Boot 백엔드 API
|
||||
│
|
||||
▼ 응답 반환
|
||||
React Query 캐시 업데이트
|
||||
│
|
||||
▼
|
||||
UI 리렌더링
|
||||
```
|
||||
|
||||
### 8-5. 인증 및 메뉴 초기화 흐름
|
||||
|
||||
```
|
||||
앱 최초 로드
|
||||
│
|
||||
▼
|
||||
src/app/providers/app-provider.tsx (AppProvider)
|
||||
│
|
||||
▼
|
||||
entities/auth/lib/auth-wrapper.tsx (AuthWrapper)
|
||||
│
|
||||
├── 쿠키에서 JWT 토큰 확인
|
||||
│ ├── 토큰 없음 → /login 리다이렉트
|
||||
│ └── 토큰 있음
|
||||
│ ├── getRemainingMs() → 남은 시간 계산
|
||||
│ └── setupRefreshTimer() → 자동 갱신 타이머 설정
|
||||
│
|
||||
├── /users/info/me API 호출 → 로그인 사용자 정보 조회
|
||||
│ └── useUserStore.setUserInfo() → Zustand 전역 상태 저장
|
||||
│
|
||||
└── entities/menu/lib/menu-wrapper.tsx (MenuWrapper)
|
||||
│
|
||||
├── 메뉴 목록 API 호출
|
||||
└── useMenuStore → Zustand에 메뉴 데이터 저장
|
||||
│
|
||||
▼
|
||||
BaseLayout에 메뉴 렌더링 (LNB)
|
||||
│
|
||||
▼
|
||||
메뉴 클릭 → router.push(item.urlPath)
|
||||
→ 해당 page.tsx로 이동
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 공통 패키지 (`packages/common`) 역할
|
||||
|
||||
### `packages/common/components` → `@common/components`
|
||||
|
||||
모든 앱에서 공유하는 기반 UI 컴포넌트 라이브러리:
|
||||
|
||||
```
|
||||
components/src/ui/
|
||||
├── button/ # Button 컴포넌트 (variant: default, outline, ghost 등)
|
||||
├── input/ # Input 텍스트 입력
|
||||
├── select/ # Select 드롭다운
|
||||
├── data-table/ # DataTable (TanStack Table 기반 페이지네이션 테이블)
|
||||
├── dialog/ # Dialog 모달
|
||||
├── date-picker/ # DatePicker 날짜 선택
|
||||
├── sidebar/ # Sidebar (LNB)
|
||||
├── header/ # Header 헤더
|
||||
├── lnb/ # LNB 좌측 네비게이션
|
||||
├── layout/ # BaseLayout (Header + LNB + Content 조합)
|
||||
├── tree/ # Tree 트리 컴포넌트
|
||||
├── pagination/ # Pagination 페이지네이션
|
||||
├── tabs/ # Tabs 탭
|
||||
├── badge/ # Badge 배지
|
||||
└── ...
|
||||
```
|
||||
|
||||
### `packages/common/business_components` (업무용 공통 컴포넌트)
|
||||
|
||||
```
|
||||
business_components/src/ui/
|
||||
├── form/ # Form 공통 래퍼
|
||||
├── table/ # 업무용 테이블
|
||||
├── search_form/ # 검색 폼 레이아웃
|
||||
├── date_picker/ # 업무용 날짜 선택
|
||||
├── excel/ # 엑셀 다운로드 버튼
|
||||
├── file-upload/ # 파일 업로드
|
||||
├── map/ # 지도 컴포넌트
|
||||
└── ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. FSD 준수 체크리스트
|
||||
|
||||
새로운 기능을 추가할 때 다음 사항을 확인합니다:
|
||||
|
||||
| 체크 항목 | 기준 |
|
||||
|----------|------|
|
||||
| ✅ `shared`에 비즈니스 로직 없음 | shared는 순수 유틸/인프라만 포함 |
|
||||
| ✅ `entities`가 `pages`를 import 안 함 | 단방향 의존성 준수 |
|
||||
| ✅ `entities` 간 참조는 `@x` 폴더 사용 | 명시적 교차 참조 |
|
||||
| ✅ `app/page.tsx`는 re-export만 함 | 1줄 연결 패턴 |
|
||||
| ✅ 각 슬라이스에 `index.ts` 존재 | Public API 캡슐화 |
|
||||
| ✅ 타입은 `model/` 세그먼트에 위치 | 타입 중앙화 |
|
||||
| ✅ API 호출은 `entities/api/` 세그먼트에 위치 | 서비스 분리 |
|
||||
| ✅ 전역 상태는 `shared/store/`에 정의 | 스토어 중앙화 |
|
||||
| ✅ 복수 entities 조합 UI는 `widgets`로 추출 | 레이어 역할 준수 |
|
||||
Generated
+7722
-4172
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user