diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-28 11:44:16 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-28 11:44:16 +0000 |
| commit | c228a89c2834ee63b209bad608837c39643f350e (patch) | |
| tree | 39c9a121b556af872072dd80750dedf2d2d62335 /lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx | |
| parent | 50ae0b8f02c034e60d4cbb504620dfa1575a836f (diff) | |
(대표님) 의존성 docx 추가, basicContract API, gtc(계약일반조건), 벤더평가 esg 평가데이터 내보내기 개선, S-EDP 피드백 대응(CLS_ID, ITEM NO 등)
Diffstat (limited to 'lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx')
| -rw-r--r-- | lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx | 435 |
1 files changed, 435 insertions, 0 deletions
diff --git a/lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx b/lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx new file mode 100644 index 00000000..30e369b4 --- /dev/null +++ b/lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx @@ -0,0 +1,435 @@ +"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>> +} + +export function ClausePreviewViewer({ + clauses, + document, + instance, + setInstance, +}: ClausePreviewViewerProps) { + const [fileLoading, setFileLoading] = useState<boolean>(true) + 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 + + requestAnimationFrame(() => { + if (viewer.current) { + import("@pdftron/webviewer").then(({ default: WebViewer }) => { + if (isCancelled.current) { + console.log("📛 WebViewer 초기화 취소됨") + return + } + + const viewerElement = viewer.current + if (!viewerElement) return + + WebViewer( + { + path: "/pdftronWeb", + licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, + fullAPI: true, + enableOfficeEditing: true, + l: "ko", + // 미리보기 모드로 설정 + enableReadOnlyMode: false, + }, + viewerElement + ).then(async (instance: WebViewerInstance) => { + setInstance(instance) + + try { + const { disableElements, enableElements, setToolbarGroup } = instance.UI + + // 미리보기에 필요한 도구만 활성화 + enableElements([ + "toolbarGroup-View", + "zoomInButton", + "zoomOutButton", + "fitButton", + "rotateCounterClockwiseButton", + "rotateClockwiseButton", + ]) + + // 편집 도구는 비활성화 + disableElements([ + "toolbarGroup-Edit", + "toolbarGroup-Insert", + "toolbarGroup-Annotate", + "toolbarGroup-Shapes", + "toolbarGroup-Forms", + ]) + + setToolbarGroup("toolbarGroup-View") + + // 조항 데이터로 문서 생성 + await generateDocumentFromClauses(instance, clauses, document) + + } catch (uiError) { + console.warn("⚠️ UI 설정 중 오류:", uiError) + } finally { + setFileLoading(false) + } + }).catch((error) => { + console.error("❌ WebViewer 초기화 실패:", error) + setFileLoading(false) + toast.error("뷰어 초기화에 실패했습니다.") + }) + }) + } + }) + } + + return () => { + if (instance) { + instance.UI.dispose() + } + isCancelled.current = true + } + }, []) + + // 조항 데이터로 워드 문서 생성 + const generateDocumentFromClauses = async ( + instance: WebViewerInstance, + clauses: GtcClauseTreeView[], + document: any + ) => { + try { + console.log("📄 조항 기반 DOCX 문서 생성 시작:", clauses.length) + + // 활성화된 조항만 필터링하고 정렬 + const activeClauses = clauses + .filter(clause => clause.isActive !== false) + .sort((a, b) => { + // sortOrder 또는 itemNumber로 정렬 + if (a.sortOrder && b.sortOrder) { + return parseFloat(a.sortOrder) - parseFloat(b.sortOrder) + } + return a.itemNumber.localeCompare(b.itemNumber, undefined, { numeric: true }) + }) + + // ✅ DOCX 문서 생성 + const docxBlob = await generateDocxDocument(activeClauses, document) + + // ✅ DOCX 파일로 변환 + const docxFile = new File([docxBlob], `${document?.title || 'GTC계약서'}_미리보기.docx`, { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + }) + + // ✅ PDFTron에서 DOCX 문서로 로드 + await instance.UI.loadDocument(docxFile, { + filename: `${document?.title || 'GTC계약서'}_미리보기.docx`, + enableOfficeEditing: true, // DOCX 편집 모드 활성화 + }) + + console.log("✅ DOCX 기반 문서 생성 완료") + toast.success("Word 문서 미리보기가 생성되었습니다.") + + } catch (err) { + console.error("❌ DOCX 문서 생성 중 오류:", err) + toast.error(`문서 생성 실패: ${err instanceof Error ? err.message : '알 수 없는 오류'}`) + } + } + + 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">문서 생성 중...</p> + <p className="text-xs text-muted-foreground mt-1"> + {clauses.filter(c => c.isActive !== false).length}개 조항 처리 중 + </p> + </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 }; + } + + // ===== helper: 이미지 불러오기 + 크기 계산 (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) { + 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); + } + // 매칭 실패 시: 아무것도 넣지 않음(토큰 제거) + + 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); + } + + // else { + // children.push( + // new Paragraph({ + // // children: [new TextRun({ text: "(상세 내용 없음)", italics: true, color: "6b7280", size: 20 })], + // indent: { left: indentContent }, + // }) + // ); + // } + + // 본문에 등장하지 않은 잔여 이미지(선택: 뒤에 추가) + + 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) +} |
