summaryrefslogtreecommitdiff
path: root/lib/basic-contract/viewer
diff options
context:
space:
mode:
Diffstat (limited to 'lib/basic-contract/viewer')
-rw-r--r--lib/basic-contract/viewer/GtcClausesComponent.tsx262
-rw-r--r--lib/basic-contract/viewer/basic-contract-sign-viewer.tsx256
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>