Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into logistream
This commit is contained in:
commit
b787b027a6
|
|
@ -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,29 +0,0 @@
|
||||||
{
|
|
||||||
"id": "1e492bb1-d069-4242-8cbf-9829b8f6c7e6",
|
|
||||||
"sentAt": "2025-10-13T01:08:34.764Z",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"subject": "제목 없음",
|
|
||||||
"htmlContent": "\n<!DOCTYPE html>\n<html>\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n</head>\n<body style=\"margin: 0; padding: 0; background-color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;\">\n <table role=\"presentation\" style=\"width: 100%; border-collapse: collapse; background-color: #ffffff;\">\n <tr>\n <td style=\"padding: 20px;\">\n<div style=\"margin: 0 0 20px 0; color: #333; font-size: 15px; line-height: 1.6; text-align: left;\"><p>ㄴㅇㄹㄴㅇㄹ</p></div><div style=\"margin: 30px 0; text-align: left;\">\n <a href=\"https://example.com\" style=\"display: inline-block; padding: 14px 28px; background-color: #007bff; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 15px;\">ㄴㅇㄹ버튼</a>\n </div><div style=\"margin: 20px 0; text-align: left;\">\n <img src=\"https://placehold.co/600x200/e5e7eb/64748b?text=Image\" alt=\"\" style=\"max-width: 100%; height: auto; display: block; border-radius: 4px;\" />\n </div><div style=\"height: 20;\"></div><div style=\"margin: 0 0 20px 0; color: #333; font-size: 15px; line-height: 1.6; text-align: left;\"><p>ㄴㅇㄹ</p></div><div style=\"margin: 0 0 20px 0; color: #333; font-size: 15px; line-height: 1.6; text-align: left;\"><p>ㄴㅇㄹ</p></div>\n </td>\n </tr>\n </table>\n\n <div style=\"margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb;\">\n \r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㄴㅇㄹ</p>\r\n </div>\r\n \n </div>\n </body>\n</html>\n",
|
|
||||||
"templateId": "template-1760315158387",
|
|
||||||
"templateName": "테스트2",
|
|
||||||
"attachments": [
|
|
||||||
{
|
|
||||||
"filename": "스크린샷 2025-10-13 오전 10.00.06.png",
|
|
||||||
"originalName": "스크린샷 2025-10-13 오전 10.00.06.png",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1760317712416-622369845.png",
|
|
||||||
"mimetype": "image/png"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"status": "success",
|
|
||||||
"messageId": "<f03bea59-9a77-b454-845e-7ad2a070bade@wace.me>",
|
|
||||||
"accepted": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"rejected": []
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
{
|
|
||||||
"id": "2d848b19-26e1-45ad-8e2c-9205f1f01c87",
|
|
||||||
"sentAt": "2025-10-02T07:50:25.817Z",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"subject": "ㅣ;ㅏㅓ",
|
|
||||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅓㅏㅣ</p>\r\n </div>\r\n ",
|
|
||||||
"attachments": [
|
|
||||||
{
|
|
||||||
"filename": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
|
||||||
"originalName": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759391422625-269479520_test____________________________33.jpg",
|
|
||||||
"mimetype": "image/jpeg"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "UI_áá
¢áá
¥á«áá
¡áá
¡á¼_áá
®á«áá
¥.md",
|
|
||||||
"originalName": "UI_áá
¢áá
¥á«áá
¡áá
¡á¼_áá
®á«áá
¥.md",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759391422626-68453569_UI_______________________________________________.md",
|
|
||||||
"mimetype": "text/x-markdown"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "testáá
á¼ áá
栠
栠
µ2.png",
|
|
||||||
"originalName": "testáá
á¼ áá
栠
栠
µ2.png",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759391422626-168170034_test____________________________2.png",
|
|
||||||
"mimetype": "image/png"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"status": "success",
|
|
||||||
"messageId": "<9d5b8275-e059-3a71-a34a-dea800730aa3@wace.me>",
|
|
||||||
"accepted": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"rejected": []
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"id": "331d95d6-3a13-4657-bc75-ab0811712eb8",
|
|
||||||
"sentAt": "2025-10-22T07:18:18.240Z",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"subject": "ㅁㄴㅇㄹㅁㄴㅇㄹ",
|
|
||||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹㅁㄴㅇㄹㄴㅇㄹㄴㅇㄹ</p>\r\n </div>\r\n ",
|
|
||||||
"status": "success",
|
|
||||||
"messageId": "<d4923c0d-f692-7d1d-d1b0-3b9e1e6cbab5@wace.me>",
|
|
||||||
"accepted": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"rejected": []
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1,41 +0,0 @@
|
||||||
{
|
|
||||||
"id": "37fce6a0-2301-431b-b573-82bdab9b8008",
|
|
||||||
"sentAt": "2025-10-02T07:44:38.128Z",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"subject": "asd",
|
|
||||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">asd</p>\r\n </div>\r\n ",
|
|
||||||
"attachments": [
|
|
||||||
{
|
|
||||||
"filename": "áá
°áá
栠
³-áá
µá·áá
µá¨áá
¯á«-áá
³á
á
©áá
¡áá
µá¯-áá
栠
´áá
栮.key",
|
|
||||||
"originalName": "áá
°áá
栠
³-áá
µá·áá
µá¨áá
¯á«-áá
³á
á
©áá
¡áá
µá¯-áá
栠
´áá
栮.key",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759391076653-58189058___________________-___________________________-___________________________-_____________________.key",
|
|
||||||
"mimetype": "application/x-iwork-keynote-sffkey"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "áá
°áá
栠
³-áá
µá·áá
µá¨áá
¯á«-áá
³á
á
©áá
¡áá
µá¯-áá
栠
´áá
栮.pptx",
|
|
||||||
"originalName": "áá
°áá
栠
³-áá
µá·áá
µá¨áá
¯á«-áá
³á
á
©áá
¡áá
µá¯-áá
栠
´áá
栮.pptx",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759391076736-190208246___________________-___________________________-___________________________-_____________________.pptx",
|
|
||||||
"mimetype": "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
|
||||||
"originalName": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759391076738-240665795_test____________________________33.jpg",
|
|
||||||
"mimetype": "image/jpeg"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"status": "success",
|
|
||||||
"messageId": "<796cb9a7-df62-31c4-ae6b-b42f383d82b4@wace.me>",
|
|
||||||
"accepted": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"rejected": []
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -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,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,29 +0,0 @@
|
||||||
{
|
|
||||||
"id": "9eab902e-f77b-424f-ada4-0ea8709b36bf",
|
|
||||||
"sentAt": "2025-10-13T00:53:55.193Z",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"subject": "제목 없음",
|
|
||||||
"htmlContent": "<div style=\"max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;\"><p style=\"margin: 16px 0; color: #333; font-size: 14px;\"><p>텍스트를 입력하세요...</p></p><div style=\"text-align: center; margin: 24px 0;\">\n <a href=\"https://example.com\" style=\"display: inline-block; padding: 12px 24px; background-color: #007bff; color: #fff; text-decoration: none; border-radius: 4px;\">버튼</a>\n </div><div style=\"text-align: center; margin: 16px 0;\">\n <img src=\"https://placehold.co/600x200/e5e7eb/64748b?text=Image\" alt=\"\" style=\"max-width: 100%; height: auto;\" />\n </div><div style=\"height: 20;\"></div><p style=\"margin: 16px 0; color: #333; font-size: 14px;\"><p>텍스트를 입력하세요...</p></p><p style=\"margin: 16px 0; color: #333; font-size: 14px;\"><p>텍스트를 입력하세요...</p></p>\n <div style=\"margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb;\">\n \r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">어덯게 나오는지 봅시다 추가메시지 영역이빈다.</p>\r\n </div>\r\n \n </div>\n </div>",
|
|
||||||
"templateId": "template-1760315158387",
|
|
||||||
"templateName": "테스트2",
|
|
||||||
"attachments": [
|
|
||||||
{
|
|
||||||
"filename": "한글.txt",
|
|
||||||
"originalName": "한글.txt",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1760316833254-789302611.txt",
|
|
||||||
"mimetype": "text/plain"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"status": "success",
|
|
||||||
"messageId": "<3d0bef10-2e58-fd63-b175-c1f499af0102@wace.me>",
|
|
||||||
"accepted": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"rejected": []
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
{
|
|
||||||
"id": "a1ca39ad-4467-44e0-963a-fba5037c8896",
|
|
||||||
"sentAt": "2025-10-02T08:22:14.721Z",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"subject": "ㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴ",
|
|
||||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹㅁㄴㅇㄹㄴㅁㅇㄹ</p>\r\n </div>\r\n ",
|
|
||||||
"attachments": [
|
|
||||||
{
|
|
||||||
"filename": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
|
||||||
"originalName": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759393332207-791945862_test____________________________33.jpg",
|
|
||||||
"mimetype": "image/jpeg"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "UI_áá
¢áá
¥á«áá
¡áá
¡á¼_áá
®á«áá
¥.md",
|
|
||||||
"originalName": "UI_áá
¢áá
¥á«áá
¡áá
¡á¼_áá
®á«áá
¥.md",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759393332208-660280542_UI_______________________________________________.md",
|
|
||||||
"mimetype": "text/x-markdown"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "testáá
á¼ áá
栠
栠
µ2.png",
|
|
||||||
"originalName": "testáá
á¼ áá
栠
栠
µ2.png",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759393332208-149486455_test____________________________2.png",
|
|
||||||
"mimetype": "image/png"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"status": "success",
|
|
||||||
"messageId": "<d52bab7c-4285-8a27-12ed-b501ff858d23@wace.me>",
|
|
||||||
"accepted": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"rejected": []
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
{
|
|
||||||
"id": "a3a9aab1-4334-46bd-bf50-b867305f66c0",
|
|
||||||
"sentAt": "2025-10-02T08:41:42.086Z",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"subject": "한글테스트",
|
|
||||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
|
|
||||||
"attachments": [
|
|
||||||
{
|
|
||||||
"filename": "UI_áá
¢áá
¥á«áá
¡áá
¡á¼_áá
®á«áá
¥.md",
|
|
||||||
"originalName": "UI_áá
¢áá
¥á«áá
¡áá
¡á¼_áá
®á«áá
¥.md",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759394500462-50127394_UI_______________________________________________.md",
|
|
||||||
"mimetype": "text/x-markdown"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
|
||||||
"originalName": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759394500463-68744474_test____________________________33.jpg",
|
|
||||||
"mimetype": "image/jpeg"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "testáá
á¼ áá
栠
栠
µ2.png",
|
|
||||||
"originalName": "testáá
á¼ áá
栠
栠
µ2.png",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759394500463-464487722_test____________________________2.png",
|
|
||||||
"mimetype": "image/png"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"status": "success",
|
|
||||||
"messageId": "<2dbfbf64-69c2-a83d-6bb7-515e4e654628@wace.me>",
|
|
||||||
"accepted": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"rejected": []
|
|
||||||
}
|
|
||||||
|
|
@ -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,48 +0,0 @@
|
||||||
{
|
|
||||||
"id": "b1d8f458-076c-4c44-982e-d2f46dcd4b03",
|
|
||||||
"sentAt": "2025-10-02T08:57:48.412Z",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"subject": "ㅁㄴㅇㄹ",
|
|
||||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
|
|
||||||
"attachments": [
|
|
||||||
{
|
|
||||||
"filename": "웨이스-임직원-프로파일-이희진.key",
|
|
||||||
"originalName": "웨이스-임직원-프로파일-이희진.key",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759395465488-120933172.key",
|
|
||||||
"mimetype": "application/x-iwork-keynote-sffkey"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "UI_개선사항_문서.md",
|
|
||||||
"originalName": "UI_개선사항_문서.md",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759395465566-306126854.md",
|
|
||||||
"mimetype": "text/x-markdown"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "test용 이미지33.jpg",
|
|
||||||
"originalName": "test용 이미지33.jpg",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759395465566-412984398.jpg",
|
|
||||||
"mimetype": "image/jpeg"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "test용 이미지2.png",
|
|
||||||
"originalName": "test용 이미지2.png",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759395465567-143883587.png",
|
|
||||||
"mimetype": "image/png"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"status": "success",
|
|
||||||
"messageId": "<e2796753-a1a9-fbac-c035-00341e29031c@wace.me>",
|
|
||||||
"accepted": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"rejected": []
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
{
|
|
||||||
"id": "b75d0b2b-7d8a-461b-b854-2bebdef959e8",
|
|
||||||
"sentAt": "2025-10-02T08:49:30.356Z",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"subject": "한글2",
|
|
||||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
|
|
||||||
"attachments": [
|
|
||||||
{
|
|
||||||
"filename": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
|
|
||||||
"originalName": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759394969516-74008147_UI__________________________.md",
|
|
||||||
"mimetype": "text/x-markdown"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "testááá¼ ááµááµááµ33.jpg",
|
|
||||||
"originalName": "testááá¼ ááµááµááµ33.jpg",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759394969516-530544653_test_______________33.jpg",
|
|
||||||
"mimetype": "image/jpeg"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "testááá¼ ááµááµááµ2.png",
|
|
||||||
"originalName": "testááá¼ ááµááµááµ2.png",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759394969517-260831218_test_______________2.png",
|
|
||||||
"mimetype": "image/png"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"status": "success",
|
|
||||||
"messageId": "<80a431a1-bb4d-31b5-2564-93f8c2539fd4@wace.me>",
|
|
||||||
"accepted": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"rejected": []
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
{
|
|
||||||
"id": "ccdd8961-1b3f-4b88-b838-51d6ed8f1601",
|
|
||||||
"sentAt": "2025-10-02T08:47:03.481Z",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"subject": "한글테스트222",
|
|
||||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">2</p>\r\n </div>\r\n ",
|
|
||||||
"attachments": [
|
|
||||||
{
|
|
||||||
"filename": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
|
|
||||||
"originalName": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759394821751-229305880_UI__________________________.md",
|
|
||||||
"mimetype": "text/x-markdown"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "testááá¼ ááµááµááµ33.jpg",
|
|
||||||
"originalName": "testááá¼ ááµááµááµ33.jpg",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759394821751-335146895_test_______________33.jpg",
|
|
||||||
"mimetype": "image/jpeg"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "testááá¼ ááµááµááµ2.png",
|
|
||||||
"originalName": "testááá¼ ááµááµááµ2.png",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759394821753-911076131_test_______________2.png",
|
|
||||||
"mimetype": "image/png"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"status": "success",
|
|
||||||
"messageId": "<69519c70-a5cd-421d-9976-8c7014d69b39@wace.me>",
|
|
||||||
"accepted": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"rejected": []
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -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,41 +0,0 @@
|
||||||
{
|
|
||||||
"id": "ee0d162c-48ad-4c00-8c56-ade80be4503f",
|
|
||||||
"sentAt": "2025-10-02T08:48:29.740Z",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"subject": "한글한글",
|
|
||||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
|
|
||||||
"attachments": [
|
|
||||||
{
|
|
||||||
"filename": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
|
|
||||||
"originalName": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759394908877-38147683_UI__________________________.md",
|
|
||||||
"mimetype": "text/x-markdown"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "testááá¼ ááµááµááµ33.jpg",
|
|
||||||
"originalName": "testááá¼ ááµááµááµ33.jpg",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759394908879-80461065_test_______________33.jpg",
|
|
||||||
"mimetype": "image/jpeg"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"filename": "testááá¼ ááµááµááµ2.png",
|
|
||||||
"originalName": "testááá¼ ááµááµááµ2.png",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1759394908880-475630926_test_______________2.png",
|
|
||||||
"mimetype": "image/png"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"status": "success",
|
|
||||||
"messageId": "<96205714-1a6b-adb7-7ae5-0e1e3fcb700b@wace.me>",
|
|
||||||
"accepted": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"rejected": []
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
{
|
|
||||||
"id": "fc26aba3-6b6e-47ba-91e8-609ae25e0e7d",
|
|
||||||
"sentAt": "2025-10-13T00:21:51.799Z",
|
|
||||||
"accountId": "account-1759310844272",
|
|
||||||
"accountName": "이희진",
|
|
||||||
"accountEmail": "hjlee@wace.me",
|
|
||||||
"to": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"subject": "test용입니다.",
|
|
||||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
|
|
||||||
"templateId": "template-1759302346758",
|
|
||||||
"templateName": "test",
|
|
||||||
"attachments": [
|
|
||||||
{
|
|
||||||
"filename": "웨이스-임직원-프로파일-이희진.key",
|
|
||||||
"originalName": "웨이스-임직원-프로파일-이희진.key",
|
|
||||||
"size": 0,
|
|
||||||
"path": "/app/uploads/mail-attachments/1760314910154-84512253.key",
|
|
||||||
"mimetype": "application/x-iwork-keynote-sffkey"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"status": "success",
|
|
||||||
"messageId": "<c84bcecc-2e8f-4a32-1b7f-44a91b195b2d@wace.me>",
|
|
||||||
"accepted": [
|
|
||||||
"zian9227@naver.com"
|
|
||||||
],
|
|
||||||
"rejected": []
|
|
||||||
}
|
|
||||||
|
|
@ -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": []
|
|
||||||
}
|
|
||||||
|
|
@ -267,4 +267,46 @@ router.post(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/external-rest-api-connections/:id/fetch
|
||||||
|
* REST API 데이터 조회 (화면관리용 프록시)
|
||||||
|
*/
|
||||||
|
router.post(
|
||||||
|
"/:id/fetch",
|
||||||
|
authenticateToken,
|
||||||
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id);
|
||||||
|
|
||||||
|
if (isNaN(id)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "유효하지 않은 ID입니다.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { endpoint, jsonPath } = req.body;
|
||||||
|
const userCompanyCode = req.user?.companyCode;
|
||||||
|
|
||||||
|
logger.info(`REST API 데이터 조회 요청: 연결 ID=${id}, endpoint=${endpoint}, jsonPath=${jsonPath}`);
|
||||||
|
|
||||||
|
const result = await ExternalRestApiConnectionService.fetchData(
|
||||||
|
id,
|
||||||
|
endpoint,
|
||||||
|
jsonPath,
|
||||||
|
userCompanyCode
|
||||||
|
);
|
||||||
|
|
||||||
|
return res.status(result.success ? 200 : 400).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("REST API 데이터 조회 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "서버 내부 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,9 @@ export class ExternalRestApiConnectionService {
|
||||||
? this.decryptSensitiveData(connection.auth_config)
|
? this.decryptSensitiveData(connection.auth_config)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// 디버깅: 조회된 연결 정보 로깅
|
||||||
|
logger.info(`REST API 연결 조회 결과 (ID: ${id}): connection_name=${connection.connection_name}, default_method=${connection.default_method}, endpoint_path=${connection.endpoint_path}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: connection,
|
data: connection,
|
||||||
|
|
@ -227,6 +230,15 @@ export class ExternalRestApiConnectionService {
|
||||||
data.created_by || "system",
|
data.created_by || "system",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 디버깅: 저장하려는 데이터 로깅
|
||||||
|
logger.info(`REST API 연결 생성 요청 데이터:`, {
|
||||||
|
connection_name: data.connection_name,
|
||||||
|
default_method: data.default_method,
|
||||||
|
endpoint_path: data.endpoint_path,
|
||||||
|
base_url: data.base_url,
|
||||||
|
default_body: data.default_body ? "있음" : "없음",
|
||||||
|
});
|
||||||
|
|
||||||
const result: QueryResult<any> = await pool.query(query, params);
|
const result: QueryResult<any> = await pool.query(query, params);
|
||||||
|
|
||||||
logger.info(`REST API 연결 생성 성공: ${data.connection_name}`);
|
logger.info(`REST API 연결 생성 성공: ${data.connection_name}`);
|
||||||
|
|
@ -316,12 +328,14 @@ export class ExternalRestApiConnectionService {
|
||||||
updateFields.push(`default_method = $${paramIndex}`);
|
updateFields.push(`default_method = $${paramIndex}`);
|
||||||
params.push(data.default_method);
|
params.push(data.default_method);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
|
logger.info(`수정 요청 - default_method: ${data.default_method}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.default_body !== undefined) {
|
if (data.default_body !== undefined) {
|
||||||
updateFields.push(`default_request_body = $${paramIndex}`);
|
updateFields.push(`default_request_body = $${paramIndex}`);
|
||||||
params.push(data.default_body);
|
params.push(data.default_body); // null이면 DB에서 NULL로 저장됨
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
|
logger.info(`수정 요청 - default_body: ${data.default_body ? "있음" : "삭제(null)"}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.auth_type !== undefined) {
|
if (data.auth_type !== undefined) {
|
||||||
|
|
@ -870,6 +884,166 @@ export class ExternalRestApiConnectionService {
|
||||||
return decrypted;
|
return decrypted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST API 데이터 조회 (화면관리용 프록시)
|
||||||
|
* 저장된 연결 정보를 사용하여 외부 REST API를 호출하고 데이터를 반환
|
||||||
|
*/
|
||||||
|
static async fetchData(
|
||||||
|
connectionId: number,
|
||||||
|
endpoint?: string,
|
||||||
|
jsonPath?: string,
|
||||||
|
userCompanyCode?: string
|
||||||
|
): Promise<ApiResponse<any>> {
|
||||||
|
try {
|
||||||
|
// 연결 정보 조회
|
||||||
|
const connectionResult = await this.getConnectionById(connectionId, userCompanyCode);
|
||||||
|
|
||||||
|
if (!connectionResult.success || !connectionResult.data) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "REST API 연결을 찾을 수 없습니다.",
|
||||||
|
error: {
|
||||||
|
code: "CONNECTION_NOT_FOUND",
|
||||||
|
details: `연결 ID ${connectionId}를 찾을 수 없습니다.`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const connection = connectionResult.data;
|
||||||
|
|
||||||
|
// 비활성화된 연결인지 확인
|
||||||
|
if (connection.is_active !== "Y") {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "비활성화된 REST API 연결입니다.",
|
||||||
|
error: {
|
||||||
|
code: "CONNECTION_INACTIVE",
|
||||||
|
details: "연결이 비활성화 상태입니다.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 엔드포인트 결정 (파라미터 > 저장된 값)
|
||||||
|
const effectiveEndpoint = endpoint || connection.endpoint_path || "";
|
||||||
|
|
||||||
|
// API 호출을 위한 테스트 요청 생성
|
||||||
|
const testRequest: RestApiTestRequest = {
|
||||||
|
id: connection.id,
|
||||||
|
base_url: connection.base_url,
|
||||||
|
endpoint: effectiveEndpoint,
|
||||||
|
method: (connection.default_method as any) || "GET",
|
||||||
|
headers: connection.default_headers,
|
||||||
|
body: connection.default_body,
|
||||||
|
auth_type: connection.auth_type,
|
||||||
|
auth_config: connection.auth_config,
|
||||||
|
timeout: connection.timeout,
|
||||||
|
};
|
||||||
|
|
||||||
|
// API 호출
|
||||||
|
const result = await this.testConnection(testRequest, connection.company_code);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: result.message || "REST API 호출에 실패했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "API_CALL_FAILED",
|
||||||
|
details: result.error_details,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 응답 데이터에서 jsonPath로 데이터 추출
|
||||||
|
let extractedData = result.response_data;
|
||||||
|
|
||||||
|
logger.info(`REST API 원본 응답 데이터 타입: ${typeof result.response_data}`);
|
||||||
|
logger.info(`REST API 원본 응답 데이터 (일부): ${JSON.stringify(result.response_data)?.substring(0, 500)}`);
|
||||||
|
|
||||||
|
if (jsonPath && result.response_data) {
|
||||||
|
try {
|
||||||
|
// jsonPath로 데이터 추출 (예: "data", "data.items", "result.list")
|
||||||
|
const pathParts = jsonPath.split(".");
|
||||||
|
logger.info(`JSON Path 파싱: ${jsonPath} -> [${pathParts.join(", ")}]`);
|
||||||
|
|
||||||
|
for (const part of pathParts) {
|
||||||
|
if (extractedData && typeof extractedData === "object") {
|
||||||
|
extractedData = (extractedData as any)[part];
|
||||||
|
logger.info(`JSON Path '${part}' 추출 결과 타입: ${typeof extractedData}, 배열?: ${Array.isArray(extractedData)}`);
|
||||||
|
} else {
|
||||||
|
logger.warn(`JSON Path '${part}' 추출 실패: extractedData가 객체가 아님`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (pathError) {
|
||||||
|
logger.warn(`JSON Path 추출 실패: ${jsonPath}`, pathError);
|
||||||
|
// 추출 실패 시 원본 데이터 반환
|
||||||
|
extractedData = result.response_data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터가 배열이 아닌 경우 배열로 변환
|
||||||
|
// null이나 undefined인 경우 빈 배열로 처리
|
||||||
|
let dataArray: any[] = [];
|
||||||
|
if (extractedData === null || extractedData === undefined) {
|
||||||
|
logger.warn("추출된 데이터가 null/undefined입니다. 원본 응답 데이터를 사용합니다.");
|
||||||
|
// jsonPath 추출 실패 시 원본 데이터에서 직접 컬럼 추출 시도
|
||||||
|
if (result.response_data && typeof result.response_data === "object") {
|
||||||
|
dataArray = Array.isArray(result.response_data) ? result.response_data : [result.response_data];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dataArray = Array.isArray(extractedData) ? extractedData : [extractedData];
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`최종 데이터 배열 길이: ${dataArray.length}`);
|
||||||
|
if (dataArray.length > 0) {
|
||||||
|
logger.info(`첫 번째 데이터 항목: ${JSON.stringify(dataArray[0])?.substring(0, 300)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컬럼 정보 추출 (첫 번째 유효한 데이터 기준)
|
||||||
|
let columns: Array<{ columnName: string; columnLabel: string; dataType: string }> = [];
|
||||||
|
|
||||||
|
// 첫 번째 유효한 객체 찾기
|
||||||
|
const firstValidItem = dataArray.find(item => item && typeof item === "object" && !Array.isArray(item));
|
||||||
|
|
||||||
|
if (firstValidItem) {
|
||||||
|
columns = Object.keys(firstValidItem).map((key) => ({
|
||||||
|
columnName: key,
|
||||||
|
columnLabel: key,
|
||||||
|
dataType: typeof firstValidItem[key],
|
||||||
|
}));
|
||||||
|
logger.info(`추출된 컬럼 수: ${columns.length}, 컬럼명: [${columns.map(c => c.columnName).join(", ")}]`);
|
||||||
|
} else {
|
||||||
|
logger.warn("유효한 데이터 항목을 찾을 수 없어 컬럼을 추출할 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
rows: dataArray,
|
||||||
|
columns,
|
||||||
|
total: dataArray.length,
|
||||||
|
connectionInfo: {
|
||||||
|
connectionId: connection.id,
|
||||||
|
connectionName: connection.connection_name,
|
||||||
|
baseUrl: connection.base_url,
|
||||||
|
endpoint: effectiveEndpoint,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
message: `${dataArray.length}개의 데이터를 조회했습니다.`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("REST API 데이터 조회 오류:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "REST API 데이터 조회에 실패했습니다.",
|
||||||
|
error: {
|
||||||
|
code: "FETCH_ERROR",
|
||||||
|
details: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 연결 데이터 유효성 검증
|
* 연결 데이터 유효성 검증
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -334,9 +334,12 @@ class MailSendSimpleService {
|
||||||
if (variables) {
|
if (variables) {
|
||||||
buttonText = this.replaceVariables(buttonText, variables);
|
buttonText = this.replaceVariables(buttonText, variables);
|
||||||
}
|
}
|
||||||
|
// styles 객체 또는 직접 속성에서 색상 가져오기
|
||||||
|
const buttonBgColor = component.styles?.backgroundColor || component.backgroundColor || '#007bff';
|
||||||
|
const buttonTextColor = component.styles?.color || component.textColor || '#fff';
|
||||||
// 버튼은 왼쪽 정렬 (text-align 제거)
|
// 버튼은 왼쪽 정렬 (text-align 제거)
|
||||||
html += `<div style="margin: 30px 0; text-align: left;">
|
html += `<div style="margin: 30px 0; text-align: left;">
|
||||||
<a href="${component.url || '#'}" style="display: inline-block; padding: 14px 28px; background-color: ${component.backgroundColor || '#007bff'}; color: ${component.textColor || '#fff'}; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 15px;">${buttonText}</a>
|
<a href="${component.url || '#'}" style="display: inline-block; padding: 14px 28px; background-color: ${buttonBgColor}; color: ${buttonTextColor}; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 15px;">${buttonText}</a>
|
||||||
</div>`;
|
</div>`;
|
||||||
break;
|
break;
|
||||||
case 'image':
|
case 'image':
|
||||||
|
|
@ -348,6 +351,89 @@ class MailSendSimpleService {
|
||||||
case 'spacer':
|
case 'spacer':
|
||||||
html += `<div style="height: ${component.height || '20px'};"></div>`;
|
html += `<div style="height: ${component.height || '20px'};"></div>`;
|
||||||
break;
|
break;
|
||||||
|
case 'header':
|
||||||
|
html += `
|
||||||
|
<div style="padding: 20px; background-color: ${component.headerBgColor || '#f8f9fa'}; border-radius: 8px; margin-bottom: 20px;">
|
||||||
|
<table style="width: 100%;">
|
||||||
|
<tr>
|
||||||
|
<td style="vertical-align: middle;">
|
||||||
|
${component.logoSrc ? `<img src="${component.logoSrc}" alt="로고" style="height: 40px; margin-right: 12px;">` : ''}
|
||||||
|
<span style="font-size: 18px; font-weight: bold;">${component.brandName || ''}</span>
|
||||||
|
</td>
|
||||||
|
<td style="text-align: right; color: #6b7280; font-size: 14px;">
|
||||||
|
${component.sendDate || ''}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
case 'infoTable':
|
||||||
|
html += `
|
||||||
|
<div style="border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; margin: 16px 0;">
|
||||||
|
${component.tableTitle ? `<div style="background-color: #f9fafb; padding: 12px 16px; font-weight: 600; border-bottom: 1px solid #e5e7eb;">${component.tableTitle}</div>` : ''}
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
${(component.rows || []).map((row: any, i: number) => `
|
||||||
|
<tr style="background-color: ${i % 2 === 0 ? '#ffffff' : '#f9fafb'};">
|
||||||
|
<td style="padding: 12px 16px; font-weight: 500; color: #4b5563; width: 35%; border-right: 1px solid #e5e7eb;">${row.label}</td>
|
||||||
|
<td style="padding: 12px 16px;">${row.value}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
case 'alertBox':
|
||||||
|
const alertColors: Record<string, { bg: string; border: string; text: string }> = {
|
||||||
|
info: { bg: '#eff6ff', border: '#3b82f6', text: '#1e40af' },
|
||||||
|
warning: { bg: '#fffbeb', border: '#f59e0b', text: '#92400e' },
|
||||||
|
danger: { bg: '#fef2f2', border: '#ef4444', text: '#991b1b' },
|
||||||
|
success: { bg: '#ecfdf5', border: '#10b981', text: '#065f46' }
|
||||||
|
};
|
||||||
|
const colors = alertColors[component.alertType || 'info'];
|
||||||
|
html += `
|
||||||
|
<div style="padding: 16px; background-color: ${colors.bg}; border-left: 4px solid ${colors.border}; border-radius: 4px; margin: 16px 0; color: ${colors.text};">
|
||||||
|
${component.alertTitle ? `<div style="font-weight: bold; margin-bottom: 8px;">${component.alertTitle}</div>` : ''}
|
||||||
|
<div>${component.content || ''}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
case 'divider':
|
||||||
|
html += `<hr style="border: none; border-top: ${component.height || 1}px solid #e5e7eb; margin: 20px 0;">`;
|
||||||
|
break;
|
||||||
|
case 'footer':
|
||||||
|
html += `
|
||||||
|
<div style="text-align: center; padding: 24px 16px; background-color: #f9fafb; border-top: 1px solid #e5e7eb; font-size: 14px; color: #6b7280;">
|
||||||
|
${component.companyName ? `<div style="font-weight: 600; color: #374151; margin-bottom: 8px;">${component.companyName}</div>` : ''}
|
||||||
|
${(component.ceoName || component.businessNumber) ? `
|
||||||
|
<div style="margin-bottom: 4px;">
|
||||||
|
${component.ceoName ? `대표: ${component.ceoName}` : ''}
|
||||||
|
${component.ceoName && component.businessNumber ? ' | ' : ''}
|
||||||
|
${component.businessNumber ? `사업자등록번호: ${component.businessNumber}` : ''}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${component.address ? `<div style="margin-bottom: 4px;">${component.address}</div>` : ''}
|
||||||
|
${(component.phone || component.email) ? `
|
||||||
|
<div style="margin-bottom: 4px;">
|
||||||
|
${component.phone ? `Tel: ${component.phone}` : ''}
|
||||||
|
${component.phone && component.email ? ' | ' : ''}
|
||||||
|
${component.email ? `Email: ${component.email}` : ''}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${component.copyright ? `<div style="margin-top: 12px; font-size: 12px; color: #9ca3af;">${component.copyright}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
case 'numberedList':
|
||||||
|
html += `
|
||||||
|
<div style="padding: 16px;">
|
||||||
|
${component.listTitle ? `<div style="font-weight: 600; margin-bottom: 12px;">${component.listTitle}</div>` : ''}
|
||||||
|
<ol style="margin: 0; padding-left: 20px;">
|
||||||
|
${(component.listItems || []).map((item: string) => `<li style="margin-bottom: 8px;">${item}</li>`).join('')}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,35 @@ import path from "path";
|
||||||
// MailComponent 인터페이스 정의
|
// MailComponent 인터페이스 정의
|
||||||
export interface MailComponent {
|
export interface MailComponent {
|
||||||
id: string;
|
id: string;
|
||||||
type: "text" | "button" | "image" | "spacer";
|
type: "text" | "button" | "image" | "spacer" | "header" | "infoTable" | "alertBox" | "divider" | "footer" | "numberedList";
|
||||||
content?: string;
|
content?: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
src?: string;
|
src?: string;
|
||||||
height?: number;
|
height?: number;
|
||||||
styles?: Record<string, string>;
|
styles?: Record<string, string>;
|
||||||
|
// 헤더 컴포넌트용
|
||||||
|
logoSrc?: string;
|
||||||
|
brandName?: string;
|
||||||
|
sendDate?: string;
|
||||||
|
headerBgColor?: string;
|
||||||
|
// 정보 테이블용
|
||||||
|
rows?: Array<{ label: string; value: string }>;
|
||||||
|
tableTitle?: string;
|
||||||
|
// 강조 박스용
|
||||||
|
alertType?: "info" | "warning" | "danger" | "success";
|
||||||
|
alertTitle?: string;
|
||||||
|
// 푸터용
|
||||||
|
companyName?: string;
|
||||||
|
ceoName?: string;
|
||||||
|
businessNumber?: string;
|
||||||
|
address?: string;
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
copyright?: string;
|
||||||
|
// 번호 리스트용
|
||||||
|
listItems?: string[];
|
||||||
|
listTitle?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// QueryConfig 인터페이스 정의 (사용하지 않지만 타입 호환성 유지)
|
// QueryConfig 인터페이스 정의 (사용하지 않지만 타입 호환성 유지)
|
||||||
|
|
@ -236,6 +258,89 @@ class MailTemplateFileService {
|
||||||
case "spacer":
|
case "spacer":
|
||||||
html += `<div style="height: ${comp.height || 20}px;"></div>`;
|
html += `<div style="height: ${comp.height || 20}px;"></div>`;
|
||||||
break;
|
break;
|
||||||
|
case "header":
|
||||||
|
html += `
|
||||||
|
<div style="padding: 20px; background-color: ${comp.headerBgColor || '#f8f9fa'}; border-radius: 8px; margin-bottom: 20px;">
|
||||||
|
<table style="width: 100%;">
|
||||||
|
<tr>
|
||||||
|
<td style="vertical-align: middle;">
|
||||||
|
${comp.logoSrc ? `<img src="${comp.logoSrc}" alt="로고" style="height: 40px; margin-right: 12px;">` : ''}
|
||||||
|
<span style="font-size: 18px; font-weight: bold;">${comp.brandName || ''}</span>
|
||||||
|
</td>
|
||||||
|
<td style="text-align: right; color: #6b7280; font-size: 14px;">
|
||||||
|
${comp.sendDate || ''}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
case "infoTable":
|
||||||
|
html += `
|
||||||
|
<div style="border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; margin: 16px 0;">
|
||||||
|
${comp.tableTitle ? `<div style="background-color: #f9fafb; padding: 12px 16px; font-weight: 600; border-bottom: 1px solid #e5e7eb;">${comp.tableTitle}</div>` : ''}
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
${(comp.rows || []).map((row, i) => `
|
||||||
|
<tr style="background-color: ${i % 2 === 0 ? '#ffffff' : '#f9fafb'};">
|
||||||
|
<td style="padding: 12px 16px; font-weight: 500; color: #4b5563; width: 35%; border-right: 1px solid #e5e7eb;">${row.label}</td>
|
||||||
|
<td style="padding: 12px 16px;">${row.value}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
case "alertBox":
|
||||||
|
const alertColors: Record<string, { bg: string; border: string; text: string }> = {
|
||||||
|
info: { bg: '#eff6ff', border: '#3b82f6', text: '#1e40af' },
|
||||||
|
warning: { bg: '#fffbeb', border: '#f59e0b', text: '#92400e' },
|
||||||
|
danger: { bg: '#fef2f2', border: '#ef4444', text: '#991b1b' },
|
||||||
|
success: { bg: '#ecfdf5', border: '#10b981', text: '#065f46' }
|
||||||
|
};
|
||||||
|
const colors = alertColors[comp.alertType || 'info'];
|
||||||
|
html += `
|
||||||
|
<div style="padding: 16px; background-color: ${colors.bg}; border-left: 4px solid ${colors.border}; border-radius: 4px; margin: 16px 0; color: ${colors.text};">
|
||||||
|
${comp.alertTitle ? `<div style="font-weight: bold; margin-bottom: 8px;">${comp.alertTitle}</div>` : ''}
|
||||||
|
<div>${comp.content || ''}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
case "divider":
|
||||||
|
html += `<hr style="border: none; border-top: ${comp.height || 1}px solid #e5e7eb; margin: 20px 0;">`;
|
||||||
|
break;
|
||||||
|
case "footer":
|
||||||
|
html += `
|
||||||
|
<div style="text-align: center; padding: 24px 16px; background-color: #f9fafb; border-top: 1px solid #e5e7eb; font-size: 14px; color: #6b7280;">
|
||||||
|
${comp.companyName ? `<div style="font-weight: 600; color: #374151; margin-bottom: 8px;">${comp.companyName}</div>` : ''}
|
||||||
|
${(comp.ceoName || comp.businessNumber) ? `
|
||||||
|
<div style="margin-bottom: 4px;">
|
||||||
|
${comp.ceoName ? `대표: ${comp.ceoName}` : ''}
|
||||||
|
${comp.ceoName && comp.businessNumber ? ' | ' : ''}
|
||||||
|
${comp.businessNumber ? `사업자등록번호: ${comp.businessNumber}` : ''}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${comp.address ? `<div style="margin-bottom: 4px;">${comp.address}</div>` : ''}
|
||||||
|
${(comp.phone || comp.email) ? `
|
||||||
|
<div style="margin-bottom: 4px;">
|
||||||
|
${comp.phone ? `Tel: ${comp.phone}` : ''}
|
||||||
|
${comp.phone && comp.email ? ' | ' : ''}
|
||||||
|
${comp.email ? `Email: ${comp.email}` : ''}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${comp.copyright ? `<div style="margin-top: 12px; font-size: 12px; color: #9ca3af;">${comp.copyright}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
case "numberedList":
|
||||||
|
html += `
|
||||||
|
<div style="padding: 16px; ${styles}">
|
||||||
|
${comp.listTitle ? `<div style="font-weight: 600; margin-bottom: 12px;">${comp.listTitle}</div>` : ''}
|
||||||
|
<ol style="margin: 0; padding-left: 20px;">
|
||||||
|
${(comp.listItems || []).map(item => `<li style="margin-bottom: 8px;">${item}</li>`).join('')}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -70,12 +70,13 @@ export class ScreenManagementService {
|
||||||
throw new Error("이미 존재하는 화면 코드입니다.");
|
throw new Error("이미 존재하는 화면 코드입니다.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 화면 생성 (Raw Query)
|
// 화면 생성 (Raw Query) - REST API 지원 추가
|
||||||
const [screen] = await query<any>(
|
const [screen] = await query<any>(
|
||||||
`INSERT INTO screen_definitions (
|
`INSERT INTO screen_definitions (
|
||||||
screen_name, screen_code, table_name, company_code, description, created_by,
|
screen_name, screen_code, table_name, company_code, description, created_by,
|
||||||
db_source_type, db_connection_id
|
db_source_type, db_connection_id, data_source_type, rest_api_connection_id,
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
rest_api_endpoint, rest_api_json_path
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
RETURNING *`,
|
RETURNING *`,
|
||||||
[
|
[
|
||||||
screenData.screenName,
|
screenData.screenName,
|
||||||
|
|
@ -86,6 +87,10 @@ export class ScreenManagementService {
|
||||||
screenData.createdBy,
|
screenData.createdBy,
|
||||||
screenData.dbSourceType || "internal",
|
screenData.dbSourceType || "internal",
|
||||||
screenData.dbConnectionId || null,
|
screenData.dbConnectionId || null,
|
||||||
|
(screenData as any).dataSourceType || "database",
|
||||||
|
(screenData as any).restApiConnectionId || null,
|
||||||
|
(screenData as any).restApiEndpoint || null,
|
||||||
|
(screenData as any).restApiJsonPath || "data",
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1977,6 +1982,11 @@ export class ScreenManagementService {
|
||||||
updatedBy: data.updated_by,
|
updatedBy: data.updated_by,
|
||||||
dbSourceType: data.db_source_type || "internal",
|
dbSourceType: data.db_source_type || "internal",
|
||||||
dbConnectionId: data.db_connection_id || undefined,
|
dbConnectionId: data.db_connection_id || undefined,
|
||||||
|
// REST API 관련 필드
|
||||||
|
dataSourceType: data.data_source_type || "database",
|
||||||
|
restApiConnectionId: data.rest_api_connection_id || undefined,
|
||||||
|
restApiEndpoint: data.rest_api_endpoint || undefined,
|
||||||
|
restApiJsonPath: data.rest_api_json_path || "data",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,11 @@ export interface ScreenDefinition {
|
||||||
updatedBy?: string;
|
updatedBy?: string;
|
||||||
dbSourceType?: "internal" | "external";
|
dbSourceType?: "internal" | "external";
|
||||||
dbConnectionId?: number;
|
dbConnectionId?: number;
|
||||||
|
// REST API 관련 필드
|
||||||
|
dataSourceType?: "database" | "restapi";
|
||||||
|
restApiConnectionId?: number;
|
||||||
|
restApiEndpoint?: string;
|
||||||
|
restApiJsonPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 화면 생성 요청
|
// 화면 생성 요청
|
||||||
|
|
@ -166,6 +171,11 @@ export interface CreateScreenRequest {
|
||||||
createdBy?: string;
|
createdBy?: string;
|
||||||
dbSourceType?: "internal" | "external";
|
dbSourceType?: "internal" | "external";
|
||||||
dbConnectionId?: number;
|
dbConnectionId?: number;
|
||||||
|
// REST API 관련 필드
|
||||||
|
dataSourceType?: "database" | "restapi";
|
||||||
|
restApiConnectionId?: number;
|
||||||
|
restApiEndpoint?: string;
|
||||||
|
restApiJsonPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 화면 수정 요청
|
// 화면 수정 요청
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ import {
|
||||||
saveDraft,
|
saveDraft,
|
||||||
updateDraft,
|
updateDraft,
|
||||||
} from "@/lib/api/mail";
|
} from "@/lib/api/mail";
|
||||||
|
import { API_BASE_URL } from "@/lib/api/client";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
export default function MailSendPage() {
|
export default function MailSendPage() {
|
||||||
|
|
@ -498,7 +499,7 @@ ${data.originalBody}`;
|
||||||
throw new Error("인증 토큰이 없습니다. 다시 로그인해주세요.");
|
throw new Error("인증 토큰이 없습니다. 다시 로그인해주세요.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch("/api/mail/send/simple", {
|
const response = await fetch(`${API_BASE_URL}/mail/send/simple`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${authToken}`,
|
Authorization: `Bearer ${authToken}`,
|
||||||
|
|
@ -1226,6 +1227,91 @@ ${data.originalBody}`;
|
||||||
여백
|
여백
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case 'header':
|
||||||
|
return (
|
||||||
|
<div key={component.id} className="p-4 rounded-lg" style={{ backgroundColor: component.headerBgColor || '#f8f9fa' }}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{component.logoSrc && <img src={component.logoSrc} alt="로고" className="h-10" />}
|
||||||
|
<span className="font-bold text-lg">{component.brandName}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-500">{component.sendDate}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'infoTable':
|
||||||
|
return (
|
||||||
|
<div key={component.id} className="border rounded-lg overflow-hidden">
|
||||||
|
{component.tableTitle && (
|
||||||
|
<div className="bg-gray-50 px-4 py-2 font-semibold border-b">{component.tableTitle}</div>
|
||||||
|
)}
|
||||||
|
<table className="w-full">
|
||||||
|
<tbody>
|
||||||
|
{component.rows?.map((row: any, i: number) => (
|
||||||
|
<tr key={i} className={i % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
|
||||||
|
<td className="px-4 py-2 font-medium text-gray-600 w-1/3 border-r">{row.label}</td>
|
||||||
|
<td className="px-4 py-2">{row.value}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'alertBox':
|
||||||
|
return (
|
||||||
|
<div key={component.id} className={`p-4 rounded-lg border-l-4 ${
|
||||||
|
component.alertType === 'info' ? 'bg-blue-50 border-blue-500 text-blue-800' :
|
||||||
|
component.alertType === 'warning' ? 'bg-amber-50 border-amber-500 text-amber-800' :
|
||||||
|
component.alertType === 'danger' ? 'bg-red-50 border-red-500 text-red-800' :
|
||||||
|
'bg-emerald-50 border-emerald-500 text-emerald-800'
|
||||||
|
}`}>
|
||||||
|
{component.alertTitle && <div className="font-bold mb-1">{component.alertTitle}</div>}
|
||||||
|
<div>{component.content}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'divider':
|
||||||
|
return (
|
||||||
|
<hr key={component.id} className="border-gray-300" style={{ borderWidth: `${component.height || 1}px` }} />
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'footer':
|
||||||
|
return (
|
||||||
|
<div key={component.id} className="text-center text-sm text-gray-500 py-4 border-t bg-gray-50">
|
||||||
|
{component.companyName && <div className="font-semibold text-gray-700">{component.companyName}</div>}
|
||||||
|
{(component.ceoName || component.businessNumber) && (
|
||||||
|
<div className="mt-1">
|
||||||
|
{component.ceoName && <span>대표: {component.ceoName}</span>}
|
||||||
|
{component.ceoName && component.businessNumber && <span className="mx-2">|</span>}
|
||||||
|
{component.businessNumber && <span>사업자등록번호: {component.businessNumber}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{component.address && <div className="mt-1">{component.address}</div>}
|
||||||
|
{(component.phone || component.email) && (
|
||||||
|
<div className="mt-1">
|
||||||
|
{component.phone && <span>Tel: {component.phone}</span>}
|
||||||
|
{component.phone && component.email && <span className="mx-2">|</span>}
|
||||||
|
{component.email && <span>Email: {component.email}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{component.copyright && <div className="mt-2 text-xs text-gray-400">{component.copyright}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'numberedList':
|
||||||
|
return (
|
||||||
|
<div key={component.id} className="p-4">
|
||||||
|
{component.listTitle && <div className="font-semibold mb-2">{component.listTitle}</div>}
|
||||||
|
<ol className="list-decimal list-inside space-y-1">
|
||||||
|
{component.listItems?.map((item: string, i: number) => (
|
||||||
|
<li key={i}>{item}</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -239,17 +239,17 @@ function ScreenViewPage() {
|
||||||
// 가로 기준 스케일 계산 (좌우 여백 16px씩 고정)
|
// 가로 기준 스케일 계산 (좌우 여백 16px씩 고정)
|
||||||
const newScale = availableWidth / designWidth;
|
const newScale = availableWidth / designWidth;
|
||||||
|
|
||||||
console.log("📐 스케일 계산:", {
|
// console.log("📐 스케일 계산:", {
|
||||||
containerWidth,
|
// containerWidth,
|
||||||
containerHeight,
|
// containerHeight,
|
||||||
MARGIN_X,
|
// MARGIN_X,
|
||||||
availableWidth,
|
// availableWidth,
|
||||||
designWidth,
|
// designWidth,
|
||||||
designHeight,
|
// designHeight,
|
||||||
finalScale: newScale,
|
// finalScale: newScale,
|
||||||
"스케일된 화면 크기": `${designWidth * newScale}px × ${designHeight * newScale}px`,
|
// "스케일된 화면 크기": `${designWidth * newScale}px × ${designHeight * newScale}px`,
|
||||||
"실제 좌우 여백": `${(containerWidth - designWidth * newScale) / 2}px씩`,
|
// "실제 좌우 여백": `${(containerWidth - designWidth * newScale) / 2}px씩`,
|
||||||
});
|
// });
|
||||||
|
|
||||||
setScale(newScale);
|
setScale(newScale);
|
||||||
// 컨테이너 너비 업데이트
|
// 컨테이너 너비 업데이트
|
||||||
|
|
|
||||||
|
|
@ -226,7 +226,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
endpoint_path: endpointPath || undefined,
|
endpoint_path: endpointPath || undefined,
|
||||||
default_headers: defaultHeaders,
|
default_headers: defaultHeaders,
|
||||||
default_method: defaultMethod,
|
default_method: defaultMethod,
|
||||||
default_body: defaultBody || undefined,
|
default_body: defaultBody.trim() || null, // 빈 문자열이면 null로 전송하여 DB 업데이트
|
||||||
auth_type: authType,
|
auth_type: authType,
|
||||||
auth_config: authType === "none" ? undefined : authConfig,
|
auth_config: authType === "none" ? undefined : authConfig,
|
||||||
timeout,
|
timeout,
|
||||||
|
|
@ -236,6 +236,13 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
is_active: isActive ? "Y" : "N",
|
is_active: isActive ? "Y" : "N",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log("저장하려는 데이터:", {
|
||||||
|
connection_name: connectionName,
|
||||||
|
default_method: defaultMethod,
|
||||||
|
endpoint_path: endpointPath,
|
||||||
|
base_url: baseUrl,
|
||||||
|
});
|
||||||
|
|
||||||
if (connection?.id) {
|
if (connection?.id) {
|
||||||
await ExternalRestApiConnectionAPI.updateConnection(connection.id, data);
|
await ExternalRestApiConnectionAPI.updateConnection(connection.id, data);
|
||||||
toast({
|
toast({
|
||||||
|
|
@ -303,7 +310,13 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
||||||
기본 URL <span className="text-destructive">*</span>
|
기본 URL <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Select value={defaultMethod} onValueChange={setDefaultMethod}>
|
<Select
|
||||||
|
value={defaultMethod}
|
||||||
|
onValueChange={(val) => {
|
||||||
|
setDefaultMethod(val);
|
||||||
|
setTestMethod(val); // 테스트 Method도 동기화
|
||||||
|
}}
|
||||||
|
>
|
||||||
<SelectTrigger className="w-[100px]">
|
<SelectTrigger className="w-[100px]">
|
||||||
<SelectValue placeholder="Method" />
|
<SelectValue placeholder="Method" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
const savedMode = localStorage.getItem("screenModal_continuousMode");
|
const savedMode = localStorage.getItem("screenModal_continuousMode");
|
||||||
if (savedMode === "true") {
|
if (savedMode === "true") {
|
||||||
setContinuousMode(true);
|
setContinuousMode(true);
|
||||||
console.log("🔄 연속 모드 복원: true");
|
// console.log("🔄 연속 모드 복원: true");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -120,10 +120,28 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 🆕 선택된 데이터 상태 추가 (RepeatScreenModal 등에서 사용)
|
||||||
|
const [selectedData, setSelectedData] = useState<Record<string, any>[]>([]);
|
||||||
|
|
||||||
// 전역 모달 이벤트 리스너
|
// 전역 모달 이벤트 리스너
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleOpenModal = (event: CustomEvent) => {
|
const handleOpenModal = (event: CustomEvent) => {
|
||||||
const { screenId, title, description, size, urlParams } = event.detail;
|
const { screenId, title, description, size, urlParams, selectedData: eventSelectedData, selectedIds } = event.detail;
|
||||||
|
|
||||||
|
console.log("📦 [ScreenModal] 모달 열기 이벤트 수신:", {
|
||||||
|
screenId,
|
||||||
|
title,
|
||||||
|
selectedData: eventSelectedData,
|
||||||
|
selectedIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🆕 선택된 데이터 저장
|
||||||
|
if (eventSelectedData && Array.isArray(eventSelectedData)) {
|
||||||
|
setSelectedData(eventSelectedData);
|
||||||
|
console.log("📦 [ScreenModal] 선택된 데이터 저장:", eventSelectedData.length, "건");
|
||||||
|
} else {
|
||||||
|
setSelectedData([]);
|
||||||
|
}
|
||||||
|
|
||||||
// 🆕 URL 파라미터가 있으면 현재 URL에 추가
|
// 🆕 URL 파라미터가 있으면 현재 URL에 추가
|
||||||
if (urlParams && typeof window !== "undefined") {
|
if (urlParams && typeof window !== "undefined") {
|
||||||
|
|
@ -164,6 +182,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
});
|
});
|
||||||
setScreenData(null);
|
setScreenData(null);
|
||||||
setFormData({});
|
setFormData({});
|
||||||
|
setSelectedData([]); // 🆕 선택된 데이터 초기화
|
||||||
setContinuousMode(false);
|
setContinuousMode(false);
|
||||||
localStorage.setItem("screenModal_continuousMode", "false"); // localStorage에 저장
|
localStorage.setItem("screenModal_continuousMode", "false"); // localStorage에 저장
|
||||||
console.log("🔄 연속 모드 초기화: false");
|
console.log("🔄 연속 모드 초기화: false");
|
||||||
|
|
@ -605,6 +624,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
userId={userId}
|
userId={userId}
|
||||||
userName={userName}
|
userName={userName}
|
||||||
companyCode={user?.companyCode}
|
companyCode={user?.companyCode}
|
||||||
|
// 🆕 선택된 데이터 전달 (RepeatScreenModal 등에서 사용)
|
||||||
|
groupedData={selectedData.length > 0 ? selectedData : undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -19,19 +19,50 @@ import {
|
||||||
Trash2,
|
Trash2,
|
||||||
Settings,
|
Settings,
|
||||||
Upload,
|
Upload,
|
||||||
X
|
X,
|
||||||
|
GripVertical,
|
||||||
|
ChevronUp,
|
||||||
|
ChevronDown,
|
||||||
|
LayoutTemplate,
|
||||||
|
Table2,
|
||||||
|
AlertCircle,
|
||||||
|
Minus,
|
||||||
|
Building2,
|
||||||
|
ListOrdered
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { getMailTemplates } from "@/lib/api/mail";
|
import { getMailTemplates } from "@/lib/api/mail";
|
||||||
|
|
||||||
export interface MailComponent {
|
export interface MailComponent {
|
||||||
id: string;
|
id: string;
|
||||||
type: "text" | "button" | "image" | "spacer" | "table";
|
type: "text" | "button" | "image" | "spacer" | "table" | "header" | "infoTable" | "alertBox" | "divider" | "footer" | "numberedList";
|
||||||
content?: string;
|
content?: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
src?: string;
|
src?: string;
|
||||||
height?: number;
|
height?: number;
|
||||||
styles?: Record<string, string>;
|
styles?: Record<string, string>;
|
||||||
|
// 헤더 컴포넌트용
|
||||||
|
logoSrc?: string;
|
||||||
|
brandName?: string;
|
||||||
|
sendDate?: string;
|
||||||
|
headerBgColor?: string;
|
||||||
|
// 정보 테이블용
|
||||||
|
rows?: Array<{ label: string; value: string }>;
|
||||||
|
tableTitle?: string;
|
||||||
|
// 강조 박스용
|
||||||
|
alertType?: "info" | "warning" | "danger" | "success";
|
||||||
|
alertTitle?: string;
|
||||||
|
// 푸터용
|
||||||
|
companyName?: string;
|
||||||
|
ceoName?: string;
|
||||||
|
businessNumber?: string;
|
||||||
|
address?: string;
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
copyright?: string;
|
||||||
|
// 번호 리스트용
|
||||||
|
listItems?: string[];
|
||||||
|
listTitle?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface QueryConfig {
|
export interface QueryConfig {
|
||||||
|
|
@ -64,6 +95,10 @@ export default function MailDesigner({
|
||||||
const [subject, setSubject] = useState("");
|
const [subject, setSubject] = useState("");
|
||||||
const [queries, setQueries] = useState<QueryConfig[]>([]);
|
const [queries, setQueries] = useState<QueryConfig[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// 드래그 앤 드롭 상태
|
||||||
|
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||||
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
// 템플릿 데이터 로드 (수정 모드)
|
// 템플릿 데이터 로드 (수정 모드)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -96,10 +131,18 @@ export default function MailDesigner({
|
||||||
|
|
||||||
// 컴포넌트 타입 정의
|
// 컴포넌트 타입 정의
|
||||||
const componentTypes = [
|
const componentTypes = [
|
||||||
{ type: "text", icon: Type, label: "텍스트", color: "bg-primary/20 hover:bg-blue-200" },
|
// 레이아웃 컴포넌트
|
||||||
{ type: "button", icon: MousePointer, label: "버튼", color: "bg-success/20 hover:bg-success/30" },
|
{ type: "header", icon: LayoutTemplate, label: "헤더", color: "bg-indigo-100 hover:bg-indigo-200", category: "layout" },
|
||||||
{ type: "image", icon: ImageIcon, label: "이미지", color: "bg-purple-100 hover:bg-purple-200" },
|
{ type: "divider", icon: Minus, label: "구분선", color: "bg-gray-100 hover:bg-gray-200", category: "layout" },
|
||||||
{ type: "spacer", icon: Square, label: "여백", color: "bg-muted hover:bg-muted/80" },
|
{ type: "spacer", icon: Square, label: "여백", color: "bg-muted hover:bg-muted/80", category: "layout" },
|
||||||
|
{ type: "footer", icon: Building2, label: "푸터", color: "bg-slate-100 hover:bg-slate-200", category: "layout" },
|
||||||
|
// 컨텐츠 컴포넌트
|
||||||
|
{ type: "text", icon: Type, label: "텍스트", color: "bg-primary/20 hover:bg-blue-200", category: "content" },
|
||||||
|
{ type: "button", icon: MousePointer, label: "버튼", color: "bg-success/20 hover:bg-success/30", category: "content" },
|
||||||
|
{ type: "image", icon: ImageIcon, label: "이미지", color: "bg-purple-100 hover:bg-purple-200", category: "content" },
|
||||||
|
{ type: "infoTable", icon: Table2, label: "정보 테이블", color: "bg-cyan-100 hover:bg-cyan-200", category: "content" },
|
||||||
|
{ type: "alertBox", icon: AlertCircle, label: "안내 박스", color: "bg-amber-100 hover:bg-amber-200", category: "content" },
|
||||||
|
{ type: "numberedList", icon: ListOrdered, label: "번호 리스트", color: "bg-emerald-100 hover:bg-emerald-200", category: "content" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// 컴포넌트 추가
|
// 컴포넌트 추가
|
||||||
|
|
@ -107,21 +150,75 @@ export default function MailDesigner({
|
||||||
const newComponent: MailComponent = {
|
const newComponent: MailComponent = {
|
||||||
id: `comp-${Date.now()}`,
|
id: `comp-${Date.now()}`,
|
||||||
type: type as any,
|
type: type as any,
|
||||||
content: type === "text" ? "" : undefined, // 🎯 빈 문자열로 시작 (HTML 태그 제거)
|
content: type === "text" ? "" : undefined,
|
||||||
text: type === "button" ? "버튼 텍스트" : undefined, // 🎯 더 명확한 기본값
|
text: type === "button" ? "버튼 텍스트" : undefined,
|
||||||
url: type === "button" || type === "image" ? "" : undefined, // 🎯 빈 문자열로 시작
|
url: type === "button" || type === "image" ? "" : undefined,
|
||||||
src: type === "image" ? "https://placehold.co/600x200/e5e7eb/64748b?text=이미지를+업로드하세요" : undefined, // 🎯 한글 안내
|
src: type === "image" ? "https://placehold.co/600x200/e5e7eb/64748b?text=이미지를+업로드하세요" : undefined,
|
||||||
height: type === "spacer" ? 30 : undefined, // 🎯 기본값 30px로 증가 (더 적절한 간격)
|
height: type === "spacer" ? 30 : type === "divider" ? 1 : undefined,
|
||||||
styles: {
|
styles: {
|
||||||
padding: "10px",
|
padding: type === "divider" ? "0" : "10px",
|
||||||
backgroundColor: type === "button" ? "#007bff" : "transparent",
|
backgroundColor: type === "button" ? "#007bff" : "transparent",
|
||||||
color: type === "button" ? "#fff" : "#333",
|
color: type === "button" ? "#fff" : "#333",
|
||||||
},
|
},
|
||||||
|
// 헤더 기본값
|
||||||
|
logoSrc: type === "header" ? "" : undefined,
|
||||||
|
brandName: type === "header" ? "회사명" : undefined,
|
||||||
|
sendDate: type === "header" ? new Date().toLocaleDateString("ko-KR") : undefined,
|
||||||
|
headerBgColor: type === "header" ? "#f8f9fa" : undefined,
|
||||||
|
// 정보 테이블 기본값
|
||||||
|
rows: type === "infoTable" ? [{ label: "항목", value: "내용" }] : undefined,
|
||||||
|
tableTitle: type === "infoTable" ? "" : undefined,
|
||||||
|
// 안내 박스 기본값
|
||||||
|
alertType: type === "alertBox" ? "info" : undefined,
|
||||||
|
alertTitle: type === "alertBox" ? "안내" : undefined,
|
||||||
|
// 푸터 기본값
|
||||||
|
companyName: type === "footer" ? "회사명" : undefined,
|
||||||
|
ceoName: type === "footer" ? "" : undefined,
|
||||||
|
businessNumber: type === "footer" ? "" : undefined,
|
||||||
|
address: type === "footer" ? "" : undefined,
|
||||||
|
phone: type === "footer" ? "" : undefined,
|
||||||
|
email: type === "footer" ? "" : undefined,
|
||||||
|
copyright: type === "footer" ? `© ${new Date().getFullYear()} All rights reserved.` : undefined,
|
||||||
|
// 번호 리스트 기본값
|
||||||
|
listItems: type === "numberedList" ? ["첫 번째 항목"] : undefined,
|
||||||
|
listTitle: type === "numberedList" ? "" : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
setComponents([...components, newComponent]);
|
setComponents([...components, newComponent]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 드래그 앤 드롭 핸들러
|
||||||
|
const handleDragStart = (index: number) => {
|
||||||
|
setDraggedIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (draggedIndex !== null && draggedIndex !== index) {
|
||||||
|
setDragOverIndex(index);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (index: number) => {
|
||||||
|
if (draggedIndex !== null && draggedIndex !== index) {
|
||||||
|
moveComponent(draggedIndex, index);
|
||||||
|
}
|
||||||
|
setDraggedIndex(null);
|
||||||
|
setDragOverIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
setDraggedIndex(null);
|
||||||
|
setDragOverIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveComponent = (fromIndex: number, toIndex: number) => {
|
||||||
|
const newComponents = [...components];
|
||||||
|
const [movedItem] = newComponents.splice(fromIndex, 1);
|
||||||
|
newComponents.splice(toIndex, 0, movedItem);
|
||||||
|
setComponents(newComponents);
|
||||||
|
};
|
||||||
|
|
||||||
// 컴포넌트 삭제
|
// 컴포넌트 삭제
|
||||||
const removeComponent = (id: string) => {
|
const removeComponent = (id: string) => {
|
||||||
setComponents(components.filter(c => c.id !== id));
|
setComponents(components.filter(c => c.id !== id));
|
||||||
|
|
@ -189,13 +286,35 @@ export default function MailDesigner({
|
||||||
<div className="flex h-screen bg-muted/30">
|
<div className="flex h-screen bg-muted/30">
|
||||||
{/* 왼쪽: 컴포넌트 팔레트 */}
|
{/* 왼쪽: 컴포넌트 팔레트 */}
|
||||||
<div className="w-64 bg-white border-r p-4 space-y-4 overflow-y-auto">
|
<div className="w-64 bg-white border-r p-4 space-y-4 overflow-y-auto">
|
||||||
|
{/* 레이아웃 컴포넌트 */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center">
|
||||||
|
<LayoutTemplate className="w-4 h-4 mr-2 text-indigo-500" />
|
||||||
|
레이아웃
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{componentTypes.filter(c => c.category === "layout").map(({ type, icon: Icon, label, color }) => (
|
||||||
|
<Button
|
||||||
|
key={type}
|
||||||
|
onClick={() => addComponent(type)}
|
||||||
|
variant="outline"
|
||||||
|
className={`w-full justify-start ${color} border`}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4 mr-2" />
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 컨텐츠 컴포넌트 */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center">
|
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center">
|
||||||
<Mail className="w-4 h-4 mr-2 text-primary" />
|
<Mail className="w-4 h-4 mr-2 text-primary" />
|
||||||
컴포넌트
|
컨텐츠
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{componentTypes.map(({ type, icon: Icon, label, color }) => (
|
{componentTypes.filter(c => c.category === "content").map(({ type, icon: Icon, label, color }) => (
|
||||||
<Button
|
<Button
|
||||||
key={type}
|
key={type}
|
||||||
onClick={() => addComponent(type)}
|
onClick={() => addComponent(type)}
|
||||||
|
|
@ -274,24 +393,57 @@ export default function MailDesigner({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 컴포넌트 렌더링 */}
|
{/* 컴포넌트 렌더링 */}
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-6 pl-14 space-y-4">
|
||||||
{components.length === 0 ? (
|
{components.length === 0 ? (
|
||||||
<div className="text-center py-16 text-muted-foreground/50">
|
<div className="text-center py-16 text-muted-foreground/50">
|
||||||
<Mail className="w-16 h-16 mx-auto mb-4 opacity-20" />
|
<Mail className="w-16 h-16 mx-auto mb-4 opacity-20" />
|
||||||
<p>왼쪽에서 컴포넌트를 추가하세요</p>
|
<p>왼쪽에서 컴포넌트를 추가하세요</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
components.map((comp) => (
|
components.map((comp, index) => (
|
||||||
<div
|
<div
|
||||||
key={comp.id}
|
key={comp.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => handleDragStart(index)}
|
||||||
|
onDragOver={(e) => handleDragOver(e, index)}
|
||||||
|
onDrop={() => handleDrop(index)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
onClick={() => selectComponent(comp.id)}
|
onClick={() => selectComponent(comp.id)}
|
||||||
className={`relative group cursor-pointer rounded-lg transition-all ${
|
className={`relative group cursor-pointer rounded-lg transition-all ${
|
||||||
selectedComponent === comp.id
|
selectedComponent === comp.id
|
||||||
? "ring-2 ring-orange-500 bg-orange-50/30"
|
? "ring-2 ring-orange-500 bg-orange-50/30"
|
||||||
: "hover:ring-2 hover:ring-gray-300"
|
: "hover:ring-2 hover:ring-gray-300"
|
||||||
|
} ${draggedIndex === index ? "opacity-50 scale-95" : ""} ${
|
||||||
|
dragOverIndex === index ? "ring-2 ring-primary ring-dashed bg-primary/10" : ""
|
||||||
}`}
|
}`}
|
||||||
style={comp.styles}
|
style={comp.styles}
|
||||||
>
|
>
|
||||||
|
{/* 드래그 핸들 & 순서 이동 버튼 */}
|
||||||
|
<div className="absolute -left-10 top-1/2 -translate-y-1/2 flex flex-col items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); if (index > 0) moveComponent(index, index - 1); }}
|
||||||
|
className="p-1 hover:bg-gray-200 rounded disabled:opacity-30"
|
||||||
|
disabled={index === 0}
|
||||||
|
>
|
||||||
|
<ChevronUp className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
<div className="cursor-grab active:cursor-grabbing p-1 hover:bg-gray-200 rounded">
|
||||||
|
<GripVertical className="w-4 h-4 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); if (index < components.length - 1) moveComponent(index, index + 1); }}
|
||||||
|
className="p-1 hover:bg-gray-200 rounded disabled:opacity-30"
|
||||||
|
disabled={index === components.length - 1}
|
||||||
|
>
|
||||||
|
<ChevronDown className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 순서 배지 */}
|
||||||
|
<div className="absolute -left-10 top-0 text-xs text-gray-400 opacity-0 group-hover:opacity-100">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 삭제 버튼 */}
|
{/* 삭제 버튼 */}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
|
|
@ -322,7 +474,82 @@ export default function MailDesigner({
|
||||||
<img src={comp.src} alt="메일 이미지" className="w-full rounded" />
|
<img src={comp.src} alt="메일 이미지" className="w-full rounded" />
|
||||||
)}
|
)}
|
||||||
{comp.type === "spacer" && (
|
{comp.type === "spacer" && (
|
||||||
<div style={{ height: `${comp.height}px` }} />
|
<div style={{ height: `${comp.height}px` }} className="bg-gray-100 rounded flex items-center justify-center text-xs text-gray-400">
|
||||||
|
여백 {comp.height}px
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{comp.type === "header" && (
|
||||||
|
<div className="p-4 rounded-lg" style={{ backgroundColor: comp.headerBgColor || "#f8f9fa" }}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{comp.logoSrc && <img src={comp.logoSrc} alt="로고" className="h-10" />}
|
||||||
|
<span className="font-bold text-lg">{comp.brandName}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-500">{comp.sendDate}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{comp.type === "infoTable" && (
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
{comp.tableTitle && (
|
||||||
|
<div className="bg-gray-50 px-4 py-2 font-semibold border-b">{comp.tableTitle}</div>
|
||||||
|
)}
|
||||||
|
<table className="w-full">
|
||||||
|
<tbody>
|
||||||
|
{comp.rows?.map((row, i) => (
|
||||||
|
<tr key={i} className={i % 2 === 0 ? "bg-white" : "bg-gray-50"}>
|
||||||
|
<td className="px-4 py-2 font-medium text-gray-600 w-1/3 border-r">{row.label}</td>
|
||||||
|
<td className="px-4 py-2">{row.value}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{comp.type === "alertBox" && (
|
||||||
|
<div className={`p-4 rounded-lg border-l-4 ${
|
||||||
|
comp.alertType === "info" ? "bg-blue-50 border-blue-500 text-blue-800" :
|
||||||
|
comp.alertType === "warning" ? "bg-amber-50 border-amber-500 text-amber-800" :
|
||||||
|
comp.alertType === "danger" ? "bg-red-50 border-red-500 text-red-800" :
|
||||||
|
"bg-emerald-50 border-emerald-500 text-emerald-800"
|
||||||
|
}`}>
|
||||||
|
{comp.alertTitle && <div className="font-bold mb-1">{comp.alertTitle}</div>}
|
||||||
|
<div>{comp.content}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{comp.type === "divider" && (
|
||||||
|
<hr className="border-gray-300" style={{ borderWidth: `${comp.height || 1}px` }} />
|
||||||
|
)}
|
||||||
|
{comp.type === "footer" && (
|
||||||
|
<div className="text-center text-sm text-gray-500 py-4 border-t bg-gray-50">
|
||||||
|
{comp.companyName && <div className="font-semibold text-gray-700">{comp.companyName}</div>}
|
||||||
|
{(comp.ceoName || comp.businessNumber) && (
|
||||||
|
<div className="mt-1">
|
||||||
|
{comp.ceoName && <span>대표: {comp.ceoName}</span>}
|
||||||
|
{comp.ceoName && comp.businessNumber && <span className="mx-2">|</span>}
|
||||||
|
{comp.businessNumber && <span>사업자등록번호: {comp.businessNumber}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{comp.address && <div className="mt-1">{comp.address}</div>}
|
||||||
|
{(comp.phone || comp.email) && (
|
||||||
|
<div className="mt-1">
|
||||||
|
{comp.phone && <span>Tel: {comp.phone}</span>}
|
||||||
|
{comp.phone && comp.email && <span className="mx-2">|</span>}
|
||||||
|
{comp.email && <span>Email: {comp.email}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{comp.copyright && <div className="mt-2 text-xs text-gray-400">{comp.copyright}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{comp.type === "numberedList" && (
|
||||||
|
<div className="p-4">
|
||||||
|
{comp.listTitle && <div className="font-semibold mb-2">{comp.listTitle}</div>}
|
||||||
|
<ol className="list-decimal list-inside space-y-1">
|
||||||
|
{comp.listItems?.map((item, i) => (
|
||||||
|
<li key={i}>{item}</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|
@ -571,13 +798,299 @@ export default function MailDesigner({
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-muted-foreground">픽셀</span>
|
<span className="text-sm text-muted-foreground">픽셀</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 p-3 bg-primary/10 rounded-lg border border-primary/20">
|
</div>
|
||||||
<p className="text-xs text-primary">
|
</div>
|
||||||
<strong>추천값:</strong><br/>
|
)}
|
||||||
• 좁은 간격: 10~20 픽셀<br/>
|
|
||||||
• 보통 간격: 30~50 픽셀<br/>
|
{/* 헤더 컴포넌트 */}
|
||||||
• 넓은 간격: 60~100 픽셀
|
{selected.type === "header" && (
|
||||||
</p>
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label>브랜드명</Label>
|
||||||
|
<Input
|
||||||
|
value={selected.brandName || ""}
|
||||||
|
onChange={(e) => updateComponent(selected.id, { brandName: e.target.value })}
|
||||||
|
placeholder="회사명"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>로고 이미지 URL</Label>
|
||||||
|
<Input
|
||||||
|
value={selected.logoSrc || ""}
|
||||||
|
onChange={(e) => updateComponent(selected.id, { logoSrc: e.target.value })}
|
||||||
|
placeholder="https://example.com/logo.png"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>발송일</Label>
|
||||||
|
<Input
|
||||||
|
value={selected.sendDate || ""}
|
||||||
|
onChange={(e) => updateComponent(selected.id, { sendDate: e.target.value })}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>배경색</Label>
|
||||||
|
<div className="flex items-center gap-3 mt-1">
|
||||||
|
<Input
|
||||||
|
type="color"
|
||||||
|
value={selected.headerBgColor || "#f8f9fa"}
|
||||||
|
onChange={(e) => updateComponent(selected.id, { headerBgColor: e.target.value })}
|
||||||
|
className="w-16 h-10"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">{selected.headerBgColor || "#f8f9fa"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 정보 테이블 컴포넌트 */}
|
||||||
|
{selected.type === "infoTable" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label>테이블 제목</Label>
|
||||||
|
<Input
|
||||||
|
value={selected.tableTitle || ""}
|
||||||
|
onChange={(e) => updateComponent(selected.id, { tableTitle: e.target.value })}
|
||||||
|
placeholder="예: 주문 정보"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>테이블 항목</Label>
|
||||||
|
<div className="space-y-2 mt-2">
|
||||||
|
{selected.rows?.map((row, i) => (
|
||||||
|
<div key={i} className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={row.label}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newRows = [...(selected.rows || [])];
|
||||||
|
newRows[i] = { ...newRows[i], label: e.target.value };
|
||||||
|
updateComponent(selected.id, { rows: newRows });
|
||||||
|
}}
|
||||||
|
placeholder="항목명"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={row.value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newRows = [...(selected.rows || [])];
|
||||||
|
newRows[i] = { ...newRows[i], value: e.target.value };
|
||||||
|
updateComponent(selected.id, { rows: newRows });
|
||||||
|
}}
|
||||||
|
placeholder="값"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const newRows = selected.rows?.filter((_, idx) => idx !== i);
|
||||||
|
updateComponent(selected.id, { rows: newRows });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const newRows = [...(selected.rows || []), { label: "", value: "" }];
|
||||||
|
updateComponent(selected.id, { rows: newRows });
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
항목 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 안내 박스 컴포넌트 */}
|
||||||
|
{selected.type === "alertBox" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label>박스 유형</Label>
|
||||||
|
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||||
|
{(["info", "warning", "danger", "success"] as const).map((type) => (
|
||||||
|
<Button
|
||||||
|
key={type}
|
||||||
|
variant={selected.alertType === type ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => updateComponent(selected.id, { alertType: type })}
|
||||||
|
className={
|
||||||
|
type === "info" ? "border-blue-300" :
|
||||||
|
type === "warning" ? "border-amber-300" :
|
||||||
|
type === "danger" ? "border-red-300" : "border-emerald-300"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{type === "info" ? "정보" : type === "warning" ? "주의" : type === "danger" ? "위험" : "성공"}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>제목</Label>
|
||||||
|
<Input
|
||||||
|
value={selected.alertTitle || ""}
|
||||||
|
onChange={(e) => updateComponent(selected.id, { alertTitle: e.target.value })}
|
||||||
|
placeholder="안내 제목"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>내용</Label>
|
||||||
|
<Textarea
|
||||||
|
value={selected.content || ""}
|
||||||
|
onChange={(e) => updateComponent(selected.id, { content: e.target.value })}
|
||||||
|
placeholder="안내 내용을 입력하세요"
|
||||||
|
rows={4}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 구분선 컴포넌트 */}
|
||||||
|
{selected.type === "divider" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label>선 두께</Label>
|
||||||
|
<div className="flex items-center gap-3 mt-1">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={selected.height || 1}
|
||||||
|
onChange={(e) => updateComponent(selected.id, { height: parseInt(e.target.value) || 1 })}
|
||||||
|
className="w-24"
|
||||||
|
min="1"
|
||||||
|
max="10"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">픽셀</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 푸터 컴포넌트 */}
|
||||||
|
{selected.type === "footer" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label>회사명</Label>
|
||||||
|
<Input
|
||||||
|
value={selected.companyName || ""}
|
||||||
|
onChange={(e) => updateComponent(selected.id, { companyName: e.target.value })}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>대표자</Label>
|
||||||
|
<Input
|
||||||
|
value={selected.ceoName || ""}
|
||||||
|
onChange={(e) => updateComponent(selected.id, { ceoName: e.target.value })}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>사업자등록번호</Label>
|
||||||
|
<Input
|
||||||
|
value={selected.businessNumber || ""}
|
||||||
|
onChange={(e) => updateComponent(selected.id, { businessNumber: e.target.value })}
|
||||||
|
placeholder="000-00-00000"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>주소</Label>
|
||||||
|
<Input
|
||||||
|
value={selected.address || ""}
|
||||||
|
onChange={(e) => updateComponent(selected.id, { address: e.target.value })}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>전화번호</Label>
|
||||||
|
<Input
|
||||||
|
value={selected.phone || ""}
|
||||||
|
onChange={(e) => updateComponent(selected.id, { phone: e.target.value })}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>이메일</Label>
|
||||||
|
<Input
|
||||||
|
value={selected.email || ""}
|
||||||
|
onChange={(e) => updateComponent(selected.id, { email: e.target.value })}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>저작권 문구</Label>
|
||||||
|
<Input
|
||||||
|
value={selected.copyright || ""}
|
||||||
|
onChange={(e) => updateComponent(selected.id, { copyright: e.target.value })}
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 번호 리스트 컴포넌트 */}
|
||||||
|
{selected.type === "numberedList" && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label>리스트 제목</Label>
|
||||||
|
<Input
|
||||||
|
value={selected.listTitle || ""}
|
||||||
|
onChange={(e) => updateComponent(selected.id, { listTitle: e.target.value })}
|
||||||
|
placeholder="예: 안내 사항"
|
||||||
|
className="mt-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>항목</Label>
|
||||||
|
<div className="space-y-2 mt-2">
|
||||||
|
{selected.listItems?.map((item, i) => (
|
||||||
|
<div key={i} className="flex gap-2">
|
||||||
|
<span className="flex items-center justify-center w-6 text-sm text-muted-foreground">{i + 1}.</span>
|
||||||
|
<Input
|
||||||
|
value={item}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newItems = [...(selected.listItems || [])];
|
||||||
|
newItems[i] = e.target.value;
|
||||||
|
updateComponent(selected.id, { listItems: newItems });
|
||||||
|
}}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const newItems = selected.listItems?.filter((_, idx) => idx !== i);
|
||||||
|
updateComponent(selected.id, { listItems: newItems });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
const newItems = [...(selected.listItems || []), ""];
|
||||||
|
updateComponent(selected.id, { listItems: newItems });
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
항목 추가
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import {
|
||||||
ResizableDialogContent,
|
ResizableDialogContent,
|
||||||
ResizableDialogHeader,
|
ResizableDialogHeader,
|
||||||
ResizableDialogTitle,
|
ResizableDialogTitle,
|
||||||
ResizableDialogDescription,
|
|
||||||
ResizableDialogFooter,
|
ResizableDialogFooter,
|
||||||
} from "@/components/ui/resizable-dialog";
|
} from "@/components/ui/resizable-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -15,11 +14,13 @@ import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Search, X, Check, ChevronsUpDown, Database } from "lucide-react";
|
import { Search, X, Check, ChevronsUpDown, Database, Globe } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { screenApi, tableTypeApi } from "@/lib/api/screen";
|
import { screenApi, tableTypeApi } from "@/lib/api/screen";
|
||||||
import { ScreenDefinition } from "@/types/screen";
|
import { ScreenDefinition } from "@/types/screen";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
|
import { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection";
|
||||||
|
|
||||||
interface CreateScreenModalProps {
|
interface CreateScreenModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -39,12 +40,22 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
||||||
const [tableSearchTerm, setTableSearchTerm] = useState("");
|
const [tableSearchTerm, setTableSearchTerm] = useState("");
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// 데이터 소스 타입 (database: 데이터베이스, restapi: REST API)
|
||||||
|
const [dataSourceType, setDataSourceType] = useState<"database" | "restapi">("database");
|
||||||
|
|
||||||
// 외부 DB 연결 관련 상태
|
// 외부 DB 연결 관련 상태
|
||||||
const [selectedDbSource, setSelectedDbSource] = useState<"internal" | number>("internal");
|
const [selectedDbSource, setSelectedDbSource] = useState<"internal" | number>("internal");
|
||||||
const [externalConnections, setExternalConnections] = useState<any[]>([]);
|
const [externalConnections, setExternalConnections] = useState<any[]>([]);
|
||||||
const [externalTableList, setExternalTableList] = useState<string[]>([]);
|
const [externalTableList, setExternalTableList] = useState<string[]>([]);
|
||||||
const [loadingExternalTables, setLoadingExternalTables] = useState(false);
|
const [loadingExternalTables, setLoadingExternalTables] = useState(false);
|
||||||
const [openDbSourceCombobox, setOpenDbSourceCombobox] = useState(false);
|
const [openDbSourceCombobox, setOpenDbSourceCombobox] = useState(false);
|
||||||
|
|
||||||
|
// REST API 연결 관련 상태
|
||||||
|
const [restApiConnections, setRestApiConnections] = useState<ExternalRestApiConnection[]>([]);
|
||||||
|
const [selectedRestApiId, setSelectedRestApiId] = useState<number | null>(null);
|
||||||
|
const [openRestApiCombobox, setOpenRestApiCombobox] = useState(false);
|
||||||
|
const [restApiEndpoint, setRestApiEndpoint] = useState("");
|
||||||
|
const [restApiJsonPath, setRestApiJsonPath] = useState("data"); // 응답에서 데이터 추출 경로
|
||||||
// 화면 코드 자동 생성
|
// 화면 코드 자동 생성
|
||||||
const generateCode = async () => {
|
const generateCode = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -109,6 +120,21 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
||||||
loadConnections();
|
loadConnections();
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
// REST API 연결 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const loadRestApiConnections = async () => {
|
||||||
|
try {
|
||||||
|
const connections = await ExternalRestApiConnectionAPI.getConnections({ is_active: "Y" });
|
||||||
|
setRestApiConnections(connections);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load REST API connections:", error);
|
||||||
|
setRestApiConnections([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadRestApiConnections();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
// 외부 DB 테이블 목록 로드
|
// 외부 DB 테이블 목록 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedDbSource === "internal" || !selectedDbSource) {
|
if (selectedDbSource === "internal" || !selectedDbSource) {
|
||||||
|
|
@ -160,8 +186,15 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
||||||
}, [open, screenCode]);
|
}, [open, screenCode]);
|
||||||
|
|
||||||
const isValid = useMemo(() => {
|
const isValid = useMemo(() => {
|
||||||
return screenName.trim().length > 0 && screenCode.trim().length > 0 && tableName.trim().length > 0;
|
const baseValid = screenName.trim().length > 0 && screenCode.trim().length > 0;
|
||||||
}, [screenName, screenCode, tableName]);
|
|
||||||
|
if (dataSourceType === "database") {
|
||||||
|
return baseValid && tableName.trim().length > 0;
|
||||||
|
} else {
|
||||||
|
// REST API: 연결 선택 필수
|
||||||
|
return baseValid && selectedRestApiId !== null;
|
||||||
|
}
|
||||||
|
}, [screenName, screenCode, tableName, dataSourceType, selectedRestApiId]);
|
||||||
|
|
||||||
// 테이블 필터링 (내부 DB용)
|
// 테이블 필터링 (내부 DB용)
|
||||||
const filteredTables = useMemo(() => {
|
const filteredTables = useMemo(() => {
|
||||||
|
|
@ -186,17 +219,30 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
const companyCode = (user as any)?.company_code || (user as any)?.companyCode || "*";
|
const companyCode = (user as any)?.company_code || (user as any)?.companyCode || "*";
|
||||||
|
|
||||||
// DB 소스 정보 추가
|
// 데이터 소스 타입에 따라 다른 정보 전달
|
||||||
const created = await screenApi.createScreen({
|
const createData: any = {
|
||||||
screenName: screenName.trim(),
|
screenName: screenName.trim(),
|
||||||
screenCode: screenCode.trim(),
|
screenCode: screenCode.trim(),
|
||||||
tableName: tableName.trim(),
|
|
||||||
companyCode,
|
companyCode,
|
||||||
description: description.trim() || undefined,
|
description: description.trim() || undefined,
|
||||||
createdBy: (user as any)?.userId,
|
createdBy: (user as any)?.userId,
|
||||||
dbSourceType: selectedDbSource === "internal" ? "internal" : "external",
|
dataSourceType: dataSourceType,
|
||||||
dbConnectionId: selectedDbSource === "internal" ? undefined : Number(selectedDbSource),
|
};
|
||||||
} as any);
|
|
||||||
|
if (dataSourceType === "database") {
|
||||||
|
// 데이터베이스 소스
|
||||||
|
createData.tableName = tableName.trim();
|
||||||
|
createData.dbSourceType = selectedDbSource === "internal" ? "internal" : "external";
|
||||||
|
createData.dbConnectionId = selectedDbSource === "internal" ? undefined : Number(selectedDbSource);
|
||||||
|
} else {
|
||||||
|
// REST API 소스
|
||||||
|
createData.tableName = `_restapi_${selectedRestApiId}`; // REST API용 가상 테이블명
|
||||||
|
createData.restApiConnectionId = selectedRestApiId;
|
||||||
|
createData.restApiEndpoint = restApiEndpoint.trim() || undefined;
|
||||||
|
createData.restApiJsonPath = restApiJsonPath.trim() || "data";
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await screenApi.createScreen(createData);
|
||||||
|
|
||||||
// 날짜 필드 보정
|
// 날짜 필드 보정
|
||||||
const mapped: ScreenDefinition = {
|
const mapped: ScreenDefinition = {
|
||||||
|
|
@ -207,11 +253,16 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
||||||
|
|
||||||
onCreated?.(mapped);
|
onCreated?.(mapped);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
|
// 폼 초기화
|
||||||
setScreenName("");
|
setScreenName("");
|
||||||
setScreenCode("");
|
setScreenCode("");
|
||||||
setTableName("");
|
setTableName("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
setSelectedDbSource("internal");
|
setSelectedDbSource("internal");
|
||||||
|
setDataSourceType("database");
|
||||||
|
setSelectedRestApiId(null);
|
||||||
|
setRestApiEndpoint("");
|
||||||
|
setRestApiJsonPath("data");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 필요 시 토스트 추가 가능
|
// 필요 시 토스트 추가 가능
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -263,83 +314,210 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* DB 소스 선택 */}
|
{/* 데이터 소스 타입 선택 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="dbSource">데이터베이스 소스</Label>
|
<Label>데이터 소스 타입</Label>
|
||||||
<Popover open={openDbSourceCombobox} onOpenChange={setOpenDbSourceCombobox}>
|
<div className="flex gap-2">
|
||||||
<PopoverTrigger asChild>
|
<Button
|
||||||
<Button
|
type="button"
|
||||||
variant="outline"
|
variant={dataSourceType === "database" ? "default" : "outline"}
|
||||||
role="combobox"
|
className="flex-1"
|
||||||
aria-expanded={openDbSourceCombobox}
|
onClick={() => {
|
||||||
className="w-full justify-between"
|
setDataSourceType("database");
|
||||||
>
|
setSelectedRestApiId(null);
|
||||||
<div className="flex items-center gap-2">
|
}}
|
||||||
<Database className="h-4 w-4" />
|
>
|
||||||
{selectedDbSource === "internal"
|
<Database className="mr-2 h-4 w-4" />
|
||||||
? "내부 데이터베이스"
|
데이터베이스
|
||||||
: externalConnections.find((conn) => conn.id === selectedDbSource)?.connection_name ||
|
</Button>
|
||||||
"선택하세요"}
|
<Button
|
||||||
</div>
|
type="button"
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
variant={dataSourceType === "restapi" ? "default" : "outline"}
|
||||||
</Button>
|
className="flex-1"
|
||||||
</PopoverTrigger>
|
onClick={() => {
|
||||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
setDataSourceType("restapi");
|
||||||
<Command>
|
setTableName("");
|
||||||
<CommandInput placeholder="데이터베이스 검색..." />
|
setSelectedDbSource("internal");
|
||||||
<CommandList>
|
}}
|
||||||
<CommandEmpty>데이터베이스를 찾을 수 없습니다.</CommandEmpty>
|
>
|
||||||
<CommandGroup>
|
<Globe className="mr-2 h-4 w-4" />
|
||||||
<CommandItem
|
REST API
|
||||||
value="internal"
|
</Button>
|
||||||
onSelect={() => {
|
</div>
|
||||||
setSelectedDbSource("internal");
|
|
||||||
setTableName("");
|
|
||||||
setTableSearchTerm("");
|
|
||||||
setOpenDbSourceCombobox(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn("mr-2 h-4 w-4", selectedDbSource === "internal" ? "opacity-100" : "opacity-0")}
|
|
||||||
/>
|
|
||||||
<Database className="mr-2 h-4 w-4 text-blue-500" />
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="font-medium">내부 데이터베이스</span>
|
|
||||||
<span className="text-xs text-gray-500">PostgreSQL (현재 시스템)</span>
|
|
||||||
</div>
|
|
||||||
</CommandItem>
|
|
||||||
{externalConnections.map((conn: any) => (
|
|
||||||
<CommandItem
|
|
||||||
key={conn.id}
|
|
||||||
value={`${conn.connection_name} ${conn.db_type}`}
|
|
||||||
onSelect={() => {
|
|
||||||
setSelectedDbSource(conn.id);
|
|
||||||
setTableName("");
|
|
||||||
setTableSearchTerm("");
|
|
||||||
setOpenDbSourceCombobox(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn("mr-2 h-4 w-4", selectedDbSource === conn.id ? "opacity-100" : "opacity-0")}
|
|
||||||
/>
|
|
||||||
<Database className="mr-2 h-4 w-4 text-green-500" />
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="font-medium">{conn.connection_name}</span>
|
|
||||||
<span className="text-xs text-gray-500">{conn.db_type?.toUpperCase()}</span>
|
|
||||||
</div>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
<p className="text-xs text-gray-500">화면에서 사용할 데이터베이스를 선택합니다</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 테이블 선택 */}
|
{/* 데이터베이스 소스 설정 */}
|
||||||
|
{dataSourceType === "database" && (
|
||||||
|
<>
|
||||||
|
{/* DB 소스 선택 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="dbSource">데이터베이스 소스</Label>
|
||||||
|
<Popover open={openDbSourceCombobox} onOpenChange={setOpenDbSourceCombobox}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={openDbSourceCombobox}
|
||||||
|
className="w-full justify-between"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Database className="h-4 w-4" />
|
||||||
|
{selectedDbSource === "internal"
|
||||||
|
? "내부 데이터베이스"
|
||||||
|
: externalConnections.find((conn) => conn.id === selectedDbSource)?.connection_name ||
|
||||||
|
"선택하세요"}
|
||||||
|
</div>
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="데이터베이스 검색..." />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>데이터베이스를 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
<CommandItem
|
||||||
|
value="internal"
|
||||||
|
onSelect={() => {
|
||||||
|
setSelectedDbSource("internal");
|
||||||
|
setTableName("");
|
||||||
|
setTableSearchTerm("");
|
||||||
|
setOpenDbSourceCombobox(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn("mr-2 h-4 w-4", selectedDbSource === "internal" ? "opacity-100" : "opacity-0")}
|
||||||
|
/>
|
||||||
|
<Database className="mr-2 h-4 w-4 text-blue-500" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">내부 데이터베이스</span>
|
||||||
|
<span className="text-xs text-gray-500">PostgreSQL (현재 시스템)</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
{externalConnections.map((conn: any) => (
|
||||||
|
<CommandItem
|
||||||
|
key={conn.id}
|
||||||
|
value={`${conn.connection_name} ${conn.db_type}`}
|
||||||
|
onSelect={() => {
|
||||||
|
setSelectedDbSource(conn.id);
|
||||||
|
setTableName("");
|
||||||
|
setTableSearchTerm("");
|
||||||
|
setOpenDbSourceCombobox(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn("mr-2 h-4 w-4", selectedDbSource === conn.id ? "opacity-100" : "opacity-0")}
|
||||||
|
/>
|
||||||
|
<Database className="mr-2 h-4 w-4 text-green-500" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{conn.connection_name}</span>
|
||||||
|
<span className="text-xs text-gray-500">{conn.db_type?.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<p className="text-xs text-gray-500">화면에서 사용할 데이터베이스를 선택합니다</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* REST API 소스 설정 */}
|
||||||
|
{dataSourceType === "restapi" && (
|
||||||
|
<>
|
||||||
|
{/* REST API 연결 선택 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="restApiConnection">REST API 연결 *</Label>
|
||||||
|
<Popover open={openRestApiCombobox} onOpenChange={setOpenRestApiCombobox}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={openRestApiCombobox}
|
||||||
|
className="w-full justify-between"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Globe className="h-4 w-4" />
|
||||||
|
{selectedRestApiId
|
||||||
|
? restApiConnections.find((conn) => conn.id === selectedRestApiId)?.connection_name ||
|
||||||
|
"선택하세요"
|
||||||
|
: "REST API 연결을 선택하세요"}
|
||||||
|
</div>
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="REST API 검색..." />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>등록된 REST API 연결이 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{restApiConnections.map((conn) => (
|
||||||
|
<CommandItem
|
||||||
|
key={conn.id}
|
||||||
|
value={`${conn.connection_name} ${conn.base_url}`}
|
||||||
|
onSelect={() => {
|
||||||
|
setSelectedRestApiId(conn.id!);
|
||||||
|
setRestApiEndpoint(conn.endpoint_path || "");
|
||||||
|
setOpenRestApiCombobox(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn("mr-2 h-4 w-4", selectedRestApiId === conn.id ? "opacity-100" : "opacity-0")}
|
||||||
|
/>
|
||||||
|
<Globe className="mr-2 h-4 w-4 text-purple-500" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium">{conn.connection_name}</span>
|
||||||
|
<span className="text-xs text-gray-500 truncate max-w-[300px]">{conn.base_url}</span>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
등록된 REST API 연결을 선택합니다.
|
||||||
|
<Link href="/admin/externalRestApi" className="ml-1 text-primary hover:underline">
|
||||||
|
새 연결 등록
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 엔드포인트 경로 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="restApiEndpoint">엔드포인트 경로</Label>
|
||||||
|
<Input
|
||||||
|
id="restApiEndpoint"
|
||||||
|
value={restApiEndpoint}
|
||||||
|
onChange={(e) => setRestApiEndpoint(e.target.value)}
|
||||||
|
placeholder="/api/data 또는 /users"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">기본 URL 뒤에 추가될 경로 (선택사항)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* JSON Path */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="restApiJsonPath">데이터 경로 (JSON Path)</Label>
|
||||||
|
<Input
|
||||||
|
id="restApiJsonPath"
|
||||||
|
value={restApiJsonPath}
|
||||||
|
onChange={(e) => setRestApiJsonPath(e.target.value)}
|
||||||
|
placeholder="data 또는 result.items"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">API 응답에서 데이터 배열을 추출할 경로 (예: data, result.items)</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 테이블 선택 (데이터베이스 모드일 때만) */}
|
||||||
|
{dataSourceType === "database" && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="tableName">테이블</Label>
|
<Label htmlFor="tableName">테이블 *</Label>
|
||||||
<Select
|
<Select
|
||||||
value={tableName}
|
value={tableName}
|
||||||
onValueChange={setTableName}
|
onValueChange={setTableName}
|
||||||
|
|
@ -422,11 +600,7 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="description">설명</Label>
|
|
||||||
<Input id="description" value={description} onChange={(e) => setDescription(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResizableDialogFooter className="mt-4">
|
<ResizableDialogFooter className="mt-4">
|
||||||
|
|
|
||||||
|
|
@ -408,6 +408,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
value: currentValue,
|
value: currentValue,
|
||||||
onChange: (value: any) => handleFormDataChange(fieldName, value),
|
onChange: (value: any) => handleFormDataChange(fieldName, value),
|
||||||
onFormDataChange: handleFormDataChange,
|
onFormDataChange: handleFormDataChange,
|
||||||
|
formData: formData, // 🆕 전체 formData 전달
|
||||||
isInteractive: true,
|
isInteractive: true,
|
||||||
readonly: readonly,
|
readonly: readonly,
|
||||||
required: required,
|
required: required,
|
||||||
|
|
@ -415,6 +416,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
className: "w-full h-full",
|
className: "w-full h-full",
|
||||||
isInModal: isInModal, // 🆕 EditModal 내부 여부 전달
|
isInModal: isInModal, // 🆕 EditModal 내부 여부 전달
|
||||||
onSave: onSave, // 🆕 EditModal의 handleSave 콜백 전달
|
onSave: onSave, // 🆕 EditModal의 handleSave 콜백 전달
|
||||||
|
groupedData: groupedData, // 🆕 그룹 데이터 전달 (RepeatScreenModal용)
|
||||||
}}
|
}}
|
||||||
config={widget.webTypeConfig}
|
config={widget.webTypeConfig}
|
||||||
onEvent={(event: string, data: any) => {
|
onEvent={(event: string, data: any) => {
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,7 @@ const calculateGridInfo = (width: number, height: number, settings: any) => {
|
||||||
import { GroupingToolbar } from "./GroupingToolbar";
|
import { GroupingToolbar } from "./GroupingToolbar";
|
||||||
import { screenApi, tableTypeApi } from "@/lib/api/screen";
|
import { screenApi, tableTypeApi } from "@/lib/api/screen";
|
||||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||||
|
import { ExternalRestApiConnectionAPI } from "@/lib/api/externalRestApiConnection";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { MenuAssignmentModal } from "./MenuAssignmentModal";
|
import { MenuAssignmentModal } from "./MenuAssignmentModal";
|
||||||
import { FileAttachmentDetailModal } from "./FileAttachmentDetailModal";
|
import { FileAttachmentDetailModal } from "./FileAttachmentDetailModal";
|
||||||
|
|
@ -835,9 +836,52 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 화면의 기본 테이블 정보 로드 (원래대로 복원)
|
// 화면의 기본 테이블/REST API 정보 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadScreenTable = async () => {
|
const loadScreenDataSource = async () => {
|
||||||
|
// REST API 데이터 소스인 경우
|
||||||
|
if (selectedScreen?.dataSourceType === "restapi" && selectedScreen?.restApiConnectionId) {
|
||||||
|
try {
|
||||||
|
const restApiData = await ExternalRestApiConnectionAPI.fetchData(
|
||||||
|
selectedScreen.restApiConnectionId,
|
||||||
|
selectedScreen.restApiEndpoint,
|
||||||
|
selectedScreen.restApiJsonPath || "data",
|
||||||
|
);
|
||||||
|
|
||||||
|
// REST API 응답에서 컬럼 정보 생성
|
||||||
|
const columns: ColumnInfo[] = restApiData.columns.map((col) => ({
|
||||||
|
tableName: `restapi_${selectedScreen.restApiConnectionId}`,
|
||||||
|
columnName: col.columnName,
|
||||||
|
columnLabel: col.columnLabel,
|
||||||
|
dataType: col.dataType === "string" ? "varchar" : col.dataType === "number" ? "numeric" : col.dataType,
|
||||||
|
webType: col.dataType === "number" ? "number" : "text",
|
||||||
|
input_type: "text",
|
||||||
|
widgetType: col.dataType === "number" ? "number" : "text",
|
||||||
|
isNullable: "YES",
|
||||||
|
required: false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const tableInfo: TableInfo = {
|
||||||
|
tableName: `restapi_${selectedScreen.restApiConnectionId}`,
|
||||||
|
tableLabel: restApiData.connectionInfo.connectionName || "REST API 데이터",
|
||||||
|
columns,
|
||||||
|
};
|
||||||
|
|
||||||
|
setTables([tableInfo]);
|
||||||
|
console.log("REST API 데이터 소스 로드 완료:", {
|
||||||
|
connectionName: restApiData.connectionInfo.connectionName,
|
||||||
|
columnsCount: columns.length,
|
||||||
|
rowsCount: restApiData.total,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("REST API 데이터 소스 로드 실패:", error);
|
||||||
|
toast.error("REST API 데이터를 불러오는데 실패했습니다.");
|
||||||
|
setTables([]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터베이스 데이터 소스인 경우 (기존 로직)
|
||||||
const tableName = selectedScreen?.tableName;
|
const tableName = selectedScreen?.tableName;
|
||||||
if (!tableName) {
|
if (!tableName) {
|
||||||
setTables([]);
|
setTables([]);
|
||||||
|
|
@ -859,16 +903,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => {
|
const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => {
|
||||||
const widgetType = col.widgetType || col.widget_type || col.webType || col.web_type;
|
const widgetType = col.widgetType || col.widget_type || col.webType || col.web_type;
|
||||||
|
|
||||||
// 🔍 이미지 타입 디버깅
|
|
||||||
// if (widgetType === "image" || col.webType === "image" || col.web_type === "image") {
|
|
||||||
// console.log("🖼️ 이미지 컬럼 발견:", {
|
|
||||||
// columnName: col.columnName || col.column_name,
|
|
||||||
// widgetType,
|
|
||||||
// webType: col.webType || col.web_type,
|
|
||||||
// rawData: col,
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tableName: col.tableName || tableName,
|
tableName: col.tableName || tableName,
|
||||||
columnName: col.columnName || col.column_name,
|
columnName: col.columnName || col.column_name,
|
||||||
|
|
@ -899,8 +933,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadScreenTable();
|
loadScreenDataSource();
|
||||||
}, [selectedScreen?.tableName, selectedScreen?.screenName]);
|
}, [selectedScreen?.tableName, selectedScreen?.screenName, selectedScreen?.dataSourceType, selectedScreen?.restApiConnectionId, selectedScreen?.restApiEndpoint, selectedScreen?.restApiJsonPath]);
|
||||||
|
|
||||||
// 화면 레이아웃 로드
|
// 화면 레이아웃 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -863,27 +863,23 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
});
|
});
|
||||||
|
|
||||||
// 래퍼 컴포넌트: 새 ConfigPanel 인터페이스를 기존 패턴에 맞춤
|
// 래퍼 컴포넌트: 새 ConfigPanel 인터페이스를 기존 패턴에 맞춤
|
||||||
const ConfigPanelWrapper = () => {
|
// Section Card, Section Paper 등 신규 컴포넌트는 componentConfig 바로 아래에 설정 저장
|
||||||
// Section Card, Section Paper 등 신규 컴포넌트는 componentConfig 바로 아래에 설정 저장
|
const config = currentConfig || definition.defaultProps?.componentConfig || {};
|
||||||
const config = currentConfig || definition.defaultProps?.componentConfig || {};
|
|
||||||
|
const handleConfigChange = (newConfig: any) => {
|
||||||
const handleConfigChange = (newConfig: any) => {
|
// componentConfig 전체를 업데이트
|
||||||
// componentConfig 전체를 업데이트
|
onUpdateProperty(selectedComponent.id, "componentConfig", newConfig);
|
||||||
onUpdateProperty(selectedComponent.id, "componentConfig", newConfig);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-2 border-b pb-2">
|
|
||||||
<Settings className="h-4 w-4 text-primary" />
|
|
||||||
<h3 className="text-sm font-semibold">{definition.name} 설정</h3>
|
|
||||||
</div>
|
|
||||||
<ConfigPanelComponent config={config} onChange={handleConfigChange} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return <ConfigPanelWrapper key={selectedComponent.id} />;
|
return (
|
||||||
|
<div className="space-y-4" key={selectedComponent.id}>
|
||||||
|
<div className="flex items-center gap-2 border-b pb-2">
|
||||||
|
<Settings className="h-4 w-4 text-primary" />
|
||||||
|
<h3 className="text-sm font-semibold">{definition.name} 설정</h3>
|
||||||
|
</div>
|
||||||
|
<ConfigPanelComponent config={config} onChange={handleConfigChange} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
console.warn("⚠️ ConfigPanel 없음:", {
|
console.warn("⚠️ ConfigPanel 없음:", {
|
||||||
componentId,
|
componentId,
|
||||||
|
|
|
||||||
|
|
@ -238,9 +238,9 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
// 컴포넌트가 선택되지 않았을 때도 해상도 설정과 격자 설정은 표시
|
// 컴포넌트가 선택되지 않았을 때도 해상도 설정과 격자 설정은 표시
|
||||||
if (!selectedComponent) {
|
if (!selectedComponent) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-white">
|
<div className="flex h-full flex-col bg-white overflow-x-auto">
|
||||||
{/* 해상도 설정과 격자 설정 표시 */}
|
{/* 해상도 설정과 격자 설정 표시 */}
|
||||||
<div className="flex-1 overflow-y-auto p-2">
|
<div className="flex-1 overflow-y-auto overflow-x-auto p-2">
|
||||||
<div className="space-y-4 text-xs">
|
<div className="space-y-4 text-xs">
|
||||||
{/* 해상도 설정 */}
|
{/* 해상도 설정 */}
|
||||||
{currentResolution && onResolutionChange && (
|
{currentResolution && onResolutionChange && (
|
||||||
|
|
@ -326,40 +326,36 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
});
|
});
|
||||||
|
|
||||||
// 래퍼 컴포넌트: 새 ConfigPanel 인터페이스를 기존 패턴에 맞춤
|
// 래퍼 컴포넌트: 새 ConfigPanel 인터페이스를 기존 패턴에 맞춤
|
||||||
const ConfigPanelWrapper = () => {
|
// Section Card, Section Paper 등 신규 컴포넌트는 componentConfig 바로 아래에 설정 저장
|
||||||
// Section Card, Section Paper 등 신규 컴포넌트는 componentConfig 바로 아래에 설정 저장
|
const config = currentConfig || definition.defaultProps?.componentConfig || {};
|
||||||
const config = currentConfig || definition.defaultProps?.componentConfig || {};
|
|
||||||
|
const handleConfigChange = (newConfig: any) => {
|
||||||
const handleConfigChange = (newConfig: any) => {
|
// componentConfig 전체를 업데이트
|
||||||
// componentConfig 전체를 업데이트
|
onUpdateProperty(selectedComponent.id, "componentConfig", newConfig);
|
||||||
onUpdateProperty(selectedComponent.id, "componentConfig", newConfig);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-2 border-b pb-2">
|
|
||||||
<Settings className="h-4 w-4 text-primary" />
|
|
||||||
<h3 className="text-sm font-semibold">{definition.name} 설정</h3>
|
|
||||||
</div>
|
|
||||||
<Suspense fallback={
|
|
||||||
<div className="flex items-center justify-center py-8">
|
|
||||||
<div className="text-sm text-muted-foreground">설정 패널 로딩 중...</div>
|
|
||||||
</div>
|
|
||||||
}>
|
|
||||||
<ConfigPanelComponent
|
|
||||||
config={config}
|
|
||||||
onChange={handleConfigChange}
|
|
||||||
tables={tables} // 테이블 정보 전달
|
|
||||||
allTables={allTables} // 🆕 전체 테이블 목록 전달 (selected-items-detail-input 등에서 사용)
|
|
||||||
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} // 🔧 화면 테이블명 전달
|
|
||||||
tableColumns={currentTable?.columns || []} // 🔧 테이블 컬럼 정보 전달
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return <ConfigPanelWrapper key={selectedComponent.id} />;
|
return (
|
||||||
|
<div className="space-y-4" key={selectedComponent.id}>
|
||||||
|
<div className="flex items-center gap-2 border-b pb-2">
|
||||||
|
<Settings className="h-4 w-4 text-primary" />
|
||||||
|
<h3 className="text-sm font-semibold">{definition.name} 설정</h3>
|
||||||
|
</div>
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="text-sm text-muted-foreground">설정 패널 로딩 중...</div>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<ConfigPanelComponent
|
||||||
|
config={config}
|
||||||
|
onChange={handleConfigChange}
|
||||||
|
tables={tables} // 테이블 정보 전달
|
||||||
|
allTables={allTables} // 🆕 전체 테이블 목록 전달 (selected-items-detail-input 등에서 사용)
|
||||||
|
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} // 🔧 화면 테이블명 전달
|
||||||
|
tableColumns={currentTable?.columns || []} // 🔧 테이블 컬럼 정보 전달
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
console.warn("⚠️ ComponentRegistry에서 ConfigPanel을 찾을 수 없음 - switch case로 이동:", {
|
console.warn("⚠️ ComponentRegistry에서 ConfigPanel을 찾을 수 없음 - switch case로 이동:", {
|
||||||
componentId,
|
componentId,
|
||||||
|
|
@ -1418,7 +1414,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 통합 컨텐츠 (탭 제거) */}
|
{/* 통합 컨텐츠 (탭 제거) */}
|
||||||
<div className="flex-1 overflow-y-auto p-2">
|
<div className="flex-1 overflow-y-auto overflow-x-auto p-2">
|
||||||
<div className="space-y-4 text-xs">
|
<div className="space-y-4 text-xs">
|
||||||
{/* 해상도 설정 - 항상 맨 위에 표시 */}
|
{/* 해상도 설정 - 항상 맨 위에 표시 */}
|
||||||
{currentResolution && onResolutionChange && (
|
{currentResolution && onResolutionChange && (
|
||||||
|
|
|
||||||
|
|
@ -176,7 +176,7 @@ const ResizableDialogContent = React.forwardRef<
|
||||||
height: Math.max(minHeight, Math.min(maxHeight, parsed.height)),
|
height: Math.max(minHeight, Math.min(maxHeight, parsed.height)),
|
||||||
userResized: true,
|
userResized: true,
|
||||||
};
|
};
|
||||||
console.log("💾 사용자가 리사이징한 크기 복원:", savedSize);
|
// console.log("💾 사용자가 리사이징한 크기 복원:", savedSize);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -221,11 +221,11 @@ export const useAuth = () => {
|
||||||
|
|
||||||
setAuthStatus(finalAuthStatus);
|
setAuthStatus(finalAuthStatus);
|
||||||
|
|
||||||
console.log("✅ 최종 사용자 상태:", {
|
// console.log("✅ 최종 사용자 상태:", {
|
||||||
userId: userInfo?.userId,
|
// userId: userInfo?.userId,
|
||||||
userName: userInfo?.userName,
|
// userName: userInfo?.userName,
|
||||||
companyCode: userInfo?.companyCode || userInfo?.company_code,
|
// companyCode: userInfo?.companyCode || userInfo?.company_code,
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 디버깅용 로그
|
// 디버깅용 로그
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -192,6 +192,43 @@ export class ExternalRestApiConnectionAPI {
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST API 데이터 조회 (화면관리용 프록시)
|
||||||
|
*/
|
||||||
|
static async fetchData(
|
||||||
|
connectionId: number,
|
||||||
|
endpoint?: string,
|
||||||
|
jsonPath?: string,
|
||||||
|
): Promise<{
|
||||||
|
rows: any[];
|
||||||
|
columns: Array<{ columnName: string; columnLabel: string; dataType: string }>;
|
||||||
|
total: number;
|
||||||
|
connectionInfo: {
|
||||||
|
connectionId: number;
|
||||||
|
connectionName: string;
|
||||||
|
baseUrl: string;
|
||||||
|
endpoint: string;
|
||||||
|
};
|
||||||
|
}> {
|
||||||
|
const response = await apiClient.post<ApiResponse<{
|
||||||
|
rows: any[];
|
||||||
|
columns: Array<{ columnName: string; columnLabel: string; dataType: string }>;
|
||||||
|
total: number;
|
||||||
|
connectionInfo: {
|
||||||
|
connectionId: number;
|
||||||
|
connectionName: string;
|
||||||
|
baseUrl: string;
|
||||||
|
endpoint: string;
|
||||||
|
};
|
||||||
|
}>>(`${this.BASE_PATH}/${connectionId}/fetch`, { endpoint, jsonPath });
|
||||||
|
|
||||||
|
if (!response.data.success) {
|
||||||
|
throw new Error(response.data.message || "REST API 데이터 조회에 실패했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data.data!;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 지원되는 인증 타입 목록
|
* 지원되는 인증 타입 목록
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -42,13 +42,35 @@ export interface UpdateMailAccountDto extends Partial<CreateMailAccountDto> {
|
||||||
|
|
||||||
export interface MailComponent {
|
export interface MailComponent {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'text' | 'button' | 'image' | 'spacer';
|
type: 'text' | 'button' | 'image' | 'spacer' | 'header' | 'infoTable' | 'alertBox' | 'divider' | 'footer' | 'numberedList';
|
||||||
content?: string;
|
content?: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
src?: string;
|
src?: string;
|
||||||
height?: number;
|
height?: number;
|
||||||
styles?: Record<string, string>;
|
styles?: Record<string, string>;
|
||||||
|
// 헤더 컴포넌트용
|
||||||
|
logoSrc?: string;
|
||||||
|
brandName?: string;
|
||||||
|
sendDate?: string;
|
||||||
|
headerBgColor?: string;
|
||||||
|
// 정보 테이블용
|
||||||
|
rows?: Array<{ label: string; value: string }>;
|
||||||
|
tableTitle?: string;
|
||||||
|
// 강조 박스용
|
||||||
|
alertType?: 'info' | 'warning' | 'danger' | 'success';
|
||||||
|
alertTitle?: string;
|
||||||
|
// 푸터용
|
||||||
|
companyName?: string;
|
||||||
|
ceoName?: string;
|
||||||
|
businessNumber?: string;
|
||||||
|
address?: string;
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
copyright?: string;
|
||||||
|
// 번호 리스트용
|
||||||
|
listItems?: string[];
|
||||||
|
listTitle?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MailTemplate {
|
export interface MailTemplate {
|
||||||
|
|
@ -470,6 +492,95 @@ export function renderTemplateToHtml(
|
||||||
case 'spacer':
|
case 'spacer':
|
||||||
html += `<div style="height: ${component.height || 20}px;"></div>`;
|
html += `<div style="height: ${component.height || 20}px;"></div>`;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'header':
|
||||||
|
html += `
|
||||||
|
<div style="padding: 20px; background-color: ${component.headerBgColor || '#f8f9fa'}; border-radius: 8px; margin-bottom: 20px;">
|
||||||
|
<table style="width: 100%;">
|
||||||
|
<tr>
|
||||||
|
<td style="vertical-align: middle;">
|
||||||
|
${component.logoSrc ? `<img src="${component.logoSrc}" alt="로고" style="height: 40px; margin-right: 12px;">` : ''}
|
||||||
|
<span style="font-size: 18px; font-weight: bold;">${component.brandName || ''}</span>
|
||||||
|
</td>
|
||||||
|
<td style="text-align: right; color: #6b7280; font-size: 14px;">
|
||||||
|
${component.sendDate || ''}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'infoTable':
|
||||||
|
html += `
|
||||||
|
<div style="border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; margin: 16px 0;">
|
||||||
|
${component.tableTitle ? `<div style="background-color: #f9fafb; padding: 12px 16px; font-weight: 600; border-bottom: 1px solid #e5e7eb;">${component.tableTitle}</div>` : ''}
|
||||||
|
<table style="width: 100%; border-collapse: collapse;">
|
||||||
|
${(component.rows || []).map((row, i) => `
|
||||||
|
<tr style="background-color: ${i % 2 === 0 ? '#ffffff' : '#f9fafb'};">
|
||||||
|
<td style="padding: 12px 16px; font-weight: 500; color: #4b5563; width: 35%; border-right: 1px solid #e5e7eb;">${row.label}</td>
|
||||||
|
<td style="padding: 12px 16px;">${row.value}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'alertBox':
|
||||||
|
const alertColors = {
|
||||||
|
info: { bg: '#eff6ff', border: '#3b82f6', text: '#1e40af' },
|
||||||
|
warning: { bg: '#fffbeb', border: '#f59e0b', text: '#92400e' },
|
||||||
|
danger: { bg: '#fef2f2', border: '#ef4444', text: '#991b1b' },
|
||||||
|
success: { bg: '#ecfdf5', border: '#10b981', text: '#065f46' }
|
||||||
|
};
|
||||||
|
const colors = alertColors[component.alertType || 'info'];
|
||||||
|
html += `
|
||||||
|
<div style="padding: 16px; background-color: ${colors.bg}; border-left: 4px solid ${colors.border}; border-radius: 4px; margin: 16px 0; color: ${colors.text};">
|
||||||
|
${component.alertTitle ? `<div style="font-weight: bold; margin-bottom: 8px;">${component.alertTitle}</div>` : ''}
|
||||||
|
<div>${component.content || ''}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'divider':
|
||||||
|
html += `<hr style="border: none; border-top: ${component.height || 1}px solid #e5e7eb; margin: 20px 0;">`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'footer':
|
||||||
|
html += `
|
||||||
|
<div style="text-align: center; padding: 24px 16px; background-color: #f9fafb; border-top: 1px solid #e5e7eb; font-size: 14px; color: #6b7280;">
|
||||||
|
${component.companyName ? `<div style="font-weight: 600; color: #374151; margin-bottom: 8px;">${component.companyName}</div>` : ''}
|
||||||
|
${(component.ceoName || component.businessNumber) ? `
|
||||||
|
<div style="margin-bottom: 4px;">
|
||||||
|
${component.ceoName ? `대표: ${component.ceoName}` : ''}
|
||||||
|
${component.ceoName && component.businessNumber ? ' | ' : ''}
|
||||||
|
${component.businessNumber ? `사업자등록번호: ${component.businessNumber}` : ''}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${component.address ? `<div style="margin-bottom: 4px;">${component.address}</div>` : ''}
|
||||||
|
${(component.phone || component.email) ? `
|
||||||
|
<div style="margin-bottom: 4px;">
|
||||||
|
${component.phone ? `Tel: ${component.phone}` : ''}
|
||||||
|
${component.phone && component.email ? ' | ' : ''}
|
||||||
|
${component.email ? `Email: ${component.email}` : ''}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${component.copyright ? `<div style="margin-top: 12px; font-size: 12px; color: #9ca3af;">${component.copyright}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'numberedList':
|
||||||
|
html += `
|
||||||
|
<div style="padding: 16px; ${styleObjectToString(component.styles)}">
|
||||||
|
${component.listTitle ? `<div style="font-weight: 600; margin-bottom: 12px;">${component.listTitle}</div>` : ''}
|
||||||
|
<ol style="margin: 0; padding-left: 20px;">
|
||||||
|
${(component.listItems || []).map(item => `<li style="margin-bottom: 8px;">${item}</li>`).join('')}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -93,7 +93,7 @@ class CodeCache {
|
||||||
* 여러 코드 카테고리를 배치로 미리 로딩
|
* 여러 코드 카테고리를 배치로 미리 로딩
|
||||||
*/
|
*/
|
||||||
async preloadCodes(categories: string[]): Promise<void> {
|
async preloadCodes(categories: string[]): Promise<void> {
|
||||||
console.log(`🔄 코드 배치 로딩 시작: ${categories.join(", ")}`);
|
// console.log(`🔄 코드 배치 로딩 시작: ${categories.join(", ")}`);
|
||||||
|
|
||||||
const promises = categories.map(async (category) => {
|
const promises = categories.map(async (category) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -101,7 +101,7 @@ class CodeCache {
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
const cacheKey = this.createCodeKey(category);
|
const cacheKey = this.createCodeKey(category);
|
||||||
this.set(cacheKey, response.data, this.defaultTTL);
|
this.set(cacheKey, response.data, this.defaultTTL);
|
||||||
console.log(`✅ 코드 로딩 완료: ${category} (${response.data.length}개)`);
|
// console.log(`✅ 코드 로딩 완료: ${category} (${response.data.length}개)`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`❌ 코드 로딩 실패: ${category}`, error);
|
console.error(`❌ 코드 로딩 실패: ${category}`, error);
|
||||||
|
|
@ -109,7 +109,7 @@ class CodeCache {
|
||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
console.log(`✅ 코드 배치 로딩 완료: ${categories.length}개 카테고리`);
|
// console.log(`✅ 코드 배치 로딩 완료: ${categories.length}개 카테고리`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ export function useEntityJoinOptimization(columnMeta: Record<string, ColumnMetaI
|
||||||
batches.push(categories.slice(i, i + maxBatchSize));
|
batches.push(categories.slice(i, i + maxBatchSize));
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`🔄 배치 코드 로딩 시작: ${categories.length}개 카테고리 (${batches.length}개 배치)`);
|
// console.log(`🔄 배치 코드 로딩 시작: ${categories.length}개 카테고리 (${batches.length}개 배치)`);
|
||||||
|
|
||||||
for (const batch of batches) {
|
for (const batch of batches) {
|
||||||
// 로딩 상태 업데이트
|
// 로딩 상태 업데이트
|
||||||
|
|
@ -125,7 +125,7 @@ export function useEntityJoinOptimization(columnMeta: Record<string, ColumnMetaI
|
||||||
const responseTime = Date.now() - startTime;
|
const responseTime = Date.now() - startTime;
|
||||||
requestTimes.current.push(responseTime);
|
requestTimes.current.push(responseTime);
|
||||||
|
|
||||||
console.log(`✅ 배치 코드 로딩 완료: ${responseTime}ms`);
|
// console.log(`✅ 배치 코드 로딩 완료: ${responseTime}ms`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 배치 코드 로딩 실패:", error);
|
console.error("❌ 배치 코드 로딩 실패:", error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -72,5 +72,5 @@ ComponentRegistry.registerComponent({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("✅ 카테고리 관리 컴포넌트 등록 완료");
|
// console.log("✅ 카테고리 관리 컴포넌트 등록 완료");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,5 +6,5 @@ import { CustomerItemMappingDefinition } from "./index";
|
||||||
// 컴포넌트 자동 등록
|
// 컴포넌트 자동 등록
|
||||||
ComponentRegistry.registerComponent(CustomerItemMappingDefinition);
|
ComponentRegistry.registerComponent(CustomerItemMappingDefinition);
|
||||||
|
|
||||||
console.log("✅ CustomerItemMapping 컴포넌트 등록 완료");
|
// console.log("✅ CustomerItemMapping 컴포넌트 등록 완료");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,8 @@ import "./customer-item-mapping/CustomerItemMappingRenderer"; // 🆕 거래처
|
||||||
import "./autocomplete-search-input/AutocompleteSearchInputRenderer";
|
import "./autocomplete-search-input/AutocompleteSearchInputRenderer";
|
||||||
import "./entity-search-input/EntitySearchInputRenderer";
|
import "./entity-search-input/EntitySearchInputRenderer";
|
||||||
import "./modal-repeater-table/ModalRepeaterTableRenderer";
|
import "./modal-repeater-table/ModalRepeaterTableRenderer";
|
||||||
|
import "./simple-repeater-table/SimpleRepeaterTableRenderer"; // 🆕 단순 반복 테이블
|
||||||
|
import "./repeat-screen-modal/RepeatScreenModalRenderer"; // 🆕 반복 화면 모달 (카드 형태)
|
||||||
import "./order-registration-modal/OrderRegistrationModalRenderer";
|
import "./order-registration-modal/OrderRegistrationModalRenderer";
|
||||||
|
|
||||||
// 🆕 조건부 컨테이너 컴포넌트
|
// 🆕 조건부 컨테이너 컴포넌트
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,206 @@
|
||||||
|
# RepeatScreenModal 컴포넌트 v3
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
`RepeatScreenModal`은 선택한 데이터를 기반으로 여러 개의 카드를 생성하고, 각 카드의 내부 레이아웃을 자유롭게 구성할 수 있는 컴포넌트입니다.
|
||||||
|
|
||||||
|
## v3 주요 변경사항
|
||||||
|
|
||||||
|
### 자유 레이아웃 시스템
|
||||||
|
|
||||||
|
기존의 "simple 모드 / withTable 모드" 구분을 없애고, **행(Row)을 추가하고 각 행마다 타입을 선택**하는 방식으로 변경되었습니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 카드 │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ [행 1] 타입: 헤더 → 품목코드, 품목명, 규격 │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ [행 2] 타입: 집계 → 총수주잔량, 현재고, 가용재고 │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ [행 3] 타입: 테이블 → 수주번호, 거래처, 납기일, 출하계획 │
|
||||||
|
├─────────────────────────────────────────────────────────────────┤
|
||||||
|
│ [행 4] 타입: 테이블 → 또 다른 테이블도 가능! │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 행 타입
|
||||||
|
|
||||||
|
| 타입 | 설명 | 사용 시나리오 |
|
||||||
|
|------|------|---------------|
|
||||||
|
| **헤더 (header)** | 필드들을 가로/세로로 나열 | 품목정보, 거래처정보 표시 |
|
||||||
|
| **필드 (fields)** | 헤더와 동일, 편집 가능 | 폼 입력 영역 |
|
||||||
|
| **집계 (aggregation)** | 그룹 내 데이터 집계값 표시 | 총수량, 합계금액 등 |
|
||||||
|
| **테이블 (table)** | 그룹 내 각 행을 테이블로 표시 | 수주목록, 품목목록 등 |
|
||||||
|
|
||||||
|
### 자유로운 조합
|
||||||
|
|
||||||
|
```
|
||||||
|
예시 1: 헤더 + 집계 + 테이블 (출하계획)
|
||||||
|
├── [행 1] 헤더: 품목코드, 품목명
|
||||||
|
├── [행 2] 집계: 총수주잔량, 현재고
|
||||||
|
└── [행 3] 테이블: 수주별 출하계획
|
||||||
|
|
||||||
|
예시 2: 집계만
|
||||||
|
└── [행 1] 집계: 총매출, 총비용, 순이익
|
||||||
|
|
||||||
|
예시 3: 테이블만
|
||||||
|
└── [행 1] 테이블: 품목 목록
|
||||||
|
|
||||||
|
예시 4: 테이블 2개
|
||||||
|
├── [행 1] 테이블: 입고 내역
|
||||||
|
└── [행 2] 테이블: 출고 내역
|
||||||
|
|
||||||
|
예시 5: 헤더 + 헤더 + 필드
|
||||||
|
├── [행 1] 헤더: 기본 정보 (읽기전용)
|
||||||
|
├── [행 2] 헤더: 상세 정보 (읽기전용)
|
||||||
|
└── [행 3] 필드: 입력 필드 (편집가능)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 설정 방법
|
||||||
|
|
||||||
|
### 1. 기본 설정 탭
|
||||||
|
|
||||||
|
- **카드 제목 표시**: 카드 상단에 제목을 표시할지 여부
|
||||||
|
- **카드 제목 템플릿**: `{field_name}` 형식으로 동적 제목 생성
|
||||||
|
- **카드 간격**: 카드 사이의 간격 (8px ~ 32px)
|
||||||
|
- **테두리**: 카드 테두리 표시 여부
|
||||||
|
- **저장 모드**: 전체 저장 / 개별 저장
|
||||||
|
|
||||||
|
### 2. 데이터 소스 탭
|
||||||
|
|
||||||
|
- **소스 테이블**: 데이터를 조회할 테이블
|
||||||
|
- **필터 필드**: formData에서 필터링할 필드 (예: selectedIds)
|
||||||
|
|
||||||
|
### 3. 그룹 탭
|
||||||
|
|
||||||
|
- **그룹핑 활성화**: 여러 행을 하나의 카드로 묶을지 여부
|
||||||
|
- **그룹 기준 필드**: 그룹핑할 필드 (예: part_code)
|
||||||
|
- **집계 설정**:
|
||||||
|
- 원본 필드: 합계할 필드 (예: balance_qty)
|
||||||
|
- 집계 타입: sum, count, avg, min, max
|
||||||
|
- 결과 필드명: 집계 결과를 저장할 필드명
|
||||||
|
- 라벨: 표시될 라벨
|
||||||
|
|
||||||
|
### 4. 레이아웃 탭
|
||||||
|
|
||||||
|
#### 행 추가
|
||||||
|
|
||||||
|
4가지 타입의 행을 추가할 수 있습니다:
|
||||||
|
- **헤더**: 필드 정보 표시 (읽기전용)
|
||||||
|
- **집계**: 그룹 집계값 표시
|
||||||
|
- **테이블**: 그룹 내 행들을 테이블로 표시
|
||||||
|
- **필드**: 입력 필드 (편집가능)
|
||||||
|
|
||||||
|
#### 헤더/필드 행 설정
|
||||||
|
|
||||||
|
- **방향**: 가로 / 세로
|
||||||
|
- **배경색**: 없음, 파랑, 초록, 보라, 주황
|
||||||
|
- **컬럼**: 필드명, 라벨, 타입, 너비, 편집 가능, 필수
|
||||||
|
- **소스 설정**: 직접 / 조인 / 수동
|
||||||
|
- **저장 설정**: 저장할 테이블과 컬럼
|
||||||
|
|
||||||
|
#### 집계 행 설정
|
||||||
|
|
||||||
|
- **레이아웃**: 가로 나열 / 그리드
|
||||||
|
- **그리드 컬럼 수**: 2, 3, 4개
|
||||||
|
- **집계 필드**: 그룹 탭에서 정의한 집계 결과 선택
|
||||||
|
- **스타일**: 배경색, 폰트 크기
|
||||||
|
|
||||||
|
#### 테이블 행 설정
|
||||||
|
|
||||||
|
- **테이블 제목**: 선택사항
|
||||||
|
- **헤더 표시**: 테이블 헤더 표시 여부
|
||||||
|
- **테이블 컬럼**: 필드명, 라벨, 타입, 너비, 편집 가능
|
||||||
|
- **저장 설정**: 편집 가능한 컬럼의 저장 위치
|
||||||
|
|
||||||
|
## 데이터 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
1. formData에서 selectedIds 가져오기
|
||||||
|
↓
|
||||||
|
2. 소스 테이블에서 해당 ID들의 데이터 조회
|
||||||
|
↓
|
||||||
|
3. 그룹핑 활성화 시 groupByField 기준으로 그룹화
|
||||||
|
↓
|
||||||
|
4. 각 그룹에 대해 집계값 계산
|
||||||
|
↓
|
||||||
|
5. 카드 렌더링 (contentRows 기반)
|
||||||
|
↓
|
||||||
|
6. 사용자 편집
|
||||||
|
↓
|
||||||
|
7. 저장 시 targetConfig에 따라 테이블별로 데이터 분류 후 저장
|
||||||
|
```
|
||||||
|
|
||||||
|
## 사용 예시
|
||||||
|
|
||||||
|
### 출하계획 등록
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
showCardTitle: true,
|
||||||
|
cardTitle: "{part_code} - {part_name}",
|
||||||
|
dataSource: {
|
||||||
|
sourceTable: "sales_order_mng",
|
||||||
|
filterField: "selectedIds"
|
||||||
|
},
|
||||||
|
grouping: {
|
||||||
|
enabled: true,
|
||||||
|
groupByField: "part_code",
|
||||||
|
aggregations: [
|
||||||
|
{ sourceField: "balance_qty", type: "sum", resultField: "total_balance", label: "총수주잔량" },
|
||||||
|
{ sourceField: "id", type: "count", resultField: "order_count", label: "수주건수" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
contentRows: [
|
||||||
|
{
|
||||||
|
id: "row-1",
|
||||||
|
type: "header",
|
||||||
|
columns: [
|
||||||
|
{ id: "c1", field: "part_code", label: "품목코드", type: "text", editable: false },
|
||||||
|
{ id: "c2", field: "part_name", label: "품목명", type: "text", editable: false }
|
||||||
|
],
|
||||||
|
layout: "horizontal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "row-2",
|
||||||
|
type: "aggregation",
|
||||||
|
aggregationLayout: "horizontal",
|
||||||
|
aggregationFields: [
|
||||||
|
{ aggregationResultField: "total_balance", label: "총수주잔량", backgroundColor: "blue" },
|
||||||
|
{ aggregationResultField: "order_count", label: "수주건수", backgroundColor: "green" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "row-3",
|
||||||
|
type: "table",
|
||||||
|
tableTitle: "수주 목록",
|
||||||
|
showTableHeader: true,
|
||||||
|
tableColumns: [
|
||||||
|
{ id: "tc1", field: "order_no", label: "수주번호", type: "text", editable: false },
|
||||||
|
{ id: "tc2", field: "partner_name", label: "거래처", type: "text", editable: false },
|
||||||
|
{ id: "tc3", field: "balance_qty", label: "미출하", type: "number", editable: false },
|
||||||
|
{
|
||||||
|
id: "tc4",
|
||||||
|
field: "plan_qty",
|
||||||
|
label: "출하계획",
|
||||||
|
type: "number",
|
||||||
|
editable: true,
|
||||||
|
targetConfig: { targetTable: "shipment_plan", targetColumn: "plan_qty", saveEnabled: true }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 레거시 호환
|
||||||
|
|
||||||
|
v2에서 사용하던 `cardMode`, `cardLayout`, `tableLayout` 설정도 계속 지원됩니다.
|
||||||
|
새로운 프로젝트에서는 `contentRows`를 사용하는 것을 권장합니다.
|
||||||
|
|
||||||
|
## 주의사항
|
||||||
|
|
||||||
|
1. **집계는 그룹핑 필수**: 집계 행은 그룹핑이 활성화되어 있어야 의미가 있습니다.
|
||||||
|
2. **테이블은 그룹핑 필수**: 테이블 행도 그룹핑이 활성화되어 있어야 그룹 내 행들을 표시할 수 있습니다.
|
||||||
|
3. **단순 모드**: 그룹핑 없이 사용하면 1행 = 1카드로 동작합니다. 이 경우 헤더/필드 타입만 사용 가능합니다.
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,13 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
|
||||||
|
import { RepeatScreenModalDefinition } from "./index";
|
||||||
|
|
||||||
|
// 컴포넌트 자동 등록
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
ComponentRegistry.registerComponent(RepeatScreenModalDefinition);
|
||||||
|
console.log("✅ RepeatScreenModal 컴포넌트 등록 완료");
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
|
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||||
|
import { ComponentCategory } from "@/types/component";
|
||||||
|
import { RepeatScreenModalComponent } from "./RepeatScreenModalComponent";
|
||||||
|
import { RepeatScreenModalConfigPanel } from "./RepeatScreenModalConfigPanel";
|
||||||
|
import type {
|
||||||
|
RepeatScreenModalProps,
|
||||||
|
CardRowConfig,
|
||||||
|
CardColumnConfig,
|
||||||
|
ColumnSourceConfig,
|
||||||
|
ColumnTargetConfig,
|
||||||
|
DataSourceConfig,
|
||||||
|
CardData,
|
||||||
|
GroupingConfig,
|
||||||
|
AggregationConfig,
|
||||||
|
TableLayoutConfig,
|
||||||
|
TableColumnConfig,
|
||||||
|
GroupedCardData,
|
||||||
|
CardRowData,
|
||||||
|
CardContentRowConfig,
|
||||||
|
AggregationDisplayConfig,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RepeatScreenModal 컴포넌트 정의 v3
|
||||||
|
* 반복 화면 모달 - 선택한 행 개수만큼 카드를 생성하며, 각 카드는 커스터마이징 가능한 레이아웃
|
||||||
|
*
|
||||||
|
* 주요 기능:
|
||||||
|
* - 🆕 v3: 자유 레이아웃 - 행(Row)을 추가하고 각 행마다 타입(헤더/집계/테이블/필드) 선택
|
||||||
|
* - 그룹핑: 특정 필드 기준으로 여러 행을 하나의 카드로 묶기
|
||||||
|
* - 집계: 그룹 내 데이터의 합계/평균/개수 등 자동 계산
|
||||||
|
* - 카드 내 테이블: 그룹 내 각 행을 테이블 형태로 표시
|
||||||
|
* - 유연한 레이아웃: 행 타입 자유 선택, 순서 자유 배치
|
||||||
|
* - 컬럼별 소스 설정: 직접 조회/조인 조회/수동 입력
|
||||||
|
* - 컬럼별 타겟 설정: 어느 테이블의 어느 컬럼에 저장할지 설정
|
||||||
|
* - 다중 테이블 저장: 하나의 카드에서 여러 테이블 동시 저장
|
||||||
|
*
|
||||||
|
* 사용 시나리오:
|
||||||
|
* - 출하계획 동시 등록 (품목별 그룹핑 + 수주별 테이블)
|
||||||
|
* - 구매발주 일괄 등록 (공급업체별 그룹핑 + 품목별 테이블)
|
||||||
|
* - 생산계획 일괄 등록 (제품별 그룹핑 + 작업지시별 테이블)
|
||||||
|
* - 입고검사 일괄 처리 (발주번호별 그룹핑 + 품목별 검사결과)
|
||||||
|
*/
|
||||||
|
export const RepeatScreenModalDefinition = createComponentDefinition({
|
||||||
|
id: "repeat-screen-modal",
|
||||||
|
name: "반복 화면 모달",
|
||||||
|
nameEng: "Repeat Screen Modal",
|
||||||
|
description:
|
||||||
|
"선택한 행을 그룹핑하여 카드로 표시하고, 각 카드는 헤더/집계/테이블을 자유롭게 구성 가능한 폼 (출하계획, 구매발주 등)",
|
||||||
|
category: ComponentCategory.DATA,
|
||||||
|
webType: "form",
|
||||||
|
component: RepeatScreenModalComponent,
|
||||||
|
defaultConfig: {
|
||||||
|
// 기본 설정
|
||||||
|
showCardTitle: true,
|
||||||
|
cardTitle: "카드 {index}",
|
||||||
|
cardSpacing: "24px",
|
||||||
|
showCardBorder: true,
|
||||||
|
saveMode: "all",
|
||||||
|
|
||||||
|
// 데이터 소스
|
||||||
|
dataSource: {
|
||||||
|
sourceTable: "",
|
||||||
|
filterField: "selectedIds",
|
||||||
|
},
|
||||||
|
|
||||||
|
// 그룹핑 설정
|
||||||
|
grouping: {
|
||||||
|
enabled: false,
|
||||||
|
groupByField: "",
|
||||||
|
aggregations: [],
|
||||||
|
},
|
||||||
|
|
||||||
|
// 🆕 v3: 자유 레이아웃 (행 추가 후 타입 선택)
|
||||||
|
contentRows: [],
|
||||||
|
|
||||||
|
// (레거시 호환)
|
||||||
|
cardMode: "simple",
|
||||||
|
cardLayout: [],
|
||||||
|
tableLayout: {
|
||||||
|
headerRows: [],
|
||||||
|
tableColumns: [],
|
||||||
|
},
|
||||||
|
} as Partial<RepeatScreenModalProps>,
|
||||||
|
defaultSize: { width: 1000, height: 800 },
|
||||||
|
configPanel: RepeatScreenModalConfigPanel,
|
||||||
|
icon: "LayoutGrid",
|
||||||
|
tags: ["모달", "폼", "반복", "카드", "그룹핑", "집계", "테이블", "편집", "데이터", "출하계획", "일괄등록", "자유레이아웃"],
|
||||||
|
version: "3.0.0",
|
||||||
|
author: "개발팀",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 타입 재 export
|
||||||
|
export type {
|
||||||
|
RepeatScreenModalProps,
|
||||||
|
CardRowConfig,
|
||||||
|
CardColumnConfig,
|
||||||
|
ColumnSourceConfig,
|
||||||
|
ColumnTargetConfig,
|
||||||
|
DataSourceConfig,
|
||||||
|
CardData,
|
||||||
|
GroupingConfig,
|
||||||
|
AggregationConfig,
|
||||||
|
TableLayoutConfig,
|
||||||
|
TableColumnConfig,
|
||||||
|
GroupedCardData,
|
||||||
|
CardRowData,
|
||||||
|
CardContentRowConfig,
|
||||||
|
AggregationDisplayConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 컴포넌트 재 export
|
||||||
|
export { RepeatScreenModalComponent, RepeatScreenModalConfigPanel };
|
||||||
|
|
@ -0,0 +1,256 @@
|
||||||
|
import { ComponentRendererProps } from "@/types/component";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RepeatScreenModal Props
|
||||||
|
* 선택한 행 개수만큼 카드를 생성하며, 각 카드는 커스터마이징 가능한 레이아웃을 가짐
|
||||||
|
*
|
||||||
|
* 🆕 v3: 행(Row) 기반 자유 레이아웃 - 각 행마다 타입(헤더/집계/테이블) 선택 가능
|
||||||
|
*/
|
||||||
|
export interface RepeatScreenModalProps {
|
||||||
|
// === 기본 설정 ===
|
||||||
|
showCardTitle?: boolean; // 카드 제목 표시 여부
|
||||||
|
cardTitle?: string; // 카드 제목 템플릿 (예: "{order_no} - {item_code}")
|
||||||
|
cardSpacing?: string; // 카드 간 간격 (기본: 24px)
|
||||||
|
showCardBorder?: boolean; // 카드 테두리 표시 여부
|
||||||
|
saveMode?: "all" | "individual"; // 저장 모드
|
||||||
|
|
||||||
|
// === 데이터 소스 ===
|
||||||
|
dataSource?: DataSourceConfig; // 데이터 소스 설정
|
||||||
|
|
||||||
|
// === 그룹핑 설정 ===
|
||||||
|
grouping?: GroupingConfig; // 그룹핑 설정
|
||||||
|
|
||||||
|
// === 🆕 v3: 자유 레이아웃 ===
|
||||||
|
contentRows?: CardContentRowConfig[]; // 카드 내부 행들 (각 행마다 타입 선택)
|
||||||
|
|
||||||
|
// === (레거시 호환) ===
|
||||||
|
cardMode?: "simple" | "withTable"; // @deprecated - contentRows 사용 권장
|
||||||
|
cardLayout?: CardRowConfig[]; // @deprecated - contentRows 사용 권장
|
||||||
|
tableLayout?: TableLayoutConfig; // @deprecated - contentRows 사용 권장
|
||||||
|
|
||||||
|
// === 값 ===
|
||||||
|
value?: any[];
|
||||||
|
onChange?: (newData: any[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 소스 설정
|
||||||
|
*/
|
||||||
|
export interface DataSourceConfig {
|
||||||
|
sourceTable: string; // 조회할 테이블 (예: "sales_order_mng")
|
||||||
|
filterField?: string; // formData에서 필터링할 필드 (예: "selectedIds")
|
||||||
|
selectColumns?: string[]; // 선택할 컬럼 목록
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그룹핑 설정
|
||||||
|
* 특정 필드 기준으로 여러 행을 하나의 카드로 묶음
|
||||||
|
*/
|
||||||
|
export interface GroupingConfig {
|
||||||
|
enabled: boolean; // 그룹핑 활성화 여부
|
||||||
|
groupByField: string; // 그룹 기준 필드 (예: "part_code")
|
||||||
|
|
||||||
|
// 집계 설정 (그룹별 합계, 개수 등)
|
||||||
|
aggregations?: AggregationConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v3: 카드 내부 행 설정
|
||||||
|
* 각 행마다 타입(헤더/집계/테이블)을 선택할 수 있음
|
||||||
|
*/
|
||||||
|
export interface CardContentRowConfig {
|
||||||
|
id: string; // 행 고유 ID
|
||||||
|
type: "header" | "aggregation" | "table" | "fields"; // 행 타입
|
||||||
|
|
||||||
|
// === header/fields 타입일 때 ===
|
||||||
|
columns?: CardColumnConfig[]; // 컬럼 설정
|
||||||
|
layout?: "horizontal" | "vertical"; // 레이아웃 방향
|
||||||
|
gap?: string; // 컬럼 간 간격
|
||||||
|
backgroundColor?: string; // 배경색
|
||||||
|
padding?: string; // 패딩
|
||||||
|
|
||||||
|
// === aggregation 타입일 때 ===
|
||||||
|
aggregationFields?: AggregationDisplayConfig[]; // 표시할 집계 필드들
|
||||||
|
aggregationLayout?: "horizontal" | "grid"; // 집계 레이아웃 (가로 나열 / 그리드)
|
||||||
|
aggregationColumns?: number; // grid일 때 컬럼 수 (기본: 4)
|
||||||
|
|
||||||
|
// === table 타입일 때 ===
|
||||||
|
tableColumns?: TableColumnConfig[]; // 테이블 컬럼 설정
|
||||||
|
tableTitle?: string; // 테이블 제목
|
||||||
|
showTableHeader?: boolean; // 테이블 헤더 표시 여부
|
||||||
|
tableMaxHeight?: string; // 테이블 최대 높이
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 v3: 집계 표시 설정
|
||||||
|
*/
|
||||||
|
export interface AggregationDisplayConfig {
|
||||||
|
aggregationResultField: string; // 그룹핑 설정의 resultField 참조
|
||||||
|
label: string; // 표시 라벨
|
||||||
|
icon?: string; // 아이콘 (lucide 아이콘명)
|
||||||
|
backgroundColor?: string; // 배경색
|
||||||
|
textColor?: string; // 텍스트 색상
|
||||||
|
fontSize?: "xs" | "sm" | "base" | "lg" | "xl" | "2xl"; // 폰트 크기
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 집계 설정
|
||||||
|
*/
|
||||||
|
export interface AggregationConfig {
|
||||||
|
sourceField: string; // 원본 필드 (예: "balance_qty")
|
||||||
|
type: "sum" | "count" | "avg" | "min" | "max"; // 집계 타입
|
||||||
|
resultField: string; // 결과 필드명 (예: "total_balance_qty")
|
||||||
|
label: string; // 표시 라벨 (예: "총수주잔량")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated v3에서는 contentRows 사용 권장
|
||||||
|
* 테이블 포함 레이아웃 설정
|
||||||
|
*/
|
||||||
|
export interface TableLayoutConfig {
|
||||||
|
headerRows: CardRowConfig[];
|
||||||
|
tableColumns: TableColumnConfig[];
|
||||||
|
tableTitle?: string;
|
||||||
|
showTableHeader?: boolean;
|
||||||
|
tableMaxHeight?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 컬럼 설정
|
||||||
|
*/
|
||||||
|
export interface TableColumnConfig {
|
||||||
|
id: string; // 컬럼 고유 ID
|
||||||
|
field: string; // 필드명
|
||||||
|
label: string; // 헤더 라벨
|
||||||
|
type: "text" | "number" | "date" | "select" | "badge"; // 타입
|
||||||
|
width?: string; // 너비 (예: "100px", "20%")
|
||||||
|
align?: "left" | "center" | "right"; // 정렬
|
||||||
|
editable: boolean; // 편집 가능 여부
|
||||||
|
required?: boolean; // 필수 입력 여부
|
||||||
|
|
||||||
|
// Select 타입 옵션
|
||||||
|
selectOptions?: { value: string; label: string }[];
|
||||||
|
|
||||||
|
// Badge 타입 설정
|
||||||
|
badgeVariant?: "default" | "secondary" | "destructive" | "outline";
|
||||||
|
badgeColorMap?: Record<string, string>; // 값별 색상 매핑
|
||||||
|
|
||||||
|
// 데이터 소스 설정
|
||||||
|
sourceConfig?: ColumnSourceConfig;
|
||||||
|
|
||||||
|
// 데이터 타겟 설정
|
||||||
|
targetConfig?: ColumnTargetConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카드 행 설정
|
||||||
|
* 카드는 여러 행(Row)으로 구성되며, 각 행은 여러 컬럼을 가짐
|
||||||
|
*/
|
||||||
|
export interface CardRowConfig {
|
||||||
|
id: string; // 행 고유 ID
|
||||||
|
columns: CardColumnConfig[]; // 이 행에 배치할 컬럼들
|
||||||
|
gap?: string; // 컬럼 간 간격 (기본: 16px)
|
||||||
|
layout?: "horizontal" | "vertical"; // 레이아웃 방향 (기본: horizontal)
|
||||||
|
|
||||||
|
// 🆕 행 스타일 설정
|
||||||
|
backgroundColor?: string; // 배경색 (예: "blue", "green")
|
||||||
|
padding?: string; // 패딩
|
||||||
|
rounded?: boolean; // 둥근 모서리
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카드 컬럼 설정
|
||||||
|
*/
|
||||||
|
export interface CardColumnConfig {
|
||||||
|
id: string; // 컬럼 고유 ID
|
||||||
|
field: string; // 필드명 (데이터 바인딩)
|
||||||
|
label: string; // 라벨
|
||||||
|
type: "text" | "number" | "date" | "select" | "textarea" | "component" | "aggregation"; // 🆕 aggregation 추가
|
||||||
|
width?: string; // 너비 (예: "50%", "200px", "1fr")
|
||||||
|
editable: boolean; // 편집 가능 여부
|
||||||
|
required?: boolean; // 필수 입력 여부
|
||||||
|
placeholder?: string; // 플레이스홀더
|
||||||
|
|
||||||
|
// Select 타입 옵션
|
||||||
|
selectOptions?: { value: string; label: string }[];
|
||||||
|
|
||||||
|
// 데이터 소스 설정 (어디서 조회?)
|
||||||
|
sourceConfig?: ColumnSourceConfig;
|
||||||
|
|
||||||
|
// 데이터 타겟 설정 (어디에 저장?)
|
||||||
|
targetConfig?: ColumnTargetConfig;
|
||||||
|
|
||||||
|
// Component 타입일 때
|
||||||
|
componentType?: string; // 삽입할 컴포넌트 타입 (예: "simple-repeater-table")
|
||||||
|
componentConfig?: any; // 컴포넌트 설정
|
||||||
|
|
||||||
|
// 🆕 Aggregation 타입일 때 (집계값 표시)
|
||||||
|
aggregationField?: string; // 표시할 집계 필드명 (GroupingConfig.aggregations의 resultField)
|
||||||
|
|
||||||
|
// 🆕 스타일 설정
|
||||||
|
textColor?: string; // 텍스트 색상
|
||||||
|
fontSize?: "xs" | "sm" | "base" | "lg" | "xl" | "2xl"; // 폰트 크기
|
||||||
|
fontWeight?: "normal" | "medium" | "semibold" | "bold"; // 폰트 굵기
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컬럼 데이터 소스 설정 (SimpleRepeaterTable과 동일)
|
||||||
|
*/
|
||||||
|
export interface ColumnSourceConfig {
|
||||||
|
type: "direct" | "join" | "manual"; // 조회 타입
|
||||||
|
sourceTable?: string; // type: "direct" - 조회할 테이블
|
||||||
|
sourceColumn?: string; // type: "direct" - 조회할 컬럼
|
||||||
|
joinTable?: string; // type: "join" - 조인할 테이블
|
||||||
|
joinColumn?: string; // type: "join" - 조인 테이블에서 가져올 컬럼
|
||||||
|
joinKey?: string; // type: "join" - 현재 데이터의 조인 키 컬럼
|
||||||
|
joinRefKey?: string; // type: "join" - 조인 테이블의 참조 키 컬럼
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컬럼 데이터 타겟 설정 (SimpleRepeaterTable과 동일)
|
||||||
|
*/
|
||||||
|
export interface ColumnTargetConfig {
|
||||||
|
targetTable: string; // 저장할 테이블
|
||||||
|
targetColumn: string; // 저장할 컬럼
|
||||||
|
saveEnabled?: boolean; // 저장 활성화 여부 (기본 true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카드 데이터 (각 카드의 상태)
|
||||||
|
*/
|
||||||
|
export interface CardData {
|
||||||
|
_cardId: string; // 카드 고유 ID
|
||||||
|
_originalData: Record<string, any>; // 원본 데이터 (조회된 데이터)
|
||||||
|
_isDirty: boolean; // 수정 여부
|
||||||
|
[key: string]: any; // 실제 필드 데이터
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 그룹화된 카드 데이터
|
||||||
|
*/
|
||||||
|
export interface GroupedCardData {
|
||||||
|
_cardId: string; // 카드 고유 ID
|
||||||
|
_groupKey: string; // 그룹 키 값 (예: "PROD-001")
|
||||||
|
_groupField: string; // 그룹 기준 필드명 (예: "part_code")
|
||||||
|
_aggregations: Record<string, number>; // 집계 결과 (예: { total_balance_qty: 100 })
|
||||||
|
_rows: CardRowData[]; // 그룹 내 각 행 데이터
|
||||||
|
_representativeData: Record<string, any>; // 그룹 대표 데이터 (첫 번째 행 기준)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 그룹 내 행 데이터
|
||||||
|
*/
|
||||||
|
export interface CardRowData {
|
||||||
|
_rowId: string; // 행 고유 ID
|
||||||
|
_originalData: Record<string, any>; // 원본 데이터
|
||||||
|
_isDirty: boolean; // 수정 여부
|
||||||
|
[key: string]: any; // 실제 필드 데이터
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 정보 (API 응답용)
|
||||||
|
*/
|
||||||
|
export interface TableInfo {
|
||||||
|
tableName: string;
|
||||||
|
displayName?: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,535 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Trash2, Loader2, X } from "lucide-react";
|
||||||
|
import { SimpleRepeaterTableProps, SimpleRepeaterColumnConfig } from "./types";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ComponentRendererProps } from "@/types/component";
|
||||||
|
import { useCalculation } from "./useCalculation";
|
||||||
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
||||||
|
export interface SimpleRepeaterTableComponentProps extends ComponentRendererProps {
|
||||||
|
config?: SimpleRepeaterTableProps;
|
||||||
|
// SimpleRepeaterTableProps의 개별 prop들도 지원 (호환성)
|
||||||
|
value?: any[];
|
||||||
|
onChange?: (newData: any[]) => void;
|
||||||
|
columns?: SimpleRepeaterColumnConfig[];
|
||||||
|
calculationRules?: any[];
|
||||||
|
readOnly?: boolean;
|
||||||
|
showRowNumber?: boolean;
|
||||||
|
allowDelete?: boolean;
|
||||||
|
maxHeight?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SimpleRepeaterTableComponent({
|
||||||
|
// ComponentRendererProps (자동 전달)
|
||||||
|
component,
|
||||||
|
isDesignMode = false,
|
||||||
|
isSelected = false,
|
||||||
|
isInteractive = false,
|
||||||
|
onClick,
|
||||||
|
className,
|
||||||
|
formData,
|
||||||
|
onFormDataChange,
|
||||||
|
|
||||||
|
// SimpleRepeaterTable 전용 props
|
||||||
|
config,
|
||||||
|
value: propValue,
|
||||||
|
onChange: propOnChange,
|
||||||
|
columns: propColumns,
|
||||||
|
calculationRules: propCalculationRules,
|
||||||
|
readOnly: propReadOnly,
|
||||||
|
showRowNumber: propShowRowNumber,
|
||||||
|
allowDelete: propAllowDelete,
|
||||||
|
maxHeight: propMaxHeight,
|
||||||
|
|
||||||
|
...props
|
||||||
|
}: SimpleRepeaterTableComponentProps) {
|
||||||
|
// config 또는 component.config 또는 개별 prop 우선순위로 병합
|
||||||
|
const componentConfig = {
|
||||||
|
...config,
|
||||||
|
...component?.config,
|
||||||
|
};
|
||||||
|
|
||||||
|
// config prop 우선, 없으면 개별 prop 사용
|
||||||
|
const columns = componentConfig?.columns || propColumns || [];
|
||||||
|
const calculationRules = componentConfig?.calculationRules || propCalculationRules || [];
|
||||||
|
const readOnly = componentConfig?.readOnly ?? propReadOnly ?? false;
|
||||||
|
const showRowNumber = componentConfig?.showRowNumber ?? propShowRowNumber ?? true;
|
||||||
|
const allowDelete = componentConfig?.allowDelete ?? propAllowDelete ?? true;
|
||||||
|
const maxHeight = componentConfig?.maxHeight || propMaxHeight || "240px";
|
||||||
|
|
||||||
|
// value는 formData[columnName] 우선, 없으면 prop 사용
|
||||||
|
const columnName = component?.columnName;
|
||||||
|
const value = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
|
||||||
|
|
||||||
|
// 🆕 로딩 상태
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// onChange 래퍼 (기존 onChange 콜백 + onFormDataChange 호출)
|
||||||
|
const handleChange = (newData: any[]) => {
|
||||||
|
// 기존 onChange 콜백 호출 (호환성)
|
||||||
|
const externalOnChange = componentConfig?.onChange || propOnChange;
|
||||||
|
if (externalOnChange) {
|
||||||
|
externalOnChange(newData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// onFormDataChange 호출하여 EditModal의 groupData 업데이트
|
||||||
|
if (onFormDataChange && columnName) {
|
||||||
|
onFormDataChange(columnName, newData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 계산 hook
|
||||||
|
const { calculateRow, calculateAll } = useCalculation(calculationRules);
|
||||||
|
|
||||||
|
// 🆕 초기 데이터 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadInitialData = async () => {
|
||||||
|
const initialConfig = componentConfig?.initialDataConfig;
|
||||||
|
if (!initialConfig || !initialConfig.sourceTable) {
|
||||||
|
return; // 초기 데이터 설정이 없으면 로드하지 않음
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setLoadError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 필터 조건 생성
|
||||||
|
const filters: Record<string, any> = {};
|
||||||
|
|
||||||
|
if (initialConfig.filterConditions) {
|
||||||
|
for (const condition of initialConfig.filterConditions) {
|
||||||
|
let filterValue = condition.value;
|
||||||
|
|
||||||
|
// formData에서 값 가져오기
|
||||||
|
if (condition.valueFromField && formData) {
|
||||||
|
filterValue = formData[condition.valueFromField];
|
||||||
|
}
|
||||||
|
|
||||||
|
filters[condition.field] = filterValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// API 호출
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/table-management/tables/${initialConfig.sourceTable}/data`,
|
||||||
|
{
|
||||||
|
search: filters,
|
||||||
|
page: 1,
|
||||||
|
size: 1000, // 대량 조회
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data.success && response.data.data?.data) {
|
||||||
|
const loadedData = response.data.data.data;
|
||||||
|
|
||||||
|
// 1. 기본 데이터 매핑 (Direct & Manual)
|
||||||
|
const baseMappedData = loadedData.map((row: any) => {
|
||||||
|
const mappedRow: any = { ...row }; // 원본 데이터 유지 (조인 키 참조용)
|
||||||
|
|
||||||
|
for (const col of columns) {
|
||||||
|
if (col.sourceConfig) {
|
||||||
|
if (col.sourceConfig.type === "direct" && col.sourceConfig.sourceColumn) {
|
||||||
|
mappedRow[col.field] = row[col.sourceConfig.sourceColumn];
|
||||||
|
} else if (col.sourceConfig.type === "manual") {
|
||||||
|
mappedRow[col.field] = col.defaultValue;
|
||||||
|
}
|
||||||
|
// Join은 2단계에서 처리
|
||||||
|
} else {
|
||||||
|
mappedRow[col.field] = row[col.field] ?? col.defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mappedRow;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 조인 데이터 처리
|
||||||
|
const joinColumns = columns.filter(
|
||||||
|
(col) => col.sourceConfig?.type === "join" && col.sourceConfig.joinTable && col.sourceConfig.joinKey
|
||||||
|
);
|
||||||
|
|
||||||
|
if (joinColumns.length > 0) {
|
||||||
|
// 조인 테이블별로 그룹화
|
||||||
|
const joinGroups = new Map<string, { key: string; refKey: string; cols: typeof columns }>();
|
||||||
|
|
||||||
|
joinColumns.forEach((col) => {
|
||||||
|
const table = col.sourceConfig!.joinTable!;
|
||||||
|
const key = col.sourceConfig!.joinKey!;
|
||||||
|
// refKey가 없으면 key와 동일하다고 가정 (하위 호환성)
|
||||||
|
const refKey = col.sourceConfig!.joinRefKey || key;
|
||||||
|
const groupKey = `${table}:${key}:${refKey}`;
|
||||||
|
|
||||||
|
if (!joinGroups.has(groupKey)) {
|
||||||
|
joinGroups.set(groupKey, { key, refKey, cols: [] });
|
||||||
|
}
|
||||||
|
joinGroups.get(groupKey)!.cols.push(col);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 각 그룹별로 데이터 조회 및 병합
|
||||||
|
await Promise.all(
|
||||||
|
Array.from(joinGroups.entries()).map(async ([groupKey, { key, refKey, cols }]) => {
|
||||||
|
const [tableName] = groupKey.split(":");
|
||||||
|
|
||||||
|
// 조인 키 값 수집 (중복 제거)
|
||||||
|
const keyValues = Array.from(new Set(
|
||||||
|
baseMappedData
|
||||||
|
.map((row: any) => row[key])
|
||||||
|
.filter((v: any) => v !== undefined && v !== null)
|
||||||
|
));
|
||||||
|
|
||||||
|
if (keyValues.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 조인 테이블 조회
|
||||||
|
// refKey(타겟 테이블 컬럼)로 검색
|
||||||
|
const response = await apiClient.post(
|
||||||
|
`/table-management/tables/${tableName}/data`,
|
||||||
|
{
|
||||||
|
search: { [refKey]: keyValues }, // { id: [1, 2, 3] }
|
||||||
|
page: 1,
|
||||||
|
size: 1000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data.success && response.data.data?.data) {
|
||||||
|
const joinedRows = response.data.data.data;
|
||||||
|
// 조인 데이터 맵 생성 (refKey -> row)
|
||||||
|
const joinMap = new Map(joinedRows.map((r: any) => [r[refKey], r]));
|
||||||
|
|
||||||
|
// 데이터 병합
|
||||||
|
baseMappedData.forEach((row: any) => {
|
||||||
|
const keyValue = row[key];
|
||||||
|
const joinedRow = joinMap.get(keyValue);
|
||||||
|
|
||||||
|
if (joinedRow) {
|
||||||
|
cols.forEach((col) => {
|
||||||
|
if (col.sourceConfig?.joinColumn) {
|
||||||
|
row[col.field] = joinedRow[col.sourceConfig.joinColumn];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`조인 실패 (${tableName}):`, error);
|
||||||
|
// 실패 시 무시하고 진행 (값은 undefined)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappedData = baseMappedData;
|
||||||
|
|
||||||
|
// 계산 필드 적용
|
||||||
|
const calculatedData = calculateAll(mappedData);
|
||||||
|
handleChange(calculatedData);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("초기 데이터 로드 실패:", error);
|
||||||
|
setLoadError(error.message || "데이터를 불러올 수 없습니다");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadInitialData();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [componentConfig?.initialDataConfig]);
|
||||||
|
|
||||||
|
// 초기 데이터에 계산 필드 적용
|
||||||
|
useEffect(() => {
|
||||||
|
if (value.length > 0 && calculationRules.length > 0) {
|
||||||
|
const calculated = calculateAll(value);
|
||||||
|
// 값이 실제로 변경된 경우만 업데이트
|
||||||
|
if (JSON.stringify(calculated) !== JSON.stringify(value)) {
|
||||||
|
handleChange(calculated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 🆕 저장 요청 시 테이블별로 데이터 그룹화 (beforeFormSave 이벤트 리스너)
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSaveRequest = async (event: Event) => {
|
||||||
|
if (value.length === 0) {
|
||||||
|
console.warn("⚠️ [SimpleRepeaterTable] 저장할 데이터 없음");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🆕 테이블별로 데이터 그룹화
|
||||||
|
const dataByTable: Record<string, any[]> = {};
|
||||||
|
|
||||||
|
for (const row of value) {
|
||||||
|
// 각 행의 데이터를 테이블별로 분리
|
||||||
|
for (const col of columns) {
|
||||||
|
// 저장 설정이 있고 저장이 활성화된 경우에만
|
||||||
|
if (col.targetConfig && col.targetConfig.targetTable && col.targetConfig.saveEnabled !== false) {
|
||||||
|
const targetTable = col.targetConfig.targetTable;
|
||||||
|
const targetColumn = col.targetConfig.targetColumn || col.field;
|
||||||
|
|
||||||
|
// 테이블 그룹 초기화
|
||||||
|
if (!dataByTable[targetTable]) {
|
||||||
|
dataByTable[targetTable] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 해당 테이블의 데이터 찾기 또는 생성
|
||||||
|
let tableRow = dataByTable[targetTable].find((r: any) => r._rowIndex === row._rowIndex);
|
||||||
|
if (!tableRow) {
|
||||||
|
tableRow = { _rowIndex: row._rowIndex };
|
||||||
|
dataByTable[targetTable].push(tableRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컬럼 값 저장
|
||||||
|
tableRow[targetColumn] = row[col.field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// _rowIndex 제거
|
||||||
|
Object.keys(dataByTable).forEach((tableName) => {
|
||||||
|
dataByTable[tableName] = dataByTable[tableName].map((row: any) => {
|
||||||
|
const { _rowIndex, ...rest } = row;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("✅ [SimpleRepeaterTable] 테이블별 저장 데이터:", dataByTable);
|
||||||
|
|
||||||
|
// CustomEvent의 detail에 테이블별 데이터 추가
|
||||||
|
if (event instanceof CustomEvent && event.detail) {
|
||||||
|
// 각 테이블별로 데이터 전달
|
||||||
|
Object.entries(dataByTable).forEach(([tableName, rows]) => {
|
||||||
|
const key = `${columnName || component?.id}_${tableName}`;
|
||||||
|
event.detail.formData[key] = rows.map((row: any) => ({
|
||||||
|
...row,
|
||||||
|
_targetTable: tableName,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("✅ [SimpleRepeaterTable] 저장 데이터 준비:", {
|
||||||
|
tables: Object.keys(dataByTable),
|
||||||
|
totalRows: Object.values(dataByTable).reduce((sum, rows) => sum + rows.length, 0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 onFormDataChange도 호출 (호환성)
|
||||||
|
if (onFormDataChange && columnName) {
|
||||||
|
// 테이블별 데이터를 통합하여 전달
|
||||||
|
onFormDataChange(columnName, Object.entries(dataByTable).flatMap(([table, rows]) =>
|
||||||
|
rows.map((row: any) => ({ ...row, _targetTable: table }))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 저장 버튼 클릭 시 데이터 수집
|
||||||
|
window.addEventListener("beforeFormSave", handleSaveRequest as EventListener);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener);
|
||||||
|
};
|
||||||
|
}, [value, columns, columnName, component?.id, onFormDataChange]);
|
||||||
|
|
||||||
|
const handleCellEdit = (rowIndex: number, field: string, cellValue: any) => {
|
||||||
|
const newRow = { ...value[rowIndex], [field]: cellValue };
|
||||||
|
|
||||||
|
// 계산 필드 업데이트
|
||||||
|
const calculatedRow = calculateRow(newRow);
|
||||||
|
|
||||||
|
const newData = [...value];
|
||||||
|
newData[rowIndex] = calculatedRow;
|
||||||
|
handleChange(newData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRowDelete = (rowIndex: number) => {
|
||||||
|
const newData = value.filter((_, i) => i !== rowIndex);
|
||||||
|
handleChange(newData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderCell = (
|
||||||
|
row: any,
|
||||||
|
column: SimpleRepeaterColumnConfig,
|
||||||
|
rowIndex: number
|
||||||
|
) => {
|
||||||
|
const cellValue = row[column.field];
|
||||||
|
|
||||||
|
// 계산 필드는 편집 불가
|
||||||
|
if (column.calculated || !column.editable || readOnly) {
|
||||||
|
return (
|
||||||
|
<div className="px-2 py-1">
|
||||||
|
{column.type === "number"
|
||||||
|
? typeof cellValue === "number"
|
||||||
|
? cellValue.toLocaleString()
|
||||||
|
: cellValue || "0"
|
||||||
|
: cellValue || "-"}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 편집 가능한 필드
|
||||||
|
switch (column.type) {
|
||||||
|
case "number":
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={cellValue || ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0)
|
||||||
|
}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "date":
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={cellValue || ""}
|
||||||
|
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "select":
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={cellValue || ""}
|
||||||
|
onValueChange={(newValue) =>
|
||||||
|
handleCellEdit(rowIndex, column.field, newValue)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-7 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{column.selectOptions?.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
|
||||||
|
default: // text
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={cellValue || ""}
|
||||||
|
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
|
||||||
|
className="h-7 text-xs"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 로딩 중일 때
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className={cn("border rounded-md overflow-hidden bg-background", className)}>
|
||||||
|
<div className="flex items-center justify-center py-12" style={{ minHeight: maxHeight }}>
|
||||||
|
<div className="text-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-primary mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-muted-foreground">데이터를 불러오는 중...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 에러 발생 시
|
||||||
|
if (loadError) {
|
||||||
|
return (
|
||||||
|
<div className={cn("border rounded-md overflow-hidden bg-background", className)}>
|
||||||
|
<div className="flex items-center justify-center py-12" style={{ minHeight: maxHeight }}>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center mx-auto mb-2">
|
||||||
|
<X className="h-6 w-6 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-destructive mb-1">데이터 로드 실패</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{loadError}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("border rounded-md overflow-hidden bg-background", className)}>
|
||||||
|
<div
|
||||||
|
className="overflow-x-auto overflow-y-auto"
|
||||||
|
style={{ maxHeight }}
|
||||||
|
>
|
||||||
|
<table className="w-full text-xs sm:text-sm">
|
||||||
|
<thead className="bg-muted sticky top-0 z-10">
|
||||||
|
<tr>
|
||||||
|
{showRowNumber && (
|
||||||
|
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-12">
|
||||||
|
#
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
{columns.map((col) => (
|
||||||
|
<th
|
||||||
|
key={col.field}
|
||||||
|
className="px-4 py-2 text-left font-medium text-muted-foreground"
|
||||||
|
style={{ width: col.width }}
|
||||||
|
>
|
||||||
|
{col.label}
|
||||||
|
{col.required && <span className="text-destructive ml-1">*</span>}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
{!readOnly && allowDelete && (
|
||||||
|
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-20">
|
||||||
|
삭제
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-background">
|
||||||
|
{value.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={columns.length + (showRowNumber ? 1 : 0) + (allowDelete && !readOnly ? 1 : 0)}
|
||||||
|
className="px-4 py-8 text-center text-muted-foreground"
|
||||||
|
>
|
||||||
|
표시할 데이터가 없습니다
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
value.map((row, rowIndex) => (
|
||||||
|
<tr key={rowIndex} className="border-t hover:bg-accent/50">
|
||||||
|
{showRowNumber && (
|
||||||
|
<td className="px-4 py-2 text-center text-muted-foreground">
|
||||||
|
{rowIndex + 1}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
{columns.map((col) => (
|
||||||
|
<td key={col.field} className="px-2 py-1">
|
||||||
|
{renderCell(row, col, rowIndex)}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
{!readOnly && allowDelete && (
|
||||||
|
<td className="px-4 py-2 text-center">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleRowDelete(rowIndex)}
|
||||||
|
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,16 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { ComponentRegistry } from "../../ComponentRegistry";
|
||||||
|
import { SimpleRepeaterTableDefinition } from "./index";
|
||||||
|
import { SimpleRepeaterTableComponent } from "./SimpleRepeaterTableComponent";
|
||||||
|
import { ComponentRendererProps } from "@/types/component";
|
||||||
|
|
||||||
|
// 컴포넌트 자동 등록
|
||||||
|
ComponentRegistry.registerComponent(SimpleRepeaterTableDefinition);
|
||||||
|
|
||||||
|
console.log("✅ SimpleRepeaterTable 컴포넌트 등록 완료");
|
||||||
|
|
||||||
|
export function SimpleRepeaterTableRenderer(props: ComponentRendererProps) {
|
||||||
|
return <SimpleRepeaterTableComponent {...props} />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||||
|
import { ComponentCategory } from "@/types/component";
|
||||||
|
import { SimpleRepeaterTableComponent } from "./SimpleRepeaterTableComponent";
|
||||||
|
import { SimpleRepeaterTableConfigPanel } from "./SimpleRepeaterTableConfigPanel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🆕 SimpleRepeaterTable 컴포넌트 정의
|
||||||
|
* 단순 반복 테이블 - 검색/추가 없이 데이터 표시 및 편집만
|
||||||
|
*
|
||||||
|
* 주요 기능:
|
||||||
|
* - 초기 데이터 로드: 어떤 테이블에서 어떤 조건으로 데이터를 가져올지 설정
|
||||||
|
* - 컬럼별 소스 설정: 각 컬럼의 데이터를 어디서 조회할지 설정 (직접 조회/조인 조회/수동 입력)
|
||||||
|
* - 컬럼별 타겟 설정: 각 컬럼의 데이터를 어느 테이블의 어느 컬럼에 저장할지 설정
|
||||||
|
* - 자동 계산: 수량 * 단가 = 금액 같은 자동 계산 지원
|
||||||
|
* - 읽기 전용 모드: 전체 테이블을 보기 전용으로 설정
|
||||||
|
*/
|
||||||
|
export const SimpleRepeaterTableDefinition = createComponentDefinition({
|
||||||
|
id: "simple-repeater-table",
|
||||||
|
name: "단순 반복 테이블",
|
||||||
|
nameEng: "Simple Repeater Table",
|
||||||
|
description: "어떤 테이블에서 조회하고 어떤 테이블에 저장할지 컬럼별로 설정 가능한 반복 테이블 (검색/추가 없음, 자동 계산 지원)",
|
||||||
|
category: ComponentCategory.DATA,
|
||||||
|
webType: "table",
|
||||||
|
component: SimpleRepeaterTableComponent,
|
||||||
|
defaultConfig: {
|
||||||
|
columns: [],
|
||||||
|
calculationRules: [],
|
||||||
|
initialDataConfig: undefined,
|
||||||
|
readOnly: false,
|
||||||
|
showRowNumber: true,
|
||||||
|
allowDelete: true,
|
||||||
|
maxHeight: "240px",
|
||||||
|
},
|
||||||
|
defaultSize: { width: 800, height: 400 },
|
||||||
|
configPanel: SimpleRepeaterTableConfigPanel,
|
||||||
|
icon: "Table",
|
||||||
|
tags: ["테이블", "반복", "편집", "데이터", "목록", "계산", "조회", "저장"],
|
||||||
|
version: "2.0.0",
|
||||||
|
author: "개발팀",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 타입 내보내기
|
||||||
|
export type {
|
||||||
|
SimpleRepeaterTableProps,
|
||||||
|
SimpleRepeaterColumnConfig,
|
||||||
|
CalculationRule,
|
||||||
|
ColumnSourceConfig,
|
||||||
|
ColumnTargetConfig,
|
||||||
|
InitialDataConfig,
|
||||||
|
DataFilterCondition,
|
||||||
|
SourceJoinCondition,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
// 컴포넌트 내보내기
|
||||||
|
export { SimpleRepeaterTableComponent } from "./SimpleRepeaterTableComponent";
|
||||||
|
export { SimpleRepeaterTableConfigPanel } from "./SimpleRepeaterTableConfigPanel";
|
||||||
|
export { useCalculation } from "./useCalculation";
|
||||||
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { CalculationRule } from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 계산 필드 자동 업데이트 훅
|
||||||
|
*/
|
||||||
|
export function useCalculation(calculationRules: CalculationRule[] = []) {
|
||||||
|
/**
|
||||||
|
* 단일 행의 계산 필드 업데이트
|
||||||
|
*/
|
||||||
|
const calculateRow = useCallback(
|
||||||
|
(row: any): any => {
|
||||||
|
if (calculationRules.length === 0) return row;
|
||||||
|
|
||||||
|
const updatedRow = { ...row };
|
||||||
|
|
||||||
|
for (const rule of calculationRules) {
|
||||||
|
try {
|
||||||
|
// formula에서 필드명 자동 추출 (영문자, 숫자, 언더스코어로 구성된 단어)
|
||||||
|
let formula = rule.formula;
|
||||||
|
const fieldMatches = formula.match(/[a-zA-Z_][a-zA-Z0-9_]*/g) || [];
|
||||||
|
|
||||||
|
// 추출된 필드명들을 사용 (dependencies가 없으면 자동 추출 사용)
|
||||||
|
const dependencies = rule.dependencies && rule.dependencies.length > 0
|
||||||
|
? rule.dependencies
|
||||||
|
: fieldMatches;
|
||||||
|
|
||||||
|
// 필드명을 실제 값으로 대체
|
||||||
|
for (const dep of dependencies) {
|
||||||
|
// 결과 필드는 제외
|
||||||
|
if (dep === rule.result) continue;
|
||||||
|
|
||||||
|
const value = parseFloat(row[dep]) || 0;
|
||||||
|
// 정확한 필드명만 대체 (단어 경계 사용)
|
||||||
|
formula = formula.replace(new RegExp(`\\b${dep}\\b`, "g"), value.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 계산 실행 (Function 사용)
|
||||||
|
const result = new Function(`return ${formula}`)();
|
||||||
|
updatedRow[rule.result] = result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`계산 오류 (${rule.formula}):`, error);
|
||||||
|
updatedRow[rule.result] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedRow;
|
||||||
|
},
|
||||||
|
[calculationRules]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 데이터의 계산 필드 업데이트
|
||||||
|
*/
|
||||||
|
const calculateAll = useCallback(
|
||||||
|
(data: any[]): any[] => {
|
||||||
|
return data.map((row) => calculateRow(row));
|
||||||
|
},
|
||||||
|
[calculateRow]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
calculateRow,
|
||||||
|
calculateAll,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -266,10 +266,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("🔍 [TableListComponent] filters → searchValues:", {
|
// console.log("🔍 [TableListComponent] filters → searchValues:", {
|
||||||
filters: filters.length,
|
// filters: filters.length,
|
||||||
searchValues: newSearchValues,
|
// searchValues: newSearchValues,
|
||||||
});
|
// });
|
||||||
|
|
||||||
setSearchValues(newSearchValues);
|
setSearchValues(newSearchValues);
|
||||||
setCurrentPage(1); // 필터 변경 시 첫 페이지로
|
setCurrentPage(1); // 필터 변경 시 첫 페이지로
|
||||||
|
|
@ -859,13 +859,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
referenceTable: col.additionalJoinInfo!.referenceTable,
|
referenceTable: col.additionalJoinInfo!.referenceTable,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log("🔍 [TableList] API 호출 시작", {
|
// console.log("🔍 [TableList] API 호출 시작", {
|
||||||
tableName: tableConfig.selectedTable,
|
// tableName: tableConfig.selectedTable,
|
||||||
page,
|
// page,
|
||||||
pageSize,
|
// pageSize,
|
||||||
sortBy,
|
// sortBy,
|
||||||
sortOrder,
|
// sortOrder,
|
||||||
});
|
// });
|
||||||
|
|
||||||
// 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원)
|
// 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원)
|
||||||
const response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
|
const response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
|
||||||
|
|
@ -883,12 +883,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
const itemNumbers = (response.data || []).map((item: any) => item.item_number);
|
const itemNumbers = (response.data || []).map((item: any) => item.item_number);
|
||||||
const uniqueItemNumbers = [...new Set(itemNumbers)];
|
const uniqueItemNumbers = [...new Set(itemNumbers)];
|
||||||
|
|
||||||
console.log("✅ [TableList] API 응답 받음");
|
// console.log("✅ [TableList] API 응답 받음");
|
||||||
console.log(` - dataLength: ${response.data?.length || 0}`);
|
// console.log(` - dataLength: ${response.data?.length || 0}`);
|
||||||
console.log(` - total: ${response.total}`);
|
// console.log(` - total: ${response.total}`);
|
||||||
console.log(` - itemNumbers: ${JSON.stringify(itemNumbers)}`);
|
// console.log(` - itemNumbers: ${JSON.stringify(itemNumbers)}`);
|
||||||
console.log(` - uniqueItemNumbers: ${JSON.stringify(uniqueItemNumbers)}`);
|
// console.log(` - uniqueItemNumbers: ${JSON.stringify(uniqueItemNumbers)}`);
|
||||||
console.log(` - isDuplicated: ${itemNumbers.length !== uniqueItemNumbers.length}`);
|
// console.log(` - isDuplicated: ${itemNumbers.length !== uniqueItemNumbers.length}`);
|
||||||
|
|
||||||
setData(response.data || []);
|
setData(response.data || []);
|
||||||
setTotalPages(response.totalPages || 0);
|
setTotalPages(response.totalPages || 0);
|
||||||
|
|
@ -1310,41 +1310,41 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
const lastColumnOrderRef = useRef<string>("");
|
const lastColumnOrderRef = useRef<string>("");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("🔍 [컬럼 순서 전달 useEffect] 실행됨:", {
|
// console.log("🔍 [컬럼 순서 전달 useEffect] 실행됨:", {
|
||||||
hasCallback: !!onSelectedRowsChange,
|
// hasCallback: !!onSelectedRowsChange,
|
||||||
visibleColumnsLength: visibleColumns.length,
|
// visibleColumnsLength: visibleColumns.length,
|
||||||
visibleColumnsNames: visibleColumns.map((c) => c.columnName),
|
// visibleColumnsNames: visibleColumns.map((c) => c.columnName),
|
||||||
});
|
// });
|
||||||
|
|
||||||
if (!onSelectedRowsChange) {
|
if (!onSelectedRowsChange) {
|
||||||
console.warn("⚠️ onSelectedRowsChange 콜백이 없습니다!");
|
// console.warn("⚠️ onSelectedRowsChange 콜백이 없습니다!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (visibleColumns.length === 0) {
|
if (visibleColumns.length === 0) {
|
||||||
console.warn("⚠️ visibleColumns가 비어있습니다!");
|
// console.warn("⚠️ visibleColumns가 비어있습니다!");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentColumnOrder = visibleColumns.map((col) => col.columnName).filter((name) => name !== "__checkbox__"); // 체크박스 컬럼 제외
|
const currentColumnOrder = visibleColumns.map((col) => col.columnName).filter((name) => name !== "__checkbox__"); // 체크박스 컬럼 제외
|
||||||
|
|
||||||
console.log("🔍 [컬럼 순서] 체크박스 제외 후:", currentColumnOrder);
|
// console.log("🔍 [컬럼 순서] 체크박스 제외 후:", currentColumnOrder);
|
||||||
|
|
||||||
// 컬럼 순서가 실제로 변경되었을 때만 전달 (무한 루프 방지)
|
// 컬럼 순서가 실제로 변경되었을 때만 전달 (무한 루프 방지)
|
||||||
const columnOrderString = currentColumnOrder.join(",");
|
const columnOrderString = currentColumnOrder.join(",");
|
||||||
console.log("🔍 [컬럼 순서] 비교:", {
|
// console.log("🔍 [컬럼 순서] 비교:", {
|
||||||
current: columnOrderString,
|
// current: columnOrderString,
|
||||||
last: lastColumnOrderRef.current,
|
// last: lastColumnOrderRef.current,
|
||||||
isDifferent: columnOrderString !== lastColumnOrderRef.current,
|
// isDifferent: columnOrderString !== lastColumnOrderRef.current,
|
||||||
});
|
// });
|
||||||
|
|
||||||
if (columnOrderString === lastColumnOrderRef.current) {
|
if (columnOrderString === lastColumnOrderRef.current) {
|
||||||
console.log("⏭️ 컬럼 순서 변경 없음, 전달 스킵");
|
// console.log("⏭️ 컬럼 순서 변경 없음, 전달 스킵");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
lastColumnOrderRef.current = columnOrderString;
|
lastColumnOrderRef.current = columnOrderString;
|
||||||
console.log("📊 현재 화면 컬럼 순서 전달:", currentColumnOrder);
|
// console.log("📊 현재 화면 컬럼 순서 전달:", currentColumnOrder);
|
||||||
|
|
||||||
// 선택된 행 데이터 가져오기
|
// 선택된 행 데이터 가져오기
|
||||||
const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index)));
|
const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index)));
|
||||||
|
|
@ -1862,13 +1862,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
}, [tableConfig.selectedTable, fetchColumnLabels, fetchTableLabel]);
|
}, [tableConfig.selectedTable, fetchColumnLabels, fetchTableLabel]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
console.log("🔍 [TableList] useEffect 실행 - 데이터 조회 트리거", {
|
// console.log("🔍 [TableList] useEffect 실행 - 데이터 조회 트리거", {
|
||||||
isDesignMode,
|
// isDesignMode,
|
||||||
tableName: tableConfig.selectedTable,
|
// tableName: tableConfig.selectedTable,
|
||||||
currentPage,
|
// currentPage,
|
||||||
sortColumn,
|
// sortColumn,
|
||||||
sortDirection,
|
// sortDirection,
|
||||||
});
|
// });
|
||||||
|
|
||||||
if (!isDesignMode && tableConfig.selectedTable) {
|
if (!isDesignMode && tableConfig.selectedTable) {
|
||||||
fetchTableDataDebounced();
|
fetchTableDataDebounced();
|
||||||
|
|
|
||||||
|
|
@ -161,5 +161,5 @@ ComponentRegistry.registerComponent({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("✅ 탭 컴포넌트 등록 완료");
|
// console.log("✅ 탭 컴포넌트 등록 완료");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -942,6 +942,7 @@ export class ButtonActionExecutor {
|
||||||
title: config.modalTitle,
|
title: config.modalTitle,
|
||||||
size: config.modalSize,
|
size: config.modalSize,
|
||||||
targetScreenId: config.targetScreenId,
|
targetScreenId: config.targetScreenId,
|
||||||
|
selectedRowsData: context.selectedRowsData,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (config.targetScreenId) {
|
if (config.targetScreenId) {
|
||||||
|
|
@ -958,6 +959,10 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🆕 선택된 행 데이터 수집
|
||||||
|
const selectedData = context.selectedRowsData || [];
|
||||||
|
console.log("📦 [handleModal] 선택된 데이터:", selectedData);
|
||||||
|
|
||||||
// 전역 모달 상태 업데이트를 위한 이벤트 발생
|
// 전역 모달 상태 업데이트를 위한 이벤트 발생
|
||||||
const modalEvent = new CustomEvent("openScreenModal", {
|
const modalEvent = new CustomEvent("openScreenModal", {
|
||||||
detail: {
|
detail: {
|
||||||
|
|
@ -965,6 +970,9 @@ export class ButtonActionExecutor {
|
||||||
title: config.modalTitle || "화면",
|
title: config.modalTitle || "화면",
|
||||||
description: description,
|
description: description,
|
||||||
size: config.modalSize || "md",
|
size: config.modalSize || "md",
|
||||||
|
// 🆕 선택된 행 데이터 전달
|
||||||
|
selectedData: selectedData,
|
||||||
|
selectedIds: selectedData.map((row: any) => row.id).filter(Boolean),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -549,6 +549,11 @@ export interface ScreenDefinition {
|
||||||
updatedBy?: string;
|
updatedBy?: string;
|
||||||
dbSourceType?: "internal" | "external";
|
dbSourceType?: "internal" | "external";
|
||||||
dbConnectionId?: number;
|
dbConnectionId?: number;
|
||||||
|
// REST API 관련 필드
|
||||||
|
dataSourceType?: "database" | "restapi";
|
||||||
|
restApiConnectionId?: number;
|
||||||
|
restApiEndpoint?: string;
|
||||||
|
restApiJsonPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -563,6 +568,11 @@ export interface CreateScreenRequest {
|
||||||
description?: string;
|
description?: string;
|
||||||
dbSourceType?: "internal" | "external";
|
dbSourceType?: "internal" | "external";
|
||||||
dbConnectionId?: number;
|
dbConnectionId?: number;
|
||||||
|
// REST API 관련 필드
|
||||||
|
dataSourceType?: "database" | "restapi";
|
||||||
|
restApiConnectionId?: number;
|
||||||
|
restApiEndpoint?: string;
|
||||||
|
restApiJsonPath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue