summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-11-07 09:40:41 +0900
committerjoonhoekim <26rote@gmail.com>2025-11-07 09:40:41 +0900
commit98e86ada15b2a867374188c79f78f5578018a911 (patch)
tree65a1004c59feb7e4497d79563f3ead095dfe9a06
parentaac4e61398ed829e9dfa2c038f76405f92563d14 (diff)
(김준회) 공통 컴포넌트 이해를 위한 문서 추가
-rw-r--r--README.md10
-rw-r--r--components/common/discipline/README.md96
-rw-r--r--components/common/selectors/nation/README.md183
-rw-r--r--components/common/selectors/purchase-group-code/README.md274
-rw-r--r--components/common/ship-type/README.md123
-rw-r--r--components/common/vendor/README.md211
-rw-r--r--lib/approval/CRONJOB_CONTEXT_FIX.md278
-rw-r--r--lib/approval/README.md514
-rw-r--r--lib/approval/README_CACHE.md253
9 files changed, 1916 insertions, 26 deletions
diff --git a/README.md b/README.md
index 40d5fea7..060e57e1 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,11 @@ zip -r public/archive-$(date +%Y%m%d-%H%M%S).zip . -x "./public/*" "./.git/*" ".
4. 환경변수 조정
- ide compare 기능 사용
- 외부개발서버, 개발서버(60.101.108.100), 품질서버(60.101.108.101), 운영서버(evcp.sevcp.com)는 환경변수를 환경에 맞게 변경해야 합니다. 현재 소스는 외부개발서버에 맞춰져 있으니, 배포시 환경변수를 그대로 사용할 수 없습니다.
+ - **필수 환경변수:** (자세한 내용은 하단 "결재 시스템" 섹션 참고)
+ - `NEXT_PUBLIC_BASE_URL`: 애플리케이션 Base URL
+ - 개발: `http://localhost:3000`
+ - 운영: `https://sevcp.com`
+ - `REVALIDATION_SECRET`: (선택) Revalidation API 보안 키
5. 배포
- `pm2 stop evcp && npm run build && pm2 start evcp`
@@ -36,9 +41,9 @@ zip -r public/archive-$(date +%Y%m%d-%H%M%S).zip . -x "./public/*" "./.git/*" ".
### pm2 관련
pm2는 쉘 연결이 종료되어도 프로그램을 계속 운영하기 위한 목적으로 사용합니다.
-현재는 간단한 설정으로 등록하여 사용하고 있습니다. (서버 재시작 대응등을 위해 추가 설정 필요)
+ecosystem.config.js 파일을 기준으로 설정합니다.
-`pm2 start npm --name "evcp" -- start`
+시작하려면, `pm2 start ecosystem.config.js` 명령어를 입력하세요.
## 로컬 실행을 위한 DB 준비
@@ -53,6 +58,7 @@ pm2는 쉘 연결이 종료되어도 프로그램을 계속 운영하기 위한
- 자동 포맷 기능을 종료해두세요 (vscode, prettier, biome 등)
- formatOnSave 옵션을 비활성화하는 설정이 `.vscode/settings.json` 설정에 작성되어 있습니다.
+
## 협업전략
- 이전 전략
diff --git a/components/common/discipline/README.md b/components/common/discipline/README.md
new file mode 100644
index 00000000..7e95dd4e
--- /dev/null
+++ b/components/common/discipline/README.md
@@ -0,0 +1,96 @@
+# 설계공종코드 선택기 (Engineering Discipline Selector)
+
+내부 PostgreSQL DB에서 설계공종코드(CD_CLF='PLJP43')를 조회하는 공용 컴포넌트입니다.
+
+## 기능
+
+- 내부 PostgreSQL DB에서 설계공종코드 실시간 검색
+- 코드와 이름으로 필터링 가능
+- 페이지네이션 지원
+- 반응형 다이얼로그 UI
+- 서버 액션을 활용한 최적화된 성능
+
+## 사용법
+
+### 기본 사용법
+
+```tsx
+import { EngineeringDisciplineSelector, DisciplineCode } from '@/components/common/discipline'
+
+function MyComponent() {
+ const [selectedDiscipline, setSelectedDiscipline] = useState<DisciplineCode>()
+
+ return (
+ <EngineeringDisciplineSelector
+ selectedDiscipline={selectedDiscipline}
+ onDisciplineSelect={(discipline) => {
+ console.log('선택된 설계공종:', discipline)
+ setSelectedDiscipline(discipline)
+ }}
+ />
+ )
+}
+```
+
+### 커스터마이징
+
+```tsx
+<EngineeringDisciplineSelector
+ selectedDiscipline={selectedDiscipline}
+ onDisciplineSelect={handleSelect}
+ placeholder="설계공종을 선택해주세요"
+ className="w-full h-10"
+ disabled={false}
+ searchOptions={{ limit: 50 }}
+/>
+```
+
+## Props
+
+| Prop | 타입 | 필수 | 기본값 | 설명 |
+|------|------|------|--------|------|
+| `selectedDiscipline` | `DisciplineCode` | ❌ | - | 선택된 설계공종 |
+| `onDisciplineSelect` | `(discipline: DisciplineCode) => void` | ✅ | - | 설계공종 선택 시 호출되는 콜백 |
+| `disabled` | `boolean` | ❌ | `false` | 비활성화 여부 |
+| `placeholder` | `string` | ❌ | `"설계공종을 선택하세요"` | 플레이스홀더 텍스트 |
+| `className` | `string` | ❌ | - | 추가 CSS 클래스 |
+| `searchOptions` | `Partial<DisciplineSearchOptions>` | ❌ | `{ limit: 100 }` | 검색 옵션 |
+
+## 타입
+
+### DisciplineCode
+```tsx
+interface DisciplineCode {
+ CD: string // 설계공종코드
+ USR_DF_CHAR_18: string // 설계공종명
+}
+```
+
+### DisciplineSearchOptions
+```tsx
+interface DisciplineSearchOptions {
+ searchTerm?: string // 검색어
+ limit?: number // 조회 제한 수
+}
+```
+
+## 데이터 소스
+
+**내부 PostgreSQL DB**
+- **테이블**: cmctbCd (NONSAP 스키마)
+- **조건**: CD_CLF = 'PLJP43'
+- **필드**:
+ - CD (설계공종코드)
+ - USR_DF_CHAR_18 (설계공종명)
+
+## 동작 방식
+
+1. **서버 액션 호출**: Next.js 서버 액션을 통한 안전한 데이터 페칭
+2. **PostgreSQL 조회**: Drizzle ORM을 사용하여 cmctbCd 테이블에서 조회
+3. **검색 최적화**: ILIKE를 사용한 대소문자 무관 검색
+4. **성능 최적화**: useTransition을 활용한 논블로킹 UI 업데이트
+
+## 주의사항
+
+- 내부 PostgreSQL DB 연결이 필요합니다
+- NONSAP 스키마의 cmctbCd 테이블에 데이터가 있어야 합니다
diff --git a/components/common/selectors/nation/README.md b/components/common/selectors/nation/README.md
new file mode 100644
index 00000000..f16c3a2f
--- /dev/null
+++ b/components/common/selectors/nation/README.md
@@ -0,0 +1,183 @@
+# 국가 선택기 (Nation Selector)
+
+국가 코드를 선택할 수 있는 컴포넌트들입니다. CMCTB_CD 테이블에서 CD_CLF='LE0010' 조건으로 국가 정보를 조회합니다.
+
+## 컴포넌트 구조
+
+- `nation-service.ts`: 국가 데이터 조회 서버 액션
+- `nation-selector.tsx`: 기본 국가 선택기 (트리거 버튼 포함)
+- `nation-single-selector.tsx`: 단일 선택 다이얼로그
+- `nation-multi-selector.tsx`: 다중 선택 다이얼로그
+
+## 데이터 구조
+
+```typescript
+interface NationCode {
+ CD: string // 2글자 국가코드 (예: KR)
+ CD2: string // 3글자 국가코드 (예: KOR)
+ CD3: string // 3글자 숫자 국가코드 (예: 410)
+ CDNM: string // 한국어 국가명 (예: 대한민국)
+ GRP_DSC: string // 영문 국가명 (예: Korea, Republic of)
+}
+```
+
+## 사용 예제
+
+### 1. 기본 국가 선택기
+
+```tsx
+import { useState } from 'react'
+import { NationSelector, NationCode } from '@/components/common/selectors/nation'
+
+function MyComponent() {
+ const [selectedNation, setSelectedNation] = useState<NationCode>()
+
+ return (
+ <NationSelector
+ selectedNation={selectedNation}
+ onNationSelect={setSelectedNation}
+ placeholder="국가를 선택하세요"
+ />
+ )
+}
+```
+
+### 2. 단일 선택 다이얼로그
+
+```tsx
+import { useState } from 'react'
+import { Button } from '@/components/ui/button'
+import { NationSingleSelector, NationCode } from '@/components/common/selectors/nation'
+
+function MyComponent() {
+ const [open, setOpen] = useState(false)
+ const [selectedNation, setSelectedNation] = useState<NationCode>()
+
+ return (
+ <>
+ <Button onClick={() => setOpen(true)}>
+ 국가 선택
+ </Button>
+
+ <NationSingleSelector
+ open={open}
+ onOpenChange={setOpen}
+ selectedNation={selectedNation}
+ onNationSelect={setSelectedNation}
+ title="국가 선택"
+ description="하나의 국가를 선택하세요"
+ showConfirmButtons={true}
+ onConfirm={(nation) => {
+ console.log('선택된 국가:', nation)
+ }}
+ onCancel={() => {
+ console.log('선택 취소됨')
+ }}
+ />
+ </>
+ )
+}
+```
+
+### 3. 다중 선택 다이얼로그
+
+```tsx
+import { useState } from 'react'
+import { Button } from '@/components/ui/button'
+import { NationMultiSelector, NationCode } from '@/components/common/selectors/nation'
+
+function MyComponent() {
+ const [open, setOpen] = useState(false)
+ const [selectedNations, setSelectedNations] = useState<NationCode[]>([])
+
+ return (
+ <>
+ <Button onClick={() => setOpen(true)}>
+ 국가 다중 선택 ({selectedNations.length}개 선택됨)
+ </Button>
+
+ <NationMultiSelector
+ open={open}
+ onOpenChange={setOpen}
+ selectedNations={selectedNations}
+ onNationsSelect={setSelectedNations}
+ title="국가 다중 선택"
+ description="여러 국가를 선택하세요"
+ maxSelection={5} // 최대 5개까지 선택 가능
+ onConfirm={(nations) => {
+ console.log('선택된 국가들:', nations)
+ }}
+ onCancel={() => {
+ console.log('선택 취소됨')
+ }}
+ />
+ </>
+ )
+}
+```
+
+### 4. 서버 액션 직접 사용
+
+```tsx
+import { getNationCodes, getNationCodeByCode } from '@/components/common/selectors/nation'
+
+// 모든 국가 조회
+const result = await getNationCodes()
+if (result.success) {
+ console.log('국가 목록:', result.data)
+}
+
+// 검색 조건으로 조회
+const searchResult = await getNationCodes({
+ searchTerm: '한국',
+ limit: 50
+})
+
+// 특정 국가 코드로 조회
+const korea = await getNationCodeByCode('KR')
+console.log('대한민국:', korea)
+```
+
+## Props 옵션
+
+### NationSelector
+
+- `selectedNation?: NationCode` - 선택된 국가
+- `onNationSelect: (nation: NationCode) => void` - 국가 선택 콜백
+- `disabled?: boolean` - 비활성화 여부
+- `placeholder?: string` - 플레이스홀더 텍스트
+- `className?: string` - 추가 CSS 클래스
+- `searchOptions?: Partial<NationSearchOptions>` - 검색 옵션
+
+### NationSingleSelector
+
+- `open: boolean` - 다이얼로그 열림 상태
+- `onOpenChange: (open: boolean) => void` - 다이얼로그 상태 변경 콜백
+- `selectedNation?: NationCode` - 선택된 국가
+- `onNationSelect: (nation: NationCode) => void` - 국가 선택 콜백
+- `onConfirm?: (nation: NationCode | undefined) => void` - 확인 버튼 콜백
+- `onCancel?: () => void` - 취소 버튼 콜백
+- `title?: string` - 다이얼로그 제목
+- `description?: string` - 다이얼로그 설명
+- `showConfirmButtons?: boolean` - 확인/취소 버튼 표시 여부
+
+### NationMultiSelector
+
+- `open: boolean` - 다이얼로그 열림 상태
+- `onOpenChange: (open: boolean) => void` - 다이얼로그 상태 변경 콜백
+- `selectedNations?: NationCode[]` - 선택된 국가들
+- `onNationsSelect: (nations: NationCode[]) => void` - 국가들 선택 콜백
+- `onConfirm?: (nations: NationCode[]) => void` - 확인 버튼 콜백
+- `onCancel?: () => void` - 취소 버튼 콜백
+- `title?: string` - 다이얼로그 제목
+- `description?: string` - 다이얼로그 설명
+- `maxSelection?: number` - 최대 선택 가능 개수
+
+## 특징
+
+- **검색 기능**: 국가코드, 국가명으로 실시간 검색
+- **페이지네이션**: 대량의 데이터를 페이지별로 표시
+- **디바운스**: 검색 성능 최적화
+- **다국어 지원**: 한국어명과 영문명 모두 표시
+- **접근성**: 키보드 네비게이션 및 스크린 리더 지원
+- **반응형**: 다양한 화면 크기에 대응
diff --git a/components/common/selectors/purchase-group-code/README.md b/components/common/selectors/purchase-group-code/README.md
new file mode 100644
index 00000000..b956a296
--- /dev/null
+++ b/components/common/selectors/purchase-group-code/README.md
@@ -0,0 +1,274 @@
+# 구매그룹코드 선택기 (Purchase Group Code Selector)
+
+구매그룹코드를 선택할 수 있는 컴포넌트들입니다. Oracle DB의 CMCTB_CDNM, CMCTB_CD 테이블에서 CD_CLF='MMA070' 조건으로 구매그룹 정보를 조회하며, 선택 시 해당 사번의 사용자 정보도 함께 반환합니다.
+
+## 컴포넌트 구조
+
+- `purchase-group-code-service.ts`: 구매그룹코드 데이터 조회 서버 액션 (Oracle DB 조회 + 사용자 정보 포함)
+- `purchase-group-code-selector.tsx`: 기본 구매그룹코드 선택기 (트리거 버튼 포함)
+- `purchase-group-code-single-selector.tsx`: 단일 선택 다이얼로그
+- `purchase-group-code-multi-selector.tsx`: 다중 선택 다이얼로그
+
+## 데이터 구조
+
+```typescript
+// 기본 구매그룹코드 정보
+interface PurchaseGroupCode {
+ PURCHASE_GROUP_CODE: string // 구매그룹코드
+ DISPLAY_NAME: string // 표시명 (이름_부서_퇴직/전배정보)
+ EMPLOYEE_NUMBER: string // 사번
+}
+
+// 사용자 정보를 포함한 구매그룹코드
+interface PurchaseGroupCodeWithUser extends PurchaseGroupCode {
+ user?: {
+ id: number
+ name: string
+ email: string
+ employeeNumber: string | null
+ userCode: string | null
+ } | null
+}
+```
+
+## 사용 예제
+
+### 1. 기본 구매그룹코드 선택기
+
+```tsx
+import { useState } from 'react'
+import { PurchaseGroupCodeSelector, PurchaseGroupCodeWithUser } from '@/components/common/selectors/purchase-group-code'
+
+function MyComponent() {
+ const [selectedCode, setSelectedCode] = useState<PurchaseGroupCodeWithUser>()
+
+ const handleSelect = (code: PurchaseGroupCodeWithUser) => {
+ console.log('선택된 구매그룹코드:', code.PURCHASE_GROUP_CODE)
+ console.log('표시명:', code.DISPLAY_NAME)
+ console.log('사번:', code.EMPLOYEE_NUMBER)
+ console.log('사용자 정보:', code.user)
+ setSelectedCode(code)
+ }
+
+ return (
+ <PurchaseGroupCodeSelector
+ selectedCode={selectedCode}
+ onCodeSelect={handleSelect}
+ placeholder="구매그룹코드를 선택하세요"
+ />
+ )
+}
+```
+
+### 2. 단일 선택 다이얼로그
+
+```tsx
+import { useState } from 'react'
+import { Button } from '@/components/ui/button'
+import {
+ PurchaseGroupCodeSingleSelector,
+ PurchaseGroupCodeWithUser
+} from '@/components/common/selectors/purchase-group-code'
+
+function MyComponent() {
+ const [open, setOpen] = useState(false)
+ const [selectedCode, setSelectedCode] = useState<PurchaseGroupCodeWithUser>()
+
+ return (
+ <>
+ <Button onClick={() => setOpen(true)}>
+ 구매그룹코드 선택
+ </Button>
+
+ <PurchaseGroupCodeSingleSelector
+ open={open}
+ onOpenChange={setOpen}
+ selectedCode={selectedCode}
+ onCodeSelect={setSelectedCode}
+ title="구매그룹코드 선택"
+ description="하나의 구매그룹코드를 선택하세요"
+ showConfirmButtons={true}
+ onConfirm={(code) => {
+ console.log('선택 완료:', code)
+ if (code?.user) {
+ console.log('연결된 사용자:', code.user.name)
+ }
+ }}
+ onCancel={() => {
+ console.log('선택 취소됨')
+ }}
+ />
+ </>
+ )
+}
+```
+
+### 3. 다중 선택 다이얼로그
+
+```tsx
+import { useState } from 'react'
+import { Button } from '@/components/ui/button'
+import {
+ PurchaseGroupCodeMultiSelector,
+ PurchaseGroupCodeWithUser
+} from '@/components/common/selectors/purchase-group-code'
+
+function MyComponent() {
+ const [open, setOpen] = useState(false)
+ const [selectedCodes, setSelectedCodes] = useState<PurchaseGroupCodeWithUser[]>([])
+
+ return (
+ <>
+ <Button onClick={() => setOpen(true)}>
+ 구매그룹코드 다중 선택 ({selectedCodes.length}개 선택됨)
+ </Button>
+
+ <PurchaseGroupCodeMultiSelector
+ open={open}
+ onOpenChange={setOpen}
+ selectedCodes={selectedCodes}
+ onCodesSelect={setSelectedCodes}
+ title="구매그룹코드 다중 선택"
+ description="여러 구매그룹코드를 선택하세요"
+ maxSelection={5} // 최대 5개까지 선택 가능
+ onConfirm={(codes) => {
+ console.log('선택된 구매그룹코드들:', codes)
+ codes.forEach(code => {
+ console.log(`${code.PURCHASE_GROUP_CODE}: ${code.user?.name}`)
+ })
+ }}
+ onCancel={() => {
+ console.log('선택 취소됨')
+ }}
+ />
+ </>
+ )
+}
+```
+
+### 4. 서버 액션 직접 사용
+
+```tsx
+import {
+ getPurchaseGroupCodes,
+ getPurchaseGroupCodeWithUser,
+ getPurchaseGroupCodeByEmployeeNumber
+} from '@/components/common/selectors/purchase-group-code'
+
+// 모든 구매그룹코드 조회
+const result = await getPurchaseGroupCodes()
+if (result.success) {
+ console.log('구매그룹코드 목록:', result.data)
+}
+
+// 검색 조건으로 조회
+const searchResult = await getPurchaseGroupCodes({
+ searchTerm: '홍길동',
+ limit: 50
+})
+
+// 특정 구매그룹코드로 조회 (사용자 정보 포함)
+const codeWithUser = await getPurchaseGroupCodeWithUser('PG001')
+if (codeWithUser) {
+ console.log('구매그룹코드:', codeWithUser.PURCHASE_GROUP_CODE)
+ console.log('사용자:', codeWithUser.user?.name)
+}
+
+// 사번으로 구매그룹코드 조회 (사용자 정보 포함)
+const byEmployeeNumber = await getPurchaseGroupCodeByEmployeeNumber('1234567')
+if (byEmployeeNumber) {
+ console.log('해당 사번의 구매그룹코드:', byEmployeeNumber.PURCHASE_GROUP_CODE)
+}
+```
+
+## Props 옵션
+
+### PurchaseGroupCodeSelector
+
+- `selectedCode?: PurchaseGroupCodeWithUser` - 선택된 구매그룹코드
+- `onCodeSelect: (code: PurchaseGroupCodeWithUser) => void` - 구매그룹코드 선택 콜백
+- `disabled?: boolean` - 비활성화 여부
+- `placeholder?: string` - 플레이스홀더 텍스트
+- `className?: string` - 추가 CSS 클래스
+- `searchOptions?: Partial<PurchaseGroupCodeSearchOptions>` - 검색 옵션
+
+### PurchaseGroupCodeSingleSelector
+
+- `open: boolean` - 다이얼로그 열림 상태
+- `onOpenChange: (open: boolean) => void` - 다이얼로그 상태 변경 콜백
+- `selectedCode?: PurchaseGroupCodeWithUser` - 선택된 구매그룹코드
+- `onCodeSelect: (code: PurchaseGroupCodeWithUser) => void` - 구매그룹코드 선택 콜백
+- `onConfirm?: (code: PurchaseGroupCodeWithUser | undefined) => void` - 확인 버튼 콜백
+- `onCancel?: () => void` - 취소 버튼 콜백
+- `title?: string` - 다이얼로그 제목
+- `description?: string` - 다이얼로그 설명
+- `showConfirmButtons?: boolean` - 확인/취소 버튼 표시 여부
+
+### PurchaseGroupCodeMultiSelector
+
+- `open: boolean` - 다이얼로그 열림 상태
+- `onOpenChange: (open: boolean) => void` - 다이얼로그 상태 변경 콜백
+- `selectedCodes?: PurchaseGroupCodeWithUser[]` - 선택된 구매그룹코드들
+- `onCodesSelect: (codes: PurchaseGroupCodeWithUser[]) => void` - 구매그룹코드들 선택 콜백
+- `onConfirm?: (codes: PurchaseGroupCodeWithUser[]) => void` - 확인 버튼 콜백
+- `onCancel?: () => void` - 취소 버튼 콜백
+- `title?: string` - 다이얼로그 제목
+- `description?: string` - 다이얼로그 설명
+- `maxSelection?: number` - 최대 선택 가능 개수
+
+## 특징
+
+- **Oracle DB 연동**: Oracle NonSAP 데이터베이스에서 실시간 조회
+- **폴백 테스트 데이터**: Oracle DB 연결 실패 시 자동으로 테스트 데이터 사용
+- **사용자 정보 통합**: 선택 시 사번으로 연결된 사용자 정보 자동 조회
+- **검색 기능**: 구매그룹코드, 이름, 사번으로 실시간 검색
+- **페이지네이션**: 대량의 데이터를 페이지별로 표시
+- **디바운스**: 검색 성능 최적화
+- **다중 선택 지원**: 여러 구매그룹코드 동시 선택 가능
+- **접근성**: 키보드 네비게이션 및 스크린 리더 지원
+- **반응형**: 다양한 화면 크기에 대응
+
+## 데이터 소스
+
+구매그룹코드는 Oracle DB의 다음 테이블에서 조회됩니다:
+
+```sql
+SELECT CD.CD AS PURCHASE_GROUP_CODE,
+ NM.CDNM AS DISPLAY_NAME,
+ CD.USR_DF_CHAR_9 AS EMPLOYEE_NUMBER
+FROM CMCTB_CDNM NM
+JOIN CMCTB_CD CD ON NM.CD_CLF = CD.CD_CLF
+ AND NM.CD = CD.CD
+ AND NM.CD2 = CD.CD3
+WHERE NM.CD_CLF = 'MMA070'
+ AND CD.USR_DF_CHAR_9 IS NOT NULL
+```
+
+사용자 정보는 PostgreSQL DB의 `users` 테이블에서 `employeeNumber`로 조회됩니다.
+
+## 주의사항
+
+1. **Oracle DB 연결 필요**: 이 컴포넌트는 Oracle DB 연결이 필요합니다. 환경 변수가 올바르게 설정되어 있는지 확인하세요.
+2. **폴백 테스트 데이터**: Oracle DB 연결 실패 시 하드코딩된 10개의 테스트 데이터가 자동으로 사용됩니다. 테스트 환경에서 유용합니다.
+3. **사용자 정보 매칭**: 사번으로 사용자 정보를 조회하므로, 사용자 테이블에 해당 사번이 존재해야 합니다.
+4. **성능**: 검색 시 Oracle DB를 직접 조회하므로, 대량 데이터 환경에서는 인덱스 설정이 중요합니다.
+
+## 폴백 테스트 데이터
+
+Oracle DB 연결이 불가능한 테스트 환경에서는 다음 10개의 구매그룹코드가 자동으로 제공됩니다:
+
+- `12L` - 김철수_구매팀_재직
+- `32F` - 이영희_자재팀_재직
+- `45A` - 박민수_조달팀_재직
+- `67K` - 정수진_구매1팀_재직
+- `89D` - 최동욱_구매2팀_재직
+- `11B` - 강미라_자재관리팀_재직
+- `23G` - 윤성호_구매기획팀_재직
+- `56H` - 임지훈_조달지원팀_재직
+- `78M` - 한소희_구매운영팀_재직
+- `90C` - 오준석_전략구매팀_재직
+
+## 관련 서비스
+
+- 구매그룹코드 동기화: `lib/nonsap-sync/purchase-group-code/purchase-group-code-sync.ts`
+- 사용자 서비스: `lib/users/service.ts`
diff --git a/components/common/ship-type/README.md b/components/common/ship-type/README.md
new file mode 100644
index 00000000..4b50773d
--- /dev/null
+++ b/components/common/ship-type/README.md
@@ -0,0 +1,123 @@
+# 선종 선택기 (Ship Type Selector)
+
+선종 정보를 검색하고 선택할 수 있는 컴포넌트입니다.
+
+## 컴포넌트 구조
+
+```text
+components/common/ship-type/
+├── ship-type-service.ts # 서버 액션 (선종 데이터 조회)
+├── ship-type-selector.tsx # 선종 선택기 컴포넌트
+├── index.ts # 모듈 export
+└── README.md # 문서
+```
+
+## 데이터 소스
+
+**내부 PostgreSQL DB - `cmctb_cdnm` 테이블**
+
+- 조건: `CD_CLF = 'PSA330' AND DEL_YN = 'N'`
+- 선종코드: `CD` 컬럼
+- 선종명: `CDNM` 컬럼
+
+## 기본 사용법
+
+```tsx
+import { ShipTypeSelector, ShipTypeItem } from '@/components/common/ship-type'
+
+function MyComponent() {
+ const [selectedShipType, setSelectedShipType] = useState<ShipTypeItem | undefined>()
+
+ const handleShipTypeSelect = (shipType: ShipTypeItem) => {
+ setSelectedShipType(shipType)
+ console.log('선택된 선종:', shipType.CD, shipType.CDNM)
+ }
+
+ return (
+ <ShipTypeSelector
+ selectedShipType={selectedShipType}
+ onShipTypeSelect={handleShipTypeSelect}
+ placeholder="선종을 선택하세요"
+ />
+ )
+}
+```
+
+## Props
+
+### ShipTypeSelector
+
+| Prop | Type | Default | Description |
+|------|------|---------|-------------|
+| `selectedShipType` | `ShipTypeItem \| undefined` | - | 현재 선택된 선종 |
+| `onShipTypeSelect` | `(shipType: ShipTypeItem) => void` | - | 선종 선택 시 호출되는 콜백 함수 |
+| `disabled` | `boolean` | `false` | 선택기 비활성화 여부 |
+| `placeholder` | `string` | `"선종을 선택하세요"` | 선택되지 않았을 때 표시할 텍스트 |
+| `className` | `string` | - | 추가 CSS 클래스 |
+
+## 타입 정의
+
+### ShipTypeItem
+
+```tsx
+interface ShipTypeItem {
+ CD: string // 선종코드
+ CDNM: string // 선종명
+ displayText: string // 표시용 텍스트 (CD + " - " + CDNM)
+}
+```
+
+### ShipTypeSearchOptions
+
+```tsx
+interface ShipTypeSearchOptions {
+ searchTerm?: string // 검색어 (선종코드 또는 선종명)
+ limit?: number // 조회 결과 제한 (기본값: 100)
+}
+```
+
+## 서버 액션
+
+### getShipTypes(options)
+
+선종 목록을 조회합니다.
+
+```tsx
+const result = await getShipTypes({
+ searchTerm: "CONT",
+ limit: 50
+})
+
+if (result.success) {
+ console.log('선종 목록:', result.data)
+} else {
+ console.error('오류:', result.error)
+}
+```
+
+### getShipTypeByCode(code)
+
+특정 선종코드로 선종 정보를 조회합니다.
+
+```tsx
+const shipType = await getShipTypeByCode("CONT")
+if (shipType) {
+ console.log('선종 정보:', shipType.CD, shipType.CDNM)
+}
+```
+
+## 특징
+
+- ✅ **즉시 검색**: 검색어 입력 시 실시간으로 결과 필터링
+- ✅ **디바운싱**: 300ms 디바운스로 API 호출 최적화
+- ✅ **서버 액션**: `useTransition`을 사용한 논블로킹 서버 호출
+- ✅ **페이지네이션**: 대량 데이터 지원 (기본 페이지당 10개)
+- ✅ **검색 최적화**: 선종코드와 선종명 모두 검색 가능
+- ✅ **사용자 친화적**: Dialog 기반 선택 UI
+
+## 주의사항
+
+- 선종 데이터는 약 50개 정도로 페이지네이션 없이도 충분히 처리 가능
+- 검색은 선종코드(`CD`)와 선종명(`CDNM`) 모두에서 수행됩니다
+- 대소문자 구분 없이 검색 가능 (ILIKE 사용)
+- `DEL_YN = 'N'` 조건으로 삭제되지 않은 선종만 조회
diff --git a/components/common/vendor/README.md b/components/common/vendor/README.md
new file mode 100644
index 00000000..7c9e54d9
--- /dev/null
+++ b/components/common/vendor/README.md
@@ -0,0 +1,211 @@
+# 벤더 선택기 (Vendor Selector)
+
+벤더 선택을 위한 공용 컴포넌트입니다. 다양한 형태로 벤더를 검색하고 선택할 수 있는 기능을 제공합니다.
+
+## 기능
+
+- PostgreSQL DB에서 벤더 실시간 검색 및 선택
+- 벤더명, 벤더코드로 필터링 가능
+- 상태별 필터링 지원 (ACTIVE, PENDING_REVIEW, APPROVED, INACTIVE 등)
+- 페이지네이션 지원
+- 단일/다중 선택 모드
+- 최대 선택 개수 제한
+- 제외 벤더 설정 가능
+- 반응형 다이얼로그 UI
+
+## 컴포넌트 구조
+
+### 1. VendorSelector (기본 선택기)
+
+Popover 기반의 기본 벤더 선택기 컴포넌트
+
+### 2. VendorSelectorDialogSingle (단일 선택 다이얼로그)
+
+Dialog 형태의 단일 벤더 선택 컴포넌트
+
+### 3. VendorSelectorDialogMulti (다중 선택 다이얼로그)
+
+Dialog 형태의 다중 벤더 선택 컴포넌트
+
+## 사용법
+
+### 기본 선택기 사용법
+
+```tsx
+import { VendorSelector, VendorSearchItem } from '@/components/common/vendor'
+
+function MyComponent() {
+ const [selectedVendors, setSelectedVendors] = useState<VendorSearchItem[]>([])
+
+ return (
+ <VendorSelector
+ selectedVendors={selectedVendors}
+ onVendorsChange={(vendors) => {
+ console.log('선택된 벤더들:', vendors)
+ setSelectedVendors(vendors)
+ }}
+ placeholder="벤더를 검색하세요..."
+ statusFilter="ACTIVE" // ACTIVE 상태의 벤더만 표시
+ />
+ )
+}
+```
+
+### 단일 선택 다이얼로그 사용법
+
+```tsx
+import { VendorSelectorDialogSingle, VendorSearchItem } from '@/components/common/vendor'
+
+function MyComponent() {
+ const [selectedVendor, setSelectedVendor] = useState<VendorSearchItem | null>(null)
+
+ return (
+ <VendorSelectorDialogSingle
+ triggerLabel="벤더 선택"
+ selectedVendor={selectedVendor}
+ onVendorSelect={(vendor) => {
+ console.log('선택된 벤더:', vendor)
+ setSelectedVendor(vendor)
+ }}
+ title="협력업체 선택"
+ description="프로젝트에 참여할 협력업체를 선택해주세요."
+ />
+ )
+}
+```
+
+### 다중 선택 다이얼로그 사용법
+
+```tsx
+import { VendorSelectorDialogMulti, VendorSearchItem } from '@/components/common/vendor'
+
+function MyComponent() {
+ const [selectedVendors, setSelectedVendors] = useState<VendorSearchItem[]>([])
+
+ return (
+ <VendorSelectorDialogMulti
+ triggerLabel="벤더 선택 (다중)"
+ selectedVendors={selectedVendors}
+ onVendorsSelect={(vendors) => {
+ console.log('선택된 벤더들:', vendors)
+ setSelectedVendors(vendors)
+ }}
+ maxSelections={5}
+ title="협력업체 선택"
+ description="프로젝트에 참여할 협력업체들을 선택해주세요. (최대 5개)"
+ showSelectedInTrigger={true}
+ />
+ )
+}
+```
+
+## Props
+
+### VendorSelector Props
+
+| Prop | 타입 | 필수 | 기본값 | 설명 |
+|------|------|------|--------|------|
+| `selectedVendors` | `VendorSearchItem[]` | ❌ | `[]` | 선택된 벤더들 |
+| `onVendorsChange` | `(vendors: VendorSearchItem[]) => void` | ❌ | - | 벤더 선택 변경 시 호출되는 콜백 |
+| `singleSelect` | `boolean` | ❌ | `false` | 단일 선택 모드 여부 |
+| `placeholder` | `string` | ❌ | `"벤더를 검색하세요..."` | 검색 입력창 placeholder |
+| `noValuePlaceHolder` | `string` | ❌ | `"벤더를 검색해주세요"` | 선택된 벤더가 없을 때 표시되는 텍스트 |
+| `disabled` | `boolean` | ❌ | `false` | 비활성화 여부 |
+| `className` | `string` | ❌ | - | 추가 CSS 클래스 |
+| `closeOnSelect` | `boolean` | ❌ | `true` | 선택 후 자동 닫기 여부 |
+| `excludeVendorIds` | `Set<number>` | ❌ | - | 제외할 벤더 ID들 |
+| `showInitialData` | `boolean` | ❌ | `true` | 초기 데이터 표시 여부 |
+| `maxSelections` | `number` | ❌ | - | 최대 선택 가능한 벤더 개수 |
+| `statusFilter` | `string` | ❌ | - | 벤더 상태 필터 |
+
+### VendorSelectorDialogSingle Props
+
+| Prop | 타입 | 필수 | 기본값 | 설명 |
+|------|------|------|--------|------|
+| `triggerLabel` | `string` | ❌ | `"벤더 선택"` | 트리거 버튼 텍스트 |
+| `selectedVendor` | `VendorSearchItem \| null` | ❌ | `null` | 선택된 벤더 |
+| `onVendorSelect` | `(vendor: VendorSearchItem \| null) => void` | ❌ | - | 벤더 선택 완료 시 호출되는 콜백 |
+| `placeholder` | `string` | ❌ | `"벤더를 검색하세요..."` | 검색 입력창 placeholder |
+| `title` | `string` | ❌ | `"벤더 선택"` | Dialog 제목 |
+| `description` | `string` | ❌ | `"원하는 벤더를 검색하고 선택해주세요."` | Dialog 설명 |
+| `disabled` | `boolean` | ❌ | `false` | 트리거 버튼 비활성화 여부 |
+| `triggerVariant` | `ButtonVariant` | ❌ | `"outline"` | 트리거 버튼 variant |
+| `excludeVendorIds` | `Set<number>` | ❌ | - | 제외할 벤더 ID들 |
+| `showInitialData` | `boolean` | ❌ | `true` | 초기 데이터 표시 여부 |
+| `statusFilter` | `string` | ❌ | - | 벤더 상태 필터 |
+
+### VendorSelectorDialogMulti Props
+
+VendorSelectorDialogSingle과 유사하지만 다음이 추가됩니다:
+
+| Prop | 타입 | 필수 | 기본값 | 설명 |
+|------|------|------|--------|------|
+| `selectedVendors` | `VendorSearchItem[]` | ❌ | `[]` | 선택된 벤더들 |
+| `onVendorsSelect` | `(vendors: VendorSearchItem[]) => void` | ❌ | - | 벤더 선택 완료 시 호출되는 콜백 |
+| `maxSelections` | `number` | ❌ | - | 최대 선택 가능한 벤더 개수 |
+| `showSelectedInTrigger` | `boolean` | ❌ | `true` | 트리거 버튼에서 선택된 벤더들을 표시할지 여부 |
+
+## 타입
+
+### VendorSearchItem
+
+```tsx
+interface VendorSearchItem {
+ id: number // 벤더 ID
+ vendorName: string // 벤더명
+ vendorCode: string | null // 벤더코드 (없을 수 있음)
+ status: string // 벤더 상태
+ displayText: string // 표시용 텍스트 (vendorName + vendorCode)
+}
+```
+
+### VendorSearchOptions
+
+```tsx
+interface VendorSearchOptions {
+ searchTerm?: string // 검색어
+ statusFilter?: string // 상태 필터
+ limit?: number // 조회 제한 수
+ offset?: number // 조회 시작 위치
+ sortBy?: 'vendorName' | 'vendorCode' | 'status' // 정렬 기준
+ sortOrder?: 'asc' | 'desc' // 정렬 순서
+}
+```
+
+### VendorPagination
+
+```tsx
+interface VendorPagination {
+ page: number // 현재 페이지
+ perPage: number // 페이지당 항목 수
+ total: number // 전체 항목 수
+ pageCount: number // 전체 페이지 수
+ hasNextPage: boolean // 다음 페이지 존재 여부
+ hasPrevPage: boolean // 이전 페이지 존재 여부
+}
+```
+
+## 데이터 소스
+
+**PostgreSQL DB의 `vendors` 테이블**
+
+- **필드**:
+ - id (벤더 ID)
+ - vendorName (벤더명)
+ - vendorCode (벤더코드)
+ - status (벤더 상태)
+
+## 동작 방식
+
+1. **서버 액션 호출**: Next.js 서버 액션을 통한 안전한 데이터 페칭
+2. **PostgreSQL 조회**: Drizzle ORM을 사용하여 vendors 테이블에서 조회
+3. **검색 최적화**: ILIKE를 사용한 대소문자 무관 검색
+4. **페이지네이션**: 대량 데이터 처리를 위한 페이지네이션 지원
+5. **상태 필터링**: 벤더 상태별 필터링 지원
+
+## 주의사항
+
+- PostgreSQL DB 연결이 필요합니다
+- vendors 스키마의 vendors 테이블에 데이터가 있어야 합니다
+- 검색은 벤더명과 벤더코드에 대해서만 수행됩니다
+- 상태 값은 테이블에 정의된 값을 사용해야 합니다
diff --git a/lib/approval/CRONJOB_CONTEXT_FIX.md b/lib/approval/CRONJOB_CONTEXT_FIX.md
new file mode 100644
index 00000000..a8169a5e
--- /dev/null
+++ b/lib/approval/CRONJOB_CONTEXT_FIX.md
@@ -0,0 +1,278 @@
+# Cronjob Request Context 문제 해결
+
+## 🔍 문제 상황
+
+결재 완료 후 `pendingActions`에서 핸들러를 호출할 때, **node-cron의 cronjob이 함수를 호출하므로 Request Context가 없어** 다음 문제가 발생했습니다:
+
+```
+❌ Error: `headers` was called outside a request scope.
+❌ Error: Invariant: static generation store missing in revalidateTag
+```
+
+### 발생 위치
+
+- `lib/vendors/service.ts` - `approveVendors()`, `rejectVendors()`
+- Next.js의 `headers()`, `revalidateTag()` 등의 API 사용
+
+### 근본 원인
+
+```typescript
+Cronjob (node-cron)
+ ↓ (Request Context 없음)
+ApprovalExecutionSaga
+ ↓
+Handler (예: approveVendorWithMDGInternal)
+ ↓
+approveVendors()
+ ↓ headers() ❌ - AsyncLocalStorage에 접근 불가
+ ↓ revalidateTag() ❌ - Static generation store 없음
+```
+
+## ✅ 해결 방법
+
+### 1. API 기반 Revalidation 사용
+
+Request Context가 없는 환경에서는 **내부 API를 호출**하여 캐시를 무효화합니다.
+
+```typescript
+// ❌ Before: 직접 호출 (Request Context 필요)
+revalidateTag("vendors");
+revalidateTag("users");
+
+// ✅ After: API 호출 (Request Context 불필요)
+const { revalidateApprovalRelatedCaches } = await import('@/lib/revalidation-utils');
+await revalidateApprovalRelatedCaches();
+```
+
+### 2. 환경변수로 BaseURL 구성
+
+`headers()`를 사용하는 대신 환경변수를 사용합니다.
+
+```typescript
+// ❌ Before: headers()로 동적 구성
+const headersList = await headers();
+const host = headersList.get('host') || 'localhost:3000';
+const protocol = headersList.get('x-forwarded-proto') || 'http';
+const baseUrl = `${protocol}://${host}`;
+
+// ✅ After: 환경변수 사용
+const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
+```
+
+## 📁 변경된 파일
+
+### 1. 새로 추가된 파일
+
+#### `lib/revalidation-utils.ts`
+Request Context가 없는 환경에서 캐시를 무효화하기 위한 유틸리티 함수들:
+
+```typescript
+// 범용 revalidation
+await revalidateViaCronJob({ tags: ['vendors', 'users'] });
+
+// 특정 도메인별 revalidation
+await revalidateVendorCaches();
+await revalidateUserCaches();
+await revalidateApprovalRelatedCaches();
+```
+
+### 2. 수정된 파일
+
+#### `lib/vendors/service.ts`
+
+**approveVendors() 함수:**
+- ✅ `headers()` 제거 → `process.env.NEXT_PUBLIC_BASE_URL` 사용
+- ✅ `revalidateTag()` 제거 → `revalidateApprovalRelatedCaches()` 사용
+
+**rejectVendors() 함수:**
+- ✅ `headers()` 제거 → `process.env.NEXT_PUBLIC_BASE_URL` 사용
+- ✅ `revalidateTag()` 제거 → `revalidateVendorCaches()`, `revalidateUserCaches()` 사용
+
+## 🔧 설정 필요
+
+### 환경변수 설정
+
+`.env` 파일에 다음 환경변수를 추가하세요:
+
+```bash
+# 애플리케이션 Base URL (이메일 링크 등에 사용)
+NEXT_PUBLIC_BASE_URL=https://your-domain.com
+
+# Revalidation API 보안 (선택사항)
+REVALIDATION_SECRET=your-secret-key
+```
+
+**개발 환경:**
+```bash
+NEXT_PUBLIC_BASE_URL=http://localhost:3000
+```
+
+**프로덕션 환경:**
+```bash
+NEXT_PUBLIC_BASE_URL=https://evcp.your-company.com
+```
+
+## 🎯 작동 방식
+
+### 1. Cronjob에서 핸들러 실행
+
+```typescript
+// lib/approval/approval-polling-service.ts
+const saga = new ApprovalExecutionSaga(apInfId);
+await saga.execute(); // ← Request Context 없음
+```
+
+### 2. 핸들러에서 Service 함수 호출
+
+```typescript
+// lib/vendors/approval-handlers.ts
+export async function approveVendorWithMDGInternal(payload) {
+ // MDG 전송...
+
+ const approveResult = await approveVendors({
+ ids: payload.vendorIds,
+ userId: payload.userId,
+ }); // ← Request Context 없는 상태로 호출됨
+}
+```
+
+### 3. Service에서 API 기반 Revalidation
+
+```typescript
+// lib/vendors/service.ts
+export async function approveVendors(input) {
+ await db.transaction(async (tx) => {
+ // 1. 벤더 승인 처리
+ // 2. 이메일 발송 (✅ 환경변수 사용)
+ const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
+ const loginUrl = `${baseUrl}/${userLang}/login`;
+ });
+
+ // 3. 캐시 무효화 (✅ API 호출 사용)
+ const { revalidateApprovalRelatedCaches } = await import('@/lib/revalidation-utils');
+ await revalidateApprovalRelatedCaches();
+}
+```
+
+### 4. Revalidation Utils에서 내부 API 호출
+
+```typescript
+// lib/revalidation-utils.ts
+export async function revalidateViaCronJob(options) {
+ const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000';
+
+ const response = await fetch(`${baseUrl}/api/revalidate/approval`, {
+ method: 'POST',
+ body: JSON.stringify({
+ tags: options.tags,
+ secret: process.env.REVALIDATION_SECRET,
+ }),
+ });
+
+ return response.json();
+}
+```
+
+### 5. API Route에서 실제 Revalidation 실행
+
+```typescript
+// app/api/revalidate/approval/route.ts
+export async function POST(request: NextRequest) {
+ // ✅ 여기는 HTTP Request Context가 있음!
+ const { tags } = await request.json();
+
+ for (const tag of tags) {
+ revalidateTag(tag); // ✅ Request Context가 있으므로 작동함
+ }
+
+ return Response.json({ success: true });
+}
+```
+
+## 📊 흐름도
+
+```
+┌─────────────────────────────────────────────────────┐
+│ Cronjob (node-cron) - 1분마다 │
+│ Request Context: ❌ 없음 │
+└─────────────────────────────────────────────────────┘
+ ↓
+┌─────────────────────────────────────────────────────┐
+│ ApprovalExecutionSaga │
+│ - Knox 결재 상태 확인 │
+│ - 승인된 경우 Handler 실행 │
+└─────────────────────────────────────────────────────┘
+ ↓
+┌─────────────────────────────────────────────────────┐
+│ approveVendorWithMDGInternal() │
+│ - MDG 전송 │
+│ - approveVendors() 호출 │
+└─────────────────────────────────────────────────────┘
+ ↓
+┌─────────────────────────────────────────────────────┐
+│ approveVendors() - lib/vendors/service.ts │
+│ ✅ baseUrl = process.env.NEXT_PUBLIC_BASE_URL │
+│ ✅ await revalidateViaCronJob({ tags: [...] }) │
+└─────────────────────────────────────────────────────┘
+ ↓
+┌─────────────────────────────────────────────────────┐
+│ revalidateViaCronJob() - lib/revalidation-utils.ts │
+│ ✅ fetch('/api/revalidate/approval', ...) │
+└─────────────────────────────────────────────────────┘
+ ↓
+┌─────────────────────────────────────────────────────┐
+│ POST /api/revalidate/approval │
+│ Request Context: ✅ 있음! │
+│ ✅ revalidateTag() 사용 가능 │
+└─────────────────────────────────────────────────────┘
+```
+
+## 🚨 주의사항
+
+### 1. 환경변수 설정 필수
+
+`NEXT_PUBLIC_BASE_URL`이 설정되지 않으면 `http://localhost:3000`이 기본값으로 사용됩니다. 프로덕션에서는 반드시 설정하세요.
+
+### 2. 이메일 링크 정확성
+
+이메일에 포함되는 링크가 올바른 도메인을 가리키는지 확인하세요:
+
+```typescript
+// 로그인 링크 예시
+https://evcp.your-company.com/ko/login
+
+// 비밀번호 재설정 링크 예시
+https://evcp.your-company.com/ko/auth/reset-password?token=xxxxx
+```
+
+### 3. Revalidation API 보안
+
+`REVALIDATION_SECRET`을 설정하여 외부에서 무단으로 캐시를 무효화하는 것을 방지할 수 있습니다 (선택사항).
+
+### 4. 다른 Service 함수에도 적용
+
+`lib/vendors/service.ts`의 다른 함수들도 동일한 패턴을 사용해야 할 수 있습니다:
+
+- `requestPQVendors()` - line 3210-3215에 `revalidateTag()` 사용
+- 기타 `headers()` 또는 `revalidateTag()`를 사용하는 함수들
+
+필요시 동일한 패턴으로 수정하세요.
+
+## 🎉 결과
+
+이제 cronjob에서 핸들러가 실행되어도 다음과 같이 정상 작동합니다:
+
+```bash
+✅ [ExecutionSaga] Step 5: Executing action
+✅ [Vendor Approval Handler] MDG 전송 성공
+✅ [Vendor Approval Handler] 벤더 승인 완료
+✅ [Revalidation] Cache invalidated: vendors, users, roles
+✅ [ExecutionSaga] ✓ Action executed
+```
+
+## 📚 참고 자료
+
+- [Next.js Dynamic APIs](https://nextjs.org/docs/messages/next-dynamic-api-wrong-context)
+- [Next.js Revalidation](https://nextjs.org/docs/app/building-your-application/data-fetching/revalidating)
+- [AsyncLocalStorage](https://nodejs.org/api/async_context.html#class-asynclocalstorage)
+
diff --git a/lib/approval/README.md b/lib/approval/README.md
index 40f783c9..7e62a1d7 100644
--- a/lib/approval/README.md
+++ b/lib/approval/README.md
@@ -360,29 +360,56 @@ export async function requestMyActionWithApproval(data: {
}
```
-### Step 4: UI에서 호출
+### Step 4: UI에서 호출 (미리보기 다이얼로그 사용)
+
+**⚠️ 중요: 모든 결재 상신은 반드시 미리보기 다이얼로그를 거쳐야 합니다.**
+
+사용자가 결재 문서 내용을 확인하고 결재선을 직접 설정하는 과정이 필수입니다.
```typescript
-// components/my-feature/my-dialog.tsx
+// components/my-feature/my-dialog-with-preview.tsx
'use client';
+import { useState } from 'react';
+import { ApprovalPreviewDialog } from '@/lib/approval/client'; // ⚠️ /client에서 import
import { requestMyActionWithApproval } from '@/lib/my-feature/approval-actions';
import { useSession } from 'next-auth/react';
-export function MyDialog() {
+export function MyDialogWithPreview() {
const { data: session } = useSession();
+ const [showPreview, setShowPreview] = useState(false);
+ const [previewData, setPreviewData] = useState(null);
+
+ const handleApproveClick = async (formData) => {
+ // 1. 템플릿 변수 준비
+ const variables = await prepareTemplateVariables(formData);
+
+ // 2. 미리보기 데이터 설정
+ setPreviewData({
+ variables,
+ title: `내 기능 요청 - ${formData.id}`,
+ description: `ID ${formData.id}에 대한 승인 요청`,
+ formData, // 나중에 사용할 데이터 저장
+ });
+
+ // 3. 미리보기 다이얼로그 열기
+ setShowPreview(true);
+ };
- const handleSubmit = async (formData) => {
+ const handlePreviewConfirm = async (approvalData: {
+ approvers: string[];
+ title: string;
+ description?: string;
+ }) => {
try {
const result = await requestMyActionWithApproval({
- id: formData.id,
- reason: formData.reason,
+ ...previewData.formData,
currentUser: {
id: Number(session?.user?.id),
epId: session?.user?.epId || null,
email: session?.user?.email || undefined,
},
- approvers: selectedApprovers,
+ approvers: approvalData.approvers, // 미리보기에서 설정한 결재선
});
if (result.status === 'pending_approval') {
@@ -393,10 +420,40 @@ export function MyDialog() {
}
};
- return <form onSubmit={handleSubmit}>...</form>;
+ return (
+ <>
+ <Button onClick={handleApproveClick}>결재 요청</Button>
+
+ {/* 결재 미리보기 다이얼로그 */}
+ {previewData && session?.user?.epId && (
+ <ApprovalPreviewDialog
+ open={showPreview}
+ onOpenChange={setShowPreview}
+ templateName="내 기능 템플릿"
+ variables={previewData.variables}
+ title={previewData.title}
+ description={previewData.description}
+ currentUser={{
+ id: Number(session.user.id),
+ epId: session.user.epId,
+ name: session.user.name || undefined,
+ email: session.user.email || undefined,
+ }}
+ onConfirm={handlePreviewConfirm}
+ />
+ )}
+ </>
+ );
}
```
+**미리보기 다이얼로그가 필수인 이유:**
+- ✅ 결재 문서 내용 최종 확인 (데이터 정확성 검증)
+- ✅ 결재선(결재자) 직접 선택 (올바른 결재 경로 설정)
+- ✅ 결재 제목/설명 커스터마이징 (명확한 의사소통)
+- ✅ 사용자가 결재 내용을 인지하고 책임감 있게 상신
+- ✅ 반응형 UI (Desktop: Dialog, Mobile: Drawer)
+
---
## 📚 API 레퍼런스
@@ -522,6 +579,74 @@ async function replaceTemplateVariables(
): Promise<string>
```
+### UI 컴포넌트
+
+#### ApprovalPreviewDialog
+
+결재 문서 미리보기 및 결재선 설정 다이얼로그 컴포넌트입니다.
+
+```typescript
+interface ApprovalPreviewDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ templateName: string; // DB에서 조회할 템플릿 이름
+ variables: Record<string, string>; // 템플릿 변수
+ title: string; // 결재 제목
+ description?: string; // 결재 설명
+ currentUser: {
+ id: number;
+ epId: string;
+ name?: string;
+ email?: string;
+ deptName?: string;
+ };
+ defaultApprovers?: string[]; // 초기 결재선 (EP ID 배열)
+ onConfirm: (data: {
+ approvers: string[];
+ title: string;
+ description?: string;
+ }) => Promise<void>;
+ allowTitleEdit?: boolean; // 제목 수정 허용 (기본: true)
+ allowDescriptionEdit?: boolean; // 설명 수정 허용 (기본: true)
+}
+```
+
+**주요 기능:**
+- 템플릿 실시간 미리보기 (변수 자동 치환)
+- 결재선 선택 UI (ApprovalLineSelector 통합)
+- 제목/설명 수정
+- 반응형 디자인 (Desktop: Dialog, Mobile: Drawer)
+- 로딩 상태 자동 처리
+
+**사용 예시:**
+
+```typescript
+// ⚠️ 클라이언트 컴포넌트는 반드시 /client에서 import
+import { ApprovalPreviewDialog } from '@/lib/approval/client';
+
+<ApprovalPreviewDialog
+ open={showPreview}
+ onOpenChange={setShowPreview}
+ templateName="벤더 가입 승인 요청"
+ variables={{
+ '업체명': 'ABC 협력업체',
+ '담당자': '홍길동',
+ '요청일': '2024-11-06',
+ }}
+ title="협력업체 가입 승인"
+ description="ABC 협력업체의 가입을 승인합니다."
+ currentUser={{
+ id: 1,
+ epId: 'EP001',
+ name: '김철수',
+ email: 'kim@example.com',
+ }}
+ onConfirm={async ({ approvers, title, description }) => {
+ await submitApproval(approvers);
+ }}
+/>
+```
+
### 캐시 관리
```typescript
@@ -544,32 +669,35 @@ async function revalidateAllApprovalCaches(): Promise<void>
```
lib/approval/
-├── approval-saga.ts # Saga 클래스 (메인 로직)
+├── approval-saga.ts # Saga 클래스 (메인 로직) [서버]
│ ├── ApprovalSubmissionSaga # 결재 상신
│ ├── ApprovalExecutionSaga # 액션 실행
│ └── ApprovalRejectionSaga # 반려 처리
-├── approval-workflow.ts # 핸들러 레지스트리
+├── approval-workflow.ts # 핸들러 레지스트리 [서버]
│ ├── registerActionHandler()
│ ├── getRegisteredHandlers()
│ └── ensureHandlersInitialized()
-├── approval-polling-service.ts # 폴링 서비스
+├── approval-polling-service.ts # 폴링 서비스 [서버]
│ ├── startApprovalPollingScheduler()
│ ├── checkPendingApprovals()
│ └── checkSingleApprovalStatus()
-├── handlers-registry.ts # 핸들러 중앙 등록소
+├── handlers-registry.ts # 핸들러 중앙 등록소 [서버]
│ └── initializeApprovalHandlers()
-├── template-utils.ts # 템플릿 유틸리티
+├── template-utils.ts # 템플릿 유틸리티 [서버]
│ ├── getApprovalTemplateByName()
│ ├── replaceTemplateVariables()
│ ├── htmlTableConverter()
│ ├── htmlListConverter()
│ └── htmlDescriptionList()
-├── cache-utils.ts # 캐시 관리
+├── approval-preview-dialog.tsx # 결재 미리보기 다이얼로그 [클라이언트]
+│ └── ApprovalPreviewDialog # 템플릿 미리보기 + 결재선 설정
+│
+├── cache-utils.ts # 캐시 관리 [서버]
│ ├── revalidateApprovalLogs()
│ ├── revalidatePendingActions()
│ └── revalidateApprovalDetail()
@@ -579,11 +707,31 @@ lib/approval/
│ ├── ApprovalResult
│ └── TemplateVariables
-├── index.ts # 공개 API Export
+├── index.ts # 서버 전용 API Export
+├── client.ts # 클라이언트 컴포넌트 Export ⚠️
└── README.md # 이 문서
```
+### Import 경로 가이드
+
+**⚠️ 중요: 서버/클라이언트 코드는 반드시 분리해서 import 해야 합니다.**
+
+```typescript
+// ✅ 서버 액션 또는 서버 컴포넌트에서
+import {
+ ApprovalSubmissionSaga,
+ getApprovalTemplateByName,
+ htmlTableConverter
+} from '@/lib/approval';
+
+// ✅ 클라이언트 컴포넌트에서
+import { ApprovalPreviewDialog } from '@/lib/approval/client';
+
+// ❌ 잘못된 사용 (서버 코드가 클라이언트 번들에 포함됨)
+import { ApprovalPreviewDialog } from '@/lib/approval';
+```
+
---
## 🛠️ 개발 가이드
@@ -635,6 +783,217 @@ INSERT INTO approval_templates (name, content, description) VALUES (
끝! 폴링 서비스가 자동으로 처리합니다.
+### ⚠️ Request Context 주의사항 (필수)
+
+**핵심: 결재 핸들러는 Cronjob 환경에서 실행됩니다!**
+
+결재 승인 후 실행되는 핸들러는 **폴링 서비스(Cronjob)**에 의해 호출됩니다.
+이 환경에서는 **Request Context가 존재하지 않으므로** 다음 함수들을 **절대 사용할 수 없습니다**:
+
+```typescript
+// ❌ Cronjob 환경에서 사용 불가
+import { headers } from 'next/headers';
+import { getServerSession } from 'next-auth';
+
+export async function myHandler(payload) {
+ const headersList = headers(); // ❌ Error: headers() called outside request scope
+ const session = await getServerSession(); // ❌ Error: cannot access request
+ revalidatePath('/some-path'); // ❌ Error: revalidatePath outside request scope
+}
+```
+
+#### 올바른 해결 방법
+
+**1. 유저 정보가 필요한 경우 → Payload에 포함**
+
+```typescript
+// ✅ 결재 상신 시 currentUser를 payload에 포함
+export async function requestWithApproval(data: RequestData) {
+ const saga = new ApprovalSubmissionSaga(
+ 'my_action',
+ {
+ id: data.id,
+ currentUser: { // ⚠️ 핸들러에서 필요한 유저 정보 포함
+ id: session.user.id,
+ name: session.user.name,
+ email: session.user.email,
+ epId: session.user.epId,
+ },
+ },
+ { ... }
+ );
+}
+
+// ✅ 핸들러에서 payload의 currentUser 사용
+export async function myHandlerInternal(payload: {
+ id: number;
+ currentUser: {
+ id: string | number;
+ name?: string | null;
+ email?: string | null;
+ epId?: string | null;
+ };
+}) {
+ // payload에서 유저 정보 사용
+ const userId = payload.currentUser.id;
+
+ // DB 작업
+ await db.insert(myTable).values({
+ createdBy: userId,
+ ...
+ });
+}
+```
+
+**2. Session/Payload 분기 처리 (기존 함수 호환)**
+
+기존 함수를 cronjob과 일반 환경에서 모두 사용하려면 분기 처리:
+
+```typescript
+// ✅ Session/Payload 분기 처리
+export async function myServiceFunction({
+ id,
+ currentUser: providedUser // 선택적 파라미터
+}: {
+ id: number;
+ currentUser?: {
+ id: string | number;
+ name?: string | null;
+ email?: string | null;
+ };
+}) {
+ let currentUser;
+
+ if (providedUser) {
+ // ✅ Cronjob 환경: payload에서 받은 유저 정보 사용
+ currentUser = providedUser;
+ } else {
+ // ✅ 일반 환경: session에서 유저 정보 가져오기
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.");
+ }
+ currentUser = session.user;
+ }
+
+ // 이제 currentUser 안전하게 사용 가능
+ await db.insert(...).values({
+ createdBy: currentUser.id,
+ ...
+ });
+}
+```
+
+**3. Revalidate는 API 경로 사용**
+
+```typescript
+// ❌ 직접 호출 불가
+revalidatePath('/my-path');
+
+// ✅ API 경로를 통해 호출
+await fetch('/api/revalidate/my-resource', {
+ method: 'POST',
+});
+```
+
+#### 실제 사례: RFQ 발송
+
+```typescript
+// lib/rfq-last/service.ts
+export interface SendRfqParams {
+ rfqId: number;
+ vendors: VendorForSend[];
+ attachmentIds: number[];
+ currentUser?: { // ⚠️ Cronjob 환경을 위한 선택적 파라미터
+ id: string | number;
+ name?: string | null;
+ email?: string | null;
+ epId?: string | null;
+ };
+}
+
+export async function sendRfqToVendors({
+ rfqId,
+ vendors,
+ attachmentIds,
+ currentUser: providedUser
+}: SendRfqParams) {
+ let currentUser;
+
+ if (providedUser) {
+ // ✅ Cronjob 환경: payload에서 받은 유저 정보 사용
+ currentUser = providedUser;
+ } else {
+ // ✅ 일반 환경: session에서 유저 정보 가져오기
+ const session = await getServerSession(authOptions);
+ if (!session?.user) {
+ throw new Error("인증이 필요합니다.");
+ }
+ currentUser = session.user;
+ }
+
+ // ... 나머지 로직
+}
+
+// lib/rfq-last/approval-handlers.ts
+export async function sendRfqWithApprovalInternal(payload: {
+ rfqId: number;
+ vendors: any[];
+ attachmentIds: number[];
+ currentUser: { // ⚠️ 필수 정보
+ id: string | number;
+ name?: string | null;
+ email?: string | null;
+ epId?: string | null;
+ };
+}) {
+ // ✅ payload의 currentUser를 서비스 함수에 전달
+ const result = await sendRfqToVendors({
+ rfqId: payload.rfqId,
+ vendors: payload.vendors,
+ attachmentIds: payload.attachmentIds,
+ currentUser: payload.currentUser, // ⚠️ 전달
+ });
+
+ return result;
+}
+
+// lib/rfq-last/approval-actions.ts
+export async function requestRfqSendWithApproval(data: RfqSendApprovalData) {
+ const saga = new ApprovalSubmissionSaga(
+ 'rfq_send_with_attachments',
+ {
+ rfqId: data.rfqId,
+ vendors: data.vendors,
+ attachmentIds: data.attachmentIds,
+ currentUser: { // ⚠️ payload에 포함
+ id: data.currentUser.id,
+ name: data.currentUser.name,
+ email: data.currentUser.email,
+ epId: data.currentUser.epId,
+ },
+ },
+ { ... }
+ );
+}
+```
+
+#### 체크리스트
+
+새로운 결재 핸들러를 작성할 때 다음을 확인하세요:
+
+- [ ] 핸들러 함수에서 `headers()` 사용하지 않음
+- [ ] 핸들러 함수에서 `getServerSession()` 직접 호출하지 않음
+- [ ] 핸들러 함수에서 `revalidatePath()` 직접 호출하지 않음
+- [ ] 유저 정보가 필요하면 payload에 `currentUser` 포함
+- [ ] 기존 서비스 함수는 session/payload 분기 처리
+- [ ] 캐시 무효화는 API 경로 사용
+
+#### 참고 문서
+
+- [CRONJOB_CONTEXT_FIX.md](./CRONJOB_CONTEXT_FIX.md) - Request Context 문제 상세 해결 가이드
+- [Next.js Dynamic API Error](https://nextjs.org/docs/messages/next-dynamic-api-wrong-context)
+
### 테스트하기
```typescript
@@ -878,10 +1237,87 @@ const saga = new ApprovalSubmissionSaga(...);
await saga.execute();
```
+### 7. Request Context 오류 (headers/session) ⚠️
+
+**증상:**
+```
+Error: `headers` was called outside a request scope
+Error: Cannot access request in cronjob context
+```
+
+**원인:** 결재 핸들러는 Cronjob 환경에서 실행되므로 Request Context가 없음
+
+**해결:**
+
+```typescript
+// ❌ 잘못된 코드
+export async function myHandler(payload) {
+ const session = await getServerSession(authOptions); // ❌ Error!
+ const userId = session.user.id;
+}
+
+// ✅ 올바른 코드 - payload에서 유저 정보 사용
+export async function myHandler(payload: {
+ id: number;
+ currentUser: {
+ id: string | number;
+ name?: string | null;
+ email?: string | null;
+ };
+}) {
+ const userId = payload.currentUser.id; // ✅ OK
+}
+
+// ✅ 결재 상신 시 currentUser 포함
+const saga = new ApprovalSubmissionSaga(
+ 'my_action',
+ {
+ id: data.id,
+ currentUser: { // ⚠️ 필수
+ id: session.user.id,
+ name: session.user.name,
+ email: session.user.email,
+ epId: session.user.epId,
+ },
+ },
+ { ... }
+);
+```
+
+**기존 서비스 함수 호환:**
+
+```typescript
+// ✅ Session/Payload 분기 처리
+export async function myServiceFunction({
+ id,
+ currentUser: providedUser
+}: {
+ id: number;
+ currentUser?: { id: string | number; ... };
+}) {
+ let currentUser;
+
+ if (providedUser) {
+ // Cronjob 환경
+ currentUser = providedUser;
+ } else {
+ // 일반 환경
+ const session = await getServerSession(authOptions);
+ currentUser = session.user;
+ }
+
+ // 안전하게 사용
+ await db.insert(...).values({ createdBy: currentUser.id });
+}
+```
+
+**자세한 내용:** [Request Context 주의사항](#️-request-context-주의사항-필수) 섹션 참조
+
---
## 📖 추가 문서
+- **[CRONJOB_CONTEXT_FIX.md](./CRONJOB_CONTEXT_FIX.md)** - Request Context 문제 상세 해결 가이드 ⚠️
- **[SAGA_PATTERN.md](./SAGA_PATTERN.md)** - Saga 패턴 상세 설명 및 리팩터링 과정
- **[README_CACHE.md](./README_CACHE.md)** - 캐시 전략 및 관리 방법
- **[USAGE_PATTERN_ANALYSIS.md](./USAGE_PATTERN_ANALYSIS.md)** - 실제 사용 패턴 분석 및 개선 제안
@@ -895,6 +1331,7 @@ await saga.execute();
- ✅ 비즈니스 액션에 결재 승인이 필요한 경우
- ✅ Knox 결재 시스템과 자동 연동이 필요한 경우
- ✅ 결재 승인 후 자동으로 액션을 실행하고 싶은 경우
+- ✅ **모든 결재는 미리보기 다이얼로그를 통해 상신해야 함 (필수)**
### 왜 Saga 패턴인가?
- ✅ Knox는 외부 시스템 → 일반 트랜잭션 불가
@@ -904,27 +1341,56 @@ await saga.execute();
### 어떻게 사용하는가?
1. **핸들러 작성 및 등록** (1회)
-2. **Saga로 결재 상신** (필요할 때마다)
-3. **폴링이 자동 실행** (설정만 하면 끝)
+2. **UI에서 미리보기 다이얼로그 열기** (필수)
+3. **사용자가 결재선 설정 후 상신**
+4. **폴링이 자동 실행** (설정만 하면 끝)
-### 코드 3줄 요약
+### 코드 요약
```typescript
-// 1. 핸들러 등록
+// 1. 핸들러 등록 (서버 시작 시 1회)
registerActionHandler('my_action', myActionHandler);
-// 2. 결재 상신
-const saga = new ApprovalSubmissionSaga('my_action', payload, config);
-const result = await saga.execute();
+// 2. UI에서 미리보기 다이얼로그 열기
+const { variables } = await prepareTemplateVariables(data);
+setShowPreview(true);
+
+// 3. 사용자가 확인 후 결재 상신
+<ApprovalPreviewDialog
+ templateName="내 템플릿"
+ variables={variables}
+ onConfirm={async ({ approvers }) => {
+ await submitApproval(approvers);
+ }}
+/>
-// 3. 끝! (폴링이 자동 실행)
+// 4. 끝! (폴링이 자동 실행)
```
---
## 📝 변경 이력
-### 2024-11 - Saga 패턴 전면 리팩터링
+### 2024-11-06 - Request Context 호환성 개선 (RFQ 발송)
+- ✅ Cronjob 환경에서 Request Context 오류 해결
+- ✅ `headers()`, `getServerSession()` 호출 문제 수정
+- ✅ Session/Payload 분기 처리 패턴 도입
+- ✅ `currentUser`를 payload에 포함하는 표준 패턴 확립
+- ✅ 기존 서비스 함수 호환성 유지 (선택적 `currentUser` 파라미터)
+- ✅ RFQ 발송 핸들러에 적용 및 검증
+- ✅ README에 "Request Context 주의사항" 섹션 추가
+- ✅ 트러블슈팅 가이드 업데이트
+
+### 2024-11-06 - 결재 미리보기 다이얼로그 도입
+- ✅ `ApprovalPreviewDialog` 공통 컴포넌트 추가
+- ✅ 모든 결재 상신은 미리보기를 거치도록 프로세스 변경
+- ✅ 사용자가 결재 문서와 결재선을 확인하는 필수 단계 추가
+- ✅ 템플릿 실시간 미리보기 및 변수 치환 기능
+- ✅ 결재선 선택 UI 통합 (ApprovalLineSelector)
+- ✅ 반응형 디자인 (Desktop: Dialog, Mobile: Drawer)
+- ✅ 서버/클라이언트 코드 분리 (`index.ts` / `client.ts`)
+
+### 2024-11-05 - Saga 패턴 전면 리팩터링
- ✅ 기존 래퍼 함수 제거
- ✅ Saga Orchestrator 클래스 도입
- ✅ 비즈니스 프로세스 명시화 (7단계)
diff --git a/lib/approval/README_CACHE.md b/lib/approval/README_CACHE.md
new file mode 100644
index 00000000..b7bee00f
--- /dev/null
+++ b/lib/approval/README_CACHE.md
@@ -0,0 +1,253 @@
+# 결재 시스템 캐시 무효화 가이드
+
+## 문제 상황
+
+Next.js는 서버 컴포넌트의 데이터 fetch 결과를 자동으로 캐시합니다. 결재 시스템에서 `withApproval()`로 결재를 상신하거나 폴링 서비스가 결재를 승인/반려 처리해도 캐시가 무효화되지 않아 페이지에 최신 데이터가 표시되지 않는 문제가 있었습니다.
+
+특히 **백그라운드 프로세스** (폴링 서비스)에서는 Next.js의 `revalidateTag`를 직접 사용할 수 없습니다. `revalidateTag`는 request 컨텍스트가 필요한데, 백그라운드에서는 이 컨텍스트가 존재하지 않기 때문입니다.
+
+## 해결 방법
+
+API 라우트를 통한 캐시 무효화 시스템을 구축했습니다.
+
+### 1. 캐시 태그 시스템
+
+결재 관련 데이터에 캐시 태그를 추가:
+
+```typescript
+// lib/approval-log/service.ts
+export async function getApprovalLogList(input: ListInput) {
+ return unstable_cache(
+ async () => {
+ // ... 데이터 조회 로직
+ },
+ [cacheKey],
+ {
+ tags: ['approval-logs'], // 🏷️ 캐시 태그
+ revalidate: 60, // 60초마다 자동 재검증 (폴백)
+ }
+ )();
+}
+```
+
+### 2. 캐시 무효화 API
+
+백그라운드에서도 사용 가능한 API 라우트:
+
+```typescript
+// app/api/revalidate/approval/route.ts
+export async function POST(request: NextRequest) {
+ const { tags } = await request.json();
+
+ // 캐시 태그 무효화
+ for (const tag of tags) {
+ revalidateTag(tag);
+ }
+
+ return NextResponse.json({ success: true });
+}
+```
+
+### 3. 캐시 무효화 헬퍼 함수
+
+편리하게 사용할 수 있는 유틸리티:
+
+```typescript
+// lib/approval/cache-utils.ts
+
+// 결재 로그 캐시 무효화
+export async function revalidateApprovalLogs() {
+ await fetch('/api/revalidate/approval', {
+ method: 'POST',
+ body: JSON.stringify({ tags: ['approval-logs'] })
+ });
+}
+
+// 모든 결재 관련 캐시 무효화
+export async function revalidateAllApprovalCaches() {
+ await fetch('/api/revalidate/approval', {
+ method: 'POST',
+ body: JSON.stringify({
+ tags: ['approval-logs', 'pending-actions', 'approval-templates']
+ })
+ });
+}
+```
+
+### 4. 워크플로우에 통합
+
+결재 상신/승인/반려 시 자동으로 캐시 무효화:
+
+```typescript
+// lib/approval/approval-workflow.ts
+
+export async function withApproval(...) {
+ // ... 결재 상신 로직
+
+ // 캐시 무효화
+ await revalidateApprovalLogs();
+
+ return result;
+}
+
+export async function executeApprovedAction(apInfId: string) {
+ // ... 액션 실행 로직
+
+ // 캐시 무효화 (백그라운드에서도 동작! ✨)
+ await revalidateApprovalLogs();
+
+ return result;
+}
+
+export async function handleRejectedAction(apInfId: string, reason?: string) {
+ // ... 반려 처리 로직
+
+ // 캐시 무효화
+ await revalidateApprovalLogs();
+}
+```
+
+## 동작 원리
+
+```mermaid
+sequenceDiagram
+ participant BG as 백그라운드 프로세스<br/>(폴링 서비스)
+ participant API as API 라우트<br/>/api/revalidate/approval
+ participant Cache as Next.js 캐시
+ participant Page as 결재 로그 페이지
+
+ BG->>BG: 결재 승인 처리
+ BG->>API: POST /api/revalidate/approval<br/>{ tags: ['approval-logs'] }
+ API->>Cache: revalidateTag('approval-logs')
+ Cache-->>Cache: 캐시 무효화 ✅
+
+ Note over Page: 사용자가 페이지 접속
+ Page->>Cache: 데이터 요청
+ Cache->>Page: 최신 데이터 반환 🎉
+```
+
+## 사용 예시
+
+### 예시 1: 새로운 결재 액션에 캐시 무효화 추가
+
+```typescript
+// lib/my-feature/approval-actions.ts
+'use server';
+
+import { withApproval } from '@/lib/approval';
+
+export async function myActionWithApproval(data: MyData) {
+ // withApproval이 자동으로 캐시 무효화를 처리합니다
+ return await withApproval('my_action', data, {
+ templateName: '나의 액션 결재',
+ variables: { ... },
+ currentUser: { ... }
+ });
+}
+```
+
+### 예시 2: 수동으로 캐시 무효화
+
+```typescript
+import { revalidateApprovalLogs } from '@/lib/approval';
+
+// 필요한 시점에 수동으로 호출
+await revalidateApprovalLogs();
+```
+
+### 예시 3: 여러 캐시 동시 무효화
+
+```typescript
+import { revalidateApprovalCache } from '@/lib/approval';
+
+await revalidateApprovalCache([
+ 'approval-logs',
+ 'pending-actions',
+ 'my-custom-cache'
+]);
+```
+
+## 캐시 태그 규칙
+
+| 태그 이름 | 적용 대상 | 무효화 시점 |
+|----------|---------|-----------|
+| `approval-logs` | 결재 로그 목록 | 결재 상신/승인/반려 시 |
+| `pending-actions` | 대기 중인 액션 목록 | 액션 실행/반려 시 |
+| `approval-log-${apInfId}` | 특정 결재 상세 | 해당 결재 상태 변경 시 |
+| `approval-templates` | 결재 템플릿 목록 | 템플릿 생성/수정/삭제 시 |
+
+## 보안 (선택사항)
+
+환경 변수로 시크릿 키를 설정하여 무단 접근 방지:
+
+```env
+# .env.local
+REVALIDATION_SECRET=your-secret-key-here
+```
+
+```typescript
+// lib/approval/cache-utils.ts
+await fetch('/api/revalidate/approval', {
+ method: 'POST',
+ body: JSON.stringify({
+ tags: ['approval-logs'],
+ secret: process.env.REVALIDATION_SECRET
+ })
+});
+```
+
+## 장점
+
+✅ **백그라운드 프로세스 지원**: 폴링 서비스에서도 캐시 무효화 가능
+✅ **공통 솔루션**: 모든 결재 관련 페이지에 자동 적용
+✅ **유연성**: 필요한 캐시만 선택적으로 무효화
+✅ **신뢰성**: API 호출 실패해도 60초 후 자동 재검증 (폴백)
+✅ **확장성**: 새로운 캐시 태그 추가 용이
+
+## 주의사항
+
+⚠️ **과도한 무효화 방지**: 너무 자주 캐시를 무효화하면 성능 저하 발생
+⚠️ **에러 처리**: 캐시 무효화 실패는 치명적이지 않으므로 로그만 남기고 진행
+⚠️ **개발 환경**: 개발 환경에서는 캐싱이 비활성화될 수 있음
+
+## 트러블슈팅
+
+### 문제: 캐시가 무효화되지 않음
+
+1. API 라우트가 올바르게 호출되는지 확인:
+ ```bash
+ # 로그 확인
+ [Approval Workflow] Revalidating cache after approval submission
+ [Cache Revalidation] Tag revalidated: approval-logs
+ ```
+
+2. 캐시 태그가 올바르게 설정되었는지 확인:
+ ```typescript
+ // getApprovalLogList에 tags: ['approval-logs'] 있는지 확인
+ ```
+
+3. 환경 변수 확인:
+ ```bash
+ # NEXT_PUBLIC_BASE_URL이 올바르게 설정되어 있는지
+ ```
+
+### 문제: API 호출이 실패함
+
+```typescript
+// cache-utils.ts에서 에러 로그 확인
+[Approval Cache] Failed to revalidate cache: Error: ...
+```
+
+원인:
+- 네트워크 이슈
+- 잘못된 BASE_URL
+- 시크릿 키 불일치
+
+해결: 로그를 확인하고 환경 변수를 점검하세요.
+
+## 참고 자료
+
+- [Next.js Caching Documentation](https://nextjs.org/docs/app/building-your-application/caching)
+- [revalidateTag API Reference](https://nextjs.org/docs/app/api-reference/functions/revalidateTag)
+- [unstable_cache API Reference](https://nextjs.org/docs/app/api-reference/functions/unstable_cache)
+