summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-11-07 12:01:16 +0900
committerjoonhoekim <26rote@gmail.com>2025-11-07 12:01:16 +0900
commit18ca4ad784aeeab9ab7a13bbc8b3c13b42ca5e49 (patch)
tree6faf1a05d1ae296202ece5f4ca95b4d9c7a0488b
parent4b6ebdef8281a413fa2bfbdf8f5565eb8b106c62 (diff)
(김준회) 결재 미리보기 공통컴포넌트 중복 제거, 기존 코드의 미리보기 호출부 수정, 템플릿 작성 가이드 간략히 추가, 결재 미리보기시 첨부파일 편집 처리
-rw-r--r--components/approval/ApprovalPreviewDialog.tsx213
-rw-r--r--lib/approval/README.md478
-rw-r--r--lib/approval/approval-preview-dialog.tsx156
-rw-r--r--lib/approval/template-utils.ts23
-rw-r--r--lib/approval/templates/README.md4
-rw-r--r--lib/approval/templates/정규업체 등록.html877
-rw-r--r--lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx1819
-rw-r--r--lib/vendor-regular-registrations/handlers.ts151
-rw-r--r--lib/vendor-regular-registrations/table/vendor-regular-registrations-table-toolbar-actions.tsx663
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>
+ )
+}