diff options
| author | joonhoekim <26rote@gmail.com> | 2025-10-23 18:13:41 +0900 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-10-23 18:13:41 +0900 |
| commit | 78c471eec35182959e0029ded18f144974ccaca2 (patch) | |
| tree | 914cdf1c8f406ca3e2aa639b8bb774f7f4e87023 /lib/approval-template/editor/approval-template-editor.tsx | |
| parent | 0be8940580c4a4a4e098b649d198160f9b60420c (diff) | |
(김준회) 결재 템플릿 에디터 및 결재 워크플로 공통함수 작성, 실사의뢰 결재 연결 예시 작성
Diffstat (limited to 'lib/approval-template/editor/approval-template-editor.tsx')
| -rw-r--r-- | lib/approval-template/editor/approval-template-editor.tsx | 146 |
1 files changed, 125 insertions, 21 deletions
diff --git a/lib/approval-template/editor/approval-template-editor.tsx b/lib/approval-template/editor/approval-template-editor.tsx index 2c4ef65e..242504e7 100644 --- a/lib/approval-template/editor/approval-template-editor.tsx +++ b/lib/approval-template/editor/approval-template-editor.tsx @@ -2,13 +2,15 @@ import * as React from "react" import { toast } from "sonner" -import { Loader, Save, ArrowLeft } from "lucide-react" +import { Loader, Save, ArrowLeft, Code2, 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 RichTextEditor from "@/components/rich-text-editor/RichTextEditor" +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" @@ -17,12 +19,26 @@ import { formatApprovalLine } from "@/lib/approval-line/utils/format" import { getApprovalLineOptionsAction } from "@/lib/approval-line/service" import { type ApprovalTemplate } from "@/lib/approval-template/service" -import { type Editor } from "@tiptap/react" import { updateApprovalTemplateAction } from "@/lib/approval-template/service" import { getActiveApprovalTemplateCategories, type ApprovalTemplateCategory } from "@/lib/approval-template/category-service" 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 @@ -34,9 +50,13 @@ export function ApprovalTemplateEditor({ templateId, initialTemplate, staticVari const { data: session } = useSession() const router = useRouter() const pathname = usePathname() - const [rte, setRte] = React.useState<Editor | null>(null) + const editorRef = React.useRef<ToastEditor | null>(null) 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) 변수 병합 @@ -53,7 +73,6 @@ export function ApprovalTemplateEditor({ templateId, initialTemplate, staticVari const [form, setForm] = React.useState({ name: template.name, subject: template.subject, - content: template.content, description: template.description ?? "", category: template.category ?? "", approvalLineId: (template as { approvalLineId?: string | null }).approvalLineId ?? "", @@ -93,6 +112,7 @@ export function ApprovalTemplateEditor({ templateId, initialTemplate, staticVari return () => { active = false } + // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // 빈 의존성 배열로 초기 한 번만 실행 // 결재선 옵션 로드 @@ -120,6 +140,20 @@ 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 handleSave() { startSaving(async () => { if (!session?.user?.id) { @@ -127,10 +161,15 @@ 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: form.content, + content, description: form.description, category: form.category || undefined, approvalLineId: form.approvalLineId ? form.approvalLineId : null, @@ -146,10 +185,12 @@ export function ApprovalTemplateEditor({ templateId, initialTemplate, staticVari toast.success("저장되었습니다") // 저장 후 목록 페이지로 이동 (back-button.tsx 로직 참고) - const segments = pathname.split('/').filter(Boolean) - const newSegments = segments.slice(0, -1) // 마지막 세그먼트(ID) 제거 - const targetPath = newSegments.length > 0 ? `/${newSegments.join('/')}` : '/' - router.push(targetPath) + if (pathname) { + const segments = pathname.split('/').filter(Boolean) + const newSegments = segments.slice(0, -1) // 마지막 세그먼트(ID) 제거 + const targetPath = newSegments.length > 0 ? `/${newSegments.join('/')}` : '/' + router.push(targetPath) + } }) } @@ -182,7 +223,18 @@ export function ApprovalTemplateEditor({ templateId, initialTemplate, staticVari <Separator /> - <Tabs defaultValue="editor" className="flex-1"> + <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> @@ -261,8 +313,30 @@ export function ApprovalTemplateEditor({ templateId, initialTemplate, staticVari <Card> <CardHeader> - <CardTitle>HTML 내용</CardTitle> - <CardDescription>표, 이미지, 변수 등을 자유롭게 편집하세요.</CardDescription> + <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> + </div> </CardHeader> <CardContent> {variableOptions.length > 0 && ( @@ -272,19 +346,49 @@ export function ApprovalTemplateEditor({ templateId, initialTemplate, staticVari key={v.label} variant="outline" size="sm" - onClick={() => rte?.chain().focus().insertContent(v.html).run()} + 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> )} - <RichTextEditor - value={form.content} - onChange={(val) => setForm((prev) => ({ ...prev, content: val }))} - onReady={(editor) => setRte(editor)} - height="400px" - /> + + {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> @@ -295,7 +399,7 @@ export function ApprovalTemplateEditor({ templateId, initialTemplate, staticVari <CardTitle>미리보기</CardTitle> </CardHeader> <CardContent className="border rounded-md p-4 overflow-auto bg-background"> - <div dangerouslySetInnerHTML={{ __html: form.content }} /> + <div dangerouslySetInnerHTML={{ __html: previewContent }} /> </CardContent> </Card> </TabsContent> |
