Compare commits
No commits in common. "184adffdcb3519ad4bf8fc9578747ed24e15fe3e" and "b66b7c66f00991ed89b76ce3a4d22ecaf6911427" have entirely different histories.
184adffdcb
...
b66b7c66f0
|
|
@ -1,19 +0,0 @@
|
||||||
{
|
|
||||||
"id": "12b583c9-a6b2-4c7f-8340-fd0e700aa32e",
|
|
||||||
"sentAt": "2025-10-22T05:17:38.303Z",
|
|
||||||
"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;\">ㄴㅇㄹㄴㅇㄹㄴㅇㄹㅇ리'ㅐㅔ'ㅑ678463ㅎㄱ휼췇흍츄</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border: 1px solid #ccc; padding: 15px; margin: 10px 0; background-color: #f9f9f9;\">\r\n <p><strong>---------- 전달된 메시지 ----------</strong></p>\r\n <p><strong>보낸 사람:</strong> \"이희진\" <zian9227@naver.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:32:34</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": "<74dbd467-6185-024d-dd60-bf4459ff9ea4@wace.me>",
|
|
||||||
"accepted": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"rejected": [],
|
|
||||||
"deletedAt": "2025-10-22T06:36:10.876Z"
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
{
|
|
||||||
"id": "1bb5ebfe-3f6c-4884-a043-161ae3f74f75",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [],
|
|
||||||
"cc": [],
|
|
||||||
"bcc": [],
|
|
||||||
"subject": "Fwd: ㄴㅇㄹㅇㄴㄴㄹ 테스트트트",
|
|
||||||
"htmlContent": "\n\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n전달된 메일:\n\n보낸사람: \"이희진\" <zian9227@naver.com>\n날짜: 2025. 10. 22. 오후 4:24:54\n제목: ㄴㅇㄹㅇㄴㄴㄹ\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\nㄹㅇㄴㄹㅇㄴㄹㅇㄴ\n",
|
|
||||||
"sentAt": "2025-10-22T07:49:50.811Z",
|
|
||||||
"status": "draft",
|
|
||||||
"isDraft": true,
|
|
||||||
"updatedAt": "2025-10-22T07:49:50.811Z",
|
|
||||||
"deletedAt": "2025-10-22T07:50:14.211Z"
|
|
||||||
}
|
|
||||||
|
|
@ -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": []
|
|
||||||
}
|
|
||||||
|
|
@ -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": []
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
{
|
|
||||||
"id": "375f2326-ca86-468a-bfc3-2d4c3825577b",
|
|
||||||
"sentAt": "2025-10-22T04:57:39.706Z",
|
|
||||||
"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:32:34</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": "<f085efa6-2668-0293-57de-88b1e7009dd1@wace.me>",
|
|
||||||
"accepted": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"rejected": [],
|
|
||||||
"deletedAt": "2025-10-22T07:11:04.666Z"
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
{
|
|
||||||
"id": "386e334a-df76-440c-ae8a-9bf06982fdc8",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [],
|
|
||||||
"cc": [],
|
|
||||||
"bcc": [],
|
|
||||||
"subject": "Fwd: ㄴ",
|
|
||||||
"htmlContent": "\n <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>---------- 전달된 메일 ----------</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 12:58:15</p>\n <p><strong>제목:</strong> ㄴ</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n <p style=\"white-space: pre-wrap;\">ㄴㅇㄹㄴㅇㄹㄴㅇㄹ\n</p>\n </div>\n ",
|
|
||||||
"sentAt": "2025-10-22T07:04:27.192Z",
|
|
||||||
"status": "draft",
|
|
||||||
"isDraft": true,
|
|
||||||
"updatedAt": "2025-10-22T07:04:57.280Z",
|
|
||||||
"deletedAt": "2025-10-22T07:50:17.136Z"
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"id": "3d411dc4-69a6-4236-b878-9693dff881be",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"cc": [],
|
|
||||||
"bcc": [],
|
|
||||||
"subject": "Re: ㄴ",
|
|
||||||
"htmlContent": "\n <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>원본 메일:</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 12:58:15</p>\n <p><strong>제목:</strong> ㄴ</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n <p style=\"white-space: pre-wrap;\">undefined</p>\n </div>\n ",
|
|
||||||
"sentAt": "2025-10-22T06:56:51.060Z",
|
|
||||||
"status": "draft",
|
|
||||||
"isDraft": true,
|
|
||||||
"updatedAt": "2025-10-22T06:56:51.060Z",
|
|
||||||
"deletedAt": "2025-10-22T07:50:22.989Z"
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
{
|
|
||||||
"id": "3e30a264-8431-44c7-96ef-eed551e66a11",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [],
|
|
||||||
"cc": [],
|
|
||||||
"bcc": [],
|
|
||||||
"subject": "Fwd: ㄴ",
|
|
||||||
"htmlContent": "\n <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>---------- 전달된 메일 ----------</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 12:58:15</p>\n <p><strong>제목:</strong> ㄴ</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n <p style=\"white-space: pre-wrap;\"></p>\n </div>\n ",
|
|
||||||
"sentAt": "2025-10-22T06:57:53.335Z",
|
|
||||||
"status": "draft",
|
|
||||||
"isDraft": true,
|
|
||||||
"updatedAt": "2025-10-22T07:00:23.394Z",
|
|
||||||
"deletedAt": "2025-10-22T07:50:20.510Z"
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
{
|
|
||||||
"id": "4a32bab5-364e-4037-bb00-31d2905824db",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [],
|
|
||||||
"cc": [],
|
|
||||||
"bcc": [],
|
|
||||||
"subject": "테스트 마지가",
|
|
||||||
"htmlContent": "ㅁㄴㅇㄹ",
|
|
||||||
"sentAt": "2025-10-22T07:49:29.948Z",
|
|
||||||
"status": "draft",
|
|
||||||
"isDraft": true,
|
|
||||||
"updatedAt": "2025-10-22T07:49:29.948Z",
|
|
||||||
"deletedAt": "2025-10-22T07:50:12.374Z"
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
{
|
|
||||||
"id": "5bfb2acd-023a-4865-a738-2900179db5fb",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [],
|
|
||||||
"cc": [],
|
|
||||||
"bcc": [],
|
|
||||||
"subject": "Fwd: ㄴ",
|
|
||||||
"htmlContent": "\n <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>---------- 전달된 메일 ----------</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 12:58:15</p>\n <p><strong>제목:</strong> ㄴ</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n <p style=\"white-space: pre-wrap;\">ㄴㅇㄹㄴㅇㄹㄴㅇㄹ\n</p>\n </div>\n ",
|
|
||||||
"sentAt": "2025-10-22T07:03:09.080Z",
|
|
||||||
"status": "draft",
|
|
||||||
"isDraft": true,
|
|
||||||
"updatedAt": "2025-10-22T07:03:39.150Z",
|
|
||||||
"deletedAt": "2025-10-22T07:50:19.035Z"
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"id": "683c1323-1895-403a-bb9a-4e111a8909f6",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"cc": [],
|
|
||||||
"bcc": [],
|
|
||||||
"subject": "Re: ㄴ",
|
|
||||||
"htmlContent": "\n <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>원본 메일:</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 12:58:15</p>\n <p><strong>제목:</strong> ㄴ</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n <p style=\"white-space: pre-wrap;\">undefined</p>\n </div>\n ",
|
|
||||||
"sentAt": "2025-10-22T06:54:55.097Z",
|
|
||||||
"status": "draft",
|
|
||||||
"isDraft": true,
|
|
||||||
"updatedAt": "2025-10-22T06:54:55.097Z",
|
|
||||||
"deletedAt": "2025-10-22T07:50:24.672Z"
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
{
|
|
||||||
"id": "7bed27d5-dae4-4ba8-85d0-c474c4fb907a",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [],
|
|
||||||
"cc": [],
|
|
||||||
"bcc": [],
|
|
||||||
"subject": "Fwd: ㅏㅣ",
|
|
||||||
"htmlContent": "\n <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>---------- 전달된 메일 ----------</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:32:34</p>\n <p><strong>제목:</strong> ㅏㅣ</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n undefined\n </div>\n ",
|
|
||||||
"sentAt": "2025-10-22T06:41:52.984Z",
|
|
||||||
"status": "draft",
|
|
||||||
"isDraft": true,
|
|
||||||
"updatedAt": "2025-10-22T06:46:23.051Z",
|
|
||||||
"deletedAt": "2025-10-22T07:50:29.124Z"
|
|
||||||
}
|
|
||||||
|
|
@ -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": []
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
{
|
|
||||||
"id": "8990ea86-3112-4e7c-b3e0-8b494181c4e0",
|
|
||||||
"accountName": "",
|
|
||||||
"accountEmail": "",
|
|
||||||
"to": [],
|
|
||||||
"subject": "",
|
|
||||||
"htmlContent": "",
|
|
||||||
"sentAt": "2025-10-22T06:17:31.379Z",
|
|
||||||
"status": "draft",
|
|
||||||
"isDraft": true,
|
|
||||||
"updatedAt": "2025-10-22T06:17:31.379Z",
|
|
||||||
"deletedAt": "2025-10-22T07:50:30.736Z"
|
|
||||||
}
|
|
||||||
|
|
@ -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": []
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
{
|
|
||||||
"id": "99703f2c-740c-492e-a866-a04289a9b699",
|
|
||||||
"accountName": "",
|
|
||||||
"accountEmail": "",
|
|
||||||
"to": [],
|
|
||||||
"subject": "",
|
|
||||||
"htmlContent": "",
|
|
||||||
"sentAt": "2025-10-22T06:20:08.450Z",
|
|
||||||
"status": "draft",
|
|
||||||
"isDraft": true,
|
|
||||||
"updatedAt": "2025-10-22T06:20:08.450Z",
|
|
||||||
"deletedAt": "2025-10-22T06:36:07.797Z"
|
|
||||||
}
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
{
|
|
||||||
"id": "9ab1e5ee-4f5e-4b79-9769-5e2a1e1ffc8e",
|
|
||||||
"sentAt": "2025-10-22T04:31:17.175Z",
|
|
||||||
"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": "<0f215ba8-a1e4-8c5a-f43f-962f0717c161@wace.me>",
|
|
||||||
"accepted": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"rejected": [],
|
|
||||||
"deletedAt": "2025-10-22T07:11:10.245Z"
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"id": "9d0b9fcf-cabf-4053-b6b6-6e110add22de",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"cc": [],
|
|
||||||
"bcc": [],
|
|
||||||
"subject": "Re: ㅏㅣ",
|
|
||||||
"htmlContent": "\n <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>원본 메일:</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:32:34</p>\n <p><strong>제목:</strong> ㅏㅣ</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n <p style=\"white-space: pre-wrap;\">undefined</p>\n </div>\n ",
|
|
||||||
"sentAt": "2025-10-22T06:50:04.224Z",
|
|
||||||
"status": "draft",
|
|
||||||
"isDraft": true,
|
|
||||||
"updatedAt": "2025-10-22T06:50:04.224Z",
|
|
||||||
"deletedAt": "2025-10-22T07:50:26.224Z"
|
|
||||||
}
|
|
||||||
|
|
@ -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": []
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"id": "b293e530-2b2d-4b8a-8081-d103fab5a13f",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"cc": [],
|
|
||||||
"bcc": [],
|
|
||||||
"subject": "Re: 수신메일확인용",
|
|
||||||
"htmlContent": "\n <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>원본 메일:</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 13. 오전 10:40:30</p>\n <p><strong>제목:</strong> 수신메일확인용</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n undefined\n </div>\n ",
|
|
||||||
"sentAt": "2025-10-22T06:47:53.815Z",
|
|
||||||
"status": "draft",
|
|
||||||
"isDraft": true,
|
|
||||||
"updatedAt": "2025-10-22T06:48:53.876Z",
|
|
||||||
"deletedAt": "2025-10-22T07:50:27.706Z"
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
{
|
|
||||||
"id": "cf892a77-1998-4165-bb9d-b390451465b2",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [],
|
|
||||||
"cc": [],
|
|
||||||
"bcc": [],
|
|
||||||
"subject": "Fwd: ㄴ",
|
|
||||||
"htmlContent": "\n\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n전달된 메일:\n\n보낸사람: \"이희진\" <zian9227@naver.com>\n날짜: 2025. 10. 22. 오후 12:58:15\n제목: ㄴ\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\nㄴㅇㄹㄴㅇㄹㄴㅇㄹ\n",
|
|
||||||
"sentAt": "2025-10-22T07:06:11.620Z",
|
|
||||||
"status": "draft",
|
|
||||||
"isDraft": true,
|
|
||||||
"updatedAt": "2025-10-22T07:07:11.749Z",
|
|
||||||
"deletedAt": "2025-10-22T07:50:15.739Z"
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
{
|
|
||||||
"id": "e3501abc-cd31-4b20-bb02-3c7ddbe54eb8",
|
|
||||||
"accountName": "",
|
|
||||||
"accountEmail": "",
|
|
||||||
"to": [],
|
|
||||||
"subject": "",
|
|
||||||
"htmlContent": "",
|
|
||||||
"sentAt": "2025-10-22T06:15:02.128Z",
|
|
||||||
"status": "draft",
|
|
||||||
"isDraft": true,
|
|
||||||
"updatedAt": "2025-10-22T06:15:02.128Z",
|
|
||||||
"deletedAt": "2025-10-22T07:08:43.543Z"
|
|
||||||
}
|
|
||||||
|
|
@ -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": []
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
{
|
|
||||||
"id": "eb92ed00-cc4f-4cc8-94c9-9bef312d16db",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [],
|
|
||||||
"cc": [],
|
|
||||||
"bcc": [],
|
|
||||||
"subject": "메일 임시저장 테스트 4",
|
|
||||||
"htmlContent": "asd",
|
|
||||||
"sentAt": "2025-10-22T06:21:40.019Z",
|
|
||||||
"status": "draft",
|
|
||||||
"isDraft": true,
|
|
||||||
"updatedAt": "2025-10-22T06:21:40.019Z",
|
|
||||||
"deletedAt": "2025-10-22T06:36:05.306Z"
|
|
||||||
}
|
|
||||||
|
|
@ -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": []
|
|
||||||
}
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
{
|
|
||||||
"id": "fd2a8b41-2e6e-4e5e-b8e8-63d31efc5082",
|
|
||||||
"sentAt": "2025-10-22T04:29:14.738Z",
|
|
||||||
"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 ",
|
|
||||||
"attachments": [
|
|
||||||
{
|
|
||||||
"filename": "test용 이미지2.png",
|
|
||||||
"originalName": "test용 이미지2.png",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1761107350246-298369766.png",
|
|
||||||
"mimetype": "image/png"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"status": "success",
|
|
||||||
"messageId": "<e68a0501-f79a-8713-a625-e882f711b30d@wace.me>",
|
|
||||||
"accepted": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"rejected": [],
|
|
||||||
"deletedAt": "2025-10-22T07:11:12.907Z"
|
|
||||||
}
|
|
||||||
|
|
@ -31,8 +31,6 @@
|
||||||
"nodemailer": "^6.10.1",
|
"nodemailer": "^6.10.1",
|
||||||
"oracledb": "^6.9.0",
|
"oracledb": "^6.9.0",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"quill": "^2.0.3",
|
|
||||||
"react-quill": "^2.0.0",
|
|
||||||
"redis": "^4.6.10",
|
"redis": "^4.6.10",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"winston": "^3.11.0"
|
"winston": "^3.11.0"
|
||||||
|
|
@ -3435,21 +3433,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/quill": {
|
|
||||||
"version": "1.3.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/quill/-/quill-1.3.10.tgz",
|
|
||||||
"integrity": "sha512-IhW3fPW+bkt9MLNlycw8u8fWb7oO7W5URC9MfZYHBlA24rex9rs23D5DETChu1zvgVdc5ka64ICjJOgQMr6Shw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"parchment": "^1.1.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/quill/node_modules/parchment": {
|
|
||||||
"version": "1.1.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz",
|
|
||||||
"integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
|
|
||||||
"license": "BSD-3-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/@types/range-parser": {
|
"node_modules/@types/range-parser": {
|
||||||
"version": "1.2.7",
|
"version": "1.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||||
|
|
@ -4454,24 +4437,6 @@
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/call-bind": {
|
|
||||||
"version": "1.0.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
|
|
||||||
"integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"call-bind-apply-helpers": "^1.0.0",
|
|
||||||
"es-define-property": "^1.0.0",
|
|
||||||
"get-intrinsic": "^1.2.4",
|
|
||||||
"set-function-length": "^1.2.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/call-bind-apply-helpers": {
|
"node_modules/call-bind-apply-helpers": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
|
|
@ -4645,15 +4610,6 @@
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/clone": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/cluster-key-slot": {
|
"node_modules/cluster-key-slot": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||||
|
|
@ -4988,26 +4944,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/deep-equal": {
|
|
||||||
"version": "1.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz",
|
|
||||||
"integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"is-arguments": "^1.1.1",
|
|
||||||
"is-date-object": "^1.0.5",
|
|
||||||
"is-regex": "^1.1.4",
|
|
||||||
"object-is": "^1.1.5",
|
|
||||||
"object-keys": "^1.1.1",
|
|
||||||
"regexp.prototype.flags": "^1.5.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/deep-is": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
|
|
@ -5052,23 +4988,6 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/define-data-property": {
|
|
||||||
"version": "1.1.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
|
||||||
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"es-define-property": "^1.0.0",
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"gopd": "^1.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/define-lazy-prop": {
|
"node_modules/define-lazy-prop": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz",
|
||||||
|
|
@ -5081,23 +5000,6 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/define-properties": {
|
|
||||||
"version": "1.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
|
|
||||||
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"define-data-property": "^1.0.1",
|
|
||||||
"has-property-descriptors": "^1.0.0",
|
|
||||||
"object-keys": "^1.1.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/delayed-stream": {
|
"node_modules/delayed-stream": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
|
|
@ -5652,12 +5554,6 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eventemitter3": {
|
|
||||||
"version": "5.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
|
||||||
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/events": {
|
"node_modules/events": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||||
|
|
@ -5793,12 +5689,6 @@
|
||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/extend": {
|
|
||||||
"version": "3.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
|
||||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
|
|
@ -5806,12 +5696,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-diff": {
|
|
||||||
"version": "1.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
|
|
||||||
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/fast-glob": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||||
|
|
@ -6113,15 +5997,6 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/functions-have-names": {
|
|
||||||
"version": "1.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
|
|
||||||
"integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/generate-function": {
|
"node_modules/generate-function": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
|
||||||
|
|
@ -6374,18 +6249,6 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/has-property-descriptors": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"es-define-property": "^1.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/has-symbols": {
|
"node_modules/has-symbols": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
|
|
@ -6700,22 +6563,6 @@
|
||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/is-arguments": {
|
|
||||||
"version": "1.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
|
|
||||||
"integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"call-bound": "^1.0.2",
|
|
||||||
"has-tostringtag": "^1.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/is-arrayish": {
|
"node_modules/is-arrayish": {
|
||||||
"version": "0.2.1",
|
"version": "0.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
|
||||||
|
|
@ -6752,22 +6599,6 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/is-date-object": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"call-bound": "^1.0.2",
|
|
||||||
"has-tostringtag": "^1.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/is-docker": {
|
"node_modules/is-docker": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz",
|
||||||
|
|
@ -6870,24 +6701,6 @@
|
||||||
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/is-regex": {
|
|
||||||
"version": "1.2.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
|
||||||
"integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"call-bound": "^1.0.2",
|
|
||||||
"gopd": "^1.2.0",
|
|
||||||
"has-tostringtag": "^1.0.2",
|
|
||||||
"hasown": "^2.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/is-stream": {
|
"node_modules/is-stream": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
|
||||||
|
|
@ -7845,24 +7658,6 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lodash": {
|
|
||||||
"version": "4.17.21",
|
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
|
||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/lodash-es": {
|
|
||||||
"version": "4.17.21",
|
|
||||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
|
|
||||||
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/lodash.clonedeep": {
|
|
||||||
"version": "4.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
|
||||||
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/lodash.includes": {
|
"node_modules/lodash.includes": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
|
|
@ -7875,13 +7670,6 @@
|
||||||
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
"integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash.isequal": {
|
|
||||||
"version": "4.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
|
|
||||||
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
|
|
||||||
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/lodash.isinteger": {
|
"node_modules/lodash.isinteger": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
|
||||||
|
|
@ -8504,31 +8292,6 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/object-is": {
|
|
||||||
"version": "1.1.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
|
|
||||||
"integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"call-bind": "^1.0.7",
|
|
||||||
"define-properties": "^1.2.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/object-keys": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/on-finished": {
|
"node_modules/on-finished": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
|
|
@ -8673,12 +8436,6 @@
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/parchment": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
|
|
||||||
"license": "BSD-3-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
|
|
@ -9203,35 +8960,6 @@
|
||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/quill": {
|
|
||||||
"version": "2.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
|
|
||||||
"integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"dependencies": {
|
|
||||||
"eventemitter3": "^5.0.1",
|
|
||||||
"lodash-es": "^4.17.21",
|
|
||||||
"parchment": "^3.0.0",
|
|
||||||
"quill-delta": "^5.1.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"npm": ">=8.2.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/quill-delta": {
|
|
||||||
"version": "5.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
|
|
||||||
"integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"fast-diff": "^1.3.0",
|
|
||||||
"lodash.clonedeep": "^4.5.0",
|
|
||||||
"lodash.isequal": "^4.5.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/range-parser": {
|
"node_modules/range-parser": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||||
|
|
@ -9275,67 +9003,6 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/react-quill": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-quill/-/react-quill-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-4qQtv1FtCfLgoD3PXAur5RyxuUbPXQGOHgTlFie3jtxp43mXDtzCKaOgQ3mLyZfi1PUlyjycfivKelFhy13QUg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/quill": "^1.3.10",
|
|
||||||
"lodash": "^4.17.4",
|
|
||||||
"quill": "^1.3.7"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "^16 || ^17 || ^18",
|
|
||||||
"react-dom": "^16 || ^17 || ^18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-quill/node_modules/eventemitter3": {
|
|
||||||
"version": "2.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz",
|
|
||||||
"integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/react-quill/node_modules/fast-diff": {
|
|
||||||
"version": "1.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz",
|
|
||||||
"integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/react-quill/node_modules/parchment": {
|
|
||||||
"version": "1.1.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz",
|
|
||||||
"integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
|
|
||||||
"license": "BSD-3-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/react-quill/node_modules/quill": {
|
|
||||||
"version": "1.3.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/quill/-/quill-1.3.7.tgz",
|
|
||||||
"integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==",
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"dependencies": {
|
|
||||||
"clone": "^2.1.1",
|
|
||||||
"deep-equal": "^1.0.1",
|
|
||||||
"eventemitter3": "^2.0.3",
|
|
||||||
"extend": "^3.0.2",
|
|
||||||
"parchment": "^1.1.4",
|
|
||||||
"quill-delta": "^3.6.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-quill/node_modules/quill-delta": {
|
|
||||||
"version": "3.6.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz",
|
|
||||||
"integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"deep-equal": "^1.0.1",
|
|
||||||
"extend": "^3.0.2",
|
|
||||||
"fast-diff": "1.1.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/readable-stream": {
|
"node_modules/readable-stream": {
|
||||||
"version": "2.3.8",
|
"version": "2.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||||
|
|
@ -9387,26 +9054,6 @@
|
||||||
"@redis/time-series": "1.1.0"
|
"@redis/time-series": "1.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/regexp.prototype.flags": {
|
|
||||||
"version": "1.5.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
|
||||||
"integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"call-bind": "^1.0.8",
|
|
||||||
"define-properties": "^1.2.1",
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"get-proto": "^1.0.1",
|
|
||||||
"gopd": "^1.2.0",
|
|
||||||
"set-function-name": "^2.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/require-directory": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
|
|
@ -9678,38 +9325,6 @@
|
||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/set-function-length": {
|
|
||||||
"version": "1.2.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
|
||||||
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"define-data-property": "^1.1.4",
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"function-bind": "^1.1.2",
|
|
||||||
"get-intrinsic": "^1.2.4",
|
|
||||||
"gopd": "^1.0.1",
|
|
||||||
"has-property-descriptors": "^1.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/set-function-name": {
|
|
||||||
"version": "2.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
|
|
||||||
"integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"define-data-property": "^1.1.4",
|
|
||||||
"es-errors": "^1.3.0",
|
|
||||||
"functions-have-names": "^1.2.3",
|
|
||||||
"has-property-descriptors": "^1.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/setprototypeof": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -45,8 +45,6 @@
|
||||||
"nodemailer": "^6.10.1",
|
"nodemailer": "^6.10.1",
|
||||||
"oracledb": "^6.9.0",
|
"oracledb": "^6.9.0",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"quill": "^2.0.3",
|
|
||||||
"react-quill": "^2.0.0",
|
|
||||||
"redis": "^4.6.10",
|
"redis": "^4.6.10",
|
||||||
"uuid": "^13.0.0",
|
"uuid": "^13.0.0",
|
||||||
"winston": "^3.11.0"
|
"winston": "^3.11.0"
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ import layoutRoutes from "./routes/layoutRoutes";
|
||||||
import mailTemplateFileRoutes from "./routes/mailTemplateFileRoutes";
|
import mailTemplateFileRoutes from "./routes/mailTemplateFileRoutes";
|
||||||
import mailAccountFileRoutes from "./routes/mailAccountFileRoutes";
|
import mailAccountFileRoutes from "./routes/mailAccountFileRoutes";
|
||||||
import mailSendSimpleRoutes from "./routes/mailSendSimpleRoutes";
|
import mailSendSimpleRoutes from "./routes/mailSendSimpleRoutes";
|
||||||
import mailSentHistoryRoutes from "./routes/mailSentHistoryRoutes";
|
|
||||||
import mailReceiveBasicRoutes from "./routes/mailReceiveBasicRoutes";
|
import mailReceiveBasicRoutes from "./routes/mailReceiveBasicRoutes";
|
||||||
import dataRoutes from "./routes/dataRoutes";
|
import dataRoutes from "./routes/dataRoutes";
|
||||||
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
|
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
|
||||||
|
|
@ -187,7 +186,6 @@ app.use("/api/layouts", layoutRoutes);
|
||||||
app.use("/api/mail/accounts", mailAccountFileRoutes); // 파일 기반 계정
|
app.use("/api/mail/accounts", mailAccountFileRoutes); // 파일 기반 계정
|
||||||
app.use("/api/mail/templates-file", mailTemplateFileRoutes); // 파일 기반 템플릿
|
app.use("/api/mail/templates-file", mailTemplateFileRoutes); // 파일 기반 템플릿
|
||||||
app.use("/api/mail/send", mailSendSimpleRoutes); // 메일 발송
|
app.use("/api/mail/send", mailSendSimpleRoutes); // 메일 발송
|
||||||
app.use("/api/mail/sent", mailSentHistoryRoutes); // 메일 발송 이력
|
|
||||||
app.use("/api/mail/receive", mailReceiveBasicRoutes); // 메일 수신
|
app.use("/api/mail/receive", mailReceiveBasicRoutes); // 메일 수신
|
||||||
app.use("/api/screen", screenStandardRoutes);
|
app.use("/api/screen", screenStandardRoutes);
|
||||||
app.use("/api/data", dataRoutes);
|
app.use("/api/data", dataRoutes);
|
||||||
|
|
@ -272,28 +270,6 @@ app.listen(PORT, HOST, async () => {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`❌ 리스크/알림 자동 갱신 시작 실패:`, error);
|
logger.error(`❌ 리스크/알림 자동 갱신 시작 실패:`, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 메일 자동 삭제 (30일 지난 삭제된 메일) - 매일 새벽 2시 실행
|
|
||||||
try {
|
|
||||||
const cron = await import("node-cron");
|
|
||||||
const { mailSentHistoryService } = await import(
|
|
||||||
"./services/mailSentHistoryService"
|
|
||||||
);
|
|
||||||
|
|
||||||
cron.schedule("0 2 * * *", async () => {
|
|
||||||
try {
|
|
||||||
logger.info("🗑️ 30일 지난 삭제된 메일 자동 삭제 시작...");
|
|
||||||
const deletedCount = await mailSentHistoryService.cleanupOldDeletedMails();
|
|
||||||
logger.info(`✅ 30일 지난 메일 ${deletedCount}개 자동 삭제 완료`);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("❌ 메일 자동 삭제 실패:", error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(`⏰ 메일 자동 삭제 스케줄러가 시작되었습니다. (매일 새벽 2시)`);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`❌ 메일 자동 삭제 스케줄러 시작 실패:`, error);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,11 @@ export class MailReceiveBasicController {
|
||||||
*/
|
*/
|
||||||
async getMailList(req: Request, res: Response) {
|
async getMailList(req: Request, res: Response) {
|
||||||
try {
|
try {
|
||||||
// console.log('📬 메일 목록 조회 요청:', {
|
console.log('📬 메일 목록 조회 요청:', {
|
||||||
// params: req.params,
|
params: req.params,
|
||||||
// path: req.path,
|
path: req.path,
|
||||||
// originalUrl: req.originalUrl
|
originalUrl: req.originalUrl
|
||||||
// });
|
});
|
||||||
|
|
||||||
const { accountId } = req.params;
|
const { accountId } = req.params;
|
||||||
const limit = parseInt(req.query.limit as string) || 50;
|
const limit = parseInt(req.query.limit as string) || 50;
|
||||||
|
|
@ -49,11 +49,11 @@ export class MailReceiveBasicController {
|
||||||
*/
|
*/
|
||||||
async getMailDetail(req: Request, res: Response) {
|
async getMailDetail(req: Request, res: Response) {
|
||||||
try {
|
try {
|
||||||
// console.log('🔍 메일 상세 조회 요청:', {
|
console.log('🔍 메일 상세 조회 요청:', {
|
||||||
// params: req.params,
|
params: req.params,
|
||||||
// path: req.path,
|
path: req.path,
|
||||||
// originalUrl: req.originalUrl
|
originalUrl: req.originalUrl
|
||||||
// });
|
});
|
||||||
|
|
||||||
const { accountId, seqno } = req.params;
|
const { accountId, seqno } = req.params;
|
||||||
const seqnoNumber = parseInt(seqno, 10);
|
const seqnoNumber = parseInt(seqno, 10);
|
||||||
|
|
@ -121,39 +121,39 @@ export class MailReceiveBasicController {
|
||||||
*/
|
*/
|
||||||
async downloadAttachment(req: Request, res: Response) {
|
async downloadAttachment(req: Request, res: Response) {
|
||||||
try {
|
try {
|
||||||
// console.log('📎🎯 컨트롤러 downloadAttachment 진입');
|
console.log('📎🎯 컨트롤러 downloadAttachment 진입');
|
||||||
const { accountId, seqno, index } = req.params;
|
const { accountId, seqno, index } = req.params;
|
||||||
// console.log(`📎 파라미터: accountId=${accountId}, seqno=${seqno}, index=${index}`);
|
console.log(`📎 파라미터: accountId=${accountId}, seqno=${seqno}, index=${index}`);
|
||||||
|
|
||||||
const seqnoNumber = parseInt(seqno, 10);
|
const seqnoNumber = parseInt(seqno, 10);
|
||||||
const indexNumber = parseInt(index, 10);
|
const indexNumber = parseInt(index, 10);
|
||||||
|
|
||||||
if (isNaN(seqnoNumber) || isNaN(indexNumber)) {
|
if (isNaN(seqnoNumber) || isNaN(indexNumber)) {
|
||||||
// console.log('❌ 유효하지 않은 파라미터');
|
console.log('❌ 유효하지 않은 파라미터');
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '유효하지 않은 파라미터입니다.',
|
message: '유효하지 않은 파라미터입니다.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log('📎 서비스 호출 시작...');
|
console.log('📎 서비스 호출 시작...');
|
||||||
const result = await this.mailReceiveService.downloadAttachment(
|
const result = await this.mailReceiveService.downloadAttachment(
|
||||||
accountId,
|
accountId,
|
||||||
seqnoNumber,
|
seqnoNumber,
|
||||||
indexNumber
|
indexNumber
|
||||||
);
|
);
|
||||||
// console.log(`📎 서비스 호출 완료: result=${result ? '있음' : '없음'}`);
|
console.log(`📎 서비스 호출 완료: result=${result ? '있음' : '없음'}`);
|
||||||
|
|
||||||
if (!result) {
|
if (!result) {
|
||||||
// console.log('❌ 첨부파일을 찾을 수 없음');
|
console.log('❌ 첨부파일을 찾을 수 없음');
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '첨부파일을 찾을 수 없습니다.',
|
message: '첨부파일을 찾을 수 없습니다.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log(`📎 파일 다운로드 시작: ${result.filename}`);
|
console.log(`📎 파일 다운로드 시작: ${result.filename}`);
|
||||||
// console.log(`📎 파일 경로: ${result.filePath}`);
|
console.log(`📎 파일 경로: ${result.filePath}`);
|
||||||
|
|
||||||
// 파일 다운로드
|
// 파일 다운로드
|
||||||
res.download(result.filePath, result.filename, (err) => {
|
res.download(result.filePath, result.filename, (err) => {
|
||||||
|
|
@ -217,35 +217,5 @@ export class MailReceiveBasicController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE /api/mail/receive/:accountId/:seqno
|
|
||||||
* IMAP 서버에서 메일 삭제
|
|
||||||
*/
|
|
||||||
async deleteMail(req: Request, res: Response) {
|
|
||||||
try {
|
|
||||||
const { accountId, seqno } = req.params;
|
|
||||||
const seqnoNumber = parseInt(seqno, 10);
|
|
||||||
|
|
||||||
if (isNaN(seqnoNumber)) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: '유효하지 않은 메일 번호입니다.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await this.mailReceiveService.deleteMail(accountId, seqnoNumber);
|
|
||||||
|
|
||||||
return res.status(200).json(result);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error('메일 삭제 실패:', error);
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: error instanceof Error ? error.message : '메일 삭제 실패',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const mailReceiveBasicController = new MailReceiveBasicController();
|
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,14 @@ export class MailSendSimpleController {
|
||||||
*/
|
*/
|
||||||
async sendMail(req: Request, res: Response) {
|
async sendMail(req: Request, res: Response) {
|
||||||
try {
|
try {
|
||||||
// console.log('📧 메일 발송 요청 수신:', {
|
console.log('📧 메일 발송 요청 수신:', {
|
||||||
// accountId: req.body.accountId,
|
accountId: req.body.accountId,
|
||||||
// to: req.body.to,
|
to: req.body.to,
|
||||||
// cc: req.body.cc,
|
cc: req.body.cc,
|
||||||
// bcc: req.body.bcc,
|
bcc: req.body.bcc,
|
||||||
// subject: req.body.subject,
|
subject: req.body.subject,
|
||||||
// attachments: req.files ? (req.files as Express.Multer.File[]).length : 0,
|
attachments: req.files ? (req.files as Express.Multer.File[]).length : 0,
|
||||||
// });
|
});
|
||||||
|
|
||||||
// FormData에서 JSON 문자열 파싱
|
// FormData에서 JSON 문자열 파싱
|
||||||
const accountId = req.body.accountId;
|
const accountId = req.body.accountId;
|
||||||
|
|
@ -31,7 +31,7 @@ export class MailSendSimpleController {
|
||||||
|
|
||||||
// 필수 파라미터 검증
|
// 필수 파라미터 검증
|
||||||
if (!accountId || !to || !Array.isArray(to) || to.length === 0) {
|
if (!accountId || !to || !Array.isArray(to) || to.length === 0) {
|
||||||
// console.log('❌ 필수 파라미터 누락');
|
console.log('❌ 필수 파라미터 누락');
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: '계정 ID와 수신자 이메일이 필요합니다.',
|
message: '계정 ID와 수신자 이메일이 필요합니다.',
|
||||||
|
|
@ -63,9 +63,9 @@ export class MailSendSimpleController {
|
||||||
if (req.body.fileNames) {
|
if (req.body.fileNames) {
|
||||||
try {
|
try {
|
||||||
parsedFileNames = JSON.parse(req.body.fileNames);
|
parsedFileNames = JSON.parse(req.body.fileNames);
|
||||||
// console.log('📎 프론트엔드에서 받은 파일명들:', parsedFileNames);
|
console.log('📎 프론트엔드에서 받은 파일명들:', parsedFileNames);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// console.warn('파일명 파싱 실패, multer originalname 사용');
|
console.warn('파일명 파싱 실패, multer originalname 사용');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,10 +83,10 @@ export class MailSendSimpleController {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// console.log('📎 최종 첨부파일 정보:', attachments.map(a => ({
|
console.log('📎 최종 첨부파일 정보:', attachments.map(a => ({
|
||||||
// filename: a.filename,
|
filename: a.filename,
|
||||||
// path: a.path.split('/').pop()
|
path: a.path.split('/').pop()
|
||||||
// })));
|
})));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 메일 발송
|
// 메일 발송
|
||||||
|
|
@ -125,63 +125,6 @@ export class MailSendSimpleController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 대량 메일 발송
|
|
||||||
*/
|
|
||||||
async sendBulkMail(req: Request, res: Response) {
|
|
||||||
try {
|
|
||||||
const { accountId, templateId, customHtml, subject, recipients } = req.body;
|
|
||||||
|
|
||||||
// 필수 파라미터 검증
|
|
||||||
if (!accountId || !subject || !recipients || !Array.isArray(recipients)) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: '필수 파라미터가 누락되었습니다.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 템플릿 또는 직접 작성 중 하나는 있어야 함
|
|
||||||
if (!templateId && !customHtml) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: '템플릿 또는 메일 내용 중 하나는 필수입니다.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recipients.length === 0) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: '수신자가 없습니다.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// console.log(`📧 대량 발송 요청: ${recipients.length}명`);
|
|
||||||
|
|
||||||
// 대량 발송 실행
|
|
||||||
const result = await mailSendSimpleService.sendBulkMail({
|
|
||||||
accountId,
|
|
||||||
templateId, // 선택
|
|
||||||
customHtml, // 선택
|
|
||||||
subject,
|
|
||||||
recipients,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
data: result,
|
|
||||||
message: `${result.success}/${result.total} 건 발송 완료`,
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const err = error as Error;
|
|
||||||
console.error('❌ 대량 발송 오류:', err);
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: '대량 발송 중 오류가 발생했습니다.',
|
|
||||||
error: err.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SMTP 연결 테스트
|
* SMTP 연결 테스트
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -11,14 +11,12 @@ export class MailSentHistoryController {
|
||||||
page: req.query.page ? parseInt(req.query.page as string) : undefined,
|
page: req.query.page ? parseInt(req.query.page as string) : undefined,
|
||||||
limit: req.query.limit ? parseInt(req.query.limit as string) : undefined,
|
limit: req.query.limit ? parseInt(req.query.limit as string) : undefined,
|
||||||
searchTerm: req.query.searchTerm as string | undefined,
|
searchTerm: req.query.searchTerm as string | undefined,
|
||||||
status: req.query.status as 'success' | 'failed' | 'draft' | 'all' | undefined,
|
status: req.query.status as 'success' | 'failed' | 'all' | undefined,
|
||||||
accountId: req.query.accountId as string | undefined,
|
accountId: req.query.accountId as string | undefined,
|
||||||
startDate: req.query.startDate as string | undefined,
|
startDate: req.query.startDate as string | undefined,
|
||||||
endDate: req.query.endDate as string | undefined,
|
endDate: req.query.endDate as string | undefined,
|
||||||
sortBy: req.query.sortBy as 'sentAt' | 'subject' | 'updatedAt' | undefined,
|
sortBy: req.query.sortBy as 'sentAt' | 'subject' | undefined,
|
||||||
sortOrder: req.query.sortOrder as 'asc' | 'desc' | undefined,
|
sortOrder: req.query.sortOrder as 'asc' | 'desc' | undefined,
|
||||||
includeDeleted: req.query.includeDeleted === 'true',
|
|
||||||
onlyDeleted: req.query.onlyDeleted === 'true',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await mailSentHistoryService.getSentMailList(query);
|
const result = await mailSentHistoryService.getSentMailList(query);
|
||||||
|
|
@ -114,144 +112,6 @@ export class MailSentHistoryController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 임시 저장 (Draft)
|
|
||||||
*/
|
|
||||||
async saveDraft(req: Request, res: Response) {
|
|
||||||
try {
|
|
||||||
const draft = await mailSentHistoryService.saveDraft(req.body);
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
data: draft,
|
|
||||||
message: '임시 저장되었습니다.',
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const err = error as Error;
|
|
||||||
console.error('임시 저장 실패:', err);
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: '임시 저장 중 오류가 발생했습니다.',
|
|
||||||
error: err.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 임시 저장 업데이트
|
|
||||||
*/
|
|
||||||
async updateDraft(req: Request, res: Response) {
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
if (!id) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: '임시 저장 ID가 필요합니다.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await mailSentHistoryService.updateDraft(id, req.body);
|
|
||||||
|
|
||||||
if (!updated) {
|
|
||||||
return res.status(404).json({
|
|
||||||
success: false,
|
|
||||||
message: '임시 저장을 찾을 수 없습니다.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
data: updated,
|
|
||||||
message: '임시 저장이 업데이트되었습니다.',
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const err = error as Error;
|
|
||||||
console.error('임시 저장 업데이트 실패:', err);
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: '임시 저장 업데이트 중 오류가 발생했습니다.',
|
|
||||||
error: err.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 메일 복구
|
|
||||||
*/
|
|
||||||
async restoreMail(req: Request, res: Response) {
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
if (!id) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: '메일 ID가 필요합니다.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const success = await mailSentHistoryService.restoreMail(id);
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
return res.status(404).json({
|
|
||||||
success: false,
|
|
||||||
message: '복구할 메일을 찾을 수 없습니다.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
message: '메일이 복구되었습니다.',
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const err = error as Error;
|
|
||||||
console.error('메일 복구 실패:', err);
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: '메일 복구 중 오류가 발생했습니다.',
|
|
||||||
error: err.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 메일 영구 삭제
|
|
||||||
*/
|
|
||||||
async permanentlyDelete(req: Request, res: Response) {
|
|
||||||
try {
|
|
||||||
const { id } = req.params;
|
|
||||||
|
|
||||||
if (!id) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: '메일 ID가 필요합니다.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const success = await mailSentHistoryService.permanentlyDeleteMail(id);
|
|
||||||
|
|
||||||
if (!success) {
|
|
||||||
return res.status(404).json({
|
|
||||||
success: false,
|
|
||||||
message: '삭제할 메일을 찾을 수 없습니다.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
message: '메일이 영구 삭제되었습니다.',
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const err = error as Error;
|
|
||||||
console.error('메일 영구 삭제 실패:', err);
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: '메일 영구 삭제 중 오류가 발생했습니다.',
|
|
||||||
error: err.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 통계 조회
|
* 통계 조회
|
||||||
*/
|
*/
|
||||||
|
|
@ -274,117 +134,6 @@ export class MailSentHistoryController {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 일괄 삭제
|
|
||||||
*/
|
|
||||||
async bulkDelete(req: Request, res: Response) {
|
|
||||||
try {
|
|
||||||
const { ids } = req.body;
|
|
||||||
|
|
||||||
if (!ids || !Array.isArray(ids) || ids.length === 0) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: '삭제할 메일 ID 목록이 필요합니다.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
ids.map((id: string) => mailSentHistoryService.deleteSentMail(id))
|
|
||||||
);
|
|
||||||
|
|
||||||
const successCount = results.filter((r) => r.status === 'fulfilled' && r.value).length;
|
|
||||||
const failCount = results.length - successCount;
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
message: `${successCount}개 메일 삭제 완료 (실패: ${failCount}개)`,
|
|
||||||
data: { successCount, failCount },
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const err = error as Error;
|
|
||||||
console.error('일괄 삭제 실패:', err);
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: '일괄 삭제 중 오류가 발생했습니다.',
|
|
||||||
error: err.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 일괄 영구 삭제
|
|
||||||
*/
|
|
||||||
async bulkPermanentDelete(req: Request, res: Response) {
|
|
||||||
try {
|
|
||||||
const { ids } = req.body;
|
|
||||||
|
|
||||||
if (!ids || !Array.isArray(ids) || ids.length === 0) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: '영구 삭제할 메일 ID 목록이 필요합니다.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
ids.map((id: string) => mailSentHistoryService.permanentlyDeleteMail(id))
|
|
||||||
);
|
|
||||||
|
|
||||||
const successCount = results.filter((r) => r.status === 'fulfilled' && r.value).length;
|
|
||||||
const failCount = results.length - successCount;
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
message: `${successCount}개 메일 영구 삭제 완료 (실패: ${failCount}개)`,
|
|
||||||
data: { successCount, failCount },
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const err = error as Error;
|
|
||||||
console.error('일괄 영구 삭제 실패:', err);
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: '일괄 영구 삭제 중 오류가 발생했습니다.',
|
|
||||||
error: err.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 일괄 복구
|
|
||||||
*/
|
|
||||||
async bulkRestore(req: Request, res: Response) {
|
|
||||||
try {
|
|
||||||
const { ids } = req.body;
|
|
||||||
|
|
||||||
if (!ids || !Array.isArray(ids) || ids.length === 0) {
|
|
||||||
return res.status(400).json({
|
|
||||||
success: false,
|
|
||||||
message: '복구할 메일 ID 목록이 필요합니다.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
ids.map((id: string) => mailSentHistoryService.restoreMail(id))
|
|
||||||
);
|
|
||||||
|
|
||||||
const successCount = results.filter((r) => r.status === 'fulfilled' && r.value).length;
|
|
||||||
const failCount = results.length - successCount;
|
|
||||||
|
|
||||||
return res.json({
|
|
||||||
success: true,
|
|
||||||
message: `${successCount}개 메일 복구 완료 (실패: ${failCount}개)`,
|
|
||||||
data: { successCount, failCount },
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const err = error as Error;
|
|
||||||
console.error('일괄 복구 실패:', err);
|
|
||||||
return res.status(500).json({
|
|
||||||
success: false,
|
|
||||||
message: '일괄 복구 중 오류가 발생했습니다.',
|
|
||||||
error: err.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mailSentHistoryController = new MailSentHistoryController();
|
export const mailSentHistoryController = new MailSentHistoryController();
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,6 @@ router.get('/:accountId/:seqno/attachment/:index', (req, res) => {
|
||||||
// 메일 읽음 표시 - 구체적인 경로
|
// 메일 읽음 표시 - 구체적인 경로
|
||||||
router.post('/:accountId/:seqno/mark-read', (req, res) => controller.markAsRead(req, res));
|
router.post('/:accountId/:seqno/mark-read', (req, res) => controller.markAsRead(req, res));
|
||||||
|
|
||||||
// 메일 삭제 - 구체적인 경로
|
|
||||||
router.delete('/:accountId/:seqno', (req, res) => controller.deleteMail(req, res));
|
|
||||||
|
|
||||||
// 메일 상세 조회 - /:accountId보다 먼저 정의해야 함
|
// 메일 상세 조회 - /:accountId보다 먼저 정의해야 함
|
||||||
router.get('/:accountId/:seqno', (req, res) => controller.getMailDetail(req, res));
|
router.get('/:accountId/:seqno', (req, res) => controller.getMailDetail(req, res));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,6 @@ router.post(
|
||||||
(req, res) => mailSendSimpleController.sendMail(req, res)
|
(req, res) => mailSendSimpleController.sendMail(req, res)
|
||||||
);
|
);
|
||||||
|
|
||||||
// POST /api/mail/send/bulk - 대량 메일 발송
|
|
||||||
router.post('/bulk', (req, res) => mailSendSimpleController.sendBulkMail(req, res));
|
|
||||||
|
|
||||||
// POST /api/mail/send/test-connection - SMTP 연결 테스트
|
// POST /api/mail/send/test-connection - SMTP 연결 테스트
|
||||||
router.post('/test-connection', (req, res) => mailSendSimpleController.testConnection(req, res));
|
router.post('/test-connection', (req, res) => mailSendSimpleController.testConnection(req, res));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,37 +7,16 @@ const router = Router();
|
||||||
// 모든 라우트에 인증 미들웨어 적용
|
// 모든 라우트에 인증 미들웨어 적용
|
||||||
router.use(authenticateToken);
|
router.use(authenticateToken);
|
||||||
|
|
||||||
// GET /api/mail/sent/statistics - 통계 조회 (⚠️ 반드시 /:id 보다 먼저 정의)
|
|
||||||
router.get('/statistics', (req, res) => mailSentHistoryController.getStatistics(req, res));
|
|
||||||
|
|
||||||
// GET /api/mail/sent - 발송 이력 목록 조회
|
// GET /api/mail/sent - 발송 이력 목록 조회
|
||||||
router.get('/', (req, res) => mailSentHistoryController.getList(req, res));
|
router.get('/', (req, res) => mailSentHistoryController.getList(req, res));
|
||||||
|
|
||||||
// POST /api/mail/sent/draft - 임시 저장 (Draft)
|
// GET /api/mail/sent/statistics - 통계 조회
|
||||||
router.post('/draft', (req, res) => mailSentHistoryController.saveDraft(req, res));
|
router.get('/statistics', (req, res) => mailSentHistoryController.getStatistics(req, res));
|
||||||
|
|
||||||
// PUT /api/mail/sent/draft/:id - 임시 저장 업데이트
|
|
||||||
router.put('/draft/:id', (req, res) => mailSentHistoryController.updateDraft(req, res));
|
|
||||||
|
|
||||||
// POST /api/mail/sent/bulk/delete - 일괄 삭제
|
|
||||||
router.post('/bulk/delete', (req, res) => mailSentHistoryController.bulkDelete(req, res));
|
|
||||||
|
|
||||||
// POST /api/mail/sent/bulk/permanent-delete - 일괄 영구 삭제
|
|
||||||
router.post('/bulk/permanent-delete', (req, res) => mailSentHistoryController.bulkPermanentDelete(req, res));
|
|
||||||
|
|
||||||
// POST /api/mail/sent/bulk/restore - 일괄 복구
|
|
||||||
router.post('/bulk/restore', (req, res) => mailSentHistoryController.bulkRestore(req, res));
|
|
||||||
|
|
||||||
// POST /api/mail/sent/:id/restore - 메일 복구
|
|
||||||
router.post('/:id/restore', (req, res) => mailSentHistoryController.restoreMail(req, res));
|
|
||||||
|
|
||||||
// DELETE /api/mail/sent/:id/permanent - 메일 영구 삭제
|
|
||||||
router.delete('/:id/permanent', (req, res) => mailSentHistoryController.permanentlyDelete(req, res));
|
|
||||||
|
|
||||||
// GET /api/mail/sent/:id - 특정 발송 이력 상세 조회
|
// GET /api/mail/sent/:id - 특정 발송 이력 상세 조회
|
||||||
router.get('/:id', (req, res) => mailSentHistoryController.getById(req, res));
|
router.get('/:id', (req, res) => mailSentHistoryController.getById(req, res));
|
||||||
|
|
||||||
// DELETE /api/mail/sent/:id - 발송 이력 삭제 (Soft Delete)
|
// DELETE /api/mail/sent/:id - 발송 이력 삭제
|
||||||
router.delete('/:id', (req, res) => mailSentHistoryController.deleteById(req, res));
|
router.delete('/:id', (req, res) => mailSentHistoryController.deleteById(req, res));
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -88,9 +88,6 @@ export class MailReceiveBasicService {
|
||||||
port: config.port,
|
port: config.port,
|
||||||
tls: config.tls,
|
tls: config.tls,
|
||||||
tlsOptions: { rejectUnauthorized: false },
|
tlsOptions: { rejectUnauthorized: false },
|
||||||
authTimeout: 30000, // 인증 타임아웃 30초
|
|
||||||
connTimeout: 30000, // 연결 타임아웃 30초
|
|
||||||
keepalive: true,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,7 +116,7 @@ export class MailReceiveBasicService {
|
||||||
tls: true,
|
tls: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
// // console.log(`📧 IMAP 연결 시도 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, 이메일: ${imapConfig.user}`);
|
// console.log(`📧 IMAP 연결 시도 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, 이메일: ${imapConfig.user}`);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const imap = this.createImapConnection(imapConfig);
|
const imap = this.createImapConnection(imapConfig);
|
||||||
|
|
@ -133,7 +130,7 @@ export class MailReceiveBasicService {
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
imap.once("ready", () => {
|
imap.once("ready", () => {
|
||||||
// // console.log('✅ IMAP 연결 성공! INBOX 열기 시도...');
|
// console.log('✅ IMAP 연결 성공! INBOX 열기 시도...');
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|
||||||
imap.openBox("INBOX", true, (err: any, box: any) => {
|
imap.openBox("INBOX", true, (err: any, box: any) => {
|
||||||
|
|
@ -143,10 +140,10 @@ export class MailReceiveBasicService {
|
||||||
return reject(err);
|
return reject(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// // console.log(`📬 INBOX 열림 - 전체 메일 수: ${box.messages.total}`);
|
// console.log(`📬 INBOX 열림 - 전체 메일 수: ${box.messages.total}`);
|
||||||
const totalMessages = box.messages.total;
|
const totalMessages = box.messages.total;
|
||||||
if (totalMessages === 0) {
|
if (totalMessages === 0) {
|
||||||
// // console.log('📭 메일함이 비어있습니다');
|
// console.log('📭 메일함이 비어있습니다');
|
||||||
imap.end();
|
imap.end();
|
||||||
return resolve([]);
|
return resolve([]);
|
||||||
}
|
}
|
||||||
|
|
@ -155,19 +152,19 @@ export class MailReceiveBasicService {
|
||||||
const start = Math.max(1, totalMessages - limit + 1);
|
const start = Math.max(1, totalMessages - limit + 1);
|
||||||
const end = totalMessages;
|
const end = totalMessages;
|
||||||
|
|
||||||
// // console.log(`📨 메일 가져오기 시작 - 범위: ${start}~${end}`);
|
// console.log(`📨 메일 가져오기 시작 - 범위: ${start}~${end}`);
|
||||||
const fetch = imap.seq.fetch(`${start}:${end}`, {
|
const fetch = imap.seq.fetch(`${start}:${end}`, {
|
||||||
bodies: ["HEADER", "TEXT"],
|
bodies: ["HEADER", "TEXT"],
|
||||||
struct: true,
|
struct: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
// // console.log(`📦 fetch 객체 생성 완료`);
|
// console.log(`📦 fetch 객체 생성 완료`);
|
||||||
|
|
||||||
let processedCount = 0;
|
let processedCount = 0;
|
||||||
const totalToProcess = end - start + 1;
|
const totalToProcess = end - start + 1;
|
||||||
|
|
||||||
fetch.on("message", (msg: any, seqno: any) => {
|
fetch.on("message", (msg: any, seqno: any) => {
|
||||||
// // console.log(`📬 메일 #${seqno} 처리 시작`);
|
// console.log(`📬 메일 #${seqno} 처리 시작`);
|
||||||
let header: string = "";
|
let header: string = "";
|
||||||
let body: string = "";
|
let body: string = "";
|
||||||
let attributes: any = null;
|
let attributes: any = null;
|
||||||
|
|
@ -225,7 +222,7 @@ export class MailReceiveBasicService {
|
||||||
};
|
};
|
||||||
|
|
||||||
mails.push(mail);
|
mails.push(mail);
|
||||||
// // console.log(`✓ 메일 #${seqno} 파싱 완료 (${mails.length}/${totalToProcess})`);
|
// console.log(`✓ 메일 #${seqno} 파싱 완료 (${mails.length}/${totalToProcess})`);
|
||||||
processedCount++;
|
processedCount++;
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
// console.error(`메일 #${seqno} 파싱 오류:`, parseError);
|
// console.error(`메일 #${seqno} 파싱 오류:`, parseError);
|
||||||
|
|
@ -243,18 +240,18 @@ export class MailReceiveBasicService {
|
||||||
});
|
});
|
||||||
|
|
||||||
fetch.once("end", () => {
|
fetch.once("end", () => {
|
||||||
// // console.log(`📭 fetch 종료 - 처리 완료 대기 중... (현재: ${mails.length}개)`);
|
// console.log(`📭 fetch 종료 - 처리 완료 대기 중... (현재: ${mails.length}개)`);
|
||||||
|
|
||||||
// 모든 메일 처리가 완료될 때까지 대기
|
// 모든 메일 처리가 완료될 때까지 대기
|
||||||
const checkComplete = setInterval(() => {
|
const checkComplete = setInterval(() => {
|
||||||
// // console.log(`⏳ 대기 중 - 처리됨: ${processedCount}/${totalToProcess}, 메일: ${mails.length}개`);
|
// console.log(`⏳ 대기 중 - 처리됨: ${processedCount}/${totalToProcess}, 메일: ${mails.length}개`);
|
||||||
if (processedCount >= totalToProcess) {
|
if (processedCount >= totalToProcess) {
|
||||||
clearInterval(checkComplete);
|
clearInterval(checkComplete);
|
||||||
// // console.log(`✅ 메일 가져오기 완료 - 총 ${mails.length}개`);
|
// console.log(`✅ 메일 가져오기 완료 - 총 ${mails.length}개`);
|
||||||
imap.end();
|
imap.end();
|
||||||
// 최신 메일이 위로 오도록 정렬
|
// 최신 메일이 위로 오도록 정렬
|
||||||
mails.sort((a, b) => b.date.getTime() - a.date.getTime());
|
mails.sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||||
// // console.log(`📤 메일 목록 반환: ${mails.length}개`);
|
// console.log(`📤 메일 목록 반환: ${mails.length}개`);
|
||||||
resolve(mails);
|
resolve(mails);
|
||||||
}
|
}
|
||||||
}, 100);
|
}, 100);
|
||||||
|
|
@ -262,7 +259,7 @@ export class MailReceiveBasicService {
|
||||||
// 최대 10초 대기
|
// 최대 10초 대기
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
clearInterval(checkComplete);
|
clearInterval(checkComplete);
|
||||||
// // console.log(`⚠️ 타임아웃 - 부분 반환: ${mails.length}/${totalToProcess}개`);
|
// console.log(`⚠️ 타임아웃 - 부분 반환: ${mails.length}/${totalToProcess}개`);
|
||||||
imap.end();
|
imap.end();
|
||||||
mails.sort((a, b) => b.date.getTime() - a.date.getTime());
|
mails.sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||||
resolve(mails);
|
resolve(mails);
|
||||||
|
|
@ -278,10 +275,10 @@ export class MailReceiveBasicService {
|
||||||
});
|
});
|
||||||
|
|
||||||
imap.once("end", () => {
|
imap.once("end", () => {
|
||||||
// // console.log('🔌 IMAP 연결 종료');
|
// console.log('🔌 IMAP 연결 종료');
|
||||||
});
|
});
|
||||||
|
|
||||||
// // console.log('🔗 IMAP.connect() 호출...');
|
// console.log('🔗 IMAP.connect() 호출...');
|
||||||
imap.connect();
|
imap.connect();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -332,9 +329,9 @@ export class MailReceiveBasicService {
|
||||||
return reject(err);
|
return reject(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log(
|
console.log(
|
||||||
// `📬 INBOX 정보 - 전체 메일: ${box.messages.total}, 요청한 seqno: ${seqno}`
|
`📬 INBOX 정보 - 전체 메일: ${box.messages.total}, 요청한 seqno: ${seqno}`
|
||||||
// );
|
);
|
||||||
|
|
||||||
if (seqno > box.messages.total || seqno < 1) {
|
if (seqno > box.messages.total || seqno < 1) {
|
||||||
console.error(
|
console.error(
|
||||||
|
|
@ -353,21 +350,21 @@ export class MailReceiveBasicService {
|
||||||
let parsingComplete = false;
|
let parsingComplete = false;
|
||||||
|
|
||||||
fetch.on("message", (msg: any, seqnum: any) => {
|
fetch.on("message", (msg: any, seqnum: any) => {
|
||||||
// console.log(`📨 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`);
|
console.log(`📨 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`);
|
||||||
|
|
||||||
msg.on("body", (stream: any, info: any) => {
|
msg.on("body", (stream: any, info: any) => {
|
||||||
// console.log(`📝 메일 본문 스트림 시작 - which: ${info.which}`);
|
console.log(`📝 메일 본문 스트림 시작 - which: ${info.which}`);
|
||||||
let buffer = "";
|
let buffer = "";
|
||||||
stream.on("data", (chunk: any) => {
|
stream.on("data", (chunk: any) => {
|
||||||
buffer += chunk.toString("utf8");
|
buffer += chunk.toString("utf8");
|
||||||
});
|
});
|
||||||
stream.once("end", async () => {
|
stream.once("end", async () => {
|
||||||
// console.log(
|
console.log(
|
||||||
// `✅ 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}`
|
`✅ 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}`
|
||||||
// );
|
);
|
||||||
try {
|
try {
|
||||||
const parsed = await simpleParser(buffer);
|
const parsed = await simpleParser(buffer);
|
||||||
// console.log(`✅ 메일 파싱 완료 - 제목: ${parsed.subject}`);
|
console.log(`✅ 메일 파싱 완료 - 제목: ${parsed.subject}`);
|
||||||
|
|
||||||
const fromAddress = Array.isArray(parsed.from)
|
const fromAddress = Array.isArray(parsed.from)
|
||||||
? parsed.from[0]
|
? parsed.from[0]
|
||||||
|
|
@ -415,7 +412,7 @@ export class MailReceiveBasicService {
|
||||||
|
|
||||||
// msg 전체가 처리되었을 때 이벤트
|
// msg 전체가 처리되었을 때 이벤트
|
||||||
msg.once("end", () => {
|
msg.once("end", () => {
|
||||||
// console.log(`📮 메일 메시지 처리 완료 - seqnum: ${seqnum}`);
|
console.log(`📮 메일 메시지 처리 완료 - seqnum: ${seqnum}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -426,15 +423,15 @@ export class MailReceiveBasicService {
|
||||||
});
|
});
|
||||||
|
|
||||||
fetch.once("end", () => {
|
fetch.once("end", () => {
|
||||||
// console.log(`🏁 Fetch 종료 - parsingComplete: ${parsingComplete}`);
|
console.log(`🏁 Fetch 종료 - parsingComplete: ${parsingComplete}`);
|
||||||
|
|
||||||
// 비동기 파싱이 완료될 때까지 대기
|
// 비동기 파싱이 완료될 때까지 대기
|
||||||
const waitForParsing = setInterval(() => {
|
const waitForParsing = setInterval(() => {
|
||||||
if (parsingComplete) {
|
if (parsingComplete) {
|
||||||
clearInterval(waitForParsing);
|
clearInterval(waitForParsing);
|
||||||
// console.log(
|
console.log(
|
||||||
// `✅ 파싱 완료 대기 종료 - mailDetail이 ${mailDetail ? "존재함" : "null"}`
|
`✅ 파싱 완료 대기 종료 - mailDetail이 ${mailDetail ? "존재함" : "null"}`
|
||||||
// );
|
);
|
||||||
imap.end();
|
imap.end();
|
||||||
resolve(mailDetail);
|
resolve(mailDetail);
|
||||||
}
|
}
|
||||||
|
|
@ -477,47 +474,29 @@ export class MailReceiveBasicService {
|
||||||
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
||||||
|
|
||||||
const accountAny = account as any;
|
const accountAny = account as any;
|
||||||
const imapPort = accountAny.imapPort || this.inferImapPort(account.smtpPort);
|
|
||||||
|
|
||||||
const imapConfig: ImapConfig = {
|
const imapConfig: ImapConfig = {
|
||||||
user: account.email,
|
user: account.email,
|
||||||
password: decryptedPassword,
|
password: decryptedPassword,
|
||||||
host: accountAny.imapHost || account.smtpHost,
|
host: accountAny.imapHost || account.smtpHost,
|
||||||
port: imapPort,
|
port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
|
||||||
tls: imapPort === 993, // 993 포트면 TLS 사용
|
tls: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const imap = this.createImapConnection(imapConfig);
|
const imap = this.createImapConnection(imapConfig);
|
||||||
|
|
||||||
// 타임아웃 설정
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
console.error('❌ IMAP 읽음 표시 타임아웃 (30초)');
|
|
||||||
imap.end();
|
|
||||||
reject(new Error("IMAP 연결 타임아웃"));
|
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
imap.once("ready", () => {
|
imap.once("ready", () => {
|
||||||
clearTimeout(timeout);
|
|
||||||
// console.log(`🔗 IMAP 연결 성공 - 읽음 표시 시작 (seqno=${seqno})`);
|
|
||||||
|
|
||||||
// false로 변경: 쓰기 가능 모드로 INBOX 열기
|
|
||||||
imap.openBox("INBOX", false, (err: any, box: any) => {
|
imap.openBox("INBOX", false, (err: any, box: any) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error('❌ INBOX 열기 실패:', err);
|
|
||||||
imap.end();
|
imap.end();
|
||||||
return reject(err);
|
return reject(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log(`📬 INBOX 열림 (쓰기 가능 모드)`);
|
|
||||||
|
|
||||||
imap.seq.addFlags(seqno, ["\\Seen"], (flagErr: any) => {
|
imap.seq.addFlags(seqno, ["\\Seen"], (flagErr: any) => {
|
||||||
imap.end();
|
imap.end();
|
||||||
if (flagErr) {
|
if (flagErr) {
|
||||||
console.error("❌ 읽음 플래그 설정 실패:", flagErr);
|
|
||||||
reject(flagErr);
|
reject(flagErr);
|
||||||
} else {
|
} else {
|
||||||
// console.log("✅ 읽음 플래그 설정 성공 - seqno:", seqno);
|
|
||||||
resolve({
|
resolve({
|
||||||
success: true,
|
success: true,
|
||||||
message: "메일을 읽음으로 표시했습니다.",
|
message: "메일을 읽음으로 표시했습니다.",
|
||||||
|
|
@ -528,16 +507,9 @@ export class MailReceiveBasicService {
|
||||||
});
|
});
|
||||||
|
|
||||||
imap.once("error", (imapErr: any) => {
|
imap.once("error", (imapErr: any) => {
|
||||||
clearTimeout(timeout);
|
|
||||||
console.error('❌ IMAP 에러:', imapErr);
|
|
||||||
reject(imapErr);
|
reject(imapErr);
|
||||||
});
|
});
|
||||||
|
|
||||||
imap.once("end", () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
});
|
|
||||||
|
|
||||||
// console.log(`🔌 IMAP 연결 시도 중... (host=${imapConfig.host}, port=${imapConfig.port})`);
|
|
||||||
imap.connect();
|
imap.connect();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -556,7 +528,7 @@ export class MailReceiveBasicService {
|
||||||
|
|
||||||
// 비밀번호 복호화
|
// 비밀번호 복호화
|
||||||
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
||||||
// // console.log(`🔐 IMAP 테스트 - 이메일: ${account.email}, 비밀번호 길이: ${decryptedPassword.length}`);
|
// console.log(`🔐 IMAP 테스트 - 이메일: ${account.email}, 비밀번호 길이: ${decryptedPassword.length}`);
|
||||||
|
|
||||||
const accountAny = account as any;
|
const accountAny = account as any;
|
||||||
const imapConfig: ImapConfig = {
|
const imapConfig: ImapConfig = {
|
||||||
|
|
@ -566,7 +538,7 @@ export class MailReceiveBasicService {
|
||||||
port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
|
port: this.inferImapPort(account.smtpPort, accountAny.imapPort),
|
||||||
tls: true,
|
tls: true,
|
||||||
};
|
};
|
||||||
// // console.log(`📧 IMAP 설정 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, TLS: ${imapConfig.tls}`);
|
// console.log(`📧 IMAP 설정 - 호스트: ${imapConfig.host}, 포트: ${imapConfig.port}, TLS: ${imapConfig.tls}`);
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const imap = this.createImapConnection(imapConfig);
|
const imap = this.createImapConnection(imapConfig);
|
||||||
|
|
@ -692,32 +664,32 @@ export class MailReceiveBasicService {
|
||||||
let parsingComplete = false;
|
let parsingComplete = false;
|
||||||
|
|
||||||
fetch.on("message", (msg: any, seqnum: any) => {
|
fetch.on("message", (msg: any, seqnum: any) => {
|
||||||
// console.log(`📎 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`);
|
console.log(`📎 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`);
|
||||||
|
|
||||||
msg.on("body", (stream: any, info: any) => {
|
msg.on("body", (stream: any, info: any) => {
|
||||||
// console.log(`📎 메일 본문 스트림 시작`);
|
console.log(`📎 메일 본문 스트림 시작`);
|
||||||
let buffer = "";
|
let buffer = "";
|
||||||
stream.on("data", (chunk: any) => {
|
stream.on("data", (chunk: any) => {
|
||||||
buffer += chunk.toString("utf8");
|
buffer += chunk.toString("utf8");
|
||||||
});
|
});
|
||||||
stream.once("end", async () => {
|
stream.once("end", async () => {
|
||||||
// console.log(
|
console.log(
|
||||||
// `📎 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}`
|
`📎 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}`
|
||||||
// );
|
);
|
||||||
try {
|
try {
|
||||||
const parsed = await simpleParser(buffer);
|
const parsed = await simpleParser(buffer);
|
||||||
// console.log(
|
console.log(
|
||||||
// `📎 파싱 완료 - 첨부파일 개수: ${parsed.attachments?.length || 0}`
|
`📎 파싱 완료 - 첨부파일 개수: ${parsed.attachments?.length || 0}`
|
||||||
// );
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
parsed.attachments &&
|
parsed.attachments &&
|
||||||
parsed.attachments[attachmentIndex]
|
parsed.attachments[attachmentIndex]
|
||||||
) {
|
) {
|
||||||
const attachment = parsed.attachments[attachmentIndex];
|
const attachment = parsed.attachments[attachmentIndex];
|
||||||
// console.log(
|
console.log(
|
||||||
// `📎 첨부파일 발견 (index ${attachmentIndex}): ${attachment.filename}`
|
`📎 첨부파일 발견 (index ${attachmentIndex}): ${attachment.filename}`
|
||||||
// );
|
);
|
||||||
|
|
||||||
// 안전한 파일명 생성
|
// 안전한 파일명 생성
|
||||||
const safeFilename = this.sanitizeFilename(
|
const safeFilename = this.sanitizeFilename(
|
||||||
|
|
@ -729,7 +701,7 @@ export class MailReceiveBasicService {
|
||||||
|
|
||||||
// 파일 저장
|
// 파일 저장
|
||||||
await fs.writeFile(filePath, attachment.content);
|
await fs.writeFile(filePath, attachment.content);
|
||||||
// console.log(`📎 파일 저장 완료: ${filePath}`);
|
console.log(`📎 파일 저장 완료: ${filePath}`);
|
||||||
|
|
||||||
attachmentResult = {
|
attachmentResult = {
|
||||||
filePath,
|
filePath,
|
||||||
|
|
@ -739,9 +711,9 @@ export class MailReceiveBasicService {
|
||||||
};
|
};
|
||||||
parsingComplete = true;
|
parsingComplete = true;
|
||||||
} else {
|
} else {
|
||||||
// console.log(
|
console.log(
|
||||||
// `❌ 첨부파일 index ${attachmentIndex}를 찾을 수 없음 (총 ${parsed.attachments?.length || 0}개)`
|
`❌ 첨부파일 index ${attachmentIndex}를 찾을 수 없음 (총 ${parsed.attachments?.length || 0}개)`
|
||||||
// );
|
);
|
||||||
parsingComplete = true;
|
parsingComplete = true;
|
||||||
}
|
}
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
|
|
@ -759,14 +731,14 @@ export class MailReceiveBasicService {
|
||||||
});
|
});
|
||||||
|
|
||||||
fetch.once("end", () => {
|
fetch.once("end", () => {
|
||||||
// console.log('📎 fetch.once("end") 호출됨 - 파싱 완료 대기 시작...');
|
console.log('📎 fetch.once("end") 호출됨 - 파싱 완료 대기 시작...');
|
||||||
|
|
||||||
// 파싱 완료를 기다림 (최대 5초)
|
// 파싱 완료를 기다림 (최대 5초)
|
||||||
const checkComplete = setInterval(() => {
|
const checkComplete = setInterval(() => {
|
||||||
if (parsingComplete) {
|
if (parsingComplete) {
|
||||||
// console.log(
|
console.log(
|
||||||
// `✅ 파싱 완료 확인 - attachmentResult: ${attachmentResult ? "있음" : "없음"}`
|
`✅ 파싱 완료 확인 - attachmentResult: ${attachmentResult ? "있음" : "없음"}`
|
||||||
// );
|
);
|
||||||
clearInterval(checkComplete);
|
clearInterval(checkComplete);
|
||||||
imap.end();
|
imap.end();
|
||||||
resolve(attachmentResult);
|
resolve(attachmentResult);
|
||||||
|
|
@ -775,9 +747,9 @@ export class MailReceiveBasicService {
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
clearInterval(checkComplete);
|
clearInterval(checkComplete);
|
||||||
// console.log(
|
console.log(
|
||||||
// `⚠️ 타임아웃 - attachmentResult: ${attachmentResult ? "있음" : "없음"}`
|
`⚠️ 타임아웃 - attachmentResult: ${attachmentResult ? "있음" : "없음"}`
|
||||||
// );
|
);
|
||||||
imap.end();
|
imap.end();
|
||||||
resolve(attachmentResult);
|
resolve(attachmentResult);
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
@ -802,96 +774,4 @@ export class MailReceiveBasicService {
|
||||||
.replace(/_{2,}/g, "_")
|
.replace(/_{2,}/g, "_")
|
||||||
.substring(0, 200); // 최대 길이 제한
|
.substring(0, 200); // 최대 길이 제한
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* IMAP 서버에서 메일 삭제 (휴지통으로 이동)
|
|
||||||
*/
|
|
||||||
async deleteMail(accountId: string, seqno: number): Promise<{ success: boolean; message: string }> {
|
|
||||||
const account = await mailAccountFileService.getAccountById(accountId);
|
|
||||||
|
|
||||||
if (!account) {
|
|
||||||
throw new Error("메일 계정을 찾을 수 없습니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 비밀번호 복호화
|
|
||||||
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
|
||||||
|
|
||||||
// IMAP 설정 (타입 캐스팅)
|
|
||||||
const accountAny = account as any;
|
|
||||||
const imapPort = accountAny.imapPort || this.inferImapPort(account.smtpPort);
|
|
||||||
|
|
||||||
const config: ImapConfig = {
|
|
||||||
user: account.smtpUsername || account.email,
|
|
||||||
password: decryptedPassword,
|
|
||||||
host: accountAny.imapHost || account.smtpHost,
|
|
||||||
port: imapPort,
|
|
||||||
tls: imapPort === 993, // 993 포트면 TLS 사용, 143이면 사용 안함
|
|
||||||
};
|
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const imap = this.createImapConnection(config);
|
|
||||||
|
|
||||||
// 30초 타임아웃 설정
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
console.error('❌ IMAP 메일 삭제 타임아웃 (30초)');
|
|
||||||
imap.end();
|
|
||||||
reject(new Error("IMAP 연결 타임아웃"));
|
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
imap.once("ready", () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
// console.log(`🔗 IMAP 연결 성공 - 메일 삭제 시작 (seqno=${seqno})`);
|
|
||||||
|
|
||||||
imap.openBox("INBOX", false, (err: any) => {
|
|
||||||
if (err) {
|
|
||||||
console.error('❌ INBOX 열기 실패:', err);
|
|
||||||
imap.end();
|
|
||||||
return reject(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 메일을 삭제 플래그로 표시 (seq.addFlags 사용)
|
|
||||||
imap.seq.addFlags(seqno, ["\\Deleted"], (flagErr: any) => {
|
|
||||||
if (flagErr) {
|
|
||||||
console.error('❌ 삭제 플래그 추가 실패:', flagErr);
|
|
||||||
imap.end();
|
|
||||||
return reject(flagErr);
|
|
||||||
}
|
|
||||||
|
|
||||||
// console.log(`✓ 삭제 플래그 추가 완료 (seqno=${seqno})`);
|
|
||||||
|
|
||||||
// 삭제 플래그가 표시된 메일을 영구 삭제 (실제로는 휴지통으로 이동)
|
|
||||||
imap.expunge((expungeErr: any) => {
|
|
||||||
imap.end();
|
|
||||||
|
|
||||||
if (expungeErr) {
|
|
||||||
console.error('❌ expunge 실패:', expungeErr);
|
|
||||||
return reject(expungeErr);
|
|
||||||
}
|
|
||||||
|
|
||||||
// console.log(`🗑️ 메일 삭제 완료: seqno=${seqno}`);
|
|
||||||
resolve({
|
|
||||||
success: true,
|
|
||||||
message: "메일이 삭제되었습니다.",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
imap.once("error", (imapErr: any) => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
console.error('❌ IMAP 에러:', imapErr);
|
|
||||||
reject(imapErr);
|
|
||||||
});
|
|
||||||
|
|
||||||
imap.once("end", () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
});
|
|
||||||
|
|
||||||
// console.log(`🔌 IMAP 연결 시도 중... (host=${config.host}, port=${config.port})`);
|
|
||||||
imap.connect();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mailReceiveBasicService = new MailReceiveBasicService();
|
|
||||||
|
|
|
||||||
|
|
@ -34,29 +34,6 @@ export interface SendMailResult {
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BulkSendRequest {
|
|
||||||
accountId: string;
|
|
||||||
templateId?: string; // 템플릿 ID (선택)
|
|
||||||
subject: string;
|
|
||||||
customHtml?: string; // 직접 작성한 HTML (선택)
|
|
||||||
recipients: Array<{
|
|
||||||
email: string;
|
|
||||||
variables?: Record<string, string>; // 템플릿 사용 시에만 필요
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BulkSendResult {
|
|
||||||
total: number;
|
|
||||||
success: number;
|
|
||||||
failed: number;
|
|
||||||
results: Array<{
|
|
||||||
email: string;
|
|
||||||
success: boolean;
|
|
||||||
messageId?: string;
|
|
||||||
error?: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
class MailSendSimpleService {
|
class MailSendSimpleService {
|
||||||
/**
|
/**
|
||||||
* 단일 메일 발송 또는 소규모 발송
|
* 단일 메일 발송 또는 소규모 발송
|
||||||
|
|
@ -86,7 +63,7 @@ class MailSendSimpleService {
|
||||||
|
|
||||||
// 🎯 수정된 컴포넌트가 있으면 덮어쓰기
|
// 🎯 수정된 컴포넌트가 있으면 덮어쓰기
|
||||||
if (request.modifiedTemplateComponents && request.modifiedTemplateComponents.length > 0) {
|
if (request.modifiedTemplateComponents && request.modifiedTemplateComponents.length > 0) {
|
||||||
// console.log('✏️ 수정된 템플릿 컴포넌트 사용:', request.modifiedTemplateComponents.length);
|
console.log('✏️ 수정된 템플릿 컴포넌트 사용:', request.modifiedTemplateComponents.length);
|
||||||
template.components = request.modifiedTemplateComponents;
|
template.components = request.modifiedTemplateComponents;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,15 +84,15 @@ class MailSendSimpleService {
|
||||||
|
|
||||||
// 4. 비밀번호 복호화
|
// 4. 비밀번호 복호화
|
||||||
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
||||||
// // console.log('🔐 비밀번호 복호화 완료');
|
// console.log('🔐 비밀번호 복호화 완료');
|
||||||
// // console.log('🔐 암호화된 비밀번호 (일부):', account.smtpPassword.substring(0, 30) + '...');
|
// console.log('🔐 암호화된 비밀번호 (일부):', account.smtpPassword.substring(0, 30) + '...');
|
||||||
// // console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length);
|
// console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length);
|
||||||
|
|
||||||
// 5. SMTP 연결 생성
|
// 5. SMTP 연결 생성
|
||||||
// 포트 465는 SSL/TLS를 사용해야 함
|
// 포트 465는 SSL/TLS를 사용해야 함
|
||||||
const isSecure = account.smtpPort === 465 ? true : (account.smtpSecure || false);
|
const isSecure = account.smtpPort === 465 ? true : (account.smtpSecure || false);
|
||||||
|
|
||||||
// // console.log('📧 SMTP 연결 설정:', {
|
// console.log('📧 SMTP 연결 설정:', {
|
||||||
// host: account.smtpHost,
|
// host: account.smtpHost,
|
||||||
// port: account.smtpPort,
|
// port: account.smtpPort,
|
||||||
// secure: isSecure,
|
// secure: isSecure,
|
||||||
|
|
@ -135,7 +112,7 @@ class MailSendSimpleService {
|
||||||
greetingTimeout: 30000,
|
greetingTimeout: 30000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// console.log('📧 메일 발송 시도 중...');
|
console.log('📧 메일 발송 시도 중...');
|
||||||
|
|
||||||
// 6. 메일 발송 (CC, BCC, 첨부파일 지원)
|
// 6. 메일 발송 (CC, BCC, 첨부파일 지원)
|
||||||
const mailOptions: any = {
|
const mailOptions: any = {
|
||||||
|
|
@ -148,13 +125,13 @@ class MailSendSimpleService {
|
||||||
// 참조(CC) 추가
|
// 참조(CC) 추가
|
||||||
if (request.cc && request.cc.length > 0) {
|
if (request.cc && request.cc.length > 0) {
|
||||||
mailOptions.cc = request.cc.join(', ');
|
mailOptions.cc = request.cc.join(', ');
|
||||||
// // console.log('📧 참조(CC):', request.cc);
|
// console.log('📧 참조(CC):', request.cc);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 숨은참조(BCC) 추가
|
// 숨은참조(BCC) 추가
|
||||||
if (request.bcc && request.bcc.length > 0) {
|
if (request.bcc && request.bcc.length > 0) {
|
||||||
mailOptions.bcc = request.bcc.join(', ');
|
mailOptions.bcc = request.bcc.join(', ');
|
||||||
// // console.log('🔒 숨은참조(BCC):', request.bcc);
|
// console.log('🔒 숨은참조(BCC):', request.bcc);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 첨부파일 추가 (한글 파일명 인코딩 처리)
|
// 첨부파일 추가 (한글 파일명 인코딩 처리)
|
||||||
|
|
@ -186,17 +163,17 @@ class MailSendSimpleService {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
// console.log('📎 첨부파일 (원본):', request.attachments.map((a: any) => a.filename.replace(/^\d+-\d+_/, '')));
|
console.log('📎 첨부파일 (원본):', request.attachments.map((a: any) => a.filename.replace(/^\d+-\d+_/, '')));
|
||||||
// console.log('📎 첨부파일 (인코딩):', mailOptions.attachments.map((a: any) => a.filename));
|
console.log('📎 첨부파일 (인코딩):', mailOptions.attachments.map((a: any) => a.filename));
|
||||||
}
|
}
|
||||||
|
|
||||||
const info = await transporter.sendMail(mailOptions);
|
const info = await transporter.sendMail(mailOptions);
|
||||||
|
|
||||||
// console.log('✅ 메일 발송 성공:', {
|
console.log('✅ 메일 발송 성공:', {
|
||||||
// messageId: info.messageId,
|
messageId: info.messageId,
|
||||||
// accepted: info.accepted,
|
accepted: info.accepted,
|
||||||
// rejected: info.rejected,
|
rejected: info.rejected,
|
||||||
// });
|
});
|
||||||
|
|
||||||
// 발송 이력 저장 (성공)
|
// 발송 이력 저장 (성공)
|
||||||
try {
|
try {
|
||||||
|
|
@ -425,73 +402,6 @@ class MailSendSimpleService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 대량 메일 발송 (배치 처리)
|
|
||||||
*/
|
|
||||||
async sendBulkMail(request: BulkSendRequest): Promise<BulkSendResult> {
|
|
||||||
const results: Array<{
|
|
||||||
email: string;
|
|
||||||
success: boolean;
|
|
||||||
messageId?: string;
|
|
||||||
error?: string;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
let successCount = 0;
|
|
||||||
let failedCount = 0;
|
|
||||||
|
|
||||||
// console.log(`📧 대량 발송 시작: ${request.recipients.length}명`);
|
|
||||||
|
|
||||||
// 순차 발송 (너무 빠르면 스팸으로 분류될 수 있음)
|
|
||||||
for (const recipient of request.recipients) {
|
|
||||||
try {
|
|
||||||
const result = await this.sendMail({
|
|
||||||
accountId: request.accountId,
|
|
||||||
templateId: request.templateId, // 템플릿이 있으면 사용
|
|
||||||
customHtml: request.customHtml, // 직접 작성한 HTML이 있으면 사용
|
|
||||||
to: [recipient.email],
|
|
||||||
subject: request.subject,
|
|
||||||
variables: recipient.variables || {}, // 템플릿 사용 시에만 필요
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
successCount++;
|
|
||||||
results.push({
|
|
||||||
email: recipient.email,
|
|
||||||
success: true,
|
|
||||||
messageId: result.messageId,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
failedCount++;
|
|
||||||
results.push({
|
|
||||||
email: recipient.email,
|
|
||||||
success: false,
|
|
||||||
error: result.error || '발송 실패',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const err = error as Error;
|
|
||||||
failedCount++;
|
|
||||||
results.push({
|
|
||||||
email: recipient.email,
|
|
||||||
success: false,
|
|
||||||
error: err.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 발송 간격 (500ms) - 스팸 방지
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
||||||
}
|
|
||||||
|
|
||||||
// console.log(`✅ 대량 발송 완료: 성공 ${successCount}, 실패 ${failedCount}`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
total: request.recipients.length,
|
|
||||||
success: successCount,
|
|
||||||
failed: failedCount,
|
|
||||||
results,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SMTP 연결 테스트
|
* SMTP 연결 테스트
|
||||||
*/
|
*/
|
||||||
|
|
@ -504,13 +414,13 @@ class MailSendSimpleService {
|
||||||
|
|
||||||
// 비밀번호 복호화
|
// 비밀번호 복호화
|
||||||
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
const decryptedPassword = encryptionService.decrypt(account.smtpPassword);
|
||||||
// // console.log('🔐 테스트용 비밀번호 복호화 완료');
|
// console.log('🔐 테스트용 비밀번호 복호화 완료');
|
||||||
// // console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length);
|
// console.log('🔐 복호화된 비밀번호 길이:', decryptedPassword.length);
|
||||||
|
|
||||||
// 포트 465는 SSL/TLS를 사용해야 함
|
// 포트 465는 SSL/TLS를 사용해야 함
|
||||||
const isSecure = account.smtpPort === 465 ? true : (account.smtpSecure || false);
|
const isSecure = account.smtpPort === 465 ? true : (account.smtpSecure || false);
|
||||||
|
|
||||||
// // console.log('🧪 SMTP 연결 테스트 시작:', {
|
// console.log('🧪 SMTP 연결 테스트 시작:', {
|
||||||
// host: account.smtpHost,
|
// host: account.smtpHost,
|
||||||
// port: account.smtpPort,
|
// port: account.smtpPort,
|
||||||
// secure: isSecure,
|
// secure: isSecure,
|
||||||
|
|
@ -533,7 +443,7 @@ class MailSendSimpleService {
|
||||||
// 연결 테스트
|
// 연결 테스트
|
||||||
await transporter.verify();
|
await transporter.verify();
|
||||||
|
|
||||||
// console.log('✅ SMTP 연결 테스트 성공');
|
console.log('✅ SMTP 연결 테스트 성공');
|
||||||
return { success: true, message: 'SMTP 연결이 성공했습니다.' };
|
return { success: true, message: 'SMTP 연결이 성공했습니다.' };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error as Error;
|
const err = error as Error;
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ class MailSentHistoryService {
|
||||||
mode: 0o644,
|
mode: 0o644,
|
||||||
});
|
});
|
||||||
|
|
||||||
// console.log("발송 이력 저장:", history.id);
|
console.log("발송 이력 저장:", history.id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("발송 이력 저장 실패:", error);
|
console.error("발송 이력 저장 실패:", error);
|
||||||
// 파일 저장 실패해도 history 객체는 반환 (메일 발송은 성공했으므로)
|
// 파일 저장 실패해도 history 객체는 반환 (메일 발송은 성공했으므로)
|
||||||
|
|
@ -86,7 +86,7 @@ class MailSentHistoryService {
|
||||||
try {
|
try {
|
||||||
// 디렉토리가 없으면 빈 배열 반환
|
// 디렉토리가 없으면 빈 배열 반환
|
||||||
if (!fs.existsSync(SENT_MAIL_DIR)) {
|
if (!fs.existsSync(SENT_MAIL_DIR)) {
|
||||||
// console.warn("메일 발송 이력 디렉토리가 없습니다:", SENT_MAIL_DIR);
|
console.warn("메일 발송 이력 디렉토리가 없습니다:", SENT_MAIL_DIR);
|
||||||
return {
|
return {
|
||||||
items: [],
|
items: [],
|
||||||
total: 0,
|
total: 0,
|
||||||
|
|
@ -124,13 +124,6 @@ class MailSentHistoryService {
|
||||||
// 필터링
|
// 필터링
|
||||||
let filtered = allHistory;
|
let filtered = allHistory;
|
||||||
|
|
||||||
// 삭제된 메일 필터
|
|
||||||
if (query.onlyDeleted) {
|
|
||||||
filtered = filtered.filter((h) => h.deletedAt);
|
|
||||||
} else if (!query.includeDeleted) {
|
|
||||||
filtered = filtered.filter((h) => !h.deletedAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 상태 필터
|
// 상태 필터
|
||||||
if (status !== "all") {
|
if (status !== "all") {
|
||||||
filtered = filtered.filter((h) => h.status === status);
|
filtered = filtered.filter((h) => h.status === status);
|
||||||
|
|
@ -216,151 +209,9 @@ class MailSentHistoryService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 임시 저장 (Draft)
|
* 발송 이력 삭제
|
||||||
*/
|
|
||||||
async saveDraft(
|
|
||||||
data: Partial<SentMailHistory> & { accountId: string }
|
|
||||||
): Promise<SentMailHistory> {
|
|
||||||
// console.log("📥 백엔드에서 받은 임시 저장 데이터:", data);
|
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const draft: SentMailHistory = {
|
|
||||||
id: data.id || uuidv4(),
|
|
||||||
accountId: data.accountId,
|
|
||||||
accountName: data.accountName || "",
|
|
||||||
accountEmail: data.accountEmail || "",
|
|
||||||
to: data.to || [],
|
|
||||||
cc: data.cc,
|
|
||||||
bcc: data.bcc,
|
|
||||||
subject: data.subject || "",
|
|
||||||
htmlContent: data.htmlContent || "",
|
|
||||||
templateId: data.templateId,
|
|
||||||
templateName: data.templateName,
|
|
||||||
attachments: data.attachments,
|
|
||||||
sentAt: data.sentAt || now,
|
|
||||||
status: "draft",
|
|
||||||
isDraft: true,
|
|
||||||
updatedAt: now,
|
|
||||||
};
|
|
||||||
|
|
||||||
// console.log("💾 저장할 draft 객체:", draft);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!fs.existsSync(SENT_MAIL_DIR)) {
|
|
||||||
fs.mkdirSync(SENT_MAIL_DIR, { recursive: true, mode: 0o755 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const filePath = path.join(SENT_MAIL_DIR, `${draft.id}.json`);
|
|
||||||
fs.writeFileSync(filePath, JSON.stringify(draft, null, 2), {
|
|
||||||
encoding: "utf-8",
|
|
||||||
mode: 0o644,
|
|
||||||
});
|
|
||||||
|
|
||||||
// console.log("💾 임시 저장:", draft.id);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("임시 저장 실패:", error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return draft;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 임시 저장 업데이트
|
|
||||||
*/
|
|
||||||
async updateDraft(
|
|
||||||
id: string,
|
|
||||||
data: Partial<SentMailHistory>
|
|
||||||
): Promise<SentMailHistory | null> {
|
|
||||||
const existing = await this.getSentMailById(id);
|
|
||||||
if (!existing) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated: SentMailHistory = {
|
|
||||||
...existing,
|
|
||||||
...data,
|
|
||||||
id: existing.id,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const filePath = path.join(SENT_MAIL_DIR, `${id}.json`);
|
|
||||||
fs.writeFileSync(filePath, JSON.stringify(updated, null, 2), {
|
|
||||||
encoding: "utf-8",
|
|
||||||
mode: 0o644,
|
|
||||||
});
|
|
||||||
|
|
||||||
// console.log("✏️ 임시 저장 업데이트:", id);
|
|
||||||
return updated;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("임시 저장 업데이트 실패:", error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 발송 이력 삭제 (Soft Delete)
|
|
||||||
*/
|
*/
|
||||||
async deleteSentMail(id: string): Promise<boolean> {
|
async deleteSentMail(id: string): Promise<boolean> {
|
||||||
const existing = await this.getSentMailById(id);
|
|
||||||
if (!existing) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated: SentMailHistory = {
|
|
||||||
...existing,
|
|
||||||
deletedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const filePath = path.join(SENT_MAIL_DIR, `${id}.json`);
|
|
||||||
fs.writeFileSync(filePath, JSON.stringify(updated, null, 2), {
|
|
||||||
encoding: "utf-8",
|
|
||||||
mode: 0o644,
|
|
||||||
});
|
|
||||||
|
|
||||||
// console.log("🗑️ 메일 삭제 (Soft Delete):", id);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("메일 삭제 실패:", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 메일 복구
|
|
||||||
*/
|
|
||||||
async restoreMail(id: string): Promise<boolean> {
|
|
||||||
const existing = await this.getSentMailById(id);
|
|
||||||
if (!existing || !existing.deletedAt) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated: SentMailHistory = {
|
|
||||||
...existing,
|
|
||||||
deletedAt: undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const filePath = path.join(SENT_MAIL_DIR, `${id}.json`);
|
|
||||||
fs.writeFileSync(filePath, JSON.stringify(updated, null, 2), {
|
|
||||||
encoding: "utf-8",
|
|
||||||
mode: 0o644,
|
|
||||||
});
|
|
||||||
|
|
||||||
// console.log("♻️ 메일 복구:", id);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error("메일 복구 실패:", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 메일 영구 삭제 (Hard Delete)
|
|
||||||
*/
|
|
||||||
async permanentlyDeleteMail(id: string): Promise<boolean> {
|
|
||||||
const filePath = path.join(SENT_MAIL_DIR, `${id}.json`);
|
const filePath = path.join(SENT_MAIL_DIR, `${id}.json`);
|
||||||
|
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
|
|
@ -369,57 +220,14 @@ class MailSentHistoryService {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
fs.unlinkSync(filePath);
|
fs.unlinkSync(filePath);
|
||||||
// console.log("🗑️ 메일 영구 삭제:", id);
|
console.log("🗑️ 발송 이력 삭제:", id);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("메일 영구 삭제 실패:", error);
|
console.error("발송 이력 삭제 실패:", error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 30일 이상 지난 삭제된 메일 자동 영구 삭제
|
|
||||||
*/
|
|
||||||
async cleanupOldDeletedMails(): Promise<number> {
|
|
||||||
const thirtyDaysAgo = new Date();
|
|
||||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
|
||||||
|
|
||||||
let deletedCount = 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (!fs.existsSync(SENT_MAIL_DIR)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const files = fs
|
|
||||||
.readdirSync(SENT_MAIL_DIR)
|
|
||||||
.filter((f) => f.endsWith(".json"));
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
try {
|
|
||||||
const filePath = path.join(SENT_MAIL_DIR, file);
|
|
||||||
const content = fs.readFileSync(filePath, "utf-8");
|
|
||||||
const mail: SentMailHistory = JSON.parse(content);
|
|
||||||
|
|
||||||
if (mail.deletedAt) {
|
|
||||||
const deletedDate = new Date(mail.deletedAt);
|
|
||||||
if (deletedDate < thirtyDaysAgo) {
|
|
||||||
fs.unlinkSync(filePath);
|
|
||||||
deletedCount++;
|
|
||||||
// console.log("🗑️ 30일 지난 메일 자동 삭제:", mail.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`파일 처리 실패: ${file}`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("자동 삭제 실패:", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return deletedCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 통계 조회
|
* 통계 조회
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -50,25 +50,11 @@ class MailTemplateFileService {
|
||||||
process.env.NODE_ENV === "production"
|
process.env.NODE_ENV === "production"
|
||||||
? "/app/uploads/mail-templates"
|
? "/app/uploads/mail-templates"
|
||||||
: path.join(process.cwd(), "uploads", "mail-templates");
|
: path.join(process.cwd(), "uploads", "mail-templates");
|
||||||
// 동기적으로 디렉토리 생성
|
this.ensureDirectoryExists();
|
||||||
this.ensureDirectoryExistsSync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 템플릿 디렉토리 생성 (동기)
|
* 템플릿 디렉토리 생성
|
||||||
*/
|
|
||||||
private ensureDirectoryExistsSync() {
|
|
||||||
try {
|
|
||||||
const fsSync = require('fs');
|
|
||||||
fsSync.accessSync(this.templatesDir);
|
|
||||||
} catch {
|
|
||||||
const fsSync = require('fs');
|
|
||||||
fsSync.mkdirSync(this.templatesDir, { recursive: true, mode: 0o755 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 템플릿 디렉토리 생성 (비동기)
|
|
||||||
*/
|
*/
|
||||||
private async ensureDirectoryExists() {
|
private async ensureDirectoryExists() {
|
||||||
try {
|
try {
|
||||||
|
|
@ -89,6 +75,8 @@ class MailTemplateFileService {
|
||||||
* 모든 템플릿 목록 조회
|
* 모든 템플릿 목록 조회
|
||||||
*/
|
*/
|
||||||
async getAllTemplates(): Promise<MailTemplate[]> {
|
async getAllTemplates(): Promise<MailTemplate[]> {
|
||||||
|
await this.ensureDirectoryExists();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const files = await fs.readdir(this.templatesDir);
|
const files = await fs.readdir(this.templatesDir);
|
||||||
const jsonFiles = files.filter((f) => f.endsWith(".json"));
|
const jsonFiles = files.filter((f) => f.endsWith(".json"));
|
||||||
|
|
@ -109,7 +97,6 @@ class MailTemplateFileService {
|
||||||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// 디렉토리가 없거나 읽기 실패 시 빈 배열 반환
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -173,7 +160,7 @@ class MailTemplateFileService {
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// // console.log(`📝 템플릿 저장 시도: ${id}, 크기: ${JSON.stringify(updated).length} bytes`);
|
// console.log(`📝 템플릿 저장 시도: ${id}, 크기: ${JSON.stringify(updated).length} bytes`);
|
||||||
|
|
||||||
await fs.writeFile(
|
await fs.writeFile(
|
||||||
this.getTemplatePath(id),
|
this.getTemplatePath(id),
|
||||||
|
|
@ -181,7 +168,7 @@ class MailTemplateFileService {
|
||||||
"utf-8"
|
"utf-8"
|
||||||
);
|
);
|
||||||
|
|
||||||
// // console.log(`✅ 템플릿 저장 성공: ${id}`);
|
// console.log(`✅ 템플릿 저장 성공: ${id}`);
|
||||||
return updated;
|
return updated;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error(`❌ 템플릿 저장 실패: ${id}`, error);
|
// console.error(`❌ 템플릿 저장 실패: ${id}`, error);
|
||||||
|
|
|
||||||
|
|
@ -24,18 +24,13 @@ export interface SentMailHistory {
|
||||||
|
|
||||||
// 발송 정보
|
// 발송 정보
|
||||||
sentAt: string; // 발송 시간 (ISO 8601)
|
sentAt: string; // 발송 시간 (ISO 8601)
|
||||||
status: 'success' | 'failed' | 'draft'; // 발송 상태 (draft 추가)
|
status: 'success' | 'failed'; // 발송 상태
|
||||||
messageId?: string; // SMTP 메시지 ID (성공 시)
|
messageId?: string; // SMTP 메시지 ID (성공 시)
|
||||||
errorMessage?: string; // 오류 메시지 (실패 시)
|
errorMessage?: string; // 오류 메시지 (실패 시)
|
||||||
|
|
||||||
// 발송 결과
|
// 발송 결과
|
||||||
accepted?: string[]; // 수락된 이메일 주소
|
accepted?: string[]; // 수락된 이메일 주소
|
||||||
rejected?: string[]; // 거부된 이메일 주소
|
rejected?: string[]; // 거부된 이메일 주소
|
||||||
|
|
||||||
// 임시 저장 및 삭제
|
|
||||||
isDraft?: boolean; // 임시 저장 여부
|
|
||||||
deletedAt?: string; // 삭제 시간 (ISO 8601)
|
|
||||||
updatedAt?: string; // 수정 시간 (ISO 8601)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AttachmentInfo {
|
export interface AttachmentInfo {
|
||||||
|
|
@ -50,14 +45,12 @@ export interface SentMailListQuery {
|
||||||
page?: number; // 페이지 번호 (1부터 시작)
|
page?: number; // 페이지 번호 (1부터 시작)
|
||||||
limit?: number; // 페이지당 항목 수
|
limit?: number; // 페이지당 항목 수
|
||||||
searchTerm?: string; // 검색어 (제목, 받는사람)
|
searchTerm?: string; // 검색어 (제목, 받는사람)
|
||||||
status?: 'success' | 'failed' | 'draft' | 'all'; // 필터: 상태 (draft 추가)
|
status?: 'success' | 'failed' | 'all'; // 필터: 상태
|
||||||
accountId?: string; // 필터: 발송 계정
|
accountId?: string; // 필터: 발송 계정
|
||||||
startDate?: string; // 필터: 시작 날짜 (ISO 8601)
|
startDate?: string; // 필터: 시작 날짜 (ISO 8601)
|
||||||
endDate?: string; // 필터: 종료 날짜 (ISO 8601)
|
endDate?: string; // 필터: 종료 날짜 (ISO 8601)
|
||||||
sortBy?: 'sentAt' | 'subject' | 'updatedAt'; // 정렬 기준 (updatedAt 추가)
|
sortBy?: 'sentAt' | 'subject'; // 정렬 기준
|
||||||
sortOrder?: 'asc' | 'desc'; // 정렬 순서
|
sortOrder?: 'asc' | 'desc'; // 정렬 순서
|
||||||
includeDeleted?: boolean; // 삭제된 메일 포함 여부
|
|
||||||
onlyDeleted?: boolean; // 삭제된 메일만 조회
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SentMailListResponse {
|
export interface SentMailListResponse {
|
||||||
|
|
|
||||||
|
|
@ -38,11 +38,11 @@ export default function MailAccountsPage() {
|
||||||
if (Array.isArray(data)) {
|
if (Array.isArray(data)) {
|
||||||
setAccounts(data);
|
setAccounts(data);
|
||||||
} else {
|
} else {
|
||||||
// console.error('API 응답이 배열이 아닙니다:', data);
|
console.error('API 응답이 배열이 아닙니다:', data);
|
||||||
setAccounts([]);
|
setAccounts([]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error('계정 로드 실패:', error);
|
console.error('계정 로드 실패:', error);
|
||||||
setAccounts([]); // 에러 시 빈 배열로 설정
|
setAccounts([]); // 에러 시 빈 배열로 설정
|
||||||
// alert('계정 목록을 불러오는데 실패했습니다.');
|
// alert('계정 목록을 불러오는데 실패했습니다.');
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -93,7 +93,7 @@ export default function MailAccountsPage() {
|
||||||
await loadAccounts();
|
await loadAccounts();
|
||||||
alert('계정이 삭제되었습니다.');
|
alert('계정이 삭제되었습니다.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error('계정 삭제 실패:', error);
|
console.error('계정 삭제 실패:', error);
|
||||||
alert('계정 삭제에 실패했습니다.');
|
alert('계정 삭제에 실패했습니다.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -104,7 +104,7 @@ export default function MailAccountsPage() {
|
||||||
await updateMailAccount(account.id, { status: newStatus });
|
await updateMailAccount(account.id, { status: newStatus });
|
||||||
await loadAccounts();
|
await loadAccounts();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error('상태 변경 실패:', error);
|
console.error('상태 변경 실패:', error);
|
||||||
alert('상태 변경에 실패했습니다.');
|
alert('상태 변경에 실패했습니다.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -120,7 +120,7 @@ export default function MailAccountsPage() {
|
||||||
alert(`❌ SMTP 연결 실패\n\n${result.message || '연결에 실패했습니다.'}`);
|
alert(`❌ SMTP 연결 실패\n\n${result.message || '연결에 실패했습니다.'}`);
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// console.error('연결 테스트 실패:', error);
|
console.error('연결 테스트 실패:', error);
|
||||||
alert(`❌ SMTP 연결 테스트 실패\n\n${error.message || '알 수 없는 오류가 발생했습니다.'}`);
|
alert(`❌ SMTP 연결 테스트 실패\n\n${error.message || '알 수 없는 오류가 발생했습니다.'}`);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
|
||||||
|
|
@ -1,524 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import {
|
|
||||||
Upload,
|
|
||||||
Send,
|
|
||||||
FileText,
|
|
||||||
Users,
|
|
||||||
AlertCircle,
|
|
||||||
CheckCircle2,
|
|
||||||
Loader2,
|
|
||||||
Download,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useToast } from "@/hooks/use-toast";
|
|
||||||
import {
|
|
||||||
MailAccount,
|
|
||||||
MailTemplate,
|
|
||||||
getMailAccounts,
|
|
||||||
getMailTemplates,
|
|
||||||
sendBulkMail,
|
|
||||||
} from "@/lib/api/mail";
|
|
||||||
|
|
||||||
interface RecipientData {
|
|
||||||
email: string;
|
|
||||||
variables: Record<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BulkSendPage() {
|
|
||||||
const router = useRouter();
|
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
const [accounts, setAccounts] = useState<MailAccount[]>([]);
|
|
||||||
const [templates, setTemplates] = useState<MailTemplate[]>([]);
|
|
||||||
const [selectedAccountId, setSelectedAccountId] = useState<string>("");
|
|
||||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string>("");
|
|
||||||
const [useTemplate, setUseTemplate] = useState<boolean>(true); // 템플릿 사용 여부
|
|
||||||
const [customHtml, setCustomHtml] = useState<string>(""); // 직접 작성한 HTML
|
|
||||||
const [subject, setSubject] = useState<string>("");
|
|
||||||
const [recipients, setRecipients] = useState<RecipientData[]>([]);
|
|
||||||
const [csvFile, setCsvFile] = useState<File | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [sending, setSending] = useState(false);
|
|
||||||
const [sendProgress, setSendProgress] = useState({ sent: 0, total: 0 });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadAccounts();
|
|
||||||
loadTemplates();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadAccounts = async () => {
|
|
||||||
try {
|
|
||||||
const data = await getMailAccounts();
|
|
||||||
setAccounts(data.filter((acc) => acc.status === 'active'));
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const err = error as Error;
|
|
||||||
toast({
|
|
||||||
title: "계정 로드 실패",
|
|
||||||
description: err.message,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadTemplates = async () => {
|
|
||||||
try {
|
|
||||||
const data = await getMailTemplates();
|
|
||||||
setTemplates(data);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const err = error as Error;
|
|
||||||
toast({
|
|
||||||
title: "템플릿 로드 실패",
|
|
||||||
description: err.message,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
|
|
||||||
if (!file.name.endsWith(".csv")) {
|
|
||||||
toast({
|
|
||||||
title: "파일 형식 오류",
|
|
||||||
description: "CSV 파일만 업로드 가능합니다.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCsvFile(file);
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const text = await file.text();
|
|
||||||
const lines = text.split("\n").filter((line) => line.trim());
|
|
||||||
|
|
||||||
if (lines.length < 2) {
|
|
||||||
throw new Error("CSV 파일에 데이터가 없습니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 첫 줄은 헤더
|
|
||||||
const headers = lines[0].split(",").map((h) => h.trim());
|
|
||||||
|
|
||||||
if (!headers.includes("email")) {
|
|
||||||
throw new Error("CSV 파일에 'email' 컬럼이 필요합니다.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const emailIndex = headers.indexOf("email");
|
|
||||||
const variableHeaders = headers.filter((h) => h !== "email");
|
|
||||||
|
|
||||||
const parsedRecipients: RecipientData[] = lines.slice(1).map((line) => {
|
|
||||||
const values = line.split(",").map((v) => v.trim());
|
|
||||||
const email = values[emailIndex];
|
|
||||||
const variables: Record<string, string> = {};
|
|
||||||
|
|
||||||
variableHeaders.forEach((header, index) => {
|
|
||||||
const valueIndex = headers.indexOf(header);
|
|
||||||
variables[header] = values[valueIndex] || "";
|
|
||||||
});
|
|
||||||
|
|
||||||
return { email, variables };
|
|
||||||
});
|
|
||||||
|
|
||||||
setRecipients(parsedRecipients);
|
|
||||||
toast({
|
|
||||||
title: "파일 업로드 성공",
|
|
||||||
description: `${parsedRecipients.length}명의 수신자를 불러왔습니다.`,
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const err = error as Error;
|
|
||||||
toast({
|
|
||||||
title: "파일 파싱 실패",
|
|
||||||
description: err.message,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
setCsvFile(null);
|
|
||||||
setRecipients([]);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSend = async () => {
|
|
||||||
if (!selectedAccountId) {
|
|
||||||
toast({
|
|
||||||
title: "계정 선택 필요",
|
|
||||||
description: "발송할 메일 계정을 선택해주세요.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 템플릿 또는 직접 작성 중 하나는 있어야 함
|
|
||||||
if (useTemplate && !selectedTemplateId) {
|
|
||||||
toast({
|
|
||||||
title: "템플릿 선택 필요",
|
|
||||||
description: "사용할 템플릿을 선택해주세요.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!useTemplate && !customHtml.trim()) {
|
|
||||||
toast({
|
|
||||||
title: "내용 입력 필요",
|
|
||||||
description: "메일 내용을 입력해주세요.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!subject.trim()) {
|
|
||||||
toast({
|
|
||||||
title: "제목 입력 필요",
|
|
||||||
description: "메일 제목을 입력해주세요.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recipients.length === 0) {
|
|
||||||
toast({
|
|
||||||
title: "수신자 없음",
|
|
||||||
description: "CSV 파일을 업로드해주세요.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSending(true);
|
|
||||||
setSendProgress({ sent: 0, total: recipients.length });
|
|
||||||
|
|
||||||
try {
|
|
||||||
await sendBulkMail({
|
|
||||||
accountId: selectedAccountId,
|
|
||||||
templateId: useTemplate ? selectedTemplateId : undefined,
|
|
||||||
customHtml: !useTemplate ? customHtml : undefined,
|
|
||||||
subject,
|
|
||||||
recipients,
|
|
||||||
onProgress: (sent, total) => {
|
|
||||||
setSendProgress({ sent, total });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "대량 발송 완료",
|
|
||||||
description: `${recipients.length}명에게 메일을 발송했습니다.`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 초기화
|
|
||||||
setSelectedAccountId("");
|
|
||||||
setSelectedTemplateId("");
|
|
||||||
setSubject("");
|
|
||||||
setRecipients([]);
|
|
||||||
setCsvFile(null);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const err = error as Error;
|
|
||||||
toast({
|
|
||||||
title: "발송 실패",
|
|
||||||
description: err.message,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setSending(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const downloadSampleCsv = () => {
|
|
||||||
const sample = `email,name,company
|
|
||||||
example1@example.com,홍길동,ABC회사
|
|
||||||
example2@example.com,김철수,XYZ회사`;
|
|
||||||
|
|
||||||
const blob = new Blob([sample], { type: "text/csv;charset=utf-8;" });
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = URL.createObjectURL(blob);
|
|
||||||
link.download = "sample.csv";
|
|
||||||
link.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-background">
|
|
||||||
<div className="mx-auto w-full space-y-6 px-6 py-8">
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="flex items-center justify-between rounded-lg border bg-card p-8">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="rounded-lg bg-primary/10 p-4">
|
|
||||||
<Users className="h-8 w-8 text-primary" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="mb-1 text-3xl font-bold text-foreground">대량 메일 발송</h1>
|
|
||||||
<p className="text-muted-foreground">CSV 파일로 여러 수신자에게 메일을 발송하세요</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Link href="/admin/mail/dashboard">
|
|
||||||
<Button variant="outline" size="lg">
|
|
||||||
대시보드로
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
|
||||||
{/* 왼쪽: 설정 */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* 계정 선택 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg">발송 설정</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="account">발송 계정</Label>
|
|
||||||
<Select value={selectedAccountId} onValueChange={setSelectedAccountId}>
|
|
||||||
<SelectTrigger id="account">
|
|
||||||
<SelectValue placeholder="계정 선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{accounts.map((account) => (
|
|
||||||
<SelectItem key={account.id} value={account.id}>
|
|
||||||
{account.name} ({account.email})
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="mode">발송 방식</Label>
|
|
||||||
<Select value={useTemplate ? "template" : "custom"} onValueChange={(v) => setUseTemplate(v === "template")}>
|
|
||||||
<SelectTrigger id="mode">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="template">템플릿 사용</SelectItem>
|
|
||||||
<SelectItem value="custom">직접 작성</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{useTemplate ? (
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="template">템플릿</Label>
|
|
||||||
<Select value={selectedTemplateId} onValueChange={setSelectedTemplateId}>
|
|
||||||
<SelectTrigger id="template">
|
|
||||||
<SelectValue placeholder="템플릿 선택" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{templates.map((template) => (
|
|
||||||
<SelectItem key={template.id} value={template.id}>
|
|
||||||
{template.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="customHtml">메일 내용</Label>
|
|
||||||
<Textarea
|
|
||||||
id="customHtml"
|
|
||||||
value={customHtml}
|
|
||||||
onChange={(e) => setCustomHtml(e.target.value)}
|
|
||||||
placeholder="메일 내용을 작성하세요..."
|
|
||||||
rows={10}
|
|
||||||
className="text-sm"
|
|
||||||
/>
|
|
||||||
{/* <p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
HTML 태그를 사용할 수 있습니다
|
|
||||||
</p> */}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="subject">제목</Label>
|
|
||||||
<Input
|
|
||||||
id="subject"
|
|
||||||
value={subject}
|
|
||||||
onChange={(e) => setSubject(e.target.value)}
|
|
||||||
placeholder="메일 제목을 입력하세요"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* CSV 업로드 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg">수신자 업로드</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="csv">CSV 파일</Label>
|
|
||||||
<div className="mt-2 flex gap-2">
|
|
||||||
<Input
|
|
||||||
id="csv"
|
|
||||||
type="file"
|
|
||||||
accept=".csv"
|
|
||||||
onChange={handleFileUpload}
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={downloadSampleCsv}
|
|
||||||
title="샘플 다운로드"
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-xs text-muted-foreground">
|
|
||||||
첫 번째 줄은 헤더(email, name, company 등)여야 합니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{csvFile && (
|
|
||||||
<div className="flex items-center justify-between rounded-md border bg-muted p-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
|
||||||
<span className="text-sm">{csvFile.name}</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setCsvFile(null);
|
|
||||||
setRecipients([]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{recipients.length > 0 && (
|
|
||||||
<div className="rounded-md border bg-muted p-4">
|
|
||||||
<div className="flex items-center gap-2 text-sm">
|
|
||||||
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
|
||||||
<span className="font-medium">{recipients.length}명의 수신자</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
변수: {Object.keys(recipients[0]?.variables || {}).join(", ")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 오른쪽: 미리보기 & 발송 */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* 발송 버튼 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg">발송 실행</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{sending && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span>발송 진행 중...</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{sendProgress.sent} / {sendProgress.total}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
|
||||||
<div
|
|
||||||
className="h-full bg-primary transition-all duration-300"
|
|
||||||
style={{
|
|
||||||
width: `${(sendProgress.sent / sendProgress.total) * 100}%`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onClick={handleSend}
|
|
||||||
disabled={sending || recipients.length === 0}
|
|
||||||
className="w-full"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
{sending ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
|
||||||
발송 중...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Send className="mr-2 h-5 w-5" />
|
|
||||||
{recipients.length}명에게 발송
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="rounded-md border bg-muted p-4">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<AlertCircle className="mt-0.5 h-4 w-4 text-muted-foreground" />
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
<p className="font-medium">주의사항</p>
|
|
||||||
<ul className="mt-1 list-inside list-disc space-y-1">
|
|
||||||
<li>발송 속도는 계정 설정에 따라 제한됩니다</li>
|
|
||||||
<li>대량 발송 시 스팸으로 분류될 수 있습니다</li>
|
|
||||||
<li>발송 후 취소할 수 없습니다</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 수신자 목록 미리보기 */}
|
|
||||||
{recipients.length > 0 && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="text-lg">수신자 목록 미리보기</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="max-h-96 space-y-2 overflow-y-auto">
|
|
||||||
{recipients.slice(0, 10).map((recipient, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="rounded-md border bg-muted p-3 text-sm"
|
|
||||||
>
|
|
||||||
<div className="font-medium">{recipient.email}</div>
|
|
||||||
<div className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{Object.entries(recipient.variables).map(([key, value]) => (
|
|
||||||
<span key={key} className="mr-2">
|
|
||||||
{key}: {value}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{recipients.length > 10 && (
|
|
||||||
<p className="text-center text-xs text-muted-foreground">
|
|
||||||
외 {recipients.length - 10}명
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -13,12 +13,9 @@ import {
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Users,
|
Users,
|
||||||
Calendar,
|
Calendar,
|
||||||
ArrowRight,
|
ArrowRight
|
||||||
Trash2,
|
|
||||||
Edit
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getMailAccounts, getMailTemplates, getMailStatistics, getTodayReceivedCount } from "@/lib/api/mail";
|
import { getMailAccounts, getMailTemplates, getMailStatistics, getTodayReceivedCount } from "@/lib/api/mail";
|
||||||
import MailNotifications from "@/components/mail/MailNotifications";
|
|
||||||
|
|
||||||
interface DashboardStats {
|
interface DashboardStats {
|
||||||
totalAccounts: number;
|
totalAccounts: number;
|
||||||
|
|
@ -45,34 +42,14 @@ export default function MailDashboardPage() {
|
||||||
try {
|
try {
|
||||||
const accounts = await getMailAccounts();
|
const accounts = await getMailAccounts();
|
||||||
const templates = await getMailTemplates();
|
const templates = await getMailTemplates();
|
||||||
|
const mailStats = await getMailStatistics();
|
||||||
// 메일 통계 조회 (실패 시 기본값 사용)
|
|
||||||
let mailStats = {
|
|
||||||
todayCount: 0,
|
|
||||||
thisMonthCount: 0,
|
|
||||||
successRate: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const stats = await getMailStatistics();
|
|
||||||
if (stats && typeof stats === 'object') {
|
|
||||||
mailStats = {
|
|
||||||
todayCount: stats.todayCount || 0,
|
|
||||||
thisMonthCount: stats.thisMonthCount || 0,
|
|
||||||
successRate: stats.successRate || 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// console.error('메일 통계 조회 실패:', error);
|
|
||||||
// 기본값 사용
|
|
||||||
}
|
|
||||||
|
|
||||||
// 오늘 수신 메일 수 조회 (IMAP 실시간 조회)
|
// 오늘 수신 메일 수 조회 (IMAP 실시간 조회)
|
||||||
let receivedTodayCount = 0;
|
let receivedTodayCount = 0;
|
||||||
try {
|
try {
|
||||||
receivedTodayCount = await getTodayReceivedCount();
|
receivedTodayCount = await getTodayReceivedCount();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error('수신 메일 수 조회 실패:', error);
|
console.error('수신 메일 수 조회 실패:', error);
|
||||||
// 실패 시 0으로 표시
|
// 실패 시 0으로 표시
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -156,13 +133,6 @@ export default function MailDashboardPage() {
|
||||||
icon: Send,
|
icon: Send,
|
||||||
color: "orange",
|
color: "orange",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "대량 발송",
|
|
||||||
description: "CSV로 대량 발송",
|
|
||||||
href: "/admin/mail/bulk-send",
|
|
||||||
icon: Users,
|
|
||||||
color: "teal",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "보낸메일함",
|
title: "보낸메일함",
|
||||||
description: "발송 이력 확인",
|
description: "발송 이력 확인",
|
||||||
|
|
@ -177,25 +147,11 @@ export default function MailDashboardPage() {
|
||||||
icon: Inbox,
|
icon: Inbox,
|
||||||
color: "purple",
|
color: "purple",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "임시 저장",
|
|
||||||
description: "작성 중인 메일",
|
|
||||||
href: "/admin/mail/drafts",
|
|
||||||
icon: Edit,
|
|
||||||
color: "amber",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "휴지통",
|
|
||||||
description: "삭제된 메일",
|
|
||||||
href: "/admin/mail/trash",
|
|
||||||
icon: Trash2,
|
|
||||||
color: "red",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-background">
|
<div className="min-h-screen bg-background">
|
||||||
<div className="w-full px-3 py-3 space-y-3">
|
<div className="w-full max-w-7xl mx-auto px-6 py-8 space-y-6">
|
||||||
{/* 페이지 제목 */}
|
{/* 페이지 제목 */}
|
||||||
<div className="flex items-center justify-between bg-card rounded-lg border p-8">
|
<div className="flex items-center justify-between bg-card rounded-lg border p-8">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
|
@ -207,8 +163,6 @@ export default function MailDashboardPage() {
|
||||||
<p className="text-muted-foreground">메일 시스템의 전체 현황을 한눈에 확인하세요</p>
|
<p className="text-muted-foreground">메일 시스템의 전체 현황을 한눈에 확인하세요</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
|
||||||
<MailNotifications />
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|
@ -219,10 +173,9 @@ export default function MailDashboardPage() {
|
||||||
새로고침
|
새로고침
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 통계 카드 */}
|
{/* 통계 카드 */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-5">
|
||||||
{statCards.map((stat, index) => (
|
{statCards.map((stat, index) => (
|
||||||
<Link key={index} href={stat.href}>
|
<Link key={index} href={stat.href}>
|
||||||
<Card className="hover:shadow-md transition-all hover:scale-105 cursor-pointer">
|
<Card className="hover:shadow-md transition-all hover:scale-105 cursor-pointer">
|
||||||
|
|
@ -254,7 +207,7 @@ export default function MailDashboardPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 이번 달 통계 */}
|
{/* 이번 달 통계 */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="border-b">
|
<CardHeader className="border-b">
|
||||||
<CardTitle className="text-lg flex items-center">
|
<CardTitle className="text-lg flex items-center">
|
||||||
|
|
|
||||||
|
|
@ -1,201 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { getSentMailList, updateDraft, deleteSentMail, bulkDeleteMails, type SentMailHistory } from "@/lib/api/mail";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
import { Edit, Trash2, Loader2, Mail } from "lucide-react";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
import { ko } from "date-fns/locale";
|
|
||||||
|
|
||||||
export default function DraftsPage() {
|
|
||||||
const router = useRouter();
|
|
||||||
const [drafts, setDrafts] = useState<SentMailHistory[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [deleting, setDeleting] = useState<string | null>(null);
|
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
|
||||||
const [bulkDeleting, setBulkDeleting] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadDrafts();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadDrafts = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const response = await getSentMailList({
|
|
||||||
status: "draft",
|
|
||||||
sortBy: "updatedAt",
|
|
||||||
sortOrder: "desc",
|
|
||||||
});
|
|
||||||
// console.log('📋 임시 저장 목록 조회:', response);
|
|
||||||
// console.log('📋 임시 저장 개수:', response.items.length);
|
|
||||||
setDrafts(response.items);
|
|
||||||
} catch (error) {
|
|
||||||
// console.error("❌ 임시 저장 메일 로드 실패:", error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdit = (draft: SentMailHistory) => {
|
|
||||||
// 임시 저장 메일을 메일 발송 페이지로 전달
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
draftId: draft.id,
|
|
||||||
to: draft.to.join(","),
|
|
||||||
cc: draft.cc?.join(",") || "",
|
|
||||||
bcc: draft.bcc?.join(",") || "",
|
|
||||||
subject: draft.subject,
|
|
||||||
content: draft.htmlContent,
|
|
||||||
accountId: draft.accountId,
|
|
||||||
});
|
|
||||||
router.push(`/admin/mail/send?${params.toString()}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
|
||||||
if (!confirm("이 임시 저장 메일을 삭제하시겠습니까?")) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setDeleting(id);
|
|
||||||
await deleteSentMail(id);
|
|
||||||
setDrafts(drafts.filter((d) => d.id !== id));
|
|
||||||
setSelectedIds(selectedIds.filter((selectedId) => selectedId !== id));
|
|
||||||
} catch (error) {
|
|
||||||
// console.error("임시 저장 메일 삭제 실패:", error);
|
|
||||||
alert("삭제에 실패했습니다.");
|
|
||||||
} finally {
|
|
||||||
setDeleting(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBulkDelete = async () => {
|
|
||||||
if (selectedIds.length === 0) {
|
|
||||||
alert("삭제할 메일을 선택해주세요.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!confirm(`선택한 ${selectedIds.length}개의 임시 저장 메일을 삭제하시겠습니까?`)) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setBulkDeleting(true);
|
|
||||||
const result = await bulkDeleteMails(selectedIds);
|
|
||||||
setDrafts(drafts.filter((d) => !selectedIds.includes(d.id)));
|
|
||||||
setSelectedIds([]);
|
|
||||||
alert(result.message);
|
|
||||||
} catch (error) {
|
|
||||||
// console.error("일괄 삭제 실패:", error);
|
|
||||||
alert("일괄 삭제에 실패했습니다.");
|
|
||||||
} finally {
|
|
||||||
setBulkDeleting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectAll = () => {
|
|
||||||
if (selectedIds.length === drafts.length) {
|
|
||||||
setSelectedIds([]);
|
|
||||||
} else {
|
|
||||||
setSelectedIds(drafts.map((d) => d.id));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelectOne = (id: string) => {
|
|
||||||
if (selectedIds.includes(id)) {
|
|
||||||
setSelectedIds(selectedIds.filter((selectedId) => selectedId !== id));
|
|
||||||
} else {
|
|
||||||
setSelectedIds([...selectedIds, id]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-3 space-y-3">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-foreground">임시보관함</h1>
|
|
||||||
<p className="mt-2 text-muted-foreground">작성 중인 메일이 자동으로 저장됩니다</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{drafts.length === 0 ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
|
||||||
<Mail className="w-12 h-12 text-muted-foreground mb-4" />
|
|
||||||
<p className="text-muted-foreground">임시 저장된 메일이 없습니다</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{drafts.map((draft) => (
|
|
||||||
<Card key={draft.id} className="hover:shadow-md transition-shadow">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<CardTitle className="text-lg truncate">
|
|
||||||
{draft.subject || "(제목 없음)"}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="mt-1">
|
|
||||||
받는 사람: {draft.to.join(", ") || "(없음)"}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 ml-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleEdit(draft)}
|
|
||||||
className="h-8"
|
|
||||||
>
|
|
||||||
<Edit className="w-4 h-4 mr-1" />
|
|
||||||
편집
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleDelete(draft.id)}
|
|
||||||
disabled={deleting === draft.id}
|
|
||||||
className="h-8"
|
|
||||||
>
|
|
||||||
{deleting === draft.id ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Trash2 className="w-4 h-4 mr-1" />
|
|
||||||
삭제
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-0">
|
|
||||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
|
||||||
<span>계정: {draft.accountName || draft.accountEmail}</span>
|
|
||||||
<span>
|
|
||||||
{draft.updatedAt
|
|
||||||
? format(new Date(draft.updatedAt), "yyyy-MM-dd HH:mm", { locale: ko })
|
|
||||||
: format(new Date(draft.sentAt), "yyyy-MM-dd HH:mm", { locale: ko })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{draft.htmlContent && (
|
|
||||||
<div
|
|
||||||
className="mt-2 text-sm text-muted-foreground line-clamp-2"
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: draft.htmlContent.replace(/<[^>]*>/g, "").substring(0, 100),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -16,30 +16,21 @@ import {
|
||||||
SortAsc,
|
SortAsc,
|
||||||
SortDesc,
|
SortDesc,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Reply,
|
|
||||||
Forward,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import {
|
import {
|
||||||
MailAccount,
|
MailAccount,
|
||||||
ReceivedMail,
|
ReceivedMail,
|
||||||
MailDetail,
|
|
||||||
getMailAccounts,
|
getMailAccounts,
|
||||||
getReceivedMails,
|
getReceivedMails,
|
||||||
testImapConnection,
|
testImapConnection,
|
||||||
getMailDetail,
|
|
||||||
markMailAsRead,
|
|
||||||
downloadMailAttachment,
|
|
||||||
} from "@/lib/api/mail";
|
} from "@/lib/api/mail";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import MailDetailModal from "@/components/mail/MailDetailModal";
|
||||||
import DOMPurify from "isomorphic-dompurify";
|
|
||||||
|
|
||||||
export default function MailReceivePage() {
|
export default function MailReceivePage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const [accounts, setAccounts] = useState<MailAccount[]>([]);
|
const [accounts, setAccounts] = useState<MailAccount[]>([]);
|
||||||
const [selectedAccountId, setSelectedAccountId] = useState<string>("");
|
const [selectedAccountId, setSelectedAccountId] = useState<string>("");
|
||||||
const [mails, setMails] = useState<ReceivedMail[]>([]);
|
const [mails, setMails] = useState<ReceivedMail[]>([]);
|
||||||
|
|
@ -50,23 +41,15 @@ export default function MailReceivePage() {
|
||||||
message: string;
|
message: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
// 메일 상세 상태 (모달 대신 패널)
|
// 메일 상세 모달 상태
|
||||||
|
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
|
||||||
const [selectedMailId, setSelectedMailId] = useState<string>("");
|
const [selectedMailId, setSelectedMailId] = useState<string>("");
|
||||||
const [selectedMailDetail, setSelectedMailDetail] = useState<MailDetail | null>(null);
|
|
||||||
const [loadingDetail, setLoadingDetail] = useState(false);
|
|
||||||
const [deleting, setDeleting] = useState(false);
|
|
||||||
|
|
||||||
// 검색 및 필터 상태
|
// 검색 및 필터 상태
|
||||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||||
const [filterStatus, setFilterStatus] = useState<string>("all"); // all, unread, read, attachment
|
const [filterStatus, setFilterStatus] = useState<string>("all"); // all, unread, read, attachment
|
||||||
const [sortBy, setSortBy] = useState<string>("date-desc"); // date-desc, date-asc, from-asc, from-desc
|
const [sortBy, setSortBy] = useState<string>("date-desc"); // date-desc, date-asc, from-asc, from-desc
|
||||||
|
|
||||||
// 페이지네이션
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const [itemsPerPage] = useState(10);
|
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
|
||||||
const [allMails, setAllMails] = useState<ReceivedMail[]>([]); // 전체 메일 저장
|
|
||||||
|
|
||||||
// 계정 목록 로드
|
// 계정 목록 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadAccounts();
|
loadAccounts();
|
||||||
|
|
@ -75,42 +58,10 @@ export default function MailReceivePage() {
|
||||||
// 계정 선택 시 메일 로드
|
// 계정 선택 시 메일 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedAccountId) {
|
if (selectedAccountId) {
|
||||||
setCurrentPage(1); // 계정 변경 시 첫 페이지로
|
|
||||||
loadMails();
|
loadMails();
|
||||||
}
|
}
|
||||||
}, [selectedAccountId]);
|
}, [selectedAccountId]);
|
||||||
|
|
||||||
// 페이지 변경 시 페이지네이션 재적용
|
|
||||||
useEffect(() => {
|
|
||||||
if (allMails.length > 0) {
|
|
||||||
applyPagination(allMails);
|
|
||||||
}
|
|
||||||
}, [currentPage]);
|
|
||||||
|
|
||||||
// URL 파라미터에서 mailId 읽기 및 자동 선택
|
|
||||||
useEffect(() => {
|
|
||||||
const mailId = searchParams.get('mailId');
|
|
||||||
const accountId = searchParams.get('accountId');
|
|
||||||
|
|
||||||
if (mailId && accountId) {
|
|
||||||
// console.log('📧 URL에서 메일 ID 감지:', mailId, accountId);
|
|
||||||
setSelectedAccountId(accountId);
|
|
||||||
setSelectedMailId(mailId);
|
|
||||||
// 메일 상세 로드는 handleMailClick에서 처리됨
|
|
||||||
}
|
|
||||||
}, [searchParams]);
|
|
||||||
|
|
||||||
// 메일 목록 로드 후 URL에서 지정된 메일 자동 선택
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedMailId && mails.length > 0 && !selectedMailDetail) {
|
|
||||||
const mail = mails.find(m => m.id === selectedMailId);
|
|
||||||
if (mail) {
|
|
||||||
// console.log('🎯 URL에서 지정된 메일 자동 선택:', selectedMailId);
|
|
||||||
handleMailClick(mail);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [mails, selectedMailId, selectedMailDetail]); // selectedMailDetail 추가로 무한 루프 방지
|
|
||||||
|
|
||||||
// 자동 새로고침 (30초마다)
|
// 자동 새로고침 (30초마다)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedAccountId) return;
|
if (!selectedAccountId) return;
|
||||||
|
|
@ -133,7 +84,7 @@ export default function MailReceivePage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("계정 로드 실패:", error);
|
console.error("계정 로드 실패:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -143,47 +94,21 @@ export default function MailReceivePage() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setTestResult(null);
|
setTestResult(null);
|
||||||
try {
|
try {
|
||||||
const data = await getReceivedMails(selectedAccountId, 200); // 더 많이 가져오기
|
const data = await getReceivedMails(selectedAccountId, 50);
|
||||||
|
setMails(data);
|
||||||
// 현재 로컬에서 읽음 처리한 메일들의 상태를 유지
|
|
||||||
const processedMails = data.map(mail => ({
|
|
||||||
...mail,
|
|
||||||
isRead: mail.isRead
|
|
||||||
}));
|
|
||||||
|
|
||||||
setAllMails(processedMails); // 전체 메일 저장
|
|
||||||
|
|
||||||
// 페이지네이션 적용
|
|
||||||
applyPagination(processedMails);
|
|
||||||
|
|
||||||
// 알림 갱신 이벤트 발생 (새 메일이 있을 수 있음)
|
|
||||||
window.dispatchEvent(new CustomEvent('mail-received'));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("메일 로드 실패:", error);
|
console.error("메일 로드 실패:", error);
|
||||||
alert(
|
alert(
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
: "메일을 불러오는데 실패했습니다."
|
: "메일을 불러오는데 실패했습니다."
|
||||||
);
|
);
|
||||||
setMails([]);
|
setMails([]);
|
||||||
setAllMails([]);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyPagination = (mailList: ReceivedMail[]) => {
|
|
||||||
const totalItems = mailList.length;
|
|
||||||
const totalPagesCalc = Math.ceil(totalItems / itemsPerPage);
|
|
||||||
setTotalPages(totalPagesCalc);
|
|
||||||
|
|
||||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
|
||||||
const endIndex = startIndex + itemsPerPage;
|
|
||||||
const paginatedMails = mailList.slice(startIndex, endIndex);
|
|
||||||
|
|
||||||
setMails(paginatedMails);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTestConnection = async () => {
|
const handleTestConnection = async () => {
|
||||||
if (!selectedAccountId) return;
|
if (!selectedAccountId) return;
|
||||||
|
|
||||||
|
|
@ -228,94 +153,14 @@ export default function MailReceivePage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMailClick = async (mail: ReceivedMail) => {
|
const handleMailClick = (mail: ReceivedMail) => {
|
||||||
setSelectedMailId(mail.id);
|
setSelectedMailId(mail.id);
|
||||||
setLoadingDetail(true);
|
setIsDetailModalOpen(true);
|
||||||
|
|
||||||
// 즉시 로컬 상태 업데이트 (UI 반응성 향상)
|
|
||||||
// console.log('📧 메일 클릭:', mail.id, '현재 읽음 상태:', mail.isRead);
|
|
||||||
setMails((prevMails) =>
|
|
||||||
prevMails.map((m) =>
|
|
||||||
m.id === mail.id ? { ...m, isRead: true } : m
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// 메일 상세 정보 로드
|
|
||||||
try {
|
|
||||||
// mail.id에서 accountId와 seqno 추출: "account-{timestamp}-{seqno}" 형식
|
|
||||||
const mailIdParts = mail.id.split('-');
|
|
||||||
const accountId = `${mailIdParts[0]}-${mailIdParts[1]}`; // "account-1759310844272"
|
|
||||||
const seqno = parseInt(mailIdParts[2], 10); // 13
|
|
||||||
|
|
||||||
// console.log('🔍 추출된 accountId:', accountId, 'seqno:', seqno, '원본 mailId:', mail.id);
|
|
||||||
|
|
||||||
const detail = await getMailDetail(accountId, seqno);
|
|
||||||
setSelectedMailDetail(detail);
|
|
||||||
|
|
||||||
// 읽음 처리
|
|
||||||
if (!mail.isRead) {
|
|
||||||
await markMailAsRead(accountId, seqno);
|
|
||||||
// console.log('✅ 읽음 처리 완료 - seqno:', seqno);
|
|
||||||
|
|
||||||
// 서버 상태 동기화 (백그라운드) - IMAP 서버 반영 대기
|
|
||||||
setTimeout(() => {
|
|
||||||
if (selectedAccountId) {
|
|
||||||
// console.log('🔄 서버 상태 동기화 시작');
|
|
||||||
loadMails();
|
|
||||||
}
|
|
||||||
}, 2000); // 2초로 증가
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// console.error('메일 상세 로드 실패:', error);
|
|
||||||
} finally {
|
|
||||||
setLoadingDetail(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteMail = async () => {
|
const handleMailRead = () => {
|
||||||
if (!selectedMailId || !confirm("이 메일을 IMAP 서버에서 삭제하시겠습니까?\n(Gmail/Naver 휴지통으로 이동됩니다)\n\n⚠️ IMAP 연결에 시간이 걸릴 수 있습니다.")) return;
|
// 메일을 읽었으므로 목록 새로고침
|
||||||
|
loadMails();
|
||||||
try {
|
|
||||||
setDeleting(true);
|
|
||||||
|
|
||||||
// mail.id에서 accountId와 seqno 추출: "account-{timestamp}-{seqno}" 형식
|
|
||||||
const mailIdParts = selectedMailId.split('-');
|
|
||||||
const accountId = `${mailIdParts[0]}-${mailIdParts[1]}`; // "account-1759310844272"
|
|
||||||
const seqno = parseInt(mailIdParts[2], 10); // 10
|
|
||||||
|
|
||||||
// console.log(`🗑️ 메일 삭제 시도: accountId=${accountId}, seqno=${seqno}`);
|
|
||||||
|
|
||||||
// IMAP 서버에서 메일 삭제 (타임아웃 40초)
|
|
||||||
const response = await apiClient.delete(`/mail/receive/${accountId}/${seqno}`, {
|
|
||||||
timeout: 40000, // 40초 타임아웃
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.data.success) {
|
|
||||||
// 메일 목록에서 제거
|
|
||||||
setMails(mails.filter((m) => m.id !== selectedMailId));
|
|
||||||
|
|
||||||
// 상세 패널 닫기
|
|
||||||
setSelectedMailId("");
|
|
||||||
setSelectedMailDetail(null);
|
|
||||||
|
|
||||||
alert("메일이 삭제되었습니다.");
|
|
||||||
// console.log("✅ 메일 삭제 완료");
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
// console.error("메일 삭제 실패:", error);
|
|
||||||
|
|
||||||
let errorMessage = "메일 삭제에 실패했습니다.";
|
|
||||||
|
|
||||||
if (error.code === 'ECONNABORTED' || error.message?.includes('timeout')) {
|
|
||||||
errorMessage = "IMAP 서버 연결 시간 초과\n네트워크 상태를 확인하거나 나중에 다시 시도해주세요.";
|
|
||||||
} else if (error.response?.data?.message) {
|
|
||||||
errorMessage = error.response.data.message;
|
|
||||||
}
|
|
||||||
|
|
||||||
alert(errorMessage);
|
|
||||||
} finally {
|
|
||||||
setDeleting(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 필터링 및 정렬된 메일 목록
|
// 필터링 및 정렬된 메일 목록
|
||||||
|
|
@ -520,10 +365,7 @@ export default function MailReceivePage() {
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 네이버 메일 스타일 3-column 레이아웃 */}
|
{/* 메일 목록 */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
||||||
{/* 왼쪽: 메일 목록 */}
|
|
||||||
<div className="lg:col-span-1">
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Card className="">
|
<Card className="">
|
||||||
<CardContent className="flex justify-center items-center py-16">
|
<CardContent className="flex justify-center items-center py-16">
|
||||||
|
|
@ -567,14 +409,14 @@ export default function MailReceivePage() {
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<div className="divide-y max-h-[calc(100vh-300px)] overflow-y-auto">
|
<div className="divide-y">
|
||||||
{filteredAndSortedMails.map((mail) => (
|
{filteredAndSortedMails.map((mail) => (
|
||||||
<div
|
<div
|
||||||
key={mail.id}
|
key={mail.id}
|
||||||
onClick={() => handleMailClick(mail)}
|
onClick={() => handleMailClick(mail)}
|
||||||
className={`p-4 hover:bg-background transition-colors cursor-pointer ${
|
className={`p-4 hover:bg-background transition-colors cursor-pointer ${
|
||||||
!mail.isRead ? "bg-blue-50/30" : ""
|
!mail.isRead ? "bg-blue-50/30" : ""
|
||||||
} ${selectedMailId === mail.id ? "bg-accent border-l-4 border-l-primary" : ""}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
{/* 읽음 표시 */}
|
{/* 읽음 표시 */}
|
||||||
|
|
@ -621,283 +463,8 @@ export default function MailReceivePage() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
{/* 페이지네이션 */}
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className="flex items-center justify-center gap-2 p-4 border-t">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setCurrentPage(1)}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
>
|
|
||||||
처음
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
>
|
|
||||||
이전
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
|
||||||
let pageNum;
|
|
||||||
if (totalPages <= 5) {
|
|
||||||
pageNum = i + 1;
|
|
||||||
} else if (currentPage <= 3) {
|
|
||||||
pageNum = i + 1;
|
|
||||||
} else if (currentPage >= totalPages - 2) {
|
|
||||||
pageNum = totalPages - 4 + i;
|
|
||||||
} else {
|
|
||||||
pageNum = currentPage - 2 + i;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
key={pageNum}
|
|
||||||
variant={currentPage === pageNum ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setCurrentPage(pageNum)}
|
|
||||||
className="w-8 h-8 p-0"
|
|
||||||
>
|
|
||||||
{pageNum}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setCurrentPage((prev) => Math.min(totalPages, prev + 1))}
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
>
|
|
||||||
다음
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setCurrentPage(totalPages)}
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
>
|
|
||||||
마지막
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 오른쪽: 메일 상세 패널 */}
|
|
||||||
<div className="lg:col-span-1">
|
|
||||||
{selectedMailDetail ? (
|
|
||||||
<Card className="sticky top-6">
|
|
||||||
<CardHeader className="border-b">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle className="text-lg">{selectedMailDetail.subject}</CardTitle>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedMailId("");
|
|
||||||
setSelectedMailDetail(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground space-y-1 mt-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-medium">보낸 사람:</span>
|
|
||||||
<span>{selectedMailDetail.from}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-medium">받는 사람:</span>
|
|
||||||
<span>{selectedMailDetail.to}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-medium">날짜:</span>
|
|
||||||
<span>{new Date(selectedMailDetail.date).toLocaleString("ko-KR")}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 답장/전달/삭제 버튼 */}
|
|
||||||
<div className="flex gap-2 mt-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
// HTML 태그 제거 함수 (강력한 버전)
|
|
||||||
const stripHtml = (html: string) => {
|
|
||||||
if (!html) return "";
|
|
||||||
|
|
||||||
// 1. DOMPurify로 먼저 정제
|
|
||||||
const cleanHtml = DOMPurify.sanitize(html, {
|
|
||||||
ALLOWED_TAGS: [], // 모든 태그 제거
|
|
||||||
KEEP_CONTENT: true // 내용만 유지
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. DOM으로 텍스트만 추출
|
|
||||||
const tmp = document.createElement("DIV");
|
|
||||||
tmp.innerHTML = cleanHtml;
|
|
||||||
let text = tmp.textContent || tmp.innerText || "";
|
|
||||||
|
|
||||||
// 3. CSS 스타일 제거 (p{...} 같은 패턴)
|
|
||||||
text = text.replace(/[a-z-]+\{[^}]*\}/gi, '');
|
|
||||||
|
|
||||||
// 4. 연속된 공백 정리
|
|
||||||
text = text.replace(/\s+/g, ' ').trim();
|
|
||||||
|
|
||||||
return text;
|
|
||||||
};
|
|
||||||
|
|
||||||
// console.log('📧 답장 데이터:', {
|
|
||||||
// htmlBody: selectedMailDetail.htmlBody,
|
|
||||||
// textBody: selectedMailDetail.textBody,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// textBody 우선 사용 (순수 텍스트), 없으면 htmlBody에서 추출
|
|
||||||
const bodyText = selectedMailDetail.textBody
|
|
||||||
|| (selectedMailDetail.htmlBody ? stripHtml(selectedMailDetail.htmlBody) : "");
|
|
||||||
|
|
||||||
// console.log('📧 변환된 본문:', bodyText);
|
|
||||||
|
|
||||||
const replyData = {
|
|
||||||
originalFrom: selectedMailDetail.from,
|
|
||||||
originalSubject: selectedMailDetail.subject,
|
|
||||||
originalDate: selectedMailDetail.date,
|
|
||||||
originalBody: bodyText,
|
|
||||||
};
|
|
||||||
router.push(
|
|
||||||
`/admin/mail/send?action=reply&data=${encodeURIComponent(JSON.stringify(replyData))}`
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Reply className="w-4 h-4 mr-1" />
|
|
||||||
답장
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
// HTML 태그 제거 함수 (강력한 버전)
|
|
||||||
const stripHtml = (html: string) => {
|
|
||||||
if (!html) return "";
|
|
||||||
|
|
||||||
// 1. DOMPurify로 먼저 정제
|
|
||||||
const cleanHtml = DOMPurify.sanitize(html, {
|
|
||||||
ALLOWED_TAGS: [], // 모든 태그 제거
|
|
||||||
KEEP_CONTENT: true // 내용만 유지
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. DOM으로 텍스트만 추출
|
|
||||||
const tmp = document.createElement("DIV");
|
|
||||||
tmp.innerHTML = cleanHtml;
|
|
||||||
let text = tmp.textContent || tmp.innerText || "";
|
|
||||||
|
|
||||||
// 3. CSS 스타일 제거 (p{...} 같은 패턴)
|
|
||||||
text = text.replace(/[a-z-]+\{[^}]*\}/gi, '');
|
|
||||||
|
|
||||||
// 4. 연속된 공백 정리
|
|
||||||
text = text.replace(/\s+/g, ' ').trim();
|
|
||||||
|
|
||||||
return text;
|
|
||||||
};
|
|
||||||
|
|
||||||
// console.log('📧 전달 데이터:', {
|
|
||||||
// htmlBody: selectedMailDetail.htmlBody,
|
|
||||||
// textBody: selectedMailDetail.textBody,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// textBody 우선 사용 (순수 텍스트), 없으면 htmlBody에서 추출
|
|
||||||
const bodyText = selectedMailDetail.textBody
|
|
||||||
|| (selectedMailDetail.htmlBody ? stripHtml(selectedMailDetail.htmlBody) : "");
|
|
||||||
|
|
||||||
// console.log('📧 변환된 본문:', bodyText);
|
|
||||||
|
|
||||||
const forwardData = {
|
|
||||||
originalFrom: selectedMailDetail.from,
|
|
||||||
originalSubject: selectedMailDetail.subject,
|
|
||||||
originalDate: selectedMailDetail.date,
|
|
||||||
originalBody: bodyText,
|
|
||||||
};
|
|
||||||
router.push(
|
|
||||||
`/admin/mail/send?action=forward&data=${encodeURIComponent(JSON.stringify(forwardData))}`
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Forward className="w-4 h-4 mr-1" />
|
|
||||||
전달
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleDeleteMail}
|
|
||||||
disabled={deleting}
|
|
||||||
>
|
|
||||||
{deleting ? (
|
|
||||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Trash2 className="w-4 h-4 mr-1" />
|
|
||||||
)}
|
|
||||||
삭제
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-6 max-h-[calc(100vh-300px)] overflow-y-auto">
|
|
||||||
{/* 첨부파일 */}
|
|
||||||
{selectedMailDetail.attachments && selectedMailDetail.attachments.length > 0 && (
|
|
||||||
<div className="mb-4 p-3 bg-muted rounded-lg">
|
|
||||||
<p className="text-sm font-medium mb-2">첨부파일 ({selectedMailDetail.attachments.length}개)</p>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{selectedMailDetail.attachments.map((att, index) => (
|
|
||||||
<div key={index} className="flex items-center gap-2 text-sm">
|
|
||||||
<Paperclip className="w-4 h-4" />
|
|
||||||
<span>{att.filename}</span>
|
|
||||||
<span className="text-muted-foreground">({(att.size / 1024).toFixed(1)} KB)</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 메일 본문 */}
|
|
||||||
{selectedMailDetail.htmlBody ? (
|
|
||||||
<div
|
|
||||||
className="prose prose-sm max-w-none"
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: DOMPurify.sanitize(selectedMailDetail.htmlBody),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="whitespace-pre-wrap text-sm">
|
|
||||||
{selectedMailDetail.textBody}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : loadingDetail ? (
|
|
||||||
<Card className="sticky top-6">
|
|
||||||
<CardContent className="flex justify-center items-center py-16">
|
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-orange-500" />
|
|
||||||
<span className="ml-3 text-muted-foreground">메일을 불러오는 중...</span>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<Card className="sticky top-6">
|
|
||||||
<CardContent className="flex flex-col justify-center items-center py-16 text-center">
|
|
||||||
<Mail className="w-16 h-16 mb-4 text-gray-300" />
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
메일을 선택하면 내용이 표시됩니다
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 안내 정보 */}
|
{/* 안내 정보 */}
|
||||||
<Card className="bg-gradient-to-r from-green-50 to-emerald-50 border-green-200 ">
|
<Card className="bg-gradient-to-r from-green-50 to-emerald-50 border-green-200 ">
|
||||||
|
|
@ -996,6 +563,15 @@ export default function MailReceivePage() {
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 메일 상세 모달 */}
|
||||||
|
<MailDetailModal
|
||||||
|
isOpen={isDetailModalOpen}
|
||||||
|
onClose={() => setIsDetailModalOpen(false)}
|
||||||
|
accountId={selectedAccountId}
|
||||||
|
mailId={selectedMailId}
|
||||||
|
onMailRead={handleMailRead}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ import {
|
||||||
Settings,
|
Settings,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import {
|
import {
|
||||||
|
|
@ -42,14 +42,11 @@ import {
|
||||||
sendMail,
|
sendMail,
|
||||||
extractTemplateVariables,
|
extractTemplateVariables,
|
||||||
renderTemplateToHtml,
|
renderTemplateToHtml,
|
||||||
saveDraft,
|
|
||||||
updateDraft,
|
|
||||||
} from "@/lib/api/mail";
|
} from "@/lib/api/mail";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
export default function MailSendPage() {
|
export default function MailSendPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [accounts, setAccounts] = useState<MailAccount[]>([]);
|
const [accounts, setAccounts] = useState<MailAccount[]>([]);
|
||||||
const [templates, setTemplates] = useState<MailTemplate[]>([]);
|
const [templates, setTemplates] = useState<MailTemplate[]>([]);
|
||||||
|
|
@ -69,7 +66,6 @@ export default function MailSendPage() {
|
||||||
const [customHtml, setCustomHtml] = useState<string>("");
|
const [customHtml, setCustomHtml] = useState<string>("");
|
||||||
const [variables, setVariables] = useState<Record<string, string>>({});
|
const [variables, setVariables] = useState<Record<string, string>>({});
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
const [isEditingHtml, setIsEditingHtml] = useState(false); // HTML 편집 모드
|
|
||||||
|
|
||||||
// 템플릿 변수
|
// 템플릿 변수
|
||||||
const [templateVariables, setTemplateVariables] = useState<string[]>([]);
|
const [templateVariables, setTemplateVariables] = useState<string[]>([]);
|
||||||
|
|
@ -78,113 +74,9 @@ export default function MailSendPage() {
|
||||||
const [attachments, setAttachments] = useState<File[]>([]);
|
const [attachments, setAttachments] = useState<File[]>([]);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
// 임시 저장
|
|
||||||
const [draftId, setDraftId] = useState<string | null>(null);
|
|
||||||
const [lastSaved, setLastSaved] = useState<Date | null>(null);
|
|
||||||
const [autoSaving, setAutoSaving] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
|
}, []);
|
||||||
// 답장/전달 데이터 처리
|
|
||||||
const action = searchParams.get("action");
|
|
||||||
const dataParam = searchParams.get("data");
|
|
||||||
|
|
||||||
if (action && dataParam) {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(decodeURIComponent(dataParam));
|
|
||||||
|
|
||||||
if (action === "reply") {
|
|
||||||
// 답장: 받는사람 자동 입력, 제목에 Re: 추가
|
|
||||||
const fromEmail = data.originalFrom.match(/<(.+?)>/)?.[1] || data.originalFrom;
|
|
||||||
setTo([fromEmail]);
|
|
||||||
setSubject(data.originalSubject.startsWith("Re: ")
|
|
||||||
? data.originalSubject
|
|
||||||
: `Re: ${data.originalSubject}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// 원본 메일을 순수 텍스트로 추가 (사용자가 읽기 쉽게)
|
|
||||||
const originalMessage = `
|
|
||||||
|
|
||||||
|
|
||||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
원본 메일:
|
|
||||||
|
|
||||||
보낸사람: ${data.originalFrom}
|
|
||||||
날짜: ${new Date(data.originalDate).toLocaleString("ko-KR")}
|
|
||||||
제목: ${data.originalSubject}
|
|
||||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
${data.originalBody}`;
|
|
||||||
|
|
||||||
setCustomHtml(originalMessage);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: '답장 작성',
|
|
||||||
description: '받는사람과 제목이 자동으로 입력되었습니다.',
|
|
||||||
});
|
|
||||||
} else if (action === "forward") {
|
|
||||||
// 전달: 받는사람 비어있음, 제목에 Fwd: 추가
|
|
||||||
setSubject(data.originalSubject.startsWith("Fwd: ")
|
|
||||||
? data.originalSubject
|
|
||||||
: `Fwd: ${data.originalSubject}`
|
|
||||||
);
|
|
||||||
|
|
||||||
// 원본 메일을 순수 텍스트로 추가 (사용자가 읽기 쉽게)
|
|
||||||
const originalMessage = `
|
|
||||||
|
|
||||||
|
|
||||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
전달된 메일:
|
|
||||||
|
|
||||||
보낸사람: ${data.originalFrom}
|
|
||||||
날짜: ${new Date(data.originalDate).toLocaleString("ko-KR")}
|
|
||||||
제목: ${data.originalSubject}
|
|
||||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
||||||
|
|
||||||
${data.originalBody}`;
|
|
||||||
|
|
||||||
setCustomHtml(originalMessage);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: '메일 전달',
|
|
||||||
description: '전달할 메일 내용이 입력되었습니다. 받는사람을 입력하세요.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// URL에서 파라미터 제거 (깔끔하게)
|
|
||||||
router.replace("/admin/mail/send");
|
|
||||||
} catch (error) {
|
|
||||||
// console.error("답장/전달 데이터 파싱 실패:", error);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 임시 저장 메일 불러오기
|
|
||||||
const draftIdParam = searchParams.get("draftId");
|
|
||||||
const toParam = searchParams.get("to");
|
|
||||||
const ccParam = searchParams.get("cc");
|
|
||||||
const bccParam = searchParams.get("bcc");
|
|
||||||
const subjectParam = searchParams.get("subject");
|
|
||||||
const contentParam = searchParams.get("content");
|
|
||||||
const accountIdParam = searchParams.get("accountId");
|
|
||||||
|
|
||||||
if (draftIdParam) {
|
|
||||||
setDraftId(draftIdParam);
|
|
||||||
if (toParam) setTo(toParam.split(",").filter(Boolean));
|
|
||||||
if (ccParam) setCc(ccParam.split(",").filter(Boolean));
|
|
||||||
if (bccParam) setBcc(bccParam.split(",").filter(Boolean));
|
|
||||||
if (subjectParam) setSubject(subjectParam);
|
|
||||||
if (contentParam) setCustomHtml(contentParam);
|
|
||||||
if (accountIdParam) setSelectedAccountId(accountIdParam);
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: '임시 저장 메일 불러오기',
|
|
||||||
description: '작성 중이던 메일을 불러왔습니다.',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}, [searchParams]);
|
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -193,28 +85,20 @@ ${data.originalBody}`;
|
||||||
getMailAccounts(),
|
getMailAccounts(),
|
||||||
getMailTemplates(),
|
getMailTemplates(),
|
||||||
]);
|
]);
|
||||||
const activeAccounts = accountsData.filter((acc) => acc.status === "active");
|
setAccounts(accountsData.filter((acc) => acc.status === "active"));
|
||||||
setAccounts(activeAccounts);
|
|
||||||
setTemplates(templatesData);
|
setTemplates(templatesData);
|
||||||
|
console.log('📦 데이터 로드 완료:', {
|
||||||
// 계정이 선택되지 않았고, 활성 계정이 있으면 첫 번째 계정 자동 선택
|
accounts: accountsData.length,
|
||||||
if (!selectedAccountId && activeAccounts.length > 0) {
|
templates: templatesData.length,
|
||||||
setSelectedAccountId(activeAccounts[0].id);
|
templatesDetail: templatesData.map(t => ({
|
||||||
// console.log('🔧 첫 번째 계정 자동 선택:', activeAccounts[0].email);
|
id: t.id,
|
||||||
}
|
name: t.name,
|
||||||
|
componentsCount: t.components?.length || 0
|
||||||
// console.log('📦 데이터 로드 완료:', {
|
}))
|
||||||
// accounts: accountsData.length,
|
});
|
||||||
// templates: templatesData.length,
|
|
||||||
// templatesDetail: templatesData.map(t => ({
|
|
||||||
// id: t.id,
|
|
||||||
// name: t.name,
|
|
||||||
// componentsCount: t.components?.length || 0
|
|
||||||
// }))
|
|
||||||
// });
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
const err = error as Error;
|
const err = error as Error;
|
||||||
// console.error('❌ 데이터 로드 실패:', err);
|
console.error('❌ 데이터 로드 실패:', err);
|
||||||
toast({
|
toast({
|
||||||
title: "데이터 로드 실패",
|
title: "데이터 로드 실패",
|
||||||
description: err.message,
|
description: err.message,
|
||||||
|
|
@ -225,62 +109,13 @@ ${data.originalBody}`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 임시 저장 함수
|
|
||||||
const handleAutoSave = async () => {
|
|
||||||
if (!selectedAccountId || (!subject && !customHtml && to.length === 0)) {
|
|
||||||
return; // 저장할 내용이 없으면 스킵
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setAutoSaving(true);
|
|
||||||
|
|
||||||
const draftData = {
|
|
||||||
accountId: selectedAccountId,
|
|
||||||
accountName: accounts.find(a => a.id === selectedAccountId)?.name || "",
|
|
||||||
accountEmail: accounts.find(a => a.id === selectedAccountId)?.email || "",
|
|
||||||
to,
|
|
||||||
cc,
|
|
||||||
bcc,
|
|
||||||
subject,
|
|
||||||
htmlContent: customHtml,
|
|
||||||
templateId: selectedTemplateId || undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (draftId) {
|
|
||||||
// 기존 임시 저장 업데이트
|
|
||||||
await updateDraft(draftId, draftData);
|
|
||||||
} else {
|
|
||||||
// 새로운 임시 저장
|
|
||||||
const savedDraft = await saveDraft(draftData);
|
|
||||||
if (savedDraft && savedDraft.id) {
|
|
||||||
setDraftId(savedDraft.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setLastSaved(new Date());
|
|
||||||
} catch (error) {
|
|
||||||
// console.error('임시 저장 실패:', error);
|
|
||||||
} finally {
|
|
||||||
setAutoSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 30초마다 자동 저장
|
|
||||||
useEffect(() => {
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
handleAutoSave();
|
|
||||||
}, 30000); // 30초
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [selectedAccountId, to, cc, bcc, subject, customHtml, selectedTemplateId, draftId]);
|
|
||||||
|
|
||||||
// 템플릿 선택 시 (원본 다시 로드)
|
// 템플릿 선택 시 (원본 다시 로드)
|
||||||
const handleTemplateChange = async (templateId: string) => {
|
const handleTemplateChange = async (templateId: string) => {
|
||||||
console.log('🔄 템플릿 선택됨:', templateId);
|
console.log('🔄 템플릿 선택됨:', templateId);
|
||||||
|
|
||||||
// "__custom__"는 직접 작성을 의미
|
// "__custom__"는 직접 작성을 의미
|
||||||
if (templateId === "__custom__") {
|
if (templateId === "__custom__") {
|
||||||
// console.log('✏️ 직접 작성 모드');
|
console.log('✏️ 직접 작성 모드');
|
||||||
setSelectedTemplateId("");
|
setSelectedTemplateId("");
|
||||||
setTemplateVariables([]);
|
setTemplateVariables([]);
|
||||||
setVariables({});
|
setVariables({});
|
||||||
|
|
@ -289,20 +124,20 @@ ${data.originalBody}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 🎯 원본 템플릿을 API에서 다시 로드 (수정사항 초기화)
|
// 🎯 원본 템플릿을 API에서 다시 로드 (수정사항 초기화)
|
||||||
// console.log('🔃 원본 템플릿 API에서 재로드 중...');
|
console.log('🔃 원본 템플릿 API에서 재로드 중...');
|
||||||
const freshTemplates = await getMailTemplates();
|
const freshTemplates = await getMailTemplates();
|
||||||
const template = freshTemplates.find((t) => t.id === templateId);
|
const template = freshTemplates.find((t) => t.id === templateId);
|
||||||
|
|
||||||
// console.log('📋 찾은 템플릿:', {
|
console.log('📋 찾은 템플릿:', {
|
||||||
// found: !!template,
|
found: !!template,
|
||||||
// templateId,
|
templateId,
|
||||||
// availableTemplates: freshTemplates.length,
|
availableTemplates: freshTemplates.length,
|
||||||
// template: template ? {
|
template: template ? {
|
||||||
// id: template.id,
|
id: template.id,
|
||||||
// name: template.name,
|
name: template.name,
|
||||||
// componentsCount: template.components?.length || 0
|
componentsCount: template.components?.length || 0
|
||||||
// } : null
|
} : null
|
||||||
// });
|
});
|
||||||
|
|
||||||
if (template) {
|
if (template) {
|
||||||
// 🎯 templates state도 원본으로 업데이트 (깨끗한 상태)
|
// 🎯 templates state도 원본으로 업데이트 (깨끗한 상태)
|
||||||
|
|
@ -318,18 +153,18 @@ ${data.originalBody}`;
|
||||||
});
|
});
|
||||||
setVariables(initialVars);
|
setVariables(initialVars);
|
||||||
|
|
||||||
// console.log('✅ 원본 템플릿 적용 완료 (깨끗한 상태):', {
|
console.log('✅ 원본 템플릿 적용 완료 (깨끗한 상태):', {
|
||||||
// subject: template.subject,
|
subject: template.subject,
|
||||||
// variables: vars
|
variables: vars
|
||||||
// });
|
});
|
||||||
} else {
|
} else {
|
||||||
setSelectedTemplateId("");
|
setSelectedTemplateId("");
|
||||||
setTemplateVariables([]);
|
setTemplateVariables([]);
|
||||||
setVariables({});
|
setVariables({});
|
||||||
// console.warn('⚠️ 템플릿을 찾을 수 없음');
|
console.warn('⚠️ 템플릿을 찾을 수 없음');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error('❌ 템플릿 재로드 실패:', error);
|
console.error('❌ 템플릿 재로드 실패:', error);
|
||||||
toast({
|
toast({
|
||||||
title: "템플릿 로드 실패",
|
title: "템플릿 로드 실패",
|
||||||
description: "템플릿을 불러오는 중 오류가 발생했습니다.",
|
description: "템플릿을 불러오는 중 오류가 발생했습니다.",
|
||||||
|
|
@ -393,7 +228,7 @@ ${data.originalBody}`;
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div style="font-family: Arial, sans-serif; padding: 20px; color: #333;">
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;">
|
||||||
${html}
|
${html}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -440,12 +275,8 @@ ${data.originalBody}`;
|
||||||
try {
|
try {
|
||||||
setSending(true);
|
setSending(true);
|
||||||
|
|
||||||
// HTML 변환
|
// 텍스트를 HTML로 자동 변환
|
||||||
let htmlContent = undefined;
|
const htmlContent = customHtml ? convertTextToHtml(customHtml) : undefined;
|
||||||
if (customHtml.trim()) {
|
|
||||||
// 일반 텍스트를 HTML로 변환
|
|
||||||
htmlContent = convertTextToHtml(customHtml);
|
|
||||||
}
|
|
||||||
|
|
||||||
// FormData 생성 (파일 첨부 지원)
|
// FormData 생성 (파일 첨부 지원)
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
|
@ -457,7 +288,7 @@ ${data.originalBody}`;
|
||||||
const currentTemplate = templates.find((t) => t.id === selectedTemplateId);
|
const currentTemplate = templates.find((t) => t.id === selectedTemplateId);
|
||||||
if (currentTemplate) {
|
if (currentTemplate) {
|
||||||
formData.append("modifiedTemplateComponents", JSON.stringify(currentTemplate.components));
|
formData.append("modifiedTemplateComponents", JSON.stringify(currentTemplate.components));
|
||||||
// console.log('📤 수정된 템플릿 컴포넌트 전송:', currentTemplate.components.length);
|
console.log('📤 수정된 템플릿 컴포넌트 전송:', currentTemplate.components.length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
formData.append("to", JSON.stringify(to));
|
formData.append("to", JSON.stringify(to));
|
||||||
|
|
@ -485,11 +316,11 @@ ${data.originalBody}`;
|
||||||
const originalFileNames = attachments.map(file => {
|
const originalFileNames = attachments.map(file => {
|
||||||
// 파일명 정규화 (NFD → NFC)
|
// 파일명 정규화 (NFD → NFC)
|
||||||
const normalizedName = file.name.normalize('NFC');
|
const normalizedName = file.name.normalize('NFC');
|
||||||
// console.log('📎 파일명 정규화:', file.name, '->', normalizedName);
|
console.log('📎 파일명 정규화:', file.name, '->', normalizedName);
|
||||||
return normalizedName;
|
return normalizedName;
|
||||||
});
|
});
|
||||||
formData.append("fileNames", JSON.stringify(originalFileNames));
|
formData.append("fileNames", JSON.stringify(originalFileNames));
|
||||||
// console.log('📎 전송할 정규화된 파일명들:', originalFileNames);
|
console.log('📎 전송할 정규화된 파일명들:', originalFileNames);
|
||||||
}
|
}
|
||||||
|
|
||||||
// API 호출 (FormData 전송)
|
// API 호출 (FormData 전송)
|
||||||
|
|
@ -523,9 +354,6 @@ ${data.originalBody}`;
|
||||||
className: "border-green-500 bg-green-50",
|
className: "border-green-500 bg-green-50",
|
||||||
});
|
});
|
||||||
|
|
||||||
// 알림 갱신 이벤트 발생
|
|
||||||
window.dispatchEvent(new CustomEvent('mail-sent'));
|
|
||||||
|
|
||||||
// 폼 초기화
|
// 폼 초기화
|
||||||
setTo([]);
|
setTo([]);
|
||||||
setCc([]);
|
setCc([]);
|
||||||
|
|
@ -555,58 +383,6 @@ ${data.originalBody}`;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 임시 저장
|
|
||||||
const handleSaveDraft = async () => {
|
|
||||||
try {
|
|
||||||
setAutoSaving(true);
|
|
||||||
|
|
||||||
const account = accounts.find(a => a.id === selectedAccountId);
|
|
||||||
const draftData = {
|
|
||||||
accountId: selectedAccountId,
|
|
||||||
accountName: account?.name || "",
|
|
||||||
accountEmail: account?.email || "",
|
|
||||||
to,
|
|
||||||
cc,
|
|
||||||
bcc,
|
|
||||||
subject,
|
|
||||||
htmlContent: customHtml,
|
|
||||||
templateId: selectedTemplateId || undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
// console.log('💾 임시 저장 데이터:', draftData);
|
|
||||||
|
|
||||||
if (draftId) {
|
|
||||||
// 기존 임시 저장 업데이트
|
|
||||||
await updateDraft(draftId, draftData);
|
|
||||||
// console.log('✏️ 임시 저장 업데이트 완료:', draftId);
|
|
||||||
} else {
|
|
||||||
// 새로운 임시 저장
|
|
||||||
const savedDraft = await saveDraft(draftData);
|
|
||||||
// console.log('💾 임시 저장 완료:', savedDraft);
|
|
||||||
if (savedDraft && savedDraft.id) {
|
|
||||||
setDraftId(savedDraft.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setLastSaved(new Date());
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: "임시 저장 완료",
|
|
||||||
description: "작성 중인 메일이 저장되었습니다.",
|
|
||||||
});
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const err = error as Error;
|
|
||||||
// console.error('❌ 임시 저장 실패:', err);
|
|
||||||
toast({
|
|
||||||
title: "임시 저장 실패",
|
|
||||||
description: err.message || "임시 저장 중 오류가 발생했습니다.",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setAutoSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 파일 첨부 관련 함수
|
// 파일 첨부 관련 함수
|
||||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const files = Array.from(e.target.files || []);
|
const files = Array.from(e.target.files || []);
|
||||||
|
|
@ -701,15 +477,15 @@ ${data.originalBody}`;
|
||||||
if (selectedTemplateId) {
|
if (selectedTemplateId) {
|
||||||
const template = templates.find((t) => t.id === selectedTemplateId);
|
const template = templates.find((t) => t.id === selectedTemplateId);
|
||||||
if (template) {
|
if (template) {
|
||||||
// console.log('🎨 템플릿 미리보기:', {
|
console.log('🎨 템플릿 미리보기:', {
|
||||||
// templateId: selectedTemplateId,
|
templateId: selectedTemplateId,
|
||||||
// templateName: template.name,
|
templateName: template.name,
|
||||||
// componentsCount: template.components?.length || 0,
|
componentsCount: template.components?.length || 0,
|
||||||
// components: template.components,
|
components: template.components,
|
||||||
// variables
|
variables
|
||||||
// });
|
});
|
||||||
const html = renderTemplateToHtml(template, variables);
|
const html = renderTemplateToHtml(template, variables);
|
||||||
// console.log('📄 생성된 HTML:', html.substring(0, 200) + '...');
|
console.log('📄 생성된 HTML:', html.substring(0, 200) + '...');
|
||||||
|
|
||||||
// 추가 메시지가 있으면 병합
|
// 추가 메시지가 있으면 병합
|
||||||
if (customHtml && customHtml.trim()) {
|
if (customHtml && customHtml.trim()) {
|
||||||
|
|
@ -755,72 +531,9 @@ ${data.originalBody}`;
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* 제목 */}
|
{/* 제목 */}
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-foreground">
|
<h1 className="text-3xl font-bold text-foreground">메일 발송</h1>
|
||||||
{subject.startsWith("Re: ") ? "답장 작성" : subject.startsWith("Fwd: ") ? "메일 전달" : "메일 발송"}
|
<p className="mt-2 text-muted-foreground">템플릿을 선택하거나 직접 작성하여 메일을 발송하세요</p>
|
||||||
</h1>
|
|
||||||
<p className="mt-2 text-muted-foreground">
|
|
||||||
{subject.startsWith("Re: ")
|
|
||||||
? "받은 메일에 답장을 작성합니다"
|
|
||||||
: subject.startsWith("Fwd: ")
|
|
||||||
? "메일을 다른 사람에게 전달합니다"
|
|
||||||
: "템플릿을 선택하거나 직접 작성하여 메일을 발송하세요"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{/* 임시 저장 표시 */}
|
|
||||||
{lastSaved && (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
{autoSaving ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
<span>저장 중...</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
|
||||||
<span>
|
|
||||||
{new Date(lastSaved).toLocaleTimeString('ko-KR', {
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit'
|
|
||||||
})} 임시 저장됨
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 임시 저장 버튼 */}
|
|
||||||
<Button
|
|
||||||
onClick={handleSaveDraft}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={autoSaving}
|
|
||||||
>
|
|
||||||
{autoSaving ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
|
||||||
저장 중...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<FileText className="w-4 h-4 mr-1" />
|
|
||||||
임시 저장
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* 임시 저장 목록 버튼 */}
|
|
||||||
<Link href="/admin/mail/drafts">
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
<Mail className="w-4 h-4 mr-1" />
|
|
||||||
임시 저장 목록
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1244,41 +957,30 @@ ${data.originalBody}`;
|
||||||
return null;
|
return null;
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* 메일 내용 입력 */}
|
{/* 메일 내용 입력 - 항상 표시 */}
|
||||||
{!showPreview && !selectedTemplateId && (
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="customHtml">내용 *</Label>
|
<Label htmlFor="customHtml">
|
||||||
|
{selectedTemplateId ? "추가 메시지 (선택)" : "내용 *"}
|
||||||
|
</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="customHtml"
|
id="customHtml"
|
||||||
value={customHtml}
|
value={customHtml}
|
||||||
onChange={(e) => setCustomHtml(e.target.value)}
|
onChange={(e) => setCustomHtml(e.target.value)}
|
||||||
placeholder="메일 내용을 입력하세요 줄바꿈은 자동으로 처리됩니다."
|
placeholder={
|
||||||
rows={12}
|
selectedTemplateId
|
||||||
className="resize-none"
|
? "템플릿 하단에 추가될 내용을 입력하세요 (선택사항)"
|
||||||
|
: "메일 내용을 입력하세요\n\n줄바꿈은 자동으로 처리됩니다."
|
||||||
|
}
|
||||||
|
rows={10}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
💡 일반 텍스트로 작성하면 자동으로 메일 형식으로 변환됩니다
|
{selectedTemplateId ? (
|
||||||
|
<>💡 입력한 내용은 템플릿 하단에 추가됩니다</>
|
||||||
|
) : (
|
||||||
|
<>💡 일반 텍스트로 작성하면 자동으로 메일 형식으로 변환됩니다</>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 템플릿 선택 시 추가 메시지 */}
|
|
||||||
{!showPreview && selectedTemplateId && (
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="customHtml">추가 메시지 (선택)</Label>
|
|
||||||
<Textarea
|
|
||||||
id="customHtml"
|
|
||||||
value={customHtml}
|
|
||||||
onChange={(e) => setCustomHtml(e.target.value)}
|
|
||||||
placeholder="템플릿 하단에 추가될 내용을 입력하세요 (선택사항)"
|
|
||||||
rows={6}
|
|
||||||
className="resize-none"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
💡 입력한 내용은 템플릿 하단에 추가됩니다
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
@ -1398,15 +1100,6 @@ ${data.originalBody}`;
|
||||||
<Eye className="w-5 h-5" />
|
<Eye className="w-5 h-5" />
|
||||||
미리보기
|
미리보기
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsEditingHtml(!isEditingHtml)}
|
|
||||||
>
|
|
||||||
{isEditingHtml ? <Eye className="w-4 h-4 mr-1" /> : <Settings className="w-4 h-4 mr-1" />}
|
|
||||||
{isEditingHtml ? "미리보기" : "편집"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -1414,7 +1107,6 @@ ${data.originalBody}`;
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|
@ -1451,17 +1143,7 @@ ${data.originalBody}`;
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{isEditingHtml ? (
|
|
||||||
<Textarea
|
|
||||||
value={customHtml}
|
|
||||||
onChange={(e) => setCustomHtml(e.target.value)}
|
|
||||||
rows={20}
|
|
||||||
className="font-mono text-xs"
|
|
||||||
placeholder="HTML 코드를 직접 편집할 수 있습니다"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div dangerouslySetInnerHTML={{ __html: getPreviewHtml() }} />
|
<div dangerouslySetInnerHTML={{ __html: getPreviewHtml() }} />
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -42,7 +42,7 @@ export default function MailTemplatesPage() {
|
||||||
const data = await getMailTemplates();
|
const data = await getMailTemplates();
|
||||||
setTemplates(data);
|
setTemplates(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error('템플릿 로드 실패:', error);
|
console.error('템플릿 로드 실패:', error);
|
||||||
alert('템플릿 목록을 불러오는데 실패했습니다.');
|
alert('템플릿 목록을 불러오는데 실패했습니다.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
@ -110,7 +110,7 @@ export default function MailTemplatesPage() {
|
||||||
await loadTemplates();
|
await loadTemplates();
|
||||||
alert('템플릿이 삭제되었습니다.');
|
alert('템플릿이 삭제되었습니다.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error('템플릿 삭제 실패:', error);
|
console.error('템플릿 삭제 실패:', error);
|
||||||
alert('템플릿 삭제에 실패했습니다.');
|
alert('템플릿 삭제에 실패했습니다.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -126,7 +126,7 @@ export default function MailTemplatesPage() {
|
||||||
await loadTemplates();
|
await loadTemplates();
|
||||||
alert('템플릿이 복사되었습니다.');
|
alert('템플릿이 복사되었습니다.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error('템플릿 복사 실패:', error);
|
console.error('템플릿 복사 실패:', error);
|
||||||
alert('템플릿 복사에 실패했습니다.');
|
alert('템플릿 복사에 실패했습니다.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,192 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { getSentMailList, restoreMail, permanentlyDeleteMail, type SentMailHistory } from "@/lib/api/mail";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { RotateCcw, Trash2, Loader2, Mail, AlertCircle } from "lucide-react";
|
|
||||||
import { format } from "date-fns";
|
|
||||||
import { ko } from "date-fns/locale";
|
|
||||||
|
|
||||||
export default function TrashPage() {
|
|
||||||
const [trashedMails, setTrashedMails] = useState<SentMailHistory[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [restoring, setRestoring] = useState<string | null>(null);
|
|
||||||
const [deleting, setDeleting] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadTrashedMails();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadTrashedMails = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const response = await getSentMailList({
|
|
||||||
onlyDeleted: true,
|
|
||||||
sortBy: "sentAt",
|
|
||||||
sortOrder: "desc",
|
|
||||||
});
|
|
||||||
setTrashedMails(response.items);
|
|
||||||
} catch (error) {
|
|
||||||
// console.error("휴지통 메일 로드 실패:", error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRestore = async (id: string) => {
|
|
||||||
try {
|
|
||||||
setRestoring(id);
|
|
||||||
await restoreMail(id);
|
|
||||||
setTrashedMails(trashedMails.filter((m) => m.id !== id));
|
|
||||||
} catch (error) {
|
|
||||||
// console.error("메일 복구 실패:", error);
|
|
||||||
alert("복구에 실패했습니다.");
|
|
||||||
} finally {
|
|
||||||
setRestoring(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePermanentDelete = async (id: string) => {
|
|
||||||
if (!confirm("이 메일을 영구적으로 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.")) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setDeleting(id);
|
|
||||||
await permanentlyDeleteMail(id);
|
|
||||||
setTrashedMails(trashedMails.filter((m) => m.id !== id));
|
|
||||||
} catch (error) {
|
|
||||||
// console.error("메일 영구 삭제 실패:", error);
|
|
||||||
alert("삭제에 실패했습니다.");
|
|
||||||
} finally {
|
|
||||||
setDeleting(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEmptyTrash = async () => {
|
|
||||||
if (!confirm(`휴지통의 모든 메일(${trashedMails.length}개)을 영구적으로 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.`)) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
await Promise.all(trashedMails.map((mail) => permanentlyDeleteMail(mail.id)));
|
|
||||||
setTrashedMails([]);
|
|
||||||
alert("휴지통을 비웠습니다.");
|
|
||||||
} catch (error) {
|
|
||||||
// console.error("휴지통 비우기 실패:", error);
|
|
||||||
alert("일부 메일 삭제에 실패했습니다.");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-3 space-y-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-3xl font-bold text-foreground">휴지통</h1>
|
|
||||||
<p className="mt-2 text-muted-foreground">삭제된 메일은 30일 후 자동으로 영구 삭제됩니다</p>
|
|
||||||
</div>
|
|
||||||
{trashedMails.length > 0 && (
|
|
||||||
<Button variant="destructive" onClick={handleEmptyTrash} className="h-10">
|
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
|
||||||
휴지통 비우기
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{trashedMails.length === 0 ? (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
|
||||||
<Mail className="w-12 h-12 text-muted-foreground mb-4" />
|
|
||||||
<p className="text-muted-foreground">휴지통이 비어 있습니다</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{trashedMails.map((mail) => {
|
|
||||||
const deletedDate = mail.deletedAt ? new Date(mail.deletedAt) : null;
|
|
||||||
const daysLeft = deletedDate
|
|
||||||
? Math.max(0, 30 - Math.floor((Date.now() - deletedDate.getTime()) / (1000 * 60 * 60 * 24)))
|
|
||||||
: 30;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card key={mail.id} className="hover:shadow-md transition-shadow">
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<CardTitle className="text-lg truncate">
|
|
||||||
{mail.subject || "(제목 없음)"}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription className="mt-1">
|
|
||||||
받는 사람: {mail.to.join(", ") || "(없음)"}
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 ml-4">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleRestore(mail.id)}
|
|
||||||
disabled={restoring === mail.id}
|
|
||||||
className="h-8"
|
|
||||||
>
|
|
||||||
{restoring === mail.id ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RotateCcw className="w-4 h-4 mr-1" />
|
|
||||||
복구
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handlePermanentDelete(mail.id)}
|
|
||||||
disabled={deleting === mail.id}
|
|
||||||
className="h-8"
|
|
||||||
>
|
|
||||||
{deleting === mail.id ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Trash2 className="w-4 h-4 mr-1" />
|
|
||||||
영구 삭제
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-0">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
계정: {mail.accountName || mail.accountEmail}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{format(new Date(mail.sentAt), "yyyy-MM-dd HH:mm", { locale: ko })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{daysLeft <= 7 && (
|
|
||||||
<div className="flex items-center gap-2 mt-2 text-xs text-amber-600">
|
|
||||||
<AlertCircle className="w-3 h-3" />
|
|
||||||
<span>{daysLeft}일 후 자동 삭제됩니다</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import React, { useState, useCallback, useRef, useEffect } from "react";
|
import React, { useState, useCallback, useRef, useEffect } from "react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { DashboardElement, QueryResult, Position } from "./types";
|
import { DashboardElement, QueryResult } from "./types";
|
||||||
import { ChartRenderer } from "./charts/ChartRenderer";
|
import { ChartRenderer } from "./charts/ChartRenderer";
|
||||||
import { GRID_CONFIG } from "./gridUtils";
|
import { GRID_CONFIG } from "./gridUtils";
|
||||||
|
|
||||||
|
|
@ -105,7 +105,7 @@ import { CalendarWidget } from "./widgets/CalendarWidget";
|
||||||
// 기사 관리 위젯 임포트
|
// 기사 관리 위젯 임포트
|
||||||
import { DriverManagementWidget } from "./widgets/DriverManagementWidget";
|
import { DriverManagementWidget } from "./widgets/DriverManagementWidget";
|
||||||
import { ListWidget } from "./widgets/ListWidget";
|
import { ListWidget } from "./widgets/ListWidget";
|
||||||
import { X } from "lucide-react";
|
import { MoreHorizontal, X } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
// 야드 관리 3D 위젯
|
// 야드 관리 3D 위젯
|
||||||
|
|
@ -137,11 +137,12 @@ interface CanvasElementProps {
|
||||||
canvasWidth?: number;
|
canvasWidth?: number;
|
||||||
onUpdate: (id: string, updates: Partial<DashboardElement>) => void;
|
onUpdate: (id: string, updates: Partial<DashboardElement>) => void;
|
||||||
onUpdateMultiple?: (updates: { id: string; updates: Partial<DashboardElement> }[]) => void; // 🔥 다중 업데이트
|
onUpdateMultiple?: (updates: { id: string; updates: Partial<DashboardElement> }[]) => void; // 🔥 다중 업데이트
|
||||||
onMultiDragStart?: (draggedId: string, otherOffsets: Record<string, { x: number; y: number }>) => void;
|
onMultiDragStart?: (draggedId: string, otherOffsets: Record<string, { x: number; y: number }>) => void; // 🔥 다중 드래그 시작
|
||||||
onMultiDragMove?: (draggedElement: DashboardElement, tempPosition: { x: number; y: number }) => void;
|
onMultiDragMove?: (draggedElement: DashboardElement, tempPosition: { x: number; y: number }) => void; // 🔥 다중 드래그 중
|
||||||
onMultiDragEnd?: () => void;
|
onMultiDragEnd?: () => void; // 🔥 다중 드래그 종료
|
||||||
onRemove: (id: string) => void;
|
onRemove: (id: string) => void;
|
||||||
onSelect: (id: string | null) => void;
|
onSelect: (id: string | null) => void;
|
||||||
|
onConfigure?: (element: DashboardElement) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -166,6 +167,7 @@ export function CanvasElement({
|
||||||
onMultiDragEnd,
|
onMultiDragEnd,
|
||||||
onRemove,
|
onRemove,
|
||||||
onSelect,
|
onSelect,
|
||||||
|
onConfigure,
|
||||||
}: CanvasElementProps) {
|
}: CanvasElementProps) {
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
|
|
@ -247,16 +249,7 @@ export function CanvasElement({
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
},
|
},
|
||||||
[
|
[element.id, element.position.x, element.position.y, onSelect, isSelected, selectedElements, allElements, onMultiDragStart],
|
||||||
element.id,
|
|
||||||
element.position.x,
|
|
||||||
element.position.y,
|
|
||||||
onSelect,
|
|
||||||
isSelected,
|
|
||||||
selectedElements,
|
|
||||||
allElements,
|
|
||||||
onMultiDragStart,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 리사이즈 핸들 마우스다운
|
// 리사이즈 핸들 마우스다운
|
||||||
|
|
@ -287,8 +280,7 @@ export function CanvasElement({
|
||||||
(e: MouseEvent) => {
|
(e: MouseEvent) => {
|
||||||
if (isDragging) {
|
if (isDragging) {
|
||||||
// 🔥 자동 스크롤: 다중 선택 시 첫 번째 위젯에서만 처리
|
// 🔥 자동 스크롤: 다중 선택 시 첫 번째 위젯에서만 처리
|
||||||
const isFirstSelectedElement =
|
const isFirstSelectedElement = !selectedElements || selectedElements.length === 0 || selectedElements[0] === element.id;
|
||||||
!selectedElements || selectedElements.length === 0 || selectedElements[0] === element.id;
|
|
||||||
|
|
||||||
if (isFirstSelectedElement) {
|
if (isFirstSelectedElement) {
|
||||||
const scrollThreshold = 100;
|
const scrollThreshold = 100;
|
||||||
|
|
@ -423,15 +415,6 @@ export function CanvasElement({
|
||||||
const relativeX = targetElement.position.x - dragStart.elementX;
|
const relativeX = targetElement.position.x - dragStart.elementX;
|
||||||
const relativeY = targetElement.position.y - dragStart.elementY;
|
const relativeY = targetElement.position.y - dragStart.elementY;
|
||||||
|
|
||||||
const newPosition: Position = {
|
|
||||||
x: Math.max(0, Math.min(canvasWidth - targetElement.size.width, finalX + relativeX)),
|
|
||||||
y: Math.max(0, finalY + relativeY),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (targetElement.position.z !== undefined) {
|
|
||||||
newPosition.z = targetElement.position.z;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
updates: {
|
updates: {
|
||||||
|
|
@ -442,7 +425,7 @@ export function CanvasElement({
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((update): update is { id: string; updates: { position: Position } } => update !== null);
|
.filter((update): update is { id: string; updates: Partial<DashboardElement> } => update !== null);
|
||||||
|
|
||||||
if (updates.length > 0) {
|
if (updates.length > 0) {
|
||||||
// console.log("🔥 다중 선택 요소 함께 이동:", updates);
|
// console.log("🔥 다중 선택 요소 함께 이동:", updates);
|
||||||
|
|
@ -688,15 +671,10 @@ export function CanvasElement({
|
||||||
|
|
||||||
// 드래그/리사이즈 중일 때는 임시 위치/크기 사용, 아니면 실제 값 사용
|
// 드래그/리사이즈 중일 때는 임시 위치/크기 사용, 아니면 실제 값 사용
|
||||||
// 🔥 다중 드래그 중이면 multiDragOffset 적용 (단, 드래그 중인 위젯은 tempPosition 우선)
|
// 🔥 다중 드래그 중이면 multiDragOffset 적용 (단, 드래그 중인 위젯은 tempPosition 우선)
|
||||||
const displayPosition: Position =
|
const displayPosition = tempPosition || (multiDragOffset && !isDragging ? {
|
||||||
tempPosition ||
|
|
||||||
(multiDragOffset && !isDragging
|
|
||||||
? {
|
|
||||||
x: element.position.x + multiDragOffset.x,
|
x: element.position.x + multiDragOffset.x,
|
||||||
y: element.position.y + multiDragOffset.y,
|
y: element.position.y + multiDragOffset.y,
|
||||||
...(element.position.z !== undefined && { z: element.position.z }),
|
} : element.position);
|
||||||
}
|
|
||||||
: element.position);
|
|
||||||
const displaySize = tempSize || element.size;
|
const displaySize = tempSize || element.size;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -718,6 +696,18 @@ export function CanvasElement({
|
||||||
<div className="flex cursor-move items-center justify-between p-3">
|
<div className="flex cursor-move items-center justify-between p-3">
|
||||||
<span className="text-sm font-bold text-gray-800">{element.customTitle || element.title}</span>
|
<span className="text-sm font-bold text-gray-800">{element.customTitle || element.title}</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
|
{/* 설정 버튼 (기사관리 위젯만 자체 설정 UI 사용) */}
|
||||||
|
{onConfigure && !(element.type === "widget" && element.subtype === "driver-management") && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 text-gray-400"
|
||||||
|
onClick={() => onConfigure(element)}
|
||||||
|
title="설정"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{/* 삭제 버튼 */}
|
{/* 삭제 버튼 */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { ChartConfig, QueryResult } from "./types";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|
@ -96,17 +97,32 @@ export function ChartConfigPanel({
|
||||||
// (SELECT에 없어도 WHERE 절에 사용 가능)
|
// (SELECT에 없어도 WHERE 절에 사용 가능)
|
||||||
setDateColumns(schema.dateColumns);
|
setDateColumns(schema.dateColumns);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((error) => {
|
||||||
|
// console.error("❌ 테이블 스키마 조회 실패:", error);
|
||||||
// 실패 시 빈 배열 (날짜 필터 비활성화)
|
// 실패 시 빈 배열 (날짜 필터 비활성화)
|
||||||
setDateColumns([]);
|
setDateColumns([]);
|
||||||
});
|
});
|
||||||
}, [query, queryResult, dataSourceType]);
|
}, [query, queryResult, dataSourceType]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-6">
|
||||||
{/* 데이터 필드 매핑 */}
|
{/* 데이터 필드 매핑 */}
|
||||||
{queryResult && (
|
{queryResult && (
|
||||||
<>
|
<>
|
||||||
|
{/* API 응답 미리보기 */}
|
||||||
|
{queryResult.rows && queryResult.rows.length > 0 && (
|
||||||
|
<Card className="border-blue-200 bg-blue-50 p-4">
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<AlertCircle className="h-4 w-4 text-blue-600" />
|
||||||
|
<h4 className="font-semibold text-blue-900">API 응답 데이터 미리보기</h4>
|
||||||
|
</div>
|
||||||
|
<div className="rounded bg-white p-3 text-xs">
|
||||||
|
<div className="mb-2 text-gray-600">총 {queryResult.totalRows}개 데이터 중 첫 번째 행:</div>
|
||||||
|
<pre className="overflow-x-auto text-gray-800">{JSON.stringify(sampleData, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 복잡한 타입 경고 */}
|
{/* 복잡한 타입 경고 */}
|
||||||
{complexColumns.length > 0 && (
|
{complexColumns.length > 0 && (
|
||||||
<Alert variant="destructive">
|
<Alert variant="destructive">
|
||||||
|
|
@ -134,27 +150,26 @@ export function ChartConfigPanel({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 차트 제목 */}
|
{/* 차트 제목 */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs font-medium text-gray-700">차트 제목</Label>
|
<Label>차트 제목</Label>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
value={currentConfig.title || ""}
|
value={currentConfig.title || ""}
|
||||||
onChange={(e) => updateConfig({ title: e.target.value })}
|
onChange={(e) => updateConfig({ title: e.target.value })}
|
||||||
placeholder="차트 제목을 입력하세요"
|
placeholder="차트 제목을 입력하세요"
|
||||||
className="h-8 text-xs"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* X축 설정 */}
|
{/* X축 설정 */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs font-medium text-gray-700">
|
<Label>
|
||||||
X축 (카테고리)
|
X축 (카테고리)
|
||||||
<span className="ml-1 text-red-500">*</span>
|
<span className="ml-1 text-red-500">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Select value={currentConfig.xAxis || undefined} onValueChange={(value) => updateConfig({ xAxis: value })}>
|
<Select value={currentConfig.xAxis || undefined} onValueChange={(value) => updateConfig({ xAxis: value })}>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="선택하세요" />
|
<SelectValue placeholder="선택하세요" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="z-[99999]">
|
<SelectContent className="z-[99999]">
|
||||||
|
|
@ -168,41 +183,41 @@ export function ChartConfigPanel({
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SelectItem key={col} value={col} className="text-xs">
|
<SelectItem key={col} value={col}>
|
||||||
{col}
|
{col}
|
||||||
{previewText && <span className="ml-1.5 text-[10px] text-gray-500">(예: {previewText})</span>}
|
{previewText && <span className="ml-2 text-xs text-gray-500">(예: {previewText})</span>}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
{simpleColumns.length === 0 && (
|
{simpleColumns.length === 0 && (
|
||||||
<p className="text-[11px] text-red-500">사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.</p>
|
<p className="text-xs text-red-500">사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Y축 설정 (다중 선택 가능) */}
|
{/* Y축 설정 (다중 선택 가능) */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs font-medium text-gray-700">
|
<Label>
|
||||||
Y축 (값) - 여러 개 선택 가능
|
Y축 (값) - 여러 개 선택 가능
|
||||||
{!isPieChart && !isApiSource && <span className="ml-1 text-red-500">*</span>}
|
{!isPieChart && !isApiSource && <span className="ml-1 text-red-500">*</span>}
|
||||||
{(isPieChart || isApiSource) && (
|
{(isPieChart || isApiSource) && (
|
||||||
<span className="ml-1.5 text-[11px] text-gray-500">(선택사항 - 그룹핑+집계 사용 가능)</span>
|
<span className="ml-2 text-xs text-gray-500">(선택사항 - 그룹핑+집계 사용 가능)</span>
|
||||||
)}
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="max-h-48 overflow-y-auto rounded border border-gray-200 bg-gray-50 p-2">
|
<Card className="max-h-60 overflow-y-auto p-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
{/* 숫자 타입 우선 표시 */}
|
{/* 숫자 타입 우선 표시 */}
|
||||||
{numericColumns.length > 0 && (
|
{numericColumns.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="mb-1.5 text-[11px] font-medium text-green-700">숫자 타입 (권장)</div>
|
<div className="mb-2 text-xs font-medium text-green-700">숫자 타입 (권장)</div>
|
||||||
{numericColumns.map((col) => {
|
{numericColumns.map((col) => {
|
||||||
const isSelected = Array.isArray(currentConfig.yAxis)
|
const isSelected = Array.isArray(currentConfig.yAxis)
|
||||||
? currentConfig.yAxis.includes(col)
|
? currentConfig.yAxis.includes(col)
|
||||||
: currentConfig.yAxis === col;
|
: currentConfig.yAxis === col;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={col} className="flex items-center gap-1.5 rounded border-green-500 bg-green-50 p-1.5">
|
<div key={col} className="flex items-center gap-2 rounded border-green-500 bg-green-50 p-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
|
|
@ -226,10 +241,10 @@ export function ChartConfigPanel({
|
||||||
updateConfig({ yAxis: newYAxis });
|
updateConfig({ yAxis: newYAxis });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Label className="flex-1 cursor-pointer text-xs font-normal">
|
<Label className="flex-1 cursor-pointer text-sm font-normal">
|
||||||
<span className="font-medium">{col}</span>
|
<span className="font-medium">{col}</span>
|
||||||
{sampleData[col] !== undefined && (
|
{sampleData[col] !== undefined && (
|
||||||
<span className="ml-1.5 text-[10px] text-gray-600">(예: {sampleData[col]})</span>
|
<span className="ml-2 text-xs text-gray-600">(예: {sampleData[col]})</span>
|
||||||
)}
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -241,8 +256,8 @@ export function ChartConfigPanel({
|
||||||
{/* 기타 간단한 타입 */}
|
{/* 기타 간단한 타입 */}
|
||||||
{simpleColumns.filter((col) => !numericColumns.includes(col)).length > 0 && (
|
{simpleColumns.filter((col) => !numericColumns.includes(col)).length > 0 && (
|
||||||
<>
|
<>
|
||||||
{numericColumns.length > 0 && <div className="my-1.5 border-t"></div>}
|
{numericColumns.length > 0 && <div className="my-2 border-t"></div>}
|
||||||
<div className="mb-1.5 text-[11px] font-medium text-gray-600">기타 타입</div>
|
<div className="mb-2 text-xs font-medium text-gray-600">기타 타입</div>
|
||||||
{simpleColumns
|
{simpleColumns
|
||||||
.filter((col) => !numericColumns.includes(col))
|
.filter((col) => !numericColumns.includes(col))
|
||||||
.map((col) => {
|
.map((col) => {
|
||||||
|
|
@ -251,7 +266,7 @@ export function ChartConfigPanel({
|
||||||
: currentConfig.yAxis === col;
|
: currentConfig.yAxis === col;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={col} className="flex items-center gap-1.5 rounded p-1.5 hover:bg-gray-50">
|
<div key={col} className="flex items-center gap-2 rounded p-2 hover:bg-gray-50">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
|
|
@ -275,10 +290,10 @@ export function ChartConfigPanel({
|
||||||
updateConfig({ yAxis: newYAxis });
|
updateConfig({ yAxis: newYAxis });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Label className="flex-1 cursor-pointer text-xs font-normal">
|
<Label className="flex-1 cursor-pointer text-sm font-normal">
|
||||||
{col}
|
{col}
|
||||||
{sampleData[col] !== undefined && (
|
{sampleData[col] !== undefined && (
|
||||||
<span className="ml-1.5 text-[10px] text-gray-500">
|
<span className="ml-2 text-xs text-gray-500">
|
||||||
(예: {String(sampleData[col]).substring(0, 30)})
|
(예: {String(sampleData[col]).substring(0, 30)})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -289,11 +304,11 @@ export function ChartConfigPanel({
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
{simpleColumns.length === 0 && (
|
{simpleColumns.length === 0 && (
|
||||||
<p className="text-[11px] text-red-500">사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.</p>
|
<p className="text-xs text-red-500">사용 가능한 컬럼이 없습니다. JSON Path를 확인하세요.</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-[11px] text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
팁: 여러 항목을 선택하면 비교 차트가 생성됩니다 (예: 갤럭시 vs 아이폰)
|
팁: 여러 항목을 선택하면 비교 차트가 생성됩니다 (예: 갤럭시 vs 아이폰)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -301,10 +316,10 @@ export function ChartConfigPanel({
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* 집계 함수 */}
|
{/* 집계 함수 */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs font-medium text-gray-700">
|
<Label>
|
||||||
집계 함수
|
집계 함수
|
||||||
<span className="ml-1.5 text-[11px] text-gray-500">(데이터 처리 방식)</span>
|
<span className="ml-2 text-xs text-gray-500">(데이터 처리 방식)</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
value={currentConfig.aggregation || "none"}
|
value={currentConfig.aggregation || "none"}
|
||||||
|
|
@ -314,54 +329,40 @@ export function ChartConfigPanel({
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="z-[99999]">
|
<SelectContent className="z-[99999]">
|
||||||
<SelectItem value="none" className="text-xs">
|
<SelectItem value="none">없음 - SQL에서 집계됨</SelectItem>
|
||||||
없음 - SQL에서 집계됨
|
<SelectItem value="sum">합계 (SUM) - 모든 값을 더함</SelectItem>
|
||||||
</SelectItem>
|
<SelectItem value="avg">평균 (AVG) - 평균값 계산</SelectItem>
|
||||||
<SelectItem value="sum" className="text-xs">
|
<SelectItem value="count">개수 (COUNT) - 데이터 개수</SelectItem>
|
||||||
합계 (SUM) - 모든 값을 더함
|
<SelectItem value="max">최대값 (MAX) - 가장 큰 값</SelectItem>
|
||||||
</SelectItem>
|
<SelectItem value="min">최소값 (MIN) - 가장 작은 값</SelectItem>
|
||||||
<SelectItem value="avg" className="text-xs">
|
|
||||||
평균 (AVG) - 평균값 계산
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="count" className="text-xs">
|
|
||||||
개수 (COUNT) - 데이터 개수
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="max" className="text-xs">
|
|
||||||
최대값 (MAX) - 가장 큰 값
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="min" className="text-xs">
|
|
||||||
최소값 (MIN) - 가장 작은 값
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-[11px] text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
그룹핑 필드와 함께 사용하면 자동으로 데이터를 집계합니다. (예: 부서별 개수, 월별 합계)
|
그룹핑 필드와 함께 사용하면 자동으로 데이터를 집계합니다. (예: 부서별 개수, 월별 합계)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 그룹핑 필드 (선택사항) */}
|
{/* 그룹핑 필드 (선택사항) */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs font-medium text-gray-700">
|
<Label>
|
||||||
그룹핑 필드 (선택사항)
|
그룹핑 필드 (선택사항)
|
||||||
<span className="ml-1.5 text-[11px] text-gray-500">(같은 값끼리 묶어서 집계)</span>
|
<span className="ml-2 text-xs text-gray-500">(같은 값끼리 묶어서 집계)</span>
|
||||||
</Label>
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
value={currentConfig.groupBy || undefined}
|
value={currentConfig.groupBy || undefined}
|
||||||
onValueChange={(value) => updateConfig({ groupBy: value })}
|
onValueChange={(value) => updateConfig({ groupBy: value })}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="없음" />
|
<SelectValue placeholder="없음" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="z-[99999]">
|
<SelectContent className="z-[99999]">
|
||||||
<SelectItem value="__none__" className="text-xs">
|
<SelectItem value="__none__">없음</SelectItem>
|
||||||
없음
|
|
||||||
</SelectItem>
|
|
||||||
{availableColumns.map((col) => (
|
{availableColumns.map((col) => (
|
||||||
<SelectItem key={col} value={col} className="text-xs">
|
<SelectItem key={col} value={col}>
|
||||||
{col}
|
{col}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
|
@ -372,8 +373,8 @@ export function ChartConfigPanel({
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* 차트 색상 */}
|
{/* 차트 색상 */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs font-medium text-gray-700">차트 색상</Label>
|
<Label>차트 색상</Label>
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<div className="grid grid-cols-4 gap-2">
|
||||||
{[
|
{[
|
||||||
["#3B82F6", "#EF4444", "#10B981", "#F59E0B"], // 기본
|
["#3B82F6", "#EF4444", "#10B981", "#F59E0B"], // 기본
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@ import React, { useState, useRef, useCallback } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { DashboardCanvas } from "./DashboardCanvas";
|
import { DashboardCanvas } from "./DashboardCanvas";
|
||||||
import { DashboardTopMenu } from "./DashboardTopMenu";
|
import { DashboardTopMenu } from "./DashboardTopMenu";
|
||||||
import { ElementConfigSidebar } from "./ElementConfigSidebar";
|
import { ElementConfigModal } from "./ElementConfigModal";
|
||||||
|
import { ListWidgetConfigModal } from "./widgets/ListWidgetConfigModal";
|
||||||
|
import { YardWidgetConfigModal } from "./widgets/YardWidgetConfigModal";
|
||||||
import { DashboardSaveModal } from "./DashboardSaveModal";
|
import { DashboardSaveModal } from "./DashboardSaveModal";
|
||||||
import { DashboardElement, ElementType, ElementSubtype } from "./types";
|
import { DashboardElement, ElementType, ElementSubtype } from "./types";
|
||||||
import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateGridConfig } from "./gridUtils";
|
import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateGridConfig } from "./gridUtils";
|
||||||
|
|
@ -42,8 +44,9 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
const { refreshMenus } = useMenu();
|
const { refreshMenus } = useMenu();
|
||||||
const [elements, setElements] = useState<DashboardElement[]>([]);
|
const [elements, setElements] = useState<DashboardElement[]>([]);
|
||||||
const [selectedElement, setSelectedElement] = useState<string | null>(null);
|
const [selectedElement, setSelectedElement] = useState<string | null>(null);
|
||||||
const [selectedElements, setSelectedElements] = useState<string[]>([]); // 다중 선택
|
const [selectedElements, setSelectedElements] = useState<string[]>([]); // 🔥 다중 선택
|
||||||
const [elementCounter, setElementCounter] = useState(0);
|
const [elementCounter, setElementCounter] = useState(0);
|
||||||
|
const [configModalElement, setConfigModalElement] = useState<DashboardElement | null>(null);
|
||||||
const [dashboardId, setDashboardId] = useState<string | null>(initialDashboardId || null);
|
const [dashboardId, setDashboardId] = useState<string | null>(initialDashboardId || null);
|
||||||
const [dashboardTitle, setDashboardTitle] = useState<string>("");
|
const [dashboardTitle, setDashboardTitle] = useState<string>("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
@ -56,10 +59,6 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
const [successModalOpen, setSuccessModalOpen] = useState(false);
|
const [successModalOpen, setSuccessModalOpen] = useState(false);
|
||||||
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
|
const [clearConfirmOpen, setClearConfirmOpen] = useState(false);
|
||||||
|
|
||||||
// 사이드바 상태
|
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
|
||||||
const [sidebarElement, setSidebarElement] = useState<DashboardElement | null>(null);
|
|
||||||
|
|
||||||
// 클립보드 (복사/붙여넣기용)
|
// 클립보드 (복사/붙여넣기용)
|
||||||
const [clipboard, setClipboard] = useState<DashboardElement | null>(null);
|
const [clipboard, setClipboard] = useState<DashboardElement | null>(null);
|
||||||
|
|
||||||
|
|
@ -291,13 +290,8 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
if (selectedElement === id) {
|
if (selectedElement === id) {
|
||||||
setSelectedElement(null);
|
setSelectedElement(null);
|
||||||
}
|
}
|
||||||
// 삭제된 요소의 사이드바가 열려있으면 닫기
|
|
||||||
if (sidebarElement?.id === id) {
|
|
||||||
setSidebarOpen(false);
|
|
||||||
setSidebarElement(null);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[selectedElement, sidebarElement],
|
[selectedElement],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 키보드 단축키 핸들러들
|
// 키보드 단축키 핸들러들
|
||||||
|
|
@ -342,7 +336,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
onDelete: handleDeleteSelected,
|
onDelete: handleDeleteSelected,
|
||||||
onCopy: handleCopyElement,
|
onCopy: handleCopyElement,
|
||||||
onPaste: handlePasteElement,
|
onPaste: handlePasteElement,
|
||||||
enabled: !saveModalOpen && !successModalOpen && !clearConfirmOpen && !sidebarOpen,
|
enabled: !saveModalOpen && !successModalOpen && !clearConfirmOpen,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 전체 삭제 확인 모달 열기
|
// 전체 삭제 확인 모달 열기
|
||||||
|
|
@ -358,34 +352,34 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
setClearConfirmOpen(false);
|
setClearConfirmOpen(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 리스트/야드 위젯 설정 저장 (Partial 업데이트)
|
// 요소 설정 모달 열기
|
||||||
const saveWidgetConfig = useCallback(
|
const openConfigModal = useCallback((element: DashboardElement) => {
|
||||||
(updates: Partial<DashboardElement>) => {
|
setConfigModalElement(element);
|
||||||
if (sidebarElement) {
|
|
||||||
updateElement(sidebarElement.id, updates);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[sidebarElement, updateElement],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 사이드바 닫기
|
|
||||||
const handleCloseSidebar = useCallback(() => {
|
|
||||||
setSidebarOpen(false);
|
|
||||||
setSidebarElement(null);
|
|
||||||
setSelectedElement(null);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 사이드바 적용
|
// 요소 설정 모달 닫기
|
||||||
const handleApplySidebar = useCallback(
|
const closeConfigModal = useCallback(() => {
|
||||||
|
setConfigModalElement(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 요소 설정 저장
|
||||||
|
const saveElementConfig = useCallback(
|
||||||
(updatedElement: DashboardElement) => {
|
(updatedElement: DashboardElement) => {
|
||||||
updateElement(updatedElement.id, updatedElement);
|
updateElement(updatedElement.id, updatedElement);
|
||||||
// 사이드바는 열린 채로 유지하여 연속 수정 가능
|
|
||||||
// 단, sidebarElement도 업데이트해서 최신 상태 반영
|
|
||||||
setSidebarElement(updatedElement);
|
|
||||||
},
|
},
|
||||||
[updateElement],
|
[updateElement],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 리스트 위젯 설정 저장 (Partial 업데이트)
|
||||||
|
const saveListWidgetConfig = useCallback(
|
||||||
|
(updates: Partial<DashboardElement>) => {
|
||||||
|
if (configModalElement) {
|
||||||
|
updateElement(configModalElement.id, updates);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[configModalElement, updateElement],
|
||||||
|
);
|
||||||
|
|
||||||
// 레이아웃 저장
|
// 레이아웃 저장
|
||||||
const saveLayout = useCallback(() => {
|
const saveLayout = useCallback(() => {
|
||||||
if (elements.length === 0) {
|
if (elements.length === 0) {
|
||||||
|
|
@ -566,22 +560,14 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
onRemoveElement={removeElement}
|
onRemoveElement={removeElement}
|
||||||
onSelectElement={(id) => {
|
onSelectElement={(id) => {
|
||||||
setSelectedElement(id);
|
setSelectedElement(id);
|
||||||
setSelectedElements([]);
|
setSelectedElements([]); // 단일 선택 시 다중 선택 해제
|
||||||
|
|
||||||
// 선택된 요소 찾아서 사이드바 열기
|
|
||||||
const element = elements.find((el) => el.id === id);
|
|
||||||
if (element) {
|
|
||||||
setSidebarElement(element);
|
|
||||||
setSidebarOpen(true);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
onSelectMultiple={(ids) => {
|
onSelectMultiple={(ids) => {
|
||||||
|
console.log("🎯 DashboardDesigner - onSelectMultiple 호출:", ids);
|
||||||
setSelectedElements(ids);
|
setSelectedElements(ids);
|
||||||
setSelectedElement(null);
|
setSelectedElement(null); // 다중 선택 시 단일 선택 해제
|
||||||
setSidebarOpen(false);
|
|
||||||
setSidebarElement(null);
|
|
||||||
}}
|
}}
|
||||||
onConfigureElement={() => {}}
|
onConfigureElement={openConfigModal}
|
||||||
backgroundColor={canvasBackgroundColor}
|
backgroundColor={canvasBackgroundColor}
|
||||||
canvasWidth={canvasConfig.width}
|
canvasWidth={canvasConfig.width}
|
||||||
canvasHeight={dynamicCanvasHeight}
|
canvasHeight={dynamicCanvasHeight}
|
||||||
|
|
@ -589,13 +575,33 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 요소 설정 사이드바 (리스트/야드 위젯 포함) */}
|
{/* 요소 설정 모달 */}
|
||||||
<ElementConfigSidebar
|
{configModalElement && (
|
||||||
element={sidebarElement}
|
<>
|
||||||
isOpen={sidebarOpen}
|
{configModalElement.type === "widget" && configModalElement.subtype === "list" ? (
|
||||||
onClose={handleCloseSidebar}
|
<ListWidgetConfigModal
|
||||||
onApply={handleApplySidebar}
|
element={configModalElement}
|
||||||
|
isOpen={true}
|
||||||
|
onClose={closeConfigModal}
|
||||||
|
onSave={saveListWidgetConfig}
|
||||||
/>
|
/>
|
||||||
|
) : configModalElement.type === "widget" && configModalElement.subtype === "yard-management-3d" ? (
|
||||||
|
<YardWidgetConfigModal
|
||||||
|
element={configModalElement}
|
||||||
|
isOpen={true}
|
||||||
|
onClose={closeConfigModal}
|
||||||
|
onSave={saveListWidgetConfig}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ElementConfigModal
|
||||||
|
element={configModalElement}
|
||||||
|
isOpen={true}
|
||||||
|
onClose={closeConfigModal}
|
||||||
|
onSave={saveElementConfig}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 저장 모달 */}
|
{/* 저장 모달 */}
|
||||||
<DashboardSaveModal
|
<DashboardSaveModal
|
||||||
|
|
|
||||||
|
|
@ -1,371 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState, useCallback, useEffect } from "react";
|
|
||||||
import { DashboardElement, ChartDataSource, ChartConfig, QueryResult } from "./types";
|
|
||||||
import { QueryEditor } from "./QueryEditor";
|
|
||||||
import { ChartConfigPanel } from "./ChartConfigPanel";
|
|
||||||
import { VehicleMapConfigPanel } from "./VehicleMapConfigPanel";
|
|
||||||
import { DatabaseConfig } from "./data-sources/DatabaseConfig";
|
|
||||||
import { ApiConfig } from "./data-sources/ApiConfig";
|
|
||||||
import { ListWidgetConfigSidebar } from "./widgets/ListWidgetConfigSidebar";
|
|
||||||
import { YardWidgetConfigSidebar } from "./widgets/YardWidgetConfigSidebar";
|
|
||||||
import { X } from "lucide-react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
|
|
||||||
interface ElementConfigSidebarProps {
|
|
||||||
element: DashboardElement | null;
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onApply: (element: DashboardElement) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 요소 설정 사이드바 컴포넌트
|
|
||||||
* - 왼쪽에서 슬라이드 인/아웃
|
|
||||||
* - 캔버스 위에 오버레이
|
|
||||||
* - "적용" 버튼으로 명시적 저장
|
|
||||||
*/
|
|
||||||
export function ElementConfigSidebar({ element, isOpen, onClose, onApply }: ElementConfigSidebarProps) {
|
|
||||||
const [dataSource, setDataSource] = useState<ChartDataSource>({
|
|
||||||
type: "database",
|
|
||||||
connectionType: "current",
|
|
||||||
refreshInterval: 0,
|
|
||||||
});
|
|
||||||
const [chartConfig, setChartConfig] = useState<ChartConfig>({});
|
|
||||||
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
|
||||||
const [customTitle, setCustomTitle] = useState<string>("");
|
|
||||||
const [showHeader, setShowHeader] = useState<boolean>(true);
|
|
||||||
|
|
||||||
// 사이드바가 열릴 때 초기화
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen && element) {
|
|
||||||
setDataSource(element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 });
|
|
||||||
setChartConfig(element.chartConfig || {});
|
|
||||||
setQueryResult(null);
|
|
||||||
setCustomTitle(element.customTitle || "");
|
|
||||||
setShowHeader(element.showHeader !== false);
|
|
||||||
}
|
|
||||||
}, [isOpen, element]);
|
|
||||||
|
|
||||||
// Esc 키로 사이드바 닫기
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
|
|
||||||
const handleEsc = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("keydown", handleEsc);
|
|
||||||
return () => window.removeEventListener("keydown", handleEsc);
|
|
||||||
}, [isOpen, onClose]);
|
|
||||||
|
|
||||||
// 데이터 소스 타입 변경
|
|
||||||
const handleDataSourceTypeChange = useCallback((type: "database" | "api") => {
|
|
||||||
if (type === "database") {
|
|
||||||
setDataSource({
|
|
||||||
type: "database",
|
|
||||||
connectionType: "current",
|
|
||||||
refreshInterval: 0,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setDataSource({
|
|
||||||
type: "api",
|
|
||||||
method: "GET",
|
|
||||||
refreshInterval: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setQueryResult(null);
|
|
||||||
setChartConfig({});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 데이터 소스 업데이트
|
|
||||||
const handleDataSourceUpdate = useCallback((updates: Partial<ChartDataSource>) => {
|
|
||||||
setDataSource((prev) => ({ ...prev, ...updates }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 차트 설정 변경 처리
|
|
||||||
const handleChartConfigChange = useCallback((newConfig: ChartConfig) => {
|
|
||||||
setChartConfig(newConfig);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 쿼리 테스트 결과 처리
|
|
||||||
const handleQueryTest = useCallback((result: QueryResult) => {
|
|
||||||
setQueryResult(result);
|
|
||||||
setChartConfig({});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 적용 처리
|
|
||||||
const handleApply = useCallback(() => {
|
|
||||||
if (!element) return;
|
|
||||||
|
|
||||||
const updatedElement: DashboardElement = {
|
|
||||||
...element,
|
|
||||||
dataSource,
|
|
||||||
chartConfig,
|
|
||||||
customTitle: customTitle.trim() || undefined,
|
|
||||||
showHeader,
|
|
||||||
};
|
|
||||||
|
|
||||||
onApply(updatedElement);
|
|
||||||
// 사이드바는 열린 채로 유지 (연속 수정 가능)
|
|
||||||
}, [element, dataSource, chartConfig, customTitle, showHeader, onApply]);
|
|
||||||
|
|
||||||
// 요소가 없으면 렌더링하지 않음
|
|
||||||
if (!element) return null;
|
|
||||||
|
|
||||||
// 리스트 위젯은 별도 사이드바로 처리
|
|
||||||
if (element.subtype === "list") {
|
|
||||||
return (
|
|
||||||
<ListWidgetConfigSidebar
|
|
||||||
element={element}
|
|
||||||
isOpen={isOpen}
|
|
||||||
onClose={onClose}
|
|
||||||
onApply={(updatedElement) => {
|
|
||||||
onApply(updatedElement);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 야드 위젯은 사이드바로 처리
|
|
||||||
if (element.subtype === "yard-management-3d") {
|
|
||||||
return (
|
|
||||||
<YardWidgetConfigSidebar
|
|
||||||
element={element}
|
|
||||||
isOpen={isOpen}
|
|
||||||
onApply={(updates) => {
|
|
||||||
onApply({ ...element, ...updates });
|
|
||||||
}}
|
|
||||||
onClose={onClose}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 차트 설정이 필요 없는 위젯 (쿼리/API만 필요)
|
|
||||||
const isSimpleWidget =
|
|
||||||
element.subtype === "todo" ||
|
|
||||||
element.subtype === "booking-alert" ||
|
|
||||||
element.subtype === "maintenance" ||
|
|
||||||
element.subtype === "document" ||
|
|
||||||
element.subtype === "risk-alert" ||
|
|
||||||
element.subtype === "vehicle-status" ||
|
|
||||||
element.subtype === "vehicle-list" ||
|
|
||||||
element.subtype === "status-summary" ||
|
|
||||||
element.subtype === "delivery-status" ||
|
|
||||||
element.subtype === "delivery-status-summary" ||
|
|
||||||
element.subtype === "delivery-today-stats" ||
|
|
||||||
element.subtype === "cargo-list" ||
|
|
||||||
element.subtype === "customer-issues" ||
|
|
||||||
element.subtype === "driver-management" ||
|
|
||||||
element.subtype === "work-history" ||
|
|
||||||
element.subtype === "transport-stats";
|
|
||||||
|
|
||||||
// 자체 기능 위젯 (DB 연결 불필요, 헤더 설정만 가능)
|
|
||||||
const isSelfContainedWidget =
|
|
||||||
element.subtype === "weather" || element.subtype === "exchange" || element.subtype === "calculator";
|
|
||||||
|
|
||||||
// 지도 위젯 (위도/경도 매핑 필요)
|
|
||||||
const isMapWidget = element.subtype === "vehicle-map" || element.subtype === "map-summary";
|
|
||||||
|
|
||||||
// 헤더 전용 위젯
|
|
||||||
const isHeaderOnlyWidget =
|
|
||||||
element.type === "widget" &&
|
|
||||||
(element.subtype === "clock" || element.subtype === "calendar" || isSelfContainedWidget);
|
|
||||||
|
|
||||||
// 저장 가능 여부 확인
|
|
||||||
const isPieChart = element.subtype === "pie" || element.subtype === "donut";
|
|
||||||
const isApiSource = dataSource.type === "api";
|
|
||||||
|
|
||||||
const hasYAxis =
|
|
||||||
chartConfig.yAxis &&
|
|
||||||
(typeof chartConfig.yAxis === "string" || (Array.isArray(chartConfig.yAxis) && chartConfig.yAxis.length > 0));
|
|
||||||
|
|
||||||
const isTitleChanged = customTitle.trim() !== (element.customTitle || "");
|
|
||||||
const isHeaderChanged = showHeader !== (element.showHeader !== false);
|
|
||||||
|
|
||||||
const canApply =
|
|
||||||
isTitleChanged ||
|
|
||||||
isHeaderChanged ||
|
|
||||||
(isSimpleWidget
|
|
||||||
? queryResult && queryResult.rows.length > 0
|
|
||||||
: isMapWidget
|
|
||||||
? queryResult && queryResult.rows.length > 0 && chartConfig.latitudeColumn && chartConfig.longitudeColumn
|
|
||||||
: queryResult &&
|
|
||||||
queryResult.rows.length > 0 &&
|
|
||||||
chartConfig.xAxis &&
|
|
||||||
(isPieChart || isApiSource ? (chartConfig.aggregation === "count" ? true : hasYAxis) : hasYAxis));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-80 flex-col bg-gray-50 transition-transform duration-300 ease-in-out",
|
|
||||||
isOpen ? "translate-x-0" : "translate-x-[-100%]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="flex items-center justify-between bg-white px-3 py-2 shadow-sm">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="bg-primary/10 flex h-6 w-6 items-center justify-center rounded">
|
|
||||||
<span className="text-primary text-xs font-bold">⚙</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs font-semibold text-gray-900">{element.title}</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-gray-100"
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5 text-gray-500" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 본문: 스크롤 가능 영역 */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-3">
|
|
||||||
{/* 기본 설정 카드 */}
|
|
||||||
<div className="mb-3 rounded-lg bg-white p-3 shadow-sm">
|
|
||||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">기본 설정</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{/* 커스텀 제목 입력 */}
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={customTitle}
|
|
||||||
onChange={(e) => setCustomTitle(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
|
||||||
placeholder="위젯 제목"
|
|
||||||
className="focus:border-primary focus:ring-primary/20 h-8 w-full rounded border border-gray-200 bg-gray-50 px-2 text-xs placeholder:text-gray-400 focus:bg-white focus:ring-1 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 헤더 표시 옵션 */}
|
|
||||||
<label className="flex cursor-pointer items-center gap-2 rounded border border-gray-200 bg-gray-50 px-2 py-1.5 transition-colors hover:border-gray-300">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="showHeader"
|
|
||||||
checked={showHeader}
|
|
||||||
onChange={(e) => setShowHeader(e.target.checked)}
|
|
||||||
className="text-primary focus:ring-primary h-3 w-3 rounded border-gray-300"
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-gray-700">헤더 표시</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 헤더 전용 위젯이 아닐 때만 데이터 소스 표시 */}
|
|
||||||
{!isHeaderOnlyWidget && (
|
|
||||||
<div className="rounded-lg bg-white p-3 shadow-sm">
|
|
||||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">데이터 소스</div>
|
|
||||||
|
|
||||||
<Tabs
|
|
||||||
defaultValue={dataSource.type}
|
|
||||||
onValueChange={(value) => handleDataSourceTypeChange(value as "database" | "api")}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<TabsList className="grid h-7 w-full grid-cols-2 bg-gray-100 p-0.5">
|
|
||||||
<TabsTrigger
|
|
||||||
value="database"
|
|
||||||
className="h-6 rounded text-[11px] data-[state=active]:bg-white data-[state=active]:shadow-sm"
|
|
||||||
>
|
|
||||||
데이터베이스
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="api"
|
|
||||||
className="h-6 rounded text-[11px] data-[state=active]:bg-white data-[state=active]:shadow-sm"
|
|
||||||
>
|
|
||||||
REST API
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="database" className="mt-2 space-y-2">
|
|
||||||
<DatabaseConfig dataSource={dataSource} onChange={handleDataSourceUpdate} />
|
|
||||||
<QueryEditor
|
|
||||||
dataSource={dataSource}
|
|
||||||
onDataSourceChange={handleDataSourceUpdate}
|
|
||||||
onQueryTest={handleQueryTest}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 차트/지도 설정 */}
|
|
||||||
{!isSimpleWidget && queryResult && queryResult.rows.length > 0 && (
|
|
||||||
<div className="mt-2">
|
|
||||||
{isMapWidget ? (
|
|
||||||
<VehicleMapConfigPanel
|
|
||||||
config={chartConfig}
|
|
||||||
queryResult={queryResult}
|
|
||||||
onConfigChange={handleChartConfigChange}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ChartConfigPanel
|
|
||||||
config={chartConfig}
|
|
||||||
queryResult={queryResult}
|
|
||||||
onConfigChange={handleChartConfigChange}
|
|
||||||
chartType={element.subtype}
|
|
||||||
dataSourceType={dataSource.type}
|
|
||||||
query={dataSource.query}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="api" className="mt-2 space-y-2">
|
|
||||||
<ApiConfig dataSource={dataSource} onChange={handleDataSourceUpdate} onTestResult={handleQueryTest} />
|
|
||||||
|
|
||||||
{/* 차트/지도 설정 */}
|
|
||||||
{!isSimpleWidget && queryResult && queryResult.rows.length > 0 && (
|
|
||||||
<div className="mt-2">
|
|
||||||
{isMapWidget ? (
|
|
||||||
<VehicleMapConfigPanel
|
|
||||||
config={chartConfig}
|
|
||||||
queryResult={queryResult}
|
|
||||||
onConfigChange={handleChartConfigChange}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ChartConfigPanel
|
|
||||||
config={chartConfig}
|
|
||||||
queryResult={queryResult}
|
|
||||||
onConfigChange={handleChartConfigChange}
|
|
||||||
chartType={element.subtype}
|
|
||||||
dataSourceType={dataSource.type}
|
|
||||||
query={dataSource.query}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
{/* 데이터 로드 상태 */}
|
|
||||||
{queryResult && (
|
|
||||||
<div className="mt-2 flex items-center gap-1.5 rounded bg-green-50 px-2 py-1">
|
|
||||||
<div className="h-1.5 w-1.5 rounded-full bg-green-500" />
|
|
||||||
<span className="text-[10px] font-medium text-green-700">
|
|
||||||
{queryResult.rows.length}개 데이터 로드됨
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 푸터: 적용 버튼 */}
|
|
||||||
<div className="flex gap-2 bg-white p-2 shadow-[0_-2px_8px_rgba(0,0,0,0.05)]">
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="flex-1 rounded bg-gray-100 py-2 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-200"
|
|
||||||
>
|
|
||||||
취소
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleApply}
|
|
||||||
disabled={isHeaderOnlyWidget ? false : !canApply}
|
|
||||||
className="bg-primary hover:bg-primary/90 flex-1 rounded py-2 text-xs font-medium text-white transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
적용
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -12,8 +12,7 @@ import { Card } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
import { Play, Loader2, Database, Code } from "lucide-react";
|
||||||
import { Play, Loader2, Database, Code, ChevronDown, ChevronRight } from "lucide-react";
|
|
||||||
import { applyQueryFilters } from "./utils/queryHelpers";
|
import { applyQueryFilters } from "./utils/queryHelpers";
|
||||||
|
|
||||||
interface QueryEditorProps {
|
interface QueryEditorProps {
|
||||||
|
|
@ -33,7 +32,6 @@ export function QueryEditor({ dataSource, onDataSourceChange, onQueryTest }: Que
|
||||||
const [isExecuting, setIsExecuting] = useState(false);
|
const [isExecuting, setIsExecuting] = useState(false);
|
||||||
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [sampleQueryOpen, setSampleQueryOpen] = useState(false);
|
|
||||||
|
|
||||||
// 쿼리 실행
|
// 쿼리 실행
|
||||||
const executeQuery = useCallback(async () => {
|
const executeQuery = useCallback(async () => {
|
||||||
|
|
@ -157,75 +155,55 @@ ORDER BY 하위부서수 DESC`,
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-6">
|
||||||
{/* 쿼리 에디터 헤더 */}
|
{/* 쿼리 에디터 헤더 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-2">
|
||||||
<Database className="h-3.5 w-3.5 text-blue-600" />
|
<Database className="h-5 w-5 text-blue-600" />
|
||||||
<h4 className="text-xs font-semibold text-gray-800">SQL 쿼리 에디터</h4>
|
<h4 className="text-lg font-semibold text-gray-800">SQL 쿼리 에디터</h4>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={executeQuery} disabled={isExecuting || !query.trim()} size="sm" className="h-7 text-xs">
|
<Button onClick={executeQuery} disabled={isExecuting || !query.trim()} size="sm">
|
||||||
{isExecuting ? (
|
{isExecuting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
실행 중
|
실행 중...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Play className="mr-1.5 h-3 w-3" />
|
<Play className="mr-2 h-4 w-4" />
|
||||||
실행
|
실행
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 샘플 쿼리 아코디언 */}
|
{/* 샘플 쿼리 버튼들 */}
|
||||||
<Collapsible open={sampleQueryOpen} onOpenChange={setSampleQueryOpen}>
|
<Card className="p-4">
|
||||||
<CollapsibleTrigger className="flex w-full items-center gap-1.5 rounded border border-gray-200 bg-gray-50 px-2 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-100">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{sampleQueryOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
<Label className="text-sm text-gray-600">샘플 쿼리:</Label>
|
||||||
샘플 쿼리
|
<Button variant="outline" size="sm" onClick={() => insertSampleQuery("users")}>
|
||||||
</CollapsibleTrigger>
|
<Code className="mr-2 h-3 w-3" />
|
||||||
<CollapsibleContent className="mt-2">
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
<button
|
|
||||||
onClick={() => insertSampleQuery("users")}
|
|
||||||
className="flex items-center gap-1 rounded border border-gray-200 bg-white px-2 py-1 text-[11px] transition-colors hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
<Code className="h-3 w-3" />
|
|
||||||
부서별 사용자
|
부서별 사용자
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button variant="outline" size="sm" onClick={() => insertSampleQuery("dept")}>
|
||||||
onClick={() => insertSampleQuery("dept")}
|
<Code className="mr-2 h-3 w-3" />
|
||||||
className="flex items-center gap-1 rounded border border-gray-200 bg-white px-2 py-1 text-[11px] transition-colors hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
<Code className="h-3 w-3" />
|
|
||||||
부서 정보
|
부서 정보
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button variant="outline" size="sm" onClick={() => insertSampleQuery("usersByDate")}>
|
||||||
onClick={() => insertSampleQuery("usersByDate")}
|
|
||||||
className="rounded border border-gray-200 bg-white px-2 py-1 text-[11px] transition-colors hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
월별 가입 추이
|
월별 가입 추이
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button variant="outline" size="sm" onClick={() => insertSampleQuery("usersByPosition")}>
|
||||||
onClick={() => insertSampleQuery("usersByPosition")}
|
|
||||||
className="rounded border border-gray-200 bg-white px-2 py-1 text-[11px] transition-colors hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
직급별 분포
|
직급별 분포
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button variant="outline" size="sm" onClick={() => insertSampleQuery("deptHierarchy")}>
|
||||||
onClick={() => insertSampleQuery("deptHierarchy")}
|
|
||||||
className="rounded border border-gray-200 bg-white px-2 py-1 text-[11px] transition-colors hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
부서 계층
|
부서 계층
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleContent>
|
</Card>
|
||||||
</Collapsible>
|
|
||||||
|
|
||||||
{/* SQL 쿼리 입력 영역 */}
|
{/* SQL 쿼리 입력 영역 */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<Label className="text-xs">SQL 쿼리</Label>
|
<Label>SQL 쿼리</Label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Textarea
|
<Textarea
|
||||||
value={query}
|
value={query}
|
||||||
|
|
@ -235,14 +213,14 @@ ORDER BY 하위부서수 DESC`,
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
placeholder="SELECT * FROM your_table WHERE condition = 'value';"
|
placeholder="SELECT * FROM your_table WHERE condition = 'value';"
|
||||||
className="h-32 resize-none font-mono text-[11px]"
|
className="h-40 resize-none font-mono text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 새로고침 간격 설정 */}
|
{/* 새로고침 간격 설정 */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<Label className="text-xs">자동 새로고침:</Label>
|
<Label className="text-sm">자동 새로고침:</Label>
|
||||||
<Select
|
<Select
|
||||||
value={String(dataSource?.refreshInterval ?? 0)}
|
value={String(dataSource?.refreshInterval ?? 0)}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
|
|
@ -254,38 +232,26 @@ ORDER BY 하위부서수 DESC`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-7 w-24 text-xs">
|
<SelectTrigger className="w-32">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="z-[99999]">
|
<SelectContent className="z-[99999]">
|
||||||
<SelectItem value="0" className="text-xs">
|
<SelectItem value="0">수동</SelectItem>
|
||||||
수동
|
<SelectItem value="10000">10초</SelectItem>
|
||||||
</SelectItem>
|
<SelectItem value="30000">30초</SelectItem>
|
||||||
<SelectItem value="10000" className="text-xs">
|
<SelectItem value="60000">1분</SelectItem>
|
||||||
10초
|
<SelectItem value="300000">5분</SelectItem>
|
||||||
</SelectItem>
|
<SelectItem value="600000">10분</SelectItem>
|
||||||
<SelectItem value="30000" className="text-xs">
|
|
||||||
30초
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="60000" className="text-xs">
|
|
||||||
1분
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="300000" className="text-xs">
|
|
||||||
5분
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="600000" className="text-xs">
|
|
||||||
10분
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 오류 메시지 */}
|
{/* 오류 메시지 */}
|
||||||
{error && (
|
{error && (
|
||||||
<Alert variant="destructive" className="py-2">
|
<Alert variant="destructive">
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<div className="text-xs font-medium">오류</div>
|
<div className="text-sm font-medium">오류</div>
|
||||||
<div className="mt-0.5 text-xs">{error}</div>
|
<div className="mt-1 text-sm">{error}</div>
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
@ -293,28 +259,24 @@ ORDER BY 하위부서수 DESC`,
|
||||||
{/* 쿼리 결과 미리보기 */}
|
{/* 쿼리 결과 미리보기 */}
|
||||||
{queryResult && (
|
{queryResult && (
|
||||||
<Card>
|
<Card>
|
||||||
<div className="border-b border-gray-200 bg-gray-50 px-2 py-1.5">
|
<div className="border-b border-gray-200 bg-gray-50 px-4 py-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs font-medium text-gray-700">쿼리 결과</span>
|
<span className="text-sm font-medium text-gray-700">쿼리 결과</span>
|
||||||
<Badge variant="secondary" className="h-4 text-[10px]">
|
<Badge variant="secondary">{queryResult.rows.length}행</Badge>
|
||||||
{queryResult.rows.length}행
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] text-gray-500">실행 시간: {queryResult.executionTime}ms</span>
|
<span className="text-xs text-gray-500">실행 시간: {queryResult.executionTime}ms</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-2">
|
<div className="p-3">
|
||||||
{queryResult.rows.length > 0 ? (
|
{queryResult.rows.length > 0 ? (
|
||||||
<div className="max-h-48 overflow-auto">
|
<div className="max-h-60 overflow-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
{queryResult.columns.map((col, idx) => (
|
{queryResult.columns.map((col, idx) => (
|
||||||
<TableHead key={idx} className="h-7 text-[11px]">
|
<TableHead key={idx}>{col}</TableHead>
|
||||||
{col}
|
|
||||||
</TableHead>
|
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
@ -322,9 +284,7 @@ ORDER BY 하위부서수 DESC`,
|
||||||
{queryResult.rows.slice(0, 10).map((row, idx) => (
|
{queryResult.rows.slice(0, 10).map((row, idx) => (
|
||||||
<TableRow key={idx}>
|
<TableRow key={idx}>
|
||||||
{queryResult.columns.map((col, colIdx) => (
|
{queryResult.columns.map((col, colIdx) => (
|
||||||
<TableCell key={colIdx} className="py-1 text-[11px]">
|
<TableCell key={colIdx}>{String(row[col] ?? "")}</TableCell>
|
||||||
{String(row[col] ?? "")}
|
|
||||||
</TableCell>
|
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|
@ -332,13 +292,13 @@ ORDER BY 하위부서수 DESC`,
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
{queryResult.rows.length > 10 && (
|
{queryResult.rows.length > 10 && (
|
||||||
<div className="mt-2 text-center text-[10px] text-gray-500">
|
<div className="mt-3 text-center text-xs text-gray-500">
|
||||||
... 및 {queryResult.rows.length - 10}개 더 (미리보기는 10행까지만 표시)
|
... 및 {queryResult.rows.length - 10}개 더 (미리보기는 10행까지만 표시)
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="py-6 text-center text-xs text-gray-500">결과가 없습니다.</div>
|
<div className="py-8 text-center text-gray-500">결과가 없습니다.</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -29,13 +29,13 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
|
||||||
const sampleData = queryResult?.rows?.[0] || {};
|
const sampleData = queryResult?.rows?.[0] || {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
<h4 className="text-xs font-semibold text-gray-800">🗺️ 지도 설정</h4>
|
<h4 className="text-lg font-semibold text-gray-800">🗺️ 지도 설정</h4>
|
||||||
|
|
||||||
{/* 쿼리 결과가 없을 때 */}
|
{/* 쿼리 결과가 없을 때 */}
|
||||||
{!queryResult && (
|
{!queryResult && (
|
||||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||||
<div className="text-yellow-800 text-xs">
|
<div className="text-yellow-800 text-sm">
|
||||||
💡 먼저 SQL 쿼리를 실행하여 데이터를 가져온 후 지도를 설정할 수 있습니다.
|
💡 먼저 SQL 쿼리를 실행하여 데이터를 가져온 후 지도를 설정할 수 있습니다.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -45,27 +45,27 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
|
||||||
{queryResult && (
|
{queryResult && (
|
||||||
<>
|
<>
|
||||||
{/* 지도 제목 */}
|
{/* 지도 제목 */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<label className="block text-xs font-medium text-gray-700">지도 제목</label>
|
<label className="block text-sm font-medium text-gray-700">지도 제목</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={currentConfig.title || ''}
|
value={currentConfig.title || ''}
|
||||||
onChange={(e) => updateConfig({ title: e.target.value })}
|
onChange={(e) => updateConfig({ title: e.target.value })}
|
||||||
placeholder="차량 위치 지도"
|
placeholder="차량 위치 지도"
|
||||||
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 위도 컬럼 설정 */}
|
{/* 위도 컬럼 설정 */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<label className="block text-xs font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
위도 컬럼 (Latitude)
|
위도 컬럼 (Latitude)
|
||||||
<span className="text-red-500 ml-1">*</span>
|
<span className="text-red-500 ml-1">*</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={currentConfig.latitudeColumn || ''}
|
value={currentConfig.latitudeColumn || ''}
|
||||||
onChange={(e) => updateConfig({ latitudeColumn: e.target.value })}
|
onChange={(e) => updateConfig({ latitudeColumn: e.target.value })}
|
||||||
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||||
>
|
>
|
||||||
<option value="">선택하세요</option>
|
<option value="">선택하세요</option>
|
||||||
{availableColumns.map((col) => (
|
{availableColumns.map((col) => (
|
||||||
|
|
@ -77,15 +77,15 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 경도 컬럼 설정 */}
|
{/* 경도 컬럼 설정 */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<label className="block text-xs font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
경도 컬럼 (Longitude)
|
경도 컬럼 (Longitude)
|
||||||
<span className="text-red-500 ml-1">*</span>
|
<span className="text-red-500 ml-1">*</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={currentConfig.longitudeColumn || ''}
|
value={currentConfig.longitudeColumn || ''}
|
||||||
onChange={(e) => updateConfig({ longitudeColumn: e.target.value })}
|
onChange={(e) => updateConfig({ longitudeColumn: e.target.value })}
|
||||||
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||||
>
|
>
|
||||||
<option value="">선택하세요</option>
|
<option value="">선택하세요</option>
|
||||||
{availableColumns.map((col) => (
|
{availableColumns.map((col) => (
|
||||||
|
|
@ -97,14 +97,14 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 라벨 컬럼 (선택사항) */}
|
{/* 라벨 컬럼 (선택사항) */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<label className="block text-xs font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
라벨 컬럼 (마커 표시명)
|
라벨 컬럼 (마커 표시명)
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={currentConfig.labelColumn || ''}
|
value={currentConfig.labelColumn || ''}
|
||||||
onChange={(e) => updateConfig({ labelColumn: e.target.value })}
|
onChange={(e) => updateConfig({ labelColumn: e.target.value })}
|
||||||
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||||
>
|
>
|
||||||
<option value="">선택하세요 (선택사항)</option>
|
<option value="">선택하세요 (선택사항)</option>
|
||||||
{availableColumns.map((col) => (
|
{availableColumns.map((col) => (
|
||||||
|
|
@ -116,14 +116,14 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 상태 컬럼 (선택사항) */}
|
{/* 상태 컬럼 (선택사항) */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<label className="block text-xs font-medium text-gray-700">
|
<label className="block text-sm font-medium text-gray-700">
|
||||||
상태 컬럼 (마커 색상)
|
상태 컬럼 (마커 색상)
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={currentConfig.statusColumn || ''}
|
value={currentConfig.statusColumn || ''}
|
||||||
onChange={(e) => updateConfig({ statusColumn: e.target.value })}
|
onChange={(e) => updateConfig({ statusColumn: e.target.value })}
|
||||||
className="w-full px-2 py-1.5 border border-gray-300 rounded-lg text-xs"
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
|
||||||
>
|
>
|
||||||
<option value="">선택하세요 (선택사항)</option>
|
<option value="">선택하세요 (선택사항)</option>
|
||||||
{availableColumns.map((col) => (
|
{availableColumns.map((col) => (
|
||||||
|
|
@ -136,7 +136,7 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
|
||||||
|
|
||||||
{/* 설정 미리보기 */}
|
{/* 설정 미리보기 */}
|
||||||
<div className="p-3 bg-gray-50 rounded-lg">
|
<div className="p-3 bg-gray-50 rounded-lg">
|
||||||
<div className="text-xs font-medium text-gray-700 mb-2">📋 설정 미리보기</div>
|
<div className="text-sm font-medium text-gray-700 mb-2">📋 설정 미리보기</div>
|
||||||
<div className="text-xs text-muted-foreground space-y-1">
|
<div className="text-xs text-muted-foreground space-y-1">
|
||||||
<div><strong>위도:</strong> {currentConfig.latitudeColumn || '미설정'}</div>
|
<div><strong>위도:</strong> {currentConfig.latitudeColumn || '미설정'}</div>
|
||||||
<div><strong>경도:</strong> {currentConfig.longitudeColumn || '미설정'}</div>
|
<div><strong>경도:</strong> {currentConfig.longitudeColumn || '미설정'}</div>
|
||||||
|
|
@ -149,7 +149,7 @@ export function VehicleMapConfigPanel({ config, queryResult, onConfigChange }: V
|
||||||
{/* 필수 필드 확인 */}
|
{/* 필수 필드 확인 */}
|
||||||
{(!currentConfig.latitudeColumn || !currentConfig.longitudeColumn) && (
|
{(!currentConfig.latitudeColumn || !currentConfig.longitudeColumn) && (
|
||||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
<div className="p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||||
<div className="text-red-800 text-xs">
|
<div className="text-red-800 text-sm">
|
||||||
⚠️ 위도와 경도 컬럼을 반드시 선택해야 지도에 표시할 수 있습니다.
|
⚠️ 위도와 경도 컬럼을 반드시 선택해야 지도에 표시할 수 있습니다.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { ChartDataSource, QueryResult, KeyValuePair } from "../types";
|
import { ChartDataSource, QueryResult, KeyValuePair } from "../types";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -313,48 +314,55 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800">2단계: REST API 설정</h3>
|
||||||
|
<p className="mt-1 text-sm text-gray-600">외부 API에서 데이터를 가져올 설정을 입력하세요</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 외부 커넥션 선택 */}
|
{/* 외부 커넥션 선택 */}
|
||||||
{apiConnections.length > 0 && (
|
{apiConnections.length > 0 && (
|
||||||
<div className="space-y-2">
|
<Card className="space-y-4 p-4">
|
||||||
<Label className="text-xs font-medium text-gray-700">외부 커넥션 (선택)</Label>
|
<div>
|
||||||
|
<Label className="text-sm font-medium text-gray-700">외부 커넥션 (선택)</Label>
|
||||||
<Select value={selectedConnectionId} onValueChange={handleConnectionSelect}>
|
<Select value={selectedConnectionId} onValueChange={handleConnectionSelect}>
|
||||||
<SelectTrigger className="h-8 text-xs">
|
<SelectTrigger className="mt-2">
|
||||||
<SelectValue placeholder="저장된 커넥션 선택" />
|
<SelectValue placeholder="저장된 커넥션 선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="z-[9999]">
|
<SelectContent className="z-[9999]">
|
||||||
<SelectItem value="manual" className="text-xs">
|
<SelectItem value="manual">직접 입력</SelectItem>
|
||||||
직접 입력
|
|
||||||
</SelectItem>
|
|
||||||
{apiConnections.map((conn) => (
|
{apiConnections.map((conn) => (
|
||||||
<SelectItem key={conn.id} value={String(conn.id)} className="text-xs">
|
<SelectItem key={conn.id} value={String(conn.id)}>
|
||||||
{conn.connection_name}
|
{conn.connection_name}
|
||||||
{conn.description && <span className="ml-1.5 text-[10px] text-gray-500">({conn.description})</span>}
|
{conn.description && <span className="ml-2 text-xs text-gray-500">({conn.description})</span>}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-[11px] text-gray-500">저장한 REST API 설정을 불러올 수 있습니다</p>
|
<p className="mt-1 text-xs text-gray-500">외부 커넥션 관리에서 저장한 REST API 설정을 불러올 수 있습니다</p>
|
||||||
</div>
|
</div>
|
||||||
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* API URL */}
|
{/* API URL */}
|
||||||
<div className="space-y-1.5">
|
<Card className="space-y-4 p-4">
|
||||||
<Label className="text-xs font-medium text-gray-700">API URL *</Label>
|
<div>
|
||||||
|
<Label className="text-sm font-medium text-gray-700">API URL *</Label>
|
||||||
<Input
|
<Input
|
||||||
type="url"
|
type="url"
|
||||||
placeholder="https://api.example.com/data"
|
placeholder="https://api.example.com/data"
|
||||||
value={dataSource.endpoint || ""}
|
value={dataSource.endpoint || ""}
|
||||||
onChange={(e) => onChange({ endpoint: e.target.value })}
|
onChange={(e) => onChange({ endpoint: e.target.value })}
|
||||||
className="h-8 text-xs"
|
className="mt-2"
|
||||||
/>
|
/>
|
||||||
<p className="text-[11px] text-gray-500">GET 요청을 보낼 API 엔드포인트</p>
|
<p className="mt-1 text-xs text-gray-500">GET 요청을 보낼 API 엔드포인트</p>
|
||||||
</div>
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* 쿼리 파라미터 */}
|
{/* 쿼리 파라미터 */}
|
||||||
<div className="space-y-2">
|
<Card className="space-y-4 p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs font-medium text-gray-700">URL 쿼리 파라미터</Label>
|
<Label className="text-sm font-medium text-gray-700">URL 쿼리 파라미터</Label>
|
||||||
<Button variant="outline" size="sm" onClick={addQueryParam} className="h-6 text-[11px]">
|
<Button variant="outline" size="sm" onClick={addQueryParam}>
|
||||||
<Plus className="mr-1 h-3 w-3" />
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
추가
|
추가
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -363,42 +371,39 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
||||||
{(() => {
|
{(() => {
|
||||||
const params = normalizeQueryParams();
|
const params = normalizeQueryParams();
|
||||||
return params.length > 0 ? (
|
return params.length > 0 ? (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
{params.map((param) => (
|
{params.map((param) => (
|
||||||
<div key={param.id} className="flex gap-1.5">
|
<div key={param.id} className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder="key"
|
placeholder="key"
|
||||||
value={param.key}
|
value={param.key}
|
||||||
onChange={(e) => updateQueryParam(param.id, { key: e.target.value })}
|
onChange={(e) => updateQueryParam(param.id, { key: e.target.value })}
|
||||||
className="h-7 flex-1 text-xs"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
placeholder="value"
|
placeholder="value"
|
||||||
value={param.value}
|
value={param.value}
|
||||||
onChange={(e) => updateQueryParam(param.id, { value: e.target.value })}
|
onChange={(e) => updateQueryParam(param.id, { value: e.target.value })}
|
||||||
className="h-7 flex-1 text-xs"
|
className="flex-1"
|
||||||
/>
|
/>
|
||||||
<button
|
<Button variant="ghost" size="icon" onClick={() => removeQueryParam(param.id)}>
|
||||||
onClick={() => removeQueryParam(param.id)}
|
<X className="h-4 w-4" />
|
||||||
className="flex h-7 w-7 items-center justify-center rounded hover:bg-gray-100"
|
</Button>
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="py-2 text-center text-[11px] text-gray-500">추가된 파라미터가 없습니다</p>
|
<p className="py-2 text-center text-sm text-gray-500">추가된 파라미터가 없습니다</p>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
<p className="text-[11px] text-gray-500">예: category=electronics, limit=10</p>
|
<p className="text-xs text-gray-500">예: category=electronics, limit=10</p>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="space-y-2">
|
<Card className="space-y-4 p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs font-medium text-gray-700">요청 헤더</Label>
|
<Label className="text-sm font-medium text-gray-700">요청 헤더</Label>
|
||||||
<Button variant="outline" size="sm" onClick={addHeader}>
|
<Button variant="outline" size="sm" onClick={addHeader}>
|
||||||
<Plus className="mr-1 h-3 w-3" />
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
추가
|
추가
|
||||||
|
|
@ -462,22 +467,22 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
||||||
<p className="py-2 text-center text-sm text-gray-500">추가된 헤더가 없습니다</p>
|
<p className="py-2 text-center text-sm text-gray-500">추가된 헤더가 없습니다</p>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
{/* JSON Path */}
|
{/* JSON Path */}
|
||||||
<div className="space-y-2">
|
<Card className="space-y-2 p-4">
|
||||||
<Label className="text-xs font-medium text-gray-700">JSON Path (선택)</Label>
|
<Label className="text-sm font-medium text-gray-700">JSON Path (선택)</Label>
|
||||||
<Input
|
<Input
|
||||||
placeholder="data.results"
|
placeholder="data.results"
|
||||||
value={dataSource.jsonPath || ""}
|
value={dataSource.jsonPath || ""}
|
||||||
onChange={(e) => onChange({ jsonPath: e.target.value })}
|
onChange={(e) => onChange({ jsonPath: e.target.value })}
|
||||||
/>
|
/>
|
||||||
<p className="text-[11px] text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
JSON 응답에서 데이터 배열의 경로 (예: data.results, items, response.data)
|
JSON 응답에서 데이터 배열의 경로 (예: data.results, items, response.data)
|
||||||
<br />
|
<br />
|
||||||
비워두면 전체 응답을 사용합니다
|
비워두면 전체 응답을 사용합니다
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
{/* 테스트 버튼 */}
|
{/* 테스트 버튼 */}
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
|
|
@ -498,7 +503,7 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
||||||
|
|
||||||
{/* 테스트 오류 */}
|
{/* 테스트 오류 */}
|
||||||
{testError && (
|
{testError && (
|
||||||
<div className="rounded bg-red-50 px-2 py-2">
|
<Card className="border-red-200 bg-red-50 p-4">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0 text-red-600" />
|
<AlertCircle className="mt-0.5 h-5 w-5 flex-shrink-0 text-red-600" />
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -506,18 +511,18 @@ export function ApiConfig({ dataSource, onChange, onTestResult }: ApiConfigProps
|
||||||
<div className="mt-1 text-sm text-red-700">{testError}</div>
|
<div className="mt-1 text-sm text-red-700">{testError}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 테스트 결과 */}
|
{/* 테스트 결과 */}
|
||||||
{testResult && (
|
{testResult && (
|
||||||
<div className="rounded bg-green-50 px-2 py-2">
|
<Card className="border-green-200 bg-green-50 p-4">
|
||||||
<div className="mb-2 text-sm font-medium text-green-800">API 호출 성공</div>
|
<div className="mb-2 text-sm font-medium text-green-800">API 호출 성공</div>
|
||||||
<div className="space-y-1 text-xs text-green-700">
|
<div className="space-y-1 text-xs text-green-700">
|
||||||
<div>총 {testResult.rows.length}개의 데이터를 불러왔습니다</div>
|
<div>총 {testResult.rows.length}개의 데이터를 불러왔습니다</div>
|
||||||
<div>컬럼: {testResult.columns.join(", ")}</div>
|
<div>컬럼: {testResult.columns.join(", ")}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { ChartDataSource } from "../types";
|
import { ChartDataSource } from "../types";
|
||||||
import { ExternalDbConnectionAPI, ExternalDbConnection } from "@/lib/api/externalDbConnection";
|
import { ExternalDbConnectionAPI, ExternalDbConnection } from "@/lib/api/externalDbConnection";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { ExternalLink, Database, Server } from "lucide-react";
|
import { ExternalLink, Database, Server } from "lucide-react";
|
||||||
|
|
||||||
interface DatabaseConfigProps {
|
interface DatabaseConfigProps {
|
||||||
|
|
@ -19,7 +20,6 @@ interface DatabaseConfigProps {
|
||||||
* - 외부 커넥션 목록 불러오기
|
* - 외부 커넥션 목록 불러오기
|
||||||
*/
|
*/
|
||||||
export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
|
export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
|
||||||
const router = useRouter();
|
|
||||||
const [connections, setConnections] = useState<ExternalDbConnection[]>([]);
|
const [connections, setConnections] = useState<ExternalDbConnection[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -49,87 +49,93 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
|
||||||
const selectedConnection = connections.find((conn) => String(conn.id) === dataSource.externalConnectionId);
|
const selectedConnection = connections.find((conn) => String(conn.id) === dataSource.externalConnectionId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
{/* 현재 DB vs 외부 DB 선택 */}
|
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-2 block text-xs font-medium text-gray-700">데이터베이스 선택</Label>
|
<h3 className="text-lg font-semibold text-gray-800">2단계: 데이터베이스 설정</h3>
|
||||||
<div className="flex gap-2">
|
<p className="mt-1 text-sm text-gray-600">데이터를 조회할 데이터베이스를 선택하세요</p>
|
||||||
<button
|
</div>
|
||||||
|
|
||||||
|
{/* 현재 DB vs 외부 DB 선택 */}
|
||||||
|
<Card className="p-4">
|
||||||
|
<Label className="mb-3 block text-sm font-medium text-gray-700">데이터베이스 선택</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Button
|
||||||
|
variant={dataSource.connectionType === "current" ? "default" : "outline"}
|
||||||
|
className="h-auto justify-start py-3"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onChange({ connectionType: "current", externalConnectionId: undefined });
|
onChange({ connectionType: "current", externalConnectionId: undefined });
|
||||||
}}
|
}}
|
||||||
className={`flex flex-1 items-center gap-1.5 rounded border px-2 py-1.5 text-xs transition-colors ${
|
|
||||||
dataSource.connectionType === "current"
|
|
||||||
? "bg-primary border-primary text-white"
|
|
||||||
: "border-gray-200 bg-white hover:bg-gray-50"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<Database className="h-3 w-3" />
|
<Database className="mr-2 h-4 w-4" />
|
||||||
현재 DB
|
<div className="text-left">
|
||||||
</button>
|
<div className="font-medium">현재 데이터베이스</div>
|
||||||
|
<div className="text-xs opacity-80">애플리케이션 기본 DB</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
|
||||||
<button
|
<Button
|
||||||
|
variant={dataSource.connectionType === "external" ? "default" : "outline"}
|
||||||
|
className="h-auto justify-start py-3"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onChange({ connectionType: "external" });
|
onChange({ connectionType: "external" });
|
||||||
}}
|
}}
|
||||||
className={`flex flex-1 items-center gap-1.5 rounded border px-2 py-1.5 text-xs transition-colors ${
|
|
||||||
dataSource.connectionType === "external"
|
|
||||||
? "bg-primary border-primary text-white"
|
|
||||||
: "border-gray-200 bg-white hover:bg-gray-50"
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<Server className="h-3 w-3" />
|
<Server className="mr-2 h-4 w-4" />
|
||||||
외부 DB
|
<div className="text-left">
|
||||||
</button>
|
<div className="font-medium">외부 데이터베이스</div>
|
||||||
|
<div className="text-xs opacity-80">등록된 외부 커넥션</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* 외부 DB 선택 시 커넥션 목록 */}
|
{/* 외부 DB 선택 시 커넥션 목록 */}
|
||||||
{dataSource.connectionType === "external" && (
|
{dataSource.connectionType === "external" && (
|
||||||
<div className="space-y-2">
|
<Card className="space-y-4 p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-xs font-medium text-gray-700">외부 커넥션</Label>
|
<Label className="text-sm font-medium text-gray-700">외부 커넥션 선택</Label>
|
||||||
<button
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push("/admin/external-connections");
|
window.open("/admin/external-connections", "_blank");
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-1 text-[11px] text-blue-600 transition-colors hover:text-blue-700"
|
className="text-xs"
|
||||||
>
|
>
|
||||||
<ExternalLink className="h-3 w-3" />
|
<ExternalLink className="mr-1 h-3 w-3" />
|
||||||
커넥션 관리
|
커넥션 관리
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="flex items-center justify-center py-3">
|
<div className="flex items-center justify-center py-4">
|
||||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600" />
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600" />
|
||||||
<span className="ml-2 text-xs text-gray-600">로딩 중...</span>
|
<span className="ml-2 text-sm text-gray-600">커넥션 목록 불러오는 중...</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded bg-red-50 px-2 py-1.5">
|
<div className="rounded-lg border border-red-200 bg-red-50 p-3">
|
||||||
<div className="text-xs text-red-800">{error}</div>
|
<div className="text-sm text-red-800">❌ {error}</div>
|
||||||
<button
|
<Button variant="ghost" size="sm" onClick={loadExternalConnections} className="mt-2 text-xs">
|
||||||
onClick={loadExternalConnections}
|
|
||||||
className="mt-1 text-[11px] text-red-600 underline hover:no-underline"
|
|
||||||
>
|
|
||||||
다시 시도
|
다시 시도
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && connections.length === 0 && (
|
{!loading && !error && connections.length === 0 && (
|
||||||
<div className="rounded bg-yellow-50 px-2 py-2 text-center">
|
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4 text-center">
|
||||||
<div className="mb-1 text-xs text-yellow-800">등록된 커넥션이 없습니다</div>
|
<div className="mb-2 text-sm text-yellow-800">등록된 외부 커넥션이 없습니다</div>
|
||||||
<button
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
router.push("/admin/external-connections");
|
window.open("/admin/external-connections", "_blank");
|
||||||
}}
|
}}
|
||||||
className="text-[11px] text-yellow-700 underline hover:no-underline"
|
|
||||||
>
|
>
|
||||||
|
<ExternalLink className="mr-1 h-3 w-3" />
|
||||||
커넥션 등록하기
|
커넥션 등록하기
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -141,15 +147,15 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
|
||||||
onChange({ externalConnectionId: value });
|
onChange({ externalConnectionId: value });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 w-full text-xs">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue placeholder="커넥션 선택" />
|
<SelectValue placeholder="커넥션을 선택하세요" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="z-[9999]">
|
<SelectContent className="z-[9999]">
|
||||||
{connections.map((conn) => (
|
{connections.map((conn) => (
|
||||||
<SelectItem key={conn.id} value={String(conn.id)} className="text-xs">
|
<SelectItem key={conn.id} value={String(conn.id)}>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium">{conn.connection_name}</span>
|
<span className="font-medium">{conn.connection_name}</span>
|
||||||
<span className="text-[10px] text-gray-500">({conn.db_type.toUpperCase()})</span>
|
<span className="text-xs text-gray-500">({conn.db_type.toUpperCase()})</span>
|
||||||
</div>
|
</div>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
|
@ -157,17 +163,27 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) {
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{selectedConnection && (
|
{selectedConnection && (
|
||||||
<div className="space-y-0.5 rounded bg-gray-50 px-2 py-1.5 text-[11px] text-gray-600">
|
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||||
|
<div className="space-y-1 text-xs text-gray-600">
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium">커넥션:</span> {selectedConnection.connection_name}
|
<span className="font-medium">커넥션명:</span> {selectedConnection.connection_name}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium">타입:</span> {selectedConnection.db_type.toUpperCase()}
|
<span className="font-medium">타입:</span> {selectedConnection.db_type.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 다음 단계 안내 */}
|
||||||
|
{(dataSource.connectionType === "current" ||
|
||||||
|
(dataSource.connectionType === "external" && dataSource.externalConnectionId)) && (
|
||||||
|
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||||
|
<div className="text-sm text-blue-800">✅ 데이터베이스가 선택되었습니다. 아래에서 SQL 쿼리를 작성하세요.</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,6 @@ export type ElementSubtype =
|
||||||
export interface Position {
|
export interface Position {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
z?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Size {
|
export interface Size {
|
||||||
|
|
@ -256,7 +255,7 @@ export interface ChartDataset {
|
||||||
|
|
||||||
// 리스트 위젯 설정
|
// 리스트 위젯 설정
|
||||||
export interface ListWidgetConfig {
|
export interface ListWidgetConfig {
|
||||||
columnMode?: "auto" | "manual"; // [Deprecated] 더 이상 사용하지 않음 (하위 호환성을 위해 유지)
|
columnMode: "auto" | "manual"; // 컬럼 설정 방식 (자동 or 수동)
|
||||||
viewMode: "table" | "card"; // 뷰 모드 (테이블 or 카드) (기본: table)
|
viewMode: "table" | "card"; // 뷰 모드 (테이블 or 카드) (기본: table)
|
||||||
columns: ListColumn[]; // 컬럼 정의
|
columns: ListColumn[]; // 컬럼 정의
|
||||||
pageSize: number; // 페이지당 행 수 (기본: 10)
|
pageSize: number; // 페이지당 행 수 (기본: 10)
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,14 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { DashboardElement, QueryResult, ListColumn } from "../types";
|
import { DashboardElement, QueryResult, ListWidgetConfig } from "../types";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
|
|
||||||
interface ListWidgetProps {
|
interface ListWidgetProps {
|
||||||
element: DashboardElement;
|
element: DashboardElement;
|
||||||
|
onConfigUpdate?: (config: Partial<DashboardElement>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -16,7 +17,7 @@ interface ListWidgetProps {
|
||||||
* - 테이블 형태로 데이터 표시
|
* - 테이블 형태로 데이터 표시
|
||||||
* - 페이지네이션, 정렬, 검색 기능
|
* - 페이지네이션, 정렬, 검색 기능
|
||||||
*/
|
*/
|
||||||
export function ListWidget({ element }: ListWidgetProps) {
|
export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) {
|
||||||
const [data, setData] = useState<QueryResult | null>(null);
|
const [data, setData] = useState<QueryResult | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -52,7 +53,7 @@ export function ListWidget({ element }: ListWidgetProps) {
|
||||||
if (element.dataSource.queryParams) {
|
if (element.dataSource.queryParams) {
|
||||||
Object.entries(element.dataSource.queryParams).forEach(([key, value]) => {
|
Object.entries(element.dataSource.queryParams).forEach(([key, value]) => {
|
||||||
if (key && value) {
|
if (key && value) {
|
||||||
params.append(key, String(value));
|
params.append(key, value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -113,19 +114,13 @@ export function ListWidget({ element }: ListWidgetProps) {
|
||||||
parseInt(element.dataSource.externalConnectionId),
|
parseInt(element.dataSource.externalConnectionId),
|
||||||
element.dataSource.query,
|
element.dataSource.query,
|
||||||
);
|
);
|
||||||
if (!externalResult.success || !externalResult.data) {
|
if (!externalResult.success) {
|
||||||
throw new Error(externalResult.message || "외부 DB 쿼리 실행 실패");
|
throw new Error(externalResult.message || "외부 DB 쿼리 실행 실패");
|
||||||
}
|
}
|
||||||
|
|
||||||
const resultData = externalResult.data as unknown as {
|
|
||||||
columns: string[];
|
|
||||||
rows: Record<string, unknown>[];
|
|
||||||
rowCount: number;
|
|
||||||
};
|
|
||||||
queryResult = {
|
queryResult = {
|
||||||
columns: resultData.columns,
|
columns: externalResult.data.columns,
|
||||||
rows: resultData.rows,
|
rows: externalResult.data.rows,
|
||||||
totalRows: resultData.rowCount,
|
totalRows: externalResult.data.rowCount,
|
||||||
executionTime: 0,
|
executionTime: 0,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -159,7 +154,13 @@ export function ListWidget({ element }: ListWidgetProps) {
|
||||||
const interval = setInterval(loadData, refreshInterval);
|
const interval = setInterval(loadData, refreshInterval);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}
|
}
|
||||||
}, [element.dataSource]);
|
}, [
|
||||||
|
element.dataSource?.query,
|
||||||
|
element.dataSource?.connectionType,
|
||||||
|
element.dataSource?.externalConnectionId,
|
||||||
|
element.dataSource?.endpoint,
|
||||||
|
element.dataSource?.refreshInterval,
|
||||||
|
]);
|
||||||
|
|
||||||
// 로딩 중
|
// 로딩 중
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
|
@ -191,22 +192,23 @@ export function ListWidget({ element }: ListWidgetProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center gap-4 p-4">
|
<div className="flex h-full w-full flex-col items-center justify-center gap-4 p-4">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mt-1 text-xs text-gray-500">데이터와 컬럼을 설정해주세요</div>
|
<div className="mb-2 text-4xl">📋</div>
|
||||||
|
<div className="text-sm font-medium text-gray-700">리스트를 설정하세요</div>
|
||||||
|
<div className="mt-1 text-xs text-gray-500">⚙️ 버튼을 클릭하여 데이터 소스와 컬럼을 설정해주세요</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 컬럼 설정이 없으면 자동으로 모든 컬럼 표시
|
// 컬럼 설정이 없으면 자동으로 모든 컬럼 표시
|
||||||
const displayColumns: ListColumn[] =
|
const displayColumns =
|
||||||
config.columns.length > 0
|
config.columns.length > 0
|
||||||
? config.columns
|
? config.columns
|
||||||
: data.columns.map((col) => ({
|
: data.columns.map((col) => ({
|
||||||
id: col,
|
id: col,
|
||||||
label: col,
|
name: col,
|
||||||
field: col,
|
dataKey: col,
|
||||||
visible: true,
|
visible: true,
|
||||||
align: "left" as const,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 페이지네이션
|
// 페이지네이션
|
||||||
|
|
@ -237,7 +239,7 @@ export function ListWidget({ element }: ListWidgetProps) {
|
||||||
className={col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}
|
className={col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}
|
||||||
style={{ width: col.width ? `${col.width}px` : undefined }}
|
style={{ width: col.width ? `${col.width}px` : undefined }}
|
||||||
>
|
>
|
||||||
{col.label}
|
{col.label || col.name}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -263,7 +265,7 @@ export function ListWidget({ element }: ListWidgetProps) {
|
||||||
key={col.id}
|
key={col.id}
|
||||||
className={col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}
|
className={col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}
|
||||||
>
|
>
|
||||||
{String(row[col.field] ?? "")}
|
{String(row[col.dataKey || col.field] ?? "")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
@ -293,11 +295,11 @@ export function ListWidget({ element }: ListWidgetProps) {
|
||||||
.filter((col) => col.visible)
|
.filter((col) => col.visible)
|
||||||
.map((col) => (
|
.map((col) => (
|
||||||
<div key={col.id}>
|
<div key={col.id}>
|
||||||
<div className="text-xs font-medium text-gray-500">{col.label}</div>
|
<div className="text-xs font-medium text-gray-500">{col.label || col.name}</div>
|
||||||
<div
|
<div
|
||||||
className={`font-medium text-gray-900 ${col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}`}
|
className={`font-medium text-gray-900 ${col.align === "center" ? "text-center" : col.align === "right" ? "text-right" : ""}`}
|
||||||
>
|
>
|
||||||
{String(row[col.field] ?? "")}
|
{String(row[col.dataKey || col.field] ?? "")}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1,265 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState, useCallback, useEffect } from "react";
|
|
||||||
import { DashboardElement, ChartDataSource, QueryResult, ListWidgetConfig } from "../types";
|
|
||||||
import { X } from "lucide-react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { DatabaseConfig } from "../data-sources/DatabaseConfig";
|
|
||||||
import { ApiConfig } from "../data-sources/ApiConfig";
|
|
||||||
import { QueryEditor } from "../QueryEditor";
|
|
||||||
import { UnifiedColumnEditor } from "./list-widget/UnifiedColumnEditor";
|
|
||||||
import { ListTableOptions } from "./list-widget/ListTableOptions";
|
|
||||||
|
|
||||||
interface ListWidgetConfigSidebarProps {
|
|
||||||
element: DashboardElement;
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onApply: (element: DashboardElement) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 리스트 위젯 설정 사이드바
|
|
||||||
*/
|
|
||||||
export function ListWidgetConfigSidebar({ element, isOpen, onClose, onApply }: ListWidgetConfigSidebarProps) {
|
|
||||||
const [title, setTitle] = useState(element.title || "📋 리스트");
|
|
||||||
const [dataSource, setDataSource] = useState<ChartDataSource>(
|
|
||||||
element.dataSource || { type: "database", connectionType: "current", refreshInterval: 0 },
|
|
||||||
);
|
|
||||||
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
|
||||||
const [listConfig, setListConfig] = useState<ListWidgetConfig>(
|
|
||||||
element.listConfig || {
|
|
||||||
viewMode: "table",
|
|
||||||
columns: [],
|
|
||||||
pageSize: 10,
|
|
||||||
enablePagination: true,
|
|
||||||
showHeader: true,
|
|
||||||
stripedRows: true,
|
|
||||||
compactMode: false,
|
|
||||||
cardColumns: 3,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// 사이드바 열릴 때 초기화
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
setTitle(element.title || "📋 리스트");
|
|
||||||
if (element.dataSource) {
|
|
||||||
setDataSource(element.dataSource);
|
|
||||||
}
|
|
||||||
if (element.listConfig) {
|
|
||||||
setListConfig(element.listConfig);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [isOpen, element]);
|
|
||||||
|
|
||||||
// Esc 키로 닫기
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isOpen) return;
|
|
||||||
|
|
||||||
const handleEsc = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("keydown", handleEsc);
|
|
||||||
return () => window.removeEventListener("keydown", handleEsc);
|
|
||||||
}, [isOpen, onClose]);
|
|
||||||
|
|
||||||
// 데이터 소스 타입 변경
|
|
||||||
const handleDataSourceTypeChange = useCallback((type: "database" | "api") => {
|
|
||||||
if (type === "database") {
|
|
||||||
setDataSource({
|
|
||||||
type: "database",
|
|
||||||
connectionType: "current",
|
|
||||||
refreshInterval: 0,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setDataSource({
|
|
||||||
type: "api",
|
|
||||||
method: "GET",
|
|
||||||
refreshInterval: 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
setQueryResult(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 데이터 소스 업데이트
|
|
||||||
const handleDataSourceUpdate = useCallback((updates: Partial<ChartDataSource>) => {
|
|
||||||
setDataSource((prev) => ({ ...prev, ...updates }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 쿼리 실행 결과 처리
|
|
||||||
const handleQueryTest = useCallback((result: QueryResult) => {
|
|
||||||
setQueryResult(result);
|
|
||||||
|
|
||||||
// 쿼리 결과의 컬럼을 자동으로 listConfig.columns에 추가 (기존 컬럼은 유지)
|
|
||||||
setListConfig((prev) => {
|
|
||||||
const existingFields = prev.columns.map((col) => col.field);
|
|
||||||
const newColumns = result.columns
|
|
||||||
.filter((col) => !existingFields.includes(col))
|
|
||||||
.map((col, idx) => ({
|
|
||||||
id: `col_${Date.now()}_${idx}`,
|
|
||||||
field: col,
|
|
||||||
label: col,
|
|
||||||
visible: true,
|
|
||||||
align: "left" as const,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
columns: [...prev.columns, ...newColumns],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 컬럼 설정 변경
|
|
||||||
const handleListConfigChange = useCallback((updates: Partial<ListWidgetConfig>) => {
|
|
||||||
setListConfig((prev) => ({ ...prev, ...updates }));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 적용
|
|
||||||
const handleApply = useCallback(() => {
|
|
||||||
const updatedElement: DashboardElement = {
|
|
||||||
...element,
|
|
||||||
title,
|
|
||||||
dataSource,
|
|
||||||
listConfig,
|
|
||||||
};
|
|
||||||
|
|
||||||
onApply(updatedElement);
|
|
||||||
}, [element, title, dataSource, listConfig, onApply]);
|
|
||||||
|
|
||||||
// 저장 가능 여부
|
|
||||||
const canApply = listConfig.columns.length > 0 && listConfig.columns.some((col) => col.visible && col.field);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-80 flex-col bg-gray-50 transition-transform duration-300 ease-in-out",
|
|
||||||
isOpen ? "translate-x-0" : "translate-x-[-100%]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="flex items-center justify-between bg-white px-3 py-2 shadow-sm">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="bg-primary/10 flex h-6 w-6 items-center justify-center rounded">
|
|
||||||
<span className="text-primary text-xs font-bold">📋</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs font-semibold text-gray-900">리스트 위젯 설정</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-gray-100"
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5 text-gray-500" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 본문: 스크롤 가능 영역 */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-3">
|
|
||||||
{/* 기본 설정 */}
|
|
||||||
<div className="mb-3 rounded-lg bg-white p-3 shadow-sm">
|
|
||||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">기본 설정</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
|
||||||
placeholder="리스트 이름"
|
|
||||||
className="focus:border-primary focus:ring-primary/20 h-8 w-full rounded border border-gray-200 bg-gray-50 px-2 text-xs placeholder:text-gray-400 focus:bg-white focus:ring-1 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 데이터 소스 */}
|
|
||||||
<div className="rounded-lg bg-white p-3 shadow-sm">
|
|
||||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">데이터 소스</div>
|
|
||||||
|
|
||||||
<Tabs
|
|
||||||
defaultValue={dataSource.type}
|
|
||||||
onValueChange={(value) => handleDataSourceTypeChange(value as "database" | "api")}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
<TabsList className="grid h-7 w-full grid-cols-2 bg-gray-100 p-0.5">
|
|
||||||
<TabsTrigger
|
|
||||||
value="database"
|
|
||||||
className="h-6 rounded text-[11px] data-[state=active]:bg-white data-[state=active]:shadow-sm"
|
|
||||||
>
|
|
||||||
데이터베이스
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger
|
|
||||||
value="api"
|
|
||||||
className="h-6 rounded text-[11px] data-[state=active]:bg-white data-[state=active]:shadow-sm"
|
|
||||||
>
|
|
||||||
REST API
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="database" className="mt-2 space-y-2">
|
|
||||||
<DatabaseConfig dataSource={dataSource} onChange={handleDataSourceUpdate} />
|
|
||||||
<QueryEditor
|
|
||||||
dataSource={dataSource}
|
|
||||||
onDataSourceChange={handleDataSourceUpdate}
|
|
||||||
onQueryTest={handleQueryTest}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="api" className="mt-2 space-y-2">
|
|
||||||
<ApiConfig dataSource={dataSource} onChange={handleDataSourceUpdate} onTestResult={handleQueryTest} />
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
{/* 데이터 로드 상태 */}
|
|
||||||
{queryResult && (
|
|
||||||
<div className="mt-2 flex items-center gap-1.5 rounded bg-green-50 px-2 py-1">
|
|
||||||
<div className="h-1.5 w-1.5 rounded-full bg-green-500" />
|
|
||||||
<span className="text-[10px] font-medium text-green-700">{queryResult.rows.length}개 데이터 로드됨</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 컬럼 설정 - 쿼리 실행 후에만 표시 */}
|
|
||||||
{queryResult && (
|
|
||||||
<div className="mt-3 rounded-lg bg-white p-3 shadow-sm">
|
|
||||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">컬럼 설정</div>
|
|
||||||
<UnifiedColumnEditor
|
|
||||||
queryResult={queryResult}
|
|
||||||
config={listConfig}
|
|
||||||
onConfigChange={handleListConfigChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 테이블 옵션 - 컬럼이 있을 때만 표시 */}
|
|
||||||
{listConfig.columns.length > 0 && (
|
|
||||||
<div className="mt-3 rounded-lg bg-white p-3 shadow-sm">
|
|
||||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">테이블 옵션</div>
|
|
||||||
<ListTableOptions config={listConfig} onConfigChange={handleListConfigChange} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 푸터: 적용 버튼 */}
|
|
||||||
<div className="flex gap-2 bg-white p-2 shadow-[0_-2px_8px_rgba(0,0,0,0.05)]">
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="flex-1 rounded bg-gray-100 py-2 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-200"
|
|
||||||
>
|
|
||||||
취소
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleApply}
|
|
||||||
disabled={!canApply}
|
|
||||||
className="bg-primary hover:bg-primary/90 flex-1 rounded py-2 text-xs font-medium text-white transition-colors disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
>
|
|
||||||
적용
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
||||||
import { DashboardElement } from "../types";
|
|
||||||
import { X } from "lucide-react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface YardWidgetConfigSidebarProps {
|
|
||||||
element: DashboardElement;
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onApply: (updates: Partial<DashboardElement>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function YardWidgetConfigSidebar({ element, isOpen, onClose, onApply }: YardWidgetConfigSidebarProps) {
|
|
||||||
const [customTitle, setCustomTitle] = useState(element.customTitle || "");
|
|
||||||
const [showHeader, setShowHeader] = useState(element.showHeader !== false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
setCustomTitle(element.customTitle || "");
|
|
||||||
setShowHeader(element.showHeader !== false);
|
|
||||||
}
|
|
||||||
}, [isOpen, element]);
|
|
||||||
|
|
||||||
const handleApply = () => {
|
|
||||||
onApply({
|
|
||||||
customTitle,
|
|
||||||
showHeader,
|
|
||||||
});
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"fixed top-14 left-0 z-[100] flex h-[calc(100vh-3.5rem)] w-80 flex-col bg-gray-50 transition-transform duration-300 ease-in-out",
|
|
||||||
isOpen ? "translate-x-0" : "translate-x-[-100%]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="flex items-center justify-between bg-white px-3 py-2 shadow-sm">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="bg-primary/10 flex h-6 w-6 items-center justify-center rounded">
|
|
||||||
<span className="text-primary text-xs font-bold">🏗️</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs font-semibold text-gray-900">야드 관리 위젯 설정</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="flex h-6 w-6 items-center justify-center rounded transition-colors hover:bg-gray-100"
|
|
||||||
>
|
|
||||||
<X className="h-3.5 w-3.5 text-gray-500" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 컨텐츠 */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-3">
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* 위젯 제목 */}
|
|
||||||
<div className="rounded-lg bg-white p-3 shadow-sm">
|
|
||||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">위젯 제목</div>
|
|
||||||
<Input
|
|
||||||
value={customTitle}
|
|
||||||
onChange={(e) => setCustomTitle(e.target.value)}
|
|
||||||
placeholder="제목을 입력하세요 (비워두면 기본 제목 사용)"
|
|
||||||
className="h-8 text-xs"
|
|
||||||
style={{ fontSize: "12px" }}
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-[10px] text-gray-500">기본 제목: 야드 관리 3D</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 헤더 표시 */}
|
|
||||||
<div className="rounded-lg bg-white p-3 shadow-sm">
|
|
||||||
<div className="mb-2 text-[10px] font-semibold tracking-wide text-gray-500 uppercase">헤더 표시</div>
|
|
||||||
<RadioGroup
|
|
||||||
value={showHeader ? "show" : "hide"}
|
|
||||||
onValueChange={(value) => setShowHeader(value === "show")}
|
|
||||||
className="flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<RadioGroupItem value="show" id="header-show" className="h-3 w-3" />
|
|
||||||
<Label htmlFor="header-show" className="cursor-pointer text-[11px] font-normal">
|
|
||||||
표시
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<RadioGroupItem value="hide" id="header-hide" className="h-3 w-3" />
|
|
||||||
<Label htmlFor="header-hide" className="cursor-pointer text-[11px] font-normal">
|
|
||||||
숨김
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</RadioGroup>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 푸터 */}
|
|
||||||
<div className="flex gap-2 bg-white p-2 shadow-[0_-2px_8px_rgba(0,0,0,0.05)]">
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="flex-1 rounded bg-gray-100 py-2 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-200"
|
|
||||||
>
|
|
||||||
취소
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleApply}
|
|
||||||
className="bg-primary hover:bg-primary/90 flex-1 rounded py-2 text-xs font-medium text-white transition-colors"
|
|
||||||
>
|
|
||||||
적용
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +1,19 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { ListColumn, QueryResult, ListWidgetConfig } from "../../types";
|
import { ListColumn } from "../../types";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { GripVertical } from "lucide-react";
|
import { GripVertical } from "lucide-react";
|
||||||
|
|
||||||
interface ColumnSelectorProps {
|
interface ColumnSelectorProps {
|
||||||
queryResult: QueryResult;
|
availableColumns: string[];
|
||||||
config: ListWidgetConfig;
|
selectedColumns: ListColumn[];
|
||||||
onConfigChange: (updates: Partial<ListWidgetConfig>) => void;
|
sampleData: Record<string, any>;
|
||||||
|
onChange: (columns: ListColumn[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -20,18 +23,15 @@ interface ColumnSelectorProps {
|
||||||
* - 정렬, 너비, 정렬 방향 설정
|
* - 정렬, 너비, 정렬 방향 설정
|
||||||
* - 드래그 앤 드롭으로 순서 변경
|
* - 드래그 앤 드롭으로 순서 변경
|
||||||
*/
|
*/
|
||||||
export function ColumnSelector({ queryResult, config, onConfigChange }: ColumnSelectorProps) {
|
export function ColumnSelector({ availableColumns, selectedColumns, sampleData, onChange }: ColumnSelectorProps) {
|
||||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||||
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||||
const availableColumns = queryResult.columns;
|
|
||||||
const selectedColumns = config.columns || [];
|
|
||||||
const sampleData = queryResult.rows[0] || {};
|
|
||||||
|
|
||||||
// 컬럼 선택/해제
|
// 컬럼 선택/해제
|
||||||
const handleToggle = (field: string) => {
|
const handleToggle = (field: string) => {
|
||||||
const exists = selectedColumns.find((col) => col.field === field);
|
const exists = selectedColumns.find((col) => col.field === field);
|
||||||
if (exists) {
|
if (exists) {
|
||||||
onConfigChange({ columns: selectedColumns.filter((col) => col.field !== field) });
|
onChange(selectedColumns.filter((col) => col.field !== field));
|
||||||
} else {
|
} else {
|
||||||
const newCol: ListColumn = {
|
const newCol: ListColumn = {
|
||||||
id: `col_${selectedColumns.length}`,
|
id: `col_${selectedColumns.length}`,
|
||||||
|
|
@ -40,22 +40,18 @@ export function ColumnSelector({ queryResult, config, onConfigChange }: ColumnSe
|
||||||
align: "left",
|
align: "left",
|
||||||
visible: true,
|
visible: true,
|
||||||
};
|
};
|
||||||
onConfigChange({ columns: [...selectedColumns, newCol] });
|
onChange([...selectedColumns, newCol]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 컬럼 라벨 변경
|
// 컬럼 라벨 변경
|
||||||
const handleLabelChange = (field: string, label: string) => {
|
const handleLabelChange = (field: string, label: string) => {
|
||||||
onConfigChange({
|
onChange(selectedColumns.map((col) => (col.field === field ? { ...col, label } : col)));
|
||||||
columns: selectedColumns.map((col) => (col.field === field ? { ...col, label } : col)),
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 정렬 방향 변경
|
// 정렬 방향 변경
|
||||||
const handleAlignChange = (field: string, align: "left" | "center" | "right") => {
|
const handleAlignChange = (field: string, align: "left" | "center" | "right") => {
|
||||||
onConfigChange({
|
onChange(selectedColumns.map((col) => (col.field === field ? { ...col, align } : col)));
|
||||||
columns: selectedColumns.map((col) => (col.field === field ? { ...col, align } : col)),
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 드래그 시작
|
// 드래그 시작
|
||||||
|
|
@ -68,29 +64,40 @@ export function ColumnSelector({ queryResult, config, onConfigChange }: ColumnSe
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (draggedIndex === null || draggedIndex === hoverIndex) return;
|
if (draggedIndex === null || draggedIndex === hoverIndex) return;
|
||||||
|
|
||||||
|
setDragOverIndex(hoverIndex);
|
||||||
|
|
||||||
const newColumns = [...selectedColumns];
|
const newColumns = [...selectedColumns];
|
||||||
const draggedItem = newColumns[draggedIndex];
|
const draggedItem = newColumns[draggedIndex];
|
||||||
newColumns.splice(draggedIndex, 1);
|
newColumns.splice(draggedIndex, 1);
|
||||||
newColumns.splice(hoverIndex, 0, draggedItem);
|
newColumns.splice(hoverIndex, 0, draggedItem);
|
||||||
|
|
||||||
setDraggedIndex(hoverIndex);
|
setDraggedIndex(hoverIndex);
|
||||||
onConfigChange({ columns: newColumns });
|
onChange(newColumns);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 드롭
|
// 드롭
|
||||||
const handleDrop = (e: React.DragEvent) => {
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setDraggedIndex(null);
|
setDraggedIndex(null);
|
||||||
|
setDragOverIndex(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 드래그 종료
|
// 드래그 종료
|
||||||
const handleDragEnd = () => {
|
const handleDragEnd = () => {
|
||||||
setDraggedIndex(null);
|
setDraggedIndex(null);
|
||||||
|
setDragOverIndex(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<Card className="p-4">
|
||||||
<div className="space-y-1.5">
|
<div className="mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800">컬럼 선택 및 설정</h3>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
표시할 컬럼을 선택하고 이름을 변경하세요. 드래그하여 순서를 변경할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
{/* 선택된 컬럼을 먼저 순서대로 표시 */}
|
{/* 선택된 컬럼을 먼저 순서대로 표시 */}
|
||||||
{selectedColumns.map((selectedCol, columnIndex) => {
|
{selectedColumns.map((selectedCol, columnIndex) => {
|
||||||
const field = selectedCol.field;
|
const field = selectedCol.field;
|
||||||
|
|
@ -120,76 +127,54 @@ export function ColumnSelector({ queryResult, config, onConfigChange }: ColumnSe
|
||||||
handleDragEnd();
|
handleDragEnd();
|
||||||
e.currentTarget.style.cursor = "grab";
|
e.currentTarget.style.cursor = "grab";
|
||||||
}}
|
}}
|
||||||
className={`group relative rounded-md border transition-all ${
|
className={`rounded-lg border p-4 transition-all ${
|
||||||
isSelected
|
isSelected ? "border-blue-300 bg-blue-50" : "border-gray-200"
|
||||||
? "border-primary/40 bg-primary/5 shadow-sm"
|
|
||||||
: "border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm"
|
|
||||||
} ${isDraggable ? "cursor-grab active:cursor-grabbing" : ""} ${
|
} ${isDraggable ? "cursor-grab active:cursor-grabbing" : ""} ${
|
||||||
draggedIndex === columnIndex ? "scale-95 opacity-50" : ""
|
draggedIndex === columnIndex ? "opacity-50" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* 헤더 */}
|
<div className="mb-3 flex items-start gap-3">
|
||||||
<div className="flex items-center gap-2 px-2.5 py-2">
|
<Checkbox checked={isSelected} onCheckedChange={() => handleToggle(field)} className="mt-1" />
|
||||||
<Checkbox
|
<div className="flex-1">
|
||||||
checked={isSelected}
|
<div className="flex items-center gap-2">
|
||||||
onCheckedChange={() => handleToggle(field)}
|
<GripVertical className={`h-4 w-4 ${isDraggable ? "text-blue-500" : "text-gray-400"}`} />
|
||||||
className="h-3.5 w-3.5 shrink-0"
|
<span className="font-medium text-gray-700">{field}</span>
|
||||||
/>
|
{previewText && <span className="text-xs text-gray-500">(예: {previewText})</span>}
|
||||||
<GripVertical
|
|
||||||
className={`h-3.5 w-3.5 shrink-0 transition-colors ${
|
|
||||||
isDraggable ? "group-hover:text-primary text-gray-400" : "text-gray-300"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-baseline gap-1.5">
|
|
||||||
<span className="truncate text-[11px] font-medium text-gray-900">{field}</span>
|
|
||||||
{previewText && <span className="shrink-0 text-[9px] text-gray-400">예: {previewText}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 설정 영역 */}
|
|
||||||
{isSelected && selectedCol && (
|
{isSelected && selectedCol && (
|
||||||
<div className="border-t border-gray-100 bg-gray-50/50 px-2.5 py-1.5">
|
<div className="ml-7 grid grid-cols-2 gap-3">
|
||||||
<div className="grid grid-cols-2 gap-1.5">
|
{/* 컬럼명 */}
|
||||||
{/* 표시 이름 */}
|
<div>
|
||||||
<div className="min-w-0">
|
<Label className="text-xs">표시 이름</Label>
|
||||||
<Input
|
<Input
|
||||||
value={selectedCol.label}
|
value={selectedCol.label}
|
||||||
onChange={(e) => handleLabelChange(field, e.target.value)}
|
onChange={(e) => handleLabelChange(field, e.target.value)}
|
||||||
placeholder="표시 이름"
|
placeholder="컬럼명"
|
||||||
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-gray-200 bg-white px-1.5 text-[10px] placeholder:text-gray-400 focus:ring-1"
|
className="mt-1"
|
||||||
style={{ fontSize: "10px" }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 정렬 */}
|
{/* 정렬 방향 */}
|
||||||
<div className="min-w-0">
|
<div>
|
||||||
|
<Label className="text-xs">정렬</Label>
|
||||||
<Select
|
<Select
|
||||||
value={selectedCol.align}
|
value={selectedCol.align}
|
||||||
onValueChange={(value: "left" | "center" | "right") => handleAlignChange(field, value)}
|
onValueChange={(value: "left" | "center" | "right") => handleAlignChange(field, value)}
|
||||||
>
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger className="mt-1">
|
||||||
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-gray-200 bg-white px-1.5 text-[10px] focus:ring-1 [&>span]:leading-none [&>svg]:h-3 [&>svg]:w-3"
|
|
||||||
style={{ fontSize: "10px", height: "24px", minHeight: "24px" }}
|
|
||||||
>
|
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="min-w-[4rem]">
|
<SelectContent>
|
||||||
<SelectItem value="left" className="py-1 text-[10px]">
|
<SelectItem value="left">왼쪽</SelectItem>
|
||||||
왼쪽
|
<SelectItem value="center">가운데</SelectItem>
|
||||||
</SelectItem>
|
<SelectItem value="right">오른쪽</SelectItem>
|
||||||
<SelectItem value="center" className="py-1 text-[10px]">
|
|
||||||
가운데
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="right" className="py-1 text-[10px]">
|
|
||||||
오른쪽
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -206,23 +191,18 @@ export function ColumnSelector({ queryResult, config, onConfigChange }: ColumnSe
|
||||||
? JSON.stringify(preview).substring(0, 30)
|
? JSON.stringify(preview).substring(0, 30)
|
||||||
: String(preview).substring(0, 30)
|
: String(preview).substring(0, 30)
|
||||||
: "";
|
: "";
|
||||||
|
const isSelected = false;
|
||||||
|
const isDraggable = false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={field} className={`rounded-lg border border-gray-200 p-4 transition-all`}>
|
||||||
key={field}
|
<div className="mb-3 flex items-start gap-3">
|
||||||
className="group rounded-md border border-gray-200 bg-white transition-all hover:border-gray-300 hover:shadow-sm"
|
<Checkbox checked={false} onCheckedChange={() => handleToggle(field)} className="mt-1" />
|
||||||
>
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2 px-2.5 py-2">
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox
|
<GripVertical className="h-4 w-4 text-gray-400" />
|
||||||
checked={false}
|
<span className="font-medium text-gray-700">{field}</span>
|
||||||
onCheckedChange={() => handleToggle(field)}
|
{previewText && <span className="text-xs text-gray-500">(예: {previewText})</span>}
|
||||||
className="h-3.5 w-3.5 shrink-0"
|
|
||||||
/>
|
|
||||||
<GripVertical className="h-3.5 w-3.5 shrink-0 text-gray-300" />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-baseline gap-1.5">
|
|
||||||
<span className="truncate text-[11px] font-medium text-gray-600">{field}</span>
|
|
||||||
{previewText && <span className="shrink-0 text-[9px] text-gray-400">예: {previewText}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -232,11 +212,10 @@ export function ColumnSelector({ queryResult, config, onConfigChange }: ColumnSe
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedColumns.length === 0 && (
|
{selectedColumns.length === 0 && (
|
||||||
<div className="mt-3 flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50/50 px-3 py-2">
|
<div className="mt-4 rounded-lg border border-yellow-300 bg-yellow-50 p-3 text-center text-sm text-yellow-700">
|
||||||
<span className="text-amber-600">⚠️</span>
|
⚠️ 최소 1개 이상의 컬럼을 선택해주세요
|
||||||
<span className="text-[10px] text-amber-700">최소 1개 이상의 컬럼을 선택해주세요</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,42 +2,70 @@
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { ListWidgetConfig } from "../../types";
|
import { ListWidgetConfig } from "../../types";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
|
||||||
interface ListTableOptionsProps {
|
interface ListTableOptionsProps {
|
||||||
config: ListWidgetConfig;
|
config: ListWidgetConfig;
|
||||||
onConfigChange: (updates: Partial<ListWidgetConfig>) => void;
|
onChange: (updates: Partial<ListWidgetConfig>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 리스트 테이블 옵션 설정 컴포넌트
|
* 리스트 테이블 옵션 설정 컴포넌트
|
||||||
* - 페이지 크기, 검색, 정렬 등 설정
|
* - 페이지 크기, 검색, 정렬 등 설정
|
||||||
*/
|
*/
|
||||||
export function ListTableOptions({ config, onConfigChange }: ListTableOptionsProps) {
|
export function ListTableOptions({ config, onChange }: ListTableOptionsProps) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<Card className="p-4">
|
||||||
<div className="space-y-3">
|
<div className="mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-800">테이블 옵션</h3>
|
||||||
|
<p className="text-sm text-gray-600">테이블 동작과 스타일을 설정하세요</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
{/* 뷰 모드 */}
|
{/* 뷰 모드 */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1 block text-[9px] font-medium text-gray-600">뷰 모드</Label>
|
<Label className="mb-2 block text-sm font-medium">뷰 모드</Label>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={config.viewMode}
|
value={config.viewMode}
|
||||||
onValueChange={(value: "table" | "card") => onConfigChange({ viewMode: value })}
|
onValueChange={(value: "table" | "card") => onChange({ viewMode: value })}
|
||||||
className="flex items-center gap-3"
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="table" id="table" className="h-3 w-3" />
|
<RadioGroupItem value="table" id="table" />
|
||||||
<Label htmlFor="table" className="cursor-pointer text-[11px] font-normal">
|
<Label htmlFor="table" className="cursor-pointer font-normal">
|
||||||
테이블
|
📊 테이블 (기본)
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="card" id="card" className="h-3 w-3" />
|
<RadioGroupItem value="card" id="card" />
|
||||||
<Label htmlFor="card" className="cursor-pointer text-[11px] font-normal">
|
<Label htmlFor="card" className="cursor-pointer font-normal">
|
||||||
카드
|
🗂️ 카드
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 컬럼 모드 */}
|
||||||
|
<div>
|
||||||
|
<Label className="mb-2 block text-sm font-medium">컬럼 설정 방식</Label>
|
||||||
|
<RadioGroup
|
||||||
|
value={config.columnMode}
|
||||||
|
onValueChange={(value: "auto" | "manual") => onChange({ columnMode: value })}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="auto" id="auto" />
|
||||||
|
<Label htmlFor="auto" className="cursor-pointer font-normal">
|
||||||
|
자동 (쿼리 결과에서 선택)
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="manual" id="manual" />
|
||||||
|
<Label htmlFor="manual" className="cursor-pointer font-normal">
|
||||||
|
수동 (직접 추가 및 매핑)
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
|
|
@ -46,122 +74,94 @@ export function ListTableOptions({ config, onConfigChange }: ListTableOptionsPro
|
||||||
{/* 카드 뷰 컬럼 수 */}
|
{/* 카드 뷰 컬럼 수 */}
|
||||||
{config.viewMode === "card" && (
|
{config.viewMode === "card" && (
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1 block text-[9px] font-medium text-gray-600">카드 컬럼 수</Label>
|
<Label className="mb-2 block text-sm font-medium">카드 컬럼 수</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
max="6"
|
max="6"
|
||||||
value={config.cardColumns || 3}
|
value={config.cardColumns || 3}
|
||||||
onChange={(e) => onConfigChange({ cardColumns: parseInt(e.target.value) || 3 })}
|
onChange={(e) => onChange({ cardColumns: parseInt(e.target.value) || 3 })}
|
||||||
className="h-6 w-full px-1.5 text-[11px]"
|
className="w-full"
|
||||||
/>
|
/>
|
||||||
<p className="mt-0.5 text-[9px] text-gray-500">한 줄에 표시할 카드 개수 (1-6)</p>
|
<p className="mt-1 text-xs text-gray-500">한 줄에 표시할 카드 개수 (1-6)</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 페이지 크기 */}
|
{/* 페이지 크기 */}
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1 block text-[9px] font-medium text-gray-600">페이지당 행 수</Label>
|
<Label className="mb-2 block text-sm font-medium">페이지당 행 수</Label>
|
||||||
<Select
|
<Select value={String(config.pageSize)} onValueChange={(value) => onChange({ pageSize: parseInt(value) })}>
|
||||||
value={String(config.pageSize)}
|
<SelectTrigger>
|
||||||
onValueChange={(value) => onConfigChange({ pageSize: parseInt(value) })}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-6 px-1.5 text-[11px]">
|
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="5" className="text-[11px]">
|
<SelectItem value="5">5개</SelectItem>
|
||||||
5개
|
<SelectItem value="10">10개</SelectItem>
|
||||||
</SelectItem>
|
<SelectItem value="20">20개</SelectItem>
|
||||||
<SelectItem value="10" className="text-[11px]">
|
<SelectItem value="50">50개</SelectItem>
|
||||||
10개
|
<SelectItem value="100">100개</SelectItem>
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="20" className="text-[11px]">
|
|
||||||
20개
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="50" className="text-[11px]">
|
|
||||||
50개
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="100" className="text-[11px]">
|
|
||||||
100개
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 기능 활성화 */}
|
{/* 기능 활성화 */}
|
||||||
<div>
|
<div className="space-y-3">
|
||||||
<Label className="mb-1 block text-[9px] font-medium text-gray-600">페이지네이션</Label>
|
<Label className="text-sm font-medium">기능</Label>
|
||||||
<RadioGroup
|
<div className="space-y-2">
|
||||||
value={config.enablePagination ? "enabled" : "disabled"}
|
<div className="flex items-center gap-2">
|
||||||
onValueChange={(value) => onConfigChange({ enablePagination: value === "enabled" })}
|
<Checkbox
|
||||||
className="flex items-center gap-3"
|
id="enablePagination"
|
||||||
>
|
checked={config.enablePagination}
|
||||||
<div className="flex items-center gap-1.5">
|
onCheckedChange={(checked) => onChange({ enablePagination: checked as boolean })}
|
||||||
<RadioGroupItem value="enabled" id="pagination-enabled" className="h-3 w-3" />
|
/>
|
||||||
<Label htmlFor="pagination-enabled" className="cursor-pointer text-[11px] font-normal">
|
<Label htmlFor="enablePagination" className="cursor-pointer font-normal">
|
||||||
사용
|
페이지네이션
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<RadioGroupItem value="disabled" id="pagination-disabled" className="h-3 w-3" />
|
|
||||||
<Label htmlFor="pagination-disabled" className="cursor-pointer text-[11px] font-normal">
|
|
||||||
사용 안 함
|
|
||||||
</Label>
|
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 헤더 표시 */}
|
{/* 스타일 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">스타일</Label>
|
||||||
|
<div className="space-y-2">
|
||||||
{config.viewMode === "table" && (
|
{config.viewMode === "table" && (
|
||||||
<div>
|
<>
|
||||||
<Label className="mb-1 block text-[9px] font-medium text-gray-600">헤더 표시</Label>
|
<div className="flex items-center gap-2">
|
||||||
<RadioGroup
|
<Checkbox
|
||||||
value={config.showHeader ? "show" : "hide"}
|
id="showHeader"
|
||||||
onValueChange={(value) => onConfigChange({ showHeader: value === "show" })}
|
checked={config.showHeader}
|
||||||
className="flex items-center gap-3"
|
onCheckedChange={(checked) => onChange({ showHeader: checked as boolean })}
|
||||||
>
|
/>
|
||||||
<div className="flex items-center gap-1.5">
|
<Label htmlFor="showHeader" className="cursor-pointer font-normal">
|
||||||
<RadioGroupItem value="show" id="header-show" className="h-3 w-3" />
|
헤더 표시
|
||||||
<Label htmlFor="header-show" className="cursor-pointer text-[11px] font-normal">
|
|
||||||
표시
|
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-2">
|
||||||
<RadioGroupItem value="hide" id="header-hide" className="h-3 w-3" />
|
<Checkbox
|
||||||
<Label htmlFor="header-hide" className="cursor-pointer text-[11px] font-normal">
|
id="stripedRows"
|
||||||
숨김
|
checked={config.stripedRows}
|
||||||
|
onCheckedChange={(checked) => onChange({ stripedRows: checked as boolean })}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="stripedRows" className="cursor-pointer font-normal">
|
||||||
|
줄무늬 행
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup>
|
</>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
{/* 줄무늬 행 */}
|
<Checkbox
|
||||||
{config.viewMode === "table" && (
|
id="compactMode"
|
||||||
<div>
|
checked={config.compactMode}
|
||||||
<Label className="mb-1 block text-[9px] font-medium text-gray-600">줄무늬 행</Label>
|
onCheckedChange={(checked) => onChange({ compactMode: checked as boolean })}
|
||||||
<RadioGroup
|
/>
|
||||||
value={config.stripedRows ? "enabled" : "disabled"}
|
<Label htmlFor="compactMode" className="cursor-pointer font-normal">
|
||||||
onValueChange={(value) => onConfigChange({ stripedRows: value === "enabled" })}
|
압축 모드 (작은 크기)
|
||||||
className="flex items-center gap-3"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<RadioGroupItem value="enabled" id="striped-enabled" className="h-3 w-3" />
|
|
||||||
<Label htmlFor="striped-enabled" className="cursor-pointer text-[11px] font-normal">
|
|
||||||
사용
|
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<RadioGroupItem value="disabled" id="striped-disabled" className="h-3 w-3" />
|
|
||||||
<Label htmlFor="striped-disabled" className="cursor-pointer text-[11px] font-normal">
|
|
||||||
사용 안 함
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</RadioGroup>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,18 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { ListColumn, ListWidgetConfig } from "../../types";
|
import { ListColumn } from "../../types";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Plus, Trash2, GripVertical } from "lucide-react";
|
import { Plus, Trash2, GripVertical } from "lucide-react";
|
||||||
|
|
||||||
interface ManualColumnEditorProps {
|
interface ManualColumnEditorProps {
|
||||||
config: ListWidgetConfig;
|
availableFields: string[];
|
||||||
onConfigChange: (updates: Partial<ListWidgetConfig>) => void;
|
columns: ListColumn[];
|
||||||
|
onChange: (columns: ListColumn[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -17,30 +21,30 @@ interface ManualColumnEditorProps {
|
||||||
* - 컬럼명과 데이터 필드 직접 매핑
|
* - 컬럼명과 데이터 필드 직접 매핑
|
||||||
* - 드래그 앤 드롭으로 순서 변경
|
* - 드래그 앤 드롭으로 순서 변경
|
||||||
*/
|
*/
|
||||||
export function ManualColumnEditor({ config, onConfigChange }: ManualColumnEditorProps) {
|
export function ManualColumnEditor({ availableFields, columns, onChange }: ManualColumnEditorProps) {
|
||||||
const columns = config.columns || [];
|
|
||||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||||
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
// 새 컬럼 추가
|
// 새 컬럼 추가
|
||||||
const handleAddColumn = () => {
|
const handleAddColumn = () => {
|
||||||
const newCol: ListColumn = {
|
const newCol: ListColumn = {
|
||||||
id: `col_${Date.now()}`,
|
id: `col_${Date.now()}`,
|
||||||
label: `컬럼 ${columns.length + 1}`,
|
label: `컬럼 ${columns.length + 1}`,
|
||||||
field: "",
|
field: availableFields[0] || "",
|
||||||
align: "left",
|
align: "left",
|
||||||
visible: true,
|
visible: true,
|
||||||
};
|
};
|
||||||
onConfigChange({ columns: [...columns, newCol] });
|
onChange([...columns, newCol]);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 컬럼 삭제
|
// 컬럼 삭제
|
||||||
const handleRemove = (id: string) => {
|
const handleRemove = (id: string) => {
|
||||||
onConfigChange({ columns: columns.filter((col) => col.id !== id) });
|
onChange(columns.filter((col) => col.id !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 컬럼 속성 업데이트
|
// 컬럼 속성 업데이트
|
||||||
const handleUpdate = (id: string, updates: Partial<ListColumn>) => {
|
const handleUpdate = (id: string, updates: Partial<ListColumn>) => {
|
||||||
onConfigChange({ columns: columns.map((col) => (col.id === id ? { ...col, ...updates } : col)) });
|
onChange(columns.map((col) => (col.id === id ? { ...col, ...updates } : col)));
|
||||||
};
|
};
|
||||||
|
|
||||||
// 드래그 시작
|
// 드래그 시작
|
||||||
|
|
@ -53,41 +57,46 @@ export function ManualColumnEditor({ config, onConfigChange }: ManualColumnEdito
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (draggedIndex === null || draggedIndex === hoverIndex) return;
|
if (draggedIndex === null || draggedIndex === hoverIndex) return;
|
||||||
|
|
||||||
|
setDragOverIndex(hoverIndex);
|
||||||
|
|
||||||
const newColumns = [...columns];
|
const newColumns = [...columns];
|
||||||
const draggedItem = newColumns[draggedIndex];
|
const draggedItem = newColumns[draggedIndex];
|
||||||
newColumns.splice(draggedIndex, 1);
|
newColumns.splice(draggedIndex, 1);
|
||||||
newColumns.splice(hoverIndex, 0, draggedItem);
|
newColumns.splice(hoverIndex, 0, draggedItem);
|
||||||
|
|
||||||
setDraggedIndex(hoverIndex);
|
setDraggedIndex(hoverIndex);
|
||||||
onConfigChange({ columns: newColumns });
|
onChange(newColumns);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 드롭
|
// 드롭
|
||||||
const handleDrop = (e: React.DragEvent) => {
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setDraggedIndex(null);
|
setDraggedIndex(null);
|
||||||
|
setDragOverIndex(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 드래그 종료
|
// 드래그 종료
|
||||||
const handleDragEnd = () => {
|
const handleDragEnd = () => {
|
||||||
setDraggedIndex(null);
|
setDraggedIndex(null);
|
||||||
|
setDragOverIndex(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
{/* 헤더 */}
|
<h3 className="text-lg font-semibold text-gray-800">수동 컬럼 편집</h3>
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<p className="text-sm text-gray-600">
|
||||||
<p className="text-[10px] text-gray-500">직접 컬럼을 추가하고 데이터 필드를 매핑하세요</p>
|
직접 컬럼을 추가하고 데이터 필드를 매핑하세요. 드래그하여 순서를 변경할 수 있습니다.
|
||||||
<button
|
</p>
|
||||||
onClick={handleAddColumn}
|
</div>
|
||||||
className="bg-primary hover:bg-primary/90 flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-medium text-white transition-colors"
|
<Button onClick={handleAddColumn} size="sm" className="gap-2">
|
||||||
>
|
<Plus className="h-4 w-4" />
|
||||||
<Plus className="h-3 w-3" />
|
|
||||||
컬럼 추가
|
컬럼 추가
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-3">
|
||||||
{columns.map((col, index) => (
|
{columns.map((col, index) => (
|
||||||
<div
|
<div
|
||||||
key={col.id}
|
key={col.id}
|
||||||
|
|
@ -102,72 +111,82 @@ export function ManualColumnEditor({ config, onConfigChange }: ManualColumnEdito
|
||||||
handleDragEnd();
|
handleDragEnd();
|
||||||
e.currentTarget.style.cursor = "grab";
|
e.currentTarget.style.cursor = "grab";
|
||||||
}}
|
}}
|
||||||
className={`group relative rounded-md border border-gray-200 bg-white shadow-sm transition-all hover:border-gray-300 hover:shadow-sm ${
|
className={`cursor-grab rounded-lg border border-gray-200 bg-gray-50 p-4 transition-all active:cursor-grabbing ${
|
||||||
draggedIndex === index ? "scale-95 opacity-50" : ""
|
draggedIndex === index ? "opacity-50" : ""
|
||||||
} cursor-grab active:cursor-grabbing`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* 헤더 */}
|
<div className="mb-3 flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2 px-2.5 py-2">
|
<GripVertical className="h-4 w-4 text-blue-500" />
|
||||||
<GripVertical className="group-hover:text-primary h-3.5 w-3.5 shrink-0 text-gray-400 transition-colors" />
|
<span className="font-medium text-gray-700">컬럼 {index + 1}</span>
|
||||||
<span className="text-[11px] font-medium text-gray-900">컬럼 {index + 1}</span>
|
<Button
|
||||||
<button
|
|
||||||
onClick={() => handleRemove(col.id)}
|
onClick={() => handleRemove(col.id)}
|
||||||
className="ml-auto flex h-5 w-5 items-center justify-center rounded text-gray-400 transition-colors hover:bg-red-50 hover:text-red-600"
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="ml-auto text-red-600 hover:text-red-700"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-3 w-3" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 설정 영역 */}
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="border-t border-gray-100 bg-gray-50/50 px-2.5 py-1.5">
|
{/* 컬럼명 */}
|
||||||
<div className="flex flex-col gap-1.5">
|
<div>
|
||||||
{/* 표시 이름 */}
|
<Label className="text-xs">표시 이름 *</Label>
|
||||||
<div className="min-w-0">
|
|
||||||
<Input
|
<Input
|
||||||
value={col.label}
|
value={col.label}
|
||||||
onChange={(e) => handleUpdate(col.id, { label: e.target.value })}
|
onChange={(e) => handleUpdate(col.id, { label: e.target.value })}
|
||||||
placeholder="표시 이름"
|
placeholder="예: 사용자 이름"
|
||||||
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-gray-200 bg-white px-1.5 text-[10px] placeholder:text-gray-400 focus:ring-1"
|
className="mt-1"
|
||||||
style={{ fontSize: "10px" }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 데이터 필드 */}
|
{/* 데이터 필드 */}
|
||||||
<div className="min-w-0">
|
<div>
|
||||||
<Input
|
<Label className="text-xs">데이터 필드 *</Label>
|
||||||
value={col.field}
|
<Select value={col.field} onValueChange={(value) => handleUpdate(col.id, { field: value })}>
|
||||||
onChange={(e) => handleUpdate(col.id, { field: e.target.value })}
|
<SelectTrigger className="mt-1">
|
||||||
placeholder="데이터 필드"
|
<SelectValue placeholder="필드 선택" />
|
||||||
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-gray-200 bg-white px-1.5 text-[10px] placeholder:text-gray-400 focus:ring-1"
|
</SelectTrigger>
|
||||||
style={{ fontSize: "10px" }}
|
<SelectContent>
|
||||||
/>
|
{availableFields.map((field) => (
|
||||||
|
<SelectItem key={field} value={field}>
|
||||||
|
{field}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 정렬 */}
|
{/* 정렬 방향 */}
|
||||||
<div className="min-w-0">
|
<div>
|
||||||
|
<Label className="text-xs">정렬</Label>
|
||||||
<Select
|
<Select
|
||||||
value={col.align}
|
value={col.align}
|
||||||
onValueChange={(value: "left" | "center" | "right") => handleUpdate(col.id, { align: value })}
|
onValueChange={(value: "left" | "center" | "right") => handleUpdate(col.id, { align: value })}
|
||||||
>
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger className="mt-1">
|
||||||
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-gray-200 bg-white px-1.5 text-[10px] focus:ring-1 [&>span]:leading-none [&>svg]:h-3 [&>svg]:w-3"
|
|
||||||
style={{ fontSize: "10px", height: "24px", minHeight: "24px" }}
|
|
||||||
>
|
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent className="min-w-[4rem]">
|
<SelectContent>
|
||||||
<SelectItem value="left" className="py-1 text-[10px]">
|
<SelectItem value="left">왼쪽</SelectItem>
|
||||||
왼쪽
|
<SelectItem value="center">가운데</SelectItem>
|
||||||
</SelectItem>
|
<SelectItem value="right">오른쪽</SelectItem>
|
||||||
<SelectItem value="center" className="py-1 text-[10px]">
|
|
||||||
가운데
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="right" className="py-1 text-[10px]">
|
|
||||||
오른쪽
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 너비 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-xs">너비 (px)</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={col.width || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleUpdate(col.id, { width: e.target.value ? parseInt(e.target.value) : undefined })
|
||||||
|
}
|
||||||
|
placeholder="자동"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -175,18 +194,13 @@ export function ManualColumnEditor({ config, onConfigChange }: ManualColumnEdito
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{columns.length === 0 && (
|
{columns.length === 0 && (
|
||||||
<div className="mt-3 flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50/50 px-3 py-2">
|
<div className="mt-4 rounded-lg border border-gray-300 bg-gray-100 p-8 text-center">
|
||||||
<span className="text-amber-600">⚠️</span>
|
<div className="text-sm text-gray-600">컬럼을 추가하여 시작하세요</div>
|
||||||
<span className="text-[10px] text-amber-700">컬럼을 추가하여 시작하세요</span>
|
<Button onClick={handleAddColumn} size="sm" className="mt-3 gap-2">
|
||||||
<button
|
<Plus className="h-4 w-4" />첫 번째 컬럼 추가
|
||||||
onClick={handleAddColumn}
|
</Button>
|
||||||
className="bg-primary hover:bg-primary/90 ml-auto flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-medium text-white transition-colors"
|
|
||||||
>
|
|
||||||
<Plus className="h-3 w-3" />
|
|
||||||
추가
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,219 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import { ListColumn, ListWidgetConfig, QueryResult } from "../../types";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { Plus, Trash2, GripVertical } from "lucide-react";
|
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
|
||||||
|
|
||||||
interface UnifiedColumnEditorProps {
|
|
||||||
queryResult: QueryResult | null;
|
|
||||||
config: ListWidgetConfig;
|
|
||||||
onConfigChange: (updates: Partial<ListWidgetConfig>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 통합 컬럼 에디터
|
|
||||||
* - 쿼리 실행 시 자동으로 컬럼 추출
|
|
||||||
* - 모든 필드 편집 가능 (필드명, 표시 이름, 정렬)
|
|
||||||
* - 수동으로 컬럼 추가 가능
|
|
||||||
*/
|
|
||||||
export function UnifiedColumnEditor({ queryResult, config, onConfigChange }: UnifiedColumnEditorProps) {
|
|
||||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const columns = config.columns || [];
|
|
||||||
const sampleData = queryResult?.rows[0] || {};
|
|
||||||
|
|
||||||
// 컬럼 추가
|
|
||||||
const handleAddColumn = () => {
|
|
||||||
const newColumn: ListColumn = {
|
|
||||||
id: `col_${Date.now()}`,
|
|
||||||
field: "",
|
|
||||||
label: "",
|
|
||||||
visible: true,
|
|
||||||
align: "left",
|
|
||||||
};
|
|
||||||
|
|
||||||
onConfigChange({
|
|
||||||
columns: [...columns, newColumn],
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 컬럼 삭제
|
|
||||||
const handleRemove = (id: string) => {
|
|
||||||
onConfigChange({
|
|
||||||
columns: columns.filter((col) => col.id !== id),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 컬럼 업데이트
|
|
||||||
const handleUpdate = (id: string, updates: Partial<ListColumn>) => {
|
|
||||||
onConfigChange({
|
|
||||||
columns: columns.map((col) => (col.id === id ? { ...col, ...updates } : col)),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 컬럼 토글
|
|
||||||
const handleToggle = (id: string) => {
|
|
||||||
onConfigChange({
|
|
||||||
columns: columns.map((col) => (col.id === id ? { ...col, visible: !col.visible } : col)),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 드래그 시작
|
|
||||||
const handleDragStart = (index: number) => {
|
|
||||||
setDraggedIndex(index);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 드래그 오버
|
|
||||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
if (draggedIndex === null || draggedIndex === index) return;
|
|
||||||
|
|
||||||
const newColumns = [...columns];
|
|
||||||
const draggedItem = newColumns[draggedIndex];
|
|
||||||
|
|
||||||
newColumns.splice(draggedIndex, 1);
|
|
||||||
newColumns.splice(index, 0, draggedItem);
|
|
||||||
|
|
||||||
onConfigChange({ columns: newColumns });
|
|
||||||
setDraggedIndex(index);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 드롭
|
|
||||||
const handleDrop = () => {
|
|
||||||
setDraggedIndex(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 드래그 종료
|
|
||||||
const handleDragEnd = () => {
|
|
||||||
setDraggedIndex(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="mb-3 flex items-center justify-between">
|
|
||||||
<p className="text-[10px] text-gray-500">컬럼을 선택하고 편집하세요</p>
|
|
||||||
<button
|
|
||||||
onClick={handleAddColumn}
|
|
||||||
className="bg-primary hover:bg-primary/90 flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-medium text-white transition-colors"
|
|
||||||
>
|
|
||||||
<Plus className="h-3 w-3" />
|
|
||||||
컬럼 추가
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{columns.map((col, index) => {
|
|
||||||
const preview = sampleData[col.field];
|
|
||||||
const previewText =
|
|
||||||
preview !== undefined && preview !== null
|
|
||||||
? typeof preview === "object"
|
|
||||||
? JSON.stringify(preview).substring(0, 30)
|
|
||||||
: String(preview).substring(0, 30)
|
|
||||||
: "";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={col.id}
|
|
||||||
draggable
|
|
||||||
onDragStart={(e) => {
|
|
||||||
handleDragStart(index);
|
|
||||||
e.currentTarget.style.cursor = "grabbing";
|
|
||||||
}}
|
|
||||||
onDragOver={(e) => handleDragOver(e, index)}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
onDragEnd={(e) => {
|
|
||||||
handleDragEnd();
|
|
||||||
e.currentTarget.style.cursor = "grab";
|
|
||||||
}}
|
|
||||||
className={`group relative rounded-md border transition-all ${
|
|
||||||
col.visible
|
|
||||||
? "border-primary/40 bg-primary/5 shadow-sm"
|
|
||||||
: "border-gray-200 bg-white hover:border-gray-300 hover:shadow-sm"
|
|
||||||
} cursor-grab active:cursor-grabbing ${draggedIndex === index ? "scale-95 opacity-50" : ""}`}
|
|
||||||
>
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="flex items-center gap-2 px-2.5 py-2">
|
|
||||||
<Checkbox
|
|
||||||
checked={col.visible}
|
|
||||||
onCheckedChange={() => handleToggle(col.id)}
|
|
||||||
className="data-[state=checked]:bg-primary data-[state=checked]:border-primary h-4 w-4 shrink-0 rounded-full"
|
|
||||||
/>
|
|
||||||
<GripVertical className="group-hover:text-primary h-3.5 w-3.5 shrink-0 text-gray-400 transition-colors" />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="truncate text-[11px] font-medium text-gray-900">
|
|
||||||
{col.field || "(필드명 없음)"}
|
|
||||||
</span>
|
|
||||||
{previewText && <span className="shrink-0 text-[9px] text-gray-400">예: {previewText}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleRemove(col.id)}
|
|
||||||
className="ml-auto flex h-5 w-5 items-center justify-center rounded text-gray-400 transition-colors hover:bg-red-50 hover:text-red-600"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 설정 영역 */}
|
|
||||||
{col.visible && (
|
|
||||||
<div className="border-t border-gray-100 bg-gray-50/50 px-2.5 py-1.5">
|
|
||||||
<div className="grid grid-cols-2 gap-1.5">
|
|
||||||
{/* 표시 이름 */}
|
|
||||||
<div className="min-w-0">
|
|
||||||
<Input
|
|
||||||
value={col.label}
|
|
||||||
onChange={(e) => handleUpdate(col.id, { label: e.target.value })}
|
|
||||||
placeholder="표시 이름"
|
|
||||||
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-gray-200 bg-white px-1.5 text-[10px] placeholder:text-gray-400 focus:ring-1"
|
|
||||||
style={{ fontSize: "10px" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 정렬 */}
|
|
||||||
<div className="min-w-0">
|
|
||||||
<Select
|
|
||||||
value={col.align}
|
|
||||||
onValueChange={(value: "left" | "center" | "right") => handleUpdate(col.id, { align: value })}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
className="focus:border-primary focus:ring-primary/20 h-6 w-full border-gray-200 bg-white px-1.5 text-[10px] focus:ring-1 [&>span]:leading-none [&>svg]:h-3 [&>svg]:w-3"
|
|
||||||
style={{ fontSize: "10px", height: "24px", minHeight: "24px" }}
|
|
||||||
>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="min-w-[4rem]">
|
|
||||||
<SelectItem value="left" className="py-1 text-[10px]">
|
|
||||||
왼쪽
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="center" className="py-1 text-[10px]">
|
|
||||||
가운데
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="right" className="py-1 text-[10px]">
|
|
||||||
오른쪽
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{columns.length === 0 && (
|
|
||||||
<div className="mt-3 flex items-center gap-2 rounded-md border border-amber-200 bg-amber-50/50 px-3 py-2">
|
|
||||||
<span className="text-amber-600">⚠️</span>
|
|
||||||
<span className="text-[10px] text-amber-700">쿼리를 실행하거나 컬럼을 추가하세요</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -111,7 +111,7 @@ export default function CargoListWidget({ element }: CargoListWidgetProps) {
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="border-primary mx-auto h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
|
<div className="border-primary mx-auto h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
|
||||||
<p className="text-muted-foreground mt-2 text-sm">데이터 로딩 중...</p>
|
<p className="mt-2 text-sm text-muted-foreground">데이터 로딩 중...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -120,11 +120,11 @@ export default function CargoListWidget({ element }: CargoListWidgetProps) {
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<div className="text-destructive text-center">
|
<div className="text-center text-destructive">
|
||||||
<p className="text-sm">⚠️ {error}</p>
|
<p className="text-sm">⚠️ {error}</p>
|
||||||
<button
|
<button
|
||||||
onClick={loadData}
|
onClick={loadData}
|
||||||
className="bg-destructive/10 hover:bg-destructive/20 mt-2 rounded-md px-3 py-1 text-xs"
|
className="mt-2 rounded-md bg-destructive/10 px-3 py-1 text-xs hover:bg-destructive/20"
|
||||||
>
|
>
|
||||||
다시 시도
|
다시 시도
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -136,29 +136,29 @@ export default function CargoListWidget({ element }: CargoListWidgetProps) {
|
||||||
if (!element?.dataSource?.query) {
|
if (!element?.dataSource?.query) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<div className="text-muted-foreground text-center">
|
<div className="text-center text-muted-foreground">
|
||||||
<p className="text-sm">데이터를 연결하세요</p>
|
<p className="text-sm">⚙️ 톱니바퀴를 클릭하여 데이터를 연결하세요</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background flex h-full flex-col overflow-hidden p-4">
|
<div className="flex h-full flex-col overflow-hidden bg-background p-4">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h3 className="text-foreground text-lg font-semibold">📦 화물 목록</h3>
|
<h3 className="text-lg font-semibold text-foreground">📦 화물 목록</h3>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="검색..."
|
placeholder="검색..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="border-input bg-background placeholder:text-muted-foreground focus:ring-ring rounded-md border px-3 py-1 text-sm focus:ring-2 focus:outline-none"
|
className="rounded-md border border-input bg-background px-3 py-1 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={loadData}
|
onClick={loadData}
|
||||||
className="text-muted-foreground hover:bg-accent hover:text-accent-foreground rounded-full p-1"
|
className="rounded-full p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
title="새로고침"
|
title="새로고침"
|
||||||
>
|
>
|
||||||
🔄
|
🔄
|
||||||
|
|
@ -167,38 +167,47 @@ export default function CargoListWidget({ element }: CargoListWidgetProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 총 건수 */}
|
{/* 총 건수 */}
|
||||||
<div className="text-muted-foreground mb-3 text-sm">
|
<div className="mb-3 text-sm text-muted-foreground">
|
||||||
총 <span className="text-foreground font-semibold">{filteredList.length}</span>건
|
총 <span className="font-semibold text-foreground">{filteredList.length}</span>건
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 테이블 */}
|
{/* 테이블 */}
|
||||||
<div className="border-border flex-1 overflow-auto rounded-md border">
|
<div className="flex-1 overflow-auto rounded-md border border-border">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-muted/50 text-muted-foreground">
|
<thead className="bg-muted/50 text-muted-foreground">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="border-border border-b p-2 text-left font-medium">운송장번호</th>
|
<th className="border-b border-border p-2 text-left font-medium">운송장번호</th>
|
||||||
<th className="border-border border-b p-2 text-left font-medium">고객명</th>
|
<th className="border-b border-border p-2 text-left font-medium">고객명</th>
|
||||||
<th className="border-border border-b p-2 text-left font-medium">목적지</th>
|
<th className="border-b border-border p-2 text-left font-medium">목적지</th>
|
||||||
<th className="border-border border-b p-2 text-left font-medium">무게(kg)</th>
|
<th className="border-b border-border p-2 text-left font-medium">무게(kg)</th>
|
||||||
<th className="border-border border-b p-2 text-left font-medium">상태</th>
|
<th className="border-b border-border p-2 text-left font-medium">상태</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{filteredList.length === 0 ? (
|
{filteredList.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="text-muted-foreground p-8 text-center">
|
<td colSpan={5} className="p-8 text-center text-muted-foreground">
|
||||||
{searchTerm ? "검색 결과가 없습니다" : "화물이 없습니다"}
|
{searchTerm ? "검색 결과가 없습니다" : "화물이 없습니다"}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredList.map((cargo, index) => (
|
filteredList.map((cargo, index) => (
|
||||||
<tr key={cargo.id || index} className="border-border hover:bg-muted/30 border-b transition-colors">
|
<tr
|
||||||
<td className="text-foreground p-2 font-medium">
|
key={cargo.id || index}
|
||||||
|
className="border-b border-border hover:bg-muted/30 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="p-2 font-medium text-foreground">
|
||||||
{cargo.tracking_number || cargo.trackingNumber || "-"}
|
{cargo.tracking_number || cargo.trackingNumber || "-"}
|
||||||
</td>
|
</td>
|
||||||
<td className="text-foreground p-2">{cargo.customer_name || cargo.customerName || "-"}</td>
|
<td className="p-2 text-foreground">
|
||||||
<td className="text-muted-foreground p-2">{cargo.destination || "-"}</td>
|
{cargo.customer_name || cargo.customerName || "-"}
|
||||||
<td className="text-muted-foreground p-2 text-right">{cargo.weight ? `${cargo.weight}kg` : "-"}</td>
|
</td>
|
||||||
|
<td className="p-2 text-muted-foreground">
|
||||||
|
{cargo.destination || "-"}
|
||||||
|
</td>
|
||||||
|
<td className="p-2 text-right text-muted-foreground">
|
||||||
|
{cargo.weight ? `${cargo.weight}kg` : "-"}
|
||||||
|
</td>
|
||||||
<td className="p-2">
|
<td className="p-2">
|
||||||
<span
|
<span
|
||||||
className={`inline-block rounded-full px-2 py-1 text-xs font-medium ${getStatusBadge(cargo.status || "")}`}
|
className={`inline-block rounded-full px-2 py-1 text-xs font-medium ${getStatusBadge(cargo.status || "")}`}
|
||||||
|
|
@ -215,3 +224,4 @@ export default function CargoListWidget({ element }: CargoListWidgetProps) {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -148,81 +148,81 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
||||||
// 3. 컬럼명 한글 번역 매핑
|
// 3. 컬럼명 한글 번역 매핑
|
||||||
const columnNameTranslation: { [key: string]: string } = {
|
const columnNameTranslation: { [key: string]: string } = {
|
||||||
// 일반
|
// 일반
|
||||||
id: "ID",
|
"id": "ID",
|
||||||
name: "이름",
|
"name": "이름",
|
||||||
title: "제목",
|
"title": "제목",
|
||||||
description: "설명",
|
"description": "설명",
|
||||||
status: "상태",
|
"status": "상태",
|
||||||
type: "유형",
|
"type": "유형",
|
||||||
category: "카테고리",
|
"category": "카테고리",
|
||||||
date: "날짜",
|
"date": "날짜",
|
||||||
time: "시간",
|
"time": "시간",
|
||||||
created_at: "생성일",
|
"created_at": "생성일",
|
||||||
updated_at: "수정일",
|
"updated_at": "수정일",
|
||||||
deleted_at: "삭제일",
|
"deleted_at": "삭제일",
|
||||||
|
|
||||||
// 물류/운송
|
// 물류/운송
|
||||||
tracking_number: "운송장 번호",
|
"tracking_number": "운송장 번호",
|
||||||
customer: "고객",
|
"customer": "고객",
|
||||||
origin: "출발지",
|
"origin": "출발지",
|
||||||
destination: "목적지",
|
"destination": "목적지",
|
||||||
estimated_delivery: "예상 도착",
|
"estimated_delivery": "예상 도착",
|
||||||
actual_delivery: "실제 도착",
|
"actual_delivery": "실제 도착",
|
||||||
delay_reason: "지연 사유",
|
"delay_reason": "지연 사유",
|
||||||
priority: "우선순위",
|
"priority": "우선순위",
|
||||||
cargo_weight: "화물 중량",
|
"cargo_weight": "화물 중량",
|
||||||
total_weight: "총 중량",
|
"total_weight": "총 중량",
|
||||||
weight: "중량",
|
"weight": "중량",
|
||||||
distance: "거리",
|
"distance": "거리",
|
||||||
total_distance: "총 거리",
|
"total_distance": "총 거리",
|
||||||
delivery_time: "배송 시간",
|
"delivery_time": "배송 시간",
|
||||||
delivery_duration: "배송 소요시간",
|
"delivery_duration": "배송 소요시간",
|
||||||
is_on_time: "정시 도착 여부",
|
"is_on_time": "정시 도착 여부",
|
||||||
on_time: "정시",
|
"on_time": "정시",
|
||||||
|
|
||||||
// 수량/금액
|
// 수량/금액
|
||||||
quantity: "수량",
|
"quantity": "수량",
|
||||||
qty: "수량",
|
"qty": "수량",
|
||||||
amount: "금액",
|
"amount": "금액",
|
||||||
price: "가격",
|
"price": "가격",
|
||||||
cost: "비용",
|
"cost": "비용",
|
||||||
fee: "수수료",
|
"fee": "수수료",
|
||||||
total: "합계",
|
"total": "합계",
|
||||||
sum: "총합",
|
"sum": "총합",
|
||||||
|
|
||||||
// 비율/효율
|
// 비율/효율
|
||||||
rate: "비율",
|
"rate": "비율",
|
||||||
ratio: "비율",
|
"ratio": "비율",
|
||||||
percent: "퍼센트",
|
"percent": "퍼센트",
|
||||||
percentage: "백분율",
|
"percentage": "백분율",
|
||||||
efficiency: "효율",
|
"efficiency": "효율",
|
||||||
|
|
||||||
// 생산/처리
|
// 생산/처리
|
||||||
throughput: "처리량",
|
"throughput": "처리량",
|
||||||
output: "산출량",
|
"output": "산출량",
|
||||||
production: "생산량",
|
"production": "생산량",
|
||||||
volume: "용량",
|
"volume": "용량",
|
||||||
|
|
||||||
// 재고/설비
|
// 재고/설비
|
||||||
stock: "재고",
|
"stock": "재고",
|
||||||
inventory: "재고",
|
"inventory": "재고",
|
||||||
equipment: "설비",
|
"equipment": "설비",
|
||||||
facility: "시설",
|
"facility": "시설",
|
||||||
machine: "기계",
|
"machine": "기계",
|
||||||
|
|
||||||
// 평가
|
// 평가
|
||||||
score: "점수",
|
"score": "점수",
|
||||||
rating: "평점",
|
"rating": "평점",
|
||||||
point: "점수",
|
"point": "점수",
|
||||||
grade: "등급",
|
"grade": "등급",
|
||||||
|
|
||||||
// 기타
|
// 기타
|
||||||
temperature: "온도",
|
"temperature": "온도",
|
||||||
temp: "온도",
|
"temp": "온도",
|
||||||
speed: "속도",
|
"speed": "속도",
|
||||||
velocity: "속도",
|
"velocity": "속도",
|
||||||
count: "개수",
|
"count": "개수",
|
||||||
number: "번호",
|
"number": "번호",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 4. 키워드 기반 자동 라벨링 및 단위 설정
|
// 4. 키워드 기반 자동 라벨링 및 단위 설정
|
||||||
|
|
@ -243,7 +243,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
||||||
color: "green",
|
color: "green",
|
||||||
icon: "⚖️",
|
icon: "⚖️",
|
||||||
aggregation: "sum",
|
aggregation: "sum",
|
||||||
koreanLabel: "총 운송량",
|
koreanLabel: "총 운송량"
|
||||||
},
|
},
|
||||||
// 거리 - 합계
|
// 거리 - 합계
|
||||||
distance: {
|
distance: {
|
||||||
|
|
@ -252,7 +252,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
||||||
color: "blue",
|
color: "blue",
|
||||||
icon: "🛣️",
|
icon: "🛣️",
|
||||||
aggregation: "sum",
|
aggregation: "sum",
|
||||||
koreanLabel: "누적 거리",
|
koreanLabel: "누적 거리"
|
||||||
},
|
},
|
||||||
// 시간/기간 - 평균
|
// 시간/기간 - 평균
|
||||||
time: {
|
time: {
|
||||||
|
|
@ -261,7 +261,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
||||||
color: "orange",
|
color: "orange",
|
||||||
icon: "⏱️",
|
icon: "⏱️",
|
||||||
aggregation: "avg",
|
aggregation: "avg",
|
||||||
koreanLabel: "평균 배송시간",
|
koreanLabel: "평균 배송시간"
|
||||||
},
|
},
|
||||||
// 수량/개수 - 합계
|
// 수량/개수 - 합계
|
||||||
quantity: {
|
quantity: {
|
||||||
|
|
@ -270,7 +270,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
||||||
color: "purple",
|
color: "purple",
|
||||||
icon: "📦",
|
icon: "📦",
|
||||||
aggregation: "sum",
|
aggregation: "sum",
|
||||||
koreanLabel: "총 수량",
|
koreanLabel: "총 수량"
|
||||||
},
|
},
|
||||||
// 금액/가격 - 합계
|
// 금액/가격 - 합계
|
||||||
amount: {
|
amount: {
|
||||||
|
|
@ -279,7 +279,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
||||||
color: "yellow",
|
color: "yellow",
|
||||||
icon: "💰",
|
icon: "💰",
|
||||||
aggregation: "sum",
|
aggregation: "sum",
|
||||||
koreanLabel: "총 금액",
|
koreanLabel: "총 금액"
|
||||||
},
|
},
|
||||||
// 비율/퍼센트 - 평균
|
// 비율/퍼센트 - 평균
|
||||||
rate: {
|
rate: {
|
||||||
|
|
@ -288,7 +288,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
||||||
color: "cyan",
|
color: "cyan",
|
||||||
icon: "📈",
|
icon: "📈",
|
||||||
aggregation: "avg",
|
aggregation: "avg",
|
||||||
koreanLabel: "평균 비율",
|
koreanLabel: "평균 비율"
|
||||||
},
|
},
|
||||||
// 처리량 - 합계
|
// 처리량 - 합계
|
||||||
throughput: {
|
throughput: {
|
||||||
|
|
@ -297,7 +297,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
||||||
color: "pink",
|
color: "pink",
|
||||||
icon: "⚡",
|
icon: "⚡",
|
||||||
aggregation: "sum",
|
aggregation: "sum",
|
||||||
koreanLabel: "총 처리량",
|
koreanLabel: "총 처리량"
|
||||||
},
|
},
|
||||||
// 재고 - 평균 (현재 재고는 평균이 의미있음)
|
// 재고 - 평균 (현재 재고는 평균이 의미있음)
|
||||||
stock: {
|
stock: {
|
||||||
|
|
@ -306,7 +306,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
||||||
color: "teal",
|
color: "teal",
|
||||||
icon: "📦",
|
icon: "📦",
|
||||||
aggregation: "avg",
|
aggregation: "avg",
|
||||||
koreanLabel: "평균 재고",
|
koreanLabel: "평균 재고"
|
||||||
},
|
},
|
||||||
// 설비/장비 - 평균
|
// 설비/장비 - 평균
|
||||||
equipment: {
|
equipment: {
|
||||||
|
|
@ -315,7 +315,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
||||||
color: "gray",
|
color: "gray",
|
||||||
icon: "🏭",
|
icon: "🏭",
|
||||||
aggregation: "avg",
|
aggregation: "avg",
|
||||||
koreanLabel: "평균 가동 설비",
|
koreanLabel: "평균 가동 설비"
|
||||||
},
|
},
|
||||||
// 점수/평점 - 평균
|
// 점수/평점 - 평균
|
||||||
score: {
|
score: {
|
||||||
|
|
@ -324,7 +324,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
||||||
color: "indigo",
|
color: "indigo",
|
||||||
icon: "⭐",
|
icon: "⭐",
|
||||||
aggregation: "avg",
|
aggregation: "avg",
|
||||||
koreanLabel: "평균 점수",
|
koreanLabel: "평균 점수"
|
||||||
},
|
},
|
||||||
// 온도 - 평균
|
// 온도 - 평균
|
||||||
temperature: {
|
temperature: {
|
||||||
|
|
@ -333,7 +333,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
||||||
color: "red",
|
color: "red",
|
||||||
icon: "🌡️",
|
icon: "🌡️",
|
||||||
aggregation: "avg",
|
aggregation: "avg",
|
||||||
koreanLabel: "평균 온도",
|
koreanLabel: "평균 온도"
|
||||||
},
|
},
|
||||||
// 속도 - 평균
|
// 속도 - 평균
|
||||||
speed: {
|
speed: {
|
||||||
|
|
@ -342,7 +342,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
||||||
color: "blue",
|
color: "blue",
|
||||||
icon: "🚀",
|
icon: "🚀",
|
||||||
aggregation: "avg",
|
aggregation: "avg",
|
||||||
koreanLabel: "평균 속도",
|
koreanLabel: "평균 속도"
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -370,9 +370,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
||||||
} else {
|
} else {
|
||||||
// 집계 방식에 따라 접두어 추가
|
// 집계 방식에 따라 접두어 추가
|
||||||
const prefix = aggregation === "avg" ? "평균 " : aggregation === "sum" ? "총 " : "";
|
const prefix = aggregation === "avg" ? "평균 " : aggregation === "sum" ? "총 " : "";
|
||||||
label =
|
label = prefix + key
|
||||||
prefix +
|
|
||||||
key
|
|
||||||
.replace(/_/g, " ")
|
.replace(/_/g, " ")
|
||||||
.replace(/([A-Z])/g, " $1")
|
.replace(/([A-Z])/g, " $1")
|
||||||
.trim();
|
.trim();
|
||||||
|
|
@ -391,20 +389,17 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
||||||
label = translatedName;
|
label = translatedName;
|
||||||
} else {
|
} else {
|
||||||
// 컬럼명에 avg, average, mean이 포함되면 평균으로 간주
|
// 컬럼명에 avg, average, mean이 포함되면 평균으로 간주
|
||||||
if (
|
if (key.toLowerCase().includes("avg") ||
|
||||||
key.toLowerCase().includes("avg") ||
|
|
||||||
key.toLowerCase().includes("average") ||
|
key.toLowerCase().includes("average") ||
|
||||||
key.toLowerCase().includes("mean")
|
key.toLowerCase().includes("mean")) {
|
||||||
) {
|
|
||||||
aggregation = "avg";
|
aggregation = "avg";
|
||||||
|
|
||||||
// 언더스코어로 분리된 각 단어 번역 시도
|
// 언더스코어로 분리된 각 단어 번역 시도
|
||||||
const cleanKey = key
|
const cleanKey = key.replace(/avg|average|mean/gi, "").replace(/_/g, " ").trim();
|
||||||
.replace(/avg|average|mean/gi, "")
|
|
||||||
.replace(/_/g, " ")
|
|
||||||
.trim();
|
|
||||||
const words = cleanKey.split(/[_\s]+/);
|
const words = cleanKey.split(/[_\s]+/);
|
||||||
const translatedWords = words.map((word) => columnNameTranslation[word.toLowerCase()] || word);
|
const translatedWords = words.map(word =>
|
||||||
|
columnNameTranslation[word.toLowerCase()] || word
|
||||||
|
);
|
||||||
label = "평균 " + translatedWords.join(" ");
|
label = "평균 " + translatedWords.join(" ");
|
||||||
}
|
}
|
||||||
// total, sum이 포함되면 합계로 간주
|
// total, sum이 포함되면 합계로 간주
|
||||||
|
|
@ -412,18 +407,17 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
||||||
aggregation = "sum";
|
aggregation = "sum";
|
||||||
|
|
||||||
// 언더스코어로 분리된 각 단어 번역 시도
|
// 언더스코어로 분리된 각 단어 번역 시도
|
||||||
const cleanKey = key
|
const cleanKey = key.replace(/total|sum/gi, "").replace(/_/g, " ").trim();
|
||||||
.replace(/total|sum/gi, "")
|
|
||||||
.replace(/_/g, " ")
|
|
||||||
.trim();
|
|
||||||
const words = cleanKey.split(/[_\s]+/);
|
const words = cleanKey.split(/[_\s]+/);
|
||||||
const translatedWords = words.map((word) => columnNameTranslation[word.toLowerCase()] || word);
|
const translatedWords = words.map(word =>
|
||||||
|
columnNameTranslation[word.toLowerCase()] || word
|
||||||
|
);
|
||||||
label = "총 " + translatedWords.join(" ");
|
label = "총 " + translatedWords.join(" ");
|
||||||
}
|
}
|
||||||
// 기본값 - 각 단어별로 번역 시도
|
// 기본값 - 각 단어별로 번역 시도
|
||||||
else {
|
else {
|
||||||
const words = key.split(/[_\s]+/);
|
const words = key.split(/[_\s]+/);
|
||||||
const translatedWords = words.map((word) => {
|
const translatedWords = words.map(word => {
|
||||||
const translated = columnNameTranslation[word.toLowerCase()];
|
const translated = columnNameTranslation[word.toLowerCase()];
|
||||||
if (translated) {
|
if (translated) {
|
||||||
return translated;
|
return translated;
|
||||||
|
|
@ -533,7 +527,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
||||||
if (matchRate >= 0.5 && filtered.length > 0) {
|
if (matchRate >= 0.5 && filtered.length > 0) {
|
||||||
setStats(filtered);
|
setStats(filtered);
|
||||||
// 실제 표시되는 라벨로 업데이트
|
// 실제 표시되는 라벨로 업데이트
|
||||||
const actualLabels = filtered.map((s) => s.label);
|
const actualLabels = filtered.map(s => s.label);
|
||||||
setSelectedStats(actualLabels);
|
setSelectedStats(actualLabels);
|
||||||
selectedStatsRef.current = actualLabels;
|
selectedStatsRef.current = actualLabels;
|
||||||
// localStorage도 업데이트하여 다음에는 정확히 일치하도록
|
// localStorage도 업데이트하여 다음에는 정확히 일치하도록
|
||||||
|
|
@ -630,7 +624,9 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mb-2 text-4xl">⚠️</div>
|
<div className="mb-2 text-4xl">⚠️</div>
|
||||||
<div className="text-sm font-medium text-gray-600">{error}</div>
|
<div className="text-sm font-medium text-gray-600">{error}</div>
|
||||||
{!element?.dataSource?.query && <div className="mt-2 text-xs text-gray-500">쿼리를 설정하세요</div>}
|
{!element?.dataSource?.query && (
|
||||||
|
<div className="mt-2 text-xs text-gray-500">톱니바퀴 아이콘을 클릭하여 쿼리를 설정하세요</div>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={loadData}
|
onClick={loadData}
|
||||||
className="mt-3 rounded-lg bg-blue-500 px-4 py-2 text-sm text-white hover:bg-blue-600"
|
className="mt-3 rounded-lg bg-blue-500 px-4 py-2 text-sm text-white hover:bg-blue-600"
|
||||||
|
|
@ -656,7 +652,9 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
||||||
|
|
||||||
const handleToggleStat = (label: string) => {
|
const handleToggleStat = (label: string) => {
|
||||||
setSelectedStats((prev) => {
|
setSelectedStats((prev) => {
|
||||||
const newStats = prev.includes(label) ? prev.filter((l) => l !== label) : [...prev, label];
|
const newStats = prev.includes(label)
|
||||||
|
? prev.filter((l) => l !== label)
|
||||||
|
: [...prev, label];
|
||||||
// console.log("🔘 토글:", label, "→", newStats.length + "개 선택");
|
// console.log("🔘 토글:", label, "→", newStats.length + "개 선택");
|
||||||
return newStats;
|
return newStats;
|
||||||
});
|
});
|
||||||
|
|
@ -695,7 +693,7 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// 설정 모달 열 때 현재 표시 중인 통계로 동기화
|
// 설정 모달 열 때 현재 표시 중인 통계로 동기화
|
||||||
const currentLabels = stats.map((s) => s.label);
|
const currentLabels = stats.map(s => s.label);
|
||||||
// console.log("⚙️ 설정 모달 열기 - 현재 표시 중:", currentLabels);
|
// console.log("⚙️ 설정 모달 열기 - 현재 표시 중:", currentLabels);
|
||||||
setSelectedStats(currentLabels);
|
setSelectedStats(currentLabels);
|
||||||
setShowSettings(true);
|
setShowSettings(true);
|
||||||
|
|
@ -715,7 +713,9 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
||||||
const colors = getColorClasses(stat.color);
|
const colors = getColorClasses(stat.color);
|
||||||
return (
|
return (
|
||||||
<div key={index} className={`rounded-lg border ${colors.bg} p-4 text-center`}>
|
<div key={index} className={`rounded-lg border ${colors.bg} p-4 text-center`}>
|
||||||
<div className="text-sm text-gray-600">{stat.label}</div>
|
<div className="text-sm text-gray-600">
|
||||||
|
{stat.label}
|
||||||
|
</div>
|
||||||
<div className={`mt-2 text-3xl font-bold ${colors.text}`}>
|
<div className={`mt-2 text-3xl font-bold ${colors.text}`}>
|
||||||
{stat.value.toFixed(stat.unit === "%" || stat.unit === "분" ? 1 : 0).toLocaleString()}
|
{stat.value.toFixed(stat.unit === "%" || stat.unit === "분" ? 1 : 0).toLocaleString()}
|
||||||
<span className="ml-1 text-lg">{stat.unit}</span>
|
<span className="ml-1 text-lg">{stat.unit}</span>
|
||||||
|
|
@ -737,7 +737,9 @@ export default function CustomStatsWidget({ element, refreshInterval = 60000 }:
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4 text-sm text-gray-600">표시하고 싶은 통계를 선택하세요 (최대 제한 없음)</div>
|
<div className="mb-4 text-sm text-gray-600">
|
||||||
|
표시하고 싶은 통계를 선택하세요 (최대 제한 없음)
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{allStats.map((stat, index) => {
|
{allStats.map((stat, index) => {
|
||||||
|
|
|
||||||
|
|
@ -102,8 +102,7 @@ export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetPr
|
||||||
return "bg-muted text-muted-foreground";
|
return "bg-muted text-muted-foreground";
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredIssues =
|
const filteredIssues = filterPriority === "all"
|
||||||
filterPriority === "all"
|
|
||||||
? issues
|
? issues
|
||||||
: issues.filter((issue) => {
|
: issues.filter((issue) => {
|
||||||
const priority = (issue.priority || "").toLowerCase();
|
const priority = (issue.priority || "").toLowerCase();
|
||||||
|
|
@ -115,7 +114,7 @@ export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetPr
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="border-primary mx-auto h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
|
<div className="border-primary mx-auto h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
|
||||||
<p className="text-muted-foreground mt-2 text-sm">데이터 로딩 중...</p>
|
<p className="mt-2 text-sm text-muted-foreground">데이터 로딩 중...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -124,11 +123,11 @@ export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetPr
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<div className="text-destructive text-center">
|
<div className="text-center text-destructive">
|
||||||
<p className="text-sm">⚠️ {error}</p>
|
<p className="text-sm">⚠️ {error}</p>
|
||||||
<button
|
<button
|
||||||
onClick={loadData}
|
onClick={loadData}
|
||||||
className="bg-destructive/10 hover:bg-destructive/20 mt-2 rounded-md px-3 py-1 text-xs"
|
className="mt-2 rounded-md bg-destructive/10 px-3 py-1 text-xs hover:bg-destructive/20"
|
||||||
>
|
>
|
||||||
다시 시도
|
다시 시도
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -140,21 +139,21 @@ export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetPr
|
||||||
if (!element?.dataSource?.query) {
|
if (!element?.dataSource?.query) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<div className="text-muted-foreground text-center">
|
<div className="text-center text-muted-foreground">
|
||||||
<p className="text-sm">클릭하여 데이터를 연결하세요</p>
|
<p className="text-sm">⚙️ 톱니바퀴를 클릭하여 데이터를 연결하세요</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background flex h-full flex-col overflow-hidden p-4">
|
<div className="flex h-full flex-col overflow-hidden bg-background p-4">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h3 className="text-foreground text-lg font-semibold">고객 클레임/이슈</h3>
|
<h3 className="text-lg font-semibold text-foreground">고객 클레임/이슈</h3>
|
||||||
<button
|
<button
|
||||||
onClick={loadData}
|
onClick={loadData}
|
||||||
className="text-muted-foreground hover:bg-accent hover:text-accent-foreground rounded-full p-1"
|
className="rounded-full p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
title="새로고침"
|
title="새로고침"
|
||||||
>
|
>
|
||||||
🔄
|
🔄
|
||||||
|
|
@ -206,48 +205,48 @@ export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetPr
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 총 건수 */}
|
{/* 총 건수 */}
|
||||||
<div className="text-muted-foreground mb-3 text-sm">
|
<div className="mb-3 text-sm text-muted-foreground">
|
||||||
총 <span className="text-foreground font-semibold">{filteredIssues.length}</span>건
|
총 <span className="font-semibold text-foreground">{filteredIssues.length}</span>건
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 이슈 리스트 */}
|
{/* 이슈 리스트 */}
|
||||||
<div className="flex-1 space-y-2 overflow-auto">
|
<div className="flex-1 space-y-2 overflow-auto">
|
||||||
{filteredIssues.length === 0 ? (
|
{filteredIssues.length === 0 ? (
|
||||||
<div className="text-muted-foreground flex h-full items-center justify-center text-center">
|
<div className="flex h-full items-center justify-center text-center text-muted-foreground">
|
||||||
<p>이슈가 없습니다</p>
|
<p>이슈가 없습니다</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredIssues.map((issue, index) => (
|
filteredIssues.map((issue, index) => (
|
||||||
<div
|
<div
|
||||||
key={issue.id || index}
|
key={issue.id || index}
|
||||||
className="border-border bg-card rounded-lg border p-3 transition-all hover:shadow-md"
|
className="rounded-lg border border-border bg-card p-3 transition-all hover:shadow-md"
|
||||||
>
|
>
|
||||||
<div className="mb-2 flex items-start justify-between">
|
<div className="mb-2 flex items-start justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="mb-1 flex items-center gap-2">
|
<div className="mb-1 flex items-center gap-2">
|
||||||
<span
|
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${getPriorityBadge(issue.priority || "")}`}>
|
||||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${getPriorityBadge(issue.priority || "")}`}
|
|
||||||
>
|
|
||||||
{issue.priority || "보통"}
|
{issue.priority || "보통"}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${getStatusBadge(issue.status || "")}`}>
|
||||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${getStatusBadge(issue.status || "")}`}
|
|
||||||
>
|
|
||||||
{issue.status || "처리중"}
|
{issue.status || "처리중"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-foreground text-sm font-medium">{issue.issue_type || issue.issueType || "기타"}</p>
|
<p className="text-sm font-medium text-foreground">
|
||||||
|
{issue.issue_type || issue.issueType || "기타"}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-muted-foreground mb-2 text-xs">
|
<p className="mb-2 text-xs text-muted-foreground">
|
||||||
고객: {issue.customer_name || issue.customerName || "-"}
|
고객: {issue.customer_name || issue.customerName || "-"}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p className="text-muted-foreground line-clamp-2 text-xs">{issue.description || "설명 없음"}</p>
|
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||||
|
{issue.description || "설명 없음"}
|
||||||
|
</p>
|
||||||
|
|
||||||
{(issue.created_at || issue.createdAt) && (
|
{(issue.created_at || issue.createdAt) && (
|
||||||
<p className="text-muted-foreground mt-2 text-xs">
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
{new Date(issue.created_at || issue.createdAt || "").toLocaleDateString("ko-KR")}
|
{new Date(issue.created_at || issue.createdAt || "").toLocaleDateString("ko-KR")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -258,3 +257,4 @@ export default function CustomerIssuesWidget({ element }: CustomerIssuesWidgetPr
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -161,7 +161,7 @@ export default function DeliveryStatusSummaryWidget({ element }: DeliveryStatusS
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<div className="text-center text-gray-500">
|
<div className="text-center text-gray-500">
|
||||||
<p className="text-sm">데이터를 연결하세요</p>
|
<p className="text-sm">⚙️ 톱니바퀴를 클릭하여 데이터를 연결하세요</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -183,7 +183,7 @@ export default function DeliveryStatusSummaryWidget({ element }: DeliveryStatusS
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={loadData}
|
onClick={loadData}
|
||||||
className="border-border hover:bg-accent flex h-7 w-7 items-center justify-center rounded border bg-white p-0 text-xs disabled:opacity-50"
|
className="flex h-7 w-7 items-center justify-center rounded border border-border bg-white p-0 text-xs hover:bg-accent disabled:opacity-50"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? "⏳" : "🔄"}
|
{loading ? "⏳" : "🔄"}
|
||||||
|
|
@ -211,3 +211,4 @@ export default function DeliveryStatusSummaryWidget({ element }: DeliveryStatusS
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,7 @@ export default function DeliveryTodayStatsWidget({ element }: DeliveryTodayStats
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<div className="text-center text-gray-500">
|
<div className="text-center text-gray-500">
|
||||||
<p className="text-sm">데이터를 연결하세요</p>
|
<p className="text-sm">⚙️ 톱니바퀴를 클릭하여 데이터를 연결하세요</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -131,7 +131,11 @@ export default function DeliveryTodayStatsWidget({ element }: DeliveryTodayStats
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h3 className="text-lg font-semibold text-gray-800">오늘 처리 현황</h3>
|
<h3 className="text-lg font-semibold text-gray-800">오늘 처리 현황</h3>
|
||||||
<button onClick={loadData} className="rounded-full p-1 text-gray-500 hover:bg-gray-100" title="새로고침">
|
<button
|
||||||
|
onClick={loadData}
|
||||||
|
className="rounded-full p-1 text-gray-500 hover:bg-gray-100"
|
||||||
|
title="새로고침"
|
||||||
|
>
|
||||||
🔄
|
🔄
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -157,3 +161,4 @@ export default function DeliveryTodayStatsWidget({ element }: DeliveryTodayStats
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,66 +22,66 @@ interface ColumnInfo {
|
||||||
const translateColumnName = (colName: string): string => {
|
const translateColumnName = (colName: string): string => {
|
||||||
const columnTranslations: { [key: string]: string } = {
|
const columnTranslations: { [key: string]: string } = {
|
||||||
// 공통
|
// 공통
|
||||||
id: "ID",
|
"id": "ID",
|
||||||
name: "이름",
|
"name": "이름",
|
||||||
status: "상태",
|
"status": "상태",
|
||||||
created_at: "생성일",
|
"created_at": "생성일",
|
||||||
updated_at: "수정일",
|
"updated_at": "수정일",
|
||||||
created_date: "생성일",
|
"created_date": "생성일",
|
||||||
updated_date: "수정일",
|
"updated_date": "수정일",
|
||||||
|
|
||||||
// 기사 관련
|
// 기사 관련
|
||||||
driver_id: "기사ID",
|
"driver_id": "기사ID",
|
||||||
phone: "전화번호",
|
"phone": "전화번호",
|
||||||
license_number: "면허번호",
|
"license_number": "면허번호",
|
||||||
vehicle_id: "차량ID",
|
"vehicle_id": "차량ID",
|
||||||
current_location: "현재위치",
|
"current_location": "현재위치",
|
||||||
rating: "평점",
|
"rating": "평점",
|
||||||
total_deliveries: "총배송건수",
|
"total_deliveries": "총배송건수",
|
||||||
average_delivery_time: "평균배송시간",
|
"average_delivery_time": "평균배송시간",
|
||||||
total_distance: "총운행거리",
|
"total_distance": "총운행거리",
|
||||||
join_date: "가입일",
|
"join_date": "가입일",
|
||||||
last_active: "마지막활동",
|
"last_active": "마지막활동",
|
||||||
|
|
||||||
// 차량 관련
|
// 차량 관련
|
||||||
vehicle_number: "차량번호",
|
"vehicle_number": "차량번호",
|
||||||
model: "모델",
|
"model": "모델",
|
||||||
year: "연식",
|
"year": "연식",
|
||||||
color: "색상",
|
"color": "색상",
|
||||||
type: "종류",
|
"type": "종류",
|
||||||
|
|
||||||
// 배송 관련
|
// 배송 관련
|
||||||
delivery_id: "배송ID",
|
"delivery_id": "배송ID",
|
||||||
order_id: "주문ID",
|
"order_id": "주문ID",
|
||||||
customer_name: "고객명",
|
"customer_name": "고객명",
|
||||||
address: "주소",
|
"address": "주소",
|
||||||
delivery_date: "배송일",
|
"delivery_date": "배송일",
|
||||||
estimated_time: "예상시간",
|
"estimated_time": "예상시간",
|
||||||
|
|
||||||
// 제품 관련
|
// 제품 관련
|
||||||
product_id: "제품ID",
|
"product_id": "제품ID",
|
||||||
product_name: "제품명",
|
"product_name": "제품명",
|
||||||
price: "가격",
|
"price": "가격",
|
||||||
stock: "재고",
|
"stock": "재고",
|
||||||
category: "카테고리",
|
"category": "카테고리",
|
||||||
description: "설명",
|
"description": "설명",
|
||||||
|
|
||||||
// 주문 관련
|
// 주문 관련
|
||||||
order_date: "주문일",
|
"order_date": "주문일",
|
||||||
quantity: "수량",
|
"quantity": "수량",
|
||||||
total_amount: "총금액",
|
"total_amount": "총금액",
|
||||||
payment_status: "결제상태",
|
"payment_status": "결제상태",
|
||||||
|
|
||||||
// 고객 관련
|
// 고객 관련
|
||||||
customer_id: "고객ID",
|
"customer_id": "고객ID",
|
||||||
email: "이메일",
|
"email": "이메일",
|
||||||
company: "회사",
|
"company": "회사",
|
||||||
department: "부서",
|
"department": "부서",
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return columnTranslations[colName.toLowerCase()] ||
|
||||||
columnTranslations[colName.toLowerCase()] || columnTranslations[colName.replace(/_/g, "").toLowerCase()] || colName
|
columnTranslations[colName.replace(/_/g, '').toLowerCase()] ||
|
||||||
);
|
colName;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -171,30 +171,34 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
|
||||||
// 테이블 이름 한글 번역
|
// 테이블 이름 한글 번역
|
||||||
const translateTableName = (name: string): string => {
|
const translateTableName = (name: string): string => {
|
||||||
const tableTranslations: { [key: string]: string } = {
|
const tableTranslations: { [key: string]: string } = {
|
||||||
drivers: "기사",
|
"drivers": "기사",
|
||||||
driver: "기사",
|
"driver": "기사",
|
||||||
vehicles: "차량",
|
"vehicles": "차량",
|
||||||
vehicle: "차량",
|
"vehicle": "차량",
|
||||||
products: "제품",
|
"products": "제품",
|
||||||
product: "제품",
|
"product": "제품",
|
||||||
orders: "주문",
|
"orders": "주문",
|
||||||
order: "주문",
|
"order": "주문",
|
||||||
customers: "고객",
|
"customers": "고객",
|
||||||
customer: "고객",
|
"customer": "고객",
|
||||||
deliveries: "배송",
|
"deliveries": "배송",
|
||||||
delivery: "배송",
|
"delivery": "배송",
|
||||||
users: "사용자",
|
"users": "사용자",
|
||||||
user: "사용자",
|
"user": "사용자",
|
||||||
};
|
};
|
||||||
|
|
||||||
return tableTranslations[name.toLowerCase()] || tableTranslations[name.replace(/_/g, "").toLowerCase()] || name;
|
return tableTranslations[name.toLowerCase()] ||
|
||||||
|
tableTranslations[name.replace(/_/g, '').toLowerCase()] ||
|
||||||
|
name;
|
||||||
};
|
};
|
||||||
|
|
||||||
const displayTitle = tableName ? `${translateTableName(tableName)} 목록` : "데이터 목록";
|
const displayTitle = tableName ? `${translateTableName(tableName)} 목록` : "데이터 목록";
|
||||||
|
|
||||||
// 검색 필터링
|
// 검색 필터링
|
||||||
const filteredData = data.filter((row) =>
|
const filteredData = data.filter((row) =>
|
||||||
Object.values(row).some((value) => String(value).toLowerCase().includes(searchTerm.toLowerCase())),
|
Object.values(row).some((value) =>
|
||||||
|
String(value).toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|
@ -240,6 +244,8 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 rounded-lg bg-blue-50 p-2 text-[10px] text-blue-700">
|
<div className="mt-2 rounded-lg bg-blue-50 p-2 text-[10px] text-blue-700">
|
||||||
|
<p className="font-medium">⚙️ 설정 방법</p>
|
||||||
|
<p className="mt-0.5">우측 상단 톱니바퀴 버튼을 클릭하여</p>
|
||||||
<p>SQL 쿼리를 입력하고 저장하세요</p>
|
<p>SQL 쿼리를 입력하고 저장하세요</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -257,7 +263,7 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={loadData}
|
onClick={loadData}
|
||||||
className="border-border hover:bg-accent flex h-7 w-7 items-center justify-center rounded border bg-white p-0 text-xs disabled:opacity-50"
|
className="flex h-7 w-7 items-center justify-center rounded border border-border bg-white p-0 text-xs hover:bg-accent disabled:opacity-50"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? "⏳" : "🔄"}
|
{loading ? "⏳" : "🔄"}
|
||||||
|
|
@ -272,7 +278,7 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
|
||||||
placeholder="검색..."
|
placeholder="검색..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="focus:border-primary focus:ring-primary w-full rounded border border-gray-300 px-2 py-1 text-xs focus:ring-1 focus:outline-none"
|
className="w-full rounded border border-gray-300 px-2 py-1 text-xs focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -284,7 +290,10 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
|
||||||
<thead className="sticky top-0 bg-gray-100">
|
<thead className="sticky top-0 bg-gray-100">
|
||||||
<tr>
|
<tr>
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<th key={col.key} className="border border-gray-300 px-2 py-1 text-left font-semibold text-gray-700">
|
<th
|
||||||
|
key={col.key}
|
||||||
|
className="border border-gray-300 px-2 py-1 text-left font-semibold text-gray-700"
|
||||||
|
>
|
||||||
{col.label}
|
{col.label}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
|
|
@ -294,7 +303,10 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
|
||||||
{filteredData.map((row, idx) => (
|
{filteredData.map((row, idx) => (
|
||||||
<tr key={idx} className="hover:bg-gray-50">
|
<tr key={idx} className="hover:bg-gray-50">
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<td key={col.key} className="border border-gray-300 px-2 py-1 text-gray-800">
|
<td
|
||||||
|
key={col.key}
|
||||||
|
className="border border-gray-300 px-2 py-1 text-gray-800"
|
||||||
|
>
|
||||||
{String(row[col.key] || "")}
|
{String(row[col.key] || "")}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
|
|
@ -311,3 +323,4 @@ export default function ListSummaryWidget({ element }: ListSummaryWidgetProps) {
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,21 +39,23 @@ interface MarkerData {
|
||||||
// 테이블명 한글 번역
|
// 테이블명 한글 번역
|
||||||
const translateTableName = (name: string): string => {
|
const translateTableName = (name: string): string => {
|
||||||
const tableTranslations: { [key: string]: string } = {
|
const tableTranslations: { [key: string]: string } = {
|
||||||
vehicle_locations: "차량",
|
"vehicle_locations": "차량",
|
||||||
vehicles: "차량",
|
"vehicles": "차량",
|
||||||
warehouses: "창고",
|
"warehouses": "창고",
|
||||||
warehouse: "창고",
|
"warehouse": "창고",
|
||||||
customers: "고객",
|
"customers": "고객",
|
||||||
customer: "고객",
|
"customer": "고객",
|
||||||
deliveries: "배송",
|
"deliveries": "배송",
|
||||||
delivery: "배송",
|
"delivery": "배송",
|
||||||
drivers: "기사",
|
"drivers": "기사",
|
||||||
driver: "기사",
|
"driver": "기사",
|
||||||
stores: "매장",
|
"stores": "매장",
|
||||||
store: "매장",
|
"store": "매장",
|
||||||
};
|
};
|
||||||
|
|
||||||
return tableTranslations[name.toLowerCase()] || tableTranslations[name.replace(/_/g, "").toLowerCase()] || name;
|
return tableTranslations[name.toLowerCase()] ||
|
||||||
|
tableTranslations[name.replace(/_/g, '').toLowerCase()] ||
|
||||||
|
name;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -160,12 +162,12 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
|
||||||
{element?.dataSource?.query ? (
|
{element?.dataSource?.query ? (
|
||||||
<p className="text-xs text-gray-500">총 {markers.length.toLocaleString()}개 마커</p>
|
<p className="text-xs text-gray-500">총 {markers.length.toLocaleString()}개 마커</p>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-orange-500">데이터를 연결하세요</p>
|
<p className="text-xs text-orange-500">⚙️ 톱니바퀴 버튼을 눌러 데이터를 연결하세요</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={loadMapData}
|
onClick={loadMapData}
|
||||||
className="border-border hover:bg-accent flex h-7 w-7 items-center justify-center rounded border bg-white p-0 text-xs disabled:opacity-50"
|
className="flex h-7 w-7 items-center justify-center rounded border border-border bg-white p-0 text-xs hover:bg-accent disabled:opacity-50"
|
||||||
disabled={loading || !element?.dataSource?.query}
|
disabled={loading || !element?.dataSource?.query}
|
||||||
>
|
>
|
||||||
{loading ? "⏳" : "🔄"}
|
{loading ? "⏳" : "🔄"}
|
||||||
|
|
@ -180,7 +182,7 @@ export default function MapSummaryWidget({ element }: MapSummaryWidgetProps) {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 지도 (항상 표시) */}
|
{/* 지도 (항상 표시) */}
|
||||||
<div className="relative z-0 flex-1 overflow-hidden rounded border border-gray-300 bg-white">
|
<div className="relative flex-1 rounded border border-gray-300 bg-white overflow-hidden z-0">
|
||||||
<MapContainer
|
<MapContainer
|
||||||
key={`map-${element.id}`}
|
key={`map-${element.id}`}
|
||||||
center={[36.5, 127.5]}
|
center={[36.5, 127.5]}
|
||||||
|
|
|
||||||
|
|
@ -21,109 +21,109 @@ interface StatusConfig {
|
||||||
// 영어 상태명 → 한글 자동 변환
|
// 영어 상태명 → 한글 자동 변환
|
||||||
const statusTranslations: { [key: string]: string } = {
|
const statusTranslations: { [key: string]: string } = {
|
||||||
// 배송 관련
|
// 배송 관련
|
||||||
delayed: "지연",
|
"delayed": "지연",
|
||||||
pickup_waiting: "픽업 대기",
|
"pickup_waiting": "픽업 대기",
|
||||||
in_transit: "배송 중",
|
"in_transit": "배송 중",
|
||||||
delivered: "배송완료",
|
"delivered": "배송완료",
|
||||||
pending: "대기중",
|
"pending": "대기중",
|
||||||
processing: "처리중",
|
"processing": "처리중",
|
||||||
completed: "완료",
|
"completed": "완료",
|
||||||
cancelled: "취소됨",
|
"cancelled": "취소됨",
|
||||||
failed: "실패",
|
"failed": "실패",
|
||||||
|
|
||||||
// 일반 상태
|
// 일반 상태
|
||||||
active: "활성",
|
"active": "활성",
|
||||||
inactive: "비활성",
|
"inactive": "비활성",
|
||||||
enabled: "사용중",
|
"enabled": "사용중",
|
||||||
disabled: "사용안함",
|
"disabled": "사용안함",
|
||||||
online: "온라인",
|
"online": "온라인",
|
||||||
offline: "오프라인",
|
"offline": "오프라인",
|
||||||
available: "사용가능",
|
"available": "사용가능",
|
||||||
unavailable: "사용불가",
|
"unavailable": "사용불가",
|
||||||
|
|
||||||
// 승인 관련
|
// 승인 관련
|
||||||
approved: "승인됨",
|
"approved": "승인됨",
|
||||||
rejected: "거절됨",
|
"rejected": "거절됨",
|
||||||
waiting: "대기중",
|
"waiting": "대기중",
|
||||||
|
|
||||||
// 차량 관련
|
// 차량 관련
|
||||||
driving: "운행중",
|
"driving": "운행중",
|
||||||
parked: "주차",
|
"parked": "주차",
|
||||||
maintenance: "정비중",
|
"maintenance": "정비중",
|
||||||
|
|
||||||
// 기사 관련 (존중하는 표현)
|
// 기사 관련 (존중하는 표현)
|
||||||
waiting: "대기중",
|
"waiting": "대기중",
|
||||||
resting: "휴식중",
|
"resting": "휴식중",
|
||||||
unavailable: "운행불가",
|
"unavailable": "운행불가",
|
||||||
|
|
||||||
// 기사 평가
|
// 기사 평가
|
||||||
excellent: "우수",
|
"excellent": "우수",
|
||||||
good: "양호",
|
"good": "양호",
|
||||||
average: "보통",
|
"average": "보통",
|
||||||
poor: "미흡",
|
"poor": "미흡",
|
||||||
|
|
||||||
// 기사 경력
|
// 기사 경력
|
||||||
veteran: "베테랑",
|
"veteran": "베테랑",
|
||||||
experienced: "숙련",
|
"experienced": "숙련",
|
||||||
intermediate: "중급",
|
"intermediate": "중급",
|
||||||
beginner: "초급",
|
"beginner": "초급",
|
||||||
};
|
};
|
||||||
|
|
||||||
// 영어 테이블명 → 한글 자동 변환
|
// 영어 테이블명 → 한글 자동 변환
|
||||||
const tableTranslations: { [key: string]: string } = {
|
const tableTranslations: { [key: string]: string } = {
|
||||||
// 배송/물류 관련
|
// 배송/물류 관련
|
||||||
deliveries: "배송",
|
"deliveries": "배송",
|
||||||
delivery: "배송",
|
"delivery": "배송",
|
||||||
shipments: "출하",
|
"shipments": "출하",
|
||||||
shipment: "출하",
|
"shipment": "출하",
|
||||||
orders: "주문",
|
"orders": "주문",
|
||||||
order: "주문",
|
"order": "주문",
|
||||||
cargo: "화물",
|
"cargo": "화물",
|
||||||
cargos: "화물",
|
"cargos": "화물",
|
||||||
packages: "소포",
|
"packages": "소포",
|
||||||
package: "소포",
|
"package": "소포",
|
||||||
|
|
||||||
// 차량 관련
|
// 차량 관련
|
||||||
vehicles: "차량",
|
"vehicles": "차량",
|
||||||
vehicle: "차량",
|
"vehicle": "차량",
|
||||||
vehicle_locations: "차량위치",
|
"vehicle_locations": "차량위치",
|
||||||
vehicle_status: "차량상태",
|
"vehicle_status": "차량상태",
|
||||||
drivers: "기사",
|
"drivers": "기사",
|
||||||
driver: "기사",
|
"driver": "기사",
|
||||||
|
|
||||||
// 사용자/고객 관련
|
// 사용자/고객 관련
|
||||||
users: "사용자",
|
"users": "사용자",
|
||||||
user: "사용자",
|
"user": "사용자",
|
||||||
customers: "고객",
|
"customers": "고객",
|
||||||
customer: "고객",
|
"customer": "고객",
|
||||||
members: "회원",
|
"members": "회원",
|
||||||
member: "회원",
|
"member": "회원",
|
||||||
|
|
||||||
// 제품/재고 관련
|
// 제품/재고 관련
|
||||||
products: "제품",
|
"products": "제품",
|
||||||
product: "제품",
|
"product": "제품",
|
||||||
items: "항목",
|
"items": "항목",
|
||||||
item: "항목",
|
"item": "항목",
|
||||||
inventory: "재고",
|
"inventory": "재고",
|
||||||
stock: "재고",
|
"stock": "재고",
|
||||||
|
|
||||||
// 업무 관련
|
// 업무 관련
|
||||||
tasks: "작업",
|
"tasks": "작업",
|
||||||
task: "작업",
|
"task": "작업",
|
||||||
projects: "프로젝트",
|
"projects": "프로젝트",
|
||||||
project: "프로젝트",
|
"project": "프로젝트",
|
||||||
issues: "이슈",
|
"issues": "이슈",
|
||||||
issue: "이슈",
|
"issue": "이슈",
|
||||||
tickets: "티켓",
|
"tickets": "티켓",
|
||||||
ticket: "티켓",
|
"ticket": "티켓",
|
||||||
|
|
||||||
// 기타
|
// 기타
|
||||||
logs: "로그",
|
"logs": "로그",
|
||||||
log: "로그",
|
"log": "로그",
|
||||||
reports: "리포트",
|
"reports": "리포트",
|
||||||
report: "리포트",
|
"report": "리포트",
|
||||||
alerts: "알림",
|
"alerts": "알림",
|
||||||
alert: "알림",
|
"alert": "알림",
|
||||||
};
|
};
|
||||||
|
|
||||||
interface StatusData {
|
interface StatusData {
|
||||||
|
|
@ -141,7 +141,7 @@ export default function StatusSummaryWidget({
|
||||||
title = "상태 요약",
|
title = "상태 요약",
|
||||||
icon = "📊",
|
icon = "📊",
|
||||||
bgGradient = "from-slate-50 to-blue-50",
|
bgGradient = "from-slate-50 to-blue-50",
|
||||||
statusConfig,
|
statusConfig
|
||||||
}: StatusSummaryWidgetProps) {
|
}: StatusSummaryWidgetProps) {
|
||||||
const [statusData, setStatusData] = useState<StatusData[]>([]);
|
const [statusData, setStatusData] = useState<StatusData[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
@ -320,6 +320,7 @@ export default function StatusSummaryWidget({
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 rounded-lg bg-blue-50 p-2 text-[10px] text-blue-700">
|
<div className="mt-2 rounded-lg bg-blue-50 p-2 text-[10px] text-blue-700">
|
||||||
<p className="font-medium">⚙️ 설정 방법</p>
|
<p className="font-medium">⚙️ 설정 방법</p>
|
||||||
|
<p className="mt-0.5">우측 상단 톱니바퀴 버튼을 클릭하여</p>
|
||||||
<p>SQL 쿼리를 입력하고 저장하세요</p>
|
<p>SQL 쿼리를 입력하고 저장하세요</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -340,7 +341,7 @@ export default function StatusSummaryWidget({
|
||||||
return tableTranslations[name.toLowerCase()];
|
return tableTranslations[name.toLowerCase()];
|
||||||
}
|
}
|
||||||
// 언더스코어 제거하고 매칭 시도
|
// 언더스코어 제거하고 매칭 시도
|
||||||
const nameWithoutUnderscore = name.replace(/_/g, "");
|
const nameWithoutUnderscore = name.replace(/_/g, '');
|
||||||
if (tableTranslations[nameWithoutUnderscore.toLowerCase()]) {
|
if (tableTranslations[nameWithoutUnderscore.toLowerCase()]) {
|
||||||
return tableTranslations[nameWithoutUnderscore.toLowerCase()];
|
return tableTranslations[nameWithoutUnderscore.toLowerCase()];
|
||||||
}
|
}
|
||||||
|
|
@ -356,9 +357,7 @@ export default function StatusSummaryWidget({
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="mb-2 flex flex-shrink-0 items-center justify-between">
|
<div className="mb-2 flex flex-shrink-0 items-center justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="text-sm font-bold text-gray-900">
|
<h3 className="text-sm font-bold text-gray-900">{icon} {displayTitle}</h3>
|
||||||
{icon} {displayTitle}
|
|
||||||
</h3>
|
|
||||||
{totalCount > 0 ? (
|
{totalCount > 0 ? (
|
||||||
<p className="text-xs text-gray-500">총 {totalCount.toLocaleString()}건</p>
|
<p className="text-xs text-gray-500">총 {totalCount.toLocaleString()}건</p>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -367,7 +366,7 @@ export default function StatusSummaryWidget({
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={loadData}
|
onClick={loadData}
|
||||||
className="border-border hover:bg-accent flex h-7 w-7 items-center justify-center rounded border bg-white p-0 text-xs disabled:opacity-50"
|
className="flex h-7 w-7 items-center justify-center rounded border border-border bg-white p-0 text-xs hover:bg-accent disabled:opacity-50"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
{loading ? "⏳" : "🔄"}
|
{loading ? "⏳" : "🔄"}
|
||||||
|
|
@ -381,7 +380,10 @@ export default function StatusSummaryWidget({
|
||||||
{statusData.map((item) => {
|
{statusData.map((item) => {
|
||||||
const colors = getColorClasses(item.status);
|
const colors = getColorClasses(item.status);
|
||||||
return (
|
return (
|
||||||
<div key={item.status} className="rounded border border-gray-200 bg-white p-1.5 shadow-sm">
|
<div
|
||||||
|
key={item.status}
|
||||||
|
className="rounded border border-gray-200 bg-white p-1.5 shadow-sm"
|
||||||
|
>
|
||||||
<div className="mb-0.5 flex items-center gap-1">
|
<div className="mb-0.5 flex items-center gap-1">
|
||||||
<div className={`h-1.5 w-1.5 rounded-full ${colors.dot}`}></div>
|
<div className={`h-1.5 w-1.5 rounded-full ${colors.dot}`}></div>
|
||||||
<div className="text-xs font-medium text-gray-600">{item.status}</div>
|
<div className="text-xs font-medium text-gray-600">{item.status}</div>
|
||||||
|
|
@ -395,3 +397,4 @@ export default function StatusSummaryWidget({
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -100,14 +100,7 @@ export default function TransportStatsWidget({ element, refreshInterval = 60000
|
||||||
const weightKeys = ["weight", "cargo_weight", "total_weight", "중량", "무게"];
|
const weightKeys = ["weight", "cargo_weight", "total_weight", "중량", "무게"];
|
||||||
const distanceKeys = ["distance", "total_distance", "거리", "주행거리"];
|
const distanceKeys = ["distance", "total_distance", "거리", "주행거리"];
|
||||||
const onTimeKeys = ["is_on_time", "on_time", "onTime", "정시", "정시도착"];
|
const onTimeKeys = ["is_on_time", "on_time", "onTime", "정시", "정시도착"];
|
||||||
const deliveryTimeKeys = [
|
const deliveryTimeKeys = ["delivery_duration", "delivery_time", "duration", "배송시간", "소요시간", "배송소요시간"];
|
||||||
"delivery_duration",
|
|
||||||
"delivery_time",
|
|
||||||
"duration",
|
|
||||||
"배송시간",
|
|
||||||
"소요시간",
|
|
||||||
"배송소요시간",
|
|
||||||
];
|
|
||||||
|
|
||||||
// 총 운송량 찾기
|
// 총 운송량 찾기
|
||||||
let total_weight = 0;
|
let total_weight = 0;
|
||||||
|
|
@ -174,14 +167,7 @@ export default function TransportStatsWidget({ element, refreshInterval = 60000
|
||||||
// 2. 배송시간 컬럼이 없으면 날짜 컬럼에서 자동 계산
|
// 2. 배송시간 컬럼이 없으면 날짜 컬럼에서 자동 계산
|
||||||
if (!foundTimeColumn) {
|
if (!foundTimeColumn) {
|
||||||
const startTimeKeys = ["created_at", "start_time", "departure_time", "출발시간", "시작시간"];
|
const startTimeKeys = ["created_at", "start_time", "departure_time", "출발시간", "시작시간"];
|
||||||
const endTimeKeys = [
|
const endTimeKeys = ["actual_delivery", "end_time", "arrival_time", "도착시간", "완료시간", "estimated_delivery"];
|
||||||
"actual_delivery",
|
|
||||||
"end_time",
|
|
||||||
"arrival_time",
|
|
||||||
"도착시간",
|
|
||||||
"완료시간",
|
|
||||||
"estimated_delivery",
|
|
||||||
];
|
|
||||||
|
|
||||||
let startKey = null;
|
let startKey = null;
|
||||||
let endKey = null;
|
let endKey = null;
|
||||||
|
|
@ -261,7 +247,9 @@ export default function TransportStatsWidget({ element, refreshInterval = 60000
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mb-2 text-4xl">⚠️</div>
|
<div className="mb-2 text-4xl">⚠️</div>
|
||||||
<div className="text-sm font-medium text-gray-600">{error || "데이터 없음"}</div>
|
<div className="text-sm font-medium text-gray-600">{error || "데이터 없음"}</div>
|
||||||
{!element?.dataSource?.query && <div className="mt-2 text-xs text-gray-500">쿼리를 설정하세요</div>}
|
{!element?.dataSource?.query && (
|
||||||
|
<div className="mt-2 text-xs text-gray-500">톱니바퀴 아이콘을 클릭하여 쿼리를 설정하세요</div>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={loadData}
|
onClick={loadData}
|
||||||
className="mt-3 rounded-lg bg-blue-500 px-4 py-2 text-sm text-white hover:bg-blue-600"
|
className="mt-3 rounded-lg bg-blue-500 px-4 py-2 text-sm text-white hover:bg-blue-600"
|
||||||
|
|
|
||||||
|
|
@ -123,12 +123,7 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
|
||||||
loadVehicles();
|
loadVehicles();
|
||||||
const interval = setInterval(loadVehicles, refreshInterval);
|
const interval = setInterval(loadVehicles, refreshInterval);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [
|
}, [element?.dataSource?.query, element?.chartConfig?.latitudeColumn, element?.chartConfig?.longitudeColumn, refreshInterval]);
|
||||||
element?.dataSource?.query,
|
|
||||||
element?.chartConfig?.latitudeColumn,
|
|
||||||
element?.chartConfig?.longitudeColumn,
|
|
||||||
refreshInterval,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 쿼리 없으면 빈 지도만 표시 (안내 메시지 제거)
|
// 쿼리 없으면 빈 지도만 표시 (안내 메시지 제거)
|
||||||
|
|
||||||
|
|
@ -177,7 +172,7 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
|
||||||
|
|
||||||
{/* 지도 영역 - 브이월드 타일맵 */}
|
{/* 지도 영역 - 브이월드 타일맵 */}
|
||||||
<div className="h-[calc(100%-60px)]">
|
<div className="h-[calc(100%-60px)]">
|
||||||
<div className="relative z-0 h-full overflow-hidden rounded-lg border-2 border-gray-300 bg-white">
|
<div className="relative h-full overflow-hidden rounded-lg border-2 border-gray-300 bg-white z-0">
|
||||||
<MapContainer
|
<MapContainer
|
||||||
key={`vehicle-map-${element.id}`}
|
key={`vehicle-map-${element.id}`}
|
||||||
center={[36.5, 127.5]}
|
center={[36.5, 127.5]}
|
||||||
|
|
@ -234,7 +229,7 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
|
|
||||||
{/* 지도 정보 */}
|
{/* 지도 정보 */}
|
||||||
<div className="absolute top-2 right-2 z-[1000] rounded-lg bg-white/90 p-2 shadow-lg backdrop-blur-sm">
|
<div className="absolute right-2 top-2 z-[1000] rounded-lg bg-white/90 p-2 shadow-lg backdrop-blur-sm">
|
||||||
<div className="text-xs text-gray-600">
|
<div className="text-xs text-gray-600">
|
||||||
<div className="mb-1 font-semibold">🗺️ 브이월드 (VWorld)</div>
|
<div className="mb-1 font-semibold">🗺️ 브이월드 (VWorld)</div>
|
||||||
<div className="text-xs">국토교통부 공식 지도</div>
|
<div className="text-xs">국토교통부 공식 지도</div>
|
||||||
|
|
@ -246,7 +241,9 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
|
||||||
{vehicles.length > 0 ? (
|
{vehicles.length > 0 ? (
|
||||||
<div className="text-xs font-semibold text-gray-900">총 {vehicles.length}대 모니터링 중</div>
|
<div className="text-xs font-semibold text-gray-900">총 {vehicles.length}대 모니터링 중</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-xs text-gray-600">데이터를 연결하세요</div>
|
<div className="text-xs text-gray-600">
|
||||||
|
⚙️ 톱니바퀴 클릭하여 데이터 연결
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -254,3 +251,4 @@ export default function VehicleMapOnlyWidget({ element, refreshInterval = 30000
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,13 @@
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { DashboardElement } from "@/components/admin/dashboard/types";
|
import { DashboardElement } from "@/components/admin/dashboard/types";
|
||||||
import { WORK_TYPE_LABELS, WORK_STATUS_LABELS, WORK_STATUS_COLORS, WorkType, WorkStatus } from "@/types/workHistory";
|
import {
|
||||||
|
WORK_TYPE_LABELS,
|
||||||
|
WORK_STATUS_LABELS,
|
||||||
|
WORK_STATUS_COLORS,
|
||||||
|
WorkType,
|
||||||
|
WorkStatus,
|
||||||
|
} from "@/types/workHistory";
|
||||||
|
|
||||||
interface WorkHistoryWidgetProps {
|
interface WorkHistoryWidgetProps {
|
||||||
element: DashboardElement;
|
element: DashboardElement;
|
||||||
|
|
@ -91,7 +97,11 @@ export default function WorkHistoryWidget({ element, refreshInterval = 60000 }:
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="mb-2 text-4xl">⚠️</div>
|
<div className="mb-2 text-4xl">⚠️</div>
|
||||||
<div className="text-sm font-medium text-gray-600">{error}</div>
|
<div className="text-sm font-medium text-gray-600">{error}</div>
|
||||||
{!element.dataSource?.query && <div className="mt-2 text-xs text-gray-500">쿼리를 설정하세요</div>}
|
{!element.dataSource?.query && (
|
||||||
|
<div className="mt-2 text-xs text-gray-500">
|
||||||
|
톱니바퀴 아이콘을 클릭하여 쿼리를 설정하세요
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={loadData}
|
onClick={loadData}
|
||||||
className="mt-3 rounded-lg bg-blue-500 px-4 py-2 text-sm text-white hover:bg-blue-600"
|
className="mt-3 rounded-lg bg-blue-500 px-4 py-2 text-sm text-white hover:bg-blue-600"
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,8 @@ import {
|
||||||
Loader2,
|
Loader2,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { MailDetail, getMailDetail, markMailAsRead, downloadMailAttachment } from "@/lib/api/mail";
|
import { MailDetail, getMailDetail, markMailAsRead } from "@/lib/api/mail";
|
||||||
import DOMPurify from "isomorphic-dompurify";
|
import DOMPurify from "isomorphic-dompurify";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
interface MailDetailModalProps {
|
interface MailDetailModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
|
@ -36,7 +35,6 @@ export default function MailDetailModal({
|
||||||
mailId,
|
mailId,
|
||||||
onMailRead,
|
onMailRead,
|
||||||
}: MailDetailModalProps) {
|
}: MailDetailModalProps) {
|
||||||
const router = useRouter();
|
|
||||||
const [mail, setMail] = useState<MailDetail | null>(null);
|
const [mail, setMail] = useState<MailDetail | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -148,9 +146,30 @@ export default function MailDetailModal({
|
||||||
try {
|
try {
|
||||||
console.log(`🖼️ 이미지 로드 시작 - index: ${index}`);
|
console.log(`🖼️ 이미지 로드 시작 - index: ${index}`);
|
||||||
const seqno = parseInt(mailId.split("-").pop() || "0", 10);
|
const seqno = parseInt(mailId.split("-").pop() || "0", 10);
|
||||||
|
const token = localStorage.getItem("authToken");
|
||||||
|
|
||||||
// API 함수 사용
|
console.log(`🔑 토큰 확인: ${token ? '있음' : '없음'}`);
|
||||||
const blob = await downloadMailAttachment(accountId, seqno, index);
|
|
||||||
|
// 🔧 임시: 백엔드 직접 호출 (프록시 우회)
|
||||||
|
const backendUrl = process.env.NODE_ENV === 'production'
|
||||||
|
? `http://39.117.244.52:8080/api/mail/receive/${accountId}/${seqno}/attachment/${index}`
|
||||||
|
: `http://localhost:8080/api/mail/receive/${accountId}/${seqno}/attachment/${index}`;
|
||||||
|
|
||||||
|
console.log(`📍 요청 URL: ${backendUrl}`);
|
||||||
|
|
||||||
|
const response = await fetch(backendUrl, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`📡 응답 상태: ${response.status}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
const blobUrl = URL.createObjectURL(blob);
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
console.log(`✅ Blob URL 생성 완료: ${blobUrl}`);
|
console.log(`✅ Blob URL 생성 완료: ${blobUrl}`);
|
||||||
|
|
@ -164,9 +183,24 @@ export default function MailDetailModal({
|
||||||
const handleDownloadAttachment = async (index: number, filename: string) => {
|
const handleDownloadAttachment = async (index: number, filename: string) => {
|
||||||
try {
|
try {
|
||||||
const seqno = parseInt(mailId.split("-").pop() || "0", 10);
|
const seqno = parseInt(mailId.split("-").pop() || "0", 10);
|
||||||
|
const token = localStorage.getItem("authToken");
|
||||||
|
|
||||||
// API 함수 사용
|
// 🔧 임시: 백엔드 직접 호출 (프록시 우회)
|
||||||
const blob = await downloadMailAttachment(accountId, seqno, index);
|
const backendUrl = process.env.NODE_ENV === 'production'
|
||||||
|
? `http://39.117.244.52:8080/api/mail/receive/${accountId}/${seqno}/attachment/${index}`
|
||||||
|
: `http://localhost:8080/api/mail/receive/${accountId}/${seqno}/attachment/${index}`;
|
||||||
|
|
||||||
|
const response = await fetch(backendUrl, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
|
|
||||||
// 다운로드 트리거
|
// 다운로드 트리거
|
||||||
|
|
@ -238,42 +272,11 @@ export default function MailDetailModal({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button variant="outline" size="sm">
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
if (!mail) return;
|
|
||||||
const replyData = {
|
|
||||||
type: 'reply',
|
|
||||||
originalFrom: mail.from,
|
|
||||||
originalSubject: mail.subject,
|
|
||||||
originalDate: mail.date,
|
|
||||||
originalBody: mail.body,
|
|
||||||
};
|
|
||||||
router.push(`/admin/mail/send?action=reply&data=${encodeURIComponent(JSON.stringify(replyData))}`);
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Reply className="w-4 h-4 mr-2" />
|
<Reply className="w-4 h-4 mr-2" />
|
||||||
답장
|
답장
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button variant="outline" size="sm">
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
if (!mail) return;
|
|
||||||
const forwardData = {
|
|
||||||
type: 'forward',
|
|
||||||
originalFrom: mail.from,
|
|
||||||
originalSubject: mail.subject,
|
|
||||||
originalDate: mail.date,
|
|
||||||
originalBody: mail.body,
|
|
||||||
originalAttachments: mail.attachments,
|
|
||||||
};
|
|
||||||
router.push(`/admin/mail/send?action=forward&data=${encodeURIComponent(JSON.stringify(forwardData))}`);
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Forward className="w-4 h-4 mr-2" />
|
<Forward className="w-4 h-4 mr-2" />
|
||||||
전달
|
전달
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -1,381 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
|
||||||
import { Bell, Mail, AlertCircle, XCircle, CheckCircle2 } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { getSentMailList, getReceivedMails, getMailAccounts } from "@/lib/api/mail";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
|
|
||||||
interface MailNotification {
|
|
||||||
id: string;
|
|
||||||
type: "new_mail" | "send_failed" | "limit_warning";
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
timestamp: string;
|
|
||||||
read: boolean;
|
|
||||||
url?: string; // 이동할 URL 추가
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MailNotifications() {
|
|
||||||
const router = useRouter();
|
|
||||||
const [notifications, setNotifications] = useState<MailNotification[]>([]);
|
|
||||||
const [unreadCount, setUnreadCount] = useState(0);
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [readNotificationIds, setReadNotificationIds] = useState<Set<string>>(new Set());
|
|
||||||
const [isInitialized, setIsInitialized] = useState(false);
|
|
||||||
|
|
||||||
// localStorage에서 읽은 알림 ID 로드 (최우선)
|
|
||||||
useEffect(() => {
|
|
||||||
const stored = localStorage.getItem('readNotificationIds');
|
|
||||||
if (stored) {
|
|
||||||
try {
|
|
||||||
const ids = JSON.parse(stored);
|
|
||||||
setReadNotificationIds(new Set(ids));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('읽은 알림 ID 로드 실패:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setIsInitialized(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// localStorage 로드 완료 후에만 알림 로드
|
|
||||||
if (!isInitialized) return;
|
|
||||||
|
|
||||||
// 알림 로드
|
|
||||||
loadNotifications();
|
|
||||||
|
|
||||||
// 5초마다 새 알림 확인 (더 빠른 실시간 업데이트)
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
checkNewNotifications();
|
|
||||||
}, 5000);
|
|
||||||
|
|
||||||
// 메일 발송/수신 이벤트 리스너 (즉시 갱신)
|
|
||||||
const handleMailEvent = () => {
|
|
||||||
console.log('📧 메일 이벤트 감지 - 알림 갱신');
|
|
||||||
checkNewNotifications();
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('mail-sent', handleMailEvent);
|
|
||||||
window.addEventListener('mail-received', handleMailEvent);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
clearInterval(interval);
|
|
||||||
window.removeEventListener('mail-sent', handleMailEvent);
|
|
||||||
window.removeEventListener('mail-received', handleMailEvent);
|
|
||||||
};
|
|
||||||
}, [isInitialized, readNotificationIds]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const count = notifications.filter((n) => !n.read).length;
|
|
||||||
setUnreadCount(count);
|
|
||||||
}, [notifications]);
|
|
||||||
|
|
||||||
// 알림 패널이 열리면 3초 후 자동으로 읽음 처리
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen && notifications.some((n) => !n.read)) {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
markAllAsRead();
|
|
||||||
}, 3000); // 3초 후 자동 읽음 처리
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [isOpen, notifications]);
|
|
||||||
|
|
||||||
const loadNotifications = async () => {
|
|
||||||
try {
|
|
||||||
const newNotifications: MailNotification[] = [];
|
|
||||||
|
|
||||||
// 1. 최근 발송 실패한 메일 확인 (최근 1시간)
|
|
||||||
try {
|
|
||||||
const sentMails = await getSentMailList({
|
|
||||||
page: 1,
|
|
||||||
limit: 20,
|
|
||||||
status: 'failed',
|
|
||||||
});
|
|
||||||
|
|
||||||
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000);
|
|
||||||
|
|
||||||
sentMails.items?.forEach((mail) => {
|
|
||||||
const sentDate = new Date(mail.sentAt);
|
|
||||||
if (sentDate > oneHourAgo) {
|
|
||||||
newNotifications.push({
|
|
||||||
id: `failed-${mail.id}`,
|
|
||||||
type: "send_failed",
|
|
||||||
title: "메일 발송 실패",
|
|
||||||
message: `${mail.to.join(', ')}에게 보낸 메일이 실패했습니다.`,
|
|
||||||
timestamp: mail.sentAt,
|
|
||||||
read: false,
|
|
||||||
url: '/admin/mail/sent', // 보낸메일함으로 이동
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('발송 실패 메일 확인 오류:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 최근 수신 메일 확인 (최근 30분)
|
|
||||||
try {
|
|
||||||
const accounts = await getMailAccounts();
|
|
||||||
const activeAccounts = accounts.filter((acc) => acc.status === 'active');
|
|
||||||
|
|
||||||
for (const account of activeAccounts) {
|
|
||||||
try {
|
|
||||||
const receivedMails = await getReceivedMails(account.id, 10);
|
|
||||||
const thirtyMinutesAgo = new Date(Date.now() - 30 * 60 * 1000);
|
|
||||||
|
|
||||||
receivedMails.forEach((mail) => {
|
|
||||||
const receivedDate = new Date(mail.date);
|
|
||||||
if (receivedDate > thirtyMinutesAgo && !mail.isRead) {
|
|
||||||
newNotifications.push({
|
|
||||||
id: `new-${mail.id}`,
|
|
||||||
type: "new_mail",
|
|
||||||
title: "새 메일 도착",
|
|
||||||
message: `${mail.from}에서 메일이 도착했습니다: ${mail.subject}`,
|
|
||||||
timestamp: mail.date,
|
|
||||||
read: false,
|
|
||||||
url: `/admin/mail/receive?mailId=${mail.id}&accountId=${account.id}`, // 특정 메일로 이동
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`계정 ${account.id} 수신 메일 확인 오류:`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('수신 메일 확인 오류:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 일일 발송 제한 경고 확인
|
|
||||||
try {
|
|
||||||
const accounts = await getMailAccounts();
|
|
||||||
const sentMails = await getSentMailList({
|
|
||||||
page: 1,
|
|
||||||
limit: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
accounts.forEach((account) => {
|
|
||||||
if (account.status === 'active' && account.dailyLimit) {
|
|
||||||
const todaySentCount = sentMails.items?.filter((mail) => {
|
|
||||||
const sentDate = new Date(mail.sentAt);
|
|
||||||
const today = new Date();
|
|
||||||
return (
|
|
||||||
mail.accountId === account.id &&
|
|
||||||
sentDate.toDateString() === today.toDateString()
|
|
||||||
);
|
|
||||||
}).length || 0;
|
|
||||||
|
|
||||||
const usagePercent = (todaySentCount / account.dailyLimit) * 100;
|
|
||||||
|
|
||||||
if (usagePercent >= 80) {
|
|
||||||
newNotifications.push({
|
|
||||||
id: `limit-${account.id}`,
|
|
||||||
type: "limit_warning",
|
|
||||||
title: "일일 발송 제한 경고",
|
|
||||||
message: `${account.name} 계정이 일일 제한의 ${usagePercent.toFixed(0)}%를 사용했습니다 (${todaySentCount}/${account.dailyLimit})`,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
read: false,
|
|
||||||
url: '/admin/mail/accounts', // 계정 관리로 이동
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('일일 제한 확인 오류:', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 최신순 정렬
|
|
||||||
newNotifications.sort((a, b) =>
|
|
||||||
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
|
||||||
);
|
|
||||||
|
|
||||||
// 읽은 알림 표시 적용
|
|
||||||
const notificationsWithReadStatus = newNotifications.map((notification) => ({
|
|
||||||
...notification,
|
|
||||||
read: readNotificationIds.has(notification.id),
|
|
||||||
}));
|
|
||||||
|
|
||||||
setNotifications(notificationsWithReadStatus);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('알림 로드 실패:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkNewNotifications = () => {
|
|
||||||
loadNotifications();
|
|
||||||
};
|
|
||||||
|
|
||||||
const markAsRead = (id: string) => {
|
|
||||||
const newReadIds = new Set(readNotificationIds);
|
|
||||||
newReadIds.add(id);
|
|
||||||
setReadNotificationIds(newReadIds);
|
|
||||||
|
|
||||||
// localStorage에 저장
|
|
||||||
localStorage.setItem('readNotificationIds', JSON.stringify(Array.from(newReadIds)));
|
|
||||||
|
|
||||||
setNotifications((prev) =>
|
|
||||||
prev.map((n) => (n.id === id ? { ...n, read: true } : n))
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNotificationClick = (notification: MailNotification) => {
|
|
||||||
// 읽음 처리
|
|
||||||
markAsRead(notification.id);
|
|
||||||
|
|
||||||
// URL이 있으면 해당 페이지로 이동
|
|
||||||
if (notification.url) {
|
|
||||||
router.push(notification.url);
|
|
||||||
setIsOpen(false); // 팝오버 닫기
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const markAllAsRead = () => {
|
|
||||||
const allIds = new Set([...readNotificationIds, ...notifications.map((n) => n.id)]);
|
|
||||||
setReadNotificationIds(allIds);
|
|
||||||
|
|
||||||
// localStorage에 저장
|
|
||||||
localStorage.setItem('readNotificationIds', JSON.stringify(Array.from(allIds)));
|
|
||||||
|
|
||||||
setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearAll = () => {
|
|
||||||
// 모든 알림을 읽음으로 표시하고 localStorage에 저장
|
|
||||||
const allIds = new Set([...readNotificationIds, ...notifications.map((n) => n.id)]);
|
|
||||||
setReadNotificationIds(allIds);
|
|
||||||
localStorage.setItem('readNotificationIds', JSON.stringify(Array.from(allIds)));
|
|
||||||
|
|
||||||
setNotifications([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getIcon = (type: MailNotification["type"]) => {
|
|
||||||
switch (type) {
|
|
||||||
case "new_mail":
|
|
||||||
return <Mail className="h-4 w-4 text-blue-600" />;
|
|
||||||
case "send_failed":
|
|
||||||
return <XCircle className="h-4 w-4 text-red-600" />;
|
|
||||||
case "limit_warning":
|
|
||||||
return <AlertCircle className="h-4 w-4 text-yellow-600" />;
|
|
||||||
default:
|
|
||||||
return <Bell className="h-4 w-4" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTypeColor = (type: MailNotification["type"]) => {
|
|
||||||
switch (type) {
|
|
||||||
case "new_mail":
|
|
||||||
return "bg-blue-50 border-blue-200";
|
|
||||||
case "send_failed":
|
|
||||||
return "bg-red-50 border-red-200";
|
|
||||||
case "limit_warning":
|
|
||||||
return "bg-yellow-50 border-yellow-200";
|
|
||||||
default:
|
|
||||||
return "bg-muted";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button variant="outline" size="icon" className="relative">
|
|
||||||
<Bell className="h-5 w-5" />
|
|
||||||
{unreadCount > 0 && (
|
|
||||||
<Badge
|
|
||||||
variant="destructive"
|
|
||||||
className="absolute -right-1 -top-1 h-5 w-5 rounded-full p-0 text-xs flex items-center justify-center"
|
|
||||||
>
|
|
||||||
{unreadCount > 9 ? "9+" : unreadCount}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-96 p-0" align="end">
|
|
||||||
<div className="flex items-center justify-between p-4 border-b">
|
|
||||||
<h3 className="font-semibold text-base">알림</h3>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{unreadCount > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={markAllAsRead}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
모두 읽음
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{notifications.length > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={clearAll}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
모두 삭제
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-h-96 overflow-y-auto">
|
|
||||||
{notifications.filter((n) => !n.read).length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
||||||
<CheckCircle2 className="h-12 w-12 text-muted-foreground mb-3" />
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
새로운 알림이 없습니다
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="divide-y">
|
|
||||||
{notifications.filter((n) => !n.read).map((notification) => (
|
|
||||||
<div
|
|
||||||
key={notification.id}
|
|
||||||
className={`p-4 hover:bg-muted/50 transition-colors cursor-pointer ${
|
|
||||||
!notification.read ? "bg-muted/30" : ""
|
|
||||||
}`}
|
|
||||||
onClick={() => handleNotificationClick(notification)}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<div
|
|
||||||
className={`p-2 rounded-lg ${getTypeColor(notification.type)}`}
|
|
||||||
>
|
|
||||||
{getIcon(notification.type)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<h4 className="font-medium text-sm truncate">
|
|
||||||
{notification.title}
|
|
||||||
</h4>
|
|
||||||
{!notification.read && (
|
|
||||||
<div className="w-2 h-2 bg-blue-600 rounded-full flex-shrink-0" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
|
||||||
{notification.message}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
{new Date(notification.timestamp).toLocaleString("ko-KR", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -3,9 +3,6 @@
|
||||||
* 파일 기반 메일 계정 및 템플릿 관리
|
* 파일 기반 메일 계정 및 템플릿 관리
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// API 기본 URL
|
|
||||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "/api";
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 타입 정의
|
// 타입 정의
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
@ -107,28 +104,23 @@ export interface SentMailHistory {
|
||||||
templateName?: string;
|
templateName?: string;
|
||||||
attachments?: AttachmentInfo[];
|
attachments?: AttachmentInfo[];
|
||||||
sentAt: string;
|
sentAt: string;
|
||||||
status: 'success' | 'failed' | 'draft';
|
status: 'success' | 'failed';
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
accepted?: string[];
|
accepted?: string[];
|
||||||
rejected?: string[];
|
rejected?: string[];
|
||||||
isDraft?: boolean;
|
|
||||||
deletedAt?: string;
|
|
||||||
updatedAt?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SentMailListQuery {
|
export interface SentMailListQuery {
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
searchTerm?: string;
|
searchTerm?: string;
|
||||||
status?: 'success' | 'failed' | 'draft' | 'all';
|
status?: 'success' | 'failed' | 'all';
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
sortBy?: 'sentAt' | 'subject' | 'updatedAt';
|
sortBy?: 'sentAt' | 'subject';
|
||||||
sortOrder?: 'asc' | 'desc';
|
sortOrder?: 'asc' | 'desc';
|
||||||
includeDeleted?: boolean;
|
|
||||||
onlyDeleted?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SentMailListResponse {
|
export interface SentMailListResponse {
|
||||||
|
|
@ -343,53 +335,6 @@ export async function sendMail(data: SendMailDto): Promise<MailSendResult> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 대량 메일 발송
|
|
||||||
*/
|
|
||||||
export interface BulkSendRequest {
|
|
||||||
accountId: string;
|
|
||||||
templateId?: string; // 템플릿 ID (선택)
|
|
||||||
customHtml?: string; // 직접 작성한 HTML (선택)
|
|
||||||
subject: string;
|
|
||||||
recipients: Array<{
|
|
||||||
email: string;
|
|
||||||
variables?: Record<string, string>; // 템플릿 사용 시에만 필요
|
|
||||||
}>;
|
|
||||||
onProgress?: (sent: number, total: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BulkSendResult {
|
|
||||||
total: number;
|
|
||||||
success: number;
|
|
||||||
failed: number;
|
|
||||||
results: Array<{
|
|
||||||
email: string;
|
|
||||||
success: boolean;
|
|
||||||
messageId?: string;
|
|
||||||
error?: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendBulkMail(request: BulkSendRequest): Promise<BulkSendResult> {
|
|
||||||
const { onProgress, ...data } = request;
|
|
||||||
|
|
||||||
// 프로그레스 콜백이 있으면 시뮬레이션 (실제로는 서버에서 스트리밍 필요)
|
|
||||||
if (onProgress) {
|
|
||||||
onProgress(0, data.recipients.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await fetchApi<BulkSendResult>('/mail/send/bulk', {
|
|
||||||
method: 'POST',
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (onProgress) {
|
|
||||||
onProgress(result.success, result.total);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 템플릿 변수 추출 (템플릿에서 {변수명} 형식 추출)
|
* 템플릿 변수 추출 (템플릿에서 {변수명} 형식 추출)
|
||||||
*/
|
*/
|
||||||
|
|
@ -545,31 +490,6 @@ export async function getMailDetail(
|
||||||
return fetchApi<MailDetail>(`/mail/receive/${accountId}/${seqno}`);
|
return fetchApi<MailDetail>(`/mail/receive/${accountId}/${seqno}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 메일 첨부파일 다운로드
|
|
||||||
*/
|
|
||||||
export async function downloadMailAttachment(
|
|
||||||
accountId: string,
|
|
||||||
seqno: number,
|
|
||||||
attachmentIndex: number
|
|
||||||
): Promise<Blob> {
|
|
||||||
const token = localStorage.getItem('authToken');
|
|
||||||
const response = await fetch(
|
|
||||||
`${API_BASE_URL}/mail/receive/${accountId}/${seqno}/attachment/${attachmentIndex}`,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`첨부파일 다운로드 실패: ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.blob();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 메일을 읽음으로 표시
|
* 메일을 읽음으로 표시
|
||||||
*/
|
*/
|
||||||
|
|
@ -614,8 +534,6 @@ export async function getSentMailList(
|
||||||
if (query.endDate) params.append('endDate', query.endDate);
|
if (query.endDate) params.append('endDate', query.endDate);
|
||||||
if (query.sortBy) params.append('sortBy', query.sortBy);
|
if (query.sortBy) params.append('sortBy', query.sortBy);
|
||||||
if (query.sortOrder) params.append('sortOrder', query.sortOrder);
|
if (query.sortOrder) params.append('sortOrder', query.sortOrder);
|
||||||
if (query.includeDeleted) params.append('includeDeleted', 'true');
|
|
||||||
if (query.onlyDeleted) params.append('onlyDeleted', 'true');
|
|
||||||
|
|
||||||
return fetchApi(`/mail/sent?${params.toString()}`);
|
return fetchApi(`/mail/sent?${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
@ -628,7 +546,7 @@ export async function getSentMailById(id: string): Promise<SentMailHistory> {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 발송 이력 삭제 (Soft Delete)
|
* 발송 이력 삭제
|
||||||
*/
|
*/
|
||||||
export async function deleteSentMail(id: string): Promise<{ success: boolean; message: string }> {
|
export async function deleteSentMail(id: string): Promise<{ success: boolean; message: string }> {
|
||||||
return fetchApi(`/mail/sent/${id}`, {
|
return fetchApi(`/mail/sent/${id}`, {
|
||||||
|
|
@ -636,70 +554,6 @@ export async function deleteSentMail(id: string): Promise<{ success: boolean; me
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 임시 저장 (Draft)
|
|
||||||
*/
|
|
||||||
export async function saveDraft(data: Partial<SentMailHistory> & { accountId: string }): Promise<SentMailHistory> {
|
|
||||||
const response = await apiClient.post('/mail/sent/draft', data);
|
|
||||||
return response.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 임시 저장 업데이트
|
|
||||||
*/
|
|
||||||
export async function updateDraft(id: string, data: Partial<SentMailHistory>): Promise<SentMailHistory> {
|
|
||||||
const response = await apiClient.put(`/mail/sent/draft/${id}`, data);
|
|
||||||
return response.data.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 메일 복구
|
|
||||||
*/
|
|
||||||
export async function restoreMail(id: string): Promise<{ success: boolean; message: string }> {
|
|
||||||
return fetchApi(`/mail/sent/${id}/restore`, {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 메일 영구 삭제
|
|
||||||
*/
|
|
||||||
export async function permanentlyDeleteMail(id: string): Promise<{ success: boolean; message: string }> {
|
|
||||||
return fetchApi(`/mail/sent/${id}/permanent`, {
|
|
||||||
method: 'DELETE',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 일괄 삭제
|
|
||||||
*/
|
|
||||||
export async function bulkDeleteMails(ids: string[]): Promise<{ success: boolean; message: string; data: { successCount: number; failCount: number } }> {
|
|
||||||
return fetchApi('/mail/sent/bulk/delete', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ ids }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 일괄 영구 삭제
|
|
||||||
*/
|
|
||||||
export async function bulkPermanentlyDeleteMails(ids: string[]): Promise<{ success: boolean; message: string; data: { successCount: number; failCount: number } }> {
|
|
||||||
return fetchApi('/mail/sent/bulk/permanent-delete', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ ids }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 일괄 복구
|
|
||||||
*/
|
|
||||||
export async function bulkRestoreMails(ids: string[]): Promise<{ success: boolean; message: string; data: { successCount: number; failCount: number } }> {
|
|
||||||
return fetchApi('/mail/sent/bulk/restore', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ ids }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 메일 발송 통계 조회
|
* 메일 발송 통계 조회
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue