From d7585b3f2ea941ee807c1e87bbc833265a193c78 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 15 Sep 2025 10:14:09 +0000 Subject: (최겸) 구매 일반계약 및 상세, PO 전달 구현 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../general-contract-approval-request-dialog.tsx | 1312 ++++++++++++++++++++ .../detail/general-contract-basic-info.tsx | 2 +- .../detail/general-contract-detail.tsx | 57 +- .../detail/general-contract-documents.tsx | 17 +- .../detail/general-contract-items-table.tsx | 1102 ++++++++-------- .../general-contract-subcontract-checklist.tsx | 610 +++++++++ .../detail/subcontract-checklist.tsx | 577 --------- .../main/create-general-contract-dialog.tsx | 20 +- .../main/general-contract-update-sheet.tsx | 7 +- .../main/general-contracts-table-columns.tsx | 31 +- .../general-contracts-table-toolbar-actions.tsx | 10 +- lib/general-contracts/service.ts | 657 +++++++++- 12 files changed, 3177 insertions(+), 1225 deletions(-) create mode 100644 lib/general-contracts/detail/general-contract-approval-request-dialog.tsx create mode 100644 lib/general-contracts/detail/general-contract-subcontract-checklist.tsx delete mode 100644 lib/general-contracts/detail/subcontract-checklist.tsx (limited to 'lib/general-contracts') diff --git a/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx b/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx new file mode 100644 index 00000000..e4aa022a --- /dev/null +++ b/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx @@ -0,0 +1,1312 @@ +'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, + getCommunicationChannel, + getLocation, + getFieldServiceRate, + getOffsetDetails, + getSubcontractChecklist, + uploadContractApprovalFile, + sendContractApprovalRequest +} from '../service' + +interface ContractApprovalRequestDialogProps { + contract: Record + open: boolean + onOpenChange: (open: boolean) => void +} + +interface ContractSummary { + basicInfo: Record + items: Record[] + communicationChannel: Record | null + location: Record | null + fieldServiceRate: Record | null + offsetDetails: Record | null + subcontractChecklist: Record | null +} + +export function ContractApprovalRequestDialog({ + contract, + open, + onOpenChange +}: ContractApprovalRequestDialogProps) { + const { data: session } = useSession() + const [currentStep, setCurrentStep] = useState(1) + const [contractSummary, setContractSummary] = useState(null) + const [uploadedFile, setUploadedFile] = useState(null) + const [generatedPdfUrl, setGeneratedPdfUrl] = useState(null) + const [generatedPdfBuffer, setGeneratedPdfBuffer] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [pdfViewerInstance, setPdfViewerInstance] = useState(null) + const [isPdfPreviewVisible, setIsPdfPreviewVisible] = useState(false) + + // 기본계약 관련 상태 + const [selectedBasicContracts, setSelectedBasicContracts] = useState>([]) + const [isLoadingBasicContracts, setIsLoadingBasicContracts] = useState(false) + + const contractId = contract.id as number + const userId = session?.user?.id || '' + + // LOI 템플릿용 변수 매핑 함수 + const mapContractSummaryToLOITemplate = (contractSummary: ContractSummary) => { + const { basicInfo, items } = contractSummary + const firstItem = items && items.length > 0 ? items[0] : {} + + // 날짜 포맷팅 헬퍼 함수 + const formatDate = (date: any) => { + if (!date) return '' + try { + const d = new Date(date) + return d.toLocaleDateString('ko-KR') + } catch { + return '' + } + } + + return { + // 날짜 관련 (템플릿에서 {{todayDate}} 형식으로 사용) + todayDate: new Date().toLocaleDateString('ko-KR'), + + // 벤더 정보 + vendorName: basicInfo?.vendorName || '', + representativeName: '', // 벤더 대표자 이름 - 현재 데이터에 없음, 향후 확장 가능 + + // 계약 기본 정보 + contractNumber: basicInfo?.contractNumber || '', + + // 프로젝트 정보 + projectNumber: '', // 프로젝트 코드 - 현재 데이터에 없음, 향후 확장 가능 + projectName: basicInfo?.projectName || '', + project: basicInfo?.projectName || '', + + // 아이템 정보 + item: firstItem?.itemInfo || '', + + // 무역 조건 + incoterms: basicInfo?.deliveryTerm || '', // Incoterms 대신 deliveryTerm 사용 + shippingLocation: basicInfo?.shippingLocation || '', + + // 금액 및 통화 + contractCurrency: basicInfo?.currency || '', + contractAmount: basicInfo?.contractAmount || '', + totalAmount: basicInfo?.contractAmount || '', // totalAmount가 없으면 contractAmount 사용 + + // 수량 + quantity: firstItem?.quantity || '', + + // 납기일 + contractDeliveryDate: formatDate(basicInfo?.contractDeliveryDate), + + // 지급 조건 + paymentTerm: basicInfo?.paymentTerm || '', + + // 유효기간 + validityEndDate: formatDate(basicInfo?.endDate), // validityEndDate 대신 endDate 사용 + } + } + + // 기본계약 생성 함수 (최종 전송 시점에 호출) + 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.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: [], + communicationChannel: null, + location: null, + fieldServiceRate: null, + offsetDetails: null, + subcontractChecklist: null + } + + // Basic Info 확인 (항상 활성화) + try { + const basicInfoData = await getBasicInfo(contractId) + if (basicInfoData && basicInfoData.success) { + summary.basicInfo = basicInfoData.data || {} + } + } catch { + console.log('Basic Info 데이터 없음') + } + + // 품목 정보 확인 + try { + const itemsData = await getContractItems(contractId) + if (itemsData && itemsData.length > 0) { + summary.items = itemsData + } + } catch { + console.log('품목 정보 데이터 없음') + } + + // 각 컴포넌트의 활성화 상태 및 데이터 확인 + try { + // Communication Channel 확인 + const commData = await getCommunicationChannel(contractId) + if (commData && commData.enabled) { + summary.communicationChannel = commData + } + } catch { + console.log('Communication Channel 데이터 없음') + } + + try { + // Location 확인 + const locationData = await getLocation(contractId) + if (locationData && locationData.enabled) { + summary.location = locationData + } + } catch { + console.log('Location 데이터 없음') + } + + try { + // Field Service Rate 확인 + const fieldServiceData = await getFieldServiceRate(contractId) + if (fieldServiceData && fieldServiceData.enabled) { + summary.fieldServiceRate = fieldServiceData + } + } catch { + console.log('Field Service Rate 데이터 없음') + } + + try { + // Offset Details 확인 + const offsetData = await getOffsetDetails(contractId) + if (offsetData && offsetData.enabled) { + summary.offsetDetails = offsetData + } + } catch { + console.log('Offset Details 데이터 없음') + } + + try { + // Subcontract Checklist 확인 + const subcontractData = await getSubcontractChecklist(contractId) + if (subcontractData && subcontractData.success && subcontractData.enabled) { + summary.subcontractChecklist = subcontractData.data + } + } catch { + console.log('Subcontract Checklist 데이터 없음') + } + + 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단계: 파일 업로드 처리 + const handleFileUpload = async (file: File) => { + // 파일 확장자 검증 + const allowedExtensions = ['.doc', '.docx'] + const fileExtension = file.name.toLowerCase().substring(file.name.lastIndexOf('.')) + + if (!allowedExtensions.includes(fileExtension)) { + toast.error('Word 문서(.doc, .docx) 파일만 업로드 가능합니다.') + return + } + + if (!userId) { + toast.error('로그인이 필요합니다.') + return + } + + setIsLoading(true) + try { + // 서버액션을 사용하여 파일 저장 (본 계약문서로 고정) + const result = await uploadContractApprovalFile( + contractId, + file, + userId + ) + + if (result.success) { + setUploadedFile(file) + toast.success('파일이 업로드되었습니다.') + } else { + throw new Error(result.error || '파일 업로드 실패') + } + } catch (error) { + console.error('Error uploading file:', error) + toast.error('파일 업로드 중 오류가 발생했습니다.') + } finally { + setIsLoading(false) + } + } + + // 4단계: PDF 생성 및 미리보기 (PDFTron 사용) + const generatePdf = async () => { + if (!uploadedFile || !contractSummary) { + toast.error('업로드된 파일과 계약 정보가 필요합니다.') + return + } + + setIsLoading(true) + try { + // 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(uploadedFile, { + filename: uploadedFile.name, + extension: 'docx', + }) + + // LOI 템플릿용 변수 매핑 + const mappedTemplateData = mapContractSummaryToLOITemplate(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 변환 완료: ${uploadedFile.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) { + console.error('❌ PDF 생성 실패:', error) + toast.error('PDF 생성 중 오류가 발생했습니다.') + } 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], { type: 'application/pdf' }) + const pdfUrl = URL.createObjectURL(pdfBlob) + console.log("🔄 PDF Blob URL 생성:", pdfUrl) + + // 문서 로드 + console.log("🔄 문서 로드 시작") + const { documentViewer } = instance.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], { 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 미리보기 닫기 완료") + } + + // 최종 전송 + 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}개의 기본계약서가 생성되었습니다.`); + } + } + } + + // 서버액션을 사용하여 계약승인요청 전송 + const result = await sendContractApprovalRequest( + contractSummary, + generatedPdfBuffer, + 'contractDocument', + userId, + generatedBasicContractPdfs + ) + + if (result.success) { + toast.success('계약승인요청이 전송되었습니다.') + onOpenChange(false) + } else { + // 서버에서 이미 처리된 에러 메시지 표시 + toast.error(result.error || '계약승인요청 전송 실패') + return + } + } catch (error: any) { + console.error('Error submitting approval request:', error) + + // 데이터베이스 중복 키 오류 처리 + if (error.message && error.message.includes('duplicate key value violates unique constraint')) { + toast.error('이미 존재하는 계약번호입니다. 다른 계약번호를 사용해주세요.') + return + } + + // 다른 오류에 대한 일반적인 처리 + toast.error('계약승인요청 전송 중 오류가 발생했습니다.') + } 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 ( + + + + + + 계약승인요청 + + + + + + + 1. 계약 현황 정리 + + + 2. 기본계약 체크 + + + 3. 문서 업로드 + + + 4. PDF 미리보기 + + + + {/* 1단계: 계약 현황 정리 */} + + + + + + 작성된 계약 현황 + + + + {isLoading ? ( +
+
+

