diff options
| author | joonhoekim <26rote@gmail.com> | 2025-11-07 09:40:41 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-11-07 09:40:41 +0900 |
| commit | 98e86ada15b2a867374188c79f78f5578018a911 (patch) | |
| tree | 65a1004c59feb7e4497d79563f3ead095dfe9a06 | |
| parent | aac4e61398ed829e9dfa2c038f76405f92563d14 (diff) | |
(김준회) 공통 컴포넌트 이해를 위한 문서 추가
| -rw-r--r-- | README.md | 10 | ||||
| -rw-r--r-- | components/common/discipline/README.md | 96 | ||||
| -rw-r--r-- | components/common/selectors/nation/README.md | 183 | ||||
| -rw-r--r-- | components/common/selectors/purchase-group-code/README.md | 274 | ||||
| -rw-r--r-- | components/common/ship-type/README.md | 123 | ||||
| -rw-r--r-- | components/common/vendor/README.md | 211 | ||||
| -rw-r--r-- | lib/approval/CRONJOB_CONTEXT_FIX.md | 278 | ||||
| -rw-r--r-- | lib/approval/README.md | 514 | ||||
| -rw-r--r-- | lib/approval/README_CACHE.md | 253 |
9 files changed, 1916 insertions, 26 deletions
@@ -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) + |
