diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-01 09:12:09 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-01 09:12:09 +0000 |
| commit | 18954df6565108a469fb1608ea3715dd9bb1b02d (patch) | |
| tree | 2675d254c547861a903a32459d89283a324e0e0d /lib/basic-contract/gtc-vendor/clause-preview-viewer.tsx | |
| parent | f91cd16a872d9cda04aeb5c4e31538e3e2bd1895 (diff) | |
(대표님) 구매 기본계약, gtc 개발
Diffstat (limited to 'lib/basic-contract/gtc-vendor/clause-preview-viewer.tsx')
| -rw-r--r-- | lib/basic-contract/gtc-vendor/clause-preview-viewer.tsx | 570 |
1 files changed, 570 insertions, 0 deletions
diff --git a/lib/basic-contract/gtc-vendor/clause-preview-viewer.tsx b/lib/basic-contract/gtc-vendor/clause-preview-viewer.tsx new file mode 100644 index 00000000..f979f0ea --- /dev/null +++ b/lib/basic-contract/gtc-vendor/clause-preview-viewer.tsx @@ -0,0 +1,570 @@ +"use client" + +import React, { + useState, + useEffect, + useRef, + SetStateAction, + Dispatch, +} from "react" +import { WebViewerInstance } from "@pdftron/webviewer" +import { Loader2 } from "lucide-react" +import { toast } from "sonner" + +import { type GtcClauseTreeView } from "@/db/schema/gtc" + +interface ClausePreviewViewerProps { + clauses: GtcClauseTreeView[] + document: any + instance: WebViewerInstance | null + setInstance: Dispatch<SetStateAction<WebViewerInstance | null>> + onSuccess?: () => void + onError?: () => void +} + +export function ClausePreviewViewer({ + clauses, + document, + instance, + setInstance, + onSuccess, + onError, +}: ClausePreviewViewerProps) { + const [fileLoading, setFileLoading] = useState<boolean>(true) + const [loadingStage, setLoadingStage] = useState<string>("뷰어 준비 중...") + const viewer = useRef<HTMLDivElement>(null) + const initialized = useRef(false) + const isCancelled = useRef(false) + + // WebViewer 초기화 (단계별) + useEffect(() => { + if (!initialized.current && viewer.current) { + initialized.current = true + isCancelled.current = false + + initializeViewerStepByStep() + } + + return () => { + if (instance) { + try { + instance.UI.dispose() + } catch (error) { + console.warn("뷰어 정리 중 오류:", error) + } + } + isCancelled.current = true; + setTimeout(() => cleanupHtmlStyle(), 500); + } + }, []) + + const initializeViewerStepByStep = async () => { + try { + setLoadingStage("라이브러리 로딩 중...") + + // 1단계: 라이브러리 동적 import (지연 추가) + await new Promise(resolve => setTimeout(resolve, 300)) + const { default: WebViewer } = await import("@pdftron/webviewer") + + if (isCancelled.current || !viewer.current) { + console.log("📛 WebViewer 초기화 취소됨") + return + } + + setLoadingStage("뷰어 초기화 중...") + + // 2단계: WebViewer 인스턴스 생성 + const webviewerInstance = await WebViewer( + { + path: "/pdftronWeb", + licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, + fullAPI: true, + enableOfficeEditing: true, + l: "ko", + enableReadOnlyMode: false, + }, + viewer.current + ) + + if (isCancelled.current) { + console.log("📛 WebViewer 초기화 취소됨") + return + } + + setInstance(webviewerInstance) + setLoadingStage("UI 설정 중...") + + // 3단계: UI 설정 (약간의 지연 후) + await new Promise(resolve => setTimeout(resolve, 500)) + await configureViewerUI(webviewerInstance) + + setLoadingStage("문서 생성 중...") + + // 4단계: 문서 생성 (충분한 지연 후) + await new Promise(resolve => setTimeout(resolve, 800)) + await generateDocumentFromClauses(webviewerInstance, clauses, document) + + } catch (error) { + console.error("❌ WebViewer 단계별 초기화 실패:", error) + setFileLoading(false) + onError?.() // 초기화 실패 콜백 호출 + toast.error(`뷰어 초기화 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) + } + } + + const configureViewerUI = async (webviewerInstance: WebViewerInstance) => { + try { + const { disableElements, enableElements, setToolbarGroup } = webviewerInstance.UI + + // 미리보기에 필요한 도구만 활성화 + enableElements([ + "toolbarGroup-View", + "zoomInButton", + "zoomOutButton", + "fitButton", + "rotateCounterClockwiseButton", + "rotateClockwiseButton", + ]) + + // 편집 도구는 비활성화 + disableElements([ + "toolbarGroup-Edit", + "toolbarGroup-Insert", + "toolbarGroup-Annotate", + "toolbarGroup-Shapes", + "toolbarGroup-Forms", + ]) + + setToolbarGroup("toolbarGroup-View") + + console.log("✅ UI 설정 완료") + } catch (uiError) { + console.warn("⚠️ UI 설정 중 오류:", uiError) + // UI 설정 실패해도 계속 진행 + } + } + + // 문서 생성 함수 (재시도 로직 포함) + const generateDocumentFromClauses = async ( + webviewerInstance: WebViewerInstance, + clauses: GtcClauseTreeView[], + document: any, + retryCount = 0 + ) => { + const MAX_RETRIES = 3 + + try { + console.log("📄 조항 기반 DOCX 문서 생성 시작:", clauses.length) + + // 활성화된 조항만 필터링하고 정렬 + const activeClauses = clauses + .filter(clause => clause.isActive !== false) + .sort((a, b) => { + if (a.sortOrder && b.sortOrder) { + return parseFloat(a.sortOrder) - parseFloat(b.sortOrder) + } + return a.itemNumber.localeCompare(b.itemNumber, undefined, { numeric: true }) + }) + + if (activeClauses.length === 0) { + throw new Error("활성화된 조항이 없습니다.") + } + + setLoadingStage(`문서 생성 중... (${activeClauses.length}개 조항 처리)`) + + // DOCX 문서 생성 (재시도 로직 포함) + const docxBlob = await generateDocxDocumentWithRetry(activeClauses, document) + + // 파일 생성 + const docxFile = new File([docxBlob], `${document?.title || 'GTC계약서'}_미리보기.docx`, { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + }) + + setLoadingStage("문서 로딩 중...") + + // WebViewer가 완전히 준비된 상태인지 확인 + await waitForViewerReady(webviewerInstance) + + // DOCX 문서 로드 (재시도 포함) + await loadDocumentWithRetry(webviewerInstance, docxFile) + + console.log("✅ DOCX 기반 문서 생성 완료") + toast.success("Word 문서 미리보기가 생성되었습니다.") + setFileLoading(false) + onSuccess?.() // 성공 콜백 호출 + + } catch (err) { + console.error(`❌ DOCX 문서 생성 중 오류 (시도 ${retryCount + 1}/${MAX_RETRIES + 1}):`, err) + + if (retryCount < MAX_RETRIES) { + console.log(`🔄 ${(retryCount + 1) * 1000}ms 후 재시도...`) + setLoadingStage(`재시도 중... (${retryCount + 1}/${MAX_RETRIES})`) + + await new Promise(resolve => setTimeout(resolve, (retryCount + 1) * 1000)) + + if (!isCancelled.current) { + return generateDocumentFromClauses(webviewerInstance, clauses, document, retryCount + 1) + } + } else { + setFileLoading(false) + onError?.() // 실패 콜백 호출 + toast.error(`문서 생성 실패: ${err instanceof Error ? err.message : '알 수 없는 오류'}`) + } + } + } + + // WebViewer 준비 상태 확인 + const waitForViewerReady = async (webviewerInstance: WebViewerInstance, timeout = 5000) => { + const startTime = Date.now() + + while (Date.now() - startTime < timeout) { + try { + // UI가 준비되었는지 확인 + if (webviewerInstance.UI && webviewerInstance.Core) { + console.log("✅ WebViewer 준비 완료") + return + } + } catch (error) { + // 아직 준비되지 않음 + } + + await new Promise(resolve => setTimeout(resolve, 100)) + } + + throw new Error("WebViewer 준비 시간 초과") + } + + // 문서 로드 재시도 함수 + const loadDocumentWithRetry = async ( + webviewerInstance: WebViewerInstance, + file: File, + retryCount = 0 + ) => { + const MAX_LOAD_RETRIES = 2 + + try { + await webviewerInstance.UI.loadDocument(file, { + filename: file.name, + enableOfficeEditing: true, + }) + console.log("✅ 문서 로드 성공") + } catch (error) { + console.error(`문서 로드 실패 (시도 ${retryCount + 1}):`, error) + + if (retryCount < MAX_LOAD_RETRIES) { + await new Promise(resolve => setTimeout(resolve, 1000)) + return loadDocumentWithRetry(webviewerInstance, file, retryCount + 1) + } else { + throw new Error(`문서 로드 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) + } + } + } + + // DOCX 생성 재시도 함수 + const generateDocxDocumentWithRetry = async ( + clauses: GtcClauseTreeView[], + document: any, + retryCount = 0 + ): Promise<Blob> => { + try { + return await generateDocxDocument(clauses, document) + } catch (error) { + console.error(`DOCX 생성 실패 (시도 ${retryCount + 1}):`, error) + + if (retryCount < 2) { + await new Promise(resolve => setTimeout(resolve, 500)) + return generateDocxDocumentWithRetry(clauses, document, retryCount + 1) + } else { + throw error + } + } + } + + return ( + <div className="relative w-full h-full overflow-hidden"> + <div + ref={viewer} + className="w-full h-full" + style={{ + position: 'relative', + overflow: 'hidden', + contain: 'layout style paint', + }} + > + {fileLoading && ( + <div className="absolute inset-0 flex flex-col items-center justify-center bg-white bg-opacity-90 z-10"> + <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> + <p className="text-sm text-muted-foreground">{loadingStage}</p> + <p className="text-xs text-muted-foreground mt-1"> + {clauses.filter(c => c.isActive !== false).length}개 조항 처리 중 + </p> + <div className="mt-3 text-xs text-gray-400"> + 초기화에 시간이 걸릴 수 있습니다... + </div> + </div> + )} + </div> + </div> + ) +} + +// ===== 유틸리티 함수들 ===== + +// data URL 판별 및 디코딩 유틸 +function isDataUrl(url: string) { + return /^data:/.test(url); +} + +function dataUrlToUint8Array(dataUrl: string): { bytes: Uint8Array; mime: string } { + // 형식: data:<mime>;base64,<payload> + const match = dataUrl.match(/^data:([^;]+);base64,(.*)$/); + if (!match) { + // base64가 아닌 data URL도 가능하지만, 여기서는 base64만 지원 + throw new Error("지원하지 않는 data URL 형식입니다."); + } + const mime = match[1]; + const base64 = match[2]; + const binary = atob(base64); + const len = binary.length; + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i); + return { bytes, mime }; +} + +// 이미지 불러오기 + 크기 계산 (data:, http:, / 경로 모두 지원) +async function fetchImageData(url: string, maxWidthPx = 500) { + let blob: Blob; + let bytes: Uint8Array; + + if (isDataUrl(url)) { + // data URL → Uint8Array, Blob + const { bytes: arr, mime } = dataUrlToUint8Array(url); + bytes = arr; + blob = new Blob([bytes], { type: mime }); + } else { + // http(s) 또는 상대 경로 + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) throw new Error(`이미지 다운로드 실패 (${res.status})`); + blob = await res.blob(); + const arrayBuffer = await blob.arrayBuffer(); + bytes = new Uint8Array(arrayBuffer); + } + + // 원본 크기 파악 (공통) + const dims = await new Promise<{ width: number; height: number }>((resolve) => { + const img = new Image(); + const objectUrl = URL.createObjectURL(blob); + img.onload = () => { + const width = img.naturalWidth || 800; + const height = img.naturalHeight || 600; + URL.revokeObjectURL(objectUrl); + resolve({ width, height }); + }; + img.onerror = () => { + URL.revokeObjectURL(objectUrl); + resolve({ width: 800, height: 600 }); // 실패 시 기본값 + }; + img.src = objectUrl; + }); + + // 비율 유지 축소 + const scale = Math.min(1, maxWidthPx / (dims.width || maxWidthPx)); + const width = Math.round((dims.width || maxWidthPx) * scale); + const height = Math.round((dims.height || Math.round(maxWidthPx * 0.6)) * scale); + + return { data: bytes, width, height }; +} + +// DOCX 문서 생성 (docx 라이브러리 사용) +async function generateDocxDocument( + clauses: GtcClauseTreeView[], + document: any +): Promise<Blob> { + const { Document, Packer, Paragraph, TextRun, AlignmentType, ImageRun } = await import("docx"); + + function textToParagraphs(text: string, indentLeft: number) { + const lines = text.split("\n"); + return [ + new Paragraph({ + children: lines + .map((line, i) => [ + new TextRun({ text: line }), + ...(i < lines.length - 1 ? [new TextRun({ break: 1 })] : []), + ]) + .flat(), + indent: { left: indentLeft }, + }), + ]; + } + + const IMG_TOKEN = /!\[([^\]]+)\]/g; // 예: ![image1753698566087] + + async function pushContentWithInlineImages( + content: string, + indentLeft: number, + children: any[], + imageMap: Map<string, any> + ) { + let lastIndex = 0; + for (const match of content.matchAll(IMG_TOKEN)) { + const start = match.index ?? 0; + const end = start + match[0].length; + const imageId = match[1]; + + // 앞부분 텍스트 + if (start > lastIndex) { + const txt = content.slice(lastIndex, start); + children.push(...textToParagraphs(txt, indentLeft)); + } + + // 이미지 삽입 + const imgMeta = imageMap.get(imageId); + if (imgMeta?.url) { + try { + const { data, width, height } = await fetchImageData(imgMeta.url, 520); + children.push( + new Paragraph({ + children: [ + new ImageRun({ + data, + transformation: { width, height }, + }), + ], + indent: { left: indentLeft }, + }) + ); + // 사용된 이미지 표시(뒤에서 중복 추가 방지) + imageMap.delete(imageId); + } catch (imgError) { + console.warn("이미지 로드 실패:", imgMeta, imgError); + // 이미지 로드 실패시 텍스트로 대체 + children.push( + new Paragraph({ + children: [new TextRun({ text: `[이미지 로드 실패: ${imgMeta.fileName || imageId}]`, color: "999999" })], + indent: { left: indentLeft }, + }) + ); + } + } + // 매칭 실패 시: 아무것도 넣지 않음(토큰 제거) + + lastIndex = end; + } + + // 남은 꼬리 텍스트 + if (lastIndex < content.length) { + const tail = content.slice(lastIndex); + children.push(...textToParagraphs(tail, indentLeft)); + } + } + + const documentTitle = document?.title || "GTC 계약서"; + const currentDate = new Date().toLocaleDateString("ko-KR"); + + // depth 추정/정렬 + const structuredClauses = organizeClausesByHierarchy(clauses); + + const children: any[] = [ + new Paragraph({ + alignment: AlignmentType.CENTER, + children: [new TextRun({ text: documentTitle, bold: true, size: 32 })], + }), + new Paragraph({ + alignment: AlignmentType.CENTER, + children: [new TextRun({ text: `생성일: ${currentDate}`, size: 20, color: "666666" })], + }), + new Paragraph({ text: "" }), + new Paragraph({ text: "" }), + ]; + + for (const clause of structuredClauses) { + const depth = Math.min(clause.estimatedDepth || 0, 3); + const indentLeft = depth * 400; // 번호/제목 + const indentContent = indentLeft + 200; // 본문/이미지 + + // 번호 + 제목 + children.push( + new Paragraph({ + children: [ + new TextRun({ text: `${clause.itemNumber}${clause.subtitle ? "." : ""}`, bold: true, color: "2563eb" }), + ...(clause.subtitle + ? [new TextRun({ text: " " }), new TextRun({ text: clause.subtitle, bold: true })] + : []), + ], + indent: { left: indentLeft }, + }) + ); + + const imageMap = new Map( + Array.isArray((clause as any).images) + ? (clause as any).images.map((im: any) => [String(im.id), im]) + : [] + ); + + // 내용 + const hasContent = clause.content && clause.content.trim(); + if (hasContent) { + await pushContentWithInlineImages(clause.content!, indentContent, children, imageMap); + } + + // 본문에 등장하지 않은 잔여 이미지(선택: 뒤에 추가) + for (const [, imgMeta] of imageMap) { + try { + const { data, width, height } = await fetchImageData(imgMeta.url, 520); + children.push( + new Paragraph({ + children: [new ImageRun({ data, transformation: { width, height } })], + indent: { left: indentContent }, + }) + ); + } catch (e) { + children.push( + new Paragraph({ + children: [new TextRun({ text: `이미지 로드 실패: ${imgMeta.fileName || imgMeta.url}`, color: "b91c1c", size: 20 })], + indent: { left: indentContent }, + }) + ); + console.warn("이미지 로드 실패(잔여):", imgMeta, e); + } + } + + // 조항 간 간격 + children.push(new Paragraph({ text: "" })); + } + + const doc = new Document({ + sections: [{ properties: {}, children }], + }); + + return await Packer.toBlob(doc); +} + +// 조항들을 계층구조로 정리 +function organizeClausesByHierarchy(clauses: GtcClauseTreeView[]) { + // depth가 없는 경우 itemNumber로 depth 추정 + return clauses.map(clause => ({ + ...clause, + estimatedDepth: clause.depth ?? estimateDepthFromItemNumber(clause.itemNumber) + })).sort((a, b) => { + // itemNumber 기준 자연 정렬 + return a.itemNumber.localeCompare(b.itemNumber, undefined, { + numeric: true, + sensitivity: 'base' + }) + }) +} + +// itemNumber로부터 depth 추정 +function estimateDepthFromItemNumber(itemNumber: string): number { + const parts = itemNumber.split('.') + return Math.max(0, parts.length - 1) +} + +// WebViewer 정리 함수 +const cleanupHtmlStyle = () => { + // iframe 스타일 정리 (WebViewer가 추가한 스타일) + const elements = document.querySelectorAll('.Document_container'); + elements.forEach((elem) => { + elem.remove(); + }); +};
\ No newline at end of file |
