diff options
Diffstat (limited to 'lib/basic-contract/viewer')
| -rw-r--r-- | lib/basic-contract/viewer/GtcClausesComponent.tsx | 262 | ||||
| -rw-r--r-- | lib/basic-contract/viewer/basic-contract-sign-viewer.tsx | 256 |
2 files changed, 384 insertions, 134 deletions
diff --git a/lib/basic-contract/viewer/GtcClausesComponent.tsx b/lib/basic-contract/viewer/GtcClausesComponent.tsx index 8f565971..381e69dc 100644 --- a/lib/basic-contract/viewer/GtcClausesComponent.tsx +++ b/lib/basic-contract/viewer/GtcClausesComponent.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useCallback,useRef } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { ScrollArea } from "@/components/ui/scroll-area"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; @@ -25,17 +25,23 @@ import { Minimize2, Maximize2, } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { - getVendorGtcData, +import { cn, formatDateTime } from "@/lib/utils"; +import { + getVendorGtcData, updateVendorClause, checkVendorClausesCommentStatus, - type GtcVendorData + type GtcVendorData } from "../service"; +import { useSession } from "next-auth/react" interface GtcClausesComponentProps { contractId?: number; - onCommentStatusChange?: (hasComments: boolean, commentCount: number) => void; + onCommentStatusChange?: ( + hasComments: boolean, + commentCount: number, + reviewStatus?: string, + isComplete?: boolean + ) => void; t?: (key: string) => string; } @@ -52,26 +58,26 @@ type GtcVendorClause = { reviewStatus: string; negotiationNote: string | null; isExcluded: boolean; - + // 실제 표시될 값들 (기본 조항 값) effectiveItemNumber: string; effectiveCategory: string | null; effectiveSubtitle: string; effectiveContent: string | null; - + // 기본 조항 정보 (동일) baseItemNumber: string; baseCategory: string | null; baseSubtitle: string; baseContent: string | null; - + // 수정 여부 (코멘트만 있으면 false) hasModifications: boolean; isNumberModified: boolean; isCategoryModified: boolean; isSubtitleModified: boolean; isContentModified: boolean; - + // 코멘트 관련 hasComment: boolean; pendingComment: string | null; @@ -82,14 +88,24 @@ interface ClauseState extends GtcVendorClause { isEditing?: boolean; tempComment?: string; isSaving?: boolean; - // 고유 식별자를 위한 헬퍼 속성 uniqueId: number; + commentHistory?: CommentHistory[]; // 추가 + showHistory?: boolean; // 이력 표시 여부 } -export function GtcClausesComponent({ - contractId, +interface CommentHistory { + vendorClauseId: number; + comment: string; + actorName?: string; + actorEmail?: string; + createdAt: Date; + action: string; +} + +export function GtcClausesComponent({ + contractId, onCommentStatusChange, - t = (key: string) => key + t = (key: string) => key }: GtcClausesComponentProps) { const [gtcData, setGtcData] = useState<GtcVendorData | null>(null); const [clauses, setClauses] = useState<ClauseState[]>([]); @@ -98,6 +114,7 @@ export function GtcClausesComponent({ const [searchTerm, setSearchTerm] = useState(""); const [expandedItems, setExpandedItems] = useState<Set<number>>(new Set()); const [compactMode, setCompactMode] = useState(true); // 컴팩트 모드 상태 추가 + const { data: session } = useSession(); const onCommentStatusChangeRef = useRef(onCommentStatusChange); onCommentStatusChangeRef.current = onCommentStatusChange; @@ -109,14 +126,14 @@ export function GtcClausesComponent({ setError(null); const data = await getVendorGtcData(contractId); - + if (!data) { setError("GTC 데이터를 찾을 수 없습니다."); return; } setGtcData(data); - + const initialClauses: ClauseState[] = data.clauses.map(clause => ({ ...clause, uniqueId: clause.id, @@ -125,7 +142,7 @@ export function GtcClausesComponent({ tempComment: clause.negotiationNote || "", isSaving: false, })); - + setClauses(initialClauses); } catch (err) { @@ -136,26 +153,33 @@ export function GtcClausesComponent({ } }, [contractId]); - const lastCommentStatusRef = useRef<{ hasComments: boolean; commentCount: number } | null>(null); + const lastCommentStatusRef = useRef<{ hasComments: boolean; commentCount: number , reviewStatus:string} | null>(null); // 코멘트 상태 변경을 별도 useEffect로 처리 useEffect(() => { - if (clauses.length > 0) { + if (clauses.length > 0 && gtcData) { const commentCount = clauses.filter(c => c.hasComment).length; const hasComments = commentCount > 0; + const reviewStatus = gtcData.vendorDocument?.reviewStatus || 'draft'; + + // reviewStatus가 complete이면 코멘트가 있어도 완료된 것으로 처리 + const isComplete = reviewStatus === 'complete' || reviewStatus === 'approved'; + + const currentStatus = { hasComments, commentCount, reviewStatus, isComplete }; - // Only call callback if status actually changed - const currentStatus = { hasComments, commentCount }; if (!lastCommentStatusRef.current || lastCommentStatusRef.current.hasComments !== hasComments || - lastCommentStatusRef.current.commentCount !== commentCount) { + lastCommentStatusRef.current.commentCount !== commentCount || + lastCommentStatusRef.current.reviewStatus !== reviewStatus) { lastCommentStatusRef.current = currentStatus; - onCommentStatusChangeRef.current?.(hasComments, commentCount); + // isComplete 정보도 전달 + onCommentStatusChangeRef.current?.(hasComments, commentCount, reviewStatus, isComplete); } } - }, [clauses]); - + }, [clauses, gtcData]); + + useEffect(() => { loadGtcData(); }, [loadGtcData]); @@ -176,11 +200,11 @@ export function GtcClausesComponent({ // 계층 구조로 조항 그룹화 const groupedClauses = React.useMemo(() => { const grouped: { [key: number]: ClauseState[] } = { 0: [] }; // 최상위는 0 - + filteredClauses.forEach(clause => { // parentId를 baseClauseId와 매핑 (parentId는 실제 baseClauseId를 가리킴) let parentKey = 0; // 기본값은 최상위 - + if (clause.parentId !== null) { // parentId에 해당하는 조항을 찾아서 그 조항의 uniqueId를 사용 const parentClause = filteredClauses.find(c => c.baseClauseId === clause.parentId); @@ -188,7 +212,7 @@ export function GtcClausesComponent({ parentKey = parentClause.uniqueId; } } - + if (!grouped[parentKey]) { grouped[parentKey] = []; } @@ -223,7 +247,7 @@ export function GtcClausesComponent({ return { ...clause, isEditing: !clause.isEditing, - tempComment: clause.negotiationNote || "", + tempComment: "", }; } return clause; @@ -240,17 +264,32 @@ export function GtcClausesComponent({ })); }, []); + // toggleCommentHistory 함수 추가 + const toggleCommentHistory = useCallback((uniqueId: number) => { + setClauses(prev => prev.map(clause => { + if (clause.uniqueId === uniqueId) { + return { ...clause, showHistory: !clause.showHistory }; + } + return clause; + })); + }, []); + // 코멘트 저장 const saveComment = useCallback(async (uniqueId: number) => { const clause = clauses.find(c => c.uniqueId === uniqueId); if (!clause) return; - setClauses(prev => prev.map(c => + // 빈 코멘트 체크 - 신규 입력 시에만 + if (!clause.hasComment && (!clause.tempComment || clause.tempComment.trim() === "")) { + toast.error("코멘트를 입력해주세요."); + return; + } + + setClauses(prev => prev.map(c => c.uniqueId === uniqueId ? { ...c, isSaving: true } : c )); try { - // 기본 조항 정보를 그대로 사용하고 코멘트만 처리 const clauseData = { itemNumber: clause.effectiveItemNumber, category: clause.effectiveCategory, @@ -260,22 +299,38 @@ export function GtcClausesComponent({ }; const result = await updateVendorClause( - clause.id, + clause.id, clause.vendorClauseId, clauseData, gtcData?.vendorDocument ); - + if (result.success) { - const hasComment = !!(clause.tempComment?.trim()); + + if (!session?.user?.id) { + toast.error("로그인이 필요합니다."); + return; + } + + // 새 코멘트를 이력에 추가 + const newHistory = { + vendorClauseId: result.vendorClauseId, + comment: clause.tempComment || "", + actorName: session.user.name ||"현재 사용자", // 실제로는 세션에서 가져와야 함 + createdAt: new Date(), + action: "commented" + }; setClauses(prev => prev.map(c => { if (c.uniqueId === uniqueId) { + const updatedHistory = [newHistory, ...(c.commentHistory || [])]; return { ...c, vendorClauseId: result.vendorClauseId || c.vendorClauseId, negotiationNote: clause.tempComment?.trim() || null, - hasComment, + latestComment: clause.tempComment?.trim() || null, + commentHistory: updatedHistory, + hasComment: true, isEditing: false, isSaving: false, }; @@ -284,22 +339,20 @@ export function GtcClausesComponent({ })); toast.success("코멘트가 저장되었습니다."); - } else { toast.error(result.error || "코멘트 저장에 실패했습니다."); - setClauses(prev => prev.map(c => + setClauses(prev => prev.map(c => c.uniqueId === uniqueId ? { ...c, isSaving: false } : c )); } } catch (error) { console.error('코멘트 저장 실패:', error); toast.error("코멘트 저장 중 오류가 발생했습니다."); - setClauses(prev => prev.map(c => + setClauses(prev => prev.map(c => c.uniqueId === uniqueId ? { ...c, isSaving: false } : c )); } }, [clauses, gtcData]); - // 편집 취소 const cancelEdit = useCallback((uniqueId: number) => { setClauses(prev => prev.map(clause => { @@ -319,7 +372,7 @@ export function GtcClausesComponent({ const isExpanded = expandedItems.has(clause.uniqueId); const children = groupedClauses[clause.uniqueId] || []; const hasChildren = children.length > 0; - + return ( <div key={clause.uniqueId} className={`${depth > 0 ? 'ml-4' : ''}`}> <div className={cn( @@ -401,8 +454,8 @@ export function GtcClausesComponent({ onClick={() => toggleEdit(clause.uniqueId)} className={cn( "h-6 w-6 p-0 transition-colors", - clause.hasComment - ? "text-amber-600 hover:text-amber-700 hover:bg-amber-50" + clause.hasComment + ? "text-amber-600 hover:text-amber-700 hover:bg-amber-50" : "text-gray-500 hover:text-gray-700 hover:bg-gray-50" )} > @@ -426,7 +479,7 @@ export function GtcClausesComponent({ <span className="text-sm text-gray-700">{clause.effectiveCategory}</span> </div> )} - + {/* 내용 */} {clause.effectiveContent && ( <p className="text-sm text-gray-700 leading-relaxed mb-3 whitespace-pre-wrap"> @@ -455,17 +508,72 @@ export function GtcClausesComponent({ )} {/* 기존 코멘트 표시 */} - {!clause.isEditing && clause.hasComment && clause.negotiationNote && ( + {!clause.isEditing && clause.hasComment && ( <div className="mb-2 p-2.5 bg-amber-50 rounded border border-amber-200"> - <div className="flex items-center text-sm font-medium text-amber-800 mb-2"> - <MessageSquare className="h-4 w-4 mr-2" /> - 협의 코멘트 + <div className="flex items-center justify-between mb-2"> + <div className="flex items-center text-sm font-medium text-amber-800"> + <MessageSquare className="h-4 w-4 mr-2" /> + 협의 코멘트 + {clause.commentHistory && clause.commentHistory.length > 1 && ( + <Badge variant="outline" className="ml-2 text-xs"> + {clause.commentHistory.length}개 이력 + </Badge> + )} + </div> + {clause.commentHistory && clause.commentHistory.length > 1 && ( + <Button + variant="ghost" + size="sm" + onClick={() => toggleCommentHistory(clause.uniqueId)} + className="h-6 px-2 text-xs text-amber-600 hover:text-amber-700" + > + {clause.showHistory ? "이력 숨기기" : "이력 보기"} + </Button> + )} + </div> + + {/* 최신 코멘트 */} + <div className="space-y-2"> + <div className="bg-white p-2 rounded border border-amber-100"> + <p className="text-sm text-amber-700 whitespace-pre-wrap"> + {clause.latestComment || clause.negotiationNote} + </p> + {clause.commentHistory?.[0] && ( + <div className="flex items-center justify-between mt-1 pt-1 border-t border-amber-100"> + <span className="text-xs text-amber-600"> + {clause.commentHistory[0].actorName || "SHI"} + </span> + <span className="text-xs text-amber-500"> + {formatDateTime(clause.commentHistory[0].createdAt, "KR")} + </span> + </div> + )} + </div> + + {/* 이전 코멘트 이력 */} + {clause.showHistory && clause.commentHistory && clause.commentHistory.length > 1 && ( + <div className="space-y-1.5 max-h-60 overflow-y-auto"> + {clause.commentHistory.slice(1).map((history, idx) => ( + <div key={idx} className="bg-white/50 p-2 rounded border border-amber-100/50"> + <p className="text-xs text-amber-600 whitespace-pre-wrap"> + {history.comment} + </p> + <div className="flex items-center justify-between mt-1 pt-1 border-t border-amber-100/50"> + <span className="text-xs text-amber-500"> + {history.actorName || "SHI"} + </span> + <span className="text-xs text-amber-400"> + {formatDateTime(history.createdAt, "KR")} + </span> + </div> + </div> + ))} + </div> + )} </div> - <p className="text-sm text-amber-700 whitespace-pre-wrap"> - {clause.negotiationNote} - </p> </div> )} + </div> )} @@ -484,7 +592,7 @@ export function GtcClausesComponent({ const isExpanded = expandedItems.has(clause.uniqueId); const children = groupedClauses[clause.uniqueId] || []; const hasChildren = children.length > 0; - + return ( <div key={clause.uniqueId} className={`mb-1 ${depth > 0 ? 'ml-4' : ''}`}> <Card className={cn( @@ -564,8 +672,8 @@ export function GtcClausesComponent({ onClick={() => toggleEdit(clause.uniqueId)} className={cn( "h-6 px-2 transition-colors", - clause.hasComment - ? "text-amber-600 hover:text-amber-700 hover:bg-amber-50" + clause.hasComment + ? "text-amber-600 hover:text-amber-700 hover:bg-amber-50" : "text-gray-500 hover:text-gray-700 hover:bg-gray-50" )} > @@ -722,6 +830,27 @@ export function GtcClausesComponent({ compactMode ? "h-4 w-4" : "h-5 w-5" )} /> {gtcData.vendorDocument.name} + + {/* reviewStatus 배지 추가 */} + {gtcData.vendorDocument.reviewStatus && ( + <Badge + variant="outline" + className={cn( + "ml-2", + gtcData.vendorDocument.reviewStatus === 'complete' || gtcData.vendorDocument.reviewStatus === 'approved' + ? "bg-green-50 text-green-700 border-green-200" + : gtcData.vendorDocument.reviewStatus === 'reviewing' + ? "bg-blue-50 text-blue-700 border-blue-200" + : "bg-gray-50 text-gray-700 border-gray-200" + )} + > + {gtcData.vendorDocument.reviewStatus === 'complete' ? '협의 완료' : + gtcData.vendorDocument.reviewStatus === 'approved' ? '승인됨' : + gtcData.vendorDocument.reviewStatus === 'reviewing' ? '협의 중' : + gtcData.vendorDocument.reviewStatus === 'draft' ? '초안' : + gtcData.vendorDocument.reviewStatus} + </Badge> + )} </h3> {!compactMode && ( <p className="text-sm text-gray-500 mt-0.5"> @@ -747,7 +876,7 @@ export function GtcClausesComponent({ <Minimize2 className="h-3 w-3" /> )} </Button> - + <Badge variant="outline" className={cn( "bg-blue-50 text-blue-700 border-blue-200", compactMode ? "text-xs px-1.5 py-0.5" : "text-xs" @@ -788,8 +917,8 @@ export function GtcClausesComponent({ /> </div> - {/* 안내 메시지 */} - {totalComments > 0 && ( + {/* 안내 메시지 수정 - reviewStatus 체크 */} + {totalComments > 0 && gtcData.vendorDocument.reviewStatus !== 'complete' && gtcData.vendorDocument.reviewStatus !== 'approved' && ( <div className={cn( "bg-amber-50 rounded border border-amber-200", compactMode ? "mt-2 p-2" : "mt-2 p-2" @@ -811,6 +940,25 @@ export function GtcClausesComponent({ )} </div> )} + + {/* 협의 완료 메시지 */} + {totalComments > 0 && (gtcData.vendorDocument.reviewStatus === 'complete' || gtcData.vendorDocument.reviewStatus === 'approved') && ( + <div className={cn( + "bg-green-50 rounded border border-green-200", + compactMode ? "mt-2 p-2" : "mt-2 p-2" + )}> + <div className={cn( + "flex items-center text-green-800", + compactMode ? "text-sm" : "text-sm" + )}> + <CheckCircle2 className={cn( + "mr-2", + compactMode ? "h-4 w-4" : "h-4 w-4" + )} /> + <span className="font-medium">협의가 완료되어 서명 가능합니다.</span> + </div> + </div> + )} </div> {/* 조항 목록 */} @@ -825,7 +973,7 @@ export function GtcClausesComponent({ </div> ) : ( <div className={compactMode ? "space-y-0.5" : "space-y-1"}> - {(groupedClauses[0] || []).map(clause => + {(groupedClauses[0] || []).map(clause => compactMode ? renderCompactClause(clause) : renderNormalClause(clause) )} </div> diff --git a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx index 943878da..e52f0d79 100644 --- a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx +++ b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx @@ -44,7 +44,12 @@ interface BasicContractSignViewerProps { setInstance: Dispatch<SetStateAction<WebViewerInstance | null>>; onSurveyComplete?: () => void; onSignatureComplete?: () => void; - onGtcCommentStatusChange?: (hasComments: boolean, commentCount: number) => void; + onGtcCommentStatusChange?: ( + hasComments: boolean, + commentCount: number, + reviewStatus?: string, + isComplete?: boolean + ) => void; mode?: 'vendor' | 'buyer'; // 추가된 mode prop t?: (key: string) => string; } @@ -63,58 +68,15 @@ interface SignaturePattern { // 초간단 안전한 서명 필드 감지 클래스 class AutoSignatureFieldDetector { private instance: WebViewerInstance; - private signaturePatterns: SignaturePattern[]; - private mode: 'vendor' | 'buyer'; // mode 추가 + private mode: 'vendor' | 'buyer'; constructor(instance: WebViewerInstance, mode: 'vendor' | 'buyer' = 'vendor') { this.instance = instance; this.mode = mode; - this.signaturePatterns = this.initializePatterns(); - } - - private initializePatterns(): SignaturePattern[] { - return [ - { - regex: /서명\s*[::]\s*[_\-\s]{3,}/gi, - name: "한국어_서명_콜론", - priority: 10, - offsetX: 80, - offsetY: -5, - width: 150, - height: 40 - }, - { - regex: /서명란\s*[_\-\s]{0,}/gi, - name: "한국어_서명란", - priority: 9, - offsetX: 60, - offsetY: -5, - width: 150, - height: 40 - }, - { - regex: /signature\s*[::]\s*[_\-\s]{3,}/gi, - name: "영어_signature_콜론", - priority: 8, - offsetX: 120, - offsetY: -5, - width: 150, - height: 40 - }, - { - regex: /sign\s+here\s*[::]?\s*[_\-\s]{0,}/gi, - name: "영어_sign_here", - priority: 9, - offsetX: 100, - offsetY: -5, - width: 150, - height: 40 - } - ]; } async detectAndCreateSignatureFields(): Promise<string[]> { - console.log(`🔍 안전한 서명 필드 감지 시작... (모드: ${this.mode})`); + console.log(`🔍 텍스트 기반 서명 필드 감지 시작... (모드: ${this.mode})`); try { if (!this.instance?.Core?.documentViewer) { @@ -129,25 +91,122 @@ class AutoSignatureFieldDetector { throw new Error("PDF 문서가 로드되지 않았습니다."); } - console.log("📄 문서 확인 완료, 기본 서명 필드 생성..."); - const defaultField = await this.createSimpleSignatureField(); + // 모드에 따라 검색할 텍스트 결정 + const searchText = this.mode === 'buyer' + ? '삼성중공업_서명란' + : '협력업체_서명란'; + + console.log(`📄 "${searchText}" 텍스트 검색 중...`); - console.log("✅ 서명 필드 생성 완료"); - return [defaultField]; + // 텍스트 검색 및 서명 필드 생성 + const fieldName = await this.createSignatureFieldAtText(searchText); + + if (fieldName) { + console.log(`✅ 텍스트 위치에 서명 필드 생성 완료: ${searchText}`); + return [fieldName]; + } else { + // 텍스트를 찾지 못한 경우 기본 위치에 생성 + console.log(`⚠️ "${searchText}" 텍스트를 찾지 못해 기본 위치에 생성`); + const defaultField = await this.createSimpleSignatureField(); + return [defaultField]; + } } catch (error) { console.error("📛 서명 필드 생성 실패:", error); - let errorMessage = "서명 필드 생성에 실패했습니다."; - if (error instanceof Error) { - if (error.message.includes("인스턴스")) { - errorMessage = "뷰어가 준비되지 않았습니다."; - } else if (error.message.includes("문서")) { - errorMessage = "문서를 불러오는 중입니다."; + // 오류 발생 시 기본 위치에 생성 + try { + const defaultField = await this.createSimpleSignatureField(); + return [defaultField]; + } catch (fallbackError) { + throw new Error("서명 필드 생성에 실패했습니다."); + } + } + } + + private async createSignatureFieldAtText(searchText: string): Promise<string | null> { + const { Core } = this.instance; + const { documentViewer, annotationManager } = Core; + const document = documentViewer.getDocument(); + + if (!document) return null; + + try { + // 모든 페이지에서 텍스트 검색 + const searchMode = Core.Search.Mode.PAGE_STOP | Core.Search.Mode.HIGHLIGHT; + const searchOptions = { + fullSearch: true, + onResult: null, + }; + + // 텍스트 검색 시작 + const textSearchIterator = await document.getTextSearchIterator(); + textSearchIterator.begin(searchText, searchMode); + + let searchResult = await textSearchIterator.next(); + + // 검색 결과가 있는 경우 + if (searchResult && searchResult.resultCode === Core.Search.ResultCode.FOUND) { + const pageNumber = searchResult.pageNum; + const quads = searchResult.quads; + + if (quads && quads.length > 0) { + // 첫 번째 검색 결과의 위치 가져오기 + const quad = quads[0]; + + // 쿼드의 좌표를 기반으로 서명 필드 위치 계산 + const x = Math.min(quad.x1, quad.x2, quad.x3, quad.x4); + const y = Math.min(quad.y1, quad.y2, quad.y3, quad.y4); + const textWidth = Math.abs(quad.x2 - quad.x1); + const textHeight = Math.abs(quad.y3 - quad.y1); + + // 서명 필드 생성 + const fieldName = `signature_at_text_${Date.now()}`; + const flags = new Core.Annotations.WidgetFlags(); + flags.set('Required', true); + + const field = new Core.Annotations.Forms.Field(fieldName, { + type: 'Sig', + flags + }); + + const widget = new Core.Annotations.SignatureWidgetAnnotation(field, { + Width: 150, + Height: 50 + }); + + widget.setPageNumber(pageNumber); + + // 텍스트 바로 아래 또는 오른쪽에 서명 필드 배치 + // 옵션 1: 텍스트 바로 아래 + widget.setX(x); + widget.setY(y + textHeight + 5); // 텍스트 아래 5픽셀 간격 + + // 옵션 2: 텍스트 오른쪽 (필요시 아래 주석 해제) + // widget.setX(x + textWidth + 10); // 텍스트 오른쪽 10픽셀 간격 + // widget.setY(y); + + widget.setWidth(150); + widget.setHeight(50); + + // 필드 매니저에 추가 + const fm = annotationManager.getFieldManager(); + fm.addField(field); + annotationManager.addAnnotation(widget); + annotationManager.drawAnnotationsFromList([widget]); + + console.log(`📌 서명 필드를 페이지 ${pageNumber}의 "${searchText}" 위치에 생성`); + + return fieldName; } } - throw new Error(errorMessage); + console.log(`⚠️ "${searchText}" 텍스트를 찾을 수 없음`); + return null; + + } catch (error) { + console.error(`📛 텍스트 검색 중 오류: ${error}`); + return null; } } @@ -163,11 +222,18 @@ class AutoSignatureFieldDetector { const flags = new Annotations.WidgetFlags(); flags.set('Required', true); - const field = new Core.Annotations.Forms.Field(fieldName, { type: 'Sig', flags }); + const field = new Core.Annotations.Forms.Field(fieldName, { + type: 'Sig', + flags + }); + + const widget = new Annotations.SignatureWidgetAnnotation(field, { + Width: 150, + Height: 50 + }); - const widget = new Annotations.SignatureWidgetAnnotation(field, { Width: 150, Height: 50 }); widget.setPageNumber(page); - + // 구매자 모드일 때는 왼쪽 하단으로 위치 설정 if (this.mode === 'buyer') { widget.setX(w * 0.1); // 왼쪽 (10%) @@ -177,7 +243,7 @@ class AutoSignatureFieldDetector { widget.setX(w * 0.7); // 오른쪽 (70%) widget.setY(h * 0.85); // 하단 (85%) } - + widget.setWidth(150); widget.setHeight(50); @@ -190,6 +256,7 @@ class AutoSignatureFieldDetector { } } + function useAutoSignatureFields(instance: WebViewerInstance | null, mode: 'vendor' | 'buyer' = 'vendor') { const [signatureFields, setSignatureFields] = useState<string[]>([]); const [isProcessing, setIsProcessing] = useState(false); @@ -280,15 +347,23 @@ function useAutoSignatureFields(instance: WebViewerInstance | null, mode: 'vendo } if (fields.length > 0) { + const hasTextBasedField = fields.some(field => field.startsWith('signature_at_text_')); const hasSimpleField = fields.some(field => field.startsWith('simple_signature_')); - if (hasSimpleField) { - const positionMessage = mode === 'buyer' + if (hasTextBasedField) { + const searchText = mode === 'buyer' ? '삼성중공업_서명란' : '협력업체_서명란'; + toast.success(`📝 "${searchText}" 위치에 서명 필드가 생성되었습니다.`, { + description: "해당 텍스트 근처의 파란색 영역에서 서명해주세요.", + icon: <FileSignature className="h-4 w-4 text-blue-500" />, + duration: 5000 + }); + } else if (hasSimpleField) { + const positionMessage = mode === 'buyer' ? "마지막 페이지 왼쪽 하단의 파란색 영역에서 서명해주세요." : "마지막 페이지 하단의 파란색 영역에서 서명해주세요."; - toast.success("📝 서명 필드가 생성되었습니다.", { - description: positionMessage, + toast.info("📝 기본 위치에 서명 필드가 생성되었습니다.", { + description: `검색 텍스트를 찾을 수 없어 ${positionMessage}`, icon: <FileSignature className="h-4 w-4 text-blue-500" />, duration: 5000 }); @@ -508,7 +583,16 @@ export function BasicContractSignViewer({ const [isInitialLoaded, setIsInitialLoaded] = useState<boolean>(false); const [surveyTemplate, setSurveyTemplate] = useState<SurveyTemplateWithQuestions | null>(null); const [surveyLoading, setSurveyLoading] = useState<boolean>(false); - const [gtcCommentStatus, setGtcCommentStatus] = useState<{ hasComments: boolean; commentCount: number }>({ hasComments: false, commentCount: 0 }); + const [gtcCommentStatus, setGtcCommentStatus] = useState<{ + hasComments: boolean; + commentCount: number; + reviewStatus?: string; + isComplete?: boolean; + }>({ + hasComments: false, + commentCount: 0, + isComplete: false + }); console.log(surveyTemplate, "surveyTemplate") @@ -739,9 +823,12 @@ export function BasicContractSignViewer({ stamp.Width = Width; stamp.Height = Height; - await stamp.setImageData(signatureImage.data.dataUrl); - annot.sign(stamp); - annot.setFieldFlag(WidgetFlags.READ_ONLY, true); + if (signatureImage) { + await stamp.setImageData(signatureImage.data.dataUrl); + annot.sign(stamp); + annot.setFieldFlag(WidgetFlags.READ_ONLY, true); + } + } } }); @@ -994,8 +1081,8 @@ export function BasicContractSignViewer({ return; } - if (isGTCTemplate && gtcCommentStatus.hasComments) { - toast.error("GTC 조항에 코멘트가 있어 서명할 수 없습니다."); + if (isGTCTemplate && gtcCommentStatus.hasComments && !gtcCommentStatus.isComplete) { + toast.error("GTC 조항에 미해결 코멘트가 있어 서명할 수 없습니다."); toast.info("모든 코멘트를 삭제하거나 협의를 완료한 후 서명해주세요."); setActiveTab('clauses'); return; @@ -1130,9 +1217,9 @@ export function BasicContractSignViewer({ {/* GTC 조항 컴포넌트 */} <GtcClausesComponent contractId={contractId} - onCommentStatusChange={(hasComments, commentCount) => { - setGtcCommentStatus({ hasComments, commentCount }); - onGtcCommentStatusChange?.(hasComments, commentCount); + onCommentStatusChange={(hasComments, commentCount, reviewStatus, isComplete) => { + setGtcCommentStatus({ hasComments, commentCount, reviewStatus, isComplete }); + onGtcCommentStatusChange?.(hasComments, commentCount, reviewStatus, isComplete); }} t={t} /> @@ -1193,9 +1280,14 @@ export function BasicContractSignViewer({ ); } - const handleGtcCommentStatusChange = React.useCallback((hasComments: boolean, commentCount: number) => { - setGtcCommentStatus({ hasComments, commentCount }); - onGtcCommentStatusChange?.(hasComments, commentCount); + const handleGtcCommentStatusChange = React.useCallback(( + hasComments: boolean, + commentCount: number, + reviewStatus?: string, + isComplete?: boolean + ) => { + setGtcCommentStatus({ hasComments, commentCount, reviewStatus, isComplete }); + onGtcCommentStatusChange?.(hasComments, commentCount, reviewStatus, isComplete); }, [onGtcCommentStatusChange]); // 다이얼로그 뷰어 렌더링 @@ -1230,6 +1322,16 @@ export function BasicContractSignViewer({ ⚠️ GTC 조항에 {gtcCommentStatus.commentCount}개의 코멘트가 있어 서명할 수 없습니다. </span> )} + {mode !== 'buyer' && isGTCTemplate && gtcCommentStatus.hasComments && !gtcCommentStatus.isComplete && ( + <span className="block mt-1 text-red-600"> + ⚠️ GTC 조항에 {gtcCommentStatus.commentCount}개의 미해결 코멘트가 있어 서명할 수 없습니다. + </span> + )} + {mode !== 'buyer' && isGTCTemplate && gtcCommentStatus.hasComments && gtcCommentStatus.isComplete && ( + <span className="block mt-1 text-green-600"> + ✅ GTC 조항 협의가 완료되어 서명 가능합니다. + </span> + )} </DialogDescription> </DialogHeader> |