계약 정보를 수집하는 중...

+
+ ) : ( +
+ {/* 기본 정보 (필수) */} +
+
+ + + 필수 +
+
+
+ 계약번호: {String(contractSummary?.basicInfo?.contractNumber || '')} +
+
+ 계약명: {String(contractSummary?.basicInfo?.contractName || '')} +
+
+ 벤더: {String(contractSummary?.basicInfo?.vendorName || '')} +
+
+ 프로젝트: {String(contractSummary?.basicInfo?.projectName || '')} +
+
+ 계약유형: {String(contractSummary?.basicInfo?.contractType || '')} +
+
+ 계약상태: {String(contractSummary?.basicInfo?.contractStatus || '')} +
+
+ 계약금액: {String(contractSummary?.basicInfo?.contractAmount || '')} {String(contractSummary?.basicInfo?.currency || '')} +
+
+ 계약기간: {String(contractSummary?.basicInfo?.startDate || '')} ~ {String(contractSummary?.basicInfo?.endDate || '')} +
+
+ 사양서 유형: {String(contractSummary?.basicInfo?.specificationType || '')} +
+
+ 단가 유형: {String(contractSummary?.basicInfo?.unitPriceType || '')} +
+
+ 연결 PO번호: {String(contractSummary?.basicInfo?.linkedPoNumber || '')} +
+
+ 연결 입찰번호: {String(contractSummary?.basicInfo?.linkedBidNumber || '')} +
+
+
+ + {/* 지급/인도 조건 */} +
+
+ + + 필수 +
+
+
+ 지급조건: {String(contractSummary?.basicInfo?.paymentTerm || '')} +
+
+ 세금 유형: {String(contractSummary?.basicInfo?.taxType || '')} +
+
+ 인도조건: {String(contractSummary?.basicInfo?.deliveryTerm || '')} +
+
+ 인도유형: {String(contractSummary?.basicInfo?.deliveryType || '')} +
+
+ 선적지: {String(contractSummary?.basicInfo?.shippingLocation || '')} +
+
+ 도착지: {String(contractSummary?.basicInfo?.dischargeLocation || '')} +
+
+ 계약납기: {String(contractSummary?.basicInfo?.contractDeliveryDate || '')} +
+
+ 위약금: {contractSummary?.basicInfo?.liquidatedDamages ? '적용' : '미적용'} +
+
+
+ + {/* 추가 조건 */} +
+
+ + + 필수 +
+
+
+ 연동제 정보: {String(contractSummary?.basicInfo?.interlockingSystem || '')} +
+
+ 계약성립조건: + {contractSummary?.basicInfo?.contractEstablishmentConditions && + Object.entries(contractSummary.basicInfo.contractEstablishmentConditions) + .filter(([, value]) => value === true) + .map(([key]) => key) + .join(', ') || '없음'} +
+
+ 계약해지조건: + {contractSummary?.basicInfo?.contractTerminationConditions && + Object.entries(contractSummary.basicInfo.contractTerminationConditions) + .filter(([, value]) => value === true) + .map(([key]) => key) + .join(', ') || '없음'} +
+
+
+ + {/* 품목 정보 */} +
+
+ 0} + disabled + /> + + 선택 +
+ {contractSummary?.items && contractSummary.items.length > 0 ? ( +
+

+ 총 {contractSummary.items.length}개 품목이 입력되어 있습니다. +

+
+ {contractSummary.items.slice(0, 3).map((item: Record, index: number) => ( +
+
{item.itemInfo || item.description || `품목 ${index + 1}`}
+
+ 수량: {item.quantity || 0} | 단가: {item.contractUnitPrice || item.unitPrice || 0} +
+
+ ))} + {contractSummary.items.length > 3 && ( +
+ ... 외 {contractSummary.items.length - 3}개 품목 +
+ )} +
+
+ ) : ( +

+ 품목 정보가 입력되지 않았습니다. +

+ )} +
+ + {/* 커뮤니케이션 채널 */} +
+
+ + + 선택 +
+

+ {contractSummary?.communicationChannel + ? '정보가 입력되어 있습니다.' + : '정보가 입력되지 않았습니다.'} +

+
+ + {/* 위치 정보 */} +
+
+ + + 선택 +
+

+ {contractSummary?.location + ? '정보가 입력되어 있습니다.' + : '정보가 입력되지 않았습니다.'} +

+
+ + {/* 현장 서비스 요율 */} +
+
+ + + 선택 +
+

