diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-12-08 03:02:42 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-12-08 03:02:42 +0000 |
| commit | c0e1cc06e0c67cb4a941889a3d63d312d1fb8fce (patch) | |
| tree | 99dc529ed88d762808dc571d4095dc880e1bef02 /lib/general-contracts/detail | |
| parent | 4a8d125cf60254417d4e2ca75a967ff08cc8567e (diff) | |
(최겸) 구매 입찰계약 수정, 입찰기간수정
Diffstat (limited to 'lib/general-contracts/detail')
| -rw-r--r-- | lib/general-contracts/detail/general-contract-approval-request-dialog.tsx | 2381 | ||||
| -rw-r--r-- | lib/general-contracts/detail/general-contract-items-table.tsx | 2 |
2 files changed, 1200 insertions, 1183 deletions
diff --git a/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx b/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx index d44f4290..db0901cb 100644 --- a/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx +++ b/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx @@ -1,1183 +1,1200 @@ -'use client'
-
-import React, { useState, useEffect } from 'react'
-import { useSession } from 'next-auth/react'
-import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
-import { Button } from '@/components/ui/button'
-import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
-import { Badge } from '@/components/ui/badge'
-import { Checkbox } from '@/components/ui/checkbox'
-import { Label } from '@/components/ui/label'
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
-import { Input } from '@/components/ui/input'
-import { toast } from 'sonner'
-import {
- FileText,
- Upload,
- Eye,
- Send,
- CheckCircle,
- Download,
- AlertCircle
-} from 'lucide-react'
-import { ContractDocuments } from './general-contract-documents'
-import { getActiveContractTemplates } from '@/lib/bidding/service'
-import { type BasicContractTemplate } from '@/db/schema'
-import {
- getBasicInfo,
- getContractItems,
- getSubcontractChecklist,
- uploadContractApprovalFile,
- sendContractApprovalRequest,
- getContractById,
- getContractTemplateByContractType,
- getStorageInfo
-} from '../service'
-import { mapContractDataToTemplateVariables } from '../utils'
-import { ApprovalPreviewDialog } from '@/lib/approval/client'
-import { requestContractApprovalWithApproval } from '../approval-actions'
-import { mapContractToApprovalTemplateVariables } from '../approval-template-variables'
-
-interface ContractApprovalRequestDialogProps {
- contract: Record<string, unknown>
- open: boolean
- onOpenChange: (open: boolean) => void
-}
-
-interface ContractSummary {
- basicInfo: Record<string, unknown>
- items: Record<string, unknown>[]
- subcontractChecklist: Record<string, unknown> | null
- storageInfo?: Record<string, unknown>[]
- pdfPath?: string
- basicContractPdfs?: Array<{ key: string; buffer: number[]; fileName: string }>
-}
-
-export function ContractApprovalRequestDialog({
- contract,
- open,
- onOpenChange
-}: ContractApprovalRequestDialogProps) {
- const { data: session } = useSession()
- const [currentStep, setCurrentStep] = useState(1)
- const [contractSummary, setContractSummary] = useState<ContractSummary | null>(null)
- const [uploadedFile, setUploadedFile] = useState<File | null>(null)
- const [generatedPdfUrl, setGeneratedPdfUrl] = useState<string | null>(null)
- const [generatedPdfBuffer, setGeneratedPdfBuffer] = useState<Uint8Array | null>(null)
- const [isLoading, setIsLoading] = useState(false)
- const [pdfViewerInstance, setPdfViewerInstance] = useState<any>(null)
- const [isPdfPreviewVisible, setIsPdfPreviewVisible] = useState(false)
-
- // 기본계약 관련 상태
- const [selectedBasicContracts, setSelectedBasicContracts] = useState<Array<{
- type: string;
- templateName: string;
- checked: boolean;
- }>>([])
- const [isLoadingBasicContracts, setIsLoadingBasicContracts] = useState(false)
-
- // 결재 관련 상태
- const [approvalDialogOpen, setApprovalDialogOpen] = useState(false)
- const [approvalVariables, setApprovalVariables] = useState<Record<string, string>>({})
- const [savedPdfPath, setSavedPdfPath] = useState<string | null>(null)
- const [savedBasicContractPdfs, setSavedBasicContractPdfs] = useState<Array<{ key: string; buffer: number[]; fileName: string }>>([])
-
- const contractId = contract.id as number
- const userId = session?.user?.id || ''
-
-
- // 기본계약 생성 함수 (최종 전송 시점에 호출)
- const generateBasicContractPdf = async (
- vendorId: number,
- contractType: string,
- templateName: string
- ): Promise<{ buffer: number[], fileName: string }> => {
- try {
- // 1. 템플릿 데이터 준비 (서버 액션 호출)
- const prepareResponse = await fetch("/api/contracts/prepare-template", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- templateName,
- vendorId,
- }),
- });
-
- if (!prepareResponse.ok) {
- const errorText = await prepareResponse.text();
- throw new Error(`템플릿 준비 실패 (${prepareResponse.status}): ${errorText}`);
- }
-
- const { template, templateData } = await prepareResponse.json();
-
- // 2. 템플릿 파일 다운로드
- const templateResponse = await fetch("/api/contracts/get-template", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ templatePath: template.filePath }),
- });
-
- const templateBlob = await templateResponse.blob();
- const templateFile = new window.File([templateBlob], "template.docx", {
- type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
- });
-
- // 3. PDFTron WebViewer로 PDF 변환
- const { default: WebViewer } = await import("@pdftron/webviewer");
-
- const tempDiv = document.createElement('div');
- tempDiv.style.display = 'none';
- document.body.appendChild(tempDiv);
-
- try {
- const instance = await WebViewer(
- {
- path: "/pdftronWeb",
- licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
- fullAPI: true,
- enableOfficeEditing: true,
- },
- tempDiv
- );
-
- const { Core } = instance;
- const { createDocument } = Core;
-
- const templateDoc = await createDocument(templateFile, {
- filename: templateFile.name,
- extension: 'docx',
- });
-
- // 변수 치환 적용
- await templateDoc.applyTemplateValues(templateData);
- await new Promise(resolve => setTimeout(resolve, 3000));
-
- const fileData = await templateDoc.getFileData();
- const pdfBuffer = await (Core as any).officeToPDFBuffer(fileData, { extension: 'docx' });
-
- const fileName = `${contractType}_${contractSummary?.basicInfo?.vendorCode || vendorId}_${Date.now()}.pdf`;
-
- instance.UI.dispose();
- return {
- buffer: Array.from(pdfBuffer),
- fileName
- };
-
- } finally {
- if (tempDiv.parentNode) {
- document.body.removeChild(tempDiv);
- }
- }
-
- } catch (error) {
- console.error(`기본계약 PDF 생성 실패 (${contractType}):`, error);
- throw error;
- }
- };
-
- // 기본계약 생성 및 선택 초기화
- const initializeBasicContracts = React.useCallback(async () => {
- if (!contractSummary?.basicInfo) return;
-
- setIsLoadingBasicContracts(true);
- try {
- // 기본적으로 사용할 수 있는 계약서 타입들
- const availableContracts: Array<{
- type: string;
- templateName: string;
- checked: boolean;
- }> = [
- { type: "NDA", templateName: "비밀", checked: false },
- { type: "General_GTC", templateName: "General GTC", checked: false },
- { type: "기술자료", templateName: "기술", checked: false }
- ];
-
- // 프로젝트 코드가 있으면 Project GTC도 추가
- if (contractSummary.basicInfo.projectCode) {
- availableContracts.push({
- type: "Project_GTC",
- templateName: contractSummary.basicInfo.projectCode as string,
- checked: false
- });
- }
-
- setSelectedBasicContracts(availableContracts);
- } catch (error) {
- console.error('기본계약 초기화 실패:', error);
- toast.error('기본계약 초기화에 실패했습니다.');
- } finally {
- setIsLoadingBasicContracts(false);
- }
- }, [contractSummary]);
-
- // 기본계약 선택 토글
- const toggleBasicContract = (type: string) => {
- setSelectedBasicContracts(prev =>
- prev.map(contract =>
- contract.type === type
- ? { ...contract, checked: !contract.checked }
- : contract
- )
- );
- };
-
-
- // 1단계: 계약 현황 수집
- const collectContractSummary = React.useCallback(async () => {
- setIsLoading(true)
- try {
- // 각 컴포넌트에서 활성화된 데이터만 수집
- const summary: ContractSummary = {
- basicInfo: {},
- items: [],
- subcontractChecklist: null
- }
-
- // Basic Info 확인 (항상 활성화)
- try {
- const basicInfoData = await getBasicInfo(contractId)
- if (basicInfoData && basicInfoData.success) {
- summary.basicInfo = basicInfoData.data || {}
- }
- // externalYardEntry 정보도 추가로 가져오기
- const contractData = await getContractById(contractId)
- if (contractData) {
- summary.basicInfo = {
- ...summary.basicInfo,
- externalYardEntry: contractData.externalYardEntry || 'N'
- }
- }
- } catch {
- console.log('Basic Info 데이터 없음')
- }
-
- // 품목 정보 확인
- try {
- const itemsData = await getContractItems(contractId)
- if (itemsData && itemsData.length > 0) {
- summary.items = itemsData
- }
- } catch {
- console.log('품목 정보 데이터 없음')
- }
-
- try {
- // Subcontract Checklist 확인
- const subcontractData = await getSubcontractChecklist(contractId)
- if (subcontractData && subcontractData.success && subcontractData.enabled) {
- summary.subcontractChecklist = subcontractData.data
- }
- } catch {
- console.log('Subcontract Checklist 데이터 없음')
- }
-
- // 임치(물품보관) 계약 정보 확인 (SG)
- try {
- if (summary.basicInfo?.contractType === 'SG') {
- const storageData = await getStorageInfo(contractId)
- if (storageData && storageData.length > 0) {
- summary.storageInfo = storageData
- }
- }
- } catch {
- console.log('임치계약 정보 없음')
- }
-
- console.log('contractSummary 구조:', summary)
- console.log('basicInfo 내용:', summary.basicInfo)
- setContractSummary(summary)
- } catch (error) {
- console.error('Error collecting contract summary:', error)
- toast.error('계약 정보를 수집하는 중 오류가 발생했습니다.')
- } finally {
- setIsLoading(false)
- }
- }, [contractId])
-
- // 3단계: PDF 생성 및 미리보기 (PDFTron 사용) - 템플릿 자동 로드
- const generatePdf = async () => {
- if (!contractSummary) {
- toast.error('계약 정보가 필요합니다.')
- return
- }
-
- setIsLoading(true)
- try {
- // 1. 계약 유형에 맞는 템플릿 조회
- const contractType = contractSummary.basicInfo.contractType as string
- const templateResult = await getContractTemplateByContractType(contractType)
-
- if (!templateResult.success || !templateResult.template) {
- throw new Error(templateResult.error || '템플릿을 찾을 수 없습니다.')
- }
-
- const template = templateResult.template
-
- // 2. 템플릿 파일 다운로드
- const templateResponse = await fetch("/api/contracts/get-template", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ templatePath: template.filePath }),
- })
-
- if (!templateResponse.ok) {
- throw new Error("템플릿 파일을 다운로드할 수 없습니다.")
- }
-
- const templateBlob = await templateResponse.blob()
- const templateFile = new File([templateBlob], template.fileName || "template.docx", {
- type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
- })
-
- // 3. PDFTron을 사용해서 변수 치환 및 PDF 변환
- // @ts-ignore
- const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer)
-
- // 임시 WebViewer 인스턴스 생성 (DOM에 추가하지 않음)
- const tempDiv = document.createElement('div')
- tempDiv.style.display = 'none'
- document.body.appendChild(tempDiv)
-
- const instance = await WebViewer(
- {
- path: "/pdftronWeb",
- licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
- fullAPI: true,
- },
- tempDiv
- )
-
- try {
- const { Core } = instance
- const { createDocument } = Core
-
- // 템플릿 문서 생성 및 변수 치환
- const templateDoc = await createDocument(templateFile, {
- filename: templateFile.name,
- extension: 'docx',
- })
-
- // 템플릿 변수 매핑
- const mappedTemplateData = mapContractDataToTemplateVariables(contractSummary)
-
- console.log("🔄 변수 치환 시작:", mappedTemplateData)
- await templateDoc.applyTemplateValues(mappedTemplateData as any)
- console.log("✅ 변수 치환 완료")
-
- // PDF 변환
- const fileData = await templateDoc.getFileData()
- const pdfBuffer = await (Core as any).officeToPDFBuffer(fileData, { extension: 'docx' })
-
- console.log(`✅ PDF 변환 완료: ${templateFile.name}`, `크기: ${pdfBuffer.byteLength} bytes`)
-
- // PDF 버퍼를 Blob URL로 변환하여 미리보기
- const pdfBlob = new Blob([pdfBuffer], { type: 'application/pdf' })
- const pdfUrl = URL.createObjectURL(pdfBlob)
- setGeneratedPdfUrl(pdfUrl)
-
- // PDF 버퍼를 상태에 저장 (최종 전송 시 사용)
- setGeneratedPdfBuffer(new Uint8Array(pdfBuffer))
-
- toast.success('PDF가 생성되었습니다.')
-
- } finally {
- // 임시 WebViewer 정리
- instance.UI.dispose()
- document.body.removeChild(tempDiv)
- }
-
- } catch (error: any) {
- console.error('❌ PDF 생성 실패:', error)
- const errorMessage = error instanceof Error ? error.message : (error?.message || '알 수 없는 오류')
- toast.error(`PDF 생성 중 오류가 발생했습니다: ${errorMessage}`)
- } finally {
- setIsLoading(false)
- }
- }
-
- // PDF 미리보기 기능
- const openPdfPreview = async () => {
- if (!generatedPdfBuffer) {
- toast.error('생성된 PDF가 없습니다.')
- return
- }
-
- setIsLoading(true)
- try {
- // @ts-ignore
- const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer)
-
- // 기존 인스턴스가 있다면 정리
- if (pdfViewerInstance) {
- console.log("🔄 기존 WebViewer 인스턴스 정리")
- try {
- pdfViewerInstance.UI.dispose()
- } catch (error) {
- console.warn('기존 WebViewer 정리 중 오류:', error)
- }
- setPdfViewerInstance(null)
- }
-
- // 미리보기용 컨테이너 확인
- let previewDiv = document.getElementById('pdf-preview-container')
- if (!previewDiv) {
- console.log("🔄 컨테이너 생성")
- previewDiv = document.createElement('div')
- previewDiv.id = 'pdf-preview-container'
- previewDiv.className = 'w-full h-full'
- previewDiv.style.width = '100%'
- previewDiv.style.height = '100%'
-
- // 실제 컨테이너에 추가
- const actualContainer = document.querySelector('[data-pdf-container]')
- if (actualContainer) {
- actualContainer.appendChild(previewDiv)
- }
- }
-
- console.log("🔄 WebViewer 인스턴스 생성 시작")
-
- // WebViewer 인스턴스 생성 (문서 없이)
- const instance = await Promise.race([
- WebViewer(
- {
- path: "/pdftronWeb",
- licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
- fullAPI: true,
- },
- previewDiv
- ),
- new Promise((_, reject) =>
- setTimeout(() => reject(new Error('WebViewer 초기화 타임아웃')), 30000)
- )
- ])
-
- console.log("🔄 WebViewer 인스턴스 생성 완료")
- setPdfViewerInstance(instance)
-
- // PDF 버퍼를 Blob으로 변환
- const pdfBlob = new Blob([generatedPdfBuffer as any], { type: 'application/pdf' })
- const pdfUrl = URL.createObjectURL(pdfBlob)
- console.log("🔄 PDF Blob URL 생성:", pdfUrl)
-
- // 문서 로드
- console.log("🔄 문서 로드 시작")
- const { documentViewer } = (instance as any).Core
-
- // 문서 로드 이벤트 대기
- await new Promise((resolve, reject) => {
- const timeout = setTimeout(() => {
- reject(new Error('문서 로드 타임아웃'))
- }, 20000)
-
- const onDocumentLoaded = () => {
- clearTimeout(timeout)
- documentViewer.removeEventListener('documentLoaded', onDocumentLoaded)
- documentViewer.removeEventListener('documentError', onDocumentError)
- console.log("🔄 문서 로드 완료")
- resolve(true)
- }
-
- const onDocumentError = (error: any) => {
- clearTimeout(timeout)
- documentViewer.removeEventListener('documentLoaded', onDocumentLoaded)
- documentViewer.removeEventListener('documentError', onDocumentError)
- console.error('문서 로드 오류:', error)
- reject(error)
- }
-
- documentViewer.addEventListener('documentLoaded', onDocumentLoaded)
- documentViewer.addEventListener('documentError', onDocumentError)
-
- // 문서 로드 시작
- documentViewer.loadDocument(pdfUrl)
- })
-
- setIsPdfPreviewVisible(true)
- toast.success('PDF 미리보기가 준비되었습니다.')
-
- } catch (error) {
- console.error('PDF 미리보기 실패:', error)
- toast.error(`PDF 미리보기 중 오류가 발생했습니다: ${error.message}`)
- } finally {
- setIsLoading(false)
- }
- }
-
- // PDF 다운로드 기능
- const downloadPdf = () => {
- if (!generatedPdfBuffer) {
- toast.error('다운로드할 PDF가 없습니다.')
- return
- }
-
- const pdfBlob = new Blob([generatedPdfBuffer as any], { type: 'application/pdf' })
- const pdfUrl = URL.createObjectURL(pdfBlob)
-
- const link = document.createElement('a')
- link.href = pdfUrl
- link.download = `contract_${contractId}_${Date.now()}.pdf`
- document.body.appendChild(link)
- link.click()
- document.body.removeChild(link)
-
- URL.revokeObjectURL(pdfUrl)
- toast.success('PDF가 다운로드되었습니다.')
- }
-
- // PDF 미리보기 닫기
- const closePdfPreview = () => {
- console.log("🔄 PDF 미리보기 닫기 시작")
- if (pdfViewerInstance) {
- try {
- console.log("🔄 WebViewer 인스턴스 정리")
- pdfViewerInstance.UI.dispose()
- } catch (error) {
- console.warn('WebViewer 정리 중 오류:', error)
- }
- setPdfViewerInstance(null)
- }
-
- // 컨테이너 정리
- const previewDiv = document.getElementById('pdf-preview-container')
- if (previewDiv) {
- try {
- previewDiv.innerHTML = ''
- } catch (error) {
- console.warn('컨테이너 정리 중 오류:', error)
- }
- }
-
- setIsPdfPreviewVisible(false)
- console.log("🔄 PDF 미리보기 닫기 완료")
- }
-
- // PDF를 서버에 저장하는 함수 (API route 사용)
- const savePdfToServer = async (pdfBuffer: Uint8Array, fileName: string): Promise<string | null> => {
- try {
- // PDF 버퍼를 Blob으로 변환
- const pdfBlob = new Blob([pdfBuffer], { type: 'application/pdf' });
-
- // FormData 생성
- const formData = new FormData();
- formData.append('file', pdfBlob, fileName);
- formData.append('contractId', String(contractId));
-
- // API route로 업로드
- const response = await fetch('/api/general-contracts/upload-pdf', {
- method: 'POST',
- body: formData,
- });
-
- if (!response.ok) {
- const errorData = await response.json();
- throw new Error(errorData.error || 'PDF 파일 저장에 실패했습니다.');
- }
-
- const result = await response.json();
-
- if (!result.success) {
- throw new Error(result.error || 'PDF 파일 저장에 실패했습니다.');
- }
-
- return result.filePath;
- } catch (error) {
- console.error('PDF 저장 실패:', error);
- return null;
- }
- };
-
- // 최종 전송 - 결재 프로세스 시작
- const handleFinalSubmit = async () => {
- if (!generatedPdfUrl || !contractSummary || !generatedPdfBuffer) {
- toast.error('생성된 PDF가 필요합니다.')
- return
- }
-
- if (!userId) {
- toast.error('로그인이 필요합니다.')
- return
- }
-
- setIsLoading(true)
- try {
- // 기본계약서 생성 (최종 전송 시점에)
- let generatedBasicContractPdfs: Array<{ key: string; buffer: number[]; fileName: string }> = [];
-
- const contractsToGenerate = selectedBasicContracts.filter(c => c.checked);
- if (contractsToGenerate.length > 0) {
- // vendorId 조회
- let vendorId: number | undefined;
- try {
- const basicInfoData = await getBasicInfo(contractId);
- if (basicInfoData && basicInfoData.success && basicInfoData.data) {
- vendorId = basicInfoData.data.vendorId;
- }
- } catch (error) {
- console.error('vendorId 조회 실패:', error);
- }
-
- if (vendorId) {
- toast.info('기본계약서를 생성하는 중입니다...');
-
- for (const contract of contractsToGenerate) {
- try {
- const pdf = await generateBasicContractPdf(vendorId, contract.type, contract.templateName);
- generatedBasicContractPdfs.push({
- key: `${vendorId}_${contract.type}_${contract.templateName}`,
- ...pdf
- });
- } catch (error) {
- console.error(`${contract.type} 계약서 생성 실패:`, error);
- // 개별 실패는 전체를 중단하지 않음
- }
- }
-
- if (generatedBasicContractPdfs.length > 0) {
- toast.success(`${generatedBasicContractPdfs.length}개의 기본계약서가 생성되었습니다.`);
- }
- }
- }
-
- // PDF를 서버에 저장
- toast.info('PDF를 서버에 저장하는 중입니다...');
- const pdfPath = await savePdfToServer(
- generatedPdfBuffer,
- `contract_${contractId}_${Date.now()}.pdf`
- );
-
- if (!pdfPath) {
- toast.error('PDF 저장에 실패했습니다.');
- return;
- }
-
- setSavedPdfPath(pdfPath);
- setSavedBasicContractPdfs(generatedBasicContractPdfs);
-
- // 결재 템플릿 변수 매핑
- const approvalVars = await mapContractToApprovalTemplateVariables(contractSummary);
- setApprovalVariables(approvalVars);
-
- // 계약승인요청 dialog close
- onOpenChange(false);
-
- // 결재 템플릿 dialog open
- setApprovalDialogOpen(true);
- } catch (error: any) {
- console.error('Error preparing approval:', error);
- toast.error('결재 준비 중 오류가 발생했습니다.')
- } finally {
- setIsLoading(false)
- }
- }
-
- // 결재 등록 처리
- const handleApprovalSubmit = async (data: {
- approvers: string[];
- title: string;
- attachments?: File[];
- }) => {
- if (!contractSummary || !savedPdfPath) {
- toast.error('계약 정보가 필요합니다.')
- return
- }
-
- setIsLoading(true)
- try {
- const result = await requestContractApprovalWithApproval({
- contractId,
- contractSummary: {
- ...contractSummary,
- // PDF 경로를 contractSummary에 추가
- pdfPath: savedPdfPath || undefined,
- basicContractPdfs: savedBasicContractPdfs.length > 0 ? savedBasicContractPdfs : undefined,
- } as ContractSummary,
- currentUser: {
- id: Number(userId),
- epId: session?.user?.epId || null,
- email: session?.user?.email || undefined,
- },
- approvers: data.approvers,
- title: data.title,
- });
-
- if (result.status === 'pending_approval') {
- toast.success('결재가 등록되었습니다.')
- setApprovalDialogOpen(false);
- } else {
- toast.error('결재 등록에 실패했습니다.')
- }
- } catch (error: any) {
- console.error('Error submitting approval:', error);
- toast.error(`결재 등록 중 오류가 발생했습니다: ${error.message || '알 수 없는 오류'}`);
- } finally {
- setIsLoading(false)
- }
- }
-
- // 다이얼로그가 열릴 때 1단계 데이터 수집
- useEffect(() => {
- if (open && currentStep === 1) {
- collectContractSummary()
- }
- }, [open, currentStep, collectContractSummary])
-
- // 계약 요약이 준비되면 기본계약 초기화
- useEffect(() => {
- if (contractSummary && currentStep === 2) {
- const loadBasicContracts = async () => {
- await initializeBasicContracts()
- }
- loadBasicContracts()
- }
- }, [contractSummary, currentStep, initializeBasicContracts])
-
- // 다이얼로그가 닫힐 때 PDF 뷰어 정리
- useEffect(() => {
- if (!open) {
- closePdfPreview()
- }
- }, [open])
-
-
- return (
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
- <DialogHeader>
- <DialogTitle className="flex items-center gap-2">
- <FileText className="h-5 w-5" />
- 계약승인요청
- </DialogTitle>
- </DialogHeader>
-
- <Tabs value={currentStep.toString()} className="w-full">
- <TabsList className="grid w-full grid-cols-3">
- <TabsTrigger value="1" disabled={currentStep < 1}>
- 1. 계약 현황 정리
- </TabsTrigger>
- <TabsTrigger value="2" disabled={currentStep < 2}>
- 2. 기본계약 체크
- </TabsTrigger>
- <TabsTrigger value="3" disabled={currentStep < 3}>
- 3. PDF 미리보기
- </TabsTrigger>
- </TabsList>
-
- {/* 1단계: 계약 현황 정리 */}
- <TabsContent value="1" className="space-y-4">
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <CheckCircle className="h-5 w-5 text-green-600" />
- 작성된 계약 현황
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- {isLoading ? (
- <div className="text-center py-4">
- <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
- <p className="mt-2 text-sm text-muted-foreground">계약 정보를 수집하는 중...</p>
- </div>
- ) : (
- <div className="space-y-4">
- {/* 기본 정보 (필수) */}
- <div className="border rounded-lg p-4">
- <div className="flex items-center gap-2 mb-3">
- <CheckCircle className="h-4 w-4 text-green-600" />
- <Label className="font-medium">기본 정보</Label>
- <Badge variant="secondary">필수</Badge>
- </div>
- <div className="grid grid-cols-2 gap-4 text-sm">
- <div>
- <span className="font-medium">계약번호:</span> {String(contractSummary?.basicInfo?.contractNumber || '')}
- </div>
- <div>
- <span className="font-medium">계약명:</span> {String(contractSummary?.basicInfo?.contractName || '')}
- </div>
- <div>
- <span className="font-medium">벤더:</span> {String(contractSummary?.basicInfo?.vendorName || '')}
- </div>
- <div>
- <span className="font-medium">프로젝트:</span> {String(contractSummary?.basicInfo?.projectName || '')}
- </div>
- <div>
- <span className="font-medium">계약유형:</span> {String(contractSummary?.basicInfo?.contractType || '')}
- </div>
- <div>
- <span className="font-medium">계약상태:</span> {String(contractSummary?.basicInfo?.contractStatus || '')}
- </div>
- <div>
- <span className="font-medium">계약금액:</span> {String(contractSummary?.basicInfo?.contractAmount || '')} {String(contractSummary?.basicInfo?.currency || '')}
- </div>
- <div>
- <span className="font-medium">계약기간:</span> {String(contractSummary?.basicInfo?.startDate || '')} ~ {String(contractSummary?.basicInfo?.endDate || '')}
- </div>
- <div>
- <span className="font-medium">사양서 유형:</span> {String(contractSummary?.basicInfo?.specificationType || '')}
- </div>
- <div>
- <span className="font-medium">단가 유형:</span> {String(contractSummary?.basicInfo?.unitPriceType || '')}
- </div>
- <div>
- <span className="font-medium">연결 PO번호:</span> {String(contractSummary?.basicInfo?.linkedPoNumber || '')}
- </div>
- <div>
- <span className="font-medium">연결 입찰번호:</span> {String(contractSummary?.basicInfo?.linkedBidNumber || '')}
- </div>
- </div>
- </div>
-
- {/* 지급/인도 조건 */}
- <div className="border rounded-lg p-4">
- <div className="flex items-center gap-2 mb-3">
- <CheckCircle className="h-4 w-4 text-green-600" />
- <Label className="font-medium">지급/인도 조건</Label>
- <Badge variant="secondary">필수</Badge>
- </div>
- <div className="grid grid-cols-2 gap-4 text-sm">
- <div>
- <span className="font-medium">지급조건:</span> {String(contractSummary?.basicInfo?.paymentTerm || '')}
- </div>
- <div>
- <span className="font-medium">세금 유형:</span> {String(contractSummary?.basicInfo?.taxType || '')}
- </div>
- <div>
- <span className="font-medium">인도조건:</span> {String(contractSummary?.basicInfo?.deliveryTerm || '')}
- </div>
- <div>
- <span className="font-medium">인도유형:</span> {String(contractSummary?.basicInfo?.deliveryType || '')}
- </div>
- <div>
- <span className="font-medium">선적지:</span> {String(contractSummary?.basicInfo?.shippingLocation || '')}
- </div>
- <div>
- <span className="font-medium">하역지:</span> {String(contractSummary?.basicInfo?.dischargeLocation || '')}
- </div>
- <div>
- <span className="font-medium">계약납기:</span> {String(contractSummary?.basicInfo?.contractDeliveryDate || '')}
- </div>
- <div>
- <span className="font-medium">위약금:</span> {contractSummary?.basicInfo?.liquidatedDamages ? '적용' : '미적용'}
- </div>
- </div>
- </div>
-
- {/* 추가 조건 */}
- <div className="border rounded-lg p-4">
- <div className="flex items-center gap-2 mb-3">
- <CheckCircle className="h-4 w-4 text-green-600" />
- <Label className="font-medium">추가 조건</Label>
- <Badge variant="secondary">필수</Badge>
- </div>
- <div className="grid grid-cols-2 gap-4 text-sm">
- <div>
- <span className="font-medium">연동제 정보:</span> {String(contractSummary?.basicInfo?.interlockingSystem || '')}
- </div>
- <div>
- <span className="font-medium">계약성립조건:</span>
- {contractSummary?.basicInfo?.contractEstablishmentConditions &&
- Object.entries(contractSummary.basicInfo.contractEstablishmentConditions as Record<string, unknown>)
- .filter(([, value]) => value === true)
- .map(([key]) => key)
- .join(', ') || '없음'}
- </div>
- <div>
- <span className="font-medium">계약해지조건:</span>
- {contractSummary?.basicInfo?.contractTerminationConditions &&
- Object.entries(contractSummary.basicInfo.contractTerminationConditions as Record<string, unknown>)
- .filter(([, value]) => value === true)
- .map(([key]) => key)
- .join(', ') || '없음'}
- </div>
- </div>
- </div>
-
- {/* 품목 정보 */}
- <div className="border rounded-lg p-4">
- <div className="flex items-center gap-2 mb-3">
- <Checkbox
- id="items-enabled"
- checked={contractSummary?.items && contractSummary.items.length > 0}
- disabled
- />
- <Label htmlFor="items-enabled" className="font-medium">품목 정보</Label>
- <Badge variant="outline">선택</Badge>
- </div>
- {contractSummary?.items && contractSummary.items.length > 0 ? (
- <div className="space-y-2">
- <p className="text-sm text-muted-foreground">
- 총 {contractSummary.items.length}개 품목이 입력되어 있습니다.
- </p>
- <div className="max-h-32 overflow-y-auto">
- {contractSummary.items.slice(0, 3).map((item: Record<string, unknown>, index: number) => (
- <div key={index} className="text-xs bg-gray-50 p-2 rounded">
- <div className="font-medium">{String(item.itemInfo || item.description || `품목 ${index + 1}`)}</div>
- <div className="text-muted-foreground">
- 수량: {String(item.quantity || 0)} | 단가: {String(item.contractUnitPrice || item.unitPrice || 0)}
- </div>
- </div>
- ))}
- {contractSummary.items.length > 3 && (
- <div className="text-xs text-muted-foreground text-center">
- ... 외 {contractSummary.items.length - 3}개 품목
- </div>
- )}
- </div>
- </div>
- ) : (
- <p className="text-sm text-muted-foreground">
- 품목 정보가 입력되지 않았습니다.
- </p>
- )}
- </div>
-
- {/* 하도급 체크리스트 */}
- <div className="border rounded-lg p-4">
- <div className="flex items-center gap-2 mb-3">
- <Checkbox
- id="subcontract-enabled"
- checked={!!contractSummary?.subcontractChecklist}
- disabled
- />
- <Label htmlFor="subcontract-enabled" className="font-medium">
- 하도급 체크리스트
- </Label>
- <Badge variant="outline">선택</Badge>
- </div>
- <p className="text-sm text-muted-foreground">
- {contractSummary?.subcontractChecklist
- ? '정보가 입력되어 있습니다.'
- : '정보가 입력되지 않았습니다.'}
- </p>
- </div>
- </div>
- )}
- </CardContent>
- </Card>
-
- <div className="flex justify-end">
- <Button
- onClick={() => setCurrentStep(2)}
- disabled={isLoading}
- >
- 다음 단계
- </Button>
- </div>
- </TabsContent>
-
- {/* 2단계: 기본계약 체크 */}
- <TabsContent value="2" className="space-y-4">
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <FileText className="h-5 w-5 text-blue-600" />
- 기본계약서 선택
- </CardTitle>
- <p className="text-sm text-muted-foreground">
- 벤더에게 발송할 기본계약서를 선택해주세요. (템플릿이 있는 계약서만 선택 가능합니다.)
- </p>
- </CardHeader>
- <CardContent className="space-y-4">
- {isLoadingBasicContracts ? (
- <div className="text-center py-8">
- <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
- <p className="mt-2 text-sm text-muted-foreground">기본계약 템플릿을 불러오는 중...</p>
- </div>
- ) : (
- <div className="space-y-4">
- {selectedBasicContracts.length > 0 ? (
- <div className="space-y-3">
- <div className="flex items-center justify-between">
- <h4 className="font-medium">필요한 기본계약서</h4>
- <Badge variant="outline">
- {selectedBasicContracts.filter(c => c.checked).length}개 선택됨
- </Badge>
- </div>
-
- <div className="grid gap-3">
- {selectedBasicContracts.map((contract) => (
- <div
- key={contract.type}
- className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50"
- >
- <div className="flex items-center gap-3">
- <Checkbox
- id={`contract-${contract.type}`}
- checked={contract.checked}
- onCheckedChange={() => toggleBasicContract(contract.type)}
- />
- <div>
- <Label
- htmlFor={`contract-${contract.type}`}
- className="font-medium cursor-pointer"
- >
- {contract.type}
- </Label>
- <p className="text-sm text-muted-foreground">
- 템플릿: {contract.templateName}
- </p>
- </div>
- </div>
- <Badge
- variant="secondary"
- className="text-xs"
- >
- {contract.checked ? "선택됨" : "미선택"}
- </Badge>
- </div>
- ))}
- </div>
-
- </div>
- ) : (
- <div className="text-center py-8 text-muted-foreground">
- <FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
- <p>기본계약서 목록을 불러올 수 없습니다.</p>
- <p className="text-sm">잠시 후 다시 시도해주세요.</p>
- </div>
- )}
-
- </div>
- )}
- </CardContent>
- </Card>
-
- <div className="flex justify-between">
- <Button variant="outline" onClick={() => setCurrentStep(1)}>
- 이전 단계
- </Button>
- <Button
- onClick={() => setCurrentStep(3)}
- disabled={isLoadingBasicContracts}
- >
- 다음 단계
- </Button>
- </div>
- </TabsContent>
-
- {/* 3단계: PDF 미리보기 */}
- <TabsContent value="3" className="space-y-4">
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <Eye className="h-5 w-5 text-purple-600" />
- PDF 미리보기
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- {!generatedPdfUrl ? (
- <div className="text-center py-8">
- <Button onClick={generatePdf} disabled={isLoading}>
- {isLoading ? 'PDF 생성 중...' : 'PDF 생성하기'}
- </Button>
- </div>
- ) : (
- <div className="space-y-4">
- <div className="border rounded-lg p-4 bg-green-50">
- <div className="flex items-center gap-2">
- <CheckCircle className="h-4 w-4 text-green-600" />
- <span className="font-medium text-green-900">PDF 생성 완료</span>
- </div>
- </div>
-
- <div className="border rounded-lg p-4">
- <div className="flex items-center justify-between mb-4">
- <h4 className="font-medium">생성된 PDF</h4>
- <div className="flex gap-2">
- <Button
- variant="outline"
- size="sm"
- onClick={downloadPdf}
- disabled={isLoading}
- >
- <Download className="h-4 w-4 mr-2" />
- 다운로드
- </Button>
- <Button
- variant="outline"
- size="sm"
- onClick={openPdfPreview}
- disabled={isLoading}
- >
- <Eye className="h-4 w-4 mr-2" />
- 미리보기
- </Button>
- </div>
- </div>
-
- {/* PDF 미리보기 영역 */}
- <div className="border rounded-lg h-96 bg-gray-50 relative" data-pdf-container>
- {isPdfPreviewVisible ? (
- <>
- <div className="absolute top-2 right-2 z-10">
- <Button
- variant="outline"
- size="sm"
- onClick={closePdfPreview}
- className="bg-white/90 hover:bg-white"
- >
- ✕ 닫기
- </Button>
- </div>
- <div id="pdf-preview-container" className="w-full h-full" />
- </>
- ) : (
- <div className="flex items-center justify-center h-full">
- <div className="text-center text-muted-foreground">
- <FileText className="h-12 w-12 mx-auto mb-2" />
- <p>미리보기 버튼을 클릭하여 PDF를 확인하세요</p>
- </div>
- </div>
- )}
- </div>
- </div>
- </div>
- )}
- </CardContent>
- </Card>
-
- <div className="flex justify-between">
- <Button variant="outline" onClick={() => setCurrentStep(2)}>
- 이전 단계
- </Button>
- <Button
- onClick={handleFinalSubmit}
- disabled={!generatedPdfUrl || isLoading}
- className="bg-green-600 hover:bg-green-700"
- >
- <Send className="h-4 w-4 mr-2" />
- {isLoading ? '전송 중...' : '최종 전송'}
- </Button>
- </div>
- </TabsContent>
- </Tabs>
- </DialogContent>
-
- {/* 결재 미리보기 Dialog */}
- {session?.user && session.user.epId && contractSummary && (
- <ApprovalPreviewDialog
- open={approvalDialogOpen}
- onOpenChange={(open) => {
- setApprovalDialogOpen(open);
- if (!open) {
- setApprovalVariables({});
- setSavedPdfPath(null);
- setSavedBasicContractPdfs([]);
- }
- }}
- templateName="일반계약 결재"
- variables={approvalVariables}
- title={`계약 체결 진행 품의 요청서 - ${contractSummary.basicInfo?.contractNumber || contractId}`}
- currentUser={{
- id: Number(session.user.id),
- epId: session.user.epId,
- name: session.user.name || undefined,
- email: session.user.email || undefined,
- }}
- onConfirm={handleApprovalSubmit}
- enableAttachments={false}
- />
- )}
- </Dialog>
+'use client' + +import React, { useState, useEffect } from 'react' +import { useSession } from 'next-auth/react' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { Button } from '@/components/ui/button' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { Checkbox } from '@/components/ui/checkbox' +import { Label } from '@/components/ui/label' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Input } from '@/components/ui/input' +import { toast } from 'sonner' +import { + FileText, + Upload, + Eye, + Send, + CheckCircle, + Download, + AlertCircle +} from 'lucide-react' +import { ContractDocuments } from './general-contract-documents' +import { getActiveContractTemplates } from '@/lib/bidding/service' +import { type BasicContractTemplate } from '@/db/schema' +import { + getBasicInfo, + getContractItems, + getSubcontractChecklist, + uploadContractApprovalFile, + sendContractApprovalRequest, + getContractById, + getContractTemplateByContractType, + getStorageInfo +} from '../service' +import { mapContractDataToTemplateVariables } from '../utils' +import { ApprovalPreviewDialog } from '@/lib/approval/client' +import { requestContractApprovalWithApproval } from '../approval-actions' +import { mapContractToApprovalTemplateVariables } from '../approval-template-variables' + +interface ContractApprovalRequestDialogProps { + contract: Record<string, unknown> + open: boolean + onOpenChange: (open: boolean) => void +} + +interface ContractSummary { + basicInfo: Record<string, unknown> + items: Record<string, unknown>[] + subcontractChecklist: Record<string, unknown> | null + storageInfo?: Record<string, unknown>[] + pdfPath?: string + basicContractPdfs?: Array<{ key: string; buffer: number[]; fileName: string }> +} + +export function ContractApprovalRequestDialog({ + contract, + open, + onOpenChange +}: ContractApprovalRequestDialogProps) { + const { data: session } = useSession() + const [currentStep, setCurrentStep] = useState(1) + const [contractSummary, setContractSummary] = useState<ContractSummary | null>(null) + const [uploadedFile, setUploadedFile] = useState<File | null>(null) + const [generatedPdfUrl, setGeneratedPdfUrl] = useState<string | null>(null) + const [generatedPdfBuffer, setGeneratedPdfBuffer] = useState<Uint8Array | null>(null) + const [isLoading, setIsLoading] = useState(false) + const [pdfViewerInstance, setPdfViewerInstance] = useState<any>(null) + const [isPdfPreviewVisible, setIsPdfPreviewVisible] = useState(false) + + // 기본계약 관련 상태 + const [selectedBasicContracts, setSelectedBasicContracts] = useState<Array<{ + type: string; + templateName: string; + checked: boolean; + }>>([]) + const [isLoadingBasicContracts, setIsLoadingBasicContracts] = useState(false) + + // 결재 관련 상태 + const [approvalDialogOpen, setApprovalDialogOpen] = useState(false) + const [approvalVariables, setApprovalVariables] = useState<Record<string, string>>({}) + const [savedPdfPath, setSavedPdfPath] = useState<string | null>(null) + const [savedBasicContractPdfs, setSavedBasicContractPdfs] = useState<Array<{ key: string; buffer: number[]; fileName: string }>>([]) + + const contractId = contract.id as number + const userId = session?.user?.id || '' + + + // 기본계약 생성 함수 (최종 전송 시점에 호출) + const generateBasicContractPdf = async ( + vendorId: number, + contractType: string, + templateName: string + ): Promise<{ buffer: number[], fileName: string }> => { + try { + // 1. 템플릿 데이터 준비 (서버 액션 호출) + const prepareResponse = await fetch("/api/contracts/prepare-template", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + templateName, + vendorId, + }), + }); + + if (!prepareResponse.ok) { + const errorText = await prepareResponse.text(); + throw new Error(`템플릿 준비 실패 (${prepareResponse.status}): ${errorText}`); + } + + const { template, templateData } = await prepareResponse.json(); + + // 2. 템플릿 파일 다운로드 + const templateResponse = await fetch("/api/contracts/get-template", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ templatePath: template.filePath }), + }); + + const templateBlob = await templateResponse.blob(); + const templateFile = new window.File([templateBlob], "template.docx", { + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + }); + + // 3. PDFTron WebViewer로 PDF 변환 + const { default: WebViewer } = await import("@pdftron/webviewer"); + + const tempDiv = document.createElement('div'); + tempDiv.style.display = 'none'; + document.body.appendChild(tempDiv); + + try { + const instance = await WebViewer( + { + path: "/pdftronWeb", + licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, + fullAPI: true, + enableOfficeEditing: true, + }, + tempDiv + ); + + const { Core } = instance; + const { createDocument } = Core; + + const templateDoc = await createDocument(templateFile, { + filename: templateFile.name, + extension: 'docx', + }); + + // 변수 치환 적용 + await templateDoc.applyTemplateValues(templateData); + await new Promise(resolve => setTimeout(resolve, 3000)); + + const fileData = await templateDoc.getFileData(); + const pdfBuffer = await (Core as any).officeToPDFBuffer(fileData, { extension: 'docx' }); + + const fileName = `${contractType}_${contractSummary?.basicInfo?.vendorCode || vendorId}_${Date.now()}.pdf`; + + instance.UI.dispose(); + return { + buffer: Array.from(pdfBuffer), + fileName + }; + + } finally { + if (tempDiv.parentNode) { + document.body.removeChild(tempDiv); + } + } + + } catch (error) { + console.error(`기본계약 PDF 생성 실패 (${contractType}):`, error); + throw error; + } + }; + + // 기본계약 생성 및 선택 초기화 + const initializeBasicContracts = React.useCallback(async () => { + if (!contractSummary?.basicInfo) return; + + setIsLoadingBasicContracts(true); + try { + // 기본적으로 사용할 수 있는 계약서 타입들 + const availableContracts: Array<{ + type: string; + templateName: string; + checked: boolean; + }> = [ + { type: "NDA", templateName: "비밀", checked: false }, + { type: "General_GTC", templateName: "General GTC", checked: false }, + { type: "기술자료", templateName: "기술", checked: false } + ]; + + // 프로젝트 코드가 있으면 Project GTC도 추가 + if (contractSummary.basicInfo.projectCode) { + availableContracts.push({ + type: "Project_GTC", + templateName: contractSummary.basicInfo.projectCode as string, + checked: false + }); + } + + setSelectedBasicContracts(availableContracts); + } catch (error) { + console.error('기본계약 초기화 실패:', error); + toast.error('기본계약 초기화에 실패했습니다.'); + } finally { + setIsLoadingBasicContracts(false); + } + }, [contractSummary]); + + // 기본계약 선택 토글 + const toggleBasicContract = (type: string) => { + setSelectedBasicContracts(prev => + prev.map(contract => + contract.type === type + ? { ...contract, checked: !contract.checked } + : contract + ) + ); + }; + + + // 1단계: 계약 현황 수집 + const collectContractSummary = React.useCallback(async () => { + setIsLoading(true) + try { + // 각 컴포넌트에서 활성화된 데이터만 수집 + const summary: ContractSummary = { + basicInfo: {}, + items: [], + subcontractChecklist: null + } + + // Basic Info 확인 (항상 활성화) + try { + const basicInfoData = await getBasicInfo(contractId) + if (basicInfoData && basicInfoData.success) { + summary.basicInfo = basicInfoData.data || {} + } + // externalYardEntry 정보도 추가로 가져오기 + const contractData = await getContractById(contractId) + if (contractData) { + summary.basicInfo = { + ...summary.basicInfo, + externalYardEntry: contractData.externalYardEntry || 'N' + } + } + } catch { + console.log('Basic Info 데이터 없음') + } + + // 품목 정보 확인 + try { + const itemsData = await getContractItems(contractId) + if (itemsData && itemsData.length > 0) { + summary.items = itemsData + } + } catch { + console.log('품목 정보 데이터 없음') + } + + try { + // Subcontract Checklist 확인 + const subcontractData = await getSubcontractChecklist(contractId) + if (subcontractData && subcontractData.success && subcontractData.enabled) { + summary.subcontractChecklist = subcontractData.data + } + } catch { + console.log('Subcontract Checklist 데이터 없음') + } + + // 임치(물품보관) 계약 정보 확인 (SG) + try { + if (summary.basicInfo?.contractType === 'SG') { + const storageData = await getStorageInfo(contractId) + if (storageData && storageData.length > 0) { + summary.storageInfo = storageData + } + } + } catch { + console.log('임치계약 정보 없음') + } + + console.log('contractSummary 구조:', summary) + console.log('basicInfo 내용:', summary.basicInfo) + setContractSummary(summary) + } catch (error) { + console.error('Error collecting contract summary:', error) + toast.error('계약 정보를 수집하는 중 오류가 발생했습니다.') + } finally { + setIsLoading(false) + } + }, [contractId]) + + // 3단계: PDF 생성 및 미리보기 (PDFTron 사용) - 템플릿 자동 로드 + const generatePdf = async () => { + if (!contractSummary) { + toast.error('계약 정보가 필요합니다.') + return + } + + setIsLoading(true) + try { + // 1. 계약 유형에 맞는 템플릿 조회 + const contractType = contractSummary.basicInfo.contractType as string + const templateResult = await getContractTemplateByContractType(contractType) + + if (!templateResult.success || !templateResult.template) { + throw new Error(templateResult.error || '템플릿을 찾을 수 없습니다.') + } + + const template = templateResult.template + + // 2. 템플릿 파일 다운로드 + const templateResponse = await fetch("/api/contracts/get-template", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ templatePath: template.filePath }), + }) + + if (!templateResponse.ok) { + throw new Error("템플릿 파일을 다운로드할 수 없습니다.") + } + + const templateBlob = await templateResponse.blob() + const templateFile = new File([templateBlob], template.fileName || "template.docx", { + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + }) + + // 3. PDFTron을 사용해서 변수 치환 및 PDF 변환 + // @ts-ignore + const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer) + + // 임시 WebViewer 인스턴스 생성 (DOM에 추가하지 않음) + const tempDiv = document.createElement('div') + tempDiv.style.display = 'none' + document.body.appendChild(tempDiv) + + const instance = await WebViewer( + { + path: "/pdftronWeb", + licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, + fullAPI: true, + }, + tempDiv + ) + + try { + const { Core } = instance + const { createDocument } = Core + + // 템플릿 문서 생성 및 변수 치환 + const templateDoc = await createDocument(templateFile, { + filename: templateFile.name, + extension: 'docx', + }) + + // 템플릿 변수 매핑 + const mappedTemplateData = mapContractDataToTemplateVariables(contractSummary) + + console.log("🔄 변수 치환 시작:", mappedTemplateData) + await templateDoc.applyTemplateValues(mappedTemplateData as any) + console.log("✅ 변수 치환 완료") + + // PDF 변환 + const fileData = await templateDoc.getFileData() + const pdfBuffer = await (Core as any).officeToPDFBuffer(fileData, { extension: 'docx' }) + + console.log(`✅ PDF 변환 완료: ${templateFile.name}`, `크기: ${pdfBuffer.byteLength} bytes`) + + // PDF 버퍼를 Blob URL로 변환하여 미리보기 + const pdfBlob = new Blob([pdfBuffer], { type: 'application/pdf' }) + const pdfUrl = URL.createObjectURL(pdfBlob) + setGeneratedPdfUrl(pdfUrl) + + // PDF 버퍼를 상태에 저장 (최종 전송 시 사용) + setGeneratedPdfBuffer(new Uint8Array(pdfBuffer)) + + toast.success('PDF가 생성되었습니다.') + + } finally { + // 임시 WebViewer 정리 + instance.UI.dispose() + document.body.removeChild(tempDiv) + } + + } catch (error: any) { + console.error('❌ PDF 생성 실패:', error) + const errorMessage = error instanceof Error ? error.message : (error?.message || '알 수 없는 오류') + toast.error(`PDF 생성 중 오류가 발생했습니다: ${errorMessage}`) + } finally { + setIsLoading(false) + } + } + + // PDF 미리보기 기능 + const openPdfPreview = async () => { + if (!generatedPdfBuffer) { + toast.error('생성된 PDF가 없습니다.') + return + } + + setIsLoading(true) + try { + // @ts-ignore + const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer) + + // 기존 인스턴스가 있다면 정리 + if (pdfViewerInstance) { + console.log("🔄 기존 WebViewer 인스턴스 정리") + try { + pdfViewerInstance.UI.dispose() + } catch (error) { + console.warn('기존 WebViewer 정리 중 오류:', error) + } + setPdfViewerInstance(null) + } + + // 미리보기용 컨테이너 확인 + let previewDiv = document.getElementById('pdf-preview-container') + if (!previewDiv) { + console.log("🔄 컨테이너 생성") + previewDiv = document.createElement('div') + previewDiv.id = 'pdf-preview-container' + previewDiv.className = 'w-full h-full' + previewDiv.style.width = '100%' + previewDiv.style.height = '100%' + + // 실제 컨테이너에 추가 + const actualContainer = document.querySelector('[data-pdf-container]') + if (actualContainer) { + actualContainer.appendChild(previewDiv) + } + } + + console.log("🔄 WebViewer 인스턴스 생성 시작") + + // WebViewer 인스턴스 생성 (문서 없이) + const instance = await Promise.race([ + WebViewer( + { + path: "/pdftronWeb", + licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, + fullAPI: true, + }, + previewDiv + ), + new Promise((_, reject) => + setTimeout(() => reject(new Error('WebViewer 초기화 타임아웃')), 30000) + ) + ]) + + console.log("🔄 WebViewer 인스턴스 생성 완료") + setPdfViewerInstance(instance) + + // PDF 버퍼를 Blob으로 변환 + const pdfBlob = new Blob([generatedPdfBuffer as any], { type: 'application/pdf' }) + const pdfUrl = URL.createObjectURL(pdfBlob) + console.log("🔄 PDF Blob URL 생성:", pdfUrl) + + // 문서 로드 + console.log("🔄 문서 로드 시작") + const { documentViewer } = (instance as any).Core + + // 문서 로드 이벤트 대기 + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('문서 로드 타임아웃')) + }, 20000) + + const onDocumentLoaded = () => { + clearTimeout(timeout) + documentViewer.removeEventListener('documentLoaded', onDocumentLoaded) + documentViewer.removeEventListener('documentError', onDocumentError) + console.log("🔄 문서 로드 완료") + resolve(true) + } + + const onDocumentError = (error: any) => { + clearTimeout(timeout) + documentViewer.removeEventListener('documentLoaded', onDocumentLoaded) + documentViewer.removeEventListener('documentError', onDocumentError) + console.error('문서 로드 오류:', error) + reject(error) + } + + documentViewer.addEventListener('documentLoaded', onDocumentLoaded) + documentViewer.addEventListener('documentError', onDocumentError) + + // 문서 로드 시작 + documentViewer.loadDocument(pdfUrl) + }) + + setIsPdfPreviewVisible(true) + toast.success('PDF 미리보기가 준비되었습니다.') + + } catch (error) { + console.error('PDF 미리보기 실패:', error) + toast.error(`PDF 미리보기 중 오류가 발생했습니다: ${error.message}`) + } finally { + setIsLoading(false) + } + } + + // PDF 다운로드 기능 + const downloadPdf = () => { + if (!generatedPdfBuffer) { + toast.error('다운로드할 PDF가 없습니다.') + return + } + + const pdfBlob = new Blob([generatedPdfBuffer as any], { type: 'application/pdf' }) + const pdfUrl = URL.createObjectURL(pdfBlob) + + const link = document.createElement('a') + link.href = pdfUrl + link.download = `contract_${contractId}_${Date.now()}.pdf` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + + URL.revokeObjectURL(pdfUrl) + toast.success('PDF가 다운로드되었습니다.') + } + + // PDF 미리보기 닫기 + const closePdfPreview = () => { + console.log("🔄 PDF 미리보기 닫기 시작") + if (pdfViewerInstance) { + try { + console.log("🔄 WebViewer 인스턴스 정리") + pdfViewerInstance.UI.dispose() + } catch (error) { + console.warn('WebViewer 정리 중 오류:', error) + } + setPdfViewerInstance(null) + } + + // 컨테이너 정리 + const previewDiv = document.getElementById('pdf-preview-container') + if (previewDiv) { + try { + previewDiv.innerHTML = '' + } catch (error) { + console.warn('컨테이너 정리 중 오류:', error) + } + } + + setIsPdfPreviewVisible(false) + console.log("🔄 PDF 미리보기 닫기 완료") + } + + // PDF를 서버에 저장하는 함수 (API route 사용) + const savePdfToServer = async (pdfBuffer: Uint8Array, fileName: string): Promise<string | null> => { + try { + // PDF 버퍼를 Blob으로 변환 + const pdfBlob = new Blob([pdfBuffer], { type: 'application/pdf' }); + + // FormData 생성 + const formData = new FormData(); + formData.append('file', pdfBlob, fileName); + formData.append('contractId', String(contractId)); + + // API route로 업로드 + const response = await fetch('/api/general-contracts/upload-pdf', { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || 'PDF 파일 저장에 실패했습니다.'); + } + + const result = await response.json(); + + if (!result.success) { + throw new Error(result.error || 'PDF 파일 저장에 실패했습니다.'); + } + + return result.filePath; + } catch (error) { + console.error('PDF 저장 실패:', error); + return null; + } + }; + + // 최종 전송 - 결재 프로세스 시작 + const handleFinalSubmit = async () => { + if (!generatedPdfUrl || !contractSummary || !generatedPdfBuffer) { + toast.error('생성된 PDF가 필요합니다.') + return + } + + if (!userId) { + toast.error('로그인이 필요합니다.') + return + } + + setIsLoading(true) + try { + // 기본계약서 생성 (최종 전송 시점에) + let generatedBasicContractPdfs: Array<{ key: string; buffer: number[]; fileName: string }> = []; + + const contractsToGenerate = selectedBasicContracts.filter(c => c.checked); + if (contractsToGenerate.length > 0) { + // vendorId 조회 + let vendorId: number | undefined; + try { + const basicInfoData = await getBasicInfo(contractId); + if (basicInfoData && basicInfoData.success && basicInfoData.data) { + vendorId = basicInfoData.data.vendorId; + } + } catch (error) { + console.error('vendorId 조회 실패:', error); + } + + if (vendorId) { + toast.info('기본계약서를 생성하는 중입니다...'); + + for (const contract of contractsToGenerate) { + try { + const pdf = await generateBasicContractPdf(vendorId, contract.type, contract.templateName); + generatedBasicContractPdfs.push({ + key: `${vendorId}_${contract.type}_${contract.templateName}`, + ...pdf + }); + } catch (error) { + console.error(`${contract.type} 계약서 생성 실패:`, error); + // 개별 실패는 전체를 중단하지 않음 + } + } + + if (generatedBasicContractPdfs.length > 0) { + toast.success(`${generatedBasicContractPdfs.length}개의 기본계약서가 생성되었습니다.`); + } + } + } + + // PDF를 서버에 저장 + toast.info('PDF를 서버에 저장하는 중입니다...'); + const pdfPath = await savePdfToServer( + generatedPdfBuffer, + `contract_${contractId}_${Date.now()}.pdf` + ); + + if (!pdfPath) { + toast.error('PDF 저장에 실패했습니다.'); + return; + } + + setSavedPdfPath(pdfPath); + setSavedBasicContractPdfs(generatedBasicContractPdfs); + + // 결재 템플릿 변수 매핑 + const approvalVars = await mapContractToApprovalTemplateVariables(contractSummary); + setApprovalVariables(approvalVars); + + // 계약승인요청 dialog close + onOpenChange(false); + + // 결재 템플릿 dialog open + setApprovalDialogOpen(true); + } catch (error: any) { + console.error('Error preparing approval:', error); + toast.error('결재 준비 중 오류가 발생했습니다.') + } finally { + setIsLoading(false) + } + } + + // 결재 등록 처리 + const handleApprovalSubmit = async (data: { + approvers: string[]; + title: string; + attachments?: File[]; + }) => { + if (!contractSummary || !savedPdfPath) { + toast.error('계약 정보가 필요합니다.') + return + } + + setIsLoading(true) + try { + const result = await requestContractApprovalWithApproval({ + contractId, + contractSummary: { + ...contractSummary, + // PDF 경로를 contractSummary에 추가 + pdfPath: savedPdfPath || undefined, + basicContractPdfs: savedBasicContractPdfs.length > 0 ? savedBasicContractPdfs : undefined, + } as ContractSummary, + currentUser: { + id: Number(userId), + epId: session?.user?.epId || null, + email: session?.user?.email || undefined, + }, + approvers: data.approvers, + title: data.title, + }); + + if (result.status === 'pending_approval') { + toast.success('결재가 등록되었습니다.') + setApprovalDialogOpen(false); + } else { + toast.error('결재 등록에 실패했습니다.') + } + } catch (error: any) { + console.error('Error submitting approval:', error); + toast.error(`결재 등록 중 오류가 발생했습니다: ${error.message || '알 수 없는 오류'}`); + } finally { + setIsLoading(false) + } + } + + // 다이얼로그가 열릴 때 1단계 데이터 수집 + useEffect(() => { + if (open && currentStep === 1) { + collectContractSummary() + } + }, [open, currentStep, collectContractSummary]) + + // 계약 요약이 준비되면 기본계약 초기화 + useEffect(() => { + if (contractSummary && currentStep === 2) { + const loadBasicContracts = async () => { + await initializeBasicContracts() + } + loadBasicContracts() + } + }, [contractSummary, currentStep, initializeBasicContracts]) + + // 다이얼로그가 닫힐 때 PDF 뷰어 정리 + useEffect(() => { + if (!open) { + closePdfPreview() + } + }, [open]) + + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <FileText className="h-5 w-5" /> + 계약승인요청 + </DialogTitle> + </DialogHeader> + + <Tabs value={currentStep.toString()} className="w-full"> + <TabsList className="grid w-full grid-cols-3"> + <TabsTrigger value="1" disabled={currentStep < 1}> + 1. 계약 현황 정리 + </TabsTrigger> + <TabsTrigger value="2" disabled={currentStep < 2}> + 2. 기본계약 체크 + </TabsTrigger> + <TabsTrigger value="3" disabled={currentStep < 3}> + 3. PDF 미리보기 + </TabsTrigger> + </TabsList> + + {/* 1단계: 계약 현황 정리 */} + <TabsContent value="1" className="space-y-4"> + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <CheckCircle className="h-5 w-5 text-green-600" /> + 작성된 계약 현황 + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {isLoading ? ( + <div className="text-center py-4"> + <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div> + <p className="mt-2 text-sm text-muted-foreground">계약 정보를 수집하는 중...</p> + </div> + ) : ( + <div className="space-y-4"> + {/* 기본 정보 (필수) */} + <div className="border rounded-lg p-4"> + <div className="flex items-center gap-2 mb-3"> + <CheckCircle className="h-4 w-4 text-green-600" /> + <Label className="font-medium">기본 정보</Label> + <Badge variant="secondary">필수</Badge> + </div> + <div className="grid grid-cols-2 gap-4 text-sm"> + <div> + <span className="font-medium">계약번호:</span> {String(contractSummary?.basicInfo?.contractNumber || '')} + </div> + <div> + <span className="font-medium">계약명:</span> {String(contractSummary?.basicInfo?.contractName || '')} + </div> + <div> + <span className="font-medium">벤더:</span> {String(contractSummary?.basicInfo?.vendorName || '')} + </div> + <div> + <span className="font-medium">프로젝트:</span> {String(contractSummary?.basicInfo?.projectName || '')} + </div> + <div> + <span className="font-medium">계약유형:</span> {String(contractSummary?.basicInfo?.contractType || '')} + </div> + <div> + <span className="font-medium">계약상태:</span> {String(contractSummary?.basicInfo?.contractStatus || '')} + </div> + <div> + <span className="font-medium">계약금액:</span> {String(contractSummary?.basicInfo?.contractAmount || '')} {String(contractSummary?.basicInfo?.currency || '')} + </div> + <div> + <span className="font-medium">계약기간:</span> {String(contractSummary?.basicInfo?.startDate || '')} ~ {String(contractSummary?.basicInfo?.endDate || '')} + </div> + <div> + <span className="font-medium">사양서 유형:</span> {String(contractSummary?.basicInfo?.specificationType || '')} + </div> + <div> + <span className="font-medium">단가 유형:</span> {String(contractSummary?.basicInfo?.unitPriceType || '')} + </div> + <div> + <span className="font-medium">연결 PO번호:</span> {String(contractSummary?.basicInfo?.linkedPoNumber || '')} + </div> + <div> + <span className="font-medium">연결 입찰번호:</span> {String(contractSummary?.basicInfo?.linkedBidNumber || '')} + </div> + </div> + </div> + + {/* 지급/인도 조건 */} + <div className="border rounded-lg p-4"> + <div className="flex items-center gap-2 mb-3"> + <CheckCircle className="h-4 w-4 text-green-600" /> + <Label className="font-medium">지급/인도 조건</Label> + <Badge variant="secondary">필수</Badge> + </div> + <div className="grid grid-cols-2 gap-4 text-sm"> + <div> + <span className="font-medium">지급조건:</span> {String(contractSummary?.basicInfo?.paymentTerm || '')} + </div> + <div> + <span className="font-medium">세금 유형:</span> {String(contractSummary?.basicInfo?.taxType || '')} + </div> + <div> + <span className="font-medium">인도조건:</span> {String(contractSummary?.basicInfo?.deliveryTerm || '')} + </div> + <div> + <span className="font-medium">인도유형:</span> {String(contractSummary?.basicInfo?.deliveryType || '')} + </div> + <div> + <span className="font-medium">선적지:</span> {String(contractSummary?.basicInfo?.shippingLocation || '')} + </div> + <div> + <span className="font-medium">하역지:</span> {String(contractSummary?.basicInfo?.dischargeLocation || '')} + </div> + <div> + <span className="font-medium">계약납기:</span> {String(contractSummary?.basicInfo?.contractDeliveryDate || '')} + </div> + <div> + <span className="font-medium">위약금:</span> {contractSummary?.basicInfo?.liquidatedDamages ? '적용' : '미적용'} + </div> + </div> + </div> + + {/* 추가 조건 */} + <div className="border rounded-lg p-4"> + <div className="flex items-center gap-2 mb-3"> + <CheckCircle className="h-4 w-4 text-green-600" /> + <Label className="font-medium">추가 조건</Label> + <Badge variant="secondary">필수</Badge> + </div> + <div className="grid grid-cols-2 gap-4 text-sm"> + <div> + <span className="font-medium">연동제 정보:</span> {String(contractSummary?.basicInfo?.interlockingSystem || '')} + </div> + <div> + <span className="font-medium">계약성립조건:</span> + {contractSummary?.basicInfo?.contractEstablishmentConditions ? (() => { + const conditions = Object.entries(contractSummary.basicInfo.contractEstablishmentConditions as Record<string, boolean>) + .filter(([, value]) => value === true) + .map(([key]) => { + const conditionMap: Record<string, string> = { + 'ownerApproval': '정규업체 등록(실사 포함) 시', + 'regularVendorRegistration': '프로젝트 수주 시', + 'shipOwnerApproval': '선주 승인 시', + 'other': '기타' + }; + return conditionMap[key] || key; + }); + return conditions.length > 0 ? conditions.join(', ') : '없음'; + })() : '없음'} + </div> + <div> + <span className="font-medium">계약해지조건:</span> + {contractSummary?.basicInfo?.contractTerminationConditions ? (() => { + const conditions = Object.entries(contractSummary.basicInfo.contractTerminationConditions as Record<string, boolean>) + .filter(([, value]) => value === true) + .map(([key]) => { + const conditionMap: Record<string, string> = { + 'standardTermination': '표준 계약해지조건', + 'projectNotAwarded': '프로젝트 미수주 시', + 'other': '기타' + }; + return conditionMap[key] || key; + }); + return conditions.length > 0 ? conditions.join(', ') : '없음'; + })() : '없음'} + </div> + </div> + </div> + + {/* 품목 정보 */} + <div className="border rounded-lg p-4"> + <div className="flex items-center gap-2 mb-3"> + <Checkbox + id="items-enabled" + checked={contractSummary?.items && contractSummary.items.length > 0} + disabled + /> + <Label htmlFor="items-enabled" className="font-medium">품목 정보</Label> + <Badge variant="outline">선택</Badge> + </div> + {contractSummary?.items && contractSummary.items.length > 0 ? ( + <div className="space-y-2"> + <p className="text-sm text-muted-foreground"> + 총 {contractSummary.items.length}개 품목이 입력되어 있습니다. + </p> + <div className="max-h-32 overflow-y-auto"> + {contractSummary.items.slice(0, 3).map((item: Record<string, unknown>, index: number) => ( + <div key={index} className="text-xs bg-gray-50 p-2 rounded"> + <div className="font-medium">{String(item.itemInfo || item.description || `품목 ${index + 1}`)}</div> + <div className="text-muted-foreground"> + 수량: {String(item.quantity || 0)} | 단가: {String(item.contractUnitPrice || item.unitPrice || 0)} + </div> + </div> + ))} + {contractSummary.items.length > 3 && ( + <div className="text-xs text-muted-foreground text-center"> + ... 외 {contractSummary.items.length - 3}개 품목 + </div> + )} + </div> + </div> + ) : ( + <p className="text-sm text-muted-foreground"> + 품목 정보가 입력되지 않았습니다. + </p> + )} + </div> + + {/* 하도급 체크리스트 */} + <div className="border rounded-lg p-4"> + <div className="flex items-center gap-2 mb-3"> + <Checkbox + id="subcontract-enabled" + checked={!!contractSummary?.subcontractChecklist} + disabled + /> + <Label htmlFor="subcontract-enabled" className="font-medium"> + 하도급 체크리스트 + </Label> + <Badge variant="outline">선택</Badge> + </div> + <p className="text-sm text-muted-foreground"> + {contractSummary?.subcontractChecklist + ? '정보가 입력되어 있습니다.' + : '정보가 입력되지 않았습니다.'} + </p> + </div> + </div> + )} + </CardContent> + </Card> + + <div className="flex justify-end"> + <Button + onClick={() => setCurrentStep(2)} + disabled={isLoading} + > + 다음 단계 + </Button> + </div> + </TabsContent> + + {/* 2단계: 기본계약 체크 */} + <TabsContent value="2" className="space-y-4"> + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <FileText className="h-5 w-5 text-blue-600" /> + 기본계약서 선택 + </CardTitle> + <p className="text-sm text-muted-foreground"> + 벤더에게 발송할 기본계약서를 선택해주세요. (템플릿이 있는 계약서만 선택 가능합니다.) + </p> + </CardHeader> + <CardContent className="space-y-4"> + {isLoadingBasicContracts ? ( + <div className="text-center py-8"> + <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div> + <p className="mt-2 text-sm text-muted-foreground">기본계약 템플릿을 불러오는 중...</p> + </div> + ) : ( + <div className="space-y-4"> + {selectedBasicContracts.length > 0 ? ( + <div className="space-y-3"> + <div className="flex items-center justify-between"> + <h4 className="font-medium">필요한 기본계약서</h4> + <Badge variant="outline"> + {selectedBasicContracts.filter(c => c.checked).length}개 선택됨 + </Badge> + </div> + + <div className="grid gap-3"> + {selectedBasicContracts.map((contract) => ( + <div + key={contract.type} + className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50" + > + <div className="flex items-center gap-3"> + <Checkbox + id={`contract-${contract.type}`} + checked={contract.checked} + onCheckedChange={() => toggleBasicContract(contract.type)} + /> + <div> + <Label + htmlFor={`contract-${contract.type}`} + className="font-medium cursor-pointer" + > + {contract.type} + </Label> + <p className="text-sm text-muted-foreground"> + 템플릿: {contract.templateName} + </p> + </div> + </div> + <Badge + variant="secondary" + className="text-xs" + > + {contract.checked ? "선택됨" : "미선택"} + </Badge> + </div> + ))} + </div> + + </div> + ) : ( + <div className="text-center py-8 text-muted-foreground"> + <FileText className="h-12 w-12 mx-auto mb-4 opacity-50" /> + <p>기본계약서 목록을 불러올 수 없습니다.</p> + <p className="text-sm">잠시 후 다시 시도해주세요.</p> + </div> + )} + + </div> + )} + </CardContent> + </Card> + + <div className="flex justify-between"> + <Button variant="outline" onClick={() => setCurrentStep(1)}> + 이전 단계 + </Button> + <Button + onClick={() => setCurrentStep(3)} + disabled={isLoadingBasicContracts} + > + 다음 단계 + </Button> + </div> + </TabsContent> + + {/* 3단계: PDF 미리보기 */} + <TabsContent value="3" className="space-y-4"> + <Card> + <CardHeader> + <CardTitle className="flex items-center gap-2"> + <Eye className="h-5 w-5 text-purple-600" /> + PDF 미리보기 + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + {!generatedPdfUrl ? ( + <div className="text-center py-8"> + <Button onClick={generatePdf} disabled={isLoading}> + {isLoading ? 'PDF 생성 중...' : 'PDF 생성하기'} + </Button> + </div> + ) : ( + <div className="space-y-4"> + <div className="border rounded-lg p-4 bg-green-50"> + <div className="flex items-center gap-2"> + <CheckCircle className="h-4 w-4 text-green-600" /> + <span className="font-medium text-green-900">PDF 생성 완료</span> + </div> + </div> + + <div className="border rounded-lg p-4"> + <div className="flex items-center justify-between mb-4"> + <h4 className="font-medium">생성된 PDF</h4> + <div className="flex gap-2"> + <Button + variant="outline" + size="sm" + onClick={downloadPdf} + disabled={isLoading} + > + <Download className="h-4 w-4 mr-2" /> + 다운로드 + </Button> + <Button + variant="outline" + size="sm" + onClick={openPdfPreview} + disabled={isLoading} + > + <Eye className="h-4 w-4 mr-2" /> + 미리보기 + </Button> + </div> + </div> + + {/* PDF 미리보기 영역 */} + <div className="border rounded-lg h-96 bg-gray-50 relative" data-pdf-container> + {isPdfPreviewVisible ? ( + <> + <div className="absolute top-2 right-2 z-10"> + <Button + variant="outline" + size="sm" + onClick={closePdfPreview} + className="bg-white/90 hover:bg-white" + > + ✕ 닫기 + </Button> + </div> + <div id="pdf-preview-container" className="w-full h-full" /> + </> + ) : ( + <div className="flex items-center justify-center h-full"> + <div className="text-center text-muted-foreground"> + <FileText className="h-12 w-12 mx-auto mb-2" /> + <p>미리보기 버튼을 클릭하여 PDF를 확인하세요</p> + </div> + </div> + )} + </div> + </div> + </div> + )} + </CardContent> + </Card> + + <div className="flex justify-between"> + <Button variant="outline" onClick={() => setCurrentStep(2)}> + 이전 단계 + </Button> + <Button + onClick={handleFinalSubmit} + disabled={!generatedPdfUrl || isLoading} + className="bg-green-600 hover:bg-green-700" + > + <Send className="h-4 w-4 mr-2" /> + {isLoading ? '전송 중...' : '최종 전송'} + </Button> + </div> + </TabsContent> + </Tabs> + </DialogContent> + + {/* 결재 미리보기 Dialog */} + {session?.user && session.user.epId && contractSummary && ( + <ApprovalPreviewDialog + open={approvalDialogOpen} + onOpenChange={(open) => { + setApprovalDialogOpen(open); + if (!open) { + setApprovalVariables({}); + setSavedPdfPath(null); + setSavedBasicContractPdfs([]); + } + }} + templateName="일반계약 결재" + variables={approvalVariables} + title={`계약 체결 진행 품의 요청서 - ${contractSummary.basicInfo?.contractNumber || contractId}`} + currentUser={{ + id: Number(session.user.id), + epId: session.user.epId, + name: session.user.name || undefined, + email: session.user.email || undefined, + }} + onConfirm={handleApprovalSubmit} + enableAttachments={false} + /> + )} + </Dialog> )}
\ No newline at end of file diff --git a/lib/general-contracts/detail/general-contract-items-table.tsx b/lib/general-contracts/detail/general-contract-items-table.tsx index 4f74cfbb..be174417 100644 --- a/lib/general-contracts/detail/general-contract-items-table.tsx +++ b/lib/general-contracts/detail/general-contract-items-table.tsx @@ -218,7 +218,7 @@ export function ContractItemsTable({ const item = itemsToSave[index] // if (!item.itemCode) errors.push(`${index + 1}번째 품목의 품목코드`) if (!item.itemInfo) errors.push(`${index + 1}번째 품목의 Item 정보`) - if (!item.quantity || item.quantity <= 0) errors.push(`${index + 1}번째 품목의 수량`) + // if (!item.quantity || item.quantity <= 0) errors.push(`${index + 1}번째 품목의 수량`) if (!item.contractUnitPrice || item.contractUnitPrice <= 0) errors.push(`${index + 1}번째 품목의 단가`) if (!item.contractDeliveryDate) errors.push(`${index + 1}번째 품목의 납기일`) } |
