From bcd462d6e60871b86008e072f4b914138fc5c328 Mon Sep 17 00:00:00 2001
From: joonhoekim <26rote@gmail.com>
Date: Mon, 11 Aug 2025 09:34:40 +0000
Subject: (김준회) 리치텍스트에디터 (결재템플릿을 위한 공통컴포넌트),
 command-menu 에러 수정, 결재 템플릿 관리, 결재선 관리, ECC RFQ+PR Item 수신시
 비즈니스테이블(ProcurementRFQ) 데이터 적재, WSDL 오류 수정
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../editor/approval-template-editor.tsx            | 257 +++++++++++++++++++++
 1 file changed, 257 insertions(+)
 create mode 100644 lib/approval-template/editor/approval-template-editor.tsx

(limited to 'lib/approval-template/editor/approval-template-editor.tsx')

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>
+  )
+}
-- 
cgit v1.2.3

