diff options
Diffstat (limited to 'lib/approval/README.md')
| -rw-r--r-- | lib/approval/README.md | 514 |
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단계) |
