summaryrefslogtreecommitdiff
path: root/lib/general-contracts
diff options
context:
space:
mode:
Diffstat (limited to 'lib/general-contracts')
-rw-r--r--lib/general-contracts/approval-actions.ts136
-rw-r--r--lib/general-contracts/approval-template-variables.ts345
-rw-r--r--lib/general-contracts/detail/general-contract-approval-request-dialog.tsx2266
-rw-r--r--lib/general-contracts/detail/general-contract-basic-info.tsx478
-rw-r--r--lib/general-contracts/detail/general-contract-items-table.tsx162
-rw-r--r--lib/general-contracts/handlers.ts157
-rw-r--r--lib/general-contracts/service.ts13
7 files changed, 2330 insertions, 1227 deletions
diff --git a/lib/general-contracts/approval-actions.ts b/lib/general-contracts/approval-actions.ts
new file mode 100644
index 00000000..e75d6cd6
--- /dev/null
+++ b/lib/general-contracts/approval-actions.ts
@@ -0,0 +1,136 @@
+/**
+ * 일반계약 관련 결재 서버 액션
+ *
+ * 사용자가 UI에서 호출하는 함수들
+ * ApprovalSubmissionSaga를 사용하여 결재 프로세스를 시작
+ */
+
+'use server';
+
+import { ApprovalSubmissionSaga } from '@/lib/approval';
+import { mapContractToApprovalTemplateVariables } from './approval-template-variables';
+import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils';
+import db from '@/db/db';
+import { eq } from 'drizzle-orm';
+import { generalContracts } from '@/db/schema/generalContract';
+import { users } from '@/db/schema';
+
+interface ContractSummary {
+ basicInfo: Record<string, unknown>;
+ items: Record<string, unknown>[];
+ subcontractChecklist: Record<string, unknown> | null;
+ storageInfo?: Record<string, unknown>[];
+ pdfPath?: string;
+ basicContractPdfs?: Array<{ key: string; buffer: number[]; fileName: string }>;
+}
+
+/**
+ * 결재를 거쳐 일반계약 승인 요청을 처리하는 서버 액션
+ *
+ * 사용법 (클라이언트 컴포넌트에서):
+ * ```typescript
+ * const result = await requestContractApprovalWithApproval({
+ * contractId: 123,
+ * contractSummary: summaryData,
+ * currentUser: { id: 1, epId: 'EP001', email: 'user@example.com' },
+ * approvers: ['EP002', 'EP003'],
+ * title: '계약 체결 진행 품의 요청서'
+ * });
+ *
+ * if (result.status === 'pending_approval') {
+ * console.log('결재 ID:', result.approvalId);
+ * }
+ * ```
+ */
+export async function requestContractApprovalWithApproval(data: {
+ contractId: number;
+ contractSummary: ContractSummary;
+ currentUser: { id: number; epId: string | null; email?: string };
+ approvers?: string[]; // Knox EP ID 배열 (결재선)
+ title?: string; // 결재 제목 (선택사항, 미지정 시 자동 생성)
+}) {
+ debugLog('[ContractApproval] 일반계약 승인 요청 결재 서버 액션 시작', {
+ contractId: data.contractId,
+ contractNumber: data.contractSummary.basicInfo?.contractNumber,
+ contractName: data.contractSummary.basicInfo?.name,
+ userId: data.currentUser.id,
+ hasEpId: !!data.currentUser.epId,
+ });
+
+ // 입력 검증
+ if (!data.currentUser.epId) {
+ debugError('[ContractApproval] Knox EP ID 없음');
+ throw new Error('Knox EP ID가 필요합니다');
+ }
+
+ if (!data.contractId) {
+ debugError('[ContractApproval] 계약 ID 없음');
+ throw new Error('계약 ID가 필요합니다');
+ }
+
+ // 1. 유저의 nonsapUserId 조회 (Cronjob 환경을 위해)
+ debugLog('[ContractApproval] nonsapUserId 조회');
+ const userResult = await db.query.users.findFirst({
+ where: eq(users.id, data.currentUser.id),
+ columns: { nonsapUserId: true }
+ });
+ const nonsapUserId = userResult?.nonsapUserId || null;
+ debugLog('[ContractApproval] nonsapUserId 조회 완료', { nonsapUserId });
+
+ // 2. 템플릿 변수 매핑
+ debugLog('[ContractApproval] 템플릿 변수 매핑 시작');
+ const variables = await mapContractToApprovalTemplateVariables(data.contractSummary);
+ debugLog('[ContractApproval] 템플릿 변수 매핑 완료', {
+ variableKeys: Object.keys(variables),
+ });
+
+ // 3. 결재 워크플로우 시작 (Saga 패턴)
+ debugLog('[ContractApproval] ApprovalSubmissionSaga 생성');
+ const saga = new ApprovalSubmissionSaga(
+ // actionType: 핸들러를 찾을 때 사용할 키
+ 'general_contract_approval',
+
+ // actionPayload: 결재 승인 후 핸들러에 전달될 데이터
+ {
+ contractId: data.contractId,
+ contractSummary: data.contractSummary,
+ currentUser: {
+ id: data.currentUser.id,
+ email: data.currentUser.email,
+ nonsapUserId: nonsapUserId,
+ },
+ },
+
+ // approvalConfig: 결재 상신 정보 (템플릿 포함)
+ {
+ title: data.title || `계약 체결 진행 품의 요청서 - ${data.contractSummary.basicInfo?.contractNumber || data.contractId}`,
+ description: `${data.contractSummary.basicInfo?.name || '일반계약'} 계약 체결 진행 품의 요청`,
+ templateName: '일반계약 결재', // 한국어 템플릿명
+ variables, // 치환할 변수들
+ approvers: data.approvers,
+ currentUser: data.currentUser,
+ }
+ );
+
+ debugLog('[ContractApproval] Saga 실행 시작');
+ const result = await saga.execute();
+
+ // 4. 결재 상신 성공 시 상태를 'approval_in_progress'로 변경
+ if (result.status === 'pending_approval') {
+ debugLog('[ContractApproval] 상태를 approval_in_progress로 변경');
+ await db.update(generalContracts)
+ .set({
+ status: 'approval_in_progress',
+ lastUpdatedAt: new Date()
+ })
+ .where(eq(generalContracts.id, data.contractId));
+ }
+
+ debugSuccess('[ContractApproval] 결재 워크플로우 완료', {
+ approvalId: result.approvalId,
+ status: result.status,
+ });
+
+ return result;
+}
+
diff --git a/lib/general-contracts/approval-template-variables.ts b/lib/general-contracts/approval-template-variables.ts
new file mode 100644
index 00000000..710e6101
--- /dev/null
+++ b/lib/general-contracts/approval-template-variables.ts
@@ -0,0 +1,345 @@
+/**
+ * 일반계약 결재 템플릿 변수 매핑 함수
+ *
+ * 제공된 HTML 템플릿의 변수명에 맞춰 매핑
+ */
+
+'use server';
+
+import { format } from 'date-fns';
+
+interface ContractSummary {
+ basicInfo: Record<string, unknown>;
+ items: Record<string, unknown>[];
+ subcontractChecklist: Record<string, unknown> | null;
+ storageInfo?: Record<string, unknown>[];
+}
+
+/**
+ * 일반계약 데이터를 결재 템플릿 변수로 매핑
+ *
+ * @param contractSummary - 계약 요약 정보
+ * @returns 템플릿 변수 객체 (Record<string, string>)
+ */
+export async function mapContractToApprovalTemplateVariables(
+ contractSummary: ContractSummary
+): Promise<Record<string, string>> {
+ const { basicInfo, items, subcontractChecklist } = contractSummary;
+
+ // 날짜 포맷팅 헬퍼
+ const formatDate = (date: any) => {
+ if (!date) return '';
+ try {
+ const d = new Date(date);
+ if (isNaN(d.getTime())) return String(date);
+ return format(d, 'yyyy-MM-dd');
+ } 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 contractPeriod = basicInfo.startDate && basicInfo.endDate
+ ? `${formatDate(basicInfo.startDate)} ~ ${formatDate(basicInfo.endDate)}`
+ : '';
+
+ // 계약체결방식
+ const contractExecutionMethod = basicInfo.executionMethod || '';
+
+ // 계약종류
+ const contractType = basicInfo.type || '';
+
+ // 업체선정방식
+ const vendorSelectionMethod = basicInfo.contractSourceType || '';
+
+ // 매입 부가가치세
+ const taxType = basicInfo.taxType || '';
+
+ // SHI 지급조건
+ const paymentTerm = basicInfo.paymentTerm || '';
+
+ // SHI 인도조건
+ const deliveryTerm = basicInfo.deliveryTerm || '';
+ const deliveryType = basicInfo.deliveryType || '';
+
+ // 사외업체 야드 투입 여부
+ const externalYardEntry = basicInfo.externalYardEntry === 'Y' ? '예' : '아니오';
+
+ // 직종
+ const workType = basicInfo.workType || '';
+
+ // 재하도 협력사
+ const subcontractVendor = basicInfo.subcontractVendorName || '';
+
+ // 계약 내용
+ const contractContent = basicInfo.notes || basicInfo.name || '';
+
+ // 계약성립조건
+ 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) {
+ console.warn('계약성립조건 파싱 실패:', e);
+ }
+ }
+
+ // 계약해지조건
+ 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) {
+ console.warn('계약해지조건 파싱 실패:', e);
+ }
+ }
+
+ // 협력사 정보
+ const vendorCode = basicInfo.vendorCode || '';
+ const vendorName = basicInfo.vendorName || '';
+ const vendorContactPerson = basicInfo.vendorContactPerson || '';
+ const vendorPhone = basicInfo.vendorPhone || '';
+ const vendorEmail = basicInfo.vendorEmail || '';
+ const vendorNote = '';
+
+ // 자재 정보 (최대 100건)
+ const materialItems = items.slice(0, 100);
+ const materialCount = items.length;
+
+ // 보증 정보
+ const guarantees: Array<{
+ type: string;
+ order: number;
+ bondNumber: string;
+ rate: string;
+ amount: string;
+ period: string;
+ startDate: string;
+ endDate: string;
+ issuer: string;
+ }> = [];
+
+ // // 계약보증 (첫 번째 항목만 사용)
+ // if (basicInfo.contractBond) {
+ // const bond = typeof basicInfo.contractBond === 'string'
+ // ? JSON.parse(basicInfo.contractBond)
+ // : basicInfo.contractBond;
+
+ // if (bond && Array.isArray(bond) && bond.length > 0) {
+ // const b = bond[0];
+ // guarantees.push({
+ // type: '계약보증',
+ // order: 1,
+ // bondNumber: b.bondNumber || '',
+ // rate: b.rate ? `${b.rate}%` : '',
+ // amount: formatCurrency(b.amount),
+ // period: b.period || '',
+ // startDate: formatDate(b.startDate),
+ // endDate: formatDate(b.endDate),
+ // issuer: b.issuer || '',
+ // });
+ // }
+ // }
+
+ // // 지급보증 (첫 번째 항목만 사용)
+ // if (basicInfo.paymentBond) {
+ // const bond = typeof basicInfo.paymentBond === 'string'
+ // ? JSON.parse(basicInfo.paymentBond)
+ // : basicInfo.paymentBond;
+
+ // if (bond && Array.isArray(bond) && bond.length > 0) {
+ // const b = bond[0];
+ // guarantees.push({
+ // type: '지급보증',
+ // order: 1,
+ // bondNumber: b.bondNumber || '',
+ // rate: b.rate ? `${b.rate}%` : '',
+ // amount: formatCurrency(b.amount),
+ // period: b.period || '',
+ // startDate: formatDate(b.startDate),
+ // endDate: formatDate(b.endDate),
+ // issuer: b.issuer || '',
+ // });
+ // }
+ // }
+
+ // // 하자보증 (첫 번째 항목만 사용)
+ // if (basicInfo.defectBond) {
+ // const bond = typeof basicInfo.defectBond === 'string'
+ // ? JSON.parse(basicInfo.defectBond)
+ // : basicInfo.defectBond;
+
+ // if (bond && Array.isArray(bond) && bond.length > 0) {
+ // const b = bond[0];
+ // guarantees.push({
+ // type: '하자보증',
+ // order: 1,
+ // bondNumber: b.bondNumber || '',
+ // rate: b.rate ? `${b.rate}%` : '',
+ // amount: formatCurrency(b.amount),
+ // period: b.period || '',
+ // startDate: formatDate(b.startDate),
+ // endDate: formatDate(b.endDate),
+ // issuer: b.issuer || '',
+ // });
+ // }
+ // }
+
+ // // 보증 전체 비고
+ // const guaranteeNote = basicInfo.guaranteeNote || '';
+
+
+ // 총 계약 금액 계산
+ const totalContractAmount = items.reduce((sum, item) => {
+ const amount = Number(item.contractAmount || item.totalLineAmount || 0);
+ return sum + (isNaN(amount) ? 0 : amount);
+ }, 0);
+
+ // 변수 매핑
+ const variables: Record<string, string> = {
+ // 계약 기본 정보
+ '계약번호': String(basicInfo.contractNumber || ''),
+ '계약명': String(basicInfo.name || basicInfo.contractName || ''),
+ '계약체결방식': String(contractExecutionMethod),
+ '계약종류': String(contractType),
+ '구매담당자': String(basicInfo.managerName || basicInfo.registeredByName || ''),
+ '업체선정방식': String(vendorSelectionMethod),
+ '입찰번호': String(basicInfo.linkedBidNumber || ''),
+ '입찰명': String(basicInfo.linkedBidName || ''),
+ '계약기간': contractPeriod,
+ '계약일자': formatDate(basicInfo.registeredAt || basicInfo.createdAt),
+ '매입_부가가치세': String(taxType),
+ '계약_담당자': String(basicInfo.managerName || basicInfo.registeredByName || ''),
+ '계약부서': String(basicInfo.departmentName || ''),
+ '계약금액': formatCurrency(basicInfo.contractAmount),
+ 'SHI_지급조건': String(paymentTerm),
+ 'SHI_인도조건': String(deliveryTerm),
+ 'SHI_인도조건_옵션': String(deliveryType),
+ '선적지': String(basicInfo.shippingLocation || ''),
+ '하역지': String(basicInfo.dischargeLocation || ''),
+ '사외업체_야드_투입여부': externalYardEntry,
+ '프로젝트': String(basicInfo.projectName || basicInfo.projectCode || ''),
+ '직종': String(workType),
+ '재하도_협력사': String(subcontractVendor),
+ '계약내용': String(contractContent),
+ '계약성립조건': establishmentConditionsText,
+ '계약해지조건': terminationConditionsText,
+
+ // 협력사 정보
+ '협력사코드': String(vendorCode),
+ '협력사명': String(vendorName),
+ '협력사_담당자': String(vendorContactPerson),
+ '전화번호': String(vendorPhone),
+ '이메일': String(vendorEmail),
+ '비고': String(vendorNote),
+
+ // 자재 정보
+ '대상_자재_수': String(materialCount),
+ };
+
+ // 자재 정보 변수 (최대 100건)
+ materialItems.forEach((item, index) => {
+ const idx = index + 1;
+ variables[`플랜트_${idx}`] = String(item.plant || '');
+ variables[`프로젝트_${idx}`] = String(item.projectName || item.projectCode || '');
+ variables[`자재그룹_${idx}`] = String(item.itemGroup || item.itemCode || '');
+ variables[`자재그룹명_${idx}`] = String(item.itemGroupName || '');
+ variables[`자재번호_${idx}`] = String(item.itemCode || '');
+ variables[`자재상세_${idx}`] = String(item.itemInfo || item.description || '');
+ variables[`연간단가여부_${idx}`] = String(item.isAnnualPrice ? '예' : '아니오');
+ variables[`수량_${idx}`] = formatCurrency(item.quantity);
+ variables[`구매단위_${idx}`] = String(item.quantityUnit || '');
+ variables[`계약단가_${idx}`] = formatCurrency(item.contractUnitPrice || item.unitPrice);
+ variables[`수량단위_${idx}`] = String(item.quantityUnit || '');
+ variables[`총중량_${idx}`] = formatCurrency(item.totalWeight);
+ variables[`중량단위_${idx}`] = String(item.weightUnit || '');
+ variables[`계약금액_${idx}`] = formatCurrency(item.contractAmount || item.totalLineAmount);
+ });
+
+ // 총 계약 금액
+ variables['총_계약금액'] = formatCurrency(totalContractAmount);
+
+ // // 보증 정보 변수 (첫 번째 항목만 사용)
+ // const contractGuarantee = guarantees.find(g => g.type === '계약보증');
+ // if (contractGuarantee) {
+ // variables['계약보증_차수_1'] = String(contractGuarantee.order);
+ // variables['계약보증_증권번호_1'] = String(contractGuarantee.bondNumber || '');
+ // variables['계약보증_보증금율_1'] = String(contractGuarantee.rate || '');
+ // variables['계약보증_보증금액_1'] = String(contractGuarantee.amount || '');
+ // variables['계약보증_보증기간_1'] = String(contractGuarantee.period || '');
+ // variables['계약보증_시작일_1'] = String(contractGuarantee.startDate || '');
+ // variables['계약보증_종료일_1'] = String(contractGuarantee.endDate || '');
+ // variables['계약보증_발행기관_1'] = String(contractGuarantee.issuer || '');
+ // variables['계약보증_비고_1'] = String(guaranteeNote); // 기존 보증 전체 비고를 계약보증 비고로 사용
+ // }
+
+ // const paymentGuarantee = guarantees.find(g => g.type === '지급보증');
+ // if (paymentGuarantee) {
+ // variables['지급보증_차수_1'] = String(paymentGuarantee.order);
+ // variables['지급보증_증권번호_1'] = String(paymentGuarantee.bondNumber || '');
+ // variables['지급보증_보증금율_1'] = String(paymentGuarantee.rate || '');
+ // variables['지급보증_보증금액_1'] = String(paymentGuarantee.amount || '');
+ // variables['지급보증_보증기간_1'] = String(paymentGuarantee.period || '');
+ // variables['지급보증_시작일_1'] = String(paymentGuarantee.startDate || '');
+ // variables['지급보증_종료일_1'] = String(paymentGuarantee.endDate || '');
+ // variables['지급보증_발행기관_1'] = String(paymentGuarantee.issuer || '');
+ // variables['지급보증_비고_1'] = String(guaranteeNote); // 기존 보증 전체 비고를 지급보증 비고로 사용
+ // }
+
+ // const defectGuarantee = guarantees.find(g => g.type === '하자보증');
+ // if (defectGuarantee) {
+ // variables['하자보증_차수_1'] = String(defectGuarantee.order);
+ // variables['하자보증_증권번호_1'] = String(defectGuarantee.bondNumber || '');
+ // variables['하자보증_보증금율_1'] = String(defectGuarantee.rate || '');
+ // variables['하자보증_보증금액_1'] = String(defectGuarantee.amount || '');
+ // variables['하자보증_보증기간_1'] = String(defectGuarantee.period || '');
+ // variables['하자보증_시작일_1'] = String(defectGuarantee.startDate || '');
+ // variables['하자보증_종료일_1'] = String(defectGuarantee.endDate || '');
+ // variables['하자보증_발행기관_1'] = String(defectGuarantee.issuer || '');
+ // variables['하자보증_비고_1'] = String(guaranteeNote); // 기존 보증 전체 비고를 하자보증 비고로 사용
+ // }
+
+ // 하도급 체크리스트 변수 (새로운 템플릿 구조에 맞춤)
+ if (subcontractChecklist) {
+ variables['작업전_서면발급_체크'] = String(subcontractChecklist.workDocumentIssuedCheck || subcontractChecklist.workDocumentIssued || '');
+ variables['기재사항_1'] = String(subcontractChecklist.legalItem1 || subcontractChecklist.sixLegalItems1 || '');
+ variables['기재사항_2'] = String(subcontractChecklist.legalItem2 || subcontractChecklist.sixLegalItems2 || '');
+ variables['기재사항_3'] = String(subcontractChecklist.legalItem3 || subcontractChecklist.sixLegalItems3 || '');
+ variables['기재사항_4'] = String(subcontractChecklist.legalItem4 || subcontractChecklist.sixLegalItems4 || '');
+ variables['기재사항_5'] = String(subcontractChecklist.legalItem5 || subcontractChecklist.sixLegalItems5 || '');
+ variables['기재사항_6'] = String(subcontractChecklist.legalItem6 || subcontractChecklist.sixLegalItems6 || '');
+ variables['부당대금_결정'] = String(subcontractChecklist.unfairPriceDecision || subcontractChecklist.unfairSubcontractPrice || '');
+ variables['점검결과'] = String(subcontractChecklist.inspectionResult || subcontractChecklist.overallResult || '');
+ variables['귀책부서'] = String(subcontractChecklist.responsibleDepartment || subcontractChecklist.overallDepartment || '');
+ variables['원인'] = String(subcontractChecklist.cause || subcontractChecklist.overallCause || '');
+ variables['대책'] = String(subcontractChecklist.countermeasure || subcontractChecklist.overallMeasure || '');
+ }
+
+ return variables;
+}
+
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 46251c71..db0901cb 100644
--- a/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx
+++ b/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx
@@ -1,1068 +1,1200 @@
-'use client'
-
-import React, { useState, useEffect } from 'react'
-import { useSession } from 'next-auth/react'
-import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
-import { Button } from '@/components/ui/button'
-import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
-import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
-import { Badge } from '@/components/ui/badge'
-import { Checkbox } from '@/components/ui/checkbox'
-import { Label } from '@/components/ui/label'
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
-import { Input } from '@/components/ui/input'
-import { toast } from 'sonner'
-import {
- FileText,
- Upload,
- Eye,
- Send,
- CheckCircle,
- Download,
- AlertCircle
-} from 'lucide-react'
-import { ContractDocuments } from './general-contract-documents'
-import { getActiveContractTemplates } from '@/lib/bidding/service'
-import { type BasicContractTemplate } from '@/db/schema'
-import {
- getBasicInfo,
- getContractItems,
- getSubcontractChecklist,
- uploadContractApprovalFile,
- sendContractApprovalRequest,
- getContractById,
- getContractTemplateByContractType,
- getStorageInfo
-} from '../service'
-import { mapContractDataToTemplateVariables } from '../utils'
-
-interface ContractApprovalRequestDialogProps {
- contract: Record<string, unknown>
- open: boolean
- onOpenChange: (open: boolean) => void
-}
-
-interface ContractSummary {
- basicInfo: Record<string, unknown>
- items: Record<string, unknown>[]
- subcontractChecklist: Record<string, unknown> | null
- storageInfo?: Record<string, unknown>[]
-}
-
-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 || ''
-
-
- // 기본계약 생성 함수 (최종 전송 시점에 호출)
- 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: [],
- subcontractChecklist: null
- }
-
- // Basic Info 확인 (항상 활성화)
- try {
- const basicInfoData = await getBasicInfo(contractId)
- if (basicInfoData && basicInfoData.success) {
- summary.basicInfo = basicInfoData.data || {}
- }
- // externalYardEntry 정보도 추가로 가져오기
- const contractData = await getContractById(contractId)
- if (contractData) {
- summary.basicInfo = {
- ...summary.basicInfo,
- externalYardEntry: contractData.externalYardEntry || 'N'
- }
- }
- } catch {
- console.log('Basic Info 데이터 없음')
- }
-
- // 품목 정보 확인
- try {
- const itemsData = await getContractItems(contractId)
- if (itemsData && itemsData.length > 0) {
- summary.items = itemsData
- }
- } catch {
- console.log('품목 정보 데이터 없음')
- }
-
- try {
- // Subcontract Checklist 확인
- const subcontractData = await getSubcontractChecklist(contractId)
- if (subcontractData && subcontractData.success && subcontractData.enabled) {
- summary.subcontractChecklist = subcontractData.data
- }
- } catch {
- console.log('Subcontract Checklist 데이터 없음')
- }
-
- // 임치(물품보관) 계약 정보 확인 (SG)
- try {
- if (summary.basicInfo?.contractType === 'SG') {
- const storageData = await getStorageInfo(contractId)
- if (storageData && storageData.length > 0) {
- summary.storageInfo = storageData
- }
- }
- } catch {
- console.log('임치계약 정보 없음')
- }
-
- console.log('contractSummary 구조:', summary)
- console.log('basicInfo 내용:', summary.basicInfo)
- setContractSummary(summary)
- } catch (error) {
- console.error('Error collecting contract summary:', error)
- toast.error('계약 정보를 수집하는 중 오류가 발생했습니다.')
- } finally {
- setIsLoading(false)
- }
- }, [contractId])
-
- // 3단계: PDF 생성 및 미리보기 (PDFTron 사용) - 템플릿 자동 로드
- const generatePdf = async () => {
- if (!contractSummary) {
- toast.error('계약 정보가 필요합니다.')
- return
- }
-
- setIsLoading(true)
- try {
- // 1. 계약 유형에 맞는 템플릿 조회
- const contractType = contractSummary.basicInfo.contractType as string
- const templateResult = await getContractTemplateByContractType(contractType)
-
- if (!templateResult.success || !templateResult.template) {
- throw new Error(templateResult.error || '템플릿을 찾을 수 없습니다.')
- }
-
- const template = templateResult.template
-
- // 2. 템플릿 파일 다운로드
- const templateResponse = await fetch("/api/contracts/get-template", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ templatePath: template.filePath }),
- })
-
- if (!templateResponse.ok) {
- throw new Error("템플릿 파일을 다운로드할 수 없습니다.")
- }
-
- const templateBlob = await templateResponse.blob()
- const templateFile = new File([templateBlob], template.fileName || "template.docx", {
- type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
- })
-
- // 3. PDFTron을 사용해서 변수 치환 및 PDF 변환
- // @ts-ignore
- const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer)
-
- // 임시 WebViewer 인스턴스 생성 (DOM에 추가하지 않음)
- const tempDiv = document.createElement('div')
- tempDiv.style.display = 'none'
- document.body.appendChild(tempDiv)
-
- const instance = await WebViewer(
- {
- path: "/pdftronWeb",
- licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
- fullAPI: true,
- },
- tempDiv
- )
-
- try {
- const { Core } = instance
- const { createDocument } = Core
-
- // 템플릿 문서 생성 및 변수 치환
- const templateDoc = await createDocument(templateFile, {
- filename: templateFile.name,
- extension: 'docx',
- })
-
- // 템플릿 변수 매핑
- const mappedTemplateData = mapContractDataToTemplateVariables(contractSummary)
-
- console.log("🔄 변수 치환 시작:", mappedTemplateData)
- await templateDoc.applyTemplateValues(mappedTemplateData as any)
- console.log("✅ 변수 치환 완료")
-
- // PDF 변환
- const fileData = await templateDoc.getFileData()
- const pdfBuffer = await (Core as any).officeToPDFBuffer(fileData, { extension: 'docx' })
-
- console.log(`✅ PDF 변환 완료: ${templateFile.name}`, `크기: ${pdfBuffer.byteLength} bytes`)
-
- // PDF 버퍼를 Blob URL로 변환하여 미리보기
- const pdfBlob = new Blob([pdfBuffer], { type: 'application/pdf' })
- const pdfUrl = URL.createObjectURL(pdfBlob)
- setGeneratedPdfUrl(pdfUrl)
-
- // PDF 버퍼를 상태에 저장 (최종 전송 시 사용)
- setGeneratedPdfBuffer(new Uint8Array(pdfBuffer))
-
- toast.success('PDF가 생성되었습니다.')
-
- } finally {
- // 임시 WebViewer 정리
- instance.UI.dispose()
- document.body.removeChild(tempDiv)
- }
-
- } catch (error: any) {
- console.error('❌ PDF 생성 실패:', error)
- const errorMessage = error instanceof Error ? error.message : (error?.message || '알 수 없는 오류')
- toast.error(`PDF 생성 중 오류가 발생했습니다: ${errorMessage}`)
- } finally {
- setIsLoading(false)
- }
- }
-
- // PDF 미리보기 기능
- const openPdfPreview = async () => {
- if (!generatedPdfBuffer) {
- toast.error('생성된 PDF가 없습니다.')
- return
- }
-
- setIsLoading(true)
- try {
- // @ts-ignore
- const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer)
-
- // 기존 인스턴스가 있다면 정리
- if (pdfViewerInstance) {
- console.log("🔄 기존 WebViewer 인스턴스 정리")
- try {
- pdfViewerInstance.UI.dispose()
- } catch (error) {
- console.warn('기존 WebViewer 정리 중 오류:', error)
- }
- setPdfViewerInstance(null)
- }
-
- // 미리보기용 컨테이너 확인
- let previewDiv = document.getElementById('pdf-preview-container')
- if (!previewDiv) {
- console.log("🔄 컨테이너 생성")
- previewDiv = document.createElement('div')
- previewDiv.id = 'pdf-preview-container'
- previewDiv.className = 'w-full h-full'
- previewDiv.style.width = '100%'
- previewDiv.style.height = '100%'
-
- // 실제 컨테이너에 추가
- const actualContainer = document.querySelector('[data-pdf-container]')
- if (actualContainer) {
- actualContainer.appendChild(previewDiv)
- }
- }
-
- console.log("🔄 WebViewer 인스턴스 생성 시작")
-
- // WebViewer 인스턴스 생성 (문서 없이)
- const instance = await Promise.race([
- WebViewer(
- {
- path: "/pdftronWeb",
- licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
- fullAPI: true,
- },
- previewDiv
- ),
- new Promise((_, reject) =>
- setTimeout(() => reject(new Error('WebViewer 초기화 타임아웃')), 30000)
- )
- ])
-
- console.log("🔄 WebViewer 인스턴스 생성 완료")
- setPdfViewerInstance(instance)
-
- // PDF 버퍼를 Blob으로 변환
- const pdfBlob = new Blob([generatedPdfBuffer as any], { type: 'application/pdf' })
- const pdfUrl = URL.createObjectURL(pdfBlob)
- console.log("🔄 PDF Blob URL 생성:", pdfUrl)
-
- // 문서 로드
- console.log("🔄 문서 로드 시작")
- const { documentViewer } = (instance as any).Core
-
- // 문서 로드 이벤트 대기
- await new Promise((resolve, reject) => {
- const timeout = setTimeout(() => {
- reject(new Error('문서 로드 타임아웃'))
- }, 20000)
-
- const onDocumentLoaded = () => {
- clearTimeout(timeout)
- documentViewer.removeEventListener('documentLoaded', onDocumentLoaded)
- documentViewer.removeEventListener('documentError', onDocumentError)
- console.log("🔄 문서 로드 완료")
- resolve(true)
- }
-
- const onDocumentError = (error: any) => {
- clearTimeout(timeout)
- documentViewer.removeEventListener('documentLoaded', onDocumentLoaded)
- documentViewer.removeEventListener('documentError', onDocumentError)
- console.error('문서 로드 오류:', error)
- reject(error)
- }
-
- documentViewer.addEventListener('documentLoaded', onDocumentLoaded)
- documentViewer.addEventListener('documentError', onDocumentError)
-
- // 문서 로드 시작
- documentViewer.loadDocument(pdfUrl)
- })
-
- setIsPdfPreviewVisible(true)
- toast.success('PDF 미리보기가 준비되었습니다.')
-
- } catch (error) {
- console.error('PDF 미리보기 실패:', error)
- toast.error(`PDF 미리보기 중 오류가 발생했습니다: ${error.message}`)
- } finally {
- setIsLoading(false)
- }
- }
-
- // PDF 다운로드 기능
- const downloadPdf = () => {
- if (!generatedPdfBuffer) {
- toast.error('다운로드할 PDF가 없습니다.')
- return
- }
-
- const pdfBlob = new Blob([generatedPdfBuffer as any], { type: 'application/pdf' })
- const pdfUrl = URL.createObjectURL(pdfBlob)
-
- const link = document.createElement('a')
- link.href = pdfUrl
- link.download = `contract_${contractId}_${Date.now()}.pdf`
- document.body.appendChild(link)
- link.click()
- document.body.removeChild(link)
-
- URL.revokeObjectURL(pdfUrl)
- toast.success('PDF가 다운로드되었습니다.')
- }
-
- // PDF 미리보기 닫기
- const closePdfPreview = () => {
- console.log("🔄 PDF 미리보기 닫기 시작")
- if (pdfViewerInstance) {
- try {
- console.log("🔄 WebViewer 인스턴스 정리")
- pdfViewerInstance.UI.dispose()
- } catch (error) {
- console.warn('WebViewer 정리 중 오류:', error)
- }
- setPdfViewerInstance(null)
- }
-
- // 컨테이너 정리
- const previewDiv = document.getElementById('pdf-preview-container')
- if (previewDiv) {
- try {
- previewDiv.innerHTML = ''
- } catch (error) {
- console.warn('컨테이너 정리 중 오류:', error)
- }
- }
-
- setIsPdfPreviewVisible(false)
- console.log("🔄 PDF 미리보기 닫기 완료")
- }
-
- // 최종 전송
- 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-3">
- <TabsTrigger value="1" disabled={currentStep < 1}>
- 1. 계약 현황 정리
- </TabsTrigger>
- <TabsTrigger value="2" disabled={currentStep < 2}>
- 2. 기본계약 체크
- </TabsTrigger>
- <TabsTrigger value="3" disabled={currentStep < 3}>
- 3. PDF 미리보기
- </TabsTrigger>
- </TabsList>
-
- {/* 1단계: 계약 현황 정리 */}
- <TabsContent value="1" className="space-y-4">
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <CheckCircle className="h-5 w-5 text-green-600" />
- 작성된 계약 현황
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- {isLoading ? (
- <div className="text-center py-4">
- <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
- <p className="mt-2 text-sm text-muted-foreground">계약 정보를 수집하는 중...</p>
- </div>
- ) : (
- <div className="space-y-4">
- {/* 기본 정보 (필수) */}
- <div className="border rounded-lg p-4">
- <div className="flex items-center gap-2 mb-3">
- <CheckCircle className="h-4 w-4 text-green-600" />
- <Label className="font-medium">기본 정보</Label>
- <Badge variant="secondary">필수</Badge>
- </div>
- <div className="grid grid-cols-2 gap-4 text-sm">
- <div>
- <span className="font-medium">계약번호:</span> {String(contractSummary?.basicInfo?.contractNumber || '')}
- </div>
- <div>
- <span className="font-medium">계약명:</span> {String(contractSummary?.basicInfo?.contractName || '')}
- </div>
- <div>
- <span className="font-medium">벤더:</span> {String(contractSummary?.basicInfo?.vendorName || '')}
- </div>
- <div>
- <span className="font-medium">프로젝트:</span> {String(contractSummary?.basicInfo?.projectName || '')}
- </div>
- <div>
- <span className="font-medium">계약유형:</span> {String(contractSummary?.basicInfo?.contractType || '')}
- </div>
- <div>
- <span className="font-medium">계약상태:</span> {String(contractSummary?.basicInfo?.contractStatus || '')}
- </div>
- <div>
- <span className="font-medium">계약금액:</span> {String(contractSummary?.basicInfo?.contractAmount || '')} {String(contractSummary?.basicInfo?.currency || '')}
- </div>
- <div>
- <span className="font-medium">계약기간:</span> {String(contractSummary?.basicInfo?.startDate || '')} ~ {String(contractSummary?.basicInfo?.endDate || '')}
- </div>
- <div>
- <span className="font-medium">사양서 유형:</span> {String(contractSummary?.basicInfo?.specificationType || '')}
- </div>
- <div>
- <span className="font-medium">단가 유형:</span> {String(contractSummary?.basicInfo?.unitPriceType || '')}
- </div>
- <div>
- <span className="font-medium">연결 PO번호:</span> {String(contractSummary?.basicInfo?.linkedPoNumber || '')}
- </div>
- <div>
- <span className="font-medium">연결 입찰번호:</span> {String(contractSummary?.basicInfo?.linkedBidNumber || '')}
- </div>
- </div>
- </div>
-
- {/* 지급/인도 조건 */}
- <div className="border rounded-lg p-4">
- <div className="flex items-center gap-2 mb-3">
- <CheckCircle className="h-4 w-4 text-green-600" />
- <Label className="font-medium">지급/인도 조건</Label>
- <Badge variant="secondary">필수</Badge>
- </div>
- <div className="grid grid-cols-2 gap-4 text-sm">
- <div>
- <span className="font-medium">지급조건:</span> {String(contractSummary?.basicInfo?.paymentTerm || '')}
- </div>
- <div>
- <span className="font-medium">세금 유형:</span> {String(contractSummary?.basicInfo?.taxType || '')}
- </div>
- <div>
- <span className="font-medium">인도조건:</span> {String(contractSummary?.basicInfo?.deliveryTerm || '')}
- </div>
- <div>
- <span className="font-medium">인도유형:</span> {String(contractSummary?.basicInfo?.deliveryType || '')}
- </div>
- <div>
- <span className="font-medium">선적지:</span> {String(contractSummary?.basicInfo?.shippingLocation || '')}
- </div>
- <div>
- <span className="font-medium">하역지:</span> {String(contractSummary?.basicInfo?.dischargeLocation || '')}
- </div>
- <div>
- <span className="font-medium">계약납기:</span> {String(contractSummary?.basicInfo?.contractDeliveryDate || '')}
- </div>
- <div>
- <span className="font-medium">위약금:</span> {contractSummary?.basicInfo?.liquidatedDamages ? '적용' : '미적용'}
- </div>
- </div>
- </div>
-
- {/* 추가 조건 */}
- <div className="border rounded-lg p-4">
- <div className="flex items-center gap-2 mb-3">
- <CheckCircle className="h-4 w-4 text-green-600" />
- <Label className="font-medium">추가 조건</Label>
- <Badge variant="secondary">필수</Badge>
- </div>
- <div className="grid grid-cols-2 gap-4 text-sm">
- <div>
- <span className="font-medium">연동제 정보:</span> {String(contractSummary?.basicInfo?.interlockingSystem || '')}
- </div>
- <div>
- <span className="font-medium">계약성립조건:</span>
- {contractSummary?.basicInfo?.contractEstablishmentConditions &&
- Object.entries(contractSummary.basicInfo.contractEstablishmentConditions as Record<string, unknown>)
- .filter(([, value]) => value === true)
- .map(([key]) => key)
- .join(', ') || '없음'}
- </div>
- <div>
- <span className="font-medium">계약해지조건:</span>
- {contractSummary?.basicInfo?.contractTerminationConditions &&
- Object.entries(contractSummary.basicInfo.contractTerminationConditions as Record<string, unknown>)
- .filter(([, value]) => value === true)
- .map(([key]) => key)
- .join(', ') || '없음'}
- </div>
- </div>
- </div>
-
- {/* 품목 정보 */}
- <div className="border rounded-lg p-4">
- <div className="flex items-center gap-2 mb-3">
- <Checkbox
- id="items-enabled"
- checked={contractSummary?.items && contractSummary.items.length > 0}
- disabled
- />
- <Label htmlFor="items-enabled" className="font-medium">품목 정보</Label>
- <Badge variant="outline">선택</Badge>
- </div>
- {contractSummary?.items && contractSummary.items.length > 0 ? (
- <div className="space-y-2">
- <p className="text-sm text-muted-foreground">
- 총 {contractSummary.items.length}개 품목이 입력되어 있습니다.
- </p>
- <div className="max-h-32 overflow-y-auto">
- {contractSummary.items.slice(0, 3).map((item: Record<string, unknown>, index: number) => (
- <div key={index} className="text-xs bg-gray-50 p-2 rounded">
- <div className="font-medium">{String(item.itemInfo || item.description || `품목 ${index + 1}`)}</div>
- <div className="text-muted-foreground">
- 수량: {String(item.quantity || 0)} | 단가: {String(item.contractUnitPrice || item.unitPrice || 0)}
- </div>
- </div>
- ))}
- {contractSummary.items.length > 3 && (
- <div className="text-xs text-muted-foreground text-center">
- ... 외 {contractSummary.items.length - 3}개 품목
- </div>
- )}
- </div>
- </div>
- ) : (
- <p className="text-sm text-muted-foreground">
- 품목 정보가 입력되지 않았습니다.
- </p>
- )}
- </div>
-
- {/* 하도급 체크리스트 */}
- <div className="border rounded-lg p-4">
- <div className="flex items-center gap-2 mb-3">
- <Checkbox
- id="subcontract-enabled"
- checked={!!contractSummary?.subcontractChecklist}
- disabled
- />
- <Label htmlFor="subcontract-enabled" className="font-medium">
- 하도급 체크리스트
- </Label>
- <Badge variant="outline">선택</Badge>
- </div>
- <p className="text-sm text-muted-foreground">
- {contractSummary?.subcontractChecklist
- ? '정보가 입력되어 있습니다.'
- : '정보가 입력되지 않았습니다.'}
- </p>
- </div>
- </div>
- )}
- </CardContent>
- </Card>
-
- <div className="flex justify-end">
- <Button
- onClick={() => setCurrentStep(2)}
- disabled={isLoading}
- >
- 다음 단계
- </Button>
- </div>
- </TabsContent>
-
- {/* 2단계: 기본계약 체크 */}
- <TabsContent value="2" className="space-y-4">
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <FileText className="h-5 w-5 text-blue-600" />
- 기본계약서 선택
- </CardTitle>
- <p className="text-sm text-muted-foreground">
- 벤더에게 발송할 기본계약서를 선택해주세요. (템플릿이 있는 계약서만 선택 가능합니다.)
- </p>
- </CardHeader>
- <CardContent className="space-y-4">
- {isLoadingBasicContracts ? (
- <div className="text-center py-8">
- <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
- <p className="mt-2 text-sm text-muted-foreground">기본계약 템플릿을 불러오는 중...</p>
- </div>
- ) : (
- <div className="space-y-4">
- {selectedBasicContracts.length > 0 ? (
- <div className="space-y-3">
- <div className="flex items-center justify-between">
- <h4 className="font-medium">필요한 기본계약서</h4>
- <Badge variant="outline">
- {selectedBasicContracts.filter(c => c.checked).length}개 선택됨
- </Badge>
- </div>
-
- <div className="grid gap-3">
- {selectedBasicContracts.map((contract) => (
- <div
- key={contract.type}
- className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50"
- >
- <div className="flex items-center gap-3">
- <Checkbox
- id={`contract-${contract.type}`}
- checked={contract.checked}
- onCheckedChange={() => toggleBasicContract(contract.type)}
- />
- <div>
- <Label
- htmlFor={`contract-${contract.type}`}
- className="font-medium cursor-pointer"
- >
- {contract.type}
- </Label>
- <p className="text-sm text-muted-foreground">
- 템플릿: {contract.templateName}
- </p>
- </div>
- </div>
- <Badge
- variant="secondary"
- className="text-xs"
- >
- {contract.checked ? "선택됨" : "미선택"}
- </Badge>
- </div>
- ))}
- </div>
-
- </div>
- ) : (
- <div className="text-center py-8 text-muted-foreground">
- <FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
- <p>기본계약서 목록을 불러올 수 없습니다.</p>
- <p className="text-sm">잠시 후 다시 시도해주세요.</p>
- </div>
- )}
-
- </div>
- )}
- </CardContent>
- </Card>
-
- <div className="flex justify-between">
- <Button variant="outline" onClick={() => setCurrentStep(1)}>
- 이전 단계
- </Button>
- <Button
- onClick={() => setCurrentStep(3)}
- disabled={isLoadingBasicContracts}
- >
- 다음 단계
- </Button>
- </div>
- </TabsContent>
-
- {/* 3단계: PDF 미리보기 */}
- <TabsContent value="3" className="space-y-4">
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <Eye className="h-5 w-5 text-purple-600" />
- PDF 미리보기
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- {!generatedPdfUrl ? (
- <div className="text-center py-8">
- <Button onClick={generatePdf} disabled={isLoading}>
- {isLoading ? 'PDF 생성 중...' : 'PDF 생성하기'}
- </Button>
- </div>
- ) : (
- <div className="space-y-4">
- <div className="border rounded-lg p-4 bg-green-50">
- <div className="flex items-center gap-2">
- <CheckCircle className="h-4 w-4 text-green-600" />
- <span className="font-medium text-green-900">PDF 생성 완료</span>
- </div>
- </div>
-
- <div className="border rounded-lg p-4">
- <div className="flex items-center justify-between mb-4">
- <h4 className="font-medium">생성된 PDF</h4>
- <div className="flex gap-2">
- <Button
- variant="outline"
- size="sm"
- onClick={downloadPdf}
- disabled={isLoading}
- >
- <Download className="h-4 w-4 mr-2" />
- 다운로드
- </Button>
- <Button
- variant="outline"
- size="sm"
- onClick={openPdfPreview}
- disabled={isLoading}
- >
- <Eye className="h-4 w-4 mr-2" />
- 미리보기
- </Button>
- </div>
- </div>
-
- {/* PDF 미리보기 영역 */}
- <div className="border rounded-lg h-96 bg-gray-50 relative" data-pdf-container>
- {isPdfPreviewVisible ? (
- <>
- <div className="absolute top-2 right-2 z-10">
- <Button
- variant="outline"
- size="sm"
- onClick={closePdfPreview}
- className="bg-white/90 hover:bg-white"
- >
- ✕ 닫기
- </Button>
- </div>
- <div id="pdf-preview-container" className="w-full h-full" />
- </>
- ) : (
- <div className="flex items-center justify-center h-full">
- <div className="text-center text-muted-foreground">
- <FileText className="h-12 w-12 mx-auto mb-2" />
- <p>미리보기 버튼을 클릭하여 PDF를 확인하세요</p>
- </div>
- </div>
- )}
- </div>
- </div>
- </div>
- )}
- </CardContent>
- </Card>
-
- <div className="flex justify-between">
- <Button variant="outline" onClick={() => setCurrentStep(2)}>
- 이전 단계
- </Button>
- <Button
- onClick={handleFinalSubmit}
- disabled={!generatedPdfUrl || isLoading}
- className="bg-green-600 hover:bg-green-700"
- >
- <Send className="h-4 w-4 mr-2" />
- {isLoading ? '전송 중...' : '최종 전송'}
- </Button>
- </div>
- </TabsContent>
- </Tabs>
- </DialogContent>
- </Dialog>
+'use client'
+
+import React, { useState, useEffect } from 'react'
+import { useSession } from 'next-auth/react'
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
+import { Button } from '@/components/ui/button'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Label } from '@/components/ui/label'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { Input } from '@/components/ui/input'
+import { toast } from 'sonner'
+import {
+ FileText,
+ Upload,
+ Eye,
+ Send,
+ CheckCircle,
+ Download,
+ AlertCircle
+} from 'lucide-react'
+import { ContractDocuments } from './general-contract-documents'
+import { getActiveContractTemplates } from '@/lib/bidding/service'
+import { type BasicContractTemplate } from '@/db/schema'
+import {
+ getBasicInfo,
+ getContractItems,
+ getSubcontractChecklist,
+ uploadContractApprovalFile,
+ sendContractApprovalRequest,
+ getContractById,
+ getContractTemplateByContractType,
+ getStorageInfo
+} from '../service'
+import { mapContractDataToTemplateVariables } from '../utils'
+import { ApprovalPreviewDialog } from '@/lib/approval/client'
+import { requestContractApprovalWithApproval } from '../approval-actions'
+import { mapContractToApprovalTemplateVariables } from '../approval-template-variables'
+
+interface ContractApprovalRequestDialogProps {
+ contract: Record<string, unknown>
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+interface ContractSummary {
+ basicInfo: Record<string, unknown>
+ items: Record<string, unknown>[]
+ subcontractChecklist: Record<string, unknown> | null
+ storageInfo?: Record<string, unknown>[]
+ pdfPath?: string
+ basicContractPdfs?: Array<{ key: string; buffer: number[]; fileName: string }>
+}
+
+export function ContractApprovalRequestDialog({
+ contract,
+ open,
+ onOpenChange
+}: ContractApprovalRequestDialogProps) {
+ const { data: session } = useSession()
+ const [currentStep, setCurrentStep] = useState(1)
+ const [contractSummary, setContractSummary] = useState<ContractSummary | null>(null)
+ const [uploadedFile, setUploadedFile] = useState<File | null>(null)
+ const [generatedPdfUrl, setGeneratedPdfUrl] = useState<string | null>(null)
+ const [generatedPdfBuffer, setGeneratedPdfBuffer] = useState<Uint8Array | null>(null)
+ const [isLoading, setIsLoading] = useState(false)
+ const [pdfViewerInstance, setPdfViewerInstance] = useState<any>(null)
+ const [isPdfPreviewVisible, setIsPdfPreviewVisible] = useState(false)
+
+ // 기본계약 관련 상태
+ const [selectedBasicContracts, setSelectedBasicContracts] = useState<Array<{
+ type: string;
+ templateName: string;
+ checked: boolean;
+ }>>([])
+ const [isLoadingBasicContracts, setIsLoadingBasicContracts] = useState(false)
+
+ // 결재 관련 상태
+ const [approvalDialogOpen, setApprovalDialogOpen] = useState(false)
+ const [approvalVariables, setApprovalVariables] = useState<Record<string, string>>({})
+ const [savedPdfPath, setSavedPdfPath] = useState<string | null>(null)
+ const [savedBasicContractPdfs, setSavedBasicContractPdfs] = useState<Array<{ key: string; buffer: number[]; fileName: string }>>([])
+
+ const contractId = contract.id as number
+ const userId = session?.user?.id || ''
+
+
+ // 기본계약 생성 함수 (최종 전송 시점에 호출)
+ const generateBasicContractPdf = async (
+ vendorId: number,
+ contractType: string,
+ templateName: string
+ ): Promise<{ buffer: number[], fileName: string }> => {
+ try {
+ // 1. 템플릿 데이터 준비 (서버 액션 호출)
+ const prepareResponse = await fetch("/api/contracts/prepare-template", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ templateName,
+ vendorId,
+ }),
+ });
+
+ if (!prepareResponse.ok) {
+ const errorText = await prepareResponse.text();
+ throw new Error(`템플릿 준비 실패 (${prepareResponse.status}): ${errorText}`);
+ }
+
+ const { template, templateData } = await prepareResponse.json();
+
+ // 2. 템플릿 파일 다운로드
+ const templateResponse = await fetch("/api/contracts/get-template", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ templatePath: template.filePath }),
+ });
+
+ const templateBlob = await templateResponse.blob();
+ const templateFile = new window.File([templateBlob], "template.docx", {
+ type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
+ });
+
+ // 3. PDFTron WebViewer로 PDF 변환
+ const { default: WebViewer } = await import("@pdftron/webviewer");
+
+ const tempDiv = document.createElement('div');
+ tempDiv.style.display = 'none';
+ document.body.appendChild(tempDiv);
+
+ try {
+ const instance = await WebViewer(
+ {
+ path: "/pdftronWeb",
+ licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
+ fullAPI: true,
+ enableOfficeEditing: true,
+ },
+ tempDiv
+ );
+
+ const { Core } = instance;
+ const { createDocument } = Core;
+
+ const templateDoc = await createDocument(templateFile, {
+ filename: templateFile.name,
+ extension: 'docx',
+ });
+
+ // 변수 치환 적용
+ await templateDoc.applyTemplateValues(templateData);
+ await new Promise(resolve => setTimeout(resolve, 3000));
+
+ const fileData = await templateDoc.getFileData();
+ const pdfBuffer = await (Core as any).officeToPDFBuffer(fileData, { extension: 'docx' });
+
+ const fileName = `${contractType}_${contractSummary?.basicInfo?.vendorCode || vendorId}_${Date.now()}.pdf`;
+
+ instance.UI.dispose();
+ return {
+ buffer: Array.from(pdfBuffer),
+ fileName
+ };
+
+ } finally {
+ if (tempDiv.parentNode) {
+ document.body.removeChild(tempDiv);
+ }
+ }
+
+ } catch (error) {
+ console.error(`기본계약 PDF 생성 실패 (${contractType}):`, error);
+ throw error;
+ }
+ };
+
+ // 기본계약 생성 및 선택 초기화
+ const initializeBasicContracts = React.useCallback(async () => {
+ if (!contractSummary?.basicInfo) return;
+
+ setIsLoadingBasicContracts(true);
+ try {
+ // 기본적으로 사용할 수 있는 계약서 타입들
+ const availableContracts: Array<{
+ type: string;
+ templateName: string;
+ checked: boolean;
+ }> = [
+ { type: "NDA", templateName: "비밀", checked: false },
+ { type: "General_GTC", templateName: "General GTC", checked: false },
+ { type: "기술자료", templateName: "기술", checked: false }
+ ];
+
+ // 프로젝트 코드가 있으면 Project GTC도 추가
+ if (contractSummary.basicInfo.projectCode) {
+ availableContracts.push({
+ type: "Project_GTC",
+ templateName: contractSummary.basicInfo.projectCode as string,
+ checked: false
+ });
+ }
+
+ setSelectedBasicContracts(availableContracts);
+ } catch (error) {
+ console.error('기본계약 초기화 실패:', error);
+ toast.error('기본계약 초기화에 실패했습니다.');
+ } finally {
+ setIsLoadingBasicContracts(false);
+ }
+ }, [contractSummary]);
+
+ // 기본계약 선택 토글
+ const toggleBasicContract = (type: string) => {
+ setSelectedBasicContracts(prev =>
+ prev.map(contract =>
+ contract.type === type
+ ? { ...contract, checked: !contract.checked }
+ : contract
+ )
+ );
+ };
+
+
+ // 1단계: 계약 현황 수집
+ const collectContractSummary = React.useCallback(async () => {
+ setIsLoading(true)
+ try {
+ // 각 컴포넌트에서 활성화된 데이터만 수집
+ const summary: ContractSummary = {
+ basicInfo: {},
+ items: [],
+ subcontractChecklist: null
+ }
+
+ // Basic Info 확인 (항상 활성화)
+ try {
+ const basicInfoData = await getBasicInfo(contractId)
+ if (basicInfoData && basicInfoData.success) {
+ summary.basicInfo = basicInfoData.data || {}
+ }
+ // externalYardEntry 정보도 추가로 가져오기
+ const contractData = await getContractById(contractId)
+ if (contractData) {
+ summary.basicInfo = {
+ ...summary.basicInfo,
+ externalYardEntry: contractData.externalYardEntry || 'N'
+ }
+ }
+ } catch {
+ console.log('Basic Info 데이터 없음')
+ }
+
+ // 품목 정보 확인
+ try {
+ const itemsData = await getContractItems(contractId)
+ if (itemsData && itemsData.length > 0) {
+ summary.items = itemsData
+ }
+ } catch {
+ console.log('품목 정보 데이터 없음')
+ }
+
+ try {
+ // Subcontract Checklist 확인
+ const subcontractData = await getSubcontractChecklist(contractId)
+ if (subcontractData && subcontractData.success && subcontractData.enabled) {
+ summary.subcontractChecklist = subcontractData.data
+ }
+ } catch {
+ console.log('Subcontract Checklist 데이터 없음')
+ }
+
+ // 임치(물품보관) 계약 정보 확인 (SG)
+ try {
+ if (summary.basicInfo?.contractType === 'SG') {
+ const storageData = await getStorageInfo(contractId)
+ if (storageData && storageData.length > 0) {
+ summary.storageInfo = storageData
+ }
+ }
+ } catch {
+ console.log('임치계약 정보 없음')
+ }
+
+ console.log('contractSummary 구조:', summary)
+ console.log('basicInfo 내용:', summary.basicInfo)
+ setContractSummary(summary)
+ } catch (error) {
+ console.error('Error collecting contract summary:', error)
+ toast.error('계약 정보를 수집하는 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }, [contractId])
+
+ // 3단계: PDF 생성 및 미리보기 (PDFTron 사용) - 템플릿 자동 로드
+ const generatePdf = async () => {
+ if (!contractSummary) {
+ toast.error('계약 정보가 필요합니다.')
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ // 1. 계약 유형에 맞는 템플릿 조회
+ const contractType = contractSummary.basicInfo.contractType as string
+ const templateResult = await getContractTemplateByContractType(contractType)
+
+ if (!templateResult.success || !templateResult.template) {
+ throw new Error(templateResult.error || '템플릿을 찾을 수 없습니다.')
+ }
+
+ const template = templateResult.template
+
+ // 2. 템플릿 파일 다운로드
+ const templateResponse = await fetch("/api/contracts/get-template", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ templatePath: template.filePath }),
+ })
+
+ if (!templateResponse.ok) {
+ throw new Error("템플릿 파일을 다운로드할 수 없습니다.")
+ }
+
+ const templateBlob = await templateResponse.blob()
+ const templateFile = new File([templateBlob], template.fileName || "template.docx", {
+ type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
+ })
+
+ // 3. PDFTron을 사용해서 변수 치환 및 PDF 변환
+ // @ts-ignore
+ const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer)
+
+ // 임시 WebViewer 인스턴스 생성 (DOM에 추가하지 않음)
+ const tempDiv = document.createElement('div')
+ tempDiv.style.display = 'none'
+ document.body.appendChild(tempDiv)
+
+ const instance = await WebViewer(
+ {
+ path: "/pdftronWeb",
+ licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
+ fullAPI: true,
+ },
+ tempDiv
+ )
+
+ try {
+ const { Core } = instance
+ const { createDocument } = Core
+
+ // 템플릿 문서 생성 및 변수 치환
+ const templateDoc = await createDocument(templateFile, {
+ filename: templateFile.name,
+ extension: 'docx',
+ })
+
+ // 템플릿 변수 매핑
+ const mappedTemplateData = mapContractDataToTemplateVariables(contractSummary)
+
+ console.log("🔄 변수 치환 시작:", mappedTemplateData)
+ await templateDoc.applyTemplateValues(mappedTemplateData as any)
+ console.log("✅ 변수 치환 완료")
+
+ // PDF 변환
+ const fileData = await templateDoc.getFileData()
+ const pdfBuffer = await (Core as any).officeToPDFBuffer(fileData, { extension: 'docx' })
+
+ console.log(`✅ PDF 변환 완료: ${templateFile.name}`, `크기: ${pdfBuffer.byteLength} bytes`)
+
+ // PDF 버퍼를 Blob URL로 변환하여 미리보기
+ const pdfBlob = new Blob([pdfBuffer], { type: 'application/pdf' })
+ const pdfUrl = URL.createObjectURL(pdfBlob)
+ setGeneratedPdfUrl(pdfUrl)
+
+ // PDF 버퍼를 상태에 저장 (최종 전송 시 사용)
+ setGeneratedPdfBuffer(new Uint8Array(pdfBuffer))
+
+ toast.success('PDF가 생성되었습니다.')
+
+ } finally {
+ // 임시 WebViewer 정리
+ instance.UI.dispose()
+ document.body.removeChild(tempDiv)
+ }
+
+ } catch (error: any) {
+ console.error('❌ PDF 생성 실패:', error)
+ const errorMessage = error instanceof Error ? error.message : (error?.message || '알 수 없는 오류')
+ toast.error(`PDF 생성 중 오류가 발생했습니다: ${errorMessage}`)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // PDF 미리보기 기능
+ const openPdfPreview = async () => {
+ if (!generatedPdfBuffer) {
+ toast.error('생성된 PDF가 없습니다.')
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ // @ts-ignore
+ const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer)
+
+ // 기존 인스턴스가 있다면 정리
+ if (pdfViewerInstance) {
+ console.log("🔄 기존 WebViewer 인스턴스 정리")
+ try {
+ pdfViewerInstance.UI.dispose()
+ } catch (error) {
+ console.warn('기존 WebViewer 정리 중 오류:', error)
+ }
+ setPdfViewerInstance(null)
+ }
+
+ // 미리보기용 컨테이너 확인
+ let previewDiv = document.getElementById('pdf-preview-container')
+ if (!previewDiv) {
+ console.log("🔄 컨테이너 생성")
+ previewDiv = document.createElement('div')
+ previewDiv.id = 'pdf-preview-container'
+ previewDiv.className = 'w-full h-full'
+ previewDiv.style.width = '100%'
+ previewDiv.style.height = '100%'
+
+ // 실제 컨테이너에 추가
+ const actualContainer = document.querySelector('[data-pdf-container]')
+ if (actualContainer) {
+ actualContainer.appendChild(previewDiv)
+ }
+ }
+
+ console.log("🔄 WebViewer 인스턴스 생성 시작")
+
+ // WebViewer 인스턴스 생성 (문서 없이)
+ const instance = await Promise.race([
+ WebViewer(
+ {
+ path: "/pdftronWeb",
+ licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
+ fullAPI: true,
+ },
+ previewDiv
+ ),
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error('WebViewer 초기화 타임아웃')), 30000)
+ )
+ ])
+
+ console.log("🔄 WebViewer 인스턴스 생성 완료")
+ setPdfViewerInstance(instance)
+
+ // PDF 버퍼를 Blob으로 변환
+ const pdfBlob = new Blob([generatedPdfBuffer as any], { type: 'application/pdf' })
+ const pdfUrl = URL.createObjectURL(pdfBlob)
+ console.log("🔄 PDF Blob URL 생성:", pdfUrl)
+
+ // 문서 로드
+ console.log("🔄 문서 로드 시작")
+ const { documentViewer } = (instance as any).Core
+
+ // 문서 로드 이벤트 대기
+ await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ reject(new Error('문서 로드 타임아웃'))
+ }, 20000)
+
+ const onDocumentLoaded = () => {
+ clearTimeout(timeout)
+ documentViewer.removeEventListener('documentLoaded', onDocumentLoaded)
+ documentViewer.removeEventListener('documentError', onDocumentError)
+ console.log("🔄 문서 로드 완료")
+ resolve(true)
+ }
+
+ const onDocumentError = (error: any) => {
+ clearTimeout(timeout)
+ documentViewer.removeEventListener('documentLoaded', onDocumentLoaded)
+ documentViewer.removeEventListener('documentError', onDocumentError)
+ console.error('문서 로드 오류:', error)
+ reject(error)
+ }
+
+ documentViewer.addEventListener('documentLoaded', onDocumentLoaded)
+ documentViewer.addEventListener('documentError', onDocumentError)
+
+ // 문서 로드 시작
+ documentViewer.loadDocument(pdfUrl)
+ })
+
+ setIsPdfPreviewVisible(true)
+ toast.success('PDF 미리보기가 준비되었습니다.')
+
+ } catch (error) {
+ console.error('PDF 미리보기 실패:', error)
+ toast.error(`PDF 미리보기 중 오류가 발생했습니다: ${error.message}`)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // PDF 다운로드 기능
+ const downloadPdf = () => {
+ if (!generatedPdfBuffer) {
+ toast.error('다운로드할 PDF가 없습니다.')
+ return
+ }
+
+ const pdfBlob = new Blob([generatedPdfBuffer as any], { type: 'application/pdf' })
+ const pdfUrl = URL.createObjectURL(pdfBlob)
+
+ const link = document.createElement('a')
+ link.href = pdfUrl
+ link.download = `contract_${contractId}_${Date.now()}.pdf`
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+
+ URL.revokeObjectURL(pdfUrl)
+ toast.success('PDF가 다운로드되었습니다.')
+ }
+
+ // PDF 미리보기 닫기
+ const closePdfPreview = () => {
+ console.log("🔄 PDF 미리보기 닫기 시작")
+ if (pdfViewerInstance) {
+ try {
+ console.log("🔄 WebViewer 인스턴스 정리")
+ pdfViewerInstance.UI.dispose()
+ } catch (error) {
+ console.warn('WebViewer 정리 중 오류:', error)
+ }
+ setPdfViewerInstance(null)
+ }
+
+ // 컨테이너 정리
+ const previewDiv = document.getElementById('pdf-preview-container')
+ if (previewDiv) {
+ try {
+ previewDiv.innerHTML = ''
+ } catch (error) {
+ console.warn('컨테이너 정리 중 오류:', error)
+ }
+ }
+
+ setIsPdfPreviewVisible(false)
+ console.log("🔄 PDF 미리보기 닫기 완료")
+ }
+
+ // PDF를 서버에 저장하는 함수 (API route 사용)
+ const savePdfToServer = async (pdfBuffer: Uint8Array, fileName: string): Promise<string | null> => {
+ try {
+ // PDF 버퍼를 Blob으로 변환
+ const pdfBlob = new Blob([pdfBuffer], { type: 'application/pdf' });
+
+ // FormData 생성
+ const formData = new FormData();
+ formData.append('file', pdfBlob, fileName);
+ formData.append('contractId', String(contractId));
+
+ // API route로 업로드
+ const response = await fetch('/api/general-contracts/upload-pdf', {
+ method: 'POST',
+ body: formData,
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json();
+ throw new Error(errorData.error || 'PDF 파일 저장에 실패했습니다.');
+ }
+
+ const result = await response.json();
+
+ if (!result.success) {
+ throw new Error(result.error || 'PDF 파일 저장에 실패했습니다.');
+ }
+
+ return result.filePath;
+ } catch (error) {
+ console.error('PDF 저장 실패:', error);
+ return null;
+ }
+ };
+
+ // 최종 전송 - 결재 프로세스 시작
+ const handleFinalSubmit = async () => {
+ if (!generatedPdfUrl || !contractSummary || !generatedPdfBuffer) {
+ toast.error('생성된 PDF가 필요합니다.')
+ return
+ }
+
+ if (!userId) {
+ toast.error('로그인이 필요합니다.')
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ // 기본계약서 생성 (최종 전송 시점에)
+ let generatedBasicContractPdfs: Array<{ key: string; buffer: number[]; fileName: string }> = [];
+
+ const contractsToGenerate = selectedBasicContracts.filter(c => c.checked);
+ if (contractsToGenerate.length > 0) {
+ // vendorId 조회
+ let vendorId: number | undefined;
+ try {
+ const basicInfoData = await getBasicInfo(contractId);
+ if (basicInfoData && basicInfoData.success && basicInfoData.data) {
+ vendorId = basicInfoData.data.vendorId;
+ }
+ } catch (error) {
+ console.error('vendorId 조회 실패:', error);
+ }
+
+ if (vendorId) {
+ toast.info('기본계약서를 생성하는 중입니다...');
+
+ for (const contract of contractsToGenerate) {
+ try {
+ const pdf = await generateBasicContractPdf(vendorId, contract.type, contract.templateName);
+ generatedBasicContractPdfs.push({
+ key: `${vendorId}_${contract.type}_${contract.templateName}`,
+ ...pdf
+ });
+ } catch (error) {
+ console.error(`${contract.type} 계약서 생성 실패:`, error);
+ // 개별 실패는 전체를 중단하지 않음
+ }
+ }
+
+ if (generatedBasicContractPdfs.length > 0) {
+ toast.success(`${generatedBasicContractPdfs.length}개의 기본계약서가 생성되었습니다.`);
+ }
+ }
+ }
+
+ // PDF를 서버에 저장
+ toast.info('PDF를 서버에 저장하는 중입니다...');
+ const pdfPath = await savePdfToServer(
+ generatedPdfBuffer,
+ `contract_${contractId}_${Date.now()}.pdf`
+ );
+
+ if (!pdfPath) {
+ toast.error('PDF 저장에 실패했습니다.');
+ return;
+ }
+
+ setSavedPdfPath(pdfPath);
+ setSavedBasicContractPdfs(generatedBasicContractPdfs);
+
+ // 결재 템플릿 변수 매핑
+ const approvalVars = await mapContractToApprovalTemplateVariables(contractSummary);
+ setApprovalVariables(approvalVars);
+
+ // 계약승인요청 dialog close
+ onOpenChange(false);
+
+ // 결재 템플릿 dialog open
+ setApprovalDialogOpen(true);
+ } catch (error: any) {
+ console.error('Error preparing approval:', error);
+ toast.error('결재 준비 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 결재 등록 처리
+ const handleApprovalSubmit = async (data: {
+ approvers: string[];
+ title: string;
+ attachments?: File[];
+ }) => {
+ if (!contractSummary || !savedPdfPath) {
+ toast.error('계약 정보가 필요합니다.')
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ const result = await requestContractApprovalWithApproval({
+ contractId,
+ contractSummary: {
+ ...contractSummary,
+ // PDF 경로를 contractSummary에 추가
+ pdfPath: savedPdfPath || undefined,
+ basicContractPdfs: savedBasicContractPdfs.length > 0 ? savedBasicContractPdfs : undefined,
+ } as ContractSummary,
+ currentUser: {
+ id: Number(userId),
+ epId: session?.user?.epId || null,
+ email: session?.user?.email || undefined,
+ },
+ approvers: data.approvers,
+ title: data.title,
+ });
+
+ if (result.status === 'pending_approval') {
+ toast.success('결재가 등록되었습니다.')
+ setApprovalDialogOpen(false);
+ } else {
+ toast.error('결재 등록에 실패했습니다.')
+ }
+ } catch (error: any) {
+ console.error('Error submitting approval:', error);
+ toast.error(`결재 등록 중 오류가 발생했습니다: ${error.message || '알 수 없는 오류'}`);
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 다이얼로그가 열릴 때 1단계 데이터 수집
+ useEffect(() => {
+ if (open && currentStep === 1) {
+ collectContractSummary()
+ }
+ }, [open, currentStep, collectContractSummary])
+
+ // 계약 요약이 준비되면 기본계약 초기화
+ useEffect(() => {
+ if (contractSummary && currentStep === 2) {
+ const loadBasicContracts = async () => {
+ await initializeBasicContracts()
+ }
+ loadBasicContracts()
+ }
+ }, [contractSummary, currentStep, initializeBasicContracts])
+
+ // 다이얼로그가 닫힐 때 PDF 뷰어 정리
+ useEffect(() => {
+ if (!open) {
+ closePdfPreview()
+ }
+ }, [open])
+
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <FileText className="h-5 w-5" />
+ 계약승인요청
+ </DialogTitle>
+ </DialogHeader>
+
+ <Tabs value={currentStep.toString()} className="w-full">
+ <TabsList className="grid w-full grid-cols-3">
+ <TabsTrigger value="1" disabled={currentStep < 1}>
+ 1. 계약 현황 정리
+ </TabsTrigger>
+ <TabsTrigger value="2" disabled={currentStep < 2}>
+ 2. 기본계약 체크
+ </TabsTrigger>
+ <TabsTrigger value="3" disabled={currentStep < 3}>
+ 3. PDF 미리보기
+ </TabsTrigger>
+ </TabsList>
+
+ {/* 1단계: 계약 현황 정리 */}
+ <TabsContent value="1" className="space-y-4">
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <CheckCircle className="h-5 w-5 text-green-600" />
+ 작성된 계약 현황
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {isLoading ? (
+ <div className="text-center py-4">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
+ <p className="mt-2 text-sm text-muted-foreground">계약 정보를 수집하는 중...</p>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ {/* 기본 정보 (필수) */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <CheckCircle className="h-4 w-4 text-green-600" />
+ <Label className="font-medium">기본 정보</Label>
+ <Badge variant="secondary">필수</Badge>
+ </div>
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div>
+ <span className="font-medium">계약번호:</span> {String(contractSummary?.basicInfo?.contractNumber || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약명:</span> {String(contractSummary?.basicInfo?.contractName || '')}
+ </div>
+ <div>
+ <span className="font-medium">벤더:</span> {String(contractSummary?.basicInfo?.vendorName || '')}
+ </div>
+ <div>
+ <span className="font-medium">프로젝트:</span> {String(contractSummary?.basicInfo?.projectName || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약유형:</span> {String(contractSummary?.basicInfo?.contractType || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약상태:</span> {String(contractSummary?.basicInfo?.contractStatus || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약금액:</span> {String(contractSummary?.basicInfo?.contractAmount || '')} {String(contractSummary?.basicInfo?.currency || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약기간:</span> {String(contractSummary?.basicInfo?.startDate || '')} ~ {String(contractSummary?.basicInfo?.endDate || '')}
+ </div>
+ <div>
+ <span className="font-medium">사양서 유형:</span> {String(contractSummary?.basicInfo?.specificationType || '')}
+ </div>
+ <div>
+ <span className="font-medium">단가 유형:</span> {String(contractSummary?.basicInfo?.unitPriceType || '')}
+ </div>
+ <div>
+ <span className="font-medium">연결 PO번호:</span> {String(contractSummary?.basicInfo?.linkedPoNumber || '')}
+ </div>
+ <div>
+ <span className="font-medium">연결 입찰번호:</span> {String(contractSummary?.basicInfo?.linkedBidNumber || '')}
+ </div>
+ </div>
+ </div>
+
+ {/* 지급/인도 조건 */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <CheckCircle className="h-4 w-4 text-green-600" />
+ <Label className="font-medium">지급/인도 조건</Label>
+ <Badge variant="secondary">필수</Badge>
+ </div>
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div>
+ <span className="font-medium">지급조건:</span> {String(contractSummary?.basicInfo?.paymentTerm || '')}
+ </div>
+ <div>
+ <span className="font-medium">세금 유형:</span> {String(contractSummary?.basicInfo?.taxType || '')}
+ </div>
+ <div>
+ <span className="font-medium">인도조건:</span> {String(contractSummary?.basicInfo?.deliveryTerm || '')}
+ </div>
+ <div>
+ <span className="font-medium">인도유형:</span> {String(contractSummary?.basicInfo?.deliveryType || '')}
+ </div>
+ <div>
+ <span className="font-medium">선적지:</span> {String(contractSummary?.basicInfo?.shippingLocation || '')}
+ </div>
+ <div>
+ <span className="font-medium">하역지:</span> {String(contractSummary?.basicInfo?.dischargeLocation || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약납기:</span> {String(contractSummary?.basicInfo?.contractDeliveryDate || '')}
+ </div>
+ <div>
+ <span className="font-medium">위약금:</span> {contractSummary?.basicInfo?.liquidatedDamages ? '적용' : '미적용'}
+ </div>
+ </div>
+ </div>
+
+ {/* 추가 조건 */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <CheckCircle className="h-4 w-4 text-green-600" />
+ <Label className="font-medium">추가 조건</Label>
+ <Badge variant="secondary">필수</Badge>
+ </div>
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div>
+ <span className="font-medium">연동제 정보:</span> {String(contractSummary?.basicInfo?.interlockingSystem || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약성립조건:</span>
+ {contractSummary?.basicInfo?.contractEstablishmentConditions ? (() => {
+ const conditions = Object.entries(contractSummary.basicInfo.contractEstablishmentConditions as Record<string, boolean>)
+ .filter(([, value]) => value === true)
+ .map(([key]) => {
+ const conditionMap: Record<string, string> = {
+ 'ownerApproval': '정규업체 등록(실사 포함) 시',
+ 'regularVendorRegistration': '프로젝트 수주 시',
+ 'shipOwnerApproval': '선주 승인 시',
+ 'other': '기타'
+ };
+ return conditionMap[key] || key;
+ });
+ return conditions.length > 0 ? conditions.join(', ') : '없음';
+ })() : '없음'}
+ </div>
+ <div>
+ <span className="font-medium">계약해지조건:</span>
+ {contractSummary?.basicInfo?.contractTerminationConditions ? (() => {
+ const conditions = Object.entries(contractSummary.basicInfo.contractTerminationConditions as Record<string, boolean>)
+ .filter(([, value]) => value === true)
+ .map(([key]) => {
+ const conditionMap: Record<string, string> = {
+ 'standardTermination': '표준 계약해지조건',
+ 'projectNotAwarded': '프로젝트 미수주 시',
+ 'other': '기타'
+ };
+ return conditionMap[key] || key;
+ });
+ return conditions.length > 0 ? conditions.join(', ') : '없음';
+ })() : '없음'}
+ </div>
+ </div>
+ </div>
+
+ {/* 품목 정보 */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <Checkbox
+ id="items-enabled"
+ checked={contractSummary?.items && contractSummary.items.length > 0}
+ disabled
+ />
+ <Label htmlFor="items-enabled" className="font-medium">품목 정보</Label>
+ <Badge variant="outline">선택</Badge>
+ </div>
+ {contractSummary?.items && contractSummary.items.length > 0 ? (
+ <div className="space-y-2">
+ <p className="text-sm text-muted-foreground">
+ 총 {contractSummary.items.length}개 품목이 입력되어 있습니다.
+ </p>
+ <div className="max-h-32 overflow-y-auto">
+ {contractSummary.items.slice(0, 3).map((item: Record<string, unknown>, index: number) => (
+ <div key={index} className="text-xs bg-gray-50 p-2 rounded">
+ <div className="font-medium">{String(item.itemInfo || item.description || `품목 ${index + 1}`)}</div>
+ <div className="text-muted-foreground">
+ 수량: {String(item.quantity || 0)} | 단가: {String(item.contractUnitPrice || item.unitPrice || 0)}
+ </div>
+ </div>
+ ))}
+ {contractSummary.items.length > 3 && (
+ <div className="text-xs text-muted-foreground text-center">
+ ... 외 {contractSummary.items.length - 3}개 품목
+ </div>
+ )}
+ </div>
+ </div>
+ ) : (
+ <p className="text-sm text-muted-foreground">
+ 품목 정보가 입력되지 않았습니다.
+ </p>
+ )}
+ </div>
+
+ {/* 하도급 체크리스트 */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <Checkbox
+ id="subcontract-enabled"
+ checked={!!contractSummary?.subcontractChecklist}
+ disabled
+ />
+ <Label htmlFor="subcontract-enabled" className="font-medium">
+ 하도급 체크리스트
+ </Label>
+ <Badge variant="outline">선택</Badge>
+ </div>
+ <p className="text-sm text-muted-foreground">
+ {contractSummary?.subcontractChecklist
+ ? '정보가 입력되어 있습니다.'
+ : '정보가 입력되지 않았습니다.'}
+ </p>
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ <div className="flex justify-end">
+ <Button
+ onClick={() => setCurrentStep(2)}
+ disabled={isLoading}
+ >
+ 다음 단계
+ </Button>
+ </div>
+ </TabsContent>
+
+ {/* 2단계: 기본계약 체크 */}
+ <TabsContent value="2" className="space-y-4">
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <FileText className="h-5 w-5 text-blue-600" />
+ 기본계약서 선택
+ </CardTitle>
+ <p className="text-sm text-muted-foreground">
+ 벤더에게 발송할 기본계약서를 선택해주세요. (템플릿이 있는 계약서만 선택 가능합니다.)
+ </p>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {isLoadingBasicContracts ? (
+ <div className="text-center py-8">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
+ <p className="mt-2 text-sm text-muted-foreground">기본계약 템플릿을 불러오는 중...</p>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ {selectedBasicContracts.length > 0 ? (
+ <div className="space-y-3">
+ <div className="flex items-center justify-between">
+ <h4 className="font-medium">필요한 기본계약서</h4>
+ <Badge variant="outline">
+ {selectedBasicContracts.filter(c => c.checked).length}개 선택됨
+ </Badge>
+ </div>
+
+ <div className="grid gap-3">
+ {selectedBasicContracts.map((contract) => (
+ <div
+ key={contract.type}
+ className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50"
+ >
+ <div className="flex items-center gap-3">
+ <Checkbox
+ id={`contract-${contract.type}`}
+ checked={contract.checked}
+ onCheckedChange={() => toggleBasicContract(contract.type)}
+ />
+ <div>
+ <Label
+ htmlFor={`contract-${contract.type}`}
+ className="font-medium cursor-pointer"
+ >
+ {contract.type}
+ </Label>
+ <p className="text-sm text-muted-foreground">
+ 템플릿: {contract.templateName}
+ </p>
+ </div>
+ </div>
+ <Badge
+ variant="secondary"
+ className="text-xs"
+ >
+ {contract.checked ? "선택됨" : "미선택"}
+ </Badge>
+ </div>
+ ))}
+ </div>
+
+ </div>
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">
+ <FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
+ <p>기본계약서 목록을 불러올 수 없습니다.</p>
+ <p className="text-sm">잠시 후 다시 시도해주세요.</p>
+ </div>
+ )}
+
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ <div className="flex justify-between">
+ <Button variant="outline" onClick={() => setCurrentStep(1)}>
+ 이전 단계
+ </Button>
+ <Button
+ onClick={() => setCurrentStep(3)}
+ disabled={isLoadingBasicContracts}
+ >
+ 다음 단계
+ </Button>
+ </div>
+ </TabsContent>
+
+ {/* 3단계: PDF 미리보기 */}
+ <TabsContent value="3" className="space-y-4">
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Eye className="h-5 w-5 text-purple-600" />
+ PDF 미리보기
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {!generatedPdfUrl ? (
+ <div className="text-center py-8">
+ <Button onClick={generatePdf} disabled={isLoading}>
+ {isLoading ? 'PDF 생성 중...' : 'PDF 생성하기'}
+ </Button>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ <div className="border rounded-lg p-4 bg-green-50">
+ <div className="flex items-center gap-2">
+ <CheckCircle className="h-4 w-4 text-green-600" />
+ <span className="font-medium text-green-900">PDF 생성 완료</span>
+ </div>
+ </div>
+
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center justify-between mb-4">
+ <h4 className="font-medium">생성된 PDF</h4>
+ <div className="flex gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={downloadPdf}
+ disabled={isLoading}
+ >
+ <Download className="h-4 w-4 mr-2" />
+ 다운로드
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={openPdfPreview}
+ disabled={isLoading}
+ >
+ <Eye className="h-4 w-4 mr-2" />
+ 미리보기
+ </Button>
+ </div>
+ </div>
+
+ {/* PDF 미리보기 영역 */}
+ <div className="border rounded-lg h-96 bg-gray-50 relative" data-pdf-container>
+ {isPdfPreviewVisible ? (
+ <>
+ <div className="absolute top-2 right-2 z-10">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={closePdfPreview}
+ className="bg-white/90 hover:bg-white"
+ >
+ ✕ 닫기
+ </Button>
+ </div>
+ <div id="pdf-preview-container" className="w-full h-full" />
+ </>
+ ) : (
+ <div className="flex items-center justify-center h-full">
+ <div className="text-center text-muted-foreground">
+ <FileText className="h-12 w-12 mx-auto mb-2" />
+ <p>미리보기 버튼을 클릭하여 PDF를 확인하세요</p>
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ <div className="flex justify-between">
+ <Button variant="outline" onClick={() => setCurrentStep(2)}>
+ 이전 단계
+ </Button>
+ <Button
+ onClick={handleFinalSubmit}
+ disabled={!generatedPdfUrl || isLoading}
+ className="bg-green-600 hover:bg-green-700"
+ >
+ <Send className="h-4 w-4 mr-2" />
+ {isLoading ? '전송 중...' : '최종 전송'}
+ </Button>
+ </div>
+ </TabsContent>
+ </Tabs>
+ </DialogContent>
+
+ {/* 결재 미리보기 Dialog */}
+ {session?.user && session.user.epId && contractSummary && (
+ <ApprovalPreviewDialog
+ open={approvalDialogOpen}
+ onOpenChange={(open) => {
+ setApprovalDialogOpen(open);
+ if (!open) {
+ setApprovalVariables({});
+ setSavedPdfPath(null);
+ setSavedBasicContractPdfs([]);
+ }
+ }}
+ templateName="일반계약 결재"
+ variables={approvalVariables}
+ title={`계약 체결 진행 품의 요청서 - ${contractSummary.basicInfo?.contractNumber || contractId}`}
+ currentUser={{
+ id: Number(session.user.id),
+ epId: session.user.epId,
+ name: session.user.name || undefined,
+ email: session.user.email || undefined,
+ }}
+ onConfirm={handleApprovalSubmit}
+ enableAttachments={false}
+ />
+ )}
+ </Dialog>
)} \ No newline at end of file
diff --git a/lib/general-contracts/detail/general-contract-basic-info.tsx b/lib/general-contracts/detail/general-contract-basic-info.tsx
index b0378912..d7533d2e 100644
--- a/lib/general-contracts/detail/general-contract-basic-info.tsx
+++ b/lib/general-contracts/detail/general-contract-basic-info.tsx
@@ -8,7 +8,21 @@ import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
-import { Save, LoaderIcon } from 'lucide-react'
+import { Save, LoaderIcon, Check, ChevronsUpDown } from 'lucide-react'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover'
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from '@/components/ui/command'
+import { cn } from '@/lib/utils'
import { updateContractBasicInfo, getContractBasicInfo } from '../service'
import { toast } from 'sonner'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
@@ -140,19 +154,28 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
// paymentDelivery에서 퍼센트와 타입 분리
const paymentDeliveryValue = contractData?.paymentDelivery || ''
+ console.log(paymentDeliveryValue,"paymentDeliveryValue")
let paymentDeliveryType = ''
let paymentDeliveryPercentValue = ''
- if (paymentDeliveryValue.includes('%')) {
+ // "60일 이내" 또는 "추가조건"은 그대로 사용
+ if (paymentDeliveryValue === '납품완료일로부터 60일 이내 지급' || paymentDeliveryValue === '추가조건') {
+ paymentDeliveryType = paymentDeliveryValue
+ } else if (paymentDeliveryValue.includes('%')) {
+ // 퍼센트가 포함된 경우 (예: "10% L/C")
const match = paymentDeliveryValue.match(/(\d+)%\s*(.+)/)
if (match) {
paymentDeliveryPercentValue = match[1]
paymentDeliveryType = match[2]
+ } else {
+ paymentDeliveryType = paymentDeliveryValue
}
} else {
+ // 일반 지급조건 코드 (예: "P008")
paymentDeliveryType = paymentDeliveryValue
}
-
+ console.log(paymentDeliveryType,"paymentDeliveryType")
+ console.log(paymentDeliveryPercentValue,"paymentDeliveryPercentValue")
setPaymentDeliveryPercent(paymentDeliveryPercentValue)
// 합의계약(AD, AW)인 경우 인도조건 기본값 설정
@@ -309,6 +332,7 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
loadShippingPlaces();
loadDestinationPlaces();
}, [loadPaymentTerms, loadIncoterms, loadShippingPlaces, loadDestinationPlaces]);
+
const handleSaveContractInfo = async () => {
if (!userId) {
toast.error('사용자 정보를 찾을 수 없습니다.')
@@ -342,12 +366,29 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
return
}
- // paymentDelivery와 paymentDeliveryPercent 합쳐서 저장
+ // paymentDelivery 저장 로직
+ // 1. "60일 이내" 또는 "추가조건"은 그대로 저장
+ // 2. L/C 또는 T/T이고 퍼센트가 있으면 "퍼센트% 코드" 형식으로 저장
+ // 3. 그 외의 경우는 그대로 저장
+ let paymentDeliveryToSave = formData.paymentDelivery
+
+ if (
+ formData.paymentDelivery !== '납품완료일로부터 60일 이내 지급' &&
+ formData.paymentDelivery !== '추가조건' &&
+ (formData.paymentDelivery === 'L/C' || formData.paymentDelivery === 'T/T') &&
+ paymentDeliveryPercent
+ ) {
+ paymentDeliveryToSave = `${paymentDeliveryPercent}% ${formData.paymentDelivery}`
+ }
+ console.log(paymentDeliveryToSave,"paymentDeliveryToSave")
+
const dataToSave = {
...formData,
- paymentDelivery: (formData.paymentDelivery === 'L/C' || formData.paymentDelivery === 'T/T') && paymentDeliveryPercent
- ? `${paymentDeliveryPercent}% ${formData.paymentDelivery}`
- : formData.paymentDelivery
+ paymentDelivery: paymentDeliveryToSave,
+ // 추가조건 선택 시에만 추가 텍스트 저장, 그 외에는 빈 문자열 또는 undefined
+ paymentDeliveryAdditionalText: formData.paymentDelivery === '추가조건'
+ ? (formData.paymentDeliveryAdditionalText || '')
+ : ''
}
await updateContractBasicInfo(contractId, dataToSave, userId as number)
@@ -1026,20 +1067,100 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
<div className="space-y-2">
<div className="space-y-1">
<Label htmlFor="paymentDelivery" className="text-xs">지급조건 *</Label>
- <Select value={formData.paymentDelivery} onValueChange={(value) => setFormData(prev => ({ ...prev, paymentDelivery: value }))}>
- <SelectTrigger className={`h-8 text-xs ${errors.paymentDelivery ? 'border-red-500' : ''}`}>
- <SelectValue placeholder="선택" />
- </SelectTrigger>
- <SelectContent>
- {paymentTermsOptions.map((term) => (
- <SelectItem key={term.code} value={term.code} className="text-xs">
- {term.code}
- </SelectItem>
- ))}
- <SelectItem value="납품완료일로부터 60일 이내 지급" className="text-xs">60일 이내</SelectItem>
- <SelectItem value="추가조건" className="text-xs">추가조건</SelectItem>
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between h-8 text-xs",
+ !formData.paymentDelivery && "text-muted-foreground",
+ errors.paymentDelivery && "border-red-500"
+ )}
+ >
+ {formData.paymentDelivery
+ ? (() => {
+ // 1. paymentTermsOptions에서 찾기
+ const foundOption = paymentTermsOptions.find((option) => option.code === formData.paymentDelivery)
+ if (foundOption) {
+ return `${foundOption.code} ${foundOption.description ? `(${foundOption.description})` : ''}`
+ }
+ // 2. 특수 케이스 처리
+ if (formData.paymentDelivery === '납품완료일로부터 60일 이내 지급') {
+ return '60일 이내'
+ }
+ if (formData.paymentDelivery === '추가조건') {
+ return '추가조건'
+ }
+ // 3. 그 외의 경우 원본 값 표시 (로드된 값이지만 옵션에 없는 경우)
+ return formData.paymentDelivery
+ })()
+ : "지급조건 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="지급조건 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {paymentTermsOptions.map((option) => (
+ <CommandItem
+ key={option.code}
+ value={`${option.code} ${option.description || ''}`}
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, paymentDelivery: option.code }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ option.code === formData.paymentDelivery
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {option.code} {option.description && `(${option.description})`}
+ </CommandItem>
+ ))}
+ <CommandItem
+ value="납품완료일로부터 60일 이내 지급"
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, paymentDelivery: '납품완료일로부터 60일 이내 지급' }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ formData.paymentDelivery === '납품완료일로부터 60일 이내 지급'
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ 60일 이내
+ </CommandItem>
+ <CommandItem
+ value="추가조건"
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, paymentDelivery: '추가조건' }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ formData.paymentDelivery === '추가조건'
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ 추가조건
+ </CommandItem>
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
{formData.paymentDelivery === '추가조건' && (
<Input
type="text"
@@ -1152,53 +1273,59 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
</div>
</div>
- {/* 지불조건 -> 세금조건 (지불조건 삭제됨) */}
+ {/*세금조건*/}
<div className="space-y-2">
<Label className="text-sm font-medium">세금조건</Label>
<div className="space-y-2">
- {/* 지불조건 필드 삭제됨
- <div className="space-y-1">
- <Label htmlFor="paymentTerm" className="text-xs">지불조건 *</Label>
- <Select
- value={formData.paymentTerm}
- onValueChange={(value) => setFormData(prev => ({ ...prev, paymentTerm: value }))}
- >
- <SelectTrigger className={`h-8 text-xs ${errors.paymentTerm ? 'border-red-500' : ''}`}>
- <SelectValue placeholder="선택" />
- </SelectTrigger>
- <SelectContent>
- {paymentTermsOptions.length > 0 ? (
- paymentTermsOptions.map((option) => (
- <SelectItem key={option.code} value={option.code} className="text-xs">
- {option.code}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled className="text-xs">
- 로딩중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
- </div>
- */}
<div className="space-y-1">
<Label htmlFor="taxType" className="text-xs">세금조건 *</Label>
- <Select
- value={formData.taxType}
- onValueChange={(value) => setFormData(prev => ({ ...prev, taxType: value }))}
- >
- <SelectTrigger className={`h-8 text-xs ${errors.taxType ? 'border-red-500' : ''}`}>
- <SelectValue placeholder="선택" />
- </SelectTrigger>
- <SelectContent>
- {TAX_CONDITIONS.map((condition) => (
- <SelectItem key={condition.code} value={condition.code} className="text-xs">
- {condition.name}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between h-8 text-xs",
+ !formData.taxType && "text-muted-foreground",
+ errors.taxType && "border-red-500"
+ )}
+ >
+ {formData.taxType
+ ? TAX_CONDITIONS.find((condition) => condition.code === formData.taxType)?.name
+ : "세금조건 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="세금조건 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {TAX_CONDITIONS.map((condition) => (
+ <CommandItem
+ key={condition.code}
+ value={`${condition.code} ${condition.name}`}
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, taxType: condition.code }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ condition.code === formData.taxType
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {condition.name}
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
</div>
</div>
</div>
@@ -1266,79 +1393,178 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
{/* 인도조건 */}
<div className="space-y-2">
<Label htmlFor="deliveryTerm" className="text-xs">인도조건</Label>
- <Select
- value={formData.deliveryTerm}
- onValueChange={(value) => setFormData(prev => ({ ...prev, deliveryTerm: value }))}
- >
- <SelectTrigger className="h-8 text-xs">
- <SelectValue placeholder="선택" />
- </SelectTrigger>
- <SelectContent>
- {incotermsOptions.length > 0 ? (
- incotermsOptions.map((option) => (
- <SelectItem key={option.code} value={option.code} className="text-xs">
- {option.code}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled className="text-xs">
- 로딩중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between h-8 text-xs",
+ !formData.deliveryTerm && "text-muted-foreground"
+ )}
+ >
+ {formData.deliveryTerm
+ ? incotermsOptions.find((option) => option.code === formData.deliveryTerm)
+ ? `${incotermsOptions.find((option) => option.code === formData.deliveryTerm)?.code} ${incotermsOptions.find((option) => option.code === formData.deliveryTerm)?.description ? `(${incotermsOptions.find((option) => option.code === formData.deliveryTerm)?.description})` : ''}`
+ : formData.deliveryTerm
+ : "인코텀즈 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="인코텀즈 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {incotermsOptions.length > 0 ? (
+ incotermsOptions.map((option) => (
+ <CommandItem
+ key={option.code}
+ value={`${option.code} ${option.description || ''}`}
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, deliveryTerm: option.code }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ option.code === formData.deliveryTerm
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {option.code} {option.description && `(${option.description})`}
+ </CommandItem>
+ ))
+ ) : (
+ <CommandItem value="loading" disabled>
+ 로딩중...
+ </CommandItem>
+ )}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
</div>
{/* 선적지 */}
<div className="space-y-2">
<Label htmlFor="shippingLocation" className="text-xs">선적지</Label>
- <Select
- value={formData.shippingLocation}
- onValueChange={(value) => setFormData(prev => ({ ...prev, shippingLocation: value }))}
- >
- <SelectTrigger className="h-8 text-xs">
- <SelectValue placeholder="선택" />
- </SelectTrigger>
- <SelectContent>
- {shippingPlaces.length > 0 ? (
- shippingPlaces.map((place) => (
- <SelectItem key={place.code} value={place.code} className="text-xs">
- {place.code}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled className="text-xs">
- 로딩중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between h-8 text-xs",
+ !formData.shippingLocation && "text-muted-foreground"
+ )}
+ >
+ {formData.shippingLocation
+ ? shippingPlaces.find((place) => place.code === formData.shippingLocation)
+ ? `${shippingPlaces.find((place) => place.code === formData.shippingLocation)?.code} ${shippingPlaces.find((place) => place.code === formData.shippingLocation)?.description ? `(${shippingPlaces.find((place) => place.code === formData.shippingLocation)?.description})` : ''}`
+ : formData.shippingLocation
+ : "선적지 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="선적지 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {shippingPlaces.length > 0 ? (
+ shippingPlaces.map((place) => (
+ <CommandItem
+ key={place.code}
+ value={`${place.code} ${place.description || ''}`}
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, shippingLocation: place.code }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ place.code === formData.shippingLocation
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {place.code} {place.description && `(${place.description})`}
+ </CommandItem>
+ ))
+ ) : (
+ <CommandItem value="loading" disabled>
+ 로딩중...
+ </CommandItem>
+ )}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
</div>
{/* 하역지 */}
<div className="space-y-2">
<Label htmlFor="dischargeLocation" className="text-xs">하역지</Label>
- <Select
- value={formData.dischargeLocation}
- onValueChange={(value) => setFormData(prev => ({ ...prev, dischargeLocation: value }))}
- >
- <SelectTrigger className="h-8 text-xs">
- <SelectValue placeholder="선택" />
- </SelectTrigger>
- <SelectContent>
- {destinationPlaces.length > 0 ? (
- destinationPlaces.map((place) => (
- <SelectItem key={place.code} value={place.code} className="text-xs">
- {place.code}
- </SelectItem>
- ))
- ) : (
- <SelectItem value="loading" disabled className="text-xs">
- 로딩중...
- </SelectItem>
- )}
- </SelectContent>
- </Select>
+ <Popover>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between h-8 text-xs",
+ !formData.dischargeLocation && "text-muted-foreground"
+ )}
+ >
+ {formData.dischargeLocation
+ ? destinationPlaces.find((place) => place.code === formData.dischargeLocation)
+ ? `${destinationPlaces.find((place) => place.code === formData.dischargeLocation)?.code} ${destinationPlaces.find((place) => place.code === formData.dischargeLocation)?.description ? `(${destinationPlaces.find((place) => place.code === formData.dischargeLocation)?.description})` : ''}`
+ : formData.dischargeLocation
+ : "하역지 선택"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput placeholder="하역지 검색..." />
+ <CommandList>
+ <CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
+ <CommandGroup>
+ {destinationPlaces.length > 0 ? (
+ destinationPlaces.map((place) => (
+ <CommandItem
+ key={place.code}
+ value={`${place.code} ${place.description || ''}`}
+ onSelect={() => {
+ setFormData(prev => ({ ...prev, dischargeLocation: place.code }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ place.code === formData.dischargeLocation
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {place.code} {place.description && `(${place.description})`}
+ </CommandItem>
+ ))
+ ) : (
+ <CommandItem value="loading" disabled>
+ 로딩중...
+ </CommandItem>
+ )}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
</div>
{/* 계약납기일 */}
diff --git a/lib/general-contracts/detail/general-contract-items-table.tsx b/lib/general-contracts/detail/general-contract-items-table.tsx
index 15e5c926..be174417 100644
--- a/lib/general-contracts/detail/general-contract-items-table.tsx
+++ b/lib/general-contracts/detail/general-contract-items-table.tsx
@@ -30,6 +30,9 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from
import { ProjectSelector } from '@/components/ProjectSelector'
import { MaterialGroupSelectorDialogSingle } from '@/components/common/material/material-group-selector-dialog-single'
import { MaterialSearchItem } from '@/lib/material/material-group-service'
+import { ProcurementItemSelectorDialogSingle } from '@/components/common/selectors/procurement-item/procurement-item-selector-dialog-single'
+import { ProcurementSearchItem } from '@/components/common/selectors/procurement-item/procurement-item-service'
+import { cn } from '@/lib/utils'
interface ContractItem {
id?: number
@@ -41,12 +44,12 @@ interface ContractItem {
materialGroupCode?: string
materialGroupDescription?: string
specification: string
- quantity: number
+ quantity: number | string // number | string으로 변경하여 입력 중 포맷팅 지원
quantityUnit: string
- totalWeight: number
+ totalWeight: number | string // number | string으로 변경하여 입력 중 포맷팅 지원
weightUnit: string
contractDeliveryDate: string
- contractUnitPrice: number
+ contractUnitPrice: number | string // number | string으로 변경하여 입력 중 포맷팅 지원
contractAmount: number
contractCurrency: string
isSelected?: boolean
@@ -103,6 +106,34 @@ export function ContractItemsTable({
contractUnitPrice: ''
})
+ // 천단위 콤마 포맷팅 헬퍼 함수들
+ const formatNumberWithCommas = (value: string | number | null | undefined): string => {
+ if (value === null || value === undefined || value === '') return ''
+ const str = value.toString()
+ const parts = str.split('.')
+ const integerPart = parts[0].replace(/,/g, '')
+
+ // 정수부가 비어있거나 '-' 만 있는 경우 처리
+ if (integerPart === '' || integerPart === '-') {
+ return str
+ }
+
+ const num = parseFloat(integerPart)
+ if (isNaN(num)) return str
+
+ const formattedInt = num.toLocaleString()
+
+ if (parts.length > 1) {
+ return `${formattedInt}.${parts[1]}`
+ }
+
+ return formattedInt
+ }
+
+ const parseNumberFromCommas = (value: string): string => {
+ return value.replace(/,/g, '')
+ }
+
// 초기 데이터 로드
React.useEffect(() => {
const loadItems = async () => {
@@ -123,6 +154,8 @@ export function ContractItemsTable({
}
}
+ // number 타입을 string으로 변환하지 않고 일단 그대로 둠 (렌더링 시 포맷팅)
+ // 단, 입력 중 편의를 위해 string이 들어올 수 있으므로 ContractItem 타입 변경함
return {
id: item.id,
projectId: item.projectId || null,
@@ -172,11 +205,20 @@ export function ContractItemsTable({
// validation 체크
const errors: string[] = []
- for (let index = 0; index < localItems.length; index++) {
- const item = localItems[index]
- if (!item.itemCode) errors.push(`${index + 1}번째 품목의 품목코드`)
+ // 저장 시 number로 변환된 데이터 준비
+ const itemsToSave = localItems.map(item => ({
+ ...item,
+ quantity: parseFloat(item.quantity.toString().replace(/,/g, '')) || 0,
+ totalWeight: parseFloat(item.totalWeight.toString().replace(/,/g, '')) || 0,
+ contractUnitPrice: parseFloat(item.contractUnitPrice.toString().replace(/,/g, '')) || 0,
+ contractAmount: parseFloat(item.contractAmount.toString().replace(/,/g, '')) || 0,
+ }));
+
+ for (let index = 0; index < itemsToSave.length; index++) {
+ const item = itemsToSave[index]
+ // if (!item.itemCode) errors.push(`${index + 1}번째 품목의 품목코드`)
if (!item.itemInfo) errors.push(`${index + 1}번째 품목의 Item 정보`)
- if (!item.quantity || item.quantity <= 0) errors.push(`${index + 1}번째 품목의 수량`)
+ // if (!item.quantity || item.quantity <= 0) errors.push(`${index + 1}번째 품목의 수량`)
if (!item.contractUnitPrice || item.contractUnitPrice <= 0) errors.push(`${index + 1}번째 품목의 단가`)
if (!item.contractDeliveryDate) errors.push(`${index + 1}번째 품목의 납기일`)
}
@@ -186,7 +228,7 @@ export function ContractItemsTable({
return
}
- await updateContractItems(contractId, localItems as any)
+ await updateContractItems(contractId, itemsToSave as any)
toast.success('품목정보가 저장되었습니다.')
} catch (error) {
console.error('Error saving contract items:', error)
@@ -197,9 +239,18 @@ export function ContractItemsTable({
}
// 총 금액 계산
- 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 totalAmount = localItems.reduce((sum, item) => {
+ const amount = parseFloat(item.contractAmount.toString().replace(/,/g, '')) || 0
+ return sum + amount
+ }, 0)
+ const totalQuantity = localItems.reduce((sum, item) => {
+ const quantity = parseFloat(item.quantity.toString().replace(/,/g, '')) || 0
+ return sum + quantity
+ }, 0)
+ const totalUnitPrice = localItems.reduce((sum, item) => {
+ const unitPrice = parseFloat(item.contractUnitPrice.toString().replace(/,/g, '')) || 0
+ return sum + unitPrice
+ }, 0)
const amountDifference = availableBudget - totalAmount
const budgetRatio = availableBudget > 0 ? (totalAmount / availableBudget) * 100 : 0
@@ -211,12 +262,14 @@ export function ContractItemsTable({
// 아이템 업데이트
const updateItem = (index: number, field: keyof ContractItem, value: string | number | boolean | undefined) => {
const updatedItems = [...localItems]
- updatedItems[index] = { ...updatedItems[index], [field]: value }
+ const updatedItem = { ...updatedItems[index], [field]: value }
+ updatedItems[index] = updatedItem
// 단가나 수량이 변경되면 금액 자동 계산
if (field === 'contractUnitPrice' || field === 'quantity') {
- const item = updatedItems[index]
- updatedItems[index].contractAmount = item.contractUnitPrice * item.quantity
+ const quantity = parseFloat(updatedItem.quantity.toString().replace(/,/g, '')) || 0
+ const unitPrice = parseFloat(updatedItem.contractUnitPrice.toString().replace(/,/g, '')) || 0
+ updatedItem.contractAmount = unitPrice * quantity
}
setLocalItems(updatedItems)
@@ -271,6 +324,34 @@ export function ContractItemsTable({
onItemsChange(updatedItems)
}
+ // 1회성 품목 선택 시 행 추가
+ const handleOneTimeItemSelect = (item: ProcurementSearchItem | null) => {
+ if (!item) return
+
+ const newItem: ContractItem = {
+ projectId: null,
+ itemCode: item.itemCode,
+ itemInfo: item.itemName,
+ materialGroupCode: '',
+ materialGroupDescription: '',
+ specification: item.specification || '',
+ quantity: 0,
+ quantityUnit: item.unit || 'EA',
+ totalWeight: 0,
+ weightUnit: 'KG',
+ contractDeliveryDate: '',
+ contractUnitPrice: 0,
+ contractAmount: 0,
+ contractCurrency: 'KRW',
+ isSelected: false
+ }
+
+ const updatedItems = [...localItems, newItem]
+ setLocalItems(updatedItems)
+ onItemsChange(updatedItems)
+ toast.success('1회성 품목이 추가되었습니다.')
+ }
+
// 일괄입력 적용
const applyBatchInput = () => {
if (localItems.length === 0) {
@@ -296,7 +377,8 @@ export function ContractItemsTable({
if (batchInputData.contractUnitPrice) {
updatedItem.contractUnitPrice = parseFloat(batchInputData.contractUnitPrice) || 0
// 단가가 변경되면 계약금액도 재계산
- updatedItem.contractAmount = updatedItem.contractUnitPrice * updatedItem.quantity
+ const quantity = parseFloat(updatedItem.quantity.toString().replace(/,/g, '')) || 0
+ updatedItem.contractAmount = (parseFloat(batchInputData.contractUnitPrice) || 0) * quantity
}
return updatedItem
@@ -382,6 +464,17 @@ export function ContractItemsTable({
<Plus className="w-4 h-4" />
행 추가
</Button>
+ <ProcurementItemSelectorDialogSingle
+ triggerLabel="1회성 품목 추가"
+ triggerVariant="outline"
+ triggerSize="sm"
+ selectedProcurementItem={null}
+ onProcurementItemSelect={handleOneTimeItemSelect}
+ title="1회성 품목 선택"
+ description="추가할 1회성 품목을 선택해주세요."
+ showConfirmButtons={false}
+ disabled={!isEnabled || readOnly}
+ />
<Dialog open={showBatchInputDialog} onOpenChange={setShowBatchInputDialog}>
<DialogTrigger asChild>
<Button
@@ -671,14 +764,23 @@ export function ContractItemsTable({
)}
</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)}
+ type="text"
+ value={formatNumberWithCommas(item.quantity)}
+ onChange={(e) => {
+ const val = parseNumberFromCommas(e.target.value)
+ if (val === '' || /^-?\d*\.?\d*$/.test(val)) {
+ updateItem(index, 'quantity', val)
+ }
+ }}
className="h-8 text-sm text-right"
placeholder="0"
- disabled={!isEnabled}
+ disabled={!isEnabled || isQuantityDisabled}
/>
+ )}
</TableCell>
<TableCell className="px-3 py-3">
{readOnly ? (
@@ -707,9 +809,14 @@ export function ContractItemsTable({
<span className="text-sm text-right">{item.totalWeight.toLocaleString()}</span>
) : (
<Input
- type="number"
- value={item.totalWeight}
- onChange={(e) => updateItem(index, 'totalWeight', parseFloat(e.target.value) || 0)}
+ type="text"
+ value={formatNumberWithCommas(item.totalWeight)}
+ onChange={(e) => {
+ const val = parseNumberFromCommas(e.target.value)
+ if (val === '' || /^-?\d*\.?\d*$/.test(val)) {
+ updateItem(index, 'totalWeight', val)
+ }
+ }}
className="h-8 text-sm text-right"
placeholder="0"
disabled={!isEnabled || isQuantityDisabled}
@@ -756,9 +863,14 @@ export function ContractItemsTable({
<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)}
+ type="text"
+ value={formatNumberWithCommas(item.contractUnitPrice)}
+ onChange={(e) => {
+ const val = parseNumberFromCommas(e.target.value)
+ if (val === '' || /^-?\d*\.?\d*$/.test(val)) {
+ updateItem(index, 'contractUnitPrice', val)
+ }
+ }}
className="h-8 text-sm text-right"
placeholder="0"
disabled={!isEnabled}
diff --git a/lib/general-contracts/handlers.ts b/lib/general-contracts/handlers.ts
new file mode 100644
index 00000000..029fb9cd
--- /dev/null
+++ b/lib/general-contracts/handlers.ts
@@ -0,0 +1,157 @@
+/**
+ * 일반계약 관련 결재 액션 핸들러
+ *
+ * 실제 비즈니스 로직만 포함 (결재 로직은 approval-workflow에서 처리)
+ */
+
+'use server';
+
+import { sendContractApprovalRequest } from './service';
+import { debugLog, debugError, debugSuccess } from '@/lib/debug-utils';
+import db from '@/db/db';
+import { eq } from 'drizzle-orm';
+import { generalContracts } from '@/db/schema/generalContract';
+
+interface ContractSummary {
+ basicInfo: Record<string, unknown>;
+ items: Record<string, unknown>[];
+ subcontractChecklist: Record<string, unknown> | null;
+ storageInfo?: Record<string, unknown>[];
+}
+
+/**
+ * 일반계약 승인 핸들러 (결재 승인 후 계약승인요청 전송 실행)
+ *
+ * 결재 승인 후 자동으로 계약승인요청을 전송함
+ * 이 함수는 직접 호출하지 않고, 결재 워크플로우에서 자동으로 호출됨
+ *
+ * @param payload - withApproval()에서 전달한 actionPayload
+ */
+export async function approveContractInternal(payload: {
+ contractId: number;
+ contractSummary: ContractSummary;
+ currentUser?: {
+ id: string | number;
+ name?: string | null;
+ email?: string | null;
+ nonsapUserId?: string | null;
+ };
+}) {
+ debugLog('[ContractApprovalHandler] 일반계약 승인 핸들러 시작', {
+ contractId: payload.contractId,
+ contractNumber: payload.contractSummary.basicInfo?.contractNumber,
+ contractName: payload.contractSummary.basicInfo?.name,
+ hasCurrentUser: !!payload.currentUser,
+ });
+
+ try {
+ // 1. 계약 정보 확인
+ const [contract] = await db
+ .select()
+ .from(generalContracts)
+ .where(eq(generalContracts.id, payload.contractId))
+ .limit(1);
+
+ if (!contract) {
+ throw new Error('계약을 찾을 수 없습니다.');
+ }
+
+ // 2. 계약승인요청 전송
+ debugLog('[ContractApprovalHandler] sendContractApprovalRequest 호출');
+
+ // PDF 경로에서 PDF 버퍼 읽기
+ const pdfPath = (payload.contractSummary as any).pdfPath;
+ if (!pdfPath) {
+ throw new Error('PDF 경로가 없습니다.');
+ }
+
+ // PDF 파일 읽기
+ const fs = await import('fs/promises');
+ const path = await import('path');
+
+ const nasPath = process.env.NAS_PATH || "/evcp_nas";
+ const isProduction = process.env.NODE_ENV === "production";
+ const baseDir = isProduction ? nasPath : path.join(process.cwd(), "public");
+
+ // publicPath에서 실제 파일 경로로 변환
+ const actualPath = pdfPath.startsWith('/')
+ ? path.join(baseDir, pdfPath)
+ : path.join(baseDir, 'generalContracts', pdfPath);
+
+ let pdfBuffer: Uint8Array;
+ try {
+ const fileBuffer = await fs.readFile(actualPath);
+ pdfBuffer = new Uint8Array(fileBuffer);
+ } catch (error) {
+ debugError('[ContractApprovalHandler] PDF 파일 읽기 실패', error);
+ throw new Error('PDF 파일을 읽을 수 없습니다.');
+ }
+
+ // 기본계약서는 클라이언트에서 이미 생성되었을 것으로 가정
+ const generatedBasicContracts: Array<{ key: string; buffer: number[]; fileName: string }> =
+ (payload.contractSummary as any).basicContractPdfs || [];
+
+ const userId = payload.currentUser?.id
+ ? String(payload.currentUser.id)
+ : String(contract.registeredById);
+
+ const result = await sendContractApprovalRequest(
+ payload.contractSummary,
+ pdfBuffer,
+ 'contractDocument',
+ userId,
+ generatedBasicContracts
+ );
+
+ if (!result.success) {
+ debugError('[ContractApprovalHandler] 계약승인요청 전송 실패', result.error);
+
+ // 전송 실패 시 상태를 원래대로 되돌림
+ await db.update(generalContracts)
+ .set({
+ status: 'Draft',
+ lastUpdatedAt: new Date()
+ })
+ .where(eq(generalContracts.id, payload.contractId));
+
+ throw new Error(result.error || '계약승인요청 전송에 실패했습니다.');
+ }
+
+ // 3. 전송 성공 시 상태를 'Contract Accept Request'로 변경
+ debugLog('[ContractApprovalHandler] 계약승인요청 전송 성공, 상태를 Contract Accept Request로 변경');
+ await db.update(generalContracts)
+ .set({
+ status: 'Contract Accept Request',
+ lastUpdatedAt: new Date()
+ })
+ .where(eq(generalContracts.id, payload.contractId));
+
+ debugSuccess('[ContractApprovalHandler] 일반계약 승인 완료', {
+ contractId: payload.contractId,
+ result: result
+ });
+
+ return {
+ success: true,
+ message: '계약승인요청이 전송되었습니다.',
+ result: result
+ };
+ } catch (error) {
+ debugError('[ContractApprovalHandler] 일반계약 승인 중 에러', error);
+
+ // 에러 발생 시 상태를 원래대로 되돌림
+ try {
+ await db.update(generalContracts)
+ .set({
+ status: 'Draft',
+ lastUpdatedAt: new Date()
+ })
+ .where(eq(generalContracts.id, payload.contractId));
+ } catch (updateError) {
+ debugError('[ContractApprovalHandler] 상태 업데이트 실패', updateError);
+ }
+
+ throw error;
+ }
+}
+
diff --git a/lib/general-contracts/service.ts b/lib/general-contracts/service.ts
index 3f3dc8de..b803d2d4 100644
--- a/lib/general-contracts/service.ts
+++ b/lib/general-contracts/service.ts
@@ -504,7 +504,7 @@ export async function updateContractBasicInfo(id: number, data: Record<string, u
linkedBidNumber,
notes,
paymentBeforeDelivery, // JSON 필드
- paymentDelivery: convertToNumberOrNull(paymentDelivery),
+ paymentDelivery,
paymentAfterDelivery, // JSON 필드
paymentTerm,
taxType,
@@ -525,7 +525,7 @@ export async function updateContractBasicInfo(id: number, data: Record<string, u
lastUpdatedAt: new Date(),
lastUpdatedById: userId,
}
-
+ console.log(updateData.paymentDelivery,"updateData.paymentDelivery")
// DB에 업데이트 실행
const [updatedContract] = await db
.update(generalContracts)
@@ -533,14 +533,9 @@ export async function updateContractBasicInfo(id: number, data: Record<string, u
.where(eq(generalContracts.id, id))
.returning()
- // 계약명 I/F 로직 (39번 화면으로의 I/F)
- // TODO: 39번 화면의 정확한 API 엔드포인트나 함수명 확인 필요
- // if (data.name) {
- // await syncContractNameToScreen39(id, data.name as string)
- // }
revalidatePath('/general-contracts')
- revalidatePath(`/general-contracts/detail/${id}`)
+ revalidatePath(`/general-contracts/${id}`)
return updatedContract
} catch (error) {
console.error('Error updating contract basic info:', error)
@@ -1391,7 +1386,7 @@ export async function sendContractApprovalRequest(
signerStatus: 'PENDING',
})
- // 사외업체 야드투입이 'Y'인 경우 안전담당자 자동 지정
+ // 사외업체 야드투입이 'Y'인 경우 안전담당자 자동 지정 - 수정필요 12/05
if (contractSummary.basicInfo?.externalYardEntry === 'Y') {
try {
// 안전담당자 역할을 가진 사용자 조회 (역할명에 '안전' 또는 'safety' 포함)