summaryrefslogtreecommitdiff
path: root/lib/approval-template/editor/approval-template-editor.tsx
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-11-06 17:44:59 +0900
committerjoonhoekim <26rote@gmail.com>2025-11-06 17:44:59 +0900
commit08b73d56c2d887931cecdf2b0af6b277381763e6 (patch)
treee2a1e466445c718dad79c100241048684b8a1923 /lib/approval-template/editor/approval-template-editor.tsx
parentba43cd261d10c6b0c5218a9da3f946993b21de6e (diff)
(김준회) 결재 프리뷰 공통컴포넌트 작성 및 index.ts --> client.ts 분리 (서버사이드 코드가 번들링되어 클라측에서 실행되는 문제 해결 목적)
Diffstat (limited to 'lib/approval-template/editor/approval-template-editor.tsx')
-rw-r--r--lib/approval-template/editor/approval-template-editor.tsx358
1 files changed, 121 insertions, 237 deletions
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>
)
}