From 0209be8fd67e7628d378fe5469c46a1ab9094e9b Mon Sep 17 00:00:00 2001 From: leeheejin Date: Wed, 1 Oct 2025 16:15:53 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A9=94=EC=9D=BC=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20=EC=A0=80=EC=9E=A5=EC=9A=A9=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- UI_개선사항_문서.md | 604 ++++++++++++ backend-node/package-lock.json | 291 +++++- backend-node/package.json | 7 +- backend-node/src/app.ts | 8 + .../controllers/mailAccountFileController.ts | 201 ++++ .../src/controllers/mailQueryController.ts | 213 +++++ .../controllers/mailSendSimpleController.ts | 96 ++ .../controllers/mailTemplateFileController.ts | 258 ++++++ .../src/routes/mailAccountFileRoutes.ts | 14 + backend-node/src/routes/mailQueryRoutes.ts | 37 + .../src/routes/mailSendSimpleRoutes.ts | 13 + .../src/routes/mailTemplateFileRoutes.ts | 18 + .../src/services/encryptionService.ts | 76 ++ .../src/services/mailAccountFileService.ts | 159 ++++ backend-node/src/services/mailQueryService.ts | 241 +++++ .../src/services/mailSendSimpleService.ts | 213 +++++ .../src/services/mailTemplateFileService.ts | 231 +++++ docker/dev/docker-compose.backend.mac.yml | 1 + .../app/(main)/admin/mail/accounts/page.tsx | 205 +++++ .../app/(main)/admin/mail/dashboard/page.tsx | 283 ++++++ .../app/(main)/admin/mail/receive/page.tsx | 104 +++ frontend/app/(main)/admin/mail/send/page.tsx | 392 ++++++++ .../app/(main)/admin/mail/templates/page.tsx | 286 ++++++ frontend/components/admin/UserToolbar.tsx | 8 - .../animations/AnimatedComponent.tsx | 154 ++++ .../redesigned/DataConnectionDesigner.tsx | 866 ++---------------- .../LeftPanel/ConnectionTypeSelector.tsx | 106 +-- .../redesigned/LeftPanel/MappingInfoPanel.tsx | 231 +++-- .../redesigned/RightPanel/ConnectionStep.tsx | 579 ++++-------- .../RightPanel/FieldMappingStep.tsx | 375 ++++---- .../redesigned/RightPanel/StepProgress.tsx | 136 ++- .../redesigned/RightPanel/TableStep.tsx | 475 ++++------ .../connection/redesigned/types/redesigned.ts | 224 +---- .../components/layout/ResponsiveContainer.tsx | 108 +++ .../components/mail/ConfirmDeleteModal.tsx | 83 ++ frontend/components/mail/MailAccountModal.tsx | 406 ++++++++ frontend/components/mail/MailAccountTable.tsx | 254 +++++ frontend/components/mail/MailDesigner.tsx | 401 ++++++++ frontend/components/mail/MailTemplateCard.tsx | 150 +++ .../mail/MailTemplateEditorModal.tsx | 79 ++ .../mail/MailTemplatePreviewModal.tsx | 163 ++++ .../screen/InteractiveScreenViewerDynamic.tsx | 4 +- frontend/components/theme/ThemeSettings.tsx | 135 +++ frontend/components/theme/ThemedButton.tsx | 96 ++ frontend/lib/animations/animations.ts | 196 ++++ frontend/lib/api/mail.ts | 361 ++++++++ frontend/lib/contexts/ThemeContext.tsx | 167 ++++ frontend/lib/hooks/useResponsive.ts | 72 ++ .../lib/registry/components/PanelRenderer.tsx | 23 +- .../AccordionBasicComponent.tsx | 25 +- .../checkbox-basic/CheckboxBasicComponent.tsx | 16 +- .../date-input/DateInputComponent.tsx | 12 +- .../image-display/ImageDisplayComponent.tsx | 12 +- .../number-input/NumberInputComponent.tsx | 12 +- .../radio-basic/RadioBasicComponent.tsx | 17 +- .../select-basic/SelectBasicComponent.tsx | 16 +- .../test-input/TestInputComponent.tsx | 12 +- .../text-display/TextDisplayComponent.tsx | 8 +- .../text-input/TextInputComponent.tsx | 12 +- .../textarea-basic/TextareaBasicComponent.tsx | 12 +- .../toggle-switch/ToggleSwitchComponent.tsx | 14 + frontend/next.config.mjs | 10 +- frontend/package-lock.json | 305 +++++- frontend/package.json | 1 + 메일관리_시스템_구현_계획서.md | 494 ++++++++++ 65 files changed, 8636 insertions(+), 2145 deletions(-) create mode 100644 UI_개선사항_문서.md create mode 100644 backend-node/src/controllers/mailAccountFileController.ts create mode 100644 backend-node/src/controllers/mailQueryController.ts create mode 100644 backend-node/src/controllers/mailSendSimpleController.ts create mode 100644 backend-node/src/controllers/mailTemplateFileController.ts create mode 100644 backend-node/src/routes/mailAccountFileRoutes.ts create mode 100644 backend-node/src/routes/mailQueryRoutes.ts create mode 100644 backend-node/src/routes/mailSendSimpleRoutes.ts create mode 100644 backend-node/src/routes/mailTemplateFileRoutes.ts create mode 100644 backend-node/src/services/encryptionService.ts create mode 100644 backend-node/src/services/mailAccountFileService.ts create mode 100644 backend-node/src/services/mailQueryService.ts create mode 100644 backend-node/src/services/mailSendSimpleService.ts create mode 100644 backend-node/src/services/mailTemplateFileService.ts create mode 100644 frontend/app/(main)/admin/mail/accounts/page.tsx create mode 100644 frontend/app/(main)/admin/mail/dashboard/page.tsx create mode 100644 frontend/app/(main)/admin/mail/receive/page.tsx create mode 100644 frontend/app/(main)/admin/mail/send/page.tsx create mode 100644 frontend/app/(main)/admin/mail/templates/page.tsx create mode 100644 frontend/components/animations/AnimatedComponent.tsx create mode 100644 frontend/components/layout/ResponsiveContainer.tsx create mode 100644 frontend/components/mail/ConfirmDeleteModal.tsx create mode 100644 frontend/components/mail/MailAccountModal.tsx create mode 100644 frontend/components/mail/MailAccountTable.tsx create mode 100644 frontend/components/mail/MailDesigner.tsx create mode 100644 frontend/components/mail/MailTemplateCard.tsx create mode 100644 frontend/components/mail/MailTemplateEditorModal.tsx create mode 100644 frontend/components/mail/MailTemplatePreviewModal.tsx create mode 100644 frontend/components/theme/ThemeSettings.tsx create mode 100644 frontend/components/theme/ThemedButton.tsx create mode 100644 frontend/lib/animations/animations.ts create mode 100644 frontend/lib/api/mail.ts create mode 100644 frontend/lib/contexts/ThemeContext.tsx create mode 100644 frontend/lib/hooks/useResponsive.ts create mode 100644 메일관리_시스템_구현_계획서.md diff --git a/UI_개선사항_문서.md b/UI_개선사항_문서.md new file mode 100644 index 00000000..da991296 --- /dev/null +++ b/UI_개선사항_문서.md @@ -0,0 +1,604 @@ +# ERP 시스템 UI/UX 디자인 가이드 + +## 📋 문서 목적 +이 문서는 ERP 시스템의 새로운 페이지나 컴포넌트를 개발할 때 참고할 수 있는 **디자인 시스템 기준안**입니다. +일관된 사용자 경험을 위해 모든 개발자는 이 가이드를 따라 개발해주세요. + +--- + +## 🎨 디자인 시스템 개요 + +### 디자인 철학 +- **일관성**: 모든 페이지에서 동일한 패턴 사용 +- **명확성**: 직관적이고 이해하기 쉬운 UI +- **접근성**: 모든 사용자가 쉽게 사용할 수 있도록 +- **반응성**: 다양한 화면 크기에 대응 + +### 기술 스택 +- **CSS Framework**: Tailwind CSS +- **UI Library**: shadcn/ui +- **Icons**: Lucide React + +--- + +## 📐 페이지 기본 구조 + +### 1. 표준 페이지 레이아웃 + +```tsx +export default function YourPage() { + return ( +
+
+ {/* 페이지 제목 */} +
+
+

