summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-10-23 18:13:41 +0900
committerjoonhoekim <26rote@gmail.com>2025-10-23 18:13:41 +0900
commit78c471eec35182959e0029ded18f144974ccaca2 (patch)
tree914cdf1c8f406ca3e2aa639b8bb774f7f4e87023
parent0be8940580c4a4a4e098b649d198160f9b60420c (diff)
(김준회) 결재 템플릿 에디터 및 결재 워크플로 공통함수 작성, 실사의뢰 결재 연결 예시 작성
-rw-r--r--app/globals.css30
-rw-r--r--components/approval/ApprovalPreviewDialog.tsx211
-rw-r--r--db/schema/index.ts1
-rw-r--r--db/schema/knox/pending-actions.ts56
-rw-r--r--instrumentation.ts18
-rw-r--r--lib/approval-template/editor/approval-template-editor.tsx146
-rw-r--r--lib/approval/approval-polling-service.ts307
-rw-r--r--lib/approval/approval-workflow.ts272
-rw-r--r--lib/approval/example-usage.ts187
-rw-r--r--lib/approval/handlers-registry.ts49
-rw-r--r--lib/approval/index.ts34
-rw-r--r--lib/approval/template-utils.ts215
-rw-r--r--lib/approval/types.ts25
-rw-r--r--lib/pq/pq-review-table-new/cancel-investigation-dialog.tsx29
-rw-r--r--lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx317
-rw-r--r--lib/vendor-investigation/approval-actions.ts248
-rw-r--r--lib/vendor-investigation/handlers.ts187
-rw-r--r--package.json7
18 files changed, 2276 insertions, 63 deletions
diff --git a/app/globals.css b/app/globals.css
index b1a58b0f..642fbebb 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -251,3 +251,33 @@ th[data-read-only="true"] {
border-right: 1px solid hsl(var(--border));
}
+/* Toast UI Editor 테이블 스타일 커스터마이징 */
+/* 에디터 내부 (편집 중) */
+.toastui-editor-contents table {
+ border-collapse: collapse;
+ width: 100%;
+}
+
+.toastui-editor-contents table thead th {
+ background-color: transparent !important; /* 헤더 배경 제거 */
+ border: 1px solid hsl(var(--border));
+ padding: 8px;
+ text-align: left;
+ font-weight: 600; /* 원하는 경우 폰트 굵기 조정 */
+}
+
+.toastui-editor-contents table tbody td {
+ border: 1px solid hsl(var(--border));
+ padding: 8px;
+}
+
+.toastui-editor-contents table tbody tr:hover {
+ background-color: hsl(var(--muted) / 0.3);
+}
+
+/* 미리보기 및 렌더링된 콘텐츠에도 동일한 스타일 적용 */
+.toastui-editor-ww-mode table thead th,
+.toastui-editor-md-mode table thead th {
+ background-color: transparent !important;
+}
+
diff --git a/components/approval/ApprovalPreviewDialog.tsx b/components/approval/ApprovalPreviewDialog.tsx
new file mode 100644
index 00000000..7739bf54
--- /dev/null
+++ b/components/approval/ApprovalPreviewDialog.tsx
@@ -0,0 +1,211 @@
+"use client";
+
+import * as React from "react";
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { Loader2, FileText, AlertCircle } from "lucide-react";
+import { toast } from "sonner";
+import { Separator } from "@/components/ui/separator";
+
+import ApprovalLineSelector, {
+ type ApprovalLineItem,
+} from "@/components/knox/approval/ApprovalLineSelector";
+import { getApprovalTemplateByName, replaceTemplateVariables } from "@/lib/approval/template-utils";
+import { debugLog, debugError, debugSuccess } from "@/lib/debug-utils";
+
+interface ApprovalPreviewDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ templateName: string;
+ variables: Record<string, string>;
+ title: string;
+ description?: string;
+ currentUser: { id: number; epId: string | null; name?: string | null; email?: string };
+ onSubmit: (approvers: ApprovalLineItem[]) => Promise<void>;
+}
+
+export function ApprovalPreviewDialog({
+ open,
+ onOpenChange,
+ templateName,
+ variables,
+ title,
+ description,
+ currentUser,
+ onSubmit,
+}: ApprovalPreviewDialogProps) {
+ const [approvalLines, setApprovalLines] = React.useState<ApprovalLineItem[]>([]);
+ const [isSubmitting, setIsSubmitting] = React.useState(false);
+ const [templateContent, setTemplateContent] = React.useState<string>("");
+ const [isLoadingTemplate, setIsLoadingTemplate] = React.useState(false);
+ const [templateError, setTemplateError] = React.useState<string | null>(null);
+
+ // 상신자 초기화
+ React.useEffect(() => {
+ if (open && currentUser.epId) {
+ const drafterLine: ApprovalLineItem = {
+ id: `drafter-${Date.now()}`,
+ epId: currentUser.epId,
+ userId: currentUser.id.toString(),
+ emailAddress: currentUser.email,
+ name: currentUser.name || undefined,
+ role: "0", // 기안
+ seq: "0",
+ opinion: "",
+ };
+ setApprovalLines([drafterLine]);
+ }
+ }, [open, currentUser]);
+
+ // 템플릿 로드 및 변수 치환
+ React.useEffect(() => {
+ if (!open) return;
+
+ const loadTemplate = async () => {
+ setIsLoadingTemplate(true);
+ setTemplateError(null);
+
+ try {
+ const template = await getApprovalTemplateByName(templateName);
+
+ if (!template) {
+ setTemplateError(`템플릿 "${templateName}"을 찾을 수 없습니다.`);
+ setTemplateContent(`<p class="text-gray-500">${description || "결재 요청"}</p>`);
+ } else {
+ // 변수 치환
+ const replaced = await replaceTemplateVariables(template.content, variables);
+ setTemplateContent(replaced);
+ }
+ } catch (error) {
+ console.error("Template load error:", error);
+ setTemplateError("템플릿을 불러오는 중 오류가 발생했습니다.");
+ setTemplateContent(`<p class="text-gray-500">${description || "결재 요청"}</p>`);
+ } finally {
+ setIsLoadingTemplate(false);
+ }
+ };
+
+ loadTemplate();
+ }, [open, templateName, variables, description]);
+
+ const handleSubmit = async () => {
+ debugLog('[ApprovalPreviewDialog] 결재 제출 시작', {
+ templateName,
+ approvalLineCount: approvalLines.length,
+ });
+
+ // 결재자가 있는지 확인 (상신자 제외)
+ const approvers = approvalLines.filter((line) => line.seq !== "0");
+ if (approvers.length === 0) {
+ debugError('[ApprovalPreviewDialog] 결재자가 없음');
+ toast.error("결재자를 최소 1명 이상 추가해주세요.");
+ return;
+ }
+
+ setIsSubmitting(true);
+ try {
+ debugLog('[ApprovalPreviewDialog] onSubmit 호출', {
+ approversCount: approvers.length,
+ });
+
+ await onSubmit(approvalLines);
+
+ debugSuccess('[ApprovalPreviewDialog] 결재 요청 성공');
+ toast.success("결재가 성공적으로 요청되었습니다.");
+ onOpenChange(false);
+ } catch (error) {
+ debugError('[ApprovalPreviewDialog] 결재 요청 실패', error);
+ const errorMessage = error instanceof Error ? error.message : "결재 요청에 실패했습니다.";
+ toast.error(errorMessage);
+ // 에러 발생 시 dialog를 닫지 않음
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <FileText className="w-5 h-5" />
+ {title}
+ </DialogTitle>
+ {description && <DialogDescription>{description}</DialogDescription>}
+ </DialogHeader>
+
+ <div className="space-y-6">
+ {/* 템플릿 미리보기 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>결재 내용 미리보기</CardTitle>
+ <CardDescription>
+ 템플릿: <span className="font-mono text-sm">{templateName}</span>
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ {isLoadingTemplate ? (
+ <div className="flex items-center justify-center py-8">
+ <Loader2 className="w-6 h-6 animate-spin text-gray-400" />
+ <span className="ml-2 text-gray-500">템플릿을 불러오는 중...</span>
+ </div>
+ ) : templateError ? (
+ <div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
+ <div className="flex items-center gap-2 text-yellow-700">
+ <AlertCircle className="w-4 h-4" />
+ <span className="font-medium">경고</span>
+ </div>
+ <p className="text-sm text-yellow-600 mt-1">{templateError}</p>
+ <p className="text-xs text-yellow-500 mt-2">
+ 기본 내용으로 대체되었습니다. 결재는 정상적으로 진행됩니다.
+ </p>
+ </div>
+ ) : null}
+
+ <div
+ className="border rounded-lg p-4 min-h-[200px] [&_table]:w-full [&_table]:border-collapse [&_table]:border [&_table]:border-border [&_th]:border [&_th]:border-border [&_th]:bg-muted/50 [&_th]:px-3 [&_th]:py-2 [&_th]:text-left [&_th]:font-semibold [&_td]:border [&_td]:border-border [&_td]:px-3 [&_td]:py-2 [&_tr:hover]:bg-muted/30"
+ dangerouslySetInnerHTML={{ __html: templateContent }}
+ />
+ </CardContent>
+ </Card>
+
+ <Separator />
+
+ {/* 결재선 선택 */}
+ <div>
+ <ApprovalLineSelector
+ value={approvalLines}
+ onChange={setApprovalLines}
+ placeholder="결재자를 검색하세요..."
+ maxSelections={10}
+ />
+ </div>
+ </div>
+
+ <DialogFooter className="gap-2">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button type="button" onClick={handleSubmit} disabled={isSubmitting || isLoadingTemplate}>
+ {isSubmitting && <Loader2 className="mr-2 w-4 h-4 animate-spin" />}
+ 결재 요청
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ );
+}
+
diff --git a/db/schema/index.ts b/db/schema/index.ts
index 0e3daf80..dbbb90a1 100644
--- a/db/schema/index.ts
+++ b/db/schema/index.ts
@@ -70,6 +70,7 @@ export * from './knox/employee'; // 임직원
export * from './knox/organization'; // 조직도
export * from './knox/titles'; // 직급
export * from './knox/approvals'; // Knox 결재 - eVCP 에서 상신한 결재를 저장
+export * from './knox/pending-actions'; // Pending Actions - 결재가 필요한 액션들을 보류 상태로 관리
// === Risks 스키마 ===
export * from './risks/risks';
diff --git a/db/schema/knox/pending-actions.ts b/db/schema/knox/pending-actions.ts
new file mode 100644
index 00000000..e909dc27
--- /dev/null
+++ b/db/schema/knox/pending-actions.ts
@@ -0,0 +1,56 @@
+import { jsonb, text, timestamp, integer, serial } from "drizzle-orm/pg-core";
+import { knoxSchema } from "./employee";
+import { approvalLogs } from "./approvals";
+import { users } from "@/db/schema/users";
+
+/**
+ * Pending Actions 테이블
+ *
+ * 결재가 필요한 액션들을 보류(pending) 상태로 저장하고,
+ * 결재 승인 후 실제 비즈니스 로직을 실행하는 워크플로우를 관리
+ *
+ * 워크플로우:
+ * 1. 사용자 요청 → pendingAction 생성 (status: 'pending')
+ * 2. Knox 결재 상신 → approvalLog와 연결
+ * 3. [폴링으로 결재 상태 감지]
+ * 4. 결재 승인 → status: 'approved' → 액션 실행
+ * 5. 실행 완료 → status: 'executed'
+ */
+export const pendingActions = knoxSchema.table("pending_actions", {
+ // 기본 정보
+ id: serial("id").primaryKey(),
+
+ // 결재 연결 (approvalLogs의 apInfId 참조)
+ apInfId: text("ap_inf_id")
+ .references(() => approvalLogs.apInfId, { onDelete: "cascade" })
+ .notNull(),
+
+ // 액션 정보
+ actionType: text("action_type").notNull(), // 예: 'vendor_investigation_request', 'purchase_order_request'
+ actionPayload: jsonb("action_payload").notNull(), // 실행에 필요한 모든 파라미터 저장
+
+ // 상태 관리
+ // pending: 결재 대기 중
+ // approved: 결재 승인됨 (실행 대기)
+ // executed: 실행 완료
+ // failed: 실행 실패
+ // rejected: 결재 반려됨
+ // cancelled: 결재 취소됨
+ status: text("status").notNull().default("pending"),
+
+ // 실행 결과 (옵션)
+ executionResult: jsonb("execution_result"), // 실행 결과 저장 (성공/실패 정보)
+ errorMessage: text("error_message"), // 실패 시 에러 메시지
+
+ // 실행 시간
+ executedAt: timestamp("executed_at"), // 실제 액션이 실행된 시간
+
+ // 생성자 정보
+ createdBy: integer("created_by")
+ .notNull()
+ .references(() => users.id, { onDelete: "set null" }),
+
+ // 타임스탬프
+ createdAt: timestamp("created_at").notNull().defaultNow(),
+ updatedAt: timestamp("updated_at").notNull().defaultNow(),
+}); \ No newline at end of file
diff --git a/instrumentation.ts b/instrumentation.ts
index 3cf9e689..de353e5f 100644
--- a/instrumentation.ts
+++ b/instrumentation.ts
@@ -81,5 +81,23 @@ export async function register() {
// console.error('Failed to start User Code sync scheduler.');
// // 스케줄러 실패해도 애플리케이션은 계속 실행
// }
+
+ try {
+ // Knox 결재 액션 핸들러 초기화 (앱 시작 시 핸들러 등록)
+ const { initializeApprovalHandlers } = await import('./lib/approval/handlers-registry');
+ await initializeApprovalHandlers();
+ } catch {
+ console.error('Failed to initialize approval handlers.');
+ // 핸들러 초기화 실패해도 애플리케이션은 계속 실행
+ }
+
+ try {
+ // Knox 결재 상태 폴링 스케줄러 시작 (1분마다 pending 결재 상태 확인)
+ const { startApprovalPollingScheduler } = await import('./lib/approval/approval-polling-service');
+ await startApprovalPollingScheduler();
+ } catch {
+ console.error('Failed to start Approval polling scheduler.');
+ // 스케줄러 실패해도 애플리케이션은 계속 실행
+ }
}
}
diff --git a/lib/approval-template/editor/approval-template-editor.tsx b/lib/approval-template/editor/approval-template-editor.tsx
index 2c4ef65e..242504e7 100644
--- a/lib/approval-template/editor/approval-template-editor.tsx
+++ b/lib/approval-template/editor/approval-template-editor.tsx
@@ -2,13 +2,15 @@
import * as React from "react"
import { toast } from "sonner"
-import { Loader, Save, ArrowLeft } from "lucide-react"
+import { Loader, Save, ArrowLeft, Code2, Eye } from "lucide-react"
import Link from "next/link"
+import dynamic from "next/dynamic"
+import type { Editor as ToastEditor } from "@toast-ui/editor"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Input } from "@/components/ui/input"
-import RichTextEditor from "@/components/rich-text-editor/RichTextEditor"
+import { Textarea } from "@/components/ui/textarea"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Separator } from "@/components/ui/separator"
import { Badge } from "@/components/ui/badge"
@@ -17,12 +19,26 @@ import { formatApprovalLine } from "@/lib/approval-line/utils/format"
import { getApprovalLineOptionsAction } from "@/lib/approval-line/service"
import { type ApprovalTemplate } from "@/lib/approval-template/service"
-import { type Editor } from "@tiptap/react"
import { updateApprovalTemplateAction } from "@/lib/approval-template/service"
import { getActiveApprovalTemplateCategories, type ApprovalTemplateCategory } from "@/lib/approval-template/category-service"
import { useSession } from "next-auth/react"
import { useRouter, usePathname } from "next/navigation"
+// Toast UI Editor를 dynamic import로 불러옴 (SSR 방지)
+const Editor = dynamic(
+ async () => {
+ // CSS를 먼저 로드
+ // @ts-expect-error - CSS 파일은 타입 선언이 없지만 런타임에 정상 작동
+ await import("@toast-ui/editor/dist/toastui-editor.css")
+ const mod = await import("@toast-ui/react-editor")
+ return mod.Editor
+ },
+ {
+ ssr: false,
+ loading: () => <div className="flex items-center justify-center h-[400px] text-muted-foreground">에디터 로드 중...</div>,
+ }
+)
+
interface ApprovalTemplateEditorProps {
templateId: string
initialTemplate: ApprovalTemplate
@@ -34,9 +50,13 @@ export function ApprovalTemplateEditor({ templateId, initialTemplate, staticVari
const { data: session } = useSession()
const router = useRouter()
const pathname = usePathname()
- const [rte, setRte] = React.useState<Editor | null>(null)
+ const editorRef = React.useRef<ToastEditor | null>(null)
const [template, setTemplate] = React.useState(initialTemplate)
const [isSaving, startSaving] = React.useTransition()
+ const [previewContent, setPreviewContent] = React.useState(initialTemplate.content)
+ const [editMode, setEditMode] = React.useState<"wysiwyg" | "html">("wysiwyg")
+ const [htmlSource, setHtmlSource] = React.useState(initialTemplate.content)
+ const [editorKey, setEditorKey] = React.useState(0) // 에디터 재마운트를 위한 키
// 편집기에 전달할 변수 목록
// 템플릿(DB) 변수 + 정적(config) 변수 병합
@@ -53,7 +73,6 @@ export function ApprovalTemplateEditor({ templateId, initialTemplate, staticVari
const [form, setForm] = React.useState({
name: template.name,
subject: template.subject,
- content: template.content,
description: template.description ?? "",
category: template.category ?? "",
approvalLineId: (template as { approvalLineId?: string | null }).approvalLineId ?? "",
@@ -93,6 +112,7 @@ export function ApprovalTemplateEditor({ templateId, initialTemplate, staticVari
return () => {
active = false
}
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, []) // 빈 의존성 배열로 초기 한 번만 실행
// 결재선 옵션 로드
@@ -120,6 +140,20 @@ export function ApprovalTemplateEditor({ templateId, initialTemplate, staticVari
setForm((prev) => ({ ...prev, [name]: value }))
}
+ // 편집 모드 토글
+ function toggleEditMode() {
+ if (editMode === "wysiwyg") {
+ // WYSIWYG → HTML: 에디터에서 HTML 가져오기
+ const currentHtml = editorRef.current?.getHTML() || ""
+ setHtmlSource(currentHtml)
+ setEditMode("html")
+ } else {
+ // HTML → WYSIWYG: 에디터를 재마운트하여 수정된 HTML 반영
+ setEditorKey(prev => prev + 1)
+ setEditMode("wysiwyg")
+ }
+ }
+
function handleSave() {
startSaving(async () => {
if (!session?.user?.id) {
@@ -127,10 +161,15 @@ export function ApprovalTemplateEditor({ templateId, initialTemplate, staticVari
return
}
+ // 현재 모드에 따라 HTML 가져오기
+ const content = editMode === "wysiwyg"
+ ? editorRef.current?.getHTML() || ""
+ : htmlSource
+
const { success, error, data } = await updateApprovalTemplateAction(templateId, {
name: form.name,
subject: form.subject,
- content: form.content,
+ content,
description: form.description,
category: form.category || undefined,
approvalLineId: form.approvalLineId ? form.approvalLineId : null,
@@ -146,10 +185,12 @@ export function ApprovalTemplateEditor({ templateId, initialTemplate, staticVari
toast.success("저장되었습니다")
// 저장 후 목록 페이지로 이동 (back-button.tsx 로직 참고)
- const segments = pathname.split('/').filter(Boolean)
- const newSegments = segments.slice(0, -1) // 마지막 세그먼트(ID) 제거
- const targetPath = newSegments.length > 0 ? `/${newSegments.join('/')}` : '/'
- router.push(targetPath)
+ if (pathname) {
+ const segments = pathname.split('/').filter(Boolean)
+ const newSegments = segments.slice(0, -1) // 마지막 세그먼트(ID) 제거
+ const targetPath = newSegments.length > 0 ? `/${newSegments.join('/')}` : '/'
+ router.push(targetPath)
+ }
})
}
@@ -182,7 +223,18 @@ export function ApprovalTemplateEditor({ templateId, initialTemplate, staticVari
<Separator />
- <Tabs defaultValue="editor" className="flex-1">
+ <Tabs
+ defaultValue="editor"
+ className="flex-1"
+ onValueChange={(value) => {
+ if (value === "preview") {
+ const currentHtml = editMode === "wysiwyg"
+ ? editorRef.current?.getHTML() || ""
+ : htmlSource
+ setPreviewContent(currentHtml)
+ }
+ }}
+ >
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="editor">편집</TabsTrigger>
<TabsTrigger value="preview">미리보기</TabsTrigger>
@@ -261,8 +313,30 @@ export function ApprovalTemplateEditor({ templateId, initialTemplate, staticVari
<Card>
<CardHeader>
- <CardTitle>HTML 내용</CardTitle>
- <CardDescription>표, 이미지, 변수 등을 자유롭게 편집하세요.</CardDescription>
+ <div className="flex items-center justify-between">
+ <div>
+ <CardTitle>HTML 내용</CardTitle>
+ <CardDescription>표, 이미지, 변수 등을 자유롭게 편집하세요.</CardDescription>
+ </div>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={toggleEditMode}
+ className="flex items-center gap-2"
+ >
+ {editMode === "wysiwyg" ? (
+ <>
+ <Code2 className="h-4 w-4" />
+ HTML로 수정
+ </>
+ ) : (
+ <>
+ <Eye className="h-4 w-4" />
+ 에디터로 수정
+ </>
+ )}
+ </Button>
+ </div>
</CardHeader>
<CardContent>
{variableOptions.length > 0 && (
@@ -272,19 +346,49 @@ export function ApprovalTemplateEditor({ templateId, initialTemplate, staticVari
key={v.label}
variant="outline"
size="sm"
- onClick={() => rte?.chain().focus().insertContent(v.html).run()}
+ onClick={() => {
+ if (editMode === "wysiwyg" && editorRef.current) {
+ editorRef.current.insertText(v.html)
+ } else if (editMode === "html") {
+ // HTML 모드에서는 텍스트 끝에 추가
+ setHtmlSource((prev) => prev + v.html)
+ }
+ }}
>
{`{{${v.label}}}`}
</Button>
))}
</div>
)}
- <RichTextEditor
- value={form.content}
- onChange={(val) => setForm((prev) => ({ ...prev, content: val }))}
- onReady={(editor) => setRte(editor)}
- height="400px"
- />
+
+ {editMode === "wysiwyg" ? (
+ <Editor
+ key={editorKey}
+ initialValue={htmlSource}
+ previewStyle="vertical"
+ height="500px"
+ initialEditType="wysiwyg"
+ useCommandShortcut={true}
+ hideModeSwitch={true}
+ toolbarItems={[
+ ["heading", "bold", "italic", "strike"],
+ ["hr", "quote"],
+ ["ul", "ol", "task"],
+ ["table", "link"],
+ ["code", "codeblock"],
+ ]}
+ onLoad={(editor) => {
+ editorRef.current = editor
+ }}
+ />
+ ) : (
+ <Textarea
+ value={htmlSource}
+ onChange={(e) => setHtmlSource(e.target.value)}
+ className="font-mono text-sm min-h-[500px] resize-y"
+ placeholder="HTML 소스를 직접 편집하세요..."
+ />
+ )}
</CardContent>
</Card>
</TabsContent>
@@ -295,7 +399,7 @@ export function ApprovalTemplateEditor({ templateId, initialTemplate, staticVari
<CardTitle>미리보기</CardTitle>
</CardHeader>
<CardContent className="border rounded-md p-4 overflow-auto bg-background">
- <div dangerouslySetInnerHTML={{ __html: form.content }} />
+ <div dangerouslySetInnerHTML={{ __html: previewContent }} />
</CardContent>
</Card>
</TabsContent>
diff --git a/lib/approval/approval-polling-service.ts b/lib/approval/approval-polling-service.ts
new file mode 100644
index 00000000..d2ee16cb
--- /dev/null
+++ b/lib/approval/approval-polling-service.ts
@@ -0,0 +1,307 @@
+/**
+ * Knox 결재 상태 폴링 서비스
+ *
+ * 기능:
+ * - 1분마다 진행중인(pending) 결재를 조회
+ * - Knox API로 상태 일괄 확인 (최대 1000건씩 배치)
+ * - 상태 변경 시 DB 업데이트 및 후속 처리
+ *
+ * 흐름:
+ * Cron (1분) → checkPendingApprovals() → Knox API 조회 → 상태 업데이트 → executeApprovedAction()
+ *
+ * 기존 syncApprovalStatusAction과의 차이점:
+ * - syncApprovalStatusAction: UI에서 수동으로 트리거 (on-demand)
+ * - checkPendingApprovals: 백그라운드 cron job (1분마다 자동)
+ * - checkPendingApprovals: 결재 완료/반려 시 자동으로 후속 처리 실행
+ */
+
+import cron from 'node-cron';
+import db from '@/db/db';
+import { eq, and, inArray } from 'drizzle-orm';
+import { executeApprovedAction, handleRejectedAction } from './approval-workflow';
+
+/**
+ * Pending 상태의 결재들을 조회하고 상태 동기화
+ *
+ * 처리 로직 (syncApprovalStatusAction 기반):
+ * 1. DB에서 진행중인 결재 조회 (암호화중~진행중)
+ * 2. Knox API로 일괄 상태 확인 (최대 1000건씩 배치)
+ * 3. 상태가 변경된 경우 DB 업데이트
+ * 4. 완결/반려로 변경 시 후속 처리 (executeApprovedAction/handleRejectedAction)
+ */
+export async function checkPendingApprovals() {
+ console.log('[Approval Polling] Starting approval status check...');
+
+ try {
+ // 동적 import로 스키마 및 Knox API 로드
+ const { approvalLogs } = await import('@/db/schema/knox/approvals');
+ const { getApprovalStatus } = await import('@/lib/knox-api/approval/approval');
+ const { upsertApprovalStatus } = await import('@/lib/knox-api/approval/service');
+
+ // 1. 진행중인 결재건들 조회 (암호화중~진행중까지)
+ // 암호화중(-2), 예약상신(-1), 보류(0), 진행중(1) 상태만 조회
+ // 완결(2), 반려(3), 상신취소(4), 전결(5), 후완결(6)은 제외
+ const pendingApprovals = await db
+ .select({
+ apInfId: approvalLogs.apInfId,
+ status: approvalLogs.status,
+ })
+ .from(approvalLogs)
+ .where(
+ and(
+ eq(approvalLogs.isDeleted, false),
+ inArray(approvalLogs.status, ['-2', '-1', '0', '1'])
+ )
+ );
+
+ if (pendingApprovals.length === 0) {
+ console.log('[Approval Polling] No pending approvals found');
+ return {
+ success: true,
+ message: 'No pending approvals',
+ checked: 0,
+ updated: 0,
+ executed: 0,
+ };
+ }
+
+ console.log(`[Approval Polling] Found ${pendingApprovals.length} pending approvals to check`);
+
+ // 2. Knox API 호출을 위한 요청 데이터 구성
+ const apinfids = pendingApprovals.map(approval => ({
+ apinfid: approval.apInfId
+ }));
+
+ // 3. Knox API로 결재 상황 조회 (최대 1000건씩 배치 처리)
+ const batchSize = 1000;
+ let updated = 0;
+ let executed = 0;
+ const failed: string[] = [];
+
+ for (let i = 0; i < apinfids.length; i += batchSize) {
+ const batch = apinfids.slice(i, i + batchSize);
+
+ try {
+ console.log(`[Approval Polling] Processing batch ${Math.floor(i / batchSize) + 1} (${batch.length} items)`);
+
+ const statusResponse = await getApprovalStatus({
+ apinfids: batch
+ });
+
+ if (statusResponse.result === 'success' && statusResponse.data) {
+ // 4. 조회된 상태로 데이터베이스 업데이트 및 후속 처리
+ for (const statusData of statusResponse.data) {
+ try {
+ // 기존 데이터 찾기
+ const currentApproval = pendingApprovals.find(
+ approval => approval.apInfId === statusData.apInfId
+ );
+
+ if (!currentApproval) {
+ console.warn(`[Approval Polling] Approval not found in local list: ${statusData.apInfId}`);
+ continue;
+ }
+
+ const oldStatus = currentApproval.status;
+ const newStatus = statusData.status;
+
+ // 상태가 변경된 경우에만 처리
+ if (oldStatus !== newStatus) {
+ console.log(`[Approval Polling] Status changed: ${statusData.apInfId} (${oldStatus} → ${newStatus})`);
+
+ // DB 상태 업데이트
+ await upsertApprovalStatus(statusData.apInfId, newStatus);
+ updated++;
+
+ // 5. 후속 처리 - 완결 상태로 변경된 경우
+ // 완결(2), 전결(5), 후완결(6)
+ if (['2', '5', '6'].includes(newStatus)) {
+ try {
+ await executeApprovedAction(currentApproval.apInfId);
+ executed++;
+ console.log(`[Approval Polling] ✅ Executed approved action: ${statusData.apInfId}`);
+ } catch (execError) {
+ console.error(`[Approval Polling] ❌ Failed to execute action: ${statusData.apInfId}`, execError);
+ // 실행 실패는 별도로 기록하되 폴링은 계속 진행
+ }
+ }
+
+ // 반려된 경우
+ else if (newStatus === '3') {
+ try {
+ await handleRejectedAction(currentApproval.apInfId, '결재가 반려되었습니다');
+ console.log(`[Approval Polling] ⛔ Handled rejected action: ${statusData.apInfId}`);
+ } catch (rejectError) {
+ console.error(`[Approval Polling] Failed to handle rejection: ${statusData.apInfId}`, rejectError);
+ }
+ }
+
+ // 상신취소된 경우
+ else if (newStatus === '4') {
+ try {
+ await handleRejectedAction(currentApproval.apInfId, '결재가 취소되었습니다');
+ console.log(`[Approval Polling] 🚫 Handled cancelled action: ${statusData.apInfId}`);
+ } catch (cancelError) {
+ console.error(`[Approval Polling] Failed to handle cancellation: ${statusData.apInfId}`, cancelError);
+ }
+ }
+ }
+ } catch (updateError) {
+ console.error(`[Approval Polling] Update failed for ${statusData.apInfId}:`, updateError);
+ failed.push(statusData.apInfId);
+ }
+ }
+ } else {
+ console.error('[Approval Polling] Knox API returned error:', statusResponse);
+ batch.forEach(item => failed.push(item.apinfid));
+ }
+ } catch (batchError) {
+ console.error('[Approval Polling] Batch processing failed:', batchError);
+ batch.forEach(item => failed.push(item.apinfid));
+ }
+ }
+
+ const summary = {
+ success: true,
+ message: `Polling completed: ${updated} updated, ${executed} executed${failed.length > 0 ? `, ${failed.length} failed` : ''}`,
+ checked: pendingApprovals.length,
+ updated,
+ executed,
+ failed: failed.length,
+ };
+
+ console.log('[Approval Polling] Summary:', summary);
+
+ return summary;
+
+ } catch (error) {
+ console.error('[Approval Polling] Error during approval status check:', error);
+ return {
+ success: false,
+ message: `Polling failed: ${error}`,
+ checked: 0,
+ updated: 0,
+ executed: 0,
+ failed: 0,
+ };
+ }
+}
+
+/**
+ * 결재 폴링 스케줄러 시작
+ * 1분마다 pending 결재 상태 확인
+ *
+ * instrumentation.ts에서 호출됨
+ */
+export async function startApprovalPollingScheduler() {
+ console.log('[Approval Polling] Starting approval polling scheduler...');
+
+ // 1분마다 실행 (cron: '* * * * *')
+ const task = cron.schedule(
+ '* * * * *', // 매분 실행
+ async () => {
+ try {
+ await checkPendingApprovals();
+ } catch (error) {
+ console.error('[Approval Polling] Scheduled task failed:', error);
+ }
+ },
+ {
+ timezone: 'Asia/Seoul',
+ }
+ );
+
+ // 앱 시작 시 즉시 한 번 실행 (선택사항)
+ // await checkPendingApprovals();
+
+ console.log('[Approval Polling] Scheduler started - running every minute');
+
+ return task;
+}
+
+/**
+ * 특정 결재의 상태를 즉시 확인 (수동 트리거용)
+ * UI에서 "상태 새로고침" 버튼 클릭 시 사용
+ *
+ * @param apInfId - Knox 결재 ID
+ * @returns 결재 상태 및 업데이트 여부
+ */
+export async function checkSingleApprovalStatus(apInfId: string) {
+ console.log(`[Approval Polling] Checking single approval: ${apInfId}`);
+
+ try {
+ // 동적 import로 스키마 및 Knox API 로드
+ const { approvalLogs } = await import('@/db/schema/knox/approvals');
+ const { getApprovalStatus } = await import('@/lib/knox-api/approval/approval');
+ const { upsertApprovalStatus } = await import('@/lib/knox-api/approval/service');
+
+ // 1. DB에서 결재 정보 조회
+ const approvalLog = await db.query.approvalLogs.findFirst({
+ where: eq(approvalLogs.apInfId, apInfId),
+ });
+
+ if (!approvalLog) {
+ throw new Error(`Approval log not found for apInfId: ${apInfId}`);
+ }
+
+ const oldStatus = approvalLog.status;
+
+ // 2. Knox API로 현재 상태 조회 (단건)
+ const statusResponse = await getApprovalStatus({
+ apinfids: [{ apinfid: apInfId }]
+ });
+
+ if (statusResponse.result !== 'success' || !statusResponse.data || statusResponse.data.length === 0) {
+ throw new Error(`Failed to fetch Knox status for ${apInfId}`);
+ }
+
+ const knoxStatus = statusResponse.data[0];
+ const newStatus = knoxStatus.status;
+
+ // 3. 상태가 변경된 경우 업데이트 및 후속 처리
+ let executed = false;
+ if (oldStatus !== newStatus) {
+ console.log(`[Approval Polling] Single check - Status changed: ${apInfId} (${oldStatus} → ${newStatus})`);
+
+ // DB 상태 업데이트
+ await upsertApprovalStatus(apInfId, newStatus);
+
+ // 4. 후속 처리
+ // 완결(2), 전결(5), 후완결(6)
+ if (['2', '5', '6'].includes(newStatus)) {
+ try {
+ await executeApprovedAction(approvalLog.apInfId);
+ executed = true;
+ console.log(`[Approval Polling] ✅ Single check - Executed approved action: ${apInfId}`);
+ } catch (execError) {
+ console.error(`[Approval Polling] ❌ Single check - Failed to execute action: ${apInfId}`, execError);
+ }
+ }
+ // 반려(3)
+ else if (newStatus === '3') {
+ await handleRejectedAction(approvalLog.apInfId, '결재가 반려되었습니다');
+ console.log(`[Approval Polling] ⛔ Single check - Handled rejected action: ${apInfId}`);
+ }
+ // 상신취소(4)
+ else if (newStatus === '4') {
+ await handleRejectedAction(approvalLog.apInfId, '결재가 취소되었습니다');
+ console.log(`[Approval Polling] 🚫 Single check - Handled cancelled action: ${apInfId}`);
+ }
+ } else {
+ console.log(`[Approval Polling] Single check - No status change: ${apInfId} (${oldStatus})`);
+ }
+
+ return {
+ success: true,
+ apInfId,
+ oldStatus,
+ newStatus,
+ updated: oldStatus !== newStatus,
+ executed,
+ };
+ } catch (error) {
+ console.error(`[Approval Polling] Failed to check single approval status for ${apInfId}:`, error);
+ throw error;
+ }
+}
+
diff --git a/lib/approval/approval-workflow.ts b/lib/approval/approval-workflow.ts
new file mode 100644
index 00000000..cc8914f9
--- /dev/null
+++ b/lib/approval/approval-workflow.ts
@@ -0,0 +1,272 @@
+/**
+ * 결재 워크플로우 관리 모듈
+ *
+ * 주요 기능:
+ * 1. 결재가 필요한 액션을 pending 상태로 저장
+ * 2. Knox 결재 시스템에 상신
+ * 3. 결재 완료 시 저장된 액션 실행
+ *
+ * 흐름:
+ * withApproval() → Knox 상신 → [폴링으로 상태 감지] → executeApprovedAction()
+ */
+
+import db from '@/db/db';
+import { eq } from 'drizzle-orm';
+import { pendingActions } from '@/db/schema/knox/pending-actions';
+
+import type { ApprovalConfig } from './types';
+import type { ApprovalLine } from '@/lib/knox-api/approval/approval';
+
+/**
+ * 액션 핸들러 타입 정의
+ * payload를 받아서 실제 비즈니스 로직을 수행하는 함수
+ */
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export type ActionHandler = (payload: any) => Promise<any>;
+
+/**
+ * 액션 타입별 핸들러 저장소
+ * registerActionHandler()로 등록된 핸들러들이 여기 저장됨
+ */
+const actionHandlers = new Map<string, ActionHandler>();
+
+/**
+ * 특정 액션 타입에 대한 핸들러 등록
+ *
+ * @example
+ * registerActionHandler('vendor_investigation_request', async (payload) => {
+ * return await createInvestigation(payload);
+ * });
+ */
+export function registerActionHandler(actionType: string, handler: ActionHandler) {
+ actionHandlers.set(actionType, handler);
+}
+
+/**
+ * 등록된 핸들러 조회 (디버깅/테스트용)
+ */
+export function getRegisteredHandlers() {
+ return Array.from(actionHandlers.keys());
+}
+
+/**
+ * 결재가 필요한 액션을 래핑하는 공통 함수
+ *
+ * 사용법:
+ * ```typescript
+ * const result = await withApproval(
+ * 'vendor_investigation_request',
+ * { vendorId: 123, reason: '실사 필요' },
+ * {
+ * title: '실사 요청 결재',
+ * description: 'ABC 협력업체 실사 요청',
+ * templateName: '협력업체 실사 요청',
+ * variables: { '수신자이름': '결재자', ... },
+ * currentUser: { id: 1, epId: 'EP001' }
+ * }
+ * );
+ * ```
+ *
+ * @param actionType - 액션 타입 (핸들러 등록 시 사용한 키)
+ * @param actionPayload - 액션 실행에 필요한 데이터
+ * @param approvalConfig - 결재 상신 설정 (템플릿명, 변수 포함)
+ * @returns pendingActionId, approvalId, status
+ */
+export async function withApproval<T>(
+ actionType: string,
+ actionPayload: T,
+ approvalConfig: ApprovalConfig
+) {
+ // 핸들러가 등록되어 있는지 확인
+ if (!actionHandlers.has(actionType)) {
+ throw new Error(`No handler registered for action type: ${actionType}`);
+ }
+
+ try {
+ // 1. 템플릿 조회 및 변수 치환
+ const { getApprovalTemplateByName, replaceTemplateVariables } = await import('./template-utils');
+ const template = await getApprovalTemplateByName(approvalConfig.templateName);
+
+ let content: string;
+ if (!template) {
+ console.warn(`[Approval Workflow] Template not found: ${approvalConfig.templateName}`);
+ // 템플릿이 없으면 기본 내용 사용
+ content = approvalConfig.description || '결재 요청';
+ } else {
+ // 템플릿 변수 치환
+ content = await replaceTemplateVariables(template.content, approvalConfig.variables);
+ }
+
+ // 2. Knox 결재 상신 (apInfId 생성, 치환된 content 사용)
+ const {
+ submitApproval,
+ createSubmitApprovalRequest,
+ createApprovalLine
+ } = await import('@/lib/knox-api/approval/approval');
+
+ // 결재선 생성
+ const aplns: ApprovalLine[] = [];
+
+ // 기안자 (현재 사용자)
+ if (approvalConfig.currentUser.epId) {
+ const drafterLine = await createApprovalLine(
+ {
+ epId: approvalConfig.currentUser.epId,
+ emailAddress: approvalConfig.currentUser.email,
+ },
+ '0', // 기안
+ '0' // seq
+ );
+ aplns.push(drafterLine);
+ }
+
+ // 결재자들
+ if (approvalConfig.approvers && approvalConfig.approvers.length > 0) {
+ for (let i = 0; i < approvalConfig.approvers.length; i++) {
+ const approverLine = await createApprovalLine(
+ { epId: approvalConfig.approvers[i] },
+ '1', // 승인
+ String(i + 1)
+ );
+ aplns.push(approverLine);
+ }
+ }
+
+ // 결재 요청 생성
+ const submitRequest = await createSubmitApprovalRequest(
+ content, // 치환된 템플릿 content
+ approvalConfig.title,
+ aplns,
+ {
+ contentsType: 'HTML', // HTML 템플릿 사용
+ }
+ );
+
+ // Knox 결재 상신
+ await submitApproval(
+ submitRequest,
+ {
+ userId: String(approvalConfig.currentUser.id),
+ epId: approvalConfig.currentUser.epId || '',
+ emailAddress: approvalConfig.currentUser.email || '',
+ }
+ );
+
+ // 3. Pending Action 생성 (approvalLog의 apInfId로 연결)
+ // Knox에서 apInfId를 반환하지 않고, 요청 시 생성한 apInfId를 사용
+ const [pendingAction] = await db.insert(pendingActions).values({
+ apInfId: submitRequest.apInfId,
+ actionType,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ actionPayload: actionPayload as any,
+ status: 'pending',
+ createdBy: approvalConfig.currentUser.id,
+ }).returning();
+
+ return {
+ pendingActionId: pendingAction.id,
+ approvalId: submitRequest.apInfId,
+ status: 'pending_approval',
+ };
+
+ } catch (error) {
+ console.error('Failed to create approval workflow:', error);
+ throw error;
+ }
+}
+
+/**
+ * 결재가 승인된 액션을 실행
+ * 폴링 서비스에서 호출됨
+ *
+ * @param apInfId - Knox 결재 ID (approvalLogs의 primary key)
+ * @returns 액션 실행 결과
+ */
+export async function executeApprovedAction(apInfId: string) {
+ try {
+ // 1. apInfId로 pendingAction 조회
+ const pendingAction = await db.query.pendingActions.findFirst({
+ where: eq(pendingActions.apInfId, apInfId),
+ });
+
+ if (!pendingAction) {
+ console.log(`[Approval Workflow] No pending action found for approval: ${apInfId}`);
+ return null; // 결재만 있고 실행할 액션이 없는 경우
+ }
+
+ // 이미 실행되었거나 실패한 액션은 스킵
+ if (['executed', 'failed'].includes(pendingAction.status)) {
+ console.log(`[Approval Workflow] Pending action already processed: ${apInfId} (${pendingAction.status})`);
+ return null;
+ }
+
+ // 2. 등록된 핸들러 조회
+ const handler = actionHandlers.get(pendingAction.actionType);
+ if (!handler) {
+ throw new Error(`Handler not found for action type: ${pendingAction.actionType}`);
+ }
+
+ // 3. 실제 액션 실행
+ console.log(`[Approval Workflow] Executing action: ${pendingAction.actionType} (${apInfId})`);
+ const result = await handler(pendingAction.actionPayload);
+
+ // 4. 실행 완료 상태 업데이트
+ await db.update(pendingActions)
+ .set({
+ status: 'executed',
+ executedAt: new Date(),
+ executionResult: result,
+ })
+ .where(eq(pendingActions.apInfId, apInfId));
+
+ console.log(`[Approval Workflow] ✅ Successfully executed: ${pendingAction.actionType} (${apInfId})`);
+ return result;
+
+ } catch (error) {
+ console.error(`[Approval Workflow] ❌ Failed to execute action for ${apInfId}:`, error);
+
+ // 실패 상태 업데이트
+ await db.update(pendingActions)
+ .set({
+ status: 'failed',
+ errorMessage: error instanceof Error ? error.message : String(error),
+ executedAt: new Date(),
+ })
+ .where(eq(pendingActions.apInfId, apInfId));
+
+ throw error;
+ }
+}
+
+/**
+ * 결재 반려 시 처리
+ *
+ * @param apInfId - Knox 결재 ID (approvalLogs의 primary key)
+ * @param reason - 반려 사유
+ */
+export async function handleRejectedAction(apInfId: string, reason?: string) {
+ try {
+ const pendingAction = await db.query.pendingActions.findFirst({
+ where: eq(pendingActions.apInfId, apInfId),
+ });
+
+ if (!pendingAction) {
+ console.log(`[Approval Workflow] No pending action found for rejected approval: ${apInfId}`);
+ return; // 결재만 있고 실행할 액션이 없는 경우
+ }
+
+ await db.update(pendingActions)
+ .set({
+ status: 'rejected',
+ errorMessage: reason,
+ executedAt: new Date(),
+ })
+ .where(eq(pendingActions.apInfId, apInfId));
+
+ // TODO: 요청자에게 알림 발송 등 추가 처리
+ } catch (error) {
+ console.error(`[Approval Workflow] Failed to handle rejected action for approval ${apInfId}:`, error);
+ throw error;
+ }
+}
+
diff --git a/lib/approval/example-usage.ts b/lib/approval/example-usage.ts
new file mode 100644
index 00000000..357f3ef1
--- /dev/null
+++ b/lib/approval/example-usage.ts
@@ -0,0 +1,187 @@
+/**
+ * 결재 워크플로우 사용 예시 (템플릿 기반)
+ *
+ * 이 파일은 실제 사용 방법을 보여주는 예시입니다.
+ * 실제 구현 시 각 서버 액션에 적용하면 됩니다.
+ *
+ * 업데이트: 템플릿 및 변수 치환 지원
+ */
+
+import { registerActionHandler, withApproval } from './approval-workflow';
+import {
+ htmlTableConverter,
+ htmlDescriptionList,
+} from './template-utils';
+
+// ============================================================================
+// 1단계: 액션 핸들러 등록
+// ============================================================================
+
+/**
+ * 실사 요청의 실제 비즈니스 로직
+ * 결재 승인 후 실행될 함수
+ */
+async function createInvestigationInternal(payload: {
+ vendorId: number;
+ vendorName: string;
+ reason: string;
+ scheduledDate: Date;
+}) {
+ // 실제 DB 작업 등 비즈니스 로직
+ console.log('Creating investigation:', payload);
+
+ // const result = await db.insert(vendorInvestigations).values({
+ // vendorId: payload.vendorId,
+ // reason: payload.reason,
+ // scheduledDate: payload.scheduledDate,
+ // status: 'scheduled',
+ // }).returning();
+
+ return { success: true, investigationId: 123 };
+}
+
+// 핸들러 등록 (앱 시작 시 한 번만 실행되면 됨)
+registerActionHandler('vendor_investigation_request', createInvestigationInternal);
+
+// ============================================================================
+// 2단계: 결재가 필요한 서버 액션 작성
+// ============================================================================
+
+/**
+ * 사용자가 호출하는 서버 액션
+ * 결재를 거쳐서 실사 요청을 생성 (템플릿 기반)
+ */
+export async function createInvestigationWithApproval(data: {
+ vendorId: number;
+ vendorName: string;
+ reason: string;
+ scheduledDate: Date;
+ currentUser: { id: number; epId: string | null };
+ approvers?: string[]; // Knox EP ID 배열
+}) {
+ 'use server'; // Next.js 15 server action
+
+ // 1. 템플릿 변수 생성 (HTML 변환 포함)
+ const summaryTable: string = await htmlTableConverter(
+ [
+ { 항목: '협력업체명', 내용: data.vendorName },
+ { 항목: '실사 사유', 내용: data.reason },
+ { 항목: '예정일', 내용: data.scheduledDate.toLocaleDateString('ko-KR') },
+ ],
+ [
+ { key: '항목', label: '항목' },
+ { key: '내용', label: '내용' },
+ ]
+ );
+
+ const basicInfo: string = await htmlDescriptionList([
+ { label: '협력업체명', value: data.vendorName },
+ { label: '실사 사유', value: data.reason },
+ ]);
+
+ const variables: Record<string, string> = {
+ 수신자이름: '결재자',
+ 협력업체명: data.vendorName,
+ '실사의뢰 요약 테이블 1': summaryTable,
+ '실사의뢰 기본정보': basicInfo,
+ '실사의뢰 요약 문구': `${data.vendorName}에 대한 실사를 요청합니다.`,
+ };
+
+ // 2. 결재 워크플로우 시작 (템플릿 사용)
+ const result = await withApproval(
+ 'vendor_investigation_request',
+ {
+ vendorId: data.vendorId,
+ vendorName: data.vendorName,
+ reason: data.reason,
+ scheduledDate: data.scheduledDate,
+ },
+ {
+ title: `실사 요청 - ${data.vendorName}`,
+ description: `협력업체 ${data.vendorName}에 대한 실사 요청`,
+ templateName: '협력업체 실사 요청', // 한국어 템플릿명
+ variables, // 치환할 변수들
+ approvers: data.approvers,
+ currentUser: data.currentUser,
+ }
+ );
+
+ return result;
+}
+
+// ============================================================================
+// 3단계: UI에서 호출
+// ============================================================================
+
+/**
+ * 클라이언트 컴포넌트에서 사용 예시
+ *
+ * ```tsx
+ * const handleSubmit = async (formData) => {
+ * try {
+ * const result = await createInvestigationWithApproval({
+ * vendorId: formData.vendorId,
+ * vendorName: formData.vendorName,
+ * reason: formData.reason,
+ * scheduledDate: new Date(formData.scheduledDate),
+ * currentUser: session.user,
+ * approvers: selectedApprovers, // 결재선
+ * });
+ *
+ * // 결재 상신 완료
+ * alert(`결재가 상신되었습니다. 결재 ID: ${result.approvalId}`);
+ *
+ * // 결재 현황 페이지로 이동하거나 대기 상태 표시
+ * router.push(`/approval/status/${result.approvalId}`);
+ * } catch (error) {
+ * console.error('Failed to submit approval:', error);
+ * }
+ * };
+ * ```
+ */
+
+// ============================================================================
+// 다른 액션 타입 예시
+// ============================================================================
+
+/**
+ * 발주 요청 핸들러
+ */
+async function createPurchaseOrderInternal(payload: {
+ vendorId: number;
+ items: Array<{ itemId: number; quantity: number }>;
+ totalAmount: number;
+}) {
+ console.log('Creating purchase order:', payload);
+ return { success: true, orderId: 456 };
+}
+
+registerActionHandler('purchase_order_request', createPurchaseOrderInternal);
+
+/**
+ * 계약 승인 핸들러
+ */
+async function approveContractInternal(payload: {
+ contractId: number;
+ approverComments: string;
+}) {
+ console.log('Approving contract:', payload);
+ return { success: true, contractId: payload.contractId };
+}
+
+registerActionHandler('contract_approval', approveContractInternal);
+
+// ============================================================================
+// 추가 유틸리티
+// ============================================================================
+
+/**
+ * 결재 상태를 수동으로 확인 (UI에서 "새로고침" 버튼용)
+ */
+export async function refreshApprovalStatus(apInfId: string) {
+ 'use server';
+
+ const { checkSingleApprovalStatus } = await import('./approval-polling-service');
+ return await checkSingleApprovalStatus(apInfId);
+}
+
diff --git a/lib/approval/handlers-registry.ts b/lib/approval/handlers-registry.ts
new file mode 100644
index 00000000..1e8140e5
--- /dev/null
+++ b/lib/approval/handlers-registry.ts
@@ -0,0 +1,49 @@
+/**
+ * 결재 액션 핸들러 중앙 등록소
+ *
+ * 모든 결재 가능한 액션의 핸들러를 한 곳에서 등록
+ * instrumentation.ts 또는 Next.js middleware에서 import하여 초기화
+ */
+
+import { registerActionHandler } from './approval-workflow';
+
+/**
+ * 모든 핸들러를 등록하는 초기화 함수
+ * 앱 시작 시 한 번만 호출
+ */
+export async function initializeApprovalHandlers() {
+ console.log('[Approval Handlers] Registering all handlers...');
+
+ // 1. PQ 실사 관련 핸들러
+ const {
+ requestPQInvestigationInternal,
+ reRequestPQInvestigationInternal
+ } = await import('@/lib/vendor-investigation/handlers');
+
+ // PQ 실사의뢰 핸들러 등록
+ registerActionHandler('pq_investigation_request', requestPQInvestigationInternal);
+
+ // PQ 실사 재의뢰 핸들러 등록
+ registerActionHandler('pq_investigation_rerequest', reRequestPQInvestigationInternal);
+
+ // 2. 발주 요청 핸들러
+ // const { createPurchaseOrderInternal } = await import('@/lib/purchase-order/handlers');
+ // registerActionHandler('purchase_order_request', createPurchaseOrderInternal);
+
+ // 3. 계약 승인 핸들러
+ // const { approveContractInternal } = await import('@/lib/contract/handlers');
+ // registerActionHandler('contract_approval', approveContractInternal);
+
+ // ... 추가 핸들러 등록
+
+ console.log('[Approval Handlers] All handlers registered successfully');
+}
+
+/**
+ * 등록된 모든 핸들러 목록 조회 (디버깅용)
+ */
+export async function listRegisteredHandlers() {
+ const { getRegisteredHandlers } = await import('./approval-workflow');
+ return await getRegisteredHandlers();
+}
+
diff --git a/lib/approval/index.ts b/lib/approval/index.ts
new file mode 100644
index 00000000..644c5fa8
--- /dev/null
+++ b/lib/approval/index.ts
@@ -0,0 +1,34 @@
+/**
+ * 결재 워크플로우 모듈 Export
+ *
+ * 사용 방법:
+ * 1. registerActionHandler()로 액션 핸들러 등록
+ * 2. withApproval()로 결재가 필요한 액션 래핑
+ * 3. 폴링 서비스가 자동으로 상태 확인 및 실행
+ */
+
+export {
+ registerActionHandler,
+ getRegisteredHandlers,
+ withApproval,
+ executeApprovedAction,
+ handleRejectedAction,
+ type ActionHandler,
+} from './approval-workflow';
+
+export {
+ startApprovalPollingScheduler,
+ checkPendingApprovals,
+ checkSingleApprovalStatus,
+} from './approval-polling-service';
+
+export {
+ getApprovalTemplateByName,
+ replaceTemplateVariables,
+ htmlTableConverter,
+ htmlListConverter,
+ htmlDescriptionList,
+} from './template-utils';
+
+export type { TemplateVariables, ApprovalConfig, ApprovalResult } from './types';
+
diff --git a/lib/approval/template-utils.ts b/lib/approval/template-utils.ts
new file mode 100644
index 00000000..a39f8ac4
--- /dev/null
+++ b/lib/approval/template-utils.ts
@@ -0,0 +1,215 @@
+/**
+ * 결재 템플릿 유틸리티 함수
+ *
+ * 기능:
+ * - 템플릿 이름으로 조회
+ * - 변수 치환 ({{변수명}})
+ * - HTML 변환 유틸리티 (테이블, 리스트)
+ */
+
+'use server';
+
+import db from '@/db/db';
+import { eq } from 'drizzle-orm';
+import { approvalTemplates } from '@/db/schema/knox/approvals';
+
+/**
+ * 템플릿 이름으로 조회
+ *
+ * @param name - 템플릿 이름 (한국어)
+ * @returns 템플릿 객체 또는 null
+ */
+export async function getApprovalTemplateByName(name: string) {
+ try {
+ const [template] = await db
+ .select()
+ .from(approvalTemplates)
+ .where(eq(approvalTemplates.name, name))
+ .limit(1);
+
+ return template || null;
+ } catch (error) {
+ console.error(`[Template Utils] Failed to get template: ${name}`, error);
+ return null;
+ }
+}
+
+/**
+ * 템플릿 변수 치환
+ *
+ * {{변수명}} 형태의 변수를 실제 값으로 치환
+ *
+ * @param content - 템플릿 HTML 내용
+ * @param variables - 변수 매핑 객체
+ * @returns 치환된 HTML
+ *
+ * @example
+ * ```typescript
+ * const content = "<p>{{이름}}님, 안녕하세요</p>";
+ * const variables = { "이름": "홍길동" };
+ * const result = await replaceTemplateVariables(content, variables);
+ * // "<p>홍길동님, 안녕하세요</p>"
+ * ```
+ */
+export async function replaceTemplateVariables(
+ content: string,
+ variables: Record<string, string>
+): Promise<string> {
+ let result = content;
+
+ Object.entries(variables).forEach(([key, value]) => {
+ // {{변수명}} 패턴을 전역으로 치환
+ const pattern = new RegExp(`\\{\\{${escapeRegex(key)}\\}\\}`, 'g');
+ result = result.replace(pattern, value);
+ });
+
+ // 치환되지 않은 변수 로그 (디버깅용)
+ const remainingVariables = result.match(/\{\{[^}]+\}\}/g);
+ if (remainingVariables && remainingVariables.length > 0) {
+ console.warn(
+ '[Template Utils] Unmatched variables found:',
+ remainingVariables
+ );
+ }
+
+ return result;
+}
+
+/**
+ * 정규식 특수문자 이스케이프
+ */
+function escapeRegex(str: string): string {
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+/**
+ * 배열 데이터를 HTML 테이블로 변환 (공통 유틸)
+ *
+ * @param data - 테이블 데이터 배열
+ * @param columns - 컬럼 정의 (키, 라벨)
+ * @returns HTML 테이블 문자열
+ *
+ * @example
+ * ```typescript
+ * const data = [
+ * { name: "홍길동", age: 30 },
+ * { name: "김철수", age: 25 }
+ * ];
+ * const columns = [
+ * { key: "name", label: "이름" },
+ * { key: "age", label: "나이" }
+ * ];
+ * const html = await htmlTableConverter(data, columns);
+ * ```
+ */
+export async function htmlTableConverter(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ data: Array<Record<string, any>>,
+ columns: Array<{ key: string; label: string }>
+): Promise<string> {
+ if (!data || data.length === 0) {
+ return '<p class="text-gray-500">데이터가 없습니다.</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>`
+ )
+ .join('');
+
+ const bodyRows = data
+ .map((row) => {
+ const cells = columns
+ .map((col) => {
+ 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>`;
+ })
+ .join('');
+ return `<tr>${cells}</tr>`;
+ })
+ .join('');
+
+ return `
+ <table class="w-full border-collapse border border-gray-300 my-4">
+ <thead>
+ <tr>${headerRow}</tr>
+ </thead>
+ <tbody>
+ ${bodyRows}
+ </tbody>
+ </table>
+ `;
+}
+
+/**
+ * 배열을 HTML 리스트로 변환
+ *
+ * @param items - 리스트 아이템 배열
+ * @param ordered - 순서 있는 리스트 여부 (기본: false)
+ * @returns HTML 리스트 문자열
+ *
+ * @example
+ * ```typescript
+ * const items = ["첫 번째", "두 번째", "세 번째"];
+ * const html = await htmlListConverter(items, true);
+ * ```
+ */
+export async function htmlListConverter(
+ items: string[],
+ ordered: boolean = false
+): Promise<string> {
+ if (!items || items.length === 0) {
+ return '<p class="text-gray-500">항목이 없습니다.</p>';
+ }
+
+ const listItems = items
+ .map((item) => `<li class="mb-1">${item}</li>`)
+ .join('');
+
+ const tag = ordered ? 'ol' : 'ul';
+ const listClass = ordered
+ ? 'list-decimal list-inside my-4'
+ : 'list-disc list-inside my-4';
+
+ return `<${tag} class="${listClass}">${listItems}</${tag}>`;
+}
+
+/**
+ * 키-값 쌍을 HTML 정의 목록으로 변환
+ *
+ * @param items - 키-값 쌍 배열
+ * @returns HTML dl 태그
+ *
+ * @example
+ * ```typescript
+ * const items = [
+ * { label: "협력업체명", value: "ABC 주식회사" },
+ * { label: "사업자등록번호", value: "123-45-67890" }
+ * ];
+ * const html = await htmlDescriptionList(items);
+ * ```
+ */
+export async function htmlDescriptionList(
+ items: Array<{ label: string; value: string }>
+): Promise<string> {
+ if (!items || items.length === 0) {
+ return '<p class="text-gray-500">정보가 없습니다.</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>
+ `
+ )
+ .join('');
+
+ return `<dl class="my-4">${listItems}</dl>`;
+}
+
diff --git a/lib/approval/types.ts b/lib/approval/types.ts
new file mode 100644
index 00000000..7f3fe52b
--- /dev/null
+++ b/lib/approval/types.ts
@@ -0,0 +1,25 @@
+/**
+ * 결재 워크플로우 타입 정의
+ */
+
+export type TemplateVariables = Record<string, string>;
+
+export interface ApprovalConfig {
+ title: string;
+ description?: string;
+ templateName: string;
+ variables: TemplateVariables;
+ approvers?: string[];
+ currentUser: {
+ id: number;
+ epId: string | null;
+ email?: string;
+ };
+}
+
+export interface ApprovalResult {
+ pendingActionId: number;
+ approvalId: string;
+ status: 'pending_approval';
+}
+
diff --git a/lib/pq/pq-review-table-new/cancel-investigation-dialog.tsx b/lib/pq/pq-review-table-new/cancel-investigation-dialog.tsx
index e135d8b8..e22b9ca1 100644
--- a/lib/pq/pq-review-table-new/cancel-investigation-dialog.tsx
+++ b/lib/pq/pq-review-table-new/cancel-investigation-dialog.tsx
@@ -11,6 +11,8 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
+import { Textarea } from "@/components/ui/textarea"
+import { Label } from "@/components/ui/label"
interface CancelInvestigationDialogProps {
isOpen: boolean
@@ -71,7 +73,7 @@ export function CancelInvestigationDialog({
interface ReRequestInvestigationDialogProps {
isOpen: boolean
onClose: () => void
- onConfirm: () => Promise<void>
+ onConfirm: (reason?: string) => Promise<void>
selectedCount: number
}
@@ -82,16 +84,24 @@ export function ReRequestInvestigationDialog({
selectedCount,
}: ReRequestInvestigationDialogProps) {
const [isPending, setIsPending] = React.useState(false)
+ const [reason, setReason] = React.useState("")
async function handleConfirm() {
setIsPending(true)
try {
- await onConfirm()
+ await onConfirm(reason || undefined)
} finally {
setIsPending(false)
}
}
+ // Dialog가 닫힐 때 상태 초기화
+ React.useEffect(() => {
+ if (!isOpen) {
+ setReason("")
+ }
+ }, [isOpen])
+
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent>
@@ -102,6 +112,21 @@ export function ReRequestInvestigationDialog({
취소 상태인 실사만 재의뢰할 수 있습니다.
</DialogDescription>
</DialogHeader>
+
+ <div className="space-y-4 py-4">
+ <div className="space-y-2">
+ <Label htmlFor="reason">재의뢰 사유 (선택)</Label>
+ <Textarea
+ id="reason"
+ placeholder="재의뢰 사유를 입력해주세요..."
+ value={reason}
+ onChange={(e) => setReason(e.target.value)}
+ rows={4}
+ disabled={isPending}
+ />
+ </div>
+ </div>
+
<DialogFooter>
<Button
type="button"
diff --git a/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx b/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx
index 95cdd4d1..a5185cab 100644
--- a/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx
+++ b/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx
@@ -4,20 +4,27 @@ import * as React from "react"
import { type Table } from "@tanstack/react-table"
import { Download, ClipboardCheck, X, Send, RefreshCw } from "lucide-react"
import { toast } from "sonner"
+import { useSession } from "next-auth/react"
import { exportTableToExcel } from "@/lib/export"
import { Button } from "@/components/ui/button"
import { PQSubmission } from "./vendors-table-columns"
import {
- requestInvestigationAction,
cancelInvestigationAction,
sendInvestigationResultsAction,
getFactoryLocationAnswer,
- reRequestInvestigationAction
+ getQMManagers
} from "@/lib/pq/service"
import { RequestInvestigationDialog } from "./request-investigation-dialog"
import { CancelInvestigationDialog, ReRequestInvestigationDialog } from "./cancel-investigation-dialog"
import { SendResultsDialog } from "./send-results-dialog"
+import { ApprovalPreviewDialog } from "@/components/approval/ApprovalPreviewDialog"
+import {
+ requestPQInvestigationWithApproval,
+ reRequestPQInvestigationWithApproval
+} from "@/lib/vendor-investigation/approval-actions"
+import type { ApprovalLineItem } from "@/components/knox/approval/ApprovalLineSelector"
+import { debugLog, debugError, debugSuccess } from "@/lib/debug-utils"
interface VendorsTableToolbarActionsProps {
table: Table<PQSubmission>
@@ -35,15 +42,38 @@ interface InvestigationInitialData {
export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActionsProps) {
const selectedRows = table.getFilteredSelectedRowModel().rows
const [isLoading, setIsLoading] = React.useState(false)
+ const { data: session } = useSession()
// Dialog 상태 관리
const [isRequestDialogOpen, setIsRequestDialogOpen] = React.useState(false)
const [isCancelDialogOpen, setIsCancelDialogOpen] = React.useState(false)
const [isSendResultsDialogOpen, setIsSendResultsDialogOpen] = React.useState(false)
const [isReRequestDialogOpen, setIsReRequestDialogOpen] = React.useState(false)
+ const [isApprovalDialogOpen, setIsApprovalDialogOpen] = React.useState(false)
+ const [isReRequestApprovalDialogOpen, setIsReRequestApprovalDialogOpen] = React.useState(false)
// 초기 데이터 상태
const [dialogInitialData, setDialogInitialData] = React.useState<InvestigationInitialData | undefined>(undefined)
+
+ // 실사 의뢰 임시 데이터 (결재 다이얼로그로 전달)
+ const [investigationFormData, setInvestigationFormData] = React.useState<{
+ qmManagerId: number;
+ qmManagerName: string;
+ qmManagerEmail?: string;
+ forecastedAt: Date;
+ investigationAddress: string;
+ investigationNotes?: string;
+ } | null>(null)
+
+ // 실사 재의뢰 임시 데이터
+ const [reRequestData, setReRequestData] = React.useState<{
+ investigationIds: number[];
+ vendorNames: string;
+ } | null>(null)
+
+ // 결재 템플릿 변수
+ const [approvalVariables, setApprovalVariables] = React.useState<Record<string, string>>({})
+ const [reRequestApprovalVariables, setReRequestApprovalVariables] = React.useState<Record<string, string>>({})
// 실사 의뢰 대화상자 열기 핸들러
// 실사 의뢰 대화상자 열기 핸들러
@@ -132,14 +162,13 @@ const handleOpenRequestDialog = async () => {
setIsRequestDialogOpen(true);
}
};
- // 실사 의뢰 요청 처리
+ // 실사 의뢰 요청 처리 - Step 1: RequestInvestigationDialog에서 정보 입력 후
const handleRequestInvestigation = async (formData: {
qmManagerId: number,
forecastedAt: Date,
investigationAddress: string,
investigationNotes?: string
}) => {
- setIsLoading(true)
try {
// 승인된 PQ 제출만 필터링 (미실사 PQ 제외)
const approvedPQs = selectedRows.filter(row =>
@@ -157,26 +186,119 @@ const handleOpenRequestDialog = async () => {
return
}
- // 서버 액션 호출
- const result = await requestInvestigationAction(
- approvedPQs.map(row => row.original.id),
- formData
- )
+ // QM 담당자 이름 및 이메일 조회
+ const qmManagersResult = await getQMManagers()
+ const qmManager = qmManagersResult.success
+ ? qmManagersResult.data.find(m => m.id === formData.qmManagerId)
+ : null
+ const qmManagerName = qmManager?.name || `QM담당자 #${formData.qmManagerId}`
+ const qmManagerEmail = qmManager?.email || undefined
+
+ // 협력사 이름 목록 생성
+ const vendorNames = approvedPQs
+ .map(row => row.original.vendorName)
+ .join(', ')
+
+ // 실사 폼 데이터 저장 (이메일 추가)
+ setInvestigationFormData({
+ qmManagerId: formData.qmManagerId,
+ qmManagerName,
+ qmManagerEmail,
+ forecastedAt: formData.forecastedAt,
+ investigationAddress: formData.investigationAddress,
+ investigationNotes: formData.investigationNotes,
+ })
- if (result.success) {
+ // 결재 템플릿 변수 생성
+ const requestedAt = new Date()
+ const { mapPQInvestigationToTemplateVariables } = await import('@/lib/vendor-investigation/handlers')
+ const variables = await mapPQInvestigationToTemplateVariables({
+ vendorNames,
+ qmManagerName,
+ qmManagerEmail,
+ forecastedAt: formData.forecastedAt,
+ investigationAddress: formData.investigationAddress,
+ investigationNotes: formData.investigationNotes,
+ requestedAt,
+ })
- toast.success(`${result.count}개 업체에 대해 실사가 의뢰되었습니다.`)
- window.location.reload()
- } else {
- toast.error(result.error || "실사 의뢰 처리 중 오류가 발생했습니다.")
- }
- } catch (error) {
- console.error("실사 의뢰 중 오류 발생:", error)
- toast.error("실사 의뢰 중 오류가 발생했습니다.")
- } finally {
- setIsLoading(false)
+ setApprovalVariables(variables)
+
+ // RequestInvestigationDialog 닫고 ApprovalPreviewDialog 열기
setIsRequestDialogOpen(false)
- setDialogInitialData(undefined); // 초기 데이터 초기화
+ setIsApprovalDialogOpen(true)
+ } catch (error) {
+ console.error("결재 준비 중 오류 발생:", error)
+ toast.error("결재 준비 중 오류가 발생했습니다.")
+ }
+ }
+
+ // 실사 의뢰 결재 요청 처리 - Step 2: ApprovalPreviewDialog에서 결재선 선택 후
+ const handleApprovalSubmit = async (approvers: ApprovalLineItem[]) => {
+ debugLog('[InvestigationApproval] 실사 의뢰 결재 요청 시작', {
+ approversCount: approvers.length,
+ hasSession: !!session?.user,
+ hasFormData: !!investigationFormData,
+ });
+
+ if (!session?.user || !investigationFormData) {
+ debugError('[InvestigationApproval] 세션 또는 폼 데이터 없음');
+ throw new Error('세션 정보가 없습니다.');
+ }
+
+ // 승인된 PQ 제출만 필터링
+ const approvedPQs = selectedRows.filter(row =>
+ row.original.status === "APPROVED" &&
+ !row.original.investigation &&
+ row.original.type !== "NON_INSPECTION"
+ )
+
+ debugLog('[InvestigationApproval] 승인된 PQ 건수', {
+ count: approvedPQs.length,
+ });
+
+ // 협력사 이름 목록
+ const vendorNames = approvedPQs
+ .map(row => row.original.vendorName)
+ .join(', ')
+
+ // 결재선에서 EP ID 추출 (상신자 제외)
+ const approverEpIds = approvers
+ .filter((line) => line.seq !== "0" && line.epId)
+ .map((line) => line.epId!)
+
+ debugLog('[InvestigationApproval] 결재선 추출 완료', {
+ approverEpIds,
+ });
+
+ // 결재 워크플로우 시작
+ const result = await requestPQInvestigationWithApproval({
+ pqSubmissionIds: approvedPQs.map(row => row.original.id),
+ vendorNames,
+ qmManagerId: investigationFormData.qmManagerId,
+ qmManagerName: investigationFormData.qmManagerName,
+ qmManagerEmail: investigationFormData.qmManagerEmail,
+ forecastedAt: investigationFormData.forecastedAt,
+ investigationAddress: investigationFormData.investigationAddress,
+ investigationNotes: investigationFormData.investigationNotes,
+ currentUser: {
+ id: Number(session.user.id),
+ epId: session.user.epId || null,
+ email: session.user.email || undefined,
+ },
+ approvers: approverEpIds,
+ })
+
+ debugSuccess('[InvestigationApproval] 결재 요청 성공', {
+ approvalId: result.approvalId,
+ pendingActionId: result.pendingActionId,
+ });
+
+ if (result.status === 'pending_approval') {
+ // 성공 시에만 상태 초기화 및 페이지 리로드
+ setInvestigationFormData(null)
+ setDialogInitialData(undefined)
+ window.location.reload()
}
}
@@ -221,9 +343,8 @@ const handleOpenRequestDialog = async () => {
}
}
- // 실사 재의뢰 처리
- const handleReRequestInvestigation = async () => {
- setIsLoading(true)
+ // 실사 재의뢰 처리 - Step 1: 확인 다이얼로그에서 확인 후
+ const handleReRequestInvestigation = async (reason?: string) => {
try {
// 취소된 실사만 필터링
const canceledInvestigations = selectedRows.filter(row =>
@@ -236,23 +357,87 @@ const handleOpenRequestDialog = async () => {
return
}
- // 서버 액션 호출
- const result = await reRequestInvestigationAction(
- canceledInvestigations.map(row => row.original.investigation!.id)
- )
+ // 협력사 이름 목록 생성
+ const vendorNames = canceledInvestigations
+ .map(row => row.original.vendorName)
+ .join(', ')
- if (result.success) {
- toast.success(`${result.count}개 업체에 대한 실사가 재의뢰되었습니다.`)
- window.location.reload()
- } else {
- toast.error(result.error || "실사 재의뢰 처리 중 오류가 발생했습니다.")
- }
- } catch (error) {
- console.error("실사 재의뢰 중 오류 발생:", error)
- toast.error("실사 재의뢰 중 오류가 발생했습니다.")
- } finally {
- setIsLoading(false)
+ // 재의뢰 데이터 저장
+ const investigationIds = canceledInvestigations.map(row => row.original.investigation!.id)
+ setReRequestData({
+ investigationIds,
+ vendorNames,
+ })
+
+ // 결재 템플릿 변수 생성
+ const reRequestedAt = new Date()
+ const { mapPQReRequestToTemplateVariables } = await import('@/lib/vendor-investigation/handlers')
+ const variables = await mapPQReRequestToTemplateVariables({
+ vendorNames,
+ investigationCount: investigationIds.length,
+ reRequestedAt,
+ reason,
+ })
+
+ setReRequestApprovalVariables(variables)
+
+ // ReRequestInvestigationDialog 닫고 ApprovalPreviewDialog 열기
setIsReRequestDialogOpen(false)
+ setIsReRequestApprovalDialogOpen(true)
+ } catch (error) {
+ console.error("재의뢰 결재 준비 중 오류 발생:", error)
+ toast.error("재의뢰 결재 준비 중 오류가 발생했습니다.")
+ }
+ }
+
+ // 실사 재의뢰 결재 요청 처리 - Step 2: ApprovalPreviewDialog에서 결재선 선택 후
+ const handleReRequestApprovalSubmit = async (approvers: ApprovalLineItem[]) => {
+ debugLog('[ReRequestApproval] 실사 재의뢰 결재 요청 시작', {
+ approversCount: approvers.length,
+ hasSession: !!session?.user,
+ hasReRequestData: !!reRequestData,
+ });
+
+ if (!session?.user || !reRequestData) {
+ debugError('[ReRequestApproval] 세션 또는 재의뢰 데이터 없음');
+ throw new Error('세션 정보가 없습니다.');
+ }
+
+ debugLog('[ReRequestApproval] 재의뢰 대상', {
+ investigationIds: reRequestData.investigationIds,
+ vendorNames: reRequestData.vendorNames,
+ });
+
+ // 결재선에서 EP ID 추출 (상신자 제외)
+ const approverEpIds = approvers
+ .filter((line) => line.seq !== "0" && line.epId)
+ .map((line) => line.epId!)
+
+ debugLog('[ReRequestApproval] 결재선 추출 완료', {
+ approverEpIds,
+ });
+
+ // 결재 워크플로우 시작
+ const result = await reRequestPQInvestigationWithApproval({
+ investigationIds: reRequestData.investigationIds,
+ vendorNames: reRequestData.vendorNames,
+ currentUser: {
+ id: Number(session.user.id),
+ epId: session.user.epId || null,
+ email: session.user.email || undefined,
+ },
+ approvers: approverEpIds,
+ })
+
+ debugSuccess('[ReRequestApproval] 재의뢰 결재 요청 성공', {
+ approvalId: result.approvalId,
+ pendingActionId: result.pendingActionId,
+ });
+
+ if (result.status === 'pending_approval') {
+ // 성공 시에만 상태 초기화 및 페이지 리로드
+ setReRequestData(null)
+ window.location.reload()
}
}
@@ -521,6 +706,60 @@ const handleOpenRequestDialog = async () => {
selectedCount={completedInvestigationsCount}
auditResults={auditResults}
/>
+
+ {/* 결재 미리보기 Dialog - 실사 의뢰 */}
+ {session?.user && investigationFormData && (
+ <ApprovalPreviewDialog
+ open={isApprovalDialogOpen}
+ onOpenChange={(open) => {
+ setIsApprovalDialogOpen(open)
+ if (!open) {
+ // 다이얼로그가 닫히면 실사 폼 데이터도 초기화
+ setInvestigationFormData(null)
+ }
+ }}
+ templateName="Vendor 실사의뢰"
+ variables={approvalVariables}
+ title={`Vendor 실사의뢰 - ${selectedRows.filter(row =>
+ row.original.status === "APPROVED" &&
+ !row.original.investigation &&
+ row.original.type !== "NON_INSPECTION"
+ ).map(row => row.original.vendorName).join(', ')}`}
+ description={`${approvedPQsCount}개 업체에 대한 실사 의뢰`}
+ currentUser={{
+ id: Number(session.user.id),
+ epId: session.user.epId || null,
+ name: session.user.name || null,
+ email: session.user.email || '',
+ }}
+ onSubmit={handleApprovalSubmit}
+ />
+ )}
+
+ {/* 결재 미리보기 Dialog - 실사 재의뢰 */}
+ {session?.user && reRequestData && (
+ <ApprovalPreviewDialog
+ open={isReRequestApprovalDialogOpen}
+ onOpenChange={(open) => {
+ setIsReRequestApprovalDialogOpen(open)
+ if (!open) {
+ // 다이얼로그가 닫히면 재의뢰 데이터도 초기화
+ setReRequestData(null)
+ }
+ }}
+ templateName="Vendor 실사 재의뢰"
+ variables={reRequestApprovalVariables}
+ title={`Vendor 실사 재의뢰 - ${reRequestData.vendorNames}`}
+ description={`${reRequestData.investigationIds.length}개 업체에 대한 실사 재의뢰`}
+ currentUser={{
+ id: Number(session.user.id),
+ epId: session.user.epId || null,
+ name: session.user.name || null,
+ email: session.user.email || '',
+ }}
+ onSubmit={handleReRequestApprovalSubmit}
+ />
+ )}
</>
)
} \ No newline at end of file
diff --git a/lib/vendor-investigation/approval-actions.ts b/lib/vendor-investigation/approval-actions.ts
new file mode 100644
index 00000000..a75b9b70
--- /dev/null
+++ b/lib/vendor-investigation/approval-actions.ts
@@ -0,0 +1,248 @@
+/**
+ * PQ 실사 관련 결재 서버 액션
+ *
+ * 사용자가 UI에서 호출하는 함수들
+ * withApproval()을 사용하여 결재 프로세스를 시작
+ */
+
+'use server';
+
+import { withApproval } from '@/lib/approval/approval-workflow';
+import { mapPQInvestigationToTemplateVariables } from './handlers';
+import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils';
+
+/**
+ * 결재를 거쳐 PQ 실사 의뢰를 생성하는 서버 액션
+ *
+ * 사용법 (클라이언트 컴포넌트에서):
+ * ```typescript
+ * const result = await requestPQInvestigationWithApproval({
+ * pqSubmissionIds: [1, 2, 3],
+ * vendorNames: "협력사A, 협력사B",
+ * qmManagerId: 123,
+ * qmManagerName: "홍길동",
+ * forecastedAt: new Date('2025-11-01'),
+ * investigationAddress: "경기도 ...",
+ * investigationNotes: "...",
+ * currentUser: { id: 1, epId: 'EP001', email: 'user@example.com' },
+ * approvers: ['EP002', 'EP003']
+ * });
+ *
+ * if (result.status === 'pending_approval') {
+ * console.log('결재 ID:', result.approvalId);
+ * }
+ * ```
+ */
+export async function requestPQInvestigationWithApproval(data: {
+ pqSubmissionIds: number[];
+ vendorNames: string; // 여러 업체명 (쉼표로 구분)
+ qmManagerId: number;
+ qmManagerName: string;
+ qmManagerEmail?: string;
+ forecastedAt: Date;
+ investigationAddress: string;
+ investigationNotes?: string;
+ currentUser: { id: number; epId: string | null; email?: string };
+ approvers?: string[]; // Knox EP ID 배열 (결재선)
+}) {
+ debugLog('[PQInvestigationApproval] 실사 의뢰 결재 서버 액션 시작', {
+ pqCount: data.pqSubmissionIds.length,
+ vendorNames: data.vendorNames,
+ qmManagerId: data.qmManagerId,
+ qmManagerName: data.qmManagerName,
+ hasQmEmail: !!data.qmManagerEmail,
+ userId: data.currentUser.id,
+ hasEpId: !!data.currentUser.epId,
+ });
+
+ // 입력 검증
+ if (!data.currentUser.epId) {
+ debugError('[PQInvestigationApproval] Knox EP ID 없음');
+ throw new Error('Knox EP ID가 필요합니다');
+ }
+
+ if (data.pqSubmissionIds.length === 0) {
+ debugError('[PQInvestigationApproval] PQ 제출 ID 없음');
+ throw new Error('실사 의뢰할 PQ를 선택해주세요');
+ }
+
+ // 1. 템플릿 변수 매핑
+ debugLog('[PQInvestigationApproval] 템플릿 변수 매핑 시작');
+ const requestedAt = new Date();
+ const variables = await mapPQInvestigationToTemplateVariables({
+ vendorNames: data.vendorNames,
+ qmManagerName: data.qmManagerName,
+ qmManagerEmail: data.qmManagerEmail,
+ forecastedAt: data.forecastedAt,
+ investigationAddress: data.investigationAddress,
+ investigationNotes: data.investigationNotes,
+ requestedAt,
+ });
+ debugLog('[PQInvestigationApproval] 템플릿 변수 매핑 완료', {
+ variableKeys: Object.keys(variables),
+ });
+
+ // 2. 결재 워크플로우 시작 (템플릿 기반)
+ debugLog('[PQInvestigationApproval] withApproval 호출');
+ const result = await withApproval(
+ // actionType: 핸들러를 찾을 때 사용할 키
+ 'pq_investigation_request',
+
+ // actionPayload: 결재 승인 후 핸들러에 전달될 데이터
+ {
+ pqSubmissionIds: data.pqSubmissionIds,
+ qmManagerId: data.qmManagerId,
+ qmManagerName: data.qmManagerName,
+ forecastedAt: data.forecastedAt,
+ investigationAddress: data.investigationAddress,
+ investigationNotes: data.investigationNotes,
+ vendorNames: data.vendorNames,
+ },
+
+ // approvalConfig: 결재 상신 정보 (템플릿 포함)
+ {
+ title: `Vendor 실사의뢰 - ${data.vendorNames}`,
+ description: `${data.vendorNames}에 대한 실사 의뢰`,
+ templateName: 'Vendor 실사의뢰', // 한국어 템플릿명
+ variables, // 치환할 변수들
+ approvers: data.approvers,
+ currentUser: data.currentUser,
+ }
+ );
+
+ debugSuccess('[PQInvestigationApproval] 결재 워크플로우 완료', {
+ approvalId: result.approvalId,
+ pendingActionId: result.pendingActionId,
+ status: result.status,
+ });
+
+ return result;
+}
+
+/**
+ * 결재를 거쳐 PQ 실사 재의뢰를 처리하는 서버 액션
+ *
+ * 사용법 (클라이언트 컴포넌트에서):
+ * ```typescript
+ * const result = await reRequestPQInvestigationWithApproval({
+ * investigationIds: [1, 2, 3],
+ * vendorNames: "협력사A, 협력사B",
+ * currentUser: { id: 1, epId: 'EP001', email: 'user@example.com' },
+ * approvers: ['EP002', 'EP003'],
+ * reason: '재의뢰 사유...'
+ * });
+ *
+ * if (result.status === 'pending_approval') {
+ * console.log('결재 ID:', result.approvalId);
+ * }
+ * ```
+ */
+export async function reRequestPQInvestigationWithApproval(data: {
+ investigationIds: number[];
+ vendorNames: string; // 여러 업체명 (쉼표로 구분)
+ currentUser: { id: number; epId: string | null; email?: string };
+ approvers?: string[]; // Knox EP ID 배열 (결재선)
+ reason?: string; // 재의뢰 사유
+}) {
+ debugLog('[PQReRequestApproval] 실사 재의뢰 결재 서버 액션 시작', {
+ investigationCount: data.investigationIds.length,
+ vendorNames: data.vendorNames,
+ userId: data.currentUser.id,
+ hasEpId: !!data.currentUser.epId,
+ hasReason: !!data.reason,
+ });
+
+ // 입력 검증
+ if (!data.currentUser.epId) {
+ debugError('[PQReRequestApproval] Knox EP ID 없음');
+ throw new Error('Knox EP ID가 필요합니다');
+ }
+
+ if (data.investigationIds.length === 0) {
+ debugError('[PQReRequestApproval] 실사 ID 없음');
+ throw new Error('재의뢰할 실사를 선택해주세요');
+ }
+
+ // 1. 기존 실사 정보 조회 (첫 번째 실사 기준)
+ debugLog('[PQReRequestApproval] 기존 실사 정보 조회');
+ const { default: db } = await import('@/db/db');
+ const { vendorInvestigations, users } = await import('@/db/schema');
+ const { eq } = await import('drizzle-orm');
+
+ const results = await db
+ .select({
+ id: vendorInvestigations.id,
+ forecastedAt: vendorInvestigations.forecastedAt,
+ investigationAddress: vendorInvestigations.investigationAddress,
+ qmManagerId: vendorInvestigations.qmManagerId,
+ qmManagerName: users.name,
+ qmManagerEmail: users.email,
+ })
+ .from(vendorInvestigations)
+ .leftJoin(users, eq(vendorInvestigations.qmManagerId, users.id))
+ .where(eq(vendorInvestigations.id, data.investigationIds[0]))
+ .limit(1);
+
+ const existingInvestigation = results[0];
+
+ if (!existingInvestigation) {
+ debugError('[PQReRequestApproval] 기존 실사 정보를 찾을 수 없음');
+ throw new Error('기존 실사 정보를 찾을 수 없습니다.');
+ }
+
+ debugLog('[PQReRequestApproval] 기존 실사 정보 조회 완료', {
+ investigationId: existingInvestigation.id,
+ qmManagerName: existingInvestigation.qmManagerName,
+ forecastedAt: existingInvestigation.forecastedAt,
+ });
+
+ // 2. 템플릿 변수 매핑
+ debugLog('[PQReRequestApproval] 템플릿 변수 매핑 시작');
+ const reRequestedAt = new Date();
+ const { mapPQReRequestToTemplateVariables } = await import('./handlers');
+ const variables = await mapPQReRequestToTemplateVariables({
+ vendorNames: data.vendorNames,
+ investigationCount: data.investigationIds.length,
+ reRequestedAt,
+ reason: data.reason,
+ // 기존 실사 정보 전달
+ forecastedAt: existingInvestigation.forecastedAt || undefined,
+ investigationAddress: existingInvestigation.investigationAddress || undefined,
+ qmManagerName: existingInvestigation.qmManagerName || undefined,
+ qmManagerEmail: existingInvestigation.qmManagerEmail || undefined,
+ });
+ debugLog('[PQReRequestApproval] 템플릿 변수 매핑 완료', {
+ variableKeys: Object.keys(variables),
+ });
+
+ // 2. 결재 워크플로우 시작 (템플릿 기반)
+ debugLog('[PQReRequestApproval] withApproval 호출');
+ const result = await withApproval(
+ // actionType: 핸들러를 찾을 때 사용할 키
+ 'pq_investigation_rerequest',
+
+ // actionPayload: 결재 승인 후 핸들러에 전달될 데이터
+ {
+ investigationIds: data.investigationIds,
+ vendorNames: data.vendorNames,
+ },
+
+ // approvalConfig: 결재 상신 정보 (템플릿 포함)
+ {
+ title: `Vendor 실사 재의뢰 - ${data.vendorNames}`,
+ description: `${data.vendorNames}에 대한 실사 재의뢰`,
+ templateName: 'Vendor 실사 재의뢰', // 한국어 템플릿명
+ variables, // 치환할 변수들
+ approvers: data.approvers,
+ currentUser: data.currentUser,
+ }
+ );
+
+ debugSuccess('[PQReRequestApproval] 재의뢰 결재 워크플로우 완료', {
+ approvalId: result.approvalId,
+ pendingActionId: result.pendingActionId,
+ status: result.status,
+ });
+
+ return result;
+}
diff --git a/lib/vendor-investigation/handlers.ts b/lib/vendor-investigation/handlers.ts
new file mode 100644
index 00000000..6c0edbd7
--- /dev/null
+++ b/lib/vendor-investigation/handlers.ts
@@ -0,0 +1,187 @@
+/**
+ * PQ 실사 관련 결재 액션 핸들러
+ *
+ * 실제 비즈니스 로직만 포함 (결재 로직은 approval-workflow에서 처리)
+ */
+
+'use server';
+
+import { requestInvestigationAction } from '@/lib/pq/service';
+import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils';
+
+/**
+ * PQ 실사 의뢰 핸들러 (결재 승인 후 실행됨)
+ *
+ * 이 함수는 직접 호출하지 않고, 결재 워크플로우에서 자동으로 호출됨
+ *
+ * @param payload - withApproval()에서 전달한 actionPayload
+ */
+export async function requestPQInvestigationInternal(payload: {
+ pqSubmissionIds: number[];
+ qmManagerId: number;
+ qmManagerName?: string;
+ forecastedAt: Date;
+ investigationAddress: string;
+ investigationNotes?: string;
+ vendorNames?: string; // 복수 업체 이름 (표시용)
+}) {
+ debugLog('[PQInvestigationHandler] 실사 의뢰 핸들러 시작', {
+ pqCount: payload.pqSubmissionIds.length,
+ qmManagerId: payload.qmManagerId,
+ vendorNames: payload.vendorNames,
+ });
+
+ try {
+ // 실제 실사 의뢰 처리
+ debugLog('[PQInvestigationHandler] requestInvestigationAction 호출');
+ const result = await requestInvestigationAction(
+ payload.pqSubmissionIds,
+ {
+ qmManagerId: payload.qmManagerId,
+ forecastedAt: payload.forecastedAt,
+ investigationAddress: payload.investigationAddress,
+ investigationNotes: payload.investigationNotes,
+ }
+ );
+
+ if (!result.success) {
+ debugError('[PQInvestigationHandler] 실사 의뢰 실패', result.error);
+ throw new Error(result.error || '실사 의뢰에 실패했습니다.');
+ }
+
+ debugSuccess('[PQInvestigationHandler] 실사 의뢰 완료', {
+ count: result.count,
+ });
+
+ return {
+ success: true,
+ count: result.count,
+ message: `${result.count}개 업체에 대해 실사가 의뢰되었습니다.`,
+ };
+ } catch (error) {
+ debugError('[PQInvestigationHandler] 실사 의뢰 중 에러', error);
+ throw error;
+ }
+}
+
+/**
+ * PQ 실사 의뢰 데이터를 결재 템플릿 변수로 매핑
+ *
+ * @param payload - 실사 의뢰 데이터
+ * @returns 템플릿 변수 객체 (Record<string, string>)
+ */
+export async function mapPQInvestigationToTemplateVariables(payload: {
+ vendorNames: string; // 여러 업체명 (쉼표로 구분)
+ qmManagerName: string;
+ qmManagerEmail?: string;
+ forecastedAt: Date;
+ investigationAddress: string;
+ investigationNotes?: string;
+ requestedAt: Date;
+}): Promise<Record<string, string>> {
+ // 담당자 연락처 (QM담당자 이메일)
+ const contactInfo = payload.qmManagerEmail
+ ? `<p>${payload.qmManagerName}: ${payload.qmManagerEmail}</p>`
+ : `<p>${payload.qmManagerName}</p>`;
+
+ // 실사 사유/목적 (있으면 포함, 없으면 빈 문자열)
+ const investigationPurpose = payload.investigationNotes || '';
+
+ return {
+ 협력사명: payload.vendorNames,
+ 실사요청일: new Date(payload.requestedAt).toLocaleDateString('ko-KR'),
+ 실사예정일: new Date(payload.forecastedAt).toLocaleDateString('ko-KR'),
+ 실사장소: payload.investigationAddress,
+ QM담당자: payload.qmManagerName,
+ 담당자연락처: contactInfo,
+ 실사사유목적: investigationPurpose,
+ };
+}
+
+/**
+ * PQ 실사 재의뢰 핸들러 (결재 승인 후 실행됨)
+ *
+ * 이 함수는 직접 호출하지 않고, 결재 워크플로우에서 자동으로 호출됨
+ *
+ * @param payload - withApproval()에서 전달한 actionPayload
+ */
+export async function reRequestPQInvestigationInternal(payload: {
+ investigationIds: number[];
+ vendorNames?: string; // 복수 업체 이름 (표시용)
+}) {
+ debugLog('[PQReRequestHandler] 실사 재의뢰 핸들러 시작', {
+ investigationCount: payload.investigationIds.length,
+ vendorNames: payload.vendorNames,
+ });
+
+ try {
+ // 실제 실사 재의뢰 처리
+ const { reRequestInvestigationAction } = await import('@/lib/pq/service');
+ debugLog('[PQReRequestHandler] reRequestInvestigationAction 호출');
+
+ const result = await reRequestInvestigationAction(payload.investigationIds);
+
+ if (!result.success) {
+ debugError('[PQReRequestHandler] 실사 재의뢰 실패', result.error);
+ throw new Error(result.error || '실사 재의뢰에 실패했습니다.');
+ }
+
+ debugSuccess('[PQReRequestHandler] 실사 재의뢰 완료', {
+ count: result.count,
+ });
+
+ return {
+ success: true,
+ count: result.count,
+ message: `${result.count}개 업체에 대해 실사가 재의뢰되었습니다.`,
+ };
+ } catch (error) {
+ debugError('[PQReRequestHandler] 실사 재의뢰 중 에러', error);
+ throw error;
+ }
+}
+
+/**
+ * PQ 실사 재의뢰 데이터를 결재 템플릿 변수로 매핑
+ *
+ * @param payload - 실사 재의뢰 데이터
+ * @returns 템플릿 변수 객체 (Record<string, string>)
+ */
+export async function mapPQReRequestToTemplateVariables(payload: {
+ vendorNames: string; // 여러 업체명 (쉼표로 구분)
+ investigationCount: number;
+ canceledDate?: Date;
+ reRequestedAt: Date;
+ reason?: string;
+ // 기존 실사 정보 (재의뢰 시 필요)
+ forecastedAt?: Date;
+ investigationAddress?: string;
+ qmManagerName?: string;
+ qmManagerEmail?: string;
+}): Promise<Record<string, string>> {
+ // 실사요청일은 재의뢰 요청일로 설정
+ const requestDate = new Date(payload.reRequestedAt).toLocaleDateString('ko-KR');
+
+ // 실사예정일 (기존 실사 정보 사용, 없으면 빈 문자열)
+ const forecastedDate = payload.forecastedAt
+ ? new Date(payload.forecastedAt).toLocaleDateString('ko-KR')
+ : '';
+
+ // 담당자 연락처 (QM담당자 이메일, 없으면 빈 문자열)
+ const contactInfo = payload.qmManagerEmail
+ ? `<p>${payload.qmManagerName || 'QM담당자'}: ${payload.qmManagerEmail}</p>`
+ : '<p>담당자 정보 없음</p>';
+
+ // 실사 사유/목적 (재의뢰 사유, 있으면 포함, 없으면 빈 문자열)
+ const investigationPurpose = payload.reason || '';
+
+ return {
+ 협력사명: payload.vendorNames,
+ 실사요청일: requestDate,
+ 실사예정일: forecastedDate,
+ 실사장소: payload.investigationAddress || '',
+ QM담당자: payload.qmManagerName || '',
+ 담당자연락처: contactInfo,
+ 실사사유목적: investigationPurpose,
+ };
+}
diff --git a/package.json b/package.json
index 391c89bd..a50805e9 100644
--- a/package.json
+++ b/package.json
@@ -106,6 +106,8 @@
"@tiptap/extension-underline": "^2.23.1",
"@tiptap/react": "^2.23.1",
"@tiptap/starter-kit": "^2.23.1",
+ "@toast-ui/editor": "^3.2.2",
+ "@toast-ui/react-editor": "^3.2.3",
"@types/docusign-esign": "^5.19.8",
"@types/formidable": "^3.4.5",
"accept-language": "^3.0.20",
@@ -200,6 +202,9 @@
"typescript": "^5.7.2"
},
"overrides": {
- "rimraf": "3.0.2"
+ "rimraf": "3.0.2",
+ "@toast-ui/react-editor": {
+ "react": "^18.3.1"
+ }
}
}