+ {contractSummary?.fieldServiceRate + ? '정보가 입력되어 있습니다.' + : '정보가 입력되지 않았습니다.'} +

+
+ + {/* 오프셋 세부사항 */} +
+
+ + + 선택 +
+

+ {contractSummary?.offsetDetails + ? '정보가 입력되어 있습니다.' + : '정보가 입력되지 않았습니다.'} +

+
+ + {/* 하도급 체크리스트 */} +
+
+ + + 선택 +
+

+ {contractSummary?.subcontractChecklist + ? '정보가 입력되어 있습니다.' + : '정보가 입력되지 않았습니다.'} +

+
+
+ )} +
+
+ +
+ +
+
+ + {/* 2단계: 기본계약 체크 */} + + + + + + 기본계약서 선택 + +

+ 벤더에게 발송할 기본계약서를 선택해주세요. (템플릿이 있는 계약서만 선택 가능합니다.) +

+
+ + {isLoadingBasicContracts ? ( +
+
+

기본계약 템플릿을 불러오는 중...

+
+ ) : ( +
+ {selectedBasicContracts.length > 0 ? ( +
+
+

필요한 기본계약서

+ + {selectedBasicContracts.filter(c => c.checked).length}개 선택됨 + +
+ +
+ {selectedBasicContracts.map((contract) => ( +
+
+ toggleBasicContract(contract.type)} + /> +
+ +

+ 템플릿: {contract.templateName} +

+
+
+ + {contract.checked ? "선택됨" : "미선택"} + +
+ ))} +
+ +
+ ) : ( +
+ +

기본계약서 목록을 불러올 수 없습니다.

+

잠시 후 다시 시도해주세요.

+
+ )} + +
+ )} +
+
+ +
+ + +
+
+ + {/* 3단계: 문서 업로드 */} + + + + + + 계약서 업로드 + + + +
+

일반계약 표준문서 관리 페이지에 접속하여, 원하는 양식의 계약서를 다운받아 수정 후 업로드하세요.

+
+ + { + const file = e.target.files?.[0] + if (file) handleFileUpload(file) + }} + /> +

+ Word 문서(.doc, .docx) 파일만 업로드 가능합니다. +

