Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into common/feat/dashboard-map
This commit is contained in:
commit
1462700c83
|
|
@ -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": []
|
||||
}
|
||||
|
|
@ -71,6 +71,7 @@ import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
|
|||
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
|
||||
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
|
||||
import orderRoutes from "./routes/orderRoutes"; // 수주 관리
|
||||
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
|
|
@ -236,6 +237,7 @@ app.use("/api/code-merge", codeMergeRoutes); // 코드 병합
|
|||
app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리
|
||||
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
|
||||
app.use("/api/orders", orderRoutes); // 수주 관리
|
||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||
// app.use('/api/users', userRoutes);
|
||||
|
|
|
|||
|
|
@ -419,3 +419,66 @@ export const getTableColumns = async (
|
|||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 특정 필드만 업데이트 (다른 테이블 지원)
|
||||
export const updateFieldValue = async (
|
||||
req: AuthenticatedRequest,
|
||||
res: Response
|
||||
): Promise<Response | void> => {
|
||||
try {
|
||||
const { companyCode, userId } = req.user as any;
|
||||
const { tableName, keyField, keyValue, updateField, updateValue } = req.body;
|
||||
|
||||
console.log("🔄 [updateFieldValue] 요청:", {
|
||||
tableName,
|
||||
keyField,
|
||||
keyValue,
|
||||
updateField,
|
||||
updateValue,
|
||||
userId,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!tableName || !keyField || keyValue === undefined || !updateField || updateValue === undefined) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다. (tableName, keyField, keyValue, updateField, updateValue)",
|
||||
});
|
||||
}
|
||||
|
||||
// SQL 인젝션 방지를 위한 테이블명/컬럼명 검증
|
||||
const validNamePattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
if (!validNamePattern.test(tableName) || !validNamePattern.test(keyField) || !validNamePattern.test(updateField)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 테이블명 또는 컬럼명입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// 업데이트 쿼리 실행
|
||||
const result = await dynamicFormService.updateFieldValue(
|
||||
tableName,
|
||||
keyField,
|
||||
keyValue,
|
||||
updateField,
|
||||
updateValue,
|
||||
companyCode,
|
||||
userId
|
||||
);
|
||||
|
||||
console.log("✅ [updateFieldValue] 성공:", result);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
message: "필드 값이 업데이트되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error("❌ [updateFieldValue] 실패:", error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "필드 업데이트에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,924 @@
|
|||
/**
|
||||
* 화면 임베딩 및 데이터 전달 시스템 컨트롤러
|
||||
*/
|
||||
|
||||
import { Request, Response } from "express";
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
const pool = getPool();
|
||||
|
||||
// ============================================
|
||||
// 1. 화면 임베딩 API
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 화면 임베딩 목록 조회
|
||||
* GET /api/screen-embedding?parentScreenId=1
|
||||
*/
|
||||
export async function getScreenEmbeddings(req: Request, res: Response) {
|
||||
try {
|
||||
const { parentScreenId } = req.query;
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
if (!parentScreenId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "부모 화면 ID가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
se.*,
|
||||
ps.screen_name as parent_screen_name,
|
||||
cs.screen_name as child_screen_name
|
||||
FROM screen_embedding se
|
||||
LEFT JOIN screen_definitions ps ON se.parent_screen_id = ps.screen_id
|
||||
LEFT JOIN screen_definitions cs ON se.child_screen_id = cs.screen_id
|
||||
WHERE se.parent_screen_id = $1
|
||||
AND se.company_code = $2
|
||||
ORDER BY se.position, se.created_at
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [parentScreenId, companyCode]);
|
||||
|
||||
logger.info("화면 임베딩 목록 조회", {
|
||||
companyCode,
|
||||
parentScreenId,
|
||||
count: result.rowCount,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("화면 임베딩 목록 조회 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "화면 임베딩 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 임베딩 상세 조회
|
||||
* GET /api/screen-embedding/:id
|
||||
*/
|
||||
export async function getScreenEmbeddingById(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
se.*,
|
||||
ps.screen_name as parent_screen_name,
|
||||
cs.screen_name as child_screen_name
|
||||
FROM screen_embedding se
|
||||
LEFT JOIN screen_definitions ps ON se.parent_screen_id = ps.screen_id
|
||||
LEFT JOIN screen_definitions cs ON se.child_screen_id = cs.screen_id
|
||||
WHERE se.id = $1
|
||||
AND se.company_code = $2
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [id, companyCode]);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "화면 임베딩 설정을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("화면 임베딩 상세 조회", { companyCode, id });
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows[0],
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("화면 임베딩 상세 조회 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "화면 임베딩 상세 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 임베딩 생성
|
||||
* POST /api/screen-embedding
|
||||
*/
|
||||
export async function createScreenEmbedding(req: Request, res: Response) {
|
||||
try {
|
||||
const {
|
||||
parentScreenId,
|
||||
childScreenId,
|
||||
position,
|
||||
mode,
|
||||
config = {},
|
||||
} = req.body;
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!parentScreenId || !childScreenId || !position || !mode) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const query = `
|
||||
INSERT INTO screen_embedding (
|
||||
parent_screen_id, child_screen_id, position, mode,
|
||||
config, company_code, created_by, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [
|
||||
parentScreenId,
|
||||
childScreenId,
|
||||
position,
|
||||
mode,
|
||||
JSON.stringify(config),
|
||||
companyCode,
|
||||
userId,
|
||||
]);
|
||||
|
||||
logger.info("화면 임베딩 생성", {
|
||||
companyCode,
|
||||
userId,
|
||||
id: result.rows[0].id,
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: result.rows[0],
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("화면 임베딩 생성 실패", error);
|
||||
|
||||
// 유니크 제약조건 위반
|
||||
if (error.code === "23505") {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: "이미 동일한 임베딩 설정이 존재합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "화면 임베딩 생성 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 임베딩 수정
|
||||
* PUT /api/screen-embedding/:id
|
||||
*/
|
||||
export async function updateScreenEmbedding(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { position, mode, config } = req.body;
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
const updates: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (position) {
|
||||
updates.push(`position = $${paramIndex++}`);
|
||||
values.push(position);
|
||||
}
|
||||
|
||||
if (mode) {
|
||||
updates.push(`mode = $${paramIndex++}`);
|
||||
values.push(mode);
|
||||
}
|
||||
|
||||
if (config) {
|
||||
updates.push(`config = $${paramIndex++}`);
|
||||
values.push(JSON.stringify(config));
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "수정할 내용이 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
updates.push(`updated_at = NOW()`);
|
||||
|
||||
values.push(id, companyCode);
|
||||
|
||||
const query = `
|
||||
UPDATE screen_embedding
|
||||
SET ${updates.join(", ")}
|
||||
WHERE id = $${paramIndex++}
|
||||
AND company_code = $${paramIndex++}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, values);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "화면 임베딩 설정을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("화면 임베딩 수정", { companyCode, id });
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows[0],
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("화면 임베딩 수정 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "화면 임베딩 수정 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 임베딩 삭제
|
||||
* DELETE /api/screen-embedding/:id
|
||||
*/
|
||||
export async function deleteScreenEmbedding(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
const query = `
|
||||
DELETE FROM screen_embedding
|
||||
WHERE id = $1 AND company_code = $2
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [id, companyCode]);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "화면 임베딩 설정을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("화면 임베딩 삭제", { companyCode, id });
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "화면 임베딩이 삭제되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("화면 임베딩 삭제 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "화면 임베딩 삭제 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 2. 데이터 전달 API
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 데이터 전달 설정 조회
|
||||
* GET /api/screen-data-transfer?sourceScreenId=1&targetScreenId=2
|
||||
*/
|
||||
export async function getScreenDataTransfer(req: Request, res: Response) {
|
||||
try {
|
||||
const { sourceScreenId, targetScreenId } = req.query;
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
if (!sourceScreenId || !targetScreenId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "소스 화면 ID와 타겟 화면 ID가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
sdt.*,
|
||||
ss.screen_name as source_screen_name,
|
||||
ts.screen_name as target_screen_name
|
||||
FROM screen_data_transfer sdt
|
||||
LEFT JOIN screen_definitions ss ON sdt.source_screen_id = ss.screen_id
|
||||
LEFT JOIN screen_definitions ts ON sdt.target_screen_id = ts.screen_id
|
||||
WHERE sdt.source_screen_id = $1
|
||||
AND sdt.target_screen_id = $2
|
||||
AND sdt.company_code = $3
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [
|
||||
sourceScreenId,
|
||||
targetScreenId,
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "데이터 전달 설정을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("데이터 전달 설정 조회", {
|
||||
companyCode,
|
||||
sourceScreenId,
|
||||
targetScreenId,
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows[0],
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("데이터 전달 설정 조회 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "데이터 전달 설정 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 전달 설정 생성
|
||||
* POST /api/screen-data-transfer
|
||||
*/
|
||||
export async function createScreenDataTransfer(req: Request, res: Response) {
|
||||
try {
|
||||
const {
|
||||
sourceScreenId,
|
||||
targetScreenId,
|
||||
sourceComponentId,
|
||||
sourceComponentType,
|
||||
dataReceivers,
|
||||
buttonConfig,
|
||||
} = req.body;
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!sourceScreenId || !targetScreenId || !dataReceivers) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const query = `
|
||||
INSERT INTO screen_data_transfer (
|
||||
source_screen_id, target_screen_id, source_component_id, source_component_type,
|
||||
data_receivers, button_config, company_code, created_by, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [
|
||||
sourceScreenId,
|
||||
targetScreenId,
|
||||
sourceComponentId,
|
||||
sourceComponentType,
|
||||
JSON.stringify(dataReceivers),
|
||||
JSON.stringify(buttonConfig || {}),
|
||||
companyCode,
|
||||
userId,
|
||||
]);
|
||||
|
||||
logger.info("데이터 전달 설정 생성", {
|
||||
companyCode,
|
||||
userId,
|
||||
id: result.rows[0].id,
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: result.rows[0],
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("데이터 전달 설정 생성 실패", error);
|
||||
|
||||
// 유니크 제약조건 위반
|
||||
if (error.code === "23505") {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: "이미 동일한 데이터 전달 설정이 존재합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "데이터 전달 설정 생성 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 전달 설정 수정
|
||||
* PUT /api/screen-data-transfer/:id
|
||||
*/
|
||||
export async function updateScreenDataTransfer(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { dataReceivers, buttonConfig } = req.body;
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
const updates: string[] = [];
|
||||
const values: any[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (dataReceivers) {
|
||||
updates.push(`data_receivers = $${paramIndex++}`);
|
||||
values.push(JSON.stringify(dataReceivers));
|
||||
}
|
||||
|
||||
if (buttonConfig) {
|
||||
updates.push(`button_config = $${paramIndex++}`);
|
||||
values.push(JSON.stringify(buttonConfig));
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "수정할 내용이 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
updates.push(`updated_at = NOW()`);
|
||||
|
||||
values.push(id, companyCode);
|
||||
|
||||
const query = `
|
||||
UPDATE screen_data_transfer
|
||||
SET ${updates.join(", ")}
|
||||
WHERE id = $${paramIndex++}
|
||||
AND company_code = $${paramIndex++}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, values);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "데이터 전달 설정을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("데이터 전달 설정 수정", { companyCode, id });
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows[0],
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("데이터 전달 설정 수정 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "데이터 전달 설정 수정 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 전달 설정 삭제
|
||||
* DELETE /api/screen-data-transfer/:id
|
||||
*/
|
||||
export async function deleteScreenDataTransfer(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
const query = `
|
||||
DELETE FROM screen_data_transfer
|
||||
WHERE id = $1 AND company_code = $2
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [id, companyCode]);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "데이터 전달 설정을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("데이터 전달 설정 삭제", { companyCode, id });
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "데이터 전달 설정이 삭제되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("데이터 전달 설정 삭제 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "데이터 전달 설정 삭제 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 3. 분할 패널 API
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 분할 패널 설정 조회
|
||||
* GET /api/screen-split-panel/:screenId
|
||||
*/
|
||||
export async function getScreenSplitPanel(req: Request, res: Response) {
|
||||
try {
|
||||
const { screenId } = req.params;
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
ssp.*,
|
||||
le.parent_screen_id as le_parent_screen_id,
|
||||
le.child_screen_id as le_child_screen_id,
|
||||
le.position as le_position,
|
||||
le.mode as le_mode,
|
||||
le.config as le_config,
|
||||
re.parent_screen_id as re_parent_screen_id,
|
||||
re.child_screen_id as re_child_screen_id,
|
||||
re.position as re_position,
|
||||
re.mode as re_mode,
|
||||
re.config as re_config,
|
||||
sdt.source_screen_id,
|
||||
sdt.target_screen_id,
|
||||
sdt.source_component_id,
|
||||
sdt.source_component_type,
|
||||
sdt.data_receivers,
|
||||
sdt.button_config
|
||||
FROM screen_split_panel ssp
|
||||
LEFT JOIN screen_embedding le ON ssp.left_embedding_id = le.id
|
||||
LEFT JOIN screen_embedding re ON ssp.right_embedding_id = re.id
|
||||
LEFT JOIN screen_data_transfer sdt ON ssp.data_transfer_id = sdt.id
|
||||
WHERE ssp.screen_id = $1
|
||||
AND ssp.company_code = $2
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [screenId, companyCode]);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "분할 패널 설정을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const row = result.rows[0];
|
||||
|
||||
// 데이터 구조화
|
||||
const data = {
|
||||
id: row.id,
|
||||
screenId: row.screen_id,
|
||||
leftEmbeddingId: row.left_embedding_id,
|
||||
rightEmbeddingId: row.right_embedding_id,
|
||||
dataTransferId: row.data_transfer_id,
|
||||
layoutConfig: row.layout_config,
|
||||
companyCode: row.company_code,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
leftEmbedding: row.le_child_screen_id
|
||||
? {
|
||||
id: row.left_embedding_id,
|
||||
parentScreenId: row.le_parent_screen_id,
|
||||
childScreenId: row.le_child_screen_id,
|
||||
position: row.le_position,
|
||||
mode: row.le_mode,
|
||||
config: row.le_config,
|
||||
}
|
||||
: null,
|
||||
rightEmbedding: row.re_child_screen_id
|
||||
? {
|
||||
id: row.right_embedding_id,
|
||||
parentScreenId: row.re_parent_screen_id,
|
||||
childScreenId: row.re_child_screen_id,
|
||||
position: row.re_position,
|
||||
mode: row.re_mode,
|
||||
config: row.re_config,
|
||||
}
|
||||
: null,
|
||||
dataTransfer: row.source_screen_id
|
||||
? {
|
||||
id: row.data_transfer_id,
|
||||
sourceScreenId: row.source_screen_id,
|
||||
targetScreenId: row.target_screen_id,
|
||||
sourceComponentId: row.source_component_id,
|
||||
sourceComponentType: row.source_component_type,
|
||||
dataReceivers: row.data_receivers,
|
||||
buttonConfig: row.button_config,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
|
||||
logger.info("분할 패널 설정 조회", { companyCode, screenId });
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("분할 패널 설정 조회 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "분할 패널 설정 조회 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 분할 패널 설정 생성
|
||||
* POST /api/screen-split-panel
|
||||
*/
|
||||
export async function createScreenSplitPanel(req: Request, res: Response) {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const {
|
||||
screenId,
|
||||
leftEmbedding,
|
||||
rightEmbedding,
|
||||
dataTransfer,
|
||||
layoutConfig,
|
||||
} = req.body;
|
||||
const companyCode = req.user!.companyCode;
|
||||
const userId = req.user!.userId;
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!screenId || !leftEmbedding || !rightEmbedding || !dataTransfer) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 필드가 누락되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 1. 좌측 임베딩 생성
|
||||
const leftEmbeddingQuery = `
|
||||
INSERT INTO screen_embedding (
|
||||
parent_screen_id, child_screen_id, position, mode,
|
||||
config, company_code, created_by, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
const leftResult = await client.query(leftEmbeddingQuery, [
|
||||
screenId,
|
||||
leftEmbedding.childScreenId,
|
||||
leftEmbedding.position,
|
||||
leftEmbedding.mode,
|
||||
JSON.stringify(leftEmbedding.config || {}),
|
||||
companyCode,
|
||||
userId,
|
||||
]);
|
||||
|
||||
const leftEmbeddingId = leftResult.rows[0].id;
|
||||
|
||||
// 2. 우측 임베딩 생성
|
||||
const rightEmbeddingQuery = `
|
||||
INSERT INTO screen_embedding (
|
||||
parent_screen_id, child_screen_id, position, mode,
|
||||
config, company_code, created_by, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
const rightResult = await client.query(rightEmbeddingQuery, [
|
||||
screenId,
|
||||
rightEmbedding.childScreenId,
|
||||
rightEmbedding.position,
|
||||
rightEmbedding.mode,
|
||||
JSON.stringify(rightEmbedding.config || {}),
|
||||
companyCode,
|
||||
userId,
|
||||
]);
|
||||
|
||||
const rightEmbeddingId = rightResult.rows[0].id;
|
||||
|
||||
// 3. 데이터 전달 설정 생성
|
||||
const dataTransferQuery = `
|
||||
INSERT INTO screen_data_transfer (
|
||||
source_screen_id, target_screen_id, source_component_id, source_component_type,
|
||||
data_receivers, button_config, company_code, created_by, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
const dataTransferResult = await client.query(dataTransferQuery, [
|
||||
dataTransfer.sourceScreenId,
|
||||
dataTransfer.targetScreenId,
|
||||
dataTransfer.sourceComponentId,
|
||||
dataTransfer.sourceComponentType,
|
||||
JSON.stringify(dataTransfer.dataReceivers),
|
||||
JSON.stringify(dataTransfer.buttonConfig || {}),
|
||||
companyCode,
|
||||
userId,
|
||||
]);
|
||||
|
||||
const dataTransferId = dataTransferResult.rows[0].id;
|
||||
|
||||
// 4. 분할 패널 생성
|
||||
const splitPanelQuery = `
|
||||
INSERT INTO screen_split_panel (
|
||||
screen_id, left_embedding_id, right_embedding_id, data_transfer_id,
|
||||
layout_config, company_code, created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const splitPanelResult = await client.query(splitPanelQuery, [
|
||||
screenId,
|
||||
leftEmbeddingId,
|
||||
rightEmbeddingId,
|
||||
dataTransferId,
|
||||
JSON.stringify(layoutConfig || {}),
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("분할 패널 설정 생성", {
|
||||
companyCode,
|
||||
userId,
|
||||
screenId,
|
||||
id: splitPanelResult.rows[0].id,
|
||||
});
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: splitPanelResult.rows[0],
|
||||
});
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("분할 패널 설정 생성 실패", error);
|
||||
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "분할 패널 설정 생성 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 분할 패널 설정 수정
|
||||
* PUT /api/screen-split-panel/:id
|
||||
*/
|
||||
export async function updateScreenSplitPanel(req: Request, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { layoutConfig } = req.body;
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
if (!layoutConfig) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "수정할 내용이 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const query = `
|
||||
UPDATE screen_split_panel
|
||||
SET layout_config = $1, updated_at = NOW()
|
||||
WHERE id = $2 AND company_code = $3
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [
|
||||
JSON.stringify(layoutConfig),
|
||||
id,
|
||||
companyCode,
|
||||
]);
|
||||
|
||||
if (result.rowCount === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "분할 패널 설정을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("분할 패널 설정 수정", { companyCode, id });
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result.rows[0],
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error("분할 패널 설정 수정 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "분할 패널 설정 수정 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 분할 패널 설정 삭제
|
||||
* DELETE /api/screen-split-panel/:id
|
||||
*/
|
||||
export async function deleteScreenSplitPanel(req: Request, res: Response) {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const companyCode = req.user!.companyCode;
|
||||
|
||||
await client.query("BEGIN");
|
||||
|
||||
// 1. 분할 패널 조회
|
||||
const selectQuery = `
|
||||
SELECT left_embedding_id, right_embedding_id, data_transfer_id
|
||||
FROM screen_split_panel
|
||||
WHERE id = $1 AND company_code = $2
|
||||
`;
|
||||
|
||||
const selectResult = await client.query(selectQuery, [id, companyCode]);
|
||||
|
||||
if (selectResult.rowCount === 0) {
|
||||
await client.query("ROLLBACK");
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "분할 패널 설정을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const { left_embedding_id, right_embedding_id, data_transfer_id } =
|
||||
selectResult.rows[0];
|
||||
|
||||
// 2. 분할 패널 삭제
|
||||
await client.query(
|
||||
"DELETE FROM screen_split_panel WHERE id = $1 AND company_code = $2",
|
||||
[id, companyCode]
|
||||
);
|
||||
|
||||
// 3. 관련 임베딩 및 데이터 전달 설정 삭제 (CASCADE로 자동 삭제되지만 명시적으로)
|
||||
if (left_embedding_id) {
|
||||
await client.query(
|
||||
"DELETE FROM screen_embedding WHERE id = $1 AND company_code = $2",
|
||||
[left_embedding_id, companyCode]
|
||||
);
|
||||
}
|
||||
|
||||
if (right_embedding_id) {
|
||||
await client.query(
|
||||
"DELETE FROM screen_embedding WHERE id = $1 AND company_code = $2",
|
||||
[right_embedding_id, companyCode]
|
||||
);
|
||||
}
|
||||
|
||||
if (data_transfer_id) {
|
||||
await client.query(
|
||||
"DELETE FROM screen_data_transfer WHERE id = $1 AND company_code = $2",
|
||||
[data_transfer_id, companyCode]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query("COMMIT");
|
||||
|
||||
logger.info("분할 패널 설정 삭제", { companyCode, id });
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "분할 패널 설정이 삭제되었습니다.",
|
||||
});
|
||||
} catch (error: any) {
|
||||
await client.query("ROLLBACK");
|
||||
logger.error("분할 패널 설정 삭제 실패", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "분할 패널 설정 삭제 중 오류가 발생했습니다.",
|
||||
error: error.message,
|
||||
});
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
|
@ -481,6 +481,52 @@ export const deleteColumnMapping = async (req: AuthenticatedRequest, res: Respon
|
|||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 테이블+컬럼 기준으로 모든 매핑 삭제
|
||||
*
|
||||
* DELETE /api/categories/column-mapping/:tableName/:columnName
|
||||
*
|
||||
* 메뉴 선택 변경 시 기존 매핑을 모두 삭제하고 새로운 매핑만 추가하기 위해 사용
|
||||
*/
|
||||
export const deleteColumnMappingsByColumn = async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const companyCode = req.user!.companyCode;
|
||||
const { tableName, columnName } = req.params;
|
||||
|
||||
if (!tableName || !columnName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "tableName과 columnName은 필수입니다",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info("테이블+컬럼 기준 매핑 삭제", {
|
||||
tableName,
|
||||
columnName,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
const deletedCount = await tableCategoryValueService.deleteColumnMappingsByColumn(
|
||||
tableName,
|
||||
columnName,
|
||||
companyCode
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: `${deletedCount}개의 컬럼 매핑이 삭제되었습니다`,
|
||||
deletedCount,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error(`테이블+컬럼 기준 매핑 삭제 실패: ${error.message}`);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: error.message || "컬럼 매핑 삭제 중 오류가 발생했습니다",
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 2레벨 메뉴 목록 조회
|
||||
*
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
saveFormDataEnhanced,
|
||||
updateFormData,
|
||||
updateFormDataPartial,
|
||||
updateFieldValue,
|
||||
deleteFormData,
|
||||
getFormData,
|
||||
getFormDataList,
|
||||
|
|
@ -23,6 +24,7 @@ router.post("/save", saveFormData); // 기존 버전 (레거시 지원)
|
|||
router.post("/save-enhanced", saveFormDataEnhanced); // 개선된 버전
|
||||
router.put("/:id", updateFormData);
|
||||
router.patch("/:id/partial", updateFormDataPartial); // 부분 업데이트
|
||||
router.put("/update-field", updateFieldValue); // 특정 필드만 업데이트 (다른 테이블 지원)
|
||||
router.delete("/:id", deleteFormData);
|
||||
router.get("/:id", getFormData);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* 화면 임베딩 및 데이터 전달 시스템 라우트
|
||||
*/
|
||||
|
||||
import express from "express";
|
||||
import {
|
||||
// 화면 임베딩
|
||||
getScreenEmbeddings,
|
||||
getScreenEmbeddingById,
|
||||
createScreenEmbedding,
|
||||
updateScreenEmbedding,
|
||||
deleteScreenEmbedding,
|
||||
// 데이터 전달
|
||||
getScreenDataTransfer,
|
||||
createScreenDataTransfer,
|
||||
updateScreenDataTransfer,
|
||||
deleteScreenDataTransfer,
|
||||
// 분할 패널
|
||||
getScreenSplitPanel,
|
||||
createScreenSplitPanel,
|
||||
updateScreenSplitPanel,
|
||||
deleteScreenSplitPanel,
|
||||
} from "../controllers/screenEmbeddingController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ============================================
|
||||
// 화면 임베딩 라우트
|
||||
// ============================================
|
||||
|
||||
// 화면 임베딩 목록 조회
|
||||
router.get("/screen-embedding", authenticateToken, getScreenEmbeddings);
|
||||
|
||||
// 화면 임베딩 상세 조회
|
||||
router.get("/screen-embedding/:id", authenticateToken, getScreenEmbeddingById);
|
||||
|
||||
// 화면 임베딩 생성
|
||||
router.post("/screen-embedding", authenticateToken, createScreenEmbedding);
|
||||
|
||||
// 화면 임베딩 수정
|
||||
router.put("/screen-embedding/:id", authenticateToken, updateScreenEmbedding);
|
||||
|
||||
// 화면 임베딩 삭제
|
||||
router.delete("/screen-embedding/:id", authenticateToken, deleteScreenEmbedding);
|
||||
|
||||
// ============================================
|
||||
// 데이터 전달 라우트
|
||||
// ============================================
|
||||
|
||||
// 데이터 전달 설정 조회
|
||||
router.get("/screen-data-transfer", authenticateToken, getScreenDataTransfer);
|
||||
|
||||
// 데이터 전달 설정 생성
|
||||
router.post("/screen-data-transfer", authenticateToken, createScreenDataTransfer);
|
||||
|
||||
// 데이터 전달 설정 수정
|
||||
router.put("/screen-data-transfer/:id", authenticateToken, updateScreenDataTransfer);
|
||||
|
||||
// 데이터 전달 설정 삭제
|
||||
router.delete("/screen-data-transfer/:id", authenticateToken, deleteScreenDataTransfer);
|
||||
|
||||
// ============================================
|
||||
// 분할 패널 라우트
|
||||
// ============================================
|
||||
|
||||
// 분할 패널 설정 조회
|
||||
router.get("/screen-split-panel/:screenId", authenticateToken, getScreenSplitPanel);
|
||||
|
||||
// 분할 패널 설정 생성
|
||||
router.post("/screen-split-panel", authenticateToken, createScreenSplitPanel);
|
||||
|
||||
// 분할 패널 설정 수정
|
||||
router.put("/screen-split-panel/:id", authenticateToken, updateScreenSplitPanel);
|
||||
|
||||
// 분할 패널 설정 삭제
|
||||
router.delete("/screen-split-panel/:id", authenticateToken, deleteScreenSplitPanel);
|
||||
|
||||
export default router;
|
||||
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
createColumnMapping,
|
||||
getLogicalColumns,
|
||||
deleteColumnMapping,
|
||||
deleteColumnMappingsByColumn,
|
||||
getSecondLevelMenus,
|
||||
} from "../controllers/tableCategoryValueController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
|
|
@ -57,7 +58,11 @@ router.get("/logical-columns/:tableName/:menuObjid", getLogicalColumns);
|
|||
// 컬럼 매핑 생성/수정
|
||||
router.post("/column-mapping", createColumnMapping);
|
||||
|
||||
// 컬럼 매핑 삭제
|
||||
// 테이블+컬럼 기준 매핑 삭제 (메뉴 선택 변경 시 기존 매핑 모두 삭제용)
|
||||
// 주의: 더 구체적인 라우트가 먼저 와야 함 (3개 세그먼트 > 1개 세그먼트)
|
||||
router.delete("/column-mapping/:tableName/:columnName/all", deleteColumnMappingsByColumn);
|
||||
|
||||
// 컬럼 매핑 삭제 (단일)
|
||||
router.delete("/column-mapping/:mappingId", deleteColumnMapping);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -299,6 +299,8 @@ export class DashboardService {
|
|||
|
||||
/**
|
||||
* 대시보드 상세 조회
|
||||
* - company_code가 일치하면 해당 회사 사용자는 모두 조회 가능
|
||||
* - company_code가 '*'인 경우 최고 관리자만 조회 가능
|
||||
*/
|
||||
static async getDashboardById(
|
||||
dashboardId: string,
|
||||
|
|
@ -310,44 +312,43 @@ export class DashboardService {
|
|||
let dashboardQuery: string;
|
||||
let dashboardParams: any[];
|
||||
|
||||
if (userId) {
|
||||
if (companyCode) {
|
||||
if (companyCode) {
|
||||
// 회사 코드가 있으면 해당 회사 대시보드 또는 공개 대시보드 조회 가능
|
||||
// 최고 관리자(companyCode = '*')는 모든 대시보드 조회 가능
|
||||
if (companyCode === '*') {
|
||||
dashboardQuery = `
|
||||
SELECT d.*
|
||||
FROM dashboards d
|
||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||
AND d.company_code = $2
|
||||
AND (d.created_by = $3 OR d.is_public = true)
|
||||
`;
|
||||
dashboardParams = [dashboardId, companyCode, userId];
|
||||
} else {
|
||||
dashboardQuery = `
|
||||
SELECT d.*
|
||||
FROM dashboards d
|
||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||
AND (d.created_by = $2 OR d.is_public = true)
|
||||
`;
|
||||
dashboardParams = [dashboardId, userId];
|
||||
}
|
||||
} else {
|
||||
if (companyCode) {
|
||||
dashboardQuery = `
|
||||
SELECT d.*
|
||||
FROM dashboards d
|
||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||
AND d.company_code = $2
|
||||
AND d.is_public = true
|
||||
`;
|
||||
dashboardParams = [dashboardId, companyCode];
|
||||
} else {
|
||||
dashboardQuery = `
|
||||
SELECT d.*
|
||||
FROM dashboards d
|
||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||
AND d.is_public = true
|
||||
`;
|
||||
dashboardParams = [dashboardId];
|
||||
} else {
|
||||
dashboardQuery = `
|
||||
SELECT d.*
|
||||
FROM dashboards d
|
||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||
AND d.company_code = $2
|
||||
`;
|
||||
dashboardParams = [dashboardId, companyCode];
|
||||
}
|
||||
} else if (userId) {
|
||||
// 회사 코드 없이 userId만 있는 경우 (본인 생성 또는 공개)
|
||||
dashboardQuery = `
|
||||
SELECT d.*
|
||||
FROM dashboards d
|
||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||
AND (d.created_by = $2 OR d.is_public = true)
|
||||
`;
|
||||
dashboardParams = [dashboardId, userId];
|
||||
} else {
|
||||
// 비로그인 사용자는 공개 대시보드만
|
||||
dashboardQuery = `
|
||||
SELECT d.*
|
||||
FROM dashboards d
|
||||
WHERE d.id = $1 AND d.deleted_at IS NULL
|
||||
AND d.is_public = true
|
||||
`;
|
||||
dashboardParams = [dashboardId];
|
||||
}
|
||||
|
||||
const dashboardResult = await PostgreSQLService.query(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { query, queryOne, transaction } from "../database/db";
|
||||
import { query, queryOne, transaction, getPool } from "../database/db";
|
||||
import { EventTriggerService } from "./eventTriggerService";
|
||||
import { DataflowControlService } from "./dataflowControlService";
|
||||
|
||||
|
|
@ -1635,6 +1635,69 @@ export class DynamicFormService {
|
|||
// 에러를 다시 던지지 않음 - 메인 저장 프로세스에 영향 주지 않기 위해
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 테이블의 특정 필드 값만 업데이트
|
||||
* (다른 테이블의 레코드 업데이트 지원)
|
||||
*/
|
||||
async updateFieldValue(
|
||||
tableName: string,
|
||||
keyField: string,
|
||||
keyValue: any,
|
||||
updateField: string,
|
||||
updateValue: any,
|
||||
companyCode: string,
|
||||
userId: string
|
||||
): Promise<{ affectedRows: number }> {
|
||||
const pool = getPool();
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
console.log("🔄 [updateFieldValue] 업데이트 실행:", {
|
||||
tableName,
|
||||
keyField,
|
||||
keyValue,
|
||||
updateField,
|
||||
updateValue,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
// 멀티테넌시: company_code 조건 추가 (최고관리자는 제외)
|
||||
let whereClause = `"${keyField}" = $1`;
|
||||
const params: any[] = [keyValue, updateValue, userId];
|
||||
let paramIndex = 4;
|
||||
|
||||
if (companyCode && companyCode !== "*") {
|
||||
whereClause += ` AND company_code = $${paramIndex}`;
|
||||
params.push(companyCode);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
const sqlQuery = `
|
||||
UPDATE "${tableName}"
|
||||
SET "${updateField}" = $2,
|
||||
updated_by = $3,
|
||||
updated_at = NOW()
|
||||
WHERE ${whereClause}
|
||||
`;
|
||||
|
||||
console.log("🔍 [updateFieldValue] 쿼리:", sqlQuery);
|
||||
console.log("🔍 [updateFieldValue] 파라미터:", params);
|
||||
|
||||
const result = await client.query(sqlQuery, params);
|
||||
|
||||
console.log("✅ [updateFieldValue] 결과:", {
|
||||
affectedRows: result.rowCount,
|
||||
});
|
||||
|
||||
return { affectedRows: result.rowCount || 0 };
|
||||
} catch (error) {
|
||||
console.error("❌ [updateFieldValue] 오류:", error);
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 싱글톤 인스턴스 생성 및 export
|
||||
|
|
|
|||
|
|
@ -166,6 +166,9 @@ export class ExternalRestApiConnectionService {
|
|||
? this.decryptSensitiveData(connection.auth_config)
|
||||
: null;
|
||||
|
||||
// 디버깅: 조회된 연결 정보 로깅
|
||||
logger.info(`REST API 연결 조회 결과 (ID: ${id}): connection_name=${connection.connection_name}, default_method=${connection.default_method}, endpoint_path=${connection.endpoint_path}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: connection,
|
||||
|
|
@ -227,6 +230,15 @@ export class ExternalRestApiConnectionService {
|
|||
data.created_by || "system",
|
||||
];
|
||||
|
||||
// 디버깅: 저장하려는 데이터 로깅
|
||||
logger.info(`REST API 연결 생성 요청 데이터:`, {
|
||||
connection_name: data.connection_name,
|
||||
default_method: data.default_method,
|
||||
endpoint_path: data.endpoint_path,
|
||||
base_url: data.base_url,
|
||||
default_body: data.default_body ? "있음" : "없음",
|
||||
});
|
||||
|
||||
const result: QueryResult<any> = await pool.query(query, params);
|
||||
|
||||
logger.info(`REST API 연결 생성 성공: ${data.connection_name}`);
|
||||
|
|
@ -316,12 +328,14 @@ export class ExternalRestApiConnectionService {
|
|||
updateFields.push(`default_method = $${paramIndex}`);
|
||||
params.push(data.default_method);
|
||||
paramIndex++;
|
||||
logger.info(`수정 요청 - default_method: ${data.default_method}`);
|
||||
}
|
||||
|
||||
if (data.default_body !== undefined) {
|
||||
updateFields.push(`default_request_body = $${paramIndex}`);
|
||||
params.push(data.default_body);
|
||||
params.push(data.default_body); // null이면 DB에서 NULL로 저장됨
|
||||
paramIndex++;
|
||||
logger.info(`수정 요청 - default_body: ${data.default_body ? "있음" : "삭제(null)"}`);
|
||||
}
|
||||
|
||||
if (data.auth_type !== undefined) {
|
||||
|
|
@ -885,6 +899,166 @@ export class ExternalRestApiConnectionService {
|
|||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* REST API 데이터 조회 (화면관리용 프록시)
|
||||
* 저장된 연결 정보를 사용하여 외부 REST API를 호출하고 데이터를 반환
|
||||
*/
|
||||
static async fetchData(
|
||||
connectionId: number,
|
||||
endpoint?: string,
|
||||
jsonPath?: string,
|
||||
userCompanyCode?: string
|
||||
): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
// 연결 정보 조회
|
||||
const connectionResult = await this.getConnectionById(connectionId, userCompanyCode);
|
||||
|
||||
if (!connectionResult.success || !connectionResult.data) {
|
||||
return {
|
||||
success: false,
|
||||
message: "REST API 연결을 찾을 수 없습니다.",
|
||||
error: {
|
||||
code: "CONNECTION_NOT_FOUND",
|
||||
details: `연결 ID ${connectionId}를 찾을 수 없습니다.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const connection = connectionResult.data;
|
||||
|
||||
// 비활성화된 연결인지 확인
|
||||
if (connection.is_active !== "Y") {
|
||||
return {
|
||||
success: false,
|
||||
message: "비활성화된 REST API 연결입니다.",
|
||||
error: {
|
||||
code: "CONNECTION_INACTIVE",
|
||||
details: "연결이 비활성화 상태입니다.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 엔드포인트 결정 (파라미터 > 저장된 값)
|
||||
const effectiveEndpoint = endpoint || connection.endpoint_path || "";
|
||||
|
||||
// API 호출을 위한 테스트 요청 생성
|
||||
const testRequest: RestApiTestRequest = {
|
||||
id: connection.id,
|
||||
base_url: connection.base_url,
|
||||
endpoint: effectiveEndpoint,
|
||||
method: (connection.default_method as any) || "GET",
|
||||
headers: connection.default_headers,
|
||||
body: connection.default_body,
|
||||
auth_type: connection.auth_type,
|
||||
auth_config: connection.auth_config,
|
||||
timeout: connection.timeout,
|
||||
};
|
||||
|
||||
// API 호출
|
||||
const result = await this.testConnection(testRequest, connection.company_code);
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: result.message || "REST API 호출에 실패했습니다.",
|
||||
error: {
|
||||
code: "API_CALL_FAILED",
|
||||
details: result.error_details,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 응답 데이터에서 jsonPath로 데이터 추출
|
||||
let extractedData = result.response_data;
|
||||
|
||||
logger.info(`REST API 원본 응답 데이터 타입: ${typeof result.response_data}`);
|
||||
logger.info(`REST API 원본 응답 데이터 (일부): ${JSON.stringify(result.response_data)?.substring(0, 500)}`);
|
||||
|
||||
if (jsonPath && result.response_data) {
|
||||
try {
|
||||
// jsonPath로 데이터 추출 (예: "data", "data.items", "result.list")
|
||||
const pathParts = jsonPath.split(".");
|
||||
logger.info(`JSON Path 파싱: ${jsonPath} -> [${pathParts.join(", ")}]`);
|
||||
|
||||
for (const part of pathParts) {
|
||||
if (extractedData && typeof extractedData === "object") {
|
||||
extractedData = (extractedData as any)[part];
|
||||
logger.info(`JSON Path '${part}' 추출 결과 타입: ${typeof extractedData}, 배열?: ${Array.isArray(extractedData)}`);
|
||||
} else {
|
||||
logger.warn(`JSON Path '${part}' 추출 실패: extractedData가 객체가 아님`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (pathError) {
|
||||
logger.warn(`JSON Path 추출 실패: ${jsonPath}`, pathError);
|
||||
// 추출 실패 시 원본 데이터 반환
|
||||
extractedData = result.response_data;
|
||||
}
|
||||
}
|
||||
|
||||
// 데이터가 배열이 아닌 경우 배열로 변환
|
||||
// null이나 undefined인 경우 빈 배열로 처리
|
||||
let dataArray: any[] = [];
|
||||
if (extractedData === null || extractedData === undefined) {
|
||||
logger.warn("추출된 데이터가 null/undefined입니다. 원본 응답 데이터를 사용합니다.");
|
||||
// jsonPath 추출 실패 시 원본 데이터에서 직접 컬럼 추출 시도
|
||||
if (result.response_data && typeof result.response_data === "object") {
|
||||
dataArray = Array.isArray(result.response_data) ? result.response_data : [result.response_data];
|
||||
}
|
||||
} else {
|
||||
dataArray = Array.isArray(extractedData) ? extractedData : [extractedData];
|
||||
}
|
||||
|
||||
logger.info(`최종 데이터 배열 길이: ${dataArray.length}`);
|
||||
if (dataArray.length > 0) {
|
||||
logger.info(`첫 번째 데이터 항목: ${JSON.stringify(dataArray[0])?.substring(0, 300)}`);
|
||||
}
|
||||
|
||||
// 컬럼 정보 추출 (첫 번째 유효한 데이터 기준)
|
||||
let columns: Array<{ columnName: string; columnLabel: string; dataType: string }> = [];
|
||||
|
||||
// 첫 번째 유효한 객체 찾기
|
||||
const firstValidItem = dataArray.find(item => item && typeof item === "object" && !Array.isArray(item));
|
||||
|
||||
if (firstValidItem) {
|
||||
columns = Object.keys(firstValidItem).map((key) => ({
|
||||
columnName: key,
|
||||
columnLabel: key,
|
||||
dataType: typeof firstValidItem[key],
|
||||
}));
|
||||
logger.info(`추출된 컬럼 수: ${columns.length}, 컬럼명: [${columns.map(c => c.columnName).join(", ")}]`);
|
||||
} else {
|
||||
logger.warn("유효한 데이터 항목을 찾을 수 없어 컬럼을 추출할 수 없습니다.");
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
rows: dataArray,
|
||||
columns,
|
||||
total: dataArray.length,
|
||||
connectionInfo: {
|
||||
connectionId: connection.id,
|
||||
connectionName: connection.connection_name,
|
||||
baseUrl: connection.base_url,
|
||||
endpoint: effectiveEndpoint,
|
||||
},
|
||||
},
|
||||
message: `${dataArray.length}개의 데이터를 조회했습니다.`,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("REST API 데이터 조회 오류:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "REST API 데이터 조회에 실패했습니다.",
|
||||
error: {
|
||||
code: "FETCH_ERROR",
|
||||
details: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 연결 데이터 유효성 검증
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -334,9 +334,12 @@ class MailSendSimpleService {
|
|||
if (variables) {
|
||||
buttonText = this.replaceVariables(buttonText, variables);
|
||||
}
|
||||
// styles 객체 또는 직접 속성에서 색상 가져오기
|
||||
const buttonBgColor = component.styles?.backgroundColor || component.backgroundColor || '#007bff';
|
||||
const buttonTextColor = component.styles?.color || component.textColor || '#fff';
|
||||
// 버튼은 왼쪽 정렬 (text-align 제거)
|
||||
html += `<div style="margin: 30px 0; text-align: left;">
|
||||
<a href="${component.url || '#'}" style="display: inline-block; padding: 14px 28px; background-color: ${component.backgroundColor || '#007bff'}; color: ${component.textColor || '#fff'}; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 15px;">${buttonText}</a>
|
||||
<a href="${component.url || '#'}" style="display: inline-block; padding: 14px 28px; background-color: ${buttonBgColor}; color: ${buttonTextColor}; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 15px;">${buttonText}</a>
|
||||
</div>`;
|
||||
break;
|
||||
case 'image':
|
||||
|
|
@ -348,6 +351,89 @@ class MailSendSimpleService {
|
|||
case 'spacer':
|
||||
html += `<div style="height: ${component.height || '20px'};"></div>`;
|
||||
break;
|
||||
case 'header':
|
||||
html += `
|
||||
<div style="padding: 20px; background-color: ${component.headerBgColor || '#f8f9fa'}; border-radius: 8px; margin-bottom: 20px;">
|
||||
<table style="width: 100%;">
|
||||
<tr>
|
||||
<td style="vertical-align: middle;">
|
||||
${component.logoSrc ? `<img src="${component.logoSrc}" alt="로고" style="height: 40px; margin-right: 12px;">` : ''}
|
||||
<span style="font-size: 18px; font-weight: bold;">${component.brandName || ''}</span>
|
||||
</td>
|
||||
<td style="text-align: right; color: #6b7280; font-size: 14px;">
|
||||
${component.sendDate || ''}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case 'infoTable':
|
||||
html += `
|
||||
<div style="border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; margin: 16px 0;">
|
||||
${component.tableTitle ? `<div style="background-color: #f9fafb; padding: 12px 16px; font-weight: 600; border-bottom: 1px solid #e5e7eb;">${component.tableTitle}</div>` : ''}
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
${(component.rows || []).map((row: any, i: number) => `
|
||||
<tr style="background-color: ${i % 2 === 0 ? '#ffffff' : '#f9fafb'};">
|
||||
<td style="padding: 12px 16px; font-weight: 500; color: #4b5563; width: 35%; border-right: 1px solid #e5e7eb;">${row.label}</td>
|
||||
<td style="padding: 12px 16px;">${row.value}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case 'alertBox':
|
||||
const alertColors: Record<string, { bg: string; border: string; text: string }> = {
|
||||
info: { bg: '#eff6ff', border: '#3b82f6', text: '#1e40af' },
|
||||
warning: { bg: '#fffbeb', border: '#f59e0b', text: '#92400e' },
|
||||
danger: { bg: '#fef2f2', border: '#ef4444', text: '#991b1b' },
|
||||
success: { bg: '#ecfdf5', border: '#10b981', text: '#065f46' }
|
||||
};
|
||||
const colors = alertColors[component.alertType || 'info'];
|
||||
html += `
|
||||
<div style="padding: 16px; background-color: ${colors.bg}; border-left: 4px solid ${colors.border}; border-radius: 4px; margin: 16px 0; color: ${colors.text};">
|
||||
${component.alertTitle ? `<div style="font-weight: bold; margin-bottom: 8px;">${component.alertTitle}</div>` : ''}
|
||||
<div>${component.content || ''}</div>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case 'divider':
|
||||
html += `<hr style="border: none; border-top: ${component.height || 1}px solid #e5e7eb; margin: 20px 0;">`;
|
||||
break;
|
||||
case 'footer':
|
||||
html += `
|
||||
<div style="text-align: center; padding: 24px 16px; background-color: #f9fafb; border-top: 1px solid #e5e7eb; font-size: 14px; color: #6b7280;">
|
||||
${component.companyName ? `<div style="font-weight: 600; color: #374151; margin-bottom: 8px;">${component.companyName}</div>` : ''}
|
||||
${(component.ceoName || component.businessNumber) ? `
|
||||
<div style="margin-bottom: 4px;">
|
||||
${component.ceoName ? `대표: ${component.ceoName}` : ''}
|
||||
${component.ceoName && component.businessNumber ? ' | ' : ''}
|
||||
${component.businessNumber ? `사업자등록번호: ${component.businessNumber}` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
${component.address ? `<div style="margin-bottom: 4px;">${component.address}</div>` : ''}
|
||||
${(component.phone || component.email) ? `
|
||||
<div style="margin-bottom: 4px;">
|
||||
${component.phone ? `Tel: ${component.phone}` : ''}
|
||||
${component.phone && component.email ? ' | ' : ''}
|
||||
${component.email ? `Email: ${component.email}` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
${component.copyright ? `<div style="margin-top: 12px; font-size: 12px; color: #9ca3af;">${component.copyright}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case 'numberedList':
|
||||
html += `
|
||||
<div style="padding: 16px;">
|
||||
${component.listTitle ? `<div style="font-weight: 600; margin-bottom: 12px;">${component.listTitle}</div>` : ''}
|
||||
<ol style="margin: 0; padding-left: 20px;">
|
||||
${(component.listItems || []).map((item: string) => `<li style="margin-bottom: 8px;">${item}</li>`).join('')}
|
||||
</ol>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -4,13 +4,35 @@ import path from "path";
|
|||
// MailComponent 인터페이스 정의
|
||||
export interface MailComponent {
|
||||
id: string;
|
||||
type: "text" | "button" | "image" | "spacer";
|
||||
type: "text" | "button" | "image" | "spacer" | "header" | "infoTable" | "alertBox" | "divider" | "footer" | "numberedList";
|
||||
content?: string;
|
||||
text?: string;
|
||||
url?: string;
|
||||
src?: string;
|
||||
height?: number;
|
||||
styles?: Record<string, string>;
|
||||
// 헤더 컴포넌트용
|
||||
logoSrc?: string;
|
||||
brandName?: string;
|
||||
sendDate?: string;
|
||||
headerBgColor?: string;
|
||||
// 정보 테이블용
|
||||
rows?: Array<{ label: string; value: string }>;
|
||||
tableTitle?: string;
|
||||
// 강조 박스용
|
||||
alertType?: "info" | "warning" | "danger" | "success";
|
||||
alertTitle?: string;
|
||||
// 푸터용
|
||||
companyName?: string;
|
||||
ceoName?: string;
|
||||
businessNumber?: string;
|
||||
address?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
copyright?: string;
|
||||
// 번호 리스트용
|
||||
listItems?: string[];
|
||||
listTitle?: string;
|
||||
}
|
||||
|
||||
// QueryConfig 인터페이스 정의 (사용하지 않지만 타입 호환성 유지)
|
||||
|
|
@ -236,6 +258,89 @@ class MailTemplateFileService {
|
|||
case "spacer":
|
||||
html += `<div style="height: ${comp.height || 20}px;"></div>`;
|
||||
break;
|
||||
case "header":
|
||||
html += `
|
||||
<div style="padding: 20px; background-color: ${comp.headerBgColor || '#f8f9fa'}; border-radius: 8px; margin-bottom: 20px;">
|
||||
<table style="width: 100%;">
|
||||
<tr>
|
||||
<td style="vertical-align: middle;">
|
||||
${comp.logoSrc ? `<img src="${comp.logoSrc}" alt="로고" style="height: 40px; margin-right: 12px;">` : ''}
|
||||
<span style="font-size: 18px; font-weight: bold;">${comp.brandName || ''}</span>
|
||||
</td>
|
||||
<td style="text-align: right; color: #6b7280; font-size: 14px;">
|
||||
${comp.sendDate || ''}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case "infoTable":
|
||||
html += `
|
||||
<div style="border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; margin: 16px 0;">
|
||||
${comp.tableTitle ? `<div style="background-color: #f9fafb; padding: 12px 16px; font-weight: 600; border-bottom: 1px solid #e5e7eb;">${comp.tableTitle}</div>` : ''}
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
${(comp.rows || []).map((row, i) => `
|
||||
<tr style="background-color: ${i % 2 === 0 ? '#ffffff' : '#f9fafb'};">
|
||||
<td style="padding: 12px 16px; font-weight: 500; color: #4b5563; width: 35%; border-right: 1px solid #e5e7eb;">${row.label}</td>
|
||||
<td style="padding: 12px 16px;">${row.value}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case "alertBox":
|
||||
const alertColors: Record<string, { bg: string; border: string; text: string }> = {
|
||||
info: { bg: '#eff6ff', border: '#3b82f6', text: '#1e40af' },
|
||||
warning: { bg: '#fffbeb', border: '#f59e0b', text: '#92400e' },
|
||||
danger: { bg: '#fef2f2', border: '#ef4444', text: '#991b1b' },
|
||||
success: { bg: '#ecfdf5', border: '#10b981', text: '#065f46' }
|
||||
};
|
||||
const colors = alertColors[comp.alertType || 'info'];
|
||||
html += `
|
||||
<div style="padding: 16px; background-color: ${colors.bg}; border-left: 4px solid ${colors.border}; border-radius: 4px; margin: 16px 0; color: ${colors.text};">
|
||||
${comp.alertTitle ? `<div style="font-weight: bold; margin-bottom: 8px;">${comp.alertTitle}</div>` : ''}
|
||||
<div>${comp.content || ''}</div>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case "divider":
|
||||
html += `<hr style="border: none; border-top: ${comp.height || 1}px solid #e5e7eb; margin: 20px 0;">`;
|
||||
break;
|
||||
case "footer":
|
||||
html += `
|
||||
<div style="text-align: center; padding: 24px 16px; background-color: #f9fafb; border-top: 1px solid #e5e7eb; font-size: 14px; color: #6b7280;">
|
||||
${comp.companyName ? `<div style="font-weight: 600; color: #374151; margin-bottom: 8px;">${comp.companyName}</div>` : ''}
|
||||
${(comp.ceoName || comp.businessNumber) ? `
|
||||
<div style="margin-bottom: 4px;">
|
||||
${comp.ceoName ? `대표: ${comp.ceoName}` : ''}
|
||||
${comp.ceoName && comp.businessNumber ? ' | ' : ''}
|
||||
${comp.businessNumber ? `사업자등록번호: ${comp.businessNumber}` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
${comp.address ? `<div style="margin-bottom: 4px;">${comp.address}</div>` : ''}
|
||||
${(comp.phone || comp.email) ? `
|
||||
<div style="margin-bottom: 4px;">
|
||||
${comp.phone ? `Tel: ${comp.phone}` : ''}
|
||||
${comp.phone && comp.email ? ' | ' : ''}
|
||||
${comp.email ? `Email: ${comp.email}` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
${comp.copyright ? `<div style="margin-top: 12px; font-size: 12px; color: #9ca3af;">${comp.copyright}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case "numberedList":
|
||||
html += `
|
||||
<div style="padding: 16px; ${styles}">
|
||||
${comp.listTitle ? `<div style="font-weight: 600; margin-bottom: 12px;">${comp.listTitle}</div>` : ''}
|
||||
<ol style="margin: 0; padding-left: 20px;">
|
||||
${(comp.listItems || []).map(item => `<li style="margin-bottom: 8px;">${item}</li>`).join('')}
|
||||
</ol>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -10,10 +10,6 @@ export interface MenuCopyResult {
|
|||
copiedMenus: number;
|
||||
copiedScreens: number;
|
||||
copiedFlows: number;
|
||||
copiedCategories: number;
|
||||
copiedCodes: number;
|
||||
copiedCategorySettings: number;
|
||||
copiedNumberingRules: number;
|
||||
menuIdMap: Record<number, number>;
|
||||
screenIdMap: Record<number, number>;
|
||||
flowIdMap: Record<number, number>;
|
||||
|
|
@ -129,35 +125,6 @@ interface FlowStepConnection {
|
|||
label: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 카테고리
|
||||
*/
|
||||
interface CodeCategory {
|
||||
category_code: string;
|
||||
category_name: string;
|
||||
category_name_eng: string | null;
|
||||
description: string | null;
|
||||
sort_order: number | null;
|
||||
is_active: string;
|
||||
company_code: string;
|
||||
menu_objid: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 정보
|
||||
*/
|
||||
interface CodeInfo {
|
||||
code_category: string;
|
||||
code_value: string;
|
||||
code_name: string;
|
||||
code_name_eng: string | null;
|
||||
description: string | null;
|
||||
sort_order: number | null;
|
||||
is_active: string;
|
||||
company_code: string;
|
||||
menu_objid: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴 복사 서비스
|
||||
*/
|
||||
|
|
@ -249,6 +216,24 @@ export class MenuCopyService {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 탭 컴포넌트 (tabs 배열 내부의 screenId)
|
||||
if (
|
||||
props?.componentConfig?.tabs &&
|
||||
Array.isArray(props.componentConfig.tabs)
|
||||
) {
|
||||
for (const tab of props.componentConfig.tabs) {
|
||||
if (tab.screenId) {
|
||||
const screenId = tab.screenId;
|
||||
const numId =
|
||||
typeof screenId === "number" ? screenId : parseInt(screenId);
|
||||
if (!isNaN(numId)) {
|
||||
referenced.push(numId);
|
||||
logger.debug(` 📑 탭 컴포넌트에서 화면 참조 발견: ${numId} (탭: ${tab.label || tab.id})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return referenced;
|
||||
|
|
@ -355,127 +340,6 @@ export class MenuCopyService {
|
|||
return flowIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 수집
|
||||
*/
|
||||
private async collectCodes(
|
||||
menuObjids: number[],
|
||||
sourceCompanyCode: string,
|
||||
client: PoolClient
|
||||
): Promise<{ categories: CodeCategory[]; codes: CodeInfo[] }> {
|
||||
logger.info(`📋 코드 수집 시작: ${menuObjids.length}개 메뉴`);
|
||||
|
||||
const categories: CodeCategory[] = [];
|
||||
const codes: CodeInfo[] = [];
|
||||
|
||||
for (const menuObjid of menuObjids) {
|
||||
// 코드 카테고리
|
||||
const catsResult = await client.query<CodeCategory>(
|
||||
`SELECT * FROM code_category
|
||||
WHERE menu_objid = $1 AND company_code = $2`,
|
||||
[menuObjid, sourceCompanyCode]
|
||||
);
|
||||
categories.push(...catsResult.rows);
|
||||
|
||||
// 각 카테고리의 코드 정보
|
||||
for (const cat of catsResult.rows) {
|
||||
const codesResult = await client.query<CodeInfo>(
|
||||
`SELECT * FROM code_info
|
||||
WHERE code_category = $1 AND menu_objid = $2 AND company_code = $3`,
|
||||
[cat.category_code, menuObjid, sourceCompanyCode]
|
||||
);
|
||||
codes.push(...codesResult.rows);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`✅ 코드 수집 완료: 카테고리 ${categories.length}개, 코드 ${codes.length}개`
|
||||
);
|
||||
return { categories, codes };
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 설정 수집
|
||||
*/
|
||||
private async collectCategorySettings(
|
||||
menuObjids: number[],
|
||||
sourceCompanyCode: string,
|
||||
client: PoolClient
|
||||
): Promise<{
|
||||
columnMappings: any[];
|
||||
categoryValues: any[];
|
||||
}> {
|
||||
logger.info(`📂 카테고리 설정 수집 시작: ${menuObjids.length}개 메뉴`);
|
||||
|
||||
const columnMappings: any[] = [];
|
||||
const categoryValues: any[] = [];
|
||||
|
||||
// 카테고리 컬럼 매핑 (메뉴별 + 공통)
|
||||
const mappingsResult = await client.query(
|
||||
`SELECT * FROM category_column_mapping
|
||||
WHERE (menu_objid = ANY($1) OR menu_objid = 0)
|
||||
AND company_code = $2`,
|
||||
[menuObjids, sourceCompanyCode]
|
||||
);
|
||||
columnMappings.push(...mappingsResult.rows);
|
||||
|
||||
// 테이블 컬럼 카테고리 값 (메뉴별 + 공통)
|
||||
const valuesResult = await client.query(
|
||||
`SELECT * FROM table_column_category_values
|
||||
WHERE (menu_objid = ANY($1) OR menu_objid = 0)
|
||||
AND company_code = $2`,
|
||||
[menuObjids, sourceCompanyCode]
|
||||
);
|
||||
categoryValues.push(...valuesResult.rows);
|
||||
|
||||
logger.info(
|
||||
`✅ 카테고리 설정 수집 완료: 컬럼 매핑 ${columnMappings.length}개 (공통 포함), 카테고리 값 ${categoryValues.length}개 (공통 포함)`
|
||||
);
|
||||
return { columnMappings, categoryValues };
|
||||
}
|
||||
|
||||
/**
|
||||
* 채번 규칙 수집
|
||||
*/
|
||||
private async collectNumberingRules(
|
||||
menuObjids: number[],
|
||||
sourceCompanyCode: string,
|
||||
client: PoolClient
|
||||
): Promise<{
|
||||
rules: any[];
|
||||
parts: any[];
|
||||
}> {
|
||||
logger.info(`📋 채번 규칙 수집 시작: ${menuObjids.length}개 메뉴`);
|
||||
|
||||
const rules: any[] = [];
|
||||
const parts: any[] = [];
|
||||
|
||||
for (const menuObjid of menuObjids) {
|
||||
// 채번 규칙
|
||||
const rulesResult = await client.query(
|
||||
`SELECT * FROM numbering_rules
|
||||
WHERE menu_objid = $1 AND company_code = $2`,
|
||||
[menuObjid, sourceCompanyCode]
|
||||
);
|
||||
rules.push(...rulesResult.rows);
|
||||
|
||||
// 각 규칙의 파트
|
||||
for (const rule of rulesResult.rows) {
|
||||
const partsResult = await client.query(
|
||||
`SELECT * FROM numbering_rule_parts
|
||||
WHERE rule_id = $1 AND company_code = $2`,
|
||||
[rule.rule_id, sourceCompanyCode]
|
||||
);
|
||||
parts.push(...partsResult.rows);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`✅ 채번 규칙 수집 완료: 규칙 ${rules.length}개, 파트 ${parts.length}개`
|
||||
);
|
||||
return { rules, parts };
|
||||
}
|
||||
|
||||
/**
|
||||
* 다음 메뉴 objid 생성
|
||||
*/
|
||||
|
|
@ -709,42 +573,8 @@ export class MenuCopyService {
|
|||
]);
|
||||
logger.info(` ✅ 메뉴 권한 삭제 완료`);
|
||||
|
||||
// 5-5. 채번 규칙 파트 삭제
|
||||
await client.query(
|
||||
`DELETE FROM numbering_rule_parts
|
||||
WHERE rule_id IN (
|
||||
SELECT rule_id FROM numbering_rules
|
||||
WHERE menu_objid = ANY($1) AND company_code = $2
|
||||
)`,
|
||||
[existingMenuIds, targetCompanyCode]
|
||||
);
|
||||
logger.info(` ✅ 채번 규칙 파트 삭제 완료`);
|
||||
|
||||
// 5-6. 채번 규칙 삭제
|
||||
await client.query(
|
||||
`DELETE FROM numbering_rules
|
||||
WHERE menu_objid = ANY($1) AND company_code = $2`,
|
||||
[existingMenuIds, targetCompanyCode]
|
||||
);
|
||||
logger.info(` ✅ 채번 규칙 삭제 완료`);
|
||||
|
||||
// 5-7. 테이블 컬럼 카테고리 값 삭제
|
||||
await client.query(
|
||||
`DELETE FROM table_column_category_values
|
||||
WHERE menu_objid = ANY($1) AND company_code = $2`,
|
||||
[existingMenuIds, targetCompanyCode]
|
||||
);
|
||||
logger.info(` ✅ 카테고리 값 삭제 완료`);
|
||||
|
||||
// 5-8. 카테고리 컬럼 매핑 삭제
|
||||
await client.query(
|
||||
`DELETE FROM category_column_mapping
|
||||
WHERE menu_objid = ANY($1) AND company_code = $2`,
|
||||
[existingMenuIds, targetCompanyCode]
|
||||
);
|
||||
logger.info(` ✅ 카테고리 매핑 삭제 완료`);
|
||||
|
||||
// 5-9. 메뉴 삭제 (역순: 하위 메뉴부터)
|
||||
// 5-5. 메뉴 삭제 (역순: 하위 메뉴부터)
|
||||
// 주의: 채번 규칙과 카테고리 설정은 회사마다 고유하므로 삭제하지 않음
|
||||
for (let i = existingMenus.length - 1; i >= 0; i--) {
|
||||
await client.query(`DELETE FROM menu_info WHERE objid = $1`, [
|
||||
existingMenus[i].objid,
|
||||
|
|
@ -801,33 +631,11 @@ export class MenuCopyService {
|
|||
|
||||
const flowIds = await this.collectFlows(screenIds, client);
|
||||
|
||||
const codes = await this.collectCodes(
|
||||
menus.map((m) => m.objid),
|
||||
sourceCompanyCode,
|
||||
client
|
||||
);
|
||||
|
||||
const categorySettings = await this.collectCategorySettings(
|
||||
menus.map((m) => m.objid),
|
||||
sourceCompanyCode,
|
||||
client
|
||||
);
|
||||
|
||||
const numberingRules = await this.collectNumberingRules(
|
||||
menus.map((m) => m.objid),
|
||||
sourceCompanyCode,
|
||||
client
|
||||
);
|
||||
|
||||
logger.info(`
|
||||
📊 수집 완료:
|
||||
- 메뉴: ${menus.length}개
|
||||
- 화면: ${screenIds.size}개
|
||||
- 플로우: ${flowIds.size}개
|
||||
- 코드 카테고리: ${codes.categories.length}개
|
||||
- 코드: ${codes.codes.length}개
|
||||
- 카테고리 설정: 컬럼 매핑 ${categorySettings.columnMappings.length}개, 카테고리 값 ${categorySettings.categoryValues.length}개
|
||||
- 채번 규칙: 규칙 ${numberingRules.rules.length}개, 파트 ${numberingRules.parts.length}개
|
||||
`);
|
||||
|
||||
// === 2단계: 플로우 복사 ===
|
||||
|
|
@ -871,30 +679,6 @@ export class MenuCopyService {
|
|||
client
|
||||
);
|
||||
|
||||
// === 6단계: 코드 복사 ===
|
||||
logger.info("\n📋 [6단계] 코드 복사");
|
||||
await this.copyCodes(codes, menuIdMap, targetCompanyCode, userId, client);
|
||||
|
||||
// === 7단계: 카테고리 설정 복사 ===
|
||||
logger.info("\n📂 [7단계] 카테고리 설정 복사");
|
||||
await this.copyCategorySettings(
|
||||
categorySettings,
|
||||
menuIdMap,
|
||||
targetCompanyCode,
|
||||
userId,
|
||||
client
|
||||
);
|
||||
|
||||
// === 8단계: 채번 규칙 복사 ===
|
||||
logger.info("\n📋 [8단계] 채번 규칙 복사");
|
||||
await this.copyNumberingRules(
|
||||
numberingRules,
|
||||
menuIdMap,
|
||||
targetCompanyCode,
|
||||
userId,
|
||||
client
|
||||
);
|
||||
|
||||
// 커밋
|
||||
await client.query("COMMIT");
|
||||
logger.info("✅ 트랜잭션 커밋 완료");
|
||||
|
|
@ -904,13 +688,6 @@ export class MenuCopyService {
|
|||
copiedMenus: menuIdMap.size,
|
||||
copiedScreens: screenIdMap.size,
|
||||
copiedFlows: flowIdMap.size,
|
||||
copiedCategories: codes.categories.length,
|
||||
copiedCodes: codes.codes.length,
|
||||
copiedCategorySettings:
|
||||
categorySettings.columnMappings.length +
|
||||
categorySettings.categoryValues.length,
|
||||
copiedNumberingRules:
|
||||
numberingRules.rules.length + numberingRules.parts.length,
|
||||
menuIdMap: Object.fromEntries(menuIdMap),
|
||||
screenIdMap: Object.fromEntries(screenIdMap),
|
||||
flowIdMap: Object.fromEntries(flowIdMap),
|
||||
|
|
@ -923,10 +700,8 @@ export class MenuCopyService {
|
|||
- 메뉴: ${result.copiedMenus}개
|
||||
- 화면: ${result.copiedScreens}개
|
||||
- 플로우: ${result.copiedFlows}개
|
||||
- 코드 카테고리: ${result.copiedCategories}개
|
||||
- 코드: ${result.copiedCodes}개
|
||||
- 카테고리 설정: ${result.copiedCategorySettings}개
|
||||
- 채번 규칙: ${result.copiedNumberingRules}개
|
||||
|
||||
⚠️ 주의: 코드, 카테고리 설정, 채번 규칙은 복사되지 않습니다.
|
||||
============================================
|
||||
`);
|
||||
|
||||
|
|
@ -1125,13 +900,31 @@ export class MenuCopyService {
|
|||
|
||||
const screenDef = screenDefResult.rows[0];
|
||||
|
||||
// 2) 새 screen_code 생성
|
||||
// 2) 중복 체크: 같은 screen_code가 대상 회사에 이미 있는지 확인
|
||||
const existingScreenResult = await client.query<{ screen_id: number }>(
|
||||
`SELECT screen_id FROM screen_definitions
|
||||
WHERE screen_code = $1 AND company_code = $2 AND deleted_date IS NULL
|
||||
LIMIT 1`,
|
||||
[screenDef.screen_code, targetCompanyCode]
|
||||
);
|
||||
|
||||
if (existingScreenResult.rows.length > 0) {
|
||||
// 이미 존재하는 화면 - 복사하지 않고 기존 ID 매핑
|
||||
const existingScreenId = existingScreenResult.rows[0].screen_id;
|
||||
screenIdMap.set(originalScreenId, existingScreenId);
|
||||
logger.info(
|
||||
` ⏭️ 화면 이미 존재 (스킵): ${originalScreenId} → ${existingScreenId} (${screenDef.screen_code})`
|
||||
);
|
||||
continue; // 레이아웃 복사도 스킵
|
||||
}
|
||||
|
||||
// 3) 새 screen_code 생성
|
||||
const newScreenCode = await this.generateUniqueScreenCode(
|
||||
targetCompanyCode,
|
||||
client
|
||||
);
|
||||
|
||||
// 2-1) 화면명 변환 적용
|
||||
// 4) 화면명 변환 적용
|
||||
let transformedScreenName = screenDef.screen_name;
|
||||
if (screenNameConfig) {
|
||||
// 1. 제거할 텍스트 제거
|
||||
|
|
@ -1150,7 +943,7 @@ export class MenuCopyService {
|
|||
}
|
||||
}
|
||||
|
||||
// 3) screen_definitions 복사 (deleted 필드는 NULL로 설정, 삭제된 화면도 활성화)
|
||||
// 5) screen_definitions 복사 (deleted 필드는 NULL로 설정, 삭제된 화면도 활성화)
|
||||
const newScreenResult = await client.query<{ screen_id: number }>(
|
||||
`INSERT INTO screen_definitions (
|
||||
screen_name, screen_code, table_name, company_code,
|
||||
|
|
@ -1479,383 +1272,4 @@ export class MenuCopyService {
|
|||
logger.info(`✅ 화면-메뉴 할당 완료: ${assignmentCount}개`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 카테고리 중복 체크
|
||||
*/
|
||||
private async checkCodeCategoryExists(
|
||||
categoryCode: string,
|
||||
companyCode: string,
|
||||
menuObjid: number,
|
||||
client: PoolClient
|
||||
): Promise<boolean> {
|
||||
const result = await client.query<{ exists: boolean }>(
|
||||
`SELECT EXISTS(
|
||||
SELECT 1 FROM code_category
|
||||
WHERE category_code = $1 AND company_code = $2 AND menu_objid = $3
|
||||
) as exists`,
|
||||
[categoryCode, companyCode, menuObjid]
|
||||
);
|
||||
return result.rows[0].exists;
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 정보 중복 체크
|
||||
*/
|
||||
private async checkCodeInfoExists(
|
||||
categoryCode: string,
|
||||
codeValue: string,
|
||||
companyCode: string,
|
||||
menuObjid: number,
|
||||
client: PoolClient
|
||||
): Promise<boolean> {
|
||||
const result = await client.query<{ exists: boolean }>(
|
||||
`SELECT EXISTS(
|
||||
SELECT 1 FROM code_info
|
||||
WHERE code_category = $1 AND code_value = $2
|
||||
AND company_code = $3 AND menu_objid = $4
|
||||
) as exists`,
|
||||
[categoryCode, codeValue, companyCode, menuObjid]
|
||||
);
|
||||
return result.rows[0].exists;
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 복사
|
||||
*/
|
||||
private async copyCodes(
|
||||
codes: { categories: CodeCategory[]; codes: CodeInfo[] },
|
||||
menuIdMap: Map<number, number>,
|
||||
targetCompanyCode: string,
|
||||
userId: string,
|
||||
client: PoolClient
|
||||
): Promise<void> {
|
||||
logger.info(`📋 코드 복사 중...`);
|
||||
|
||||
let categoryCount = 0;
|
||||
let codeCount = 0;
|
||||
let skippedCategories = 0;
|
||||
let skippedCodes = 0;
|
||||
|
||||
// 1) 코드 카테고리 복사 (중복 체크)
|
||||
for (const category of codes.categories) {
|
||||
const newMenuObjid = menuIdMap.get(category.menu_objid);
|
||||
if (!newMenuObjid) continue;
|
||||
|
||||
// 중복 체크
|
||||
const exists = await this.checkCodeCategoryExists(
|
||||
category.category_code,
|
||||
targetCompanyCode,
|
||||
newMenuObjid,
|
||||
client
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
skippedCategories++;
|
||||
logger.debug(
|
||||
` ⏭️ 카테고리 이미 존재: ${category.category_code} (menu_objid=${newMenuObjid})`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 카테고리 복사
|
||||
await client.query(
|
||||
`INSERT INTO code_category (
|
||||
category_code, category_name, category_name_eng, description,
|
||||
sort_order, is_active, company_code, menu_objid, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[
|
||||
category.category_code,
|
||||
category.category_name,
|
||||
category.category_name_eng,
|
||||
category.description,
|
||||
category.sort_order,
|
||||
category.is_active,
|
||||
targetCompanyCode, // 새 회사 코드
|
||||
newMenuObjid, // 재매핑
|
||||
userId,
|
||||
]
|
||||
);
|
||||
|
||||
categoryCount++;
|
||||
}
|
||||
|
||||
// 2) 코드 정보 복사 (중복 체크)
|
||||
for (const code of codes.codes) {
|
||||
const newMenuObjid = menuIdMap.get(code.menu_objid);
|
||||
if (!newMenuObjid) continue;
|
||||
|
||||
// 중복 체크
|
||||
const exists = await this.checkCodeInfoExists(
|
||||
code.code_category,
|
||||
code.code_value,
|
||||
targetCompanyCode,
|
||||
newMenuObjid,
|
||||
client
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
skippedCodes++;
|
||||
logger.debug(
|
||||
` ⏭️ 코드 이미 존재: ${code.code_category}.${code.code_value} (menu_objid=${newMenuObjid})`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 코드 복사
|
||||
await client.query(
|
||||
`INSERT INTO code_info (
|
||||
code_category, code_value, code_name, code_name_eng, description,
|
||||
sort_order, is_active, company_code, menu_objid, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||
[
|
||||
code.code_category,
|
||||
code.code_value,
|
||||
code.code_name,
|
||||
code.code_name_eng,
|
||||
code.description,
|
||||
code.sort_order,
|
||||
code.is_active,
|
||||
targetCompanyCode, // 새 회사 코드
|
||||
newMenuObjid, // 재매핑
|
||||
userId,
|
||||
]
|
||||
);
|
||||
|
||||
codeCount++;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`✅ 코드 복사 완료: 카테고리 ${categoryCount}개 (${skippedCategories}개 스킵), 코드 ${codeCount}개 (${skippedCodes}개 스킵)`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 설정 복사
|
||||
*/
|
||||
private async copyCategorySettings(
|
||||
settings: { columnMappings: any[]; categoryValues: any[] },
|
||||
menuIdMap: Map<number, number>,
|
||||
targetCompanyCode: string,
|
||||
userId: string,
|
||||
client: PoolClient
|
||||
): Promise<void> {
|
||||
logger.info(`📂 카테고리 설정 복사 중...`);
|
||||
|
||||
const valueIdMap = new Map<number, number>(); // 원본 value_id → 새 value_id
|
||||
let mappingCount = 0;
|
||||
let valueCount = 0;
|
||||
|
||||
// 1) 카테고리 컬럼 매핑 복사 (덮어쓰기 모드)
|
||||
for (const mapping of settings.columnMappings) {
|
||||
// menu_objid = 0인 공통 설정은 그대로 0으로 유지
|
||||
let newMenuObjid: number | undefined;
|
||||
|
||||
if (
|
||||
mapping.menu_objid === 0 ||
|
||||
mapping.menu_objid === "0" ||
|
||||
mapping.menu_objid == 0
|
||||
) {
|
||||
newMenuObjid = 0; // 공통 설정
|
||||
} else {
|
||||
newMenuObjid = menuIdMap.get(mapping.menu_objid);
|
||||
if (newMenuObjid === undefined) {
|
||||
logger.debug(
|
||||
` ⏭️ 매핑할 메뉴가 없음: menu_objid=${mapping.menu_objid}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 기존 매핑 삭제 (덮어쓰기)
|
||||
await client.query(
|
||||
`DELETE FROM category_column_mapping
|
||||
WHERE table_name = $1 AND physical_column_name = $2 AND company_code = $3`,
|
||||
[mapping.table_name, mapping.physical_column_name, targetCompanyCode]
|
||||
);
|
||||
|
||||
// 새 매핑 추가
|
||||
await client.query(
|
||||
`INSERT INTO category_column_mapping (
|
||||
table_name, logical_column_name, physical_column_name,
|
||||
menu_objid, company_code, description, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[
|
||||
mapping.table_name,
|
||||
mapping.logical_column_name,
|
||||
mapping.physical_column_name,
|
||||
newMenuObjid,
|
||||
targetCompanyCode,
|
||||
mapping.description,
|
||||
userId,
|
||||
]
|
||||
);
|
||||
|
||||
mappingCount++;
|
||||
}
|
||||
|
||||
// 2) 테이블 컬럼 카테고리 값 복사 (덮어쓰기 모드, 부모-자식 관계 유지)
|
||||
const sortedValues = settings.categoryValues.sort(
|
||||
(a, b) => a.depth - b.depth
|
||||
);
|
||||
|
||||
// 먼저 기존 값들을 모두 삭제 (테이블+컬럼 단위)
|
||||
const uniqueTableColumns = new Set<string>();
|
||||
for (const value of sortedValues) {
|
||||
uniqueTableColumns.add(`${value.table_name}:${value.column_name}`);
|
||||
}
|
||||
|
||||
for (const tableColumn of uniqueTableColumns) {
|
||||
const [tableName, columnName] = tableColumn.split(":");
|
||||
await client.query(
|
||||
`DELETE FROM table_column_category_values
|
||||
WHERE table_name = $1 AND column_name = $2 AND company_code = $3`,
|
||||
[tableName, columnName, targetCompanyCode]
|
||||
);
|
||||
logger.debug(` 🗑️ 기존 카테고리 값 삭제: ${tableName}.${columnName}`);
|
||||
}
|
||||
|
||||
// 새 값 추가
|
||||
for (const value of sortedValues) {
|
||||
// menu_objid = 0인 공통 설정은 그대로 0으로 유지
|
||||
let newMenuObjid: number | undefined;
|
||||
|
||||
if (
|
||||
value.menu_objid === 0 ||
|
||||
value.menu_objid === "0" ||
|
||||
value.menu_objid == 0
|
||||
) {
|
||||
newMenuObjid = 0; // 공통 설정
|
||||
} else {
|
||||
newMenuObjid = menuIdMap.get(value.menu_objid);
|
||||
if (newMenuObjid === undefined) {
|
||||
logger.debug(
|
||||
` ⏭️ 매핑할 메뉴가 없음: menu_objid=${value.menu_objid}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 부모 ID 재매핑
|
||||
let newParentValueId = null;
|
||||
if (value.parent_value_id) {
|
||||
newParentValueId = valueIdMap.get(value.parent_value_id) || null;
|
||||
}
|
||||
|
||||
const result = await client.query(
|
||||
`INSERT INTO table_column_category_values (
|
||||
table_name, column_name, value_code, value_label,
|
||||
value_order, parent_value_id, depth, description,
|
||||
color, icon, is_active, is_default,
|
||||
company_code, menu_objid, created_by
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||
RETURNING value_id`,
|
||||
[
|
||||
value.table_name,
|
||||
value.column_name,
|
||||
value.value_code,
|
||||
value.value_label,
|
||||
value.value_order,
|
||||
newParentValueId,
|
||||
value.depth,
|
||||
value.description,
|
||||
value.color,
|
||||
value.icon,
|
||||
value.is_active,
|
||||
value.is_default,
|
||||
targetCompanyCode,
|
||||
newMenuObjid,
|
||||
userId,
|
||||
]
|
||||
);
|
||||
|
||||
// ID 매핑 저장
|
||||
const newValueId = result.rows[0].value_id;
|
||||
valueIdMap.set(value.value_id, newValueId);
|
||||
|
||||
valueCount++;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`✅ 카테고리 설정 복사 완료: 컬럼 매핑 ${mappingCount}개, 카테고리 값 ${valueCount}개 (덮어쓰기)`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 채번 규칙 복사
|
||||
*/
|
||||
private async copyNumberingRules(
|
||||
rules: { rules: any[]; parts: any[] },
|
||||
menuIdMap: Map<number, number>,
|
||||
targetCompanyCode: string,
|
||||
userId: string,
|
||||
client: PoolClient
|
||||
): Promise<void> {
|
||||
logger.info(`📋 채번 규칙 복사 중...`);
|
||||
|
||||
const ruleIdMap = new Map<string, string>(); // 원본 rule_id → 새 rule_id
|
||||
let ruleCount = 0;
|
||||
let partCount = 0;
|
||||
|
||||
// 1) 채번 규칙 복사
|
||||
for (const rule of rules.rules) {
|
||||
const newMenuObjid = menuIdMap.get(rule.menu_objid);
|
||||
if (!newMenuObjid) continue;
|
||||
|
||||
// 새 rule_id 생성 (타임스탬프 기반)
|
||||
const newRuleId = `rule-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
ruleIdMap.set(rule.rule_id, newRuleId);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO numbering_rules (
|
||||
rule_id, rule_name, description, separator,
|
||||
reset_period, current_sequence, table_name, column_name,
|
||||
company_code, menu_objid, created_by, scope_type
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
|
||||
[
|
||||
newRuleId,
|
||||
rule.rule_name,
|
||||
rule.description,
|
||||
rule.separator,
|
||||
rule.reset_period,
|
||||
1, // 시퀀스 초기화
|
||||
rule.table_name,
|
||||
rule.column_name,
|
||||
targetCompanyCode,
|
||||
newMenuObjid,
|
||||
userId,
|
||||
rule.scope_type,
|
||||
]
|
||||
);
|
||||
|
||||
ruleCount++;
|
||||
}
|
||||
|
||||
// 2) 채번 규칙 파트 복사
|
||||
for (const part of rules.parts) {
|
||||
const newRuleId = ruleIdMap.get(part.rule_id);
|
||||
if (!newRuleId) continue;
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO numbering_rule_parts (
|
||||
rule_id, part_order, part_type, generation_method,
|
||||
auto_config, manual_config, company_code
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[
|
||||
newRuleId,
|
||||
part.part_order,
|
||||
part.part_type,
|
||||
part.generation_method,
|
||||
part.auto_config,
|
||||
part.manual_config,
|
||||
targetCompanyCode,
|
||||
]
|
||||
);
|
||||
|
||||
partCount++;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`✅ 채번 규칙 복사 완료: 규칙 ${ruleCount}개, 파트 ${partCount}개`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,6 +102,72 @@ export async function getSiblingMenuObjids(menuObjid: number): Promise<number[]>
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택한 메뉴와 그 하위 메뉴들의 OBJID 조회
|
||||
*
|
||||
* 형제 메뉴는 포함하지 않고, 선택한 메뉴와 그 자식 메뉴들만 반환합니다.
|
||||
* 채번 규칙 필터링 등 특정 메뉴 계층만 필요할 때 사용합니다.
|
||||
*
|
||||
* @param menuObjid 메뉴 OBJID
|
||||
* @returns 선택한 메뉴 + 모든 하위 메뉴 OBJID 배열 (재귀적)
|
||||
*
|
||||
* @example
|
||||
* // 메뉴 구조:
|
||||
* // └── 구매관리 (100)
|
||||
* // ├── 공급업체관리 (101)
|
||||
* // ├── 발주관리 (102)
|
||||
* // └── 입고관리 (103)
|
||||
* // └── 입고상세 (104)
|
||||
*
|
||||
* await getMenuAndChildObjids(100);
|
||||
* // 결과: [100, 101, 102, 103, 104]
|
||||
*/
|
||||
export async function getMenuAndChildObjids(menuObjid: number): Promise<number[]> {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
logger.debug("메뉴 및 하위 메뉴 조회 시작", { menuObjid });
|
||||
|
||||
// 재귀 CTE를 사용하여 선택한 메뉴와 모든 하위 메뉴 조회
|
||||
const query = `
|
||||
WITH RECURSIVE menu_tree AS (
|
||||
-- 시작점: 선택한 메뉴
|
||||
SELECT objid, parent_obj_id, 1 AS depth
|
||||
FROM menu_info
|
||||
WHERE objid = $1
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- 재귀: 하위 메뉴들
|
||||
SELECT m.objid, m.parent_obj_id, mt.depth + 1
|
||||
FROM menu_info m
|
||||
INNER JOIN menu_tree mt ON m.parent_obj_id = mt.objid
|
||||
WHERE mt.depth < 10 -- 무한 루프 방지
|
||||
)
|
||||
SELECT objid FROM menu_tree ORDER BY depth, objid
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [menuObjid]);
|
||||
const objids = result.rows.map((row) => Number(row.objid));
|
||||
|
||||
logger.debug("메뉴 및 하위 메뉴 조회 완료", {
|
||||
menuObjid,
|
||||
totalCount: objids.length,
|
||||
objids
|
||||
});
|
||||
|
||||
return objids;
|
||||
} catch (error: any) {
|
||||
logger.error("메뉴 및 하위 메뉴 조회 실패", {
|
||||
menuObjid,
|
||||
error: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
// 에러 발생 시 안전하게 자기 자신만 반환
|
||||
return [menuObjid];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 메뉴의 형제 메뉴 OBJID 합집합 조회
|
||||
*
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
import { getPool } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
import { getSiblingMenuObjids } from "./menuService";
|
||||
import { getMenuAndChildObjids } from "./menuService";
|
||||
|
||||
interface NumberingRulePart {
|
||||
id?: number;
|
||||
|
|
@ -161,7 +161,7 @@ class NumberingRuleService {
|
|||
companyCode: string,
|
||||
menuObjid?: number
|
||||
): Promise<NumberingRuleConfig[]> {
|
||||
let siblingObjids: number[] = []; // catch 블록에서 접근 가능하도록 함수 최상단에 선언
|
||||
let menuAndChildObjids: number[] = []; // catch 블록에서 접근 가능하도록 함수 최상단에 선언
|
||||
|
||||
try {
|
||||
logger.info("메뉴별 사용 가능한 채번 규칙 조회 시작 (메뉴 스코프)", {
|
||||
|
|
@ -171,14 +171,14 @@ class NumberingRuleService {
|
|||
|
||||
const pool = getPool();
|
||||
|
||||
// 1. 형제 메뉴 OBJID 조회
|
||||
// 1. 선택한 메뉴와 하위 메뉴 OBJID 조회 (형제 메뉴 제외)
|
||||
if (menuObjid) {
|
||||
siblingObjids = await getSiblingMenuObjids(menuObjid);
|
||||
logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids });
|
||||
menuAndChildObjids = await getMenuAndChildObjids(menuObjid);
|
||||
logger.info("선택한 메뉴 및 하위 메뉴 OBJID 목록", { menuObjid, menuAndChildObjids });
|
||||
}
|
||||
|
||||
// menuObjid가 없으면 global 규칙만 반환
|
||||
if (!menuObjid || siblingObjids.length === 0) {
|
||||
if (!menuObjid || menuAndChildObjids.length === 0) {
|
||||
let query: string;
|
||||
let params: any[];
|
||||
|
||||
|
|
@ -280,7 +280,7 @@ class NumberingRuleService {
|
|||
let params: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 모든 규칙 조회 (형제 메뉴 포함)
|
||||
// 최고 관리자: 모든 규칙 조회 (선택한 메뉴 + 하위 메뉴)
|
||||
query = `
|
||||
SELECT
|
||||
rule_id AS "ruleId",
|
||||
|
|
@ -301,8 +301,7 @@ class NumberingRuleService {
|
|||
WHERE
|
||||
scope_type = 'global'
|
||||
OR (scope_type = 'menu' AND menu_objid = ANY($1))
|
||||
OR (scope_type = 'table' AND menu_objid = ANY($1)) -- ✅ 메뉴별로 필터링
|
||||
OR (scope_type = 'table' AND menu_objid IS NULL) -- ✅ 기존 규칙(menu_objid NULL) 포함 (하위 호환성)
|
||||
OR (scope_type = 'table' AND menu_objid = ANY($1))
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($1)) THEN 1
|
||||
|
|
@ -311,10 +310,10 @@ class NumberingRuleService {
|
|||
END,
|
||||
created_at DESC
|
||||
`;
|
||||
params = [siblingObjids];
|
||||
logger.info("최고 관리자: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { siblingObjids });
|
||||
params = [menuAndChildObjids];
|
||||
logger.info("최고 관리자: 메뉴 및 하위 메뉴 기반 채번 규칙 조회", { menuAndChildObjids });
|
||||
} else {
|
||||
// 일반 회사: 자신의 규칙만 조회 (형제 메뉴 포함, 메뉴별 필터링)
|
||||
// 일반 회사: 자신의 규칙만 조회 (선택한 메뉴 + 하위 메뉴)
|
||||
query = `
|
||||
SELECT
|
||||
rule_id AS "ruleId",
|
||||
|
|
@ -336,8 +335,7 @@ class NumberingRuleService {
|
|||
AND (
|
||||
scope_type = 'global'
|
||||
OR (scope_type = 'menu' AND menu_objid = ANY($2))
|
||||
OR (scope_type = 'table' AND menu_objid = ANY($2)) -- ✅ 메뉴별로 필터링
|
||||
OR (scope_type = 'table' AND menu_objid IS NULL) -- ✅ 기존 규칙(menu_objid NULL) 포함 (하위 호환성)
|
||||
OR (scope_type = 'table' AND menu_objid = ANY($2))
|
||||
)
|
||||
ORDER BY
|
||||
CASE
|
||||
|
|
@ -347,8 +345,8 @@ class NumberingRuleService {
|
|||
END,
|
||||
created_at DESC
|
||||
`;
|
||||
params = [companyCode, siblingObjids];
|
||||
logger.info("회사별: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { companyCode, siblingObjids });
|
||||
params = [companyCode, menuAndChildObjids];
|
||||
logger.info("회사별: 메뉴 및 하위 메뉴 기반 채번 규칙 조회", { companyCode, menuAndChildObjids });
|
||||
}
|
||||
|
||||
logger.info("🔍 채번 규칙 쿼리 실행", {
|
||||
|
|
@ -420,7 +418,7 @@ class NumberingRuleService {
|
|||
logger.info("메뉴별 사용 가능한 채번 규칙 조회 완료", {
|
||||
companyCode,
|
||||
menuObjid,
|
||||
siblingCount: siblingObjids.length,
|
||||
menuAndChildCount: menuAndChildObjids.length,
|
||||
count: result.rowCount,
|
||||
});
|
||||
|
||||
|
|
@ -432,7 +430,7 @@ class NumberingRuleService {
|
|||
errorStack: error.stack,
|
||||
companyCode,
|
||||
menuObjid,
|
||||
siblingObjids: siblingObjids || [],
|
||||
menuAndChildObjids: menuAndChildObjids || [],
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,12 +70,13 @@ export class ScreenManagementService {
|
|||
throw new Error("이미 존재하는 화면 코드입니다.");
|
||||
}
|
||||
|
||||
// 화면 생성 (Raw Query)
|
||||
// 화면 생성 (Raw Query) - REST API 지원 추가
|
||||
const [screen] = await query<any>(
|
||||
`INSERT INTO screen_definitions (
|
||||
screen_name, screen_code, table_name, company_code, description, created_by,
|
||||
db_source_type, db_connection_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
db_source_type, db_connection_id, data_source_type, rest_api_connection_id,
|
||||
rest_api_endpoint, rest_api_json_path
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING *`,
|
||||
[
|
||||
screenData.screenName,
|
||||
|
|
@ -86,6 +87,10 @@ export class ScreenManagementService {
|
|||
screenData.createdBy,
|
||||
screenData.dbSourceType || "internal",
|
||||
screenData.dbConnectionId || null,
|
||||
(screenData as any).dataSourceType || "database",
|
||||
(screenData as any).restApiConnectionId || null,
|
||||
(screenData as any).restApiEndpoint || null,
|
||||
(screenData as any).restApiJsonPath || "data",
|
||||
]
|
||||
);
|
||||
|
||||
|
|
@ -1977,6 +1982,11 @@ export class ScreenManagementService {
|
|||
updatedBy: data.updated_by,
|
||||
dbSourceType: data.db_source_type || "internal",
|
||||
dbConnectionId: data.db_connection_id || undefined,
|
||||
// REST API 관련 필드
|
||||
dataSourceType: data.data_source_type || "database",
|
||||
restApiConnectionId: data.rest_api_connection_id || undefined,
|
||||
restApiEndpoint: data.rest_api_endpoint || undefined,
|
||||
restApiJsonPath: data.rest_api_json_path || "data",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1066,6 +1066,66 @@ class TableCategoryValueService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블+컬럼 기준으로 모든 매핑 삭제
|
||||
*
|
||||
* 메뉴 선택 변경 시 기존 매핑을 모두 삭제하고 새로운 매핑만 추가하기 위해 사용
|
||||
*
|
||||
* @param tableName - 테이블명
|
||||
* @param columnName - 컬럼명
|
||||
* @param companyCode - 회사 코드
|
||||
* @returns 삭제된 매핑 수
|
||||
*/
|
||||
async deleteColumnMappingsByColumn(
|
||||
tableName: string,
|
||||
columnName: string,
|
||||
companyCode: string
|
||||
): Promise<number> {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
logger.info("테이블+컬럼 기준 매핑 삭제", { tableName, columnName, companyCode });
|
||||
|
||||
// 멀티테넌시 적용
|
||||
let deleteQuery: string;
|
||||
let deleteParams: any[];
|
||||
|
||||
if (companyCode === "*") {
|
||||
// 최고 관리자: 해당 테이블+컬럼의 모든 매핑 삭제
|
||||
deleteQuery = `
|
||||
DELETE FROM category_column_mapping
|
||||
WHERE table_name = $1
|
||||
AND logical_column_name = $2
|
||||
`;
|
||||
deleteParams = [tableName, columnName];
|
||||
} else {
|
||||
// 일반 회사: 자신의 매핑만 삭제
|
||||
deleteQuery = `
|
||||
DELETE FROM category_column_mapping
|
||||
WHERE table_name = $1
|
||||
AND logical_column_name = $2
|
||||
AND company_code = $3
|
||||
`;
|
||||
deleteParams = [tableName, columnName, companyCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(deleteQuery, deleteParams);
|
||||
const deletedCount = result.rowCount || 0;
|
||||
|
||||
logger.info("테이블+컬럼 기준 매핑 삭제 완료", {
|
||||
tableName,
|
||||
columnName,
|
||||
companyCode,
|
||||
deletedCount
|
||||
});
|
||||
|
||||
return deletedCount;
|
||||
} catch (error: any) {
|
||||
logger.error(`테이블+컬럼 기준 매핑 삭제 실패: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 논리적 컬럼명을 물리적 컬럼명으로 변환
|
||||
*
|
||||
|
|
|
|||
|
|
@ -154,6 +154,11 @@ export interface ScreenDefinition {
|
|||
updatedBy?: string;
|
||||
dbSourceType?: "internal" | "external";
|
||||
dbConnectionId?: number;
|
||||
// REST API 관련 필드
|
||||
dataSourceType?: "database" | "restapi";
|
||||
restApiConnectionId?: number;
|
||||
restApiEndpoint?: string;
|
||||
restApiJsonPath?: string;
|
||||
}
|
||||
|
||||
// 화면 생성 요청
|
||||
|
|
@ -166,6 +171,11 @@ export interface CreateScreenRequest {
|
|||
createdBy?: string;
|
||||
dbSourceType?: "internal" | "external";
|
||||
dbConnectionId?: number;
|
||||
// REST API 관련 필드
|
||||
dataSourceType?: "database" | "restapi";
|
||||
restApiConnectionId?: number;
|
||||
restApiEndpoint?: string;
|
||||
restApiJsonPath?: string;
|
||||
}
|
||||
|
||||
// 화면 수정 요청
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import {
|
|||
saveDraft,
|
||||
updateDraft,
|
||||
} from "@/lib/api/mail";
|
||||
import { API_BASE_URL } from "@/lib/api/client";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
export default function MailSendPage() {
|
||||
|
|
@ -498,7 +499,7 @@ ${data.originalBody}`;
|
|||
throw new Error("인증 토큰이 없습니다. 다시 로그인해주세요.");
|
||||
}
|
||||
|
||||
const response = await fetch("/api/mail/send/simple", {
|
||||
const response = await fetch(`${API_BASE_URL}/mail/send/simple`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
|
|
@ -1226,6 +1227,91 @@ ${data.originalBody}`;
|
|||
여백
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'header':
|
||||
return (
|
||||
<div key={component.id} className="p-4 rounded-lg" style={{ backgroundColor: component.headerBgColor || '#f8f9fa' }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{component.logoSrc && <img src={component.logoSrc} alt="로고" className="h-10" />}
|
||||
<span className="font-bold text-lg">{component.brandName}</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">{component.sendDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'infoTable':
|
||||
return (
|
||||
<div key={component.id} className="border rounded-lg overflow-hidden">
|
||||
{component.tableTitle && (
|
||||
<div className="bg-gray-50 px-4 py-2 font-semibold border-b">{component.tableTitle}</div>
|
||||
)}
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{component.rows?.map((row: any, i: number) => (
|
||||
<tr key={i} className={i % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
|
||||
<td className="px-4 py-2 font-medium text-gray-600 w-1/3 border-r">{row.label}</td>
|
||||
<td className="px-4 py-2">{row.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'alertBox':
|
||||
return (
|
||||
<div key={component.id} className={`p-4 rounded-lg border-l-4 ${
|
||||
component.alertType === 'info' ? 'bg-blue-50 border-blue-500 text-blue-800' :
|
||||
component.alertType === 'warning' ? 'bg-amber-50 border-amber-500 text-amber-800' :
|
||||
component.alertType === 'danger' ? 'bg-red-50 border-red-500 text-red-800' :
|
||||
'bg-emerald-50 border-emerald-500 text-emerald-800'
|
||||
}`}>
|
||||
{component.alertTitle && <div className="font-bold mb-1">{component.alertTitle}</div>}
|
||||
<div>{component.content}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'divider':
|
||||
return (
|
||||
<hr key={component.id} className="border-gray-300" style={{ borderWidth: `${component.height || 1}px` }} />
|
||||
);
|
||||
|
||||
case 'footer':
|
||||
return (
|
||||
<div key={component.id} className="text-center text-sm text-gray-500 py-4 border-t bg-gray-50">
|
||||
{component.companyName && <div className="font-semibold text-gray-700">{component.companyName}</div>}
|
||||
{(component.ceoName || component.businessNumber) && (
|
||||
<div className="mt-1">
|
||||
{component.ceoName && <span>대표: {component.ceoName}</span>}
|
||||
{component.ceoName && component.businessNumber && <span className="mx-2">|</span>}
|
||||
{component.businessNumber && <span>사업자등록번호: {component.businessNumber}</span>}
|
||||
</div>
|
||||
)}
|
||||
{component.address && <div className="mt-1">{component.address}</div>}
|
||||
{(component.phone || component.email) && (
|
||||
<div className="mt-1">
|
||||
{component.phone && <span>Tel: {component.phone}</span>}
|
||||
{component.phone && component.email && <span className="mx-2">|</span>}
|
||||
{component.email && <span>Email: {component.email}</span>}
|
||||
</div>
|
||||
)}
|
||||
{component.copyright && <div className="mt-2 text-xs text-gray-400">{component.copyright}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'numberedList':
|
||||
return (
|
||||
<div key={component.id} className="p-4">
|
||||
{component.listTitle && <div className="font-semibold mb-2">{component.listTitle}</div>}
|
||||
<ol className="list-decimal list-inside space-y-1">
|
||||
{component.listItems?.map((item: string, i: number) => (
|
||||
<li key={i}>{item}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { apiClient } from "@/lib/api/client";
|
|||
import { commonCodeApi } from "@/lib/api/commonCode";
|
||||
import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin";
|
||||
import { ddlApi } from "@/lib/api/ddl";
|
||||
import { getSecondLevelMenus, createColumnMapping } from "@/lib/api/tableCategoryValue";
|
||||
import { getSecondLevelMenus, createColumnMapping, deleteColumnMappingsByColumn } from "@/lib/api/tableCategoryValue";
|
||||
import { CreateTableModal } from "@/components/admin/CreateTableModal";
|
||||
import { AddColumnModal } from "@/components/admin/AddColumnModal";
|
||||
import { DDLLogViewer } from "@/components/admin/DDLLogViewer";
|
||||
|
|
@ -488,52 +488,69 @@ export default function TableManagementPage() {
|
|||
if (response.data.success) {
|
||||
console.log("✅ 컬럼 설정 저장 성공");
|
||||
|
||||
// 🆕 Category 타입인 경우 컬럼 매핑 생성
|
||||
// 🆕 Category 타입인 경우 컬럼 매핑 처리
|
||||
console.log("🔍 카테고리 조건 체크:", {
|
||||
isCategory: column.inputType === "category",
|
||||
hasCategoryMenus: !!column.categoryMenus,
|
||||
length: column.categoryMenus?.length || 0,
|
||||
});
|
||||
|
||||
if (column.inputType === "category" && column.categoryMenus && column.categoryMenus.length > 0) {
|
||||
console.log("📥 카테고리 메뉴 매핑 시작:", {
|
||||
if (column.inputType === "category") {
|
||||
// 1. 먼저 기존 매핑 모두 삭제
|
||||
console.log("🗑️ 기존 카테고리 메뉴 매핑 삭제 시작:", {
|
||||
tableName: selectedTable,
|
||||
columnName: column.columnName,
|
||||
categoryMenus: column.categoryMenus,
|
||||
count: column.categoryMenus.length,
|
||||
});
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const menuObjid of column.categoryMenus) {
|
||||
try {
|
||||
const mappingResponse = await createColumnMapping({
|
||||
tableName: selectedTable,
|
||||
logicalColumnName: column.columnName,
|
||||
physicalColumnName: column.columnName,
|
||||
menuObjid,
|
||||
description: `${column.displayName} (메뉴별 카테고리)`,
|
||||
});
|
||||
|
||||
if (mappingResponse.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
console.error("❌ 매핑 생성 실패:", mappingResponse);
|
||||
failCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 메뉴 ${menuObjid}에 대한 매핑 생성 실패:`, error);
|
||||
failCount++;
|
||||
}
|
||||
try {
|
||||
const deleteResponse = await deleteColumnMappingsByColumn(selectedTable, column.columnName);
|
||||
console.log("🗑️ 기존 매핑 삭제 결과:", deleteResponse);
|
||||
} catch (error) {
|
||||
console.error("❌ 기존 매핑 삭제 실패:", error);
|
||||
}
|
||||
|
||||
// 2. 새로운 매핑 추가 (선택된 메뉴가 있는 경우만)
|
||||
if (column.categoryMenus && column.categoryMenus.length > 0) {
|
||||
console.log("📥 카테고리 메뉴 매핑 시작:", {
|
||||
columnName: column.columnName,
|
||||
categoryMenus: column.categoryMenus,
|
||||
count: column.categoryMenus.length,
|
||||
});
|
||||
|
||||
if (successCount > 0 && failCount === 0) {
|
||||
toast.success(`컬럼 설정 및 ${successCount}개 메뉴 매핑이 저장되었습니다.`);
|
||||
} else if (successCount > 0 && failCount > 0) {
|
||||
toast.warning(`컬럼 설정 저장 성공. ${successCount}개 메뉴 매핑 성공, ${failCount}개 실패.`);
|
||||
} else if (failCount > 0) {
|
||||
toast.error(`컬럼 설정 저장 성공. 메뉴 매핑 생성 실패.`);
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const menuObjid of column.categoryMenus) {
|
||||
try {
|
||||
const mappingResponse = await createColumnMapping({
|
||||
tableName: selectedTable,
|
||||
logicalColumnName: column.columnName,
|
||||
physicalColumnName: column.columnName,
|
||||
menuObjid,
|
||||
description: `${column.displayName} (메뉴별 카테고리)`,
|
||||
});
|
||||
|
||||
if (mappingResponse.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
console.error("❌ 매핑 생성 실패:", mappingResponse);
|
||||
failCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 메뉴 ${menuObjid}에 대한 매핑 생성 실패:`, error);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0 && failCount === 0) {
|
||||
toast.success(`컬럼 설정 및 ${successCount}개 메뉴 매핑이 저장되었습니다.`);
|
||||
} else if (successCount > 0 && failCount > 0) {
|
||||
toast.warning(`컬럼 설정 저장 성공. ${successCount}개 메뉴 매핑 성공, ${failCount}개 실패.`);
|
||||
} else if (failCount > 0) {
|
||||
toast.error(`컬럼 설정 저장 성공. 메뉴 매핑 생성 실패.`);
|
||||
}
|
||||
} else {
|
||||
toast.success("컬럼 설정이 저장되었습니다. (메뉴 매핑 없음)");
|
||||
}
|
||||
} else {
|
||||
toast.success("컬럼 설정이 성공적으로 저장되었습니다.");
|
||||
|
|
@ -596,10 +613,8 @@ export default function TableManagementPage() {
|
|||
);
|
||||
|
||||
if (response.data.success) {
|
||||
// 🆕 Category 타입 컬럼들의 메뉴 매핑 생성
|
||||
const categoryColumns = columns.filter(
|
||||
(col) => col.inputType === "category" && col.categoryMenus && col.categoryMenus.length > 0
|
||||
);
|
||||
// 🆕 Category 타입 컬럼들의 메뉴 매핑 처리
|
||||
const categoryColumns = columns.filter((col) => col.inputType === "category");
|
||||
|
||||
console.log("📥 전체 저장: 카테고리 컬럼 확인", {
|
||||
totalColumns: columns.length,
|
||||
|
|
@ -615,33 +630,49 @@ export default function TableManagementPage() {
|
|||
let totalFailCount = 0;
|
||||
|
||||
for (const column of categoryColumns) {
|
||||
for (const menuObjid of column.categoryMenus!) {
|
||||
try {
|
||||
console.log("🔄 매핑 API 호출:", {
|
||||
tableName: selectedTable,
|
||||
columnName: column.columnName,
|
||||
menuObjid,
|
||||
});
|
||||
// 1. 먼저 기존 매핑 모두 삭제
|
||||
console.log("🗑️ 기존 카테고리 메뉴 매핑 삭제:", {
|
||||
tableName: selectedTable,
|
||||
columnName: column.columnName,
|
||||
});
|
||||
|
||||
const mappingResponse = await createColumnMapping({
|
||||
tableName: selectedTable,
|
||||
logicalColumnName: column.columnName,
|
||||
physicalColumnName: column.columnName,
|
||||
menuObjid,
|
||||
description: `${column.displayName} (메뉴별 카테고리)`,
|
||||
});
|
||||
try {
|
||||
const deleteResponse = await deleteColumnMappingsByColumn(selectedTable, column.columnName);
|
||||
console.log("🗑️ 기존 매핑 삭제 결과:", deleteResponse);
|
||||
} catch (error) {
|
||||
console.error("❌ 기존 매핑 삭제 실패:", error);
|
||||
}
|
||||
|
||||
console.log("✅ 매핑 API 응답:", mappingResponse);
|
||||
// 2. 새로운 매핑 추가 (선택된 메뉴가 있는 경우만)
|
||||
if (column.categoryMenus && column.categoryMenus.length > 0) {
|
||||
for (const menuObjid of column.categoryMenus) {
|
||||
try {
|
||||
console.log("🔄 매핑 API 호출:", {
|
||||
tableName: selectedTable,
|
||||
columnName: column.columnName,
|
||||
menuObjid,
|
||||
});
|
||||
|
||||
if (mappingResponse.success) {
|
||||
totalSuccessCount++;
|
||||
} else {
|
||||
console.error("❌ 매핑 생성 실패:", mappingResponse);
|
||||
const mappingResponse = await createColumnMapping({
|
||||
tableName: selectedTable,
|
||||
logicalColumnName: column.columnName,
|
||||
physicalColumnName: column.columnName,
|
||||
menuObjid,
|
||||
description: `${column.displayName} (메뉴별 카테고리)`,
|
||||
});
|
||||
|
||||
console.log("✅ 매핑 API 응답:", mappingResponse);
|
||||
|
||||
if (mappingResponse.success) {
|
||||
totalSuccessCount++;
|
||||
} else {
|
||||
console.error("❌ 매핑 생성 실패:", mappingResponse);
|
||||
totalFailCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 메뉴 ${menuObjid}에 대한 매핑 생성 실패:`, error);
|
||||
totalFailCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 메뉴 ${menuObjid}에 대한 매핑 생성 실패:`, error);
|
||||
totalFailCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { useAuth } from "@/hooks/useAuth"; // 🆕 사용자 정보
|
|||
import { useResponsive } from "@/lib/hooks/useResponsive"; // 🆕 반응형 감지
|
||||
import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; // 🆕 테이블 옵션
|
||||
import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext"; // 🆕 높이 관리
|
||||
import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 🆕 컴포넌트 간 통신
|
||||
|
||||
function ScreenViewPage() {
|
||||
const params = useParams();
|
||||
|
|
@ -239,17 +240,17 @@ function ScreenViewPage() {
|
|||
// 가로 기준 스케일 계산 (좌우 여백 16px씩 고정)
|
||||
const newScale = availableWidth / designWidth;
|
||||
|
||||
console.log("📐 스케일 계산:", {
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
MARGIN_X,
|
||||
availableWidth,
|
||||
designWidth,
|
||||
designHeight,
|
||||
finalScale: newScale,
|
||||
"스케일된 화면 크기": `${designWidth * newScale}px × ${designHeight * newScale}px`,
|
||||
"실제 좌우 여백": `${(containerWidth - designWidth * newScale) / 2}px씩`,
|
||||
});
|
||||
// console.log("📐 스케일 계산:", {
|
||||
// containerWidth,
|
||||
// containerHeight,
|
||||
// MARGIN_X,
|
||||
// availableWidth,
|
||||
// designWidth,
|
||||
// designHeight,
|
||||
// finalScale: newScale,
|
||||
// "스케일된 화면 크기": `${designWidth * newScale}px × ${designHeight * newScale}px`,
|
||||
// "실제 좌우 여백": `${(containerWidth - designWidth * newScale) / 2}px씩`,
|
||||
// });
|
||||
|
||||
setScale(newScale);
|
||||
// 컨테이너 너비 업데이트
|
||||
|
|
@ -796,7 +797,9 @@ function ScreenViewPage() {
|
|||
function ScreenViewPageWrapper() {
|
||||
return (
|
||||
<TableSearchWidgetHeightProvider>
|
||||
<ScreenViewPage />
|
||||
<ScreenContextProvider>
|
||||
<ScreenViewPage />
|
||||
</ScreenContextProvider>
|
||||
</TableSearchWidgetHeightProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
|||
<TableCell className="h-16 px-6 py-3">{formatDiskUsage(company)}</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
{/* <Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleManageDepartments(company)}
|
||||
|
|
@ -170,7 +170,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
|||
aria-label="부서관리"
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
</Button>
|
||||
</Button> */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
|
|
|||
|
|
@ -294,18 +294,10 @@ export function MenuCopyDialog({
|
|||
<span className="text-muted-foreground">화면:</span>{" "}
|
||||
<span className="font-medium">{result.copiedScreens}개</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="col-span-2">
|
||||
<span className="text-muted-foreground">플로우:</span>{" "}
|
||||
<span className="font-medium">{result.copiedFlows}개</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">코드 카테고리:</span>{" "}
|
||||
<span className="font-medium">{result.copiedCategories}개</span>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="text-muted-foreground">코드:</span>{" "}
|
||||
<span className="font-medium">{result.copiedCodes}개</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -226,7 +226,7 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
endpoint_path: endpointPath || undefined,
|
||||
default_headers: defaultHeaders,
|
||||
default_method: defaultMethod,
|
||||
default_body: defaultBody || undefined,
|
||||
default_body: defaultBody.trim() || null, // 빈 문자열이면 null로 전송하여 DB 업데이트
|
||||
auth_type: authType,
|
||||
auth_config: authType === "none" ? undefined : authConfig,
|
||||
timeout,
|
||||
|
|
@ -236,6 +236,13 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
is_active: isActive ? "Y" : "N",
|
||||
};
|
||||
|
||||
console.log("저장하려는 데이터:", {
|
||||
connection_name: connectionName,
|
||||
default_method: defaultMethod,
|
||||
endpoint_path: endpointPath,
|
||||
base_url: baseUrl,
|
||||
});
|
||||
|
||||
if (connection?.id) {
|
||||
await ExternalRestApiConnectionAPI.updateConnection(connection.id, data);
|
||||
toast({
|
||||
|
|
@ -303,7 +310,13 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
기본 URL <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select value={defaultMethod} onValueChange={setDefaultMethod}>
|
||||
<Select
|
||||
value={defaultMethod}
|
||||
onValueChange={(val) => {
|
||||
setDefaultMethod(val);
|
||||
setTestMethod(val); // 테스트 Method도 동기화
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="Method" />
|
||||
</SelectTrigger>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { Plus, ChevronDown, ChevronRight, Users, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
|
||||
// 연속 등록 모드 상태 (state로 변경 - 체크박스 UI 업데이트를 위해)
|
||||
const [continuousMode, setContinuousMode] = useState(false);
|
||||
|
||||
|
||||
// 화면 리셋 키 (컴포넌트 강제 리마운트용)
|
||||
const [resetKey, setResetKey] = useState(0);
|
||||
|
||||
|
|
@ -68,7 +68,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
const savedMode = localStorage.getItem("screenModal_continuousMode");
|
||||
if (savedMode === "true") {
|
||||
setContinuousMode(true);
|
||||
console.log("🔄 연속 모드 복원: true");
|
||||
// console.log("🔄 연속 모드 복원: true");
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
@ -120,10 +120,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
};
|
||||
};
|
||||
|
||||
// 모달이 열린 시간 추적 (저장 성공 이벤트 무시용)
|
||||
const modalOpenedAtRef = React.useRef<number>(0);
|
||||
|
||||
// 전역 모달 이벤트 리스너
|
||||
useEffect(() => {
|
||||
const handleOpenModal = (event: CustomEvent) => {
|
||||
const { screenId, title, description, size, urlParams } = event.detail;
|
||||
const { screenId, title, description, size, urlParams, editData } = event.detail;
|
||||
|
||||
// 🆕 모달 열린 시간 기록
|
||||
modalOpenedAtRef.current = Date.now();
|
||||
console.log("🕐 [ScreenModal] 모달 열림 시간 기록:", modalOpenedAtRef.current);
|
||||
|
||||
// 🆕 URL 파라미터가 있으면 현재 URL에 추가
|
||||
if (urlParams && typeof window !== "undefined") {
|
||||
|
|
@ -136,6 +143,12 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
console.log("✅ URL 파라미터 추가:", urlParams);
|
||||
}
|
||||
|
||||
// 🆕 editData가 있으면 formData로 설정 (수정 모드)
|
||||
if (editData) {
|
||||
console.log("📝 [ScreenModal] 수정 데이터 설정:", editData);
|
||||
setFormData(editData);
|
||||
}
|
||||
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
screenId,
|
||||
|
|
@ -164,6 +177,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
});
|
||||
setScreenData(null);
|
||||
setFormData({});
|
||||
setSelectedData([]); // 🆕 선택된 데이터 초기화
|
||||
setContinuousMode(false);
|
||||
localStorage.setItem("screenModal_continuousMode", "false"); // localStorage에 저장
|
||||
console.log("🔄 연속 모드 초기화: false");
|
||||
|
|
@ -171,6 +185,13 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
|
||||
// 저장 성공 이벤트 처리 (연속 등록 모드 지원)
|
||||
const handleSaveSuccess = () => {
|
||||
// 🆕 모달이 열린 후 500ms 이내의 저장 성공 이벤트는 무시 (이전 이벤트 방지)
|
||||
const timeSinceOpen = Date.now() - modalOpenedAtRef.current;
|
||||
if (timeSinceOpen < 500) {
|
||||
console.log("⏭️ [ScreenModal] 모달 열린 직후 저장 성공 이벤트 무시:", { timeSinceOpen });
|
||||
return;
|
||||
}
|
||||
|
||||
const isContinuousMode = continuousMode;
|
||||
console.log("💾 저장 성공 이벤트 수신");
|
||||
console.log("📌 현재 연속 모드 상태:", isContinuousMode);
|
||||
|
|
@ -182,11 +203,11 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
|
||||
// 1. 폼 데이터 초기화
|
||||
setFormData({});
|
||||
|
||||
|
||||
// 2. 리셋 키 변경 (컴포넌트 강제 리마운트)
|
||||
setResetKey(prev => prev + 1);
|
||||
setResetKey((prev) => prev + 1);
|
||||
console.log("🔄 resetKey 증가 - 컴포넌트 리마운트");
|
||||
|
||||
|
||||
// 3. 화면 데이터 다시 로드 (채번 규칙 새로 생성)
|
||||
if (modalState.screenId) {
|
||||
console.log("🔄 화면 데이터 다시 로드:", modalState.screenId);
|
||||
|
|
@ -314,17 +335,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
if (Array.isArray(data)) {
|
||||
return data.map(normalizeDates);
|
||||
}
|
||||
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
|
||||
if (typeof data !== "object" || data === null) {
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
const normalized: any = {};
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
|
||||
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
|
||||
// ISO 날짜 형식 감지: YYYY-MM-DD만 추출
|
||||
const before = value;
|
||||
const after = value.split('T')[0];
|
||||
const after = value.split("T")[0];
|
||||
console.log(`🔧 [날짜 정규화] ${key}: ${before} → ${after}`);
|
||||
normalized[key] = after;
|
||||
} else {
|
||||
|
|
@ -333,14 +354,16 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
|
||||
console.log("📥 [ScreenModal] API 응답 원본:", JSON.stringify(response.data, null, 2));
|
||||
const normalizedData = normalizeDates(response.data);
|
||||
console.log("📥 [ScreenModal] 정규화 후:", JSON.stringify(normalizedData, null, 2));
|
||||
|
||||
|
||||
// 🔧 배열 데이터는 formData로 설정하지 않음 (SelectedItemsDetailInput만 사용)
|
||||
if (Array.isArray(normalizedData)) {
|
||||
console.log("⚠️ [ScreenModal] 그룹 레코드(배열)는 formData로 설정하지 않음. SelectedItemsDetailInput만 사용합니다.");
|
||||
console.log(
|
||||
"⚠️ [ScreenModal] 그룹 레코드(배열)는 formData로 설정하지 않음. SelectedItemsDetailInput만 사용합니다.",
|
||||
);
|
||||
setFormData(normalizedData); // SelectedItemsDetailInput이 직접 사용
|
||||
} else {
|
||||
setFormData(normalizedData);
|
||||
|
|
@ -416,7 +439,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
window.history.pushState({}, "", currentUrl.toString());
|
||||
console.log("🧹 [ScreenModal] URL 파라미터 제거 (모달 닫힘)");
|
||||
}
|
||||
|
||||
|
||||
setModalState({
|
||||
isOpen: false,
|
||||
screenId: null,
|
||||
|
|
@ -440,7 +463,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
// 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스
|
||||
const headerHeight = 60; // DialogHeader (타이틀 + 패딩)
|
||||
const footerHeight = 52; // 연속 등록 모드 체크박스 영역
|
||||
|
||||
|
||||
const totalHeight = screenDimensions.height + headerHeight + footerHeight;
|
||||
|
||||
return {
|
||||
|
|
@ -581,6 +604,15 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
},
|
||||
};
|
||||
|
||||
// 🆕 formData 전달 확인 로그
|
||||
console.log("📝 [ScreenModal] InteractiveScreenViewerDynamic에 formData 전달:", {
|
||||
componentId: component.id,
|
||||
componentType: component.type,
|
||||
componentComponentType: (component as any).componentType, // 🆕 실제 componentType 확인
|
||||
hasFormData: !!formData,
|
||||
formDataKeys: formData ? Object.keys(formData) : [],
|
||||
});
|
||||
|
||||
return (
|
||||
<InteractiveScreenViewerDynamic
|
||||
key={`${component.id}-${resetKey}`}
|
||||
|
|
@ -605,6 +637,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
userId={userId}
|
||||
userName={userName}
|
||||
companyCode={user?.companyCode}
|
||||
// 🆕 선택된 데이터 전달 (RepeatScreenModal 등에서 사용)
|
||||
groupedData={selectedData.length > 0 ? selectedData : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -19,19 +19,50 @@ import {
|
|||
Trash2,
|
||||
Settings,
|
||||
Upload,
|
||||
X
|
||||
X,
|
||||
GripVertical,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
LayoutTemplate,
|
||||
Table2,
|
||||
AlertCircle,
|
||||
Minus,
|
||||
Building2,
|
||||
ListOrdered
|
||||
} from "lucide-react";
|
||||
import { getMailTemplates } from "@/lib/api/mail";
|
||||
|
||||
export interface MailComponent {
|
||||
id: string;
|
||||
type: "text" | "button" | "image" | "spacer" | "table";
|
||||
type: "text" | "button" | "image" | "spacer" | "table" | "header" | "infoTable" | "alertBox" | "divider" | "footer" | "numberedList";
|
||||
content?: string;
|
||||
text?: string;
|
||||
url?: string;
|
||||
src?: string;
|
||||
height?: number;
|
||||
styles?: Record<string, string>;
|
||||
// 헤더 컴포넌트용
|
||||
logoSrc?: string;
|
||||
brandName?: string;
|
||||
sendDate?: string;
|
||||
headerBgColor?: string;
|
||||
// 정보 테이블용
|
||||
rows?: Array<{ label: string; value: string }>;
|
||||
tableTitle?: string;
|
||||
// 강조 박스용
|
||||
alertType?: "info" | "warning" | "danger" | "success";
|
||||
alertTitle?: string;
|
||||
// 푸터용
|
||||
companyName?: string;
|
||||
ceoName?: string;
|
||||
businessNumber?: string;
|
||||
address?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
copyright?: string;
|
||||
// 번호 리스트용
|
||||
listItems?: string[];
|
||||
listTitle?: string;
|
||||
}
|
||||
|
||||
export interface QueryConfig {
|
||||
|
|
@ -64,6 +95,10 @@ export default function MailDesigner({
|
|||
const [subject, setSubject] = useState("");
|
||||
const [queries, setQueries] = useState<QueryConfig[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 드래그 앤 드롭 상태
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
|
||||
// 템플릿 데이터 로드 (수정 모드)
|
||||
useEffect(() => {
|
||||
|
|
@ -96,10 +131,18 @@ export default function MailDesigner({
|
|||
|
||||
// 컴포넌트 타입 정의
|
||||
const componentTypes = [
|
||||
{ type: "text", icon: Type, label: "텍스트", color: "bg-primary/20 hover:bg-blue-200" },
|
||||
{ type: "button", icon: MousePointer, label: "버튼", color: "bg-success/20 hover:bg-success/30" },
|
||||
{ type: "image", icon: ImageIcon, label: "이미지", color: "bg-purple-100 hover:bg-purple-200" },
|
||||
{ type: "spacer", icon: Square, label: "여백", color: "bg-muted hover:bg-muted/80" },
|
||||
// 레이아웃 컴포넌트
|
||||
{ type: "header", icon: LayoutTemplate, label: "헤더", color: "bg-indigo-100 hover:bg-indigo-200", category: "layout" },
|
||||
{ type: "divider", icon: Minus, label: "구분선", color: "bg-gray-100 hover:bg-gray-200", category: "layout" },
|
||||
{ type: "spacer", icon: Square, label: "여백", color: "bg-muted hover:bg-muted/80", category: "layout" },
|
||||
{ type: "footer", icon: Building2, label: "푸터", color: "bg-slate-100 hover:bg-slate-200", category: "layout" },
|
||||
// 컨텐츠 컴포넌트
|
||||
{ type: "text", icon: Type, label: "텍스트", color: "bg-primary/20 hover:bg-blue-200", category: "content" },
|
||||
{ type: "button", icon: MousePointer, label: "버튼", color: "bg-success/20 hover:bg-success/30", category: "content" },
|
||||
{ type: "image", icon: ImageIcon, label: "이미지", color: "bg-purple-100 hover:bg-purple-200", category: "content" },
|
||||
{ type: "infoTable", icon: Table2, label: "정보 테이블", color: "bg-cyan-100 hover:bg-cyan-200", category: "content" },
|
||||
{ type: "alertBox", icon: AlertCircle, label: "안내 박스", color: "bg-amber-100 hover:bg-amber-200", category: "content" },
|
||||
{ type: "numberedList", icon: ListOrdered, label: "번호 리스트", color: "bg-emerald-100 hover:bg-emerald-200", category: "content" },
|
||||
];
|
||||
|
||||
// 컴포넌트 추가
|
||||
|
|
@ -107,21 +150,75 @@ export default function MailDesigner({
|
|||
const newComponent: MailComponent = {
|
||||
id: `comp-${Date.now()}`,
|
||||
type: type as any,
|
||||
content: type === "text" ? "" : undefined, // 🎯 빈 문자열로 시작 (HTML 태그 제거)
|
||||
text: type === "button" ? "버튼 텍스트" : undefined, // 🎯 더 명확한 기본값
|
||||
url: type === "button" || type === "image" ? "" : undefined, // 🎯 빈 문자열로 시작
|
||||
src: type === "image" ? "https://placehold.co/600x200/e5e7eb/64748b?text=이미지를+업로드하세요" : undefined, // 🎯 한글 안내
|
||||
height: type === "spacer" ? 30 : undefined, // 🎯 기본값 30px로 증가 (더 적절한 간격)
|
||||
content: type === "text" ? "" : undefined,
|
||||
text: type === "button" ? "버튼 텍스트" : undefined,
|
||||
url: type === "button" || type === "image" ? "" : undefined,
|
||||
src: type === "image" ? "https://placehold.co/600x200/e5e7eb/64748b?text=이미지를+업로드하세요" : undefined,
|
||||
height: type === "spacer" ? 30 : type === "divider" ? 1 : undefined,
|
||||
styles: {
|
||||
padding: "10px",
|
||||
padding: type === "divider" ? "0" : "10px",
|
||||
backgroundColor: type === "button" ? "#007bff" : "transparent",
|
||||
color: type === "button" ? "#fff" : "#333",
|
||||
},
|
||||
// 헤더 기본값
|
||||
logoSrc: type === "header" ? "" : undefined,
|
||||
brandName: type === "header" ? "회사명" : undefined,
|
||||
sendDate: type === "header" ? new Date().toLocaleDateString("ko-KR") : undefined,
|
||||
headerBgColor: type === "header" ? "#f8f9fa" : undefined,
|
||||
// 정보 테이블 기본값
|
||||
rows: type === "infoTable" ? [{ label: "항목", value: "내용" }] : undefined,
|
||||
tableTitle: type === "infoTable" ? "" : undefined,
|
||||
// 안내 박스 기본값
|
||||
alertType: type === "alertBox" ? "info" : undefined,
|
||||
alertTitle: type === "alertBox" ? "안내" : undefined,
|
||||
// 푸터 기본값
|
||||
companyName: type === "footer" ? "회사명" : undefined,
|
||||
ceoName: type === "footer" ? "" : undefined,
|
||||
businessNumber: type === "footer" ? "" : undefined,
|
||||
address: type === "footer" ? "" : undefined,
|
||||
phone: type === "footer" ? "" : undefined,
|
||||
email: type === "footer" ? "" : undefined,
|
||||
copyright: type === "footer" ? `© ${new Date().getFullYear()} All rights reserved.` : undefined,
|
||||
// 번호 리스트 기본값
|
||||
listItems: type === "numberedList" ? ["첫 번째 항목"] : undefined,
|
||||
listTitle: type === "numberedList" ? "" : undefined,
|
||||
};
|
||||
|
||||
setComponents([...components, newComponent]);
|
||||
};
|
||||
|
||||
// 드래그 앤 드롭 핸들러
|
||||
const handleDragStart = (index: number) => {
|
||||
setDraggedIndex(index);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
if (draggedIndex !== null && draggedIndex !== index) {
|
||||
setDragOverIndex(index);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (index: number) => {
|
||||
if (draggedIndex !== null && draggedIndex !== index) {
|
||||
moveComponent(draggedIndex, index);
|
||||
}
|
||||
setDraggedIndex(null);
|
||||
setDragOverIndex(null);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedIndex(null);
|
||||
setDragOverIndex(null);
|
||||
};
|
||||
|
||||
const moveComponent = (fromIndex: number, toIndex: number) => {
|
||||
const newComponents = [...components];
|
||||
const [movedItem] = newComponents.splice(fromIndex, 1);
|
||||
newComponents.splice(toIndex, 0, movedItem);
|
||||
setComponents(newComponents);
|
||||
};
|
||||
|
||||
// 컴포넌트 삭제
|
||||
const removeComponent = (id: string) => {
|
||||
setComponents(components.filter(c => c.id !== id));
|
||||
|
|
@ -189,13 +286,35 @@ export default function MailDesigner({
|
|||
<div className="flex h-screen bg-muted/30">
|
||||
{/* 왼쪽: 컴포넌트 팔레트 */}
|
||||
<div className="w-64 bg-white border-r p-4 space-y-4 overflow-y-auto">
|
||||
{/* 레이아웃 컴포넌트 */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center">
|
||||
<LayoutTemplate className="w-4 h-4 mr-2 text-indigo-500" />
|
||||
레이아웃
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{componentTypes.filter(c => c.category === "layout").map(({ type, icon: Icon, label, color }) => (
|
||||
<Button
|
||||
key={type}
|
||||
onClick={() => addComponent(type)}
|
||||
variant="outline"
|
||||
className={`w-full justify-start ${color} border`}
|
||||
>
|
||||
<Icon className="w-4 h-4 mr-2" />
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 컨텐츠 컴포넌트 */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center">
|
||||
<Mail className="w-4 h-4 mr-2 text-primary" />
|
||||
컴포넌트
|
||||
컨텐츠
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{componentTypes.map(({ type, icon: Icon, label, color }) => (
|
||||
{componentTypes.filter(c => c.category === "content").map(({ type, icon: Icon, label, color }) => (
|
||||
<Button
|
||||
key={type}
|
||||
onClick={() => addComponent(type)}
|
||||
|
|
@ -274,24 +393,57 @@ export default function MailDesigner({
|
|||
)}
|
||||
|
||||
{/* 컴포넌트 렌더링 */}
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="p-6 pl-14 space-y-4">
|
||||
{components.length === 0 ? (
|
||||
<div className="text-center py-16 text-muted-foreground/50">
|
||||
<Mail className="w-16 h-16 mx-auto mb-4 opacity-20" />
|
||||
<p>왼쪽에서 컴포넌트를 추가하세요</p>
|
||||
</div>
|
||||
) : (
|
||||
components.map((comp) => (
|
||||
components.map((comp, index) => (
|
||||
<div
|
||||
key={comp.id}
|
||||
draggable
|
||||
onDragStart={() => handleDragStart(index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={() => handleDrop(index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={() => selectComponent(comp.id)}
|
||||
className={`relative group cursor-pointer rounded-lg transition-all ${
|
||||
selectedComponent === comp.id
|
||||
? "ring-2 ring-orange-500 bg-orange-50/30"
|
||||
: "hover:ring-2 hover:ring-gray-300"
|
||||
} ${draggedIndex === index ? "opacity-50 scale-95" : ""} ${
|
||||
dragOverIndex === index ? "ring-2 ring-primary ring-dashed bg-primary/10" : ""
|
||||
}`}
|
||||
style={comp.styles}
|
||||
>
|
||||
{/* 드래그 핸들 & 순서 이동 버튼 */}
|
||||
<div className="absolute -left-10 top-1/2 -translate-y-1/2 flex flex-col items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); if (index > 0) moveComponent(index, index - 1); }}
|
||||
className="p-1 hover:bg-gray-200 rounded disabled:opacity-30"
|
||||
disabled={index === 0}
|
||||
>
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
</button>
|
||||
<div className="cursor-grab active:cursor-grabbing p-1 hover:bg-gray-200 rounded">
|
||||
<GripVertical className="w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); if (index < components.length - 1) moveComponent(index, index + 1); }}
|
||||
className="p-1 hover:bg-gray-200 rounded disabled:opacity-30"
|
||||
disabled={index === components.length - 1}
|
||||
>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 순서 배지 */}
|
||||
<div className="absolute -left-10 top-0 text-xs text-gray-400 opacity-0 group-hover:opacity-100">
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
|
|
@ -322,7 +474,82 @@ export default function MailDesigner({
|
|||
<img src={comp.src} alt="메일 이미지" className="w-full rounded" />
|
||||
)}
|
||||
{comp.type === "spacer" && (
|
||||
<div style={{ height: `${comp.height}px` }} />
|
||||
<div style={{ height: `${comp.height}px` }} className="bg-gray-100 rounded flex items-center justify-center text-xs text-gray-400">
|
||||
여백 {comp.height}px
|
||||
</div>
|
||||
)}
|
||||
{comp.type === "header" && (
|
||||
<div className="p-4 rounded-lg" style={{ backgroundColor: comp.headerBgColor || "#f8f9fa" }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{comp.logoSrc && <img src={comp.logoSrc} alt="로고" className="h-10" />}
|
||||
<span className="font-bold text-lg">{comp.brandName}</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">{comp.sendDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{comp.type === "infoTable" && (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
{comp.tableTitle && (
|
||||
<div className="bg-gray-50 px-4 py-2 font-semibold border-b">{comp.tableTitle}</div>
|
||||
)}
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{comp.rows?.map((row, i) => (
|
||||
<tr key={i} className={i % 2 === 0 ? "bg-white" : "bg-gray-50"}>
|
||||
<td className="px-4 py-2 font-medium text-gray-600 w-1/3 border-r">{row.label}</td>
|
||||
<td className="px-4 py-2">{row.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{comp.type === "alertBox" && (
|
||||
<div className={`p-4 rounded-lg border-l-4 ${
|
||||
comp.alertType === "info" ? "bg-blue-50 border-blue-500 text-blue-800" :
|
||||
comp.alertType === "warning" ? "bg-amber-50 border-amber-500 text-amber-800" :
|
||||
comp.alertType === "danger" ? "bg-red-50 border-red-500 text-red-800" :
|
||||
"bg-emerald-50 border-emerald-500 text-emerald-800"
|
||||
}`}>
|
||||
{comp.alertTitle && <div className="font-bold mb-1">{comp.alertTitle}</div>}
|
||||
<div>{comp.content}</div>
|
||||
</div>
|
||||
)}
|
||||
{comp.type === "divider" && (
|
||||
<hr className="border-gray-300" style={{ borderWidth: `${comp.height || 1}px` }} />
|
||||
)}
|
||||
{comp.type === "footer" && (
|
||||
<div className="text-center text-sm text-gray-500 py-4 border-t bg-gray-50">
|
||||
{comp.companyName && <div className="font-semibold text-gray-700">{comp.companyName}</div>}
|
||||
{(comp.ceoName || comp.businessNumber) && (
|
||||
<div className="mt-1">
|
||||
{comp.ceoName && <span>대표: {comp.ceoName}</span>}
|
||||
{comp.ceoName && comp.businessNumber && <span className="mx-2">|</span>}
|
||||
{comp.businessNumber && <span>사업자등록번호: {comp.businessNumber}</span>}
|
||||
</div>
|
||||
)}
|
||||
{comp.address && <div className="mt-1">{comp.address}</div>}
|
||||
{(comp.phone || comp.email) && (
|
||||
<div className="mt-1">
|
||||
{comp.phone && <span>Tel: {comp.phone}</span>}
|
||||
{comp.phone && comp.email && <span className="mx-2">|</span>}
|
||||
{comp.email && <span>Email: {comp.email}</span>}
|
||||
</div>
|
||||
)}
|
||||
{comp.copyright && <div className="mt-2 text-xs text-gray-400">{comp.copyright}</div>}
|
||||
</div>
|
||||
)}
|
||||
{comp.type === "numberedList" && (
|
||||
<div className="p-4">
|
||||
{comp.listTitle && <div className="font-semibold mb-2">{comp.listTitle}</div>}
|
||||
<ol className="list-decimal list-inside space-y-1">
|
||||
{comp.listItems?.map((item, i) => (
|
||||
<li key={i}>{item}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
|
|
@ -571,13 +798,299 @@ export default function MailDesigner({
|
|||
/>
|
||||
<span className="text-sm text-muted-foreground">픽셀</span>
|
||||
</div>
|
||||
<div className="mt-3 p-3 bg-primary/10 rounded-lg border border-primary/20">
|
||||
<p className="text-xs text-primary">
|
||||
<strong>추천값:</strong><br/>
|
||||
• 좁은 간격: 10~20 픽셀<br/>
|
||||
• 보통 간격: 30~50 픽셀<br/>
|
||||
• 넓은 간격: 60~100 픽셀
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 헤더 컴포넌트 */}
|
||||
{selected.type === "header" && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>브랜드명</Label>
|
||||
<Input
|
||||
value={selected.brandName || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { brandName: e.target.value })}
|
||||
placeholder="회사명"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>로고 이미지 URL</Label>
|
||||
<Input
|
||||
value={selected.logoSrc || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { logoSrc: e.target.value })}
|
||||
placeholder="https://example.com/logo.png"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>발송일</Label>
|
||||
<Input
|
||||
value={selected.sendDate || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { sendDate: e.target.value })}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>배경색</Label>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<Input
|
||||
type="color"
|
||||
value={selected.headerBgColor || "#f8f9fa"}
|
||||
onChange={(e) => updateComponent(selected.id, { headerBgColor: e.target.value })}
|
||||
className="w-16 h-10"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{selected.headerBgColor || "#f8f9fa"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 정보 테이블 컴포넌트 */}
|
||||
{selected.type === "infoTable" && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>테이블 제목</Label>
|
||||
<Input
|
||||
value={selected.tableTitle || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { tableTitle: e.target.value })}
|
||||
placeholder="예: 주문 정보"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>테이블 항목</Label>
|
||||
<div className="space-y-2 mt-2">
|
||||
{selected.rows?.map((row, i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<Input
|
||||
value={row.label}
|
||||
onChange={(e) => {
|
||||
const newRows = [...(selected.rows || [])];
|
||||
newRows[i] = { ...newRows[i], label: e.target.value };
|
||||
updateComponent(selected.id, { rows: newRows });
|
||||
}}
|
||||
placeholder="항목명"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={row.value}
|
||||
onChange={(e) => {
|
||||
const newRows = [...(selected.rows || [])];
|
||||
newRows[i] = { ...newRows[i], value: e.target.value };
|
||||
updateComponent(selected.id, { rows: newRows });
|
||||
}}
|
||||
placeholder="값"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newRows = selected.rows?.filter((_, idx) => idx !== i);
|
||||
updateComponent(selected.id, { rows: newRows });
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newRows = [...(selected.rows || []), { label: "", value: "" }];
|
||||
updateComponent(selected.id, { rows: newRows });
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
항목 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 안내 박스 컴포넌트 */}
|
||||
{selected.type === "alertBox" && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>박스 유형</Label>
|
||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||
{(["info", "warning", "danger", "success"] as const).map((type) => (
|
||||
<Button
|
||||
key={type}
|
||||
variant={selected.alertType === type ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => updateComponent(selected.id, { alertType: type })}
|
||||
className={
|
||||
type === "info" ? "border-blue-300" :
|
||||
type === "warning" ? "border-amber-300" :
|
||||
type === "danger" ? "border-red-300" : "border-emerald-300"
|
||||
}
|
||||
>
|
||||
{type === "info" ? "정보" : type === "warning" ? "주의" : type === "danger" ? "위험" : "성공"}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>제목</Label>
|
||||
<Input
|
||||
value={selected.alertTitle || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { alertTitle: e.target.value })}
|
||||
placeholder="안내 제목"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>내용</Label>
|
||||
<Textarea
|
||||
value={selected.content || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { content: e.target.value })}
|
||||
placeholder="안내 내용을 입력하세요"
|
||||
rows={4}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 구분선 컴포넌트 */}
|
||||
{selected.type === "divider" && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>선 두께</Label>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<Input
|
||||
type="number"
|
||||
value={selected.height || 1}
|
||||
onChange={(e) => updateComponent(selected.id, { height: parseInt(e.target.value) || 1 })}
|
||||
className="w-24"
|
||||
min="1"
|
||||
max="10"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">픽셀</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 푸터 컴포넌트 */}
|
||||
{selected.type === "footer" && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>회사명</Label>
|
||||
<Input
|
||||
value={selected.companyName || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { companyName: e.target.value })}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>대표자</Label>
|
||||
<Input
|
||||
value={selected.ceoName || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { ceoName: e.target.value })}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>사업자등록번호</Label>
|
||||
<Input
|
||||
value={selected.businessNumber || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { businessNumber: e.target.value })}
|
||||
placeholder="000-00-00000"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>주소</Label>
|
||||
<Input
|
||||
value={selected.address || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { address: e.target.value })}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>전화번호</Label>
|
||||
<Input
|
||||
value={selected.phone || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { phone: e.target.value })}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>이메일</Label>
|
||||
<Input
|
||||
value={selected.email || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { email: e.target.value })}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>저작권 문구</Label>
|
||||
<Input
|
||||
value={selected.copyright || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { copyright: e.target.value })}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 번호 리스트 컴포넌트 */}
|
||||
{selected.type === "numberedList" && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>리스트 제목</Label>
|
||||
<Input
|
||||
value={selected.listTitle || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { listTitle: e.target.value })}
|
||||
placeholder="예: 안내 사항"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>항목</Label>
|
||||
<div className="space-y-2 mt-2">
|
||||
{selected.listItems?.map((item, i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<span className="flex items-center justify-center w-6 text-sm text-muted-foreground">{i + 1}.</span>
|
||||
<Input
|
||||
value={item}
|
||||
onChange={(e) => {
|
||||
const newItems = [...(selected.listItems || [])];
|
||||
newItems[i] = e.target.value;
|
||||
updateComponent(selected.id, { listItems: newItems });
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newItems = selected.listItems?.filter((_, idx) => idx !== i);
|
||||
updateComponent(selected.id, { listItems: newItems });
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newItems = [...(selected.listItems || []), ""];
|
||||
updateComponent(selected.id, { listItems: newItems });
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
항목 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,408 @@
|
|||
/**
|
||||
* 임베드된 화면 컴포넌트
|
||||
* 다른 화면 안에 임베드되어 표시되는 화면
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { forwardRef, useImperativeHandle, useState, useEffect, useRef, useCallback } from "react";
|
||||
import type {
|
||||
ScreenEmbedding,
|
||||
DataReceiver,
|
||||
DataReceivable,
|
||||
EmbeddedScreenHandle,
|
||||
DataReceiveMode,
|
||||
} from "@/types/screen-embedding";
|
||||
import type { ComponentData } from "@/types/screen";
|
||||
import { logger } from "@/lib/utils/logger";
|
||||
import { applyMappingRules, filterDataByCondition } from "@/lib/utils/dataMapping";
|
||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
||||
import { ScreenContextProvider } from "@/contexts/ScreenContext";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
|
||||
interface EmbeddedScreenProps {
|
||||
embedding: ScreenEmbedding;
|
||||
onSelectionChanged?: (selectedRows: any[]) => void;
|
||||
position?: SplitPanelPosition; // 분할 패널 내 위치 (left/right)
|
||||
initialFormData?: Record<string, any>; // 🆕 수정 모드에서 전달되는 초기 데이터
|
||||
}
|
||||
|
||||
/**
|
||||
* 임베드된 화면 컴포넌트
|
||||
*/
|
||||
export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenProps>(
|
||||
({ embedding, onSelectionChanged, position, initialFormData }, ref) => {
|
||||
const [layout, setLayout] = useState<ComponentData[]>([]);
|
||||
const [selectedRows, setSelectedRows] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [screenInfo, setScreenInfo] = useState<any>(null);
|
||||
const [formData, setFormData] = useState<Record<string, any>>(initialFormData || {}); // 🆕 초기 데이터로 시작
|
||||
|
||||
// 컴포넌트 참조 맵
|
||||
const componentRefs = useRef<Map<string, DataReceivable>>(new Map());
|
||||
|
||||
// 분할 패널 컨텍스트 (분할 패널 내부에 있을 때만 사용)
|
||||
const splitPanelContext = useSplitPanelContext();
|
||||
|
||||
// 🆕 사용자 정보 가져오기 (저장 액션에 필요)
|
||||
const { userId, userName, companyCode } = useAuth();
|
||||
|
||||
// 컴포넌트들의 실제 영역 계산 (가로폭 맞춤을 위해)
|
||||
const contentBounds = React.useMemo(() => {
|
||||
if (layout.length === 0) return { width: 0, height: 0 };
|
||||
|
||||
let maxRight = 0;
|
||||
let maxBottom = 0;
|
||||
|
||||
layout.forEach((component) => {
|
||||
const { position: compPosition = { x: 0, y: 0 }, size = { width: 200, height: 40 } } = component;
|
||||
const right = (compPosition.x || 0) + (size.width || 200);
|
||||
const bottom = (compPosition.y || 0) + (size.height || 40);
|
||||
|
||||
if (right > maxRight) maxRight = right;
|
||||
if (bottom > maxBottom) maxBottom = bottom;
|
||||
});
|
||||
|
||||
return { width: maxRight, height: maxBottom };
|
||||
}, [layout]);
|
||||
|
||||
// 필드 값 변경 핸들러
|
||||
const handleFieldChange = useCallback((fieldName: string, value: any) => {
|
||||
console.log("📝 [EmbeddedScreen] 필드 값 변경:", { fieldName, value });
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 화면 데이터 로드
|
||||
useEffect(() => {
|
||||
loadScreenData();
|
||||
}, [embedding.childScreenId]);
|
||||
|
||||
// 🆕 initialFormData 변경 시 formData 업데이트 (수정 모드)
|
||||
useEffect(() => {
|
||||
if (initialFormData && Object.keys(initialFormData).length > 0) {
|
||||
console.log("📝 [EmbeddedScreen] 초기 폼 데이터 설정:", initialFormData);
|
||||
setFormData(initialFormData);
|
||||
}
|
||||
}, [initialFormData]);
|
||||
|
||||
// 선택 변경 이벤트 전파
|
||||
useEffect(() => {
|
||||
onSelectionChanged?.(selectedRows);
|
||||
}, [selectedRows, onSelectionChanged]);
|
||||
|
||||
/**
|
||||
* 화면 레이아웃 로드
|
||||
*/
|
||||
const loadScreenData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 화면 정보 로드 (screenApi.getScreen은 직접 ScreenDefinition 객체를 반환)
|
||||
const screenData = await screenApi.getScreen(embedding.childScreenId);
|
||||
console.log("📋 [EmbeddedScreen] 화면 정보 API 응답:", {
|
||||
screenId: embedding.childScreenId,
|
||||
hasData: !!screenData,
|
||||
tableName: screenData?.tableName,
|
||||
screenName: screenData?.name || screenData?.screenName,
|
||||
position,
|
||||
});
|
||||
if (screenData) {
|
||||
setScreenInfo(screenData);
|
||||
} else {
|
||||
console.warn("⚠️ [EmbeddedScreen] 화면 정보 로드 실패:", {
|
||||
screenId: embedding.childScreenId,
|
||||
});
|
||||
}
|
||||
|
||||
// 화면 레이아웃 로드 (별도 API)
|
||||
const layoutData = await screenApi.getLayout(embedding.childScreenId);
|
||||
|
||||
logger.info("📦 화면 레이아웃 로드 완료", {
|
||||
screenId: embedding.childScreenId,
|
||||
mode: embedding.mode,
|
||||
hasLayoutData: !!layoutData,
|
||||
componentsCount: layoutData?.components?.length || 0,
|
||||
position,
|
||||
});
|
||||
|
||||
if (layoutData && layoutData.components && Array.isArray(layoutData.components)) {
|
||||
setLayout(layoutData.components);
|
||||
|
||||
logger.info("✅ 임베드 화면 컴포넌트 설정 완료", {
|
||||
screenId: embedding.childScreenId,
|
||||
componentsCount: layoutData.components.length,
|
||||
});
|
||||
} else {
|
||||
logger.warn("⚠️ 화면에 컴포넌트가 없습니다", {
|
||||
screenId: embedding.childScreenId,
|
||||
layoutData,
|
||||
});
|
||||
setLayout([]);
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.error("화면 레이아웃 로드 실패", err);
|
||||
setError(err.message || "화면을 불러올 수 없습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 컴포넌트 등록
|
||||
*/
|
||||
const registerComponent = useCallback((id: string, component: DataReceivable) => {
|
||||
componentRefs.current.set(id, component);
|
||||
|
||||
logger.debug("컴포넌트 등록", {
|
||||
componentId: id,
|
||||
componentType: component.componentType,
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 컴포넌트 등록 해제
|
||||
*/
|
||||
const unregisterComponent = useCallback((id: string) => {
|
||||
componentRefs.current.delete(id);
|
||||
|
||||
logger.debug("컴포넌트 등록 해제", {
|
||||
componentId: id,
|
||||
});
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 선택된 행 업데이트
|
||||
*/
|
||||
const handleSelectionChange = useCallback((rows: any[]) => {
|
||||
setSelectedRows(rows);
|
||||
}, []);
|
||||
|
||||
// 외부에서 호출 가능한 메서드
|
||||
useImperativeHandle(ref, () => ({
|
||||
/**
|
||||
* 선택된 행 가져오기
|
||||
*/
|
||||
getSelectedRows: () => {
|
||||
return selectedRows;
|
||||
},
|
||||
|
||||
/**
|
||||
* 선택 초기화
|
||||
*/
|
||||
clearSelection: () => {
|
||||
setSelectedRows([]);
|
||||
},
|
||||
|
||||
/**
|
||||
* 데이터 수신
|
||||
*/
|
||||
receiveData: async (data: any[], receivers: DataReceiver[]) => {
|
||||
logger.info("데이터 수신 시작", {
|
||||
dataCount: data.length,
|
||||
receiversCount: receivers.length,
|
||||
});
|
||||
|
||||
const errors: Array<{ componentId: string; error: string }> = [];
|
||||
|
||||
// 각 데이터 수신자에게 데이터 전달
|
||||
for (const receiver of receivers) {
|
||||
try {
|
||||
const component = componentRefs.current.get(receiver.targetComponentId);
|
||||
|
||||
if (!component) {
|
||||
const errorMsg = `컴포넌트를 찾을 수 없습니다: ${receiver.targetComponentId}`;
|
||||
logger.warn(errorMsg);
|
||||
errors.push({
|
||||
componentId: receiver.targetComponentId,
|
||||
error: errorMsg,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 1. 조건 필터링
|
||||
let filteredData = data;
|
||||
if (receiver.condition) {
|
||||
filteredData = filterDataByCondition(data, receiver.condition);
|
||||
|
||||
logger.debug("조건 필터링 적용", {
|
||||
componentId: receiver.targetComponentId,
|
||||
originalCount: data.length,
|
||||
filteredCount: filteredData.length,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 매핑 규칙 적용
|
||||
const mappedData = applyMappingRules(filteredData, receiver.mappingRules);
|
||||
|
||||
logger.debug("매핑 규칙 적용", {
|
||||
componentId: receiver.targetComponentId,
|
||||
mappingRulesCount: receiver.mappingRules.length,
|
||||
});
|
||||
|
||||
// 3. 검증
|
||||
if (receiver.validation) {
|
||||
if (receiver.validation.required && mappedData.length === 0) {
|
||||
throw new Error("필수 데이터가 없습니다.");
|
||||
}
|
||||
|
||||
if (receiver.validation.minRows && mappedData.length < receiver.validation.minRows) {
|
||||
throw new Error(`최소 ${receiver.validation.minRows}개의 데이터가 필요합니다.`);
|
||||
}
|
||||
|
||||
if (receiver.validation.maxRows && mappedData.length > receiver.validation.maxRows) {
|
||||
throw new Error(`최대 ${receiver.validation.maxRows}개까지만 허용됩니다.`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 데이터 전달
|
||||
await component.receiveData(mappedData, receiver.mode);
|
||||
|
||||
logger.info("데이터 전달 성공", {
|
||||
componentId: receiver.targetComponentId,
|
||||
componentType: receiver.targetComponentType,
|
||||
mode: receiver.mode,
|
||||
dataCount: mappedData.length,
|
||||
});
|
||||
} catch (err: any) {
|
||||
logger.error("데이터 전달 실패", {
|
||||
componentId: receiver.targetComponentId,
|
||||
error: err.message,
|
||||
});
|
||||
|
||||
errors.push({
|
||||
componentId: receiver.targetComponentId,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`일부 컴포넌트에 데이터 전달 실패: ${errors.map((e) => e.componentId).join(", ")}`);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 현재 데이터 가져오기
|
||||
*/
|
||||
getData: () => {
|
||||
const allData: Record<string, any> = {};
|
||||
|
||||
componentRefs.current.forEach((component, id) => {
|
||||
allData[id] = component.getData();
|
||||
});
|
||||
|
||||
return allData;
|
||||
},
|
||||
}));
|
||||
|
||||
// 로딩 상태
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="border-primary h-8 w-8 animate-spin rounded-full border-4 border-t-transparent" />
|
||||
<p className="text-muted-foreground text-sm">화면을 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div className="bg-destructive/10 flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<svg className="text-destructive h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">화면을 불러올 수 없습니다</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">{error}</p>
|
||||
</div>
|
||||
<button onClick={loadScreenData} className="text-primary text-sm hover:underline">
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 화면 렌더링 - 절대 위치 기반 레이아웃 (원본 화면과 동일하게)
|
||||
// position을 ScreenContextProvider에 전달하여 중첩된 화면에서도 위치를 알 수 있게 함
|
||||
return (
|
||||
<ScreenContextProvider
|
||||
screenId={embedding.childScreenId}
|
||||
tableName={screenInfo?.tableName}
|
||||
splitPanelPosition={position}
|
||||
>
|
||||
<div className="relative h-full w-full overflow-auto p-4">
|
||||
{layout.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-muted-foreground text-sm">화면에 컴포넌트가 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{
|
||||
minHeight: contentBounds.height + 20, // 여유 공간 추가
|
||||
}}
|
||||
>
|
||||
{layout.map((component) => {
|
||||
const { position: compPosition = { x: 0, y: 0, z: 1 }, size = { width: 200, height: 40 } } = component;
|
||||
|
||||
// 컴포넌트가 컨테이너 너비를 초과하지 않도록 너비 조정
|
||||
// 부모 컨테이너의 100%를 기준으로 계산
|
||||
const componentStyle: React.CSSProperties = {
|
||||
left: compPosition.x || 0,
|
||||
top: compPosition.y || 0,
|
||||
width: size.width || 200,
|
||||
height: size.height || 40,
|
||||
zIndex: compPosition.z || 1,
|
||||
// 컴포넌트가 오른쪽 경계를 넘어가면 너비 조정
|
||||
maxWidth: `calc(100% - ${compPosition.x || 0}px)`,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={component.id}
|
||||
className="absolute"
|
||||
style={componentStyle}
|
||||
>
|
||||
<DynamicComponentRenderer
|
||||
component={component}
|
||||
isInteractive={true}
|
||||
screenId={embedding.childScreenId}
|
||||
tableName={screenInfo?.tableName}
|
||||
formData={formData}
|
||||
onFormDataChange={handleFieldChange}
|
||||
onSelectionChange={embedding.mode === "select" ? handleSelectionChange : undefined}
|
||||
userId={userId}
|
||||
userName={userName}
|
||||
companyCode={companyCode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScreenContextProvider>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
EmbeddedScreen.displayName = "EmbeddedScreen";
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
/**
|
||||
* 분할 패널 컴포넌트
|
||||
* 좌측과 우측에 화면을 임베드합니다.
|
||||
*
|
||||
* 데이터 전달은 좌측 화면에 배치된 버튼의 transferData 액션으로 처리됩니다.
|
||||
* 예: 좌측 화면에 TableListComponent + Button(transferData 액션) 배치
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useMemo } from "react";
|
||||
import { EmbeddedScreen } from "./EmbeddedScreen";
|
||||
import { Columns2 } from "lucide-react";
|
||||
import { SplitPanelProvider } from "@/contexts/SplitPanelContext";
|
||||
|
||||
interface ScreenSplitPanelProps {
|
||||
screenId?: number;
|
||||
config?: any; // 설정 패널에서 오는 config (leftScreenId, rightScreenId, splitRatio, resizable)
|
||||
initialFormData?: Record<string, any>; // 🆕 수정 모드에서 전달되는 초기 데이터
|
||||
}
|
||||
|
||||
/**
|
||||
* 분할 패널 컴포넌트
|
||||
* 순수하게 화면 분할 기능만 제공합니다.
|
||||
*/
|
||||
export function ScreenSplitPanel({ screenId, config, initialFormData }: ScreenSplitPanelProps) {
|
||||
// config에서 splitRatio 추출 (기본값 50)
|
||||
const configSplitRatio = config?.splitRatio ?? 50;
|
||||
|
||||
console.log("🎯 [ScreenSplitPanel] 렌더링됨!", {
|
||||
screenId,
|
||||
config,
|
||||
leftScreenId: config?.leftScreenId,
|
||||
rightScreenId: config?.rightScreenId,
|
||||
configSplitRatio,
|
||||
configKeys: config ? Object.keys(config) : [],
|
||||
});
|
||||
|
||||
// 🆕 initialFormData 별도 로그 (명확한 확인)
|
||||
console.log("📝 [ScreenSplitPanel] initialFormData 확인:", {
|
||||
hasInitialFormData: !!initialFormData,
|
||||
initialFormDataKeys: initialFormData ? Object.keys(initialFormData) : [],
|
||||
initialFormData: initialFormData,
|
||||
});
|
||||
|
||||
// 드래그로 조절 가능한 splitRatio 상태
|
||||
const [splitRatio, setSplitRatio] = useState(configSplitRatio);
|
||||
|
||||
// config.splitRatio가 변경되면 동기화 (설정 패널에서 변경 시)
|
||||
React.useEffect(() => {
|
||||
console.log("📐 [ScreenSplitPanel] splitRatio 동기화:", { configSplitRatio, currentSplitRatio: splitRatio });
|
||||
setSplitRatio(configSplitRatio);
|
||||
}, [configSplitRatio]);
|
||||
|
||||
// 설정 패널에서 오는 간단한 config를 임베딩 설정으로 변환
|
||||
const leftEmbedding = config?.leftScreenId
|
||||
? {
|
||||
id: 1,
|
||||
parentScreenId: screenId || 0,
|
||||
childScreenId: config.leftScreenId,
|
||||
position: "left" as const,
|
||||
mode: "view" as const, // 기본 view 모드 (select는 테이블 자체 설정)
|
||||
config: {},
|
||||
companyCode: "*",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
: null;
|
||||
|
||||
const rightEmbedding = config?.rightScreenId
|
||||
? {
|
||||
id: 2,
|
||||
parentScreenId: screenId || 0,
|
||||
childScreenId: config.rightScreenId,
|
||||
position: "right" as const,
|
||||
mode: "view" as const, // 기본 view 모드
|
||||
config: {},
|
||||
companyCode: "*",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
: null;
|
||||
|
||||
/**
|
||||
* 리사이저 드래그 핸들러
|
||||
*/
|
||||
const handleResize = useCallback((newRatio: number) => {
|
||||
setSplitRatio(Math.max(20, Math.min(80, newRatio)));
|
||||
}, []);
|
||||
|
||||
// config가 없는 경우 (디자이너 모드 또는 초기 상태)
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="border-muted-foreground/25 flex h-full items-center justify-center rounded-lg border-2 border-dashed">
|
||||
<div className="space-y-4 p-6 text-center">
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<div className="bg-muted flex h-16 w-16 items-center justify-center rounded-lg">
|
||||
<Columns2 className="text-muted-foreground h-8 w-8" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-2 text-base font-semibold">화면 분할 패널</p>
|
||||
<p className="text-muted-foreground/60 mb-1 text-xs">좌우로 화면을 나눕니다</p>
|
||||
<p className="text-muted-foreground/60 text-xs">
|
||||
우측 속성 패널 → 상세 설정에서 좌측/우측 화면을 선택하세요
|
||||
</p>
|
||||
<p className="text-muted-foreground/60 mt-2 text-[10px]">
|
||||
💡 데이터 전달: 좌측 화면에 버튼 배치 후 transferData 액션 설정
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 좌측 또는 우측 화면이 설정되지 않은 경우 안내 메시지 표시
|
||||
const hasLeftScreen = !!leftEmbedding;
|
||||
const hasRightScreen = !!rightEmbedding;
|
||||
|
||||
// 분할 패널 고유 ID 생성
|
||||
const splitPanelId = useMemo(() => `split-panel-${screenId || "unknown"}-${Date.now()}`, [screenId]);
|
||||
|
||||
return (
|
||||
<SplitPanelProvider
|
||||
splitPanelId={splitPanelId}
|
||||
leftScreenId={config?.leftScreenId || null}
|
||||
rightScreenId={config?.rightScreenId || null}
|
||||
>
|
||||
<div className="flex h-full">
|
||||
{/* 좌측 패널 */}
|
||||
<div style={{ width: `${splitRatio}%` }} className="h-full flex-shrink-0 overflow-hidden border-r">
|
||||
{hasLeftScreen ? (
|
||||
<EmbeddedScreen embedding={leftEmbedding!} position="left" initialFormData={initialFormData} />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center bg-muted/30">
|
||||
<p className="text-muted-foreground text-sm">좌측 화면을 선택하세요</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 리사이저 */}
|
||||
{config?.resizable !== false && (
|
||||
<div
|
||||
className="group bg-border hover:bg-primary/20 relative w-1 flex-shrink-0 cursor-col-resize transition-colors"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startRatio = splitRatio;
|
||||
const containerWidth = e.currentTarget.parentElement!.offsetWidth;
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const deltaX = moveEvent.clientX - startX;
|
||||
const deltaRatio = (deltaX / containerWidth) * 100;
|
||||
handleResize(startRatio + deltaRatio);
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
}}
|
||||
>
|
||||
<div className="bg-primary absolute inset-y-0 left-1/2 w-1 -translate-x-1/2 opacity-0 transition-opacity group-hover:opacity-100" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 우측 패널 */}
|
||||
<div style={{ width: `${100 - splitRatio}%` }} className="h-full flex-shrink-0 overflow-hidden">
|
||||
{hasRightScreen ? (
|
||||
<EmbeddedScreen embedding={rightEmbedding!} position="right" initialFormData={initialFormData} />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center bg-muted/30">
|
||||
<p className="text-muted-foreground text-sm">우측 화면을 선택하세요</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SplitPanelProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
/**
|
||||
* 화면 임베딩 및 데이터 전달 시스템 컴포넌트
|
||||
*/
|
||||
|
||||
export { EmbeddedScreen } from "./EmbeddedScreen";
|
||||
export { ScreenSplitPanel } from "./ScreenSplitPanel";
|
||||
|
||||
|
|
@ -6,7 +6,6 @@ import {
|
|||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -15,11 +14,13 @@ import { Label } from "@/components/ui/label";
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Search, X, Check, ChevronsUpDown, Database } from "lucide-react";
|
||||
import { Search, X, Check, ChevronsUpDown, Database, Globe } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { screenApi, tableTypeApi } from "@/lib/api/screen";
|
||||
import { ScreenDefinition } from "@/types/screen";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection";
|
||||
|
||||
interface CreateScreenModalProps {
|
||||
open: boolean;
|
||||
|
|
@ -39,12 +40,22 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|||
const [tableSearchTerm, setTableSearchTerm] = useState("");
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 데이터 소스 타입 (database: 데이터베이스, restapi: REST API)
|
||||
const [dataSourceType, setDataSourceType] = useState<"database" | "restapi">("database");
|
||||
|
||||
// 외부 DB 연결 관련 상태
|
||||
const [selectedDbSource, setSelectedDbSource] = useState<"internal" | number>("internal");
|
||||
const [externalConnections, setExternalConnections] = useState<any[]>([]);
|
||||
const [externalTableList, setExternalTableList] = useState<string[]>([]);
|
||||
const [loadingExternalTables, setLoadingExternalTables] = useState(false);
|
||||
const [openDbSourceCombobox, setOpenDbSourceCombobox] = useState(false);
|
||||
|
||||
// REST API 연결 관련 상태
|
||||
const [restApiConnections, setRestApiConnections] = useState<ExternalRestApiConnection[]>([]);
|
||||
const [selectedRestApiId, setSelectedRestApiId] = useState<number | null>(null);
|
||||
const [openRestApiCombobox, setOpenRestApiCombobox] = useState(false);
|
||||
const [restApiEndpoint, setRestApiEndpoint] = useState("");
|
||||
const [restApiJsonPath, setRestApiJsonPath] = useState("data"); // 응답에서 데이터 추출 경로
|
||||
// 화면 코드 자동 생성
|
||||
const generateCode = async () => {
|
||||
try {
|
||||
|
|
@ -109,6 +120,21 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|||
loadConnections();
|
||||
}, [open]);
|
||||
|
||||
// REST API 연결 목록 로드
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const loadRestApiConnections = async () => {
|
||||
try {
|
||||
const connections = await ExternalRestApiConnectionAPI.getConnections({ is_active: "Y" });
|
||||
setRestApiConnections(connections);
|
||||
} catch (error) {
|
||||
console.error("Failed to load REST API connections:", error);
|
||||
setRestApiConnections([]);
|
||||
}
|
||||
};
|
||||
loadRestApiConnections();
|
||||
}, [open]);
|
||||
|
||||
// 외부 DB 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
if (selectedDbSource === "internal" || !selectedDbSource) {
|
||||
|
|
@ -160,8 +186,15 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|||
}, [open, screenCode]);
|
||||
|
||||
const isValid = useMemo(() => {
|
||||
return screenName.trim().length > 0 && screenCode.trim().length > 0 && tableName.trim().length > 0;
|
||||
}, [screenName, screenCode, tableName]);
|
||||
const baseValid = screenName.trim().length > 0 && screenCode.trim().length > 0;
|
||||
|
||||
if (dataSourceType === "database") {
|
||||
return baseValid && tableName.trim().length > 0;
|
||||
} else {
|
||||
// REST API: 연결 선택 필수
|
||||
return baseValid && selectedRestApiId !== null;
|
||||
}
|
||||
}, [screenName, screenCode, tableName, dataSourceType, selectedRestApiId]);
|
||||
|
||||
// 테이블 필터링 (내부 DB용)
|
||||
const filteredTables = useMemo(() => {
|
||||
|
|
@ -186,17 +219,30 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|||
setSubmitting(true);
|
||||
const companyCode = (user as any)?.company_code || (user as any)?.companyCode || "*";
|
||||
|
||||
// DB 소스 정보 추가
|
||||
const created = await screenApi.createScreen({
|
||||
// 데이터 소스 타입에 따라 다른 정보 전달
|
||||
const createData: any = {
|
||||
screenName: screenName.trim(),
|
||||
screenCode: screenCode.trim(),
|
||||
tableName: tableName.trim(),
|
||||
companyCode,
|
||||
description: description.trim() || undefined,
|
||||
createdBy: (user as any)?.userId,
|
||||
dbSourceType: selectedDbSource === "internal" ? "internal" : "external",
|
||||
dbConnectionId: selectedDbSource === "internal" ? undefined : Number(selectedDbSource),
|
||||
} as any);
|
||||
dataSourceType: dataSourceType,
|
||||
};
|
||||
|
||||
if (dataSourceType === "database") {
|
||||
// 데이터베이스 소스
|
||||
createData.tableName = tableName.trim();
|
||||
createData.dbSourceType = selectedDbSource === "internal" ? "internal" : "external";
|
||||
createData.dbConnectionId = selectedDbSource === "internal" ? undefined : Number(selectedDbSource);
|
||||
} else {
|
||||
// REST API 소스
|
||||
createData.tableName = `_restapi_${selectedRestApiId}`; // REST API용 가상 테이블명
|
||||
createData.restApiConnectionId = selectedRestApiId;
|
||||
createData.restApiEndpoint = restApiEndpoint.trim() || undefined;
|
||||
createData.restApiJsonPath = restApiJsonPath.trim() || "data";
|
||||
}
|
||||
|
||||
const created = await screenApi.createScreen(createData);
|
||||
|
||||
// 날짜 필드 보정
|
||||
const mapped: ScreenDefinition = {
|
||||
|
|
@ -207,11 +253,16 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|||
|
||||
onCreated?.(mapped);
|
||||
onOpenChange(false);
|
||||
// 폼 초기화
|
||||
setScreenName("");
|
||||
setScreenCode("");
|
||||
setTableName("");
|
||||
setDescription("");
|
||||
setSelectedDbSource("internal");
|
||||
setDataSourceType("database");
|
||||
setSelectedRestApiId(null);
|
||||
setRestApiEndpoint("");
|
||||
setRestApiJsonPath("data");
|
||||
} catch (e) {
|
||||
// 필요 시 토스트 추가 가능
|
||||
} finally {
|
||||
|
|
@ -263,83 +314,210 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* DB 소스 선택 */}
|
||||
{/* 데이터 소스 타입 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dbSource">데이터베이스 소스</Label>
|
||||
<Popover open={openDbSourceCombobox} onOpenChange={setOpenDbSourceCombobox}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openDbSourceCombobox}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4" />
|
||||
{selectedDbSource === "internal"
|
||||
? "내부 데이터베이스"
|
||||
: externalConnections.find((conn) => conn.id === selectedDbSource)?.connection_name ||
|
||||
"선택하세요"}
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="데이터베이스 검색..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>데이터베이스를 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="internal"
|
||||
onSelect={() => {
|
||||
setSelectedDbSource("internal");
|
||||
setTableName("");
|
||||
setTableSearchTerm("");
|
||||
setOpenDbSourceCombobox(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-4 w-4", selectedDbSource === "internal" ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
<Database className="mr-2 h-4 w-4 text-blue-500" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">내부 데이터베이스</span>
|
||||
<span className="text-xs text-gray-500">PostgreSQL (현재 시스템)</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
{externalConnections.map((conn: any) => (
|
||||
<CommandItem
|
||||
key={conn.id}
|
||||
value={`${conn.connection_name} ${conn.db_type}`}
|
||||
onSelect={() => {
|
||||
setSelectedDbSource(conn.id);
|
||||
setTableName("");
|
||||
setTableSearchTerm("");
|
||||
setOpenDbSourceCombobox(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-4 w-4", selectedDbSource === conn.id ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
<Database className="mr-2 h-4 w-4 text-green-500" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{conn.connection_name}</span>
|
||||
<span className="text-xs text-gray-500">{conn.db_type?.toUpperCase()}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-xs text-gray-500">화면에서 사용할 데이터베이스를 선택합니다</p>
|
||||
<Label>데이터 소스 타입</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={dataSourceType === "database" ? "default" : "outline"}
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setDataSourceType("database");
|
||||
setSelectedRestApiId(null);
|
||||
}}
|
||||
>
|
||||
<Database className="mr-2 h-4 w-4" />
|
||||
데이터베이스
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={dataSourceType === "restapi" ? "default" : "outline"}
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setDataSourceType("restapi");
|
||||
setTableName("");
|
||||
setSelectedDbSource("internal");
|
||||
}}
|
||||
>
|
||||
<Globe className="mr-2 h-4 w-4" />
|
||||
REST API
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 선택 */}
|
||||
{/* 데이터베이스 소스 설정 */}
|
||||
{dataSourceType === "database" && (
|
||||
<>
|
||||
{/* DB 소스 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dbSource">데이터베이스 소스</Label>
|
||||
<Popover open={openDbSourceCombobox} onOpenChange={setOpenDbSourceCombobox}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openDbSourceCombobox}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4" />
|
||||
{selectedDbSource === "internal"
|
||||
? "내부 데이터베이스"
|
||||
: externalConnections.find((conn) => conn.id === selectedDbSource)?.connection_name ||
|
||||
"선택하세요"}
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="데이터베이스 검색..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>데이터베이스를 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="internal"
|
||||
onSelect={() => {
|
||||
setSelectedDbSource("internal");
|
||||
setTableName("");
|
||||
setTableSearchTerm("");
|
||||
setOpenDbSourceCombobox(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-4 w-4", selectedDbSource === "internal" ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
<Database className="mr-2 h-4 w-4 text-blue-500" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">내부 데이터베이스</span>
|
||||
<span className="text-xs text-gray-500">PostgreSQL (현재 시스템)</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
{externalConnections.map((conn: any) => (
|
||||
<CommandItem
|
||||
key={conn.id}
|
||||
value={`${conn.connection_name} ${conn.db_type}`}
|
||||
onSelect={() => {
|
||||
setSelectedDbSource(conn.id);
|
||||
setTableName("");
|
||||
setTableSearchTerm("");
|
||||
setOpenDbSourceCombobox(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-4 w-4", selectedDbSource === conn.id ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
<Database className="mr-2 h-4 w-4 text-green-500" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{conn.connection_name}</span>
|
||||
<span className="text-xs text-gray-500">{conn.db_type?.toUpperCase()}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-xs text-gray-500">화면에서 사용할 데이터베이스를 선택합니다</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* REST API 소스 설정 */}
|
||||
{dataSourceType === "restapi" && (
|
||||
<>
|
||||
{/* REST API 연결 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="restApiConnection">REST API 연결 *</Label>
|
||||
<Popover open={openRestApiCombobox} onOpenChange={setOpenRestApiCombobox}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openRestApiCombobox}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
{selectedRestApiId
|
||||
? restApiConnections.find((conn) => conn.id === selectedRestApiId)?.connection_name ||
|
||||
"선택하세요"
|
||||
: "REST API 연결을 선택하세요"}
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="REST API 검색..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>등록된 REST API 연결이 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{restApiConnections.map((conn) => (
|
||||
<CommandItem
|
||||
key={conn.id}
|
||||
value={`${conn.connection_name} ${conn.base_url}`}
|
||||
onSelect={() => {
|
||||
setSelectedRestApiId(conn.id!);
|
||||
setRestApiEndpoint(conn.endpoint_path || "");
|
||||
setOpenRestApiCombobox(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-4 w-4", selectedRestApiId === conn.id ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
<Globe className="mr-2 h-4 w-4 text-purple-500" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{conn.connection_name}</span>
|
||||
<span className="text-xs text-gray-500 truncate max-w-[300px]">{conn.base_url}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-xs text-gray-500">
|
||||
등록된 REST API 연결을 선택합니다.
|
||||
<Link href="/admin/externalRestApi" className="ml-1 text-primary hover:underline">
|
||||
새 연결 등록
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 엔드포인트 경로 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="restApiEndpoint">엔드포인트 경로</Label>
|
||||
<Input
|
||||
id="restApiEndpoint"
|
||||
value={restApiEndpoint}
|
||||
onChange={(e) => setRestApiEndpoint(e.target.value)}
|
||||
placeholder="/api/data 또는 /users"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">기본 URL 뒤에 추가될 경로 (선택사항)</p>
|
||||
</div>
|
||||
|
||||
{/* JSON Path */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="restApiJsonPath">데이터 경로 (JSON Path)</Label>
|
||||
<Input
|
||||
id="restApiJsonPath"
|
||||
value={restApiJsonPath}
|
||||
onChange={(e) => setRestApiJsonPath(e.target.value)}
|
||||
placeholder="data 또는 result.items"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">API 응답에서 데이터 배열을 추출할 경로 (예: data, result.items)</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 테이블 선택 (데이터베이스 모드일 때만) */}
|
||||
{dataSourceType === "database" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tableName">테이블</Label>
|
||||
<Label htmlFor="tableName">테이블 *</Label>
|
||||
<Select
|
||||
value={tableName}
|
||||
onValueChange={setTableName}
|
||||
|
|
@ -422,11 +600,7 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Input id="description" value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter className="mt-4">
|
||||
|
|
|
|||
|
|
@ -408,6 +408,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
value: currentValue,
|
||||
onChange: (value: any) => handleFormDataChange(fieldName, value),
|
||||
onFormDataChange: handleFormDataChange,
|
||||
formData: formData, // 🆕 전체 formData 전달
|
||||
isInteractive: true,
|
||||
readonly: readonly,
|
||||
required: required,
|
||||
|
|
@ -415,6 +416,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
className: "w-full h-full",
|
||||
isInModal: isInModal, // 🆕 EditModal 내부 여부 전달
|
||||
onSave: onSave, // 🆕 EditModal의 handleSave 콜백 전달
|
||||
groupedData: groupedData, // 🆕 그룹 데이터 전달 (RepeatScreenModal용)
|
||||
}}
|
||||
config={widget.webTypeConfig}
|
||||
onEvent={(event: string, data: any) => {
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ const calculateGridInfo = (width: number, height: number, settings: any) => {
|
|||
import { GroupingToolbar } from "./GroupingToolbar";
|
||||
import { screenApi, tableTypeApi } from "@/lib/api/screen";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
import { ExternalRestApiConnectionAPI } from "@/lib/api/externalRestApiConnection";
|
||||
import { toast } from "sonner";
|
||||
import { MenuAssignmentModal } from "./MenuAssignmentModal";
|
||||
import { FileAttachmentDetailModal } from "./FileAttachmentDetailModal";
|
||||
|
|
@ -527,9 +528,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
|
||||
// 🆕 size 변경 시 style도 함께 업데이트 (파란 테두리와 실제 크기 동기화)
|
||||
if (path === "size.width" || path === "size.height" || path === "size") {
|
||||
if (!newComp.style) {
|
||||
newComp.style = {};
|
||||
}
|
||||
// 🔧 style 객체를 새로 복사하여 불변성 유지
|
||||
newComp.style = { ...(newComp.style || {}) };
|
||||
|
||||
if (path === "size.width") {
|
||||
newComp.style.width = `${value}px`;
|
||||
|
|
@ -835,9 +835,52 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
}
|
||||
}, []);
|
||||
|
||||
// 화면의 기본 테이블 정보 로드 (원래대로 복원)
|
||||
// 화면의 기본 테이블/REST API 정보 로드
|
||||
useEffect(() => {
|
||||
const loadScreenTable = async () => {
|
||||
const loadScreenDataSource = async () => {
|
||||
// REST API 데이터 소스인 경우
|
||||
if (selectedScreen?.dataSourceType === "restapi" && selectedScreen?.restApiConnectionId) {
|
||||
try {
|
||||
const restApiData = await ExternalRestApiConnectionAPI.fetchData(
|
||||
selectedScreen.restApiConnectionId,
|
||||
selectedScreen.restApiEndpoint,
|
||||
selectedScreen.restApiJsonPath || "data",
|
||||
);
|
||||
|
||||
// REST API 응답에서 컬럼 정보 생성
|
||||
const columns: ColumnInfo[] = restApiData.columns.map((col) => ({
|
||||
tableName: `restapi_${selectedScreen.restApiConnectionId}`,
|
||||
columnName: col.columnName,
|
||||
columnLabel: col.columnLabel,
|
||||
dataType: col.dataType === "string" ? "varchar" : col.dataType === "number" ? "numeric" : col.dataType,
|
||||
webType: col.dataType === "number" ? "number" : "text",
|
||||
input_type: "text",
|
||||
widgetType: col.dataType === "number" ? "number" : "text",
|
||||
isNullable: "YES",
|
||||
required: false,
|
||||
}));
|
||||
|
||||
const tableInfo: TableInfo = {
|
||||
tableName: `restapi_${selectedScreen.restApiConnectionId}`,
|
||||
tableLabel: restApiData.connectionInfo.connectionName || "REST API 데이터",
|
||||
columns,
|
||||
};
|
||||
|
||||
setTables([tableInfo]);
|
||||
console.log("REST API 데이터 소스 로드 완료:", {
|
||||
connectionName: restApiData.connectionInfo.connectionName,
|
||||
columnsCount: columns.length,
|
||||
rowsCount: restApiData.total,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("REST API 데이터 소스 로드 실패:", error);
|
||||
toast.error("REST API 데이터를 불러오는데 실패했습니다.");
|
||||
setTables([]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 데이터베이스 데이터 소스인 경우 (기존 로직)
|
||||
const tableName = selectedScreen?.tableName;
|
||||
if (!tableName) {
|
||||
setTables([]);
|
||||
|
|
@ -859,16 +902,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => {
|
||||
const widgetType = col.widgetType || col.widget_type || col.webType || col.web_type;
|
||||
|
||||
// 🔍 이미지 타입 디버깅
|
||||
// if (widgetType === "image" || col.webType === "image" || col.web_type === "image") {
|
||||
// console.log("🖼️ 이미지 컬럼 발견:", {
|
||||
// columnName: col.columnName || col.column_name,
|
||||
// widgetType,
|
||||
// webType: col.webType || col.web_type,
|
||||
// rawData: col,
|
||||
// });
|
||||
// }
|
||||
|
||||
return {
|
||||
tableName: col.tableName || tableName,
|
||||
columnName: col.columnName || col.column_name,
|
||||
|
|
@ -899,8 +932,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
}
|
||||
};
|
||||
|
||||
loadScreenTable();
|
||||
}, [selectedScreen?.tableName, selectedScreen?.screenName]);
|
||||
loadScreenDataSource();
|
||||
}, [selectedScreen?.tableName, selectedScreen?.screenName, selectedScreen?.dataSourceType, selectedScreen?.restApiConnectionId, selectedScreen?.restApiEndpoint, selectedScreen?.restApiJsonPath]);
|
||||
|
||||
// 화면 레이아웃 로드
|
||||
useEffect(() => {
|
||||
|
|
@ -962,6 +995,17 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
// console.log("🔧 기본 해상도 적용:", defaultResolution);
|
||||
}
|
||||
|
||||
// 🔍 디버깅: 로드된 버튼 컴포넌트의 action 확인
|
||||
const buttonComponents = layoutWithDefaultGrid.components.filter(
|
||||
(c: any) => c.componentType?.startsWith("button")
|
||||
);
|
||||
console.log("🔍 [로드] 버튼 컴포넌트 action 확인:", buttonComponents.map((c: any) => ({
|
||||
id: c.id,
|
||||
type: c.componentType,
|
||||
actionType: c.componentConfig?.action?.type,
|
||||
fullAction: c.componentConfig?.action,
|
||||
})));
|
||||
|
||||
setLayout(layoutWithDefaultGrid);
|
||||
setHistory([layoutWithDefaultGrid]);
|
||||
setHistoryIndex(0);
|
||||
|
|
@ -1419,7 +1463,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
};
|
||||
// 🔍 버튼 컴포넌트들의 action.type 확인
|
||||
const buttonComponents = layoutWithResolution.components.filter(
|
||||
(c: any) => c.type === "button" || c.type === "button-primary" || c.type === "button-secondary",
|
||||
(c: any) => c.componentType?.startsWith("button") || c.type === "button" || c.type === "button-primary",
|
||||
);
|
||||
console.log("💾 저장 시작:", {
|
||||
screenId: selectedScreen.screenId,
|
||||
|
|
@ -1429,6 +1473,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
buttonComponents: buttonComponents.map((c: any) => ({
|
||||
id: c.id,
|
||||
type: c.type,
|
||||
componentType: c.componentType,
|
||||
text: c.componentConfig?.text,
|
||||
actionType: c.componentConfig?.action?.type,
|
||||
fullAction: c.componentConfig?.action,
|
||||
|
|
|
|||
|
|
@ -83,6 +83,14 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
const [blockTablePopoverOpen, setBlockTablePopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 테이블 Popover 열림 상태
|
||||
const [blockColumnPopoverOpen, setBlockColumnPopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 컬럼 Popover 열림 상태
|
||||
|
||||
// 🆕 데이터 전달 필드 매핑용 상태
|
||||
const [mappingSourceColumns, setMappingSourceColumns] = useState<Array<{ name: string; label: string }>>([]);
|
||||
const [mappingTargetColumns, setMappingTargetColumns] = useState<Array<{ name: string; label: string }>>([]);
|
||||
const [mappingSourcePopoverOpen, setMappingSourcePopoverOpen] = useState<Record<number, boolean>>({});
|
||||
const [mappingTargetPopoverOpen, setMappingTargetPopoverOpen] = useState<Record<number, boolean>>({});
|
||||
const [mappingSourceSearch, setMappingSourceSearch] = useState<Record<number, string>>({});
|
||||
const [mappingTargetSearch, setMappingTargetSearch] = useState<Record<number, string>>({});
|
||||
|
||||
// 🎯 플로우 위젯이 화면에 있는지 확인
|
||||
const hasFlowWidget = useMemo(() => {
|
||||
const found = allComponents.some((comp: any) => {
|
||||
|
|
@ -258,6 +266,58 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// 🆕 데이터 전달 소스/타겟 테이블 컬럼 로드
|
||||
useEffect(() => {
|
||||
const sourceTable = config.action?.dataTransfer?.sourceTable;
|
||||
const targetTable = config.action?.dataTransfer?.targetTable;
|
||||
|
||||
const loadColumns = async () => {
|
||||
if (sourceTable) {
|
||||
try {
|
||||
const response = await apiClient.get(`/table-management/tables/${sourceTable}/columns`);
|
||||
if (response.data.success) {
|
||||
let columnData = response.data.data;
|
||||
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
|
||||
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
|
||||
|
||||
if (Array.isArray(columnData)) {
|
||||
const columns = columnData.map((col: any) => ({
|
||||
name: col.name || col.columnName,
|
||||
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
|
||||
}));
|
||||
setMappingSourceColumns(columns);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("소스 테이블 컬럼 로드 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (targetTable) {
|
||||
try {
|
||||
const response = await apiClient.get(`/table-management/tables/${targetTable}/columns`);
|
||||
if (response.data.success) {
|
||||
let columnData = response.data.data;
|
||||
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
|
||||
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
|
||||
|
||||
if (Array.isArray(columnData)) {
|
||||
const columns = columnData.map((col: any) => ({
|
||||
name: col.name || col.columnName,
|
||||
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
|
||||
}));
|
||||
setMappingTargetColumns(columns);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("타겟 테이블 컬럼 로드 실패:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadColumns();
|
||||
}, [config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable]);
|
||||
|
||||
// 화면 목록 가져오기 (현재 편집 중인 화면의 회사 코드 기준)
|
||||
useEffect(() => {
|
||||
const fetchScreens = async () => {
|
||||
|
|
@ -434,6 +494,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
<SelectItem value="edit">편집</SelectItem>
|
||||
<SelectItem value="copy">복사 (품목코드 초기화)</SelectItem>
|
||||
<SelectItem value="navigate">페이지 이동</SelectItem>
|
||||
<SelectItem value="transferData">📦 데이터 전달</SelectItem>
|
||||
<SelectItem value="openModalWithData">데이터 전달 + 모달 열기 🆕</SelectItem>
|
||||
<SelectItem value="modal">모달 열기</SelectItem>
|
||||
<SelectItem value="control">제어 흐름</SelectItem>
|
||||
|
|
@ -442,6 +503,8 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
<SelectItem value="excel_upload">엑셀 업로드</SelectItem>
|
||||
<SelectItem value="barcode_scan">바코드 스캔</SelectItem>
|
||||
<SelectItem value="code_merge">코드 병합</SelectItem>
|
||||
<SelectItem value="geolocation">위치정보 가져오기</SelectItem>
|
||||
<SelectItem value="update_field">필드 값 변경</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
@ -1601,6 +1664,875 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 위치정보 가져오기 설정 */}
|
||||
{(component.componentConfig?.action?.type || "save") === "geolocation" && (
|
||||
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
|
||||
<h4 className="text-sm font-medium text-foreground">📍 위치정보 설정</h4>
|
||||
|
||||
{/* 테이블 선택 */}
|
||||
<div>
|
||||
<Label htmlFor="geolocation-table">
|
||||
저장할 테이블 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={config.action?.geolocationTableName || currentTableName || ""}
|
||||
onValueChange={(value) => {
|
||||
onUpdateProperty("componentConfig.action.geolocationTableName", value);
|
||||
onUpdateProperty("componentConfig.action.geolocationLatField", "");
|
||||
onUpdateProperty("componentConfig.action.geolocationLngField", "");
|
||||
onUpdateProperty("componentConfig.action.geolocationAccuracyField", "");
|
||||
onUpdateProperty("componentConfig.action.geolocationTimestampField", "");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTables.map((table) => (
|
||||
<SelectItem key={table.name} value={table.name} className="text-xs">
|
||||
{table.label || table.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
위치 정보를 저장할 테이블 (기본: 현재 화면 테이블)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="geolocation-lat-field">
|
||||
위도 저장 필드 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="geolocation-lat-field"
|
||||
placeholder="예: latitude"
|
||||
value={config.action?.geolocationLatField || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationLatField", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="geolocation-lng-field">
|
||||
경도 저장 필드 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="geolocation-lng-field"
|
||||
placeholder="예: longitude"
|
||||
value={config.action?.geolocationLngField || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationLngField", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="geolocation-accuracy-field">정확도 저장 필드 (선택)</Label>
|
||||
<Input
|
||||
id="geolocation-accuracy-field"
|
||||
placeholder="예: accuracy"
|
||||
value={config.action?.geolocationAccuracyField || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationAccuracyField", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="geolocation-timestamp-field">타임스탬프 저장 필드 (선택)</Label>
|
||||
<Input
|
||||
id="geolocation-timestamp-field"
|
||||
placeholder="예: location_time"
|
||||
value={config.action?.geolocationTimestampField || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationTimestampField", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="geolocation-high-accuracy">고정밀 모드</Label>
|
||||
<p className="text-xs text-muted-foreground">GPS를 사용하여 더 정확한 위치 (배터리 소모 증가)</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="geolocation-high-accuracy"
|
||||
checked={config.action?.geolocationHighAccuracy !== false}
|
||||
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.geolocationHighAccuracy", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="geolocation-auto-save">위치 가져온 후 자동 저장</Label>
|
||||
<p className="text-xs text-muted-foreground">위치 정보를 가져온 후 자동으로 폼을 저장합니다</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="geolocation-auto-save"
|
||||
checked={config.action?.geolocationAutoSave === true}
|
||||
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.geolocationAutoSave", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
|
||||
<p className="text-xs text-blue-900 dark:text-blue-100">
|
||||
<strong>사용 방법:</strong>
|
||||
<br />
|
||||
1. 버튼을 클릭하면 브라우저가 위치 권한을 요청합니다
|
||||
<br />
|
||||
2. 사용자가 허용하면 현재 GPS 좌표를 가져옵니다
|
||||
<br />
|
||||
3. 위도/경도가 지정된 필드에 자동으로 입력됩니다
|
||||
<br />
|
||||
<br />
|
||||
<strong>참고:</strong> HTTPS 환경에서만 위치정보가 작동합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필드 값 변경 설정 */}
|
||||
{(component.componentConfig?.action?.type || "save") === "update_field" && (
|
||||
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
|
||||
<h4 className="text-sm font-medium text-foreground">📝 필드 값 변경 설정</h4>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="update-table">
|
||||
대상 테이블 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={config.action?.updateTableName || currentTableName || ""}
|
||||
onValueChange={(value) => {
|
||||
onUpdateProperty("componentConfig.action.updateTableName", value);
|
||||
onUpdateProperty("componentConfig.action.updateTargetField", "");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTables.map((table) => (
|
||||
<SelectItem key={table.name} value={table.name} className="text-xs">
|
||||
{table.label || table.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
필드 값을 변경할 테이블 (기본: 현재 화면 테이블)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="update-target-field">
|
||||
변경할 필드명 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="update-target-field"
|
||||
placeholder="예: status"
|
||||
value={config.action?.updateTargetField || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.updateTargetField", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">변경할 DB 컬럼</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="update-target-value">
|
||||
변경할 값 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="update-target-value"
|
||||
placeholder="예: active"
|
||||
value={config.action?.updateTargetValue || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.updateTargetValue", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">변경할 값 (문자열, 숫자)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="update-auto-save">변경 후 자동 저장</Label>
|
||||
<p className="text-xs text-muted-foreground">버튼 클릭 시 즉시 DB에 저장</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="update-auto-save"
|
||||
checked={config.action?.updateAutoSave !== false}
|
||||
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.updateAutoSave", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="update-confirm-message">확인 메시지 (선택)</Label>
|
||||
<Input
|
||||
id="update-confirm-message"
|
||||
placeholder="예: 운행을 시작하시겠습니까?"
|
||||
value={config.action?.confirmMessage || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.confirmMessage", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">입력하면 변경 전 확인 창이 표시됩니다</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="update-success-message">성공 메시지 (선택)</Label>
|
||||
<Input
|
||||
id="update-success-message"
|
||||
placeholder="예: 운행이 시작되었습니다."
|
||||
value={config.action?.successMessage || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.successMessage", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="update-error-message">오류 메시지 (선택)</Label>
|
||||
<Input
|
||||
id="update-error-message"
|
||||
placeholder="예: 운행 시작에 실패했습니다."
|
||||
value={config.action?.errorMessage || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.errorMessage", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
|
||||
<p className="text-xs text-blue-900 dark:text-blue-100">
|
||||
<strong>사용 예시:</strong>
|
||||
<br />
|
||||
- 운행알림 버튼: status 필드를 "active"로 변경
|
||||
<br />
|
||||
- 승인 버튼: approval_status 필드를 "approved"로 변경
|
||||
<br />
|
||||
- 완료 버튼: is_completed 필드를 "Y"로 변경
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 데이터 전달 액션 설정 */}
|
||||
{(component.componentConfig?.action?.type || "save") === "transferData" && (
|
||||
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
|
||||
<h4 className="text-sm font-medium text-foreground">📦 데이터 전달 설정</h4>
|
||||
|
||||
{/* 소스 컴포넌트 선택 (Combobox) */}
|
||||
<div>
|
||||
<Label>
|
||||
소스 컴포넌트 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={config.action?.dataTransfer?.sourceComponentId || ""}
|
||||
onValueChange={(value) => onUpdateProperty("componentConfig.action.dataTransfer.sourceComponentId", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="데이터를 가져올 컴포넌트 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* 데이터 제공 가능한 컴포넌트 필터링 */}
|
||||
{allComponents
|
||||
.filter((comp: any) => {
|
||||
const type = comp.componentType || comp.type || "";
|
||||
// 데이터를 제공할 수 있는 컴포넌트 타입들
|
||||
return ["table-list", "repeater-field-group", "form-group", "data-table"].some(
|
||||
(t) => type.includes(t)
|
||||
);
|
||||
})
|
||||
.map((comp: any) => {
|
||||
const compType = comp.componentType || comp.type || "unknown";
|
||||
const compLabel = comp.label || comp.componentConfig?.title || comp.id;
|
||||
return (
|
||||
<SelectItem key={comp.id} value={comp.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">{compLabel}</span>
|
||||
<span className="text-[10px] text-muted-foreground">({compType})</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
{allComponents.filter((comp: any) => {
|
||||
const type = comp.componentType || comp.type || "";
|
||||
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) => type.includes(t));
|
||||
}).length === 0 && (
|
||||
<SelectItem value="__none__" disabled>
|
||||
데이터 제공 가능한 컴포넌트가 없습니다
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
테이블, 반복 필드 그룹 등 데이터를 제공하는 컴포넌트
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="target-type">
|
||||
타겟 타입 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={config.action?.dataTransfer?.targetType || "component"}
|
||||
onValueChange={(value) => onUpdateProperty("componentConfig.action.dataTransfer.targetType", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="component">같은 화면의 컴포넌트</SelectItem>
|
||||
<SelectItem value="splitPanel">분할 패널 반대편 화면</SelectItem>
|
||||
<SelectItem value="screen" disabled>다른 화면 (구현 예정)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{config.action?.dataTransfer?.targetType === "splitPanel" && (
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
이 버튼이 분할 패널 내부에 있어야 합니다. 좌측 화면에서 우측으로, 또는 우측에서 좌측으로 데이터가 전달됩니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 타겟 컴포넌트 선택 (같은 화면의 컴포넌트일 때만) */}
|
||||
{config.action?.dataTransfer?.targetType === "component" && (
|
||||
<div>
|
||||
<Label>
|
||||
타겟 컴포넌트 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={config.action?.dataTransfer?.targetComponentId || ""}
|
||||
onValueChange={(value) => onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="데이터를 받을 컴포넌트 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{/* 데이터 수신 가능한 컴포넌트 필터링 (소스와 다른 컴포넌트만) */}
|
||||
{allComponents
|
||||
.filter((comp: any) => {
|
||||
const type = comp.componentType || comp.type || "";
|
||||
// 데이터를 받을 수 있는 컴포넌트 타입들
|
||||
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some(
|
||||
(t) => type.includes(t)
|
||||
);
|
||||
// 소스와 다른 컴포넌트만
|
||||
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
|
||||
})
|
||||
.map((comp: any) => {
|
||||
const compType = comp.componentType || comp.type || "unknown";
|
||||
const compLabel = comp.label || comp.componentConfig?.title || comp.id;
|
||||
return (
|
||||
<SelectItem key={comp.id} value={comp.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">{compLabel}</span>
|
||||
<span className="text-[10px] text-muted-foreground">({compType})</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
{allComponents.filter((comp: any) => {
|
||||
const type = comp.componentType || comp.type || "";
|
||||
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) => type.includes(t));
|
||||
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
|
||||
}).length === 0 && (
|
||||
<SelectItem value="__none__" disabled>
|
||||
데이터 수신 가능한 컴포넌트가 없습니다
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
테이블, 반복 필드 그룹 등 데이터를 받는 컴포넌트
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 분할 패널 반대편 타겟 설정 */}
|
||||
{config.action?.dataTransfer?.targetType === "splitPanel" && (
|
||||
<div>
|
||||
<Label>
|
||||
타겟 컴포넌트 ID (선택사항)
|
||||
</Label>
|
||||
<Input
|
||||
value={config.action?.dataTransfer?.targetComponentId || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", e.target.value)}
|
||||
placeholder="비워두면 첫 번째 수신 가능 컴포넌트로 전달"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
반대편 화면의 특정 컴포넌트 ID를 지정하거나, 비워두면 자동으로 첫 번째 수신 가능 컴포넌트로 전달됩니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="transfer-mode">데이터 전달 모드</Label>
|
||||
<Select
|
||||
value={config.action?.dataTransfer?.mode || "append"}
|
||||
onValueChange={(value) => onUpdateProperty("componentConfig.action.dataTransfer.mode", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="append">추가 (Append)</SelectItem>
|
||||
<SelectItem value="replace">교체 (Replace)</SelectItem>
|
||||
<SelectItem value="merge">병합 (Merge)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
기존 데이터를 어떻게 처리할지 선택
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="clear-after-transfer">전달 후 소스 선택 초기화</Label>
|
||||
<p className="text-xs text-muted-foreground">데이터 전달 후 소스의 선택을 해제합니다</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="clear-after-transfer"
|
||||
checked={config.action?.dataTransfer?.clearAfterTransfer === true}
|
||||
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.dataTransfer.clearAfterTransfer", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="confirm-before-transfer">전달 전 확인 메시지</Label>
|
||||
<p className="text-xs text-muted-foreground">데이터 전달 전 확인 다이얼로그를 표시합니다</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="confirm-before-transfer"
|
||||
checked={config.action?.dataTransfer?.confirmBeforeTransfer === true}
|
||||
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.dataTransfer.confirmBeforeTransfer", checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{config.action?.dataTransfer?.confirmBeforeTransfer && (
|
||||
<div>
|
||||
<Label htmlFor="confirm-message">확인 메시지</Label>
|
||||
<Input
|
||||
id="confirm-message"
|
||||
placeholder="선택한 항목을 전달하시겠습니까?"
|
||||
value={config.action?.dataTransfer?.confirmMessage || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.dataTransfer.confirmMessage", e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>검증 설정</Label>
|
||||
<div className="space-y-2 rounded-md border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="min-selection" className="text-xs">
|
||||
최소 선택 개수
|
||||
</Label>
|
||||
<Input
|
||||
id="min-selection"
|
||||
type="number"
|
||||
placeholder="0"
|
||||
value={config.action?.dataTransfer?.validation?.minSelection || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.dataTransfer.validation.minSelection", parseInt(e.target.value) || 0)}
|
||||
className="h-8 w-20 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="max-selection" className="text-xs">
|
||||
최대 선택 개수
|
||||
</Label>
|
||||
<Input
|
||||
id="max-selection"
|
||||
type="number"
|
||||
placeholder="제한없음"
|
||||
value={config.action?.dataTransfer?.validation?.maxSelection || ""}
|
||||
onChange={(e) => onUpdateProperty("componentConfig.action.dataTransfer.validation.maxSelection", parseInt(e.target.value) || undefined)}
|
||||
className="h-8 w-20 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>추가 데이터 소스 (선택사항)</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
조건부 컨테이너의 카테고리 값 등 추가 데이터를 함께 전달할 수 있습니다
|
||||
</p>
|
||||
<div className="space-y-2 rounded-md border p-3">
|
||||
<div>
|
||||
<Label className="text-xs">추가 컴포넌트</Label>
|
||||
<Select
|
||||
value={config.action?.dataTransfer?.additionalSources?.[0]?.componentId || ""}
|
||||
onValueChange={(value) => {
|
||||
const currentSources = config.action?.dataTransfer?.additionalSources || [];
|
||||
const newSources = [...currentSources];
|
||||
if (newSources.length === 0) {
|
||||
newSources.push({ componentId: value, fieldName: "" });
|
||||
} else {
|
||||
newSources[0] = { ...newSources[0], componentId: value };
|
||||
}
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="추가 데이터 컴포넌트 선택 (선택사항)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__clear__">
|
||||
<span className="text-muted-foreground">선택 안 함</span>
|
||||
</SelectItem>
|
||||
{/* 추가 데이터 제공 가능한 컴포넌트 (조건부 컨테이너, 셀렉트박스 등) */}
|
||||
{allComponents
|
||||
.filter((comp: any) => {
|
||||
const type = comp.componentType || comp.type || "";
|
||||
// 소스/타겟과 다른 컴포넌트 중 값을 제공할 수 있는 타입
|
||||
return ["conditional-container", "select-basic", "select", "combobox"].some(
|
||||
(t) => type.includes(t)
|
||||
);
|
||||
})
|
||||
.map((comp: any) => {
|
||||
const compType = comp.componentType || comp.type || "unknown";
|
||||
const compLabel = comp.label || comp.componentConfig?.controlLabel || comp.id;
|
||||
return (
|
||||
<SelectItem key={comp.id} value={comp.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">{compLabel}</span>
|
||||
<span className="text-[10px] text-muted-foreground">({compType})</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
조건부 컨테이너, 셀렉트박스 등 (카테고리 값 전달용)
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="additional-field-name" className="text-xs">
|
||||
필드명 (선택사항)
|
||||
</Label>
|
||||
<Input
|
||||
id="additional-field-name"
|
||||
placeholder="예: inbound_type (비워두면 전체 데이터)"
|
||||
value={config.action?.dataTransfer?.additionalSources?.[0]?.fieldName || ""}
|
||||
onChange={(e) => {
|
||||
const currentSources = config.action?.dataTransfer?.additionalSources || [];
|
||||
const newSources = [...currentSources];
|
||||
if (newSources.length === 0) {
|
||||
newSources.push({ componentId: "", fieldName: e.target.value });
|
||||
} else {
|
||||
newSources[0] = { ...newSources[0], fieldName: e.target.value };
|
||||
}
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
타겟 테이블에 저장될 필드명
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필드 매핑 규칙 */}
|
||||
<div className="space-y-3">
|
||||
<Label>필드 매핑 설정</Label>
|
||||
|
||||
{/* 소스/타겟 테이블 선택 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">소스 테이블</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{config.action?.dataTransfer?.sourceTable
|
||||
? availableTables.find((t) => t.name === config.action?.dataTransfer?.sourceTable)?.label ||
|
||||
config.action?.dataTransfer?.sourceTable
|
||||
: "테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[250px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">테이블을 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableTables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.name}
|
||||
value={`${table.label} ${table.name}`}
|
||||
onSelect={() => {
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.sourceTable", table.name);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
config.action?.dataTransfer?.sourceTable === table.name ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium">{table.label}</span>
|
||||
<span className="ml-1 text-muted-foreground">({table.name})</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">타겟 테이블</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{config.action?.dataTransfer?.targetTable
|
||||
? availableTables.find((t) => t.name === config.action?.dataTransfer?.targetTable)?.label ||
|
||||
config.action?.dataTransfer?.targetTable
|
||||
: "테이블 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[250px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">테이블을 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableTables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.name}
|
||||
value={`${table.label} ${table.name}`}
|
||||
onSelect={() => {
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.targetTable", table.name);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
config.action?.dataTransfer?.targetTable === table.name ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium">{table.label}</span>
|
||||
<span className="ml-1 text-muted-foreground">({table.name})</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필드 매핑 규칙 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs">필드 매핑 규칙</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 text-[10px]"
|
||||
onClick={() => {
|
||||
const currentRules = config.action?.dataTransfer?.mappingRules || [];
|
||||
const newRule = { sourceField: "", targetField: "", transform: "" };
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", [...currentRules, newRule]);
|
||||
}}
|
||||
disabled={!config.action?.dataTransfer?.sourceTable || !config.action?.dataTransfer?.targetTable}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
매핑 추가
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
소스 필드를 타겟 필드에 매핑합니다. 비워두면 같은 이름의 필드로 자동 매핑됩니다.
|
||||
</p>
|
||||
|
||||
{(!config.action?.dataTransfer?.sourceTable || !config.action?.dataTransfer?.targetTable) ? (
|
||||
<div className="rounded-md border border-dashed p-3 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
먼저 소스 테이블과 타겟 테이블을 선택하세요.
|
||||
</p>
|
||||
</div>
|
||||
) : (config.action?.dataTransfer?.mappingRules || []).length === 0 ? (
|
||||
<div className="rounded-md border border-dashed p-3 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
매핑 규칙이 없습니다. 같은 이름의 필드로 자동 매핑됩니다.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{(config.action?.dataTransfer?.mappingRules || []).map((rule: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-2 rounded-md border bg-background p-2">
|
||||
{/* 소스 필드 선택 (Combobox) */}
|
||||
<div className="flex-1">
|
||||
<Popover
|
||||
open={mappingSourcePopoverOpen[index] || false}
|
||||
onOpenChange={(open) => setMappingSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-7 w-full justify-between text-xs"
|
||||
>
|
||||
{rule.sourceField
|
||||
? mappingSourceColumns.find((c) => c.name === rule.sourceField)?.label || rule.sourceField
|
||||
: "소스 필드"}
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="컬럼 검색..."
|
||||
className="h-8 text-xs"
|
||||
value={mappingSourceSearch[index] || ""}
|
||||
onValueChange={(value) => setMappingSourceSearch((prev) => ({ ...prev, [index]: value }))}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">컬럼을 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{mappingSourceColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={col.name}
|
||||
value={`${col.label} ${col.name}`}
|
||||
onSelect={() => {
|
||||
const rules = [...(config.action?.dataTransfer?.mappingRules || [])];
|
||||
rules[index] = { ...rules[index], sourceField: col.name };
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules);
|
||||
setMappingSourcePopoverOpen((prev) => ({ ...prev, [index]: false }));
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
rule.sourceField === col.name ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span>{col.label}</span>
|
||||
{col.label !== col.name && (
|
||||
<span className="ml-1 text-muted-foreground">({col.name})</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-muted-foreground">→</span>
|
||||
|
||||
{/* 타겟 필드 선택 (Combobox) */}
|
||||
<div className="flex-1">
|
||||
<Popover
|
||||
open={mappingTargetPopoverOpen[index] || false}
|
||||
onOpenChange={(open) => setMappingTargetPopoverOpen((prev) => ({ ...prev, [index]: open }))}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-7 w-full justify-between text-xs"
|
||||
>
|
||||
{rule.targetField
|
||||
? mappingTargetColumns.find((c) => c.name === rule.targetField)?.label || rule.targetField
|
||||
: "타겟 필드"}
|
||||
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="컬럼 검색..."
|
||||
className="h-8 text-xs"
|
||||
value={mappingTargetSearch[index] || ""}
|
||||
onValueChange={(value) => setMappingTargetSearch((prev) => ({ ...prev, [index]: value }))}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-center text-xs">컬럼을 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{mappingTargetColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={col.name}
|
||||
value={`${col.label} ${col.name}`}
|
||||
onSelect={() => {
|
||||
const rules = [...(config.action?.dataTransfer?.mappingRules || [])];
|
||||
rules[index] = { ...rules[index], targetField: col.name };
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules);
|
||||
setMappingTargetPopoverOpen((prev) => ({ ...prev, [index]: false }));
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
rule.targetField === col.name ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span>{col.label}</span>
|
||||
{col.label !== col.name && (
|
||||
<span className="ml-1 text-muted-foreground">({col.name})</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
const rules = [...(config.action?.dataTransfer?.mappingRules || [])];
|
||||
rules.splice(index, 1);
|
||||
onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
|
||||
<p className="text-xs text-blue-900 dark:text-blue-100">
|
||||
<strong>사용 방법:</strong>
|
||||
<br />
|
||||
1. 소스 컴포넌트에서 데이터를 선택합니다
|
||||
<br />
|
||||
2. 필드 매핑 규칙을 설정합니다 (예: 품번 → 품목코드)
|
||||
<br />
|
||||
3. 이 버튼을 클릭하면 매핑된 데이터가 타겟으로 전달됩니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 제어 기능 섹션 */}
|
||||
<div className="mt-8 border-t border-border pt-6">
|
||||
<ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />
|
||||
|
|
|
|||
|
|
@ -740,6 +740,12 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
const handleConfigChange = (newConfig: WebTypeConfig) => {
|
||||
// 강제 새 객체 생성으로 React 변경 감지 보장
|
||||
const freshConfig = { ...newConfig };
|
||||
console.log("🔧 [DetailSettingsPanel] handleConfigChange 호출:", {
|
||||
widgetId: widget.id,
|
||||
widgetLabel: widget.label,
|
||||
widgetType: widget.widgetType,
|
||||
newConfig: freshConfig,
|
||||
});
|
||||
onUpdateProperty(widget.id, "webTypeConfig", freshConfig);
|
||||
|
||||
// TextTypeConfig의 자동입력 설정을 autoGeneration으로도 매핑
|
||||
|
|
@ -863,27 +869,23 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
});
|
||||
|
||||
// 래퍼 컴포넌트: 새 ConfigPanel 인터페이스를 기존 패턴에 맞춤
|
||||
const ConfigPanelWrapper = () => {
|
||||
// Section Card, Section Paper 등 신규 컴포넌트는 componentConfig 바로 아래에 설정 저장
|
||||
const config = currentConfig || definition.defaultProps?.componentConfig || {};
|
||||
|
||||
const handleConfigChange = (newConfig: any) => {
|
||||
// componentConfig 전체를 업데이트
|
||||
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>
|
||||
);
|
||||
// Section Card, Section Paper 등 신규 컴포넌트는 componentConfig 바로 아래에 설정 저장
|
||||
const config = currentConfig || definition.defaultProps?.componentConfig || {};
|
||||
|
||||
const handleConfigChange = (newConfig: any) => {
|
||||
// componentConfig 전체를 업데이트
|
||||
onUpdateProperty(selectedComponent.id, "componentConfig", newConfig);
|
||||
};
|
||||
|
||||
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 {
|
||||
console.warn("⚠️ ConfigPanel 없음:", {
|
||||
componentId,
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
}) => {
|
||||
const { webTypes } = useWebTypes({ active: "Y" });
|
||||
const [localComponentDetailType, setLocalComponentDetailType] = useState<string>("");
|
||||
|
||||
|
||||
// 높이/너비 입력 로컬 상태 (자유 입력 허용)
|
||||
const [localHeight, setLocalHeight] = useState<string>("");
|
||||
const [localWidth, setLocalWidth] = useState<string>("");
|
||||
|
|
@ -147,7 +147,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
}
|
||||
}
|
||||
}, [selectedComponent?.type, selectedComponent?.componentConfig?.webType, selectedComponent?.id]);
|
||||
|
||||
|
||||
// 높이 값 동기화
|
||||
useEffect(() => {
|
||||
if (selectedComponent?.size?.height !== undefined) {
|
||||
|
|
@ -179,7 +179,10 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
// 최대 컬럼 수 계산
|
||||
const MIN_COLUMN_WIDTH = 30;
|
||||
const maxColumns = currentResolution
|
||||
? Math.floor((currentResolution.width - gridSettings.padding * 2 + gridSettings.gap) / (MIN_COLUMN_WIDTH + gridSettings.gap))
|
||||
? Math.floor(
|
||||
(currentResolution.width - gridSettings.padding * 2 + gridSettings.gap) /
|
||||
(MIN_COLUMN_WIDTH + gridSettings.gap),
|
||||
)
|
||||
: 24;
|
||||
const safeMaxColumns = Math.max(1, Math.min(maxColumns, 100)); // 최대 100개로 제한
|
||||
|
||||
|
|
@ -189,7 +192,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
<Grid3X3 className="text-primary h-3 w-3" />
|
||||
<h4 className="text-xs font-semibold">격자 설정</h4>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* 토글들 */}
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -226,9 +229,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
|
||||
{/* 10px 단위 스냅 안내 */}
|
||||
<div className="bg-muted/50 rounded-md p-2">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
모든 컴포넌트는 10px 단위로 자동 배치됩니다.
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[10px]">모든 컴포넌트는 10px 단위로 자동 배치됩니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -238,9 +239,9 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
// 컴포넌트가 선택되지 않았을 때도 해상도 설정과 격자 설정은 표시
|
||||
if (!selectedComponent) {
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-white">
|
||||
<div className="flex h-full flex-col overflow-x-auto bg-white">
|
||||
{/* 해상도 설정과 격자 설정 표시 */}
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
<div className="flex-1 overflow-x-auto overflow-y-auto p-2">
|
||||
<div className="space-y-4 text-xs">
|
||||
{/* 해상도 설정 */}
|
||||
{currentResolution && onResolutionChange && (
|
||||
|
|
@ -287,9 +288,9 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
if (!selectedComponent) return null;
|
||||
|
||||
// 🎯 Section Card, Section Paper 등 신규 컴포넌트는 componentType에서 감지
|
||||
const componentType =
|
||||
selectedComponent.componentType || // ⭐ 1순위: ScreenDesigner가 설정한 componentType (section-card 등)
|
||||
selectedComponent.componentConfig?.type ||
|
||||
const componentType =
|
||||
selectedComponent.componentType || // ⭐ 1순위: ScreenDesigner가 설정한 componentType (section-card 등)
|
||||
selectedComponent.componentConfig?.type ||
|
||||
selectedComponent.componentConfig?.id ||
|
||||
selectedComponent.type;
|
||||
|
||||
|
|
@ -305,15 +306,15 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
};
|
||||
|
||||
// 🆕 ComponentRegistry에서 ConfigPanel 가져오기 시도
|
||||
const componentId =
|
||||
selectedComponent.componentType || // ⭐ section-card 등
|
||||
selectedComponent.componentConfig?.type ||
|
||||
const componentId =
|
||||
selectedComponent.componentType || // ⭐ section-card 등
|
||||
selectedComponent.componentConfig?.type ||
|
||||
selectedComponent.componentConfig?.id ||
|
||||
(selectedComponent.type === "component" ? selectedComponent.id : null); // 🆕 독립 컴포넌트 (table-search-widget 등)
|
||||
|
||||
|
||||
if (componentId) {
|
||||
const definition = ComponentRegistry.getComponent(componentId);
|
||||
|
||||
|
||||
if (definition?.configPanel) {
|
||||
const ConfigPanelComponent = definition.configPanel;
|
||||
const currentConfig = selectedComponent.componentConfig || {};
|
||||
|
|
@ -325,41 +326,48 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
currentConfig,
|
||||
});
|
||||
|
||||
// 래퍼 컴포넌트: 새 ConfigPanel 인터페이스를 기존 패턴에 맞춤
|
||||
const ConfigPanelWrapper = () => {
|
||||
// Section Card, Section Paper 등 신규 컴포넌트는 componentConfig 바로 아래에 설정 저장
|
||||
const config = currentConfig || definition.defaultProps?.componentConfig || {};
|
||||
|
||||
const handleConfigChange = (newConfig: any) => {
|
||||
// componentConfig 전체를 업데이트
|
||||
onUpdateProperty(selectedComponent.id, "componentConfig", newConfig);
|
||||
};
|
||||
// 🔧 ConfigPanelWrapper를 인라인 함수 대신 직접 JSX 반환 (리마운트 방지)
|
||||
const config = currentConfig || definition.defaultProps?.componentConfig || {};
|
||||
|
||||
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>
|
||||
);
|
||||
const handlePanelConfigChange = (newConfig: any) => {
|
||||
// 🔧 Partial 업데이트: 기존 componentConfig를 유지하면서 새 설정만 병합
|
||||
const mergedConfig = {
|
||||
...currentConfig, // 기존 설정 유지
|
||||
...newConfig, // 새 설정 병합
|
||||
};
|
||||
console.log("🔧 [ConfigPanel] handleConfigChange:", {
|
||||
componentId: selectedComponent.id,
|
||||
currentConfig,
|
||||
newConfig,
|
||||
mergedConfig,
|
||||
});
|
||||
onUpdateProperty(selectedComponent.id, "componentConfig", mergedConfig);
|
||||
};
|
||||
|
||||
return <ConfigPanelWrapper key={selectedComponent.id} />;
|
||||
return (
|
||||
<div key={selectedComponent.id} className="space-y-4">
|
||||
<div className="flex items-center gap-2 border-b pb-2">
|
||||
<Settings className="text-primary h-4 w-4" />
|
||||
<h3 className="text-sm font-semibold">{definition.name} 설정</h3>
|
||||
</div>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-muted-foreground text-sm">설정 패널 로딩 중...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ConfigPanelComponent
|
||||
config={config}
|
||||
onChange={handlePanelConfigChange}
|
||||
tables={tables} // 테이블 정보 전달
|
||||
allTables={allTables} // 🆕 전체 테이블 목록 전달 (selected-items-detail-input 등에서 사용)
|
||||
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} // 🔧 화면 테이블명 전달
|
||||
tableColumns={currentTable?.columns || []} // 🔧 테이블 컬럼 정보 전달
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
console.warn("⚠️ ComponentRegistry에서 ConfigPanel을 찾을 수 없음 - switch case로 이동:", {
|
||||
componentId,
|
||||
|
|
@ -418,9 +426,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">Section Card 설정</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
제목과 테두리가 있는 명확한 그룹화 컨테이너
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">제목과 테두리가 있는 명확한 그룹화 컨테이너</p>
|
||||
</div>
|
||||
|
||||
{/* 헤더 표시 */}
|
||||
|
|
@ -432,7 +438,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
handleUpdateProperty(selectedComponent.id, "componentConfig.showHeader", checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="showHeader" className="text-xs cursor-pointer">
|
||||
<Label htmlFor="showHeader" className="cursor-pointer text-xs">
|
||||
헤더 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -462,7 +468,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
handleUpdateProperty(selectedComponent.id, "componentConfig.description", e.target.value);
|
||||
}}
|
||||
placeholder="섹션 설명 입력"
|
||||
className="text-xs resize-none"
|
||||
className="resize-none text-xs"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -530,7 +536,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
</div>
|
||||
|
||||
{/* 접기/펼치기 기능 */}
|
||||
<div className="space-y-2 pt-2 border-t">
|
||||
<div className="space-y-2 border-t pt-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="collapsible"
|
||||
|
|
@ -539,13 +545,13 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
handleUpdateProperty(selectedComponent.id, "componentConfig.collapsible", checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="collapsible" className="text-xs cursor-pointer">
|
||||
<Label htmlFor="collapsible" className="cursor-pointer text-xs">
|
||||
접기/펼치기 가능
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{selectedComponent.componentConfig?.collapsible && (
|
||||
<div className="flex items-center space-x-2 ml-6">
|
||||
<div className="ml-6 flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="defaultOpen"
|
||||
checked={selectedComponent.componentConfig?.defaultOpen !== false}
|
||||
|
|
@ -553,7 +559,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
handleUpdateProperty(selectedComponent.id, "componentConfig.defaultOpen", checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="defaultOpen" className="text-xs cursor-pointer">
|
||||
<Label htmlFor="defaultOpen" className="cursor-pointer text-xs">
|
||||
기본으로 펼치기
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -567,9 +573,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">Section Paper 설정</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
배경색 기반의 미니멀한 그룹화 컨테이너
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">배경색 기반의 미니멀한 그룹화 컨테이너</p>
|
||||
</div>
|
||||
|
||||
{/* 배경색 */}
|
||||
|
|
@ -680,7 +684,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
handleUpdateProperty(selectedComponent.id, "componentConfig.showBorder", checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="showBorder" className="text-xs cursor-pointer">
|
||||
<Label htmlFor="showBorder" className="cursor-pointer text-xs">
|
||||
미묘한 테두리 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -691,9 +695,9 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
// ConfigPanel이 없는 경우 경고 표시
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
|
||||
<Settings className="mb-4 h-12 w-12 text-muted-foreground" />
|
||||
<Settings className="text-muted-foreground mb-4 h-12 w-12" />
|
||||
<h3 className="mb-2 text-base font-medium">⚠️ 설정 패널 없음</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
컴포넌트 "{componentId || componentType}"에 대한 설정 패널이 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -1418,7 +1422,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
</div>
|
||||
|
||||
{/* 통합 컨텐츠 (탭 제거) */}
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
<div className="flex-1 overflow-x-auto overflow-y-auto p-2">
|
||||
<div className="space-y-4 text-xs">
|
||||
{/* 해상도 설정 - 항상 맨 위에 표시 */}
|
||||
{currentResolution && onResolutionChange && (
|
||||
|
|
|
|||
|
|
@ -176,7 +176,7 @@ const ResizableDialogContent = React.forwardRef<
|
|||
height: Math.max(minHeight, Math.min(maxHeight, parsed.height)),
|
||||
userResized: true,
|
||||
};
|
||||
console.log("💾 사용자가 리사이징한 크기 복원:", savedSize);
|
||||
// console.log("💾 사용자가 리사이징한 크기 복원:", savedSize);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
|
@ -8,8 +8,9 @@ import { Button } from "@/components/ui/button";
|
|||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Plus, X, GripVertical, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { RepeaterFieldGroupConfig, RepeaterData, RepeaterItemData, RepeaterFieldDefinition } from "@/types/repeater";
|
||||
import { RepeaterFieldGroupConfig, RepeaterData, RepeaterItemData, RepeaterFieldDefinition, CalculationFormula } from "@/types/repeater";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useBreakpoint } from "@/hooks/useBreakpoint";
|
||||
import { usePreviewBreakpoint } from "@/components/screen/ResponsivePreviewModal";
|
||||
|
|
@ -21,6 +22,7 @@ export interface RepeaterInputProps {
|
|||
disabled?: boolean;
|
||||
readonly?: boolean;
|
||||
className?: string;
|
||||
menuObjid?: number; // 카테고리 조회용 메뉴 ID
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -34,6 +36,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
disabled = false,
|
||||
readonly = false,
|
||||
className,
|
||||
menuObjid,
|
||||
}) => {
|
||||
// 현재 브레이크포인트 감지
|
||||
const globalBreakpoint = useBreakpoint();
|
||||
|
|
@ -42,6 +45,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
// 미리보기 모달 내에서는 previewBreakpoint 우선 사용
|
||||
const breakpoint = previewBreakpoint || globalBreakpoint;
|
||||
|
||||
// 카테고리 매핑 데이터 (값 -> {label, color})
|
||||
const [categoryMappings, setCategoryMappings] = useState<Record<string, Record<string, { label: string; color: string }>>>({});
|
||||
|
||||
// 설정 기본값
|
||||
const {
|
||||
fields = [],
|
||||
|
|
@ -72,6 +78,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
|
||||
// 접힌 상태 관리 (각 항목별)
|
||||
const [collapsedItems, setCollapsedItems] = useState<Set<number>>(new Set());
|
||||
|
||||
// 🆕 초기 계산 완료 여부 추적 (무한 루프 방지)
|
||||
const initialCalcDoneRef = useRef(false);
|
||||
|
||||
// 🆕 삭제된 항목 ID 목록 추적 (ref로 관리하여 즉시 반영)
|
||||
const deletedItemIdsRef = useRef<string[]>([]);
|
||||
|
||||
// 빈 항목 생성
|
||||
function createEmptyItem(): RepeaterItemData {
|
||||
|
|
@ -82,10 +94,39 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
return item;
|
||||
}
|
||||
|
||||
// 외부 value 변경 시 동기화
|
||||
// 외부 value 변경 시 동기화 및 초기 계산식 필드 업데이트
|
||||
useEffect(() => {
|
||||
if (value.length > 0) {
|
||||
setItems(value);
|
||||
// 🆕 초기 로드 시 계산식 필드 자동 업데이트 (한 번만 실행)
|
||||
const calculatedFields = fields.filter(f => f.type === "calculated");
|
||||
|
||||
if (calculatedFields.length > 0 && !initialCalcDoneRef.current) {
|
||||
const updatedValue = value.map(item => {
|
||||
const updatedItem = { ...item };
|
||||
let hasChange = false;
|
||||
|
||||
calculatedFields.forEach(calcField => {
|
||||
const calculatedValue = calculateValue(calcField.formula, updatedItem);
|
||||
if (calculatedValue !== null && updatedItem[calcField.name] !== calculatedValue) {
|
||||
updatedItem[calcField.name] = calculatedValue;
|
||||
hasChange = true;
|
||||
}
|
||||
});
|
||||
|
||||
return hasChange ? updatedItem : item;
|
||||
});
|
||||
|
||||
setItems(updatedValue);
|
||||
initialCalcDoneRef.current = true;
|
||||
|
||||
// 계산된 값이 있으면 onChange 호출 (초기 1회만)
|
||||
const dataWithMeta = config.targetTable
|
||||
? updatedValue.map((item) => ({ ...item, _targetTable: config.targetTable }))
|
||||
: updatedValue;
|
||||
onChange?.(dataWithMeta);
|
||||
} else {
|
||||
setItems(value);
|
||||
}
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
|
|
@ -111,14 +152,32 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
if (items.length <= minItems) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 🆕 삭제되는 항목의 ID 저장 (DB에서 삭제할 때 필요)
|
||||
const removedItem = items[index];
|
||||
if (removedItem?.id) {
|
||||
console.log("🗑️ [RepeaterInput] 삭제할 항목 ID 추가:", removedItem.id);
|
||||
deletedItemIdsRef.current = [...deletedItemIdsRef.current, removedItem.id];
|
||||
}
|
||||
|
||||
const newItems = items.filter((_, i) => i !== index);
|
||||
setItems(newItems);
|
||||
|
||||
// targetTable이 설정된 경우 각 항목에 메타데이터 추가
|
||||
// 🆕 삭제된 항목 ID 목록도 함께 전달 (ref에서 최신값 사용)
|
||||
const currentDeletedIds = deletedItemIdsRef.current;
|
||||
console.log("🗑️ [RepeaterInput] 현재 삭제 목록:", currentDeletedIds);
|
||||
|
||||
const dataWithMeta = config.targetTable
|
||||
? newItems.map((item) => ({ ...item, _targetTable: config.targetTable }))
|
||||
? newItems.map((item, idx) => ({
|
||||
...item,
|
||||
_targetTable: config.targetTable,
|
||||
// 첫 번째 항목에만 삭제 ID 목록 포함
|
||||
...(idx === 0 ? { _deletedItemIds: currentDeletedIds } : {}),
|
||||
}))
|
||||
: newItems;
|
||||
|
||||
console.log("🗑️ [RepeaterInput] onChange 호출 - dataWithMeta:", dataWithMeta);
|
||||
onChange?.(dataWithMeta);
|
||||
|
||||
// 접힌 상태도 업데이트
|
||||
|
|
@ -134,6 +193,16 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
...newItems[itemIndex],
|
||||
[fieldName]: value,
|
||||
};
|
||||
|
||||
// 🆕 계산식 필드 자동 업데이트: 변경된 항목의 모든 계산식 필드 값을 재계산
|
||||
const calculatedFields = fields.filter(f => f.type === "calculated");
|
||||
calculatedFields.forEach(calcField => {
|
||||
const calculatedValue = calculateValue(calcField.formula, newItems[itemIndex]);
|
||||
if (calculatedValue !== null) {
|
||||
newItems[itemIndex][calcField.name] = calculatedValue;
|
||||
}
|
||||
});
|
||||
|
||||
setItems(newItems);
|
||||
console.log("✏️ RepeaterInput 필드 변경, onChange 호출:", {
|
||||
itemIndex,
|
||||
|
|
@ -143,8 +212,15 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
});
|
||||
|
||||
// targetTable이 설정된 경우 각 항목에 메타데이터 추가
|
||||
// 🆕 삭제된 항목 ID 목록도 유지
|
||||
const currentDeletedIds = deletedItemIdsRef.current;
|
||||
const dataWithMeta = config.targetTable
|
||||
? newItems.map((item) => ({ ...item, _targetTable: config.targetTable }))
|
||||
? newItems.map((item, idx) => ({
|
||||
...item,
|
||||
_targetTable: config.targetTable,
|
||||
// 첫 번째 항목에만 삭제 ID 목록 포함 (삭제된 항목이 있는 경우에만)
|
||||
...(idx === 0 && currentDeletedIds.length > 0 ? { _deletedItemIds: currentDeletedIds } : {}),
|
||||
}))
|
||||
: newItems;
|
||||
|
||||
onChange?.(dataWithMeta);
|
||||
|
|
@ -192,24 +268,183 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
setDraggedIndex(null);
|
||||
};
|
||||
|
||||
/**
|
||||
* 계산식 실행
|
||||
* @param formula 계산식 정의
|
||||
* @param item 현재 항목 데이터
|
||||
* @returns 계산 결과
|
||||
*/
|
||||
const calculateValue = (formula: CalculationFormula | undefined, item: RepeaterItemData): number | null => {
|
||||
if (!formula || !formula.field1) return null;
|
||||
|
||||
const value1 = parseFloat(item[formula.field1]) || 0;
|
||||
const value2 = formula.field2
|
||||
? (parseFloat(item[formula.field2]) || 0)
|
||||
: (formula.constantValue ?? 0);
|
||||
|
||||
let result: number;
|
||||
|
||||
switch (formula.operator) {
|
||||
case "+":
|
||||
result = value1 + value2;
|
||||
break;
|
||||
case "-":
|
||||
result = value1 - value2;
|
||||
break;
|
||||
case "*":
|
||||
result = value1 * value2;
|
||||
break;
|
||||
case "/":
|
||||
result = value2 !== 0 ? value1 / value2 : 0;
|
||||
break;
|
||||
case "%":
|
||||
result = value2 !== 0 ? value1 % value2 : 0;
|
||||
break;
|
||||
case "round":
|
||||
const decimalPlaces = formula.decimalPlaces ?? 0;
|
||||
const multiplier = Math.pow(10, decimalPlaces);
|
||||
result = Math.round(value1 * multiplier) / multiplier;
|
||||
break;
|
||||
case "floor":
|
||||
const floorMultiplier = Math.pow(10, formula.decimalPlaces ?? 0);
|
||||
result = Math.floor(value1 * floorMultiplier) / floorMultiplier;
|
||||
break;
|
||||
case "ceil":
|
||||
const ceilMultiplier = Math.pow(10, formula.decimalPlaces ?? 0);
|
||||
result = Math.ceil(value1 * ceilMultiplier) / ceilMultiplier;
|
||||
break;
|
||||
case "abs":
|
||||
result = Math.abs(value1);
|
||||
break;
|
||||
default:
|
||||
result = value1;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* 숫자 포맷팅
|
||||
* @param value 숫자 값
|
||||
* @param format 포맷 설정
|
||||
* @returns 포맷된 문자열
|
||||
*/
|
||||
const formatNumber = (
|
||||
value: number | null,
|
||||
format?: RepeaterFieldDefinition["numberFormat"]
|
||||
): string => {
|
||||
if (value === null || isNaN(value)) return "-";
|
||||
|
||||
let formattedValue = value;
|
||||
|
||||
// 소수점 자릿수 적용
|
||||
if (format?.decimalPlaces !== undefined) {
|
||||
formattedValue = parseFloat(value.toFixed(format.decimalPlaces));
|
||||
}
|
||||
|
||||
// 천 단위 구분자
|
||||
let result = format?.useThousandSeparator !== false
|
||||
? formattedValue.toLocaleString("ko-KR", {
|
||||
minimumFractionDigits: format?.minimumFractionDigits ?? 0,
|
||||
maximumFractionDigits: format?.maximumFractionDigits ?? format?.decimalPlaces ?? 0,
|
||||
})
|
||||
: formattedValue.toString();
|
||||
|
||||
// 접두사/접미사 추가
|
||||
if (format?.prefix) result = format.prefix + result;
|
||||
if (format?.suffix) result = result + format.suffix;
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// 개별 필드 렌더링
|
||||
const renderField = (field: RepeaterFieldDefinition, itemIndex: number, value: any) => {
|
||||
const isReadonly = disabled || readonly || field.readonly;
|
||||
|
||||
const commonProps = {
|
||||
value: value || "",
|
||||
disabled: disabled || readonly,
|
||||
disabled: isReadonly,
|
||||
placeholder: field.placeholder,
|
||||
required: field.required,
|
||||
};
|
||||
|
||||
// 계산식 필드: 자동으로 계산된 값을 표시 (읽기 전용)
|
||||
if (field.type === "calculated") {
|
||||
const item = items[itemIndex];
|
||||
const calculatedValue = calculateValue(field.formula, item);
|
||||
const formattedValue = formatNumber(calculatedValue, field.numberFormat);
|
||||
|
||||
return (
|
||||
<span className="text-sm font-medium text-blue-700 min-w-[80px] inline-block">
|
||||
{formattedValue}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// 카테고리 타입은 항상 배지로 표시 (카테고리 관리에서 설정한 색상 적용)
|
||||
if (field.type === "category") {
|
||||
if (!value) return <span className="text-muted-foreground text-sm">-</span>;
|
||||
|
||||
// field.name을 키로 사용 (테이블 리스트와 동일)
|
||||
const mapping = categoryMappings[field.name];
|
||||
const valueStr = String(value); // 값을 문자열로 변환
|
||||
const categoryData = mapping?.[valueStr];
|
||||
const displayLabel = categoryData?.label || valueStr;
|
||||
const displayColor = categoryData?.color || "#64748b"; // 기본 색상 (slate)
|
||||
|
||||
console.log(`🏷️ [RepeaterInput] 카테고리 배지 렌더링:`, {
|
||||
fieldName: field.name,
|
||||
value: valueStr,
|
||||
mapping,
|
||||
categoryData,
|
||||
displayLabel,
|
||||
displayColor,
|
||||
});
|
||||
|
||||
// 색상이 "none"이면 일반 텍스트로 표시
|
||||
if (displayColor === "none") {
|
||||
return <span className="text-sm">{displayLabel}</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
style={{
|
||||
backgroundColor: displayColor,
|
||||
borderColor: displayColor,
|
||||
}}
|
||||
className="text-white"
|
||||
>
|
||||
{displayLabel}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// 읽기 전용 모드: 텍스트로 표시
|
||||
// displayMode가 "readonly"이면 isReadonly 여부와 관계없이 텍스트로 표시
|
||||
if (field.displayMode === "readonly") {
|
||||
// select 타입인 경우 옵션에서 라벨 찾기
|
||||
if (field.type === "select" && value && field.options) {
|
||||
const option = field.options.find(opt => opt.value === value);
|
||||
return <span className="text-sm">{option?.label || value}</span>;
|
||||
}
|
||||
|
||||
// 일반 텍스트
|
||||
return (
|
||||
<span className="text-sm text-foreground">
|
||||
{value || "-"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
switch (field.type) {
|
||||
case "select":
|
||||
return (
|
||||
<Select
|
||||
value={value || ""}
|
||||
onValueChange={(val) => handleFieldChange(itemIndex, field.name, val)}
|
||||
disabled={disabled || readonly}
|
||||
disabled={isReadonly}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectTrigger className="w-full min-w-[80px]">
|
||||
<SelectValue placeholder={field.placeholder || "선택하세요"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -228,7 +463,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
{...commonProps}
|
||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
||||
rows={3}
|
||||
className="resize-none"
|
||||
className="resize-none min-w-[100px]"
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -238,10 +473,45 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
{...commonProps}
|
||||
type="date"
|
||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
||||
className="min-w-[120px]"
|
||||
/>
|
||||
);
|
||||
|
||||
case "number":
|
||||
// 숫자 포맷이 설정된 경우 포맷팅된 텍스트로 표시
|
||||
if (field.numberFormat?.useThousandSeparator || field.numberFormat?.prefix || field.numberFormat?.suffix) {
|
||||
const numValue = parseFloat(value) || 0;
|
||||
const formattedDisplay = formatNumber(numValue, field.numberFormat);
|
||||
|
||||
// 읽기 전용이면 포맷팅된 텍스트만 표시
|
||||
if (isReadonly) {
|
||||
return (
|
||||
<span className="text-sm min-w-[80px] inline-block">
|
||||
{formattedDisplay}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// 편집 가능: 입력은 숫자로, 표시는 포맷팅
|
||||
return (
|
||||
<div className="relative min-w-[80px]">
|
||||
<Input
|
||||
{...commonProps}
|
||||
type="number"
|
||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
||||
min={field.validation?.min}
|
||||
max={field.validation?.max}
|
||||
className="pr-1"
|
||||
/>
|
||||
{value && (
|
||||
<div className="text-muted-foreground text-[10px] mt-0.5">
|
||||
{formattedDisplay}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
{...commonProps}
|
||||
|
|
@ -249,6 +519,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
||||
min={field.validation?.min}
|
||||
max={field.validation?.max}
|
||||
className="min-w-[80px]"
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -258,6 +529,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
{...commonProps}
|
||||
type="email"
|
||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
||||
className="min-w-[120px]"
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -267,6 +539,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
{...commonProps}
|
||||
type="tel"
|
||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
||||
className="min-w-[100px]"
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
@ -277,11 +550,69 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
type="text"
|
||||
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
|
||||
maxLength={field.validation?.maxLength}
|
||||
className="min-w-[80px]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 카테고리 매핑 로드 (카테고리 필드가 있을 때 자동 로드)
|
||||
// 테이블 리스트와 동일한 API 사용: /table-categories/{tableName}/{columnName}/values
|
||||
useEffect(() => {
|
||||
const categoryFields = fields.filter(f => f.type === "category");
|
||||
if (categoryFields.length === 0) return;
|
||||
|
||||
const loadCategoryMappings = async () => {
|
||||
const apiClient = (await import("@/lib/api/client")).apiClient;
|
||||
|
||||
for (const field of categoryFields) {
|
||||
const columnName = field.name; // 실제 컬럼명
|
||||
const categoryCode = field.categoryCode || columnName;
|
||||
|
||||
// 이미 로드된 경우 스킵
|
||||
if (categoryMappings[columnName]) continue;
|
||||
|
||||
try {
|
||||
// config에서 targetTable 가져오기, 없으면 스킵
|
||||
const tableName = config.targetTable;
|
||||
if (!tableName) {
|
||||
console.warn(`[RepeaterInput] targetTable이 설정되지 않아 카테고리 매핑을 로드할 수 없습니다.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`📡 [RepeaterInput] 카테고리 매핑 로드: ${tableName}/${columnName}`);
|
||||
|
||||
// 테이블 리스트와 동일한 API 사용
|
||||
const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
|
||||
|
||||
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
|
||||
const mapping: Record<string, { label: string; color: string }> = {};
|
||||
|
||||
response.data.data.forEach((item: any) => {
|
||||
// valueCode를 문자열로 변환하여 키로 사용 (테이블 리스트와 동일)
|
||||
const key = String(item.valueCode);
|
||||
mapping[key] = {
|
||||
label: item.valueLabel || key,
|
||||
color: item.color || "#64748b", // color 필드 사용 (DB 컬럼명과 동일)
|
||||
};
|
||||
});
|
||||
|
||||
console.log(`✅ [RepeaterInput] 카테고리 매핑 로드 완료 [${columnName}]:`, mapping);
|
||||
|
||||
setCategoryMappings(prev => ({
|
||||
...prev,
|
||||
[columnName]: mapping,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ [RepeaterInput] 카테고리 매핑 로드 실패 (${columnName}):`, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadCategoryMappings();
|
||||
}, [fields, config.targetTable]);
|
||||
|
||||
// 필드가 정의되지 않았을 때
|
||||
if (fields.length === 0) {
|
||||
return (
|
||||
|
|
@ -324,18 +655,18 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
<TableHeader>
|
||||
<TableRow className="bg-background">
|
||||
{showIndex && (
|
||||
<TableHead className="h-12 w-12 px-6 py-3 text-center text-sm font-semibold">#</TableHead>
|
||||
<TableHead className="h-10 w-10 px-2.5 py-2 text-center text-sm font-semibold">#</TableHead>
|
||||
)}
|
||||
{allowReorder && (
|
||||
<TableHead className="h-12 w-12 px-6 py-3 text-center text-sm font-semibold"></TableHead>
|
||||
<TableHead className="h-10 w-10 px-2.5 py-2 text-center text-sm font-semibold"></TableHead>
|
||||
)}
|
||||
{fields.map((field) => (
|
||||
<TableHead key={field.name} className="h-12 px-6 py-3 text-sm font-semibold">
|
||||
<TableHead key={field.name} className="h-10 px-2.5 py-2 text-sm font-semibold">
|
||||
{field.label}
|
||||
{field.required && <span className="ml-1 text-destructive">*</span>}
|
||||
</TableHead>
|
||||
))}
|
||||
<TableHead className="h-12 w-20 px-6 py-3 text-center text-sm font-semibold">작업</TableHead>
|
||||
<TableHead className="h-10 w-14 px-2.5 py-2 text-center text-sm font-semibold">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
|
@ -354,27 +685,27 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
|
|||
>
|
||||
{/* 인덱스 번호 */}
|
||||
{showIndex && (
|
||||
<TableCell className="h-16 px-6 py-3 text-center text-sm font-medium">
|
||||
<TableCell className="h-12 px-2.5 py-2 text-center text-sm font-medium">
|
||||
{itemIndex + 1}
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
{/* 드래그 핸들 */}
|
||||
{allowReorder && !readonly && !disabled && (
|
||||
<TableCell className="h-16 px-6 py-3 text-center">
|
||||
<TableCell className="h-12 px-2.5 py-2 text-center">
|
||||
<GripVertical className="h-4 w-4 cursor-move text-muted-foreground" />
|
||||
</TableCell>
|
||||
)}
|
||||
|
||||
{/* 필드들 */}
|
||||
{fields.map((field) => (
|
||||
<TableCell key={field.name} className="h-16 px-6 py-3">
|
||||
<TableCell key={field.name} className="h-12 px-2.5 py-2">
|
||||
{renderField(field, itemIndex, item[field.name])}
|
||||
</TableCell>
|
||||
))}
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<TableCell className="h-16 px-6 py-3 text-center">
|
||||
<TableCell className="h-12 px-2.5 py-2 text-center">
|
||||
{!readonly && !disabled && items.length > minItems && (
|
||||
<Button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Plus, X, GripVertical, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { RepeaterFieldGroupConfig, RepeaterFieldDefinition, RepeaterFieldType } from "@/types/repeater";
|
||||
import { Plus, X, GripVertical, Check, ChevronsUpDown, Calculator } from "lucide-react";
|
||||
import { RepeaterFieldGroupConfig, RepeaterFieldDefinition, RepeaterFieldType, CalculationOperator, CalculationFormula } from "@/types/repeater";
|
||||
import { ColumnInfo } from "@/types/screen";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -192,6 +192,32 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<p className="text-xs text-gray-500">반복 필드 데이터를 저장할 테이블을 선택하세요.</p>
|
||||
</div>
|
||||
|
||||
{/* 그룹화 컬럼 설정 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-semibold">수정 시 그룹화 컬럼 (선택)</Label>
|
||||
<Select
|
||||
value={config.groupByColumn || "__none__"}
|
||||
onValueChange={(value) => handleChange("groupByColumn", value === "__none__" ? undefined : value)}
|
||||
>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="그룹화 컬럼 선택 (선택사항)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">사용 안함</SelectItem>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-gray-500">
|
||||
수정 모드에서 이 컬럼 값을 기준으로 관련된 모든 데이터를 조회합니다.
|
||||
<br />
|
||||
예: 입고번호를 선택하면 같은 입고번호를 가진 모든 품목이 표시됩니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 필드 정의 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">필드 정의</Label>
|
||||
|
|
@ -235,10 +261,23 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
key={column.columnName}
|
||||
value={column.columnName}
|
||||
onSelect={() => {
|
||||
// input_type (DB에서 설정한 타입) 우선 사용, 없으면 webType/widgetType
|
||||
const col = column as any;
|
||||
const fieldType = col.input_type || col.inputType || col.webType || col.widgetType || "text";
|
||||
|
||||
console.log("🔍 [RepeaterConfigPanel] 필드 타입 결정:", {
|
||||
columnName: column.columnName,
|
||||
input_type: col.input_type,
|
||||
inputType: col.inputType,
|
||||
webType: col.webType,
|
||||
widgetType: col.widgetType,
|
||||
finalType: fieldType,
|
||||
});
|
||||
|
||||
updateField(index, {
|
||||
name: column.columnName,
|
||||
label: column.columnLabel || column.columnName,
|
||||
type: (column.widgetType as RepeaterFieldType) || "text",
|
||||
type: fieldType as RepeaterFieldType,
|
||||
});
|
||||
// 로컬 입력 상태도 업데이트
|
||||
setLocalInputs(prev => ({
|
||||
|
|
@ -293,13 +332,25 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="text">텍스트</SelectItem>
|
||||
<SelectItem value="number">숫자</SelectItem>
|
||||
<SelectItem value="email">이메일</SelectItem>
|
||||
<SelectItem value="tel">전화번호</SelectItem>
|
||||
<SelectItem value="date">날짜</SelectItem>
|
||||
<SelectItem value="select">선택박스</SelectItem>
|
||||
<SelectItem value="textarea">텍스트영역</SelectItem>
|
||||
{/* 테이블 타입 관리에서 사용하는 input_type 목록 */}
|
||||
<SelectItem value="text">텍스트 (text)</SelectItem>
|
||||
<SelectItem value="number">숫자 (number)</SelectItem>
|
||||
<SelectItem value="textarea">텍스트영역 (textarea)</SelectItem>
|
||||
<SelectItem value="date">날짜 (date)</SelectItem>
|
||||
<SelectItem value="select">선택박스 (select)</SelectItem>
|
||||
<SelectItem value="checkbox">체크박스 (checkbox)</SelectItem>
|
||||
<SelectItem value="radio">라디오 (radio)</SelectItem>
|
||||
<SelectItem value="category">카테고리 (category)</SelectItem>
|
||||
<SelectItem value="entity">엔티티 참조 (entity)</SelectItem>
|
||||
<SelectItem value="code">공통코드 (code)</SelectItem>
|
||||
<SelectItem value="image">이미지 (image)</SelectItem>
|
||||
<SelectItem value="direct">직접입력 (direct)</SelectItem>
|
||||
<SelectItem value="calculated">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calculator className="h-3 w-3" />
|
||||
계산식 (calculated)
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
@ -316,16 +367,316 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`required-${index}`}
|
||||
checked={field.required ?? false}
|
||||
onCheckedChange={(checked) => updateField(index, { required: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor={`required-${index}`} className="cursor-pointer text-xs font-normal">
|
||||
필수 입력
|
||||
</Label>
|
||||
</div>
|
||||
{/* 계산식 타입일 때 계산식 설정 */}
|
||||
{field.type === "calculated" && (
|
||||
<div className="space-y-3 rounded-lg border border-blue-200 bg-blue-50 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calculator className="h-4 w-4 text-blue-600" />
|
||||
<Label className="text-xs font-semibold text-blue-800">계산식 설정</Label>
|
||||
</div>
|
||||
|
||||
{/* 필드 1 선택 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-blue-700">필드 1</Label>
|
||||
<Select
|
||||
value={field.formula?.field1 || ""}
|
||||
onValueChange={(value) => updateField(index, {
|
||||
formula: { ...field.formula, field1: value } as CalculationFormula
|
||||
})}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[9999]">
|
||||
{localFields
|
||||
.filter((f, i) => i !== index && f.type !== "calculated" && f.type !== "category")
|
||||
.map((f) => (
|
||||
<SelectItem key={f.name} value={f.name} className="text-xs">
|
||||
{f.label || f.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 연산자 선택 */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-blue-700">연산자</Label>
|
||||
<Select
|
||||
value={field.formula?.operator || "+"}
|
||||
onValueChange={(value) => updateField(index, {
|
||||
formula: { ...field.formula, operator: value as CalculationOperator } as CalculationFormula
|
||||
})}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[9999]">
|
||||
<SelectItem value="+" className="text-xs">+ 더하기</SelectItem>
|
||||
<SelectItem value="-" className="text-xs">- 빼기</SelectItem>
|
||||
<SelectItem value="*" className="text-xs">× 곱하기</SelectItem>
|
||||
<SelectItem value="/" className="text-xs">÷ 나누기</SelectItem>
|
||||
<SelectItem value="%" className="text-xs">% 나머지</SelectItem>
|
||||
<SelectItem value="round" className="text-xs">반올림</SelectItem>
|
||||
<SelectItem value="floor" className="text-xs">내림</SelectItem>
|
||||
<SelectItem value="ceil" className="text-xs">올림</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 두 번째 필드 또는 상수값 */}
|
||||
{!["round", "floor", "ceil", "abs"].includes(field.formula?.operator || "") ? (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-blue-700">필드 2 / 상수</Label>
|
||||
<Select
|
||||
value={field.formula?.field2 || (field.formula?.constantValue !== undefined ? `__const__${field.formula.constantValue}` : "")}
|
||||
onValueChange={(value) => {
|
||||
if (value.startsWith("__const__")) {
|
||||
updateField(index, {
|
||||
formula: {
|
||||
...field.formula,
|
||||
field2: undefined,
|
||||
constantValue: 0
|
||||
} as CalculationFormula
|
||||
});
|
||||
} else {
|
||||
updateField(index, {
|
||||
formula: {
|
||||
...field.formula,
|
||||
field2: value,
|
||||
constantValue: undefined
|
||||
} as CalculationFormula
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[9999]">
|
||||
{localFields
|
||||
.filter((f, i) => i !== index && f.type !== "calculated" && f.type !== "category")
|
||||
.map((f) => (
|
||||
<SelectItem key={f.name} value={f.name} className="text-xs">
|
||||
{f.label || f.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="__const__0" className="text-xs text-blue-600">
|
||||
상수값 입력
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-blue-700">소수점 자릿수</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
value={field.formula?.decimalPlaces ?? 0}
|
||||
onChange={(e) => updateField(index, {
|
||||
formula: { ...field.formula, decimalPlaces: parseInt(e.target.value) || 0 } as CalculationFormula
|
||||
})}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 상수값 입력 필드 */}
|
||||
{field.formula?.constantValue !== undefined && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[10px] text-blue-700">상수값</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={field.formula.constantValue}
|
||||
onChange={(e) => updateField(index, {
|
||||
formula: { ...field.formula, constantValue: parseFloat(e.target.value) || 0 } as CalculationFormula
|
||||
})}
|
||||
placeholder="숫자 입력"
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 숫자 포맷 설정 */}
|
||||
<div className="space-y-2 border-t border-blue-200 pt-2">
|
||||
<Label className="text-[10px] text-blue-700">숫자 표시 형식</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`thousand-sep-${index}`}
|
||||
checked={field.numberFormat?.useThousandSeparator ?? true}
|
||||
onCheckedChange={(checked) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean }
|
||||
})}
|
||||
/>
|
||||
<Label htmlFor={`thousand-sep-${index}`} className="cursor-pointer text-[10px]">
|
||||
천 단위 구분자
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Label className="text-[10px]">소수점:</Label>
|
||||
<Input
|
||||
value={field.numberFormat?.decimalPlaces ?? 0}
|
||||
onChange={(e) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 }
|
||||
})}
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
className="h-6 w-12 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
value={field.numberFormat?.prefix || ""}
|
||||
onChange={(e) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, prefix: e.target.value }
|
||||
})}
|
||||
placeholder="접두사 (₩)"
|
||||
className="h-7 text-[10px]"
|
||||
/>
|
||||
<Input
|
||||
value={field.numberFormat?.suffix || ""}
|
||||
onChange={(e) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, suffix: e.target.value }
|
||||
})}
|
||||
placeholder="접미사 (원)"
|
||||
className="h-7 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 계산식 미리보기 */}
|
||||
<div className="rounded bg-white p-2 text-xs">
|
||||
<span className="text-gray-500">계산식: </span>
|
||||
<code className="font-mono text-blue-700">
|
||||
{field.formula?.field1 || "필드1"} {field.formula?.operator || "+"} {
|
||||
field.formula?.field2 ||
|
||||
(field.formula?.constantValue !== undefined ? field.formula.constantValue : "필드2")
|
||||
}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 숫자 타입일 때 숫자 표시 형식 설정 */}
|
||||
{field.type === "number" && (
|
||||
<div className="space-y-2 rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||
<Label className="text-xs font-semibold text-gray-700">숫자 표시 형식</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`number-thousand-sep-${index}`}
|
||||
checked={field.numberFormat?.useThousandSeparator ?? false}
|
||||
onCheckedChange={(checked) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean }
|
||||
})}
|
||||
/>
|
||||
<Label htmlFor={`number-thousand-sep-${index}`} className="cursor-pointer text-[10px]">
|
||||
천 단위 구분자
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Label className="text-[10px]">소수점:</Label>
|
||||
<Input
|
||||
value={field.numberFormat?.decimalPlaces ?? 0}
|
||||
onChange={(e) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 }
|
||||
})}
|
||||
type="number"
|
||||
min={0}
|
||||
max={10}
|
||||
className="h-6 w-12 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
value={field.numberFormat?.prefix || ""}
|
||||
onChange={(e) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, prefix: e.target.value }
|
||||
})}
|
||||
placeholder="접두사 (₩)"
|
||||
className="h-7 text-[10px]"
|
||||
/>
|
||||
<Input
|
||||
value={field.numberFormat?.suffix || ""}
|
||||
onChange={(e) => updateField(index, {
|
||||
numberFormat: { ...field.numberFormat, suffix: e.target.value }
|
||||
})}
|
||||
placeholder="접미사 (원)"
|
||||
className="h-7 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 카테고리 타입일 때 카테고리 코드 입력 */}
|
||||
{field.type === "category" && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">카테고리 코드</Label>
|
||||
<Input
|
||||
value={field.categoryCode || field.name || ""}
|
||||
onChange={(e) => updateField(index, { categoryCode: e.target.value })}
|
||||
placeholder="카테고리 코드 (예: INBOUND_TYPE)"
|
||||
className="h-8 w-full text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
카테고리 관리에서 설정한 색상으로 배지가 표시됩니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 카테고리 타입이 아닐 때만 표시 모드 선택 */}
|
||||
{field.type !== "category" && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">표시 모드</Label>
|
||||
<Select
|
||||
value={field.displayMode || "input"}
|
||||
onValueChange={(value) => updateField(index, { displayMode: value as any })}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="input">입력 (편집 가능)</SelectItem>
|
||||
<SelectItem value="readonly">읽기전용 (텍스트)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 pt-5">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`required-${index}`}
|
||||
checked={field.required ?? false}
|
||||
onCheckedChange={(checked) => updateField(index, { required: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor={`required-${index}`} className="cursor-pointer text-xs font-normal">
|
||||
필수
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 카테고리 타입일 때는 필수만 표시 */}
|
||||
{field.type === "category" && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`required-${index}`}
|
||||
checked={field.required ?? false}
|
||||
onCheckedChange={(checked) => updateField(index, { required: checked as boolean })}
|
||||
/>
|
||||
<Label htmlFor={`required-${index}`} className="cursor-pointer text-xs font-normal">
|
||||
필수 입력
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
/**
|
||||
* 화면 컨텍스트
|
||||
* 같은 화면 내의 컴포넌트들이 서로 통신할 수 있도록 합니다.
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useCallback, useRef } from "react";
|
||||
import type { DataProvidable, DataReceivable } from "@/types/data-transfer";
|
||||
import { logger } from "@/lib/utils/logger";
|
||||
import type { SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
||||
|
||||
interface ScreenContextValue {
|
||||
screenId?: number;
|
||||
tableName?: string;
|
||||
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치 (left/right)
|
||||
|
||||
// 컴포넌트 등록
|
||||
registerDataProvider: (componentId: string, provider: DataProvidable) => void;
|
||||
unregisterDataProvider: (componentId: string) => void;
|
||||
registerDataReceiver: (componentId: string, receiver: DataReceivable) => void;
|
||||
unregisterDataReceiver: (componentId: string) => void;
|
||||
|
||||
// 컴포넌트 조회
|
||||
getDataProvider: (componentId: string) => DataProvidable | undefined;
|
||||
getDataReceiver: (componentId: string) => DataReceivable | undefined;
|
||||
|
||||
// 모든 컴포넌트 조회
|
||||
getAllDataProviders: () => Map<string, DataProvidable>;
|
||||
getAllDataReceivers: () => Map<string, DataReceivable>;
|
||||
}
|
||||
|
||||
const ScreenContext = createContext<ScreenContextValue | null>(null);
|
||||
|
||||
interface ScreenContextProviderProps {
|
||||
screenId?: number;
|
||||
tableName?: string;
|
||||
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 컨텍스트 프로바이더
|
||||
*/
|
||||
export function ScreenContextProvider({ screenId, tableName, splitPanelPosition, children }: ScreenContextProviderProps) {
|
||||
const dataProvidersRef = useRef<Map<string, DataProvidable>>(new Map());
|
||||
const dataReceiversRef = useRef<Map<string, DataReceivable>>(new Map());
|
||||
|
||||
const registerDataProvider = useCallback((componentId: string, provider: DataProvidable) => {
|
||||
dataProvidersRef.current.set(componentId, provider);
|
||||
logger.debug("데이터 제공자 등록", { componentId, componentType: provider.componentType });
|
||||
}, []);
|
||||
|
||||
const unregisterDataProvider = useCallback((componentId: string) => {
|
||||
dataProvidersRef.current.delete(componentId);
|
||||
logger.debug("데이터 제공자 해제", { componentId });
|
||||
}, []);
|
||||
|
||||
const registerDataReceiver = useCallback((componentId: string, receiver: DataReceivable) => {
|
||||
dataReceiversRef.current.set(componentId, receiver);
|
||||
logger.debug("데이터 수신자 등록", { componentId, componentType: receiver.componentType });
|
||||
}, []);
|
||||
|
||||
const unregisterDataReceiver = useCallback((componentId: string) => {
|
||||
dataReceiversRef.current.delete(componentId);
|
||||
logger.debug("데이터 수신자 해제", { componentId });
|
||||
}, []);
|
||||
|
||||
const getDataProvider = useCallback((componentId: string) => {
|
||||
return dataProvidersRef.current.get(componentId);
|
||||
}, []);
|
||||
|
||||
const getDataReceiver = useCallback((componentId: string) => {
|
||||
return dataReceiversRef.current.get(componentId);
|
||||
}, []);
|
||||
|
||||
const getAllDataProviders = useCallback(() => {
|
||||
return new Map(dataProvidersRef.current);
|
||||
}, []);
|
||||
|
||||
const getAllDataReceivers = useCallback(() => {
|
||||
return new Map(dataReceiversRef.current);
|
||||
}, []);
|
||||
|
||||
// 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지)
|
||||
const value = React.useMemo<ScreenContextValue>(() => ({
|
||||
screenId,
|
||||
tableName,
|
||||
splitPanelPosition,
|
||||
registerDataProvider,
|
||||
unregisterDataProvider,
|
||||
registerDataReceiver,
|
||||
unregisterDataReceiver,
|
||||
getDataProvider,
|
||||
getDataReceiver,
|
||||
getAllDataProviders,
|
||||
getAllDataReceivers,
|
||||
}), [
|
||||
screenId,
|
||||
tableName,
|
||||
splitPanelPosition,
|
||||
registerDataProvider,
|
||||
unregisterDataProvider,
|
||||
registerDataReceiver,
|
||||
unregisterDataReceiver,
|
||||
getDataProvider,
|
||||
getDataReceiver,
|
||||
getAllDataProviders,
|
||||
getAllDataReceivers,
|
||||
]);
|
||||
|
||||
return <ScreenContext.Provider value={value}>{children}</ScreenContext.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 컨텍스트 훅
|
||||
*/
|
||||
export function useScreenContext() {
|
||||
const context = useContext(ScreenContext);
|
||||
if (!context) {
|
||||
throw new Error("useScreenContext는 ScreenContextProvider 내부에서만 사용할 수 있습니다.");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 컨텍스트 훅 (선택적)
|
||||
* 컨텍스트가 없어도 에러를 발생시키지 않습니다.
|
||||
*/
|
||||
export function useScreenContextOptional() {
|
||||
return useContext(ScreenContext);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useCallback, useRef, useState } from "react";
|
||||
import { logger } from "@/lib/utils/logger";
|
||||
|
||||
/**
|
||||
* 분할 패널 내 화면 위치
|
||||
*/
|
||||
export type SplitPanelPosition = "left" | "right";
|
||||
|
||||
/**
|
||||
* 데이터 수신자 인터페이스
|
||||
*/
|
||||
export interface SplitPanelDataReceiver {
|
||||
componentId: string;
|
||||
componentType: string;
|
||||
receiveData: (data: any[], mode: "append" | "replace" | "merge") => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 분할 패널 컨텍스트 값
|
||||
*/
|
||||
interface SplitPanelContextValue {
|
||||
// 분할 패널 ID
|
||||
splitPanelId: string;
|
||||
|
||||
// 좌측/우측 화면 ID
|
||||
leftScreenId: number | null;
|
||||
rightScreenId: number | null;
|
||||
|
||||
// 데이터 수신자 등록/해제
|
||||
registerReceiver: (position: SplitPanelPosition, componentId: string, receiver: SplitPanelDataReceiver) => void;
|
||||
unregisterReceiver: (position: SplitPanelPosition, componentId: string) => void;
|
||||
|
||||
// 반대편 화면으로 데이터 전달
|
||||
transferToOtherSide: (
|
||||
fromPosition: SplitPanelPosition,
|
||||
data: any[],
|
||||
targetComponentId?: string, // 특정 컴포넌트 지정 (없으면 첫 번째 수신자)
|
||||
mode?: "append" | "replace" | "merge"
|
||||
) => Promise<{ success: boolean; message: string }>;
|
||||
|
||||
// 반대편 화면의 수신자 목록 가져오기
|
||||
getOtherSideReceivers: (fromPosition: SplitPanelPosition) => SplitPanelDataReceiver[];
|
||||
|
||||
// 현재 위치 확인
|
||||
isInSplitPanel: boolean;
|
||||
|
||||
// screenId로 위치 찾기
|
||||
getPositionByScreenId: (screenId: number) => SplitPanelPosition | null;
|
||||
}
|
||||
|
||||
const SplitPanelContext = createContext<SplitPanelContextValue | null>(null);
|
||||
|
||||
interface SplitPanelProviderProps {
|
||||
splitPanelId: string;
|
||||
leftScreenId: number | null;
|
||||
rightScreenId: number | null;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 분할 패널 컨텍스트 프로바이더
|
||||
*/
|
||||
export function SplitPanelProvider({
|
||||
splitPanelId,
|
||||
leftScreenId,
|
||||
rightScreenId,
|
||||
children,
|
||||
}: SplitPanelProviderProps) {
|
||||
// 좌측/우측 화면의 데이터 수신자 맵
|
||||
const leftReceiversRef = useRef<Map<string, SplitPanelDataReceiver>>(new Map());
|
||||
const rightReceiversRef = useRef<Map<string, SplitPanelDataReceiver>>(new Map());
|
||||
|
||||
// 강제 리렌더링용 상태
|
||||
const [, forceUpdate] = useState(0);
|
||||
|
||||
/**
|
||||
* 데이터 수신자 등록
|
||||
*/
|
||||
const registerReceiver = useCallback(
|
||||
(position: SplitPanelPosition, componentId: string, receiver: SplitPanelDataReceiver) => {
|
||||
const receiversRef = position === "left" ? leftReceiversRef : rightReceiversRef;
|
||||
receiversRef.current.set(componentId, receiver);
|
||||
|
||||
logger.debug(`[SplitPanelContext] 수신자 등록: ${position} - ${componentId}`, {
|
||||
componentType: receiver.componentType,
|
||||
});
|
||||
|
||||
forceUpdate((n) => n + 1);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* 데이터 수신자 해제
|
||||
*/
|
||||
const unregisterReceiver = useCallback(
|
||||
(position: SplitPanelPosition, componentId: string) => {
|
||||
const receiversRef = position === "left" ? leftReceiversRef : rightReceiversRef;
|
||||
receiversRef.current.delete(componentId);
|
||||
|
||||
logger.debug(`[SplitPanelContext] 수신자 해제: ${position} - ${componentId}`);
|
||||
|
||||
forceUpdate((n) => n + 1);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* 반대편 화면의 수신자 목록 가져오기
|
||||
*/
|
||||
const getOtherSideReceivers = useCallback(
|
||||
(fromPosition: SplitPanelPosition): SplitPanelDataReceiver[] => {
|
||||
const receiversRef = fromPosition === "left" ? rightReceiversRef : leftReceiversRef;
|
||||
return Array.from(receiversRef.current.values());
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* 반대편 화면으로 데이터 전달
|
||||
*/
|
||||
const transferToOtherSide = useCallback(
|
||||
async (
|
||||
fromPosition: SplitPanelPosition,
|
||||
data: any[],
|
||||
targetComponentId?: string,
|
||||
mode: "append" | "replace" | "merge" = "append"
|
||||
): Promise<{ success: boolean; message: string }> => {
|
||||
const toPosition = fromPosition === "left" ? "right" : "left";
|
||||
const receiversRef = fromPosition === "left" ? rightReceiversRef : leftReceiversRef;
|
||||
|
||||
logger.info(`[SplitPanelContext] 데이터 전달 시작: ${fromPosition} → ${toPosition}`, {
|
||||
dataCount: data.length,
|
||||
targetComponentId,
|
||||
mode,
|
||||
availableReceivers: Array.from(receiversRef.current.keys()),
|
||||
});
|
||||
|
||||
if (receiversRef.current.size === 0) {
|
||||
const message = `${toPosition === "left" ? "좌측" : "우측"} 화면에 데이터를 받을 수 있는 컴포넌트가 없습니다.`;
|
||||
logger.warn(`[SplitPanelContext] ${message}`);
|
||||
return { success: false, message };
|
||||
}
|
||||
|
||||
try {
|
||||
let targetReceiver: SplitPanelDataReceiver | undefined;
|
||||
|
||||
if (targetComponentId) {
|
||||
// 특정 컴포넌트 지정
|
||||
targetReceiver = receiversRef.current.get(targetComponentId);
|
||||
if (!targetReceiver) {
|
||||
const message = `타겟 컴포넌트 '${targetComponentId}'를 찾을 수 없습니다.`;
|
||||
logger.warn(`[SplitPanelContext] ${message}`);
|
||||
return { success: false, message };
|
||||
}
|
||||
} else {
|
||||
// 첫 번째 수신자 사용
|
||||
targetReceiver = receiversRef.current.values().next().value;
|
||||
}
|
||||
|
||||
if (!targetReceiver) {
|
||||
return { success: false, message: "데이터 수신자를 찾을 수 없습니다." };
|
||||
}
|
||||
|
||||
await targetReceiver.receiveData(data, mode);
|
||||
|
||||
const message = `${data.length}개 항목이 ${toPosition === "left" ? "좌측" : "우측"} 화면으로 전달되었습니다.`;
|
||||
logger.info(`[SplitPanelContext] ${message}`);
|
||||
|
||||
return { success: true, message };
|
||||
} catch (error: any) {
|
||||
const message = error.message || "데이터 전달 중 오류가 발생했습니다.";
|
||||
logger.error(`[SplitPanelContext] 데이터 전달 실패`, error);
|
||||
return { success: false, message };
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
/**
|
||||
* screenId로 위치 찾기
|
||||
*/
|
||||
const getPositionByScreenId = useCallback(
|
||||
(screenId: number): SplitPanelPosition | null => {
|
||||
if (leftScreenId === screenId) return "left";
|
||||
if (rightScreenId === screenId) return "right";
|
||||
return null;
|
||||
},
|
||||
[leftScreenId, rightScreenId]
|
||||
);
|
||||
|
||||
// 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지)
|
||||
const value = React.useMemo<SplitPanelContextValue>(() => ({
|
||||
splitPanelId,
|
||||
leftScreenId,
|
||||
rightScreenId,
|
||||
registerReceiver,
|
||||
unregisterReceiver,
|
||||
transferToOtherSide,
|
||||
getOtherSideReceivers,
|
||||
isInSplitPanel: true,
|
||||
getPositionByScreenId,
|
||||
}), [
|
||||
splitPanelId,
|
||||
leftScreenId,
|
||||
rightScreenId,
|
||||
registerReceiver,
|
||||
unregisterReceiver,
|
||||
transferToOtherSide,
|
||||
getOtherSideReceivers,
|
||||
getPositionByScreenId,
|
||||
]);
|
||||
|
||||
return (
|
||||
<SplitPanelContext.Provider value={value}>
|
||||
{children}
|
||||
</SplitPanelContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 분할 패널 컨텍스트 훅
|
||||
*/
|
||||
export function useSplitPanelContext() {
|
||||
return useContext(SplitPanelContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* 분할 패널 내부인지 확인하는 훅
|
||||
*/
|
||||
export function useIsInSplitPanel(): boolean {
|
||||
const context = useContext(SplitPanelContext);
|
||||
return context?.isInSplitPanel ?? false;
|
||||
}
|
||||
|
||||
|
|
@ -221,11 +221,11 @@ export const useAuth = () => {
|
|||
|
||||
setAuthStatus(finalAuthStatus);
|
||||
|
||||
console.log("✅ 최종 사용자 상태:", {
|
||||
userId: userInfo?.userId,
|
||||
userName: userInfo?.userName,
|
||||
companyCode: userInfo?.companyCode || userInfo?.company_code,
|
||||
});
|
||||
// console.log("✅ 최종 사용자 상태:", {
|
||||
// userId: userInfo?.userId,
|
||||
// userName: userInfo?.userName,
|
||||
// companyCode: userInfo?.companyCode || userInfo?.company_code,
|
||||
// });
|
||||
|
||||
// 디버깅용 로그
|
||||
|
||||
|
|
|
|||
|
|
@ -192,6 +192,43 @@ export class ExternalRestApiConnectionAPI {
|
|||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* REST API 데이터 조회 (화면관리용 프록시)
|
||||
*/
|
||||
static async fetchData(
|
||||
connectionId: number,
|
||||
endpoint?: string,
|
||||
jsonPath?: string,
|
||||
): Promise<{
|
||||
rows: any[];
|
||||
columns: Array<{ columnName: string; columnLabel: string; dataType: string }>;
|
||||
total: number;
|
||||
connectionInfo: {
|
||||
connectionId: number;
|
||||
connectionName: string;
|
||||
baseUrl: string;
|
||||
endpoint: string;
|
||||
};
|
||||
}> {
|
||||
const response = await apiClient.post<ApiResponse<{
|
||||
rows: any[];
|
||||
columns: Array<{ columnName: string; columnLabel: string; dataType: string }>;
|
||||
total: number;
|
||||
connectionInfo: {
|
||||
connectionId: number;
|
||||
connectionName: string;
|
||||
baseUrl: string;
|
||||
endpoint: string;
|
||||
};
|
||||
}>>(`${this.BASE_PATH}/${connectionId}/fetch`, { endpoint, jsonPath });
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "REST API 데이터 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
return response.data.data!;
|
||||
}
|
||||
|
||||
/**
|
||||
* 지원되는 인증 타입 목록
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -42,13 +42,35 @@ export interface UpdateMailAccountDto extends Partial<CreateMailAccountDto> {
|
|||
|
||||
export interface MailComponent {
|
||||
id: string;
|
||||
type: 'text' | 'button' | 'image' | 'spacer';
|
||||
type: 'text' | 'button' | 'image' | 'spacer' | 'header' | 'infoTable' | 'alertBox' | 'divider' | 'footer' | 'numberedList';
|
||||
content?: string;
|
||||
text?: string;
|
||||
url?: string;
|
||||
src?: string;
|
||||
height?: number;
|
||||
styles?: Record<string, string>;
|
||||
// 헤더 컴포넌트용
|
||||
logoSrc?: string;
|
||||
brandName?: string;
|
||||
sendDate?: string;
|
||||
headerBgColor?: string;
|
||||
// 정보 테이블용
|
||||
rows?: Array<{ label: string; value: string }>;
|
||||
tableTitle?: string;
|
||||
// 강조 박스용
|
||||
alertType?: 'info' | 'warning' | 'danger' | 'success';
|
||||
alertTitle?: string;
|
||||
// 푸터용
|
||||
companyName?: string;
|
||||
ceoName?: string;
|
||||
businessNumber?: string;
|
||||
address?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
copyright?: string;
|
||||
// 번호 리스트용
|
||||
listItems?: string[];
|
||||
listTitle?: string;
|
||||
}
|
||||
|
||||
export interface MailTemplate {
|
||||
|
|
@ -470,6 +492,95 @@ export function renderTemplateToHtml(
|
|||
case 'spacer':
|
||||
html += `<div style="height: ${component.height || 20}px;"></div>`;
|
||||
break;
|
||||
|
||||
case 'header':
|
||||
html += `
|
||||
<div style="padding: 20px; background-color: ${component.headerBgColor || '#f8f9fa'}; border-radius: 8px; margin-bottom: 20px;">
|
||||
<table style="width: 100%;">
|
||||
<tr>
|
||||
<td style="vertical-align: middle;">
|
||||
${component.logoSrc ? `<img src="${component.logoSrc}" alt="로고" style="height: 40px; margin-right: 12px;">` : ''}
|
||||
<span style="font-size: 18px; font-weight: bold;">${component.brandName || ''}</span>
|
||||
</td>
|
||||
<td style="text-align: right; color: #6b7280; font-size: 14px;">
|
||||
${component.sendDate || ''}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
|
||||
case 'infoTable':
|
||||
html += `
|
||||
<div style="border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; margin: 16px 0;">
|
||||
${component.tableTitle ? `<div style="background-color: #f9fafb; padding: 12px 16px; font-weight: 600; border-bottom: 1px solid #e5e7eb;">${component.tableTitle}</div>` : ''}
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
${(component.rows || []).map((row, i) => `
|
||||
<tr style="background-color: ${i % 2 === 0 ? '#ffffff' : '#f9fafb'};">
|
||||
<td style="padding: 12px 16px; font-weight: 500; color: #4b5563; width: 35%; border-right: 1px solid #e5e7eb;">${row.label}</td>
|
||||
<td style="padding: 12px 16px;">${row.value}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
|
||||
case 'alertBox':
|
||||
const alertColors = {
|
||||
info: { bg: '#eff6ff', border: '#3b82f6', text: '#1e40af' },
|
||||
warning: { bg: '#fffbeb', border: '#f59e0b', text: '#92400e' },
|
||||
danger: { bg: '#fef2f2', border: '#ef4444', text: '#991b1b' },
|
||||
success: { bg: '#ecfdf5', border: '#10b981', text: '#065f46' }
|
||||
};
|
||||
const colors = alertColors[component.alertType || 'info'];
|
||||
html += `
|
||||
<div style="padding: 16px; background-color: ${colors.bg}; border-left: 4px solid ${colors.border}; border-radius: 4px; margin: 16px 0; color: ${colors.text};">
|
||||
${component.alertTitle ? `<div style="font-weight: bold; margin-bottom: 8px;">${component.alertTitle}</div>` : ''}
|
||||
<div>${component.content || ''}</div>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
|
||||
case 'divider':
|
||||
html += `<hr style="border: none; border-top: ${component.height || 1}px solid #e5e7eb; margin: 20px 0;">`;
|
||||
break;
|
||||
|
||||
case 'footer':
|
||||
html += `
|
||||
<div style="text-align: center; padding: 24px 16px; background-color: #f9fafb; border-top: 1px solid #e5e7eb; font-size: 14px; color: #6b7280;">
|
||||
${component.companyName ? `<div style="font-weight: 600; color: #374151; margin-bottom: 8px;">${component.companyName}</div>` : ''}
|
||||
${(component.ceoName || component.businessNumber) ? `
|
||||
<div style="margin-bottom: 4px;">
|
||||
${component.ceoName ? `대표: ${component.ceoName}` : ''}
|
||||
${component.ceoName && component.businessNumber ? ' | ' : ''}
|
||||
${component.businessNumber ? `사업자등록번호: ${component.businessNumber}` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
${component.address ? `<div style="margin-bottom: 4px;">${component.address}</div>` : ''}
|
||||
${(component.phone || component.email) ? `
|
||||
<div style="margin-bottom: 4px;">
|
||||
${component.phone ? `Tel: ${component.phone}` : ''}
|
||||
${component.phone && component.email ? ' | ' : ''}
|
||||
${component.email ? `Email: ${component.email}` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
${component.copyright ? `<div style="margin-top: 12px; font-size: 12px; color: #9ca3af;">${component.copyright}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
|
||||
case 'numberedList':
|
||||
html += `
|
||||
<div style="padding: 16px; ${styleObjectToString(component.styles)}">
|
||||
${component.listTitle ? `<div style="font-weight: 600; margin-bottom: 12px;">${component.listTitle}</div>` : ''}
|
||||
<ol style="margin: 0; padding-left: 20px;">
|
||||
${(component.listItems || []).map(item => `<li style="margin-bottom: 8px;">${item}</li>`).join('')}
|
||||
</ol>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -199,8 +199,6 @@ export interface MenuCopyResult {
|
|||
copiedMenus: number;
|
||||
copiedScreens: number;
|
||||
copiedFlows: number;
|
||||
copiedCategories: number;
|
||||
copiedCodes: number;
|
||||
menuIdMap: Record<number, number>;
|
||||
screenIdMap: Record<number, number>;
|
||||
flowIdMap: Record<number, number>;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,271 @@
|
|||
/**
|
||||
* 화면 임베딩 및 데이터 전달 시스템 API 클라이언트
|
||||
*/
|
||||
|
||||
import apiClient from "./client";
|
||||
import type {
|
||||
ScreenEmbedding,
|
||||
ScreenDataTransfer,
|
||||
ScreenSplitPanel,
|
||||
CreateScreenEmbeddingRequest,
|
||||
CreateScreenDataTransferRequest,
|
||||
CreateScreenSplitPanelRequest,
|
||||
ApiResponse,
|
||||
} from "@/types/screen-embedding";
|
||||
|
||||
// ============================================
|
||||
// 1. 화면 임베딩 API
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 화면 임베딩 목록 조회
|
||||
*/
|
||||
export async function getScreenEmbeddings(
|
||||
parentScreenId: number
|
||||
): Promise<ApiResponse<ScreenEmbedding[]>> {
|
||||
try {
|
||||
const response = await apiClient.get("/screen-embedding", {
|
||||
params: { parentScreenId },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || "화면 임베딩 목록 조회 실패",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 임베딩 상세 조회
|
||||
*/
|
||||
export async function getScreenEmbeddingById(
|
||||
id: number
|
||||
): Promise<ApiResponse<ScreenEmbedding>> {
|
||||
try {
|
||||
const response = await apiClient.get(`/screen-embedding/${id}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || "화면 임베딩 조회 실패",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 임베딩 생성
|
||||
*/
|
||||
export async function createScreenEmbedding(
|
||||
data: CreateScreenEmbeddingRequest
|
||||
): Promise<ApiResponse<ScreenEmbedding>> {
|
||||
try {
|
||||
const response = await apiClient.post("/screen-embedding", data);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || "화면 임베딩 생성 실패",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 임베딩 수정
|
||||
*/
|
||||
export async function updateScreenEmbedding(
|
||||
id: number,
|
||||
data: Partial<CreateScreenEmbeddingRequest>
|
||||
): Promise<ApiResponse<ScreenEmbedding>> {
|
||||
try {
|
||||
const response = await apiClient.put(`/screen-embedding/${id}`, data);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || "화면 임베딩 수정 실패",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 화면 임베딩 삭제
|
||||
*/
|
||||
export async function deleteScreenEmbedding(
|
||||
id: number
|
||||
): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
const response = await apiClient.delete(`/screen-embedding/${id}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || "화면 임베딩 삭제 실패",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 2. 데이터 전달 API
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 데이터 전달 설정 조회
|
||||
*/
|
||||
export async function getScreenDataTransfer(
|
||||
sourceScreenId: number,
|
||||
targetScreenId: number
|
||||
): Promise<ApiResponse<ScreenDataTransfer>> {
|
||||
try {
|
||||
const response = await apiClient.get("/screen-data-transfer", {
|
||||
params: { sourceScreenId, targetScreenId },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || "데이터 전달 설정 조회 실패",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 전달 설정 생성
|
||||
*/
|
||||
export async function createScreenDataTransfer(
|
||||
data: CreateScreenDataTransferRequest
|
||||
): Promise<ApiResponse<ScreenDataTransfer>> {
|
||||
try {
|
||||
const response = await apiClient.post("/screen-data-transfer", data);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || "데이터 전달 설정 생성 실패",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 전달 설정 수정
|
||||
*/
|
||||
export async function updateScreenDataTransfer(
|
||||
id: number,
|
||||
data: Partial<CreateScreenDataTransferRequest>
|
||||
): Promise<ApiResponse<ScreenDataTransfer>> {
|
||||
try {
|
||||
const response = await apiClient.put(`/screen-data-transfer/${id}`, data);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || "데이터 전달 설정 수정 실패",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 전달 설정 삭제
|
||||
*/
|
||||
export async function deleteScreenDataTransfer(
|
||||
id: number
|
||||
): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
const response = await apiClient.delete(`/screen-data-transfer/${id}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || "데이터 전달 설정 삭제 실패",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 3. 분할 패널 API
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 분할 패널 설정 조회
|
||||
*/
|
||||
export async function getScreenSplitPanel(
|
||||
screenId: number
|
||||
): Promise<ApiResponse<ScreenSplitPanel>> {
|
||||
try {
|
||||
const response = await apiClient.get(`/screen-split-panel/${screenId}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || "분할 패널 설정 조회 실패",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 분할 패널 설정 생성
|
||||
*/
|
||||
export async function createScreenSplitPanel(
|
||||
data: CreateScreenSplitPanelRequest
|
||||
): Promise<ApiResponse<ScreenSplitPanel>> {
|
||||
try {
|
||||
const response = await apiClient.post("/screen-split-panel", data);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || "분할 패널 설정 생성 실패",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 분할 패널 설정 수정
|
||||
*/
|
||||
export async function updateScreenSplitPanel(
|
||||
id: number,
|
||||
layoutConfig: any
|
||||
): Promise<ApiResponse<ScreenSplitPanel>> {
|
||||
try {
|
||||
const response = await apiClient.put(`/screen-split-panel/${id}`, {
|
||||
layoutConfig,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || "분할 패널 설정 수정 실패",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 분할 패널 설정 삭제
|
||||
*/
|
||||
export async function deleteScreenSplitPanel(
|
||||
id: number
|
||||
): Promise<ApiResponse<void>> {
|
||||
try {
|
||||
const response = await apiClient.delete(`/screen-split-panel/${id}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data?.message || "분할 패널 설정 삭제 실패",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 4. 유틸리티 함수
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 화면 임베딩 전체 설정 조회 (분할 패널 포함)
|
||||
*/
|
||||
export async function getFullScreenEmbeddingConfig(
|
||||
screenId: number
|
||||
): Promise<ApiResponse<ScreenSplitPanel>> {
|
||||
return getScreenSplitPanel(screenId);
|
||||
}
|
||||
|
||||
|
|
@ -259,6 +259,28 @@ export async function deleteColumnMapping(mappingId: number) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블+컬럼 기준으로 모든 매핑 삭제
|
||||
*
|
||||
* 메뉴 선택 변경 시 기존 매핑을 모두 삭제하고 새로운 매핑만 추가하기 위해 사용
|
||||
*
|
||||
* @param tableName - 테이블명
|
||||
* @param columnName - 컬럼명
|
||||
*/
|
||||
export async function deleteColumnMappingsByColumn(tableName: string, columnName: string) {
|
||||
try {
|
||||
const response = await apiClient.delete<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
deletedCount: number;
|
||||
}>(`/table-categories/column-mapping/${tableName}/${columnName}/all`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("테이블+컬럼 기준 매핑 삭제 실패:", error);
|
||||
return { success: false, error: error.message, deletedCount: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 2레벨 메뉴 목록 조회
|
||||
*
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ class CodeCache {
|
|||
* 여러 코드 카테고리를 배치로 미리 로딩
|
||||
*/
|
||||
async preloadCodes(categories: string[]): Promise<void> {
|
||||
console.log(`🔄 코드 배치 로딩 시작: ${categories.join(", ")}`);
|
||||
// console.log(`🔄 코드 배치 로딩 시작: ${categories.join(", ")}`);
|
||||
|
||||
const promises = categories.map(async (category) => {
|
||||
try {
|
||||
|
|
@ -101,7 +101,7 @@ class CodeCache {
|
|||
if (response.success && response.data) {
|
||||
const cacheKey = this.createCodeKey(category);
|
||||
this.set(cacheKey, response.data, this.defaultTTL);
|
||||
console.log(`✅ 코드 로딩 완료: ${category} (${response.data.length}개)`);
|
||||
// console.log(`✅ 코드 로딩 완료: ${category} (${response.data.length}개)`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 코드 로딩 실패: ${category}`, error);
|
||||
|
|
@ -109,7 +109,7 @@ class CodeCache {
|
|||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
console.log(`✅ 코드 배치 로딩 완료: ${categories.length}개 카테고리`);
|
||||
// console.log(`✅ 코드 배치 로딩 완료: ${categories.length}개 카테고리`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ export function useEntityJoinOptimization(columnMeta: Record<string, ColumnMetaI
|
|||
batches.push(categories.slice(i, i + maxBatchSize));
|
||||
}
|
||||
|
||||
console.log(`🔄 배치 코드 로딩 시작: ${categories.length}개 카테고리 (${batches.length}개 배치)`);
|
||||
// console.log(`🔄 배치 코드 로딩 시작: ${categories.length}개 카테고리 (${batches.length}개 배치)`);
|
||||
|
||||
for (const batch of batches) {
|
||||
// 로딩 상태 업데이트
|
||||
|
|
@ -125,7 +125,7 @@ export function useEntityJoinOptimization(columnMeta: Record<string, ColumnMetaI
|
|||
const responseTime = Date.now() - startTime;
|
||||
requestTimes.current.push(responseTime);
|
||||
|
||||
console.log(`✅ 배치 코드 로딩 완료: ${responseTime}ms`);
|
||||
// console.log(`✅ 배치 코드 로딩 완료: ${responseTime}ms`);
|
||||
} catch (error) {
|
||||
console.error("❌ 배치 코드 로딩 실패:", error);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -224,6 +224,19 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
// 1. 새 컴포넌트 시스템에서 먼저 조회
|
||||
const newComponent = ComponentRegistry.getComponent(componentType);
|
||||
|
||||
// 🔍 디버깅: screen-split-panel 조회 결과 확인
|
||||
if (componentType === "screen-split-panel") {
|
||||
console.log("🔍 [DynamicComponentRenderer] screen-split-panel 조회:", {
|
||||
componentType,
|
||||
found: !!newComponent,
|
||||
componentId: component.id,
|
||||
componentConfig: component.componentConfig,
|
||||
hasFormData: !!props.formData,
|
||||
formDataKeys: props.formData ? Object.keys(props.formData) : [],
|
||||
registeredComponents: ComponentRegistry.getAllComponents().map(c => c.id),
|
||||
});
|
||||
}
|
||||
|
||||
// 🔍 디버깅: select-basic 조회 결과 확인
|
||||
if (componentType === "select-basic") {
|
||||
console.log("🔍 [DynamicComponentRenderer] select-basic 조회:", {
|
||||
|
|
@ -234,6 +247,20 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
});
|
||||
}
|
||||
|
||||
// 🔍 디버깅: text-input 컴포넌트 조회 결과 확인
|
||||
if (componentType === "text-input" || component.id?.includes("text") || (component as any).webType === "text") {
|
||||
console.log("🔍 [DynamicComponentRenderer] text-input 조회:", {
|
||||
componentType,
|
||||
componentId: component.id,
|
||||
componentLabel: component.label,
|
||||
componentConfig: component.componentConfig,
|
||||
webTypeConfig: (component as any).webTypeConfig,
|
||||
autoGeneration: (component as any).autoGeneration,
|
||||
found: !!newComponent,
|
||||
registeredComponents: ComponentRegistry.getAllComponents().map(c => c.id),
|
||||
});
|
||||
}
|
||||
|
||||
if (newComponent) {
|
||||
// 새 컴포넌트 시스템으로 렌더링
|
||||
try {
|
||||
|
|
@ -294,6 +321,19 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
} else {
|
||||
currentValue = formData?.[fieldName] || "";
|
||||
}
|
||||
|
||||
// 🆕 디버깅: text-input 값 추출 확인
|
||||
if (componentType === "text-input" && formData && Object.keys(formData).length > 0) {
|
||||
console.log("🔍 [DynamicComponentRenderer] text-input 값 추출:", {
|
||||
componentId: component.id,
|
||||
componentLabel: component.label,
|
||||
columnName: (component as any).columnName,
|
||||
fieldName,
|
||||
currentValue,
|
||||
hasFormData: !!formData,
|
||||
formDataKeys: Object.keys(formData).slice(0, 10), // 처음 10개만
|
||||
});
|
||||
}
|
||||
|
||||
// onChange 핸들러 - 컴포넌트 타입에 따라 다르게 처리
|
||||
const handleChange = (value: any) => {
|
||||
|
|
@ -422,8 +462,14 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
if (!renderer) {
|
||||
console.error(`⚠️ 등록되지 않은 컴포넌트 타입: ${componentType}`, {
|
||||
component: component,
|
||||
componentId: component.id,
|
||||
componentLabel: component.label,
|
||||
componentType: componentType,
|
||||
originalType: component.type,
|
||||
originalComponentType: (component as any).componentType,
|
||||
componentConfig: component.componentConfig,
|
||||
webTypeConfig: (component as any).webTypeConfig,
|
||||
autoGeneration: (component as any).autoGeneration,
|
||||
availableNewComponents: ComponentRegistry.getAllComponents().map((c) => c.id),
|
||||
availableLegacyComponents: legacyComponentRegistry.getRegisteredTypes(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ import { toast } from "sonner";
|
|||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
import { useCurrentFlowStep } from "@/stores/flowStepStore";
|
||||
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
|
||||
import { applyMappingRules } from "@/lib/utils/dataMapping";
|
||||
|
||||
export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
|
||||
config?: ButtonPrimaryConfig;
|
||||
|
|
@ -97,6 +100,14 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
...props
|
||||
}) => {
|
||||
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
|
||||
const screenContext = useScreenContextOptional(); // 화면 컨텍스트
|
||||
const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트
|
||||
// 🆕 ScreenContext에서 splitPanelPosition 가져오기 (중첩 화면에서도 작동)
|
||||
const splitPanelPosition = screenContext?.splitPanelPosition;
|
||||
|
||||
// 🆕 tableName이 props로 전달되지 않으면 ScreenContext에서 가져오기
|
||||
const effectiveTableName = tableName || screenContext?.tableName;
|
||||
const effectiveScreenId = screenId || screenContext?.screenId;
|
||||
|
||||
// 🆕 props에서 onSave 추출 (명시적으로 선언되지 않은 경우 ...props에서 추출)
|
||||
const propsOnSave = (props as any).onSave as (() => Promise<void>) | undefined;
|
||||
|
|
@ -146,7 +157,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
} | null>(null);
|
||||
|
||||
// 토스트 정리를 위한 ref
|
||||
const currentLoadingToastRef = useRef<string | number | undefined>();
|
||||
const currentLoadingToastRef = useRef<string | number | undefined>(undefined);
|
||||
|
||||
// 컴포넌트 언마운트 시 토스트 정리
|
||||
useEffect(() => {
|
||||
|
|
@ -190,9 +201,11 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
}, [component.componentConfig?.action?.type, component.config?.action?.type, component.webTypeConfig?.actionType]);
|
||||
|
||||
// 컴포넌트 설정
|
||||
// 🔥 component.componentConfig도 병합해야 함 (화면 디자이너에서 저장된 설정)
|
||||
const componentConfig = {
|
||||
...config,
|
||||
...component.config,
|
||||
...component.componentConfig, // 🔥 화면 디자이너에서 저장된 action 등 포함
|
||||
} as ButtonPrimaryConfig;
|
||||
|
||||
// 🎨 동적 색상 설정 (속성편집 모달의 "색상" 필드와 연동)
|
||||
|
|
@ -227,13 +240,12 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
|
||||
// 스타일 계산
|
||||
// height: 100%로 부모(RealtimePreviewDynamic의 내부 div)의 높이를 따라감
|
||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||
const componentStyle: React.CSSProperties = {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
...component.style,
|
||||
...style,
|
||||
// width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어)
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
};
|
||||
|
||||
// 디자인 모드 스타일 (border 속성 분리하여 충돌 방지)
|
||||
|
|
@ -374,6 +386,261 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
};
|
||||
|
||||
// 이벤트 핸들러
|
||||
/**
|
||||
* transferData 액션 처리
|
||||
*/
|
||||
const handleTransferDataAction = async (actionConfig: any) => {
|
||||
const dataTransferConfig = actionConfig.dataTransfer;
|
||||
|
||||
if (!dataTransferConfig) {
|
||||
toast.error("데이터 전달 설정이 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!screenContext) {
|
||||
toast.error("화면 컨텍스트를 찾을 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 소스 컴포넌트에서 데이터 가져오기
|
||||
let sourceProvider = screenContext.getDataProvider(dataTransferConfig.sourceComponentId);
|
||||
|
||||
// 🆕 소스 컴포넌트를 찾을 수 없으면, 현재 화면에서 테이블 리스트 자동 탐색
|
||||
// (조건부 컨테이너의 다른 섹션으로 전환했을 때 이전 컴포넌트 ID가 남아있는 경우 대응)
|
||||
if (!sourceProvider) {
|
||||
console.log(`⚠️ [ButtonPrimary] 지정된 소스 컴포넌트를 찾을 수 없음: ${dataTransferConfig.sourceComponentId}`);
|
||||
console.log(`🔍 [ButtonPrimary] 현재 화면에서 DataProvider 자동 탐색...`);
|
||||
|
||||
const allProviders = screenContext.getAllDataProviders();
|
||||
|
||||
// 테이블 리스트 우선 탐색
|
||||
for (const [id, provider] of allProviders) {
|
||||
if (provider.componentType === "table-list") {
|
||||
sourceProvider = provider;
|
||||
console.log(`✅ [ButtonPrimary] 테이블 리스트 자동 발견: ${id}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 테이블 리스트가 없으면 첫 번째 DataProvider 사용
|
||||
if (!sourceProvider && allProviders.size > 0) {
|
||||
const firstEntry = allProviders.entries().next().value;
|
||||
if (firstEntry) {
|
||||
sourceProvider = firstEntry[1];
|
||||
console.log(`✅ [ButtonPrimary] 첫 번째 DataProvider 사용: ${firstEntry[0]} (${sourceProvider.componentType})`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!sourceProvider) {
|
||||
toast.error("데이터를 제공할 수 있는 컴포넌트를 찾을 수 없습니다.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const rawSourceData = sourceProvider.getSelectedData();
|
||||
|
||||
// 🆕 배열이 아닌 경우 배열로 변환
|
||||
const sourceData = Array.isArray(rawSourceData) ? rawSourceData : (rawSourceData ? [rawSourceData] : []);
|
||||
|
||||
console.log("📦 소스 데이터:", { rawSourceData, sourceData, isArray: Array.isArray(rawSourceData) });
|
||||
|
||||
if (!sourceData || sourceData.length === 0) {
|
||||
toast.warning("선택된 데이터가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 1.5. 추가 데이터 소스 처리 (예: 조건부 컨테이너의 카테고리 값)
|
||||
let additionalData: Record<string, any> = {};
|
||||
|
||||
// 방법 1: additionalSources 설정에서 가져오기
|
||||
if (dataTransferConfig.additionalSources && Array.isArray(dataTransferConfig.additionalSources)) {
|
||||
for (const additionalSource of dataTransferConfig.additionalSources) {
|
||||
const additionalProvider = screenContext.getDataProvider(additionalSource.componentId);
|
||||
|
||||
if (additionalProvider) {
|
||||
const additionalValues = additionalProvider.getSelectedData();
|
||||
|
||||
if (additionalValues && additionalValues.length > 0) {
|
||||
// 첫 번째 값 사용 (조건부 컨테이너는 항상 1개)
|
||||
const firstValue = additionalValues[0];
|
||||
|
||||
// fieldName이 지정되어 있으면 그 필드만 추출
|
||||
if (additionalSource.fieldName) {
|
||||
additionalData[additionalSource.fieldName] = firstValue[additionalSource.fieldName] || firstValue.condition || firstValue;
|
||||
} else {
|
||||
// fieldName이 없으면 전체 객체 병합
|
||||
additionalData = { ...additionalData, ...firstValue };
|
||||
}
|
||||
|
||||
console.log("📦 추가 데이터 수집 (additionalSources):", {
|
||||
sourceId: additionalSource.componentId,
|
||||
fieldName: additionalSource.fieldName,
|
||||
value: additionalData[additionalSource.fieldName || 'all'],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 방법 2: formData에서 조건부 컨테이너 값 가져오기 (자동)
|
||||
// ConditionalSectionViewer가 __conditionalContainerValue, __conditionalContainerControlField를 formData에 포함시킴
|
||||
if (formData && formData.__conditionalContainerValue) {
|
||||
// includeConditionalValue 설정이 true이거나 설정이 없으면 자동 포함
|
||||
if (dataTransferConfig.includeConditionalValue !== false) {
|
||||
const conditionalValue = formData.__conditionalContainerValue;
|
||||
const conditionalLabel = formData.__conditionalContainerLabel;
|
||||
const controlField = formData.__conditionalContainerControlField; // 🆕 제어 필드명 직접 사용
|
||||
|
||||
// 🆕 controlField가 있으면 그것을 필드명으로 사용 (자동 매핑!)
|
||||
if (controlField) {
|
||||
additionalData[controlField] = conditionalValue;
|
||||
console.log("📦 조건부 컨테이너 값 자동 매핑:", {
|
||||
controlField,
|
||||
value: conditionalValue,
|
||||
label: conditionalLabel,
|
||||
});
|
||||
} else {
|
||||
// controlField가 없으면 기존 방식: formData에서 같은 값을 가진 키 찾기
|
||||
for (const [key, value] of Object.entries(formData)) {
|
||||
if (value === conditionalValue && !key.startsWith('__')) {
|
||||
additionalData[key] = conditionalValue;
|
||||
console.log("📦 조건부 컨테이너 값 자동 포함:", {
|
||||
fieldName: key,
|
||||
value: conditionalValue,
|
||||
label: conditionalLabel,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 못 찾았으면 기본 필드명 사용
|
||||
if (!Object.keys(additionalData).some(k => !k.startsWith('__'))) {
|
||||
additionalData['condition_type'] = conditionalValue;
|
||||
console.log("📦 조건부 컨테이너 값 (기본 필드명):", {
|
||||
fieldName: 'condition_type',
|
||||
value: conditionalValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 검증
|
||||
const validation = dataTransferConfig.validation;
|
||||
if (validation) {
|
||||
if (validation.minSelection && sourceData.length < validation.minSelection) {
|
||||
toast.error(`최소 ${validation.minSelection}개 이상 선택해야 합니다.`);
|
||||
return;
|
||||
}
|
||||
if (validation.maxSelection && sourceData.length > validation.maxSelection) {
|
||||
toast.error(`최대 ${validation.maxSelection}개까지 선택할 수 있습니다.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 확인 메시지
|
||||
if (dataTransferConfig.confirmBeforeTransfer) {
|
||||
const confirmMessage = dataTransferConfig.confirmMessage || `${sourceData.length}개 항목을 전달하시겠습니까?`;
|
||||
if (!window.confirm(confirmMessage)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 매핑 규칙 적용 + 추가 데이터 병합
|
||||
const mappedData = sourceData.map((row) => {
|
||||
const mappedRow = applyMappingRules(row, dataTransferConfig.mappingRules || []);
|
||||
|
||||
// 추가 데이터를 모든 행에 포함
|
||||
return {
|
||||
...mappedRow,
|
||||
...additionalData,
|
||||
};
|
||||
});
|
||||
|
||||
console.log("📦 데이터 전달:", {
|
||||
sourceData,
|
||||
mappedData,
|
||||
targetType: dataTransferConfig.targetType,
|
||||
targetComponentId: dataTransferConfig.targetComponentId,
|
||||
targetScreenId: dataTransferConfig.targetScreenId,
|
||||
});
|
||||
|
||||
// 5. 타겟으로 데이터 전달
|
||||
if (dataTransferConfig.targetType === "component") {
|
||||
// 같은 화면의 컴포넌트로 전달
|
||||
const targetReceiver = screenContext.getDataReceiver(dataTransferConfig.targetComponentId);
|
||||
|
||||
if (!targetReceiver) {
|
||||
toast.error(`타겟 컴포넌트를 찾을 수 없습니다: ${dataTransferConfig.targetComponentId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await targetReceiver.receiveData(mappedData, {
|
||||
targetComponentId: dataTransferConfig.targetComponentId,
|
||||
targetComponentType: targetReceiver.componentType,
|
||||
mode: dataTransferConfig.mode || "append",
|
||||
mappingRules: dataTransferConfig.mappingRules || [],
|
||||
});
|
||||
|
||||
toast.success(`${sourceData.length}개 항목이 전달되었습니다.`);
|
||||
} else if (dataTransferConfig.targetType === "splitPanel") {
|
||||
// 🆕 분할 패널의 반대편 화면으로 전달
|
||||
if (!splitPanelContext) {
|
||||
toast.error("분할 패널 컨텍스트를 찾을 수 없습니다. 이 버튼이 분할 패널 내부에 있는지 확인하세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 🆕 useSplitPanelPosition 훅으로 위치 가져오기 (중첩된 화면에서도 작동)
|
||||
// screenId로 찾는 것은 직접 임베드된 화면에서만 작동하므로,
|
||||
// SplitPanelPositionProvider로 전달된 위치를 우선 사용
|
||||
const currentPosition = splitPanelPosition || (screenId ? splitPanelContext.getPositionByScreenId(screenId) : null);
|
||||
|
||||
if (!currentPosition) {
|
||||
toast.error("분할 패널 내 위치를 확인할 수 없습니다. screenId: " + screenId);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("📦 분할 패널 데이터 전달:", {
|
||||
currentPosition,
|
||||
splitPanelPositionFromHook: splitPanelPosition,
|
||||
screenId,
|
||||
leftScreenId: splitPanelContext.leftScreenId,
|
||||
rightScreenId: splitPanelContext.rightScreenId,
|
||||
});
|
||||
|
||||
const result = await splitPanelContext.transferToOtherSide(
|
||||
currentPosition,
|
||||
mappedData,
|
||||
dataTransferConfig.targetComponentId, // 특정 컴포넌트 지정 (선택사항)
|
||||
dataTransferConfig.mode || "append"
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
} else {
|
||||
toast.error(result.message);
|
||||
return;
|
||||
}
|
||||
} else if (dataTransferConfig.targetType === "screen") {
|
||||
// 다른 화면으로 전달 (구현 예정)
|
||||
toast.info("다른 화면으로의 데이터 전달은 추후 구현 예정입니다.");
|
||||
return;
|
||||
} else {
|
||||
toast.success(`${sourceData.length}개 항목이 전달되었습니다.`);
|
||||
}
|
||||
|
||||
// 6. 전달 후 정리
|
||||
if (dataTransferConfig.clearAfterTransfer) {
|
||||
sourceProvider.clearSelection();
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("❌ 데이터 전달 실패:", error);
|
||||
toast.error(error.message || "데이터 전달 중 오류가 발생했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
|
|
@ -390,6 +657,12 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
|
||||
// 인터랙티브 모드에서 액션 실행
|
||||
if (isInteractive && processedConfig.action) {
|
||||
// transferData 액션 처리 (화면 컨텍스트 필요)
|
||||
if (processedConfig.action.type === "transferData") {
|
||||
await handleTransferDataAction(processedConfig.action);
|
||||
return;
|
||||
}
|
||||
|
||||
// 삭제 액션인데 선택된 데이터가 없으면 경고 메시지 표시하고 중단
|
||||
const hasDataToDelete =
|
||||
(selectedRowsData && selectedRowsData.length > 0) || (flowSelectedData && flowSelectedData.length > 0);
|
||||
|
|
@ -409,11 +682,21 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
// 🆕 디버깅: tableName 확인
|
||||
console.log("🔍 [ButtonPrimaryComponent] context 생성:", {
|
||||
propsTableName: tableName,
|
||||
contextTableName: screenContext?.tableName,
|
||||
effectiveTableName,
|
||||
propsScreenId: screenId,
|
||||
contextScreenId: screenContext?.screenId,
|
||||
effectiveScreenId,
|
||||
});
|
||||
|
||||
const context: ButtonActionContext = {
|
||||
formData: formData || {},
|
||||
originalData: originalData || {}, // 부분 업데이트용 원본 데이터 추가
|
||||
screenId,
|
||||
tableName,
|
||||
screenId: effectiveScreenId, // 🆕 ScreenContext에서 가져온 값 사용
|
||||
tableName: effectiveTableName, // 🆕 ScreenContext에서 가져온 값 사용
|
||||
userId, // 🆕 사용자 ID
|
||||
userName, // 🆕 사용자 이름
|
||||
companyCode, // 🆕 회사 코드
|
||||
|
|
|
|||
|
|
@ -72,5 +72,5 @@ ComponentRegistry.registerComponent({
|
|||
},
|
||||
});
|
||||
|
||||
console.log("✅ 카테고리 관리 컴포넌트 등록 완료");
|
||||
// console.log("✅ 카테고리 관리 컴포넌트 등록 완료");
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import {
|
|||
import { ConditionalContainerProps, ConditionalSection } from "./types";
|
||||
import { ConditionalSectionViewer } from "./ConditionalSectionViewer";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||
import type { DataProvidable } from "@/types/data-transfer";
|
||||
|
||||
/**
|
||||
* 조건부 컨테이너 컴포넌트
|
||||
|
|
@ -42,6 +44,9 @@ export function ConditionalContainerComponent({
|
|||
onSave, // 🆕 EditModal의 handleSave 콜백
|
||||
}: ConditionalContainerProps) {
|
||||
|
||||
// 화면 컨텍스트 (데이터 제공자로 등록)
|
||||
const screenContext = useScreenContextOptional();
|
||||
|
||||
// config prop 우선, 없으면 개별 prop 사용
|
||||
const controlField = config?.controlField || propControlField || "condition";
|
||||
const controlLabel = config?.controlLabel || propControlLabel || "조건 선택";
|
||||
|
|
@ -50,30 +55,86 @@ export function ConditionalContainerComponent({
|
|||
const showBorder = config?.showBorder ?? propShowBorder ?? true;
|
||||
const spacing = config?.spacing || propSpacing || "normal";
|
||||
|
||||
// 초기값 계산 (한 번만)
|
||||
const initialValue = React.useMemo(() => {
|
||||
return value || formData?.[controlField] || defaultValue || "";
|
||||
}, []); // 의존성 없음 - 마운트 시 한 번만 계산
|
||||
|
||||
// 현재 선택된 값
|
||||
const [selectedValue, setSelectedValue] = useState<string>(
|
||||
value || formData?.[controlField] || defaultValue || ""
|
||||
);
|
||||
const [selectedValue, setSelectedValue] = useState<string>(initialValue);
|
||||
|
||||
// 최신 값을 ref로 유지 (클로저 문제 방지)
|
||||
const selectedValueRef = React.useRef(selectedValue);
|
||||
selectedValueRef.current = selectedValue; // 렌더링마다 업데이트 (useEffect 대신)
|
||||
|
||||
// formData 변경 시 동기화
|
||||
useEffect(() => {
|
||||
if (formData?.[controlField]) {
|
||||
setSelectedValue(formData[controlField]);
|
||||
}
|
||||
}, [formData, controlField]);
|
||||
|
||||
// 값 변경 핸들러
|
||||
const handleValueChange = (newValue: string) => {
|
||||
// 콜백 refs (의존성 제거)
|
||||
const onChangeRef = React.useRef(onChange);
|
||||
const onFormDataChangeRef = React.useRef(onFormDataChange);
|
||||
onChangeRef.current = onChange;
|
||||
onFormDataChangeRef.current = onFormDataChange;
|
||||
|
||||
// 값 변경 핸들러 - 의존성 없음
|
||||
const handleValueChange = React.useCallback((newValue: string) => {
|
||||
// 같은 값이면 무시
|
||||
if (newValue === selectedValueRef.current) return;
|
||||
|
||||
setSelectedValue(newValue);
|
||||
|
||||
if (onChange) {
|
||||
onChange(newValue);
|
||||
if (onChangeRef.current) {
|
||||
onChangeRef.current(newValue);
|
||||
}
|
||||
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange(controlField, newValue);
|
||||
if (onFormDataChangeRef.current) {
|
||||
onFormDataChangeRef.current(controlField, newValue);
|
||||
}
|
||||
};
|
||||
}, [controlField]);
|
||||
|
||||
// sectionsRef 추가 (dataProvider에서 사용)
|
||||
const sectionsRef = React.useRef(sections);
|
||||
React.useEffect(() => {
|
||||
sectionsRef.current = sections;
|
||||
}, [sections]);
|
||||
|
||||
// dataProvider를 useMemo로 감싸서 불필요한 재생성 방지
|
||||
const dataProvider = React.useMemo<DataProvidable>(() => ({
|
||||
componentId: componentId || "conditional-container",
|
||||
componentType: "conditional-container",
|
||||
|
||||
getSelectedData: () => {
|
||||
// ref를 통해 최신 값 참조 (클로저 문제 방지)
|
||||
const currentValue = selectedValueRef.current;
|
||||
const currentSections = sectionsRef.current;
|
||||
return [{
|
||||
[controlField]: currentValue,
|
||||
condition: currentValue,
|
||||
label: currentSections.find(s => s.condition === currentValue)?.label || currentValue,
|
||||
}];
|
||||
},
|
||||
|
||||
getAllData: () => {
|
||||
const currentSections = sectionsRef.current;
|
||||
return currentSections.map(section => ({
|
||||
condition: section.condition,
|
||||
label: section.label,
|
||||
}));
|
||||
},
|
||||
|
||||
clearSelection: () => {
|
||||
// 조건부 컨테이너는 초기화하지 않음
|
||||
console.log("조건부 컨테이너는 선택 초기화를 지원하지 않습니다.");
|
||||
},
|
||||
}), [componentId, controlField]); // selectedValue, sections는 ref로 참조
|
||||
|
||||
// 화면 컨텍스트에 데이터 제공자로 등록
|
||||
useEffect(() => {
|
||||
if (screenContext && componentId) {
|
||||
screenContext.registerDataProvider(componentId, dataProvider);
|
||||
|
||||
return () => {
|
||||
screenContext.unregisterDataProvider(componentId);
|
||||
};
|
||||
}
|
||||
}, [screenContext, componentId, dataProvider]);
|
||||
|
||||
// 컨테이너 높이 측정용 ref
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -158,6 +219,8 @@ export function ConditionalContainerComponent({
|
|||
onFormDataChange={onFormDataChange}
|
||||
groupedData={groupedData}
|
||||
onSave={onSave}
|
||||
controlField={controlField}
|
||||
selectedCondition={selectedValue}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -179,6 +242,8 @@ export function ConditionalContainerComponent({
|
|||
onFormDataChange={onFormDataChange}
|
||||
groupedData={groupedData}
|
||||
onSave={onSave}
|
||||
controlField={controlField}
|
||||
selectedCondition={selectedValue}
|
||||
/>
|
||||
) : null
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,19 +12,38 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Plus, Trash2, GripVertical, Loader2 } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Plus, Trash2, GripVertical, Loader2, Check, ChevronsUpDown, Database } from "lucide-react";
|
||||
import { ConditionalContainerConfig, ConditionalSection } from "./types";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getCategoryColumnsByMenu, getCategoryValues, getSecondLevelMenus } from "@/lib/api/tableCategoryValue";
|
||||
|
||||
interface ConditionalContainerConfigPanelProps {
|
||||
config: ConditionalContainerConfig;
|
||||
onConfigChange: (config: ConditionalContainerConfig) => void;
|
||||
onChange?: (config: ConditionalContainerConfig) => void;
|
||||
onConfigChange?: (config: ConditionalContainerConfig) => void;
|
||||
}
|
||||
|
||||
export function ConditionalContainerConfigPanel({
|
||||
config,
|
||||
onChange,
|
||||
onConfigChange,
|
||||
}: ConditionalContainerConfigPanelProps) {
|
||||
// onChange 또는 onConfigChange 둘 다 지원
|
||||
const handleConfigChange = onChange || onConfigChange;
|
||||
const [localConfig, setLocalConfig] = useState<ConditionalContainerConfig>({
|
||||
controlField: config.controlField || "condition",
|
||||
controlLabel: config.controlLabel || "조건 선택",
|
||||
|
|
@ -38,6 +57,21 @@ export function ConditionalContainerConfigPanel({
|
|||
const [screens, setScreens] = useState<any[]>([]);
|
||||
const [screensLoading, setScreensLoading] = useState(false);
|
||||
|
||||
// 🆕 메뉴 기반 카테고리 관련 상태
|
||||
const [availableMenus, setAvailableMenus] = useState<Array<{ menuObjid: number; menuName: string; parentMenuName: string; screenCode?: string }>>([]);
|
||||
const [menusLoading, setMenusLoading] = useState(false);
|
||||
const [selectedMenuObjid, setSelectedMenuObjid] = useState<number | null>(null);
|
||||
const [menuPopoverOpen, setMenuPopoverOpen] = useState(false);
|
||||
|
||||
const [categoryColumns, setCategoryColumns] = useState<Array<{ columnName: string; columnLabel: string; tableName: string }>>([]);
|
||||
const [categoryColumnsLoading, setCategoryColumnsLoading] = useState(false);
|
||||
const [selectedCategoryColumn, setSelectedCategoryColumn] = useState<string>("");
|
||||
const [selectedCategoryTableName, setSelectedCategoryTableName] = useState<string>("");
|
||||
const [columnPopoverOpen, setColumnPopoverOpen] = useState(false);
|
||||
|
||||
const [categoryValues, setCategoryValues] = useState<Array<{ value: string; label: string }>>([]);
|
||||
const [categoryValuesLoading, setCategoryValuesLoading] = useState(false);
|
||||
|
||||
// 화면 목록 로드
|
||||
useEffect(() => {
|
||||
const loadScreens = async () => {
|
||||
|
|
@ -56,11 +90,122 @@ export function ConditionalContainerConfigPanel({
|
|||
loadScreens();
|
||||
}, []);
|
||||
|
||||
// 🆕 2레벨 메뉴 목록 로드
|
||||
useEffect(() => {
|
||||
const loadMenus = async () => {
|
||||
setMenusLoading(true);
|
||||
try {
|
||||
const response = await getSecondLevelMenus();
|
||||
console.log("🔍 [ConditionalContainer] 메뉴 목록 응답:", response);
|
||||
if (response.success && response.data) {
|
||||
setAvailableMenus(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("메뉴 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setMenusLoading(false);
|
||||
}
|
||||
};
|
||||
loadMenus();
|
||||
}, []);
|
||||
|
||||
// 🆕 선택된 메뉴의 카테고리 컬럼 목록 로드
|
||||
useEffect(() => {
|
||||
if (!selectedMenuObjid) {
|
||||
setCategoryColumns([]);
|
||||
setSelectedCategoryColumn("");
|
||||
setSelectedCategoryTableName("");
|
||||
setCategoryValues([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadCategoryColumns = async () => {
|
||||
setCategoryColumnsLoading(true);
|
||||
try {
|
||||
console.log("🔍 [ConditionalContainer] 메뉴별 카테고리 컬럼 로드:", selectedMenuObjid);
|
||||
const response = await getCategoryColumnsByMenu(selectedMenuObjid);
|
||||
console.log("✅ [ConditionalContainer] 카테고리 컬럼 응답:", response);
|
||||
|
||||
if (response.success && response.data) {
|
||||
setCategoryColumns(response.data.map((col: any) => ({
|
||||
columnName: col.columnName || col.column_name,
|
||||
columnLabel: col.columnLabel || col.column_label || col.columnName || col.column_name,
|
||||
tableName: col.tableName || col.table_name,
|
||||
})));
|
||||
} else {
|
||||
setCategoryColumns([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 컬럼 로드 실패:", error);
|
||||
setCategoryColumns([]);
|
||||
} finally {
|
||||
setCategoryColumnsLoading(false);
|
||||
}
|
||||
};
|
||||
loadCategoryColumns();
|
||||
}, [selectedMenuObjid]);
|
||||
|
||||
// 🆕 선택된 카테고리 컬럼의 값 목록 로드
|
||||
useEffect(() => {
|
||||
if (!selectedCategoryTableName || !selectedCategoryColumn || !selectedMenuObjid) {
|
||||
setCategoryValues([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadCategoryValues = async () => {
|
||||
setCategoryValuesLoading(true);
|
||||
try {
|
||||
console.log("🔍 [ConditionalContainer] 카테고리 값 로드:", selectedCategoryTableName, selectedCategoryColumn, selectedMenuObjid);
|
||||
const response = await getCategoryValues(selectedCategoryTableName, selectedCategoryColumn, false, selectedMenuObjid);
|
||||
console.log("✅ [ConditionalContainer] 카테고리 값 응답:", response);
|
||||
|
||||
if (response.success && response.data) {
|
||||
const values = response.data.map((v: any) => ({
|
||||
value: v.valueCode || v.value_code,
|
||||
label: v.valueLabel || v.value_label || v.valueCode || v.value_code,
|
||||
}));
|
||||
setCategoryValues(values);
|
||||
} else {
|
||||
setCategoryValues([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("카테고리 값 로드 실패:", error);
|
||||
setCategoryValues([]);
|
||||
} finally {
|
||||
setCategoryValuesLoading(false);
|
||||
}
|
||||
};
|
||||
loadCategoryValues();
|
||||
}, [selectedCategoryTableName, selectedCategoryColumn, selectedMenuObjid]);
|
||||
|
||||
// 🆕 테이블 카테고리에서 섹션 자동 생성
|
||||
const generateSectionsFromCategory = () => {
|
||||
if (categoryValues.length === 0) {
|
||||
alert("먼저 테이블과 카테고리 컬럼을 선택하고 값을 로드해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const newSections: ConditionalSection[] = categoryValues.map((option, index) => ({
|
||||
id: `section_${Date.now()}_${index}`,
|
||||
condition: option.value,
|
||||
label: option.label,
|
||||
screenId: null,
|
||||
screenName: undefined,
|
||||
}));
|
||||
|
||||
updateConfig({
|
||||
sections: newSections,
|
||||
controlField: selectedCategoryColumn, // 카테고리 컬럼명을 제어 필드로 사용
|
||||
});
|
||||
|
||||
alert(`${newSections.length}개의 섹션이 생성되었습니다.`);
|
||||
};
|
||||
|
||||
// 설정 업데이트 헬퍼
|
||||
const updateConfig = (updates: Partial<ConditionalContainerConfig>) => {
|
||||
const newConfig = { ...localConfig, ...updates };
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
handleConfigChange?.(newConfig);
|
||||
};
|
||||
|
||||
// 새 섹션 추가
|
||||
|
|
@ -134,6 +279,207 @@ export function ConditionalContainerConfigPanel({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 🆕 메뉴별 카테고리에서 섹션 자동 생성 */}
|
||||
<div className="space-y-3 p-3 border rounded-lg bg-blue-50/50 dark:bg-blue-950/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-blue-600" />
|
||||
<Label className="text-xs font-semibold text-blue-700 dark:text-blue-400">
|
||||
메뉴 카테고리에서 자동 생성
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* 1. 메뉴 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">
|
||||
1. 메뉴 선택
|
||||
</Label>
|
||||
<Popover open={menuPopoverOpen} onOpenChange={setMenuPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={menuPopoverOpen}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
disabled={menusLoading}
|
||||
>
|
||||
{menusLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
|
||||
로딩 중...
|
||||
</>
|
||||
) : selectedMenuObjid ? (
|
||||
(() => {
|
||||
const menu = availableMenus.find((m) => m.menuObjid === selectedMenuObjid);
|
||||
return menu ? `${menu.parentMenuName} > ${menu.menuName}` : `메뉴 ${selectedMenuObjid}`;
|
||||
})()
|
||||
) : (
|
||||
"메뉴 선택..."
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[350px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="메뉴 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-xs">메뉴를 찾을 수 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableMenus.map((menu) => (
|
||||
<CommandItem
|
||||
key={menu.menuObjid}
|
||||
value={`${menu.parentMenuName} ${menu.menuName}`}
|
||||
onSelect={() => {
|
||||
setSelectedMenuObjid(menu.menuObjid);
|
||||
setSelectedCategoryColumn("");
|
||||
setSelectedCategoryTableName("");
|
||||
setMenuPopoverOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
selectedMenuObjid === menu.menuObjid ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span>{menu.parentMenuName} > {menu.menuName}</span>
|
||||
{menu.screenCode && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{menu.screenCode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 2. 카테고리 컬럼 선택 */}
|
||||
{selectedMenuObjid && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">
|
||||
2. 카테고리 컬럼 선택
|
||||
</Label>
|
||||
{categoryColumnsLoading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground h-8 px-3 border rounded">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
로딩 중...
|
||||
</div>
|
||||
) : categoryColumns.length > 0 ? (
|
||||
<Popover open={columnPopoverOpen} onOpenChange={setColumnPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={columnPopoverOpen}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
>
|
||||
{selectedCategoryColumn ? (
|
||||
categoryColumns.find((c) => c.columnName === selectedCategoryColumn)?.columnLabel || selectedCategoryColumn
|
||||
) : (
|
||||
"카테고리 컬럼 선택..."
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[280px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="py-2 text-xs">카테고리 컬럼이 없습니다</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{categoryColumns.map((col) => (
|
||||
<CommandItem
|
||||
key={`${col.tableName}.${col.columnName}`}
|
||||
value={col.columnName}
|
||||
onSelect={() => {
|
||||
setSelectedCategoryColumn(col.columnName);
|
||||
setSelectedCategoryTableName(col.tableName);
|
||||
setColumnPopoverOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-3 w-3",
|
||||
selectedCategoryColumn === col.columnName ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span>{col.columnLabel}</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{col.tableName}.{col.columnName}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<p className="text-[10px] text-amber-600 dark:text-amber-400">
|
||||
이 메뉴에 설정된 카테고리 컬럼이 없습니다.
|
||||
카테고리 관리에서 먼저 설정해주세요.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 3. 카테고리 값 미리보기 */}
|
||||
{selectedCategoryColumn && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-[10px] text-muted-foreground">
|
||||
3. 카테고리 값 미리보기
|
||||
</Label>
|
||||
{categoryValuesLoading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
로딩 중...
|
||||
</div>
|
||||
) : categoryValues.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{categoryValues.map((option) => (
|
||||
<span
|
||||
key={option.value}
|
||||
className="px-2 py-0.5 text-[10px] bg-blue-100 text-blue-800 rounded dark:bg-blue-900 dark:text-blue-200"
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[10px] text-amber-600 dark:text-amber-400">
|
||||
이 컬럼에 등록된 카테고리 값이 없습니다.
|
||||
카테고리 관리에서 값을 먼저 등록해주세요.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={generateSectionsFromCategory}
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="h-7 w-full text-xs"
|
||||
disabled={!selectedCategoryColumn || categoryValues.length === 0 || categoryValuesLoading}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
{categoryValues.length > 0 ? `${categoryValues.length}개 섹션 자동 생성` : "섹션 자동 생성"}
|
||||
</Button>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
선택한 메뉴의 카테고리 값들로 조건별 섹션을 자동으로 생성합니다.
|
||||
각 섹션에 표시할 화면은 아래에서 개별 설정하세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 조건별 섹션 설정 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ export function ConditionalSectionViewer({
|
|||
onFormDataChange,
|
||||
groupedData, // 🆕 그룹 데이터
|
||||
onSave, // 🆕 EditModal의 handleSave 콜백
|
||||
controlField, // 🆕 조건부 컨테이너의 제어 필드명
|
||||
selectedCondition, // 🆕 현재 선택된 조건 값
|
||||
}: ConditionalSectionViewerProps) {
|
||||
const { userId, userName, user } = useAuth();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
|
@ -34,6 +36,24 @@ export function ConditionalSectionViewer({
|
|||
const [screenInfo, setScreenInfo] = useState<{ id: number; tableName?: string } | null>(null);
|
||||
const [screenResolution, setScreenResolution] = useState<{ width: number; height: number } | null>(null);
|
||||
|
||||
// 🆕 조건 값을 포함한 formData 생성
|
||||
const enhancedFormData = React.useMemo(() => {
|
||||
const base = formData || {};
|
||||
|
||||
// 조건부 컨테이너의 현재 선택 값을 formData에 포함
|
||||
if (controlField && selectedCondition) {
|
||||
return {
|
||||
...base,
|
||||
[controlField]: selectedCondition,
|
||||
__conditionalContainerValue: selectedCondition,
|
||||
__conditionalContainerLabel: label,
|
||||
__conditionalContainerControlField: controlField, // 🆕 제어 필드명도 포함
|
||||
};
|
||||
}
|
||||
|
||||
return base;
|
||||
}, [formData, controlField, selectedCondition, label]);
|
||||
|
||||
// 화면 로드
|
||||
useEffect(() => {
|
||||
if (!screenId) {
|
||||
|
|
@ -154,18 +174,18 @@ export function ConditionalSectionViewer({
|
|||
}}
|
||||
>
|
||||
<DynamicComponentRenderer
|
||||
component={component}
|
||||
component={component}
|
||||
isInteractive={true}
|
||||
screenId={screenInfo?.id}
|
||||
tableName={screenInfo?.tableName}
|
||||
userId={userId}
|
||||
userName={userName}
|
||||
companyCode={user?.companyCode}
|
||||
formData={formData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
screenId={screenInfo?.id}
|
||||
tableName={screenInfo?.tableName}
|
||||
userId={userId}
|
||||
userName={userName}
|
||||
companyCode={user?.companyCode}
|
||||
formData={enhancedFormData}
|
||||
onFormDataChange={onFormDataChange}
|
||||
groupedData={groupedData}
|
||||
onSave={onSave}
|
||||
/>
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -79,5 +79,8 @@ export interface ConditionalSectionViewerProps {
|
|||
onFormDataChange?: (fieldName: string, value: any) => void;
|
||||
groupedData?: Record<string, any>[]; // 🆕 그룹 데이터
|
||||
onSave?: () => Promise<void>; // 🆕 EditModal의 handleSave 콜백
|
||||
// 🆕 조건부 컨테이너 정보 (자식 화면에 전달)
|
||||
controlField?: string; // 제어 필드명 (예: "inbound_type")
|
||||
selectedCondition?: string; // 현재 선택된 조건 값 (예: "PURCHASE_IN")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,5 +6,5 @@ import { CustomerItemMappingDefinition } from "./index";
|
|||
// 컴포넌트 자동 등록
|
||||
ComponentRegistry.registerComponent(CustomerItemMappingDefinition);
|
||||
|
||||
console.log("✅ CustomerItemMapping 컴포넌트 등록 완료");
|
||||
// console.log("✅ CustomerItemMapping 컴포넌트 등록 완료");
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ import "./customer-item-mapping/CustomerItemMappingRenderer"; // 🆕 거래처
|
|||
import "./autocomplete-search-input/AutocompleteSearchInputRenderer";
|
||||
import "./entity-search-input/EntitySearchInputRenderer";
|
||||
import "./modal-repeater-table/ModalRepeaterTableRenderer";
|
||||
import "./simple-repeater-table/SimpleRepeaterTableRenderer"; // 🆕 단순 반복 테이블
|
||||
import "./repeat-screen-modal/RepeatScreenModalRenderer"; // 🆕 반복 화면 모달 (카드 형태)
|
||||
import "./order-registration-modal/OrderRegistrationModalRenderer";
|
||||
|
||||
// 🆕 조건부 컨테이너 컴포넌트
|
||||
|
|
@ -62,6 +64,15 @@ import "./section-card/SectionCardRenderer"; // Section Card (제목+테두리
|
|||
// 🆕 탭 컴포넌트
|
||||
import "./tabs/tabs-component"; // 탭 기반 화면 전환 컴포넌트
|
||||
|
||||
// 🆕 반복 화면 모달 컴포넌트
|
||||
import "./repeat-screen-modal/RepeatScreenModalRenderer";
|
||||
|
||||
// 🆕 출발지/도착지 선택 컴포넌트
|
||||
import "./location-swap-selector/LocationSwapSelectorRenderer";
|
||||
|
||||
// 🆕 화면 임베딩 및 분할 패널 컴포넌트
|
||||
import "./screen-split-panel/ScreenSplitPanelRenderer"; // 화면 분할 패널 (좌우 화면 임베딩 + 데이터 전달)
|
||||
|
||||
/**
|
||||
* 컴포넌트 초기화 함수
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,432 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { ArrowLeftRight, ChevronDown } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
interface LocationOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface DataSourceConfig {
|
||||
type: "table" | "code" | "static";
|
||||
tableName?: string;
|
||||
valueField?: string;
|
||||
labelField?: string;
|
||||
codeCategory?: string;
|
||||
staticOptions?: LocationOption[];
|
||||
}
|
||||
|
||||
export interface LocationSwapSelectorProps {
|
||||
// 기본 props
|
||||
id?: string;
|
||||
style?: React.CSSProperties;
|
||||
isDesignMode?: boolean;
|
||||
|
||||
// 데이터 소스 설정
|
||||
dataSource?: DataSourceConfig;
|
||||
|
||||
// 필드 매핑
|
||||
departureField?: string;
|
||||
destinationField?: string;
|
||||
departureLabelField?: string;
|
||||
destinationLabelField?: string;
|
||||
|
||||
// UI 설정
|
||||
departureLabel?: string;
|
||||
destinationLabel?: string;
|
||||
showSwapButton?: boolean;
|
||||
swapButtonPosition?: "center" | "right";
|
||||
variant?: "card" | "inline" | "minimal";
|
||||
|
||||
// 폼 데이터
|
||||
formData?: Record<string, any>;
|
||||
onFormDataChange?: (field: string, value: any) => void;
|
||||
|
||||
// componentConfig (화면 디자이너에서 전달)
|
||||
componentConfig?: {
|
||||
dataSource?: DataSourceConfig;
|
||||
departureField?: string;
|
||||
destinationField?: string;
|
||||
departureLabelField?: string;
|
||||
destinationLabelField?: string;
|
||||
departureLabel?: string;
|
||||
destinationLabel?: string;
|
||||
showSwapButton?: boolean;
|
||||
swapButtonPosition?: "center" | "right";
|
||||
variant?: "card" | "inline" | "minimal";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* LocationSwapSelector 컴포넌트
|
||||
* 출발지/도착지 선택 및 교환 기능
|
||||
*/
|
||||
export function LocationSwapSelectorComponent(props: LocationSwapSelectorProps) {
|
||||
const {
|
||||
id,
|
||||
style,
|
||||
isDesignMode = false,
|
||||
formData = {},
|
||||
onFormDataChange,
|
||||
componentConfig,
|
||||
} = props;
|
||||
|
||||
// componentConfig에서 설정 가져오기 (우선순위: componentConfig > props)
|
||||
const config = componentConfig || {};
|
||||
const dataSource = config.dataSource || props.dataSource || { type: "static", staticOptions: [] };
|
||||
const departureField = config.departureField || props.departureField || "departure";
|
||||
const destinationField = config.destinationField || props.destinationField || "destination";
|
||||
const departureLabelField = config.departureLabelField || props.departureLabelField;
|
||||
const destinationLabelField = config.destinationLabelField || props.destinationLabelField;
|
||||
const departureLabel = config.departureLabel || props.departureLabel || "출발지";
|
||||
const destinationLabel = config.destinationLabel || props.destinationLabel || "도착지";
|
||||
const showSwapButton = config.showSwapButton !== false && props.showSwapButton !== false;
|
||||
const variant = config.variant || props.variant || "card";
|
||||
|
||||
// 상태
|
||||
const [options, setOptions] = useState<LocationOption[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isSwapping, setIsSwapping] = useState(false);
|
||||
|
||||
// 현재 선택된 값
|
||||
const departureValue = formData[departureField] || "";
|
||||
const destinationValue = formData[destinationField] || "";
|
||||
|
||||
// 옵션 로드
|
||||
useEffect(() => {
|
||||
const loadOptions = async () => {
|
||||
if (dataSource.type === "static") {
|
||||
setOptions(dataSource.staticOptions || []);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dataSource.type === "code" && dataSource.codeCategory) {
|
||||
// 코드 관리에서 가져오기
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.get(`/api/codes/${dataSource.codeCategory}`);
|
||||
if (response.data.success && response.data.data) {
|
||||
const codeOptions = response.data.data.map((code: any) => ({
|
||||
value: code.code_value || code.codeValue,
|
||||
label: code.code_name || code.codeName,
|
||||
}));
|
||||
setOptions(codeOptions);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("코드 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (dataSource.type === "table" && dataSource.tableName) {
|
||||
// 테이블에서 가져오기
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await apiClient.get(`/api/dynamic/${dataSource.tableName}`, {
|
||||
params: { pageSize: 1000 },
|
||||
});
|
||||
if (response.data.success && response.data.data) {
|
||||
const tableOptions = response.data.data.map((row: any) => ({
|
||||
value: row[dataSource.valueField || "id"],
|
||||
label: row[dataSource.labelField || "name"],
|
||||
}));
|
||||
setOptions(tableOptions);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 데이터 로드 실패:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!isDesignMode) {
|
||||
loadOptions();
|
||||
} else {
|
||||
// 디자인 모드에서는 샘플 데이터
|
||||
setOptions([
|
||||
{ value: "seoul", label: "서울" },
|
||||
{ value: "busan", label: "부산" },
|
||||
{ value: "pohang", label: "포항" },
|
||||
{ value: "gwangyang", label: "광양" },
|
||||
]);
|
||||
}
|
||||
}, [dataSource, isDesignMode]);
|
||||
|
||||
// 출발지 변경
|
||||
const handleDepartureChange = (value: string) => {
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange(departureField, value);
|
||||
// 라벨 필드도 업데이트
|
||||
if (departureLabelField) {
|
||||
const selectedOption = options.find((opt) => opt.value === value);
|
||||
onFormDataChange(departureLabelField, selectedOption?.label || "");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 도착지 변경
|
||||
const handleDestinationChange = (value: string) => {
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange(destinationField, value);
|
||||
// 라벨 필드도 업데이트
|
||||
if (destinationLabelField) {
|
||||
const selectedOption = options.find((opt) => opt.value === value);
|
||||
onFormDataChange(destinationLabelField, selectedOption?.label || "");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 출발지/도착지 교환
|
||||
const handleSwap = () => {
|
||||
if (!onFormDataChange) return;
|
||||
|
||||
setIsSwapping(true);
|
||||
|
||||
// 값 교환
|
||||
const tempDeparture = departureValue;
|
||||
const tempDestination = destinationValue;
|
||||
|
||||
onFormDataChange(departureField, tempDestination);
|
||||
onFormDataChange(destinationField, tempDeparture);
|
||||
|
||||
// 라벨도 교환
|
||||
if (departureLabelField && destinationLabelField) {
|
||||
const tempDepartureLabel = formData[departureLabelField];
|
||||
const tempDestinationLabel = formData[destinationLabelField];
|
||||
onFormDataChange(departureLabelField, tempDestinationLabel);
|
||||
onFormDataChange(destinationLabelField, tempDepartureLabel);
|
||||
}
|
||||
|
||||
// 애니메이션 효과
|
||||
setTimeout(() => setIsSwapping(false), 300);
|
||||
};
|
||||
|
||||
// 선택된 라벨 가져오기
|
||||
const getDepartureLabel = () => {
|
||||
const option = options.find((opt) => opt.value === departureValue);
|
||||
return option?.label || "선택";
|
||||
};
|
||||
|
||||
const getDestinationLabel = () => {
|
||||
const option = options.find((opt) => opt.value === destinationValue);
|
||||
return option?.label || "선택";
|
||||
};
|
||||
|
||||
// 스타일에서 width, height 추출
|
||||
const { width, height, ...restStyle } = style || {};
|
||||
|
||||
// Card 스타일 (이미지 참고)
|
||||
if (variant === "card") {
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="h-full w-full"
|
||||
style={restStyle}
|
||||
>
|
||||
<div className="flex items-center justify-between rounded-lg border bg-card p-4 shadow-sm">
|
||||
{/* 출발지 */}
|
||||
<div className="flex flex-1 flex-col items-center">
|
||||
<span className="mb-1 text-xs text-muted-foreground">{departureLabel}</span>
|
||||
<Select
|
||||
value={departureValue}
|
||||
onValueChange={handleDepartureChange}
|
||||
disabled={loading || isDesignMode}
|
||||
>
|
||||
<SelectTrigger className="h-auto w-full max-w-[120px] border-0 bg-transparent p-0 text-center text-lg font-bold shadow-none focus:ring-0">
|
||||
<SelectValue placeholder="선택">
|
||||
<span className={cn(isSwapping && "animate-pulse")}>
|
||||
{getDepartureLabel()}
|
||||
</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 교환 버튼 */}
|
||||
{showSwapButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleSwap}
|
||||
disabled={isDesignMode || !departureValue || !destinationValue}
|
||||
className={cn(
|
||||
"mx-2 h-10 w-10 rounded-full border bg-background transition-transform hover:bg-muted",
|
||||
isSwapping && "rotate-180"
|
||||
)}
|
||||
>
|
||||
<ArrowLeftRight className="h-5 w-5 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 도착지 */}
|
||||
<div className="flex flex-1 flex-col items-center">
|
||||
<span className="mb-1 text-xs text-muted-foreground">{destinationLabel}</span>
|
||||
<Select
|
||||
value={destinationValue}
|
||||
onValueChange={handleDestinationChange}
|
||||
disabled={loading || isDesignMode}
|
||||
>
|
||||
<SelectTrigger className="h-auto w-full max-w-[120px] border-0 bg-transparent p-0 text-center text-lg font-bold shadow-none focus:ring-0">
|
||||
<SelectValue placeholder="선택">
|
||||
<span className={cn(isSwapping && "animate-pulse")}>
|
||||
{getDestinationLabel()}
|
||||
</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Inline 스타일
|
||||
if (variant === "inline") {
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="flex h-full w-full items-center gap-2"
|
||||
style={restStyle}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-xs text-muted-foreground">{departureLabel}</label>
|
||||
<Select
|
||||
value={departureValue}
|
||||
onValueChange={handleDepartureChange}
|
||||
disabled={loading || isDesignMode}
|
||||
>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{showSwapButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleSwap}
|
||||
disabled={isDesignMode}
|
||||
className="mt-5 h-10 w-10"
|
||||
>
|
||||
<ArrowLeftRight className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-xs text-muted-foreground">{destinationLabel}</label>
|
||||
<Select
|
||||
value={destinationValue}
|
||||
onValueChange={handleDestinationChange}
|
||||
disabled={loading || isDesignMode}
|
||||
>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Minimal 스타일
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className="flex h-full w-full items-center gap-1"
|
||||
style={restStyle}
|
||||
>
|
||||
<Select
|
||||
value={departureValue}
|
||||
onValueChange={handleDepartureChange}
|
||||
disabled={loading || isDesignMode}
|
||||
>
|
||||
<SelectTrigger className="h-8 flex-1 text-sm">
|
||||
<SelectValue placeholder={departureLabel} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{showSwapButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleSwap}
|
||||
disabled={isDesignMode}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ArrowLeftRight className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Select
|
||||
value={destinationValue}
|
||||
onValueChange={handleDestinationChange}
|
||||
disabled={loading || isDesignMode}
|
||||
>
|
||||
<SelectTrigger className="h-8 flex-1 text-sm">
|
||||
<SelectValue placeholder={destinationLabel} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,415 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
interface LocationSwapSelectorConfigPanelProps {
|
||||
config: any;
|
||||
onChange: (config: any) => void;
|
||||
tableColumns?: Array<{ columnName: string; columnLabel?: string; dataType?: string }>;
|
||||
screenTableName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* LocationSwapSelector 설정 패널
|
||||
*/
|
||||
export function LocationSwapSelectorConfigPanel({
|
||||
config,
|
||||
onChange,
|
||||
tableColumns = [],
|
||||
screenTableName,
|
||||
}: LocationSwapSelectorConfigPanelProps) {
|
||||
const [tables, setTables] = useState<Array<{ name: string; label: string }>>([]);
|
||||
const [columns, setColumns] = useState<Array<{ name: string; label: string }>>([]);
|
||||
const [codeCategories, setCodeCategories] = useState<Array<{ value: string; label: string }>>([]);
|
||||
|
||||
// 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
const loadTables = async () => {
|
||||
try {
|
||||
const response = await apiClient.get("/table-management/tables");
|
||||
if (response.data.success && response.data.data) {
|
||||
setTables(
|
||||
response.data.data.map((t: any) => ({
|
||||
name: t.tableName || t.table_name,
|
||||
label: t.displayName || t.tableLabel || t.table_label || t.tableName || t.table_name,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
loadTables();
|
||||
}, []);
|
||||
|
||||
// 선택된 테이블의 컬럼 로드
|
||||
useEffect(() => {
|
||||
const loadColumns = async () => {
|
||||
const tableName = config?.dataSource?.tableName;
|
||||
if (!tableName) {
|
||||
setColumns([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
||||
if (response.data.success) {
|
||||
// API 응답 구조 처리: data가 배열이거나 data.columns가 배열일 수 있음
|
||||
let columnData = response.data.data;
|
||||
if (!Array.isArray(columnData) && columnData?.columns) {
|
||||
columnData = columnData.columns;
|
||||
}
|
||||
|
||||
if (Array.isArray(columnData)) {
|
||||
setColumns(
|
||||
columnData.map((c: any) => ({
|
||||
name: c.columnName || c.column_name || c.name,
|
||||
label: c.displayName || c.columnLabel || c.column_label || c.columnName || c.column_name || c.name,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("컬럼 목록 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (config?.dataSource?.type === "table") {
|
||||
loadColumns();
|
||||
}
|
||||
}, [config?.dataSource?.tableName, config?.dataSource?.type]);
|
||||
|
||||
// 코드 카테고리 로드
|
||||
useEffect(() => {
|
||||
const loadCodeCategories = async () => {
|
||||
try {
|
||||
const response = await apiClient.get("/code-management/categories");
|
||||
if (response.data.success && response.data.data) {
|
||||
setCodeCategories(
|
||||
response.data.data.map((c: any) => ({
|
||||
value: c.category_code || c.categoryCode || c.code,
|
||||
label: c.category_name || c.categoryName || c.name,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("코드 카테고리 로드 실패:", error);
|
||||
}
|
||||
};
|
||||
loadCodeCategories();
|
||||
}, []);
|
||||
|
||||
const handleChange = (path: string, value: any) => {
|
||||
const keys = path.split(".");
|
||||
const newConfig = { ...config };
|
||||
let current: any = newConfig;
|
||||
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
if (!current[keys[i]]) {
|
||||
current[keys[i]] = {};
|
||||
}
|
||||
current = current[keys[i]];
|
||||
}
|
||||
|
||||
current[keys[keys.length - 1]] = value;
|
||||
onChange(newConfig);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 데이터 소스 타입 */}
|
||||
<div className="space-y-2">
|
||||
<Label>데이터 소스 타입</Label>
|
||||
<Select
|
||||
value={config?.dataSource?.type || "static"}
|
||||
onValueChange={(value) => handleChange("dataSource.type", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="static">정적 옵션 (하드코딩)</SelectItem>
|
||||
<SelectItem value="table">테이블</SelectItem>
|
||||
<SelectItem value="code">코드 관리</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 테이블 선택 (type이 table일 때) */}
|
||||
{config?.dataSource?.type === "table" && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>테이블</Label>
|
||||
<Select
|
||||
value={config?.dataSource?.tableName || ""}
|
||||
onValueChange={(value) => handleChange("dataSource.tableName", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="테이블 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tables.map((table) => (
|
||||
<SelectItem key={table.name} value={table.name}>
|
||||
{table.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label>값 필드</Label>
|
||||
<Select
|
||||
value={config?.dataSource?.valueField || ""}
|
||||
onValueChange={(value) => handleChange("dataSource.valueField", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.name} value={col.name}>
|
||||
{col.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>표시 필드</Label>
|
||||
<Select
|
||||
value={config?.dataSource?.labelField || ""}
|
||||
onValueChange={(value) => handleChange("dataSource.labelField", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col) => (
|
||||
<SelectItem key={col.name} value={col.name}>
|
||||
{col.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 코드 카테고리 선택 (type이 code일 때) */}
|
||||
{config?.dataSource?.type === "code" && (
|
||||
<div className="space-y-2">
|
||||
<Label>코드 카테고리</Label>
|
||||
<Select
|
||||
value={config?.dataSource?.codeCategory || ""}
|
||||
onValueChange={(value) => handleChange("dataSource.codeCategory", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="카테고리 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{codeCategories.map((cat) => (
|
||||
<SelectItem key={cat.value} value={cat.value}>
|
||||
{cat.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필드 매핑 */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<h4 className="text-sm font-medium">필드 매핑 (저장 위치)</h4>
|
||||
{screenTableName && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
현재 화면 테이블: <strong>{screenTableName}</strong>
|
||||
</p>
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label>출발지 저장 컬럼</Label>
|
||||
{tableColumns.length > 0 ? (
|
||||
<Select
|
||||
value={config?.departureField || ""}
|
||||
onValueChange={(value) => handleChange("departureField", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={config?.departureField || "departure"}
|
||||
onChange={(e) => handleChange("departureField", e.target.value)}
|
||||
placeholder="departure"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>도착지 저장 컬럼</Label>
|
||||
{tableColumns.length > 0 ? (
|
||||
<Select
|
||||
value={config?.destinationField || ""}
|
||||
onValueChange={(value) => handleChange("destinationField", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={config?.destinationField || "destination"}
|
||||
onChange={(e) => handleChange("destinationField", e.target.value)}
|
||||
placeholder="destination"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label>출발지명 저장 컬럼 (선택)</Label>
|
||||
{tableColumns.length > 0 ? (
|
||||
<Select
|
||||
value={config?.departureLabelField || ""}
|
||||
onValueChange={(value) => handleChange("departureLabelField", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="컬럼 선택 (선택사항)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">없음</SelectItem>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={config?.departureLabelField || ""}
|
||||
onChange={(e) => handleChange("departureLabelField", e.target.value)}
|
||||
placeholder="departure_name"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>도착지명 저장 컬럼 (선택)</Label>
|
||||
{tableColumns.length > 0 ? (
|
||||
<Select
|
||||
value={config?.destinationLabelField || ""}
|
||||
onValueChange={(value) => handleChange("destinationLabelField", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="컬럼 선택 (선택사항)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">없음</SelectItem>
|
||||
{tableColumns.map((col) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={config?.destinationLabelField || ""}
|
||||
onChange={(e) => handleChange("destinationLabelField", e.target.value)}
|
||||
placeholder="destination_name"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* UI 설정 */}
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<h4 className="text-sm font-medium">UI 설정</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-2">
|
||||
<Label>출발지 라벨</Label>
|
||||
<Input
|
||||
value={config?.departureLabel || "출발지"}
|
||||
onChange={(e) => handleChange("departureLabel", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>도착지 라벨</Label>
|
||||
<Input
|
||||
value={config?.destinationLabel || "도착지"}
|
||||
onChange={(e) => handleChange("destinationLabel", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>스타일</Label>
|
||||
<Select
|
||||
value={config?.variant || "card"}
|
||||
onValueChange={(value) => handleChange("variant", value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="card">카드 (이미지 참고)</SelectItem>
|
||||
<SelectItem value="inline">인라인</SelectItem>
|
||||
<SelectItem value="minimal">미니멀</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>교환 버튼 표시</Label>
|
||||
<Switch
|
||||
checked={config?.showSwapButton !== false}
|
||||
onCheckedChange={(checked) => handleChange("showSwapButton", checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 안내 */}
|
||||
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
|
||||
<p className="text-xs text-blue-900 dark:text-blue-100">
|
||||
<strong>사용 방법:</strong>
|
||||
<br />
|
||||
1. 데이터 소스에서 장소 목록을 가져올 위치를 선택합니다
|
||||
<br />
|
||||
2. 출발지/도착지 값이 저장될 필드를 지정합니다
|
||||
<br />
|
||||
3. 교환 버튼을 클릭하면 출발지와 도착지가 바뀝니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { LocationSwapSelectorDefinition } from "./index";
|
||||
import { LocationSwapSelectorComponent } from "./LocationSwapSelectorComponent";
|
||||
|
||||
/**
|
||||
* LocationSwapSelector 렌더러
|
||||
*/
|
||||
export class LocationSwapSelectorRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = LocationSwapSelectorDefinition;
|
||||
|
||||
render(): React.ReactElement {
|
||||
return <LocationSwapSelectorComponent {...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
// 자동 등록 실행
|
||||
LocationSwapSelectorRenderer.registerSelf();
|
||||
|
||||
// Hot Reload 지원 (개발 모드)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
LocationSwapSelectorRenderer.enableHotReload();
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
"use client";
|
||||
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { LocationSwapSelectorComponent } from "./LocationSwapSelectorComponent";
|
||||
import { LocationSwapSelectorConfigPanel } from "./LocationSwapSelectorConfigPanel";
|
||||
|
||||
/**
|
||||
* LocationSwapSelector 컴포넌트 정의
|
||||
* 출발지/도착지 선택 및 교환 기능을 제공하는 컴포넌트
|
||||
*/
|
||||
export const LocationSwapSelectorDefinition = createComponentDefinition({
|
||||
id: "location-swap-selector",
|
||||
name: "출발지/도착지 선택",
|
||||
nameEng: "Location Swap Selector",
|
||||
description: "출발지와 도착지를 선택하고 교환할 수 있는 컴포넌트 (모바일 최적화)",
|
||||
category: ComponentCategory.INPUT,
|
||||
webType: "form",
|
||||
component: LocationSwapSelectorComponent,
|
||||
defaultConfig: {
|
||||
// 데이터 소스 설정
|
||||
dataSource: {
|
||||
type: "table", // "table" | "code" | "static"
|
||||
tableName: "", // 장소 테이블명
|
||||
valueField: "location_code", // 값 필드
|
||||
labelField: "location_name", // 표시 필드
|
||||
codeCategory: "", // 코드 관리 카테고리 (type이 "code"일 때)
|
||||
staticOptions: [], // 정적 옵션 (type이 "static"일 때)
|
||||
},
|
||||
// 필드 매핑
|
||||
departureField: "departure", // 출발지 저장 필드
|
||||
destinationField: "destination", // 도착지 저장 필드
|
||||
departureLabelField: "departure_name", // 출발지명 저장 필드 (선택)
|
||||
destinationLabelField: "destination_name", // 도착지명 저장 필드 (선택)
|
||||
// UI 설정
|
||||
departureLabel: "출발지",
|
||||
destinationLabel: "도착지",
|
||||
showSwapButton: true,
|
||||
swapButtonPosition: "center", // "center" | "right"
|
||||
// 스타일
|
||||
variant: "card", // "card" | "inline" | "minimal"
|
||||
},
|
||||
defaultSize: { width: 400, height: 100 },
|
||||
configPanel: LocationSwapSelectorConfigPanel,
|
||||
icon: "ArrowLeftRight",
|
||||
tags: ["출발지", "도착지", "교환", "스왑", "위치", "모바일"],
|
||||
version: "1.0.0",
|
||||
author: "개발팀",
|
||||
});
|
||||
|
||||
// 컴포넌트 내보내기
|
||||
export { LocationSwapSelectorComponent } from "./LocationSwapSelectorComponent";
|
||||
export { LocationSwapSelectorRenderer } from "./LocationSwapSelectorRenderer";
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -1,33 +1,316 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import React, { useEffect, useRef, useCallback, useMemo, useState } from "react";
|
||||
import { Layers } from "lucide-react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { ComponentDefinition, ComponentCategory, ComponentRendererProps } from "@/types/component";
|
||||
import { RepeaterInput } from "@/components/webtypes/RepeaterInput";
|
||||
import { RepeaterConfigPanel } from "@/components/webtypes/config/RepeaterConfigPanel";
|
||||
import { useScreenContextOptional, DataReceivable } from "@/contexts/ScreenContext";
|
||||
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
|
||||
import { applyMappingRules } from "@/lib/utils/dataMapping";
|
||||
import { toast } from "sonner";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
/**
|
||||
* Repeater Field Group 컴포넌트
|
||||
*/
|
||||
const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) => {
|
||||
const { component, value, onChange, readonly, disabled } = props;
|
||||
const { component, value, onChange, readonly, disabled, formData, onFormDataChange, menuObjid } = props;
|
||||
const screenContext = useScreenContextOptional();
|
||||
const splitPanelContext = useSplitPanelContext();
|
||||
const receiverRef = useRef<DataReceivable | null>(null);
|
||||
|
||||
// 🆕 그룹화된 데이터를 저장하는 상태
|
||||
const [groupedData, setGroupedData] = useState<any[] | null>(null);
|
||||
const [isLoadingGroupData, setIsLoadingGroupData] = useState(false);
|
||||
const groupDataLoadedRef = useRef(false);
|
||||
|
||||
// 🆕 원본 데이터 ID 목록 (삭제 추적용)
|
||||
const [originalItemIds, setOriginalItemIds] = useState<string[]>([]);
|
||||
|
||||
// 컴포넌트의 필드명 (formData 키)
|
||||
const fieldName = (component as any).columnName || component.id;
|
||||
|
||||
// repeaterConfig 또는 componentConfig에서 설정 가져오기
|
||||
const config = (component as any).repeaterConfig || component.componentConfig || { fields: [] };
|
||||
|
||||
// 🆕 그룹화 설정 (예: groupByColumn: "inbound_number")
|
||||
const groupByColumn = config.groupByColumn;
|
||||
const targetTable = config.targetTable;
|
||||
|
||||
// formData에서 값 가져오기 (value prop보다 우선)
|
||||
const rawValue = formData?.[fieldName] ?? value;
|
||||
|
||||
// 🆕 수정 모드 감지: formData에 id가 있고, fieldName으로 값을 찾지 못한 경우
|
||||
// formData 자체를 배열의 첫 번째 항목으로 사용 (단일 행 수정 시)
|
||||
const isEditMode = formData?.id && !rawValue && !value;
|
||||
|
||||
// 🆕 반복 필드 그룹의 필드들이 formData에 있는지 확인
|
||||
const configFields = config.fields || [];
|
||||
const hasRepeaterFieldsInFormData = configFields.length > 0 &&
|
||||
configFields.some((field: any) => formData?.[field.name] !== undefined);
|
||||
|
||||
// 🆕 formData와 config.fields의 필드 이름 매칭 확인
|
||||
const matchingFields = configFields.filter((field: any) => formData?.[field.name] !== undefined);
|
||||
|
||||
// 🆕 그룹 키 값 (예: formData.inbound_number)
|
||||
const groupKeyValue = groupByColumn ? formData?.[groupByColumn] : null;
|
||||
|
||||
console.log("🔄 [RepeaterFieldGroup] 렌더링:", {
|
||||
fieldName,
|
||||
hasFormData: !!formData,
|
||||
formDataId: formData?.id,
|
||||
formDataValue: formData?.[fieldName],
|
||||
propsValue: value,
|
||||
rawValue,
|
||||
isEditMode,
|
||||
hasRepeaterFieldsInFormData,
|
||||
configFieldNames: configFields.map((f: any) => f.name),
|
||||
formDataKeys: formData ? Object.keys(formData) : [],
|
||||
matchingFieldNames: matchingFields.map((f: any) => f.name),
|
||||
groupByColumn,
|
||||
groupKeyValue,
|
||||
targetTable,
|
||||
hasGroupedData: groupedData !== null,
|
||||
groupedDataLength: groupedData?.length,
|
||||
});
|
||||
|
||||
// 🆕 수정 모드에서 그룹화된 데이터 로드
|
||||
useEffect(() => {
|
||||
const loadGroupedData = async () => {
|
||||
// 이미 로드했거나 조건이 맞지 않으면 스킵
|
||||
if (groupDataLoadedRef.current) return;
|
||||
if (!isEditMode || !groupByColumn || !groupKeyValue || !targetTable) return;
|
||||
|
||||
console.log("📥 [RepeaterFieldGroup] 그룹 데이터 로드 시작:", {
|
||||
groupByColumn,
|
||||
groupKeyValue,
|
||||
targetTable,
|
||||
});
|
||||
|
||||
setIsLoadingGroupData(true);
|
||||
groupDataLoadedRef.current = true;
|
||||
|
||||
try {
|
||||
// API 호출: 같은 그룹 키를 가진 모든 데이터 조회
|
||||
// search 파라미터 사용 (filters가 아닌 search)
|
||||
const response = await apiClient.post(`/table-management/tables/${targetTable}/data`, {
|
||||
page: 1,
|
||||
size: 100, // 충분히 큰 값
|
||||
search: { [groupByColumn]: groupKeyValue },
|
||||
});
|
||||
|
||||
console.log("🔍 [RepeaterFieldGroup] API 응답 구조:", {
|
||||
success: response.data?.success,
|
||||
hasData: !!response.data?.data,
|
||||
dataType: typeof response.data?.data,
|
||||
dataKeys: response.data?.data ? Object.keys(response.data.data) : [],
|
||||
});
|
||||
|
||||
// 응답 구조: { success, data: { data: [...], total, page, totalPages } }
|
||||
if (response.data?.success && response.data?.data?.data) {
|
||||
const items = response.data.data.data; // 실제 데이터 배열
|
||||
console.log("✅ [RepeaterFieldGroup] 그룹 데이터 로드 완료:", {
|
||||
count: items.length,
|
||||
groupByColumn,
|
||||
groupKeyValue,
|
||||
firstItem: items[0],
|
||||
});
|
||||
setGroupedData(items);
|
||||
|
||||
// 🆕 원본 데이터 ID 목록 저장 (삭제 추적용)
|
||||
const itemIds = items.map((item: any) => item.id).filter(Boolean);
|
||||
setOriginalItemIds(itemIds);
|
||||
console.log("📋 [RepeaterFieldGroup] 원본 데이터 ID 목록 저장:", itemIds);
|
||||
|
||||
// onChange 호출하여 부모에게 알림
|
||||
if (onChange && items.length > 0) {
|
||||
const dataWithMeta = items.map((item: any) => ({
|
||||
...item,
|
||||
_targetTable: targetTable,
|
||||
_originalItemIds: itemIds, // 🆕 원본 ID 목록도 함께 전달
|
||||
}));
|
||||
onChange(dataWithMeta);
|
||||
}
|
||||
} else {
|
||||
console.warn("⚠️ [RepeaterFieldGroup] 그룹 데이터 로드 실패:", response.data);
|
||||
setGroupedData([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ [RepeaterFieldGroup] 그룹 데이터 로드 오류:", error);
|
||||
setGroupedData([]);
|
||||
} finally {
|
||||
setIsLoadingGroupData(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadGroupedData();
|
||||
}, [isEditMode, groupByColumn, groupKeyValue, targetTable, onChange]);
|
||||
|
||||
// 값이 JSON 문자열인 경우 파싱
|
||||
let parsedValue: any[] = [];
|
||||
if (typeof value === "string") {
|
||||
|
||||
// 🆕 그룹화된 데이터가 있으면 우선 사용
|
||||
if (groupedData !== null && groupedData.length > 0) {
|
||||
parsedValue = groupedData;
|
||||
} else if (isEditMode && hasRepeaterFieldsInFormData && !groupByColumn) {
|
||||
// 그룹화 설정이 없는 경우에만 단일 행 사용
|
||||
console.log("📝 [RepeaterFieldGroup] 수정 모드 - formData를 초기 데이터로 사용", {
|
||||
formDataId: formData?.id,
|
||||
matchingFieldsCount: matchingFields.length,
|
||||
});
|
||||
parsedValue = [{ ...formData }];
|
||||
} else if (typeof rawValue === "string" && rawValue.trim() !== "") {
|
||||
// 빈 문자열이 아닌 경우에만 JSON 파싱 시도
|
||||
try {
|
||||
parsedValue = JSON.parse(value);
|
||||
parsedValue = JSON.parse(rawValue);
|
||||
} catch {
|
||||
parsedValue = [];
|
||||
}
|
||||
} else if (Array.isArray(value)) {
|
||||
parsedValue = value;
|
||||
} else if (Array.isArray(rawValue)) {
|
||||
parsedValue = rawValue;
|
||||
}
|
||||
|
||||
// parsedValue를 ref로 관리하여 최신 값 유지
|
||||
const parsedValueRef = useRef(parsedValue);
|
||||
parsedValueRef.current = parsedValue;
|
||||
|
||||
// onChange를 ref로 관리
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
// onFormDataChange를 ref로 관리
|
||||
const onFormDataChangeRef = useRef(onFormDataChange);
|
||||
onFormDataChangeRef.current = onFormDataChange;
|
||||
|
||||
// fieldName을 ref로 관리
|
||||
const fieldNameRef = useRef(fieldName);
|
||||
fieldNameRef.current = fieldName;
|
||||
|
||||
// config를 ref로 관리
|
||||
const configRef = useRef(config);
|
||||
configRef.current = config;
|
||||
|
||||
// 데이터 수신 핸들러
|
||||
const handleReceiveData = useCallback((data: any[], mappingRulesOrMode?: any[] | string) => {
|
||||
console.log("📥 [RepeaterFieldGroup] 데이터 수신:", { data, mappingRulesOrMode });
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
toast.warning("전달할 데이터가 없습니다");
|
||||
return;
|
||||
}
|
||||
|
||||
// 매핑 규칙이 배열인 경우에만 적용
|
||||
let processedData = data;
|
||||
if (Array.isArray(mappingRulesOrMode) && mappingRulesOrMode.length > 0) {
|
||||
processedData = applyMappingRules(data, mappingRulesOrMode);
|
||||
}
|
||||
|
||||
// 데이터 정규화: 각 항목에서 실제 데이터 추출
|
||||
// 데이터가 {0: {...}, inbound_type: "..."} 형태인 경우 처리
|
||||
const normalizedData = processedData.map((item: any) => {
|
||||
// item이 {0: {...실제데이터...}, 추가필드: 값} 형태인 경우
|
||||
if (item && typeof item === "object" && item[0] && typeof item[0] === "object") {
|
||||
// 0번 인덱스의 데이터와 나머지 필드를 병합
|
||||
const { 0: originalData, ...additionalFields } = item;
|
||||
return { ...originalData, ...additionalFields };
|
||||
}
|
||||
return item;
|
||||
});
|
||||
|
||||
// 🆕 정의된 필드만 필터링 (불필요한 필드 제거)
|
||||
// 반복 필드 그룹에 정의된 필드 + 시스템 필드만 유지
|
||||
const definedFields = configRef.current.fields || [];
|
||||
const definedFieldNames = new Set(definedFields.map((f: any) => f.name));
|
||||
// 시스템 필드 및 필수 필드 추가
|
||||
const systemFields = new Set(['id', '_targetTable', 'created_date', 'updated_date', 'writer', 'company_code']);
|
||||
|
||||
const filteredData = normalizedData.map((item: any) => {
|
||||
const filteredItem: Record<string, any> = {};
|
||||
Object.keys(item).forEach(key => {
|
||||
// 정의된 필드이거나 시스템 필드인 경우만 포함
|
||||
if (definedFieldNames.has(key) || systemFields.has(key)) {
|
||||
filteredItem[key] = item[key];
|
||||
}
|
||||
});
|
||||
return filteredItem;
|
||||
});
|
||||
|
||||
console.log("📥 [RepeaterFieldGroup] 정규화된 데이터:", normalizedData);
|
||||
console.log("📥 [RepeaterFieldGroup] 필터링된 데이터:", filteredData);
|
||||
|
||||
// 기존 데이터에 새 데이터 추가 (기본 모드: append)
|
||||
const currentValue = parsedValueRef.current;
|
||||
|
||||
// mode가 "replace"인 경우 기존 데이터 대체, 그 외에는 추가
|
||||
// 🆕 필터링된 데이터 사용
|
||||
const mode = typeof mappingRulesOrMode === "string" ? mappingRulesOrMode : "append";
|
||||
const newItems = mode === "replace" ? filteredData : [...currentValue, ...filteredData];
|
||||
|
||||
console.log("📥 [RepeaterFieldGroup] 최종 데이터:", { currentValue, newItems, mode });
|
||||
|
||||
// JSON 문자열로 변환하여 저장
|
||||
const jsonValue = JSON.stringify(newItems);
|
||||
console.log("📥 [RepeaterFieldGroup] onChange/onFormDataChange 호출:", {
|
||||
jsonValue,
|
||||
hasOnChange: !!onChangeRef.current,
|
||||
hasOnFormDataChange: !!onFormDataChangeRef.current,
|
||||
fieldName: fieldNameRef.current,
|
||||
});
|
||||
|
||||
// onFormDataChange가 있으면 우선 사용 (EmbeddedScreen의 formData 상태 업데이트)
|
||||
if (onFormDataChangeRef.current) {
|
||||
onFormDataChangeRef.current(fieldNameRef.current, jsonValue);
|
||||
}
|
||||
// 그렇지 않으면 onChange 사용
|
||||
else if (onChangeRef.current) {
|
||||
onChangeRef.current(jsonValue);
|
||||
}
|
||||
|
||||
toast.success(`${filteredData.length}개 항목이 추가되었습니다`);
|
||||
}, []);
|
||||
|
||||
// DataReceivable 인터페이스 구현
|
||||
const dataReceiver = useMemo<DataReceivable>(() => ({
|
||||
componentId: component.id,
|
||||
componentType: "repeater-field-group",
|
||||
receiveData: handleReceiveData,
|
||||
}), [component.id, handleReceiveData]);
|
||||
|
||||
// ScreenContext에 데이터 수신자로 등록
|
||||
useEffect(() => {
|
||||
if (screenContext && component.id) {
|
||||
console.log("📋 [RepeaterFieldGroup] ScreenContext에 데이터 수신자 등록:", component.id);
|
||||
screenContext.registerDataReceiver(component.id, dataReceiver);
|
||||
|
||||
return () => {
|
||||
screenContext.unregisterDataReceiver(component.id);
|
||||
};
|
||||
}
|
||||
}, [screenContext, component.id, dataReceiver]);
|
||||
|
||||
// SplitPanelContext에 데이터 수신자로 등록 (분할 패널 내에서만)
|
||||
useEffect(() => {
|
||||
const splitPanelPosition = screenContext?.splitPanelPosition;
|
||||
|
||||
if (splitPanelContext?.isInSplitPanel && splitPanelPosition && component.id) {
|
||||
console.log("🔗 [RepeaterFieldGroup] SplitPanelContext에 데이터 수신자 등록:", {
|
||||
componentId: component.id,
|
||||
position: splitPanelPosition,
|
||||
});
|
||||
|
||||
splitPanelContext.registerReceiver(splitPanelPosition, component.id, dataReceiver);
|
||||
receiverRef.current = dataReceiver;
|
||||
|
||||
return () => {
|
||||
console.log("🔗 [RepeaterFieldGroup] SplitPanelContext에서 데이터 수신자 해제:", component.id);
|
||||
splitPanelContext.unregisterReceiver(splitPanelPosition, component.id);
|
||||
receiverRef.current = null;
|
||||
};
|
||||
}
|
||||
}, [splitPanelContext, screenContext?.splitPanelPosition, component.id, dataReceiver]);
|
||||
|
||||
return (
|
||||
<RepeaterInput
|
||||
value={parsedValue}
|
||||
|
|
@ -39,6 +322,7 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
|
|||
config={config}
|
||||
disabled={disabled}
|
||||
readonly={readonly}
|
||||
menuObjid={menuObjid}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,351 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Settings, Layout, ArrowRight, Database, Loader2, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ScreenSplitPanelConfigPanelProps {
|
||||
config: any;
|
||||
onChange: (newConfig: any) => void;
|
||||
}
|
||||
|
||||
export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSplitPanelConfigPanelProps) {
|
||||
// 화면 목록 상태
|
||||
const [screens, setScreens] = useState<any[]>([]);
|
||||
const [isLoadingScreens, setIsLoadingScreens] = useState(true);
|
||||
|
||||
// Combobox 상태
|
||||
const [leftOpen, setLeftOpen] = useState(false);
|
||||
const [rightOpen, setRightOpen] = useState(false);
|
||||
|
||||
const [localConfig, setLocalConfig] = useState({
|
||||
screenId: config.screenId || 0,
|
||||
leftScreenId: config.leftScreenId || 0,
|
||||
rightScreenId: config.rightScreenId || 0,
|
||||
splitRatio: config.splitRatio || 50,
|
||||
resizable: config.resizable ?? true,
|
||||
buttonLabel: config.buttonLabel || "데이터 전달",
|
||||
buttonPosition: config.buttonPosition || "center",
|
||||
...config,
|
||||
});
|
||||
|
||||
// config prop이 변경되면 localConfig 동기화
|
||||
useEffect(() => {
|
||||
console.log("🔄 [ScreenSplitPanelConfigPanel] config prop 변경 감지:", config);
|
||||
setLocalConfig({
|
||||
screenId: config.screenId || 0,
|
||||
leftScreenId: config.leftScreenId || 0,
|
||||
rightScreenId: config.rightScreenId || 0,
|
||||
splitRatio: config.splitRatio || 50,
|
||||
resizable: config.resizable ?? true,
|
||||
buttonLabel: config.buttonLabel || "데이터 전달",
|
||||
buttonPosition: config.buttonPosition || "center",
|
||||
...config,
|
||||
});
|
||||
}, [config]);
|
||||
|
||||
// 화면 목록 로드
|
||||
useEffect(() => {
|
||||
const loadScreens = async () => {
|
||||
try {
|
||||
setIsLoadingScreens(true);
|
||||
const response = await screenApi.getScreens({ page: 1, size: 1000 });
|
||||
if (response.data) {
|
||||
setScreens(response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("화면 목록 로드 실패:", error);
|
||||
} finally {
|
||||
setIsLoadingScreens(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadScreens();
|
||||
}, []);
|
||||
|
||||
const updateConfig = (key: string, value: any) => {
|
||||
const newConfig = {
|
||||
...localConfig,
|
||||
[key]: value,
|
||||
};
|
||||
setLocalConfig(newConfig);
|
||||
|
||||
console.log("📝 [ScreenSplitPanelConfigPanel] 설정 변경:", {
|
||||
key,
|
||||
value,
|
||||
newConfig,
|
||||
hasOnChange: !!onChange,
|
||||
});
|
||||
|
||||
// 변경 즉시 부모에게 전달
|
||||
if (onChange) {
|
||||
onChange(newConfig);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Tabs defaultValue="layout" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="layout" className="gap-2">
|
||||
<Layout className="h-4 w-4" />
|
||||
레이아웃
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="screens" className="gap-2">
|
||||
<Database className="h-4 w-4" />
|
||||
화면 설정
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 레이아웃 탭 */}
|
||||
<TabsContent value="layout" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">분할 비율</CardTitle>
|
||||
<CardDescription className="text-xs">좌측과 우측 패널의 너비 비율을 설정합니다</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="splitRatio" className="text-xs">
|
||||
좌측 패널 너비 (%)
|
||||
</Label>
|
||||
<span className="text-xs font-medium">{localConfig.splitRatio}%</span>
|
||||
</div>
|
||||
<Input
|
||||
id="splitRatio"
|
||||
type="range"
|
||||
min="20"
|
||||
max="80"
|
||||
step="5"
|
||||
value={localConfig.splitRatio}
|
||||
onChange={(e) => updateConfig("splitRatio", parseInt(e.target.value))}
|
||||
className="h-2"
|
||||
/>
|
||||
<div className="text-muted-foreground flex justify-between text-xs">
|
||||
<span>20%</span>
|
||||
<span>50%</span>
|
||||
<span>80%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="resizable" className="text-xs font-medium">
|
||||
크기 조절 가능
|
||||
</Label>
|
||||
<p className="text-muted-foreground text-xs">사용자가 패널 크기를 조절할 수 있습니다</p>
|
||||
</div>
|
||||
<Checkbox
|
||||
id="resizable"
|
||||
checked={localConfig.resizable}
|
||||
onCheckedChange={(checked) => updateConfig("resizable", checked)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 화면 설정 탭 */}
|
||||
<TabsContent value="screens" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">임베드할 화면 선택</CardTitle>
|
||||
<CardDescription className="text-xs">좌측과 우측에 표시할 화면을 선택합니다</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{isLoadingScreens ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
<span className="text-muted-foreground ml-2 text-xs">화면 목록 로딩 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="leftScreenId" className="text-xs">
|
||||
좌측 화면 (소스)
|
||||
</Label>
|
||||
<Popover open={leftOpen} onOpenChange={setLeftOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={leftOpen}
|
||||
className="h-9 w-full justify-between text-xs"
|
||||
>
|
||||
{localConfig.leftScreenId
|
||||
? screens.find((s) => s.screenId === localConfig.leftScreenId)?.screenName || "화면 선택..."
|
||||
: "화면 선택..."}
|
||||
<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="화면 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs">화면을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{screens.map((screen) => (
|
||||
<CommandItem
|
||||
key={screen.screenId}
|
||||
value={`${screen.screenName} ${screen.screenCode}`}
|
||||
onSelect={() => {
|
||||
updateConfig("leftScreenId", screen.screenId);
|
||||
setLeftOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
localConfig.leftScreenId === screen.screenId ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{screen.screenName}</span>
|
||||
<span className="text-[10px] text-gray-500">{screen.screenCode}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-muted-foreground text-xs">데이터를 선택할 소스 화면</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rightScreenId" className="text-xs">
|
||||
우측 화면 (타겟)
|
||||
</Label>
|
||||
<Popover open={rightOpen} onOpenChange={setRightOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={rightOpen}
|
||||
className="h-9 w-full justify-between text-xs"
|
||||
>
|
||||
{localConfig.rightScreenId
|
||||
? screens.find((s) => s.screenId === localConfig.rightScreenId)?.screenName ||
|
||||
"화면 선택..."
|
||||
: "화면 선택..."}
|
||||
<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="화면 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs">화면을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{screens.map((screen) => (
|
||||
<CommandItem
|
||||
key={screen.screenId}
|
||||
value={`${screen.screenName} ${screen.screenCode}`}
|
||||
onSelect={() => {
|
||||
updateConfig("rightScreenId", screen.screenId);
|
||||
setRightOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
localConfig.rightScreenId === screen.screenId ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{screen.screenName}</span>
|
||||
<span className="text-[10px] text-gray-500">{screen.screenCode}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-muted-foreground text-xs">데이터를 받을 타겟 화면</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950">
|
||||
<p className="text-xs text-amber-800 dark:text-amber-200">
|
||||
💡 <strong>데이터 전달 방법:</strong> 좌측 화면에 테이블과 버튼을 배치하고, 버튼의 액션을
|
||||
"transferData"로 설정하세요.
|
||||
<br />
|
||||
버튼 설정에서 소스 컴포넌트(테이블), 타겟 화면, 필드 매핑을 지정할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* 설정 요약 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">현재 설정</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">좌측 화면:</span>
|
||||
<span className="font-medium">
|
||||
{localConfig.leftScreenId
|
||||
? screens.find((s) => s.screenId === localConfig.leftScreenId)?.screenName ||
|
||||
`ID: ${localConfig.leftScreenId}`
|
||||
: "미설정"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">우측 화면:</span>
|
||||
<span className="font-medium">
|
||||
{localConfig.rightScreenId
|
||||
? screens.find((s) => s.screenId === localConfig.rightScreenId)?.screenName ||
|
||||
`ID: ${localConfig.rightScreenId}`
|
||||
: "미설정"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">분할 비율:</span>
|
||||
<span className="font-medium">
|
||||
{localConfig.splitRatio}% / {100 - localConfig.splitRatio}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">크기 조절:</span>
|
||||
<span className="font-medium">{localConfig.resizable ? "가능" : "불가능"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { ScreenSplitPanel } from "@/components/screen-embedding/ScreenSplitPanel";
|
||||
import { ScreenSplitPanelConfigPanel } from "./ScreenSplitPanelConfigPanel";
|
||||
|
||||
/**
|
||||
* 화면 분할 패널 Renderer
|
||||
* 좌우 화면 임베딩 및 데이터 전달 기능을 제공하는 컴포넌트
|
||||
*/
|
||||
class ScreenSplitPanelRenderer extends AutoRegisteringComponentRenderer {
|
||||
static componentDefinition = {
|
||||
id: "screen-split-panel",
|
||||
name: "화면 분할 패널",
|
||||
nameEng: "Screen Split Panel",
|
||||
description: "좌우 화면 임베딩 및 데이터 전달 기능을 제공하는 분할 패널",
|
||||
category: ComponentCategory.LAYOUT,
|
||||
webType: "text", // 레이아웃 컴포넌트는 기본 webType 사용
|
||||
component: ScreenSplitPanelRenderer, // 🆕 Renderer 클래스 자체를 등록 (ScreenSplitPanel 아님)
|
||||
configPanel: ScreenSplitPanelConfigPanel, // 설정 패널
|
||||
tags: ["split", "panel", "embed", "data-transfer", "layout"],
|
||||
defaultSize: {
|
||||
width: 1200,
|
||||
height: 600,
|
||||
},
|
||||
defaultConfig: {
|
||||
screenId: 0,
|
||||
leftScreenId: 0,
|
||||
rightScreenId: 0,
|
||||
splitRatio: 50,
|
||||
resizable: true,
|
||||
buttonLabel: "데이터 전달",
|
||||
buttonPosition: "center",
|
||||
},
|
||||
version: "1.0.0",
|
||||
author: "ERP System",
|
||||
documentation: `
|
||||
# 화면 분할 패널
|
||||
|
||||
좌우로 화면을 나누고 각 영역에 다른 화면을 임베딩할 수 있는 레이아웃 컴포넌트입니다.
|
||||
|
||||
## 주요 기능
|
||||
|
||||
- **화면 임베딩**: 좌우 영역에 기존 화면을 임베딩
|
||||
- **데이터 전달**: 좌측 화면에서 선택한 데이터를 우측 화면으로 전달
|
||||
- **다중 컴포넌트 매핑**: 테이블, 입력 필드, 폼 등 다양한 컴포넌트로 데이터 전달 가능
|
||||
- **데이터 변환**: sum, average, concat 등 데이터 변환 함수 지원
|
||||
- **조건부 전달**: 특정 조건을 만족하는 데이터만 전달 가능
|
||||
|
||||
## 사용 시나리오
|
||||
|
||||
1. **입고 등록**: 발주 목록(좌) → 입고 품목 입력(우)
|
||||
2. **수주 등록**: 품목 목록(좌) → 수주 상세 입력(우)
|
||||
3. **출고 등록**: 재고 목록(좌) → 출고 품목 입력(우)
|
||||
|
||||
## 설정 방법
|
||||
|
||||
1. 화면 디자이너에서 "화면 분할 패널" 컴포넌트를 드래그하여 배치
|
||||
2. 속성 패널에서 좌측/우측 화면 선택
|
||||
3. 데이터 전달 규칙 설정 (소스 → 타겟 매핑)
|
||||
4. 전달 버튼 설정 (라벨, 위치, 검증 규칙)
|
||||
`,
|
||||
};
|
||||
|
||||
render() {
|
||||
console.log("🚀 [ScreenSplitPanelRenderer] render() 호출됨!", this.props);
|
||||
|
||||
const { component, style = {}, componentConfig, config, screenId, formData } = this.props as any;
|
||||
|
||||
// componentConfig 또는 config 또는 component.componentConfig 사용
|
||||
const finalConfig = componentConfig || config || component?.componentConfig || {};
|
||||
|
||||
console.log("🔍 [ScreenSplitPanelRenderer] 설정 분석:", {
|
||||
hasComponentConfig: !!componentConfig,
|
||||
hasConfig: !!config,
|
||||
hasComponentComponentConfig: !!component?.componentConfig,
|
||||
finalConfig,
|
||||
splitRatio: finalConfig.splitRatio,
|
||||
leftScreenId: finalConfig.leftScreenId,
|
||||
rightScreenId: finalConfig.rightScreenId,
|
||||
componentType: component?.componentType,
|
||||
componentId: component?.id,
|
||||
});
|
||||
|
||||
// 🆕 formData 별도 로그 (명확한 확인)
|
||||
console.log("📝 [ScreenSplitPanelRenderer] formData 확인:", {
|
||||
hasFormData: !!formData,
|
||||
formDataKeys: formData ? Object.keys(formData) : [],
|
||||
formData: formData,
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ width: "100%", height: "100%", ...style }}>
|
||||
<ScreenSplitPanel
|
||||
screenId={screenId || finalConfig.screenId}
|
||||
config={finalConfig}
|
||||
initialFormData={formData} // 🆕 수정 데이터 전달
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 자동 등록
|
||||
ScreenSplitPanelRenderer.registerSelf();
|
||||
|
||||
export default ScreenSplitPanelRenderer;
|
||||
|
|
@ -2,6 +2,8 @@ import React, { useState, useEffect, useRef, useMemo } from "react";
|
|||
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||
import { useCodeOptions, useTableCodeCategory } from "@/hooks/queries/useCodes";
|
||||
import { cn } from "@/lib/registry/components/common/inputStyles";
|
||||
import { useScreenContextOptional } from "@/contexts/ScreenContext";
|
||||
import type { DataProvidable } from "@/types/data-transfer";
|
||||
|
||||
interface Option {
|
||||
value: string;
|
||||
|
|
@ -50,6 +52,9 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
menuObjid, // 🆕 메뉴 OBJID
|
||||
...props
|
||||
}) => {
|
||||
// 화면 컨텍스트 (데이터 제공자로 등록)
|
||||
const screenContext = useScreenContextOptional();
|
||||
|
||||
// 🚨 최초 렌더링 확인용 (테스트 후 제거)
|
||||
console.log("🚨🚨🚨 [SelectBasicComponent] 렌더링됨!!!!", {
|
||||
componentId: component.id,
|
||||
|
|
@ -249,6 +254,47 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
|||
// - 중복 요청 방지: 동일한 queryKey에 대해 자동 중복 제거
|
||||
// - 상태 동기화: 모든 컴포넌트가 같은 캐시 공유
|
||||
|
||||
// 📦 DataProvidable 인터페이스 구현 (데이터 전달 시 셀렉트 값 제공)
|
||||
const dataProvider: DataProvidable = {
|
||||
componentId: component.id,
|
||||
componentType: "select",
|
||||
|
||||
getSelectedData: () => {
|
||||
// 현재 선택된 값을 배열로 반환
|
||||
const fieldName = component.columnName || "selectedValue";
|
||||
return [{
|
||||
[fieldName]: selectedValue,
|
||||
value: selectedValue,
|
||||
label: selectedLabel,
|
||||
}];
|
||||
},
|
||||
|
||||
getAllData: () => {
|
||||
// 모든 옵션 반환
|
||||
const configOptions = config.options || [];
|
||||
return [...codeOptions, ...categoryOptions, ...configOptions];
|
||||
},
|
||||
|
||||
clearSelection: () => {
|
||||
setSelectedValue("");
|
||||
setSelectedLabel("");
|
||||
if (isMultiple) {
|
||||
setSelectedValues([]);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// 화면 컨텍스트에 데이터 제공자로 등록
|
||||
useEffect(() => {
|
||||
if (screenContext && component.id) {
|
||||
screenContext.registerDataProvider(component.id, dataProvider);
|
||||
|
||||
return () => {
|
||||
screenContext.unregisterDataProvider(component.id);
|
||||
};
|
||||
}
|
||||
}, [screenContext, component.id, selectedValue, selectedLabel, selectedValues]);
|
||||
|
||||
// 선택된 값에 따른 라벨 업데이트
|
||||
useEffect(() => {
|
||||
const getAllOptions = () => {
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue