diff options
Diffstat (limited to 'lib')
4 files changed, 206 insertions, 1168 deletions
diff --git a/lib/basic-contract/service-vendor-info.ts b/lib/basic-contract/service-vendor-info.ts new file mode 100644 index 00000000..2fe2d512 --- /dev/null +++ b/lib/basic-contract/service-vendor-info.ts @@ -0,0 +1,35 @@ +"use server"; + +import db from "@/db/db"; +import { vendors } from "@/db/schema"; +import { eq } from "drizzle-orm"; + +/** + * 벤더 ID로 벤더 정보 조회 (사업자번호 등) + */ +export async function getVendorInfo(vendorId: number) { + try { + const result = await db + .select({ + id: vendors.id, + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + taxId: vendors.taxId, // 사업자등록번호 + corporateRegistrationNumber: vendors.corporateRegistrationNumber, // 법인등록번호 + country: vendors.country, // 국가 코드 (KR: 내자, 그외: 외자) + }) + .from(vendors) + .where(eq(vendors.id, vendorId)) + .limit(1); + + if (!result || result.length === 0) { + return { success: false, error: "Vendor not found" }; + } + + return { success: true, data: result[0] }; + } catch (error) { + console.error("Error fetching vendor info:", error); + return { success: false, error: "Failed to fetch vendor info" }; + } +} + diff --git a/lib/basic-contract/vendor-table/basic-contract-sign-dialog-test.tsx b/lib/basic-contract/vendor-table/basic-contract-sign-dialog-test.tsx deleted file mode 100644 index ace34454..00000000 --- a/lib/basic-contract/vendor-table/basic-contract-sign-dialog-test.tsx +++ /dev/null @@ -1,1166 +0,0 @@ -"use client"; - -import * as React from "react"; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { Button } from "@/components/ui/button"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { formatDate } from "@/lib/utils"; -import { useToast } from "@/hooks/use-toast"; -import { cn } from "@/lib/utils"; -import type { WebViewerInstance } from "@pdftron/webviewer"; -import type { BasicContractView } from "@/db/schema"; -import { - Upload, - FileSignature, - CheckCircle2, - Search, - Clock, - FileText, - User, - AlertCircle, - Calendar, - Loader2, - ArrowRight, - Trophy, - Target, - Shield -} from "lucide-react"; -import { Input } from "@/components/ui/input"; -import { Badge } from "@/components/ui/badge"; -import { Separator } from "@/components/ui/separator"; -import { useRouter } from "next/navigation" -import { BasicContractSignViewer } from "../viewer/basic-contract-sign-viewer"; -import { getVendorAttachments, processBuyerSignatureAction } from "../service"; - -// 계약서 상태 타입 정의 -interface ContractStatus { - id: number; - status: 'pending' | 'completed' | 'error' | 'vendor_signed'; - errorMessage?: string; -} - -interface BasicContractSignDialogProps { - contracts: BasicContractView[]; - onSuccess?: () => void; - hasSelectedRows?: boolean; - mode?: 'vendor' | 'buyer'; - onBuyerSignComplete?: (contractId: number, signedData: ArrayBuffer) => void; - t: (key: string) => string; - // 외부 상태 제어를 위한 새로운 props (선택적) - open?: boolean; - onOpenChange?: (open: boolean) => void; -} - -export function BasicContractSignDialog({ - contracts, - onSuccess, - hasSelectedRows = false, - mode = 'vendor', - onBuyerSignComplete, - t, - // 새로 추가된 props - open: externalOpen, - onOpenChange: externalOnOpenChange -}: BasicContractSignDialogProps) { - const { toast } = useToast(); - - // 내부 상태 (외부 제어가 없을 때 사용) - const [internalOpen, setInternalOpen] = React.useState(false); - const [selectedContract, setSelectedContract] = React.useState<BasicContractView | null>(null); - const [instance, setInstance] = React.useState<null | WebViewerInstance>(null); - const [searchTerm, setSearchTerm] = React.useState(""); - const [isSubmitting, setIsSubmitting] = React.useState(false); - - // 추가된 state들 - const [additionalFiles, setAdditionalFiles] = React.useState<any[]>([]); - const [isLoadingAttachments, setIsLoadingAttachments] = React.useState(false); - - // 계약서 상태 관리 - const [contractStatuses, setContractStatuses] = React.useState<ContractStatus[]>([]); - - // 서명/설문/GTC 코멘트 완료 상태 관리 - const [surveyCompletionStatus, setSurveyCompletionStatus] = React.useState<Record<number, boolean>>({}); - const [signatureStatus, setSignatureStatus] = React.useState<Record<number, boolean>>({}); - const [gtcCommentStatus, setGtcCommentStatus] = React.useState<Record<number, { - hasComments: boolean; - commentCount: number; - reviewStatus?: string; - isComplete?: boolean; - }>>({}); - - const router = useRouter() - - // 실제 사용할 open 상태 (외부 제어가 있으면 외부 상태 사용, 없으면 내부 상태 사용) - const isControlledExternally = externalOpen !== undefined; - const open = isControlledExternally ? externalOpen : internalOpen; - - // 모드에 따른 텍스트 - const isBuyerMode = mode === 'buyer'; - const dialogTitle = isBuyerMode ? "구매자 최종승인 서명" : t("basicContracts.dialog.title"); - const signButtonText = isBuyerMode ? "최종승인 완료" : "서명 완료 및 저장"; - - // 버튼 비활성화 조건 - const isButtonDisabled = !hasSelectedRows || contracts.length === 0; - - // 비활성화 이유 텍스트 - const getDisabledReason = () => { - if (!hasSelectedRows) { - return t("basicContracts.toolbar.selectRows"); - } - if (contracts.length === 0) { - return t("basicContracts.toolbar.noPendingContracts"); - } - return ""; - }; - - // 현재 선택된 계약서의 서명 완료 가능 여부 확인 -const canCompleteCurrentContract = React.useMemo(() => { - if (!selectedContract) return false; - - const contractId = selectedContract.id; - - // 구매자 모드: 기존 로직 유지 - if (isBuyerMode) { - const signatureCompleted = signatureStatus[contractId] === true; - return signatureCompleted; - } - - // 협력업체 모드 + 내자: TrustNet으로 이동만 하면 되므로 버튼은 항상 활성화 - if (isDomesticVendorForContract(selectedContract)) { - return true; - } - - const isComplianceTemplate = selectedContract.templateName?.includes('준법'); - const isGTCTemplate = selectedContract.templateName?.includes('GTC'); - const requiresNegotiationComplete = isComplianceTemplate || isGTCTemplate; - - const surveyCompleted = isComplianceTemplate ? surveyCompletionStatus[contractId] === true : true; - - const negotiationStatus = gtcCommentStatus[contractId]; - const negotiationCleared = requiresNegotiationComplete - ? (!negotiationStatus?.hasComments || negotiationStatus?.isComplete === true) - : true; - - const signatureCompleted = signatureStatus[contractId] === true; - - return surveyCompleted && negotiationCleared && signatureCompleted; -}, [selectedContract, surveyCompletionStatus, signatureStatus, gtcCommentStatus, isBuyerMode]); - - - // 계약서별 상태 초기화 - // Vendor signed 상태의 계약서도 포함하여 초기화 - React.useEffect(() => { - if (contracts.length > 0 && contractStatuses.length === 0) { - setContractStatuses( - contracts.map(contract => { - // 이미 서명된 계약서는 vendor_signed 상태로 초기화 - const isSigned = contract.vendorSignedAt || - contract.status === "COMPLETED" || - contract.status === "VENDOR_SIGNED"; - return { - id: contract.id, - status: isSigned ? ('vendor_signed' as const) : ('pending' as const) - }; - }) - ); - } - }, [contracts, contractStatuses.length]); - - // 완료된 계약서 수 계산 - const completedCount = contractStatuses.filter(status => - status.status === 'completed' || status.status === 'vendor_signed' - ).length; - const totalCount = contracts.length; - const allCompleted = completedCount === totalCount && totalCount > 0; - - // 현재 선택된 계약서의 상태 - const currentContractStatus = selectedContract - ? contractStatuses.find(status => status.id === selectedContract.id) - : null; - - // 내자/외자 판별 (vendors.country 기반, KR 앞 2자리) - const getVendorCountryCode = React.useCallback((contract: BasicContractView | null) => { - if (!contract || !contract.vendorCountry) return null; - return contract.vendorCountry.substring(0, 2).toUpperCase(); - }, []); - - const isDomesticVendorForContract = React.useCallback((contract: BasicContractView | null) => { - if (!contract) return false; - const countryCode = getVendorCountryCode(contract); - return countryCode === "KR"; - }, [getVendorCountryCode]); - - const isDomesticVendor = React.useMemo(() => { - return isDomesticVendorForContract(selectedContract); - }, [selectedContract, isDomesticVendorForContract]); - - // 다음 미완료 계약서 찾기 - const getNextPendingContract = () => { - const pendingStatuses = contractStatuses.filter(status => - status.status === 'pending' || status.status === 'error' - ); - if (pendingStatuses.length === 0) return null; - - const nextPendingId = pendingStatuses[0].id; - return contracts.find(contract => contract.id === nextPendingId) || null; - }; - - // 다이얼로그 열기/닫기 핸들러 (외부 제어 지원) - const handleOpenChange = (isOpen: boolean) => { - if (!isOpen && !allCompleted && completedCount > 0) { - // 완료되지 않은 계약서가 있으면 확인 대화상자 - // const confirmClose = window.confirm( - // `${completedCount}/${totalCount}개 계약서가 완료되었습니다. 정말 나가시겠습니까?` - // ); - const confirmClose = window.confirm( - `정말 나가시겠습니까?` - ); - if (!confirmClose) return; - } - - // 외부 제어가 있으면 외부 콜백 호출, 없으면 내부 상태 업데이트 - if (isControlledExternally && externalOnOpenChange) { - externalOnOpenChange(isOpen); - } else { - setInternalOpen(isOpen); - } - - if (!isOpen) { - // 다이얼로그 닫을 때 상태 초기화 - setSelectedContract(null); - setSearchTerm(""); - setAdditionalFiles([]); - setContractStatuses([]); - setSurveyCompletionStatus({}); - setSignatureStatus({}); - setGtcCommentStatus({}); - // WebViewer 인스턴스 정리 - if (instance) { - try { - instance.UI.dispose(); - } catch (error) { - console.log("WebViewer dispose error:", error); - } - setInstance(null); - } - } - }; - - // 계약서 선택 핸들러 - const handleSelectContract = (contract: BasicContractView) => { - console.log("계약서 선택:", contract.id, contract.templateName); - setSelectedContract(contract); - }; - - // 검색된 계약서 필터링 - const filteredContracts = React.useMemo(() => { - if (!searchTerm.trim()) return contracts; - - const term = searchTerm.toLowerCase(); - return contracts.filter(contract => - (contract.templateName || '').toLowerCase().includes(term) || - (contract.requestedByName || '').toLowerCase().includes(term) - ); - }, [contracts, searchTerm]); - - // 다이얼로그가 열릴 때 첫 번째 미완료 계약서 자동 선택 - React.useEffect(() => { - if (open && contracts.length > 0 && !selectedContract) { - const firstPending = getNextPendingContract(); - if (firstPending) { - setSelectedContract(firstPending); - } else { - setSelectedContract(contracts[0]); - } - } - }, [open, contracts, selectedContract, contractStatuses]); - - // 추가 파일 가져오기 useEffect (구매자 모드에서는 스킵) - React.useEffect(() => { - if (isBuyerMode) { - setAdditionalFiles([]); - return; - } - - const fetchAdditionalFiles = async () => { - if (!selectedContract) { - setAdditionalFiles([]); - return; - } - - // "비밀유지 계약서"인 경우에만 추가 파일 가져오기 - if (selectedContract.templateName === "비밀유지 계약서" && selectedContract.vendorId) { - setIsLoadingAttachments(true); - try { - const result = await getVendorAttachments(selectedContract.vendorId); - if (result.success) { - setAdditionalFiles(result.data); - console.log("추가 파일 로드됨:", result.data); - } else { - console.error("Failed to fetch attachments:", result.error); - setAdditionalFiles([]); - } - } catch (error) { - console.error("Error fetching attachments:", error); - setAdditionalFiles([]); - } finally { - setIsLoadingAttachments(false); - } - } else { - setAdditionalFiles([]); - } - }; - - fetchAdditionalFiles(); - }, [selectedContract, isBuyerMode]); - - // 설문조사 완료 콜백 함수 - const handleSurveyComplete = React.useCallback((contractId: number) => { - console.log(`📋 설문조사 완료: 계약서 ${contractId}`); - setSurveyCompletionStatus(prev => ({ - ...prev, - [contractId]: true - })); - }, []); - - // 서명 완료 콜백 함수 - const handleSignatureComplete = React.useCallback((contractId: number) => { - console.log(`✍️ 서명 완료: 계약서 ${contractId}`); - setSignatureStatus(prev => ({ - ...prev, - [contractId]: true - })); - }, []); - - // GTC 코멘트 상태 변경 콜백 함수 - const handleGtcCommentStatusChange = React.useCallback(( - contractId: number, - hasComments: boolean, - commentCount: number, - reviewStatus?: string, - isComplete?: boolean - ) => { - console.log(`📋 GTC 상태 변경: 계약서 ${contractId}, 코멘트 ${commentCount}개, 상태: ${reviewStatus}, 완료: ${isComplete}`); - setGtcCommentStatus(prev => ({ - ...prev, - [contractId]: { hasComments, commentCount, reviewStatus, isComplete } - })); - }, []); - - // 서명 완료 핸들러 - const completeSign = async () => { - if (!selectedContract) return; - - // 협력업체 모드 + 내자일 때는 TrustNet으로 이동 (기존 PDF 서명 로직 사용 안 함) - if (!isBuyerMode && isDomesticVendor) { - try { - const baseUrl = "https://partners.sevcp.com/trustnet/contract-req.html"; - const origin = typeof window !== "undefined" ? window.location.origin : ""; - const url = `${baseUrl}?contractId=${encodeURIComponent( - String(selectedContract.id) - )}&origin=${encodeURIComponent(origin)}`; - - window.open(url, "_blank", "noopener,noreferrer"); - - toast({ - title: "공동인증서 서명 페이지가 새 창으로 열렸습니다.", - description: "TrustNet 화면에서 전자서명을 완료해주세요.", - }); - } catch (error) { - console.error("TrustNet 서명 페이지 열기 실패:", error); - toast({ - title: "TrustNet 서명 페이지를 여는 중 오류가 발생했습니다.", - variant: "destructive", - }); - } - - return; - } - - if (!instance) return; - - // 서명 완료 가능 여부 재확인 - if (!canCompleteCurrentContract) { - const contractId = selectedContract.id; - - if (isBuyerMode) { - const signatureCompleted = signatureStatus[contractId] === true; - - if (!signatureCompleted) { - toast({ - title: "계약서에 서명을 먼저 완료해주세요.", - description: "문서의 서명 필드에 서명해주세요.", - variant: "destructive" - }); - return; - } - } else { - // 협력업체 모드의 기존 검증 로직 - const isComplianceTemplate = selectedContract.templateName?.includes('준법'); - const isGTCTemplate = selectedContract.templateName?.includes('GTC'); - const surveyCompleted = isComplianceTemplate ? surveyCompletionStatus[contractId] === true : true; - const requiresNegotiationComplete = isComplianceTemplate || isGTCTemplate; - const negotiationStatus = gtcCommentStatus[contractId]; - const negotiationCleared = requiresNegotiationComplete - ? (!negotiationStatus?.hasComments || negotiationStatus?.isComplete === true) - : true; - const signatureCompleted = signatureStatus[contractId] === true; - - if (!surveyCompleted) { - toast({ - title: "준법 설문조사를 먼저 완료해주세요.", - description: "설문조사 탭에서 모든 필수 항목을 완료해주세요.", - variant: "destructive" - }); - return; - } - - if (!negotiationCleared) { - toast({ - title: "코멘트가 있어 서명할 수 없습니다.", - description: "협의 코멘트 탭에서 모든 코멘트를 삭제하거나 협의를 완료해주세요.", - variant: "destructive" - }); - return; - } - - if (!signatureCompleted) { - toast({ - title: "계약서에 서명을 먼저 완료해주세요.", - description: "문서의 서명 필드에 서명해주세요.", - variant: "destructive" - }); - return; - } - } - - return; - } - - setIsSubmitting(true); - try { - const { documentViewer, annotationManager } = instance.Core; - const doc = documentViewer.getDocument(); - const xfdfString = await annotationManager.exportAnnotations(); - - // 폼 필드 데이터 수집 - const fieldManager = annotationManager.getFieldManager(); - const fields = fieldManager.getFields(); - const formData: any = {}; - fields.forEach((field: any) => { - formData[field.name] = field.value; - }); - - const data = await doc.getFileData({ - xfdfString, - downloadType: "pdf", - }); - - if (isBuyerMode) { - // 구매자 모드: 최종승인 처리 - const result = await processBuyerSignatureAction( - selectedContract.id, - data, - selectedContract.signedFileName || `contract_${selectedContract.id}_buyer_signed.pdf` - ); - - if (result.success) { - // 성공시 해당 계약서 상태를 완료로 업데이트 - setContractStatuses(prev => - prev.map(status => - status.id === selectedContract.id - ? { ...status, status: 'completed' as const } - : status - ) - ); - - toast({ - title: "최종승인이 완료되었습니다!", - description: `${selectedContract.templateName} - ${completedCount + 1}/${totalCount}개 완료` - }); - - // 구매자 서명 완료 콜백 호출 - if (onBuyerSignComplete) { - onBuyerSignComplete(selectedContract.id, data); - } - - // 다음 미완료 계약서로 자동 이동 - const nextContract = getNextPendingContract(); - if (nextContract) { - setSelectedContract(nextContract); - } else { - // 모든 계약서 완료시 - toast({ - title: "🎉 모든 계약서 최종승인이 완료되었습니다!", - description: `총 ${totalCount}개 계약서 승인 완료` - }); - } - - router.refresh(); - } else { - // 실패시 에러 상태 업데이트 - setContractStatuses(prev => - prev.map(status => - status.id === selectedContract.id - ? { ...status, status: 'error' as const, errorMessage: result.message } - : status - ) - ); - - toast({ - title: "최종승인 처리 중 오류가 발생했습니다", - description: result.message, - variant: "destructive" - }); - } - } else { - // 협력업체 모드: 기존 로직 - const submitFormData = new FormData(); - submitFormData.append('file', new Blob([data], { type: 'application/pdf' })); - submitFormData.append('tableRowId', selectedContract.id.toString()); - submitFormData.append('templateName', selectedContract.signedFileName || ''); - - // 폼 필드 데이터 추가 - if (Object.keys(formData).length > 0) { - submitFormData.append('formData', JSON.stringify(formData)); - } - - // API 호출 - const response = await fetch('/api/upload/signed-contract', { - method: 'POST', - body: submitFormData, - next: { tags: ["basicContractView-vendor"] }, - }); - - const result = await response.json(); - - if (result.result) { - // 성공시 해당 계약서 상태를 완료로 업데이트 - setContractStatuses(prev => - prev.map(status => - status.id === selectedContract.id - ? { ...status, status: 'completed' as const } - : status - ) - ); - - toast({ - title: "계약서 서명이 완료되었습니다!", - description: `${selectedContract.templateName} - ${completedCount + 1}/${totalCount}개 완료` - }); - - // 다음 미완료 계약서로 자동 이동 - const nextContract = getNextPendingContract(); - if (nextContract) { - setSelectedContract(nextContract); - } else { - // 모든 계약서 완료시 - toast({ - title: "🎉 모든 계약서 서명이 완료되었습니다!", - description: `총 ${totalCount}개 계약서 서명 완료` - }); - } - - router.refresh(); - } else { - // 실패시 에러 상태 업데이트 - setContractStatuses(prev => - prev.map(status => - status.id === selectedContract.id - ? { ...status, status: 'error' as const, errorMessage: result.error } - : status - ) - ); - - toast({ - title: "서명 처리 중 오류가 발생했습니다", - description: result.error, - variant: "destructive" - }); - } - } - } catch (error) { - console.error("서명 완료 중 오류:", error); - - // 에러 상태 업데이트 - setContractStatuses(prev => - prev.map(status => - status.id === selectedContract.id - ? { ...status, status: 'error' as const, errorMessage: '서명 처리 중 오류가 발생했습니다' } - : status - ) - ); - - toast({ - title: "서명 처리 중 오류가 발생했습니다", - variant: "destructive" - }); - } finally { - setIsSubmitting(false); - } - }; - - // 모든 서명 완료 핸들러 - const completeAllSigns = () => { - handleOpenChange(false); - if (onSuccess) { - onSuccess(); - } - const successMessage = isBuyerMode - ? "모든 계약서 최종승인이 완료되었습니다!" - : "모든 계약서 서명이 완료되었습니다!"; - - toast({ - title: successMessage, - description: "계약서 관리 페이지가 새고침됩니다." - }); - }; - - return ( - <> - {/* 서명 버튼 - 외부 제어가 없을 때만 표시 */} - {!isControlledExternally && ( - <Button - variant="outline" - size="sm" - onClick={() => handleOpenChange(true)} - disabled={isButtonDisabled} - className={cn( - "gap-2 transition-all disabled:opacity-50 disabled:cursor-not-allowed", - isBuyerMode - ? "hover:bg-green-50 hover:text-green-600 hover:border-green-200" - : "hover:bg-blue-50 hover:text-blue-600 hover:border-blue-200" - )} - > - {isBuyerMode ? ( - <Shield - className={`size-4 ${isButtonDisabled ? 'text-gray-400' : 'text-green-500'}`} - aria-hidden="true" - /> - ) : ( - <Upload - className={`size-4 ${isButtonDisabled ? 'text-gray-400' : 'text-blue-500'}`} - aria-hidden="true" - /> - )} - <span className="hidden sm:inline flex items-center"> - {isBuyerMode ? "구매자 서명" : t("basicContracts.toolbar.sign")} - {contracts.length > 0 && !isButtonDisabled && ( - <Badge - variant="secondary" - className={cn( - "ml-2", - isBuyerMode - ? "bg-green-100 text-green-700 hover:bg-green-200" - : "bg-blue-100 text-blue-700 hover:bg-blue-200" - )} - > - {contracts.length} - </Badge> - )} - {isButtonDisabled && ( - <span className="ml-2 text-xs text-gray-400"> - ({getDisabledReason()}) - </span> - )} - </span> - </Button> - )} - - {/* 서명 다이얼로그 */} - <Dialog open={open} onOpenChange={handleOpenChange}> - <DialogContent className="max-w-7xl w-[95vw] h-[90vh] p-0 flex flex-col overflow-hidden" style={{ width: '95vw', maxWidth: '95vw' }}> - {/* 고정 헤더 - 진행 상황 표시 */} - <DialogHeader className={cn( - "px-6 py-4 border-b flex-shrink-0", - isBuyerMode - ? "bg-gradient-to-r from-green-50 to-emerald-50" - : "bg-gradient-to-r from-blue-50 to-purple-50" - )}> - <DialogTitle className="text-xl font-bold flex items-center justify-between text-gray-800"> - <div className="flex items-center"> - {isBuyerMode ? ( - <Shield className="mr-2 h-5 w-5 text-green-500" /> - ) : ( - <FileSignature className="mr-2 h-5 w-5 text-blue-500" /> - )} - {dialogTitle} - {/* 진행 상황 표시 */} - {/* <Badge - variant="outline" - className={cn( - "ml-3", - isBuyerMode - ? "bg-green-50 text-green-700 border-green-200" - : "bg-blue-50 text-blue-700 border-blue-200" - )} - > - {completedCount}/{totalCount} 완료 - </Badge> */} - {/* 추가 파일 로딩 표시 */} - {isLoadingAttachments && ( - <Loader2 className="ml-2 h-4 w-4 animate-spin text-blue-500" /> - )} - </div> - - {allCompleted && ( - <Badge variant="default" className="bg-green-100 text-green-700 border-green-200"> - <Trophy className="h-4 w-4 mr-1" /> - 전체 완료! - </Badge> - )} - </DialogTitle> - - {/* 진행률 바 */} - {totalCount > 1 && ( - <div className="mt-3"> - <div className="flex justify-between text-xs text-gray-600 mb-1"> - <span>전체 진행률</span> - <span>{Math.round((completedCount / totalCount) * 100)}%</span> - </div> - <div className="w-full bg-gray-200 rounded-full h-2"> - <div - className={cn( - "h-2 rounded-full transition-all duration-500", - isBuyerMode - ? "bg-gradient-to-r from-green-500 to-emerald-500" - : "bg-gradient-to-r from-blue-500 to-green-500" - )} - style={{ width: `${(completedCount / totalCount) * 100}%` }} - /> - </div> - </div> - )} - </DialogHeader> - - {/* 메인 컨텐츠 영역 - Flexbox 사용 */} - <div className="flex flex-1 min-h-0 overflow-hidden"> - {/* 왼쪽 영역 - 계약서 목록 (고정 너비) */} - <div className="w-80 border-r border-gray-200 bg-gray-50 flex flex-col flex-shrink-0"> - <div className="p-3 border-b flex-shrink-0"> - <div className="relative"> - <div className="absolute inset-y-0 left-2 flex items-center pointer-events-none"> - <Search className="h-4 w-4 text-gray-400" /> - </div> - <Input - placeholder={t("basicContracts.dialog.searchPlaceholder")} - className="bg-white pl-8 text-sm" - value={searchTerm} - onChange={(e) => setSearchTerm(e.target.value)} - /> - </div> - </div> - - <ScrollArea className="flex-1"> - <div className="p-2"> - {filteredContracts.length === 0 ? ( - <div className="flex flex-col items-center justify-center h-32 text-center"> - <FileText className="h-8 w-8 text-gray-300 mb-2" /> - <p className="text-gray-500 text-sm font-medium">{t("basicContracts.dialog.noDocuments")}</p> - </div> - ) : ( - <div className="space-y-2"> - {filteredContracts.map((contract) => { - const contractStatus = contractStatuses.find(status => status.id === contract.id); - const isCompleted = contractStatus?.status === 'completed' || contractStatus?.status === 'vendor_signed'; - const hasError = contractStatus?.status === 'error'; - - // 계약서별 완료 상태 확인 - const isComplianceTemplate = contract.templateName?.includes('준법'); - const isGTCTemplate = contract.templateName?.includes('GTC'); - const requiresNegotiation = isComplianceTemplate || isGTCTemplate; - const hasSurveyCompleted = isComplianceTemplate ? surveyCompletionStatus[contract.id] === true : true; - const negotiationStatus = gtcCommentStatus[contract.id]; - const hasNegotiationCompleted = requiresNegotiation - ? (!negotiationStatus?.hasComments || negotiationStatus?.isComplete === true) - : true; - const hasSignatureCompleted = signatureStatus[contract.id] === true; - - return ( - <Button - key={contract.id} - variant="outline" - className={cn( - "w-full justify-start text-left h-auto p-2 bg-white transition-colors", - "border border-gray-200 rounded-md", - selectedContract?.id === contract.id && !isCompleted && "border-blue-500 bg-blue-50 shadow-sm", - isCompleted && "border-green-200 bg-green-50", - hasError && "border-red-200 bg-red-50", - !isCompleted && !hasError && "hover:bg-blue-50 hover:border-blue-200" - )} - onClick={() => handleSelectContract(contract)} - // Vendor signed 상태에서도 코멘트를 볼 수 있도록 비활성화하지 않음 - disabled={false} - > - <div className="flex flex-col w-full space-y-1"> - {/* 첫 번째 줄: 제목 + 상태 */} - <div className="flex items-center justify-between w-full"> - <span className="font-medium text-xs truncate text-gray-800 flex items-center min-w-0"> - {isBuyerMode ? ( - <Shield className="h-3 w-3 mr-1 text-green-500 flex-shrink-0" /> - ) : ( - <FileText className="h-3 w-3 mr-1 text-blue-500 flex-shrink-0" /> - )} - <span className="truncate">{contract.templateName || t("basicContracts.dialog.document")}</span> - {/* 비밀유지 계약서인 경우 표시 */} - {contract.templateName === "비밀유지 계약서" && ( - <Badge variant="outline" className="ml-1 bg-green-50 text-green-700 border-green-200 text-xs"> - NDA - </Badge> - )} - {/* GTC 계약서인 경우 표시 */} - {contract.templateName?.includes('GTC') && ( - <Badge variant="outline" className="ml-1 bg-purple-50 text-purple-700 border-purple-200 text-xs"> - GTC - </Badge> - )} - </span> - - {/* 상태 표시 */} - {isCompleted ? ( - <Badge variant="outline" className="bg-green-50 text-green-700 border-green-200 text-xs ml-2 flex-shrink-0"> - <CheckCircle2 className="h-3 w-3 mr-1" /> - 완료 - </Badge> - ) : hasError ? ( - <Badge variant="outline" className="bg-red-50 text-red-700 border-red-200 text-xs ml-2 flex-shrink-0"> - <AlertCircle className="h-3 w-3 mr-1" /> - 오류 - </Badge> - ) : ( - <Badge variant="outline" className="bg-yellow-50 text-yellow-700 border-yellow-200 text-xs ml-2 flex-shrink-0"> - 대기 - </Badge> - )} - </div> - - {/* 완료 상태 표시 (구매자 모드에서는 간소화) */} - {!isCompleted && !hasError && !isBuyerMode && ( - <div className="flex items-center space-x-2 text-xs"> - {isComplianceTemplate && ( - <span className={`flex items-center ${hasSurveyCompleted ? 'text-green-600' : 'text-gray-400'}`}> - <CheckCircle2 className={`h-3 w-3 mr-1 ${hasSurveyCompleted ? 'text-green-500' : 'text-gray-300'}`} /> - 설문 - </span> - )} - {requiresNegotiation && ( - <span className={`flex items-center ${hasNegotiationCompleted ? 'text-green-600' : 'text-red-600'}`}> - <CheckCircle2 className={`h-3 w-3 mr-1 ${hasNegotiationCompleted ? 'text-green-500' : 'text-red-500'}`} /> - 협의 - </span> - )} - <span className={`flex items-center ${hasSignatureCompleted ? 'text-green-600' : 'text-gray-400'}`}> - <Target className={`h-3 w-3 mr-1 ${hasSignatureCompleted ? 'text-green-500' : 'text-gray-300'}`} /> - 서명 - </span> - </div> - )} - - {/* 구매자 모드의 간소화된 상태 표시 */} - {!isCompleted && !hasError && isBuyerMode && ( - <div className="flex items-center space-x-2 text-xs"> - <span className={`flex items-center ${hasSignatureCompleted ? 'text-green-600' : 'text-gray-400'}`}> - <Target className={`h-3 w-3 mr-1 ${hasSignatureCompleted ? 'text-green-500' : 'text-gray-300'}`} /> - 구매자 서명 - </span> - </div> - )} - - {/* 두 번째 줄: 사용자 + 날짜 */} - <div className="flex items-center justify-between text-xs text-gray-500"> - <div className="flex items-center min-w-0"> - <User className="h-3 w-3 mr-1 flex-shrink-0" /> - <span className="truncate">{contract.requestedByName || t("basicContracts.dialog.unknown")}</span> - </div> - <div className="flex items-center ml-2 flex-shrink-0"> - <Calendar className="h-3 w-3 mr-1 flex-shrink-0" /> - <span>{formatDate(contract.createdAt)}</span> - </div> - </div> - - {/* 에러 메시지 표시 */} - {hasError && contractStatus?.errorMessage && ( - <div className="text-xs text-red-600 mt-1"> - {contractStatus.errorMessage} - </div> - )} - </div> - </Button> - ); - })} - </div> - )} - </div> - </ScrollArea> - </div> - - {/* 오른쪽 영역 - 문서 뷰어 (확장 가능) */} - <div className="flex-1 bg-white flex flex-col min-w-0"> - {selectedContract ? ( - <> - {/* 뷰어 헤더 */} - <div className="p-4 border-b bg-gray-50 flex-shrink-0"> - <h3 className="font-semibold text-gray-800 flex items-center"> - {isBuyerMode ? ( - <Shield className="h-4 w-4 mr-2 text-green-500" /> - ) : ( - <FileText className="h-4 w-4 mr-2 text-blue-500" /> - )} - {selectedContract.templateName || t("basicContracts.dialog.document")} - - {/* 현재 계약서 상태 표시 */} - {(currentContractStatus?.status === 'completed' || currentContractStatus?.status === 'vendor_signed') ? ( - <Badge variant="outline" className="ml-2 bg-green-50 text-green-700 border-green-200"> - <CheckCircle2 className="h-3 w-3 mr-1" /> - {isBuyerMode ? "승인 완료" : "서명 완료"} - </Badge> - ) : currentContractStatus?.status === 'error' ? ( - <Badge variant="outline" className="ml-2 bg-red-50 text-red-700 border-red-200"> - <AlertCircle className="h-3 w-3 mr-1" /> - 처리 실패 - </Badge> - ) : ( - <Badge variant="outline" className="ml-2 bg-yellow-50 text-yellow-700 border-yellow-200"> - {isBuyerMode ? "승인 대기" : "서명 대기"} - </Badge> - )} - - {/* 구매자 모드 배지 */} - {isBuyerMode && ( - <Badge variant="outline" className="ml-2 bg-green-50 text-green-700 border-green-200"> - 구매자 모드 - </Badge> - )} - - {/* 준법/GTC 템플릿 표시 (구매자 모드가 아닐 때만) */} - {!isBuyerMode && selectedContract.templateName?.includes('준법') && ( - <Badge variant="outline" className="ml-2 bg-amber-50 text-amber-700 border-amber-200"> - 준법 서류 - </Badge> - )} - - {!isBuyerMode && selectedContract.templateName?.includes('GTC') && ( - <Badge variant="outline" className="ml-2 bg-purple-50 text-purple-700 border-purple-200"> - GTC 계약서 - </Badge> - )} - - {/* 비밀유지 계약서인 경우 추가 파일 수 표시 (구매자 모드가 아닐 때만) */} - {!isBuyerMode && selectedContract.templateName === "비밀유지 계약서" && additionalFiles.length > 0 && ( - <Badge variant="outline" className="ml-2 bg-blue-50 text-blue-700 border-blue-200"> - 첨부파일 {additionalFiles.length}개 - </Badge> - )} - </h3> - <div className="flex justify-between items-center mt-2 text-sm text-gray-500"> - <span className="flex items-center"> - <User className="h-3 w-3 mr-1" /> - {t("basicContracts.dialog.requester")}: {selectedContract.requestedByName || t("basicContracts.dialog.unknown")} - </span> - <span className="flex items-center"> - <Clock className="h-3 w-3 mr-1" /> - {formatDate(selectedContract.createdAt)} - </span> - </div> - </div> - - {/* 뷰어 영역 - 남은 공간 모두 사용 */} - <div className="flex-1 min-h-0 overflow-hidden"> - <BasicContractSignViewer - key={selectedContract.id} - contractId={selectedContract.id} - filePath={selectedContract.signedFilePath || undefined} - templateName={selectedContract.templateName || ""} - additionalFiles={additionalFiles} - instance={instance} - setInstance={setInstance} - onSurveyComplete={() => handleSurveyComplete(selectedContract.id)} - onSignatureComplete={() => handleSignatureComplete(selectedContract.id)} - onGtcCommentStatusChange={(hasComments, commentCount, reviewStatus, isComplete) => - handleGtcCommentStatusChange(selectedContract.id, hasComments, commentCount, reviewStatus, isComplete) - } - mode={mode} - t={t} - negotiationCompletedAt={(selectedContract as any).negotiationCompletedAt || null} - /> - </div> - - {/* 고정 푸터 - 동적 버튼 */} - <div className="p-4 flex justify-between items-center bg-gray-50 border-t flex-shrink-0"> - <div className="flex items-center space-x-4"> - {/* 현재 계약서가 완료된 경우 */} - {currentContractStatus?.status === 'completed' || currentContractStatus?.status === 'vendor_signed' ? ( - <p className="text-sm text-green-600 flex items-center"> - <CheckCircle2 className="h-4 w-4 text-green-500 mr-1" /> - 이 계약서는 이미 {isBuyerMode ? "승인이" : "서명이"} 완료되었습니다. 코멘트를 확인할 수 있습니다. - </p> - ) : currentContractStatus?.status === 'error' ? ( - <p className="text-sm text-red-600 flex items-center"> - <AlertCircle className="h-4 w-4 text-red-500 mr-1" /> - {isBuyerMode ? "승인" : "서명"} 처리 중 오류가 발생했습니다. 다시 시도해주세요. - </p> - ) : ( - <> - {/* 완료 조건 안내 메시지 */} - <div className="flex flex-col space-y-1"> - <p className="text-sm text-gray-600 flex items-center"> - <AlertCircle className="h-4 w-4 text-yellow-500 mr-1" /> - {isBuyerMode - ? "계약서에 구매자 서명을 완료해주세요." - : t("basicContracts.dialog.signWarning") - } - </p> - - {/* 완료 상태 체크리스트 */} - {!isBuyerMode && ( - <div className="flex items-center space-x-4 text-xs"> - {selectedContract.templateName?.includes('준법') && ( - <span className={`flex items-center ${surveyCompletionStatus[selectedContract.id] ? 'text-green-600' : 'text-red-600'}`}> - <CheckCircle2 className={`h-3 w-3 mr-1 ${surveyCompletionStatus[selectedContract.id] ? 'text-green-500' : 'text-red-500'}`} /> - 설문조사 {surveyCompletionStatus[selectedContract.id] ? '완료' : '미완료'} - </span> - )} - {(selectedContract.templateName?.includes('GTC') || selectedContract.templateName?.includes('준법')) && ( - <span className={`flex items-center ${ - (gtcCommentStatus[selectedContract.id]?.isComplete === true) || !gtcCommentStatus[selectedContract.id]?.hasComments - ? 'text-green-600' - : 'text-red-600' - }`}> - <CheckCircle2 className={`h-3 w-3 mr-1 ${ - (gtcCommentStatus[selectedContract.id]?.isComplete === true) || !gtcCommentStatus[selectedContract.id]?.hasComments - ? 'text-green-500' - : 'text-red-500' - }`} /> - 협의 {(!gtcCommentStatus[selectedContract.id]?.hasComments || gtcCommentStatus[selectedContract.id]?.isComplete === true) - ? '완료' - : `미완료 (코멘트 ${gtcCommentStatus[selectedContract.id]?.commentCount || 0}개)`} - </span> - )} - <span className={`flex items-center ${signatureStatus[selectedContract.id] ? 'text-green-600' : 'text-red-600'}`}> - <Target className={`h-3 w-3 mr-1 ${signatureStatus[selectedContract.id] ? 'text-green-500' : 'text-red-500'}`} /> - 서명 {signatureStatus[selectedContract.id] ? '완료' : '미완료'} - </span> - </div> - )} - - {/* 구매자 모드의 간소화된 체크리스트 */} - {isBuyerMode && ( - <div className="flex items-center space-x-4 text-xs"> - <span className={`flex items-center ${signatureStatus[selectedContract.id] ? 'text-green-600' : 'text-red-600'}`}> - <Target className={`h-3 w-3 mr-1 ${signatureStatus[selectedContract.id] ? 'text-green-500' : 'text-red-500'}`} /> - 구매자 서명 {signatureStatus[selectedContract.id] ? '완료' : '미완료'} - </span> - </div> - )} - </div> - </> - )} - </div> - - {/* 동적 버튼 영역 */} - <div className="flex items-center space-x-2"> - {allCompleted ? ( - // 모든 계약서 완료시 - <Button - className={cn( - "gap-2 transition-colors", - isBuyerMode - ? "bg-green-600 hover:bg-green-700" - : "bg-green-600 hover:bg-green-700" - )} - onClick={completeAllSigns} - > - <Trophy className="h-4 w-4" /> - 모든 {isBuyerMode ? "승인" : "서명"} 완료 - </Button> - ) : (currentContractStatus?.status === 'completed' || currentContractStatus?.status === 'vendor_signed') ? ( - // 현재 계약서가 완료된 경우 - 코멘트 확인만 가능 - <div className="flex items-center gap-2"> - <Button - variant="outline" - className="gap-2" - onClick={() => { - const nextContract = getNextPendingContract(); - if (nextContract) { - setSelectedContract(nextContract); - } - }} - disabled={!getNextPendingContract()} - > - <ArrowRight className="h-4 w-4" /> - 다음 계약서 - </Button> - <p className="text-sm text-gray-500"> - 서명 완료 - 코멘트 확인 가능 - </p> - </div> - ) : ( - // 현재 계약서를 서명해야 하는 경우 - <Button - className={cn( - "gap-2 transition-colors", - canCompleteCurrentContract - ? isBuyerMode - ? "bg-green-600 hover:bg-green-700" - : "bg-blue-600 hover:bg-blue-700" - : "bg-gray-400 cursor-not-allowed" - )} - onClick={completeSign} - disabled={!canCompleteCurrentContract || isSubmitting} - > - {isSubmitting ? ( - <> - <svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> - <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> - <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> - </svg> - 처리중... - </> - ) : ( - <> - {isBuyerMode ? ( - <Shield className="h-4 w-4" /> - ) : ( - <FileSignature className="h-4 w-4" /> - )} - {signButtonText} - {totalCount > 1 && ( - <span className="ml-1 text-xs"> - ({completedCount}/{totalCount}) - </span> - )} - </> - )} - </Button> - )} - </div> - </div> - </> - ) : ( - <div className="flex flex-col items-center justify-center h-full text-center p-6"> - <div className={cn( - "p-6 rounded-full mb-4", - isBuyerMode ? "bg-green-50" : "bg-blue-50" - )}> - {isBuyerMode ? ( - <Shield className="h-12 w-12 text-green-500" /> - ) : ( - <FileSignature className="h-12 w-12 text-blue-500" /> - )} - </div> - <h3 className="text-xl font-medium text-gray-800 mb-2">{t("basicContracts.dialog.selectDocument")}</h3> - <p className="text-gray-500 max-w-md"> - {isBuyerMode - ? "승인할 계약서를 선택해주세요." - : t("basicContracts.dialog.selectDocumentDescription") - } - </p> - </div> - )} - </div> - </div> - </DialogContent> - </Dialog> - </> - ); -}
\ No newline at end of file diff --git a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx index 407a3c4d..3a6dcf9b 100644 --- a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx +++ b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx @@ -31,6 +31,7 @@ import { Separator } from "@/components/ui/separator"; import { useRouter } from "next/navigation" import { BasicContractSignViewer } from "../viewer/basic-contract-sign-viewer"; import { getVendorAttachments, processBuyerSignatureAction } from "../service"; +import { getVendorInfo } from "../service-vendor-info"; // 계약서 상태 타입 정의 interface ContractStatus { @@ -77,6 +78,7 @@ export function BasicContractSignDialog({ // 계약서 상태 관리 const [contractStatuses, setContractStatuses] = React.useState<ContractStatus[]>([]); + const [vendorInfo, setVendorInfo] = React.useState<{taxId?: string | null, country?: string | null} | null>(null); // 서명/설문/GTC 코멘트 완료 상태 관리 const [surveyCompletionStatus, setSurveyCompletionStatus] = React.useState<Record<number, boolean>>({}); @@ -97,7 +99,13 @@ export function BasicContractSignDialog({ // 모드에 따른 텍스트 const isBuyerMode = mode === 'buyer'; const dialogTitle = isBuyerMode ? "구매자 최종승인 서명" : t("basicContracts.dialog.title"); - const signButtonText = isBuyerMode ? "최종승인 완료" : "서명 완료 및 저장"; + + // 서명 버튼 텍스트 (내자/외자 구분) + const signButtonText = React.useMemo(() => { + if (isBuyerMode) return "최종승인 완료"; + if (vendorInfo?.country === 'KR') return "공동인증 서명"; + return "서명 완료 및 저장"; + }, [isBuyerMode, vendorInfo]); // 버튼 비활성화 조건 const isButtonDisabled = !hasSelectedRows || contracts.length === 0; @@ -257,15 +265,26 @@ const canCompleteCurrentContract = React.useMemo(() => { React.useEffect(() => { if (isBuyerMode) { setAdditionalFiles([]); + setVendorInfo(null); return; } const fetchAdditionalFiles = async () => { if (!selectedContract) { setAdditionalFiles([]); + setVendorInfo(null); return; } + // 벤더 정보 가져오기 (사업자번호 등) + if (selectedContract.vendorId) { + getVendorInfo(selectedContract.vendorId).then(res => { + if (res.success && res.data) { + setVendorInfo(res.data); + } + }); + } + // "비밀유지 계약서"인 경우에만 추가 파일 가져오기 if (selectedContract.templateName === "비밀유지 계약서" && selectedContract.vendorId) { setIsLoadingAttachments(true); @@ -329,6 +348,104 @@ const canCompleteCurrentContract = React.useMemo(() => { const completeSign = async () => { if (!instance || !selectedContract) return; + // 🔹 내자인 경우 공동인증 팝업 열기 + if (!isBuyerMode && vendorInfo?.country === 'KR') { + console.log("🔐 내자 협력업체 - 공동인증 서명 시작"); + + // 검증 로직은 동일하게 수행 + const contractId = selectedContract.id; + const isComplianceTemplate = selectedContract.templateName?.includes('준법'); + const isGTCTemplate = selectedContract.templateName?.includes('GTC'); + const surveyCompleted = isComplianceTemplate ? surveyCompletionStatus[contractId] === true : true; + const requiresNegotiationComplete = isComplianceTemplate || isGTCTemplate; + const negotiationStatus = gtcCommentStatus[contractId]; + const negotiationCleared = requiresNegotiationComplete + ? (!negotiationStatus?.hasComments || negotiationStatus?.isComplete === true) + : true; + + if (!surveyCompleted) { + toast({ + title: "준법 설문조사를 먼저 완료해주세요.", + description: "설문조사 탭에서 모든 필수 항목을 완료해주세요.", + variant: "destructive" + }); + return; + } + + if (!negotiationCleared) { + toast({ + title: "코멘트가 있어 서명할 수 없습니다.", + description: "협의 코멘트 탭에서 모든 코멘트를 삭제하거나 협의를 완료해주세요.", + variant: "destructive" + }); + return; + } + + // 공동인증 팝업 열기 + // trustnet은 포트 없이 접근 (<ip>:<port> → <ip>/trustnet) + // 단, API 호출을 위해 부모 창의 origin (포트 포함)을 전달 + const ssn = vendorInfo.taxId || ''; + const ssnParam = ssn ? `&ssn=${encodeURIComponent(ssn)}` : ''; + const baseUrl = `${window.location.protocol}//${window.location.hostname}`; + const apiOrigin = window.location.origin; // 포트 포함 (예: http://60.101.108.100:3001) + const popupUrl = `${baseUrl}/trustnet?contractId=${selectedContract.id}${ssnParam}&autoStart=true&apiOrigin=${encodeURIComponent(apiOrigin)}`; + + console.log('🔐 공동인증 팝업 열기:', { baseUrl, apiOrigin, popupUrl, ssn }); + const popupWidth = 600; + const popupHeight = 700; + const left = (window.screen.width - popupWidth) / 2; + const top = (window.screen.height - popupHeight) / 2; + + const popup = window.open( + popupUrl, + 'ContractSign', + `width=${popupWidth},height=${popupHeight},left=${left},top=${top},resizable=yes,scrollbars=yes` + ); + + if (!popup) { + toast({ + title: "팝업이 차단되었습니다", + description: "브라우저 팝업 차단을 해제해주세요.", + variant: "destructive" + }); + return; + } + + toast({ + title: "공동인증 서명 창이 열렸습니다", + description: "공동인증서로 서명을 진행해주세요.", + duration: 3000 // 3초 후 자동으로 사라짐 + }); + + // 팝업에서 서명 완료 메시지를 받으면 새로고침 + const handleMessage = (event: MessageEvent) => { + console.log('📨 메시지 수신:', event.data); + + if (event.data.type === 'INTERNAL_SIGN_COMPLETE') { + console.log('✅ 공동인증 서명 완료:', event.data.contractId); + + toast({ + title: "서명이 완료되었습니다", + description: "계약서 상태가 업데이트됩니다.", + duration: 3000 // 3초 후 자동으로 사라짐 + }); + + // 팝업 닫힌 후 테이블 데이터만 재조회 (페이지 전체 리로드 대신) + if (onSuccess) { + onSuccess(); + } + router.refresh(); + + // 리스너 제거 + window.removeEventListener('message', handleMessage); + } + }; + + window.addEventListener('message', handleMessage); + + return; + } + // 서명 완료 가능 여부 재확인 if (!canCompleteCurrentContract) { const contractId = selectedContract.id; @@ -345,7 +462,7 @@ const canCompleteCurrentContract = React.useMemo(() => { return; } } else { - // 협력업체 모드의 기존 검증 로직 + // 협력업체 모드의 기존 검증 로직 (외자) const isComplianceTemplate = selectedContract.templateName?.includes('준법'); const isGTCTemplate = selectedContract.templateName?.includes('GTC'); const surveyCompleted = isComplianceTemplate ? surveyCompletionStatus[contractId] === true : true; @@ -882,6 +999,21 @@ const canCompleteCurrentContract = React.useMemo(() => { </Badge> )} + {/* 내자/외자 표시 (협력업체 모드일 때만) */} + {!isBuyerMode && vendorInfo && ( + <Badge + variant="outline" + className={cn( + "ml-2", + vendorInfo.country === 'KR' + ? "bg-blue-50 text-blue-700 border-blue-200" + : "bg-gray-50 text-gray-700 border-gray-200" + )} + > + {vendorInfo.country === 'KR' ? '🔐 내자 (공동인증)' : '🌍 외자'} + </Badge> + )} + {/* 준법/GTC 템플릿 표시 (구매자 모드가 아닐 때만) */} {!isBuyerMode && selectedContract.templateName?.includes('준법') && ( <Badge variant="outline" className="ml-2 bg-amber-50 text-amber-700 border-amber-200"> @@ -932,6 +1064,8 @@ const canCompleteCurrentContract = React.useMemo(() => { mode={mode} t={t} negotiationCompletedAt={(selectedContract as any).negotiationCompletedAt || null} + vendorTaxId={vendorInfo?.taxId || undefined} + vendorCountry={vendorInfo?.country || undefined} /> </div> diff --git a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx index 8185e33e..98204763 100644 --- a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx +++ b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx @@ -54,6 +54,8 @@ interface BasicContractSignViewerProps { mode?: 'vendor' | 'buyer'; // 추가된 mode prop t?: (key: string) => string; negotiationCompletedAt?: Date | null; // 협의 완료 시간 추가 + vendorTaxId?: string; + vendorCountry?: string; } // 자동 서명 필드 생성을 위한 타입 정의 @@ -694,6 +696,8 @@ export function BasicContractSignViewer({ mode = 'vendor', // 기본값 vendor t = (key: string) => key, negotiationCompletedAt, + vendorTaxId, + vendorCountry, }: BasicContractSignViewerProps) { const { toast } = useToast(); @@ -1255,6 +1259,12 @@ export function BasicContractSignViewer({ const currentInstance = webViewerInstance.current || instance; if (!currentInstance) return; + // 내자(KR)인 경우 TrustNet 공동인증 서명 팝업 호출 (구매자 모드가 아닐 때만) + if (mode === 'vendor' && vendorCountry === 'KR') { + handleCertificateSign(); + return; + } + try { const { documentViewer, annotationManager } = currentInstance.Core; const doc = documentViewer.getDocument(); @@ -1325,6 +1335,31 @@ export function BasicContractSignViewer({ } }; + const handleCertificateSign = () => { + if (!contractId) { + toast({ title: "계약서 ID가 없습니다.", variant: "destructive" }); + return; + } + + // 사업자번호가 없으면 입력하도록 안내 (혹은 그냥 빈값으로 열어서 직접 입력하게 할 수도 있음) + // 여기서는 경고만 하고 열어줌 + if (!vendorTaxId) { + // toast({ title: "사업자번호 정보를 불러올 수 없습니다.", description: "팝업에서 직접 입력해주세요.", variant: "default" }); + } + + const width = 600; + const height = 700; + const left = window.screenX + (window.outerWidth - width) / 2; + const top = window.screenY + (window.outerHeight - height) / 2; + + const ssnParam = vendorTaxId ? `&ssn=${encodeURIComponent(vendorTaxId)}` : ''; + const baseUrl = `${window.location.protocol}//${window.location.hostname}`; + const apiOrigin = encodeURIComponent(window.location.origin); + const url = `${baseUrl}/trustnet?contractId=${contractId}${ssnParam}&autoStart=true&apiOrigin=${apiOrigin}`; + + window.open(url, 'TrustNetSign', `width=${width},height=${height},top=${top},left=${left}`); + }; + // 서명 상태 표시 컴포넌트 - 처리 중이거나 오류일 때만 표시 const SignatureFieldsStatus = () => { // 처리 중이거나 오류가 있을 때만 표시 (완료 후에는 자동으로 사라짐) |
