From 9da494b0e3bbe7b513521d0915510fe9ee376b8b Mon Sep 17 00:00:00 2001
From: dujinkim <dujin.kim@dtsolution.co.kr>
Date: Mon, 21 Jul 2025 07:19:52 +0000
Subject: (대표님, 최겸) 작업사항 - 이메일 템플릿, 메일링, 기술영업 요구사항
 반영
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../editor/template-variable-manager.tsx           | 562 +++++++++++++++++++++
 1 file changed, 562 insertions(+)
 create mode 100644 lib/email-template/editor/template-variable-manager.tsx

(limited to 'lib/email-template/editor/template-variable-manager.tsx')

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

