summaryrefslogtreecommitdiff
path: root/lib/general-contracts_old
diff options
context:
space:
mode:
Diffstat (limited to 'lib/general-contracts_old')
-rw-r--r--lib/general-contracts_old/detail/general-contract-approval-request-dialog.tsx1312
-rw-r--r--lib/general-contracts_old/detail/general-contract-basic-info.tsx1250
-rw-r--r--lib/general-contracts_old/detail/general-contract-communication-channel.tsx362
-rw-r--r--lib/general-contracts_old/detail/general-contract-detail.tsx186
-rw-r--r--lib/general-contracts_old/detail/general-contract-documents.tsx383
-rw-r--r--lib/general-contracts_old/detail/general-contract-field-service-rate.tsx288
-rw-r--r--lib/general-contracts_old/detail/general-contract-info-header.tsx211
-rw-r--r--lib/general-contracts_old/detail/general-contract-items-table.tsx602
-rw-r--r--lib/general-contracts_old/detail/general-contract-location.tsx480
-rw-r--r--lib/general-contracts_old/detail/general-contract-offset-details.tsx314
-rw-r--r--lib/general-contracts_old/detail/general-contract-subcontract-checklist.tsx610
-rw-r--r--lib/general-contracts_old/main/create-general-contract-dialog.tsx413
-rw-r--r--lib/general-contracts_old/main/general-contract-update-sheet.tsx401
-rw-r--r--lib/general-contracts_old/main/general-contracts-table-columns.tsx571
-rw-r--r--lib/general-contracts_old/main/general-contracts-table-toolbar-actions.tsx124
-rw-r--r--lib/general-contracts_old/main/general-contracts-table.tsx217
-rw-r--r--lib/general-contracts_old/service.ts1933
-rw-r--r--lib/general-contracts_old/types.ts125
-rw-r--r--lib/general-contracts_old/validation.ts82
19 files changed, 9864 insertions, 0 deletions
diff --git a/lib/general-contracts_old/detail/general-contract-approval-request-dialog.tsx b/lib/general-contracts_old/detail/general-contract-approval-request-dialog.tsx
new file mode 100644
index 00000000..f05fe9ef
--- /dev/null
+++ b/lib/general-contracts_old/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<string, unknown>
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+interface ContractSummary {
+ basicInfo: Record<string, unknown>
+ items: Record<string, unknown>[]
+ communicationChannel: Record<string, unknown> | null
+ location: Record<string, unknown> | null
+ fieldServiceRate: Record<string, unknown> | null
+ offsetDetails: Record<string, unknown> | null
+ subcontractChecklist: Record<string, unknown> | null
+}
+
+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 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 (
+ <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-4">
+ <TabsTrigger value="1" disabled={currentStep < 1}>
+ 1. 계약 현황 정리
+ </TabsTrigger>
+ <TabsTrigger value="2" disabled={currentStep < 2}>
+ 2. 기본계약 체크
+ </TabsTrigger>
+ <TabsTrigger value="3" disabled={currentStep < 3}>
+ 3. 문서 업로드
+ </TabsTrigger>
+ <TabsTrigger value="4" disabled={currentStep < 4}>
+ 4. 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)
+ .filter(([, value]) => value === true)
+ .map(([key]) => key)
+ .join(', ') || '없음'}
+ </div>
+ <div>
+ <span className="font-medium">계약해지조건:</span>
+ {contractSummary?.basicInfo?.contractTerminationConditions &&
+ Object.entries(contractSummary.basicInfo.contractTerminationConditions)
+ .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">{item.itemInfo || item.description || `품목 ${index + 1}`}</div>
+ <div className="text-muted-foreground">
+ 수량: {item.quantity || 0} | 단가: {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="communication-enabled"
+ checked={!!contractSummary?.communicationChannel}
+ disabled
+ />
+ <Label htmlFor="communication-enabled" className="font-medium">
+ 커뮤니케이션 채널
+ </Label>
+ <Badge variant="outline">선택</Badge>
+ </div>
+ <p className="text-sm text-muted-foreground">
+ {contractSummary?.communicationChannel
+ ? '정보가 입력되어 있습니다.'
+ : '정보가 입력되지 않았습니다.'}
+ </p>
+ </div>
+
+ {/* 위치 정보 */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <Checkbox
+ id="location-enabled"
+ checked={!!contractSummary?.location}
+ disabled
+ />
+ <Label htmlFor="location-enabled" className="font-medium">
+ 위치 정보
+ </Label>
+ <Badge variant="outline">선택</Badge>
+ </div>
+ <p className="text-sm text-muted-foreground">
+ {contractSummary?.location
+ ? '정보가 입력되어 있습니다.'
+ : '정보가 입력되지 않았습니다.'}
+ </p>
+ </div>
+
+ {/* 현장 서비스 요율 */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <Checkbox
+ id="fieldService-enabled"
+ checked={!!contractSummary?.fieldServiceRate}
+ disabled
+ />
+ <Label htmlFor="fieldService-enabled" className="font-medium">
+ 현장 서비스 요율
+ </Label>
+ <Badge variant="outline">선택</Badge>
+ </div>
+ <p className="text-sm text-muted-foreground">
+ {contractSummary?.fieldServiceRate
+ ? '정보가 입력되어 있습니다.'
+ : '정보가 입력되지 않았습니다.'}
+ </p>
+ </div>
+
+ {/* 오프셋 세부사항 */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <Checkbox
+ id="offset-enabled"
+ checked={!!contractSummary?.offsetDetails}
+ disabled
+ />
+ <Label htmlFor="offset-enabled" className="font-medium">
+ 오프셋 세부사항
+ </Label>
+ <Badge variant="outline">선택</Badge>
+ </div>
+ <p className="text-sm text-muted-foreground">
+ {contractSummary?.offsetDetails
+ ? '정보가 입력되어 있습니다.'
+ : '정보가 입력되지 않았습니다.'}
+ </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단계: 문서 업로드 */}
+ <TabsContent value="3" className="space-y-4">
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Upload className="h-5 w-5 text-blue-600" />
+ 계약서 업로드
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="space-y-4">
+ <p className="text-lg text-muted-foreground">일반계약 표준문서 관리 페이지에 접속하여, 원하는 양식의 계약서를 다운받아 수정 후 업로드하세요.</p>
+ <div>
+ <Label htmlFor="file-upload">파일 업로드</Label>
+ <Input
+ id="file-upload"
+ type="file"
+ accept=".doc,.docx"
+ onChange={(e) => {
+ const file = e.target.files?.[0]
+ if (file) handleFileUpload(file)
+ }}
+ />
+ <p className="text-sm text-muted-foreground mt-1">
+ Word 문서(.doc, .docx) 파일만 업로드 가능합니다.
+ </p>
+ </div>
+
+ {/* ContractDocuments 컴포넌트 사용 */}
+ {/* <div className="mt-4">
+ <Label>업로드된 문서</Label>
+ <ContractDocuments
+ contractId={contractId}
+ userId={userId}
+ readOnly={false}
+ />
+ </div> */}
+
+ {uploadedFile && (
+ <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">업로드 완료</span>
+ </div>
+ <p className="text-sm text-green-800 mt-1">{uploadedFile.name}</p>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+
+ <div className="flex justify-between">
+ <Button variant="outline" onClick={() => setCurrentStep(2)}>
+ 이전 단계
+ </Button>
+ <Button
+ onClick={() => setCurrentStep(4)}
+ disabled={!uploadedFile}
+ >
+ 다음 단계
+ </Button>
+ </div>
+ </TabsContent>
+
+ {/* 4단계: PDF 미리보기 */}
+ <TabsContent value="4" 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(3)}>
+ 이전 단계
+ </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>
+ )} \ No newline at end of file
diff --git a/lib/general-contracts_old/detail/general-contract-basic-info.tsx b/lib/general-contracts_old/detail/general-contract-basic-info.tsx
new file mode 100644
index 00000000..d891fe63
--- /dev/null
+++ b/lib/general-contracts_old/detail/general-contract-basic-info.tsx
@@ -0,0 +1,1250 @@
+'use client'
+
+import React, { useState } from 'react'
+import { useSession } from 'next-auth/react'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { Textarea } from '@/components/ui/textarea'
+import { Button } from '@/components/ui/button'
+import { Save, LoaderIcon } from 'lucide-react'
+import { updateContractBasicInfo, getContractBasicInfo } from '../service'
+import { toast } from 'sonner'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { GeneralContract } from '@/db/schema'
+import { ContractDocuments } from './general-contract-documents'
+import { getPaymentTermsForSelection, getIncotermsForSelection, getPlaceOfShippingForSelection, getPlaceOfDestinationForSelection } from '@/lib/procurement-select/service'
+import { TAX_CONDITIONS, getTaxConditionName } from '@/lib/tax-conditions/types'
+
+interface ContractBasicInfoProps {
+ contractId: number
+}
+
+export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
+ const session = useSession()
+ const [isLoading, setIsLoading] = useState(false)
+ const [contract, setContract] = useState<GeneralContract | null>(null)
+ const userId = session.data?.user?.id ? Number(session.data.user.id) : null
+
+ // 독립적인 상태 관리
+ const [paymentDeliveryPercent, setPaymentDeliveryPercent] = useState('')
+
+ // Procurement 데이터 상태들
+ const [paymentTermsOptions, setPaymentTermsOptions] = useState<Array<{code: string, description: string}>>([])
+ const [incotermsOptions, setIncotermsOptions] = useState<Array<{code: string, description: string}>>([])
+ const [shippingPlaces, setShippingPlaces] = useState<Array<{code: string, description: string}>>([])
+ const [destinationPlaces, setDestinationPlaces] = useState<Array<{code: string, description: string}>>([])
+ const [procurementLoading, setProcurementLoading] = useState(false)
+
+ const [formData, setFormData] = useState({
+ specificationType: '',
+ specificationManualText: '',
+ unitPriceType: '',
+ warrantyPeriod: {
+ 납품후: { enabled: false, period: 0, maxPeriod: 0 },
+ 인도후: { enabled: false, period: 0, maxPeriod: 0 },
+ 작업후: { enabled: false, period: 0, maxPeriod: 0 },
+ 기타: { enabled: false, period: 0, maxPeriod: 0 },
+ },
+ contractAmount: null as number | null,
+ currency: 'KRW',
+ linkedPoNumber: '',
+ linkedBidNumber: '',
+ notes: '',
+ // 개별 JSON 필드들 (스키마에 맞게)
+ paymentBeforeDelivery: {} as any,
+ paymentDelivery: '', // varchar 타입
+ paymentAfterDelivery: {} as any,
+ paymentTerm: '',
+ taxType: '',
+ liquidatedDamages: false as boolean,
+ liquidatedDamagesPercent: '',
+ deliveryType: '',
+ deliveryTerm: '',
+ shippingLocation: '',
+ dischargeLocation: '',
+ contractDeliveryDate: '',
+ contractEstablishmentConditions: {
+ regularVendorRegistration: false,
+ projectAward: false,
+ ownerApproval: false,
+ other: false,
+ },
+ interlockingSystem: '',
+ mandatoryDocuments: {
+ technicalDataAgreement: false,
+ nda: false,
+ basicCompliance: false,
+ safetyHealthAgreement: false,
+ },
+ contractTerminationConditions: {
+ standardTermination: false,
+ projectNotAwarded: false,
+ other: false,
+ },
+ })
+
+ const [errors] = useState<Record<string, string>>({})
+
+ // 계약 데이터 로드
+ React.useEffect(() => {
+ const loadContract = async () => {
+ try {
+ console.log('Loading contract with ID:', contractId)
+ const contractData = await getContractBasicInfo(contractId)
+ console.log('Contract data received:', contractData)
+ setContract(contractData as GeneralContract)
+
+ // JSON 필드들 파싱 (null 체크) - 스키마에 맞게 개별 필드로 접근
+ const paymentBeforeDelivery = (contractData?.paymentBeforeDelivery && typeof contractData.paymentBeforeDelivery === 'object') ? contractData.paymentBeforeDelivery as any : {}
+ const paymentAfterDelivery = (contractData?.paymentAfterDelivery && typeof contractData.paymentAfterDelivery === 'object') ? contractData.paymentAfterDelivery as any : {}
+ const warrantyPeriod = (contractData?.warrantyPeriod && typeof contractData.warrantyPeriod === 'object') ? contractData.warrantyPeriod as any : {}
+ const contractEstablishmentConditions = (contractData?.contractEstablishmentConditions && typeof contractData.contractEstablishmentConditions === 'object') ? contractData.contractEstablishmentConditions as any : {}
+ const mandatoryDocuments = (contractData?.mandatoryDocuments && typeof contractData.mandatoryDocuments === 'object') ? contractData.mandatoryDocuments as any : {}
+ const contractTerminationConditions = (contractData?.contractTerminationConditions && typeof contractData.contractTerminationConditions === 'object') ? contractData.contractTerminationConditions as any : {}
+
+ // paymentDelivery에서 퍼센트와 타입 분리
+ const paymentDeliveryValue = contractData?.paymentDelivery || ''
+ let paymentDeliveryType = ''
+ let paymentDeliveryPercentValue = ''
+
+ if (paymentDeliveryValue.includes('%')) {
+ const match = paymentDeliveryValue.match(/(\d+)%\s*(.+)/)
+ if (match) {
+ paymentDeliveryPercentValue = match[1]
+ paymentDeliveryType = match[2]
+ }
+ } else {
+ paymentDeliveryType = paymentDeliveryValue
+ }
+
+ setPaymentDeliveryPercent(paymentDeliveryPercentValue)
+
+ setFormData({
+ specificationType: contractData?.specificationType || '',
+ specificationManualText: contractData?.specificationManualText || '',
+ unitPriceType: contractData?.unitPriceType || '',
+ warrantyPeriod: warrantyPeriod || {
+ 납품후: { enabled: false, period: 0, maxPeriod: 0 },
+ 인도후: { enabled: false, period: 0, maxPeriod: 0 },
+ 작업후: { enabled: false, period: 0, maxPeriod: 0 },
+ 기타: { enabled: false, period: 0, maxPeriod: 0 },
+ },
+ contractAmount: contractData?.contractAmount || null,
+ currency: contractData?.currency || 'KRW',
+ linkedPoNumber: contractData?.linkedPoNumber || '',
+ linkedBidNumber: contractData?.linkedBidNumber || '',
+ notes: contractData?.notes || '',
+ // 개별 JSON 필드들
+ paymentBeforeDelivery: paymentBeforeDelivery || {} as any,
+ paymentDelivery: paymentDeliveryType, // 분리된 타입만 저장
+ paymentAfterDelivery: paymentAfterDelivery || {} as any,
+ paymentTerm: contractData?.paymentTerm || '',
+ taxType: contractData?.taxType || '',
+ liquidatedDamages: Boolean(contractData?.liquidatedDamages),
+ liquidatedDamagesPercent: contractData?.liquidatedDamagesPercent || '',
+ deliveryType: contractData?.deliveryType || '',
+ deliveryTerm: contractData?.deliveryTerm || '',
+ shippingLocation: contractData?.shippingLocation || '',
+ dischargeLocation: contractData?.dischargeLocation || '',
+ contractDeliveryDate: contractData?.contractDeliveryDate || '',
+ contractEstablishmentConditions: contractEstablishmentConditions || {
+ regularVendorRegistration: false,
+ projectAward: false,
+ ownerApproval: false,
+ other: false,
+ },
+ interlockingSystem: contractData?.interlockingSystem || '',
+ mandatoryDocuments: mandatoryDocuments || {
+ technicalDataAgreement: false,
+ nda: false,
+ basicCompliance: false,
+ safetyHealthAgreement: false,
+ },
+ contractTerminationConditions: contractTerminationConditions || {
+ standardTermination: false,
+ projectNotAwarded: false,
+ other: false,
+ },
+ })
+ } catch (error) {
+ console.error('Error loading contract:', error)
+ toast.error('계약 정보를 불러오는 중 오류가 발생했습니다.')
+ }
+ }
+
+ if (contractId) {
+ loadContract()
+ }
+ }, [contractId])
+
+ // Procurement 데이터 로드 함수들
+ const loadPaymentTerms = React.useCallback(async () => {
+ setProcurementLoading(true);
+ try {
+ const data = await getPaymentTermsForSelection();
+ setPaymentTermsOptions(data);
+ } catch (error) {
+ console.error("Failed to load payment terms:", error);
+ toast.error("결제조건 목록을 불러오는데 실패했습니다.");
+ } finally {
+ setProcurementLoading(false);
+ }
+ }, []);
+
+ const loadIncoterms = React.useCallback(async () => {
+ setProcurementLoading(true);
+ try {
+ const data = await getIncotermsForSelection();
+ setIncotermsOptions(data);
+ } catch (error) {
+ console.error("Failed to load incoterms:", error);
+ toast.error("운송조건 목록을 불러오는데 실패했습니다.");
+ } finally {
+ setProcurementLoading(false);
+ }
+ }, []);
+
+ const loadShippingPlaces = React.useCallback(async () => {
+ setProcurementLoading(true);
+ try {
+ const data = await getPlaceOfShippingForSelection();
+ setShippingPlaces(data);
+ } catch (error) {
+ console.error("Failed to load shipping places:", error);
+ toast.error("선적지 목록을 불러오는데 실패했습니다.");
+ } finally {
+ setProcurementLoading(false);
+ }
+ }, []);
+
+ const loadDestinationPlaces = React.useCallback(async () => {
+ setProcurementLoading(true);
+ try {
+ const data = await getPlaceOfDestinationForSelection();
+ setDestinationPlaces(data);
+ } catch (error) {
+ console.error("Failed to load destination places:", error);
+ toast.error("하역지 목록을 불러오는데 실패했습니다.");
+ } finally {
+ setProcurementLoading(false);
+ }
+ }, []);
+
+ // 컴포넌트 마운트 시 procurement 데이터 로드
+ React.useEffect(() => {
+ loadPaymentTerms();
+ loadIncoterms();
+ loadShippingPlaces();
+ loadDestinationPlaces();
+ }, [loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces]);
+ const handleSaveContractInfo = async () => {
+ if (!userId) {
+ toast.error('사용자 정보를 찾을 수 없습니다.')
+ return
+ }
+ try {
+ setIsLoading(true)
+
+ // 필수값 validation 체크
+ const validationErrors: string[] = []
+ if (!formData.specificationType) validationErrors.push('사양')
+ if (!formData.paymentDelivery) validationErrors.push('납품 지급조건')
+ if (!formData.currency) validationErrors.push('계약통화')
+ if (!formData.paymentTerm) validationErrors.push('지불조건')
+ if (!formData.taxType) validationErrors.push('세금조건')
+
+ if (validationErrors.length > 0) {
+ toast.error(`다음 필수 항목을 입력해주세요: ${validationErrors.join(', ')}`)
+ return
+ }
+
+ // paymentDelivery와 paymentDeliveryPercent 합쳐서 저장
+ const dataToSave = {
+ ...formData,
+ paymentDelivery: (formData.paymentDelivery === 'L/C' || formData.paymentDelivery === 'T/T') && paymentDeliveryPercent
+ ? `${paymentDeliveryPercent}% ${formData.paymentDelivery}`
+ : formData.paymentDelivery
+ }
+
+ await updateContractBasicInfo(contractId, dataToSave, userId as number)
+ toast.success('계약 정보가 저장되었습니다.')
+ } catch (error) {
+ console.error('Error saving contract info:', error)
+ toast.error('계약 정보 저장 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return (
+ <Card className="w-full">
+ <CardHeader>
+ <CardTitle>계약 기본 정보</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <Tabs defaultValue="basic" className="w-full">
+ <TabsList className="grid w-full grid-cols-4 h-auto overflow-x-auto">
+ <TabsTrigger value="basic" className="text-xs px-2 py-2 whitespace-nowrap">기본 정보</TabsTrigger>
+ <TabsTrigger value="conditions" className="text-xs px-2 py-2 whitespace-nowrap">지급/인도 조건</TabsTrigger>
+ <TabsTrigger value="additional" className="text-xs px-2 py-2 whitespace-nowrap">추가 조건</TabsTrigger>
+ <TabsTrigger value="documents" className="text-xs px-2 py-2 whitespace-nowrap">계약첨부문서</TabsTrigger>
+ </TabsList>
+
+ {/* 기본 정보 탭 */}
+ <TabsContent value="basic" className="space-y-6">
+ <Card>
+ {/* 보증기간 및 단가유형 */}
+ <CardHeader>
+ <CardTitle>보증기간 및 단가유형</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {/* 3그리드: 보증기간, 사양, 단가 */}
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
+ {/* 보증기간 */}
+ <div className="flex flex-col gap-2">
+ <Label htmlFor="warrantyPeriod">품질/하자 보증기간</Label>
+ <div className="space-y-3">
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="warrantyAfterDelivery"
+ checked={formData.warrantyPeriod.납품후?.enabled || false}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ warrantyPeriod: {
+ ...prev.warrantyPeriod,
+ 납품후: {
+ ...prev.warrantyPeriod.납품후,
+ enabled: e.target.checked
+ }
+ }
+ }))}
+ className="rounded"
+ />
+ <Label htmlFor="warrantyAfterDelivery" className="text-sm">납품 후</Label>
+ </div>
+ {formData.warrantyPeriod.납품후?.enabled && (
+ <div className="ml-6 flex items-center space-x-2">
+ <Input
+ type="number"
+ placeholder="보증기간"
+ value={formData.warrantyPeriod.납품후?.period || ''}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ warrantyPeriod: {
+ ...prev.warrantyPeriod,
+ 납품후: {
+ ...prev.warrantyPeriod.납품후,
+ period: parseInt(e.target.value) || 0
+ }
+ }
+ }))}
+ className="w-20 h-8 text-sm"
+ />
+ <span className="text-xs text-muted-foreground">개월, 최대</span>
+ <Input
+ type="number"
+ placeholder="최대"
+ value={formData.warrantyPeriod.납품후?.maxPeriod || ''}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ warrantyPeriod: {
+ ...prev.warrantyPeriod,
+ 납품후: {
+ ...prev.warrantyPeriod.납품후,
+ maxPeriod: parseInt(e.target.value) || 0
+ }
+ }
+ }))}
+ className="w-20 h-8 text-sm"
+ />
+ <span className="text-xs text-muted-foreground">개월</span>
+ </div>
+ )}
+
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="warrantyAfterHandover"
+ checked={formData.warrantyPeriod.인도후?.enabled || false}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ warrantyPeriod: {
+ ...prev.warrantyPeriod,
+ 인도후: {
+ ...prev.warrantyPeriod.인도후,
+ enabled: e.target.checked
+ }
+ }
+ }))}
+ className="rounded"
+ />
+ <Label htmlFor="warrantyAfterHandover" className="text-sm">인도 후</Label>
+ </div>
+ {formData.warrantyPeriod.인도후?.enabled && (
+ <div className="ml-6 flex items-center space-x-2">
+ <Input
+ type="number"
+ placeholder="보증기간"
+ value={formData.warrantyPeriod.인도후?.period || ''}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ warrantyPeriod: {
+ ...prev.warrantyPeriod,
+ 인도후: {
+ ...prev.warrantyPeriod.인도후,
+ period: parseInt(e.target.value) || 0
+ }
+ }
+ }))}
+ className="w-20 h-8 text-sm"
+ />
+ <span className="text-xs text-muted-foreground">개월, 최대</span>
+ <Input
+ type="number"
+ placeholder="최대"
+ value={formData.warrantyPeriod.인도후?.maxPeriod || ''}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ warrantyPeriod: {
+ ...prev.warrantyPeriod,
+ 인도후: {
+ ...prev.warrantyPeriod.인도후,
+ maxPeriod: parseInt(e.target.value) || 0
+ }
+ }
+ }))}
+ className="w-20 h-8 text-sm"
+ />
+ <span className="text-xs text-muted-foreground">개월</span>
+ </div>
+ )}
+
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="warrantyAfterWork"
+ checked={formData.warrantyPeriod.작업후?.enabled || false}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ warrantyPeriod: {
+ ...prev.warrantyPeriod,
+ 작업후: {
+ ...prev.warrantyPeriod.작업후,
+ enabled: e.target.checked
+ }
+ }
+ }))}
+ className="rounded"
+ />
+ <Label htmlFor="warrantyAfterWork" className="text-sm">작업 후</Label>
+ </div>
+ {formData.warrantyPeriod.작업후?.enabled && (
+ <div className="ml-6 flex items-center space-x-2">
+ <Input
+ type="number"
+ placeholder="보증기간"
+ value={formData.warrantyPeriod.작업후?.period || ''}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ warrantyPeriod: {
+ ...prev.warrantyPeriod,
+ 작업후: {
+ ...prev.warrantyPeriod.작업후,
+ period: parseInt(e.target.value) || 0
+ }
+ }
+ }))}
+ className="w-20 h-8 text-sm"
+ />
+ <span className="text-xs text-muted-foreground">개월, 최대</span>
+ <Input
+ type="number"
+ placeholder="최대"
+ value={formData.warrantyPeriod.작업후?.maxPeriod || ''}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ warrantyPeriod: {
+ ...prev.warrantyPeriod,
+ 작업후: {
+ ...prev.warrantyPeriod.작업후,
+ maxPeriod: parseInt(e.target.value) || 0
+ }
+ }
+ }))}
+ className="w-20 h-8 text-sm"
+ />
+ <span className="text-xs text-muted-foreground">개월</span>
+ </div>
+ )}
+
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="warrantyOther"
+ checked={formData.warrantyPeriod.기타?.enabled || false}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ warrantyPeriod: {
+ ...prev.warrantyPeriod,
+ 기타: {
+ ...prev.warrantyPeriod.기타,
+ enabled: e.target.checked
+ }
+ }
+ }))}
+ className="rounded"
+ />
+ <Label htmlFor="warrantyOther" className="text-sm">기타/미적용</Label>
+ </div>
+ </div>
+ </div>
+ {/* 사양 */}
+ <div className="flex flex-col gap-2">
+ <Label htmlFor="specificationType">사양 <span className="text-red-600">*</span></Label>
+ <Select value={formData.specificationType} onValueChange={(value) => setFormData(prev => ({ ...prev, specificationType: value }))}>
+ <SelectTrigger className={errors.specificationType ? 'border-red-500' : ''}>
+ <SelectValue placeholder="사양을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="첨부파일">첨부파일</SelectItem>
+ <SelectItem value="표준사양">표준사양</SelectItem>
+ <SelectItem value="수기사양">수기사양</SelectItem>
+ </SelectContent>
+ </Select>
+ {errors.specificationType && (
+ <p className="text-sm text-red-600">사양은 필수값입니다.</p>
+ )}
+ </div>
+ {/* 단가 */}
+ <div className="flex flex-col gap-2">
+ <Label htmlFor="unitPriceType">단가 유형</Label>
+ <Select value={formData.unitPriceType} onValueChange={(value) => setFormData(prev => ({ ...prev, unitPriceType: value }))}>
+ <SelectTrigger>
+ <SelectValue placeholder="단가 유형을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="자재개별단가">자재개별단가</SelectItem>
+ <SelectItem value="서비스용역단가">서비스용역단가</SelectItem>
+ <SelectItem value="프로젝트단가">프로젝트단가</SelectItem>
+ <SelectItem value="지역별단가">지역별단가</SelectItem>
+ <SelectItem value="직무직급단가">직무직급단가</SelectItem>
+ <SelectItem value="단계별단가">단계별단가</SelectItem>
+ <SelectItem value="기타">기타</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ {/* 선택에 따른 폼: vertical로 출력 */}
+
+
+ {/* 사양이 수기사양일 때 매뉴얼 텍스트 */}
+ {formData.specificationType === '수기사양' && (
+ <div className="flex flex-col gap-2">
+ <Label htmlFor="specificationManualText">사양 매뉴얼 텍스트</Label>
+ <Textarea
+ value={formData.specificationManualText}
+ onChange={(e) => setFormData(prev => ({ ...prev, specificationManualText: e.target.value }))}
+ placeholder="사양 매뉴얼 텍스트를 입력하세요"
+ rows={3}
+ />
+ </div>
+ )}
+
+ </div>
+
+
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ {/* 지급/인도 조건 탭 */}
+ <TabsContent value="conditions" className="space-y-6">
+ <Card>
+ <CardHeader>
+ <CardTitle>Payment & Delivery Conditions (지급/인도 조건)</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div className="grid grid-cols-5 gap-6">
+ {/* 납품 전 지급조건 */}
+ <div className="space-y-4">
+ <Label className="text-base font-medium">납품 전</Label>
+ <div className="space-y-3">
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="apBond"
+ checked={formData.paymentBeforeDelivery.apBond || false}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ paymentBeforeDelivery: {
+ ...prev.paymentBeforeDelivery,
+ apBond: e.target.checked
+ }
+ }))}
+ className="rounded"
+ />
+ <Label htmlFor="apBond" className="text-sm">AP Bond & Performance Bond</Label>
+ <Input
+ type="number"
+ min="0"
+ placeholder="%"
+ className="w-16"
+ value={formData.paymentBeforeDelivery.apBondPercent || ''}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ paymentBeforeDelivery: {
+ ...prev.paymentBeforeDelivery,
+ apBondPercent: e.target.value
+ }
+ }))}
+ disabled={!formData.paymentBeforeDelivery.apBond}
+ />
+ </div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="drawingSubmission"
+ checked={formData.paymentBeforeDelivery.drawingSubmission || false}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ paymentBeforeDelivery: {
+ ...prev.paymentBeforeDelivery,
+ drawingSubmission: e.target.checked
+ }
+ }))}
+ className="rounded"
+ />
+ <Label htmlFor="drawingSubmission" className="text-sm">도면제출</Label>
+ <Input
+ type="number"
+ min="0"
+ placeholder="%"
+ className="w-16"
+ value={formData.paymentBeforeDelivery.drawingSubmissionPercent || ''}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ paymentBeforeDelivery: {
+ ...prev.paymentBeforeDelivery,
+ drawingSubmissionPercent: e.target.value
+ }
+ }))}
+ disabled={!formData.paymentBeforeDelivery.drawingSubmission}
+ />
+ </div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="materialPurchase"
+ checked={formData.paymentBeforeDelivery.materialPurchase || false}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ paymentBeforeDelivery: {
+ ...prev.paymentBeforeDelivery,
+ materialPurchase: e.target.checked
+ }
+ }))}
+ className="rounded"
+ />
+ <Label htmlFor="materialPurchase" className="text-sm">소재구매 문서</Label>
+ <Input
+ type="number"
+ min="0"
+ placeholder="%"
+ className="w-16"
+ value={formData.paymentBeforeDelivery.materialPurchasePercent || ''}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ paymentBeforeDelivery: {
+ ...prev.paymentBeforeDelivery,
+ materialPurchasePercent: e.target.value
+ }
+ }))}
+ disabled={!formData.paymentBeforeDelivery.materialPurchase}
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 납품 지급조건 */}
+ <div className="space-y-4">
+ <Label className="text-base font-medium">납품</Label>
+ <div className="space-y-3">
+ <div className="space-y-2">
+ <Label htmlFor="paymentDelivery">납품 지급조건 <span className="text-red-600">*</span></Label>
+ <Select value={formData.paymentDelivery} onValueChange={(value) => setFormData(prev => ({ ...prev, paymentDelivery: value }))}>
+ <SelectTrigger className={errors.paymentDelivery ? 'border-red-500' : ''}>
+ <SelectValue placeholder="납품 지급조건을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="L/C">L/C</SelectItem>
+ <SelectItem value="T/T">T/T</SelectItem>
+ <SelectItem value="거래명세서 기반 정기지급조건">거래명세서 기반 정기지급조건</SelectItem>
+ <SelectItem value="작업 및 입고 검사 완료">작업 및 입고 검사 완료</SelectItem>
+ <SelectItem value="청구내역서 제출 및 승인">청구내역서 제출 및 승인</SelectItem>
+ <SelectItem value="정규금액 월 단위 정산(지정일 지급)">정규금액 월 단위 정산(지정일 지급)</SelectItem>
+ </SelectContent>
+ </Select>
+ {/* L/C 또는 T/T 선택 시 퍼센트 입력 필드 */}
+ {(formData.paymentDelivery === 'L/C' || formData.paymentDelivery === 'T/T') && (
+ <div className="flex items-center gap-2 mt-2">
+ <Input
+ type="number"
+ min="0"
+ value={paymentDeliveryPercent}
+ onChange={(e) => setPaymentDeliveryPercent(e.target.value)}
+ placeholder="퍼센트"
+ className="w-20 h-8 text-sm"
+ />
+ <span className="text-sm text-gray-600">%</span>
+ </div>
+ )}
+ {errors.paymentDelivery && (
+ <p className="text-sm text-red-600">납품 지급조건은 필수값입니다.</p>
+ )}
+ </div>
+ </div>
+ </div>
+
+ {/* 납품 외 지급조건 */}
+ <div className="space-y-4">
+ <Label className="text-base font-medium">납품 외</Label>
+ <div className="space-y-3">
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="commissioning"
+ checked={formData.paymentAfterDelivery.commissioning || false}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ paymentAfterDelivery: {
+ ...prev.paymentAfterDelivery,
+ commissioning: e.target.checked
+ }
+ }))}
+ className="rounded"
+ />
+ <Label htmlFor="commissioning" className="text-sm">Commissioning 완료</Label>
+ <Input
+ type="number"
+ min="0"
+ placeholder="%"
+ className="w-16"
+ value={formData.paymentAfterDelivery.commissioningPercent || ''}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ paymentAfterDelivery: {
+ ...prev.paymentAfterDelivery,
+ commissioningPercent: e.target.value
+ }
+ }))}
+ disabled={!formData.paymentAfterDelivery.commissioning}
+ />
+ </div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="finalDocument"
+ checked={formData.paymentAfterDelivery.finalDocument || false}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ paymentAfterDelivery: {
+ ...prev.paymentAfterDelivery,
+ finalDocument: e.target.checked
+ }
+ }))}
+ className="rounded"
+ />
+ <Label htmlFor="finalDocument" className="text-sm">최종문서 승인</Label>
+ <Input
+ type="number"
+ min="0"
+ placeholder="%"
+ className="w-16"
+ value={formData.paymentAfterDelivery.finalDocumentPercent || ''}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ paymentAfterDelivery: {
+ ...prev.paymentAfterDelivery,
+ finalDocumentPercent: e.target.value
+ }
+ }))}
+ disabled={!formData.paymentAfterDelivery.finalDocument}
+ />
+ </div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="other"
+ checked={formData.paymentAfterDelivery.other || false}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ paymentAfterDelivery: {
+ ...prev.paymentAfterDelivery,
+ other: e.target.checked
+ }
+ }))}
+ className="rounded"
+ />
+ <Label htmlFor="other" className="text-sm">기타</Label>
+ <Input
+ type="text"
+ placeholder="기타 조건을 입력하세요"
+ className="w-48"
+ value={formData.paymentAfterDelivery.otherText || ''}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ paymentAfterDelivery: {
+ ...prev.paymentAfterDelivery,
+ otherText: e.target.value
+ }
+ }))}
+ disabled={!formData.paymentAfterDelivery.other}
+ />
+ </div>
+ </div>
+ </div>
+
+ {/* 지불조건 */}
+ <div className="space-y-4">
+ <Label className="text-base font-medium">지불조건</Label>
+ <div className="space-y-3">
+ <div className="space-y-2">
+ <Label htmlFor="paymentTerm">지불조건 <span className="text-red-600">*</span></Label>
+ <Select
+ value={formData.paymentTerm}
+ onValueChange={(value) => setFormData(prev => ({ ...prev, paymentTerm: value }))}
+ >
+ <SelectTrigger className={errors.paymentTerm ? 'border-red-500' : ''}>
+ <SelectValue placeholder="지불조건을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {paymentTermsOptions.length > 0 ? (
+ paymentTermsOptions.map((option) => (
+ <SelectItem key={option.code} value={option.code}>
+ {option.code} {option.description && `(${option.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
+ {errors.paymentTerm && (
+ <p className="text-sm text-red-600">지불조건은 필수값입니다.</p>
+ )}
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="taxType">세금조건 <span className="text-red-600">*</span></Label>
+ <Select
+ value={formData.taxType}
+ onValueChange={(value) => setFormData(prev => ({ ...prev, taxType: value }))}
+ >
+ <SelectTrigger className={errors.taxType ? 'border-red-500' : ''}>
+ <SelectValue placeholder="세금조건을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {TAX_CONDITIONS.map((condition) => (
+ <SelectItem key={condition.code} value={condition.code}>
+ {condition.name}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ {errors.taxType && (
+ <p className="text-sm text-red-600">세금조건은 필수값입니다.</p>
+ )}
+ </div>
+ </div>
+ </div>
+
+ {/* 클레임금액 */}
+ <div className="space-y-4">
+ <Label className="text-base font-medium">클레임금액</Label>
+ <div className="space-y-3">
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="liquidatedDamages"
+ checked={formData.liquidatedDamages || false}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ liquidatedDamages: e.target.checked
+ }))}
+ className="rounded"
+ />
+ <Label htmlFor="liquidatedDamages" className="text-sm">지체상금</Label>
+ <Input
+ type="number"
+ min="0"
+ placeholder="%"
+ className="w-16"
+ value={formData.liquidatedDamagesPercent || ''}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ liquidatedDamagesPercent: e.target.value
+ }))}
+ disabled={!formData.liquidatedDamages}
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* 인도조건 섹션 */}
+ <div className="mt-8">
+ <h3 className="text-lg font-semibold mb-4">인도조건</h3>
+ <div className="grid grid-cols-5 gap-6">
+ {/* 납기종류 */}
+ <div className="space-y-4">
+ <div className="space-y-3">
+ <div className="space-y-2">
+ <Label htmlFor="deliveryType">납기종류</Label>
+ <Select value={formData.deliveryType} onValueChange={(value) => setFormData(prev => ({ ...prev, deliveryType: value }))}>
+ <SelectTrigger>
+ <SelectValue placeholder="납기종류를 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="단일납기">단일납기</SelectItem>
+ <SelectItem value="분할납기">분할납기</SelectItem>
+ <SelectItem value="구간납기">구간납기</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+ </div>
+
+ {/* 인도조건 */}
+ <div className="space-y-4">
+ <div className="space-y-3">
+ <div className="space-y-2">
+ <Label htmlFor="deliveryTerm">인도조건</Label>
+ <Select
+ value={formData.deliveryTerm}
+ onValueChange={(value) => setFormData(prev => ({ ...prev, deliveryTerm: value }))}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="인도조건을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {incotermsOptions.length > 0 ? (
+ incotermsOptions.map((option) => (
+ <SelectItem key={option.code} value={option.code}>
+ {option.code} {option.description && `(${option.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+ </div>
+
+ {/* 선적지 */}
+ <div className="space-y-4">
+ <div className="space-y-3">
+ <div className="space-y-2">
+ <Label htmlFor="shippingLocation">선적지</Label>
+ <Select
+ value={formData.shippingLocation}
+ onValueChange={(value) => setFormData(prev => ({ ...prev, shippingLocation: value }))}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="선적지를 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {shippingPlaces.length > 0 ? (
+ shippingPlaces.map((place) => (
+ <SelectItem key={place.code} value={place.code}>
+ {place.code} {place.description && `(${place.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+ </div>
+
+ {/* 하역지 */}
+ <div className="space-y-4">
+ <div className="space-y-3">
+ <div className="space-y-2">
+ <Label htmlFor="dischargeLocation">하역지</Label>
+ <Select
+ value={formData.dischargeLocation}
+ onValueChange={(value) => setFormData(prev => ({ ...prev, dischargeLocation: value }))}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="하역지를 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {destinationPlaces.length > 0 ? (
+ destinationPlaces.map((place) => (
+ <SelectItem key={place.code} value={place.code}>
+ {place.code} {place.description && `(${place.description})`}
+ </SelectItem>
+ ))
+ ) : (
+ <SelectItem value="loading" disabled>
+ 데이터를 불러오는 중...
+ </SelectItem>
+ )}
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+ </div>
+
+ {/* 계약납기일 */}
+ <div className="space-y-4">
+ <div className="space-y-3">
+ <div className="space-y-2">
+ <Label htmlFor="contractDeliveryDate">계약납기일</Label>
+ <Input
+ type="date"
+ value={formData.contractDeliveryDate}
+ onChange={(e) => setFormData(prev => ({ ...prev, contractDeliveryDate: e.target.value }))}
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ {/* 추가 조건 탭 */}
+ <TabsContent value="additional" className="space-y-6">
+ <Card>
+ <CardHeader>
+ <CardTitle>Additional Conditions (추가조건)</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="contractAmount">계약금액 (자동계산)</Label>
+ <Input
+ type="text"
+ value={contract?.contractAmount ? new Intl.NumberFormat('ko-KR').format(Number(contract.contractAmount)) : '품목정보 없음'}
+ readOnly
+ className="bg-gray-50"
+ placeholder="품목정보에서 자동 계산됩니다"
+ />
+ </div>
+ <div className="space-y-2">
+ <Label htmlFor="currency">계약통화 <span className="text-red-600">*</span></Label>
+ <Input
+ type="text"
+ value={formData.currency}
+ onChange={(e) => setFormData(prev => ({ ...prev, currency: e.target.value }))}
+ placeholder="계약통화를 입력하세요"
+ className={errors.currency ? 'border-red-500' : ''}
+ />
+ {errors.currency && (
+ <p className="text-sm text-red-600">계약통화는 필수값입니다.</p>
+ )}
+ </div>
+
+ {/* 계약성립조건 */}
+ <div className="space-y-4 col-span-2">
+ <Label className="text-base font-medium">계약성립조건</Label>
+ <div className="space-y-3">
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="regularVendorRegistration"
+ checked={formData.contractEstablishmentConditions.regularVendorRegistration}
+ onChange={(e) => setFormData(prev => ({ ...prev, contractEstablishmentConditions: { ...prev.contractEstablishmentConditions, regularVendorRegistration: e.target.checked } }))}
+ className="rounded"
+ />
+ <Label htmlFor="regularVendorRegistration">정규업체 등록(실사 포함) 시</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="projectAward"
+ checked={formData.contractEstablishmentConditions.projectAward}
+ onChange={(e) => setFormData(prev => ({ ...prev, contractEstablishmentConditions: { ...prev.contractEstablishmentConditions, projectAward: e.target.checked } }))}
+ className="rounded"
+ />
+ <Label htmlFor="projectAward">프로젝트 수주 시</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="ownerApproval"
+ checked={formData.contractEstablishmentConditions.ownerApproval}
+ onChange={(e) => setFormData(prev => ({ ...prev, contractEstablishmentConditions: { ...prev.contractEstablishmentConditions, ownerApproval: e.target.checked } }))}
+ className="rounded"
+ />
+ <Label htmlFor="ownerApproval">선주 승인 시</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="establishmentOther"
+ checked={formData.contractEstablishmentConditions.other}
+ onChange={(e) => setFormData(prev => ({ ...prev, contractEstablishmentConditions: { ...prev.contractEstablishmentConditions, other: e.target.checked } }))}
+ className="rounded"
+ />
+ <Label htmlFor="establishmentOther">기타</Label>
+ </div>
+ </div>
+ </div>
+
+ {/* 연동제적용 */}
+ <div className="space-y-4">
+ <Label className="text-base font-medium">연동제적용</Label>
+ <div className="space-y-2">
+ <Select value={formData.interlockingSystem} onValueChange={(value) => setFormData(prev => ({ ...prev, interlockingSystem: value }))}>
+ <SelectTrigger>
+ <SelectValue placeholder="연동제적용을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="Y">Y</SelectItem>
+ <SelectItem value="N">N</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+
+ {/* 필수문서동의 */}
+ {/* <div className="space-y-4">
+ <Label className="text-base font-medium">필수문서동의</Label>
+ <div className="space-y-3">
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="technicalDataAgreement"
+ checked={formData.mandatoryDocuments.technicalDataAgreement}
+ onChange={(e) => setFormData(prev => ({ ...prev, mandatoryDocuments: { ...prev.mandatoryDocuments, technicalDataAgreement: e.target.checked } }))}
+ className="rounded"
+ />
+ <Label htmlFor="technicalDataAgreement">기술자료제공동의서</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="nda"
+ checked={formData.mandatoryDocuments.nda}
+ onChange={(e) => setFormData(prev => ({ ...prev, mandatoryDocuments: { ...prev.mandatoryDocuments, nda: e.target.checked } }))}
+ className="rounded"
+ />
+ <Label htmlFor="nda">비밀유지계약서(NDA)</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="basicCompliance"
+ checked={formData.mandatoryDocuments.basicCompliance}
+ onChange={(e) => setFormData(prev => ({ ...prev, mandatoryDocuments: { ...prev.mandatoryDocuments, basicCompliance: e.target.checked } }))}
+ className="rounded"
+ />
+ <Label htmlFor="basicCompliance">기본준수서약서</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="safetyHealthAgreement"
+ checked={formData.mandatoryDocuments.safetyHealthAgreement}
+ onChange={(e) => setFormData(prev => ({ ...prev, mandatoryDocuments: { ...prev.mandatoryDocuments, safetyHealthAgreement: e.target.checked } }))}
+ className="rounded"
+ />
+ <Label htmlFor="safetyHealthAgreement">안전보건관리 약정서</Label>
+ </div>
+ </div>
+ </div> */}
+
+ {/* 계약해지조건 */}
+ <div className="space-y-4">
+ <Label className="text-base font-medium">계약해지조건</Label>
+ <div className="space-y-3">
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="standardTermination"
+ checked={formData.contractTerminationConditions.standardTermination}
+ onChange={(e) => setFormData(prev => ({ ...prev, contractTerminationConditions: { ...prev.contractTerminationConditions, standardTermination: e.target.checked } }))}
+ className="rounded"
+ />
+ <Label htmlFor="standardTermination">표준 계약해지조건</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="projectNotAwarded"
+ checked={formData.contractTerminationConditions.projectNotAwarded}
+ onChange={(e) => setFormData(prev => ({ ...prev, contractTerminationConditions: { ...prev.contractTerminationConditions, projectNotAwarded: e.target.checked } }))}
+ className="rounded"
+ />
+ <Label htmlFor="projectNotAwarded">프로젝트 미수주 시</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="terminationOther"
+ checked={formData.contractTerminationConditions.other}
+ onChange={(e) => setFormData(prev => ({ ...prev, contractTerminationConditions: { ...prev.contractTerminationConditions, other: e.target.checked } }))}
+ className="rounded"
+ />
+ <Label htmlFor="terminationOther">기타</Label>
+ </div>
+ </div>
+ </div>
+
+ <div className="space-y-2 col-span-2">
+ <Label htmlFor="notes">비고</Label>
+ <Textarea
+ value={formData.notes}
+ onChange={(e) => setFormData(prev => ({ ...prev, notes: e.target.value }))}
+ placeholder="비고사항을 입력하세요"
+ rows={4}
+ />
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+
+ {/* 계약첨부문서 탭 */}
+ <TabsContent value="documents" className="space-y-6">
+ <ContractDocuments
+ contractId={contractId}
+ userId={userId?.toString() || "1"}
+ />
+ </TabsContent>
+ </Tabs>
+
+ {/* 저장 버튼 */}
+ <div className="flex justify-end mt-6 pt-4 border-t border-gray-200">
+ <Button
+ onClick={handleSaveContractInfo}
+ disabled={isLoading}
+ className="flex items-center gap-2"
+ >
+ {isLoading ? (
+ <LoaderIcon className="w-4 h-4 animate-spin" />
+ ) : (
+ <Save className="w-4 h-4" />
+ )}
+ 계약 정보 저장
+ </Button>
+ </div>
+ </CardContent>
+ </Card>
+ )
+}
diff --git a/lib/general-contracts_old/detail/general-contract-communication-channel.tsx b/lib/general-contracts_old/detail/general-contract-communication-channel.tsx
new file mode 100644
index 00000000..f5cd79b2
--- /dev/null
+++ b/lib/general-contracts_old/detail/general-contract-communication-channel.tsx
@@ -0,0 +1,362 @@
+'use client'
+
+import React, { useState, useEffect } from 'react'
+import { useSession } from 'next-auth/react'
+import { Input } from '@/components/ui/input'
+import { Button } from '@/components/ui/button'
+import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Plus, Trash2, Save, LoaderIcon, MessageSquare } from 'lucide-react'
+import { updateCommunicationChannel, getCommunicationChannel } from '../service'
+import { toast } from 'sonner'
+
+interface CommunicationChannelProps {
+ contractType?: string
+ contractId: number
+}
+
+interface Representative {
+ id: string
+ position: string
+ name: string
+ telNo: string
+ email: string
+ isActive: boolean
+}
+
+export function CommunicationChannel({ contractId }: CommunicationChannelProps) {
+ const session = useSession()
+ const [isLoading, setIsLoading] = useState(false)
+ const [isEnabled, setIsEnabled] = useState(true)
+
+ // 일단 모든 계약종류에서 활성화
+ const isDisabled = false
+
+ const [contractorReps, setContractorReps] = useState<Representative[]>([])
+ const [supplierReps, setSupplierReps] = useState<Representative[]>([])
+
+ // 초기 데이터 로드
+ useEffect(() => {
+ const loadCommunicationChannel = async () => {
+ try {
+ const data = await getCommunicationChannel(contractId)
+ if (data && data.enabled !== undefined) {
+ setIsEnabled(data.enabled)
+ setContractorReps(data.contractorRepresentatives || [])
+ setSupplierReps(data.supplierRepresentatives || [])
+ }
+ } catch (error) {
+ console.error('Error loading communication channel:', error)
+
+ }
+ }
+
+ loadCommunicationChannel()
+ }, [contractId])
+
+ const addContractorRow = () => {
+ const newId = (contractorReps.length + 1).toString()
+ setContractorReps([...contractorReps, {
+ id: newId,
+ position: '',
+ name: '',
+ telNo: '',
+ email: '',
+ isActive: false
+ }])
+ }
+
+ const removeContractorRow = () => {
+ const selectedRows = contractorReps.filter(rep => rep.isActive)
+ if (selectedRows.length > 0) {
+ setContractorReps(contractorReps.filter(rep => !rep.isActive))
+ }
+ }
+
+ const addSupplierRow = () => {
+ const newId = (supplierReps.length + 1).toString()
+ setSupplierReps([...supplierReps, {
+ id: newId,
+ position: '',
+ name: '',
+ telNo: '',
+ email: '',
+ isActive: false
+ }])
+ }
+
+ const removeSupplierRow = () => {
+ const selectedRows = supplierReps.filter(rep => rep.isActive)
+ if (selectedRows.length > 0) {
+ setSupplierReps(supplierReps.filter(rep => !rep.isActive))
+ }
+ }
+
+ const updateContractorRep = (id: string, field: keyof Representative, value: string | boolean) => {
+ setContractorReps(contractorReps.map(rep =>
+ rep.id === id ? { ...rep, [field]: value } : rep
+ ))
+ }
+
+ const updateSupplierRep = (id: string, field: keyof Representative, value: string | boolean) => {
+ setSupplierReps(supplierReps.map(rep =>
+ rep.id === id ? { ...rep, [field]: value } : rep
+ ))
+ }
+
+ const handleSaveCommunicationChannel = async () => {
+ const userId = session.data?.user?.id ? Number(session.data.user.id) : null
+
+ if (!userId) {
+ toast.error('사용자 정보를 찾을 수 없습니다.')
+ return
+ }
+
+ try {
+ setIsLoading(true)
+
+ const communicationData = {
+ enabled: isEnabled,
+ contractorRepresentatives: contractorReps,
+ supplierRepresentatives: supplierReps
+ }
+
+ await updateCommunicationChannel(contractId, communicationData, userId)
+ toast.success('커뮤니케이션 채널이 저장되었습니다.')
+ } catch (error) {
+ console.error('Error saving communication channel:', error)
+ toast.error('커뮤니케이션 채널 저장에 실패했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return (
+ <div className="w-full">
+ <Accordion type="single" collapsible className="w-full">
+ {/* Communication Channel 활성화 */}
+ <AccordionItem value="communication-channel">
+ <AccordionTrigger className="hover:no-underline">
+ <div className="flex items-center gap-3 w-full">
+ <MessageSquare className="w-5 h-5" />
+ <span className="font-medium">Communication Channel</span>
+ </div>
+ </AccordionTrigger>
+ <AccordionContent>
+ <div className="space-y-6">
+ {/* 체크박스 */}
+ <div className="flex items-center gap-2">
+ <Checkbox
+ checked={isEnabled}
+ disabled={isDisabled}
+ onCheckedChange={(checked) => {
+ if (!isDisabled) {
+ setIsEnabled(checked as boolean)
+ }
+ }}
+ />
+ <span className="text-sm font-medium">Communication Channel 활성화</span>
+ </div>
+
+ {/* Table 1: The Contractor's Representatives */}
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <h3 className="text-lg font-medium">Table 1: The Contractor &apos;s Representatives</h3>
+ <div className="flex gap-2">
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={addContractorRow}
+ disabled={isDisabled || !isEnabled}
+ >
+ <Plus className="w-4 h-4 mr-1" />
+ 행 추가
+ </Button>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={removeContractorRow}
+ disabled={isDisabled || !isEnabled}
+ >
+ <Trash2 className="w-4 h-4 mr-1" />
+ 행 삭제
+ </Button>
+ </div>
+ </div>
+
+ <div className="overflow-x-auto">
+ <table className={`w-full border-collapse border border-gray-300 ${!isEnabled ? 'opacity-50' : ''}`}>
+ <thead>
+ <tr className="bg-yellow-100">
+ <th className="border border-gray-300 p-2 w-12"></th>
+ <th className="border border-gray-300 p-2 w-16">No.</th>
+ <th className="border border-gray-300 p-2">Position</th>
+ <th className="border border-gray-300 p-2">Name</th>
+ <th className="border border-gray-300 p-2">Tel. No.</th>
+ <th className="border border-gray-300 p-2">Email</th>
+ </tr>
+ </thead>
+ <tbody>
+ {contractorReps.map((rep) => (
+ <tr key={rep.id} className="bg-yellow-50">
+ <td className="border border-gray-300 p-2 text-center">
+ <Checkbox
+ checked={rep.isActive}
+ onCheckedChange={(checked) => updateContractorRep(rep.id, 'isActive', checked as boolean)}
+ disabled={isDisabled || !isEnabled}
+ />
+ </td>
+ <td className="border border-gray-300 p-2 text-center">{rep.id}</td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={rep.position}
+ onChange={(e) => updateContractorRep(rep.id, 'position', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={rep.name}
+ onChange={(e) => updateContractorRep(rep.id, 'name', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={rep.telNo}
+ onChange={(e) => updateContractorRep(rep.id, 'telNo', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={rep.email}
+ onChange={(e) => updateContractorRep(rep.id, 'email', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ />
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ </div>
+
+ {/* Table 2: The Supplier's Representatives */}
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <h3 className="text-lg font-medium">Table 2: The Supplier &apos;s Representatives</h3>
+ <div className="flex gap-2">
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={addSupplierRow}
+ disabled={isDisabled || !isEnabled}
+ >
+ <Plus className="w-4 h-4 mr-1" />
+ 행 추가
+ </Button>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={removeSupplierRow}
+ disabled={isDisabled || !isEnabled}
+ >
+ <Trash2 className="w-4 h-4 mr-1" />
+ 행 삭제
+ </Button>
+ </div>
+ </div>
+
+ <div className="overflow-x-auto">
+ <table className={`w-full border-collapse border border-gray-300 ${!isEnabled ? 'opacity-50' : ''}`}>
+ <thead>
+ <tr className="bg-yellow-100">
+ <th className="border border-gray-300 p-2 w-12"></th>
+ <th className="border border-gray-300 p-2 w-16">No.</th>
+ <th className="border border-gray-300 p-2">Position</th>
+ <th className="border border-gray-300 p-2">Name</th>
+ <th className="border border-gray-300 p-2">Tel. No.</th>
+ <th className="border border-gray-300 p-2">Email</th>
+ </tr>
+ </thead>
+ <tbody>
+ {supplierReps.map((rep) => (
+ <tr key={rep.id} className="bg-yellow-50">
+ <td className="border border-gray-300 p-2 text-center">
+ <Checkbox
+ checked={rep.isActive}
+ onCheckedChange={(checked) => updateSupplierRep(rep.id, 'isActive', checked as boolean)}
+ disabled={isDisabled || !isEnabled}
+ />
+ </td>
+ <td className="border border-gray-300 p-2 text-center">{rep.id}</td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={rep.position}
+ onChange={(e) => updateSupplierRep(rep.id, 'position', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={rep.name}
+ onChange={(e) => updateSupplierRep(rep.id, 'name', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={rep.telNo}
+ onChange={(e) => updateSupplierRep(rep.id, 'telNo', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={rep.email}
+ onChange={(e) => updateSupplierRep(rep.id, 'email', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ />
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ </div>
+
+ {/* 저장 버튼 */}
+ <div className="flex justify-end pt-4 border-t">
+ <Button
+ onClick={handleSaveCommunicationChannel}
+ disabled={isLoading || isDisabled || !isEnabled}
+ className="flex items-center gap-2"
+ >
+ {isLoading ? (
+ <LoaderIcon className="w-4 h-4 animate-spin" />
+ ) : (
+ <Save className="w-4 h-4" />
+ )}
+ 커뮤니케이션 채널 저장
+ </Button>
+ </div>
+ </div>
+ </AccordionContent>
+ </AccordionItem>
+ </Accordion>
+ </div>
+ )
+}
diff --git a/lib/general-contracts_old/detail/general-contract-detail.tsx b/lib/general-contracts_old/detail/general-contract-detail.tsx
new file mode 100644
index 00000000..8e7a7aff
--- /dev/null
+++ b/lib/general-contracts_old/detail/general-contract-detail.tsx
@@ -0,0 +1,186 @@
+'use client'
+
+import { useState, useEffect } from 'react'
+import { useParams } from 'next/navigation'
+import Link from 'next/link'
+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 './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()
+ const contractId = params?.id ? parseInt(params.id as string) : null
+
+ const [contract, setContract] = useState<Record<string, unknown> | null>(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState<string | null>(null)
+ const [showApprovalDialog, setShowApprovalDialog] = useState(false)
+ const [subcontractChecklistData, setSubcontractChecklistData] = useState<any>(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('계약 정보를 불러오는 중 오류가 발생했습니다.')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ if (contractId && !isNaN(contractId)) {
+ fetchContract()
+ } else {
+ setError('유효하지 않은 계약 ID입니다.')
+ setLoading(false)
+ }
+ }, [contractId])
+
+ if (loading) {
+ return (
+ <div className="container mx-auto py-6 space-y-6">
+ <Skeleton className="h-8 w-64" />
+ <div className="grid gap-6">
+ <div className="grid grid-cols-2 gap-4">
+ <Skeleton className="h-10 w-full" />
+ <Skeleton className="h-10 w-full" />
+ </div>
+ <div className="grid grid-cols-3 gap-4">
+ <Skeleton className="h-10 w-full" />
+ <Skeleton className="h-10 w-full" />
+ <Skeleton className="h-10 w-full" />
+ </div>
+ <Skeleton className="h-32 w-full" />
+ </div>
+ </div>
+ )
+ }
+
+ if (error) {
+ return (
+ <div className="container mx-auto py-6">
+ <Alert variant="destructive">
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ {error}
+ </AlertDescription>
+ </Alert>
+ </div>
+ )
+ }
+
+ return (
+ <div className="container mx-auto py-6 space-y-6">
+
+
+ <div className="flex items-center justify-between">
+ <div>
+ <h1 className="text-3xl font-bold tracking-tight">계약 상세</h1>
+ <p className="text-muted-foreground">
+ 계약번호: {contract?.contractNumber as string} (Rev.{contract?.revision as number})
+ </p>
+ </div>
+ <div className="flex gap-2">
+ {/* 계약승인요청 버튼 */}
+ <Button
+ onClick={() => setShowApprovalDialog(true)}
+ className="bg-blue-600 hover:bg-blue-700"
+ >
+ 계약승인요청
+ </Button>
+ {/* 계약목록으로 돌아가기 버튼 */}
+ <Button asChild variant="outline" size="sm">
+ <Link href="/evcp/general-contracts">
+ <ArrowLeft className="h-4 w-4 mr-2" />
+ 계약목록으로 돌아가기
+ </Link>
+ </Button>
+ </div>
+ </div>
+ {/* 계약 정보 헤더 */}
+ {contract && <GeneralContractInfoHeader contract={contract} />}
+
+ {/* 계약 상세 폼 */}
+ {contract && (
+ <div className="space-y-6">
+ {/* ContractBasicInfo */}
+ <ContractBasicInfo contractId={contract.id as number} />
+ {/* 품목정보 */}
+ {/* {!(contract?.contractScope === '단가' || contract?.contractScope === '물량(실적)') && (
+ <div className="mb-4">
+ <p className="text-sm text-gray-600 mb-2">
+ <strong>품목정보 입력 안내:</strong>
+ <br />
+ 단가/물량 확정 계약의 경우 수량 및 총 계약금액은 별도로 관리됩니다.
+ </p>
+ </div>
+ )} */}
+ <ContractItemsTable
+ contractId={contract.id as number}
+ items={[]}
+ onItemsChange={() => {}}
+ onTotalAmountChange={() => {}}
+ availableBudget={0}
+ readOnly={contract?.contractScope === '단가' || contract?.contractScope === '물량(실적)'}
+ />
+ {/* 하도급법 자율점검 체크리스트 */}
+ <SubcontractChecklist
+ contractId={contract.id as number}
+ onDataChange={(data) => setSubcontractChecklistData(data)}
+ readOnly={false}
+ initialData={subcontractChecklistData}
+ />
+ {/* Communication Channel */}
+ <CommunicationChannel contractId={Number(contract.id)} />
+
+ {/* Location */}
+ <Location contractId={Number(contract.id)} />
+
+ {/* Field Service Rate */}
+ <FieldServiceRate contractId={Number(contract.id)} />
+
+ {/* Offset Details */}
+ <OffsetDetails contractId={Number(contract.id)} />
+ </div>
+ )}
+
+ {/* 계약승인요청 다이얼로그 */}
+ {contract && (
+ <ContractApprovalRequestDialog
+ contract={contract}
+ open={showApprovalDialog}
+ onOpenChange={setShowApprovalDialog}
+ />
+ )}
+ </div>
+ )
+}
diff --git a/lib/general-contracts_old/detail/general-contract-documents.tsx b/lib/general-contracts_old/detail/general-contract-documents.tsx
new file mode 100644
index 00000000..b0f20e7f
--- /dev/null
+++ b/lib/general-contracts_old/detail/general-contract-documents.tsx
@@ -0,0 +1,383 @@
+'use client'
+
+import React, { useState, useEffect } from 'react'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import {
+ Download,
+ Trash2,
+ FileText,
+ LoaderIcon,
+ Paperclip,
+ MessageSquare
+} from 'lucide-react'
+import { toast } from 'sonner'
+import { useTransition } from 'react'
+import {
+ uploadContractAttachment,
+ getContractAttachments,
+ getContractAttachmentForDownload,
+ deleteContractAttachment
+} from '../service'
+import { downloadFile } from '@/lib/file-download'
+
+interface ContractDocument {
+ id: number
+ contractId: number
+ documentName: string
+ fileName: string
+ filePath: string
+ documentType?: string
+ shiComment?: string | null
+ vendorComment?: string | null
+ uploadedAt: Date
+ uploadedById: number
+}
+
+interface ContractDocumentsProps {
+ contractId: number
+ userId: string
+ readOnly?: boolean
+}
+
+export function ContractDocuments({ contractId, userId, readOnly = false }: ContractDocumentsProps) {
+ const [documents, setDocuments] = useState<ContractDocument[]>([])
+ const [isLoading, setIsLoading] = useState(false)
+ const [isPending, startTransition] = useTransition()
+ const [editingComment, setEditingComment] = useState<{ id: number; type: 'shi' | 'vendor' } | null>(null)
+ const [commentText, setCommentText] = useState('')
+ const [selectedDocumentType, setSelectedDocumentType] = useState('')
+
+ const loadDocuments = React.useCallback(async () => {
+ setIsLoading(true)
+ try {
+ const documentList = await getContractAttachments(contractId)
+ setDocuments(documentList as ContractDocument[])
+ } catch (error) {
+ console.error('Error loading documents:', error)
+ toast.error('문서 목록을 불러오는 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }, [contractId])
+
+ useEffect(() => {
+ loadDocuments()
+ }, [loadDocuments])
+
+ const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
+ const file = event.target.files?.[0]
+ if (!file) return
+
+ if (!selectedDocumentType) {
+ toast.error('문서 유형을 선택해주세요.')
+ return
+ }
+
+ 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()
+ // 파일 입력 초기화
+ event.target.value = ''
+ } catch (error) {
+ console.error('Error uploading document:', error)
+ toast.error('문서 업로드 중 오류가 발생했습니다.')
+ }
+ })
+ }
+
+ const handleDownload = async (document: ContractDocument) => {
+ try {
+ const fileData = await getContractAttachmentForDownload(document.id, contractId)
+ downloadFile(fileData.attachment?.filePath || '', fileData.attachment?.fileName || '', {
+ showToast: true
+ })
+ } catch (error) {
+ console.error('Error downloading document:', error)
+ toast.error('문서 다운로드 중 오류가 발생했습니다.')
+ }
+ }
+
+ const handleDelete = async (documentId: number) => {
+
+ startTransition(async () => {
+ try {
+ await deleteContractAttachment(documentId, contractId)
+ toast.success('문서가 삭제되었습니다.')
+ loadDocuments()
+ } catch (error) {
+ console.error('Error deleting document:', error)
+ toast.error('문서 삭제 중 오류가 발생했습니다.')
+ }
+ })
+ }
+
+ const handleEditComment = (documentId: number, type: 'shi' | 'vendor', currentComment?: string) => {
+ setEditingComment({ id: documentId, type })
+ setCommentText(currentComment || '')
+ }
+
+ const handleSaveComment = async () => {
+ if (!editingComment) return
+
+ try {
+ // TODO: API 호출로 댓글 저장
+ toast.success('댓글이 저장되었습니다.')
+ setEditingComment(null)
+ setCommentText('')
+ loadDocuments()
+ } catch (error) {
+ console.error('Error saving comment:', error)
+ toast.error('댓글 저장 중 오류가 발생했습니다.')
+ }
+ }
+
+ const getDocumentTypeLabel = (documentName: string) => {
+ switch (documentName) {
+ case 'specification': return '사양'
+ case 'pricing': return '단가종류'
+ case 'other': return '기타'
+ default: return documentName
+ }
+ }
+
+ const getDocumentTypeColor = (documentName: string) => {
+ switch (documentName) {
+ case 'specification': return 'bg-blue-100 text-blue-800'
+ case 'pricing': return 'bg-green-100 text-green-800'
+ case 'other': return 'bg-gray-100 text-gray-800'
+ default: return 'bg-gray-100 text-gray-800'
+ }
+ }
+
+ const groupedDocuments = documents.reduce((acc, doc) => {
+ const type = doc.documentName
+ if (!acc[type]) {
+ acc[type] = []
+ }
+ acc[type].push(doc)
+ return acc
+ }, {} as Record<string, ContractDocument[]>)
+
+ const documentTypes = [
+ { value: 'specification', label: '사양' },
+ { value: 'pricing', label: '단가종류' },
+ { value: 'other', label: '기타' }
+ ]
+
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Paperclip className="h-5 w-5" />
+ 계약 첨부문서
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ {/* 파일 업로드 */}
+ {!readOnly && (
+ <div className="space-y-4">
+ <div className="flex items-center gap-4">
+ <Select value={selectedDocumentType} onValueChange={setSelectedDocumentType}>
+ <SelectTrigger className="w-40">
+ <SelectValue placeholder="문서 유형" />
+ </SelectTrigger>
+ <SelectContent>
+ {documentTypes.map((type) => (
+ <SelectItem key={type.value} value={type.value}>
+ {type.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <Input
+ type="file"
+ onChange={handleFileUpload}
+ disabled={isPending}
+ className="flex-1"
+ />
+ </div>
+ </div>
+ )}
+
+ {/* 문서 목록 */}
+ {isLoading ? (
+ <div className="flex items-center justify-center py-8">
+ <LoaderIcon className="h-6 w-6 animate-spin" />
+ <span className="ml-2">문서를 불러오는 중...</span>
+ </div>
+ ) : documents.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground">
+ <FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
+ <p>업로드된 문서가 없습니다.</p>
+ </div>
+ ) : (
+ <div className="space-y-6">
+ {Object.entries(groupedDocuments).map(([type, docs]) => (
+ <div key={type} className="space-y-3">
+ <div className="flex items-center gap-2">
+ <Badge className={getDocumentTypeColor(type)}>
+ {getDocumentTypeLabel(type)}
+ </Badge>
+ <span className="text-sm text-muted-foreground">
+ {docs.length}개 문서
+ </span>
+ </div>
+
+ <div className="space-y-3">
+ {docs.map((doc) => (
+ <div key={doc.id} className="border rounded-lg p-4 space-y-3">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-3">
+ <FileText className="h-5 w-5 text-muted-foreground" />
+ <div>
+ <p className="font-medium">{doc.fileName}</p>
+ <p className="text-sm text-muted-foreground">
+ 업로드: {new Date(doc.uploadedAt).toLocaleDateString()}
+ </p>
+ </div>
+ </div>
+
+ {!readOnly && (
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => handleDownload(doc)}
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => handleDelete(doc.id)}
+ className="text-red-600 hover:text-red-700"
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </div>
+ )}
+ </div>
+
+ {/* 댓글 섹션 */}
+ <div className="grid grid-cols-2 gap-4">
+ {/* SHI 댓글 */}
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <Label className="text-sm font-medium">SHI 댓글</Label>
+ {!readOnly && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleEditComment(doc.id, 'shi', doc.shiComment || '')}
+ >
+ <MessageSquare className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ {editingComment?.id === doc.id && editingComment.type === 'shi' ? (
+ <div className="space-y-2">
+ <Textarea
+ value={commentText}
+ onChange={(e) => setCommentText(e.target.value)}
+ placeholder="SHI 댓글을 입력하세요"
+ rows={3}
+ />
+ <div className="flex gap-2">
+ <Button size="sm" onClick={handleSaveComment}>
+ 저장
+ </Button>
+ <Button
+ size="sm"
+ variant="outline"
+ onClick={() => setEditingComment(null)}
+ >
+ 취소
+ </Button>
+ </div>
+ </div>
+ ) : (
+ <div className="min-h-[60px] p-3 bg-gray-50 rounded border">
+ {doc.shiComment ? (
+ <p className="text-sm">{doc.shiComment}</p>
+ ) : (
+ <p className="text-sm text-muted-foreground">댓글이 없습니다.</p>
+ )}
+ </div>
+ )}
+ </div>
+
+ {/* Vendor 댓글 */}
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <Label className="text-sm font-medium">Vendor 댓글</Label>
+ {!readOnly && (
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => handleEditComment(doc.id, 'vendor', doc.vendorComment || '')}
+ >
+ <MessageSquare className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ {editingComment?.id === doc.id && editingComment.type === 'vendor' ? (
+ <div className="space-y-2">
+ <Textarea
+ value={commentText}
+ onChange={(e) => setCommentText(e.target.value)}
+ placeholder="Vendor 댓글을 입력하세요"
+ rows={3}
+ />
+ <div className="flex gap-2">
+ <Button size="sm" onClick={handleSaveComment}>
+ 저장
+ </Button>
+ <Button
+ size="sm"
+ variant="outline"
+ onClick={() => setEditingComment(null)}
+ >
+ 취소
+ </Button>
+ </div>
+ </div>
+ ) : (
+ <div className="min-h-[60px] p-3 bg-gray-50 rounded border">
+ {doc.vendorComment ? (
+ <p className="text-sm">{doc.vendorComment}</p>
+ ) : (
+ <p className="text-sm text-muted-foreground">댓글이 없습니다.</p>
+ )}
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ )
+}
diff --git a/lib/general-contracts_old/detail/general-contract-field-service-rate.tsx b/lib/general-contracts_old/detail/general-contract-field-service-rate.tsx
new file mode 100644
index 00000000..a8158307
--- /dev/null
+++ b/lib/general-contracts_old/detail/general-contract-field-service-rate.tsx
@@ -0,0 +1,288 @@
+'use client'
+
+import React, { useState, useEffect } from 'react'
+import { useSession } from 'next-auth/react'
+import { Input } from '@/components/ui/input'
+import { Button } from '@/components/ui/button'
+import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Plus, Trash2, Save, LoaderIcon, DollarSign } from 'lucide-react'
+import { getFieldServiceRate, updateFieldServiceRate } from '../service'
+import { toast } from 'sonner'
+
+interface FieldServiceRateProps {
+ contractId: number
+ contractType?: string
+}
+
+interface FieldServiceRateItem {
+ id: string
+ levelOfSpecialist: string
+ description: string
+ rateCurrency: string
+ onshoreRate: string
+ offshoreRate: string
+ rateUnit: string
+ remark: string
+}
+
+export function FieldServiceRate({ contractId }: FieldServiceRateProps) {
+ const session = useSession()
+ const [isLoading, setIsLoading] = useState(false)
+ const [isEnabled, setIsEnabled] = useState(true)
+
+ // 특정 계약종류를 제외한 일반계약은 Default로 표시
+ const isDisabled = false
+
+ const [fieldServiceRates, setFieldServiceRates] = useState<FieldServiceRateItem[]>([])
+
+ // 초기 데이터 로드
+ useEffect(() => {
+ const loadFieldServiceRate = async () => {
+ try {
+ const data = await getFieldServiceRate(contractId)
+ if (data && data.enabled !== undefined) {
+ setIsEnabled(data.enabled)
+ setFieldServiceRates(data.fieldServiceRates || [])
+ } else {
+ }
+ } catch (error) {
+ console.error('Field Service Rate 데이터 로드 실패:', error)
+ toast.error('Field Service Rate 데이터를 불러오는데 실패했습니다.')
+ }
+ }
+
+ loadFieldServiceRate()
+ }, [contractId])
+
+ const addFieldServiceRateRow = () => {
+ const newRow: FieldServiceRateItem = {
+ id: Date.now().toString(),
+ levelOfSpecialist: '',
+ description: '',
+ rateCurrency: 'USD',
+ onshoreRate: '',
+ offshoreRate: '',
+ rateUnit: 'day',
+ remark: ''
+ }
+ setFieldServiceRates([...fieldServiceRates, newRow])
+ }
+
+ const removeFieldServiceRateRow = (id: string) => {
+ setFieldServiceRates(fieldServiceRates.filter(item => item.id !== id))
+ }
+
+ const updateFieldServiceRateData = (id: string, field: keyof FieldServiceRateItem, value: string) => {
+ setFieldServiceRates(prev =>
+ prev.map(item =>
+ item.id === id ? { ...item, [field]: value } : item
+ )
+ )
+ }
+
+ const handleSaveFieldServiceRate = async () => {
+ if (!session.data?.user?.id) {
+ toast.error('로그인이 필요합니다.')
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ const fieldServiceRateData = {
+ enabled: isEnabled,
+ fieldServiceRates: fieldServiceRates
+ }
+
+ await updateFieldServiceRate(contractId, fieldServiceRateData, Number(session.data.user.id))
+ toast.success('Field Service Rate가 성공적으로 저장되었습니다.')
+ } catch (error) {
+ console.error('Field Service Rate 저장 실패:', error)
+ toast.error('Field Service Rate 저장에 실패했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return (
+ <div className="w-full">
+ <Accordion type="single" collapsible className="w-full">
+ <AccordionItem value="field-service-rate">
+ <AccordionTrigger className="hover:no-underline">
+ <div className="flex items-center gap-3 w-full">
+ <DollarSign className="w-5 h-5" />
+ <span className="font-medium">Field Service Rate</span>
+ </div>
+ </AccordionTrigger>
+ <AccordionContent>
+ <div className="space-y-6">
+ {/* 체크박스 */}
+ <div className="flex items-center gap-2">
+ <Checkbox
+ checked={isEnabled}
+ disabled={isDisabled}
+ onCheckedChange={(checked) => {
+ if (!isDisabled) {
+ setIsEnabled(checked as boolean)
+ }
+ }}
+ />
+ <span className="text-sm font-medium">Field Service Rate 활성화</span>
+ </div>
+
+ {/* Field Service Rate 테이블 */}
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <h3 className="text-lg font-medium">Field Service Rate</h3>
+ <div className="flex gap-2">
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={addFieldServiceRateRow}
+ disabled={isDisabled || !isEnabled}
+ className="flex items-center gap-2"
+ >
+ <Plus className="w-4 h-4" />
+ 행 추가
+ </Button>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ if (confirm('선택된 행들을 삭제하시겠습니까?')) {
+ // 선택된 행들 삭제 로직 (필요시 구현)
+ }
+ }}
+ disabled={isDisabled || !isEnabled}
+ className="flex items-center gap-2"
+ >
+ <Trash2 className="w-4 h-4" />
+ 행 삭제
+ </Button>
+ </div>
+ </div>
+
+ <div className="overflow-x-auto">
+ <table className={`w-full border-collapse border border-gray-300 ${!isEnabled ? 'opacity-50' : ''}`}>
+ <thead>
+ <tr className="bg-yellow-100">
+ <th className="border border-gray-300 p-2 w-16">No.</th>
+ <th className="border border-gray-300 p-2 w-40">Level of Specialist</th>
+ <th className="border border-gray-300 p-2">Description</th>
+ <th className="border border-gray-300 p-2 w-32">Rate Currency</th>
+ <th className="border border-gray-300 p-2 w-32">Onshore Rate</th>
+ <th className="border border-gray-300 p-2 w-32">Offshore Rate</th>
+ <th className="border border-gray-300 p-2 w-24">Rate Unit</th>
+ <th className="border border-gray-300 p-2 w-32">Remark</th>
+ <th className="border border-gray-300 p-2 w-20">Action</th>
+ </tr>
+ </thead>
+ <tbody>
+ {fieldServiceRates.map((item, index) => (
+ <tr key={item.id} className="bg-yellow-50">
+ <td className="border border-gray-300 p-2 text-center">{index + 1}</td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={item.levelOfSpecialist}
+ onChange={(e) => updateFieldServiceRateData(item.id, 'levelOfSpecialist', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={item.description}
+ onChange={(e) => updateFieldServiceRateData(item.id, 'description', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={item.rateCurrency}
+ onChange={(e) => updateFieldServiceRateData(item.id, 'rateCurrency', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={item.onshoreRate}
+ onChange={(e) => updateFieldServiceRateData(item.id, 'onshoreRate', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={item.offshoreRate}
+ onChange={(e) => updateFieldServiceRateData(item.id, 'offshoreRate', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={item.rateUnit}
+ onChange={(e) => updateFieldServiceRateData(item.id, 'rateUnit', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={item.remark}
+ onChange={(e) => updateFieldServiceRateData(item.id, 'remark', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ />
+ </td>
+ <td className="border border-gray-300 p-2 text-center">
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeFieldServiceRateRow(item.id)}
+ disabled={isDisabled || !isEnabled}
+ className="h-8 w-8 p-0 text-red-600 hover:text-red-700"
+ >
+ <Trash2 className="w-4 h-4" />
+ </Button>
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+
+ {/* Note 정보 */}
+ <div className="space-y-2 text-sm text-gray-600">
+ <p><strong>Note #1:</strong> Air fare, travelling costs and hours, Visa, training, medical test and any additional applications are included.</p>
+ <p><strong>Note #2:</strong> Accommodation, meal and local transportation are included.</p>
+ </div>
+ </div>
+
+ {/* 저장 버튼 */}
+ <div className="flex justify-end pt-4 border-t">
+ <Button
+ onClick={handleSaveFieldServiceRate}
+ disabled={isLoading || isDisabled || !isEnabled}
+ className="flex items-center gap-2"
+ >
+ {isLoading ? (
+ <LoaderIcon className="w-4 h-4 animate-spin" />
+ ) : (
+ <Save className="w-4 h-4" />
+ )}
+ Field Service Rate 저장
+ </Button>
+ </div>
+ </div>
+ </AccordionContent>
+ </AccordionItem>
+ </Accordion>
+ </div>
+ )
+}
diff --git a/lib/general-contracts_old/detail/general-contract-info-header.tsx b/lib/general-contracts_old/detail/general-contract-info-header.tsx
new file mode 100644
index 00000000..9be9840d
--- /dev/null
+++ b/lib/general-contracts_old/detail/general-contract-info-header.tsx
@@ -0,0 +1,211 @@
+import { Building2, Package, DollarSign, Calendar, FileText } from 'lucide-react'
+import { formatDate } from '@/lib/utils'
+
+interface GeneralContractInfoHeaderProps {
+ contract: {
+ id: number
+ contractNumber: string
+ revision: number
+ status: string
+ category: string
+ type: string
+ name: string
+ vendorName?: string
+ vendorCode?: string
+ startDate: string
+ endDate: string
+ validityEndDate: string
+ contractAmount?: string
+ currency?: string
+ registeredAt: string
+ signedAt?: string
+ linkedRfqOrItb?: string
+ linkedBidNumber?: string
+ linkedPoNumber?: string
+ }
+}
+
+const statusLabels = {
+ 'Draft': '임시저장',
+ 'Request to Review': '조건검토요청',
+ 'Confirm to Review': '조건검토완료',
+ 'Contract Accept Request': '계약승인요청',
+ 'Complete the Contract': '계약체결',
+ 'Reject to Accept Contract': '계약승인거절',
+ 'Contract Delete': '계약폐기',
+ 'PCR Request': 'PCR요청',
+ 'VO Request': 'VO요청',
+ 'PCR Accept': 'PCR승인',
+ 'PCR Reject': 'PCR거절'
+}
+
+const categoryLabels = {
+ '단가계약': '단가계약',
+ '일반계약': '일반계약',
+ '매각계약': '매각계약'
+}
+
+const typeLabels = {
+ 'UP': '자재단가계약',
+ 'LE': '임대차계약',
+ 'IL': '개별운송계약',
+ 'AL': '연간운송계약',
+ 'OS': '외주용역계약',
+ 'OW': '도급계약',
+ 'IS': '검사계약',
+ 'LO': 'LOI',
+ 'FA': 'FA',
+ 'SC': '납품합의계약',
+ 'OF': '클레임상계계약',
+ 'AW': '사전작업합의',
+ 'AD': '사전납품합의',
+ 'AM': '설계계약',
+ 'SC_SELL': '폐기물매각계약'
+}
+
+export function GeneralContractInfoHeader({ contract }: GeneralContractInfoHeaderProps) {
+ return (
+ <div className="bg-white border rounded-lg p-6 mb-6 shadow-sm">
+ {/* 3개 섹션을 Grid로 배치 - 각 섹션이 동일한 width로 꽉 채움 */}
+ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
+ {/* 왼쪽 섹션: 계약 기본 정보 */}
+ <div className="w-full space-y-4">
+ {/* 계약번호 */}
+ <div className="mb-4">
+ <div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
+ <FileText className="w-4 h-4" />
+ <span>계약번호 (Rev.)</span>
+ </div>
+ <div className="font-mono font-medium text-gray-900">
+ {contract.contractNumber} (Rev.{contract.revision})
+ </div>
+ </div>
+
+ {/* 계약명 */}
+ <div className="mb-4">
+ <div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
+ <Package className="w-4 h-4" />
+ <span>계약명</span>
+ </div>
+ <div className="font-medium text-gray-900">{contract.name}</div>
+ </div>
+
+ {/* 협력업체 */}
+ <div className="mb-4">
+ <div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
+ <Building2 className="w-4 h-4" />
+ <span>협력업체</span>
+ </div>
+ <div className="font-medium text-gray-900">
+ {contract.vendorName || '협력업체 미선택'}
+ {contract.vendorCode && (
+ <span className="text-sm text-gray-500 ml-2">({contract.vendorCode})</span>
+ )}
+ </div>
+ </div>
+
+
+ {/* 계약금액 */}
+ {contract.contractAmount && (
+ <div className="mb-4">
+ <div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
+ <DollarSign className="w-4 h-4" />
+ <span>계약금액</span>
+ </div>
+ <div className="font-semibold text-gray-900">
+ {new Intl.NumberFormat('ko-KR', {
+ style: 'currency',
+ currency: contract.currency || 'KRW',
+ }).format(Number(contract.contractAmount))}
+ </div>
+ </div>
+ )}
+ </div>
+
+ {/* 가운데 섹션: 계약 분류 정보 */}
+ <div className="w-full border-l border-gray-100 pl-6">
+ <div className="space-y-3">
+ <div className="flex flex-col gap-1">
+ <span className="text-gray-500 text-sm">계약상태</span>
+ <span className="font-medium">{statusLabels[contract.status as keyof typeof statusLabels] || contract.status}</span>
+ </div>
+
+ <div className="flex flex-col gap-1">
+ <span className="text-gray-500 text-sm">계약구분</span>
+ <span className="font-medium">{categoryLabels[contract.category as keyof typeof categoryLabels] || contract.category}</span>
+ </div>
+
+ <div className="flex flex-col gap-1">
+ <span className="text-gray-500 text-sm">계약종류</span>
+ <span className="font-medium">{typeLabels[contract.type as keyof typeof typeLabels] || contract.type}</span>
+ </div>
+
+ <div className="flex flex-col gap-1">
+ <span className="text-gray-500 text-sm">통화</span>
+ <span className="font-mono font-medium">{contract.currency || 'KRW'}</span>
+ </div>
+ </div>
+ </div>
+
+ {/* 오른쪽 섹션: 일정 및 연계 정보 */}
+ <div className="w-full border-l border-gray-100 pl-6">
+ <div className="flex items-center gap-2 mb-3 text-sm text-gray-500">
+ <Calendar className="w-4 h-4" />
+ <span>일정 및 연계 정보</span>
+ </div>
+ <div className="space-y-3">
+ <div>
+ <span className="text-gray-500 text-sm">계약기간</span>
+ <div className="font-medium">
+ {formatDate(contract.startDate, 'KR')} ~ {formatDate(contract.endDate, 'KR')}
+ </div>
+ </div>
+
+ <div>
+ <span className="text-gray-500 text-sm">계약 유효기간</span>
+ <div className="font-medium">{formatDate(contract.validityEndDate, 'KR')}</div>
+ </div>
+
+ {contract.signedAt && (
+ <div>
+ <span className="text-gray-500 text-sm">계약체결일</span>
+ <div className="font-medium">{formatDate(contract.signedAt, 'KR')}</div>
+ </div>
+ )}
+
+ {contract.registeredAt && (
+ <div>
+ <span className="text-gray-500 text-sm">등록일</span>
+ <div className="font-medium">{formatDate(contract.registeredAt, 'KR')}</div>
+ </div>
+ )}
+
+ {(contract.linkedRfqOrItb || contract.linkedBidNumber || contract.linkedPoNumber) && (
+ <div className="space-y-2">
+ <span className="text-gray-500 text-sm font-medium">연계 정보</span>
+ {contract.linkedRfqOrItb && (
+ <div>
+ <span className="text-gray-500 text-xs">연계 견적/입찰번호</span>
+ <div className="font-medium text-sm">{contract.linkedRfqOrItb}</div>
+ </div>
+ )}
+ {contract.linkedBidNumber && (
+ <div>
+ <span className="text-gray-500 text-xs">연계 BID번호</span>
+ <div className="font-medium text-sm">{contract.linkedBidNumber}</div>
+ </div>
+ )}
+ {contract.linkedPoNumber && (
+ <div>
+ <span className="text-gray-500 text-xs">연계 PO번호</span>
+ <div className="font-medium text-sm">{contract.linkedPoNumber}</div>
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+ </div>
+ )
+}
diff --git a/lib/general-contracts_old/detail/general-contract-items-table.tsx b/lib/general-contracts_old/detail/general-contract-items-table.tsx
new file mode 100644
index 00000000..1b9a1a06
--- /dev/null
+++ b/lib/general-contracts_old/detail/general-contract-items-table.tsx
@@ -0,0 +1,602 @@
+'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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+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
+ itemCode: string
+ itemInfo: string
+ specification: string
+ quantity: number
+ quantityUnit: string
+ totalWeight: number
+ weightUnit: 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
+ availableBudget?: number
+ readOnly?: boolean
+}
+
+// 통화 목록
+const CURRENCIES = ["USD", "EUR", "KRW", "JPY", "CNY"];
+
+// 수량 단위 목록
+const QUANTITY_UNITS = ["KG", "TON", "EA", "M", "M2", "M3", "L", "ML", "G", "SET", "PCS"];
+
+// 중량 단위 목록
+const WEIGHT_UNITS = ["KG", "TON", "G", "LB", "OZ"];
+
+export function ContractItemsTable({
+ contractId,
+ items,
+ onItemsChange,
+ onTotalAmountChange,
+ availableBudget = 0,
+ readOnly = false
+}: ContractItemsTableProps) {
+ const [localItems, setLocalItems] = React.useState<ContractItem[]>(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,
+ itemCode: item.itemCode || '',
+ itemInfo: item.itemInfo || '',
+ specification: item.specification || '',
+ quantity: Number(item.quantity) || 0,
+ quantityUnit: item.quantityUnit || 'EA',
+ totalWeight: Number(item.totalWeight) || 0,
+ weightUnit: item.weightUnit || 'KG',
+ contractDeliveryDate: item.contractDeliveryDate || '',
+ contractUnitPrice: Number(item.contractUnitPrice) || 0,
+ contractAmount: Number(item.contractAmount) || 0,
+ contractCurrency: item.contractCurrency || 'KRW',
+ 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, 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.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.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 = {
+ itemCode: '',
+ itemInfo: '',
+ specification: '',
+ quantity: 0,
+ quantityUnit: 'EA', // 기본 수량 단위
+ totalWeight: 0,
+ weightUnit: 'KG', // 기본 중량 단위
+ contractDeliveryDate: '',
+ contractUnitPrice: 0,
+ contractAmount: 0,
+ contractCurrency: 'KRW', // 기본 통화
+ 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, currency: string = 'KRW') => {
+ 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 (
+ <Accordion type="single" collapsible className="w-full">
+ <AccordionItem value="items">
+ <AccordionTrigger className="hover:no-underline">
+ <div className="flex items-center gap-2">
+ <Package className="w-5 h-5" />
+ <span>품목 정보</span>
+ <span className="text-sm text-gray-500">(로딩 중...)</span>
+ </div>
+ </AccordionTrigger>
+ <AccordionContent>
+ <div className="flex items-center justify-center py-8">
+ <LoaderIcon className="w-6 h-6 animate-spin mr-2" />
+ <span>품목 정보를 불러오는 중...</span>
+ </div>
+ </AccordionContent>
+ </AccordionItem>
+ </Accordion>
+ )
+ }
+
+ return (
+ <Accordion type="single" collapsible className="w-full">
+ <AccordionItem value="items">
+ <AccordionTrigger className="hover:no-underline">
+ <div className="flex items-center gap-3 w-full">
+ <Package className="w-5 h-5" />
+ <span className="font-medium">품목 정보</span>
+ <span className="text-sm text-gray-500">({localItems.length}개 품목)</span>
+ </div>
+ </AccordionTrigger>
+ <AccordionContent>
+ <Card>
+ <CardHeader>
+ {/* 체크박스 */}
+ <div className="flex items-center gap-2 mb-4">
+ <Checkbox
+ checked={isEnabled}
+ onCheckedChange={(checked) => setIsEnabled(checked as boolean)}
+ disabled={readOnly}
+ />
+ <span className="text-sm font-medium">품목 정보 활성화</span>
+ </div>
+
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <span className="text-sm text-gray-600">총 금액: {formatCurrency(totalAmount, localItems[0]?.contractCurrency || 'KRW')}</span>
+ <span className="text-sm text-gray-600">총 수량: {totalQuantity.toLocaleString()}</span>
+ </div>
+ {!readOnly && (
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={addRow}
+ disabled={!isEnabled}
+ className="flex items-center gap-2"
+ >
+ <Plus className="w-4 h-4" />
+ 행 추가
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={deleteSelectedRows}
+ disabled={!isEnabled}
+ className="flex items-center gap-2 text-red-600 hover:text-red-700"
+ >
+ <Trash2 className="w-4 h-4" />
+ 행 삭제
+ </Button>
+ <Button
+ onClick={handleSaveItems}
+ disabled={isSaving || !isEnabled}
+ className="flex items-center gap-2"
+ >
+ {isSaving ? (
+ <LoaderIcon className="w-4 h-4 animate-spin" />
+ ) : (
+ <Save className="w-4 h-4" />
+ )}
+ 품목정보 저장
+ </Button>
+ </div>
+ )}
+ </div>
+
+ {/* 요약 정보 */}
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
+ <div className="space-y-1">
+ <Label className="text-sm font-medium">총 계약금액</Label>
+ <div className="text-lg font-bold text-primary">
+ {formatCurrency(totalAmount, localItems[0]?.contractCurrency || 'KRW')}
+ </div>
+ </div>
+ <div className="space-y-1">
+ <Label className="text-sm font-medium">가용예산</Label>
+ <div className="text-lg font-bold">
+ {formatCurrency(availableBudget, localItems[0]?.contractCurrency || 'KRW')}
+ </div>
+ </div>
+ <div className="space-y-1">
+ <Label className="text-sm font-medium">가용예산 比 (금액차)</Label>
+ <div className={`text-lg font-bold ${amountDifference >= 0 ? 'text-green-600' : 'text-red-600'}`}>
+ {formatCurrency(amountDifference, localItems[0]?.contractCurrency || 'KRW')}
+ </div>
+ </div>
+ <div className="space-y-1">
+ <Label className="text-sm font-medium">가용예산 比 (비율)</Label>
+ <div className={`text-lg font-bold ${budgetRatio <= 100 ? 'text-green-600' : 'text-red-600'}`}>
+ {budgetRatio.toFixed(1)}%
+ </div>
+ </div>
+ </div>
+ </CardHeader>
+
+ <CardContent>
+ <div className="overflow-x-auto">
+ <Table>
+ <TableHeader>
+ <TableRow className="border-b-2">
+ <TableHead className="w-12 px-2">
+ {!readOnly && (
+ <Checkbox
+ checked={allSelected}
+ ref={(el) => {
+ if (el) (el as HTMLInputElement & { indeterminate?: boolean }).indeterminate = someSelected && !allSelected
+ }}
+ onCheckedChange={toggleSelectAll}
+ disabled={!isEnabled}
+ />
+ )}
+ </TableHead>
+ <TableHead className="px-3 py-3 font-semibold">품목코드 (PKG No.)</TableHead>
+ <TableHead className="px-3 py-3 font-semibold">Item 정보 (자재그룹 / 자재코드)</TableHead>
+ <TableHead className="px-3 py-3 font-semibold">규격</TableHead>
+ <TableHead className="px-3 py-3 font-semibold text-right">수량</TableHead>
+ <TableHead className="px-3 py-3 font-semibold">수량단위</TableHead>
+ <TableHead className="px-3 py-3 font-semibold text-right">총 중량</TableHead>
+ <TableHead className="px-3 py-3 font-semibold">중량단위</TableHead>
+ <TableHead className="px-3 py-3 font-semibold">계약납기일</TableHead>
+ <TableHead className="px-3 py-3 font-semibold text-right">계약단가</TableHead>
+ <TableHead className="px-3 py-3 font-semibold text-right">계약금액</TableHead>
+ <TableHead className="px-3 py-3 font-semibold">계약통화</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {localItems.map((item, index) => (
+ <TableRow key={index} className="hover:bg-muted/30 transition-colors">
+ <TableCell className="px-2">
+ {!readOnly && (
+ <Checkbox
+ checked={item.isSelected || false}
+ onCheckedChange={(checked) =>
+ updateItem(index, 'isSelected', checked)
+ }
+ disabled={!isEnabled}
+ />
+ )}
+ </TableCell>
+ <TableCell className="px-3 py-3">
+ {readOnly ? (
+ <span className="text-sm">{item.itemCode || '-'}</span>
+ ) : (
+ <Input
+ value={item.itemCode}
+ onChange={(e) => updateItem(index, 'itemCode', e.target.value)}
+ placeholder="품목코드"
+ className="h-8 text-sm"
+ disabled={!isEnabled}
+ />
+ )}
+ </TableCell>
+ <TableCell className="px-3 py-3">
+ {readOnly ? (
+ <span className="text-sm">{item.itemInfo || '-'}</span>
+ ) : (
+ <Input
+ value={item.itemInfo}
+ onChange={(e) => updateItem(index, 'itemInfo', e.target.value)}
+ placeholder="Item 정보"
+ className="h-8 text-sm"
+ disabled={!isEnabled}
+ />
+ )}
+ </TableCell>
+ <TableCell className="px-3 py-3">
+ {readOnly ? (
+ <span className="text-sm">{item.specification || '-'}</span>
+ ) : (
+ <Input
+ value={item.specification}
+ onChange={(e) => updateItem(index, 'specification', e.target.value)}
+ placeholder="규격"
+ className="h-8 text-sm"
+ disabled={!isEnabled}
+ />
+ )}
+ </TableCell>
+ <TableCell className="px-3 py-3">
+ {readOnly ? (
+ <span className="text-sm text-right">{item.quantity.toLocaleString()}</span>
+ ) : (
+ <Input
+ type="number"
+ value={item.quantity}
+ onChange={(e) => updateItem(index, 'quantity', parseFloat(e.target.value) || 0)}
+ className="h-8 text-sm text-right"
+ placeholder="0"
+ disabled={!isEnabled}
+ />
+ )}
+ </TableCell>
+ <TableCell className="px-3 py-3">
+ {readOnly ? (
+ <span className="text-sm">{item.quantityUnit || '-'}</span>
+ ) : (
+ <Select
+ value={item.quantityUnit}
+ onValueChange={(value) => updateItem(index, 'quantityUnit', value)}
+ disabled={!isEnabled}
+ >
+ <SelectTrigger className="h-8 text-sm w-20">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {QUANTITY_UNITS.map((unit) => (
+ <SelectItem key={unit} value={unit}>
+ {unit}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ )}
+ </TableCell>
+ <TableCell className="px-3 py-3">
+ {readOnly ? (
+ <span className="text-sm text-right">{item.totalWeight.toLocaleString()}</span>
+ ) : (
+ <Input
+ type="number"
+ value={item.totalWeight}
+ onChange={(e) => updateItem(index, 'totalWeight', parseFloat(e.target.value) || 0)}
+ className="h-8 text-sm text-right"
+ placeholder="0"
+ disabled={!isEnabled}
+ />
+ )}
+ </TableCell>
+ <TableCell className="px-3 py-3">
+ {readOnly ? (
+ <span className="text-sm">{item.weightUnit || '-'}</span>
+ ) : (
+ <Select
+ value={item.weightUnit}
+ onValueChange={(value) => updateItem(index, 'weightUnit', value)}
+ disabled={!isEnabled}
+ >
+ <SelectTrigger className="h-8 text-sm w-20">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {WEIGHT_UNITS.map((unit) => (
+ <SelectItem key={unit} value={unit}>
+ {unit}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ )}
+ </TableCell>
+ <TableCell className="px-3 py-3">
+ {readOnly ? (
+ <span className="text-sm">{item.contractDeliveryDate || '-'}</span>
+ ) : (
+ <Input
+ type="date"
+ value={item.contractDeliveryDate}
+ onChange={(e) => updateItem(index, 'contractDeliveryDate', e.target.value)}
+ className="h-8 text-sm"
+ disabled={!isEnabled}
+ />
+ )}
+ </TableCell>
+ <TableCell className="px-3 py-3">
+ {readOnly ? (
+ <span className="text-sm text-right">{item.contractUnitPrice.toLocaleString()}</span>
+ ) : (
+ <Input
+ type="number"
+ value={item.contractUnitPrice}
+ onChange={(e) => updateItem(index, 'contractUnitPrice', parseFloat(e.target.value) || 0)}
+ className="h-8 text-sm text-right"
+ placeholder="0"
+ disabled={!isEnabled}
+ />
+ )}
+ </TableCell>
+ <TableCell className="px-3 py-3">
+ <div className="font-semibold text-primary text-right text-sm">
+ {formatCurrency(item.contractAmount)}
+ </div>
+ </TableCell>
+ <TableCell className="px-3 py-3">
+ {readOnly ? (
+ <span className="text-sm">{item.contractCurrency || '-'}</span>
+ ) : (
+ <Select
+ value={item.contractCurrency}
+ onValueChange={(value) => updateItem(index, 'contractCurrency', value)}
+ disabled={!isEnabled}
+ >
+ <SelectTrigger className="h-8 text-sm w-20">
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {CURRENCIES.map((currency) => (
+ <SelectItem key={currency} value={currency}>
+ {currency}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ )}
+ </TableCell>
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </div>
+
+ {/* 합계 정보 */}
+ {localItems.length > 0 && (
+ <div className="mt-6 flex justify-end">
+ <Card className="w-80 bg-gradient-to-r from-primary/5 to-primary/10 border-primary/20">
+ <CardContent className="p-6">
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <span className="text-sm font-medium text-muted-foreground">총 수량</span>
+ <span className="text-lg font-semibold">
+ {totalQuantity.toLocaleString()} {localItems[0]?.quantityUnit || 'KG'}
+ </span>
+ </div>
+ <div className="flex items-center justify-between">
+ <span className="text-sm font-medium text-muted-foreground">총 단가</span>
+ <span className="text-lg font-semibold">
+ {formatCurrency(totalUnitPrice, localItems[0]?.contractCurrency || 'KRW')}
+ </span>
+ </div>
+ <div className="border-t pt-4">
+ <div className="flex items-center justify-between">
+ <span className="text-xl font-bold text-primary">합계 금액</span>
+ <span className="text-2xl font-bold text-primary">
+ {formatCurrency(totalAmount, localItems[0]?.contractCurrency || 'KRW')}
+ </span>
+ </div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ </AccordionContent>
+ </AccordionItem>
+ </Accordion>
+ )
+}
diff --git a/lib/general-contracts_old/detail/general-contract-location.tsx b/lib/general-contracts_old/detail/general-contract-location.tsx
new file mode 100644
index 00000000..5b388895
--- /dev/null
+++ b/lib/general-contracts_old/detail/general-contract-location.tsx
@@ -0,0 +1,480 @@
+'use client'
+
+import React, { useState, useEffect } from 'react'
+import { useSession } from 'next-auth/react'
+import { Input } from '@/components/ui/input'
+import { Button } from '@/components/ui/button'
+import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Save, LoaderIcon, MapPin } from 'lucide-react'
+import { updateLocation, getLocation } from '../service'
+import { toast } from 'sonner'
+
+interface LocationProps {
+ contractType?: string
+ contractId: number
+}
+
+interface LocationData {
+ country: {
+ projectManager: string
+ engineering: string
+ procurement: string
+ fabrication: string
+ assembly: string
+ test: string
+ shippingExw: string
+ shippingFob: string
+ remark: string
+ }
+ location: {
+ projectManager: string
+ engineering: string
+ procurement: string
+ fabrication: string
+ assembly: string
+ test: string
+ shippingExw: string
+ shippingFob: string
+ remark: string
+ }
+ subContractor: {
+ projectManager: string
+ engineering: string
+ procurement: string
+ fabrication: string
+ assembly: string
+ test: string
+ shippingExw: string
+ shippingFob: string
+ remark: string
+ }
+}
+
+export function Location({ contractId }: LocationProps) {
+ const session = useSession()
+ const [isLoading, setIsLoading] = useState(false)
+ const [isEnabled, setIsEnabled] = useState(true)
+ const [locationData, setLocationData] = useState<LocationData>({
+ country: {
+ projectManager: '',
+ engineering: '',
+ procurement: '',
+ fabrication: '',
+ assembly: '',
+ test: '',
+ shippingExw: '',
+ shippingFob: '',
+ remark: ''
+ },
+ location: {
+ projectManager: '',
+ engineering: '',
+ procurement: '',
+ fabrication: '',
+ assembly: '',
+ test: '',
+ shippingExw: '',
+ shippingFob: '',
+ remark: ''
+ },
+ subContractor: {
+ projectManager: '',
+ engineering: '',
+ procurement: '',
+ fabrication: '',
+ assembly: '',
+ test: '',
+ shippingExw: '',
+ shippingFob: '',
+ remark: ''
+ }
+ })
+
+ // 특정 계약종류를 제외한 일반계약은 Default로 표시
+ const isDisabled = false // 일단 모든 계약종류에서 활성화
+
+ // 초기 데이터 로드
+ useEffect(() => {
+ const loadLocationData = async () => {
+ try {
+ const data = await getLocation(contractId)
+ if (data && data.locations) {
+ setLocationData(data.locations)
+ setIsEnabled(data.enabled || true)
+ } else {
+ // 기본 데이터는 이미 useState에서 설정됨
+ }
+ } catch (error) {
+ console.error('Error loading location data:', error)
+ // 기본 데이터는 이미 useState에서 설정됨
+ }
+ }
+
+ loadLocationData()
+ }, [contractId])
+
+ const updateLocationData = (rowType: keyof LocationData, field: keyof LocationData['country'], value: string) => {
+ setLocationData(prev => ({
+ ...prev,
+ [rowType]: {
+ ...prev[rowType],
+ [field]: value
+ }
+ }))
+ }
+
+ const handleSaveLocation = async () => {
+ const userId = session.data?.user?.id ? Number(session.data.user.id) : null
+
+ if (!userId) {
+ toast.error('사용자 정보를 찾을 수 없습니다.')
+ return
+ }
+
+ try {
+ setIsLoading(true)
+
+ const locationDataToSave = {
+ enabled: isEnabled,
+ locations: locationData
+ }
+
+ await updateLocation(contractId, locationDataToSave, userId)
+ toast.success('Location 정보가 저장되었습니다.')
+ } catch (error) {
+ console.error('Error saving location:', error)
+ toast.error('Location 정보 저장에 실패했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return (
+ <div className="w-full">
+ <Accordion type="single" collapsible className="w-full">
+ <AccordionItem value="location">
+ <AccordionTrigger className="hover:no-underline">
+ <div className="flex items-center gap-3 w-full">
+ <MapPin className="w-5 h-5" />
+ <span className="font-medium">Location</span>
+ </div>
+ </AccordionTrigger>
+ <AccordionContent>
+ <div className="space-y-6">
+ {/* 체크박스 */}
+ <div className="flex items-center gap-2">
+ <Checkbox
+ checked={isEnabled}
+ disabled={isDisabled}
+ onCheckedChange={(checked) => {
+ if (!isDisabled) {
+ setIsEnabled(checked as boolean)
+ }
+ }}
+ />
+ <span className="text-sm font-medium">Location 활성화</span>
+ </div>
+
+ {/* Location 테이블 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-medium">Location</h3>
+
+ <div className="overflow-x-auto">
+ <table className={`w-full border-collapse border border-gray-300 ${!isEnabled ? 'opacity-50' : ''}`}>
+ <thead>
+ <tr className="bg-yellow-100">
+ <th className="border border-gray-300 p-2">Activity</th>
+ <th className="border border-gray-300 p-2">Project Manager</th>
+ <th className="border border-gray-300 p-2">Engineering</th>
+ <th className="border border-gray-300 p-2">Procurement</th>
+ <th className="border border-gray-300 p-2">Fabrication</th>
+ <th className="border border-gray-300 p-2">Assembly</th>
+ <th className="border border-gray-300 p-2">Test (FAT)</th>
+ <th className="border border-gray-300 p-2">Shipping (EXW)</th>
+ <th className="border border-gray-300 p-2">Shipping (FOB)</th>
+ <th className="border border-gray-300 p-2">Remark</th>
+ </tr>
+ </thead>
+ <tbody>
+ {/* Country Row */}
+ <tr className="bg-yellow-50">
+ <td className="border border-gray-300 p-2 font-medium">Country</td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={locationData?.country?.projectManager || ''}
+ onChange={(e) => updateLocationData('country', 'projectManager', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ placeholder="국가"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={locationData?.country?.engineering || ''}
+ onChange={(e) => updateLocationData('country', 'engineering', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ placeholder="국가"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={locationData?.country.procurement}
+ onChange={(e) => updateLocationData('country', 'procurement', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ placeholder="국가"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={locationData?.country.fabrication}
+ onChange={(e) => updateLocationData('country', 'fabrication', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ placeholder="국가"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={locationData?.country.assembly}
+ onChange={(e) => updateLocationData('country', 'assembly', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ placeholder="국가"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={locationData?.country.test}
+ onChange={(e) => updateLocationData('country', 'test', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ placeholder="국가"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={locationData?.country.shippingExw}
+ onChange={(e) => updateLocationData('country', 'shippingExw', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ placeholder="국가"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={locationData?.country.shippingFob}
+ onChange={(e) => updateLocationData('country', 'shippingFob', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ placeholder="국가"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={locationData?.country.remark}
+ onChange={(e) => updateLocationData('country', 'remark', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ />
+ </td>
+ </tr>
+
+ {/* Location (City) Row */}
+ <tr className="bg-yellow-50">
+ <td className="border border-gray-300 p-2 font-medium">Location (City)</td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={locationData?.location.projectManager}
+ onChange={(e) => updateLocationData('location', 'projectManager', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ placeholder="국가 내 지역"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={locationData?.location.engineering}
+ onChange={(e) => updateLocationData('location', 'engineering', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ placeholder="국가 내 지역"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={locationData?.location.procurement}
+ onChange={(e) => updateLocationData('location', 'procurement', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ placeholder="국가 내 지역"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={locationData?.location.fabrication}
+ onChange={(e) => updateLocationData('location', 'fabrication', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ placeholder="국가 내 지역"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={locationData?.location.assembly}
+ onChange={(e) => updateLocationData('location', 'assembly', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ placeholder="국가 내 지역"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={locationData?.location.test}
+ onChange={(e) => updateLocationData('location', 'test', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ placeholder="국가 내 지역"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={locationData?.location.shippingExw}
+ onChange={(e) => updateLocationData('location', 'shippingExw', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ placeholder="국가 내 지역"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={locationData?.location.shippingFob}
+ onChange={(e) => updateLocationData('location', 'shippingFob', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ placeholder="국가 내 지역"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={locationData?.location.remark}
+ onChange={(e) => updateLocationData('location', 'remark', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ />
+ </td>
+ </tr>
+
+ {/* Sub-Contractor Row */}
+ <tr className="bg-yellow-50">
+ <td className="border border-gray-300 p-2 font-medium">Sub-Contractor<br />(where applicable)</td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={locationData?.subContractor?.projectManager || ''}
+ onChange={(e) => updateLocationData('subContractor', 'projectManager', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ placeholder="국가 및 지역"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={locationData?.subContractor?.engineering || ''}
+ onChange={(e) => updateLocationData('subContractor', 'engineering', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ placeholder="국가 및 지역"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={locationData?.subContractor?.procurement || ''}
+ onChange={(e) => updateLocationData('subContractor', 'procurement', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ placeholder="국가 및 지역"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={locationData?.subContractor?.fabrication || ''}
+ onChange={(e) => updateLocationData('subContractor', 'fabrication', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ placeholder="국가 및 지역"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={locationData?.subContractor?.assembly || ''}
+ onChange={(e) => updateLocationData('subContractor', 'assembly', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ placeholder="국가 및 지역"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={locationData?.subContractor?.test || ''}
+ onChange={(e) => updateLocationData('subContractor', 'test', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ placeholder="국가 및 지역"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={locationData?.subContractor?.shippingExw || ''}
+ onChange={(e) => updateLocationData('subContractor', 'shippingExw', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ placeholder="국가 및 지역"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={locationData?.subContractor?.shippingFob || ''}
+ onChange={(e) => updateLocationData('subContractor', 'shippingFob', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ placeholder="국가 및 지역"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={locationData?.subContractor?.remark || ''}
+ onChange={(e) => updateLocationData('subContractor', 'remark', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ />
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </div>
+
+ {/* 저장 버튼 */}
+ <div className="flex justify-end pt-4 border-t">
+ <Button
+ onClick={handleSaveLocation}
+ disabled={isLoading || isDisabled || !isEnabled}
+ className="flex items-center gap-2"
+ >
+ {isLoading ? (
+ <LoaderIcon className="w-4 h-4 animate-spin" />
+ ) : (
+ <Save className="w-4 h-4" />
+ )}
+ Location 저장
+ </Button>
+ </div>
+ </div>
+ </AccordionContent>
+ </AccordionItem>
+ </Accordion>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/general-contracts_old/detail/general-contract-offset-details.tsx b/lib/general-contracts_old/detail/general-contract-offset-details.tsx
new file mode 100644
index 00000000..af4f2ef2
--- /dev/null
+++ b/lib/general-contracts_old/detail/general-contract-offset-details.tsx
@@ -0,0 +1,314 @@
+'use client'
+
+import React, { useState, useEffect } from 'react'
+import { useSession } from 'next-auth/react'
+import { Input } from '@/components/ui/input'
+import { Button } from '@/components/ui/button'
+import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { Plus, Trash2, Save, LoaderIcon, RotateCcw } from 'lucide-react'
+import { getOffsetDetails, updateOffsetDetails } from '../service'
+import { toast } from 'sonner'
+
+interface OffsetDetailsProps {
+ contractId: number
+ contractType?: string
+}
+
+interface OffsetDetailItem {
+ id: string
+ project: string
+ poNumber: string
+ poItemDescription: string
+ offsetReason: string
+ contractCurrency: string
+ contractAmount: string
+ offsetDate: string
+ remark: string
+}
+
+export function OffsetDetails({ contractId }: OffsetDetailsProps) {
+ const session = useSession()
+ const [isLoading, setIsLoading] = useState(false)
+ const [isEnabled, setIsEnabled] = useState(true)
+
+ // 특정 계약종류를 제외한 일반계약은 Default로 표시
+ const isDisabled = false
+
+ const [offsetDetails, setOffsetDetails] = useState<OffsetDetailItem[]>([])
+
+ // 회입/상계사유 옵션
+ const offsetReasonOptions = [
+ '판매자 사양불만족',
+ '납기지연',
+ '품질불량',
+ '계약조건변경',
+ '기타'
+ ]
+
+ // 초기 데이터 로드
+ useEffect(() => {
+ const loadOffsetDetails = async () => {
+ try {
+ const data = await getOffsetDetails(contractId)
+ if (data && data.enabled !== undefined) {
+ setIsEnabled(data.enabled)
+ setOffsetDetails(data.offsetDetails || [])
+ } else {
+ }
+ } catch (error) {
+ console.error('회입/상계내역 데이터 로드 실패:', error)
+ toast.error('회입/상계내역 데이터를 불러오는데 실패했습니다.')
+ }
+ }
+
+ loadOffsetDetails()
+ }, [contractId])
+
+ const addOffsetDetailRow = () => {
+ const newRow: OffsetDetailItem = {
+ id: Date.now().toString(),
+ project: '',
+ poNumber: '',
+ poItemDescription: '',
+ offsetReason: '',
+ contractCurrency: 'KRW',
+ contractAmount: '',
+ offsetDate: '',
+ remark: ''
+ }
+ setOffsetDetails([...offsetDetails, newRow])
+ }
+
+ const removeOffsetDetailRow = (id: string) => {
+ setOffsetDetails(offsetDetails.filter(item => item.id !== id))
+ }
+
+ const updateOffsetDetailData = (id: string, field: keyof OffsetDetailItem, value: string) => {
+ setOffsetDetails(prev =>
+ prev.map(item =>
+ item.id === id ? { ...item, [field]: value } : item
+ )
+ )
+ }
+
+ const handleSaveOffsetDetails = async () => {
+ if (!session.data?.user?.id) {
+ toast.error('로그인이 필요합니다.')
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ const offsetDetailsData = {
+ enabled: isEnabled,
+ offsetDetails: offsetDetails
+ }
+
+ await updateOffsetDetails(contractId, offsetDetailsData, Number(session.data.user.id))
+ toast.success('회입/상계내역이 성공적으로 저장되었습니다.')
+ } catch (error) {
+ console.error('회입/상계내역 저장 실패:', error)
+ toast.error('회입/상계내역 저장에 실패했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return (
+ <div className="w-full">
+ <Accordion type="single" collapsible className="w-full">
+ <AccordionItem value="offset-details">
+ <AccordionTrigger className="hover:no-underline">
+ <div className="flex items-center gap-3 w-full">
+ <RotateCcw className="w-5 h-5" />
+ <span className="font-medium">회입/상계내역</span>
+ </div>
+ </AccordionTrigger>
+ <AccordionContent>
+ <div className="space-y-6">
+ {/* 체크박스 */}
+ <div className="flex items-center gap-2">
+ <Checkbox
+ checked={isEnabled}
+ disabled={isDisabled}
+ onCheckedChange={(checked) => {
+ if (!isDisabled) {
+ setIsEnabled(checked as boolean)
+ }
+ }}
+ />
+ <span className="text-sm font-medium">회입/상계내역 활성화</span>
+ </div>
+
+ {/* 회입/상계내역 테이블 */}
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <h3 className="text-lg font-medium">회입/상계내역</h3>
+ <div className="flex gap-2">
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={addOffsetDetailRow}
+ disabled={isDisabled || !isEnabled}
+ className="flex items-center gap-2"
+ >
+ <Plus className="w-4 h-4" />
+ 행 추가
+ </Button>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={() => {
+ if (confirm('선택된 행들을 삭제하시겠습니까?')) {
+ // 선택된 행들 삭제 로직 (필요시 구현)
+ }
+ }}
+ disabled={isDisabled || !isEnabled}
+ className="flex items-center gap-2"
+ >
+ <Trash2 className="w-4 h-4" />
+ 행 삭제
+ </Button>
+ </div>
+ </div>
+
+ <div className="overflow-x-auto">
+ <table className={`w-full border-collapse border border-gray-300 ${!isEnabled ? 'opacity-50' : ''}`}>
+ <thead>
+ <tr className="bg-yellow-100">
+ <th className="border border-gray-300 p-2 w-16">No.</th>
+ <th className="border border-gray-300 p-2 w-32">프로젝트</th>
+ <th className="border border-gray-300 p-2 w-40">발주번호</th>
+ <th className="border border-gray-300 p-2">발주품목(Description)</th>
+ <th className="border border-gray-300 p-2 w-40">회입/상계사유</th>
+ <th className="border border-gray-300 p-2 w-32">계약통화</th>
+ <th className="border border-gray-300 p-2 w-32">계약금액</th>
+ <th className="border border-gray-300 p-2 w-32">회입/상계일</th>
+ <th className="border border-gray-300 p-2">비고</th>
+ <th className="border border-gray-300 p-2 w-20">Action</th>
+ </tr>
+ </thead>
+ <tbody>
+ {offsetDetails.map((item, index) => (
+ <tr key={item.id} className="bg-yellow-50">
+ <td className="border border-gray-300 p-2 text-center">{index + 1}</td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={item.project}
+ onChange={(e) => updateOffsetDetailData(item.id, 'project', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={item.poNumber}
+ onChange={(e) => updateOffsetDetailData(item.id, 'poNumber', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={item.poItemDescription}
+ onChange={(e) => updateOffsetDetailData(item.id, 'poItemDescription', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Select
+ value={item.offsetReason}
+ onValueChange={(value) => updateOffsetDetailData(item.id, 'offsetReason', value)}
+ disabled={isDisabled || !isEnabled}
+ >
+ <SelectTrigger className="border-0 bg-transparent p-0 h-auto">
+ <SelectValue placeholder="선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {offsetReasonOptions.map((option) => (
+ <SelectItem key={option} value={option}>
+ {option}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={item.contractCurrency}
+ onChange={(e) => updateOffsetDetailData(item.id, 'contractCurrency', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={item.contractAmount}
+ onChange={(e) => updateOffsetDetailData(item.id, 'contractAmount', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ type="date"
+ value={item.offsetDate}
+ onChange={(e) => updateOffsetDetailData(item.id, 'offsetDate', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ />
+ </td>
+ <td className="border border-gray-300 p-2">
+ <Input
+ value={item.remark}
+ onChange={(e) => updateOffsetDetailData(item.id, 'remark', e.target.value)}
+ disabled={isDisabled || !isEnabled}
+ className="border-0 bg-transparent p-0 h-auto"
+ />
+ </td>
+ <td className="border border-gray-300 p-2 text-center">
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeOffsetDetailRow(item.id)}
+ disabled={isDisabled || !isEnabled}
+ className="h-8 w-8 p-0 text-red-600 hover:text-red-700"
+ >
+ <Trash2 className="w-4 h-4" />
+ </Button>
+ </td>
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+ </div>
+
+ {/* 저장 버튼 */}
+ <div className="flex justify-end pt-4 border-t">
+ <Button
+ onClick={handleSaveOffsetDetails}
+ disabled={isLoading || isDisabled || !isEnabled}
+ className="flex items-center gap-2"
+ >
+ {isLoading ? (
+ <LoaderIcon className="w-4 h-4 animate-spin" />
+ ) : (
+ <Save className="w-4 h-4" />
+ )}
+ 회입/상계내역 저장
+ </Button>
+ </div>
+ </div>
+ </AccordionContent>
+ </AccordionItem>
+ </Accordion>
+ </div>
+ )
+}
diff --git a/lib/general-contracts_old/detail/general-contract-subcontract-checklist.tsx b/lib/general-contracts_old/detail/general-contract-subcontract-checklist.tsx
new file mode 100644
index 00000000..ce7c8baf
--- /dev/null
+++ b/lib/general-contracts_old/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<SubcontractChecklistData>(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: <CheckCircle className="h-5 w-5 text-green-600" />,
+ label: '준수',
+ color: 'bg-green-100 text-green-800',
+ description: '1. 계약서면발급의 모든 항목에 체크, 2. 부당하도급에서 X항목에 체크한 상태'
+ }
+ case 'violation':
+ return {
+ icon: <XCircle className="h-5 w-5 text-red-600" />,
+ label: '위반',
+ color: 'bg-red-100 text-red-800',
+ description: '1. 계약서면발급의 모든 항목 중 1개 이상 미체크 한 경우'
+ }
+ case 'suspected_violation':
+ return {
+ icon: <AlertTriangle className="h-5 w-5 text-yellow-600" />,
+ label: '위반의심',
+ color: 'bg-yellow-100 text-yellow-800',
+ description: '2. 부당하도급에서 O항목에 체크한 경우'
+ }
+ default:
+ // 기본값으로 준수 상태 반환
+ return {
+ icon: <CheckCircle className="h-5 w-5 text-green-600" />,
+ 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 (
+ <Accordion type="single" collapsible className="w-full">
+ <AccordionItem value="checklist">
+ <AccordionTrigger className="hover:no-underline">
+ <div className="flex items-center gap-3 w-full">
+ <HelpCircle className="h-5 w-5" />
+ <span className="font-medium">하도급법 자율점검 체크리스트</span>
+ <Badge className={resultInfo.color}>
+ {resultInfo.label}
+ </Badge>
+ </div>
+ </AccordionTrigger>
+ <AccordionContent>
+ <Card>
+ <CardContent className="space-y-6 pt-6">
+ {/* 체크박스 */}
+ <div className="flex items-center gap-2">
+ <Checkbox
+ checked={isEnabled}
+ onCheckedChange={(checked) => setIsEnabled(checked as boolean)}
+ disabled={readOnly}
+ />
+ <span className="text-sm font-medium">하도급법 자율점검 체크리스트 활성화</span>
+ </div>
+
+ {/* 점검결과 표시 */}
+ <div className="flex items-center gap-4 p-4 bg-gray-50 rounded-lg">
+ <div className="flex items-center gap-2">
+ {resultInfo.icon}
+ <Badge className={resultInfo.color}>
+ {resultInfo.label}
+ </Badge>
+ </div>
+ <div className="text-sm text-gray-600">
+ {resultInfo.description}
+ </div>
+ </div>
+
+ <Accordion type="multiple" defaultValue={["contract-document", "unfair-pricing"]} className="w-full">
+ {/* 1. 계약서면발급 */}
+ <AccordionItem value="contract-document">
+ <AccordionTrigger className="text-lg font-semibold">
+ 1. 계약서면발급
+ </AccordionTrigger>
+ <AccordionContent>
+ <div className="space-y-4 p-4">
+ <Alert>
+ <AlertDescription>
+ 본 계약에 해당하는 항목을 아래 안내사항에 따라 &apos;O&apos;인 경우 체크하세요.
+ </AlertDescription>
+ </Alert>
+
+ <div className="space-y-4">
+ <div className="flex items-start space-x-3">
+ <Checkbox
+ id="workOrderBeforeStart"
+ checked={data.contractDocumentIssuance.workOrderBeforeStart}
+ onCheckedChange={(checked) => handleContractDocumentChange('workOrderBeforeStart', checked as boolean)}
+ disabled={!isEnabled || readOnly}
+ />
+ <div className="space-y-1">
+ <Label htmlFor="workOrderBeforeStart" className="text-sm font-medium">
+ (1) 작업 착수前 계약 서면을 발급하지 못하는 경우 작업지시서(선작업합의서)를 발급했는가?
+ </Label>
+ <p className="text-xs text-gray-500">
+ ※ 단가, 물량 등을 정하지 못하는 경우 정하는 기일을 기재
+ </p>
+ </div>
+ </div>
+
+ <div className="flex items-start space-x-3">
+ <Checkbox
+ id="entrustmentDetails"
+ checked={data.contractDocumentIssuance.entrustmentDetails}
+ onCheckedChange={(checked) => handleContractDocumentChange('entrustmentDetails', checked as boolean)}
+ disabled={!isEnabled || readOnly}
+ />
+ <div className="space-y-1">
+ <Label htmlFor="entrustmentDetails" className="text-sm font-medium">
+ (2) 위탁일자와 위탁내용(품명, 수량 등)을 명기하였는가?
+ </Label>
+ </div>
+ </div>
+
+ <div className="flex items-start space-x-3">
+ <Checkbox
+ id="deliveryDetails"
+ checked={data.contractDocumentIssuance.deliveryDetails}
+ onCheckedChange={(checked) => handleContractDocumentChange('deliveryDetails', checked as boolean)}
+ disabled={!isEnabled || readOnly}
+ />
+ <div className="space-y-1">
+ <Label htmlFor="deliveryDetails" className="text-sm font-medium">
+ (3) 납품, 인도 또는 제공하는 시기 및 장소(납기 및 납품장소)를 명기하였는가?
+ </Label>
+ <p className="text-xs text-gray-500">
+ 예: 삼성의 검사완료(승인) 후 목적물 인도 등
+ </p>
+ </div>
+ </div>
+
+ <div className="flex items-start space-x-3">
+ <Checkbox
+ id="inspectionMethod"
+ checked={data.contractDocumentIssuance.inspectionMethod}
+ onCheckedChange={(checked) => handleContractDocumentChange('inspectionMethod', checked as boolean)}
+ disabled={!isEnabled || readOnly}
+ />
+ <div className="space-y-1">
+ <Label htmlFor="inspectionMethod" className="text-sm font-medium">
+ (4) 검사의 방법 및 시기를 명기하였는가?
+ </Label>
+ <p className="text-xs text-gray-500">
+ 예: 작업완료 후 삼성담당자 입회하에 검사를 실시하고 10일 이내 검사결과 통보
+ </p>
+ </div>
+ </div>
+
+ <div className="flex items-start space-x-3">
+ <Checkbox
+ id="subcontractPayment"
+ checked={data.contractDocumentIssuance.subcontractPayment}
+ onCheckedChange={(checked) => handleContractDocumentChange('subcontractPayment', checked as boolean)}
+ disabled={!isEnabled || readOnly}
+ />
+ <div className="space-y-1">
+ <Label htmlFor="subcontractPayment" className="text-sm font-medium">
+ (5) 하도급대금과 그 지급방법(현금, 어음 등) 및 지급기일을 명기하였는가?
+ </Label>
+ </div>
+ </div>
+
+ <div className="flex items-start space-x-3">
+ <Checkbox
+ id="materialProvision"
+ checked={data.contractDocumentIssuance.materialProvision}
+ onCheckedChange={(checked) => handleContractDocumentChange('materialProvision', checked as boolean)}
+ disabled={!isEnabled || readOnly}
+ />
+ <div className="space-y-1">
+ <Label htmlFor="materialProvision" className="text-sm font-medium">
+ (6) 원재료 등 제공 시 품명/수량/제공일/대가/대가 지급방법 및 기일을 명기하였는가?
+ </Label>
+ <p className="text-xs text-gray-500">
+ 해당사항 없을 시에도 기재로 간주
+ </p>
+ </div>
+ </div>
+
+ <div className="flex items-start space-x-3">
+ <Checkbox
+ id="priceAdjustment"
+ checked={data.contractDocumentIssuance.priceAdjustment}
+ onCheckedChange={(checked) => handleContractDocumentChange('priceAdjustment', checked as boolean)}
+ disabled={!isEnabled || readOnly}
+ />
+ <div className="space-y-1">
+ <Label htmlFor="priceAdjustment" className="text-sm font-medium">
+ (7) 원재료 등 가격변동에 따른 대금 조정 요건/방법/절차를 명기하였는가?
+ </Label>
+ </div>
+ </div>
+ </div>
+ </div>
+ </AccordionContent>
+ </AccordionItem>
+
+ {/* 2. 부당하도급대금결정행위 */}
+ <AccordionItem value="unfair-pricing">
+ <AccordionTrigger className="text-lg font-semibold">
+ 2. 부당하도급대금결정행위
+ </AccordionTrigger>
+ <AccordionContent>
+ <div className="space-y-4 p-4">
+ <Alert>
+ <AlertDescription>
+ 본 계약에 해당하는 항목을 아래 안내사항에 따라 &apos;O&apos;인 경우 체크하세요.
+ <br />
+ <strong>※ &apos;X&apos; 항목에 다음 안내사항이 자동 표기됩니다:</strong>
+ </AlertDescription>
+ </Alert>
+
+ <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 space-y-2">
+ <h4 className="font-medium text-yellow-800">안내사항:</h4>
+ <ul className="text-sm text-yellow-700 space-y-1">
+ <li>• 단가 인하時 객관/타당한 근거에 의해 산출하고 협력사와 합의</li>
+ <li>• 최저가 경쟁입찰 후 입찰자와 대금인하 협상 불가</li>
+ <li>• 협력사에 발주량 등 거래조건에 착오를 일으키게 하거나 타 사업자 견적 또는 거짓 견적을 보여주는 등 기만하여 대금을 결정할 수 없음</li>
+ <li>• 정당한 이유 없이 일률적 비율로 단가 인하 불가</li>
+ <li>• 정당한 이유 없이 특정 사업자를 차별 취급 하도록 대금 결정 불가</li>
+ </ul>
+ </div>
+
+ <div className="space-y-4">
+ <div className="flex items-start space-x-3">
+ <Checkbox
+ id="priceReductionWithBasis"
+ checked={data.unfairSubcontractPricing.priceReductionWithBasis}
+ onCheckedChange={(checked) => handleUnfairPricingChange('priceReductionWithBasis', checked as boolean)}
+ disabled={!isEnabled || readOnly}
+ />
+ <Label htmlFor="priceReductionWithBasis" className="text-sm font-medium">
+ 단가 인하時 객관/타당한 근거에 의해 산출하고 협력사와 합의
+ </Label>
+ </div>
+
+ <div className="flex items-start space-x-3">
+ <Checkbox
+ id="noNegotiationAfterLowestBid"
+ checked={data.unfairSubcontractPricing.noNegotiationAfterLowestBid}
+ onCheckedChange={(checked) => handleUnfairPricingChange('noNegotiationAfterLowestBid', checked as boolean)}
+ disabled={!isEnabled || readOnly}
+ />
+ <Label htmlFor="noNegotiationAfterLowestBid" className="text-sm font-medium">
+ 최저가 경쟁입찰 후 입찰자와 대금인하 협상 불가
+ </Label>
+ </div>
+
+ <div className="flex items-start space-x-3">
+ <Checkbox
+ id="noDeceptionInPricing"
+ checked={data.unfairSubcontractPricing.noDeceptionInPricing}
+ onCheckedChange={(checked) => handleUnfairPricingChange('noDeceptionInPricing', checked as boolean)}
+ disabled={!isEnabled || readOnly}
+ />
+ <Label htmlFor="noDeceptionInPricing" className="text-sm font-medium">
+ 협력사에 발주량 등 거래조건에 착오를 일으키게 하거나 타 사업자 견적 또는 거짓 견적을 보여주는 등 기만하여 대금을 결정할 수 없음
+ </Label>
+ </div>
+
+ <div className="flex items-start space-x-3">
+ <Checkbox
+ id="noUniformPriceReduction"
+ checked={data.unfairSubcontractPricing.noUniformPriceReduction}
+ onCheckedChange={(checked) => handleUnfairPricingChange('noUniformPriceReduction', checked as boolean)}
+ disabled={!isEnabled || readOnly}
+ />
+ <Label htmlFor="noUniformPriceReduction" className="text-sm font-medium">
+ 정당한 이유 없이 일률적 비율로 단가 인하 불가
+ </Label>
+ </div>
+
+ <div className="flex items-start space-x-3">
+ <Checkbox
+ id="noDiscriminatoryTreatment"
+ checked={data.unfairSubcontractPricing.noDiscriminatoryTreatment}
+ onCheckedChange={(checked) => handleUnfairPricingChange('noDiscriminatoryTreatment', checked as boolean)}
+ disabled={!isEnabled || readOnly}
+ />
+ <Label htmlFor="noDiscriminatoryTreatment" className="text-sm font-medium">
+ 정당한 이유 없이 특정 사업자를 차별 취급 하도록 대금 결정 불가
+ </Label>
+ </div>
+ </div>
+ </div>
+ </AccordionContent>
+ </AccordionItem>
+
+ {/* 위반/위반의심 시 추가 정보 */}
+ {isViolationOrSuspected && (
+ <AccordionItem value="violation-details">
+ <AccordionTrigger className="text-lg font-semibold">
+ 위반/위반의심 상세 정보
+ </AccordionTrigger>
+ <AccordionContent>
+ <div className="space-y-4 p-4">
+ <Alert>
+ <AlertTriangle className="h-4 w-4" />
+ <AlertDescription>
+ 점검결과가 위반 또는 위반의심인 경우 아래 정보를 필수로 입력해주세요.
+ </AlertDescription>
+ </Alert>
+
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div className="space-y-2">
+ <Label htmlFor="responsibleDepartment">귀책부서 *</Label>
+ <Textarea
+ id="responsibleDepartment"
+ value={data.responsibleDepartment || ''}
+ onChange={(e) => handleFieldChange('responsibleDepartment', e.target.value)}
+ placeholder="귀책부서를 입력하세요"
+ rows={2}
+ disabled={!isEnabled || readOnly}
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="cause">원인 *</Label>
+ <Select
+ value={data.cause || ''}
+ onValueChange={(value) => handleFieldChange('cause', value)}
+ disabled={!isEnabled || readOnly}
+ >
+ <SelectTrigger disabled={!isEnabled || readOnly}>
+ <SelectValue placeholder="원인을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {causeOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ {data.cause === '기타' && (
+ <Textarea
+ value={data.causeOther || ''}
+ onChange={(e) => handleFieldChange('causeOther', e.target.value)}
+ placeholder="기타 원인을 입력하세요"
+ rows={2}
+ disabled={!isEnabled || readOnly}
+ />
+ )}
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="countermeasure">대책 *</Label>
+ <Select
+ value={data.countermeasure || ''}
+ onValueChange={(value) => handleFieldChange('countermeasure', value)}
+ disabled={!isEnabled || readOnly}
+ >
+ <SelectTrigger disabled={!isEnabled || readOnly}>
+ <SelectValue placeholder="대책을 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ {countermeasureOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ {data.countermeasure === '기타' && (
+ <Textarea
+ value={data.countermeasureOther || ''}
+ onChange={(e) => handleFieldChange('countermeasureOther', e.target.value)}
+ placeholder="기타 대책을 입력하세요"
+ rows={2}
+ disabled={!isEnabled || readOnly}
+ />
+ )}
+ </div>
+ </div>
+ </div>
+ </AccordionContent>
+ </AccordionItem>
+ )}
+ </Accordion>
+
+ {/* 저장 버튼 */}
+ {!readOnly && (
+ <div className="flex justify-end pt-4 border-t">
+ <Button onClick={handleSave} className="flex items-center gap-2">
+ <Save className="h-4 w-4" />
+ 체크리스트 저장
+ </Button>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ </AccordionContent>
+ </AccordionItem>
+ </Accordion>
+ )
+}
diff --git a/lib/general-contracts_old/main/create-general-contract-dialog.tsx b/lib/general-contracts_old/main/create-general-contract-dialog.tsx
new file mode 100644
index 00000000..2c3fc8bc
--- /dev/null
+++ b/lib/general-contracts_old/main/create-general-contract-dialog.tsx
@@ -0,0 +1,413 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { Plus } from "lucide-react"
+import { toast } from "sonner"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Textarea } from "@/components/ui/textarea"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { Calendar } from "@/components/ui/calendar"
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
+import { CalendarIcon } from "lucide-react"
+import { format } from "date-fns"
+import { ko } from "date-fns/locale"
+import { cn } from "@/lib/utils"
+import { createContract, getVendors, getProjects } from "@/lib/general-contracts/service"
+import {
+ GENERAL_CONTRACT_CATEGORIES,
+ GENERAL_CONTRACT_TYPES,
+ GENERAL_EXECUTION_METHODS
+} from "@/lib/general-contracts/types"
+import { useSession } from "next-auth/react"
+
+interface CreateContractForm {
+ contractNumber: string
+ name: string
+ category: string
+ type: string
+ executionMethod: string
+ vendorId: number | null
+ projectId: number | null
+ startDate: Date | undefined
+ endDate: Date | undefined
+ validityEndDate: Date | undefined
+ notes: string
+}
+
+export function CreateGeneralContractDialog() {
+ const router = useRouter()
+ const { data: session } = useSession()
+ const [open, setOpen] = React.useState(false)
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [vendors, setVendors] = React.useState<Array<{ id: number; vendorName: string; vendorCode: string | null }>>([])
+ const [projects, setProjects] = React.useState<Array<{ id: number; code: string; name: string; type: string }>>([])
+
+ const [form, setForm] = React.useState<CreateContractForm>({
+ contractNumber: '',
+ name: '',
+ category: '',
+ type: '',
+ executionMethod: '',
+ vendorId: null,
+ projectId: null,
+ startDate: undefined,
+ endDate: undefined,
+ validityEndDate: undefined,
+ notes: '',
+ })
+
+ // 업체 목록 조회
+ React.useEffect(() => {
+ const fetchVendors = async () => {
+ try {
+ const vendorList = await getVendors()
+ setVendors(vendorList)
+ } catch (error) {
+ console.error('Error fetching vendors:', error)
+ }
+ }
+ fetchVendors()
+ }, [])
+
+ // 프로젝트 목록 조회
+ React.useEffect(() => {
+ const fetchProjects = async () => {
+ try {
+ const projectList = await getProjects()
+ console.log(projectList)
+ setProjects(projectList)
+ } catch (error) {
+ console.error('Error fetching projects:', error)
+ }
+ }
+ fetchProjects()
+ }, [])
+
+ const handleSubmit = async () => {
+ // 필수 필드 검증
+ if (!form.name || !form.category || !form.type || !form.executionMethod ||
+ !form.vendorId || !form.startDate || !form.endDate) {
+ toast.error("필수 항목을 모두 입력해주세요.")
+ return
+ }
+
+ if (!form.validityEndDate) {
+ setForm(prev => ({ ...prev, validityEndDate: form.endDate }))
+ }
+
+ try {
+ setIsLoading(true)
+
+ const contractData = {
+ contractNumber: '',
+ name: form.name,
+ category: form.category,
+ type: form.type,
+ executionMethod: form.executionMethod,
+ projectId: form.projectId,
+ contractSourceType: 'manual',
+ vendorId: form.vendorId!,
+ startDate: form.startDate!.toISOString().split('T')[0],
+ endDate: form.endDate!.toISOString().split('T')[0],
+ validityEndDate: (form.validityEndDate || form.endDate!).toISOString().split('T')[0],
+ status: 'Draft',
+ registeredById: session?.user?.id || 1,
+ lastUpdatedById: session?.user?.id || 1,
+ notes: form.notes,
+ }
+
+ await createContract(contractData)
+
+ toast.success("새 계약이 생성되었습니다.")
+ setOpen(false)
+ resetForm()
+
+ // 상세 페이지로 이동
+ router.refresh()
+ } catch (error) {
+ console.error('Error creating contract:', error)
+ toast.error("계약 생성 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const resetForm = () => {
+ setForm({
+ contractNumber: '',
+ name: '',
+ category: '',
+ type: '',
+ executionMethod: '',
+ vendorId: null,
+ projectId: null,
+ startDate: undefined,
+ endDate: undefined,
+ validityEndDate: undefined,
+ notes: '',
+ })
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={(newOpen) => {
+ setOpen(newOpen)
+ if (!newOpen) resetForm()
+ }}>
+ <DialogTrigger asChild>
+ <Button size="sm">
+ <Plus className="mr-2 h-4 w-4" />
+ 신규등록
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>새 계약 등록</DialogTitle>
+ <DialogDescription>
+ 새로운 계약의 기본 정보를 입력하세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="grid gap-4 py-4">
+ <div className="grid grid-cols-1 gap-4">
+ <div className="grid gap-2">
+ <Label htmlFor="name">계약명 *</Label>
+ <Input
+ id="name"
+ value={form.name}
+ onChange={(e) => setForm(prev => ({ ...prev, name: e.target.value }))}
+ placeholder="계약명을 입력하세요"
+ />
+ </div>
+ </div>
+
+ <div className="grid grid-cols-3 gap-4">
+ <div className="grid gap-2">
+ <Label htmlFor="category">계약구분 *</Label>
+ <Select value={form.category} onValueChange={(value) => setForm(prev => ({ ...prev, category: value }))}>
+ <SelectTrigger>
+ <SelectValue placeholder="계약구분 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {GENERAL_CONTRACT_CATEGORIES.map((category) => {
+ const categoryLabels = {
+ 'unit_price': '단가계약',
+ 'general': '일반계약',
+ 'sale': '매각계약'
+ }
+ return (
+ <SelectItem key={category} value={category}>
+ {category} - {categoryLabels[category as keyof typeof categoryLabels]}
+ </SelectItem>
+ )
+ })}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div className="grid gap-2">
+ <Label htmlFor="type">계약종류 *</Label>
+ <Select value={form.type} onValueChange={(value) => setForm(prev => ({ ...prev, type: value }))}>
+ <SelectTrigger>
+ <SelectValue placeholder="계약종류 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {GENERAL_CONTRACT_TYPES.map((type) => {
+ const typeLabels = {
+ 'UP': '자재단가계약',
+ 'LE': '임대차계약',
+ 'IL': '개별운송계약',
+ 'AL': '연간운송계약',
+ 'OS': '외주용역계약',
+ 'OW': '도급계약',
+ 'IS': '검사계약',
+ 'LO': 'LOI',
+ 'FA': 'FA',
+ 'SC': '납품합의계약',
+ 'OF': '클레임상계계약',
+ 'AW': '사전작업합의',
+ 'AD': '사전납품합의',
+ 'AM': '설계계약',
+ 'SC_SELL': '폐기물매각계약'
+ }
+ return (
+ <SelectItem key={type} value={type}>
+ {type} - {typeLabels[type as keyof typeof typeLabels]}
+ </SelectItem>
+ )
+ })}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div className="grid gap-2">
+ <Label htmlFor="executionMethod">체결방식 *</Label>
+ <Select value={form.executionMethod} onValueChange={(value) => setForm(prev => ({ ...prev, executionMethod: value }))}>
+ <SelectTrigger>
+ <SelectValue placeholder="체결방식 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {GENERAL_EXECUTION_METHODS.map((method) => (
+ <SelectItem key={method} value={method}>
+ {method}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+
+ <div className="grid gap-2">
+ <Label htmlFor="project">프로젝트</Label>
+ <Select value={form.projectId?.toString()} onValueChange={(value) => setForm(prev => ({ ...prev, projectId: parseInt(value) }))}>
+ <SelectTrigger>
+ <SelectValue placeholder="프로젝트 선택 (선택사항)" />
+ </SelectTrigger>
+ <SelectContent>
+ {projects.map((project) => (
+ <SelectItem key={project.id} value={project.id.toString()}>
+ {project.name} ({project.code})
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div className="grid gap-2">
+ <Label htmlFor="vendor">협력업체 *</Label>
+ <Select value={form.vendorId?.toString()} onValueChange={(value) => setForm(prev => ({ ...prev, vendorId: parseInt(value) }))}>
+ <SelectTrigger>
+ <SelectValue placeholder="협력업체 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {vendors.map((vendor) => (
+ <SelectItem key={vendor.id} value={vendor.id.toString()}>
+ {vendor.vendorName} ({vendor.vendorCode})
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div className="grid grid-cols-3 gap-4">
+ <div className="grid gap-2">
+ <Label>계약시작일 *</Label>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ className={cn(
+ "justify-start text-left font-normal",
+ !form.startDate && "text-muted-foreground"
+ )}
+ >
+ <CalendarIcon className="mr-2 h-4 w-4" />
+ {form.startDate ? format(form.startDate, "yyyy-MM-dd", { locale: ko }) : "날짜 선택"}
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0">
+ <Calendar
+ mode="single"
+ selected={form.startDate}
+ onSelect={(date) => setForm(prev => ({ ...prev, startDate: date }))}
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ </div>
+
+ <div className="grid gap-2">
+ <Label>계약종료일 *</Label>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ className={cn(
+ "justify-start text-left font-normal",
+ !form.endDate && "text-muted-foreground"
+ )}
+ >
+ <CalendarIcon className="mr-2 h-4 w-4" />
+ {form.endDate ? format(form.endDate, "yyyy-MM-dd", { locale: ko }) : "날짜 선택"}
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0">
+ <Calendar
+ mode="single"
+ selected={form.endDate}
+ onSelect={(date) => setForm(prev => ({ ...prev, endDate: date }))}
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ </div>
+
+ <div className="grid gap-2">
+ <Label>유효기간종료일</Label>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ className={cn(
+ "justify-start text-left font-normal",
+ !form.validityEndDate && "text-muted-foreground"
+ )}
+ >
+ <CalendarIcon className="mr-2 h-4 w-4" />
+ {form.validityEndDate ? format(form.validityEndDate, "yyyy-MM-dd", { locale: ko }) : "날짜 선택"}
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0">
+ <Calendar
+ mode="single"
+ selected={form.validityEndDate}
+ onSelect={(date) => setForm(prev => ({ ...prev, validityEndDate: date }))}
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ </div>
+ </div>
+ <div className="grid gap-2">
+ <Label htmlFor="notes">비고</Label>
+ <Textarea
+ id="notes"
+ value={form.notes}
+ onChange={(e) => setForm(prev => ({ ...prev, notes: e.target.value }))}
+ placeholder="비고사항을 입력하세요"
+ rows={3}
+ />
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setOpen(false)}
+ >
+ 취소
+ </Button>
+ <Button
+ type="button"
+ onClick={handleSubmit}
+ disabled={isLoading}
+ >
+ {isLoading ? '생성 중...' : '생성'}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/general-contracts_old/main/general-contract-update-sheet.tsx b/lib/general-contracts_old/main/general-contract-update-sheet.tsx
new file mode 100644
index 00000000..54f4ae4e
--- /dev/null
+++ b/lib/general-contracts_old/main/general-contract-update-sheet.tsx
@@ -0,0 +1,401 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { z } from "zod"
+import { toast } from "sonner"
+import { Button } from "@/components/ui/button"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ GENERAL_CONTRACT_CATEGORIES,
+ GENERAL_CONTRACT_TYPES,
+ GENERAL_EXECUTION_METHODS,
+} from "@/lib/general-contracts/types"
+import { updateContract } from "../service"
+import { GeneralContractListItem } from "./general-contracts-table-columns"
+import { useSession } from "next-auth/react"
+const updateContractSchema = z.object({
+ category: z.string().min(1, "계약구분을 선택해주세요"),
+ type: z.string().min(1, "계약종류를 선택해주세요"),
+ executionMethod: z.string().min(1, "체결방식을 선택해주세요"),
+ name: z.string().min(1, "계약명을 입력해주세요"),
+ startDate: z.string().min(1, "계약시작일을 선택해주세요"),
+ endDate: z.string().min(1, "계약종료일을 선택해주세요"),
+ validityEndDate: z.string().min(1, "유효기간종료일을 선택해주세요"),
+ contractScope: z.string().min(1, "계약확정범위를 선택해주세요"),
+ notes: z.string().optional(),
+ linkedRfqOrItb: z.string().optional(),
+ linkedPoNumber: z.string().optional(),
+ linkedBidNumber: z.string().optional(),
+})
+
+type UpdateContractFormData = z.infer<typeof updateContractSchema>
+
+interface GeneralContractUpdateSheetProps {
+ contract: GeneralContractListItem | null
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onSuccess?: () => void
+}
+
+export function GeneralContractUpdateSheet({
+ contract,
+ open,
+ onOpenChange,
+ onSuccess,
+}: GeneralContractUpdateSheetProps) {
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const session = useSession()
+ const userId = session.data?.user?.id ? Number(session.data.user.id) : null
+ const form = useForm<UpdateContractFormData>({
+ resolver: zodResolver(updateContractSchema),
+ defaultValues: {
+ category: "",
+ type: "",
+ executionMethod: "",
+ name: "",
+ startDate: "",
+ endDate: "",
+ validityEndDate: "",
+ contractScope: "",
+ notes: "",
+ linkedRfqOrItb: "",
+ linkedPoNumber: "",
+ linkedBidNumber: "",
+ },
+ })
+
+ // 계약확정범위에 따른 품목정보 필드 비활성화 여부
+ const watchedContractScope = form.watch("contractScope")
+ const isItemsDisabled = watchedContractScope === '단가' || watchedContractScope === '물량(실적)'
+
+ // 계약 데이터가 변경될 때 폼 초기화
+ React.useEffect(() => {
+ if (contract) {
+ console.log("Loading contract data:", contract)
+ const formData = {
+ category: contract.category || "",
+ type: contract.type || "",
+ executionMethod: contract.executionMethod || "",
+ name: contract.name || "",
+ startDate: contract.startDate || "",
+ endDate: contract.endDate || "",
+ validityEndDate: contract.validityEndDate || "",
+ contractScope: contract.contractScope || "",
+ notes: contract.notes || "",
+ linkedRfqOrItb: contract.linkedRfqOrItb || "",
+ linkedPoNumber: contract.linkedPoNumber || "",
+ linkedBidNumber: contract.linkedBidNumber || "",
+ }
+ console.log("Form data to reset:", formData)
+ form.reset(formData)
+ }
+ }, [contract, form])
+
+ const onSubmit = async (data: UpdateContractFormData) => {
+ if (!contract) return
+
+ try {
+ setIsSubmitting(true)
+
+ await updateContract(contract.id, {
+ category: data.category,
+ type: data.type,
+ executionMethod: data.executionMethod,
+ name: data.name,
+ startDate: data.startDate,
+ endDate: data.endDate,
+ validityEndDate: data.validityEndDate,
+ contractScope: data.contractScope,
+ notes: data.notes,
+ linkedRfqOrItb: data.linkedRfqOrItb,
+ linkedPoNumber: data.linkedPoNumber,
+ linkedBidNumber: data.linkedBidNumber,
+ vendorId: contract.vendorId,
+ lastUpdatedById: userId,
+ })
+
+ toast.success("계약 정보가 성공적으로 수정되었습니다.")
+ onOpenChange(false)
+ onSuccess?.()
+ } catch (error) {
+ console.error("Error updating contract:", error)
+ toast.error("계약 정보 수정 중 오류가 발생했습니다.")
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+ <SheetContent className="w-[800px] sm:max-w-[800px] flex flex-col" style={{width: 800, maxWidth: 800, height: '100vh'}}>
+ <SheetHeader className="flex-shrink-0">
+ <SheetTitle>계약 정보 수정</SheetTitle>
+ <SheetDescription>
+ 계약의 기본 정보를 수정합니다. 변경사항은 즉시 저장됩니다.
+ </SheetDescription>
+ </SheetHeader>
+
+ <div className="flex-1 overflow-y-auto min-h-0">
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 h-full">
+ <div className="grid gap-4 py-4">
+ {/* 계약구분 */}
+ <FormField
+ control={form.control}
+ name="category"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약구분 *</FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="계약구분을 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {GENERAL_CONTRACT_CATEGORIES.map((category) => {
+ const categoryLabels = {
+ 'unit_price': '단가계약',
+ 'general': '일반계약',
+ 'sale': '매각계약'
+ }
+ return (
+ <SelectItem key={category} value={category}>
+ {category} - {categoryLabels[category as keyof typeof categoryLabels]}
+ </SelectItem>
+ )})}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 계약종류 */}
+ <FormField
+ control={form.control}
+ name="type"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약종류 *</FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="계약종류를 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {GENERAL_CONTRACT_TYPES.map((type) => {
+ const typeLabels = {
+ 'UP': '자재단가계약',
+ 'LE': '임대차계약',
+ 'IL': '개별운송계약',
+ 'AL': '연간운송계약',
+ 'OS': '외주용역계약',
+ 'OW': '도급계약',
+ 'IS': '검사계약',
+ 'LO': 'LOI',
+ 'FA': 'FA',
+ 'SC': '납품합의계약',
+ 'OF': '클레임상계계약',
+ 'AW': '사전작업합의',
+ 'AD': '사전납품합의',
+ 'AM': '설계계약',
+ 'SC_SELL': '폐기물매각계약'
+ }
+ return (
+ <SelectItem key={type} value={type}>
+ {type} - {typeLabels[type as keyof typeof typeLabels]}
+ </SelectItem>
+ )})}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 체결방식 */}
+ <FormField
+ control={form.control}
+ name="executionMethod"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>체결방식 *</FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="체결방식을 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {GENERAL_EXECUTION_METHODS.map((method) => {
+ const methodLabels = {
+ '전자계약': '전자계약',
+ '오프라인계약': '오프라인계약'
+ }
+ return (
+ <SelectItem key={method} value={method}>
+ {method} - {methodLabels[method as keyof typeof methodLabels]}
+ </SelectItem>
+ )})}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 계약명 */}
+ <FormField
+ control={form.control}
+ name="name"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약명 *</FormLabel>
+ <FormControl>
+ <Input placeholder="계약명을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 계약시작일 */}
+ <FormField
+ control={form.control}
+ name="startDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약시작일 *</FormLabel>
+ <FormControl>
+ <Input type="date" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 계약종료일 */}
+ <FormField
+ control={form.control}
+ name="endDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약종료일 *</FormLabel>
+ <FormControl>
+ <Input type="date" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 유효기간종료일 */}
+ <FormField
+ control={form.control}
+ name="validityEndDate"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>유효기간종료일 *</FormLabel>
+ <FormControl>
+ <Input type="date" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 계약확정범위 */}
+ <FormField
+ control={form.control}
+ name="contractScope"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>계약확정범위 *</FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="계약확정범위를 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="단가">단가</SelectItem>
+ <SelectItem value="금액">금액</SelectItem>
+ <SelectItem value="물량(실적)">물량(실적)</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ <p className="text-sm text-muted-foreground">
+ 해당 계약으로 확정되는 범위를 선택하세요.
+ </p>
+ </FormItem>
+ )}
+ />
+
+ {/* 비고 */}
+ <FormField
+ control={form.control}
+ name="notes"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>비고</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="비고를 입력하세요"
+ className="min-h-[100px]"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <SheetFooter className="flex-shrink-0 mt-6">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={isSubmitting}>
+ {isSubmitting ? "수정 중..." : "수정"}
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </div>
+ </SheetContent>
+ </Sheet>
+ )
+}
diff --git a/lib/general-contracts_old/main/general-contracts-table-columns.tsx b/lib/general-contracts_old/main/general-contracts-table-columns.tsx
new file mode 100644
index 00000000..a08d8b81
--- /dev/null
+++ b/lib/general-contracts_old/main/general-contracts-table-columns.tsx
@@ -0,0 +1,571 @@
+"use client"
+
+import * as React from "react"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import {
+ Eye, Edit, MoreHorizontal
+} from "lucide-react"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { DataTableRowAction } from "@/types/table"
+import { formatDate } from "@/lib/utils"
+
+// 일반계약 리스트 아이템 타입 정의
+export interface GeneralContractListItem {
+ id: number
+ contractNumber: string
+ revision: number
+ status: string
+ category: string
+ type: string
+ executionMethod: string
+ name: string
+ contractSourceType?: string
+ startDate: string
+ endDate: string
+ validityEndDate?: string
+ contractScope?: string
+ specificationType?: string
+ specificationManualText?: string
+ contractAmount?: number | string | null
+ totalAmount?: number | string | null
+ currency?: string
+ registeredAt: string
+ signedAt?: string
+ linkedPoNumber?: string
+ linkedRfqOrItb?: string
+ linkedBidNumber?: string
+ lastUpdatedAt: string
+ notes?: string
+ vendorId?: number
+ vendorName?: string
+ vendorCode?: string
+ projectId?: number
+ projectName?: string
+ projectCode?: string
+ managerName?: string
+ lastUpdatedByName?: string
+}
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<GeneralContractListItem> | null>>
+}
+
+// 상태별 배지 색상
+const getStatusBadgeVariant = (status: string) => {
+ switch (status) {
+ case 'Draft':
+ return 'outline'
+ case 'Request to Review':
+ case 'Confirm to Review':
+ return 'secondary'
+ case 'Contract Accept Request':
+ return 'default'
+ case 'Complete the Contract':
+ return 'default'
+ case 'Reject to Accept Contract':
+ case 'Contract Delete':
+ return 'destructive'
+ default:
+ return 'outline'
+ }
+}
+
+// 상태 텍스트 변환
+const getStatusText = (status: string) => {
+ switch (status) {
+ case 'Draft':
+ return '임시저장'
+ case 'Request to Review':
+ return '조건검토요청'
+ case 'Confirm to Review':
+ return '조건검토완료'
+ case 'Contract Accept Request':
+ return '계약승인요청'
+ case 'Complete the Contract':
+ return '계약체결'
+ case 'Reject to Accept Contract':
+ return '계약승인거절'
+ case 'Contract Delete':
+ return '계약폐기'
+ case 'PCR Request':
+ return 'PCR요청'
+ case 'VO Request':
+ return 'VO요청'
+ case 'PCR Accept':
+ return 'PCR승인'
+ case 'PCR Reject':
+ return 'PCR거절'
+ default:
+ return status
+ }
+}
+
+// 계약구분 텍스트 변환
+const getCategoryText = (category: string) => {
+ switch (category) {
+ case 'unit_price':
+ return '단가계약'
+ case 'general':
+ return '일반계약'
+ case 'sale':
+ return '매각계약'
+ default:
+ return category
+ }
+}
+
+// 계약종류 텍스트 변환
+const getTypeText = (type: string) => {
+ switch (type) {
+ case 'UP':
+ return '자재단가계약'
+ case 'LE':
+ return '임대차계약'
+ case 'IL':
+ return '개별운송계약'
+ case 'AL':
+ return '연간운송계약'
+ case 'OS':
+ return '외주용역계약'
+ case 'OW':
+ return '도급계약'
+ case 'IS':
+ return '검사계약'
+ case 'LO':
+ return 'LOI'
+ case 'FA':
+ return 'FA'
+ case 'SC':
+ return '납품합의계약'
+ case 'OF':
+ return '클레임상계계약'
+ case 'AW':
+ return '사전작업합의'
+ case 'AD':
+ return '사전납품합의'
+ case 'AM':
+ return '설계계약'
+ case 'SC_SELL':
+ return '폐기물매각계약'
+ default:
+ return type
+ }
+}
+
+// 체결방식 텍스트 변환
+const getExecutionMethodText = (method: string) => {
+ switch (method) {
+ case '전자계약':
+ return '전자계약'
+ case '오프라인계약':
+ return '오프라인계약'
+ default:
+ return method
+ }
+}
+
+// 업체선정방법 텍스트 변환
+const getcontractSourceTypeText = (method?: string) => {
+ if (!method) return '-'
+ switch (method) {
+ case 'estimate':
+ return '견적'
+ case 'bid':
+ return '입찰'
+ case 'manual':
+ return '자체생성'
+ default:
+ return method
+ }
+}
+
+// 금액 포맷팅
+const formatCurrency = (amount: string | number | null | undefined, currency = 'KRW') => {
+ if (!amount && amount !== 0) return '-'
+
+ const numAmount = typeof amount === 'string' ? parseFloat(amount) : amount
+ if (isNaN(numAmount)) return '-'
+
+ // 통화 코드가 null이거나 유효하지 않은 경우 기본값 사용
+ const safeCurrency = currency && typeof currency === 'string' ? currency : 'USD'
+
+ return new Intl.NumberFormat('ko-KR', {
+ style: 'currency',
+ currency: safeCurrency,
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 0,
+ }).format(numAmount)
+}
+
+export function getGeneralContractsColumns({ setRowAction }: GetColumnsProps): ColumnDef<GeneralContractListItem>[] {
+ return [
+ // ═══════════════════════════════════════════════════════════════
+ // 선택 및 기본 정보
+ // ═══════════════════════════════════════════════════════════════
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
+ onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
+ aria-label="select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(v) => row.toggleSelected(!!v)}
+ aria-label="select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ },
+
+ // ░░░ 계약번호 ░░░
+ {
+ accessorKey: "contractNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약번호 (Rev.)" />,
+ cell: ({ row }) => (
+ <div className="font-mono text-sm">
+ {row.original.contractNumber}
+ {row.original.revision > 0 && (
+ <span className="ml-1 text-xs text-muted-foreground">
+ Rev.{row.original.revision}
+ </span>
+ )}
+ </div>
+ ),
+ size: 150,
+ meta: { excelHeader: "계약번호 (Rev.)" },
+ },
+
+ // ░░░ 계약상태 ░░░
+ {
+ accessorKey: "status",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약상태" />,
+ cell: ({ row }) => (
+ <Badge variant={getStatusBadgeVariant(row.original.status)}>
+ {getStatusText(row.original.status)}
+ </Badge>
+ ),
+ size: 120,
+ meta: { excelHeader: "계약상태" },
+ },
+
+ // ░░░ 계약명 ░░░
+ {
+ accessorKey: "name",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약명" />,
+ cell: ({ row }) => (
+ <div className="truncate max-w-[200px]" title={row.original.name}>
+ <Button
+ variant="link"
+ className="p-0 h-auto text-left justify-start"
+ onClick={() => setRowAction({ row, type: "view" })}
+ >
+ {row.original.name}
+ </Button>
+ </div>
+ ),
+ size: 200,
+ meta: { excelHeader: "계약명" },
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // 계약 정보
+ // ═══════════════════════════════════════════════════════════════
+ {
+ header: "계약 정보",
+ columns: [
+ {
+ accessorKey: "category",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약구분" />,
+ cell: ({ row }) => (
+ <Badge variant="outline">
+ {getCategoryText(row.original.category)}
+ </Badge>
+ ),
+ size: 100,
+ meta: { excelHeader: "계약구분" },
+ },
+
+ {
+ accessorKey: "type",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약종류" />,
+ cell: ({ row }) => (
+ <Badge variant="secondary">
+ {getTypeText(row.original.type)}
+ </Badge>
+ ),
+ size: 120,
+ meta: { excelHeader: "계약종류" },
+ },
+
+ {
+ accessorKey: "executionMethod",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="체결방식" />,
+ cell: ({ row }) => (
+ <Badge variant="outline">
+ {getExecutionMethodText(row.original.executionMethod)}
+ </Badge>
+ ),
+ size: 100,
+ meta: { excelHeader: "체결방식" },
+ },
+
+ {
+ accessorKey: "contractSourceType",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="업체선정방법" />,
+ cell: ({ row }) => (
+ <Badge variant="outline">
+ {getcontractSourceTypeText(row.original.contractSourceType)}
+ </Badge>
+ ),
+ size: 200,
+ meta: { excelHeader: "업체선정방법" },
+ },
+ ]
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // 협력업체 정보
+ // ═══════════════════════════════════════════════════════════════
+ {
+ header: "협력업체",
+ columns: [
+ {
+ accessorKey: "vendorName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="협력업체명" />,
+ cell: ({ row }) => (
+ <div className="flex flex-col">
+ <span className="font-medium">{row.original.vendorName || '-'}</span>
+ <span className="text-xs text-muted-foreground">
+ {row.original.vendorCode ? row.original.vendorCode : "-"}
+ </span>
+ </div>
+ ),
+ size: 150,
+ meta: { excelHeader: "협력업체명" },
+ },
+
+ {
+ accessorKey: "projectName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트명" />,
+ cell: ({ row }) => (
+ <div className="flex flex-col">
+ <span className="font-medium">{row.original.projectName || '-'}</span>
+ <span className="text-xs text-muted-foreground">
+ {row.original.projectCode ? row.original.projectCode : "-"}
+ </span>
+ </div>
+ ),
+ size: 150,
+ meta: { excelHeader: "프로젝트명" },
+ },
+
+ ]
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // 기간 정보
+ // ═══════════════════════════════════════════════════════════════
+ {
+ header: "계약기간",
+ columns: [
+ {
+ id: "contractPeriod",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약기간" />,
+ cell: ({ row }) => {
+ const startDate = row.original.startDate
+ const endDate = row.original.endDate
+
+ if (!startDate || !endDate) return <span className="text-muted-foreground">-</span>
+
+ const now = new Date()
+ const isActive = now >= new Date(startDate) && now <= new Date(endDate)
+ const isExpired = now > new Date(endDate)
+
+ return (
+ <div className="text-xs">
+ <div className={`${isActive ? 'text-green-600 font-medium' : isExpired ? 'text-red-600' : 'text-gray-600'}`}>
+ {formatDate(startDate, "KR")} ~ {formatDate(endDate, "KR")}
+ </div>
+ {isActive && (
+ <Badge variant="default" className="text-xs mt-1">진행중</Badge>
+ )}
+ {isExpired && (
+ <Badge variant="destructive" className="text-xs mt-1">만료</Badge>
+ )}
+ </div>
+ )
+ },
+ size: 200,
+ meta: { excelHeader: "계약기간" },
+ },
+ ]
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // 금액 정보
+ // ═══════════════════════════════════════════════════════════════
+ {
+ header: "금액 정보",
+ columns: [
+ {
+ accessorKey: "currency",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="통화" />,
+ cell: ({ row }) => (
+ <span className="font-mono text-sm">{row.original.currency || 'KRW'}</span>
+ ),
+ size: 60,
+ meta: { excelHeader: "통화" },
+ },
+
+ {
+ accessorKey: "contractAmount",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약금액" />,
+ cell: ({ row }) => (
+ <span className="text-sm font-medium">
+ {formatCurrency(row.original.contractAmount, row.original.currency)}
+ </span>
+ ),
+ size: 200,
+ meta: { excelHeader: "계약금액" },
+ },
+ ]
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // 담당자 및 관리 정보
+ // ═══════════════════════════════════════════════════════════════
+ {
+ header: "관리 정보",
+ columns: [
+ {
+ accessorKey: "managerName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약담당자" />,
+ cell: ({ row }) => (
+ <div className="truncate max-w-[100px]" title={row.original.managerName || ''}>
+ {row.original.managerName || '-'}
+ </div>
+ ),
+ size: 100,
+ meta: { excelHeader: "계약담당자" },
+ },
+
+ {
+ accessorKey: "registeredAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약등록일" />,
+ cell: ({ row }) => (
+ <span className="text-sm">{formatDate(row.original.registeredAt, "KR")}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "계약등록일" },
+ },
+
+ {
+ accessorKey: "signedAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="계약체결일" />,
+ cell: ({ row }) => (
+ <span className="text-sm">
+ {row.original.signedAt ? formatDate(row.original.signedAt, "KR") : '-'}
+ </span>
+ ),
+ size: 100,
+ meta: { excelHeader: "계약체결일" },
+ },
+
+ {
+ accessorKey: "linkedPoNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="연계 PO번호" />,
+ cell: ({ row }) => (
+ <span className="font-mono text-sm">{row.original.linkedPoNumber || '-'}</span>
+ ),
+ size: 140,
+ meta: { excelHeader: "연계 PO번호" },
+ },
+
+ {
+ accessorKey: "lastUpdatedAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종수정일" />,
+ cell: ({ row }) => (
+ <span className="text-sm">{formatDate(row.original.lastUpdatedAt, "KR")}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "최종수정일" },
+ },
+
+ {
+ accessorKey: "lastUpdatedByName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="최종수정자" />,
+ cell: ({ row }) => (
+ <span className="text-sm">{row.original.lastUpdatedByName || '-'}</span>
+ ),
+ size: 100,
+ meta: { excelHeader: "최종수정자" },
+ },
+ ]
+ },
+
+ // ░░░ 비고 ░░░
+ {
+ accessorKey: "notes",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="비고" />,
+ cell: ({ row }) => (
+ <div className="truncate max-w-[150px]" title={row.original.notes || ''}>
+ {row.original.notes || '-'}
+ </div>
+ ),
+ size: 150,
+ meta: { excelHeader: "비고" },
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // 액션
+ // ═══════════════════════════════════════════════════════════════
+ {
+ id: "actions",
+ header: "액션",
+ cell: ({ row }) => (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" className="h-8 w-8 p-0">
+ <span className="sr-only">메뉴 열기</span>
+ <MoreHorizontal className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ {row.original.status !== 'Contract Delete' && (
+ <>
+ <DropdownMenuItem onClick={() => setRowAction({ row, type: "view" })}>
+ <Eye className="mr-2 h-4 w-4" />
+ 상세보기
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={() => setRowAction({ row, type: "update" })}>
+ <Edit className="mr-2 h-4 w-4" />
+ 수정
+ </DropdownMenuItem>
+ </>
+ )}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ ),
+ size: 50,
+ enableSorting: false,
+ enableHiding: false,
+ },
+ ]
+}
diff --git a/lib/general-contracts_old/main/general-contracts-table-toolbar-actions.tsx b/lib/general-contracts_old/main/general-contracts-table-toolbar-actions.tsx
new file mode 100644
index 00000000..f16b759a
--- /dev/null
+++ b/lib/general-contracts_old/main/general-contracts-table-toolbar-actions.tsx
@@ -0,0 +1,124 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import {
+ Download, FileSpreadsheet,
+ Trash2,
+} from "lucide-react"
+import { deleteContract } from "../service"
+import { toast } from "sonner"
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { GeneralContractListItem } from "./general-contracts-table-columns"
+import { CreateGeneralContractDialog } from "./create-general-contract-dialog"
+
+interface GeneralContractsTableToolbarActionsProps {
+ table: Table<GeneralContractListItem>
+}
+
+export function GeneralContractsTableToolbarActions({ table }: GeneralContractsTableToolbarActionsProps) {
+ const [isExporting, setIsExporting] = React.useState(false)
+
+ // 선택된 계약들
+ const selectedContracts = React.useMemo(() => {
+ return table
+ .getFilteredSelectedRowModel()
+ .rows
+ .map(row => row.original)
+ }, [table.getFilteredSelectedRowModel().rows])
+
+ const handleExport = async () => {
+ try {
+ setIsExporting(true)
+ await exportTableToExcel(table, {
+ filename: "general-contracts",
+ excludeColumns: ["select", "actions"],
+ })
+ toast.success("계약 목록이 성공적으로 내보내졌습니다.")
+ } catch (error) {
+ toast.error("내보내기 중 오류가 발생했습니다.")
+ } finally {
+ setIsExporting(false)
+ }
+ }
+
+
+ const handleDelete = async () => {
+ if (selectedContracts.length === 0) {
+ toast.error("계약폐기할 계약을 선택해주세요.")
+ return
+ }
+
+ // // 계약폐기 확인
+ // const confirmed = window.confirm(
+ // `선택한 ${selectedContracts.length}개 계약을 폐기하시겠습니까?\n계약폐기 후에는 복구할 수 없습니다.`
+ // )
+
+ // if (!confirmed) return
+
+ try {
+ // 선택된 모든 계약을 폐기 처리
+ const deletePromises = selectedContracts.map(contract =>
+ deleteContract(contract.id)
+ )
+
+ await Promise.all(deletePromises)
+
+ toast.success(`${selectedContracts.length}개 계약이 폐기되었습니다.`)
+
+ // 테이블 새로고침
+ } catch (error) {
+ console.error('Error deleting contracts:', error)
+ toast.error("계약폐기 중 오류가 발생했습니다.")
+ }
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ {/* 신규 등록 */}
+ <CreateGeneralContractDialog />
+
+ {/* 계약폐기 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleDelete}
+ disabled={selectedContracts.length === 0}
+ className="text-red-600 hover:text-red-700 hover:bg-red-50"
+ >
+ <Trash2 className="mr-2 h-4 w-4" />
+ 계약폐기
+ </Button>
+
+ {/* Export */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ className="gap-2"
+ disabled={isExporting}
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">
+ {isExporting ? "내보내는 중..." : "Export"}
+ </span>
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem onClick={handleExport} disabled={isExporting}>
+ <FileSpreadsheet className="mr-2 size-4" />
+ <span>계약 목록 내보내기</span>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ )
+}
diff --git a/lib/general-contracts_old/main/general-contracts-table.tsx b/lib/general-contracts_old/main/general-contracts-table.tsx
new file mode 100644
index 00000000..e4c96ee3
--- /dev/null
+++ b/lib/general-contracts_old/main/general-contracts-table.tsx
@@ -0,0 +1,217 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { getGeneralContractsColumns, GeneralContractListItem } from "./general-contracts-table-columns"
+import { getGeneralContracts, getGeneralContractStatusCounts } from "@/lib/general-contracts/service"
+import { GeneralContractsTableToolbarActions } from "./general-contracts-table-toolbar-actions"
+import { GeneralContractUpdateSheet } from "./general-contract-update-sheet"
+import {
+ GENERAL_EXECUTION_METHODS
+} from "@/lib/general-contracts/types"
+
+// 상태 라벨 매핑
+const contractStatusLabels = {
+ 'Draft': '임시저장',
+ 'Request to Review': '조건검토요청',
+ 'Confirm to Review': '조건검토완료',
+ 'Contract Accept Request': '계약승인요청',
+ 'Complete the Contract': '계약체결',
+ 'Reject to Accept Contract': '계약승인거절',
+ 'Contract Delete': '계약폐기',
+ 'PCR Request': 'PCR요청',
+ 'VO Request': 'VO요청',
+ 'PCR Accept': 'PCR승인',
+ 'PCR Reject': 'PCR거절'
+}
+
+// 계약구분 라벨 매핑
+const contractCategoryLabels = {
+ '단가계약': '단가계약',
+ '일반계약': '일반계약',
+ '매각계약': '매각계약'
+}
+
+// 계약종류 라벨 매핑
+const contractTypeLabels = {
+ 'UP': '자재단가계약',
+ 'LE': '임대차계약',
+ 'IL': '개별운송계약',
+ 'AL': '연간운송계약',
+ 'OS': '외주용역계약',
+ 'OW': '도급계약',
+ 'IS': '검사계약',
+ 'LO': 'LOI',
+ 'FA': 'FA',
+ 'SC': '납품합의계약',
+ 'OF': '클레임상계계약',
+ 'AW': '사전작업합의',
+ 'AD': '사전납품합의',
+ 'AM': '설계계약',
+ 'SC_SELL': '폐기물매각계약'
+}
+
+interface GeneralContractsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getGeneralContracts>>,
+ Awaited<ReturnType<typeof getGeneralContractStatusCounts>>
+ ]
+ >
+}
+
+export function GeneralContractsTable({ promises }: GeneralContractsTableProps) {
+ const [{ data, pageCount }, statusCounts] = React.use(promises)
+ const [isCompact, setIsCompact] = React.useState<boolean>(false)
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<GeneralContractListItem> | null>(null)
+ const [updateSheetOpen, setUpdateSheetOpen] = React.useState(false)
+ const [selectedContract, setSelectedContract] = React.useState<GeneralContractListItem | null>(null)
+
+ console.log(data, "data")
+
+ const router = useRouter()
+
+ const columns = React.useMemo(
+ () => getGeneralContractsColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ // rowAction 변경 감지하여 해당 액션 처리
+ React.useEffect(() => {
+ if (rowAction) {
+ setSelectedContract(rowAction.row.original)
+
+ switch (rowAction.type) {
+ case "view":
+ // 상세 페이지로 이동
+ router.push(`/evcp/general-contracts/${rowAction.row.original.id}`)
+ break
+ case "update":
+ // 수정 시트 열기
+ setSelectedContract(rowAction.row.original)
+ setUpdateSheetOpen(true)
+ break
+ default:
+ break
+ }
+ }
+ }, [rowAction, router])
+
+ const filterFields: DataTableFilterField<GeneralContractListItem>[] = []
+
+ const advancedFilterFields: DataTableAdvancedFilterField<GeneralContractListItem>[] = [
+ { id: "name", label: "계약명", type: "text" },
+ { id: "contractNumber", label: "계약번호", type: "text" },
+ { id: "vendorName", label: "협력업체명", type: "text" },
+ { id: "managerName", label: "계약담당자", type: "text" },
+ {
+ id: "status",
+ label: "계약상태",
+ type: "multi-select",
+ options: Object.entries(contractStatusLabels).map(([value, label]) => ({
+ label,
+ value,
+ count: statusCounts[value] || 0,
+ })),
+ },
+ {
+ id: "category",
+ label: "계약구분",
+ type: "select",
+ options: Object.entries(contractCategoryLabels).map(([value, label]) => ({
+ label,
+ value,
+ })),
+ },
+ {
+ id: "type",
+ label: "계약종류",
+ type: "select",
+ options: Object.entries(contractTypeLabels).map(([value, label]) => ({
+ label,
+ value,
+ })),
+ },
+ {
+ id: "executionMethod",
+ label: "체결방식",
+ type: "select",
+ options: GENERAL_EXECUTION_METHODS.map(value => ({
+ label: value,
+ value: value,
+ })),
+ },
+ {
+ id: "contractSourceType",
+ label: "업체선정방법",
+ type: "select",
+ options: [
+ { label: "estimate", value: "견적" },
+ { label: "bid", value: "입찰" },
+ { label: "manual", value: "자체생성" },
+ ],
+ },
+ { id: "registeredAt", label: "계약등록일", type: "date" },
+ { id: "signedAt", label: "계약체결일", type: "date" },
+ { id: "lastUpdatedAt", label: "최종수정일", type: "date" },
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "registeredAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ const handleCompactChange = React.useCallback((compact: boolean) => {
+ setIsCompact(compact)
+ }, [])
+
+ return (
+ <>
+ <DataTable
+ table={table}
+ compact={isCompact}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ enableCompactToggle={true}
+ compactStorageKey="generalContractsTableCompact"
+ onCompactChange={handleCompactChange}
+ >
+ <GeneralContractsTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ <GeneralContractUpdateSheet
+ contract={selectedContract}
+ open={updateSheetOpen}
+ onOpenChange={setUpdateSheetOpen}
+ onSuccess={() => {
+ // 테이블 새로고침 또는 상태 업데이트
+ window.location.reload()
+ }}
+ />
+ </>
+ )
+}
diff --git a/lib/general-contracts_old/service.ts b/lib/general-contracts_old/service.ts
new file mode 100644
index 00000000..2422706a
--- /dev/null
+++ b/lib/general-contracts_old/service.ts
@@ -0,0 +1,1933 @@
+'use server'
+
+import { revalidatePath } from 'next/cache'
+import { eq, and, or, desc, asc, count, ilike, SQL, gte, lte, lt, like, sql } from 'drizzle-orm'
+import db from '@/db/db'
+import path from 'path'
+import { promises as fs } from 'fs'
+import { generalContracts, generalContractItems, generalContractAttachments } from '@/db/schema/generalContract'
+import { contracts, contractItems, contractEnvelopes, contractSigners } from '@/db/schema/contract'
+import { basicContract, basicContractTemplates } from '@/db/schema/basicContractDocumnet'
+import { vendors } from '@/db/schema/vendors'
+import { users } from '@/db/schema/users'
+import { projects } from '@/db/schema/projects'
+import { items } from '@/db/schema/items'
+import { filterColumns } from '@/lib/filter-columns'
+import { saveDRMFile } from '@/lib/file-stroage'
+import { decryptWithServerAction } from '@/components/drm/drmUtils'
+import { saveBuffer } from '@/lib/file-stroage'
+import { v4 as uuidv4 } from 'uuid'
+import { GetGeneralContractsSchema } from './validation'
+import { sendEmail } from '../mail/sendEmail'
+
+export async function getGeneralContracts(input: GetGeneralContractsSchema) {
+ try {
+ const offset = (input.page - 1) * input.perPage
+
+ console.log(input.filters)
+ console.log(input.sort)
+
+ // ✅ 1) 고급 필터 조건
+ let advancedWhere: SQL<unknown> | undefined = undefined
+ if (input.filters && input.filters.length > 0) {
+ advancedWhere = filterColumns({
+ table: generalContracts,
+ filters: input.filters as any,
+ joinOperator: input.joinOperator || 'and',
+ })
+ }
+
+ // ✅ 2) 기본 필터 조건들
+ const basicConditions: SQL<unknown>[] = []
+
+ if (input.contractNumber) {
+ basicConditions.push(ilike(generalContracts.contractNumber, `%${input.contractNumber}%`))
+ }
+
+ if (input.name) {
+ basicConditions.push(ilike(generalContracts.name, `%${input.name}%`))
+ }
+
+ if (input.status && input.status.length > 0) {
+ basicConditions.push(
+ or(...input.status.map(status => eq(generalContracts.status, status)))!
+ )
+ }
+
+ if (input.category && input.category.length > 0) {
+ basicConditions.push(
+ or(...input.category.map(category => eq(generalContracts.category, category)))!
+ )
+ }
+
+ if (input.type && input.type.length > 0) {
+ basicConditions.push(
+ or(...input.type.map(type => eq(generalContracts.type, type)))!
+ )
+ }
+
+ if (input.executionMethod && input.executionMethod.length > 0) {
+ basicConditions.push(
+ or(...input.executionMethod.map(method => eq(generalContracts.executionMethod, method)))!
+ )
+ }
+
+ if (input.contractSourceType && input.contractSourceType.length > 0) {
+ basicConditions.push(
+ or(...input.contractSourceType.map(method => eq(generalContracts.contractSourceType, method)))!
+ )
+ }
+
+ if (input.vendorId && input.vendorId > 0) {
+ basicConditions.push(eq(generalContracts.vendorId, input.vendorId))
+ }
+
+ if (input.managerName) {
+ basicConditions.push(ilike(users.name, `%${input.managerName}%`))
+ }
+
+ // 날짜 필터들
+ if (input.registeredAtFrom) {
+ basicConditions.push(gte(generalContracts.registeredAt, new Date(input.registeredAtFrom)))
+ }
+ if (input.registeredAtTo) {
+ basicConditions.push(lte(generalContracts.registeredAt, new Date(input.registeredAtTo)))
+ }
+
+ if (input.signedAtFrom) {
+ basicConditions.push(gte(generalContracts.signedAt, new Date(input.signedAtFrom)))
+ }
+ if (input.signedAtTo) {
+ basicConditions.push(lte(generalContracts.signedAt, new Date(input.signedAtTo)))
+ }
+
+ if (input.startDateFrom) {
+ basicConditions.push(gte(generalContracts.startDate, new Date(input.startDateFrom)))
+ }
+ if (input.startDateTo) {
+ basicConditions.push(lte(generalContracts.startDate, new Date(input.startDateTo)))
+ }
+
+ if (input.endDateFrom) {
+ basicConditions.push(gte(generalContracts.endDate, new Date(input.endDateFrom)))
+ }
+ if (input.endDateTo) {
+ basicConditions.push(lte(generalContracts.endDate, new Date(input.endDateTo)))
+ }
+
+ // 금액 필터들
+ if (input.contractAmountMin) {
+ basicConditions.push(gte(generalContracts.contractAmount, parseFloat(input.contractAmountMin)))
+ }
+ if (input.contractAmountMax) {
+ basicConditions.push(lte(generalContracts.contractAmount, parseFloat(input.contractAmountMax)))
+ }
+
+ const basicWhere = basicConditions.length > 0 ? and(...basicConditions) : undefined
+
+ // ✅ 3) 글로벌 검색 조건
+ let globalWhere: SQL<unknown> | undefined = undefined
+ if (input.search) {
+ const s = `%${input.search}%`
+ const searchConditions = [
+ ilike(generalContracts.contractNumber, s),
+ ilike(generalContracts.name, s),
+ ilike(generalContracts.notes, s),
+ ilike(vendors.vendorName, s),
+ ilike(users.name, s),
+ ilike(generalContracts.linkedPoNumber, s),
+ ilike(generalContracts.linkedRfqOrItb, s),
+ ilike(generalContracts.linkedBidNumber, s),
+ ]
+ globalWhere = or(...searchConditions)
+ }
+
+ // ✅ 4) 최종 WHERE 조건
+ const whereConditions: SQL<unknown>[] = []
+ if (advancedWhere) whereConditions.push(advancedWhere)
+ if (basicWhere) whereConditions.push(basicWhere)
+ if (globalWhere) whereConditions.push(globalWhere)
+
+ const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined
+
+ // ✅ 5) 전체 개수 조회
+ const totalResult = await db
+ .select({ count: count() })
+ .from(generalContracts)
+ .leftJoin(vendors, eq(generalContracts.vendorId, vendors.id))
+ .leftJoin(users, eq(generalContracts.registeredById, users.id))
+ .where(finalWhere)
+
+ const total = totalResult[0]?.count || 0
+
+ if (total === 0) {
+ return { data: [], pageCount: 0, total: 0 }
+ }
+
+ console.log("Total contracts:", total)
+
+ // ✅ 6) 정렬 및 페이징
+ const orderByColumns: any[] = []
+
+ for (const sort of input.sort) {
+ const column = sort.id
+
+ // generalContracts 테이블의 컬럼들
+ if (column in generalContracts) {
+ const contractColumn = generalContracts[column as keyof typeof generalContracts]
+ orderByColumns.push(sort.desc ? desc(contractColumn) : asc(contractColumn))
+ }
+ // vendors 테이블의 컬럼들
+ else if (column === 'vendorName' || column === 'vendorCode') {
+ const vendorColumn = vendors[column as keyof typeof vendors]
+ orderByColumns.push(sort.desc ? desc(vendorColumn) : asc(vendorColumn))
+ }
+ // users 테이블의 컬럼들
+ else if (column === 'managerName' || column === 'lastUpdatedByName') {
+ const userColumn = users.name
+ orderByColumns.push(sort.desc ? desc(userColumn) : asc(userColumn))
+ }
+ }
+
+ if (orderByColumns.length === 0) {
+ orderByColumns.push(desc(generalContracts.registeredAt))
+ }
+
+ // ✅ 7) 메인 쿼리
+ const data = await db
+ .select({
+ id: generalContracts.id,
+ contractNumber: generalContracts.contractNumber,
+ revision: generalContracts.revision,
+ status: generalContracts.status,
+ category: generalContracts.category,
+ type: generalContracts.type,
+ executionMethod: generalContracts.executionMethod,
+ name: generalContracts.name,
+ contractSourceType: generalContracts.contractSourceType,
+ startDate: generalContracts.startDate,
+ endDate: generalContracts.endDate,
+ validityEndDate: generalContracts.validityEndDate,
+ contractScope: generalContracts.contractScope,
+ specificationType: generalContracts.specificationType,
+ specificationManualText: generalContracts.specificationManualText,
+ contractAmount: generalContracts.contractAmount,
+ totalAmount: generalContracts.totalAmount,
+ currency: generalContracts.currency,
+ registeredAt: generalContracts.registeredAt,
+ signedAt: generalContracts.signedAt,
+ linkedRfqOrItb: generalContracts.linkedRfqOrItb,
+ linkedPoNumber: generalContracts.linkedPoNumber,
+ linkedBidNumber: generalContracts.linkedBidNumber,
+ lastUpdatedAt: generalContracts.lastUpdatedAt,
+ notes: generalContracts.notes,
+ // Vendor info
+ vendorId: generalContracts.vendorId,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ // Project info
+ projectId: generalContracts.projectId,
+ projectName: projects.name,
+ projectCode: projects.code,
+ // User info
+ managerName: users.name,
+ lastUpdatedByName: users.name,
+ })
+ .from(generalContracts)
+ .leftJoin(vendors, eq(generalContracts.vendorId, vendors.id))
+ .leftJoin(users, eq(generalContracts.registeredById, users.id))
+ .leftJoin(projects, eq(generalContracts.projectId, projects.id))
+ .where(finalWhere)
+ .orderBy(...orderByColumns)
+ .limit(input.perPage)
+ .offset(offset)
+
+ const pageCount = Math.ceil(total / input.perPage)
+
+ return { data, pageCount, total }
+
+ } catch (err) {
+ console.error("Error in getGeneralContracts:", err)
+ return { data: [], pageCount: 0, total: 0 }
+ }
+}
+
+export async function getContractById(id: number) {
+ try {
+ // ID 유효성 검사
+ if (!id || isNaN(id) || id <= 0) {
+ throw new Error('Invalid contract ID')
+ }
+
+ const contract = await db
+ .select()
+ .from(generalContracts)
+ .where(eq(generalContracts.id, id))
+ .limit(1)
+
+ if (!contract.length) {
+ throw new Error('Contract not found')
+ }
+
+ // Get contract items
+ const items = await db
+ .select()
+ .from(generalContractItems)
+ .where(eq(generalContractItems.contractId, id))
+
+ // Get contract attachments
+ const attachments = await db
+ .select()
+ .from(generalContractAttachments)
+ .where(eq(generalContractAttachments.contractId, id))
+
+ // Get vendor info
+ const vendor = await db
+ .select()
+ .from(vendors)
+ .where(eq(vendors.id, contract[0].vendorId))
+ .limit(1)
+
+ // Get project info
+ const project = contract[0].projectId ? await db
+ .select()
+ .from(projects)
+ .where(eq(projects.id, contract[0].projectId))
+ .limit(1) : null
+
+ // Get manager info
+ const manager = await db
+ .select()
+ .from(users)
+ .where(eq(users.id, contract[0].registeredById))
+ .limit(1)
+
+ return {
+ ...contract[0],
+ contractItems: items,
+ attachments,
+ vendor: vendor[0] || null,
+ vendorCode: vendor[0]?.vendorCode || null,
+ vendorName: vendor[0]?.vendorName || null,
+ project: project ? project[0] : null,
+ projectName: project ? project[0].name : null,
+ projectCode: project ? project[0].code : null,
+ manager: manager[0] || null
+ }
+ } catch (error) {
+ console.error('Error fetching contract by ID:', error)
+ throw new Error('Failed to fetch contract')
+ }
+}
+
+export async function getContractBasicInfo(id: number) {
+ try {
+ const [contract] = await db
+ .select()
+ .from(generalContracts)
+ .where(eq(generalContracts.id, id))
+ .limit(1)
+
+ if (!contract) {
+ return null
+ }
+
+ // JSON 필드를 문자열에서 객체로 변환하여 클라이언트에서 사용하기 쉽게 만듭니다.
+ // Drizzle ORM이 JSONB 타입을 처리하지만, 명확성을 위해 명시적으로 파싱하는 것이 좋습니다.
+ const parsedContract = {
+ ...contract,
+ warrantyPeriod: contract.warrantyPeriod as any,
+ paymentBeforeDelivery: contract.paymentBeforeDelivery as any,
+ paymentAfterDelivery: contract.paymentAfterDelivery as any,
+ contractEstablishmentConditions: contract.contractEstablishmentConditions as any,
+ mandatoryDocuments: contract.mandatoryDocuments as any,
+ contractTerminationConditions: contract.contractTerminationConditions as any,
+ }
+
+ // 품목정보 총합 계산 로직 (기존 코드와 동일)
+ const contractItems = await db
+ .select()
+ .from(generalContractItems)
+ .where(eq(generalContractItems.contractId, id))
+
+ let calculatedContractAmount = null
+ if (contractItems && contractItems.length > 0) {
+ calculatedContractAmount = contractItems.reduce((sum, item) => {
+ const amount = parseFloat(item.contractAmount || '0')
+ return sum + amount
+ }, 0)
+ }
+
+ return {
+ ...parsedContract,
+ contractAmount: calculatedContractAmount,
+ }
+
+ } catch (error) {
+ console.error('Error getting contract basic info:', error)
+ throw new Error('Failed to fetch contract basic info')
+ }
+}
+
+export async function createContract(data: Record<string, unknown>) {
+ try {
+ // 계약번호 자동 생성
+ // TODO: 구매 발주담당자 코드 필요 - 파라미터 추가
+ const rawUserId = data.registeredById
+ const userId = (rawUserId && !isNaN(Number(rawUserId))) ? String(rawUserId) : undefined
+ const contractNumber = await generateContractNumber(
+ userId,
+ data.type as string
+ )
+
+ const [newContract] = await db
+ .insert(generalContracts)
+ .values({
+ contractNumber: contractNumber,
+ revision: 0,
+ // contractSourceType: data.contractSourceType || 'manual',
+ status: data.status || 'Draft',
+ category: data.category as string,
+ type: data.type as string,
+ executionMethod: data.executionMethod as string,
+ name: data.name as string,
+ vendorId: data.vendorId as number,
+ projectId: data.projectId as number,
+ startDate: data.startDate as string,
+ endDate: data.endDate as string,
+ validityEndDate: data.validityEndDate as string,
+ linkedRfqOrItb: data.linkedRfqOrItb as string,
+ linkedPoNumber: data.linkedPoNumber as string,
+ linkedBidNumber: data.linkedBidNumber as string,
+ contractScope: data.contractScope as string,
+ warrantyPeriod: data.warrantyPeriod || {},
+ specificationType: data.specificationType as string,
+ specificationManualText: data.specificationManualText as string,
+ unitPriceType: data.unitPriceType as string,
+ contractAmount: data.contractAmount as number,
+ currency: data.currency as string,
+ paymentBeforeDelivery: data.paymentBeforeDelivery || {},
+ paymentDelivery: data.paymentDelivery as string,
+ paymentAfterDelivery: data.paymentAfterDelivery || {},
+ paymentTerm: data.paymentTerm as string,
+ taxType: data.taxType as string,
+ liquidatedDamages: data.liquidatedDamages as number,
+ liquidatedDamagesPercent: data.liquidatedDamagesPercent as number,
+ deliveryType: data.deliveryType as string,
+ deliveryTerm: data.deliveryTerm as string,
+ shippingLocation: data.shippingLocation as string,
+ dischargeLocation: data.dischargeLocation as string,
+ contractDeliveryDate: data.contractDeliveryDate as string,
+ contractEstablishmentConditions: data.contractEstablishmentConditions || {},
+ interlockingSystem: data.interlockingSystem as string,
+ mandatoryDocuments: data.mandatoryDocuments || {},
+ contractTerminationConditions: data.contractTerminationConditions || {},
+ terms: data.terms || {},
+ complianceChecklist: data.complianceChecklist || {},
+ communicationChannels: data.communicationChannels || {},
+ locations: data.locations || {},
+ fieldServiceRates: data.fieldServiceRates || {},
+ offsetDetails: data.offsetDetails || {},
+ totalAmount: data.totalAmount as number,
+ availableBudget: data.availableBudget as number,
+ registeredById: data.registeredById as number,
+ lastUpdatedById: data.lastUpdatedById as number,
+ notes: data.notes as string,
+ })
+ .returning()
+ console.log(newContract,"newContract")
+
+
+ revalidatePath('/general-contracts')
+ return newContract
+ } catch (error) {
+ console.error('Error creating contract:', error)
+ throw new Error('Failed to create contract')
+ }
+}
+
+export async function updateContractBasicInfo(id: number, data: Record<string, unknown>, userId: number) {
+ try {
+ // 업데이트할 데이터 정리
+ // 클라이언트에서 전송된 formData를 그대로 사용합니다.
+ const {
+ specificationType,
+ specificationManualText,
+ unitPriceType,
+ warrantyPeriod,
+ currency,
+ linkedPoNumber,
+ linkedBidNumber,
+ notes,
+ paymentBeforeDelivery,
+ paymentDelivery,
+ paymentAfterDelivery,
+ paymentTerm,
+ taxType,
+ liquidatedDamages,
+ liquidatedDamagesPercent,
+ deliveryType,
+ deliveryTerm,
+ shippingLocation,
+ dischargeLocation,
+ contractDeliveryDate,
+ contractEstablishmentConditions,
+ interlockingSystem,
+ mandatoryDocuments,
+ contractTerminationConditions,
+ } = data
+
+ // 계약금액 자동 집계 로직
+ const contractItems = await db
+ .select()
+ .from(generalContractItems)
+ .where(eq(generalContractItems.contractId, id))
+
+ let calculatedContractAmount: number | null = null
+ if (contractItems && contractItems.length > 0) {
+ calculatedContractAmount = contractItems.reduce((sum, item) => {
+ const amount = parseFloat(item.contractAmount || '0')
+ return sum + amount
+ }, 0)
+ }
+
+ // 데이터 타입 변환 및 검증
+ const convertToNumberOrNull = (value: unknown): number | null => {
+ if (value === null || value === undefined || value === '' || value === 'false') {
+ return null
+ }
+ const num = typeof value === 'string' ? parseFloat(value) : Number(value)
+ return isNaN(num) ? null : num
+ }
+
+ // 날짜 필드에서 빈 문자열을 null로 변환
+ const convertEmptyStringToNull = (value: unknown): string | null => {
+ return (value === '' || value === undefined) ? null : value as string
+ }
+
+ // 업데이트할 데이터 객체 생성
+ const updateData: Record<string, unknown> = {
+ specificationType,
+ specificationManualText,
+ unitPriceType,
+ warrantyPeriod, // JSON 필드
+ currency,
+ linkedPoNumber,
+ linkedBidNumber,
+ notes,
+ paymentBeforeDelivery, // JSON 필드
+ paymentDelivery: convertToNumberOrNull(paymentDelivery),
+ paymentAfterDelivery, // JSON 필드
+ paymentTerm,
+ taxType,
+ liquidatedDamages: convertToNumberOrNull(liquidatedDamages),
+ liquidatedDamagesPercent: convertToNumberOrNull(liquidatedDamagesPercent),
+ deliveryType,
+ deliveryTerm,
+ shippingLocation,
+ dischargeLocation,
+ contractDeliveryDate: convertEmptyStringToNull(contractDeliveryDate),
+ contractEstablishmentConditions, // JSON 필드
+ interlockingSystem,
+ mandatoryDocuments, // JSON 필드
+ contractTerminationConditions, // JSON 필드
+ contractAmount: calculatedContractAmount || 0,
+ lastUpdatedAt: new Date(),
+ lastUpdatedById: userId,
+ }
+
+ // DB에 업데이트 실행
+ const [updatedContract] = await db
+ .update(generalContracts)
+ .set(updateData)
+ .where(eq(generalContracts.id, id))
+ .returning()
+
+ revalidatePath('/general-contracts')
+ revalidatePath(`/general-contracts/detail/${id}`)
+ return updatedContract
+ } catch (error) {
+ console.error('Error updating contract basic info:', error)
+ throw new Error('Failed to update contract basic info')
+ }
+}
+
+// 품목정보 조회
+export async function getContractItems(contractId: number) {
+ try {
+ const items = await db
+ .select()
+ .from(generalContractItems)
+ .where(eq(generalContractItems.contractId, contractId))
+ .orderBy(asc(generalContractItems.id))
+
+ return items
+ } catch (error) {
+ console.error('Error getting contract items:', error)
+ throw new Error('Failed to get contract items')
+ }
+}
+
+// 품목정보 생성
+export async function createContractItem(contractId: number, itemData: Record<string, unknown>) {
+ try {
+ const [newItem] = await db
+ .insert(generalContractItems)
+ .values({
+ contractId,
+ itemCode: itemData.itemCode as string,
+ itemInfo: itemData.itemInfo as string,
+ specification: itemData.specification as string,
+ quantity: itemData.quantity as number,
+ quantityUnit: itemData.quantityUnit as string,
+ contractDeliveryDate: itemData.contractDeliveryDate as string,
+ contractUnitPrice: itemData.contractUnitPrice as number,
+ contractAmount: itemData.contractAmount as number,
+ contractCurrency: itemData.contractCurrency as string,
+ })
+ .returning()
+
+ // 계약금액 자동 업데이트
+ await updateContractAmount(contractId)
+
+ revalidatePath('/general-contracts')
+ return newItem
+ } catch (error) {
+ console.error('Error creating contract item:', error)
+ throw new Error('Failed to create contract item')
+ }
+}
+
+// 품목정보 업데이트
+export async function updateContractItem(itemId: number, itemData: Record<string, unknown>) {
+ try {
+ const [updatedItem] = await db
+ .update(generalContractItems)
+ .set({
+ itemCode: itemData.itemCode as string,
+ itemInfo: itemData.itemInfo as string,
+ specification: itemData.specification as string,
+ quantity: itemData.quantity as number,
+ quantityUnit: itemData.quantityUnit as string,
+ contractDeliveryDate: itemData.contractDeliveryDate as string,
+ contractUnitPrice: itemData.contractUnitPrice as number,
+ contractAmount: itemData.contractAmount as number,
+ contractCurrency: itemData.contractCurrency as string,
+ updatedAt: new Date()
+ })
+ .where(eq(generalContractItems.id, itemId))
+ .returning()
+
+ // 계약금액 자동 업데이트
+ await updateContractAmount(updatedItem.contractId)
+
+ revalidatePath('/general-contracts')
+ return updatedItem
+ } catch (error) {
+ console.error('Error updating contract item:', error)
+ throw new Error('Failed to update contract item')
+ }
+}
+
+// 품목정보 삭제
+export async function deleteContractItem(itemId: number) {
+ try {
+ // 삭제 전 계약 ID 조회
+ const [item] = await db
+ .select({ contractId: generalContractItems.contractId })
+ .from(generalContractItems)
+ .where(eq(generalContractItems.id, itemId))
+ .limit(1)
+
+ if (!item) {
+ throw new Error('Contract item not found')
+ }
+
+ await db
+ .delete(generalContractItems)
+ .where(eq(generalContractItems.id, itemId))
+
+ // 계약금액 자동 업데이트
+ await updateContractAmount(item.contractId)
+
+ revalidatePath('/general-contracts')
+ return { success: true }
+ } catch (error) {
+ console.error('Error deleting contract item:', error)
+ throw new Error('Failed to delete contract item')
+ }
+}
+
+// 품목정보 일괄 업데이트 (기존 함수 개선)
+export async function updateContractItems(contractId: number, items: Record<string, unknown>[]) {
+ try {
+ // 기존 품목 삭제
+ await db
+ .delete(generalContractItems)
+ .where(eq(generalContractItems.contractId, contractId))
+
+ // 새 품목 추가
+ if (items && items.length > 0) {
+ await db
+ .insert(generalContractItems)
+ .values(
+ items.map((item: Record<string, unknown>) => ({
+ contractId,
+ itemCode: item.itemCode as string,
+ itemInfo: item.itemInfo as string,
+ specification: item.specification as string,
+ quantity: item.quantity as number,
+ quantityUnit: item.quantityUnit as string,
+ totalWeight: item.totalWeight as number,
+ weightUnit: item.weightUnit as string,
+ contractDeliveryDate: item.contractDeliveryDate as string,
+ contractUnitPrice: item.contractUnitPrice as number,
+ contractAmount: item.contractAmount as number,
+ contractCurrency: item.contractCurrency as string,
+ }))
+ )
+ }
+
+ // 계약금액 자동 업데이트
+ await updateContractAmount(contractId)
+
+ revalidatePath('/general-contracts')
+ return { success: true }
+ } catch (error) {
+ console.error('Error updating contract items:', error)
+ throw new Error('Failed to update contract items')
+ }
+}
+
+// 계약금액 자동 업데이트 헬퍼 함수
+async function updateContractAmount(contractId: number) {
+ try {
+ const items = await db
+ .select({ contractAmount: generalContractItems.contractAmount })
+ .from(generalContractItems)
+ .where(eq(generalContractItems.contractId, contractId))
+
+ let calculatedContractAmount: number | null = null
+ if (items && items.length > 0) {
+ calculatedContractAmount = items.reduce((sum, item) => {
+ const amount = parseFloat(String(item.contractAmount || '0'))
+ return sum + amount
+ }, 0)
+ }
+
+ // 계약 테이블의 contractAmount 업데이트
+ await db
+ .update(generalContracts)
+ .set({
+ contractAmount: calculatedContractAmount || 0,
+ lastUpdatedAt: new Date()
+ })
+ .where(eq(generalContracts.id, contractId))
+ } catch (error) {
+ console.error('Error updating contract amount:', error)
+ throw new Error('Failed to update contract amount')
+ }
+}
+
+export async function updateSubcontractChecklist(contractId: number, checklistData: Record<string, unknown>) {
+ try {
+ await db
+ .update(generalContracts)
+ .set({
+ complianceChecklist: checklistData,
+ lastUpdatedAt: new Date()
+ })
+ .where(eq(generalContracts.id, contractId))
+
+ revalidatePath('/general-contracts')
+ return { success: true }
+ } catch (error) {
+ console.error('Error updating subcontract checklist:', error)
+ throw new Error('Failed to update subcontract checklist')
+ }
+}
+
+export async function getSubcontractChecklist(contractId: number) {
+ try {
+ const result = await db
+ .select()
+ .from(generalContracts)
+ .where(eq(generalContracts.id, contractId))
+ .limit(1)
+
+ if (result.length === 0) {
+ return { success: false, error: '계약을 찾을 수 없습니다.' }
+ }
+
+ const contract = result[0]
+ const checklistData = contract.complianceChecklist as any
+
+ return {
+ success: true,
+ enabled: !!checklistData,
+ data: checklistData || {}
+ }
+ } catch (error) {
+ console.error('Error getting subcontract checklist:', error)
+ return { success: false, error: '하도급 체크리스트 조회에 실패했습니다.' }
+ }
+}
+
+export async function getBasicInfo(contractId: number) {
+ try {
+ const result = await db
+ .select()
+ .from(generalContracts)
+ .where(eq(generalContracts.id, contractId))
+ .limit(1)
+
+ if (result.length === 0) {
+ return { success: false, error: '계약을 찾을 수 없습니다.' }
+ }
+
+ const contract = result[0]
+ return {
+ success: true,
+ enabled: true, // basic-info는 항상 활성화
+ data: {
+ // 기본 정보
+ contractNumber: contract.contractNumber,
+ contractName: contract.name,
+ vendorId: contract.vendorId,
+ vendorName: contract.vendorName,
+ projectName: contract.projectName,
+ contractType: contract.type,
+ contractStatus: contract.status,
+ startDate: contract.startDate,
+ endDate: contract.endDate,
+ contractAmount: contract.contractAmount,
+ currency: contract.currency,
+ description: contract.description,
+ specificationType: contract.specificationType,
+ specificationManualText: contract.specificationManualText,
+ unitPriceType: contract.unitPriceType,
+ warrantyPeriod: contract.warrantyPeriod,
+ linkedPoNumber: contract.linkedPoNumber,
+ linkedBidNumber: contract.linkedBidNumber,
+ notes: contract.notes,
+
+ // 지급/인도 조건
+ paymentBeforeDelivery: contract.paymentBeforeDelivery,
+ paymentDelivery: contract.paymentDelivery,
+ paymentAfterDelivery: contract.paymentAfterDelivery,
+ paymentTerm: contract.paymentTerm,
+ taxType: contract.taxType,
+ liquidatedDamages: contract.liquidatedDamages,
+ liquidatedDamagesPercent: contract.liquidatedDamagesPercent,
+ deliveryType: contract.deliveryType,
+ deliveryTerm: contract.deliveryTerm,
+ shippingLocation: contract.shippingLocation,
+ dischargeLocation: contract.dischargeLocation,
+ contractDeliveryDate: contract.contractDeliveryDate,
+
+ // 추가 조건
+ contractEstablishmentConditions: contract.contractEstablishmentConditions,
+ interlockingSystem: contract.interlockingSystem,
+ mandatoryDocuments: contract.mandatoryDocuments,
+ contractTerminationConditions: contract.contractTerminationConditions
+ }
+ }
+ } catch (error) {
+ console.error('Error getting basic info:', error)
+ return { success: false, error: '기본 정보 조회에 실패했습니다.' }
+ }
+}
+
+
+export async function getCommunicationChannel(contractId: number) {
+ try {
+ const [contract] = await db
+ .select({
+ communicationChannels: generalContracts.communicationChannels
+ })
+ .from(generalContracts)
+ .where(eq(generalContracts.id, contractId))
+ .limit(1)
+
+ if (!contract) {
+ return null
+ }
+
+ return contract.communicationChannels as any
+ } catch (error) {
+ console.error('Error getting communication channel:', error)
+ throw new Error('Failed to get communication channel')
+ }
+}
+
+export async function updateCommunicationChannel(contractId: number, communicationData: Record<string, unknown>, userId: number) {
+ try {
+ await db
+ .update(generalContracts)
+ .set({
+ communicationChannels: communicationData,
+ lastUpdatedAt: new Date(),
+ lastUpdatedById: userId
+ })
+ .where(eq(generalContracts.id, contractId))
+
+ revalidatePath('/general-contracts')
+ return { success: true }
+ } catch (error) {
+ console.error('Error updating communication channel:', error)
+ throw new Error('Failed to update communication channel')
+ }
+}
+
+export async function updateLocation(contractId: number, locationData: Record<string, unknown>, userId: number) {
+ try {
+ await db
+ .update(generalContracts)
+ .set({
+ locations: locationData,
+ lastUpdatedAt: new Date(),
+ lastUpdatedById: userId
+ })
+ .where(eq(generalContracts.id, contractId))
+
+ revalidatePath('/general-contracts')
+ return { success: true }
+ } catch (error) {
+ console.error('Error updating location:', error)
+ throw new Error('Failed to update location')
+ }
+}
+
+export async function getLocation(contractId: number) {
+ try {
+ const [contract] = await db
+ .select({
+ locations: generalContracts.locations
+ })
+ .from(generalContracts)
+ .where(eq(generalContracts.id, contractId))
+ .limit(1)
+
+ if (!contract) {
+ return null
+ }
+
+ return contract.locations as any
+ } catch (error) {
+ console.error('Error getting location:', error)
+ throw new Error('Failed to get location')
+ }
+}
+
+export async function updateContract(id: number, data: Record<string, unknown>) {
+ try {
+ // 숫자 필드에서 빈 문자열을 null로 변환
+ const cleanedData = { ...data }
+ const numericFields = [
+ 'vendorId',
+ 'projectId',
+ 'warrantyPeriodValue',
+ 'warrantyPeriodMax',
+ 'contractAmount',
+ 'totalAmount',
+ 'availableBudget',
+ 'liquidatedDamages',
+ 'liquidatedDamagesPercent',
+ 'lastUpdatedById'
+ ]
+
+ // 모든 필드에서 빈 문자열, undefined, 빈 객체 등을 정리
+ Object.keys(cleanedData).forEach(key => {
+ const value = cleanedData[key]
+
+ // 빈 문자열을 null로 변환
+ if (value === '') {
+ cleanedData[key] = null
+ }
+
+ // 빈 객체를 null로 변환
+ if (value && typeof value === 'object' && Object.keys(value).length === 0) {
+ cleanedData[key] = null
+ }
+ })
+
+ // 숫자 필드들 추가 정리 (vendorId는 NOT NULL이므로 null로 설정하지 않음)
+ numericFields.forEach(field => {
+ if (field === 'vendorId') {
+ // vendorId는 필수 필드이므로 null로 설정하지 않음
+ if (cleanedData[field] === '' || cleanedData[field] === undefined || cleanedData[field] === 0) {
+ // 유효하지 않은 값이면 에러 발생
+ throw new Error('Vendor ID is required and cannot be null')
+ }
+ } else {
+ // 다른 숫자 필드들은 빈 값이면 null로 설정
+ if (cleanedData[field] === '' || cleanedData[field] === undefined || cleanedData[field] === 0) {
+ cleanedData[field] = null
+ }
+ }
+ })
+
+ const [updatedContract] = await db
+ .update(generalContracts)
+ .set({
+ ...cleanedData,
+ lastUpdatedAt: new Date(),
+ revision: (cleanedData.revision as number) ? (cleanedData.revision as number) + 1 : 0,
+ })
+ .where(eq(generalContracts.id, id))
+ .returning()
+
+ // Update contract items if provided
+ if (data.contractItems && Array.isArray(data.contractItems)) {
+ // Delete existing items
+ await db
+ .delete(generalContractItems)
+ .where(eq(generalContractItems.contractId, id))
+
+ // Insert new items
+ if (data.contractItems.length > 0) {
+ await db
+ .insert(generalContractItems)
+ .values(
+ data.contractItems.map((item: any) => ({
+ project: item.project,
+ itemCode: item.itemCode,
+ itemInfo: item.itemInfo,
+ specification: item.specification,
+ quantity: item.quantity,
+ quantityUnit: item.quantityUnit,
+ contractDeliveryDate: item.contractDeliveryDate,
+ contractUnitPrice: item.contractUnitPrice,
+ contractAmount: item.contractAmount,
+ contractCurrency: item.contractCurrency,
+ contractId: id,
+ }))
+ )
+ }
+ }
+
+ // Update attachments if provided
+ if (data.attachments && Array.isArray(data.attachments)) {
+ // Delete existing attachments
+ await db
+ .delete(generalContractAttachments)
+ .where(eq(generalContractAttachments.contractId, id))
+
+ // Insert new attachments
+ if (data.attachments.length > 0) {
+ await db
+ .insert(generalContractAttachments)
+ .values(
+ data.attachments.map((attachment: any) => ({
+ ...attachment,
+ contractId: id,
+ }))
+ )
+ }
+ }
+
+ revalidatePath('/general-contracts')
+ revalidatePath(`/general-contracts/detail/${id}`)
+ return updatedContract
+ } catch (error) {
+ console.error('Error updating contract:', error)
+ throw new Error('Failed to update contract')
+ }
+}
+
+export async function deleteContract(id: number) {
+ try {
+ // 현재 계약 정보 조회
+ await db
+ .select({ revision: generalContracts.revision })
+ .from(generalContracts)
+ .where(eq(generalContracts.id, id))
+ .limit(1)
+
+ // 계약폐기: status를 'Contract Delete'로 변경
+ const [updatedContract] = await db
+ .update(generalContracts)
+ .set({
+ status: 'Contract Delete',
+ lastUpdatedAt: new Date(),
+ // revision: (currentContract[0]?.revision || 0) + 1 // 계약 파기 시 리비전 증가? 확인 필요
+ })
+ .where(eq(generalContracts.id, id))
+ .returning()
+
+ revalidatePath('/general-contracts')
+ return { success: true, contract: updatedContract }
+ } catch (error) {
+ console.error('Error deleting contract:', error)
+ throw new Error('Failed to delete contract')
+ }
+}
+
+// 상태별 개수 집계
+export async function getGeneralContractStatusCounts() {
+ try {
+ const counts = await db
+ .select({
+ status: generalContracts.status,
+ count: count(),
+ })
+ .from(generalContracts)
+ .groupBy(generalContracts.status)
+
+ return counts.reduce((acc, { status, count }) => {
+ acc[status] = count
+ return acc
+ }, {} as Record<string, number>)
+ } catch (error) {
+ console.error('Failed to get contract status counts:', error)
+ return {}
+ }
+}
+
+// 계약구분별 개수 집계
+export async function getGeneralContractCategoryCounts() {
+ try {
+ const counts = await db
+ .select({
+ category: generalContracts.category,
+ count: count(),
+ })
+ .from(generalContracts)
+ .groupBy(generalContracts.category)
+
+ return counts.reduce((acc, { category, count }) => {
+ acc[category] = count
+ return acc
+ }, {} as Record<string, number>)
+ } catch (error) {
+ console.error('Failed to get contract category counts:', error)
+ return {}
+ }
+}
+
+export async function getVendors() {
+ try {
+ const vendorList = await db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ })
+ .from(vendors)
+ .orderBy(asc(vendors.vendorName))
+
+ return vendorList
+ } catch (error) {
+ console.error('Error fetching vendors:', error)
+ throw new Error('Failed to fetch vendors')
+ }
+}
+
+// 첨부파일 업로드
+export async function uploadContractAttachment(contractId: number, file: File, userId: string, documentName: string = '사양 및 공급범위') {
+ try {
+ // userId를 숫자로 변환
+ const userIdNumber = parseInt(userId)
+ if (isNaN(userIdNumber)) {
+ throw new Error('Invalid user ID')
+ }
+
+ const saveResult = await saveDRMFile(
+ file,
+ decryptWithServerAction,
+ `general-contracts/${contractId}/attachments`,
+ userId,
+ )
+
+ if (saveResult.success && saveResult.publicPath) {
+ // generalContractAttachments 테이블에 저장
+ const [attachment] = await db.insert(generalContractAttachments).values({
+ contractId,
+ documentName,
+ fileName: saveResult.fileName || file.name,
+ filePath: saveResult.publicPath,
+ uploadedById: userIdNumber,
+ uploadedAt: new Date(),
+ }).returning()
+
+ return {
+ success: true,
+ message: '파일이 성공적으로 업로드되었습니다.',
+ attachment
+ }
+ } else {
+ return {
+ success: false,
+ error: saveResult.error || '파일 저장에 실패했습니다.'
+ }
+ }
+ } catch (error) {
+ console.error('Failed to upload contract attachment:', error)
+ return {
+ success: false,
+ error: '파일 업로드에 실패했습니다.'
+ }
+ }
+}
+
+// 첨부파일 목록 조회
+export async function getContractAttachments(contractId: number) {
+ try {
+ const attachments = await db
+ .select()
+ .from(generalContractAttachments)
+ .where(eq(generalContractAttachments.contractId, contractId))
+ .orderBy(desc(generalContractAttachments.uploadedAt))
+
+ return attachments
+ } catch (error) {
+ console.error('Failed to get contract attachments:', error)
+ return []
+ }
+}
+
+// 첨부파일 다운로드
+export async function getContractAttachmentForDownload(attachmentId: number, contractId: number) {
+ try {
+ const attachments = await db
+ .select()
+ .from(generalContractAttachments)
+ .where(and(
+ eq(generalContractAttachments.id, attachmentId),
+ eq(generalContractAttachments.contractId, contractId)
+ ))
+ .limit(1)
+
+ if (attachments.length === 0) {
+ return {
+ success: false,
+ error: '첨부파일을 찾을 수 없습니다.'
+ }
+ }
+
+ return {
+ success: true,
+ attachment: attachments[0]
+ }
+ } catch (error) {
+ console.error('Failed to get contract attachment for download:', error)
+ return {
+ success: false,
+ error: '첨부파일 다운로드 준비에 실패했습니다.'
+ }
+ }
+}
+
+// 첨부파일 삭제
+export async function deleteContractAttachment(attachmentId: number, contractId: number) {
+ try {
+ const attachments = await db
+ .select()
+ .from(generalContractAttachments)
+ .where(and(
+ eq(generalContractAttachments.id, attachmentId),
+ eq(generalContractAttachments.contractId, contractId)
+ ))
+ .limit(1)
+
+ if (attachments.length === 0) {
+ return {
+ success: false,
+ error: '첨부파일을 찾을 수 없습니다.'
+ }
+ }
+
+ // 데이터베이스에서 삭제
+ await db
+ .delete(generalContractAttachments)
+ .where(eq(generalContractAttachments.id, attachmentId))
+
+ return {
+ success: true,
+ message: '첨부파일이 삭제되었습니다.'
+ }
+ } catch (error) {
+ console.error('Failed to delete contract attachment:', error)
+ return {
+ success: false,
+ error: '첨부파일 삭제에 실패했습니다.'
+ }
+ }
+}
+
+// 계약승인요청용 파일 업로드 (DRM 사용)
+export async function uploadContractApprovalFile(contractId: number, file: File, userId: string) {
+ try {
+ const userIdNumber = parseInt(userId)
+ if (isNaN(userIdNumber)) {
+ throw new Error('Invalid user ID')
+ }
+
+ const saveResult = await saveDRMFile(
+ file,
+ decryptWithServerAction,
+ `general-contracts/${contractId}/approval-documents`,
+ userId,
+ )
+
+ if (saveResult.success && saveResult.publicPath) {
+ return {
+ success: true,
+ message: '파일이 성공적으로 업로드되었습니다.',
+ filePath: saveResult.publicPath,
+ fileName: saveResult.fileName || file.name
+ }
+ } else {
+ return {
+ success: false,
+ error: saveResult.error || '파일 저장에 실패했습니다.'
+ }
+ }
+ } catch (error) {
+ console.error('Failed to upload contract approval file:', error)
+ return {
+ success: false,
+ error: '파일 업로드에 실패했습니다.'
+ }
+ }
+}
+
+
+
+// 계약승인요청 전송
+export async function sendContractApprovalRequest(
+ contractSummary: any,
+ pdfBuffer: Uint8Array,
+ documentType: string,
+ userId: string,
+ generatedBasicContracts?: Array<{ key: string; buffer: number[]; fileName: string }>
+) {
+ try {
+ // contracts 테이블에 새 계약 생성 (generalContracts에서 contracts로 복사)
+ const contractData = await mapContractSummaryToDb(contractSummary)
+
+ const [newContract] = await db.insert(contracts).values({
+ ...contractData,
+ contractNo: contractData.contractNo || `GC-${Date.now()}`, // contractNumber 대신 contractNo 사용
+ }).returning()
+
+ const contractId = newContract.id
+
+ // const items: {
+ // id: number;
+ // createdAt: Date;
+ // updatedAt: Date;
+ // contractId: number;
+ // itemCode: string | null;
+ // quantity: string | null;
+ // contractAmount: string | null;
+ // contractCurrency: string | null;
+ // contractDeliveryDate: string | null;
+ // specification: string | null;
+ // itemInfo: string | null;
+ // quantityUnit: string | null;
+ // totalWeight: string | null;
+ // weightUnit: string | null;
+ // contractUnitPrice: string | null;
+ // }[]
+
+ // contractItems 테이블에 품목 정보 저장 (general-contract-items가 있을 때만)
+ if (contractSummary.items && contractSummary.items.length > 0) {
+ const projectNo = contractSummary.basicInfo?.projectCode || contractSummary.basicInfo?.projectId?.toString() || 'NULL'
+
+ for (const item of contractSummary.items) {
+ let itemId: number
+
+ // 1. items 테이블에서 itemCode로 기존 아이템 검색
+ if (item.itemCode) {
+ // const existingItem = await db
+ // .select({ id: items.id })
+ // .from(items)
+ // .where(and(
+ // eq(items.itemCode, item.itemCode),
+ // eq(items.ProjectNo, projectNo)
+ // ))
+ // .limit(1)
+ const existingItem = await db
+ .select({ id: items.id })
+ .from(items)
+ .where(
+ eq(items.itemCode, item.itemCode)
+ )
+ .limit(1)
+
+ if (existingItem.length > 0) {
+ // 기존 아이템이 있으면 해당 ID 사용
+ itemId = existingItem[0].id
+ } else {
+ // 기존 아이템이 없으면 새로 생성
+ const newItem = await db.insert(items).values({
+ ProjectNo: projectNo,
+ itemCode: item.itemCode,
+ itemName: item.itemInfo || item.description || item.itemCode,
+ packageCode: item.itemCode,
+ description: item.specification || item.description || '',
+ unitOfMeasure: item.quantityUnit || 'EA',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ }).returning({ id: items.id })
+
+ itemId = newItem[0].id
+ }
+
+
+ // 2. contractItems에 저장
+ await db.insert(contractItems).values({
+ contractId,
+ itemId: itemId,
+ description: item.itemInfo || item.description || '',
+ quantity: Math.floor(Number(item.quantity) || 1), // 정수로 변환
+ unitPrice: item.contractUnitPrice || item.unitPrice || 0,
+ taxRate: item.taxRate || 0,
+ taxAmount: item.taxAmount || 0,
+ totalLineAmount: item.contractAmount || item.totalLineAmount || 0,
+ remark: item.remark || '',
+ })
+ }else{
+ //아이템코드가 없으니 pass
+ continue
+ }
+ }
+ }
+
+ // PDF 버퍼를 saveBuffer 함수로 저장
+ const fileId = uuidv4()
+ const fileName = `${fileId}.pdf`
+
+ // PDF 버퍼를 Buffer로 변환
+ let bufferData: Buffer
+ if (Buffer.isBuffer(pdfBuffer)) {
+ bufferData = pdfBuffer
+ } else if (pdfBuffer instanceof ArrayBuffer) {
+ bufferData = Buffer.from(pdfBuffer)
+ } else if (pdfBuffer instanceof Uint8Array) {
+ bufferData = Buffer.from(pdfBuffer)
+ } else {
+ bufferData = Buffer.from(pdfBuffer as any)
+ }
+
+ // saveBuffer 함수를 사용해서 파일 저장
+ const saveResult = await saveBuffer({
+ buffer: bufferData,
+ fileName: fileName,
+ directory: "generalContracts",
+ originalName: `contract_${contractId}_${documentType}_${fileId}.pdf`,
+ userId: userId
+ })
+
+ if (!saveResult.success) {
+ throw new Error(saveResult.error || 'PDF 파일 저장에 실패했습니다.')
+ }
+
+ const finalFileName = saveResult.fileName || fileName
+ const finalFilePath = saveResult.publicPath
+ ? saveResult.publicPath.replace('/api/files/', '')
+ : `/generalContracts/${fileName}`
+
+ // contractEnvelopes 테이블에 서명할 PDF 파일 정보 저장
+ const [newEnvelope] = await db.insert(contractEnvelopes).values({
+ contractId: contractId,
+ envelopeId: `envelope_${contractId}_${Date.now()}`,
+ documentId: `document_${contractId}_${Date.now()}`,
+ envelopeStatus: 'PENDING',
+ fileName: finalFileName,
+ filePath: finalFilePath,
+ }).returning()
+
+ // contractSigners 테이블에 벤더 서명자 정보 저장
+ const vendorEmail = contractSummary.basicInfo?.vendorEmail || 'vendor@example.com'
+ const vendorName = contractSummary.basicInfo?.vendorName || '벤더'
+
+ await db.insert(contractSigners).values({
+ envelopeId: newEnvelope.id,
+ signerType: 'VENDOR',
+ signerEmail: vendorEmail,
+ signerName: vendorName,
+ signerPosition: '대표자',
+ signerStatus: 'PENDING',
+ })
+
+ // generalContractAttachments에 contractId 업데이트 (일반계약의 첨부파일들을 PO 계약과 연결)
+ const generalContractId = contractSummary.basicInfo?.id || contractSummary.id
+ if (generalContractId) {
+ await db.update(generalContractAttachments)
+ .set({ poContractId: contractId })
+ .where(eq(generalContractAttachments.contractId, generalContractId))
+ }
+
+ // 기본계약 처리 (클라이언트에서 생성된 PDF 사용 또는 자동 생성)
+ await processGeneratedBasicContracts(contractSummary, contractId, userId, generatedBasicContracts)
+
+ try {
+ sendEmail({
+ to: contractSummary.basicInfo.vendorEmail,
+ subject: `계약승인요청`,
+ template: "contract-approval-request",
+ context: {
+ contractId: contractId,
+ loginUrl: `${process.env.NEXT_PUBLIC_URL}/partners/po`,
+ language: "ko",
+ },
+ })
+ // 계약 상태 업데이트
+ await db.update(generalContracts)
+ .set({
+ status: 'Contract Accept Request',
+ lastUpdatedAt: new Date()
+ })
+ .where(eq(generalContracts.id, generalContractId))
+
+ } catch (error) {
+ console.error('계약승인요청 전송 오류:', error)
+
+ }
+
+
+ revalidatePath('/evcp/general-contracts')
+ revalidatePath('/evcp/general-contracts/detail')
+ revalidatePath('/evcp/general-contracts/detail/contract-approval-request-dialog')
+
+ return {
+ success: true,
+ message: '계약승인요청이 성공적으로 전송되었습니다.',
+ pdfPath: saveResult.publicPath
+ }
+
+ } catch (error: any) {
+ console.error('계약승인요청 전송 오류:', error)
+
+ // 중복 계약 번호 오류 처리
+ if (error.message && error.message.includes('duplicate key value violates unique constraint')) {
+ return {
+ success: false,
+ error: '이미 존재하는 계약번호입니다. 다른 계약번호를 사용해주세요.'
+ }
+ }
+
+ // 다른 데이터베이스 오류 처리
+ if (error.code === '23505') { // PostgreSQL unique constraint violation
+ return {
+ success: false,
+ error: '중복된 데이터가 존재합니다. 입력값을 확인해주세요.'
+ }
+ }
+
+ return {
+ success: false,
+ error: `계약승인요청 전송 중 오류가 발생했습니다: ${error.message}`
+ }
+ }
+}
+
+// 클라이언트에서 생성된 기본계약 처리 (RFQ-Last 방식)
+async function processGeneratedBasicContracts(
+ contractSummary: any,
+ contractId: number,
+ userId: string,
+ generatedBasicContracts: Array<{ key: string; buffer: number[]; fileName: string }>
+): Promise<void> {
+ try {
+ const userIdNumber = parseInt(userId)
+ if (isNaN(userIdNumber)) {
+ throw new Error('Invalid user ID')
+ }
+
+ console.log(`${generatedBasicContracts.length}개의 클라이언트 생성 기본계약을 처리합니다.`)
+
+ // 기본계약 디렉토리 생성 (RFQ-Last 방식)
+ const nasPath = process.env.NAS_PATH || "/evcp_nas"
+ const isProduction = process.env.NODE_ENV === "production"
+ const baseDir = isProduction ? nasPath : path.join(process.cwd(), "public")
+ const contractsDir = path.join(baseDir, "basicContracts")
+ await fs.mkdir(contractsDir, { recursive: true })
+
+ for (const contractData of generatedBasicContracts) {
+ try {
+ console.log(contractSummary.basicInfo?.vendorId || 'unknown', contractData.buffer.length)
+
+ // PDF 버퍼를 Buffer로 변환 및 파일 저장
+ const pdfBuffer = Buffer.from(contractData.buffer)
+ const fileName = contractData.fileName
+ const filePath = path.join(contractsDir, fileName)
+
+ await fs.writeFile(filePath, pdfBuffer)
+
+ // key에서 템플릿 정보 추출 (vendorId_type_templateName 형식)
+ const keyParts = contractData.key.split('_')
+ const vendorId = parseInt(keyParts[0])
+ const contractType = keyParts[1]
+ const templateName = keyParts.slice(2).join('_')
+
+ // 템플릿 조회
+ const template = await getTemplateByName(templateName)
+
+ console.log("템플릿", templateName, template)
+
+ if (template) {
+ // 웹 접근 경로 설정 (RFQ-Last 방식)
+ let filePublicPath: string
+ if (isProduction) {
+ filePublicPath = `/api/files/basicContracts/${fileName}`
+ } else {
+ filePublicPath = `/basicContracts/${fileName}`
+ }
+
+ // basicContract 테이블에 저장
+ const deadline = new Date()
+ deadline.setDate(deadline.getDate() + 10) // 10일 후 마감
+
+ await db.insert(basicContract).values({
+ templateId: template.id,
+ vendorId: vendorId,
+ requestedBy: userIdNumber,
+ generalContractId: contractSummary.basicInfo?.id || contractSummary.id,
+ fileName: fileName,
+ filePath: filePublicPath,
+ deadline: deadline.toISOString().split('T')[0], // YYYY-MM-DD 형식으로
+ status: 'PENDING'
+ })
+
+ console.log(`클라이언트 생성 기본계약 저장 완료:${contractData.fileName}`)
+ } else {
+ console.error(`템플릿을 찾을 수 없음: ${templateName}`)
+ }
+
+ } catch (error) {
+ console.error(`기본계약 처리 실패 (${contractData.fileName}):`, error)
+ // 개별 계약서 처리 실패는 전체 프로세스를 중단하지 않음
+ }
+ }
+
+ } catch (error) {
+ console.error('클라이언트 생성 기본계약 처리 중 오류:', error)
+ // 기본계약 생성 실패는 계약 승인 요청 전체를 실패시키지 않음
+ }
+}
+
+// 템플릿명으로 템플릿 조회 (RFQ-Last 방식)
+async function getTemplateByName(templateName: string) {
+ const [template] = await db
+ .select()
+ .from(basicContractTemplates)
+ .where(
+ and(
+ ilike(basicContractTemplates.templateName, `%${templateName}%`),
+ eq(basicContractTemplates.status, "ACTIVE")
+ )
+ )
+ .limit(1)
+
+ return template
+}
+
+async function mapContractSummaryToDb(contractSummary: any) {
+ const basicInfo = contractSummary.basicInfo || {}
+
+ // 계약번호 생성
+ const contractNumber = await generateContractNumber(
+ basicInfo.userId,
+ basicInfo.contractType || basicInfo.type || 'UP'
+ )
+
+ return {
+ // 기본 정보
+ projectId: basicInfo.projectId || null, // 기본값 설정
+ vendorId: basicInfo.vendorId,
+ contractNo: contractNumber,
+ contractName: basicInfo.contractName || '계약승인요청',
+ status: 'PENDING_APPROVAL',
+
+ // 계약 기간
+ startDate: basicInfo.startDate || new Date().toISOString().split('T')[0],
+ endDate: basicInfo.endDate || new Date().toISOString().split('T')[0],
+
+ // 지급/인도 조건
+ paymentTerms: basicInfo.paymentTerm || '',
+ deliveryTerms: basicInfo.deliveryTerm || '',
+ deliveryDate: basicInfo.contractDeliveryDate || basicInfo.deliveryDate || new Date().toISOString().split('T')[0],
+ shippmentPlace: basicInfo.shippingLocation || basicInfo.shippmentPlace || '',
+ deliveryLocation: basicInfo.dischargeLocation || basicInfo.deliveryLocation || '',
+
+ // 금액 정보
+ budgetAmount: Number(basicInfo.totalAmount || basicInfo.contractAmount || 0),
+ budgetCurrency: basicInfo.currency || basicInfo.contractCurrency || 'USD',
+ totalAmountKrw: Number(basicInfo.totalAmount || basicInfo.contractAmount || 0),
+ currency: basicInfo.currency || basicInfo.contractCurrency || 'USD',
+ totalAmount: Number(basicInfo.totalAmount || basicInfo.contractAmount || 0),
+
+ // // SAP ECC 관련 필드들
+ // poVersion: basicInfo.revision || 1,
+ // purchaseDocType: basicInfo.type || 'UP',
+ // purchaseOrg: basicInfo.purchaseOrg || '',
+ // purchaseGroup: basicInfo.purchaseGroup || '',
+ // exchangeRate: Number(basicInfo.exchangeRate || 1),
+
+ // // 계약/보증 관련
+ // contractGuaranteeCode: basicInfo.contractGuaranteeCode || '',
+ // defectGuaranteeCode: basicInfo.defectGuaranteeCode || '',
+ // guaranteePeriodCode: basicInfo.guaranteePeriodCode || '',
+ // advancePaymentYn: basicInfo.advancePaymentYn || 'N',
+
+ // // 전자계약/승인 관련
+ // electronicContractYn: basicInfo.electronicContractYn || 'Y',
+ // electronicApprovalDate: basicInfo.electronicApprovalDate || null,
+ // electronicApprovalTime: basicInfo.electronicApprovalTime || '',
+ // ownerApprovalYn: basicInfo.ownerApprovalYn || 'N',
+
+ // // 기타
+ // plannedInOutFlag: basicInfo.plannedInOutFlag || 'I',
+ // settlementStandard: basicInfo.settlementStandard || 'A',
+ // weightSettlementFlag: basicInfo.weightSettlementFlag || 'N',
+
+ // 연동제 관련
+ priceIndexYn: basicInfo.priceIndexYn || 'N',
+ writtenContractNo: basicInfo.contractNumber || '',
+ contractVersion: basicInfo.revision || 1,
+
+ // // 부분 납품/결제
+ // partialShippingAllowed: basicInfo.partialShippingAllowed || false,
+ // partialPaymentAllowed: basicInfo.partialPaymentAllowed || false,
+
+ // 메모
+ remarks: basicInfo.notes || basicInfo.remarks || '',
+
+ // 버전 관리
+ version: basicInfo.revision || 1,
+
+ // 타임스탬프 (contracts 테이블 스키마에 맞게)
+ createdAt: new Date(),
+ updatedAt: new Date()
+ }
+}
+
+// Field Service Rate 관련 서버 액션들
+export async function getFieldServiceRate(contractId: number) {
+ try {
+ const result = await db
+ .select({ fieldServiceRates: generalContracts.fieldServiceRates })
+ .from(generalContracts)
+ .where(eq(generalContracts.id, contractId))
+ .limit(1)
+
+ if (result.length === 0) {
+ return null
+ }
+
+ return result[0].fieldServiceRates as Record<string, unknown> || null
+ } catch (error) {
+ console.error('Failed to get field service rate:', error)
+ throw new Error('Field Service Rate 데이터를 불러오는데 실패했습니다.')
+ }
+}
+
+export async function updateFieldServiceRate(
+ contractId: number,
+ fieldServiceRateData: Record<string, unknown>,
+ userId: number
+) {
+ try {
+ await db
+ .update(generalContracts)
+ .set({
+ fieldServiceRates: fieldServiceRateData,
+ lastUpdatedAt: new Date(),
+ lastUpdatedById: userId
+ })
+ .where(eq(generalContracts.id, contractId))
+
+ revalidatePath('/evcp/general-contracts')
+ return { success: true }
+ } catch (error) {
+ console.error('Failed to update field service rate:', error)
+ throw new Error('Field Service Rate 업데이트에 실패했습니다.')
+ }
+}
+
+// Offset Details 관련 서버 액션들
+export async function getOffsetDetails(contractId: number) {
+ try {
+ const result = await db
+ .select({ offsetDetails: generalContracts.offsetDetails })
+ .from(generalContracts)
+ .where(eq(generalContracts.id, contractId))
+ .limit(1)
+
+ if (result.length === 0) {
+ return null
+ }
+
+ return result[0].offsetDetails as Record<string, unknown> || null
+ } catch (error) {
+ console.error('Failed to get offset details:', error)
+ throw new Error('회입/상계내역 데이터를 불러오는데 실패했습니다.')
+ }
+}
+
+export async function updateOffsetDetails(
+ contractId: number,
+ offsetDetailsData: Record<string, unknown>,
+ userId: number
+) {
+ try {
+ await db
+ .update(generalContracts)
+ .set({
+ offsetDetails: offsetDetailsData,
+ lastUpdatedAt: new Date(),
+ lastUpdatedById: userId
+ })
+ .where(eq(generalContracts.id, contractId))
+
+ revalidatePath('/evcp/general-contracts')
+ return { success: true }
+ } catch (error) {
+ console.error('Failed to update offset details:', error)
+ throw new Error('회입/상계내역 업데이트에 실패했습니다.')
+ }
+}
+
+// 계약번호 생성 함수
+export async function generateContractNumber(
+ userId?: string,
+ contractType: string
+): Promise<string> {
+ try {
+ // 계약종류 매핑 (2자리) - GENERAL_CONTRACT_TYPES 상수 사용
+ const contractTypeMap: Record<string, string> = {
+ 'UP': 'UP', // 자재단가계약
+ 'LE': 'LE', // 임대차계약
+ 'IL': 'IL', // 개별운송계약
+ 'AL': 'AL', // 연간운송계약
+ 'OS': 'OS', // 외주용역계약
+ 'OW': 'OW', // 도급계약
+ 'IS': 'IS', // 검사계약
+ 'LO': 'LO', // LOI (의향서)
+ 'FA': 'FA', // FA (Frame Agreement)
+ 'SC': 'SC', // 납품합의계약 (Supply Contract)
+ 'OF': 'OF', // 클레임상계계약 (Offset Agreement)
+ 'AW': 'AW', // 사전작업합의 (Advanced Work)
+ 'AD': 'AD', // 사전납품합의 (Advanced Delivery)
+ 'AM': 'AM', // 설계계약
+ 'SC_SELL': 'SC' // 폐기물매각계약 (Scrap) - 납품합의계약과 동일한 코드 사용
+ }
+
+ const typeCode = contractTypeMap[contractType] || 'XX' // 기본값
+ // user 테이블의 user.userCode가 있으면 발주담당자 코드로 사용
+ // userId가 주어졌을 때 user.userCode를 조회, 없으면 '000' 사용
+ let purchaseManagerCode = '000';
+ if (userId) {
+ const user = await db
+ .select({ userCode: users.userCode })
+ .from(users)
+ .where(eq(users.id, parseInt(userId || '0')))
+ .limit(1);
+ if (user[0]?.userCode && user[0].userCode.length >= 3) {
+ purchaseManagerCode = user[0].userCode.substring(0, 3).toUpperCase();
+ }
+ }
+ let managerCode: string
+ if (purchaseManagerCode && purchaseManagerCode.length >= 3) {
+ // 발주담당자 코드가 있으면 3자리 사용
+ managerCode = purchaseManagerCode.substring(0, 3).toUpperCase()
+ } else {
+ // 발주담당자 코드가 없으면 일련번호로 대체 (001부터 시작)
+ const currentYear = new Date().getFullYear()
+ const prefix = `C${typeCode}${currentYear.toString().slice(-2)}`
+
+ // 해당 패턴으로 시작하는 계약번호 중 가장 큰 일련번호 찾기
+ const existingContracts = await db
+ .select({ contractNumber: generalContracts.contractNumber })
+ .from(generalContracts)
+ .where(like(generalContracts.contractNumber, `${prefix}%`))
+ .orderBy(desc(generalContracts.contractNumber))
+ .limit(1)
+
+ let sequenceNumber = 1
+ if (existingContracts.length > 0) {
+ const lastContractNumber = existingContracts[0].contractNumber
+ const lastSequenceStr = lastContractNumber.slice(-3)
+
+ // contractNumber에서 숫자만 추출하여 sequence 찾기
+ const numericParts = lastContractNumber.match(/\d+/g)
+ if (numericParts && numericParts.length > 0) {
+ // 마지막 숫자 부분을 시퀀스로 사용 (일반적으로 마지막 3자리)
+ const potentialSequence = numericParts[numericParts.length - 1]
+ const lastSequence = parseInt(potentialSequence)
+
+ if (!isNaN(lastSequence)) {
+ sequenceNumber = lastSequence + 1
+ }
+ }
+ // 숫자를 찾지 못했거나 파싱 실패 시 sequenceNumber = 1 유지
+ }
+
+ // 일련번호를 3자리로 포맷팅
+ managerCode = sequenceNumber.toString().padStart(3, '0')
+ }
+
+ // 일련번호 생성 (3자리)
+ const currentYear = new Date().getFullYear()
+ const prefix = `C${managerCode}${typeCode}${currentYear.toString().slice(-2)}`
+
+ // 해당 패턴으로 시작하는 계약번호 중 가장 큰 일련번호 찾기
+ const existingContracts = await db
+ .select({ contractNumber: generalContracts.contractNumber })
+ .from(generalContracts)
+ .where(like(generalContracts.contractNumber, `${prefix}%`))
+ .orderBy(desc(generalContracts.contractNumber))
+ .limit(1)
+
+ let sequenceNumber = 1
+ if (existingContracts.length > 0) {
+ const lastContractNumber = existingContracts[0].contractNumber
+
+ // contractNumber에서 숫자만 추출하여 sequence 찾기
+ const numericParts = lastContractNumber.match(/\d+/g)
+ if (numericParts && numericParts.length > 0) {
+ // 마지막 숫자 부분을 시퀀스로 사용
+ const potentialSequence = numericParts[numericParts.length - 1]
+ const lastSequence = parseInt(potentialSequence)
+
+ if (!isNaN(lastSequence)) {
+ sequenceNumber = lastSequence + 1
+ }
+ }
+ // 숫자를 찾지 못했거나 파싱 실패 시 sequenceNumber = 1 유지
+ }
+
+ // 최종 계약번호 생성: C + 발주담당자코드(3자리) + 계약종류(2자리) + 연도(2자리) + 일련번호(3자리)
+ const finalSequence = sequenceNumber.toString().padStart(3, '0')
+ const contractNumber = `C${managerCode}${typeCode}${currentYear.toString().slice(-2)}${finalSequence}`
+
+ return contractNumber
+
+ } catch (error) {
+ console.error('계약번호 생성 오류:', error)
+ throw new Error('계약번호 생성에 실패했습니다.')
+ }
+}
+
+// 프로젝트 목록 조회
+export async function getProjects() {
+ try {
+ const projectList = await db
+ .select({
+ id: projects.id,
+ code: projects.code,
+ name: projects.name,
+ type: projects.type,
+ })
+ .from(projects)
+ .orderBy(asc(projects.name))
+
+ return projectList
+ } catch (error) {
+ console.error('Error fetching projects:', error)
+ throw new Error('Failed to fetch projects')
+ }
+}
diff --git a/lib/general-contracts_old/types.ts b/lib/general-contracts_old/types.ts
new file mode 100644
index 00000000..2b6731b6
--- /dev/null
+++ b/lib/general-contracts_old/types.ts
@@ -0,0 +1,125 @@
+// 일반계약 관련 타입 정의
+
+// 1. 계약구분
+export const GENERAL_CONTRACT_CATEGORIES = [
+ 'unit_price', // 단가계약
+ 'general', // 일반계약
+ 'sale' // 매각계약
+] as const;
+
+export type GeneralContractCategory = typeof GENERAL_CONTRACT_CATEGORIES[number];
+
+// 2. 계약종류
+export const GENERAL_CONTRACT_TYPES = [
+ 'UP', // 자재단가계약
+ 'LE', // 임대차계약
+ 'IL', // 개별운송계약
+ 'AL', // 연간운송계약
+ 'OS', // 외주용역계약
+ 'OW', // 도급계약
+ 'IS', // 검사계약
+ 'LO', // LOI (의향서)
+ 'FA', // FA (Frame Agreement)
+ 'SC', // 납품합의계약 (Supply Contract)
+ 'OF', // 클레임상계계약 (Offset Agreement)
+ 'AW', // 사전작업합의 (Advanced Work)
+ 'AD', // 사전납품합의 (Advanced Delivery)
+ 'AM', // 설계계약
+ 'SC_SELL' // 폐기물매각계약 (Scrap) - 납품합의계약과 코드 중복으로 별도 명명
+] as const;
+
+export type GeneralContractType = typeof GENERAL_CONTRACT_TYPES[number];
+
+// 3. 계약상태
+export const GENERAL_CONTRACT_STATUSES = [
+ 'Draft', // 임시 저장
+ 'Request to Review', // 조건검토요청
+ 'Confirm to Review', // 조건검토완료
+ 'Contract Accept Request', // 계약승인요청
+ 'Complete the Contract', // 계약체결(승인)
+ 'Reject to Accept Contract', // 계약승인거절
+ 'Contract Delete', // 계약폐기
+ 'PCR Request', // PCR요청
+ 'VO Request', // VO 요청
+ 'PCR Accept', // PCR승인
+ 'PCR Reject' // PCR거절
+] as const;
+
+export type GeneralContractStatus = typeof GENERAL_CONTRACT_STATUSES[number];
+
+// 4. 체결방식
+export const GENERAL_EXECUTION_METHODS = [
+ '전자계약',
+ '오프라인계약'
+] as const;
+
+export type GeneralExecutionMethod = typeof GENERAL_EXECUTION_METHODS[number];
+
+// 6. 계약확정범위
+export const GENERAL_CONTRACT_SCOPES = [
+ '단가',
+ '금액',
+ '물량',
+ '기타'
+] as const;
+
+export type GeneralContractScope = typeof GENERAL_CONTRACT_SCOPES[number];
+
+// 7. 납기종류
+export const GENERAL_DELIVERY_TYPES = [
+ '단일납기',
+ '분할납기',
+ '구간납기'
+] as const;
+
+export type GeneralDeliveryType = typeof GENERAL_DELIVERY_TYPES[number];
+
+// 8. 연동제 적용 여부
+export const GENERAL_LINKAGE_TYPES = [
+ 'Y',
+ 'N'
+] as const;
+
+export type GeneralLinkageType = typeof GENERAL_LINKAGE_TYPES[number];
+
+// 9. 하도급법 점검결과
+export const GENERAL_COMPLIANCE_RESULTS = [
+ '준수',
+ '위반',
+ '위반의심'
+] as const;
+
+export type GeneralComplianceResult = typeof GENERAL_COMPLIANCE_RESULTS[number];
+
+// 타입 가드 함수들
+export const isGeneralContractCategory = (value: string): value is GeneralContractCategory => {
+ return GENERAL_CONTRACT_CATEGORIES.includes(value as GeneralContractCategory);
+};
+
+export const isGeneralContractType = (value: string): value is GeneralContractType => {
+ return GENERAL_CONTRACT_TYPES.includes(value as GeneralContractType);
+};
+
+export const isGeneralContractStatus = (value: string): value is GeneralContractStatus => {
+ return GENERAL_CONTRACT_STATUSES.includes(value as GeneralContractStatus);
+};
+
+export const isGeneralExecutionMethod = (value: string): value is GeneralExecutionMethod => {
+ return GENERAL_EXECUTION_METHODS.includes(value as GeneralExecutionMethod);
+};
+
+export const isGeneralContractScope = (value: string): value is GeneralContractScope => {
+ return GENERAL_CONTRACT_SCOPES.includes(value as GeneralContractScope);
+};
+
+export const isGeneralDeliveryType = (value: string): value is GeneralDeliveryType => {
+ return GENERAL_DELIVERY_TYPES.includes(value as GeneralDeliveryType);
+};
+
+export const isGeneralLinkageType = (value: string): value is GeneralLinkageType => {
+ return GENERAL_LINKAGE_TYPES.includes(value as GeneralLinkageType);
+};
+
+export const isGeneralComplianceResult = (value: string): value is GeneralComplianceResult => {
+ return GENERAL_COMPLIANCE_RESULTS.includes(value as GeneralComplianceResult);
+};
diff --git a/lib/general-contracts_old/validation.ts b/lib/general-contracts_old/validation.ts
new file mode 100644
index 00000000..5aa516e7
--- /dev/null
+++ b/lib/general-contracts_old/validation.ts
@@ -0,0 +1,82 @@
+import { generalContracts } from "@/db/schema/generalContract"
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+
+export const searchParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<typeof generalContracts.$inferSelect>().withDefault([
+ { id: "registeredAt", desc: true },
+ ]),
+
+ // 기본 필터
+ contractNumber: parseAsString.withDefault(""),
+ name: parseAsString.withDefault(""),
+ status: parseAsArrayOf(z.enum(generalContracts.status.enumValues)).withDefault([]),
+ category: parseAsArrayOf(z.enum(generalContracts.category.enumValues)).withDefault([]),
+ type: parseAsArrayOf(z.enum(generalContracts.type.enumValues)).withDefault([]),
+ executionMethod: parseAsArrayOf(z.enum(generalContracts.executionMethod.enumValues)).withDefault([]),
+ contractSourceType: parseAsArrayOf(z.enum(generalContracts.contractSourceType.enumValues)).withDefault([]),
+ vendorId: parseAsInteger.withDefault(0),
+ managerName: parseAsString.withDefault(""),
+
+ // 날짜 필터
+ registeredAtFrom: parseAsString.withDefault(""),
+ registeredAtTo: parseAsString.withDefault(""),
+ signedAtFrom: parseAsString.withDefault(""),
+ signedAtTo: parseAsString.withDefault(""),
+ startDateFrom: parseAsString.withDefault(""),
+ startDateTo: parseAsString.withDefault(""),
+ endDateFrom: parseAsString.withDefault(""),
+ endDateTo: parseAsString.withDefault(""),
+
+ // 금액 필터
+ contractAmountMin: parseAsString.withDefault(""),
+ contractAmountMax: parseAsString.withDefault(""),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+})
+
+export type GetGeneralContractsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
+
+export const createGeneralContractSchema = z.object({
+ contractNumber: z.string().optional(),
+ name: z.string().min(1, "계약명을 입력해주세요"),
+ category: z.string().min(1, "계약구분을 선택해주세요"),
+ type: z.string().min(1, "계약종류를 선택해주세요"),
+ executionMethod: z.string().min(1, "체결방식을 선택해주세요"),
+ vendorId: z.number().min(1, "협력업체를 선택해주세요"),
+ startDate: z.string().min(1, "계약시작일을 선택해주세요"),
+ endDate: z.string().min(1, "계약종료일을 선택해주세요"),
+ validityEndDate: z.string().optional(),
+ contractScope: z.string().optional(),
+ specificationType: z.string().optional(),
+ specificationManualText: z.string().optional(),
+ contractAmount: z.number().optional(),
+ currency: z.string().default("KRW"),
+ notes: z.string().optional(),
+ linkedRfqOrItb: z.string().optional(),
+ linkedPoNumber: z.string().optional(),
+ linkedBidNumber: z.string().optional(),
+ registeredById: z.number().min(1, "등록자 ID가 필요합니다"),
+ lastUpdatedById: z.number().min(1, "수정자 ID가 필요합니다"),
+})
+
+export const updateGeneralContractSchema = createGeneralContractSchema.partial().extend({
+ id: z.number().min(1, "계약 ID가 필요합니다"),
+})
+
+export type CreateGeneralContractInput = z.infer<typeof createGeneralContractSchema>
+export type UpdateGeneralContractInput = z.infer<typeof updateGeneralContractSchema>