diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-10-15 12:52:11 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-10-15 12:52:11 +0000 |
| commit | b54f6f03150dd78d86db62201b6386bf14b72394 (patch) | |
| tree | b3092bb34805fdc65eee5282e86a9fb90ba20d6e /lib/cover/table/cover-template-dialog.tsx | |
| parent | c1bd1a2f499ee2f0742170021b37dab410983ab7 (diff) | |
(대표님) 커버, 데이터룸, 파일매니저, 담당자할당 등
Diffstat (limited to 'lib/cover/table/cover-template-dialog.tsx')
| -rw-r--r-- | lib/cover/table/cover-template-dialog.tsx | 455 |
1 files changed, 455 insertions, 0 deletions
diff --git a/lib/cover/table/cover-template-dialog.tsx b/lib/cover/table/cover-template-dialog.tsx new file mode 100644 index 00000000..f5ac3fae --- /dev/null +++ b/lib/cover/table/cover-template-dialog.tsx @@ -0,0 +1,455 @@ +"use client" + +import * as React from "react" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Upload, Save, Download, Copy, Check, Loader2 } from "lucide-react" +import { WebViewerInstance } from "@pdftron/webviewer" +import { Project } from "@/db/schema" +import { toast } from "sonner" +import { quickDownload } from "@/lib/file-download" +import { BasicContractTemplateViewer } from "@/lib/basic-contract/template/basic-contract-template-viewer" +import { useRouter, usePathname } from "next/navigation" + +interface CoverTemplateDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + project: Project | null +} + +export function CoverTemplateDialog({ open, onOpenChange, project }: CoverTemplateDialogProps) { + const [instance, setInstance] = React.useState<WebViewerInstance | null>(null) + const [filePath, setFilePath] = React.useState<string>("") + const [uploadedFile, setUploadedFile] = React.useState<File | null>(null) + const [isSaving, setIsSaving] = React.useState(false) + const [isUploading, setIsUploading] = React.useState(false) + const [copiedVar, setCopiedVar] = React.useState<string | null>(null) + const router = useRouter() + + // 필수 템플릿 변수 + const templateVariables = [ + { key: "docNumber", value: "docNumber", label: "문서 번호" }, + { key: "projectNumber", value: "projectNumber", label: "프로젝트 번호" }, + { key: "projectName", value: "projectName", label: "프로젝트명" } + ] + + // instance 상태 모니터링 + React.useEffect(() => { + console.log("🔍 Instance 상태:", instance ? "있음" : "없음"); + }, [instance]); + + // 다이얼로그가 열릴 때마다 상태 초기화 및 템플릿 로드 + React.useEffect(() => { + if (open) { + // instance는 초기화하지 않음 - 뷰어가 알아서 설정함 + setUploadedFile(null) + setIsSaving(false) + setIsUploading(false) + setCopiedVar(null) + + // 프로젝트에 저장된 템플릿이 있으면 로드 + if (project?.coverTemplatePath) { + setFilePath(project.coverTemplatePath) + } else { + setFilePath("") + } + } else { + // 다이얼로그가 닫힐 때만 완전히 초기화 + setFilePath("") + setInstance(null) + setUploadedFile(null) + } + }, [open, project]) + + // 파일 업로드 핸들러 + const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { + const file = e.target.files?.[0] + if (!file) return + + if (!file.name.endsWith('.docx')) { + toast.error("DOCX 파일만 업로드 가능합니다") + return + } + + setIsUploading(true) + setUploadedFile(file) + + const formData = new FormData() + formData.append("file", file) + formData.append("projectId", String(project?.id)) + + try { + const response = await fetch("/api/projects/cover-template/upload", { + method: "POST", + body: formData, + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.message || "업로드 실패") + } + + const data = await response.json() + setFilePath(data.filePath) + router.refresh() + toast.success("템플릿 파일이 업로드되었습니다") + } catch (error) { + console.error("파일 업로드 오류:", error) + toast.error(error instanceof Error ? error.message : "파일 업로드 중 오류가 발생했습니다") + } finally { + setIsUploading(false) + } + } + + // 복사 함수 - 더 강력한 버전 + const copyToClipboard = async (text: string, key: string) => { + let copySuccess = false; + + // 방법 1: 최신 Clipboard API (가장 확실함) + try { + await navigator.clipboard.writeText(text); + copySuccess = true; + setCopiedVar(key); + setTimeout(() => setCopiedVar(null), 2000); + toast.success(`복사됨: ${text}`, { + description: "문서에 붙여넣으세요 (Ctrl+V)" + }); + return; + } catch (err) { + console.error("Clipboard API 실패:", err); + } + + // 방법 2: 이벤트 기반 복사 (사용자 상호작용 컨텍스트 유지) + try { + const listener = (e: ClipboardEvent) => { + e.clipboardData?.setData('text/plain', text); + e.preventDefault(); + copySuccess = true; + }; + + document.addEventListener('copy', listener); + const result = document.execCommand('copy'); + document.removeEventListener('copy', listener); + + if (result && copySuccess) { + setCopiedVar(key); + setTimeout(() => setCopiedVar(null), 2000); + toast.success(`복사됨: ${text}`, { + description: "문서에 붙여넣으세요 (Ctrl+V)" + }); + return; + } + } catch (err) { + console.error("이벤트 기반 복사 실패:", err); + } + + // 방법 3: textarea 방식 (강화 버전) + try { + const textArea = document.createElement("textarea"); + textArea.value = text; + + // 스타일 설정으로 화면에 보이지 않게 + textArea.style.position = "fixed"; + textArea.style.top = "0"; + textArea.style.left = "0"; + textArea.style.width = "2em"; + textArea.style.height = "2em"; + textArea.style.padding = "0"; + textArea.style.border = "none"; + textArea.style.outline = "none"; + textArea.style.boxShadow = "none"; + textArea.style.background = "transparent"; + textArea.style.opacity = "0"; + + document.body.appendChild(textArea); + + // iOS 대응 + if (navigator.userAgent.match(/ipad|ipod|iphone/i)) { + textArea.contentEditable = "true"; + textArea.readOnly = false; + const range = document.createRange(); + range.selectNodeContents(textArea); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + textArea.setSelectionRange(0, 999999); + } else { + textArea.select(); + textArea.setSelectionRange(0, 99999); + } + + const successful = document.execCommand('copy'); + document.body.removeChild(textArea); + + if (successful) { + setCopiedVar(key); + setTimeout(() => setCopiedVar(null), 2000); + toast.success(`복사됨: ${text}`, { + description: "문서에 붙여넣으세요 (Ctrl+V)" + }); + copySuccess = true; + return; + } + } catch (err) { + console.error("textarea 복사 실패:", err); + } + + // 모든 방법 실패 + if (!copySuccess) { + toast.error("자동 복사 실패", { + description: `수동으로 복사하세요: ${text}`, + duration: 5000, + }); + } + }; + + // 템플릿 저장 + const handleSaveTemplate = async () => { + console.log("💾 저장 시도 - instance:", instance); + console.log("💾 저장 시도 - project:", project); + + if (!instance) { + toast.error("뷰어가 아직 준비되지 않았습니다", { + description: "문서가 완전히 로드될 때까지 기다려주세요" + }) + return + } + + if (!project) { + toast.error("프로젝트 정보가 없습니다") + return + } + + setIsSaving(true) + + try { + const { documentViewer } = instance.Core + const doc = documentViewer.getDocument() + + if (!doc) { + throw new Error("문서가 로드되지 않았습니다") + } + + console.log("📄 문서 export 시작..."); + + // DOCX로 export + const data = await doc.getFileData({ + downloadType: 'office', + includeAnnotations: true + }) + + console.log("✅ 문서 export 완료, 크기:", data.byteLength); + + const blob = new Blob([data], { + type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + }) + + // FormData 생성 + const formData = new FormData() + formData.append("file", blob, `${project.code}_cover_template.docx`) + formData.append("projectId", String(project.id)) + formData.append("templateName", `${project.name} 커버 템플릿`) + + console.log("📤 서버 전송 시작..."); + + const response = await fetch("/api/projects/cover-template/save", { + method: "POST", + body: formData, + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.message || "저장 실패") + } + + const result = await response.json() + + console.log("✅ 서버 저장 완료:", result); + router.refresh() + toast.success("커버 페이지가 생성되었습니다") + + // 저장된 파일 경로 업데이트 + if (result.filePath) { + setFilePath(result.filePath) + } + + onOpenChange(false) + } catch (error) { + console.error("❌ 템플릿 저장 오류:", error) + toast.error(error instanceof Error ? error.message : "템플릿 저장 중 오류가 발생했습니다") + } finally { + setIsSaving(false) + } + } + + const handleDownloadTemplate = () => { + if (!filePath || !project) return + quickDownload(filePath, `${project.code}_cover_template.docx`) + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-7xl w-[90vw] h-[85vh] p-0 gap-0 flex flex-col"> + <DialogHeader className="px-6 py-3 border-b"> + <DialogTitle className="text-base"> + 커버 페이지 템플릿 관리 - {project?.name} ({project?.code}) + </DialogTitle> + </DialogHeader> + + <div className="flex flex-1 min-h-0 overflow-hidden"> + <div className="w-80 h-full border-r p-4 overflow-y-auto flex flex-col gap-4"> + <div className="space-y-2"> + <Label>템플릿 파일 업로드</Label> + <div className="flex gap-2"> + <Input + type="file" + accept=".docx" + onChange={handleFileUpload} + disabled={isUploading} + className="flex-1" + /> + {filePath && ( + <Button + size="icon" + variant="outline" + onClick={handleDownloadTemplate} + title="현재 템플릿 다운로드" + > + <Download className="h-4 w-4" /> + </Button> + )} + </div> + {isUploading && ( + <p className="text-xs text-muted-foreground">업로드 중...</p> + )} + </div> + + <div className="space-y-2"> + <Label>필수 템플릿 변수</Label> + <div className="text-xs text-muted-foreground mb-2"> + 복사 버튼을 클릭하여 변수를 복사한 후 문서에 붙여넣으세요 + </div> + + <div className="space-y-2"> + {templateVariables.map(({ key, value, label }) => ( + <div key={key} className="flex gap-2 items-center"> + <div className="flex-1"> + <div className="text-xs text-muted-foreground mb-1">{label}</div> + <div className="flex gap-2"> + <Input + value={`{{${value}}}`} + readOnly + className="flex-1 text-xs font-mono bg-muted/50" + /> + <Button + type="button" + size="sm" + variant="outline" + onClick={() => copyToClipboard(`{{${value}}}`, key)} + title="클립보드에 복사" + > + {copiedVar === key ? ( + <Check className="h-3 w-3 text-green-600" /> + ) : ( + <Copy className="h-3 w-3" /> + )} + </Button> + </div> + </div> + </div> + ))} + </div> + + <div className="text-xs text-muted-foreground p-3 bg-muted/50 rounded-md mt-3"> + <div className="font-semibold mb-1">💡 사용 방법</div> + 1. 복사 버튼을 클릭하여 변수를 복사<br /> + 2. 문서에서 원하는 위치에 Ctrl+V로 붙여넣기<br /> + 3. 문서 생성 시 변수는 실제 값으로 자동 치환됩니다<br /> + <br /> + <div className="font-semibold">📌 커스텀 변수</div> + 필요한 경우 {`{{customField}}`} 형식으로 직접 입력 가능 + </div> + </div> + + <div className="mt-auto pt-4 space-y-2"> + {/* 상태 표시 */} + <div className="text-xs text-muted-foreground space-y-1 p-2 bg-muted/30 rounded"> + <div className="flex items-center gap-2"> + <div className={`w-2 h-2 rounded-full ${filePath ? 'bg-green-500' : 'bg-gray-300'}`} /> + 파일: {filePath ? '준비됨' : '없음'} + </div> + {filePath && + <div className="flex items-center gap-2"> + <div className={`w-2 h-2 rounded-full ${instance ? 'bg-green-500' : 'bg-yellow-500'}`} /> + 뷰어: {instance ? '준비됨' : '로딩 중...'} + </div> + } + </div> + + <Button + className="w-full" + onClick={handleSaveTemplate} + disabled={!filePath || isSaving || !instance} + > + {(() => { + if (isSaving) { + return ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 저장 중... + </> + ); + } + + if (!filePath) { + return ( + <> + <Upload className="mr-2 h-4 w-4" /> + 파일을 먼저 업로드하세요 + </> + ); + } + + if (!instance) { + return ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 뷰어 로딩 중... + </> + ); + } + + return ( + <> + <Save className="mr-2 h-4 w-4" /> + 커버 페이지 생성 + </> + ); + })()} + </Button> + </div> + </div> + + <div className="flex-1 relative overflow-hidden"> + {filePath ? ( + <div className="absolute inset-0"> + <BasicContractTemplateViewer + key={filePath} + filePath={filePath} + instance={instance} + setInstance={setInstance} + /> + </div> + ) : ( + <div className="flex items-center justify-center h-full text-muted-foreground"> + DOCX 파일을 업로드하세요 + </div> + )} + </div> + </div> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file |
