summaryrefslogtreecommitdiff
path: root/lib/project-doc-templates/table/project-doc-template-editor.tsx
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-09-29 13:31:40 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-09-29 13:31:40 +0000
commit4614210aa9878922cfa1e424ce677ef893a1b6b2 (patch)
tree5e7edcce05fbee207230af0a43ed08cd351d7c4f /lib/project-doc-templates/table/project-doc-template-editor.tsx
parente41e3af4e72870d44a94b03e0f3246d6ccaaca48 (diff)
(대표님) 구매 권한설정, data room 등
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.tsx645
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