287 lines
10 KiB
TypeScript
287 lines
10 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import React, { useState, useEffect } from "react";
|
||
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { Plus, FileText, Loader2, RefreshCw, Search } from "lucide-react";
|
||
|
|
import {
|
||
|
|
MailTemplate,
|
||
|
|
getMailTemplates,
|
||
|
|
createMailTemplate,
|
||
|
|
updateMailTemplate,
|
||
|
|
deleteMailTemplate,
|
||
|
|
CreateMailTemplateDto,
|
||
|
|
UpdateMailTemplateDto,
|
||
|
|
} from "@/lib/api/mail";
|
||
|
|
import MailTemplateCard from "@/components/mail/MailTemplateCard";
|
||
|
|
import MailTemplatePreviewModal from "@/components/mail/MailTemplatePreviewModal";
|
||
|
|
import MailTemplateEditorModal from "@/components/mail/MailTemplateEditorModal";
|
||
|
|
import ConfirmDeleteModal from "@/components/mail/ConfirmDeleteModal";
|
||
|
|
|
||
|
|
export default function MailTemplatesPage() {
|
||
|
|
const [templates, setTemplates] = useState<MailTemplate[]>([]);
|
||
|
|
const [loading, setLoading] = useState(false);
|
||
|
|
const [searchTerm, setSearchTerm] = useState('');
|
||
|
|
const [categoryFilter, setCategoryFilter] = useState<string>('all');
|
||
|
|
|
||
|
|
// 모달 상태
|
||
|
|
const [isEditorOpen, setIsEditorOpen] = useState(false);
|
||
|
|
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||
|
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||
|
|
const [selectedTemplate, setSelectedTemplate] = useState<MailTemplate | null>(null);
|
||
|
|
const [editorMode, setEditorMode] = useState<'create' | 'edit'>('create');
|
||
|
|
|
||
|
|
// 템플릿 목록 불러오기
|
||
|
|
const loadTemplates = async () => {
|
||
|
|
setLoading(true);
|
||
|
|
try {
|
||
|
|
const data = await getMailTemplates();
|
||
|
|
setTemplates(data);
|
||
|
|
} catch (error) {
|
||
|
|
console.error('템플릿 로드 실패:', error);
|
||
|
|
alert('템플릿 목록을 불러오는데 실패했습니다.');
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
loadTemplates();
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
// 필터링된 템플릿
|
||
|
|
const filteredTemplates = templates.filter((template) => {
|
||
|
|
const matchesSearch =
|
||
|
|
template.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
|
|
template.subject.toLowerCase().includes(searchTerm.toLowerCase());
|
||
|
|
const matchesCategory =
|
||
|
|
categoryFilter === 'all' || template.category === categoryFilter;
|
||
|
|
return matchesSearch && matchesCategory;
|
||
|
|
});
|
||
|
|
|
||
|
|
// 카테고리 목록 추출
|
||
|
|
const categories = Array.from(new Set(templates.map((t) => t.category).filter(Boolean)));
|
||
|
|
|
||
|
|
const handleOpenCreateModal = () => {
|
||
|
|
setEditorMode('create');
|
||
|
|
setSelectedTemplate(null);
|
||
|
|
setIsEditorOpen(true);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleOpenEditModal = (template: MailTemplate) => {
|
||
|
|
setEditorMode('edit');
|
||
|
|
setSelectedTemplate(template);
|
||
|
|
setIsEditorOpen(true);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleOpenPreviewModal = (template: MailTemplate) => {
|
||
|
|
setSelectedTemplate(template);
|
||
|
|
setIsPreviewOpen(true);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleOpenDeleteModal = (template: MailTemplate) => {
|
||
|
|
setSelectedTemplate(template);
|
||
|
|
setIsDeleteModalOpen(true);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleSaveTemplate = async (data: CreateMailTemplateDto | UpdateMailTemplateDto) => {
|
||
|
|
try {
|
||
|
|
if (editorMode === 'create') {
|
||
|
|
await createMailTemplate(data as CreateMailTemplateDto);
|
||
|
|
} else if (editorMode === 'edit' && selectedTemplate) {
|
||
|
|
await updateMailTemplate(selectedTemplate.id, data as UpdateMailTemplateDto);
|
||
|
|
}
|
||
|
|
await loadTemplates();
|
||
|
|
setIsEditorOpen(false);
|
||
|
|
} catch (error) {
|
||
|
|
throw error; // 모달에서 에러 처리
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleDeleteTemplate = async () => {
|
||
|
|
if (!selectedTemplate) return;
|
||
|
|
|
||
|
|
try {
|
||
|
|
await deleteMailTemplate(selectedTemplate.id);
|
||
|
|
await loadTemplates();
|
||
|
|
alert('템플릿이 삭제되었습니다.');
|
||
|
|
} catch (error) {
|
||
|
|
console.error('템플릿 삭제 실패:', error);
|
||
|
|
alert('템플릿 삭제에 실패했습니다.');
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleDuplicateTemplate = async (template: MailTemplate) => {
|
||
|
|
try {
|
||
|
|
await createMailTemplate({
|
||
|
|
name: `${template.name} (복사본)`,
|
||
|
|
subject: template.subject,
|
||
|
|
components: template.components,
|
||
|
|
category: template.category,
|
||
|
|
});
|
||
|
|
await loadTemplates();
|
||
|
|
alert('템플릿이 복사되었습니다.');
|
||
|
|
} catch (error) {
|
||
|
|
console.error('템플릿 복사 실패:', error);
|
||
|
|
alert('템플릿 복사에 실패했습니다.');
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="min-h-screen bg-gray-50">
|
||
|
|
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||
|
|
{/* 페이지 제목 */}
|
||
|
|
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||
|
|
<div>
|
||
|
|
<h1 className="text-3xl font-bold text-gray-900">메일 템플릿 관리</h1>
|
||
|
|
<p className="mt-2 text-gray-600">드래그 앤 드롭으로 메일 템플릿을 만들고 관리합니다</p>
|
||
|
|
</div>
|
||
|
|
<div className="flex gap-2">
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
onClick={loadTemplates}
|
||
|
|
disabled={loading}
|
||
|
|
>
|
||
|
|
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||
|
|
새로고침
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
onClick={handleOpenCreateModal}
|
||
|
|
className="bg-orange-500 hover:bg-orange-600"
|
||
|
|
>
|
||
|
|
<Plus className="w-4 h-4 mr-2" />
|
||
|
|
새 템플릿 만들기
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 검색 및 필터 */}
|
||
|
|
<Card className="shadow-sm">
|
||
|
|
<CardContent className="p-4">
|
||
|
|
<div className="flex gap-4">
|
||
|
|
<div className="flex-1 relative">
|
||
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
value={searchTerm}
|
||
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||
|
|
placeholder="템플릿 이름, 제목으로 검색..."
|
||
|
|
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<select
|
||
|
|
value={categoryFilter}
|
||
|
|
onChange={(e) => setCategoryFilter(e.target.value)}
|
||
|
|
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||
|
|
>
|
||
|
|
<option value="all">전체 카테고리</option>
|
||
|
|
{categories.map((cat) => (
|
||
|
|
<option key={cat} value={cat}>
|
||
|
|
{cat}
|
||
|
|
</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
{/* 메인 컨텐츠 */}
|
||
|
|
{loading ? (
|
||
|
|
<Card className="shadow-sm">
|
||
|
|
<CardContent className="flex justify-center items-center py-16">
|
||
|
|
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
) : filteredTemplates.length === 0 ? (
|
||
|
|
<Card className="text-center py-16 bg-white shadow-sm">
|
||
|
|
<CardContent className="pt-6">
|
||
|
|
<FileText className="w-16 h-16 mx-auto mb-4 text-gray-300" />
|
||
|
|
<p className="text-gray-500 mb-4">
|
||
|
|
{templates.length === 0
|
||
|
|
? '아직 생성된 템플릿이 없습니다'
|
||
|
|
: '검색 결과가 없습니다'}
|
||
|
|
</p>
|
||
|
|
{templates.length === 0 && (
|
||
|
|
<Button
|
||
|
|
onClick={handleOpenCreateModal}
|
||
|
|
className="bg-orange-500 hover:bg-orange-600"
|
||
|
|
>
|
||
|
|
<Plus className="w-4 h-4 mr-2" />
|
||
|
|
첫 템플릿 만들기
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
) : (
|
||
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||
|
|
{filteredTemplates.map((template) => (
|
||
|
|
<MailTemplateCard
|
||
|
|
key={template.id}
|
||
|
|
template={template}
|
||
|
|
onEdit={handleOpenEditModal}
|
||
|
|
onDelete={handleOpenDeleteModal}
|
||
|
|
onPreview={handleOpenPreviewModal}
|
||
|
|
onDuplicate={handleDuplicateTemplate}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* 안내 정보 */}
|
||
|
|
<Card className="bg-gradient-to-r from-orange-50 to-amber-50 border-orange-200 shadow-sm">
|
||
|
|
<CardHeader>
|
||
|
|
<CardTitle className="text-lg flex items-center">
|
||
|
|
<FileText className="w-5 h-5 mr-2 text-orange-500" />
|
||
|
|
템플릿 디자이너
|
||
|
|
</CardTitle>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent>
|
||
|
|
<p className="text-gray-700 mb-4">
|
||
|
|
💡 드래그 앤 드롭으로 손쉽게 메일 템플릿을 만들 수 있어요!
|
||
|
|
</p>
|
||
|
|
<ul className="space-y-2 text-sm text-gray-600">
|
||
|
|
<li className="flex items-start">
|
||
|
|
<span className="text-orange-500 mr-2">✓</span>
|
||
|
|
<span>텍스트, 버튼, 이미지, 여백 컴포넌트 지원</span>
|
||
|
|
</li>
|
||
|
|
<li className="flex items-start">
|
||
|
|
<span className="text-orange-500 mr-2">✓</span>
|
||
|
|
<span>실시간 미리보기로 즉시 확인 가능</span>
|
||
|
|
</li>
|
||
|
|
<li className="flex items-start">
|
||
|
|
<span className="text-orange-500 mr-2">✓</span>
|
||
|
|
<span>동적 변수 지원 (예: {"{customer_name}"})</span>
|
||
|
|
</li>
|
||
|
|
</ul>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 모달들 */}
|
||
|
|
<MailTemplateEditorModal
|
||
|
|
isOpen={isEditorOpen}
|
||
|
|
onClose={() => setIsEditorOpen(false)}
|
||
|
|
onSave={handleSaveTemplate}
|
||
|
|
template={selectedTemplate}
|
||
|
|
mode={editorMode}
|
||
|
|
/>
|
||
|
|
|
||
|
|
<MailTemplatePreviewModal
|
||
|
|
isOpen={isPreviewOpen}
|
||
|
|
onClose={() => setIsPreviewOpen(false)}
|
||
|
|
template={selectedTemplate}
|
||
|
|
/>
|
||
|
|
|
||
|
|
<ConfirmDeleteModal
|
||
|
|
isOpen={isDeleteModalOpen}
|
||
|
|
onClose={() => setIsDeleteModalOpen(false)}
|
||
|
|
onConfirm={handleDeleteTemplate}
|
||
|
|
title="템플릿 삭제"
|
||
|
|
message="이 템플릿을 삭제하시겠습니까?"
|
||
|
|
itemName={selectedTemplate?.name}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|