summaryrefslogtreecommitdiff
path: root/lib/approval
diff options
context:
space:
mode:
Diffstat (limited to 'lib/approval')
-rw-r--r--lib/approval/approval-preview-dialog.tsx435
-rw-r--r--lib/approval/client.ts23
-rw-r--r--lib/approval/index.ts3
-rw-r--r--lib/approval/template-utils.ts30
-rw-r--r--lib/approval/templates/벤더 가입 승인 요청.html233
-rw-r--r--lib/approval/templates/실사의뢰 및 실사재의뢰 요청.html316
6 files changed, 1025 insertions, 15 deletions
diff --git a/lib/approval/approval-preview-dialog.tsx b/lib/approval/approval-preview-dialog.tsx
new file mode 100644
index 00000000..bc5a4f65
--- /dev/null
+++ b/lib/approval/approval-preview-dialog.tsx
@@ -0,0 +1,435 @@
+"use client";
+
+import * as React from "react";
+import { Loader2, Eye, Send, X } from "lucide-react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ Drawer,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+} from "@/components/ui/drawer";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { ScrollArea } from "@/components/ui/scroll-area";
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { useMediaQuery } from "@/hooks/use-media-query";
+
+import { ApprovalLineSelector } from "@/components/knox/approval/ApprovalLineSelector";
+import {
+ getApprovalTemplateByName,
+ replaceTemplateVariables
+} from "./template-utils";
+
+import type { ApprovalLine } from "@/components/knox/approval/types";
+
+/**
+ * 결재 미리보기 다이얼로그 Props
+ */
+export interface ApprovalPreviewDialogProps {
+ /** 다이얼로그 열림 상태 */
+ open: boolean;
+ /** 다이얼로그 열림 상태 변경 핸들러 */
+ onOpenChange: (open: boolean) => void;
+ /** 템플릿 이름 (DB에서 조회) */
+ templateName: string;
+ /** 템플릿 변수 ({{변수명}} 형태로 치환) */
+ variables: Record<string, string>;
+ /** 결재 제목 */
+ title: string;
+ /** 결재 설명 (선택사항) */
+ description?: string;
+ /** 현재 사용자 정보 */
+ currentUser: {
+ id: number;
+ epId: string;
+ name?: string;
+ email?: string;
+ deptName?: string;
+ };
+ /** 초기 결재선 (선택사항) */
+ defaultApprovers?: string[];
+ /** 확인 버튼 클릭 시 콜백 */
+ onConfirm: (data: {
+ approvers: string[];
+ title: string;
+ description?: string;
+ }) => Promise<void>;
+ /** 제목 수정 가능 여부 (기본: true) */
+ allowTitleEdit?: boolean;
+ /** 설명 수정 가능 여부 (기본: true) */
+ allowDescriptionEdit?: boolean;
+}
+
+/**
+ * 결재 미리보기 다이얼로그 컴포넌트
+ *
+ * **주요 기능:**
+ * 1. 템플릿 실시간 미리보기 (변수 치환)
+ * 2. 결재선 선택 (ApprovalLineSelector 활용)
+ * 3. 제목/설명 수정
+ * 4. 반응형 UI (Desktop: Dialog, Mobile: Drawer)
+ *
+ * **사용 예시:**
+ * ```tsx
+ * <ApprovalPreviewDialog
+ * open={isOpen}
+ * onOpenChange={setIsOpen}
+ * templateName="벤더 가입 승인 요청"
+ * variables={{ "업체명": "ABC 협력업체" }}
+ * title="협력업체 가입 승인"
+ * currentUser={{ id: 1, epId: "EP001", name: "홍길동" }}
+ * onConfirm={async ({ approvers }) => {
+ * await submitApproval(approvers);
+ * }}
+ * />
+ * ```
+ */
+export function ApprovalPreviewDialog({
+ open,
+ onOpenChange,
+ templateName,
+ variables,
+ title: initialTitle,
+ description: initialDescription,
+ currentUser,
+ defaultApprovers = [],
+ onConfirm,
+ allowTitleEdit = true,
+ allowDescriptionEdit = true,
+}: ApprovalPreviewDialogProps) {
+ const isDesktop = useMediaQuery("(min-width: 768px)");
+
+ // 로딩 상태
+ const [isLoadingTemplate, setIsLoadingTemplate] = React.useState(false);
+ const [isSubmitting, setIsSubmitting] = React.useState(false);
+
+ // 폼 상태
+ const [title, setTitle] = React.useState(initialTitle);
+ const [description, setDescription] = React.useState(initialDescription || "");
+ const [approvalLines, setApprovalLines] = React.useState<ApprovalLine[]>([]);
+ const [previewHtml, setPreviewHtml] = React.useState<string>("");
+
+ // 템플릿 로딩 및 미리보기 생성
+ React.useEffect(() => {
+ if (!open) return;
+
+ async function loadTemplatePreview() {
+ try {
+ setIsLoadingTemplate(true);
+
+ // 1. 템플릿 조회
+ const template = await getApprovalTemplateByName(templateName);
+ if (!template) {
+ toast.error(`템플릿을 찾을 수 없습니다: ${templateName}`);
+ return;
+ }
+
+ // 2. 변수 치환
+ const renderedHtml = await replaceTemplateVariables(
+ template.content || "",
+ variables
+ );
+
+ setPreviewHtml(renderedHtml);
+ } catch (error) {
+ console.error("[ApprovalPreviewDialog] 템플릿 로딩 실패:", error);
+ toast.error("템플릿을 불러오는 중 오류가 발생했습니다.");
+ } finally {
+ setIsLoadingTemplate(false);
+ }
+ }
+
+ loadTemplatePreview();
+ }, [open, templateName, variables]);
+
+ // 초기 결재선 설정
+ React.useEffect(() => {
+ if (!open) return;
+
+ // 상신자 추가
+ const submitter: ApprovalLine = {
+ id: `submitter-${currentUser.id}`,
+ epId: currentUser.epId,
+ userId: currentUser.id.toString(),
+ emailAddress: currentUser.email || "",
+ name: currentUser.name || "상신자",
+ deptName: currentUser.deptName,
+ role: "0", // 상신자
+ seq: "0",
+ opinion: "",
+ };
+
+ // 기본 결재자들 추가 (있는 경우)
+ const defaultLines: ApprovalLine[] = defaultApprovers.map((epId, index) => ({
+ id: `approver-${index}`,
+ epId: epId,
+ userId: "", // EP ID로만 식별
+ emailAddress: "",
+ name: `결재자 ${index + 1}`,
+ role: "1", // 결재
+ seq: (index + 1).toString(),
+ opinion: "",
+ }));
+
+ setApprovalLines([submitter, ...defaultLines]);
+ }, [open, currentUser, defaultApprovers]);
+
+ // 결재선 변경 핸들러
+ const handleApprovalLinesChange = (lines: ApprovalLine[]) => {
+ setApprovalLines(lines);
+ };
+
+ // 제출 핸들러
+ const handleSubmit = async () => {
+ try {
+ // 검증: 결재선 확인
+ const approvers = approvalLines
+ .filter((line) => line.role === "1" && line.seq !== "0")
+ .sort((a, b) => parseInt(a.seq) - parseInt(b.seq));
+
+ if (approvers.length === 0) {
+ toast.error("최소 1명의 결재자를 선택해주세요.");
+ return;
+ }
+
+ // 검증: 제목 확인
+ if (!title.trim()) {
+ toast.error("결재 제목을 입력해주세요.");
+ return;
+ }
+
+ setIsSubmitting(true);
+
+ // EP ID 목록 추출
+ const approverEpIds = approvers
+ .map((line) => line.epId)
+ .filter((epId): epId is string => !!epId);
+
+ // 상위 컴포넌트로 데이터 전달
+ await onConfirm({
+ approvers: approverEpIds,
+ title: title.trim(),
+ description: description.trim() || undefined,
+ });
+
+ // 성공 시 다이얼로그 닫기
+ onOpenChange(false);
+ } catch (error) {
+ console.error("[ApprovalPreviewDialog] 제출 실패:", error);
+ // 에러는 상위 컴포넌트에서 처리 (toast 등)
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ // 취소 핸들러
+ const handleCancel = () => {
+ onOpenChange(false);
+ };
+
+ // 폼 내용
+ const FormContent = () => (
+ <div className="space-y-6">
+ {/* 탭: 미리보기 / 결재선 설정 */}
+ <Tabs defaultValue="preview" className="w-full">
+ <TabsList className="grid w-full grid-cols-2">
+ <TabsTrigger value="preview" className="gap-2">
+ <Eye className="size-4" />
+ 미리보기
+ </TabsTrigger>
+ <TabsTrigger value="approvers" className="gap-2">
+ <Send className="size-4" />
+ 결재선 설정
+ </TabsTrigger>
+ </TabsList>
+
+ {/* 미리보기 탭 */}
+ <TabsContent value="preview" className="space-y-4 mt-4">
+ {/* 제목 입력 */}
+ <div className="space-y-2">
+ <Label htmlFor="title">결재 제목</Label>
+ <Input
+ id="title"
+ value={title}
+ onChange={(e) => setTitle(e.target.value)}
+ placeholder="결재 제목을 입력하세요"
+ disabled={!allowTitleEdit || isSubmitting}
+ />
+ </div>
+
+ {/* 설명 입력 */}
+ <div className="space-y-2">
+ <Label htmlFor="description">결재 설명 (선택사항)</Label>
+ <Textarea
+ id="description"
+ value={description}
+ onChange={(e) => setDescription(e.target.value)}
+ placeholder="결재 설명을 입력하세요"
+ rows={3}
+ disabled={!allowDescriptionEdit || isSubmitting}
+ />
+ </div>
+
+ {/* 템플릿 미리보기 */}
+ <div className="space-y-2">
+ <Label>문서 미리보기</Label>
+ <ScrollArea className="h-[300px] w-full rounded-md border bg-gray-50 p-4">
+ {isLoadingTemplate ? (
+ <div className="flex items-center justify-center h-full">
+ <Loader2 className="size-6 animate-spin text-muted-foreground" />
+ <span className="ml-2 text-sm text-muted-foreground">
+ 템플릿을 불러오는 중...
+ </span>
+ </div>
+ ) : (
+ <div
+ className="prose prose-sm max-w-none"
+ dangerouslySetInnerHTML={{ __html: previewHtml }}
+ />
+ )}
+ </ScrollArea>
+ </div>
+ </TabsContent>
+
+ {/* 결재선 설정 탭 */}
+ <TabsContent value="approvers" className="space-y-4 mt-4">
+ <div className="space-y-2">
+ <Label>결재선</Label>
+ <p className="text-sm text-muted-foreground">
+ 결재자를 검색하여 추가하고, 결재 순서를 설정하세요.
+ </p>
+ </div>
+
+ <ApprovalLineSelector
+ value={approvalLines}
+ onChange={handleApprovalLinesChange}
+ placeholder="결재자를 검색하세요..."
+ maxSelections={10}
+ domainFilter={{ type: "exclude", domains: ["partners"] }}
+ />
+
+ {/* 결재선 요약 */}
+ <div className="rounded-md bg-blue-50 p-3 border border-blue-200">
+ <p className="text-sm font-medium text-blue-900 mb-1">
+ 📋 결재 경로
+ </p>
+ <p className="text-sm text-blue-700">
+ {approvalLines
+ .filter((line) => line.seq !== "0")
+ .sort((a, b) => parseInt(a.seq) - parseInt(b.seq))
+ .map((line) => line.name)
+ .join(" → ") || "결재자를 선택해주세요"}
+ </p>
+ </div>
+ </TabsContent>
+ </Tabs>
+ </div>
+ );
+
+ // Desktop: Dialog
+ if (isDesktop) {
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
+ <DialogHeader>
+ <DialogTitle>결재 미리보기</DialogTitle>
+ <DialogDescription>
+ 결재 문서를 확인하고 결재선을 설정한 후 상신하세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="flex-1 overflow-y-auto">
+ <FormContent />
+ </div>
+
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <Button
+ variant="outline"
+ onClick={handleCancel}
+ disabled={isSubmitting}
+ >
+ <X className="size-4 mr-2" />
+ 취소
+ </Button>
+ <Button
+ onClick={handleSubmit}
+ disabled={isSubmitting || isLoadingTemplate}
+ >
+ {isSubmitting ? (
+ <>
+ <Loader2 className="size-4 mr-2 animate-spin" />
+ 상신 중...
+ </>
+ ) : (
+ <>
+ <Send className="size-4 mr-2" />
+ 결재 상신
+ </>
+ )}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+ }
+
+ // Mobile: Drawer
+ return (
+ <Drawer open={open} onOpenChange={onOpenChange}>
+ <DrawerContent className="max-h-[90vh]">
+ <DrawerHeader>
+ <DrawerTitle>결재 미리보기</DrawerTitle>
+ <DrawerDescription>
+ 결재 문서를 확인하고 결재선을 설정한 후 상신하세요.
+ </DrawerDescription>
+ </DrawerHeader>
+
+ <div className="px-4 overflow-y-auto">
+ <FormContent />
+ </div>
+
+ <DrawerFooter className="gap-2">
+ <Button
+ variant="outline"
+ onClick={handleCancel}
+ disabled={isSubmitting}
+ >
+ <X className="size-4 mr-2" />
+ 취소
+ </Button>
+ <Button
+ onClick={handleSubmit}
+ disabled={isSubmitting || isLoadingTemplate}
+ >
+ {isSubmitting ? (
+ <>
+ <Loader2 className="size-4 mr-2 animate-spin" />
+ 상신 중...
+ </>
+ ) : (
+ <>
+ <Send className="size-4 mr-2" />
+ 결재 상신
+ </>
+ )}
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ );
+}
+
diff --git a/lib/approval/client.ts b/lib/approval/client.ts
new file mode 100644
index 00000000..4f62ddfc
--- /dev/null
+++ b/lib/approval/client.ts
@@ -0,0 +1,23 @@
+/**
+ * 결재 시스템 - 클라이언트 컴포넌트 Export
+ *
+ * ⚠️ 중요: 이 파일은 클라이언트 컴포넌트만 export합니다.
+ *
+ * 서버 전용 함수(Saga, 템플릿 유틸 등)와 분리하여
+ * 클라이언트 번들에 서버 전용 코드가 포함되는 것을 방지합니다.
+ *
+ * 사용법:
+ * ```typescript
+ * // ✅ 올바른 방법
+ * import { ApprovalPreviewDialog } from '@/lib/approval/client';
+ *
+ * // ❌ 잘못된 방법 (서버 코드가 클라이언트 번들에 포함됨)
+ * import { ApprovalPreviewDialog } from '@/lib/approval';
+ * ```
+ */
+
+export {
+ ApprovalPreviewDialog,
+ type ApprovalPreviewDialogProps,
+} from './approval-preview-dialog';
+
diff --git a/lib/approval/index.ts b/lib/approval/index.ts
index 943ada81..a2750805 100644
--- a/lib/approval/index.ts
+++ b/lib/approval/index.ts
@@ -55,3 +55,6 @@ export {
revalidateAllApprovalCaches,
revalidateApprovalDetail,
} from './cache-utils';
+
+// ⚠️ 주의: 클라이언트 컴포넌트는 '@/lib/approval/client'에서 import 하세요
+// export { ApprovalPreviewDialog } from './approval-preview-dialog'; // 제거됨 \ No newline at end of file
diff --git a/lib/approval/template-utils.ts b/lib/approval/template-utils.ts
index a39f8ac4..0607f289 100644
--- a/lib/approval/template-utils.ts
+++ b/lib/approval/template-utils.ts
@@ -108,13 +108,13 @@ export async function htmlTableConverter(
columns: Array<{ key: string; label: string }>
): Promise<string> {
if (!data || data.length === 0) {
- return '<p class="text-gray-500">데이터가 없습니다.</p>';
+ return '<p style="color: #6b7280;">데이터가 없습니다.</p>';
}
const headerRow = columns
.map(
(col) =>
- `<th class="border border-gray-300 px-4 py-2 bg-gray-100 font-semibold text-left">${col.label}</th>`
+ `<th style="border: 1px solid #d1d5db; padding: 8px 16px; background-color: #f3f4f6; font-weight: 600; text-align: left;">${col.label}</th>`
)
.join('');
@@ -125,7 +125,7 @@ export async function htmlTableConverter(
const value = row[col.key];
const displayValue =
value !== undefined && value !== null ? String(value) : '-';
- return `<td class="border border-gray-300 px-4 py-2">${displayValue}</td>`;
+ return `<td style="border: 1px solid #d1d5db; padding: 8px 16px;">${displayValue}</td>`;
})
.join('');
return `<tr>${cells}</tr>`;
@@ -133,7 +133,7 @@ export async function htmlTableConverter(
.join('');
return `
- <table class="w-full border-collapse border border-gray-300 my-4">
+ <table style="width: 100%; border-collapse: collapse; border: 1px solid #d1d5db; margin: 16px 0;">
<thead>
<tr>${headerRow}</tr>
</thead>
@@ -162,19 +162,19 @@ export async function htmlListConverter(
ordered: boolean = false
): Promise<string> {
if (!items || items.length === 0) {
- return '<p class="text-gray-500">항목이 없습니다.</p>';
+ return '<p style="color: #6b7280;">항목이 없습니다.</p>';
}
const listItems = items
- .map((item) => `<li class="mb-1">${item}</li>`)
+ .map((item) => `<li style="margin-bottom: 4px;">${item}</li>`)
.join('');
const tag = ordered ? 'ol' : 'ul';
- const listClass = ordered
- ? 'list-decimal list-inside my-4'
- : 'list-disc list-inside my-4';
+ const listStyle = ordered
+ ? 'list-style-type: decimal; list-style-position: inside; margin: 16px 0; padding-left: 20px;'
+ : 'list-style-type: disc; list-style-position: inside; margin: 16px 0; padding-left: 20px;';
- return `<${tag} class="${listClass}">${listItems}</${tag}>`;
+ return `<${tag} style="${listStyle}">${listItems}</${tag}>`;
}
/**
@@ -196,20 +196,20 @@ export async function htmlDescriptionList(
items: Array<{ label: string; value: string }>
): Promise<string> {
if (!items || items.length === 0) {
- return '<p class="text-gray-500">정보가 없습니다.</p>';
+ return '<p style="color: #6b7280;">정보가 없습니다.</p>';
}
const listItems = items
.map(
(item) => `
- <div class="flex border-b border-gray-200 py-2">
- <dt class="w-1/3 font-semibold text-gray-700">${item.label}</dt>
- <dd class="w-2/3 text-gray-900">${item.value}</dd>
+ <div style="display: flex; border-bottom: 1px solid #e5e7eb; padding: 8px 0;">
+ <dt style="width: 33.333%; font-weight: 600; color: #374151;">${item.label}</dt>
+ <dd style="width: 66.667%; color: #111827;">${item.value}</dd>
</div>
`
)
.join('');
- return `<dl class="my-4">${listItems}</dl>`;
+ return `<dl style="margin: 16px 0;">${listItems}</dl>`;
}
diff --git a/lib/approval/templates/벤더 가입 승인 요청.html b/lib/approval/templates/벤더 가입 승인 요청.html
new file mode 100644
index 00000000..f3e216aa
--- /dev/null
+++ b/lib/approval/templates/벤더 가입 승인 요청.html
@@ -0,0 +1,233 @@
+<!-- 벤더 가입 승인 요청 템플릿 -->
+<!-- 이 템플릿은 approvalTemplates 테이블에 저장됩니다 -->
+<!-- 템플릿명: 벤더 가입 승인 요청 -->
+
+<div
+ style="
+ max-width: 900px;
+ 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
+ colspan="3"
+ 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: #f5f5f5;
+ color: #333;
+ padding: 12px;
+ font-weight: 600;
+ width: 150px;
+ border: 1px solid #ccc;
+ "
+ >
+ 요청일
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #000;
+ padding: 12px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{요청일}}
+ </td>
+ </tr>
+ <tr>
+ <td
+ style="
+ background-color: #f5f5f5;
+ color: #333;
+ padding: 12px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 요청자
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #000;
+ padding: 12px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{요청자}}
+ </td>
+ </tr>
+ <tr>
+ <td
+ style="
+ background-color: #f5f5f5;
+ color: #333;
+ padding: 12px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 승인 대상
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #000;
+ padding: 12px;
+ font-weight: 600;
+ 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
+ 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: #fff;
+ color: #333;
+ padding: 15px;
+ border: 1px solid #ccc;
+ line-height: 1.8;
+ "
+ >
+ {{업체목록}}
+ </td>
+ </tr>
+ <tr>
+ <td style="background-color: #fff; padding: 0; 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
+ 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: #fff;
+ color: #333;
+ padding: 20px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{업체상세정보}}
+ </td>
+ </tr>
+ </tbody>
+ </table>
+</div>
diff --git a/lib/approval/templates/실사의뢰 및 실사재의뢰 요청.html b/lib/approval/templates/실사의뢰 및 실사재의뢰 요청.html
new file mode 100644
index 00000000..e2a7cf6d
--- /dev/null
+++ b/lib/approval/templates/실사의뢰 및 실사재의뢰 요청.html
@@ -0,0 +1,316 @@
+<div
+ style="
+ max-width: 900px;
+ 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
+ colspan="3"
+ style="
+ background-color: #fff;
+ color: #000;
+ padding: 20px;
+ text-align: center;
+ font-size: 24px;
+ font-weight: 700;
+ "
+ >
+ VENDOR 실사의뢰 (재의뢰)
+ </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: #f5f5f5;
+ color: #333;
+ padding: 12px;
+ font-weight: 600;
+ width: 150px;
+ border: 1px solid #ccc;
+ "
+ >
+ 협력사명
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #000;
+ padding: 12px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{협력사명}}
+ </td>
+ </tr>
+ <tr>
+ <td
+ style="
+ background-color: #f5f5f5;
+ color: #333;
+ padding: 12px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 실사요청일
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #000;
+ padding: 12px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{실사요청일}}
+ </td>
+ </tr>
+ <tr>
+ <td
+ style="
+ background-color: #f5f5f5;
+ color: #333;
+ padding: 12px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 실사예정일
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #000;
+ padding: 12px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{실사예정일}}
+ </td>
+ </tr>
+ <tr>
+ <td
+ style="
+ background-color: #f5f5f5;
+ color: #333;
+ padding: 12px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 실사장소
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #000;
+ padding: 12px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{실사장소}}
+ </td>
+ </tr>
+ <tr>
+ <td
+ style="
+ background-color: #f5f5f5;
+ color: #333;
+ padding: 12px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ QM담당자
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #000;
+ padding: 12px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{QM담당자}}
+ </td>
+ </tr>
+ <tr>
+ <td
+ style="
+ background-color: #f5f5f5;
+ color: #333;
+ padding: 12px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ 실사사유목적
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #000;
+ padding: 12px;
+ border: 1px solid #ccc;
+ "
+ >
+ {{실사사유목적}}
+ </td>
+ </tr>
+ <tr>
+ <td
+ style="
+ background-color: #f5f5f5;
+ color: #333;
+ padding: 12px;
+ font-weight: 600;
+ border: 1px solid #ccc;
+ "
+ >
+ ISO 9000S 보유여부
+ </td>
+ <td
+ style="
+ background-color: #fff;
+ color: #000;
+ padding: 12px;
+ 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
+ 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: #fff;
+ color: #333;
+ padding: 15px;
+ border: 1px solid #ccc;
+ line-height: 1.8;
+ "
+ >
+ {{담당자연락처}}
+ </td>
+ </tr>
+ </tbody>
+ </table>
+
+ <!-- 실사품목 -->
+ <table
+ style="
+ width: 100%;
+ border-collapse: collapse;
+ margin-bottom: 20px;
+ border: 1px solid #666;
+ "
+ >
+ <thead>
+ <tr>
+ <th
+ 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: #fff;
+ color: #333;
+ padding: 15px;
+ border: 1px solid #ccc;
+ line-height: 1.8;
+ "
+ >
+ 해당 정보 없음...
+ </td>
+ </tr>
+ </tbody>
+ </table>
+</div>