diff options
Diffstat (limited to 'lib/approval')
| -rw-r--r-- | lib/approval/README.md | 478 | ||||
| -rw-r--r-- | lib/approval/approval-preview-dialog.tsx | 156 | ||||
| -rw-r--r-- | lib/approval/template-utils.ts | 23 | ||||
| -rw-r--r-- | lib/approval/templates/README.md | 4 | ||||
| -rw-r--r-- | lib/approval/templates/정규업체 등록.html | 877 |
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> |
