메일관련된거 커밋

This commit is contained in:
leeheejin 2025-11-28 11:34:48 +09:00
parent ab734268a4
commit 8dcffa8927
33 changed files with 1244 additions and 812 deletions

View File

@ -1,18 +0,0 @@
{
"id": "1d997eeb-3d61-427d-8b54-119d4372b9b3",
"sentAt": "2025-10-22T07:13:30.905Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "Fwd: ㄴ",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">전달히야야양</p><p style=\"margin: 0 0 16px 0; line-height: 1.6;\"><br>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━<br>전달된 메일:</p><p style=\"margin: 0 0 16px 0; line-height: 1.6;\">보낸사람: \"이희진\" <zian9227@naver.com><br>날짜: 2025. 10. 22. 오후 12:58:15<br>제목: ㄴ<br>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</p><p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㄴㅇㄹㄴㅇㄹㄴㅇㄹ<br></p>\r\n </div>\r\n ",
"status": "success",
"messageId": "<d20cd501-04a4-bbe6-8b50-7f43e19bd70a@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -1,29 +0,0 @@
{
"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": []
}

View File

@ -1,41 +0,0 @@
{
"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": []
}

View File

@ -1,18 +0,0 @@
{
"id": "331d95d6-3a13-4657-bc75-ab0811712eb8",
"sentAt": "2025-10-22T07:18:18.240Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "ㅁㄴㅇㄹㅁㄴㅇㄹ",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹㅁㄴㅇㄹㄴㅇㄹㄴㅇㄹ</p>\r\n </div>\r\n ",
"status": "success",
"messageId": "<d4923c0d-f692-7d1d-d1b0-3b9e1e6cbab5@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

File diff suppressed because one or more lines are too long

View File

@ -1,41 +0,0 @@
{
"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

File diff suppressed because one or more lines are too long

View File

@ -1,18 +0,0 @@
{
"id": "84ee9619-49ff-4f61-a7fa-0bb0b0b7199a",
"sentAt": "2025-10-22T04:27:51.044Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"\"이희진\" <zian9227@naver.com>"
],
"subject": "Re: ㅅㄷㄴㅅ",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">야야야야야야야야ㅑㅇ야ㅑㅇ</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border-left: 3px solid #ccc; padding-left: 10px; margin-left: 10px; color: #666;\">\r\n <p><strong>보낸 사람:</strong> \"이희진\" <zian9227@naver.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:03:03</p>\r\n <p><strong>제목:</strong> ㅅㄷㄴㅅ</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\r\n ",
"status": "success",
"messageId": "<5fa451ff-7d29-7da4-ce56-ca7391c147af@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -1,18 +0,0 @@
{
"id": "89a32ace-f39b-44fa-b614-c65d96548f92",
"sentAt": "2025-10-22T03:49:48.461Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "Fwd: 기상청 API허브 회원가입 인증번호",
"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;\"><br> <br/><br/><br> <div style=\"border: 1px solid #ccc; padding: 15px; margin: 10px 0; background-color: #f9f9f9;\"><br> <p><strong>---------- 전달된 메시지 ----------</strong></p><br> <p><strong>보낸 사람:</strong> \"기상청 API허브\" <noreply@apihube.kma.go.kr></p><br> <p><strong>날짜:</strong> 2025. 10. 13. 오후 4:26:45</p><br> <p><strong>제목:</strong> 기상청 API허브 회원가입 인증번호</p><br> <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" /><br> undefined<br> </div><br> </p>\r\n </div>\r\n ",
"status": "success",
"messageId": "<9b36ce56-4ef1-cf0c-1f39-2c73bcb521da@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -1,29 +0,0 @@
{
"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": []
}

View File

@ -1,41 +0,0 @@
{
"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": []
}

View File

@ -1,41 +0,0 @@
{
"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": []
}

View File

@ -1,18 +0,0 @@
{
"id": "a638f7d0-ee31-47fa-9f72-de66ef31ea44",
"sentAt": "2025-10-22T07:21:13.723Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "ㄹㅇㄴㅁㄹㅇㄴㅁ",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㄹㅇㄴㅁㄹㅇㄴㅁㅇㄹㅇㄴㅁ</p>\r\n </div>\r\n ",
"status": "success",
"messageId": "<5ea07d02-78bf-a655-8289-bcbd8eaf7741@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -1,48 +0,0 @@
{
"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": []
}

View File

@ -1,41 +0,0 @@
{
"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": []
}

View File

@ -1,41 +0,0 @@
{
"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

View File

@ -1,27 +0,0 @@
{
"id": "e93848a8-6901-44c4-b4db-27c8d2aeb8dd",
"sentAt": "2025-10-22T04:28:42.686Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"\"권은아\" <chna8137s@gmail.com>"
],
"subject": "Re: 매우 졸린 오후예요",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">호홋 답장 기능을 구현했다죵<br>얼른 퇴근하고 싪네여</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border-left: 3px solid #ccc; padding-left: 10px; margin-left: 10px; color: #666;\">\r\n <p><strong>보낸 사람:</strong> \"권은아\" <chna8137s@gmail.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:10:37</p>\r\n <p><strong>제목:</strong> 매우 졸린 오후예요</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\r\n ",
"attachments": [
{
"filename": "test용 이미지2.png",
"originalName": "test용 이미지2.png",
"size": 0,
"path": "/app/uploads/mail-attachments/1761107318152-717716316.png",
"mimetype": "image/png"
}
],
"status": "success",
"messageId": "<19981423-259b-0a50-e76d-23c860692c16@wace.me>",
"accepted": [
"chna8137s@gmail.com"
],
"rejected": []
}

View File

@ -1,41 +0,0 @@
{
"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": []
}

View File

@ -1,29 +0,0 @@
{
"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": []
}

View File

@ -1,18 +0,0 @@
{
"id": "fcea6149-a098-4212-aa00-baef0cc083d6",
"sentAt": "2025-10-22T04:24:54.126Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"\"DHS\" <ddhhss0603@gmail.com>"
],
"subject": "Re: 안녕하세여",
"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 <br/><br/>\r\n <div style=\"border-left: 3px solid #ccc; padding-left: 10px; margin-left: 10px; color: #666;\">\r\n <p><strong>보낸 사람:</strong> \"DHS\" <ddhhss0603@gmail.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:09:49</p>\r\n <p><strong>제목:</strong> 안녕하세여</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\r\n ",
"status": "success",
"messageId": "<c24b04f0-b958-5e0b-4cc7-2bff30f23c2c@wace.me>",
"accepted": [
"ddhhss0603@gmail.com"
],
"rejected": []
}

View File

@ -334,9 +334,12 @@ class MailSendSimpleService {
if (variables) {
buttonText = this.replaceVariables(buttonText, variables);
}
// styles 객체 또는 직접 속성에서 색상 가져오기
const buttonBgColor = component.styles?.backgroundColor || component.backgroundColor || '#007bff';
const buttonTextColor = component.styles?.color || component.textColor || '#fff';
// 버튼은 왼쪽 정렬 (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>
<a href="${component.url || '#'}" style="display: inline-block; padding: 14px 28px; background-color: ${buttonBgColor}; color: ${buttonTextColor}; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 15px;">${buttonText}</a>
</div>`;
break;
case 'image':
@ -348,6 +351,89 @@ class MailSendSimpleService {
case 'spacer':
html += `<div style="height: ${component.height || '20px'};"></div>`;
break;
case 'header':
html += `
<div style="padding: 20px; background-color: ${component.headerBgColor || '#f8f9fa'}; border-radius: 8px; margin-bottom: 20px;">
<table style="width: 100%;">
<tr>
<td style="vertical-align: middle;">
${component.logoSrc ? `<img src="${component.logoSrc}" alt="로고" style="height: 40px; margin-right: 12px;">` : ''}
<span style="font-size: 18px; font-weight: bold;">${component.brandName || ''}</span>
</td>
<td style="text-align: right; color: #6b7280; font-size: 14px;">
${component.sendDate || ''}
</td>
</tr>
</table>
</div>
`;
break;
case 'infoTable':
html += `
<div style="border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; margin: 16px 0;">
${component.tableTitle ? `<div style="background-color: #f9fafb; padding: 12px 16px; font-weight: 600; border-bottom: 1px solid #e5e7eb;">${component.tableTitle}</div>` : ''}
<table style="width: 100%; border-collapse: collapse;">
${(component.rows || []).map((row: any, i: number) => `
<tr style="background-color: ${i % 2 === 0 ? '#ffffff' : '#f9fafb'};">
<td style="padding: 12px 16px; font-weight: 500; color: #4b5563; width: 35%; border-right: 1px solid #e5e7eb;">${row.label}</td>
<td style="padding: 12px 16px;">${row.value}</td>
</tr>
`).join('')}
</table>
</div>
`;
break;
case 'alertBox':
const alertColors: Record<string, { bg: string; border: string; text: string }> = {
info: { bg: '#eff6ff', border: '#3b82f6', text: '#1e40af' },
warning: { bg: '#fffbeb', border: '#f59e0b', text: '#92400e' },
danger: { bg: '#fef2f2', border: '#ef4444', text: '#991b1b' },
success: { bg: '#ecfdf5', border: '#10b981', text: '#065f46' }
};
const colors = alertColors[component.alertType || 'info'];
html += `
<div style="padding: 16px; background-color: ${colors.bg}; border-left: 4px solid ${colors.border}; border-radius: 4px; margin: 16px 0; color: ${colors.text};">
${component.alertTitle ? `<div style="font-weight: bold; margin-bottom: 8px;">${component.alertTitle}</div>` : ''}
<div>${component.content || ''}</div>
</div>
`;
break;
case 'divider':
html += `<hr style="border: none; border-top: ${component.height || 1}px solid #e5e7eb; margin: 20px 0;">`;
break;
case 'footer':
html += `
<div style="text-align: center; padding: 24px 16px; background-color: #f9fafb; border-top: 1px solid #e5e7eb; font-size: 14px; color: #6b7280;">
${component.companyName ? `<div style="font-weight: 600; color: #374151; margin-bottom: 8px;">${component.companyName}</div>` : ''}
${(component.ceoName || component.businessNumber) ? `
<div style="margin-bottom: 4px;">
${component.ceoName ? `대표: ${component.ceoName}` : ''}
${component.ceoName && component.businessNumber ? ' | ' : ''}
${component.businessNumber ? `사업자등록번호: ${component.businessNumber}` : ''}
</div>
` : ''}
${component.address ? `<div style="margin-bottom: 4px;">${component.address}</div>` : ''}
${(component.phone || component.email) ? `
<div style="margin-bottom: 4px;">
${component.phone ? `Tel: ${component.phone}` : ''}
${component.phone && component.email ? ' | ' : ''}
${component.email ? `Email: ${component.email}` : ''}
</div>
` : ''}
${component.copyright ? `<div style="margin-top: 12px; font-size: 12px; color: #9ca3af;">${component.copyright}</div>` : ''}
</div>
`;
break;
case 'numberedList':
html += `
<div style="padding: 16px;">
${component.listTitle ? `<div style="font-weight: 600; margin-bottom: 12px;">${component.listTitle}</div>` : ''}
<ol style="margin: 0; padding-left: 20px;">
${(component.listItems || []).map((item: string) => `<li style="margin-bottom: 8px;">${item}</li>`).join('')}
</ol>
</div>
`;
break;
}
});

View File

@ -4,13 +4,35 @@ import path from "path";
// MailComponent 인터페이스 정의
export interface MailComponent {
id: string;
type: "text" | "button" | "image" | "spacer";
type: "text" | "button" | "image" | "spacer" | "header" | "infoTable" | "alertBox" | "divider" | "footer" | "numberedList";
content?: string;
text?: string;
url?: string;
src?: string;
height?: number;
styles?: Record<string, string>;
// 헤더 컴포넌트용
logoSrc?: string;
brandName?: string;
sendDate?: string;
headerBgColor?: string;
// 정보 테이블용
rows?: Array<{ label: string; value: string }>;
tableTitle?: string;
// 강조 박스용
alertType?: "info" | "warning" | "danger" | "success";
alertTitle?: string;
// 푸터용
companyName?: string;
ceoName?: string;
businessNumber?: string;
address?: string;
phone?: string;
email?: string;
copyright?: string;
// 번호 리스트용
listItems?: string[];
listTitle?: string;
}
// QueryConfig 인터페이스 정의 (사용하지 않지만 타입 호환성 유지)
@ -236,6 +258,89 @@ class MailTemplateFileService {
case "spacer":
html += `<div style="height: ${comp.height || 20}px;"></div>`;
break;
case "header":
html += `
<div style="padding: 20px; background-color: ${comp.headerBgColor || '#f8f9fa'}; border-radius: 8px; margin-bottom: 20px;">
<table style="width: 100%;">
<tr>
<td style="vertical-align: middle;">
${comp.logoSrc ? `<img src="${comp.logoSrc}" alt="로고" style="height: 40px; margin-right: 12px;">` : ''}
<span style="font-size: 18px; font-weight: bold;">${comp.brandName || ''}</span>
</td>
<td style="text-align: right; color: #6b7280; font-size: 14px;">
${comp.sendDate || ''}
</td>
</tr>
</table>
</div>
`;
break;
case "infoTable":
html += `
<div style="border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; margin: 16px 0;">
${comp.tableTitle ? `<div style="background-color: #f9fafb; padding: 12px 16px; font-weight: 600; border-bottom: 1px solid #e5e7eb;">${comp.tableTitle}</div>` : ''}
<table style="width: 100%; border-collapse: collapse;">
${(comp.rows || []).map((row, i) => `
<tr style="background-color: ${i % 2 === 0 ? '#ffffff' : '#f9fafb'};">
<td style="padding: 12px 16px; font-weight: 500; color: #4b5563; width: 35%; border-right: 1px solid #e5e7eb;">${row.label}</td>
<td style="padding: 12px 16px;">${row.value}</td>
</tr>
`).join('')}
</table>
</div>
`;
break;
case "alertBox":
const alertColors: Record<string, { bg: string; border: string; text: string }> = {
info: { bg: '#eff6ff', border: '#3b82f6', text: '#1e40af' },
warning: { bg: '#fffbeb', border: '#f59e0b', text: '#92400e' },
danger: { bg: '#fef2f2', border: '#ef4444', text: '#991b1b' },
success: { bg: '#ecfdf5', border: '#10b981', text: '#065f46' }
};
const colors = alertColors[comp.alertType || 'info'];
html += `
<div style="padding: 16px; background-color: ${colors.bg}; border-left: 4px solid ${colors.border}; border-radius: 4px; margin: 16px 0; color: ${colors.text};">
${comp.alertTitle ? `<div style="font-weight: bold; margin-bottom: 8px;">${comp.alertTitle}</div>` : ''}
<div>${comp.content || ''}</div>
</div>
`;
break;
case "divider":
html += `<hr style="border: none; border-top: ${comp.height || 1}px solid #e5e7eb; margin: 20px 0;">`;
break;
case "footer":
html += `
<div style="text-align: center; padding: 24px 16px; background-color: #f9fafb; border-top: 1px solid #e5e7eb; font-size: 14px; color: #6b7280;">
${comp.companyName ? `<div style="font-weight: 600; color: #374151; margin-bottom: 8px;">${comp.companyName}</div>` : ''}
${(comp.ceoName || comp.businessNumber) ? `
<div style="margin-bottom: 4px;">
${comp.ceoName ? `대표: ${comp.ceoName}` : ''}
${comp.ceoName && comp.businessNumber ? ' | ' : ''}
${comp.businessNumber ? `사업자등록번호: ${comp.businessNumber}` : ''}
</div>
` : ''}
${comp.address ? `<div style="margin-bottom: 4px;">${comp.address}</div>` : ''}
${(comp.phone || comp.email) ? `
<div style="margin-bottom: 4px;">
${comp.phone ? `Tel: ${comp.phone}` : ''}
${comp.phone && comp.email ? ' | ' : ''}
${comp.email ? `Email: ${comp.email}` : ''}
</div>
` : ''}
${comp.copyright ? `<div style="margin-top: 12px; font-size: 12px; color: #9ca3af;">${comp.copyright}</div>` : ''}
</div>
`;
break;
case "numberedList":
html += `
<div style="padding: 16px; ${styles}">
${comp.listTitle ? `<div style="font-weight: 600; margin-bottom: 12px;">${comp.listTitle}</div>` : ''}
<ol style="margin: 0; padding-left: 20px;">
${(comp.listItems || []).map(item => `<li style="margin-bottom: 8px;">${item}</li>`).join('')}
</ol>
</div>
`;
break;
}
});

View File

@ -70,12 +70,13 @@ export class ScreenManagementService {
throw new Error("이미 존재하는 화면 코드입니다.");
}
// 화면 생성 (Raw Query)
// 화면 생성 (Raw Query) - REST API 지원 추가
const [screen] = await query<any>(
`INSERT INTO screen_definitions (
screen_name, screen_code, table_name, company_code, description, created_by,
db_source_type, db_connection_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
db_source_type, db_connection_id, data_source_type, rest_api_connection_id,
rest_api_endpoint, rest_api_json_path
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING *`,
[
screenData.screenName,
@ -86,6 +87,10 @@ export class ScreenManagementService {
screenData.createdBy,
screenData.dbSourceType || "internal",
screenData.dbConnectionId || null,
(screenData as any).dataSourceType || "database",
(screenData as any).restApiConnectionId || null,
(screenData as any).restApiEndpoint || null,
(screenData as any).restApiJsonPath || "data",
]
);
@ -1977,6 +1982,11 @@ export class ScreenManagementService {
updatedBy: data.updated_by,
dbSourceType: data.db_source_type || "internal",
dbConnectionId: data.db_connection_id || undefined,
// REST API 관련 필드
dataSourceType: data.data_source_type || "database",
restApiConnectionId: data.rest_api_connection_id || undefined,
restApiEndpoint: data.rest_api_endpoint || undefined,
restApiJsonPath: data.rest_api_json_path || "data",
};
}

View File

@ -154,6 +154,11 @@ export interface ScreenDefinition {
updatedBy?: string;
dbSourceType?: "internal" | "external";
dbConnectionId?: number;
// REST API 관련 필드
dataSourceType?: "database" | "restapi";
restApiConnectionId?: number;
restApiEndpoint?: string;
restApiJsonPath?: string;
}
// 화면 생성 요청
@ -166,6 +171,11 @@ export interface CreateScreenRequest {
createdBy?: string;
dbSourceType?: "internal" | "external";
dbConnectionId?: number;
// REST API 관련 필드
dataSourceType?: "database" | "restapi";
restApiConnectionId?: number;
restApiEndpoint?: string;
restApiJsonPath?: string;
}
// 화면 수정 요청

View File

@ -45,6 +45,7 @@ import {
saveDraft,
updateDraft,
} from "@/lib/api/mail";
import { API_BASE_URL } from "@/lib/api/client";
import { useToast } from "@/hooks/use-toast";
export default function MailSendPage() {
@ -498,7 +499,7 @@ ${data.originalBody}`;
throw new Error("인증 토큰이 없습니다. 다시 로그인해주세요.");
}
const response = await fetch("/api/mail/send/simple", {
const response = await fetch(`${API_BASE_URL}/mail/send/simple`, {
method: "POST",
headers: {
Authorization: `Bearer ${authToken}`,
@ -1226,6 +1227,91 @@ ${data.originalBody}`;
</div>
);
case 'header':
return (
<div key={component.id} className="p-4 rounded-lg" style={{ backgroundColor: component.headerBgColor || '#f8f9fa' }}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{component.logoSrc && <img src={component.logoSrc} alt="로고" className="h-10" />}
<span className="font-bold text-lg">{component.brandName}</span>
</div>
<span className="text-sm text-gray-500">{component.sendDate}</span>
</div>
</div>
);
case 'infoTable':
return (
<div key={component.id} className="border rounded-lg overflow-hidden">
{component.tableTitle && (
<div className="bg-gray-50 px-4 py-2 font-semibold border-b">{component.tableTitle}</div>
)}
<table className="w-full">
<tbody>
{component.rows?.map((row: any, i: number) => (
<tr key={i} className={i % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className="px-4 py-2 font-medium text-gray-600 w-1/3 border-r">{row.label}</td>
<td className="px-4 py-2">{row.value}</td>
</tr>
))}
</tbody>
</table>
</div>
);
case 'alertBox':
return (
<div key={component.id} className={`p-4 rounded-lg border-l-4 ${
component.alertType === 'info' ? 'bg-blue-50 border-blue-500 text-blue-800' :
component.alertType === 'warning' ? 'bg-amber-50 border-amber-500 text-amber-800' :
component.alertType === 'danger' ? 'bg-red-50 border-red-500 text-red-800' :
'bg-emerald-50 border-emerald-500 text-emerald-800'
}`}>
{component.alertTitle && <div className="font-bold mb-1">{component.alertTitle}</div>}
<div>{component.content}</div>
</div>
);
case 'divider':
return (
<hr key={component.id} className="border-gray-300" style={{ borderWidth: `${component.height || 1}px` }} />
);
case 'footer':
return (
<div key={component.id} className="text-center text-sm text-gray-500 py-4 border-t bg-gray-50">
{component.companyName && <div className="font-semibold text-gray-700">{component.companyName}</div>}
{(component.ceoName || component.businessNumber) && (
<div className="mt-1">
{component.ceoName && <span>: {component.ceoName}</span>}
{component.ceoName && component.businessNumber && <span className="mx-2">|</span>}
{component.businessNumber && <span>: {component.businessNumber}</span>}
</div>
)}
{component.address && <div className="mt-1">{component.address}</div>}
{(component.phone || component.email) && (
<div className="mt-1">
{component.phone && <span>Tel: {component.phone}</span>}
{component.phone && component.email && <span className="mx-2">|</span>}
{component.email && <span>Email: {component.email}</span>}
</div>
)}
{component.copyright && <div className="mt-2 text-xs text-gray-400">{component.copyright}</div>}
</div>
);
case 'numberedList':
return (
<div key={component.id} className="p-4">
{component.listTitle && <div className="font-semibold mb-2">{component.listTitle}</div>}
<ol className="list-decimal list-inside space-y-1">
{component.listItems?.map((item: string, i: number) => (
<li key={i}>{item}</li>
))}
</ol>
</div>
);
default:
return null;

View File

@ -19,19 +19,50 @@ import {
Trash2,
Settings,
Upload,
X
X,
GripVertical,
ChevronUp,
ChevronDown,
LayoutTemplate,
Table2,
AlertCircle,
Minus,
Building2,
ListOrdered
} from "lucide-react";
import { getMailTemplates } from "@/lib/api/mail";
export interface MailComponent {
id: string;
type: "text" | "button" | "image" | "spacer" | "table";
type: "text" | "button" | "image" | "spacer" | "table" | "header" | "infoTable" | "alertBox" | "divider" | "footer" | "numberedList";
content?: string;
text?: string;
url?: string;
src?: string;
height?: number;
styles?: Record<string, string>;
// 헤더 컴포넌트용
logoSrc?: string;
brandName?: string;
sendDate?: string;
headerBgColor?: string;
// 정보 테이블용
rows?: Array<{ label: string; value: string }>;
tableTitle?: string;
// 강조 박스용
alertType?: "info" | "warning" | "danger" | "success";
alertTitle?: string;
// 푸터용
companyName?: string;
ceoName?: string;
businessNumber?: string;
address?: string;
phone?: string;
email?: string;
copyright?: string;
// 번호 리스트용
listItems?: string[];
listTitle?: string;
}
export interface QueryConfig {
@ -64,6 +95,10 @@ export default function MailDesigner({
const [subject, setSubject] = useState("");
const [queries, setQueries] = useState<QueryConfig[]>([]);
const [isLoading, setIsLoading] = useState(false);
// 드래그 앤 드롭 상태
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
// 템플릿 데이터 로드 (수정 모드)
useEffect(() => {
@ -96,10 +131,18 @@ export default function MailDesigner({
// 컴포넌트 타입 정의
const componentTypes = [
{ type: "text", icon: Type, label: "텍스트", color: "bg-primary/20 hover:bg-blue-200" },
{ type: "button", icon: MousePointer, label: "버튼", color: "bg-success/20 hover:bg-success/30" },
{ type: "image", icon: ImageIcon, label: "이미지", color: "bg-purple-100 hover:bg-purple-200" },
{ type: "spacer", icon: Square, label: "여백", color: "bg-muted hover:bg-muted/80" },
// 레이아웃 컴포넌트
{ type: "header", icon: LayoutTemplate, label: "헤더", color: "bg-indigo-100 hover:bg-indigo-200", category: "layout" },
{ type: "divider", icon: Minus, label: "구분선", color: "bg-gray-100 hover:bg-gray-200", category: "layout" },
{ type: "spacer", icon: Square, label: "여백", color: "bg-muted hover:bg-muted/80", category: "layout" },
{ type: "footer", icon: Building2, label: "푸터", color: "bg-slate-100 hover:bg-slate-200", category: "layout" },
// 컨텐츠 컴포넌트
{ type: "text", icon: Type, label: "텍스트", color: "bg-primary/20 hover:bg-blue-200", category: "content" },
{ type: "button", icon: MousePointer, label: "버튼", color: "bg-success/20 hover:bg-success/30", category: "content" },
{ type: "image", icon: ImageIcon, label: "이미지", color: "bg-purple-100 hover:bg-purple-200", category: "content" },
{ type: "infoTable", icon: Table2, label: "정보 테이블", color: "bg-cyan-100 hover:bg-cyan-200", category: "content" },
{ type: "alertBox", icon: AlertCircle, label: "안내 박스", color: "bg-amber-100 hover:bg-amber-200", category: "content" },
{ type: "numberedList", icon: ListOrdered, label: "번호 리스트", color: "bg-emerald-100 hover:bg-emerald-200", category: "content" },
];
// 컴포넌트 추가
@ -107,21 +150,75 @@ export default function MailDesigner({
const newComponent: MailComponent = {
id: `comp-${Date.now()}`,
type: type as any,
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로 증가 (더 적절한 간격)
content: type === "text" ? "" : undefined,
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 : type === "divider" ? 1 : undefined,
styles: {
padding: "10px",
padding: type === "divider" ? "0" : "10px",
backgroundColor: type === "button" ? "#007bff" : "transparent",
color: type === "button" ? "#fff" : "#333",
},
// 헤더 기본값
logoSrc: type === "header" ? "" : undefined,
brandName: type === "header" ? "회사명" : undefined,
sendDate: type === "header" ? new Date().toLocaleDateString("ko-KR") : undefined,
headerBgColor: type === "header" ? "#f8f9fa" : undefined,
// 정보 테이블 기본값
rows: type === "infoTable" ? [{ label: "항목", value: "내용" }] : undefined,
tableTitle: type === "infoTable" ? "" : undefined,
// 안내 박스 기본값
alertType: type === "alertBox" ? "info" : undefined,
alertTitle: type === "alertBox" ? "안내" : undefined,
// 푸터 기본값
companyName: type === "footer" ? "회사명" : undefined,
ceoName: type === "footer" ? "" : undefined,
businessNumber: type === "footer" ? "" : undefined,
address: type === "footer" ? "" : undefined,
phone: type === "footer" ? "" : undefined,
email: type === "footer" ? "" : undefined,
copyright: type === "footer" ? `© ${new Date().getFullYear()} All rights reserved.` : undefined,
// 번호 리스트 기본값
listItems: type === "numberedList" ? ["첫 번째 항목"] : undefined,
listTitle: type === "numberedList" ? "" : undefined,
};
setComponents([...components, newComponent]);
};
// 드래그 앤 드롭 핸들러
const handleDragStart = (index: number) => {
setDraggedIndex(index);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
if (draggedIndex !== null && draggedIndex !== index) {
setDragOverIndex(index);
}
};
const handleDrop = (index: number) => {
if (draggedIndex !== null && draggedIndex !== index) {
moveComponent(draggedIndex, index);
}
setDraggedIndex(null);
setDragOverIndex(null);
};
const handleDragEnd = () => {
setDraggedIndex(null);
setDragOverIndex(null);
};
const moveComponent = (fromIndex: number, toIndex: number) => {
const newComponents = [...components];
const [movedItem] = newComponents.splice(fromIndex, 1);
newComponents.splice(toIndex, 0, movedItem);
setComponents(newComponents);
};
// 컴포넌트 삭제
const removeComponent = (id: string) => {
setComponents(components.filter(c => c.id !== id));
@ -189,13 +286,35 @@ export default function MailDesigner({
<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-foreground mb-3 flex items-center">
<LayoutTemplate className="w-4 h-4 mr-2 text-indigo-500" />
</h3>
<div className="space-y-2">
{componentTypes.filter(c => c.category === "layout").map(({ type, icon: Icon, label, color }) => (
<Button
key={type}
onClick={() => addComponent(type)}
variant="outline"
className={`w-full justify-start ${color} border`}
>
<Icon className="w-4 h-4 mr-2" />
{label}
</Button>
))}
</div>
</div>
{/* 컨텐츠 컴포넌트 */}
<div>
<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">
{componentTypes.map(({ type, icon: Icon, label, color }) => (
{componentTypes.filter(c => c.category === "content").map(({ type, icon: Icon, label, color }) => (
<Button
key={type}
onClick={() => addComponent(type)}
@ -274,24 +393,57 @@ export default function MailDesigner({
)}
{/* 컴포넌트 렌더링 */}
<div className="p-6 space-y-4">
<div className="p-6 pl-14 space-y-4">
{components.length === 0 ? (
<div className="text-center py-16 text-muted-foreground/50">
<Mail className="w-16 h-16 mx-auto mb-4 opacity-20" />
<p> </p>
</div>
) : (
components.map((comp) => (
components.map((comp, index) => (
<div
key={comp.id}
draggable
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={() => handleDrop(index)}
onDragEnd={handleDragEnd}
onClick={() => selectComponent(comp.id)}
className={`relative group cursor-pointer rounded-lg transition-all ${
selectedComponent === comp.id
? "ring-2 ring-orange-500 bg-orange-50/30"
: "hover:ring-2 hover:ring-gray-300"
} ${draggedIndex === index ? "opacity-50 scale-95" : ""} ${
dragOverIndex === index ? "ring-2 ring-primary ring-dashed bg-primary/10" : ""
}`}
style={comp.styles}
>
{/* 드래그 핸들 & 순서 이동 버튼 */}
<div className="absolute -left-10 top-1/2 -translate-y-1/2 flex flex-col items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => { e.stopPropagation(); if (index > 0) moveComponent(index, index - 1); }}
className="p-1 hover:bg-gray-200 rounded disabled:opacity-30"
disabled={index === 0}
>
<ChevronUp className="w-3 h-3" />
</button>
<div className="cursor-grab active:cursor-grabbing p-1 hover:bg-gray-200 rounded">
<GripVertical className="w-4 h-4 text-gray-400" />
</div>
<button
onClick={(e) => { e.stopPropagation(); if (index < components.length - 1) moveComponent(index, index + 1); }}
className="p-1 hover:bg-gray-200 rounded disabled:opacity-30"
disabled={index === components.length - 1}
>
<ChevronDown className="w-3 h-3" />
</button>
</div>
{/* 순서 배지 */}
<div className="absolute -left-10 top-0 text-xs text-gray-400 opacity-0 group-hover:opacity-100">
{index + 1}
</div>
{/* 삭제 버튼 */}
<button
onClick={(e) => {
@ -322,7 +474,82 @@ export default function MailDesigner({
<img src={comp.src} alt="메일 이미지" className="w-full rounded" />
)}
{comp.type === "spacer" && (
<div style={{ height: `${comp.height}px` }} />
<div style={{ height: `${comp.height}px` }} className="bg-gray-100 rounded flex items-center justify-center text-xs text-gray-400">
{comp.height}px
</div>
)}
{comp.type === "header" && (
<div className="p-4 rounded-lg" style={{ backgroundColor: comp.headerBgColor || "#f8f9fa" }}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{comp.logoSrc && <img src={comp.logoSrc} alt="로고" className="h-10" />}
<span className="font-bold text-lg">{comp.brandName}</span>
</div>
<span className="text-sm text-gray-500">{comp.sendDate}</span>
</div>
</div>
)}
{comp.type === "infoTable" && (
<div className="border rounded-lg overflow-hidden">
{comp.tableTitle && (
<div className="bg-gray-50 px-4 py-2 font-semibold border-b">{comp.tableTitle}</div>
)}
<table className="w-full">
<tbody>
{comp.rows?.map((row, i) => (
<tr key={i} className={i % 2 === 0 ? "bg-white" : "bg-gray-50"}>
<td className="px-4 py-2 font-medium text-gray-600 w-1/3 border-r">{row.label}</td>
<td className="px-4 py-2">{row.value}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{comp.type === "alertBox" && (
<div className={`p-4 rounded-lg border-l-4 ${
comp.alertType === "info" ? "bg-blue-50 border-blue-500 text-blue-800" :
comp.alertType === "warning" ? "bg-amber-50 border-amber-500 text-amber-800" :
comp.alertType === "danger" ? "bg-red-50 border-red-500 text-red-800" :
"bg-emerald-50 border-emerald-500 text-emerald-800"
}`}>
{comp.alertTitle && <div className="font-bold mb-1">{comp.alertTitle}</div>}
<div>{comp.content}</div>
</div>
)}
{comp.type === "divider" && (
<hr className="border-gray-300" style={{ borderWidth: `${comp.height || 1}px` }} />
)}
{comp.type === "footer" && (
<div className="text-center text-sm text-gray-500 py-4 border-t bg-gray-50">
{comp.companyName && <div className="font-semibold text-gray-700">{comp.companyName}</div>}
{(comp.ceoName || comp.businessNumber) && (
<div className="mt-1">
{comp.ceoName && <span>: {comp.ceoName}</span>}
{comp.ceoName && comp.businessNumber && <span className="mx-2">|</span>}
{comp.businessNumber && <span>: {comp.businessNumber}</span>}
</div>
)}
{comp.address && <div className="mt-1">{comp.address}</div>}
{(comp.phone || comp.email) && (
<div className="mt-1">
{comp.phone && <span>Tel: {comp.phone}</span>}
{comp.phone && comp.email && <span className="mx-2">|</span>}
{comp.email && <span>Email: {comp.email}</span>}
</div>
)}
{comp.copyright && <div className="mt-2 text-xs text-gray-400">{comp.copyright}</div>}
</div>
)}
{comp.type === "numberedList" && (
<div className="p-4">
{comp.listTitle && <div className="font-semibold mb-2">{comp.listTitle}</div>}
<ol className="list-decimal list-inside space-y-1">
{comp.listItems?.map((item, i) => (
<li key={i}>{item}</li>
))}
</ol>
</div>
)}
</div>
))
@ -571,13 +798,299 @@ export default function MailDesigner({
/>
<span className="text-sm text-muted-foreground"></span>
</div>
<div className="mt-3 p-3 bg-primary/10 rounded-lg border border-primary/20">
<p className="text-xs text-primary">
<strong>:</strong><br/>
간격: 10~20 <br/>
간격: 30~50 <br/>
간격: 60~100
</p>
</div>
</div>
)}
{/* 헤더 컴포넌트 */}
{selected.type === "header" && (
<div className="space-y-4">
<div>
<Label></Label>
<Input
value={selected.brandName || ""}
onChange={(e) => updateComponent(selected.id, { brandName: e.target.value })}
placeholder="회사명"
className="mt-1"
/>
</div>
<div>
<Label> URL</Label>
<Input
value={selected.logoSrc || ""}
onChange={(e) => updateComponent(selected.id, { logoSrc: e.target.value })}
placeholder="https://example.com/logo.png"
className="mt-1"
/>
</div>
<div>
<Label></Label>
<Input
value={selected.sendDate || ""}
onChange={(e) => updateComponent(selected.id, { sendDate: e.target.value })}
className="mt-1"
/>
</div>
<div>
<Label></Label>
<div className="flex items-center gap-3 mt-1">
<Input
type="color"
value={selected.headerBgColor || "#f8f9fa"}
onChange={(e) => updateComponent(selected.id, { headerBgColor: e.target.value })}
className="w-16 h-10"
/>
<span className="text-sm text-muted-foreground">{selected.headerBgColor || "#f8f9fa"}</span>
</div>
</div>
</div>
)}
{/* 정보 테이블 컴포넌트 */}
{selected.type === "infoTable" && (
<div className="space-y-4">
<div>
<Label> </Label>
<Input
value={selected.tableTitle || ""}
onChange={(e) => updateComponent(selected.id, { tableTitle: e.target.value })}
placeholder="예: 주문 정보"
className="mt-1"
/>
</div>
<div>
<Label> </Label>
<div className="space-y-2 mt-2">
{selected.rows?.map((row, i) => (
<div key={i} className="flex gap-2">
<Input
value={row.label}
onChange={(e) => {
const newRows = [...(selected.rows || [])];
newRows[i] = { ...newRows[i], label: e.target.value };
updateComponent(selected.id, { rows: newRows });
}}
placeholder="항목명"
className="flex-1"
/>
<Input
value={row.value}
onChange={(e) => {
const newRows = [...(selected.rows || [])];
newRows[i] = { ...newRows[i], value: e.target.value };
updateComponent(selected.id, { rows: newRows });
}}
placeholder="값"
className="flex-1"
/>
<Button
variant="ghost"
size="sm"
onClick={() => {
const newRows = selected.rows?.filter((_, idx) => idx !== i);
updateComponent(selected.id, { rows: newRows });
}}
>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={() => {
const newRows = [...(selected.rows || []), { label: "", value: "" }];
updateComponent(selected.id, { rows: newRows });
}}
className="w-full"
>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
</div>
)}
{/* 안내 박스 컴포넌트 */}
{selected.type === "alertBox" && (
<div className="space-y-4">
<div>
<Label> </Label>
<div className="grid grid-cols-2 gap-2 mt-2">
{(["info", "warning", "danger", "success"] as const).map((type) => (
<Button
key={type}
variant={selected.alertType === type ? "default" : "outline"}
size="sm"
onClick={() => updateComponent(selected.id, { alertType: type })}
className={
type === "info" ? "border-blue-300" :
type === "warning" ? "border-amber-300" :
type === "danger" ? "border-red-300" : "border-emerald-300"
}
>
{type === "info" ? "정보" : type === "warning" ? "주의" : type === "danger" ? "위험" : "성공"}
</Button>
))}
</div>
</div>
<div>
<Label></Label>
<Input
value={selected.alertTitle || ""}
onChange={(e) => updateComponent(selected.id, { alertTitle: e.target.value })}
placeholder="안내 제목"
className="mt-1"
/>
</div>
<div>
<Label></Label>
<Textarea
value={selected.content || ""}
onChange={(e) => updateComponent(selected.id, { content: e.target.value })}
placeholder="안내 내용을 입력하세요"
rows={4}
className="mt-1"
/>
</div>
</div>
)}
{/* 구분선 컴포넌트 */}
{selected.type === "divider" && (
<div className="space-y-4">
<div>
<Label> </Label>
<div className="flex items-center gap-3 mt-1">
<Input
type="number"
value={selected.height || 1}
onChange={(e) => updateComponent(selected.id, { height: parseInt(e.target.value) || 1 })}
className="w-24"
min="1"
max="10"
/>
<span className="text-sm text-muted-foreground"></span>
</div>
</div>
</div>
)}
{/* 푸터 컴포넌트 */}
{selected.type === "footer" && (
<div className="space-y-4">
<div>
<Label></Label>
<Input
value={selected.companyName || ""}
onChange={(e) => updateComponent(selected.id, { companyName: e.target.value })}
className="mt-1"
/>
</div>
<div>
<Label></Label>
<Input
value={selected.ceoName || ""}
onChange={(e) => updateComponent(selected.id, { ceoName: e.target.value })}
className="mt-1"
/>
</div>
<div>
<Label></Label>
<Input
value={selected.businessNumber || ""}
onChange={(e) => updateComponent(selected.id, { businessNumber: e.target.value })}
placeholder="000-00-00000"
className="mt-1"
/>
</div>
<div>
<Label></Label>
<Input
value={selected.address || ""}
onChange={(e) => updateComponent(selected.id, { address: e.target.value })}
className="mt-1"
/>
</div>
<div>
<Label></Label>
<Input
value={selected.phone || ""}
onChange={(e) => updateComponent(selected.id, { phone: e.target.value })}
className="mt-1"
/>
</div>
<div>
<Label></Label>
<Input
value={selected.email || ""}
onChange={(e) => updateComponent(selected.id, { email: e.target.value })}
className="mt-1"
/>
</div>
<div>
<Label> </Label>
<Input
value={selected.copyright || ""}
onChange={(e) => updateComponent(selected.id, { copyright: e.target.value })}
className="mt-1"
/>
</div>
</div>
)}
{/* 번호 리스트 컴포넌트 */}
{selected.type === "numberedList" && (
<div className="space-y-4">
<div>
<Label> </Label>
<Input
value={selected.listTitle || ""}
onChange={(e) => updateComponent(selected.id, { listTitle: e.target.value })}
placeholder="예: 안내 사항"
className="mt-1"
/>
</div>
<div>
<Label></Label>
<div className="space-y-2 mt-2">
{selected.listItems?.map((item, i) => (
<div key={i} className="flex gap-2">
<span className="flex items-center justify-center w-6 text-sm text-muted-foreground">{i + 1}.</span>
<Input
value={item}
onChange={(e) => {
const newItems = [...(selected.listItems || [])];
newItems[i] = e.target.value;
updateComponent(selected.id, { listItems: newItems });
}}
className="flex-1"
/>
<Button
variant="ghost"
size="sm"
onClick={() => {
const newItems = selected.listItems?.filter((_, idx) => idx !== i);
updateComponent(selected.id, { listItems: newItems });
}}
>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={() => {
const newItems = [...(selected.listItems || []), ""];
updateComponent(selected.id, { listItems: newItems });
}}
className="w-full"
>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
</div>

View File

@ -6,7 +6,6 @@ import {
ResizableDialogContent,
ResizableDialogHeader,
ResizableDialogTitle,
ResizableDialogDescription,
ResizableDialogFooter,
} from "@/components/ui/resizable-dialog";
import { Button } from "@/components/ui/button";
@ -15,11 +14,13 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Search, X, Check, ChevronsUpDown, Database } from "lucide-react";
import { Search, X, Check, ChevronsUpDown, Database, Globe } from "lucide-react";
import Link from "next/link";
import { cn } from "@/lib/utils";
import { screenApi, tableTypeApi } from "@/lib/api/screen";
import { ScreenDefinition } from "@/types/screen";
import { useAuth } from "@/hooks/useAuth";
import { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection";
interface CreateScreenModalProps {
open: boolean;
@ -39,12 +40,22 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
const [tableSearchTerm, setTableSearchTerm] = useState("");
const searchInputRef = useRef<HTMLInputElement>(null);
// 데이터 소스 타입 (database: 데이터베이스, restapi: REST API)
const [dataSourceType, setDataSourceType] = useState<"database" | "restapi">("database");
// 외부 DB 연결 관련 상태
const [selectedDbSource, setSelectedDbSource] = useState<"internal" | number>("internal");
const [externalConnections, setExternalConnections] = useState<any[]>([]);
const [externalTableList, setExternalTableList] = useState<string[]>([]);
const [loadingExternalTables, setLoadingExternalTables] = useState(false);
const [openDbSourceCombobox, setOpenDbSourceCombobox] = useState(false);
// REST API 연결 관련 상태
const [restApiConnections, setRestApiConnections] = useState<ExternalRestApiConnection[]>([]);
const [selectedRestApiId, setSelectedRestApiId] = useState<number | null>(null);
const [openRestApiCombobox, setOpenRestApiCombobox] = useState(false);
const [restApiEndpoint, setRestApiEndpoint] = useState("");
const [restApiJsonPath, setRestApiJsonPath] = useState("data"); // 응답에서 데이터 추출 경로
// 화면 코드 자동 생성
const generateCode = async () => {
try {
@ -109,6 +120,21 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
loadConnections();
}, [open]);
// REST API 연결 목록 로드
useEffect(() => {
if (!open) return;
const loadRestApiConnections = async () => {
try {
const connections = await ExternalRestApiConnectionAPI.getConnections({ is_active: "Y" });
setRestApiConnections(connections);
} catch (error) {
console.error("Failed to load REST API connections:", error);
setRestApiConnections([]);
}
};
loadRestApiConnections();
}, [open]);
// 외부 DB 테이블 목록 로드
useEffect(() => {
if (selectedDbSource === "internal" || !selectedDbSource) {
@ -160,8 +186,15 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
}, [open, screenCode]);
const isValid = useMemo(() => {
return screenName.trim().length > 0 && screenCode.trim().length > 0 && tableName.trim().length > 0;
}, [screenName, screenCode, tableName]);
const baseValid = screenName.trim().length > 0 && screenCode.trim().length > 0;
if (dataSourceType === "database") {
return baseValid && tableName.trim().length > 0;
} else {
// REST API: 연결 선택 필수
return baseValid && selectedRestApiId !== null;
}
}, [screenName, screenCode, tableName, dataSourceType, selectedRestApiId]);
// 테이블 필터링 (내부 DB용)
const filteredTables = useMemo(() => {
@ -186,17 +219,30 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
setSubmitting(true);
const companyCode = (user as any)?.company_code || (user as any)?.companyCode || "*";
// DB 소스 정보 추가
const created = await screenApi.createScreen({
// 데이터 소스 타입에 따라 다른 정보 전달
const createData: any = {
screenName: screenName.trim(),
screenCode: screenCode.trim(),
tableName: tableName.trim(),
companyCode,
description: description.trim() || undefined,
createdBy: (user as any)?.userId,
dbSourceType: selectedDbSource === "internal" ? "internal" : "external",
dbConnectionId: selectedDbSource === "internal" ? undefined : Number(selectedDbSource),
} as any);
dataSourceType: dataSourceType,
};
if (dataSourceType === "database") {
// 데이터베이스 소스
createData.tableName = tableName.trim();
createData.dbSourceType = selectedDbSource === "internal" ? "internal" : "external";
createData.dbConnectionId = selectedDbSource === "internal" ? undefined : Number(selectedDbSource);
} else {
// REST API 소스
createData.tableName = `_restapi_${selectedRestApiId}`; // REST API용 가상 테이블명
createData.restApiConnectionId = selectedRestApiId;
createData.restApiEndpoint = restApiEndpoint.trim() || undefined;
createData.restApiJsonPath = restApiJsonPath.trim() || "data";
}
const created = await screenApi.createScreen(createData);
// 날짜 필드 보정
const mapped: ScreenDefinition = {
@ -207,11 +253,16 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
onCreated?.(mapped);
onOpenChange(false);
// 폼 초기화
setScreenName("");
setScreenCode("");
setTableName("");
setDescription("");
setSelectedDbSource("internal");
setDataSourceType("database");
setSelectedRestApiId(null);
setRestApiEndpoint("");
setRestApiJsonPath("data");
} catch (e) {
// 필요 시 토스트 추가 가능
} finally {
@ -263,83 +314,210 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
/>
</div>
{/* DB 소스 선택 */}
{/* 데이터 소스 타입 선택 */}
<div className="space-y-2">
<Label htmlFor="dbSource"> </Label>
<Popover open={openDbSourceCombobox} onOpenChange={setOpenDbSourceCombobox}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openDbSourceCombobox}
className="w-full justify-between"
>
<div className="flex items-center gap-2">
<Database className="h-4 w-4" />
{selectedDbSource === "internal"
? "내부 데이터베이스"
: externalConnections.find((conn) => conn.id === selectedDbSource)?.connection_name ||
"선택하세요"}
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="데이터베이스 검색..." />
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
<CommandItem
value="internal"
onSelect={() => {
setSelectedDbSource("internal");
setTableName("");
setTableSearchTerm("");
setOpenDbSourceCombobox(false);
}}
>
<Check
className={cn("mr-2 h-4 w-4", selectedDbSource === "internal" ? "opacity-100" : "opacity-0")}
/>
<Database className="mr-2 h-4 w-4 text-blue-500" />
<div className="flex flex-col">
<span className="font-medium"> </span>
<span className="text-xs text-gray-500">PostgreSQL ( )</span>
</div>
</CommandItem>
{externalConnections.map((conn: any) => (
<CommandItem
key={conn.id}
value={`${conn.connection_name} ${conn.db_type}`}
onSelect={() => {
setSelectedDbSource(conn.id);
setTableName("");
setTableSearchTerm("");
setOpenDbSourceCombobox(false);
}}
>
<Check
className={cn("mr-2 h-4 w-4", selectedDbSource === conn.id ? "opacity-100" : "opacity-0")}
/>
<Database className="mr-2 h-4 w-4 text-green-500" />
<div className="flex flex-col">
<span className="font-medium">{conn.connection_name}</span>
<span className="text-xs text-gray-500">{conn.db_type?.toUpperCase()}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-xs text-gray-500"> </p>
<Label> </Label>
<div className="flex gap-2">
<Button
type="button"
variant={dataSourceType === "database" ? "default" : "outline"}
className="flex-1"
onClick={() => {
setDataSourceType("database");
setSelectedRestApiId(null);
}}
>
<Database className="mr-2 h-4 w-4" />
</Button>
<Button
type="button"
variant={dataSourceType === "restapi" ? "default" : "outline"}
className="flex-1"
onClick={() => {
setDataSourceType("restapi");
setTableName("");
setSelectedDbSource("internal");
}}
>
<Globe className="mr-2 h-4 w-4" />
REST API
</Button>
</div>
</div>
{/* 테이블 선택 */}
{/* 데이터베이스 소스 설정 */}
{dataSourceType === "database" && (
<>
{/* DB 소스 선택 */}
<div className="space-y-2">
<Label htmlFor="dbSource"> </Label>
<Popover open={openDbSourceCombobox} onOpenChange={setOpenDbSourceCombobox}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openDbSourceCombobox}
className="w-full justify-between"
>
<div className="flex items-center gap-2">
<Database className="h-4 w-4" />
{selectedDbSource === "internal"
? "내부 데이터베이스"
: externalConnections.find((conn) => conn.id === selectedDbSource)?.connection_name ||
"선택하세요"}
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="데이터베이스 검색..." />
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
<CommandItem
value="internal"
onSelect={() => {
setSelectedDbSource("internal");
setTableName("");
setTableSearchTerm("");
setOpenDbSourceCombobox(false);
}}
>
<Check
className={cn("mr-2 h-4 w-4", selectedDbSource === "internal" ? "opacity-100" : "opacity-0")}
/>
<Database className="mr-2 h-4 w-4 text-blue-500" />
<div className="flex flex-col">
<span className="font-medium"> </span>
<span className="text-xs text-gray-500">PostgreSQL ( )</span>
</div>
</CommandItem>
{externalConnections.map((conn: any) => (
<CommandItem
key={conn.id}
value={`${conn.connection_name} ${conn.db_type}`}
onSelect={() => {
setSelectedDbSource(conn.id);
setTableName("");
setTableSearchTerm("");
setOpenDbSourceCombobox(false);
}}
>
<Check
className={cn("mr-2 h-4 w-4", selectedDbSource === conn.id ? "opacity-100" : "opacity-0")}
/>
<Database className="mr-2 h-4 w-4 text-green-500" />
<div className="flex flex-col">
<span className="font-medium">{conn.connection_name}</span>
<span className="text-xs text-gray-500">{conn.db_type?.toUpperCase()}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-xs text-gray-500"> </p>
</div>
</>
)}
{/* REST API 소스 설정 */}
{dataSourceType === "restapi" && (
<>
{/* REST API 연결 선택 */}
<div className="space-y-2">
<Label htmlFor="restApiConnection">REST API *</Label>
<Popover open={openRestApiCombobox} onOpenChange={setOpenRestApiCombobox}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openRestApiCombobox}
className="w-full justify-between"
>
<div className="flex items-center gap-2">
<Globe className="h-4 w-4" />
{selectedRestApiId
? restApiConnections.find((conn) => conn.id === selectedRestApiId)?.connection_name ||
"선택하세요"
: "REST API 연결을 선택하세요"}
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="REST API 검색..." />
<CommandList>
<CommandEmpty> REST API .</CommandEmpty>
<CommandGroup>
{restApiConnections.map((conn) => (
<CommandItem
key={conn.id}
value={`${conn.connection_name} ${conn.base_url}`}
onSelect={() => {
setSelectedRestApiId(conn.id!);
setRestApiEndpoint(conn.endpoint_path || "");
setOpenRestApiCombobox(false);
}}
>
<Check
className={cn("mr-2 h-4 w-4", selectedRestApiId === conn.id ? "opacity-100" : "opacity-0")}
/>
<Globe className="mr-2 h-4 w-4 text-purple-500" />
<div className="flex flex-col">
<span className="font-medium">{conn.connection_name}</span>
<span className="text-xs text-gray-500 truncate max-w-[300px]">{conn.base_url}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-xs text-gray-500">
REST API .
<Link href="/admin/externalRestApi" className="ml-1 text-primary hover:underline">
</Link>
</p>
</div>
{/* 엔드포인트 경로 */}
<div className="space-y-2">
<Label htmlFor="restApiEndpoint"> </Label>
<Input
id="restApiEndpoint"
value={restApiEndpoint}
onChange={(e) => setRestApiEndpoint(e.target.value)}
placeholder="/api/data 또는 /users"
/>
<p className="text-xs text-gray-500"> URL ()</p>
</div>
{/* JSON Path */}
<div className="space-y-2">
<Label htmlFor="restApiJsonPath"> (JSON Path)</Label>
<Input
id="restApiJsonPath"
value={restApiJsonPath}
onChange={(e) => setRestApiJsonPath(e.target.value)}
placeholder="data 또는 result.items"
/>
<p className="text-xs text-gray-500">API (: data, result.items)</p>
</div>
</>
)}
{/* 테이블 선택 (데이터베이스 모드일 때만) */}
{dataSourceType === "database" && (
<div className="space-y-2">
<Label htmlFor="tableName"></Label>
<Label htmlFor="tableName"> *</Label>
<Select
value={tableName}
onValueChange={setTableName}
@ -422,11 +600,7 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Input id="description" value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
)}
</div>
<ResizableDialogFooter className="mt-4">

View File

@ -42,13 +42,35 @@ export interface UpdateMailAccountDto extends Partial<CreateMailAccountDto> {
export interface MailComponent {
id: string;
type: 'text' | 'button' | 'image' | 'spacer';
type: 'text' | 'button' | 'image' | 'spacer' | 'header' | 'infoTable' | 'alertBox' | 'divider' | 'footer' | 'numberedList';
content?: string;
text?: string;
url?: string;
src?: string;
height?: number;
styles?: Record<string, string>;
// 헤더 컴포넌트용
logoSrc?: string;
brandName?: string;
sendDate?: string;
headerBgColor?: string;
// 정보 테이블용
rows?: Array<{ label: string; value: string }>;
tableTitle?: string;
// 강조 박스용
alertType?: 'info' | 'warning' | 'danger' | 'success';
alertTitle?: string;
// 푸터용
companyName?: string;
ceoName?: string;
businessNumber?: string;
address?: string;
phone?: string;
email?: string;
copyright?: string;
// 번호 리스트용
listItems?: string[];
listTitle?: string;
}
export interface MailTemplate {
@ -470,6 +492,95 @@ export function renderTemplateToHtml(
case 'spacer':
html += `<div style="height: ${component.height || 20}px;"></div>`;
break;
case 'header':
html += `
<div style="padding: 20px; background-color: ${component.headerBgColor || '#f8f9fa'}; border-radius: 8px; margin-bottom: 20px;">
<table style="width: 100%;">
<tr>
<td style="vertical-align: middle;">
${component.logoSrc ? `<img src="${component.logoSrc}" alt="로고" style="height: 40px; margin-right: 12px;">` : ''}
<span style="font-size: 18px; font-weight: bold;">${component.brandName || ''}</span>
</td>
<td style="text-align: right; color: #6b7280; font-size: 14px;">
${component.sendDate || ''}
</td>
</tr>
</table>
</div>
`;
break;
case 'infoTable':
html += `
<div style="border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; margin: 16px 0;">
${component.tableTitle ? `<div style="background-color: #f9fafb; padding: 12px 16px; font-weight: 600; border-bottom: 1px solid #e5e7eb;">${component.tableTitle}</div>` : ''}
<table style="width: 100%; border-collapse: collapse;">
${(component.rows || []).map((row, i) => `
<tr style="background-color: ${i % 2 === 0 ? '#ffffff' : '#f9fafb'};">
<td style="padding: 12px 16px; font-weight: 500; color: #4b5563; width: 35%; border-right: 1px solid #e5e7eb;">${row.label}</td>
<td style="padding: 12px 16px;">${row.value}</td>
</tr>
`).join('')}
</table>
</div>
`;
break;
case 'alertBox':
const alertColors = {
info: { bg: '#eff6ff', border: '#3b82f6', text: '#1e40af' },
warning: { bg: '#fffbeb', border: '#f59e0b', text: '#92400e' },
danger: { bg: '#fef2f2', border: '#ef4444', text: '#991b1b' },
success: { bg: '#ecfdf5', border: '#10b981', text: '#065f46' }
};
const colors = alertColors[component.alertType || 'info'];
html += `
<div style="padding: 16px; background-color: ${colors.bg}; border-left: 4px solid ${colors.border}; border-radius: 4px; margin: 16px 0; color: ${colors.text};">
${component.alertTitle ? `<div style="font-weight: bold; margin-bottom: 8px;">${component.alertTitle}</div>` : ''}
<div>${component.content || ''}</div>
</div>
`;
break;
case 'divider':
html += `<hr style="border: none; border-top: ${component.height || 1}px solid #e5e7eb; margin: 20px 0;">`;
break;
case 'footer':
html += `
<div style="text-align: center; padding: 24px 16px; background-color: #f9fafb; border-top: 1px solid #e5e7eb; font-size: 14px; color: #6b7280;">
${component.companyName ? `<div style="font-weight: 600; color: #374151; margin-bottom: 8px;">${component.companyName}</div>` : ''}
${(component.ceoName || component.businessNumber) ? `
<div style="margin-bottom: 4px;">
${component.ceoName ? `대표: ${component.ceoName}` : ''}
${component.ceoName && component.businessNumber ? ' | ' : ''}
${component.businessNumber ? `사업자등록번호: ${component.businessNumber}` : ''}
</div>
` : ''}
${component.address ? `<div style="margin-bottom: 4px;">${component.address}</div>` : ''}
${(component.phone || component.email) ? `
<div style="margin-bottom: 4px;">
${component.phone ? `Tel: ${component.phone}` : ''}
${component.phone && component.email ? ' | ' : ''}
${component.email ? `Email: ${component.email}` : ''}
</div>
` : ''}
${component.copyright ? `<div style="margin-top: 12px; font-size: 12px; color: #9ca3af;">${component.copyright}</div>` : ''}
</div>
`;
break;
case 'numberedList':
html += `
<div style="padding: 16px; ${styleObjectToString(component.styles)}">
${component.listTitle ? `<div style="font-weight: 600; margin-bottom: 12px;">${component.listTitle}</div>` : ''}
<ol style="margin: 0; padding-left: 20px;">
${(component.listItems || []).map(item => `<li style="margin-bottom: 8px;">${item}</li>`).join('')}
</ol>
</div>
`;
break;
}
});

View File

@ -549,6 +549,11 @@ export interface ScreenDefinition {
updatedBy?: string;
dbSourceType?: "internal" | "external";
dbConnectionId?: number;
// REST API 관련 필드
dataSourceType?: "database" | "restapi";
restApiConnectionId?: number;
restApiEndpoint?: string;
restApiJsonPath?: string;
}
/**
@ -563,6 +568,11 @@ export interface CreateScreenRequest {
description?: string;
dbSourceType?: "internal" | "external";
dbConnectionId?: number;
// REST API 관련 필드
dataSourceType?: "database" | "restapi";
restApiConnectionId?: number;
restApiEndpoint?: string;
restApiJsonPath?: string;
}
/**