Compare commits
3 Commits
df64841c1e
...
51dea84bc5
| Author | SHA1 | Date |
|---|---|---|
|
|
51dea84bc5 | |
|
|
95c98cbda3 | |
|
|
b4c5be1f17 |
196
UI_개선사항_문서.md
196
UI_개선사항_문서.md
|
|
@ -601,4 +601,200 @@ export default function EmptyStatePage() {
|
|||
|
||||
---
|
||||
|
||||
## 📧 메일 관리 시스템 UI 개선사항
|
||||
|
||||
### 최근 업데이트 (2025-01-02)
|
||||
|
||||
#### 1. 메일 발송 페이지 헤더 개선
|
||||
**변경 전:**
|
||||
```tsx
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-lg">
|
||||
<Send className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">메일 발송</h1>
|
||||
<p className="text-sm text-gray-500">설명</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**변경 후 (표준 헤더 카드 적용):**
|
||||
```tsx
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl">메일 발송</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
템플릿을 선택하거나 직접 작성하여 메일을 발송하세요
|
||||
</p>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
```
|
||||
|
||||
**개선 사항:**
|
||||
- ✅ 불필요한 아이콘 제거 (종이비행기)
|
||||
- ✅ 표준 Card 컴포넌트 사용으로 통일감 향상
|
||||
- ✅ 다른 페이지와 동일한 헤더 스타일 적용
|
||||
|
||||
#### 2. 메일 내용 입력 개선
|
||||
**변경 전:**
|
||||
```tsx
|
||||
<Textarea placeholder="메일 내용을 html로 작성하세요" />
|
||||
```
|
||||
|
||||
**변경 후:**
|
||||
```tsx
|
||||
<Textarea
|
||||
placeholder="메일 내용을 입력하세요
|
||||
|
||||
줄바꿈은 자동으로 처리됩니다."
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
💡 일반 텍스트로 작성하면 자동으로 메일 형식으로 변환됩니다
|
||||
</p>
|
||||
```
|
||||
|
||||
**개선 사항:**
|
||||
- ✅ HTML 지식 없이도 사용 가능
|
||||
- ✅ 일반 텍스트 입력 후 자동 HTML 변환
|
||||
- ✅ 사용자 친화적인 안내 메시지
|
||||
|
||||
#### 3. CC/BCC 기능 추가
|
||||
**구현 내용:**
|
||||
```tsx
|
||||
{/* To 태그 입력 */}
|
||||
<EmailTagInput
|
||||
tags={to}
|
||||
onTagsChange={setTo}
|
||||
placeholder="받는 사람 이메일"
|
||||
/>
|
||||
|
||||
{/* CC 태그 입력 */}
|
||||
<EmailTagInput
|
||||
tags={cc}
|
||||
onTagsChange={setCc}
|
||||
placeholder="참조 (선택사항)"
|
||||
/>
|
||||
|
||||
{/* BCC 태그 입력 */}
|
||||
<EmailTagInput
|
||||
tags={bcc}
|
||||
onTagsChange={setBcc}
|
||||
placeholder="숨은참조 (선택사항)"
|
||||
/>
|
||||
```
|
||||
|
||||
**특징:**
|
||||
- ✅ 이메일 주소를 태그 형태로 시각화
|
||||
- ✅ 쉼표로 구분하여 입력 가능
|
||||
- ✅ 개별 삭제 가능
|
||||
|
||||
#### 4. 파일 첨부 기능 (Phase 1 완료)
|
||||
|
||||
**백엔드 구현:**
|
||||
```typescript
|
||||
// multer 설정
|
||||
export const uploadMailAttachment = multer({
|
||||
storage,
|
||||
fileFilter,
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024, // 10MB 제한
|
||||
files: 5, // 최대 5개 파일
|
||||
},
|
||||
});
|
||||
|
||||
// 발송 API
|
||||
router.post(
|
||||
'/simple',
|
||||
uploadMailAttachment.array('attachments', 5),
|
||||
(req, res) => mailSendSimpleController.sendMail(req, res)
|
||||
);
|
||||
```
|
||||
|
||||
**보안 기능:**
|
||||
- ✅ 위험한 파일 확장자 차단 (.exe, .bat, .cmd, .sh 등)
|
||||
- ✅ 파일 크기 제한 (10MB)
|
||||
- ✅ 파일 개수 제한 (최대 5개)
|
||||
- ✅ 안전한 파일명 생성
|
||||
|
||||
**프론트엔드 구현 예정 (Phase 1-3):**
|
||||
- 드래그 앤 드롭 파일 업로드
|
||||
- 첨부된 파일 목록 표시
|
||||
- 파일 삭제 기능
|
||||
- 미리보기에 첨부파일 정보 표시
|
||||
|
||||
#### 5. 향후 작업 계획
|
||||
|
||||
**Phase 2: 보낸메일함 백엔드**
|
||||
- 발송 이력 자동 저장 (JSON 파일)
|
||||
- 발송 상태 관리 (성공/실패)
|
||||
- 발송 이력 조회 API
|
||||
|
||||
**Phase 3: 보낸메일함 프론트엔드**
|
||||
- `/admin/mail/sent` 페이지
|
||||
- 발송 목록 테이블
|
||||
- 상세보기 모달
|
||||
- 재전송 기능
|
||||
|
||||
**Phase 4: 대시보드 통합**
|
||||
- 대시보드에 "보낸메일함" 링크
|
||||
- 실제 발송 통계 연동
|
||||
- 최근 활동 목록
|
||||
|
||||
### 메일 시스템 UI 가이드
|
||||
|
||||
#### 이메일 태그 입력
|
||||
```tsx
|
||||
// 이메일 주소를 시각적으로 표시
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map((tag, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-1 px-3 py-1.5 bg-blue-100 text-blue-700 rounded-md text-sm"
|
||||
>
|
||||
<Mail className="w-3 h-3" />
|
||||
{tag}
|
||||
<button onClick={() => removeTag(index)}>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
```
|
||||
|
||||
#### 파일 첨부 영역 (예정)
|
||||
```tsx
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 hover:border-orange-400 transition-colors">
|
||||
<input type="file" multiple className="hidden" />
|
||||
<div className="text-center">
|
||||
<Upload className="w-12 h-12 mx-auto text-gray-400" />
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
파일을 드래그하거나 클릭하여 선택하세요
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
최대 5개, 각 10MB 이하
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### 발송 성공 토스트
|
||||
```tsx
|
||||
<div className="fixed top-4 right-4 bg-white rounded-lg shadow-lg border border-green-200 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">메일이 발송되었습니다</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
{to.length}명에게 전송 완료
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**이 가이드를 따라 개발하면 일관되고 아름다운 UI를 만들 수 있습니다!** 🎨✨
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"id": "1e492bb1-d069-4242-8cbf-9829b8f6c7e6",
|
||||
"sentAt": "2025-10-13T01:08:34.764Z",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"subject": "제목 없음",
|
||||
"htmlContent": "\n<!DOCTYPE html>\n<html>\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n</head>\n<body style=\"margin: 0; padding: 0; background-color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;\">\n <table role=\"presentation\" style=\"width: 100%; border-collapse: collapse; background-color: #ffffff;\">\n <tr>\n <td style=\"padding: 20px;\">\n<div style=\"margin: 0 0 20px 0; color: #333; font-size: 15px; line-height: 1.6; text-align: left;\"><p>ㄴㅇㄹㄴㅇㄹ</p></div><div style=\"margin: 30px 0; text-align: left;\">\n <a href=\"https://example.com\" style=\"display: inline-block; padding: 14px 28px; background-color: #007bff; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 15px;\">ㄴㅇㄹ버튼</a>\n </div><div style=\"margin: 20px 0; text-align: left;\">\n <img src=\"https://placehold.co/600x200/e5e7eb/64748b?text=Image\" alt=\"\" style=\"max-width: 100%; height: auto; display: block; border-radius: 4px;\" />\n </div><div style=\"height: 20;\"></div><div style=\"margin: 0 0 20px 0; color: #333; font-size: 15px; line-height: 1.6; text-align: left;\"><p>ㄴㅇㄹ</p></div><div style=\"margin: 0 0 20px 0; color: #333; font-size: 15px; line-height: 1.6; text-align: left;\"><p>ㄴㅇㄹ</p></div>\n </td>\n </tr>\n </table>\n\n <div style=\"margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb;\">\n \r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㄴㅇㄹ</p>\r\n </div>\r\n \n </div>\n </body>\n</html>\n",
|
||||
"templateId": "template-1760315158387",
|
||||
"templateName": "테스트2",
|
||||
"attachments": [
|
||||
{
|
||||
"filename": "스크린샷 2025-10-13 오전 10.00.06.png",
|
||||
"originalName": "스크린샷 2025-10-13 오전 10.00.06.png",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1760317712416-622369845.png",
|
||||
"mimetype": "image/png"
|
||||
}
|
||||
],
|
||||
"status": "success",
|
||||
"messageId": "<f03bea59-9a77-b454-845e-7ad2a070bade@wace.me>",
|
||||
"accepted": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"rejected": []
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"id": "2d848b19-26e1-45ad-8e2c-9205f1f01c87",
|
||||
"sentAt": "2025-10-02T07:50:25.817Z",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"subject": "ㅣ;ㅏㅓ",
|
||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅓㅏㅣ</p>\r\n </div>\r\n ",
|
||||
"attachments": [
|
||||
{
|
||||
"filename": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
||||
"originalName": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759391422625-269479520_test____________________________33.jpg",
|
||||
"mimetype": "image/jpeg"
|
||||
},
|
||||
{
|
||||
"filename": "UI_áá
¢áá
¥á«áá
¡áá
¡á¼_áá
®á«áá
¥.md",
|
||||
"originalName": "UI_áá
¢áá
¥á«áá
¡áá
¡á¼_áá
®á«áá
¥.md",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759391422626-68453569_UI_______________________________________________.md",
|
||||
"mimetype": "text/x-markdown"
|
||||
},
|
||||
{
|
||||
"filename": "testáá
á¼ áá
栠
栠
µ2.png",
|
||||
"originalName": "testáá
á¼ áá
栠
栠
µ2.png",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759391422626-168170034_test____________________________2.png",
|
||||
"mimetype": "image/png"
|
||||
}
|
||||
],
|
||||
"status": "success",
|
||||
"messageId": "<9d5b8275-e059-3a71-a34a-dea800730aa3@wace.me>",
|
||||
"accepted": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"rejected": []
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"id": "37fce6a0-2301-431b-b573-82bdab9b8008",
|
||||
"sentAt": "2025-10-02T07:44:38.128Z",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"subject": "asd",
|
||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">asd</p>\r\n </div>\r\n ",
|
||||
"attachments": [
|
||||
{
|
||||
"filename": "áá
°áá
栠
³-áá
µá·áá
µá¨áá
¯á«-áá
³á
á
©áá
¡áá
µá¯-áá
栠
´áá
栮.key",
|
||||
"originalName": "áá
°áá
栠
³-áá
µá·áá
µá¨áá
¯á«-áá
³á
á
©áá
¡áá
µá¯-áá
栠
´áá
栮.key",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759391076653-58189058___________________-___________________________-___________________________-_____________________.key",
|
||||
"mimetype": "application/x-iwork-keynote-sffkey"
|
||||
},
|
||||
{
|
||||
"filename": "áá
°áá
栠
³-áá
µá·áá
µá¨áá
¯á«-áá
³á
á
©áá
¡áá
µá¯-áá
栠
´áá
栮.pptx",
|
||||
"originalName": "áá
°áá
栠
³-áá
µá·áá
µá¨áá
¯á«-áá
³á
á
©áá
¡áá
µá¯-áá
栠
´áá
栮.pptx",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759391076736-190208246___________________-___________________________-___________________________-_____________________.pptx",
|
||||
"mimetype": "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
},
|
||||
{
|
||||
"filename": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
||||
"originalName": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759391076738-240665795_test____________________________33.jpg",
|
||||
"mimetype": "image/jpeg"
|
||||
}
|
||||
],
|
||||
"status": "success",
|
||||
"messageId": "<796cb9a7-df62-31c4-ae6b-b42f383d82b4@wace.me>",
|
||||
"accepted": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"rejected": []
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"id": "9eab902e-f77b-424f-ada4-0ea8709b36bf",
|
||||
"sentAt": "2025-10-13T00:53:55.193Z",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"subject": "제목 없음",
|
||||
"htmlContent": "<div style=\"max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;\"><p style=\"margin: 16px 0; color: #333; font-size: 14px;\"><p>텍스트를 입력하세요...</p></p><div style=\"text-align: center; margin: 24px 0;\">\n <a href=\"https://example.com\" style=\"display: inline-block; padding: 12px 24px; background-color: #007bff; color: #fff; text-decoration: none; border-radius: 4px;\">버튼</a>\n </div><div style=\"text-align: center; margin: 16px 0;\">\n <img src=\"https://placehold.co/600x200/e5e7eb/64748b?text=Image\" alt=\"\" style=\"max-width: 100%; height: auto;\" />\n </div><div style=\"height: 20;\"></div><p style=\"margin: 16px 0; color: #333; font-size: 14px;\"><p>텍스트를 입력하세요...</p></p><p style=\"margin: 16px 0; color: #333; font-size: 14px;\"><p>텍스트를 입력하세요...</p></p>\n <div style=\"margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb;\">\n \r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">어덯게 나오는지 봅시다 추가메시지 영역이빈다.</p>\r\n </div>\r\n \n </div>\n </div>",
|
||||
"templateId": "template-1760315158387",
|
||||
"templateName": "테스트2",
|
||||
"attachments": [
|
||||
{
|
||||
"filename": "한글.txt",
|
||||
"originalName": "한글.txt",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1760316833254-789302611.txt",
|
||||
"mimetype": "text/plain"
|
||||
}
|
||||
],
|
||||
"status": "success",
|
||||
"messageId": "<3d0bef10-2e58-fd63-b175-c1f499af0102@wace.me>",
|
||||
"accepted": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"rejected": []
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"id": "a1ca39ad-4467-44e0-963a-fba5037c8896",
|
||||
"sentAt": "2025-10-02T08:22:14.721Z",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"subject": "ㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴ",
|
||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹㅁㄴㅇㄹㄴㅁㅇㄹ</p>\r\n </div>\r\n ",
|
||||
"attachments": [
|
||||
{
|
||||
"filename": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
||||
"originalName": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759393332207-791945862_test____________________________33.jpg",
|
||||
"mimetype": "image/jpeg"
|
||||
},
|
||||
{
|
||||
"filename": "UI_áá
¢áá
¥á«áá
¡áá
¡á¼_áá
®á«áá
¥.md",
|
||||
"originalName": "UI_áá
¢áá
¥á«áá
¡áá
¡á¼_áá
®á«áá
¥.md",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759393332208-660280542_UI_______________________________________________.md",
|
||||
"mimetype": "text/x-markdown"
|
||||
},
|
||||
{
|
||||
"filename": "testáá
á¼ áá
栠
栠
µ2.png",
|
||||
"originalName": "testáá
á¼ áá
栠
栠
µ2.png",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759393332208-149486455_test____________________________2.png",
|
||||
"mimetype": "image/png"
|
||||
}
|
||||
],
|
||||
"status": "success",
|
||||
"messageId": "<d52bab7c-4285-8a27-12ed-b501ff858d23@wace.me>",
|
||||
"accepted": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"rejected": []
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"id": "a3a9aab1-4334-46bd-bf50-b867305f66c0",
|
||||
"sentAt": "2025-10-02T08:41:42.086Z",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"subject": "한글테스트",
|
||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
|
||||
"attachments": [
|
||||
{
|
||||
"filename": "UI_áá
¢áá
¥á«áá
¡áá
¡á¼_áá
®á«áá
¥.md",
|
||||
"originalName": "UI_áá
¢áá
¥á«áá
¡áá
¡á¼_áá
®á«áá
¥.md",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759394500462-50127394_UI_______________________________________________.md",
|
||||
"mimetype": "text/x-markdown"
|
||||
},
|
||||
{
|
||||
"filename": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
||||
"originalName": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759394500463-68744474_test____________________________33.jpg",
|
||||
"mimetype": "image/jpeg"
|
||||
},
|
||||
{
|
||||
"filename": "testáá
á¼ áá
栠
栠
µ2.png",
|
||||
"originalName": "testáá
á¼ áá
栠
栠
µ2.png",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759394500463-464487722_test____________________________2.png",
|
||||
"mimetype": "image/png"
|
||||
}
|
||||
],
|
||||
"status": "success",
|
||||
"messageId": "<2dbfbf64-69c2-a83d-6bb7-515e4e654628@wace.me>",
|
||||
"accepted": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"rejected": []
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"id": "b1d8f458-076c-4c44-982e-d2f46dcd4b03",
|
||||
"sentAt": "2025-10-02T08:57:48.412Z",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"subject": "ㅁㄴㅇㄹ",
|
||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
|
||||
"attachments": [
|
||||
{
|
||||
"filename": "웨이스-임직원-프로파일-이희진.key",
|
||||
"originalName": "웨이스-임직원-프로파일-이희진.key",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759395465488-120933172.key",
|
||||
"mimetype": "application/x-iwork-keynote-sffkey"
|
||||
},
|
||||
{
|
||||
"filename": "UI_개선사항_문서.md",
|
||||
"originalName": "UI_개선사항_문서.md",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759395465566-306126854.md",
|
||||
"mimetype": "text/x-markdown"
|
||||
},
|
||||
{
|
||||
"filename": "test용 이미지33.jpg",
|
||||
"originalName": "test용 이미지33.jpg",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759395465566-412984398.jpg",
|
||||
"mimetype": "image/jpeg"
|
||||
},
|
||||
{
|
||||
"filename": "test용 이미지2.png",
|
||||
"originalName": "test용 이미지2.png",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759395465567-143883587.png",
|
||||
"mimetype": "image/png"
|
||||
}
|
||||
],
|
||||
"status": "success",
|
||||
"messageId": "<e2796753-a1a9-fbac-c035-00341e29031c@wace.me>",
|
||||
"accepted": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"rejected": []
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"id": "b75d0b2b-7d8a-461b-b854-2bebdef959e8",
|
||||
"sentAt": "2025-10-02T08:49:30.356Z",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"subject": "한글2",
|
||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
|
||||
"attachments": [
|
||||
{
|
||||
"filename": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
|
||||
"originalName": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759394969516-74008147_UI__________________________.md",
|
||||
"mimetype": "text/x-markdown"
|
||||
},
|
||||
{
|
||||
"filename": "testááá¼ ááµááµááµ33.jpg",
|
||||
"originalName": "testááá¼ ááµááµááµ33.jpg",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759394969516-530544653_test_______________33.jpg",
|
||||
"mimetype": "image/jpeg"
|
||||
},
|
||||
{
|
||||
"filename": "testááá¼ ááµááµááµ2.png",
|
||||
"originalName": "testááá¼ ááµááµááµ2.png",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759394969517-260831218_test_______________2.png",
|
||||
"mimetype": "image/png"
|
||||
}
|
||||
],
|
||||
"status": "success",
|
||||
"messageId": "<80a431a1-bb4d-31b5-2564-93f8c2539fd4@wace.me>",
|
||||
"accepted": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"rejected": []
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"id": "ccdd8961-1b3f-4b88-b838-51d6ed8f1601",
|
||||
"sentAt": "2025-10-02T08:47:03.481Z",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"subject": "한글테스트222",
|
||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">2</p>\r\n </div>\r\n ",
|
||||
"attachments": [
|
||||
{
|
||||
"filename": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
|
||||
"originalName": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759394821751-229305880_UI__________________________.md",
|
||||
"mimetype": "text/x-markdown"
|
||||
},
|
||||
{
|
||||
"filename": "testááá¼ ááµááµááµ33.jpg",
|
||||
"originalName": "testááá¼ ááµááµááµ33.jpg",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759394821751-335146895_test_______________33.jpg",
|
||||
"mimetype": "image/jpeg"
|
||||
},
|
||||
{
|
||||
"filename": "testááá¼ ááµááµááµ2.png",
|
||||
"originalName": "testááá¼ ááµááµááµ2.png",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759394821753-911076131_test_______________2.png",
|
||||
"mimetype": "image/png"
|
||||
}
|
||||
],
|
||||
"status": "success",
|
||||
"messageId": "<69519c70-a5cd-421d-9976-8c7014d69b39@wace.me>",
|
||||
"accepted": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"rejected": []
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,41 @@
|
|||
{
|
||||
"id": "ee0d162c-48ad-4c00-8c56-ade80be4503f",
|
||||
"sentAt": "2025-10-02T08:48:29.740Z",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"subject": "한글한글",
|
||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
|
||||
"attachments": [
|
||||
{
|
||||
"filename": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
|
||||
"originalName": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759394908877-38147683_UI__________________________.md",
|
||||
"mimetype": "text/x-markdown"
|
||||
},
|
||||
{
|
||||
"filename": "testááá¼ ááµááµááµ33.jpg",
|
||||
"originalName": "testááá¼ ááµááµááµ33.jpg",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759394908879-80461065_test_______________33.jpg",
|
||||
"mimetype": "image/jpeg"
|
||||
},
|
||||
{
|
||||
"filename": "testááá¼ ááµááµááµ2.png",
|
||||
"originalName": "testááá¼ ááµááµááµ2.png",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759394908880-475630926_test_______________2.png",
|
||||
"mimetype": "image/png"
|
||||
}
|
||||
],
|
||||
"status": "success",
|
||||
"messageId": "<96205714-1a6b-adb7-7ae5-0e1e3fcb700b@wace.me>",
|
||||
"accepted": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"rejected": []
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"id": "fc26aba3-6b6e-47ba-91e8-609ae25e0e7d",
|
||||
"sentAt": "2025-10-13T00:21:51.799Z",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"subject": "test용입니다.",
|
||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
|
||||
"templateId": "template-1759302346758",
|
||||
"templateName": "test",
|
||||
"attachments": [
|
||||
{
|
||||
"filename": "웨이스-임직원-프로파일-이희진.key",
|
||||
"originalName": "웨이스-임직원-프로파일-이희진.key",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1760314910154-84512253.key",
|
||||
"mimetype": "application/x-iwork-keynote-sffkey"
|
||||
}
|
||||
],
|
||||
"status": "success",
|
||||
"messageId": "<c84bcecc-2e8f-4a32-1b7f-44a91b195b2d@wace.me>",
|
||||
"accepted": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"rejected": []
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"watch": ["src"],
|
||||
"ignore": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts",
|
||||
"data/**",
|
||||
"uploads/**",
|
||||
"logs/**",
|
||||
"*.log"
|
||||
],
|
||||
"ext": "ts,json",
|
||||
"exec": "ts-node src/app.ts",
|
||||
"delay": 2000
|
||||
}
|
||||
|
||||
|
|
@ -18,6 +18,7 @@
|
|||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"helmet": "^7.1.0",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"imap": "^0.8.19",
|
||||
"joi": "^17.11.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
|
|
@ -4247,6 +4248,18 @@
|
|||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/body-parser/node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
|
|
@ -6375,15 +6388,19 @@
|
|||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
|
||||
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
|
|
@ -8040,22 +8057,6 @@
|
|||
"node": ">= 8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mysql2/node_modules/iconv-lite": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
|
||||
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/named-placeholders": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz",
|
||||
|
|
@ -8950,6 +8951,18 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body/node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@
|
|||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"helmet": "^7.1.0",
|
||||
"iconv-lite": "^0.7.0",
|
||||
"imap": "^0.8.19",
|
||||
"joi": "^17.11.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import mailTemplateFileRoutes from "./routes/mailTemplateFileRoutes";
|
|||
import mailAccountFileRoutes from "./routes/mailAccountFileRoutes";
|
||||
import mailSendSimpleRoutes from "./routes/mailSendSimpleRoutes";
|
||||
import mailReceiveBasicRoutes from "./routes/mailReceiveBasicRoutes";
|
||||
import mailSentHistoryRoutes from "./routes/mailSentHistoryRoutes";
|
||||
import dataRoutes from "./routes/dataRoutes";
|
||||
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
|
||||
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
|
||||
|
|
@ -73,8 +74,8 @@ app.use(
|
|||
})
|
||||
);
|
||||
app.use(compression());
|
||||
app.use(express.json({ limit: "10mb" }));
|
||||
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
|
||||
app.use(express.json({ limit: "50mb" }));
|
||||
app.use(express.urlencoded({ extended: true, limit: "50mb" }));
|
||||
|
||||
// 정적 파일 서빙 전에 CORS 미들웨어 추가 (OPTIONS 요청 처리)
|
||||
app.options("/uploads/*", (req, res) => {
|
||||
|
|
@ -174,7 +175,19 @@ app.use("/api/layouts", layoutRoutes);
|
|||
app.use("/api/mail/accounts", mailAccountFileRoutes); // 파일 기반 계정
|
||||
app.use("/api/mail/templates-file", mailTemplateFileRoutes); // 파일 기반 템플릿
|
||||
app.use("/api/mail/send", mailSendSimpleRoutes); // 메일 발송
|
||||
// 메일 수신 라우트 디버깅 - 모든 요청 로깅
|
||||
app.use("/api/mail/receive", (req, res, next) => {
|
||||
console.log(`\n🔍 [MAIL RECEIVE REQUEST]`);
|
||||
console.log(` Method: ${req.method}`);
|
||||
console.log(` URL: ${req.originalUrl}`);
|
||||
console.log(` Path: ${req.path}`);
|
||||
console.log(` Base URL: ${req.baseUrl}`);
|
||||
console.log(` Params: ${JSON.stringify(req.params)}`);
|
||||
console.log(` Query: ${JSON.stringify(req.query)}`);
|
||||
next();
|
||||
});
|
||||
app.use("/api/mail/receive", mailReceiveBasicRoutes); // 메일 수신
|
||||
app.use("/api/mail/sent", mailSentHistoryRoutes); // 발송 이력
|
||||
app.use("/api/screen", screenStandardRoutes);
|
||||
app.use("/api/data", dataRoutes);
|
||||
app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
// 업로드 디렉토리 경로
|
||||
const UPLOAD_DIR = path.join(__dirname, '../../uploads/mail-attachments');
|
||||
|
||||
// 디렉토리 생성 (없으면)
|
||||
if (!fs.existsSync(UPLOAD_DIR)) {
|
||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// 간단한 파일명 정규화 함수 (한글-분석.txt 방식)
|
||||
function normalizeFileName(filename: string): string {
|
||||
if (!filename) return filename;
|
||||
|
||||
try {
|
||||
// NFC 정규화만 수행 (복잡한 디코딩 제거)
|
||||
return filename.normalize('NFC');
|
||||
} catch (error) {
|
||||
console.error(`Failed to normalize filename: ${filename}`, error);
|
||||
return filename;
|
||||
}
|
||||
}
|
||||
|
||||
// 파일 저장 설정
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, UPLOAD_DIR);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
try {
|
||||
// 파일명 정규화 (한글-분석.txt 방식)
|
||||
file.originalname = file.originalname.normalize('NFC');
|
||||
|
||||
console.log('File upload - Processing:', {
|
||||
original: file.originalname,
|
||||
originalHex: Buffer.from(file.originalname).toString('hex'),
|
||||
});
|
||||
|
||||
// UUID + 확장자로 유니크한 파일명 생성
|
||||
const uniqueId = Date.now() + '-' + Math.round(Math.random() * 1e9);
|
||||
const ext = path.extname(file.originalname);
|
||||
const filename = `${uniqueId}${ext}`;
|
||||
|
||||
console.log('Generated filename:', {
|
||||
original: file.originalname,
|
||||
generated: filename,
|
||||
});
|
||||
|
||||
cb(null, filename);
|
||||
} catch (error) {
|
||||
console.error('Filename processing error:', error);
|
||||
const fallbackFilename = `${Date.now()}-${Math.round(Math.random() * 1e9)}_error.tmp`;
|
||||
cb(null, fallbackFilename);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 파일 필터 (허용할 파일 타입)
|
||||
const fileFilter = (req: any, file: Express.Multer.File, cb: multer.FileFilterCallback) => {
|
||||
// 파일명 정규화 (fileFilter가 filename보다 먼저 실행되므로 여기서 먼저 처리)
|
||||
try {
|
||||
// NFD를 NFC로 정규화만 수행
|
||||
file.originalname = file.originalname.normalize('NFC');
|
||||
} catch (error) {
|
||||
console.warn('Failed to normalize filename in fileFilter:', error);
|
||||
}
|
||||
|
||||
// 위험한 파일 확장자 차단
|
||||
const dangerousExtensions = ['.exe', '.bat', '.cmd', '.sh', '.ps1', '.msi'];
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
|
||||
if (dangerousExtensions.includes(ext)) {
|
||||
console.log(`❌ 차단된 파일 타입: ${ext}`);
|
||||
cb(new Error(`보안상의 이유로 ${ext} 파일은 첨부할 수 없습니다.`));
|
||||
return;
|
||||
}
|
||||
|
||||
cb(null, true);
|
||||
};
|
||||
|
||||
// Multer 설정
|
||||
export const uploadMailAttachment = multer({
|
||||
storage,
|
||||
fileFilter,
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024, // 10MB 제한
|
||||
files: 5, // 최대 5개 파일
|
||||
},
|
||||
});
|
||||
|
||||
// 첨부파일 정보 추출 헬퍼
|
||||
export interface AttachmentInfo {
|
||||
filename: string;
|
||||
originalName: string;
|
||||
size: number;
|
||||
path: string;
|
||||
mimetype: string;
|
||||
}
|
||||
|
||||
export const extractAttachmentInfo = (files: Express.Multer.File[]): AttachmentInfo[] => {
|
||||
return files.map((file) => ({
|
||||
filename: file.filename,
|
||||
originalName: file.originalname,
|
||||
size: file.size,
|
||||
path: file.path,
|
||||
mimetype: file.mimetype,
|
||||
}));
|
||||
};
|
||||
|
||||
|
|
@ -18,6 +18,12 @@ export class MailReceiveBasicController {
|
|||
*/
|
||||
async getMailList(req: Request, res: Response) {
|
||||
try {
|
||||
console.log('📬 메일 목록 조회 요청:', {
|
||||
params: req.params,
|
||||
path: req.path,
|
||||
originalUrl: req.originalUrl
|
||||
});
|
||||
|
||||
const { accountId } = req.params;
|
||||
const limit = parseInt(req.query.limit as string) || 50;
|
||||
|
||||
|
|
@ -43,6 +49,12 @@ export class MailReceiveBasicController {
|
|||
*/
|
||||
async getMailDetail(req: Request, res: Response) {
|
||||
try {
|
||||
console.log('🔍 메일 상세 조회 요청:', {
|
||||
params: req.params,
|
||||
path: req.path,
|
||||
originalUrl: req.originalUrl
|
||||
});
|
||||
|
||||
const { accountId, seqno } = req.params;
|
||||
const seqnoNumber = parseInt(seqno, 10);
|
||||
|
||||
|
|
@ -109,29 +121,39 @@ export class MailReceiveBasicController {
|
|||
*/
|
||||
async downloadAttachment(req: Request, res: Response) {
|
||||
try {
|
||||
console.log('📎🎯 컨트롤러 downloadAttachment 진입');
|
||||
const { accountId, seqno, index } = req.params;
|
||||
console.log(`📎 파라미터: accountId=${accountId}, seqno=${seqno}, index=${index}`);
|
||||
|
||||
const seqnoNumber = parseInt(seqno, 10);
|
||||
const indexNumber = parseInt(index, 10);
|
||||
|
||||
if (isNaN(seqnoNumber) || isNaN(indexNumber)) {
|
||||
console.log('❌ 유효하지 않은 파라미터');
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '유효하지 않은 파라미터입니다.',
|
||||
});
|
||||
}
|
||||
|
||||
console.log('📎 서비스 호출 시작...');
|
||||
const result = await this.mailReceiveService.downloadAttachment(
|
||||
accountId,
|
||||
seqnoNumber,
|
||||
indexNumber
|
||||
);
|
||||
console.log(`📎 서비스 호출 완료: result=${result ? '있음' : '없음'}`);
|
||||
|
||||
if (!result) {
|
||||
console.log('❌ 첨부파일을 찾을 수 없음');
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '첨부파일을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`📎 파일 다운로드 시작: ${result.filename}`);
|
||||
console.log(`📎 파일 경로: ${result.filePath}`);
|
||||
|
||||
// 파일 다운로드
|
||||
res.download(result.filePath, result.filename, (err) => {
|
||||
|
|
@ -173,5 +195,27 @@ export class MailReceiveBasicController {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/mail/receive/today-count
|
||||
* 오늘 수신 메일 수 조회
|
||||
*/
|
||||
async getTodayReceivedCount(req: Request, res: Response) {
|
||||
try {
|
||||
const { accountId } = req.query;
|
||||
const count = await this.mailReceiveService.getTodayReceivedCount(accountId as string);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: { count }
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error('오늘 수신 메일 수 조회 실패:', error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '오늘 수신 메일 수 조회에 실패했습니다.'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,12 +3,31 @@ import { mailSendSimpleService } from '../services/mailSendSimpleService';
|
|||
|
||||
export class MailSendSimpleController {
|
||||
/**
|
||||
* 메일 발송 (단건 또는 소규모)
|
||||
* 메일 발송 (단건 또는 소규모) - 첨부파일 지원
|
||||
*/
|
||||
async sendMail(req: Request, res: Response) {
|
||||
try {
|
||||
console.log('📧 메일 발송 요청 수신:', { accountId: req.body.accountId, to: req.body.to, subject: req.body.subject });
|
||||
const { accountId, templateId, to, subject, variables, customHtml } = req.body;
|
||||
console.log('📧 메일 발송 요청 수신:', {
|
||||
accountId: req.body.accountId,
|
||||
to: req.body.to,
|
||||
cc: req.body.cc,
|
||||
bcc: req.body.bcc,
|
||||
subject: req.body.subject,
|
||||
attachments: req.files ? (req.files as Express.Multer.File[]).length : 0,
|
||||
});
|
||||
|
||||
// FormData에서 JSON 문자열 파싱
|
||||
const accountId = req.body.accountId;
|
||||
const templateId = req.body.templateId;
|
||||
const modifiedTemplateComponents = req.body.modifiedTemplateComponents
|
||||
? JSON.parse(req.body.modifiedTemplateComponents)
|
||||
: undefined; // 🎯 수정된 템플릿 컴포넌트
|
||||
const to = req.body.to ? JSON.parse(req.body.to) : [];
|
||||
const cc = req.body.cc ? JSON.parse(req.body.cc) : undefined;
|
||||
const bcc = req.body.bcc ? JSON.parse(req.body.bcc) : undefined;
|
||||
const subject = req.body.subject;
|
||||
const variables = req.body.variables ? JSON.parse(req.body.variables) : undefined;
|
||||
const customHtml = req.body.customHtml;
|
||||
|
||||
// 필수 파라미터 검증
|
||||
if (!accountId || !to || !Array.isArray(to) || to.length === 0) {
|
||||
|
|
@ -34,14 +53,54 @@ export class MailSendSimpleController {
|
|||
});
|
||||
}
|
||||
|
||||
// 첨부파일 처리 (한글 파일명 지원)
|
||||
const attachments: Array<{ filename: string; path: string; contentType?: string }> = [];
|
||||
if (req.files && Array.isArray(req.files)) {
|
||||
const files = req.files as Express.Multer.File[];
|
||||
|
||||
// 프론트엔드에서 전송한 정규화된 파일명 사용 (한글-분석.txt 방식)
|
||||
let parsedFileNames: string[] = [];
|
||||
if (req.body.fileNames) {
|
||||
try {
|
||||
parsedFileNames = JSON.parse(req.body.fileNames);
|
||||
console.log('📎 프론트엔드에서 받은 파일명들:', parsedFileNames);
|
||||
} catch (e) {
|
||||
console.warn('파일명 파싱 실패, multer originalname 사용');
|
||||
}
|
||||
}
|
||||
|
||||
files.forEach((file, index) => {
|
||||
// 클라이언트에서 전송한 파일명 우선 사용, 없으면 multer의 originalname 사용
|
||||
let originalName = parsedFileNames[index] || file.originalname;
|
||||
|
||||
// NFC 정규화 확실히 수행
|
||||
originalName = originalName.normalize('NFC');
|
||||
|
||||
attachments.push({
|
||||
filename: originalName,
|
||||
path: file.path,
|
||||
contentType: file.mimetype,
|
||||
});
|
||||
});
|
||||
|
||||
console.log('📎 최종 첨부파일 정보:', attachments.map(a => ({
|
||||
filename: a.filename,
|
||||
path: a.path.split('/').pop()
|
||||
})));
|
||||
}
|
||||
|
||||
// 메일 발송
|
||||
const result = await mailSendSimpleService.sendMail({
|
||||
accountId,
|
||||
templateId,
|
||||
modifiedTemplateComponents, // 🎯 수정된 템플릿 컴포넌트 전달
|
||||
to,
|
||||
cc,
|
||||
bcc,
|
||||
subject,
|
||||
variables,
|
||||
customHtml,
|
||||
attachments: attachments.length > 0 ? attachments : undefined,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,140 @@
|
|||
import { Request, Response } from 'express';
|
||||
import { mailSentHistoryService } from '../services/mailSentHistoryService';
|
||||
|
||||
export class MailSentHistoryController {
|
||||
/**
|
||||
* 발송 이력 목록 조회
|
||||
*/
|
||||
async getList(req: Request, res: Response) {
|
||||
try {
|
||||
const query = {
|
||||
page: req.query.page ? parseInt(req.query.page as string) : undefined,
|
||||
limit: req.query.limit ? parseInt(req.query.limit as string) : undefined,
|
||||
searchTerm: req.query.searchTerm as string | undefined,
|
||||
status: req.query.status as 'success' | 'failed' | 'all' | undefined,
|
||||
accountId: req.query.accountId as string | undefined,
|
||||
startDate: req.query.startDate as string | undefined,
|
||||
endDate: req.query.endDate as string | undefined,
|
||||
sortBy: req.query.sortBy as 'sentAt' | 'subject' | undefined,
|
||||
sortOrder: req.query.sortOrder as 'asc' | 'desc' | undefined,
|
||||
};
|
||||
|
||||
const result = await mailSentHistoryService.getSentMailList(query);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
console.error('발송 이력 목록 조회 실패:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '발송 이력 조회 중 오류가 발생했습니다.',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 발송 이력 상세 조회
|
||||
*/
|
||||
async getById(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '발송 이력 ID가 필요합니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const history = await mailSentHistoryService.getSentMailById(id);
|
||||
|
||||
if (!history) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: '발송 이력을 찾을 수 없습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: history,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
console.error('발송 이력 조회 실패:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '발송 이력 조회 중 오류가 발생했습니다.',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 발송 이력 삭제
|
||||
*/
|
||||
async deleteById(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: '발송 이력 ID가 필요합니다.',
|
||||
});
|
||||
}
|
||||
|
||||
const success = await mailSentHistoryService.deleteSentMail(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;
|
||||
console.error('발송 이력 삭제 실패:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '발송 이력 삭제 중 오류가 발생했습니다.',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 조회
|
||||
*/
|
||||
async getStatistics(req: Request, res: Response) {
|
||||
try {
|
||||
const accountId = req.query.accountId as string | undefined;
|
||||
const stats = await mailSentHistoryService.getStatistics(accountId);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: stats,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
console.error('통계 조회 실패:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: '통계 조회 중 오류가 발생했습니다.',
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const mailSentHistoryController = new MailSentHistoryController();
|
||||
|
||||
|
|
@ -12,20 +12,29 @@ const router = express.Router();
|
|||
router.use(authenticateToken);
|
||||
const controller = new MailReceiveBasicController();
|
||||
|
||||
// 메일 목록 조회
|
||||
router.get('/:accountId', (req, res) => controller.getMailList(req, res));
|
||||
// 오늘 수신 메일 수 조회 (통계) - 가장 먼저 정의 (가장 구체적)
|
||||
router.get('/today-count', (req, res) => controller.getTodayReceivedCount(req, res));
|
||||
|
||||
// 메일 상세 조회
|
||||
router.get('/:accountId/:seqno', (req, res) => controller.getMailDetail(req, res));
|
||||
// 첨부파일 다운로드 - 매우 구체적인 경로
|
||||
router.get('/:accountId/:seqno/attachment/:index', (req, res) => {
|
||||
console.log(`📎 첨부파일 라우트 핸들러 진입!`);
|
||||
console.log(` accountId: ${req.params.accountId}`);
|
||||
console.log(` seqno: ${req.params.seqno}`);
|
||||
console.log(` index: ${req.params.index}`);
|
||||
controller.downloadAttachment(req, res);
|
||||
});
|
||||
|
||||
// 첨부파일 다운로드 (상세 조회보다 먼저 정의해야 함)
|
||||
router.get('/:accountId/:seqno/attachment/:index', (req, res) => controller.downloadAttachment(req, res));
|
||||
|
||||
// 메일 읽음 표시
|
||||
// 메일 읽음 표시 - 구체적인 경로
|
||||
router.post('/:accountId/:seqno/mark-read', (req, res) => controller.markAsRead(req, res));
|
||||
|
||||
// IMAP 연결 테스트
|
||||
// 메일 상세 조회 - /:accountId보다 먼저 정의해야 함
|
||||
router.get('/:accountId/:seqno', (req, res) => controller.getMailDetail(req, res));
|
||||
|
||||
// IMAP 연결 테스트 - /:accountId보다 먼저 정의해야 함
|
||||
router.post('/:accountId/test-imap', (req, res) => controller.testImapConnection(req, res));
|
||||
|
||||
// 메일 목록 조회 - 가장 마지막에 정의 (가장 일반적)
|
||||
router.get('/:accountId', (req, res) => controller.getMailList(req, res));
|
||||
|
||||
export default router;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,19 @@
|
|||
import { Router } from 'express';
|
||||
import { mailSendSimpleController } from '../controllers/mailSendSimpleController';
|
||||
import { authenticateToken } from '../middleware/authMiddleware';
|
||||
import { uploadMailAttachment } from '../config/multerConfig';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 모든 메일 발송 라우트에 인증 미들웨어 적용
|
||||
router.use(authenticateToken);
|
||||
|
||||
// POST /api/mail/send/simple - 메일 발송
|
||||
router.post('/simple', (req, res) => mailSendSimpleController.sendMail(req, res));
|
||||
// POST /api/mail/send/simple - 메일 발송 (첨부파일 지원)
|
||||
router.post(
|
||||
'/simple',
|
||||
uploadMailAttachment.array('attachments', 5), // 최대 5개 파일
|
||||
(req, res) => mailSendSimpleController.sendMail(req, res)
|
||||
);
|
||||
|
||||
// POST /api/mail/send/test-connection - SMTP 연결 테스트
|
||||
router.post('/test-connection', (req, res) => mailSendSimpleController.testConnection(req, res));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
import { Router } from 'express';
|
||||
import { mailSentHistoryController } from '../controllers/mailSentHistoryController';
|
||||
import { authenticateToken } from '../middleware/authMiddleware';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 모든 라우트에 인증 미들웨어 적용
|
||||
router.use(authenticateToken);
|
||||
|
||||
// GET /api/mail/sent - 발송 이력 목록 조회
|
||||
router.get('/', (req, res) => mailSentHistoryController.getList(req, res));
|
||||
|
||||
// GET /api/mail/sent/statistics - 통계 조회
|
||||
router.get('/statistics', (req, res) => mailSentHistoryController.getStatistics(req, res));
|
||||
|
||||
// GET /api/mail/sent/:id - 특정 발송 이력 상세 조회
|
||||
router.get('/:id', (req, res) => mailSentHistoryController.getById(req, res));
|
||||
|
||||
// DELETE /api/mail/sent/:id - 발송 이력 삭제
|
||||
router.delete('/:id', (req, res) => mailSentHistoryController.deleteById(req, res));
|
||||
|
||||
export default router;
|
||||
|
||||
|
|
@ -3,9 +3,11 @@
|
|||
* IMAP 연결 및 메일 목록 조회
|
||||
*/
|
||||
|
||||
import * as Imap from 'imap';
|
||||
// CommonJS 모듈이므로 require 사용
|
||||
const Imap = require('imap');
|
||||
import { simpleParser } from 'mailparser';
|
||||
import { mailAccountFileService } from './mailAccountFileService';
|
||||
import { encryptionService } from './encryptionService';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
|
|
@ -57,6 +59,20 @@ export class MailReceiveBasicService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SMTP 포트에서 IMAP 포트 추론
|
||||
*/
|
||||
private inferImapPort(smtpPort: number, imapPort?: number): number {
|
||||
if (imapPort) return imapPort;
|
||||
|
||||
if (smtpPort === 465 || smtpPort === 587) {
|
||||
return 993; // IMAPS (SSL/TLS)
|
||||
} else if (smtpPort === 25) {
|
||||
return 143; // IMAP (no encryption)
|
||||
}
|
||||
return 993; // 기본값: IMAPS
|
||||
}
|
||||
|
||||
/**
|
||||
* IMAP 연결 생성
|
||||
*/
|
||||
|
|
@ -80,27 +96,47 @@ export class MailReceiveBasicService {
|
|||
throw new Error('메일 계정을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 비밀번호 복호화
|
||||
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
||||
|
||||
// IMAP 설정
|
||||
const accountAny = account as any;
|
||||
const imapConfig: ImapConfig = {
|
||||
user: account.email,
|
||||
password: account.smtpPassword, // 이미 복호화됨
|
||||
host: account.smtpHost,
|
||||
port: account.smtpPort === 587 ? 993 : account.smtpPort, // SMTP 587 -> IMAP 993
|
||||
password: decryptedPassword,
|
||||
host: accountAny.imapHost || account.smtpHost,
|
||||
port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
|
||||
tls: true,
|
||||
};
|
||||
|
||||
// console.log(`📧 IMAP 연결 시도 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, 이메일: ${imapConfig.user}`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const imap = this.createImapConnection(imapConfig);
|
||||
const mails: ReceivedMail[] = [];
|
||||
|
||||
// 30초 타임아웃 설정
|
||||
const timeout = setTimeout(() => {
|
||||
// console.error('❌ IMAP 연결 타임아웃 (30초)');
|
||||
imap.end();
|
||||
reject(new Error('IMAP 연결 타임아웃'));
|
||||
}, 30000);
|
||||
|
||||
imap.once('ready', () => {
|
||||
// console.log('✅ IMAP 연결 성공! INBOX 열기 시도...');
|
||||
clearTimeout(timeout);
|
||||
|
||||
imap.openBox('INBOX', true, (err: any, box: any) => {
|
||||
if (err) {
|
||||
// console.error('❌ INBOX 열기 실패:', err);
|
||||
imap.end();
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
// console.log(`📬 INBOX 열림 - 전체 메일 수: ${box.messages.total}`);
|
||||
const totalMessages = box.messages.total;
|
||||
if (totalMessages === 0) {
|
||||
// console.log('📭 메일함이 비어있습니다');
|
||||
imap.end();
|
||||
return resolve([]);
|
||||
}
|
||||
|
|
@ -109,15 +145,23 @@ export class MailReceiveBasicService {
|
|||
const start = Math.max(1, totalMessages - limit + 1);
|
||||
const end = totalMessages;
|
||||
|
||||
// console.log(`📨 메일 가져오기 시작 - 범위: ${start}~${end}`);
|
||||
const fetch = imap.seq.fetch(`${start}:${end}`, {
|
||||
bodies: ['HEADER', 'TEXT'],
|
||||
struct: true,
|
||||
});
|
||||
|
||||
// console.log(`📦 fetch 객체 생성 완료`);
|
||||
|
||||
let processedCount = 0;
|
||||
const totalToProcess = end - start + 1;
|
||||
|
||||
fetch.on('message', (msg: any, seqno: any) => {
|
||||
// console.log(`📬 메일 #${seqno} 처리 시작`);
|
||||
let header: string = '';
|
||||
let body: string = '';
|
||||
let attributes: any = null;
|
||||
let bodiesReceived = 0;
|
||||
|
||||
msg.on('body', (stream: any, info: any) => {
|
||||
let buffer = '';
|
||||
|
|
@ -130,6 +174,7 @@ export class MailReceiveBasicService {
|
|||
} else {
|
||||
body = buffer;
|
||||
}
|
||||
bodiesReceived++;
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -137,50 +182,88 @@ export class MailReceiveBasicService {
|
|||
attributes = attrs;
|
||||
});
|
||||
|
||||
msg.once('end', async () => {
|
||||
try {
|
||||
const parsed = await simpleParser(header + '\r\n\r\n' + body);
|
||||
msg.once('end', () => {
|
||||
// body 데이터를 모두 받을 때까지 대기
|
||||
const waitForBodies = setInterval(async () => {
|
||||
if (bodiesReceived >= 2 || (header && body)) {
|
||||
clearInterval(waitForBodies);
|
||||
|
||||
try {
|
||||
const parsed = await simpleParser(header + '\r\n\r\n' + body);
|
||||
|
||||
const fromAddress = Array.isArray(parsed.from) ? parsed.from[0] : parsed.from;
|
||||
const toAddress = Array.isArray(parsed.to) ? parsed.to[0] : parsed.to;
|
||||
const fromAddress = Array.isArray(parsed.from) ? parsed.from[0] : parsed.from;
|
||||
const toAddress = Array.isArray(parsed.to) ? parsed.to[0] : parsed.to;
|
||||
|
||||
const mail: ReceivedMail = {
|
||||
id: `${accountId}-${seqno}`,
|
||||
messageId: parsed.messageId || `${seqno}`,
|
||||
from: fromAddress?.text || 'Unknown',
|
||||
to: toAddress?.text || '',
|
||||
subject: parsed.subject || '(제목 없음)',
|
||||
date: parsed.date || new Date(),
|
||||
preview: this.extractPreview(parsed.text || parsed.html || ''),
|
||||
isRead: attributes?.flags?.includes('\\Seen') || false,
|
||||
hasAttachments: (parsed.attachments?.length || 0) > 0,
|
||||
};
|
||||
const mail: ReceivedMail = {
|
||||
id: `${accountId}-${seqno}`,
|
||||
messageId: parsed.messageId || `${seqno}`,
|
||||
from: fromAddress?.text || 'Unknown',
|
||||
to: toAddress?.text || '',
|
||||
subject: parsed.subject || '(제목 없음)',
|
||||
date: parsed.date || new Date(),
|
||||
preview: this.extractPreview(parsed.text || parsed.html || ''),
|
||||
isRead: attributes?.flags?.includes('\\Seen') || false,
|
||||
hasAttachments: (parsed.attachments?.length || 0) > 0,
|
||||
};
|
||||
|
||||
mails.push(mail);
|
||||
} catch (parseError) {
|
||||
console.error('메일 파싱 오류:', parseError);
|
||||
}
|
||||
mails.push(mail);
|
||||
// console.log(`✓ 메일 #${seqno} 파싱 완료 (${mails.length}/${totalToProcess})`);
|
||||
processedCount++;
|
||||
} catch (parseError) {
|
||||
// console.error(`메일 #${seqno} 파싱 오류:`, parseError);
|
||||
processedCount++;
|
||||
}
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
});
|
||||
|
||||
fetch.once('error', (fetchErr: any) => {
|
||||
// console.error('❌ 메일 fetch 에러:', fetchErr);
|
||||
imap.end();
|
||||
reject(fetchErr);
|
||||
});
|
||||
|
||||
fetch.once('end', () => {
|
||||
imap.end();
|
||||
// 최신 메일이 위로 오도록 정렬
|
||||
mails.sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||
resolve(mails);
|
||||
// console.log(`📭 fetch 종료 - 처리 완료 대기 중... (현재: ${mails.length}개)`);
|
||||
|
||||
// 모든 메일 처리가 완료될 때까지 대기
|
||||
const checkComplete = setInterval(() => {
|
||||
// console.log(`⏳ 대기 중 - 처리됨: ${processedCount}/${totalToProcess}, 메일: ${mails.length}개`);
|
||||
if (processedCount >= totalToProcess) {
|
||||
clearInterval(checkComplete);
|
||||
// console.log(`✅ 메일 가져오기 완료 - 총 ${mails.length}개`);
|
||||
imap.end();
|
||||
// 최신 메일이 위로 오도록 정렬
|
||||
mails.sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||
// console.log(`📤 메일 목록 반환: ${mails.length}개`);
|
||||
resolve(mails);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// 최대 10초 대기
|
||||
setTimeout(() => {
|
||||
clearInterval(checkComplete);
|
||||
// console.log(`⚠️ 타임아웃 - 부분 반환: ${mails.length}/${totalToProcess}개`);
|
||||
imap.end();
|
||||
mails.sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||
resolve(mails);
|
||||
}, 10000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
imap.once('error', (imapErr: any) => {
|
||||
// console.error('❌ IMAP 연결 에러:', imapErr.message || imapErr);
|
||||
clearTimeout(timeout);
|
||||
reject(imapErr);
|
||||
});
|
||||
|
||||
imap.once('end', () => {
|
||||
// console.log('🔌 IMAP 연결 종료');
|
||||
});
|
||||
|
||||
// console.log('🔗 IMAP.connect() 호출...');
|
||||
imap.connect();
|
||||
});
|
||||
}
|
||||
|
|
@ -206,11 +289,15 @@ export class MailReceiveBasicService {
|
|||
throw new Error('메일 계정을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 비밀번호 복호화
|
||||
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
||||
|
||||
const accountAny = account as any;
|
||||
const imapConfig: ImapConfig = {
|
||||
user: account.email,
|
||||
password: account.smtpPassword,
|
||||
host: account.smtpHost,
|
||||
port: account.smtpPort === 587 ? 993 : account.smtpPort,
|
||||
password: decryptedPassword,
|
||||
host: accountAny.imapHost || account.smtpHost,
|
||||
port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
|
||||
tls: true,
|
||||
};
|
||||
|
||||
|
|
@ -224,22 +311,36 @@ export class MailReceiveBasicService {
|
|||
return reject(err);
|
||||
}
|
||||
|
||||
console.log(`📬 INBOX 정보 - 전체 메일: ${box.messages.total}, 요청한 seqno: ${seqno}`);
|
||||
|
||||
if (seqno > box.messages.total || seqno < 1) {
|
||||
console.error(`❌ 유효하지 않은 seqno: ${seqno} (메일 총 개수: ${box.messages.total})`);
|
||||
imap.end();
|
||||
return resolve(null);
|
||||
}
|
||||
|
||||
const fetch = imap.seq.fetch(`${seqno}:${seqno}`, {
|
||||
bodies: '',
|
||||
struct: true,
|
||||
});
|
||||
|
||||
let mailDetail: MailDetail | null = null;
|
||||
let parsingComplete = false;
|
||||
|
||||
fetch.on('message', (msg: any, seqnum: any) => {
|
||||
console.log(`📨 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`);
|
||||
|
||||
msg.on('body', (stream: any, info: any) => {
|
||||
console.log(`📝 메일 본문 스트림 시작 - which: ${info.which}`);
|
||||
let buffer = '';
|
||||
stream.on('data', (chunk: any) => {
|
||||
buffer += chunk.toString('utf8');
|
||||
});
|
||||
stream.once('end', async () => {
|
||||
console.log(`✅ 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}`);
|
||||
try {
|
||||
const parsed = await simpleParser(buffer);
|
||||
console.log(`✅ 메일 파싱 완료 - 제목: ${parsed.subject}`);
|
||||
|
||||
const fromAddress = Array.isArray(parsed.from) ? parsed.from[0] : parsed.from;
|
||||
const toAddress = Array.isArray(parsed.to) ? parsed.to[0] : parsed.to;
|
||||
|
|
@ -266,21 +367,48 @@ export class MailReceiveBasicService {
|
|||
size: att.size || 0,
|
||||
})),
|
||||
};
|
||||
parsingComplete = true;
|
||||
} catch (parseError) {
|
||||
console.error('메일 파싱 오류:', parseError);
|
||||
parsingComplete = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// msg 전체가 처리되었을 때 이벤트
|
||||
msg.once('end', () => {
|
||||
console.log(`📮 메일 메시지 처리 완료 - seqnum: ${seqnum}`);
|
||||
});
|
||||
});
|
||||
|
||||
fetch.once('error', (fetchErr: any) => {
|
||||
console.error(`❌ Fetch 에러:`, fetchErr);
|
||||
imap.end();
|
||||
reject(fetchErr);
|
||||
});
|
||||
|
||||
fetch.once('end', () => {
|
||||
imap.end();
|
||||
resolve(mailDetail);
|
||||
console.log(`🏁 Fetch 종료 - parsingComplete: ${parsingComplete}`);
|
||||
|
||||
// 비동기 파싱이 완료될 때까지 대기
|
||||
const waitForParsing = setInterval(() => {
|
||||
if (parsingComplete) {
|
||||
clearInterval(waitForParsing);
|
||||
console.log(`✅ 파싱 완료 대기 종료 - mailDetail이 ${mailDetail ? '존재함' : 'null'}`);
|
||||
imap.end();
|
||||
resolve(mailDetail);
|
||||
}
|
||||
}, 10); // 10ms마다 체크
|
||||
|
||||
// 타임아웃 설정 (10초)
|
||||
setTimeout(() => {
|
||||
if (!parsingComplete) {
|
||||
clearInterval(waitForParsing);
|
||||
console.error('❌ 파싱 타임아웃');
|
||||
imap.end();
|
||||
resolve(mailDetail); // 타임아웃 시에도 현재 상태 반환
|
||||
}
|
||||
}, 10000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -302,11 +430,15 @@ export class MailReceiveBasicService {
|
|||
throw new Error('메일 계정을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 비밀번호 복호화
|
||||
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
||||
|
||||
const accountAny = account as any;
|
||||
const imapConfig: ImapConfig = {
|
||||
user: account.email,
|
||||
password: account.smtpPassword,
|
||||
host: account.smtpHost,
|
||||
port: account.smtpPort === 587 ? 993 : account.smtpPort,
|
||||
password: decryptedPassword,
|
||||
host: accountAny.imapHost || account.smtpHost,
|
||||
port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
|
||||
tls: true,
|
||||
};
|
||||
|
||||
|
|
@ -352,13 +484,19 @@ export class MailReceiveBasicService {
|
|||
throw new Error('메일 계정을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 비밀번호 복호화
|
||||
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
||||
// console.log(`🔐 IMAP 테스트 - 이메일: ${account.email}, 비밀번호 길이: ${decryptedPassword.length}`);
|
||||
|
||||
const accountAny = account as any;
|
||||
const imapConfig: ImapConfig = {
|
||||
user: account.email,
|
||||
password: account.smtpPassword,
|
||||
host: account.smtpHost,
|
||||
port: account.smtpPort === 587 ? 993 : account.smtpPort,
|
||||
password: decryptedPassword,
|
||||
host: accountAny.imapHost || account.smtpHost,
|
||||
port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
|
||||
tls: true,
|
||||
};
|
||||
// console.log(`📧 IMAP 설정 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, TLS: ${imapConfig.tls}`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const imap = this.createImapConnection(imapConfig);
|
||||
|
|
@ -395,6 +533,43 @@ export class MailReceiveBasicService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 오늘 수신한 메일 수 조회 (통계용)
|
||||
*/
|
||||
async getTodayReceivedCount(accountId?: string): Promise<number> {
|
||||
try {
|
||||
const accounts = accountId
|
||||
? [await mailAccountFileService.getAccountById(accountId)]
|
||||
: await mailAccountFileService.getAllAccounts();
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
let totalCount = 0;
|
||||
|
||||
for (const account of accounts) {
|
||||
if (!account) continue;
|
||||
|
||||
try {
|
||||
const mails = await this.fetchMailList(account.id, 100);
|
||||
const todayMails = mails.filter(mail => {
|
||||
const mailDate = new Date(mail.date);
|
||||
return mailDate >= today;
|
||||
});
|
||||
totalCount += todayMails.length;
|
||||
} catch (error) {
|
||||
// 개별 계정 오류는 무시하고 계속 진행
|
||||
console.error(`계정 ${account.id} 메일 조회 실패:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return totalCount;
|
||||
} catch (error) {
|
||||
console.error('오늘 수신 메일 수 조회 실패:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부파일 다운로드
|
||||
*/
|
||||
|
|
@ -408,11 +583,15 @@ export class MailReceiveBasicService {
|
|||
throw new Error('메일 계정을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 비밀번호 복호화
|
||||
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
||||
|
||||
const accountAny = account as any;
|
||||
const imapConfig: ImapConfig = {
|
||||
user: account.email,
|
||||
password: account.smtpPassword,
|
||||
host: account.smtpHost,
|
||||
port: account.smtpPort === 587 ? 993 : account.smtpPort,
|
||||
password: decryptedPassword,
|
||||
host: accountAny.imapHost || account.smtpHost,
|
||||
port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
|
||||
tls: true,
|
||||
};
|
||||
|
||||
|
|
@ -432,19 +611,26 @@ export class MailReceiveBasicService {
|
|||
});
|
||||
|
||||
let attachmentResult: { filePath: string; filename: string; contentType: string } | null = null;
|
||||
let parsingComplete = false;
|
||||
|
||||
fetch.on('message', (msg: any, seqnum: any) => {
|
||||
console.log(`📎 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`);
|
||||
|
||||
msg.on('body', (stream: any, info: any) => {
|
||||
console.log(`📎 메일 본문 스트림 시작`);
|
||||
let buffer = '';
|
||||
stream.on('data', (chunk: any) => {
|
||||
buffer += chunk.toString('utf8');
|
||||
});
|
||||
stream.once('end', async () => {
|
||||
console.log(`📎 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}`);
|
||||
try {
|
||||
const parsed = await simpleParser(buffer);
|
||||
console.log(`📎 파싱 완료 - 첨부파일 개수: ${parsed.attachments?.length || 0}`);
|
||||
|
||||
if (parsed.attachments && parsed.attachments[attachmentIndex]) {
|
||||
const attachment = parsed.attachments[attachmentIndex];
|
||||
console.log(`📎 첨부파일 발견 (index ${attachmentIndex}): ${attachment.filename}`);
|
||||
|
||||
// 안전한 파일명 생성
|
||||
const safeFilename = this.sanitizeFilename(
|
||||
|
|
@ -456,28 +642,51 @@ export class MailReceiveBasicService {
|
|||
|
||||
// 파일 저장
|
||||
await fs.writeFile(filePath, attachment.content);
|
||||
console.log(`📎 파일 저장 완료: ${filePath}`);
|
||||
|
||||
attachmentResult = {
|
||||
filePath,
|
||||
filename: attachment.filename || 'unnamed',
|
||||
contentType: attachment.contentType || 'application/octet-stream',
|
||||
};
|
||||
parsingComplete = true;
|
||||
} else {
|
||||
console.log(`❌ 첨부파일 index ${attachmentIndex}를 찾을 수 없음 (총 ${parsed.attachments?.length || 0}개)`);
|
||||
parsingComplete = true;
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error('첨부파일 파싱 오류:', parseError);
|
||||
parsingComplete = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
fetch.once('error', (fetchErr: any) => {
|
||||
console.error('❌ fetch 오류:', fetchErr);
|
||||
imap.end();
|
||||
reject(fetchErr);
|
||||
});
|
||||
|
||||
fetch.once('end', () => {
|
||||
imap.end();
|
||||
resolve(attachmentResult);
|
||||
console.log('📎 fetch.once("end") 호출됨 - 파싱 완료 대기 시작...');
|
||||
|
||||
// 파싱 완료를 기다림 (최대 5초)
|
||||
const checkComplete = setInterval(() => {
|
||||
if (parsingComplete) {
|
||||
console.log(`✅ 파싱 완료 확인 - attachmentResult: ${attachmentResult ? '있음' : '없음'}`);
|
||||
clearInterval(checkComplete);
|
||||
imap.end();
|
||||
resolve(attachmentResult);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
setTimeout(() => {
|
||||
clearInterval(checkComplete);
|
||||
console.log(`⚠️ 타임아웃 - attachmentResult: ${attachmentResult ? '있음' : '없음'}`);
|
||||
imap.end();
|
||||
resolve(attachmentResult);
|
||||
}, 5000);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,14 +7,23 @@ import nodemailer from 'nodemailer';
|
|||
import { mailAccountFileService } from './mailAccountFileService';
|
||||
import { mailTemplateFileService } from './mailTemplateFileService';
|
||||
import { encryptionService } from './encryptionService';
|
||||
import { mailSentHistoryService } from './mailSentHistoryService';
|
||||
|
||||
export interface SendMailRequest {
|
||||
accountId: string;
|
||||
templateId?: string;
|
||||
to: string[]; // 수신자 이메일 배열
|
||||
modifiedTemplateComponents?: any[]; // 🎯 프론트엔드에서 수정된 템플릿 컴포넌트
|
||||
to: string[]; // 받는 사람
|
||||
cc?: string[]; // 참조 (Carbon Copy)
|
||||
bcc?: string[]; // 숨은참조 (Blind Carbon Copy)
|
||||
subject: string;
|
||||
variables?: Record<string, string>; // 템플릿 변수 치환
|
||||
customHtml?: string; // 템플릿 없이 직접 HTML 작성 시
|
||||
attachments?: Array<{ // 첨부파일
|
||||
filename: string;
|
||||
path: string;
|
||||
contentType?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface SendMailResult {
|
||||
|
|
@ -30,6 +39,8 @@ class MailSendSimpleService {
|
|||
* 단일 메일 발송 또는 소규모 발송
|
||||
*/
|
||||
async sendMail(request: SendMailRequest): Promise<SendMailResult> {
|
||||
let htmlContent = ''; // 상위 스코프로 이동
|
||||
|
||||
try {
|
||||
// 1. 계정 조회
|
||||
const account = await mailAccountFileService.getAccountById(request.accountId);
|
||||
|
|
@ -42,15 +53,29 @@ class MailSendSimpleService {
|
|||
throw new Error('비활성 상태의 계정입니다.');
|
||||
}
|
||||
|
||||
// 3. HTML 생성 (템플릿 또는 커스텀)
|
||||
let htmlContent = request.customHtml || '';
|
||||
|
||||
if (!htmlContent && request.templateId) {
|
||||
// 3. HTML 생성 (템플릿 + 추가 메시지 병합)
|
||||
if (request.templateId) {
|
||||
// 템플릿 사용
|
||||
const template = await mailTemplateFileService.getTemplateById(request.templateId);
|
||||
if (!template) {
|
||||
throw new Error('템플릿을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 🎯 수정된 컴포넌트가 있으면 덮어쓰기
|
||||
if (request.modifiedTemplateComponents && request.modifiedTemplateComponents.length > 0) {
|
||||
console.log('✏️ 수정된 템플릿 컴포넌트 사용:', request.modifiedTemplateComponents.length);
|
||||
template.components = request.modifiedTemplateComponents;
|
||||
}
|
||||
|
||||
htmlContent = this.renderTemplate(template, request.variables);
|
||||
|
||||
// 템플릿 + 추가 메시지 병합
|
||||
if (request.customHtml && request.customHtml.trim()) {
|
||||
htmlContent = this.mergeTemplateAndCustomContent(htmlContent, request.customHtml);
|
||||
}
|
||||
} else {
|
||||
// 직접 작성
|
||||
htmlContent = request.customHtml || '';
|
||||
}
|
||||
|
||||
if (!htmlContent) {
|
||||
|
|
@ -59,20 +84,20 @@ class MailSendSimpleService {
|
|||
|
||||
// 4. 비밀번호 복호화
|
||||
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
||||
console.log('🔐 비밀번호 복호화 완료');
|
||||
console.log('🔐 암호화된 비밀번호 (일부):', account.smtpPassword.substring(0, 30) + '...');
|
||||
console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length);
|
||||
// console.log('🔐 비밀번호 복호화 완료');
|
||||
// console.log('🔐 암호화된 비밀번호 (일부):', account.smtpPassword.substring(0, 30) + '...');
|
||||
// console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length);
|
||||
|
||||
// 5. SMTP 연결 생성
|
||||
// 포트 465는 SSL/TLS를 사용해야 함
|
||||
const isSecure = account.smtpPort === 465 ? true : (account.smtpSecure || false);
|
||||
|
||||
console.log('📧 SMTP 연결 설정:', {
|
||||
host: account.smtpHost,
|
||||
port: account.smtpPort,
|
||||
secure: isSecure,
|
||||
user: account.smtpUsername,
|
||||
});
|
||||
// console.log('📧 SMTP 연결 설정:', {
|
||||
// host: account.smtpHost,
|
||||
// port: account.smtpPort,
|
||||
// secure: isSecure,
|
||||
// user: account.smtpUsername,
|
||||
// });
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: account.smtpHost,
|
||||
|
|
@ -89,13 +114,60 @@ class MailSendSimpleService {
|
|||
|
||||
console.log('📧 메일 발송 시도 중...');
|
||||
|
||||
// 6. 메일 발송
|
||||
const info = await transporter.sendMail({
|
||||
// 6. 메일 발송 (CC, BCC, 첨부파일 지원)
|
||||
const mailOptions: any = {
|
||||
from: `"${account.name}" <${account.email}>`,
|
||||
to: request.to.join(', '),
|
||||
subject: this.replaceVariables(request.subject, request.variables),
|
||||
html: htmlContent,
|
||||
});
|
||||
};
|
||||
|
||||
// 참조(CC) 추가
|
||||
if (request.cc && request.cc.length > 0) {
|
||||
mailOptions.cc = request.cc.join(', ');
|
||||
// console.log('📧 참조(CC):', request.cc);
|
||||
}
|
||||
|
||||
// 숨은참조(BCC) 추가
|
||||
if (request.bcc && request.bcc.length > 0) {
|
||||
mailOptions.bcc = request.bcc.join(', ');
|
||||
// console.log('🔒 숨은참조(BCC):', request.bcc);
|
||||
}
|
||||
|
||||
// 첨부파일 추가 (한글 파일명 인코딩 처리)
|
||||
if (request.attachments && request.attachments.length > 0) {
|
||||
mailOptions.attachments = request.attachments.map(att => {
|
||||
// 파일명에서 타임스탬프_랜덤숫자_ 부분 제거하여 원본 파일명 복원
|
||||
let filename = att.filename.replace(/^\d+-\d+_/, '');
|
||||
|
||||
// NFC 정규화 (한글 조합 문자 정규화)
|
||||
filename = filename.normalize('NFC');
|
||||
|
||||
// ISO-8859-1 호환을 위한 안전한 파일명 생성
|
||||
// 한글이 포함된 경우 RFC 2047 MIME 인코딩 사용
|
||||
const hasKorean = /[\uAC00-\uD7AF]/.test(filename);
|
||||
let safeFilename = filename;
|
||||
|
||||
if (hasKorean) {
|
||||
// 한글이 포함된 경우: RFC 2047 MIME 인코딩 사용
|
||||
safeFilename = `=?UTF-8?B?${Buffer.from(filename, 'utf8').toString('base64')}?=`;
|
||||
}
|
||||
|
||||
return {
|
||||
filename: safeFilename,
|
||||
path: att.path,
|
||||
contentType: att.contentType,
|
||||
// 다중 호환성을 위한 헤더 설정
|
||||
headers: {
|
||||
'Content-Disposition': `attachment; filename="${safeFilename}"; filename*=UTF-8''${encodeURIComponent(filename)}`
|
||||
}
|
||||
};
|
||||
});
|
||||
console.log('📎 첨부파일 (원본):', request.attachments.map((a: any) => a.filename.replace(/^\d+-\d+_/, '')));
|
||||
console.log('📎 첨부파일 (인코딩):', mailOptions.attachments.map((a: any) => a.filename));
|
||||
}
|
||||
|
||||
const info = await transporter.sendMail(mailOptions);
|
||||
|
||||
console.log('✅ 메일 발송 성공:', {
|
||||
messageId: info.messageId,
|
||||
|
|
@ -103,6 +175,43 @@ class MailSendSimpleService {
|
|||
rejected: info.rejected,
|
||||
});
|
||||
|
||||
// 발송 이력 저장 (성공)
|
||||
try {
|
||||
const template = request.templateId
|
||||
? await mailTemplateFileService.getTemplateById(request.templateId)
|
||||
: undefined;
|
||||
|
||||
// AttachmentInfo 형식으로 변환
|
||||
const attachmentInfos = request.attachments?.map(att => ({
|
||||
filename: att.filename,
|
||||
originalName: att.filename,
|
||||
size: 0, // multer에서 제공하지 않으므로 0으로 설정
|
||||
path: att.path,
|
||||
mimetype: att.contentType || 'application/octet-stream',
|
||||
}));
|
||||
|
||||
await mailSentHistoryService.saveSentMail({
|
||||
accountId: account.id,
|
||||
accountName: account.name,
|
||||
accountEmail: account.email,
|
||||
to: request.to,
|
||||
cc: request.cc,
|
||||
bcc: request.bcc,
|
||||
subject: this.replaceVariables(request.subject, request.variables),
|
||||
htmlContent,
|
||||
templateId: request.templateId,
|
||||
templateName: template?.name,
|
||||
attachments: attachmentInfos,
|
||||
status: 'success',
|
||||
messageId: info.messageId,
|
||||
accepted: info.accepted as string[],
|
||||
rejected: info.rejected as string[],
|
||||
});
|
||||
} catch (historyError) {
|
||||
console.error('발송 이력 저장 실패:', historyError);
|
||||
// 이력 저장 실패는 메일 발송 성공에 영향 주지 않음
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: info.messageId,
|
||||
|
|
@ -113,6 +222,52 @@ class MailSendSimpleService {
|
|||
const err = error as Error;
|
||||
console.error('❌ 메일 발송 실패:', err.message);
|
||||
console.error('❌ 에러 상세:', err);
|
||||
|
||||
// 발송 이력 저장 (실패)
|
||||
try {
|
||||
// 계정 정보 가져오기 (실패 시에도 필요)
|
||||
let accountInfo = { name: 'Unknown', email: 'unknown@example.com' };
|
||||
try {
|
||||
const acc = await mailAccountFileService.getAccountById(request.accountId);
|
||||
if (acc) {
|
||||
accountInfo = { name: acc.name, email: acc.email };
|
||||
}
|
||||
} catch (accError) {
|
||||
// 계정 조회 실패는 무시
|
||||
}
|
||||
|
||||
const template = request.templateId
|
||||
? await mailTemplateFileService.getTemplateById(request.templateId)
|
||||
: undefined;
|
||||
|
||||
// AttachmentInfo 형식으로 변환
|
||||
const attachmentInfos = request.attachments?.map(att => ({
|
||||
filename: att.filename,
|
||||
originalName: att.filename,
|
||||
size: 0,
|
||||
path: att.path,
|
||||
mimetype: att.contentType || 'application/octet-stream',
|
||||
}));
|
||||
|
||||
await mailSentHistoryService.saveSentMail({
|
||||
accountId: request.accountId,
|
||||
accountName: accountInfo.name,
|
||||
accountEmail: accountInfo.email,
|
||||
to: request.to,
|
||||
cc: request.cc,
|
||||
bcc: request.bcc,
|
||||
subject: request.subject,
|
||||
htmlContent: htmlContent || '',
|
||||
templateId: request.templateId,
|
||||
templateName: template?.name,
|
||||
attachments: attachmentInfos,
|
||||
status: 'failed',
|
||||
errorMessage: err.message,
|
||||
});
|
||||
} catch (historyError) {
|
||||
console.error('발송 이력 저장 실패:', historyError);
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: err.message,
|
||||
|
|
@ -121,13 +276,25 @@ class MailSendSimpleService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 템플릿 렌더링 (간단 버전)
|
||||
* 템플릿 렌더링 (일반 메일 양식)
|
||||
*/
|
||||
private renderTemplate(
|
||||
template: any,
|
||||
variables?: Record<string, string>
|
||||
): string {
|
||||
let html = '<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">';
|
||||
// 일반적인 메일 레이아웃 (전체 너비, 그림자 없음)
|
||||
let html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;">
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; background-color: #ffffff;">
|
||||
<tr>
|
||||
<td style="padding: 20px;">
|
||||
`;
|
||||
|
||||
template.components.forEach((component: any) => {
|
||||
switch (component.type) {
|
||||
|
|
@ -136,48 +303,51 @@ class MailSendSimpleService {
|
|||
if (variables) {
|
||||
content = this.replaceVariables(content, variables);
|
||||
}
|
||||
html += `<div style="${this.styleObjectToString(component.styles)}">${content}</div>`;
|
||||
// 텍스트는 왼쪽 정렬, 적절한 줄간격
|
||||
html += `<div style="margin: 0 0 20px 0; color: ${component.color || '#333'}; font-size: ${component.fontSize || '15px'}; line-height: 1.6; text-align: left;">${content}</div>`;
|
||||
break;
|
||||
|
||||
case 'button':
|
||||
let buttonText = component.text || 'Button';
|
||||
if (variables) {
|
||||
buttonText = this.replaceVariables(buttonText, variables);
|
||||
}
|
||||
html += `
|
||||
<a href="${component.url || '#'}" style="
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
background-color: ${component.styles?.backgroundColor || '#007bff'};
|
||||
color: ${component.styles?.color || 'white'};
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
${this.styleObjectToString(component.styles)}
|
||||
">${buttonText}</a>
|
||||
`;
|
||||
// 버튼은 왼쪽 정렬 (text-align 제거)
|
||||
html += `<div style="margin: 30px 0; text-align: left;">
|
||||
<a href="${component.url || '#'}" style="display: inline-block; padding: 14px 28px; background-color: ${component.backgroundColor || '#007bff'}; color: ${component.textColor || '#fff'}; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 15px;">${buttonText}</a>
|
||||
</div>`;
|
||||
break;
|
||||
|
||||
case 'image':
|
||||
html += `<img src="${component.src || ''}" style="max-width: 100%; ${this.styleObjectToString(component.styles)}" />`;
|
||||
// 이미지는 왼쪽 정렬
|
||||
html += `<div style="margin: 20px 0; text-align: left;">
|
||||
<img src="${component.src}" alt="${component.alt || ''}" style="max-width: 100%; height: auto; display: block; border-radius: 4px;" />
|
||||
</div>`;
|
||||
break;
|
||||
|
||||
case 'spacer':
|
||||
html += `<div style="height: ${component.height || 20}px;"></div>`;
|
||||
html += `<div style="height: ${component.height || '20px'};"></div>`;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
html += `
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* 변수 치환
|
||||
*/
|
||||
private replaceVariables(text: string, variables?: Record<string, string>): string {
|
||||
if (!variables) return text;
|
||||
private replaceVariables(
|
||||
content: string,
|
||||
variables?: Record<string, string>
|
||||
): string {
|
||||
if (!variables) return content;
|
||||
|
||||
let result = text;
|
||||
let result = content;
|
||||
Object.entries(variables).forEach(([key, value]) => {
|
||||
const regex = new RegExp(`\\{${key}\\}`, 'g');
|
||||
result = result.replace(regex, value);
|
||||
|
|
@ -187,20 +357,49 @@ class MailSendSimpleService {
|
|||
}
|
||||
|
||||
/**
|
||||
* 스타일 객체를 CSS 문자열로 변환
|
||||
* 템플릿과 추가 메시지 병합
|
||||
* 템플릿 HTML의 body 태그 끝 부분에 추가 메시지를 삽입
|
||||
*/
|
||||
private styleObjectToString(styles?: Record<string, string>): string {
|
||||
if (!styles) return '';
|
||||
return Object.entries(styles)
|
||||
.map(([key, value]) => `${this.camelToKebab(key)}: ${value}`)
|
||||
.join('; ');
|
||||
}
|
||||
private mergeTemplateAndCustomContent(templateHtml: string, customContent: string): string {
|
||||
// customContent에 HTML 태그가 없으면 기본 스타일 적용
|
||||
let formattedCustomContent = customContent;
|
||||
if (!customContent.includes('<')) {
|
||||
// 일반 텍스트인 경우 단락으로 변환
|
||||
const paragraphs = customContent
|
||||
.split('\n\n')
|
||||
.filter((p) => p.trim())
|
||||
.map((p) => `<p style="margin: 16px 0; line-height: 1.6;">${p.replace(/\n/g, '<br>')}</p>`)
|
||||
.join('');
|
||||
|
||||
formattedCustomContent = `
|
||||
<div style="margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb;">
|
||||
${paragraphs}
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
// 이미 HTML인 경우 구분선만 추가
|
||||
formattedCustomContent = `
|
||||
<div style="margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb;">
|
||||
${customContent}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* camelCase를 kebab-case로 변환
|
||||
*/
|
||||
private camelToKebab(str: string): string {
|
||||
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
||||
// </body> 또는 </div> 태그 앞에 삽입
|
||||
if (templateHtml.includes('</body>')) {
|
||||
return templateHtml.replace('</body>', `${formattedCustomContent}</body>`);
|
||||
} else if (templateHtml.includes('</div>')) {
|
||||
// 마지막 </div> 앞에 삽입
|
||||
const lastDivIndex = templateHtml.lastIndexOf('</div>');
|
||||
return (
|
||||
templateHtml.substring(0, lastDivIndex) +
|
||||
formattedCustomContent +
|
||||
templateHtml.substring(lastDivIndex)
|
||||
);
|
||||
} else {
|
||||
// 태그가 없으면 단순 결합
|
||||
return templateHtml + formattedCustomContent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -208,26 +407,25 @@ class MailSendSimpleService {
|
|||
*/
|
||||
async testConnection(accountId: string): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
console.log('🔌 SMTP 연결 테스트 시작:', accountId);
|
||||
|
||||
const account = await mailAccountFileService.getAccountById(accountId);
|
||||
if (!account) {
|
||||
throw new Error('계정을 찾을 수 없습니다.');
|
||||
return { success: false, message: '메일 계정을 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
// 비밀번호 복호화
|
||||
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
||||
console.log('🔐 비밀번호 복호화 완료');
|
||||
// console.log('🔐 테스트용 비밀번호 복호화 완료');
|
||||
// console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length);
|
||||
|
||||
// 포트 465는 SSL/TLS를 사용해야 함
|
||||
const isSecure = account.smtpPort === 465 ? true : (account.smtpSecure || false);
|
||||
|
||||
console.log('🔌 SMTP 연결 설정:', {
|
||||
host: account.smtpHost,
|
||||
port: account.smtpPort,
|
||||
secure: isSecure,
|
||||
user: account.smtpUsername,
|
||||
});
|
||||
// console.log('🧪 SMTP 연결 테스트 시작:', {
|
||||
// host: account.smtpHost,
|
||||
// port: account.smtpPort,
|
||||
// secure: isSecure,
|
||||
// user: account.smtpUsername,
|
||||
// });
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: account.smtpHost,
|
||||
|
|
@ -237,28 +435,22 @@ class MailSendSimpleService {
|
|||
user: account.smtpUsername,
|
||||
pass: decryptedPassword, // 복호화된 비밀번호 사용
|
||||
},
|
||||
connectionTimeout: 10000, // 10초 타임아웃
|
||||
// 테스트용 타임아웃 (10초)
|
||||
connectionTimeout: 10000,
|
||||
greetingTimeout: 10000,
|
||||
});
|
||||
|
||||
console.log('🔌 SMTP 연결 검증 중...');
|
||||
// 연결 테스트
|
||||
await transporter.verify();
|
||||
console.log('✅ SMTP 연결 검증 성공!');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'SMTP 연결 성공!',
|
||||
};
|
||||
|
||||
console.log('✅ SMTP 연결 테스트 성공');
|
||||
return { success: true, message: 'SMTP 연결이 성공했습니다.' };
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
console.error('❌ SMTP 연결 실패:', err.message);
|
||||
return {
|
||||
success: false,
|
||||
message: `연결 실패: ${err.message}`,
|
||||
};
|
||||
console.error('❌ SMTP 연결 테스트 실패:', err.message);
|
||||
return { success: false, message: `SMTP 연결 실패: ${err.message}` };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const mailSendSimpleService = new MailSendSimpleService();
|
||||
|
||||
export const mailSendSimpleService = new MailSendSimpleService();
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
/**
|
||||
* 메일 발송 이력 관리 서비스 (파일 기반)
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import {
|
||||
SentMailHistory,
|
||||
SentMailListQuery,
|
||||
SentMailListResponse,
|
||||
AttachmentInfo,
|
||||
} from '../types/mailSentHistory';
|
||||
|
||||
const SENT_MAIL_DIR = path.join(__dirname, '../../data/mail-sent');
|
||||
|
||||
class MailSentHistoryService {
|
||||
constructor() {
|
||||
// 디렉토리 생성 (없으면)
|
||||
if (!fs.existsSync(SENT_MAIL_DIR)) {
|
||||
fs.mkdirSync(SENT_MAIL_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 발송 이력 저장
|
||||
*/
|
||||
async saveSentMail(data: Omit<SentMailHistory, 'id' | 'sentAt'>): Promise<SentMailHistory> {
|
||||
const history: SentMailHistory = {
|
||||
id: uuidv4(),
|
||||
sentAt: new Date().toISOString(),
|
||||
...data,
|
||||
};
|
||||
|
||||
const filePath = path.join(SENT_MAIL_DIR, `${history.id}.json`);
|
||||
fs.writeFileSync(filePath, JSON.stringify(history, null, 2), 'utf-8');
|
||||
|
||||
console.log('💾 발송 이력 저장:', history.id);
|
||||
return history;
|
||||
}
|
||||
|
||||
/**
|
||||
* 발송 이력 목록 조회 (필터링, 페이징)
|
||||
*/
|
||||
async getSentMailList(query: SentMailListQuery): Promise<SentMailListResponse> {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 20,
|
||||
searchTerm = '',
|
||||
status = 'all',
|
||||
accountId,
|
||||
startDate,
|
||||
endDate,
|
||||
sortBy = 'sentAt',
|
||||
sortOrder = 'desc',
|
||||
} = query;
|
||||
|
||||
// 모든 발송 이력 파일 읽기
|
||||
const files = fs.readdirSync(SENT_MAIL_DIR).filter((f) => f.endsWith('.json'));
|
||||
let allHistory: SentMailHistory[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = path.join(SENT_MAIL_DIR, file);
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const history: SentMailHistory = JSON.parse(content);
|
||||
allHistory.push(history);
|
||||
} catch (error) {
|
||||
console.error(`발송 이력 파일 읽기 실패: ${file}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// 필터링
|
||||
let filtered = allHistory;
|
||||
|
||||
// 상태 필터
|
||||
if (status !== 'all') {
|
||||
filtered = filtered.filter((h) => h.status === status);
|
||||
}
|
||||
|
||||
// 계정 필터
|
||||
if (accountId) {
|
||||
filtered = filtered.filter((h) => h.accountId === accountId);
|
||||
}
|
||||
|
||||
// 날짜 필터
|
||||
if (startDate) {
|
||||
filtered = filtered.filter((h) => h.sentAt >= startDate);
|
||||
}
|
||||
if (endDate) {
|
||||
filtered = filtered.filter((h) => h.sentAt <= endDate);
|
||||
}
|
||||
|
||||
// 검색어 필터 (제목, 받는사람)
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(h) =>
|
||||
h.subject.toLowerCase().includes(term) ||
|
||||
h.to.some((email) => email.toLowerCase().includes(term)) ||
|
||||
(h.cc && h.cc.some((email) => email.toLowerCase().includes(term)))
|
||||
);
|
||||
}
|
||||
|
||||
// 정렬
|
||||
filtered.sort((a, b) => {
|
||||
let aVal: any = a[sortBy];
|
||||
let bVal: any = b[sortBy];
|
||||
|
||||
if (sortBy === 'sentAt') {
|
||||
aVal = new Date(aVal).getTime();
|
||||
bVal = new Date(bVal).getTime();
|
||||
} else {
|
||||
aVal = aVal ? aVal.toLowerCase() : '';
|
||||
bVal = bVal ? bVal.toLowerCase() : '';
|
||||
}
|
||||
|
||||
if (sortOrder === 'asc') {
|
||||
return aVal > bVal ? 1 : -1;
|
||||
} else {
|
||||
return aVal < bVal ? 1 : -1;
|
||||
}
|
||||
});
|
||||
|
||||
// 페이징
|
||||
const total = filtered.length;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
const start = (page - 1) * limit;
|
||||
const end = start + limit;
|
||||
const items = filtered.slice(start, end);
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 발송 이력 조회
|
||||
*/
|
||||
async getSentMailById(id: string): Promise<SentMailHistory | null> {
|
||||
const filePath = path.join(SENT_MAIL_DIR, `${id}.json`);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
return JSON.parse(content) as SentMailHistory;
|
||||
} catch (error) {
|
||||
console.error('발송 이력 읽기 실패:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 발송 이력 삭제
|
||||
*/
|
||||
async deleteSentMail(id: string): Promise<boolean> {
|
||||
const filePath = path.join(SENT_MAIL_DIR, `${id}.json`);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
console.log('🗑️ 발송 이력 삭제:', id);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('발송 이력 삭제 실패:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 조회
|
||||
*/
|
||||
async getStatistics(accountId?: string): Promise<{
|
||||
totalSent: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
todayCount: number;
|
||||
thisMonthCount: number;
|
||||
successRate: number;
|
||||
}> {
|
||||
const files = fs.readdirSync(SENT_MAIL_DIR).filter((f) => f.endsWith('.json'));
|
||||
let allHistory: SentMailHistory[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = path.join(SENT_MAIL_DIR, file);
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const history: SentMailHistory = JSON.parse(content);
|
||||
|
||||
// 계정 필터
|
||||
if (!accountId || history.accountId === accountId) {
|
||||
allHistory.push(history);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`발송 이력 파일 읽기 실패: ${file}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).toISOString();
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString();
|
||||
|
||||
const totalSent = allHistory.length;
|
||||
const successCount = allHistory.filter((h) => h.status === 'success').length;
|
||||
const failedCount = allHistory.filter((h) => h.status === 'failed').length;
|
||||
const todayCount = allHistory.filter((h) => h.sentAt >= todayStart).length;
|
||||
const thisMonthCount = allHistory.filter((h) => h.sentAt >= monthStart).length;
|
||||
const successRate = totalSent > 0 ? Math.round((successCount / totalSent) * 100) : 0;
|
||||
|
||||
return {
|
||||
totalSent,
|
||||
successCount,
|
||||
failedCount,
|
||||
todayCount,
|
||||
thisMonthCount,
|
||||
successRate,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const mailSentHistoryService = new MailSentHistoryService();
|
||||
|
||||
|
|
@ -141,26 +141,35 @@ class MailTemplateFileService {
|
|||
id: string,
|
||||
data: Partial<Omit<MailTemplate, 'id' | 'createdAt'>>
|
||||
): Promise<MailTemplate | null> {
|
||||
const existing = await this.getTemplateById(id);
|
||||
if (!existing) {
|
||||
return null;
|
||||
try {
|
||||
const existing = await this.getTemplateById(id);
|
||||
if (!existing) {
|
||||
// console.error(`❌ 템플릿을 찾을 수 없음: ${id}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const updated: MailTemplate = {
|
||||
...existing,
|
||||
...data,
|
||||
id: existing.id,
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// console.log(`📝 템플릿 저장 시도: ${id}, 크기: ${JSON.stringify(updated).length} bytes`);
|
||||
|
||||
await fs.writeFile(
|
||||
this.getTemplatePath(id),
|
||||
JSON.stringify(updated, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
// console.log(`✅ 템플릿 저장 성공: ${id}`);
|
||||
return updated;
|
||||
} catch (error) {
|
||||
// console.error(`❌ 템플릿 저장 실패: ${id}`, error);
|
||||
throw error; // 에러를 컨트롤러로 전달
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* 발송 이력 타입 정의
|
||||
*/
|
||||
|
||||
export interface SentMailHistory {
|
||||
id: string; // UUID
|
||||
accountId: string; // 발송 계정 ID
|
||||
accountName: string; // 발송 계정 이름
|
||||
accountEmail: string; // 발송 계정 이메일
|
||||
|
||||
// 수신자 정보
|
||||
to: string[]; // 받는 사람
|
||||
cc?: string[]; // 참조
|
||||
bcc?: string[]; // 숨은참조
|
||||
|
||||
// 메일 내용
|
||||
subject: string; // 제목
|
||||
htmlContent: string; // HTML 내용
|
||||
templateId?: string; // 사용한 템플릿 ID (있는 경우)
|
||||
templateName?: string; // 사용한 템플릿 이름
|
||||
|
||||
// 첨부파일 정보
|
||||
attachments?: AttachmentInfo[];
|
||||
|
||||
// 발송 정보
|
||||
sentAt: string; // 발송 시간 (ISO 8601)
|
||||
status: 'success' | 'failed'; // 발송 상태
|
||||
messageId?: string; // SMTP 메시지 ID (성공 시)
|
||||
errorMessage?: string; // 오류 메시지 (실패 시)
|
||||
|
||||
// 발송 결과
|
||||
accepted?: string[]; // 수락된 이메일 주소
|
||||
rejected?: string[]; // 거부된 이메일 주소
|
||||
}
|
||||
|
||||
export interface AttachmentInfo {
|
||||
filename: string; // 저장된 파일명
|
||||
originalName: string; // 원본 파일명
|
||||
size: number; // 파일 크기 (bytes)
|
||||
path: string; // 파일 경로
|
||||
mimetype: string; // MIME 타입
|
||||
}
|
||||
|
||||
export interface SentMailListQuery {
|
||||
page?: number; // 페이지 번호 (1부터 시작)
|
||||
limit?: number; // 페이지당 항목 수
|
||||
searchTerm?: string; // 검색어 (제목, 받는사람)
|
||||
status?: 'success' | 'failed' | 'all'; // 필터: 상태
|
||||
accountId?: string; // 필터: 발송 계정
|
||||
startDate?: string; // 필터: 시작 날짜 (ISO 8601)
|
||||
endDate?: string; // 필터: 종료 날짜 (ISO 8601)
|
||||
sortBy?: 'sentAt' | 'subject'; // 정렬 기준
|
||||
sortOrder?: 'asc' | 'desc'; // 정렬 순서
|
||||
}
|
||||
|
||||
export interface SentMailListResponse {
|
||||
items: SentMailHistory[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
|
|
@ -1,513 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft, Save, RotateCcw, Eye } from "lucide-react";
|
||||
import { useButtonActions, type ButtonActionFormData } from "@/hooks/admin/useButtonActions";
|
||||
import Link from "next/link";
|
||||
|
||||
// 기본 카테고리 목록
|
||||
const DEFAULT_CATEGORIES = ["crud", "navigation", "utility", "custom"];
|
||||
|
||||
// 기본 변형 목록
|
||||
const DEFAULT_VARIANTS = ["default", "destructive", "outline", "secondary", "ghost", "link"];
|
||||
|
||||
export default function EditButtonActionPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const actionType = params.actionType as string;
|
||||
|
||||
const { buttonActions, updateButtonAction, isUpdating, updateError, isLoading } = useButtonActions();
|
||||
|
||||
const [formData, setFormData] = useState<Partial<ButtonActionFormData>>({});
|
||||
const [originalData, setOriginalData] = useState<any>(null);
|
||||
const [isDataLoaded, setIsDataLoaded] = useState(false);
|
||||
|
||||
const [jsonErrors, setJsonErrors] = useState<{
|
||||
validation_rules?: string;
|
||||
action_config?: string;
|
||||
}>({});
|
||||
|
||||
// JSON 문자열 상태 (편집용)
|
||||
const [jsonStrings, setJsonStrings] = useState({
|
||||
validation_rules: "{}",
|
||||
action_config: "{}",
|
||||
});
|
||||
|
||||
// 버튼 액션 데이터 로드
|
||||
useEffect(() => {
|
||||
if (buttonActions && actionType && !isDataLoaded) {
|
||||
const found = buttonActions.find((ba) => ba.action_type === actionType);
|
||||
if (found) {
|
||||
setOriginalData(found);
|
||||
setFormData({
|
||||
action_name: found.action_name,
|
||||
action_name_eng: found.action_name_eng || "",
|
||||
description: found.description || "",
|
||||
category: found.category,
|
||||
default_text: found.default_text || "",
|
||||
default_text_eng: found.default_text_eng || "",
|
||||
default_icon: found.default_icon || "",
|
||||
default_color: found.default_color || "",
|
||||
default_variant: found.default_variant || "default",
|
||||
confirmation_required: found.confirmation_required || false,
|
||||
confirmation_message: found.confirmation_message || "",
|
||||
validation_rules: found.validation_rules || {},
|
||||
action_config: found.action_config || {},
|
||||
sort_order: found.sort_order || 0,
|
||||
is_active: found.is_active,
|
||||
});
|
||||
setJsonStrings({
|
||||
validation_rules: JSON.stringify(found.validation_rules || {}, null, 2),
|
||||
action_config: JSON.stringify(found.action_config || {}, null, 2),
|
||||
});
|
||||
setIsDataLoaded(true);
|
||||
} else {
|
||||
toast.error("버튼 액션을 찾을 수 없습니다.");
|
||||
router.push("/admin/system-settings/button-actions");
|
||||
}
|
||||
}
|
||||
}, [buttonActions, actionType, isDataLoaded, router]);
|
||||
|
||||
// 입력값 변경 핸들러
|
||||
const handleInputChange = (field: keyof ButtonActionFormData, value: any) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
// JSON 입력 변경 핸들러
|
||||
const handleJsonChange = (field: "validation_rules" | "action_config", value: string) => {
|
||||
setJsonStrings((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
|
||||
// JSON 파싱 시도
|
||||
try {
|
||||
const parsed = value.trim() ? JSON.parse(value) : {};
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: parsed,
|
||||
}));
|
||||
setJsonErrors((prev) => ({
|
||||
...prev,
|
||||
[field]: undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
setJsonErrors((prev) => ({
|
||||
...prev,
|
||||
[field]: "유효하지 않은 JSON 형식입니다.",
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 폼 유효성 검사
|
||||
const validateForm = (): boolean => {
|
||||
if (!formData.action_name?.trim()) {
|
||||
toast.error("액션명을 입력해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!formData.category?.trim()) {
|
||||
toast.error("카테고리를 선택해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// JSON 에러가 있는지 확인
|
||||
const hasJsonErrors = Object.values(jsonErrors).some((error) => error);
|
||||
if (hasJsonErrors) {
|
||||
toast.error("JSON 형식 오류를 수정해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 저장 핸들러
|
||||
const handleSave = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
try {
|
||||
await updateButtonAction(actionType, formData);
|
||||
toast.success("버튼 액션이 성공적으로 수정되었습니다.");
|
||||
router.push(`/admin/system-settings/button-actions/${actionType}`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "수정 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 폼 초기화 (원본 데이터로 되돌리기)
|
||||
const handleReset = () => {
|
||||
if (originalData) {
|
||||
setFormData({
|
||||
action_name: originalData.action_name,
|
||||
action_name_eng: originalData.action_name_eng || "",
|
||||
description: originalData.description || "",
|
||||
category: originalData.category,
|
||||
default_text: originalData.default_text || "",
|
||||
default_text_eng: originalData.default_text_eng || "",
|
||||
default_icon: originalData.default_icon || "",
|
||||
default_color: originalData.default_color || "",
|
||||
default_variant: originalData.default_variant || "default",
|
||||
confirmation_required: originalData.confirmation_required || false,
|
||||
confirmation_message: originalData.confirmation_message || "",
|
||||
validation_rules: originalData.validation_rules || {},
|
||||
action_config: originalData.action_config || {},
|
||||
sort_order: originalData.sort_order || 0,
|
||||
is_active: originalData.is_active,
|
||||
});
|
||||
setJsonStrings({
|
||||
validation_rules: JSON.stringify(originalData.validation_rules || {}, null, 2),
|
||||
action_config: JSON.stringify(originalData.action_config || {}, null, 2),
|
||||
});
|
||||
setJsonErrors({});
|
||||
}
|
||||
};
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading || !isDataLoaded) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-lg">버튼 액션 정보를 불러오는 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 버튼 액션을 찾지 못한 경우
|
||||
if (!originalData) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-muted-foreground mb-2 text-lg">버튼 액션을 찾을 수 없습니다.</div>
|
||||
<Link href="/admin/system-settings/button-actions">
|
||||
<Button variant="outline">목록으로 돌아가기</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
<Link href={`/admin/system-settings/button-actions/${actionType}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
상세보기로
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight">버튼 액션 편집</h1>
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{actionType}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground">{originalData.action_name} 버튼 액션의 정보를 수정합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* 기본 정보 */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>기본 정보</CardTitle>
|
||||
<CardDescription>버튼 액션의 기본적인 정보를 수정해주세요.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 액션 타입 (읽기 전용) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="action_type">액션 타입</Label>
|
||||
<Input id="action_type" value={actionType} disabled className="bg-muted font-mono" />
|
||||
<p className="text-muted-foreground text-xs">액션 타입은 수정할 수 없습니다.</p>
|
||||
</div>
|
||||
|
||||
{/* 액션명 */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="action_name">
|
||||
액션명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="action_name"
|
||||
value={formData.action_name || ""}
|
||||
onChange={(e) => handleInputChange("action_name", e.target.value)}
|
||||
placeholder="예: 저장"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="action_name_eng">영문명</Label>
|
||||
<Input
|
||||
id="action_name_eng"
|
||||
value={formData.action_name_eng || ""}
|
||||
onChange={(e) => handleInputChange("action_name_eng", e.target.value)}
|
||||
placeholder="예: Save"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">
|
||||
카테고리 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={formData.category || ""} onValueChange={(value) => handleInputChange("category", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="카테고리 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEFAULT_CATEGORIES.map((category) => (
|
||||
<SelectItem key={category} value={category}>
|
||||
{category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 설명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description || ""}
|
||||
onChange={(e) => handleInputChange("description", e.target.value)}
|
||||
placeholder="버튼 액션에 대한 설명을 입력해주세요..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 정렬 순서 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sort_order">정렬 순서</Label>
|
||||
<Input
|
||||
id="sort_order"
|
||||
type="number"
|
||||
value={formData.sort_order || 0}
|
||||
onChange={(e) => handleInputChange("sort_order", parseInt(e.target.value) || 0)}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">낮은 숫자일수록 먼저 표시됩니다.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 상태 설정 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>상태 설정</CardTitle>
|
||||
<CardDescription>버튼 액션의 활성화 상태를 설정합니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="is_active">활성화 상태</Label>
|
||||
<p className="text-muted-foreground text-xs">비활성화 시 화면관리에서 사용할 수 없습니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="is_active"
|
||||
checked={formData.is_active === "Y"}
|
||||
onCheckedChange={(checked) => handleInputChange("is_active", checked ? "Y" : "N")}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Badge variant={formData.is_active === "Y" ? "default" : "secondary"}>
|
||||
{formData.is_active === "Y" ? "활성화" : "비활성화"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 기본 설정 */}
|
||||
<Card className="lg:col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>기본 설정</CardTitle>
|
||||
<CardDescription>버튼의 기본 스타일과 동작을 설정합니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{/* 기본 텍스트 */}
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_text">기본 텍스트</Label>
|
||||
<Input
|
||||
id="default_text"
|
||||
value={formData.default_text || ""}
|
||||
onChange={(e) => handleInputChange("default_text", e.target.value)}
|
||||
placeholder="예: 저장"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_text_eng">영문 텍스트</Label>
|
||||
<Input
|
||||
id="default_text_eng"
|
||||
value={formData.default_text_eng || ""}
|
||||
onChange={(e) => handleInputChange("default_text_eng", e.target.value)}
|
||||
placeholder="예: Save"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 아이콘 및 색상 */}
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_icon">기본 아이콘</Label>
|
||||
<Input
|
||||
id="default_icon"
|
||||
value={formData.default_icon || ""}
|
||||
onChange={(e) => handleInputChange("default_icon", e.target.value)}
|
||||
placeholder="예: Save (Lucide 아이콘명)"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_color">기본 색상</Label>
|
||||
<Input
|
||||
id="default_color"
|
||||
value={formData.default_color || ""}
|
||||
onChange={(e) => handleInputChange("default_color", e.target.value)}
|
||||
placeholder="예: blue, red, green..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 변형 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_variant">기본 변형</Label>
|
||||
<Select
|
||||
value={formData.default_variant || "default"}
|
||||
onValueChange={(value) => handleInputChange("default_variant", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="변형 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEFAULT_VARIANTS.map((variant) => (
|
||||
<SelectItem key={variant} value={variant}>
|
||||
{variant}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 확인 설정 */}
|
||||
<Card className="lg:col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>확인 설정</CardTitle>
|
||||
<CardDescription>버튼 실행 전 확인 메시지 설정입니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="confirmation_required">확인 메시지 필요</Label>
|
||||
<p className="text-muted-foreground text-xs">버튼 실행 전 사용자 확인을 받습니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="confirmation_required"
|
||||
checked={formData.confirmation_required || false}
|
||||
onCheckedChange={(checked) => handleInputChange("confirmation_required", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formData.confirmation_required && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmation_message">확인 메시지</Label>
|
||||
<Textarea
|
||||
id="confirmation_message"
|
||||
value={formData.confirmation_message || ""}
|
||||
onChange={(e) => handleInputChange("confirmation_message", e.target.value)}
|
||||
placeholder="예: 정말로 삭제하시겠습니까?"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* JSON 설정 */}
|
||||
<Card className="lg:col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>고급 설정 (JSON)</CardTitle>
|
||||
<CardDescription>버튼 액션의 세부 설정을 JSON 형식으로 수정할 수 있습니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{/* 유효성 검사 규칙 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="validation_rules">유효성 검사 규칙</Label>
|
||||
<Textarea
|
||||
id="validation_rules"
|
||||
value={jsonStrings.validation_rules}
|
||||
onChange={(e) => handleJsonChange("validation_rules", e.target.value)}
|
||||
placeholder='{"requiresData": true, "minItems": 1}'
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.validation_rules && <p className="text-xs text-red-500">{jsonErrors.validation_rules}</p>}
|
||||
</div>
|
||||
|
||||
{/* 액션 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="action_config">액션 설정</Label>
|
||||
<Textarea
|
||||
id="action_config"
|
||||
value={jsonStrings.action_config}
|
||||
onChange={(e) => handleJsonChange("action_config", e.target.value)}
|
||||
placeholder='{"apiEndpoint": "/api/save", "redirectUrl": "/list"}'
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.action_config && <p className="text-xs text-red-500">{jsonErrors.action_config}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<Link href={`/admin/system-settings/button-actions/${actionType}`}>
|
||||
<Button variant="outline">
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
상세보기
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="flex gap-4">
|
||||
<Button variant="outline" onClick={handleReset}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
되돌리기
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isUpdating}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{isUpdating ? "저장 중..." : "저장"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{updateError && (
|
||||
<div className="mt-4 rounded-md border border-red-200 bg-red-50 p-4">
|
||||
<p className="text-red-600">
|
||||
수정 중 오류가 발생했습니다: {updateError instanceof Error ? updateError.message : "알 수 없는 오류"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,344 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft, Edit, Settings, Code, Eye, CheckCircle, AlertCircle } from "lucide-react";
|
||||
import { useButtonActions } from "@/hooks/admin/useButtonActions";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function ButtonActionDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const actionType = params.actionType as string;
|
||||
|
||||
const { buttonActions, isLoading, error } = useButtonActions();
|
||||
const [actionData, setActionData] = useState<any>(null);
|
||||
|
||||
// 버튼 액션 데이터 로드
|
||||
useEffect(() => {
|
||||
if (buttonActions && actionType) {
|
||||
const found = buttonActions.find((ba) => ba.action_type === actionType);
|
||||
if (found) {
|
||||
setActionData(found);
|
||||
} else {
|
||||
toast.error("버튼 액션을 찾을 수 없습니다.");
|
||||
router.push("/admin/system-settings/button-actions");
|
||||
}
|
||||
}
|
||||
}, [buttonActions, actionType, router]);
|
||||
|
||||
// JSON 포맷팅 함수
|
||||
const formatJson = (obj: any): string => {
|
||||
if (!obj || typeof obj !== "object") return "{}";
|
||||
try {
|
||||
return JSON.stringify(obj, null, 2);
|
||||
} catch {
|
||||
return "{}";
|
||||
}
|
||||
};
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-lg">버튼 액션 정보를 불러오는 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-lg text-red-600">버튼 액션 정보를 불러오는데 실패했습니다.</div>
|
||||
<Link href="/admin/system-settings/button-actions">
|
||||
<Button variant="outline">목록으로 돌아가기</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 버튼 액션을 찾지 못한 경우
|
||||
if (!actionData) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-muted-foreground mb-2 text-lg">버튼 액션을 찾을 수 없습니다.</div>
|
||||
<Link href="/admin/system-settings/button-actions">
|
||||
<Button variant="outline">목록으로 돌아가기</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin/system-settings/button-actions">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
목록으로
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{actionData.action_name}</h1>
|
||||
<Badge variant={actionData.is_active === "Y" ? "default" : "secondary"}>
|
||||
{actionData.is_active === "Y" ? "활성화" : "비활성화"}
|
||||
</Badge>
|
||||
{actionData.confirmation_required && (
|
||||
<Badge variant="outline" className="text-orange-600">
|
||||
<AlertCircle className="mr-1 h-3 w-3" />
|
||||
확인 필요
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-4">
|
||||
<p className="text-muted-foreground font-mono">{actionData.action_type}</p>
|
||||
{actionData.action_name_eng && <p className="text-muted-foreground">{actionData.action_name_eng}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link href={`/admin/system-settings/button-actions/${actionType}/edit`}>
|
||||
<Button>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
편집
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="overview" className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview" className="flex items-center gap-2">
|
||||
<Eye className="h-4 w-4" />
|
||||
개요
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="config" className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
설정
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="json" className="flex items-center gap-2">
|
||||
<Code className="h-4 w-4" />
|
||||
JSON 데이터
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 개요 탭 */}
|
||||
<TabsContent value="overview" className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">액션 타입</dt>
|
||||
<dd className="font-mono text-lg">{actionData.action_type}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">액션명</dt>
|
||||
<dd className="text-lg">{actionData.action_name}</dd>
|
||||
</div>
|
||||
{actionData.action_name_eng && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">영문명</dt>
|
||||
<dd className="text-lg">{actionData.action_name_eng}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">카테고리</dt>
|
||||
<dd>
|
||||
<Badge variant="secondary">{actionData.category}</Badge>
|
||||
</dd>
|
||||
</div>
|
||||
{actionData.description && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">설명</dt>
|
||||
<dd className="text-muted-foreground text-sm">{actionData.description}</dd>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 기본 설정 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>기본 설정</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{actionData.default_text && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">기본 텍스트</dt>
|
||||
<dd className="text-lg">{actionData.default_text}</dd>
|
||||
{actionData.default_text_eng && (
|
||||
<dd className="text-muted-foreground text-sm">{actionData.default_text_eng}</dd>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{actionData.default_icon && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">기본 아이콘</dt>
|
||||
<dd className="font-mono">{actionData.default_icon}</dd>
|
||||
</div>
|
||||
)}
|
||||
{actionData.default_color && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">기본 색상</dt>
|
||||
<dd>
|
||||
<Badge
|
||||
variant="outline"
|
||||
style={{
|
||||
borderColor: actionData.default_color,
|
||||
color: actionData.default_color,
|
||||
}}
|
||||
>
|
||||
{actionData.default_color}
|
||||
</Badge>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{actionData.default_variant && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">기본 변형</dt>
|
||||
<dd>
|
||||
<Badge variant="outline">{actionData.default_variant}</Badge>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 확인 설정 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>확인 설정</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">확인 메시지 필요</dt>
|
||||
<dd className="flex items-center gap-2">
|
||||
{actionData.confirmation_required ? (
|
||||
<>
|
||||
<AlertCircle className="h-4 w-4 text-orange-600" />
|
||||
<span className="text-orange-600">예</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
<span className="text-green-600">아니오</span>
|
||||
</>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
{actionData.confirmation_required && actionData.confirmation_message && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">확인 메시지</dt>
|
||||
<dd className="bg-muted rounded-md p-3 text-sm">{actionData.confirmation_message}</dd>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 메타데이터 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>메타데이터</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">정렬 순서</dt>
|
||||
<dd className="text-lg">{actionData.sort_order || 0}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">상태</dt>
|
||||
<dd>
|
||||
<Badge variant={actionData.is_active === "Y" ? "default" : "secondary"}>
|
||||
{actionData.is_active === "Y" ? "활성화" : "비활성화"}
|
||||
</Badge>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">생성일</dt>
|
||||
<dd className="text-sm">
|
||||
{actionData.created_date ? new Date(actionData.created_date).toLocaleString("ko-KR") : "-"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">생성자</dt>
|
||||
<dd className="text-sm">{actionData.created_by || "-"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">최종 수정일</dt>
|
||||
<dd className="text-sm">
|
||||
{actionData.updated_date ? new Date(actionData.updated_date).toLocaleString("ko-KR") : "-"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">수정자</dt>
|
||||
<dd className="text-sm">{actionData.updated_by || "-"}</dd>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 설정 탭 */}
|
||||
<TabsContent value="config" className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{/* 유효성 검사 규칙 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>유효성 검사 규칙</CardTitle>
|
||||
<CardDescription>실행 전 검증을 위한 규칙입니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="bg-muted overflow-auto rounded-md p-4 text-xs">
|
||||
{formatJson(actionData.validation_rules)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 액션 설정 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>액션 설정</CardTitle>
|
||||
<CardDescription>액션별 추가 설정입니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="bg-muted overflow-auto rounded-md p-4 text-xs">
|
||||
{formatJson(actionData.action_config)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* JSON 데이터 탭 */}
|
||||
<TabsContent value="json" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>전체 JSON 데이터</CardTitle>
|
||||
<CardDescription>버튼 액션의 모든 데이터를 JSON 형식으로 확인할 수 있습니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="bg-muted max-h-96 overflow-auto rounded-md p-4 text-xs">{formatJson(actionData)}</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,466 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft, Save, RotateCcw } from "lucide-react";
|
||||
import { useButtonActions, type ButtonActionFormData } from "@/hooks/admin/useButtonActions";
|
||||
import Link from "next/link";
|
||||
|
||||
// 기본 카테고리 목록
|
||||
const DEFAULT_CATEGORIES = ["crud", "navigation", "utility", "custom"];
|
||||
|
||||
// 기본 변형 목록
|
||||
const DEFAULT_VARIANTS = ["default", "destructive", "outline", "secondary", "ghost", "link"];
|
||||
|
||||
export default function NewButtonActionPage() {
|
||||
const router = useRouter();
|
||||
const { createButtonAction, isCreating, createError } = useButtonActions();
|
||||
|
||||
const [formData, setFormData] = useState<ButtonActionFormData>({
|
||||
action_type: "",
|
||||
action_name: "",
|
||||
action_name_eng: "",
|
||||
description: "",
|
||||
category: "general",
|
||||
default_text: "",
|
||||
default_text_eng: "",
|
||||
default_icon: "",
|
||||
default_color: "",
|
||||
default_variant: "default",
|
||||
confirmation_required: false,
|
||||
confirmation_message: "",
|
||||
validation_rules: {},
|
||||
action_config: {},
|
||||
sort_order: 0,
|
||||
is_active: "Y",
|
||||
});
|
||||
|
||||
const [jsonErrors, setJsonErrors] = useState<{
|
||||
validation_rules?: string;
|
||||
action_config?: string;
|
||||
}>({});
|
||||
|
||||
// JSON 문자열 상태 (편집용)
|
||||
const [jsonStrings, setJsonStrings] = useState({
|
||||
validation_rules: "{}",
|
||||
action_config: "{}",
|
||||
});
|
||||
|
||||
// 입력값 변경 핸들러
|
||||
const handleInputChange = (field: keyof ButtonActionFormData, value: any) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
// JSON 입력 변경 핸들러
|
||||
const handleJsonChange = (field: "validation_rules" | "action_config", value: string) => {
|
||||
setJsonStrings((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
|
||||
// JSON 파싱 시도
|
||||
try {
|
||||
const parsed = value.trim() ? JSON.parse(value) : {};
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: parsed,
|
||||
}));
|
||||
setJsonErrors((prev) => ({
|
||||
...prev,
|
||||
[field]: undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
setJsonErrors((prev) => ({
|
||||
...prev,
|
||||
[field]: "유효하지 않은 JSON 형식입니다.",
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 폼 유효성 검사
|
||||
const validateForm = (): boolean => {
|
||||
if (!formData.action_type.trim()) {
|
||||
toast.error("액션 타입을 입력해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!formData.action_name.trim()) {
|
||||
toast.error("액션명을 입력해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!formData.category.trim()) {
|
||||
toast.error("카테고리를 선택해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// JSON 에러가 있는지 확인
|
||||
const hasJsonErrors = Object.values(jsonErrors).some((error) => error);
|
||||
if (hasJsonErrors) {
|
||||
toast.error("JSON 형식 오류를 수정해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 저장 핸들러
|
||||
const handleSave = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
try {
|
||||
await createButtonAction(formData);
|
||||
toast.success("버튼 액션이 성공적으로 생성되었습니다.");
|
||||
router.push("/admin/system-settings/button-actions");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "생성 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 폼 초기화
|
||||
const handleReset = () => {
|
||||
setFormData({
|
||||
action_type: "",
|
||||
action_name: "",
|
||||
action_name_eng: "",
|
||||
description: "",
|
||||
category: "general",
|
||||
default_text: "",
|
||||
default_text_eng: "",
|
||||
default_icon: "",
|
||||
default_color: "",
|
||||
default_variant: "default",
|
||||
confirmation_required: false,
|
||||
confirmation_message: "",
|
||||
validation_rules: {},
|
||||
action_config: {},
|
||||
sort_order: 0,
|
||||
is_active: "Y",
|
||||
});
|
||||
setJsonStrings({
|
||||
validation_rules: "{}",
|
||||
action_config: "{}",
|
||||
});
|
||||
setJsonErrors({});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
<Link href="/admin/system-settings/button-actions">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
목록으로
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">새 버튼 액션 추가</h1>
|
||||
<p className="text-muted-foreground">새로운 버튼 액션을 생성하여 화면관리에서 사용할 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* 기본 정보 */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>기본 정보</CardTitle>
|
||||
<CardDescription>버튼 액션의 기본적인 정보를 입력해주세요.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 액션 타입 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="action_type">
|
||||
액션 타입 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="action_type"
|
||||
value={formData.action_type}
|
||||
onChange={(e) => handleInputChange("action_type", e.target.value)}
|
||||
placeholder="예: save, delete, edit..."
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">영문 소문자, 숫자, 언더스코어(_)만 사용 가능합니다.</p>
|
||||
</div>
|
||||
|
||||
{/* 액션명 */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="action_name">
|
||||
액션명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="action_name"
|
||||
value={formData.action_name}
|
||||
onChange={(e) => handleInputChange("action_name", e.target.value)}
|
||||
placeholder="예: 저장"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="action_name_eng">영문명</Label>
|
||||
<Input
|
||||
id="action_name_eng"
|
||||
value={formData.action_name_eng}
|
||||
onChange={(e) => handleInputChange("action_name_eng", e.target.value)}
|
||||
placeholder="예: Save"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">
|
||||
카테고리 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={formData.category} onValueChange={(value) => handleInputChange("category", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="카테고리 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEFAULT_CATEGORIES.map((category) => (
|
||||
<SelectItem key={category} value={category}>
|
||||
{category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 설명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => handleInputChange("description", e.target.value)}
|
||||
placeholder="버튼 액션에 대한 설명을 입력해주세요..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 정렬 순서 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sort_order">정렬 순서</Label>
|
||||
<Input
|
||||
id="sort_order"
|
||||
type="number"
|
||||
value={formData.sort_order}
|
||||
onChange={(e) => handleInputChange("sort_order", parseInt(e.target.value) || 0)}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">낮은 숫자일수록 먼저 표시됩니다.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 상태 설정 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>상태 설정</CardTitle>
|
||||
<CardDescription>버튼 액션의 활성화 상태를 설정합니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="is_active">활성화 상태</Label>
|
||||
<p className="text-muted-foreground text-xs">비활성화 시 화면관리에서 사용할 수 없습니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="is_active"
|
||||
checked={formData.is_active === "Y"}
|
||||
onCheckedChange={(checked) => handleInputChange("is_active", checked ? "Y" : "N")}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Badge variant={formData.is_active === "Y" ? "default" : "secondary"}>
|
||||
{formData.is_active === "Y" ? "활성화" : "비활성화"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 기본 설정 */}
|
||||
<Card className="lg:col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>기본 설정</CardTitle>
|
||||
<CardDescription>버튼의 기본 스타일과 동작을 설정합니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{/* 기본 텍스트 */}
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_text">기본 텍스트</Label>
|
||||
<Input
|
||||
id="default_text"
|
||||
value={formData.default_text}
|
||||
onChange={(e) => handleInputChange("default_text", e.target.value)}
|
||||
placeholder="예: 저장"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_text_eng">영문 텍스트</Label>
|
||||
<Input
|
||||
id="default_text_eng"
|
||||
value={formData.default_text_eng}
|
||||
onChange={(e) => handleInputChange("default_text_eng", e.target.value)}
|
||||
placeholder="예: Save"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 아이콘 및 색상 */}
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_icon">기본 아이콘</Label>
|
||||
<Input
|
||||
id="default_icon"
|
||||
value={formData.default_icon}
|
||||
onChange={(e) => handleInputChange("default_icon", e.target.value)}
|
||||
placeholder="예: Save (Lucide 아이콘명)"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_color">기본 색상</Label>
|
||||
<Input
|
||||
id="default_color"
|
||||
value={formData.default_color}
|
||||
onChange={(e) => handleInputChange("default_color", e.target.value)}
|
||||
placeholder="예: blue, red, green..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 변형 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_variant">기본 변형</Label>
|
||||
<Select
|
||||
value={formData.default_variant}
|
||||
onValueChange={(value) => handleInputChange("default_variant", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="변형 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEFAULT_VARIANTS.map((variant) => (
|
||||
<SelectItem key={variant} value={variant}>
|
||||
{variant}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 확인 설정 */}
|
||||
<Card className="lg:col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>확인 설정</CardTitle>
|
||||
<CardDescription>버튼 실행 전 확인 메시지 설정입니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="confirmation_required">확인 메시지 필요</Label>
|
||||
<p className="text-muted-foreground text-xs">버튼 실행 전 사용자 확인을 받습니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="confirmation_required"
|
||||
checked={formData.confirmation_required}
|
||||
onCheckedChange={(checked) => handleInputChange("confirmation_required", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formData.confirmation_required && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmation_message">확인 메시지</Label>
|
||||
<Textarea
|
||||
id="confirmation_message"
|
||||
value={formData.confirmation_message}
|
||||
onChange={(e) => handleInputChange("confirmation_message", e.target.value)}
|
||||
placeholder="예: 정말로 삭제하시겠습니까?"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* JSON 설정 */}
|
||||
<Card className="lg:col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>고급 설정 (JSON)</CardTitle>
|
||||
<CardDescription>버튼 액션의 세부 설정을 JSON 형식으로 입력할 수 있습니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{/* 유효성 검사 규칙 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="validation_rules">유효성 검사 규칙</Label>
|
||||
<Textarea
|
||||
id="validation_rules"
|
||||
value={jsonStrings.validation_rules}
|
||||
onChange={(e) => handleJsonChange("validation_rules", e.target.value)}
|
||||
placeholder='{"requiresData": true, "minItems": 1}'
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.validation_rules && <p className="text-xs text-red-500">{jsonErrors.validation_rules}</p>}
|
||||
</div>
|
||||
|
||||
{/* 액션 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="action_config">액션 설정</Label>
|
||||
<Textarea
|
||||
id="action_config"
|
||||
value={jsonStrings.action_config}
|
||||
onChange={(e) => handleJsonChange("action_config", e.target.value)}
|
||||
placeholder='{"apiEndpoint": "/api/save", "redirectUrl": "/list"}'
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.action_config && <p className="text-xs text-red-500">{jsonErrors.action_config}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="mt-6 flex justify-end gap-4">
|
||||
<Button variant="outline" onClick={handleReset}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
초기화
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isCreating}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{isCreating ? "생성 중..." : "저장"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{createError && (
|
||||
<div className="mt-4 rounded-md border border-red-200 bg-red-50 p-4">
|
||||
<p className="text-red-600">
|
||||
생성 중 오류가 발생했습니다: {createError instanceof Error ? createError.message : "알 수 없는 오류"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,376 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Edit,
|
||||
Trash2,
|
||||
Eye,
|
||||
Filter,
|
||||
RotateCcw,
|
||||
Settings,
|
||||
SortAsc,
|
||||
SortDesc,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { useButtonActions } from "@/hooks/admin/useButtonActions";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function ButtonActionsManagePage() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>("");
|
||||
const [activeFilter, setActiveFilter] = useState<string>("Y");
|
||||
const [sortField, setSortField] = useState<string>("sort_order");
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||
|
||||
// 버튼 액션 데이터 조회
|
||||
const { buttonActions, isLoading, error, deleteButtonAction, isDeleting, deleteError, refetch } = useButtonActions({
|
||||
active: activeFilter || undefined,
|
||||
search: searchTerm || undefined,
|
||||
category: categoryFilter || undefined,
|
||||
});
|
||||
|
||||
// 카테고리 목록 생성
|
||||
const categories = useMemo(() => {
|
||||
const uniqueCategories = Array.from(new Set(buttonActions.map((ba) => ba.category).filter(Boolean)));
|
||||
return uniqueCategories.sort();
|
||||
}, [buttonActions]);
|
||||
|
||||
// 필터링 및 정렬된 데이터
|
||||
const filteredAndSortedButtonActions = useMemo(() => {
|
||||
let filtered = [...buttonActions];
|
||||
|
||||
// 정렬
|
||||
filtered.sort((a, b) => {
|
||||
let aValue: any = a[sortField as keyof typeof a];
|
||||
let bValue: any = b[sortField as keyof typeof b];
|
||||
|
||||
// 숫자 필드 처리
|
||||
if (sortField === "sort_order") {
|
||||
aValue = aValue || 0;
|
||||
bValue = bValue || 0;
|
||||
}
|
||||
|
||||
// 문자열 필드 처리
|
||||
if (typeof aValue === "string") {
|
||||
aValue = aValue.toLowerCase();
|
||||
}
|
||||
if (typeof bValue === "string") {
|
||||
bValue = bValue.toLowerCase();
|
||||
}
|
||||
|
||||
if (aValue < bValue) return sortDirection === "asc" ? -1 : 1;
|
||||
if (aValue > bValue) return sortDirection === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [buttonActions, sortField, sortDirection]);
|
||||
|
||||
// 정렬 변경 핸들러
|
||||
const handleSort = (field: string) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection("asc");
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 핸들러
|
||||
const handleDelete = async (actionType: string, actionName: string) => {
|
||||
try {
|
||||
await deleteButtonAction(actionType);
|
||||
toast.success(`버튼 액션 '${actionName}'이 삭제되었습니다.`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "삭제 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 필터 초기화
|
||||
const resetFilters = () => {
|
||||
setSearchTerm("");
|
||||
setCategoryFilter("");
|
||||
setActiveFilter("Y");
|
||||
setSortField("sort_order");
|
||||
setSortDirection("asc");
|
||||
};
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-lg">버튼 액션 목록을 불러오는 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-lg text-red-600">버튼 액션 목록을 불러오는데 실패했습니다.</div>
|
||||
<Button onClick={() => refetch()} variant="outline">
|
||||
다시 시도
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">버튼 액션 관리</h1>
|
||||
<p className="text-muted-foreground">화면관리에서 사용할 버튼 액션들을 관리합니다.</p>
|
||||
</div>
|
||||
<Link href="/admin/system-settings/button-actions/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />새 버튼 액션 추가
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 필터 및 검색 */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Filter className="h-5 w-5" />
|
||||
필터 및 검색
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
{/* 검색 */}
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute top-3 left-3 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="액션명, 설명 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 필터 */}
|
||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="카테고리 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">전체 카테고리</SelectItem>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category} value={category}>
|
||||
{category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 활성화 상태 필터 */}
|
||||
<Select value={activeFilter} onValueChange={setActiveFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="상태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">전체</SelectItem>
|
||||
<SelectItem value="Y">활성화</SelectItem>
|
||||
<SelectItem value="N">비활성화</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 초기화 버튼 */}
|
||||
<Button variant="outline" onClick={resetFilters}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 결과 통계 */}
|
||||
<div className="mb-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
총 {filteredAndSortedButtonActions.length}개의 버튼 액션이 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 버튼 액션 목록 테이블 */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("sort_order")}>
|
||||
<div className="flex items-center gap-2">
|
||||
순서
|
||||
{sortField === "sort_order" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("action_type")}>
|
||||
<div className="flex items-center gap-2">
|
||||
액션 타입
|
||||
{sortField === "action_type" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("action_name")}>
|
||||
<div className="flex items-center gap-2">
|
||||
액션명
|
||||
{sortField === "action_name" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("category")}>
|
||||
<div className="flex items-center gap-2">
|
||||
카테고리
|
||||
{sortField === "category" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead>기본 텍스트</TableHead>
|
||||
<TableHead>확인 필요</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("is_active")}>
|
||||
<div className="flex items-center gap-2">
|
||||
상태
|
||||
{sortField === "is_active" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("updated_date")}>
|
||||
<div className="flex items-center gap-2">
|
||||
최종 수정일
|
||||
{sortField === "updated_date" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="text-center">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredAndSortedButtonActions.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="py-8 text-center">
|
||||
조건에 맞는 버튼 액션이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredAndSortedButtonActions.map((action) => (
|
||||
<TableRow key={action.action_type}>
|
||||
<TableCell className="font-mono">{action.sort_order || 0}</TableCell>
|
||||
<TableCell className="font-mono">{action.action_type}</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{action.action_name}
|
||||
{action.action_name_eng && (
|
||||
<div className="text-muted-foreground text-xs">{action.action_name_eng}</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{action.category}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs truncate">{action.default_text || "-"}</TableCell>
|
||||
<TableCell>
|
||||
{action.confirmation_required ? (
|
||||
<div className="flex items-center gap-1 text-orange-600">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span className="text-xs">필요</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-1 text-gray-500">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<span className="text-xs">불필요</span>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs truncate">{action.description || "-"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={action.is_active === "Y" ? "default" : "secondary"}>
|
||||
{action.is_active === "Y" ? "활성화" : "비활성화"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{action.updated_date ? new Date(action.updated_date).toLocaleDateString("ko-KR") : "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={`/admin/system-settings/button-actions/${action.action_type}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/admin/system-settings/button-actions/${action.action_type}/edit`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>버튼 액션 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
'{action.action_name}' 버튼 액션을 삭제하시겠습니까?
|
||||
<br />이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleDelete(action.action_type, action.action_name)}
|
||||
disabled={isDeleting}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
{isDeleting ? "삭제 중..." : "삭제"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{deleteError && (
|
||||
<div className="mt-4 rounded-md border border-red-200 bg-red-50 p-4">
|
||||
<p className="text-red-600">
|
||||
삭제 중 오류가 발생했습니다: {deleteError instanceof Error ? deleteError.message : "알 수 없는 오류"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,430 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft, Save, RotateCcw, Eye } from "lucide-react";
|
||||
import { useWebTypes, type WebTypeFormData } from "@/hooks/admin/useWebTypes";
|
||||
import Link from "next/link";
|
||||
|
||||
// 기본 카테고리 목록
|
||||
const DEFAULT_CATEGORIES = ["input", "select", "display", "special"];
|
||||
|
||||
export default function EditWebTypePage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const webType = params.webType as string;
|
||||
|
||||
const { webTypes, updateWebType, isUpdating, updateError, isLoading } = useWebTypes();
|
||||
|
||||
const [formData, setFormData] = useState<Partial<WebTypeFormData>>({});
|
||||
const [originalData, setOriginalData] = useState<any>(null);
|
||||
const [isDataLoaded, setIsDataLoaded] = useState(false);
|
||||
|
||||
const [jsonErrors, setJsonErrors] = useState<{
|
||||
default_config?: string;
|
||||
validation_rules?: string;
|
||||
default_style?: string;
|
||||
input_properties?: string;
|
||||
}>({});
|
||||
|
||||
// JSON 문자열 상태 (편집용)
|
||||
const [jsonStrings, setJsonStrings] = useState({
|
||||
default_config: "{}",
|
||||
validation_rules: "{}",
|
||||
default_style: "{}",
|
||||
input_properties: "{}",
|
||||
});
|
||||
|
||||
// 웹타입 데이터 로드
|
||||
useEffect(() => {
|
||||
if (webTypes && webType && !isDataLoaded) {
|
||||
const found = webTypes.find((wt) => wt.web_type === webType);
|
||||
if (found) {
|
||||
setOriginalData(found);
|
||||
setFormData({
|
||||
type_name: found.type_name,
|
||||
type_name_eng: found.type_name_eng || "",
|
||||
description: found.description || "",
|
||||
category: found.category,
|
||||
default_config: found.default_config || {},
|
||||
validation_rules: found.validation_rules || {},
|
||||
default_style: found.default_style || {},
|
||||
input_properties: found.input_properties || {},
|
||||
sort_order: found.sort_order || 0,
|
||||
is_active: found.is_active,
|
||||
});
|
||||
setJsonStrings({
|
||||
default_config: JSON.stringify(found.default_config || {}, null, 2),
|
||||
validation_rules: JSON.stringify(found.validation_rules || {}, null, 2),
|
||||
default_style: JSON.stringify(found.default_style || {}, null, 2),
|
||||
input_properties: JSON.stringify(found.input_properties || {}, null, 2),
|
||||
});
|
||||
setIsDataLoaded(true);
|
||||
} else {
|
||||
toast.error("웹타입을 찾을 수 없습니다.");
|
||||
router.push("/admin/system-settings/web-types");
|
||||
}
|
||||
}
|
||||
}, [webTypes, webType, isDataLoaded, router]);
|
||||
|
||||
// 입력값 변경 핸들러
|
||||
const handleInputChange = (field: keyof WebTypeFormData, value: any) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
// JSON 입력 변경 핸들러
|
||||
const handleJsonChange = (
|
||||
field: "default_config" | "validation_rules" | "default_style" | "input_properties",
|
||||
value: string,
|
||||
) => {
|
||||
setJsonStrings((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
|
||||
// JSON 파싱 시도
|
||||
try {
|
||||
const parsed = value.trim() ? JSON.parse(value) : {};
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: parsed,
|
||||
}));
|
||||
setJsonErrors((prev) => ({
|
||||
...prev,
|
||||
[field]: undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
setJsonErrors((prev) => ({
|
||||
...prev,
|
||||
[field]: "유효하지 않은 JSON 형식입니다.",
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 폼 유효성 검사
|
||||
const validateForm = (): boolean => {
|
||||
if (!formData.type_name?.trim()) {
|
||||
toast.error("웹타입명을 입력해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!formData.category?.trim()) {
|
||||
toast.error("카테고리를 선택해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// JSON 에러가 있는지 확인
|
||||
const hasJsonErrors = Object.values(jsonErrors).some((error) => error);
|
||||
if (hasJsonErrors) {
|
||||
toast.error("JSON 형식 오류를 수정해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 저장 핸들러
|
||||
const handleSave = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
try {
|
||||
await updateWebType(webType, formData);
|
||||
toast.success("웹타입이 성공적으로 수정되었습니다.");
|
||||
router.push(`/admin/system-settings/web-types/${webType}`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "수정 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 폼 초기화 (원본 데이터로 되돌리기)
|
||||
const handleReset = () => {
|
||||
if (originalData) {
|
||||
setFormData({
|
||||
type_name: originalData.type_name,
|
||||
type_name_eng: originalData.type_name_eng || "",
|
||||
description: originalData.description || "",
|
||||
category: originalData.category,
|
||||
default_config: originalData.default_config || {},
|
||||
validation_rules: originalData.validation_rules || {},
|
||||
default_style: originalData.default_style || {},
|
||||
input_properties: originalData.input_properties || {},
|
||||
sort_order: originalData.sort_order || 0,
|
||||
is_active: originalData.is_active,
|
||||
});
|
||||
setJsonStrings({
|
||||
default_config: JSON.stringify(originalData.default_config || {}, null, 2),
|
||||
validation_rules: JSON.stringify(originalData.validation_rules || {}, null, 2),
|
||||
default_style: JSON.stringify(originalData.default_style || {}, null, 2),
|
||||
input_properties: JSON.stringify(originalData.input_properties || {}, null, 2),
|
||||
});
|
||||
setJsonErrors({});
|
||||
}
|
||||
};
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading || !isDataLoaded) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-lg">웹타입 정보를 불러오는 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 웹타입을 찾지 못한 경우
|
||||
if (!originalData) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-muted-foreground mb-2 text-lg">웹타입을 찾을 수 없습니다.</div>
|
||||
<Link href="/admin/system-settings/web-types">
|
||||
<Button variant="outline">목록으로 돌아가기</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
<Link href={`/admin/system-settings/web-types/${webType}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
상세보기로
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight">웹타입 편집</h1>
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{webType}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground">{originalData.type_name} 웹타입의 정보를 수정합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* 기본 정보 */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>기본 정보</CardTitle>
|
||||
<CardDescription>웹타입의 기본적인 정보를 수정해주세요.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 웹타입 코드 (읽기 전용) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="web_type">웹타입 코드</Label>
|
||||
<Input id="web_type" value={webType} disabled className="bg-muted font-mono" />
|
||||
<p className="text-muted-foreground text-xs">웹타입 코드는 수정할 수 없습니다.</p>
|
||||
</div>
|
||||
|
||||
{/* 웹타입명 */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type_name">
|
||||
웹타입명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="type_name"
|
||||
value={formData.type_name || ""}
|
||||
onChange={(e) => handleInputChange("type_name", e.target.value)}
|
||||
placeholder="예: 텍스트 입력"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type_name_eng">영문명</Label>
|
||||
<Input
|
||||
id="type_name_eng"
|
||||
value={formData.type_name_eng || ""}
|
||||
onChange={(e) => handleInputChange("type_name_eng", e.target.value)}
|
||||
placeholder="예: Text Input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">
|
||||
카테고리 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={formData.category || ""} onValueChange={(value) => handleInputChange("category", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="카테고리 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEFAULT_CATEGORIES.map((category) => (
|
||||
<SelectItem key={category} value={category}>
|
||||
{category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 설명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description || ""}
|
||||
onChange={(e) => handleInputChange("description", e.target.value)}
|
||||
placeholder="웹타입에 대한 설명을 입력해주세요..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 정렬 순서 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sort_order">정렬 순서</Label>
|
||||
<Input
|
||||
id="sort_order"
|
||||
type="number"
|
||||
value={formData.sort_order || 0}
|
||||
onChange={(e) => handleInputChange("sort_order", parseInt(e.target.value) || 0)}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">낮은 숫자일수록 먼저 표시됩니다.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 상태 설정 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>상태 설정</CardTitle>
|
||||
<CardDescription>웹타입의 활성화 상태를 설정합니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="is_active">활성화 상태</Label>
|
||||
<p className="text-muted-foreground text-xs">비활성화 시 화면관리에서 사용할 수 없습니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="is_active"
|
||||
checked={formData.is_active === "Y"}
|
||||
onCheckedChange={(checked) => handleInputChange("is_active", checked ? "Y" : "N")}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Badge variant={formData.is_active === "Y" ? "default" : "secondary"}>
|
||||
{formData.is_active === "Y" ? "활성화" : "비활성화"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* JSON 설정 */}
|
||||
<Card className="lg:col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>고급 설정 (JSON)</CardTitle>
|
||||
<CardDescription>웹타입의 세부 설정을 JSON 형식으로 수정할 수 있습니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{/* 기본 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_config">기본 설정</Label>
|
||||
<Textarea
|
||||
id="default_config"
|
||||
value={jsonStrings.default_config}
|
||||
onChange={(e) => handleJsonChange("default_config", e.target.value)}
|
||||
placeholder='{"placeholder": "입력하세요..."}'
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.default_config && <p className="text-xs text-red-500">{jsonErrors.default_config}</p>}
|
||||
</div>
|
||||
|
||||
{/* 유효성 검사 규칙 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="validation_rules">유효성 검사 규칙</Label>
|
||||
<Textarea
|
||||
id="validation_rules"
|
||||
value={jsonStrings.validation_rules}
|
||||
onChange={(e) => handleJsonChange("validation_rules", e.target.value)}
|
||||
placeholder='{"required": true, "minLength": 1}'
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.validation_rules && <p className="text-xs text-red-500">{jsonErrors.validation_rules}</p>}
|
||||
</div>
|
||||
|
||||
{/* 기본 스타일 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_style">기본 스타일</Label>
|
||||
<Textarea
|
||||
id="default_style"
|
||||
value={jsonStrings.default_style}
|
||||
onChange={(e) => handleJsonChange("default_style", e.target.value)}
|
||||
placeholder='{"width": "100%", "height": "40px"}'
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.default_style && <p className="text-xs text-red-500">{jsonErrors.default_style}</p>}
|
||||
</div>
|
||||
|
||||
{/* 입력 속성 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="input_properties">HTML 입력 속성</Label>
|
||||
<Textarea
|
||||
id="input_properties"
|
||||
value={jsonStrings.input_properties}
|
||||
onChange={(e) => handleJsonChange("input_properties", e.target.value)}
|
||||
placeholder='{"type": "text", "autoComplete": "off"}'
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.input_properties && <p className="text-xs text-red-500">{jsonErrors.input_properties}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<Link href={`/admin/system-settings/web-types/${webType}`}>
|
||||
<Button variant="outline">
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
상세보기
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="flex gap-4">
|
||||
<Button variant="outline" onClick={handleReset}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
되돌리기
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isUpdating}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{isUpdating ? "저장 중..." : "저장"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{updateError && (
|
||||
<div className="mt-4 rounded-md border border-red-200 bg-red-50 p-4">
|
||||
<p className="text-red-600">
|
||||
수정 중 오류가 발생했습니다: {updateError instanceof Error ? updateError.message : "알 수 없는 오류"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,285 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft, Edit, Settings, Code, Eye } from "lucide-react";
|
||||
import { useWebTypes } from "@/hooks/admin/useWebTypes";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function WebTypeDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const webType = params.webType as string;
|
||||
|
||||
const { webTypes, isLoading, error } = useWebTypes();
|
||||
const [webTypeData, setWebTypeData] = useState<any>(null);
|
||||
|
||||
// 웹타입 데이터 로드
|
||||
useEffect(() => {
|
||||
if (webTypes && webType) {
|
||||
const found = webTypes.find((wt) => wt.web_type === webType);
|
||||
if (found) {
|
||||
setWebTypeData(found);
|
||||
} else {
|
||||
toast.error("웹타입을 찾을 수 없습니다.");
|
||||
router.push("/admin/system-settings/web-types");
|
||||
}
|
||||
}
|
||||
}, [webTypes, webType, router]);
|
||||
|
||||
// JSON 포맷팅 함수
|
||||
const formatJson = (obj: any): string => {
|
||||
if (!obj || typeof obj !== "object") return "{}";
|
||||
try {
|
||||
return JSON.stringify(obj, null, 2);
|
||||
} catch {
|
||||
return "{}";
|
||||
}
|
||||
};
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-lg">웹타입 정보를 불러오는 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-lg text-red-600">웹타입 정보를 불러오는데 실패했습니다.</div>
|
||||
<Link href="/admin/system-settings/web-types">
|
||||
<Button variant="outline">목록으로 돌아가기</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 웹타입을 찾지 못한 경우
|
||||
if (!webTypeData) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-muted-foreground mb-2 text-lg">웹타입을 찾을 수 없습니다.</div>
|
||||
<Link href="/admin/system-settings/web-types">
|
||||
<Button variant="outline">목록으로 돌아가기</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/admin/system-settings/web-types">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
목록으로
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight">{webTypeData.type_name}</h1>
|
||||
<Badge variant={webTypeData.is_active === "Y" ? "default" : "secondary"}>
|
||||
{webTypeData.is_active === "Y" ? "활성화" : "비활성화"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-4">
|
||||
<p className="text-muted-foreground font-mono">{webTypeData.web_type}</p>
|
||||
{webTypeData.type_name_eng && <p className="text-muted-foreground">{webTypeData.type_name_eng}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link href={`/admin/system-settings/web-types/${webType}/edit`}>
|
||||
<Button>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
편집
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="overview" className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview" className="flex items-center gap-2">
|
||||
<Eye className="h-4 w-4" />
|
||||
개요
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="config" className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
설정
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="json" className="flex items-center gap-2">
|
||||
<Code className="h-4 w-4" />
|
||||
JSON 데이터
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 개요 탭 */}
|
||||
<TabsContent value="overview" className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">웹타입 코드</dt>
|
||||
<dd className="font-mono text-lg">{webTypeData.web_type}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">웹타입명</dt>
|
||||
<dd className="text-lg">{webTypeData.type_name}</dd>
|
||||
</div>
|
||||
{webTypeData.type_name_eng && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">영문명</dt>
|
||||
<dd className="text-lg">{webTypeData.type_name_eng}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">카테고리</dt>
|
||||
<dd>
|
||||
<Badge variant="secondary">{webTypeData.category}</Badge>
|
||||
</dd>
|
||||
</div>
|
||||
{webTypeData.description && (
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">설명</dt>
|
||||
<dd className="text-muted-foreground text-sm">{webTypeData.description}</dd>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 메타데이터 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>메타데이터</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">정렬 순서</dt>
|
||||
<dd className="text-lg">{webTypeData.sort_order || 0}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">상태</dt>
|
||||
<dd>
|
||||
<Badge variant={webTypeData.is_active === "Y" ? "default" : "secondary"}>
|
||||
{webTypeData.is_active === "Y" ? "활성화" : "비활성화"}
|
||||
</Badge>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">생성일</dt>
|
||||
<dd className="text-sm">
|
||||
{webTypeData.created_date ? new Date(webTypeData.created_date).toLocaleString("ko-KR") : "-"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">생성자</dt>
|
||||
<dd className="text-sm">{webTypeData.created_by || "-"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">최종 수정일</dt>
|
||||
<dd className="text-sm">
|
||||
{webTypeData.updated_date ? new Date(webTypeData.updated_date).toLocaleString("ko-KR") : "-"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-muted-foreground text-sm font-medium">수정자</dt>
|
||||
<dd className="text-sm">{webTypeData.updated_by || "-"}</dd>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 설정 탭 */}
|
||||
<TabsContent value="config" className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{/* 기본 설정 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>기본 설정</CardTitle>
|
||||
<CardDescription>웹타입의 기본 동작 설정입니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="bg-muted overflow-auto rounded-md p-4 text-xs">
|
||||
{formatJson(webTypeData.default_config)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 유효성 검사 규칙 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>유효성 검사 규칙</CardTitle>
|
||||
<CardDescription>입력값 검증을 위한 규칙입니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="bg-muted overflow-auto rounded-md p-4 text-xs">
|
||||
{formatJson(webTypeData.validation_rules)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 기본 스타일 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>기본 스타일</CardTitle>
|
||||
<CardDescription>웹타입의 기본 스타일 설정입니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="bg-muted overflow-auto rounded-md p-4 text-xs">
|
||||
{formatJson(webTypeData.default_style)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* HTML 입력 속성 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>HTML 입력 속성</CardTitle>
|
||||
<CardDescription>HTML 요소에 적용될 기본 속성입니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="bg-muted overflow-auto rounded-md p-4 text-xs">
|
||||
{formatJson(webTypeData.input_properties)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* JSON 데이터 탭 */}
|
||||
<TabsContent value="json" className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>전체 JSON 데이터</CardTitle>
|
||||
<CardDescription>웹타입의 모든 데이터를 JSON 형식으로 확인할 수 있습니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="bg-muted max-h-96 overflow-auto rounded-md p-4 text-xs">{formatJson(webTypeData)}</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,381 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
import { ArrowLeft, Save, RotateCcw } from "lucide-react";
|
||||
import { useWebTypes, type WebTypeFormData } from "@/hooks/admin/useWebTypes";
|
||||
import Link from "next/link";
|
||||
|
||||
// 기본 카테고리 목록
|
||||
const DEFAULT_CATEGORIES = ["input", "select", "display", "special"];
|
||||
|
||||
export default function NewWebTypePage() {
|
||||
const router = useRouter();
|
||||
const { createWebType, isCreating, createError } = useWebTypes();
|
||||
|
||||
const [formData, setFormData] = useState<WebTypeFormData>({
|
||||
web_type: "",
|
||||
type_name: "",
|
||||
type_name_eng: "",
|
||||
description: "",
|
||||
category: "input",
|
||||
default_config: {},
|
||||
validation_rules: {},
|
||||
default_style: {},
|
||||
input_properties: {},
|
||||
sort_order: 0,
|
||||
is_active: "Y",
|
||||
});
|
||||
|
||||
const [jsonErrors, setJsonErrors] = useState<{
|
||||
default_config?: string;
|
||||
validation_rules?: string;
|
||||
default_style?: string;
|
||||
input_properties?: string;
|
||||
}>({});
|
||||
|
||||
// JSON 문자열 상태 (편집용)
|
||||
const [jsonStrings, setJsonStrings] = useState({
|
||||
default_config: "{}",
|
||||
validation_rules: "{}",
|
||||
default_style: "{}",
|
||||
input_properties: "{}",
|
||||
});
|
||||
|
||||
// 입력값 변경 핸들러
|
||||
const handleInputChange = (field: keyof WebTypeFormData, value: any) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
// JSON 입력 변경 핸들러
|
||||
const handleJsonChange = (
|
||||
field: "default_config" | "validation_rules" | "default_style" | "input_properties",
|
||||
value: string,
|
||||
) => {
|
||||
setJsonStrings((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
|
||||
// JSON 파싱 시도
|
||||
try {
|
||||
const parsed = value.trim() ? JSON.parse(value) : {};
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: parsed,
|
||||
}));
|
||||
setJsonErrors((prev) => ({
|
||||
...prev,
|
||||
[field]: undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
setJsonErrors((prev) => ({
|
||||
...prev,
|
||||
[field]: "유효하지 않은 JSON 형식입니다.",
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 폼 유효성 검사
|
||||
const validateForm = (): boolean => {
|
||||
if (!formData.web_type.trim()) {
|
||||
toast.error("웹타입 코드를 입력해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!formData.type_name.trim()) {
|
||||
toast.error("웹타입명을 입력해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!formData.category.trim()) {
|
||||
toast.error("카테고리를 선택해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// JSON 에러가 있는지 확인
|
||||
const hasJsonErrors = Object.values(jsonErrors).some((error) => error);
|
||||
if (hasJsonErrors) {
|
||||
toast.error("JSON 형식 오류를 수정해주세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 저장 핸들러
|
||||
const handleSave = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
try {
|
||||
await createWebType(formData);
|
||||
toast.success("웹타입이 성공적으로 생성되었습니다.");
|
||||
router.push("/admin/system-settings/web-types");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "생성 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 폼 초기화
|
||||
const handleReset = () => {
|
||||
setFormData({
|
||||
web_type: "",
|
||||
type_name: "",
|
||||
type_name_eng: "",
|
||||
description: "",
|
||||
category: "input",
|
||||
default_config: {},
|
||||
validation_rules: {},
|
||||
default_style: {},
|
||||
input_properties: {},
|
||||
sort_order: 0,
|
||||
is_active: "Y",
|
||||
});
|
||||
setJsonStrings({
|
||||
default_config: "{}",
|
||||
validation_rules: "{}",
|
||||
default_style: "{}",
|
||||
input_properties: "{}",
|
||||
});
|
||||
setJsonErrors({});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6 flex items-center gap-4">
|
||||
<Link href="/admin/system-settings/web-types">
|
||||
<Button variant="ghost" size="sm">
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
목록으로
|
||||
</Button>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">새 웹타입 추가</h1>
|
||||
<p className="text-muted-foreground">새로운 웹타입을 생성하여 화면관리에서 사용할 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* 기본 정보 */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>기본 정보</CardTitle>
|
||||
<CardDescription>웹타입의 기본적인 정보를 입력해주세요.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 웹타입 코드 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="web_type">
|
||||
웹타입 코드 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="web_type"
|
||||
value={formData.web_type}
|
||||
onChange={(e) => handleInputChange("web_type", e.target.value)}
|
||||
placeholder="예: text, number, email..."
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">영문 소문자, 숫자, 언더스코어(_)만 사용 가능합니다.</p>
|
||||
</div>
|
||||
|
||||
{/* 웹타입명 */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type_name">
|
||||
웹타입명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="type_name"
|
||||
value={formData.type_name}
|
||||
onChange={(e) => handleInputChange("type_name", e.target.value)}
|
||||
placeholder="예: 텍스트 입력"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type_name_eng">영문명</Label>
|
||||
<Input
|
||||
id="type_name_eng"
|
||||
value={formData.type_name_eng}
|
||||
onChange={(e) => handleInputChange("type_name_eng", e.target.value)}
|
||||
placeholder="예: Text Input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">
|
||||
카테고리 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={formData.category} onValueChange={(value) => handleInputChange("category", value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="카테고리 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DEFAULT_CATEGORIES.map((category) => (
|
||||
<SelectItem key={category} value={category}>
|
||||
{category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 설명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => handleInputChange("description", e.target.value)}
|
||||
placeholder="웹타입에 대한 설명을 입력해주세요..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 정렬 순서 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sort_order">정렬 순서</Label>
|
||||
<Input
|
||||
id="sort_order"
|
||||
type="number"
|
||||
value={formData.sort_order}
|
||||
onChange={(e) => handleInputChange("sort_order", parseInt(e.target.value) || 0)}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">낮은 숫자일수록 먼저 표시됩니다.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 상태 설정 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>상태 설정</CardTitle>
|
||||
<CardDescription>웹타입의 활성화 상태를 설정합니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="is_active">활성화 상태</Label>
|
||||
<p className="text-muted-foreground text-xs">비활성화 시 화면관리에서 사용할 수 없습니다.</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="is_active"
|
||||
checked={formData.is_active === "Y"}
|
||||
onCheckedChange={(checked) => handleInputChange("is_active", checked ? "Y" : "N")}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Badge variant={formData.is_active === "Y" ? "default" : "secondary"}>
|
||||
{formData.is_active === "Y" ? "활성화" : "비활성화"}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* JSON 설정 */}
|
||||
<Card className="lg:col-span-3">
|
||||
<CardHeader>
|
||||
<CardTitle>고급 설정 (JSON)</CardTitle>
|
||||
<CardDescription>웹타입의 세부 설정을 JSON 형식으로 입력할 수 있습니다.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{/* 기본 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_config">기본 설정</Label>
|
||||
<Textarea
|
||||
id="default_config"
|
||||
value={jsonStrings.default_config}
|
||||
onChange={(e) => handleJsonChange("default_config", e.target.value)}
|
||||
placeholder='{"placeholder": "입력하세요..."}'
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.default_config && <p className="text-xs text-red-500">{jsonErrors.default_config}</p>}
|
||||
</div>
|
||||
|
||||
{/* 유효성 검사 규칙 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="validation_rules">유효성 검사 규칙</Label>
|
||||
<Textarea
|
||||
id="validation_rules"
|
||||
value={jsonStrings.validation_rules}
|
||||
onChange={(e) => handleJsonChange("validation_rules", e.target.value)}
|
||||
placeholder='{"required": true, "minLength": 1}'
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.validation_rules && <p className="text-xs text-red-500">{jsonErrors.validation_rules}</p>}
|
||||
</div>
|
||||
|
||||
{/* 기본 스타일 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default_style">기본 스타일</Label>
|
||||
<Textarea
|
||||
id="default_style"
|
||||
value={jsonStrings.default_style}
|
||||
onChange={(e) => handleJsonChange("default_style", e.target.value)}
|
||||
placeholder='{"width": "100%", "height": "40px"}'
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.default_style && <p className="text-xs text-red-500">{jsonErrors.default_style}</p>}
|
||||
</div>
|
||||
|
||||
{/* 입력 속성 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="input_properties">HTML 입력 속성</Label>
|
||||
<Textarea
|
||||
id="input_properties"
|
||||
value={jsonStrings.input_properties}
|
||||
onChange={(e) => handleJsonChange("input_properties", e.target.value)}
|
||||
placeholder='{"type": "text", "autoComplete": "off"}'
|
||||
rows={4}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
{jsonErrors.input_properties && <p className="text-xs text-red-500">{jsonErrors.input_properties}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="mt-6 flex justify-end gap-4">
|
||||
<Button variant="outline" onClick={handleReset}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
초기화
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isCreating}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{isCreating ? "생성 중..." : "저장"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{createError && (
|
||||
<div className="mt-4 rounded-md border border-red-200 bg-red-50 p-4">
|
||||
<p className="text-red-600">
|
||||
생성 중 오류가 발생했습니다: {createError instanceof Error ? createError.message : "알 수 없는 오류"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,345 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { toast } from "sonner";
|
||||
import { Plus, Search, Edit, Trash2, Eye, Filter, RotateCcw, Settings, SortAsc, SortDesc } from "lucide-react";
|
||||
import { useWebTypes } from "@/hooks/admin/useWebTypes";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function WebTypesManagePage() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>("");
|
||||
const [activeFilter, setActiveFilter] = useState<string>("Y");
|
||||
const [sortField, setSortField] = useState<string>("sort_order");
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||
|
||||
// 웹타입 데이터 조회
|
||||
const { webTypes, isLoading, error, deleteWebType, isDeleting, deleteError, refetch } = useWebTypes({
|
||||
active: activeFilter || undefined,
|
||||
search: searchTerm || undefined,
|
||||
category: categoryFilter || undefined,
|
||||
});
|
||||
|
||||
// 카테고리 목록 생성
|
||||
const categories = useMemo(() => {
|
||||
const uniqueCategories = Array.from(new Set(webTypes.map((wt) => wt.category).filter(Boolean)));
|
||||
return uniqueCategories.sort();
|
||||
}, [webTypes]);
|
||||
|
||||
// 필터링 및 정렬된 데이터
|
||||
const filteredAndSortedWebTypes = useMemo(() => {
|
||||
let filtered = [...webTypes];
|
||||
|
||||
// 정렬
|
||||
filtered.sort((a, b) => {
|
||||
let aValue: any = a[sortField as keyof typeof a];
|
||||
let bValue: any = b[sortField as keyof typeof b];
|
||||
|
||||
// 숫자 필드 처리
|
||||
if (sortField === "sort_order") {
|
||||
aValue = aValue || 0;
|
||||
bValue = bValue || 0;
|
||||
}
|
||||
|
||||
// 문자열 필드 처리
|
||||
if (typeof aValue === "string") {
|
||||
aValue = aValue.toLowerCase();
|
||||
}
|
||||
if (typeof bValue === "string") {
|
||||
bValue = bValue.toLowerCase();
|
||||
}
|
||||
|
||||
if (aValue < bValue) return sortDirection === "asc" ? -1 : 1;
|
||||
if (aValue > bValue) return sortDirection === "asc" ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [webTypes, sortField, sortDirection]);
|
||||
|
||||
// 정렬 변경 핸들러
|
||||
const handleSort = (field: string) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection("asc");
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 핸들러
|
||||
const handleDelete = async (webType: string, typeName: string) => {
|
||||
try {
|
||||
await deleteWebType(webType);
|
||||
toast.success(`웹타입 '${typeName}'이 삭제되었습니다.`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "삭제 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 필터 초기화
|
||||
const resetFilters = () => {
|
||||
setSearchTerm("");
|
||||
setCategoryFilter("");
|
||||
setActiveFilter("Y");
|
||||
setSortField("sort_order");
|
||||
setSortDirection("asc");
|
||||
};
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-lg">웹타입 목록을 불러오는 중...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-lg text-red-600">웹타입 목록을 불러오는데 실패했습니다.</div>
|
||||
<Button onClick={() => refetch()} variant="outline">
|
||||
다시 시도
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
{/* 헤더 */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">웹타입 관리</h1>
|
||||
<p className="text-muted-foreground">화면관리에서 사용할 웹타입들을 관리합니다.</p>
|
||||
</div>
|
||||
<Link href="/admin/system-settings/web-types/new">
|
||||
<Button>
|
||||
<Plus className="mr-2 h-4 w-4" />새 웹타입 추가
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 필터 및 검색 */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Filter className="h-5 w-5" />
|
||||
필터 및 검색
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
{/* 검색 */}
|
||||
<div className="relative">
|
||||
<Search className="text-muted-foreground absolute top-3 left-3 h-4 w-4" />
|
||||
<Input
|
||||
placeholder="웹타입명, 설명 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 카테고리 필터 */}
|
||||
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="카테고리 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">전체 카테고리</SelectItem>
|
||||
{categories.map((category) => (
|
||||
<SelectItem key={category} value={category}>
|
||||
{category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 활성화 상태 필터 */}
|
||||
<Select value={activeFilter} onValueChange={setActiveFilter}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="상태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">전체</SelectItem>
|
||||
<SelectItem value="Y">활성화</SelectItem>
|
||||
<SelectItem value="N">비활성화</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 초기화 버튼 */}
|
||||
<Button variant="outline" onClick={resetFilters}>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 결과 통계 */}
|
||||
<div className="mb-4">
|
||||
<p className="text-muted-foreground text-sm">총 {filteredAndSortedWebTypes.length}개의 웹타입이 있습니다.</p>
|
||||
</div>
|
||||
|
||||
{/* 웹타입 목록 테이블 */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("sort_order")}>
|
||||
<div className="flex items-center gap-2">
|
||||
순서
|
||||
{sortField === "sort_order" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("web_type")}>
|
||||
<div className="flex items-center gap-2">
|
||||
웹타입 코드
|
||||
{sortField === "web_type" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("type_name")}>
|
||||
<div className="flex items-center gap-2">
|
||||
웹타입명
|
||||
{sortField === "type_name" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("category")}>
|
||||
<div className="flex items-center gap-2">
|
||||
카테고리
|
||||
{sortField === "category" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead>설명</TableHead>
|
||||
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("is_active")}>
|
||||
<div className="flex items-center gap-2">
|
||||
상태
|
||||
{sortField === "is_active" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("updated_date")}>
|
||||
<div className="flex items-center gap-2">
|
||||
최종 수정일
|
||||
{sortField === "updated_date" &&
|
||||
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="text-center">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredAndSortedWebTypes.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="py-8 text-center">
|
||||
조건에 맞는 웹타입이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredAndSortedWebTypes.map((webType) => (
|
||||
<TableRow key={webType.web_type}>
|
||||
<TableCell className="font-mono">{webType.sort_order || 0}</TableCell>
|
||||
<TableCell className="font-mono">{webType.web_type}</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{webType.type_name}
|
||||
{webType.type_name_eng && (
|
||||
<div className="text-muted-foreground text-xs">{webType.type_name_eng}</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{webType.category}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs truncate">{webType.description || "-"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={webType.is_active === "Y" ? "default" : "secondary"}>
|
||||
{webType.is_active === "Y" ? "활성화" : "비활성화"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{webType.updated_date ? new Date(webType.updated_date).toLocaleDateString("ko-KR") : "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href={`/admin/system-settings/web-types/${webType.web_type}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/admin/system-settings/web-types/${webType.web_type}/edit`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>웹타입 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
'{webType.type_name}' 웹타입을 삭제하시겠습니까?
|
||||
<br />이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => handleDelete(webType.web_type, webType.type_name)}
|
||||
disabled={isDeleting}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
{isDeleting ? "삭제 중..." : "삭제"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{deleteError && (
|
||||
<div className="mt-4 rounded-md border border-red-200 bg-red-50 p-4">
|
||||
<p className="text-red-600">
|
||||
삭제 중 오류가 발생했습니다: {deleteError instanceof Error ? deleteError.message : "알 수 없는 오류"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,7 +3,10 @@
|
|||
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 { Mail, Plus, Loader2, RefreshCw, ChevronRight } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
MailAccount,
|
||||
getMailAccounts,
|
||||
|
|
@ -19,6 +22,7 @@ import MailAccountTable from "@/components/mail/MailAccountTable";
|
|||
import ConfirmDeleteModal from "@/components/mail/ConfirmDeleteModal";
|
||||
|
||||
export default function MailAccountsPage() {
|
||||
const router = useRouter();
|
||||
const [accounts, setAccounts] = useState<MailAccount[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
|
@ -124,43 +128,60 @@ export default function MailAccountsPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||
{/* 페이지 제목 */}
|
||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">메일 계정 관리</h1>
|
||||
<p className="mt-2 text-gray-600">SMTP 메일 계정을 관리하고 발송 통계를 확인합니다</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadAccounts}
|
||||
disabled={loading}
|
||||
<div className="bg-card rounded-lg border p-6 space-y-4">
|
||||
{/* 브레드크럼브 */}
|
||||
<nav className="flex items-center gap-2 text-sm">
|
||||
<Link
|
||||
href="/admin/mail/dashboard"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
onClick={handleOpenCreateModal}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
새 계정 추가
|
||||
</Button>
|
||||
메일 관리
|
||||
</Link>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-foreground font-medium">계정 관리</span>
|
||||
</nav>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 제목 + 액션 버튼들 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">메일 계정 관리</h1>
|
||||
<p className="mt-2 text-muted-foreground">SMTP 메일 계정을 관리하고 발송 통계를 확인합니다</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadAccounts}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleOpenCreateModal}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
새 계정 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 메인 컨텐츠 */}
|
||||
{loading ? (
|
||||
<Card className="shadow-sm">
|
||||
<Card>
|
||||
<CardContent className="flex justify-center items-center py-16">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="shadow-sm">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<MailAccountTable
|
||||
accounts={accounts}
|
||||
|
|
@ -174,28 +195,28 @@ export default function MailAccountsPage() {
|
|||
)}
|
||||
|
||||
{/* 안내 정보 */}
|
||||
<Card className="bg-gradient-to-r from-orange-50 to-amber-50 border-orange-200 shadow-sm">
|
||||
<Card className="bg-muted/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center">
|
||||
<Mail className="w-5 h-5 mr-2 text-orange-500" />
|
||||
<Mail className="w-5 h-5 mr-2 text-foreground" />
|
||||
메일 계정 관리
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-700 mb-4">
|
||||
<p className="text-foreground mb-4">
|
||||
💡 SMTP 계정을 등록하여 시스템에서 메일을 발송할 수 있어요!
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm text-gray-600">
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li className="flex items-start">
|
||||
<span className="text-orange-500 mr-2">✓</span>
|
||||
<span className="text-foreground mr-2">✓</span>
|
||||
<span>Gmail, Naver, 자체 SMTP 서버 지원</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-orange-500 mr-2">✓</span>
|
||||
<span className="text-foreground mr-2">✓</span>
|
||||
<span>비밀번호는 암호화되어 안전하게 저장됩니다</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-orange-500 mr-2">✓</span>
|
||||
<span className="text-foreground mr-2">✓</span>
|
||||
<span>일일 발송 제한 설정 가능</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
|
|
@ -12,9 +13,9 @@ import {
|
|||
TrendingUp,
|
||||
Users,
|
||||
Calendar,
|
||||
Clock
|
||||
ArrowRight
|
||||
} from "lucide-react";
|
||||
import { getMailAccounts, getMailTemplates } from "@/lib/api/mail";
|
||||
import { getMailAccounts, getMailTemplates, getMailStatistics, getTodayReceivedCount } from "@/lib/api/mail";
|
||||
|
||||
interface DashboardStats {
|
||||
totalAccounts: number;
|
||||
|
|
@ -39,19 +40,26 @@ export default function MailDashboardPage() {
|
|||
const loadStats = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 계정 수 (apiClient를 통해 토큰 포함)
|
||||
const accounts = await getMailAccounts();
|
||||
|
||||
// 템플릿 수 (apiClient를 통해 토큰 포함)
|
||||
const templates = await getMailTemplates();
|
||||
const mailStats = await getMailStatistics();
|
||||
|
||||
// 오늘 수신 메일 수 조회 (IMAP 실시간 조회)
|
||||
let receivedTodayCount = 0;
|
||||
try {
|
||||
receivedTodayCount = await getTodayReceivedCount();
|
||||
} catch (error) {
|
||||
console.error('수신 메일 수 조회 실패:', error);
|
||||
// 실패 시 0으로 표시
|
||||
}
|
||||
|
||||
setStats({
|
||||
totalAccounts: accounts.length,
|
||||
totalTemplates: templates.length,
|
||||
sentToday: 0, // TODO: 실제 발송 통계 API 연동
|
||||
receivedToday: 0,
|
||||
sentThisMonth: 0,
|
||||
successRate: 0,
|
||||
sentToday: mailStats.todayCount,
|
||||
receivedToday: receivedTodayCount,
|
||||
sentThisMonth: mailStats.thisMonthCount,
|
||||
successRate: mailStats.successRate,
|
||||
});
|
||||
} catch (error) {
|
||||
// console.error('통계 로드 실패:', error);
|
||||
|
|
@ -71,7 +79,8 @@ export default function MailDashboardPage() {
|
|||
icon: Users,
|
||||
color: "blue",
|
||||
bgColor: "bg-blue-100",
|
||||
iconColor: "text-blue-500",
|
||||
iconColor: "text-blue-600",
|
||||
href: "/admin/mail/accounts",
|
||||
},
|
||||
{
|
||||
title: "템플릿 수",
|
||||
|
|
@ -79,7 +88,8 @@ export default function MailDashboardPage() {
|
|||
icon: FileText,
|
||||
color: "green",
|
||||
bgColor: "bg-green-100",
|
||||
iconColor: "text-green-500",
|
||||
iconColor: "text-green-600",
|
||||
href: "/admin/mail/templates",
|
||||
},
|
||||
{
|
||||
title: "오늘 발송",
|
||||
|
|
@ -87,7 +97,8 @@ export default function MailDashboardPage() {
|
|||
icon: Send,
|
||||
color: "orange",
|
||||
bgColor: "bg-orange-100",
|
||||
iconColor: "text-orange-500",
|
||||
iconColor: "text-orange-600",
|
||||
href: "/admin/mail/sent",
|
||||
},
|
||||
{
|
||||
title: "오늘 수신",
|
||||
|
|
@ -95,94 +106,171 @@ export default function MailDashboardPage() {
|
|||
icon: Inbox,
|
||||
color: "purple",
|
||||
bgColor: "bg-purple-100",
|
||||
iconColor: "text-purple-500",
|
||||
iconColor: "text-purple-600",
|
||||
href: "/admin/mail/receive",
|
||||
},
|
||||
];
|
||||
|
||||
const quickLinks = [
|
||||
{
|
||||
title: "계정 관리",
|
||||
description: "메일 계정 설정",
|
||||
href: "/admin/mail/accounts",
|
||||
icon: Users,
|
||||
color: "blue",
|
||||
},
|
||||
{
|
||||
title: "템플릿 관리",
|
||||
description: "템플릿 편집",
|
||||
href: "/admin/mail/templates",
|
||||
icon: FileText,
|
||||
color: "green",
|
||||
},
|
||||
{
|
||||
title: "메일 발송",
|
||||
description: "메일 보내기",
|
||||
href: "/admin/mail/send",
|
||||
icon: Send,
|
||||
color: "orange",
|
||||
},
|
||||
{
|
||||
title: "보낸메일함",
|
||||
description: "발송 이력 확인",
|
||||
href: "/admin/mail/sent",
|
||||
icon: Mail,
|
||||
color: "indigo",
|
||||
},
|
||||
{
|
||||
title: "수신함",
|
||||
description: "받은 메일 확인",
|
||||
href: "/admin/mail/receive",
|
||||
icon: Inbox,
|
||||
color: "purple",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="w-full max-w-7xl mx-auto px-6 py-8 space-y-6">
|
||||
{/* 페이지 제목 */}
|
||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">메일 관리 대시보드</h1>
|
||||
<p className="mt-2 text-gray-600">메일 시스템의 전체 현황을 확인합니다</p>
|
||||
<div className="flex items-center justify-between bg-card rounded-lg border p-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-4 bg-primary/10 rounded-lg">
|
||||
<Mail className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground mb-1">메일 관리 대시보드</h1>
|
||||
<p className="text-muted-foreground">메일 시스템의 전체 현황을 한눈에 확인하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={loadStats}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
<RefreshCw className={`w-5 h-5 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||
{statCards.map((stat, index) => (
|
||||
<Card key={index} className="shadow-sm hover:shadow-md transition-shadow">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 mb-1">{stat.title}</p>
|
||||
<p className="text-3xl font-bold text-gray-900">{stat.value}</p>
|
||||
<Link key={index} href={stat.href}>
|
||||
<Card className="hover:shadow-md transition-all hover:scale-105 cursor-pointer">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-muted-foreground mb-3">
|
||||
{stat.title}
|
||||
</p>
|
||||
<p className="text-4xl font-bold text-foreground">
|
||||
{stat.value}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-muted rounded-lg">
|
||||
<stat.icon className="w-7 h-7 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<div className={`${stat.bgColor} p-3 rounded-lg`}>
|
||||
<stat.icon className={`w-6 h-6 ${stat.iconColor}`} />
|
||||
{/* 진행 바 */}
|
||||
<div className="h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-1000"
|
||||
style={{ width: `${Math.min((stat.value / 10) * 100, 100)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 이번 달 통계 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="border-b bg-gradient-to-r from-slate-50 to-gray-50">
|
||||
<CardTitle className="flex items-center">
|
||||
<Calendar className="w-5 h-5 mr-2 text-orange-500" />
|
||||
이번 달 발송 통계
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
<Card>
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle className="text-lg flex items-center">
|
||||
<div className="p-2 bg-muted rounded-lg mr-3">
|
||||
<Calendar className="w-5 h-5 text-foreground" />
|
||||
</div>
|
||||
<span>이번 달 발송 통계</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">총 발송 건수</span>
|
||||
<span className="text-lg font-semibold text-gray-900">
|
||||
{stats.sentThisMonth}건
|
||||
</span>
|
||||
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
||||
<span className="text-sm font-medium text-muted-foreground">총 발송 건수</span>
|
||||
<span className="text-2xl font-bold text-foreground">{stats.sentThisMonth} 건</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">성공률</span>
|
||||
<span className="text-lg font-semibold text-green-600">
|
||||
{stats.successRate}%
|
||||
</span>
|
||||
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
||||
<span className="text-sm font-medium text-muted-foreground">성공률</span>
|
||||
<span className="text-2xl font-bold text-foreground">{stats.successRate}%</span>
|
||||
</div>
|
||||
<div className="pt-4 border-t">
|
||||
<div className="flex items-center text-sm text-gray-500">
|
||||
<TrendingUp className="w-4 h-4 mr-2 text-green-500" />
|
||||
전월 대비 12% 증가
|
||||
{/* 전월 대비 통계는 현재 불필요하여 주석처리
|
||||
<div className="flex items-center justify-between pt-3 border-t">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
전월 대비
|
||||
</div>
|
||||
<span className="text-lg font-bold text-foreground">+12%</span>
|
||||
</div>
|
||||
*/}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="border-b bg-gradient-to-r from-slate-50 to-gray-50">
|
||||
<CardTitle className="flex items-center">
|
||||
<Clock className="w-5 h-5 mr-2 text-blue-500" />
|
||||
최근 활동
|
||||
<Card>
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle className="text-lg flex items-center">
|
||||
<div className="p-2 bg-muted rounded-lg mr-3">
|
||||
<Mail className="w-5 h-5 text-foreground" />
|
||||
</div>
|
||||
<span>시스템 상태</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-3">
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
<Mail className="w-12 h-12 mx-auto mb-3 text-gray-300" />
|
||||
<p className="text-sm">최근 활동이 없습니다</p>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-3 h-3 bg-primary rounded-full animate-pulse"></div>
|
||||
<span className="text-sm font-medium text-muted-foreground">메일 서버</span>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-foreground">정상 작동</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-3 h-3 bg-primary rounded-full"></div>
|
||||
<span className="text-sm font-medium text-muted-foreground">활성 계정</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-foreground">{stats.totalAccounts} 개</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-3 h-3 bg-primary rounded-full"></div>
|
||||
<span className="text-sm font-medium text-muted-foreground">사용 가능 템플릿</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-foreground">{stats.totalTemplates} 개</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -190,93 +278,32 @@ export default function MailDashboardPage() {
|
|||
</div>
|
||||
|
||||
{/* 빠른 액세스 */}
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="border-b bg-gradient-to-r from-slate-50 to-gray-50">
|
||||
<CardTitle>빠른 액세스</CardTitle>
|
||||
<Card>
|
||||
<CardHeader className="border-b">
|
||||
<CardTitle className="text-lg">빠른 액세스</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<a
|
||||
href="/admin/mail/accounts"
|
||||
className="flex items-center p-4 rounded-lg border border-gray-200 hover:border-orange-300 hover:bg-orange-50 transition-all"
|
||||
>
|
||||
<Users className="w-8 h-8 text-blue-500 mr-3" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">계정 관리</p>
|
||||
<p className="text-sm text-gray-500">메일 계정 설정</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/admin/mail/templates"
|
||||
className="flex items-center p-4 rounded-lg border border-gray-200 hover:border-orange-300 hover:bg-orange-50 transition-all"
|
||||
>
|
||||
<FileText className="w-8 h-8 text-green-500 mr-3" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">템플릿 관리</p>
|
||||
<p className="text-sm text-gray-500">템플릿 편집</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/admin/mail/send"
|
||||
className="flex items-center p-4 rounded-lg border border-gray-200 hover:border-orange-300 hover:bg-orange-50 transition-all"
|
||||
>
|
||||
<Send className="w-8 h-8 text-orange-500 mr-3" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">메일 발송</p>
|
||||
<p className="text-sm text-gray-500">메일 보내기</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="/admin/mail/receive"
|
||||
className="flex items-center p-4 rounded-lg border border-gray-200 hover:border-orange-300 hover:bg-orange-50 transition-all"
|
||||
>
|
||||
<Inbox className="w-8 h-8 text-purple-500 mr-3" />
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">수신함</p>
|
||||
<p className="text-sm text-gray-500">받은 메일 확인</p>
|
||||
</div>
|
||||
</a>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{quickLinks.map((link, index) => (
|
||||
<a
|
||||
key={index}
|
||||
href={link.href}
|
||||
className="group flex items-center gap-4 p-5 rounded-lg border hover:border-primary/50 hover:shadow-md transition-all bg-card hover:bg-muted/50"
|
||||
>
|
||||
<div className="p-3 bg-muted rounded-lg group-hover:scale-105 transition-transform">
|
||||
<link.icon className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold text-foreground text-base mb-1">{link.title}</p>
|
||||
<p className="text-sm text-muted-foreground truncate">{link.description}</p>
|
||||
</div>
|
||||
<ArrowRight className="w-5 h-5 text-muted-foreground group-hover:text-foreground group-hover:translate-x-1 transition-all" />
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 안내 정보 */}
|
||||
<Card className="bg-gradient-to-r from-orange-50 to-amber-50 border-orange-200 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center">
|
||||
<Mail className="w-5 h-5 mr-2 text-orange-500" />
|
||||
메일 관리 시스템 안내
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-700 mb-4">
|
||||
💡 메일 관리 시스템의 주요 기능을 확인하세요!
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm text-gray-600">
|
||||
<li className="flex items-start">
|
||||
<span className="text-orange-500 mr-2">✓</span>
|
||||
<span>SMTP 계정을 등록하여 메일 발송</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-orange-500 mr-2">✓</span>
|
||||
<span>드래그 앤 드롭으로 템플릿 디자인</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-orange-500 mr-2">✓</span>
|
||||
<span>동적 변수와 SQL 쿼리 연동</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-orange-500 mr-2">✓</span>
|
||||
<span>발송 통계 및 이력 관리</span>
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,11 @@ import {
|
|||
Filter,
|
||||
SortAsc,
|
||||
SortDesc,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
MailAccount,
|
||||
ReceivedMail,
|
||||
|
|
@ -26,6 +30,7 @@ import {
|
|||
import MailDetailModal from "@/components/mail/MailDetailModal";
|
||||
|
||||
export default function MailReceivePage() {
|
||||
const router = useRouter();
|
||||
const [accounts, setAccounts] = useState<MailAccount[]>([]);
|
||||
const [selectedAccountId, setSelectedAccountId] = useState<string>("");
|
||||
const [mails, setMails] = useState<ReceivedMail[]>([]);
|
||||
|
|
@ -197,17 +202,33 @@ export default function MailReceivePage() {
|
|||
}, [mails, searchTerm, filterStatus, sortBy]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||
{/* 페이지 제목 */}
|
||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">메일 수신함</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
IMAP으로 받은 메일을 확인합니다
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="bg-card rounded-lg border p-6 space-y-4">
|
||||
{/* 브레드크럼브 */}
|
||||
<nav className="flex items-center gap-2 text-sm">
|
||||
<Link
|
||||
href="/admin/mail/dashboard"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
메일 관리
|
||||
</Link>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-foreground font-medium">메일 수신함</span>
|
||||
</nav>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 제목 + 액션 버튼들 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">메일 수신함</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
IMAP으로 받은 메일을 확인합니다
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
@ -232,20 +253,21 @@ export default function MailReceivePage() {
|
|||
)}
|
||||
연결 테스트
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 계정 선택 */}
|
||||
<Card className="shadow-sm">
|
||||
<Card className="">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="text-sm font-medium text-gray-700 whitespace-nowrap">
|
||||
<label className="text-sm font-medium text-foreground whitespace-nowrap">
|
||||
메일 계정:
|
||||
</label>
|
||||
<select
|
||||
value={selectedAccountId}
|
||||
onChange={(e) => setSelectedAccountId(e.target.value)}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
className="flex-1 px-4 py-2 border border rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
>
|
||||
<option value="">계정 선택</option>
|
||||
{accounts.map((account) => (
|
||||
|
|
@ -278,7 +300,7 @@ export default function MailReceivePage() {
|
|||
|
||||
{/* 검색 및 필터 */}
|
||||
{selectedAccountId && (
|
||||
<Card className="shadow-sm">
|
||||
<Card className="">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col md:flex-row gap-3">
|
||||
{/* 검색 */}
|
||||
|
|
@ -289,17 +311,17 @@ export default function MailReceivePage() {
|
|||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="제목, 발신자, 내용으로 검색..."
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
className="w-full pl-10 pr-4 py-2 border border rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 필터 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-4 h-4 text-gray-500" />
|
||||
<Filter className="w-4 h-4 text-muted-foreground" />
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
className="px-3 py-2 border border rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
>
|
||||
<option value="all">전체</option>
|
||||
<option value="unread">읽지 않음</option>
|
||||
|
|
@ -311,14 +333,14 @@ export default function MailReceivePage() {
|
|||
{/* 정렬 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{sortBy.includes("desc") ? (
|
||||
<SortDesc className="w-4 h-4 text-gray-500" />
|
||||
<SortDesc className="w-4 h-4 text-muted-foreground" />
|
||||
) : (
|
||||
<SortAsc className="w-4 h-4 text-gray-500" />
|
||||
<SortAsc className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
className="px-3 py-2 border border rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
>
|
||||
<option value="date-desc">날짜 ↓ (최신순)</option>
|
||||
<option value="date-asc">날짜 ↑ (오래된순)</option>
|
||||
|
|
@ -330,7 +352,7 @@ export default function MailReceivePage() {
|
|||
|
||||
{/* 검색 결과 카운트 */}
|
||||
{(searchTerm || filterStatus !== "all") && (
|
||||
<div className="mt-3 text-sm text-gray-600">
|
||||
<div className="mt-3 text-sm text-muted-foreground">
|
||||
{filteredAndSortedMails.length}개의 메일이 검색되었습니다
|
||||
{searchTerm && (
|
||||
<span className="ml-2">
|
||||
|
|
@ -345,17 +367,17 @@ export default function MailReceivePage() {
|
|||
|
||||
{/* 메일 목록 */}
|
||||
{loading ? (
|
||||
<Card className="shadow-sm">
|
||||
<Card className="">
|
||||
<CardContent className="flex justify-center items-center py-16">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
|
||||
<span className="ml-3 text-gray-600">메일을 불러오는 중...</span>
|
||||
<span className="ml-3 text-muted-foreground">메일을 불러오는 중...</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : filteredAndSortedMails.length === 0 ? (
|
||||
<Card className="text-center py-16 bg-white shadow-sm">
|
||||
<Card className="text-center py-16 bg-card ">
|
||||
<CardContent className="pt-6">
|
||||
<Mail className="w-16 h-16 mx-auto mb-4 text-gray-300" />
|
||||
<p className="text-gray-500 mb-4">
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{!selectedAccountId
|
||||
? "메일 계정을 선택하세요"
|
||||
: searchTerm || filterStatus !== "all"
|
||||
|
|
@ -379,7 +401,7 @@ export default function MailReceivePage() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="shadow-sm">
|
||||
<Card className="">
|
||||
<CardHeader className="bg-gradient-to-r from-slate-50 to-gray-50 border-b">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Inbox className="w-5 h-5 text-orange-500" />
|
||||
|
|
@ -392,7 +414,7 @@ export default function MailReceivePage() {
|
|||
<div
|
||||
key={mail.id}
|
||||
onClick={() => handleMailClick(mail)}
|
||||
className={`p-4 hover:bg-gray-50 transition-colors cursor-pointer ${
|
||||
className={`p-4 hover:bg-background transition-colors cursor-pointer ${
|
||||
!mail.isRead ? "bg-blue-50/30" : ""
|
||||
}`}
|
||||
>
|
||||
|
|
@ -410,8 +432,8 @@ export default function MailReceivePage() {
|
|||
<span
|
||||
className={`text-sm ${
|
||||
mail.isRead
|
||||
? "text-gray-600"
|
||||
: "text-gray-900 font-semibold"
|
||||
? "text-muted-foreground"
|
||||
: "text-foreground font-semibold"
|
||||
}`}
|
||||
>
|
||||
{mail.from}
|
||||
|
|
@ -420,19 +442,19 @@ export default function MailReceivePage() {
|
|||
{mail.hasAttachments && (
|
||||
<Paperclip className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
<span className="text-xs text-gray-500">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDate(mail.date)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<h3
|
||||
className={`text-sm mb-1 truncate ${
|
||||
mail.isRead ? "text-gray-700" : "text-gray-900 font-medium"
|
||||
mail.isRead ? "text-foreground" : "text-foreground font-medium"
|
||||
}`}
|
||||
>
|
||||
{mail.subject}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 line-clamp-2">
|
||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||
{mail.preview}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -445,7 +467,7 @@ export default function MailReceivePage() {
|
|||
)}
|
||||
|
||||
{/* 안내 정보 */}
|
||||
<Card className="bg-gradient-to-r from-green-50 to-emerald-50 border-green-200 shadow-sm">
|
||||
<Card className="bg-gradient-to-r from-green-50 to-emerald-50 border-green-200 ">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center">
|
||||
<CheckCircle className="w-5 h-5 mr-2 text-green-600" />
|
||||
|
|
@ -453,13 +475,13 @@ export default function MailReceivePage() {
|
|||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-700 mb-4">
|
||||
<p className="text-foreground mb-4">
|
||||
✅ 구현 완료된 모든 기능:
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<p className="font-medium text-gray-800 mb-2">📬 기본 기능</p>
|
||||
<ul className="space-y-1 text-sm text-gray-600">
|
||||
<ul className="space-y-1 text-sm text-muted-foreground">
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span>IMAP 프로토콜 메일 수신</span>
|
||||
|
|
@ -480,7 +502,7 @@ export default function MailReceivePage() {
|
|||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800 mb-2">📄 상세보기</p>
|
||||
<ul className="space-y-1 text-sm text-gray-600">
|
||||
<ul className="space-y-1 text-sm text-muted-foreground">
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span>HTML 본문 렌더링</span>
|
||||
|
|
@ -501,7 +523,7 @@ export default function MailReceivePage() {
|
|||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800 mb-2">🔍 고급 기능</p>
|
||||
<ul className="space-y-1 text-sm text-gray-600">
|
||||
<ul className="space-y-1 text-sm text-muted-foreground">
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span>통합 검색 (제목/발신자/내용)</span>
|
||||
|
|
@ -522,7 +544,7 @@ export default function MailReceivePage() {
|
|||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800 mb-2">🔒 보안</p>
|
||||
<ul className="space-y-1 text-sm text-gray-600">
|
||||
<ul className="space-y-1 text-sm text-muted-foreground">
|
||||
<li className="flex items-start">
|
||||
<span className="text-green-500 mr-2">✓</span>
|
||||
<span>XSS 방지 (DOMPurify)</span>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,699 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, 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 { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Inbox,
|
||||
Search,
|
||||
Filter,
|
||||
Eye,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Mail,
|
||||
Calendar,
|
||||
User,
|
||||
Paperclip,
|
||||
Loader2,
|
||||
X,
|
||||
File,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Send,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
SentMailHistory,
|
||||
getSentMailList,
|
||||
deleteSentMail,
|
||||
getMailAccounts,
|
||||
MailAccount,
|
||||
getMailStatistics,
|
||||
} from "@/lib/api/mail";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
export default function SentMailPage() {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [mails, setMails] = useState<SentMailHistory[]>([]);
|
||||
const [accounts, setAccounts] = useState<MailAccount[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedMail, setSelectedMail] = useState<SentMailHistory | null>(null);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
// 통계
|
||||
const [stats, setStats] = useState({
|
||||
totalSent: 0,
|
||||
successCount: 0,
|
||||
failedCount: 0,
|
||||
todayCount: 0,
|
||||
});
|
||||
|
||||
// 필터 및 페이징
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [filterStatus, setFilterStatus] = useState<'all' | 'success' | 'failed'>('all');
|
||||
const [filterAccountId, setFilterAccountId] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState<'sentAt' | 'subject'>('sentAt');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
const [page, setPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
loadAccounts();
|
||||
loadStats();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadMails();
|
||||
}, [page, filterStatus, filterAccountId, sortBy, sortOrder]);
|
||||
|
||||
const loadAccounts = async () => {
|
||||
try {
|
||||
const data = await getMailAccounts();
|
||||
setAccounts(data);
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
toast({
|
||||
title: "계정 로드 실패",
|
||||
description: err.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const data = await getMailStatistics();
|
||||
setStats({
|
||||
totalSent: data.totalSent,
|
||||
successCount: data.successCount,
|
||||
failedCount: data.failedCount,
|
||||
todayCount: data.todayCount,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
console.error('통계 로드 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadMails = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await getSentMailList({
|
||||
page,
|
||||
limit: 20,
|
||||
searchTerm: searchTerm || undefined,
|
||||
status: filterStatus,
|
||||
accountId: filterAccountId !== 'all' ? filterAccountId : undefined,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
});
|
||||
|
||||
setMails(result.items);
|
||||
setTotalPages(result.totalPages);
|
||||
setTotal(result.total);
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
toast({
|
||||
title: "발송 이력 로드 실패",
|
||||
description: err.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setPage(1);
|
||||
loadMails();
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm("이 발송 이력을 삭제하시겠습니까?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteSentMail(id);
|
||||
toast({
|
||||
title: "삭제 완료",
|
||||
description: "발송 이력이 삭제되었습니다.",
|
||||
});
|
||||
loadMails();
|
||||
loadStats();
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
toast({
|
||||
title: "삭제 실패",
|
||||
description: err.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + " " + sizes[i];
|
||||
};
|
||||
|
||||
if (loading && page === 1 && mails.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6 bg-background min-h-screen">
|
||||
{/* 헤더 */}
|
||||
<div className="bg-card rounded-lg border p-6 space-y-4">
|
||||
{/* 브레드크럼브 */}
|
||||
<nav className="flex items-center gap-2 text-sm">
|
||||
<Link
|
||||
href="/admin/mail/dashboard"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
메일 관리
|
||||
</Link>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-foreground font-medium">발송 내역</span>
|
||||
</nav>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 제목 및 빠른 액션 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground flex items-center gap-2">
|
||||
<Inbox className="w-8 h-8" />
|
||||
보낸메일함
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">총 {total}개의 발송 이력</p>
|
||||
</div>
|
||||
<Button onClick={loadMails} variant="outline" size="sm">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 통계 카드 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">전체 발송</p>
|
||||
<p className="text-2xl font-bold text-foreground mt-1">{stats.totalSent}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-muted rounded-lg">
|
||||
<Send className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">발송 성공</p>
|
||||
<p className="text-2xl font-bold text-foreground mt-1">{stats.successCount}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-muted rounded-lg">
|
||||
<CheckCircle2 className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">발송 실패</p>
|
||||
<p className="text-2xl font-bold text-foreground mt-1">{stats.failedCount}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-muted rounded-lg">
|
||||
<XCircle className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">오늘 발송</p>
|
||||
<p className="text-2xl font-bold text-foreground mt-1">{stats.todayCount}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-muted rounded-lg">
|
||||
<Calendar className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 검색 및 필터 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Search className="w-5 h-5" />
|
||||
<CardTitle>검색</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
>
|
||||
<Filter className="w-4 h-4 mr-2" />
|
||||
고급 필터
|
||||
{showFilters ? <ChevronUp className="w-4 h-4 ml-1" /> : <ChevronDown className="w-4 h-4 ml-1" />}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 기본 검색 */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
placeholder="제목 또는 받는사람으로 검색..."
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={handleSearch}>
|
||||
<Search className="w-4 h-4 mr-2" />
|
||||
검색
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 고급 필터 (접기/펼치기) */}
|
||||
{showFilters && (
|
||||
<div className="pt-4 border-t space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* 상태 필터 */}
|
||||
<div>
|
||||
<Label>발송 상태</Label>
|
||||
<Select value={filterStatus} onValueChange={(v: any) => {
|
||||
setFilterStatus(v);
|
||||
setPage(1);
|
||||
}}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
<SelectItem value="success">✓ 성공</SelectItem>
|
||||
<SelectItem value="failed">✗ 실패</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 계정 필터 */}
|
||||
<div>
|
||||
<Label>발송 계정</Label>
|
||||
<Select value={filterAccountId} onValueChange={(v) => {
|
||||
setFilterAccountId(v);
|
||||
setPage(1);
|
||||
}}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체 계정</SelectItem>
|
||||
{accounts.map((acc) => (
|
||||
<SelectItem key={acc.id} value={acc.id}>
|
||||
{acc.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 정렬 */}
|
||||
<div>
|
||||
<Label>정렬</Label>
|
||||
<Select value={`${sortBy}-${sortOrder}`} onValueChange={(v) => {
|
||||
const [by, order] = v.split('-');
|
||||
setSortBy(by as 'sentAt' | 'subject');
|
||||
setSortOrder(order as 'asc' | 'desc');
|
||||
setPage(1);
|
||||
}}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sentAt-desc">최신순</SelectItem>
|
||||
<SelectItem value="sentAt-asc">오래된순</SelectItem>
|
||||
<SelectItem value="subject-asc">제목 (가나다순)</SelectItem>
|
||||
<SelectItem value="subject-desc">제목 (역순)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필터 초기화 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSearchTerm("");
|
||||
setFilterStatus('all');
|
||||
setFilterAccountId('all');
|
||||
setSortBy('sentAt');
|
||||
setSortOrder('desc');
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
필터 초기화
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 메일 목록 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Mail className="w-5 h-5" />
|
||||
발송 이력 ({total}건)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
||||
</div>
|
||||
) : mails.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Inbox className="w-16 h-16 mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">발송 이력이 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{mails.map((mail) => (
|
||||
<div
|
||||
key={mail.id}
|
||||
className="p-4 border rounded-lg hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
{/* 메일 정보 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{/* 상태 배지 */}
|
||||
{mail.status === 'success' ? (
|
||||
<Badge variant="default" className="bg-green-100 text-green-700 hover:bg-green-100">
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||
발송 성공
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive">
|
||||
<XCircle className="w-3 h-3 mr-1" />
|
||||
발송 실패
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{/* 첨부파일 */}
|
||||
{mail.attachmentCount > 0 && (
|
||||
<Badge variant="outline">
|
||||
<Paperclip className="w-3 h-3 mr-1" />
|
||||
{mail.attachmentCount}개
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 제목 */}
|
||||
<h3 className="font-semibold text-foreground mb-1 truncate">
|
||||
{mail.subject || "(제목 없음)"}
|
||||
</h3>
|
||||
|
||||
{/* 수신자 및 날짜 */}
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<User className="w-3 h-3" />
|
||||
{Array.isArray(mail.to) ? mail.to.join(", ") : mail.to}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
{formatDate(mail.sentAt)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 실패 메시지 */}
|
||||
{mail.status === 'failed' && mail.errorMessage && (
|
||||
<div className="mt-2 text-sm text-red-600 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
{mail.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setSelectedMail(mail)}
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(mail.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 페이징 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-6 pt-6 border-t">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
페이지 {page} / {totalPages}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(Math.max(1, page - 1))}
|
||||
disabled={page === 1}
|
||||
>
|
||||
이전
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(Math.min(totalPages, page + 1))}
|
||||
disabled={page === totalPages}
|
||||
>
|
||||
다음
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 메일 상세 모달 */}
|
||||
{selectedMail && (
|
||||
<Dialog open={!!selectedMail} onOpenChange={() => setSelectedMail(null)}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Mail className="w-5 h-5" />
|
||||
발송 상세정보
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 상태 */}
|
||||
<div>
|
||||
{selectedMail.status === 'success' ? (
|
||||
<Badge variant="default" className="bg-green-100 text-green-700 hover:bg-green-100">
|
||||
<CheckCircle2 className="w-4 h-4 mr-1" />
|
||||
발송 성공
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive">
|
||||
<XCircle className="w-4 h-4 mr-1" />
|
||||
발송 실패
|
||||
</Badge>
|
||||
)}
|
||||
{selectedMail.status === 'failed' && selectedMail.errorMessage && (
|
||||
<p className="mt-2 text-sm text-red-600">{selectedMail.errorMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 발신 정보 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold text-foreground">발신 정보</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex">
|
||||
<span className="w-24 text-muted-foreground">보낸사람:</span>
|
||||
<span className="flex-1 font-medium">{selectedMail.from}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-24 text-muted-foreground">받는사람:</span>
|
||||
<span className="flex-1">
|
||||
{Array.isArray(selectedMail.to) ? selectedMail.to.join(", ") : selectedMail.to}
|
||||
</span>
|
||||
</div>
|
||||
{selectedMail.cc && selectedMail.cc.length > 0 && (
|
||||
<div className="flex">
|
||||
<span className="w-24 text-muted-foreground">참조:</span>
|
||||
<span className="flex-1">{selectedMail.cc.join(", ")}</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedMail.bcc && selectedMail.bcc.length > 0 && (
|
||||
<div className="flex">
|
||||
<span className="w-24 text-muted-foreground">숨은참조:</span>
|
||||
<span className="flex-1">{selectedMail.bcc.join(", ")}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex">
|
||||
<span className="w-24 text-muted-foreground">발송일시:</span>
|
||||
<span className="flex-1">{formatDate(selectedMail.sentAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 메일 내용 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold text-foreground">메일 내용</h3>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-2">제목</p>
|
||||
<p className="font-medium">{selectedMail.subject || "(제목 없음)"}</p>
|
||||
</div>
|
||||
{selectedMail.templateUsed && (
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-2">사용 템플릿</p>
|
||||
<Badge variant="outline">{selectedMail.templateUsed}</Badge>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-2">본문</p>
|
||||
<div
|
||||
className="p-4 border rounded-lg bg-muted/30 max-h-96 overflow-y-auto"
|
||||
dangerouslySetInnerHTML={{ __html: selectedMail.htmlBody || selectedMail.textBody || "" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 첨부파일 */}
|
||||
{selectedMail.attachments && selectedMail.attachments.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-semibold text-foreground flex items-center gap-2">
|
||||
<Paperclip className="w-4 h-4" />
|
||||
첨부파일 ({selectedMail.attachments.length}개)
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{selectedMail.attachments.map((att, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<File className="w-5 h-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{att.filename}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatFileSize(att.size || 0)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 수신 결과 (성공/실패 목록) */}
|
||||
{selectedMail.acceptedRecipients && selectedMail.acceptedRecipients.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-foreground text-sm">수신 성공</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedMail.acceptedRecipients.map((email, idx) => (
|
||||
<Badge key={idx} variant="default" className="bg-green-100 text-green-700 hover:bg-green-100">
|
||||
{email}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{selectedMail.rejectedRecipients && selectedMail.rejectedRecipients.length > 0 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-foreground text-sm">수신 실패</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedMail.rejectedRecipients.map((email, idx) => (
|
||||
<Badge key={idx} variant="destructive">
|
||||
{email}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,7 +3,10 @@
|
|||
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 { Plus, FileText, Loader2, RefreshCw, Search, ChevronRight } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
MailTemplate,
|
||||
getMailTemplates,
|
||||
|
|
@ -19,6 +22,7 @@ import MailTemplateEditorModal from "@/components/mail/MailTemplateEditorModal";
|
|||
import ConfirmDeleteModal from "@/components/mail/ConfirmDeleteModal";
|
||||
|
||||
export default function MailTemplatesPage() {
|
||||
const router = useRouter();
|
||||
const [templates, setTemplates] = useState<MailTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
|
@ -128,52 +132,69 @@ export default function MailTemplatesPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="w-full max-w-none px-4 py-8 space-y-8">
|
||||
{/* 페이지 제목 */}
|
||||
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">메일 템플릿 관리</h1>
|
||||
<p className="mt-2 text-gray-600">드래그 앤 드롭으로 메일 템플릿을 만들고 관리합니다</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadTemplates}
|
||||
disabled={loading}
|
||||
<div className="bg-card rounded-lg border p-6 space-y-4">
|
||||
{/* 브레드크럼브 */}
|
||||
<nav className="flex items-center gap-2 text-sm">
|
||||
<Link
|
||||
href="/admin/mail/dashboard"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleOpenCreateModal}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
새 템플릿 만들기
|
||||
</Button>
|
||||
메일 관리
|
||||
</Link>
|
||||
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-foreground font-medium">템플릿 관리</span>
|
||||
</nav>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 제목 + 액션 버튼들 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">메일 템플릿 관리</h1>
|
||||
<p className="mt-2 text-muted-foreground">드래그 앤 드롭으로 메일 템플릿을 만들고 관리합니다</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadTemplates}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
새로고침
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleOpenCreateModal}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
새 템플릿 만들기
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 검색 및 필터 */}
|
||||
<Card className="shadow-sm">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="템플릿 이름, 제목으로 검색..."
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
className="w-full pl-10 pr-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-background"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
className="px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-background"
|
||||
>
|
||||
<option value="all">전체 카테고리</option>
|
||||
{categories.map((cat) => (
|
||||
|
|
@ -188,24 +209,24 @@ export default function MailTemplatesPage() {
|
|||
|
||||
{/* 메인 컨텐츠 */}
|
||||
{loading ? (
|
||||
<Card className="shadow-sm">
|
||||
<Card>
|
||||
<CardContent className="flex justify-center items-center py-16">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : filteredTemplates.length === 0 ? (
|
||||
<Card className="text-center py-16 bg-white shadow-sm">
|
||||
<Card className="text-center py-16">
|
||||
<CardContent className="pt-6">
|
||||
<FileText className="w-16 h-16 mx-auto mb-4 text-gray-300" />
|
||||
<p className="text-gray-500 mb-4">
|
||||
<FileText className="w-16 h-16 mx-auto mb-4 text-muted-foreground" />
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{templates.length === 0
|
||||
? '아직 생성된 템플릿이 없습니다'
|
||||
: '검색 결과가 없습니다'}
|
||||
</p>
|
||||
{templates.length === 0 && (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleOpenCreateModal}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
첫 템플릿 만들기
|
||||
|
|
@ -229,28 +250,28 @@ export default function MailTemplatesPage() {
|
|||
)}
|
||||
|
||||
{/* 안내 정보 */}
|
||||
<Card className="bg-gradient-to-r from-orange-50 to-amber-50 border-orange-200 shadow-sm">
|
||||
<Card className="bg-muted/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center">
|
||||
<FileText className="w-5 h-5 mr-2 text-orange-500" />
|
||||
<FileText className="w-5 h-5 mr-2 text-foreground" />
|
||||
템플릿 디자이너
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-700 mb-4">
|
||||
<p className="text-foreground mb-4">
|
||||
💡 드래그 앤 드롭으로 손쉽게 메일 템플릿을 만들 수 있어요!
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm text-gray-600">
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li className="flex items-start">
|
||||
<span className="text-orange-500 mr-2">✓</span>
|
||||
<span className="text-foreground mr-2">✓</span>
|
||||
<span>텍스트, 버튼, 이미지, 여백 컴포넌트 지원</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-orange-500 mr-2">✓</span>
|
||||
<span className="text-foreground mr-2">✓</span>
|
||||
<span>실시간 미리보기로 즉시 확인 가능</span>
|
||||
</li>
|
||||
<li className="flex items-start">
|
||||
<span className="text-orange-500 mr-2">✓</span>
|
||||
<span className="text-foreground mr-2">✓</span>
|
||||
<span>동적 변수 지원 (예: {"{customer_name}"})</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export default function ConfirmDeleteModal({
|
|||
|
||||
{/* 내용 */}
|
||||
<div className="p-6 space-y-4">
|
||||
<p className="text-gray-700">{message}</p>
|
||||
<p className="text-foreground">{message}</p>
|
||||
{itemName && (
|
||||
<div className="bg-destructive/10 border border-destructive/20 rounded-lg p-3">
|
||||
<p className="text-sm font-medium text-red-800">
|
||||
|
|
@ -55,7 +55,7 @@ export default function ConfirmDeleteModal({
|
|||
</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-gray-500">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
이 작업은 되돌릴 수 없습니다. 계속하시겠습니까?
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ export default function MailAccountModal({
|
|||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white rounded-xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
{/* 헤더 */}
|
||||
<div className="sticky top-0 bg-gradient-to-r from-orange-500 to-orange-600 px-6 py-4 flex items-center justify-between">
|
||||
<div className="sticky top-0 bg-gradient-to-r from-primary to-primary px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Mail className="w-6 h-6 text-white" />
|
||||
<h2 className="text-xl font-bold text-white">
|
||||
|
|
@ -154,13 +154,13 @@ export default function MailAccountModal({
|
|||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
||||
<Mail className="w-5 h-5 text-orange-500" />
|
||||
<h3 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<Mail className="w-5 h-5 text-primary" />
|
||||
기본 정보
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
계정명 *
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -169,13 +169,13 @@ export default function MailAccountModal({
|
|||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
className="w-full px-4 py-2 border border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
|
||||
placeholder="예: 회사 공식 메일"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
발신 이메일 *
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -184,7 +184,7 @@ export default function MailAccountModal({
|
|||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
className="w-full px-4 py-2 border border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
|
||||
placeholder="info@company.com"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -192,14 +192,14 @@ export default function MailAccountModal({
|
|||
|
||||
{/* SMTP 설정 */}
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<h3 className="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
||||
<Server className="w-5 h-5 text-orange-500" />
|
||||
<h3 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<Server className="w-5 h-5 text-primary" />
|
||||
SMTP 서버 설정
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
SMTP 호스트 *
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -208,16 +208,16 @@ export default function MailAccountModal({
|
|||
value={formData.smtpHost}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
className="w-full px-4 py-2 border border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
|
||||
placeholder="smtp.gmail.com"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
예: smtp.gmail.com, smtp.naver.com, smtp.office365.com
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
SMTP 포트 *
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -226,16 +226,16 @@ export default function MailAccountModal({
|
|||
value={formData.smtpPort}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
className="w-full px-4 py-2 border border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
|
||||
placeholder="587"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
일반적으로 587 (TLS) 또는 465 (SSL)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
보안 연결
|
||||
</label>
|
||||
<select
|
||||
|
|
@ -247,7 +247,7 @@ export default function MailAccountModal({
|
|||
smtpSecure: e.target.value === 'true',
|
||||
}))
|
||||
}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
className="w-full px-4 py-2 border border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
|
||||
>
|
||||
<option value="false">TLS (포트 587)</option>
|
||||
<option value="true">SSL (포트 465)</option>
|
||||
|
|
@ -258,13 +258,13 @@ export default function MailAccountModal({
|
|||
|
||||
{/* 인증 정보 */}
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<h3 className="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
||||
<Lock className="w-5 h-5 text-orange-500" />
|
||||
<h3 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<Lock className="w-5 h-5 text-primary" />
|
||||
인증 정보
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
사용자명 *
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -273,16 +273,16 @@ export default function MailAccountModal({
|
|||
value={formData.smtpUsername}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
className="w-full px-4 py-2 border border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
|
||||
placeholder="info@company.com"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
대부분 이메일 주소와 동일
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
비밀번호 {mode === 'edit' && '(변경 시에만 입력)'}
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -291,10 +291,10 @@ export default function MailAccountModal({
|
|||
value={formData.smtpPassword}
|
||||
onChange={handleChange}
|
||||
required={mode === 'create'}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
className="w-full px-4 py-2 border border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
|
||||
placeholder={mode === 'edit' ? '변경하지 않으려면 비워두세요' : '••••••••'}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Gmail의 경우 앱 비밀번호 사용 권장
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -302,13 +302,13 @@ export default function MailAccountModal({
|
|||
|
||||
{/* 발송 제한 */}
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<h3 className="text-lg font-semibold text-gray-800 flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-orange-500" />
|
||||
<h3 className="text-lg font-semibold text-foreground flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-primary" />
|
||||
발송 설정
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
일일 발송 제한
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -316,10 +316,10 @@ export default function MailAccountModal({
|
|||
name="dailyLimit"
|
||||
value={formData.dailyLimit}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
className="w-full px-4 py-2 border border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary"
|
||||
placeholder="1000"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
하루에 발송 가능한 최대 메일 수 (0 = 제한 없음)
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -82,12 +82,12 @@ export default function MailAccountTable({
|
|||
|
||||
if (accounts.length === 0) {
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-gray-50 to-gray-100 rounded-xl p-12 text-center border-2 border-dashed border-gray-300">
|
||||
<div className="bg-gradient-to-br from-gray-50 to-gray-100 rounded-xl p-12 text-center border-2 border-dashed border">
|
||||
<Mail className="w-16 h-16 text-gray-400 mx-auto mb-4" />
|
||||
<p className="text-lg font-medium text-muted-foreground mb-2">
|
||||
등록된 메일 계정이 없습니다
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
"새 계정 추가" 버튼을 클릭하여 첫 번째 메일 계정을 등록하세요.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -104,22 +104,22 @@ export default function MailAccountTable({
|
|||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="계정명, 이메일, 서버로 검색..."
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500 transition-all"
|
||||
className="w-full pl-10 pr-4 py-3 border border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary transition-all"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div className="bg-white rounded-xl shadow-sm border border overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gradient-to-r from-slate-50 to-gray-50 border-b border-gray-200">
|
||||
<thead className="bg-gradient-to-r from-slate-50 to-gray-50 border-b border">
|
||||
<tr>
|
||||
<th
|
||||
className="px-6 py-4 text-left text-sm font-semibold text-gray-700 cursor-pointer hover:bg-gray-100 transition"
|
||||
className="px-6 py-4 text-left text-sm font-semibold text-foreground cursor-pointer hover:bg-muted transition"
|
||||
onClick={() => handleSort('name')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="w-4 h-4 text-orange-500" />
|
||||
<Mail className="w-4 h-4 text-primary" />
|
||||
계정명
|
||||
{sortField === 'name' && (
|
||||
<span className="text-xs">
|
||||
|
|
@ -129,39 +129,39 @@ export default function MailAccountTable({
|
|||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-4 text-left text-sm font-semibold text-gray-700 cursor-pointer hover:bg-gray-100 transition"
|
||||
className="px-6 py-4 text-left text-sm font-semibold text-foreground cursor-pointer hover:bg-muted transition"
|
||||
onClick={() => handleSort('email')}
|
||||
>
|
||||
이메일 주소
|
||||
</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold text-gray-700">
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold text-foreground">
|
||||
SMTP 서버
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-4 text-center text-sm font-semibold text-gray-700 cursor-pointer hover:bg-gray-100 transition"
|
||||
className="px-6 py-4 text-center text-sm font-semibold text-foreground cursor-pointer hover:bg-muted transition"
|
||||
onClick={() => handleSort('status')}
|
||||
>
|
||||
상태
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-4 text-center text-sm font-semibold text-gray-700 cursor-pointer hover:bg-gray-100 transition"
|
||||
className="px-6 py-4 text-center text-sm font-semibold text-foreground cursor-pointer hover:bg-muted transition"
|
||||
onClick={() => handleSort('dailyLimit')}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Zap className="w-4 h-4 text-orange-500" />
|
||||
<Zap className="w-4 h-4 text-primary" />
|
||||
일일 제한
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-4 text-center text-sm font-semibold text-gray-700 cursor-pointer hover:bg-gray-100 transition"
|
||||
className="px-6 py-4 text-center text-sm font-semibold text-foreground cursor-pointer hover:bg-muted transition"
|
||||
onClick={() => handleSort('createdAt')}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-orange-500" />
|
||||
<Calendar className="w-4 h-4 text-primary" />
|
||||
생성일
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-4 text-center text-sm font-semibold text-gray-700">
|
||||
<th className="px-6 py-4 text-center text-sm font-semibold text-foreground">
|
||||
액션
|
||||
</th>
|
||||
</tr>
|
||||
|
|
@ -173,7 +173,7 @@ export default function MailAccountTable({
|
|||
className="hover:bg-orange-50/50 transition-colors"
|
||||
>
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-medium text-gray-900">{account.name}</div>
|
||||
<div className="font-medium text-foreground">{account.name}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-sm text-muted-foreground">{account.email}</div>
|
||||
|
|
@ -192,7 +192,7 @@ export default function MailAccountTable({
|
|||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all hover:scale-105 ${
|
||||
account.status === 'active'
|
||||
? 'bg-green-100 text-green-700 hover:bg-green-200'
|
||||
: 'bg-gray-100 text-muted-foreground hover:bg-gray-200'
|
||||
: 'bg-muted text-muted-foreground hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{account.status === 'active' ? (
|
||||
|
|
@ -209,7 +209,7 @@ export default function MailAccountTable({
|
|||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-center">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{account.dailyLimit > 0
|
||||
? account.dailyLimit.toLocaleString()
|
||||
: '무제한'}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -17,8 +17,11 @@ import {
|
|||
Save,
|
||||
Plus,
|
||||
Trash2,
|
||||
Settings
|
||||
Settings,
|
||||
Upload,
|
||||
X
|
||||
} from "lucide-react";
|
||||
import { getMailTemplates } from "@/lib/api/mail";
|
||||
|
||||
export interface MailComponent {
|
||||
id: string;
|
||||
|
|
@ -60,13 +63,43 @@ export default function MailDesigner({
|
|||
const [templateName, setTemplateName] = useState("");
|
||||
const [subject, setSubject] = useState("");
|
||||
const [queries, setQueries] = useState<QueryConfig[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 템플릿 데이터 로드 (수정 모드)
|
||||
useEffect(() => {
|
||||
if (templateId) {
|
||||
loadTemplate(templateId);
|
||||
}
|
||||
}, [templateId]);
|
||||
|
||||
const loadTemplate = async (id: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const templates = await getMailTemplates();
|
||||
const template = templates.find(t => t.id === id);
|
||||
|
||||
if (template) {
|
||||
setTemplateName(template.name);
|
||||
setSubject(template.subject);
|
||||
setComponents(template.components || []);
|
||||
console.log('✅ 템플릿 로드 완료:', {
|
||||
name: template.name,
|
||||
components: template.components?.length || 0
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 템플릿 로드 실패:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 컴포넌트 타입 정의
|
||||
const componentTypes = [
|
||||
{ type: "text", icon: Type, label: "텍스트", color: "bg-primary/20 hover:bg-blue-200" },
|
||||
{ type: "button", icon: MousePointer, label: "버튼", color: "bg-green-100 hover:bg-green-200" },
|
||||
{ type: "image", icon: ImageIcon, label: "이미지", color: "bg-purple-100 hover:bg-purple-200" },
|
||||
{ type: "spacer", icon: Square, label: "여백", color: "bg-gray-100 hover:bg-gray-200" },
|
||||
{ type: "spacer", icon: Square, label: "여백", color: "bg-muted hover:bg-gray-200" },
|
||||
];
|
||||
|
||||
// 컴포넌트 추가
|
||||
|
|
@ -74,11 +107,11 @@ export default function MailDesigner({
|
|||
const newComponent: MailComponent = {
|
||||
id: `comp-${Date.now()}`,
|
||||
type: type as any,
|
||||
content: type === "text" ? "<p>텍스트를 입력하세요...</p>" : undefined,
|
||||
text: type === "button" ? "버튼" : undefined,
|
||||
url: type === "button" || type === "image" ? "https://example.com" : undefined,
|
||||
src: type === "image" ? "https://placehold.co/600x200/e5e7eb/64748b?text=Image" : undefined,
|
||||
height: type === "spacer" ? 20 : undefined,
|
||||
content: type === "text" ? "" : undefined, // 🎯 빈 문자열로 시작 (HTML 태그 제거)
|
||||
text: type === "button" ? "버튼 텍스트" : undefined, // 🎯 더 명확한 기본값
|
||||
url: type === "button" || type === "image" ? "" : undefined, // 🎯 빈 문자열로 시작
|
||||
src: type === "image" ? "https://placehold.co/600x200/e5e7eb/64748b?text=이미지를+업로드하세요" : undefined, // 🎯 한글 안내
|
||||
height: type === "spacer" ? 30 : undefined, // 🎯 기본값 30px로 증가 (더 적절한 간격)
|
||||
styles: {
|
||||
padding: "10px",
|
||||
backgroundColor: type === "button" ? "#007bff" : "transparent",
|
||||
|
|
@ -140,13 +173,25 @@ export default function MailDesigner({
|
|||
// 선택된 컴포넌트 가져오기
|
||||
const selected = components.find(c => c.id === selectedComponent);
|
||||
|
||||
// 로딩 중일 때
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen bg-muted/30">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-orange-500 mx-auto mb-4"></div>
|
||||
<p className="text-muted-foreground">템플릿을 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-50">
|
||||
<div className="flex h-screen bg-muted/30">
|
||||
{/* 왼쪽: 컴포넌트 팔레트 */}
|
||||
<div className="w-64 bg-white border-r p-4 space-y-4 overflow-y-auto">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-700 mb-3 flex items-center">
|
||||
<Mail className="w-4 h-4 mr-2 text-orange-500" />
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center">
|
||||
<Mail className="w-4 h-4 mr-2 text-primary" />
|
||||
컴포넌트
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
|
|
@ -155,7 +200,7 @@ export default function MailDesigner({
|
|||
key={type}
|
||||
onClick={() => addComponent(type)}
|
||||
variant="outline"
|
||||
className={`w-full justify-start ${color} border-gray-300`}
|
||||
className={`w-full justify-start ${color} border`}
|
||||
>
|
||||
<Icon className="w-4 h-4 mr-2" />
|
||||
{label}
|
||||
|
|
@ -211,10 +256,10 @@ export default function MailDesigner({
|
|||
{/* 중앙: 캔버스 */}
|
||||
<div className="flex-1 p-8 overflow-y-auto">
|
||||
<Card className="max-w-3xl mx-auto">
|
||||
<CardHeader className="bg-gradient-to-r from-orange-50 to-amber-50 border-b">
|
||||
<CardHeader className="bg-gradient-to-r from-muted to-muted border-b">
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span>메일 미리보기</span>
|
||||
<span className="text-sm text-gray-500 font-normal">
|
||||
<span className="text-sm text-muted-foreground font-normal">
|
||||
{components.length}개 컴포넌트
|
||||
</span>
|
||||
</CardTitle>
|
||||
|
|
@ -222,8 +267,8 @@ export default function MailDesigner({
|
|||
<CardContent className="p-0">
|
||||
{/* 제목 영역 */}
|
||||
{subject && (
|
||||
<div className="p-6 bg-gray-50 border-b">
|
||||
<p className="text-sm text-gray-500">제목:</p>
|
||||
<div className="p-6 bg-muted/30 border-b">
|
||||
<p className="text-sm text-muted-foreground">제목:</p>
|
||||
<p className="font-semibold text-lg">{subject}</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -292,8 +337,8 @@ export default function MailDesigner({
|
|||
{selected ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-semibold text-gray-700 flex items-center">
|
||||
<Settings className="w-4 h-4 mr-2 text-orange-500" />
|
||||
<h3 className="text-sm font-semibold text-foreground flex items-center">
|
||||
<Settings className="w-4 h-4 mr-2 text-primary" />
|
||||
속성 편집
|
||||
</h3>
|
||||
<Button
|
||||
|
|
@ -307,84 +352,234 @@ export default function MailDesigner({
|
|||
|
||||
{/* 텍스트 컴포넌트 */}
|
||||
{selected.type === "text" && (
|
||||
<div>
|
||||
<Label className="text-xs">내용 (HTML)</Label>
|
||||
<Textarea
|
||||
value={selected.content || ""}
|
||||
onChange={(e) =>
|
||||
updateComponent(selected.id, { content: e.target.value })
|
||||
}
|
||||
rows={8}
|
||||
className="mt-1 font-mono text-xs"
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||
내용
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
||||
메일에 표시될 텍스트를 입력하세요
|
||||
</p>
|
||||
<Textarea
|
||||
value={(() => {
|
||||
// 🎯 HTML 태그 자동 제거 (비개발자 친화적)
|
||||
const content = selected.content || "";
|
||||
const tmp = document.createElement("DIV");
|
||||
tmp.innerHTML = content;
|
||||
return tmp.textContent || tmp.innerText || "";
|
||||
})()}
|
||||
onChange={(e) =>
|
||||
updateComponent(selected.id, { content: e.target.value })
|
||||
}
|
||||
onFocus={(e) => {
|
||||
// 🎯 클릭 시 placeholder 같은 텍스트 자동 제거
|
||||
const currentValue = e.target.value.trim();
|
||||
if (currentValue === '텍스트를 입력하세요' ||
|
||||
currentValue === '텍스트를 입력하세요...') {
|
||||
e.target.value = '';
|
||||
updateComponent(selected.id, { content: '' });
|
||||
}
|
||||
}}
|
||||
rows={8}
|
||||
className="mt-1"
|
||||
placeholder="예) 안녕하세요! 특별한 소식을 전해드립니다..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 버튼 컴포넌트 */}
|
||||
{selected.type === "button" && (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-xs">버튼 텍스트</Label>
|
||||
<Label className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||
버튼 텍스트
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
||||
버튼에 표시될 글자를 입력하세요
|
||||
</p>
|
||||
<Input
|
||||
value={selected.text || ""}
|
||||
onChange={(e) =>
|
||||
updateComponent(selected.id, { text: e.target.value })
|
||||
}
|
||||
className="mt-1"
|
||||
placeholder="예) 자세히 보기, 지금 시작하기"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">링크 URL</Label>
|
||||
<Label className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||
연결할 주소
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
||||
버튼을 클릭하면 이동할 웹사이트 주소를 입력하세요
|
||||
</p>
|
||||
<Input
|
||||
value={selected.url || ""}
|
||||
onChange={(e) =>
|
||||
updateComponent(selected.id, { url: e.target.value })
|
||||
}
|
||||
className="mt-1"
|
||||
placeholder="예) https://www.example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">배경색</Label>
|
||||
<Input
|
||||
type="color"
|
||||
value={selected.styles?.backgroundColor || "#007bff"}
|
||||
onChange={(e) =>
|
||||
updateComponent(selected.id, {
|
||||
styles: { ...selected.styles, backgroundColor: e.target.value },
|
||||
})
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
<Label className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||
버튼 색상
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
||||
버튼의 배경색을 선택하세요
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
type="color"
|
||||
value={selected.styles?.backgroundColor || "#007bff"}
|
||||
onChange={(e) =>
|
||||
updateComponent(selected.id, {
|
||||
styles: { ...selected.styles, backgroundColor: e.target.value },
|
||||
})
|
||||
}
|
||||
className="w-16 h-10 cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{selected.styles?.backgroundColor || "#007bff"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 이미지 컴포넌트 */}
|
||||
{selected.type === "image" && (
|
||||
<div>
|
||||
<Label className="text-xs">이미지 URL</Label>
|
||||
<Input
|
||||
value={selected.src || ""}
|
||||
onChange={(e) =>
|
||||
updateComponent(selected.id, { src: e.target.value })
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||
이미지 선택
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
||||
컴퓨터에서 이미지 파일을 선택하세요
|
||||
</p>
|
||||
|
||||
{/* 파일 업로드 버튼 */}
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
input.onchange = (e: any) => {
|
||||
const file = e.target?.files?.[0];
|
||||
if (file) {
|
||||
// 파일을 Base64로 변환
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
updateComponent(selected.id, {
|
||||
src: event.target?.result as string
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}}
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
이미지 파일 선택
|
||||
</Button>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs">
|
||||
<span className="px-2 bg-white text-muted-foreground">또는</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* URL 입력 */}
|
||||
<div>
|
||||
<Label className="text-xs text-muted-foreground mb-1 block">
|
||||
이미지 웹 주소 입력
|
||||
</Label>
|
||||
<Input
|
||||
value={selected.src || ""}
|
||||
onChange={(e) =>
|
||||
updateComponent(selected.id, { src: e.target.value })
|
||||
}
|
||||
className="text-sm"
|
||||
placeholder="예) https://example.com/image.jpg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 미리보기 */}
|
||||
{selected.src && (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-xs text-muted-foreground">미리보기:</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => updateComponent(selected.id, { src: "" })}
|
||||
className="h-6 text-xs text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<X className="w-3 h-3 mr-1" />
|
||||
이미지 제거
|
||||
</Button>
|
||||
</div>
|
||||
<div className="border rounded-lg p-2 bg-muted/30">
|
||||
<img
|
||||
src={selected.src}
|
||||
alt="미리보기"
|
||||
className="w-full rounded"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src = 'https://placehold.co/600x200?text=이미지+로드+실패';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 여백 컴포넌트 */}
|
||||
{selected.type === "spacer" && (
|
||||
<div>
|
||||
<Label className="text-xs">높이 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={selected.height || 20}
|
||||
onChange={(e) =>
|
||||
updateComponent(selected.id, { height: parseInt(e.target.value) })
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-sm font-medium text-foreground flex items-center gap-2">
|
||||
여백 크기
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground mt-1 mb-2">
|
||||
요소 사이의 간격을 조절하세요
|
||||
</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
type="number"
|
||||
value={selected.height || 20}
|
||||
onChange={(e) =>
|
||||
updateComponent(selected.id, { height: parseInt(e.target.value) || 20 })
|
||||
}
|
||||
className="w-24"
|
||||
min="0"
|
||||
max="200"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">픽셀</span>
|
||||
</div>
|
||||
<div className="mt-3 p-3 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<p className="text-xs text-blue-800">
|
||||
<strong>추천값:</strong><br/>
|
||||
• 좁은 간격: 10~20 픽셀<br/>
|
||||
• 보통 간격: 30~50 픽셀<br/>
|
||||
• 넓은 간격: 60~100 픽셀
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -38,12 +38,17 @@ export default function MailDetailModal({
|
|||
const [mail, setMail] = useState<MailDetail | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showHtml, setShowHtml] = useState(true); // HTML/텍스트 토글
|
||||
const [imageBlobUrls, setImageBlobUrls] = useState<{ [key: number]: string }>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && mailId) {
|
||||
loadMailDetail();
|
||||
}
|
||||
|
||||
// 컴포넌트 언마운트 시 Blob URL 정리
|
||||
return () => {
|
||||
Object.values(imageBlobUrls).forEach((url) => URL.revokeObjectURL(url));
|
||||
};
|
||||
}, [isOpen, mailId]);
|
||||
|
||||
const loadMailDetail = async () => {
|
||||
|
|
@ -67,6 +72,15 @@ export default function MailDetailModal({
|
|||
await markMailAsRead(accountId, seqno);
|
||||
onMailRead?.(); // 목록 갱신
|
||||
}
|
||||
|
||||
// 이미지 첨부파일 자동 로드
|
||||
if (mailDetail.attachments) {
|
||||
mailDetail.attachments.forEach((attachment, index) => {
|
||||
if (attachment.contentType?.startsWith('image/')) {
|
||||
loadImageAttachment(index);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("메일 상세 조회 실패:", err);
|
||||
setError(
|
||||
|
|
@ -128,20 +142,77 @@ export default function MailDetailModal({
|
|||
});
|
||||
};
|
||||
|
||||
const loadImageAttachment = async (index: number) => {
|
||||
try {
|
||||
console.log(`🖼️ 이미지 로드 시작 - index: ${index}`);
|
||||
const seqno = parseInt(mailId.split("-").pop() || "0", 10);
|
||||
const token = localStorage.getItem("authToken");
|
||||
|
||||
console.log(`🔑 토큰 확인: ${token ? '있음' : '없음'}`);
|
||||
|
||||
// 🔧 임시: 백엔드 직접 호출 (프록시 우회)
|
||||
const backendUrl = process.env.NODE_ENV === 'production'
|
||||
? `http://39.117.244.52:8080/api/mail/receive/${accountId}/${seqno}/attachment/${index}`
|
||||
: `http://localhost:8080/api/mail/receive/${accountId}/${seqno}/attachment/${index}`;
|
||||
|
||||
console.log(`📍 요청 URL: ${backendUrl}`);
|
||||
|
||||
const response = await fetch(backendUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`📡 응답 상태: ${response.status}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
|
||||
console.log(`✅ Blob URL 생성 완료: ${blobUrl}`);
|
||||
|
||||
setImageBlobUrls((prev) => ({ ...prev, [index]: blobUrl }));
|
||||
} catch (err) {
|
||||
console.error(`❌ 이미지 로드 실패 (index ${index}):`, err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadAttachment = async (index: number, filename: string) => {
|
||||
try {
|
||||
const seqno = parseInt(mailId.split("-").pop() || "0", 10);
|
||||
const token = localStorage.getItem("authToken");
|
||||
|
||||
// 다운로드 URL
|
||||
const downloadUrl = `http://localhost:8080/api/mail/receive/${accountId}/${seqno}/attachment/${index}`;
|
||||
// 🔧 임시: 백엔드 직접 호출 (프록시 우회)
|
||||
const backendUrl = process.env.NODE_ENV === 'production'
|
||||
? `http://39.117.244.52:8080/api/mail/receive/${accountId}/${seqno}/attachment/${index}`
|
||||
: `http://localhost:8080/api/mail/receive/${accountId}/${seqno}/attachment/${index}`;
|
||||
|
||||
const response = await fetch(backendUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
// 다운로드 트리거
|
||||
const link = document.createElement('a');
|
||||
link.href = downloadUrl;
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// Blob URL 정리
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error('첨부파일 다운로드 실패:', err);
|
||||
alert('첨부파일 다운로드에 실패했습니다.');
|
||||
|
|
@ -152,22 +223,14 @@ export default function MailDetailModal({
|
|||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center justify-between pr-6">
|
||||
<span className="text-xl font-bold truncate">메일 상세</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-4"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
<DialogTitle className="text-xl font-bold truncate">
|
||||
메일 상세
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center py-16">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
<span className="ml-3 text-muted-foreground">메일을 불러오는 중...</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
|
|
@ -182,27 +245,27 @@ export default function MailDetailModal({
|
|||
<div className="flex-1 overflow-y-auto space-y-4">
|
||||
{/* 메일 헤더 */}
|
||||
<div className="border-b pb-4 space-y-2">
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
<h2 className="text-2xl font-bold text-foreground">
|
||||
{mail.subject}
|
||||
</h2>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1 text-sm">
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">보낸사람:</span>{" "}
|
||||
<span className="text-gray-900">{mail.from}</span>
|
||||
<span className="font-medium text-foreground">보낸사람:</span>{" "}
|
||||
<span className="text-foreground">{mail.from}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">받는사람:</span>{" "}
|
||||
<span className="font-medium text-foreground">받는사람:</span>{" "}
|
||||
<span className="text-muted-foreground">{mail.to}</span>
|
||||
</div>
|
||||
{mail.cc && (
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">참조:</span>{" "}
|
||||
<span className="font-medium text-foreground">참조:</span>{" "}
|
||||
<span className="text-muted-foreground">{mail.cc}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="font-medium text-gray-700">날짜:</span>{" "}
|
||||
<span className="font-medium text-foreground">날짜:</span>{" "}
|
||||
<span className="text-muted-foreground">
|
||||
{formatDate(mail.date)}
|
||||
</span>
|
||||
|
|
@ -223,81 +286,94 @@ export default function MailDetailModal({
|
|||
|
||||
{/* 첨부파일 */}
|
||||
{mail.attachments && mail.attachments.length > 0 && (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="bg-muted/30 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Paperclip className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="font-medium text-gray-700">
|
||||
<span className="font-medium text-foreground">
|
||||
첨부파일 ({mail.attachments.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{mail.attachments.map((attachment, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between bg-white rounded px-3 py-2 border hover:border-orange-300 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Paperclip className="w-4 h-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-900">
|
||||
{attachment.filename}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{formatFileSize(attachment.size)}
|
||||
</Badge>
|
||||
{mail.attachments.map((attachment, index) => {
|
||||
const isImage = attachment.contentType?.startsWith('image/');
|
||||
|
||||
return (
|
||||
<div key={index}>
|
||||
{/* 첨부파일 정보 */}
|
||||
<div className="flex items-center justify-between bg-card rounded px-3 py-2 border hover:border-primary/30 transition-colors">
|
||||
<div className="flex items-center gap-2">
|
||||
<Paperclip className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm text-foreground">
|
||||
{attachment.filename}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{formatFileSize(attachment.size)}
|
||||
</Badge>
|
||||
{isImage && (
|
||||
<Badge variant="outline" className="text-xs bg-blue-50 text-blue-700 border-blue-200">
|
||||
이미지
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDownloadAttachment(index, attachment.filename)}
|
||||
className="hover:bg-accent"
|
||||
>
|
||||
다운로드
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 이미지 미리보기 */}
|
||||
{isImage && (
|
||||
<div className="mt-2 border rounded-lg overflow-hidden bg-card p-4">
|
||||
{imageBlobUrls[index] ? (
|
||||
<img
|
||||
src={imageBlobUrls[index]}
|
||||
alt={attachment.filename}
|
||||
className="max-w-full h-auto max-h-96 mx-auto object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
||||
<Loader2 className="w-6 h-6 animate-spin mb-2" />
|
||||
<p className="text-sm">이미지 로딩 중...</p>
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={() => loadImageAttachment(index)}
|
||||
className="mt-2"
|
||||
>
|
||||
다시 시도
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDownloadAttachment(index, attachment.filename)}
|
||||
className="hover:bg-orange-50 hover:text-orange-600"
|
||||
>
|
||||
다운로드
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* HTML/텍스트 토글 */}
|
||||
{mail.htmlBody && mail.textBody && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={showHtml ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setShowHtml(true)}
|
||||
className={
|
||||
showHtml ? "bg-orange-500 hover:bg-orange-600" : ""
|
||||
}
|
||||
>
|
||||
HTML 보기
|
||||
</Button>
|
||||
<Button
|
||||
variant={!showHtml ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => setShowHtml(false)}
|
||||
className={
|
||||
!showHtml ? "bg-orange-500 hover:bg-orange-600" : ""
|
||||
}
|
||||
>
|
||||
텍스트 보기
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 메일 본문 */}
|
||||
<div className="border rounded-lg p-6 bg-white min-h-[300px]">
|
||||
{showHtml && mail.htmlBody ? (
|
||||
<div className="border rounded-lg p-6 bg-card min-h-[300px]">
|
||||
{mail.htmlBody ? (
|
||||
<div
|
||||
className="prose max-w-none"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: sanitizeHtml(mail.htmlBody),
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap font-sans text-sm text-gray-800">
|
||||
{mail.textBody || "본문 내용이 없습니다."}
|
||||
) : mail.textBody ? (
|
||||
<pre className="whitespace-pre-wrap font-sans text-sm text-foreground">
|
||||
{mail.textBody}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-center py-8">
|
||||
본문 내용이 없습니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -34,22 +34,22 @@ export default function MailTemplateCard({
|
|||
promotion: 'bg-purple-100 text-purple-700 border-purple-300',
|
||||
notification: 'bg-green-100 text-green-700 border-green-300',
|
||||
newsletter: 'bg-orange-100 text-orange-700 border-orange-300',
|
||||
system: 'bg-gray-100 text-gray-700 border-gray-300',
|
||||
system: 'bg-muted text-foreground border',
|
||||
};
|
||||
return colors[category || ''] || 'bg-gray-100 text-gray-700 border-gray-300';
|
||||
return colors[category || ''] || 'bg-muted text-foreground border';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group bg-white rounded-xl border border-gray-200 shadow-sm hover:shadow-lg transition-all duration-300 overflow-hidden">
|
||||
<div className="group bg-white rounded-xl border border shadow-sm hover:shadow-lg transition-all duration-300 overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
<div className="bg-gradient-to-r from-orange-50 to-amber-50 p-4 border-b border-gray-200">
|
||||
<div className="bg-gradient-to-r from-muted to-muted p-4 border-b border">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
<div className="p-2 bg-white rounded-lg shadow-sm">
|
||||
<Mail className="w-5 h-5 text-orange-500" />
|
||||
<Mail className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 truncate">
|
||||
<h3 className="font-semibold text-foreground truncate">
|
||||
{template.name}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground truncate mt-1">
|
||||
|
|
@ -71,8 +71,8 @@ export default function MailTemplateCard({
|
|||
|
||||
{/* 본문 미리보기 */}
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="bg-gray-50 rounded-lg p-3 border border-gray-200 min-h-[100px]">
|
||||
<p className="text-xs text-gray-500 mb-2">컴포넌트 {template.components.length}개</p>
|
||||
<div className="bg-muted/30 rounded-lg p-3 border border min-h-[100px]">
|
||||
<p className="text-xs text-muted-foreground mb-2">컴포넌트 {template.components.length}개</p>
|
||||
<div className="space-y-1">
|
||||
{template.components.slice(0, 3).map((component, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
|
|
@ -94,7 +94,7 @@ export default function MailTemplateCard({
|
|||
</div>
|
||||
|
||||
{/* 메타 정보 */}
|
||||
<div className="flex items-center gap-3 text-xs text-gray-500 pt-2 border-t">
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground pt-2 border-t">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
<span>{formatDate(template.createdAt)}</span>
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ export default function MailTemplateEditorModal({
|
|||
return (
|
||||
<div className="fixed inset-0 z-50 bg-white">
|
||||
{/* 헤더 */}
|
||||
<div className="sticky top-0 bg-gradient-to-r from-orange-500 to-orange-600 px-6 py-4 flex items-center justify-between shadow-lg z-10">
|
||||
<div className="sticky top-0 bg-gradient-to-r from-primary to-primary px-6 py-4 flex items-center justify-between shadow-lg z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-xl font-bold text-white">
|
||||
{mode === 'create' ? '새 메일 템플릿 만들기' : '메일 템플릿 수정'}
|
||||
|
|
|
|||
|
|
@ -32,23 +32,23 @@ export default function MailTemplatePreviewModal({
|
|||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div
|
||||
className={`bg-white rounded-xl shadow-2xl overflow-hidden transition-all ${
|
||||
className={`bg-card rounded-xl shadow-2xl overflow-hidden transition-all border ${
|
||||
isFullscreen ? 'w-full h-full' : 'max-w-6xl w-full max-h-[90vh]'
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="sticky top-0 bg-gradient-to-r from-orange-500 to-orange-600 px-6 py-4 flex items-center justify-between z-10">
|
||||
<div className="sticky top-0 bg-muted border-b px-6 py-4 flex items-center justify-between z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<Eye className="w-6 h-6 text-white" />
|
||||
<Eye className="w-6 h-6 text-foreground" />
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white">{template.name}</h2>
|
||||
<p className="text-sm text-orange-100">{template.subject}</p>
|
||||
<h2 className="text-xl font-bold text-foreground">{template.name}</h2>
|
||||
<p className="text-sm text-muted-foreground">{template.subject}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setViewMode(viewMode === 'preview' ? 'code' : 'preview')}
|
||||
className="text-white hover:bg-white/20 rounded-lg px-3 py-2 transition flex items-center gap-2"
|
||||
className="text-foreground hover:bg-background rounded-lg px-3 py-2 transition flex items-center gap-2"
|
||||
>
|
||||
{viewMode === 'preview' ? (
|
||||
<>
|
||||
|
|
@ -64,7 +64,7 @@ export default function MailTemplatePreviewModal({
|
|||
</button>
|
||||
<button
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
className="text-white hover:bg-white/20 rounded-lg p-2 transition"
|
||||
className="text-foreground hover:bg-background rounded-lg p-2 transition"
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize2 className="w-5 h-5" />
|
||||
|
|
@ -74,7 +74,7 @@ export default function MailTemplatePreviewModal({
|
|||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-white hover:bg-white/20 rounded-lg p-2 transition"
|
||||
className="text-foreground hover:bg-background rounded-lg p-2 transition"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
|
@ -85,15 +85,15 @@ export default function MailTemplatePreviewModal({
|
|||
<div className="flex h-full overflow-hidden">
|
||||
{/* 왼쪽: 변수 입력 (변수가 있을 때만) */}
|
||||
{templateVariables.length > 0 && (
|
||||
<div className="w-80 bg-gray-50 border-r border-gray-200 p-6 overflow-y-auto">
|
||||
<h3 className="text-lg font-semibold text-gray-800 mb-4 flex items-center gap-2">
|
||||
<Mail className="w-5 h-5 text-orange-500" />
|
||||
<div className="w-80 bg-muted/30 border-r p-6 overflow-y-auto">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-4 flex items-center gap-2">
|
||||
<Mail className="w-5 h-5 text-foreground" />
|
||||
템플릿 변수
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{templateVariables.map((variable) => (
|
||||
<div key={variable}>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
{variable}
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -101,13 +101,13 @@ export default function MailTemplatePreviewModal({
|
|||
value={variables[variable] || ''}
|
||||
onChange={(e) => handleVariableChange(variable, e.target.value)}
|
||||
placeholder={`{${variable}}`}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary bg-background"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 p-4 bg-accent border border-primary/20 rounded-lg">
|
||||
<p className="text-xs text-blue-800">
|
||||
<div className="mt-6 p-4 bg-muted border rounded-lg">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
💡 변수 값을 입력하면 미리보기에 반영됩니다.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -117,21 +117,21 @@ export default function MailTemplatePreviewModal({
|
|||
{/* 오른쪽: 미리보기 또는 코드 */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{viewMode === 'preview' ? (
|
||||
<div className="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
|
||||
<div className="bg-card border rounded-lg overflow-hidden">
|
||||
{/* 이메일 헤더 시뮬레이션 */}
|
||||
<div className="bg-gray-100 px-6 py-4 border-b border-gray-200">
|
||||
<div className="bg-muted px-6 py-4 border-b">
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex">
|
||||
<span className="font-semibold text-muted-foreground w-20">제목:</span>
|
||||
<span className="text-gray-900">{template.subject}</span>
|
||||
<span className="text-foreground">{template.subject}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-semibold text-muted-foreground w-20">발신:</span>
|
||||
<span className="text-gray-700">your-email@company.com</span>
|
||||
<span className="text-foreground">your-email@company.com</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="font-semibold text-muted-foreground w-20">수신:</span>
|
||||
<span className="text-gray-700">recipient@example.com</span>
|
||||
<span className="text-foreground">recipient@example.com</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -143,15 +143,15 @@ export default function MailTemplatePreviewModal({
|
|||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-gray-900 text-gray-100 p-6 rounded-lg font-mono text-sm overflow-x-auto">
|
||||
<pre className="whitespace-pre-wrap break-words">{renderedHtml}</pre>
|
||||
<div className="bg-muted/50 border p-6 rounded-lg font-mono text-sm overflow-x-auto">
|
||||
<pre className="whitespace-pre-wrap break-words text-foreground">{renderedHtml}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="sticky bottom-0 bg-gray-50 border-t border-gray-200 px-6 py-4 flex justify-end gap-3">
|
||||
<div className="sticky bottom-0 bg-muted/30 border-t px-6 py-4 flex justify-end gap-3">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
닫기
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -12,12 +12,12 @@ const getApiBaseUrl = (): string => {
|
|||
const currentHost = window.location.hostname;
|
||||
const currentPort = window.location.port;
|
||||
|
||||
// 로컬 개발환경: localhost:9771 또는 localhost:3000 → localhost:8080
|
||||
// 🎯 로컬 개발환경: Next.js 프록시 사용 (대용량 요청 안정성)
|
||||
if (
|
||||
(currentHost === "localhost" || currentHost === "127.0.0.1") &&
|
||||
(currentPort === "9771" || currentPort === "3000")
|
||||
) {
|
||||
return "http://localhost:8080/api";
|
||||
return "/api"; // 프록시 사용
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -70,12 +70,76 @@ export interface UpdateMailTemplateDto extends Partial<CreateMailTemplateDto> {}
|
|||
export interface SendMailDto {
|
||||
accountId: string;
|
||||
templateId?: string;
|
||||
to: string[]; // 수신자 이메일 배열
|
||||
to: string[]; // 받는 사람
|
||||
cc?: string[]; // 참조 (Carbon Copy)
|
||||
bcc?: string[]; // 숨은참조 (Blind Carbon Copy)
|
||||
subject: string;
|
||||
variables?: Record<string, string>; // 템플릿 변수 치환
|
||||
customHtml?: string; // 템플릿 없이 직접 HTML 작성 시
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 발송 이력 타입
|
||||
// ============================================
|
||||
|
||||
export interface AttachmentInfo {
|
||||
filename: string;
|
||||
originalName: string;
|
||||
size: number;
|
||||
path: string;
|
||||
mimetype: string;
|
||||
}
|
||||
|
||||
export interface SentMailHistory {
|
||||
id: string;
|
||||
accountId: string;
|
||||
accountName: string;
|
||||
accountEmail: string;
|
||||
to: string[];
|
||||
cc?: string[];
|
||||
bcc?: string[];
|
||||
subject: string;
|
||||
htmlContent: string;
|
||||
templateId?: string;
|
||||
templateName?: string;
|
||||
attachments?: AttachmentInfo[];
|
||||
sentAt: string;
|
||||
status: 'success' | 'failed';
|
||||
messageId?: string;
|
||||
errorMessage?: string;
|
||||
accepted?: string[];
|
||||
rejected?: string[];
|
||||
}
|
||||
|
||||
export interface SentMailListQuery {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
searchTerm?: string;
|
||||
status?: 'success' | 'failed' | 'all';
|
||||
accountId?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
sortBy?: 'sentAt' | 'subject';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface SentMailListResponse {
|
||||
items: SentMailHistory[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface MailStatistics {
|
||||
totalSent: number;
|
||||
successCount: number;
|
||||
failedCount: number;
|
||||
todayCount: number;
|
||||
thisMonthCount: number;
|
||||
successRate: number;
|
||||
}
|
||||
|
||||
export interface MailSendResult {
|
||||
success: boolean;
|
||||
messageId?: string;
|
||||
|
|
@ -96,7 +160,7 @@ async function fetchApi<T>(
|
|||
|
||||
try {
|
||||
const response = await apiClient({
|
||||
url: `/mail${endpoint}`,
|
||||
url: endpoint, // `/mail` 접두사 제거 (apiClient는 이미 /api를 포함)
|
||||
method,
|
||||
data,
|
||||
});
|
||||
|
|
@ -124,14 +188,14 @@ async function fetchApi<T>(
|
|||
* 전체 메일 계정 목록 조회
|
||||
*/
|
||||
export async function getMailAccounts(): Promise<MailAccount[]> {
|
||||
return fetchApi<MailAccount[]>('/accounts');
|
||||
return fetchApi<MailAccount[]>('/mail/accounts');
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 메일 계정 조회
|
||||
*/
|
||||
export async function getMailAccount(id: string): Promise<MailAccount> {
|
||||
return fetchApi<MailAccount>(`/accounts/${id}`);
|
||||
return fetchApi<MailAccount>(`/mail/accounts/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -140,7 +204,7 @@ export async function getMailAccount(id: string): Promise<MailAccount> {
|
|||
export async function createMailAccount(
|
||||
data: CreateMailAccountDto
|
||||
): Promise<MailAccount> {
|
||||
return fetchApi<MailAccount>('/accounts', {
|
||||
return fetchApi<MailAccount>('/mail/accounts', {
|
||||
method: 'POST',
|
||||
data,
|
||||
});
|
||||
|
|
@ -153,7 +217,7 @@ export async function updateMailAccount(
|
|||
id: string,
|
||||
data: UpdateMailAccountDto
|
||||
): Promise<MailAccount> {
|
||||
return fetchApi<MailAccount>(`/accounts/${id}`, {
|
||||
return fetchApi<MailAccount>(`/mail/accounts/${id}`, {
|
||||
method: 'PUT',
|
||||
data,
|
||||
});
|
||||
|
|
@ -163,7 +227,7 @@ export async function updateMailAccount(
|
|||
* 메일 계정 삭제
|
||||
*/
|
||||
export async function deleteMailAccount(id: string): Promise<{ success: boolean }> {
|
||||
return fetchApi<{ success: boolean }>(`/accounts/${id}`, {
|
||||
return fetchApi<{ success: boolean }>(`/mail/accounts/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
|
@ -172,7 +236,7 @@ export async function deleteMailAccount(id: string): Promise<{ success: boolean
|
|||
* SMTP 연결 테스트
|
||||
*/
|
||||
export async function testMailAccountConnection(id: string): Promise<{ success: boolean; message: string }> {
|
||||
return fetchApi<{ success: boolean; message: string }>(`/accounts/${id}/test-connection`, {
|
||||
return fetchApi<{ success: boolean; message: string }>(`/mail/accounts/${id}/test-connection`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
|
@ -185,7 +249,7 @@ export async function testMailConnection(id: string): Promise<{
|
|||
message: string;
|
||||
}> {
|
||||
return fetchApi<{ success: boolean; message: string }>(
|
||||
`/accounts/${id}/test-connection`,
|
||||
`/mail/accounts/${id}/test-connection`,
|
||||
{
|
||||
method: 'POST',
|
||||
}
|
||||
|
|
@ -200,14 +264,14 @@ export async function testMailConnection(id: string): Promise<{
|
|||
* 전체 메일 템플릿 목록 조회
|
||||
*/
|
||||
export async function getMailTemplates(): Promise<MailTemplate[]> {
|
||||
return fetchApi<MailTemplate[]>('/templates-file');
|
||||
return fetchApi<MailTemplate[]>('/mail/templates-file');
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 메일 템플릿 조회
|
||||
*/
|
||||
export async function getMailTemplate(id: string): Promise<MailTemplate> {
|
||||
return fetchApi<MailTemplate>(`/templates-file/${id}`);
|
||||
return fetchApi<MailTemplate>(`/mail/templates-file/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -216,7 +280,7 @@ export async function getMailTemplate(id: string): Promise<MailTemplate> {
|
|||
export async function createMailTemplate(
|
||||
data: CreateMailTemplateDto
|
||||
): Promise<MailTemplate> {
|
||||
return fetchApi<MailTemplate>('/templates-file', {
|
||||
return fetchApi<MailTemplate>('/mail/templates-file', {
|
||||
method: 'POST',
|
||||
data,
|
||||
});
|
||||
|
|
@ -229,7 +293,7 @@ export async function updateMailTemplate(
|
|||
id: string,
|
||||
data: UpdateMailTemplateDto
|
||||
): Promise<MailTemplate> {
|
||||
return fetchApi<MailTemplate>(`/templates-file/${id}`, {
|
||||
return fetchApi<MailTemplate>(`/mail/templates-file/${id}`, {
|
||||
method: 'PUT',
|
||||
data,
|
||||
});
|
||||
|
|
@ -239,7 +303,7 @@ export async function updateMailTemplate(
|
|||
* 메일 템플릿 삭제
|
||||
*/
|
||||
export async function deleteMailTemplate(id: string): Promise<{ success: boolean }> {
|
||||
return fetchApi<{ success: boolean }>(`/templates-file/${id}`, {
|
||||
return fetchApi<{ success: boolean }>(`/mail/templates-file/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
|
@ -251,7 +315,7 @@ export async function previewMailTemplate(
|
|||
id: string,
|
||||
sampleData?: Record<string, string>
|
||||
): Promise<{ html: string }> {
|
||||
return fetchApi<{ html: string }>(`/templates-file/${id}/preview`, {
|
||||
return fetchApi<{ html: string }>(`/mail/templates-file/${id}/preview`, {
|
||||
method: 'POST',
|
||||
data: { sampleData },
|
||||
});
|
||||
|
|
@ -265,7 +329,7 @@ export async function previewMailTemplate(
|
|||
* 메일 발송 (단건 또는 소규모 발송)
|
||||
*/
|
||||
export async function sendMail(data: SendMailDto): Promise<MailSendResult> {
|
||||
return fetchApi<MailSendResult>('/send/simple', {
|
||||
return fetchApi<MailSendResult>('/mail/send/simple', {
|
||||
method: 'POST',
|
||||
data,
|
||||
});
|
||||
|
|
@ -407,6 +471,15 @@ export async function getReceivedMails(
|
|||
return fetchApi<ReceivedMail[]>(`/mail/receive/${accountId}?limit=${limit}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 오늘 수신한 메일 수 조회 (통계)
|
||||
*/
|
||||
export async function getTodayReceivedCount(accountId?: string): Promise<number> {
|
||||
const params = accountId ? `?accountId=${accountId}` : '';
|
||||
const response = await fetchApi<{ count: number }>(`/mail/receive/today-count${params}`);
|
||||
return response.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 상세 조회
|
||||
*/
|
||||
|
|
@ -439,3 +512,52 @@ export async function testImapConnection(
|
|||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 발송 이력 API
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 발송 이력 목록 조회
|
||||
*/
|
||||
export async function getSentMailList(
|
||||
query: SentMailListQuery = {}
|
||||
): Promise<SentMailListResponse> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (query.page) params.append('page', query.page.toString());
|
||||
if (query.limit) params.append('limit', query.limit.toString());
|
||||
if (query.searchTerm) params.append('searchTerm', query.searchTerm);
|
||||
if (query.status && query.status !== 'all') params.append('status', query.status);
|
||||
if (query.accountId) params.append('accountId', query.accountId);
|
||||
if (query.startDate) params.append('startDate', query.startDate);
|
||||
if (query.endDate) params.append('endDate', query.endDate);
|
||||
if (query.sortBy) params.append('sortBy', query.sortBy);
|
||||
if (query.sortOrder) params.append('sortOrder', query.sortOrder);
|
||||
|
||||
return fetchApi(`/mail/sent?${params.toString()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 발송 이력 상세 조회
|
||||
*/
|
||||
export async function getSentMailById(id: string): Promise<SentMailHistory> {
|
||||
return fetchApi(`/mail/sent/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 발송 이력 삭제
|
||||
*/
|
||||
export async function deleteSentMail(id: string): Promise<{ success: boolean; message: string }> {
|
||||
return fetchApi(`/mail/sent/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 발송 통계 조회
|
||||
*/
|
||||
export async function getMailStatistics(accountId?: string): Promise<MailStatistics> {
|
||||
const params = accountId ? `?accountId=${accountId}` : '';
|
||||
return fetchApi(`/mail/sent/statistics${params}`);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue