From c228a89c2834ee63b209bad608837c39643f350e Mon Sep 17 00:00:00 2001
From: dujinkim <dujin.kim@dtsolution.co.kr>
Date: Mon, 28 Jul 2025 11:44:16 +0000
Subject: (대표님) 의존성 docx 추가, basicContract API, gtc(계약일반조건),
 벤더평가 esg 평가데이터 내보내기 개선, S-EDP 피드백 대응(CLS_ID, ITEM NO 등)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../gtc-clauses/table/clause-preview-viewer.tsx    | 435 +++++++++++++++++++++
 1 file changed, 435 insertions(+)
 create mode 100644 lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx

(limited to 'lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx')

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)
+}
-- 
cgit v1.2.3

