diff --git a/backend-node/data/mail-sent/1d997eeb-3d61-427d-8b54-119d4372b9b3.json b/backend-node/data/mail-sent/1d997eeb-3d61-427d-8b54-119d4372b9b3.json deleted file mode 100644 index 683ad20c..00000000 --- a/backend-node/data/mail-sent/1d997eeb-3d61-427d-8b54-119d4372b9b3.json +++ /dev/null @@ -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
\r\n

전달히야야양


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
전달된 메일:

보낸사람: \"이희진\"
날짜: 2025. 10. 22. 오후 12:58:15
제목: ㄴ
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

ㄴㅇㄹㄴㅇㄹㄴㅇㄹ

\r\n
\r\n ", - "status": "success", - "messageId": "", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/1e492bb1-d069-4242-8cbf-9829b8f6c7e6.json b/backend-node/data/mail-sent/1e492bb1-d069-4242-8cbf-9829b8f6c7e6.json deleted file mode 100644 index eccdc063..00000000 --- a/backend-node/data/mail-sent/1e492bb1-d069-4242-8cbf-9829b8f6c7e6.json +++ /dev/null @@ -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\n\n\n \n \n\n\n \n \n \n \n
\n

ㄴㅇㄹㄴㅇㄹ

\n \"\"\n

ㄴㅇㄹ

ㄴㅇㄹ

\n
\n\n
\n \r\n
\r\n

ㄴㅇㄹ

\r\n
\r\n \n
\n \n\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": "", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/2d848b19-26e1-45ad-8e2c-9205f1f01c87.json b/backend-node/data/mail-sent/2d848b19-26e1-45ad-8e2c-9205f1f01c87.json deleted file mode 100644 index a6fed281..00000000 --- a/backend-node/data/mail-sent/2d848b19-26e1-45ad-8e2c-9205f1f01c87.json +++ /dev/null @@ -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
\r\n

ㅓㅏㅣ

\r\n
\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": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/331d95d6-3a13-4657-bc75-ab0811712eb8.json b/backend-node/data/mail-sent/331d95d6-3a13-4657-bc75-ab0811712eb8.json deleted file mode 100644 index 5090fdd2..00000000 --- a/backend-node/data/mail-sent/331d95d6-3a13-4657-bc75-ab0811712eb8.json +++ /dev/null @@ -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
\r\n

ㅁㄴㅇㄹㅁㄴㅇㄹㄴㅇㄹㄴㅇㄹ

\r\n
\r\n ", - "status": "success", - "messageId": "", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/34f7f149-ac97-442e-b595-02c990082f86.json b/backend-node/data/mail-sent/34f7f149-ac97-442e-b595-02c990082f86.json deleted file mode 100644 index 46b0b1b8..00000000 --- a/backend-node/data/mail-sent/34f7f149-ac97-442e-b595-02c990082f86.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "id": "34f7f149-ac97-442e-b595-02c990082f86", - "sentAt": "2025-10-13T01:04:08.560Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "subject": "제목 없음", - "htmlContent": "\n\n\n\n \n \n\n\n \n \n \n \n
\n

텍스트 영역 1

\n 버튼1\n
\n \"\"\n

텍스트 영역2

텍스트 영역3

\n
\n\n
\n \r\n
\r\n

선택메시지 영역

\r\n
\r\n \n
\n \n\n", - "templateId": "template-1760315158387", - "templateName": "테스트2", - "attachments": [ - { - "filename": "한글.txt", - "originalName": "한글.txt", - "size": 0, - "path": "/app/uploads/mail-attachments/1760317447824-27488793.txt", - "mimetype": "text/plain" - } - ], - "status": "success", - "messageId": "<1d7caa77-12f1-a791-a230-162826cf03ea@wace.me>", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/37fce6a0-2301-431b-b573-82bdab9b8008.json b/backend-node/data/mail-sent/37fce6a0-2301-431b-b573-82bdab9b8008.json deleted file mode 100644 index d70b6897..00000000 --- a/backend-node/data/mail-sent/37fce6a0-2301-431b-b573-82bdab9b8008.json +++ /dev/null @@ -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
\r\n

asd

\r\n
\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": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/3f72cbab-b60e-45e7-ac8d-7e441bc2b900.json b/backend-node/data/mail-sent/3f72cbab-b60e-45e7-ac8d-7e441bc2b900.json deleted file mode 100644 index 05eb18c2..00000000 --- a/backend-node/data/mail-sent/3f72cbab-b60e-45e7-ac8d-7e441bc2b900.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "id": "3f72cbab-b60e-45e7-ac8d-7e441bc2b900", - "sentAt": "2025-10-13T01:34:19.363Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "subject": "테스트 템플릿이에용22", - "htmlContent": "\n\n\n\n \n \n\n\n \n \n \n \n
\n

안녕안녕하세요 이건 테스트용 템플릿입니다용22

\n \"\"\n

