"use client"; import * as React from "react"; import { Button } from "@/components/ui/button"; import { toast } from "sonner"; import { Save, RefreshCw, Type, FileText, AlertCircle, Copy, Download, Settings, ChevronDown, ChevronUp } from "lucide-react"; import type { WebViewerInstance } from "@pdftron/webviewer"; import { Badge } from "@/components/ui/badge"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; import { getProjectDocTemplateById, updateProjectDocTemplate, type DocTemplateVariable } from "@/lib/project-doc-templates/service"; import type { ProjectDocTemplate } from "@/db/schema/project-doc-templates"; import { BasicContractTemplateViewer } from "@/lib/basic-contract/template/basic-contract-template-viewer"; import { v4 as uuidv4 } from 'uuid'; import { Progress } from "@/components/ui/progress"; interface ProjectDocTemplateEditorProps { templateId: string | number; filePath: string; fileName: string; refreshAction?: () => Promise; mode?: "view" | "edit"; } // 변수별 한글 설명 매핑은 기존과 동일 const VARIABLE_DESCRIPTION_MAP: Record = { "document_number": "문서번호", "project_code": "프로젝트 코드", "project_name": "프로젝트명", "created_date": "작성일", "author_name": "작성자", "department": "부서명", "company_name": "회사명", "company_address": "회사주소", "representative_name": "대표자명", "signature_date": "서명날짜", "today_date": "오늘날짜", "tax_id": "사업자등록번호", "phone_number": "전화번호", "email": "이메일", }; const VARIABLE_PATTERN = /\{\{([^}]+)\}\}/g; export function ProjectDocTemplateEditor({ templateId, filePath, fileName, refreshAction, mode = "edit", }: ProjectDocTemplateEditorProps) { const [instance, setInstance] = React.useState(null); const [isSaving, setIsSaving] = React.useState(false); const [documentVariables, setDocumentVariables] = React.useState([]); const [templateInfo, setTemplateInfo] = React.useState(null); const [predefinedVariables, setPredefinedVariables] = React.useState([]); const [isVariablePanelOpen, setIsVariablePanelOpen] = React.useState(false); // 변수 패널 접기/펴기 상태 const [uploadProgress, setUploadProgress] = React.useState(0); const [showProgress, setShowProgress] = React.useState(false); // 템플릿 정보 로드 React.useEffect(() => { const loadTemplateInfo = async () => { try { const data = await getProjectDocTemplateById(Number(templateId)); setTemplateInfo(data as ProjectDocTemplate); setPredefinedVariables(data.variables || []); console.log("📋 템플릿 정보:", data); console.log("📝 정의된 변수들:", data.variables); } catch (error) { console.error("템플릿 정보 로드 오류:", error); toast.error("템플릿 정보를 불러오는데 실패했습니다."); } }; if (templateId) { loadTemplateInfo(); } }, [templateId]); // 문서에서 변수 추출 - 기존과 동일 const extractVariablesFromDocument = async () => { if (!instance) return; try { const { documentViewer } = instance.Core; const doc = documentViewer.getDocument(); if (!doc) return; const textContent = await doc.getDocumentCompletePromise().then(async () => { const pageCount = doc.getPageCount(); let fullText = ""; for (let i = 1; i <= pageCount; i++) { try { const pageText = await doc.loadPageText(i); fullText += pageText + " "; } catch (error) { console.warn(`페이지 ${i} 텍스트 추출 실패:`, error); } } return fullText; }); const matches = textContent.match(VARIABLE_PATTERN); const variables = matches ? [...new Set(matches.map(match => match.replace(/[{}]/g, '')))] : []; setDocumentVariables(variables); if (variables.length > 0) { console.log("🔍 문서에서 발견된 변수들:", variables); const undefinedVars = variables.filter( v => !predefinedVariables.find(pv => pv.name === v) ); if (undefinedVars.length > 0) { toast.warning( `정의되지 않은 변수가 발견되었습니다: ${undefinedVars.join(", ")}`, { duration: 5000 } ); } } } catch (error) { console.error("변수 추출 중 오류:", error); } }; // 청크 업로드 함수 const CHUNK_SIZE = 1 * 1024 * 1024; // 1MB const uploadFileInChunks = async (file: Blob, fileName: string, fileId: string) => { const totalChunks = Math.ceil(file.size / CHUNK_SIZE); setShowProgress(true); setUploadProgress(0); for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { const start = chunkIndex * CHUNK_SIZE; const end = Math.min(start + CHUNK_SIZE, file.size); const chunk = file.slice(start, end); const formData = new FormData(); formData.append('chunk', chunk); formData.append('filename', fileName); formData.append('chunkIndex', chunkIndex.toString()); formData.append('totalChunks', totalChunks.toString()); formData.append('fileId', fileId); const response = await fetch('/api/upload/project-doc-template/chunk', { method: 'POST', body: formData, }); if (!response.ok) { throw new Error(`청크 업로드 실패: ${response.statusText}`); } const progress = Math.round(((chunkIndex + 1) / totalChunks) * 100); setUploadProgress(progress); const result = await response.json(); if (chunkIndex === totalChunks - 1) { return result; } } }; // 문서 저장 - 개선된 버전 const handleSave = async () => { if (!instance || mode === "view") { toast.error(mode === "view" ? "읽기 전용 모드입니다." : "뷰어가 준비되지 않았습니다."); return; } setIsSaving(true); try { const { documentViewer } = instance.Core; const doc = documentViewer.getDocument(); if (!doc) { throw new Error("문서를 찾을 수 없습니다."); } // Word 문서 데이터 추출 const data = await doc.getFileData({ downloadType: "office", includeAnnotations: true }); // Blob 생성 const fileBlob = new Blob([data], { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }); // 파일 업로드 (청크 방식) const fileId = `template_${templateId}_${Date.now()}`; const uploadResult = await uploadFileInChunks(fileBlob, fileName, fileId); if (!uploadResult?.success) { throw new Error("파일 업로드에 실패했습니다."); } // 서버에 파일 경로 업데이트 const updateResult = await updateProjectDocTemplate(Number(templateId), { filePath: uploadResult.filePath, fileName: uploadResult.fileName, fileSize: uploadResult.fileSize, variables: documentVariables.length > 0 ? documentVariables.map(varName => { const existing = predefinedVariables.find(v => v.name === varName); return existing || { name: varName, displayName: VARIABLE_DESCRIPTION_MAP[varName] || varName, type: "text" as const, required: false, description: "" }; }) : predefinedVariables }); if (!updateResult.success) { throw new Error(updateResult.error || "템플릿 업데이트에 실패했습니다."); } toast.success("템플릿이 성공적으로 저장되었습니다."); // 변수 재추출 await extractVariablesFromDocument(); // 페이지 새로고침 if (refreshAction) { await refreshAction(); } } catch (error) { console.error("저장 오류:", error); toast.error(error instanceof Error ? error.message : "저장 중 오류가 발생했습니다."); } finally { setIsSaving(false); setShowProgress(false); setUploadProgress(0); } }; // 나머지 함수들은 기존과 동일 React.useEffect(() => { if (instance) { const { documentViewer } = instance.Core; const onDocumentLoaded = () => { setTimeout(() => extractVariablesFromDocument(), 1000); }; documentViewer.addEventListener("documentLoaded", onDocumentLoaded); return () => { documentViewer.removeEventListener("documentLoaded", onDocumentLoaded); }; } }, [instance, predefinedVariables]); const insertVariable = async (variable: DocTemplateVariable) => { if (!instance) { toast.error("뷰어가 준비되지 않았습니다."); return; } const textToInsert = `{{${variable.name}}}`; // variable.name으로 수정 try { // textarea를 보이는 위치에 잠시 생성 const tempTextArea = document.createElement('textarea'); tempTextArea.value = textToInsert; tempTextArea.style.position = 'fixed'; tempTextArea.style.top = '20px'; tempTextArea.style.left = '20px'; tempTextArea.style.width = '200px'; tempTextArea.style.height = '30px'; tempTextArea.style.fontSize = '12px'; tempTextArea.style.zIndex = '10000'; tempTextArea.style.opacity = '0.01'; // 거의 투명하게 document.body.appendChild(tempTextArea); // 포커스와 선택을 확실하게 tempTextArea.focus(); tempTextArea.select(); tempTextArea.setSelectionRange(0, tempTextArea.value.length); // 전체 선택 보장 let successful = false; try { successful = document.execCommand('copy'); console.log('복사 시도 결과:', successful, '복사된 텍스트:', textToInsert); } catch (err) { console.error('execCommand 실패:', err); } // 잠시 후 제거 (즉시 제거하면 복사가 안될 수 있음) setTimeout(() => { document.body.removeChild(tempTextArea); }, 100); if (successful) { toast.success(

