diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-11-07 02:59:13 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-11-07 02:59:13 +0000 |
| commit | cc2dc9c162ceadfc41f8fa1fcd4c82341c33af7c (patch) | |
| tree | 1062f38fa8c4f0fb2302843ab089e76c3582b93e | |
| parent | 5c02b1cc95503cdd7bcd70c6e5fad848250685ec (diff) | |
(임수민) 기본계약서 수정하기 리비전 수정, 서명란 바로가기 버튼 추가
| -rw-r--r-- | lib/basic-contract/service.ts | 50 | ||||
| -rw-r--r-- | lib/basic-contract/template/basic-contract-template-viewer.tsx | 159 | ||||
| -rw-r--r-- | lib/basic-contract/viewer/basic-contract-sign-viewer.tsx | 153 |
3 files changed, 314 insertions, 48 deletions
diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts index 8c29dbf2..2fcebd59 100644 --- a/lib/basic-contract/service.ts +++ b/lib/basic-contract/service.ts @@ -400,15 +400,41 @@ export async function updateTemplate({ unstable_noStore();
try {
+ // 기존 템플릿 조회 (revision 유지 및 중복 체크를 위해)
+ const existingTemplate = await db.query.basicContractTemplates.findFirst({
+ where: eq(basicContractTemplates.id, id),
+ });
+
+ if (!existingTemplate) {
+ return { error: "템플릿을 찾을 수 없습니다." };
+ }
+
// 필수값
const templateName = formData.get("templateName") as string | null;
if (!templateName) {
return { error: "템플릿 이름은 필수입니다." };
}
- // 선택/추가 필드 파싱
- const revisionStr = formData.get("revision")?.toString() ?? "1";
- const revision = Number(revisionStr) || 1;
+ // revision 처리: FormData에 있으면 사용, 없으면 기존 값 유지
+ const revisionStr = formData.get("revision")?.toString();
+ const revision = revisionStr ? Number(revisionStr) : existingTemplate.revision;
+
+ // templateName과 revision 조합이 unique이므로, 다른 레코드와 중복되는지 확인
+ if (templateName !== existingTemplate.templateName || revision !== existingTemplate.revision) {
+ const duplicateCheck = await db.query.basicContractTemplates.findFirst({
+ where: and(
+ eq(basicContractTemplates.templateName, templateName),
+ eq(basicContractTemplates.revision, revision),
+ ne(basicContractTemplates.id, id) // 자기 자신은 제외
+ ),
+ });
+
+ if (duplicateCheck) {
+ return {
+ error: `템플릿 이름 "${templateName}"과 리비전 ${revision} 조합이 이미 존재합니다. 다른 리비전을 사용하거나 템플릿 이름을 변경해주세요.`
+ };
+ }
+ }
const legalReviewRequired = getBool(formData, "legalReviewRequired", false);
@@ -432,13 +458,11 @@ export async function updateTemplate({ if (file) {
// 1) 새 파일 저장 (DRM 해제 로직 적용)
-
- const saveResult = await saveDRMFile(
- file,
- decryptWithServerAction,
- 'basicContract/template'
- );
-
+ const saveResult = await saveDRMFile(
+ file,
+ decryptWithServerAction,
+ 'basicContract/template'
+ );
if (!saveResult.success) {
return { success: false, error: saveResult.error };
@@ -446,11 +470,7 @@ export async function updateTemplate({ fileName = file.name;
filePath = saveResult.publicPath;
- // 2) 기존 파일 삭제
- const existingTemplate = await db.query.basicContractTemplates.findFirst({
- where: eq(basicContractTemplates.id, id),
- });
-
+ // 2) 기존 파일 삭제 (existingTemplate은 이미 위에서 조회됨)
if (existingTemplate?.filePath) {
const deleted = await deleteFile(existingTemplate.filePath);
if (deleted) {
diff --git a/lib/basic-contract/template/basic-contract-template-viewer.tsx b/lib/basic-contract/template/basic-contract-template-viewer.tsx index 59989e46..018db3a0 100644 --- a/lib/basic-contract/template/basic-contract-template-viewer.tsx +++ b/lib/basic-contract/template/basic-contract-template-viewer.tsx @@ -8,8 +8,9 @@ import React, { Dispatch, } from "react"; import { WebViewerInstance } from "@pdftron/webviewer"; -import { Loader2 } from "lucide-react"; +import { Loader2, Target } from "lucide-react"; import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; interface BasicContractTemplateViewerProps { templateId?: number; @@ -18,6 +19,98 @@ interface BasicContractTemplateViewerProps { setInstance: Dispatch<SetStateAction<WebViewerInstance | null>>; } +// 서명란 위치 정보 인터페이스 +interface SignatureFieldLocation { + pageNumber: number; + x: number; + y: number; + searchText: string; +} + +// 서명란으로 이동하는 함수 +const navigateToSignatureField = async ( + instance: WebViewerInstance | null, + location: SignatureFieldLocation | null +) => { + if (!instance || !location) { + toast.error("서명란 위치를 찾을 수 없습니다."); + return; + } + + try { + const { documentViewer } = instance.Core; + + // 해당 페이지로 이동 + documentViewer.setCurrentPage(location.pageNumber, true); + + // 페이지 로드 후 알림 + setTimeout(() => { + toast.success(`서명란으로 이동했습니다. (페이지 ${location.pageNumber})`, { + description: `"${location.searchText}" 텍스트를 찾아주세요.` + }); + }, 500); + } catch (error) { + console.error("서명란으로 이동 실패:", error); + toast.error("서명란으로 이동하는데 실패했습니다."); + } +}; + +// 서명란 텍스트 찾기 함수 +const findSignatureFieldLocation = async ( + instance: WebViewerInstance | null +): Promise<SignatureFieldLocation | null> => { + if (!instance?.Core?.documentViewer) { + return null; + } + + try { + const { documentViewer } = instance.Core; + const doc = documentViewer.getDocument(); + if (!doc) return null; + + // 검색할 텍스트 목록 (협력업체와 구매자 모두) + const searchTexts = ['협력업체_서명란', '삼성중공업_서명란']; + const pageCount = documentViewer.getPageCount(); + + for (const searchText of searchTexts) { + for (let pageNumber = 1; pageNumber <= pageCount; pageNumber++) { + try { + const pageText = await doc.loadPageText(pageNumber); + if (!pageText) continue; + + const startIndex = pageText.indexOf(searchText); + if (startIndex === -1) continue; + + const endIndex = startIndex + searchText.length; + + // 텍스트 위치 가져오기 + const quads = await doc.getTextPosition(pageNumber, startIndex, endIndex); + if (!quads || quads.length === 0) continue; + + const q = quads[0] as any; + const x = Math.min(q.x1, q.x2, q.x3, q.x4); + const y = Math.min(q.y1, q.y2, q.y3, q.y4); + + return { + pageNumber, + x, + y, + searchText + }; + } catch (error) { + console.warn(`페이지 ${pageNumber}에서 ${searchText} 검색 실패:`, error); + continue; + } + } + } + + return null; + } catch (error) { + console.error("서명란 위치 찾기 실패:", error); + return null; + } +}; + export function BasicContractTemplateViewer({ templateId, filePath, @@ -25,6 +118,7 @@ export function BasicContractTemplateViewer({ setInstance, }: BasicContractTemplateViewerProps) { const [fileLoading, setFileLoading] = useState<boolean>(true); + const [signatureLocation, setSignatureLocation] = useState<SignatureFieldLocation | null>(null); const viewer = useRef<HTMLDivElement>(null); const initialized = useRef(false); const isCancelled = useRef(false); @@ -54,7 +148,6 @@ export function BasicContractTemplateViewer({ fullAPI: true, // 한글 입력 지원을 위한 설정 enableOfficeEditing: true, // Office 편집 모드에서 IME 지원 필요 - l: "ko", // 한국어 로케일 설정 }, viewerElement ).then((instance: WebViewerInstance) => { @@ -94,7 +187,7 @@ export function BasicContractTemplateViewer({ // IME 지원 활성화 const documentBody = iframeWindow.document.body; if (documentBody) { - documentBody.style.imeMode = 'active'; + (documentBody.style as any).imeMode = 'active'; documentBody.setAttribute('lang', 'ko-KR'); } @@ -138,6 +231,40 @@ export function BasicContractTemplateViewer({ loadDocument(instance, filePath); }, [instance, filePath]); + // 문서 로드 후 서명란 위치 찾기 + useEffect(() => { + if (!instance) return; + + const { documentViewer } = instance.Core; + + const handleDocumentLoaded = async () => { + // 문서 로드 완료 후 서명란 위치 찾기 + setTimeout(async () => { + const location = await findSignatureFieldLocation(instance); + if (location) { + setSignatureLocation(location); + console.log("✅ 서명란 위치 발견:", location); + } + }, 2000); + }; + + documentViewer.addEventListener('documentLoaded', handleDocumentLoaded); + + // 이미 문서가 로드되어 있다면 즉시 실행 + if (documentViewer.getDocument()) { + setTimeout(async () => { + const location = await findSignatureFieldLocation(instance); + if (location) { + setSignatureLocation(location); + } + }, 2000); + } + + return () => { + documentViewer.removeEventListener('documentLoaded', handleDocumentLoaded); + }; + }, [instance]); + // 한글 지원 Office 문서 로드 const loadDocument = async (instance: WebViewerInstance, documentPath: string) => { setFileLoading(true); @@ -157,11 +284,6 @@ export function BasicContractTemplateViewer({ await instance.UI.loadDocument(fullPath, { filename: fileName, enableOfficeEditing: true, - // 한글 입력 지원을 위한 추가 옵션 - officeOptions: { - locale: 'ko-KR', - enableIME: true, - } }); // 문서 로드 후 한글 입력 환경 설정 @@ -174,9 +296,9 @@ export function BasicContractTemplateViewer({ iframeWindow.document.querySelector('.office-editor') || iframeWindow.document.body; - if (officeContainer) { + if (officeContainer && officeContainer instanceof HTMLElement) { // 한글 입력 최적화 설정 - officeContainer.style.imeMode = 'active'; + officeContainer.style.setProperty('ime-mode', 'active'); officeContainer.setAttribute('lang', 'ko-KR'); officeContainer.setAttribute('inputmode', 'text'); @@ -203,7 +325,22 @@ export function BasicContractTemplateViewer({ // 기존 SignViewer와 동일한 렌더링 (확대 문제 해결) return ( - <div className="relative w-full h-full overflow-hidden"> + <div className="relative w-full h-full overflow-hidden flex flex-col"> + {/* 서명란으로 이동 버튼 */} + {signatureLocation && !fileLoading && ( + <div className="absolute top-2 right-2 z-20"> + <Button + variant="outline" + size="sm" + className="h-7 px-2 text-xs bg-blue-50 hover:bg-blue-100 border-blue-200" + onClick={() => navigateToSignatureField(instance, signatureLocation)} + > + <Target className="h-3 w-3 mr-1" /> + 서명란으로 이동 + </Button> + </div> + )} + <div ref={viewer} className="w-full h-full" diff --git a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx index 246c5200..d1492fdb 100644 --- a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx +++ b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx @@ -66,16 +66,31 @@ interface SignaturePattern { height?: number; } +// 서명 필드 위치 정보를 저장하는 인터페이스 +interface SignatureFieldLocation { + pageNumber: number; + x: number; + y: number; + fieldName: string; + searchText: string; +} + // 초간단 안전한 서명 필드 감지 클래스 class AutoSignatureFieldDetector { private instance: WebViewerInstance; private mode: 'vendor' | 'buyer'; + private location: SignatureFieldLocation | null = null; // 위치 정보 저장 constructor(instance: WebViewerInstance, mode: 'vendor' | 'buyer' = 'vendor') { this.instance = instance; this.mode = mode; } + // 위치 정보 getter 추가 + getLocation(): SignatureFieldLocation | null { + return this.location; + } + async detectAndCreateSignatureFields(): Promise<string[]> { console.log(`🔍 텍스트 기반 서명 필드 감지 시작... (모드: ${this.mode})`); @@ -149,13 +164,24 @@ class AutoSignatureFieldDetector { if (!quads || quads.length === 0) continue; // 첫 글자의 quad만 사용해 대략적인 위치 산출 - const q = quads[0]; + const q = quads[0] as any; // PDFTron의 Quad 타입 const x = Math.min(q.x1, q.x2, q.x3, q.x4); const y = Math.min(q.y1, q.y2, q.y3, q.y4); const textHeight = Math.abs(q.y3 - q.y1); // 4) 서명 필드 생성 const fieldName = `signature_at_text_${Date.now()}`; + const signatureY = y + textHeight + 5; + + // 위치 정보 저장 + this.location = { + pageNumber, + x, + y: signatureY, + fieldName, + searchText + }; + const flags = new Annotations.WidgetFlags(); flags.set('Required', true); @@ -165,7 +191,7 @@ class AutoSignatureFieldDetector { widget.setPageNumber(pageNumber); // 텍스트 바로 아래에 배치 (필요하면 오른쪽 배치로 바꿀 수 있음) widget.setX(x); - widget.setY(y + textHeight + 5); + widget.setY(signatureY); widget.setWidth(150); widget.setHeight(50); @@ -194,6 +220,20 @@ class AutoSignatureFieldDetector { const h = documentViewer.getPageHeight(page) || 792; const fieldName = `simple_signature_${Date.now()}`; + + // 구매자 모드일 때는 왼쪽 하단으로 위치 설정 + const x = this.mode === 'buyer' ? w * 0.1 : w * 0.7; + const y = h * 0.85; + + // 위치 정보 저장 + this.location = { + pageNumber: page, + x, + y, + fieldName, + searchText: this.mode === 'buyer' ? '삼성중공업_서명란' : '협력업체_서명란' + }; + const flags = new Annotations.WidgetFlags(); flags.set('Required', true); @@ -208,17 +248,8 @@ class AutoSignatureFieldDetector { }); widget.setPageNumber(page); - - // 구매자 모드일 때는 왼쪽 하단으로 위치 설정 - if (this.mode === 'buyer') { - widget.setX(w * 0.1); // 왼쪽 (10%) - widget.setY(h * 0.85); // 하단 (85%) - } else { - // 협력업체 모드일 때는 기존처럼 오른쪽 - widget.setX(w * 0.7); // 오른쪽 (70%) - widget.setY(h * 0.85); // 하단 (85%) - } - + widget.setX(x); + widget.setY(y); widget.setWidth(150); widget.setHeight(50); @@ -298,6 +329,7 @@ function useAutoSignatureFields(instance: WebViewerInstance | null, mode: 'vendo const [signatureFields, setSignatureFields] = useState<string[]>([]); const [isProcessing, setIsProcessing] = useState(false); const [error, setError] = useState<string | null>(null); + const [signatureLocation, setSignatureLocation] = useState<SignatureFieldLocation | null>(null); // 위치 정보 state 추가 // 한 번만 실행되도록 보장하는 플래그들 const processingRef = useRef(false); @@ -377,6 +409,12 @@ function useAutoSignatureFields(instance: WebViewerInstance | null, mode: 'vendo const fields = await detector.detectAndCreateSignatureFields(); setSignatureFields(fields); + + // 위치 정보 저장 + const location = detector.getLocation(); + if (location) { + setSignatureLocation(location); + } // 처리 완료 표시 if (documentId) { @@ -477,9 +515,58 @@ function useAutoSignatureFields(instance: WebViewerInstance | null, mode: 'vendo signatureFields, isProcessing, hasSignatureFields: signatureFields.length > 0, - error + error, + signatureLocation // 위치 정보 반환 }; } + +// 서명란으로 이동하는 함수 추가 +const navigateToSignatureField = ( + instance: WebViewerInstance | null, + location: SignatureFieldLocation | null +) => { + if (!instance || !location) { + toast.error("서명란 위치를 찾을 수 없습니다."); + return; + } + + try { + const { documentViewer, annotationManager } = instance.Core; + + // 1. 해당 페이지로 이동 + documentViewer.setCurrentPage(location.pageNumber); + + // 2. 서명 필드 위젯 찾기 + const annotations = annotationManager.getAnnotationsList(); + const signatureWidget = annotations.find( + (annot: any) => annot instanceof instance.Core.Annotations.SignatureWidgetAnnotation && + annot.getField()?.name === location.fieldName + ); + + if (signatureWidget) { + // 위젯이 있으면 선택하여 자동으로 해당 위치로 스크롤 + annotationManager.selectAnnotation(signatureWidget); + + // 추가로 스크롤 위치 조정 + setTimeout(() => { + try { + // annotation을 다시 선택하여 스크롤 보장 + annotationManager.selectAnnotation(signatureWidget); + } catch (scrollError) { + console.warn("스크롤 조정 실패:", scrollError); + } + }, 300); + + toast.success(`서명란으로 이동했습니다. (페이지 ${location.pageNumber})`); + } else { + // 위젯을 찾지 못한 경우 페이지만 이동 + toast.info(`페이지 ${location.pageNumber}로 이동했습니다.`); + } + } catch (error) { + console.error("서명란으로 이동 실패:", error); + toast.error("서명란으로 이동하는데 실패했습니다."); + } +}; // XFDF 기반 서명 감지 function useSignatureDetection(instance: WebViewerInstance | null, onSignatureComplete?: () => void) { const [hasValidSignature, setHasValidSignature] = useState(false); @@ -644,8 +731,14 @@ export function BasicContractSignViewer({ const [showDialog, setShowDialog] = useState(isOpen); const webViewerInstance = useRef<WebViewerInstance | null>(null); - // mode 전달 - const { signatureFields, isProcessing: isAutoSignProcessing, hasSignatureFields, error: autoSignError } = useAutoSignatureFields(webViewerInstance.current || instance, mode); + // mode 전달 - signatureLocation도 받아오기 + const { + signatureFields, + isProcessing: isAutoSignProcessing, + hasSignatureFields, + error: autoSignError, + signatureLocation // 위치 정보 추가 + } = useAutoSignatureFields(webViewerInstance.current || instance, mode); const { hasValidSignature } = useSignatureDetection(webViewerInstance.current || instance, onSignatureComplete); @@ -887,8 +980,9 @@ export function BasicContractSignViewer({ stamp.Width = Width; stamp.Height = Height; - if (signatureImage) { - await stamp.setImageData(signatureImage.data.dataUrl); + const dataUrl = signatureImage?.data?.dataUrl; + if (dataUrl) { + await stamp.setImageData(dataUrl); annot.sign(stamp); annot.setFieldFlag(WidgetFlags.READ_ONLY, true); } @@ -1182,6 +1276,8 @@ export function BasicContractSignViewer({ const SignatureFieldsStatus = () => { if (!hasSignatureFields && !isAutoSignProcessing && !autoSignError && !hasValidSignature) return null; + const currentInstance = webViewerInstance.current || instance; + return ( <div className="mb-2 flex items-center space-x-2"> {isAutoSignProcessing ? ( @@ -1195,10 +1291,23 @@ export function BasicContractSignViewer({ 자동 생성 실패 </Badge> ) : hasSignatureFields ? ( - <Badge variant="outline" className="text-xs bg-blue-50 text-blue-700 border-blue-200"> - <Target className="h-3 w-3 mr-1" /> - {signatureFields.length}개 서명 필드 자동 생성됨 {mode === 'buyer' ? '(왼쪽 하단)' : ''} - </Badge> + <> + <Badge variant="outline" className="text-xs bg-blue-50 text-blue-700 border-blue-200"> + <Target className="h-3 w-3 mr-1" /> + {signatureFields.length}개 서명 필드 자동 생성됨 {mode === 'buyer' ? '(왼쪽 하단)' : ''} + </Badge> + {signatureLocation && ( + <Button + variant="ghost" + size="sm" + className="h-6 px-2 text-xs hover:bg-blue-50" + onClick={() => navigateToSignatureField(currentInstance, signatureLocation)} + > + <Target className="h-3 w-3 mr-1" /> + 서명란으로 이동 + </Button> + )} + </> ) : null} {hasValidSignature && ( |
