From 08b73d56c2d887931cecdf2b0af6b277381763e6 Mon Sep 17 00:00:00 2001
From: joonhoekim <26rote@gmail.com>
Date: Thu, 6 Nov 2025 17:44:59 +0900
Subject: (김준회) 결재 프리뷰 공통컴포넌트 작성 및 index.ts --> client.ts 분리
 (서버사이드 코드가 번들링되어 클라측에서 실행되는 문제 해결 목적)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../editor/approval-template-editor.tsx            | 358 +++++++--------------
 1 file changed, 121 insertions(+), 237 deletions(-)

(limited to 'lib/approval-template/editor/approval-template-editor.tsx')

diff --git a/lib/approval-template/editor/approval-template-editor.tsx b/lib/approval-template/editor/approval-template-editor.tsx
index 242504e7..7fe7c700 100644
--- a/lib/approval-template/editor/approval-template-editor.tsx
+++ b/lib/approval-template/editor/approval-template-editor.tsx
@@ -2,16 +2,13 @@
 
 import * as React from "react"
 import { toast } from "sonner"
-import { Loader, Save, ArrowLeft, Code2, Eye } from "lucide-react"
+import { Loader, Save, ArrowLeft, Eye } from "lucide-react"
 import Link from "next/link"
-import dynamic from "next/dynamic"
-import type { Editor as ToastEditor } from "@toast-ui/editor"
 
 import { Button } from "@/components/ui/button"
 import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
 import { Input } from "@/components/ui/input"
 import { Textarea } from "@/components/ui/textarea"
-import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
 import { Separator } from "@/components/ui/separator"
 import { Badge } from "@/components/ui/badge"
 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -24,51 +21,20 @@ import { getActiveApprovalTemplateCategories, type ApprovalTemplateCategory } fr
 import { useSession } from "next-auth/react"
 import { useRouter, usePathname } from "next/navigation"
 
-// Toast UI Editor를 dynamic import로 불러옴 (SSR 방지)
-const Editor = dynamic(
-  async () => {
-    // CSS를 먼저 로드
-    // @ts-expect-error - CSS 파일은 타입 선언이 없지만 런타임에 정상 작동
-    await import("@toast-ui/editor/dist/toastui-editor.css")
-    const mod = await import("@toast-ui/react-editor")
-    return mod.Editor
-  },
-  {
-    ssr: false,
-    loading: () => <div className="flex items-center justify-center h-[400px] text-muted-foreground">에디터 로드 중...</div>,
-  }
-)
-
 interface ApprovalTemplateEditorProps {
   templateId: string
   initialTemplate: ApprovalTemplate
-  staticVariables?: Array<{ variableName: string }>
   approvalLineOptions: Array<{ id: string; name: string }>
 }
 