안녕하세용 [222]이안에 뭘 넣어보세용

여기에 뭘 또 입력해보세용[222] 안에 넣어도 돼요

\n
\n\n
\n \r\n
\r\n

ㄴㅇㄹㄴㅇㄹㄴㅇㄹ

\r\n
\r\n \n
\n \n\n", - "templateId": "template-1760315158387", - "templateName": "테스트2", - "attachments": [ - { - "filename": "blender study.docx", - "originalName": "blender study.docx", - "size": 0, - "path": "/app/uploads/mail-attachments/1760319257947-827879690.docx", - "mimetype": "application/vnd.openxmlformats-officedocument.wordprocessingml.document" - } - ], - "status": "success", - "messageId": "<5b3d9f82-8531-f427-c7f7-9446b4f19da4@wace.me>", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/43466fc8-56e8-44a0-875c-dec2c3c8eb78.json b/backend-node/data/mail-sent/43466fc8-56e8-44a0-875c-dec2c3c8eb78.json new file mode 100644 index 00000000..ea3b568f --- /dev/null +++ b/backend-node/data/mail-sent/43466fc8-56e8-44a0-875c-dec2c3c8eb78.json @@ -0,0 +1,20 @@ +{ + "id": "43466fc8-56e8-44a0-875c-dec2c3c8eb78", + "sentAt": "2025-11-28T02:34:02.239Z", + "accountId": "account-1759310844272", + "accountName": "이희진", + "accountEmail": "hjlee@wace.me", + "to": [ + "zian9227@naver.com" + ], + "subject": "임의로 설정한 제목", + "htmlContent": "\n\n\n\n \n \n\n\n \n \n \n \n
\n\n
\n \n \n \n \n \n
\n \n (주)웨이스\n \n 2025. 11. 28.\n
\n
\n
\n naver\n
\n \"\"\n
\n
\n
(주)웨이스
\n \n
\n 대표: 이희진\n \n \n
\n \n
주소주소
\n \n
\n Tel: 전화번호 01010101011010\n | \n Email: 이메일이메일\n
\n \n
© 2025 All rights reserved.
\n
\n \n
\n \n \n \n \n \n \n \n \n
항목내용
\n
\n \n
\n
안내
\n
안내를 합시다 합시다 합시다
\n
\n \n
\n \n
    \n
  1. 첫 번째 항목
  2. 두번째항목
  3. \n
\n
\n \n
\n \n
    \n
  1. 첫 번째 항목
  2. \n
\n
\n \n
\n \n
    \n
  1. 첫 번째 항목
  2. \n
\n
\n \n
\n \n
    \n
  1. 첫 번째 항목
  2. \n
\n
\n \n
\n\n\n", + "templateId": "template-1764296982213", + "templateName": "제목 있음", + "status": "success", + "messageId": "<78b63521-2648-f6eb-eeba-efdeebce8459@wace.me>", + "accepted": [ + "zian9227@naver.com" + ], + "rejected": [] +} \ No newline at end of file diff --git a/backend-node/data/mail-sent/449d9951-51e8-4e81-ada4-e73aed8ff60e.json b/backend-node/data/mail-sent/449d9951-51e8-4e81-ada4-e73aed8ff60e.json deleted file mode 100644 index 29ec634e..00000000 --- a/backend-node/data/mail-sent/449d9951-51e8-4e81-ada4-e73aed8ff60e.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "id": "449d9951-51e8-4e81-ada4-e73aed8ff60e", - "sentAt": "2025-10-13T01:29:25.975Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "subject": "테스트 템플릿이에용", - "htmlContent": "\n\n\n\n \n \n\n\n \n \n \n \n
\n
안녕안녕하세요 이건 테스트용 템플릿입니다용
\n \"\"\n

안녕하세용 [뭘 넣은 결과 입니당]이안에 뭘 넣어보세용

여기에 뭘 또 입력해보세용[안에 뭘 넣은 결과입니다.] 안에 넣어도 돼요

\n
\n\n\n", - "templateId": "template-1760315158387", - "templateName": "테스트2", - "status": "success", - "messageId": "<5d52accb-777b-b6c2-aab7-1a2f7b7754ab@wace.me>", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/6dd3673a-f510-4ba9-9634-0b391f925230.json b/backend-node/data/mail-sent/6dd3673a-f510-4ba9-9634-0b391f925230.json deleted file mode 100644 index ee094c49..00000000 --- a/backend-node/data/mail-sent/6dd3673a-f510-4ba9-9634-0b391f925230.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "id": "6dd3673a-f510-4ba9-9634-0b391f925230", - "sentAt": "2025-10-13T01:01:55.097Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "subject": "테스트용입니당.", - "htmlContent": "\n\n\n\n \n \n\n\n \n \n \n \n
\n \n \n \n \n
\n

텍스트 영역 1

\n 버튼1\n
\n \"\"\n

텍스트 영역2

텍스트 영역3

