diff options
Diffstat (limited to 'lib/approval-template/editor/approval-template-editor.tsx')
| -rw-r--r-- | lib/approval-template/editor/approval-template-editor.tsx | 257 |
1 files changed, 257 insertions, 0 deletions
diff --git a/lib/approval-template/editor/approval-template-editor.tsx b/lib/approval-template/editor/approval-template-editor.tsx new file mode 100644 index 00000000..f23ac4bd --- /dev/null +++ b/lib/approval-template/editor/approval-template-editor.tsx @@ -0,0 +1,257 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" +import { Loader, Save, ArrowLeft } from "lucide-react" +import Link from "next/link" + +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 { 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" +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 { useSession } from "next-auth/react" + +interface ApprovalTemplateEditorProps { + templateId: string + initialTemplate: ApprovalTemplate + staticVariables?: Array<{ variableName: string }> + approvalLineOptions: Array<{ id: string; name: string }> + approvalLineCategories: string[] +} + +export function ApprovalTemplateEditor({ templateId, initialTemplate, staticVariables = [], approvalLineOptions, approvalLineCategories }: ApprovalTemplateEditorProps) { + const { data: session } = useSession() + const [rte, setRte] = React.useState<Editor | null>(null) + const [template, setTemplate] = React.useState(initialTemplate) + const [isSaving, startSaving] = React.useTransition() + + // 편집기에 전달할 변수 목록 + // 템플릿(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 [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 ?? "", + }) + + const [category, setCategory] = React.useState(form.category ?? (approvalLineCategories[0] ?? "")) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [lineOptions, setLineOptions] = React.useState(approvalLineOptions as Array<{ id: string; name: string; aplns?: any[]; category?: string | null }>) + const [isLoadingLines, setIsLoadingLines] = React.useState(false) + + React.useEffect(() => { + let active = true + const run = async () => { + setIsLoadingLines(true) + const { success, data } = await getApprovalLineOptionsAction(category || undefined) + if (active) { + setIsLoadingLines(false) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (success && data) setLineOptions(data as any) + // 카테고리 바뀌면 결재선 선택 초기화 + setForm((prev) => ({ ...prev, category, approvalLineId: "" })) + } + } + run() + return () => { + active = false + } + }, [category]) + + function handleChange(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) { + const { name, value } = e.target + setForm((prev) => ({ ...prev, [name]: value })) + } + + function handleSave() { + startSaving(async () => { + if (!session?.user?.id) { + toast.error("로그인이 필요합니다") + return + } + + const { success, error, data } = await updateApprovalTemplateAction(templateId, { + name: form.name, + subject: form.subject, + content: form.content, + description: form.description, + category: form.category || undefined, + approvalLineId: form.approvalLineId ? form.approvalLineId : null, + updatedBy: Number(session.user.id), + }) + + if (!success || error || !data) { + toast.error(error ?? "저장에 실패했습니다") + return + } + + setTemplate(data) + toast.success("저장되었습니다") + }) + } + + return ( + <div className="flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8"> + {/* Header */} + <div className="flex items-center gap-4"> + <Button variant="ghost" size="icon" asChild> + <Link href="/evcp/approval/template"> + <ArrowLeft className="h-4 w-4" /> + </Link> + </Button> + <div className="flex-1"> + <div className="flex items-center gap-3"> + <h1 className="text-2xl font-semibold">{template.name}</h1> + <Badge variant="outline" className="text-xs"> + 최근 수정: {new Date(template.updatedAt).toLocaleDateString("ko-KR")} + </Badge> + {template.category && ( + <Badge variant="secondary" className="text-xs">{template.category}</Badge> + )} + </div> + <p className="text-sm text-muted-foreground">{template.description || "결재 템플릿 편집"}</p> + </div> + <Button onClick={handleSave} disabled={isSaving}> + {isSaving && <Loader className="mr-2 h-4 w-4 animate-spin" />} + <Save className="mr-2 h-4 w-4" /> 저장 + </Button> + </div> + + <Separator /> + + <Tabs defaultValue="editor" className="flex-1"> + <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} + onValueChange={(value) => setCategory(value)} + > + <SelectTrigger> + <SelectValue placeholder="카테고리를 선택하세요" /> + </SelectTrigger> + <SelectContent> + {approvalLineCategories.map((cat) => ( + <SelectItem key={cat} value={cat}> + {cat} + </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>HTML 내용</CardTitle> + <CardDescription>표, 이미지, 변수 등을 자유롭게 편집하세요.</CardDescription> + </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={() => rte?.chain().focus().insertContent(v.html).run()} + > + {`{{${v.label}}}`} + </Button> + ))} + </div> + )} + <RichTextEditor + value={form.content} + onChange={(val) => setForm((prev) => ({ ...prev, content: val }))} + onReady={(editor) => setRte(editor)} + height="400px" + /> + </CardContent> + </Card> + </TabsContent> + + <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: form.content }} /> + </CardContent> + </Card> + </TabsContent> + </Tabs> + </div> + ) +} |
