diff --git a/apps/as/app/(with-base-layout)/esntl-atch-file-mng/page.tsx b/apps/as/app/(with-base-layout)/esntl-atch-file-mng/page.tsx new file mode 100644 index 000000000..9516e46c5 --- /dev/null +++ b/apps/as/app/(with-base-layout)/esntl-atch-file-mng/page.tsx @@ -0,0 +1 @@ +export { EsntlAtchFileMngPage as default } from "@/pages/esntl-atch-file-mng"; diff --git a/apps/as/docs/essential-attachment-file-management/SPEC.md b/apps/as/docs/essential-attachment-file-management/SPEC.md new file mode 100644 index 000000000..72e7c06b7 --- /dev/null +++ b/apps/as/docs/essential-attachment-file-management/SPEC.md @@ -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` | `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. diff --git a/apps/as/middleware.ts b/apps/as/middleware.ts index 0b7be3348..94a1d0381 100644 --- a/apps/as/middleware.ts +++ b/apps/as/middleware.ts @@ -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*", "/"], }; diff --git a/apps/as/package.json b/apps/as/package.json index b30e4e45e..cf2d547ba 100644 --- a/apps/as/package.json +++ b/apps/as/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@common/assets": "workspace:*", + "@common/auth": "workspace:*", "@common/components": "workspace:*", "@common/network": "workspace:*", "@dnd-kit/core": "^6.3.1", diff --git a/apps/as/src/entities/acntg/api/acntg-service.ts b/apps/as/src/entities/acntg/api/acntg-service.ts index 60690a0b9..32afc1730 100644 --- a/apps/as/src/entities/acntg/api/acntg-service.ts +++ b/apps/as/src/entities/acntg/api/acntg-service.ts @@ -5,7 +5,7 @@ import type { AcntgSeMngTreeDto, AcntgService } from "../model/acntg-interface"; async function findTree(): Promise { const response = await client.get( - `${ENDPOINT.AS_COMMON_SERVICE.ACNTG_SE}/tree`, + `${ENDPOINT.AS_COMMON_SERVICE.ACNTG_SE}/list/tree`, ); return response.data; } diff --git a/apps/as/src/entities/auth/api/auth-service.ts b/apps/as/src/entities/auth/api/auth-service.ts new file mode 100644 index 000000000..0c1730054 --- /dev/null +++ b/apps/as/src/entities/auth/api/auth-service.ts @@ -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 { + 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 { + 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 { + clearAuthToken(); + if (typeof window !== "undefined") { + window.dispatchEvent(new CustomEvent("auth:logout")); + } +} + +export const authService: AuthService = { + login, + silentRefresh, + logout, +}; diff --git a/apps/as/src/entities/auth/api/login-service.ts b/apps/as/src/entities/auth/api/login-service.ts deleted file mode 100644 index 2dc784e97..000000000 --- a/apps/as/src/entities/auth/api/login-service.ts +++ /dev/null @@ -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 { - 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 { - 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(); diff --git a/apps/as/src/entities/auth/index.ts b/apps/as/src/entities/auth/index.ts index 97e142225..7f0d0081a 100644 --- a/apps/as/src/entities/auth/index.ts +++ b/apps/as/src/entities/auth/index.ts @@ -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"; diff --git a/apps/as/src/entities/auth/lib/auth-wrapper.tsx b/apps/as/src/entities/auth/lib/auth-wrapper.tsx index e92957163..ef454dfb9 100644 --- a/apps/as/src/entities/auth/lib/auth-wrapper.tsx +++ b/apps/as/src/entities/auth/lib/auth-wrapper.tsx @@ -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(`${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(`${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}; diff --git a/apps/as/src/entities/auth/lib/token-manager.ts b/apps/as/src/entities/auth/lib/token-manager.ts index c5eeffc3a..d449e711b 100644 --- a/apps/as/src/entities/auth/lib/token-manager.ts +++ b/apps/as/src/entities/auth/lib/token-manager.ts @@ -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, - 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; diff --git a/apps/as/src/entities/auth/model/auth-interface.ts b/apps/as/src/entities/auth/model/auth-interface.ts new file mode 100644 index 000000000..fb6e10630 --- /dev/null +++ b/apps/as/src/entities/auth/model/auth-interface.ts @@ -0,0 +1,10 @@ +export interface AuthPayload { + userId: string; + password: string; +} + +export interface AuthService { + login(payload: AuthPayload): Promise; + silentRefresh(): Promise; + logout(): Promise; +} diff --git a/apps/as/src/entities/auth/model/login-interface.ts b/apps/as/src/entities/auth/model/login-interface.ts deleted file mode 100644 index 5aaf53044..000000000 --- a/apps/as/src/entities/auth/model/login-interface.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface ILogin { - userId: string; - password: string; -} - -export interface LoginService { - login(payload: ILogin): Promise; - silentRefresh(): Promise; -} diff --git a/apps/as/src/entities/cklst/api/cklst-mng-service.ts b/apps/as/src/entities/cklst/api/cklst-mng-service.ts index d3b6713e8..fdae4e8ff 100644 --- a/apps/as/src/entities/cklst/api/cklst-mng-service.ts +++ b/apps/as/src/entities/cklst/api/cklst-mng-service.ts @@ -24,7 +24,7 @@ async function findById(cklstSn: number): Promise { } async function findTree(params: CklstTreeSearchParams): Promise { - const response = await client.get(`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST}/tree`, { + const response = await client.get(`${ENDPOINT.AS_CHECKLIST_SERVICE.CKLST}/list/tree`, { params, }); return response.data; diff --git a/apps/as/src/entities/cnstp/api/cnstp-service.ts b/apps/as/src/entities/cnstp/api/cnstp-service.ts index 00eb80371..e8f264b23 100644 --- a/apps/as/src/entities/cnstp/api/cnstp-service.ts +++ b/apps/as/src/entities/cnstp/api/cnstp-service.ts @@ -5,7 +5,7 @@ import type { CnstpSeMngTreeDto, CnstpService } from "../model/cnstp-interface"; async function findTree(): Promise { const response = await client.get( - `${ENDPOINT.AS_COMMON_SERVICE.CNSTP_SE}/tree`, + `${ENDPOINT.AS_COMMON_SERVICE.CNSTP_SE}/list/tree`, ); return response.data; } diff --git a/apps/as/src/entities/esntl/api/esntl-service.ts b/apps/as/src/entities/esntl/api/esntl-service.ts new file mode 100644 index 000000000..458940399 --- /dev/null +++ b/apps/as/src/entities/esntl/api/esntl-service.ts @@ -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 { + const response = await client.get( + `${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/list/tree`, + { params }, + ); + return response.data; +} + +async function findPage( + params: EsntlAtchFileMngSearchParams, +): Promise> { + const response = await client.get>( + ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE, + { params }, + ); + return response.data; +} + +async function findById(esntlAtchFileId: string): Promise { + const response = await client.get( + `${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/${esntlAtchFileId}`, + ); + return response.data; +} + +async function create(body: EsntlAtchFileMngSaveRequest): Promise { + const response = await client.post(ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE, body); + return response.data; +} + +async function update(esntlAtchFileId: string, body: EsntlAtchFileMngSaveRequest): Promise { + const response = await client.put( + `${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/${esntlAtchFileId}`, + body, + ); + return response.data; +} + +async function remove(esntlAtchFileId: string): Promise { + const response = await client.delete( + `${ENDPOINT.AS_COMMON_SERVICE.ESNTL_ATCH_FILE}/${esntlAtchFileId}`, + ); + return response.data; +} + +export const esntlAtchFileMngService = { + findTree, + findPage, + findById, + create, + update, + remove, +} satisfies EsntlAtchFileMngService; diff --git a/apps/as/src/entities/esntl/api/queries.ts b/apps/as/src/entities/esntl/api/queries.ts new file mode 100644 index 000000000..48e30fc68 --- /dev/null +++ b/apps/as/src/entities/esntl/api/queries.ts @@ -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 }); + }, + }); +} diff --git a/apps/as/src/entities/esntl/index.ts b/apps/as/src/entities/esntl/index.ts new file mode 100644 index 000000000..f359e1b44 --- /dev/null +++ b/apps/as/src/entities/esntl/index.ts @@ -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"; diff --git a/apps/as/src/entities/esntl/model/esntl-interface.ts b/apps/as/src/entities/esntl/model/esntl-interface.ts new file mode 100644 index 000000000..56fd8b1dd --- /dev/null +++ b/apps/as/src/entities/esntl/model/esntl-interface.ts @@ -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; + /** 필수첨부파일관리 목록 조회 (페이징) */ + findPage(params: EsntlAtchFileMngSearchParams): Promise>; + /** 필수첨부파일관리 상세 조회 */ + findById(esntlAtchFileId: string): Promise; + /** 필수첨부파일관리 등록 */ + create(body: EsntlAtchFileMngSaveRequest): Promise; + /** 필수첨부파일관리 수정 */ + update(esntlAtchFileId: string, body: EsntlAtchFileMngSaveRequest): Promise; + /** 필수첨부파일관리 삭제 */ + remove(esntlAtchFileId: string): Promise; +} + +/** 필수첨부파일관리 저장 요청 DTO */ +export interface EsntlAtchFileMngSaveRequest { + /** 문서구분공통코드 */ + docSeComCd: string; + /** 필수첨부파일명 */ + esntlAtchFileNm?: string; + /** 필수첨부여부 ("Y" | "N") */ + esntlAtchYn?: "Y" | "N"; + /** 필수첨부파일비고 */ + esntlAtchFileRmrk?: string; +} diff --git a/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts b/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts index 63d280d86..fbd58d39a 100644 --- a/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts +++ b/apps/as/src/entities/instl-sncn/model/instl-sncn-interface.ts @@ -23,7 +23,7 @@ export interface InstlSncnMngSearchParams { export interface InstlSncnMng { instlSncnDocNo: string; - dongId: string; + admdstCd: string; sidoNm?: string; sggNm?: string; chrgrNm?: string; diff --git a/apps/as/src/pages/auth/login/ui/login-page.tsx b/apps/as/src/pages/auth/login/ui/login-page.tsx index 60faf66bb..be1c65e13 100644 --- a/apps/as/src/pages/auth/login/ui/login-page.tsx +++ b/apps/as/src/pages/auth/login/ui/login-page.tsx @@ -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 : "로그인에 실패했습니다."; diff --git a/apps/as/src/pages/esntl-atch-file-mng/index.ts b/apps/as/src/pages/esntl-atch-file-mng/index.ts new file mode 100644 index 000000000..73333e63c --- /dev/null +++ b/apps/as/src/pages/esntl-atch-file-mng/index.ts @@ -0,0 +1 @@ +export { EsntlAtchFileMngPage } from "./ui/esntl-atch-file-mng-page"; diff --git a/apps/as/src/pages/esntl-atch-file-mng/model/esntl-atch-file-columns.tsx b/apps/as/src/pages/esntl-atch-file-mng/model/esntl-atch-file-columns.tsx new file mode 100644 index 000000000..ee3b04dda --- /dev/null +++ b/apps/as/src/pages/esntl-atch-file-mng/model/esntl-atch-file-columns.tsx @@ -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(); + +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 }) => ( +
e.stopPropagation()}> + + +
+ ), + }), + ]; +} diff --git a/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-form-dialog.tsx b/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-form-dialog.tsx new file mode 100644 index 000000000..0f1faf8a1 --- /dev/null +++ b/apps/as/src/pages/esntl-atch-file-mng/ui/esntl-atch-file-form-dialog.tsx @@ -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({ + 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 ( + + + + {mode === "create" ? "필수첨부파일 등록" : "필수첨부파일 수정"} + +
+ {mode === "edit" && initial ? ( +
+ +

+ {initial.esntlAtchFileId} +

+
+ ) : null} +
+ + setValue("docSeComCd", v, { shouldValidate: true })} + placeholder="문서구분 선택" + className="w-full" + /> +
+
+ + +
+
+ + + setValue("esntlAtchYn", v as "Y" | "N", { shouldValidate: true }) + } + className="flex gap-6" + > +
+ + +
+
+ + +
+
+
+
+ +