diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-15 10:14:09 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-15 10:14:09 +0000 |
| commit | d7585b3f2ea941ee807c1e87bbc833265a193c78 (patch) | |
| tree | 70d659154ed5feeebc312a5bf850ceecc1c4c441 /lib | |
| parent | dd831478a3ab5ac7182903d41aa4b3e47f28224f (diff) | |
(최겸) 구매 일반계약 및 상세, PO 전달 구현
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/general-contracts/detail/general-contract-approval-request-dialog.tsx | 1312 | ||||
| -rw-r--r-- | lib/general-contracts/detail/general-contract-basic-info.tsx | 2 | ||||
| -rw-r--r-- | lib/general-contracts/detail/general-contract-detail.tsx | 57 | ||||
| -rw-r--r-- | lib/general-contracts/detail/general-contract-documents.tsx | 17 | ||||
| -rw-r--r-- | lib/general-contracts/detail/general-contract-items-table.tsx | 1102 | ||||
| -rw-r--r-- | lib/general-contracts/detail/general-contract-subcontract-checklist.tsx (renamed from lib/general-contracts/detail/subcontract-checklist.tsx) | 39 | ||||
| -rw-r--r-- | lib/general-contracts/main/create-general-contract-dialog.tsx | 20 | ||||
| -rw-r--r-- | lib/general-contracts/main/general-contract-update-sheet.tsx | 7 | ||||
| -rw-r--r-- | lib/general-contracts/main/general-contracts-table-columns.tsx | 31 | ||||
| -rw-r--r-- | lib/general-contracts/main/general-contracts-table-toolbar-actions.tsx | 10 | ||||
| -rw-r--r-- | lib/general-contracts/service.ts | 657 | ||||
| -rw-r--r-- | lib/mail/templates/contract-approval-request.hbs | 176 |
12 files changed, 2779 insertions, 651 deletions
diff --git a/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx b/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx new file mode 100644 index 00000000..e4aa022a --- /dev/null +++ b/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx @@ -0,0 +1,1312 @@ +'use client'
+
+import React, { useState, useEffect } from 'react'
+import { useSession } from 'next-auth/react'
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
+import { Button } from '@/components/ui/button'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Label } from '@/components/ui/label'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { Input } from '@/components/ui/input'
+import { toast } from 'sonner'
+import {
+ FileText,
+ Upload,
+ Eye,
+ Send,
+ CheckCircle,
+ Download,
+ AlertCircle
+} from 'lucide-react'
+import { ContractDocuments } from './general-contract-documents'
+import { getActiveContractTemplates } from '@/lib/bidding/service'
+import { type BasicContractTemplate } from '@/db/schema'
+import {
+ getBasicInfo,
+ getContractItems,
+ getCommunicationChannel,
+ getLocation,
+ getFieldServiceRate,
+ getOffsetDetails,
+ getSubcontractChecklist,
+ uploadContractApprovalFile,
+ sendContractApprovalRequest
+} from '../service'
+
+interface ContractApprovalRequestDialogProps {
+ contract: Record<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/detail/general-contract-basic-info.tsx b/lib/general-contracts/detail/general-contract-basic-info.tsx index fd8983f6..ac1315bb 100644 --- a/lib/general-contracts/detail/general-contract-basic-info.tsx +++ b/lib/general-contracts/detail/general-contract-basic-info.tsx @@ -1078,7 +1078,7 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) { <TabsContent value="documents" className="space-y-6">
<ContractDocuments
contractId={contractId}
- userId="1" // 실제 구현시 현재 사용자 ID로 변경
+ userId={userId?.toString() || "1"}
/>
</TabsContent>
</Tabs>
diff --git a/lib/general-contracts/detail/general-contract-detail.tsx b/lib/general-contracts/detail/general-contract-detail.tsx index 7c01fb16..9d9f35bd 100644 --- a/lib/general-contracts/detail/general-contract-detail.tsx +++ b/lib/general-contracts/detail/general-contract-detail.tsx @@ -3,19 +3,20 @@ import { useState, useEffect } from 'react' import { useParams } from 'next/navigation' import Link from 'next/link' -import { getContractById } from '../service' +import { getContractById, getSubcontractChecklist } from '../service' import { GeneralContractInfoHeader } from './general-contract-info-header' import { Alert, AlertDescription } from '@/components/ui/alert' import { Button } from '@/components/ui/button' import { AlertCircle, ArrowLeft } from 'lucide-react' import { Skeleton } from '@/components/ui/skeleton' import { ContractItemsTable } from './general-contract-items-table' -import { SubcontractChecklist } from './subcontract-checklist' +import { SubcontractChecklist } from './general-contract-subcontract-checklist' import { ContractBasicInfo } from './general-contract-basic-info' import { CommunicationChannel } from './general-contract-communication-channel' import { Location } from './general-contract-location' import { FieldServiceRate } from './general-contract-field-service-rate' import { OffsetDetails } from './general-contract-offset-details' +import { ContractApprovalRequestDialog } from './general-contract-approval-request-dialog' export default function ContractDetailPage() { const params = useParams() @@ -24,14 +25,30 @@ export default function ContractDetailPage() { const [contract, setContract] = useState<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('계약 정보를 불러오는 중 오류가 발생했습니다.') @@ -92,13 +109,22 @@ export default function ContractDetailPage() { 계약번호: {contract?.contractNumber as string} (Rev.{contract?.revision as number}) </p> </div> - {/* 계약목록으로 돌아가기 버튼 */} - <Button asChild variant="outline" size="sm"> - <Link href="/evcp/general-contracts"> - <ArrowLeft className="h-4 w-4 mr-2" /> - 계약목록으로 돌아가기 - </Link> - </Button> + <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} />} @@ -130,9 +156,9 @@ export default function ContractDetailPage() { {/* 하도급법 자율점검 체크리스트 */} <SubcontractChecklist contractId={contract.id as number} - onDataChange={() => {}} + onDataChange={(data) => setSubcontractChecklistData(data)} readOnly={false} - initialData={undefined} + initialData={subcontractChecklistData} /> {/* Communication Channel */} <CommunicationChannel contractId={Number(contract.id)} /> @@ -147,6 +173,15 @@ export default function ContractDetailPage() { <OffsetDetails contractId={Number(contract.id)} /> </div> )} + + {/* 계약승인요청 다이얼로그 */} + {contract && ( + <ContractApprovalRequestDialog + contract={contract} + open={showApprovalDialog} + onOpenChange={setShowApprovalDialog} + /> + )} </div> ) } diff --git a/lib/general-contracts/detail/general-contract-documents.tsx b/lib/general-contracts/detail/general-contract-documents.tsx index 11d2de68..b0f20e7f 100644 --- a/lib/general-contracts/detail/general-contract-documents.tsx +++ b/lib/general-contracts/detail/general-contract-documents.tsx @@ -32,6 +32,7 @@ interface ContractDocument { documentName: string
fileName: string
filePath: string
+ documentType?: string
shiComment?: string | null
vendorComment?: string | null
uploadedAt: Date
@@ -80,6 +81,16 @@ export function ContractDocuments({ contractId, userId, readOnly = false }: Cont startTransition(async () => {
try {
+ // 본 계약문서 타입인 경우 기존 문서 확인
+ if (selectedDocumentType === 'main') {
+ const existingMainDoc = documents.find(doc => doc.documentType === 'main')
+ if (existingMainDoc) {
+ toast.info('기존 계약문서가 새롭게 업로드한 문서로 대체됩니다.')
+ // 기존 본 계약문서 삭제
+ await deleteContractAttachment(existingMainDoc.id, contractId)
+ }
+ }
+
await uploadContractAttachment(contractId, file, userId, selectedDocumentType)
toast.success('문서가 업로드되었습니다.')
loadDocuments()
@@ -108,7 +119,7 @@ export function ContractDocuments({ contractId, userId, readOnly = false }: Cont startTransition(async () => {
try {
- await deleteContractAttachment(documentId, contractId, userId)
+ await deleteContractAttachment(documentId, contractId)
toast.success('문서가 삭제되었습니다.')
loadDocuments()
} catch (error) {
@@ -275,7 +286,7 @@ export function ContractDocuments({ contractId, userId, readOnly = false }: Cont <Button
variant="ghost"
size="sm"
- onClick={() => handleEditComment(doc.id, 'shi', doc.shiComment)}
+ onClick={() => handleEditComment(doc.id, 'shi', doc.shiComment || '')}
>
<MessageSquare className="h-4 w-4" />
</Button>
@@ -321,7 +332,7 @@ export function ContractDocuments({ contractId, userId, readOnly = false }: Cont <Button
variant="ghost"
size="sm"
- onClick={() => handleEditComment(doc.id, 'vendor', doc.vendorComment)}
+ onClick={() => handleEditComment(doc.id, 'vendor', doc.vendorComment || '')}
>
<MessageSquare className="h-4 w-4" />
</Button>
diff --git a/lib/general-contracts/detail/general-contract-items-table.tsx b/lib/general-contracts/detail/general-contract-items-table.tsx index 23057cb7..5176c6ce 100644 --- a/lib/general-contracts/detail/general-contract-items-table.tsx +++ b/lib/general-contracts/detail/general-contract-items-table.tsx @@ -1,549 +1,553 @@ -'use client'
-
-import * as React from 'react'
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
-import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
-import { Input } from '@/components/ui/input'
-import { Label } from '@/components/ui/label'
-import { Button } from '@/components/ui/button'
-import { Checkbox } from '@/components/ui/checkbox'
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from '@/components/ui/table'
-import {
- Package,
- Plus,
- Trash2,
- Calculator
-} from 'lucide-react'
-import { toast } from 'sonner'
-import { updateContractItems, getContractItems } from '../service'
-import { Save, LoaderIcon } from 'lucide-react'
-
-interface ContractItem {
- id?: number
- project: string
- itemCode: string
- itemInfo: string
- specification: string
- quantity: number
- quantityUnit: string
- contractDeliveryDate: string
- contractUnitPrice: number
- contractAmount: number
- contractCurrency: string
- isSelected?: boolean
-}
-
-interface ContractItemsTableProps {
- contractId: number
- items: ContractItem[]
- onItemsChange: (items: ContractItem[]) => void
- onTotalAmountChange: (total: number) => void
- currency?: string
- availableBudget?: number
- readOnly?: boolean
-}
-
-export function ContractItemsTable({
- contractId,
- items,
- onItemsChange,
- onTotalAmountChange,
- currency = 'USD',
- availableBudget = 0,
- readOnly = false
-}: ContractItemsTableProps) {
- const [localItems, setLocalItems] = React.useState<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,
- project: item.project || '',
- itemCode: item.itemCode || '',
- itemInfo: item.itemInfo || '',
- specification: item.specification || '',
- quantity: item.quantity || 0,
- quantityUnit: item.quantityUnit || 'KG',
- contractDeliveryDate: item.contractDeliveryDate || '',
- contractUnitPrice: item.contractUnitPrice || 0,
- contractAmount: item.contractAmount || 0,
- contractCurrency: item.contractCurrency || currency,
- isSelected: false
- }))
- setLocalItems(formattedItems)
- onItemsChange(formattedItems)
- } catch (error) {
- console.error('Error loading contract items:', error)
- // 기본 빈 배열로 설정
- setLocalItems([])
- onItemsChange([])
- } finally {
- setIsLoading(false)
- }
- }
-
- loadItems()
- }, [contractId, currency, onItemsChange])
-
- // 로컬 상태와 부모 상태 동기화 (초기 로드 후에는 부모 상태 우선)
- React.useEffect(() => {
- if (items.length > 0) {
- setLocalItems(items)
- }
- }, [items])
-
- const handleSaveItems = async () => {
- try {
- setIsSaving(true)
-
- // validation 체크
- const errors = []
- localItems.forEach((item, index) => {
- if (!item.project) errors.push(`${index + 1}번째 품목의 프로젝트`)
- if (!item.itemCode) errors.push(`${index + 1}번째 품목의 품목코드`)
- if (!item.itemInfo) errors.push(`${index + 1}번째 품목의 Item 정보`)
- if (!item.specification) errors.push(`${index + 1}번째 품목의 사양`)
- if (!item.quantity || item.quantity <= 0) errors.push(`${index + 1}번째 품목의 수량`)
- if (!item.contractUnitPrice || item.contractUnitPrice <= 0) errors.push(`${index + 1}번째 품목의 단가`)
- if (!item.contractDeliveryDate) errors.push(`${index + 1}번째 품목의 납기일`)
- })
-
- if (errors.length > 0) {
- toast.error(`다음 항목을 입력해주세요: ${errors.join(', ')}`)
- return
- }
-
- await updateContractItems(contractId, localItems)
- toast.success('품목정보가 저장되었습니다.')
- } catch (error) {
- console.error('Error saving contract items:', error)
- toast.error('품목정보 저장 중 오류가 발생했습니다.')
- } finally {
- setIsSaving(false)
- }
- }
-
- // 총 금액 계산
- const totalAmount = localItems.reduce((sum, item) => sum + item.contractAmount, 0)
- const totalQuantity = localItems.reduce((sum, item) => sum + item.quantity, 0)
- const totalUnitPrice = localItems.reduce((sum, item) => sum + item.contractUnitPrice, 0)
- const amountDifference = availableBudget - totalAmount
- const budgetRatio = availableBudget > 0 ? (totalAmount / availableBudget) * 100 : 0
-
- // 부모 컴포넌트에 총 금액 전달
- React.useEffect(() => {
- onTotalAmountChange(totalAmount)
- }, [totalAmount, onTotalAmountChange])
-
- // 아이템 업데이트
- const updateItem = (index: number, field: keyof ContractItem, value: any) => {
- const updatedItems = [...localItems]
- updatedItems[index] = { ...updatedItems[index], [field]: value }
-
- // 단가나 수량이 변경되면 금액 자동 계산
- if (field === 'contractUnitPrice' || field === 'quantity') {
- const item = updatedItems[index]
- updatedItems[index].contractAmount = item.contractUnitPrice * item.quantity
- }
-
- setLocalItems(updatedItems)
- onItemsChange(updatedItems)
- }
-
- // 행 추가
- const addRow = () => {
- const newItem: ContractItem = {
- project: '',
- itemCode: '',
- itemInfo: '',
- specification: '',
- quantity: 0,
- quantityUnit: 'KG',
- contractDeliveryDate: '',
- contractUnitPrice: 0,
- contractAmount: 0,
- contractCurrency: currency,
- isSelected: false
- }
- const updatedItems = [...localItems, newItem]
- setLocalItems(updatedItems)
- onItemsChange(updatedItems)
- }
-
- // 선택된 행 삭제
- const deleteSelectedRows = () => {
- const selectedIndices = localItems
- .map((item, index) => item.isSelected ? index : -1)
- .filter(index => index !== -1)
-
- if (selectedIndices.length === 0) {
- toast.error("삭제할 행을 선택해주세요.")
- return
- }
-
- const updatedItems = localItems.filter((_, index) => !selectedIndices.includes(index))
- setLocalItems(updatedItems)
- onItemsChange(updatedItems)
- toast.success(`${selectedIndices.length}개 행이 삭제되었습니다.`)
- }
-
- // 전체 선택/해제
- const toggleSelectAll = (checked: boolean) => {
- const updatedItems = localItems.map(item => ({ ...item, isSelected: checked }))
- setLocalItems(updatedItems)
- onItemsChange(updatedItems)
- }
-
-
- // 통화 포맷팅
- const formatCurrency = (amount: number) => {
- return new Intl.NumberFormat('ko-KR', {
- style: 'currency',
- currency: currency,
- }).format(amount)
- }
-
- const allSelected = localItems.length > 0 && localItems.every(item => item.isSelected)
- const someSelected = localItems.some(item => item.isSelected)
-
- if (isLoading) {
- return (
- <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">총 금액: {totalAmount.toLocaleString()} {currency}</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)}
- </div>
- </div>
- <div className="space-y-1">
- <Label className="text-sm font-medium">가용예산</Label>
- <div className="text-lg font-bold">
- {formatCurrency(availableBudget)}
- </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)}
- </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>
- <TableHead className="w-12">
- {!readOnly && (
- <Checkbox
- checked={allSelected}
- ref={(el) => {
- if (el) (el as any).indeterminate = someSelected && !allSelected
- }}
- onCheckedChange={toggleSelectAll}
- disabled={!isEnabled}
- />
- )}
- </TableHead>
- <TableHead>프로젝트</TableHead>
- <TableHead>품목코드 (PKG No.)</TableHead>
- <TableHead>Item 정보 (자재그룹 / 자재코드)</TableHead>
- <TableHead>규격</TableHead>
- <TableHead className="text-right">수량</TableHead>
- <TableHead>수량단위</TableHead>
- <TableHead>계약납기일</TableHead>
- <TableHead className="text-right">계약단가</TableHead>
- <TableHead className="text-right">계약금액</TableHead>
- <TableHead>계약통화</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {localItems.map((item, index) => (
- <TableRow key={index}>
- <TableCell>
- {!readOnly && (
- <Checkbox
- checked={item.isSelected || false}
- onCheckedChange={(checked) =>
- updateItem(index, 'isSelected', checked)
- }
- disabled={!isEnabled}
- />
- )}
- </TableCell>
- <TableCell>
- {readOnly ? (
- <span>{item.project || '-'}</span>
- ) : (
- <Input
- value={item.project}
- onChange={(e) => updateItem(index, 'project', e.target.value)}
- placeholder="프로젝트"
- className="w-32"
- disabled={!isEnabled}
- />
- )}
- </TableCell>
- <TableCell>
- {readOnly ? (
- <span>{item.itemCode || '-'}</span>
- ) : (
- <Input
- value={item.itemCode}
- onChange={(e) => updateItem(index, 'itemCode', e.target.value)}
- placeholder="품목코드"
- className="w-32"
- disabled={!isEnabled}
- />
- )}
- </TableCell>
- <TableCell>
- {readOnly ? (
- <span>{item.itemInfo || '-'}</span>
- ) : (
- <Input
- value={item.itemInfo}
- onChange={(e) => updateItem(index, 'itemInfo', e.target.value)}
- placeholder="Item 정보"
- className="w-48"
- disabled={!isEnabled}
- />
- )}
- </TableCell>
- <TableCell>
- {readOnly ? (
- <span>{item.specification || '-'}</span>
- ) : (
- <Input
- value={item.specification}
- onChange={(e) => updateItem(index, 'specification', e.target.value)}
- placeholder="규격"
- className="w-32"
- disabled={!isEnabled}
- />
- )}
- </TableCell>
- <TableCell>
- {readOnly ? (
- <span className="text-right">{item.quantity.toLocaleString()}</span>
- ) : (
- <Input
- type="number"
- value={item.quantity}
- onChange={(e) => updateItem(index, 'quantity', parseFloat(e.target.value) || 0)}
- className="w-24 text-right"
- placeholder="0"
- disabled={!isEnabled}
- />
- )}
- </TableCell>
- <TableCell>
- {readOnly ? (
- <span>{item.quantityUnit || '-'}</span>
- ) : (
- <Input
- value={item.quantityUnit}
- onChange={(e) => updateItem(index, 'quantityUnit', e.target.value)}
- placeholder="단위"
- className="w-16"
- disabled={!isEnabled}
- />
- )}
- </TableCell>
- <TableCell>
- {readOnly ? (
- <span>{item.contractDeliveryDate || '-'}</span>
- ) : (
- <Input
- type="date"
- value={item.contractDeliveryDate}
- onChange={(e) => updateItem(index, 'contractDeliveryDate', e.target.value)}
- className="w-36"
- disabled={!isEnabled}
- />
- )}
- </TableCell>
- <TableCell>
- {readOnly ? (
- <span className="text-right">{item.contractUnitPrice.toLocaleString()}</span>
- ) : (
- <Input
- type="number"
- value={item.contractUnitPrice}
- onChange={(e) => updateItem(index, 'contractUnitPrice', parseFloat(e.target.value) || 0)}
- className="w-24 text-right"
- placeholder="0"
- disabled={!isEnabled}
- />
- )}
- </TableCell>
- <TableCell>
- <div className="font-semibold text-primary text-right">
- {formatCurrency(item.contractAmount)}
- </div>
- </TableCell>
- <TableCell>
- {readOnly ? (
- <span>{item.contractCurrency || '-'}</span>
- ) : (
- <Input
- value={item.contractCurrency}
- onChange={(e) => updateItem(index, 'contractCurrency', e.target.value)}
- placeholder="통화"
- className="w-16"
- disabled={!isEnabled}
- />
- )}
- </TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
- </div>
-
- {/* 합계 행 */}
- {localItems.length > 0 && (
- <div className="mt-4">
- <Table>
- <TableBody>
- <TableRow className="bg-muted/50 font-semibold">
- <TableCell colSpan={5} className="text-center">
- 합계
- </TableCell>
- <TableCell className="text-right">
- {totalQuantity.toLocaleString()}
- </TableCell>
- <TableCell>
- {localItems[0]?.quantityUnit || '-'}
- </TableCell>
- <TableCell></TableCell>
- <TableCell className="text-right">
- {totalUnitPrice.toLocaleString()}
- </TableCell>
- <TableCell className="text-right font-bold text-primary">
- {formatCurrency(totalAmount)}
- </TableCell>
- <TableCell>
- {currency}
- </TableCell>
- </TableRow>
- </TableBody>
- </Table>
- </div>
- )}
- </CardContent>
- </Card>
- </AccordionContent>
- </AccordionItem>
- </Accordion>
- )
-}
+'use client' + +import * as React from 'react' +import { Card, CardContent, CardHeader } from '@/components/ui/card' +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Button } from '@/components/ui/button' +import { Checkbox } from '@/components/ui/checkbox' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + Package, + Plus, + Trash2, +} from 'lucide-react' +import { toast } from 'sonner' +import { updateContractItems, getContractItems } from '../service' +import { Save, LoaderIcon } from 'lucide-react' + +interface ContractItem { + id?: number + project: string + itemCode: string + itemInfo: string + specification: string + quantity: number + quantityUnit: string + contractDeliveryDate: string + contractUnitPrice: number + contractAmount: number + contractCurrency: string + isSelected?: boolean + [key: string]: unknown +} + +interface ContractItemsTableProps { + contractId: number + items: ContractItem[] + onItemsChange: (items: ContractItem[]) => void + onTotalAmountChange: (total: number) => void + currency?: string + availableBudget?: number + readOnly?: boolean +} + +export function ContractItemsTable({ + contractId, + items, + onItemsChange, + onTotalAmountChange, + currency = 'USD', + availableBudget = 0, + readOnly = false +}: ContractItemsTableProps) { + // 통화 코드가 null이거나 undefined일 때 기본값 설정 + const safeCurrency = currency || 'USD' + const [localItems, setLocalItems] = React.useState<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, + project: item.project || '', + itemCode: item.itemCode || '', + itemInfo: item.itemInfo || '', + specification: item.specification || '', + quantity: Number(item.quantity) || 0, + quantityUnit: item.quantityUnit || 'KG', + contractDeliveryDate: item.contractDeliveryDate || '', + contractUnitPrice: Number(item.contractUnitPrice) || 0, + contractAmount: Number(item.contractAmount) || 0, + contractCurrency: item.contractCurrency || safeCurrency, + isSelected: false + })) as ContractItem[] + setLocalItems(formattedItems as ContractItem[]) + onItemsChange(formattedItems as ContractItem[]) + } catch (error) { + console.error('Error loading contract items:', error) + // 기본 빈 배열로 설정 + setLocalItems([]) + onItemsChange([]) + } finally { + setIsLoading(false) + } + } + + loadItems() + }, [contractId, currency, onItemsChange]) + + // 로컬 상태와 부모 상태 동기화 (초기 로드 후에는 부모 상태 우선) + React.useEffect(() => { + if (items.length > 0) { + setLocalItems(items) + } + }, [items]) + + const handleSaveItems = async () => { + try { + setIsSaving(true) + + // validation 체크 + const errors: string[] = [] + for (let index = 0; index < localItems.length; index++) { + const item = localItems[index] + if (!item.project) errors.push(`${index + 1}번째 품목의 프로젝트`) + if (!item.itemCode) errors.push(`${index + 1}번째 품목의 품목코드`) + if (!item.itemInfo) errors.push(`${index + 1}번째 품목의 Item 정보`) + if (!item.specification) errors.push(`${index + 1}번째 품목의 사양`) + if (!item.quantity || item.quantity <= 0) errors.push(`${index + 1}번째 품목의 수량`) + if (!item.contractUnitPrice || item.contractUnitPrice <= 0) errors.push(`${index + 1}번째 품목의 단가`) + if (!item.contractDeliveryDate) errors.push(`${index + 1}번째 품목의 납기일`) + } + + if (errors.length > 0) { + toast.error(`다음 항목을 입력해주세요: ${errors.join(', ')}`) + return + } + + await updateContractItems(contractId, localItems as any) + toast.success('품목정보가 저장되었습니다.') + } catch (error) { + console.error('Error saving contract items:', error) + toast.error('품목정보 저장 중 오류가 발생했습니다.') + } finally { + setIsSaving(false) + } + } + + // 총 금액 계산 + const totalAmount = localItems.reduce((sum, item) => sum + item.contractAmount, 0) + const totalQuantity = localItems.reduce((sum, item) => sum + item.quantity, 0) + const totalUnitPrice = localItems.reduce((sum, item) => sum + item.contractUnitPrice, 0) + const amountDifference = availableBudget - totalAmount + const budgetRatio = availableBudget > 0 ? (totalAmount / availableBudget) * 100 : 0 + + // 부모 컴포넌트에 총 금액 전달 + React.useEffect(() => { + onTotalAmountChange(totalAmount) + }, [totalAmount, onTotalAmountChange]) + + // 아이템 업데이트 + const updateItem = (index: number, field: keyof ContractItem, value: string | number | boolean | undefined) => { + const updatedItems = [...localItems] + updatedItems[index] = { ...updatedItems[index], [field]: value } + + // 단가나 수량이 변경되면 금액 자동 계산 + if (field === 'contractUnitPrice' || field === 'quantity') { + const item = updatedItems[index] + updatedItems[index].contractAmount = item.contractUnitPrice * item.quantity + } + + setLocalItems(updatedItems) + onItemsChange(updatedItems) + } + + // 행 추가 + const addRow = () => { + const newItem: ContractItem = { + project: '', + itemCode: '', + itemInfo: '', + specification: '', + quantity: 0, + quantityUnit: 'KG', + contractDeliveryDate: '', + contractUnitPrice: 0, + contractAmount: 0, + contractCurrency: safeCurrency, + isSelected: false + } + const updatedItems = [...localItems, newItem] + setLocalItems(updatedItems) + onItemsChange(updatedItems) + } + + // 선택된 행 삭제 + const deleteSelectedRows = () => { + const selectedIndices = localItems + .map((item, index) => item.isSelected ? index : -1) + .filter(index => index !== -1) + + if (selectedIndices.length === 0) { + toast.error("삭제할 행을 선택해주세요.") + return + } + + const updatedItems = localItems.filter((_, index) => !selectedIndices.includes(index)) + setLocalItems(updatedItems) + onItemsChange(updatedItems) + toast.success(`${selectedIndices.length}개 행이 삭제되었습니다.`) + } + + // 전체 선택/해제 + const toggleSelectAll = (checked: boolean) => { + const updatedItems = localItems.map(item => ({ ...item, isSelected: checked })) + setLocalItems(updatedItems) + onItemsChange(updatedItems) + } + + + // 통화 포맷팅 + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('ko-KR', { + style: 'currency', + currency: safeCurrency, + }).format(amount) + } + + const allSelected = localItems.length > 0 && localItems.every(item => item.isSelected) + const someSelected = localItems.some(item => item.isSelected) + + if (isLoading) { + return ( + <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">총 금액: {totalAmount.toLocaleString()} {currency}</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)} + </div> + </div> + <div className="space-y-1"> + <Label className="text-sm font-medium">가용예산</Label> + <div className="text-lg font-bold"> + {formatCurrency(availableBudget)} + </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)} + </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">프로젝트</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">계약납기일</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.project || '-'}</span> + ) : ( + <Input + value={item.project} + onChange={(e) => updateItem(index, 'project', e.target.value)} + placeholder="프로젝트" + className="h-8 text-sm" + 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> + ) : ( + <Input + value={item.quantityUnit} + onChange={(e) => updateItem(index, 'quantityUnit', e.target.value)} + placeholder="단위" + className="h-8 text-sm w-16" + disabled={!isEnabled} + /> + )} + </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> + ) : ( + <Input + value={item.contractCurrency} + onChange={(e) => updateItem(index, 'contractCurrency', e.target.value)} + placeholder="통화" + className="h-8 text-sm w-16" + disabled={!isEnabled} + /> + )} + </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"> + {totalUnitPrice.toLocaleString()} {currency} + </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)} + </span> + </div> + </div> + </div> + </CardContent> + </Card> + </div> + )} + </CardContent> + </Card> + </AccordionContent> + </AccordionItem> + </Accordion> + ) +} diff --git a/lib/general-contracts/detail/subcontract-checklist.tsx b/lib/general-contracts/detail/general-contract-subcontract-checklist.tsx index ef2e5c23..ce7c8baf 100644 --- a/lib/general-contracts/detail/subcontract-checklist.tsx +++ b/lib/general-contracts/detail/general-contract-subcontract-checklist.tsx @@ -53,8 +53,8 @@ interface SubcontractChecklistProps { }
export function SubcontractChecklist({ contractId, onDataChange, readOnly = false, initialData }: SubcontractChecklistProps) {
- const [isEnabled, setIsEnabled] = useState(true)
- const [data, setData] = useState<SubcontractChecklistData>(initialData || {
+ // 기본 데이터 구조
+ const defaultData: SubcontractChecklistData = {
contractDocumentIssuance: {
workOrderBeforeStart: false,
entrustmentDetails: false,
@@ -72,7 +72,32 @@ export function SubcontractChecklist({ contractId, onDataChange, readOnly = fals 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 = (
@@ -184,6 +209,14 @@ export function SubcontractChecklist({ contractId, onDataChange, readOnly = fals 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: '점검 결과가 유효하지 않습니다.'
+ }
}
}
diff --git a/lib/general-contracts/main/create-general-contract-dialog.tsx b/lib/general-contracts/main/create-general-contract-dialog.tsx index b2f538c3..3eb8b11c 100644 --- a/lib/general-contracts/main/create-general-contract-dialog.tsx +++ b/lib/general-contracts/main/create-general-contract-dialog.tsx @@ -2,7 +2,6 @@ import * as React from "react"
import { useRouter } from "next/navigation"
-import { useSession } from "next-auth/react"
import { Plus } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
@@ -29,9 +28,9 @@ import { createContract, getVendors } from "@/lib/general-contracts/service" import {
GENERAL_CONTRACT_CATEGORIES,
GENERAL_CONTRACT_TYPES,
- GENERAL_EXECUTION_METHODS,
- GENERAL_CONTRACT_SCOPES
+ GENERAL_EXECUTION_METHODS
} from "@/lib/general-contracts/types"
+import { useSession } from "next-auth/react"
interface CreateContractForm {
contractNumber: string
@@ -91,15 +90,6 @@ export function CreateGeneralContractDialog() { fetchVendors()
}, [])
- const generateContractNumber = () => {
- const now = new Date()
- const year = now.getFullYear()
- const month = String(now.getMonth() + 1).padStart(2, '0')
- const day = String(now.getDate()).padStart(2, '0')
- const time = String(now.getHours()).padStart(2, '0') + String(now.getMinutes()).padStart(2, '0')
- return `CT${year}${month}${day}${time}`
- }
-
const handleSubmit = async () => {
// 필수 필드 검증
if (!form.name || !form.category || !form.type || !form.executionMethod ||
@@ -116,7 +106,7 @@ export function CreateGeneralContractDialog() { setIsLoading(true)
const contractData = {
- contractNumber: generateContractNumber(),
+ contractNumber: '',
name: form.name,
category: form.category,
type: form.type,
@@ -129,8 +119,8 @@ export function CreateGeneralContractDialog() { // contractScope: form.contractScope,
// specificationType: form.specificationType,
status: 'Draft',
- registeredById: session?.user?.id ? parseInt(session.user.id) : 3,
- lastUpdatedById: session?.user?.id ? parseInt(session.user.id) : 3,
+ registeredById: session?.user?.id || 1,
+ lastUpdatedById: session?.user?.id || 1,
notes: form.notes,
linkedRfqOrItb: form.linkedRfqOrItb,
linkedBidNumber: form.linkedBidNumber,
diff --git a/lib/general-contracts/main/general-contract-update-sheet.tsx b/lib/general-contracts/main/general-contract-update-sheet.tsx index 57e4fe16..064fded3 100644 --- a/lib/general-contracts/main/general-contract-update-sheet.tsx +++ b/lib/general-contracts/main/general-contract-update-sheet.tsx @@ -38,7 +38,7 @@ import { } 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, "계약종류를 선택해주세요"), @@ -70,7 +70,8 @@ export function GeneralContractUpdateSheet({ 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: { @@ -141,6 +142,8 @@ export function GeneralContractUpdateSheet({ linkedRfqOrItb: data.linkedRfqOrItb, linkedPoNumber: data.linkedPoNumber, linkedBidNumber: data.linkedBidNumber, + vendorId: contract.vendorId, + lastUpdatedById: userId, }) toast.success("계약 정보가 성공적으로 수정되었습니다.") diff --git a/lib/general-contracts/main/general-contracts-table-columns.tsx b/lib/general-contracts/main/general-contracts-table-columns.tsx index 394a2cf5..d7854ee6 100644 --- a/lib/general-contracts/main/general-contracts-table-columns.tsx +++ b/lib/general-contracts/main/general-contracts-table-columns.tsx @@ -46,6 +46,7 @@ export interface GeneralContractListItem { linkedBidNumber?: string
lastUpdatedAt: string
notes?: string
+ vendorId?: number
vendorName?: string
vendorCode?: string
managerName?: string
@@ -188,13 +189,16 @@ const getSelectionMethodText = (method?: string) => { // 금액 포맷팅
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: currency,
+ currency: safeCurrency,
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(numAmount)
@@ -527,15 +531,18 @@ export function getGeneralContractsColumns({ setRowAction }: GetColumnsProps): C </Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
- <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>
- <DropdownMenuSeparator />
+ {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>
),
diff --git a/lib/general-contracts/main/general-contracts-table-toolbar-actions.tsx b/lib/general-contracts/main/general-contracts-table-toolbar-actions.tsx index 28d64824..f16b759a 100644 --- a/lib/general-contracts/main/general-contracts-table-toolbar-actions.tsx +++ b/lib/general-contracts/main/general-contracts-table-toolbar-actions.tsx @@ -56,12 +56,12 @@ export function GeneralContractsTableToolbarActions({ table }: GeneralContractsT return
}
- // 계약폐기 확인
- const confirmed = window.confirm(
- `선택한 ${selectedContracts.length}개 계약을 폐기하시겠습니까?\n계약폐기 후에는 복구할 수 없습니다.`
- )
+ // // 계약폐기 확인
+ // const confirmed = window.confirm(
+ // `선택한 ${selectedContracts.length}개 계약을 폐기하시겠습니까?\n계약폐기 후에는 복구할 수 없습니다.`
+ // )
- if (!confirmed) return
+ // if (!confirmed) return
try {
// 선택된 모든 계약을 폐기 처리
diff --git a/lib/general-contracts/service.ts b/lib/general-contracts/service.ts index 6d9e5c39..7f95ae3d 100644 --- a/lib/general-contracts/service.ts +++ b/lib/general-contracts/service.ts @@ -1,15 +1,22 @@ 'use server'
import { revalidatePath } from 'next/cache'
-import { eq, and, or, desc, asc, count, ilike, SQL, gte, lte, lt } from 'drizzle-orm'
+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 { 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 {
@@ -213,6 +220,7 @@ export async function getGeneralContracts(input: GetGeneralContractsSchema) { lastUpdatedAt: generalContracts.lastUpdatedAt,
notes: generalContracts.notes,
// Vendor info
+ vendorId: generalContracts.vendorId,
vendorName: vendors.vendorName,
vendorCode: vendors.vendorCode,
// User info
@@ -342,41 +350,11 @@ export async function getContractBasicInfo(id: number) { }
}
-// 계약번호 생성 함수
-async function generateContractNumber(registeredById: number, contractType: string): Promise<string> {
- // 발주담당자코드 3자리 (사용자 ID를 3자리로 패딩)
- const managerCode = String(registeredById).padStart(3, '0')
-
- // 계약종류 2자리 (영문)
- const typeCode = contractType.substring(0, 2).toUpperCase()
-
- // 일련번호 3자리 (현재 날짜 기준으로 생성)
- const today = new Date()
-
- // 같은 날짜의 계약 개수 조회
- const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate())
- const endOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1)
-
- const countResult = await db
- .select({ count: count() })
- .from(generalContracts)
- .where(
- and(
- gte(generalContracts.registeredAt, startOfDay),
- lt(generalContracts.registeredAt, endOfDay)
- )
- )
-
- const sequenceNumber = String((countResult[0]?.count || 0) + 1).padStart(3, '0')
-
- return `C${managerCode}${typeCode}${sequenceNumber}`
-}
-
export async function createContract(data: Record<string, unknown>) {
try {
// 계약번호 자동 생성
- const contractNumber = data.contractNumber || await generateContractNumber(
- data.registeredById as number,
+ // TODO: 구매 발주담당자 코드 필요 - 파라미터 추가
+ const contractNumber = await generateContractNumber(
data.type as string
)
@@ -434,6 +412,7 @@ export async function createContract(data: Record<string, unknown>) { notes: data.notes as string,
})
.returning()
+ console.log(newContract,"newContract")
revalidatePath('/general-contracts')
@@ -489,6 +468,20 @@ export async function updateContractBasicInfo(id: number, data: Record<string, u }, 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,
@@ -500,22 +493,22 @@ export async function updateContractBasicInfo(id: number, data: Record<string, u linkedBidNumber,
notes,
paymentBeforeDelivery, // JSON 필드
- paymentDelivery,
+ paymentDelivery: convertToNumberOrNull(paymentDelivery),
paymentAfterDelivery, // JSON 필드
paymentTerm,
taxType,
- liquidatedDamages,
- liquidatedDamagesPercent,
+ liquidatedDamages: convertToNumberOrNull(liquidatedDamages),
+ liquidatedDamagesPercent: convertToNumberOrNull(liquidatedDamagesPercent),
deliveryType,
deliveryTerm,
shippingLocation,
dischargeLocation,
- contractDeliveryDate,
+ contractDeliveryDate: convertEmptyStringToNull(contractDeliveryDate),
contractEstablishmentConditions, // JSON 필드
interlockingSystem,
mandatoryDocuments, // JSON 필드
contractTerminationConditions, // JSON 필드
- contractAmount: calculatedContractAmount,
+ contractAmount: calculatedContractAmount || 0,
lastUpdatedAt: new Date(),
lastUpdatedById: userId,
}
@@ -704,7 +697,7 @@ async function updateContractAmount(contractId: number) { await db
.update(generalContracts)
.set({
- contractAmount: calculatedContractAmount,
+ contractAmount: calculatedContractAmount || 0,
lastUpdatedAt: new Date()
})
.where(eq(generalContracts.id, contractId))
@@ -719,7 +712,7 @@ export async function updateSubcontractChecklist(contractId: number, checklistDa await db
.update(generalContracts)
.set({
- subcontractChecklist: checklistData,
+ complianceChecklist: checklistData,
lastUpdatedAt: new Date()
})
.where(eq(generalContracts.id, contractId))
@@ -732,6 +725,98 @@ export async function updateSubcontractChecklist(contractId: number, checklistDa }
}
+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
@@ -843,10 +928,19 @@ export async function updateContract(id: number, data: Record<string, unknown>) }
})
- // 숫자 필드들 추가 정리
+ // 숫자 필드들 추가 정리 (vendorId는 NOT NULL이므로 null로 설정하지 않음)
numericFields.forEach(field => {
- if (cleanedData[field] === '' || cleanedData[field] === undefined || cleanedData[field] === 0) {
- cleanedData[field] = null
+ 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
+ }
}
})
@@ -997,7 +1091,6 @@ export async function getVendors() { vendorCode: vendors.vendorCode,
})
.from(vendors)
- .where(eq(vendors.status, 'ACTIVE'))
.orderBy(asc(vendors.vendorName))
return vendorList
@@ -1023,13 +1116,13 @@ export async function uploadContractAttachment(contractId: number, file: File, u userId,
)
- if (saveResult.success && saveResult.filePath) {
+ if (saveResult.success && saveResult.publicPath) {
// generalContractAttachments 테이블에 저장
const [attachment] = await db.insert(generalContractAttachments).values({
contractId,
documentName,
fileName: saveResult.fileName || file.name,
- filePath: saveResult.filePath,
+ filePath: saveResult.publicPath,
uploadedById: userIdNumber,
uploadedAt: new Date(),
}).returning()
@@ -1139,6 +1232,384 @@ export async function deleteContractAttachment(attachmentId: number, contractId: }
}
+// 계약승인요청용 파일 업로드 (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
+
+ // contractItems 테이블에 품목 정보 저장 (general-contract-items가 있을 때만)
+ if (contractSummary.items && contractSummary.items.length > 0) {
+ // 새 품목 추가
+ for (const item of contractSummary.items) {
+ await db.insert(contractItems).values({
+ contractId,
+ itemId: item.itemId || 2602, // 기본값 설정
+ 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 || '',
+ })
+ }
+ }
+
+ // 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",
+ },
+ })
+ } catch (error) {
+ console.error('계약승인요청 전송 오류:', error)
+
+ }
+ //계약상태변경
+ revalidatePath('/evcp/general-contracts')
+ revalidatePath('/evcp/general-contracts/detail')
+ revalidatePath('/evcp/general-contracts/detail/contract-approval-request-dialog')
+ 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.contractType || basicInfo.type || 'UP',
+ basicInfo.purchaseManagerCode
+ )
+
+ 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 {
@@ -1169,8 +1640,8 @@ export async function updateFieldServiceRate( .update(generalContracts)
.set({
fieldServiceRates: fieldServiceRateData,
- updatedAt: new Date(),
- updatedBy: userId
+ lastUpdatedAt: new Date(),
+ lastUpdatedById: userId
})
.where(eq(generalContracts.id, contractId))
@@ -1212,8 +1683,8 @@ export async function updateOffsetDetails( .update(generalContracts)
.set({
offsetDetails: offsetDetailsData,
- updatedAt: new Date(),
- updatedBy: userId
+ lastUpdatedAt: new Date(),
+ lastUpdatedById: userId
})
.where(eq(generalContracts.id, contractId))
@@ -1224,3 +1695,89 @@ export async function updateOffsetDetails( throw new Error('회입/상계내역 업데이트에 실패했습니다.')
}
}
+
+// 계약번호 생성 함수
+export async function generateContractNumber(
+ contractType: string,
+ purchaseManagerCode?: 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' // 기본값
+ // 발주담당자 코드 처리
+ 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 lastSequence = parseInt(lastContractNumber.slice(-3))
+ sequenceNumber = lastSequence + 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
+ const lastSequence = parseInt(lastContractNumber.slice(-3))
+ sequenceNumber = lastSequence + 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('계약번호 생성에 실패했습니다.')
+ }
+}
diff --git a/lib/mail/templates/contract-approval-request.hbs b/lib/mail/templates/contract-approval-request.hbs new file mode 100644 index 00000000..eef09fb8 --- /dev/null +++ b/lib/mail/templates/contract-approval-request.hbs @@ -0,0 +1,176 @@ +<!-- contract-approval-request.hbs --> +<!DOCTYPE html> +<html> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>계약 승인 요청</title> + <style> + body { + font-family: 'Malgun Gothic', '맑은 고딕', Arial, sans-serif; + line-height: 1.6; + color: #333; + max-width: 600px; + margin: 0 auto; + padding: 20px; + background-color: #f5f5f5; + } + .container { + background-color: #ffffff; + border-radius: 8px; + padding: 30px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + } + .header { + text-align: center; + border-bottom: 2px solid #e9ecef; + padding-bottom: 20px; + margin-bottom: 30px; + } + .header h1 { + color: #2563eb; + font-size: 24px; + margin: 0; + } + .content { + margin-bottom: 30px; + } + .info-box { + background-color: #f8f9fa; + border-left: 4px solid #2563eb; + padding: 15px; + margin: 20px 0; + border-radius: 4px; + } + .contract-details { + background-color: #f8f9fa; + border-radius: 6px; + padding: 15px; + margin: 15px 0; + } + .detail-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 0; + border-bottom: 1px solid #e9ecef; + } + .detail-row:last-child { + border-bottom: none; + } + .detail-label { + font-weight: bold; + color: #495057; + } + .detail-value { + color: #212529; + } + .button { + display: inline-block; + background-color: #2563eb; + color: white; + padding: 12px 24px; + text-decoration: none; + border-radius: 6px; + font-weight: bold; + text-align: center; + margin: 20px auto; + } + .button:hover { + background-color: #1d4ed8; + } + .message-box { + background-color: #fef3c7; + border: 1px solid #f59e0b; + border-radius: 6px; + padding: 15px; + margin: 20px 0; + } + .footer { + border-top: 1px solid #e9ecef; + padding-top: 20px; + margin-top: 30px; + text-align: center; + color: #6b7280; + font-size: 14px; + } + .urgent-notice { + background-color: #fef2f2; + border: 1px solid #fca5a5; + border-radius: 6px; + padding: 15px; + margin: 20px 0; + text-align: center; + } + .urgent-notice strong { + color: #dc2626; + } + </style> +</head> +<body> + <div class="container"> + <div class="header"> + <h1>📋 계약 승인 요청</h1> + </div> + + <div class="content"> + <p>안녕하세요, {{vendorName}}님</p> + + <p>새로운 계약이 생성되어 승인을 요청드립니다.</p> + + <div class="info-box"> + <p><strong>📄 계약 정보</strong></p> + <ul style="margin: 10px 0;"> + <li>계약 ID: {{contractId}}</li> + <li>요청 일시: {{requestDate}}</li> + </ul> + </div> + + {{#if contractDetails}} + <div class="contract-details"> + <p><strong>📋 상세 정보:</strong></p> + {{#each contractDetails}} + <div class="detail-row"> + <span class="detail-label">{{@key}}:</span> + <span class="detail-value">{{this}}</span> + </div> + {{/each}} + </div> + {{/if}} + + {{#if message}} + <div class="message-box"> + <p><strong>💬 요청자 메시지:</strong></p> + <p style="margin: 8px 0; white-space: pre-line;">{{message}}</p> + </div> + {{/if}} + + <div class="urgent-notice"> + <p><strong>⚡ 긴급 안내</strong></p> + <p>계약 승인은 7일 이내에 완료되어야 합니다. 기한 내에 승인하지 않으시면 계약이 자동 취소될 수 있습니다.</p> + </div> + + <div style="text-align: center;"> + <a href="{{loginUrl}}" class="button"> + 🔗 계약 승인 페이지로 이동 + </a> + </div> + + <div class="info-box"> + <p><strong>💡 승인 절차 안내:</strong></p> + <ul style="margin: 10px 0;"> + <li>위 링크를 클릭하여 계약 시스템에 로그인해주세요.</li> + <li>계약 내용을 자세히 확인하신 후 승인 또는 거부 버튼을 클릭해주세요.</li> + <li>거부하실 경우 반드시 거부 사유를 기재해주세요.</li> + <li>문의사항이 있으시면 계약 담당자에게 직접 연락해주세요.</li> + </ul> + </div> + </div> + + <div class="footer"> + <p>이 메일은 계약 관리 시스템에서 자동으로 발송되었습니다.</p> + <p>문의사항이 있으시면 시스템 관리자에게 연락해주세요.</p> + </div> + </div> +</body> +</html> |
