summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/approval/CRONJOB_CONTEXT_FIX.md278
-rw-r--r--lib/approval/README.md514
-rw-r--r--lib/approval/README_CACHE.md253
3 files changed, 1021 insertions, 24 deletions
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)
+