diff options
| author | joonhoekim <26rote@gmail.com> | 2025-10-23 18:13:41 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-10-23 18:13:41 +0900 |
| commit | 78c471eec35182959e0029ded18f144974ccaca2 (patch) | |
| tree | 914cdf1c8f406ca3e2aa639b8bb774f7f4e87023 /lib | |
| parent | 0be8940580c4a4a4e098b649d198160f9b60420c (diff) | |
(김준회) 결재 템플릿 에디터 및 결재 워크플로 공통함수 작성, 실사의뢰 결재 연결 예시 작성
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/approval-template/editor/approval-template-editor.tsx | 146 | ||||
| -rw-r--r-- | lib/approval/approval-polling-service.ts | 307 | ||||
| -rw-r--r-- | lib/approval/approval-workflow.ts | 272 | ||||
| -rw-r--r-- | lib/approval/example-usage.ts | 187 | ||||
| -rw-r--r-- | lib/approval/handlers-registry.ts | 49 | ||||
| -rw-r--r-- | lib/approval/index.ts | 34 | ||||
| -rw-r--r-- | lib/approval/template-utils.ts | 215 | ||||
| -rw-r--r-- | lib/approval/types.ts | 25 | ||||
| -rw-r--r-- | lib/pq/pq-review-table-new/cancel-investigation-dialog.tsx | 29 | ||||
| -rw-r--r-- | lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx | 317 | ||||
| -rw-r--r-- | lib/vendor-investigation/approval-actions.ts | 248 | ||||
| -rw-r--r-- | lib/vendor-investigation/handlers.ts | 187 |
12 files changed, 1954 insertions, 62 deletions
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, + }; +} |
