diff options
| -rw-r--r-- | app/[lng]/evcp/(evcp)/(system)/email-template/[slug]/page.tsx | 40 | ||||
| -rw-r--r-- | lib/email-template/editor/template-editor.tsx | 45 | ||||
| -rw-r--r-- | lib/email-template/editor/template-settings.tsx | 205 | ||||
| -rw-r--r-- | lib/email-template/service.ts | 64 | ||||
| -rw-r--r-- | lib/email-template/table/duplicate-template-sheet.tsx | 142 | ||||
| -rw-r--r-- | lib/email-template/table/email-template-table.tsx | 2 | ||||
| -rw-r--r-- | lib/email-template/table/template-table-columns.tsx | 187 |
7 files changed, 337 insertions, 348 deletions
diff --git a/app/[lng]/evcp/(evcp)/(system)/email-template/[slug]/page.tsx b/app/[lng]/evcp/(evcp)/(system)/email-template/[slug]/page.tsx index 2654489f..d081b69a 100644 --- a/app/[lng]/evcp/(evcp)/(system)/email-template/[slug]/page.tsx +++ b/app/[lng]/evcp/(evcp)/(system)/email-template/[slug]/page.tsx @@ -1,38 +1,44 @@ -import * as React from "react"
-import { type Metadata } from "next"
-import { notFound } from "next/navigation"
+/* IMPORT */
+import { getTemplateAction } from '@/lib/email-template/service';
+import { notFound } from 'next/navigation';
+import { TemplateEditor } from '@/lib/email-template/editor/template-editor';
+import { type Metadata } from 'next';
-import { getTemplateAction } from "@/lib/email-template/service"
-import { TemplateEditor } from "@/lib/email-template/editor/template-editor"
+// ----------------------------------------------------------------------------------------------------
+/* TYPES */
interface TemplateDetailPageProps {
params: Promise<{
- slug: string
- }>
+ slug: string;
+ }>;
}
+// ----------------------------------------------------------------------------------------------------
+
export async function generateMetadata({ params }: TemplateDetailPageProps): Promise<Metadata> {
- const { slug } = await params
- const result = await getTemplateAction(slug)
+ const { slug } = await params;
+ const result = await getTemplateAction(slug);
if (!result.success || !result.data) {
return {
- title: "템플릿을 찾을 수 없음",
- }
+ title: '템플릿을 찾을 수 없음',
+ };
}
return {
title: `${result.data.name} - 템플릿 편집`,
description: result.data.description || `${result.data.name} 템플릿을 편집합니다.`,
- }
+ };
}
+// ----------------------------------------------------------------------------------------------------
+
export default async function TemplateDetailPage({ params }: TemplateDetailPageProps) {
- const { slug } = await params
- const result = await getTemplateAction(slug)
+ const { slug } = await params;
+ const result = await getTemplateAction(slug);
if (!result.success || !result.data) {
- notFound()
+ notFound();
}
return (
@@ -42,5 +48,5 @@ export default async function TemplateDetailPage({ params }: TemplateDetailPageP initialTemplate={result.data}
/>
</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 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 + ]; +} |
