diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-12-01 11:20:08 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-12-01 11:20:08 +0000 |
| commit | c691e816c56bbe0069e7f2da90466b8480f0cd15 (patch) | |
| tree | 08747df599ea874a9437ce94555acdd64a8de423 | |
| parent | c3a1e8480ab016d2bf0b1fc2470943a0d64749d5 (diff) | |
(임수민) 공동인증 테스트 파일
| -rw-r--r-- | lib/basic-contract/vendor-table/basic-contract-sign-dialog-test.tsx | 1166 |
1 files changed, 1166 insertions, 0 deletions
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 new file mode 100644 index 00000000..ace34454 --- /dev/null +++ b/lib/basic-contract/vendor-table/basic-contract-sign-dialog-test.tsx @@ -0,0 +1,1166 @@ +"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 |
