Merge pull request 'lhj' (#226) from lhj into main

Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/226
This commit is contained in:
hjlee 2025-11-28 15:06:10 +09:00
commit f1ff835a45
48 changed files with 1626 additions and 894 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

@ -267,4 +267,46 @@ router.post(
}
);
/**
* POST /api/external-rest-api-connections/:id/fetch
* REST API ( )
*/
router.post(
"/:id/fetch",
authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
try {
const id = parseInt(req.params.id);
if (isNaN(id)) {
return res.status(400).json({
success: false,
message: "유효하지 않은 ID입니다.",
});
}
const { endpoint, jsonPath } = req.body;
const userCompanyCode = req.user?.companyCode;
logger.info(`REST API 데이터 조회 요청: 연결 ID=${id}, endpoint=${endpoint}, jsonPath=${jsonPath}`);
const result = await ExternalRestApiConnectionService.fetchData(
id,
endpoint,
jsonPath,
userCompanyCode
);
return res.status(result.success ? 200 : 400).json(result);
} catch (error) {
logger.error("REST API 데이터 조회 오류:", error);
return res.status(500).json({
success: false,
message: "서버 내부 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
);
export default router;

View File

@ -166,6 +166,9 @@ export class ExternalRestApiConnectionService {
? this.decryptSensitiveData(connection.auth_config)
: null;
// 디버깅: 조회된 연결 정보 로깅
logger.info(`REST API 연결 조회 결과 (ID: ${id}): connection_name=${connection.connection_name}, default_method=${connection.default_method}, endpoint_path=${connection.endpoint_path}`);
return {
success: true,
data: connection,
@ -227,6 +230,15 @@ export class ExternalRestApiConnectionService {
data.created_by || "system",
];
// 디버깅: 저장하려는 데이터 로깅
logger.info(`REST API 연결 생성 요청 데이터:`, {
connection_name: data.connection_name,
default_method: data.default_method,
endpoint_path: data.endpoint_path,
base_url: data.base_url,
default_body: data.default_body ? "있음" : "없음",
});
const result: QueryResult<any> = await pool.query(query, params);
logger.info(`REST API 연결 생성 성공: ${data.connection_name}`);
@ -316,12 +328,14 @@ export class ExternalRestApiConnectionService {
updateFields.push(`default_method = $${paramIndex}`);
params.push(data.default_method);
paramIndex++;
logger.info(`수정 요청 - default_method: ${data.default_method}`);
}
if (data.default_body !== undefined) {
updateFields.push(`default_request_body = $${paramIndex}`);
params.push(data.default_body);
params.push(data.default_body); // null이면 DB에서 NULL로 저장됨
paramIndex++;
logger.info(`수정 요청 - default_body: ${data.default_body ? "있음" : "삭제(null)"}`);
}
if (data.auth_type !== undefined) {
@ -870,6 +884,166 @@ export class ExternalRestApiConnectionService {
return decrypted;
}
/**
* REST API ( )
* REST API를
*/
static async fetchData(
connectionId: number,
endpoint?: string,
jsonPath?: string,
userCompanyCode?: string
): Promise<ApiResponse<any>> {
try {
// 연결 정보 조회
const connectionResult = await this.getConnectionById(connectionId, userCompanyCode);
if (!connectionResult.success || !connectionResult.data) {
return {
success: false,
message: "REST API 연결을 찾을 수 없습니다.",
error: {
code: "CONNECTION_NOT_FOUND",
details: `연결 ID ${connectionId}를 찾을 수 없습니다.`,
},
};
}
const connection = connectionResult.data;
// 비활성화된 연결인지 확인
if (connection.is_active !== "Y") {
return {
success: false,
message: "비활성화된 REST API 연결입니다.",
error: {
code: "CONNECTION_INACTIVE",
details: "연결이 비활성화 상태입니다.",
},
};
}
// 엔드포인트 결정 (파라미터 > 저장된 값)
const effectiveEndpoint = endpoint || connection.endpoint_path || "";
// API 호출을 위한 테스트 요청 생성
const testRequest: RestApiTestRequest = {
id: connection.id,
base_url: connection.base_url,
endpoint: effectiveEndpoint,
method: (connection.default_method as any) || "GET",
headers: connection.default_headers,
body: connection.default_body,
auth_type: connection.auth_type,
auth_config: connection.auth_config,
timeout: connection.timeout,
};
// API 호출
const result = await this.testConnection(testRequest, connection.company_code);
if (!result.success) {
return {
success: false,
message: result.message || "REST API 호출에 실패했습니다.",
error: {
code: "API_CALL_FAILED",
details: result.error_details,
},
};
}
// 응답 데이터에서 jsonPath로 데이터 추출
let extractedData = result.response_data;
logger.info(`REST API 원본 응답 데이터 타입: ${typeof result.response_data}`);
logger.info(`REST API 원본 응답 데이터 (일부): ${JSON.stringify(result.response_data)?.substring(0, 500)}`);
if (jsonPath && result.response_data) {
try {
// jsonPath로 데이터 추출 (예: "data", "data.items", "result.list")
const pathParts = jsonPath.split(".");
logger.info(`JSON Path 파싱: ${jsonPath} -> [${pathParts.join(", ")}]`);
for (const part of pathParts) {
if (extractedData && typeof extractedData === "object") {
extractedData = (extractedData as any)[part];
logger.info(`JSON Path '${part}' 추출 결과 타입: ${typeof extractedData}, 배열?: ${Array.isArray(extractedData)}`);
} else {
logger.warn(`JSON Path '${part}' 추출 실패: extractedData가 객체가 아님`);
break;
}
}
} catch (pathError) {
logger.warn(`JSON Path 추출 실패: ${jsonPath}`, pathError);
// 추출 실패 시 원본 데이터 반환
extractedData = result.response_data;
}
}
// 데이터가 배열이 아닌 경우 배열로 변환
// null이나 undefined인 경우 빈 배열로 처리
let dataArray: any[] = [];
if (extractedData === null || extractedData === undefined) {
logger.warn("추출된 데이터가 null/undefined입니다. 원본 응답 데이터를 사용합니다.");
// jsonPath 추출 실패 시 원본 데이터에서 직접 컬럼 추출 시도
if (result.response_data && typeof result.response_data === "object") {
dataArray = Array.isArray(result.response_data) ? result.response_data : [result.response_data];
}
} else {
dataArray = Array.isArray(extractedData) ? extractedData : [extractedData];
}
logger.info(`최종 데이터 배열 길이: ${dataArray.length}`);
if (dataArray.length > 0) {
logger.info(`첫 번째 데이터 항목: ${JSON.stringify(dataArray[0])?.substring(0, 300)}`);
}
// 컬럼 정보 추출 (첫 번째 유효한 데이터 기준)
let columns: Array<{ columnName: string; columnLabel: string; dataType: string }> = [];
// 첫 번째 유효한 객체 찾기
const firstValidItem = dataArray.find(item => item && typeof item === "object" && !Array.isArray(item));
if (firstValidItem) {
columns = Object.keys(firstValidItem).map((key) => ({
columnName: key,
columnLabel: key,
dataType: typeof firstValidItem[key],
}));
logger.info(`추출된 컬럼 수: ${columns.length}, 컬럼명: [${columns.map(c => c.columnName).join(", ")}]`);
} else {
logger.warn("유효한 데이터 항목을 찾을 수 없어 컬럼을 추출할 수 없습니다.");
}
return {
success: true,
data: {
rows: dataArray,
columns,
total: dataArray.length,
connectionInfo: {
connectionId: connection.id,
connectionName: connection.connection_name,
baseUrl: connection.base_url,
endpoint: effectiveEndpoint,
},
},
message: `${dataArray.length}개의 데이터를 조회했습니다.`,
};
} catch (error) {
logger.error("REST API 데이터 조회 오류:", error);
return {
success: false,
message: "REST API 데이터 조회에 실패했습니다.",
error: {
code: "FETCH_ERROR",
details: error instanceof Error ? error.message : "알 수 없는 오류",
},
};
}
}
/**
*
*/

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

@ -239,17 +239,17 @@ function ScreenViewPage() {
// 가로 기준 스케일 계산 (좌우 여백 16px씩 고정)
const newScale = availableWidth / designWidth;
console.log("📐 스케일 계산:", {
containerWidth,
containerHeight,
MARGIN_X,
availableWidth,
designWidth,
designHeight,
finalScale: newScale,
"스케일된 화면 크기": `${designWidth * newScale}px × ${designHeight * newScale}px`,
"실제 좌우 여백": `${(containerWidth - designWidth * newScale) / 2}px씩`,
});
// console.log("📐 스케일 계산:", {
// containerWidth,
// containerHeight,
// MARGIN_X,
// availableWidth,
// designWidth,
// designHeight,
// finalScale: newScale,
// "스케일된 화면 크기": `${designWidth * newScale}px × ${designHeight * newScale}px`,
// "실제 좌우 여백": `${(containerWidth - designWidth * newScale) / 2}px씩`,
// });
setScale(newScale);
// 컨테이너 너비 업데이트

View File

@ -226,7 +226,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
endpoint_path: endpointPath || undefined,
default_headers: defaultHeaders,
default_method: defaultMethod,
default_body: defaultBody || undefined,
default_body: defaultBody.trim() || null, // 빈 문자열이면 null로 전송하여 DB 업데이트
auth_type: authType,
auth_config: authType === "none" ? undefined : authConfig,
timeout,
@ -236,6 +236,13 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
is_active: isActive ? "Y" : "N",
};
console.log("저장하려는 데이터:", {
connection_name: connectionName,
default_method: defaultMethod,
endpoint_path: endpointPath,
base_url: baseUrl,
});
if (connection?.id) {
await ExternalRestApiConnectionAPI.updateConnection(connection.id, data);
toast({
@ -303,7 +310,13 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
URL <span className="text-destructive">*</span>
</Label>
<div className="flex gap-2">
<Select value={defaultMethod} onValueChange={setDefaultMethod}>
<Select
value={defaultMethod}
onValueChange={(val) => {
setDefaultMethod(val);
setTestMethod(val); // 테스트 Method도 동기화
}}
>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="Method" />
</SelectTrigger>

View File

@ -68,7 +68,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
const savedMode = localStorage.getItem("screenModal_continuousMode");
if (savedMode === "true") {
setContinuousMode(true);
console.log("🔄 연속 모드 복원: true");
// console.log("🔄 연속 모드 복원: true");
}
}, []);

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

@ -66,6 +66,7 @@ const calculateGridInfo = (width: number, height: number, settings: any) => {
import { GroupingToolbar } from "./GroupingToolbar";
import { screenApi, tableTypeApi } from "@/lib/api/screen";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { ExternalRestApiConnectionAPI } from "@/lib/api/externalRestApiConnection";
import { toast } from "sonner";
import { MenuAssignmentModal } from "./MenuAssignmentModal";
import { FileAttachmentDetailModal } from "./FileAttachmentDetailModal";
@ -835,9 +836,52 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}
}, []);
// 화면의 기본 테이블 정보 로드 (원래대로 복원)
// 화면의 기본 테이블/REST API 정보 로드
useEffect(() => {
const loadScreenTable = async () => {
const loadScreenDataSource = async () => {
// REST API 데이터 소스인 경우
if (selectedScreen?.dataSourceType === "restapi" && selectedScreen?.restApiConnectionId) {
try {
const restApiData = await ExternalRestApiConnectionAPI.fetchData(
selectedScreen.restApiConnectionId,
selectedScreen.restApiEndpoint,
selectedScreen.restApiJsonPath || "data",
);
// REST API 응답에서 컬럼 정보 생성
const columns: ColumnInfo[] = restApiData.columns.map((col) => ({
tableName: `restapi_${selectedScreen.restApiConnectionId}`,
columnName: col.columnName,
columnLabel: col.columnLabel,
dataType: col.dataType === "string" ? "varchar" : col.dataType === "number" ? "numeric" : col.dataType,
webType: col.dataType === "number" ? "number" : "text",
input_type: "text",
widgetType: col.dataType === "number" ? "number" : "text",
isNullable: "YES",
required: false,
}));
const tableInfo: TableInfo = {
tableName: `restapi_${selectedScreen.restApiConnectionId}`,
tableLabel: restApiData.connectionInfo.connectionName || "REST API 데이터",
columns,
};
setTables([tableInfo]);
console.log("REST API 데이터 소스 로드 완료:", {
connectionName: restApiData.connectionInfo.connectionName,
columnsCount: columns.length,
rowsCount: restApiData.total,
});
} catch (error) {
console.error("REST API 데이터 소스 로드 실패:", error);
toast.error("REST API 데이터를 불러오는데 실패했습니다.");
setTables([]);
}
return;
}
// 데이터베이스 데이터 소스인 경우 (기존 로직)
const tableName = selectedScreen?.tableName;
if (!tableName) {
setTables([]);
@ -859,16 +903,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => {
const widgetType = col.widgetType || col.widget_type || col.webType || col.web_type;
// 🔍 이미지 타입 디버깅
// if (widgetType === "image" || col.webType === "image" || col.web_type === "image") {
// console.log("🖼️ 이미지 컬럼 발견:", {
// columnName: col.columnName || col.column_name,
// widgetType,
// webType: col.webType || col.web_type,
// rawData: col,
// });
// }
return {
tableName: col.tableName || tableName,
columnName: col.columnName || col.column_name,
@ -899,8 +933,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}
};
loadScreenTable();
}, [selectedScreen?.tableName, selectedScreen?.screenName]);
loadScreenDataSource();
}, [selectedScreen?.tableName, selectedScreen?.screenName, selectedScreen?.dataSourceType, selectedScreen?.restApiConnectionId, selectedScreen?.restApiEndpoint, selectedScreen?.restApiJsonPath]);
// 화면 레이아웃 로드
useEffect(() => {

View File

@ -176,7 +176,7 @@ const ResizableDialogContent = React.forwardRef<
height: Math.max(minHeight, Math.min(maxHeight, parsed.height)),
userResized: true,
};
console.log("💾 사용자가 리사이징한 크기 복원:", savedSize);
// console.log("💾 사용자가 리사이징한 크기 복원:", savedSize);
}
}
} catch (error) {

View File

@ -221,11 +221,11 @@ export const useAuth = () => {
setAuthStatus(finalAuthStatus);
console.log("✅ 최종 사용자 상태:", {
userId: userInfo?.userId,
userName: userInfo?.userName,
companyCode: userInfo?.companyCode || userInfo?.company_code,
});
// console.log("✅ 최종 사용자 상태:", {
// userId: userInfo?.userId,
// userName: userInfo?.userName,
// companyCode: userInfo?.companyCode || userInfo?.company_code,
// });
// 디버깅용 로그

View File

@ -192,6 +192,43 @@ export class ExternalRestApiConnectionAPI {
return response.data;
}
/**
* REST API ( )
*/
static async fetchData(
connectionId: number,
endpoint?: string,
jsonPath?: string,
): Promise<{
rows: any[];
columns: Array<{ columnName: string; columnLabel: string; dataType: string }>;
total: number;
connectionInfo: {
connectionId: number;
connectionName: string;
baseUrl: string;
endpoint: string;
};
}> {
const response = await apiClient.post<ApiResponse<{
rows: any[];
columns: Array<{ columnName: string; columnLabel: string; dataType: string }>;
total: number;
connectionInfo: {
connectionId: number;
connectionName: string;
baseUrl: string;
endpoint: string;
};
}>>(`${this.BASE_PATH}/${connectionId}/fetch`, { endpoint, jsonPath });
if (!response.data.success) {
throw new Error(response.data.message || "REST API 데이터 조회에 실패했습니다.");
}
return response.data.data!;
}
/**
*
*/

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

@ -93,7 +93,7 @@ class CodeCache {
*
*/
async preloadCodes(categories: string[]): Promise<void> {
console.log(`🔄 코드 배치 로딩 시작: ${categories.join(", ")}`);
// console.log(`🔄 코드 배치 로딩 시작: ${categories.join(", ")}`);
const promises = categories.map(async (category) => {
try {
@ -101,7 +101,7 @@ class CodeCache {
if (response.success && response.data) {
const cacheKey = this.createCodeKey(category);
this.set(cacheKey, response.data, this.defaultTTL);
console.log(`✅ 코드 로딩 완료: ${category} (${response.data.length}개)`);
// console.log(`✅ 코드 로딩 완료: ${category} (${response.data.length}개)`);
}
} catch (error) {
console.error(`❌ 코드 로딩 실패: ${category}`, error);
@ -109,7 +109,7 @@ class CodeCache {
});
await Promise.all(promises);
console.log(`✅ 코드 배치 로딩 완료: ${categories.length}개 카테고리`);
// console.log(`✅ 코드 배치 로딩 완료: ${categories.length}개 카테고리`);
}
/**

View File

@ -99,7 +99,7 @@ export function useEntityJoinOptimization(columnMeta: Record<string, ColumnMetaI
batches.push(categories.slice(i, i + maxBatchSize));
}
console.log(`🔄 배치 코드 로딩 시작: ${categories.length}개 카테고리 (${batches.length}개 배치)`);
// console.log(`🔄 배치 코드 로딩 시작: ${categories.length}개 카테고리 (${batches.length}개 배치)`);
for (const batch of batches) {
// 로딩 상태 업데이트
@ -125,7 +125,7 @@ export function useEntityJoinOptimization(columnMeta: Record<string, ColumnMetaI
const responseTime = Date.now() - startTime;
requestTimes.current.push(responseTime);
console.log(`✅ 배치 코드 로딩 완료: ${responseTime}ms`);
// console.log(`✅ 배치 코드 로딩 완료: ${responseTime}ms`);
} catch (error) {
console.error("❌ 배치 코드 로딩 실패:", error);
} finally {

View File

@ -72,5 +72,5 @@ ComponentRegistry.registerComponent({
},
});
console.log("✅ 카테고리 관리 컴포넌트 등록 완료");
// console.log("✅ 카테고리 관리 컴포넌트 등록 완료");

View File

@ -6,5 +6,5 @@ import { CustomerItemMappingDefinition } from "./index";
// 컴포넌트 자동 등록
ComponentRegistry.registerComponent(CustomerItemMappingDefinition);
console.log("✅ CustomerItemMapping 컴포넌트 등록 완료");
// console.log("✅ CustomerItemMapping 컴포넌트 등록 완료");

View File

@ -266,10 +266,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}
});
console.log("🔍 [TableListComponent] filters → searchValues:", {
filters: filters.length,
searchValues: newSearchValues,
});
// console.log("🔍 [TableListComponent] filters → searchValues:", {
// filters: filters.length,
// searchValues: newSearchValues,
// });
setSearchValues(newSearchValues);
setCurrentPage(1); // 필터 변경 시 첫 페이지로
@ -859,13 +859,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
referenceTable: col.additionalJoinInfo!.referenceTable,
}));
console.log("🔍 [TableList] API 호출 시작", {
tableName: tableConfig.selectedTable,
page,
pageSize,
sortBy,
sortOrder,
});
// console.log("🔍 [TableList] API 호출 시작", {
// tableName: tableConfig.selectedTable,
// page,
// pageSize,
// sortBy,
// sortOrder,
// });
// 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원)
const response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
@ -883,12 +883,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const itemNumbers = (response.data || []).map((item: any) => item.item_number);
const uniqueItemNumbers = [...new Set(itemNumbers)];
console.log("✅ [TableList] API 응답 받음");
console.log(` - dataLength: ${response.data?.length || 0}`);
console.log(` - total: ${response.total}`);
console.log(` - itemNumbers: ${JSON.stringify(itemNumbers)}`);
console.log(` - uniqueItemNumbers: ${JSON.stringify(uniqueItemNumbers)}`);
console.log(` - isDuplicated: ${itemNumbers.length !== uniqueItemNumbers.length}`);
// console.log("✅ [TableList] API 응답 받음");
// console.log(` - dataLength: ${response.data?.length || 0}`);
// console.log(` - total: ${response.total}`);
// console.log(` - itemNumbers: ${JSON.stringify(itemNumbers)}`);
// console.log(` - uniqueItemNumbers: ${JSON.stringify(uniqueItemNumbers)}`);
// console.log(` - isDuplicated: ${itemNumbers.length !== uniqueItemNumbers.length}`);
setData(response.data || []);
setTotalPages(response.totalPages || 0);
@ -1310,41 +1310,41 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const lastColumnOrderRef = useRef<string>("");
useEffect(() => {
console.log("🔍 [컬럼 순서 전달 useEffect] 실행됨:", {
hasCallback: !!onSelectedRowsChange,
visibleColumnsLength: visibleColumns.length,
visibleColumnsNames: visibleColumns.map((c) => c.columnName),
});
// console.log("🔍 [컬럼 순서 전달 useEffect] 실행됨:", {
// hasCallback: !!onSelectedRowsChange,
// visibleColumnsLength: visibleColumns.length,
// visibleColumnsNames: visibleColumns.map((c) => c.columnName),
// });
if (!onSelectedRowsChange) {
console.warn("⚠️ onSelectedRowsChange 콜백이 없습니다!");
// console.warn("⚠️ onSelectedRowsChange 콜백이 없습니다!");
return;
}
if (visibleColumns.length === 0) {
console.warn("⚠️ visibleColumns가 비어있습니다!");
// console.warn("⚠️ visibleColumns가 비어있습니다!");
return;
}
const currentColumnOrder = visibleColumns.map((col) => col.columnName).filter((name) => name !== "__checkbox__"); // 체크박스 컬럼 제외
console.log("🔍 [컬럼 순서] 체크박스 제외 후:", currentColumnOrder);
// console.log("🔍 [컬럼 순서] 체크박스 제외 후:", currentColumnOrder);
// 컬럼 순서가 실제로 변경되었을 때만 전달 (무한 루프 방지)
const columnOrderString = currentColumnOrder.join(",");
console.log("🔍 [컬럼 순서] 비교:", {
current: columnOrderString,
last: lastColumnOrderRef.current,
isDifferent: columnOrderString !== lastColumnOrderRef.current,
});
// console.log("🔍 [컬럼 순서] 비교:", {
// current: columnOrderString,
// last: lastColumnOrderRef.current,
// isDifferent: columnOrderString !== lastColumnOrderRef.current,
// });
if (columnOrderString === lastColumnOrderRef.current) {
console.log("⏭️ 컬럼 순서 변경 없음, 전달 스킵");
// console.log("⏭️ 컬럼 순서 변경 없음, 전달 스킵");
return;
}
lastColumnOrderRef.current = columnOrderString;
console.log("📊 현재 화면 컬럼 순서 전달:", currentColumnOrder);
// console.log("📊 현재 화면 컬럼 순서 전달:", currentColumnOrder);
// 선택된 행 데이터 가져오기
const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index)));
@ -1862,13 +1862,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}, [tableConfig.selectedTable, fetchColumnLabels, fetchTableLabel]);
useEffect(() => {
console.log("🔍 [TableList] useEffect 실행 - 데이터 조회 트리거", {
isDesignMode,
tableName: tableConfig.selectedTable,
currentPage,
sortColumn,
sortDirection,
});
// console.log("🔍 [TableList] useEffect 실행 - 데이터 조회 트리거", {
// isDesignMode,
// tableName: tableConfig.selectedTable,
// currentPage,
// sortColumn,
// sortDirection,
// });
if (!isDesignMode && tableConfig.selectedTable) {
fetchTableDataDebounced();

View File

@ -161,5 +161,5 @@ ComponentRegistry.registerComponent({
},
});
console.log("✅ 탭 컴포넌트 등록 완료");
// console.log("✅ 탭 컴포넌트 등록 완료");

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;
}
/**