summaryrefslogtreecommitdiff
path: root/lib/approval-template/editor/approval-template-editor.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/approval-template/editor/approval-template-editor.tsx')
-rw-r--r--lib/approval-template/editor/approval-template-editor.tsx257
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>
+ )
+}