메일 관리 작업 저장용 커밋
This commit is contained in:
parent
2a8841c6dc
commit
0209be8fd6
|
|
@ -0,0 +1,604 @@
|
|||
# ERP 시스템 UI/UX 디자인 가이드
|
||||
|
||||
## 📋 문서 목적
|
||||
이 문서는 ERP 시스템의 새로운 페이지나 컴포넌트를 개발할 때 참고할 수 있는 **디자인 시스템 기준안**입니다.
|
||||
일관된 사용자 경험을 위해 모든 개발자는 이 가이드를 따라 개발해주세요.
|
||||
|
||||
---
|
||||
|
||||
## 🎨 디자인 시스템 개요
|
||||
|
||||
### 디자인 철학
|
||||
- **일관성**: 모든 페이지에서 동일한 패턴 사용
|
||||
- **명확성**: 직관적이고 이해하기 쉬운 UI
|
||||
- **접근성**: 모든 사용자가 쉽게 사용할 수 있도록
|
||||
- **반응성**: 다양한 화면 크기에 대응
|
||||
|
||||
### 기술 스택
|
||||
- **CSS Framework**: Tailwind CSS
|
||||
- **UI Library**: shadcn/ui
|
||||
- **Icons**: Lucide React
|
||||
|
||||
---
|
||||
|
||||
## 📐 페이지 기본 구조
|
||||
|
||||
### 1. 표준 페이지 레이아웃
|
||||
|
||||
```tsx
|
||||
export default function YourPage() {
|
||||
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">
|
||||
{/* 버튼들 */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 컨텐츠 */}
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
{/* 내용 */}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 구조 설명
|
||||
|
||||
#### 최상위 래퍼
|
||||
```tsx
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
```
|
||||
- `min-h-screen`: 최소 높이를 화면 전체로
|
||||
- `bg-gray-50`: 연한 회색 배경 (전체 페이지 기본 배경)
|
||||
|
||||
#### 컨테이너
|
||||
```tsx
|
||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||
```
|
||||
- `w-full max-w-none`: 전체 너비 사용
|
||||
- `px-4`: 좌우 패딩 1rem (16px)
|
||||
- `py-8`: 상하 패딩 2rem (32px)
|
||||
- `space-y-8`: 하위 요소 간 수직 간격 2rem
|
||||
|
||||
#### 헤더 카드
|
||||
```tsx
|
||||
<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">
|
||||
{/* 버튼들 */}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 컴포넌트 디자인 기준
|
||||
|
||||
### 1. 버튼
|
||||
|
||||
#### 주요 버튼 (Primary)
|
||||
```tsx
|
||||
<Button className="bg-orange-500 hover:bg-orange-600">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
버튼 텍스트
|
||||
</Button>
|
||||
```
|
||||
|
||||
#### 보조 버튼 (Secondary)
|
||||
```tsx
|
||||
<Button variant="outline" size="sm">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
새로고침
|
||||
</Button>
|
||||
```
|
||||
|
||||
#### 위험 버튼 (Danger)
|
||||
```tsx
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-red-500 hover:text-red-600"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
삭제
|
||||
</Button>
|
||||
```
|
||||
|
||||
### 2. 카드 (Card)
|
||||
|
||||
#### 기본 카드
|
||||
```tsx
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>카드 제목</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
{/* 내용 */}
|
||||
</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
#### 강조 카드
|
||||
```tsx
|
||||
<Card className="bg-gradient-to-r from-orange-50 to-amber-50 border-orange-200 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Icon className="w-5 h-5 mr-2 text-orange-500" />
|
||||
제목
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-700">내용</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
### 3. 테이블
|
||||
|
||||
#### 기본 테이블 구조
|
||||
```tsx
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200/40 bg-gradient-to-r from-slate-50/90 to-gray-50/70">
|
||||
<th className="h-12 px-6 py-4 text-left text-sm font-semibold text-gray-700">
|
||||
컬럼명
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="h-12 border-b border-gray-100/60 hover:bg-gradient-to-r hover:from-orange-50/80 hover:to-orange-100/60">
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
데이터
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
### 4. 폼 (Form)
|
||||
|
||||
#### 입력 필드
|
||||
```tsx
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
라벨
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-orange-500 transition-all duration-200"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### 셀렉트
|
||||
```tsx
|
||||
<Select>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">옵션 1</SelectItem>
|
||||
<SelectItem value="2">옵션 2</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
```
|
||||
|
||||
### 5. 빈 상태 (Empty State)
|
||||
|
||||
```tsx
|
||||
<Card className="text-center py-16 bg-white shadow-sm">
|
||||
<CardContent className="pt-6">
|
||||
<Icon className="w-16 h-16 mx-auto mb-4 text-gray-300" />
|
||||
<p className="text-gray-500 mb-4">데이터가 없습니다</p>
|
||||
<Button className="bg-orange-500 hover:bg-orange-600">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
추가하기
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
```
|
||||
|
||||
### 6. 로딩 상태
|
||||
|
||||
```tsx
|
||||
<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>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 색상 시스템
|
||||
|
||||
### 주 색상 (Primary)
|
||||
```css
|
||||
orange-50 #fff7ed /* 매우 연한 배경 */
|
||||
orange-100 #ffedd5 /* 연한 배경 */
|
||||
orange-500 #f97316 /* 주요 버튼, 강조 */
|
||||
orange-600 #ea580c /* 버튼 호버 */
|
||||
```
|
||||
|
||||
### 회색 (Gray)
|
||||
```css
|
||||
gray-50 #f9fafb /* 페이지 배경 */
|
||||
gray-100 #f3f4f6 /* 카드 내부 구분 */
|
||||
gray-200 #e5e7eb /* 테두리 */
|
||||
gray-300 #d1d5db /* 입력 필드 테두리 */
|
||||
gray-500 #6b7280 /* 보조 텍스트 */
|
||||
gray-600 #4b5563 /* 일반 텍스트 */
|
||||
gray-700 #374151 /* 라벨, 헤더 */
|
||||
gray-800 #1f2937 /* 제목 */
|
||||
gray-900 #111827 /* 주요 제목 */
|
||||
```
|
||||
|
||||
### 상태 색상
|
||||
```css
|
||||
/* 성공 */
|
||||
green-100 #dcfce7
|
||||
green-500 #22c55e
|
||||
green-700 #15803d
|
||||
|
||||
/* 경고 */
|
||||
red-100 #fee2e2
|
||||
red-500 #ef4444
|
||||
red-600 #dc2626
|
||||
|
||||
/* 정보 */
|
||||
blue-50 #eff6ff
|
||||
blue-100 #dbeafe
|
||||
blue-500 #3b82f6
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📏 간격 시스템
|
||||
|
||||
### Spacing Scale
|
||||
```css
|
||||
space-y-2 0.5rem (8px) /* 폼 요소 간 간격 */
|
||||
space-y-4 1rem (16px) /* 섹션 내부 간격 */
|
||||
space-y-6 1.5rem (24px) /* 카드 내부 큰 간격 */
|
||||
space-y-8 2rem (32px) /* 페이지 주요 섹션 간격 */
|
||||
|
||||
gap-2 0.5rem (8px) /* 버튼 그룹 간격 */
|
||||
gap-4 1rem (16px) /* 카드 그리드 간격 */
|
||||
gap-6 1.5rem (24px) /* 큰 카드 그리드 간격 */
|
||||
```
|
||||
|
||||
### Padding
|
||||
```css
|
||||
p-2 0.5rem (8px) /* 작은 요소 */
|
||||
p-4 1rem (16px) /* 일반 요소 */
|
||||
p-6 1.5rem (24px) /* 카드, 헤더 */
|
||||
p-8 2rem (32px) /* 큰 영역 */
|
||||
|
||||
px-3 좌우 0.75rem /* 입력 필드 */
|
||||
px-4 좌우 1rem /* 버튼 */
|
||||
px-6 좌우 1.5rem /* 테이블 셀 */
|
||||
|
||||
py-2 상하 0.5rem /* 버튼 */
|
||||
py-4 상하 1rem /* 입력 필드 */
|
||||
py-8 상하 2rem /* 페이지 컨테이너 */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 타이포그래피
|
||||
|
||||
### 제목 (Headings)
|
||||
```css
|
||||
/* 페이지 제목 */
|
||||
text-3xl font-bold text-gray-900
|
||||
/* 예: 30px, Bold, #111827 */
|
||||
|
||||
/* 섹션 제목 */
|
||||
text-2xl font-bold text-gray-900
|
||||
/* 예: 24px, Bold */
|
||||
|
||||
/* 카드 제목 */
|
||||
text-lg font-semibold text-gray-800
|
||||
/* 예: 18px, Semi-bold */
|
||||
|
||||
/* 작은 제목 */
|
||||
text-base font-medium text-gray-700
|
||||
/* 예: 16px, Medium */
|
||||
```
|
||||
|
||||
### 본문 (Body Text)
|
||||
```css
|
||||
/* 일반 텍스트 */
|
||||
text-sm text-gray-600
|
||||
/* 14px, #4b5563 */
|
||||
|
||||
/* 보조 설명 */
|
||||
text-sm text-gray-500
|
||||
/* 14px, #6b7280 */
|
||||
|
||||
/* 라벨 */
|
||||
text-sm font-medium text-gray-700
|
||||
/* 14px, Medium */
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎭 인터랙션 패턴
|
||||
|
||||
### 호버 효과
|
||||
```css
|
||||
/* 버튼 호버 */
|
||||
hover:bg-orange-600
|
||||
hover:shadow-md
|
||||
|
||||
/* 카드 호버 */
|
||||
hover:shadow-lg transition-shadow
|
||||
|
||||
/* 테이블 행 호버 */
|
||||
hover:bg-gradient-to-r hover:from-orange-50/80 hover:to-orange-100/60
|
||||
```
|
||||
|
||||
### 포커스 효과
|
||||
```css
|
||||
/* 입력 필드 포커스 */
|
||||
focus:outline-none
|
||||
focus:ring-2
|
||||
focus:ring-orange-500
|
||||
focus:border-orange-500
|
||||
```
|
||||
|
||||
### 전환 효과
|
||||
```css
|
||||
/* 일반 전환 */
|
||||
transition-all duration-200
|
||||
|
||||
/* 그림자 전환 */
|
||||
transition-shadow
|
||||
|
||||
/* 색상 전환 */
|
||||
transition-colors duration-200
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔲 그리드 시스템
|
||||
|
||||
### 반응형 그리드
|
||||
```tsx
|
||||
{/* 1열 → 2열 → 3열 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* 카드들 */}
|
||||
</div>
|
||||
|
||||
{/* 1열 → 2열 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 항목들 */}
|
||||
</div>
|
||||
```
|
||||
|
||||
### 브레이크포인트
|
||||
```css
|
||||
sm: 640px @media (min-width: 640px)
|
||||
md: 768px @media (min-width: 768px)
|
||||
lg: 1024px @media (min-width: 1024px)
|
||||
xl: 1280px @media (min-width: 1280px)
|
||||
2xl: 1536px @media (min-width: 1536px)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 실전 예제
|
||||
|
||||
### 예제 1: 관리 페이지 (데이터 있음)
|
||||
|
||||
```tsx
|
||||
export default function ManagementPage() {
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
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={loadData}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button className="bg-orange-500 hover:bg-orange-600">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
새로 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">총 개수</p>
|
||||
<p className="text-2xl font-bold text-gray-900">156</p>
|
||||
</div>
|
||||
<div className="bg-blue-100 p-3 rounded-lg">
|
||||
<Database className="w-6 h-6 text-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* 나머지 통계 카드들... */}
|
||||
</div>
|
||||
|
||||
{/* 데이터 테이블 */}
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200/40 bg-gradient-to-r from-slate-50/90 to-gray-50/70">
|
||||
<th className="h-12 px-6 py-4 text-left text-sm font-semibold text-gray-700">
|
||||
이름
|
||||
</th>
|
||||
<th className="h-12 px-6 py-4 text-left text-sm font-semibold text-gray-700">
|
||||
상태
|
||||
</th>
|
||||
<th className="h-12 px-6 py-4 text-left text-sm font-semibold text-gray-700">
|
||||
작업
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((item) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="h-12 border-b border-gray-100/60 hover:bg-gradient-to-r hover:from-orange-50/80 hover:to-orange-100/60"
|
||||
>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{item.name}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="px-2 py-1 text-xs rounded bg-green-100 text-green-700">
|
||||
활성
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline">
|
||||
수정
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" className="text-red-500">
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 예제 2: 빈 상태 페이지
|
||||
|
||||
```tsx
|
||||
export default function EmptyStatePage() {
|
||||
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>
|
||||
<Button className="bg-orange-500 hover:bg-orange-600">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
새로 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 빈 상태 */}
|
||||
<Card className="text-center py-16 bg-white shadow-sm">
|
||||
<CardContent className="pt-6">
|
||||
<Database className="w-16 h-16 mx-auto mb-4 text-gray-300" />
|
||||
<p className="text-gray-500 mb-4">아직 등록된 데이터가 없습니다</p>
|
||||
<Button className="bg-orange-500 hover:bg-orange-600">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
첫 데이터 추가하기
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 안내 정보 */}
|
||||
<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">
|
||||
<Info 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>기능 설명 1</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-orange-500 mr-2">✓</span>
|
||||
<span>기능 설명 2</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 체크리스트
|
||||
|
||||
### 새 페이지 만들 때
|
||||
- [ ] `min-h-screen bg-gray-50` 래퍼 사용
|
||||
- [ ] 헤더 카드 (`bg-white rounded-lg shadow-sm border p-6`) 포함
|
||||
- [ ] 제목은 `text-3xl font-bold text-gray-900`
|
||||
- [ ] 설명은 `mt-2 text-gray-600`
|
||||
- [ ] 주요 버튼은 `bg-orange-500 hover:bg-orange-600`
|
||||
- [ ] 카드는 `shadow-sm` 클래스 포함
|
||||
- [ ] 간격은 `space-y-8` 사용
|
||||
|
||||
### 새 컴포넌트 만들 때
|
||||
- [ ] 일관된 패딩 사용 (`p-4`, `p-6`)
|
||||
- [ ] 호버 효과 추가
|
||||
- [ ] 전환 애니메이션 적용 (`transition-all duration-200`)
|
||||
- [ ] 적절한 아이콘 사용 (Lucide React)
|
||||
- [ ] 반응형 디자인 고려 (`md:`, `lg:`)
|
||||
|
||||
---
|
||||
|
||||
## 📚 참고 자료
|
||||
|
||||
### Tailwind CSS 공식 문서
|
||||
- https://tailwindcss.com/docs
|
||||
|
||||
### shadcn/ui 컴포넌트
|
||||
- https://ui.shadcn.com/
|
||||
|
||||
### Lucide 아이콘
|
||||
- https://lucide.dev/icons/
|
||||
|
||||
---
|
||||
|
||||
**이 가이드를 따라 개발하면 일관되고 아름다운 UI를 만들 수 있습니다!** 🎨✨
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.16.2",
|
||||
"@types/imap": "^0.8.42",
|
||||
"@types/mssql": "^9.1.8",
|
||||
"axios": "^1.11.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
|
|
@ -19,13 +20,15 @@
|
|||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"helmet": "^7.1.0",
|
||||
"imap": "^0.8.19",
|
||||
"joi": "^17.11.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mailparser": "^3.7.4",
|
||||
"mssql": "^11.0.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.15.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^6.9.7",
|
||||
"nodemailer": "^6.10.1",
|
||||
"oracledb": "^6.9.0",
|
||||
"pg": "^8.16.3",
|
||||
"redis": "^4.6.10",
|
||||
|
|
@ -43,7 +46,7 @@
|
|||
"@types/multer": "^1.4.13",
|
||||
"@types/node": "^20.10.5",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"@types/nodemailer": "^6.4.20",
|
||||
"@types/oracledb": "^6.9.1",
|
||||
"@types/pg": "^8.15.5",
|
||||
"@types/sanitize-html": "^2.9.5",
|
||||
|
|
@ -2398,6 +2401,19 @@
|
|||
"@redis/client": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@selderee/plugin-htmlparser2": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz",
|
||||
"integrity": "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domhandler": "^5.0.3",
|
||||
"selderee": "^0.11.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
}
|
||||
},
|
||||
"node_modules/@sideway/address": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz",
|
||||
|
|
@ -3277,6 +3293,15 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/imap": {
|
||||
"version": "0.8.42",
|
||||
"resolved": "https://registry.npmjs.org/@types/imap/-/imap-0.8.42.tgz",
|
||||
"integrity": "sha512-FusePG9Cp2GYN6OLow9xBCkjznFkAR7WCz0Fm+j1p/ER6C8V8P71DtjpSmwrZsS7zekCeqdTPHEk9N5OgPwcsg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/istanbul-lib-coverage": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||
|
|
@ -3412,9 +3437,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "6.4.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.19.tgz",
|
||||
"integrity": "sha512-Fi8DwmuAduTk1/1MpkR9EwS0SsDvYXx5RxivAVII1InDCIxmhj/iQm3W8S3EVb/0arnblr6PK0FK4wYa7bwdLg==",
|
||||
"version": "6.4.20",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.20.tgz",
|
||||
"integrity": "sha512-uj83z0GqwqMUE6RI4EKptPlav0FYE6vpIlqJAnxzu+/sSezRdbH69rSBCMsdW6DdsCAzoFQZ52c2UIlhRVQYDA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
|
@ -5040,7 +5065,6 @@
|
|||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
|
|
@ -5218,7 +5242,6 @@
|
|||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0",
|
||||
|
|
@ -5233,7 +5256,6 @@
|
|||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
|
@ -5246,7 +5268,6 @@
|
|||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"domelementtype": "^2.3.0"
|
||||
|
|
@ -5262,7 +5283,6 @@
|
|||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"dom-serializer": "^2.0.0",
|
||||
|
|
@ -5377,11 +5397,19 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/encoding-japanese": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
|
||||
"integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
|
|
@ -6463,6 +6491,15 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/he": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz",
|
||||
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"node_modules/helmet": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-7.2.0.tgz",
|
||||
|
|
@ -6479,11 +6516,26 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-to-text": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-9.0.5.tgz",
|
||||
"integrity": "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@selderee/plugin-htmlparser2": "^0.11.0",
|
||||
"deepmerge": "^4.3.1",
|
||||
"dom-serializer": "^2.0.0",
|
||||
"htmlparser2": "^8.0.2",
|
||||
"selderee": "^0.11.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||
{
|
||||
|
|
@ -6600,6 +6652,42 @@
|
|||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/imap": {
|
||||
"version": "0.8.19",
|
||||
"resolved": "https://registry.npmjs.org/imap/-/imap-0.8.19.tgz",
|
||||
"integrity": "sha512-z5DxEA1uRnZG73UcPA4ES5NSCGnPuuouUx43OPX7KZx1yzq3N8/vx2mtXEShT5inxB3pRgnfG1hijfu7XN2YMw==",
|
||||
"dependencies": {
|
||||
"readable-stream": "1.1.x",
|
||||
"utf7": ">=1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/imap/node_modules/isarray": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
|
||||
"integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/imap/node_modules/readable-stream": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
|
||||
"integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.1",
|
||||
"isarray": "0.0.1",
|
||||
"string_decoder": "~0.10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/imap/node_modules/string_decoder": {
|
||||
"version": "0.10.31",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
|
||||
"integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
|
|
@ -7678,6 +7766,15 @@
|
|||
"integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/leac": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/leac/-/leac-0.6.0.tgz",
|
||||
"integrity": "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
}
|
||||
},
|
||||
"node_modules/leven": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
||||
|
|
@ -7702,6 +7799,42 @@
|
|||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/libbase64": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz",
|
||||
"integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/libmime": {
|
||||
"version": "5.3.7",
|
||||
"resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.7.tgz",
|
||||
"integrity": "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encoding-japanese": "2.2.0",
|
||||
"iconv-lite": "0.6.3",
|
||||
"libbase64": "1.3.0",
|
||||
"libqp": "2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/libmime/node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/libqp": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz",
|
||||
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
|
|
@ -7709,6 +7842,15 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"uc.micro": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
|
|
@ -7829,6 +7971,56 @@
|
|||
"url": "https://github.com/sponsors/wellwelwel"
|
||||
}
|
||||
},
|
||||
"node_modules/mailparser": {
|
||||
"version": "3.7.4",
|
||||
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.7.4.tgz",
|
||||
"integrity": "sha512-Beh4yyR4jLq3CZZ32asajByrXnW8dLyKCAQD3WvtTiBnMtFWhxO+wa93F6sJNjDmfjxXs4NRNjw3XAGLqZR3Vg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encoding-japanese": "2.2.0",
|
||||
"he": "1.2.0",
|
||||
"html-to-text": "9.0.5",
|
||||
"iconv-lite": "0.6.3",
|
||||
"libmime": "5.3.7",
|
||||
"linkify-it": "5.0.0",
|
||||
"mailsplit": "5.4.5",
|
||||
"nodemailer": "7.0.4",
|
||||
"punycode.js": "2.3.1",
|
||||
"tlds": "1.259.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mailparser/node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mailparser/node_modules/nodemailer": {
|
||||
"version": "7.0.4",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.4.tgz",
|
||||
"integrity": "sha512-9O00Vh89/Ld2EcVCqJ/etd7u20UhME0f/NToPfArwPEe1Don1zy4mAIz6ariRr7mJ2RDxtaDzN0WJVdVXPtZaw==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mailsplit": {
|
||||
"version": "5.4.5",
|
||||
"resolved": "https://registry.npmjs.org/mailsplit/-/mailsplit-5.4.5.tgz",
|
||||
"integrity": "sha512-oMfhmvclR689IIaQmIcR5nODnZRRVwAKtqFT407TIvmhX2OLUBnshUTcxzQBt3+96sZVDud9NfSe1NxAkUNXEQ==",
|
||||
"license": "(MIT OR EUPL-1.1+)",
|
||||
"dependencies": {
|
||||
"libbase64": "1.3.0",
|
||||
"libmime": "5.3.7",
|
||||
"libqp": "2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||
|
|
@ -8511,6 +8703,19 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/parseley": {
|
||||
"version": "0.12.1",
|
||||
"resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz",
|
||||
"integrity": "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"leac": "^0.6.0",
|
||||
"peberminta": "^0.9.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
|
|
@ -8580,6 +8785,15 @@
|
|||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/peberminta": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz",
|
||||
"integrity": "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
}
|
||||
},
|
||||
"node_modules/perfect-debounce": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||
|
|
@ -8971,6 +9185,15 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode.js": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/pure-rand": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz",
|
||||
|
|
@ -9296,6 +9519,18 @@
|
|||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/selderee": {
|
||||
"version": "0.11.0",
|
||||
"resolved": "https://registry.npmjs.org/selderee/-/selderee-0.11.0.tgz",
|
||||
"integrity": "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"parseley": "^0.12.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/killymxi"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
||||
|
|
@ -9904,6 +10139,15 @@
|
|||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tlds": {
|
||||
"version": "1.259.0",
|
||||
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.259.0.tgz",
|
||||
"integrity": "sha512-AldGGlDP0PNgwppe2quAvuBl18UcjuNtOnDuUkqhd6ipPqrYYBt3aTxK1QTsBVknk97lS2JcafWMghjGWFtunw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tlds": "bin.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tmpl": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
|
||||
|
|
@ -10150,6 +10394,12 @@
|
|||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/uc.micro": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uglify-js": {
|
||||
"version": "3.19.3",
|
||||
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
|
||||
|
|
@ -10227,6 +10477,23 @@
|
|||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/utf7": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/utf7/-/utf7-1.0.2.tgz",
|
||||
"integrity": "sha512-qQrPtYLLLl12NF4DrM9CvfkxkYI97xOb5dsnGZHE3teFr0tWiEZ9UdgMPczv24vl708cYMpe6mGXGHrotIp3Bw==",
|
||||
"dependencies": {
|
||||
"semver": "~5.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/utf7/node_modules/semver": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz",
|
||||
"integrity": "sha512-mfmm3/H9+67MCVix1h+IXTpDwL6710LyHuk7+cWC9T1mE0qz4iHhh6r4hU2wrIT9iTsAAC2XQRvfblL028cpLw==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.16.2",
|
||||
"@types/imap": "^0.8.42",
|
||||
"@types/mssql": "^9.1.8",
|
||||
"axios": "^1.11.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
|
|
@ -37,13 +38,15 @@
|
|||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"helmet": "^7.1.0",
|
||||
"imap": "^0.8.19",
|
||||
"joi": "^17.11.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mailparser": "^3.7.4",
|
||||
"mssql": "^11.0.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.15.0",
|
||||
"node-cron": "^4.2.1",
|
||||
"nodemailer": "^6.9.7",
|
||||
"nodemailer": "^6.10.1",
|
||||
"oracledb": "^6.9.0",
|
||||
"pg": "^8.16.3",
|
||||
"redis": "^4.6.10",
|
||||
|
|
@ -61,7 +64,7 @@
|
|||
"@types/multer": "^1.4.13",
|
||||
"@types/node": "^20.10.5",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"@types/nodemailer": "^6.4.20",
|
||||
"@types/oracledb": "^6.9.1",
|
||||
"@types/pg": "^8.15.5",
|
||||
"@types/sanitize-html": "^2.9.5",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@ import screenStandardRoutes from "./routes/screenStandardRoutes";
|
|||
import templateStandardRoutes from "./routes/templateStandardRoutes";
|
||||
import componentStandardRoutes from "./routes/componentStandardRoutes";
|
||||
import layoutRoutes from "./routes/layoutRoutes";
|
||||
import mailQueryRoutes from "./routes/mailQueryRoutes";
|
||||
import mailTemplateFileRoutes from "./routes/mailTemplateFileRoutes";
|
||||
import mailAccountFileRoutes from "./routes/mailAccountFileRoutes";
|
||||
import mailSendSimpleRoutes from "./routes/mailSendSimpleRoutes";
|
||||
import dataRoutes from "./routes/dataRoutes";
|
||||
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
|
||||
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
|
||||
|
|
@ -156,6 +160,10 @@ app.use("/api/admin/button-actions", buttonActionStandardRoutes);
|
|||
app.use("/api/admin/template-standards", templateStandardRoutes);
|
||||
app.use("/api/admin/component-standards", componentStandardRoutes);
|
||||
app.use("/api/layouts", layoutRoutes);
|
||||
app.use("/api/mail/accounts", mailAccountFileRoutes); // 파일 기반 계정
|
||||
app.use("/api/mail/templates-file", mailTemplateFileRoutes); // 파일 기반 템플릿
|
||||
app.use("/api/mail/query", mailQueryRoutes); // SQL 쿼리 빌더
|
||||
app.use("/api/mail/send", mailSendSimpleRoutes); // 메일 발송
|
||||
app.use("/api/screen", screenStandardRoutes);
|
||||
app.use("/api/data", dataRoutes);
|
||||
app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,201 @@
|
|||
import { Request, Response } from 'express';
|
||||
import { mailAccountFileService } from '../services/mailAccountFileService';
|
||||
|
||||
export class MailAccountFileController {
|
||||
async getAllAccounts(req: Request, res: Response) {
|
||||
try {
|
||||
const accounts = await mailAccountFileService.getAllAccounts();
|
||||
|
||||
// 비밀번호는 반환하지 않음
|
||||
const safeAccounts = accounts.map(({ smtpPassword, ...account }) => account);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: safeAccounts,
|
||||
total: safeAccounts.length,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '계정 조회 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getAccountById(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const account = await mailAccountFileService.getAccountById(id);
|
||||
|
||||
if (!account) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '계정을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// 비밀번호는 마스킹 처리
|
||||
const { smtpPassword, ...safeAccount } = account;
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
...safeAccount,
|
||||
smtpPassword: '••••••••', // 마스킹
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '계정 조회 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async createAccount(req: Request, res: Response) {
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
email,
|
||||
smtpHost,
|
||||
smtpPort,
|
||||
smtpSecure,
|
||||
smtpUsername,
|
||||
smtpPassword,
|
||||
dailyLimit,
|
||||
status,
|
||||
} = req.body;
|
||||
|
||||
if (!name || !email || !smtpHost || !smtpPort || !smtpUsername || !smtpPassword) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '필수 필드가 누락되었습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// 이메일 중복 확인
|
||||
const existingAccount = await mailAccountFileService.getAccountByEmail(email);
|
||||
if (existingAccount) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '이미 등록된 이메일입니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const account = await mailAccountFileService.createAccount({
|
||||
name,
|
||||
email,
|
||||
smtpHost,
|
||||
smtpPort,
|
||||
smtpSecure: smtpSecure || false,
|
||||
smtpUsername,
|
||||
smtpPassword,
|
||||
dailyLimit: dailyLimit || 1000,
|
||||
status: status || 'active',
|
||||
});
|
||||
|
||||
// 비밀번호 제외하고 반환
|
||||
const { smtpPassword: _, ...safeAccount } = account;
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: safeAccount,
|
||||
message: '메일 계정이 생성되었습니다.',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '계정 생성 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async updateAccount(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
const account = await mailAccountFileService.updateAccount(id, updates);
|
||||
|
||||
if (!account) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '계정을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// 비밀번호 제외하고 반환
|
||||
const { smtpPassword: _, ...safeAccount } = account;
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: safeAccount,
|
||||
message: '계정이 수정되었습니다.',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '계정 수정 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAccount(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const success = await mailAccountFileService.deleteAccount(id);
|
||||
|
||||
if (!success) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '계정을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: '계정이 삭제되었습니다.',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '계정 삭제 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async testConnection(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// TODO: 실제 SMTP 연결 테스트 구현
|
||||
// const account = await mailAccountFileService.getAccountById(id);
|
||||
// nodemailer로 연결 테스트
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: '연결 테스트 성공 (미구현)',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '연결 테스트 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const mailAccountFileController = new MailAccountFileController();
|
||||
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
import { Request, Response } from 'express';
|
||||
import { mailQueryService, QueryParameter } from '../services/mailQueryService';
|
||||
|
||||
export class MailQueryController {
|
||||
// 쿼리에서 파라미터 감지
|
||||
async detectParameters(req: Request, res: Response) {
|
||||
try {
|
||||
const { sql } = req.body;
|
||||
|
||||
if (!sql) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'SQL 쿼리가 필요합니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const parameters = mailQueryService.detectParameters(sql);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: parameters,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '파라미터 감지 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 쿼리 테스트 실행
|
||||
async testQuery(req: Request, res: Response) {
|
||||
try {
|
||||
const { sql, parameters } = req.body;
|
||||
|
||||
if (!sql) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'SQL 쿼리가 필요합니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await mailQueryService.testQuery(
|
||||
sql,
|
||||
parameters || []
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: result.success,
|
||||
data: result,
|
||||
message: result.success
|
||||
? '쿼리 테스트 성공'
|
||||
: '쿼리 테스트 실패',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '쿼리 테스트 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 쿼리 실행
|
||||
async executeQuery(req: Request, res: Response) {
|
||||
try {
|
||||
const { sql, parameters } = req.body;
|
||||
|
||||
if (!sql) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'SQL 쿼리가 필요합니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await mailQueryService.executeQuery(
|
||||
sql,
|
||||
parameters || []
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: result.success,
|
||||
data: result,
|
||||
message: result.success
|
||||
? '쿼리 실행 성공'
|
||||
: '쿼리 실행 실패',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '쿼리 실행 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 템플릿 변수 추출
|
||||
async extractVariables(req: Request, res: Response) {
|
||||
try {
|
||||
const { template } = req.body;
|
||||
|
||||
if (!template) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '템플릿이 필요합니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const variables = mailQueryService.extractVariables(template);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: variables,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '변수 추출 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 변수 매핑 검증
|
||||
async validateMapping(req: Request, res: Response) {
|
||||
try {
|
||||
const { templateVariables, queryFields } = req.body;
|
||||
|
||||
if (!templateVariables || !queryFields) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '템플릿 변수와 쿼리 필드가 필요합니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const validation = mailQueryService.validateVariableMapping(
|
||||
templateVariables,
|
||||
queryFields
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: validation,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '변수 매핑 검증 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 대량 메일 데이터 처리
|
||||
async processMailData(req: Request, res: Response) {
|
||||
try {
|
||||
const { templateHtml, templateSubject, sql, parameters } = req.body;
|
||||
|
||||
if (!templateHtml || !templateSubject || !sql) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '템플릿, 제목, SQL 쿼리가 모두 필요합니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// 쿼리 실행
|
||||
const queryResult = await mailQueryService.executeQuery(
|
||||
sql,
|
||||
parameters || []
|
||||
);
|
||||
|
||||
if (!queryResult.success) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '쿼리 실행 실패',
|
||||
error: queryResult.error,
|
||||
});
|
||||
}
|
||||
|
||||
// 메일 데이터 처리
|
||||
const mailData = await mailQueryService.processMailData(
|
||||
templateHtml,
|
||||
templateSubject,
|
||||
queryResult
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
totalRecipients: mailData.length,
|
||||
mailData: mailData.slice(0, 5), // 미리보기용 5개만
|
||||
},
|
||||
message: `${mailData.length}명의 수신자에게 발송 준비 완료`,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '메일 데이터 처리 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const mailQueryController = new MailQueryController();
|
||||
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import { Request, Response } from 'express';
|
||||
import { mailSendSimpleService } from '../services/mailSendSimpleService';
|
||||
|
||||
export class MailSendSimpleController {
|
||||
/**
|
||||
* 메일 발송 (단건 또는 소규모)
|
||||
*/
|
||||
async sendMail(req: Request, res: Response) {
|
||||
try {
|
||||
const { accountId, templateId, to, subject, variables, customHtml } = req.body;
|
||||
|
||||
// 필수 파라미터 검증
|
||||
if (!accountId || !to || !Array.isArray(to) || to.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '계정 ID와 수신자 이메일이 필요합니다.',
|
||||
});
|
||||
}
|
||||
|
||||
if (!subject) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '메일 제목이 필요합니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// 템플릿 또는 커스텀 HTML 중 하나는 있어야 함
|
||||
if (!templateId && !customHtml) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '템플릿 또는 메일 내용이 필요합니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// 메일 발송
|
||||
const result = await mailSendSimpleService.sendMail({
|
||||
accountId,
|
||||
templateId,
|
||||
to,
|
||||
subject,
|
||||
variables,
|
||||
customHtml,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: '메일이 발송되었습니다.',
|
||||
});
|
||||
} else {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: result.error || '메일 발송 실패',
|
||||
});
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '메일 발송 중 오류가 발생했습니다.',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP 연결 테스트
|
||||
*/
|
||||
async testConnection(req: Request, res: Response) {
|
||||
try {
|
||||
const { accountId } = req.body;
|
||||
|
||||
if (!accountId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '계정 ID가 필요합니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const result = await mailSendSimpleService.testConnection(accountId);
|
||||
|
||||
return res.json(result);
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '연결 테스트 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const mailSendSimpleController = new MailSendSimpleController();
|
||||
|
||||
|
|
@ -0,0 +1,258 @@
|
|||
import { Request, Response } from 'express';
|
||||
import { mailTemplateFileService } from '../services/mailTemplateFileService';
|
||||
import { mailQueryService } from '../services/mailQueryService';
|
||||
|
||||
export class MailTemplateFileController {
|
||||
// 모든 템플릿 조회
|
||||
async getAllTemplates(req: Request, res: Response) {
|
||||
try {
|
||||
const { category, search } = req.query;
|
||||
|
||||
let templates;
|
||||
if (search) {
|
||||
templates = await mailTemplateFileService.searchTemplates(search as string);
|
||||
} else if (category) {
|
||||
templates = await mailTemplateFileService.getTemplatesByCategory(category as string);
|
||||
} else {
|
||||
templates = await mailTemplateFileService.getAllTemplates();
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: templates,
|
||||
total: templates.length,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '템플릿 조회 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 특정 템플릿 조회
|
||||
async getTemplateById(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const template = await mailTemplateFileService.getTemplateById(id);
|
||||
|
||||
if (!template) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '템플릿을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: template,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '템플릿 조회 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 템플릿 생성
|
||||
async createTemplate(req: Request, res: Response) {
|
||||
try {
|
||||
const { name, subject, components, queryConfig, recipientConfig, category } = req.body;
|
||||
|
||||
if (!name || !subject || !Array.isArray(components)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '템플릿 이름, 제목, 컴포넌트가 필요합니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const template = await mailTemplateFileService.createTemplate({
|
||||
name,
|
||||
subject,
|
||||
components,
|
||||
queryConfig,
|
||||
recipientConfig,
|
||||
category,
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: template,
|
||||
message: '템플릿이 생성되었습니다.',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '템플릿 생성 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 템플릿 수정
|
||||
async updateTemplate(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
const template = await mailTemplateFileService.updateTemplate(id, updates);
|
||||
|
||||
if (!template) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '템플릿을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: template,
|
||||
message: '템플릿이 수정되었습니다.',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '템플릿 수정 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 템플릿 삭제
|
||||
async deleteTemplate(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const success = await mailTemplateFileService.deleteTemplate(id);
|
||||
|
||||
if (!success) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '템플릿을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: '템플릿이 삭제되었습니다.',
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '템플릿 삭제 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 템플릿 미리보기 (HTML 렌더링)
|
||||
async previewTemplate(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { sampleData } = req.body;
|
||||
|
||||
const template = await mailTemplateFileService.getTemplateById(id);
|
||||
if (!template) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '템플릿을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// HTML 렌더링
|
||||
let html = mailTemplateFileService.renderTemplateToHtml(template.components);
|
||||
let subject = template.subject;
|
||||
|
||||
// 샘플 데이터가 있으면 변수 치환
|
||||
if (sampleData) {
|
||||
html = mailQueryService.replaceVariables(html, sampleData);
|
||||
subject = mailQueryService.replaceVariables(subject, sampleData);
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
subject,
|
||||
html,
|
||||
sampleData,
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '미리보기 생성 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 템플릿 + 쿼리 통합 미리보기
|
||||
async previewWithQuery(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { queryId, parameters } = req.body;
|
||||
|
||||
const template = await mailTemplateFileService.getTemplateById(id);
|
||||
if (!template) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '템플릿을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// 쿼리 실행
|
||||
const query = template.queryConfig?.queries.find(q => q.id === queryId);
|
||||
if (!query) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '쿼리를 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const queryResult = await mailQueryService.executeQuery(query.sql, parameters || []);
|
||||
if (!queryResult.success || !queryResult.data || queryResult.data.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '쿼리 결과가 없습니다.',
|
||||
error: queryResult.error,
|
||||
});
|
||||
}
|
||||
|
||||
// 첫 번째 행으로 미리보기
|
||||
const sampleData = queryResult.data[0];
|
||||
let html = mailTemplateFileService.renderTemplateToHtml(template.components);
|
||||
let subject = template.subject;
|
||||
|
||||
html = mailQueryService.replaceVariables(html, sampleData);
|
||||
subject = mailQueryService.replaceVariables(subject, sampleData);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
subject,
|
||||
html,
|
||||
sampleData,
|
||||
totalRecipients: queryResult.data.length,
|
||||
},
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '쿼리 미리보기 실패',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const mailTemplateFileController = new MailTemplateFileController();
|
||||
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { Router } from 'express';
|
||||
import { mailAccountFileController } from '../controllers/mailAccountFileController';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/', (req, res) => mailAccountFileController.getAllAccounts(req, res));
|
||||
router.get('/:id', (req, res) => mailAccountFileController.getAccountById(req, res));
|
||||
router.post('/', (req, res) => mailAccountFileController.createAccount(req, res));
|
||||
router.put('/:id', (req, res) => mailAccountFileController.updateAccount(req, res));
|
||||
router.delete('/:id', (req, res) => mailAccountFileController.deleteAccount(req, res));
|
||||
router.post('/:id/test-connection', (req, res) => mailAccountFileController.testConnection(req, res));
|
||||
|
||||
export default router;
|
||||
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { Router } from 'express';
|
||||
import { mailQueryController } from '../controllers/mailQueryController';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 쿼리 파라미터 자동 감지
|
||||
router.post('/detect-parameters', (req, res) =>
|
||||
mailQueryController.detectParameters(req, res)
|
||||
);
|
||||
|
||||
// 쿼리 테스트 실행
|
||||
router.post('/test', (req, res) =>
|
||||
mailQueryController.testQuery(req, res)
|
||||
);
|
||||
|
||||
// 쿼리 실행
|
||||
router.post('/execute', (req, res) =>
|
||||
mailQueryController.executeQuery(req, res)
|
||||
);
|
||||
|
||||
// 템플릿 변수 추출
|
||||
router.post('/extract-variables', (req, res) =>
|
||||
mailQueryController.extractVariables(req, res)
|
||||
);
|
||||
|
||||
// 변수 매핑 검증
|
||||
router.post('/validate-mapping', (req, res) =>
|
||||
mailQueryController.validateMapping(req, res)
|
||||
);
|
||||
|
||||
// 대량 메일 데이터 처리
|
||||
router.post('/process-mail-data', (req, res) =>
|
||||
mailQueryController.processMailData(req, res)
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { Router } from 'express';
|
||||
import { mailSendSimpleController } from '../controllers/mailSendSimpleController';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// POST /api/mail/send/simple - 메일 발송
|
||||
router.post('/simple', (req, res) => mailSendSimpleController.sendMail(req, res));
|
||||
|
||||
// POST /api/mail/send/test-connection - SMTP 연결 테스트
|
||||
router.post('/test-connection', (req, res) => mailSendSimpleController.testConnection(req, res));
|
||||
|
||||
export default router;
|
||||
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { Router } from 'express';
|
||||
import { mailTemplateFileController } from '../controllers/mailTemplateFileController';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 템플릿 CRUD
|
||||
router.get('/', (req, res) => mailTemplateFileController.getAllTemplates(req, res));
|
||||
router.get('/:id', (req, res) => mailTemplateFileController.getTemplateById(req, res));
|
||||
router.post('/', (req, res) => mailTemplateFileController.createTemplate(req, res));
|
||||
router.put('/:id', (req, res) => mailTemplateFileController.updateTemplate(req, res));
|
||||
router.delete('/:id', (req, res) => mailTemplateFileController.deleteTemplate(req, res));
|
||||
|
||||
// 미리보기
|
||||
router.post('/:id/preview', (req, res) => mailTemplateFileController.previewTemplate(req, res));
|
||||
router.post('/:id/preview-with-query', (req, res) => mailTemplateFileController.previewWithQuery(req, res));
|
||||
|
||||
export default router;
|
||||
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import crypto from 'crypto';
|
||||
|
||||
class EncryptionService {
|
||||
private readonly algorithm = 'aes-256-gcm';
|
||||
private readonly key: Buffer;
|
||||
|
||||
constructor() {
|
||||
const keyString = process.env.ENCRYPTION_KEY;
|
||||
if (!keyString) {
|
||||
throw new Error('ENCRYPTION_KEY environment variable is required');
|
||||
}
|
||||
this.key = crypto.scryptSync(keyString, 'salt', 32);
|
||||
}
|
||||
|
||||
encrypt(text: string): string {
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipher(this.algorithm, this.key);
|
||||
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
return iv.toString('hex') + ':' + authTag.toString('hex') + ':' + encrypted;
|
||||
}
|
||||
|
||||
decrypt(encryptedText: string): string {
|
||||
const [ivHex, authTagHex, encrypted] = encryptedText.split(':');
|
||||
|
||||
if (!ivHex || !authTagHex || !encrypted) {
|
||||
throw new Error('Invalid encrypted text format');
|
||||
}
|
||||
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const authTag = Buffer.from(authTagHex, 'hex');
|
||||
|
||||
const decipher = crypto.createDecipher(this.algorithm, this.key);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
// 비밀번호 해싱 (bcrypt 대신 사용)
|
||||
hashPassword(password: string): string {
|
||||
const salt = crypto.randomBytes(16).toString('hex');
|
||||
const hash = crypto.pbkdf2Sync(password, salt, 10000, 64, 'sha512').toString('hex');
|
||||
return salt + ':' + hash;
|
||||
}
|
||||
|
||||
verifyPassword(password: string, hashedPassword: string): boolean {
|
||||
const [salt, hash] = hashedPassword.split(':');
|
||||
const verifyHash = crypto.pbkdf2Sync(password, salt, 10000, 64, 'sha512').toString('hex');
|
||||
return hash === verifyHash;
|
||||
}
|
||||
|
||||
// 랜덤 토큰 생성
|
||||
generateToken(length: number = 32): string {
|
||||
return crypto.randomBytes(length).toString('hex');
|
||||
}
|
||||
|
||||
// HMAC 서명 생성
|
||||
createHmac(data: string, secret: string): string {
|
||||
return crypto.createHmac('sha256', secret).update(data).digest('hex');
|
||||
}
|
||||
|
||||
// HMAC 검증
|
||||
verifyHmac(data: string, signature: string, secret: string): boolean {
|
||||
const expectedSignature = this.createHmac(data, secret);
|
||||
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature));
|
||||
}
|
||||
}
|
||||
|
||||
export const encryptionService = new EncryptionService();
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { encryptionService } from './encryptionService';
|
||||
|
||||
export interface MailAccount {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
smtpHost: string;
|
||||
smtpPort: number;
|
||||
smtpSecure: boolean;
|
||||
smtpUsername: string;
|
||||
smtpPassword: string; // 암호화된 비밀번호
|
||||
dailyLimit: number;
|
||||
status: 'active' | 'inactive' | 'suspended';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
class MailAccountFileService {
|
||||
private accountsDir: string;
|
||||
|
||||
constructor() {
|
||||
this.accountsDir = path.join(process.cwd(), 'uploads', 'mail-accounts');
|
||||
this.ensureDirectoryExists();
|
||||
}
|
||||
|
||||
private async ensureDirectoryExists() {
|
||||
try {
|
||||
await fs.access(this.accountsDir);
|
||||
} catch {
|
||||
await fs.mkdir(this.accountsDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
private getAccountPath(id: string): string {
|
||||
return path.join(this.accountsDir, `${id}.json`);
|
||||
}
|
||||
|
||||
async getAllAccounts(): Promise<MailAccount[]> {
|
||||
await this.ensureDirectoryExists();
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(this.accountsDir);
|
||||
const jsonFiles = files.filter(f => f.endsWith('.json'));
|
||||
|
||||
const accounts = await Promise.all(
|
||||
jsonFiles.map(async (file) => {
|
||||
const content = await fs.readFile(
|
||||
path.join(this.accountsDir, file),
|
||||
'utf-8'
|
||||
);
|
||||
return JSON.parse(content) as MailAccount;
|
||||
})
|
||||
);
|
||||
|
||||
return accounts.sort((a, b) =>
|
||||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getAccountById(id: string): Promise<MailAccount | null> {
|
||||
try {
|
||||
const content = await fs.readFile(this.getAccountPath(id), 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async createAccount(
|
||||
data: Omit<MailAccount, 'id' | 'createdAt' | 'updatedAt'>
|
||||
): Promise<MailAccount> {
|
||||
const id = `account-${Date.now()}`;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// 비밀번호 암호화
|
||||
const encryptedPassword = encryptionService.encrypt(data.smtpPassword);
|
||||
|
||||
const account: MailAccount = {
|
||||
...data,
|
||||
id,
|
||||
smtpPassword: encryptedPassword,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
this.getAccountPath(id),
|
||||
JSON.stringify(account, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
async updateAccount(
|
||||
id: string,
|
||||
data: Partial<Omit<MailAccount, 'id' | 'createdAt'>>
|
||||
): Promise<MailAccount | null> {
|
||||
const existing = await this.getAccountById(id);
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 비밀번호가 변경되면 암호화
|
||||
if (data.smtpPassword && data.smtpPassword !== existing.smtpPassword) {
|
||||
data.smtpPassword = encryptionService.encrypt(data.smtpPassword);
|
||||
}
|
||||
|
||||
const updated: MailAccount = {
|
||||
...existing,
|
||||
...data,
|
||||
id: existing.id,
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
this.getAccountPath(id),
|
||||
JSON.stringify(updated, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deleteAccount(id: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.unlink(this.getAccountPath(id));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async getAccountByEmail(email: string): Promise<MailAccount | null> {
|
||||
const accounts = await this.getAllAccounts();
|
||||
return accounts.find(a => a.email === email) || null;
|
||||
}
|
||||
|
||||
async getActiveAccounts(): Promise<MailAccount[]> {
|
||||
const accounts = await this.getAllAccounts();
|
||||
return accounts.filter(a => a.status === 'active');
|
||||
}
|
||||
|
||||
/**
|
||||
* 비밀번호 복호화
|
||||
*/
|
||||
decryptPassword(encryptedPassword: string): string {
|
||||
return encryptionService.decrypt(encryptedPassword);
|
||||
}
|
||||
}
|
||||
|
||||
export const mailAccountFileService = new MailAccountFileService();
|
||||
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
import { query } from '../database/db';
|
||||
|
||||
export interface QueryParameter {
|
||||
name: string; // $1, $2, etc.
|
||||
type: 'text' | 'number' | 'date';
|
||||
value?: any;
|
||||
}
|
||||
|
||||
export interface QueryResult {
|
||||
success: boolean;
|
||||
data?: any[];
|
||||
fields?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface QueryConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
sql: string;
|
||||
parameters: QueryParameter[];
|
||||
}
|
||||
|
||||
export interface MailQueryConfig extends QueryConfig {}
|
||||
|
||||
export interface MailComponent {
|
||||
id: string;
|
||||
type: "text" | "button" | "image" | "spacer" | "table";
|
||||
content?: string;
|
||||
text?: string;
|
||||
url?: string;
|
||||
src?: string;
|
||||
height?: number;
|
||||
styles?: Record<string, string>;
|
||||
}
|
||||
|
||||
class MailQueryService {
|
||||
/**
|
||||
* 쿼리에서 파라미터 자동 감지 ($1, $2, ...)
|
||||
*/
|
||||
detectParameters(sql: string): QueryParameter[] {
|
||||
const regex = /\$(\d+)/g;
|
||||
const matches = Array.from(sql.matchAll(regex));
|
||||
const uniqueParams = new Set(matches.map(m => m[1]));
|
||||
|
||||
return Array.from(uniqueParams)
|
||||
.sort((a, b) => parseInt(a) - parseInt(b))
|
||||
.map(num => ({
|
||||
name: `$${num}`,
|
||||
type: 'text',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿼리 실행 및 결과 반환
|
||||
*/
|
||||
async executeQuery(
|
||||
sql: string,
|
||||
parameters: QueryParameter[]
|
||||
): Promise<QueryResult> {
|
||||
try {
|
||||
// 파라미터 값을 배열로 변환
|
||||
const paramValues = parameters
|
||||
.sort((a, b) => {
|
||||
const aNum = parseInt(a.name.substring(1));
|
||||
const bNum = parseInt(b.name.substring(1));
|
||||
return aNum - bNum;
|
||||
})
|
||||
.map(p => {
|
||||
if (p.type === 'number') {
|
||||
return parseFloat(p.value);
|
||||
} else if (p.type === 'date') {
|
||||
return new Date(p.value);
|
||||
}
|
||||
return p.value;
|
||||
});
|
||||
|
||||
// 쿼리 실행
|
||||
const rows = await query(sql, paramValues);
|
||||
|
||||
// 결과에서 필드명 추출
|
||||
const fields = rows.length > 0 ? Object.keys(rows[0]) : [];
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: rows,
|
||||
fields,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return {
|
||||
success: false,
|
||||
error: err.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿼리 결과에서 이메일 필드 자동 감지
|
||||
*/
|
||||
detectEmailFields(fields: string[]): string[] {
|
||||
const emailPattern = /email|mail|e_mail/i;
|
||||
return fields.filter(field => emailPattern.test(field));
|
||||
}
|
||||
|
||||
/**
|
||||
* 동적 변수 치환
|
||||
* 예: "{customer_name}" → "홍길동"
|
||||
*/
|
||||
replaceVariables(template: string, data: Record<string, any>): string {
|
||||
let result = template;
|
||||
|
||||
Object.keys(data).forEach(key => {
|
||||
const regex = new RegExp(`\\{${key}\\}`, 'g');
|
||||
result = result.replace(regex, String(data[key] || ''));
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿에서 사용된 변수 추출
|
||||
* 예: "Hello {name}!" → ["name"]
|
||||
*/
|
||||
extractVariables(template: string): string[] {
|
||||
const regex = /\{(\w+)\}/g;
|
||||
const matches = Array.from(template.matchAll(regex));
|
||||
return matches.map(m => m[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿼리 결과와 템플릿 변수 매칭 검증
|
||||
*/
|
||||
validateVariableMapping(
|
||||
templateVariables: string[],
|
||||
queryFields: string[]
|
||||
): {
|
||||
valid: boolean;
|
||||
missing: string[];
|
||||
available: string[];
|
||||
} {
|
||||
const missing = templateVariables.filter(v => !queryFields.includes(v));
|
||||
|
||||
return {
|
||||
valid: missing.length === 0,
|
||||
missing,
|
||||
available: queryFields,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 대량 발송용: 각 행마다 템플릿 치환
|
||||
*/
|
||||
async processMailData(
|
||||
templateHtml: string,
|
||||
templateSubject: string,
|
||||
queryResult: QueryResult
|
||||
): Promise<Array<{
|
||||
email: string;
|
||||
subject: string;
|
||||
content: string;
|
||||
variables: Record<string, any>;
|
||||
}>> {
|
||||
if (!queryResult.success || !queryResult.data) {
|
||||
throw new Error('Invalid query result');
|
||||
}
|
||||
|
||||
const emailFields = this.detectEmailFields(queryResult.fields || []);
|
||||
if (emailFields.length === 0) {
|
||||
throw new Error('No email field found in query result');
|
||||
}
|
||||
|
||||
const emailField = emailFields[0]; // 첫 번째 이메일 필드 사용
|
||||
|
||||
return queryResult.data.map(row => {
|
||||
const email = row[emailField];
|
||||
const subject = this.replaceVariables(templateSubject, row);
|
||||
const content = this.replaceVariables(templateHtml, row);
|
||||
|
||||
return {
|
||||
email,
|
||||
subject,
|
||||
content,
|
||||
variables: row,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿼리 테스트 (파라미터 값 미리보기)
|
||||
*/
|
||||
async testQuery(
|
||||
sql: string,
|
||||
sampleParams: QueryParameter[]
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
preview: any[];
|
||||
totalRows: number;
|
||||
fields: string[];
|
||||
emailFields: string[];
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const result = await this.executeQuery(sql, sampleParams);
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
preview: [],
|
||||
totalRows: 0,
|
||||
fields: [],
|
||||
emailFields: [],
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
const fields = result.fields || [];
|
||||
const emailFields = this.detectEmailFields(fields);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
preview: (result.data || []).slice(0, 5), // 최대 5개만 미리보기
|
||||
totalRows: (result.data || []).length,
|
||||
fields,
|
||||
emailFields,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
return {
|
||||
success: false,
|
||||
preview: [],
|
||||
totalRows: 0,
|
||||
fields: [],
|
||||
emailFields: [],
|
||||
error: err.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const mailQueryService = new MailQueryService();
|
||||
|
||||
|
|
@ -0,0 +1,213 @@
|
|||
/**
|
||||
* 간단한 메일 발송 서비스 (쿼리 제외)
|
||||
* Nodemailer를 사용한 직접 발송
|
||||
*/
|
||||
|
||||
import nodemailer from 'nodemailer';
|
||||
import { mailAccountFileService } from './mailAccountFileService';
|
||||
import { mailTemplateFileService } from './mailTemplateFileService';
|
||||
|
||||
export interface SendMailRequest {
|
||||
accountId: string;
|
||||
templateId?: string;
|
||||
to: string[]; // 수신자 이메일 배열
|
||||
subject: string;
|
||||
variables?: Record<string, string>; // 템플릿 변수 치환
|
||||
customHtml?: string; // 템플릿 없이 직접 HTML 작성 시
|
||||
}
|
||||
|
||||
export interface SendMailResult {
|
||||
success: boolean;
|
||||
messageId?: string;
|
||||
accepted?: string[];
|
||||
rejected?: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
class MailSendSimpleService {
|
||||
/**
|
||||
* 단일 메일 발송 또는 소규모 발송
|
||||
*/
|
||||
async sendMail(request: SendMailRequest): Promise<SendMailResult> {
|
||||
try {
|
||||
// 1. 계정 조회
|
||||
const account = await mailAccountFileService.getAccountById(request.accountId);
|
||||
if (!account) {
|
||||
throw new Error('메일 계정을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 2. 계정 활성화 확인
|
||||
if (account.status !== 'active') {
|
||||
throw new Error('비활성 상태의 계정입니다.');
|
||||
}
|
||||
|
||||
// 3. HTML 생성 (템플릿 또는 커스텀)
|
||||
let htmlContent = request.customHtml || '';
|
||||
|
||||
if (!htmlContent && request.templateId) {
|
||||
const template = await mailTemplateFileService.getTemplateById(request.templateId);
|
||||
if (!template) {
|
||||
throw new Error('템플릿을 찾을 수 없습니다.');
|
||||
}
|
||||
htmlContent = this.renderTemplate(template, request.variables);
|
||||
}
|
||||
|
||||
if (!htmlContent) {
|
||||
throw new Error('메일 내용이 없습니다.');
|
||||
}
|
||||
|
||||
// 4. SMTP 연결 생성
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: account.smtpHost,
|
||||
port: account.smtpPort,
|
||||
secure: account.smtpSecure, // SSL/TLS
|
||||
auth: {
|
||||
user: account.smtpUsername,
|
||||
pass: account.smtpPassword,
|
||||
},
|
||||
});
|
||||
|
||||
// 5. 메일 발송
|
||||
const info = await transporter.sendMail({
|
||||
from: `"${account.name}" <${account.email}>`,
|
||||
to: request.to.join(', '),
|
||||
subject: this.replaceVariables(request.subject, request.variables),
|
||||
html: htmlContent,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: info.messageId,
|
||||
accepted: info.accepted as string[],
|
||||
rejected: info.rejected as string[],
|
||||
};
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
return {
|
||||
success: false,
|
||||
error: err.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 렌더링 (간단 버전)
|
||||
*/
|
||||
private renderTemplate(
|
||||
template: any,
|
||||
variables?: Record<string, string>
|
||||
): string {
|
||||
let html = '<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">';
|
||||
|
||||
template.components.forEach((component: any) => {
|
||||
switch (component.type) {
|
||||
case 'text':
|
||||
let content = component.content || '';
|
||||
if (variables) {
|
||||
content = this.replaceVariables(content, variables);
|
||||
}
|
||||
html += `<div style="${this.styleObjectToString(component.styles)}">${content}</div>`;
|
||||
break;
|
||||
|
||||
case 'button':
|
||||
let buttonText = component.text || 'Button';
|
||||
if (variables) {
|
||||
buttonText = this.replaceVariables(buttonText, variables);
|
||||
}
|
||||
html += `
|
||||
<a href="${component.url || '#'}" style="
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
background-color: ${component.styles?.backgroundColor || '#007bff'};
|
||||
color: ${component.styles?.color || 'white'};
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
${this.styleObjectToString(component.styles)}
|
||||
">${buttonText}</a>
|
||||
`;
|
||||
break;
|
||||
|
||||
case 'image':
|
||||
html += `<img src="${component.src || ''}" style="max-width: 100%; ${this.styleObjectToString(component.styles)}" />`;
|
||||
break;
|
||||
|
||||
case 'spacer':
|
||||
html += `<div style="height: ${component.height || 20}px;"></div>`;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* 변수 치환
|
||||
*/
|
||||
private replaceVariables(text: string, variables?: Record<string, string>): string {
|
||||
if (!variables) return text;
|
||||
|
||||
let result = text;
|
||||
Object.entries(variables).forEach(([key, value]) => {
|
||||
const regex = new RegExp(`\\{${key}\\}`, 'g');
|
||||
result = result.replace(regex, value);
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 스타일 객체를 CSS 문자열로 변환
|
||||
*/
|
||||
private styleObjectToString(styles?: Record<string, string>): string {
|
||||
if (!styles) return '';
|
||||
return Object.entries(styles)
|
||||
.map(([key, value]) => `${this.camelToKebab(key)}: ${value}`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
/**
|
||||
* camelCase를 kebab-case로 변환
|
||||
*/
|
||||
private camelToKebab(str: string): string {
|
||||
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP 연결 테스트
|
||||
*/
|
||||
async testConnection(accountId: string): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const account = await mailAccountFileService.getAccountById(accountId);
|
||||
if (!account) {
|
||||
throw new Error('계정을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: account.smtpHost,
|
||||
port: account.smtpPort,
|
||||
secure: account.smtpSecure,
|
||||
auth: {
|
||||
user: account.smtpUsername,
|
||||
pass: account.smtpPassword,
|
||||
},
|
||||
});
|
||||
|
||||
await transporter.verify();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'SMTP 연결 성공!',
|
||||
};
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
return {
|
||||
success: false,
|
||||
message: `연결 실패: ${err.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const mailSendSimpleService = new MailSendSimpleService();
|
||||
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { MailComponent, QueryConfig } from './mailQueryService';
|
||||
|
||||
export interface MailTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
subject: string;
|
||||
components: MailComponent[];
|
||||
queryConfig?: {
|
||||
queries: QueryConfig[];
|
||||
};
|
||||
recipientConfig?: {
|
||||
type: 'query' | 'manual';
|
||||
emailField?: string;
|
||||
nameField?: string;
|
||||
queryId?: string;
|
||||
manualList?: Array<{ email: string; name?: string }>;
|
||||
};
|
||||
category?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
class MailTemplateFileService {
|
||||
private templatesDir: string;
|
||||
|
||||
constructor() {
|
||||
// uploads/mail-templates 디렉토리 사용
|
||||
this.templatesDir = path.join(process.cwd(), 'uploads', 'mail-templates');
|
||||
this.ensureDirectoryExists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 디렉토리 생성 (없으면)
|
||||
*/
|
||||
private async ensureDirectoryExists() {
|
||||
try {
|
||||
await fs.access(this.templatesDir);
|
||||
} catch {
|
||||
await fs.mkdir(this.templatesDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 파일 경로 생성
|
||||
*/
|
||||
private getTemplatePath(id: string): string {
|
||||
return path.join(this.templatesDir, `${id}.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 템플릿 목록 조회
|
||||
*/
|
||||
async getAllTemplates(): Promise<MailTemplate[]> {
|
||||
await this.ensureDirectoryExists();
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(this.templatesDir);
|
||||
const jsonFiles = files.filter(f => f.endsWith('.json'));
|
||||
|
||||
const templates = await Promise.all(
|
||||
jsonFiles.map(async (file) => {
|
||||
const content = await fs.readFile(
|
||||
path.join(this.templatesDir, file),
|
||||
'utf-8'
|
||||
);
|
||||
return JSON.parse(content) as MailTemplate;
|
||||
})
|
||||
);
|
||||
|
||||
// 최신순 정렬
|
||||
return templates.sort((a, b) =>
|
||||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
);
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 템플릿 조회
|
||||
*/
|
||||
async getTemplateById(id: string): Promise<MailTemplate | null> {
|
||||
try {
|
||||
const content = await fs.readFile(this.getTemplatePath(id), 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 생성
|
||||
*/
|
||||
async createTemplate(
|
||||
data: Omit<MailTemplate, 'id' | 'createdAt' | 'updatedAt'>
|
||||
): Promise<MailTemplate> {
|
||||
const id = `template-${Date.now()}`;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const template: MailTemplate = {
|
||||
...data,
|
||||
id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
this.getTemplatePath(id),
|
||||
JSON.stringify(template, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
return template;
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 수정
|
||||
*/
|
||||
async updateTemplate(
|
||||
id: string,
|
||||
data: Partial<Omit<MailTemplate, 'id' | 'createdAt'>>
|
||||
): Promise<MailTemplate | null> {
|
||||
const existing = await this.getTemplateById(id);
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const updated: MailTemplate = {
|
||||
...existing,
|
||||
...data,
|
||||
id: existing.id,
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
this.getTemplatePath(id),
|
||||
JSON.stringify(updated, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 삭제
|
||||
*/
|
||||
async deleteTemplate(id: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.unlink(this.getTemplatePath(id));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿을 HTML로 렌더링
|
||||
*/
|
||||
renderTemplateToHtml(components: MailComponent[]): string {
|
||||
let html = '<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">';
|
||||
|
||||
components.forEach(comp => {
|
||||
const styles = Object.entries(comp.styles || {})
|
||||
.map(([key, value]) => `${this.camelToKebab(key)}: ${value}`)
|
||||
.join('; ');
|
||||
|
||||
switch (comp.type) {
|
||||
case 'text':
|
||||
html += `<div style="${styles}">${comp.content || ''}</div>`;
|
||||
break;
|
||||
case 'button':
|
||||
html += `<div style="text-align: center; ${styles}">
|
||||
<a href="${comp.url || '#'}"
|
||||
style="display: inline-block; padding: 12px 24px; text-decoration: none;
|
||||
background-color: ${comp.styles?.backgroundColor || '#007bff'};
|
||||
color: ${comp.styles?.color || '#fff'};
|
||||
border-radius: 4px;">
|
||||
${comp.text || 'Button'}
|
||||
</a>
|
||||
</div>`;
|
||||
break;
|
||||
case 'image':
|
||||
html += `<div style="${styles}">
|
||||
<img src="${comp.src || ''}" alt="" style="max-width: 100%; height: auto;" />
|
||||
</div>`;
|
||||
break;
|
||||
case 'spacer':
|
||||
html += `<div style="height: ${comp.height || 20}px;"></div>`;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* camelCase를 kebab-case로 변환
|
||||
*/
|
||||
private camelToKebab(str: string): string {
|
||||
return str.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리별 템플릿 조회
|
||||
*/
|
||||
async getTemplatesByCategory(category: string): Promise<MailTemplate[]> {
|
||||
const allTemplates = await this.getAllTemplates();
|
||||
return allTemplates.filter(t => t.category === category);
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 검색
|
||||
*/
|
||||
async searchTemplates(keyword: string): Promise<MailTemplate[]> {
|
||||
const allTemplates = await this.getAllTemplates();
|
||||
const lowerKeyword = keyword.toLowerCase();
|
||||
|
||||
return allTemplates.filter(t =>
|
||||
t.name.toLowerCase().includes(lowerKeyword) ||
|
||||
t.subject.toLowerCase().includes(lowerKeyword) ||
|
||||
t.category?.toLowerCase().includes(lowerKeyword)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const mailTemplateFileService = new MailTemplateFileService();
|
||||
|
||||
|
|
@ -18,6 +18,7 @@ services:
|
|||
- CORS_ORIGIN=http://localhost:9771
|
||||
- CORS_CREDENTIALS=true
|
||||
- LOG_LEVEL=debug
|
||||
- ENCRYPTION_KEY=ilshin-plm-mail-encryption-key-32characters-2024-secure
|
||||
volumes:
|
||||
- ../../backend-node:/app # 개발 모드: 코드 변경 시 자동 반영
|
||||
- /app/node_modules
|
||||
|
|
|
|||
|
|
@ -0,0 +1,205 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Mail, Plus, Loader2, RefreshCw } from "lucide-react";
|
||||
import {
|
||||
MailAccount,
|
||||
getMailAccounts,
|
||||
createMailAccount,
|
||||
updateMailAccount,
|
||||
deleteMailAccount,
|
||||
CreateMailAccountDto,
|
||||
UpdateMailAccountDto,
|
||||
} from "@/lib/api/mail";
|
||||
import MailAccountModal from "@/components/mail/MailAccountModal";
|
||||
import MailAccountTable from "@/components/mail/MailAccountTable";
|
||||
import ConfirmDeleteModal from "@/components/mail/ConfirmDeleteModal";
|
||||
|
||||
export default function MailAccountsPage() {
|
||||
const [accounts, setAccounts] = useState<MailAccount[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [selectedAccount, setSelectedAccount] = useState<MailAccount | null>(null);
|
||||
const [modalMode, setModalMode] = useState<'create' | 'edit'>('create');
|
||||
|
||||
const loadAccounts = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await getMailAccounts();
|
||||
// 배열인지 확인하고 설정
|
||||
if (Array.isArray(data)) {
|
||||
setAccounts(data);
|
||||
} else {
|
||||
console.error('API 응답이 배열이 아닙니다:', data);
|
||||
setAccounts([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('계정 로드 실패:', error);
|
||||
setAccounts([]); // 에러 시 빈 배열로 설정
|
||||
// alert('계정 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadAccounts();
|
||||
}, []);
|
||||
|
||||
const handleOpenCreateModal = () => {
|
||||
setModalMode('create');
|
||||
setSelectedAccount(null);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenEditModal = (account: MailAccount) => {
|
||||
setModalMode('edit');
|
||||
setSelectedAccount(account);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenDeleteModal = (account: MailAccount) => {
|
||||
setSelectedAccount(account);
|
||||
setIsDeleteModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveAccount = async (data: CreateMailAccountDto | UpdateMailAccountDto) => {
|
||||
try {
|
||||
if (modalMode === 'create') {
|
||||
await createMailAccount(data as CreateMailAccountDto);
|
||||
} else if (modalMode === 'edit' && selectedAccount) {
|
||||
await updateMailAccount(selectedAccount.id, data as UpdateMailAccountDto);
|
||||
}
|
||||
await loadAccounts();
|
||||
setIsModalOpen(false);
|
||||
} catch (error) {
|
||||
throw error; // 모달에서 에러 처리
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAccount = async () => {
|
||||
if (!selectedAccount) return;
|
||||
|
||||
try {
|
||||
await deleteMailAccount(selectedAccount.id);
|
||||
await loadAccounts();
|
||||
alert('계정이 삭제되었습니다.');
|
||||
} catch (error) {
|
||||
console.error('계정 삭제 실패:', error);
|
||||
alert('계정 삭제에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStatus = async (account: MailAccount) => {
|
||||
try {
|
||||
const newStatus = account.status === 'active' ? 'inactive' : 'active';
|
||||
await updateMailAccount(account.id, { status: newStatus });
|
||||
await loadAccounts();
|
||||
} 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">SMTP 메일 계정을 관리하고 발송 통계를 확인합니다</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadAccounts}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
onClick={handleOpenCreateModal}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
새 계정 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 컨텐츠 */}
|
||||
{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>
|
||||
) : (
|
||||
<Card className="shadow-sm">
|
||||
<CardContent className="p-6">
|
||||
<MailAccountTable
|
||||
accounts={accounts}
|
||||
onEdit={handleOpenEditModal}
|
||||
onDelete={handleOpenDeleteModal}
|
||||
onToggleStatus={handleToggleStatus}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 안내 정보 */}
|
||||
<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">
|
||||
<Mail className="w-5 h-5 mr-2 text-orange-500" />
|
||||
메일 계정 관리
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-700 mb-4">
|
||||
💡 SMTP 계정을 등록하여 시스템에서 메일을 발송할 수 있어요!
|
||||
</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>Gmail, Naver, 자체 SMTP 서버 지원</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>일일 발송 제한 설정 가능</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 모달들 */}
|
||||
<MailAccountModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onSave={handleSaveAccount}
|
||||
account={selectedAccount}
|
||||
mode={modalMode}
|
||||
/>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
onClose={() => setIsDeleteModalOpen(false)}
|
||||
onConfirm={handleDeleteAccount}
|
||||
title="메일 계정 삭제"
|
||||
message="이 메일 계정을 삭제하시겠습니까?"
|
||||
itemName={selectedAccount?.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,283 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Mail,
|
||||
Send,
|
||||
Inbox,
|
||||
FileText,
|
||||
RefreshCw,
|
||||
TrendingUp,
|
||||
Users,
|
||||
Calendar,
|
||||
Clock
|
||||
} from "lucide-react";
|
||||
|
||||
interface DashboardStats {
|
||||
totalAccounts: number;
|
||||
totalTemplates: number;
|
||||
sentToday: number;
|
||||
receivedToday: number;
|
||||
sentThisMonth: number;
|
||||
successRate: number;
|
||||
}
|
||||
|
||||
export default function MailDashboardPage() {
|
||||
const [stats, setStats] = useState<DashboardStats>({
|
||||
totalAccounts: 0,
|
||||
totalTemplates: 0,
|
||||
sentToday: 0,
|
||||
receivedToday: 0,
|
||||
sentThisMonth: 0,
|
||||
successRate: 0,
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadStats = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 계정 수
|
||||
const accountsRes = await fetch('/api/mail/accounts');
|
||||
const accountsData = await accountsRes.json();
|
||||
|
||||
// 템플릿 수
|
||||
const templatesRes = await fetch('/api/mail/templates-file');
|
||||
const templatesData = await templatesRes.json();
|
||||
|
||||
setStats({
|
||||
totalAccounts: accountsData.success ? accountsData.data.length : 0,
|
||||
totalTemplates: templatesData.success ? templatesData.data.length : 0,
|
||||
sentToday: 0, // TODO: 실제 발송 통계 API 연동
|
||||
receivedToday: 0,
|
||||
sentThisMonth: 0,
|
||||
successRate: 0,
|
||||
});
|
||||
} catch (error) {
|
||||
// console.error('통계 로드 실패:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
}, []);
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
title: "등록된 계정",
|
||||
value: stats.totalAccounts,
|
||||
icon: Users,
|
||||
color: "blue",
|
||||
bgColor: "bg-blue-100",
|
||||
iconColor: "text-blue-500",
|
||||
},
|
||||
{
|
||||
title: "템플릿 수",
|
||||
value: stats.totalTemplates,
|
||||
icon: FileText,
|
||||
color: "green",
|
||||
bgColor: "bg-green-100",
|
||||
iconColor: "text-green-500",
|
||||
},
|
||||
{
|
||||
title: "오늘 발송",
|
||||
value: stats.sentToday,
|
||||
icon: Send,
|
||||
color: "orange",
|
||||
bgColor: "bg-orange-100",
|
||||
iconColor: "text-orange-500",
|
||||
},
|
||||
{
|
||||
title: "오늘 수신",
|
||||
value: stats.receivedToday,
|
||||
icon: Inbox,
|
||||
color: "purple",
|
||||
bgColor: "bg-purple-100",
|
||||
iconColor: "text-purple-500",
|
||||
},
|
||||
];
|
||||
|
||||
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>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadStats}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{statCards.map((stat, index) => (
|
||||
<Card key={index} className="shadow-sm hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-1">{stat.title}</p>
|
||||
<p className="text-3xl font-bold text-gray-900">{stat.value}</p>
|
||||
</div>
|
||||
<div className={`${stat.bgColor} p-3 rounded-lg`}>
|
||||
<stat.icon className={`w-6 h-6 ${stat.iconColor}`} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 이번 달 통계 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="border-b bg-gradient-to-r from-slate-50 to-gray-50">
|
||||
<CardTitle className="flex items-center">
|
||||
<Calendar className="w-5 h-5 mr-2 text-orange-500" />
|
||||
이번 달 발송 통계
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">총 발송 건수</span>
|
||||
<span className="text-lg font-semibold text-gray-900">
|
||||
{stats.sentThisMonth}건
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">성공률</span>
|
||||
<span className="text-lg font-semibold text-green-600">
|
||||
{stats.successRate}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="pt-4 border-t">
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<TrendingUp className="w-4 h-4 mr-2 text-green-500" />
|
||||
전월 대비 12% 증가
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="border-b bg-gradient-to-r from-slate-50 to-gray-50">
|
||||
<CardTitle className="flex items-center">
|
||||
<Clock className="w-5 h-5 mr-2 text-blue-500" />
|
||||
최근 활동
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-3">
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
<Mail className="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
||||
<p className="text-sm">최근 활동이 없습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 빠른 액세스 */}
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="border-b bg-gradient-to-r from-slate-50 to-gray-50">
|
||||
<CardTitle>빠른 액세스</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<a
|
||||
href="/admin/mail/accounts"
|
||||
className="flex items-center p-4 rounded-lg border border-gray-200 hover:border-orange-300 hover:bg-orange-50 transition-all"
|
||||
>
|
||||
<Users className="w-8 h-8 text-blue-500 mr-3" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">계정 관리</p>
|
||||
<p className="text-sm text-gray-500">메일 계정 설정</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/admin/mail/templates"
|
||||
className="flex items-center p-4 rounded-lg border border-gray-200 hover:border-orange-300 hover:bg-orange-50 transition-all"
|
||||
>
|
||||
<FileText className="w-8 h-8 text-green-500 mr-3" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">템플릿 관리</p>
|
||||
<p className="text-sm text-gray-500">템플릿 편집</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/admin/mail/send"
|
||||
className="flex items-center p-4 rounded-lg border border-gray-200 hover:border-orange-300 hover:bg-orange-50 transition-all"
|
||||
>
|
||||
<Send className="w-8 h-8 text-orange-500 mr-3" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">메일 발송</p>
|
||||
<p className="text-sm text-gray-500">메일 보내기</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/admin/mail/receive"
|
||||
className="flex items-center p-4 rounded-lg border border-gray-200 hover:border-orange-300 hover:bg-orange-50 transition-all"
|
||||
>
|
||||
<Inbox className="w-8 h-8 text-purple-500 mr-3" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">수신함</p>
|
||||
<p className="text-sm text-gray-500">받은 메일 확인</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 안내 정보 */}
|
||||
<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">
|
||||
<Mail 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>SMTP 계정을 등록하여 메일 발송</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>동적 변수와 SQL 쿼리 연동</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-orange-500 mr-2">✓</span>
|
||||
<span>발송 통계 및 이력 관리</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Inbox, Mail, Clock, AlertCircle, RefreshCw } from "lucide-react";
|
||||
|
||||
export default function MailReceivePage() {
|
||||
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>
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 메일 목록 미리보기 */}
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="bg-gradient-to-r from-slate-50 to-gray-50 border-b">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Inbox className="w-5 h-5 text-orange-500" />
|
||||
받은 메일함
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
{/* 빈 상태 */}
|
||||
<div className="text-center py-16">
|
||||
<Mail className="w-16 h-16 mx-auto mb-4 text-gray-300" />
|
||||
<h3 className="text-lg font-semibold text-gray-700 mb-2">
|
||||
메일 수신 기능 준비 중
|
||||
</h3>
|
||||
<p className="text-gray-500 mb-6">
|
||||
IMAP/POP3 기반 메일 수신 기능이 곧 추가될 예정입니다.
|
||||
</p>
|
||||
|
||||
{/* 예상 레이아웃 미리보기 */}
|
||||
<div className="max-w-3xl mx-auto space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-4 p-4 bg-gray-50 rounded-lg border border-gray-200 opacity-40"
|
||||
>
|
||||
<div className="w-10 h-10 bg-gray-200 rounded-full" />
|
||||
<div className="flex-1 text-left">
|
||||
<div className="h-4 bg-gray-200 rounded w-1/3 mb-2" />
|
||||
<div className="h-3 bg-gray-200 rounded w-2/3" />
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
<Clock className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 안내 정보 */}
|
||||
<Card className="bg-gradient-to-r from-blue-50 to-indigo-50 border-blue-200 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center">
|
||||
<AlertCircle className="w-5 h-5 mr-2 text-blue-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-blue-500 mr-2">✓</span>
|
||||
<span>IMAP/POP3 프로토콜을 통한 메일 수신</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-blue-500 mr-2">✓</span>
|
||||
<span>받은 메일 목록 조회 및 검색</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-blue-500 mr-2">✓</span>
|
||||
<span>메일 읽음/읽지않음 상태 관리</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-blue-500 mr-2">✓</span>
|
||||
<span>첨부파일 다운로드</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-blue-500 mr-2">✓</span>
|
||||
<span>메일 필터링 및 정렬</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,392 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Send, Mail, Eye, Plus, X, Loader2, CheckCircle } from "lucide-react";
|
||||
import {
|
||||
MailAccount,
|
||||
MailTemplate,
|
||||
getMailAccounts,
|
||||
getMailTemplates,
|
||||
sendMail,
|
||||
extractTemplateVariables,
|
||||
renderTemplateToHtml,
|
||||
} from "@/lib/api/mail";
|
||||
|
||||
export default function MailSendPage() {
|
||||
const [accounts, setAccounts] = useState<MailAccount[]>([]);
|
||||
const [templates, setTemplates] = useState<MailTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// 폼 상태
|
||||
const [selectedAccountId, setSelectedAccountId] = useState<string>("");
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
|
||||
const [subject, setSubject] = useState<string>("");
|
||||
const [recipients, setRecipients] = useState<string[]>([""]);
|
||||
const [variables, setVariables] = useState<Record<string, string>>({});
|
||||
|
||||
// UI 상태
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [sendResult, setSendResult] = useState<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [accountsData, templatesData] = await Promise.all([
|
||||
getMailAccounts(),
|
||||
getMailTemplates(),
|
||||
]);
|
||||
setAccounts(Array.isArray(accountsData) ? accountsData : []);
|
||||
setTemplates(Array.isArray(templatesData) ? templatesData : []);
|
||||
|
||||
// 기본값 설정
|
||||
if (accountsData.length > 0 && !selectedAccountId) {
|
||||
setSelectedAccountId(accountsData[0].id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('데이터 로드 실패:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedTemplate = templates.find((t) => t.id === selectedTemplateId);
|
||||
const templateVariables = selectedTemplate
|
||||
? extractTemplateVariables(selectedTemplate)
|
||||
: [];
|
||||
|
||||
// 템플릿 선택 시 제목 자동 입력 및 변수 초기화
|
||||
useEffect(() => {
|
||||
if (selectedTemplate) {
|
||||
setSubject(selectedTemplate.subject);
|
||||
const initialVars: Record<string, string> = {};
|
||||
templateVariables.forEach((varName) => {
|
||||
initialVars[varName] = "";
|
||||
});
|
||||
setVariables(initialVars);
|
||||
}
|
||||
}, [selectedTemplateId]);
|
||||
|
||||
const addRecipient = () => {
|
||||
setRecipients([...recipients, ""]);
|
||||
};
|
||||
|
||||
const removeRecipient = (index: number) => {
|
||||
setRecipients(recipients.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateRecipient = (index: number, value: string) => {
|
||||
const newRecipients = [...recipients];
|
||||
newRecipients[index] = value;
|
||||
setRecipients(newRecipients);
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
// 유효성 검증
|
||||
const validRecipients = recipients.filter((email) => email.trim() !== "");
|
||||
if (validRecipients.length === 0) {
|
||||
alert("수신자 이메일을 입력하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedAccountId) {
|
||||
alert("발송 계정을 선택하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!subject.trim()) {
|
||||
alert("메일 제목을 입력하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedTemplateId) {
|
||||
alert("템플릿을 선택하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSending(true);
|
||||
setSendResult(null);
|
||||
|
||||
try {
|
||||
const result = await sendMail({
|
||||
accountId: selectedAccountId,
|
||||
templateId: selectedTemplateId,
|
||||
to: validRecipients,
|
||||
subject,
|
||||
variables,
|
||||
});
|
||||
|
||||
setSendResult({
|
||||
success: true,
|
||||
message: `${result.accepted?.length || 0}개 발송 성공`,
|
||||
});
|
||||
|
||||
// 성공 후 초기화
|
||||
setRecipients([""]);
|
||||
setVariables({});
|
||||
} catch (error) {
|
||||
setSendResult({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : "발송 실패",
|
||||
});
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const previewHtml = selectedTemplate
|
||||
? renderTemplateToHtml(selectedTemplate, variables)
|
||||
: "";
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="w-full max-w-6xl mx-auto px-4 py-8 space-y-6">
|
||||
{/* 페이지 제목 */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900">메일 발송</h1>
|
||||
<p className="mt-2 text-gray-600">템플릿을 선택하여 메일을 발송합니다</p>
|
||||
</div>
|
||||
|
||||
{/* 메인 폼 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* 왼쪽: 발송 설정 */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Mail className="w-5 h-5 text-orange-500" />
|
||||
발송 설정
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 발송 계정 선택 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
발송 계정 *
|
||||
</label>
|
||||
<select
|
||||
value={selectedAccountId}
|
||||
onChange={(e) => setSelectedAccountId(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
>
|
||||
<option value="">계정 선택</option>
|
||||
{accounts
|
||||
.filter((acc) => acc.status === "active")
|
||||
.map((account) => (
|
||||
<option key={account.id} value={account.id}>
|
||||
{account.name} ({account.email})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 템플릿 선택 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
템플릿 *
|
||||
</label>
|
||||
<select
|
||||
value={selectedTemplateId}
|
||||
onChange={(e) => setSelectedTemplateId(e.target.value)}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
>
|
||||
<option value="">템플릿 선택</option>
|
||||
{templates.map((template) => (
|
||||
<option key={template.id} value={template.id}>
|
||||
{template.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 메일 제목 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
메일 제목 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder="예: 환영합니다!"
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 수신자 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
수신자 이메일 *
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{recipients.map((email, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => updateRecipient(index, e.target.value)}
|
||||
placeholder="example@email.com"
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
/>
|
||||
{recipients.length > 1 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => removeRecipient(index)}
|
||||
className="text-red-500 hover:text-red-600"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addRecipient}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
수신자 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 템플릿 변수 */}
|
||||
{templateVariables.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
템플릿 변수
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{templateVariables.map((varName) => (
|
||||
<div key={varName}>
|
||||
<label className="block text-xs text-gray-600 mb-1">
|
||||
{varName}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={variables[varName] || ""}
|
||||
onChange={(e) =>
|
||||
setVariables({ ...variables, [varName]: e.target.value })
|
||||
}
|
||||
placeholder={`{${varName}}`}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 발송 버튼 */}
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={() => setShowPreview(!showPreview)}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
disabled={!selectedTemplateId}
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
미리보기
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={isSending || !selectedAccountId || !selectedTemplateId}
|
||||
className="flex-1 bg-orange-500 hover:bg-orange-600"
|
||||
>
|
||||
{isSending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
발송 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
발송
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 발송 결과 */}
|
||||
{sendResult && (
|
||||
<Card
|
||||
className={
|
||||
sendResult.success
|
||||
? "border-green-200 bg-green-50"
|
||||
: "border-red-200 bg-red-50"
|
||||
}
|
||||
>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-2">
|
||||
{sendResult.success ? (
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
) : (
|
||||
<X className="w-5 h-5 text-red-600" />
|
||||
)}
|
||||
<p
|
||||
className={
|
||||
sendResult.success ? "text-green-800" : "text-red-800"
|
||||
}
|
||||
>
|
||||
{sendResult.message}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 미리보기 */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card className="sticky top-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Eye className="w-4 h-4 text-orange-500" />
|
||||
미리보기
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{showPreview && previewHtml ? (
|
||||
<div className="border rounded-lg p-4 bg-white max-h-[600px] overflow-y-auto">
|
||||
<div className="text-xs text-gray-500 mb-2">제목: {subject}</div>
|
||||
<div dangerouslySetInnerHTML={{ __html: previewHtml }} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 text-gray-400">
|
||||
<Mail className="w-12 h-12 mx-auto mb-2 opacity-20" />
|
||||
<p className="text-sm">
|
||||
템플릿을 선택하고
|
||||
<br />
|
||||
미리보기 버튼을 클릭하세요
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,286 @@
|
|||
"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>
|
||||
);
|
||||
}
|
||||
|
|
@ -116,14 +116,6 @@ export function UserToolbar({
|
|||
|
||||
{/* 고급 검색 필드들 */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{/* <div>
|
||||
<label className="text-muted-foreground mb-1 block text-xs font-medium">사번</label>
|
||||
<Input
|
||||
placeholder="사번 검색"
|
||||
value={searchFilter.search_sabun || ""}
|
||||
onChange={(e) => handleAdvancedSearchChange("search_sabun", e.target.value)}
|
||||
/>
|
||||
</div> */}
|
||||
|
||||
<div>
|
||||
<label className="text-muted-foreground mb-1 block text-xs font-medium">회사명</label>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,154 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { animations, animationCombos, AnimationConfig } from "@/lib/animations/animations";
|
||||
|
||||
interface AnimatedComponentProps {
|
||||
children: React.ReactNode;
|
||||
animation?: keyof typeof animations;
|
||||
combo?: keyof typeof animationCombos;
|
||||
config?: AnimationConfig;
|
||||
trigger?: "mount" | "hover" | "click" | "visible";
|
||||
delay?: number;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const AnimatedComponent: React.FC<AnimatedComponentProps> = ({
|
||||
children,
|
||||
animation = "fadeIn",
|
||||
combo,
|
||||
config = {},
|
||||
trigger = "mount",
|
||||
delay = 0,
|
||||
className = "",
|
||||
style = {},
|
||||
}) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (trigger === "mount") {
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(true);
|
||||
setIsAnimating(true);
|
||||
}, delay);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [trigger, delay]);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (trigger === "hover") {
|
||||
setIsAnimating(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (trigger === "hover") {
|
||||
setIsAnimating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (trigger === "click") {
|
||||
setIsAnimating(true);
|
||||
setTimeout(() => setIsAnimating(false), config.duration || 300);
|
||||
}
|
||||
};
|
||||
|
||||
const getAnimationStyle = () => {
|
||||
if (combo) {
|
||||
return animationCombos[combo]();
|
||||
}
|
||||
|
||||
if (animation) {
|
||||
return animations[animation](config);
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
const animationStyle = isAnimating ? getAnimationStyle() : {};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
...style,
|
||||
...animationStyle,
|
||||
}}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 특화된 애니메이션 컴포넌트들
|
||||
export const FadeIn: React.FC<Omit<AnimatedComponentProps, "animation">> = (props) => (
|
||||
<AnimatedComponent {...props} animation="fadeIn" />
|
||||
);
|
||||
|
||||
export const SlideInFromLeft: React.FC<Omit<AnimatedComponentProps, "animation">> = (props) => (
|
||||
<AnimatedComponent {...props} animation="slideInFromLeft" />
|
||||
);
|
||||
|
||||
export const SlideInFromRight: React.FC<Omit<AnimatedComponentProps, "animation">> = (props) => (
|
||||
<AnimatedComponent {...props} animation="slideInFromRight" />
|
||||
);
|
||||
|
||||
export const SlideInFromTop: React.FC<Omit<AnimatedComponentProps, "animation">> = (props) => (
|
||||
<AnimatedComponent {...props} animation="slideInFromTop" />
|
||||
);
|
||||
|
||||
export const SlideInFromBottom: React.FC<Omit<AnimatedComponentProps, "animation">> = (props) => (
|
||||
<AnimatedComponent {...props} animation="slideInFromBottom" />
|
||||
);
|
||||
|
||||
export const ScaleIn: React.FC<Omit<AnimatedComponentProps, "animation">> = (props) => (
|
||||
<AnimatedComponent {...props} animation="scaleIn" />
|
||||
);
|
||||
|
||||
export const Bounce: React.FC<Omit<AnimatedComponentProps, "animation">> = (props) => (
|
||||
<AnimatedComponent {...props} animation="bounce" />
|
||||
);
|
||||
|
||||
export const Pulse: React.FC<Omit<AnimatedComponentProps, "animation">> = (props) => (
|
||||
<AnimatedComponent {...props} animation="pulse" />
|
||||
);
|
||||
|
||||
export const Glow: React.FC<Omit<AnimatedComponentProps, "animation">> = (props) => (
|
||||
<AnimatedComponent {...props} animation="glow" />
|
||||
);
|
||||
|
||||
// 조합 애니메이션 컴포넌트들
|
||||
export const PageTransition: React.FC<Omit<AnimatedComponentProps, "combo"> & { direction?: "left" | "right" | "up" | "down" }> = ({ direction, ...props }) => (
|
||||
<AnimatedComponent {...props} combo="pageTransition" />
|
||||
);
|
||||
|
||||
export const ModalEnter: React.FC<Omit<AnimatedComponentProps, "combo">> = (props) => (
|
||||
<AnimatedComponent {...props} combo="modalEnter" />
|
||||
);
|
||||
|
||||
export const ModalExit: React.FC<Omit<AnimatedComponentProps, "combo">> = (props) => (
|
||||
<AnimatedComponent {...props} combo="modalExit" />
|
||||
);
|
||||
|
||||
export const ButtonClick: React.FC<Omit<AnimatedComponentProps, "combo">> = (props) => (
|
||||
<AnimatedComponent {...props} combo="buttonClick" trigger="click" />
|
||||
);
|
||||
|
||||
export const SuccessNotification: React.FC<Omit<AnimatedComponentProps, "combo">> = (props) => (
|
||||
<AnimatedComponent {...props} combo="successNotification" />
|
||||
);
|
||||
|
||||
export const LoadingSpinner: React.FC<Omit<AnimatedComponentProps, "combo">> = (props) => (
|
||||
<AnimatedComponent {...props} combo="loadingSpinner" />
|
||||
);
|
||||
|
||||
export const HoverLift: React.FC<Omit<AnimatedComponentProps, "combo">> = (props) => (
|
||||
<AnimatedComponent {...props} combo="hoverLift" trigger="hover" />
|
||||
);
|
||||
|
|
@ -1,46 +1,17 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect } from "react";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { toast } from "sonner";
|
||||
import { X, ArrowLeft } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { ConnectionTypeSelector } from "./LeftPanel/ConnectionTypeSelector";
|
||||
import { MappingInfoPanel } from "./LeftPanel/MappingInfoPanel";
|
||||
import { StepProgress } from "./RightPanel/StepProgress";
|
||||
import { ConnectionStep } from "./RightPanel/ConnectionStep";
|
||||
import { TableStep } from "./RightPanel/TableStep";
|
||||
import { FieldMappingStep } from "./RightPanel/FieldMappingStep";
|
||||
import { DataConnectionState } from "./types/redesigned";
|
||||
import { ResponsiveContainer, ResponsiveGrid } from "@/components/layout/ResponsiveContainer";
|
||||
import { useResponsive } from "@/lib/hooks/useResponsive";
|
||||
|
||||
// API import
|
||||
import { saveDataflowRelationship, checkRelationshipNameDuplicate } from "@/lib/api/dataflowSave";
|
||||
import { getColumnsFromConnection } from "@/lib/api/multiConnection";
|
||||
|
||||
// 타입 import
|
||||
import {
|
||||
DataConnectionState,
|
||||
DataConnectionActions,
|
||||
DataConnectionDesignerProps,
|
||||
FieldMapping,
|
||||
ValidationResult,
|
||||
TestResult,
|
||||
MappingStats,
|
||||
ActionGroup,
|
||||
SingleAction,
|
||||
} from "./types/redesigned";
|
||||
import { ColumnInfo, Connection, TableInfo } from "@/lib/types/multiConnection";
|
||||
|
||||
// 컴포넌트 import
|
||||
import LeftPanel from "./LeftPanel/LeftPanel";
|
||||
import RightPanel from "./RightPanel/RightPanel";
|
||||
|
||||
/**
|
||||
* 🎨 데이터 연결 설정 메인 디자이너
|
||||
* - 좌우 분할 레이아웃 (30% + 70%)
|
||||
* - 상태 관리 및 액션 처리
|
||||
* - 기존 모달 기능을 메인 화면으로 통합
|
||||
*/
|
||||
const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
|
||||
onClose,
|
||||
initialData,
|
||||
showBackButton = false,
|
||||
}) => {
|
||||
// 🔄 상태 관리
|
||||
const [state, setState] = useState<DataConnectionState>(() => ({
|
||||
const initialState: DataConnectionState = {
|
||||
connectionType: "data_save",
|
||||
currentStep: 1,
|
||||
fieldMappings: [],
|
||||
|
|
@ -52,762 +23,87 @@ const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
|
|||
estimatedRows: 0,
|
||||
actionType: "INSERT",
|
||||
},
|
||||
// 제어 실행 조건 초기값
|
||||
controlConditions: [],
|
||||
|
||||
// 액션 그룹 초기값 (멀티 액션)
|
||||
actionGroups: [
|
||||
{
|
||||
id: "group_1",
|
||||
name: "기본 액션 그룹",
|
||||
logicalOperator: "AND" as const,
|
||||
actions: [
|
||||
{
|
||||
id: "action_1",
|
||||
name: "액션 1",
|
||||
actionType: "insert" as const,
|
||||
conditions: [],
|
||||
fieldMappings: [],
|
||||
isEnabled: true,
|
||||
},
|
||||
],
|
||||
isEnabled: true,
|
||||
},
|
||||
],
|
||||
groupsLogicalOperator: "AND" as "AND" | "OR",
|
||||
|
||||
// 기존 호환성 필드들 (deprecated)
|
||||
actionType: "insert",
|
||||
actionConditions: [],
|
||||
actionFieldMappings: [],
|
||||
isLoading: false,
|
||||
validationErrors: [],
|
||||
};
|
||||
|
||||
// 컬럼 정보 초기화
|
||||
fromColumns: [],
|
||||
toColumns: [],
|
||||
...initialData,
|
||||
}));
|
||||
|
||||
// 🔧 수정 모드 감지 (initialData에 diagramId가 있으면 수정 모드)
|
||||
const diagramId = initialData?.diagramId;
|
||||
|
||||
// 🔄 초기 데이터 로드
|
||||
useEffect(() => {
|
||||
if (initialData && Object.keys(initialData).length > 1) {
|
||||
console.log("🔄 초기 데이터 로드:", initialData);
|
||||
|
||||
// 로드된 데이터로 state 업데이트
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
connectionType: initialData.connectionType || prev.connectionType,
|
||||
|
||||
// 🔧 관계 정보 로드
|
||||
relationshipName: initialData.relationshipName || prev.relationshipName,
|
||||
description: initialData.description || prev.description,
|
||||
groupsLogicalOperator: initialData.groupsLogicalOperator || prev.groupsLogicalOperator,
|
||||
|
||||
fromConnection: initialData.fromConnection || prev.fromConnection,
|
||||
toConnection: initialData.toConnection || prev.toConnection,
|
||||
fromTable: initialData.fromTable || prev.fromTable,
|
||||
toTable: initialData.toTable || prev.toTable,
|
||||
controlConditions: initialData.controlConditions || prev.controlConditions,
|
||||
fieldMappings: initialData.fieldMappings || prev.fieldMappings,
|
||||
|
||||
// 🔧 외부호출 설정 로드
|
||||
externalCallConfig: initialData.externalCallConfig || prev.externalCallConfig,
|
||||
|
||||
// 🔧 액션 그룹 데이터 로드 (기존 호환성 포함)
|
||||
actionGroups:
|
||||
initialData.actionGroups ||
|
||||
// 기존 단일 액션 데이터를 그룹으로 변환
|
||||
(initialData.actionType || initialData.actionConditions
|
||||
? [
|
||||
{
|
||||
id: "group_1",
|
||||
name: "기본 액션 그룹",
|
||||
logicalOperator: "AND" as const,
|
||||
actions: [
|
||||
{
|
||||
id: "action_1",
|
||||
name: "액션 1",
|
||||
actionType: initialData.actionType || ("insert" as const),
|
||||
conditions: initialData.actionConditions || [],
|
||||
fieldMappings: initialData.actionFieldMappings || [],
|
||||
isEnabled: true,
|
||||
},
|
||||
],
|
||||
isEnabled: true,
|
||||
},
|
||||
]
|
||||
: prev.actionGroups),
|
||||
|
||||
// 기존 호환성 필드들
|
||||
actionType: initialData.actionType || prev.actionType,
|
||||
actionConditions: initialData.actionConditions || prev.actionConditions,
|
||||
actionFieldMappings: initialData.actionFieldMappings || prev.actionFieldMappings,
|
||||
|
||||
currentStep: initialData.fromConnection && initialData.toConnection ? 4 : 1, // 연결 정보가 있으면 4단계부터 시작
|
||||
}));
|
||||
|
||||
console.log("✅ 초기 데이터 로드 완료");
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
// 🎯 액션 핸들러들
|
||||
const actions: DataConnectionActions = {
|
||||
// 연결 타입 설정
|
||||
setConnectionType: useCallback((type: "data_save" | "external_call") => {
|
||||
console.log("🔄 [DataConnectionDesigner] setConnectionType 호출됨:", type);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
connectionType: type,
|
||||
// 타입 변경 시 상태 초기화
|
||||
currentStep: 1,
|
||||
fromConnection: undefined,
|
||||
toConnection: undefined,
|
||||
fromTable: undefined,
|
||||
toTable: undefined,
|
||||
fieldMappings: [],
|
||||
validationErrors: [],
|
||||
}));
|
||||
toast.success(`연결 타입이 ${type === "data_save" ? "데이터 저장" : "외부 호출"}로 변경되었습니다.`);
|
||||
}, []),
|
||||
|
||||
// 🔧 관계 정보 설정
|
||||
setRelationshipName: useCallback((name: string) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
relationshipName: name,
|
||||
}));
|
||||
}, []),
|
||||
|
||||
setDescription: useCallback((description: string) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
description: description,
|
||||
}));
|
||||
}, []),
|
||||
|
||||
setGroupsLogicalOperator: useCallback((operator: "AND" | "OR") => {
|
||||
setState((prev) => ({ ...prev, groupsLogicalOperator: operator }));
|
||||
console.log("🔄 그룹 간 논리 연산자 변경:", operator);
|
||||
}, []),
|
||||
|
||||
// 단계 이동
|
||||
goToStep: useCallback((step: 1 | 2 | 3 | 4) => {
|
||||
setState((prev) => ({ ...prev, currentStep: step }));
|
||||
}, []),
|
||||
|
||||
// 연결 선택
|
||||
selectConnection: useCallback((type: "from" | "to", connection: Connection) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
[type === "from" ? "fromConnection" : "toConnection"]: connection,
|
||||
// 연결 변경 시 테이블과 매핑 초기화
|
||||
[type === "from" ? "fromTable" : "toTable"]: undefined,
|
||||
fieldMappings: [],
|
||||
}));
|
||||
toast.success(`${type === "from" ? "소스" : "대상"} 연결이 선택되었습니다: ${connection.name}`);
|
||||
}, []),
|
||||
|
||||
// 테이블 선택
|
||||
selectTable: useCallback((type: "from" | "to", table: TableInfo) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
[type === "from" ? "fromTable" : "toTable"]: table,
|
||||
// 테이블 변경 시 매핑과 컬럼 정보 초기화
|
||||
fieldMappings: [],
|
||||
fromColumns: type === "from" ? [] : prev.fromColumns,
|
||||
toColumns: type === "to" ? [] : prev.toColumns,
|
||||
}));
|
||||
toast.success(
|
||||
`${type === "from" ? "소스" : "대상"} 테이블이 선택되었습니다: ${table.displayName || table.tableName}`,
|
||||
);
|
||||
}, []),
|
||||
|
||||
// 컬럼 정보 로드 (중앙 관리)
|
||||
loadColumns: useCallback(async () => {
|
||||
if (!state.fromConnection || !state.toConnection || !state.fromTable || !state.toTable) {
|
||||
console.log("❌ 컬럼 로드: 필수 정보 누락");
|
||||
return;
|
||||
}
|
||||
|
||||
// 이미 로드된 경우 스킵 (배열 길이로 확인)
|
||||
if (state.fromColumns && state.toColumns && state.fromColumns.length > 0 && state.toColumns.length > 0) {
|
||||
console.log("✅ 컬럼 정보 이미 로드됨, 스킵", {
|
||||
fromColumns: state.fromColumns.length,
|
||||
toColumns: state.toColumns.length,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("🔄 중앙 컬럼 로드 시작:", {
|
||||
from: `${state.fromConnection.id}/${state.fromTable.tableName}`,
|
||||
to: `${state.toConnection.id}/${state.toTable.tableName}`,
|
||||
});
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
isLoading: true,
|
||||
fromColumns: [],
|
||||
toColumns: [],
|
||||
}));
|
||||
|
||||
try {
|
||||
const [fromCols, toCols] = await Promise.all([
|
||||
getColumnsFromConnection(state.fromConnection.id, state.fromTable.tableName),
|
||||
getColumnsFromConnection(state.toConnection.id, state.toTable.tableName),
|
||||
]);
|
||||
|
||||
console.log("✅ 중앙 컬럼 로드 완료:", {
|
||||
fromColumns: fromCols.length,
|
||||
toColumns: toCols.length,
|
||||
});
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
fromColumns: Array.isArray(fromCols) ? fromCols : [],
|
||||
toColumns: Array.isArray(toCols) ? toCols : [],
|
||||
isLoading: false,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("❌ 중앙 컬럼 로드 실패:", error);
|
||||
setState((prev) => ({ ...prev, isLoading: false }));
|
||||
toast.error("컬럼 정보를 불러오는데 실패했습니다.");
|
||||
}
|
||||
}, [state.fromConnection, state.toConnection, state.fromTable, state.toTable, state.fromColumns, state.toColumns]),
|
||||
|
||||
// 필드 매핑 생성 (호환성용 - 실제로는 각 액션에서 직접 관리)
|
||||
createMapping: useCallback((fromField: ColumnInfo, toField: ColumnInfo) => {
|
||||
const newMapping: FieldMapping = {
|
||||
id: `${fromField.columnName}_to_${toField.columnName}_${Date.now()}`,
|
||||
fromField,
|
||||
toField,
|
||||
isValid: true,
|
||||
validationMessage: undefined,
|
||||
};
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
fieldMappings: [...prev.fieldMappings, newMapping],
|
||||
}));
|
||||
|
||||
console.log("🔗 전역 매핑 생성 (호환성):", {
|
||||
newMapping,
|
||||
fieldName: `${fromField.columnName} → ${toField.columnName}`,
|
||||
});
|
||||
|
||||
toast.success(`매핑이 생성되었습니다: ${fromField.columnName} → ${toField.columnName}`);
|
||||
}, []),
|
||||
|
||||
// 필드 매핑 업데이트
|
||||
updateMapping: useCallback((mappingId: string, updates: Partial<FieldMapping>) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
fieldMappings: prev.fieldMappings.map((mapping) =>
|
||||
mapping.id === mappingId ? { ...mapping, ...updates } : mapping,
|
||||
),
|
||||
}));
|
||||
}, []),
|
||||
|
||||
// 필드 매핑 삭제 (호환성용 - 실제로는 각 액션에서 직접 관리)
|
||||
deleteMapping: useCallback((mappingId: string) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
fieldMappings: prev.fieldMappings.filter((mapping) => mapping.id !== mappingId),
|
||||
}));
|
||||
|
||||
console.log("🗑️ 전역 매핑 삭제 (호환성):", { mappingId });
|
||||
toast.success("매핑이 삭제되었습니다.");
|
||||
}, []),
|
||||
|
||||
// 매핑 검증
|
||||
validateMappings: useCallback(async (): Promise<ValidationResult> => {
|
||||
setState((prev) => ({ ...prev, isLoading: true }));
|
||||
|
||||
try {
|
||||
// TODO: 실제 검증 로직 구현
|
||||
const result: ValidationResult = {
|
||||
isValid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
validationErrors: result.errors,
|
||||
isLoading: false,
|
||||
}));
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
setState((prev) => ({ ...prev, isLoading: false }));
|
||||
throw error;
|
||||
}
|
||||
}, []),
|
||||
|
||||
// 제어 조건 관리 (전체 실행 조건)
|
||||
addControlCondition: useCallback(() => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
controlConditions: [
|
||||
...prev.controlConditions,
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
type: "condition",
|
||||
field: "",
|
||||
operator: "=",
|
||||
value: "",
|
||||
dataType: "string",
|
||||
},
|
||||
],
|
||||
}));
|
||||
}, []),
|
||||
|
||||
updateControlCondition: useCallback((index: number, condition: any) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
controlConditions: prev.controlConditions.map((cond, i) => (i === index ? { ...cond, ...condition } : cond)),
|
||||
}));
|
||||
}, []),
|
||||
|
||||
deleteControlCondition: useCallback((index: number) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
controlConditions: prev.controlConditions.filter((_, i) => i !== index),
|
||||
}));
|
||||
toast.success("제어 조건이 삭제되었습니다.");
|
||||
}, []),
|
||||
|
||||
// 외부호출 설정 업데이트
|
||||
updateExternalCallConfig: useCallback((config: any) => {
|
||||
console.log("🔄 외부호출 설정 업데이트:", config);
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
externalCallConfig: config,
|
||||
}));
|
||||
}, []),
|
||||
|
||||
// 액션 설정 관리
|
||||
setActionType: useCallback((type: "insert" | "update" | "delete" | "upsert") => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionType: type,
|
||||
// INSERT가 아닌 경우 조건 초기화
|
||||
actionConditions: type === "insert" ? [] : prev.actionConditions,
|
||||
}));
|
||||
toast.success(`액션 타입이 ${type.toUpperCase()}로 변경되었습니다.`);
|
||||
}, []),
|
||||
|
||||
addActionCondition: useCallback(() => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionConditions: [
|
||||
...prev.actionConditions,
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
type: "condition",
|
||||
field: "",
|
||||
operator: "=",
|
||||
value: "",
|
||||
dataType: "string",
|
||||
},
|
||||
],
|
||||
}));
|
||||
}, []),
|
||||
|
||||
updateActionCondition: useCallback((index: number, condition: any) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionConditions: prev.actionConditions.map((cond, i) => (i === index ? { ...cond, ...condition } : cond)),
|
||||
}));
|
||||
}, []),
|
||||
|
||||
// 🔧 액션 조건 배열 전체 업데이트 (ActionConditionBuilder용)
|
||||
setActionConditions: useCallback((conditions: any[]) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionConditions: conditions,
|
||||
}));
|
||||
}, []),
|
||||
|
||||
deleteActionCondition: useCallback((index: number) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionConditions: prev.actionConditions.filter((_, i) => i !== index),
|
||||
}));
|
||||
toast.success("조건이 삭제되었습니다.");
|
||||
}, []),
|
||||
|
||||
// 🎯 액션 그룹 관리 (멀티 액션)
|
||||
addActionGroup: useCallback(() => {
|
||||
const newGroupId = `group_${Date.now()}`;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionGroups: [
|
||||
...prev.actionGroups,
|
||||
{
|
||||
id: newGroupId,
|
||||
name: `액션 그룹 ${prev.actionGroups.length + 1}`,
|
||||
logicalOperator: "AND" as const,
|
||||
actions: [
|
||||
{
|
||||
id: `action_${Date.now()}`,
|
||||
name: "액션 1",
|
||||
actionType: "insert" as const,
|
||||
conditions: [],
|
||||
fieldMappings: [],
|
||||
isEnabled: true,
|
||||
},
|
||||
],
|
||||
isEnabled: true,
|
||||
},
|
||||
],
|
||||
}));
|
||||
toast.success("새 액션 그룹이 추가되었습니다.");
|
||||
}, []),
|
||||
|
||||
updateActionGroup: useCallback((groupId: string, updates: Partial<ActionGroup>) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionGroups: prev.actionGroups.map((group) => (group.id === groupId ? { ...group, ...updates } : group)),
|
||||
}));
|
||||
}, []),
|
||||
|
||||
deleteActionGroup: useCallback((groupId: string) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionGroups: prev.actionGroups.filter((group) => group.id !== groupId),
|
||||
}));
|
||||
toast.success("액션 그룹이 삭제되었습니다.");
|
||||
}, []),
|
||||
|
||||
addActionToGroup: useCallback((groupId: string) => {
|
||||
const newActionId = `action_${Date.now()}`;
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionGroups: prev.actionGroups.map((group) =>
|
||||
group.id === groupId
|
||||
? {
|
||||
...group,
|
||||
actions: [
|
||||
...group.actions,
|
||||
{
|
||||
id: newActionId,
|
||||
name: `액션 ${group.actions.length + 1}`,
|
||||
actionType: "insert" as const,
|
||||
conditions: [],
|
||||
fieldMappings: [],
|
||||
isEnabled: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
: group,
|
||||
),
|
||||
}));
|
||||
toast.success("새 액션이 추가되었습니다.");
|
||||
}, []),
|
||||
|
||||
updateActionInGroup: useCallback((groupId: string, actionId: string, updates: Partial<SingleAction>) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionGroups: prev.actionGroups.map((group) =>
|
||||
group.id === groupId
|
||||
? {
|
||||
...group,
|
||||
actions: group.actions.map((action) => (action.id === actionId ? { ...action, ...updates } : action)),
|
||||
}
|
||||
: group,
|
||||
),
|
||||
}));
|
||||
}, []),
|
||||
|
||||
deleteActionFromGroup: useCallback((groupId: string, actionId: string) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
actionGroups: prev.actionGroups.map((group) =>
|
||||
group.id === groupId
|
||||
? {
|
||||
...group,
|
||||
actions: group.actions.filter((action) => action.id !== actionId),
|
||||
}
|
||||
: group,
|
||||
),
|
||||
}));
|
||||
toast.success("액션이 삭제되었습니다.");
|
||||
}, []),
|
||||
|
||||
// 매핑 저장 (직접 저장)
|
||||
saveMappings: useCallback(async () => {
|
||||
// 관계명과 설명이 없으면 저장할 수 없음
|
||||
if (!state.relationshipName?.trim()) {
|
||||
toast.error("관계 이름을 입력해주세요.");
|
||||
actions.goToStep(1); // 첫 번째 단계로 이동
|
||||
return;
|
||||
}
|
||||
|
||||
// 외부호출인 경우 API URL만 확인 (테이블 검증 제외)
|
||||
if (state.connectionType === "external_call") {
|
||||
if (!state.externalCallConfig?.restApiSettings?.apiUrl) {
|
||||
toast.error("API URL을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
// 외부호출은 테이블 정보 검증 건너뛰기
|
||||
}
|
||||
|
||||
// 중복 체크 (수정 모드가 아닌 경우에만)
|
||||
if (!diagramId) {
|
||||
try {
|
||||
const duplicateCheck = await checkRelationshipNameDuplicate(state.relationshipName, diagramId);
|
||||
if (duplicateCheck.isDuplicate) {
|
||||
toast.error(`"${state.relationshipName}" 이름이 이미 사용 중입니다. 다른 이름을 사용해주세요.`);
|
||||
actions.goToStep(1); // 첫 번째 단계로 이동
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("중복 체크 실패:", error);
|
||||
toast.error("관계명 중복 체크 중 오류가 발생했습니다.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, isLoading: true }));
|
||||
|
||||
try {
|
||||
// 실제 저장 로직 구현 - connectionType에 따라 필요한 설정만 포함
|
||||
let saveData: any = {
|
||||
relationshipName: state.relationshipName,
|
||||
description: state.description,
|
||||
connectionType: state.connectionType,
|
||||
};
|
||||
|
||||
if (state.connectionType === "external_call") {
|
||||
// 외부호출 타입인 경우: 외부호출 설정만 포함
|
||||
console.log("💾 외부호출 타입 저장 - 외부호출 설정만 포함");
|
||||
saveData = {
|
||||
...saveData,
|
||||
// 외부호출 관련 설정만 포함
|
||||
externalCallConfig: state.externalCallConfig,
|
||||
actionType: "external_call",
|
||||
// 데이터 저장 관련 설정은 제외 (null/빈 배열로 설정)
|
||||
fromConnection: null,
|
||||
toConnection: null,
|
||||
fromTable: null,
|
||||
toTable: null,
|
||||
actionGroups: [],
|
||||
controlConditions: [],
|
||||
actionConditions: [],
|
||||
fieldMappings: [],
|
||||
};
|
||||
} else if (state.connectionType === "data_save") {
|
||||
// 데이터 저장 타입인 경우: 데이터 저장 설정만 포함
|
||||
console.log("💾 데이터 저장 타입 저장 - 데이터 저장 설정만 포함");
|
||||
saveData = {
|
||||
...saveData,
|
||||
// 데이터 저장 관련 설정만 포함
|
||||
fromConnection: state.fromConnection,
|
||||
toConnection: state.toConnection,
|
||||
fromTable: state.fromTable,
|
||||
toTable: state.toTable,
|
||||
actionGroups: state.actionGroups,
|
||||
groupsLogicalOperator: state.groupsLogicalOperator,
|
||||
controlConditions: state.controlConditions,
|
||||
// 기존 호환성을 위한 필드들 (첫 번째 액션 그룹의 첫 번째 액션에서 추출)
|
||||
actionType: state.actionGroups[0]?.actions[0]?.actionType || state.actionType || "insert",
|
||||
actionConditions: state.actionGroups[0]?.actions[0]?.conditions || state.actionConditions || [],
|
||||
fieldMappings: state.actionGroups[0]?.actions[0]?.fieldMappings || state.fieldMappings || [],
|
||||
// 외부호출 관련 설정은 제외 (null로 설정)
|
||||
externalCallConfig: null,
|
||||
};
|
||||
}
|
||||
|
||||
console.log("💾 직접 저장 시작:", { saveData, diagramId, isEdit: !!diagramId });
|
||||
|
||||
// 데이터 저장 타입인 경우 기존 외부호출 설정 정리
|
||||
if (state.connectionType === "data_save" && diagramId) {
|
||||
console.log("🧹 데이터 저장 타입으로 변경 - 기존 외부호출 설정 정리");
|
||||
try {
|
||||
const { ExternalCallConfigAPI } = await import("@/lib/api/externalCallConfig");
|
||||
|
||||
// 기존 외부호출 설정이 있는지 확인하고 삭제 또는 비활성화
|
||||
const existingConfigs = await ExternalCallConfigAPI.getConfigs({
|
||||
company_code: "*",
|
||||
is_active: "Y",
|
||||
});
|
||||
|
||||
const existingConfig = existingConfigs.data?.find(
|
||||
(config: any) => config.config_name === (state.relationshipName || "외부호출 설정")
|
||||
);
|
||||
|
||||
if (existingConfig) {
|
||||
console.log("🗑️ 기존 외부호출 설정 비활성화:", existingConfig.id);
|
||||
// 설정을 비활성화 (삭제하지 않고 is_active를 'N'으로 변경)
|
||||
await ExternalCallConfigAPI.updateConfig(existingConfig.id, {
|
||||
...existingConfig,
|
||||
is_active: "N",
|
||||
updated_at: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
} catch (cleanupError) {
|
||||
console.warn("⚠️ 외부호출 설정 정리 실패 (무시하고 계속):", cleanupError);
|
||||
}
|
||||
}
|
||||
|
||||
// 외부호출인 경우에만 external-call-configs에 설정 저장
|
||||
if (state.connectionType === "external_call" && state.externalCallConfig) {
|
||||
try {
|
||||
const { ExternalCallConfigAPI } = await import("@/lib/api/externalCallConfig");
|
||||
|
||||
const configData = {
|
||||
config_name: state.relationshipName || "외부호출 설정",
|
||||
call_type: "rest-api",
|
||||
api_type: "generic",
|
||||
config_data: state.externalCallConfig.restApiSettings,
|
||||
description: state.description || "",
|
||||
company_code: "*", // 기본값
|
||||
};
|
||||
|
||||
let configResult;
|
||||
|
||||
if (diagramId) {
|
||||
// 수정 모드: 기존 설정이 있는지 확인하고 업데이트 또는 생성
|
||||
console.log("🔄 수정 모드 - 외부호출 설정 처리");
|
||||
|
||||
try {
|
||||
// 먼저 기존 설정 조회 시도
|
||||
const existingConfigs = await ExternalCallConfigAPI.getConfigs({
|
||||
company_code: "*",
|
||||
is_active: "Y",
|
||||
});
|
||||
|
||||
const existingConfig = existingConfigs.data?.find(
|
||||
(config: any) => config.config_name === (state.relationshipName || "외부호출 설정")
|
||||
);
|
||||
|
||||
if (existingConfig) {
|
||||
// 기존 설정 업데이트
|
||||
console.log("📝 기존 외부호출 설정 업데이트:", existingConfig.id);
|
||||
configResult = await ExternalCallConfigAPI.updateConfig(existingConfig.id, configData);
|
||||
} else {
|
||||
// 기존 설정이 없으면 새로 생성
|
||||
console.log("🆕 새 외부호출 설정 생성 (수정 모드)");
|
||||
configResult = await ExternalCallConfigAPI.createConfig(configData);
|
||||
}
|
||||
} catch (updateError) {
|
||||
// 중복 생성 오류인 경우 무시하고 계속 진행
|
||||
if (updateError.message && updateError.message.includes("이미 존재합니다")) {
|
||||
console.log("⚠️ 외부호출 설정이 이미 존재함 - 기존 설정 사용");
|
||||
configResult = { success: true, message: "기존 외부호출 설정 사용" };
|
||||
} else {
|
||||
console.warn("⚠️ 외부호출 설정 처리 실패:", updateError);
|
||||
throw updateError;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 신규 생성 모드
|
||||
console.log("🆕 신규 생성 모드 - 외부호출 설정 생성");
|
||||
try {
|
||||
configResult = await ExternalCallConfigAPI.createConfig(configData);
|
||||
} catch (createError) {
|
||||
// 중복 생성 오류인 경우 무시하고 계속 진행
|
||||
if (createError.message && createError.message.includes("이미 존재합니다")) {
|
||||
console.log("⚠️ 외부호출 설정이 이미 존재함 - 기존 설정 사용");
|
||||
configResult = { success: true, message: "기존 외부호출 설정 사용" };
|
||||
} else {
|
||||
throw createError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!configResult.success) {
|
||||
throw new Error(configResult.error || "외부호출 설정 저장 실패");
|
||||
}
|
||||
|
||||
console.log("✅ 외부호출 설정 저장 완료:", configResult.data);
|
||||
} catch (configError) {
|
||||
console.error("❌ 외부호출 설정 저장 실패:", configError);
|
||||
// 외부호출 설정 저장 실패해도 관계는 저장하도록 함
|
||||
toast.error("외부호출 설정 저장에 실패했지만 관계는 저장되었습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
// 백엔드 API 호출 (수정 모드인 경우 diagramId 전달)
|
||||
const result = await saveDataflowRelationship(saveData, diagramId);
|
||||
|
||||
console.log("✅ 저장 완료:", result);
|
||||
|
||||
setState((prev) => ({ ...prev, isLoading: false }));
|
||||
toast.success(`"${state.relationshipName}" 관계가 성공적으로 저장되었습니다.`);
|
||||
|
||||
// 저장 후 닫기
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("❌ 저장 실패:", error);
|
||||
setState((prev) => ({ ...prev, isLoading: false }));
|
||||
toast.error(error.message || "저장 중 오류가 발생했습니다.");
|
||||
}
|
||||
}, [state, diagramId, onClose]),
|
||||
|
||||
// 테스트 실행
|
||||
testExecution: useCallback(async (): Promise<TestResult> => {
|
||||
setState((prev) => ({ ...prev, isLoading: true }));
|
||||
|
||||
try {
|
||||
// TODO: 실제 테스트 로직 구현
|
||||
const result: TestResult = {
|
||||
success: true,
|
||||
message: "테스트가 성공적으로 완료되었습니다.",
|
||||
affectedRows: 10,
|
||||
executionTime: 250,
|
||||
};
|
||||
|
||||
setState((prev) => ({ ...prev, isLoading: false }));
|
||||
toast.success(result.message);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
setState((prev) => ({ ...prev, isLoading: false }));
|
||||
toast.error("테스트 실행 중 오류가 발생했습니다.");
|
||||
throw error;
|
||||
}
|
||||
}, []),
|
||||
};
|
||||
export const DataConnectionDesigner: React.FC = () => {
|
||||
const [state, setState] = useState<DataConnectionState>(initialState);
|
||||
const { isMobile, isTablet } = useResponsive();
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border bg-white shadow-sm">
|
||||
{/* 상단 네비게이션 */}
|
||||
{showBackButton && (
|
||||
<div className="flex-shrink-0 border-b bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="outline" onClick={onClose} className="flex items-center gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
목록으로
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">🔗 데이터 연결 설정</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{state.connectionType === "data_save" ? "데이터 저장" : "외부 호출"} 연결 설정
|
||||
<div className="h-screen bg-gradient-to-br from-slate-50 to-gray-100">
|
||||
<div className="bg-white border-b border-gray-200 px-6 py-4">
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
🎨 제어관리 - 데이터 연결 설정
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
시각적 필드 매핑으로 데이터 연결을 쉽게 설정하세요
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-[calc(100vh-80px)]">
|
||||
<div className="w-[30%] bg-white border-r border-gray-200 flex flex-col">
|
||||
<ConnectionTypeSelector
|
||||
connectionType={state.connectionType}
|
||||
onConnectionTypeChange={(type) => setState(prev => ({ ...prev, connectionType: type }))}
|
||||
/>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<MappingInfoPanel
|
||||
mappingStats={state.mappingStats}
|
||||
fieldMappings={state.fieldMappings}
|
||||
selectedMapping={state.selectedMapping}
|
||||
onMappingSelect={(mappingId) => setState(prev => ({ ...prev, selectedMapping: mappingId }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 메인 컨텐츠 - 좌우 분할 레이아웃 */}
|
||||
<div className="flex h-[calc(100vh-200px)] min-h-[700px] overflow-hidden">
|
||||
{/* 좌측 패널 (30%) - 항상 표시 */}
|
||||
<div className="flex w-[30%] flex-col border-r bg-white">
|
||||
<LeftPanel state={state} actions={actions} />
|
||||
<div className="w-[70%] bg-gray-50 flex flex-col">
|
||||
<StepProgress
|
||||
currentStep={state.currentStep}
|
||||
onStepChange={(step) => setState(prev => ({ ...prev, currentStep: step }))}
|
||||
/>
|
||||
|
||||
<div className="flex-1 p-6">
|
||||
{state.currentStep === 1 && (
|
||||
<ConnectionStep
|
||||
fromConnection={state.fromConnection}
|
||||
toConnection={state.toConnection}
|
||||
onFromConnectionChange={(conn) => setState(prev => ({ ...prev, fromConnection: conn }))}
|
||||
onToConnectionChange={(conn) => setState(prev => ({ ...prev, toConnection: conn }))}
|
||||
onNext={() => setState(prev => ({ ...prev, currentStep: 2 }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state.currentStep === 2 && (
|
||||
<TableStep
|
||||
fromConnection={state.fromConnection}
|
||||
toConnection={state.toConnection}
|
||||
fromTable={state.fromTable}
|
||||
toTable={state.toTable}
|
||||
onFromTableChange={(table) => setState(prev => ({ ...prev, fromTable: table }))}
|
||||
onToTableChange={(table) => setState(prev => ({ ...prev, toTable: table }))}
|
||||
onNext={() => setState(prev => ({ ...prev, currentStep: 3 }))}
|
||||
onBack={() => setState(prev => ({ ...prev, currentStep: 1 }))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{state.currentStep === 3 && (
|
||||
<FieldMappingStep
|
||||
fromTable={state.fromTable}
|
||||
toTable={state.toTable}
|
||||
fieldMappings={state.fieldMappings}
|
||||
onMappingsChange={(mappings) => setState(prev => ({ ...prev, fieldMappings: mappings }))}
|
||||
onBack={() => setState(prev => ({ ...prev, currentStep: 2 }))}
|
||||
onSave={() => {
|
||||
// 저장 로직
|
||||
console.log("저장:", state);
|
||||
alert("데이터 연결 설정이 저장되었습니다!");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 우측 패널 (70%) */}
|
||||
<div className="flex w-[70%] flex-col bg-gray-50">
|
||||
<RightPanel key={state.connectionType} state={state} actions={actions} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,62 +1,66 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Database, Globe } from "lucide-react";
|
||||
import { ConnectionType } from "../types/redesigned";
|
||||
|
||||
// 타입 import
|
||||
import { ConnectionType, ConnectionTypeSelectorProps } from "../types/redesigned";
|
||||
interface ConnectionTypeSelectorProps {
|
||||
connectionType: "data_save" | "external_call";
|
||||
onConnectionTypeChange: (type: "data_save" | "external_call") => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔘 연결 타입 선택 컴포넌트
|
||||
* - 데이터 저장 (INSERT/UPDATE/DELETE)
|
||||
* - 외부 호출 (API/Webhook)
|
||||
*/
|
||||
const ConnectionTypeSelector: React.FC<ConnectionTypeSelectorProps> = ({ selectedType, onTypeChange }) => {
|
||||
const connectionTypes: ConnectionType[] = [
|
||||
{
|
||||
id: "data_save",
|
||||
label: "데이터 저장",
|
||||
description: "INSERT/UPDATE/DELETE 작업",
|
||||
icon: <Database className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
id: "external_call",
|
||||
label: "외부 호출",
|
||||
description: "API/Webhook 호출",
|
||||
icon: <Globe className="h-4 w-4" />,
|
||||
},
|
||||
];
|
||||
const connectionTypes: ConnectionType[] = [
|
||||
{
|
||||
id: "data_save",
|
||||
label: "데이터 저장",
|
||||
description: "INSERT/UPDATE/DELETE 작업",
|
||||
icon: <Database className="w-6 h-6" />,
|
||||
},
|
||||
{
|
||||
id: "external_call",
|
||||
label: "외부 호출",
|
||||
description: "API/Webhook 호출",
|
||||
icon: <Globe className="w-6 h-6" />,
|
||||
},
|
||||
];
|
||||
|
||||
export const ConnectionTypeSelector: React.FC<ConnectionTypeSelectorProps> = ({
|
||||
connectionType,
|
||||
onConnectionTypeChange,
|
||||
}) => {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<RadioGroup
|
||||
value={selectedType}
|
||||
onValueChange={(value) => {
|
||||
console.log("🔘 [ConnectionTypeSelector] 라디오 버튼 변경:", value);
|
||||
onTypeChange(value as "data_save" | "external_call");
|
||||
}}
|
||||
className="space-y-3"
|
||||
>
|
||||
{connectionTypes.map((type) => (
|
||||
<div key={type.id} className="flex items-start space-x-3">
|
||||
<RadioGroupItem value={type.id} id={type.id} className="mt-1" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<Label htmlFor={type.id} className="flex cursor-pointer items-center gap-2 font-medium">
|
||||
{type.icon}
|
||||
{type.label}
|
||||
</Label>
|
||||
<p className="text-muted-foreground mt-1 text-xs">{type.description}</p>
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
연결 타입 선택
|
||||
</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
{connectionTypes.map((type) => (
|
||||
<div
|
||||
key={type.id}
|
||||
className={`p-4 rounded-lg border-2 cursor-pointer transition-all duration-200 ${
|
||||
connectionType === type.id
|
||||
? "border-orange-500 bg-orange-50 shadow-md"
|
||||
: "border-gray-200 bg-white hover:border-orange-300 hover:bg-orange-25"
|
||||
}`}
|
||||
onClick={() => onConnectionTypeChange(type.id)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${
|
||||
connectionType === type.id
|
||||
? "bg-orange-100 text-orange-600"
|
||||
: "bg-gray-100 text-gray-600"
|
||||
}`}>
|
||||
{type.icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">{type.label}</h3>
|
||||
<p className="text-sm text-gray-600">{type.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectionTypeSelector;
|
||||
};
|
||||
|
|
@ -1,115 +1,146 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { CheckCircle, AlertTriangle, XCircle, Info } from "lucide-react";
|
||||
import { CheckCircle, XCircle, AlertCircle, Database } from "lucide-react";
|
||||
import { MappingStats, FieldMapping } from "../types/redesigned";
|
||||
|
||||
// 타입 import
|
||||
import { MappingInfoPanelProps } from "../types/redesigned";
|
||||
|
||||
/**
|
||||
* 📊 매핑 정보 패널
|
||||
* - 실시간 매핑 통계
|
||||
* - 검증 상태 표시
|
||||
* - 예상 처리량 정보
|
||||
*/
|
||||
const MappingInfoPanel: React.FC<MappingInfoPanelProps> = ({ stats, validationErrors }) => {
|
||||
const errorCount = validationErrors.filter((e) => e.type === "error").length;
|
||||
const warningCount = validationErrors.filter((e) => e.type === "warning").length;
|
||||
interface MappingInfoPanelProps {
|
||||
mappingStats: MappingStats;
|
||||
fieldMappings: FieldMapping[];
|
||||
selectedMapping?: string;
|
||||
onMappingSelect: (mappingId: string) => void;
|
||||
}
|
||||
|
||||
export const MappingInfoPanel: React.FC<MappingInfoPanelProps> = ({
|
||||
mappingStats,
|
||||
fieldMappings,
|
||||
selectedMapping,
|
||||
onMappingSelect,
|
||||
}) => {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="space-y-3 p-4">
|
||||
{/* 매핑 통계 */}
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">총 매핑:</span>
|
||||
<Badge variant="outline">{stats.totalMappings}개</Badge>
|
||||
<div className="p-6">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">
|
||||
매핑 정보
|
||||
</h2>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-2 gap-3 mb-6">
|
||||
<div className="bg-green-50 p-3 rounded-lg border border-green-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
<span className="text-sm font-medium text-green-800">유효한 매핑</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">유효한 매핑:</span>
|
||||
<Badge variant="outline" className="text-green-600">
|
||||
<CheckCircle className="mr-1 h-3 w-3" />
|
||||
{stats.validMappings}개
|
||||
</Badge>
|
||||
<div className="text-2xl font-bold text-green-900 mt-1">
|
||||
{mappingStats.validMappings}
|
||||
</div>
|
||||
|
||||
{stats.invalidMappings > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">타입 불일치:</span>
|
||||
<Badge variant="outline" className="text-orange-600">
|
||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||
{stats.invalidMappings}개
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{stats.missingRequiredFields > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">필수 필드 누락:</span>
|
||||
<Badge variant="outline" className="text-red-600">
|
||||
<XCircle className="mr-1 h-3 w-3" />
|
||||
{stats.missingRequiredFields}개
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 액션 정보 */}
|
||||
{stats.totalMappings > 0 && (
|
||||
<div className="space-y-2 border-t pt-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">액션:</span>
|
||||
<Badge variant="secondary">{stats.actionType}</Badge>
|
||||
</div>
|
||||
<div className="bg-red-50 p-3 rounded-lg border border-red-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<XCircle className="w-4 h-4 text-red-600" />
|
||||
<span className="text-sm font-medium text-red-800">오류 매핑</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-red-900 mt-1">
|
||||
{mappingStats.invalidMappings}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stats.estimatedRows > 0 && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">예상 처리량:</span>
|
||||
<span className="font-medium">~{stats.estimatedRows.toLocaleString()} rows</span>
|
||||
<div className="bg-blue-50 p-3 rounded-lg border border-blue-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="w-4 h-4 text-blue-600" />
|
||||
<span className="text-sm font-medium text-blue-800">총 매핑</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-blue-900 mt-1">
|
||||
{mappingStats.totalMappings}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 p-3 rounded-lg border border-yellow-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-yellow-600" />
|
||||
<span className="text-sm font-medium text-yellow-800">누락 필드</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-yellow-900 mt-1">
|
||||
{mappingStats.missingRequiredFields}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 매핑 목록 */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">
|
||||
필드 매핑 목록
|
||||
</h3>
|
||||
|
||||
{fieldMappings.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
<Database className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">아직 매핑이 없습니다</p>
|
||||
<p className="text-xs">3단계에서 필드를 매핑하세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||
{fieldMappings.map((mapping) => (
|
||||
<div
|
||||
key={mapping.id}
|
||||
className={`p-3 rounded-lg border cursor-pointer transition-all duration-200 ${
|
||||
selectedMapping === mapping.id
|
||||
? "border-orange-500 bg-orange-50"
|
||||
: mapping.isValid
|
||||
? "border-green-200 bg-green-50 hover:border-green-300"
|
||||
: "border-red-200 bg-red-50 hover:border-red-300"
|
||||
}`}
|
||||
onClick={() => onMappingSelect(mapping.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-900 truncate">
|
||||
{mapping.fromField.name}
|
||||
</span>
|
||||
<span className="text-gray-400">→</span>
|
||||
<span className="text-sm font-medium text-gray-900 truncate">
|
||||
{mapping.toField.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={`text-xs px-2 py-1 rounded ${
|
||||
mapping.fromField.type === mapping.toField.type
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-yellow-100 text-yellow-800"
|
||||
}`}>
|
||||
{mapping.fromField.type}
|
||||
</span>
|
||||
<span className="text-gray-400">→</span>
|
||||
<span className={`text-xs px-2 py-1 rounded ${
|
||||
mapping.fromField.type === mapping.toField.type
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-yellow-100 text-yellow-800"
|
||||
}`}>
|
||||
{mapping.toField.type}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-2">
|
||||
{mapping.isValid ? (
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="w-4 h-4 text-red-600" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mapping.validationMessage && (
|
||||
<p className="text-xs text-red-600 mt-1">
|
||||
{mapping.validationMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 검증 오류 요약 */}
|
||||
{validationErrors.length > 0 && (
|
||||
<div className="border-t pt-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Info className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-muted-foreground">검증 결과:</span>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
{errorCount > 0 && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
오류 {errorCount}개
|
||||
</Badge>
|
||||
)}
|
||||
{warningCount > 0 && (
|
||||
<Badge variant="outline" className="ml-1 text-xs text-orange-600">
|
||||
경고 {warningCount}개
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 빈 상태 */}
|
||||
{stats.totalMappings === 0 && (
|
||||
<div className="text-muted-foreground py-4 text-center text-sm">
|
||||
<Database className="mx-auto mb-2 h-8 w-8 opacity-50" />
|
||||
<p>아직 매핑된 필드가 없습니다.</p>
|
||||
<p className="mt-1 text-xs">우측에서 연결을 설정해주세요.</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Database 아이콘 import 추가
|
||||
import { Database } from "lucide-react";
|
||||
|
||||
export default MappingInfoPanel;
|
||||
};
|
||||
|
|
@ -1,416 +1,187 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
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 { Badge } from "@/components/ui/badge";
|
||||
import { ArrowRight, Database, Globe, Loader2, AlertTriangle, CheckCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// API import
|
||||
import { getActiveConnections, ConnectionInfo } from "@/lib/api/multiConnection";
|
||||
import { checkRelationshipNameDuplicate } from "@/lib/api/dataflowSave";
|
||||
|
||||
// 타입 import
|
||||
import { Connection } from "@/lib/types/multiConnection";
|
||||
import React, { useState } from "react";
|
||||
import { Database, ArrowRight, CheckCircle } from "lucide-react";
|
||||
import { Connection } from "../types/redesigned";
|
||||
|
||||
interface ConnectionStepProps {
|
||||
connectionType: "data_save" | "external_call";
|
||||
fromConnection?: Connection;
|
||||
toConnection?: Connection;
|
||||
relationshipName?: string;
|
||||
description?: string;
|
||||
diagramId?: number; // 🔧 수정 모드 감지용
|
||||
onSelectConnection: (type: "from" | "to", connection: Connection) => void;
|
||||
onSetRelationshipName: (name: string) => void;
|
||||
onSetDescription: (description: string) => void;
|
||||
onFromConnectionChange: (connection: Connection) => void;
|
||||
onToConnectionChange: (connection: Connection) => void;
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔗 1단계: 연결 선택
|
||||
* - FROM/TO 데이터베이스 연결 선택
|
||||
* - 연결 상태 표시
|
||||
* - 지연시간 정보
|
||||
*/
|
||||
const ConnectionStep: React.FC<ConnectionStepProps> = React.memo(
|
||||
({
|
||||
connectionType,
|
||||
fromConnection,
|
||||
toConnection,
|
||||
relationshipName,
|
||||
description,
|
||||
diagramId,
|
||||
onSelectConnection,
|
||||
onSetRelationshipName,
|
||||
onSetDescription,
|
||||
onNext,
|
||||
}) => {
|
||||
const [connections, setConnections] = useState<Connection[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [nameCheckStatus, setNameCheckStatus] = useState<"idle" | "checking" | "valid" | "duplicate">("idle");
|
||||
|
||||
// API 응답을 Connection 타입으로 변환
|
||||
const convertToConnection = (connectionInfo: ConnectionInfo): Connection => ({
|
||||
id: connectionInfo.id,
|
||||
name: connectionInfo.connection_name,
|
||||
type: connectionInfo.db_type,
|
||||
host: connectionInfo.host,
|
||||
port: connectionInfo.port,
|
||||
database: connectionInfo.database_name,
|
||||
username: connectionInfo.username,
|
||||
isActive: connectionInfo.is_active === "Y",
|
||||
companyCode: connectionInfo.company_code,
|
||||
createdDate: connectionInfo.created_date,
|
||||
updatedDate: connectionInfo.updated_date,
|
||||
});
|
||||
|
||||
// 🔍 관계명 중복 체크 (디바운스 적용)
|
||||
const checkNameDuplicate = useCallback(
|
||||
async (name: string) => {
|
||||
if (!name.trim()) {
|
||||
setNameCheckStatus("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
setNameCheckStatus("checking");
|
||||
|
||||
try {
|
||||
const result = await checkRelationshipNameDuplicate(name, diagramId);
|
||||
setNameCheckStatus(result.isDuplicate ? "duplicate" : "valid");
|
||||
|
||||
if (result.isDuplicate) {
|
||||
toast.warning(`"${name}" 이름이 이미 사용 중입니다. (${result.duplicateCount}개 발견)`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("중복 체크 실패:", error);
|
||||
setNameCheckStatus("idle");
|
||||
}
|
||||
},
|
||||
[diagramId],
|
||||
);
|
||||
|
||||
// 관계명 변경 시 중복 체크 (디바운스)
|
||||
useEffect(() => {
|
||||
if (!relationshipName) {
|
||||
setNameCheckStatus("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
checkNameDuplicate(relationshipName);
|
||||
}, 500); // 500ms 디바운스
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [relationshipName, checkNameDuplicate]);
|
||||
|
||||
// 연결 목록 로드
|
||||
useEffect(() => {
|
||||
const loadConnections = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await getActiveConnections();
|
||||
|
||||
// 메인 DB 연결 추가
|
||||
const mainConnection: Connection = {
|
||||
id: 0,
|
||||
name: "메인 데이터베이스",
|
||||
type: "postgresql",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
database: "main",
|
||||
username: "main_user",
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
// API 응답을 Connection 타입으로 변환
|
||||
const convertedConnections = data.map(convertToConnection);
|
||||
|
||||
// 중복 방지: 기존에 메인 연결이 없는 경우에만 추가
|
||||
const hasMainConnection = convertedConnections.some((conn) => conn.id === 0);
|
||||
const preliminaryConnections = hasMainConnection
|
||||
? convertedConnections
|
||||
: [mainConnection, ...convertedConnections];
|
||||
|
||||
// ID 중복 제거 (Set 사용)
|
||||
const uniqueConnections = preliminaryConnections.filter(
|
||||
(conn, index, arr) => arr.findIndex((c) => c.id === conn.id) === index,
|
||||
);
|
||||
|
||||
console.log("🔗 연결 목록 로드 완료:", uniqueConnections);
|
||||
setConnections(uniqueConnections);
|
||||
} catch (error) {
|
||||
console.error("❌ 연결 목록 로드 실패:", error);
|
||||
toast.error("연결 목록을 불러오는데 실패했습니다.");
|
||||
|
||||
// 에러 시에도 메인 연결은 제공
|
||||
const mainConnection: Connection = {
|
||||
id: 0,
|
||||
name: "메인 데이터베이스",
|
||||
type: "postgresql",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
database: "main",
|
||||
username: "main_user",
|
||||
isActive: true,
|
||||
};
|
||||
setConnections([mainConnection]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadConnections();
|
||||
}, []);
|
||||
|
||||
const handleConnectionSelect = (type: "from" | "to", connectionId: string) => {
|
||||
const connection = connections.find((c) => c.id.toString() === connectionId);
|
||||
if (connection) {
|
||||
onSelectConnection(type, connection);
|
||||
}
|
||||
};
|
||||
|
||||
const canProceed = fromConnection && toConnection;
|
||||
|
||||
const getConnectionIcon = (connection: Connection) => {
|
||||
return connection.id === 0 ? <Database className="h-4 w-4" /> : <Globe className="h-4 w-4" />;
|
||||
};
|
||||
|
||||
const getConnectionBadge = (connection: Connection) => {
|
||||
if (connection.id === 0) {
|
||||
return (
|
||||
<Badge variant="default" className="text-xs">
|
||||
메인 DB
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{connection.type?.toUpperCase()}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database className="h-5 w-5" />
|
||||
1단계: 연결 선택
|
||||
</CardTitle>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{connectionType === "data_save"
|
||||
? "데이터를 저장할 소스와 대상 데이터베이스를 선택하세요."
|
||||
: "외부 호출을 위한 소스와 대상 연결을 선택하세요."}
|
||||
</p>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="max-h-[calc(100vh-400px)] min-h-[400px] space-y-6 overflow-y-auto">
|
||||
{/* 관계 정보 입력 */}
|
||||
<div className="bg-muted/30 space-y-4 rounded-lg border p-4">
|
||||
<h3 className="font-medium">관계 정보</h3>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="relationshipName">관계 이름 *</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="relationshipName"
|
||||
placeholder="예: 사용자 데이터 동기화"
|
||||
value={relationshipName || ""}
|
||||
onChange={(e) => onSetRelationshipName(e.target.value)}
|
||||
className={`pr-10 ${
|
||||
nameCheckStatus === "duplicate"
|
||||
? "border-red-500 focus:border-red-500"
|
||||
: nameCheckStatus === "valid"
|
||||
? "border-green-500 focus:border-green-500"
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
<div className="absolute top-1/2 right-3 -translate-y-1/2">
|
||||
{nameCheckStatus === "checking" && (
|
||||
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{nameCheckStatus === "valid" && <CheckCircle className="h-4 w-4 text-green-500" />}
|
||||
{nameCheckStatus === "duplicate" && <AlertTriangle className="h-4 w-4 text-red-500" />}
|
||||
</div>
|
||||
</div>
|
||||
{nameCheckStatus === "duplicate" && <p className="text-sm text-red-600">이미 사용 중인 이름입니다.</p>}
|
||||
{nameCheckStatus === "valid" && <p className="text-sm text-green-600">사용 가능한 이름입니다.</p>}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="이 관계에 대한 설명을 입력하세요"
|
||||
value={description || ""}
|
||||
onChange={(e) => onSetDescription(e.target.value)}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="mr-2 h-6 w-6 animate-spin" />
|
||||
<span>연결 목록을 불러오는 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* FROM 연결 선택 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium">FROM 연결 (소스)</h3>
|
||||
{fromConnection && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-green-600">
|
||||
🟢 연결됨
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">지연시간: ~23ms</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={fromConnection?.id.toString() || ""}
|
||||
onValueChange={(value) => handleConnectionSelect("from", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="소스 연결을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{connections.length === 0 ? (
|
||||
<div className="text-muted-foreground p-4 text-center">연결 정보가 없습니다.</div>
|
||||
) : (
|
||||
connections.map((connection, index) => (
|
||||
<SelectItem key={`from_${connection.id}_${index}`} value={connection.id.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
{getConnectionIcon(connection)}
|
||||
<span>{connection.name}</span>
|
||||
{getConnectionBadge(connection)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{fromConnection && (
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
{getConnectionIcon(fromConnection)}
|
||||
<span className="font-medium">{fromConnection.name}</span>
|
||||
{getConnectionBadge(fromConnection)}
|
||||
</div>
|
||||
<div className="text-muted-foreground space-y-1 text-xs">
|
||||
<p>
|
||||
호스트: {fromConnection.host}:{fromConnection.port}
|
||||
</p>
|
||||
<p>데이터베이스: {fromConnection.database}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* TO 연결 선택 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium">TO 연결 (대상)</h3>
|
||||
{toConnection && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-green-600">
|
||||
🟢 연결됨
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">지연시간: ~45ms</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={toConnection?.id.toString() || ""}
|
||||
onValueChange={(value) => handleConnectionSelect("to", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="대상 연결을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{connections.length === 0 ? (
|
||||
<div className="text-muted-foreground p-4 text-center">연결 정보가 없습니다.</div>
|
||||
) : (
|
||||
connections.map((connection, index) => (
|
||||
<SelectItem key={`to_${connection.id}_${index}`} value={connection.id.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
{getConnectionIcon(connection)}
|
||||
<span>{connection.name}</span>
|
||||
{getConnectionBadge(connection)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{toConnection && (
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
{getConnectionIcon(toConnection)}
|
||||
<span className="font-medium">{toConnection.name}</span>
|
||||
{getConnectionBadge(toConnection)}
|
||||
</div>
|
||||
<div className="text-muted-foreground space-y-1 text-xs">
|
||||
<p>
|
||||
호스트: {toConnection.host}:{toConnection.port}
|
||||
</p>
|
||||
<p>데이터베이스: {toConnection.database}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 연결 매핑 표시 */}
|
||||
{fromConnection && toConnection && (
|
||||
<div className="bg-primary/5 border-primary/20 rounded-lg border p-4">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<div className="text-center">
|
||||
<div className="font-medium">{fromConnection.name}</div>
|
||||
<div className="text-muted-foreground text-xs">소스</div>
|
||||
</div>
|
||||
|
||||
<ArrowRight className="text-primary h-5 w-5" />
|
||||
|
||||
<div className="text-center">
|
||||
<div className="font-medium">{toConnection.name}</div>
|
||||
<div className="text-muted-foreground text-xs">대상</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-center">
|
||||
<Badge variant="outline" className="text-primary">
|
||||
💡 연결 매핑 설정 완료
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 다음 단계 버튼 */}
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button onClick={onNext} disabled={!canProceed} className="flex items-center gap-2">
|
||||
다음: 테이블 선택
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</>
|
||||
);
|
||||
// 임시 연결 데이터 (실제로는 API에서 가져올 것)
|
||||
const mockConnections: Connection[] = [
|
||||
{
|
||||
id: "conn1",
|
||||
name: "메인 데이터베이스",
|
||||
type: "PostgreSQL",
|
||||
host: "localhost",
|
||||
port: 5432,
|
||||
database: "main_db",
|
||||
username: "admin",
|
||||
tables: []
|
||||
},
|
||||
);
|
||||
{
|
||||
id: "conn2",
|
||||
name: "외부 API",
|
||||
type: "REST API",
|
||||
host: "api.example.com",
|
||||
port: 443,
|
||||
database: "external",
|
||||
username: "api_user",
|
||||
tables: []
|
||||
},
|
||||
{
|
||||
id: "conn3",
|
||||
name: "백업 데이터베이스",
|
||||
type: "MySQL",
|
||||
host: "backup.local",
|
||||
port: 3306,
|
||||
database: "backup_db",
|
||||
username: "backup_user",
|
||||
tables: []
|
||||
}
|
||||
];
|
||||
|
||||
ConnectionStep.displayName = "ConnectionStep";
|
||||
export const ConnectionStep: React.FC<ConnectionStepProps> = ({
|
||||
fromConnection,
|
||||
toConnection,
|
||||
onFromConnectionChange,
|
||||
onToConnectionChange,
|
||||
onNext,
|
||||
}) => {
|
||||
const [selectedFrom, setSelectedFrom] = useState<string>(fromConnection?.id || "");
|
||||
const [selectedTo, setSelectedTo] = useState<string>(toConnection?.id || "");
|
||||
|
||||
export default ConnectionStep;
|
||||
const handleFromSelect = (connectionId: string) => {
|
||||
const connection = mockConnections.find(c => c.id === connectionId);
|
||||
if (connection) {
|
||||
setSelectedFrom(connectionId);
|
||||
onFromConnectionChange(connection);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToSelect = (connectionId: string) => {
|
||||
const connection = mockConnections.find(c => c.id === connectionId);
|
||||
if (connection) {
|
||||
setSelectedTo(connectionId);
|
||||
onToConnectionChange(connection);
|
||||
}
|
||||
};
|
||||
|
||||
const canProceed = selectedFrom && selectedTo && selectedFrom !== selectedTo;
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
연결 선택
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
데이터를 가져올 연결과 저장할 연결을 선택하세요
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* FROM 연결 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-blue-600 font-bold">1</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">FROM 연결</h3>
|
||||
<span className="text-sm text-gray-500">(데이터 소스)</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{mockConnections.map((connection) => (
|
||||
<div
|
||||
key={connection.id}
|
||||
className={`p-4 rounded-lg border-2 cursor-pointer transition-all duration-200 ${
|
||||
selectedFrom === connection.id
|
||||
? "border-blue-500 bg-blue-50 shadow-md"
|
||||
: "border-gray-200 bg-white hover:border-blue-300 hover:bg-blue-25"
|
||||
}`}
|
||||
onClick={() => handleFromSelect(connection.id)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Database className="w-6 h-6 text-blue-600" />
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-gray-900">{connection.name}</h4>
|
||||
<p className="text-sm text-gray-600">{connection.type}</p>
|
||||
<p className="text-xs text-gray-500">{connection.host}:{connection.port}</p>
|
||||
</div>
|
||||
{selectedFrom === connection.id && (
|
||||
<CheckCircle className="w-5 h-5 text-blue-600" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 화살표 */}
|
||||
<div className="hidden lg:flex items-center justify-center">
|
||||
<div className="w-12 h-12 bg-orange-100 rounded-full flex items-center justify-center">
|
||||
<ArrowRight className="w-6 h-6 text-orange-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TO 연결 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-green-600 font-bold">2</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">TO 연결</h3>
|
||||
<span className="text-sm text-gray-500">(데이터 대상)</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{mockConnections.map((connection) => (
|
||||
<div
|
||||
key={connection.id}
|
||||
className={`p-4 rounded-lg border-2 cursor-pointer transition-all duration-200 ${
|
||||
selectedTo === connection.id
|
||||
? "border-green-500 bg-green-50 shadow-md"
|
||||
: "border-gray-200 bg-white hover:border-green-300 hover:bg-green-25"
|
||||
}`}
|
||||
onClick={() => handleToSelect(connection.id)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Database className="w-6 h-6 text-green-600" />
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-gray-900">{connection.name}</h4>
|
||||
<p className="text-sm text-gray-600">{connection.type}</p>
|
||||
<p className="text-xs text-gray-500">{connection.host}:{connection.port}</p>
|
||||
</div>
|
||||
{selectedTo === connection.id && (
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 다음 버튼 */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={!canProceed}
|
||||
className={`px-6 py-3 rounded-lg font-medium transition-all duration-200 ${
|
||||
canProceed
|
||||
? "bg-orange-500 text-white hover:bg-orange-600 shadow-md hover:shadow-lg"
|
||||
: "bg-gray-300 text-gray-500 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
다음 단계: 테이블 선택
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,199 +1,232 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft, Link, Loader2, CheckCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// API import
|
||||
import { getColumnsFromConnection } from "@/lib/api/multiConnection";
|
||||
|
||||
// 타입 import
|
||||
import { Connection, TableInfo, ColumnInfo } from "@/lib/types/multiConnection";
|
||||
import { FieldMapping } from "../types/redesigned";
|
||||
|
||||
// 컴포넌트 import
|
||||
import FieldMappingCanvas from "./VisualMapping/FieldMappingCanvas";
|
||||
import React, { useState } from "react";
|
||||
import { ArrowLeft, Save, CheckCircle, XCircle, AlertCircle } from "lucide-react";
|
||||
import { TableInfo, FieldMapping, ColumnInfo } from "../types/redesigned";
|
||||
|
||||
interface FieldMappingStepProps {
|
||||
fromTable?: TableInfo;
|
||||
toTable?: TableInfo;
|
||||
fromConnection?: Connection;
|
||||
toConnection?: Connection;
|
||||
fieldMappings: FieldMapping[];
|
||||
onCreateMapping: (fromField: ColumnInfo, toField: ColumnInfo) => void;
|
||||
onDeleteMapping: (mappingId: string) => void;
|
||||
onNext: () => void;
|
||||
onMappingsChange: (mappings: FieldMapping[]) => void;
|
||||
onBack: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎯 3단계: 시각적 필드 매핑
|
||||
* - SVG 기반 연결선 표시
|
||||
* - 드래그 앤 드롭 지원 (향후)
|
||||
* - 실시간 매핑 업데이트
|
||||
*/
|
||||
const FieldMappingStep: React.FC<FieldMappingStepProps> = ({
|
||||
export const FieldMappingStep: React.FC<FieldMappingStepProps> = ({
|
||||
fromTable,
|
||||
toTable,
|
||||
fromConnection,
|
||||
toConnection,
|
||||
fieldMappings,
|
||||
onCreateMapping,
|
||||
onDeleteMapping,
|
||||
onNext,
|
||||
onMappingsChange,
|
||||
onBack,
|
||||
onSave,
|
||||
}) => {
|
||||
const [fromColumns, setFromColumns] = useState<ColumnInfo[]>([]);
|
||||
const [toColumns, setToColumns] = useState<ColumnInfo[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [draggedField, setDraggedField] = useState<ColumnInfo | null>(null);
|
||||
|
||||
// 컬럼 정보 로드
|
||||
useEffect(() => {
|
||||
const loadColumns = async () => {
|
||||
console.log("🔍 컬럼 로딩 시작:", {
|
||||
fromConnection: fromConnection?.id,
|
||||
toConnection: toConnection?.id,
|
||||
fromTable: fromTable?.tableName,
|
||||
toTable: toTable?.tableName,
|
||||
});
|
||||
|
||||
if (!fromConnection || !toConnection || !fromTable || !toTable) {
|
||||
console.warn("⚠️ 필수 정보 누락:", {
|
||||
fromConnection: !!fromConnection,
|
||||
toConnection: !!toConnection,
|
||||
fromTable: !!fromTable,
|
||||
toTable: !!toTable,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
console.log("📡 API 호출 시작:", {
|
||||
fromAPI: `getColumnsFromConnection(${fromConnection.id}, "${fromTable.tableName}")`,
|
||||
toAPI: `getColumnsFromConnection(${toConnection.id}, "${toTable.tableName}")`,
|
||||
});
|
||||
|
||||
const [fromCols, toCols] = await Promise.all([
|
||||
getColumnsFromConnection(fromConnection.id, fromTable.tableName),
|
||||
getColumnsFromConnection(toConnection.id, toTable.tableName),
|
||||
]);
|
||||
|
||||
console.log("🔍 원본 API 응답 확인:", {
|
||||
fromCols: fromCols,
|
||||
toCols: toCols,
|
||||
fromType: typeof fromCols,
|
||||
toType: typeof toCols,
|
||||
fromIsArray: Array.isArray(fromCols),
|
||||
toIsArray: Array.isArray(toCols),
|
||||
});
|
||||
|
||||
// 안전한 배열 처리
|
||||
const safeFromCols = Array.isArray(fromCols) ? fromCols : [];
|
||||
const safeToCols = Array.isArray(toCols) ? toCols : [];
|
||||
|
||||
console.log("✅ 컬럼 로딩 성공:", {
|
||||
fromColumns: safeFromCols.length,
|
||||
toColumns: safeToCols.length,
|
||||
fromData: safeFromCols.slice(0, 2), // 처음 2개만 로깅
|
||||
toData: safeToCols.slice(0, 2),
|
||||
originalFromType: typeof fromCols,
|
||||
originalToType: typeof toCols,
|
||||
});
|
||||
|
||||
setFromColumns(safeFromCols);
|
||||
setToColumns(safeToCols);
|
||||
} catch (error) {
|
||||
console.error("❌ 컬럼 정보 로드 실패:", error);
|
||||
toast.error("필드 정보를 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
const createMapping = (fromField: ColumnInfo, toField: ColumnInfo) => {
|
||||
const mapping: FieldMapping = {
|
||||
id: `${fromField.name}-${toField.name}`,
|
||||
fromField,
|
||||
toField,
|
||||
isValid: fromField.type === toField.type,
|
||||
validationMessage: fromField.type !== toField.type ? "타입이 다릅니다" : undefined
|
||||
};
|
||||
|
||||
loadColumns();
|
||||
}, [fromConnection, toConnection, fromTable, toTable]);
|
||||
const newMappings = [...fieldMappings, mapping];
|
||||
onMappingsChange(newMappings);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<CardContent className="flex items-center justify-center py-12">
|
||||
<Loader2 className="mr-2 h-6 w-6 animate-spin" />
|
||||
<span>필드 정보를 불러오는 중...</span>
|
||||
</CardContent>
|
||||
);
|
||||
}
|
||||
const removeMapping = (mappingId: string) => {
|
||||
const newMappings = fieldMappings.filter(m => m.id !== mappingId);
|
||||
onMappingsChange(newMappings);
|
||||
};
|
||||
|
||||
const handleDragStart = (field: ColumnInfo) => {
|
||||
setDraggedField(field);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent, toField: ColumnInfo) => {
|
||||
e.preventDefault();
|
||||
if (draggedField) {
|
||||
createMapping(draggedField, toField);
|
||||
setDraggedField(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getMappedFromField = (toFieldName: string) => {
|
||||
return fieldMappings.find(m => m.toField.name === toFieldName)?.fromField;
|
||||
};
|
||||
|
||||
const isFieldMapped = (fieldName: string) => {
|
||||
return fieldMappings.some(m => m.fromField.name === fieldName || m.toField.name === fieldName);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Link className="h-5 w-5" />
|
||||
3단계: 컬럼 매핑
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<div className="space-y-8">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
필드 매핑
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
소스 테이블의 필드를 대상 테이블의 필드에 드래그하여 매핑하세요
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CardContent className="flex h-full flex-col p-0">
|
||||
{/* 매핑 캔버스 - 전체 영역 사용 */}
|
||||
<div className="min-h-0 flex-1 p-4">
|
||||
{isLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-muted-foreground">컬럼 정보를 불러오는 중...</div>
|
||||
</div>
|
||||
) : fromColumns.length > 0 && toColumns.length > 0 ? (
|
||||
<FieldMappingCanvas
|
||||
fromFields={fromColumns}
|
||||
toFields={toColumns}
|
||||
mappings={fieldMappings}
|
||||
onCreateMapping={onCreateMapping}
|
||||
onDeleteMapping={onDeleteMapping}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-3">
|
||||
<div className="text-muted-foreground">컬럼 정보를 찾을 수 없습니다.</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
FROM 컬럼: {fromColumns.length}개, TO 컬럼: {toColumns.length}개
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
console.log("🔄 수동 재로딩 시도");
|
||||
setFromColumns([]);
|
||||
setToColumns([]);
|
||||
// useEffect가 재실행되도록 강제 업데이트
|
||||
setIsLoading(true);
|
||||
setTimeout(() => setIsLoading(false), 100);
|
||||
}}
|
||||
>
|
||||
다시 시도
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{/* 매핑 통계 */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="bg-blue-50 p-4 rounded-lg border border-blue-200">
|
||||
<div className="text-2xl font-bold text-blue-900">{fieldMappings.length}</div>
|
||||
<div className="text-sm text-blue-700">총 매핑</div>
|
||||
</div>
|
||||
<div className="bg-green-50 p-4 rounded-lg border border-green-200">
|
||||
<div className="text-2xl font-bold text-green-900">
|
||||
{fieldMappings.filter(m => m.isValid).length}
|
||||
</div>
|
||||
<div className="text-sm text-green-700">유효한 매핑</div>
|
||||
</div>
|
||||
<div className="bg-red-50 p-4 rounded-lg border border-red-200">
|
||||
<div className="text-2xl font-bold text-red-900">
|
||||
{fieldMappings.filter(m => !m.isValid).length}
|
||||
</div>
|
||||
<div className="text-sm text-red-700">오류 매핑</div>
|
||||
</div>
|
||||
<div className="bg-yellow-50 p-4 rounded-lg border border-yellow-200">
|
||||
<div className="text-2xl font-bold text-yellow-900">
|
||||
{(toTable?.columns.length || 0) - fieldMappings.length}
|
||||
</div>
|
||||
<div className="text-sm text-yellow-700">미매핑 필드</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하단 네비게이션 - 고정 */}
|
||||
<div className="flex-shrink-0 border-t bg-white p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="outline" onClick={onBack} className="flex items-center gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
이전
|
||||
</Button>
|
||||
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{fieldMappings.length > 0 ? `${fieldMappings.length}개 매핑 완료` : "컬럼을 선택해서 매핑하세요"}
|
||||
{/* 매핑 영역 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* FROM 테이블 필드들 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<div className="w-6 h-6 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-blue-600 font-bold text-sm">FROM</span>
|
||||
</div>
|
||||
|
||||
<Button onClick={onNext} disabled={fieldMappings.length === 0} className="flex items-center gap-2">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
저장
|
||||
</Button>
|
||||
{fromTable?.name} 필드들
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
{fromTable?.columns.map((field) => (
|
||||
<div
|
||||
key={field.name}
|
||||
draggable
|
||||
onDragStart={() => handleDragStart(field)}
|
||||
className={`p-3 rounded-lg border-2 cursor-move transition-all duration-200 ${
|
||||
isFieldMapped(field.name)
|
||||
? "border-green-300 bg-green-50 opacity-60"
|
||||
: "border-blue-200 bg-blue-50 hover:border-blue-400 hover:bg-blue-100"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{field.name}</div>
|
||||
<div className="text-sm text-gray-600">{field.type}</div>
|
||||
{field.primaryKey && (
|
||||
<span className="text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded">
|
||||
PK
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isFieldMapped(field.name) && (
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldMappingStep;
|
||||
{/* TO 테이블 필드들 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<div className="w-6 h-6 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-green-600 font-bold text-sm">TO</span>
|
||||
</div>
|
||||
{toTable?.name} 필드들
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
{toTable?.columns.map((field) => {
|
||||
const mappedFromField = getMappedFromField(field.name);
|
||||
return (
|
||||
<div
|
||||
key={field.name}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={(e) => handleDrop(e, field)}
|
||||
className={`p-3 rounded-lg border-2 transition-all duration-200 ${
|
||||
mappedFromField
|
||||
? "border-green-300 bg-green-50"
|
||||
: "border-gray-200 bg-gray-50 hover:border-green-300 hover:bg-green-25"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{field.name}</div>
|
||||
<div className="text-sm text-gray-600">{field.type}</div>
|
||||
{field.primaryKey && (
|
||||
<span className="text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded">
|
||||
PK
|
||||
</span>
|
||||
)}
|
||||
{mappedFromField && (
|
||||
<div className="text-xs text-green-700 mt-1">
|
||||
← {mappedFromField.name} ({mappedFromField.type})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{mappedFromField && (
|
||||
<button
|
||||
onClick={() => removeMapping(`${mappedFromField.name}-${field.name}`)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<XCircle className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{mappedFromField && (
|
||||
<div>
|
||||
{fieldMappings.find(m => m.toField.name === field.name)?.isValid ? (
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
) : (
|
||||
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 버튼들 */}
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 px-6 py-3 rounded-lg font-medium text-gray-600 bg-gray-100 hover:bg-gray-200 transition-all duration-200"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
이전 단계
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onSave}
|
||||
className="flex items-center gap-2 px-6 py-3 rounded-lg font-medium bg-orange-500 text-white hover:bg-orange-600 shadow-md hover:shadow-lg transition-all duration-200"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
연결 설정 저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,90 +1,70 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { CheckCircle, Circle, ArrowRight } from "lucide-react";
|
||||
import { Check, ArrowRight } from "lucide-react";
|
||||
|
||||
// 타입 import
|
||||
import { StepProgressProps } from "../types/redesigned";
|
||||
interface StepProgressProps {
|
||||
currentStep: 1 | 2 | 3;
|
||||
onStepChange: (step: 1 | 2 | 3) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 📊 단계 진행 표시
|
||||
* - 현재 단계 하이라이트
|
||||
* - 완료된 단계 체크 표시
|
||||
* - 클릭으로 단계 이동
|
||||
*/
|
||||
const StepProgress: React.FC<StepProgressProps> = ({ currentStep, completedSteps, onStepClick }) => {
|
||||
const steps = [
|
||||
{ number: 1, title: "연결 선택", description: "FROM/TO 데이터베이스 연결" },
|
||||
{ number: 2, title: "테이블 선택", description: "소스/대상 테이블 선택" },
|
||||
{ number: 3, title: "제어 조건", description: "전체 제어 실행 조건 설정" },
|
||||
{ number: 4, title: "액션 및 매핑", description: "액션 설정 및 컬럼 매핑" },
|
||||
];
|
||||
|
||||
const getStepStatus = (stepNumber: number) => {
|
||||
if (completedSteps.includes(stepNumber)) return "completed";
|
||||
if (stepNumber === currentStep) return "current";
|
||||
return "pending";
|
||||
};
|
||||
|
||||
const getStepIcon = (stepNumber: number) => {
|
||||
const status = getStepStatus(stepNumber);
|
||||
|
||||
if (status === "completed") {
|
||||
return <CheckCircle className="h-5 w-5 text-green-600" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Circle className={`h-5 w-5 ${status === "current" ? "text-primary fill-primary" : "text-muted-foreground"}`} />
|
||||
);
|
||||
};
|
||||
|
||||
const canClickStep = (stepNumber: number) => {
|
||||
// 현재 단계이거나 완료된 단계만 클릭 가능
|
||||
return stepNumber === currentStep || completedSteps.includes(stepNumber);
|
||||
};
|
||||
const steps = [
|
||||
{ id: 1, title: "연결 선택", description: "FROM/TO 연결 설정" },
|
||||
{ id: 2, title: "테이블 선택", description: "소스/타겟 테이블 선택" },
|
||||
{ id: 3, title: "필드 매핑", description: "시각적 필드 매핑" },
|
||||
];
|
||||
|
||||
export const StepProgress: React.FC<StepProgressProps> = ({
|
||||
currentStep,
|
||||
onStepChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="mx-auto flex max-w-4xl items-center justify-between">
|
||||
{steps.map((step, index) => (
|
||||
<React.Fragment key={step.number}>
|
||||
{/* 단계 */}
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={`flex h-auto items-center gap-3 p-3 ${
|
||||
canClickStep(step.number) ? "hover:bg-muted/50 cursor-pointer" : "cursor-default"
|
||||
<div className="bg-white border-b border-gray-200 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
{steps.map((step, index) => (
|
||||
<React.Fragment key={step.id}>
|
||||
<div
|
||||
className={`flex items-center gap-3 cursor-pointer transition-all duration-200 ${
|
||||
step.id <= currentStep ? "opacity-100" : "opacity-50"
|
||||
}`}
|
||||
onClick={() => canClickStep(step.number) && onStepClick(step.number as 1 | 2 | 3 | 4 | 5)}
|
||||
disabled={!canClickStep(step.number)}
|
||||
onClick={() => step.id <= currentStep && onStepChange(step.id as 1 | 2 | 3)}
|
||||
>
|
||||
{/* 아이콘 */}
|
||||
<div className="flex-shrink-0">{getStepIcon(step.number)}</div>
|
||||
|
||||
{/* 텍스트 */}
|
||||
<div className="text-left">
|
||||
<div
|
||||
className={`text-sm font-medium ${
|
||||
getStepStatus(step.number) === "current"
|
||||
? "text-primary"
|
||||
: getStepStatus(step.number) === "completed"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{step.title}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">{step.description}</div>
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-all duration-200 ${
|
||||
step.id < currentStep
|
||||
? "bg-green-500 text-white"
|
||||
: step.id === currentStep
|
||||
? "bg-orange-500 text-white"
|
||||
: "bg-gray-200 text-gray-600"
|
||||
}`}
|
||||
>
|
||||
{step.id < currentStep ? (
|
||||
<Check className="w-4 h-4" />
|
||||
) : (
|
||||
step.id
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 화살표 (마지막 단계 제외) */}
|
||||
{index < steps.length - 1 && <ArrowRight className="text-muted-foreground mx-2 h-4 w-4" />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
<div>
|
||||
<h3 className={`text-sm font-medium ${
|
||||
step.id <= currentStep ? "text-gray-900" : "text-gray-500"
|
||||
}`}>
|
||||
{step.title}
|
||||
</h3>
|
||||
<p className={`text-xs ${
|
||||
step.id <= currentStep ? "text-gray-600" : "text-gray-400"
|
||||
}`}>
|
||||
{step.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{index < steps.length - 1 && (
|
||||
<ArrowRight className="w-4 h-4 text-gray-400 mx-4" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StepProgress;
|
||||
};
|
||||
|
|
@ -1,343 +1,212 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ArrowLeft, ArrowRight, Table, Search, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// API import
|
||||
import { getTablesFromConnection, getBatchTablesWithColumns } from "@/lib/api/multiConnection";
|
||||
|
||||
// 타입 import
|
||||
import { Connection, TableInfo } from "@/lib/types/multiConnection";
|
||||
import React, { useState } from "react";
|
||||
import { Table, ArrowLeft, ArrowRight, CheckCircle, Database } from "lucide-react";
|
||||
import { Connection, TableInfo } from "../types/redesigned";
|
||||
|
||||
interface TableStepProps {
|
||||
fromConnection?: Connection;
|
||||
toConnection?: Connection;
|
||||
fromTable?: TableInfo;
|
||||
toTable?: TableInfo;
|
||||
onSelectTable: (type: "from" | "to", table: TableInfo) => void;
|
||||
onFromTableChange: (table: TableInfo) => void;
|
||||
onToTableChange: (table: TableInfo) => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 📋 2단계: 테이블 선택
|
||||
* - FROM/TO 테이블 선택
|
||||
* - 테이블 검색 기능
|
||||
* - 컬럼 수 정보 표시
|
||||
*/
|
||||
const TableStep: React.FC<TableStepProps> = ({
|
||||
// 임시 테이블 데이터
|
||||
const mockTables: TableInfo[] = [
|
||||
{
|
||||
name: "users",
|
||||
schema: "public",
|
||||
columns: [
|
||||
{ name: "id", type: "integer", nullable: false, primaryKey: true },
|
||||
{ name: "name", type: "varchar", nullable: false, primaryKey: false },
|
||||
{ name: "email", type: "varchar", nullable: true, primaryKey: false },
|
||||
{ name: "created_at", type: "timestamp", nullable: false, primaryKey: false }
|
||||
],
|
||||
rowCount: 1250
|
||||
},
|
||||
{
|
||||
name: "orders",
|
||||
schema: "public",
|
||||
columns: [
|
||||
{ name: "id", type: "integer", nullable: false, primaryKey: true },
|
||||
{ name: "user_id", type: "integer", nullable: false, primaryKey: false, foreignKey: true },
|
||||
{ name: "product_name", type: "varchar", nullable: false, primaryKey: false },
|
||||
{ name: "amount", type: "decimal", nullable: false, primaryKey: false },
|
||||
{ name: "order_date", type: "timestamp", nullable: false, primaryKey: false }
|
||||
],
|
||||
rowCount: 3420
|
||||
},
|
||||
{
|
||||
name: "products",
|
||||
schema: "public",
|
||||
columns: [
|
||||
{ name: "id", type: "integer", nullable: false, primaryKey: true },
|
||||
{ name: "name", type: "varchar", nullable: false, primaryKey: false },
|
||||
{ name: "price", type: "decimal", nullable: false, primaryKey: false },
|
||||
{ name: "category", type: "varchar", nullable: true, primaryKey: false }
|
||||
],
|
||||
rowCount: 156
|
||||
}
|
||||
];
|
||||
|
||||
export const TableStep: React.FC<TableStepProps> = ({
|
||||
fromConnection,
|
||||
toConnection,
|
||||
fromTable,
|
||||
toTable,
|
||||
onSelectTable,
|
||||
onFromTableChange,
|
||||
onToTableChange,
|
||||
onNext,
|
||||
onBack,
|
||||
}) => {
|
||||
const [fromTables, setFromTables] = useState<TableInfo[]>([]);
|
||||
const [toTables, setToTables] = useState<TableInfo[]>([]);
|
||||
const [fromSearch, setFromSearch] = useState("");
|
||||
const [toSearch, setToSearch] = useState("");
|
||||
const [isLoadingFrom, setIsLoadingFrom] = useState(false);
|
||||
const [isLoadingTo, setIsLoadingTo] = useState(false);
|
||||
const [tableColumnCounts, setTableColumnCounts] = useState<Record<string, number>>({});
|
||||
const [selectedFromTable, setSelectedFromTable] = useState<string>(fromTable?.name || "");
|
||||
const [selectedToTable, setSelectedToTable] = useState<string>(toTable?.name || "");
|
||||
|
||||
// FROM 테이블 목록 로드 (배치 조회)
|
||||
useEffect(() => {
|
||||
if (fromConnection) {
|
||||
const loadFromTables = async () => {
|
||||
try {
|
||||
setIsLoadingFrom(true);
|
||||
console.log("🚀 FROM 테이블 배치 조회 시작");
|
||||
|
||||
// 배치 조회로 테이블 정보와 컬럼 수를 한번에 가져오기
|
||||
const batchResult = await getBatchTablesWithColumns(fromConnection.id);
|
||||
|
||||
console.log("✅ FROM 테이블 배치 조회 완료:", batchResult);
|
||||
|
||||
// TableInfo 형식으로 변환
|
||||
const tables: TableInfo[] = batchResult.map((item) => ({
|
||||
tableName: item.tableName,
|
||||
displayName: item.displayName || item.tableName,
|
||||
}));
|
||||
|
||||
setFromTables(tables);
|
||||
|
||||
// 컬럼 수 정보를 state에 저장
|
||||
const columnCounts: Record<string, number> = {};
|
||||
batchResult.forEach((item) => {
|
||||
columnCounts[`from_${item.tableName}`] = item.columnCount;
|
||||
});
|
||||
|
||||
setTableColumnCounts((prev) => ({
|
||||
...prev,
|
||||
...columnCounts,
|
||||
}));
|
||||
|
||||
console.log(`📊 FROM 테이블 ${tables.length}개 로드 완료, 컬럼 수:`, columnCounts);
|
||||
} catch (error) {
|
||||
console.error("FROM 테이블 목록 로드 실패:", error);
|
||||
toast.error("소스 테이블 목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setIsLoadingFrom(false);
|
||||
}
|
||||
};
|
||||
loadFromTables();
|
||||
}
|
||||
}, [fromConnection]);
|
||||
|
||||
// TO 테이블 목록 로드 (배치 조회)
|
||||
useEffect(() => {
|
||||
if (toConnection) {
|
||||
const loadToTables = async () => {
|
||||
try {
|
||||
setIsLoadingTo(true);
|
||||
console.log("🚀 TO 테이블 배치 조회 시작");
|
||||
|
||||
// 배치 조회로 테이블 정보와 컬럼 수를 한번에 가져오기
|
||||
const batchResult = await getBatchTablesWithColumns(toConnection.id);
|
||||
|
||||
console.log("✅ TO 테이블 배치 조회 완료:", batchResult);
|
||||
|
||||
// TableInfo 형식으로 변환
|
||||
const tables: TableInfo[] = batchResult.map((item) => ({
|
||||
tableName: item.tableName,
|
||||
displayName: item.displayName || item.tableName,
|
||||
}));
|
||||
|
||||
setToTables(tables);
|
||||
|
||||
// 컬럼 수 정보를 state에 저장
|
||||
const columnCounts: Record<string, number> = {};
|
||||
batchResult.forEach((item) => {
|
||||
columnCounts[`to_${item.tableName}`] = item.columnCount;
|
||||
});
|
||||
|
||||
setTableColumnCounts((prev) => ({
|
||||
...prev,
|
||||
...columnCounts,
|
||||
}));
|
||||
|
||||
console.log(`📊 TO 테이블 ${tables.length}개 로드 완료, 컬럼 수:`, columnCounts);
|
||||
} catch (error) {
|
||||
console.error("TO 테이블 목록 로드 실패:", error);
|
||||
toast.error("대상 테이블 목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setIsLoadingTo(false);
|
||||
}
|
||||
};
|
||||
loadToTables();
|
||||
}
|
||||
}, [toConnection]);
|
||||
|
||||
// 테이블 필터링
|
||||
const filteredFromTables = fromTables.filter((table) =>
|
||||
(table.displayName || table.tableName).toLowerCase().includes(fromSearch.toLowerCase()),
|
||||
);
|
||||
|
||||
const filteredToTables = toTables.filter((table) =>
|
||||
(table.displayName || table.tableName).toLowerCase().includes(toSearch.toLowerCase()),
|
||||
);
|
||||
|
||||
const handleTableSelect = (type: "from" | "to", tableName: string) => {
|
||||
const tables = type === "from" ? fromTables : toTables;
|
||||
const table = tables.find((t) => t.tableName === tableName);
|
||||
const handleFromTableSelect = (tableName: string) => {
|
||||
const table = mockTables.find(t => t.name === tableName);
|
||||
if (table) {
|
||||
onSelectTable(type, table);
|
||||
setSelectedFromTable(tableName);
|
||||
onFromTableChange(table);
|
||||
}
|
||||
};
|
||||
|
||||
const canProceed = fromTable && toTable;
|
||||
|
||||
const renderTableItem = (table: TableInfo, type: "from" | "to") => {
|
||||
const displayName =
|
||||
table.displayName && table.displayName !== table.tableName ? table.displayName : table.tableName;
|
||||
|
||||
const columnCount = tableColumnCounts[`${type}_${table.tableName}`];
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Table className="h-4 w-4" />
|
||||
<span>{displayName}</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{columnCount !== undefined ? columnCount : table.columnCount || 0}개 컬럼
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
const handleToTableSelect = (tableName: string) => {
|
||||
const table = mockTables.find(t => t.name === tableName);
|
||||
if (table) {
|
||||
setSelectedToTable(tableName);
|
||||
onToTableChange(table);
|
||||
}
|
||||
};
|
||||
|
||||
const canProceed = selectedFromTable && selectedToTable;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Table className="h-5 w-5" />
|
||||
2단계: 테이블 선택
|
||||
</CardTitle>
|
||||
<p className="text-muted-foreground text-sm">연결된 데이터베이스에서 소스와 대상 테이블을 선택하세요.</p>
|
||||
</CardHeader>
|
||||
<div className="space-y-8">
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
테이블 선택
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
소스 테이블과 대상 테이블을 선택하세요
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<CardContent className="max-h-[calc(100vh-400px)] min-h-[400px] space-y-6 overflow-y-auto">
|
||||
{/* FROM 테이블 선택 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium">FROM 테이블 (소스)</h3>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{fromConnection?.name}
|
||||
</Badge>
|
||||
{/* 연결 정보 표시 */}
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Database className="w-5 h-5 text-blue-600" />
|
||||
<span className="font-medium text-gray-900">{fromConnection?.name}</span>
|
||||
<span className="text-sm text-gray-500">→</span>
|
||||
<Database className="w-5 h-5 text-green-600" />
|
||||
<span className="font-medium text-gray-900">{toConnection?.name}</span>
|
||||
</div>
|
||||
|
||||
{/* 검색 */}
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
|
||||
<Input
|
||||
placeholder="테이블 검색..."
|
||||
value={fromSearch}
|
||||
onChange={(e) => setFromSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 테이블 선택 */}
|
||||
{isLoadingFrom ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">테이블 목록 로드 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<Select value={fromTable?.tableName || ""} onValueChange={(value) => handleTableSelect("from", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="소스 테이블을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filteredFromTables.map((table) => (
|
||||
<SelectItem key={table.tableName} value={table.tableName}>
|
||||
{renderTableItem(table, "from")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{fromTable && (
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="font-medium">{fromTable.displayName || fromTable.tableName}</span>
|
||||
<Badge variant="secondary">
|
||||
📊 {tableColumnCounts[`from_${fromTable.tableName}`] || fromTable.columnCount || 0}개 컬럼
|
||||
</Badge>
|
||||
</div>
|
||||
{fromTable.description && <p className="text-muted-foreground text-xs">{fromTable.description}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TO 테이블 선택 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium">TO 테이블 (대상)</h3>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{toConnection?.name}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* 검색 */}
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
|
||||
<Input
|
||||
placeholder="테이블 검색..."
|
||||
value={toSearch}
|
||||
onChange={(e) => setToSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 테이블 선택 */}
|
||||
{isLoadingTo ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<span className="text-sm">테이블 목록 로드 중...</span>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* FROM 테이블 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-blue-600 font-bold">1</span>
|
||||
</div>
|
||||
) : (
|
||||
<Select value={toTable?.tableName || ""} onValueChange={(value) => handleTableSelect("to", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="대상 테이블을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{filteredToTables.map((table) => (
|
||||
<SelectItem key={table.tableName} value={table.tableName}>
|
||||
{renderTableItem(table, "to")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{toTable && (
|
||||
<div className="bg-muted/50 rounded-lg p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="font-medium">{toTable.displayName || toTable.tableName}</span>
|
||||
<Badge variant="secondary">
|
||||
📊 {tableColumnCounts[`to_${toTable.tableName}`] || toTable.columnCount || 0}개 컬럼
|
||||
</Badge>
|
||||
</div>
|
||||
{toTable.description && <p className="text-muted-foreground text-xs">{toTable.description}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 테이블 매핑 표시 */}
|
||||
{fromTable && toTable && (
|
||||
<div className="bg-primary/5 border-primary/20 rounded-lg border p-4">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<div className="text-center">
|
||||
<div className="font-medium">{fromTable.displayName || fromTable.tableName}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{tableColumnCounts[`from_${fromTable.tableName}`] || fromTable.columnCount || 0}개 컬럼
|
||||
<h3 className="text-lg font-semibold text-gray-900">소스 테이블</h3>
|
||||
<span className="text-sm text-gray-500">(FROM)</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{mockTables.map((table) => (
|
||||
<div
|
||||
key={table.name}
|
||||
className={`p-4 rounded-lg border-2 cursor-pointer transition-all duration-200 ${
|
||||
selectedFromTable === table.name
|
||||
? "border-blue-500 bg-blue-50 shadow-md"
|
||||
: "border-gray-200 bg-white hover:border-blue-300 hover:bg-blue-25"
|
||||
}`}
|
||||
onClick={() => handleFromTableSelect(table.name)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Table className="w-6 h-6 text-blue-600" />
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-gray-900">{table.name}</h4>
|
||||
<p className="text-sm text-gray-600">{table.columns.length}개 컬럼</p>
|
||||
<p className="text-xs text-gray-500">{table.rowCount?.toLocaleString()}개 행</p>
|
||||
</div>
|
||||
{selectedFromTable === table.name && (
|
||||
<CheckCircle className="w-5 h-5 text-blue-600" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArrowRight className="text-primary h-5 w-5" />
|
||||
|
||||
<div className="text-center">
|
||||
<div className="font-medium">{toTable.displayName || toTable.tableName}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{tableColumnCounts[`to_${toTable.tableName}`] || toTable.columnCount || 0}개 컬럼
|
||||
{/* TO 테이블 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<span className="text-green-600 font-bold">2</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900">대상 테이블</h3>
|
||||
<span className="text-sm text-gray-500">(TO)</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{mockTables.map((table) => (
|
||||
<div
|
||||
key={table.name}
|
||||
className={`p-4 rounded-lg border-2 cursor-pointer transition-all duration-200 ${
|
||||
selectedToTable === table.name
|
||||
? "border-green-500 bg-green-50 shadow-md"
|
||||
: "border-gray-200 bg-white hover:border-green-300 hover:bg-green-25"
|
||||
}`}
|
||||
onClick={() => handleToTableSelect(table.name)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Table className="w-6 h-6 text-green-600" />
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-gray-900">{table.name}</h4>
|
||||
<p className="text-sm text-gray-600">{table.columns.length}개 컬럼</p>
|
||||
<p className="text-xs text-gray-500">{table.rowCount?.toLocaleString()}개 행</p>
|
||||
</div>
|
||||
{selectedToTable === table.name && (
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-center">
|
||||
<Badge variant="outline" className="text-primary">
|
||||
💡 테이블 매핑: {fromTable.displayName || fromTable.tableName} →{" "}
|
||||
{toTable.displayName || toTable.tableName}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 네비게이션 버튼 */}
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button variant="outline" onClick={onBack} className="flex items-center gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
이전: 연결 선택
|
||||
</Button>
|
||||
|
||||
<Button onClick={onNext} disabled={!canProceed} className="flex items-center gap-2">
|
||||
다음: 컬럼 매핑
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</>
|
||||
</div>
|
||||
|
||||
{/* 버튼들 */}
|
||||
<div className="flex justify-between">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 px-6 py-3 rounded-lg font-medium text-gray-600 bg-gray-100 hover:bg-gray-200 transition-all duration-200"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
이전 단계
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={!canProceed}
|
||||
className={`flex items-center gap-2 px-6 py-3 rounded-lg font-medium transition-all duration-200 ${
|
||||
canProceed
|
||||
? "bg-orange-500 text-white hover:bg-orange-600 shadow-md hover:shadow-lg"
|
||||
: "bg-gray-300 text-gray-500 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
다음 단계: 필드 매핑
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TableStep;
|
||||
};
|
||||
|
|
@ -1,8 +1,3 @@
|
|||
// 🎨 제어관리 UI 재설계 - 타입 정의
|
||||
|
||||
import { ColumnInfo, Connection, TableInfo } from "@/lib/types/multiConnection";
|
||||
|
||||
// 연결 타입
|
||||
export interface ConnectionType {
|
||||
id: "data_save" | "external_call";
|
||||
label: string;
|
||||
|
|
@ -10,7 +5,6 @@ export interface ConnectionType {
|
|||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
// 필드 매핑
|
||||
export interface FieldMapping {
|
||||
id: string;
|
||||
fromField: ColumnInfo;
|
||||
|
|
@ -20,18 +14,33 @@ export interface FieldMapping {
|
|||
validationMessage?: string;
|
||||
}
|
||||
|
||||
// 시각적 연결선
|
||||
export interface MappingLine {
|
||||
id: string;
|
||||
fromX: number;
|
||||
fromY: number;
|
||||
toX: number;
|
||||
toY: number;
|
||||
isValid: boolean;
|
||||
isHovered: boolean;
|
||||
export interface ColumnInfo {
|
||||
name: string;
|
||||
type: string;
|
||||
nullable: boolean;
|
||||
primaryKey: boolean;
|
||||
foreignKey?: boolean;
|
||||
defaultValue?: any;
|
||||
}
|
||||
|
||||
export interface TableInfo {
|
||||
name: string;
|
||||
schema: string;
|
||||
columns: ColumnInfo[];
|
||||
rowCount?: number;
|
||||
}
|
||||
|
||||
export interface Connection {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
database: string;
|
||||
username: string;
|
||||
tables: TableInfo[];
|
||||
}
|
||||
|
||||
// 매핑 통계
|
||||
export interface MappingStats {
|
||||
totalMappings: number;
|
||||
validMappings: number;
|
||||
|
|
@ -41,58 +50,16 @@ export interface MappingStats {
|
|||
actionType: "INSERT" | "UPDATE" | "DELETE";
|
||||
}
|
||||
|
||||
// 검증 결과
|
||||
export interface ValidationError {
|
||||
id: string;
|
||||
type: "error" | "warning" | "info";
|
||||
field: string;
|
||||
message: string;
|
||||
fieldId?: string;
|
||||
severity: "error" | "warning" | "info";
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
errors: ValidationError[];
|
||||
warnings: ValidationError[];
|
||||
}
|
||||
|
||||
// 테스트 결과
|
||||
export interface TestResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
affectedRows?: number;
|
||||
executionTime?: number;
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
// 단일 액션 정의
|
||||
export interface SingleAction {
|
||||
id: string;
|
||||
name: string;
|
||||
actionType: "insert" | "update" | "delete" | "upsert";
|
||||
conditions: any[];
|
||||
fieldMappings: any[];
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
// 액션 그룹 (AND/OR 조건으로 연결)
|
||||
export interface ActionGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
logicalOperator: "AND" | "OR";
|
||||
actions: SingleAction[];
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
// 메인 상태
|
||||
export interface DataConnectionState {
|
||||
// 기본 설정
|
||||
connectionType: "data_save" | "external_call";
|
||||
currentStep: 1 | 2 | 3 | 4;
|
||||
|
||||
// 관계 정보
|
||||
diagramId?: number; // 🔧 수정 모드 감지용
|
||||
relationshipName?: string;
|
||||
description?: string;
|
||||
currentStep: 1 | 2 | 3;
|
||||
|
||||
// 연결 정보
|
||||
fromConnection?: Connection;
|
||||
|
|
@ -104,141 +71,8 @@ export interface DataConnectionState {
|
|||
fieldMappings: FieldMapping[];
|
||||
mappingStats: MappingStats;
|
||||
|
||||
// 제어 실행 조건 (전체 제어가 언제 실행될지)
|
||||
controlConditions: any[]; // 전체 제어 트리거 조건
|
||||
|
||||
// 액션 설정 (멀티 액션 지원)
|
||||
actionGroups: ActionGroup[];
|
||||
groupsLogicalOperator?: "AND" | "OR"; // 그룹 간의 논리 연산자
|
||||
|
||||
// 외부호출 설정
|
||||
externalCallConfig?: {
|
||||
restApiSettings: {
|
||||
apiUrl: string;
|
||||
httpMethod: string;
|
||||
headers: Record<string, string>;
|
||||
bodyTemplate: string;
|
||||
authentication: {
|
||||
type: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
timeout: number;
|
||||
retryCount: number;
|
||||
};
|
||||
};
|
||||
|
||||
// 기존 호환성을 위한 필드들 (deprecated)
|
||||
actionType?: "insert" | "update" | "delete" | "upsert";
|
||||
actionConditions?: any[]; // 각 액션의 대상 레코드 조건
|
||||
actionFieldMappings?: any[]; // 액션별 필드 매핑
|
||||
|
||||
// UI 상태
|
||||
selectedMapping?: string;
|
||||
fromColumns?: ColumnInfo[]; // 🔧 FROM 테이블 컬럼 정보 (중앙 관리)
|
||||
toColumns?: ColumnInfo[]; // 🔧 TO 테이블 컬럼 정보 (중앙 관리)
|
||||
isLoading: boolean;
|
||||
validationErrors: ValidationError[];
|
||||
}
|
||||
|
||||
// 액션 인터페이스
|
||||
export interface DataConnectionActions {
|
||||
// 연결 타입
|
||||
setConnectionType: (type: "data_save" | "external_call") => void;
|
||||
|
||||
// 관계 정보
|
||||
setRelationshipName: (name: string) => void;
|
||||
setDescription: (description: string) => void;
|
||||
setGroupsLogicalOperator: (operator: "AND" | "OR") => void;
|
||||
|
||||
// 단계 진행
|
||||
goToStep: (step: 1 | 2 | 3 | 4) => void;
|
||||
|
||||
// 연결/테이블 선택
|
||||
selectConnection: (type: "from" | "to", connection: Connection) => void;
|
||||
selectTable: (type: "from" | "to", table: TableInfo) => void;
|
||||
|
||||
// 컬럼 정보 로드 (중앙 관리)
|
||||
loadColumns: () => Promise<void>;
|
||||
|
||||
// 필드 매핑
|
||||
createMapping: (fromField: ColumnInfo, toField: ColumnInfo) => void;
|
||||
updateMapping: (mappingId: string, updates: Partial<FieldMapping>) => void;
|
||||
deleteMapping: (mappingId: string) => void;
|
||||
|
||||
// 제어 조건 관리 (전체 실행 조건)
|
||||
addControlCondition: () => void;
|
||||
updateControlCondition: (index: number, condition: any) => void;
|
||||
deleteControlCondition: (index: number) => void;
|
||||
|
||||
// 외부호출 설정 관리
|
||||
updateExternalCallConfig: (config: any) => void;
|
||||
|
||||
// 액션 그룹 관리 (멀티 액션)
|
||||
addActionGroup: () => void;
|
||||
updateActionGroup: (groupId: string, updates: Partial<ActionGroup>) => void;
|
||||
deleteActionGroup: (groupId: string) => void;
|
||||
addActionToGroup: (groupId: string) => void;
|
||||
updateActionInGroup: (groupId: string, actionId: string, updates: Partial<SingleAction>) => void;
|
||||
deleteActionFromGroup: (groupId: string, actionId: string) => void;
|
||||
|
||||
// 기존 액션 설정 (호환성)
|
||||
setActionType: (type: "insert" | "update" | "delete" | "upsert") => void;
|
||||
addActionCondition: () => void;
|
||||
updateActionCondition: (index: number, condition: any) => void;
|
||||
setActionConditions: (conditions: any[]) => void; // 액션 조건 배열 전체 업데이트
|
||||
deleteActionCondition: (index: number) => void;
|
||||
|
||||
// 검증 및 저장
|
||||
validateMappings: () => Promise<ValidationResult>;
|
||||
saveMappings: () => Promise<void>;
|
||||
testExecution: () => Promise<TestResult>;
|
||||
}
|
||||
|
||||
// 컴포넌트 Props 타입들
|
||||
export interface DataConnectionDesignerProps {
|
||||
onClose?: () => void;
|
||||
initialData?: Partial<DataConnectionState>;
|
||||
showBackButton?: boolean;
|
||||
}
|
||||
|
||||
export interface LeftPanelProps {
|
||||
state: DataConnectionState;
|
||||
actions: DataConnectionActions;
|
||||
}
|
||||
|
||||
export interface RightPanelProps {
|
||||
state: DataConnectionState;
|
||||
actions: DataConnectionActions;
|
||||
}
|
||||
|
||||
export interface ConnectionTypeSelectorProps {
|
||||
selectedType: "data_save" | "external_call";
|
||||
onTypeChange: (type: "data_save" | "external_call") => void;
|
||||
}
|
||||
|
||||
export interface MappingInfoPanelProps {
|
||||
stats: MappingStats;
|
||||
validationErrors: ValidationError[];
|
||||
}
|
||||
|
||||
export interface MappingDetailListProps {
|
||||
mappings: FieldMapping[];
|
||||
selectedMapping?: string;
|
||||
onSelectMapping: (mappingId: string) => void;
|
||||
onUpdateMapping: (mappingId: string, updates: Partial<FieldMapping>) => void;
|
||||
onDeleteMapping: (mappingId: string) => void;
|
||||
}
|
||||
|
||||
export interface StepProgressProps {
|
||||
currentStep: 1 | 2 | 3 | 4;
|
||||
completedSteps: number[];
|
||||
onStepClick: (step: 1 | 2 | 3 | 4) => void;
|
||||
}
|
||||
|
||||
export interface FieldMappingCanvasProps {
|
||||
fromFields: ColumnInfo[];
|
||||
toFields: ColumnInfo[];
|
||||
mappings: FieldMapping[];
|
||||
onCreateMapping: (fromField: ColumnInfo, toField: ColumnInfo) => void;
|
||||
onDeleteMapping: (mappingId: string) => void;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useResponsive } from "@/lib/hooks/useResponsive";
|
||||
|
||||
interface ResponsiveContainerProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
mobileClassName?: string;
|
||||
tabletClassName?: string;
|
||||
desktopClassName?: string;
|
||||
breakpoint?: "sm" | "md" | "lg" | "xl" | "2xl";
|
||||
}
|
||||
|
||||
export const ResponsiveContainer: React.FC<ResponsiveContainerProps> = ({
|
||||
children,
|
||||
className = "",
|
||||
mobileClassName = "",
|
||||
tabletClassName = "",
|
||||
desktopClassName = "",
|
||||
breakpoint = "md",
|
||||
}) => {
|
||||
const { isMobile, isTablet, isDesktop } = useResponsive();
|
||||
|
||||
const getResponsiveClassName = () => {
|
||||
let responsiveClass = className;
|
||||
|
||||
if (isMobile) {
|
||||
responsiveClass += ` ${mobileClassName}`;
|
||||
} else if (isTablet) {
|
||||
responsiveClass += ` ${tabletClassName}`;
|
||||
} else if (isDesktop) {
|
||||
responsiveClass += ` ${desktopClassName}`;
|
||||
}
|
||||
|
||||
return responsiveClass.trim();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={getResponsiveClassName()}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ResponsiveGridProps {
|
||||
children: React.ReactNode;
|
||||
cols?: {
|
||||
mobile?: number;
|
||||
tablet?: number;
|
||||
desktop?: number;
|
||||
};
|
||||
gap?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ResponsiveGrid: React.FC<ResponsiveGridProps> = ({
|
||||
children,
|
||||
cols = { mobile: 1, tablet: 2, desktop: 3 },
|
||||
gap = "4",
|
||||
className = "",
|
||||
}) => {
|
||||
const { isMobile, isTablet, isDesktop } = useResponsive();
|
||||
|
||||
const getGridCols = () => {
|
||||
if (isMobile) return `grid-cols-${cols.mobile || 1}`;
|
||||
if (isTablet) return `grid-cols-${cols.tablet || 2}`;
|
||||
if (isDesktop) return `grid-cols-${cols.desktop || 3}`;
|
||||
return "grid-cols-1";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`grid ${getGridCols()} gap-${gap} ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ResponsiveTextProps {
|
||||
children: React.ReactNode;
|
||||
size?: {
|
||||
mobile?: string;
|
||||
tablet?: string;
|
||||
desktop?: string;
|
||||
};
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ResponsiveText: React.FC<ResponsiveTextProps> = ({
|
||||
children,
|
||||
size = { mobile: "text-sm", tablet: "text-base", desktop: "text-lg" },
|
||||
className = "",
|
||||
}) => {
|
||||
const { isMobile, isTablet, isDesktop } = useResponsive();
|
||||
|
||||
const getTextSize = () => {
|
||||
if (isMobile) return size.mobile || "text-sm";
|
||||
if (isTablet) return size.tablet || "text-base";
|
||||
if (isDesktop) return size.desktop || "text-lg";
|
||||
return "text-base";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${getTextSize()} ${className}`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { AlertTriangle, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface ConfirmDeleteModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
itemName?: string;
|
||||
}
|
||||
|
||||
export default function ConfirmDeleteModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
itemName,
|
||||
}: ConfirmDeleteModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white rounded-xl shadow-2xl max-w-md w-full">
|
||||
{/* 헤더 */}
|
||||
<div className="bg-gradient-to-r from-red-500 to-red-600 px-6 py-4 flex items-center justify-between rounded-t-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle className="w-6 h-6 text-white" />
|
||||
<h2 className="text-xl font-bold text-white">{title}</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white hover:bg-white/20 rounded-lg p-2 transition"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 내용 */}
|
||||
<div className="p-6 space-y-4">
|
||||
<p className="text-gray-700">{message}</p>
|
||||
{itemName && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
|
||||
<p className="text-sm font-medium text-red-800">
|
||||
삭제 대상: <span className="font-bold">{itemName}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-gray-500">
|
||||
이 작업은 되돌릴 수 없습니다. 계속하시겠습니까?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex gap-3 px-6 pb-6">
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
className="flex-1 bg-red-500 hover:bg-red-600"
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,406 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Mail, Server, Lock, Zap, Loader2, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
MailAccount,
|
||||
CreateMailAccountDto,
|
||||
UpdateMailAccountDto,
|
||||
testMailConnection,
|
||||
} from '@/lib/api/mail';
|
||||
|
||||
interface MailAccountModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (data: CreateMailAccountDto | UpdateMailAccountDto) => Promise<void>;
|
||||
account?: MailAccount | null;
|
||||
mode: 'create' | 'edit';
|
||||
}
|
||||
|
||||
export default function MailAccountModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
account,
|
||||
mode,
|
||||
}: MailAccountModalProps) {
|
||||
const [formData, setFormData] = useState<CreateMailAccountDto>({
|
||||
name: '',
|
||||
email: '',
|
||||
smtpHost: '',
|
||||
smtpPort: 587,
|
||||
smtpSecure: false,
|
||||
smtpUsername: '',
|
||||
smtpPassword: '',
|
||||
dailyLimit: 1000,
|
||||
});
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
} | null>(null);
|
||||
|
||||
// 수정 모드일 때 기존 데이터 로드
|
||||
useEffect(() => {
|
||||
if (mode === 'edit' && account) {
|
||||
setFormData({
|
||||
name: account.name,
|
||||
email: account.email,
|
||||
smtpHost: account.smtpHost,
|
||||
smtpPort: account.smtpPort,
|
||||
smtpSecure: account.smtpSecure,
|
||||
smtpUsername: account.smtpUsername,
|
||||
smtpPassword: '', // 비밀번호는 비워둠 (보안)
|
||||
dailyLimit: account.dailyLimit,
|
||||
});
|
||||
} else {
|
||||
// 생성 모드일 때 초기화
|
||||
setFormData({
|
||||
name: '',
|
||||
email: '',
|
||||
smtpHost: '',
|
||||
smtpPort: 587,
|
||||
smtpSecure: false,
|
||||
smtpUsername: '',
|
||||
smtpPassword: '',
|
||||
dailyLimit: 1000,
|
||||
});
|
||||
}
|
||||
setTestResult(null);
|
||||
}, [mode, account, isOpen]);
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>
|
||||
) => {
|
||||
const { name, value, type } = e.target;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]:
|
||||
type === 'number'
|
||||
? parseInt(value)
|
||||
: type === 'checkbox'
|
||||
? (e.target as HTMLInputElement).checked
|
||||
: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
if (!account?.id && mode === 'edit') return;
|
||||
|
||||
setIsTesting(true);
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
// 수정 모드에서만 테스트 가능 (저장된 계정만)
|
||||
if (mode === 'edit' && account) {
|
||||
const result = await testMailConnection(account.id);
|
||||
setTestResult(result);
|
||||
} else {
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: '계정을 먼저 저장한 후 테스트할 수 있습니다.',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '연결 테스트 실패',
|
||||
});
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
await onSave(formData);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('저장 실패:', error);
|
||||
alert(error instanceof Error ? error.message : '저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
{/* 헤더 */}
|
||||
<div className="sticky top-0 bg-gradient-to-r from-orange-500 to-orange-600 px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Mail className="w-6 h-6 text-white" />
|
||||
<h2 className="text-xl font-bold text-white">
|
||||
{mode === 'create' ? '새 메일 계정 추가' : '메일 계정 수정'}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white hover:bg-white/20 rounded-lg p-2 transition"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 폼 */}
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
||||
<Mail className="w-5 h-5 text-orange-500" />
|
||||
기본 정보
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
계정명 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
placeholder="예: 회사 공식 메일"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
발신 이메일 *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
placeholder="info@company.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SMTP 설정 */}
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<h3 className="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
||||
<Server className="w-5 h-5 text-orange-500" />
|
||||
SMTP 서버 설정
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
SMTP 호스트 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="smtpHost"
|
||||
value={formData.smtpHost}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
placeholder="smtp.gmail.com"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
예: smtp.gmail.com, smtp.naver.com, smtp.office365.com
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
SMTP 포트 *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="smtpPort"
|
||||
value={formData.smtpPort}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
placeholder="587"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
일반적으로 587 (TLS) 또는 465 (SSL)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
보안 연결
|
||||
</label>
|
||||
<select
|
||||
name="smtpSecure"
|
||||
value={formData.smtpSecure ? 'true' : 'false'}
|
||||
onChange={(e) =>
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
smtpSecure: e.target.value === 'true',
|
||||
}))
|
||||
}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
>
|
||||
<option value="false">TLS (포트 587)</option>
|
||||
<option value="true">SSL (포트 465)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 인증 정보 */}
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<h3 className="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
||||
<Lock className="w-5 h-5 text-orange-500" />
|
||||
인증 정보
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
사용자명 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="smtpUsername"
|
||||
value={formData.smtpUsername}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
placeholder="info@company.com"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
대부분 이메일 주소와 동일
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
비밀번호 {mode === 'edit' && '(변경 시에만 입력)'}
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="smtpPassword"
|
||||
value={formData.smtpPassword}
|
||||
onChange={handleChange}
|
||||
required={mode === 'create'}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
placeholder={mode === 'edit' ? '변경하지 않으려면 비워두세요' : '••••••••'}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Gmail의 경우 앱 비밀번호 사용 권장
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 발송 제한 */}
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<h3 className="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-orange-500" />
|
||||
발송 설정
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
일일 발송 제한
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="dailyLimit"
|
||||
value={formData.dailyLimit}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
placeholder="1000"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
하루에 발송 가능한 최대 메일 수 (0 = 제한 없음)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 연결 테스트 (수정 모드만) */}
|
||||
{mode === 'edit' && account && (
|
||||
<div className="pt-4 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleTestConnection}
|
||||
disabled={isTesting}
|
||||
className="w-full bg-blue-500 hover:bg-blue-600"
|
||||
>
|
||||
{isTesting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
연결 테스트 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="w-4 h-4 mr-2" />
|
||||
SMTP 연결 테스트
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{testResult && (
|
||||
<div
|
||||
className={`mt-3 p-3 rounded-lg flex items-start gap-2 ${
|
||||
testResult.success
|
||||
? 'bg-green-50 border border-green-200'
|
||||
: 'bg-red-50 border border-red-200'
|
||||
}`}
|
||||
>
|
||||
{testResult.success ? (
|
||||
<CheckCircle2 className="w-5 h-5 text-green-600 flex-shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
<p
|
||||
className={`text-sm ${
|
||||
testResult.success ? 'text-green-800' : 'text-red-800'
|
||||
}`}
|
||||
>
|
||||
{testResult.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1 bg-orange-500 hover:bg-orange-600"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
저장 중...
|
||||
</>
|
||||
) : (
|
||||
'저장'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Mail,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Power,
|
||||
PowerOff,
|
||||
Search,
|
||||
Calendar,
|
||||
Zap,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MailAccount } from '@/lib/api/mail';
|
||||
|
||||
interface MailAccountTableProps {
|
||||
accounts: MailAccount[];
|
||||
onEdit: (account: MailAccount) => void;
|
||||
onDelete: (account: MailAccount) => void;
|
||||
onToggleStatus: (account: MailAccount) => void;
|
||||
}
|
||||
|
||||
export default function MailAccountTable({
|
||||
accounts,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onToggleStatus,
|
||||
}: MailAccountTableProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortField, setSortField] = useState<keyof MailAccount>('createdAt');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
|
||||
// 검색 필터링
|
||||
const filteredAccounts = accounts.filter(
|
||||
(account) =>
|
||||
account.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
account.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
account.smtpHost.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
// 정렬
|
||||
const sortedAccounts = [...filteredAccounts].sort((a, b) => {
|
||||
const aValue = a[sortField];
|
||||
const bValue = b[sortField];
|
||||
|
||||
if (typeof aValue === 'string' && typeof bValue === 'string') {
|
||||
return sortOrder === 'asc'
|
||||
? aValue.localeCompare(bValue)
|
||||
: bValue.localeCompare(aValue);
|
||||
}
|
||||
|
||||
if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||
return sortOrder === 'asc' ? aValue - bValue : bValue - aValue;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
|
||||
const handleSort = (field: keyof MailAccount) => {
|
||||
if (sortField === field) {
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortOrder('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
if (accounts.length === 0) {
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-gray-50 to-gray-100 rounded-xl p-12 text-center border-2 border-dashed border-gray-300">
|
||||
<Mail className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-lg font-medium text-gray-600 mb-2">
|
||||
등록된 메일 계정이 없습니다
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
"새 계정 추가" 버튼을 클릭하여 첫 번째 메일 계정을 등록하세요.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 검색 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="계정명, 이메일, 서버로 검색..."
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500 transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gradient-to-r from-slate-50 to-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th
|
||||
className="px-6 py-4 text-left text-sm font-semibold text-gray-700 cursor-pointer hover:bg-gray-100 transition"
|
||||
onClick={() => handleSort('name')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="w-4 h-4 text-orange-500" />
|
||||
계정명
|
||||
{sortField === 'name' && (
|
||||
<span className="text-xs">
|
||||
{sortOrder === 'asc' ? '↑' : '↓'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-4 text-left text-sm font-semibold text-gray-700 cursor-pointer hover:bg-gray-100 transition"
|
||||
onClick={() => handleSort('email')}
|
||||
>
|
||||
이메일 주소
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700">
|
||||
SMTP 서버
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-4 text-center text-sm font-semibold text-gray-700 cursor-pointer hover:bg-gray-100 transition"
|
||||
onClick={() => handleSort('status')}
|
||||
>
|
||||
상태
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-4 text-center text-sm font-semibold text-gray-700 cursor-pointer hover:bg-gray-100 transition"
|
||||
onClick={() => handleSort('dailyLimit')}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Zap className="w-4 h-4 text-orange-500" />
|
||||
일일 제한
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-4 text-center text-sm font-semibold text-gray-700 cursor-pointer hover:bg-gray-100 transition"
|
||||
onClick={() => handleSort('createdAt')}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-orange-500" />
|
||||
생성일
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-4 text-center text-sm font-semibold text-gray-700">
|
||||
액션
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{sortedAccounts.map((account) => (
|
||||
<tr
|
||||
key={account.id}
|
||||
className="hover:bg-orange-50/50 transition-colors"
|
||||
>
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-medium text-gray-900">{account.name}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm text-gray-600">{account.email}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm text-gray-600">
|
||||
{account.smtpHost}:{account.smtpPort}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{account.smtpSecure ? 'SSL' : 'TLS'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-center">
|
||||
<button
|
||||
onClick={() => onToggleStatus(account)}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all hover:scale-105 ${
|
||||
account.status === 'active'
|
||||
? 'bg-green-100 text-green-700 hover:bg-green-200'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{account.status === 'active' ? (
|
||||
<>
|
||||
<CheckCircle className="w-3.5 h-3.5" />
|
||||
활성
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="w-3.5 h-3.5" />
|
||||
비활성
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-center">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{account.dailyLimit > 0
|
||||
? account.dailyLimit.toLocaleString()
|
||||
: '무제한'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-center">
|
||||
<div className="text-sm text-gray-600">
|
||||
{formatDate(account.createdAt)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
onClick={() => onEdit(account)}
|
||||
className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||
title="수정"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(account)}
|
||||
className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 결과 요약 */}
|
||||
<div className="text-sm text-gray-600 text-center">
|
||||
전체 {accounts.length}개 중 {sortedAccounts.length}개 표시
|
||||
{searchTerm && ` (검색: "${searchTerm}")`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,401 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Mail,
|
||||
Type,
|
||||
Image as ImageIcon,
|
||||
Square,
|
||||
MousePointer,
|
||||
Eye,
|
||||
Send,
|
||||
Save,
|
||||
Plus,
|
||||
Trash2,
|
||||
Settings
|
||||
} from "lucide-react";
|
||||
|
||||
export interface MailComponent {
|
||||
id: string;
|
||||
type: "text" | "button" | "image" | "spacer" | "table";
|
||||
content?: string;
|
||||
text?: string;
|
||||
url?: string;
|
||||
src?: string;
|
||||
height?: number;
|
||||
styles?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface QueryConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
sql: string;
|
||||
parameters: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
value?: any;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface MailDesignerProps {
|
||||
templateId?: string;
|
||||
onSave?: (data: any) => void;
|
||||
onPreview?: (data: any) => void;
|
||||
onSend?: (data: any) => void;
|
||||
}
|
||||
|
||||
export default function MailDesigner({
|
||||
templateId,
|
||||
onSave,
|
||||
onPreview,
|
||||
onSend,
|
||||
}: MailDesignerProps) {
|
||||
const [components, setComponents] = useState<MailComponent[]>([]);
|
||||
const [selectedComponent, setSelectedComponent] = useState<string | null>(null);
|
||||
const [templateName, setTemplateName] = useState("");
|
||||
const [subject, setSubject] = useState("");
|
||||
const [queries, setQueries] = useState<QueryConfig[]>([]);
|
||||
|
||||
// 컴포넌트 타입 정의
|
||||
const componentTypes = [
|
||||
{ type: "text", icon: Type, label: "텍스트", color: "bg-blue-100 hover:bg-blue-200" },
|
||||
{ type: "button", icon: MousePointer, label: "버튼", color: "bg-green-100 hover:bg-green-200" },
|
||||
{ type: "image", icon: ImageIcon, label: "이미지", color: "bg-purple-100 hover:bg-purple-200" },
|
||||
{ type: "spacer", icon: Square, label: "여백", color: "bg-gray-100 hover:bg-gray-200" },
|
||||
];
|
||||
|
||||
// 컴포넌트 추가
|
||||
const addComponent = (type: string) => {
|
||||
const newComponent: MailComponent = {
|
||||
id: `comp-${Date.now()}`,
|
||||
type: type as any,
|
||||
content: type === "text" ? "<p>텍스트를 입력하세요...</p>" : undefined,
|
||||
text: type === "button" ? "버튼" : undefined,
|
||||
url: type === "button" || type === "image" ? "https://example.com" : undefined,
|
||||
src: type === "image" ? "https://placehold.co/600x200/e5e7eb/64748b?text=Image" : undefined,
|
||||
height: type === "spacer" ? 20 : undefined,
|
||||
styles: {
|
||||
padding: "10px",
|
||||
backgroundColor: type === "button" ? "#007bff" : "transparent",
|
||||
color: type === "button" ? "#fff" : "#333",
|
||||
},
|
||||
};
|
||||
|
||||
setComponents([...components, newComponent]);
|
||||
};
|
||||
|
||||
// 컴포넌트 삭제
|
||||
const removeComponent = (id: string) => {
|
||||
setComponents(components.filter(c => c.id !== id));
|
||||
if (selectedComponent === id) {
|
||||
setSelectedComponent(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 컴포넌트 선택
|
||||
const selectComponent = (id: string) => {
|
||||
setSelectedComponent(id);
|
||||
};
|
||||
|
||||
// 컴포넌트 내용 업데이트
|
||||
const updateComponent = (id: string, updates: Partial<MailComponent>) => {
|
||||
setComponents(
|
||||
components.map(c => c.id === id ? { ...c, ...updates } : c)
|
||||
);
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSave = () => {
|
||||
const data = {
|
||||
name: templateName,
|
||||
subject,
|
||||
components,
|
||||
queries,
|
||||
};
|
||||
|
||||
if (onSave) {
|
||||
onSave(data);
|
||||
}
|
||||
};
|
||||
|
||||
// 미리보기
|
||||
const handlePreview = () => {
|
||||
if (onPreview) {
|
||||
onPreview({ components, subject });
|
||||
}
|
||||
};
|
||||
|
||||
// 발송
|
||||
const handleSend = () => {
|
||||
if (onSend) {
|
||||
onSend({ components, subject, queries });
|
||||
}
|
||||
};
|
||||
|
||||
// 선택된 컴포넌트 가져오기
|
||||
const selected = components.find(c => c.id === selectedComponent);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-50">
|
||||
{/* 왼쪽: 컴포넌트 팔레트 */}
|
||||
<div className="w-64 bg-white border-r p-4 space-y-4 overflow-y-auto">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center">
|
||||
<Mail className="w-4 h-4 mr-2 text-orange-500" />
|
||||
컴포넌트
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{componentTypes.map(({ type, icon: Icon, label, color }) => (
|
||||
<Button
|
||||
key={type}
|
||||
onClick={() => addComponent(type)}
|
||||
variant="outline"
|
||||
className={`w-full justify-start ${color} border-gray-300`}
|
||||
>
|
||||
<Icon className="w-4 h-4 mr-2" />
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 템플릿 정보 */}
|
||||
<Card>
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<CardTitle className="text-sm">템플릿 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-2 space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">템플릿 이름</Label>
|
||||
<Input
|
||||
value={templateName}
|
||||
onChange={(e) => setTemplateName(e.target.value)}
|
||||
placeholder="예: 고객 환영 메일"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">제목</Label>
|
||||
<Input
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
placeholder="예: {customer_name}님 환영합니다!"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="space-y-2">
|
||||
<Button onClick={handleSave} className="w-full" variant="default">
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
저장
|
||||
</Button>
|
||||
<Button onClick={handlePreview} className="w-full" variant="outline">
|
||||
<Eye className="w-4 h-4 mr-2" />
|
||||
미리보기
|
||||
</Button>
|
||||
<Button onClick={handleSend} className="w-full bg-orange-500 hover:bg-orange-600 text-white">
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
발송
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 중앙: 캔버스 */}
|
||||
<div className="flex-1 p-8 overflow-y-auto">
|
||||
<Card className="max-w-3xl mx-auto">
|
||||
<CardHeader className="bg-gradient-to-r from-orange-50 to-amber-50 border-b">
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>메일 미리보기</span>
|
||||
<span className="text-sm text-gray-500 font-normal">
|
||||
{components.length}개 컴포넌트
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{/* 제목 영역 */}
|
||||
{subject && (
|
||||
<div className="p-6 bg-gray-50 border-b">
|
||||
<p className="text-sm text-gray-500">제목:</p>
|
||||
<p className="font-semibold text-lg">{subject}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 컴포넌트 렌더링 */}
|
||||
<div className="p-6 space-y-4">
|
||||
{components.length === 0 ? (
|
||||
<div className="text-center py-16 text-gray-400">
|
||||
<Mail className="w-16 h-16 mx-auto mb-4 opacity-20" />
|
||||
<p>왼쪽에서 컴포넌트를 추가하세요</p>
|
||||
</div>
|
||||
) : (
|
||||
components.map((comp) => (
|
||||
<div
|
||||
key={comp.id}
|
||||
onClick={() => selectComponent(comp.id)}
|
||||
className={`relative group cursor-pointer rounded-lg transition-all ${
|
||||
selectedComponent === comp.id
|
||||
? "ring-2 ring-orange-500 bg-orange-50/30"
|
||||
: "hover:ring-2 hover:ring-gray-300"
|
||||
}`}
|
||||
style={comp.styles}
|
||||
>
|
||||
{/* 삭제 버튼 */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeComponent(comp.id);
|
||||
}}
|
||||
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity bg-red-500 text-white rounded-full p-1 hover:bg-red-600"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* 컴포넌트 내용 */}
|
||||
{comp.type === "text" && (
|
||||
<div dangerouslySetInnerHTML={{ __html: comp.content || "" }} />
|
||||
)}
|
||||
{comp.type === "button" && (
|
||||
<a
|
||||
href={comp.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block px-6 py-3 rounded-md"
|
||||
style={comp.styles}
|
||||
>
|
||||
{comp.text}
|
||||
</a>
|
||||
)}
|
||||
{comp.type === "image" && (
|
||||
<img src={comp.src} alt="메일 이미지" className="w-full rounded" />
|
||||
)}
|
||||
{comp.type === "spacer" && (
|
||||
<div style={{ height: `${comp.height}px` }} />
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 속성 패널 */}
|
||||
<div className="w-80 bg-white border-l p-4 overflow-y-auto">
|
||||
{selected ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 flex items-center">
|
||||
<Settings className="w-4 h-4 mr-2 text-orange-500" />
|
||||
속성 편집
|
||||
</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setSelectedComponent(null)}
|
||||
>
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 텍스트 컴포넌트 */}
|
||||
{selected.type === "text" && (
|
||||
<div>
|
||||
<Label className="text-xs">내용 (HTML)</Label>
|
||||
<Textarea
|
||||
value={selected.content || ""}
|
||||
onChange={(e) =>
|
||||
updateComponent(selected.id, { content: e.target.value })
|
||||
}
|
||||
rows={8}
|
||||
className="mt-1 font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 버튼 컴포넌트 */}
|
||||
{selected.type === "button" && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs">버튼 텍스트</Label>
|
||||
<Input
|
||||
value={selected.text || ""}
|
||||
onChange={(e) =>
|
||||
updateComponent(selected.id, { text: e.target.value })
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">링크 URL</Label>
|
||||
<Input
|
||||
value={selected.url || ""}
|
||||
onChange={(e) =>
|
||||
updateComponent(selected.id, { url: e.target.value })
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">배경색</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={selected.styles?.backgroundColor || "#007bff"}
|
||||
onChange={(e) =>
|
||||
updateComponent(selected.id, {
|
||||
styles: { ...selected.styles, backgroundColor: e.target.value },
|
||||
})
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 이미지 컴포넌트 */}
|
||||
{selected.type === "image" && (
|
||||
<div>
|
||||
<Label className="text-xs">이미지 URL</Label>
|
||||
<Input
|
||||
value={selected.src || ""}
|
||||
onChange={(e) =>
|
||||
updateComponent(selected.id, { src: e.target.value })
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 여백 컴포넌트 */}
|
||||
{selected.type === "spacer" && (
|
||||
<div>
|
||||
<Label className="text-xs">높이 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={selected.height || 20}
|
||||
onChange={(e) =>
|
||||
updateComponent(selected.id, { height: parseInt(e.target.value) })
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 text-gray-400">
|
||||
<Settings className="w-12 h-12 mx-auto mb-4 opacity-20" />
|
||||
<p className="text-sm">컴포넌트를 선택하세요</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { Mail, Edit2, Trash2, Eye, Copy, Calendar } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MailTemplate } from '@/lib/api/mail';
|
||||
|
||||
interface MailTemplateCardProps {
|
||||
template: MailTemplate;
|
||||
onEdit: (template: MailTemplate) => void;
|
||||
onDelete: (template: MailTemplate) => void;
|
||||
onPreview: (template: MailTemplate) => void;
|
||||
onDuplicate?: (template: MailTemplate) => void;
|
||||
}
|
||||
|
||||
export default function MailTemplateCard({
|
||||
template,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onPreview,
|
||||
onDuplicate,
|
||||
}: MailTemplateCardProps) {
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const getCategoryColor = (category?: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
welcome: 'bg-blue-100 text-blue-700 border-blue-300',
|
||||
promotion: 'bg-purple-100 text-purple-700 border-purple-300',
|
||||
notification: 'bg-green-100 text-green-700 border-green-300',
|
||||
newsletter: 'bg-orange-100 text-orange-700 border-orange-300',
|
||||
system: 'bg-gray-100 text-gray-700 border-gray-300',
|
||||
};
|
||||
return colors[category || ''] || 'bg-gray-100 text-gray-700 border-gray-300';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group bg-white rounded-xl border border-gray-200 shadow-sm hover:shadow-lg transition-all duration-300 overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
<div className="bg-gradient-to-r from-orange-50 to-amber-50 p-4 border-b border-gray-200">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
<div className="p-2 bg-white rounded-lg shadow-sm">
|
||||
<Mail className="w-5 h-5 text-orange-500" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 truncate">
|
||||
{template.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 truncate mt-1">
|
||||
{template.subject}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{template.category && (
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded-full border ${getCategoryColor(
|
||||
template.category
|
||||
)}`}
|
||||
>
|
||||
{template.category}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 미리보기 */}
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="bg-gray-50 rounded-lg p-3 border border-gray-200 min-h-[100px]">
|
||||
<p className="text-xs text-gray-500 mb-2">컴포넌트 {template.components.length}개</p>
|
||||
<div className="space-y-1">
|
||||
{template.components.slice(0, 3).map((component, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 text-xs text-gray-600">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-orange-400" />
|
||||
<span className="capitalize">{component.type}</span>
|
||||
{component.type === 'text' && component.content && (
|
||||
<span className="text-gray-400 truncate flex-1">
|
||||
{component.content.replace(/<[^>]*>/g, '').substring(0, 30)}...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{template.components.length > 3 && (
|
||||
<p className="text-xs text-gray-400 pl-3.5">
|
||||
+{template.components.length - 3}개 더보기
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메타 정보 */}
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500 pt-2 border-t">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
<span>{formatDate(template.createdAt)}</span>
|
||||
</div>
|
||||
{template.updatedAt !== template.createdAt && (
|
||||
<span className="text-gray-400">수정됨</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="p-4 pt-0 flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex-1 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-300"
|
||||
onClick={() => onPreview(template)}
|
||||
>
|
||||
<Eye className="w-4 h-4 mr-1" />
|
||||
미리보기
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex-1 hover:bg-green-50 hover:text-green-600 hover:border-green-300"
|
||||
onClick={() => onEdit(template)}
|
||||
>
|
||||
<Edit2 className="w-4 h-4 mr-1" />
|
||||
수정
|
||||
</Button>
|
||||
{onDuplicate && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="hover:bg-purple-50 hover:text-purple-600 hover:border-purple-300"
|
||||
onClick={() => onDuplicate(template)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="hover:bg-red-50 hover:text-red-600 hover:border-red-300"
|
||||
onClick={() => onDelete(template)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Save, Eye } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MailTemplate, CreateMailTemplateDto, UpdateMailTemplateDto } from '@/lib/api/mail';
|
||||
import MailDesigner from './MailDesigner';
|
||||
|
||||
interface MailTemplateEditorModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (data: CreateMailTemplateDto | UpdateMailTemplateDto) => Promise<void>;
|
||||
template?: MailTemplate | null;
|
||||
mode: 'create' | 'edit';
|
||||
}
|
||||
|
||||
export default function MailTemplateEditorModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
template,
|
||||
mode,
|
||||
}: MailTemplateEditorModalProps) {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const handleSave = async (designerData: any) => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// MailDesigner가 보내는 데이터 구조에 맞춰서 처리
|
||||
await onSave({
|
||||
name: designerData.name || designerData.templateName || '제목 없음',
|
||||
subject: designerData.subject || '제목 없음',
|
||||
components: designerData.components || [],
|
||||
category: designerData.category,
|
||||
});
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('템플릿 저장 실패:', error);
|
||||
alert(error instanceof Error ? error.message : '저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-white">
|
||||
{/* 헤더 */}
|
||||
<div className="sticky top-0 bg-gradient-to-r from-orange-500 to-orange-600 px-6 py-4 flex items-center justify-between shadow-lg z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-xl font-bold text-white">
|
||||
{mode === 'create' ? '새 메일 템플릿 만들기' : '메일 템플릿 수정'}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
className="text-white hover:bg-white/20 rounded-lg p-2 transition disabled:opacity-50"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* MailDesigner 컴포넌트 */}
|
||||
<div className="h-[calc(100vh-73px)] overflow-hidden">
|
||||
<MailDesigner
|
||||
templateId={template?.id}
|
||||
onSave={handleSave}
|
||||
onPreview={(data) => {
|
||||
// 미리보기 로직은 MailDesigner 내부에서 처리
|
||||
console.log('Preview:', data);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { X, Eye, Mail, Code, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MailTemplate, renderTemplateToHtml, extractTemplateVariables } from '@/lib/api/mail';
|
||||
|
||||
interface MailTemplatePreviewModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
template: MailTemplate | null;
|
||||
}
|
||||
|
||||
export default function MailTemplatePreviewModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
template,
|
||||
}: MailTemplatePreviewModalProps) {
|
||||
const [viewMode, setViewMode] = useState<'preview' | 'code'>('preview');
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [variables, setVariables] = useState<Record<string, string>>({});
|
||||
|
||||
if (!isOpen || !template) return null;
|
||||
|
||||
const templateVariables = extractTemplateVariables(template);
|
||||
const renderedHtml = renderTemplateToHtml(template, variables);
|
||||
|
||||
const handleVariableChange = (key: string, value: string) => {
|
||||
setVariables((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div
|
||||
className={`bg-white rounded-xl shadow-2xl overflow-hidden transition-all ${
|
||||
isFullscreen ? 'w-full h-full' : 'max-w-6xl w-full max-h-[90vh]'
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="sticky top-0 bg-gradient-to-r from-orange-500 to-orange-600 px-6 py-4 flex items-center justify-between z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<Eye className="w-6 h-6 text-white" />
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">{template.name}</h2>
|
||||
<p className="text-sm text-orange-100">{template.subject}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setViewMode(viewMode === 'preview' ? 'code' : 'preview')}
|
||||
className="text-white hover:bg-white/20 rounded-lg px-3 py-2 transition flex items-center gap-2"
|
||||
>
|
||||
{viewMode === 'preview' ? (
|
||||
<>
|
||||
<Code className="w-4 h-4" />
|
||||
코드
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className="w-4 h-4" />
|
||||
미리보기
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
className="text-white hover:bg-white/20 rounded-lg p-2 transition"
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize2 className="w-5 h-5" />
|
||||
) : (
|
||||
<Maximize2 className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white hover:bg-white/20 rounded-lg p-2 transition"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="flex h-full overflow-hidden">
|
||||
{/* 왼쪽: 변수 입력 (변수가 있을 때만) */}
|
||||
{templateVariables.length > 0 && (
|
||||
<div className="w-80 bg-gray-50 border-r border-gray-200 p-6 overflow-y-auto">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||
<Mail className="w-5 h-5 text-orange-500" />
|
||||
템플릿 변수
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{templateVariables.map((variable) => (
|
||||
<div key={variable}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{variable}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={variables[variable] || ''}
|
||||
onChange={(e) => handleVariableChange(variable, e.target.value)}
|
||||
placeholder={`{${variable}}`}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<p className="text-xs text-blue-800">
|
||||
💡 변수 값을 입력하면 미리보기에 반영됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 오른쪽: 미리보기 또는 코드 */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{viewMode === 'preview' ? (
|
||||
<div className="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
|
||||
{/* 이메일 헤더 시뮬레이션 */}
|
||||
<div className="bg-gray-100 px-6 py-4 border-b border-gray-200">
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex">
|
||||
<span className="font-semibold text-gray-600 w-20">제목:</span>
|
||||
<span className="text-gray-900">{template.subject}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-semibold text-gray-600 w-20">발신:</span>
|
||||
<span className="text-gray-700">your-email@company.com</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-semibold text-gray-600 w-20">수신:</span>
|
||||
<span className="text-gray-700">recipient@example.com</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 이메일 본문 */}
|
||||
<div
|
||||
className="p-6"
|
||||
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-gray-900 text-gray-100 p-6 rounded-lg font-mono text-sm overflow-x-auto">
|
||||
<pre className="whitespace-pre-wrap break-words">{renderedHtml}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="sticky bottom-0 bg-gray-50 border-t border-gray-200 px-6 py-4 flex justify-end gap-3">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -188,11 +188,11 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
tableName={screenInfo?.tableName}
|
||||
onRefresh={() => {
|
||||
// 화면 새로고침 로직 (필요시 구현)
|
||||
console.log("화면 새로고침 요청");
|
||||
// console.log("화면 새로고침 요청");
|
||||
}}
|
||||
onClose={() => {
|
||||
// 화면 닫기 로직 (필요시 구현)
|
||||
console.log("화면 닫기 요청");
|
||||
// console.log("화면 닫기 요청");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,135 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useTheme } from "@/lib/contexts/ThemeContext";
|
||||
import { Sun, Moon, Monitor, Palette, Settings } from "lucide-react";
|
||||
|
||||
export const ThemeSettings: React.FC = () => {
|
||||
const { theme, setTheme, toggleMode, isDark, colors } = useTheme();
|
||||
|
||||
const colorSchemes = [
|
||||
{ id: "orange", name: "오렌지", color: "#f97316" },
|
||||
{ id: "blue", name: "블루", color: "#3b82f6" },
|
||||
{ id: "green", name: "그린", color: "#10b981" },
|
||||
{ id: "purple", name: "퍼플", color: "#8b5cf6" },
|
||||
{ id: "red", name: "레드", color: "#ef4444" },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-50">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-4 min-w-[300px]">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Settings className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">테마 설정</h3>
|
||||
</div>
|
||||
|
||||
{/* 다크모드 토글 */}
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 block">
|
||||
테마 모드
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setTheme({ mode: "light" })}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
theme.mode === "light"
|
||||
? "bg-orange-100 text-orange-700 border border-orange-300"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
<Sun className="w-4 h-4" />
|
||||
라이트
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTheme({ mode: "dark" })}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
theme.mode === "dark"
|
||||
? "bg-orange-100 text-orange-700 border border-orange-300"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
<Moon className="w-4 h-4" />
|
||||
다크
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTheme({ mode: "system" })}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
theme.mode === "system"
|
||||
? "bg-orange-100 text-orange-700 border border-orange-300"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
<Monitor className="w-4 h-4" />
|
||||
시스템
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 색상 스키마 */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 block">
|
||||
색상 스키마
|
||||
</label>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{colorSchemes.map((scheme) => (
|
||||
<button
|
||||
key={scheme.id}
|
||||
onClick={() => setTheme({ colorScheme: scheme.id })}
|
||||
className={`p-3 rounded-lg border-2 transition-all ${
|
||||
theme.colorScheme === scheme.id
|
||||
? "border-orange-500 shadow-md"
|
||||
: "border-gray-200 hover:border-gray-300"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="w-6 h-6 rounded-full mx-auto"
|
||||
style={{ backgroundColor: scheme.color }}
|
||||
/>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
{scheme.name}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 현재 색상 미리보기 */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 block">
|
||||
현재 색상
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg border border-gray-200"
|
||||
style={{ backgroundColor: colors.primary }}
|
||||
title="Primary"
|
||||
/>
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg border border-gray-200"
|
||||
style={{ backgroundColor: colors.secondary }}
|
||||
title="Secondary"
|
||||
/>
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg border border-gray-200"
|
||||
style={{ backgroundColor: colors.accent }}
|
||||
title="Accent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 빠른 토글 버튼 */}
|
||||
<div className="pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={toggleMode}
|
||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 transition-colors"
|
||||
>
|
||||
{isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||
{isDark ? "라이트 모드로" : "다크 모드로"} 전환
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useTheme } from "@/lib/contexts/ThemeContext";
|
||||
|
||||
interface ThemedButtonProps {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
variant?: "primary" | "secondary" | "outline";
|
||||
size?: "sm" | "md" | "lg";
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const ThemedButton: React.FC<ThemedButtonProps> = ({
|
||||
children,
|
||||
onClick,
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
disabled = false,
|
||||
className = "",
|
||||
}) => {
|
||||
const { colors } = useTheme();
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "px-3 py-1.5 text-sm",
|
||||
md: "px-4 py-2 text-base",
|
||||
lg: "px-6 py-3 text-lg",
|
||||
};
|
||||
|
||||
const variantStyles = {
|
||||
primary: {
|
||||
backgroundColor: colors.primary,
|
||||
color: "white",
|
||||
border: `1px solid ${colors.primary}`,
|
||||
hover: {
|
||||
backgroundColor: colors.secondary,
|
||||
borderColor: colors.secondary,
|
||||
},
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: colors.surface,
|
||||
color: colors.text,
|
||||
border: `1px solid ${colors.border}`,
|
||||
hover: {
|
||||
backgroundColor: colors.hover,
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
},
|
||||
outline: {
|
||||
backgroundColor: "transparent",
|
||||
color: colors.primary,
|
||||
border: `1px solid ${colors.primary}`,
|
||||
hover: {
|
||||
backgroundColor: colors.primary,
|
||||
color: "white",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const style = variantStyles[variant];
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
${sizeClasses[size]}
|
||||
rounded-lg font-medium transition-all duration-200
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
${className}
|
||||
`}
|
||||
style={{
|
||||
backgroundColor: style.backgroundColor,
|
||||
color: style.color,
|
||||
border: style.border,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!disabled) {
|
||||
e.currentTarget.style.backgroundColor = style.hover.backgroundColor;
|
||||
e.currentTarget.style.borderColor = style.hover.borderColor;
|
||||
e.currentTarget.style.color = style.hover.color;
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!disabled) {
|
||||
e.currentTarget.style.backgroundColor = style.backgroundColor;
|
||||
e.currentTarget.style.borderColor = style.border;
|
||||
e.currentTarget.style.color = style.color;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
export interface AnimationConfig {
|
||||
duration?: number;
|
||||
delay?: number;
|
||||
easing?: string;
|
||||
fillMode?: "forwards" | "backwards" | "both" | "none";
|
||||
iterationCount?: number | "infinite";
|
||||
}
|
||||
|
||||
export const animations = {
|
||||
// 페이드 애니메이션
|
||||
fadeIn: (config: AnimationConfig = {}) => ({
|
||||
animation: `fadeIn ${config.duration || 300}ms ${config.easing || "ease-in-out"} ${config.delay || 0}ms ${config.fillMode || "forwards"}`,
|
||||
"@keyframes fadeIn": {
|
||||
"0%": { opacity: 0 },
|
||||
"100%": { opacity: 1 },
|
||||
},
|
||||
}),
|
||||
|
||||
fadeOut: (config: AnimationConfig = {}) => ({
|
||||
animation: `fadeOut ${config.duration || 300}ms ${config.easing || "ease-in-out"} ${config.delay || 0}ms ${config.fillMode || "forwards"}`,
|
||||
"@keyframes fadeOut": {
|
||||
"0%": { opacity: 1 },
|
||||
"100%": { opacity: 0 },
|
||||
},
|
||||
}),
|
||||
|
||||
// 슬라이드 애니메이션
|
||||
slideInFromLeft: (config: AnimationConfig = {}) => ({
|
||||
animation: `slideInFromLeft ${config.duration || 400}ms ${config.easing || "ease-out"} ${config.delay || 0}ms ${config.fillMode || "forwards"}`,
|
||||
"@keyframes slideInFromLeft": {
|
||||
"0%": { transform: "translateX(-100%)", opacity: 0 },
|
||||
"100%": { transform: "translateX(0)", opacity: 1 },
|
||||
},
|
||||
}),
|
||||
|
||||
slideInFromRight: (config: AnimationConfig = {}) => ({
|
||||
animation: `slideInFromRight ${config.duration || 400}ms ${config.easing || "ease-out"} ${config.delay || 0}ms ${config.fillMode || "forwards"}`,
|
||||
"@keyframes slideInFromRight": {
|
||||
"0%": { transform: "translateX(100%)", opacity: 0 },
|
||||
"100%": { transform: "translateX(0)", opacity: 1 },
|
||||
},
|
||||
}),
|
||||
|
||||
slideInFromTop: (config: AnimationConfig = {}) => ({
|
||||
animation: `slideInFromTop ${config.duration || 400}ms ${config.easing || "ease-out"} ${config.delay || 0}ms ${config.fillMode || "forwards"}`,
|
||||
"@keyframes slideInFromTop": {
|
||||
"0%": { transform: "translateY(-100%)", opacity: 0 },
|
||||
"100%": { transform: "translateY(0)", opacity: 1 },
|
||||
},
|
||||
}),
|
||||
|
||||
slideInFromBottom: (config: AnimationConfig = {}) => ({
|
||||
animation: `slideInFromBottom ${config.duration || 400}ms ${config.easing || "ease-out"} ${config.delay || 0}ms ${config.fillMode || "forwards"}`,
|
||||
"@keyframes slideInFromBottom": {
|
||||
"0%": { transform: "translateY(100%)", opacity: 0 },
|
||||
"100%": { transform: "translateY(0)", opacity: 1 },
|
||||
},
|
||||
}),
|
||||
|
||||
// 스케일 애니메이션
|
||||
scaleIn: (config: AnimationConfig = {}) => ({
|
||||
animation: `scaleIn ${config.duration || 300}ms ${config.easing || "ease-out"} ${config.delay || 0}ms ${config.fillMode || "forwards"}`,
|
||||
"@keyframes scaleIn": {
|
||||
"0%": { transform: "scale(0)", opacity: 0 },
|
||||
"100%": { transform: "scale(1)", opacity: 1 },
|
||||
},
|
||||
}),
|
||||
|
||||
scaleOut: (config: AnimationConfig = {}) => ({
|
||||
animation: `scaleOut ${config.duration || 300}ms ${config.easing || "ease-in"} ${config.delay || 0}ms ${config.fillMode || "forwards"}`,
|
||||
"@keyframes scaleOut": {
|
||||
"0%": { transform: "scale(1)", opacity: 1 },
|
||||
"100%": { transform: "scale(0)", opacity: 0 },
|
||||
},
|
||||
}),
|
||||
|
||||
// 바운스 애니메이션
|
||||
bounce: (config: AnimationConfig = {}) => ({
|
||||
animation: `bounce ${config.duration || 600}ms ${config.easing || "ease-in-out"} ${config.delay || 0}ms ${config.iterationCount || 1}`,
|
||||
"@keyframes bounce": {
|
||||
"0%, 20%, 53%, 80%, 100%": { transform: "translate3d(0,0,0)" },
|
||||
"40%, 43%": { transform: "translate3d(0,-30px,0)" },
|
||||
"70%": { transform: "translate3d(0,-15px,0)" },
|
||||
"90%": { transform: "translate3d(0,-4px,0)" },
|
||||
},
|
||||
}),
|
||||
|
||||
// 회전 애니메이션
|
||||
rotate: (config: AnimationConfig = {}) => ({
|
||||
animation: `rotate ${config.duration || 1000}ms ${config.easing || "linear"} ${config.delay || 0}ms ${config.iterationCount || "infinite"}`,
|
||||
"@keyframes rotate": {
|
||||
"0%": { transform: "rotate(0deg)" },
|
||||
"100%": { transform: "rotate(360deg)" },
|
||||
},
|
||||
}),
|
||||
|
||||
// 펄스 애니메이션
|
||||
pulse: (config: AnimationConfig = {}) => ({
|
||||
animation: `pulse ${config.duration || 1000}ms ${config.easing || "ease-in-out"} ${config.delay || 0}ms ${config.iterationCount || "infinite"}`,
|
||||
"@keyframes pulse": {
|
||||
"0%": { transform: "scale(1)", opacity: 1 },
|
||||
"50%": { transform: "scale(1.05)", opacity: 0.8 },
|
||||
"100%": { transform: "scale(1)", opacity: 1 },
|
||||
},
|
||||
}),
|
||||
|
||||
// 타이핑 애니메이션
|
||||
typewriter: (config: AnimationConfig = {}) => ({
|
||||
animation: `typewriter ${config.duration || 2000}ms ${config.easing || "steps(40, end)"} ${config.delay || 0}ms ${config.fillMode || "forwards"}`,
|
||||
"@keyframes typewriter": {
|
||||
"0%": { width: "0" },
|
||||
"100%": { width: "100%" },
|
||||
},
|
||||
}),
|
||||
|
||||
// 글로우 애니메이션
|
||||
glow: (config: AnimationConfig = {}) => ({
|
||||
animation: `glow ${config.duration || 2000}ms ${config.easing || "ease-in-out"} ${config.delay || 0}ms ${config.iterationCount || "infinite"}`,
|
||||
"@keyframes glow": {
|
||||
"0%, 100%": { boxShadow: "0 0 5px rgba(59, 130, 246, 0.5)" },
|
||||
"50%": { boxShadow: "0 0 20px rgba(59, 130, 246, 0.8), 0 0 30px rgba(59, 130, 246, 0.6)" },
|
||||
},
|
||||
}),
|
||||
|
||||
// 웨이브 애니메이션
|
||||
wave: (config: AnimationConfig = {}) => ({
|
||||
animation: `wave ${config.duration || 1000}ms ${config.easing || "ease-in-out"} ${config.delay || 0}ms ${config.iterationCount || "infinite"}`,
|
||||
"@keyframes wave": {
|
||||
"0%, 100%": { transform: "rotate(0deg)" },
|
||||
"25%": { transform: "rotate(20deg)" },
|
||||
"75%": { transform: "rotate(-10deg)" },
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
// 애니메이션 조합
|
||||
export const animationCombos = {
|
||||
// 페이지 전환
|
||||
pageTransition: (direction: "left" | "right" | "up" | "down" = "right") => {
|
||||
const slideAnimation = direction === "left" ? animations.slideInFromLeft :
|
||||
direction === "right" ? animations.slideInFromRight :
|
||||
direction === "up" ? animations.slideInFromTop :
|
||||
animations.slideInFromBottom;
|
||||
|
||||
return {
|
||||
...slideAnimation({ duration: 500, easing: "cubic-bezier(0.4, 0, 0.2, 1)" }),
|
||||
...animations.fadeIn({ duration: 500, delay: 100 }),
|
||||
};
|
||||
},
|
||||
|
||||
// 모달 등장
|
||||
modalEnter: () => ({
|
||||
...animations.scaleIn({ duration: 300, easing: "cubic-bezier(0.34, 1.56, 0.64, 1)" }),
|
||||
...animations.fadeIn({ duration: 300 }),
|
||||
}),
|
||||
|
||||
// 모달 퇴장
|
||||
modalExit: () => ({
|
||||
...animations.scaleOut({ duration: 200, easing: "cubic-bezier(0.4, 0, 1, 1)" }),
|
||||
...animations.fadeOut({ duration: 200 }),
|
||||
}),
|
||||
|
||||
// 버튼 클릭
|
||||
buttonClick: () => ({
|
||||
...animations.scaleIn({ duration: 150, easing: "ease-out" }),
|
||||
}),
|
||||
|
||||
// 성공 알림
|
||||
successNotification: () => ({
|
||||
...animations.slideInFromRight({ duration: 400, easing: "ease-out" }),
|
||||
...animations.bounce({ duration: 600, delay: 200 }),
|
||||
}),
|
||||
|
||||
// 로딩 스피너
|
||||
loadingSpinner: () => ({
|
||||
...animations.rotate({ duration: 1000, iterationCount: "infinite" }),
|
||||
}),
|
||||
|
||||
// 호버 효과
|
||||
hoverLift: () => ({
|
||||
transition: "transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out",
|
||||
"&:hover": {
|
||||
transform: "translateY(-2px)",
|
||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
|
||||
},
|
||||
}),
|
||||
|
||||
// 타이핑 효과
|
||||
typingText: (text: string, speed: number = 50) => ({
|
||||
...animations.typewriter({ duration: text.length * speed }),
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
borderRight: "2px solid",
|
||||
borderRightColor: "currentColor",
|
||||
}),
|
||||
};
|
||||
|
|
@ -0,0 +1,361 @@
|
|||
/**
|
||||
* 메일 관리 시스템 API 클라이언트
|
||||
* 파일 기반 메일 계정 및 템플릿 관리
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// 타입 정의
|
||||
// ============================================
|
||||
|
||||
export interface MailAccount {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
smtpHost: string;
|
||||
smtpPort: number;
|
||||
smtpSecure: boolean;
|
||||
smtpUsername: string;
|
||||
smtpPassword: string; // 암호화된 상태
|
||||
dailyLimit: number;
|
||||
status: 'active' | 'inactive';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateMailAccountDto {
|
||||
name: string;
|
||||
email: string;
|
||||
smtpHost: string;
|
||||
smtpPort: number;
|
||||
smtpSecure: boolean;
|
||||
smtpUsername: string;
|
||||
smtpPassword: string;
|
||||
dailyLimit?: number;
|
||||
}
|
||||
|
||||
export interface UpdateMailAccountDto extends Partial<CreateMailAccountDto> {
|
||||
status?: 'active' | 'inactive';
|
||||
}
|
||||
|
||||
export interface MailComponent {
|
||||
id: string;
|
||||
type: 'text' | 'button' | 'image' | 'spacer';
|
||||
content?: string;
|
||||
text?: string;
|
||||
url?: string;
|
||||
src?: string;
|
||||
height?: number;
|
||||
styles?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface MailTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
subject: string;
|
||||
components: MailComponent[];
|
||||
category?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateMailTemplateDto {
|
||||
name: string;
|
||||
subject: string;
|
||||
components: MailComponent[];
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export interface UpdateMailTemplateDto extends Partial<CreateMailTemplateDto> {}
|
||||
|
||||
export interface SendMailDto {
|
||||
accountId: string;
|
||||
templateId?: string;
|
||||
to: string[]; // 수신자 이메일 배열
|
||||
subject: string;
|
||||
variables?: Record<string, string>; // 템플릿 변수 치환
|
||||
customHtml?: string; // 템플릿 없이 직접 HTML 작성 시
|
||||
}
|
||||
|
||||
export interface MailSendResult {
|
||||
success: boolean;
|
||||
messageId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// API 기본 설정
|
||||
// ============================================
|
||||
|
||||
const API_BASE_URL = '/api/mail';
|
||||
|
||||
async function fetchApi<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: 'Unknown error' }));
|
||||
throw new Error(error.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// 백엔드가 { success: true, data: ... } 형식으로 반환하는 경우 처리
|
||||
if (result.success && result.data !== undefined) {
|
||||
return result.data as T;
|
||||
}
|
||||
|
||||
return result as T;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 메일 계정 API
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 전체 메일 계정 목록 조회
|
||||
*/
|
||||
export async function getMailAccounts(): Promise<MailAccount[]> {
|
||||
return fetchApi<MailAccount[]>('/accounts');
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 메일 계정 조회
|
||||
*/
|
||||
export async function getMailAccount(id: string): Promise<MailAccount> {
|
||||
return fetchApi<MailAccount>(`/accounts/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 계정 생성
|
||||
*/
|
||||
export async function createMailAccount(
|
||||
data: CreateMailAccountDto
|
||||
): Promise<MailAccount> {
|
||||
return fetchApi<MailAccount>('/accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 계정 수정
|
||||
*/
|
||||
export async function updateMailAccount(
|
||||
id: string,
|
||||
data: UpdateMailAccountDto
|
||||
): Promise<MailAccount> {
|
||||
return fetchApi<MailAccount>(`/accounts/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 계정 삭제
|
||||
*/
|
||||
export async function deleteMailAccount(id: string): Promise<{ success: boolean }> {
|
||||
return fetchApi<{ success: boolean }>(`/accounts/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP 연결 테스트
|
||||
*/
|
||||
export async function testMailConnection(id: string): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
}> {
|
||||
return fetchApi<{ success: boolean; message: string }>(
|
||||
`/accounts/${id}/test-connection`,
|
||||
{
|
||||
method: 'POST',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 메일 템플릿 API
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 전체 메일 템플릿 목록 조회
|
||||
*/
|
||||
export async function getMailTemplates(): Promise<MailTemplate[]> {
|
||||
return fetchApi<MailTemplate[]>('/templates-file');
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 메일 템플릿 조회
|
||||
*/
|
||||
export async function getMailTemplate(id: string): Promise<MailTemplate> {
|
||||
return fetchApi<MailTemplate>(`/templates-file/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 템플릿 생성
|
||||
*/
|
||||
export async function createMailTemplate(
|
||||
data: CreateMailTemplateDto
|
||||
): Promise<MailTemplate> {
|
||||
return fetchApi<MailTemplate>('/templates-file', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 템플릿 수정
|
||||
*/
|
||||
export async function updateMailTemplate(
|
||||
id: string,
|
||||
data: UpdateMailTemplateDto
|
||||
): Promise<MailTemplate> {
|
||||
return fetchApi<MailTemplate>(`/templates-file/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 템플릿 삭제
|
||||
*/
|
||||
export async function deleteMailTemplate(id: string): Promise<{ success: boolean }> {
|
||||
return fetchApi<{ success: boolean }>(`/templates-file/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 템플릿 미리보기 (샘플 데이터)
|
||||
*/
|
||||
export async function previewMailTemplate(
|
||||
id: string,
|
||||
sampleData?: Record<string, string>
|
||||
): Promise<{ html: string }> {
|
||||
return fetchApi<{ html: string }>(`/templates-file/${id}/preview`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sampleData }),
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 메일 발송 API (간단한 버전 - 쿼리 제외)
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 메일 발송 (단건 또는 소규모 발송)
|
||||
*/
|
||||
export async function sendMail(data: SendMailDto): Promise<MailSendResult> {
|
||||
return fetchApi<MailSendResult>('/send/simple', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 변수 추출 (템플릿에서 {변수명} 형식 추출)
|
||||
*/
|
||||
export function extractTemplateVariables(template: MailTemplate): string[] {
|
||||
const variableRegex = /\{(\w+)\}/g;
|
||||
const variables = new Set<string>();
|
||||
|
||||
// subject에서 추출
|
||||
const subjectMatches = template.subject.matchAll(variableRegex);
|
||||
for (const match of subjectMatches) {
|
||||
variables.add(match[1]);
|
||||
}
|
||||
|
||||
// 컴포넌트 content에서 추출
|
||||
template.components.forEach((component) => {
|
||||
if (component.content) {
|
||||
const contentMatches = component.content.matchAll(variableRegex);
|
||||
for (const match of contentMatches) {
|
||||
variables.add(match[1]);
|
||||
}
|
||||
}
|
||||
if (component.text) {
|
||||
const textMatches = component.text.matchAll(variableRegex);
|
||||
for (const match of textMatches) {
|
||||
variables.add(match[1]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(variables);
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿을 HTML로 렌더링 (프론트엔드 미리보기용)
|
||||
*/
|
||||
export function renderTemplateToHtml(
|
||||
template: MailTemplate,
|
||||
variables?: Record<string, string>
|
||||
): string {
|
||||
let html = '<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">';
|
||||
|
||||
template.components.forEach((component) => {
|
||||
switch (component.type) {
|
||||
case 'text':
|
||||
let content = component.content || '';
|
||||
if (variables) {
|
||||
Object.entries(variables).forEach(([key, value]) => {
|
||||
content = content.replace(new RegExp(`\\{${key}\\}`, 'g'), value);
|
||||
});
|
||||
}
|
||||
html += `<div style="${styleObjectToString(component.styles)}">${content}</div>`;
|
||||
break;
|
||||
|
||||
case 'button':
|
||||
let buttonText = component.text || 'Button';
|
||||
if (variables) {
|
||||
Object.entries(variables).forEach(([key, value]) => {
|
||||
buttonText = buttonText.replace(new RegExp(`\\{${key}\\}`, 'g'), value);
|
||||
});
|
||||
}
|
||||
html += `
|
||||
<a href="${component.url || '#'}" style="
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
${styleObjectToString(component.styles)}
|
||||
">${buttonText}</a>
|
||||
`;
|
||||
break;
|
||||
|
||||
case 'image':
|
||||
html += `<img src="${component.src || ''}" style="max-width: 100%; ${styleObjectToString(component.styles)}" />`;
|
||||
break;
|
||||
|
||||
case 'spacer':
|
||||
html += `<div style="height: ${component.height || 20}px;"></div>`;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function styleObjectToString(styles?: Record<string, string>): string {
|
||||
if (!styles) return '';
|
||||
return Object.entries(styles)
|
||||
.map(([key, value]) => `${camelToKebab(key)}: ${value}`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
function camelToKebab(str: string): string {
|
||||
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect } from "react";
|
||||
|
||||
export type ThemeMode = "light" | "dark" | "system";
|
||||
export type ColorScheme = "orange" | "blue" | "green" | "purple" | "red";
|
||||
|
||||
export interface ThemeConfig {
|
||||
mode: ThemeMode;
|
||||
colorScheme: ColorScheme;
|
||||
customColors?: {
|
||||
primary?: string;
|
||||
secondary?: string;
|
||||
accent?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: ThemeConfig;
|
||||
setTheme: (theme: Partial<ThemeConfig>) => void;
|
||||
toggleMode: () => void;
|
||||
isDark: boolean;
|
||||
colors: {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
accent: string;
|
||||
background: string;
|
||||
surface: string;
|
||||
text: string;
|
||||
textSecondary: string;
|
||||
border: string;
|
||||
hover: string;
|
||||
};
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
const colorSchemes = {
|
||||
orange: {
|
||||
primary: "#f97316",
|
||||
secondary: "#ea580c",
|
||||
accent: "#fb923c",
|
||||
},
|
||||
blue: {
|
||||
primary: "#3b82f6",
|
||||
secondary: "#2563eb",
|
||||
accent: "#60a5fa",
|
||||
},
|
||||
green: {
|
||||
primary: "#10b981",
|
||||
secondary: "#059669",
|
||||
accent: "#34d399",
|
||||
},
|
||||
purple: {
|
||||
primary: "#8b5cf6",
|
||||
secondary: "#7c3aed",
|
||||
accent: "#a78bfa",
|
||||
},
|
||||
red: {
|
||||
primary: "#ef4444",
|
||||
secondary: "#dc2626",
|
||||
accent: "#f87171",
|
||||
},
|
||||
};
|
||||
|
||||
const lightColors = {
|
||||
background: "#ffffff",
|
||||
surface: "#f8fafc",
|
||||
text: "#1f2937",
|
||||
textSecondary: "#6b7280",
|
||||
border: "#e5e7eb",
|
||||
hover: "#f3f4f6",
|
||||
};
|
||||
|
||||
const darkColors = {
|
||||
background: "#0f172a",
|
||||
surface: "#1e293b",
|
||||
text: "#f1f5f9",
|
||||
textSecondary: "#94a3b8",
|
||||
border: "#334155",
|
||||
hover: "#334155",
|
||||
};
|
||||
|
||||
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [theme, setThemeState] = useState<ThemeConfig>({
|
||||
mode: "system",
|
||||
colorScheme: "orange",
|
||||
});
|
||||
|
||||
const [isDark, setIsDark] = useState(false);
|
||||
|
||||
// 시스템 테마 감지
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handleChange = () => {
|
||||
if (theme.mode === "system") {
|
||||
setIsDark(mediaQuery.matches);
|
||||
}
|
||||
};
|
||||
|
||||
handleChange();
|
||||
mediaQuery.addEventListener("change", handleChange);
|
||||
return () => mediaQuery.removeEventListener("change", handleChange);
|
||||
}, [theme.mode]);
|
||||
|
||||
// 테마 모드에 따른 다크모드 설정
|
||||
useEffect(() => {
|
||||
switch (theme.mode) {
|
||||
case "light":
|
||||
setIsDark(false);
|
||||
break;
|
||||
case "dark":
|
||||
setIsDark(true);
|
||||
break;
|
||||
case "system":
|
||||
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
setIsDark(mediaQuery.matches);
|
||||
break;
|
||||
}
|
||||
}, [theme.mode]);
|
||||
|
||||
const setTheme = (newTheme: Partial<ThemeConfig>) => {
|
||||
setThemeState(prev => ({ ...prev, ...newTheme }));
|
||||
};
|
||||
|
||||
const toggleMode = () => {
|
||||
setThemeState(prev => ({
|
||||
...prev,
|
||||
mode: prev.mode === "light" ? "dark" : "light"
|
||||
}));
|
||||
};
|
||||
|
||||
const baseColors = colorSchemes[theme.colorScheme];
|
||||
const themeColors = isDark ? darkColors : lightColors;
|
||||
|
||||
const colors = {
|
||||
primary: theme.customColors?.primary || baseColors.primary,
|
||||
secondary: theme.customColors?.secondary || baseColors.secondary,
|
||||
accent: theme.customColors?.accent || baseColors.accent,
|
||||
background: themeColors.background,
|
||||
surface: themeColors.surface,
|
||||
text: themeColors.text,
|
||||
textSecondary: themeColors.textSecondary,
|
||||
border: themeColors.border,
|
||||
hover: themeColors.hover,
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{
|
||||
theme,
|
||||
setTheme,
|
||||
toggleMode,
|
||||
isDark,
|
||||
colors,
|
||||
}}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export type Breakpoint = "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
|
||||
|
||||
export interface ResponsiveConfig {
|
||||
breakpoints: Record<Breakpoint, number>;
|
||||
currentBreakpoint: Breakpoint;
|
||||
isMobile: boolean;
|
||||
isTablet: boolean;
|
||||
isDesktop: boolean;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const defaultBreakpoints = {
|
||||
xs: 0,
|
||||
sm: 640,
|
||||
md: 768,
|
||||
lg: 1024,
|
||||
xl: 1280,
|
||||
"2xl": 1536,
|
||||
};
|
||||
|
||||
export const useResponsive = (customBreakpoints?: Partial<Record<Breakpoint, number>>) => {
|
||||
const breakpoints = { ...defaultBreakpoints, ...customBreakpoints };
|
||||
|
||||
const [windowSize, setWindowSize] = useState({
|
||||
width: typeof window !== "undefined" ? window.innerWidth : 1024,
|
||||
height: typeof window !== "undefined" ? window.innerHeight : 768,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setWindowSize({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}, []);
|
||||
|
||||
const getCurrentBreakpoint = (): Breakpoint => {
|
||||
const { width } = windowSize;
|
||||
|
||||
if (width >= breakpoints["2xl"]) return "2xl";
|
||||
if (width >= breakpoints.xl) return "xl";
|
||||
if (width >= breakpoints.lg) return "lg";
|
||||
if (width >= breakpoints.md) return "md";
|
||||
if (width >= breakpoints.sm) return "sm";
|
||||
return "xs";
|
||||
};
|
||||
|
||||
const currentBreakpoint = getCurrentBreakpoint();
|
||||
|
||||
const isMobile = currentBreakpoint === "xs" || currentBreakpoint === "sm";
|
||||
const isTablet = currentBreakpoint === "md";
|
||||
const isDesktop = currentBreakpoint === "lg" || currentBreakpoint === "xl" || currentBreakpoint === "2xl";
|
||||
|
||||
return {
|
||||
breakpoints,
|
||||
currentBreakpoint,
|
||||
isMobile,
|
||||
isTablet,
|
||||
isDesktop,
|
||||
width: windowSize.width,
|
||||
height: windowSize.height,
|
||||
};
|
||||
};
|
||||
|
|
@ -15,32 +15,35 @@ const PanelRenderer: ComponentRenderer = ({ component, children, ...props }) =>
|
|||
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
|
||||
|
||||
return (
|
||||
<Card className="h-full w-full" style={style}>
|
||||
<CardHeader className="pb-2">
|
||||
<Card
|
||||
className="h-full w-full border-2 border-gray-200 shadow-lg hover:shadow-xl transition-all duration-300 hover:border-orange-300"
|
||||
style={style}
|
||||
>
|
||||
<CardHeader className="pb-2 bg-gradient-to-r from-slate-50 to-gray-50 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">{title}</CardTitle>
|
||||
<CardTitle className="text-lg font-semibold text-gray-800">{title}</CardTitle>
|
||||
{collapsible && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="pointer-events-none h-6 w-6 p-0"
|
||||
className="pointer-events-none h-6 w-6 p-0 hover:bg-orange-100 rounded-full"
|
||||
disabled
|
||||
>
|
||||
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
{isExpanded ? <ChevronUp className="h-4 w-4 text-orange-600" /> : <ChevronDown className="h-4 w-4 text-orange-600" />}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
{isExpanded && (
|
||||
<CardContent className="flex-1">
|
||||
<CardContent className="flex-1 bg-white">
|
||||
{children && React.Children.count(children) > 0 ? (
|
||||
children
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-center">
|
||||
<div>
|
||||
<div className="text-sm text-gray-600">패널 내용 영역</div>
|
||||
<div className="mt-1 text-xs text-gray-400">컴포넌트를 여기에 배치하세요</div>
|
||||
<div className="flex h-full items-center justify-center text-center p-8">
|
||||
<div className="bg-gray-50 rounded-lg p-6 border-2 border-dashed border-gray-300">
|
||||
<div className="text-sm text-gray-600 font-medium">패널 내용 영역</div>
|
||||
<div className="mt-2 text-xs text-gray-400">컴포넌트를 여기에 배치하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -89,26 +89,32 @@ const CustomAccordion: React.FC<CustomAccordionProps> = ({
|
|||
onClick={() => toggleItem(item.id)}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "12px 16px",
|
||||
padding: "16px 20px",
|
||||
textAlign: "left",
|
||||
borderTop: "1px solid #e5e7eb",
|
||||
borderLeft: "1px solid #e5e7eb",
|
||||
borderRight: "1px solid #e5e7eb",
|
||||
borderBottom: openItems.has(item.id) ? "none" : index === items.length - 1 ? "1px solid #e5e7eb" : "none",
|
||||
backgroundColor: "#f9fafb",
|
||||
backgroundColor: openItems.has(item.id) ? "#fef3c7" : "#f9fafb",
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
fontSize: "14px",
|
||||
fontWeight: "500",
|
||||
transition: "all 0.2s ease",
|
||||
fontWeight: "600",
|
||||
transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
borderRadius: openItems.has(item.id) ? "8px 8px 0 0" : "0",
|
||||
boxShadow: openItems.has(item.id) ? "0 2px 4px rgba(0, 0, 0, 0.1)" : "none",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#f3f4f6";
|
||||
e.currentTarget.style.backgroundColor = openItems.has(item.id) ? "#fde68a" : "#f3f4f6";
|
||||
e.currentTarget.style.transform = "translateY(-1px)";
|
||||
e.currentTarget.style.boxShadow = "0 4px 8px rgba(0, 0, 0, 0.15)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = "#f9fafb";
|
||||
e.currentTarget.style.backgroundColor = openItems.has(item.id) ? "#fef3c7" : "#f9fafb";
|
||||
e.currentTarget.style.transform = "translateY(0)";
|
||||
e.currentTarget.style.boxShadow = openItems.has(item.id) ? "0 2px 4px rgba(0, 0, 0, 0.1)" : "none";
|
||||
}}
|
||||
>
|
||||
<span>{item.title}</span>
|
||||
|
|
@ -125,13 +131,16 @@ const CustomAccordion: React.FC<CustomAccordionProps> = ({
|
|||
<div
|
||||
className="accordion-content"
|
||||
style={{
|
||||
maxHeight: openItems.has(item.id) ? "200px" : "0px",
|
||||
maxHeight: openItems.has(item.id) ? "500px" : "0px",
|
||||
overflow: "hidden",
|
||||
transition: "max-height 0.3s ease",
|
||||
transition: "max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
borderLeft: openItems.has(item.id) ? "1px solid #e5e7eb" : "none",
|
||||
borderRight: openItems.has(item.id) ? "1px solid #e5e7eb" : "none",
|
||||
borderTop: "none",
|
||||
borderBottom: index === items.length - 1 ? "1px solid #e5e7eb" : "none",
|
||||
backgroundColor: "#ffffff",
|
||||
borderRadius: openItems.has(item.id) ? "0 0 8px 8px" : "0",
|
||||
boxShadow: openItems.has(item.id) ? "0 2px 4px rgba(0, 0, 0, 0.1)" : "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -109,14 +109,28 @@ export const CheckboxBasicComponent: React.FC<CheckboxBasicComponentProps> = ({
|
|||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
gap: "12px",
|
||||
cursor: "pointer",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
fontSize: "14px",
|
||||
padding: "12px",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #e5e7eb",
|
||||
backgroundColor: "#f9fafb",
|
||||
transition: "all 0.2s ease-in-out",
|
||||
boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = "#f97316";
|
||||
e.currentTarget.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.1)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = "#e5e7eb";
|
||||
e.currentTarget.style.boxShadow = "0 1px 2px 0 rgba(0, 0, 0, 0.05)";
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
|
|
|
|||
|
|
@ -322,13 +322,23 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
|||
width: "100%",
|
||||
height: "100%",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: "4px",
|
||||
borderRadius: "8px",
|
||||
padding: "8px 12px",
|
||||
fontSize: "14px",
|
||||
outline: "none",
|
||||
transition: "all 0.2s ease-in-out",
|
||||
boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.target.style.borderColor = "#f97316";
|
||||
e.target.style.boxShadow = "0 0 0 3px rgba(249, 115, 22, 0.1)";
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.target.style.borderColor = "#d1d5db";
|
||||
e.target.style.boxShadow = "0 1px 2px 0 rgba(0, 0, 0, 0.05)";
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
|
|
|
|||
|
|
@ -95,12 +95,22 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
|
|||
width: "100%",
|
||||
height: "100%",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: "4px",
|
||||
borderRadius: "8px",
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
backgroundColor: "#f9fafb",
|
||||
transition: "all 0.2s ease-in-out",
|
||||
boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = "#f97316";
|
||||
e.currentTarget.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.1)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = "#d1d5db";
|
||||
e.currentTarget.style.boxShadow = "0 1px 2px 0 rgba(0, 0, 0, 0.05)";
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
|
|
|
|||
|
|
@ -123,13 +123,23 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
|
|||
width: "100%",
|
||||
height: "100%",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: "4px",
|
||||
borderRadius: "8px",
|
||||
padding: "8px 12px",
|
||||
fontSize: "14px",
|
||||
outline: "none",
|
||||
transition: "all 0.2s ease-in-out",
|
||||
boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.target.style.borderColor = "#f97316";
|
||||
e.target.style.boxShadow = "0 0 0 3px rgba(249, 115, 22, 0.1)";
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.target.style.borderColor = "#d1d5db";
|
||||
e.target.style.boxShadow = "0 1px 2px 0 rgba(0, 0, 0, 0.05)";
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
|
|
|
|||
|
|
@ -111,11 +111,24 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
|
|||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: componentConfig.direction === "horizontal" ? "row" : "column",
|
||||
gap: "8px",
|
||||
padding: "8px",
|
||||
gap: "12px",
|
||||
padding: "12px",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #e5e7eb",
|
||||
backgroundColor: "#f9fafb",
|
||||
transition: "all 0.2s ease-in-out",
|
||||
boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = "#f97316";
|
||||
e.currentTarget.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.1)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = "#e5e7eb";
|
||||
e.currentTarget.style.boxShadow = "0 1px 2px 0 rgba(0, 0, 0, 0.05)";
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
|
|
|
|||
|
|
@ -329,10 +329,24 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
|
||||
{/* 커스텀 셀렉트 박스 */}
|
||||
<div
|
||||
className={`flex w-full cursor-pointer items-center justify-between rounded-md border border-gray-300 bg-white px-3 py-2 ${isDesignMode ? "pointer-events-none" : "hover:border-gray-400"} ${isSelected ? "ring-2 ring-blue-500" : ""} ${isOpen ? "border-blue-500" : ""} `}
|
||||
className={`flex w-full cursor-pointer items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2 ${isDesignMode ? "pointer-events-none" : "hover:border-orange-400"} ${isSelected ? "ring-2 ring-orange-500" : ""} ${isOpen ? "border-orange-500" : ""} `}
|
||||
onClick={handleToggle}
|
||||
style={{
|
||||
pointerEvents: isDesignMode ? "none" : "auto",
|
||||
transition: "all 0.2s ease-in-out",
|
||||
boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isDesignMode) {
|
||||
e.currentTarget.style.borderColor = "#f97316";
|
||||
e.currentTarget.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.1)";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isDesignMode) {
|
||||
e.currentTarget.style.borderColor = "#d1d5db";
|
||||
e.currentTarget.style.boxShadow = "0 1px 2px 0 rgba(0, 0, 0, 0.05)";
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className={selectedLabel ? "text-gray-900" : "text-gray-500"}>{selectedLabel || placeholder}</span>
|
||||
|
|
|
|||
|
|
@ -101,10 +101,20 @@ export const TestInputComponent: React.FC<TestInputComponentProps> = ({
|
|||
width: "100%",
|
||||
height: "100%",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: "4px",
|
||||
borderRadius: "8px",
|
||||
padding: "8px 12px",
|
||||
fontSize: "14px",
|
||||
outline: "none",
|
||||
transition: "all 0.2s ease-in-out",
|
||||
boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.target.style.borderColor = "#f97316";
|
||||
e.target.style.boxShadow = "0 0 0 3px rgba(249, 115, 22, 0.1)";
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.target.style.borderColor = "#d1d5db";
|
||||
e.target.style.boxShadow = "0 1px 2px 0 rgba(0, 0, 0, 0.05)";
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
|
|
|
|||
|
|
@ -75,9 +75,9 @@ export const TextDisplayComponent: React.FC<TextDisplayComponentProps> = ({
|
|||
color: componentConfig.color || "#3b83f6",
|
||||
textAlign: componentConfig.textAlign || "left",
|
||||
backgroundColor: componentConfig.backgroundColor || "transparent",
|
||||
padding: componentConfig.padding || "0",
|
||||
borderRadius: componentConfig.borderRadius || "0",
|
||||
border: componentConfig.border || "none",
|
||||
padding: componentConfig.padding || "8px 12px",
|
||||
borderRadius: componentConfig.borderRadius || "8px",
|
||||
border: componentConfig.border || "1px solid #e5e7eb",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
|
|
@ -90,6 +90,8 @@ export const TextDisplayComponent: React.FC<TextDisplayComponentProps> = ({
|
|||
: "flex-start",
|
||||
wordBreak: "break-word",
|
||||
overflow: "hidden",
|
||||
transition: "all 0.2s ease-in-out",
|
||||
boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -237,13 +237,23 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
width: "100%",
|
||||
height: "100%",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: "4px",
|
||||
borderRadius: "8px",
|
||||
padding: "8px 12px",
|
||||
fontSize: "14px",
|
||||
outline: "none",
|
||||
transition: "all 0.2s ease-in-out",
|
||||
boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.target.style.borderColor = "#f97316";
|
||||
e.target.style.boxShadow = "0 0 0 3px rgba(249, 115, 22, 0.1)";
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.target.style.borderColor = "#d1d5db";
|
||||
e.target.style.boxShadow = "0 1px 2px 0 rgba(0, 0, 0, 0.05)";
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
|
|
|
|||
|
|
@ -116,14 +116,24 @@ export const TextareaBasicComponent: React.FC<TextareaBasicComponentProps> = ({
|
|||
width: "100%",
|
||||
height: "100%",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: "4px",
|
||||
borderRadius: "8px",
|
||||
padding: "8px 12px",
|
||||
fontSize: "14px",
|
||||
outline: "none",
|
||||
resize: "none",
|
||||
transition: "all 0.2s ease-in-out",
|
||||
boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.target.style.borderColor = "#f97316";
|
||||
e.target.style.boxShadow = "0 0 0 3px rgba(249, 115, 22, 0.1)";
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.target.style.borderColor = "#d1d5db";
|
||||
e.target.style.boxShadow = "0 1px 2px 0 rgba(0, 0, 0, 0.05)";
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
|
|
|
|||
|
|
@ -114,9 +114,23 @@ export const ToggleSwitchComponent: React.FC<ToggleSwitchComponentProps> = ({
|
|||
width: "100%",
|
||||
height: "100%",
|
||||
fontSize: "14px",
|
||||
padding: "12px",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid #e5e7eb",
|
||||
backgroundColor: "#f9fafb",
|
||||
transition: "all 0.2s ease-in-out",
|
||||
boxShadow: "0 1px 2px 0 rgba(0, 0, 0, 0.05)",
|
||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||
...(isInteractive && component.style ? component.style : {}),
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.borderColor = "#f97316";
|
||||
e.currentTarget.style.boxShadow = "0 4px 6px -1px rgba(0, 0, 0, 0.1)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.borderColor = "#e5e7eb";
|
||||
e.currentTarget.style.boxShadow = "0 1px 2px 0 rgba(0, 0, 0, 0.05)";
|
||||
}}
|
||||
onClick={handleClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,15 @@ const nextConfig = {
|
|||
outputFileTracingRoot: undefined,
|
||||
},
|
||||
|
||||
// 프록시 설정 제거 - 모든 API가 직접 백엔드 호출
|
||||
// API 프록시 설정 - 백엔드로 요청 전달
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: "/api/:path*",
|
||||
destination: "http://host.docker.internal:8080/api/:path*",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
// 개발 환경에서 CORS 처리
|
||||
async headers() {
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@
|
|||
"react-hook-form": "^7.62.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-window": "^2.1.0",
|
||||
"recharts": "^3.2.1",
|
||||
"sheetjs-style": "^0.15.8",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
|
|
@ -2279,6 +2280,32 @@
|
|||
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.9.0.tgz",
|
||||
"integrity": "sha512-fSfQlSRu9Z5yBkvsNhYF2rPS8cGXn/TZVrlwN1948QyZ8xMZ0JvP50S2acZNaf+o63u6aEeMjipFyksjIcWrog==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^10.0.3",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rtsao/scc": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
|
||||
|
|
@ -2297,7 +2324,6 @@
|
|||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
|
||||
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
|
|
@ -2690,6 +2716,12 @@
|
|||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
|
|
@ -2705,6 +2737,12 @@
|
|||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
|
|
@ -2714,12 +2752,48 @@
|
|||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-selection": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
|
||||
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-transition": {
|
||||
"version": "3.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||
|
|
@ -2798,6 +2872,12 @@
|
|||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.44.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.1.tgz",
|
||||
|
|
@ -4139,6 +4219,18 @@
|
|||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
|
|
@ -4179,6 +4271,15 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
|
||||
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
|
|
@ -4191,6 +4292,31 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-selection": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
|
|
@ -4200,6 +4326,42 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
|
|
@ -4339,6 +4501,12 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
|
|
@ -4710,6 +4878,16 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.39.10",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.39.10.tgz",
|
||||
"integrity": "sha512-E0iGnTtbDhkeczB0T+mxmoVlT4YNweEKBLq7oaU4p11mecdsZpNWOglI4895Vh4usbQ+LsJiuLuI2L0Vdmfm2w==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/escape-string-regexp": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||
|
|
@ -5196,6 +5374,12 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
||||
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/exit-on-epipe": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/exit-on-epipe/-/exit-on-epipe-1.0.1.tgz",
|
||||
|
|
@ -5748,6 +5932,16 @@
|
|||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.1.3",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.1.3.tgz",
|
||||
"integrity": "sha512-tmjF/k8QDKydUlm3mZU+tjM6zeq9/fFpPqH9SzWmBnVVKsPBg/V66qsMwb3/Bo90cgUN+ghdVBess+hPsxUyRw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
|
|
@ -5796,6 +5990,15 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-array-buffer": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||
|
|
@ -7636,9 +7839,31 @@
|
|||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-remove-scroll": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz",
|
||||
|
|
@ -7753,6 +7978,48 @@
|
|||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.2.1.tgz",
|
||||
"integrity": "sha512-0JKwHRiFZdmLq/6nmilxEZl3pqb4T+aKkOkOi/ZISRZwfBhVMgInxzlYU9D4KnCH3KINScLy68m/OvMXoYGZUw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "1.x.x || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reflect.getprototypeof": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||
|
|
@ -7797,6 +8064,12 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.10",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
||||
|
|
@ -8491,6 +8764,12 @@
|
|||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz",
|
||||
|
|
@ -8841,6 +9120,28 @@
|
|||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@
|
|||
"react-hook-form": "^7.62.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-window": "^2.1.0",
|
||||
"recharts": "^3.2.1",
|
||||
"sheetjs-style": "^0.15.8",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,494 @@
|
|||
# 메일 관리 시스템 구현 계획서
|
||||
|
||||
## 📋 프로젝트 개요
|
||||
|
||||
**목적**: SMTP 기반 메일 계정 관리 및 드래그 앤 드롭 메일 템플릿 디자이너 구축
|
||||
**방식**: 파일 시스템 기반 (DB 테이블 불필요)
|
||||
**저장 위치**: `uploads/mail-accounts/`, `uploads/mail-templates/`
|
||||
|
||||
---
|
||||
|
||||
## 🎯 핵심 기능
|
||||
|
||||
### 1. 메일 계정 관리
|
||||
- SMTP 계정 등록/수정/삭제
|
||||
- 비밀번호 AES-256 암호화 저장
|
||||
- 계정 상태 관리 (활성/비활성)
|
||||
- 일일 발송 제한 설정
|
||||
|
||||
### 2. 메일 템플릿 관리
|
||||
- 드래그 앤 드롭 에디터
|
||||
- 컴포넌트 기반 디자인
|
||||
- 텍스트 (HTML 편집)
|
||||
- 버튼 (링크, 색상 설정)
|
||||
- 이미지
|
||||
- 여백
|
||||
- 실시간 미리보기
|
||||
- 템플릿 저장/불러오기
|
||||
|
||||
### 3. SQL 쿼리 연동
|
||||
- 쿼리 파라미터 자동 감지 (`$1`, `$2`, ...)
|
||||
- 동적 변수 치환 (`{customer_name}` → 실제 값)
|
||||
- 쿼리 결과로 수신자 자동 선택
|
||||
- 이메일 필드 자동 감지
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 시스템 아키텍처
|
||||
|
||||
### 백엔드 (Node.js + TypeScript)
|
||||
|
||||
#### **파일 구조**
|
||||
```
|
||||
backend-node/src/
|
||||
├── services/
|
||||
│ ├── mailAccountFileService.ts # 메일 계정 관리
|
||||
│ ├── mailTemplateFileService.ts # 템플릿 관리
|
||||
│ ├── mailQueryService.ts # SQL 쿼리 빌더
|
||||
│ └── encryptionService.ts # AES-256 암호화
|
||||
├── controllers/
|
||||
│ ├── mailAccountFileController.ts # 계정 API
|
||||
│ ├── mailTemplateFileController.ts # 템플릿 API
|
||||
│ └── mailQueryController.ts # 쿼리 API
|
||||
└── routes/
|
||||
├── mailAccountFileRoutes.ts # /api/mail/accounts
|
||||
├── mailTemplateFileRoutes.ts # /api/mail/templates-file
|
||||
└── mailQueryRoutes.ts # /api/mail/query
|
||||
```
|
||||
|
||||
#### **API 엔드포인트**
|
||||
|
||||
**메일 계정 API** (`/api/mail/accounts`)
|
||||
- `GET /` - 전체 계정 목록
|
||||
- `GET /:id` - 특정 계정 조회
|
||||
- `POST /` - 계정 생성
|
||||
- `PUT /:id` - 계정 수정
|
||||
- `DELETE /:id` - 계정 삭제
|
||||
- `POST /:id/test-connection` - SMTP 연결 테스트
|
||||
|
||||
**메일 템플릿 API** (`/api/mail/templates-file`)
|
||||
- `GET /` - 전체 템플릿 목록
|
||||
- `GET /:id` - 특정 템플릿 조회
|
||||
- `POST /` - 템플릿 생성
|
||||
- `PUT /:id` - 템플릿 수정
|
||||
- `DELETE /:id` - 템플릿 삭제
|
||||
- `POST /:id/preview` - 미리보기 (샘플 데이터)
|
||||
- `POST /:id/preview-with-query` - 쿼리 결과 미리보기
|
||||
|
||||
**SQL 쿼리 API** (`/api/mail/query`)
|
||||
- `POST /detect-parameters` - 쿼리 파라미터 자동 감지
|
||||
- `POST /test` - 쿼리 테스트 실행
|
||||
- `POST /execute` - 쿼리 실행
|
||||
- `POST /extract-variables` - 템플릿 변수 추출
|
||||
- `POST /validate-mapping` - 변수 매핑 검증
|
||||
- `POST /process-mail-data` - 대량 메일 데이터 처리
|
||||
|
||||
---
|
||||
|
||||
### 프론트엔드 (Next.js 14)
|
||||
|
||||
#### **페이지 구조**
|
||||
```
|
||||
frontend/app/(main)/mail/
|
||||
├── accounts/page.tsx # 메일 계정 관리
|
||||
├── templates/page.tsx # 메일 템플릿 관리
|
||||
├── send/page.tsx # 메일 발송
|
||||
└── receive/page.tsx # 메일 수신함
|
||||
|
||||
frontend/components/mail/
|
||||
└── MailDesigner.tsx # 드래그 앤 드롭 에디터
|
||||
```
|
||||
|
||||
#### **주요 컴포넌트**
|
||||
|
||||
**MailDesigner** - 드래그 앤 드롭 메일 에디터
|
||||
- **왼쪽 패널**: 컴포넌트 팔레트, 템플릿 정보, 액션 버튼
|
||||
- **중앙 캔버스**: 실시간 미리보기, 컴포넌트 선택/삭제
|
||||
- **오른쪽 패널**: 속성 편집 (선택된 컴포넌트)
|
||||
|
||||
**컴포넌트 타입**
|
||||
```typescript
|
||||
interface MailComponent {
|
||||
id: string;
|
||||
type: "text" | "button" | "image" | "spacer";
|
||||
content?: string; // 텍스트 HTML
|
||||
text?: string; // 버튼 텍스트
|
||||
url?: string; // 링크/이미지 URL
|
||||
src?: string; // 이미지 소스
|
||||
height?: number; // 여백 높이
|
||||
styles?: Record<string, string>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💾 데이터 저장 구조
|
||||
|
||||
### 파일 시스템 기반 (JSON)
|
||||
|
||||
#### **메일 계정** (`uploads/mail-accounts/{account-id}.json`)
|
||||
```json
|
||||
{
|
||||
"id": "account-1735970000000",
|
||||
"name": "회사 공식 메일",
|
||||
"email": "info@company.com",
|
||||
"smtpHost": "smtp.gmail.com",
|
||||
"smtpPort": 587,
|
||||
"smtpSecure": false,
|
||||
"smtpUsername": "info@company.com",
|
||||
"smtpPassword": "암호화된_비밀번호",
|
||||
"dailyLimit": 1000,
|
||||
"status": "active",
|
||||
"createdAt": "2025-01-04T12:00:00Z",
|
||||
"updatedAt": "2025-01-04T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
#### **메일 템플릿** (`uploads/mail-templates/{template-id}.json`)
|
||||
```json
|
||||
{
|
||||
"id": "template-1735970100000",
|
||||
"name": "고객 환영 메일",
|
||||
"subject": "{customer_name}님 환영합니다!",
|
||||
"components": [
|
||||
{
|
||||
"id": "comp-1",
|
||||
"type": "text",
|
||||
"content": "<p>안녕하세요, {customer_name}님!</p>",
|
||||
"styles": {
|
||||
"fontSize": "16px",
|
||||
"color": "#333"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "comp-2",
|
||||
"type": "button",
|
||||
"text": "시작하기",
|
||||
"url": "https://example.com/start",
|
||||
"styles": {
|
||||
"backgroundColor": "#007bff",
|
||||
"color": "#fff"
|
||||
}
|
||||
}
|
||||
],
|
||||
"queryConfig": {
|
||||
"queries": [
|
||||
{
|
||||
"id": "q-1",
|
||||
"name": "고객 목록",
|
||||
"sql": "SELECT name AS customer_name, email FROM customers WHERE active = $1",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "$1",
|
||||
"type": "boolean",
|
||||
"value": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"recipientConfig": {
|
||||
"type": "query",
|
||||
"emailField": "email",
|
||||
"nameField": "customer_name",
|
||||
"queryId": "q-1"
|
||||
},
|
||||
"category": "welcome",
|
||||
"createdAt": "2025-01-04T12:00:00Z",
|
||||
"updatedAt": "2025-01-04T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔒 보안 고려사항
|
||||
|
||||
### 1. 비밀번호 암호화
|
||||
- **알고리즘**: AES-256-CBC
|
||||
- **키 관리**: 환경변수 (`ENCRYPTION_KEY`)
|
||||
- **저장**: 암호화된 상태로 JSON 파일에 저장
|
||||
|
||||
### 2. API 보안
|
||||
- JWT 기반 인증 (기존 시스템 활용)
|
||||
- 비밀번호 반환 시 마스킹 처리 (`••••••••`)
|
||||
- 입력값 검증 및 Sanitization
|
||||
|
||||
### 3. 파일 시스템 보안
|
||||
- 저장 디렉토리 권한 제한
|
||||
- 파일명 검증 (Path Traversal 방지)
|
||||
|
||||
---
|
||||
|
||||
## 📈 성능 최적화
|
||||
|
||||
### 1. 파일 I/O 최적화
|
||||
- 비동기 파일 읽기/쓰기 (`fs.promises`)
|
||||
- 목록 조회 시 병렬 처리 (`Promise.all`)
|
||||
|
||||
### 2. 캐싱 전략
|
||||
- 프론트엔드: React Query로 API 응답 캐싱
|
||||
- 백엔드: 필요 시 메모리 캐시 추가 가능
|
||||
|
||||
### 3. 대량 메일 처리
|
||||
- 쿼리 결과를 스트림으로 처리
|
||||
- 배치 단위 메일 발송 (추후 구현)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 구현 단계
|
||||
|
||||
### ✅ Phase 1: 파일 기반 저장 시스템 (완료)
|
||||
- [x] mailAccountFileService
|
||||
- [x] mailTemplateFileService
|
||||
- [x] encryptionService
|
||||
|
||||
### ✅ Phase 2: SQL 쿼리 빌더 (완료)
|
||||
- [x] mailQueryService
|
||||
- [x] 쿼리 파라미터 감지
|
||||
- [x] 동적 변수 치환
|
||||
- [x] 쿼리 실행 및 결과 처리
|
||||
|
||||
### ✅ Phase 3: 백엔드 API (완료)
|
||||
- [x] Controllers
|
||||
- [x] Routes
|
||||
- [x] app.ts 통합
|
||||
|
||||
### ✅ Phase 4: 프론트엔드 UI (완료)
|
||||
- [x] 메일 계정 관리 페이지
|
||||
- [x] 메일 템플릿 관리 페이지
|
||||
- [x] MailDesigner 컴포넌트
|
||||
- [x] 드래그 앤 드롭 에디터
|
||||
- [x] 메일 대시보드 페이지
|
||||
- [x] URL 구조 변경 (`/admin/mail/*`)
|
||||
|
||||
### 🔜 Phase 5: 세부 기능 구현 (진행 예정)
|
||||
#### 5-1. 메일 계정 관리 (우선순위 ⭐⭐⭐)
|
||||
- [ ] 계정 추가 모달 구현
|
||||
- [ ] 계정 수정 모달 구현
|
||||
- [ ] 계정 삭제 확인 모달
|
||||
- [ ] SMTP 연결 테스트 기능
|
||||
- [ ] 계정 목록 테이블 (정렬, 검색)
|
||||
- [ ] 계정 상태 토글 (활성/비활성)
|
||||
- [ ] 일일 발송 제한 표시/수정
|
||||
|
||||
#### 5-2. 메일 템플릿 관리 (우선순위 ⭐⭐⭐)
|
||||
- [ ] 템플릿 목록 카드/테이블 뷰
|
||||
- [ ] MailDesigner 통합 (생성/수정 모드)
|
||||
- [ ] 템플릿 미리보기 모달
|
||||
- [ ] 템플릿 삭제 확인
|
||||
- [ ] 템플릿 카테고리 관리
|
||||
- [ ] 템플릿 검색/필터링
|
||||
- [ ] 템플릿 복사 기능
|
||||
|
||||
#### 5-3. SQL 쿼리 빌더 (우선순위 ⭐⭐)
|
||||
- [ ] 쿼리 에디터 컴포넌트
|
||||
- [ ] 파라미터 자동 감지 UI
|
||||
- [ ] 쿼리 테스트 실행 버튼
|
||||
- [ ] 결과 테이블 표시
|
||||
- [ ] 변수 매핑 UI (템플릿 변수 ↔ 쿼리 결과)
|
||||
- [ ] 이메일 필드 자동 감지
|
||||
- [ ] 수신자 미리보기
|
||||
|
||||
#### 5-4. 메일 발송 시스템 (우선순위 ⭐⭐)
|
||||
- [ ] 발송 폼 UI (계정 선택, 템플릿 선택)
|
||||
- [ ] 수신자 입력 방식 선택 (직접 입력 / SQL 쿼리)
|
||||
- [ ] 발송 전 미리보기
|
||||
- [ ] 실제 메일 발송 API 구현 (Nodemailer)
|
||||
- [ ] 발송 큐 관리
|
||||
- [ ] 발송 이력 저장 (파일 or DB)
|
||||
- [ ] 발송 진행 상태 표시
|
||||
- [ ] 실패 시 재시도 로직
|
||||
|
||||
#### 5-5. 대시보드 통계 (우선순위 ⭐)
|
||||
- [ ] 실제 발송 건수 집계
|
||||
- [ ] 성공률 계산
|
||||
- [ ] 최근 발송 이력 표시
|
||||
- [ ] 계정별 발송 통계
|
||||
- [ ] 일별/주별/월별 차트
|
||||
|
||||
#### 5-6. 메일 수신함 (우선순위 낮음, 보류)
|
||||
- [ ] IMAP/POP3 연동
|
||||
- [ ] 메일 파싱
|
||||
- [ ] 첨부파일 처리
|
||||
- [ ] 수신함 UI
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 🎯 구현 우선순위 및 순서
|
||||
|
||||
### 📅 **추천 구현 순서**
|
||||
|
||||
#### **1단계: 메일 계정 관리** (2-3일 예상)
|
||||
메일 발송의 기반이 되므로 최우선 구현 필요
|
||||
- 계정 CRUD 모달
|
||||
- SMTP 연결 테스트
|
||||
- 목록 조회/검색
|
||||
|
||||
#### **2단계: 메일 템플릿 관리** (3-4일 예상)
|
||||
MailDesigner 통합 및 템플릿 저장/불러오기
|
||||
- 템플릿 CRUD
|
||||
- MailDesigner 통합
|
||||
- 미리보기 기능
|
||||
|
||||
#### **3단계: 메일 발송 시스템** (4-5일 예상)
|
||||
실제 메일 발송 기능 구현
|
||||
- Nodemailer 연동
|
||||
- 발송 폼 및 미리보기
|
||||
- 발송 이력 관리
|
||||
|
||||
#### **4단계: SQL 쿼리 빌더** (3-4일 예상)
|
||||
대량 발송을 위한 쿼리 연동
|
||||
- 쿼리 에디터
|
||||
- 변수 매핑
|
||||
- 결과 미리보기
|
||||
|
||||
#### **5단계: 대시보드 통계** (2-3일 예상)
|
||||
실제 데이터 기반 통계 표시
|
||||
- 발송 건수 집계
|
||||
- 차트 및 통계
|
||||
|
||||
---
|
||||
|
||||
## 📝 사용 방법 (구현 후)
|
||||
|
||||
### 1. 메일 계정 등록
|
||||
1. 메뉴: "메일 관리" → "메일 계정 관리" (`/admin/mail/accounts`)
|
||||
2. "새 계정 추가" 버튼 클릭
|
||||
3. SMTP 정보 입력 (호스트, 포트, 인증 정보)
|
||||
4. 연결 테스트 → 성공 시 저장
|
||||
5. → `uploads/mail-accounts/account-{timestamp}.json` 생성
|
||||
|
||||
### 2. 메일 템플릿 디자인
|
||||
1. 메뉴: "메일 관리" → "메일 템플릿 관리" (`/admin/mail/templates`)
|
||||
2. "새 템플릿 만들기" 버튼 클릭
|
||||
3. 드래그 앤 드롭 에디터(MailDesigner) 사용:
|
||||
- **왼쪽**: 컴포넌트 팔레트 (텍스트, 버튼, 이미지, 여백)
|
||||
- **중앙**: 실시간 미리보기 캔버스
|
||||
- **오른쪽**: 선택된 컴포넌트 속성 편집
|
||||
4. SQL 쿼리 설정 (선택 사항):
|
||||
- 쿼리 작성 → 파라미터 자동 감지
|
||||
- 결과 변수를 템플릿 변수에 매핑
|
||||
5. 저장 → `uploads/mail-templates/template-{timestamp}.json` 생성
|
||||
|
||||
### 3. 메일 발송
|
||||
1. 메뉴: "메일 관리" → "메일 발송" (`/admin/mail/send`)
|
||||
2. 발송 계정 선택 (등록된 SMTP 계정)
|
||||
3. 템플릿 선택 (또는 직접 작성)
|
||||
4. 수신자 설정:
|
||||
- **직접 입력**: 이메일 주소 입력
|
||||
- **SQL 쿼리**: 템플릿의 쿼리 사용 또는 새 쿼리 작성
|
||||
5. 미리보기 확인 (샘플 데이터로 렌더링)
|
||||
6. 발송 실행 → 발송 큐에 추가 → 순차 발송
|
||||
7. 발송 이력 확인
|
||||
|
||||
### 4. 대시보드 확인
|
||||
1. 메뉴: "메일 관리" → "메일 대시보드" (`/admin/mail/dashboard`)
|
||||
2. 통계 카드:
|
||||
- 총 발송 건수
|
||||
- 성공/실패 건수 및 성공률
|
||||
- 오늘/이번 주/이번 달 발송 건수
|
||||
3. 차트:
|
||||
- 일별/주별 발송 추이
|
||||
- 계정별 발송 통계
|
||||
4. 최근 발송 이력 테이블
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 개발 환경 설정
|
||||
|
||||
### 필수 환경변수
|
||||
```bash
|
||||
# docker/dev/docker-compose.backend.mac.yml
|
||||
ENCRYPTION_KEY=ilshin-plm-mail-encryption-key-32characters-2024-secure
|
||||
```
|
||||
|
||||
### 저장 디렉토리 생성
|
||||
```bash
|
||||
mkdir -p uploads/mail-accounts
|
||||
mkdir -p uploads/mail-templates
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 참고 문서
|
||||
|
||||
- [Nodemailer Documentation](https://nodemailer.com/)
|
||||
- [Node.js Crypto Module](https://nodejs.org/api/crypto.html)
|
||||
- [Next.js File System Routing](https://nextjs.org/docs/app/building-your-application/routing)
|
||||
|
||||
---
|
||||
|
||||
## 📌 주의사항
|
||||
|
||||
1. **DB 테이블 불필요**: 모든 데이터는 파일 시스템에 JSON으로 저장
|
||||
2. **메뉴 연결**: 메뉴 관리 UI에서 각 메뉴의 URL만 설정 (✅ 완료)
|
||||
- 메일 대시보드: `/admin/mail/dashboard`
|
||||
- 메일 계정: `/admin/mail/accounts`
|
||||
- 메일 템플릿: `/admin/mail/templates`
|
||||
- 메일 발송: `/admin/mail/send`
|
||||
- 메일 수신함: `/admin/mail/receive`
|
||||
3. **보안**: 비밀번호는 항상 암호화되어 저장됨
|
||||
4. **백업**: `uploads/` 폴더를 정기적으로 백업 권장
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## 🔍 다음 단계: Phase 5-1 메일 계정 관리 상세 구현 계획
|
||||
|
||||
### 📋 **필요한 컴포넌트**
|
||||
|
||||
1. **MailAccountModal.tsx** (계정 추가/수정 모달)
|
||||
- Form 필드: 계정명, 이메일, SMTP 호스트/포트, 인증정보, 일일 제한
|
||||
- SMTP 연결 테스트 버튼
|
||||
- 저장/취소 버튼
|
||||
|
||||
2. **MailAccountTable.tsx** (계정 목록 테이블)
|
||||
- 컬럼: 계정명, 이메일, 상태, 일일 제한, 생성일, 액션
|
||||
- 정렬/검색 기능
|
||||
- 상태 토글 (활성/비활성)
|
||||
- 수정/삭제 버튼
|
||||
|
||||
3. **ConfirmDeleteModal.tsx** (삭제 확인 모달)
|
||||
- 재사용 가능한 확인 모달 컴포넌트
|
||||
|
||||
### 🔌 **필요한 API 클라이언트**
|
||||
|
||||
`frontend/lib/api/mail.ts` 생성 예정:
|
||||
```typescript
|
||||
// GET /api/mail/accounts
|
||||
export const getMailAccounts = async () => { ... }
|
||||
|
||||
// GET /api/mail/accounts/:id
|
||||
export const getMailAccount = async (id: string) => { ... }
|
||||
|
||||
// POST /api/mail/accounts
|
||||
export const createMailAccount = async (data) => { ... }
|
||||
|
||||
// PUT /api/mail/accounts/:id
|
||||
export const updateMailAccount = async (id, data) => { ... }
|
||||
|
||||
// DELETE /api/mail/accounts/:id
|
||||
export const deleteMailAccount = async (id) => { ... }
|
||||
|
||||
// POST /api/mail/accounts/:id/test-connection
|
||||
export const testMailConnection = async (id) => { ... }
|
||||
```
|
||||
|
||||
### 📦 **상태 관리**
|
||||
|
||||
React Query 사용 권장:
|
||||
- `useQuery(['mailAccounts'])` - 계정 목록 조회
|
||||
- `useMutation(createMailAccount)` - 계정 생성
|
||||
- `useMutation(updateMailAccount)` - 계정 수정
|
||||
- `useMutation(deleteMailAccount)` - 계정 삭제
|
||||
- `useMutation(testMailConnection)` - 연결 테스트
|
||||
|
||||
---
|
||||
|
||||
**작성일**: 2025-01-04
|
||||
**최종 수정**: 2025-01-04
|
||||
**상태**: ✅ Phase 1-4 완료, 🔜 Phase 5-1 착수 준비 완료
|
||||
|
||||
Loading…
Reference in New Issue