From 18ca4ad784aeeab9ab7a13bbc8b3c13b42ca5e49 Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Fri, 7 Nov 2025 12:01:16 +0900 Subject: (김준회) 결재 미리보기 공통컴포넌트 중복 제거, 기존 코드의 미리보기 호출부 수정, 템플릿 작성 가이드 간략히 추가, 결재 미리보기시 첨부파일 편집 처리 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/approval/ApprovalPreviewDialog.tsx | 213 --- lib/approval/README.md | 478 ++++- lib/approval/approval-preview-dialog.tsx | 156 ++ lib/approval/template-utils.ts | 23 +- lib/approval/templates/README.md | 4 + ...\205\354\262\264 \353\223\261\353\241\235.html" | 877 ++++++++++ .../vendors-table-toolbar-actions.tsx | 1819 ++++++++++---------- lib/vendor-regular-registrations/handlers.ts | 151 +- ...regular-registrations-table-toolbar-actions.tsx | 663 ++++--- 9 files changed, 2810 insertions(+), 1574 deletions(-) delete mode 100644 components/approval/ApprovalPreviewDialog.tsx create mode 100644 lib/approval/templates/README.md create mode 100644 "lib/approval/templates/\354\240\225\352\267\234\354\227\205\354\262\264 \353\223\261\353\241\235.html" diff --git a/components/approval/ApprovalPreviewDialog.tsx b/components/approval/ApprovalPreviewDialog.tsx deleted file mode 100644 index 7b5ff615..00000000 --- a/components/approval/ApprovalPreviewDialog.tsx +++ /dev/null @@ -1,213 +0,0 @@ -"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; - title: string; - description?: string; - currentUser: { id: number; epId: string | null; name?: string | null; email?: string }; - onSubmit: (approvers: ApprovalLineItem[]) => Promise; -} - -export function ApprovalPreviewDialog({ - open, - onOpenChange, - templateName, - variables, - title, - description, - currentUser, - onSubmit, -}: ApprovalPreviewDialogProps) { - const [approvalLines, setApprovalLines] = React.useState([]); - const [isSubmitting, setIsSubmitting] = React.useState(false); - const [templateContent, setTemplateContent] = React.useState(""); - const [isLoadingTemplate, setIsLoadingTemplate] = React.useState(false); - const [templateError, setTemplateError] = React.useState(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(`

${description || "결재 요청"}

`); - } else { - // 변수 치환 - const replaced = await replaceTemplateVariables(template.content, variables); - setTemplateContent(replaced); - } - } catch (error) { - console.error("Template load error:", error); - setTemplateError("템플릿을 불러오는 중 오류가 발생했습니다."); - setTemplateContent(`

${description || "결재 요청"}

`); - } 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 ( - - - - - - {title} - - {description && {description}} - - -
- - {/* 결재선 선택 */} -
- -
- - - - {/* 템플릿 미리보기 */} - - - 결재 내용 미리보기 - - 템플릿: {templateName} - - - - {isLoadingTemplate ? ( -
- - 템플릿을 불러오는 중... -
- ) : templateError ? ( -
-
- - 경고 -
-

{templateError}

-

- 기본 내용으로 대체되었습니다. 결재는 정상적으로 진행됩니다. -

-
- ) : null} - -
- - - -
- - - - - - -
- ); -} - diff --git a/lib/approval/README.md b/lib/approval/README.md index 7e62a1d7..5b3c1839 100644 --- a/lib/approval/README.md +++ b/lib/approval/README.md @@ -585,68 +585,438 @@ async function replaceTemplateVariables( 결재 문서 미리보기 및 결재선 설정 다이얼로그 컴포넌트입니다. +**위치:** `lib/approval/approval-preview-dialog.tsx` + +**Import:** +```typescript +// ⚠️ 클라이언트 컴포넌트는 반드시 /client에서 import +import { ApprovalPreviewDialog } from '@/lib/approval/client'; +``` + +#### Props 인터페이스 + ```typescript interface ApprovalPreviewDialogProps { + // 기본 제어 open: boolean; onOpenChange: (open: boolean) => void; + + // 템플릿 설정 templateName: string; // DB에서 조회할 템플릿 이름 - variables: Record; // 템플릿 변수 - title: string; // 결재 제목 - description?: string; // 결재 설명 + variables: Record; // 템플릿 변수 ({{변수명}} 형태로 치환) + title: string; // 결재 제목 (초기값) + + // 사용자 정보 currentUser: { - id: number; - epId: string; - name?: string; - email?: string; - deptName?: string; + id: number; // 사용자 DB ID + epId: string; // Knox EP ID (필수) + name?: string; // 사용자 이름 + email?: string; // 이메일 + deptName?: string; // 부서명 }; + + // 결재선 설정 defaultApprovers?: string[]; // 초기 결재선 (EP ID 배열) + + // 콜백 onConfirm: (data: { - approvers: string[]; - title: string; - description?: string; + approvers: string[]; // 선택된 결재자 EP ID 배열 + title: string; // (수정 가능한) 결재 제목 + attachments?: File[]; // 첨부파일 (enableAttachments가 true일 때) }) => Promise; + + // 옵션 allowTitleEdit?: boolean; // 제목 수정 허용 (기본: true) - allowDescriptionEdit?: boolean; // 설명 수정 허용 (기본: true) + + // 첨부파일 (선택적 기능) + enableAttachments?: boolean; // 첨부파일 UI 활성화 (기본: false) + maxAttachments?: number; // 최대 첨부파일 개수 (기본: 10) + maxFileSize?: number; // 최대 파일 크기 bytes (기본: 100MB) } ``` -**주요 기능:** -- 템플릿 실시간 미리보기 (변수 자동 치환) -- 결재선 선택 UI (ApprovalLineSelector 통합) -- 제목/설명 수정 -- 반응형 디자인 (Desktop: Dialog, Mobile: Drawer) -- 로딩 상태 자동 처리 +#### 주요 기능 + +- ✅ 템플릿 실시간 미리보기 (변수 자동 치환) +- ✅ 결재선 선택 UI (ApprovalLineSelector 통합) +- ✅ 결재 제목 수정 가능 +- ✅ **선택적 첨부파일 업로드** (드래그 앤 드롭) +- ✅ 반응형 디자인 (Desktop: Dialog, Mobile: Drawer) +- ✅ 로딩 상태 자동 처리 +- ✅ 유효성 검사 및 에러 핸들링 -**사용 예시:** +#### 사용 예시 1: 기본 사용 (첨부파일 없이) ```typescript -// ⚠️ 클라이언트 컴포넌트는 반드시 /client에서 import +'use client'; + +import { useState } from 'react'; import { ApprovalPreviewDialog } from '@/lib/approval/client'; +import { useSession } from 'next-auth/react'; +import { toast } from 'sonner'; - { - await submitApproval(approvers); - }} -/> +export function VendorApprovalButton({ vendorData }) { + const { data: session } = useSession(); + const [showPreview, setShowPreview] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleOpenPreview = async () => { + // 1. 템플릿 변수 준비 + const variables = { + '업체명': vendorData.companyName, + '사업자번호': vendorData.businessNumber, + '담당자': vendorData.contactPerson, + '요청일': new Date().toLocaleDateString('ko-KR'), + '요청사유': vendorData.reason, + }; + + // 2. 미리보기 열기 + setShowPreview(true); + }; + + const handleConfirm = async ({ approvers, title, attachments }) => { + try { + setIsSubmitting(true); + + // 3. 실제 결재 상신 API 호출 + const result = await fetch('/api/vendor/approval', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + vendorId: vendorData.id, + approvers, + title, + }), + }); + + if (result.ok) { + toast.success('결재가 성공적으로 상신되었습니다.'); + setShowPreview(false); + } + } catch (error) { + toast.error('결재 상신에 실패했습니다.'); + } finally { + setIsSubmitting(false); + } + }; + + // EP ID 없으면 버튼 비활성화 + if (!session?.user?.epId) { + return ( + + ); + } + + return ( + <> + + + {showPreview && ( + + )} + + ); +} +``` + +#### 사용 예시 2: 첨부파일 포함 + +```typescript +'use client'; + +import { useState } from 'react'; +import { ApprovalPreviewDialog } from '@/lib/approval/client'; +import { submitApproval } from '@/lib/knox-api/approval/approval'; + +export function ContractApprovalButton({ contractData }) { + const { data: session } = useSession(); + const [showPreview, setShowPreview] = useState(false); + + const handleConfirm = async ({ approvers, title, attachments }) => { + console.log('결재자:', approvers.length, '명'); + console.log('첨부파일:', attachments?.length || 0, '개'); + + // Knox API로 결재 상신 (첨부파일 포함) + const approvalData = { + // 기본 정보 + subject: title, + contents: `
계약서 검토를 요청합니다.
`, + contentsType: 'HTML', + + // 결재선 설정 + aplns: approvers.map((epId, index) => ({ + epId: epId, // Knox EP ID + seq: index.toString(), // 결재 순서 (0부터 시작) + role: '1', // 역할: 0=기안, 1=결재, 2=합의, 3=참조 + aplnStatsCode: '0', // 결재 상태: 0=대기 + arbPmtYn: 'Y', // 임의승인 허용 + contentsMdfyPmtYn: 'Y', // 내용수정 허용 + aplnMdfyPmtYn: 'Y', // 결재선수정 허용 + opinion: '', // 의견 + })), + + // 첨부파일 + attachments: attachments || [], // File[] 배열 + + // 문서 설정 + docSecuType: 'PERSONAL', // 보안등급: PERSONAL, COMPANY, SECRET + notifyOption: '0', // 알림옵션: 0=전체, 1=결재완료, 2=없음 + urgYn: 'N', // 긴급여부 + docMngSaveCode: '0', // 문서관리저장코드 + sbmLang: 'ko', // 언어 + }; + + // Knox API 호출 + const result = await submitApproval(approvalData, { + userId: session.user.id.toString(), + epId: session.user.epId!, + emailAddress: session.user.email || '', + }); + + if (result.success) { + toast.success(`결재가 상신되었습니다. (ID: ${result.approvalId})`); + } + }; + + if (!session?.user?.epId) return null; + + return ( + <> + + + + + ); +} +``` + +#### Knox API 연동: approvalData 객체 상세 + +Knox 결재 API (`submitApproval`)에 전달하는 전체 객체 구조: + +```typescript +interface KnoxApprovalData { + // ===== 기본 정보 ===== + subject: string; // 결재 제목 (필수) + contents: string; // 결재 내용 HTML (필수) + contentsType: 'HTML' | 'TEXT'; // 내용 타입 (기본: 'HTML') + + // ===== 결재선 설정 ===== + aplns: Array<{ + epId: string; // Knox EP ID (필수) + seq: string; // 결재 순서 "0", "1", "2"... (필수) + role: string; // 역할 (필수) + // "0" = 기안자 + // "1" = 결재자 + // "2" = 합의자 + // "3" = 참조자 + aplnStatsCode: string; // 결재 상태 (필수) + // "0" = 대기 + // "1" = 진행중 + // "2" = 승인 + // "3" = 반려 + arbPmtYn: 'Y' | 'N'; // 임의승인 허용 (기본: 'Y') + contentsMdfyPmtYn: 'Y' | 'N'; // 내용수정 허용 (기본: 'Y') + aplnMdfyPmtYn: 'Y' | 'N'; // 결재선수정 허용 (기본: 'Y') + opinion?: string; // 결재 의견 (선택) + }>; + + // ===== 첨부파일 ===== + attachments?: File[]; // File 객체 배열 (선택) + + // ===== 문서 설정 ===== + docSecuType?: string; // 보안등급 (기본: 'PERSONAL') + // 'PERSONAL' = 개인 + // 'COMPANY' = 회사 + // 'SECRET' = 비밀 + notifyOption?: string; // 알림옵션 (기본: '0') + // '0' = 전체 알림 + // '1' = 결재완료 알림 + // '2' = 알림 없음 + urgYn?: 'Y' | 'N'; // 긴급여부 (기본: 'N') + docMngSaveCode?: string; // 문서관리저장코드 (기본: '0') + // '0' = 개인문서함 + // '1' = 부서문서함 + sbmLang?: 'ko' | 'en'; // 언어 (기본: 'ko') + + // ===== 추가 옵션 ===== + comment?: string; // 기안 의견 (선택) + refLineYn?: 'Y' | 'N'; // 참조선 사용여부 (기본: 'N') + agreementLineYn?: 'Y' | 'N'; // 합의선 사용여부 (기본: 'N') +} + +// 사용자 정보 +interface KnoxUserInfo { + userId: string; // 사용자 ID (필수) + epId: string; // Knox EP ID (필수) + emailAddress: string; // 이메일 (필수) +} +``` + +#### 전체 연동 예시 (Server Action) + +```typescript +// app/api/vendor/approval/route.ts +'use server'; + +import { ApprovalSubmissionSaga } from '@/lib/approval'; +import { mapVendorToTemplateVariables } from '@/lib/vendors/handlers'; + +export async function submitVendorApproval(data: { + vendorId: number; + approvers: string[]; + title: string; + attachments?: File[]; +}) { + // 1. 템플릿 변수 매핑 + const vendor = await getVendorById(data.vendorId); + const variables = await mapVendorToTemplateVariables(vendor); + + // 2. Saga로 결재 상신 + const saga = new ApprovalSubmissionSaga( + 'vendor_registration', // 핸들러 타입 + { + vendorId: data.vendorId, + // 필요한 payload 데이터 + }, + { + title: data.title, + templateName: '정규업체 등록', + variables, + approvers: data.approvers, // EP ID 배열 + currentUser: { + id: user.id, + epId: user.epId, + email: user.email, + }, + } + ); + + return await saga.execute(); +} ``` +#### 첨부파일 처리 + +첨부파일은 Knox API 내부에서 자동으로 FormData로 변환되어 전송됩니다: + +```typescript +// lib/knox-api/approval/approval.ts 내부 (참고용) +export async function submitApproval( + approvalData: KnoxApprovalData, + userInfo: KnoxUserInfo +) { + const formData = new FormData(); + + // 기본 데이터를 JSON으로 직렬화 + formData.append('data', JSON.stringify({ + subject: approvalData.subject, + contents: approvalData.contents, + aplns: approvalData.aplns, + // ... 기타 필드 + })); + + // 첨부파일 추가 + if (approvalData.attachments) { + approvalData.attachments.forEach((file, index) => { + formData.append(`attachment_${index}`, file); + }); + } + + // Knox API 호출 + const response = await fetch(KNOX_APPROVAL_API_URL, { + method: 'POST', + headers: { + 'X-User-Id': userInfo.userId, + 'X-EP-Id': userInfo.epId, + }, + body: formData, + }); + + return response.json(); +} +``` + +#### 주의사항 + +1. **EP ID 필수 체크** + ```typescript + if (!session?.user?.epId) { + return ; + } + ``` + +2. **onConfirm 에러 핸들링** + ```typescript + const handleConfirm = async ({ approvers, title, attachments }) => { + try { + await submitApproval(...); + toast.success('성공'); + } catch (error) { + toast.error('실패'); + // ⚠️ throw하지 않으면 다이얼로그가 자동으로 닫힘 + // 에러 시 다이얼로그를 열어두려면 throw 필요 + throw error; + } + }; + ``` + +3. **첨부파일 제한** + - 기본값: 최대 10개, 파일당 100MB + - `maxAttachments`, `maxFileSize`로 커스터마이징 가능 + - 중복 파일 자동 필터링 + +4. **반응형 UI** + - Desktop (≥768px): Dialog 형태 + - Mobile (<768px): Drawer 형태 + - 자동으로 화면 크기에 맞게 렌더링 + ### 캐시 관리 ```typescript @@ -670,9 +1040,9 @@ async function revalidateAllApprovalCaches(): Promise ``` lib/approval/ ├── approval-saga.ts # Saga 클래스 (메인 로직) [서버] -│ ├── ApprovalSubmissionSaga # 결재 상신 -│ ├── ApprovalExecutionSaga # 액션 실행 -│ └── ApprovalRejectionSaga # 반려 처리 +│ ├── ApprovalSubmissionSaga # 결재 상신 (7단계) +│ ├── ApprovalExecutionSaga # 액션 실행 (7단계) +│ └── ApprovalRejectionSaga # 반려 처리 (4단계) │ ├── approval-workflow.ts # 핸들러 레지스트리 [서버] │ ├── registerActionHandler() @@ -695,7 +1065,7 @@ lib/approval/ │ └── htmlDescriptionList() │ ├── approval-preview-dialog.tsx # 결재 미리보기 다이얼로그 [클라이언트] -│ └── ApprovalPreviewDialog # 템플릿 미리보기 + 결재선 설정 +│ └── ApprovalPreviewDialog # 템플릿 미리보기 + 결재선 설정 + 첨부파일 │ ├── cache-utils.ts # 캐시 관리 [서버] │ ├── revalidateApprovalLogs() @@ -709,8 +1079,13 @@ lib/approval/ │ ├── index.ts # 서버 전용 API Export ├── client.ts # 클라이언트 컴포넌트 Export ⚠️ +│ └── ApprovalPreviewDialog # ← 클라이언트에서는 이 파일에서 import │ -└── README.md # 이 문서 +├── README.md # 이 문서 (전체 시스템 가이드) +├── SAGA_PATTERN.md # Saga 패턴 상세 설명 +├── CRONJOB_CONTEXT_FIX.md # Request Context 문제 해결 가이드 +├── USAGE_PATTERN_ANALYSIS.md # 실제 사용 패턴 분석 +└── ARCHITECTURE_REVIEW.md # 아키텍처 평가 ``` ### Import 경로 가이드 @@ -1371,6 +1746,17 @@ setShowPreview(true); ## 📝 변경 이력 +### 2024-11-07 - ApprovalPreviewDialog 컴포넌트 통합 및 첨부파일 기능 추가 +- ✅ 중복 컴포넌트 통합: `components/approval/ApprovalPreviewDialog.tsx` → `lib/approval/approval-preview-dialog.tsx`로 일원화 +- ✅ 선택적 첨부파일 업로드 기능 추가 (`enableAttachments`, `maxAttachments`, `maxFileSize` props) +- ✅ 파일 드래그 앤 드롭 UI 구현 (Dropzone, FileList 컴포넌트 활용) +- ✅ 첨부파일 유효성 검사 (크기 제한, 개수 제한, 중복 파일 필터링) +- ✅ API 인터페이스 개선: `onSubmit` → `onConfirm`으로 변경 +- ✅ 콜백 시그니처 변경: `(approvers: ApprovalLineItem[])` → `({ approvers: string[], title: string, attachments?: File[] })` +- ✅ Knox API 연동 상세 가이드 추가 (approvalData 객체 전체 필드 문서화) +- ✅ 기존 사용처 업데이트 (vendor-regular-registrations, pq-review-table-new) +- ✅ README 상세화: 사용 예시, Props 인터페이스, 주의사항 등 보완 + ### 2024-11-06 - Request Context 호환성 개선 (RFQ 발송) - ✅ Cronjob 환경에서 Request Context 오류 해결 - ✅ `headers()`, `getServerSession()` 호출 문제 수정 diff --git a/lib/approval/approval-preview-dialog.tsx b/lib/approval/approval-preview-dialog.tsx index a91e146c..8bb7ba0f 100644 --- a/lib/approval/approval-preview-dialog.tsx +++ b/lib/approval/approval-preview-dialog.tsx @@ -25,6 +25,29 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ScrollArea } from "@/components/ui/scroll-area"; import { useMediaQuery } from "@/hooks/use-media-query"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Paperclip } from "lucide-react"; +import { Separator } from "@/components/ui/separator"; +import prettyBytes from "pretty-bytes"; +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone"; +import { + FileList, + FileListAction, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, + FileListSize, +} from "@/components/ui/file-list"; import { ApprovalLineSelector, @@ -63,9 +86,16 @@ export interface ApprovalPreviewDialogProps { onConfirm: (data: { approvers: string[]; title: string; + attachments?: File[]; }) => Promise; /** 제목 수정 가능 여부 (기본: true) */ allowTitleEdit?: boolean; + /** 첨부파일 UI 활성화 여부 (기본: false) */ + enableAttachments?: boolean; + /** 최대 첨부파일 개수 (기본: 10) */ + maxAttachments?: number; + /** 최대 파일 크기 (기본: 100MB) */ + maxFileSize?: number; } /** @@ -102,6 +132,9 @@ export function ApprovalPreviewDialog({ defaultApprovers = [], onConfirm, allowTitleEdit = true, + enableAttachments = false, + maxAttachments = 10, + maxFileSize = 100 * 1024 * 1024, // 100MB }: ApprovalPreviewDialogProps) { const isDesktop = useMediaQuery("(min-width: 768px)"); @@ -113,6 +146,7 @@ export function ApprovalPreviewDialog({ const [title, setTitle] = React.useState(initialTitle); const [approvalLines, setApprovalLines] = React.useState([]); const [previewHtml, setPreviewHtml] = React.useState(""); + const [attachments, setAttachments] = React.useState([]); // 템플릿 로딩 및 미리보기 생성 React.useEffect(() => { @@ -155,6 +189,7 @@ export function ApprovalPreviewDialog({ setTitle(initialTitle); setApprovalLines([]); setPreviewHtml(""); + setAttachments([]); return; } @@ -195,6 +230,36 @@ export function ApprovalPreviewDialog({ setApprovalLines(lines); }; + // 파일 드롭 핸들러 + const handleDropAccepted = React.useCallback( + (files: File[]) => { + if (attachments.length + files.length > maxAttachments) { + toast.error(`최대 ${maxAttachments}개의 파일만 첨부할 수 있습니다.`); + return; + } + + // 중복 파일 체크 + const newFiles = files.filter( + (file) => !attachments.some((existing) => existing.name === file.name && existing.size === file.size) + ); + + if (newFiles.length !== files.length) { + toast.warning("일부 중복된 파일은 제외되었습니다."); + } + + setAttachments((prev) => [...prev, ...newFiles]); + }, + [attachments, maxAttachments] + ); + + const handleDropRejected = React.useCallback(() => { + toast.error(`파일 크기는 ${prettyBytes(maxFileSize)} 이하여야 합니다.`); + }, [maxFileSize]); + + const handleRemoveFile = React.useCallback((index: number) => { + setAttachments((prev) => prev.filter((_, i) => i !== index)); + }, []); + // 제출 핸들러 const handleSubmit = async () => { try { @@ -225,6 +290,7 @@ export function ApprovalPreviewDialog({ await onConfirm({ approvers: approverEpIds, title: title.trim(), + attachments: enableAttachments ? attachments : undefined, }); // 성공 시 다이얼로그 닫기 @@ -275,6 +341,96 @@ export function ApprovalPreviewDialog({ /> + {/* 첨부파일 섹션 (enableAttachments가 true일 때만 표시) */} + {enableAttachments && ( + <> + + + + + + + 첨부파일 + {attachments.length > 0 && ( + + ({attachments.length}/{maxAttachments}) + + )} + + + 결재 문서에 첨부할 파일을 추가하세요 (최대 {maxAttachments}개, 파일당 최대 {prettyBytes(maxFileSize)}) + + + + {/* 파일 드롭존 */} + {attachments.length < maxAttachments && ( + + {() => ( + + +
+ +
+ 파일을 드래그하거나 클릭하여 업로드 + + 모든 형식의 파일을 첨부할 수 있습니다 + +
+
+
+ )} +
+ )} + + {/* 첨부된 파일 목록 */} + {attachments.length > 0 && ( + + {attachments.map((file, index) => ( + + + + +
+ {file.name} + + {file.size} + {file.type && ( + <> + + {file.type} + + )} + +
+
+
+ handleRemoveFile(index)} + disabled={isSubmitting} + title="파일 제거" + > + + +
+ ))} +
+ )} + + {attachments.length === 0 && ( +

+ 첨부된 파일이 없습니다 +

+ )} +
+
+ + )} + {/* 템플릿 미리보기 */}
diff --git a/lib/approval/template-utils.ts b/lib/approval/template-utils.ts index 0607f289..5a5bb307 100644 --- a/lib/approval/template-utils.ts +++ b/lib/approval/template-utils.ts @@ -39,14 +39,18 @@ export async function getApprovalTemplateByName(name: string) { * * {{변수명}} 형태의 변수를 실제 값으로 치환 * + * **중요**: 변수명의 앞뒤 공백은 자동으로 제거됩니다. + * - 템플릿: `{{ 변수명 }}` → `{{변수명}}`으로 정규화 + * - 변수 키: ` 변수명 ` → `변수명`으로 정규화 + * * @param content - 템플릿 HTML 내용 * @param variables - 변수 매핑 객체 * @returns 치환된 HTML * * @example * ```typescript - * const content = "

{{이름}}님, 안녕하세요

"; - * const variables = { "이름": "홍길동" }; + * const content = "

{{ 이름 }}님, 안녕하세요

"; + * const variables = { " 이름 ": "홍길동" }; // 공백 있어도 OK * const result = await replaceTemplateVariables(content, variables); * // "

홍길동님, 안녕하세요

" * ``` @@ -57,9 +61,20 @@ export async function replaceTemplateVariables( ): Promise { let result = content; + // 변수 키를 trim하여 정규화된 맵 생성 + const normalizedVariables: Record = {}; Object.entries(variables).forEach(([key, value]) => { - // {{변수명}} 패턴을 전역으로 치환 - const pattern = new RegExp(`\\{\\{${escapeRegex(key)}\\}\\}`, 'g'); + normalizedVariables[key.trim()] = value; + }); + + // 템플릿에서 {{ 변수명 }} 패턴을 찾아 치환 + // 공백을 허용하는 정규식: {{\s*변수명\s*}} + Object.entries(normalizedVariables).forEach(([key, value]) => { + // 변수명 앞뒤에 공백이 있을 수 있으므로 \s*를 추가 + const pattern = new RegExp( + `\\{\\{\\s*${escapeRegex(key)}\\s*\\}\\}`, + 'g' + ); result = result.replace(pattern, value); }); diff --git a/lib/approval/templates/README.md b/lib/approval/templates/README.md new file mode 100644 index 00000000..ab033fb8 --- /dev/null +++ b/lib/approval/templates/README.md @@ -0,0 +1,4 @@ +결재 HTML 템플릿 작성 가이드: +1. html, head, body 태그 사용 불가 +2. 모든 스타일은 인라인 에디팅으로 작성 +3. 검정, 회색 무채색 계열(기업 사내 시스템에 적절하도록) \ No newline at end of file diff --git "a/lib/approval/templates/\354\240\225\352\267\234\354\227\205\354\262\264 \353\223\261\353\241\235.html" "b/lib/approval/templates/\354\240\225\352\267\234\354\227\205\354\262\264 \353\223\261\353\241\235.html" new file mode 100644 index 00000000..5bcf1ba2 --- /dev/null +++ "b/lib/approval/templates/\354\240\225\352\267\234\354\227\205\354\262\264 \353\223\261\353\241\235.html" @@ -0,0 +1,877 @@ +
+ + + + + + + +
+ 정규업체 등록 +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ■ 정규업체 등록 요청 정보 +
+ 사업자번호 + + {{협력업체기본정보-사업자번호}} +
+ 업체명 + + {{협력업체기본정보-업체명}} +
+ 대표자명 + + {{협력업체기본정보-대표자명}} +
+ 대표 전화번호 + + {{협력업체기본정보-대표전화}} +
+ 대표 팩스번호 + + {{협력업체기본정보-FAX}} +
+ 대표 이메일 + + {{협력업체기본정보-Email}} +
+ 우편번호 + + {{협력업체기본정보-우편번호}} +
+ 회사주소 + + {{협력업체기본정보-회사주소}} +
+ 상세주소 + + {{협력업체기본정보-상세주소}} +
+ 사업유형 + + {{협력업체기본정보-사업유형}} +
+ 산업유형 + + {{협력업체기본정보-산업유형}} +
+ 회사규모 + + {{협력업체기본정보-회사규모}} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ ■ 담당자 연락처 +
+ 영업 (sales) + + 설계 (design) + + 납기 (delivery) + + 품질 (quality) + + 세금계산서 +
+ 담당자명 + + {{영업담당자-담당자명}} + + {{설계담당자-담당자명}} + + {{납기담당자-담당자명}} + + {{품질담당자-담당자명}} + + {{세금계산서담당자-담당자명}} +
+ 직급 + + {{영업담당자-직급}} + + {{설계담당자-직급}} + + {{납기담당자-직급}} + + {{품질담당자-직급}} + + {{세금계산서담당자-직급}} +
+ 부서 + + {{영업담당자-부서}} + + {{설계담당자-부서}} + + {{납기담당자-부서}} + + {{품질담당자-부서}} + + {{세금계산서담당자-부서}} +
+ 담당업무 + + {{영업담당자-담당업무}} + + {{설계담당자-담당업무}} + + {{납기담당자-담당업무}} + + {{품질담당자-담당업무}} + + {{세금계산서담당자-담당업무}} +
+ 이메일 + + {{영업담당자-이메일}} + + {{설계담당자-이메일}} + + {{납기담당자-이메일}} + + {{품질담당자-이메일}} + + {{세금계산서담당자-이메일}} +
+ + + + + + + + + + + + + + + + + + + + +
+ ■ 기본계약서 현황 +
+ 기본계약서명 + + 상태 + + 서약일자 +
+ {{계약유형}} + + {{계약상태}} + + {{서약일자}} +
+
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 f93959a6..4584e772 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 @@ -1,917 +1,904 @@ -"use client" - -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 { - cancelInvestigationAction, - sendInvestigationResultsAction, - getFactoryLocationAnswer, - getQMManagers -} from "@/lib/pq/service" -import { SiteVisitDialog } from "./site-visit-dialog" -import type { SiteVisitRequestFormValues } from "./site-visit-dialog" -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 -} - -interface InvestigationInitialData { - investigationMethod?: "PURCHASE_SELF_EVAL" | "DOCUMENT_EVAL" | "PRODUCT_INSPECTION" | "SITE_VISIT_EVAL"; - qmManagerId?: number; - forecastedAt?: Date; - createdAt?: Date; - investigationAddress?: string; - investigationNotes?: string; -} - -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 [isReinspectionDialogOpen, setIsReinspectionDialogOpen] = React.useState(false) - const [isApprovalDialogOpen, setIsApprovalDialogOpen] = React.useState(false) - const [isReRequestApprovalDialogOpen, setIsReRequestApprovalDialogOpen] = React.useState(false) - - // 초기 데이터 상태 - const [dialogInitialData, setDialogInitialData] = React.useState(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>({}) - const [reRequestApprovalVariables, setReRequestApprovalVariables] = React.useState>({}) - - // 실사 의뢰 대화상자 열기 핸들러 -// 실사 의뢰 대화상자 열기 핸들러 -const handleOpenRequestDialog = async () => { - setIsLoading(true); - const initialData: InvestigationInitialData = {}; - - try { - // 선택된 행이 정확히 1개인 경우에만 초기값 설정 - if (selectedRows.length === 1) { - const row = selectedRows[0].original; - - // 승인된 PQ이고 아직 실사가 없는 경우 - if (row.status === "APPROVED" && !row.investigation) { - // Factory Location 정보 가져오기 - const locationResponse = await getFactoryLocationAnswer( - row.vendorId, - row.projectId - ); - - // 기본 주소 설정 - Factory Location 응답 또는 fallback - let defaultAddress = ""; - if (locationResponse.success && locationResponse.factoryLocation) { - defaultAddress = locationResponse.factoryLocation; - } else { - // Factory Location을 찾지 못한 경우 fallback - defaultAddress = row.taxId ? - `${row.vendorName} 사업장 (${row.taxId})` : - `${row.vendorName} 사업장`; - } - - // 이미 같은 회사에 대한 다른 실사가 있는지 확인 - const existingInvestigations = table.getFilteredRowModel().rows - .map(r => r.original) - .filter(r => - r.vendorId === row.vendorId && - r.investigation !== null - ); - - // 같은 업체의 이전 실사 기록이 있다면 참고하되, 주소는 Factory Location 사용 - if (existingInvestigations.length > 0) { - // 날짜 기준으로 정렬하여 가장 최근 것을 가져옴 - const latestInvestigation = existingInvestigations.sort((a, b) => { - const dateA = a.investigation?.createdAt || new Date(0); - const dateB = b.investigation?.createdAt || new Date(0); - return (dateB as Date).getTime() - (dateA as Date).getTime(); - })[0].investigation; - - if (latestInvestigation) { - initialData.investigationMethod = latestInvestigation.investigationMethod || undefined; - initialData.qmManagerId = latestInvestigation.qmManagerId || undefined; - initialData.investigationAddress = defaultAddress; // Factory Location 사용 - - // 날짜는 미래로 설정 - const futureDate = new Date(); - futureDate.setDate(futureDate.getDate() + 14); // 기본값으로 2주 후 - initialData.forecastedAt = futureDate; - } - } else { - // 기본값 설정 - initialData.investigationMethod = undefined; - const futureDate = new Date(); - futureDate.setDate(futureDate.getDate() + 14); // 기본값으로 2주 후 - initialData.forecastedAt = futureDate; - initialData.investigationAddress = defaultAddress; // Factory Location 사용 - } - } - // 실사가 이미 있고 수정하는 경우 - // else if (row.investigation) { - // initialData.investigationMethod = row.investigation.investigationMethod || undefined; - // initialData.qmManagerId = row.investigation.qmManagerId !== null ? - // row.investigation.qmManagerId : undefined; - // initialData.forecastedAt = row.investigation.forecastedAt || new Date(); - // initialData.investigationAddress = row.investigation.investigationAddress || ""; - // initialData.investigationNotes = row.investigation.investigationNotes || ""; - // } - } - } catch (error) { - console.error("초기 데이터 로드 중 오류:", error); - toast.error("초기 데이터 로드 중 오류가 발생했습니다."); - } finally { - setIsLoading(false); - - // 초기 데이터 설정 및 대화상자 열기 - setDialogInitialData(Object.keys(initialData).length > 0 ? initialData : undefined); - setIsRequestDialogOpen(true); - } -}; - // 실사 의뢰 요청 처리 - Step 1: RequestInvestigationDialog에서 정보 입력 후 - const handleRequestInvestigation = async (formData: { - qmManagerId: number, - forecastedAt: Date, - investigationAddress: string, - investigationNotes?: string - }) => { - try { - // 승인된 PQ 제출만 필터링 (미실사 PQ 제외) - const approvedPQs = selectedRows.filter(row => - row.original.status === "APPROVED" && - !row.original.investigation && - row.original.type !== "NON_INSPECTION" - ) - - if (approvedPQs.length === 0) { - if (hasNonInspectionPQ) { - toast.error("미실사 PQ는 실사 의뢰할 수 없습니다. 미실사 PQ를 제외하고 선택해주세요.") - } else { - toast.error("실사를 의뢰할 수 있는 업체가 없습니다. 승인된 PQ 제출만 실사 의뢰가 가능합니다.") - } - return - } - - // 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, - }) - - // 결재 템플릿 변수 생성 - 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, - }) - - setApprovalVariables(variables) - - // RequestInvestigationDialog 닫고 ApprovalPreviewDialog 열기 - setIsRequestDialogOpen(false) - 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() - } - } - - const handleCloseRequestDialog = () => { - setIsRequestDialogOpen(false); - setDialogInitialData(undefined); - }; - - - // 실사 의뢰 취소 처리 - const handleCancelInvestigation = async () => { - setIsLoading(true) - try { - // 실사가 계획됨 상태인 PQ만 필터링 - const plannedInvestigations = selectedRows.filter(row => - row.original.investigation && - row.original.investigation.investigationStatus === "PLANNED" - ) - - if (plannedInvestigations.length === 0) { - toast.error("취소할 수 있는 실사 의뢰가 없습니다. 계획 상태의 실사만 취소할 수 있습니다.") - return - } - - // 서버 액션 호출 - const result = await cancelInvestigationAction( - plannedInvestigations.map(row => row.original.investigation!.id) - ) - - 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) - setIsCancelDialogOpen(false) - } - } - - // 실사 재의뢰 처리 - Step 1: 확인 다이얼로그에서 확인 후 - const handleReRequestInvestigation = async (reason?: string) => { - try { - // 취소된 실사만 필터링 - const canceledInvestigations = selectedRows.filter(row => - row.original.investigation && - row.original.investigation.investigationStatus === "CANCELED" - ) - - if (canceledInvestigations.length === 0) { - toast.error("재의뢰할 수 있는 실사가 없습니다. 취소 상태의 실사만 재의뢰할 수 있습니다.") - return - } - - // 협력사 이름 목록 생성 - const vendorNames = canceledInvestigations - .map(row => row.original.vendorName) - .join(', ') - - // 재의뢰 데이터 저장 - 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() - } - } - - // 재실사 요청 처리 - const handleRequestReinspection = async ( - data: SiteVisitRequestFormValues, - attachments?: File[] - ) => { - try { - // 보완-재실사 대상 실사만 필터링 - const supplementReinspectInvestigations = selectedRows.filter(row => - row.original.investigation && - row.original.investigation.evaluationResult === "SUPPLEMENT_REINSPECT" - ); - - if (supplementReinspectInvestigations.length === 0) { - toast.error("보완-재실사 대상 실사가 없습니다."); - return; - } - - // 첫 번째 대상 실사로 재실사 요청 생성 - const targetRow = supplementReinspectInvestigations[0].original; - const targetInvestigation = targetRow.investigation!; - const { requestSupplementReinspectionAction } = await import('@/lib/vendor-investigation/service'); - - // SiteVisitRequestFormValues를 requestSupplementReinspectionAction 형식으로 변환 - // shiAttendees는 그대로 전달 (새로운 형식: {checked, attendees}) - const result = await requestSupplementReinspectionAction({ - investigationId: targetInvestigation.id, - siteVisitData: { - inspectionDuration: data.inspectionDuration, - requestedStartDate: data.requestedStartDate, - requestedEndDate: data.requestedEndDate, - shiAttendees: data.shiAttendees || {}, - vendorRequests: data.vendorRequests || {}, - additionalRequests: data.additionalRequests || "", - }, - }); - - if (result.success) { - toast.success("재실사 요청이 생성되었습니다."); - setIsReinspectionDialogOpen(false); - window.location.reload(); - } else { - toast.error(result.error || "재실사 요청 생성 중 오류가 발생했습니다."); - } - } catch (error) { - console.error("재실사 요청 오류:", error); - toast.error("재실사 요청 중 오류가 발생했습니다."); - } - }; - - // 실사 결과 발송 처리 - const handleSendInvestigationResults = async (data: { purchaseComment?: string }) => { - try { - setIsLoading(true) - - // 완료된 실사 중 승인된 결과 또는 보완된 결과만 필터링 - const approvedInvestigations = selectedRows.filter(row => { - const investigation = row.original.investigation - return investigation && - (investigation.investigationStatus === "COMPLETED" || - investigation.investigationStatus === "SUPPLEMENT_REQUIRED" || - investigation.evaluationResult === "REJECTED") - - }) - - if (approvedInvestigations.length === 0) { - toast.error("발송할 실사 결과가 없습니다. 완료되고 승인된 실사만 결과를 발송할 수 있습니다.") - return - } - - // 서버 액션 호출 - const result = await sendInvestigationResultsAction({ - investigationIds: approvedInvestigations.map(row => row.original.investigation!.id), - purchaseComment: data.purchaseComment, - }) - - if (result.success) { - toast.success(result.message || `${result.data?.successCount || 0}개 업체에 대한 실사 결과가 발송되었습니다.`) - window.location.reload() - } else { - toast.error(result.error || "실사 결과 발송 처리 중 오류가 발생했습니다.") - } - } catch (error) { - console.error("실사 결과 발송 중 오류 발생:", error) - toast.error("실사 결과 발송 중 오류가 발생했습니다.") - } finally { - setIsLoading(false) - setIsSendResultsDialogOpen(false) - } - } - - // 승인된 업체 수 확인 (미실사 PQ 제외) - const approvedPQsCount = selectedRows.filter(row => - row.original.status === "APPROVED" && - !row.original.investigation && - row.original.type !== "NON_INSPECTION" - ).length - - // 계획 상태 실사 수 확인 - const plannedInvestigationsCount = selectedRows.filter(row => - row.original.investigation && - row.original.investigation.investigationStatus === "PLANNED" - ).length - - // 완료된 실사 수 확인 (승인된 결과만) - const completedInvestigationsCount = selectedRows.filter(row => - row.original.investigation && - row.original.investigation.investigationStatus === "COMPLETED" && - row.original.investigation.evaluationResult === "APPROVED" - ).length - - // 취소된 실사 수 확인 - const canceledInvestigationsCount = selectedRows.filter(row => - row.original.investigation && - row.original.investigation.investigationStatus === "CANCELED" - ).length - - // 재실사 요청 대상 수 확인 (보완-재실사 결과만) - const reinspectInvestigationsCount = selectedRows.filter(row => - row.original.investigation && - row.original.investigation.evaluationResult === "SUPPLEMENT_REINSPECT" - ).length - - // 재실사 요청 가능 여부 확인 (방문실사평가 또는 제품검사평가만 가능) - const canRequestReinspection = selectedRows.some(row => { - const investigation = row.original.investigation - if (!investigation) return false - if (investigation.evaluationResult !== "SUPPLEMENT_REINSPECT") return false - const method = investigation.investigationMethod - // 서류평가 또는 구매자체평가는 재방문실사 불가 - return method === "SITE_VISIT_EVAL" || method === "PRODUCT_INSPECTION" - }) - - // 미실사 PQ가 선택되었는지 확인 - const hasNonInspectionPQ = selectedRows.some(row => - row.original.type === "NON_INSPECTION" - ) - - // 실사 방법 라벨 변환 함수 - const getInvestigationMethodLabel = (method: string): string => { - switch (method) { - case "PURCHASE_SELF_EVAL": - return "구매자체평가" - case "DOCUMENT_EVAL": - return "서류평가" - case "PRODUCT_INSPECTION": - return "제품검사평가" - case "SITE_VISIT_EVAL": - return "방문실사평가" - default: - return method - } - } - - // 실사 결과 발송용 데이터 준비 - const auditResults = selectedRows - .filter(row => - row.original.investigation && - (row.original.investigation.investigationStatus === "COMPLETED" || row.original.investigation.investigationStatus === "SUPPLEMENT_REQUIRED") && ( - (row.original.investigation.evaluationResult === "APPROVED" || - row.original.investigation.evaluationResult === "SUPPLEMENT" || - row.original.investigation.evaluationResult === "SUPPLEMENT_REINSPECT" || - row.original.investigation.evaluationResult === "SUPPLEMENT_DOCUMENT")) - ) - .map(row => { - const investigation = row.original.investigation! - const pqSubmission = row.original - - // pqItems를 상세하게 포맷팅 (itemCode-itemName 형태로 모든 항목 표시) - const formatAuditItem = (pqItems: any): string => { - if (!pqItems) return pqSubmission.projectName || "N/A"; - - try { - // 이미 파싱된 객체 배열인 경우 - if (Array.isArray(pqItems)) { - return pqItems.map(item => { - if (typeof item === 'string') return item; - if (typeof item === 'object') { - const code = item.itemCode || item.code || ""; - const name = item.itemName || item.name || ""; - if (code && name) return `${code}-${name}`; - return name || code || String(item); - } - return String(item); - }).join(', '); - } - - // JSON 문자열인 경우 - if (typeof pqItems === 'string') { - try { - const parsed = JSON.parse(pqItems); - if (Array.isArray(parsed)) { - return parsed.map(item => { - if (typeof item === 'string') return item; - if (typeof item === 'object') { - const code = item.itemCode || item.code || ""; - const name = item.itemName || item.name || ""; - if (code && name) return `${code}-${name}`; - return name || code || String(item); - } - return String(item); - }).join(', '); - } - return String(parsed); - } catch { - return String(pqItems); - } - } - - // 기타 경우 - return String(pqItems); - } catch { - return pqSubmission.projectName || "N/A"; - } - }; - - return { - id: investigation.id, - vendorCode: row.original.vendorCode || "N/A", - vendorName: row.original.vendorName || "N/A", - vendorEmail: row.original.email || "N/A", - vendorContactPerson: (row.original as any).representativeName || row.original.vendorName || "N/A", - pqNumber: pqSubmission.pqNumber || "N/A", - auditItem: formatAuditItem(pqSubmission.pqItems), - auditFactoryAddress: investigation.investigationAddress || "N/A", - auditMethod: getInvestigationMethodLabel(investigation.investigationMethod || ""), - auditResult: investigation.evaluationResult === "APPROVED" ? "Pass(승인)" : - investigation.evaluationResult === "SUPPLEMENT" ? "Pass(조건부승인)" : - investigation.evaluationResult === "REJECTED" ? "Fail(미승인)" : "N/A", - additionalNotes: investigation.investigationNotes || undefined, - investigationNotes: investigation.investigationNotes || undefined, - } - }) - - return ( - <> -
- {/* 실사 의뢰 버튼 */} - - - {/* 실사 의뢰 취소 버튼 */} - - - {/* 실사 재의뢰 버튼 */} - - - {/* 재실사 요청 버튼 */} - - - {/* 실사 결과 발송 버튼 */} - - - {/** Export 버튼 */} - -
- - {/* 실사 의뢰 Dialog */} - - - - {/* 실사 취소 Dialog */} - setIsCancelDialogOpen(false)} - onConfirm={handleCancelInvestigation} - selectedCount={plannedInvestigationsCount} - /> - - {/* 실사 재의뢰 Dialog */} - setIsReRequestDialogOpen(false)} - onConfirm={handleReRequestInvestigation} - selectedCount={canceledInvestigationsCount} - /> - - {/* 결과 발송 Dialog */} - setIsSendResultsDialogOpen(false)} - onConfirm={handleSendInvestigationResults} - selectedCount={completedInvestigationsCount} - auditResults={auditResults} - /> - - {/* 재방문실사 요청 Dialog */} - {(() => { - // 보완-재실사 대상 실사 찾기 - const supplementReinspectInvestigations = selectedRows.filter(row => - row.original.investigation && - row.original.investigation.evaluationResult === "SUPPLEMENT_REINSPECT" - ); - - if (supplementReinspectInvestigations.length === 0) { - return null; - } - - const targetRow = supplementReinspectInvestigations[0].original; - const targetInvestigation = targetRow.investigation!; - - return ( - setIsReinspectionDialogOpen(false)} - onSubmit={handleRequestReinspection} - investigation={{ - id: targetInvestigation.id, - investigationMethod: targetInvestigation.investigationMethod || undefined, - investigationAddress: targetInvestigation.investigationAddress || undefined, - investigationNotes: targetInvestigation.investigationNotes || undefined, - vendorName: targetRow.vendorName, - vendorCode: targetRow.vendorCode, - projectName: targetRow.projectName || undefined, - projectCode: targetRow.projectCode || undefined, - pqItems: targetRow.pqItems || null, - }} - isReinspection={true} - /> - ); - })()} - - {/* 결재 미리보기 Dialog - 실사 의뢰 */} - {session?.user && investigationFormData && ( - { - 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 && ( - { - 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} - /> - )} - - ) +"use client" + +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 { + cancelInvestigationAction, + sendInvestigationResultsAction, + getFactoryLocationAnswer, + getQMManagers +} from "@/lib/pq/service" +import { SiteVisitDialog } from "./site-visit-dialog" +import type { SiteVisitRequestFormValues } from "./site-visit-dialog" +import { RequestInvestigationDialog } from "./request-investigation-dialog" +import { CancelInvestigationDialog, ReRequestInvestigationDialog } from "./cancel-investigation-dialog" +import { SendResultsDialog } from "./send-results-dialog" +import { ApprovalPreviewDialog } from "@/lib/approval/approval-preview-dialog" +import { + requestPQInvestigationWithApproval, + reRequestPQInvestigationWithApproval +} from "@/lib/vendor-investigation/approval-actions" +import { debugLog, debugError, debugSuccess } from "@/lib/debug-utils" + +interface VendorsTableToolbarActionsProps { + table: Table +} + +interface InvestigationInitialData { + investigationMethod?: "PURCHASE_SELF_EVAL" | "DOCUMENT_EVAL" | "PRODUCT_INSPECTION" | "SITE_VISIT_EVAL"; + qmManagerId?: number; + forecastedAt?: Date; + createdAt?: Date; + investigationAddress?: string; + investigationNotes?: string; +} + +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 [isReinspectionDialogOpen, setIsReinspectionDialogOpen] = React.useState(false) + const [isApprovalDialogOpen, setIsApprovalDialogOpen] = React.useState(false) + const [isReRequestApprovalDialogOpen, setIsReRequestApprovalDialogOpen] = React.useState(false) + + // 초기 데이터 상태 + const [dialogInitialData, setDialogInitialData] = React.useState(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>({}) + const [reRequestApprovalVariables, setReRequestApprovalVariables] = React.useState>({}) + + // 실사 의뢰 대화상자 열기 핸들러 +// 실사 의뢰 대화상자 열기 핸들러 +const handleOpenRequestDialog = async () => { + setIsLoading(true); + const initialData: InvestigationInitialData = {}; + + try { + // 선택된 행이 정확히 1개인 경우에만 초기값 설정 + if (selectedRows.length === 1) { + const row = selectedRows[0].original; + + // 승인된 PQ이고 아직 실사가 없는 경우 + if (row.status === "APPROVED" && !row.investigation) { + // Factory Location 정보 가져오기 + const locationResponse = await getFactoryLocationAnswer( + row.vendorId, + row.projectId + ); + + // 기본 주소 설정 - Factory Location 응답 또는 fallback + let defaultAddress = ""; + if (locationResponse.success && locationResponse.factoryLocation) { + defaultAddress = locationResponse.factoryLocation; + } else { + // Factory Location을 찾지 못한 경우 fallback + defaultAddress = row.taxId ? + `${row.vendorName} 사업장 (${row.taxId})` : + `${row.vendorName} 사업장`; + } + + // 이미 같은 회사에 대한 다른 실사가 있는지 확인 + const existingInvestigations = table.getFilteredRowModel().rows + .map(r => r.original) + .filter(r => + r.vendorId === row.vendorId && + r.investigation !== null + ); + + // 같은 업체의 이전 실사 기록이 있다면 참고하되, 주소는 Factory Location 사용 + if (existingInvestigations.length > 0) { + // 날짜 기준으로 정렬하여 가장 최근 것을 가져옴 + const latestInvestigation = existingInvestigations.sort((a, b) => { + const dateA = a.investigation?.createdAt || new Date(0); + const dateB = b.investigation?.createdAt || new Date(0); + return (dateB as Date).getTime() - (dateA as Date).getTime(); + })[0].investigation; + + if (latestInvestigation) { + initialData.investigationMethod = latestInvestigation.investigationMethod || undefined; + initialData.qmManagerId = latestInvestigation.qmManagerId || undefined; + initialData.investigationAddress = defaultAddress; // Factory Location 사용 + + // 날짜는 미래로 설정 + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 14); // 기본값으로 2주 후 + initialData.forecastedAt = futureDate; + } + } else { + // 기본값 설정 + initialData.investigationMethod = undefined; + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 14); // 기본값으로 2주 후 + initialData.forecastedAt = futureDate; + initialData.investigationAddress = defaultAddress; // Factory Location 사용 + } + } + // 실사가 이미 있고 수정하는 경우 + // else if (row.investigation) { + // initialData.investigationMethod = row.investigation.investigationMethod || undefined; + // initialData.qmManagerId = row.investigation.qmManagerId !== null ? + // row.investigation.qmManagerId : undefined; + // initialData.forecastedAt = row.investigation.forecastedAt || new Date(); + // initialData.investigationAddress = row.investigation.investigationAddress || ""; + // initialData.investigationNotes = row.investigation.investigationNotes || ""; + // } + } + } catch (error) { + console.error("초기 데이터 로드 중 오류:", error); + toast.error("초기 데이터 로드 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + + // 초기 데이터 설정 및 대화상자 열기 + setDialogInitialData(Object.keys(initialData).length > 0 ? initialData : undefined); + setIsRequestDialogOpen(true); + } +}; + // 실사 의뢰 요청 처리 - Step 1: RequestInvestigationDialog에서 정보 입력 후 + const handleRequestInvestigation = async (formData: { + qmManagerId: number, + forecastedAt: Date, + investigationAddress: string, + investigationNotes?: string + }) => { + try { + // 승인된 PQ 제출만 필터링 (미실사 PQ 제외) + const approvedPQs = selectedRows.filter(row => + row.original.status === "APPROVED" && + !row.original.investigation && + row.original.type !== "NON_INSPECTION" + ) + + if (approvedPQs.length === 0) { + if (hasNonInspectionPQ) { + toast.error("미실사 PQ는 실사 의뢰할 수 없습니다. 미실사 PQ를 제외하고 선택해주세요.") + } else { + toast.error("실사를 의뢰할 수 있는 업체가 없습니다. 승인된 PQ 제출만 실사 의뢰가 가능합니다.") + } + return + } + + // 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, + }) + + // 결재 템플릿 변수 생성 + 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, + }) + + setApprovalVariables(variables) + + // RequestInvestigationDialog 닫고 ApprovalPreviewDialog 열기 + setIsRequestDialogOpen(false) + setIsApprovalDialogOpen(true) + } catch (error) { + console.error("결재 준비 중 오류 발생:", error) + toast.error("결재 준비 중 오류가 발생했습니다.") + } + } + + // 실사 의뢰 결재 요청 처리 - Step 2: ApprovalPreviewDialog에서 결재선 선택 후 + const handleApprovalSubmit = async ({ approvers, title, attachments }: { approvers: string[], title: string, attachments?: File[] }) => { + 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(', ') + + debugLog('[InvestigationApproval] 결재선 추출 완료', { + approverEpIds: approvers, + }); + + // 결재 워크플로우 시작 (approvers는 이미 EP ID 배열) + 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: approvers, + }) + + debugSuccess('[InvestigationApproval] 결재 요청 성공', { + approvalId: result.approvalId, + pendingActionId: result.pendingActionId, + }); + + if (result.status === 'pending_approval') { + // 성공 시에만 상태 초기화 및 페이지 리로드 + setInvestigationFormData(null) + setDialogInitialData(undefined) + window.location.reload() + } + } + + const handleCloseRequestDialog = () => { + setIsRequestDialogOpen(false); + setDialogInitialData(undefined); + }; + + + // 실사 의뢰 취소 처리 + const handleCancelInvestigation = async () => { + setIsLoading(true) + try { + // 실사가 계획됨 상태인 PQ만 필터링 + const plannedInvestigations = selectedRows.filter(row => + row.original.investigation && + row.original.investigation.investigationStatus === "PLANNED" + ) + + if (plannedInvestigations.length === 0) { + toast.error("취소할 수 있는 실사 의뢰가 없습니다. 계획 상태의 실사만 취소할 수 있습니다.") + return + } + + // 서버 액션 호출 + const result = await cancelInvestigationAction( + plannedInvestigations.map(row => row.original.investigation!.id) + ) + + 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) + setIsCancelDialogOpen(false) + } + } + + // 실사 재의뢰 처리 - Step 1: 확인 다이얼로그에서 확인 후 + const handleReRequestInvestigation = async (reason?: string) => { + try { + // 취소된 실사만 필터링 + const canceledInvestigations = selectedRows.filter(row => + row.original.investigation && + row.original.investigation.investigationStatus === "CANCELED" + ) + + if (canceledInvestigations.length === 0) { + toast.error("재의뢰할 수 있는 실사가 없습니다. 취소 상태의 실사만 재의뢰할 수 있습니다.") + return + } + + // 협력사 이름 목록 생성 + const vendorNames = canceledInvestigations + .map(row => row.original.vendorName) + .join(', ') + + // 재의뢰 데이터 저장 + 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, title, attachments }: { approvers: string[], title: string, attachments?: File[] }) => { + 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, + }); + + debugLog('[ReRequestApproval] 결재선 추출 완료', { + approverEpIds: approvers, + }); + + // 결재 워크플로우 시작 (approvers는 이미 EP ID 배열) + 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: approvers, + }) + + debugSuccess('[ReRequestApproval] 재의뢰 결재 요청 성공', { + approvalId: result.approvalId, + pendingActionId: result.pendingActionId, + }); + + if (result.status === 'pending_approval') { + // 성공 시에만 상태 초기화 및 페이지 리로드 + setReRequestData(null) + window.location.reload() + } + } + + // 재실사 요청 처리 + const handleRequestReinspection = async ( + data: SiteVisitRequestFormValues, + attachments?: File[] + ) => { + try { + // 보완-재실사 대상 실사만 필터링 + const supplementReinspectInvestigations = selectedRows.filter(row => + row.original.investigation && + row.original.investigation.evaluationResult === "SUPPLEMENT_REINSPECT" + ); + + if (supplementReinspectInvestigations.length === 0) { + toast.error("보완-재실사 대상 실사가 없습니다."); + return; + } + + // 첫 번째 대상 실사로 재실사 요청 생성 + const targetRow = supplementReinspectInvestigations[0].original; + const targetInvestigation = targetRow.investigation!; + const { requestSupplementReinspectionAction } = await import('@/lib/vendor-investigation/service'); + + // SiteVisitRequestFormValues를 requestSupplementReinspectionAction 형식으로 변환 + // shiAttendees는 그대로 전달 (새로운 형식: {checked, attendees}) + const result = await requestSupplementReinspectionAction({ + investigationId: targetInvestigation.id, + siteVisitData: { + inspectionDuration: data.inspectionDuration, + requestedStartDate: data.requestedStartDate, + requestedEndDate: data.requestedEndDate, + shiAttendees: data.shiAttendees || {}, + vendorRequests: data.vendorRequests || {}, + additionalRequests: data.additionalRequests || "", + }, + }); + + if (result.success) { + toast.success("재실사 요청이 생성되었습니다."); + setIsReinspectionDialogOpen(false); + window.location.reload(); + } else { + toast.error(result.error || "재실사 요청 생성 중 오류가 발생했습니다."); + } + } catch (error) { + console.error("재실사 요청 오류:", error); + toast.error("재실사 요청 중 오류가 발생했습니다."); + } + }; + + // 실사 결과 발송 처리 + const handleSendInvestigationResults = async (data: { purchaseComment?: string }) => { + try { + setIsLoading(true) + + // 완료된 실사 중 승인된 결과 또는 보완된 결과만 필터링 + const approvedInvestigations = selectedRows.filter(row => { + const investigation = row.original.investigation + return investigation && + (investigation.investigationStatus === "COMPLETED" || + investigation.investigationStatus === "SUPPLEMENT_REQUIRED" || + investigation.evaluationResult === "REJECTED") + + }) + + if (approvedInvestigations.length === 0) { + toast.error("발송할 실사 결과가 없습니다. 완료되고 승인된 실사만 결과를 발송할 수 있습니다.") + return + } + + // 서버 액션 호출 + const result = await sendInvestigationResultsAction({ + investigationIds: approvedInvestigations.map(row => row.original.investigation!.id), + purchaseComment: data.purchaseComment, + }) + + if (result.success) { + toast.success(result.message || `${result.data?.successCount || 0}개 업체에 대한 실사 결과가 발송되었습니다.`) + window.location.reload() + } else { + toast.error(result.error || "실사 결과 발송 처리 중 오류가 발생했습니다.") + } + } catch (error) { + console.error("실사 결과 발송 중 오류 발생:", error) + toast.error("실사 결과 발송 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + setIsSendResultsDialogOpen(false) + } + } + + // 승인된 업체 수 확인 (미실사 PQ 제외) + const approvedPQsCount = selectedRows.filter(row => + row.original.status === "APPROVED" && + !row.original.investigation && + row.original.type !== "NON_INSPECTION" + ).length + + // 계획 상태 실사 수 확인 + const plannedInvestigationsCount = selectedRows.filter(row => + row.original.investigation && + row.original.investigation.investigationStatus === "PLANNED" + ).length + + // 완료된 실사 수 확인 (승인된 결과만) + const completedInvestigationsCount = selectedRows.filter(row => + row.original.investigation && + row.original.investigation.investigationStatus === "COMPLETED" && + row.original.investigation.evaluationResult === "APPROVED" + ).length + + // 취소된 실사 수 확인 + const canceledInvestigationsCount = selectedRows.filter(row => + row.original.investigation && + row.original.investigation.investigationStatus === "CANCELED" + ).length + + // 재실사 요청 대상 수 확인 (보완-재실사 결과만) + const reinspectInvestigationsCount = selectedRows.filter(row => + row.original.investigation && + row.original.investigation.evaluationResult === "SUPPLEMENT_REINSPECT" + ).length + + // 재실사 요청 가능 여부 확인 (방문실사평가 또는 제품검사평가만 가능) + const canRequestReinspection = selectedRows.some(row => { + const investigation = row.original.investigation + if (!investigation) return false + if (investigation.evaluationResult !== "SUPPLEMENT_REINSPECT") return false + const method = investigation.investigationMethod + // 서류평가 또는 구매자체평가는 재방문실사 불가 + return method === "SITE_VISIT_EVAL" || method === "PRODUCT_INSPECTION" + }) + + // 미실사 PQ가 선택되었는지 확인 + const hasNonInspectionPQ = selectedRows.some(row => + row.original.type === "NON_INSPECTION" + ) + + // 실사 방법 라벨 변환 함수 + const getInvestigationMethodLabel = (method: string): string => { + switch (method) { + case "PURCHASE_SELF_EVAL": + return "구매자체평가" + case "DOCUMENT_EVAL": + return "서류평가" + case "PRODUCT_INSPECTION": + return "제품검사평가" + case "SITE_VISIT_EVAL": + return "방문실사평가" + default: + return method + } + } + + // 실사 결과 발송용 데이터 준비 + const auditResults = selectedRows + .filter(row => + row.original.investigation && + (row.original.investigation.investigationStatus === "COMPLETED" || row.original.investigation.investigationStatus === "SUPPLEMENT_REQUIRED") && ( + (row.original.investigation.evaluationResult === "APPROVED" || + row.original.investigation.evaluationResult === "SUPPLEMENT" || + row.original.investigation.evaluationResult === "SUPPLEMENT_REINSPECT" || + row.original.investigation.evaluationResult === "SUPPLEMENT_DOCUMENT")) + ) + .map(row => { + const investigation = row.original.investigation! + const pqSubmission = row.original + + // pqItems를 상세하게 포맷팅 (itemCode-itemName 형태로 모든 항목 표시) + const formatAuditItem = (pqItems: any): string => { + if (!pqItems) return pqSubmission.projectName || "N/A"; + + try { + // 이미 파싱된 객체 배열인 경우 + if (Array.isArray(pqItems)) { + return pqItems.map(item => { + if (typeof item === 'string') return item; + if (typeof item === 'object') { + const code = item.itemCode || item.code || ""; + const name = item.itemName || item.name || ""; + if (code && name) return `${code}-${name}`; + return name || code || String(item); + } + return String(item); + }).join(', '); + } + + // JSON 문자열인 경우 + if (typeof pqItems === 'string') { + try { + const parsed = JSON.parse(pqItems); + if (Array.isArray(parsed)) { + return parsed.map(item => { + if (typeof item === 'string') return item; + if (typeof item === 'object') { + const code = item.itemCode || item.code || ""; + const name = item.itemName || item.name || ""; + if (code && name) return `${code}-${name}`; + return name || code || String(item); + } + return String(item); + }).join(', '); + } + return String(parsed); + } catch { + return String(pqItems); + } + } + + // 기타 경우 + return String(pqItems); + } catch { + return pqSubmission.projectName || "N/A"; + } + }; + + return { + id: investigation.id, + vendorCode: row.original.vendorCode || "N/A", + vendorName: row.original.vendorName || "N/A", + vendorEmail: row.original.email || "N/A", + vendorContactPerson: (row.original as any).representativeName || row.original.vendorName || "N/A", + pqNumber: pqSubmission.pqNumber || "N/A", + auditItem: formatAuditItem(pqSubmission.pqItems), + auditFactoryAddress: investigation.investigationAddress || "N/A", + auditMethod: getInvestigationMethodLabel(investigation.investigationMethod || ""), + auditResult: investigation.evaluationResult === "APPROVED" ? "Pass(승인)" : + investigation.evaluationResult === "SUPPLEMENT" ? "Pass(조건부승인)" : + investigation.evaluationResult === "REJECTED" ? "Fail(미승인)" : "N/A", + additionalNotes: investigation.investigationNotes || undefined, + investigationNotes: investigation.investigationNotes || undefined, + } + }) + + return ( + <> +
+ {/* 실사 의뢰 버튼 */} + + + {/* 실사 의뢰 취소 버튼 */} + + + {/* 실사 재의뢰 버튼 */} + + + {/* 재실사 요청 버튼 */} + + + {/* 실사 결과 발송 버튼 */} + + + {/** Export 버튼 */} + +
+ + {/* 실사 의뢰 Dialog */} + + + + {/* 실사 취소 Dialog */} + setIsCancelDialogOpen(false)} + onConfirm={handleCancelInvestigation} + selectedCount={plannedInvestigationsCount} + /> + + {/* 실사 재의뢰 Dialog */} + setIsReRequestDialogOpen(false)} + onConfirm={handleReRequestInvestigation} + selectedCount={canceledInvestigationsCount} + /> + + {/* 결과 발송 Dialog */} + setIsSendResultsDialogOpen(false)} + onConfirm={handleSendInvestigationResults} + selectedCount={completedInvestigationsCount} + auditResults={auditResults} + /> + + {/* 재방문실사 요청 Dialog */} + {(() => { + // 보완-재실사 대상 실사 찾기 + const supplementReinspectInvestigations = selectedRows.filter(row => + row.original.investigation && + row.original.investigation.evaluationResult === "SUPPLEMENT_REINSPECT" + ); + + if (supplementReinspectInvestigations.length === 0) { + return null; + } + + const targetRow = supplementReinspectInvestigations[0].original; + const targetInvestigation = targetRow.investigation!; + + return ( + setIsReinspectionDialogOpen(false)} + onSubmit={handleRequestReinspection} + investigation={{ + id: targetInvestigation.id, + investigationMethod: targetInvestigation.investigationMethod || undefined, + investigationAddress: targetInvestigation.investigationAddress || undefined, + investigationNotes: targetInvestigation.investigationNotes || undefined, + vendorName: targetRow.vendorName, + vendorCode: targetRow.vendorCode, + projectName: targetRow.projectName || undefined, + projectCode: targetRow.projectCode || undefined, + pqItems: targetRow.pqItems || null, + }} + isReinspection={true} + /> + ); + })()} + + {/* 결재 미리보기 Dialog - 실사 의뢰 */} + {session?.user && session.user.epId && investigationFormData && ( + { + 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(', ')}`} + currentUser={{ + id: Number(session.user.id), + epId: session.user.epId, + name: session.user.name || undefined, + email: session.user.email || undefined, + }} + onConfirm={handleApprovalSubmit} + /> + )} + + {/* 결재 미리보기 Dialog - 실사 재의뢰 */} + {session?.user && session.user.epId && reRequestData && ( + { + setIsReRequestApprovalDialogOpen(open) + if (!open) { + // 다이얼로그가 닫히면 재의뢰 데이터도 초기화 + setReRequestData(null) + } + }} + templateName="Vendor 실사 재의뢰" + variables={reRequestApprovalVariables} + title={`Vendor 실사 재의뢰 - ${reRequestData.vendorNames}`} + currentUser={{ + id: Number(session.user.id), + epId: session.user.epId, + name: session.user.name || undefined, + email: session.user.email || undefined, + }} + onConfirm={handleReRequestApprovalSubmit} + /> + )} + + ) } \ No newline at end of file diff --git a/lib/vendor-regular-registrations/handlers.ts b/lib/vendor-regular-registrations/handlers.ts index 95acde23..7490d81a 100644 --- a/lib/vendor-regular-registrations/handlers.ts +++ b/lib/vendor-regular-registrations/handlers.ts @@ -107,7 +107,12 @@ export async function mapRegistrationToTemplateVariables(payload: { const { requestData, requestedAt, vendorId } = payload; // vendors 테이블에서 추가 정보 가져오기 - let vendorInfo: any = {}; + let vendorInfo: { + postalCode?: string | null; + businessSize?: string | null; + addressDetail?: string | null; + } = {}; + if (vendorId) { try { const vendorResult = await db @@ -115,7 +120,6 @@ export async function mapRegistrationToTemplateVariables(payload: { postalCode: vendors.postalCode, businessSize: vendors.businessSize, addressDetail: vendors.addressDetail, - // FAX, 사업유형, 산업유형은 vendors 테이블에 없으므로 빈 값으로 처리 }) .from(vendors) .where(eq(vendors.id, vendorId)) @@ -126,6 +130,7 @@ export async function mapRegistrationToTemplateVariables(payload: { console.warn('[Template Variables] Failed to fetch vendor info:', error); } } + // 추가정보 조회 let additionalInfo = { businessType: '', @@ -137,77 +142,101 @@ export async function mapRegistrationToTemplateVariables(payload: { }; if (vendorId) { - const additionalInfoResult = await db - .select({ - businessType: vendorAdditionalInfo.businessType, - industryType: vendorAdditionalInfo.industryType, - companySize: vendorAdditionalInfo.companySize, - revenue: vendorAdditionalInfo.revenue, - factoryEstablishedDate: vendorAdditionalInfo.factoryEstablishedDate, - preferredContractTerms: vendorAdditionalInfo.preferredContractTerms, - }) - .from(vendorAdditionalInfo) - .where(eq(vendorAdditionalInfo.vendorId, vendorId)) - .limit(1); + try { + const additionalInfoResult = await db + .select({ + businessType: vendorAdditionalInfo.businessType, + industryType: vendorAdditionalInfo.industryType, + companySize: vendorAdditionalInfo.companySize, + revenue: vendorAdditionalInfo.revenue, + factoryEstablishedDate: vendorAdditionalInfo.factoryEstablishedDate, + preferredContractTerms: vendorAdditionalInfo.preferredContractTerms, + }) + .from(vendorAdditionalInfo) + .where(eq(vendorAdditionalInfo.vendorId, vendorId)) + .limit(1); - additionalInfo = additionalInfoResult[0] || additionalInfo; + const info = additionalInfoResult[0]; + if (info) { + additionalInfo = { + businessType: info.businessType ?? '', + industryType: info.industryType ?? '', + companySize: info.companySize ?? '', + revenue: info.revenue ?? '', + factoryEstablishedDate: info.factoryEstablishedDate ?? '', + preferredContractTerms: info.preferredContractTerms ?? '', + }; + } + } catch (error) { + console.warn('[Template Variables] Failed to fetch additional info:', error); + } } console.log('[Template Variables] Additional info:', additionalInfo); + + // 변수명은 공백 없이 깔끔하게 정의 (template-utils.ts에서 자동으로 trim 처리됨) const variables = { - // 협력업체 기본정보 (템플릿의 정확한 변수명 사용) - ' 협력업체 기본정보-사업자번호 ': requestData.businessNumber || '', - ' 협력업체 기본정보-업체명 ': requestData.companyNameKor || '', - ' 협력업체 기본정보-대표자명 ': requestData.representativeNameKor || '', - ' 협력업체 기본정보 대표전화 ': requestData.headOfficePhone || '', - ' 협력업체 기본정보 -FAX ': '', // FAX 정보는 vendors 테이블에 없으므로 빈 문자열 - ' 협력업체 기본정보 -E-mail ': requestData.representativeEmail || '', - ' 협력업체 기본정보-우편번호 ': vendorInfo.postalCode || '', // vendors 테이블에서 우편번호 가져오기 - ' 협력업체 기본정보-회사주소': requestData.headOfficeAddress || '', - ' 협력업체 기본정보-상세주소': vendorInfo.addressDetail || '', // 상세주소는 벤더 상세주소로 - ' 협력업체 기본정보-사업유형': additionalInfo.businessType || '', // 주요품목을 사업유형으로 사용 - ' 협력업체 기본정보-산업유형': additionalInfo.industryType || '', // 주요품목을 산업유형으로도 사용 - ' 협력업체 기본정보-회사규모': additionalInfo.companySize || '', // 기업규모 + // 협력업체 기본정보 + '협력업체기본정보-사업자번호': requestData.businessNumber || '', + '협력업체기본정보-업체명': requestData.companyNameKor || '', + '협력업체기본정보-대표자명': requestData.representativeNameKor || '', + '협력업체기본정보-대표전화': requestData.headOfficePhone || '', + '협력업체기본정보-FAX': '', // FAX 정보는 vendors 테이블에 없으므로 빈 문자열 + '협력업체기본정보-Email': requestData.representativeEmail || '', + '협력업체기본정보-우편번호': vendorInfo.postalCode || '', + '협력업체기본정보-회사주소': requestData.headOfficeAddress || '', + '협력업체기본정보-상세주소': vendorInfo.addressDetail || '', + '협력업체기본정보-사업유형': additionalInfo.businessType || '', + '협력업체기본정보-산업유형': additionalInfo.industryType || '', + '협력업체기본정보-회사규모': additionalInfo.companySize || '', - // 담당자 연락처 (각 담당자별로 동일한 정보 반복 - 템플릿에서 여러 번 사용됨) - ' 협력업체 관리-상세보기-영업담당자-담당자명 ': requestData.businessContacts.sales.name || '', - ' 협력업체 관리-상세보기-영업담당자-직급 ': requestData.businessContacts.sales.position || '', - ' 협력업체 관리-상세보기-영업담당자-부서 ': requestData.businessContacts.sales.department || '', - ' 협력업체 관리-상세보기-영업담당자-담당업무 ': requestData.businessContacts.sales.responsibility || '', - ' 협력업체 관리-상세보기-영업담당자-이메일 ': requestData.businessContacts.sales.email || '', - ' 협력업체 관리-상세보기-설계담당자-담당자명 ': requestData.businessContacts.design.name || '', - ' 협력업체 관리-상세보기-설계담당자-직급 ': requestData.businessContacts.design.position || '', - ' 협력업체 관리-상세보기-설계담당자-부서 ': requestData.businessContacts.design.department || '', - ' 협력업체 관리-상세보기-설계담당자-담당업무 ': requestData.businessContacts.design.responsibility || '', - ' 협력업체 관리-상세보기-설계담당자-이메일 ': requestData.businessContacts.design.email || '', - ' 협력업체 관리-상세보기-납기담당자-담당자명 ': requestData.businessContacts.delivery.name || '', - ' 협력업체 관리-상세보기-납기담당자-직급 ': requestData.businessContacts.delivery.position || '', - ' 협력업체 관리-상세보기-납기담당자-부서 ': requestData.businessContacts.delivery.department || '', - ' 협력업체 관리-상세보기-납기담당자-담당업무 ': requestData.businessContacts.delivery.responsibility || '', - ' 협력업체 관리-상세보기-납기담당자-이메일 ': requestData.businessContacts.delivery.email || '', - ' 협력업체 관리-상세보기-품질담당자-담당자명 ': requestData.businessContacts.quality.name || '', - ' 협력업체 관리-상세보기-품질담당자-직급 ': requestData.businessContacts.quality.position || '', - ' 협력업체 관리-상세보기-품질담당자-부서 ': requestData.businessContacts.quality.department || '', - ' 협력업체 관리-상세보기-품질담당자-담당업무 ': requestData.businessContacts.quality.responsibility || '', - ' 협력업체 관리-상세보기-품질담당자-이메일 ': requestData.businessContacts.quality.email || '', - ' 협력업체 관리-상세보기-세금계산서담당자-담당자명 ': requestData.businessContacts.taxInvoice.name || '', - ' 협력업체 관리-상세보기-세금계산서담당자-직급 ': requestData.businessContacts.taxInvoice.position || '', - ' 협력업체 관리-상세보기-세금계산서담당자-부서 ': requestData.businessContacts.taxInvoice.department || '', - ' 협력업체 관리-상세보기-세금계산서담당자-담당업무 ': requestData.businessContacts.taxInvoice.responsibility || '', - ' 협력업체 관리-상세보기-세금계산서담당자-이메일 ': requestData.businessContacts.taxInvoice.email || '', + // 담당자 연락처 - 영업담당자 + '영업담당자-담당자명': requestData.businessContacts.sales.name || '', + '영업담당자-직급': requestData.businessContacts.sales.position || '', + '영업담당자-부서': requestData.businessContacts.sales.department || '', + '영업담당자-담당업무': requestData.businessContacts.sales.responsibility || '', + '영업담당자-이메일': requestData.businessContacts.sales.email || '', + + // 담당자 연락처 - 설계담당자 + '설계담당자-담당자명': requestData.businessContacts.design.name || '', + '설계담당자-직급': requestData.businessContacts.design.position || '', + '설계담당자-부서': requestData.businessContacts.design.department || '', + '설계담당자-담당업무': requestData.businessContacts.design.responsibility || '', + '설계담당자-이메일': requestData.businessContacts.design.email || '', + + // 담당자 연락처 - 납기담당자 + '납기담당자-담당자명': requestData.businessContacts.delivery.name || '', + '납기담당자-직급': requestData.businessContacts.delivery.position || '', + '납기담당자-부서': requestData.businessContacts.delivery.department || '', + '납기담당자-담당업무': requestData.businessContacts.delivery.responsibility || '', + '납기담당자-이메일': requestData.businessContacts.delivery.email || '', + + // 담당자 연락처 - 품질담당자 + '품질담당자-담당자명': requestData.businessContacts.quality.name || '', + '품질담당자-직급': requestData.businessContacts.quality.position || '', + '품질담당자-부서': requestData.businessContacts.quality.department || '', + '품질담당자-담당업무': requestData.businessContacts.quality.responsibility || '', + '품질담당자-이메일': requestData.businessContacts.quality.email || '', + + // 담당자 연락처 - 세금계산서담당자 + '세금계산서담당자-담당자명': requestData.businessContacts.taxInvoice.name || '', + '세금계산서담당자-직급': requestData.businessContacts.taxInvoice.position || '', + '세금계산서담당자-부서': requestData.businessContacts.taxInvoice.department || '', + '세금계산서담당자-담당업무': requestData.businessContacts.taxInvoice.responsibility || '', + '세금계산서담당자-이메일': requestData.businessContacts.taxInvoice.email || '', - // 기본계약서 현황 (정규업체 등록 시점에는 아직 계약서가 없으므로 빈 값) - '정규업체등록관리-문서현황-계약동의현황-계약유형 ': '정규업체 등록 요청', - '정규업체등록관리-문서현황-계약동의현황-상태 ': '등록 대기', - '정규업체등록관리-문서현황-계약동의현황-서약일자 ': new Date(requestedAt).toLocaleDateString('ko-KR'), + // 기본계약서 현황 + '계약유형': '정규업체 등록 요청', + '계약상태': '등록 대기', + '서약일자': new Date(requestedAt).toLocaleDateString('ko-KR'), }; // 디버깅을 위한 로그 출력 console.log('[Template Variables] Generated variables:', Object.keys(variables)); console.log('[Template Variables] Sample values:', { - companyName: variables[' 협력업체 기본정보-업체명 '], - businessNumber: variables[' 협력업체 기본정보-사업자번호 '], - representative: variables[' 협력업체 기본정보-대표자명 '], + companyName: variables['협력업체기본정보-업체명'], + businessNumber: variables['협력업체기본정보-사업자번호'], + representative: variables['협력업체기본정보-대표자명'], }); return variables; diff --git a/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-toolbar-actions.tsx b/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-toolbar-actions.tsx index f40a41f7..f879f065 100644 --- a/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-toolbar-actions.tsx +++ b/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-toolbar-actions.tsx @@ -1,334 +1,329 @@ -"use client" - -import { type Table } from "@tanstack/react-table" -import { toast } from "sonner" - -import { Button } from "@/components/ui/button" -import { Mail, FileWarning, Scale, FileText } from "lucide-react" -import type { VendorRegularRegistration } from "@/config/vendorRegularRegistrationsColumnsConfig" -import { - sendMissingContractRequestEmails, - sendAdditionalInfoRequestEmails, - skipLegalReview -} from "../service" -import { useState } from "react" -import { SkipReasonDialog } from "@/components/vendor-regular-registrations/skip-reason-dialog" -import { RegistrationRequestDialog } from "@/components/vendor-regular-registrations/registration-request-dialog" -import { ApprovalPreviewDialog } from "@/components/approval/ApprovalPreviewDialog" -import { useRouter } from "next/navigation" -import { useSession } from "next-auth/react" -import { registerVendorWithApproval } from "../approval-actions" -import { mapRegistrationToTemplateVariables } from "../handlers" -import type { RegistrationRequestData } from "@/components/vendor-regular-registrations/registration-request-dialog" - -interface VendorRegularRegistrationsTableToolbarActionsProps { - table: Table -} - -export function VendorRegularRegistrationsTableToolbarActions({ - table, -}: VendorRegularRegistrationsTableToolbarActionsProps) { - const router = useRouter() - const { data: session } = useSession() - - const [syncLoading, setSyncLoading] = useState<{ - missingContract: boolean; - additionalInfo: boolean; - legalSkip: boolean; - registrationRequest: boolean; - }>({ - missingContract: false, - additionalInfo: false, - legalSkip: false, - registrationRequest: false, - }) - - const [skipDialogs, setSkipDialogs] = useState<{ - legalReview: boolean; - }>({ - legalReview: false, - }) - - // 2-step 결재 프로세스를 위한 상태 - const [registrationRequestDialog, setRegistrationRequestDialog] = useState<{ - open: boolean; - registration: VendorRegularRegistration | null; - }>({ - open: false, - registration: null, - }) - - const [approvalDialog, setApprovalDialog] = useState<{ - open: boolean; - registration: VendorRegularRegistration | null; - }>({ - open: false, - registration: null, - }) - - // 결재를 위한 중간 상태 저장 - const [registrationFormData, setRegistrationFormData] = useState(null) - const [approvalVariables, setApprovalVariables] = useState>({}) - - const selectedRows = table.getFilteredSelectedRowModel().rows.map(row => row.original) - - - - const handleSendMissingContractRequest = async () => { - if (selectedRows.length === 0) { - toast.error("이메일을 발송할 업체를 선택해주세요.") - return - } - - setSyncLoading(prev => ({ ...prev, missingContract: true })) - try { - const vendorIds = selectedRows.map(row => row.vendorId) - const result = await sendMissingContractRequestEmails(vendorIds) - - if (result.success) { - toast.success(result.message) - } else { - toast.error(result.error) - } - } catch (error) { - console.error("Error sending missing contract request:", error) - toast.error("누락계약요청 이메일 발송 중 오류가 발생했습니다.") - } finally { - setSyncLoading(prev => ({ ...prev, missingContract: false })) - } - } - - const handleSendAdditionalInfoRequest = async () => { - if (selectedRows.length === 0) { - toast.error("이메일을 발송할 업체를 선택해주세요.") - return - } - - setSyncLoading(prev => ({ ...prev, additionalInfo: true })) - try { - const vendorIds = selectedRows.map(row => row.vendorId) - const result = await sendAdditionalInfoRequestEmails(vendorIds) - - if (result.success) { - toast.success(result.message) - } else { - toast.error(result.error) - } - } catch (error) { - console.error("Error sending additional info request:", error) - toast.error("추가정보요청 이메일 발송 중 오류가 발생했습니다.") - } finally { - setSyncLoading(prev => ({ ...prev, additionalInfo: false })) - } - } - - const handleLegalReviewSkip = async (reason: string) => { - const cpReviewRows = selectedRows.filter(row => row.status === "cp_review"); - if (cpReviewRows.length === 0) { - toast.error("CP검토 상태인 업체를 선택해주세요."); - return; - } - - setSyncLoading(prev => ({ ...prev, legalSkip: true })); - try { - const vendorIds = cpReviewRows.map(row => row.vendorId); - const result = await skipLegalReview(vendorIds, reason); - - if (result.success) { - toast.success(result.message); - router.refresh(); - } else { - toast.error(result.error); - } - } catch (error) { - console.error("Error skipping legal review:", error); - toast.error("법무검토 Skip 처리 중 오류가 발생했습니다."); - } finally { - setSyncLoading(prev => ({ ...prev, legalSkip: false })); - } - }; - - // 등록요청 핸들러 - Step 1: 정보 입력 - const handleRegistrationRequest = () => { - const approvalReadyRows = selectedRows.filter(row => row.status === "approval_ready"); - - if (approvalReadyRows.length === 0) { - toast.error("조건충족 상태의 벤더를 선택해주세요."); - return; - } - - if (approvalReadyRows.length > 1) { - toast.error("정규업체 등록 요청은 한 번에 하나씩만 가능합니다."); - return; - } - - setRegistrationRequestDialog({ - open: true, - registration: approvalReadyRows[0], - }); - }; - - // 등록요청 정보 입력 완료 - Step 1에서 Step 2로 전환 - const handleRegistrationRequestSubmit = async (requestData: RegistrationRequestData) => { - if (!registrationRequestDialog.registration || !session?.user) return; - - try { - // 폼 데이터 저장 - setRegistrationFormData(requestData); - - // 결재 템플릿 변수 생성 - const requestedAt = new Date(); - const variables = await mapRegistrationToTemplateVariables({ - requestData, - requestedAt, - }); - - setApprovalVariables(variables); - - // RegistrationRequestDialog 닫고 ApprovalPreviewDialog 열기 - setRegistrationRequestDialog({ open: false, registration: null }); - setApprovalDialog({ - open: true, - registration: registrationRequestDialog.registration, - }); - } catch (error) { - console.error("결재 준비 중 오류 발생:", error); - toast.error("결재 준비 중 오류가 발생했습니다."); - } - }; - - // 결재 상신 - Step 2: 결재선 선택 후 최종 상신 - const handleApprovalSubmit = async (approvers: any[]) => { - if (!approvalDialog.registration || !registrationFormData || !session?.user) { - toast.error("세션 정보가 없습니다."); - return; - } - - setSyncLoading(prev => ({ ...prev, registrationRequest: true })); - try { - // 결재선에서 EP ID 추출 (상신자 제외) - const approverEpIds = approvers - .filter((line) => line.seq !== "0" && line.epId) - .map((line) => line.epId!); - - // 결재 워크플로우 시작 - const result = await registerVendorWithApproval({ - registrationId: approvalDialog.registration.id, - requestData: registrationFormData, - vendorId: approvalDialog.registration.vendorId, // vendors 테이블에서 정보를 가져오기 위한 vendorId - currentUser: { - id: Number(session.user.id), - epId: session.user.epId || null, - email: session.user.email || undefined, - }, - approvers: approverEpIds, - }); - - if (result.status === 'pending_approval') { - // 성공 시에만 상태 초기화 및 페이지 리로드 - setRegistrationFormData(null); - setApprovalVariables({}); - setApprovalDialog({ open: false, registration: null }); - toast.success("정규업체 등록 결재가 상신되었습니다."); - router.refresh(); - } - } catch (error) { - console.error("결재 상신 중 오류:", error); - toast.error("결재 상신 중 오류가 발생했습니다."); - } finally { - setSyncLoading(prev => ({ ...prev, registrationRequest: false })); - } - }; - - // CP검토 상태인 선택된 행들 개수 - const cpReviewCount = selectedRows.filter(row => row.status === "cp_review").length; - - // 조건충족 상태인 선택된 행들 개수 - const approvalReadyCount = selectedRows.filter(row => row.status === "approval_ready").length; - - return ( -
- - - - - - - - - setSkipDialogs(prev => ({ ...prev, legalReview: open }))} - title="GTC Skip" - description={`선택된 ${cpReviewCount}개 업체의 GTC를 Skip하고 CP완료 상태로 변경합니다. Skip 사유를 입력해주세요.`} - onConfirm={handleLegalReviewSkip} - loading={syncLoading.legalSkip} - /> - - setRegistrationRequestDialog(prev => ({ ...prev, open }))} - registration={registrationRequestDialog.registration} - onSubmit={handleRegistrationRequestSubmit} - /> - - {/* 결재 미리보기 Dialog - 정규업체 등록 */} - {session?.user && approvalDialog.registration && ( - { - setApprovalDialog(prev => ({ ...prev, open })); - if (!open) { - // 다이얼로그가 닫히면 폼 데이터도 초기화 - setRegistrationFormData(null); - setApprovalVariables({}); - } - }} - templateName="정규업체 등록" - variables={approvalVariables} - title={`정규업체 등록 - ${approvalDialog.registration.companyName}`} - description={`${approvalDialog.registration.companyName} 정규업체 등록 요청`} - currentUser={{ - id: Number(session.user.id), - epId: session.user.epId || null, - name: session.user.name || null, - email: session.user.email || '', - }} - onSubmit={handleApprovalSubmit} - /> - )} -
- ) -} +"use client" + +import { type Table } from "@tanstack/react-table" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { Mail, FileWarning, Scale, FileText } from "lucide-react" +import type { VendorRegularRegistration } from "@/config/vendorRegularRegistrationsColumnsConfig" +import { + sendMissingContractRequestEmails, + sendAdditionalInfoRequestEmails, + skipLegalReview +} from "../service" +import { useState } from "react" +import { SkipReasonDialog } from "@/components/vendor-regular-registrations/skip-reason-dialog" +import { RegistrationRequestDialog } from "@/components/vendor-regular-registrations/registration-request-dialog" +import { ApprovalPreviewDialog } from "@/lib/approval/approval-preview-dialog" +import { useRouter } from "next/navigation" +import { useSession } from "next-auth/react" +import { registerVendorWithApproval } from "../approval-actions" +import { mapRegistrationToTemplateVariables } from "../handlers" +import type { RegistrationRequestData } from "@/components/vendor-regular-registrations/registration-request-dialog" + +interface VendorRegularRegistrationsTableToolbarActionsProps { + table: Table +} + +export function VendorRegularRegistrationsTableToolbarActions({ + table, +}: VendorRegularRegistrationsTableToolbarActionsProps) { + const router = useRouter() + const { data: session } = useSession() + + const [syncLoading, setSyncLoading] = useState<{ + missingContract: boolean; + additionalInfo: boolean; + legalSkip: boolean; + registrationRequest: boolean; + }>({ + missingContract: false, + additionalInfo: false, + legalSkip: false, + registrationRequest: false, + }) + + const [skipDialogs, setSkipDialogs] = useState<{ + legalReview: boolean; + }>({ + legalReview: false, + }) + + // 2-step 결재 프로세스를 위한 상태 + const [registrationRequestDialog, setRegistrationRequestDialog] = useState<{ + open: boolean; + registration: VendorRegularRegistration | null; + }>({ + open: false, + registration: null, + }) + + const [approvalDialog, setApprovalDialog] = useState<{ + open: boolean; + registration: VendorRegularRegistration | null; + }>({ + open: false, + registration: null, + }) + + // 결재를 위한 중간 상태 저장 + const [registrationFormData, setRegistrationFormData] = useState(null) + const [approvalVariables, setApprovalVariables] = useState>({}) + + const selectedRows = table.getFilteredSelectedRowModel().rows.map(row => row.original) + + + + const handleSendMissingContractRequest = async () => { + if (selectedRows.length === 0) { + toast.error("이메일을 발송할 업체를 선택해주세요.") + return + } + + setSyncLoading(prev => ({ ...prev, missingContract: true })) + try { + const vendorIds = selectedRows.map(row => row.vendorId) + const result = await sendMissingContractRequestEmails(vendorIds) + + if (result.success) { + toast.success(result.message) + } else { + toast.error(result.error) + } + } catch (error) { + console.error("Error sending missing contract request:", error) + toast.error("누락계약요청 이메일 발송 중 오류가 발생했습니다.") + } finally { + setSyncLoading(prev => ({ ...prev, missingContract: false })) + } + } + + const handleSendAdditionalInfoRequest = async () => { + if (selectedRows.length === 0) { + toast.error("이메일을 발송할 업체를 선택해주세요.") + return + } + + setSyncLoading(prev => ({ ...prev, additionalInfo: true })) + try { + const vendorIds = selectedRows.map(row => row.vendorId) + const result = await sendAdditionalInfoRequestEmails(vendorIds) + + if (result.success) { + toast.success(result.message) + } else { + toast.error(result.error) + } + } catch (error) { + console.error("Error sending additional info request:", error) + toast.error("추가정보요청 이메일 발송 중 오류가 발생했습니다.") + } finally { + setSyncLoading(prev => ({ ...prev, additionalInfo: false })) + } + } + + const handleLegalReviewSkip = async (reason: string) => { + const cpReviewRows = selectedRows.filter(row => row.status === "cp_review"); + if (cpReviewRows.length === 0) { + toast.error("CP검토 상태인 업체를 선택해주세요."); + return; + } + + setSyncLoading(prev => ({ ...prev, legalSkip: true })); + try { + const vendorIds = cpReviewRows.map(row => row.vendorId); + const result = await skipLegalReview(vendorIds, reason); + + if (result.success) { + toast.success(result.message); + router.refresh(); + } else { + toast.error(result.error); + } + } catch (error) { + console.error("Error skipping legal review:", error); + toast.error("법무검토 Skip 처리 중 오류가 발생했습니다."); + } finally { + setSyncLoading(prev => ({ ...prev, legalSkip: false })); + } + }; + + // 등록요청 핸들러 - Step 1: 정보 입력 + const handleRegistrationRequest = () => { + const approvalReadyRows = selectedRows.filter(row => row.status === "approval_ready"); + + if (approvalReadyRows.length === 0) { + toast.error("조건충족 상태의 벤더를 선택해주세요."); + return; + } + + if (approvalReadyRows.length > 1) { + toast.error("정규업체 등록 요청은 한 번에 하나씩만 가능합니다."); + return; + } + + setRegistrationRequestDialog({ + open: true, + registration: approvalReadyRows[0], + }); + }; + + // 등록요청 정보 입력 완료 - Step 1에서 Step 2로 전환 + const handleRegistrationRequestSubmit = async (requestData: RegistrationRequestData) => { + if (!registrationRequestDialog.registration || !session?.user) return; + + try { + // 폼 데이터 저장 + setRegistrationFormData(requestData); + + // 결재 템플릿 변수 생성 (vendorId 포함) + const requestedAt = new Date(); + const variables = await mapRegistrationToTemplateVariables({ + requestData, + requestedAt, + vendorId: registrationRequestDialog.registration.vendorId, // vendors 테이블에서 추가 정보 가져오기 + }); + + setApprovalVariables(variables); + + // RegistrationRequestDialog 닫고 ApprovalPreviewDialog 열기 + setRegistrationRequestDialog({ open: false, registration: null }); + setApprovalDialog({ + open: true, + registration: registrationRequestDialog.registration, + }); + } catch (error) { + console.error("결재 준비 중 오류 발생:", error); + toast.error("결재 준비 중 오류가 발생했습니다."); + } + }; + + // 결재 상신 - Step 2: 결재선 선택 후 최종 상신 + const handleApprovalSubmit = async ({ approvers, title, attachments }: { approvers: string[], title: string, attachments?: File[] }) => { + if (!approvalDialog.registration || !registrationFormData || !session?.user) { + toast.error("세션 정보가 없습니다."); + return; + } + + setSyncLoading(prev => ({ ...prev, registrationRequest: true })); + try { + // 결재 워크플로우 시작 (approvers는 이미 EP ID 배열) + const result = await registerVendorWithApproval({ + registrationId: approvalDialog.registration.id, + requestData: registrationFormData, + vendorId: approvalDialog.registration.vendorId, // vendors 테이블에서 정보를 가져오기 위한 vendorId + currentUser: { + id: Number(session.user.id), + epId: session.user.epId || null, + email: session.user.email || undefined, + }, + approvers: approvers, + }); + + if (result.status === 'pending_approval') { + // 성공 시에만 상태 초기화 및 페이지 리로드 + setRegistrationFormData(null); + setApprovalVariables({}); + setApprovalDialog({ open: false, registration: null }); + toast.success("정규업체 등록 결재가 상신되었습니다."); + router.refresh(); + } + } catch (error) { + console.error("결재 상신 중 오류:", error); + toast.error("결재 상신 중 오류가 발생했습니다."); + } finally { + setSyncLoading(prev => ({ ...prev, registrationRequest: false })); + } + }; + + // CP검토 상태인 선택된 행들 개수 + const cpReviewCount = selectedRows.filter(row => row.status === "cp_review").length; + + // 조건충족 상태인 선택된 행들 개수 + const approvalReadyCount = selectedRows.filter(row => row.status === "approval_ready").length; + + return ( +
+ + + + + + + + + setSkipDialogs(prev => ({ ...prev, legalReview: open }))} + title="GTC Skip" + description={`선택된 ${cpReviewCount}개 업체의 GTC를 Skip하고 CP완료 상태로 변경합니다. Skip 사유를 입력해주세요.`} + onConfirm={handleLegalReviewSkip} + loading={syncLoading.legalSkip} + /> + + setRegistrationRequestDialog(prev => ({ ...prev, open }))} + registration={registrationRequestDialog.registration} + onSubmit={handleRegistrationRequestSubmit} + /> + + {/* 결재 미리보기 Dialog - 정규업체 등록 */} + {session?.user && session.user.epId && approvalDialog.registration && ( + { + setApprovalDialog(prev => ({ ...prev, open })); + if (!open) { + // 다이얼로그가 닫히면 폼 데이터도 초기화 + setRegistrationFormData(null); + setApprovalVariables({}); + } + }} + templateName="정규업체 등록" + variables={approvalVariables} + title={`정규업체 등록 - ${approvalDialog.registration.companyName}`} + currentUser={{ + id: Number(session.user.id), + epId: session.user.epId, + name: session.user.name || undefined, + email: session.user.email || undefined, + }} + onConfirm={handleApprovalSubmit} + /> + )} +
+ ) +} -- cgit v1.2.3