{variable.displayName} 변수가 복사되었습니다.

{textToInsert}

문서에서 원하는 위치에 Ctrl+V로 붙여넣기 하세요.

, { duration: 3000 } ); // 복사 확인용 - 개발 중에만 사용 if (process.env.NODE_ENV === 'development') { navigator.clipboard.readText().then(text => { console.log('클립보드 내용:', text); }).catch(err => { console.log('클립보드 읽기 실패:', err); }); } } else { // 복사 실패 시 대안 제공 const fallbackInput = document.createElement('input'); fallbackInput.value = textToInsert; fallbackInput.style.position = 'fixed'; fallbackInput.style.top = '50%'; fallbackInput.style.left = '50%'; fallbackInput.style.transform = 'translate(-50%, -50%)'; fallbackInput.style.zIndex = '10001'; fallbackInput.style.padding = '8px'; fallbackInput.style.border = '2px solid #3b82f6'; fallbackInput.style.borderRadius = '4px'; fallbackInput.style.backgroundColor = 'white'; document.body.appendChild(fallbackInput); fallbackInput.select(); toast.error(

자동 복사 실패

표시된 텍스트를 Ctrl+C로 복사하세요.

, { duration: 5000, onDismiss: () => { if (document.body.contains(fallbackInput)) { document.body.removeChild(fallbackInput); } } } ); } } catch (error) { console.error("변수 삽입 오류:", error); toast.error("변수 삽입 중 오류가 발생했습니다."); } }; const handleDownload = async () => { try { const response = await fetch(filePath); const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.style.display = 'none'; a.href = url; a.download = fileName; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); toast.success("파일이 다운로드되었습니다."); } catch (error) { console.error("다운로드 오류:", error); toast.error("파일 다운로드 중 오류가 발생했습니다."); } }; const handleRefresh = () => { window.location.reload(); }; const isReadOnly = mode === "view"; return (
{/* 상단 도구 모음 */}
{!isReadOnly && ( )}
{templateInfo?.projectCode && ( {templateInfo.projectCode} )} {fileName} {documentVariables.length > 0 && ( 변수 {documentVariables.length}개 )}
{/* 변수 도구 - Collapsible로 변경 */} {(predefinedVariables.length > 0 || documentVariables.length > 0) && (
템플릿 변수 관리 {documentVariables.length > 0 && ( {documentVariables.length}개 )}
{/* 정의된 변수들 */} {predefinedVariables.length > 0 && (

정의된 템플릿 변수 (클릭하여 복사):

{predefinedVariables.map((variable, index) => (

{variable.displayName}

{variable.description && (

{variable.description}

)} {variable.defaultValue && (

기본값: {variable.defaultValue}

)}

타입: {variable.type} {variable.required && "(필수)"}

))}
)} {/* 문서에서 발견된 변수들 */} {documentVariables.length > 0 && (

문서에서 발견된 변수:

{documentVariables.map((variable, index) => { const isDefined = predefinedVariables.find(v => v.name === variable); return ( {`{{${variable}}}`} {!isDefined && ( )} ); })}
)} {/* 변수 사용 안내 */}

변수 사용 안내

  • {'{{변수명}}'} 형식으로 문서에 변수를 삽입하세요.
  • 필수 변수(*)는 반드시 값이 입력되어야 합니다.
  • 한글 입력 제한 시 외부 에디터에서 작성 후 붙여넣기 하세요.
)} {/* 업로드 진행률 */} {showProgress && (
저장 진행률 {uploadProgress}%
)}
{/* 뷰어 영역 */}
); }