summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-11-07 02:59:13 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-11-07 02:59:13 +0000
commitcc2dc9c162ceadfc41f8fa1fcd4c82341c33af7c (patch)
tree1062f38fa8c4f0fb2302843ab089e76c3582b93e
parent5c02b1cc95503cdd7bcd70c6e5fad848250685ec (diff)
(임수민) 기본계약서 수정하기 리비전 수정, 서명란 바로가기 버튼 추가
-rw-r--r--lib/basic-contract/service.ts50
-rw-r--r--lib/basic-contract/template/basic-contract-template-viewer.tsx159
-rw-r--r--lib/basic-contract/viewer/basic-contract-sign-viewer.tsx153
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 && (