summaryrefslogtreecommitdiff
path: root/lib/email-template
diff options
context:
space:
mode:
Diffstat (limited to 'lib/email-template')
-rw-r--r--lib/email-template/editor/template-content-editor.tsx609
-rw-r--r--lib/email-template/editor/template-editor.tsx175
-rw-r--r--lib/email-template/editor/template-preview.tsx406
-rw-r--r--lib/email-template/editor/template-settings.tsx474
-rw-r--r--lib/email-template/editor/template-variable-manager.tsx562
-rw-r--r--lib/email-template/security.ts178
-rw-r--r--lib/email-template/service.ts899
-rw-r--r--lib/email-template/table/create-template-sheet.tsx381
-rw-r--r--lib/email-template/table/delete-template-dialog.tsx137
-rw-r--r--lib/email-template/table/duplicate-template-sheet.tsx208
-rw-r--r--lib/email-template/table/email-template-table.tsx155
-rw-r--r--lib/email-template/table/template-table-columns.tsx296
-rw-r--r--lib/email-template/table/template-table-toolbar-actions.tsx115
-rw-r--r--lib/email-template/table/update-template-sheet.tsx215
-rw-r--r--lib/email-template/validations.ts82
15 files changed, 4892 insertions, 0 deletions
diff --git a/lib/email-template/editor/template-content-editor.tsx b/lib/email-template/editor/template-content-editor.tsx
new file mode 100644
index 00000000..4d31753c
--- /dev/null
+++ b/lib/email-template/editor/template-content-editor.tsx
@@ -0,0 +1,609 @@
+"use client"
+
+import * as React from "react"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Badge } from "@/components/ui/badge"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { AlertTriangle, Save, Mail, RefreshCw, Settings2, Code2, Lightbulb, Moon, Sun } from "lucide-react"
+import { toast } from "sonner"
+import { type TemplateWithVariables } from "@/db/schema"
+import { previewTemplateAction, updateTemplateAction } from "../service"
+import { useSession } from "next-auth/react"
+import { HtmlCodeEditor } from "@/components/template-editor/html-code-editor"
+import { formatHtml } from "@/lib/utils"
+
+interface TemplateContentEditorProps {
+ template: TemplateWithVariables
+ onUpdate: (template: TemplateWithVariables) => void
+}
+
+export function TemplateContentEditor({ template, onUpdate }: TemplateContentEditorProps) {
+ const [content, setContent] = React.useState(() => {
+ return formatHtml(template.content)
+ })
+ const [subject, setSubject] = React.useState(template.subject || '')
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [isPreviewLoading, setIsPreviewLoading] = React.useState(false)
+ const [previewHtml, setPreviewHtml] = React.useState<string>("")
+ const [previewSubject, setPreviewSubject] = React.useState<string>("")
+ const [validationErrors, setValidationErrors] = React.useState<string[]>([])
+ const [sampleData, setSampleData] = React.useState(template.sampleData || {})
+ const [autoPreview, setAutoPreview] = React.useState(true)
+ const [darkMode, setDarkMode] = React.useState(false) // 다크모드 상태 추가
+ const { data: session } = useSession();
+
+ React.useEffect(() => {
+ if (template.content !== content) {
+ const formatted = formatHtml(template.content)
+ setContent(formatted)
+ }
+ }, [template.content])
+
+ // HtmlCodeEditor ref
+ const editorRef = React.useRef<{
+ focus: () => void
+ insertText: (text: string) => void
+ getEditor: () => any
+ }>(null)
+
+ React.useEffect(() => {
+ if (!session?.user?.id) {
+ toast.error("로그인이 필요합니다");
+ }
+ }, [session]);
+
+ // 자동 미리보기 (디바운스) - 시간 늘림
+ React.useEffect(() => {
+ if (!autoPreview) return
+
+ const timer = setTimeout(() => {
+ handlePreview(true)
+ }, 1500) // 1.5초로 증가
+
+ return () => clearTimeout(timer)
+ }, [content, subject, sampleData, autoPreview])
+
+ // 실시간 검증
+ React.useEffect(() => {
+ validateContent(content, subject)
+ }, [content, subject])
+
+ // 변수 사용량 분석
+ const analyzeVariableUsage = React.useCallback(() => {
+ const templateVars = template.variables.map(v => v.variableName)
+ const usedVars = new Set<string>()
+
+ const combinedText = content + ' ' + subject
+ const matches = combinedText.match(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g)
+ if (matches) {
+ matches.forEach(match => {
+ const varName = match.replace(/\{\{\s*|\s*\}\}/g, '')
+ usedVars.add(varName)
+ })
+ }
+
+ const unusedVars = templateVars.filter(v => !usedVars.has(v))
+ const undefinedVars = Array.from(usedVars).filter(v =>
+ !templateVars.includes(v) &&
+ !['t', 'siteName', 'companyName', 'i18n'].includes(v)
+ )
+
+ return { unusedVars, undefinedVars, usedVars: Array.from(usedVars) }
+ }, [content, subject, template.variables])
+
+ // 검증 함수
+ const validateContent = (content: string, subject: string) => {
+ const errors: string[] = []
+
+ if (subject.length > 200) {
+ errors.push('이메일 제목이 너무 깁니다 (200자 초과)')
+ }
+
+ const forbiddenChars = ['\n', '\r', '\t']
+ forbiddenChars.forEach(char => {
+ if (subject.includes(char)) {
+ errors.push('이메일 제목에 개행 문자나 탭 문자를 사용할 수 없습니다')
+ }
+ })
+
+ // HTML 기본 검증 추가
+ if (content.trim()) {
+ try {
+ const parser = new DOMParser()
+ const doc = parser.parseFromString(`<div>${content}</div>`, 'text/html')
+
+ const parseError = doc.querySelector('parsererror')
+ if (parseError) {
+ errors.push('유효하지 않은 HTML 구조가 감지되었습니다.')
+ }
+ } catch (error) {
+ errors.push('HTML 구문 분석 중 오류가 발생했습니다.')
+ }
+ }
+
+ const dangerousPatterns = [
+ /\{\{\s*(constructor|prototype|__proto__|process|global|require|import|eval|Function)\s*\}\}/gi,
+ /\{\{\s*.*\.(constructor|prototype|__proto__)\s*.*\}\}/gi,
+ ]
+
+ const combinedText = content + ' ' + subject
+ for (const pattern of dangerousPatterns) {
+ if (pattern.test(combinedText)) {
+ errors.push('보안상 위험한 구문이 감지되었습니다.')
+ break
+ }
+ }
+
+ setValidationErrors(errors)
+ }
+
+ // 저장 함수
+ const handleSave = async () => {
+ if (validationErrors.length > 0) {
+ toast.error('검증 오류를 먼저 해결해주세요.')
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ if (!session?.user?.id) {
+ toast.error("로그인이 필요합니다")
+ return
+ }
+
+ const result = await updateTemplateAction(template.slug, {
+ subject,
+ content,
+ sampleData,
+ updatedBy: Number(session.user.id)
+ })
+
+ if (result.success) {
+ toast.success('템플릿이 저장되었습니다.')
+ onUpdate({
+ ...template,
+ subject,
+ content,
+ sampleData,
+ version: template.version + 1
+ })
+ } else {
+ toast.error(result.error || '저장에 실패했습니다.')
+ }
+ } catch (error) {
+ toast.error('저장 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 미리보기 생성
+ const handlePreview = async (silent = false) => {
+ if (!silent) setIsPreviewLoading(true)
+
+ try {
+ const result = await previewTemplateAction(
+ template.slug,
+ sampleData,
+ content,
+ // subject // 주석 해제
+ )
+
+ if (result.success) {
+ setPreviewHtml(result.data.html)
+ setPreviewSubject(result.data.subject)
+ if (!silent) toast.success('미리보기가 생성되었습니다.')
+ } else {
+ if (!silent) toast.error(result.error || '미리보기 생성에 실패했습니다.')
+ }
+ } catch (error) {
+ if (!silent) toast.error('미리보기 생성 중 오류가 발생했습니다.')
+ } finally {
+ if (!silent) setIsPreviewLoading(false)
+ }
+ }
+
+ // 변수 삽입 함수 - HtmlCodeEditor 지원
+ const insertVariable = (variableName: string, targetField: 'subject' | 'content') => {
+ const variable = `{{${variableName}}}`
+
+ if (targetField === 'subject') {
+ const input = document.querySelector('input[data-subject-input]') as HTMLInputElement
+ if (input) {
+ const start = input.selectionStart || 0
+ const end = input.selectionEnd || 0
+ const newValue = subject.substring(0, start) + variable + subject.substring(end)
+ setSubject(newValue)
+
+ setTimeout(() => {
+ input.focus()
+ input.setSelectionRange(start + variable.length, start + variable.length)
+ }, 0)
+ }
+ } else {
+ // HtmlCodeEditor에 변수 삽입
+ if (editorRef.current) {
+ editorRef.current.insertText(variable)
+ }
+ }
+ }
+
+ // HTML 템플릿 스니펫 삽입 - HtmlCodeEditor 지원
+ const insertHtmlSnippet = (snippet: string) => {
+ if (editorRef.current) {
+ editorRef.current.insertText(snippet)
+ }
+ }
+
+ // 샘플 데이터 업데이트
+ const updateSampleData = (key: string, value: any) => {
+ const newSampleData = { ...sampleData, [key]: value }
+ setSampleData(newSampleData)
+ }
+
+ const variableAnalysis = analyzeVariableUsage()
+
+ // HTML 스니펫들 - 개행 추가로 더 깔끔하게
+ const htmlSnippets = [
+ {
+ name: '버튼',
+ snippet: `\n<div style="text-align: center; margin: 20px 0;">
+ <a href="{{buttonUrl}}" style="background-color: #007bff; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block;">
+ {{buttonText}}
+ </a>
+</div>\n`
+ },
+ {
+ name: '구분선',
+ snippet: `\n<hr style="border: none; border-top: 1px solid #eee; margin: 30px 0;">\n`
+ },
+ {
+ name: '박스',
+ snippet: `\n<div style="background-color: #f8f9fa; border: 1px solid #dee2e6; border-radius: 5px; padding: 20px; margin: 20px 0;">
+ {{content}}
+</div>\n`
+ },
+ {
+ name: '이미지',
+ snippet: `\n<div style="text-align: center; margin: 20px 0;">
+ <img src="{{imageUrl}}" alt="{{imageAlt}}" style="max-width: 100%; height: auto;">
+</div>\n`
+ },
+ {
+ name: '2열 레이아웃',
+ snippet: `\n<table width="100%" cellpadding="0" cellspacing="0" style="margin: 20px 0;">
+ <tr>
+ <td width="50%" style="padding: 10px;">
+ {{leftContent}}
+ </td>
+ <td width="50%" style="padding: 10px;">
+ {{rightContent}}
+ </td>
+ </tr>
+</table>\n`
+ },
+ {
+ name: '헤더',
+ snippet: `\n<h2 style="color: #333; margin: 20px 0 10px 0; font-size: 24px;">{{title}}</h2>\n`
+ }
+ ]
+
+ return (
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
+ {/* 왼쪽: 편집 영역 */}
+ <div className="space-y-6">
+ {/* 상태 표시 */}
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ {validationErrors.length > 0 && (
+ <Badge variant="destructive" className="gap-1">
+ <AlertTriangle className="h-3 w-3" />
+ {validationErrors.length}개 오류
+ </Badge>
+ )}
+
+ {variableAnalysis.undefinedVars.length > 0 && (
+ <Badge variant="secondary" className="gap-1">
+ <AlertTriangle className="h-3 w-3" />
+ {variableAnalysis.undefinedVars.length}개 미정의 변수
+ </Badge>
+ )}
+
+ <Badge variant="outline">
+ {variableAnalysis.usedVars.length}/{template.variables.length} 변수 사용 중
+ </Badge>
+ </div>
+
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setDarkMode(!darkMode)}
+ title="다크모드 전환"
+ >
+ {darkMode ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
+ </Button>
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setAutoPreview(!autoPreview)}
+ className={autoPreview ? 'bg-blue-50' : ''}
+ >
+ <RefreshCw className={`mr-2 h-4 w-4 ${autoPreview ? 'text-blue-600' : ''}`} />
+ 자동 미리보기
+ </Button>
+
+ <Button
+ size="sm"
+ onClick={handleSave}
+ disabled={isLoading || validationErrors.length > 0}
+ >
+ <Save className="mr-2 h-4 w-4" />
+ {isLoading ? '저장 중...' : '저장'}
+ </Button>
+ </div>
+ </div>
+
+ {/* 오류 표시 */}
+ {validationErrors.length > 0 && (
+ <div className="bg-red-50 border border-red-200 rounded-lg p-4">
+ <div className="flex items-start gap-3">
+ <AlertTriangle className="h-5 w-5 text-red-600 mt-0.5" />
+ <div>
+ <h3 className="font-semibold text-red-800">검증 오류</h3>
+ <ul className="mt-2 text-sm text-red-700 space-y-1">
+ {validationErrors.map((error, index) => (
+ <li key={index}>• {error}</li>
+ ))}
+ </ul>
+ </div>
+ </div>
+ </div>
+ )}
+
+ {/* 이메일 제목 편집 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Mail className="h-5 w-5" />
+ 이메일 제목
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div>
+ <Label htmlFor="subject">제목 템플릿</Label>
+ <Input
+ id="subject"
+ data-subject-input
+ value={subject}
+ onChange={(e) => setSubject(e.target.value)}
+ placeholder="예: {{userName}}님의 {{type}} 알림"
+ className="font-mono"
+ />
+ </div>
+
+ <div>
+ <Label className="text-sm font-medium">제목에 변수 삽입</Label>
+ <div className="flex flex-wrap gap-1 mt-2">
+ {template.variables.map(variable => (
+ <Badge
+ key={variable.id}
+ variant="outline"
+ className="cursor-pointer hover:bg-blue-50"
+ onClick={() => insertVariable(variable.variableName, 'subject')}
+ >
+ {variable.variableName}
+ </Badge>
+ ))}
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 변수 분석 결과 */}
+ {(variableAnalysis.undefinedVars.length > 0 || variableAnalysis.unusedVars.length > 0) && (
+ <div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
+ <h3 className="font-semibold text-blue-800 mb-3">변수 사용 분석</h3>
+
+ {variableAnalysis.undefinedVars.length > 0 && (
+ <div className="mb-3">
+ <h4 className="text-sm font-medium text-blue-700 mb-1">미정의 변수</h4>
+ <div className="flex flex-wrap gap-1">
+ {variableAnalysis.undefinedVars.map(varName => (
+ <Badge key={varName} variant="destructive" className="text-xs">
+ {varName}
+ </Badge>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {variableAnalysis.unusedVars.length > 0 && (
+ <div>
+ <h4 className="text-sm font-medium text-blue-700 mb-1">미사용 변수</h4>
+ <div className="flex flex-wrap gap-1">
+ {variableAnalysis.unusedVars.map(varName => (
+ <Badge key={varName} variant="secondary" className="text-xs">
+ {varName}
+ </Badge>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+ )}
+
+ {/* HTML 스니펫 도구 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Code2 className="h-5 w-5" />
+ 빠른 HTML 삽입
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-2 gap-2">
+ {htmlSnippets.map((snippet, index) => (
+ <Button
+ key={index}
+ variant="outline"
+ size="sm"
+ onClick={() => insertHtmlSnippet(snippet.snippet)}
+ className="justify-start"
+ >
+ {snippet.name}
+ </Button>
+ ))}
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* HtmlCodeEditor로 교체된 HTML 편집기 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Code2 className="h-5 w-5" />
+ HTML 템플릿 편집
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {/* HtmlCodeEditor 사용 */}
+ <HtmlCodeEditor
+ ref={editorRef}
+ value={content}
+ onChange={setContent}
+ height="600px"
+ darkMode={darkMode}
+ placeholder="HTML 템플릿을 입력하세요..."
+ />
+
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ {/* 변수 삽입 */}
+ <div className="bg-gray-50 p-4 rounded-lg">
+ <h4 className="font-medium text-gray-900 mb-2">변수 삽입</h4>
+ <div className="flex flex-wrap gap-1">
+ {template.variables.map(variable => (
+ <Badge
+ key={variable.id}
+ variant="outline"
+ className="cursor-pointer hover:bg-blue-50"
+ onClick={() => insertVariable(variable.variableName, 'content')}
+ >
+ {variable.variableName}
+ </Badge>
+ ))}
+ </div>
+ </div>
+
+ {/* 도움말 */}
+ <div className="bg-amber-50 p-4 rounded-lg">
+ <h4 className="font-medium text-amber-900 mb-2 flex items-center gap-1">
+ <Lightbulb className="h-4 w-4" />
+ 이메일 HTML 팁
+ </h4>
+ <ul className="text-sm text-amber-800 space-y-1">
+ <li>• 테이블 기반 레이아웃 사용</li>
+ <li>• 인라인 CSS 스타일 권장</li>
+ <li>• max-width: 600px 권장</li>
+ <li>• 이미지에 alt 속성 필수</li>
+ </ul>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+
+ {/* 오른쪽: 미리보기 영역 */}
+ <div className="space-y-6">
+ {/* 샘플 데이터 편집 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Settings2 className="h-5 w-5" />
+ 샘플 데이터
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-3">
+ {template.variables.length > 0 ? (
+ <>
+ {template.variables.map(variable => (
+ <div key={variable.id}>
+ <Label className="text-sm">{variable.variableName}</Label>
+ <Input
+ value={sampleData[variable.variableName] || ''}
+ onChange={(e) => updateSampleData(variable.variableName, e.target.value)}
+ placeholder={variable.description || `${variable.variableName} 값 입력`}
+ className="text-sm"
+ />
+ </div>
+ ))}
+
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => handlePreview(false)}
+ disabled={isPreviewLoading}
+ className="w-full mt-4"
+ >
+ <RefreshCw className={`mr-2 h-4 w-4 ${isPreviewLoading ? 'animate-spin' : ''}`} />
+ 미리보기 새로고침
+ </Button>
+ </>
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">
+ <p>변수가 없습니다.</p>
+ <p className="text-sm">변수 관리 탭에서 변수를 추가하세요.</p>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ {/* 미리보기 결과 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>미리보기</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {/* 제목 미리보기 */}
+ {previewSubject && (
+ <div className="p-3 bg-blue-50 rounded-lg">
+ <Label className="text-sm font-medium text-blue-900">제목:</Label>
+ <p className="font-semibold text-blue-900 break-words">{previewSubject}</p>
+ </div>
+ )}
+
+ {/* 내용 미리보기 */}
+ {previewHtml ? (
+ <div className="border rounded-lg bg-white">
+ <iframe
+ srcDoc={previewHtml}
+ sandbox="allow-same-origin"
+ className="w-full h-96 border-0 rounded-lg"
+ title="Template Preview"
+ />
+ </div>
+ ) : (
+ <div className="flex items-center justify-center h-96 bg-gray-50 rounded-lg border-2 border-dashed border-gray-300">
+ <div className="text-center">
+ <Mail className="h-12 w-12 text-gray-400 mx-auto mb-4" />
+ <p className="text-gray-500 mb-2">템플릿을 편집하면 미리보기가 표시됩니다</p>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => handlePreview(false)}
+ className="mt-2"
+ >
+ 미리보기 생성
+ </Button>
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/email-template/editor/template-editor.tsx b/lib/email-template/editor/template-editor.tsx
new file mode 100644
index 00000000..68cade45
--- /dev/null
+++ b/lib/email-template/editor/template-editor.tsx
@@ -0,0 +1,175 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { ArrowLeft, Save, Edit, Settings, List } from "lucide-react"
+import Link from "next/link"
+
+import { Button } from "@/components/ui/button"
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { Separator } from "@/components/ui/separator"
+import { Badge } from "@/components/ui/badge"
+
+import { type TemplateWithVariables } from "@/db/schema"
+import { TemplateContentEditor } from "./template-content-editor"
+import { TemplateVariableManager } from "./template-variable-manager"
+import { TemplateSettings } from "./template-settings"
+
+interface TemplateEditorProps {
+ templateSlug: string
+ initialTemplate: TemplateWithVariables
+}
+
+export function TemplateEditor({ templateSlug, initialTemplate }: TemplateEditorProps) {
+ const router = useRouter()
+ const [template, setTemplate] = React.useState(initialTemplate)
+
+ return (
+ <div className="flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8">
+ {/* 헤더 */}
+ <div className="flex items-center gap-4">
+ <Button variant="ghost" size="icon" asChild>
+ <Link href="/evcp/email-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">
+ v{template.version}
+ </Badge>
+ {template.category && (
+ <Badge variant="secondary" className="text-xs">
+ {template.category}
+ </Badge>
+ )}
+ </div>
+ <p className="text-sm text-muted-foreground">
+ {template.description || "템플릿 편집"}
+ </p>
+ </div>
+
+ {/* 헤더 액션 버튼들 */}
+ <div className="flex items-center gap-2">
+ <Button variant="outline" size="sm" asChild>
+ <Link href={`/evcp/templates/${template.slug}/send`}>
+ 테스트 발송
+ </Link>
+ </Button>
+ </div>
+ </div>
+
+ <Separator />
+
+ {/* 메인 편집 영역 - 3개 탭으로 간소화 */}
+ <Tabs defaultValue="editor" className="flex-1">
+ <TabsList className="grid w-full grid-cols-3">
+ <TabsTrigger value="editor" className="gap-2">
+ <Edit className="h-4 w-4" />
+ 편집 & 미리보기
+ </TabsTrigger>
+ <TabsTrigger value="variables" className="gap-2">
+ <List className="h-4 w-4" />
+ 변수 관리
+ </TabsTrigger>
+ <TabsTrigger value="settings" className="gap-2">
+ <Settings className="h-4 w-4" />
+ 설정
+ </TabsTrigger>
+ </TabsList>
+
+ {/* 편집 & 미리보기 탭 (통합) */}
+ <TabsContent value="editor" className="mt-6">
+ <Card>
+ <CardHeader>
+ <CardTitle>템플릿 편집 & 미리보기</CardTitle>
+ <CardDescription>
+ 왼쪽에서 이메일 제목과 내용을 편집하고, 오른쪽에서 실시간 미리보기를 확인하세요.
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <TemplateContentEditor
+ template={template}
+ onUpdate={setTemplate}
+ />
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ {/* 변수 관리 탭 */}
+ <TabsContent value="variables" className="mt-6">
+ <Card>
+ <CardHeader>
+ <CardTitle>변수 관리</CardTitle>
+ <CardDescription>
+ 템플릿에서 사용할 변수를 추가하고 관리하세요. 추가되는 변수는 실제 코드에서 정의가 되어있지 않으면 템플릿에 있더라도 나타나지 않습니다.
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <TemplateVariableManager
+ template={template}
+ onUpdate={setTemplate}
+ />
+ </CardContent>
+ </Card>
+ </TabsContent>
+
+ {/* 설정 탭 */}
+ <TabsContent value="settings" className="mt-6">
+ <Card>
+ <CardHeader>
+ <CardTitle>템플릿 설정</CardTitle>
+ <CardDescription>
+ 템플릿의 기본 정보와 설정을 관리하세요.
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <TemplateSettings
+ template={template}
+ onUpdate={setTemplate}
+ />
+ </CardContent>
+ </Card>
+ </TabsContent>
+ </Tabs>
+
+ {/* 추가 정보 */}
+ <div className="mt-8 grid grid-cols-1 md:grid-cols-3 gap-4 text-sm text-muted-foreground">
+ <div className="bg-blue-50 p-4 rounded-lg">
+ <h4 className="font-medium text-blue-900 mb-2">💡 편집 팁</h4>
+ <ul className="space-y-1 text-blue-800">
+ <li>• 자동 미리보기를 켜두면 편집하면서 바로 확인 가능</li>
+ <li>• 변수 배지를 클릭하면 커서 위치에 바로 삽입</li>
+ <li>• Ctrl+S로 빠른 저장</li>
+ </ul>
+ </div>
+
+ <div className="bg-green-50 p-4 rounded-lg">
+ <h4 className="font-medium text-green-900 mb-2">📊 템플릿 상태</h4>
+ <ul className="space-y-1 text-green-800">
+ <li>• 변수: {template.variables.length}개</li>
+ <li>• 필수 변수: {template.variables.filter(v => v.isRequired).length}개</li>
+ <li>• 최종 수정: {new Date(template.updatedAt).toLocaleDateString('ko-KR')}</li>
+ </ul>
+ </div>
+
+ <div className="bg-purple-50 p-4 rounded-lg">
+ <h4 className="font-medium text-purple-900 mb-2">🚀 다음 단계</h4>
+ <ul className="space-y-1 text-purple-800">
+ <li>• 변수 관리에서 필요한 변수 추가</li>
+ <li>• 설정에서 카테고리 및 설명 수정</li>
+ <li>• 테스트 발송으로 실제 이메일 확인</li>
+ </ul>
+ </div>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/email-template/editor/template-preview.tsx b/lib/email-template/editor/template-preview.tsx
new file mode 100644
index 00000000..df6b8461
--- /dev/null
+++ b/lib/email-template/editor/template-preview.tsx
@@ -0,0 +1,406 @@
+"use client"
+
+import * as React from "react"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { Badge } from "@/components/ui/badge"
+import {
+ Eye,
+ Smartphone,
+ Monitor,
+ Tablet,
+ RefreshCw,
+ Copy,
+ Download,
+ Send
+} from "lucide-react"
+import { toast } from "sonner"
+import { type TemplateWithVariables } from "@/db/schema"
+import { previewTemplateAction } from "../service"
+import { copyTextToClipboard } from "@/lib/utils"
+
+interface TemplatePreviewProps {
+ template: TemplateWithVariables
+}
+
+type ViewMode = 'desktop' | 'tablet' | 'mobile'
+
+export function TemplatePreview({ template }: TemplatePreviewProps) {
+ const [previewData, setPreviewData] = React.useState<Record<string, any>>(
+ template.sampleData || {}
+ )
+ const [previewHtml, setPreviewHtml] = React.useState<string>("")
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [viewMode, setViewMode] = React.useState<ViewMode>('desktop')
+ const [lastUpdated, setLastUpdated] = React.useState<Date | null>(null)
+
+ // 미리보기 생성
+ const generatePreview = async () => {
+ setIsLoading(true)
+ try {
+ const result = await previewTemplateAction(
+ template.slug,
+ previewData,
+ template.content
+ )
+
+ if (result.success) {
+ setPreviewHtml(result.data.html)
+ setLastUpdated(new Date())
+ toast.success('미리보기가 생성되었습니다.')
+ } else {
+ toast.error("샘플데이터를 먼저 생성해주세요." || '미리보기 생성에 실패했습니다.')
+ }
+ } catch (error) {
+ toast.error('미리보기 생성 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 초기 미리보기 생성
+ React.useEffect(() => {
+ generatePreview()
+ }, [])
+
+ // 샘플 데이터 업데이트
+ const updatePreviewData = (key: string, value: any) => {
+ setPreviewData(prev => ({ ...prev, [key]: value }))
+ }
+
+ // 샘플 데이터 리셋
+ const resetToDefaults = () => {
+ const defaultData: Record<string, any> = {}
+
+ template.variables.forEach(variable => {
+ if (variable.defaultValue) {
+ switch (variable.variableType) {
+ case 'number':
+ defaultData[variable.variableName] = parseFloat(variable.defaultValue) || 0
+ break
+ case 'boolean':
+ defaultData[variable.variableName] = variable.defaultValue.toLowerCase() === 'true'
+ break
+ default:
+ defaultData[variable.variableName] = variable.defaultValue
+ }
+ } else {
+ // 기본 샘플 값
+ switch (variable.variableType) {
+ case 'string':
+ defaultData[variable.variableName] = `샘플 ${variable.variableName}`
+ break
+ case 'number':
+ defaultData[variable.variableName] = 123
+ break
+ case 'boolean':
+ defaultData[variable.variableName] = true
+ break
+ case 'date':
+ defaultData[variable.variableName] = new Date().toLocaleDateString('ko-KR')
+ break
+ }
+ }
+ })
+
+ setPreviewData(defaultData)
+ toast.success('샘플 데이터가 초기화되었습니다.')
+ }
+
+ // HTML 복사
+ const copyHtml = async () => {
+ if (!previewHtml) {
+ toast.error("미리보기를 먼저 생성해주세요.")
+ return
+ }
+
+ const ok = await copyTextToClipboard(previewHtml)
+ ok
+ ? toast.success("HTML이 클립보드에 복사되었습니다.")
+ : toast.error("복사에 실패했습니다.")
+ }
+ // HTML 다운로드
+ const downloadHtml = () => {
+ if (!previewHtml) {
+ toast.error('미리보기를 먼저 생성해주세요.')
+ return
+ }
+
+ const blob = new Blob([previewHtml], { type: 'text/html' })
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement('a')
+ link.href = url
+ link.download = `${template.slug}-preview.html`
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+ URL.revokeObjectURL(url)
+
+ toast.success('HTML 파일이 다운로드되었습니다.')
+ }
+
+ // 뷰포트 크기
+ const getViewportStyle = (): React.CSSProperties => {
+ switch (viewMode) {
+ case 'mobile':
+ return { width: '375px', height: '667px' }
+ case 'tablet':
+ return { width: '768px', height: '1024px' }
+ case 'desktop':
+ default:
+ return { width: '100%', height: '600px' }
+ }
+ }
+
+ // 입력 컴포넌트 렌더링
+ const renderInputForVariable = (variable: any) => {
+ const value = previewData[variable.variableName] || ''
+
+ switch (variable.variableType) {
+ case 'boolean':
+ return (
+ <Select
+ value={String(value)}
+ onValueChange={(newValue) => updatePreviewData(variable.variableName, newValue === 'true')}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="true">True</SelectItem>
+ <SelectItem value="false">False</SelectItem>
+ </SelectContent>
+ </Select>
+ )
+
+ case 'number':
+ return (
+ <Input
+ type="number"
+ value={value}
+ onChange={(e) => updatePreviewData(variable.variableName, parseFloat(e.target.value) || 0)}
+ placeholder="숫자를 입력하세요"
+ />
+ )
+
+ case 'date':
+ return (
+ <Input
+ type="date"
+ value={value}
+ onChange={(e) => updatePreviewData(variable.variableName, e.target.value)}
+ />
+ )
+
+ default:
+ // string 타입이거나 긴 텍스트인 경우
+ if (variable.variableName.toLowerCase().includes('message') ||
+ variable.variableName.toLowerCase().includes('content') ||
+ variable.variableName.toLowerCase().includes('description')) {
+ return (
+ <Textarea
+ value={value}
+ onChange={(e) => updatePreviewData(variable.variableName, e.target.value)}
+ placeholder="텍스트를 입력하세요"
+ className="min-h-[80px]"
+ />
+ )
+ }
+
+ return (
+ <Input
+ value={value}
+ onChange={(e) => updatePreviewData(variable.variableName, e.target.value)}
+ placeholder="텍스트를 입력하세요"
+ />
+ )
+ }
+ }
+
+ return (
+ <div className="space-y-6">
+ {/* 상태 표시 */}
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <Badge variant="outline">
+ {template.variables.length}개 변수
+ </Badge>
+ {lastUpdated && (
+ <Badge variant="secondary" className="text-xs">
+ 마지막 업데이트: {lastUpdated.toLocaleTimeString('ko-KR')}
+ </Badge>
+ )}
+ </div>
+
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={copyHtml}
+ disabled={!previewHtml}
+ >
+ <Copy className="mr-2 h-4 w-4" />
+ HTML 복사
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={downloadHtml}
+ disabled={!previewHtml}
+ >
+ <Download className="mr-2 h-4 w-4" />
+ 다운로드
+ </Button>
+
+ </div>
+ </div>
+
+ <Tabs defaultValue="preview" className="space-y-6">
+ <TabsList>
+ <TabsTrigger value="preview">미리보기</TabsTrigger>
+ <TabsTrigger value="data">샘플 데이터</TabsTrigger>
+ </TabsList>
+
+ <TabsContent value="preview" className="space-y-4">
+ {/* 뷰포트 모드 선택 */}
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-2">
+ <Label>뷰포트:</Label>
+ <div className="flex items-center gap-1">
+ <Button
+ variant={viewMode === 'desktop' ? 'default' : 'outline'}
+ size="sm"
+ onClick={() => setViewMode('desktop')}
+ >
+ <Monitor className="h-4 w-4" />
+ </Button>
+ <Button
+ variant={viewMode === 'tablet' ? 'default' : 'outline'}
+ size="sm"
+ onClick={() => setViewMode('tablet')}
+ >
+ <Tablet className="h-4 w-4" />
+ </Button>
+ <Button
+ variant={viewMode === 'mobile' ? 'default' : 'outline'}
+ size="sm"
+ onClick={() => setViewMode('mobile')}
+ >
+ <Smartphone className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+
+ <Button
+ onClick={generatePreview}
+ disabled={isLoading}
+ size="sm"
+ >
+ <RefreshCw className={`mr-2 h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
+ {isLoading ? '생성 중...' : '새로고침'}
+ </Button>
+ </div>
+
+ {/* 미리보기 영역 */}
+ <div className="border rounded-lg bg-gray-100 p-4 flex justify-center">
+ {previewHtml ? (
+ <div
+ className="bg-white rounded-lg shadow-lg overflow-hidden transition-all duration-300"
+ style={getViewportStyle()}
+ >
+ <iframe
+ srcDoc={previewHtml}
+ sandbox="allow-same-origin"
+ className="w-full h-full border-0"
+ title="Template Preview"
+ />
+ </div>
+ ) : (
+ <div className="flex items-center justify-center h-[400px] text-muted-foreground">
+ <div className="text-center">
+ <Eye className="h-12 w-12 mx-auto mb-4 opacity-50" />
+ <p>미리보기를 생성하려면 새로고침 버튼을 클릭하세요.</p>
+ </div>
+ </div>
+ )}
+ </div>
+ </TabsContent>
+
+ <TabsContent value="data" className="space-y-4">
+ <div className="flex items-center justify-between">
+ <h3 className="text-lg font-semibold">샘플 데이터 편집</h3>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={resetToDefaults}
+ >
+ 기본값으로 리셋
+ </Button>
+ </div>
+
+ {template.variables.length === 0 ? (
+ <div className="text-center py-12 border border-dashed rounded-lg">
+ <p className="text-muted-foreground">등록된 변수가 없습니다.</p>
+ </div>
+ ) : (
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ {template.variables.map((variable) => (
+ <div key={variable.id} className="space-y-2">
+ <div className="flex items-center gap-2">
+ <Label htmlFor={variable.variableName}>
+ {variable.variableName}
+ </Label>
+ <Badge variant="outline" className="text-xs">
+ {variable.variableType}
+ </Badge>
+ {variable.isRequired && (
+ <Badge variant="destructive" className="text-xs">
+ 필수
+ </Badge>
+ )}
+ </div>
+
+ {renderInputForVariable(variable)}
+
+ {variable.description && (
+ <p className="text-xs text-muted-foreground">
+ {variable.description}
+ </p>
+ )}
+ </div>
+ ))}
+ </div>
+ )}
+
+ <div className="flex justify-end">
+ <Button onClick={generatePreview} disabled={isLoading}>
+ <Eye className="mr-2 h-4 w-4" />
+ 미리보기 업데이트
+ </Button>
+ </div>
+ </TabsContent>
+ </Tabs>
+
+ {/* 도움말 */}
+ <div className="bg-green-50 p-4 rounded-lg">
+ <h4 className="font-medium text-green-900 mb-2">미리보기 가이드</h4>
+ <div className="text-sm text-green-800 space-y-1">
+ <p>• 샘플 데이터를 수정하여 다양한 시나리오를 테스트해보세요</p>
+ <p>• 다양한 뷰포트에서 이메일이 어떻게 보이는지 확인하세요</p>
+ <p>• HTML을 복사하여 다른 이메일 클라이언트에서 테스트할 수 있습니다</p>
+ <p>• 변수가 올바르게 치환되는지 확인하세요</p>
+ </div>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/email-template/editor/template-settings.tsx b/lib/email-template/editor/template-settings.tsx
new file mode 100644
index 00000000..f253f87d
--- /dev/null
+++ b/lib/email-template/editor/template-settings.tsx
@@ -0,0 +1,474 @@
+"use client"
+
+import * as React from "react"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card"
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog"
+import { Badge } from "@/components/ui/badge"
+import { Separator } from "@/components/ui/separator"
+import {
+ Save,
+ Trash2,
+ AlertTriangle,
+ Info,
+ Calendar,
+ User,
+ Hash,
+ Copy
+} from "lucide-react"
+import { toast } from "sonner"
+import { useRouter } from "next/navigation"
+import { type TemplateWithVariables } from "@/db/schema"
+import { deleteTemplate, duplicateTemplate, updateTemplateAction } from "../service"
+import { TEMPLATE_CATEGORY_OPTIONS, getCategoryDisplayName } from "../validations"
+
+
+interface TemplateSettingsProps {
+ template: TemplateWithVariables
+ onUpdate: (template: TemplateWithVariables) => void
+}
+
+export function TemplateSettings({ template, onUpdate }: TemplateSettingsProps) {
+ const router = useRouter()
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [formData, setFormData] = React.useState({
+ name: template.name,
+ description: template.description || '',
+ category: template.category || '',
+ sampleData: JSON.stringify(template.sampleData || {}, null, 2)
+ })
+
+ // 폼 데이터 업데이트
+ const updateFormData = (field: keyof typeof formData, value: string) => {
+ setFormData(prev => ({ ...prev, [field]: value }))
+ }
+
+ // 기본 정보 저장
+ const handleSaveBasicInfo = async () => {
+ setIsLoading(true)
+ try {
+ // 샘플 데이터 JSON 파싱 검증
+ let parsedSampleData = {}
+ if (formData.sampleData.trim()) {
+ try {
+ parsedSampleData = JSON.parse(formData.sampleData)
+ } catch (error) {
+ toast.error('샘플 데이터 JSON 형식이 올바르지 않습니다.')
+ setIsLoading(false)
+ return
+ }
+ }
+
+ const result = await updateTemplateAction(template.slug, {
+ name: formData.name,
+ description: formData.description || undefined,
+ sampleData: parsedSampleData,
+ updatedBy: 'current-user-id' // TODO: 실제 사용자 ID
+ })
+
+ if (result.success) {
+ toast.success('템플릿 설정이 저장되었습니다.')
+ onUpdate({
+ ...template,
+ name: formData.name,
+ description: formData.description,
+ sampleData: parsedSampleData,
+ version: template.version + 1
+ })
+ } else {
+ toast.error(result.error || '저장에 실패했습니다.')
+ }
+ } catch (error) {
+ toast.error('저장 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 템플릿 복제
+ const handleDuplicate = async () => {
+ setIsLoading(true)
+ try {
+ const copyName = `${template.name} (복사본)`
+ const copySlug = `${template.slug}-copy-${Date.now()}`
+
+ const result = await duplicateTemplate(
+ template.id,
+ copyName,
+ copySlug,
+ 'current-user-id' // TODO: 실제 사용자 ID
+ )
+
+ if (result.success && result.data) {
+ toast.success('템플릿이 복제되었습니다.')
+ router.push(`/evcp/templates/${result.data.slug}`)
+ } else {
+ toast.error(result.error || '복제에 실패했습니다.')
+ }
+ } catch (error) {
+ toast.error('복제 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 템플릿 삭제
+ const handleDelete = async () => {
+ setIsLoading(true)
+ try {
+ const result = await deleteTemplate(template.id)
+
+ if (result.success) {
+ toast.success('템플릿이 삭제되었습니다.')
+ router.push('/evcp/templates')
+ } else {
+ toast.error(result.error || '삭제에 실패했습니다.')
+ }
+ } catch (error) {
+ toast.error('삭제 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 샘플 데이터 포맷팅
+ const formatSampleData = () => {
+ try {
+ const parsed = JSON.parse(formData.sampleData)
+ const formatted = JSON.stringify(parsed, null, 2)
+ updateFormData('sampleData', formatted)
+ toast.success('JSON이 포맷팅되었습니다.')
+ } catch (error) {
+ toast.error('유효한 JSON이 아닙니다.')
+ }
+ }
+
+ // 기본 샘플 데이터 생성
+ const generateDefaultSampleData = () => {
+ const defaultData: Record<string, any> = {}
+
+ template.variables.forEach(variable => {
+ switch (variable.variableType) {
+ case 'string':
+ defaultData[variable.variableName] = variable.defaultValue || `샘플 ${variable.variableName}`
+ break
+ case 'number':
+ defaultData[variable.variableName] = variable.defaultValue ? parseFloat(variable.defaultValue) : 123
+ break
+ case 'boolean':
+ defaultData[variable.variableName] = variable.defaultValue ? variable.defaultValue === 'true' : true
+ break
+ case 'date':
+ defaultData[variable.variableName] = variable.defaultValue || new Date().toLocaleDateString('ko-KR')
+ break
+ }
+ })
+
+ const formatted = JSON.stringify(defaultData, null, 2)
+ updateFormData('sampleData', formatted)
+ toast.success('기본 샘플 데이터가 생성되었습니다.')
+ }
+
+ return (
+ <div className="space-y-6">
+ {/* 기본 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>기본 정보</CardTitle>
+ <CardDescription>
+ 템플릿의 기본 정보를 수정할 수 있습니다.
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div>
+ <Label htmlFor="name">템플릿 이름</Label>
+ <Input
+ id="name"
+ value={formData.name}
+ onChange={(e) => updateFormData('name', e.target.value)}
+ placeholder="템플릿 이름을 입력하세요"
+ />
+ </div>
+
+ <div>
+ <Label htmlFor="description">설명</Label>
+ <Textarea
+ id="description"
+ value={formData.description}
+ onChange={(e) => updateFormData('description', e.target.value)}
+ placeholder="템플릿에 대한 설명을 입력하세요"
+ className="min-h-[100px]"
+ />
+ </div>
+
+ <div>
+ <Label htmlFor="category">카테고리</Label>
+ <Select
+ value={formData.category || "none"} // 빈 문자열일 때 "none"으로 표시
+ onValueChange={(value) => {
+ // "none"이 선택되면 빈 문자열로 변환
+ updateFormData('category', value === "none" ? "" : value)
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="카테고리를 선택하세요" />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="none">카테고리 없음</SelectItem> {/* ✅ "none" 사용 */}
+ {TEMPLATE_CATEGORY_OPTIONS.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+</div>
+
+ <div className="flex justify-end">
+ <Button onClick={handleSaveBasicInfo} disabled={isLoading}>
+ <Save className="mr-2 h-4 w-4" />
+ {isLoading ? '저장 중...' : '기본 정보 저장'}
+ </Button>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 샘플 데이터 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>샘플 데이터</CardTitle>
+ <CardDescription>
+ 미리보기에서 사용될 기본 샘플 데이터를 설정합니다.
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={formatSampleData}
+ >
+ JSON 포맷팅
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={generateDefaultSampleData}
+ >
+ 기본 데이터 생성
+ </Button>
+ </div>
+
+ <div>
+ <Label htmlFor="sampleData">샘플 데이터 (JSON)</Label>
+ <Textarea
+ id="sampleData"
+ value={formData.sampleData}
+ onChange={(e) => updateFormData('sampleData', e.target.value)}
+ placeholder='{"userName": "홍길동", "email": "user@example.com"}'
+ className="min-h-[200px] font-mono text-sm"
+ />
+ </div>
+
+ <div className="bg-blue-50 p-3 rounded-lg">
+ <p className="text-sm text-blue-800">
+ <Info className="inline h-4 w-4 mr-1" />
+ 샘플 데이터는 템플릿 미리보기에서 기본값으로 사용됩니다.
+ </p>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 메타 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>메타 정보</CardTitle>
+ <CardDescription>
+ 템플릿의 상세 정보를 확인할 수 있습니다.
+ </CardDescription>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <div className="space-y-3">
+ <div className="flex items-center gap-2">
+ <Hash className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm font-medium">Slug:</span>
+ <code className="text-sm bg-muted px-2 py-1 rounded">
+ {template.slug}
+ </code>
+ </div>
+
+ <div className="flex items-center gap-2">
+ <Badge variant="outline">버전 {template.version}</Badge>
+ <Badge variant={template.category ? "default" : "secondary"}>
+ {getCategoryDisplayName(template.category)}
+ </Badge>
+ </div>
+
+ <div className="flex items-center gap-2">
+ <Calendar className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm">
+ 생성일: {new Date(template.createdAt).toLocaleString('ko-KR')}
+ </span>
+ </div>
+
+ <div className="flex items-center gap-2">
+ <Calendar className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm">
+ 수정일: {new Date(template.updatedAt).toLocaleString('ko-KR')}
+ </span>
+ </div>
+ </div>
+
+ <div className="space-y-3">
+ <div className="flex items-center gap-2">
+ <User className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm">
+ 생성자: {template.createdBy}
+ </span>
+ </div>
+
+ <div>
+ <span className="text-sm font-medium">변수 개수:</span>
+ <span className="ml-2 text-sm">{template.variables.length}개</span>
+ </div>
+
+ <div>
+ <span className="text-sm font-medium">필수 변수:</span>
+ <span className="ml-2 text-sm">
+ {template.variables.filter(v => v.isRequired).length}개
+ </span>
+ </div>
+
+ <div>
+ <span className="text-sm font-medium">콘텐츠 길이:</span>
+ <span className="ml-2 text-sm">{template.content.length} 문자</span>
+ </div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
+ <Separator />
+
+ {/* 위험한 작업 */}
+ <Card className="border-destructive">
+ <CardHeader>
+ <CardTitle className="text-destructive">위험한 작업</CardTitle>
+ <CardDescription>
+ 다음 작업들은 신중히 수행해주세요. 일부는 되돌릴 수 없습니다.
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="flex items-center justify-between p-4 border rounded-lg">
+ <div>
+ <h4 className="font-medium">템플릿 복제</h4>
+ <p className="text-sm text-muted-foreground">
+ 현재 템플릿을 복사하여 새로운 템플릿을 생성합니다.
+ </p>
+ </div>
+ <Button
+ variant="outline"
+ onClick={handleDuplicate}
+ disabled={isLoading}
+ >
+ <Copy className="mr-2 h-4 w-4" />
+ 복제
+ </Button>
+ </div>
+
+ <div className="flex items-center justify-between p-4 border border-destructive rounded-lg bg-destructive/5">
+ <div>
+ <h4 className="font-medium text-destructive">템플릿 삭제</h4>
+ <p className="text-sm text-muted-foreground">
+ 이 템플릿을 완전히 삭제합니다. 이 작업은 되돌릴 수 없습니다.
+ </p>
+ </div>
+ <AlertDialog>
+ <AlertDialogTrigger asChild>
+ <Button variant="destructive" disabled={isLoading}>
+ <Trash2 className="mr-2 h-4 w-4" />
+ 삭제
+ </Button>
+ </AlertDialogTrigger>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>템플릿 삭제 확인</AlertDialogTitle>
+ <AlertDialogDescription>
+ 정말로 <strong>"{template.name}"</strong> 템플릿을 삭제하시겠습니까?
+ <br />
+ <br />
+ 이 작업은 되돌릴 수 없으며, 다음 항목들이 함께 삭제됩니다:
+ <br />
+ • 템플릿 내용 및 설정
+ <br />
+ • 모든 변수 ({template.variables.length}개)
+ <br />
+ • 변경 이력
+ <br />
+ <br />
+ <span className="text-destructive font-medium">
+ 삭제하려면 "영구 삭제"를 클릭하세요.
+ </span>
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel>취소</AlertDialogCancel>
+ <AlertDialogAction
+ onClick={handleDelete}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ <Trash2 className="mr-2 h-4 w-4" />
+ 영구 삭제
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 주의사항 */}
+ <div className="bg-amber-50 border border-amber-200 p-4 rounded-lg">
+ <div className="flex items-start gap-3">
+ <AlertTriangle className="h-5 w-5 text-amber-600 mt-0.5" />
+ <div>
+ <h3 className="font-semibold text-amber-800">주의사항</h3>
+ <div className="mt-2 text-sm text-amber-700 space-y-1">
+ <p>• 템플릿 설정 변경 시 기존 미리보기가 무효화될 수 있습니다.</p>
+ <p>• 샘플 데이터는 유효한 JSON 형식이어야 합니다.</p>
+ <p>• 템플릿 삭제는 되돌릴 수 없으니 신중히 결정하세요.</p>
+ <p>• 카테고리 변경 시 관련 기본 변수가 영향받을 수 있습니다.</p>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/email-template/editor/template-variable-manager.tsx b/lib/email-template/editor/template-variable-manager.tsx
new file mode 100644
index 00000000..9b86dd5e
--- /dev/null
+++ b/lib/email-template/editor/template-variable-manager.tsx
@@ -0,0 +1,562 @@
+"use client"
+
+import * as React from "react"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Label } from "@/components/ui/label"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog"
+import { Badge } from "@/components/ui/badge"
+import { Plus, Edit, Trash2, GripVertical, Copy } from "lucide-react"
+import { toast } from "sonner"
+import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd"
+import { type TemplateWithVariables, type TemplateVariable } from "@/db/schema"
+import { addTemplateVariableAction } from "../service"
+
+interface TemplateVariableManagerProps {
+ template: TemplateWithVariables
+ onUpdate: (template: TemplateWithVariables) => void
+}
+
+interface VariableFormData {
+ variableName: string
+ variableType: 'string' | 'number' | 'boolean' | 'date'
+ defaultValue: string
+ isRequired: boolean
+ description: string
+}
+
+export function TemplateVariableManager({ template, onUpdate }: TemplateVariableManagerProps) {
+ const [variables, setVariables] = React.useState(template.variables)
+ // const [isAddDialogOpen, setIsAddDialogOpen] = React.useState(false)
+ const [editingVariable, setEditingVariable] = React.useState<TemplateVariable | null>(null)
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const [isDialogOpen, setIsDialogOpen] = React.useState(false) // 이름 변경
+
+ const [formData, setFormData] = React.useState<VariableFormData>({
+ variableName: '',
+ variableType: 'string',
+ defaultValue: '',
+ isRequired: false,
+ description: ''
+ })
+
+ const isEditMode = editingVariable !== null
+
+ // 폼 초기화
+ const resetForm = () => {
+ setFormData({
+ variableName: '',
+ variableType: 'string',
+ defaultValue: '',
+ isRequired: false,
+ description: ''
+ })
+ setEditingVariable(null)
+ }
+
+ const handleEditVariable = (variable: TemplateVariable) => {
+ setEditingVariable(variable)
+ setFormData({
+ variableName: variable.variableName,
+ variableType: variable.variableType as any,
+ defaultValue: variable.defaultValue || '',
+ isRequired: variable.isRequired || false,
+ description: variable.description || ''
+ })
+ setIsDialogOpen(true)
+ }
+
+
+ const handleSubmitVariable = async () => {
+ if (!formData.variableName.trim()) {
+ toast.error('변수명을 입력해주세요.')
+ return
+ }
+
+ // 편집 모드가 아닐 때만 중복 검사
+ if (!isEditMode && variables.some(v => v.variableName === formData.variableName)) {
+ toast.error('이미 존재하는 변수명입니다.')
+ return
+ }
+
+ // 편집 모드일 때 다른 변수와 중복되는지 검사
+ if (isEditMode && variables.some(v => v.id !== editingVariable!.id && v.variableName === formData.variableName)) {
+ toast.error('이미 존재하는 변수명입니다.')
+ return
+ }
+
+ // 변수명 유효성 검사
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(formData.variableName)) {
+ toast.error('변수명은 영문자, 숫자, 언더스코어만 사용 가능하며 숫자로 시작할 수 없습니다.')
+ return
+ }
+
+ setIsSubmitting(true)
+ try {
+ if (isEditMode) {
+ // 편집 모드 - TODO: updateTemplateVariableAction 구현 필요
+ // const result = await updateTemplateVariableAction(template.slug, editingVariable!.id, formData)
+
+ // 임시로 클라이언트 사이드에서 업데이트
+ const updatedVariables = variables.map(v =>
+ v.id === editingVariable!.id
+ ? { ...v, ...formData }
+ : v
+ )
+ setVariables(updatedVariables)
+ onUpdate({ ...template, variables: updatedVariables })
+ toast.success('변수가 수정되었습니다.')
+ } else {
+ // 추가 모드
+ const result = await addTemplateVariableAction(template.slug, {
+ variableName: formData.variableName,
+ variableType: formData.variableType,
+ defaultValue: formData.defaultValue || undefined,
+ isRequired: formData.isRequired,
+ description: formData.description || undefined,
+ })
+
+ if (result.success) {
+ const newVariable = result.data
+ const updatedVariables = [...variables, newVariable]
+ setVariables(updatedVariables)
+ onUpdate({ ...template, variables: updatedVariables })
+ toast.success('변수가 추가되었습니다.')
+ } else {
+ toast.error(result.error || '변수 추가에 실패했습니다.')
+ return
+ }
+ }
+
+ setIsDialogOpen(false)
+ resetForm()
+ } catch (error) {
+ toast.error(`변수 ${isEditMode ? '수정' : '추가'} 중 오류가 발생했습니다.`)
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ // Dialog 닫기 처리
+ const handleDialogClose = (open: boolean) => {
+ setIsDialogOpen(open)
+ if (!open) {
+ resetForm()
+ }
+ }
+
+ // 변수 추가
+ // const handleAddVariable = async () => {
+ // if (!formData.variableName.trim()) {
+ // toast.error('변수명을 입력해주세요.')
+ // return
+ // }
+
+ // // 변수명 중복 검사
+ // if (variables.some(v => v.variableName === formData.variableName)) {
+ // toast.error('이미 존재하는 변수명입니다.')
+ // return
+ // }
+
+ // // 변수명 유효성 검사
+ // if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(formData.variableName)) {
+ // toast.error('변수명은 영문자, 숫자, 언더스코어만 사용 가능하며 숫자로 시작할 수 없습니다.')
+ // return
+ // }
+
+ // setIsSubmitting(true)
+ // try {
+ // const result = await addTemplateVariableAction(template.slug, {
+ // variableName: formData.variableName,
+ // variableType: formData.variableType,
+ // defaultValue: formData.defaultValue || undefined,
+ // isRequired: formData.isRequired,
+ // description: formData.description || undefined,
+ // })
+
+ // if (result.success) {
+ // const newVariable = result.data
+ // const updatedVariables = [...variables, newVariable]
+ // setVariables(updatedVariables)
+ // onUpdate({ ...template, variables: updatedVariables })
+
+ // toast.success('변수가 추가되었습니다.')
+ // setIsAddDialogOpen(false)
+ // resetForm()
+ // } else {
+ // toast.error(result.error || '변수 추가에 실패했습니다.')
+ // }
+ // } catch (error) {
+ // toast.error('변수 추가 중 오류가 발생했습니다.')
+ // } finally {
+ // setIsSubmitting(false)
+ // }
+ // }
+
+ // 변수 순서 변경
+ const handleDragEnd = (result: any) => {
+ if (!result.destination) return
+
+ const items = Array.from(variables)
+ const [reorderedItem] = items.splice(result.source.index, 1)
+ items.splice(result.destination.index, 0, reorderedItem)
+
+ // displayOrder 업데이트
+ const updatedItems = items.map((item, index) => ({
+ ...item,
+ displayOrder: index
+ }))
+
+ setVariables(updatedItems)
+ onUpdate({ ...template, variables: updatedItems })
+
+ // TODO: 서버에 순서 변경 요청
+ toast.success('변수 순서가 변경되었습니다.')
+ }
+
+ // 변수 복사
+ const handleCopyVariable = (variable: TemplateVariable) => {
+ const copyName = `${variable.variableName}_copy`
+ setFormData({
+ variableName: copyName,
+ variableType: variable.variableType as any,
+ defaultValue: variable.defaultValue || '',
+ isRequired: variable.isRequired || false,
+ description: variable.description || ''
+ })
+ setIsDialogOpen(true)
+ }
+
+ // 변수 삭제
+ const handleDeleteVariable = async (variableId: string) => {
+ // TODO: 서버에서 변수 삭제 구현
+ const updatedVariables = variables.filter(v => v.id !== variableId)
+ setVariables(updatedVariables)
+ onUpdate({ ...template, variables: updatedVariables })
+ toast.success('변수가 삭제되었습니다.')
+ }
+
+ // 변수 타입에 따른 기본값 예시
+ const getDefaultValuePlaceholder = (type: string) => {
+ switch (type) {
+ case 'string': return '예: 홍길동'
+ case 'number': return '예: 123'
+ case 'boolean': return 'true 또는 false'
+ case 'date': return '예: 2025-01-01'
+ default: return ''
+ }
+ }
+
+ // 변수 타입별 아이콘
+ const getVariableTypeIcon = (type: string) => {
+ switch (type) {
+ case 'string': return '📝'
+ case 'number': return '🔢'
+ case 'boolean': return '✅'
+ case 'date': return '📅'
+ default: return '❓'
+ }
+ }
+
+ return (
+ <div className="space-y-6">
+ {/* 헤더 */}
+ <div className="flex items-center justify-between">
+ <div>
+ {/* <h3 className="text-lg font-semibold">변수 관리</h3> */}
+ <p className="text-sm text-muted-foreground">
+ 총 {variables.length}개의 변수가 등록되어 있습니다.
+ </p>
+ </div>
+
+ <Dialog open={isDialogOpen} onOpenChange={handleDialogClose}>
+ <DialogTrigger asChild>
+ <Button onClick={() => { resetForm(); setIsDialogOpen(true) }}>
+ <Plus className="mr-2 h-4 w-4" />
+ 변수 추가
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="sm:max-w-md">
+ <DialogHeader>
+ <DialogTitle>
+ {isEditMode ? '변수 수정' : '새 변수 추가'}
+ </DialogTitle>
+ <DialogDescription>
+ {isEditMode
+ ? '기존 변수의 정보를 수정합니다.'
+ : '템플릿에서 사용할 새로운 변수를 추가합니다.'
+ }
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ <div>
+ <Label htmlFor="variableName">변수명</Label>
+ <Input
+ id="variableName"
+ value={formData.variableName}
+ onChange={(e) => setFormData(prev => ({ ...prev, variableName: e.target.value }))}
+ placeholder="예: userName, orderDate"
+ />
+ </div>
+
+ <div>
+ <Label htmlFor="variableType">타입</Label>
+ <Select
+ value={formData.variableType}
+ onValueChange={(value) => setFormData(prev => ({ ...prev, variableType: value as any }))}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ <SelectItem value="string">📝 문자열</SelectItem>
+ <SelectItem value="number">🔢 숫자</SelectItem>
+ <SelectItem value="boolean">✅ 불린</SelectItem>
+ <SelectItem value="date">📅 날짜</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ <div>
+ <Label htmlFor="defaultValue">기본값</Label>
+ <Input
+ id="defaultValue"
+ value={formData.defaultValue}
+ onChange={(e) => setFormData(prev => ({ ...prev, defaultValue: e.target.value }))}
+ placeholder={getDefaultValuePlaceholder(formData.variableType)}
+ />
+ </div>
+
+ <div>
+ <Label htmlFor="description">설명</Label>
+ <Textarea
+ id="description"
+ value={formData.description}
+ onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
+ placeholder="변수에 대한 설명을 입력하세요"
+ className="min-h-[80px]"
+ />
+ </div>
+
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="isRequired"
+ checked={formData.isRequired}
+ onCheckedChange={(checked) => setFormData(prev => ({ ...prev, isRequired: !!checked }))}
+ />
+ <Label htmlFor="isRequired">필수 변수</Label>
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => handleDialogClose(false)}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={handleSubmitVariable}
+ disabled={isSubmitting}
+ >
+ {isSubmitting
+ ? `${isEditMode ? '수정' : '추가'} 중...`
+ : isEditMode ? '수정' : '추가'
+ }
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </div>
+
+ {/* 변수 목록 */}
+ {variables.length === 0 ? (
+ <div className="text-center py-12 border border-dashed rounded-lg">
+ <div className="text-muted-foreground">
+ <Plus className="h-12 w-12 mx-auto mb-4 opacity-50" />
+ <p className="text-lg font-medium">등록된 변수가 없습니다</p>
+ <p className="text-sm">첫 번째 변수를 추가해보세요.</p>
+ </div>
+ </div>
+ ) : (
+ <DragDropContext onDragEnd={handleDragEnd}>
+ <Droppable droppableId="variables">
+ {(provided) => (
+ <div
+ {...provided.droppableProps}
+ ref={provided.innerRef}
+ className="border rounded-lg"
+ >
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-10"></TableHead>
+ <TableHead>변수명</TableHead>
+ <TableHead>타입</TableHead>
+ <TableHead>기본값</TableHead>
+ <TableHead>필수</TableHead>
+ <TableHead>설명</TableHead>
+ <TableHead className="w-24">작업</TableHead>
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {variables.map((variable, index) => (
+ <Draggable
+ key={variable.id}
+ draggableId={variable.id}
+ index={index}
+ >
+ {(provided, snapshot) => (
+ <TableRow
+ ref={provided.innerRef}
+ {...provided.draggableProps}
+ className={snapshot.isDragging ? 'bg-muted' : ''}
+ >
+ <TableCell>
+ <div
+ {...provided.dragHandleProps}
+ className="cursor-grab hover:cursor-grabbing"
+ >
+ <GripVertical className="h-4 w-4 text-muted-foreground" />
+ </div>
+ </TableCell>
+ <TableCell>
+ <div className="font-mono text-sm">
+ {variable.variableName}
+ </div>
+ </TableCell>
+ <TableCell>
+ <Badge variant="outline" className="gap-1">
+ {getVariableTypeIcon(variable.variableType)}
+ {variable.variableType}
+ </Badge>
+ </TableCell>
+ <TableCell>
+ <div className="text-sm text-muted-foreground max-w-[100px] truncate">
+ {variable.defaultValue || '-'}
+ </div>
+ </TableCell>
+ <TableCell>
+ {variable.isRequired ? (
+ <Badge variant="destructive" className="text-xs">필수</Badge>
+ ) : (
+ <Badge variant="secondary" className="text-xs">선택</Badge>
+ )}
+ </TableCell>
+ <TableCell>
+ <div className="text-sm text-muted-foreground max-w-[200px] truncate">
+ {variable.description || '-'}
+ </div>
+ </TableCell>
+ <TableCell>
+ <div className="flex items-center gap-1">
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-8 w-8"
+ onClick={() => handleCopyVariable(variable)}
+ >
+ <Copy className="h-3 w-3" />
+ </Button>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-8 w-8"
+ onClick={() => handleEditVariable(variable)}
+ >
+ <Edit className="h-3 w-3" />
+ </Button>
+ <AlertDialog>
+ <AlertDialogTrigger asChild>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-8 w-8 text-destructive hover:text-destructive"
+ >
+ <Trash2 className="h-3 w-3" />
+ </Button>
+ </AlertDialogTrigger>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>변수 삭제</AlertDialogTitle>
+ <AlertDialogDescription>
+ 정말로 '{variable.variableName}' 변수를 삭제하시겠습니까?
+ 이 작업은 되돌릴 수 없습니다.
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel>취소</AlertDialogCancel>
+ <AlertDialogAction
+ onClick={() => handleDeleteVariable(variable.id)}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ 삭제
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ </div>
+ </TableCell>
+ </TableRow>
+ )}
+ </Draggable>
+ ))}
+ {provided.placeholder}
+ </TableBody>
+ </Table>
+ </div>
+ )}
+ </Droppable>
+ </DragDropContext>
+ )}
+
+ {/* 도움말 */}
+ <div className="bg-blue-50 p-4 rounded-lg">
+ <h4 className="font-medium text-blue-900 mb-2">변수 사용법</h4>
+ <div className="text-sm text-blue-800 space-y-1">
+ <p>• 템플릿에서 <code className="bg-blue-100 px-1 rounded">{`{{variableName}}`}</code> 형태로 사용</p>
+ <p>• 드래그 앤 드롭으로 변수 순서 변경 가능</p>
+ <p>• 필수 변수는 반드시 값이 제공되어야 함</p>
+ <p>• 변수명은 영문자, 숫자, 언더스코어만 사용 가능</p>
+ </div>
+ </div>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/email-template/security.ts b/lib/email-template/security.ts
new file mode 100644
index 00000000..57eb4314
--- /dev/null
+++ b/lib/email-template/security.ts
@@ -0,0 +1,178 @@
+
+
+export interface ValidationResult {
+ isValid: boolean;
+ errors: string[];
+ warnings: string[];
+ }
+
+ // 허용된 변수 목록 (환경변수나 설정파일에서 관리하는 것을 권장)
+ const ALLOWED_VARIABLES = [
+ 'userName', 'companyName', 'email', 'date', 'projectName',
+ 'message', 'currentYear', 'language', 'name', 'loginUrl'
+ ];
+
+ // 허용된 Handlebars 헬퍼
+ const ALLOWED_HELPERS = ['if', 'unless', 'each', 'with'];
+
+ // 위험한 패턴들
+ const DANGEROUS_PATTERNS = [
+ // JavaScript 생성자 및 프로토타입 접근
+ /\{\{\s*(constructor|prototype|__proto__|process|global|require|import|eval|Function)\s*\}\}/gi,
+ /\{\{\s*.*\.(constructor|prototype|__proto__)\s*.*\}\}/gi,
+ /\{\{\s*.*\[\s*['"`]constructor['"`]\s*\]\s*.*\}\}/gi,
+
+ // 시스템 함수 접근
+ /\{\{\s*.*require\s*\(.*\)\s*.*\}\}/gi,
+ /\{\{\s*.*process\s*\..*\}\}/gi,
+ /\{\{\s*.*global\s*\..*\}\}/gi,
+ /\{\{\s*.*this\s*\..*\}\}/gi,
+
+ // 경로 순회 공격
+ /\{\{\s*#with\s+.*\.\.\s*\}\}/gi,
+ /\{\{\s*.*\.\.\/.*\}\}/gi,
+
+ // 함수 호출 패턴
+ /\{\{\s*.*\(\s*.*\)\s*.*\}\}/gi,
+
+ // 특수 문자를 이용한 우회 시도
+ /\{\{\s*.*[\[\](){}].*\}\}/gi,
+ ];
+
+ // 의심스러운 패턴들 (경고용)
+ const SUSPICIOUS_PATTERNS = [
+ /\{\{\s*.*password.*\}\}/gi,
+ /\{\{\s*.*secret.*\}\}/gi,
+ /\{\{\s*.*key.*\}\}/gi,
+ /\{\{\s*.*token.*\}\}/gi,
+ ];
+
+ /**
+ * 템플릿 내용의 보안성을 검증합니다
+ */
+ export function validateTemplateContent(content: string): ValidationResult {
+ const errors: string[] = [];
+ const warnings: string[] = [];
+
+ // 1. 위험한 패턴 검사
+ for (const pattern of DANGEROUS_PATTERNS) {
+ if (pattern.test(content)) {
+ errors.push('보안상 위험한 구문이 감지되었습니다.');
+ break;
+ }
+ }
+
+ // 2. 의심스러운 패턴 검사 (경고)
+ for (const pattern of SUSPICIOUS_PATTERNS) {
+ if (pattern.test(content)) {
+ warnings.push('민감한 정보가 포함될 수 있는 변수가 감지되었습니다.');
+ break;
+ }
+ }
+
+ // 3. 허용되지 않은 변수 검사
+ const variableMatches = content.match(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g);
+ if (variableMatches) {
+ const usedVariables = new Set<string>();
+ for (const match of variableMatches) {
+ const variable = match.replace(/\{\{\s*|\s*\}\}/g, '');
+ usedVariables.add(variable);
+ }
+
+ for (const variable of usedVariables) {
+ if (!ALLOWED_VARIABLES.includes(variable)) {
+ errors.push(`허용되지 않은 변수 '${variable}'가 사용되었습니다.`);
+ }
+ }
+ }
+
+ // 4. 허용되지 않은 헬퍼 검사
+ const helperMatches = content.match(/\{\{\s*#([a-zA-Z_][a-zA-Z0-9_]*)/g);
+ if (helperMatches) {
+ const usedHelpers = new Set<string>();
+ for (const match of helperMatches) {
+ const helper = match.replace(/\{\{\s*#/, '');
+ usedHelpers.add(helper);
+ }
+
+ for (const helper of usedHelpers) {
+ if (!ALLOWED_HELPERS.includes(helper)) {
+ errors.push(`허용되지 않은 헬퍼 '${helper}'가 사용되었습니다.`);
+ }
+ }
+ }
+
+ // 5. HTML 출력 검사 ({{{...}}} 형태)
+ const htmlOutputMatches = content.match(/\{\{\{[^}]+\}\}\}/g);
+ if (htmlOutputMatches) {
+ warnings.push('HTML 출력 구문이 감지되었습니다. 보안상 일반 출력으로 변환됩니다.');
+ }
+
+ // 6. 템플릿 길이 제한 (DOS 방지)
+ if (content.length > 50000) { // 50KB 제한
+ errors.push('템플릿 크기가 제한을 초과했습니다.');
+ }
+
+ // 7. 중첩 깊이 제한
+ const nestingDepth = (content.match(/\{\{#/g) || []).length;
+ if (nestingDepth > 10) {
+ errors.push('템플릿 중첩 깊이가 제한을 초과했습니다.');
+ }
+
+ return {
+ isValid: errors.length === 0,
+ errors,
+ warnings
+ };
+ }
+
+ /**
+ * HTML 출력을 일반 출력으로 변환합니다
+ */
+ export function sanitizeTripleBraces(content: string): string {
+ return content.replace(/\{\{\{([^}]+)\}\}\}/g, (match, variable) => {
+ return `{{${variable.trim()}}}`;
+ });
+ }
+
+ /**
+ * 템플릿을 안전하게 전처리합니다
+ */
+ export function preprocessTemplate(content: string): string {
+ // 1. HTML 출력을 일반 출력으로 변환
+ let processed = sanitizeTripleBraces(content);
+
+ // 2. 의심스러운 공백 문자 제거
+ processed = processed.replace(/[\u200B-\u200D\uFEFF]/g, '');
+
+ // 3. 과도한 공백 정리
+ processed = processed.replace(/\s+/g, ' ').trim();
+
+ return processed;
+ }
+
+ /**
+ * Handlebars 컨텍스트를 제한합니다
+ */
+ export function createSafeContext(data: Record<string, any>): Record<string, any> {
+ const safeContext: Record<string, any> = {};
+
+ // 허용된 변수만 컨텍스트에 포함
+ for (const key of ALLOWED_VARIABLES) {
+ if (key in data) {
+ // 값도 안전하게 처리
+ const value = data[key];
+ if (typeof value === 'string') {
+ // HTML 태그 제거 (선택적)
+ safeContext[key] = value.replace(/<[^>]*>/g, '');
+ } else if (typeof value === 'number' || typeof value === 'boolean') {
+ safeContext[key] = value;
+ } else {
+ // 객체나 함수는 제외
+ safeContext[key] = String(value);
+ }
+ }
+ }
+
+ return safeContext;
+ } \ No newline at end of file
diff --git a/lib/email-template/service.ts b/lib/email-template/service.ts
new file mode 100644
index 00000000..13aba77b
--- /dev/null
+++ b/lib/email-template/service.ts
@@ -0,0 +1,899 @@
+'use server';
+
+import db from '@/db/db';
+import { eq, and, desc, sql, count, ilike, asc, or } from 'drizzle-orm';
+import handlebars from 'handlebars';
+import i18next from 'i18next';
+import resourcesToBackend from 'i18next-resources-to-backend';
+import { getOptions } from '@/i18n/settings';
+
+// Schema imports
+import {
+ templateListView,
+ templateDetailView,
+ type TemplateListView,
+ type TemplateDetailView
+} from '@/db/schema';
+import {
+ templates,
+ templateVariables,
+ templateHistory,
+ type Template,
+ type TemplateVariable,
+ type TemplateHistory
+} from '@/db/schema/templates';
+
+// Validation imports
+import { GetEmailTemplateSchema } from './validations';
+import { filterColumns } from '../filter-columns';
+
+// ===========================================
+// Types
+// ===========================================
+
+export interface TemplateWithVariables extends Template {
+ variables: TemplateVariable[];
+}
+
+interface ValidationResult {
+ isValid: boolean;
+ errors: string[];
+ warnings: string[];
+}
+
+// ===========================================
+// Security Constants & Functions
+// ===========================================
+
+const ALLOWED_HELPERS = ['if', 'unless', 'each', 'with', 'eq', 'ne'];
+
+const DANGEROUS_PATTERNS = [
+ /\{\{\s*(constructor|prototype|__proto__|process|global|require|import|eval|Function)\s*\}\}/gi,
+ /\{\{\s*.*\.(constructor|prototype|__proto__)\s*.*\}\}/gi,
+ /\{\{\s*.*\[\s*['"`]constructor['"`]\s*\]\s*.*\}\}/gi,
+ /\{\{\s*.*require\s*\(.*\)\s*.*\}\}/gi,
+ /\{\{\s*.*process\s*\..*\}\}/gi,
+ /\{\{\s*.*global\s*\..*\}\}/gi,
+ /\{\{\s*.*this\s*\..*\}\}/gi,
+ /\{\{\s*#with\s+.*\.\.\s*\}\}/gi,
+];
+
+function validateTemplateContent(content: string, allowedVariables: string[] = []): ValidationResult {
+ const errors: string[] = [];
+ const warnings: string[] = [];
+
+ // 위험한 패턴 검사
+ for (const pattern of DANGEROUS_PATTERNS) {
+ if (pattern.test(content)) {
+ errors.push('보안상 위험한 구문이 감지되었습니다.');
+ break;
+ }
+ }
+
+ // 허용된 변수 검사
+ if (allowedVariables.length > 0) {
+ const variableMatches = content.match(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g);
+ if (variableMatches) {
+ const usedVariables = new Set<string>();
+ for (const match of variableMatches) {
+ const variable = match.replace(/\{\{\s*|\s*\}\}/g, '');
+ usedVariables.add(variable);
+ }
+
+ for (const variable of usedVariables) {
+ if (!allowedVariables.includes(variable)) {
+ errors.push(`허용되지 않은 변수 '${variable}'가 사용되었습니다.`);
+ }
+ }
+ }
+ }
+
+ // 허용된 헬퍼 검사
+ const helperMatches = content.match(/\{\{\s*#([a-zA-Z_][a-zA-Z0-9_]*)/g);
+ if (helperMatches) {
+ const usedHelpers = new Set<string>();
+ for (const match of helperMatches) {
+ const helper = match.replace(/\{\{\s*#/, '');
+ usedHelpers.add(helper);
+ }
+
+ for (const helper of usedHelpers) {
+ if (!ALLOWED_HELPERS.includes(helper)) {
+ errors.push(`허용되지 않은 헬퍼 '${helper}'가 사용되었습니다.`);
+ }
+ }
+ }
+
+ // HTML 출력 검사
+ const htmlOutputMatches = content.match(/\{\{\{[^}]+\}\}\}/g);
+ if (htmlOutputMatches) {
+ warnings.push('HTML 출력 구문이 감지되었습니다. 보안상 일반 출력으로 변환됩니다.');
+ }
+
+ return { isValid: errors.length === 0, errors, warnings };
+}
+
+function sanitizeTripleBraces(content: string): string {
+ return content.replace(/\{\{\{([^}]+)\}\}\}/g, (match, variable) => {
+ return `{{${variable.trim()}}}`;
+ });
+}
+
+function preprocessTemplate(content: string): string {
+ let processed = sanitizeTripleBraces(content);
+ processed = processed.replace(/[\u200B-\u200D\uFEFF]/g, '');
+ processed = processed.replace(/\s+/g, ' ').trim();
+ return processed;
+}
+
+function createSafeContext(data: Record<string, any>, allowedVariables: string[]): Record<string, any> {
+ const safeContext: Record<string, any> = {};
+
+ for (const key of allowedVariables) {
+ if (key in data) {
+ const value = data[key];
+ if (typeof value === 'string') {
+ safeContext[key] = value.replace(/<[^>]*>/g, '');
+ } else if (typeof value === 'number' || typeof value === 'boolean') {
+ safeContext[key] = value;
+ } else {
+ safeContext[key] = String(value);
+ }
+ }
+ }
+
+ return safeContext;
+}
+
+// ===========================================
+// Handlebars Initialization
+// ===========================================
+
+let initialized = false;
+
+async function initializeAsync(): Promise<void> {
+ if (initialized) return;
+
+ try {
+ if (!i18next.isInitialized) {
+ await i18next
+ .use(resourcesToBackend((language: string, namespace: string) =>
+ import(`@/i18n/locales/${language}/${namespace}.json`)
+ ))
+ .init(getOptions());
+ }
+
+ registerHandlebarsHelpers();
+ initialized = true;
+ } catch (error) {
+ console.error('Failed to initialize TemplateService:', error);
+ registerHandlebarsHelpers();
+ initialized = true;
+ }
+}
+
+async function ensureInitialized(): Promise<void> {
+ if (initialized) return;
+ await initializeAsync();
+}
+
+function registerHandlebarsHelpers(): void {
+ handlebars.registerHelper('t', function(key: string, options: { hash?: Record<string, unknown> }) {
+ return i18next.t(key, options.hash || {});
+ });
+
+ handlebars.registerHelper('formatDate', function(date: Date | string, format?: string) {
+ if (!date) return '';
+ const d = new Date(date);
+ if (isNaN(d.getTime())) return '';
+
+ if (!format || format === 'date') {
+ return d.toISOString().split('T')[0];
+ }
+
+ if (format === 'datetime') {
+ return d.toLocaleString('ko-KR', {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit'
+ });
+ }
+
+ return d.toLocaleDateString('ko-KR');
+ });
+
+ handlebars.registerHelper('formatNumber', function(number: number | string) {
+ if (typeof number === 'string') {
+ number = parseFloat(number);
+ }
+ if (isNaN(number)) return '';
+ return number.toLocaleString('ko-KR');
+ });
+
+ handlebars.registerHelper('ifEquals', function(this: unknown, arg1: unknown, arg2: unknown, options: { fn: (context: unknown) => string; inverse: (context: unknown) => string }) {
+ return (arg1 === arg2) ? options.fn(this) : options.inverse(this);
+ });
+
+ handlebars.registerHelper('ifArrayLength', function(this: unknown, array: unknown[], length: number, options: { fn: (context: unknown) => string; inverse: (context: unknown) => string }) {
+ return (Array.isArray(array) && array.length === length) ? options.fn(this) : options.inverse(this);
+ });
+}
+
+// ===========================================
+// Core Service Functions
+// ===========================================
+
+/**
+ * 템플릿 목록 조회 (View 테이블 사용)
+ */
+export async function getTemplateList(input: GetEmailTemplateSchema) {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ const advancedWhere = filterColumns({
+ table: templateListView,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ // 전역 검색 조건 구성
+ let globalWhere;
+ if (input.search) {
+ try {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(templateListView.name, s),
+ ilike(templateListView.slug, s),
+ ilike(templateListView.description, s),
+ ilike(templateListView.category, s)
+ );
+ } catch (searchErr) {
+ console.error("Error building search where:", searchErr);
+ globalWhere = undefined;
+ }
+ }
+
+ const conditions = [];
+ if (advancedWhere) conditions.push(advancedWhere);
+ if (globalWhere) conditions.push(globalWhere);
+
+ let finalWhere;
+ if (conditions.length > 0) {
+ finalWhere = conditions.length > 1 ? and(...conditions) : conditions[0];
+ }
+
+ // 아니면 ilike, inArray, gte 등으로 where 절 구성
+ const where = finalWhere
+
+ // 정렬 조건 구성
+ let orderBy;
+ try {
+ orderBy = input.sort.length > 0
+ ? input.sort
+ .map((item) => {
+ if (!item || !item.id || typeof item.id !== "string" || !(item.id in templateListView)) return null;
+ const col = templateListView[item.id as keyof typeof templateListView];
+ return item.desc ? desc(col) : asc(col);
+ })
+ .filter((v): v is Exclude<typeof v, null> => v !== null)
+ : [desc(templateListView.updatedAt)]; // 기본값: 최신 수정일 순
+ } catch (orderErr) {
+ console.error("Error building order by:", orderErr);
+ orderBy = [desc(templateListView.updatedAt)];
+ }
+
+ // 데이터 조회
+ const data = await db
+ .select()
+ .from(templateListView)
+ .where(where)
+ .orderBy(...orderBy)
+ .limit(input.perPage)
+ .offset(offset);
+
+ // 총 개수 조회
+ const totalResult = await db
+ .select({ count: count() })
+ .from(templateListView)
+ .where(where);
+
+ const total = totalResult[0]?.count ?? 0;
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return {
+ data,
+ pageCount
+ };
+ } catch (error) {
+ console.error('Error getting template list:', error);
+ return {
+ data: [],
+ pageCount: 0
+ };
+ }
+}
+
+/**
+ * 특정 템플릿 조회 (slug 기준, 변수 포함)
+ */
+export async function getTemplate(slug: string): Promise<TemplateWithVariables | null> {
+ try {
+ // 템플릿 기본 정보 조회
+ const [template] = await db
+ .select()
+ .from(templates)
+ .where(and(eq(templates.slug, slug), eq(templates.isActive, true)))
+ .limit(1);
+
+ if (!template) {
+ return null;
+ }
+
+ // 템플릿 변수들 조회
+ const variables = await db
+ .select()
+ .from(templateVariables)
+ .where(eq(templateVariables.templateId, template.id))
+ .orderBy(templateVariables.displayOrder);
+
+ return {
+ ...template,
+ variables
+ };
+ } catch (error) {
+ console.error('Error getting template:', error);
+ throw new Error('템플릿을 가져오는데 실패했습니다.');
+ }
+}
+
+/**
+ * 템플릿 생성
+ */
+export async function createTemplate(data: {
+ name: string;
+ slug: string;
+ subject: string;
+ content: string;
+ category?: string;
+ description?: string;
+ sampleData?: Record<string, any>;
+ createdBy: number;
+ variables?: Array<{
+ variableName: string;
+ variableType: string;
+ defaultValue?: string;
+ isRequired?: boolean;
+ description?: string;
+ }>;
+}): Promise<TemplateWithVariables> {
+ try {
+ // slug 중복 확인
+ const existingTemplate = await db
+ .select({ id: templates.id })
+ .from(templates)
+ .where(eq(templates.slug, data.slug))
+ .limit(1);
+
+ if (existingTemplate.length > 0) {
+ throw new Error('이미 존재하는 slug입니다.');
+ }
+
+ // 템플릿 생성
+ const [newTemplate] = await db
+ .insert(templates)
+ .values({
+ name: data.name,
+ slug: data.slug,
+ subject: data.subject,
+ content: data.content,
+ category: data.category,
+ description: data.description,
+ sampleData: data.sampleData || {},
+ createdBy: data.createdBy,
+ })
+ .returning();
+
+ // 변수들 생성
+ if (data.variables && data.variables.length > 0) {
+ const variableData = data.variables.map((variable, index) => ({
+ templateId: newTemplate.id,
+ variableName: variable.variableName,
+ variableType: variable.variableType,
+ defaultValue: variable.defaultValue,
+ isRequired: variable.isRequired || false,
+ description: variable.description,
+ displayOrder: index,
+ }));
+
+ await db.insert(templateVariables).values(variableData);
+ }
+
+ // 생성된 템플릿 조회 및 반환
+ const result = await getTemplate(data.slug);
+ if (!result) {
+ throw new Error('생성된 템플릿을 조회할 수 없습니다.');
+ }
+
+ return result;
+ } catch (error) {
+ console.error('Error creating template:', error);
+ throw error;
+ }
+}
+
+/**
+ * 템플릿 업데이트
+ */
+export async function updateTemplate(slug: string, data: {
+ name?: string;
+ subject?: string;
+ content?: string;
+ description?: string;
+ sampleData?: Record<string, any>;
+ updatedBy: number;
+}): Promise<TemplateWithVariables> {
+ try {
+ const existingTemplate = await getTemplate(slug);
+ if (!existingTemplate) {
+ throw new Error('템플릿을 찾을 수 없습니다.');
+ }
+
+ // 버전 히스토리 저장
+ await db.insert(templateHistory).values({
+ templateId: existingTemplate.id,
+ version: existingTemplate.version,
+ subject: existingTemplate.subject,
+ content: existingTemplate.content,
+ changeDescription: '템플릿 업데이트',
+ changedBy: data.updatedBy,
+ });
+
+ // 템플릿 업데이트
+ await db
+ .update(templates)
+ .set({
+ name: data.name || existingTemplate.name,
+ content: data.content || existingTemplate.content,
+ subject: data.subject || existingTemplate.subject,
+ description: data.description || existingTemplate.description,
+ sampleData: data.sampleData || existingTemplate.sampleData,
+ version: existingTemplate.version + 1,
+ updatedAt: new Date(),
+ })
+ .where(eq(templates.id, existingTemplate.id));
+
+ const result = await getTemplate(slug);
+ if (!result) {
+ throw new Error('업데이트된 템플릿을 조회할 수 없습니다.');
+ }
+
+ return result;
+ } catch (error) {
+ console.error('Error updating template:', error);
+ throw error;
+ }
+}
+
+/**
+ * 템플릿 변수 추가
+ */
+export async function addTemplateVariable(slug: string, variable: {
+ variableName: string;
+ variableType: string;
+ defaultValue?: string;
+ isRequired?: boolean;
+ description?: string;
+}): Promise<TemplateVariable> {
+ try {
+ const template = await getTemplate(slug);
+ if (!template) {
+ throw new Error('템플릿을 찾을 수 없습니다.');
+ }
+
+ // 변수명 중복 확인
+ const existingVariable = template.variables.find(v => v.variableName === variable.variableName);
+ if (existingVariable) {
+ throw new Error('이미 존재하는 변수명입니다.');
+ }
+
+ // 새 변수 추가
+ const [newVariable] = await db
+ .insert(templateVariables)
+ .values({
+ templateId: template.id,
+ variableName: variable.variableName,
+ variableType: variable.variableType,
+ defaultValue: variable.defaultValue,
+ isRequired: variable.isRequired || false,
+ description: variable.description,
+ displayOrder: template.variables.length,
+ })
+ .returning();
+
+ return newVariable;
+ } catch (error) {
+ console.error('Error adding template variable:', error);
+ throw error;
+ }
+}
+
+/**
+ * 템플릿 삭제 (소프트 삭제)
+ */
+export async function deleteTemplate(id: string): Promise<{ success: boolean; error?: string }> {
+ try {
+ await db
+ .update(templates)
+ .set({
+ isActive: false,
+ updatedAt: new Date()
+ })
+ .where(eq(templates.id, id));
+
+ return { success: true };
+ } catch (error) {
+ console.error('Error deleting template:', error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '템플릿 삭제에 실패했습니다.'
+ };
+ }
+}
+
+/**
+ * 템플릿 일괄 삭제
+ */
+export async function bulkDeleteTemplates(ids: string[]): Promise<{ success: boolean; error?: string }> {
+ try {
+ await db
+ .update(templates)
+ .set({
+ isActive: false,
+ updatedAt: new Date()
+ })
+ .where(sql`${templates.id} = ANY(${ids})`);
+
+ return { success: true };
+ } catch (error) {
+ console.error('Error bulk deleting templates:', error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '템플릿 일괄 삭제에 실패했습니다.'
+ };
+ }
+}
+
+/**
+ * 템플릿 복제
+ */
+export async function duplicateTemplate(
+ id: string,
+ newName: string,
+ newSlug: string,
+ userId: string
+): Promise<{ success: boolean; error?: string; data?: any }> {
+ try {
+ // 원본 템플릿 조회 (변수 포함)
+ const [originalTemplate] = await db
+ .select()
+ .from(templates)
+ .where(eq(templates.id, id))
+ .limit(1);
+
+ if (!originalTemplate) {
+ return { success: false, error: '원본 템플릿을 찾을 수 없습니다.' };
+ }
+
+ // 원본 템플릿의 변수들 조회
+ const originalVariables = await db
+ .select()
+ .from(templateVariables)
+ .where(eq(templateVariables.templateId, id))
+ .orderBy(templateVariables.displayOrder);
+
+ // slug 중복 확인
+ const [existingTemplate] = await db
+ .select({ id: templates.id })
+ .from(templates)
+ .where(eq(templates.slug, newSlug))
+ .limit(1);
+
+ if (existingTemplate) {
+ return { success: false, error: '이미 존재하는 slug입니다.' };
+ }
+
+ // 새 템플릿 생성
+ const [newTemplate] = await db
+ .insert(templates)
+ .values({
+ name: newName,
+ slug: newSlug,
+ content: originalTemplate.content,
+ description: originalTemplate.description ? `${originalTemplate.description} (복사본)` : undefined,
+ category: originalTemplate.category,
+ sampleData: originalTemplate.sampleData,
+ createdBy: userId,
+ version: 1
+ })
+ .returning();
+
+ // 변수들 복제
+ if (originalVariables.length > 0) {
+ const variableData = originalVariables.map((variable) => ({
+ templateId: newTemplate.id,
+ variableName: variable.variableName,
+ variableType: variable.variableType,
+ defaultValue: variable.defaultValue,
+ isRequired: variable.isRequired,
+ description: variable.description,
+ validationRule: variable.validationRule,
+ displayOrder: variable.displayOrder,
+ }));
+
+ await db.insert(templateVariables).values(variableData);
+ }
+
+ return {
+ success: true,
+ data: { id: newTemplate.id, slug: newTemplate.slug }
+ };
+ } catch (error) {
+ console.error('Error duplicating template:', error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '템플릿 복제에 실패했습니다.'
+ };
+ }
+}
+
+/**
+ * 템플릿 미리보기
+ */
+export async function previewTemplate(slug: string, data?: Record<string, unknown>, customContent?: string): Promise<string> {
+ try {
+ await ensureInitialized();
+
+ const template = await getTemplate(slug);
+ if (!template) {
+ throw new Error('템플릿을 찾을 수 없습니다.');
+ }
+
+ const content = customContent || template.content;
+ const allowedVariables = template.variables.map(v => v.variableName);
+
+ // 보안 검증
+ const validation = validateTemplateContent(content, allowedVariables);
+ if (!validation.isValid) {
+ throw new Error(`보안 검증 실패: ${validation.errors.join(', ')}`);
+ }
+
+ // 템플릿 전처리
+ const processedContent = preprocessTemplate(content);
+
+ // 안전한 컨텍스트 생성
+ const contextData = data || template.sampleData || {};
+ const safeContext = createSafeContext(contextData, allowedVariables);
+
+ // 템플릿 컴파일 및 렌더링
+ const compiledTemplate = handlebars.compile(processedContent, {
+ noEscape: false,
+ strict: true,
+ });
+
+ return compiledTemplate(safeContext);
+ } catch (error) {
+ console.error('Error previewing template:', error);
+ throw error;
+ }
+}
+
+// ===========================================
+// Server Actions
+// ===========================================
+
+export async function getTemplatesAction(input: GetEmailTemplateSchema) {
+ try {
+ const result = await getTemplateList(input);
+ return {
+ success: true,
+ data: result.data,
+ pageCount: result.pageCount
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '템플릿 목록을 가져오는데 실패했습니다.',
+ data: [],
+ pageCount: 0
+ };
+ }
+}
+
+export async function getTemplateAction(slug: string) {
+ try {
+ const template = await getTemplate(slug);
+
+ if (!template) {
+ return {
+ success: false,
+ error: '템플릿을 찾을 수 없습니다.'
+ };
+ }
+
+ return {
+ success: true,
+ data: template
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '템플릿을 가져오는데 실패했습니다.'
+ };
+ }
+}
+
+export async function createTemplateAction(data: {
+ name: string;
+ slug: string;
+ subject: string;
+ content: string;
+ category?: string;
+ description?: string;
+ sampleData?: Record<string, any>;
+ createdBy: number;
+ variables?: Array<{
+ variableName: string;
+ variableType: string;
+ defaultValue?: string;
+ isRequired?: boolean;
+ description?: string;
+ }>;
+}) {
+ try {
+ // 보안 검증
+ const allowedVariables = data.variables?.map(v => v.variableName) || [];
+ const validation = validateTemplateContent(data.content, allowedVariables);
+
+ if (!validation.isValid) {
+ return {
+ success: false,
+ error: `보안 검증 실패: ${validation.errors.join(', ')}`
+ };
+ }
+
+ // 전처리
+ const processedContent = preprocessTemplate(data.content);
+
+ const template = await createTemplate({
+ ...data,
+ content: processedContent
+ });
+
+ return {
+ success: true,
+ data: { slug: template.slug }
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '템플릿 생성에 실패했습니다.'
+ };
+ }
+}
+
+export async function updateTemplateAction(slug: string, data: {
+ name?: string;
+ subject?: string;
+ content?: string;
+ description?: string;
+ sampleData?: Record<string, any>;
+ updatedBy: string;
+}) {
+ try {
+ if (data.content) {
+ // 기존 템플릿의 허용 변수 조회
+ const existingTemplate = await getTemplate(slug);
+ const allowedVariables = existingTemplate?.variables.map(v => v.variableName) || [];
+
+ // 보안 검증
+ const validation = validateTemplateContent(data.content, allowedVariables);
+ if (!validation.isValid) {
+ return {
+ success: false,
+ error: `보안 검증 실패: ${validation.errors.join(', ')}`
+ };
+ }
+
+ // 전처리
+ data.content = preprocessTemplate(data.content);
+ }
+
+ const template = await updateTemplate(slug, data);
+
+ return {
+ success: true,
+ data: template
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '템플릿 수정에 실패했습니다.'
+ };
+ }
+}
+
+export async function addTemplateVariableAction(slug: string, variable: {
+ variableName: string;
+ variableType: string;
+ defaultValue?: string;
+ isRequired?: boolean;
+ description?: string;
+}) {
+ try {
+ const newVariable = await addTemplateVariable(slug, variable);
+
+ return {
+ success: true,
+ data: newVariable
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '변수 추가에 실패했습니다.'
+ };
+ }
+}
+
+export async function previewTemplateAction(
+ slug: string,
+ data: Record<string, any>,
+ customContent?: string,
+ // subject: string
+) {
+ try {
+ const html = await previewTemplate(slug, data, customContent);
+
+ return {
+ success: true,
+ data: { html }
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '미리보기 생성에 실패했습니다.'
+ };
+ }
+}
+
+export async function getTemplateSchemaAction(slug: string) {
+ try {
+ const template = await getTemplate(slug);
+
+ if (!template) {
+ return {
+ success: false,
+ error: '템플릿을 찾을 수 없습니다.'
+ };
+ }
+
+ const schema = {
+ allowedVariables: template.variables.map(v => v.variableName),
+ allowedHelpers: ALLOWED_HELPERS,
+ templateType: template.category || 'general',
+ sampleData: template.sampleData || {},
+ variables: template.variables
+ };
+
+ return {
+ success: true,
+ data: schema
+ };
+ } catch (error) {
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : '스키마 조회에 실패했습니다.'
+ };
+ }
+} \ No newline at end of file
diff --git a/lib/email-template/table/create-template-sheet.tsx b/lib/email-template/table/create-template-sheet.tsx
new file mode 100644
index 00000000..199e20ab
--- /dev/null
+++ b/lib/email-template/table/create-template-sheet.tsx
@@ -0,0 +1,381 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Loader } from "lucide-react"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+import { z } from "zod"
+import { useRouter } from "next/navigation"
+import { useSession } from "next-auth/react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+
+import { createTemplateAction } from "../service"
+import { TEMPLATE_CATEGORY_OPTIONS } from "../validations"
+
+// Validation Schema (수정됨)
+const createTemplateSchema = z.object({
+ name: z.string().min(1, "템플릿 이름은 필수입니다").max(100, "템플릿 이름은 100자 이하여야 합니다"),
+ slug: z.string()
+ .min(1, "Slug는 필수입니다")
+ .max(50, "Slug는 50자 이하여야 합니다")
+ .regex(/^[a-z0-9-]+$/, "Slug는 소문자, 숫자, 하이픈만 사용 가능합니다"),
+ description: z.string().max(500, "설명은 500자 이하여야 합니다").optional(),
+ category: z.string().optional(), // 빈 문자열이나 undefined 모두 허용
+})
+
+
+
+type CreateTemplateSchema = z.infer<typeof createTemplateSchema>
+
+interface CreateTemplateSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+}
+
+export function CreateTemplateSheet({ ...props }: CreateTemplateSheetProps) {
+ const [isCreatePending, startCreateTransition] = React.useTransition()
+ const router = useRouter()
+ const { data: session } = useSession();
+
+ // 또는 더 안전하게
+ if (!session?.user?.id) {
+ toast.error("로그인이 필요합니다")
+ return
+ }
+
+ const form = useForm<CreateTemplateSchema>({
+ resolver: zodResolver(createTemplateSchema),
+ defaultValues: {
+ name: "",
+ slug: "",
+ description: "",
+ category: undefined, // 기본값을 undefined로 설정
+ },
+ })
+
+ // 이름 입력 시 자동으로 slug 생성
+ const watchedName = form.watch("name")
+ React.useEffect(() => {
+ if (watchedName && !form.formState.dirtyFields.slug) {
+ const autoSlug = watchedName
+ .toLowerCase()
+ .replace(/[^a-z0-9\s-]/g, '') // 특수문자 제거
+ .replace(/\s+/g, '-') // 공백을 하이픈으로
+ .replace(/-+/g, '-') // 연속 하이픈 제거
+ .trim()
+ .slice(0, 50) // 최대 50자
+
+ form.setValue("slug", autoSlug, { shouldValidate: false })
+ }
+ }, [watchedName, form])
+
+ // 기본 템플릿 내용 생성
+ const getDefaultContent = (category: string, name: string) => {
+ const templates = {
+ 'welcome-email': `
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="UTF-8">
+ <title>환영합니다!</title>
+</head>
+<body>
+ <div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
+ <h1>안녕하세요, {{userName}}님!</h1>
+ <p>${name}에 오신 것을 환영합니다.</p>
+ <p>{{message}}</p>
+ </div>
+</body>
+</html>`,
+ 'password-reset': `
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="UTF-8">
+ <title>비밀번호 재설정</title>
+</head>
+<body>
+ <div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
+ <h1>비밀번호 재설정</h1>
+ <p>안녕하세요, {{userName}}님.</p>
+ <p>비밀번호 재설정을 위해 아래 링크를 클릭해주세요.</p>
+ <a href="{{resetLink}}">비밀번호 재설정</a>
+ </div>
+</body>
+</html>`,
+ 'notification': `
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="UTF-8">
+ <title>알림</title>
+</head>
+<body>
+ <div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
+ <h1>알림</h1>
+ <p>안녕하세요, {{userName}}님.</p>
+ <p>{{message}}</p>
+ </div>
+</body>
+</html>`,
+ }
+
+ return templates[category as keyof typeof templates] || `
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="UTF-8">
+ <title>${name}</title>
+</head>
+<body>
+ <div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">
+ <h1>${name}</h1>
+ <p>안녕하세요, {{userName}}님.</p>
+ <p>{{message}}</p>
+ </div>
+</body>
+</html>`
+ }
+
+ // 기본 변수 생성
+ const getDefaultVariables = (category: string) => {
+ const variableTemplates = {
+ 'welcome-email': [
+ { variableName: 'userName', variableType: 'string', isRequired: true, description: '사용자 이름' },
+ { variableName: 'email', variableType: 'string', isRequired: true, description: '사용자 이메일' },
+ { variableName: 'message', variableType: 'string', isRequired: false, description: '환영 메시지' },
+ ],
+ 'password-reset': [
+ { variableName: 'userName', variableType: 'string', isRequired: true, description: '사용자 이름' },
+ { variableName: 'expiryTime', variableType: 'string', isRequired: true, description: '링크 유효 시간' },
+ { variableName: 'resetLink', variableType: 'string', isRequired: true, description: '재설정 URL' },
+ { variableName: 'supportEmail', variableType: 'string', isRequired: true, description: 'eVCP 서포터 이메일' },
+ ],
+ 'notification': [
+ { variableName: 'userName', variableType: 'string', isRequired: true, description: '사용자 이름' },
+ { variableName: 'email', variableType: 'string', isRequired: true, description: '사용자 이메일' },
+ { variableName: 'message', variableType: 'string', isRequired: true, description: '알림 메시지' },
+ ],
+ }
+
+ return variableTemplates[category as keyof typeof variableTemplates] || [
+ { variableName: 'userName', variableType: 'string', isRequired: true, description: '사용자 이름' },
+ { variableName: 'message', variableType: 'string', isRequired: false, description: '메시지 내용' },
+ ]
+ }
+
+ const getDefaultSubject = (category: string, name: string) => {
+ const subjectTemplates = {
+ 'welcome-email': '{{siteName}}에 오신 것을 환영합니다, {{userName}}님!',
+ 'password-reset': '{{userName}}님의 비밀번호 재설정 요청',
+ 'notification': '[{{notificationType}}] {{title}}',
+ 'invoice': '{{companyName}} 인보이스 #{{invoiceNumber}}',
+ 'marketing': '{{title}} - {{siteName}}',
+ 'system': '[시스템] {{title}}'
+ }
+
+ return subjectTemplates[category as keyof typeof subjectTemplates] ||
+ `${name} - {{siteName}}`
+ }
+
+
+ function onSubmit(input: CreateTemplateSchema) {
+ startCreateTransition(async () => {
+ if (!session?.user?.id) {
+ toast.error("로그인이 필요합니다")
+ return
+ }
+
+
+ const defaultContent = getDefaultContent(input.category || '', input.name)
+ const defaultVariables = getDefaultVariables(input.category || '')
+ const defaultSubject = getDefaultSubject(input.category || '', input.name)
+
+ const { error, data } = await createTemplateAction({
+ name: input.name,
+ slug: input.slug,
+ subject: defaultSubject,
+ content: defaultContent,
+ description: input.description,
+ category: input.category || undefined, // 빈 문자열 대신 undefined 전달
+ sampleData: {
+ userName: '홍길동',
+ email: 'user@example.com',
+ message: '샘플 메시지입니다.',
+ },
+ createdBy: Number(session.user.id),
+ variables: defaultVariables,
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ form.reset()
+ props.onOpenChange?.(false)
+ toast.success("템플릿이 생성되었습니다")
+
+ // 생성된 템플릿의 세부 페이지로 이동
+ if (data?.slug) {
+ router.push(`/evcp/email-template/${data.slug}`)
+ } else {
+ window.location.reload()
+ }
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>새 템플릿 생성</SheetTitle>
+ <SheetDescription>
+ 새로운 이메일 템플릿을 생성합니다. 기본 구조가 자동으로 생성되며, 생성 후 세부 내용을 편집할 수 있습니다.
+ </SheetDescription>
+ </SheetHeader>
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col gap-4"
+ >
+ <FormField
+ control={form.control}
+ name="name"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>템플릿 이름</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: 신규 회원 환영 메일"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="slug"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Slug</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: welcome-new-member"
+ {...field}
+ />
+ </FormControl>
+ <FormDescription>
+ URL에 사용될 고유 식별자입니다. 소문자, 숫자, 하이픈만 사용 가능합니다.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="category"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>카테고리</FormLabel>
+ <Select
+ onValueChange={(value) => {
+ // "none" 값이 선택되면 undefined로 설정
+ field.onChange(value === "none" ? undefined : value)
+ }}
+ value={field.value || "none"} // undefined인 경우 "none"으로 표시
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="카테고리를 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="none">카테고리 없음</SelectItem>
+ {TEMPLATE_CATEGORY_OPTIONS.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormDescription>
+ 카테고리에 따라 기본 템플릿과 변수가 자동으로 생성됩니다.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>설명 (선택사항)</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="템플릿에 대한 설명을 입력하세요"
+ className="min-h-[80px]"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ 취소
+ </Button>
+ </SheetClose>
+ <Button disabled={isCreatePending}>
+ {isCreatePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 생성 후 편집하기
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/email-template/table/delete-template-dialog.tsx b/lib/email-template/table/delete-template-dialog.tsx
new file mode 100644
index 00000000..5bc7dc53
--- /dev/null
+++ b/lib/email-template/table/delete-template-dialog.tsx
@@ -0,0 +1,137 @@
+// delete-template-dialog.tsx
+"use client"
+
+import * as React from "react"
+import { Loader, Trash } from "lucide-react"
+import { toast } from "sonner"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import { type TemplateListView } from "@/db/schema"
+
+import { deleteTemplate, bulkDeleteTemplates } from "../service"
+
+interface DeleteTemplateDialogProps
+ extends React.ComponentPropsWithRef<typeof Dialog> {
+ templates: TemplateListView[]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteTemplateDialog({
+ templates,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteTemplateDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+
+ const isMultiple = templates.length > 1
+ const templateName = isMultiple ? `${templates.length}개 템플릿` : templates[0]?.name
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+ let result
+
+ if (isMultiple) {
+ const ids = templates.map(t => t.id)
+ result = await bulkDeleteTemplates(ids)
+ } else {
+ result = await deleteTemplate(templates[0].id)
+ }
+
+ if (result.error) {
+ toast.error(result.error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+ onSuccess?.()
+ toast.success(
+ isMultiple
+ ? `${templates.length}개 템플릿이 삭제되었습니다`
+ : "템플릿이 삭제되었습니다"
+ )
+
+ // 페이지 새로고침으로 데이터 갱신
+ window.location.reload()
+ })
+ }
+
+ return (
+ <Dialog {...props}>
+ {showTrigger && (
+ <DialogTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ className="text-destructive hover:text-destructive"
+ >
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제
+ </Button>
+ </DialogTrigger>
+ )}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>템플릿 삭제 확인</DialogTitle>
+ <DialogDescription>
+ 정말로 <strong>{templateName}</strong>을(를) 삭제하시겠습니까?
+ {!isMultiple && (
+ <>
+ <br />
+ 이 작업은 되돌릴 수 없습니다.
+ </>
+ )}
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 삭제될 템플릿 목록 표시 */}
+ {templates.length > 0 && (
+ <div className="rounded-lg bg-muted p-3 max-h-40 overflow-y-auto">
+ <h4 className="text-sm font-medium mb-2">삭제될 템플릿</h4>
+ <div className="space-y-1">
+ {templates.map((template) => (
+ <div key={template.id} className="text-xs text-muted-foreground">
+ <div className="font-medium">{template.name}</div>
+ <div>Slug: <code className="bg-background px-1 rounded">{template.slug}</code></div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => props.onOpenChange?.(false)}
+ disabled={isDeletePending}
+ >
+ 취소
+ </Button>
+ <Button
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ {isMultiple ? `${templates.length}개 삭제` : "삭제"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/email-template/table/duplicate-template-sheet.tsx b/lib/email-template/table/duplicate-template-sheet.tsx
new file mode 100644
index 00000000..767a8bb6
--- /dev/null
+++ b/lib/email-template/table/duplicate-template-sheet.tsx
@@ -0,0 +1,208 @@
+"use client"
+
+import * as React from "react"
+import { type TemplateListView } from "@/db/schema/template-views"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Loader } from "lucide-react"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+import { z } from "zod"
+import { useRouter } from "next/navigation"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+
+import { duplicateTemplate } from "../service"
+
+// Validation Schema
+const duplicateTemplateSchema = z.object({
+ name: z.string().min(1, "템플릿 이름은 필수입니다").max(100, "템플릿 이름은 100자 이하여야 합니다"),
+ slug: z.string()
+ .min(1, "Slug는 필수입니다")
+ .max(50, "Slug는 50자 이하여야 합니다")
+ .regex(/^[a-z0-9-]+$/, "Slug는 소문자, 숫자, 하이픈만 사용 가능합니다"),
+})
+
+type DuplicateTemplateSchema = z.infer<typeof duplicateTemplateSchema>
+
+interface DuplicateTemplateSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ template: TemplateListView | null
+}
+
+export function DuplicateTemplateSheet({ template, ...props }: DuplicateTemplateSheetProps) {
+ const [isDuplicatePending, startDuplicateTransition] = React.useTransition()
+ const router = useRouter()
+
+ const form = useForm<DuplicateTemplateSchema>({
+ resolver: zodResolver(duplicateTemplateSchema),
+ defaultValues: {
+ name: "",
+ slug: "",
+ },
+ })
+
+ React.useEffect(() => {
+ if (template) {
+ const copyName = `${template.name} (복사본)`
+ const copySlug = `${template.slug}-copy-${Date.now()}`
+
+ form.reset({
+ name: copyName,
+ slug: copySlug,
+ })
+ }
+ }, [template, form])
+
+ // 이름 입력 시 자동으로 slug 생성
+ const watchedName = form.watch("name")
+ React.useEffect(() => {
+ if (watchedName && !form.formState.dirtyFields.slug) {
+ const autoSlug = watchedName
+ .toLowerCase()
+ .replace(/[^a-z0-9\s-]/g, '')
+ .replace(/\s+/g, '-')
+ .replace(/-+/g, '-')
+ .trim()
+ .slice(0, 50)
+
+ form.setValue("slug", autoSlug, { shouldValidate: false })
+ }
+ }, [watchedName, form])
+
+ function onSubmit(input: DuplicateTemplateSchema) {
+ startDuplicateTransition(async () => {
+ if (!template) return
+
+ // 현재 사용자 ID (실제로는 인증에서 가져와야 함)
+ const currentUserId = "current-user-id" // TODO: 실제 사용자 ID로 교체
+
+ const { error, data } = await duplicateTemplate(
+ template.id,
+ input.name,
+ input.slug,
+ currentUserId
+ )
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ form.reset()
+ props.onOpenChange?.(false)
+ toast.success("템플릿이 복제되었습니다")
+
+ // 복제된 템플릿의 세부 페이지로 이동
+ if (data?.slug) {
+ router.push(`/evcp/templates/${data.slug}`)
+ } else {
+ window.location.reload()
+ }
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>템플릿 복제</SheetTitle>
+ <SheetDescription>
+ 기존 템플릿을 복사하여 새로운 템플릿을 생성합니다. 모든 내용과 변수가 복제됩니다.
+ </SheetDescription>
+ </SheetHeader>
+
+ {template && (
+ <div className="rounded-lg bg-muted p-3">
+ <h4 className="text-sm font-medium mb-2">원본 템플릿</h4>
+ <div className="space-y-1 text-xs text-muted-foreground">
+ <div>이름: {template.name}</div>
+ <div>Slug: <code className="bg-background px-1 rounded">{template.slug}</code></div>
+ <div>카테고리: {template.categoryDisplayName}</div>
+ <div>변수: {template.variableCount}개</div>
+ </div>
+ </div>
+ )}
+
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col gap-4"
+ >
+ <FormField
+ control={form.control}
+ name="name"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>새 템플릿 이름</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="복제된 템플릿의 이름을 입력하세요"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="slug"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>새 Slug</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="복제된 템플릿의 slug를 입력하세요"
+ {...field}
+ />
+ </FormControl>
+ <FormDescription>
+ 고유한 식별자여야 합니다. 소문자, 숫자, 하이픈만 사용 가능합니다.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ 취소
+ </Button>
+ </SheetClose>
+ <Button disabled={isDuplicatePending}>
+ {isDuplicatePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 복제하기
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/email-template/table/email-template-table.tsx b/lib/email-template/table/email-template-table.tsx
new file mode 100644
index 00000000..65e93a10
--- /dev/null
+++ b/lib/email-template/table/email-template-table.tsx
@@ -0,0 +1,155 @@
+"use client"
+
+import * as React from "react"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+
+import { TemplateTableToolbarActions } from "./template-table-toolbar-actions"
+import { getColumns } from "./template-table-columns"
+import { type TemplateListView } from "@/db/schema"
+import { getTemplateList } from "../service"
+import { UpdateTemplateSheet } from "./update-template-sheet"
+import { CreateTemplateSheet } from "./create-template-sheet"
+import { DuplicateTemplateSheet } from "./duplicate-template-sheet"
+import { DeleteTemplateDialog } from "./delete-template-dialog"
+
+interface TemplateTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getTemplateList>>,
+ ]
+ >
+}
+
+export function TemplateTable({ promises }: TemplateTableProps) {
+ const [{ data, pageCount }] = React.use(promises)
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<TemplateListView> | null>(null)
+
+ const [showCreateSheet, setShowCreateSheet] = React.useState(false)
+
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ /**
+ * 기본 필터 필드 (드롭다운 형태)
+ */
+ const filterFields: DataTableFilterField<TemplateListView>[] = [
+
+ ]
+
+ /**
+ * 고급 필터 필드 (검색, 날짜 등)
+ */
+ const advancedFilterFields: DataTableAdvancedFilterField<TemplateListView>[] = [
+ {
+ id: "name",
+ label: "템플릿 이름",
+ type: "text",
+ },
+ {
+ id: "slug",
+ label: "Slug",
+ type: "text",
+ },
+
+ {
+ id: "variableCount",
+ label: "변수 개수",
+ type: "text",
+ },
+ {
+ id: "version",
+ label: "버전",
+ type: "text",
+ },
+ {
+ id: "createdAt",
+ label: "생성일",
+ type: "date",
+ },
+ {
+ id: "updatedAt",
+ label: "수정일",
+ type: "date",
+ },
+ ]
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "updatedAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ columnVisibility: {
+ slug: false, // 기본적으로 slug 컬럼은 숨김
+ },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <TemplateTableToolbarActions
+ table={table}
+ onCreateTemplate={() => setShowCreateSheet(true)}
+ />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* 새 템플릿 생성 Sheet */}
+ <CreateTemplateSheet
+ open={showCreateSheet}
+ onOpenChange={setShowCreateSheet}
+ />
+
+ {/* 템플릿 수정 Sheet */}
+ <UpdateTemplateSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ template={rowAction?.type === "update" ? rowAction.row.original : null}
+ />
+
+ {/* 템플릿 복제 Sheet */}
+ <DuplicateTemplateSheet
+ open={rowAction?.type === "duplicate"}
+ onOpenChange={() => setRowAction(null)}
+ template={rowAction?.type === "duplicate" ? rowAction.row.original : null}
+ />
+
+ {/* 템플릿 삭제 Dialog */}
+ <DeleteTemplateDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ templates={rowAction?.type === "delete" ? [rowAction.row.original] : []}
+ showTrigger={false}
+ onSuccess={() => {
+ setRowAction(null)
+ // 테이블 새로고침은 server action에서 자동으로 처리됨
+ }}
+ />
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/email-template/table/template-table-columns.tsx b/lib/email-template/table/template-table-columns.tsx
new file mode 100644
index 00000000..d20739cc
--- /dev/null
+++ b/lib/email-template/table/template-table-columns.tsx
@@ -0,0 +1,296 @@
+"use client"
+
+import * as React from "react"
+import { type ColumnDef } from "@tanstack/react-table"
+import { ArrowUpDown, Copy, MoreHorizontal, Edit, Trash, Eye } from "lucide-react"
+import Link from "next/link"
+
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { toast } from "sonner"
+import { formatDate } from "@/lib/utils"
+import { type TemplateListView } from "@/db/schema"
+import { type DataTableRowAction } from "@/types/table"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { getCategoryDisplayName, getCategoryVariant } from "../validations"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TemplateListView> | null>>
+}
+
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<TemplateListView>[] {
+ return [
+ // 체크박스 컬럼
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ },
+
+ // 템플릿 이름 컬럼 (클릭 시 세부 페이지로)
+ {
+ accessorKey: "name",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="템플릿 이름" />
+ ),
+ cell: ({ row }) => {
+ const template = row.original
+ return (
+ <div className="flex flex-col gap-1">
+ <Link
+ href={`/evcp/email-template/${template.slug}`}
+ className="font-medium text-blue-600 hover:text-blue-800 hover:underline"
+ >
+ {template.name}
+ </Link>
+ {template.description && (
+ <div className="text-xs text-muted-foreground line-clamp-2">
+ {template.description}
+ </div>
+ )}
+ </div>
+ )
+ },
+ enableSorting: true,
+ enableHiding: false,
+ size:200
+ },
+
+ {
+ accessorKey: "subject",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="이메일 제목" />
+ ),
+ cell: ({ getValue }) => {
+ const subject = getValue() as string
+ return (
+ <div className="text-sm text-muted-foreground">
+ {subject}
+ </div>
+ )
+ },
+ enableSorting: true,
+ size:250
+ },
+
+ // Slug 컬럼
+ {
+ accessorKey: "slug",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="Slug" />
+ ),
+ cell: ({ getValue }) => {
+ const slug = getValue() as string
+ return (
+ <div className="font-mono text-sm text-muted-foreground">
+ {slug}
+ </div>
+ )
+ },
+ enableSorting: true,
+ size:120
+
+ },
+
+ // 카테고리 컬럼
+ {
+ accessorKey: "category",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="카테고리" />
+ ),
+ cell: ({ row }) => {
+ const category = row.original.category
+ const displayName = getCategoryDisplayName(category)
+ const variant = getCategoryVariant(category)
+
+ return (
+ <Badge variant={variant}>
+ {displayName}
+ </Badge>
+ )
+ },
+ enableSorting: true,
+ filterFn: (row, id, value) => {
+ return value.includes(row.getValue(id))
+ },
+ size:120
+
+ },
+
+ // 변수 개수 컬럼
+ {
+ accessorKey: "variableCount",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="변수" />
+ ),
+ cell: ({ row }) => {
+ const variableCount = row.original.variableCount
+ const requiredCount = row.original.requiredVariableCount
+
+ return (
+ <div className="text-center">
+ <div className="text-sm font-medium">{variableCount}</div>
+ {requiredCount > 0 && (
+ <div className="text-xs text-muted-foreground">
+ 필수: {requiredCount}
+ </div>
+ )}
+ </div>
+ )
+ },
+ enableSorting: true,
+ size:80
+
+ },
+
+ // 버전 컬럼
+ {
+ accessorKey: "version",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="버전" />
+ ),
+ cell: ({ getValue }) => {
+ const version = getValue() as number
+ return (
+ <div className="text-center">
+ <Badge variant="outline" className="font-mono text-xs">
+ v{version}
+ </Badge>
+ </div>
+ )
+ },
+ enableSorting: true,
+ size:80
+
+ },
+
+ // 생성일 컬럼
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="생성일" />
+ ),
+ cell: ({ cell }) => {
+ const date = cell.getValue() as Date
+ return (
+ <div className="text-sm text-muted-foreground">
+ {formatDate(date)}
+ </div>
+ )
+ },
+ enableSorting: true,
+ size:200
+
+ },
+
+ // 수정일 컬럼
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="수정일" />
+ ),
+ cell: ({ cell }) => {
+ const date = cell.getValue() as Date
+ return (
+ <div className="text-sm text-muted-foreground">
+ {formatDate(date)}
+ </div>
+ )
+ },
+ enableSorting: true,
+ size:200
+
+ },
+
+ // Actions 컬럼
+ {
+ id: "actions",
+ cell: ({ row }) => {
+ const template = row.original
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <MoreHorizontal className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem asChild>
+ <Link href={`/evcp/templates/${template.slug}`}>
+ <Eye className="mr-2 size-4" aria-hidden="true" />
+ 보기
+ </Link>
+ </DropdownMenuItem>
+
+ <DropdownMenuItem
+ onClick={() => {
+ setRowAction({ type: "update", row })
+ }}
+ >
+ <Edit className="mr-2 size-4" aria-hidden="true" />
+ 수정
+ </DropdownMenuItem>
+
+ <DropdownMenuItem
+ onClick={() => {
+ setRowAction({ type: "duplicate", row })
+ }}
+ >
+ <Copy className="mr-2 size-4" aria-hidden="true" />
+ 복제
+ </DropdownMenuItem>
+
+ <DropdownMenuSeparator />
+
+ <DropdownMenuItem
+ onClick={() => {
+ setRowAction({ type: "delete", row })
+ }}
+ className="text-destructive focus:text-destructive"
+ >
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ enableSorting: false,
+ enableHiding: false,
+ size:80
+
+ },
+ ]
+} \ No newline at end of file
diff --git a/lib/email-template/table/template-table-toolbar-actions.tsx b/lib/email-template/table/template-table-toolbar-actions.tsx
new file mode 100644
index 00000000..a7e107c6
--- /dev/null
+++ b/lib/email-template/table/template-table-toolbar-actions.tsx
@@ -0,0 +1,115 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, Plus, Trash } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import { type TemplateListView } from "@/db/schema/template-views"
+import { DeleteTemplateDialog } from "./delete-template-dialog"
+import { toast } from "sonner"
+
+interface TemplateTableToolbarActionsProps {
+ table: Table<TemplateListView>
+ onCreateTemplate: () => void
+}
+
+export function TemplateTableToolbarActions({
+ table,
+ onCreateTemplate,
+}: TemplateTableToolbarActionsProps) {
+ const [showDeleteDialog, setShowDeleteDialog] = React.useState(false)
+
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ const selectedTemplates = selectedRows.map(row => row.original)
+
+ // CSV 내보내기 함수
+ const exportToCsv = React.useCallback(() => {
+ const headers = ['이름', 'Slug', '카테고리', '변수 개수', '버전', '생성일', '수정일']
+ const csvData = [
+ headers,
+ ...table.getFilteredRowModel().rows.map(row => {
+ const template = row.original
+ return [
+ template.name,
+ template.slug,
+ template.categoryDisplayName || '미분류',
+ template.variableCount.toString(),
+ template.version.toString(),
+ new Date(template.createdAt).toLocaleDateString('ko-KR'),
+ new Date(template.updatedAt).toLocaleDateString('ko-KR'),
+ ]
+ })
+ ]
+
+ const csvContent = csvData.map(row =>
+ row.map(field => `"${field}"`).join(',')
+ ).join('\n')
+
+ const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
+ const link = document.createElement('a')
+
+ if (link.download !== undefined) {
+ const url = URL.createObjectURL(blob)
+ link.setAttribute('href', url)
+ link.setAttribute('download', `templates_${new Date().toISOString().split('T')[0]}.csv`)
+ link.style.visibility = 'hidden'
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+ }
+
+ toast.success('템플릿 목록이 CSV로 내보내졌습니다.')
+ }, [table])
+
+ return (
+ <div className="flex items-center gap-2">
+ {/* 새 템플릿 생성 버튼 */}
+ <Button
+ variant="default"
+ size="sm"
+ onClick={onCreateTemplate}
+ >
+ <Plus className="mr-2 size-4" aria-hidden="true" />
+ 새 템플릿
+ </Button>
+
+ {/* CSV 내보내기 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={exportToCsv}
+ >
+ <Download className="mr-2 size-4" aria-hidden="true" />
+ 내보내기
+ </Button>
+
+ {/* 선택된 항목 일괄 삭제 버튼 */}
+ {selectedTemplates.length > 0 && (
+ <>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => setShowDeleteDialog(true)}
+ className="text-destructive hover:text-destructive"
+ >
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ 삭제 ({selectedTemplates.length})
+ </Button>
+
+ {/* 일괄 삭제 Dialog */}
+ <DeleteTemplateDialog
+ open={showDeleteDialog}
+ onOpenChange={setShowDeleteDialog}
+ templates={selectedTemplates}
+ showTrigger={false}
+ onSuccess={() => {
+ table.toggleAllRowsSelected(false)
+ setShowDeleteDialog(false)
+ }}
+ />
+ </>
+ )}
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/email-template/table/update-template-sheet.tsx b/lib/email-template/table/update-template-sheet.tsx
new file mode 100644
index 00000000..58da0626
--- /dev/null
+++ b/lib/email-template/table/update-template-sheet.tsx
@@ -0,0 +1,215 @@
+"use client"
+
+import * as React from "react"
+import { type TemplateListView} from "@/db/schema"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Loader } from "lucide-react"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+import { z } from "zod"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+
+import { updateTemplateAction } from "../service"
+import { TEMPLATE_CATEGORY_OPTIONS } from "../validations"
+
+// Validation Schema
+const updateTemplateSchema = z.object({
+ name: z.string().min(1, "템플릿 이름은 필수입니다").max(100, "템플릿 이름은 100자 이하여야 합니다"),
+ description: z.string().max(500, "설명은 500자 이하여야 합니다").optional(),
+ category: z.string().optional(),
+})
+
+type UpdateTemplateSchema = z.infer<typeof updateTemplateSchema>
+
+interface UpdateTemplateSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ template: TemplateListView | null
+}
+
+export function UpdateTemplateSheet({ template, ...props }: UpdateTemplateSheetProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ const form = useForm<UpdateTemplateSchema>({
+ resolver: zodResolver(updateTemplateSchema),
+ defaultValues: {
+ name: template?.name ?? "",
+ description: template?.description ?? "",
+ category: template?.category ?? "",
+ },
+ })
+
+ React.useEffect(() => {
+ if (template) {
+ form.reset({
+ name: template.name ?? "",
+ description: template.description ?? "",
+ category: template.category ?? "",
+ })
+ }
+ }, [template, form])
+
+ function onSubmit(input: UpdateTemplateSchema) {
+ startUpdateTransition(async () => {
+ if (!template) return
+
+ // 현재 사용자 ID (실제로는 인증에서 가져와야 함)
+ const currentUserId = "current-user-id" // TODO: 실제 사용자 ID로 교체
+
+ const { error } = await updateTemplateAction(template.slug, {
+ name: input.name,
+ description: input.description || undefined,
+ // category는 일반적으로 수정하지 않는 것이 좋지만, 필요시 포함
+ updatedBy: currentUserId,
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ form.reset()
+ props.onOpenChange?.(false)
+ toast.success("템플릿이 업데이트되었습니다")
+
+ // 페이지 새로고침으로 데이터 갱신
+ window.location.reload()
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>템플릿 수정</SheetTitle>
+ <SheetDescription>
+ 템플릿의 기본 정보를 수정할 수 있습니다. 템플릿 내용을 수정하려면 세부 페이지에서 편집하세요.
+ </SheetDescription>
+ </SheetHeader>
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col gap-4"
+ >
+ <FormField
+ control={form.control}
+ name="name"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>템플릿 이름</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="템플릿 이름을 입력하세요"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>설명</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="템플릿에 대한 설명을 입력하세요"
+ className="min-h-[80px]"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="category"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>카테고리</FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="카테고리를 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="">카테고리 없음</SelectItem>
+ {TEMPLATE_CATEGORY_OPTIONS.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 현재 정보 표시 */}
+ {template && (
+ <div className="rounded-lg bg-muted p-3">
+ <h4 className="text-sm font-medium mb-2">현재 정보</h4>
+ <div className="space-y-1 text-xs text-muted-foreground">
+ <div>Slug: <code className="bg-background px-1 rounded">{template.slug}</code></div>
+ <div>버전: v{template.version}</div>
+ <div>변수: {template.variableCount}개 (필수: {template.requiredVariableCount}개)</div>
+ <div>수정일: {new Date(template.updatedAt).toLocaleString('ko-KR')}</div>
+ </div>
+ </div>
+ )}
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ 취소
+ </Button>
+ </SheetClose>
+ <Button disabled={isUpdatePending}>
+ {isUpdatePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 저장
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/email-template/validations.ts b/lib/email-template/validations.ts
new file mode 100644
index 00000000..ece5437e
--- /dev/null
+++ b/lib/email-template/validations.ts
@@ -0,0 +1,82 @@
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server";
+import * as z from "zod";
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers";
+import { templateListView, type TemplateListView } from '@/db/schema';
+
+export const SearchParamsEmailTemplateCache = createSearchParamsCache({
+ // UI 모드나 플래그 관련
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+
+ // 페이징
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 정렬 (createdAt 기준 내림차순)
+ sort: getSortingStateParser<typeof templateListView>().withDefault([
+ { id: "updatedAt", desc: true }]),
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+});
+export type GetEmailTemplateSchema = Awaited<ReturnType<typeof SearchParamsEmailTemplateCache.parse>>;
+
+
+// 카테고리 옵션과 유틸리티 함수들 (애플리케이션 레벨에서 처리)
+export const TEMPLATE_CATEGORY_OPTIONS = [
+ { label: '환영 메일', value: 'welcome-email' },
+ { label: '비밀번호 재설정', value: 'password-reset' },
+ { label: '알림', value: 'notification' },
+ { label: '청구서', value: 'invoice' },
+ { label: '마케팅', value: 'marketing' },
+ { label: '시스템', value: 'system' },
+] as const;
+
+// 카테고리 변환 함수 (애플리케이션 레벨)
+export function getCategoryDisplayName(category: string | null): string {
+ const categoryMap: Record<string, string> = {
+ 'welcome-email': '환영 메일',
+ 'password-reset': '비밀번호 재설정',
+ 'notification': '알림',
+ 'invoice': '청구서',
+ 'marketing': '마케팅',
+ 'system': '시스템',
+ };
+
+ return categoryMap[category || ''] || '미분류';
+}
+
+// 카테고리 변형(variant) 함수
+export function getCategoryVariant(category: string | null): 'default' | 'secondary' | 'destructive' | 'outline' {
+ const variantMap: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
+ 'welcome-email': 'default',
+ 'password-reset': 'destructive',
+ 'notification': 'secondary',
+ 'invoice': 'outline',
+ 'marketing': 'default',
+ 'system': 'secondary',
+ };
+
+ return variantMap[category || ''] || 'outline';
+}
+
+// 확장된 템플릿 타입 (display name 포함)
+export type TemplateListViewWithDisplay = TemplateListView & {
+ categoryDisplayName: string;
+ categoryVariant: 'default' | 'secondary' | 'destructive' | 'outline';
+};
+
+// 유틸리티 함수: View 데이터에 display 정보 추가
+export function enhanceTemplateListView(template: TemplateListView): TemplateListViewWithDisplay {
+ return {
+ ...template,
+ categoryDisplayName: getCategoryDisplayName(template.category),
+ categoryVariant: getCategoryVariant(template.category),
+ };
+} \ No newline at end of file