+```
+- `min-h-screen`: 최소 높이를 화면 전체로
+- `bg-gray-50`: 연한 회색 배경 (전체 페이지 기본 배경)
+
+#### 컨테이너
+```tsx
+
+```
+- `w-full max-w-none`: 전체 너비 사용
+- `px-4`: 좌우 패딩 1rem (16px)
+- `py-8`: 상하 패딩 2rem (32px)
+- `space-y-8`: 하위 요소 간 수직 간격 2rem
+
+#### 헤더 카드
+```tsx
+
+```
+
+---
+
+## 🎯 컴포넌트 디자인 기준
+
+### 1. 버튼
+
+#### 주요 버튼 (Primary)
+```tsx
+
+
+ 버튼 텍스트
+
+```
+
+#### 보조 버튼 (Secondary)
+```tsx
+
+
+ 새로고침
+
+```
+
+#### 위험 버튼 (Danger)
+```tsx
+
+
+ 삭제
+
+```
+
+### 2. 카드 (Card)
+
+#### 기본 카드
+```tsx
+
+
+ 카드 제목
+
+
+ {/* 내용 */}
+
+
+```
+
+#### 강조 카드
+```tsx
+
+
+
+
+ 제목
+
+
+
+ 내용
+
+
+```
+
+### 3. 테이블
+
+#### 기본 테이블 구조
+```tsx
+
+
+
+
+
+
+ 컬럼명
+
+
+
+
+
+
+ 데이터
+
+
+
+
+
+
+```
+
+### 4. 폼 (Form)
+
+#### 입력 필드
+```tsx
+
+
+ 라벨
+
+
+
+```
+
+#### 셀렉트
+```tsx
+
+
+
+
+
+ 옵션 1
+ 옵션 2
+
+
+```
+
+### 5. 빈 상태 (Empty State)
+
+```tsx
+
+
+
+ 데이터가 없습니다
+
+
+ 추가하기
+
+
+
+```
+
+### 6. 로딩 상태
+
+```tsx
+
+
+
+
+
+```
+
+---
+
+## 🎨 색상 시스템
+
+### 주 색상 (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열 */}
+
+ {/* 카드들 */}
+
+
+{/* 1열 → 2열 */}
+
+ {/* 항목들 */}
+
+```
+
+### 브레이크포인트
+```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 (
+
+
+ {/* 헤더 */}
+
+
+
데이터 관리
+
시스템 데이터를 관리합니다
+
+
+
+
+ 새로고침
+
+
+
+ 새로 추가
+
+
+
+
+ {/* 통계 카드 */}
+
+
+
+
+
+
+ {/* 나머지 통계 카드들... */}
+
+
+ {/* 데이터 테이블 */}
+
+
+
+
+
+
+ 이름
+
+
+ 상태
+
+
+ 작업
+
+
+
+
+ {data.map((item) => (
+
+
+ {item.name}
+
+
+
+ 활성
+
+
+
+
+
+ 수정
+
+
+ 삭제
+
+
+
+
+ ))}
+
+
+
+
+
+
+ );
+}
+```
+
+### 예제 2: 빈 상태 페이지
+
+```tsx
+export default function EmptyStatePage() {
+ return (
+
+
+ {/* 헤더 */}
+
+
+
데이터 관리
+
시스템 데이터를 관리합니다
+
+
+
+ 새로 추가
+
+
+
+ {/* 빈 상태 */}
+
+
+
+ 아직 등록된 데이터가 없습니다
+
+
+ 첫 데이터 추가하기
+
+
+
+
+ {/* 안내 정보 */}
+
+
+
+
+ 데이터 관리 안내
+
+
+
+
+ 💡 데이터를 추가하여 시스템을 사용해보세요!
+
+
+
+ ✓
+ 기능 설명 1
+
+
+ ✓
+ 기능 설명 2
+
+
+
+
+
+
+ );
+}
+```
+
+---
+
+## ✅ 체크리스트
+
+### 새 페이지 만들 때
+- [ ] `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를 만들 수 있습니다!** 🎨✨
diff --git a/backend-node/package-lock.json b/backend-node/package-lock.json
index 2619199d..5084d3ce 100644
--- a/backend-node/package-lock.json
+++ b/backend-node/package-lock.json
@@ -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",
diff --git a/backend-node/package.json b/backend-node/package.json
index 9d892e3f..2caf0e9c 100644
--- a/backend-node/package.json
+++ b/backend-node/package.json
@@ -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",
diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts
index 3c8974d4..412916d3 100644
--- a/backend-node/src/app.ts
+++ b/backend-node/src/app.ts
@@ -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);
diff --git a/backend-node/src/controllers/mailAccountFileController.ts b/backend-node/src/controllers/mailAccountFileController.ts
new file mode 100644
index 00000000..702a5dd4
--- /dev/null
+++ b/backend-node/src/controllers/mailAccountFileController.ts
@@ -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();
+
diff --git a/backend-node/src/controllers/mailQueryController.ts b/backend-node/src/controllers/mailQueryController.ts
new file mode 100644
index 00000000..127c613e
--- /dev/null
+++ b/backend-node/src/controllers/mailQueryController.ts
@@ -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();
+
diff --git a/backend-node/src/controllers/mailSendSimpleController.ts b/backend-node/src/controllers/mailSendSimpleController.ts
new file mode 100644
index 00000000..cbd9b647
--- /dev/null
+++ b/backend-node/src/controllers/mailSendSimpleController.ts
@@ -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();
+
diff --git a/backend-node/src/controllers/mailTemplateFileController.ts b/backend-node/src/controllers/mailTemplateFileController.ts
new file mode 100644
index 00000000..f5dcb7c3
--- /dev/null
+++ b/backend-node/src/controllers/mailTemplateFileController.ts
@@ -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();
+
diff --git a/backend-node/src/routes/mailAccountFileRoutes.ts b/backend-node/src/routes/mailAccountFileRoutes.ts
new file mode 100644
index 00000000..cc022cb8
--- /dev/null
+++ b/backend-node/src/routes/mailAccountFileRoutes.ts
@@ -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;
+
diff --git a/backend-node/src/routes/mailQueryRoutes.ts b/backend-node/src/routes/mailQueryRoutes.ts
new file mode 100644
index 00000000..f4c1a376
--- /dev/null
+++ b/backend-node/src/routes/mailQueryRoutes.ts
@@ -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;
+
diff --git a/backend-node/src/routes/mailSendSimpleRoutes.ts b/backend-node/src/routes/mailSendSimpleRoutes.ts
new file mode 100644
index 00000000..db56b66d
--- /dev/null
+++ b/backend-node/src/routes/mailSendSimpleRoutes.ts
@@ -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;
+
diff --git a/backend-node/src/routes/mailTemplateFileRoutes.ts b/backend-node/src/routes/mailTemplateFileRoutes.ts
new file mode 100644
index 00000000..eb79ed34
--- /dev/null
+++ b/backend-node/src/routes/mailTemplateFileRoutes.ts
@@ -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;
+
diff --git a/backend-node/src/services/encryptionService.ts b/backend-node/src/services/encryptionService.ts
new file mode 100644
index 00000000..a3608b2e
--- /dev/null
+++ b/backend-node/src/services/encryptionService.ts
@@ -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();
diff --git a/backend-node/src/services/mailAccountFileService.ts b/backend-node/src/services/mailAccountFileService.ts
new file mode 100644
index 00000000..d81d2c37
--- /dev/null
+++ b/backend-node/src/services/mailAccountFileService.ts
@@ -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
{
+ 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 {
+ try {
+ const content = await fs.readFile(this.getAccountPath(id), 'utf-8');
+ return JSON.parse(content);
+ } catch {
+ return null;
+ }
+ }
+
+ async createAccount(
+ data: Omit
+ ): Promise {
+ 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>
+ ): Promise {
+ 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 {
+ try {
+ await fs.unlink(this.getAccountPath(id));
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ async getAccountByEmail(email: string): Promise {
+ const accounts = await this.getAllAccounts();
+ return accounts.find(a => a.email === email) || null;
+ }
+
+ async getActiveAccounts(): Promise {
+ 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();
+
diff --git a/backend-node/src/services/mailQueryService.ts b/backend-node/src/services/mailQueryService.ts
new file mode 100644
index 00000000..91815cf0
--- /dev/null
+++ b/backend-node/src/services/mailQueryService.ts
@@ -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;
+}
+
+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 {
+ 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 {
+ 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;
+ }>> {
+ 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();
+
diff --git a/backend-node/src/services/mailSendSimpleService.ts b/backend-node/src/services/mailSendSimpleService.ts
new file mode 100644
index 00000000..c5d2fc4f
--- /dev/null
+++ b/backend-node/src/services/mailSendSimpleService.ts
@@ -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; // 템플릿 변수 치환
+ customHtml?: string; // 템플릿 없이 직접 HTML 작성 시
+}
+
+export interface SendMailResult {
+ success: boolean;
+ messageId?: string;
+ accepted?: string[];
+ rejected?: string[];
+ error?: string;
+}
+
+class MailSendSimpleService {
+ /**
+ * 단일 메일 발송 또는 소규모 발송
+ */
+ async sendMail(request: SendMailRequest): Promise {
+ 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 {
+ let html = '';
+
+ template.components.forEach((component: any) => {
+ switch (component.type) {
+ case 'text':
+ let content = component.content || '';
+ if (variables) {
+ content = this.replaceVariables(content, variables);
+ }
+ html += `
${content}
`;
+ break;
+
+ case 'button':
+ let buttonText = component.text || 'Button';
+ if (variables) {
+ buttonText = this.replaceVariables(buttonText, variables);
+ }
+ html += `
+
${buttonText}
+ `;
+ break;
+
+ case 'image':
+ html += `
`;
+ break;
+
+ case 'spacer':
+ html += `
`;
+ break;
+ }
+ });
+
+ html += '
';
+ return html;
+ }
+
+ /**
+ * 변수 치환
+ */
+ private replaceVariables(text: string, variables?: Record): 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 {
+ 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();
+
diff --git a/backend-node/src/services/mailTemplateFileService.ts b/backend-node/src/services/mailTemplateFileService.ts
new file mode 100644
index 00000000..e793bc35
--- /dev/null
+++ b/backend-node/src/services/mailTemplateFileService.ts
@@ -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 {
+ 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 {
+ try {
+ const content = await fs.readFile(this.getTemplatePath(id), 'utf-8');
+ return JSON.parse(content);
+ } catch {
+ return null;
+ }
+ }
+
+ /**
+ * 템플릿 생성
+ */
+ async createTemplate(
+ data: Omit
+ ): Promise {
+ 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>
+ ): Promise {
+ 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 {
+ try {
+ await fs.unlink(this.getTemplatePath(id));
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ /**
+ * 템플릿을 HTML로 렌더링
+ */
+ renderTemplateToHtml(components: MailComponent[]): string {
+ let html = '';
+
+ components.forEach(comp => {
+ const styles = Object.entries(comp.styles || {})
+ .map(([key, value]) => `${this.camelToKebab(key)}: ${value}`)
+ .join('; ');
+
+ switch (comp.type) {
+ case 'text':
+ html += `
${comp.content || ''}
`;
+ break;
+ case 'button':
+ html += `
`;
+ break;
+ case 'image':
+ html += `
+
+
`;
+ break;
+ case 'spacer':
+ html += `
`;
+ break;
+ }
+ });
+
+ html += '
';
+ return html;
+ }
+
+ /**
+ * camelCase를 kebab-case로 변환
+ */
+ private camelToKebab(str: string): string {
+ return str.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
+ }
+
+ /**
+ * 카테고리별 템플릿 조회
+ */
+ async getTemplatesByCategory(category: string): Promise {
+ const allTemplates = await this.getAllTemplates();
+ return allTemplates.filter(t => t.category === category);
+ }
+
+ /**
+ * 템플릿 검색
+ */
+ async searchTemplates(keyword: string): Promise {
+ 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();
+
diff --git a/docker/dev/docker-compose.backend.mac.yml b/docker/dev/docker-compose.backend.mac.yml
index 3862a74f..8257a238 100644
--- a/docker/dev/docker-compose.backend.mac.yml
+++ b/docker/dev/docker-compose.backend.mac.yml
@@ -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
diff --git a/frontend/app/(main)/admin/mail/accounts/page.tsx b/frontend/app/(main)/admin/mail/accounts/page.tsx
new file mode 100644
index 00000000..0171f2b6
--- /dev/null
+++ b/frontend/app/(main)/admin/mail/accounts/page.tsx
@@ -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([]);
+ const [loading, setLoading] = useState(false);
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
+ const [selectedAccount, setSelectedAccount] = useState(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 (
+
+
+ {/* 페이지 제목 */}
+
+
+
메일 계정 관리
+
SMTP 메일 계정을 관리하고 발송 통계를 확인합니다
+
+
+
+
+ 새로고침
+
+
+
+ 새 계정 추가
+
+
+
+
+ {/* 메인 컨텐츠 */}
+ {loading ? (
+
+
+
+
+
+ ) : (
+
+
+
+
+
+ )}
+
+ {/* 안내 정보 */}
+
+
+
+
+ 메일 계정 관리
+
+
+
+
+ 💡 SMTP 계정을 등록하여 시스템에서 메일을 발송할 수 있어요!
+
+
+
+ ✓
+ Gmail, Naver, 자체 SMTP 서버 지원
+
+
+ ✓
+ 비밀번호는 암호화되어 안전하게 저장됩니다
+
+
+ ✓
+ 일일 발송 제한 설정 가능
+
+
+
+
+
+
+ {/* 모달들 */}
+
setIsModalOpen(false)}
+ onSave={handleSaveAccount}
+ account={selectedAccount}
+ mode={modalMode}
+ />
+
+ setIsDeleteModalOpen(false)}
+ onConfirm={handleDeleteAccount}
+ title="메일 계정 삭제"
+ message="이 메일 계정을 삭제하시겠습니까?"
+ itemName={selectedAccount?.name}
+ />
+
+ );
+}
diff --git a/frontend/app/(main)/admin/mail/dashboard/page.tsx b/frontend/app/(main)/admin/mail/dashboard/page.tsx
new file mode 100644
index 00000000..1fa6a728
--- /dev/null
+++ b/frontend/app/(main)/admin/mail/dashboard/page.tsx
@@ -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({
+ 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 (
+
+
+ {/* 페이지 제목 */}
+
+
+
메일 관리 대시보드
+
메일 시스템의 전체 현황을 확인합니다
+
+
+
+ 새로고침
+
+
+
+ {/* 통계 카드 */}
+
+ {statCards.map((stat, index) => (
+
+
+
+
+
{stat.title}
+
{stat.value}
+
+
+
+
+
+
+
+ ))}
+
+
+ {/* 이번 달 통계 */}
+
+
+
+
+
+ 이번 달 발송 통계
+
+
+
+
+
+ 총 발송 건수
+
+ {stats.sentThisMonth}건
+
+
+
+ 성공률
+
+ {stats.successRate}%
+
+
+
+
+
+
+
+
+
+
+
+ 최근 활동
+
+
+
+
+
+
+
+
+ {/* 빠른 액세스 */}
+
+
+ 빠른 액세스
+
+
+
+
+
+
+ {/* 안내 정보 */}
+
+
+
+
+ 메일 관리 시스템 안내
+
+
+
+
+ 💡 메일 관리 시스템의 주요 기능을 확인하세요!
+
+
+
+ ✓
+ SMTP 계정을 등록하여 메일 발송
+
+
+ ✓
+ 드래그 앤 드롭으로 템플릿 디자인
+
+
+ ✓
+ 동적 변수와 SQL 쿼리 연동
+
+
+ ✓
+ 발송 통계 및 이력 관리
+
+
+
+
+
+
+ );
+}
+
diff --git a/frontend/app/(main)/admin/mail/receive/page.tsx b/frontend/app/(main)/admin/mail/receive/page.tsx
new file mode 100644
index 00000000..9412e662
--- /dev/null
+++ b/frontend/app/(main)/admin/mail/receive/page.tsx
@@ -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 (
+
+
+ {/* 페이지 제목 */}
+
+
+
메일 수신함
+
받은 메일을 확인하고 관리합니다
+
+
+
+ 새로고침
+
+
+
+ {/* 메일 목록 미리보기 */}
+
+
+
+
+ 받은 메일함
+
+
+
+ {/* 빈 상태 */}
+
+
+
+ 메일 수신 기능 준비 중
+
+
+ IMAP/POP3 기반 메일 수신 기능이 곧 추가될 예정입니다.
+
+
+ {/* 예상 레이아웃 미리보기 */}
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+
+
+
+
+ {/* 안내 정보 */}
+
+
+
+
+ 구현 예정 기능
+
+
+
+
+ 💡 메일 수신함에 추가될 기능들:
+
+
+
+ ✓
+ IMAP/POP3 프로토콜을 통한 메일 수신
+
+
+ ✓
+ 받은 메일 목록 조회 및 검색
+
+
+ ✓
+ 메일 읽음/읽지않음 상태 관리
+
+
+ ✓
+ 첨부파일 다운로드
+
+
+ ✓
+ 메일 필터링 및 정렬
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/app/(main)/admin/mail/send/page.tsx b/frontend/app/(main)/admin/mail/send/page.tsx
new file mode 100644
index 00000000..10a9853c
--- /dev/null
+++ b/frontend/app/(main)/admin/mail/send/page.tsx
@@ -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([]);
+ const [templates, setTemplates] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ // 폼 상태
+ const [selectedAccountId, setSelectedAccountId] = useState("");
+ const [selectedTemplateId, setSelectedTemplateId] = useState("");
+ const [subject, setSubject] = useState("");
+ const [recipients, setRecipients] = useState([""]);
+ const [variables, setVariables] = useState>({});
+
+ // 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 = {};
+ 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 (
+
+
+
+ );
+ }
+
+ return (
+
+
+ {/* 페이지 제목 */}
+
+
메일 발송
+
템플릿을 선택하여 메일을 발송합니다
+
+
+ {/* 메인 폼 */}
+
+ {/* 왼쪽: 발송 설정 */}
+
+
+
+
+
+ 발송 설정
+
+
+
+ {/* 발송 계정 선택 */}
+
+
+ 발송 계정 *
+
+ 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"
+ >
+ 계정 선택
+ {accounts
+ .filter((acc) => acc.status === "active")
+ .map((account) => (
+
+ {account.name} ({account.email})
+
+ ))}
+
+
+
+ {/* 템플릿 선택 */}
+
+
+ 템플릿 *
+
+ 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"
+ >
+ 템플릿 선택
+ {templates.map((template) => (
+
+ {template.name}
+
+ ))}
+
+
+
+ {/* 메일 제목 */}
+
+
+ 메일 제목 *
+
+ 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"
+ />
+
+
+ {/* 수신자 */}
+
+
+ 수신자 이메일 *
+
+
+ {recipients.map((email, index) => (
+
+ 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 && (
+ removeRecipient(index)}
+ className="text-red-500 hover:text-red-600"
+ >
+
+
+ )}
+
+ ))}
+
+
+ 수신자 추가
+
+
+
+
+ {/* 템플릿 변수 */}
+ {templateVariables.length > 0 && (
+
+
+ 템플릿 변수
+
+
+ {templateVariables.map((varName) => (
+
+
+ {varName}
+
+
+ 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"
+ />
+
+ ))}
+
+
+ )}
+
+
+
+ {/* 발송 버튼 */}
+
+ setShowPreview(!showPreview)}
+ variant="outline"
+ className="flex-1"
+ disabled={!selectedTemplateId}
+ >
+
+ 미리보기
+
+
+ {isSending ? (
+ <>
+
+ 발송 중...
+ >
+ ) : (
+ <>
+
+ 발송
+ >
+ )}
+
+
+
+ {/* 발송 결과 */}
+ {sendResult && (
+
+
+
+ {sendResult.success ? (
+
+ ) : (
+
+ )}
+
+ {sendResult.message}
+
+
+
+
+ )}
+
+
+ {/* 오른쪽: 미리보기 */}
+
+
+
+
+
+ 미리보기
+
+
+
+ {showPreview && previewHtml ? (
+
+ ) : (
+
+
+
+ 템플릿을 선택하고
+
+ 미리보기 버튼을 클릭하세요
+
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/app/(main)/admin/mail/templates/page.tsx b/frontend/app/(main)/admin/mail/templates/page.tsx
new file mode 100644
index 00000000..6814af06
--- /dev/null
+++ b/frontend/app/(main)/admin/mail/templates/page.tsx
@@ -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([]);
+ const [loading, setLoading] = useState(false);
+ const [searchTerm, setSearchTerm] = useState('');
+ const [categoryFilter, setCategoryFilter] = useState('all');
+
+ // 모달 상태
+ const [isEditorOpen, setIsEditorOpen] = useState(false);
+ const [isPreviewOpen, setIsPreviewOpen] = useState(false);
+ const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
+ const [selectedTemplate, setSelectedTemplate] = useState(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 (
+
+
+ {/* 페이지 제목 */}
+
+
+
메일 템플릿 관리
+
드래그 앤 드롭으로 메일 템플릿을 만들고 관리합니다
+
+
+
+
+ 새로고침
+
+
+
+ 새 템플릿 만들기
+
+
+
+
+ {/* 검색 및 필터 */}
+
+
+
+
+
+ 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"
+ />
+
+
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"
+ >
+ 전체 카테고리
+ {categories.map((cat) => (
+
+ {cat}
+
+ ))}
+
+
+
+
+
+ {/* 메인 컨텐츠 */}
+ {loading ? (
+
+
+
+
+
+ ) : filteredTemplates.length === 0 ? (
+
+
+
+
+ {templates.length === 0
+ ? '아직 생성된 템플릿이 없습니다'
+ : '검색 결과가 없습니다'}
+
+ {templates.length === 0 && (
+
+
+ 첫 템플릿 만들기
+
+ )}
+
+
+ ) : (
+
+ {filteredTemplates.map((template) => (
+
+ ))}
+
+ )}
+
+ {/* 안내 정보 */}
+
+
+
+
+ 템플릿 디자이너
+
+
+
+
+ 💡 드래그 앤 드롭으로 손쉽게 메일 템플릿을 만들 수 있어요!
+
+
+
+ ✓
+ 텍스트, 버튼, 이미지, 여백 컴포넌트 지원
+
+
+ ✓
+ 실시간 미리보기로 즉시 확인 가능
+
+
+ ✓
+ 동적 변수 지원 (예: {"{customer_name}"})
+
+
+
+
+
+
+ {/* 모달들 */}
+
setIsEditorOpen(false)}
+ onSave={handleSaveTemplate}
+ template={selectedTemplate}
+ mode={editorMode}
+ />
+
+ setIsPreviewOpen(false)}
+ template={selectedTemplate}
+ />
+
+ setIsDeleteModalOpen(false)}
+ onConfirm={handleDeleteTemplate}
+ title="템플릿 삭제"
+ message="이 템플릿을 삭제하시겠습니까?"
+ itemName={selectedTemplate?.name}
+ />
+
+ );
+}
diff --git a/frontend/components/admin/UserToolbar.tsx b/frontend/components/admin/UserToolbar.tsx
index 4c0709a5..e4e7439a 100644
--- a/frontend/components/admin/UserToolbar.tsx
+++ b/frontend/components/admin/UserToolbar.tsx
@@ -116,14 +116,6 @@ export function UserToolbar({
{/* 고급 검색 필드들 */}
- {/*
- 사번
- handleAdvancedSearchChange("search_sabun", e.target.value)}
- />
-
*/}
회사명
diff --git a/frontend/components/animations/AnimatedComponent.tsx b/frontend/components/animations/AnimatedComponent.tsx
new file mode 100644
index 00000000..6f2715a0
--- /dev/null
+++ b/frontend/components/animations/AnimatedComponent.tsx
@@ -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
= ({
+ 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 (
+
+ {children}
+
+ );
+};
+
+// 특화된 애니메이션 컴포넌트들
+export const FadeIn: React.FC> = (props) => (
+
+);
+
+export const SlideInFromLeft: React.FC> = (props) => (
+
+);
+
+export const SlideInFromRight: React.FC> = (props) => (
+
+);
+
+export const SlideInFromTop: React.FC> = (props) => (
+
+);
+
+export const SlideInFromBottom: React.FC> = (props) => (
+
+);
+
+export const ScaleIn: React.FC> = (props) => (
+
+);
+
+export const Bounce: React.FC> = (props) => (
+
+);
+
+export const Pulse: React.FC> = (props) => (
+
+);
+
+export const Glow: React.FC> = (props) => (
+
+);
+
+// 조합 애니메이션 컴포넌트들
+export const PageTransition: React.FC & { direction?: "left" | "right" | "up" | "down" }> = ({ direction, ...props }) => (
+
+);
+
+export const ModalEnter: React.FC> = (props) => (
+
+);
+
+export const ModalExit: React.FC> = (props) => (
+
+);
+
+export const ButtonClick: React.FC> = (props) => (
+
+);
+
+export const SuccessNotification: React.FC> = (props) => (
+
+);
+
+export const LoadingSpinner: React.FC> = (props) => (
+
+);
+
+export const HoverLift: React.FC> = (props) => (
+
+);
diff --git a/frontend/components/dataflow/connection/redesigned/DataConnectionDesigner.tsx b/frontend/components/dataflow/connection/redesigned/DataConnectionDesigner.tsx
index db25934f..860724f2 100644
--- a/frontend/components/dataflow/connection/redesigned/DataConnectionDesigner.tsx
+++ b/frontend/components/dataflow/connection/redesigned/DataConnectionDesigner.tsx
@@ -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 = ({
- onClose,
- initialData,
- showBackButton = false,
-}) => {
- // 🔄 상태 관리
- const [state, setState] = useState(() => ({
+const initialState: DataConnectionState = {
connectionType: "data_save",
currentStep: 1,
fieldMappings: [],
@@ -52,762 +23,87 @@ const DataConnectionDesigner: React.FC = ({
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) => {
- 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 => {
- 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) => {
- 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) => {
- 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 => {
- 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(initialState);
+ const { isMobile, isTablet } = useResponsive();
return (
-
- {/* 상단 네비게이션 */}
- {showBackButton && (
-
-
-
-
-
- 목록으로
-
-
-
🔗 데이터 연결 설정
-
- {state.connectionType === "data_save" ? "데이터 저장" : "외부 호출"} 연결 설정
+
+
+
+ 🎨 제어관리 - 데이터 연결 설정
+
+
+ 시각적 필드 매핑으로 데이터 연결을 쉽게 설정하세요
-
+
+
+
+
setState(prev => ({ ...prev, connectionType: type }))}
+ />
+
+
+ setState(prev => ({ ...prev, selectedMapping: mappingId }))}
+ />
- )}
- {/* 메인 컨텐츠 - 좌우 분할 레이아웃 */}
-
- {/* 좌측 패널 (30%) - 항상 표시 */}
-
-
+
+
setState(prev => ({ ...prev, currentStep: step }))}
+ />
+
+
+ {state.currentStep === 1 && (
+
setState(prev => ({ ...prev, fromConnection: conn }))}
+ onToConnectionChange={(conn) => setState(prev => ({ ...prev, toConnection: conn }))}
+ onNext={() => setState(prev => ({ ...prev, currentStep: 2 }))}
+ />
+ )}
+
+ {state.currentStep === 2 && (
+ 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 && (
+ setState(prev => ({ ...prev, fieldMappings: mappings }))}
+ onBack={() => setState(prev => ({ ...prev, currentStep: 2 }))}
+ onSave={() => {
+ // 저장 로직
+ console.log("저장:", state);
+ alert("데이터 연결 설정이 저장되었습니다!");
+ }}
+ />
+ )}
-
- {/* 우측 패널 (70%) */}
-
-
diff --git a/frontend/components/dataflow/connection/redesigned/LeftPanel/ConnectionTypeSelector.tsx b/frontend/components/dataflow/connection/redesigned/LeftPanel/ConnectionTypeSelector.tsx
index 4002a408..0d5aa5e4 100644
--- a/frontend/components/dataflow/connection/redesigned/LeftPanel/ConnectionTypeSelector.tsx
+++ b/frontend/components/dataflow/connection/redesigned/LeftPanel/ConnectionTypeSelector.tsx
@@ -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
= ({ selectedType, onTypeChange }) => {
- const connectionTypes: ConnectionType[] = [
- {
- id: "data_save",
- label: "데이터 저장",
- description: "INSERT/UPDATE/DELETE 작업",
- icon: ,
- },
- {
- id: "external_call",
- label: "외부 호출",
- description: "API/Webhook 호출",
- icon: ,
- },
- ];
+const connectionTypes: ConnectionType[] = [
+ {
+ id: "data_save",
+ label: "데이터 저장",
+ description: "INSERT/UPDATE/DELETE 작업",
+ icon: ,
+ },
+ {
+ id: "external_call",
+ label: "외부 호출",
+ description: "API/Webhook 호출",
+ icon: ,
+ },
+];
+export const ConnectionTypeSelector: React.FC = ({
+ connectionType,
+ onConnectionTypeChange,
+}) => {
return (
-
-
- {
- console.log("🔘 [ConnectionTypeSelector] 라디오 버튼 변경:", value);
- onTypeChange(value as "data_save" | "external_call");
- }}
- className="space-y-3"
- >
- {connectionTypes.map((type) => (
-
-
-
-
- {type.icon}
- {type.label}
-
-
{type.description}
+
+
+ 연결 타입 선택
+
+
+
+ {connectionTypes.map((type) => (
+
onConnectionTypeChange(type.id)}
+ >
+
+
+ {type.icon}
+
+
+
{type.label}
+
{type.description}
- ))}
-
-
-
+
+ ))}
+
+
);
-};
-
-export default ConnectionTypeSelector;
+};
\ No newline at end of file
diff --git a/frontend/components/dataflow/connection/redesigned/LeftPanel/MappingInfoPanel.tsx b/frontend/components/dataflow/connection/redesigned/LeftPanel/MappingInfoPanel.tsx
index 3b407dd6..92b79908 100644
--- a/frontend/components/dataflow/connection/redesigned/LeftPanel/MappingInfoPanel.tsx
+++ b/frontend/components/dataflow/connection/redesigned/LeftPanel/MappingInfoPanel.tsx
@@ -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
= ({ 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 = ({
+ mappingStats,
+ fieldMappings,
+ selectedMapping,
+ onMappingSelect,
+}) => {
return (
-
-
- {/* 매핑 통계 */}
-
-
-
총 매핑:
-
{stats.totalMappings}개
+
+
+ 매핑 정보
+
+
+ {/* 통계 카드 */}
+
+
+
+
+ 유효한 매핑
-
-
-
유효한 매핑:
-
-
- {stats.validMappings}개
-
+
+ {mappingStats.validMappings}
-
- {stats.invalidMappings > 0 && (
-
-
타입 불일치:
-
-
- {stats.invalidMappings}개
-
-
- )}
-
- {stats.missingRequiredFields > 0 && (
-
- 필수 필드 누락:
-
-
- {stats.missingRequiredFields}개
-
-
- )}
- {/* 액션 정보 */}
- {stats.totalMappings > 0 && (
-
-
- 액션:
- {stats.actionType}
-
+
+
+
+ 오류 매핑
+
+
+ {mappingStats.invalidMappings}
+
+
- {stats.estimatedRows > 0 && (
-
-
예상 처리량:
-
~{stats.estimatedRows.toLocaleString()} rows
+
+
+
+ 총 매핑
+
+
+ {mappingStats.totalMappings}
+
+
+
+
+
+
+ {mappingStats.missingRequiredFields}
+
+
+
+
+ {/* 매핑 목록 */}
+
+
+ 필드 매핑 목록
+
+
+ {fieldMappings.length === 0 ? (
+
+
+
아직 매핑이 없습니다
+
3단계에서 필드를 매핑하세요
+
+ ) : (
+
+ {fieldMappings.map((mapping) => (
+
onMappingSelect(mapping.id)}
+ >
+
+
+
+
+ {mapping.fromField.name}
+
+ →
+
+ {mapping.toField.name}
+
+
+
+
+ {mapping.fromField.type}
+
+ →
+
+ {mapping.toField.type}
+
+
+
+
+
+ {mapping.isValid ? (
+
+ ) : (
+
+ )}
+
+
+
+ {mapping.validationMessage && (
+
+ {mapping.validationMessage}
+
+ )}
- )}
+ ))}
)}
-
- {/* 검증 오류 요약 */}
- {validationErrors.length > 0 && (
-
-
-
- 검증 결과:
-
-
- {errorCount > 0 && (
-
- 오류 {errorCount}개
-
- )}
- {warningCount > 0 && (
-
- 경고 {warningCount}개
-
- )}
-
-
- )}
-
- {/* 빈 상태 */}
- {stats.totalMappings === 0 && (
-
-
-
아직 매핑된 필드가 없습니다.
-
우측에서 연결을 설정해주세요.
-
- )}
-
-
+
+
);
-};
-
-// Database 아이콘 import 추가
-import { Database } from "lucide-react";
-
-export default MappingInfoPanel;
+};
\ No newline at end of file
diff --git a/frontend/components/dataflow/connection/redesigned/RightPanel/ConnectionStep.tsx b/frontend/components/dataflow/connection/redesigned/RightPanel/ConnectionStep.tsx
index 9129f690..a26c9a0f 100644
--- a/frontend/components/dataflow/connection/redesigned/RightPanel/ConnectionStep.tsx
+++ b/frontend/components/dataflow/connection/redesigned/RightPanel/ConnectionStep.tsx
@@ -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
= React.memo(
- ({
- connectionType,
- fromConnection,
- toConnection,
- relationshipName,
- description,
- diagramId,
- onSelectConnection,
- onSetRelationshipName,
- onSetDescription,
- onNext,
- }) => {
- const [connections, setConnections] = useState([]);
- 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 ? : ;
- };
-
- const getConnectionBadge = (connection: Connection) => {
- if (connection.id === 0) {
- return (
-
- 메인 DB
-
- );
- }
- return (
-
- {connection.type?.toUpperCase()}
-
- );
- };
-
- return (
- <>
-
-
-
- 1단계: 연결 선택
-
-
- {connectionType === "data_save"
- ? "데이터를 저장할 소스와 대상 데이터베이스를 선택하세요."
- : "외부 호출을 위한 소스와 대상 연결을 선택하세요."}
-
-
-
-
- {/* 관계 정보 입력 */}
-
-
관계 정보
-
-
-
관계 이름 *
-
-
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"
- : ""
- }`}
- />
-
- {nameCheckStatus === "checking" && (
-
- )}
- {nameCheckStatus === "valid" &&
}
- {nameCheckStatus === "duplicate" &&
}
-
-
- {nameCheckStatus === "duplicate" &&
이미 사용 중인 이름입니다.
}
- {nameCheckStatus === "valid" &&
사용 가능한 이름입니다.
}
-
-
- 설명
-
-
-
-
- {isLoading ? (
-
-
- 연결 목록을 불러오는 중...
-
- ) : (
- <>
- {/* FROM 연결 선택 */}
-
-
-
FROM 연결 (소스)
- {fromConnection && (
-
-
- 🟢 연결됨
-
- 지연시간: ~23ms
-
- )}
-
-
-
handleConnectionSelect("from", value)}
- >
-
-
-
-
- {connections.length === 0 ? (
- 연결 정보가 없습니다.
- ) : (
- connections.map((connection, index) => (
-
-
- {getConnectionIcon(connection)}
- {connection.name}
- {getConnectionBadge(connection)}
-
-
- ))
- )}
-
-
-
- {fromConnection && (
-
-
- {getConnectionIcon(fromConnection)}
- {fromConnection.name}
- {getConnectionBadge(fromConnection)}
-
-
-
- 호스트: {fromConnection.host}:{fromConnection.port}
-
-
데이터베이스: {fromConnection.database}
-
-
- )}
-
-
- {/* TO 연결 선택 */}
-
-
-
TO 연결 (대상)
- {toConnection && (
-
-
- 🟢 연결됨
-
- 지연시간: ~45ms
-
- )}
-
-
-
handleConnectionSelect("to", value)}
- >
-
-
-
-
- {connections.length === 0 ? (
- 연결 정보가 없습니다.
- ) : (
- connections.map((connection, index) => (
-
-
- {getConnectionIcon(connection)}
- {connection.name}
- {getConnectionBadge(connection)}
-
-
- ))
- )}
-
-
-
- {toConnection && (
-
-
- {getConnectionIcon(toConnection)}
- {toConnection.name}
- {getConnectionBadge(toConnection)}
-
-
-
- 호스트: {toConnection.host}:{toConnection.port}
-
-
데이터베이스: {toConnection.database}
-
-
- )}
-
-
- {/* 연결 매핑 표시 */}
- {fromConnection && toConnection && (
-
-
-
-
{fromConnection.name}
-
소스
-
-
-
-
-
-
{toConnection.name}
-
대상
-
-
-
-
-
- 💡 연결 매핑 설정 완료
-
-
-
- )}
-
- {/* 다음 단계 버튼 */}
-
- >
- )}
-
- >
- );
+// 임시 연결 데이터 (실제로는 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 = ({
+ fromConnection,
+ toConnection,
+ onFromConnectionChange,
+ onToConnectionChange,
+ onNext,
+}) => {
+ const [selectedFrom, setSelectedFrom] = useState(fromConnection?.id || "");
+ const [selectedTo, setSelectedTo] = useState(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 (
+
+
+
+ 연결 선택
+
+
+ 데이터를 가져올 연결과 저장할 연결을 선택하세요
+
+
+
+
+ {/* FROM 연결 */}
+
+
+
+ 1
+
+
FROM 연결
+
(데이터 소스)
+
+
+
+ {mockConnections.map((connection) => (
+
handleFromSelect(connection.id)}
+ >
+
+
+
+
{connection.name}
+
{connection.type}
+
{connection.host}:{connection.port}
+
+ {selectedFrom === connection.id && (
+
+ )}
+
+
+ ))}
+
+
+
+ {/* 화살표 */}
+
+
+ {/* TO 연결 */}
+
+
+
+ 2
+
+
TO 연결
+
(데이터 대상)
+
+
+
+ {mockConnections.map((connection) => (
+
handleToSelect(connection.id)}
+ >
+
+
+
+
{connection.name}
+
{connection.type}
+
{connection.host}:{connection.port}
+
+ {selectedTo === connection.id && (
+
+ )}
+
+
+ ))}
+
+
+
+
+ {/* 다음 버튼 */}
+
+
+ 다음 단계: 테이블 선택
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/frontend/components/dataflow/connection/redesigned/RightPanel/FieldMappingStep.tsx b/frontend/components/dataflow/connection/redesigned/RightPanel/FieldMappingStep.tsx
index d7ec2f7b..b5f7bfb0 100644
--- a/frontend/components/dataflow/connection/redesigned/RightPanel/FieldMappingStep.tsx
+++ b/frontend/components/dataflow/connection/redesigned/RightPanel/FieldMappingStep.tsx
@@ -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 = ({
+export const FieldMappingStep: React.FC = ({
fromTable,
toTable,
- fromConnection,
- toConnection,
fieldMappings,
- onCreateMapping,
- onDeleteMapping,
- onNext,
+ onMappingsChange,
onBack,
+ onSave,
}) => {
- const [fromColumns, setFromColumns] = useState([]);
- const [toColumns, setToColumns] = useState([]);
- const [isLoading, setIsLoading] = useState(false);
+ const [draggedField, setDraggedField] = useState(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 (
-
-
- 필드 정보를 불러오는 중...
-
- );
- }
+ 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 (
- <>
-
-
-
- 3단계: 컬럼 매핑
-
-
+
+
+
+ 필드 매핑
+
+
+ 소스 테이블의 필드를 대상 테이블의 필드에 드래그하여 매핑하세요
+
+
-
- {/* 매핑 캔버스 - 전체 영역 사용 */}
-
- {isLoading ? (
-
- ) : fromColumns.length > 0 && toColumns.length > 0 ? (
-
- ) : (
-
-
컬럼 정보를 찾을 수 없습니다.
-
- FROM 컬럼: {fromColumns.length}개, TO 컬럼: {toColumns.length}개
-
-
{
- console.log("🔄 수동 재로딩 시도");
- setFromColumns([]);
- setToColumns([]);
- // useEffect가 재실행되도록 강제 업데이트
- setIsLoading(true);
- setTimeout(() => setIsLoading(false), 100);
- }}
- >
- 다시 시도
-
-
- )}
+ {/* 매핑 통계 */}
+
+
+
{fieldMappings.length}
+
총 매핑
+
+
+ {fieldMappings.filter(m => m.isValid).length}
+
+
유효한 매핑
+
+
+
+ {fieldMappings.filter(m => !m.isValid).length}
+
+
오류 매핑
+
+
+
+ {(toTable?.columns.length || 0) - fieldMappings.length}
+
+
미매핑 필드
+
+
- {/* 하단 네비게이션 - 고정 */}
-
-
-
-
- 이전
-
-
-
- {fieldMappings.length > 0 ? `${fieldMappings.length}개 매핑 완료` : "컬럼을 선택해서 매핑하세요"}
+ {/* 매핑 영역 */}
+
+ {/* FROM 테이블 필드들 */}
+
+
+
+ FROM
-
-
-
- 저장
-
+ {fromTable?.name} 필드들
+
+
+
+ {fromTable?.columns.map((field) => (
+
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"
+ }`}
+ >
+
+
+
{field.name}
+
{field.type}
+ {field.primaryKey && (
+
+ PK
+
+ )}
+
+ {isFieldMapped(field.name) && (
+
+ )}
+
+
+ ))}
-
- >
- );
-};
-export default FieldMappingStep;
+ {/* TO 테이블 필드들 */}
+
+
+
+ TO
+
+ {toTable?.name} 필드들
+
+
+
+ {toTable?.columns.map((field) => {
+ const mappedFromField = getMappedFromField(field.name);
+ return (
+
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"
+ }`}
+ >
+
+
+
{field.name}
+
{field.type}
+ {field.primaryKey && (
+
+ PK
+
+ )}
+ {mappedFromField && (
+
+ ← {mappedFromField.name} ({mappedFromField.type})
+
+ )}
+
+
+ {mappedFromField && (
+
removeMapping(`${mappedFromField.name}-${field.name}`)}
+ className="text-red-500 hover:text-red-700"
+ >
+
+
+ )}
+ {mappedFromField && (
+
+ {fieldMappings.find(m => m.toField.name === field.name)?.isValid ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+
+ );
+ })}
+
+
+
+
+ {/* 버튼들 */}
+
+
+
+ 이전 단계
+
+
+
+
+ 연결 설정 저장
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/frontend/components/dataflow/connection/redesigned/RightPanel/StepProgress.tsx b/frontend/components/dataflow/connection/redesigned/RightPanel/StepProgress.tsx
index 340320ea..c7af4d75 100644
--- a/frontend/components/dataflow/connection/redesigned/RightPanel/StepProgress.tsx
+++ b/frontend/components/dataflow/connection/redesigned/RightPanel/StepProgress.tsx
@@ -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
= ({ 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 ;
- }
-
- return (
-
- );
- };
-
- 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 = ({
+ currentStep,
+ onStepChange,
+}) => {
return (
-
- {steps.map((step, index) => (
-
- {/* 단계 */}
-
-
+
+ {steps.map((step, index) => (
+
+ 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)}
>
- {/* 아이콘 */}
-
{getStepIcon(step.number)}
-
- {/* 텍스트 */}
-
-
- {step.title}
-
-
{step.description}
+
+ {step.id < currentStep ? (
+
+ ) : (
+ step.id
+ )}
-
-
-
- {/* 화살표 (마지막 단계 제외) */}
- {index < steps.length - 1 &&
}
-
- ))}
+
+
+
+ {step.title}
+
+
+ {step.description}
+
+
+
+
+ {index < steps.length - 1 && (
+
+ )}
+
+ ))}
+
);
-};
-
-export default StepProgress;
+};
\ No newline at end of file
diff --git a/frontend/components/dataflow/connection/redesigned/RightPanel/TableStep.tsx b/frontend/components/dataflow/connection/redesigned/RightPanel/TableStep.tsx
index 568303a7..d4423fc6 100644
--- a/frontend/components/dataflow/connection/redesigned/RightPanel/TableStep.tsx
+++ b/frontend/components/dataflow/connection/redesigned/RightPanel/TableStep.tsx
@@ -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 = ({
+// 임시 테이블 데이터
+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 = ({
fromConnection,
toConnection,
fromTable,
toTable,
- onSelectTable,
+ onFromTableChange,
+ onToTableChange,
onNext,
onBack,
}) => {
- const [fromTables, setFromTables] = useState([]);
- const [toTables, setToTables] = useState([]);
- const [fromSearch, setFromSearch] = useState("");
- const [toSearch, setToSearch] = useState("");
- const [isLoadingFrom, setIsLoadingFrom] = useState(false);
- const [isLoadingTo, setIsLoadingTo] = useState(false);
- const [tableColumnCounts, setTableColumnCounts] = useState>({});
+ const [selectedFromTable, setSelectedFromTable] = useState(fromTable?.name || "");
+ const [selectedToTable, setSelectedToTable] = useState(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 = {};
- 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 = {};
- 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 (
-
-
-
- {columnCount !== undefined ? columnCount : table.columnCount || 0}개 컬럼
-
-
- );
+ const handleToTableSelect = (tableName: string) => {
+ const table = mockTables.find(t => t.name === tableName);
+ if (table) {
+ setSelectedToTable(tableName);
+ onToTableChange(table);
+ }
};
+ const canProceed = selectedFromTable && selectedToTable;
+
return (
- <>
-
-
-
- 2단계: 테이블 선택
-
- 연결된 데이터베이스에서 소스와 대상 테이블을 선택하세요.
-
+
+
+
+ 테이블 선택
+
+
+ 소스 테이블과 대상 테이블을 선택하세요
+
+
-
- {/* FROM 테이블 선택 */}
-
-
-
FROM 테이블 (소스)
-
- {fromConnection?.name}
-
+ {/* 연결 정보 표시 */}
+
+
+
+
+ {fromConnection?.name}
+ →
+
+ {toConnection?.name}
-
- {/* 검색 */}
-
-
- setFromSearch(e.target.value)}
- className="pl-9"
- />
-
-
- {/* 테이블 선택 */}
- {isLoadingFrom ? (
-
-
- 테이블 목록 로드 중...
-
- ) : (
-
handleTableSelect("from", value)}>
-
-
-
-
- {filteredFromTables.map((table) => (
-
- {renderTableItem(table, "from")}
-
- ))}
-
-
- )}
-
- {fromTable && (
-
-
- {fromTable.displayName || fromTable.tableName}
-
- 📊 {tableColumnCounts[`from_${fromTable.tableName}`] || fromTable.columnCount || 0}개 컬럼
-
-
- {fromTable.description &&
{fromTable.description}
}
-
- )}
+
- {/* TO 테이블 선택 */}
-
-
-
TO 테이블 (대상)
-
- {toConnection?.name}
-
-
-
- {/* 검색 */}
-
-
- setToSearch(e.target.value)}
- className="pl-9"
- />
-
-
- {/* 테이블 선택 */}
- {isLoadingTo ? (
-
-
-
테이블 목록 로드 중...
+
+ {/* FROM 테이블 */}
+
+
+
+ 1
- ) : (
-
handleTableSelect("to", value)}>
-
-
-
-
- {filteredToTables.map((table) => (
-
- {renderTableItem(table, "to")}
-
- ))}
-
-
- )}
-
- {toTable && (
-
-
- {toTable.displayName || toTable.tableName}
-
- 📊 {tableColumnCounts[`to_${toTable.tableName}`] || toTable.columnCount || 0}개 컬럼
-
-
- {toTable.description &&
{toTable.description}
}
-
- )}
-
-
- {/* 테이블 매핑 표시 */}
- {fromTable && toTable && (
-
-
-
-
{fromTable.displayName || fromTable.tableName}
-
- {tableColumnCounts[`from_${fromTable.tableName}`] || fromTable.columnCount || 0}개 컬럼
+
소스 테이블
+ (FROM)
+
+
+
+ {mockTables.map((table) => (
+
handleFromTableSelect(table.name)}
+ >
+
+
+
+
{table.name}
+
{table.columns.length}개 컬럼
+
{table.rowCount?.toLocaleString()}개 행
+
+ {selectedFromTable === table.name && (
+
+ )}
+ ))}
+
+
-
-
-
-
{toTable.displayName || toTable.tableName}
-
- {tableColumnCounts[`to_${toTable.tableName}`] || toTable.columnCount || 0}개 컬럼
+ {/* TO 테이블 */}
+
+
+
+ 2
+
+
대상 테이블
+
(TO)
+
+
+
+ {mockTables.map((table) => (
+
handleToTableSelect(table.name)}
+ >
+
+
+
+
{table.name}
+
{table.columns.length}개 컬럼
+
{table.rowCount?.toLocaleString()}개 행
+
+ {selectedToTable === table.name && (
+
+ )}
-
-
-
-
- 💡 테이블 매핑: {fromTable.displayName || fromTable.tableName} →{" "}
- {toTable.displayName || toTable.tableName}
-
-
+ ))}
- )}
-
- {/* 네비게이션 버튼 */}
-
-
-
- 이전: 연결 선택
-
-
-
- 다음: 컬럼 매핑
-
-
-
- >
+
+
+ {/* 버튼들 */}
+
+
+
+ 이전 단계
+
+
+
+ 다음 단계: 필드 매핑
+
+
+
+
);
-};
-
-export default TableStep;
+};
\ No newline at end of file
diff --git a/frontend/components/dataflow/connection/redesigned/types/redesigned.ts b/frontend/components/dataflow/connection/redesigned/types/redesigned.ts
index 2ba75792..d385d9ef 100644
--- a/frontend/components/dataflow/connection/redesigned/types/redesigned.ts
+++ b/frontend/components/dataflow/connection/redesigned/types/redesigned.ts
@@ -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
;
- 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;
-
- // 필드 매핑
- createMapping: (fromField: ColumnInfo, toField: ColumnInfo) => void;
- updateMapping: (mappingId: string, updates: Partial) => 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) => void;
- deleteActionGroup: (groupId: string) => void;
- addActionToGroup: (groupId: string) => void;
- updateActionInGroup: (groupId: string, actionId: string, updates: Partial) => 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;
- saveMappings: () => Promise;
- testExecution: () => Promise;
-}
-
-// 컴포넌트 Props 타입들
-export interface DataConnectionDesignerProps {
- onClose?: () => void;
- initialData?: Partial;
- 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) => 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;
-}
+}
\ No newline at end of file
diff --git a/frontend/components/layout/ResponsiveContainer.tsx b/frontend/components/layout/ResponsiveContainer.tsx
new file mode 100644
index 00000000..74cf731f
--- /dev/null
+++ b/frontend/components/layout/ResponsiveContainer.tsx
@@ -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 = ({
+ 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 (
+
+ {children}
+
+ );
+};
+
+interface ResponsiveGridProps {
+ children: React.ReactNode;
+ cols?: {
+ mobile?: number;
+ tablet?: number;
+ desktop?: number;
+ };
+ gap?: string;
+ className?: string;
+}
+
+export const ResponsiveGrid: React.FC = ({
+ 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 (
+
+ {children}
+
+ );
+};
+
+interface ResponsiveTextProps {
+ children: React.ReactNode;
+ size?: {
+ mobile?: string;
+ tablet?: string;
+ desktop?: string;
+ };
+ className?: string;
+}
+
+export const ResponsiveText: React.FC = ({
+ 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 (
+
+ {children}
+
+ );
+};
diff --git a/frontend/components/mail/ConfirmDeleteModal.tsx b/frontend/components/mail/ConfirmDeleteModal.tsx
new file mode 100644
index 00000000..9c8a8633
--- /dev/null
+++ b/frontend/components/mail/ConfirmDeleteModal.tsx
@@ -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 (
+
+
+ {/* 헤더 */}
+
+
+ {/* 내용 */}
+
+
{message}
+ {itemName && (
+
+
+ 삭제 대상: {itemName}
+
+
+ )}
+
+ 이 작업은 되돌릴 수 없습니다. 계속하시겠습니까?
+
+
+
+ {/* 버튼 */}
+
+
+ 취소
+
+
+ 삭제
+
+
+
+
+ );
+}
+
diff --git a/frontend/components/mail/MailAccountModal.tsx b/frontend/components/mail/MailAccountModal.tsx
new file mode 100644
index 00000000..11497ff1
--- /dev/null
+++ b/frontend/components/mail/MailAccountModal.tsx
@@ -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;
+ account?: MailAccount | null;
+ mode: 'create' | 'edit';
+}
+
+export default function MailAccountModal({
+ isOpen,
+ onClose,
+ onSave,
+ account,
+ mode,
+}: MailAccountModalProps) {
+ const [formData, setFormData] = useState({
+ 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
+ ) => {
+ 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 (
+
+
+ {/* 헤더 */}
+
+
+
+
+ {mode === 'create' ? '새 메일 계정 추가' : '메일 계정 수정'}
+
+
+
+
+
+
+
+ {/* 폼 */}
+
+
+
+ );
+}
+
diff --git a/frontend/components/mail/MailAccountTable.tsx b/frontend/components/mail/MailAccountTable.tsx
new file mode 100644
index 00000000..e4ff9680
--- /dev/null
+++ b/frontend/components/mail/MailAccountTable.tsx
@@ -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('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 (
+
+
+
+ 등록된 메일 계정이 없습니다
+
+
+ "새 계정 추가" 버튼을 클릭하여 첫 번째 메일 계정을 등록하세요.
+
+
+ );
+ }
+
+ return (
+
+ {/* 검색 */}
+
+
+ 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"
+ />
+
+
+ {/* 테이블 */}
+
+
+
+
+
+ handleSort('name')}
+ >
+
+
+ 계정명
+ {sortField === 'name' && (
+
+ {sortOrder === 'asc' ? '↑' : '↓'}
+
+ )}
+
+
+ handleSort('email')}
+ >
+ 이메일 주소
+
+
+ SMTP 서버
+
+ handleSort('status')}
+ >
+ 상태
+
+ handleSort('dailyLimit')}
+ >
+
+
+ 일일 제한
+
+
+ handleSort('createdAt')}
+ >
+
+
+ 생성일
+
+
+
+ 액션
+
+
+
+
+ {sortedAccounts.map((account) => (
+
+
+ {account.name}
+
+
+ {account.email}
+
+
+
+ {account.smtpHost}:{account.smtpPort}
+
+
+ {account.smtpSecure ? 'SSL' : 'TLS'}
+
+
+
+ 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' ? (
+ <>
+
+ 활성
+ >
+ ) : (
+ <>
+
+ 비활성
+ >
+ )}
+
+
+
+
+ {account.dailyLimit > 0
+ ? account.dailyLimit.toLocaleString()
+ : '무제한'}
+
+
+
+
+ {formatDate(account.createdAt)}
+
+
+
+
+ onEdit(account)}
+ className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
+ title="수정"
+ >
+
+
+ onDelete(account)}
+ className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
+ title="삭제"
+ >
+
+
+
+
+
+ ))}
+
+
+
+
+
+ {/* 결과 요약 */}
+
+ 전체 {accounts.length}개 중 {sortedAccounts.length}개 표시
+ {searchTerm && ` (검색: "${searchTerm}")`}
+
+
+ );
+}
+
diff --git a/frontend/components/mail/MailDesigner.tsx b/frontend/components/mail/MailDesigner.tsx
new file mode 100644
index 00000000..110156bf
--- /dev/null
+++ b/frontend/components/mail/MailDesigner.tsx
@@ -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;
+}
+
+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([]);
+ const [selectedComponent, setSelectedComponent] = useState(null);
+ const [templateName, setTemplateName] = useState("");
+ const [subject, setSubject] = useState("");
+ const [queries, setQueries] = useState([]);
+
+ // 컴포넌트 타입 정의
+ 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" ? "텍스트를 입력하세요...
" : 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) => {
+ 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 (
+
+ {/* 왼쪽: 컴포넌트 팔레트 */}
+
+
+
+
+ 컴포넌트
+
+
+ {componentTypes.map(({ type, icon: Icon, label, color }) => (
+ addComponent(type)}
+ variant="outline"
+ className={`w-full justify-start ${color} border-gray-300`}
+ >
+
+ {label}
+
+ ))}
+
+
+
+ {/* 템플릿 정보 */}
+
+
+ 템플릿 정보
+
+
+
+ 템플릿 이름
+ setTemplateName(e.target.value)}
+ placeholder="예: 고객 환영 메일"
+ className="mt-1"
+ />
+
+
+ 제목
+ setSubject(e.target.value)}
+ placeholder="예: {customer_name}님 환영합니다!"
+ className="mt-1"
+ />
+
+
+
+
+ {/* 액션 버튼 */}
+
+
+
+ 저장
+
+
+
+ 미리보기
+
+
+
+ 발송
+
+
+
+
+ {/* 중앙: 캔버스 */}
+
+
+
+
+ 메일 미리보기
+
+ {components.length}개 컴포넌트
+
+
+
+
+ {/* 제목 영역 */}
+ {subject && (
+
+ )}
+
+ {/* 컴포넌트 렌더링 */}
+
+ {components.length === 0 ? (
+
+ ) : (
+ components.map((comp) => (
+
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}
+ >
+ {/* 삭제 버튼 */}
+
{
+ 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"
+ >
+
+
+
+ {/* 컴포넌트 내용 */}
+ {comp.type === "text" && (
+
+ )}
+ {comp.type === "button" && (
+
+ {comp.text}
+
+ )}
+ {comp.type === "image" && (
+
+ )}
+ {comp.type === "spacer" && (
+
+ )}
+
+ ))
+ )}
+
+
+
+
+
+ {/* 오른쪽: 속성 패널 */}
+
+ {selected ? (
+
+
+
+
+ 속성 편집
+
+ setSelectedComponent(null)}
+ >
+ 닫기
+
+
+
+ {/* 텍스트 컴포넌트 */}
+ {selected.type === "text" && (
+
+ 내용 (HTML)
+
+ )}
+
+ {/* 버튼 컴포넌트 */}
+ {selected.type === "button" && (
+ <>
+
+ 버튼 텍스트
+
+ updateComponent(selected.id, { text: e.target.value })
+ }
+ className="mt-1"
+ />
+
+
+ 링크 URL
+
+ updateComponent(selected.id, { url: e.target.value })
+ }
+ className="mt-1"
+ />
+
+
+ 배경색
+
+ updateComponent(selected.id, {
+ styles: { ...selected.styles, backgroundColor: e.target.value },
+ })
+ }
+ className="mt-1"
+ />
+
+ >
+ )}
+
+ {/* 이미지 컴포넌트 */}
+ {selected.type === "image" && (
+
+ 이미지 URL
+
+ updateComponent(selected.id, { src: e.target.value })
+ }
+ className="mt-1"
+ />
+
+ )}
+
+ {/* 여백 컴포넌트 */}
+ {selected.type === "spacer" && (
+
+ 높이 (px)
+
+ updateComponent(selected.id, { height: parseInt(e.target.value) })
+ }
+ className="mt-1"
+ />
+
+ )}
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
diff --git a/frontend/components/mail/MailTemplateCard.tsx b/frontend/components/mail/MailTemplateCard.tsx
new file mode 100644
index 00000000..4ab30c9f
--- /dev/null
+++ b/frontend/components/mail/MailTemplateCard.tsx
@@ -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 = {
+ 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 (
+
+ {/* 헤더 */}
+
+
+
+
+
+
+
+
+ {template.name}
+
+
+ {template.subject}
+
+
+
+ {template.category && (
+
+ {template.category}
+
+ )}
+
+
+
+ {/* 본문 미리보기 */}
+
+
+
컴포넌트 {template.components.length}개
+
+ {template.components.slice(0, 3).map((component, idx) => (
+
+
+
{component.type}
+ {component.type === 'text' && component.content && (
+
+ {component.content.replace(/<[^>]*>/g, '').substring(0, 30)}...
+
+ )}
+
+ ))}
+ {template.components.length > 3 && (
+
+ +{template.components.length - 3}개 더보기
+
+ )}
+
+
+
+ {/* 메타 정보 */}
+
+
+
+ {formatDate(template.createdAt)}
+
+ {template.updatedAt !== template.createdAt && (
+
수정됨
+ )}
+
+
+
+ {/* 액션 버튼 */}
+
+ onPreview(template)}
+ >
+
+ 미리보기
+
+ onEdit(template)}
+ >
+
+ 수정
+
+ {onDuplicate && (
+ onDuplicate(template)}
+ >
+
+
+ )}
+ onDelete(template)}
+ >
+
+
+
+
+ );
+}
+
diff --git a/frontend/components/mail/MailTemplateEditorModal.tsx b/frontend/components/mail/MailTemplateEditorModal.tsx
new file mode 100644
index 00000000..7b6f08ce
--- /dev/null
+++ b/frontend/components/mail/MailTemplateEditorModal.tsx
@@ -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;
+ 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 (
+
+ {/* 헤더 */}
+
+
+
+ {mode === 'create' ? '새 메일 템플릿 만들기' : '메일 템플릿 수정'}
+
+
+
+
+
+
+
+ {/* MailDesigner 컴포넌트 */}
+
+ {
+ // 미리보기 로직은 MailDesigner 내부에서 처리
+ console.log('Preview:', data);
+ }}
+ />
+
+
+ );
+}
+
diff --git a/frontend/components/mail/MailTemplatePreviewModal.tsx b/frontend/components/mail/MailTemplatePreviewModal.tsx
new file mode 100644
index 00000000..7203762a
--- /dev/null
+++ b/frontend/components/mail/MailTemplatePreviewModal.tsx
@@ -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>({});
+
+ 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 (
+
+
+ {/* 헤더 */}
+
+
+
+
+
{template.name}
+
{template.subject}
+
+
+
+ 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' ? (
+ <>
+
+ 코드
+ >
+ ) : (
+ <>
+
+ 미리보기
+ >
+ )}
+
+ setIsFullscreen(!isFullscreen)}
+ className="text-white hover:bg-white/20 rounded-lg p-2 transition"
+ >
+ {isFullscreen ? (
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+ {/* 본문 */}
+
+ {/* 왼쪽: 변수 입력 (변수가 있을 때만) */}
+ {templateVariables.length > 0 && (
+
+
+
+ 템플릿 변수
+
+
+ {templateVariables.map((variable) => (
+
+
+ {variable}
+
+ 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"
+ />
+
+ ))}
+
+
+
+ 💡 변수 값을 입력하면 미리보기에 반영됩니다.
+
+
+
+ )}
+
+ {/* 오른쪽: 미리보기 또는 코드 */}
+
+ {viewMode === 'preview' ? (
+
+ {/* 이메일 헤더 시뮬레이션 */}
+
+
+
+ 제목:
+ {template.subject}
+
+
+ 발신:
+ your-email@company.com
+
+
+ 수신:
+ recipient@example.com
+
+
+
+
+ {/* 이메일 본문 */}
+
+
+ ) : (
+
+ )}
+
+
+
+ {/* 푸터 */}
+
+
+ 닫기
+
+
+
+
+ );
+}
+
diff --git a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx
index 30227fb5..013b3d27 100644
--- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx
+++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx
@@ -188,11 +188,11 @@ export const InteractiveScreenViewerDynamic: React.FC {
// 화면 새로고침 로직 (필요시 구현)
- console.log("화면 새로고침 요청");
+ // console.log("화면 새로고침 요청");
}}
onClose={() => {
// 화면 닫기 로직 (필요시 구현)
- console.log("화면 닫기 요청");
+ // console.log("화면 닫기 요청");
}}
/>
);
diff --git a/frontend/components/theme/ThemeSettings.tsx b/frontend/components/theme/ThemeSettings.tsx
new file mode 100644
index 00000000..99a6bd3b
--- /dev/null
+++ b/frontend/components/theme/ThemeSettings.tsx
@@ -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 (
+
+
+
+
+
테마 설정
+
+
+ {/* 다크모드 토글 */}
+
+
+
+ 테마 모드
+
+
+ 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"
+ }`}
+ >
+
+ 라이트
+
+ 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"
+ }`}
+ >
+
+ 다크
+
+ 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"
+ }`}
+ >
+
+ 시스템
+
+
+
+
+ {/* 색상 스키마 */}
+
+
+ 색상 스키마
+
+
+ {colorSchemes.map((scheme) => (
+
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"
+ }`}
+ >
+
+
+ {scheme.name}
+
+
+ ))}
+
+
+
+ {/* 현재 색상 미리보기 */}
+
+
+ {/* 빠른 토글 버튼 */}
+
+
+ {isDark ? : }
+ {isDark ? "라이트 모드로" : "다크 모드로"} 전환
+
+
+
+
+
+ );
+};
diff --git a/frontend/components/theme/ThemedButton.tsx b/frontend/components/theme/ThemedButton.tsx
new file mode 100644
index 00000000..d4ef67d6
--- /dev/null
+++ b/frontend/components/theme/ThemedButton.tsx
@@ -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 = ({
+ 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 (
+ {
+ 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}
+
+ );
+};
diff --git a/frontend/lib/animations/animations.ts b/frontend/lib/animations/animations.ts
new file mode 100644
index 00000000..f717377f
--- /dev/null
+++ b/frontend/lib/animations/animations.ts
@@ -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",
+ }),
+};
diff --git a/frontend/lib/api/mail.ts b/frontend/lib/api/mail.ts
new file mode 100644
index 00000000..794860b1
--- /dev/null
+++ b/frontend/lib/api/mail.ts
@@ -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 {
+ 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;
+}
+
+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 {}
+
+export interface SendMailDto {
+ accountId: string;
+ templateId?: string;
+ to: string[]; // 수신자 이메일 배열
+ subject: string;
+ variables?: Record; // 템플릿 변수 치환
+ customHtml?: string; // 템플릿 없이 직접 HTML 작성 시
+}
+
+export interface MailSendResult {
+ success: boolean;
+ messageId?: string;
+ error?: string;
+}
+
+// ============================================
+// API 기본 설정
+// ============================================
+
+const API_BASE_URL = '/api/mail';
+
+async function fetchApi(
+ endpoint: string,
+ options: RequestInit = {}
+): Promise {
+ 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 {
+ return fetchApi('/accounts');
+}
+
+/**
+ * 특정 메일 계정 조회
+ */
+export async function getMailAccount(id: string): Promise {
+ return fetchApi(`/accounts/${id}`);
+}
+
+/**
+ * 메일 계정 생성
+ */
+export async function createMailAccount(
+ data: CreateMailAccountDto
+): Promise {
+ return fetchApi('/accounts', {
+ method: 'POST',
+ body: JSON.stringify(data),
+ });
+}
+
+/**
+ * 메일 계정 수정
+ */
+export async function updateMailAccount(
+ id: string,
+ data: UpdateMailAccountDto
+): Promise {
+ return fetchApi(`/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 {
+ return fetchApi('/templates-file');
+}
+
+/**
+ * 특정 메일 템플릿 조회
+ */
+export async function getMailTemplate(id: string): Promise {
+ return fetchApi(`/templates-file/${id}`);
+}
+
+/**
+ * 메일 템플릿 생성
+ */
+export async function createMailTemplate(
+ data: CreateMailTemplateDto
+): Promise {
+ return fetchApi('/templates-file', {
+ method: 'POST',
+ body: JSON.stringify(data),
+ });
+}
+
+/**
+ * 메일 템플릿 수정
+ */
+export async function updateMailTemplate(
+ id: string,
+ data: UpdateMailTemplateDto
+): Promise {
+ return fetchApi(`/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
+): 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 {
+ return fetchApi('/send/simple', {
+ method: 'POST',
+ body: JSON.stringify(data),
+ });
+}
+
+/**
+ * 템플릿 변수 추출 (템플릿에서 {변수명} 형식 추출)
+ */
+export function extractTemplateVariables(template: MailTemplate): string[] {
+ const variableRegex = /\{(\w+)\}/g;
+ const variables = new Set();
+
+ // 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 {
+ let html = '';
+
+ 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 += `
${content}
`;
+ 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 += `
+
${buttonText}
+ `;
+ break;
+
+ case 'image':
+ html += `
`;
+ break;
+
+ case 'spacer':
+ html += `
`;
+ break;
+ }
+ });
+
+ html += '
';
+ return html;
+}
+
+function styleObjectToString(styles?: Record): 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();
+}
+
diff --git a/frontend/lib/contexts/ThemeContext.tsx b/frontend/lib/contexts/ThemeContext.tsx
new file mode 100644
index 00000000..cdfa08f6
--- /dev/null
+++ b/frontend/lib/contexts/ThemeContext.tsx
@@ -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) => 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(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({
+ 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) => {
+ 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 (
+
+ {children}
+
+ );
+};
+
+export const useTheme = () => {
+ const context = useContext(ThemeContext);
+ if (context === undefined) {
+ throw new Error("useTheme must be used within a ThemeProvider");
+ }
+ return context;
+};
diff --git a/frontend/lib/hooks/useResponsive.ts b/frontend/lib/hooks/useResponsive.ts
new file mode 100644
index 00000000..3e19e78c
--- /dev/null
+++ b/frontend/lib/hooks/useResponsive.ts
@@ -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;
+ 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>) => {
+ 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,
+ };
+};
diff --git a/frontend/lib/registry/components/PanelRenderer.tsx b/frontend/lib/registry/components/PanelRenderer.tsx
index 42260386..c6b4dac2 100644
--- a/frontend/lib/registry/components/PanelRenderer.tsx
+++ b/frontend/lib/registry/components/PanelRenderer.tsx
@@ -15,32 +15,35 @@ const PanelRenderer: ComponentRenderer = ({ component, children, ...props }) =>
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
return (
-
-
+
+
- {title}
+ {title}
{collapsible && (
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 ? : }
+ {isExpanded ? : }
)}
{isExpanded && (
-
+
{children && React.Children.count(children) > 0 ? (
children
) : (
-
-
-
패널 내용 영역
-
컴포넌트를 여기에 배치하세요
+
+
+
패널 내용 영역
+
컴포넌트를 여기에 배치하세요
)}
diff --git a/frontend/lib/registry/components/accordion-basic/AccordionBasicComponent.tsx b/frontend/lib/registry/components/accordion-basic/AccordionBasicComponent.tsx
index ad919d95..0658ace1 100644
--- a/frontend/lib/registry/components/accordion-basic/AccordionBasicComponent.tsx
+++ b/frontend/lib/registry/components/accordion-basic/AccordionBasicComponent.tsx
@@ -89,26 +89,32 @@ const CustomAccordion: React.FC
= ({
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";
}}
>
{item.title}
@@ -125,13 +131,16 @@ const CustomAccordion: React.FC = ({
= ({
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}
diff --git a/frontend/lib/registry/components/date-input/DateInputComponent.tsx b/frontend/lib/registry/components/date-input/DateInputComponent.tsx
index a950314c..9660b074 100644
--- a/frontend/lib/registry/components/date-input/DateInputComponent.tsx
+++ b/frontend/lib/registry/components/date-input/DateInputComponent.tsx
@@ -322,13 +322,23 @@ export const DateInputComponent: React.FC
= ({
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}
diff --git a/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx b/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx
index b4cb444b..bce8fb11 100644
--- a/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx
+++ b/frontend/lib/registry/components/image-display/ImageDisplayComponent.tsx
@@ -95,12 +95,22 @@ export const ImageDisplayComponent: React.FC = ({
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}
diff --git a/frontend/lib/registry/components/number-input/NumberInputComponent.tsx b/frontend/lib/registry/components/number-input/NumberInputComponent.tsx
index 05aa96b0..cb4924ca 100644
--- a/frontend/lib/registry/components/number-input/NumberInputComponent.tsx
+++ b/frontend/lib/registry/components/number-input/NumberInputComponent.tsx
@@ -123,13 +123,23 @@ export const NumberInputComponent: React.FC = ({
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}
diff --git a/frontend/lib/registry/components/radio-basic/RadioBasicComponent.tsx b/frontend/lib/registry/components/radio-basic/RadioBasicComponent.tsx
index 29fe0dda..799f8770 100644
--- a/frontend/lib/registry/components/radio-basic/RadioBasicComponent.tsx
+++ b/frontend/lib/registry/components/radio-basic/RadioBasicComponent.tsx
@@ -111,11 +111,24 @@ export const RadioBasicComponent: React.FC = ({
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}
diff --git a/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx b/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx
index 851a212c..3b72e292 100644
--- a/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx
+++ b/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx
@@ -329,10 +329,24 @@ const SelectBasicComponent: React.FC = ({
{/* 커스텀 셀렉트 박스 */}
{
+ 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)";
+ }
}}
>
{selectedLabel || placeholder}
diff --git a/frontend/lib/registry/components/test-input/TestInputComponent.tsx b/frontend/lib/registry/components/test-input/TestInputComponent.tsx
index 43c8bf4b..53dfb3ec 100644
--- a/frontend/lib/registry/components/test-input/TestInputComponent.tsx
+++ b/frontend/lib/registry/components/test-input/TestInputComponent.tsx
@@ -101,10 +101,20 @@ export const TestInputComponent: React.FC
= ({
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}
diff --git a/frontend/lib/registry/components/text-display/TextDisplayComponent.tsx b/frontend/lib/registry/components/text-display/TextDisplayComponent.tsx
index f6e61551..5fe44fa3 100644
--- a/frontend/lib/registry/components/text-display/TextDisplayComponent.tsx
+++ b/frontend/lib/registry/components/text-display/TextDisplayComponent.tsx
@@ -75,9 +75,9 @@ export const TextDisplayComponent: React.FC = ({
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 = ({
: "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 (
diff --git a/frontend/lib/registry/components/text-input/TextInputComponent.tsx b/frontend/lib/registry/components/text-input/TextInputComponent.tsx
index 54caec16..37130d9d 100644
--- a/frontend/lib/registry/components/text-input/TextInputComponent.tsx
+++ b/frontend/lib/registry/components/text-input/TextInputComponent.tsx
@@ -237,13 +237,23 @@ export const TextInputComponent: React.FC = ({
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}
diff --git a/frontend/lib/registry/components/textarea-basic/TextareaBasicComponent.tsx b/frontend/lib/registry/components/textarea-basic/TextareaBasicComponent.tsx
index 482280b0..732cfaef 100644
--- a/frontend/lib/registry/components/textarea-basic/TextareaBasicComponent.tsx
+++ b/frontend/lib/registry/components/textarea-basic/TextareaBasicComponent.tsx
@@ -116,14 +116,24 @@ export const TextareaBasicComponent: React.FC = ({
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}
diff --git a/frontend/lib/registry/components/toggle-switch/ToggleSwitchComponent.tsx b/frontend/lib/registry/components/toggle-switch/ToggleSwitchComponent.tsx
index f71a4127..b3f8e68e 100644
--- a/frontend/lib/registry/components/toggle-switch/ToggleSwitchComponent.tsx
+++ b/frontend/lib/registry/components/toggle-switch/ToggleSwitchComponent.tsx
@@ -114,9 +114,23 @@ export const ToggleSwitchComponent: React.FC = ({
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}
diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs
index 6d01bfab..4b9515fc 100644
--- a/frontend/next.config.mjs
+++ b/frontend/next.config.mjs
@@ -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() {
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 7db705eb..d1c88cde 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -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",
diff --git a/frontend/package.json b/frontend/package.json
index a66a452a..e5cd9c8e 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -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",
diff --git a/메일관리_시스템_구현_계획서.md b/메일관리_시스템_구현_계획서.md
new file mode 100644
index 00000000..31cd2ee9
--- /dev/null
+++ b/메일관리_시스템_구현_계획서.md
@@ -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;
+}
+```
+
+---
+
+## 💾 데이터 저장 구조
+
+### 파일 시스템 기반 (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": "안녕하세요, {customer_name}님!
",
+ "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 착수 준비 완료
+