summaryrefslogtreecommitdiff
path: root/lib/approval
diff options
context:
space:
mode:
Diffstat (limited to 'lib/approval')
-rw-r--r--lib/approval/README.md478
-rw-r--r--lib/approval/approval-preview-dialog.tsx156
-rw-r--r--lib/approval/template-utils.ts23
-rw-r--r--lib/approval/templates/README.md4
-rw-r--r--lib/approval/templates/정규업체 등록.html877
5 files changed, 1488 insertions, 50 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()` 호출 문제 수정
diff --git a/lib/approval/approval-preview-dialog.tsx b/lib/approval/approval-preview-dialog.tsx
index a91e146c..8bb7ba0f 100644
--- a/lib/approval/approval-preview-dialog.tsx
+++ b/lib/approval/approval-preview-dialog.tsx
@@ -25,6 +25,29 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useMediaQuery } from "@/hooks/use-media-query";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Paperclip } from "lucide-react";
+import { Separator } from "@/components/ui/separator";
+import prettyBytes from "pretty-bytes";
+import {
+ Dropzone,
+ DropzoneDescription,
+ DropzoneInput,
+ DropzoneTitle,
+ DropzoneUploadIcon,
+ DropzoneZone,
+} from "@/components/ui/dropzone";
+import {
+ FileList,
+ FileListAction,
+ FileListDescription,
+ FileListHeader,
+ FileListIcon,
+ FileListInfo,
+ FileListItem,
+ FileListName,
+ FileListSize,
+} from "@/components/ui/file-list";
import {
ApprovalLineSelector,
@@ -63,9 +86,16 @@ export interface ApprovalPreviewDialogProps {
onConfirm: (data: {
approvers: string[];
title: string;
+ attachments?: File[];
}) => Promise<void>;
/** 제목 수정 가능 여부 (기본: true) */
allowTitleEdit?: boolean;
+ /** 첨부파일 UI 활성화 여부 (기본: false) */
+ enableAttachments?: boolean;
+ /** 최대 첨부파일 개수 (기본: 10) */
+ maxAttachments?: number;
+ /** 최대 파일 크기 (기본: 100MB) */
+ maxFileSize?: number;
}
/**
@@ -102,6 +132,9 @@ export function ApprovalPreviewDialog({
defaultApprovers = [],
onConfirm,
allowTitleEdit = true,
+ enableAttachments = false,
+ maxAttachments = 10,
+ maxFileSize = 100 * 1024 * 1024, // 100MB
}: ApprovalPreviewDialogProps) {
const isDesktop = useMediaQuery("(min-width: 768px)");
@@ -113,6 +146,7 @@ export function ApprovalPreviewDialog({
const [title, setTitle] = React.useState(initialTitle);
const [approvalLines, setApprovalLines] = React.useState<ApprovalLineItem[]>([]);
const [previewHtml, setPreviewHtml] = React.useState<string>("");
+ const [attachments, setAttachments] = React.useState<File[]>([]);
// 템플릿 로딩 및 미리보기 생성
React.useEffect(() => {
@@ -155,6 +189,7 @@ export function ApprovalPreviewDialog({
setTitle(initialTitle);
setApprovalLines([]);
setPreviewHtml("");
+ setAttachments([]);
return;
}
@@ -195,6 +230,36 @@ export function ApprovalPreviewDialog({
setApprovalLines(lines);
};
+ // 파일 드롭 핸들러
+ const handleDropAccepted = React.useCallback(
+ (files: File[]) => {
+ if (attachments.length + files.length > maxAttachments) {
+ toast.error(`최대 ${maxAttachments}개의 파일만 첨부할 수 있습니다.`);
+ return;
+ }
+
+ // 중복 파일 체크
+ const newFiles = files.filter(
+ (file) => !attachments.some((existing) => existing.name === file.name && existing.size === file.size)
+ );
+
+ if (newFiles.length !== files.length) {
+ toast.warning("일부 중복된 파일은 제외되었습니다.");
+ }
+
+ setAttachments((prev) => [...prev, ...newFiles]);
+ },
+ [attachments, maxAttachments]
+ );
+
+ const handleDropRejected = React.useCallback(() => {
+ toast.error(`파일 크기는 ${prettyBytes(maxFileSize)} 이하여야 합니다.`);
+ }, [maxFileSize]);
+
+ const handleRemoveFile = React.useCallback((index: number) => {
+ setAttachments((prev) => prev.filter((_, i) => i !== index));
+ }, []);
+
// 제출 핸들러
const handleSubmit = async () => {
try {
@@ -225,6 +290,7 @@ export function ApprovalPreviewDialog({
await onConfirm({
approvers: approverEpIds,
title: title.trim(),
+ attachments: enableAttachments ? attachments : undefined,
});
// 성공 시 다이얼로그 닫기
@@ -275,6 +341,96 @@ export function ApprovalPreviewDialog({
/>
</div>
+ {/* 첨부파일 섹션 (enableAttachments가 true일 때만 표시) */}
+ {enableAttachments && (
+ <>
+ <Separator />
+
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Paperclip className="w-4 h-4" />
+ 첨부파일
+ {attachments.length > 0 && (
+ <span className="text-sm font-normal text-muted-foreground">
+ ({attachments.length}/{maxAttachments})
+ </span>
+ )}
+ </CardTitle>
+ <CardDescription>
+ 결재 문서에 첨부할 파일을 추가하세요 (최대 {maxAttachments}개, 파일당 최대 {prettyBytes(maxFileSize)})
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {/* 파일 드롭존 */}
+ {attachments.length < maxAttachments && (
+ <Dropzone
+ maxSize={maxFileSize}
+ onDropAccepted={handleDropAccepted}
+ onDropRejected={handleDropRejected}
+ disabled={isSubmitting}
+ >
+ {() => (
+ <DropzoneZone className="flex justify-center h-24">
+ <DropzoneInput />
+ <div className="flex items-center gap-4">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>파일을 드래그하거나 클릭하여 업로드</DropzoneTitle>
+ <DropzoneDescription>
+ 모든 형식의 파일을 첨부할 수 있습니다
+ </DropzoneDescription>
+ </div>
+ </div>
+ </DropzoneZone>
+ )}
+ </Dropzone>
+ )}
+
+ {/* 첨부된 파일 목록 */}
+ {attachments.length > 0 && (
+ <FileList>
+ {attachments.map((file, index) => (
+ <FileListItem key={`${file.name}-${index}`}>
+ <FileListHeader>
+ <FileListIcon />
+ <FileListInfo>
+ <div className="flex-1">
+ <FileListName>{file.name}</FileListName>
+ <FileListDescription>
+ <FileListSize>{file.size}</FileListSize>
+ {file.type && (
+ <>
+ <span>•</span>
+ <span>{file.type}</span>
+ </>
+ )}
+ </FileListDescription>
+ </div>
+ </FileListInfo>
+ </FileListHeader>
+ <FileListAction
+ onClick={() => handleRemoveFile(index)}
+ disabled={isSubmitting}
+ title="파일 제거"
+ >
+ <X className="w-4 h-4" />
+ </FileListAction>
+ </FileListItem>
+ ))}
+ </FileList>
+ )}
+
+ {attachments.length === 0 && (
+ <p className="text-sm text-muted-foreground text-center py-4">
+ 첨부된 파일이 없습니다
+ </p>
+ )}
+ </CardContent>
+ </Card>
+ </>
+ )}
+
{/* 템플릿 미리보기 */}
<div className="space-y-2">
<Label>문서 미리보기</Label>
diff --git a/lib/approval/template-utils.ts b/lib/approval/template-utils.ts
index 0607f289..5a5bb307 100644
--- a/lib/approval/template-utils.ts
+++ b/lib/approval/template-utils.ts
@@ -39,14 +39,18 @@ export async function getApprovalTemplateByName(name: string) {
*
* {{변수명}} 형태의 변수를 실제 값으로 치환
*
+ * **중요**: 변수명의 앞뒤 공백은 자동으로 제거됩니다.
+ * - 템플릿: `{{ 변수명 }}` → `{{변수명}}`으로 정규화
+ * - 변수 키: ` 변수명 ` → `변수명`으로 정규화
+ *
* @param content - 템플릿 HTML 내용
* @param variables - 변수 매핑 객체
* @returns 치환된 HTML
*
* @example
* ```typescript
- * const content = "<p>{{이름}}님, 안녕하세요</p>";
- * const variables = { "이름": "홍길동" };
+ * const content = "<p>{{ 이름 }}님, 안녕하세요</p>";
+ * const variables = { " 이름 ": "홍길동" }; // 공백 있어도 OK
* const result = await replaceTemplateVariables(content, variables);
* // "<p>홍길동님, 안녕하세요</p>"
* ```
@@ -57,9 +61,20 @@ export async function replaceTemplateVariables(
): Promise<string> {
let result = content;
+ // 변수 키를 trim하여 정규화된 맵 생성
+ const normalizedVariables: Record<string, string> = {};
Object.entries(variables).forEach(([key, value]) => {
- // {{변수명}} 패턴을 전역으로 치환
- const pattern = new RegExp(`\\{\\{${escapeRegex(key)}\\}\\}`, 'g');
+ normalizedVariables[key.trim()] = value;
+ });
+
+ // 템플릿에서 {{ 변수명 }} 패턴을 찾아 치환
+ // 공백을 허용하는 정규식: {{\s*변수명\s*}}
+ Object.entries(normalizedVariables).forEach(([key, value]) => {
+ // 변수명 앞뒤에 공백이 있을 수 있으므로 \s*를 추가
+ const pattern = new RegExp(
+ `\\{\\{\\s*${escapeRegex(key)}\\s*\\}\\}`,
+ 'g'
+ );
result = result.replace(pattern, value);
});
diff --git a/lib/approval/templates/README.md b/lib/approval/templates/README.md
new file mode 100644
index 00000000..ab033fb8
--- /dev/null
+++ b/lib/approval/templates/README.md
@@ -0,0 +1,4 @@
+결재 HTML 템플릿 작성 가이드:
+1. html, head, body 태그 사용 불가
+2. 모든 스타일은 인라인 에디팅으로 작성
+3. 검정, 회색 무채색 계열(기업 사내 시스템에 적절하도록) \ No newline at end of file
diff --git a/lib/approval/templates/정규업체 등록.html b/lib/approval/templates/정규업체 등록.html
new file mode 100644
index 00000000..5bcf1ba2
--- /dev/null
+++ b/lib/approval/templates/정규업체 등록.html
@@ -0,0 +1,877 @@
+<div
+ style="
+ max-width: 800px;
+ margin: 0 auto;
+ font-family: 'Segoe UI', 'Malgun Gothic', sans-serif;
+ color: #333;
+ line-height: 1.6;
+ "
+>
+ <!-- 헤더 -->
+ <table
+ style="
+ width: 100%;
+ border-collapse: collapse;
+ margin-bottom: 20px;
+ border: 2px solid #000;
+ "
+ >
+ <thead>
+ <tr>
+ <th
+ style="
+ background-color: #fff;
+ color: #000;
+ padding: 20px;
+ text-align: center;
+ font-size: 24px;
+ font-weight: 700;
+ "
+ >
+ 정규업체 등록
+ </th>
+ </tr>
+ </thead>
+ </table>
+
+ <!-- 정규업체 등록 요청 정보 -->
+ <table
+ style="
+ width: 100%;
+ border-collapse: collapse;
+ margin-bottom: 20px;
+ border: 1px solid #666;
+ "
+ >
+ <thead>
+ <tr>
+ <th
+ colspan="2"
+ style="
+ background-color: #333;
+ color: #fff;
+ padding: 12px;
+ text-align: left;
+ font-size: 16px;
+ font-weight: 600;
+ border-bottom: 1px solid #666;
+ "
+ >
+ ■ 정규업체 등록 요청 정보
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <td
+ style="
+ background-color: #e8e8e8;
+ color: #000;
+ padding: 10px;
+ font-weight: 600;
+ width: 20%;
+ border: 1px solid #ccc;
+ "
+ >
+ 사업자번호
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{협력업체기본정보-사업자번호}}
+ </td>
+ </tr>
+ <tr>
+ <td
+ style="
+ background-color: #e8e8e8;
+ color: #000;
+ padding: 10px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 업체명
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{협력업체기본정보-업체명}}
+ </td>
+ </tr>
+ <tr>
+ <td
+ style="
+ background-color: #e8e8e8;
+ color: #000;
+ padding: 10px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 대표자명
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{협력업체기본정보-대표자명}}
+ </td>
+ </tr>
+ <tr>
+ <td
+ style="
+ background-color: #e8e8e8;
+ color: #000;
+ padding: 10px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 대표 전화번호
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{협력업체기본정보-대표전화}}
+ </td>
+ </tr>
+ <tr>
+ <td
+ style="
+ background-color: #e8e8e8;
+ color: #000;
+ padding: 10px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 대표 팩스번호
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{협력업체기본정보-FAX}}
+ </td>
+ </tr>
+ <tr>
+ <td
+ style="
+ background-color: #e8e8e8;
+ color: #000;
+ padding: 10px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 대표 이메일
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{협력업체기본정보-Email}}
+ </td>
+ </tr>
+ <tr>
+ <td
+ style="
+ background-color: #e8e8e8;
+ color: #000;
+ padding: 10px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 우편번호
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{협력업체기본정보-우편번호}}
+ </td>
+ </tr>
+ <tr>
+ <td
+ style="
+ background-color: #e8e8e8;
+ color: #000;
+ padding: 10px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 회사주소
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{협력업체기본정보-회사주소}}
+ </td>
+ </tr>
+ <tr>
+ <td
+ style="
+ background-color: #e8e8e8;
+ color: #000;
+ padding: 10px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 상세주소
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{협력업체기본정보-상세주소}}
+ </td>
+ </tr>
+ <tr>
+ <td
+ style="
+ background-color: #e8e8e8;
+ color: #000;
+ padding: 10px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 사업유형
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{협력업체기본정보-사업유형}}
+ </td>
+ </tr>
+ <tr>
+ <td
+ style="
+ background-color: #e8e8e8;
+ color: #000;
+ padding: 10px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 산업유형
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{협력업체기본정보-산업유형}}
+ </td>
+ </tr>
+ <tr>
+ <td
+ style="
+ background-color: #e8e8e8;
+ color: #000;
+ padding: 10px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 회사규모
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{협력업체기본정보-회사규모}}
+ </td>
+ </tr>
+ </tbody>
+ </table>
+
+ <!-- 담당자 연락처 -->
+ <table
+ style="
+ width: 100%;
+ border-collapse: collapse;
+ margin-bottom: 20px;
+ border: 1px solid #666;
+ "
+ >
+ <thead>
+ <tr>
+ <th
+ colspan="6"
+ style="
+ background-color: #333;
+ color: #fff;
+ padding: 12px;
+ text-align: left;
+ font-size: 16px;
+ font-weight: 600;
+ border-bottom: 1px solid #666;
+ "
+ >
+ ■ 담당자 연락처
+ </th>
+ </tr>
+ <tr>
+ <th
+ style="
+ background-color: #f5f5f5;
+ color: #000;
+ padding: 10px;
+ text-align: center;
+ font-weight: 600;
+ width: 15%;
+ border: 1px solid #ccc;
+ "
+ ></th>
+ <th
+ style="
+ background-color: #d9d9d9;
+ color: #000;
+ padding: 10px;
+ text-align: center;
+ font-weight: 600;
+ width: 17%;
+ border: 1px solid #ccc;
+ "
+ >
+ 영업 (sales)
+ </th>
+ <th
+ style="
+ background-color: #d9d9d9;
+ color: #000;
+ padding: 10px;
+ text-align: center;
+ font-weight: 600;
+ width: 17%;
+ border: 1px solid #ccc;
+ "
+ >
+ 설계 (design)
+ </th>
+ <th
+ style="
+ background-color: #d9d9d9;
+ color: #000;
+ padding: 10px;
+ text-align: center;
+ font-weight: 600;
+ width: 17%;
+ border: 1px solid #ccc;
+ "
+ >
+ 납기 (delivery)
+ </th>
+ <th
+ style="
+ background-color: #d9d9d9;
+ color: #000;
+ padding: 10px;
+ text-align: center;
+ font-weight: 600;
+ width: 17%;
+ border: 1px solid #ccc;
+ "
+ >
+ 품질 (quality)
+ </th>
+ <th
+ style="
+ background-color: #d9d9d9;
+ color: #000;
+ padding: 10px;
+ text-align: center;
+ font-weight: 600;
+ width: 17%;
+ border: 1px solid #ccc;
+ "
+ >
+ 세금계산서
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <td
+ style="
+ background-color: #e8e8e8;
+ color: #000;
+ padding: 10px;
+ font-weight: 600;
+ text-align: center;
+ border: 1px solid #ccc;
+ "
+ >
+ 담당자명
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{영업담당자-담당자명}}
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{설계담당자-담당자명}}
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{납기담당자-담당자명}}
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{품질담당자-담당자명}}
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{세금계산서담당자-담당자명}}
+ </td>
+ </tr>
+ <tr>
+ <td
+ style="
+ background-color: #e8e8e8;
+ color: #000;
+ padding: 10px;
+ font-weight: 600;
+ text-align: center;
+ border: 1px solid #ccc;
+ "
+ >
+ 직급
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{영업담당자-직급}}
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{설계담당자-직급}}
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{납기담당자-직급}}
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{품질담당자-직급}}
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{세금계산서담당자-직급}}
+ </td>
+ </tr>
+ <tr>
+ <td
+ style="
+ background-color: #e8e8e8;
+ color: #000;
+ padding: 10px;
+ font-weight: 600;
+ text-align: center;
+ border: 1px solid #ccc;
+ "
+ >
+ 부서
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{영업담당자-부서}}
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{설계담당자-부서}}
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{납기담당자-부서}}
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{품질담당자-부서}}
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{세금계산서담당자-부서}}
+ </td>
+ </tr>
+ <tr>
+ <td
+ style="
+ background-color: #e8e8e8;
+ color: #000;
+ padding: 10px;
+ font-weight: 600;
+ text-align: center;
+ border: 1px solid #ccc;
+ "
+ >
+ 담당업무
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{영업담당자-담당업무}}
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{설계담당자-담당업무}}
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{납기담당자-담당업무}}
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{품질담당자-담당업무}}
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{세금계산서담당자-담당업무}}
+ </td>
+ </tr>
+ <tr>
+ <td
+ style="
+ background-color: #e8e8e8;
+ color: #000;
+ padding: 10px;
+ font-weight: 600;
+ text-align: center;
+ border: 1px solid #ccc;
+ "
+ >
+ 이메일
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{영업담당자-이메일}}
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{설계담당자-이메일}}
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{납기담당자-이메일}}
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{품질담당자-이메일}}
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{세금계산서담당자-이메일}}
+ </td>
+ </tr>
+ </tbody>
+ </table>
+
+ <!-- 기본계약서 현황 -->
+ <table
+ style="
+ width: 100%;
+ border-collapse: collapse;
+ margin-bottom: 20px;
+ border: 1px solid #666;
+ "
+ >
+ <thead>
+ <tr>
+ <th
+ colspan="3"
+ style="
+ background-color: #333;
+ color: #fff;
+ padding: 12px;
+ text-align: left;
+ font-size: 16px;
+ font-weight: 600;
+ border-bottom: 1px solid #666;
+ "
+ >
+ ■ 기본계약서 현황
+ </th>
+ </tr>
+ <tr>
+ <th
+ style="
+ background-color: #e8e8e8;
+ color: #000;
+ padding: 10px;
+ text-align: center;
+ font-weight: 600;
+ width: 50%;
+ border: 1px solid #ccc;
+ "
+ >
+ 기본계약서명
+ </th>
+ <th
+ style="
+ background-color: #e8e8e8;
+ color: #000;
+ padding: 10px;
+ text-align: center;
+ font-weight: 600;
+ width: 30%;
+ border: 1px solid #ccc;
+ "
+ >
+ 상태
+ </th>
+ <th
+ style="
+ background-color: #e8e8e8;
+ color: #000;
+ padding: 10px;
+ text-align: center;
+ font-weight: 600;
+ width: 20%;
+ border: 1px solid #ccc;
+ "
+ >
+ 서약일자
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{계약유형}}
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{계약상태}}
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #333;
+ padding: 10px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{서약일자}}
+ </td>
+ </tr>
+ </tbody>
+ </table>
+</div>