\n
\n
\n\n
\n \r\n
\r\n

이건 저장이 안되는군

\r\n
\r\n \n
\n \n\n", - "templateId": "template-1760315158387", - "templateName": "테스트2", - "attachments": [ - { - "filename": "한글-분석.txt", - "originalName": "한글-분석.txt", - "size": 0, - "path": "/app/uploads/mail-attachments/1760317313641-761345104.txt", - "mimetype": "text/plain" - } - ], - "status": "success", - "messageId": "", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/84ee9619-49ff-4f61-a7fa-0bb0b0b7199a.json b/backend-node/data/mail-sent/84ee9619-49ff-4f61-a7fa-0bb0b0b7199a.json deleted file mode 100644 index 37317a6a..00000000 --- a/backend-node/data/mail-sent/84ee9619-49ff-4f61-a7fa-0bb0b0b7199a.json +++ /dev/null @@ -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": [ - "\"이희진\" " - ], - "subject": "Re: ㅅㄷㄴㅅ", - "htmlContent": "\r\n
\r\n

야야야야야야야야ㅑㅇ야ㅑㅇ

\r\n
\r\n

\r\n
\r\n

보낸 사람: \"이희진\"

\r\n

날짜: 2025. 10. 22. 오후 1:03:03

\r\n

제목: ㅅㄷㄴㅅ

\r\n
\r\n undefined\r\n
\r\n ", - "status": "success", - "messageId": "<5fa451ff-7d29-7da4-ce56-ca7391c147af@wace.me>", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/89a32ace-f39b-44fa-b614-c65d96548f92.json b/backend-node/data/mail-sent/89a32ace-f39b-44fa-b614-c65d96548f92.json deleted file mode 100644 index 4ac647c7..00000000 --- a/backend-node/data/mail-sent/89a32ace-f39b-44fa-b614-c65d96548f92.json +++ /dev/null @@ -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
\r\n






---------- 전달된 메시지 ----------


보낸 사람: \"기상청 API허브\"


날짜: 2025. 10. 13. 오후 4:26:45


제목: 기상청 API허브 회원가입 인증번호




undefined

\r\n
\r\n ", - "status": "success", - "messageId": "<9b36ce56-4ef1-cf0c-1f39-2c73bcb521da@wace.me>", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/9eab902e-f77b-424f-ada4-0ea8709b36bf.json b/backend-node/data/mail-sent/9eab902e-f77b-424f-ada4-0ea8709b36bf.json deleted file mode 100644 index ed2e4b14..00000000 --- a/backend-node/data/mail-sent/9eab902e-f77b-424f-ada4-0ea8709b36bf.json +++ /dev/null @@ -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": "

텍스트를 입력하세요...

\n 버튼\n
\n \"\"\n

텍스트를 입력하세요...

텍스트를 입력하세요...

\n
\n \r\n
\r\n

어덯게 나오는지 봅시다 추가메시지 영역이빈다.

\r\n
\r\n \n
\n
", - "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": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/a1ca39ad-4467-44e0-963a-fba5037c8896.json b/backend-node/data/mail-sent/a1ca39ad-4467-44e0-963a-fba5037c8896.json deleted file mode 100644 index 31492a08..00000000 --- a/backend-node/data/mail-sent/a1ca39ad-4467-44e0-963a-fba5037c8896.json +++ /dev/null @@ -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
\r\n

ㅁㄴㅇㄹㅁㄴㅇㄹㄴㅁㅇㄹ

\r\n
\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": "", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/a3a9aab1-4334-46bd-bf50-b867305f66c0.json b/backend-node/data/mail-sent/a3a9aab1-4334-46bd-bf50-b867305f66c0.json deleted file mode 100644 index 1435f837..00000000 --- a/backend-node/data/mail-sent/a3a9aab1-4334-46bd-bf50-b867305f66c0.json +++ /dev/null @@ -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
\r\n

ㅁㄴㅇㄹ

\r\n
\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": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/a638f7d0-ee31-47fa-9f72-de66ef31ea44.json b/backend-node/data/mail-sent/a638f7d0-ee31-47fa-9f72-de66ef31ea44.json deleted file mode 100644 index 5cf165c3..00000000 --- a/backend-node/data/mail-sent/a638f7d0-ee31-47fa-9f72-de66ef31ea44.json +++ /dev/null @@ -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
\r\n

ㄹㅇㄴㅁㄹㅇㄴㅁㅇㄹㅇㄴㅁ

\r\n
\r\n ", - "status": "success", - "messageId": "<5ea07d02-78bf-a655-8289-bcbd8eaf7741@wace.me>", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/b1d8f458-076c-4c44-982e-d2f46dcd4b03.json b/backend-node/data/mail-sent/b1d8f458-076c-4c44-982e-d2f46dcd4b03.json deleted file mode 100644 index 8f8d5059..00000000 --- a/backend-node/data/mail-sent/b1d8f458-076c-4c44-982e-d2f46dcd4b03.json +++ /dev/null @@ -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
\r\n

