Merge remote-tracking branch 'origin/iipbs/as/dev' into iipbs/as/dev
# Conflicts: # apps/as/src/pages/instl-sncn/ui/cnfm-regist-page.tsx
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.
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@common/assets": "workspace:*",
|
||||
"@common/auth": "workspace:*",
|
||||
"@common/components": "workspace:*",
|
||||
"@common/network": "workspace:*",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
import { client } from "@/shared/api";
|
||||
import { ENDPOINT } from "@/shared/config";
|
||||
import type { PageResponse } from "@/shared/model";
|
||||
|
||||
import type {
|
||||
EsntlAtchFileMng,
|
||||
EsntlAtchFileMngSaveRequest,
|
||||
EsntlAtchFileMngSearchParams,
|
||||
EsntlAtchFileMngService,
|
||||
EsntlAtchFileMngTreeResponseDto,
|
||||
EsntlAtchFileMngTreeSearchParams,
|
||||
} from "../model/esntl-interface";
|
||||
|
||||
async function findTree(
|
||||
params: EsntlAtchFileMngSearchParams,
|
||||
params: EsntlAtchFileMngTreeSearchParams,
|
||||
): Promise<EsntlAtchFileMngTreeResponseDto[]> {
|
||||
const response = await client.get<EsntlAtchFileMngTreeResponseDto[]>(
|
||||
`${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/list/tree`,
|
||||
@@ -17,6 +21,48 @@ async function findTree(
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findPage(
|
||||
params: EsntlAtchFileMngSearchParams,
|
||||
): Promise<PageResponse<EsntlAtchFileMng>> {
|
||||
const response = await client.get<PageResponse<EsntlAtchFileMng>>(
|
||||
ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE,
|
||||
{ params },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function findById(esntlAtchFileId: string): Promise<EsntlAtchFileMng> {
|
||||
const response = await client.get<EsntlAtchFileMng>(
|
||||
`${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/${esntlAtchFileId}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function create(body: EsntlAtchFileMngSaveRequest): Promise<string> {
|
||||
const response = await client.post<string>(ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE, body);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function update(esntlAtchFileId: string, body: EsntlAtchFileMngSaveRequest): Promise<string> {
|
||||
const response = await client.put<string>(
|
||||
`${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/${esntlAtchFileId}`,
|
||||
body,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function remove(esntlAtchFileId: string): Promise<string> {
|
||||
const response = await client.delete<string>(
|
||||
`${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/${esntlAtchFileId}`,
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const esntlAtchFileMngService = {
|
||||
findTree,
|
||||
findPage,
|
||||
findById,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
} satisfies EsntlAtchFileMngService;
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,17 @@
|
||||
export { esntlAtchFileMngService } from "./api/esntl-service";
|
||||
export {
|
||||
useCreateEsntlAtchFileMng,
|
||||
useDeleteEsntlAtchFileMng,
|
||||
useEsntlAtchFileMng,
|
||||
useEsntlAtchFileMngPage,
|
||||
useEsntlAtchFileMngTree,
|
||||
useUpdateEsntlAtchFileMng,
|
||||
} from "./api/queries";
|
||||
export type {
|
||||
EsntlAtchFileMng,
|
||||
EsntlAtchFileMngSaveRequest,
|
||||
EsntlAtchFileMngSearchParams,
|
||||
EsntlAtchFileMngService,
|
||||
EsntlAtchFileMngTreeResponseDto,
|
||||
EsntlAtchFileMngTreeSearchParams,
|
||||
} from "./model/esntl-interface";
|
||||
export { esntlAtchFileMngService } from "./api/esntl-service";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { PageableParams, PageResponse } from "@/shared/model";
|
||||
|
||||
/** 필수첨부파일관리 트리 조회 응답 DTO */
|
||||
export interface EsntlAtchFileMngTreeResponseDto {
|
||||
/** 필수첨부파일아이디 */
|
||||
@@ -12,14 +14,72 @@ export interface EsntlAtchFileMngTreeResponseDto {
|
||||
esntlAtchFileRmrk: string;
|
||||
}
|
||||
|
||||
/** 필수첨부파일관리 조회 파라미터 */
|
||||
export interface EsntlAtchFileMngSearchParams {
|
||||
/** 필수첨부파일관리 엔티티 (목록/상세 공용 응답 DTO) */
|
||||
export interface EsntlAtchFileMng {
|
||||
/** 필수첨부파일아이디 */
|
||||
esntlAtchFileId: string;
|
||||
/** 문서구분공통코드 */
|
||||
docSeComCd: string;
|
||||
/** 필수첨부파일명 */
|
||||
esntlAtchFileNm: string;
|
||||
/** 필수첨부여부 ("Y" | "N") */
|
||||
esntlAtchYn: "Y" | "N";
|
||||
/** 필수첨부파일비고 */
|
||||
esntlAtchFileRmrk?: string;
|
||||
/** 등록자UUID */
|
||||
rgtr?: string;
|
||||
/** 등록자명 */
|
||||
rgtrNm?: string;
|
||||
/** 수정자UUID */
|
||||
mdfr?: string;
|
||||
/** 등록일시 (yyyy-MM-dd HH:mm:ss) */
|
||||
regDt?: string;
|
||||
/** 수정일시 */
|
||||
mdfcnDt?: string;
|
||||
}
|
||||
|
||||
/** 필수첨부파일관리 트리 조회 파라미터 */
|
||||
export interface EsntlAtchFileMngTreeSearchParams {
|
||||
/** 문서구분공통코드 (예: "INSTL") */
|
||||
docSeComCd: string;
|
||||
}
|
||||
|
||||
/** 필수첨부파일관리 목록 조회 파라미터 (페이징 포함) */
|
||||
export interface EsntlAtchFileMngSearchParams extends PageableParams {
|
||||
/** 문서구분공통코드 (예: "INSTL") */
|
||||
docSeComCd?: string;
|
||||
/** 필수첨부파일명 */
|
||||
esntlAtchFileNm?: string;
|
||||
/** 필수첨부여부 ("Y" | "N") */
|
||||
esntlAtchYn?: "Y" | "N";
|
||||
}
|
||||
|
||||
/** 필수첨부파일관리 서비스 인터페이스 */
|
||||
export interface EsntlAtchFileMngService {
|
||||
/** 필수첨부파일 트리 조회 */
|
||||
findTree(params: EsntlAtchFileMngSearchParams): Promise<EsntlAtchFileMngTreeResponseDto[]>;
|
||||
findTree(params: EsntlAtchFileMngTreeSearchParams): Promise<EsntlAtchFileMngTreeResponseDto[]>;
|
||||
/** 필수첨부파일관리 목록 조회 (페이징) */
|
||||
findPage(params: EsntlAtchFileMngSearchParams): Promise<PageResponse<EsntlAtchFileMng>>;
|
||||
/** 필수첨부파일관리 상세 조회 */
|
||||
findById(esntlAtchFileId: string): Promise<EsntlAtchFileMng>;
|
||||
/** 필수첨부파일관리 등록 */
|
||||
create(body: EsntlAtchFileMngSaveRequest): Promise<string>;
|
||||
/** 필수첨부파일관리 수정 */
|
||||
update(esntlAtchFileId: string, body: EsntlAtchFileMngSaveRequest): Promise<string>;
|
||||
/** 필수첨부파일관리 삭제 */
|
||||
remove(esntlAtchFileId: string): Promise<string>;
|
||||
}
|
||||
|
||||
/** 필수첨부파일관리 저장 요청 DTO */
|
||||
export interface EsntlAtchFileMngSaveRequest {
|
||||
/** 필수첨부파일아이디 */
|
||||
esntlAtchFileId: string;
|
||||
/** 문서구분공통코드 */
|
||||
docSeComCd?: string;
|
||||
/** 필수첨부파일명 */
|
||||
esntlAtchFileNm?: string;
|
||||
/** 필수첨부여부 ("Y" | "N") */
|
||||
esntlAtchYn?: "Y" | "N";
|
||||
/** 필수첨부파일비고 */
|
||||
esntlAtchFileRmrk?: string;
|
||||
}
|
||||
|
||||
@@ -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,30 @@
|
||||
import type { EsntlAtchFileMngSearchParams } from "@/entities/esntl";
|
||||
|
||||
export const ESNTL_ATCH_FILE_ALL_DOC_SE = "";
|
||||
|
||||
export const DEFAULT_ESNTL_ATCH_FILE_PAGE_SIZE = 100;
|
||||
|
||||
export const DEFAULT_ESNTL_ATCH_FILE_SORT = "regDt,desc";
|
||||
|
||||
export function createDefaultEsntlAtchFileSearchParams(): EsntlAtchFileMngSearchParams {
|
||||
return {
|
||||
page: 0,
|
||||
size: DEFAULT_ESNTL_ATCH_FILE_PAGE_SIZE,
|
||||
sort: DEFAULT_ESNTL_ATCH_FILE_SORT,
|
||||
};
|
||||
}
|
||||
|
||||
export function toEsntlAtchFileQueryParams(
|
||||
params: EsntlAtchFileMngSearchParams,
|
||||
): EsntlAtchFileMngSearchParams {
|
||||
const { docSeComCd, esntlAtchFileNm, esntlAtchYn, page, size, sort } = params;
|
||||
|
||||
return {
|
||||
...(docSeComCd ? { docSeComCd } : {}),
|
||||
...(esntlAtchFileNm ? { esntlAtchFileNm } : {}),
|
||||
...(esntlAtchYn ? { esntlAtchYn } : {}),
|
||||
page: page ?? 0,
|
||||
size: size ?? DEFAULT_ESNTL_ATCH_FILE_PAGE_SIZE,
|
||||
sort: sort ?? DEFAULT_ESNTL_ATCH_FILE_SORT,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
const EAF_ID_PREFIX = "EAF-";
|
||||
const EAF_ID_PATTERN = /^EAF-(\d+)$/;
|
||||
|
||||
export function generateEsntlAtchFileId(existingIds: string[]): string {
|
||||
const maxSeq = existingIds.reduce((max, id) => {
|
||||
const match = id.match(EAF_ID_PATTERN);
|
||||
if (!match) return max;
|
||||
return Math.max(max, Number(match[1]));
|
||||
}, 0);
|
||||
|
||||
return `${EAF_ID_PREFIX}${String(maxSeq + 1).padStart(4, "0")}`;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { CodeSelect } from "@/entities/code";
|
||||
import type { EsntlAtchFileMng } from "@/entities/esntl";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Input,
|
||||
Label,
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
Textarea,
|
||||
} from "@common/components";
|
||||
|
||||
export interface EsntlAtchFileFormValues {
|
||||
esntlAtchFileId: string;
|
||||
docSeComCd: string;
|
||||
esntlAtchFileNm: string;
|
||||
esntlAtchYn: "Y" | "N";
|
||||
esntlAtchFileRmrk: string;
|
||||
}
|
||||
|
||||
interface EsntlAtchFileFormDialogProps {
|
||||
mode: "create" | "edit";
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
initial?: EsntlAtchFileMng | null;
|
||||
generatedId?: string;
|
||||
isSubmitting?: boolean;
|
||||
onSubmit: (values: EsntlAtchFileFormValues) => void;
|
||||
}
|
||||
|
||||
function EsntlAtchFileFormDialog({
|
||||
mode,
|
||||
open,
|
||||
onOpenChange,
|
||||
initial,
|
||||
generatedId,
|
||||
isSubmitting,
|
||||
onSubmit,
|
||||
}: EsntlAtchFileFormDialogProps) {
|
||||
const { register, handleSubmit, reset, setValue, watch } = useForm<EsntlAtchFileFormValues>({
|
||||
defaultValues: {
|
||||
esntlAtchFileId: "",
|
||||
docSeComCd: "",
|
||||
esntlAtchFileNm: "",
|
||||
esntlAtchYn: "Y",
|
||||
esntlAtchFileRmrk: "",
|
||||
},
|
||||
});
|
||||
|
||||
const docSeComCd = watch("docSeComCd");
|
||||
const esntlAtchYn = watch("esntlAtchYn");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
if (mode === "edit" && initial) {
|
||||
reset({
|
||||
esntlAtchFileId: initial.esntlAtchFileId,
|
||||
docSeComCd: initial.docSeComCd ?? "",
|
||||
esntlAtchFileNm: initial.esntlAtchFileNm ?? "",
|
||||
esntlAtchYn: initial.esntlAtchYn ?? "Y",
|
||||
esntlAtchFileRmrk: initial.esntlAtchFileRmrk ?? "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
reset({
|
||||
esntlAtchFileId: generatedId ?? "",
|
||||
docSeComCd: "",
|
||||
esntlAtchFileNm: "",
|
||||
esntlAtchYn: "Y",
|
||||
esntlAtchFileRmrk: "",
|
||||
});
|
||||
}, [open, mode, initial, generatedId, reset]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[52rem]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{mode === "create" ? "필수첨부파일 등록" : "필수첨부파일 수정"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form className="space-y-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
{mode === "edit" ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="esntlAtchFileId">필수첨부파일아이디</Label>
|
||||
<p className="p-2 text-xl">{watch("esntlAtchFileId")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<input type="hidden" {...register("esntlAtchFileId", { required: true })} />
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label>문서구분</Label>
|
||||
<CodeSelect
|
||||
upCd="ASDOCSECD"
|
||||
value={docSeComCd}
|
||||
onValueChange={(v) => setValue("docSeComCd", v, { shouldValidate: true })}
|
||||
placeholder="문서구분 선택"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="esntlAtchFileNm">필수첨부파일명</Label>
|
||||
<Input
|
||||
id="esntlAtchFileNm"
|
||||
placeholder="예: 사업계획서"
|
||||
{...register("esntlAtchFileNm", { required: true })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>필수첨부여부</Label>
|
||||
<RadioGroup
|
||||
value={esntlAtchYn}
|
||||
onValueChange={(v) =>
|
||||
setValue("esntlAtchYn", v as "Y" | "N", { shouldValidate: true })
|
||||
}
|
||||
className="flex gap-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="Y" id="esntlAtchYn-y" />
|
||||
<Label htmlFor="esntlAtchYn-y">예</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="N" id="esntlAtchYn-n" />
|
||||
<Label htmlFor="esntlAtchYn-n">아니오</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="esntlAtchFileRmrk">비고</Label>
|
||||
<Textarea
|
||||
id="esntlAtchFileRmrk"
|
||||
rows={3}
|
||||
placeholder="비고를 입력하세요"
|
||||
{...register("esntlAtchFileRmrk")}
|
||||
className="!text-xl"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" color="white" onClick={() => onOpenChange(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export { EsntlAtchFileFormDialog };
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { getCoreRowModel, type PaginationState } from "@tanstack/react-table";
|
||||
|
||||
import { useDataTable } from "@/shared/lib";
|
||||
import { useCodeList } from "@/entities/code";
|
||||
import {
|
||||
type EsntlAtchFileMng,
|
||||
type EsntlAtchFileMngSearchParams,
|
||||
useEsntlAtchFileMngPage,
|
||||
} from "@/entities/esntl";
|
||||
|
||||
import { createEsntlAtchFileColumns } from "../model/esntl-atch-file-columns";
|
||||
import { toEsntlAtchFileQueryParams } from "../model/esntl-atch-file-search-params";
|
||||
|
||||
interface EsntlAtchFileListProps {
|
||||
searchParams: EsntlAtchFileMngSearchParams;
|
||||
pagination: PaginationState;
|
||||
onPaginationChange: (pagination: PaginationState) => void;
|
||||
onEdit: (row: EsntlAtchFileMng) => void;
|
||||
onDelete: (row: EsntlAtchFileMng) => void;
|
||||
}
|
||||
|
||||
function EsntlAtchFileList({
|
||||
searchParams,
|
||||
pagination,
|
||||
onPaginationChange,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: EsntlAtchFileListProps) {
|
||||
const { data: docSeCodes } = useCodeList("ASDOCSECD");
|
||||
|
||||
const queryParams = useMemo(
|
||||
() =>
|
||||
toEsntlAtchFileQueryParams({
|
||||
...searchParams,
|
||||
page: pagination.pageIndex,
|
||||
size: pagination.pageSize,
|
||||
}),
|
||||
[pagination.pageIndex, pagination.pageSize, searchParams],
|
||||
);
|
||||
|
||||
const pageQuery = useEsntlAtchFileMngPage(queryParams);
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
createEsntlAtchFileColumns({
|
||||
docSeCodes,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}),
|
||||
[docSeCodes, onDelete, onEdit],
|
||||
);
|
||||
|
||||
const { tableComponent } = useDataTable({
|
||||
query: pageQuery,
|
||||
tableOptions: {
|
||||
columns,
|
||||
state: { pagination },
|
||||
onPaginationChange: (updater) => {
|
||||
const next = typeof updater === "function" ? updater(pagination) : updater;
|
||||
onPaginationChange(next);
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
manualPagination: true,
|
||||
},
|
||||
dataTableOptions: {
|
||||
viewPagination: true,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="card card-border mx-0 bg-background pt-0">
|
||||
<div className="card-header">
|
||||
<h3 className="text-2xl font-semibold">필수첨부파일 목록</h3>
|
||||
</div>
|
||||
{tableComponent}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export { EsntlAtchFileList };
|
||||
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { useConfirm } from "@frontend-opensource/use-react-hooks";
|
||||
import type { PaginationState } from "@tanstack/react-table";
|
||||
|
||||
import { useCodeList } from "@/entities/code";
|
||||
import {
|
||||
type EsntlAtchFileMng,
|
||||
type EsntlAtchFileMngSearchParams,
|
||||
esntlAtchFileMngService,
|
||||
useCreateEsntlAtchFileMng,
|
||||
useDeleteEsntlAtchFileMng,
|
||||
useUpdateEsntlAtchFileMng,
|
||||
} from "@/entities/esntl";
|
||||
import {
|
||||
Button,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@common/components";
|
||||
|
||||
import {
|
||||
createDefaultEsntlAtchFileSearchParams,
|
||||
DEFAULT_ESNTL_ATCH_FILE_PAGE_SIZE,
|
||||
ESNTL_ATCH_FILE_ALL_DOC_SE,
|
||||
} from "../model/esntl-atch-file-search-params";
|
||||
import { generateEsntlAtchFileId } from "../model/generate-esntl-atch-file-id";
|
||||
|
||||
import {
|
||||
EsntlAtchFileFormDialog,
|
||||
type EsntlAtchFileFormValues,
|
||||
} from "./esntl-atch-file-form-dialog";
|
||||
import { EsntlAtchFileList } from "./esntl-atch-file-list";
|
||||
|
||||
const ALL_DOC_SE_VALUE = "__ALL__";
|
||||
|
||||
function EsntlAtchFileMngPage() {
|
||||
const { confirm } = useConfirm();
|
||||
const { data: docSeCodes, isPending: isDocSeLoading } = useCodeList("ASDOCSECD");
|
||||
|
||||
const [draftDocSeComCd, setDraftDocSeComCd] = useState(ESNTL_ATCH_FILE_ALL_DOC_SE);
|
||||
const [searchParams, setSearchParams] = useState<EsntlAtchFileMngSearchParams>(
|
||||
createDefaultEsntlAtchFileSearchParams(),
|
||||
);
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: DEFAULT_ESNTL_ATCH_FILE_PAGE_SIZE,
|
||||
});
|
||||
const [dialogMode, setDialogMode] = useState<"create" | "edit" | null>(null);
|
||||
const [editingRow, setEditingRow] = useState<EsntlAtchFileMng | null>(null);
|
||||
const [generatedId, setGeneratedId] = useState("");
|
||||
|
||||
const createMutation = useCreateEsntlAtchFileMng();
|
||||
const updateMutation = useUpdateEsntlAtchFileMng();
|
||||
const deleteMutation = useDeleteEsntlAtchFileMng();
|
||||
|
||||
const handleSearch = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
setSearchParams({
|
||||
...createDefaultEsntlAtchFileSearchParams(),
|
||||
...(draftDocSeComCd ? { docSeComCd: draftDocSeComCd } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenCreate = async () => {
|
||||
const page = await esntlAtchFileMngService.findPage({
|
||||
page: 0,
|
||||
size: DEFAULT_ESNTL_ATCH_FILE_PAGE_SIZE,
|
||||
sort: "esntlAtchFileId,desc",
|
||||
});
|
||||
const existingIds = page.content.map((row) => row.esntlAtchFileId);
|
||||
setGeneratedId(generateEsntlAtchFileId(existingIds));
|
||||
setEditingRow(null);
|
||||
setDialogMode("create");
|
||||
};
|
||||
|
||||
const handleEdit = useCallback((row: EsntlAtchFileMng) => {
|
||||
setEditingRow(row);
|
||||
setDialogMode("edit");
|
||||
}, []);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (row: EsntlAtchFileMng) => {
|
||||
if (!(await confirm("삭제하시겠습니까?"))) return;
|
||||
await deleteMutation.mutateAsync(row.esntlAtchFileId);
|
||||
},
|
||||
[confirm, deleteMutation],
|
||||
);
|
||||
|
||||
const handleFormSubmit = async (values: EsntlAtchFileFormValues) => {
|
||||
if (!values.docSeComCd || !values.esntlAtchFileNm || !values.esntlAtchYn) return;
|
||||
|
||||
const body = {
|
||||
esntlAtchFileId: values.esntlAtchFileId,
|
||||
docSeComCd: values.docSeComCd,
|
||||
esntlAtchFileNm: values.esntlAtchFileNm,
|
||||
esntlAtchYn: values.esntlAtchYn,
|
||||
esntlAtchFileRmrk: values.esntlAtchFileRmrk || undefined,
|
||||
};
|
||||
|
||||
if (dialogMode === "create") {
|
||||
if (!(await confirm("등록하시겠습니까?"))) return;
|
||||
await createMutation.mutateAsync(body);
|
||||
alert("저장되었습니다.");
|
||||
} else if (dialogMode === "edit" && editingRow) {
|
||||
await updateMutation.mutateAsync({
|
||||
esntlAtchFileId: editingRow.esntlAtchFileId,
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
setDialogMode(null);
|
||||
setEditingRow(null);
|
||||
setGeneratedId("");
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
setDialogMode(null);
|
||||
setEditingRow(null);
|
||||
setGeneratedId("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 px-[1.8rem] pb-6">
|
||||
<section className="card card-border mx-0 bg-background">
|
||||
<form
|
||||
className="flex flex-wrap items-end justify-between gap-4 p-4"
|
||||
onSubmit={handleSearch}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Label htmlFor="docSeComCd">문서구분</Label>
|
||||
<Select
|
||||
value={draftDocSeComCd || ALL_DOC_SE_VALUE}
|
||||
onValueChange={(value) =>
|
||||
setDraftDocSeComCd(value === ALL_DOC_SE_VALUE ? ESNTL_ATCH_FILE_ALL_DOC_SE : value)
|
||||
}
|
||||
disabled={isDocSeLoading}
|
||||
>
|
||||
<SelectTrigger id="docSeComCd" className="w-[20rem]">
|
||||
<SelectValue placeholder={isDocSeLoading ? "로딩 중…" : "전체"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_DOC_SE_VALUE}>전체</SelectItem>
|
||||
{docSeCodes?.map((item) => (
|
||||
<SelectItem key={item.lwpoCd} value={item.lwpoCd}>
|
||||
{item.lwpoCdNm}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button type="submit">조회</Button>
|
||||
</div>
|
||||
<Button type="button" onClick={handleOpenCreate}>
|
||||
등록
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<EsntlAtchFileList
|
||||
searchParams={searchParams}
|
||||
pagination={pagination}
|
||||
onPaginationChange={setPagination}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
{dialogMode ? (
|
||||
<EsntlAtchFileFormDialog
|
||||
mode={dialogMode}
|
||||
open={Boolean(dialogMode)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeDialog();
|
||||
}}
|
||||
initial={dialogMode === "edit" ? editingRow : null}
|
||||
generatedId={generatedId}
|
||||
isSubmitting={createMutation.isPending || updateMutation.isPending}
|
||||
onSubmit={handleFormSubmit}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { EsntlAtchFileMngPage };
|
||||
@@ -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;
|
||||
|
||||
Generated
+7725
-4172
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user