summaryrefslogtreecommitdiff
path: root/lib/approval/README.md
diff options
context:
space:
mode:
Diffstat (limited to 'lib/approval/README.md')
-rw-r--r--lib/approval/README.md514
1 files changed, 490 insertions, 24 deletions
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단계)