summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-12-01 03:09:00 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-12-01 03:09:00 +0000
commit3c9a95332298450c7e0f75bfb08944439e1a3739 (patch)
tree1ecc04bf97dfd572736ee56119b02bd72678720d
parentc92ddd6bae8e187cccfddb37373460ebea0ade27 (diff)
(최겸)구매 일반계약 템플릿 자동 연동 및 매핑 기능 추가
-rw-r--r--lib/general-contracts/detail/general-contract-approval-request-dialog.tsx281
-rw-r--r--lib/general-contracts/detail/general-contract-review-request-dialog.tsx281
-rw-r--r--lib/general-contracts/main/create-general-contract-dialog.tsx132
-rw-r--r--lib/general-contracts/main/general-contract-update-sheet.tsx15
-rw-r--r--lib/general-contracts/main/general-contracts-table-columns.tsx19
-rw-r--r--lib/general-contracts/service.ts29
-rw-r--r--lib/general-contracts/utils.ts304
7 files changed, 571 insertions, 490 deletions
diff --git a/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx b/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx
index 25c1fb9a..46251c71 100644
--- a/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx
+++ b/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx
@@ -29,8 +29,12 @@ import {
getContractItems,
getSubcontractChecklist,
uploadContractApprovalFile,
- sendContractApprovalRequest
+ sendContractApprovalRequest,
+ getContractById,
+ getContractTemplateByContractType,
+ getStorageInfo
} from '../service'
+import { mapContractDataToTemplateVariables } from '../utils'
interface ContractApprovalRequestDialogProps {
contract: Record<string, unknown>
@@ -42,6 +46,7 @@ interface ContractSummary {
basicInfo: Record<string, unknown>
items: Record<string, unknown>[]
subcontractChecklist: Record<string, unknown> | null
+ storageInfo?: Record<string, unknown>[]
}
export function ContractApprovalRequestDialog({
@@ -70,63 +75,6 @@ export function ContractApprovalRequestDialog({
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 (
@@ -312,6 +260,18 @@ export function ContractApprovalRequestDialog({
} catch {
console.log('Subcontract Checklist 데이터 없음')
}
+
+ // 임치(물품보관) 계약 정보 확인 (SG)
+ try {
+ if (summary.basicInfo?.contractType === 'SG') {
+ const storageData = await getStorageInfo(contractId)
+ if (storageData && storageData.length > 0) {
+ summary.storageInfo = storageData
+ }
+ }
+ } catch {
+ console.log('임치계약 정보 없음')
+ }
console.log('contractSummary 구조:', summary)
console.log('basicInfo 내용:', summary.basicInfo)
@@ -324,55 +284,42 @@ export function ContractApprovalRequestDialog({
}
}, [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('로그인이 필요합니다.')
+ // 3단계: PDF 생성 및 미리보기 (PDFTron 사용) - 템플릿 자동 로드
+ const generatePdf = async () => {
+ if (!contractSummary) {
+ toast.error('계약 정보가 필요합니다.')
return
}
setIsLoading(true)
try {
- // 서버액션을 사용하여 파일 저장 (본 계약문서로 고정)
- const result = await uploadContractApprovalFile(
- contractId,
- file,
- userId
- )
+ // 1. 계약 유형에 맞는 템플릿 조회
+ const contractType = contractSummary.basicInfo.contractType as string
+ const templateResult = await getContractTemplateByContractType(contractType)
- if (result.success) {
- setUploadedFile(file)
- toast.success('파일이 업로드되었습니다.')
- } else {
- throw new Error(result.error || '파일 업로드 실패')
+ if (!templateResult.success || !templateResult.template) {
+ throw new Error(templateResult.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
- }
+ const template = templateResult.template
- setIsLoading(true)
- try {
- // PDFTron을 사용해서 변수 치환 및 PDF 변환
+ // 2. 템플릿 파일 다운로드
+ const templateResponse = await fetch("/api/contracts/get-template", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ templatePath: template.filePath }),
+ })
+
+ if (!templateResponse.ok) {
+ throw new Error("템플릿 파일을 다운로드할 수 없습니다.")
+ }
+
+ const templateBlob = await templateResponse.blob()
+ const templateFile = new File([templateBlob], template.fileName || "template.docx", {
+ type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
+ })
+
+ // 3. PDFTron을 사용해서 변수 치환 및 PDF 변환
// @ts-ignore
const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer)
@@ -394,34 +341,34 @@ export function ContractApprovalRequestDialog({
const { Core } = instance
const { createDocument } = Core
- // 템플릿 문서 생성 및 변수 치환
- const templateDoc = await createDocument(uploadedFile, {
- filename: uploadedFile.name,
- extension: 'docx',
- })
+ // 템플릿 문서 생성 및 변수 치환
+ const templateDoc = await createDocument(templateFile, {
+ filename: templateFile.name,
+ extension: 'docx',
+ })
- // LOI 템플릿용 변수 매핑
- const mappedTemplateData = mapContractSummaryToLOITemplate(contractSummary)
+ // 템플릿 변수 매핑
+ const mappedTemplateData = mapContractDataToTemplateVariables(contractSummary)
- console.log("🔄 변수 치환 시작:", mappedTemplateData)
- await templateDoc.applyTemplateValues(mappedTemplateData as any)
- console.log("✅ 변수 치환 완료")
+ console.log("🔄 변수 치환 시작:", mappedTemplateData)
+ await templateDoc.applyTemplateValues(mappedTemplateData as any)
+ console.log("✅ 변수 치환 완료")
- // PDF 변환
- const fileData = await templateDoc.getFileData()
+ // PDF 변환
+ const fileData = await templateDoc.getFileData()
const pdfBuffer = await (Core as any).officeToPDFBuffer(fileData, { extension: 'docx' })
- console.log(`✅ PDF 변환 완료: ${uploadedFile.name}`, `크기: ${pdfBuffer.byteLength} bytes`)
+ console.log(`✅ PDF 변환 완료: ${templateFile.name}`, `크기: ${pdfBuffer.byteLength} bytes`)
- // PDF 버퍼를 Blob URL로 변환하여 미리보기
- const pdfBlob = new Blob([pdfBuffer], { type: 'application/pdf' })
- const pdfUrl = URL.createObjectURL(pdfBlob)
- setGeneratedPdfUrl(pdfUrl)
-
- // PDF 버퍼를 상태에 저장 (최종 전송 시 사용)
- setGeneratedPdfBuffer(new Uint8Array(pdfBuffer))
-
- toast.success('PDF가 생성되었습니다.')
+ // 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 정리
@@ -429,9 +376,10 @@ export function ContractApprovalRequestDialog({
document.body.removeChild(tempDiv)
}
- } catch (error) {
+ } catch (error: any) {
console.error('❌ PDF 생성 실패:', error)
- toast.error('PDF 생성 중 오류가 발생했습니다.')
+ const errorMessage = error instanceof Error ? error.message : (error?.message || '알 수 없는 오류')
+ toast.error(`PDF 생성 중 오류가 발생했습니다: ${errorMessage}`)
} finally {
setIsLoading(false)
}
@@ -498,13 +446,13 @@ export function ContractApprovalRequestDialog({
setPdfViewerInstance(instance)
// PDF 버퍼를 Blob으로 변환
- const pdfBlob = new Blob([generatedPdfBuffer], { type: 'application/pdf' })
+ const pdfBlob = new Blob([generatedPdfBuffer as any], { type: 'application/pdf' })
const pdfUrl = URL.createObjectURL(pdfBlob)
console.log("🔄 PDF Blob URL 생성:", pdfUrl)
// 문서 로드
console.log("🔄 문서 로드 시작")
- const { documentViewer } = instance.Core
+ const { documentViewer } = (instance as any).Core
// 문서 로드 이벤트 대기
await new Promise((resolve, reject) => {
@@ -553,7 +501,7 @@ export function ContractApprovalRequestDialog({
return
}
- const pdfBlob = new Blob([generatedPdfBuffer], { type: 'application/pdf' })
+ const pdfBlob = new Blob([generatedPdfBuffer as any], { type: 'application/pdf' })
const pdfUrl = URL.createObjectURL(pdfBlob)
const link = document.createElement('a')
@@ -715,7 +663,7 @@ export function ContractApprovalRequestDialog({
</DialogHeader>
<Tabs value={currentStep.toString()} className="w-full">
- <TabsList className="grid w-full grid-cols-4">
+ <TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="1" disabled={currentStep < 1}>
1. 계약 현황 정리
</TabsTrigger>
@@ -723,10 +671,7 @@ export function ContractApprovalRequestDialog({
2. 기본계약 체크
</TabsTrigger>
<TabsTrigger value="3" disabled={currentStep < 3}>
- 3. 문서 업로드
- </TabsTrigger>
- <TabsTrigger value="4" disabled={currentStep < 4}>
- 4. PDF 미리보기
+ 3. PDF 미리보기
</TabsTrigger>
</TabsList>
@@ -843,7 +788,7 @@ export function ContractApprovalRequestDialog({
<div>
<span className="font-medium">계약성립조건:</span>
{contractSummary?.basicInfo?.contractEstablishmentConditions &&
- Object.entries(contractSummary.basicInfo.contractEstablishmentConditions)
+ Object.entries(contractSummary.basicInfo.contractEstablishmentConditions as Record<string, unknown>)
.filter(([, value]) => value === true)
.map(([key]) => key)
.join(', ') || '없음'}
@@ -851,7 +796,7 @@ export function ContractApprovalRequestDialog({
<div>
<span className="font-medium">계약해지조건:</span>
{contractSummary?.basicInfo?.contractTerminationConditions &&
- Object.entries(contractSummary.basicInfo.contractTerminationConditions)
+ Object.entries(contractSummary.basicInfo.contractTerminationConditions as Record<string, unknown>)
.filter(([, value]) => value === true)
.map(([key]) => key)
.join(', ') || '없음'}
@@ -878,9 +823,9 @@ export function ContractApprovalRequestDialog({
<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="font-medium">{String(item.itemInfo || item.description || `품목 ${index + 1}`)}</div>
<div className="text-muted-foreground">
- 수량: {item.quantity || 0} | 단가: {item.contractUnitPrice || item.unitPrice || 0}
+ 수량: {String(item.quantity || 0)} | 단가: {String(item.contractUnitPrice || item.unitPrice || 0)}
</div>
</div>
))}
@@ -1022,75 +967,11 @@ export function ContractApprovalRequestDialog({
</div>
</TabsContent>
- {/* 3단계: 문서 업로드 */}
+ {/* 3단계: PDF 미리보기 */}
<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>
@@ -1168,7 +1049,7 @@ export function ContractApprovalRequestDialog({
</Card>
<div className="flex justify-between">
- <Button variant="outline" onClick={() => setCurrentStep(3)}>
+ <Button variant="outline" onClick={() => setCurrentStep(2)}>
이전 단계
</Button>
<Button
diff --git a/lib/general-contracts/detail/general-contract-review-request-dialog.tsx b/lib/general-contracts/detail/general-contract-review-request-dialog.tsx
index b487ae25..c31ce4ac 100644
--- a/lib/general-contracts/detail/general-contract-review-request-dialog.tsx
+++ b/lib/general-contracts/detail/general-contract-review-request-dialog.tsx
@@ -9,11 +9,9 @@ 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 { Input } from '@/components/ui/input'
import { toast } from 'sonner'
import {
FileText,
- Upload,
Eye,
Send,
CheckCircle,
@@ -23,10 +21,12 @@ import {
getBasicInfo,
getContractItems,
getSubcontractChecklist,
- uploadContractReviewFile,
sendContractReviewRequest,
- getContractById
+ getContractById,
+ getContractTemplateByContractType,
+ getStorageInfo
} from '../service'
+import { mapContractDataToTemplateVariables } from '../utils'
interface ContractReviewRequestDialogProps {
contract: Record<string, unknown>
@@ -38,6 +38,7 @@ interface ContractSummary {
basicInfo: Record<string, unknown>
items: Record<string, unknown>[]
subcontractChecklist: Record<string, unknown> | null
+ storageInfo?: Record<string, unknown>[]
}
export function ContractReviewRequestDialog({
@@ -58,63 +59,6 @@ export function ContractReviewRequestDialog({
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: unknown) => {
- 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 사용
- }
- }
// 1단계: 계약 현황 수집
const collectContractSummary = React.useCallback(async () => {
@@ -164,6 +108,18 @@ export function ContractReviewRequestDialog({
} catch {
console.log('Subcontract Checklist 데이터 없음')
}
+
+ // 임치(물품보관) 계약 정보 확인 (SG)
+ try {
+ if (summary.basicInfo?.contractType === 'SG') {
+ const storageData = await getStorageInfo(contractId)
+ if (storageData && storageData.length > 0) {
+ summary.storageInfo = storageData
+ }
+ }
+ } catch {
+ console.log('임치계약 정보 없음')
+ }
console.log('contractSummary 구조:', summary)
console.log('basicInfo 내용:', summary.basicInfo)
@@ -176,56 +132,43 @@ export function ContractReviewRequestDialog({
}
}, [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('로그인이 필요합니다.')
+ // 2단계: PDF 생성 및 미리보기 (PDFTron 사용) - 템플릿 자동 로드
+ const generatePdf = async () => {
+ if (!contractSummary) {
+ toast.error('계약 정보가 필요합니다.')
return
}
setIsLoading(true)
try {
- // 서버액션을 사용하여 파일 저장 (조건검토용)
- const result = await uploadContractReviewFile(
- contractId,
- file,
- userId
- )
+ // 1. 계약 유형에 맞는 템플릿 조회
+ const contractType = contractSummary.basicInfo.contractType as string
+ const templateResult = await getContractTemplateByContractType(contractType)
- if (result.success) {
- setUploadedFile(file)
- toast.success('파일이 업로드되었습니다.')
- } else {
- throw new Error(result.error || '파일 업로드 실패')
+ if (!templateResult.success || !templateResult.template) {
+ throw new Error(templateResult.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
- }
+ const template = templateResult.template
- setIsLoading(true)
- try {
- // PDFTron을 사용해서 변수 치환 및 PDF 변환
- // @ts-expect-error - PDFTron WebViewer dynamic import
+ // 2. 템플릿 파일 다운로드
+ const templateResponse = await fetch("/api/contracts/get-template", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ templatePath: template.filePath }),
+ })
+
+ if (!templateResponse.ok) {
+ throw new Error("템플릿 파일을 다운로드할 수 없습니다.")
+ }
+
+ const templateBlob = await templateResponse.blob()
+ const templateFile = new File([templateBlob], template.fileName || "template.docx", {
+ type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
+ })
+
+ // 3. PDFTron을 사용해서 변수 치환 및 PDF 변환
+ // @ts-ignore
const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer)
// 임시 WebViewer 인스턴스 생성 (DOM에 추가하지 않음)
@@ -246,34 +189,34 @@ export function ContractReviewRequestDialog({
const { Core } = instance
const { createDocument } = Core
- // 템플릿 문서 생성 및 변수 치환
- const templateDoc = await createDocument(uploadedFile, {
- filename: uploadedFile.name,
- extension: 'docx',
- })
+ // 템플릿 문서 생성 및 변수 치환
+ const templateDoc = await createDocument(templateFile, {
+ filename: templateFile.name,
+ extension: 'docx',
+ })
- // LOI 템플릿용 변수 매핑
- const mappedTemplateData = mapContractSummaryToLOITemplate(contractSummary)
+ // 템플릿 변수 매핑
+ const mappedTemplateData = mapContractDataToTemplateVariables(contractSummary)
- console.log("🔄 변수 치환 시작:", mappedTemplateData)
- await templateDoc.applyTemplateValues(mappedTemplateData as Record<string, unknown>)
- console.log("✅ 변수 치환 완료")
+ console.log("🔄 변수 치환 시작:", mappedTemplateData)
+ await templateDoc.applyTemplateValues(mappedTemplateData as any)
+ console.log("✅ 변수 치환 완료")
- // PDF 변환
- const fileData = await templateDoc.getFileData()
- const pdfBuffer = await (Core as { officeToPDFBuffer: (data: unknown, options: { extension: string }) => Promise<Uint8Array> }).officeToPDFBuffer(fileData, { extension: 'docx' })
+ // PDF 변환
+ const fileData = await templateDoc.getFileData()
+ const pdfBuffer = await (Core as any).officeToPDFBuffer(fileData, { extension: 'docx' })
- console.log(`✅ PDF 변환 완료: ${uploadedFile.name}`, `크기: ${pdfBuffer.byteLength} bytes`)
+ console.log(`✅ PDF 변환 완료: ${templateFile.name}`, `크기: ${pdfBuffer.byteLength} bytes`)
- // PDF 버퍼를 Blob URL로 변환하여 미리보기
- const pdfBlob = new Blob([pdfBuffer], { type: 'application/pdf' })
- const pdfUrl = URL.createObjectURL(pdfBlob)
- setGeneratedPdfUrl(pdfUrl)
-
- // PDF 버퍼를 상태에 저장 (최종 전송 시 사용)
- setGeneratedPdfBuffer(new Uint8Array(pdfBuffer))
-
- toast.success('PDF가 생성되었습니다.')
+ // 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 정리
@@ -283,7 +226,8 @@ export function ContractReviewRequestDialog({
} catch (error) {
console.error('❌ PDF 생성 실패:', error)
- toast.error('PDF 생성 중 오류가 발생했습니다.')
+ const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류'
+ toast.error(`PDF 생성 중 오류가 발생했습니다: ${errorMessage}`)
} finally {
setIsLoading(false)
}
@@ -298,7 +242,7 @@ export function ContractReviewRequestDialog({
setIsLoading(true)
try {
- // @ts-expect-error - PDFTron WebViewer dynamic import
+ // @ts-ignore - PDFTron WebViewer dynamic import
const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer)
// 기존 인스턴스가 있다면 정리
@@ -350,13 +294,13 @@ export function ContractReviewRequestDialog({
setPdfViewerInstance(instance)
// PDF 버퍼를 Blob으로 변환
- const pdfBlob = new Blob([generatedPdfBuffer], { type: 'application/pdf' })
+ const pdfBlob = new Blob([generatedPdfBuffer as any], { type: 'application/pdf' })
const pdfUrl = URL.createObjectURL(pdfBlob)
console.log("🔄 PDF Blob URL 생성:", pdfUrl)
// 문서 로드
console.log("🔄 문서 로드 시작")
- const { documentViewer } = instance.Core
+ const { documentViewer } = (instance as any).Core
// 문서 로드 이벤트 대기
await new Promise((resolve, reject) => {
@@ -406,7 +350,7 @@ export function ContractReviewRequestDialog({
return
}
- const pdfBlob = new Blob([generatedPdfBuffer], { type: 'application/pdf' })
+ const pdfBlob = new Blob([generatedPdfBuffer as any], { type: 'application/pdf' })
const pdfUrl = URL.createObjectURL(pdfBlob)
const link = document.createElement('a')
@@ -426,6 +370,8 @@ export function ContractReviewRequestDialog({
if (pdfViewerInstance) {
try {
console.log("🔄 WebViewer 인스턴스 정리")
+ // @ts-expect-error - PDFTron WebViewer dynamic import
+ // @ts-ignore
pdfViewerInstance.UI.dispose()
} catch (error) {
console.warn('WebViewer 정리 중 오류:', error)
@@ -500,7 +446,6 @@ export function ContractReviewRequestDialog({
closePdfPreview()
// 상태 초기화
setCurrentStep(1)
- setUploadedFile(null)
setGeneratedPdfUrl(null)
setGeneratedPdfBuffer(null)
setIsPdfPreviewVisible(false)
@@ -520,15 +465,12 @@ export function ContractReviewRequestDialog({
</DialogHeader>
<Tabs value={currentStep.toString()} className="w-full">
- <TabsList className="grid w-full grid-cols-3">
+ <TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="1" disabled={currentStep < 1}>
1. 미리보기
</TabsTrigger>
<TabsTrigger value="2" disabled={currentStep < 2}>
- 2. 템플릿 업로드
- </TabsTrigger>
- <TabsTrigger value="3" disabled={currentStep < 3}>
- 3. PDF 미리보기
+ 2. PDF 미리보기
</TabsTrigger>
</TabsList>
@@ -645,7 +587,7 @@ export function ContractReviewRequestDialog({
<div>
<span className="font-medium">계약성립조건:</span>
{contractSummary?.basicInfo?.contractEstablishmentConditions &&
- Object.entries(contractSummary.basicInfo.contractEstablishmentConditions)
+ Object.entries(contractSummary.basicInfo.contractEstablishmentConditions as Record<string, unknown>)
.filter(([, value]) => value === true)
.map(([key]) => key)
.join(', ') || '없음'}
@@ -653,7 +595,7 @@ export function ContractReviewRequestDialog({
<div>
<span className="font-medium">계약해지조건:</span>
{contractSummary?.basicInfo?.contractTerminationConditions &&
- Object.entries(contractSummary.basicInfo.contractTerminationConditions)
+ Object.entries(contractSummary.basicInfo.contractTerminationConditions as Record<string, unknown>)
.filter(([, value]) => value === true)
.map(([key]) => key)
.join(', ') || '없음'}
@@ -680,9 +622,9 @@ export function ContractReviewRequestDialog({
<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="font-medium">{String(item.itemInfo || item.description || `품목 ${index + 1}`)}</div>
<div className="text-muted-foreground">
- 수량: {item.quantity || 0} | 단가: {item.contractUnitPrice || item.unitPrice || 0}
+ 수량: {String(item.quantity || 0)} | 단가: {String(item.contractUnitPrice || item.unitPrice || 0)}
</div>
</div>
))}
@@ -734,65 +676,11 @@ export function ContractReviewRequestDialog({
</div>
</TabsContent>
- {/* 2단계: 문서 업로드 */}
+ {/* 2단계: PDF 미리보기 (자동 생성) */}
<TabsContent value="2" 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-review">파일 업로드</Label>
- <Input
- id="file-upload-review"
- 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>
-
- {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(1)}>
- 이전 단계
- </Button>
- <Button
- onClick={() => setCurrentStep(3)}
- disabled={!uploadedFile}
- >
- 다음 단계
- </Button>
- </div>
- </TabsContent>
-
- {/* 3단계: PDF 미리보기 */}
- <TabsContent value="3" className="space-y-4">
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
<Eye className="h-5 w-5 text-purple-600" />
PDF 미리보기
</CardTitle>
@@ -870,7 +758,7 @@ export function ContractReviewRequestDialog({
</Card>
<div className="flex justify-between">
- <Button variant="outline" onClick={() => setCurrentStep(2)}>
+ <Button variant="outline" onClick={() => setCurrentStep(1)}>
이전 단계
</Button>
<Button
@@ -888,4 +776,3 @@ export function ContractReviewRequestDialog({
</Dialog>
)
}
-
diff --git a/lib/general-contracts/main/create-general-contract-dialog.tsx b/lib/general-contracts/main/create-general-contract-dialog.tsx
index bb251408..8a506e4f 100644
--- a/lib/general-contracts/main/create-general-contract-dialog.tsx
+++ b/lib/general-contracts/main/create-general-contract-dialog.tsx
@@ -18,12 +18,6 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
-import { Calendar } from "@/components/ui/calendar"
-import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
-import { CalendarIcon } from "lucide-react"
-import { format } from "date-fns"
-import { ko } from "date-fns/locale"
-import { cn } from "@/lib/utils"
import { createContract } from "@/lib/general-contracts/service"
import {
GENERAL_CONTRACT_CATEGORIES,
@@ -40,9 +34,9 @@ interface CreateContractForm {
category: string
type: string
executionMethod: string
- startDate: Date | undefined
- endDate: Date | undefined
- validityEndDate: Date | undefined
+ startDate: string
+ endDate: string
+ validityEndDate: string
notes: string
}
@@ -59,9 +53,9 @@ export function CreateGeneralContractDialog() {
category: '',
type: '',
executionMethod: '',
- startDate: undefined,
- endDate: undefined,
- validityEndDate: undefined,
+ startDate: '',
+ endDate: '',
+ validityEndDate: '',
notes: '',
})
@@ -106,9 +100,9 @@ export function CreateGeneralContractDialog() {
executionMethod: form.executionMethod,
contractSourceType: 'manual',
vendorId: selectedVendor!.id,
- startDate: form.startDate!.toISOString().split('T')[0],
- endDate: form.endDate!.toISOString().split('T')[0],
- validityEndDate: (form.validityEndDate || form.endDate!).toISOString().split('T')[0],
+ startDate: form.startDate,
+ endDate: form.endDate,
+ validityEndDate: form.validityEndDate || form.endDate,
status: 'Draft',
registeredById: session?.user?.id || 1,
lastUpdatedById: session?.user?.id || 1,
@@ -138,9 +132,9 @@ export function CreateGeneralContractDialog() {
category: '',
type: '',
executionMethod: '',
- startDate: undefined,
- endDate: undefined,
- validityEndDate: undefined,
+ startDate: '',
+ endDate: '',
+ validityEndDate: '',
notes: '',
})
setSelectedVendor(null)
@@ -276,81 +270,45 @@ export function CreateGeneralContractDialog() {
<div className="grid grid-cols-3 gap-4">
<div className="grid gap-2">
- <Label>계약시작일 *</Label>
- <Popover>
- <PopoverTrigger asChild>
- <Button
- variant="outline"
- className={cn(
- "justify-start text-left font-normal",
- !form.startDate && "text-muted-foreground"
- )}
- >
- <CalendarIcon className="mr-2 h-4 w-4" />
- {form.startDate ? format(form.startDate, "yyyy-MM-dd", { locale: ko }) : "날짜 선택"}
- </Button>
- </PopoverTrigger>
- <PopoverContent className="w-auto p-0">
- <Calendar
- mode="single"
- selected={form.startDate}
- onSelect={(date) => setForm(prev => ({ ...prev, startDate: date }))}
- initialFocus
- />
- </PopoverContent>
- </Popover>
+ <Label htmlFor="startDate">
+ 계약시작일
+ {!['AD', 'LO', 'OF'].includes(form.type) && <span className="text-red-600 ml-1">*</span>}
+ </Label>
+ <Input
+ id="startDate"
+ type="date"
+ value={form.startDate}
+ onChange={(e) => setForm(prev => ({ ...prev, startDate: e.target.value }))}
+ min="1900-01-01"
+ max="2100-12-31"
+ />
</div>
<div className="grid gap-2">
- <Label>계약종료일 *</Label>
- <Popover>
- <PopoverTrigger asChild>
- <Button
- variant="outline"
- className={cn(
- "justify-start text-left font-normal",
- !form.endDate && "text-muted-foreground"
- )}
- >
- <CalendarIcon className="mr-2 h-4 w-4" />
- {form.endDate ? format(form.endDate, "yyyy-MM-dd", { locale: ko }) : "날짜 선택"}
- </Button>
- </PopoverTrigger>
- <PopoverContent className="w-auto p-0">
- <Calendar
- mode="single"
- selected={form.endDate}
- onSelect={(date) => setForm(prev => ({ ...prev, endDate: date }))}
- initialFocus
- />
- </PopoverContent>
- </Popover>
+ <Label htmlFor="endDate">
+ 계약종료일
+ {!['AD', 'LO', 'OF'].includes(form.type) && <span className="text-red-600 ml-1">*</span>}
+ </Label>
+ <Input
+ id="endDate"
+ type="date"
+ value={form.endDate}
+ onChange={(e) => setForm(prev => ({ ...prev, endDate: e.target.value }))}
+ min="1900-01-01"
+ max="2100-12-31"
+ />
</div>
<div className="grid gap-2">
- <Label>유효기간종료일</Label>
- <Popover>
- <PopoverTrigger asChild>
- <Button
- variant="outline"
- className={cn(
- "justify-start text-left font-normal",
- !form.validityEndDate && "text-muted-foreground"
- )}
- >
- <CalendarIcon className="mr-2 h-4 w-4" />
- {form.validityEndDate ? format(form.validityEndDate, "yyyy-MM-dd", { locale: ko }) : "날짜 선택"}
- </Button>
- </PopoverTrigger>
- <PopoverContent className="w-auto p-0">
- <Calendar
- mode="single"
- selected={form.validityEndDate}
- onSelect={(date) => setForm(prev => ({ ...prev, validityEndDate: date }))}
- initialFocus
- />
- </PopoverContent>
- </Popover>
+ <Label htmlFor="validityEndDate">유효기간종료일</Label>
+ <Input
+ id="validityEndDate"
+ type="date"
+ value={form.validityEndDate}
+ onChange={(e) => setForm(prev => ({ ...prev, validityEndDate: e.target.value }))}
+ min="1900-01-01"
+ max="2100-12-31"
+ />
</div>
</div>
<div className="grid gap-2">
diff --git a/lib/general-contracts/main/general-contract-update-sheet.tsx b/lib/general-contracts/main/general-contract-update-sheet.tsx
index 8df74beb..02bde6c9 100644
--- a/lib/general-contracts/main/general-contract-update-sheet.tsx
+++ b/lib/general-contracts/main/general-contract-update-sheet.tsx
@@ -116,14 +116,23 @@ export function GeneralContractUpdateSheet({
React.useEffect(() => {
if (contract) {
console.log("Loading contract data:", contract)
+
+ // 날짜 포맷팅 헬퍼 (YYYY-MM-DD)
+ const formatDateValue = (dateStr: string | null | undefined) => {
+ if (!dateStr) return ""
+ // KST 기준 날짜 변환 (입찰 로직과 동일)
+ const date = new Date(dateStr)
+ return new Date(date.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 10)
+ }
+
const formData = {
category: contract.category || "",
type: contract.type || "",
executionMethod: contract.executionMethod || "",
name: contract.name || "",
- startDate: contract.startDate || "",
- endDate: contract.endDate || "",
- validityEndDate: contract.validityEndDate || "",
+ startDate: formatDateValue(contract.startDate),
+ endDate: formatDateValue(contract.endDate),
+ validityEndDate: formatDateValue(contract.validityEndDate),
contractScope: contract.contractScope || "",
notes: contract.notes || "",
linkedRfqOrItb: contract.linkedRfqOrItb || "",
diff --git a/lib/general-contracts/main/general-contracts-table-columns.tsx b/lib/general-contracts/main/general-contracts-table-columns.tsx
index 0b3143fe..c43bb383 100644
--- a/lib/general-contracts/main/general-contracts-table-columns.tsx
+++ b/lib/general-contracts/main/general-contracts-table-columns.tsx
@@ -368,14 +368,27 @@ export function getGeneralContractsColumns({ setRowAction }: GetColumnsProps): C
if (!startDate || !endDate) return <span className="text-muted-foreground">-</span>
+ // UI 표시용 KST 변환 (YYYY-MM-DD)
+ const formatKstDate = (d: string | Date) => {
+ const date = new Date(d)
+ return new Date(date.getTime() + 9 * 60 * 60 * 1000).toISOString().slice(0, 10)
+ }
+
+ const formattedStart = formatKstDate(startDate)
+ const formattedEnd = formatKstDate(endDate)
+
const now = new Date()
- const isActive = now >= new Date(startDate) && now <= new Date(endDate)
- const isExpired = now > new Date(endDate)
+ const startObj = new Date(startDate)
+ const endObj = new Date(endDate)
+
+ // 종료일의 경우 23:59:59까지 유효하다고 가정하거나, 단순히 날짜 비교
+ const isActive = now >= startObj && now <= new Date(endObj.getTime() + 24 * 60 * 60 * 1000 - 1)
+ const isExpired = now > new Date(endObj.getTime() + 24 * 60 * 60 * 1000 - 1)
return (
<div className="text-xs">
<div className={`${isActive ? 'text-green-600 font-medium' : isExpired ? 'text-red-600' : 'text-gray-600'}`}>
- {formatDate(startDate, "KR")} ~ {formatDate(endDate, "KR")}
+ {formattedStart} ~ {formattedEnd}
</div>
{isActive && (
<Badge variant="default" className="text-xs mt-1">진행중</Badge>
diff --git a/lib/general-contracts/service.ts b/lib/general-contracts/service.ts
index 991616d9..3f3dc8de 100644
--- a/lib/general-contracts/service.ts
+++ b/lib/general-contracts/service.ts
@@ -8,6 +8,7 @@ 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 { generalContractTemplates } from '@/db/schema'
import { vendors } from '@/db/schema/vendors'
import { users, roles, userRoles } from '@/db/schema/users'
import { projects } from '@/db/schema/projects'
@@ -2728,4 +2729,32 @@ export async function confirmContractReview(
console.error('당사 검토 확정 오류:', error)
throw error
}
+}
+
+// 계약 유형에 맞는 최신 템플릿 조회
+export async function getContractTemplateByContractType(contractType: string) {
+ try {
+ // 1. 정확한 타입 매칭 시도
+ const templates = await db
+ .select()
+ .from(generalContractTemplates)
+ .where(
+ and(
+ eq(generalContractTemplates.contractTemplateType, contractType),
+ eq(generalContractTemplates.status, 'ACTIVE')
+ )
+ )
+ .orderBy(desc(generalContractTemplates.revision)) // 최신 리비전 우선
+ .limit(1)
+
+ if (templates.length > 0) {
+ return { success: true, template: templates[0] }
+ }
+
+ // 2. 매칭되는 템플릿이 없을 경우 (필요 시 로직 추가)
+ return { success: false, error: '해당 계약 유형에 맞는 템플릿을 찾을 수 없습니다.' }
+ } catch (error) {
+ console.error('템플릿 조회 오류:', error)
+ return { success: false, error: '템플릿 조회 중 오류가 발생했습니다.' }
+ }
} \ No newline at end of file
diff --git a/lib/general-contracts/utils.ts b/lib/general-contracts/utils.ts
new file mode 100644
index 00000000..ec15a3a1
--- /dev/null
+++ b/lib/general-contracts/utils.ts
@@ -0,0 +1,304 @@
+import { format } from "date-fns"
+
+/**
+ * ContractSummary 인터페이스 (UI 컴포넌트와 맞춤)
+ */
+interface ContractSummary {
+ basicInfo: Record<string, any>
+ items: Record<string, any>[]
+ subcontractChecklist: Record<string, any> | null
+ storageInfo?: Record<string, any>[] // 임치(물품보관) 계약 정보
+}
+
+/**
+ * 계약 데이터를 템플릿 변수로 매핑하는 함수
+ *
+ * @param contractSummary 계약 요약 정보
+ * @returns PDFTron 템플릿에 적용할 변수 맵 (Key-Value)
+ */
+export function mapContractDataToTemplateVariables(contractSummary: ContractSummary) {
+ const { basicInfo, items, storageInfo } = contractSummary
+ const firstItem = items && items.length > 0 ? items[0] : {}
+
+ // 날짜 포맷팅 헬퍼 (YYYY-MM-DD)
+ const formatDate = (date: any) => {
+ if (!date) return ''
+ try {
+ const d = new Date(date)
+ if (isNaN(d.getTime())) return String(date)
+ const year = d.getFullYear()
+ const month = String(d.getMonth() + 1).padStart(2, '0')
+ const day = String(d.getDate()).padStart(2, '0')
+ return `${year}-${month}-${day}`
+ } catch {
+ return String(date)
+ }
+ }
+
+ // 금액 포맷팅 헬퍼 (천단위 콤마)
+ const formatCurrency = (amount: any) => {
+ if (amount === undefined || amount === null || amount === '') return ''
+ const num = Number(amount)
+ if (isNaN(num)) return String(amount)
+ return num.toLocaleString('ko-KR')
+ }
+
+ // 비율 포맷팅 (소수점 제거 등 필요 시)
+ const formatRate = (rate: any) => {
+ if (!rate) return ''
+ return String(rate)
+ }
+
+ // 1. 프로젝트 정보 (Items에서 프로젝트명 추출 시도)
+ const projectName = basicInfo.projectName || (items.length > 0 ? items[0].projectName : '') || "Following potential projects"
+ const projectCode = basicInfo.projectCode || (items.length > 0 ? items[0].projectCode : '') || ''
+
+ // 2. 계약금액 표시 로직 (단가/물량 계약은 '별첨 참조')
+ const contractScope = basicInfo.contractScope || ''
+ let displayContractAmount = formatCurrency(basicInfo.contractAmount)
+ let displayContractAmountText = ''
+
+ if (contractScope === '단가' || contractScope === '물량(실적)') {
+ displayContractAmount = '별첨 참조'
+ displayContractAmountText = '별첨 참조'
+ } else {
+ displayContractAmountText = displayContractAmount
+ }
+
+ // 공급가액 & 부가세 (임시 계산 로직 제거)
+ // 실제로는 taxType에 따라 다를 수 있음 (영세율 등) - 데이터가 있으면 매핑
+ const supplyPrice = basicInfo.supplyPrice ? formatCurrency(basicInfo.supplyPrice) : ''
+ const vat = basicInfo.vat ? formatCurrency(basicInfo.vat) : ''
+
+ // 3. 지급조건 상세 텍스트 생성
+ // 납품 전
+ const prePaymentData = basicInfo.paymentBeforeDelivery || {}
+ let prePaymentText = ''
+ const prePaymentParts: string[] = []
+ if (prePaymentData.apBond) prePaymentParts.push(`AP Bond(${prePaymentData.apBondPercent}%)`)
+ if (prePaymentData.drawingSubmission) prePaymentParts.push(`도면제출(${prePaymentData.drawingSubmissionPercent}%)`)
+ if (prePaymentData.materialPurchase) prePaymentParts.push(`소재구매(${prePaymentData.materialPurchasePercent}%)`)
+ if (prePaymentData.additionalCondition) prePaymentParts.push(`추가조건(${prePaymentData.additionalConditionPercent}%)`)
+ if (prePaymentParts.length > 0) {
+ prePaymentText = `(선급금) ${prePaymentParts.join(', ')}`
+ }
+
+ // 납품
+ const deliveryPaymentText = basicInfo.paymentDelivery ? `(본품 납품) ${basicInfo.paymentDelivery}` : ''
+
+ // 납품 후
+ const postPaymentData = basicInfo.paymentAfterDelivery || {}
+ let postPaymentText = ''
+ const postPaymentParts: string[] = []
+ if (postPaymentData.commissioning) postPaymentParts.push(`Commissioning(${postPaymentData.commissioningPercent}%)`)
+ if (postPaymentData.finalDocument) postPaymentParts.push(`최종문서(${postPaymentData.finalDocumentPercent}%)`)
+ if (postPaymentData.other) postPaymentParts.push(`기타(${postPaymentData.otherText})`)
+ if (postPaymentParts.length > 0) {
+ postPaymentText = `(납품 외) ${postPaymentParts.join(', ')}`
+ }
+
+ // 4. 보증금 및 위약금 (DB 필드값 사용, 임시 계산 제거)
+ // DB에 해당 필드가 없으면 빈 값으로 매핑됨.
+ const contractDepositAmount = basicInfo.contractDepositAmount || ''
+ const defectDepositAmount = basicInfo.defectDepositAmount || ''
+ const paymentDepositAmount = basicInfo.paymentDepositAmount || ''
+ const unfairJointActPenaltyAmount = basicInfo.unfairJointActPenaltyAmount || ''
+
+ // 지체상금
+ const liquidatedDamagesRate = basicInfo.liquidatedDamagesPercent || '0'
+
+ // 5. 조건 텍스트 변환 (JSON -> String)
+ // 계약해지조건
+ let terminationConditionsText = ''
+ if (basicInfo.contractTerminationConditions) {
+ try {
+ const cond = typeof basicInfo.contractTerminationConditions === 'string'
+ ? JSON.parse(basicInfo.contractTerminationConditions)
+ : basicInfo.contractTerminationConditions
+
+ const active: string[] = []
+ if (cond.standardTermination) active.push('표준 계약해지조건')
+ if (cond.projectNotAwarded) active.push('프로젝트 미수주 시')
+ if (cond.other) active.push('기타')
+ terminationConditionsText = active.join(', ')
+ } catch (e) {}
+ }
+
+ // 계약성립조건
+ let establishmentConditionsText = ''
+ if (basicInfo.contractEstablishmentConditions) {
+ try {
+ const cond = typeof basicInfo.contractEstablishmentConditions === 'string'
+ ? JSON.parse(basicInfo.contractEstablishmentConditions)
+ : basicInfo.contractEstablishmentConditions
+
+ const active: string[] = []
+ if (cond.regularVendorRegistration) active.push('정규업체 등록(실사 포함) 시')
+ if (cond.projectAward) active.push('프로젝트 수주 시')
+ if (cond.ownerApproval) active.push('선주 승인 시')
+ if (cond.other) active.push('기타')
+ establishmentConditionsText = active.join(', ')
+ } catch (e) {}
+ }
+
+ // 품질/하자보증기간 텍스트
+ let warrantyPeriodText = ''
+ if (basicInfo.warrantyPeriod) {
+ try {
+ const wp = typeof basicInfo.warrantyPeriod === 'string' ? JSON.parse(basicInfo.warrantyPeriod) : basicInfo.warrantyPeriod
+ const parts: string[] = []
+ if (wp.납품후?.enabled) parts.push(`납품 후 ${wp.납품후.period}개월`)
+ if (wp.인도후?.enabled) parts.push(`인도 후 ${wp.인도후.period}개월`)
+ if (wp.작업후?.enabled) parts.push(`작업 후 ${wp.작업후.period}개월`)
+ if (wp.기타?.enabled) parts.push(`기타`)
+ warrantyPeriodText = parts.join(', ')
+ } catch(e) {}
+ }
+
+ // 6. 임치(물품보관) 계약 관련 (SG)
+ const storageItems = storageInfo || []
+ // 템플릿에서 루프를 지원하지 않을 경우를 대비한 텍스트 포맷 (Fallback)
+ const storageTableText = storageItems.length > 0
+ ? storageItems.map((item, idx) =>
+ `${idx + 1}. PO No.: ${item.poNumber || '-'}, 호선: ${item.hullNumber || '-'}, 미입고 잔여금액: ${formatCurrency(item.remainingAmount)}`
+ ).join('\n')
+ : ''
+
+
+ // ═══════════════════════════════════════════════════════════════
+ // 변수 매핑 시작
+ // ═══════════════════════════════════════════════════════════════
+ const variables: Record<string, any> = {
+ // ----------------------------------
+ // 시스템/공통
+ // ----------------------------------
+ todayDate: formatDate(new Date()), // {{Today}} : 현재 날짜
+
+ // ----------------------------------
+ // 계약 기본 정보
+ // ----------------------------------
+ contractName: basicInfo.contractName || basicInfo.name || '', // {{계약명}}
+ contractNumber: basicInfo.contractNumber || '', // {{계약번호}}
+ contractDate: formatDate(basicInfo.registeredAt || basicInfo.createdAt), // {{계약일자}}
+
+ // ----------------------------------
+ // 프로젝트 정보
+ // ----------------------------------
+ projectName: projectName, // {{프로젝트}}, {{대상호선}} : 없으면 'Following potential projects'
+ projectCode: projectCode, // {{프로젝트코드}}
+
+ // ----------------------------------
+ // 금액 정보
+ // ----------------------------------
+ contractAmount: displayContractAmount, // {{계약금액}} : '별첨 참조' 또는 금액
+ supplyPrice: supplyPrice, // (공급가액)
+ vat: vat, // (부가가치세)
+ contractCurrency: basicInfo.currency || 'KRW', // 통화
+
+ // ----------------------------------
+ // 협력업체(Vendor) 정보
+ // ----------------------------------
+ vendorName: basicInfo.vendorName || '', // {{VendorName}}, {{계약업체}}, {{수탁자}}
+ vendorAddress: basicInfo.vendorAddress || basicInfo.address || '', // {{VendorAddress}}, {{수탁자 주소}}, {{보관장소}}
+ vendorCeoName: basicInfo.vendorCeoName || basicInfo.representativeName || '', // {{Vendor_CEO_Name}}, {{대표이사}}
+ // vendorPhone, vendorEmail 등 필요시 추가
+
+ // ----------------------------------
+ // 당사(SHI) 정보 (고정값/설정값)
+ // ----------------------------------
+ shiAddress: "경기도 성남시 분당구 판교로 227번길 23", // {{SHI_Address}}, {{위탁자 주소}}
+ shiCeoName: "최성안", // {{SHI_CEO_Name}}, {{대표이사}}
+
+ // ----------------------------------
+ // 품목 정보
+ // ----------------------------------
+ // Frame Agreement 등의 {{자재그룹}}, {{자재그룹명}}
+ itemGroup: firstItem.itemCode || '', // {{자재그룹}} : 일단 ItemCode 매핑 (자재그룹 코드가 별도로 있다면 수정 필요)
+ itemGroupName: firstItem.itemInfo || '', // {{자재그룹명}} : ItemInfo 매핑
+ pkgNo: firstItem.itemCode || '', // {{PKG No.}}
+ pkgName: firstItem.itemInfo || '', // {{PKG명}}
+
+ // 일반 계약품목 / 임치 대상품목
+ itemDescription: firstItem.itemInfo || firstItem.description || basicInfo.contractName || '', // {{계약품목}}, {{계약내용}}
+ itemInfo: firstItem.itemInfo || '', // {{Item 정보}}
+ itemName: firstItem.itemInfo || '', // {{ItemName}}
+
+ // OF 배상품목
+ reimbursementItem: firstItem.itemInfo || '', // {{배상품목}}
+
+ // ----------------------------------
+ // 사양 및 공급범위
+ // ----------------------------------
+ // {{사양 및 공급범위}} : 사양서 파일 유무에 따라 텍스트 변경
+ // 실제 파일 존재 여부를 여기서 알기 어려우므로 specificationType으로 판단
+ scopeOfSupply: basicInfo.specificationType === '첨부서류 참조'
+ ? '사양서 파일 참조(As per Technical agreement)'
+ : (basicInfo.specificationManualText || basicInfo.contractName || ''),
+
+ // ----------------------------------
+ // 계약 기간 및 유효기간
+ // ----------------------------------
+ contractPeriod: `${formatDate(basicInfo.startDate)} ~ ${formatDate(basicInfo.endDate)}`, // {{계약기간}}, {{FA 유효기간}}, {{보관날짜}}
+ contractStartDate: formatDate(basicInfo.startDate),
+ contractEndDate: formatDate(basicInfo.endDate),
+ validityEndDate: formatDate(basicInfo.validityEndDate || basicInfo.endDate), // {{LOI 유효기간}}, {{계약체결유효기간}}
+
+ // ----------------------------------
+ // 인도/지급 조건
+ // ----------------------------------
+ incoterms: basicInfo.deliveryTerm || '', // {{Incoterms}}, {{물품인도조건}}
+ paymentTerms: basicInfo.paymentTerm || '', // {{지급조건}}, {{대금지불조건}} - 코드값(L003 등)일 수 있음
+
+ // 상세 지급조건 (선급금, 납품, 납품 외)
+ // 템플릿에 (선급금) ... (본품 납품) ... 항목이 미리 적혀있는지, 변수로 넣어야 하는지에 따라 다름
+ // 예시에서는 줄글로 보임. 각각 매핑.
+ prePaymentCondition: prePaymentText, // (선급금) 조건 텍스트
+ deliveryPaymentCondition: deliveryPaymentText, // (본품 납품) 조건 텍스트
+ postPaymentCondition: postPaymentText, // (납품 외) 조건 텍스트
+
+ // ----------------------------------
+ // 보증기간 및 보증금
+ // ----------------------------------
+ warrantyPeriod: warrantyPeriodText, // {{품질/하자보증기간}}
+
+ // 금액 계산 필드들 (DB 필드값이 없으면 빈 값)
+ contractDeposit: formatCurrency(contractDepositAmount), // {{계약보증금}}
+ defectDeposit: formatCurrency(defectDepositAmount), // {{하자보증금}}
+ paymentDeposit: formatCurrency(paymentDepositAmount), // {{지급보증금}}
+
+ unfairJointActPenalty: formatCurrency(unfairJointActPenaltyAmount), // {{부정담합위약금}}, {{부당한공동행위}}
+
+ // 지체상금
+ liquidatedDamagesRate: formatRate(liquidatedDamagesRate), // {{지체상금비율}}
+ // liquidatedDamages: formatCurrency(liquidatedDamagesAmount), // 금액이 필요한 경우 사용
+
+ // ----------------------------------
+ // 기타 조건
+ // ----------------------------------
+ terminationConditions: terminationConditionsText, // {{계약해지조건}}
+ establishmentConditions: establishmentConditionsText, // {{계약성립조건}}
+ subcontractInterlocking: basicInfo.interlockingSystem || 'N', // {{하도급연동}}
+
+ // ----------------------------------
+ // 참조/연결 정보
+ // ----------------------------------
+ // OF의 {{관련계약번호}}
+ linkedContractNumber: basicInfo.linkedPoNumber || basicInfo.linkedBidNumber || basicInfo.linkedRfqOrItb || '',
+
+ // ----------------------------------
+ // 임치(물품보관) 계약 (SG)
+ // ----------------------------------
+ storageTableText: storageTableText, // {{storageTableText}} (fallback)
+ // PDFTron에서 배열을 받아 테이블 루프를 돌릴 수 있다면 아래 키를 사용
+ storageList: storageItems,
+ }
+
+ // 3. 모든 키를 순회하며 undefined나 null을 빈 문자열로 변환 (안전장치)
+ Object.keys(variables).forEach(key => {
+ if (variables[key] === undefined || variables[key] === null) {
+ variables[key] = ''
+ }
+ })
+
+ return variables
+}