diff options
Diffstat (limited to 'lib/project-doc-templates/table/project-doc-template-editor.tsx')
| -rw-r--r-- | lib/project-doc-templates/table/project-doc-template-editor.tsx | 645 |
1 files changed, 645 insertions, 0 deletions
diff --git a/lib/project-doc-templates/table/project-doc-template-editor.tsx b/lib/project-doc-templates/table/project-doc-template-editor.tsx new file mode 100644 index 00000000..e4f798a9 --- /dev/null +++ b/lib/project-doc-templates/table/project-doc-template-editor.tsx @@ -0,0 +1,645 @@ +"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<void>; + mode?: "view" | "edit"; +} + +// 변수별 한글 설명 매핑은 기존과 동일 +const VARIABLE_DESCRIPTION_MAP: Record<string, string> = { + "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<WebViewerInstance | null>(null); + const [isSaving, setIsSaving] = React.useState(false); + const [documentVariables, setDocumentVariables] = React.useState<string[]>([]); + const [templateInfo, setTemplateInfo] = React.useState<ProjectDocTemplate | null>(null); + const [predefinedVariables, setPredefinedVariables] = React.useState<DocTemplateVariable[]>([]); + 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( + <div> + <p className="font-medium">{variable.displayName} 변수가 복사되었습니다.</p> + <code className="bg-gray-100 px-1 rounded text-xs">{textToInsert}</code> + <p className="text-sm text-muted-foreground mt-1"> + 문서에서 원하는 위치에 Ctrl+V로 붙여넣기 하세요. + </p> + </div>, + { 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( + <div> + <p className="font-medium">자동 복사 실패</p> + <p className="text-sm">표시된 텍스트를 Ctrl+C로 복사하세요.</p> + </div>, + { + 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 ( + <div className="h-full flex flex-col"> + {/* 상단 도구 모음 */} + <div className="border-b bg-gray-50 p-3"> + <div className="flex items-center justify-between"> + <div className="flex items-center space-x-2"> + {!isReadOnly && ( + <Button + variant="outline" + size="sm" + onClick={handleSave} + disabled={isSaving || !instance} + > + {isSaving ? ( + <> + <RefreshCw className="mr-2 h-4 w-4 animate-spin" /> + 저장 중... + </> + ) : ( + <> + <Save className="mr-2 h-4 w-4" /> + 저장 + </> + )} + </Button> + )} + + <Button + variant="outline" + size="sm" + onClick={handleDownload} + > + <Download className="mr-2 h-4 w-4" /> + 다운로드 + </Button> + + <Button + variant="outline" + size="sm" + onClick={handleRefresh} + > + <RefreshCw className="mr-2 h-4 w-4" /> + 새로고침 + </Button> + </div> + + <div className="flex items-center space-x-2"> + {templateInfo?.projectCode && ( + <Badge variant="outline"> + <FileText className="mr-1 h-3 w-3" /> + {templateInfo.projectCode} + </Badge> + )} + <Badge variant="secondary"> + {fileName} + </Badge> + {documentVariables.length > 0 && ( + <Badge variant="secondary"> + <Type className="mr-1 h-3 w-3" /> + 변수 {documentVariables.length}개 + </Badge> + )} + </div> + </div> + + + {/* 변수 도구 - Collapsible로 변경 */} + {(predefinedVariables.length > 0 || documentVariables.length > 0) && ( + <Collapsible + open={isVariablePanelOpen} + onOpenChange={setIsVariablePanelOpen} + className="mt-3" + > + <Card> + <CardHeader className="pb-3"> + <div className="flex items-center justify-between"> + <CardTitle className="text-sm flex items-center"> + <Type className="mr-2 h-4 w-4 text-blue-500" /> + 템플릿 변수 관리 + {documentVariables.length > 0 && ( + <Badge variant="secondary" className="ml-2"> + {documentVariables.length}개 + </Badge> + )} + </CardTitle> + <CollapsibleTrigger asChild> + <Button variant="ghost" size="sm"> + {isVariablePanelOpen ? ( + <> + <ChevronUp className="h-4 w-4 mr-1" /> + 접기 + </> + ) : ( + <> + <ChevronDown className="h-4 w-4 mr-1" /> + 펼치기 + </> + )} + </Button> + </CollapsibleTrigger> + </div> + </CardHeader> + + <CollapsibleContent> + <CardContent className="space-y-3"> + {/* 정의된 변수들 */} + {predefinedVariables.length > 0 && ( + <div> + <p className="text-xs text-muted-foreground mb-2">정의된 템플릿 변수 (클릭하여 복사):</p> + <TooltipProvider> + <div className="flex flex-wrap gap-1"> + {predefinedVariables.map((variable, index) => ( + <Tooltip key={index}> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="sm" + className="h-7 px-2 text-xs hover:bg-blue-50" + onClick={() => !isReadOnly && insertVariable(variable)} + disabled={isReadOnly} + > + <span className="font-mono"> + {`{{${variable.name}}}`} + </span> + {variable.required && ( + <span className="ml-1 text-red-500">*</span> + )} + </Button> + </TooltipTrigger> + <TooltipContent> + <div className="space-y-1"> + <p className="font-medium">{variable.displayName}</p> + {variable.description && ( + <p className="text-xs">{variable.description}</p> + )} + {variable.defaultValue && ( + <p className="text-xs">기본값: {variable.defaultValue}</p> + )} + <p className="text-xs text-muted-foreground"> + 타입: {variable.type} {variable.required && "(필수)"} + </p> + </div> + </TooltipContent> + </Tooltip> + ))} + </div> + </TooltipProvider> + </div> + )} + + {/* 문서에서 발견된 변수들 */} + {documentVariables.length > 0 && ( + <div> + <p className="text-xs text-muted-foreground mb-2">문서에서 발견된 변수:</p> + <div className="flex flex-wrap gap-1"> + {documentVariables.map((variable, index) => { + const isDefined = predefinedVariables.find(v => v.name === variable); + return ( + <Badge + key={index} + variant={isDefined ? "secondary" : "destructive"} + className="text-xs" + > + {`{{${variable}}}`} + {!isDefined && ( + <AlertCircle className="ml-1 h-3 w-3" /> + )} + </Badge> + ); + })} + </div> + </div> + )} + + {/* 변수 사용 안내 */} + <div className="mt-3 p-2 bg-blue-50 rounded-lg"> + <div className="flex items-start"> + <AlertCircle className="h-4 w-4 text-blue-600 mt-0.5 mr-2 flex-shrink-0" /> + <div className="text-xs text-blue-900 space-y-1"> + <p className="font-medium">변수 사용 안내</p> + <ul className="list-disc list-inside space-y-0.5"> + <li>{'{{변수명}}'} 형식으로 문서에 변수를 삽입하세요.</li> + <li>필수 변수(*)는 반드시 값이 입력되어야 합니다.</li> + <li>한글 입력 제한 시 외부 에디터에서 작성 후 붙여넣기 하세요.</li> + </ul> + </div> + </div> + </div> + </CardContent> + </CollapsibleContent> + </Card> + </Collapsible> + )} + + {/* 업로드 진행률 */} + {showProgress && ( + <div className="mt-3 p-3 bg-white rounded border"> + <div className="space-y-2"> + <div className="flex justify-between text-sm"> + <span>저장 진행률</span> + <span>{uploadProgress}%</span> + </div> + <Progress value={uploadProgress} /> + </div> + </div> + )} + </div> + + {/* 뷰어 영역 */} + <div className="flex-1 relative overflow-hidden"> + <div className="absolute inset-0"> + <BasicContractTemplateViewer + templateId={templateId} + filePath={filePath} + instance={instance} + setInstance={setInstance} + /> + </div> + </div> + </div> + ); +}
\ No newline at end of file |