ㅁㄴㅇㄹ

\r\n
\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": "", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/b75d0b2b-7d8a-461b-b854-2bebdef959e8.json b/backend-node/data/mail-sent/b75d0b2b-7d8a-461b-b854-2bebdef959e8.json deleted file mode 100644 index dbec91a5..00000000 --- a/backend-node/data/mail-sent/b75d0b2b-7d8a-461b-b854-2bebdef959e8.json +++ /dev/null @@ -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
\r\n

ㅁㄴㅇㄹ

\r\n
\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": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/ccdd8961-1b3f-4b88-b838-51d6ed8f1601.json b/backend-node/data/mail-sent/ccdd8961-1b3f-4b88-b838-51d6ed8f1601.json deleted file mode 100644 index d2d4c424..00000000 --- a/backend-node/data/mail-sent/ccdd8961-1b3f-4b88-b838-51d6ed8f1601.json +++ /dev/null @@ -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
\r\n

2

\r\n
\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": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/e2801ec2-6219-4c3c-83b4-8a6834569488.json b/backend-node/data/mail-sent/e2801ec2-6219-4c3c-83b4-8a6834569488.json deleted file mode 100644 index 1a388699..00000000 --- a/backend-node/data/mail-sent/e2801ec2-6219-4c3c-83b4-8a6834569488.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "id": "e2801ec2-6219-4c3c-83b4-8a6834569488", - "sentAt": "2025-10-13T00:59:46.729Z", - "accountId": "account-1759310844272", - "accountName": "이희진", - "accountEmail": "hjlee@wace.me", - "to": [ - "zian9227@naver.com" - ], - "subject": "제목 없음", - "htmlContent": "

텍스트 영역 1

\n 버튼1\n
\n \"\"\n

텍스트 영역2

텍스트 영역3

\n
\n \r\n
\r\n

추가메시지 영역

\r\n
\r\n \n
\n
", - "templateId": "template-1760315158387", - "templateName": "테스트2", - "attachments": [ - { - "filename": "한글.txt", - "originalName": "한글.txt", - "size": 0, - "path": "/app/uploads/mail-attachments/1760317184642-745285906.txt", - "mimetype": "text/plain" - } - ], - "status": "success", - "messageId": "<1e0abffb-a6cc-8312-d8b4-31c33cb72aa7@wace.me>", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/e93848a8-6901-44c4-b4db-27c8d2aeb8dd.json b/backend-node/data/mail-sent/e93848a8-6901-44c4-b4db-27c8d2aeb8dd.json deleted file mode 100644 index 74c8212f..00000000 --- a/backend-node/data/mail-sent/e93848a8-6901-44c4-b4db-27c8d2aeb8dd.json +++ /dev/null @@ -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": [ - "\"권은아\" " - ], - "subject": "Re: 매우 졸린 오후예요", - "htmlContent": "\r\n
\r\n

호홋 답장 기능을 구현했다죵
얼른 퇴근하고 싪네여

\r\n
\r\n

\r\n
\r\n

보낸 사람: \"권은아\"

\r\n

날짜: 2025. 10. 22. 오후 1:10:37

\r\n

제목: 매우 졸린 오후예요

\r\n
\r\n undefined\r\n
\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": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/ee0d162c-48ad-4c00-8c56-ade80be4503f.json b/backend-node/data/mail-sent/ee0d162c-48ad-4c00-8c56-ade80be4503f.json deleted file mode 100644 index 45c6a1eb..00000000 --- a/backend-node/data/mail-sent/ee0d162c-48ad-4c00-8c56-ade80be4503f.json +++ /dev/null @@ -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
\r\n

ㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹ

\r\n
\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": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/fc26aba3-6b6e-47ba-91e8-609ae25e0e7d.json b/backend-node/data/mail-sent/fc26aba3-6b6e-47ba-91e8-609ae25e0e7d.json deleted file mode 100644 index f64daf8c..00000000 --- a/backend-node/data/mail-sent/fc26aba3-6b6e-47ba-91e8-609ae25e0e7d.json +++ /dev/null @@ -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
\r\n

ㅁㄴㅇㄹ

\r\n
\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": "", - "accepted": [ - "zian9227@naver.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/data/mail-sent/fcea6149-a098-4212-aa00-baef0cc083d6.json b/backend-node/data/mail-sent/fcea6149-a098-4212-aa00-baef0cc083d6.json deleted file mode 100644 index efd9a0c0..00000000 --- a/backend-node/data/mail-sent/fcea6149-a098-4212-aa00-baef0cc083d6.json +++ /dev/null @@ -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\" " - ], - "subject": "Re: 안녕하세여", - "htmlContent": "\r\n
\r\n

어떻게 가는지 궁금한데 이따가 화면 보여주세영

\r\n
\r\n

\r\n
\r\n

보낸 사람: \"DHS\"