+
+ + {/* ContractDocuments 컴포넌트 사용 */} + {/*
+ + +
*/} + + {uploadedFile && ( +
+
+ + 업로드 완료 +
+

{uploadedFile.name}

+
+ )} +
+
+
+ +
+ + +
+
+ + {/* 4단계: PDF 미리보기 */} + + + + + + PDF 미리보기 + + + + {!generatedPdfUrl ? ( +
+ +
+ ) : ( +
+
+
+ + PDF 생성 완료 +
+
+ +
+
+

생성된 PDF

+
+ + +
+
+ + {/* PDF 미리보기 영역 */} +
+ {isPdfPreviewVisible ? ( + <> +
+ +
+
+ + ) : ( +
+
+ +

미리보기 버튼을 클릭하여 PDF를 확인하세요

+
+
+ )} +
+
+
+ )} + + + +
+ + +
+ + + +
+ )} \ No newline at end of file diff --git a/lib/general-contracts/detail/general-contract-basic-info.tsx b/lib/general-contracts/detail/general-contract-basic-info.tsx index fd8983f6..ac1315bb 100644 --- a/lib/general-contracts/detail/general-contract-basic-info.tsx +++ b/lib/general-contracts/detail/general-contract-basic-info.tsx @@ -1078,7 +1078,7 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) { diff --git a/lib/general-contracts/detail/general-contract-detail.tsx b/lib/general-contracts/detail/general-contract-detail.tsx index 7c01fb16..9d9f35bd 100644 --- a/lib/general-contracts/detail/general-contract-detail.tsx +++ b/lib/general-contracts/detail/general-contract-detail.tsx @@ -3,19 +3,20 @@ import { useState, useEffect } from 'react' import { useParams } from 'next/navigation' import Link from 'next/link' -import { getContractById } from '../service' +import { getContractById, getSubcontractChecklist } from '../service' import { GeneralContractInfoHeader } from './general-contract-info-header' import { Alert, AlertDescription } from '@/components/ui/alert' import { Button } from '@/components/ui/button' import { AlertCircle, ArrowLeft } from 'lucide-react' import { Skeleton } from '@/components/ui/skeleton' import { ContractItemsTable } from './general-contract-items-table' -import { SubcontractChecklist } from './subcontract-checklist' +import { SubcontractChecklist } from './general-contract-subcontract-checklist' import { ContractBasicInfo } from './general-contract-basic-info' import { CommunicationChannel } from './general-contract-communication-channel' import { Location } from './general-contract-location' import { FieldServiceRate } from './general-contract-field-service-rate' import { OffsetDetails } from './general-contract-offset-details' +import { ContractApprovalRequestDialog } from './general-contract-approval-request-dialog' export default function ContractDetailPage() { const params = useParams() @@ -24,14 +25,30 @@ export default function ContractDetailPage() { const [contract, setContract] = useState | null>(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [showApprovalDialog, setShowApprovalDialog] = useState(false) + const [subcontractChecklistData, setSubcontractChecklistData] = useState(null) useEffect(() => { const fetchContract = async () => { try { setLoading(true) setError(null) + + // 계약 기본 정보 로드 const contractData = await getContractById(contractId!) setContract(contractData) + + // 하도급법 체크리스트 데이터 로드 + try { + const checklistData = await getSubcontractChecklist(contractId!) + if (checklistData.success && checklistData.data) { + setSubcontractChecklistData(checklistData.data) + } + } catch (checklistError) { + console.log('하도급법 체크리스트 데이터 로드 실패:', checklistError) + // 체크리스트 로드 실패는 전체 로드를 실패시키지 않음 + } + } catch (err) { console.error('Error fetching contract:', err) setError('계약 정보를 불러오는 중 오류가 발생했습니다.') @@ -92,13 +109,22 @@ export default function ContractDetailPage() { 계약번호: {contract?.contractNumber as string} (Rev.{contract?.revision as number})

- {/* 계약목록으로 돌아가기 버튼 */} - +
+ {/* 계약승인요청 버튼 */} + + {/* 계약목록으로 돌아가기 버튼 */} + +
{/* 계약 정보 헤더 */} {contract && } @@ -130,9 +156,9 @@ export default function ContractDetailPage() { {/* 하도급법 자율점검 체크리스트 */} {}} + onDataChange={(data) => setSubcontractChecklistData(data)} readOnly={false} - initialData={undefined} + initialData={subcontractChecklistData} /> {/* Communication Channel */} @@ -147,6 +173,15 @@ export default function ContractDetailPage() { )} + + {/* 계약승인요청 다이얼로그 */} + {contract && ( + + )} ) } diff --git a/lib/general-contracts/detail/general-contract-documents.tsx b/lib/general-contracts/detail/general-contract-documents.tsx index 11d2de68..b0f20e7f 100644 --- a/lib/general-contracts/detail/general-contract-documents.tsx +++ b/lib/general-contracts/detail/general-contract-documents.tsx @@ -32,6 +32,7 @@ interface ContractDocument { documentName: string fileName: string filePath: string + documentType?: string shiComment?: string | null vendorComment?: string | null uploadedAt: Date @@ -80,6 +81,16 @@ export function ContractDocuments({ contractId, userId, readOnly = false }: Cont startTransition(async () => { try { + // 본 계약문서 타입인 경우 기존 문서 확인 + if (selectedDocumentType === 'main') { + const existingMainDoc = documents.find(doc => doc.documentType === 'main') + if (existingMainDoc) { + toast.info('기존 계약문서가 새롭게 업로드한 문서로 대체됩니다.') + // 기존 본 계약문서 삭제 + await deleteContractAttachment(existingMainDoc.id, contractId) + } + } + await uploadContractAttachment(contractId, file, userId, selectedDocumentType) toast.success('문서가 업로드되었습니다.') loadDocuments() @@ -108,7 +119,7 @@ export function ContractDocuments({ contractId, userId, readOnly = false }: Cont startTransition(async () => { try { - await deleteContractAttachment(documentId, contractId, userId) + await deleteContractAttachment(documentId, contractId) toast.success('문서가 삭제되었습니다.') loadDocuments() } catch (error) { @@ -275,7 +286,7 @@ export function ContractDocuments({ contractId, userId, readOnly = false }: Cont @@ -321,7 +332,7 @@ export function ContractDocuments({ contractId, userId, readOnly = false }: Cont diff --git a/lib/general-contracts/detail/general-contract-items-table.tsx b/lib/general-contracts/detail/general-contract-items-table.tsx index 23057cb7..5176c6ce 100644 --- a/lib/general-contracts/detail/general-contract-items-table.tsx +++ b/lib/general-contracts/detail/general-contract-items-table.tsx @@ -1,549 +1,553 @@ -'use client' - -import * as React from 'react' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' -import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' -import { Button } from '@/components/ui/button' -import { Checkbox } from '@/components/ui/checkbox' -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from '@/components/ui/table' -import { - Package, - Plus, - Trash2, - Calculator -} from 'lucide-react' -import { toast } from 'sonner' -import { updateContractItems, getContractItems } from '../service' -import { Save, LoaderIcon } from 'lucide-react' - -interface ContractItem { - id?: number - project: string - itemCode: string - itemInfo: string - specification: string - quantity: number - quantityUnit: string - contractDeliveryDate: string - contractUnitPrice: number - contractAmount: number - contractCurrency: string - isSelected?: boolean -} - -interface ContractItemsTableProps { - contractId: number - items: ContractItem[] - onItemsChange: (items: ContractItem[]) => void - onTotalAmountChange: (total: number) => void - currency?: string - availableBudget?: number - readOnly?: boolean -} - -export function ContractItemsTable({ - contractId, - items, - onItemsChange, - onTotalAmountChange, - currency = 'USD', - availableBudget = 0, - readOnly = false -}: ContractItemsTableProps) { - const [localItems, setLocalItems] = React.useState(items) - const [isSaving, setIsSaving] = React.useState(false) - const [isLoading, setIsLoading] = React.useState(false) - const [isEnabled, setIsEnabled] = React.useState(true) - - // 초기 데이터 로드 - React.useEffect(() => { - const loadItems = async () => { - try { - setIsLoading(true) - const fetchedItems = await getContractItems(contractId) - const formattedItems = fetchedItems.map(item => ({ - id: item.id, - project: item.project || '', - itemCode: item.itemCode || '', - itemInfo: item.itemInfo || '', - specification: item.specification || '', - quantity: item.quantity || 0, - quantityUnit: item.quantityUnit || 'KG', - contractDeliveryDate: item.contractDeliveryDate || '', - contractUnitPrice: item.contractUnitPrice || 0, - contractAmount: item.contractAmount || 0, - contractCurrency: item.contractCurrency || currency, - isSelected: false - })) - setLocalItems(formattedItems) - onItemsChange(formattedItems) - } catch (error) { - console.error('Error loading contract items:', error) - // 기본 빈 배열로 설정 - setLocalItems([]) - onItemsChange([]) - } finally { - setIsLoading(false) - } - } - - loadItems() - }, [contractId, currency, onItemsChange]) - - // 로컬 상태와 부모 상태 동기화 (초기 로드 후에는 부모 상태 우선) - React.useEffect(() => { - if (items.length > 0) { - setLocalItems(items) - } - }, [items]) - - const handleSaveItems = async () => { - try { - setIsSaving(true) - - // validation 체크 - const errors = [] - localItems.forEach((item, index) => { - if (!item.project) errors.push(`${index + 1}번째 품목의 프로젝트`) - if (!item.itemCode) errors.push(`${index + 1}번째 품목의 품목코드`) - if (!item.itemInfo) errors.push(`${index + 1}번째 품목의 Item 정보`) - if (!item.specification) 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}번째 품목의 납기일`) - }) - - if (errors.length > 0) { - toast.error(`다음 항목을 입력해주세요: ${errors.join(', ')}`) - return - } - - await updateContractItems(contractId, localItems) - toast.success('품목정보가 저장되었습니다.') - } catch (error) { - console.error('Error saving contract items:', error) - toast.error('품목정보 저장 중 오류가 발생했습니다.') - } finally { - setIsSaving(false) - } - } - - // 총 금액 계산 - const totalAmount = localItems.reduce((sum, item) => sum + item.contractAmount, 0) - const totalQuantity = localItems.reduce((sum, item) => sum + item.quantity, 0) - const totalUnitPrice = localItems.reduce((sum, item) => sum + item.contractUnitPrice, 0) - const amountDifference = availableBudget - totalAmount - const budgetRatio = availableBudget > 0 ? (totalAmount / availableBudget) * 100 : 0 - - // 부모 컴포넌트에 총 금액 전달 - React.useEffect(() => { - onTotalAmountChange(totalAmount) - }, [totalAmount, onTotalAmountChange]) - - // 아이템 업데이트 - const updateItem = (index: number, field: keyof ContractItem, value: any) => { - const updatedItems = [...localItems] - updatedItems[index] = { ...updatedItems[index], [field]: value } - - // 단가나 수량이 변경되면 금액 자동 계산 - if (field === 'contractUnitPrice' || field === 'quantity') { - const item = updatedItems[index] - updatedItems[index].contractAmount = item.contractUnitPrice * item.quantity - } - - setLocalItems(updatedItems) - onItemsChange(updatedItems) - } - - // 행 추가 - const addRow = () => { - const newItem: ContractItem = { - project: '', - itemCode: '', - itemInfo: '', - specification: '', - quantity: 0, - quantityUnit: 'KG', - contractDeliveryDate: '', - contractUnitPrice: 0, - contractAmount: 0, - contractCurrency: currency, - isSelected: false - } - const updatedItems = [...localItems, newItem] - setLocalItems(updatedItems) - onItemsChange(updatedItems) - } - - // 선택된 행 삭제 - const deleteSelectedRows = () => { - const selectedIndices = localItems - .map((item, index) => item.isSelected ? index : -1) - .filter(index => index !== -1) - - if (selectedIndices.length === 0) { - toast.error("삭제할 행을 선택해주세요.") - return - } - - const updatedItems = localItems.filter((_, index) => !selectedIndices.includes(index)) - setLocalItems(updatedItems) - onItemsChange(updatedItems) - toast.success(`${selectedIndices.length}개 행이 삭제되었습니다.`) - } - - // 전체 선택/해제 - const toggleSelectAll = (checked: boolean) => { - const updatedItems = localItems.map(item => ({ ...item, isSelected: checked })) - setLocalItems(updatedItems) - onItemsChange(updatedItems) - } - - - // 통화 포맷팅 - const formatCurrency = (amount: number) => { - return new Intl.NumberFormat('ko-KR', { - style: 'currency', - currency: currency, - }).format(amount) - } - - const allSelected = localItems.length > 0 && localItems.every(item => item.isSelected) - const someSelected = localItems.some(item => item.isSelected) - - if (isLoading) { - return ( - - - -
- - 품목 정보 - (로딩 중...) -
-
- -
- - 품목 정보를 불러오는 중... -
-
-
-
- ) - } - - return ( - - - -
- - 품목 정보 - ({localItems.length}개 품목) -
-
- - - - {/* 체크박스 */} -
- setIsEnabled(checked as boolean)} - disabled={readOnly} - /> - 품목 정보 활성화 -
- -
-
- 총 금액: {totalAmount.toLocaleString()} {currency} - 총 수량: {totalQuantity.toLocaleString()} -
- {!readOnly && ( -
- - - -
- )} -
- - {/* 요약 정보 */} -
-
- -
- {formatCurrency(totalAmount)} -
-
-
- -
- {formatCurrency(availableBudget)} -
-
-
- -
= 0 ? 'text-green-600' : 'text-red-600'}`}> - {formatCurrency(amountDifference)} -
-
-
- -
- {budgetRatio.toFixed(1)}% -
-
-
-
- - -
- - - - - {!readOnly && ( - { - if (el) (el as any).indeterminate = someSelected && !allSelected - }} - onCheckedChange={toggleSelectAll} - disabled={!isEnabled} - /> - )} - - 프로젝트 - 품목코드 (PKG No.) - Item 정보 (자재그룹 / 자재코드) - 규격 - 수량 - 수량단위 - 계약납기일 - 계약단가 - 계약금액 - 계약통화 - - - - {localItems.map((item, index) => ( - - - {!readOnly && ( - - updateItem(index, 'isSelected', checked) - } - disabled={!isEnabled} - /> - )} - - - {readOnly ? ( - {item.project || '-'} - ) : ( - updateItem(index, 'project', e.target.value)} - placeholder="프로젝트" - className="w-32" - disabled={!isEnabled} - /> - )} - - - {readOnly ? ( - {item.itemCode || '-'} - ) : ( - updateItem(index, 'itemCode', e.target.value)} - placeholder="품목코드" - className="w-32" - disabled={!isEnabled} - /> - )} - - - {readOnly ? ( - {item.itemInfo || '-'} - ) : ( - updateItem(index, 'itemInfo', e.target.value)} - placeholder="Item 정보" - className="w-48" - disabled={!isEnabled} - /> - )} - - - {readOnly ? ( - {item.specification || '-'} - ) : ( - updateItem(index, 'specification', e.target.value)} - placeholder="규격" - className="w-32" - disabled={!isEnabled} - /> - )} - - - {readOnly ? ( - {item.quantity.toLocaleString()} - ) : ( - updateItem(index, 'quantity', parseFloat(e.target.value) || 0)} - className="w-24 text-right" - placeholder="0" - disabled={!isEnabled} - /> - )} - - - {readOnly ? ( - {item.quantityUnit || '-'} - ) : ( - updateItem(index, 'quantityUnit', e.target.value)} - placeholder="단위" - className="w-16" - disabled={!isEnabled} - /> - )} - - - {readOnly ? ( - {item.contractDeliveryDate || '-'} - ) : ( - updateItem(index, 'contractDeliveryDate', e.target.value)} - className="w-36" - disabled={!isEnabled} - /> - )} - - - {readOnly ? ( - {item.contractUnitPrice.toLocaleString()} - ) : ( - updateItem(index, 'contractUnitPrice', parseFloat(e.target.value) || 0)} - className="w-24 text-right" - placeholder="0" - disabled={!isEnabled} - /> - )} - - -
- {formatCurrency(item.contractAmount)} -
-
- - {readOnly ? ( - {item.contractCurrency || '-'} - ) : ( - updateItem(index, 'contractCurrency', e.target.value)} - placeholder="통화" - className="w-16" - disabled={!isEnabled} - /> - )} - -
- ))} -
-
-
- - {/* 합계 행 */} - {localItems.length > 0 && ( -
- - - - - 합계 - - - {totalQuantity.toLocaleString()} - - - {localItems[0]?.quantityUnit || '-'} - - - - {totalUnitPrice.toLocaleString()} - - - {formatCurrency(totalAmount)} - - - {currency} - - - -
-
- )} -
-
-
-
-
- ) -} +'use client' + +import * as React from 'react' +import { Card, CardContent, CardHeader } from '@/components/ui/card' +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + Package, + Plus, + Trash2, +} from 'lucide-react' +import { toast } from 'sonner' +import { updateContractItems, getContractItems } from '../service' +import { Save, LoaderIcon } from 'lucide-react' + +interface ContractItem { + id?: number + project: string + itemCode: string + itemInfo: string + specification: string + quantity: number + quantityUnit: string + contractDeliveryDate: string + contractUnitPrice: number + contractAmount: number + contractCurrency: string + isSelected?: boolean + [key: string]: unknown +} + +interface ContractItemsTableProps { + contractId: number + items: ContractItem[] + onItemsChange: (items: ContractItem[]) => void + onTotalAmountChange: (total: number) => void + currency?: string + availableBudget?: number + readOnly?: boolean +} + +export function ContractItemsTable({ + contractId, + items, + onItemsChange, + onTotalAmountChange, + currency = 'USD', + availableBudget = 0, + readOnly = false +}: ContractItemsTableProps) { + // 통화 코드가 null이거나 undefined일 때 기본값 설정 + const safeCurrency = currency || 'USD' + const [localItems, setLocalItems] = React.useState(items) + const [isSaving, setIsSaving] = React.useState(false) + const [isLoading, setIsLoading] = React.useState(false) + const [isEnabled, setIsEnabled] = React.useState(true) + + // 초기 데이터 로드 + React.useEffect(() => { + const loadItems = async () => { + try { + setIsLoading(true) + const fetchedItems = await getContractItems(contractId) + const formattedItems = fetchedItems.map(item => ({ + id: item.id, + project: item.project || '', + itemCode: item.itemCode || '', + itemInfo: item.itemInfo || '', + specification: item.specification || '', + quantity: Number(item.quantity) || 0, + quantityUnit: item.quantityUnit || 'KG', + contractDeliveryDate: item.contractDeliveryDate || '', + contractUnitPrice: Number(item.contractUnitPrice) || 0, + contractAmount: Number(item.contractAmount) || 0, + contractCurrency: item.contractCurrency || safeCurrency, + isSelected: false + })) as ContractItem[] + setLocalItems(formattedItems as ContractItem[]) + onItemsChange(formattedItems as ContractItem[]) + } catch (error) { + console.error('Error loading contract items:', error) + // 기본 빈 배열로 설정 + setLocalItems([]) + onItemsChange([]) + } finally { + setIsLoading(false) + } + } + + loadItems() + }, [contractId, currency, onItemsChange]) + + // 로컬 상태와 부모 상태 동기화 (초기 로드 후에는 부모 상태 우선) + React.useEffect(() => { + if (items.length > 0) { + setLocalItems(items) + } + }, [items]) + + const handleSaveItems = async () => { + try { + setIsSaving(true) + + // validation 체크 + const errors: string[] = [] + for (let index = 0; index < localItems.length; index++) { + const item = localItems[index] + if (!item.project) errors.push(`${index + 1}번째 품목의 프로젝트`) + if (!item.itemCode) errors.push(`${index + 1}번째 품목의 품목코드`) + if (!item.itemInfo) errors.push(`${index + 1}번째 품목의 Item 정보`) + if (!item.specification) 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}번째 품목의 납기일`) + } + + if (errors.length > 0) { + toast.error(`다음 항목을 입력해주세요: ${errors.join(', ')}`) + return + } + + await updateContractItems(contractId, localItems as any) + toast.success('품목정보가 저장되었습니다.') + } catch (error) { + console.error('Error saving contract items:', error) + toast.error('품목정보 저장 중 오류가 발생했습니다.') + } finally { + setIsSaving(false) + } + } + + // 총 금액 계산 + const totalAmount = localItems.reduce((sum, item) => sum + item.contractAmount, 0) + const totalQuantity = localItems.reduce((sum, item) => sum + item.quantity, 0) + const totalUnitPrice = localItems.reduce((sum, item) => sum + item.contractUnitPrice, 0) + const amountDifference = availableBudget - totalAmount + const budgetRatio = availableBudget > 0 ? (totalAmount / availableBudget) * 100 : 0 + + // 부모 컴포넌트에 총 금액 전달 + React.useEffect(() => { + onTotalAmountChange(totalAmount) + }, [totalAmount, onTotalAmountChange]) + + // 아이템 업데이트 + const updateItem = (index: number, field: keyof ContractItem, value: string | number | boolean | undefined) => { + const updatedItems = [...localItems] + updatedItems[index] = { ...updatedItems[index], [field]: value } + + // 단가나 수량이 변경되면 금액 자동 계산 + if (field === 'contractUnitPrice' || field === 'quantity') { + const item = updatedItems[index] + updatedItems[index].contractAmount = item.contractUnitPrice * item.quantity + } + + setLocalItems(updatedItems) + onItemsChange(updatedItems) + } + + // 행 추가 + const addRow = () => { + const newItem: ContractItem = { + project: '', + itemCode: '', + itemInfo: '', + specification: '', + quantity: 0, + quantityUnit: 'KG', + contractDeliveryDate: '', + contractUnitPrice: 0, + contractAmount: 0, + contractCurrency: safeCurrency, + isSelected: false + } + const updatedItems = [...localItems, newItem] + setLocalItems(updatedItems) + onItemsChange(updatedItems) + } + + // 선택된 행 삭제 + const deleteSelectedRows = () => { + const selectedIndices = localItems + .map((item, index) => item.isSelected ? index : -1) + .filter(index => index !== -1) + + if (selectedIndices.length === 0) { + toast.error("삭제할 행을 선택해주세요.") + return + } + + const updatedItems = localItems.filter((_, index) => !selectedIndices.includes(index)) + setLocalItems(updatedItems) + onItemsChange(updatedItems) + toast.success(`${selectedIndices.length}개 행이 삭제되었습니다.`) + } + + // 전체 선택/해제 + const toggleSelectAll = (checked: boolean) => { + const updatedItems = localItems.map(item => ({ ...item, isSelected: checked })) + setLocalItems(updatedItems) + onItemsChange(updatedItems) + } + + + // 통화 포맷팅 + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('ko-KR', { + style: 'currency', + currency: safeCurrency, + }).format(amount) + } + + const allSelected = localItems.length > 0 && localItems.every(item => item.isSelected) + const someSelected = localItems.some(item => item.isSelected) + + if (isLoading) { + return ( + + + +
+ + 품목 정보 + (로딩 중...) +
+
+ +
+ + 품목 정보를 불러오는 중... +
+
+
+
+ ) + } + + return ( + + + +
+ + 품목 정보 + ({localItems.length}개 품목) +
+
+ + + + {/* 체크박스 */} +
+ setIsEnabled(checked as boolean)} + disabled={readOnly} + /> + 품목 정보 활성화 +
+ +
+
+ 총 금액: {totalAmount.toLocaleString()} {currency} + 총 수량: {totalQuantity.toLocaleString()} +
+ {!readOnly && ( +
+ + + +
+ )} +
+ + {/* 요약 정보 */} +
+
+ +
+ {formatCurrency(totalAmount)} +
+
+
+ +
+ {formatCurrency(availableBudget)} +
+
+
+ +
= 0 ? 'text-green-600' : 'text-red-600'}`}> + {formatCurrency(amountDifference)} +
+
+
+ +
+ {budgetRatio.toFixed(1)}% +
+
+
+
+ + +
+ + + + + {!readOnly && ( + { + if (el) (el as HTMLInputElement & { indeterminate?: boolean }).indeterminate = someSelected && !allSelected + }} + onCheckedChange={toggleSelectAll} + disabled={!isEnabled} + /> + )} + + 프로젝트 + 품목코드 (PKG No.) + Item 정보 (자재그룹 / 자재코드) + 규격 + 수량 + 수량단위 + 계약납기일 + 계약단가 + 계약금액 + 계약통화 + + + + {localItems.map((item, index) => ( + + + {!readOnly && ( + + updateItem(index, 'isSelected', checked) + } + disabled={!isEnabled} + /> + )} + + + {readOnly ? ( + {item.project || '-'} + ) : ( + updateItem(index, 'project', e.target.value)} + placeholder="프로젝트" + className="h-8 text-sm" + disabled={!isEnabled} + /> + )} + + + {readOnly ? ( + {item.itemCode || '-'} + ) : ( + updateItem(index, 'itemCode', e.target.value)} + placeholder="품목코드" + className="h-8 text-sm" + disabled={!isEnabled} + /> + )} + + + {readOnly ? ( + {item.itemInfo || '-'} + ) : ( + updateItem(index, 'itemInfo', e.target.value)} + placeholder="Item 정보" + className="h-8 text-sm" + disabled={!isEnabled} + /> + )} + + + {readOnly ? ( + {item.specification || '-'} + ) : ( + updateItem(index, 'specification', e.target.value)} + placeholder="규격" + className="h-8 text-sm" + disabled={!isEnabled} + /> + )} + + + {readOnly ? ( + {item.quantity.toLocaleString()} + ) : ( + updateItem(index, 'quantity', parseFloat(e.target.value) || 0)} + className="h-8 text-sm text-right" + placeholder="0" + disabled={!isEnabled} + /> + )} + + + {readOnly ? ( + {item.quantityUnit || '-'} + ) : ( + updateItem(index, 'quantityUnit', e.target.value)} + placeholder="단위" + className="h-8 text-sm w-16" + disabled={!isEnabled} + /> + )} + + + {readOnly ? ( + {item.contractDeliveryDate || '-'} + ) : ( + updateItem(index, 'contractDeliveryDate', e.target.value)} + className="h-8 text-sm" + disabled={!isEnabled} + /> + )} + + + {readOnly ? ( + {item.contractUnitPrice.toLocaleString()} + ) : ( + updateItem(index, 'contractUnitPrice', parseFloat(e.target.value) || 0)} + className="h-8 text-sm text-right" + placeholder="0" + disabled={!isEnabled} + /> + )} + + +
+ {formatCurrency(item.contractAmount)} +
+
+ + {readOnly ? ( + {item.contractCurrency || '-'} + ) : ( + updateItem(index, 'contractCurrency', e.target.value)} + placeholder="통화" + className="h-8 text-sm w-16" + disabled={!isEnabled} + /> + )} + +
+ ))} +
+
+
+ + {/* 합계 정보 */} + {localItems.length > 0 && ( +
+ + +
+
+ 총 수량 + + {totalQuantity.toLocaleString()} {localItems[0]?.quantityUnit || 'KG'} + +
+
+ 총 단가 + + {totalUnitPrice.toLocaleString()} {currency} + +
+
+
+ 합계 금액 + + {formatCurrency(totalAmount)} + +
+
+
+
+
+
+ )} +
+
+
+
+
+ ) +} diff --git a/lib/general-contracts/detail/general-contract-subcontract-checklist.tsx b/lib/general-contracts/detail/general-contract-subcontract-checklist.tsx new file mode 100644 index 00000000..ce7c8baf --- /dev/null +++ b/lib/general-contracts/detail/general-contract-subcontract-checklist.tsx @@ -0,0 +1,610 @@ +'use client' + +import React, { useState } from 'react' +import { Card, CardContent } from '@/components/ui/card' +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion' +import { Checkbox } from '@/components/ui/checkbox' +import { Label } from '@/components/ui/label' +import { Badge } from '@/components/ui/badge' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' +import { Textarea } from '@/components/ui/textarea' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { Button } from '@/components/ui/button' +import { updateSubcontractChecklist } from '../service' +import { toast } from 'sonner' +import { AlertTriangle, CheckCircle, XCircle, HelpCircle, Save } from 'lucide-react' + +interface SubcontractChecklistData { + // 1. 계약서면발급 + contractDocumentIssuance: { + workOrderBeforeStart: boolean + entrustmentDetails: boolean + deliveryDetails: boolean + inspectionMethod: boolean + subcontractPayment: boolean + materialProvision: boolean + priceAdjustment: boolean + } + // 2. 부당하도급대금결정행위 + unfairSubcontractPricing: { + priceReductionWithBasis: boolean + noNegotiationAfterLowestBid: boolean + noDeceptionInPricing: boolean + noUniformPriceReduction: boolean + noDiscriminatoryTreatment: boolean + } + // 점검결과 + inspectionResult: 'compliant' | 'violation' | 'suspected_violation' + // 귀책부서 (위반/위반의심 시 필수) + responsibleDepartment?: string + // 원인 (위반/위반의심 시 필수) + cause?: string + causeOther?: string + // 대책 (위반/위반의심 시 필수) + countermeasure?: string + countermeasureOther?: string +} + +interface SubcontractChecklistProps { + contractId: number + onDataChange: (data: SubcontractChecklistData) => void + readOnly?: boolean + initialData?: SubcontractChecklistData +} + +export function SubcontractChecklist({ contractId, onDataChange, readOnly = false, initialData }: SubcontractChecklistProps) { + // 기본 데이터 구조 + const defaultData: SubcontractChecklistData = { + contractDocumentIssuance: { + workOrderBeforeStart: false, + entrustmentDetails: false, + deliveryDetails: false, + inspectionMethod: false, + subcontractPayment: false, + materialProvision: false, + priceAdjustment: false, + }, + unfairSubcontractPricing: { + priceReductionWithBasis: false, + noNegotiationAfterLowestBid: false, + noDeceptionInPricing: false, + noUniformPriceReduction: false, + noDiscriminatoryTreatment: false, + }, + inspectionResult: 'compliant', + } + + // initialData와 기본값을 깊이 병합 + const mergedInitialData = React.useMemo(() => { + if (!initialData) return defaultData + + return { + contractDocumentIssuance: { + ...defaultData.contractDocumentIssuance, + ...(initialData.contractDocumentIssuance || {}), + }, + unfairSubcontractPricing: { + ...defaultData.unfairSubcontractPricing, + ...(initialData.unfairSubcontractPricing || {}), + }, + inspectionResult: initialData.inspectionResult || defaultData.inspectionResult, + responsibleDepartment: initialData.responsibleDepartment, + cause: initialData.cause, + causeOther: initialData.causeOther, + countermeasure: initialData.countermeasure, + countermeasureOther: initialData.countermeasureOther, + } + }, [initialData]) + + const [isEnabled, setIsEnabled] = useState(true) + const [data, setData] = useState(mergedInitialData) + + // 점검결과 자동 계산 함수 + const calculateInspectionResult = ( + contractDocumentIssuance: SubcontractChecklistData['contractDocumentIssuance'], + unfairSubcontractPricing: SubcontractChecklistData['unfairSubcontractPricing'] + ): 'compliant' | 'violation' | 'suspected_violation' => { + // 1. 계약서면발급의 모든 항목이 체크되어야 함 + const allContractItemsChecked = Object.values(contractDocumentIssuance).every(checked => checked) + + // 2. 부당하도급대금결정행위에서 'X' 항목 체크 확인 + const hasUnfairPricingViolation = Object.values(unfairSubcontractPricing).some(checked => !checked) + + if (!allContractItemsChecked) { + return 'violation' + } else if (hasUnfairPricingViolation) { + return 'suspected_violation' + } + + return 'compliant' + } + + const handleContractDocumentChange = (field: keyof SubcontractChecklistData['contractDocumentIssuance'], checked: boolean) => { + setData(prev => { + const newContractDocumentIssuance = { + ...prev.contractDocumentIssuance, + [field]: checked + } + const newInspectionResult = calculateInspectionResult(newContractDocumentIssuance, prev.unfairSubcontractPricing) + + return { + ...prev, + contractDocumentIssuance: newContractDocumentIssuance, + inspectionResult: newInspectionResult + } + }) + } + + const handleUnfairPricingChange = (field: keyof SubcontractChecklistData['unfairSubcontractPricing'], checked: boolean) => { + setData(prev => { + const newUnfairSubcontractPricing = { + ...prev.unfairSubcontractPricing, + [field]: checked + } + const newInspectionResult = calculateInspectionResult(prev.contractDocumentIssuance, newUnfairSubcontractPricing) + + return { + ...prev, + unfairSubcontractPricing: newUnfairSubcontractPricing, + inspectionResult: newInspectionResult + } + }) + } + + const handleFieldChange = (field: keyof SubcontractChecklistData, value: string) => { + setData(prev => ({ ...prev, [field]: value })) + } + + // 데이터 변경 시 부모 컴포넌트에 전달 (저장 시에만) + const handleSave = async () => { + try { + // validation 체크 + const errors = [] + + // 위반 또는 위반의심인 경우 필수 필드 체크 + if (data.inspectionResult === 'violation' || data.inspectionResult === 'suspected_violation') { + if (!data.responsibleDepartment) errors.push('귀책부서') + if (!data.cause) errors.push('원인') + if (!data.countermeasure) errors.push('대책') + + // 기타 선택 시 추가 입력 필드 체크 + if (data.cause === '기타' && !data.causeOther) errors.push('원인 기타 입력') + if (data.countermeasure === '기타' && !data.countermeasureOther) errors.push('대책 기타 입력') + } + + if (errors.length > 0) { + toast.error(`다음 항목을 입력해주세요: ${errors.join(', ')}`) + return + } + + await updateSubcontractChecklist(contractId, data) + onDataChange(data) + toast.success('하도급법 체크리스트가 저장되었습니다.') + } catch (error) { + console.error('Error saving subcontract checklist:', error) + toast.error('하도급법 체크리스트 저장 중 오류가 발생했습니다.') + } + } + + const getInspectionResultInfo = () => { + switch (data.inspectionResult) { + case 'compliant': + return { + icon: , + label: '준수', + color: 'bg-green-100 text-green-800', + description: '1. 계약서면발급의 모든 항목에 체크, 2. 부당하도급에서 X항목에 체크한 상태' + } + case 'violation': + return { + icon: , + label: '위반', + color: 'bg-red-100 text-red-800', + description: '1. 계약서면발급의 모든 항목 중 1개 이상 미체크 한 경우' + } + case 'suspected_violation': + return { + icon: , + label: '위반의심', + color: 'bg-yellow-100 text-yellow-800', + description: '2. 부당하도급에서 O항목에 체크한 경우' + } + default: + // 기본값으로 준수 상태 반환 + return { + icon: , + label: '준수', + color: 'bg-green-100 text-green-800', + description: '점검 결과가 유효하지 않습니다.' + } + } + } + + const resultInfo = getInspectionResultInfo() + const isViolationOrSuspected = data.inspectionResult === 'violation' || data.inspectionResult === 'suspected_violation' + + const causeOptions = [ + { value: '서면미교부_현업부서 하도급법 이해 부족', label: '서면미교부_현업부서 하도급법 이해 부족' }, + { value: '서면미교부_기존계약 만료前 계약연장에 대한 사전조치 소홀', label: '서면미교부_기존계약 만료前 계약연장에 대한 사전조치 소홀' }, + { value: '서면미교부_긴급작업時 先작업합의서 체결 절차 未인지', label: '서면미교부_긴급작업時 先작업합의서 체결 절차 未인지' }, + { value: '부당가격인하_예산부족 等 원가절감 필요성 대두', label: '부당가격인하_예산부족 等 원가절감 필요성 대두' }, + { value: '부당가격인하_하도급법 이해부족 및 금액 협의과정에 대한 근거 미흡', label: '부당가격인하_하도급법 이해부족 및 금액 협의과정에 대한 근거 미흡' }, + { value: '기타', label: '기타' } + ] + + const countermeasureOptions = [ + { value: '서면미교부_준법지원을 통한 현업부서 계몽활동 실시', label: '서면미교부_준법지원을 통한 현업부서 계몽활동 실시' }, + { value: '서면미교부_계약만료일정 별도 관리 및 사전점검', label: '서면미교부_계약만료일정 별도 관리 및 사전점검' }, + { value: '서면미교부_작업착수前 先작업합의서 체결토록 현업에 가이드', label: '서면미교부_작업착수前 先작업합의서 체결토록 현업에 가이드' }, + { value: '부당가격인하_최종 협의된 견적서 접수/보관 必', label: '부당가격인하_최종 협의된 견적서 접수/보관 必' }, + { value: '부당가격인하_합의서 체결시 \'자율적 의사결정\' 等 문구 삽입', label: '부당가격인하_합의서 체결시 \'자율적 의사결정\' 等 문구 삽입' }, + { value: '부당가격인하_수의계약時 금액 협의과정에 대한 근거 확보 (회의록, 메일, 당초/변경 견적서 等)', label: '부당가격인하_수의계약時 금액 협의과정에 대한 근거 확보 (회의록, 메일, 당초/변경 견적서 等)' }, + { value: '기타', label: '기타' } + ] + + return ( + + + +
+ + 하도급법 자율점검 체크리스트 + + {resultInfo.label} + +
+
+ + + + {/* 체크박스 */} +
+ setIsEnabled(checked as boolean)} + disabled={readOnly} + /> + 하도급법 자율점검 체크리스트 활성화 +
+ + {/* 점검결과 표시 */} +
+
+ {resultInfo.icon} + + {resultInfo.label} + +
+
+ {resultInfo.description} +
+
+ + + {/* 1. 계약서면발급 */} + + + 1. 계약서면발급 + + +
+ + + 본 계약에 해당하는 항목을 아래 안내사항에 따라 'O'인 경우 체크하세요. + + + +
+
+ handleContractDocumentChange('workOrderBeforeStart', checked as boolean)} + disabled={!isEnabled || readOnly} + /> +
+ +

+ ※ 단가, 물량 등을 정하지 못하는 경우 정하는 기일을 기재 +

+
+
+ +
+ handleContractDocumentChange('entrustmentDetails', checked as boolean)} + disabled={!isEnabled || readOnly} + /> +
+ +
+
+ +
+ handleContractDocumentChange('deliveryDetails', checked as boolean)} + disabled={!isEnabled || readOnly} + /> +
+ +

+ 예: 삼성의 검사완료(승인) 후 목적물 인도 등 +

+
+
+ +
+ handleContractDocumentChange('inspectionMethod', checked as boolean)} + disabled={!isEnabled || readOnly} + /> +
+ +

+ 예: 작업완료 후 삼성담당자 입회하에 검사를 실시하고 10일 이내 검사결과 통보 +

+
+
+ +
+ handleContractDocumentChange('subcontractPayment', checked as boolean)} + disabled={!isEnabled || readOnly} + /> +
+ +
+
+ +
+ handleContractDocumentChange('materialProvision', checked as boolean)} + disabled={!isEnabled || readOnly} + /> +
+ +

+ 해당사항 없을 시에도 기재로 간주 +

+
+
+ +
+ handleContractDocumentChange('priceAdjustment', checked as boolean)} + disabled={!isEnabled || readOnly} + /> +
+ +
+
+
+
+
+
+ + {/* 2. 부당하도급대금결정행위 */} + + + 2. 부당하도급대금결정행위 + + +
+ + + 본 계약에 해당하는 항목을 아래 안내사항에 따라 'O'인 경우 체크하세요. +
+ ※ 'X' 항목에 다음 안내사항이 자동 표기됩니다: +
+
+ +
+

안내사항:

+
    +
  • • 단가 인하時 객관/타당한 근거에 의해 산출하고 협력사와 합의
  • +
  • • 최저가 경쟁입찰 후 입찰자와 대금인하 협상 불가
  • +
  • • 협력사에 발주량 등 거래조건에 착오를 일으키게 하거나 타 사업자 견적 또는 거짓 견적을 보여주는 등 기만하여 대금을 결정할 수 없음
  • +
  • • 정당한 이유 없이 일률적 비율로 단가 인하 불가
  • +
  • • 정당한 이유 없이 특정 사업자를 차별 취급 하도록 대금 결정 불가
  • +
+
+ +
+
+ handleUnfairPricingChange('priceReductionWithBasis', checked as boolean)} + disabled={!isEnabled || readOnly} + /> + +
+ +
+ handleUnfairPricingChange('noNegotiationAfterLowestBid', checked as boolean)} + disabled={!isEnabled || readOnly} + /> + +
+ +
+ handleUnfairPricingChange('noDeceptionInPricing', checked as boolean)} + disabled={!isEnabled || readOnly} + /> + +
+ +
+ handleUnfairPricingChange('noUniformPriceReduction', checked as boolean)} + disabled={!isEnabled || readOnly} + /> + +
+ +
+ handleUnfairPricingChange('noDiscriminatoryTreatment', checked as boolean)} + disabled={!isEnabled || readOnly} + /> + +
+
+
+
+
+ + {/* 위반/위반의심 시 추가 정보 */} + {isViolationOrSuspected && ( + + + 위반/위반의심 상세 정보 + + +
+ + + + 점검결과가 위반 또는 위반의심인 경우 아래 정보를 필수로 입력해주세요. + + + +
+
+ +