페이지 제목

+

페이지 설명

+
+
+ {/* 버튼들 */} +
+
+ + {/* 메인 컨텐츠 */} + + + {/* 내용 */} + + +
+
+ ); +} +``` + +### 2. 구조 설명 + +#### 최상위 래퍼 +```tsx +
+``` +- `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 + +``` + +### 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 ( +
+
+ {/* 헤더 */} +
+
+

데이터 관리

+

시스템 데이터를 관리합니다

+
+
+ + +
+
+ + {/* 통계 카드 */} +
+ + +
+
+

총 개수

+

156

+
+
+ +
+
+
+
+ {/* 나머지 통계 카드들... */} +
+ + {/* 데이터 테이블 */} + + + + + + + + + + + + {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}% + +
+
+
+ + 전월 대비 12% 증가 +
+
+
+
+
+ + + + + + 최근 활동 + + + +
+
+ +

최근 활동이 없습니다

+
+
+
+
+
+ + {/* 빠른 액세스 */} + + + 빠른 액세스 + + + + + + + {/* 안내 정보 */} + + + + + 메일 관리 시스템 안내 + + + +

+ 💡 메일 관리 시스템의 주요 기능을 확인하세요! +

+
    +
  • + + 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 ( +
+
+ {/* 페이지 제목 */} +
+

메일 발송

+

템플릿을 선택하여 메일을 발송합니다

+
+ + {/* 메인 폼 */} +
+ {/* 왼쪽: 발송 설정 */} +
+ + + + + 발송 설정 + + + + {/* 발송 계정 선택 */} +
+ + +
+ + {/* 템플릿 선택 */} +
+ + +
+ + {/* 메일 제목 */} +
+ + 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 && ( + + )} +
+ ))} + +
+
+ + {/* 템플릿 변수 */} + {templateVariables.length > 0 && ( +
+ +
+ {templateVariables.map((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" + /> +
+ ))} +
+
+ )} +
+
+ + {/* 발송 버튼 */} +
+ + +
+ + {/* 발송 결과 */} + {sendResult && ( + + +
+ {sendResult.success ? ( + + ) : ( + + )} +

+ {sendResult.message} +

+
+
+
+ )} +
+ + {/* 오른쪽: 미리보기 */} +
+ + + + + 미리보기 + + + + {showPreview && previewHtml ? ( +
+
제목: {subject}
+
+
+ ) : ( +
+ +

+ 템플릿을 선택하고 +
+ 미리보기 버튼을 클릭하세요 +

+
+ )} + + +
+
+
+
+ ); +} 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" + /> +
+ +
+
+
+ + {/* 메인 컨텐츠 */} + {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.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" &&

사용 가능한 이름입니다.

} -
-
- -