\r\n

날짜: 2025. 10. 22. 오후 1:09:49

\r\n

제목: 안녕하세여

\r\n
\r\n undefined\r\n
\r\n ", - "status": "success", - "messageId": "", - "accepted": [ - "ddhhss0603@gmail.com" - ], - "rejected": [] -} \ No newline at end of file diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index fc69cdb1..2e753b56 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -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); diff --git a/backend-node/src/controllers/dynamicFormController.ts b/backend-node/src/controllers/dynamicFormController.ts index 9b8ef6fc..738d1964 100644 --- a/backend-node/src/controllers/dynamicFormController.ts +++ b/backend-node/src/controllers/dynamicFormController.ts @@ -419,3 +419,66 @@ export const getTableColumns = async ( }); } }; + +// 특정 필드만 업데이트 (다른 테이블 지원) +export const updateFieldValue = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + 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 || "필드 업데이트에 실패했습니다.", + }); + } +}; diff --git a/backend-node/src/controllers/screenEmbeddingController.ts b/backend-node/src/controllers/screenEmbeddingController.ts new file mode 100644 index 00000000..43087589 --- /dev/null +++ b/backend-node/src/controllers/screenEmbeddingController.ts @@ -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(); + } +} diff --git a/backend-node/src/controllers/tableCategoryValueController.ts b/backend-node/src/controllers/tableCategoryValueController.ts index c25b4127..248bb867 100644 --- a/backend-node/src/controllers/tableCategoryValueController.ts +++ b/backend-node/src/controllers/tableCategoryValueController.ts @@ -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레벨 메뉴 목록 조회 * diff --git a/backend-node/src/routes/dynamicFormRoutes.ts b/backend-node/src/routes/dynamicFormRoutes.ts index 5514fb54..21140617 100644 --- a/backend-node/src/routes/dynamicFormRoutes.ts +++ b/backend-node/src/routes/dynamicFormRoutes.ts @@ -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); diff --git a/backend-node/src/routes/externalRestApiConnectionRoutes.ts b/backend-node/src/routes/externalRestApiConnectionRoutes.ts index a789b218..48813575 100644 --- a/backend-node/src/routes/externalRestApiConnectionRoutes.ts +++ b/backend-node/src/routes/externalRestApiConnectionRoutes.ts @@ -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; diff --git a/backend-node/src/routes/screenEmbeddingRoutes.ts b/backend-node/src/routes/screenEmbeddingRoutes.ts new file mode 100644 index 00000000..6b604c15 --- /dev/null +++ b/backend-node/src/routes/screenEmbeddingRoutes.ts @@ -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; + diff --git a/backend-node/src/routes/tableCategoryValueRoutes.ts b/backend-node/src/routes/tableCategoryValueRoutes.ts index c4afe66e..b79aab75 100644 --- a/backend-node/src/routes/tableCategoryValueRoutes.ts +++ b/backend-node/src/routes/tableCategoryValueRoutes.ts @@ -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; diff --git a/backend-node/src/services/DashboardService.ts b/backend-node/src/services/DashboardService.ts index b75034c2..4b13d6b8 100644 --- a/backend-node/src/services/DashboardService.ts +++ b/backend-node/src/services/DashboardService.ts @@ -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( diff --git a/backend-node/src/services/dynamicFormService.ts b/backend-node/src/services/dynamicFormService.ts index c40037bb..11648577 100644 --- a/backend-node/src/services/dynamicFormService.ts +++ b/backend-node/src/services/dynamicFormService.ts @@ -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 diff --git a/backend-node/src/services/externalRestApiConnectionService.ts b/backend-node/src/services/externalRestApiConnectionService.ts index 0599a409..af37eff1 100644 --- a/backend-node/src/services/externalRestApiConnectionService.ts +++ b/backend-node/src/services/externalRestApiConnectionService.ts @@ -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 = 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> { + 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 : "알 수 없는 오류", + }, + }; + } + } + /** * 연결 데이터 유효성 검증 */ diff --git a/backend-node/src/services/mailSendSimpleService.ts b/backend-node/src/services/mailSendSimpleService.ts index b4dce503..4e44006a 100644 --- a/backend-node/src/services/mailSendSimpleService.ts +++ b/backend-node/src/services/mailSendSimpleService.ts @@ -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 += ``; break; case 'image': @@ -348,6 +351,89 @@ class MailSendSimpleService { case 'spacer': html += `
`; break; + case 'header': + html += ` +
+ + + + + +
+ ${component.logoSrc ? `로고` : ''} + ${component.brandName || ''} + + ${component.sendDate || ''} +
+
+ `; + break; + case 'infoTable': + html += ` +
+ ${component.tableTitle ? `
${component.tableTitle}
` : ''} + + ${(component.rows || []).map((row: any, i: number) => ` + + + + + `).join('')} +
${row.label}${row.value}
+
+ `; + break; + case 'alertBox': + const alertColors: Record = { + 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 += ` +
+ ${component.alertTitle ? `
${component.alertTitle}
` : ''} +
${component.content || ''}
+
+ `; + break; + case 'divider': + html += `
`; + break; + case 'footer': + html += ` +
+ ${component.companyName ? `
${component.companyName}
` : ''} + ${(component.ceoName || component.businessNumber) ? ` +
+ ${component.ceoName ? `대표: ${component.ceoName}` : ''} + ${component.ceoName && component.businessNumber ? ' | ' : ''} + ${component.businessNumber ? `사업자등록번호: ${component.businessNumber}` : ''} +
+ ` : ''} + ${component.address ? `
${component.address}
` : ''} + ${(component.phone || component.email) ? ` +
+ ${component.phone ? `Tel: ${component.phone}` : ''} + ${component.phone && component.email ? ' | ' : ''} + ${component.email ? `Email: ${component.email}` : ''} +
+ ` : ''} + ${component.copyright ? `
${component.copyright}
` : ''} +
+ `; + break; + case 'numberedList': + html += ` +
+ ${component.listTitle ? `
${component.listTitle}
` : ''} +
    + ${(component.listItems || []).map((item: string) => `
  1. ${item}
  2. `).join('')} +
