diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-21 07:19:52 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-07-21 07:19:52 +0000 |
| commit | 9da494b0e3bbe7b513521d0915510fe9ee376b8b (patch) | |
| tree | f936f69626bf2808ac409ce7cad97433465b3672 /lib/email-template/editor/template-variable-manager.tsx | |
| parent | e275618ff8a1ce6977d3e2567d943edb941897f9 (diff) | |
(대표님, 최겸) 작업사항 - 이메일 템플릿, 메일링, 기술영업 요구사항 반영
Diffstat (limited to 'lib/email-template/editor/template-variable-manager.tsx')
| -rw-r--r-- | lib/email-template/editor/template-variable-manager.tsx | 562 |
1 files changed, 562 insertions, 0 deletions
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 |
