summaryrefslogtreecommitdiff
path: root/lib/cover/table/cover-template-dialog.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/cover/table/cover-template-dialog.tsx')
-rw-r--r--lib/cover/table/cover-template-dialog.tsx455
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