+
+ `; + break; } }); diff --git a/backend-node/src/services/mailTemplateFileService.ts b/backend-node/src/services/mailTemplateFileService.ts index adb72fff..bd82a7d2 100644 --- a/backend-node/src/services/mailTemplateFileService.ts +++ b/backend-node/src/services/mailTemplateFileService.ts @@ -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; + // 헤더 컴포넌트용 + 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 += `
`; break; + case "header": + html += ` +
+ + + + + +
+ ${comp.logoSrc ? `로고` : ''} + ${comp.brandName || ''} + + ${comp.sendDate || ''} +
+
+ `; + break; + case "infoTable": + html += ` +
+ ${comp.tableTitle ? `
${comp.tableTitle}
` : ''} + + ${(comp.rows || []).map((row, i) => ` + + + + + `).join('')} +
${row.label}${row.value}
+
+ `; + break; + case "alertBox": + const alertColors: Record = { + 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 += ` +
+ ${comp.alertTitle ? `
${comp.alertTitle}
` : ''} +
${comp.content || ''}
+
+ `; + break; + case "divider": + html += `
`; + break; + case "footer": + html += ` +
+ ${comp.companyName ? `
${comp.companyName}
` : ''} + ${(comp.ceoName || comp.businessNumber) ? ` +
+ ${comp.ceoName ? `대표: ${comp.ceoName}` : ''} + ${comp.ceoName && comp.businessNumber ? ' | ' : ''} + ${comp.businessNumber ? `사업자등록번호: ${comp.businessNumber}` : ''} +
+ ` : ''} + ${comp.address ? `
${comp.address}
` : ''} + ${(comp.phone || comp.email) ? ` +
+ ${comp.phone ? `Tel: ${comp.phone}` : ''} + ${comp.phone && comp.email ? ' | ' : ''} + ${comp.email ? `Email: ${comp.email}` : ''} +
+ ` : ''} + ${comp.copyright ? `
${comp.copyright}
` : ''} +
+ `; + break; + case "numberedList": + html += ` +
+ ${comp.listTitle ? `
${comp.listTitle}
` : ''} +
    + ${(comp.listItems || []).map(item => `
  1. ${item}
  2. `).join('')} +
