summaryrefslogtreecommitdiff
path: root/lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-28 11:44:16 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-28 11:44:16 +0000
commitc228a89c2834ee63b209bad608837c39643f350e (patch)
tree39c9a121b556af872072dd80750dedf2d2d62335 /lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx
parent50ae0b8f02c034e60d4cbb504620dfa1575a836f (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.tsx435
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)
+}