diff options
Diffstat (limited to 'lib/approval-template')
| -rw-r--r-- | lib/approval-template/editor/approval-template-editor.tsx | 358 |
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> ) } |
