diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-29 11:48:59 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-29 11:48:59 +0000 |
| commit | 10f90dc68dec42e9a64e081cc0dce6a484447290 (patch) | |
| tree | 5bc8bb30e03b09a602e7d414d943d0e7f24b1a0f /lib/gtc-contract/gtc-clauses | |
| parent | 792fb0c21136eededecf52b5b4aa1a252bdc4bfb (diff) | |
(대표님, 박서영, 최겸) document-list-only, gtc, vendorDocu, docu-list-rule
Diffstat (limited to 'lib/gtc-contract/gtc-clauses')
5 files changed, 1392 insertions, 325 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 index 30e369b4..f979f0ea 100644 --- a/lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx +++ b/lib/gtc-contract/gtc-clauses/table/clause-preview-viewer.tsx @@ -18,6 +18,8 @@ interface ClausePreviewViewerProps { document: any instance: WebViewerInstance | null setInstance: Dispatch<SetStateAction<WebViewerInstance | null>> + onSuccess?: () => void + onError?: () => void } export function ClausePreviewViewer({ @@ -25,140 +27,263 @@ export function ClausePreviewViewer({ 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 초기화 + // 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("뷰어 초기화에 실패했습니다.") - }) - }) - } - }) + initializeViewerStepByStep() } return () => { if (instance) { - instance.UI.dispose() + try { + instance.UI.dispose() + } catch (error) { + console.warn("뷰어 정리 중 오류:", error) + } } - isCancelled.current = true + 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 ( - instance: WebViewerInstance, - clauses: GtcClauseTreeView[], - document: any + 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) => { - // 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' + 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) - // ✅ PDFTron에서 DOCX 문서로 로드 - await instance.UI.loadDocument(docxFile, { - filename: `${document?.title || 'GTC계약서'}_미리보기.docx`, - enableOfficeEditing: true, // DOCX 편집 모드 활성화 + 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) - console.log("✅ DOCX 기반 문서 생성 완료") - toast.success("Word 문서 미리보기가 생성되었습니다.") + 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) - } catch (err) { - console.error("❌ DOCX 문서 생성 중 오류:", err) - toast.error(`문서 생성 실패: ${err instanceof Error ? err.message : '알 수 없는 오류'}`) + 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} + <div + ref={viewer} className="w-full h-full" style={{ position: 'relative', @@ -169,10 +294,13 @@ export function ClausePreviewViewer({ {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-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> @@ -180,82 +308,81 @@ export function ClausePreviewViewer({ ) } +// ===== 유틸리티 함수들 ===== - -// ===== data URL 판별 및 디코딩 유틸 ===== +// 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 }; + 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 형식입니다."); } - - // ===== 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 }; + 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) { + 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({ @@ -269,11 +396,10 @@ function textToParagraphs(text: string, indentLeft: number) { }), ]; } - - const IMG_TOKEN = /!\[([^\]]+)\]/g; // 예: ![image1753698566087] + const IMG_TOKEN = /!\[([^\]]+)\]/g; // 예: ![image1753698566087] -async function pushContentWithInlineImages( + async function pushContentWithInlineImages( content: string, indentLeft: number, children: any[], @@ -284,135 +410,135 @@ async function pushContentWithInlineImages( 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 }, + children: [ + new ImageRun({ + data, + transformation: { width, height }, + }), + ], + indent: { left: indentLeft }, }) ); - } catch (e) { + // 사용된 이미지 표시(뒤에서 중복 추가 방지) + imageMap.delete(imageId); + } catch (imgError) { + console.warn("이미지 로드 실패:", imgMeta, imgError); + // 이미지 로드 실패시 텍스트로 대체 children.push( new Paragraph({ - children: [new TextRun({ text: `이미지 로드 실패: ${imgMeta.fileName || imgMeta.url}`, color: "b91c1c", size: 20 })], - indent: { left: indentContent }, + children: [new TextRun({ text: `[이미지 로드 실패: ${imgMeta.fileName || imageId}]`, color: "999999" })], + indent: { left: indentLeft }, }) ); - console.warn("이미지 로드 실패(잔여):", imgMeta, e); } } - - // 조항 간 간격 - children.push(new Paragraph({ text: "" })); + // 매칭 실패 시: 아무것도 넣지 않음(토큰 제거) + + 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); } - - const doc = new Document({ - sections: [{ properties: {}, children }], - }); - - return await Packer.toBlob(doc); + + // 본문에 등장하지 않은 잔여 이미지(선택: 뒤에 추가) + 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 추정 @@ -421,9 +547,9 @@ function organizeClausesByHierarchy(clauses: GtcClauseTreeView[]) { estimatedDepth: clause.depth ?? estimateDepthFromItemNumber(clause.itemNumber) })).sort((a, b) => { // itemNumber 기준 자연 정렬 - return a.itemNumber.localeCompare(b.itemNumber, undefined, { - numeric: true, - sensitivity: 'base' + return a.itemNumber.localeCompare(b.itemNumber, undefined, { + numeric: true, + sensitivity: 'base' }) }) } @@ -433,3 +559,12 @@ 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 diff --git a/lib/gtc-contract/gtc-clauses/table/excel-import.tsx b/lib/gtc-contract/gtc-clauses/table/excel-import.tsx new file mode 100644 index 00000000..d8f435f7 --- /dev/null +++ b/lib/gtc-contract/gtc-clauses/table/excel-import.tsx @@ -0,0 +1,340 @@ +import { ExcelColumnDef } from "@/lib/export" +import ExcelJS from "exceljs" + +/** + * Excel 템플릿 다운로드 함수 + */ +export async function downloadExcelTemplate( + columns: ExcelColumnDef[], + { + filename = "template", + includeExampleData = true, + useGroupHeader = true, + }: { + filename?: string + includeExampleData?: boolean + useGroupHeader?: boolean + } = {} +): Promise<void> { + let sheetData: any[][] + + if (useGroupHeader) { + // 2줄 헤더 생성 + const row1: string[] = [] + const row2: string[] = [] + + columns.forEach((col) => { + row1.push(col.group ?? "") + row2.push(col.header) + }) + + sheetData = [row1, row2] + + // 예시 데이터 추가 + if (includeExampleData) { + // 빈 행 3개 추가 (사용자가 데이터 입력할 공간) + for (let i = 0; i < 3; i++) { + const exampleRow = columns.map((col) => { + // 컬럼 타입에 따른 예시 데이터 + if (col.id === "itemNumber") return i === 0 ? `1.${i + 1}` : i === 1 ? "2.1" : "" + if (col.id === "subtitle") return i === 0 ? "예시 조항 소제목" : i === 1 ? "하위 조항 예시" : "" + if (col.id === "content") return i === 0 ? "조항의 상세 내용을 입력합니다." : i === 1 ? "하위 조항의 내용" : "" + if (col.id === "category") return i === 0 ? "일반조항" : i === 1 ? "특별조항" : "" + if (col.id === "sortOrder") return i === 0 ? "10" : i === 1 ? "20" : "" + if (col.id === "parentId") return i === 1 ? "1" : "" + if (col.id === "isActive") return i === 0 ? "활성" : i === 1 ? "활성" : "" + if (col.id === "editReason") return i === 0 ? "신규 작성" : "" + return "" + }) + sheetData.push(exampleRow) + } + } + } else { + // 1줄 헤더 + const headerRow = columns.map((col) => col.header) + sheetData = [headerRow] + + if (includeExampleData) { + // 예시 데이터 행 추가 + const exampleRow = columns.map(() => "") + sheetData.push(exampleRow) + } + } + + // ExcelJS로 워크북 생성 + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("GTC조항템플릿") + + // 데이터 추가 + sheetData.forEach((arr, idx) => { + const row = worksheet.addRow(arr) + + // 헤더 스타일 적용 + if (useGroupHeader) { + if (idx < 2) { + row.font = { bold: true } + row.alignment = { horizontal: "center" } + row.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFE6F3FF" }, // 연한 파란색 + } + cell.border = { + top: { style: "thin" }, + left: { style: "thin" }, + bottom: { style: "thin" }, + right: { style: "thin" }, + } + }) + } + } else { + if (idx === 0) { + row.font = { bold: true } + row.alignment = { horizontal: "center" } + row.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFE6F3FF" }, + } + }) + } + } + + // 예시 데이터 행 스타일 + if (includeExampleData && idx === (useGroupHeader ? 2 : 1)) { + row.eachCell((cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFFFEAA7" }, // 연한 노란색 + } + cell.font = { italic: true, color: { argb: "FF666666" } } + }) + } + }) + + // 그룹 헤더 병합 + if (useGroupHeader) { + const groupRowIndex = 1 + const groupRow = worksheet.getRow(groupRowIndex) + + let start = 1 + let prevValue = groupRow.getCell(start).value + + for (let c = 2; c <= columns.length; c++) { + const cellVal = groupRow.getCell(c).value + if (cellVal !== prevValue) { + if (prevValue && prevValue.toString().trim() !== "") { + worksheet.mergeCells(groupRowIndex, start, groupRowIndex, c - 1) + } + start = c + prevValue = cellVal + } + } + + if (prevValue && prevValue.toString().trim() !== "") { + worksheet.mergeCells(groupRowIndex, start, groupRowIndex, columns.length) + } + } + + // 컬럼 너비 자동 조정 + columns.forEach((col, idx) => { + let width = Math.max(col.header.length + 5, 15) + + // 특정 컬럼은 더 넓게 + if (col.id === "content" || col.id === "subtitle") { + width = 30 + } else if (col.id === "itemNumber") { + width = 15 + } else if (col.id === "editReason") { + width = 20 + } + + worksheet.getColumn(idx + 1).width = width + }) + + // 사용 안내 시트 추가 + const instructionSheet = workbook.addWorksheet("사용안내") + const instructions = [ + ["GTC 조항 Excel 가져오기 사용 안내"], + [""], + ["1. 기본 규칙"], + [" - 첫 번째 시트(GTC조항템플릿)에 데이터를 입력하세요"], + [" - 헤더 행은 수정하지 마세요"], + [" - 예시 데이터(노란색 행)는 삭제하고 실제 데이터를 입력하세요"], + [""], + ["2. 필수 입력 항목"], + [" - 채번: 필수 입력 (예: 1.1, 2.3.1)"], + [" - 소제목: 필수 입력"], + [""], + ["3. 선택 입력 항목"], + [" - 상세항목: 조항의 구체적인 내용"], + [" - 분류: 조항의 카테고리 (예: 일반조항, 특별조항)"], + [" - 순서: 숫자 (기본값: 10, 20, 30...)"], + [" - 상위 조항 ID: 계층 구조를 만들 때 사용"], + [" - 활성 상태: '활성' 또는 '비활성' (기본값: 활성)"], + [" - 편집 사유: 작성/수정 이유"], + [""], + ["4. 자동 처리 항목"], + [" - ID, 생성일, 수정일: 시스템에서 자동 생성"], + [" - 계층 깊이: 상위 조항 ID를 기반으로 자동 계산"], + [" - 전체 경로: 시스템에서 자동 생성"], + [""], + ["5. 채번 규칙"], + [" - 같은 부모 하에서 채번은 유일해야 합니다"], + [" - 예: 상위 조항이 같으면 1.1, 1.2는 가능하지만 1.1이 중복되면 오류"], + [""], + ["6. 계층 구조 만들기"], + [" - 상위 조항 ID: 기존 조항의 ID를 입력"], + [" - 예: ID가 5인 조항 하위에 조항을 만들려면 상위 조항 ID에 5 입력"], + [" - 최상위 조항은 상위 조항 ID를 비워두세요"], + [""], + ["7. 주의사항"], + [" - 순서는 숫자로 입력하세요 (소수점 가능: 10, 15.5, 20)"], + [" - 상위 조항 ID는 반드시 존재하는 조항의 ID여야 합니다"], + [" - 파일 저장 시 .xlsx 형식으로 저장하세요"], + ] + + instructions.forEach((instruction, idx) => { + const row = instructionSheet.addRow(instruction) + if (idx === 0) { + row.font = { bold: true, size: 14 } + row.alignment = { horizontal: "center" } + } else if (instruction[0]?.match(/^\d+\./)) { + row.font = { bold: true } + } + }) + + instructionSheet.getColumn(1).width = 80 + + // 파일 다운로드 + const buffer = await workbook.xlsx.writeBuffer() + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = `${filename}.xlsx` + link.click() + URL.revokeObjectURL(url) +} + +/** + * Excel 파일에서 데이터 파싱 + */ +export async function parseExcelFile<TData>( + file: File, + columns: ExcelColumnDef[], + { + hasGroupHeader = true, + sheetName = "GTC조항템플릿", + }: { + hasGroupHeader?: boolean + sheetName?: string + } = {} +): Promise<{ + data: Partial<TData>[] + errors: string[] +}> { + const errors: string[] = [] + const data: Partial<TData>[] = [] + + try { + const arrayBuffer = await file.arrayBuffer() + const workbook = new ExcelJS.Workbook() + await workbook.xlsx.load(arrayBuffer) + + const worksheet = workbook.getWorksheet(sheetName) || workbook.worksheets[0] + + if (!worksheet) { + errors.push("워크시트를 찾을 수 없습니다.") + return { data, errors } + } + + // 헤더 행 인덱스 결정 + const headerRowIndex = hasGroupHeader ? 2 : 1 + const dataStartRowIndex = headerRowIndex + 1 + + // 헤더 검증 + const headerRow = worksheet.getRow(headerRowIndex) + const expectedHeaders = columns.map(col => col.header) + + for (let i = 0; i < expectedHeaders.length; i++) { + const cellValue = headerRow.getCell(i + 1).value?.toString() || "" + if (cellValue !== expectedHeaders[i]) { + errors.push(`헤더가 일치하지 않습니다. 예상: "${expectedHeaders[i]}", 실제: "${cellValue}"`) + } + } + + if (errors.length > 0) { + return { data, errors } + } + + // 데이터 파싱 + let rowIndex = dataStartRowIndex + while (rowIndex <= worksheet.actualRowCount) { + const row = worksheet.getRow(rowIndex) + + // 빈 행 체크 (모든 셀이 비어있으면 스킵) + const isEmpty = columns.every((col, colIndex) => { + const cellValue = row.getCell(colIndex + 1).value + return !cellValue || cellValue.toString().trim() === "" + }) + + if (isEmpty) { + rowIndex++ + continue + } + + const rowData: Partial<TData> = {} + let hasError = false + + columns.forEach((col, colIndex) => { + const cellValue = row.getCell(colIndex + 1).value + let processedValue: any = cellValue + + // 데이터 타입별 처리 + if (cellValue !== null && cellValue !== undefined) { + const strValue = cellValue.toString().trim() + + // 특별한 처리가 필요한 컬럼들 + if (col.id === "isActive") { + processedValue = strValue === "활성" + } else if (col.id === "sortOrder") { + const numValue = parseFloat(strValue) + processedValue = isNaN(numValue) ? null : numValue + } else if (col.id === "parentId") { + const numValue = parseInt(strValue) + processedValue = isNaN(numValue) ? null : numValue + } else { + processedValue = strValue + } + } + + // 필수 필드 검증 + if ((col.id === "itemNumber" || col.id === "subtitle") && (!processedValue || processedValue === "")) { + errors.push(`${rowIndex}행: ${col.header}은(는) 필수 입력 항목입니다.`) + hasError = true + } + + if (processedValue !== null && processedValue !== undefined && processedValue !== "") { + (rowData as any)[col.id] = processedValue + } + }) + + if (!hasError) { + data.push(rowData) + } + + rowIndex++ + } + + } catch (error) { + errors.push(`파일 파싱 중 오류가 발생했습니다: ${error instanceof Error ? error.message : "알 수 없는 오류"}`) + } + + return { data, errors } +}
\ No newline at end of file diff --git a/lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-toolbar-actions.tsx b/lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-toolbar-actions.tsx index 2a7452ef..ea516f49 100644 --- a/lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-toolbar-actions.tsx +++ b/lib/gtc-contract/gtc-clauses/table/gtc-clauses-table-toolbar-actions.tsx @@ -32,42 +32,170 @@ import { type GtcClauseTreeView } from "@/db/schema/gtc" import { CreateGtcClauseDialog } from "./create-gtc-clause-dialog" import { PreviewDocumentDialog } from "./preview-document-dialog" import { DeleteGtcClausesDialog } from "./delete-gtc-clauses-dialog" +import { exportTableToExcel } from "@/lib/export" +import { exportFullDataToExcel, type ExcelColumnDef } from "@/lib/export" +import { getAllGtcClausesForExport, importGtcClausesFromExcel } from "../../service" +import { ImportExcelDialog } from "./import-excel-dialog" +import { toast } from "@/hooks/use-toast" interface GtcClausesTableToolbarActionsProps { table: Table<GtcClauseTreeView> documentId: number document: any + currentUserId?: number // 현재 사용자 ID 추가 } +// GTC 조항을 위한 Excel 컬럼 정의 (실용적으로 간소화) +const gtcClauseExcelColumns: ExcelColumnDef[] = [ + { + id: "itemNumber", + header: "채번", + accessor: "itemNumber", + group: "필수 정보" + }, + { + id: "subtitle", + header: "소제목", + accessor: "subtitle", + group: "필수 정보" + }, + { + id: "content", + header: "상세항목", + accessor: "content", + group: "기본 정보" + }, + { + id: "category", + header: "분류", + accessor: "category", + group: "기본 정보" + }, + { + id: "sortOrder", + header: "순서", + accessor: "sortOrder", + group: "순서" + }, + { + id: "parentId", + header: "상위 조항 ID", + accessor: "parentId", + group: "계층 구조" + }, + { + id: "isActive", + header: "활성 상태", + accessor: (row) => row.isActive ? "활성" : "비활성", + group: "상태" + }, + { + id: "editReason", + header: "편집 사유", + accessor: "editReason", + group: "추가 정보" + } +] + export function GtcClausesTableToolbarActions({ table, documentId, document, + currentUserId = 1, // 기본값 설정 (실제로는 auth에서 가져와야 함) }: GtcClausesTableToolbarActionsProps) { const [showCreateDialog, setShowCreateDialog] = React.useState(false) const [showReorderDialog, setShowReorderDialog] = React.useState(false) const [showBulkUpdateDialog, setShowBulkUpdateDialog] = React.useState(false) const [showGenerateVariablesDialog, setShowGenerateVariablesDialog] = React.useState(false) - const [showPreviewDialog, setShowPreviewDialog] = React.useState(false) // ✅ 미리보기 다이얼로그 상태 + const [showPreviewDialog, setShowPreviewDialog] = React.useState(false) + const [isExporting, setIsExporting] = React.useState(false) const selectedRows = table.getSelectedRowModel().rows const selectedCount = selectedRows.length - // ✅ 테이블의 모든 데이터 가져오기 + // 테이블의 모든 데이터 가져오기 (현재 페이지만) const allClauses = table.getRowModel().rows.map(row => row.original) - const handleExportToExcel = () => { - // Excel 내보내기 로직 - console.log("Export to Excel") + // 현재 페이지 데이터만 Excel로 내보내기 + const handleExportCurrentPageToExcel = () => { + exportTableToExcel(table, { + filename: `gtc-clauses-page-${new Date().toISOString().split('T')[0]}`, + excludeColumns: ["select", "actions"], + }) + } + + // 전체 데이터를 Excel로 내보내기 + const handleExportAllToExcel = async () => { + try { + setIsExporting(true) + + // 서버에서 전체 데이터 가져오기 + const allData = await getAllGtcClausesForExport(documentId) + + // 전체 데이터를 Excel로 내보내기 + await exportFullDataToExcel( + allData, + gtcClauseExcelColumns, + { + filename: `gtc-clauses-all-${new Date().toISOString().split('T')[0]}`, + useGroupHeader: true + } + ) + + toast({ + title: "내보내기 완료", + description: `총 ${allData.length}개의 조항이 Excel 파일로 내보내졌습니다.`, + }) + } catch (error) { + console.error("Excel export failed:", error) + toast({ + title: "내보내기 실패", + description: "Excel 파일 내보내기 중 오류가 발생했습니다.", + variant: "destructive" + }) + } finally { + setIsExporting(false) + } } - const handleImportFromExcel = () => { - // Excel 가져오기 로직 - console.log("Import from Excel") + // Excel 데이터 가져오기 처리 + const handleImportExcelData = async (data: Partial<GtcClauseTreeView>[]) => { + try { + const result = await importGtcClausesFromExcel(documentId, data, currentUserId) + + if (result.success) { + toast({ + title: "가져오기 성공", + description: `${result.importedCount}개의 조항이 성공적으로 가져와졌습니다.`, + }) + + // 테이블 새로고침 + handleRefreshTable() + } else { + const errorMessage = result.errors.length > 0 + ? `오류: ${result.errors.slice(0, 3).join(', ')}${result.errors.length > 3 ? '...' : ''}` + : "알 수 없는 오류가 발생했습니다." + + toast({ + title: "가져오기 실패", + description: errorMessage, + variant: "destructive" + }) + + // 오류가 있어도 일부는 성공했을 수 있음 + if (result.importedCount > 0) { + handleRefreshTable() + } + + throw new Error("Import failed with errors") + } + } catch (error) { + console.error("Excel import failed:", error) + throw error // ImportExcelDialog에서 처리하도록 다시 throw + } } const handlePreviewDocument = () => { - // ✅ 미리보기 다이얼로그 열기 setShowPreviewDialog(true) } @@ -108,9 +236,8 @@ export function GtcClausesTableToolbarActions({ {selectedCount > 0 && ( <> <DeleteGtcClausesDialog - gtcClauses={allClauses} - onSuccess={() => table.toggleAllRowsSelected(false)} - + gtcClauses={allClauses} + onSuccess={() => table.toggleAllRowsSelected(false)} /> </> )} @@ -118,33 +245,39 @@ export function GtcClausesTableToolbarActions({ {/* 관리 도구 드롭다운 */} <DropdownMenu> <DropdownMenuTrigger asChild> - <Button variant="outline" size="sm"> + <Button variant="outline" size="sm" disabled={isExporting}> <Settings2 className="mr-2 h-4 w-4" /> 관리 도구 </Button> </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="w-56"> - {/* <DropdownMenuItem onClick={handleReorderClauses}> - <ArrowUpDown className="mr-2 h-4 w-4" /> - 조항 순서 변경 + <DropdownMenuContent align="end" className="w-64"> + <DropdownMenuItem onClick={handleExportCurrentPageToExcel}> + <Download className="mr-2 h-4 w-4" /> + 현재 페이지 Excel로 내보내기 </DropdownMenuItem> - <DropdownMenuItem onClick={handleGenerateVariables}> - <Wand2 className="mr-2 h-4 w-4" /> - PDFTron 변수명 일괄 생성 - </DropdownMenuItem> */} - - <DropdownMenuSeparator /> - - <DropdownMenuItem onClick={handleExportToExcel}> + <DropdownMenuItem + onClick={handleExportAllToExcel} + disabled={isExporting} + > <Download className="mr-2 h-4 w-4" /> - Excel로 내보내기 + {isExporting ? "내보내는 중..." : "전체 데이터 Excel로 내보내기"} </DropdownMenuItem> - <DropdownMenuItem onClick={handleImportFromExcel}> - <Upload className="mr-2 h-4 w-4" /> - Excel에서 가져오기 - </DropdownMenuItem> + <DropdownMenuSeparator /> + + <ImportExcelDialog + documentId={documentId} + columns={gtcClauseExcelColumns} + onSuccess={handleRefreshTable} + onImport={handleImportExcelData} + trigger={ + <DropdownMenuItem onSelect={(e) => e.preventDefault()}> + <Upload className="mr-2 h-4 w-4" /> + Excel에서 가져오기 + </DropdownMenuItem> + } + /> <DropdownMenuSeparator /> @@ -152,11 +285,6 @@ export function GtcClausesTableToolbarActions({ <Eye className="mr-2 h-4 w-4" /> 문서 미리보기 </DropdownMenuItem> - - {/* <DropdownMenuItem onClick={handleGenerateDocument}> - <FileText className="mr-2 h-4 w-4" /> - 최종 문서 생성 - </DropdownMenuItem> */} </DropdownMenuContent> </DropdownMenu> @@ -180,7 +308,7 @@ export function GtcClausesTableToolbarActions({ )} </div> - {/* ✅ 미리보기 다이얼로그 */} + {/* 미리보기 다이얼로그 */} <PreviewDocumentDialog open={showPreviewDialog} onOpenChange={setShowPreviewDialog} diff --git a/lib/gtc-contract/gtc-clauses/table/import-excel-dialog.tsx b/lib/gtc-contract/gtc-clauses/table/import-excel-dialog.tsx new file mode 100644 index 00000000..f37566fc --- /dev/null +++ b/lib/gtc-contract/gtc-clauses/table/import-excel-dialog.tsx @@ -0,0 +1,381 @@ +"use client" + +import * as React from "react" +import { Upload, Download, FileText, AlertCircle, CheckCircle2, X } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { Badge } from "@/components/ui/badge" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Separator } from "@/components/ui/separator" + +import { type ExcelColumnDef } from "@/lib/export" +import { downloadExcelTemplate, parseExcelFile } from "./excel-import" +import { type GtcClauseTreeView } from "@/db/schema/gtc" +import { toast } from "@/hooks/use-toast" + +interface ImportExcelDialogProps { + documentId: number + columns: ExcelColumnDef[] + onSuccess?: () => void + onImport?: (data: Partial<GtcClauseTreeView>[]) => Promise<void> + trigger?: React.ReactNode +} + +type ImportStep = "upload" | "preview" | "importing" | "complete" + +export function ImportExcelDialog({ + documentId, + columns, + onSuccess, + onImport, + trigger, +}: ImportExcelDialogProps) { + const [open, setOpen] = React.useState(false) + const [step, setStep] = React.useState<ImportStep>("upload") + const [selectedFile, setSelectedFile] = React.useState<File | null>(null) + const [parsedData, setParsedData] = React.useState<Partial<GtcClauseTreeView>[]>([]) + const [errors, setErrors] = React.useState<string[]>([]) + const [isProcessing, setIsProcessing] = React.useState(false) + const fileInputRef = React.useRef<HTMLInputElement>(null) + + // 다이얼로그 열기/닫기 시 상태 초기화 + const handleOpenChange = (isOpen: boolean) => { + setOpen(isOpen) + if (!isOpen) { + // 다이얼로그 닫을 때 상태 초기화 + setStep("upload") + setSelectedFile(null) + setParsedData([]) + setErrors([]) + setIsProcessing(false) + if (fileInputRef.current) { + fileInputRef.current.value = "" + } + } + } + + // 템플릿 다운로드 + const handleDownloadTemplate = async () => { + try { + await downloadExcelTemplate(columns, { + filename: `gtc-clauses-template-${new Date().toISOString().split('T')[0]}`, + includeExampleData: true, + useGroupHeader: true, + }) + + toast({ + title: "템플릿 다운로드 완료", + description: "Excel 템플릿이 다운로드되었습니다. 템플릿에 데이터를 입력한 후 업로드해주세요.", + }) + } catch (error) { + toast({ + title: "템플릿 다운로드 실패", + description: "템플릿 다운로드 중 오류가 발생했습니다.", + variant: "destructive", + }) + } + } + + // 파일 선택 + const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => { + const file = event.target.files?.[0] + if (file) { + if (file.type !== "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" && + file.type !== "application/vnd.ms-excel") { + toast({ + title: "잘못된 파일 형식", + description: "Excel 파일(.xlsx, .xls)만 업로드할 수 있습니다.", + variant: "destructive", + }) + return + } + setSelectedFile(file) + } + } + + // 파일 파싱 + const handleParseFile = async () => { + if (!selectedFile) return + + setIsProcessing(true) + try { + const result = await parseExcelFile<GtcClauseTreeView>( + selectedFile, + columns, + { + hasGroupHeader: true, + sheetName: "GTC조항템플릿", + } + ) + + setParsedData(result.data) + setErrors(result.errors) + + if (result.errors.length > 0) { + toast({ + title: "파싱 완료 (오류 있음)", + description: `${result.data.length}개의 행을 파싱했지만 ${result.errors.length}개의 오류가 있습니다.`, + variant: "destructive", + }) + } else { + toast({ + title: "파싱 완료", + description: `${result.data.length}개의 행이 성공적으로 파싱되었습니다.`, + }) + } + + setStep("preview") + } catch (error) { + toast({ + title: "파싱 실패", + description: "파일 파싱 중 오류가 발생했습니다.", + variant: "destructive", + }) + } finally { + setIsProcessing(false) + } + } + + // 데이터 가져오기 실행 + const handleImportData = async () => { + if (parsedData.length === 0 || !onImport) return + + setStep("importing") + try { + await onImport(parsedData) + setStep("complete") + + toast({ + title: "가져오기 완료", + description: `${parsedData.length}개의 조항이 성공적으로 가져와졌습니다.`, + }) + + // 성공 콜백 호출 후 잠시 후 다이얼로그 닫기 + setTimeout(() => { + onSuccess?.() + setOpen(false) + }, 2000) + } catch (error) { + toast({ + title: "가져오기 실패", + description: "데이터 가져오기 중 오류가 발생했습니다.", + variant: "destructive", + }) + setStep("preview") + } + } + + const renderUploadStep = () => ( + <div className="space-y-6"> + <div className="text-center"> + <FileText className="mx-auto h-12 w-12 text-muted-foreground" /> + <h3 className="mt-4 text-lg font-semibold">Excel 파일로 조항 가져오기</h3> + <p className="mt-2 text-sm text-muted-foreground"> + 먼저 템플릿을 다운로드하여 데이터를 입력한 후 업로드해주세요. + </p> + </div> + + <div className="space-y-4"> + <div> + <Button + onClick={handleDownloadTemplate} + variant="outline" + className="w-full" + > + <Download className="mr-2 h-4 w-4" /> + Excel 템플릿 다운로드 + </Button> + <p className="mt-2 text-xs text-muted-foreground"> + 템플릿에는 입력 가이드와 예시 데이터가 포함되어 있습니다. + </p> + </div> + + <Separator /> + + <div> + <input + ref={fileInputRef} + type="file" + accept=".xlsx,.xls" + onChange={handleFileSelect} + className="hidden" + /> + <Button + onClick={() => fileInputRef.current?.click()} + variant={selectedFile ? "secondary" : "outline"} + className="w-full" + > + <Upload className="mr-2 h-4 w-4" /> + {selectedFile ? selectedFile.name : "Excel 파일 선택"} + </Button> + </div> + + {selectedFile && ( + <Button + onClick={handleParseFile} + disabled={isProcessing} + className="w-full" + > + {isProcessing ? "파싱 중..." : "파일 분석하기"} + </Button> + )} + </div> + </div> + ) + + const renderPreviewStep = () => ( + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <h3 className="text-lg font-semibold">데이터 미리보기</h3> + <div className="flex items-center gap-2"> + <Badge variant="secondary"> + {parsedData.length}개 행 + </Badge> + {errors.length > 0 && ( + <Badge variant="destructive"> + {errors.length}개 오류 + </Badge> + )} + </div> + </div> + + {errors.length > 0 && ( + <Alert variant="destructive"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + <div className="space-y-1"> + <div className="font-medium">다음 오류들을 확인해주세요:</div> + <ul className="list-disc list-inside space-y-1 text-sm"> + {errors.slice(0, 5).map((error, index) => ( + <li key={index}>{error}</li> + ))} + {errors.length > 5 && ( + <li>... 및 {errors.length - 5}개 추가 오류</li> + )} + </ul> + </div> + </AlertDescription> + </Alert> + )} + + <ScrollArea className="h-[300px] border rounded-md"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-12">#</TableHead> + <TableHead>채번</TableHead> + <TableHead>소제목</TableHead> + <TableHead>상세항목</TableHead> + <TableHead>분류</TableHead> + <TableHead>상태</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {parsedData.map((item, index) => ( + <TableRow key={index}> + <TableCell>{index + 1}</TableCell> + <TableCell className="font-mono"> + {item.itemNumber || "-"} + </TableCell> + <TableCell className="max-w-[200px] truncate"> + {item.subtitle || "-"} + </TableCell> + <TableCell className="max-w-[300px] truncate"> + {item.content || "-"} + </TableCell> + <TableCell>{item.category || "-"}</TableCell> + <TableCell> + <Badge variant={item.isActive ? "default" : "secondary"}> + {item.isActive ? "활성" : "비활성"} + </Badge> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + </ScrollArea> + + <div className="flex gap-2"> + <Button + variant="outline" + onClick={() => setStep("upload")} + className="flex-1" + > + 다시 선택 + </Button> + <Button + onClick={handleImportData} + disabled={parsedData.length === 0 || errors.length > 0} + className="flex-1" + > + {errors.length > 0 ? "오류 수정 후 가져오기" : `${parsedData.length}개 조항 가져오기`} + </Button> + </div> + </div> + ) + + const renderImportingStep = () => ( + <div className="text-center py-8"> + <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div> + <h3 className="mt-4 text-lg font-semibold">가져오는 중...</h3> + <p className="mt-2 text-sm text-muted-foreground"> + {parsedData.length}개의 조항을 데이터베이스에 저장하고 있습니다. + </p> + </div> + ) + + const renderCompleteStep = () => ( + <div className="text-center py-8"> + <CheckCircle2 className="mx-auto h-12 w-12 text-green-500" /> + <h3 className="mt-4 text-lg font-semibold">가져오기 완료!</h3> + <p className="mt-2 text-sm text-muted-foreground"> + {parsedData.length}개의 조항이 성공적으로 가져와졌습니다. + </p> + </div> + ) + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogTrigger asChild> + {trigger || ( + <Button variant="outline" size="sm"> + <Upload className="mr-2 h-4 w-4" /> + Excel에서 가져오기 + </Button> + )} + </DialogTrigger> + <DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden"> + <DialogHeader> + <DialogTitle>Excel에서 조항 가져오기</DialogTitle> + <DialogDescription> + Excel 파일을 사용하여 여러 조항을 한 번에 가져올 수 있습니다. + </DialogDescription> + </DialogHeader> + + <div className="mt-4"> + {step === "upload" && renderUploadStep()} + {step === "preview" && renderPreviewStep()} + {step === "importing" && renderImportingStep()} + {step === "complete" && renderCompleteStep()} + </div> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/gtc-contract/gtc-clauses/table/preview-document-dialog.tsx b/lib/gtc-contract/gtc-clauses/table/preview-document-dialog.tsx index 29ab1b5a..3639c0f3 100644 --- a/lib/gtc-contract/gtc-clauses/table/preview-document-dialog.tsx +++ b/lib/gtc-contract/gtc-clauses/table/preview-document-dialog.tsx @@ -12,7 +12,8 @@ import { Loader2, FileText, RefreshCw, - Settings + Settings, + AlertCircle } from "lucide-react" import { toast } from "sonner" @@ -35,6 +36,7 @@ export function PreviewDocumentDialog({ const [isGenerating, setIsGenerating] = React.useState(false) const [documentGenerated, setDocumentGenerated] = React.useState(false) const [viewerInstance, setViewerInstance] = React.useState<any>(null) + const [hasError, setHasError] = React.useState(false) // 조항 통계 계산 const stats = React.useMemo(() => { @@ -52,38 +54,84 @@ export function PreviewDocumentDialog({ const handleGeneratePreview = async () => { setIsGenerating(true) + setHasError(false) + setDocumentGenerated(false) + try { - // 잠시 후 문서 생성 완료로 설정 (실제로는 뷰어에서 처리) - setTimeout(() => { + // 실제로는 ClausePreviewViewer에서 문서 생성을 처리하므로 + // 여기서는 상태만 관리 + console.log("🚀 문서 미리보기 생성 시작") + + // ClausePreviewViewer가 완전히 로드될 때까지 기다림 + await new Promise(resolve => setTimeout(resolve, 2000)) + + if (!hasError) { setDocumentGenerated(true) - setIsGenerating(false) toast.success("문서 미리보기가 생성되었습니다.") - }, 1500) + } } catch (error) { - setIsGenerating(false) + console.error("문서 생성 중 오류:", error) + setHasError(true) toast.error("문서 생성 중 오류가 발생했습니다.") + } finally { + setIsGenerating(false) } } const handleExportDocument = () => { if (viewerInstance) { - // PDFTron의 다운로드 기능 실행 - viewerInstance.UI.downloadPdf({ - filename: `${document?.title || 'GTC계약서'}_미리보기.pdf` - }) - toast.success("문서가 다운로드됩니다.") + try { + // PDFTron의 다운로드 기능 실행 + viewerInstance.UI.downloadPdf({ + filename: `${document?.title || 'GTC계약서'}_미리보기.pdf` + }) + toast.success("PDF 다운로드가 시작됩니다.") + } catch (error) { + console.error("다운로드 오류:", error) + toast.error("다운로드 중 오류가 발생했습니다.") + } + } else { + toast.error("뷰어가 준비되지 않았습니다.") } } const handleRegenerateDocument = () => { + console.log("🔄 문서 재생성 시작") setDocumentGenerated(false) + setHasError(false) handleGeneratePreview() } + const handleViewerSuccess = React.useCallback(() => { + setDocumentGenerated(true) + setIsGenerating(false) + setHasError(false) + }, []) + + const handleViewerError = React.useCallback(() => { + setHasError(true) + setIsGenerating(false) + setDocumentGenerated(false) + }, []) + + // 다이얼로그가 열릴 때 자동으로 미리보기 생성 + React.useEffect(() => { + if (props.open && !documentGenerated && !isGenerating && !hasError) { + const timer = setTimeout(() => { + handleGeneratePreview() + }, 300) // 다이얼로그 애니메이션 후 시작 + + return () => clearTimeout(timer) + } + }, [props.open, documentGenerated, isGenerating, hasError]) + + // 다이얼로그가 닫힐 때 상태 초기화 React.useEffect(() => { - // 다이얼로그가 열릴 때 자동으로 미리보기 생성 - if (props.open && !documentGenerated && !isGenerating) { - handleGeneratePreview() + if (!props.open) { + setDocumentGenerated(false) + setIsGenerating(false) + setHasError(false) + setViewerInstance(null) } }, [props.open]) @@ -107,9 +155,15 @@ export function PreviewDocumentDialog({ <FileText className="h-4 w-4" /> <span className="font-medium">{document?.title || 'GTC 계약서'}</span> <Badge variant="outline">{stats.total}개 조항</Badge> + {hasError && ( + <Badge variant="destructive" className="gap-1"> + <AlertCircle className="h-3 w-3" /> + 오류 발생 + </Badge> + )} </div> <div className="flex items-center gap-2"> - {documentGenerated && ( + {documentGenerated && !hasError && ( <> <Button variant="outline" @@ -117,19 +171,31 @@ export function PreviewDocumentDialog({ onClick={handleRegenerateDocument} disabled={isGenerating} > - <RefreshCw className="mr-2 h-3 w-3" /> + <RefreshCw className={`mr-2 h-3 w-3 ${isGenerating ? 'animate-spin' : ''}`} /> 재생성 </Button> <Button variant="outline" size="sm" onClick={handleExportDocument} + disabled={!viewerInstance} > <Download className="mr-2 h-3 w-3" /> PDF 다운로드 </Button> </> )} + {hasError && ( + <Button + variant="default" + size="sm" + onClick={handleRegenerateDocument} + disabled={isGenerating} + > + <RefreshCw className={`mr-2 h-3 w-3 ${isGenerating ? 'animate-spin' : ''}`} /> + 다시 시도 + </Button> + )} </div> </div> @@ -164,6 +230,21 @@ export function PreviewDocumentDialog({ <p className="text-sm text-muted-foreground"> {stats.total}개의 조항을 배치하고 있습니다. </p> + <p className="text-xs text-gray-400 mt-2"> + 초기화에 시간이 걸릴 수 있습니다... + </p> + </div> + ) : hasError ? ( + <div className="absolute inset-0 flex flex-col items-center justify-center bg-muted/10"> + <AlertCircle className="h-12 w-12 text-destructive mb-4" /> + <p className="text-lg font-medium mb-2 text-destructive">문서 생성 실패</p> + <p className="text-sm text-muted-foreground mb-4 text-center max-w-md"> + 문서 생성 중 오류가 발생했습니다. 네트워크 연결이나 파일 권한을 확인해주세요. + </p> + <Button onClick={handleRegenerateDocument} disabled={isGenerating}> + <RefreshCw className="mr-2 h-4 w-4" /> + 다시 시도 + </Button> </div> ) : documentGenerated ? ( <ClausePreviewViewer @@ -171,6 +252,8 @@ export function PreviewDocumentDialog({ document={document} instance={viewerInstance} setInstance={setViewerInstance} + onSuccess={handleViewerSuccess} + onError={handleViewerError} /> ) : ( <div className="absolute inset-0 flex flex-col items-center justify-center bg-muted/10"> |