-export function ApprovalTemplateEditor({ templateId, initialTemplate, staticVariables = [], approvalLineOptions }: ApprovalTemplateEditorProps) {
-    const { data: session } = useSession()
-    const router = useRouter()
-    const pathname = usePathname()
-  const editorRef = React.useRef<ToastEditor | null>(null)
+export function ApprovalTemplateEditor({ templateId, initialTemplate, approvalLineOptions }: ApprovalTemplateEditorProps) {
+  const { data: session } = useSession()
+  const router = useRouter()
+  const pathname = usePathname()
   const [template, setTemplate] = React.useState(initialTemplate)
   const [isSaving, startSaving] = React.useTransition()
-  const [previewContent, setPreviewContent] = React.useState(initialTemplate.content)
-  const [editMode, setEditMode] = React.useState<"wysiwyg" | "html">("wysiwyg")
-  const [htmlSource, setHtmlSource] = React.useState(initialTemplate.content)
-  const [editorKey, setEditorKey] = React.useState(0) // 에디터 재마운트를 위한 키
-
-  // 편집기에 전달할 변수 목록
-  // 템플릿(DB) 변수 + 정적(config) 변수 병합
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  const dbVariables: string[] = Array.isArray((template as any).variables)
-    ? // eslint-disable-next-line @typescript-eslint/no-explicit-any
-      (template as any).variables.map((v: any) => v.variableName)
-    : []
-
-  const mergedVariables = Array.from(new Set([...dbVariables, ...staticVariables.map((v) => v.variableName)]))
-
-  const variableOptions = mergedVariables.map((name) => ({ label: name, html: `{{${name}}}` }))
+  const [htmlContent, setHtmlContent] = React.useState(initialTemplate.content)
+  const [previewKey, setPreviewKey] = React.useState(0) // 미리보기 업데이트용
 
   const [form, setForm] = React.useState({
     name: template.name,
@@ -140,18 +106,10 @@ export function ApprovalTemplateEditor({ templateId, initialTemplate, staticVari
     setForm((prev) => ({ ...prev, [name]: value }))
   }
 
-  // 편집 모드 토글
-  function toggleEditMode() {
-    if (editMode === "wysiwyg") {
-      // WYSIWYG → HTML: 에디터에서 HTML 가져오기
-      const currentHtml = editorRef.current?.getHTML() || ""
-      setHtmlSource(currentHtml)
-      setEditMode("html")
-    } else {
-      // HTML → WYSIWYG: 에디터를 재마운트하여 수정된 HTML 반영
-      setEditorKey(prev => prev + 1)
-      setEditMode("wysiwyg")
-    }
+  // 미리보기 새로고침
+  function refreshPreview() {
+    setPreviewKey((prev) => prev + 1)
+    toast.success("미리보기를 업데이트했습니다")
   }
 
   function handleSave() {
@@ -161,15 +119,10 @@ export function ApprovalTemplateEditor({ templateId, initialTemplate, staticVari
         return
       }
 
-      // 현재 모드에 따라 HTML 가져오기
-      const content = editMode === "wysiwyg" 
-        ? editorRef.current?.getHTML() || ""
-        : htmlSource
-
       const { success, error, data } = await updateApprovalTemplateAction(templateId, {
         name: form.name,
         subject: form.subject,
-        content,
+        content: htmlContent,
         description: form.description,
         category: form.category || undefined,
         approvalLineId: form.approvalLineId ? form.approvalLineId : null,
@@ -215,6 +168,9 @@ export function ApprovalTemplateEditor({ templateId, initialTemplate, staticVari
           </div>
           <p className="text-sm text-muted-foreground">{template.description || "결재 템플릿 편집"}</p>
         </div>
+        <Button onClick={refreshPreview} variant="outline" size="sm">
+          <Eye className="mr-2 h-4 w-4" /> 미리보기 새로고침
+        </Button>
         <Button onClick={handleSave} disabled={isSaving}>
           {isSaving && <Loader className="mr-2 h-4 w-4 animate-spin" />}
           <Save className="mr-2 h-4 w-4" /> 저장
@@ -223,187 +179,115 @@ export function ApprovalTemplateEditor({ templateId, initialTemplate, staticVari
 
       <Separator />
 
-      <Tabs 
-        defaultValue="editor" 
-        className="flex-1"
-        onValueChange={(value) => {
-          if (value === "preview") {
-            const currentHtml = editMode === "wysiwyg"
-              ? editorRef.current?.getHTML() || ""
-              : htmlSource
-            setPreviewContent(currentHtml)
-          }
-        }}
-      >
-        <TabsList className="grid w-full grid-cols-2">
-          <TabsTrigger value="editor">편집</TabsTrigger>
-          <TabsTrigger value="preview">미리보기</TabsTrigger>
-        </TabsList>
-
-        <TabsContent value="editor" className="mt-4 flex flex-col gap-4">
-          <Card>
-            <CardHeader>
-              <CardTitle>기본 정보</CardTitle>
-            </CardHeader>
-            <CardContent className="space-y-4">
-              <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
-                <div className="space-y-2">
-                  <label className="text-sm font-medium">이름</label>
-                  <Input name="name" value={form.name} onChange={handleChange} />
-                </div>
-                <div className="space-y-2">
-                  <label className="text-sm font-medium">카테고리</label>
-                  <Select
-                    value={category || "none"}
-                    onValueChange={(value) => setCategory(value === "none" ? "" : value)}
-                    disabled={isLoadingCategories}
-                  >
-                    <SelectTrigger>
-                      <SelectValue placeholder={isLoadingCategories ? "카테고리 로드 중..." : "카테고리를 선택하세요"} />
-                    </SelectTrigger>
-                    <SelectContent>
-                      <SelectItem value="none">선택 안함</SelectItem>
-                      {categories
-                        .sort((a, b) => a.sortOrder - b.sortOrder)
-                        .map((cat) => (
-                          <SelectItem key={`category-${cat.id}`} value={cat.name}>
-                            {cat.name}
-                            {cat.description && (
-                              <span className="text-muted-foreground ml-2">({cat.description})</span>
-                            )}
-                          </SelectItem>
-                        ))}
-                    </SelectContent>
-                  </Select>
-                </div>
-              </div>
-              <div className="space-y-2">
-                <label className="text-sm font-medium">설명 (선택)</label>
-                <Input name="description" value={form.description} onChange={handleChange} />
-              </div>
-              <div className="space-y-2">
-                <label className="text-sm font-medium">제목</label>
-                <Input name="subject" value={form.subject} onChange={handleChange} />
-              </div>
-              <div className="space-y-2">
-                <label className="text-sm font-medium">결재선</label>
-                <Select
-                  value={form.approvalLineId}
-                  onValueChange={(value) => setForm((prev) => ({ ...prev, approvalLineId: value }))}
-                  disabled={!category || isLoadingLines}
-                >
-                  <SelectTrigger>
-                    <SelectValue placeholder={category ? (isLoadingLines ? "불러오는 중..." : "결재선을 선택하세요") : "카테고리를 먼저 선택하세요"} />
-                  </SelectTrigger>
-                  <SelectContent>
-                    {lineOptions.map((opt) => {
-                      // eslint-disable-next-line @typescript-eslint/no-explicit-any
-                      const label = opt.aplns ? `${opt.name} — ${formatApprovalLine(opt.aplns as any)}` : opt.name
-                      return (
-                        <SelectItem key={opt.id} value={opt.id}>
-                          {label}
-                        </SelectItem>
-                      )
-                    })}
-                  </SelectContent>
-                </Select>
-              </div>
-            </CardContent>
-          </Card>
+      {/* 기본 정보 */}
+      <Card>
+        <CardHeader>
+          <CardTitle>기본 정보</CardTitle>
+        </CardHeader>
+        <CardContent className="space-y-4">
+          <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+            <div className="space-y-2">
+              <label className="text-sm font-medium">이름</label>
+              <Input name="name" value={form.name} onChange={handleChange} />
+            </div>
+            <div className="space-y-2">
+              <label className="text-sm font-medium">카테고리</label>
+              <Select
+                value={category || "none"}
+                onValueChange={(value) => setCategory(value === "none" ? "" : value)}
+                disabled={isLoadingCategories}
+              >
+                <SelectTrigger>
+                  <SelectValue placeholder={isLoadingCategories ? "카테고리 로드 중..." : "카테고리를 선택하세요"} />
+                </SelectTrigger>
+                <SelectContent>
+                  <SelectItem value="none">선택 안함</SelectItem>
+                  {categories
+                    .sort((a, b) => a.sortOrder - b.sortOrder)
+                    .map((cat) => (
+                      <SelectItem key={`category-${cat.id}`} value={cat.name}>
+                        {cat.name}
+                        {cat.description && (
+                          <span className="text-muted-foreground ml-2">({cat.description})</span>
+                        )}
+                      </SelectItem>
+                    ))}
+                </SelectContent>
+              </Select>
+            </div>
+          </div>
+          <div className="space-y-2">
+            <label className="text-sm font-medium">설명 (선택)</label>
+            <Input name="description" value={form.description} onChange={handleChange} />
+          </div>
+          <div className="space-y-2">
+            <label className="text-sm font-medium">제목</label>
+            <Input name="subject" value={form.subject} onChange={handleChange} />
+          </div>
+          <div className="space-y-2">
+            <label className="text-sm font-medium">결재선</label>
+            <Select
+              value={form.approvalLineId}
+              onValueChange={(value) => setForm((prev) => ({ ...prev, approvalLineId: value }))}
+              disabled={!category || isLoadingLines}
+            >
+              <SelectTrigger>
+                <SelectValue placeholder={category ? (isLoadingLines ? "불러오는 중..." : "결재선을 선택하세요") : "카테고리를 먼저 선택하세요"} />
+              </SelectTrigger>
+              <SelectContent>
+                {lineOptions.map((opt) => {
+                  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+                  const label = opt.aplns ? `${opt.name} — ${formatApprovalLine(opt.aplns as any)}` : opt.name
+                  return (
+                    <SelectItem key={opt.id} value={opt.id}>
+                      {label}
+                    </SelectItem>
+                  )
+                })}
+              </SelectContent>
+            </Select>
+          </div>
+        </CardContent>
+      </Card>
 
-          <Card>
-            <CardHeader>
-              <div className="flex items-center justify-between">
-                <div>
-                  <CardTitle>HTML 내용</CardTitle>
-                  <CardDescription>표, 이미지, 변수 등을 자유롭게 편집하세요.</CardDescription>
-                </div>
-                <Button
-                  variant="outline"
-                  size="sm"
-                  onClick={toggleEditMode}
-                  className="flex items-center gap-2"
-                >
-                  {editMode === "wysiwyg" ? (
-                    <>
-                      <Code2 className="h-4 w-4" />
-                      HTML로 수정
-                    </>
-                  ) : (
-                    <>
-                      <Eye className="h-4 w-4" />
-                      에디터로 수정
-                    </>
-                  )}
-                </Button>
+      {/* 2컬럼 레이아웃: 왼쪽 HTML 편집기, 오른쪽 미리보기 */}
+      <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 flex-1">
+        {/* 왼쪽: HTML 편집기 */}
+        <Card className="flex flex-col">
+          <CardHeader>
+            <div className="flex items-center justify-between">
+              <div>
+                <CardTitle>HTML 편집기</CardTitle>
+                <CardDescription>HTML 코드를 직접 편집하세요</CardDescription>
               </div>
-            </CardHeader>
-            <CardContent>
-              {variableOptions.length > 0 && (
-                <div className="mb-4 flex flex-wrap gap-2">
-                  {variableOptions.map((v: { label: string; html: string }) => (
-                    <Button
-                      key={v.label}
-                      variant="outline"
-                      size="sm"
-                      onClick={() => {
-                        if (editMode === "wysiwyg" && editorRef.current) {
-                          editorRef.current.insertText(v.html)
-                        } else if (editMode === "html") {
-                          // HTML 모드에서는 텍스트 끝에 추가
-                          setHtmlSource((prev) => prev + v.html)
-                        }
-                      }}
-                    >
-                      {`{{${v.label}}}`}
-                    </Button>
-                  ))}
-                </div>
-              )}
-              
-              {editMode === "wysiwyg" ? (
-                <Editor
-                  key={editorKey}
-                  initialValue={htmlSource}
-                  previewStyle="vertical"
-                  height="500px"
-                  initialEditType="wysiwyg"
-                  useCommandShortcut={true}
-                  hideModeSwitch={true}
-                  toolbarItems={[
-                    ["heading", "bold", "italic", "strike"],
-                    ["hr", "quote"],
-                    ["ul", "ol", "task"],
-                    ["table", "link"],
-                    ["code", "codeblock"],
-                  ]}
-                  onLoad={(editor) => {
-                    editorRef.current = editor
-                  }}
-                />
-              ) : (
-                <Textarea
-                  value={htmlSource}
-                  onChange={(e) => setHtmlSource(e.target.value)}
-                  className="font-mono text-sm min-h-[500px] resize-y"
-                  placeholder="HTML 소스를 직접 편집하세요..."
-                />
-              )}
-            </CardContent>
-          </Card>
-        </TabsContent>
+            </div>
+          </CardHeader>
+          <CardContent className="flex-1 flex flex-col">
+            {/* HTML 입력 영역 */}
+            <Textarea
+              value={htmlContent}
+              onChange={(e) => setHtmlContent(e.target.value)}
+              className="font-mono text-sm flex-1 min-h-[600px] resize-none"
+              placeholder="HTML 소스를 입력하세요..."
+            />
+          </CardContent>
+        </Card>
 
-        <TabsContent value="preview" className="mt-4">
-          <Card>
-            <CardHeader>
-              <CardTitle>미리보기</CardTitle>
-            </CardHeader>
-            <CardContent className="border rounded-md p-4 overflow-auto bg-background">
-              <div dangerouslySetInnerHTML={{ __html: previewContent }} />
-            </CardContent>
-          </Card>
-        </TabsContent>
-      </Tabs>
+        {/* 오른쪽: 미리보기 */}
+        <Card className="flex flex-col">
+          <CardHeader>
+            <CardTitle>미리보기</CardTitle>
+            <CardDescription>HTML이 실시간으로 렌더링됩니다</CardDescription>
+          </CardHeader>
+          <CardContent className="flex-1 overflow-auto">
+            <div 
+              key={previewKey}
+              className="border rounded-md p-4 bg-background min-h-[600px]"
+              dangerouslySetInnerHTML={{ __html: htmlContent }} 
+            />
+          </CardContent>
+        </Card>
+      </div>
     </div>
   )
 }
-- 
cgit v1.2.3

