diff options
| author | joonhoekim <26rote@gmail.com> | 2025-11-07 12:01:16 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-11-07 12:01:16 +0900 |
| commit | 18ca4ad784aeeab9ab7a13bbc8b3c13b42ca5e49 (patch) | |
| tree | 6faf1a05d1ae296202ece5f4ca95b4d9c7a0488b | |
| parent | 4b6ebdef8281a413fa2bfbdf8f5565eb8b106c62 (diff) | |
(김준회) 결재 미리보기 공통컴포넌트 중복 제거, 기존 코드의 미리보기 호출부 수정, 템플릿 작성 가이드 간략히 추가, 결재 미리보기시 첨부파일 편집 처리
| -rw-r--r-- | components/approval/ApprovalPreviewDialog.tsx | 213 | ||||
| -rw-r--r-- | lib/approval/README.md | 478 | ||||
| -rw-r--r-- | lib/approval/approval-preview-dialog.tsx | 156 | ||||
| -rw-r--r-- | lib/approval/template-utils.ts | 23 | ||||
| -rw-r--r-- | lib/approval/templates/README.md | 4 | ||||
| -rw-r--r-- | lib/approval/templates/정규업체 등록.html | 877 | ||||
| -rw-r--r-- | lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx | 1819 | ||||
| -rw-r--r-- | lib/vendor-regular-registrations/handlers.ts | 151 | ||||
| -rw-r--r-- | lib/vendor-regular-registrations/table/vendor-regular-registrations-table-toolbar-actions.tsx | 663 |
9 files changed, 2810 insertions, 1574 deletions
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<string, string>; - title: string; - description?: string; - currentUser: { id: number; epId: string | null; name?: string | null; email?: string }; - onSubmit: (approvers: ApprovalLineItem[]) => Promise<void>; -} - -export function ApprovalPreviewDialog({ - open, - onOpenChange, - templateName, - variables, - title, - description, - currentUser, - onSubmit, -}: ApprovalPreviewDialogProps) { - const [approvalLines, setApprovalLines] = React.useState<ApprovalLineItem[]>([]); - const [isSubmitting, setIsSubmitting] = React.useState(false); - const [templateContent, setTemplateContent] = React.useState<string>(""); - const [isLoadingTemplate, setIsLoadingTemplate] = React.useState(false); - const [templateError, setTemplateError] = React.useState<string | null>(null); - - // 상신자 초기화 - React.useEffect(() => { - if (open && currentUser.epId) { - const drafterLine: ApprovalLineItem = { - id: `drafter-${Date.now()}`, - epId: currentUser.epId, - userId: currentUser.id.toString(), - emailAddress: currentUser.email, - name: currentUser.name || undefined, - role: "0", // 기안 - seq: "0", - opinion: "", - }; - setApprovalLines([drafterLine]); - } - }, [open, currentUser]); - - // 템플릿 로드 및 변수 치환 - React.useEffect(() => { - if (!open) return; - - const loadTemplate = async () => { - setIsLoadingTemplate(true); - setTemplateError(null); - - try { - const template = await getApprovalTemplateByName(templateName); - - if (!template) { - setTemplateError(`템플릿 "${templateName}"을 찾을 수 없습니다.`); - setTemplateContent(`<p class="text-gray-500">${description || "결재 요청"}</p>`); - } else { - // 변수 치환 - const replaced = await replaceTemplateVariables(template.content, variables); - setTemplateContent(replaced); - } - } catch (error) { - console.error("Template load error:", error); - setTemplateError("템플릿을 불러오는 중 오류가 발생했습니다."); - setTemplateContent(`<p class="text-gray-500">${description || "결재 요청"}</p>`); - } finally { - setIsLoadingTemplate(false); - } - }; - - loadTemplate(); - }, [open, templateName, variables, description]); - - const handleSubmit = async () => { - debugLog('[ApprovalPreviewDialog] 결재 제출 시작', { - templateName, - approvalLineCount: approvalLines.length, - }); - - // 결재자가 있는지 확인 (상신자 제외) - const approvers = approvalLines.filter((line) => line.seq !== "0"); - if (approvers.length === 0) { - debugError('[ApprovalPreviewDialog] 결재자가 없음'); - toast.error("결재자를 최소 1명 이상 추가해주세요."); - return; - } - - setIsSubmitting(true); - try { - debugLog('[ApprovalPreviewDialog] onSubmit 호출', { - approversCount: approvers.length, - }); - - await onSubmit(approvalLines); - - debugSuccess('[ApprovalPreviewDialog] 결재 요청 성공'); - toast.success("결재가 성공적으로 요청되었습니다."); - onOpenChange(false); - } catch (error) { - debugError('[ApprovalPreviewDialog] 결재 요청 실패', error); - const errorMessage = error instanceof Error ? error.message : "결재 요청에 실패했습니다."; - toast.error(errorMessage); - // 에러 발생 시 dialog를 닫지 않음 - } finally { - setIsSubmitting(false); - } - }; - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="max-w-5xl max-h-[90vh] overflow-y-auto"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - <FileText className="w-5 h-5" /> - {title} - </DialogTitle> - {description && <DialogDescription>{description}</DialogDescription>} - </DialogHeader> - - <div className="space-y-6"> - - {/* 결재선 선택 */} - <div> - <ApprovalLineSelector - value={approvalLines} - onChange={setApprovalLines} - placeholder="결재자를 검색하세요..." - maxSelections={10} - /> - </div> - - <Separator /> - - {/* 템플릿 미리보기 */} - <Card> - <CardHeader> - <CardTitle>결재 내용 미리보기</CardTitle> - <CardDescription> - 템플릿: <span className="font-mono text-sm">{templateName}</span> - </CardDescription> - </CardHeader> - <CardContent> - {isLoadingTemplate ? ( - <div className="flex items-center justify-center py-8"> - <Loader2 className="w-6 h-6 animate-spin text-gray-400" /> - <span className="ml-2 text-gray-500">템플릿을 불러오는 중...</span> - </div> - ) : templateError ? ( - <div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg"> - <div className="flex items-center gap-2 text-yellow-700"> - <AlertCircle className="w-4 h-4" /> - <span className="font-medium">경고</span> - </div> - <p className="text-sm text-yellow-600 mt-1">{templateError}</p> - <p className="text-xs text-yellow-500 mt-2"> - 기본 내용으로 대체되었습니다. 결재는 정상적으로 진행됩니다. - </p> - </div> - ) : null} - - <div - className="border rounded-lg p-4 min-h-[200px] [&_table]:w-full [&_table]:border-collapse [&_table]:border [&_table]:border-border [&_th]:border [&_th]:border-border [&_th]:bg-muted/50 [&_th]:px-3 [&_th]:py-2 [&_th]:text-left [&_th]:font-semibold [&_td]:border [&_td]:border-border [&_td]:px-3 [&_td]:py-2 [&_tr:hover]:bg-muted/30" - dangerouslySetInnerHTML={{ __html: templateContent }} - /> - </CardContent> - </Card> - - </div> - - <DialogFooter className="gap-2"> - <Button - type="button" - variant="outline" - onClick={() => onOpenChange(false)} - disabled={isSubmitting} - > - 취소 - </Button> - <Button type="button" onClick={handleSubmit} disabled={isSubmitting || isLoadingTemplate}> - {isSubmitting && <Loader2 className="mr-2 w-4 h-4 animate-spin" />} - 결재 요청 - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - ); -} - diff --git a/lib/approval/README.md b/lib/approval/README.md index 7e62a1d7..5b3c1839 100644 --- a/lib/approval/README.md +++ b/lib/approval/README.md @@ -585,68 +585,438 @@ async function replaceTemplateVariables( 결재 문서 미리보기 및 결재선 설정 다이얼로그 컴포넌트입니다. +**위치:** `lib/approval/approval-preview-dialog.tsx` + +**Import:** +```typescript +// ⚠️ 클라이언트 컴포넌트는 반드시 /client에서 import +import { ApprovalPreviewDialog } from '@/lib/approval/client'; +``` + +#### Props 인터페이스 + ```typescript interface ApprovalPreviewDialogProps { + // 기본 제어 open: boolean; onOpenChange: (open: boolean) => void; + + // 템플릿 설정 templateName: string; // DB에서 조회할 템플릿 이름 - variables: Record<string, string>; // 템플릿 변수 - title: string; // 결재 제목 - description?: string; // 결재 설명 + variables: Record<string, string>; // 템플릿 변수 ({{변수명}} 형태로 치환) + title: string; // 결재 제목 (초기값) + + // 사용자 정보 currentUser: { - id: number; - epId: string; - name?: string; - email?: string; - deptName?: string; + id: number; // 사용자 DB ID + epId: string; // Knox EP ID (필수) + name?: string; // 사용자 이름 + email?: string; // 이메일 + deptName?: string; // 부서명 }; + + // 결재선 설정 defaultApprovers?: string[]; // 초기 결재선 (EP ID 배열) + + // 콜백 onConfirm: (data: { - approvers: string[]; - title: string; - description?: string; + approvers: string[]; // 선택된 결재자 EP ID 배열 + title: string; // (수정 가능한) 결재 제목 + attachments?: File[]; // 첨부파일 (enableAttachments가 true일 때) }) => Promise<void>; + + // 옵션 allowTitleEdit?: boolean; // 제목 수정 허용 (기본: true) - allowDescriptionEdit?: boolean; // 설명 수정 허용 (기본: true) + + // 첨부파일 (선택적 기능) + enableAttachments?: boolean; // 첨부파일 UI 활성화 (기본: false) + maxAttachments?: number; // 최대 첨부파일 개수 (기본: 10) + maxFileSize?: number; // 최대 파일 크기 bytes (기본: 100MB) } ``` -**주요 기능:** -- 템플릿 실시간 미리보기 (변수 자동 치환) -- 결재선 선택 UI (ApprovalLineSelector 통합) -- 제목/설명 수정 -- 반응형 디자인 (Desktop: Dialog, Mobile: Drawer) -- 로딩 상태 자동 처리 +#### 주요 기능 + +- ✅ 템플릿 실시간 미리보기 (변수 자동 치환) +- ✅ 결재선 선택 UI (ApprovalLineSelector 통합) +- ✅ 결재 제목 수정 가능 +- ✅ **선택적 첨부파일 업로드** (드래그 앤 드롭) +- ✅ 반응형 디자인 (Desktop: Dialog, Mobile: Drawer) +- ✅ 로딩 상태 자동 처리 +- ✅ 유효성 검사 및 에러 핸들링 -**사용 예시:** +#### 사용 예시 1: 기본 사용 (첨부파일 없이) ```typescript -// ⚠️ 클라이언트 컴포넌트는 반드시 /client에서 import +'use client'; + +import { useState } from 'react'; import { ApprovalPreviewDialog } from '@/lib/approval/client'; +import { useSession } from 'next-auth/react'; +import { toast } from 'sonner'; -<ApprovalPreviewDialog - open={showPreview} - onOpenChange={setShowPreview} - templateName="벤더 가입 승인 요청" - variables={{ - '업체명': 'ABC 협력업체', - '담당자': '홍길동', - '요청일': '2024-11-06', - }} - title="협력업체 가입 승인" - description="ABC 협력업체의 가입을 승인합니다." - currentUser={{ - id: 1, - epId: 'EP001', - name: '김철수', - email: 'kim@example.com', - }} - onConfirm={async ({ approvers, title, description }) => { - await submitApproval(approvers); - }} -/> +export function VendorApprovalButton({ vendorData }) { + const { data: session } = useSession(); + const [showPreview, setShowPreview] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + + const handleOpenPreview = async () => { + // 1. 템플릿 변수 준비 + const variables = { + '업체명': vendorData.companyName, + '사업자번호': vendorData.businessNumber, + '담당자': vendorData.contactPerson, + '요청일': new Date().toLocaleDateString('ko-KR'), + '요청사유': vendorData.reason, + }; + + // 2. 미리보기 열기 + setShowPreview(true); + }; + + const handleConfirm = async ({ approvers, title, attachments }) => { + try { + setIsSubmitting(true); + + // 3. 실제 결재 상신 API 호출 + const result = await fetch('/api/vendor/approval', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + vendorId: vendorData.id, + approvers, + title, + }), + }); + + if (result.ok) { + toast.success('결재가 성공적으로 상신되었습니다.'); + setShowPreview(false); + } + } catch (error) { + toast.error('결재 상신에 실패했습니다.'); + } finally { + setIsSubmitting(false); + } + }; + + // EP ID 없으면 버튼 비활성화 + if (!session?.user?.epId) { + return ( + <Button disabled> + 결재 요청 (Knox EP ID 필요) + </Button> + ); + } + + return ( + <> + <Button onClick={handleOpenPreview}> + 정규업체 등록 결재 요청 + </Button> + + {showPreview && ( + <ApprovalPreviewDialog + open={showPreview} + onOpenChange={setShowPreview} + templateName="정규업체 등록" + variables={{ + '업체명': vendorData.companyName, + '사업자번호': vendorData.businessNumber, + '담당자': vendorData.contactPerson, + '요청일': new Date().toLocaleDateString('ko-KR'), + }} + title={`정규업체 등록 - ${vendorData.companyName}`} + currentUser={{ + id: Number(session.user.id), + epId: session.user.epId, + name: session.user.name || undefined, + email: session.user.email || undefined, + }} + onConfirm={handleConfirm} + /> + )} + </> + ); +} +``` + +#### 사용 예시 2: 첨부파일 포함 + +```typescript +'use client'; + +import { useState } from 'react'; +import { ApprovalPreviewDialog } from '@/lib/approval/client'; +import { submitApproval } from '@/lib/knox-api/approval/approval'; + +export function ContractApprovalButton({ contractData }) { + const { data: session } = useSession(); + const [showPreview, setShowPreview] = useState(false); + + const handleConfirm = async ({ approvers, title, attachments }) => { + console.log('결재자:', approvers.length, '명'); + console.log('첨부파일:', attachments?.length || 0, '개'); + + // Knox API로 결재 상신 (첨부파일 포함) + const approvalData = { + // 기본 정보 + subject: title, + contents: `<div>계약서 검토를 요청합니다.</div>`, + contentsType: 'HTML', + + // 결재선 설정 + aplns: approvers.map((epId, index) => ({ + epId: epId, // Knox EP ID + seq: index.toString(), // 결재 순서 (0부터 시작) + role: '1', // 역할: 0=기안, 1=결재, 2=합의, 3=참조 + aplnStatsCode: '0', // 결재 상태: 0=대기 + arbPmtYn: 'Y', // 임의승인 허용 + contentsMdfyPmtYn: 'Y', // 내용수정 허용 + aplnMdfyPmtYn: 'Y', // 결재선수정 허용 + opinion: '', // 의견 + })), + + // 첨부파일 + attachments: attachments || [], // File[] 배열 + + // 문서 설정 + docSecuType: 'PERSONAL', // 보안등급: PERSONAL, COMPANY, SECRET + notifyOption: '0', // 알림옵션: 0=전체, 1=결재완료, 2=없음 + urgYn: 'N', // 긴급여부 + docMngSaveCode: '0', // 문서관리저장코드 + sbmLang: 'ko', // 언어 + }; + + // Knox API 호출 + const result = await submitApproval(approvalData, { + userId: session.user.id.toString(), + epId: session.user.epId!, + emailAddress: session.user.email || '', + }); + + if (result.success) { + toast.success(`결재가 상신되었습니다. (ID: ${result.approvalId})`); + } + }; + + if (!session?.user?.epId) return null; + + return ( + <> + <Button onClick={() => setShowPreview(true)}> + 계약서 검토 결재 요청 + </Button> + + <ApprovalPreviewDialog + open={showPreview} + onOpenChange={setShowPreview} + templateName="계약서 검토" + variables={{ + '계약명': contractData.title, + '계약일': contractData.date, + '계약업체': contractData.vendor, + '계약금액': contractData.amount.toLocaleString(), + }} + title={`계약서 검토 - ${contractData.title}`} + currentUser={{ + id: Number(session.user.id), + epId: session.user.epId, + name: session.user.name || undefined, + email: session.user.email || undefined, + }} + onConfirm={handleConfirm} + // 첨부파일 기능 활성화 + enableAttachments={true} + maxAttachments={5} + maxFileSize={50 * 1024 * 1024} // 50MB + /> + </> + ); +} +``` + +#### Knox API 연동: approvalData 객체 상세 + +Knox 결재 API (`submitApproval`)에 전달하는 전체 객체 구조: + +```typescript +interface KnoxApprovalData { + // ===== 기본 정보 ===== + subject: string; // 결재 제목 (필수) + contents: string; // 결재 내용 HTML (필수) + contentsType: 'HTML' | 'TEXT'; // 내용 타입 (기본: 'HTML') + + // ===== 결재선 설정 ===== + aplns: Array<{ + epId: string; // Knox EP ID (필수) + seq: string; // 결재 순서 "0", "1", "2"... (필수) + role: string; // 역할 (필수) + // "0" = 기안자 + // "1" = 결재자 + // "2" = 합의자 + // "3" = 참조자 + aplnStatsCode: string; // 결재 상태 (필수) + // "0" = 대기 + // "1" = 진행중 + // "2" = 승인 + // "3" = 반려 + arbPmtYn: 'Y' | 'N'; // 임의승인 허용 (기본: 'Y') + contentsMdfyPmtYn: 'Y' | 'N'; // 내용수정 허용 (기본: 'Y') + aplnMdfyPmtYn: 'Y' | 'N'; // 결재선수정 허용 (기본: 'Y') + opinion?: string; // 결재 의견 (선택) + }>; + + // ===== 첨부파일 ===== + attachments?: File[]; // File 객체 배열 (선택) + + // ===== 문서 설정 ===== + docSecuType?: string; // 보안등급 (기본: 'PERSONAL') + // 'PERSONAL' = 개인 + // 'COMPANY' = 회사 + // 'SECRET' = 비밀 + notifyOption?: string; // 알림옵션 (기본: '0') + // '0' = 전체 알림 + // '1' = 결재완료 알림 + // '2' = 알림 없음 + urgYn?: 'Y' | 'N'; // 긴급여부 (기본: 'N') + docMngSaveCode?: string; // 문서관리저장코드 (기본: '0') + // '0' = 개인문서함 + // '1' = 부서문서함 + sbmLang?: 'ko' | 'en'; // 언어 (기본: 'ko') + + // ===== 추가 옵션 ===== + comment?: string; // 기안 의견 (선택) + refLineYn?: 'Y' | 'N'; // 참조선 사용여부 (기본: 'N') + agreementLineYn?: 'Y' | 'N'; // 합의선 사용여부 (기본: 'N') +} + +// 사용자 정보 +interface KnoxUserInfo { + userId: string; // 사용자 ID (필수) + epId: string; // Knox EP ID (필수) + emailAddress: string; // 이메일 (필수) +} +``` + +#### 전체 연동 예시 (Server Action) + +```typescript +// app/api/vendor/approval/route.ts +'use server'; + +import { ApprovalSubmissionSaga } from '@/lib/approval'; +import { mapVendorToTemplateVariables } from '@/lib/vendors/handlers'; + +export async function submitVendorApproval(data: { + vendorId: number; + approvers: string[]; + title: string; + attachments?: File[]; +}) { + // 1. 템플릿 변수 매핑 + const vendor = await getVendorById(data.vendorId); + const variables = await mapVendorToTemplateVariables(vendor); + + // 2. Saga로 결재 상신 + const saga = new ApprovalSubmissionSaga( + 'vendor_registration', // 핸들러 타입 + { + vendorId: data.vendorId, + // 필요한 payload 데이터 + }, + { + title: data.title, + templateName: '정규업체 등록', + variables, + approvers: data.approvers, // EP ID 배열 + currentUser: { + id: user.id, + epId: user.epId, + email: user.email, + }, + } + ); + + return await saga.execute(); +} ``` +#### 첨부파일 처리 + +첨부파일은 Knox API 내부에서 자동으로 FormData로 변환되어 전송됩니다: + +```typescript +// lib/knox-api/approval/approval.ts 내부 (참고용) +export async function submitApproval( + approvalData: KnoxApprovalData, + userInfo: KnoxUserInfo +) { + const formData = new FormData(); + + // 기본 데이터를 JSON으로 직렬화 + formData.append('data', JSON.stringify({ + subject: approvalData.subject, + contents: approvalData.contents, + aplns: approvalData.aplns, + // ... 기타 필드 + })); + + // 첨부파일 추가 + if (approvalData.attachments) { + approvalData.attachments.forEach((file, index) => { + formData.append(`attachment_${index}`, file); + }); + } + + // Knox API 호출 + const response = await fetch(KNOX_APPROVAL_API_URL, { + method: 'POST', + headers: { + 'X-User-Id': userInfo.userId, + 'X-EP-Id': userInfo.epId, + }, + body: formData, + }); + + return response.json(); +} +``` + +#### 주의사항 + +1. **EP ID 필수 체크** + ```typescript + if (!session?.user?.epId) { + return <Button disabled>Knox EP ID 필요</Button>; + } + ``` + +2. **onConfirm 에러 핸들링** + ```typescript + const handleConfirm = async ({ approvers, title, attachments }) => { + try { + await submitApproval(...); + toast.success('성공'); + } catch (error) { + toast.error('실패'); + // ⚠️ throw하지 않으면 다이얼로그가 자동으로 닫힘 + // 에러 시 다이얼로그를 열어두려면 throw 필요 + throw error; + } + }; + ``` + +3. **첨부파일 제한** + - 기본값: 최대 10개, 파일당 100MB + - `maxAttachments`, `maxFileSize`로 커스터마이징 가능 + - 중복 파일 자동 필터링 + +4. **반응형 UI** + - Desktop (≥768px): Dialog 형태 + - Mobile (<768px): Drawer 형태 + - 자동으로 화면 크기에 맞게 렌더링 + ### 캐시 관리 ```typescript @@ -670,9 +1040,9 @@ async function revalidateAllApprovalCaches(): Promise<void> ``` lib/approval/ ├── approval-saga.ts # Saga 클래스 (메인 로직) [서버] -│ ├── ApprovalSubmissionSaga # 결재 상신 -│ ├── ApprovalExecutionSaga # 액션 실행 -│ └── ApprovalRejectionSaga # 반려 처리 +│ ├── ApprovalSubmissionSaga # 결재 상신 (7단계) +│ ├── ApprovalExecutionSaga # 액션 실행 (7단계) +│ └── ApprovalRejectionSaga # 반려 처리 (4단계) │ ├── approval-workflow.ts # 핸들러 레지스트리 [서버] │ ├── registerActionHandler() @@ -695,7 +1065,7 @@ lib/approval/ │ └── htmlDescriptionList() │ ├── approval-preview-dialog.tsx # 결재 미리보기 다이얼로그 [클라이언트] -│ └── ApprovalPreviewDialog # 템플릿 미리보기 + 결재선 설정 +│ └── ApprovalPreviewDialog # 템플릿 미리보기 + 결재선 설정 + 첨부파일 │ ├── cache-utils.ts # 캐시 관리 [서버] │ ├── revalidateApprovalLogs() @@ -709,8 +1079,13 @@ lib/approval/ │ ├── index.ts # 서버 전용 API Export ├── client.ts # 클라이언트 컴포넌트 Export ⚠️ +│ └── ApprovalPreviewDialog # ← 클라이언트에서는 이 파일에서 import │ -└── README.md # 이 문서 +├── README.md # 이 문서 (전체 시스템 가이드) +├── SAGA_PATTERN.md # Saga 패턴 상세 설명 +├── CRONJOB_CONTEXT_FIX.md # Request Context 문제 해결 가이드 +├── USAGE_PATTERN_ANALYSIS.md # 실제 사용 패턴 분석 +└── ARCHITECTURE_REVIEW.md # 아키텍처 평가 ``` ### Import 경로 가이드 @@ -1371,6 +1746,17 @@ setShowPreview(true); ## 📝 변경 이력 +### 2024-11-07 - ApprovalPreviewDialog 컴포넌트 통합 및 첨부파일 기능 추가 +- ✅ 중복 컴포넌트 통합: `components/approval/ApprovalPreviewDialog.tsx` → `lib/approval/approval-preview-dialog.tsx`로 일원화 +- ✅ 선택적 첨부파일 업로드 기능 추가 (`enableAttachments`, `maxAttachments`, `maxFileSize` props) +- ✅ 파일 드래그 앤 드롭 UI 구현 (Dropzone, FileList 컴포넌트 활용) +- ✅ 첨부파일 유효성 검사 (크기 제한, 개수 제한, 중복 파일 필터링) +- ✅ API 인터페이스 개선: `onSubmit` → `onConfirm`으로 변경 +- ✅ 콜백 시그니처 변경: `(approvers: ApprovalLineItem[])` → `({ approvers: string[], title: string, attachments?: File[] })` +- ✅ Knox API 연동 상세 가이드 추가 (approvalData 객체 전체 필드 문서화) +- ✅ 기존 사용처 업데이트 (vendor-regular-registrations, pq-review-table-new) +- ✅ README 상세화: 사용 예시, Props 인터페이스, 주의사항 등 보완 + ### 2024-11-06 - Request Context 호환성 개선 (RFQ 발송) - ✅ Cronjob 환경에서 Request Context 오류 해결 - ✅ `headers()`, `getServerSession()` 호출 문제 수정 diff --git a/lib/approval/approval-preview-dialog.tsx b/lib/approval/approval-preview-dialog.tsx index a91e146c..8bb7ba0f 100644 --- a/lib/approval/approval-preview-dialog.tsx +++ b/lib/approval/approval-preview-dialog.tsx @@ -25,6 +25,29 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ScrollArea } from "@/components/ui/scroll-area"; import { useMediaQuery } from "@/hooks/use-media-query"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Paperclip } from "lucide-react"; +import { Separator } from "@/components/ui/separator"; +import prettyBytes from "pretty-bytes"; +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone"; +import { + FileList, + FileListAction, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, + FileListSize, +} from "@/components/ui/file-list"; import { ApprovalLineSelector, @@ -63,9 +86,16 @@ export interface ApprovalPreviewDialogProps { onConfirm: (data: { approvers: string[]; title: string; + attachments?: File[]; }) => Promise<void>; /** 제목 수정 가능 여부 (기본: true) */ allowTitleEdit?: boolean; + /** 첨부파일 UI 활성화 여부 (기본: false) */ + enableAttachments?: boolean; + /** 최대 첨부파일 개수 (기본: 10) */ + maxAttachments?: number; + /** 최대 파일 크기 (기본: 100MB) */ + maxFileSize?: number; } /** @@ -102,6 +132,9 @@ export function ApprovalPreviewDialog({ defaultApprovers = [], onConfirm, allowTitleEdit = true, + enableAttachments = false, + maxAttachments = 10, + maxFileSize = 100 * 1024 * 1024, // 100MB }: ApprovalPreviewDialogProps) { const isDesktop = useMediaQuery("(min-width: 768px)"); @@ -113,6 +146,7 @@ export function ApprovalPreviewDialog({ const [title, setTitle] = React.useState(initialTitle); const [approvalLines, setApprovalLines] = React.useState<ApprovalLineItem[]>([]); const [previewHtml, setPreviewHtml] = React.useState<string>(""); + const [attachments, setAttachments] = React.useState<File[]>([]); // 템플릿 로딩 및 미리보기 생성 React.useEffect(() => { @@ -155,6 +189,7 @@ export function ApprovalPreviewDialog({ setTitle(initialTitle); setApprovalLines([]); setPreviewHtml(""); + setAttachments([]); return; } @@ -195,6 +230,36 @@ export function ApprovalPreviewDialog({ setApprovalLines(lines); }; + // 파일 드롭 핸들러 + const handleDropAccepted = React.useCallback( + (files: File[]) => { + if (attachments.length + files.length > maxAttachments) { + toast.error(`최대 ${maxAttachments}개의 파일만 첨부할 수 있습니다.`); + return; + } + + // 중복 파일 체크 + const newFiles = files.filter( + (file) => !attachments.some((existing) => existing.name === file.name && existing.size === file.size) + ); + + if (newFiles.length !== files.length) { + toast.warning("일부 중복된 파일은 제외되었습니다."); + } + + setAttachments((prev) => [...prev, ...newFiles]); + }, + [attachments, maxAttachments] + ); + + const handleDropRejected = React.useCallback(() => { + toast.error(`파일 크기는 ${prettyBytes(maxFileSize)} 이하여야 합니다.`); + }, [maxFileSize]); + + const handleRemoveFile = React.useCallback((index: number) => { + setAttachments((prev) => prev.filter((_, i) => i !== index)); + }, []); + // 제출 핸들러 const handleSubmit = async () => { try { @@ -225,6 +290,7 @@ export function ApprovalPreviewDialog({ await onConfirm({ approvers: approverEpIds, title: title.trim(), + attachments: enableAttachments ? attachments : undefined, }); // 성공 시 다이얼로그 닫기 @@ -275,6 +341,96 @@ export function ApprovalPreviewDialog({ /> </div> + {/* 첨부파일 섹션 (enableAttachments가 true일 때만 표시) */} + {enableAttachments && ( + <> + <Separator /> + + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Paperclip className="w-4 h-4" /> + 첨부파일 + {attachments.length > 0 && ( + <span className="text-sm font-normal text-muted-foreground"> + ({attachments.length}/{maxAttachments}) + </span> + )} + </CardTitle> + <CardDescription> + 결재 문서에 첨부할 파일을 추가하세요 (최대 {maxAttachments}개, 파일당 최대 {prettyBytes(maxFileSize)}) + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + {/* 파일 드롭존 */} + {attachments.length < maxAttachments && ( + <Dropzone + maxSize={maxFileSize} + onDropAccepted={handleDropAccepted} + onDropRejected={handleDropRejected} + disabled={isSubmitting} + > + {() => ( + <DropzoneZone className="flex justify-center h-24"> + <DropzoneInput /> + <div className="flex items-center gap-4"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>파일을 드래그하거나 클릭하여 업로드</DropzoneTitle> + <DropzoneDescription> + 모든 형식의 파일을 첨부할 수 있습니다 + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + )} + </Dropzone> + )} + + {/* 첨부된 파일 목록 */} + {attachments.length > 0 && ( + <FileList> + {attachments.map((file, index) => ( + <FileListItem key={`${file.name}-${index}`}> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <div className="flex-1"> + <FileListName>{file.name}</FileListName> + <FileListDescription> + <FileListSize>{file.size}</FileListSize> + {file.type && ( + <> + <span>•</span> + <span>{file.type}</span> + </> + )} + </FileListDescription> + </div> + </FileListInfo> + </FileListHeader> + <FileListAction + onClick={() => handleRemoveFile(index)} + disabled={isSubmitting} + title="파일 제거" + > + <X className="w-4 h-4" /> + </FileListAction> + </FileListItem> + ))} + </FileList> + )} + + {attachments.length === 0 && ( + <p className="text-sm text-muted-foreground text-center py-4"> + 첨부된 파일이 없습니다 + </p> + )} + </CardContent> + </Card> + </> + )} + {/* 템플릿 미리보기 */} <div className="space-y-2"> <Label>문서 미리보기</Label> diff --git a/lib/approval/template-utils.ts b/lib/approval/template-utils.ts index 0607f289..5a5bb307 100644 --- a/lib/approval/template-utils.ts +++ b/lib/approval/template-utils.ts @@ -39,14 +39,18 @@ export async function getApprovalTemplateByName(name: string) { * * {{변수명}} 형태의 변수를 실제 값으로 치환 * + * **중요**: 변수명의 앞뒤 공백은 자동으로 제거됩니다. + * - 템플릿: `{{ 변수명 }}` → `{{변수명}}`으로 정규화 + * - 변수 키: ` 변수명 ` → `변수명`으로 정규화 + * * @param content - 템플릿 HTML 내용 * @param variables - 변수 매핑 객체 * @returns 치환된 HTML * * @example * ```typescript - * const content = "<p>{{이름}}님, 안녕하세요</p>"; - * const variables = { "이름": "홍길동" }; + * const content = "<p>{{ 이름 }}님, 안녕하세요</p>"; + * const variables = { " 이름 ": "홍길동" }; // 공백 있어도 OK * const result = await replaceTemplateVariables(content, variables); * // "<p>홍길동님, 안녕하세요</p>" * ``` @@ -57,9 +61,20 @@ export async function replaceTemplateVariables( ): Promise<string> { let result = content; + // 변수 키를 trim하여 정규화된 맵 생성 + const normalizedVariables: Record<string, string> = {}; Object.entries(variables).forEach(([key, value]) => { - // {{변수명}} 패턴을 전역으로 치환 - const pattern = new RegExp(`\\{\\{${escapeRegex(key)}\\}\\}`, 'g'); + normalizedVariables[key.trim()] = value; + }); + + // 템플릿에서 {{ 변수명 }} 패턴을 찾아 치환 + // 공백을 허용하는 정규식: {{\s*변수명\s*}} + Object.entries(normalizedVariables).forEach(([key, value]) => { + // 변수명 앞뒤에 공백이 있을 수 있으므로 \s*를 추가 + const pattern = new RegExp( + `\\{\\{\\s*${escapeRegex(key)}\\s*\\}\\}`, + 'g' + ); result = result.replace(pattern, value); }); diff --git a/lib/approval/templates/README.md b/lib/approval/templates/README.md new file mode 100644 index 00000000..ab033fb8 --- /dev/null +++ b/lib/approval/templates/README.md @@ -0,0 +1,4 @@ +결재 HTML 템플릿 작성 가이드: +1. html, head, body 태그 사용 불가 +2. 모든 스타일은 인라인 에디팅으로 작성 +3. 검정, 회색 무채색 계열(기업 사내 시스템에 적절하도록)
\ No newline at end of file diff --git a/lib/approval/templates/정규업체 등록.html b/lib/approval/templates/정규업체 등록.html new file mode 100644 index 00000000..5bcf1ba2 --- /dev/null +++ b/lib/approval/templates/정규업체 등록.html @@ -0,0 +1,877 @@ +<div + style=" + max-width: 800px; + margin: 0 auto; + font-family: 'Segoe UI', 'Malgun Gothic', sans-serif; + color: #333; + line-height: 1.6; + " +> + <!-- 헤더 --> + <table + style=" + width: 100%; + border-collapse: collapse; + margin-bottom: 20px; + border: 2px solid #000; + " + > + <thead> + <tr> + <th + style=" + background-color: #fff; + color: #000; + padding: 20px; + text-align: center; + font-size: 24px; + font-weight: 700; + " + > + 정규업체 등록 + </th> + </tr> + </thead> + </table> + + <!-- 정규업체 등록 요청 정보 --> + <table + style=" + width: 100%; + border-collapse: collapse; + margin-bottom: 20px; + border: 1px solid #666; + " + > + <thead> + <tr> + <th + colspan="2" + style=" + background-color: #333; + color: #fff; + padding: 12px; + text-align: left; + font-size: 16px; + font-weight: 600; + border-bottom: 1px solid #666; + " + > + ■ 정규업체 등록 요청 정보 + </th> + </tr> + </thead> + <tbody> + <tr> + <td + style=" + background-color: #e8e8e8; + color: #000; + padding: 10px; + font-weight: 600; + width: 20%; + border: 1px solid #ccc; + " + > + 사업자번호 + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{협력업체기본정보-사업자번호}} + </td> + </tr> + <tr> + <td + style=" + background-color: #e8e8e8; + color: #000; + padding: 10px; + font-weight: 600; + border: 1px solid #ccc; + " + > + 업체명 + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{협력업체기본정보-업체명}} + </td> + </tr> + <tr> + <td + style=" + background-color: #e8e8e8; + color: #000; + padding: 10px; + font-weight: 600; + border: 1px solid #ccc; + " + > + 대표자명 + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{협력업체기본정보-대표자명}} + </td> + </tr> + <tr> + <td + style=" + background-color: #e8e8e8; + color: #000; + padding: 10px; + font-weight: 600; + border: 1px solid #ccc; + " + > + 대표 전화번호 + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{협력업체기본정보-대표전화}} + </td> + </tr> + <tr> + <td + style=" + background-color: #e8e8e8; + color: #000; + padding: 10px; + font-weight: 600; + border: 1px solid #ccc; + " + > + 대표 팩스번호 + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{협력업체기본정보-FAX}} + </td> + </tr> + <tr> + <td + style=" + background-color: #e8e8e8; + color: #000; + padding: 10px; + font-weight: 600; + border: 1px solid #ccc; + " + > + 대표 이메일 + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{협력업체기본정보-Email}} + </td> + </tr> + <tr> + <td + style=" + background-color: #e8e8e8; + color: #000; + padding: 10px; + font-weight: 600; + border: 1px solid #ccc; + " + > + 우편번호 + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{협력업체기본정보-우편번호}} + </td> + </tr> + <tr> + <td + style=" + background-color: #e8e8e8; + color: #000; + padding: 10px; + font-weight: 600; + border: 1px solid #ccc; + " + > + 회사주소 + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{협력업체기본정보-회사주소}} + </td> + </tr> + <tr> + <td + style=" + background-color: #e8e8e8; + color: #000; + padding: 10px; + font-weight: 600; + border: 1px solid #ccc; + " + > + 상세주소 + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{협력업체기본정보-상세주소}} + </td> + </tr> + <tr> + <td + style=" + background-color: #e8e8e8; + color: #000; + padding: 10px; + font-weight: 600; + border: 1px solid #ccc; + " + > + 사업유형 + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{협력업체기본정보-사업유형}} + </td> + </tr> + <tr> + <td + style=" + background-color: #e8e8e8; + color: #000; + padding: 10px; + font-weight: 600; + border: 1px solid #ccc; + " + > + 산업유형 + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{협력업체기본정보-산업유형}} + </td> + </tr> + <tr> + <td + style=" + background-color: #e8e8e8; + color: #000; + padding: 10px; + font-weight: 600; + border: 1px solid #ccc; + " + > + 회사규모 + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{협력업체기본정보-회사규모}} + </td> + </tr> + </tbody> + </table> + + <!-- 담당자 연락처 --> + <table + style=" + width: 100%; + border-collapse: collapse; + margin-bottom: 20px; + border: 1px solid #666; + " + > + <thead> + <tr> + <th + colspan="6" + style=" + background-color: #333; + color: #fff; + padding: 12px; + text-align: left; + font-size: 16px; + font-weight: 600; + border-bottom: 1px solid #666; + " + > + ■ 담당자 연락처 + </th> + </tr> + <tr> + <th + style=" + background-color: #f5f5f5; + color: #000; + padding: 10px; + text-align: center; + font-weight: 600; + width: 15%; + border: 1px solid #ccc; + " + ></th> + <th + style=" + background-color: #d9d9d9; + color: #000; + padding: 10px; + text-align: center; + font-weight: 600; + width: 17%; + border: 1px solid #ccc; + " + > + 영업 (sales) + </th> + <th + style=" + background-color: #d9d9d9; + color: #000; + padding: 10px; + text-align: center; + font-weight: 600; + width: 17%; + border: 1px solid #ccc; + " + > + 설계 (design) + </th> + <th + style=" + background-color: #d9d9d9; + color: #000; + padding: 10px; + text-align: center; + font-weight: 600; + width: 17%; + border: 1px solid #ccc; + " + > + 납기 (delivery) + </th> + <th + style=" + background-color: #d9d9d9; + color: #000; + padding: 10px; + text-align: center; + font-weight: 600; + width: 17%; + border: 1px solid #ccc; + " + > + 품질 (quality) + </th> + <th + style=" + background-color: #d9d9d9; + color: #000; + padding: 10px; + text-align: center; + font-weight: 600; + width: 17%; + border: 1px solid #ccc; + " + > + 세금계산서 + </th> + </tr> + </thead> + <tbody> + <tr> + <td + style=" + background-color: #e8e8e8; + color: #000; + padding: 10px; + font-weight: 600; + text-align: center; + border: 1px solid #ccc; + " + > + 담당자명 + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{영업담당자-담당자명}} + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{설계담당자-담당자명}} + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{납기담당자-담당자명}} + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{품질담당자-담당자명}} + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{세금계산서담당자-담당자명}} + </td> + </tr> + <tr> + <td + style=" + background-color: #e8e8e8; + color: #000; + padding: 10px; + font-weight: 600; + text-align: center; + border: 1px solid #ccc; + " + > + 직급 + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{영업담당자-직급}} + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{설계담당자-직급}} + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{납기담당자-직급}} + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{품질담당자-직급}} + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{세금계산서담당자-직급}} + </td> + </tr> + <tr> + <td + style=" + background-color: #e8e8e8; + color: #000; + padding: 10px; + font-weight: 600; + text-align: center; + border: 1px solid #ccc; + " + > + 부서 + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{영업담당자-부서}} + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{설계담당자-부서}} + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{납기담당자-부서}} + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{품질담당자-부서}} + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{세금계산서담당자-부서}} + </td> + </tr> + <tr> + <td + style=" + background-color: #e8e8e8; + color: #000; + padding: 10px; + font-weight: 600; + text-align: center; + border: 1px solid #ccc; + " + > + 담당업무 + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{영업담당자-담당업무}} + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{설계담당자-담당업무}} + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{납기담당자-담당업무}} + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{품질담당자-담당업무}} + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{세금계산서담당자-담당업무}} + </td> + </tr> + <tr> + <td + style=" + background-color: #e8e8e8; + color: #000; + padding: 10px; + font-weight: 600; + text-align: center; + border: 1px solid #ccc; + " + > + 이메일 + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{영업담당자-이메일}} + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{설계담당자-이메일}} + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{납기담당자-이메일}} + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{품질담당자-이메일}} + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{세금계산서담당자-이메일}} + </td> + </tr> + </tbody> + </table> + + <!-- 기본계약서 현황 --> + <table + style=" + width: 100%; + border-collapse: collapse; + margin-bottom: 20px; + border: 1px solid #666; + " + > + <thead> + <tr> + <th + colspan="3" + style=" + background-color: #333; + color: #fff; + padding: 12px; + text-align: left; + font-size: 16px; + font-weight: 600; + border-bottom: 1px solid #666; + " + > + ■ 기본계약서 현황 + </th> + </tr> + <tr> + <th + style=" + background-color: #e8e8e8; + color: #000; + padding: 10px; + text-align: center; + font-weight: 600; + width: 50%; + border: 1px solid #ccc; + " + > + 기본계약서명 + </th> + <th + style=" + background-color: #e8e8e8; + color: #000; + padding: 10px; + text-align: center; + font-weight: 600; + width: 30%; + border: 1px solid #ccc; + " + > + 상태 + </th> + <th + style=" + background-color: #e8e8e8; + color: #000; + padding: 10px; + text-align: center; + font-weight: 600; + width: 20%; + border: 1px solid #ccc; + " + > + 서약일자 + </th> + </tr> + </thead> + <tbody> + <tr> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{계약유형}} + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{계약상태}} + </td> + <td + style=" + background-color: #fff; + color: #333; + padding: 10px; + border: 1px solid #ccc; + " + > + {{서약일자}} + </td> + </tr> + </tbody> + </table> +</div> 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<PQSubmission>
-}
-
-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<InvestigationInitialData | undefined>(undefined)
-
- // 실사 의뢰 임시 데이터 (결재 다이얼로그로 전달)
- const [investigationFormData, setInvestigationFormData] = React.useState<{
- qmManagerId: number;
- qmManagerName: string;
- qmManagerEmail?: string;
- forecastedAt: Date;
- investigationAddress: string;
- investigationNotes?: string;
- } | null>(null)
-
- // 실사 재의뢰 임시 데이터
- const [reRequestData, setReRequestData] = React.useState<{
- investigationIds: number[];
- vendorNames: string;
- } | null>(null)
-
- // 결재 템플릿 변수
- const [approvalVariables, setApprovalVariables] = React.useState<Record<string, string>>({})
- const [reRequestApprovalVariables, setReRequestApprovalVariables] = React.useState<Record<string, string>>({})
-
- // 실사 의뢰 대화상자 열기 핸들러
-// 실사 의뢰 대화상자 열기 핸들러
-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 (
- <>
- <div className="flex items-center gap-2">
- {/* 실사 의뢰 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={handleOpenRequestDialog} // 여기를 수정: 새로운 핸들러 함수 사용
- disabled={isLoading || selectedRows.length === 0 || hasNonInspectionPQ}
- className="gap-2"
- title={hasNonInspectionPQ ? "미실사 PQ는 실사 의뢰할 수 없습니다." : undefined}
- >
- <ClipboardCheck className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">실사 의뢰</span>
- </Button>
-
- {/* 실사 의뢰 취소 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={() => setIsCancelDialogOpen(true)}
- disabled={
- isLoading ||
- selectedRows.length === 0 ||
- !selectedRows.every(row => row.original.investigation?.investigationStatus === "PLANNED")
- }
- className="gap-2"
- >
- <X className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">실사 취소</span>
- </Button>
-
- {/* 실사 재의뢰 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={() => setIsReRequestDialogOpen(true)}
- disabled={
- isLoading ||
- selectedRows.length === 0 ||
- !selectedRows.every(row => row.original.investigation?.investigationStatus === "CANCELED")
- }
- className="gap-2"
- >
- <RefreshCw className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">실사 재의뢰</span>
- </Button>
-
- {/* 재실사 요청 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={() => setIsReinspectionDialogOpen(true)}
- disabled={
- isLoading ||
- selectedRows.length === 0 ||
- reinspectInvestigationsCount === 0 ||
- !canRequestReinspection
- }
- className="gap-2"
- title={
- !canRequestReinspection && reinspectInvestigationsCount > 0
- ? "재방문 실사 요청은 방문실사평가 또는 제품검사평가에만 가능합니다."
- : undefined
- }
- >
- <RefreshCw className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">재방문 실사 요청</span>
- </Button>
-
- {/* 실사 결과 발송 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={() => setIsSendResultsDialogOpen(true)}
- disabled={
- isLoading ||
- selectedRows.length === 0 ||
- !selectedRows.every(row => {
- const investigation = row.original.investigation;
- if (!investigation) return false;
-
- // 실사 완료 상태이거나 평가 결과가 있는 경우에만 활성화, 실사결과발송 상태가 아닌 경우에만 활성화
- return investigation.investigationStatus === "COMPLETED" ||
- investigation.investigationStatus === "SUPPLEMENT_REQUIRED" ||
- investigation.evaluationResult === "REJECTED"
- })
- }
- className="gap-2"
- >
- <Send className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">결과 발송</span>
- </Button>
-
- {/** Export 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={() =>
- exportTableToExcel(table, {
- filename: "vendors-pq-submissions",
- excludeColumns: ["select", "actions"],
- })
- }
- className="gap-2"
- >
- <Download className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">Export</span>
- </Button>
- </div>
-
- {/* 실사 의뢰 Dialog */}
- <RequestInvestigationDialog
- isOpen={isRequestDialogOpen}
- onClose={handleCloseRequestDialog} // 새로운 핸들러로 변경
- onSubmit={handleRequestInvestigation}
- selectedCount={approvedPQsCount}
- initialData={dialogInitialData} // 초기 데이터 전달
- />
-
-
- {/* 실사 취소 Dialog */}
- <CancelInvestigationDialog
- isOpen={isCancelDialogOpen}
- onClose={() => setIsCancelDialogOpen(false)}
- onConfirm={handleCancelInvestigation}
- selectedCount={plannedInvestigationsCount}
- />
-
- {/* 실사 재의뢰 Dialog */}
- <ReRequestInvestigationDialog
- isOpen={isReRequestDialogOpen}
- onClose={() => setIsReRequestDialogOpen(false)}
- onConfirm={handleReRequestInvestigation}
- selectedCount={canceledInvestigationsCount}
- />
-
- {/* 결과 발송 Dialog */}
- <SendResultsDialog
- isOpen={isSendResultsDialogOpen}
- onClose={() => 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 (
- <SiteVisitDialog
- isOpen={isReinspectionDialogOpen}
- onClose={() => 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 && (
- <ApprovalPreviewDialog
- open={isApprovalDialogOpen}
- onOpenChange={(open) => {
- setIsApprovalDialogOpen(open)
- if (!open) {
- // 다이얼로그가 닫히면 실사 폼 데이터도 초기화
- setInvestigationFormData(null)
- }
- }}
- templateName="Vendor 실사의뢰"
- variables={approvalVariables}
- title={`Vendor 실사의뢰 - ${selectedRows.filter(row =>
- row.original.status === "APPROVED" &&
- !row.original.investigation &&
- row.original.type !== "NON_INSPECTION"
- ).map(row => row.original.vendorName).join(', ')}`}
- description={`${approvedPQsCount}개 업체에 대한 실사 의뢰`}
- currentUser={{
- id: Number(session.user.id),
- epId: session.user.epId || null,
- name: session.user.name || null,
- email: session.user.email || '',
- }}
- onSubmit={handleApprovalSubmit}
- />
- )}
-
- {/* 결재 미리보기 Dialog - 실사 재의뢰 */}
- {session?.user && reRequestData && (
- <ApprovalPreviewDialog
- open={isReRequestApprovalDialogOpen}
- onOpenChange={(open) => {
- setIsReRequestApprovalDialogOpen(open)
- if (!open) {
- // 다이얼로그가 닫히면 재의뢰 데이터도 초기화
- setReRequestData(null)
- }
- }}
- templateName="Vendor 실사 재의뢰"
- variables={reRequestApprovalVariables}
- title={`Vendor 실사 재의뢰 - ${reRequestData.vendorNames}`}
- description={`${reRequestData.investigationIds.length}개 업체에 대한 실사 재의뢰`}
- currentUser={{
- id: Number(session.user.id),
- epId: session.user.epId || null,
- name: session.user.name || null,
- email: session.user.email || '',
- }}
- onSubmit={handleReRequestApprovalSubmit}
- />
- )}
- </>
- )
+"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<PQSubmission> +} + +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<InvestigationInitialData | undefined>(undefined) + + // 실사 의뢰 임시 데이터 (결재 다이얼로그로 전달) + const [investigationFormData, setInvestigationFormData] = React.useState<{ + qmManagerId: number; + qmManagerName: string; + qmManagerEmail?: string; + forecastedAt: Date; + investigationAddress: string; + investigationNotes?: string; + } | null>(null) + + // 실사 재의뢰 임시 데이터 + const [reRequestData, setReRequestData] = React.useState<{ + investigationIds: number[]; + vendorNames: string; + } | null>(null) + + // 결재 템플릿 변수 + const [approvalVariables, setApprovalVariables] = React.useState<Record<string, string>>({}) + const [reRequestApprovalVariables, setReRequestApprovalVariables] = React.useState<Record<string, string>>({}) + + // 실사 의뢰 대화상자 열기 핸들러 +// 실사 의뢰 대화상자 열기 핸들러 +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 ( + <> + <div className="flex items-center gap-2"> + {/* 실사 의뢰 버튼 */} + <Button + variant="outline" + size="sm" + onClick={handleOpenRequestDialog} // 여기를 수정: 새로운 핸들러 함수 사용 + disabled={isLoading || selectedRows.length === 0 || hasNonInspectionPQ} + className="gap-2" + title={hasNonInspectionPQ ? "미실사 PQ는 실사 의뢰할 수 없습니다." : undefined} + > + <ClipboardCheck className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">실사 의뢰</span> + </Button> + + {/* 실사 의뢰 취소 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => setIsCancelDialogOpen(true)} + disabled={ + isLoading || + selectedRows.length === 0 || + !selectedRows.every(row => row.original.investigation?.investigationStatus === "PLANNED") + } + className="gap-2" + > + <X className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">실사 취소</span> + </Button> + + {/* 실사 재의뢰 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => setIsReRequestDialogOpen(true)} + disabled={ + isLoading || + selectedRows.length === 0 || + !selectedRows.every(row => row.original.investigation?.investigationStatus === "CANCELED") + } + className="gap-2" + > + <RefreshCw className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">실사 재의뢰</span> + </Button> + + {/* 재실사 요청 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => setIsReinspectionDialogOpen(true)} + disabled={ + isLoading || + selectedRows.length === 0 || + reinspectInvestigationsCount === 0 || + !canRequestReinspection + } + className="gap-2" + title={ + !canRequestReinspection && reinspectInvestigationsCount > 0 + ? "재방문 실사 요청은 방문실사평가 또는 제품검사평가에만 가능합니다." + : undefined + } + > + <RefreshCw className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">재방문 실사 요청</span> + </Button> + + {/* 실사 결과 발송 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => setIsSendResultsDialogOpen(true)} + disabled={ + isLoading || + selectedRows.length === 0 || + !selectedRows.every(row => { + const investigation = row.original.investigation; + if (!investigation) return false; + + // 실사 완료 상태이거나 평가 결과가 있는 경우에만 활성화, 실사결과발송 상태가 아닌 경우에만 활성화 + return investigation.investigationStatus === "COMPLETED" || + investigation.investigationStatus === "SUPPLEMENT_REQUIRED" || + investigation.evaluationResult === "REJECTED" + }) + } + className="gap-2" + > + <Send className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">결과 발송</span> + </Button> + + {/** Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "vendors-pq-submissions", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + + {/* 실사 의뢰 Dialog */} + <RequestInvestigationDialog + isOpen={isRequestDialogOpen} + onClose={handleCloseRequestDialog} // 새로운 핸들러로 변경 + onSubmit={handleRequestInvestigation} + selectedCount={approvedPQsCount} + initialData={dialogInitialData} // 초기 데이터 전달 + /> + + + {/* 실사 취소 Dialog */} + <CancelInvestigationDialog + isOpen={isCancelDialogOpen} + onClose={() => setIsCancelDialogOpen(false)} + onConfirm={handleCancelInvestigation} + selectedCount={plannedInvestigationsCount} + /> + + {/* 실사 재의뢰 Dialog */} + <ReRequestInvestigationDialog + isOpen={isReRequestDialogOpen} + onClose={() => setIsReRequestDialogOpen(false)} + onConfirm={handleReRequestInvestigation} + selectedCount={canceledInvestigationsCount} + /> + + {/* 결과 발송 Dialog */} + <SendResultsDialog + isOpen={isSendResultsDialogOpen} + onClose={() => 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 ( + <SiteVisitDialog + isOpen={isReinspectionDialogOpen} + onClose={() => 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 && ( + <ApprovalPreviewDialog + open={isApprovalDialogOpen} + onOpenChange={(open) => { + setIsApprovalDialogOpen(open) + if (!open) { + // 다이얼로그가 닫히면 실사 폼 데이터도 초기화 + setInvestigationFormData(null) + } + }} + templateName="Vendor 실사의뢰" + variables={approvalVariables} + title={`Vendor 실사의뢰 - ${selectedRows.filter(row => + row.original.status === "APPROVED" && + !row.original.investigation && + row.original.type !== "NON_INSPECTION" + ).map(row => row.original.vendorName).join(', ')}`} + 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 && ( + <ApprovalPreviewDialog + open={isReRequestApprovalDialogOpen} + onOpenChange={(open) => { + 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<VendorRegularRegistration>
-}
-
-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<RegistrationRequestData | null>(null)
- const [approvalVariables, setApprovalVariables] = useState<Record<string, string>>({})
-
- 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 (
- <div className="flex items-center gap-2">
- <Button
- variant="outline"
- size="sm"
- onClick={handleSendMissingContractRequest}
- disabled={syncLoading.missingContract || selectedRows.length === 0}
- >
- <FileWarning className="mr-2 h-4 w-4" />
- {syncLoading.missingContract ? "발송 중..." : "누락계약요청"}
- </Button>
-
- <Button
- variant="outline"
- size="sm"
- onClick={handleSendAdditionalInfoRequest}
- disabled={syncLoading.additionalInfo || selectedRows.length === 0}
- >
- <Mail className="mr-2 h-4 w-4" />
- {syncLoading.additionalInfo ? "발송 중..." : "추가정보요청"}
- </Button>
-
- <Button
- variant="outline"
- size="sm"
- onClick={() => setSkipDialogs(prev => ({ ...prev, legalReview: true }))}
- disabled={syncLoading.legalSkip || cpReviewCount === 0}
- >
- <Scale className="mr-2 h-4 w-4" />
- {syncLoading.legalSkip ? "처리 중..." : "GTC Skip"}
- </Button>
-
- <Button
- variant="default"
- size="sm"
- onClick={handleRegistrationRequest}
- disabled={syncLoading.registrationRequest || approvalReadyCount === 0}
- >
- <FileText className="mr-2 h-4 w-4" />
- {syncLoading.registrationRequest ? "처리 중..." : "등록요청"}
- </Button>
-
- <SkipReasonDialog
- open={skipDialogs.legalReview}
- onOpenChange={(open) => setSkipDialogs(prev => ({ ...prev, legalReview: open }))}
- title="GTC Skip"
- description={`선택된 ${cpReviewCount}개 업체의 GTC를 Skip하고 CP완료 상태로 변경합니다. Skip 사유를 입력해주세요.`}
- onConfirm={handleLegalReviewSkip}
- loading={syncLoading.legalSkip}
- />
-
- <RegistrationRequestDialog
- open={registrationRequestDialog.open}
- onOpenChange={(open) => setRegistrationRequestDialog(prev => ({ ...prev, open }))}
- registration={registrationRequestDialog.registration}
- onSubmit={handleRegistrationRequestSubmit}
- />
-
- {/* 결재 미리보기 Dialog - 정규업체 등록 */}
- {session?.user && approvalDialog.registration && (
- <ApprovalPreviewDialog
- open={approvalDialog.open}
- onOpenChange={(open) => {
- 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}
- />
- )}
- </div>
- )
-}
+"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<VendorRegularRegistration> +} + +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<RegistrationRequestData | null>(null) + const [approvalVariables, setApprovalVariables] = useState<Record<string, string>>({}) + + 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 ( + <div className="flex items-center gap-2"> + <Button + variant="outline" + size="sm" + onClick={handleSendMissingContractRequest} + disabled={syncLoading.missingContract || selectedRows.length === 0} + > + <FileWarning className="mr-2 h-4 w-4" /> + {syncLoading.missingContract ? "발송 중..." : "누락계약요청"} + </Button> + + <Button + variant="outline" + size="sm" + onClick={handleSendAdditionalInfoRequest} + disabled={syncLoading.additionalInfo || selectedRows.length === 0} + > + <Mail className="mr-2 h-4 w-4" /> + {syncLoading.additionalInfo ? "발송 중..." : "추가정보요청"} + </Button> + + <Button + variant="outline" + size="sm" + onClick={() => setSkipDialogs(prev => ({ ...prev, legalReview: true }))} + disabled={syncLoading.legalSkip || cpReviewCount === 0} + > + <Scale className="mr-2 h-4 w-4" /> + {syncLoading.legalSkip ? "처리 중..." : "GTC Skip"} + </Button> + + <Button + variant="default" + size="sm" + onClick={handleRegistrationRequest} + disabled={syncLoading.registrationRequest || approvalReadyCount === 0} + > + <FileText className="mr-2 h-4 w-4" /> + {syncLoading.registrationRequest ? "처리 중..." : "등록요청"} + </Button> + + <SkipReasonDialog + open={skipDialogs.legalReview} + onOpenChange={(open) => setSkipDialogs(prev => ({ ...prev, legalReview: open }))} + title="GTC Skip" + description={`선택된 ${cpReviewCount}개 업체의 GTC를 Skip하고 CP완료 상태로 변경합니다. Skip 사유를 입력해주세요.`} + onConfirm={handleLegalReviewSkip} + loading={syncLoading.legalSkip} + /> + + <RegistrationRequestDialog + open={registrationRequestDialog.open} + onOpenChange={(open) => setRegistrationRequestDialog(prev => ({ ...prev, open }))} + registration={registrationRequestDialog.registration} + onSubmit={handleRegistrationRequestSubmit} + /> + + {/* 결재 미리보기 Dialog - 정규업체 등록 */} + {session?.user && session.user.epId && approvalDialog.registration && ( + <ApprovalPreviewDialog + open={approvalDialog.open} + onOpenChange={(open) => { + 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} + /> + )} + </div> + ) +} |