+
+ `; + break; } }); diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index 7d969b06..70b45af4 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -10,10 +10,6 @@ export interface MenuCopyResult { copiedMenus: number; copiedScreens: number; copiedFlows: number; - copiedCategories: number; - copiedCodes: number; - copiedCategorySettings: number; - copiedNumberingRules: number; menuIdMap: Record; screenIdMap: Record; flowIdMap: Record; @@ -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( - `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( - `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 { - 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 { - 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, - targetCompanyCode: string, - userId: string, - client: PoolClient - ): Promise { - 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, - targetCompanyCode: string, - userId: string, - client: PoolClient - ): Promise { - logger.info(`📂 카테고리 설정 복사 중...`); - - const valueIdMap = new Map(); // 원본 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(); - 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, - targetCompanyCode: string, - userId: string, - client: PoolClient - ): Promise { - logger.info(`📋 채번 규칙 복사 중...`); - - const ruleIdMap = new Map(); // 원본 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}개` - ); - } } diff --git a/backend-node/src/services/menuService.ts b/backend-node/src/services/menuService.ts index 86df579c..57bddabd 100644 --- a/backend-node/src/services/menuService.ts +++ b/backend-node/src/services/menuService.ts @@ -102,6 +102,72 @@ export async function getSiblingMenuObjids(menuObjid: number): Promise } } +/** + * 선택한 메뉴와 그 하위 메뉴들의 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 { + 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 합집합 조회 * diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index cb405b33..83b4f63b 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -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 { - 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; } diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index a7445637..71550fd6 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -70,12 +70,13 @@ export class ScreenManagementService { throw new Error("이미 존재하는 화면 코드입니다."); } - // 화면 생성 (Raw Query) + // 화면 생성 (Raw Query) - REST API 지원 추가 const [screen] = await query( `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", }; } diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index 2a379ae0..b68d5f05 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -1066,6 +1066,66 @@ class TableCategoryValueService { } } + /** + * 테이블+컬럼 기준으로 모든 매핑 삭제 + * + * 메뉴 선택 변경 시 기존 매핑을 모두 삭제하고 새로운 매핑만 추가하기 위해 사용 + * + * @param tableName - 테이블명 + * @param columnName - 컬럼명 + * @param companyCode - 회사 코드 + * @returns 삭제된 매핑 수 + */ + async deleteColumnMappingsByColumn( + tableName: string, + columnName: string, + companyCode: string + ): Promise { + 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; + } + } + /** * 논리적 컬럼명을 물리적 컬럼명으로 변환 * diff --git a/backend-node/src/types/screen.ts b/backend-node/src/types/screen.ts index ca5a466f..8260f3c6 100644 --- a/backend-node/src/types/screen.ts +++ b/backend-node/src/types/screen.ts @@ -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; } // 화면 수정 요청 diff --git a/frontend/app/(main)/admin/mail/send/page.tsx b/frontend/app/(main)/admin/mail/send/page.tsx index 9f368f13..56922043 100644 --- a/frontend/app/(main)/admin/mail/send/page.tsx +++ b/frontend/app/(main)/admin/mail/send/page.tsx @@ -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}`; 여백 ); + + case 'header': + return ( +
+
+
+ {component.logoSrc && 로고} + {component.brandName} +
+ {component.sendDate} +
+
+ ); + + case 'infoTable': + return ( +
+ {component.tableTitle && ( +
{component.tableTitle}
+ )} + + + {component.rows?.map((row: any, i: number) => ( + + + + + ))} + +
{row.label}{row.value}
+
+ ); + + case 'alertBox': + return ( +
+ {component.alertTitle &&
{component.alertTitle}
} +
{component.content}
+
+ ); + + case 'divider': + return ( +
+ ); + + case 'footer': + return ( +
+ {component.companyName &&
{component.companyName}
} + {(component.ceoName || component.businessNumber) && ( +
+ {component.ceoName && 대표: {component.ceoName}} + {component.ceoName && component.businessNumber && |} + {component.businessNumber && 사업자등록번호: {component.businessNumber}} +
+ )} + {component.address &&
{component.address}
} + {(component.phone || component.email) && ( +
+ {component.phone && Tel: {component.phone}} + {component.phone && component.email && |} + {component.email && Email: {component.email}} +
+ )} + {component.copyright &&
{component.copyright}
} +
+ ); + + case 'numberedList': + return ( +
+ {component.listTitle &&
{component.listTitle}
} +
    + {component.listItems?.map((item: string, i: number) => ( +
  1. {item}
  2. + ))} +
+
+ ); default: return null; diff --git a/frontend/app/(main)/admin/tableMng/page.tsx b/frontend/app/(main)/admin/tableMng/page.tsx index 290109f3..5dcbb6be 100644 --- a/frontend/app/(main)/admin/tableMng/page.tsx +++ b/frontend/app/(main)/admin/tableMng/page.tsx @@ -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++; } } } diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 5685d23a..8ab31ff7 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -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 ( - + + + ); } diff --git a/frontend/components/admin/CompanyTable.tsx b/frontend/components/admin/CompanyTable.tsx index 78b9ca3e..b36a757b 100644 --- a/frontend/components/admin/CompanyTable.tsx +++ b/frontend/components/admin/CompanyTable.tsx @@ -162,7 +162,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company {formatDiskUsage(company)}
- + */}
-
+
플로우:{" "} {result.copiedFlows}개
-
- 코드 카테고리:{" "} - {result.copiedCategories}개 -
-
- 코드:{" "} - {result.copiedCodes}개 -
)} diff --git a/frontend/components/admin/RestApiConnectionModal.tsx b/frontend/components/admin/RestApiConnectionModal.tsx index 8795fa40..aa7d79d8 100644 --- a/frontend/components/admin/RestApiConnectionModal.tsx +++ b/frontend/components/admin/RestApiConnectionModal.tsx @@ -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 *
- { + setDefaultMethod(val); + setTestMethod(val); // 테스트 Method도 동기화 + }} + > diff --git a/frontend/components/admin/department/DepartmentStructure.tsx b/frontend/components/admin/department/DepartmentStructure.tsx index c094846e..4347d612 100644 --- a/frontend/components/admin/department/DepartmentStructure.tsx +++ b/frontend/components/admin/department/DepartmentStructure.tsx @@ -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"; diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 65dbf84c..3e0f1a61 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -59,7 +59,7 @@ export const ScreenModal: React.FC = ({ className }) => { // 연속 등록 모드 상태 (state로 변경 - 체크박스 UI 업데이트를 위해) const [continuousMode, setContinuousMode] = useState(false); - + // 화면 리셋 키 (컴포넌트 강제 리마운트용) const [resetKey, setResetKey] = useState(0); @@ -68,7 +68,7 @@ export const ScreenModal: React.FC = ({ 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 = ({ className }) => { }; }; + // 모달이 열린 시간 추적 (저장 성공 이벤트 무시용) + const modalOpenedAtRef = React.useRef(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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ className }) => { window.history.pushState({}, "", currentUrl.toString()); console.log("🧹 [ScreenModal] URL 파라미터 제거 (모달 닫힘)"); } - + setModalState({ isOpen: false, screenId: null, @@ -440,7 +463,7 @@ export const ScreenModal: React.FC = ({ className }) => { // 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스 const headerHeight = 60; // DialogHeader (타이틀 + 패딩) const footerHeight = 52; // 연속 등록 모드 체크박스 영역 - + const totalHeight = screenDimensions.height + headerHeight + footerHeight; return { @@ -581,6 +604,15 @@ export const ScreenModal: React.FC = ({ 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 ( = ({ className }) => { userId={userId} userName={userName} companyCode={user?.companyCode} + // 🆕 선택된 데이터 전달 (RepeatScreenModal 등에서 사용) + groupedData={selectedData.length > 0 ? selectedData : undefined} /> ); })} diff --git a/frontend/components/mail/MailDesigner.tsx b/frontend/components/mail/MailDesigner.tsx index 8d3a38f9..464de85d 100644 --- a/frontend/components/mail/MailDesigner.tsx +++ b/frontend/components/mail/MailDesigner.tsx @@ -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; + // 헤더 컴포넌트용 + 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([]); const [isLoading, setIsLoading] = useState(false); + + // 드래그 앤 드롭 상태 + const [draggedIndex, setDraggedIndex] = useState(null); + const [dragOverIndex, setDragOverIndex] = useState(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({
{/* 왼쪽: 컴포넌트 팔레트 */}
+ {/* 레이아웃 컴포넌트 */} +
+

+ + 레이아웃 +

+
+ {componentTypes.filter(c => c.category === "layout").map(({ type, icon: Icon, label, color }) => ( + + ))} +
+
+ + {/* 컨텐츠 컴포넌트 */}

- 컴포넌트 + 컨텐츠

- {componentTypes.map(({ type, icon: Icon, label, color }) => ( + {componentTypes.filter(c => c.category === "content").map(({ type, icon: Icon, label, color }) => ( +
+ +
+ +
+ + {/* 순서 배지 */} +
+ {index + 1} +
+ {/* 삭제 버튼 */}
-
-

- 추천값:
- • 좁은 간격: 10~20 픽셀
- • 보통 간격: 30~50 픽셀
- • 넓은 간격: 60~100 픽셀 -

+
+
+ )} + + {/* 헤더 컴포넌트 */} + {selected.type === "header" && ( +
+
+ + updateComponent(selected.id, { brandName: e.target.value })} + placeholder="회사명" + className="mt-1" + /> +
+
+ + updateComponent(selected.id, { logoSrc: e.target.value })} + placeholder="https://example.com/logo.png" + className="mt-1" + /> +
+
+ + updateComponent(selected.id, { sendDate: e.target.value })} + className="mt-1" + /> +
+
+ +
+ updateComponent(selected.id, { headerBgColor: e.target.value })} + className="w-16 h-10" + /> + {selected.headerBgColor || "#f8f9fa"} +
+
+
+ )} + + {/* 정보 테이블 컴포넌트 */} + {selected.type === "infoTable" && ( +
+
+ + updateComponent(selected.id, { tableTitle: e.target.value })} + placeholder="예: 주문 정보" + className="mt-1" + /> +
+
+ +
+ {selected.rows?.map((row, i) => ( +
+ { + const newRows = [...(selected.rows || [])]; + newRows[i] = { ...newRows[i], label: e.target.value }; + updateComponent(selected.id, { rows: newRows }); + }} + placeholder="항목명" + className="flex-1" + /> + { + const newRows = [...(selected.rows || [])]; + newRows[i] = { ...newRows[i], value: e.target.value }; + updateComponent(selected.id, { rows: newRows }); + }} + placeholder="값" + className="flex-1" + /> + +
+ ))} + +
+
+
+ )} + + {/* 안내 박스 컴포넌트 */} + {selected.type === "alertBox" && ( +
+
+ +
+ {(["info", "warning", "danger", "success"] as const).map((type) => ( + + ))} +
+
+
+ + updateComponent(selected.id, { alertTitle: e.target.value })} + placeholder="안내 제목" + className="mt-1" + /> +
+
+ +