summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-09-30 05:04:33 +0000
committerjoonhoekim <26rote@gmail.com>2025-09-30 05:04:33 +0000
commitfb20768fa881841d3f80d12a276a9445feb6f514 (patch)
treedfc51a2be68136b1b67d9caca8fc24a9c2c78e20 /lib
parentf9afa89a4f27283f5b115cd89ececa08145b5c89 (diff)
(고건) 이메일 템플릿 정보 수정/복제 기능 에러 수정
Diffstat (limited to 'lib')
-rw-r--r--lib/email-template/editor/template-editor.tsx45
-rw-r--r--lib/email-template/editor/template-settings.tsx205
-rw-r--r--lib/email-template/service.ts64
-rw-r--r--lib/email-template/table/duplicate-template-sheet.tsx142
-rw-r--r--lib/email-template/table/email-template-table.tsx2
-rw-r--r--lib/email-template/table/template-table-columns.tsx187
6 files changed, 314 insertions, 331 deletions
diff --git a/lib/email-template/editor/template-editor.tsx b/lib/email-template/editor/template-editor.tsx
index dc77a558..a22fb059 100644
--- a/lib/email-template/editor/template-editor.tsx
+++ b/lib/email-template/editor/template-editor.tsx
@@ -1,35 +1,36 @@
-"use client"
+'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 */
+import { ArrowLeft, Edit, List, Settings } from 'lucide-react';
+import { Badge } from '@/components/ui/badge';
+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"
+} from '@/components/ui/card';
+import { getCategoryDisplayName } from '../validations';
+import Link from 'next/link';
+import { Separator } from '@/components/ui/separator';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { TemplateContentEditor } from './template-content-editor';
+import { TemplateSettings } from './template-settings';
+import { TemplateVariableManager } from './template-variable-manager';
+import { type TemplateWithVariables } from '@/db/schema';
+import { useState } from 'react';
-import { type TemplateWithVariables } from "@/db/schema"
-import { TemplateContentEditor } from "./template-content-editor"
-import { TemplateVariableManager } from "./template-variable-manager"
-import { TemplateSettings } from "./template-settings"
+// ----------------------------------------------------------------------------------------------------
+/* TYPES */
interface TemplateEditorProps {
- templateSlug: string
- initialTemplate: TemplateWithVariables
+ templateSlug: string;
+ initialTemplate: TemplateWithVariables;
}
export function TemplateEditor({ templateSlug, initialTemplate }: TemplateEditorProps) {
- const router = useRouter()
- const [template, setTemplate] = React.useState(initialTemplate)
+ const [template, setTemplate] = useState(initialTemplate);
return (
<div className="flex flex-1 flex-col gap-4 p-4 md:gap-8 md:p-8">
@@ -48,12 +49,12 @@ export function TemplateEditor({ templateSlug, initialTemplate }: TemplateEditor
</Badge>
{template.category && (
<Badge variant="secondary" className="text-xs">
- {template.category}
+ {getCategoryDisplayName(template.category)}
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">
- {template.description || "템플릿 편집"}
+ {template.description || '템플릿 편집'}
</p>
</div>
@@ -171,4 +172,4 @@ export function TemplateEditor({ templateSlug, initialTemplate }: TemplateEditor
</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
index f253f87d..99ef5443 100644
--- a/lib/email-template/editor/template-settings.tsx
+++ b/lib/email-template/editor/template-settings.tsx
@@ -1,24 +1,6 @@
-"use client"
+'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 */
import {
AlertDialog,
AlertDialogAction,
@@ -29,68 +11,92 @@ import {
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
-} from "@/components/ui/alert-dialog"
-import { Badge } from "@/components/ui/badge"
-import { Separator } from "@/components/ui/separator"
+} from '@/components/ui/alert-dialog';
import {
- Save,
- Trash2,
AlertTriangle,
- Info,
Calendar,
- User,
+ Copy,
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"
+ Info,
+ Save,
+ Trash2,
+ User,
+} from 'lucide-react';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import { deleteTemplate, duplicateTemplate, getCurrentUserId, updateTemplateAction } from '../service';
+import { getCategoryDisplayName, TEMPLATE_CATEGORY_OPTIONS } from '../validations';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select';
+import { Separator } from '@/components/ui/separator';
+import { Textarea } from '@/components/ui/textarea';
+import { toast } from 'sonner';
+import { type TemplateWithVariables } from '@/db/schema';
+import { useRouter } from 'next/navigation';
+import { useState } from 'react';
+// ----------------------------------------------------------------------------------------------------
+/* TYPES */
interface TemplateSettingsProps {
- template: TemplateWithVariables
- onUpdate: (template: TemplateWithVariables) => void
+ 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({
+ const router = useRouter();
+ const [isLoading, setIsLoading] = useState(false);
+ const [formData, setFormData] = 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)
+ setIsLoading(true);
try {
// 샘플 데이터 JSON 파싱 검증
- let parsedSampleData = {}
+ let parsedSampleData = {};
if (formData.sampleData.trim()) {
try {
- parsedSampleData = JSON.parse(formData.sampleData)
+ parsedSampleData = JSON.parse(formData.sampleData);
} catch (error) {
- toast.error('샘플 데이터 JSON 형식이 올바르지 않습니다.')
- setIsLoading(false)
- return
+ toast.error('샘플 데이터 JSON 형식이 올바르지 않습니다.');
+ setIsLoading(false);
+ return;
}
}
const result = await updateTemplateAction(template.slug, {
name: formData.name,
description: formData.description || undefined,
+ category: formData.category,
sampleData: parsedSampleData,
- updatedBy: 'current-user-id' // TODO: 실제 사용자 ID
- })
+ updatedBy: await getCurrentUserId(),
+ });
if (result.success) {
toast.success('템플릿 설정이 저장되었습니다.')
@@ -98,62 +104,63 @@ export function TemplateSettings({ template, onUpdate }: TemplateSettingsProps)
...template,
name: formData.name,
description: formData.description,
+ category: formData.category,
sampleData: parsedSampleData,
- version: template.version + 1
- })
+ version: template.version ? template.version + 1 : 1,
+ });
} else {
- toast.error(result.error || '저장에 실패했습니다.')
+ toast.error(result.error || '저장에 실패했습니다.');
}
} catch (error) {
- toast.error('저장 중 오류가 발생했습니다.')
+ toast.error('저장 중 오류가 발생했습니다.');
} finally {
- setIsLoading(false)
+ setIsLoading(false);
}
}
// 템플릿 복제
const handleDuplicate = async () => {
- setIsLoading(true)
+ setIsLoading(true);
try {
- const copyName = `${template.name} (복사본)`
- const copySlug = `${template.slug}-copy-${Date.now()}`
+ const copyName = `${template.name} (복사본)`;
+ const copySlug = `${template.slug}-copy-${Date.now()}`;
const result = await duplicateTemplate(
template.id,
copyName,
copySlug,
- 'current-user-id' // TODO: 실제 사용자 ID
- )
+ await getCurrentUserId(),
+ );
if (result.success && result.data) {
- toast.success('템플릿이 복제되었습니다.')
- router.push(`/evcp/templates/${result.data.slug}`)
+ toast.success('템플릿이 복제되었습니다.');
+ router.push(`/evcp/email-template/${result.data.slug}`);
} else {
- toast.error(result.error || '복제에 실패했습니다.')
+ toast.error(result.error || '복제에 실패했습니다.');
}
} catch (error) {
- toast.error('복제 중 오류가 발생했습니다.')
+ toast.error('복제 중 오류가 발생했습니다.');
} finally {
- setIsLoading(false)
+ setIsLoading(false);
}
}
// 템플릿 삭제
const handleDelete = async () => {
- setIsLoading(true)
+ setIsLoading(true);
try {
- const result = await deleteTemplate(template.id)
+ const result = await deleteTemplate(template.id);
if (result.success) {
- toast.success('템플릿이 삭제되었습니다.')
- router.push('/evcp/templates')
+ toast.success('템플릿이 삭제되었습니다.');
+ router.push('/evcp/email-template');
} else {
- toast.error(result.error || '삭제에 실패했습니다.')
+ toast.error(result.error || '삭제에 실패했습니다.');
}
} catch (error) {
- toast.error('삭제 중 오류가 발생했습니다.')
+ toast.error('삭제 중 오류가 발생했습니다.');
} finally {
- setIsLoading(false)
+ setIsLoading(false);
}
}
@@ -215,7 +222,6 @@ export function TemplateSettings({ template, onUpdate }: TemplateSettingsProps)
placeholder="템플릿 이름을 입력하세요"
/>
</div>
-
<div>
<Label htmlFor="description">설명</Label>
<Textarea
@@ -226,30 +232,28 @@ export function TemplateSettings({ template, onUpdate }: TemplateSettingsProps)
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>
-
+ <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" />
@@ -284,7 +288,6 @@ export function TemplateSettings({ template, onUpdate }: TemplateSettingsProps)
기본 데이터 생성
</Button>
</div>
-
<div>
<Label htmlFor="sampleData">샘플 데이터 (JSON)</Label>
<Textarea
@@ -295,7 +298,6 @@ export function TemplateSettings({ template, onUpdate }: TemplateSettingsProps)
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" />
@@ -323,21 +325,18 @@ export function TemplateSettings({ template, onUpdate }: TemplateSettingsProps)
{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">
@@ -345,7 +344,6 @@ export function TemplateSettings({ template, onUpdate }: TemplateSettingsProps)
</span>
</div>
</div>
-
<div className="space-y-3">
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-muted-foreground" />
@@ -353,19 +351,16 @@ export function TemplateSettings({ template, onUpdate }: TemplateSettingsProps)
생성자: {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>
@@ -471,4 +466,4 @@ export function TemplateSettings({ template, onUpdate }: TemplateSettingsProps)
</div>
</div>
)
-} \ No newline at end of file
+}
diff --git a/lib/email-template/service.ts b/lib/email-template/service.ts
index 7c92b377..946e0a26 100644
--- a/lib/email-template/service.ts
+++ b/lib/email-template/service.ts
@@ -1,40 +1,31 @@
'use server';
+/* IMPORT */
+import { and, asc, count, desc, eq, ilike, or, sql, type SQL } from 'drizzle-orm';
+import { authOptions } from '@/app/api/auth/[...nextauth]/route';
import db from '@/db/db';
-import { eq, and, desc, sql, count, ilike, asc, or, SQL } from 'drizzle-orm';
+import { filterColumns } from '../filter-columns';
+import { getOptions } from '@/i18n/settings';
+import { getServerSession } from 'next-auth';
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';
+import { templateListView } from '@/db/schema';
+import { type GetEmailTemplateSchema } from './validations';
-// Validation imports
-import { GetEmailTemplateSchema } from './validations';
-import { filterColumns } from '../filter-columns';
-
-// ===========================================
-// Types
-// ===========================================
+// ----------------------------------------------------------------------------------------------------
+/* TYPES */
export interface TemplateWithVariables extends Template {
variables: TemplateVariable[];
}
-
interface ValidationResult {
isValid: boolean;
errors: string[];
@@ -225,6 +216,17 @@ function registerHandlebarsHelpers(): void {
// Core Service Functions
// ===========================================
+/* HELPER FUNCTION FOR GETTING CURRENT USER ID */
+export async function getCurrentUserId(): Promise<number> {
+ try {
+ const session = await getServerSession(authOptions);
+ return session?.user?.id ? Number(session.user.id) : 3; // 기본값 3, 실제 환경에서는 적절한 기본값 설정
+ } catch (error) {
+ console.error('Error in Getting Current Session User ID:', error);
+ return 3; // 기본값 3
+ }
+}
+
/**
* 템플릿 목록 조회 (View 테이블 사용)
*/
@@ -433,6 +435,7 @@ export async function updateTemplate(slug: string, data: {
subject?: string;
content?: string;
description?: string;
+ category?: string;
sampleData?: Record<string, any>;
updatedBy: number;
}): Promise<TemplateWithVariables> {
@@ -444,10 +447,10 @@ export async function updateTemplate(slug: string, data: {
// 버전 히스토리 저장
await db.insert(templateHistory).values({
- templateId: existingTemplate.id,
- version: existingTemplate.version,
- subject: existingTemplate.subject,
- content: existingTemplate.content,
+ templateId: existingTemplate.id!,
+ version: existingTemplate.version!,
+ subject: existingTemplate.subject!,
+ content: existingTemplate.content!,
changeDescription: '템플릿 업데이트',
changedBy: data.updatedBy,
});
@@ -460,8 +463,9 @@ export async function updateTemplate(slug: string, data: {
content: data.content || existingTemplate.content,
subject: data.subject || existingTemplate.subject,
description: data.description || existingTemplate.description,
+ category: data.category || existingTemplate.category,
sampleData: data.sampleData || existingTemplate.sampleData,
- version: existingTemplate.version + 1,
+ version: existingTemplate.version ? existingTemplate.version + 1 : 1,
updatedAt: new Date(),
})
.where(eq(templates.id, existingTemplate.id));
@@ -653,7 +657,7 @@ export async function duplicateTemplate(
id: string,
newName: string,
newSlug: string,
- userId: string
+ userId: number,
): Promise<{ success: boolean; error?: string; data?: any }> {
try {
// 원본 템플릿 조회 (변수 포함)
@@ -691,12 +695,13 @@ export async function duplicateTemplate(
.values({
name: newName,
slug: newSlug,
+ subject: originalTemplate.subject,
content: originalTemplate.content,
description: originalTemplate.description ? `${originalTemplate.description} (복사본)` : undefined,
category: originalTemplate.category,
sampleData: originalTemplate.sampleData,
+ version: 1,
createdBy: userId,
- version: 1
})
.returning();
@@ -869,6 +874,7 @@ export async function updateTemplateAction(slug: string, data: {
subject?: string;
content?: string;
description?: string;
+ category?: string;
sampleData?: Record<string, any>;
updatedBy: number;
}) {
@@ -883,7 +889,7 @@ export async function updateTemplateAction(slug: string, data: {
if (!validation.isValid) {
return {
success: false,
- error: `보안 검증 실패: ${validation.errors.join(', ')}`
+ error: `보안 검증 실패: ${validation.errors.join(', ')}`,
};
}
@@ -895,12 +901,12 @@ export async function updateTemplateAction(slug: string, data: {
return {
success: true,
- data: template
+ data: template,
};
} catch (error) {
return {
success: false,
- error: error instanceof Error ? error.message : '템플릿 수정에 실패했습니다.'
+ error: error instanceof Error ? error.message : '템플릿 수정에 실패했습니다.',
};
}
}
diff --git a/lib/email-template/table/duplicate-template-sheet.tsx b/lib/email-template/table/duplicate-template-sheet.tsx
index 767a8bb6..d59d80f0 100644
--- a/lib/email-template/table/duplicate-template-sheet.tsx
+++ b/lib/email-template/table/duplicate-template-sheet.tsx
@@ -1,15 +1,8 @@
-"use client"
+'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 */
+import { Button } from '@/components/ui/button';
+import { duplicateTemplate, getCurrentUserId } from '../service';
import {
Form,
FormControl,
@@ -18,8 +11,10 @@ import {
FormLabel,
FormMessage,
FormDescription,
-} from "@/components/ui/form"
-import { Input } from "@/components/ui/input"
+} from '@/components/ui/form';
+import { getCategoryDisplayName } from '../validations';
+import { Input } from '@/components/ui/input';
+import { Loader } from 'lucide-react';
import {
Sheet,
SheetClose,
@@ -28,53 +23,59 @@ import {
SheetFooter,
SheetHeader,
SheetTitle,
-} from "@/components/ui/sheet"
-
-import { duplicateTemplate } from "../service"
-
-// Validation Schema
+} from '@/components/ui/sheet';
+import { toast } from 'sonner';
+import { type ComponentPropsWithRef, useEffect, useTransition } from 'react';
+import { type TemplateListView } from '@/db/schema';
+import { useForm } from 'react-hook-form';
+import { useRouter } from 'next/navigation';
+import { z } from 'zod';
+import { zodResolver } from '@hookform/resolvers/zod';
+
+// ----------------------------------------------------------------------------------------------------
+
+/* TYPES */
const duplicateTemplateSchema = z.object({
- name: z.string().min(1, "템플릿 이름은 필수입니다").max(100, "템플릿 이름은 100자 이하여야 합니다"),
+ 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
+ .min(1, 'Slug는 필수입니다.')
+ .max(50, 'Slug는 50자 이하여야 합니다.')
+ .regex(/^[a-z0-9-]+$/, 'Slug는 소문자, 숫자, 하이픈만 사용 가능합니다.'),
+});
+type DuplicateTemplateSchema = z.infer<typeof duplicateTemplateSchema>;
+interface DuplicateTemplateSheetProps extends ComponentPropsWithRef<typeof Sheet> {
+ template: TemplateListView | null;
}
-export function DuplicateTemplateSheet({ template, ...props }: DuplicateTemplateSheetProps) {
- const [isDuplicatePending, startDuplicateTransition] = React.useTransition()
- const router = useRouter()
+// ----------------------------------------------------------------------------------------------------
+/* DUPLICATE EMAIL TEMPLATE SHEET COMPONENT */
+function DuplicateTemplateSheet({ template, ...props }: DuplicateTemplateSheetProps) {
+ const [isDuplicatePending, startDuplicateTransition] = useTransition();
const form = useForm<DuplicateTemplateSchema>({
resolver: zodResolver(duplicateTemplateSchema),
defaultValues: {
- name: "",
- slug: "",
+ name: '',
+ slug: '',
},
})
+ const router = useRouter();
+ const watchedName = form.watch('name');
- React.useEffect(() => {
+ useEffect(() => {
if (template) {
- const copyName = `${template.name} (복사본)`
- const copySlug = `${template.slug}-copy-${Date.now()}`
-
+ const copyName = `${template.name} (복사본)`;
+ const copySlug = `${template.slug}-copy-${Date.now()}`;
form.reset({
name: copyName,
slug: copySlug,
- })
+ });
}
- }, [template, form])
+ }, [template, form]);
- // 이름 입력 시 자동으로 slug 생성
- const watchedName = form.watch("name")
- React.useEffect(() => {
+ useEffect(() => {
if (watchedName && !form.formState.dirtyFields.slug) {
const autoSlug = watchedName
.toLowerCase()
@@ -82,42 +83,40 @@ export function DuplicateTemplateSheet({ template, ...props }: DuplicateTemplate
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim()
- .slice(0, 50)
-
- form.setValue("slug", autoSlug, { shouldValidate: false })
+ .slice(0, 50);
+ form.setValue('slug', autoSlug, { shouldValidate: false });
}
- }, [watchedName, form])
+ }, [watchedName, form]);
function onSubmit(input: DuplicateTemplateSchema) {
startDuplicateTransition(async () => {
- if (!template) return
-
- // 현재 사용자 ID (실제로는 인증에서 가져와야 함)
- const currentUserId = "current-user-id" // TODO: 실제 사용자 ID로 교체
+ if (!template) {
+ return;
+ }
+ const currentUserId = await getCurrentUserId();
const { error, data } = await duplicateTemplate(
template.id,
input.name,
input.slug,
- currentUserId
- )
+ currentUserId,
+ );
if (error) {
- toast.error(error)
- return
+ toast.error(error);
+ return;
}
- form.reset()
- props.onOpenChange?.(false)
- toast.success("템플릿이 복제되었습니다")
+ form.reset();
+ props.onOpenChange?.(false);
+ toast.success('템플릿이 복제되었습니다.');
- // 복제된 템플릿의 세부 페이지로 이동
if (data?.slug) {
- router.push(`/evcp/templates/${data.slug}`)
+ router.push(`/evcp/email-template/${data.slug}`);
} else {
- window.location.reload()
+ window.location.reload();
}
- })
+ });
}
return (
@@ -129,19 +128,17 @@ export function DuplicateTemplateSheet({ template, ...props }: DuplicateTemplate
기존 템플릿을 복사하여 새로운 템플릿을 생성합니다. 모든 내용과 변수가 복제됩니다.
</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>카테고리: {getCategoryDisplayName(template.category)}</div>
<div>변수: {template.variableCount}개</div>
</div>
</div>
)}
-
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
@@ -155,7 +152,7 @@ export function DuplicateTemplateSheet({ template, ...props }: DuplicateTemplate
<FormLabel>새 템플릿 이름</FormLabel>
<FormControl>
<Input
- placeholder="복제된 템플릿의 이름을 입력하세요"
+ placeholder="복제된 템플릿의 이름을 입력하세요."
{...field}
/>
</FormControl>
@@ -163,7 +160,6 @@ export function DuplicateTemplateSheet({ template, ...props }: DuplicateTemplate
</FormItem>
)}
/>
-
<FormField
control={form.control}
name="slug"
@@ -172,7 +168,7 @@ export function DuplicateTemplateSheet({ template, ...props }: DuplicateTemplate
<FormLabel>새 Slug</FormLabel>
<FormControl>
<Input
- placeholder="복제된 템플릿의 slug를 입력하세요"
+ placeholder="복제된 템플릿의 Slug를 입력하세요."
{...field}
/>
</FormControl>
@@ -183,7 +179,6 @@ export function DuplicateTemplateSheet({ template, ...props }: DuplicateTemplate
</FormItem>
)}
/>
-
<SheetFooter className="gap-2 pt-2 sm:space-x-0">
<SheetClose asChild>
<Button type="button" variant="outline">
@@ -204,5 +199,10 @@ export function DuplicateTemplateSheet({ template, ...props }: DuplicateTemplate
</Form>
</SheetContent>
</Sheet>
- )
-} \ No newline at end of file
+ );
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* EXPORT */
+export default DuplicateTemplateSheet;
diff --git a/lib/email-template/table/email-template-table.tsx b/lib/email-template/table/email-template-table.tsx
index 65e93a10..acce0ace 100644
--- a/lib/email-template/table/email-template-table.tsx
+++ b/lib/email-template/table/email-template-table.tsx
@@ -17,7 +17,7 @@ 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 DuplicateTemplateSheet from './duplicate-template-sheet';
import { DeleteTemplateDialog } from "./delete-template-dialog"
interface TemplateTableProps {
diff --git a/lib/email-template/table/template-table-columns.tsx b/lib/email-template/table/template-table-columns.tsx
index a678a20a..9c3e69c2 100644
--- a/lib/email-template/table/template-table-columns.tsx
+++ b/lib/email-template/table/template-table-columns.tsx
@@ -1,41 +1,46 @@
-"use client"
+'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 */
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Copy, Eye, MoreHorizontal, Trash } from 'lucide-react';
+import { DataTableColumnHeaderSimple } from '@/components/data-table/data-table-column-simple-header';
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"
+} from '@/components/ui/dropdown-menu';
+import { formatDate } from '@/lib/utils';
+import { getCategoryDisplayName, getCategoryVariant } from '../validations';
+import Link from 'next/link';
+import { type ColumnDef } from '@tanstack/react-table';
+import { type Dispatch, type SetStateAction } from 'react';
+import { type DataTableRowAction } from '@/types/table';
+import { type TemplateListView } from '@/db/schema';
+
+// ----------------------------------------------------------------------------------------------------
+/* TYPES */
interface GetColumnsProps {
- setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TemplateListView> | null>>
+ setRowAction: Dispatch<SetStateAction<DataTableRowAction<TemplateListView> | null>>;
}
+// ----------------------------------------------------------------------------------------------------
+
+/* FUNCTION FOR GETTING COLUMNS SETTING */
export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<TemplateListView>[] {
return [
- // 체크박스 컬럼
+ // [1] Checkbox Column
{
- id: "select",
+ id: 'select',
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
- (table.getIsSomePageRowsSelected() && "indeterminate")
+ (table.getIsSomePageRowsSelected() && 'indeterminate')
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
@@ -53,10 +58,9 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<Templat
enableSorting: false,
enableHiding: false,
},
-
- // 템플릿 이름 컬럼 (클릭 시 세부 페이지로)
+ // [2] Template Name Column
{
- accessorKey: "name",
+ accessorKey: 'name',
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="템플릿 이름" />
),
@@ -80,34 +84,33 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<Templat
},
enableSorting: true,
enableHiding: false,
- size:200
+ size: 200,
},
-
+ // [3] Email Subject Column
{
- 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
+ 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>
+ )
},
-
- // Slug 컬럼
+ enableSorting: true,
+ size: 250,
+ },
+ // [4] Slug Column
{
- accessorKey: "slug",
+ accessorKey: 'slug',
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="Slug" />
),
cell: ({ getValue }) => {
- const slug = getValue() as string
+ const slug = getValue() as string;
return (
<div className="font-mono text-sm text-muted-foreground">
{slug}
@@ -115,44 +118,40 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<Templat
)
},
enableSorting: true,
- size:120
-
+ size: 120,
},
-
- // 카테고리 컬럼
+ // [5] Category Column
{
- accessorKey: "category",
+ accessorKey: 'category',
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="카테고리" />
),
cell: ({ row }) => {
- const category = row.original.category
- const displayName = getCategoryDisplayName(category)
- const variant = getCategoryVariant(category)
+ 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))
+ return value.includes(row.getValue(id));
},
- size:120
-
+ enableSorting: true,
+ size: 120,
},
-
- // 변수 개수 컬럼
+ // [6] Variable Count Column
{
- accessorKey: "variableCount",
+ accessorKey: 'variableCount',
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="변수" />
),
cell: ({ row }) => {
- const variableCount = row.original.variableCount
- const requiredCount = row.original.requiredVariableCount
+ const variableCount = row.original.variableCount;
+ const requiredCount = row.original.requiredVariableCount;
return (
<div className="text-center">
@@ -163,21 +162,20 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<Templat
</div>
)}
</div>
- )
+ );
},
enableSorting: true,
- size:80
-
+ size: 80,
},
-
- // 버전 컬럼
+ // [7] Version Column
{
- accessorKey: "version",
+ accessorKey: 'version',
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="버전" />
),
cell: ({ getValue }) => {
- const version = getValue() as number
+ const version = getValue() as number;
+
return (
<div className="text-center">
<Badge variant="outline" className="font-mono text-xs">
@@ -187,18 +185,17 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<Templat
)
},
enableSorting: true,
- size:80
-
+ size: 80,
},
-
- // 생성일 컬럼
+ // [8] Created At Column
{
- accessorKey: "createdAt",
+ accessorKey: 'createdAt',
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="생성일" />
),
cell: ({ cell }) => {
- const date = cell.getValue() as Date
+ const date = cell.getValue() as Date;
+
return (
<div className="text-sm text-muted-foreground">
{formatDate(date)}
@@ -206,18 +203,17 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<Templat
)
},
enableSorting: true,
- size:200
-
+ size: 150,
},
-
- // 수정일 컬럼
+ // [9] Updated At Column
{
- accessorKey: "updatedAt",
+ accessorKey: 'updatedAt',
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="수정일" />
),
cell: ({ cell }) => {
- const date = cell.getValue() as Date
+ const date = cell.getValue() as Date;
+
return (
<div className="text-sm text-muted-foreground">
{formatDate(date)}
@@ -225,15 +221,13 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<Templat
)
},
enableSorting: true,
- size:200
-
+ size: 150,
},
-
- // Actions 컬럼
+ // [10] Actions Column
{
- id: "actions",
+ id: 'actions',
cell: ({ row }) => {
- const template = row.original
+ const template = row.original;
return (
<DropdownMenu>
@@ -253,30 +247,18 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<Templat
상세 보기
</Link>
</DropdownMenuItem>
-
- {/* <DropdownMenuItem
- onClick={() => {
- setRowAction({ type: "update", row })
- }}
- >
- <Edit className="mr-2 size-4" aria-hidden="true" />
- 업데이트
- </DropdownMenuItem> */}
-
<DropdownMenuItem
onClick={() => {
- setRowAction({ type: "duplicate", row })
+ setRowAction({ type: 'duplicate', row })
}}
>
<Copy className="mr-2 size-4" aria-hidden="true" />
복제하기
</DropdownMenuItem>
-
<DropdownMenuSeparator />
-
<DropdownMenuItem
onClick={() => {
- setRowAction({ type: "delete", row })
+ setRowAction({ type: 'delete', row })
}}
className="text-destructive focus:text-destructive"
>
@@ -290,7 +272,6 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<Templat
enableSorting: false,
enableHiding: false,
size:80
-
},
- ]
-} \ No newline at end of file
+ ];
+}