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.md478
1 files changed, 432 insertions, 46 deletions
diff --git a/lib/approval/README.md b/lib/approval/README.md
index 7e62a1d7..5b3c1839 100644
--- a/lib/approval/README.md
+++ b/lib/approval/README.md
@@ -585,68 +585,438 @@ async function replaceTemplateVariables(
결재 문서 미리보기 및 결재선 설정 다이얼로그 컴포넌트입니다.
+**위치:** `lib/approval/approval-preview-dialog.tsx`
+
+**Import:**
+```typescript
+// ⚠️ 클라이언트 컴포넌트는 반드시 /client에서 import
+import { ApprovalPreviewDialog } from '@/lib/approval/client';
+```
+
+#### Props 인터페이스
+
```typescript
interface ApprovalPreviewDialogProps {
+ // 기본 제어
open: boolean;
onOpenChange: (open: boolean) => void;
+
+ // 템플릿 설정
templateName: string; // DB에서 조회할 템플릿 이름
- variables: Record<string, string>; // 템플릿 변수
- title: string; // 결재 제목
- description?: string; // 결재 설명
+ variables: Record<string, string>; // 템플릿 변수 ({{변수명}} 형태로 치환)
+ title: string; // 결재 제목 (초기값)
+
+ // 사용자 정보
currentUser: {
- id: number;
- epId: string;
- name?: string;
- email?: string;
- deptName?: string;
+ id: number; // 사용자 DB ID
+ epId: string; // Knox EP ID (필수)
+ name?: string; // 사용자 이름
+ email?: string; // 이메일
+ deptName?: string; // 부서명
};
+
+ // 결재선 설정
defaultApprovers?: string[]; // 초기 결재선 (EP ID 배열)
+
+ // 콜백
onConfirm: (data: {
- approvers: string[];
- title: string;
- description?: string;
+ approvers: string[]; // 선택된 결재자 EP ID 배열
+ title: string; // (수정 가능한) 결재 제목
+ attachments?: File[]; // 첨부파일 (enableAttachments가 true일 때)
}) => Promise<void>;
+
+ // 옵션
allowTitleEdit?: boolean; // 제목 수정 허용 (기본: true)
- allowDescriptionEdit?: boolean; // 설명 수정 허용 (기본: true)
+
+ // 첨부파일 (선택적 기능)
+ enableAttachments?: boolean; // 첨부파일 UI 활성화 (기본: false)
+ maxAttachments?: number; // 최대 첨부파일 개수 (기본: 10)
+ maxFileSize?: number; // 최대 파일 크기 bytes (기본: 100MB)
}
```
-**주요 기능:**
-- 템플릿 실시간 미리보기 (변수 자동 치환)
-- 결재선 선택 UI (ApprovalLineSelector 통합)
-- 제목/설명 수정
-- 반응형 디자인 (Desktop: Dialog, Mobile: Drawer)
-- 로딩 상태 자동 처리
+#### 주요 기능
+
+- ✅ 템플릿 실시간 미리보기 (변수 자동 치환)
+- ✅ 결재선 선택 UI (ApprovalLineSelector 통합)
+- ✅ 결재 제목 수정 가능
+- ✅ **선택적 첨부파일 업로드** (드래그 앤 드롭)
+- ✅ 반응형 디자인 (Desktop: Dialog, Mobile: Drawer)
+- ✅ 로딩 상태 자동 처리
+- ✅ 유효성 검사 및 에러 핸들링
-**사용 예시:**
+#### 사용 예시 1: 기본 사용 (첨부파일 없이)
```typescript
-// ⚠️ 클라이언트 컴포넌트는 반드시 /client에서 import
+'use client';
+
+import { useState } from 'react';
import { ApprovalPreviewDialog } from '@/lib/approval/client';
+import { useSession } from 'next-auth/react';
+import { toast } from 'sonner';
-<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);
- }}
-/>
+export function VendorApprovalButton({ vendorData }) {
+ const { data: session } = useSession();
+ const [showPreview, setShowPreview] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+
+ const handleOpenPreview = async () => {
+ // 1. 템플릿 변수 준비
+ const variables = {
+ '업체명': vendorData.companyName,
+ '사업자번호': vendorData.businessNumber,
+ '담당자': vendorData.contactPerson,
+ '요청일': new Date().toLocaleDateString('ko-KR'),
+ '요청사유': vendorData.reason,
+ };
+
+ // 2. 미리보기 열기
+ setShowPreview(true);
+ };
+
+ const handleConfirm = async ({ approvers, title, attachments }) => {
+ try {
+ setIsSubmitting(true);
+
+ // 3. 실제 결재 상신 API 호출
+ const result = await fetch('/api/vendor/approval', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ vendorId: vendorData.id,
+ approvers,
+ title,
+ }),
+ });
+
+ if (result.ok) {
+ toast.success('결재가 성공적으로 상신되었습니다.');
+ setShowPreview(false);
+ }
+ } catch (error) {
+ toast.error('결재 상신에 실패했습니다.');
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ // EP ID 없으면 버튼 비활성화
+ if (!session?.user?.epId) {
+ return (
+ <Button disabled>
+ 결재 요청 (Knox EP ID 필요)
+ </Button>
+ );
+ }
+
+ return (
+ <>
+ <Button onClick={handleOpenPreview}>
+ 정규업체 등록 결재 요청
+ </Button>
+
+ {showPreview && (
+ <ApprovalPreviewDialog
+ open={showPreview}
+ onOpenChange={setShowPreview}
+ templateName="정규업체 등록"
+ variables={{
+ '업체명': vendorData.companyName,
+ '사업자번호': vendorData.businessNumber,
+ '담당자': vendorData.contactPerson,
+ '요청일': new Date().toLocaleDateString('ko-KR'),
+ }}
+ title={`정규업체 등록 - ${vendorData.companyName}`}
+ currentUser={{
+ id: Number(session.user.id),
+ epId: session.user.epId,
+ name: session.user.name || undefined,
+ email: session.user.email || undefined,
+ }}
+ onConfirm={handleConfirm}
+ />
+ )}
+ </>
+ );
+}
+```
+
+#### 사용 예시 2: 첨부파일 포함
+
+```typescript
+'use client';
+
+import { useState } from 'react';
+import { ApprovalPreviewDialog } from '@/lib/approval/client';
+import { submitApproval } from '@/lib/knox-api/approval/approval';
+
+export function ContractApprovalButton({ contractData }) {
+ const { data: session } = useSession();
+ const [showPreview, setShowPreview] = useState(false);
+
+ const handleConfirm = async ({ approvers, title, attachments }) => {
+ console.log('결재자:', approvers.length, '명');
+ console.log('첨부파일:', attachments?.length || 0, '개');
+
+ // Knox API로 결재 상신 (첨부파일 포함)
+ const approvalData = {
+ // 기본 정보
+ subject: title,
+ contents: `<div>계약서 검토를 요청합니다.</div>`,
+ contentsType: 'HTML',
+
+ // 결재선 설정
+ aplns: approvers.map((epId, index) => ({
+ epId: epId, // Knox EP ID
+ seq: index.toString(), // 결재 순서 (0부터 시작)
+ role: '1', // 역할: 0=기안, 1=결재, 2=합의, 3=참조
+ aplnStatsCode: '0', // 결재 상태: 0=대기
+ arbPmtYn: 'Y', // 임의승인 허용
+ contentsMdfyPmtYn: 'Y', // 내용수정 허용
+ aplnMdfyPmtYn: 'Y', // 결재선수정 허용
+ opinion: '', // 의견
+ })),
+
+ // 첨부파일
+ attachments: attachments || [], // File[] 배열
+
+ // 문서 설정
+ docSecuType: 'PERSONAL', // 보안등급: PERSONAL, COMPANY, SECRET
+ notifyOption: '0', // 알림옵션: 0=전체, 1=결재완료, 2=없음
+ urgYn: 'N', // 긴급여부
+ docMngSaveCode: '0', // 문서관리저장코드
+ sbmLang: 'ko', // 언어
+ };
+
+ // Knox API 호출
+ const result = await submitApproval(approvalData, {
+ userId: session.user.id.toString(),
+ epId: session.user.epId!,
+ emailAddress: session.user.email || '',
+ });
+
+ if (result.success) {
+ toast.success(`결재가 상신되었습니다. (ID: ${result.approvalId})`);
+ }
+ };
+
+ if (!session?.user?.epId) return null;
+
+ return (
+ <>
+ <Button onClick={() => setShowPreview(true)}>
+ 계약서 검토 결재 요청
+ </Button>
+
+ <ApprovalPreviewDialog
+ open={showPreview}
+ onOpenChange={setShowPreview}
+ templateName="계약서 검토"
+ variables={{
+ '계약명': contractData.title,
+ '계약일': contractData.date,
+ '계약업체': contractData.vendor,
+ '계약금액': contractData.amount.toLocaleString(),
+ }}
+ title={`계약서 검토 - ${contractData.title}`}
+ currentUser={{
+ id: Number(session.user.id),
+ epId: session.user.epId,
+ name: session.user.name || undefined,
+ email: session.user.email || undefined,
+ }}
+ onConfirm={handleConfirm}
+ // 첨부파일 기능 활성화
+ enableAttachments={true}
+ maxAttachments={5}
+ maxFileSize={50 * 1024 * 1024} // 50MB
+ />
+ </>
+ );
+}
+```
+
+#### Knox API 연동: approvalData 객체 상세
+
+Knox 결재 API (`submitApproval`)에 전달하는 전체 객체 구조:
+
+```typescript
+interface KnoxApprovalData {
+ // ===== 기본 정보 =====
+ subject: string; // 결재 제목 (필수)
+ contents: string; // 결재 내용 HTML (필수)
+ contentsType: 'HTML' | 'TEXT'; // 내용 타입 (기본: 'HTML')
+
+ // ===== 결재선 설정 =====
+ aplns: Array<{
+ epId: string; // Knox EP ID (필수)
+ seq: string; // 결재 순서 "0", "1", "2"... (필수)
+ role: string; // 역할 (필수)
+ // "0" = 기안자
+ // "1" = 결재자
+ // "2" = 합의자
+ // "3" = 참조자
+ aplnStatsCode: string; // 결재 상태 (필수)
+ // "0" = 대기
+ // "1" = 진행중
+ // "2" = 승인
+ // "3" = 반려
+ arbPmtYn: 'Y' | 'N'; // 임의승인 허용 (기본: 'Y')
+ contentsMdfyPmtYn: 'Y' | 'N'; // 내용수정 허용 (기본: 'Y')
+ aplnMdfyPmtYn: 'Y' | 'N'; // 결재선수정 허용 (기본: 'Y')
+ opinion?: string; // 결재 의견 (선택)
+ }>;
+
+ // ===== 첨부파일 =====
+ attachments?: File[]; // File 객체 배열 (선택)
+
+ // ===== 문서 설정 =====
+ docSecuType?: string; // 보안등급 (기본: 'PERSONAL')
+ // 'PERSONAL' = 개인
+ // 'COMPANY' = 회사
+ // 'SECRET' = 비밀
+ notifyOption?: string; // 알림옵션 (기본: '0')
+ // '0' = 전체 알림
+ // '1' = 결재완료 알림
+ // '2' = 알림 없음
+ urgYn?: 'Y' | 'N'; // 긴급여부 (기본: 'N')
+ docMngSaveCode?: string; // 문서관리저장코드 (기본: '0')
+ // '0' = 개인문서함
+ // '1' = 부서문서함
+ sbmLang?: 'ko' | 'en'; // 언어 (기본: 'ko')
+
+ // ===== 추가 옵션 =====
+ comment?: string; // 기안 의견 (선택)
+ refLineYn?: 'Y' | 'N'; // 참조선 사용여부 (기본: 'N')
+ agreementLineYn?: 'Y' | 'N'; // 합의선 사용여부 (기본: 'N')
+}
+
+// 사용자 정보
+interface KnoxUserInfo {
+ userId: string; // 사용자 ID (필수)
+ epId: string; // Knox EP ID (필수)
+ emailAddress: string; // 이메일 (필수)
+}
+```
+
+#### 전체 연동 예시 (Server Action)
+
+```typescript
+// app/api/vendor/approval/route.ts
+'use server';
+
+import { ApprovalSubmissionSaga } from '@/lib/approval';
+import { mapVendorToTemplateVariables } from '@/lib/vendors/handlers';
+
+export async function submitVendorApproval(data: {
+ vendorId: number;
+ approvers: string[];
+ title: string;
+ attachments?: File[];
+}) {
+ // 1. 템플릿 변수 매핑
+ const vendor = await getVendorById(data.vendorId);
+ const variables = await mapVendorToTemplateVariables(vendor);
+
+ // 2. Saga로 결재 상신
+ const saga = new ApprovalSubmissionSaga(
+ 'vendor_registration', // 핸들러 타입
+ {
+ vendorId: data.vendorId,
+ // 필요한 payload 데이터
+ },
+ {
+ title: data.title,
+ templateName: '정규업체 등록',
+ variables,
+ approvers: data.approvers, // EP ID 배열
+ currentUser: {
+ id: user.id,
+ epId: user.epId,
+ email: user.email,
+ },
+ }
+ );
+
+ return await saga.execute();
+}
```
+#### 첨부파일 처리
+
+첨부파일은 Knox API 내부에서 자동으로 FormData로 변환되어 전송됩니다:
+
+```typescript
+// lib/knox-api/approval/approval.ts 내부 (참고용)
+export async function submitApproval(
+ approvalData: KnoxApprovalData,
+ userInfo: KnoxUserInfo
+) {
+ const formData = new FormData();
+
+ // 기본 데이터를 JSON으로 직렬화
+ formData.append('data', JSON.stringify({
+ subject: approvalData.subject,
+ contents: approvalData.contents,
+ aplns: approvalData.aplns,
+ // ... 기타 필드
+ }));
+
+ // 첨부파일 추가
+ if (approvalData.attachments) {
+ approvalData.attachments.forEach((file, index) => {
+ formData.append(`attachment_${index}`, file);
+ });
+ }
+
+ // Knox API 호출
+ const response = await fetch(KNOX_APPROVAL_API_URL, {
+ method: 'POST',
+ headers: {
+ 'X-User-Id': userInfo.userId,
+ 'X-EP-Id': userInfo.epId,
+ },
+ body: formData,
+ });
+
+ return response.json();
+}
+```
+
+#### 주의사항
+
+1. **EP ID 필수 체크**
+ ```typescript
+ if (!session?.user?.epId) {
+ return <Button disabled>Knox EP ID 필요</Button>;
+ }
+ ```
+
+2. **onConfirm 에러 핸들링**
+ ```typescript
+ const handleConfirm = async ({ approvers, title, attachments }) => {
+ try {
+ await submitApproval(...);
+ toast.success('성공');
+ } catch (error) {
+ toast.error('실패');
+ // ⚠️ throw하지 않으면 다이얼로그가 자동으로 닫힘
+ // 에러 시 다이얼로그를 열어두려면 throw 필요
+ throw error;
+ }
+ };
+ ```
+
+3. **첨부파일 제한**
+ - 기본값: 최대 10개, 파일당 100MB
+ - `maxAttachments`, `maxFileSize`로 커스터마이징 가능
+ - 중복 파일 자동 필터링
+
+4. **반응형 UI**
+ - Desktop (≥768px): Dialog 형태
+ - Mobile (<768px): Drawer 형태
+ - 자동으로 화면 크기에 맞게 렌더링
+
### 캐시 관리
```typescript
@@ -670,9 +1040,9 @@ async function revalidateAllApprovalCaches(): Promise<void>
```
lib/approval/
├── approval-saga.ts # Saga 클래스 (메인 로직) [서버]
-│ ├── ApprovalSubmissionSaga # 결재 상신
-│ ├── ApprovalExecutionSaga # 액션 실행
-│ └── ApprovalRejectionSaga # 반려 처리
+│ ├── ApprovalSubmissionSaga # 결재 상신 (7단계)
+│ ├── ApprovalExecutionSaga # 액션 실행 (7단계)
+│ └── ApprovalRejectionSaga # 반려 처리 (4단계)
├── approval-workflow.ts # 핸들러 레지스트리 [서버]
│ ├── registerActionHandler()
@@ -695,7 +1065,7 @@ lib/approval/
│ └── htmlDescriptionList()
├── approval-preview-dialog.tsx # 결재 미리보기 다이얼로그 [클라이언트]
-│ └── ApprovalPreviewDialog # 템플릿 미리보기 + 결재선 설정
+│ └── ApprovalPreviewDialog # 템플릿 미리보기 + 결재선 설정 + 첨부파일
├── cache-utils.ts # 캐시 관리 [서버]
│ ├── revalidateApprovalLogs()
@@ -709,8 +1079,13 @@ lib/approval/
├── index.ts # 서버 전용 API Export
├── client.ts # 클라이언트 컴포넌트 Export ⚠️
+│ └── ApprovalPreviewDialog # ← 클라이언트에서는 이 파일에서 import
-└── README.md # 이 문서
+├── README.md # 이 문서 (전체 시스템 가이드)
+├── SAGA_PATTERN.md # Saga 패턴 상세 설명
+├── CRONJOB_CONTEXT_FIX.md # Request Context 문제 해결 가이드
+├── USAGE_PATTERN_ANALYSIS.md # 실제 사용 패턴 분석
+└── ARCHITECTURE_REVIEW.md # 아키텍처 평가
```
### Import 경로 가이드
@@ -1371,6 +1746,17 @@ setShowPreview(true);
## 📝 변경 이력
+### 2024-11-07 - ApprovalPreviewDialog 컴포넌트 통합 및 첨부파일 기능 추가
+- ✅ 중복 컴포넌트 통합: `components/approval/ApprovalPreviewDialog.tsx` → `lib/approval/approval-preview-dialog.tsx`로 일원화
+- ✅ 선택적 첨부파일 업로드 기능 추가 (`enableAttachments`, `maxAttachments`, `maxFileSize` props)
+- ✅ 파일 드래그 앤 드롭 UI 구현 (Dropzone, FileList 컴포넌트 활용)
+- ✅ 첨부파일 유효성 검사 (크기 제한, 개수 제한, 중복 파일 필터링)
+- ✅ API 인터페이스 개선: `onSubmit` → `onConfirm`으로 변경
+- ✅ 콜백 시그니처 변경: `(approvers: ApprovalLineItem[])` → `({ approvers: string[], title: string, attachments?: File[] })`
+- ✅ Knox API 연동 상세 가이드 추가 (approvalData 객체 전체 필드 문서화)
+- ✅ 기존 사용처 업데이트 (vendor-regular-registrations, pq-review-table-new)
+- ✅ README 상세화: 사용 예시, Props 인터페이스, 주의사항 등 보완
+
### 2024-11-06 - Request Context 호환성 개선 (RFQ 발송)
- ✅ Cronjob 환경에서 Request Context 오류 해결
- ✅ `headers()`, `getServerSession()` 호출 문제 수정