diff options
Diffstat (limited to 'lib/email-template/editor/template-settings.tsx')
| -rw-r--r-- | lib/email-template/editor/template-settings.tsx | 474 |
1 files changed, 474 insertions, 0 deletions
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 |
