Merge remote-tracking branch 'upstream/main'

This commit is contained in:
kjs 2025-12-01 15:22:55 +09:00
commit 5511f563ca
161 changed files with 23445 additions and 4773 deletions

42
PLAN.MD
View File

@ -1,28 +1,36 @@
# 프로젝트: Digital Twin 에디터 안정화 # 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원)
## 개요 ## 개요
현재 GET 방식 위주로 구현된 외부 REST API 커넥션 관리 기능을 확장하여, POST, PUT, DELETE 등 다양한 HTTP 메서드와 JSON Request Body를 설정하고 테스트할 수 있도록 개선합니다. 이를 통해 토큰 발급 API나 데이터 전송 API 등 다양한 외부 시스템과의 연동을 지원합니다.
Digital Twin 에디터(`DigitalTwinEditor.tsx`)에서 발생한 런타임 에러(`TypeError: Cannot read properties of undefined`)를 수정하고, 전반적인 안정성을 확보합니다.
## 핵심 기능 ## 핵심 기능
1. **DB 스키마 확장**: `external_rest_api_connections` 테이블에 `default_method`, `default_body` 컬럼 추가
1. `DigitalTwinEditor` 버그 수정 2. **백엔드 로직 개선**:
2. 비동기 함수 입력값 유효성 검증 강화 - 커넥션 생성/수정 시 메서드와 바디 정보 저장
3. 외부 DB 연결 상태에 따른 방어 코드 추가 - 연결 테스트 시 설정된 메서드와 바디를 사용하여 요청 수행
- SSL 인증서 검증 우회 옵션 적용 (내부망/테스트망 지원)
3. **프론트엔드 UI 개선**:
- 커넥션 설정 모달에 HTTP 메서드 선택(Select) 및 Body 입력(Textarea/JSON Editor) 필드 추가
- 테스트 기능에서 Body 데이터 포함하여 요청 전송
## 테스트 계획 ## 테스트 계획
### 1단계: 기본 기능 및 DB 마이그레이션
- [x] DB 마이그레이션 스크립트 작성 및 실행
- [x] 백엔드 타입 정의 수정 (`default_method`, `default_body` 추가)
### 1단계: 긴급 버그 수정 ### 2단계: 백엔드 로직 구현
- [x] 커넥션 생성/수정 API 수정 (필드 추가)
- [x] 커넥션 상세 조회 API 확인
- [x] 연결 테스트 API 수정 (Method, Body 반영하여 요청 전송)
- [x] `loadMaterialCountsForLocations` 함수에서 `locaKeys` undefined 체크 추가 (완료) ### 3단계: 프론트엔드 구현
- [ ] 에디터 로드 및 객체 조작 시 에러 발생 여부 확인 - [x] 커넥션 관리 리스트/모달 UI 수정
- [x] 연결 테스트 UI 수정 및 기능 확인
### 2단계: 잠재적 문제 점검 ## 에러 처리 계획
- **JSON 파싱 에러**: Body 입력값이 유효한 JSON이 아닐 경우 에러 처리
- [ ] `loadLayout` 등 주요 로딩 함수의 데이터 유효성 검사 - **API 호출 에러**: 외부 API 호출 실패 시 상세 로그 기록 및 클라이언트에 에러 메시지 전달
- [ ] `handleToolDragStart`, `handleCanvasDrop` 등 인터랙션 함수의 예외 처리 - **SSL 인증 에러**: `rejectUnauthorized: false` 옵션으로 처리 (기존 `RestApiConnector` 활용)
## 진행 상태 ## 진행 상태
- [완료] 모든 단계 구현 완료
- [진행중] 1단계 긴급 버그 수정 완료 후 사용자 피드백 대기 중

View File

@ -1,18 +0,0 @@
{
"id": "1d997eeb-3d61-427d-8b54-119d4372b9b3",
"sentAt": "2025-10-22T07:13:30.905Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "Fwd: ㄴ",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">전달히야야양</p><p style=\"margin: 0 0 16px 0; line-height: 1.6;\"><br>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━<br>전달된 메일:</p><p style=\"margin: 0 0 16px 0; line-height: 1.6;\">보낸사람: \"이희진\" <zian9227@naver.com><br>날짜: 2025. 10. 22. 오후 12:58:15<br>제목: ㄴ<br>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</p><p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㄴㅇㄹㄴㅇㄹㄴㅇㄹ<br></p>\r\n </div>\r\n ",
"status": "success",
"messageId": "<d20cd501-04a4-bbe6-8b50-7f43e19bd70a@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -1,29 +0,0 @@
{
"id": "1e492bb1-d069-4242-8cbf-9829b8f6c7e6",
"sentAt": "2025-10-13T01:08:34.764Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "제목 없음",
"htmlContent": "\n<!DOCTYPE html>\n<html>\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n</head>\n<body style=\"margin: 0; padding: 0; background-color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;\">\n <table role=\"presentation\" style=\"width: 100%; border-collapse: collapse; background-color: #ffffff;\">\n <tr>\n <td style=\"padding: 20px;\">\n<div style=\"margin: 0 0 20px 0; color: #333; font-size: 15px; line-height: 1.6; text-align: left;\"><p>ㄴㅇㄹㄴㅇㄹ</p></div><div style=\"margin: 30px 0; text-align: left;\">\n <a href=\"https://example.com\" style=\"display: inline-block; padding: 14px 28px; background-color: #007bff; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 15px;\">ㄴㅇㄹ버튼</a>\n </div><div style=\"margin: 20px 0; text-align: left;\">\n <img src=\"https://placehold.co/600x200/e5e7eb/64748b?text=Image\" alt=\"\" style=\"max-width: 100%; height: auto; display: block; border-radius: 4px;\" />\n </div><div style=\"height: 20;\"></div><div style=\"margin: 0 0 20px 0; color: #333; font-size: 15px; line-height: 1.6; text-align: left;\"><p>ㄴㅇㄹ</p></div><div style=\"margin: 0 0 20px 0; color: #333; font-size: 15px; line-height: 1.6; text-align: left;\"><p>ㄴㅇㄹ</p></div>\n </td>\n </tr>\n </table>\n\n <div style=\"margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb;\">\n \r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㄴㅇㄹ</p>\r\n </div>\r\n \n </div>\n </body>\n</html>\n",
"templateId": "template-1760315158387",
"templateName": "테스트2",
"attachments": [
{
"filename": "스크린샷 2025-10-13 오전 10.00.06.png",
"originalName": "스크린샷 2025-10-13 오전 10.00.06.png",
"size": 0,
"path": "/app/uploads/mail-attachments/1760317712416-622369845.png",
"mimetype": "image/png"
}
],
"status": "success",
"messageId": "<f03bea59-9a77-b454-845e-7ad2a070bade@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -1,41 +0,0 @@
{
"id": "2d848b19-26e1-45ad-8e2c-9205f1f01c87",
"sentAt": "2025-10-02T07:50:25.817Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "ㅣ;ㅏㅓ",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅓㅏㅣ</p>\r\n </div>\r\n ",
"attachments": [
{
"filename": "test용 이미지33.jpg",
"originalName": "test용 이미지33.jpg",
"size": 0,
"path": "/app/uploads/mail-attachments/1759391422625-269479520_test____________________________33.jpg",
"mimetype": "image/jpeg"
},
{
"filename": "UI_개선사항_문서.md",
"originalName": "UI_개선사항_문서.md",
"size": 0,
"path": "/app/uploads/mail-attachments/1759391422626-68453569_UI_______________________________________________.md",
"mimetype": "text/x-markdown"
},
{
"filename": "test용 이미지2.png",
"originalName": "test용 이미지2.png",
"size": 0,
"path": "/app/uploads/mail-attachments/1759391422626-168170034_test____________________________2.png",
"mimetype": "image/png"
}
],
"status": "success",
"messageId": "<9d5b8275-e059-3a71-a34a-dea800730aa3@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -1,18 +0,0 @@
{
"id": "331d95d6-3a13-4657-bc75-ab0811712eb8",
"sentAt": "2025-10-22T07:18:18.240Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "ㅁㄴㅇㄹㅁㄴㅇㄹ",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹㅁㄴㅇㄹㄴㅇㄹㄴㅇㄹ</p>\r\n </div>\r\n ",
"status": "success",
"messageId": "<d4923c0d-f692-7d1d-d1b0-3b9e1e6cbab5@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

File diff suppressed because one or more lines are too long

View File

@ -1,41 +0,0 @@
{
"id": "37fce6a0-2301-431b-b573-82bdab9b8008",
"sentAt": "2025-10-02T07:44:38.128Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "asd",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">asd</p>\r\n </div>\r\n ",
"attachments": [
{
"filename": "웨이스-임직원-프로파일-이희진.key",
"originalName": "웨이스-임직원-프로파일-이희진.key",
"size": 0,
"path": "/app/uploads/mail-attachments/1759391076653-58189058___________________-___________________________-___________________________-_____________________.key",
"mimetype": "application/x-iwork-keynote-sffkey"
},
{
"filename": "웨이스-임직원-프로파일-이희진.pptx",
"originalName": "웨이스-임직원-프로파일-이희진.pptx",
"size": 0,
"path": "/app/uploads/mail-attachments/1759391076736-190208246___________________-___________________________-___________________________-_____________________.pptx",
"mimetype": "application/vnd.openxmlformats-officedocument.presentationml.presentation"
},
{
"filename": "test용 이미지33.jpg",
"originalName": "test용 이미지33.jpg",
"size": 0,
"path": "/app/uploads/mail-attachments/1759391076738-240665795_test____________________________33.jpg",
"mimetype": "image/jpeg"
}
],
"status": "success",
"messageId": "<796cb9a7-df62-31c4-ae6b-b42f383d82b4@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,18 +0,0 @@
{
"id": "84ee9619-49ff-4f61-a7fa-0bb0b0b7199a",
"sentAt": "2025-10-22T04:27:51.044Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"\"이희진\" <zian9227@naver.com>"
],
"subject": "Re: ㅅㄷㄴㅅ",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">야야야야야야야야ㅑㅇ야ㅑㅇ</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border-left: 3px solid #ccc; padding-left: 10px; margin-left: 10px; color: #666;\">\r\n <p><strong>보낸 사람:</strong> \"이희진\" <zian9227@naver.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:03:03</p>\r\n <p><strong>제목:</strong> ㅅㄷㄴㅅ</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\r\n ",
"status": "success",
"messageId": "<5fa451ff-7d29-7da4-ce56-ca7391c147af@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -1,18 +0,0 @@
{
"id": "89a32ace-f39b-44fa-b614-c65d96548f92",
"sentAt": "2025-10-22T03:49:48.461Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "Fwd: 기상청 API허브 회원가입 인증번호",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\"><br> <br/><br/><br> <div style=\"border: 1px solid #ccc; padding: 15px; margin: 10px 0; background-color: #f9f9f9;\"><br> <p><strong>---------- 전달된 메시지 ----------</strong></p><br> <p><strong>보낸 사람:</strong> \"기상청 API허브\" <noreply@apihube.kma.go.kr></p><br> <p><strong>날짜:</strong> 2025. 10. 13. 오후 4:26:45</p><br> <p><strong>제목:</strong> 기상청 API허브 회원가입 인증번호</p><br> <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" /><br> undefined<br> </div><br> </p>\r\n </div>\r\n ",
"status": "success",
"messageId": "<9b36ce56-4ef1-cf0c-1f39-2c73bcb521da@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -1,29 +0,0 @@
{
"id": "9eab902e-f77b-424f-ada4-0ea8709b36bf",
"sentAt": "2025-10-13T00:53:55.193Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "제목 없음",
"htmlContent": "<div style=\"max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;\"><p style=\"margin: 16px 0; color: #333; font-size: 14px;\"><p>텍스트를 입력하세요...</p></p><div style=\"text-align: center; margin: 24px 0;\">\n <a href=\"https://example.com\" style=\"display: inline-block; padding: 12px 24px; background-color: #007bff; color: #fff; text-decoration: none; border-radius: 4px;\">버튼</a>\n </div><div style=\"text-align: center; margin: 16px 0;\">\n <img src=\"https://placehold.co/600x200/e5e7eb/64748b?text=Image\" alt=\"\" style=\"max-width: 100%; height: auto;\" />\n </div><div style=\"height: 20;\"></div><p style=\"margin: 16px 0; color: #333; font-size: 14px;\"><p>텍스트를 입력하세요...</p></p><p style=\"margin: 16px 0; color: #333; font-size: 14px;\"><p>텍스트를 입력하세요...</p></p>\n <div style=\"margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb;\">\n \r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">어덯게 나오는지 봅시다 추가메시지 영역이빈다.</p>\r\n </div>\r\n \n </div>\n </div>",
"templateId": "template-1760315158387",
"templateName": "테스트2",
"attachments": [
{
"filename": "한글.txt",
"originalName": "한글.txt",
"size": 0,
"path": "/app/uploads/mail-attachments/1760316833254-789302611.txt",
"mimetype": "text/plain"
}
],
"status": "success",
"messageId": "<3d0bef10-2e58-fd63-b175-c1f499af0102@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -1,41 +0,0 @@
{
"id": "a1ca39ad-4467-44e0-963a-fba5037c8896",
"sentAt": "2025-10-02T08:22:14.721Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "ㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴ",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹㅁㄴㅇㄹㄴㅁㅇㄹ</p>\r\n </div>\r\n ",
"attachments": [
{
"filename": "test용 이미지33.jpg",
"originalName": "test용 이미지33.jpg",
"size": 0,
"path": "/app/uploads/mail-attachments/1759393332207-791945862_test____________________________33.jpg",
"mimetype": "image/jpeg"
},
{
"filename": "UI_개선사항_문서.md",
"originalName": "UI_개선사항_문서.md",
"size": 0,
"path": "/app/uploads/mail-attachments/1759393332208-660280542_UI_______________________________________________.md",
"mimetype": "text/x-markdown"
},
{
"filename": "test용 이미지2.png",
"originalName": "test용 이미지2.png",
"size": 0,
"path": "/app/uploads/mail-attachments/1759393332208-149486455_test____________________________2.png",
"mimetype": "image/png"
}
],
"status": "success",
"messageId": "<d52bab7c-4285-8a27-12ed-b501ff858d23@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -1,41 +0,0 @@
{
"id": "a3a9aab1-4334-46bd-bf50-b867305f66c0",
"sentAt": "2025-10-02T08:41:42.086Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "한글테스트",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
"attachments": [
{
"filename": "UI_개선사항_문서.md",
"originalName": "UI_개선사항_문서.md",
"size": 0,
"path": "/app/uploads/mail-attachments/1759394500462-50127394_UI_______________________________________________.md",
"mimetype": "text/x-markdown"
},
{
"filename": "test용 이미지33.jpg",
"originalName": "test용 이미지33.jpg",
"size": 0,
"path": "/app/uploads/mail-attachments/1759394500463-68744474_test____________________________33.jpg",
"mimetype": "image/jpeg"
},
{
"filename": "test용 이미지2.png",
"originalName": "test용 이미지2.png",
"size": 0,
"path": "/app/uploads/mail-attachments/1759394500463-464487722_test____________________________2.png",
"mimetype": "image/png"
}
],
"status": "success",
"messageId": "<2dbfbf64-69c2-a83d-6bb7-515e4e654628@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -1,18 +0,0 @@
{
"id": "a638f7d0-ee31-47fa-9f72-de66ef31ea44",
"sentAt": "2025-10-22T07:21:13.723Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "ㄹㅇㄴㅁㄹㅇㄴㅁ",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㄹㅇㄴㅁㄹㅇㄴㅁㅇㄹㅇㄴㅁ</p>\r\n </div>\r\n ",
"status": "success",
"messageId": "<5ea07d02-78bf-a655-8289-bcbd8eaf7741@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -1,48 +0,0 @@
{
"id": "b1d8f458-076c-4c44-982e-d2f46dcd4b03",
"sentAt": "2025-10-02T08:57:48.412Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "ㅁㄴㅇㄹ",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
"attachments": [
{
"filename": "웨이스-임직원-프로파일-이희진.key",
"originalName": "웨이스-임직원-프로파일-이희진.key",
"size": 0,
"path": "/app/uploads/mail-attachments/1759395465488-120933172.key",
"mimetype": "application/x-iwork-keynote-sffkey"
},
{
"filename": "UI_개선사항_문서.md",
"originalName": "UI_개선사항_문서.md",
"size": 0,
"path": "/app/uploads/mail-attachments/1759395465566-306126854.md",
"mimetype": "text/x-markdown"
},
{
"filename": "test용 이미지33.jpg",
"originalName": "test용 이미지33.jpg",
"size": 0,
"path": "/app/uploads/mail-attachments/1759395465566-412984398.jpg",
"mimetype": "image/jpeg"
},
{
"filename": "test용 이미지2.png",
"originalName": "test용 이미지2.png",
"size": 0,
"path": "/app/uploads/mail-attachments/1759395465567-143883587.png",
"mimetype": "image/png"
}
],
"status": "success",
"messageId": "<e2796753-a1a9-fbac-c035-00341e29031c@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -1,41 +0,0 @@
{
"id": "b75d0b2b-7d8a-461b-b854-2bebdef959e8",
"sentAt": "2025-10-02T08:49:30.356Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "한글2",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
"attachments": [
{
"filename": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
"originalName": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
"size": 0,
"path": "/app/uploads/mail-attachments/1759394969516-74008147_UI__________________________.md",
"mimetype": "text/x-markdown"
},
{
"filename": "testáá­á¼ ááµááµááµ33.jpg",
"originalName": "testáá­á¼ ááµááµááµ33.jpg",
"size": 0,
"path": "/app/uploads/mail-attachments/1759394969516-530544653_test_______________33.jpg",
"mimetype": "image/jpeg"
},
{
"filename": "testáá­á¼ ááµááµááµ2.png",
"originalName": "testáá­á¼ ááµááµááµ2.png",
"size": 0,
"path": "/app/uploads/mail-attachments/1759394969517-260831218_test_______________2.png",
"mimetype": "image/png"
}
],
"status": "success",
"messageId": "<80a431a1-bb4d-31b5-2564-93f8c2539fd4@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -1,41 +0,0 @@
{
"id": "ccdd8961-1b3f-4b88-b838-51d6ed8f1601",
"sentAt": "2025-10-02T08:47:03.481Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "한글테스트222",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">2</p>\r\n </div>\r\n ",
"attachments": [
{
"filename": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
"originalName": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
"size": 0,
"path": "/app/uploads/mail-attachments/1759394821751-229305880_UI__________________________.md",
"mimetype": "text/x-markdown"
},
{
"filename": "testáá­á¼ ááµááµááµ33.jpg",
"originalName": "testáá­á¼ ááµááµááµ33.jpg",
"size": 0,
"path": "/app/uploads/mail-attachments/1759394821751-335146895_test_______________33.jpg",
"mimetype": "image/jpeg"
},
{
"filename": "testáá­á¼ ááµááµááµ2.png",
"originalName": "testáá­á¼ ááµááµááµ2.png",
"size": 0,
"path": "/app/uploads/mail-attachments/1759394821753-911076131_test_______________2.png",
"mimetype": "image/png"
}
],
"status": "success",
"messageId": "<69519c70-a5cd-421d-9976-8c7014d69b39@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

File diff suppressed because one or more lines are too long

View File

@ -1,27 +0,0 @@
{
"id": "e93848a8-6901-44c4-b4db-27c8d2aeb8dd",
"sentAt": "2025-10-22T04:28:42.686Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"\"권은아\" <chna8137s@gmail.com>"
],
"subject": "Re: 매우 졸린 오후예요",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">호홋 답장 기능을 구현했다죵<br>얼른 퇴근하고 싪네여</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border-left: 3px solid #ccc; padding-left: 10px; margin-left: 10px; color: #666;\">\r\n <p><strong>보낸 사람:</strong> \"권은아\" <chna8137s@gmail.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:10:37</p>\r\n <p><strong>제목:</strong> 매우 졸린 오후예요</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\r\n ",
"attachments": [
{
"filename": "test용 이미지2.png",
"originalName": "test용 이미지2.png",
"size": 0,
"path": "/app/uploads/mail-attachments/1761107318152-717716316.png",
"mimetype": "image/png"
}
],
"status": "success",
"messageId": "<19981423-259b-0a50-e76d-23c860692c16@wace.me>",
"accepted": [
"chna8137s@gmail.com"
],
"rejected": []
}

View File

@ -1,41 +0,0 @@
{
"id": "ee0d162c-48ad-4c00-8c56-ade80be4503f",
"sentAt": "2025-10-02T08:48:29.740Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "한글한글",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
"attachments": [
{
"filename": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
"originalName": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
"size": 0,
"path": "/app/uploads/mail-attachments/1759394908877-38147683_UI__________________________.md",
"mimetype": "text/x-markdown"
},
{
"filename": "testáá­á¼ ááµááµááµ33.jpg",
"originalName": "testáá­á¼ ááµááµááµ33.jpg",
"size": 0,
"path": "/app/uploads/mail-attachments/1759394908879-80461065_test_______________33.jpg",
"mimetype": "image/jpeg"
},
{
"filename": "testáá­á¼ ááµááµááµ2.png",
"originalName": "testáá­á¼ ááµááµááµ2.png",
"size": 0,
"path": "/app/uploads/mail-attachments/1759394908880-475630926_test_______________2.png",
"mimetype": "image/png"
}
],
"status": "success",
"messageId": "<96205714-1a6b-adb7-7ae5-0e1e3fcb700b@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -1,29 +0,0 @@
{
"id": "fc26aba3-6b6e-47ba-91e8-609ae25e0e7d",
"sentAt": "2025-10-13T00:21:51.799Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"zian9227@naver.com"
],
"subject": "test용입니다.",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
"templateId": "template-1759302346758",
"templateName": "test",
"attachments": [
{
"filename": "웨이스-임직원-프로파일-이희진.key",
"originalName": "웨이스-임직원-프로파일-이희진.key",
"size": 0,
"path": "/app/uploads/mail-attachments/1760314910154-84512253.key",
"mimetype": "application/x-iwork-keynote-sffkey"
}
],
"status": "success",
"messageId": "<c84bcecc-2e8f-4a32-1b7f-44a91b195b2d@wace.me>",
"accepted": [
"zian9227@naver.com"
],
"rejected": []
}

View File

@ -1,18 +0,0 @@
{
"id": "fcea6149-a098-4212-aa00-baef0cc083d6",
"sentAt": "2025-10-22T04:24:54.126Z",
"accountId": "account-1759310844272",
"accountName": "이희진",
"accountEmail": "hjlee@wace.me",
"to": [
"\"DHS\" <ddhhss0603@gmail.com>"
],
"subject": "Re: 안녕하세여",
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">어떻게 가는지 궁금한데 이따가 화면 보여주세영</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border-left: 3px solid #ccc; padding-left: 10px; margin-left: 10px; color: #666;\">\r\n <p><strong>보낸 사람:</strong> \"DHS\" <ddhhss0603@gmail.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:09:49</p>\r\n <p><strong>제목:</strong> 안녕하세여</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\r\n ",
"status": "success",
"messageId": "<c24b04f0-b958-5e0b-4cc7-2bff30f23c2c@wace.me>",
"accepted": [
"ddhhss0603@gmail.com"
],
"rejected": []
}

View File

@ -71,6 +71,7 @@ import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리 import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색 import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
import orderRoutes from "./routes/orderRoutes"; // 수주 관리 import orderRoutes from "./routes/orderRoutes"; // 수주 관리
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
import { BatchSchedulerService } from "./services/batchSchedulerService"; import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@ -236,6 +237,7 @@ app.use("/api/code-merge", codeMergeRoutes); // 코드 병합
app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리 app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색 app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
app.use("/api/orders", orderRoutes); // 수주 관리 app.use("/api/orders", orderRoutes); // 수주 관리
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
// app.use("/api/collections", collectionRoutes); // 임시 주석 // app.use("/api/collections", collectionRoutes); // 임시 주석
// app.use("/api/batch", batchRoutes); // 임시 주석 // app.use("/api/batch", batchRoutes); // 임시 주석
// app.use('/api/users', userRoutes); // app.use('/api/users', userRoutes);
@ -280,7 +282,7 @@ app.listen(PORT, HOST, async () => {
// 배치 스케줄러 초기화 // 배치 스케줄러 초기화
try { try {
await BatchSchedulerService.initialize(); await BatchSchedulerService.initializeScheduler();
logger.info(`⏰ 배치 스케줄러가 시작되었습니다.`); logger.info(`⏰ 배치 스케줄러가 시작되었습니다.`);
} catch (error) { } catch (error) {
logger.error(`❌ 배치 스케줄러 초기화 실패:`, error); logger.error(`❌ 배치 스케줄러 초기화 실패:`, error);

View File

@ -1,4 +1,7 @@
import { Response } from "express"; import { Response } from "express";
import https from "https";
import axios, { AxiosRequestConfig } from "axios";
import { logger } from "../utils/logger";
import { AuthenticatedRequest } from "../middleware/authMiddleware"; import { AuthenticatedRequest } from "../middleware/authMiddleware";
import { DashboardService } from "../services/DashboardService"; import { DashboardService } from "../services/DashboardService";
import { import {
@ -7,6 +10,7 @@ import {
DashboardListQuery, DashboardListQuery,
} from "../types/dashboard"; } from "../types/dashboard";
import { PostgreSQLService } from "../database/PostgreSQLService"; import { PostgreSQLService } from "../database/PostgreSQLService";
import { ExternalRestApiConnectionService } from "../services/externalRestApiConnectionService";
/** /**
* *
@ -415,7 +419,7 @@ export class DashboardController {
limit: Math.min(parseInt(req.query.limit as string) || 20, 100), limit: Math.min(parseInt(req.query.limit as string) || 20, 100),
search: req.query.search as string, search: req.query.search as string,
category: req.query.category as string, category: req.query.category as string,
createdBy: userId, // 본인이 만든 대시보드만 // createdBy 제거 - 회사 대시보드 전체 표시
}; };
const result = await DashboardService.getDashboards( const result = await DashboardService.getDashboards(
@ -590,7 +594,14 @@ export class DashboardController {
res: Response res: Response
): Promise<void> { ): Promise<void> {
try { try {
const { url, method = "GET", headers = {}, queryParams = {} } = req.body; const {
url,
method = "GET",
headers = {},
queryParams = {},
body,
externalConnectionId, // 프론트엔드에서 선택된 커넥션 ID를 전달받아야 함
} = req.body;
if (!url || typeof url !== "string") { if (!url || typeof url !== "string") {
res.status(400).json({ res.status(400).json({
@ -608,85 +619,131 @@ export class DashboardController {
} }
}); });
// 외부 API 호출 (타임아웃 30초) // Axios 요청 설정
// @ts-ignore - node-fetch dynamic import const requestConfig: AxiosRequestConfig = {
const fetch = (await import("node-fetch")).default; url: urlObj.toString(),
method: method.toUpperCase(),
// 타임아웃 설정 (Node.js 글로벌 AbortController 사용) headers: {
const controller = new (global as any).AbortController(); "Content-Type": "application/json",
const timeoutId = setTimeout(() => controller.abort(), 60000); // 60초 (기상청 API는 느림) Accept: "application/json",
...headers,
let response; },
try { timeout: 60000, // 60초 타임아웃
response = await fetch(urlObj.toString(), { validateStatus: () => true, // 모든 상태 코드 허용 (에러도 응답으로 처리)
method: method.toUpperCase(), };
headers: {
"Content-Type": "application/json", // 외부 커넥션 ID가 있는 경우, 해당 커넥션의 인증 정보(DB 토큰 등)를 적용
...headers, if (externalConnectionId) {
}, try {
signal: controller.signal, // 사용자 회사 코드가 있으면 사용하고, 없으면 '*' (최고 관리자)로 시도
}); let companyCode = req.user?.companyCode;
clearTimeout(timeoutId);
} catch (err: any) { if (!companyCode) {
clearTimeout(timeoutId); companyCode = "*";
if (err.name === 'AbortError') { }
throw new Error('외부 API 요청 타임아웃 (30초 초과)');
// 커넥션 로드
const connectionResult =
await ExternalRestApiConnectionService.getConnectionById(
Number(externalConnectionId),
companyCode
);
if (connectionResult.success && connectionResult.data) {
const connection = connectionResult.data;
// 인증 헤더 생성 (DB 토큰 등)
const authHeaders =
await ExternalRestApiConnectionService.getAuthHeaders(
connection.auth_type,
connection.auth_config,
connection.company_code
);
// 기존 헤더에 인증 헤더 병합
requestConfig.headers = {
...requestConfig.headers,
...authHeaders,
};
// API Key가 Query Param인 경우 처리
if (
connection.auth_type === "api-key" &&
connection.auth_config?.keyLocation === "query" &&
connection.auth_config?.keyName &&
connection.auth_config?.keyValue
) {
const currentUrl = new URL(requestConfig.url!);
currentUrl.searchParams.append(
connection.auth_config.keyName,
connection.auth_config.keyValue
);
requestConfig.url = currentUrl.toString();
}
}
} catch (connError) {
logger.error(
`외부 커넥션(${externalConnectionId}) 정보 로드 및 인증 적용 실패:`,
connError
);
} }
throw err;
} }
if (!response.ok) { // Body 처리
if (body) {
requestConfig.data = body;
}
// TLS 인증서 검증 예외 처리 (thiratis.com 등 내부망/레거시 API 대응)
// ExternalRestApiConnectionService와 동일한 로직 적용
const bypassDomains = ["thiratis.com"];
const hostname = urlObj.hostname;
const shouldBypassTls = bypassDomains.some((domain) =>
hostname.includes(domain)
);
if (shouldBypassTls) {
requestConfig.httpsAgent = new https.Agent({
rejectUnauthorized: false,
});
}
const response = await axios(requestConfig);
if (response.status >= 400) {
throw new Error( throw new Error(
`외부 API 오류: ${response.status} ${response.statusText}` `외부 API 오류: ${response.status} ${response.statusText}`
); );
} }
// Content-Type에 따라 응답 파싱 let data = response.data;
const contentType = response.headers.get("content-type"); const contentType = response.headers["content-type"];
let data: any;
// 한글 인코딩 처리 (EUC-KR → UTF-8) // 텍스트 응답인 경우 포맷팅
const isKoreanApi = urlObj.hostname.includes('kma.go.kr') || if (typeof data === "string") {
urlObj.hostname.includes('data.go.kr'); data = { text: data, contentType };
if (isKoreanApi) {
// 한국 정부 API는 EUC-KR 인코딩 사용
const buffer = await response.arrayBuffer();
const decoder = new TextDecoder('euc-kr');
const text = decoder.decode(buffer);
try {
data = JSON.parse(text);
} catch {
data = { text, contentType };
}
} else if (contentType && contentType.includes("application/json")) {
data = await response.json();
} else if (contentType && contentType.includes("text/")) {
// 텍스트 응답 (CSV, 일반 텍스트 등)
const text = await response.text();
data = { text, contentType };
} else {
// 기타 응답 (JSON으로 시도)
try {
data = await response.json();
} catch {
const text = await response.text();
data = { text, contentType };
}
} }
res.status(200).json({ res.status(200).json({
success: true, success: true,
data, data,
}); });
} catch (error) { } catch (error: any) {
const status = error.response?.status || 500;
const message = error.response?.statusText || error.message;
logger.error("외부 API 호출 오류:", {
message,
status,
data: error.response?.data,
});
res.status(500).json({ res.status(500).json({
success: false, success: false,
message: "외부 API 호출 중 오류가 발생했습니다.", message: "외부 API 호출 중 오류가 발생했습니다.",
error: error:
process.env.NODE_ENV === "development" process.env.NODE_ENV === "development"
? (error as Error).message ? message
: "외부 API 호출 오류", : "외부 API 호출 오류",
}); });
} }

View File

@ -169,22 +169,18 @@ export class BatchController {
static async getBatchConfigById(req: AuthenticatedRequest, res: Response) { static async getBatchConfigById(req: AuthenticatedRequest, res: Response) {
try { try {
const { id } = req.params; const { id } = req.params;
const userCompanyCode = req.user?.companyCode; const result = await BatchService.getBatchConfigById(Number(id));
const batchConfig = await BatchService.getBatchConfigById(
Number(id),
userCompanyCode
);
if (!batchConfig) { if (!result.success || !result.data) {
return res.status(404).json({ return res.status(404).json({
success: false, success: false,
message: "배치 설정을 찾을 수 없습니다.", message: result.message || "배치 설정을 찾을 수 없습니다.",
}); });
} }
return res.json({ return res.json({
success: true, success: true,
data: batchConfig, data: result.data,
}); });
} catch (error) { } catch (error) {
console.error("배치 설정 조회 오류:", error); console.error("배치 설정 조회 오류:", error);

View File

@ -62,6 +62,11 @@ export class BatchExecutionLogController {
try { try {
const data: CreateBatchExecutionLogRequest = req.body; const data: CreateBatchExecutionLogRequest = req.body;
// 멀티테넌시: company_code가 없으면 현재 사용자 회사 코드로 설정
if (!data.company_code) {
data.company_code = req.user?.companyCode || "*";
}
const result = await BatchExecutionLogService.createExecutionLog(data); const result = await BatchExecutionLogService.createExecutionLog(data);
if (result.success) { if (result.success) {

View File

@ -265,8 +265,12 @@ export class BatchManagementController {
try { try {
// 실행 로그 생성 // 실행 로그 생성
executionLog = await BatchService.createExecutionLog({ const { BatchExecutionLogService } = await import(
"../services/batchExecutionLogService"
);
const logResult = await BatchExecutionLogService.createExecutionLog({
batch_config_id: Number(id), batch_config_id: Number(id),
company_code: batchConfig.company_code,
execution_status: "RUNNING", execution_status: "RUNNING",
start_time: startTime, start_time: startTime,
total_records: 0, total_records: 0,
@ -274,6 +278,14 @@ export class BatchManagementController {
failed_records: 0, failed_records: 0,
}); });
if (!logResult.success || !logResult.data) {
throw new Error(
logResult.message || "배치 실행 로그를 생성할 수 없습니다."
);
}
executionLog = logResult.data;
// BatchSchedulerService의 executeBatchConfig 메서드 사용 (중복 로직 제거) // BatchSchedulerService의 executeBatchConfig 메서드 사용 (중복 로직 제거)
const { BatchSchedulerService } = await import( const { BatchSchedulerService } = await import(
"../services/batchSchedulerService" "../services/batchSchedulerService"
@ -290,7 +302,7 @@ export class BatchManagementController {
const duration = endTime.getTime() - startTime.getTime(); const duration = endTime.getTime() - startTime.getTime();
// 실행 로그 업데이트 (성공) // 실행 로그 업데이트 (성공)
await BatchService.updateExecutionLog(executionLog.id, { await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
execution_status: "SUCCESS", execution_status: "SUCCESS",
end_time: endTime, end_time: endTime,
duration_ms: duration, duration_ms: duration,
@ -406,22 +418,34 @@ export class BatchManagementController {
paramName, paramName,
paramValue, paramValue,
paramSource, paramSource,
requestBody,
} = req.body; } = req.body;
if (!apiUrl || !apiKey || !endpoint) { // apiUrl, endpoint는 항상 필수
if (!apiUrl || !endpoint) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: "API URL, API Key, 엔드포인트는 필수입니다.", message: "API URL과 엔드포인트는 필수입니다.",
});
}
// GET 요청일 때만 API Key 필수 (POST/PUT/DELETE는 선택)
if ((!method || method === "GET") && !apiKey) {
return res.status(400).json({
success: false,
message: "GET 메서드에서는 API Key가 필요합니다.",
}); });
} }
console.log("🔍 REST API 미리보기 요청:", { console.log("🔍 REST API 미리보기 요청:", {
apiUrl, apiUrl,
endpoint, endpoint,
method,
paramType, paramType,
paramName, paramName,
paramValue, paramValue,
paramSource, paramSource,
requestBody: requestBody ? "Included" : "None",
}); });
// RestApiConnector 사용하여 데이터 조회 // RestApiConnector 사용하여 데이터 조회
@ -429,7 +453,7 @@ export class BatchManagementController {
const connector = new RestApiConnector({ const connector = new RestApiConnector({
baseUrl: apiUrl, baseUrl: apiUrl,
apiKey: apiKey, apiKey: apiKey || "",
timeout: 30000, timeout: 30000,
}); });
@ -456,9 +480,28 @@ export class BatchManagementController {
console.log("🔗 최종 엔드포인트:", finalEndpoint); console.log("🔗 최종 엔드포인트:", finalEndpoint);
// 데이터 조회 (최대 5개만) - GET 메서드만 지원 // Request Body 파싱
const result = await connector.executeQuery(finalEndpoint, method); let parsedBody = undefined;
console.log(`[previewRestApiData] executeQuery 결과:`, { if (requestBody && typeof requestBody === "string") {
try {
parsedBody = JSON.parse(requestBody);
} catch (e) {
console.warn("Request Body JSON 파싱 실패:", e);
// 파싱 실패 시 원본 문자열 사용하거나 무시 (상황에 따라 결정, 여기선 undefined로 처리하거나 에러 반환 가능)
// 여기서는 경고 로그 남기고 진행
}
} else if (requestBody) {
parsedBody = requestBody;
}
// 데이터 조회 - executeRequest 사용 (POST/PUT/DELETE 지원)
const result = await connector.executeRequest(
finalEndpoint,
method as "GET" | "POST" | "PUT" | "DELETE",
parsedBody
);
console.log(`[previewRestApiData] executeRequest 결과:`, {
rowCount: result.rowCount, rowCount: result.rowCount,
rowsLength: result.rows ? result.rows.length : "undefined", rowsLength: result.rows ? result.rows.length : "undefined",
firstRow: firstRow:
@ -532,20 +575,26 @@ export class BatchManagementController {
apiMappings, apiMappings,
}); });
// 🔐 멀티테넌시: 현재 사용자 회사 코드 사용 (프론트에서 받지 않음)
const companyCode = req.user?.companyCode || "*";
const userId = req.user?.userId;
// BatchService를 사용하여 배치 설정 저장 // BatchService를 사용하여 배치 설정 저장
const batchConfig: CreateBatchConfigRequest = { const batchConfig: CreateBatchConfigRequest = {
batchName: batchName, batchName: batchName,
description: description || "", description: description || "",
cronSchedule: cronSchedule, cronSchedule: cronSchedule,
isActive: "Y",
companyCode,
mappings: apiMappings, mappings: apiMappings,
}; };
const result = await BatchService.createBatchConfig(batchConfig); const result = await BatchService.createBatchConfig(batchConfig, userId);
if (result.success && result.data) { if (result.success && result.data) {
// 스케줄러에 자동 등록 ✅ // 스케줄러에 자동 등록 ✅
try { try {
await BatchSchedulerService.scheduleBatchConfig(result.data); await BatchSchedulerService.scheduleBatch(result.data);
console.log( console.log(
`✅ 새로운 배치가 스케줄러에 등록되었습니다: ${batchName} (ID: ${result.data.id})` `✅ 새로운 배치가 스케줄러에 등록되었습니다: ${batchName} (ID: ${result.data.id})`
); );

View File

@ -22,11 +22,19 @@ export const getLayouts = async (
LEFT JOIN user_info u1 ON l.created_by = u1.user_id LEFT JOIN user_info u1 ON l.created_by = u1.user_id
LEFT JOIN user_info u2 ON l.updated_by = u2.user_id LEFT JOIN user_info u2 ON l.updated_by = u2.user_id
LEFT JOIN digital_twin_objects o ON l.id = o.layout_id LEFT JOIN digital_twin_objects o ON l.id = o.layout_id
WHERE l.company_code = $1
`; `;
const params: any[] = [companyCode]; const params: any[] = [];
let paramIndex = 2; let paramIndex = 1;
// 최고 관리자는 모든 레이아웃 조회 가능
if (companyCode && companyCode !== '*') {
query += ` WHERE l.company_code = $${paramIndex}`;
params.push(companyCode);
paramIndex++;
} else {
query += ` WHERE 1=1`;
}
if (externalDbConnectionId) { if (externalDbConnectionId) {
query += ` AND l.external_db_connection_id = $${paramIndex}`; query += ` AND l.external_db_connection_id = $${paramIndex}`;
@ -75,14 +83,27 @@ export const getLayoutById = async (
const companyCode = req.user?.companyCode; const companyCode = req.user?.companyCode;
const { id } = req.params; const { id } = req.params;
// 레이아웃 기본 정보 // 레이아웃 기본 정보 - 최고 관리자는 모든 레이아웃 조회 가능
const layoutQuery = ` let layoutQuery: string;
SELECT l.* let layoutParams: any[];
FROM digital_twin_layout l
WHERE l.id = $1 AND l.company_code = $2
`;
const layoutResult = await pool.query(layoutQuery, [id, companyCode]); if (companyCode && companyCode !== '*') {
layoutQuery = `
SELECT l.*
FROM digital_twin_layout l
WHERE l.id = $1 AND l.company_code = $2
`;
layoutParams = [id, companyCode];
} else {
layoutQuery = `
SELECT l.*
FROM digital_twin_layout l
WHERE l.id = $1
`;
layoutParams = [id];
}
const layoutResult = await pool.query(layoutQuery, layoutParams);
if (layoutResult.rowCount === 0) { if (layoutResult.rowCount === 0) {
return res.status(404).json({ return res.status(404).json({

View File

@ -161,3 +161,4 @@ export const createMappingTemplate = async (

View File

@ -203,7 +203,7 @@ export const updateFormDataPartial = async (
}; };
const result = await dynamicFormService.updateFormDataPartial( const result = await dynamicFormService.updateFormDataPartial(
parseInt(id), id, // 🔧 parseInt 제거 - UUID 문자열도 지원
tableName, tableName,
originalData, originalData,
newDataWithMeta newDataWithMeta
@ -419,3 +419,66 @@ export const getTableColumns = async (
}); });
} }
}; };
// 특정 필드만 업데이트 (다른 테이블 지원)
export const updateFieldValue = async (
req: AuthenticatedRequest,
res: Response
): Promise<Response | void> => {
try {
const { companyCode, userId } = req.user as any;
const { tableName, keyField, keyValue, updateField, updateValue } = req.body;
console.log("🔄 [updateFieldValue] 요청:", {
tableName,
keyField,
keyValue,
updateField,
updateValue,
userId,
companyCode,
});
// 필수 필드 검증
if (!tableName || !keyField || keyValue === undefined || !updateField || updateValue === undefined) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다. (tableName, keyField, keyValue, updateField, updateValue)",
});
}
// SQL 인젝션 방지를 위한 테이블명/컬럼명 검증
const validNamePattern = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
if (!validNamePattern.test(tableName) || !validNamePattern.test(keyField) || !validNamePattern.test(updateField)) {
return res.status(400).json({
success: false,
message: "유효하지 않은 테이블명 또는 컬럼명입니다.",
});
}
// 업데이트 쿼리 실행
const result = await dynamicFormService.updateFieldValue(
tableName,
keyField,
keyValue,
updateField,
updateValue,
companyCode,
userId
);
console.log("✅ [updateFieldValue] 성공:", result);
res.json({
success: true,
data: result,
message: "필드 값이 업데이트되었습니다.",
});
} catch (error: any) {
console.error("❌ [updateFieldValue] 실패:", error);
res.status(500).json({
success: false,
message: error.message || "필드 업데이트에 실패했습니다.",
});
}
};

View File

@ -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();
}
}

View File

@ -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 * 2
* *

View File

@ -1,4 +1,5 @@
import axios, { AxiosInstance, AxiosResponse } from "axios"; import axios, { AxiosInstance, AxiosResponse } from "axios";
import https from "https";
import { import {
DatabaseConnector, DatabaseConnector,
ConnectionConfig, ConnectionConfig,
@ -24,16 +25,26 @@ export class RestApiConnector implements DatabaseConnector {
constructor(config: RestApiConfig) { constructor(config: RestApiConfig) {
this.config = config; this.config = config;
// Axios 인스턴스 생성 // Axios 인스턴스 생성
// 🔐 apiKey가 없을 수도 있으므로 Authorization 헤더는 선택적으로만 추가
const defaultHeaders: Record<string, string> = {
"Content-Type": "application/json",
Accept: "application/json",
};
if (config.apiKey) {
defaultHeaders["Authorization"] = `Bearer ${config.apiKey}`;
}
this.httpClient = axios.create({ this.httpClient = axios.create({
baseURL: config.baseUrl, baseURL: config.baseUrl,
timeout: config.timeout || 30000, timeout: config.timeout || 30000,
headers: { headers: defaultHeaders,
"Content-Type": "application/json", // ⚠️ 외부 API 중 자체 서명 인증서를 사용하는 경우가 있어서
Authorization: `Bearer ${config.apiKey}`, // 인증서 검증을 끈 HTTPS 에이전트를 사용한다.
Accept: "application/json", // 내부망/신뢰된 시스템 전용으로 사용해야 하며,
}, // 공개 인터넷용 API에는 적용하면 안 된다.
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
}); });
// 요청/응답 인터셉터 설정 // 요청/응답 인터셉터 설정
@ -75,26 +86,16 @@ export class RestApiConnector implements DatabaseConnector {
} }
async connect(): Promise<void> { async connect(): Promise<void> {
try { // 기존에는 /health 엔드포인트를 호출해서 미리 연결을 검사했지만,
// 연결 테스트 - 기본 엔드포인트 호출 // 일반 외부 API들은 /health가 없거나 401/500을 반환하는 경우가 많아
await this.httpClient.get("/health", { timeout: 5000 }); // 불필요하게 예외가 나면서 미리보기/배치 실행이 막히는 문제가 있었다.
console.log(`[RestApiConnector] 연결 성공: ${this.config.baseUrl}`); //
} catch (error) { // 따라서 여기서는 "연결 준비 완료" 정도만 로그로 남기고
// health 엔드포인트가 없을 수 있으므로 404는 정상으로 처리 // 실제 호출 실패 여부는 executeRequest 단계에서만 판단하도록 한다.
if (axios.isAxiosError(error) && error.response?.status === 404) { console.log(
console.log( `[RestApiConnector] 연결 준비 완료 (사전 헬스체크 생략): ${this.config.baseUrl}`
`[RestApiConnector] 연결 성공 (health 엔드포인트 없음): ${this.config.baseUrl}` );
); return;
return;
}
console.error(
`[RestApiConnector] 연결 실패: ${this.config.baseUrl}`,
error
);
throw new Error(
`REST API 연결 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`
);
}
} }
async disconnect(): Promise<void> { async disconnect(): Promise<void> {

View File

@ -5,6 +5,7 @@ import {
saveFormDataEnhanced, saveFormDataEnhanced,
updateFormData, updateFormData,
updateFormDataPartial, updateFormDataPartial,
updateFieldValue,
deleteFormData, deleteFormData,
getFormData, getFormData,
getFormDataList, getFormDataList,
@ -23,6 +24,7 @@ router.post("/save", saveFormData); // 기존 버전 (레거시 지원)
router.post("/save-enhanced", saveFormDataEnhanced); // 개선된 버전 router.post("/save-enhanced", saveFormDataEnhanced); // 개선된 버전
router.put("/:id", updateFormData); router.put("/:id", updateFormData);
router.patch("/:id/partial", updateFormDataPartial); // 부분 업데이트 router.patch("/:id/partial", updateFormDataPartial); // 부분 업데이트
router.put("/update-field", updateFieldValue); // 특정 필드만 업데이트 (다른 테이블 지원)
router.delete("/:id", deleteFormData); router.delete("/:id", deleteFormData);
router.get("/:id", getFormData); router.get("/:id", getFormData);

View File

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

View File

@ -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;

View File

@ -11,6 +11,7 @@ import {
createColumnMapping, createColumnMapping,
getLogicalColumns, getLogicalColumns,
deleteColumnMapping, deleteColumnMapping,
deleteColumnMappingsByColumn,
getSecondLevelMenus, getSecondLevelMenus,
} from "../controllers/tableCategoryValueController"; } from "../controllers/tableCategoryValueController";
import { authenticateToken } from "../middleware/authMiddleware"; import { authenticateToken } from "../middleware/authMiddleware";
@ -57,7 +58,11 @@ router.get("/logical-columns/:tableName/:menuObjid", getLogicalColumns);
// 컬럼 매핑 생성/수정 // 컬럼 매핑 생성/수정
router.post("/column-mapping", createColumnMapping); router.post("/column-mapping", createColumnMapping);
// 컬럼 매핑 삭제 // 테이블+컬럼 기준 매핑 삭제 (메뉴 선택 변경 시 기존 매핑 모두 삭제용)
// 주의: 더 구체적인 라우트가 먼저 와야 함 (3개 세그먼트 > 1개 세그먼트)
router.delete("/column-mapping/:tableName/:columnName/all", deleteColumnMappingsByColumn);
// 컬럼 매핑 삭제 (단일)
router.delete("/column-mapping/:mappingId", deleteColumnMapping); router.delete("/column-mapping/:mappingId", deleteColumnMapping);
export default router; export default router;

View File

@ -178,21 +178,24 @@ export class DashboardService {
let params: any[] = []; let params: any[] = [];
let paramIndex = 1; let paramIndex = 1;
// 회사 코드 필터링 (최우선) // 회사 코드 필터링 - company_code가 일치하면 해당 회사 사용자는 모두 조회 가능
if (companyCode) { if (companyCode) {
whereConditions.push(`d.company_code = $${paramIndex}`); if (companyCode === '*') {
params.push(companyCode); // 최고 관리자는 모든 대시보드 조회 가능
paramIndex++; } else {
} whereConditions.push(`d.company_code = $${paramIndex}`);
params.push(companyCode);
// 권한 필터링 paramIndex++;
if (userId) { }
} else if (userId) {
// 회사 코드 없이 userId만 있는 경우 (본인 생성 또는 공개)
whereConditions.push( whereConditions.push(
`(d.created_by = $${paramIndex} OR d.is_public = true)` `(d.created_by = $${paramIndex} OR d.is_public = true)`
); );
params.push(userId); params.push(userId);
paramIndex++; paramIndex++;
} else { } else {
// 비로그인 사용자는 공개 대시보드만
whereConditions.push("d.is_public = true"); whereConditions.push("d.is_public = true");
} }
@ -228,7 +231,7 @@ export class DashboardService {
const whereClause = whereConditions.join(" AND "); const whereClause = whereConditions.join(" AND ");
// 대시보드 목록 조회 (users 테이블 조인 제거) // 대시보드 목록 조회 (user_info 조인하여 생성자 이름 포함)
const dashboardQuery = ` const dashboardQuery = `
SELECT SELECT
d.id, d.id,
@ -242,13 +245,16 @@ export class DashboardService {
d.tags, d.tags,
d.category, d.category,
d.view_count, d.view_count,
d.company_code,
u.user_name as created_by_name,
COUNT(de.id) as elements_count COUNT(de.id) as elements_count
FROM dashboards d FROM dashboards d
LEFT JOIN dashboard_elements de ON d.id = de.dashboard_id LEFT JOIN dashboard_elements de ON d.id = de.dashboard_id
LEFT JOIN user_info u ON d.created_by = u.user_id
WHERE ${whereClause} WHERE ${whereClause}
GROUP BY d.id, d.title, d.description, d.thumbnail_url, d.is_public, GROUP BY d.id, d.title, d.description, d.thumbnail_url, d.is_public,
d.created_by, d.created_at, d.updated_at, d.tags, d.category, d.created_by, d.created_at, d.updated_at, d.tags, d.category,
d.view_count d.view_count, d.company_code, u.user_name
ORDER BY d.updated_at DESC ORDER BY d.updated_at DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1} LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`; `;
@ -277,12 +283,14 @@ export class DashboardService {
thumbnailUrl: row.thumbnail_url, thumbnailUrl: row.thumbnail_url,
isPublic: row.is_public, isPublic: row.is_public,
createdBy: row.created_by, createdBy: row.created_by,
createdByName: row.created_by_name || row.created_by,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
tags: JSON.parse(row.tags || "[]"), tags: JSON.parse(row.tags || "[]"),
category: row.category, category: row.category,
viewCount: parseInt(row.view_count || "0"), viewCount: parseInt(row.view_count || "0"),
elementsCount: parseInt(row.elements_count || "0"), elementsCount: parseInt(row.elements_count || "0"),
companyCode: row.company_code,
})), })),
pagination: { pagination: {
page, page,
@ -299,6 +307,8 @@ export class DashboardService {
/** /**
* *
* - company_code가
* - company_code가 '*'
*/ */
static async getDashboardById( static async getDashboardById(
dashboardId: string, dashboardId: string,
@ -310,44 +320,43 @@ export class DashboardService {
let dashboardQuery: string; let dashboardQuery: string;
let dashboardParams: any[]; let dashboardParams: any[];
if (userId) { if (companyCode) {
if (companyCode) { // 회사 코드가 있으면 해당 회사 대시보드 또는 공개 대시보드 조회 가능
// 최고 관리자(companyCode = '*')는 모든 대시보드 조회 가능
if (companyCode === '*') {
dashboardQuery = ` dashboardQuery = `
SELECT d.* SELECT d.*
FROM dashboards d FROM dashboards d
WHERE d.id = $1 AND d.deleted_at IS NULL 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]; 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( const dashboardResult = await PostgreSQLService.query(

View File

@ -170,3 +170,4 @@ export class DigitalTwinTemplateService {

View File

@ -130,13 +130,14 @@ export class BatchExecutionLogService {
try { try {
const log = await queryOne<BatchExecutionLog>( const log = await queryOne<BatchExecutionLog>(
`INSERT INTO batch_execution_logs ( `INSERT INTO batch_execution_logs (
batch_config_id, execution_status, start_time, end_time, batch_config_id, company_code, execution_status, start_time, end_time,
duration_ms, total_records, success_records, failed_records, duration_ms, total_records, success_records, failed_records,
error_message, error_details, server_name, process_id error_message, error_details, server_name, process_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING *`, RETURNING *`,
[ [
data.batch_config_id, data.batch_config_id,
data.company_code,
data.execution_status, data.execution_status,
data.start_time || new Date(), data.start_time || new Date(),
data.end_time, data.end_time,

File diff suppressed because it is too large Load Diff

View File

@ -1,258 +1,114 @@
// 배치 스케줄러 서비스 import cron from "node-cron";
// 작성일: 2024-12-24
import * as cron from "node-cron";
import { query, queryOne } from "../database/db";
import { BatchService } from "./batchService"; import { BatchService } from "./batchService";
import { BatchExecutionLogService } from "./batchExecutionLogService"; import { BatchExecutionLogService } from "./batchExecutionLogService";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
export class BatchSchedulerService { export class BatchSchedulerService {
private static scheduledTasks: Map<number, cron.ScheduledTask> = new Map(); private static scheduledTasks: Map<number, cron.ScheduledTask> = new Map();
private static isInitialized = false;
private static executingBatches: Set<number> = new Set(); // 실행 중인 배치 추적
/** /**
* *
*/ */
static async initialize() { static async initializeScheduler() {
try { try {
logger.info("배치 스케줄러 초기화 시작..."); logger.info("배치 스케줄러 초기화 시작");
// 기존 모든 스케줄 정리 (중복 방지) const batchConfigsResponse = await BatchService.getBatchConfigs({
this.clearAllSchedules(); is_active: "Y",
});
// 활성화된 배치 설정들을 로드하여 스케줄 등록 if (!batchConfigsResponse.success || !batchConfigsResponse.data) {
await this.loadActiveBatchConfigs(); logger.warn("스케줄링할 활성 배치 설정이 없습니다.");
this.isInitialized = true;
logger.info("배치 스케줄러 초기화 완료");
} catch (error) {
logger.error("배치 스케줄러 초기화 실패:", error);
throw error;
}
}
/**
*
*/
private static clearAllSchedules() {
logger.info(`기존 스케줄 ${this.scheduledTasks.size}개 정리 중...`);
for (const [id, task] of this.scheduledTasks) {
try {
task.stop();
task.destroy();
logger.info(`스케줄 정리 완료: ID ${id}`);
} catch (error) {
logger.error(`스케줄 정리 실패: ID ${id}`, error);
}
}
this.scheduledTasks.clear();
this.isInitialized = false;
logger.info("모든 스케줄 정리 완료");
}
/**
*
*/
private static async loadActiveBatchConfigs() {
try {
const activeConfigs = await query<any>(
`SELECT
bc.*,
json_agg(
json_build_object(
'id', bm.id,
'batch_config_id', bm.batch_config_id,
'from_connection_type', bm.from_connection_type,
'from_connection_id', bm.from_connection_id,
'from_table_name', bm.from_table_name,
'from_column_name', bm.from_column_name,
'from_column_type', bm.from_column_type,
'to_connection_type', bm.to_connection_type,
'to_connection_id', bm.to_connection_id,
'to_table_name', bm.to_table_name,
'to_column_name', bm.to_column_name,
'to_column_type', bm.to_column_type,
'mapping_order', bm.mapping_order,
'from_api_url', bm.from_api_url,
'from_api_key', bm.from_api_key,
'from_api_method', bm.from_api_method,
'from_api_param_type', bm.from_api_param_type,
'from_api_param_name', bm.from_api_param_name,
'from_api_param_value', bm.from_api_param_value,
'from_api_param_source', bm.from_api_param_source,
'to_api_url', bm.to_api_url,
'to_api_key', bm.to_api_key,
'to_api_method', bm.to_api_method,
'to_api_body', bm.to_api_body
)
) FILTER (WHERE bm.id IS NOT NULL) as batch_mappings
FROM batch_configs bc
LEFT JOIN batch_mappings bm ON bc.id = bm.batch_config_id
WHERE bc.is_active = 'Y'
GROUP BY bc.id`,
[]
);
logger.info(`활성화된 배치 설정 ${activeConfigs.length}개 발견`);
for (const config of activeConfigs) {
await this.scheduleBatchConfig(config);
}
} catch (error) {
logger.error("활성화된 배치 설정 로드 실패:", error);
throw error;
}
}
/**
*
*/
static async scheduleBatchConfig(config: any) {
try {
const { id, batch_name, cron_schedule } = config;
// 기존 스케줄이 있다면 제거
if (this.scheduledTasks.has(id)) {
this.scheduledTasks.get(id)?.stop();
this.scheduledTasks.delete(id);
}
// cron 스케줄 유효성 검사
if (!cron.validate(cron_schedule)) {
logger.error(`잘못된 cron 스케줄: ${cron_schedule} (배치 ID: ${id})`);
return; return;
} }
// 새로운 스케줄 등록 const batchConfigs = batchConfigsResponse.data;
const task = cron.schedule(cron_schedule, async () => { logger.info(`${batchConfigs.length}개의 배치 설정 스케줄링 등록`);
// 중복 실행 방지 체크
if (this.executingBatches.has(id)) {
logger.warn(
`⚠️ 배치가 이미 실행 중입니다. 건너뜀: ${batch_name} (ID: ${id})`
);
return;
}
logger.info(`🔄 스케줄 배치 실행 시작: ${batch_name} (ID: ${id})`); for (const config of batchConfigs) {
await this.scheduleBatch(config);
}
// 실행 중 플래그 설정 logger.info("배치 스케줄러 초기화 완료");
this.executingBatches.add(id); } catch (error) {
logger.error("배치 스케줄러 초기화 중 오류 발생:", error);
}
}
try { /**
await this.executeBatchConfig(config); *
} finally { */
// 실행 완료 후 플래그 제거 static async scheduleBatch(config: any) {
this.executingBatches.delete(id); try {
} // 기존 스케줄이 있으면 제거
if (this.scheduledTasks.has(config.id)) {
this.scheduledTasks.get(config.id)?.stop();
this.scheduledTasks.delete(config.id);
}
if (config.is_active !== "Y") {
logger.info(
`배치 스케줄링 건너뜀 (비활성 상태): ${config.batch_name} (ID: ${config.id})`
);
return;
}
if (!cron.validate(config.cron_schedule)) {
logger.error(
`유효하지 않은 Cron 표현식: ${config.cron_schedule} (Batch ID: ${config.id})`
);
return;
}
logger.info(
`배치 스케줄 등록: ${config.batch_name} (ID: ${config.id}, Cron: ${config.cron_schedule})`
);
const task = cron.schedule(config.cron_schedule, async () => {
logger.info(
`스케줄에 의한 배치 실행 시작: ${config.batch_name} (ID: ${config.id})`
);
await this.executeBatchConfig(config);
}); });
// 스케줄 시작 (기본적으로 시작되지만 명시적으로 호출) this.scheduledTasks.set(config.id, task);
task.start();
this.scheduledTasks.set(id, task);
logger.info(
`배치 스케줄 등록 완료: ${batch_name} (ID: ${id}, Schedule: ${cron_schedule}) - 스케줄 시작됨`
);
} catch (error) { } catch (error) {
logger.error(`배치 스케줄 등록 실패 (ID: ${config.id}):`, error); logger.error(`배치 스케줄링 중 오류 발생 (ID: ${config.id}):`, error);
} }
} }
/** /**
* * ( )
*/
static async unscheduleBatchConfig(batchConfigId: number) {
try {
if (this.scheduledTasks.has(batchConfigId)) {
this.scheduledTasks.get(batchConfigId)?.stop();
this.scheduledTasks.delete(batchConfigId);
logger.info(`배치 스케줄 제거 완료 (ID: ${batchConfigId})`);
}
} catch (error) {
logger.error(`배치 스케줄 제거 실패 (ID: ${batchConfigId}):`, error);
}
}
/**
*
*/ */
static async updateBatchSchedule( static async updateBatchSchedule(
configId: number, configId: number,
executeImmediately: boolean = true executeImmediately: boolean = true
) { ) {
try { try {
// 기존 스케줄 제거 const result = await BatchService.getBatchConfigById(configId);
await this.unscheduleBatchConfig(configId); if (!result.success || !result.data) {
// 설정이 없으면 스케줄 제거
// 업데이트된 배치 설정 조회 if (this.scheduledTasks.has(configId)) {
const configResult = await query<any>( this.scheduledTasks.get(configId)?.stop();
`SELECT this.scheduledTasks.delete(configId);
bc.*, }
json_agg(
json_build_object(
'id', bm.id,
'batch_config_id', bm.batch_config_id,
'from_connection_type', bm.from_connection_type,
'from_connection_id', bm.from_connection_id,
'from_table_name', bm.from_table_name,
'from_column_name', bm.from_column_name,
'from_column_type', bm.from_column_type,
'to_connection_type', bm.to_connection_type,
'to_connection_id', bm.to_connection_id,
'to_table_name', bm.to_table_name,
'to_column_name', bm.to_column_name,
'to_column_type', bm.to_column_type,
'mapping_order', bm.mapping_order,
'from_api_url', bm.from_api_url,
'from_api_key', bm.from_api_key,
'from_api_method', bm.from_api_method,
'from_api_param_type', bm.from_api_param_type,
'from_api_param_name', bm.from_api_param_name,
'from_api_param_value', bm.from_api_param_value,
'from_api_param_source', bm.from_api_param_source,
'to_api_url', bm.to_api_url,
'to_api_key', bm.to_api_key,
'to_api_method', bm.to_api_method,
'to_api_body', bm.to_api_body
)
) FILTER (WHERE bm.id IS NOT NULL) as batch_mappings
FROM batch_configs bc
LEFT JOIN batch_mappings bm ON bc.id = bm.batch_config_id
WHERE bc.id = $1
GROUP BY bc.id`,
[configId]
);
const config = configResult[0] || null;
if (!config) {
logger.warn(`배치 설정을 찾을 수 없습니다: ID ${configId}`);
return; return;
} }
// 활성화된 배치만 다시 스케줄 등록 const config = result.data;
if (config.is_active === "Y") {
await this.scheduleBatchConfig(config);
logger.info(
`배치 스케줄 업데이트 완료: ${config.batch_name} (ID: ${configId})`
);
// 활성화 시 즉시 실행 (옵션) // 스케줄 재등록
if (executeImmediately) { await this.scheduleBatch(config);
logger.info(
`🚀 배치 활성화 즉시 실행: ${config.batch_name} (ID: ${configId})` // 즉시 실행 옵션이 있으면 실행
); /*
await this.executeBatchConfig(config); if (executeImmediately && config.is_active === "Y") {
} logger.info(`배치 설정 변경 후 즉시 실행: ${config.batch_name}`);
} else { this.executeBatchConfig(config).catch((err) =>
logger.info( logger.error(`즉시 실행 중 오류 발생:`, err)
`비활성화된 배치 스케줄 제거: ${config.batch_name} (ID: ${configId})`
); );
} }
*/
} catch (error) { } catch (error) {
logger.error(`배치 스케줄 업데이트 실패: ID ${configId}`, error); logger.error(`배치 스케줄 업데이트 실패: ID ${configId}`, error);
} }
@ -268,10 +124,19 @@ export class BatchSchedulerService {
try { try {
logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id})`); logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id})`);
// 매핑 정보가 없으면 상세 조회로 다시 가져오기
if (!config.batch_mappings || config.batch_mappings.length === 0) {
const fullConfig = await BatchService.getBatchConfigById(config.id);
if (fullConfig.success && fullConfig.data) {
config = fullConfig.data;
}
}
// 실행 로그 생성 // 실행 로그 생성
const executionLogResponse = const executionLogResponse =
await BatchExecutionLogService.createExecutionLog({ await BatchExecutionLogService.createExecutionLog({
batch_config_id: config.id, batch_config_id: config.id,
company_code: config.company_code,
execution_status: "RUNNING", execution_status: "RUNNING",
start_time: startTime, start_time: startTime,
total_records: 0, total_records: 0,
@ -313,21 +178,20 @@ export class BatchSchedulerService {
// 성공 결과 반환 // 성공 결과 반환
return result; return result;
} catch (error) { } catch (error) {
logger.error(`배치 실행 실패: ${config.batch_name}`, error); logger.error(`배치 실행 중 오류 발생: ${config.batch_name}`, error);
// 실행 로그 업데이트 (실패) // 실행 로그 업데이트 (실패)
if (executionLog) { if (executionLog) {
await BatchExecutionLogService.updateExecutionLog(executionLog.id, { await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
execution_status: "FAILED", execution_status: "FAILURE",
end_time: new Date(), end_time: new Date(),
duration_ms: Date.now() - startTime.getTime(), duration_ms: Date.now() - startTime.getTime(),
error_message: error_message:
error instanceof Error ? error.message : "알 수 없는 오류", error instanceof Error ? error.message : "알 수 없는 오류",
error_details: error instanceof Error ? error.stack : String(error),
}); });
} }
// 실패 시에도 결과 반환 // 실패 결과 반환
return { return {
totalRecords: 0, totalRecords: 0,
successRecords: 0, successRecords: 0,
@ -379,6 +243,8 @@ export class BatchSchedulerService {
const { BatchExternalDbService } = await import( const { BatchExternalDbService } = await import(
"./batchExternalDbService" "./batchExternalDbService"
); );
// 👇 Body 파라미터 추가 (POST 요청 시)
const apiResult = await BatchExternalDbService.getDataFromRestApi( const apiResult = await BatchExternalDbService.getDataFromRestApi(
firstMapping.from_api_url!, firstMapping.from_api_url!,
firstMapping.from_api_key!, firstMapping.from_api_key!,
@ -394,7 +260,9 @@ export class BatchSchedulerService {
firstMapping.from_api_param_type, firstMapping.from_api_param_type,
firstMapping.from_api_param_name, firstMapping.from_api_param_name,
firstMapping.from_api_param_value, firstMapping.from_api_param_value,
firstMapping.from_api_param_source firstMapping.from_api_param_source,
// 👇 Body 전달 (FROM - REST API - POST 요청)
firstMapping.from_api_body
); );
if (apiResult.success && apiResult.data) { if (apiResult.success && apiResult.data) {
@ -416,6 +284,17 @@ export class BatchSchedulerService {
totalRecords += fromData.length; totalRecords += fromData.length;
// 컬럼 매핑 적용하여 TO 테이블 형식으로 변환 // 컬럼 매핑 적용하여 TO 테이블 형식으로 변환
// 유틸리티 함수: 점 표기법을 사용하여 중첩된 객체 값 가져오기
const getValueByPath = (obj: any, path: string) => {
if (!path) return undefined;
// path가 'response.access_token' 처럼 점을 포함하는 경우
if (path.includes(".")) {
return path.split(".").reduce((acc, part) => acc && acc[part], obj);
}
// 단순 키인 경우
return obj[path];
};
const mappedData = fromData.map((row) => { const mappedData = fromData.map((row) => {
const mappedRow: any = {}; const mappedRow: any = {};
for (const mapping of mappings) { for (const mapping of mappings) {
@ -428,10 +307,25 @@ export class BatchSchedulerService {
mappedRow[mapping.from_column_name] = mappedRow[mapping.from_column_name] =
row[mapping.from_column_name]; row[mapping.from_column_name];
} else { } else {
// 기존 로직: to_column_name을 키로 사용 // REST API -> DB (POST 요청 포함) 또는 DB -> DB
mappedRow[mapping.to_column_name] = row[mapping.from_column_name]; // row[mapping.from_column_name] 대신 getValueByPath 사용
const value = getValueByPath(row, mapping.from_column_name);
mappedRow[mapping.to_column_name] = value;
} }
} }
// 멀티테넌시: TO가 DB일 때 company_code 자동 주입
// - 배치 설정에 company_code가 있고
// - 매핑에서 company_code를 명시적으로 다루지 않은 경우만
if (
firstMapping.to_connection_type !== "restapi" &&
config.company_code &&
mappedRow.company_code === undefined
) {
mappedRow.company_code = config.company_code;
}
return mappedRow; return mappedRow;
}); });
@ -482,22 +376,12 @@ export class BatchSchedulerService {
); );
} }
} else { } else {
// 기존 REST API 전송 (REST API → DB 배치) // 기존 REST API 전송 (REST API → DB 배치) - 사실 이 경우는 거의 없음 (REST to REST)
const apiResult = await BatchExternalDbService.sendDataToRestApi( // 지원하지 않음
firstMapping.to_api_url!, logger.warn(
firstMapping.to_api_key!, "REST API -> REST API (단순 매핑)은 아직 지원하지 않습니다."
firstMapping.to_table_name,
(firstMapping.to_api_method as "POST" | "PUT") || "POST",
mappedData
); );
insertResult = { successCount: 0, failedCount: 0 };
if (apiResult.success && apiResult.data) {
insertResult = apiResult.data;
} else {
throw new Error(
`REST API 데이터 전송 실패: ${apiResult.message}`
);
}
} }
} else { } else {
// DB에 데이터 삽입 // DB에 데이터 삽입
@ -511,167 +395,13 @@ export class BatchSchedulerService {
successRecords += insertResult.successCount; successRecords += insertResult.successCount;
failedRecords += insertResult.failedCount; failedRecords += insertResult.failedCount;
logger.info(
`테이블 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`
);
} catch (error) { } catch (error) {
logger.error(`테이블 처리 실패: ${tableKey}`, error); logger.error(`테이블 처리 중 오류 발생: ${tableKey}`, error);
failedRecords += 1; // 해당 테이블 처리 실패는 전체 실패로 간주하지 않고, 실패 카운트만 증가?
// 여기서는 일단 실패 로그만 남기고 계속 진행 (필요시 정책 변경)
} }
} }
return { totalRecords, successRecords, failedRecords }; return { totalRecords, successRecords, failedRecords };
} }
/**
* ( - )
*/
private static async processBatchMappings(config: any) {
const { batch_mappings } = config;
let totalRecords = 0;
let successRecords = 0;
let failedRecords = 0;
if (!batch_mappings || batch_mappings.length === 0) {
logger.warn(`배치 매핑이 없습니다: ${config.batch_name}`);
return { totalRecords, successRecords, failedRecords };
}
for (const mapping of batch_mappings) {
try {
logger.info(
`매핑 처리 시작: ${mapping.from_table_name} -> ${mapping.to_table_name}`
);
// FROM 테이블에서 데이터 조회
const fromData = await this.getDataFromSource(mapping);
totalRecords += fromData.length;
// TO 테이블에 데이터 삽입
const insertResult = await this.insertDataToTarget(mapping, fromData);
successRecords += insertResult.successCount;
failedRecords += insertResult.failedCount;
logger.info(
`매핑 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`
);
} catch (error) {
logger.error(
`매핑 처리 실패: ${mapping.from_table_name} -> ${mapping.to_table_name}`,
error
);
failedRecords += 1;
}
}
return { totalRecords, successRecords, failedRecords };
}
/**
* FROM
*/
private static async getDataFromSource(mapping: any) {
try {
if (mapping.from_connection_type === "internal") {
// 내부 DB에서 조회
const result = await query<any>(
`SELECT * FROM ${mapping.from_table_name}`,
[]
);
return result;
} else {
// 외부 DB에서 조회 (구현 필요)
logger.warn("외부 DB 조회는 아직 구현되지 않았습니다.");
return [];
}
} catch (error) {
logger.error(
`FROM 테이블 데이터 조회 실패: ${mapping.from_table_name}`,
error
);
throw error;
}
}
/**
* TO
*/
private static async insertDataToTarget(mapping: any, data: any[]) {
let successCount = 0;
let failedCount = 0;
try {
if (mapping.to_connection_type === "internal") {
// 내부 DB에 삽입
for (const record of data) {
try {
// 매핑된 컬럼만 추출
const mappedData = this.mapColumns(record, mapping);
const columns = Object.keys(mappedData);
const values = Object.values(mappedData);
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
await query(
`INSERT INTO ${mapping.to_table_name} (${columns.join(", ")}) VALUES (${placeholders})`,
values
);
successCount++;
} catch (error) {
logger.error(`레코드 삽입 실패:`, error);
failedCount++;
}
}
} else {
// 외부 DB에 삽입 (구현 필요)
logger.warn("외부 DB 삽입은 아직 구현되지 않았습니다.");
failedCount = data.length;
}
} catch (error) {
logger.error(
`TO 테이블 데이터 삽입 실패: ${mapping.to_table_name}`,
error
);
throw error;
}
return { successCount, failedCount };
}
/**
*
*/
private static mapColumns(record: any, mapping: any) {
const mappedData: any = {};
// 단순한 컬럼 매핑 (실제로는 더 복잡한 로직 필요)
mappedData[mapping.to_column_name] = record[mapping.from_column_name];
return mappedData;
}
/**
*
*/
static async stopAllSchedules() {
try {
for (const [id, task] of this.scheduledTasks) {
task.stop();
logger.info(`배치 스케줄 중지: ID ${id}`);
}
this.scheduledTasks.clear();
this.isInitialized = false;
logger.info("모든 배치 스케줄이 중지되었습니다.");
} catch (error) {
logger.error("배치 스케줄 중지 실패:", error);
}
}
/**
*
*/
static getScheduledTasks() {
return Array.from(this.scheduledTasks.keys());
}
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
import { query, queryOne, transaction } from "../database/db"; import { query, queryOne, transaction, getPool } from "../database/db";
import { EventTriggerService } from "./eventTriggerService"; import { EventTriggerService } from "./eventTriggerService";
import { DataflowControlService } from "./dataflowControlService"; import { DataflowControlService } from "./dataflowControlService";
@ -746,7 +746,7 @@ export class DynamicFormService {
* ( ) * ( )
*/ */
async updateFormDataPartial( async updateFormDataPartial(
id: number, id: string | number, // 🔧 UUID 문자열도 지원
tableName: string, tableName: string,
originalData: Record<string, any>, originalData: Record<string, any>,
newData: Record<string, any> newData: Record<string, any>
@ -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 // 싱글톤 인스턴스 생성 및 export

View File

@ -1,4 +1,6 @@
import { Pool, QueryResult } from "pg"; import { Pool, QueryResult } from "pg";
import axios, { AxiosResponse } from "axios";
import https from "https";
import { getPool } from "../database/db"; import { getPool } from "../database/db";
import logger from "../utils/logger"; import logger from "../utils/logger";
import { import {
@ -30,6 +32,10 @@ export class ExternalRestApiConnectionService {
let query = ` let query = `
SELECT SELECT
id, connection_name, description, base_url, endpoint_path, default_headers, id, connection_name, description, base_url, endpoint_path, default_headers,
default_method,
-- DB default_request_body
-- default_body alias
default_request_body AS default_body,
auth_type, auth_config, timeout, retry_count, retry_delay, auth_type, auth_config, timeout, retry_count, retry_delay,
company_code, is_active, created_date, created_by, company_code, is_active, created_date, created_by,
updated_date, updated_by, last_test_date, last_test_result, last_test_message updated_date, updated_by, last_test_date, last_test_result, last_test_message
@ -129,6 +135,8 @@ export class ExternalRestApiConnectionService {
let query = ` let query = `
SELECT SELECT
id, connection_name, description, base_url, endpoint_path, default_headers, id, connection_name, description, base_url, endpoint_path, default_headers,
default_method,
default_request_body AS default_body,
auth_type, auth_config, timeout, retry_count, retry_delay, auth_type, auth_config, timeout, retry_count, retry_delay,
company_code, is_active, created_date, created_by, company_code, is_active, created_date, created_by,
updated_date, updated_by, last_test_date, last_test_result, last_test_message updated_date, updated_by, last_test_date, last_test_result, last_test_message
@ -158,6 +166,9 @@ export class ExternalRestApiConnectionService {
? this.decryptSensitiveData(connection.auth_config) ? this.decryptSensitiveData(connection.auth_config)
: null; : null;
// 디버깅: 조회된 연결 정보 로깅
logger.info(`REST API 연결 조회 결과 (ID: ${id}): connection_name=${connection.connection_name}, default_method=${connection.default_method}, endpoint_path=${connection.endpoint_path}`);
return { return {
success: true, success: true,
data: connection, data: connection,
@ -194,9 +205,10 @@ export class ExternalRestApiConnectionService {
const query = ` const query = `
INSERT INTO external_rest_api_connections ( INSERT INTO external_rest_api_connections (
connection_name, description, base_url, endpoint_path, default_headers, connection_name, description, base_url, endpoint_path, default_headers,
default_method, default_request_body,
auth_type, auth_config, timeout, retry_count, retry_delay, auth_type, auth_config, timeout, retry_count, retry_delay,
company_code, is_active, created_by company_code, is_active, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
RETURNING * RETURNING *
`; `;
@ -206,6 +218,8 @@ export class ExternalRestApiConnectionService {
data.base_url, data.base_url,
data.endpoint_path || null, data.endpoint_path || null,
JSON.stringify(data.default_headers || {}), JSON.stringify(data.default_headers || {}),
data.default_method || "GET",
data.default_body || null,
data.auth_type, data.auth_type,
encryptedAuthConfig ? JSON.stringify(encryptedAuthConfig) : null, encryptedAuthConfig ? JSON.stringify(encryptedAuthConfig) : null,
data.timeout || 30000, data.timeout || 30000,
@ -216,6 +230,15 @@ export class ExternalRestApiConnectionService {
data.created_by || "system", data.created_by || "system",
]; ];
// 디버깅: 저장하려는 데이터 로깅
logger.info(`REST API 연결 생성 요청 데이터:`, {
connection_name: data.connection_name,
default_method: data.default_method,
endpoint_path: data.endpoint_path,
base_url: data.base_url,
default_body: data.default_body ? "있음" : "없음",
});
const result: QueryResult<any> = await pool.query(query, params); const result: QueryResult<any> = await pool.query(query, params);
logger.info(`REST API 연결 생성 성공: ${data.connection_name}`); logger.info(`REST API 연결 생성 성공: ${data.connection_name}`);
@ -301,6 +324,20 @@ export class ExternalRestApiConnectionService {
paramIndex++; paramIndex++;
} }
if (data.default_method !== undefined) {
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); // null이면 DB에서 NULL로 저장됨
paramIndex++;
logger.info(`수정 요청 - default_body: ${data.default_body ? "있음" : "삭제(null)"}`);
}
if (data.auth_type !== undefined) { if (data.auth_type !== undefined) {
updateFields.push(`auth_type = $${paramIndex}`); updateFields.push(`auth_type = $${paramIndex}`);
params.push(data.auth_type); params.push(data.auth_type);
@ -437,38 +474,125 @@ export class ExternalRestApiConnectionService {
} }
} }
/**
*
*/
static async getAuthHeaders(
authType: AuthType,
authConfig: any,
companyCode?: string
): Promise<Record<string, string>> {
const headers: Record<string, string> = {};
if (authType === "db-token") {
const cfg = authConfig || {};
const {
dbTableName,
dbValueColumn,
dbWhereColumn,
dbWhereValue,
dbHeaderName,
dbHeaderTemplate,
} = cfg;
if (!dbTableName || !dbValueColumn) {
throw new Error("DB 토큰 설정이 올바르지 않습니다.");
}
if (!companyCode) {
throw new Error("DB 토큰 모드에서는 회사 코드가 필요합니다.");
}
const hasWhereColumn = !!dbWhereColumn;
const hasWhereValue =
dbWhereValue !== undefined &&
dbWhereValue !== null &&
dbWhereValue !== "";
// where 컬럼/값은 둘 다 비우거나 둘 다 채워야 함
if (hasWhereColumn !== hasWhereValue) {
throw new Error(
"DB 토큰 설정에서 조건 컬럼과 조건 값은 둘 다 비우거나 둘 다 입력해야 합니다."
);
}
// 식별자 검증 (간단한 화이트리스트)
const identifierRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
if (
!identifierRegex.test(dbTableName) ||
!identifierRegex.test(dbValueColumn) ||
(hasWhereColumn && !identifierRegex.test(dbWhereColumn as string))
) {
throw new Error(
"DB 토큰 설정에 유효하지 않은 테이블 또는 컬럼명이 포함되어 있습니다."
);
}
let sql = `
SELECT ${dbValueColumn} AS token_value
FROM ${dbTableName}
WHERE company_code = $1
`;
const params: any[] = [companyCode];
if (hasWhereColumn && hasWhereValue) {
sql += ` AND ${dbWhereColumn} = $2`;
params.push(dbWhereValue);
}
sql += `
ORDER BY updated_date DESC
LIMIT 1
`;
const tokenResult: QueryResult<any> = await pool.query(sql, params);
if (tokenResult.rowCount === 0) {
throw new Error("DB에서 토큰을 찾을 수 없습니다.");
}
const tokenValue = tokenResult.rows[0]["token_value"];
const headerName = dbHeaderName || "Authorization";
const template = dbHeaderTemplate || "Bearer {{value}}";
headers[headerName] = template.replace("{{value}}", tokenValue);
} else if (authType === "bearer" && authConfig?.token) {
headers["Authorization"] = `Bearer ${authConfig.token}`;
} else if (authType === "basic" && authConfig) {
const credentials = Buffer.from(
`${authConfig.username}:${authConfig.password}`
).toString("base64");
headers["Authorization"] = `Basic ${credentials}`;
} else if (authType === "api-key" && authConfig) {
if (authConfig.keyLocation === "header") {
headers[authConfig.keyName] = authConfig.keyValue;
}
}
return headers;
}
/** /**
* REST API ( ) * REST API ( )
*/ */
static async testConnection( static async testConnection(
testRequest: RestApiTestRequest testRequest: RestApiTestRequest,
userCompanyCode?: string
): Promise<RestApiTestResult> { ): Promise<RestApiTestResult> {
const startTime = Date.now(); const startTime = Date.now();
try { try {
// 헤더 구성 // 헤더 구성
const headers = { ...testRequest.headers }; let headers = { ...testRequest.headers };
// 인증 헤더 추가 // 인증 헤더 생성 및 병합
if ( const authHeaders = await this.getAuthHeaders(
testRequest.auth_type === "bearer" && testRequest.auth_type,
testRequest.auth_config?.token testRequest.auth_config,
) { userCompanyCode
headers["Authorization"] = `Bearer ${testRequest.auth_config.token}`; );
} else if (testRequest.auth_type === "basic" && testRequest.auth_config) { headers = { ...headers, ...authHeaders };
const credentials = Buffer.from(
`${testRequest.auth_config.username}:${testRequest.auth_config.password}`
).toString("base64");
headers["Authorization"] = `Basic ${credentials}`;
} else if (
testRequest.auth_type === "api-key" &&
testRequest.auth_config
) {
if (testRequest.auth_config.keyLocation === "header") {
headers[testRequest.auth_config.keyName] =
testRequest.auth_config.keyValue;
}
}
// URL 구성 // URL 구성
let url = testRequest.base_url; let url = testRequest.base_url;
@ -493,25 +617,84 @@ export class ExternalRestApiConnectionService {
`REST API 연결 테스트: ${testRequest.method || "GET"} ${url}` `REST API 연결 테스트: ${testRequest.method || "GET"} ${url}`
); );
// HTTP 요청 실행 // Body 처리
const response = await fetch(url, { let body: any = undefined;
method: testRequest.method || "GET", if (testRequest.body) {
headers, // 이미 문자열이면 그대로, 객체면 JSON 문자열로 변환
signal: AbortSignal.timeout(testRequest.timeout || 30000), if (typeof testRequest.body === "string") {
}); body = testRequest.body;
} else {
body = JSON.stringify(testRequest.body);
}
const responseTime = Date.now() - startTime; // Content-Type 헤더가 없으면 기본적으로 application/json 추가
let responseData = null; const hasContentType = Object.keys(headers).some(
(k) => k.toLowerCase() === "content-type"
try { );
responseData = await response.json(); if (!hasContentType) {
} catch { headers["Content-Type"] = "application/json";
// JSON 파싱 실패는 무시 (텍스트 응답일 수 있음) }
} }
// HTTP 요청 실행
// [인수인계 중요] 2024-11-27 추가
// 특정 레거시/내부망 API(예: thiratis.com)의 경우 SSL 인증서 체인 문제로 인해
// Node.js 레벨에서 검증 실패(UNABLE_TO_VERIFY_LEAF_SIGNATURE)가 발생합니다.
//
// 원래는 인프라(OS/Docker)에 루트 CA를 등록하는 것이 정석이나,
// 유지보수 및 설정 편의성을 위해 코드 레벨에서 '특정 도메인'에 한해서만
// SSL 검증을 우회하도록 예외 처리를 해두었습니다.
//
// ※ 보안 주의: 여기에 모르는 도메인을 함부로 추가하면 중간자 공격(MITM)에 취약해질 수 있습니다.
// 꼭 필요한 신뢰할 수 있는 도메인만 추가하세요.
const bypassDomains = ["thiratis.com"];
const shouldBypassTls = bypassDomains.some((domain) =>
url.includes(domain)
);
const httpsAgent = new https.Agent({
// bypassDomains에 포함된 URL이면 검증을 무시(false), 아니면 정상 검증(true)
rejectUnauthorized: !shouldBypassTls,
});
const requestConfig = {
url,
method: (testRequest.method || "GET") as any,
headers,
data: body,
httpsAgent,
timeout: testRequest.timeout || 30000,
// 4xx/5xx 도 예외가 아니라 응답 객체로 처리
validateStatus: () => true,
};
// 요청 상세 로그 (민감 정보는 최소화)
logger.info(
`REST API 연결 테스트 요청 상세: ${JSON.stringify({
method: requestConfig.method,
url: requestConfig.url,
headers: {
...requestConfig.headers,
// Authorization 헤더는 마스킹
Authorization: requestConfig.headers?.Authorization
? "***masked***"
: undefined,
},
hasBody: !!body,
})}`
);
const response: AxiosResponse = await axios.request(requestConfig);
const responseTime = Date.now() - startTime;
// axios는 response.data에 이미 파싱된 응답 본문을 담아준다.
// JSON이 아니어도 그대로 내려보내서 프론트에서 확인할 수 있게 한다.
const responseData = response.data ?? null;
return { return {
success: response.ok, success: response.status >= 200 && response.status < 300,
message: response.ok message:
response.status >= 200 && response.status < 300
? "연결 성공" ? "연결 성공"
: `연결 실패 (${response.status} ${response.statusText})`, : `연결 실패 (${response.status} ${response.statusText})`,
response_time: responseTime, response_time: responseTime,
@ -552,17 +735,27 @@ export class ExternalRestApiConnectionService {
const connection = connectionResult.data; const connection = connectionResult.data;
// 리스트에서 endpoint를 넘기지 않으면,
// 저장된 endpoint_path를 기본 엔드포인트로 사용
const effectiveEndpoint =
endpoint || connection.endpoint_path || undefined;
const testRequest: RestApiTestRequest = { const testRequest: RestApiTestRequest = {
id: connection.id, id: connection.id,
base_url: connection.base_url, base_url: connection.base_url,
endpoint, endpoint: effectiveEndpoint,
method: (connection.default_method as any) || "GET", // 기본 메서드 적용
headers: connection.default_headers, headers: connection.default_headers,
body: connection.default_body, // 기본 바디 적용
auth_type: connection.auth_type, auth_type: connection.auth_type,
auth_config: connection.auth_config, auth_config: connection.auth_config,
timeout: connection.timeout, timeout: connection.timeout,
}; };
const result = await this.testConnection(testRequest); const result = await this.testConnection(
testRequest,
connection.company_code
);
// 테스트 결과 저장 // 테스트 결과 저장
await pool.query( await pool.query(
@ -580,11 +773,34 @@ export class ExternalRestApiConnectionService {
return result; return result;
} catch (error) { } catch (error) {
logger.error("REST API 연결 테스트 (ID) 오류:", error); logger.error("REST API 연결 테스트 (ID) 오류:", error);
const errorMessage =
error instanceof Error ? error.message : "알 수 없는 오류";
// 예외가 발생한 경우에도 마지막 테스트 결과를 실패로 기록
try {
await pool.query(
`
UPDATE external_rest_api_connections
SET
last_test_date = NOW(),
last_test_result = $1,
last_test_message = $2
WHERE id = $3
`,
["N", errorMessage, id]
);
} catch (updateError) {
logger.error(
"REST API 연결 테스트 (ID) 오류 기록 실패:",
updateError
);
}
return { return {
success: false, success: false,
message: "연결 테스트에 실패했습니다.", message: "연결 테스트에 실패했습니다.",
error_details: error_details: errorMessage,
error instanceof Error ? error.message : "알 수 없는 오류",
}; };
} }
} }
@ -683,6 +899,166 @@ export class ExternalRestApiConnectionService {
return decrypted; return decrypted;
} }
/**
* REST API ( )
* REST API를
*/
static async fetchData(
connectionId: number,
endpoint?: string,
jsonPath?: string,
userCompanyCode?: string
): Promise<ApiResponse<any>> {
try {
// 연결 정보 조회
const connectionResult = await this.getConnectionById(connectionId, userCompanyCode);
if (!connectionResult.success || !connectionResult.data) {
return {
success: false,
message: "REST API 연결을 찾을 수 없습니다.",
error: {
code: "CONNECTION_NOT_FOUND",
details: `연결 ID ${connectionId}를 찾을 수 없습니다.`,
},
};
}
const connection = connectionResult.data;
// 비활성화된 연결인지 확인
if (connection.is_active !== "Y") {
return {
success: false,
message: "비활성화된 REST API 연결입니다.",
error: {
code: "CONNECTION_INACTIVE",
details: "연결이 비활성화 상태입니다.",
},
};
}
// 엔드포인트 결정 (파라미터 > 저장된 값)
const effectiveEndpoint = endpoint || connection.endpoint_path || "";
// API 호출을 위한 테스트 요청 생성
const testRequest: RestApiTestRequest = {
id: connection.id,
base_url: connection.base_url,
endpoint: effectiveEndpoint,
method: (connection.default_method as any) || "GET",
headers: connection.default_headers,
body: connection.default_body,
auth_type: connection.auth_type,
auth_config: connection.auth_config,
timeout: connection.timeout,
};
// API 호출
const result = await this.testConnection(testRequest, connection.company_code);
if (!result.success) {
return {
success: false,
message: result.message || "REST API 호출에 실패했습니다.",
error: {
code: "API_CALL_FAILED",
details: result.error_details,
},
};
}
// 응답 데이터에서 jsonPath로 데이터 추출
let extractedData = result.response_data;
logger.info(`REST API 원본 응답 데이터 타입: ${typeof result.response_data}`);
logger.info(`REST API 원본 응답 데이터 (일부): ${JSON.stringify(result.response_data)?.substring(0, 500)}`);
if (jsonPath && result.response_data) {
try {
// jsonPath로 데이터 추출 (예: "data", "data.items", "result.list")
const pathParts = jsonPath.split(".");
logger.info(`JSON Path 파싱: ${jsonPath} -> [${pathParts.join(", ")}]`);
for (const part of pathParts) {
if (extractedData && typeof extractedData === "object") {
extractedData = (extractedData as any)[part];
logger.info(`JSON Path '${part}' 추출 결과 타입: ${typeof extractedData}, 배열?: ${Array.isArray(extractedData)}`);
} else {
logger.warn(`JSON Path '${part}' 추출 실패: extractedData가 객체가 아님`);
break;
}
}
} catch (pathError) {
logger.warn(`JSON Path 추출 실패: ${jsonPath}`, pathError);
// 추출 실패 시 원본 데이터 반환
extractedData = result.response_data;
}
}
// 데이터가 배열이 아닌 경우 배열로 변환
// null이나 undefined인 경우 빈 배열로 처리
let dataArray: any[] = [];
if (extractedData === null || extractedData === undefined) {
logger.warn("추출된 데이터가 null/undefined입니다. 원본 응답 데이터를 사용합니다.");
// jsonPath 추출 실패 시 원본 데이터에서 직접 컬럼 추출 시도
if (result.response_data && typeof result.response_data === "object") {
dataArray = Array.isArray(result.response_data) ? result.response_data : [result.response_data];
}
} else {
dataArray = Array.isArray(extractedData) ? extractedData : [extractedData];
}
logger.info(`최종 데이터 배열 길이: ${dataArray.length}`);
if (dataArray.length > 0) {
logger.info(`첫 번째 데이터 항목: ${JSON.stringify(dataArray[0])?.substring(0, 300)}`);
}
// 컬럼 정보 추출 (첫 번째 유효한 데이터 기준)
let columns: Array<{ columnName: string; columnLabel: string; dataType: string }> = [];
// 첫 번째 유효한 객체 찾기
const firstValidItem = dataArray.find(item => item && typeof item === "object" && !Array.isArray(item));
if (firstValidItem) {
columns = Object.keys(firstValidItem).map((key) => ({
columnName: key,
columnLabel: key,
dataType: typeof firstValidItem[key],
}));
logger.info(`추출된 컬럼 수: ${columns.length}, 컬럼명: [${columns.map(c => c.columnName).join(", ")}]`);
} else {
logger.warn("유효한 데이터 항목을 찾을 수 없어 컬럼을 추출할 수 없습니다.");
}
return {
success: true,
data: {
rows: dataArray,
columns,
total: dataArray.length,
connectionInfo: {
connectionId: connection.id,
connectionName: connection.connection_name,
baseUrl: connection.base_url,
endpoint: effectiveEndpoint,
},
},
message: `${dataArray.length}개의 데이터를 조회했습니다.`,
};
} catch (error) {
logger.error("REST API 데이터 조회 오류:", error);
return {
success: false,
message: "REST API 데이터 조회에 실패했습니다.",
error: {
code: "FETCH_ERROR",
details: error instanceof Error ? error.message : "알 수 없는 오류",
},
};
}
}
/** /**
* *
*/ */
@ -709,6 +1085,7 @@ export class ExternalRestApiConnectionService {
"bearer", "bearer",
"basic", "basic",
"oauth2", "oauth2",
"db-token",
]; ];
if (!validAuthTypes.includes(data.auth_type)) { if (!validAuthTypes.includes(data.auth_type)) {
throw new Error("올바르지 않은 인증 타입입니다."); throw new Error("올바르지 않은 인증 타입입니다.");

View File

@ -334,9 +334,12 @@ class MailSendSimpleService {
if (variables) { if (variables) {
buttonText = this.replaceVariables(buttonText, variables); buttonText = this.replaceVariables(buttonText, variables);
} }
// styles 객체 또는 직접 속성에서 색상 가져오기
const buttonBgColor = component.styles?.backgroundColor || component.backgroundColor || '#007bff';
const buttonTextColor = component.styles?.color || component.textColor || '#fff';
// 버튼은 왼쪽 정렬 (text-align 제거) // 버튼은 왼쪽 정렬 (text-align 제거)
html += `<div style="margin: 30px 0; text-align: left;"> html += `<div style="margin: 30px 0; text-align: left;">
<a href="${component.url || '#'}" style="display: inline-block; padding: 14px 28px; background-color: ${component.backgroundColor || '#007bff'}; color: ${component.textColor || '#fff'}; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 15px;">${buttonText}</a> <a href="${component.url || '#'}" style="display: inline-block; padding: 14px 28px; background-color: ${buttonBgColor}; color: ${buttonTextColor}; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 15px;">${buttonText}</a>
</div>`; </div>`;
break; break;
case 'image': case 'image':
@ -348,6 +351,89 @@ class MailSendSimpleService {
case 'spacer': case 'spacer':
html += `<div style="height: ${component.height || '20px'};"></div>`; html += `<div style="height: ${component.height || '20px'};"></div>`;
break; break;
case 'header':
html += `
<div style="padding: 20px; background-color: ${component.headerBgColor || '#f8f9fa'}; border-radius: 8px; margin-bottom: 20px;">
<table style="width: 100%;">
<tr>
<td style="vertical-align: middle;">
${component.logoSrc ? `<img src="${component.logoSrc}" alt="로고" style="height: 40px; margin-right: 12px;">` : ''}
<span style="font-size: 18px; font-weight: bold;">${component.brandName || ''}</span>
</td>
<td style="text-align: right; color: #6b7280; font-size: 14px;">
${component.sendDate || ''}
</td>
</tr>
</table>
</div>
`;
break;
case 'infoTable':
html += `
<div style="border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; margin: 16px 0;">
${component.tableTitle ? `<div style="background-color: #f9fafb; padding: 12px 16px; font-weight: 600; border-bottom: 1px solid #e5e7eb;">${component.tableTitle}</div>` : ''}
<table style="width: 100%; border-collapse: collapse;">
${(component.rows || []).map((row: any, i: number) => `
<tr style="background-color: ${i % 2 === 0 ? '#ffffff' : '#f9fafb'};">
<td style="padding: 12px 16px; font-weight: 500; color: #4b5563; width: 35%; border-right: 1px solid #e5e7eb;">${row.label}</td>
<td style="padding: 12px 16px;">${row.value}</td>
</tr>
`).join('')}
</table>
</div>
`;
break;
case 'alertBox':
const alertColors: Record<string, { bg: string; border: string; text: string }> = {
info: { bg: '#eff6ff', border: '#3b82f6', text: '#1e40af' },
warning: { bg: '#fffbeb', border: '#f59e0b', text: '#92400e' },
danger: { bg: '#fef2f2', border: '#ef4444', text: '#991b1b' },
success: { bg: '#ecfdf5', border: '#10b981', text: '#065f46' }
};
const colors = alertColors[component.alertType || 'info'];
html += `
<div style="padding: 16px; background-color: ${colors.bg}; border-left: 4px solid ${colors.border}; border-radius: 4px; margin: 16px 0; color: ${colors.text};">
${component.alertTitle ? `<div style="font-weight: bold; margin-bottom: 8px;">${component.alertTitle}</div>` : ''}
<div>${component.content || ''}</div>
</div>
`;
break;
case 'divider':
html += `<hr style="border: none; border-top: ${component.height || 1}px solid #e5e7eb; margin: 20px 0;">`;
break;
case 'footer':
html += `
<div style="text-align: center; padding: 24px 16px; background-color: #f9fafb; border-top: 1px solid #e5e7eb; font-size: 14px; color: #6b7280;">
${component.companyName ? `<div style="font-weight: 600; color: #374151; margin-bottom: 8px;">${component.companyName}</div>` : ''}
${(component.ceoName || component.businessNumber) ? `
<div style="margin-bottom: 4px;">
${component.ceoName ? `대표: ${component.ceoName}` : ''}
${component.ceoName && component.businessNumber ? ' | ' : ''}
${component.businessNumber ? `사업자등록번호: ${component.businessNumber}` : ''}
</div>
` : ''}
${component.address ? `<div style="margin-bottom: 4px;">${component.address}</div>` : ''}
${(component.phone || component.email) ? `
<div style="margin-bottom: 4px;">
${component.phone ? `Tel: ${component.phone}` : ''}
${component.phone && component.email ? ' | ' : ''}
${component.email ? `Email: ${component.email}` : ''}
</div>
` : ''}
${component.copyright ? `<div style="margin-top: 12px; font-size: 12px; color: #9ca3af;">${component.copyright}</div>` : ''}
</div>
`;
break;
case 'numberedList':
html += `
<div style="padding: 16px;">
${component.listTitle ? `<div style="font-weight: 600; margin-bottom: 12px;">${component.listTitle}</div>` : ''}
<ol style="margin: 0; padding-left: 20px;">
${(component.listItems || []).map((item: string) => `<li style="margin-bottom: 8px;">${item}</li>`).join('')}
</ol>
</div>
`;
break;
} }
}); });

View File

@ -4,13 +4,35 @@ import path from "path";
// MailComponent 인터페이스 정의 // MailComponent 인터페이스 정의
export interface MailComponent { export interface MailComponent {
id: string; id: string;
type: "text" | "button" | "image" | "spacer"; type: "text" | "button" | "image" | "spacer" | "header" | "infoTable" | "alertBox" | "divider" | "footer" | "numberedList";
content?: string; content?: string;
text?: string; text?: string;
url?: string; url?: string;
src?: string; src?: string;
height?: number; height?: number;
styles?: Record<string, string>; styles?: Record<string, string>;
// 헤더 컴포넌트용
logoSrc?: string;
brandName?: string;
sendDate?: string;
headerBgColor?: string;
// 정보 테이블용
rows?: Array<{ label: string; value: string }>;
tableTitle?: string;
// 강조 박스용
alertType?: "info" | "warning" | "danger" | "success";
alertTitle?: string;
// 푸터용
companyName?: string;
ceoName?: string;
businessNumber?: string;
address?: string;
phone?: string;
email?: string;
copyright?: string;
// 번호 리스트용
listItems?: string[];
listTitle?: string;
} }
// QueryConfig 인터페이스 정의 (사용하지 않지만 타입 호환성 유지) // QueryConfig 인터페이스 정의 (사용하지 않지만 타입 호환성 유지)
@ -236,6 +258,89 @@ class MailTemplateFileService {
case "spacer": case "spacer":
html += `<div style="height: ${comp.height || 20}px;"></div>`; html += `<div style="height: ${comp.height || 20}px;"></div>`;
break; break;
case "header":
html += `
<div style="padding: 20px; background-color: ${comp.headerBgColor || '#f8f9fa'}; border-radius: 8px; margin-bottom: 20px;">
<table style="width: 100%;">
<tr>
<td style="vertical-align: middle;">
${comp.logoSrc ? `<img src="${comp.logoSrc}" alt="로고" style="height: 40px; margin-right: 12px;">` : ''}
<span style="font-size: 18px; font-weight: bold;">${comp.brandName || ''}</span>
</td>
<td style="text-align: right; color: #6b7280; font-size: 14px;">
${comp.sendDate || ''}
</td>
</tr>
</table>
</div>
`;
break;
case "infoTable":
html += `
<div style="border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; margin: 16px 0;">
${comp.tableTitle ? `<div style="background-color: #f9fafb; padding: 12px 16px; font-weight: 600; border-bottom: 1px solid #e5e7eb;">${comp.tableTitle}</div>` : ''}
<table style="width: 100%; border-collapse: collapse;">
${(comp.rows || []).map((row, i) => `
<tr style="background-color: ${i % 2 === 0 ? '#ffffff' : '#f9fafb'};">
<td style="padding: 12px 16px; font-weight: 500; color: #4b5563; width: 35%; border-right: 1px solid #e5e7eb;">${row.label}</td>
<td style="padding: 12px 16px;">${row.value}</td>
</tr>
`).join('')}
</table>
</div>
`;
break;
case "alertBox":
const alertColors: Record<string, { bg: string; border: string; text: string }> = {
info: { bg: '#eff6ff', border: '#3b82f6', text: '#1e40af' },
warning: { bg: '#fffbeb', border: '#f59e0b', text: '#92400e' },
danger: { bg: '#fef2f2', border: '#ef4444', text: '#991b1b' },
success: { bg: '#ecfdf5', border: '#10b981', text: '#065f46' }
};
const colors = alertColors[comp.alertType || 'info'];
html += `
<div style="padding: 16px; background-color: ${colors.bg}; border-left: 4px solid ${colors.border}; border-radius: 4px; margin: 16px 0; color: ${colors.text};">
${comp.alertTitle ? `<div style="font-weight: bold; margin-bottom: 8px;">${comp.alertTitle}</div>` : ''}
<div>${comp.content || ''}</div>
</div>
`;
break;
case "divider":
html += `<hr style="border: none; border-top: ${comp.height || 1}px solid #e5e7eb; margin: 20px 0;">`;
break;
case "footer":
html += `
<div style="text-align: center; padding: 24px 16px; background-color: #f9fafb; border-top: 1px solid #e5e7eb; font-size: 14px; color: #6b7280;">
${comp.companyName ? `<div style="font-weight: 600; color: #374151; margin-bottom: 8px;">${comp.companyName}</div>` : ''}
${(comp.ceoName || comp.businessNumber) ? `
<div style="margin-bottom: 4px;">
${comp.ceoName ? `대표: ${comp.ceoName}` : ''}
${comp.ceoName && comp.businessNumber ? ' | ' : ''}
${comp.businessNumber ? `사업자등록번호: ${comp.businessNumber}` : ''}
</div>
` : ''}
${comp.address ? `<div style="margin-bottom: 4px;">${comp.address}</div>` : ''}
${(comp.phone || comp.email) ? `
<div style="margin-bottom: 4px;">
${comp.phone ? `Tel: ${comp.phone}` : ''}
${comp.phone && comp.email ? ' | ' : ''}
${comp.email ? `Email: ${comp.email}` : ''}
</div>
` : ''}
${comp.copyright ? `<div style="margin-top: 12px; font-size: 12px; color: #9ca3af;">${comp.copyright}</div>` : ''}
</div>
`;
break;
case "numberedList":
html += `
<div style="padding: 16px; ${styles}">
${comp.listTitle ? `<div style="font-weight: 600; margin-bottom: 12px;">${comp.listTitle}</div>` : ''}
<ol style="margin: 0; padding-left: 20px;">
${(comp.listItems || []).map(item => `<li style="margin-bottom: 8px;">${item}</li>`).join('')}
</ol>
</div>
`;
break;
} }
}); });

View File

@ -10,10 +10,6 @@ export interface MenuCopyResult {
copiedMenus: number; copiedMenus: number;
copiedScreens: number; copiedScreens: number;
copiedFlows: number; copiedFlows: number;
copiedCategories: number;
copiedCodes: number;
copiedCategorySettings: number;
copiedNumberingRules: number;
menuIdMap: Record<number, number>; menuIdMap: Record<number, number>;
screenIdMap: Record<number, number>; screenIdMap: Record<number, number>;
flowIdMap: Record<number, number>; flowIdMap: Record<number, number>;
@ -129,35 +125,6 @@ interface FlowStepConnection {
label: string | null; 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; return referenced;
@ -355,127 +340,6 @@ export class MenuCopyService {
return flowIds; return flowIds;
} }
/**
*
*/
private async collectCodes(
menuObjids: number[],
sourceCompanyCode: string,
client: PoolClient
): Promise<{ categories: CodeCategory[]; codes: CodeInfo[] }> {
logger.info(`📋 코드 수집 시작: ${menuObjids.length}개 메뉴`);
const categories: CodeCategory[] = [];
const codes: CodeInfo[] = [];
for (const menuObjid of menuObjids) {
// 코드 카테고리
const catsResult = await client.query<CodeCategory>(
`SELECT * FROM code_category
WHERE menu_objid = $1 AND company_code = $2`,
[menuObjid, sourceCompanyCode]
);
categories.push(...catsResult.rows);
// 각 카테고리의 코드 정보
for (const cat of catsResult.rows) {
const codesResult = await client.query<CodeInfo>(
`SELECT * FROM code_info
WHERE code_category = $1 AND menu_objid = $2 AND company_code = $3`,
[cat.category_code, menuObjid, sourceCompanyCode]
);
codes.push(...codesResult.rows);
}
}
logger.info(
`✅ 코드 수집 완료: 카테고리 ${categories.length}개, 코드 ${codes.length}`
);
return { categories, codes };
}
/**
*
*/
private async collectCategorySettings(
menuObjids: number[],
sourceCompanyCode: string,
client: PoolClient
): Promise<{
columnMappings: any[];
categoryValues: any[];
}> {
logger.info(`📂 카테고리 설정 수집 시작: ${menuObjids.length}개 메뉴`);
const columnMappings: any[] = [];
const categoryValues: any[] = [];
// 카테고리 컬럼 매핑 (메뉴별 + 공통)
const mappingsResult = await client.query(
`SELECT * FROM category_column_mapping
WHERE (menu_objid = ANY($1) OR menu_objid = 0)
AND company_code = $2`,
[menuObjids, sourceCompanyCode]
);
columnMappings.push(...mappingsResult.rows);
// 테이블 컬럼 카테고리 값 (메뉴별 + 공통)
const valuesResult = await client.query(
`SELECT * FROM table_column_category_values
WHERE (menu_objid = ANY($1) OR menu_objid = 0)
AND company_code = $2`,
[menuObjids, sourceCompanyCode]
);
categoryValues.push(...valuesResult.rows);
logger.info(
`✅ 카테고리 설정 수집 완료: 컬럼 매핑 ${columnMappings.length}개 (공통 포함), 카테고리 값 ${categoryValues.length}개 (공통 포함)`
);
return { columnMappings, categoryValues };
}
/**
*
*/
private async collectNumberingRules(
menuObjids: number[],
sourceCompanyCode: string,
client: PoolClient
): Promise<{
rules: any[];
parts: any[];
}> {
logger.info(`📋 채번 규칙 수집 시작: ${menuObjids.length}개 메뉴`);
const rules: any[] = [];
const parts: any[] = [];
for (const menuObjid of menuObjids) {
// 채번 규칙
const rulesResult = await client.query(
`SELECT * FROM numbering_rules
WHERE menu_objid = $1 AND company_code = $2`,
[menuObjid, sourceCompanyCode]
);
rules.push(...rulesResult.rows);
// 각 규칙의 파트
for (const rule of rulesResult.rows) {
const partsResult = await client.query(
`SELECT * FROM numbering_rule_parts
WHERE rule_id = $1 AND company_code = $2`,
[rule.rule_id, sourceCompanyCode]
);
parts.push(...partsResult.rows);
}
}
logger.info(
`✅ 채번 규칙 수집 완료: 규칙 ${rules.length}개, 파트 ${parts.length}`
);
return { rules, parts };
}
/** /**
* objid * objid
*/ */
@ -709,42 +573,8 @@ export class MenuCopyService {
]); ]);
logger.info(` ✅ 메뉴 권한 삭제 완료`); logger.info(` ✅ 메뉴 권한 삭제 완료`);
// 5-5. 채번 규칙 파트 삭제 // 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. 메뉴 삭제 (역순: 하위 메뉴부터)
for (let i = existingMenus.length - 1; i >= 0; i--) { for (let i = existingMenus.length - 1; i >= 0; i--) {
await client.query(`DELETE FROM menu_info WHERE objid = $1`, [ await client.query(`DELETE FROM menu_info WHERE objid = $1`, [
existingMenus[i].objid, existingMenus[i].objid,
@ -801,33 +631,11 @@ export class MenuCopyService {
const flowIds = await this.collectFlows(screenIds, client); 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(` logger.info(`
📊 : 📊 :
- 메뉴: ${menus.length} - 메뉴: ${menus.length}
- 화면: ${screenIds.size} - 화면: ${screenIds.size}
- 플로우: ${flowIds.size} - 플로우: ${flowIds.size}
- 카테고리: ${codes.categories.length}
- 코드: ${codes.codes.length}
- 설정: 컬럼 ${categorySettings.columnMappings.length}, ${categorySettings.categoryValues.length}
- 규칙: 규칙 ${numberingRules.rules.length}, ${numberingRules.parts.length}
`); `);
// === 2단계: 플로우 복사 === // === 2단계: 플로우 복사 ===
@ -871,30 +679,6 @@ export class MenuCopyService {
client 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"); await client.query("COMMIT");
logger.info("✅ 트랜잭션 커밋 완료"); logger.info("✅ 트랜잭션 커밋 완료");
@ -904,13 +688,6 @@ export class MenuCopyService {
copiedMenus: menuIdMap.size, copiedMenus: menuIdMap.size,
copiedScreens: screenIdMap.size, copiedScreens: screenIdMap.size,
copiedFlows: flowIdMap.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), menuIdMap: Object.fromEntries(menuIdMap),
screenIdMap: Object.fromEntries(screenIdMap), screenIdMap: Object.fromEntries(screenIdMap),
flowIdMap: Object.fromEntries(flowIdMap), flowIdMap: Object.fromEntries(flowIdMap),
@ -923,10 +700,8 @@ export class MenuCopyService {
- 메뉴: ${result.copiedMenus} - 메뉴: ${result.copiedMenus}
- 화면: ${result.copiedScreens} - 화면: ${result.copiedScreens}
- 플로우: ${result.copiedFlows} - 플로우: ${result.copiedFlows}
- 카테고리: ${result.copiedCategories}
- 코드: ${result.copiedCodes} 주의: 코드, , .
- 설정: ${result.copiedCategorySettings}
- 규칙: ${result.copiedNumberingRules}
============================================ ============================================
`); `);
@ -1125,13 +900,31 @@ export class MenuCopyService {
const screenDef = screenDefResult.rows[0]; 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( const newScreenCode = await this.generateUniqueScreenCode(
targetCompanyCode, targetCompanyCode,
client client
); );
// 2-1) 화면명 변환 적용 // 4) 화면명 변환 적용
let transformedScreenName = screenDef.screen_name; let transformedScreenName = screenDef.screen_name;
if (screenNameConfig) { if (screenNameConfig) {
// 1. 제거할 텍스트 제거 // 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 }>( const newScreenResult = await client.query<{ screen_id: number }>(
`INSERT INTO screen_definitions ( `INSERT INTO screen_definitions (
screen_name, screen_code, table_name, company_code, screen_name, screen_code, table_name, company_code,
@ -1479,383 +1272,4 @@ export class MenuCopyService {
logger.info(`✅ 화면-메뉴 할당 완료: ${assignmentCount}`); logger.info(`✅ 화면-메뉴 할당 완료: ${assignmentCount}`);
} }
/**
*
*/
private async checkCodeCategoryExists(
categoryCode: string,
companyCode: string,
menuObjid: number,
client: PoolClient
): Promise<boolean> {
const result = await client.query<{ exists: boolean }>(
`SELECT EXISTS(
SELECT 1 FROM code_category
WHERE category_code = $1 AND company_code = $2 AND menu_objid = $3
) as exists`,
[categoryCode, companyCode, menuObjid]
);
return result.rows[0].exists;
}
/**
*
*/
private async checkCodeInfoExists(
categoryCode: string,
codeValue: string,
companyCode: string,
menuObjid: number,
client: PoolClient
): Promise<boolean> {
const result = await client.query<{ exists: boolean }>(
`SELECT EXISTS(
SELECT 1 FROM code_info
WHERE code_category = $1 AND code_value = $2
AND company_code = $3 AND menu_objid = $4
) as exists`,
[categoryCode, codeValue, companyCode, menuObjid]
);
return result.rows[0].exists;
}
/**
*
*/
private async copyCodes(
codes: { categories: CodeCategory[]; codes: CodeInfo[] },
menuIdMap: Map<number, number>,
targetCompanyCode: string,
userId: string,
client: PoolClient
): Promise<void> {
logger.info(`📋 코드 복사 중...`);
let categoryCount = 0;
let codeCount = 0;
let skippedCategories = 0;
let skippedCodes = 0;
// 1) 코드 카테고리 복사 (중복 체크)
for (const category of codes.categories) {
const newMenuObjid = menuIdMap.get(category.menu_objid);
if (!newMenuObjid) continue;
// 중복 체크
const exists = await this.checkCodeCategoryExists(
category.category_code,
targetCompanyCode,
newMenuObjid,
client
);
if (exists) {
skippedCategories++;
logger.debug(
` ⏭️ 카테고리 이미 존재: ${category.category_code} (menu_objid=${newMenuObjid})`
);
continue;
}
// 카테고리 복사
await client.query(
`INSERT INTO code_category (
category_code, category_name, category_name_eng, description,
sort_order, is_active, company_code, menu_objid, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
category.category_code,
category.category_name,
category.category_name_eng,
category.description,
category.sort_order,
category.is_active,
targetCompanyCode, // 새 회사 코드
newMenuObjid, // 재매핑
userId,
]
);
categoryCount++;
}
// 2) 코드 정보 복사 (중복 체크)
for (const code of codes.codes) {
const newMenuObjid = menuIdMap.get(code.menu_objid);
if (!newMenuObjid) continue;
// 중복 체크
const exists = await this.checkCodeInfoExists(
code.code_category,
code.code_value,
targetCompanyCode,
newMenuObjid,
client
);
if (exists) {
skippedCodes++;
logger.debug(
` ⏭️ 코드 이미 존재: ${code.code_category}.${code.code_value} (menu_objid=${newMenuObjid})`
);
continue;
}
// 코드 복사
await client.query(
`INSERT INTO code_info (
code_category, code_value, code_name, code_name_eng, description,
sort_order, is_active, company_code, menu_objid, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
[
code.code_category,
code.code_value,
code.code_name,
code.code_name_eng,
code.description,
code.sort_order,
code.is_active,
targetCompanyCode, // 새 회사 코드
newMenuObjid, // 재매핑
userId,
]
);
codeCount++;
}
logger.info(
`✅ 코드 복사 완료: 카테고리 ${categoryCount}개 (${skippedCategories}개 스킵), 코드 ${codeCount}개 (${skippedCodes}개 스킵)`
);
}
/**
*
*/
private async copyCategorySettings(
settings: { columnMappings: any[]; categoryValues: any[] },
menuIdMap: Map<number, number>,
targetCompanyCode: string,
userId: string,
client: PoolClient
): Promise<void> {
logger.info(`📂 카테고리 설정 복사 중...`);
const valueIdMap = new Map<number, number>(); // 원본 value_id → 새 value_id
let mappingCount = 0;
let valueCount = 0;
// 1) 카테고리 컬럼 매핑 복사 (덮어쓰기 모드)
for (const mapping of settings.columnMappings) {
// menu_objid = 0인 공통 설정은 그대로 0으로 유지
let newMenuObjid: number | undefined;
if (
mapping.menu_objid === 0 ||
mapping.menu_objid === "0" ||
mapping.menu_objid == 0
) {
newMenuObjid = 0; // 공통 설정
} else {
newMenuObjid = menuIdMap.get(mapping.menu_objid);
if (newMenuObjid === undefined) {
logger.debug(
` ⏭️ 매핑할 메뉴가 없음: menu_objid=${mapping.menu_objid}`
);
continue;
}
}
// 기존 매핑 삭제 (덮어쓰기)
await client.query(
`DELETE FROM category_column_mapping
WHERE table_name = $1 AND physical_column_name = $2 AND company_code = $3`,
[mapping.table_name, mapping.physical_column_name, targetCompanyCode]
);
// 새 매핑 추가
await client.query(
`INSERT INTO category_column_mapping (
table_name, logical_column_name, physical_column_name,
menu_objid, company_code, description, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
mapping.table_name,
mapping.logical_column_name,
mapping.physical_column_name,
newMenuObjid,
targetCompanyCode,
mapping.description,
userId,
]
);
mappingCount++;
}
// 2) 테이블 컬럼 카테고리 값 복사 (덮어쓰기 모드, 부모-자식 관계 유지)
const sortedValues = settings.categoryValues.sort(
(a, b) => a.depth - b.depth
);
// 먼저 기존 값들을 모두 삭제 (테이블+컬럼 단위)
const uniqueTableColumns = new Set<string>();
for (const value of sortedValues) {
uniqueTableColumns.add(`${value.table_name}:${value.column_name}`);
}
for (const tableColumn of uniqueTableColumns) {
const [tableName, columnName] = tableColumn.split(":");
await client.query(
`DELETE FROM table_column_category_values
WHERE table_name = $1 AND column_name = $2 AND company_code = $3`,
[tableName, columnName, targetCompanyCode]
);
logger.debug(` 🗑️ 기존 카테고리 값 삭제: ${tableName}.${columnName}`);
}
// 새 값 추가
for (const value of sortedValues) {
// menu_objid = 0인 공통 설정은 그대로 0으로 유지
let newMenuObjid: number | undefined;
if (
value.menu_objid === 0 ||
value.menu_objid === "0" ||
value.menu_objid == 0
) {
newMenuObjid = 0; // 공통 설정
} else {
newMenuObjid = menuIdMap.get(value.menu_objid);
if (newMenuObjid === undefined) {
logger.debug(
` ⏭️ 매핑할 메뉴가 없음: menu_objid=${value.menu_objid}`
);
continue;
}
}
// 부모 ID 재매핑
let newParentValueId = null;
if (value.parent_value_id) {
newParentValueId = valueIdMap.get(value.parent_value_id) || null;
}
const result = await client.query(
`INSERT INTO table_column_category_values (
table_name, column_name, value_code, value_label,
value_order, parent_value_id, depth, description,
color, icon, is_active, is_default,
company_code, menu_objid, created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
RETURNING value_id`,
[
value.table_name,
value.column_name,
value.value_code,
value.value_label,
value.value_order,
newParentValueId,
value.depth,
value.description,
value.color,
value.icon,
value.is_active,
value.is_default,
targetCompanyCode,
newMenuObjid,
userId,
]
);
// ID 매핑 저장
const newValueId = result.rows[0].value_id;
valueIdMap.set(value.value_id, newValueId);
valueCount++;
}
logger.info(
`✅ 카테고리 설정 복사 완료: 컬럼 매핑 ${mappingCount}개, 카테고리 값 ${valueCount}개 (덮어쓰기)`
);
}
/**
*
*/
private async copyNumberingRules(
rules: { rules: any[]; parts: any[] },
menuIdMap: Map<number, number>,
targetCompanyCode: string,
userId: string,
client: PoolClient
): Promise<void> {
logger.info(`📋 채번 규칙 복사 중...`);
const ruleIdMap = new Map<string, string>(); // 원본 rule_id → 새 rule_id
let ruleCount = 0;
let partCount = 0;
// 1) 채번 규칙 복사
for (const rule of rules.rules) {
const newMenuObjid = menuIdMap.get(rule.menu_objid);
if (!newMenuObjid) continue;
// 새 rule_id 생성 (타임스탬프 기반)
const newRuleId = `rule-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
ruleIdMap.set(rule.rule_id, newRuleId);
await client.query(
`INSERT INTO numbering_rules (
rule_id, rule_name, description, separator,
reset_period, current_sequence, table_name, column_name,
company_code, menu_objid, created_by, scope_type
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
[
newRuleId,
rule.rule_name,
rule.description,
rule.separator,
rule.reset_period,
1, // 시퀀스 초기화
rule.table_name,
rule.column_name,
targetCompanyCode,
newMenuObjid,
userId,
rule.scope_type,
]
);
ruleCount++;
}
// 2) 채번 규칙 파트 복사
for (const part of rules.parts) {
const newRuleId = ruleIdMap.get(part.rule_id);
if (!newRuleId) continue;
await client.query(
`INSERT INTO numbering_rule_parts (
rule_id, part_order, part_type, generation_method,
auto_config, manual_config, company_code
) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
newRuleId,
part.part_order,
part.part_type,
part.generation_method,
part.auto_config,
part.manual_config,
targetCompanyCode,
]
);
partCount++;
}
logger.info(
`✅ 채번 규칙 복사 완료: 규칙 ${ruleCount}개, 파트 ${partCount}`
);
}
} }

View File

@ -102,6 +102,72 @@ export async function getSiblingMenuObjids(menuObjid: number): Promise<number[]>
} }
} }
/**
* OBJID
*
* , .
* .
*
* @param menuObjid OBJID
* @returns + OBJID ()
*
* @example
* // 메뉴 구조:
* // └── 구매관리 (100)
* // ├── 공급업체관리 (101)
* // ├── 발주관리 (102)
* // └── 입고관리 (103)
* // └── 입고상세 (104)
*
* await getMenuAndChildObjids(100);
* // 결과: [100, 101, 102, 103, 104]
*/
export async function getMenuAndChildObjids(menuObjid: number): Promise<number[]> {
const pool = getPool();
try {
logger.debug("메뉴 및 하위 메뉴 조회 시작", { menuObjid });
// 재귀 CTE를 사용하여 선택한 메뉴와 모든 하위 메뉴 조회
const query = `
WITH RECURSIVE menu_tree AS (
-- 시작점: 선택한
SELECT objid, parent_obj_id, 1 AS depth
FROM menu_info
WHERE objid = $1
UNION ALL
-- 재귀: 하위
SELECT m.objid, m.parent_obj_id, mt.depth + 1
FROM menu_info m
INNER JOIN menu_tree mt ON m.parent_obj_id = mt.objid
WHERE mt.depth < 10 --
)
SELECT objid FROM menu_tree ORDER BY depth, objid
`;
const result = await pool.query(query, [menuObjid]);
const objids = result.rows.map((row) => Number(row.objid));
logger.debug("메뉴 및 하위 메뉴 조회 완료", {
menuObjid,
totalCount: objids.length,
objids
});
return objids;
} catch (error: any) {
logger.error("메뉴 및 하위 메뉴 조회 실패", {
menuObjid,
error: error.message,
stack: error.stack
});
// 에러 발생 시 안전하게 자기 자신만 반환
return [menuObjid];
}
}
/** /**
* OBJID * OBJID
* *

View File

@ -4,7 +4,7 @@
import { getPool } from "../database/db"; import { getPool } from "../database/db";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { getSiblingMenuObjids } from "./menuService"; import { getMenuAndChildObjids } from "./menuService";
interface NumberingRulePart { interface NumberingRulePart {
id?: number; id?: number;
@ -161,7 +161,7 @@ class NumberingRuleService {
companyCode: string, companyCode: string,
menuObjid?: number menuObjid?: number
): Promise<NumberingRuleConfig[]> { ): Promise<NumberingRuleConfig[]> {
let siblingObjids: number[] = []; // catch 블록에서 접근 가능하도록 함수 최상단에 선언 let menuAndChildObjids: number[] = []; // catch 블록에서 접근 가능하도록 함수 최상단에 선언
try { try {
logger.info("메뉴별 사용 가능한 채번 규칙 조회 시작 (메뉴 스코프)", { logger.info("메뉴별 사용 가능한 채번 규칙 조회 시작 (메뉴 스코프)", {
@ -171,14 +171,14 @@ class NumberingRuleService {
const pool = getPool(); const pool = getPool();
// 1. 형제 메뉴 OBJID 조회 // 1. 선택한 메뉴와 하위 메뉴 OBJID 조회 (형제 메뉴 제외)
if (menuObjid) { if (menuObjid) {
siblingObjids = await getSiblingMenuObjids(menuObjid); menuAndChildObjids = await getMenuAndChildObjids(menuObjid);
logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids }); logger.info("선택한 메뉴 및 하위 메뉴 OBJID 목록", { menuObjid, menuAndChildObjids });
} }
// menuObjid가 없으면 global 규칙만 반환 // menuObjid가 없으면 global 규칙만 반환
if (!menuObjid || siblingObjids.length === 0) { if (!menuObjid || menuAndChildObjids.length === 0) {
let query: string; let query: string;
let params: any[]; let params: any[];
@ -280,7 +280,7 @@ class NumberingRuleService {
let params: any[]; let params: any[];
if (companyCode === "*") { if (companyCode === "*") {
// 최고 관리자: 모든 규칙 조회 (형제 메뉴 포함) // 최고 관리자: 모든 규칙 조회 (선택한 메뉴 + 하위 메뉴)
query = ` query = `
SELECT SELECT
rule_id AS "ruleId", rule_id AS "ruleId",
@ -301,8 +301,7 @@ class NumberingRuleService {
WHERE WHERE
scope_type = 'global' scope_type = 'global'
OR (scope_type = 'menu' AND menu_objid = ANY($1)) 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 = ANY($1))
OR (scope_type = 'table' AND menu_objid IS NULL) -- (menu_objid NULL) ( )
ORDER BY ORDER BY
CASE CASE
WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($1)) THEN 1 WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($1)) THEN 1
@ -311,10 +310,10 @@ class NumberingRuleService {
END, END,
created_at DESC created_at DESC
`; `;
params = [siblingObjids]; params = [menuAndChildObjids];
logger.info("최고 관리자: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { siblingObjids }); logger.info("최고 관리자: 메뉴 및 하위 메뉴 기반 채번 규칙 조회", { menuAndChildObjids });
} else { } else {
// 일반 회사: 자신의 규칙만 조회 (형제 메뉴 포함, 메뉴별 필터링) // 일반 회사: 자신의 규칙만 조회 (선택한 메뉴 + 하위 메뉴)
query = ` query = `
SELECT SELECT
rule_id AS "ruleId", rule_id AS "ruleId",
@ -336,8 +335,7 @@ class NumberingRuleService {
AND ( AND (
scope_type = 'global' scope_type = 'global'
OR (scope_type = 'menu' AND menu_objid = ANY($2)) 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 = ANY($2))
OR (scope_type = 'table' AND menu_objid IS NULL) -- (menu_objid NULL) ( )
) )
ORDER BY ORDER BY
CASE CASE
@ -347,8 +345,8 @@ class NumberingRuleService {
END, END,
created_at DESC created_at DESC
`; `;
params = [companyCode, siblingObjids]; params = [companyCode, menuAndChildObjids];
logger.info("회사별: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { companyCode, siblingObjids }); logger.info("회사별: 메뉴 및 하위 메뉴 기반 채번 규칙 조회", { companyCode, menuAndChildObjids });
} }
logger.info("🔍 채번 규칙 쿼리 실행", { logger.info("🔍 채번 규칙 쿼리 실행", {
@ -420,7 +418,7 @@ class NumberingRuleService {
logger.info("메뉴별 사용 가능한 채번 규칙 조회 완료", { logger.info("메뉴별 사용 가능한 채번 규칙 조회 완료", {
companyCode, companyCode,
menuObjid, menuObjid,
siblingCount: siblingObjids.length, menuAndChildCount: menuAndChildObjids.length,
count: result.rowCount, count: result.rowCount,
}); });
@ -432,7 +430,7 @@ class NumberingRuleService {
errorStack: error.stack, errorStack: error.stack,
companyCode, companyCode,
menuObjid, menuObjid,
siblingObjids: siblingObjids || [], menuAndChildObjids: menuAndChildObjids || [],
}); });
throw error; throw error;
} }

View File

@ -70,12 +70,13 @@ export class ScreenManagementService {
throw new Error("이미 존재하는 화면 코드입니다."); throw new Error("이미 존재하는 화면 코드입니다.");
} }
// 화면 생성 (Raw Query) // 화면 생성 (Raw Query) - REST API 지원 추가
const [screen] = await query<any>( const [screen] = await query<any>(
`INSERT INTO screen_definitions ( `INSERT INTO screen_definitions (
screen_name, screen_code, table_name, company_code, description, created_by, screen_name, screen_code, table_name, company_code, description, created_by,
db_source_type, db_connection_id db_source_type, db_connection_id, data_source_type, rest_api_connection_id,
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) rest_api_endpoint, rest_api_json_path
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING *`, RETURNING *`,
[ [
screenData.screenName, screenData.screenName,
@ -86,6 +87,10 @@ export class ScreenManagementService {
screenData.createdBy, screenData.createdBy,
screenData.dbSourceType || "internal", screenData.dbSourceType || "internal",
screenData.dbConnectionId || null, screenData.dbConnectionId || null,
(screenData as any).dataSourceType || "database",
(screenData as any).restApiConnectionId || null,
(screenData as any).restApiEndpoint || null,
(screenData as any).restApiJsonPath || "data",
] ]
); );
@ -1977,6 +1982,11 @@ export class ScreenManagementService {
updatedBy: data.updated_by, updatedBy: data.updated_by,
dbSourceType: data.db_source_type || "internal", dbSourceType: data.db_source_type || "internal",
dbConnectionId: data.db_connection_id || undefined, dbConnectionId: data.db_connection_id || undefined,
// REST API 관련 필드
dataSourceType: data.data_source_type || "database",
restApiConnectionId: data.rest_api_connection_id || undefined,
restApiEndpoint: data.rest_api_endpoint || undefined,
restApiJsonPath: data.rest_api_json_path || "data",
}; };
} }

View File

@ -1066,6 +1066,66 @@ class TableCategoryValueService {
} }
} }
/**
* +
*
*
*
* @param tableName -
* @param columnName -
* @param companyCode -
* @returns
*/
async deleteColumnMappingsByColumn(
tableName: string,
columnName: string,
companyCode: string
): Promise<number> {
const pool = getPool();
try {
logger.info("테이블+컬럼 기준 매핑 삭제", { tableName, columnName, companyCode });
// 멀티테넌시 적용
let deleteQuery: string;
let deleteParams: any[];
if (companyCode === "*") {
// 최고 관리자: 해당 테이블+컬럼의 모든 매핑 삭제
deleteQuery = `
DELETE FROM category_column_mapping
WHERE table_name = $1
AND logical_column_name = $2
`;
deleteParams = [tableName, columnName];
} else {
// 일반 회사: 자신의 매핑만 삭제
deleteQuery = `
DELETE FROM category_column_mapping
WHERE table_name = $1
AND logical_column_name = $2
AND company_code = $3
`;
deleteParams = [tableName, columnName, companyCode];
}
const result = await pool.query(deleteQuery, deleteParams);
const deletedCount = result.rowCount || 0;
logger.info("테이블+컬럼 기준 매핑 삭제 완료", {
tableName,
columnName,
companyCode,
deletedCount
});
return deletedCount;
} catch (error: any) {
logger.error(`테이블+컬럼 기준 매핑 삭제 실패: ${error.message}`);
throw error;
}
}
/** /**
* *
* *

View File

@ -1165,12 +1165,26 @@ export class TableManagementService {
paramCount: number; paramCount: number;
} | null> { } | null> {
try { try {
// 🔧 날짜 범위 문자열 "YYYY-MM-DD|YYYY-MM-DD" 체크 (최우선!) // 🔧 파이프로 구분된 문자열 처리 (다중선택 또는 날짜 범위)
if (typeof value === "string" && value.includes("|")) { if (typeof value === "string" && value.includes("|")) {
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName); const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
// 날짜 타입이면 날짜 범위로 처리
if (columnInfo && (columnInfo.webType === "date" || columnInfo.webType === "datetime")) { if (columnInfo && (columnInfo.webType === "date" || columnInfo.webType === "datetime")) {
return this.buildDateRangeCondition(columnName, value, paramIndex); return this.buildDateRangeCondition(columnName, value, paramIndex);
} }
// 그 외 타입이면 다중선택(IN 조건)으로 처리
const multiValues = value.split("|").filter((v: string) => v.trim() !== "");
if (multiValues.length > 0) {
const placeholders = multiValues.map((_: string, idx: number) => `$${paramIndex + idx}`).join(", ");
logger.info(`🔍 다중선택 필터 적용: ${columnName} IN (${multiValues.join(", ")})`);
return {
whereClause: `${columnName}::text IN (${placeholders})`,
values: multiValues,
paramCount: multiValues.length,
};
}
} }
// 🔧 날짜 범위 객체 {from, to} 체크 // 🔧 날짜 범위 객체 {from, to} 체크

View File

@ -4,6 +4,7 @@
export interface BatchExecutionLog { export interface BatchExecutionLog {
id?: number; id?: number;
batch_config_id: number; batch_config_id: number;
company_code?: string;
execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED'; execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED';
start_time: Date; start_time: Date;
end_time?: Date | null; end_time?: Date | null;
@ -19,6 +20,7 @@ export interface BatchExecutionLog {
export interface CreateBatchExecutionLogRequest { export interface CreateBatchExecutionLogRequest {
batch_config_id: number; batch_config_id: number;
company_code?: string;
execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED'; execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED';
start_time?: Date; start_time?: Date;
end_time?: Date | null; end_time?: Date | null;

View File

@ -1,86 +1,13 @@
// 배치관리 타입 정의 import { ApiResponse, ColumnInfo } from './batchTypes';
// 작성일: 2024-12-24
// 배치 타입 정의 export interface BatchConnectionInfo {
export type BatchType = 'db-to-db' | 'db-to-restapi' | 'restapi-to-db' | 'restapi-to-restapi';
export interface BatchTypeOption {
value: BatchType;
label: string;
description: string;
}
export interface BatchConfig {
id?: number;
batch_name: string;
description?: string;
cron_schedule: string;
is_active?: string;
company_code?: string;
created_date?: Date;
created_by?: string;
updated_date?: Date;
updated_by?: string;
batch_mappings?: BatchMapping[];
}
export interface BatchMapping {
id?: number;
batch_config_id?: number;
// FROM 정보
from_connection_type: 'internal' | 'external' | 'restapi';
from_connection_id?: number;
from_table_name: string; // DB: 테이블명, REST API: 엔드포인트
from_column_name: string; // DB: 컬럼명, REST API: JSON 필드명
from_column_type?: string;
from_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; // REST API 전용
from_api_url?: string; // REST API 서버 URL
from_api_key?: string; // REST API 키
from_api_param_type?: 'url' | 'query'; // API 파라미터 타입
from_api_param_name?: string; // API 파라미터명
from_api_param_value?: string; // API 파라미터 값 또는 템플릿
from_api_param_source?: 'static' | 'dynamic'; // 파라미터 소스 타입
// TO 정보
to_connection_type: 'internal' | 'external' | 'restapi';
to_connection_id?: number;
to_table_name: string; // DB: 테이블명, REST API: 엔드포인트
to_column_name: string; // DB: 컬럼명, REST API: JSON 필드명
to_column_type?: string;
to_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; // REST API 전용
to_api_url?: string; // REST API 서버 URL
to_api_key?: string; // REST API 키
to_api_body?: string; // Request Body 템플릿 (DB → REST API 배치용)
mapping_order?: number;
created_date?: Date;
created_by?: string;
}
export interface BatchConfigFilter {
page?: number;
limit?: number;
batch_name?: string;
is_active?: string;
company_code?: string;
search?: string;
}
export interface ConnectionInfo {
type: 'internal' | 'external'; type: 'internal' | 'external';
id?: number; id?: number;
name: string; name: string;
db_type?: string; db_type?: string;
} }
export interface TableInfo { export interface BatchColumnInfo {
table_name: string;
columns: ColumnInfo[];
description?: string | null;
}
export interface ColumnInfo {
column_name: string; column_name: string;
data_type: string; data_type: string;
is_nullable?: string; is_nullable?: string;
@ -100,6 +27,8 @@ export interface BatchMappingRequest {
from_api_param_name?: string; // API 파라미터명 from_api_param_name?: string; // API 파라미터명
from_api_param_value?: string; // API 파라미터 값 또는 템플릿 from_api_param_value?: string; // API 파라미터 값 또는 템플릿
from_api_param_source?: 'static' | 'dynamic'; // 파라미터 소스 타입 from_api_param_source?: 'static' | 'dynamic'; // 파라미터 소스 타입
// 👇 REST API Body 추가 (FROM - REST API에서 POST 요청 시 필요)
from_api_body?: string;
to_connection_type: 'internal' | 'external' | 'restapi'; to_connection_type: 'internal' | 'external' | 'restapi';
to_connection_id?: number; to_connection_id?: number;
to_table_name: string; to_table_name: string;
@ -116,6 +45,8 @@ export interface CreateBatchConfigRequest {
batchName: string; batchName: string;
description?: string; description?: string;
cronSchedule: string; cronSchedule: string;
isActive: 'Y' | 'N';
companyCode: string;
mappings: BatchMappingRequest[]; mappings: BatchMappingRequest[];
} }
@ -123,25 +54,11 @@ export interface UpdateBatchConfigRequest {
batchName?: string; batchName?: string;
description?: string; description?: string;
cronSchedule?: string; cronSchedule?: string;
isActive?: 'Y' | 'N';
mappings?: BatchMappingRequest[]; mappings?: BatchMappingRequest[];
isActive?: string;
} }
export interface BatchValidationResult { export interface BatchValidationResult {
isValid: boolean; isValid: boolean;
errors: string[]; errors: string[];
warnings?: string[];
}
export interface ApiResponse<T> {
success: boolean;
data?: T;
message?: string;
error?: string;
pagination?: {
page: number;
limit: number;
total: number;
totalPages: number;
};
} }

View File

@ -1,6 +1,12 @@
// 외부 REST API 연결 관리 타입 정의 // 외부 REST API 연결 관리 타입 정의
export type AuthType = "none" | "api-key" | "bearer" | "basic" | "oauth2"; export type AuthType =
| "none"
| "api-key"
| "bearer"
| "basic"
| "oauth2"
| "db-token";
export interface ExternalRestApiConnection { export interface ExternalRestApiConnection {
id?: number; id?: number;
@ -9,6 +15,11 @@ export interface ExternalRestApiConnection {
base_url: string; base_url: string;
endpoint_path?: string; endpoint_path?: string;
default_headers: Record<string, string>; default_headers: Record<string, string>;
// 기본 메서드 및 바디 추가
default_method?: string;
default_body?: string;
auth_type: AuthType; auth_type: AuthType;
auth_config?: { auth_config?: {
// API Key // API Key
@ -28,6 +39,14 @@ export interface ExternalRestApiConnection {
clientSecret?: string; clientSecret?: string;
tokenUrl?: string; tokenUrl?: string;
accessToken?: string; accessToken?: string;
// DB 기반 토큰 모드
dbTableName?: string;
dbValueColumn?: string;
dbWhereColumn?: string;
dbWhereValue?: string;
dbHeaderName?: string;
dbHeaderTemplate?: string;
}; };
timeout?: number; timeout?: number;
retry_count?: number; retry_count?: number;
@ -54,8 +73,9 @@ export interface RestApiTestRequest {
id?: number; id?: number;
base_url: string; base_url: string;
endpoint?: string; endpoint?: string;
method?: "GET" | "POST" | "PUT" | "DELETE"; method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
headers?: Record<string, string>; headers?: Record<string, string>;
body?: any; // 테스트 요청 바디 추가
auth_type?: AuthType; auth_type?: AuthType;
auth_config?: any; auth_config?: any;
timeout?: number; timeout?: number;
@ -76,4 +96,5 @@ export const AUTH_TYPE_OPTIONS = [
{ value: "bearer", label: "Bearer Token" }, { value: "bearer", label: "Bearer Token" },
{ value: "basic", label: "Basic Auth" }, { value: "basic", label: "Basic Auth" },
{ value: "oauth2", label: "OAuth 2.0" }, { value: "oauth2", label: "OAuth 2.0" },
{ value: "db-token", label: "DB 토큰" },
]; ];

View File

@ -154,6 +154,11 @@ export interface ScreenDefinition {
updatedBy?: string; updatedBy?: string;
dbSourceType?: "internal" | "external"; dbSourceType?: "internal" | "external";
dbConnectionId?: number; dbConnectionId?: number;
// REST API 관련 필드
dataSourceType?: "database" | "restapi";
restApiConnectionId?: number;
restApiEndpoint?: string;
restApiJsonPath?: string;
} }
// 화면 생성 요청 // 화면 생성 요청
@ -166,6 +171,11 @@ export interface CreateScreenRequest {
createdBy?: string; createdBy?: string;
dbSourceType?: "internal" | "external"; dbSourceType?: "internal" | "external";
dbConnectionId?: number; dbConnectionId?: number;
// REST API 관련 필드
dataSourceType?: "database" | "restapi";
restApiConnectionId?: number;
restApiEndpoint?: string;
restApiJsonPath?: string;
} }
// 화면 수정 요청 // 화면 수정 요청

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useMemo, memo } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -33,6 +33,31 @@ interface BatchColumnInfo {
is_nullable: string; is_nullable: string;
} }
interface RestApiToDbMappingCardProps {
fromApiFields: string[];
toColumns: BatchColumnInfo[];
fromApiData: any[];
apiFieldMappings: Record<string, string>;
setApiFieldMappings: React.Dispatch<
React.SetStateAction<Record<string, string>>
>;
apiFieldPathOverrides: Record<string, string>;
setApiFieldPathOverrides: React.Dispatch<
React.SetStateAction<Record<string, string>>
>;
}
interface DbToRestApiMappingCardProps {
fromColumns: BatchColumnInfo[];
selectedColumns: string[];
toApiFields: string[];
dbToApiFieldMapping: Record<string, string>;
setDbToApiFieldMapping: React.Dispatch<
React.SetStateAction<Record<string, string>>
>;
setToApiBody: (body: string) => void;
}
export default function BatchManagementNewPage() { export default function BatchManagementNewPage() {
const router = useRouter(); const router = useRouter();
@ -52,7 +77,8 @@ export default function BatchManagementNewPage() {
const [fromApiUrl, setFromApiUrl] = useState(""); const [fromApiUrl, setFromApiUrl] = useState("");
const [fromApiKey, setFromApiKey] = useState(""); const [fromApiKey, setFromApiKey] = useState("");
const [fromEndpoint, setFromEndpoint] = useState(""); const [fromEndpoint, setFromEndpoint] = useState("");
const [fromApiMethod, setFromApiMethod] = useState<'GET'>('GET'); // GET만 지원 const [fromApiMethod, setFromApiMethod] = useState<'GET' | 'POST' | 'PUT' | 'DELETE'>('GET');
const [fromApiBody, setFromApiBody] = useState(""); // Request Body (JSON)
// REST API 파라미터 설정 // REST API 파라미터 설정
const [apiParamType, setApiParamType] = useState<'none' | 'url' | 'query'>('none'); const [apiParamType, setApiParamType] = useState<'none' | 'url' | 'query'>('none');
@ -83,6 +109,8 @@ export default function BatchManagementNewPage() {
// API 필드 → DB 컬럼 매핑 // API 필드 → DB 컬럼 매핑
const [apiFieldMappings, setApiFieldMappings] = useState<Record<string, string>>({}); const [apiFieldMappings, setApiFieldMappings] = useState<Record<string, string>>({});
// API 필드별 JSON 경로 오버라이드 (예: "response.access_token")
const [apiFieldPathOverrides, setApiFieldPathOverrides] = useState<Record<string, string>>({});
// 배치 타입 상태 // 배치 타입 상태
const [batchType, setBatchType] = useState<BatchType>('restapi-to-db'); const [batchType, setBatchType] = useState<BatchType>('restapi-to-db');
@ -182,24 +210,17 @@ export default function BatchManagementNewPage() {
// TO 테이블 변경 핸들러 // TO 테이블 변경 핸들러
const handleToTableChange = async (tableName: string) => { const handleToTableChange = async (tableName: string) => {
console.log("🔍 테이블 변경:", { tableName, toConnection });
setToTable(tableName); setToTable(tableName);
setToColumns([]); setToColumns([]);
if (toConnection && tableName) { if (toConnection && tableName) {
try { try {
const connectionType = toConnection.type === 'internal' ? 'internal' : 'external'; const connectionType = toConnection.type === 'internal' ? 'internal' : 'external';
console.log("🔍 컬럼 조회 시작:", { connectionType, connectionId: toConnection.id, tableName });
const result = await BatchManagementAPI.getTableColumns(connectionType, tableName, toConnection.id); const result = await BatchManagementAPI.getTableColumns(connectionType, tableName, toConnection.id);
console.log("🔍 컬럼 조회 결과:", result);
if (result && result.length > 0) { if (result && result.length > 0) {
setToColumns(result); setToColumns(result);
console.log("✅ 컬럼 설정 완료:", result.length, "개");
} else { } else {
setToColumns([]); setToColumns([]);
console.log("⚠️ 컬럼이 없음");
} }
} catch (error) { } catch (error) {
console.error("❌ 컬럼 목록 로드 오류:", error); console.error("❌ 컬럼 목록 로드 오류:", error);
@ -239,7 +260,6 @@ export default function BatchManagementNewPage() {
// FROM 테이블 변경 핸들러 (DB → REST API용) // FROM 테이블 변경 핸들러 (DB → REST API용)
const handleFromTableChange = async (tableName: string) => { const handleFromTableChange = async (tableName: string) => {
console.log("🔍 FROM 테이블 변경:", { tableName, fromConnection });
setFromTable(tableName); setFromTable(tableName);
setFromColumns([]); setFromColumns([]);
setSelectedColumns([]); // 선택된 컬럼도 초기화 setSelectedColumns([]); // 선택된 컬럼도 초기화
@ -248,17 +268,11 @@ export default function BatchManagementNewPage() {
if (fromConnection && tableName) { if (fromConnection && tableName) {
try { try {
const connectionType = fromConnection.type === 'internal' ? 'internal' : 'external'; const connectionType = fromConnection.type === 'internal' ? 'internal' : 'external';
console.log("🔍 FROM 컬럼 조회 시작:", { connectionType, connectionId: fromConnection.id, tableName });
const result = await BatchManagementAPI.getTableColumns(connectionType, tableName, fromConnection.id); const result = await BatchManagementAPI.getTableColumns(connectionType, tableName, fromConnection.id);
console.log("🔍 FROM 컬럼 조회 결과:", result);
if (result && result.length > 0) { if (result && result.length > 0) {
setFromColumns(result); setFromColumns(result);
console.log("✅ FROM 컬럼 설정 완료:", result.length, "개");
} else { } else {
setFromColumns([]); setFromColumns([]);
console.log("⚠️ FROM 컬럼이 없음");
} }
} catch (error) { } catch (error) {
console.error("❌ FROM 컬럼 목록 로드 오류:", error); console.error("❌ FROM 컬럼 목록 로드 오류:", error);
@ -276,8 +290,6 @@ export default function BatchManagementNewPage() {
} }
try { try {
console.log("🔍 TO API 미리보기 시작:", { toApiUrl, toApiKey, toEndpoint, toApiMethod });
const result = await BatchManagementAPI.previewRestApiData( const result = await BatchManagementAPI.previewRestApiData(
toApiUrl, toApiUrl,
toApiKey, toApiKey,
@ -285,8 +297,6 @@ export default function BatchManagementNewPage() {
'GET' // 미리보기는 항상 GET으로 'GET' // 미리보기는 항상 GET으로
); );
console.log("🔍 TO API 미리보기 결과:", result);
if (result.fields && result.fields.length > 0) { if (result.fields && result.fields.length > 0) {
setToApiFields(result.fields); setToApiFields(result.fields);
toast.success(`TO API 필드 ${result.fields.length}개를 조회했습니다.`); toast.success(`TO API 필드 ${result.fields.length}개를 조회했습니다.`);
@ -303,17 +313,22 @@ export default function BatchManagementNewPage() {
// REST API 데이터 미리보기 // REST API 데이터 미리보기
const previewRestApiData = async () => { const previewRestApiData = async () => {
if (!fromApiUrl || !fromApiKey || !fromEndpoint) { // API URL, 엔드포인트는 항상 필수
toast.error("API URL, API Key, 엔드포인트를 모두 입력해주세요."); if (!fromApiUrl || !fromEndpoint) {
toast.error("API URL과 엔드포인트를 모두 입력해주세요.");
return;
}
// GET 메서드일 때만 API 키 필수
if (fromApiMethod === "GET" && !fromApiKey) {
toast.error("GET 메서드에서는 API 키를 입력해주세요.");
return; return;
} }
try { try {
console.log("REST API 데이터 미리보기 시작...");
const result = await BatchManagementAPI.previewRestApiData( const result = await BatchManagementAPI.previewRestApiData(
fromApiUrl, fromApiUrl,
fromApiKey, fromApiKey || "",
fromEndpoint, fromEndpoint,
fromApiMethod, fromApiMethod,
// 파라미터 정보 추가 // 파라미터 정보 추가
@ -322,33 +337,23 @@ export default function BatchManagementNewPage() {
paramName: apiParamName, paramName: apiParamName,
paramValue: apiParamValue, paramValue: apiParamValue,
paramSource: apiParamSource paramSource: apiParamSource
} : undefined } : undefined,
// Request Body 추가 (POST/PUT/DELETE)
(fromApiMethod === 'POST' || fromApiMethod === 'PUT' || fromApiMethod === 'DELETE') ? fromApiBody : undefined
); );
console.log("API 미리보기 결과:", result);
console.log("result.fields:", result.fields);
console.log("result.samples:", result.samples);
console.log("result.totalCount:", result.totalCount);
if (result.fields && result.fields.length > 0) { if (result.fields && result.fields.length > 0) {
console.log("✅ 백엔드에서 fields 제공됨:", result.fields);
setFromApiFields(result.fields); setFromApiFields(result.fields);
setFromApiData(result.samples); setFromApiData(result.samples);
console.log("추출된 필드:", result.fields);
toast.success(`API 데이터 미리보기 완료! ${result.fields.length}개 필드, ${result.totalCount}개 레코드`); toast.success(`API 데이터 미리보기 완료! ${result.fields.length}개 필드, ${result.totalCount}개 레코드`);
} else if (result.samples && result.samples.length > 0) { } else if (result.samples && result.samples.length > 0) {
// 백엔드에서 fields를 제대로 보내지 않은 경우, 프론트엔드에서 직접 추출 // 백엔드에서 fields를 제대로 보내지 않은 경우, 프론트엔드에서 직접 추출
console.log("⚠️ 백엔드에서 fields가 없어서 프론트엔드에서 추출");
const extractedFields = Object.keys(result.samples[0]); const extractedFields = Object.keys(result.samples[0]);
console.log("프론트엔드에서 추출한 필드:", extractedFields);
setFromApiFields(extractedFields); setFromApiFields(extractedFields);
setFromApiData(result.samples); setFromApiData(result.samples);
toast.success(`API 데이터 미리보기 완료! ${extractedFields.length}개 필드, ${result.samples.length}개 레코드`); toast.success(`API 데이터 미리보기 완료! ${extractedFields.length}개 필드, ${result.samples.length}개 레코드`);
} else { } else {
console.log("❌ 데이터가 없음");
setFromApiFields([]); setFromApiFields([]);
setFromApiData([]); setFromApiData([]);
toast.warning("API에서 데이터를 가져올 수 없습니다."); toast.warning("API에서 데이터를 가져올 수 없습니다.");
@ -370,38 +375,53 @@ export default function BatchManagementNewPage() {
// 배치 타입별 검증 및 저장 // 배치 타입별 검증 및 저장
if (batchType === 'restapi-to-db') { if (batchType === 'restapi-to-db') {
const mappedFields = Object.keys(apiFieldMappings).filter(field => apiFieldMappings[field]); const mappedFields = Object.keys(apiFieldMappings).filter(
(field) => apiFieldMappings[field]
);
if (mappedFields.length === 0) { if (mappedFields.length === 0) {
toast.error("최소 하나의 API 필드를 DB 컬럼에 매핑해주세요."); toast.error("최소 하나의 API 필드를 DB 컬럼에 매핑해주세요.");
return; return;
} }
// API 필드 매핑을 배치 매핑 형태로 변환 // API 필드 매핑을 배치 매핑 형태로 변환
const apiMappings = mappedFields.map(apiField => ({ const apiMappings = mappedFields.map((apiField) => {
from_connection_type: 'restapi' as const, const toColumnName = apiFieldMappings[apiField]; // 매핑된 DB 컬럼 (예: access_token)
from_table_name: fromEndpoint, // API 엔드포인트
from_column_name: apiField, // API 필드명
from_api_url: fromApiUrl,
from_api_key: fromApiKey,
from_api_method: fromApiMethod,
// API 파라미터 정보 추가
from_api_param_type: apiParamType !== 'none' ? apiParamType : undefined,
from_api_param_name: apiParamType !== 'none' ? apiParamName : undefined,
from_api_param_value: apiParamType !== 'none' ? apiParamValue : undefined,
from_api_param_source: apiParamType !== 'none' ? apiParamSource : undefined,
to_connection_type: toConnection?.type === 'internal' ? 'internal' : 'external',
to_connection_id: toConnection?.type === 'internal' ? undefined : toConnection?.id,
to_table_name: toTable,
to_column_name: apiFieldMappings[apiField], // 매핑된 DB 컬럼
mapping_type: 'direct' as const
}));
console.log("REST API 배치 설정 저장:", { // 기본은 상위 필드 그대로 사용하되,
batchName, // 사용자가 JSON 경로를 직접 입력한 경우 해당 경로를 우선 사용
batchType, let fromColumnName = apiField;
cronSchedule, const overridePath = apiFieldPathOverrides[apiField];
description, if (overridePath && overridePath.trim().length > 0) {
apiMappings fromColumnName = overridePath.trim();
}
return {
from_connection_type: "restapi" as const,
from_table_name: fromEndpoint, // API 엔드포인트
from_column_name: fromColumnName, // API 필드명 또는 중첩 경로
from_api_url: fromApiUrl,
from_api_key: fromApiKey,
from_api_method: fromApiMethod,
from_api_body:
fromApiMethod === "POST" ||
fromApiMethod === "PUT" ||
fromApiMethod === "DELETE"
? fromApiBody
: undefined,
// API 파라미터 정보 추가
from_api_param_type: apiParamType !== "none" ? apiParamType : undefined,
from_api_param_name: apiParamType !== "none" ? apiParamName : undefined,
from_api_param_value: apiParamType !== "none" ? apiParamValue : undefined,
from_api_param_source:
apiParamType !== "none" ? apiParamSource : undefined,
to_connection_type:
toConnection?.type === "internal" ? "internal" : "external",
to_connection_id:
toConnection?.type === "internal" ? undefined : toConnection?.id,
to_table_name: toTable,
to_column_name: toColumnName, // 매핑된 DB 컬럼
mapping_type: "direct" as const,
};
}); });
// 실제 API 호출 // 실제 API 호출
@ -492,14 +512,6 @@ export default function BatchManagementNewPage() {
} }
} }
console.log("DB → REST API 배치 설정 저장:", {
batchName,
batchType,
cronSchedule,
description,
dbMappings
});
// 실제 API 호출 (기존 saveRestApiBatch 재사용) // 실제 API 호출 (기존 saveRestApiBatch 재사용)
try { try {
const result = await BatchManagementAPI.saveRestApiBatch({ const result = await BatchManagementAPI.saveRestApiBatch({
@ -645,13 +657,19 @@ export default function BatchManagementNewPage() {
/> />
</div> </div>
<div> <div>
<Label htmlFor="fromApiKey">API *</Label> <Label htmlFor="fromApiKey">
API
{fromApiMethod === "GET" && <span className="text-red-500 ml-0.5">*</span>}
</Label>
<Input <Input
id="fromApiKey" id="fromApiKey"
value={fromApiKey} value={fromApiKey}
onChange={(e) => setFromApiKey(e.target.value)} onChange={(e) => setFromApiKey(e.target.value)}
placeholder="ak_your_api_key_here" placeholder="ak_your_api_key_here"
/> />
<p className="text-xs text-gray-500 mt-1">
GET , POST/PUT/DELETE일 .
</p>
</div> </div>
</div> </div>
@ -673,12 +691,33 @@ export default function BatchManagementNewPage() {
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="GET">GET ( )</SelectItem> <SelectItem value="GET">GET ( )</SelectItem>
<SelectItem value="POST">POST ( /)</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
</div> </div>
{/* Request Body (POST/PUT/DELETE용) */}
{(fromApiMethod === 'POST' || fromApiMethod === 'PUT' || fromApiMethod === 'DELETE') && (
<div>
<Label htmlFor="fromApiBody">Request Body (JSON)</Label>
<Textarea
id="fromApiBody"
value={fromApiBody}
onChange={(e) => setFromApiBody(e.target.value)}
placeholder='{"username": "myuser", "token": "abc"}'
className="min-h-[100px]"
rows={5}
/>
<p className="text-xs text-gray-500 mt-1">
API JSON .
</p>
</div>
)}
{/* API 파라미터 설정 */} {/* API 파라미터 설정 */}
<div className="space-y-4"> <div className="space-y-4">
<div className="border-t pt-4"> <div className="border-t pt-4">
@ -771,7 +810,10 @@ export default function BatchManagementNewPage() {
)} )}
</div> </div>
{fromApiUrl && fromApiKey && fromEndpoint && ( {/* API URL + 엔드포인트는 필수, GET일 때만 API 키 필수 */}
{fromApiUrl &&
fromEndpoint &&
(fromApiMethod !== "GET" || fromApiKey) && (
<div className="space-y-3"> <div className="space-y-3">
<div className="p-3 bg-gray-50 rounded-lg"> <div className="p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700">API </div> <div className="text-sm font-medium text-gray-700">API </div>
@ -786,7 +828,11 @@ export default function BatchManagementNewPage() {
: '' : ''
} }
</div> </div>
<div className="text-xs text-gray-500 mt-1">Headers: X-API-Key: {fromApiKey.substring(0, 10)}...</div> {fromApiKey && (
<div className="text-xs text-gray-500 mt-1">
Headers: X-API-Key: {fromApiKey.substring(0, 10)}...
</div>
)}
{apiParamType !== 'none' && apiParamName && apiParamValue && ( {apiParamType !== 'none' && apiParamName && apiParamValue && (
<div className="text-xs text-blue-600 mt-1"> <div className="text-xs text-blue-600 mt-1">
: {apiParamName} = {apiParamValue} ({apiParamSource === 'static' ? '고정값' : '동적값'}) : {apiParamName} = {apiParamValue} ({apiParamSource === 'static' ? '고정값' : '동적값'})
@ -980,172 +1026,33 @@ export default function BatchManagementNewPage() {
{/* 매핑 UI - 배치 타입별 동적 렌더링 */} {/* 매핑 UI - 배치 타입별 동적 렌더링 */}
{/* REST API → DB 매핑 */} {/* REST API → DB 매핑 */}
{batchType === 'restapi-to-db' && fromApiFields.length > 0 && toColumns.length > 0 && ( {batchType === "restapi-to-db" &&
<Card> fromApiFields.length > 0 &&
<CardHeader> toColumns.length > 0 && (
<CardTitle>API DB </CardTitle> <RestApiToDbMappingCard
</CardHeader> fromApiFields={fromApiFields}
<CardContent> toColumns={toColumns}
<div className="space-y-3 max-h-96 overflow-y-auto border rounded-lg p-4"> fromApiData={fromApiData}
{fromApiFields.map((apiField) => ( apiFieldMappings={apiFieldMappings}
<div key={apiField} className="flex items-center space-x-4 p-3 bg-gray-50 rounded-lg"> setApiFieldMappings={setApiFieldMappings}
{/* API 필드 정보 */} apiFieldPathOverrides={apiFieldPathOverrides}
<div className="flex-1"> setApiFieldPathOverrides={setApiFieldPathOverrides}
<div className="font-medium text-sm">{apiField}</div> />
<div className="text-xs text-gray-500"> )}
{fromApiData.length > 0 && fromApiData[0][apiField] !== undefined
? `예: ${String(fromApiData[0][apiField]).substring(0, 30)}${String(fromApiData[0][apiField]).length > 30 ? '...' : ''}`
: 'API 필드'
}
</div>
</div>
{/* 화살표 */}
<div className="text-gray-400">
<ArrowRight className="w-4 h-4" />
</div>
{/* DB 컬럼 선택 */}
<div className="flex-1">
<Select
value={apiFieldMappings[apiField] || "none"}
onValueChange={(value) => {
setApiFieldMappings(prev => ({
...prev,
[apiField]: value === "none" ? "" : value
}));
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="DB 컬럼 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{toColumns.map((column) => (
<SelectItem key={column.column_name} value={column.column_name}>
<div className="flex flex-col">
<span className="font-medium">{column.column_name.toUpperCase()}</span>
<span className="text-xs text-gray-500">{column.data_type}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
))}
</div>
{fromApiData.length > 0 && (
<div className="mt-3 p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-2"> ( 3)</div>
<div className="space-y-2 max-h-40 overflow-y-auto">
{fromApiData.slice(0, 3).map((item, index) => (
<div key={index} className="text-xs bg-white p-2 rounded border">
<pre className="whitespace-pre-wrap">{JSON.stringify(item, null, 2)}</pre>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
)}
{/* DB → REST API 매핑 */} {/* DB → REST API 매핑 */}
{batchType === 'db-to-restapi' && selectedColumns.length > 0 && toApiFields.length > 0 && ( {batchType === "db-to-restapi" &&
<Card> selectedColumns.length > 0 &&
<CardHeader> toApiFields.length > 0 && (
<CardTitle>DB API </CardTitle> <DbToRestApiMappingCard
<CardDescription> fromColumns={fromColumns}
DB REST API Request Body에 . Request Body 릿 {`{{컬럼명}}`} . selectedColumns={selectedColumns}
</CardDescription> toApiFields={toApiFields}
</CardHeader> dbToApiFieldMapping={dbToApiFieldMapping}
<CardContent> setDbToApiFieldMapping={setDbToApiFieldMapping}
<div className="space-y-3 max-h-96 overflow-y-auto border rounded-lg p-4"> setToApiBody={setToApiBody}
{fromColumns.filter(column => selectedColumns.includes(column.column_name)).map((column) => ( />
<div key={column.column_name} className="flex items-center space-x-4 p-3 bg-gray-50 rounded-lg"> )}
{/* DB 컬럼 정보 */}
<div className="flex-1">
<div className="font-medium text-sm">{column.column_name}</div>
<div className="text-xs text-gray-500">
: {column.data_type} | NULL: {column.is_nullable ? 'Y' : 'N'}
</div>
</div>
{/* 화살표 */}
<div className="text-gray-400"></div>
{/* API 필드 선택 드롭다운 */}
<div className="flex-1">
<Select
value={dbToApiFieldMapping[column.column_name] || ''}
onValueChange={(value) => {
setDbToApiFieldMapping(prev => ({
...prev,
[column.column_name]: value === 'none' ? '' : value
}));
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="API 필드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{toApiFields.map((apiField) => (
<SelectItem key={apiField} value={apiField}>
{apiField}
</SelectItem>
))}
<SelectItem value="custom"> ...</SelectItem>
</SelectContent>
</Select>
{/* 직접 입력 모드 */}
{dbToApiFieldMapping[column.column_name] === 'custom' && (
<input
type="text"
placeholder="API 필드명을 직접 입력하세요"
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 mt-2"
onChange={(e) => {
setDbToApiFieldMapping(prev => ({
...prev,
[column.column_name]: e.target.value
}));
}}
/>
)}
<div className="text-xs text-gray-500 mt-1">
{dbToApiFieldMapping[column.column_name]
? `매핑: ${column.column_name}${dbToApiFieldMapping[column.column_name]}`
: `기본값: ${column.column_name} (DB 컬럼명 사용)`
}
</div>
</div>
{/* 템플릿 미리보기 */}
<div className="flex-1">
<div className="text-sm font-mono bg-white p-2 rounded border">
{`{{${column.column_name}}}`}
</div>
<div className="text-xs text-gray-500 mt-1">
DB
</div>
</div>
</div>
))}
</div>
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<div className="text-sm font-medium text-blue-800"> </div>
<div className="text-xs text-blue-600 mt-1 font-mono">
{`{"id": "{{id}}", "name": "{{user_name}}", "email": "{{email}}"}`}
</div>
</div>
</CardContent>
</Card>
)}
{/* TO 설정 */} {/* TO 설정 */}
<Card> <Card>
@ -1348,4 +1255,278 @@ export default function BatchManagementNewPage() {
</Card> </Card>
</div> </div>
); );
} }
const RestApiToDbMappingCard = memo(function RestApiToDbMappingCard({
fromApiFields,
toColumns,
fromApiData,
apiFieldMappings,
setApiFieldMappings,
apiFieldPathOverrides,
setApiFieldPathOverrides,
}: RestApiToDbMappingCardProps) {
// 샘플 JSON 문자열은 의존 데이터가 바뀔 때만 계산
const sampleJsonList = useMemo(
() =>
fromApiData.slice(0, 3).map((item) => JSON.stringify(item, null, 2)),
[fromApiData]
);
const firstSample = fromApiData[0] || null;
return (
<Card>
<CardHeader>
<CardTitle>API DB </CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3 max-h-96 overflow-y-auto border rounded-lg p-4">
{fromApiFields.map((apiField) => (
<div
key={apiField}
className="flex items-center space-x-4 p-3 bg-gray-50 rounded-lg"
>
{/* API 필드 정보 */}
<div className="flex-1">
<div className="font-medium text-sm">{apiField}</div>
<div className="text-xs text-gray-500">
{firstSample && firstSample[apiField] !== undefined
? `예: ${String(firstSample[apiField]).substring(0, 30)}${
String(firstSample[apiField]).length > 30 ? "..." : ""
}`
: "API 필드"}
</div>
{/* JSON 경로 오버라이드 입력 */}
<div className="mt-1.5">
<Input
value={apiFieldPathOverrides[apiField] || ""}
onChange={(e) =>
setApiFieldPathOverrides((prev) => ({
...prev,
[apiField]: e.target.value,
}))
}
placeholder="JSON 경로 (예: response.access_token)"
className="h-7 text-xs"
/>
<p className="text-[11px] text-gray-500 mt-0.5">
"{apiField}" ,
.
</p>
</div>
</div>
{/* 화살표 */}
<div className="text-gray-400">
<ArrowRight className="w-4 h-4" />
</div>
{/* DB 컬럼 선택 */}
<div className="flex-1">
<Select
value={apiFieldMappings[apiField] || "none"}
onValueChange={(value) => {
setApiFieldMappings((prev) => ({
...prev,
[apiField]: value === "none" ? "" : value,
}));
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="DB 컬럼 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{toColumns.map((column) => (
<SelectItem
key={column.column_name}
value={column.column_name}
>
<div className="flex flex-col">
<span className="font-medium">
{column.column_name.toUpperCase()}
</span>
<span className="text-xs text-gray-500">
{column.data_type}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
))}
</div>
{sampleJsonList.length > 0 && (
<div className="mt-3 p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-2">
( 3)
</div>
<div className="space-y-2 max-h-40 overflow-y-auto">
{sampleJsonList.map((json, index) => (
<div
key={index}
className="text-xs bg-white p-2 rounded border"
>
<pre className="whitespace-pre-wrap">{json}</pre>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
);
});
const DbToRestApiMappingCard = memo(function DbToRestApiMappingCard({
fromColumns,
selectedColumns,
toApiFields,
dbToApiFieldMapping,
setDbToApiFieldMapping,
setToApiBody,
}: DbToRestApiMappingCardProps) {
const selectedColumnObjects = useMemo(
() =>
fromColumns.filter((column) =>
selectedColumns.includes(column.column_name)
),
[fromColumns, selectedColumns]
);
const autoJsonPreview = useMemo(() => {
if (selectedColumns.length === 0) {
return "";
}
const obj = selectedColumns.reduce((acc, col) => {
const apiField = dbToApiFieldMapping[col] || col;
acc[apiField] = `{{${col}}}`;
return acc;
}, {} as Record<string, string>);
return JSON.stringify(obj, null, 2);
}, [selectedColumns, dbToApiFieldMapping]);
return (
<Card>
<CardHeader>
<CardTitle>DB API </CardTitle>
<CardDescription>
DB REST API Request Body에 . Request Body
릿 {`{{컬럼명}}`} .
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3 max-h-96 overflow-y-auto border rounded-lg p-4">
{selectedColumnObjects.map((column) => (
<div
key={column.column_name}
className="flex items-center space-x-4 p-3 bg-gray-50 rounded-lg"
>
{/* DB 컬럼 정보 */}
<div className="flex-1">
<div className="font-medium text-sm">{column.column_name}</div>
<div className="text-xs text-gray-500">
: {column.data_type} | NULL: {column.is_nullable ? "Y" : "N"}
</div>
</div>
{/* 화살표 */}
<div className="text-gray-400"></div>
{/* API 필드 선택 드롭다운 */}
<div className="flex-1">
<Select
value={dbToApiFieldMapping[column.column_name] || ""}
onValueChange={(value) => {
setDbToApiFieldMapping((prev) => ({
...prev,
[column.column_name]: value === "none" ? "" : value,
}));
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="API 필드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{toApiFields.map((apiField) => (
<SelectItem key={apiField} value={apiField}>
{apiField}
</SelectItem>
))}
<SelectItem value="custom"> ...</SelectItem>
</SelectContent>
</Select>
{/* 직접 입력 모드 */}
{dbToApiFieldMapping[column.column_name] === "custom" && (
<input
type="text"
placeholder="API 필드명을 직접 입력하세요"
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 mt-2"
onChange={(e) => {
setDbToApiFieldMapping((prev) => ({
...prev,
[column.column_name]: e.target.value,
}));
}}
/>
)}
<div className="text-xs text-gray-500 mt-1">
{dbToApiFieldMapping[column.column_name]
? `매핑: ${column.column_name}${dbToApiFieldMapping[column.column_name]}`
: `기본값: ${column.column_name} (DB 컬럼명 사용)`}
</div>
</div>
{/* 템플릿 미리보기 */}
<div className="flex-1">
<div className="text-sm font-mono bg-white p-2 rounded border">
{`{{${column.column_name}}}`}
</div>
<div className="text-xs text-gray-500 mt-1">
DB
</div>
</div>
</div>
))}
</div>
{selectedColumns.length > 0 && (
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<div className="text-sm font-medium text-blue-800">
JSON
</div>
<pre className="mt-1 text-xs text-blue-600 font-mono overflow-x-auto">
{autoJsonPreview}
</pre>
<button
type="button"
onClick={() => {
setToApiBody(autoJsonPreview);
toast.success(
"Request Body에 자동 생성된 JSON이 적용되었습니다."
);
}}
className="mt-2 px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700"
>
Request Body에
</button>
</div>
)}
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<div className="text-sm font-medium text-blue-800"> </div>
<div className="text-xs text-blue-600 mt-1 font-mono">
{`{"id": "{{id}}", "name": "{{user_name}}", "email": "{{email}}"}`}
</div>
</div>
</CardContent>
</Card>
);
});

View File

@ -7,12 +7,24 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { RefreshCw, Save, ArrowLeft, Plus, Trash2 } from "lucide-react"; import { RefreshCw, Save, ArrowLeft, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { BatchAPI, BatchConfig, BatchMapping, ConnectionInfo } from "@/lib/api/batch"; import {
BatchAPI,
BatchConfig,
BatchMapping,
ConnectionInfo,
} from "@/lib/api/batch";
import { BatchManagementAPI } from "@/lib/api/batchManagement";
interface BatchColumnInfo { interface BatchColumnInfo {
column_name: string; column_name: string;
@ -66,6 +78,9 @@ export default function BatchEditPage() {
// 배치 타입 감지 // 배치 타입 감지
const [batchType, setBatchType] = useState<'db-to-db' | 'restapi-to-db' | 'db-to-restapi' | null>(null); const [batchType, setBatchType] = useState<'db-to-db' | 'restapi-to-db' | 'db-to-restapi' | null>(null);
// REST API 미리보기 상태
const [apiPreviewData, setApiPreviewData] = useState<any[]>([]);
// 페이지 로드 시 배치 정보 조회 // 페이지 로드 시 배치 정보 조회
useEffect(() => { useEffect(() => {
@ -335,6 +350,86 @@ export default function BatchEditPage() {
setMappings([...mappings, newMapping]); setMappings([...mappings, newMapping]);
}; };
// REST API → DB 매핑 추가
const addRestapiToDbMapping = () => {
if (!batchConfig || !batchConfig.batch_mappings || batchConfig.batch_mappings.length === 0) {
return;
}
const first = batchConfig.batch_mappings[0] as any;
const newMapping: BatchMapping = {
// FROM: REST API (기존 설정 그대로 복사)
from_connection_type: "restapi" as any,
from_connection_id: first.from_connection_id,
from_table_name: first.from_table_name,
from_column_name: "",
from_column_type: "",
// TO: DB (기존 설정 그대로 복사)
to_connection_type: first.to_connection_type as any,
to_connection_id: first.to_connection_id,
to_table_name: first.to_table_name,
to_column_name: "",
to_column_type: "",
mapping_type: (first.mapping_type as any) || "direct",
mapping_order: mappings.length + 1,
};
setMappings((prev) => [...prev, newMapping]);
};
// REST API 데이터 미리보기 (수정 화면용)
const previewRestApiData = async () => {
if (!mappings || mappings.length === 0) {
toast.error("미리보기할 REST API 매핑이 없습니다.");
return;
}
const first: any = mappings[0];
if (!first.from_api_url || !first.from_table_name) {
toast.error("API URL과 엔드포인트 정보가 없습니다.");
return;
}
try {
const method =
(first.from_api_method as "GET" | "POST" | "PUT" | "DELETE") || "GET";
const paramInfo =
first.from_api_param_type &&
first.from_api_param_name &&
first.from_api_param_value
? {
paramType: first.from_api_param_type as "url" | "query",
paramName: first.from_api_param_name as string,
paramValue: first.from_api_param_value as string,
paramSource:
(first.from_api_param_source as "static" | "dynamic") ||
"static",
}
: undefined;
const result = await BatchManagementAPI.previewRestApiData(
first.from_api_url,
first.from_api_key || "",
first.from_table_name,
method,
paramInfo,
first.from_api_body || undefined
);
setApiPreviewData(result.samples || []);
toast.success(
`API 데이터 미리보기 완료! ${result.fields.length}개 필드, ${result.samples.length}개 레코드`
);
} catch (error) {
console.error("REST API 미리보기 오류:", error);
toast.error("API 데이터 미리보기에 실패했습니다.");
}
};
// 매핑 삭제 // 매핑 삭제
const removeMapping = (index: number) => { const removeMapping = (index: number) => {
const updatedMappings = mappings.filter((_, i) => i !== index); const updatedMappings = mappings.filter((_, i) => i !== index);
@ -404,14 +499,16 @@ export default function BatchEditPage() {
<h1 className="text-3xl font-bold"> </h1> <h1 className="text-3xl font-bold"> </h1>
</div> </div>
<div className="flex space-x-2"> <div className="flex space-x-2">
<Button onClick={loadBatchConfig} variant="outline" disabled={loading}> <Button
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} /> onClick={loadBatchConfig}
variant="outline"
disabled={loading}
>
<RefreshCw
className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`}
/>
</Button> </Button>
<Button onClick={saveBatchConfig} disabled={loading}>
<Save className="w-4 h-4 mr-2" />
</Button>
</div> </div>
</div> </div>
@ -580,22 +677,91 @@ export default function BatchEditPage() {
</div> </div>
{mappings.length > 0 && ( {mappings.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="space-y-4">
<div> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Label>API URL</Label> <div>
<Input value={mappings[0]?.from_api_url || ''} readOnly /> <Label>API URL</Label>
<Input value={mappings[0]?.from_api_url || ""} readOnly />
</div>
<div>
<Label>API </Label>
<Input
value={mappings[0]?.from_table_name || ""}
readOnly
/>
</div>
<div>
<Label>HTTP </Label>
<Input
value={mappings[0]?.from_api_method || "GET"}
readOnly
/>
</div>
<div>
<Label> </Label>
<Input
value={mappings[0]?.to_table_name || ""}
readOnly
/>
</div>
</div> </div>
{/* Request Body (JSON) 편집 UI */}
<div> <div>
<Label>API </Label> <Label>Request Body (JSON)</Label>
<Input value={mappings[0]?.from_table_name || ''} readOnly /> <Textarea
rows={5}
className="font-mono text-sm"
placeholder='{"id": "wace", "pwd": "wace!$%Pwdmo^^"}'
value={mappings[0]?.from_api_body || ""}
onChange={(e) => {
const value = e.target.value;
setMappings((prev) => {
if (prev.length === 0) return prev;
const updated = [...prev];
updated[0] = {
...updated[0],
from_api_body: value,
} as any;
return updated;
});
}}
/>
<p className="text-xs text-muted-foreground mt-1.5">
POST JSON Request Body를 .
.
</p>
</div> </div>
<div>
<Label>HTTP </Label> {/* API 데이터 미리보기 */}
<Input value={mappings[0]?.from_api_method || 'GET'} readOnly /> <div className="space-y-3">
</div> <Button
<div> variant="outline"
<Label> </Label> size="sm"
<Input value={mappings[0]?.to_table_name || ''} readOnly /> onClick={previewRestApiData}
className="mt-2"
>
<RefreshCw className="w-4 h-4 mr-2" />
API
</Button>
{apiPreviewData.length > 0 && (
<div className="mt-2 rounded-lg border bg-muted p-3">
<p className="text-sm font-medium text-muted-foreground">
( 3)
</p>
<div className="mt-2 space-y-2 max-h-60 overflow-y-auto">
{apiPreviewData.slice(0, 3).map((item, index) => (
<pre
key={index}
className="whitespace-pre-wrap rounded border bg-background p-2 text-xs font-mono"
>
{JSON.stringify(item, null, 2)}
</pre>
))}
</div>
</div>
)}
</div> </div>
</div> </div>
)} )}
@ -647,6 +813,12 @@ export default function BatchEditPage() {
</Button> </Button>
)} )}
{batchType === 'restapi-to-db' && (
<Button onClick={addRestapiToDbMapping} size="sm">
<Plus className="w-4 h-4 mr-2" />
</Button>
)}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -751,20 +923,73 @@ export default function BatchEditPage() {
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div> <div>
<h4 className="font-medium"> #{index + 1}</h4> <h4 className="font-medium"> #{index + 1}</h4>
<p className="text-sm text-gray-600"> {mapping.from_column_name && mapping.to_column_name && (
API : {mapping.from_column_name} DB : {mapping.to_column_name} <p className="text-sm text-gray-600">
</p> API : {mapping.from_column_name} DB : {mapping.to_column_name}
</p>
)}
</div> </div>
<Button
variant="outline"
size="sm"
onClick={() => removeMapping(index)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<Label>API </Label> <Label>API (JSON )</Label>
<Input value={mapping.from_column_name || ''} readOnly /> <Input
value={mapping.from_column_name || ""}
onChange={(e) =>
updateMapping(
index,
"from_column_name",
e.target.value
)
}
placeholder="response.access_token"
/>
</div> </div>
<div> <div>
<Label>DB </Label> <Label>DB </Label>
<Input value={mapping.to_column_name || ''} readOnly /> <Select
value={mapping.to_column_name || ""}
onValueChange={(value) => {
updateMapping(index, "to_column_name", value);
const selectedColumn = toColumns.find(
(col) => col.column_name === value
);
if (selectedColumn) {
updateMapping(
index,
"to_column_type",
selectedColumn.data_type
);
}
}}
>
<SelectTrigger>
<SelectValue placeholder="대상 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{toColumns.map((column) => (
<SelectItem
key={column.column_name}
value={column.column_name}
>
{column.column_name} ({column.data_type})
</SelectItem>
))}
</SelectContent>
</Select>
{toColumns.length === 0 && (
<p className="text-xs text-gray-500 mt-1">
.
</p>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -195,6 +195,7 @@ export default function DashboardListClient() {
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b"> <TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
<TableHead className="h-12 text-sm font-semibold"></TableHead> <TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead> <TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead> <TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead> <TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-right text-sm font-semibold"></TableHead> <TableHead className="h-12 text-right text-sm font-semibold"></TableHead>
@ -209,6 +210,9 @@ export default function DashboardListClient() {
<TableCell className="h-16"> <TableCell className="h-16">
<div className="bg-muted h-4 animate-pulse rounded"></div> <div className="bg-muted h-4 animate-pulse rounded"></div>
</TableCell> </TableCell>
<TableCell className="h-16">
<div className="bg-muted h-4 w-20 animate-pulse rounded"></div>
</TableCell>
<TableCell className="h-16"> <TableCell className="h-16">
<div className="bg-muted h-4 w-24 animate-pulse rounded"></div> <div className="bg-muted h-4 w-24 animate-pulse rounded"></div>
</TableCell> </TableCell>
@ -277,6 +281,7 @@ export default function DashboardListClient() {
<TableRow className="bg-muted/50 hover:bg-muted/50 border-b"> <TableRow className="bg-muted/50 hover:bg-muted/50 border-b">
<TableHead className="h-12 text-sm font-semibold"></TableHead> <TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead> <TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead> <TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-sm font-semibold"></TableHead> <TableHead className="h-12 text-sm font-semibold"></TableHead>
<TableHead className="h-12 text-right text-sm font-semibold"></TableHead> <TableHead className="h-12 text-right text-sm font-semibold"></TableHead>
@ -296,6 +301,9 @@ export default function DashboardListClient() {
<TableCell className="text-muted-foreground h-16 max-w-md truncate text-sm"> <TableCell className="text-muted-foreground h-16 max-w-md truncate text-sm">
{dashboard.description || "-"} {dashboard.description || "-"}
</TableCell> </TableCell>
<TableCell className="text-muted-foreground h-16 text-sm">
{dashboard.createdByName || dashboard.createdBy || "-"}
</TableCell>
<TableCell className="text-muted-foreground h-16 text-sm"> <TableCell className="text-muted-foreground h-16 text-sm">
{formatDate(dashboard.createdAt)} {formatDate(dashboard.createdAt)}
</TableCell> </TableCell>
@ -363,6 +371,10 @@ export default function DashboardListClient() {
<span className="text-muted-foreground"></span> <span className="text-muted-foreground"></span>
<span className="max-w-[200px] truncate font-medium">{dashboard.description || "-"}</span> <span className="max-w-[200px] truncate font-medium">{dashboard.description || "-"}</span>
</div> </div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">{dashboard.createdByName || dashboard.createdBy || "-"}</span>
</div>
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-muted-foreground"></span> <span className="text-muted-foreground"></span>
<span className="font-medium">{formatDate(dashboard.createdAt)}</span> <span className="font-medium">{formatDate(dashboard.createdAt)}</span>

View File

@ -45,6 +45,7 @@ import {
saveDraft, saveDraft,
updateDraft, updateDraft,
} from "@/lib/api/mail"; } from "@/lib/api/mail";
import { API_BASE_URL } from "@/lib/api/client";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
export default function MailSendPage() { export default function MailSendPage() {
@ -498,7 +499,7 @@ ${data.originalBody}`;
throw new Error("인증 토큰이 없습니다. 다시 로그인해주세요."); throw new Error("인증 토큰이 없습니다. 다시 로그인해주세요.");
} }
const response = await fetch("/api/mail/send/simple", { const response = await fetch(`${API_BASE_URL}/mail/send/simple`, {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${authToken}`, Authorization: `Bearer ${authToken}`,
@ -1226,6 +1227,91 @@ ${data.originalBody}`;
</div> </div>
); );
case 'header':
return (
<div key={component.id} className="p-4 rounded-lg" style={{ backgroundColor: component.headerBgColor || '#f8f9fa' }}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{component.logoSrc && <img src={component.logoSrc} alt="로고" className="h-10" />}
<span className="font-bold text-lg">{component.brandName}</span>
</div>
<span className="text-sm text-gray-500">{component.sendDate}</span>
</div>
</div>
);
case 'infoTable':
return (
<div key={component.id} className="border rounded-lg overflow-hidden">
{component.tableTitle && (
<div className="bg-gray-50 px-4 py-2 font-semibold border-b">{component.tableTitle}</div>
)}
<table className="w-full">
<tbody>
{component.rows?.map((row: any, i: number) => (
<tr key={i} className={i % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
<td className="px-4 py-2 font-medium text-gray-600 w-1/3 border-r">{row.label}</td>
<td className="px-4 py-2">{row.value}</td>
</tr>
))}
</tbody>
</table>
</div>
);
case 'alertBox':
return (
<div key={component.id} className={`p-4 rounded-lg border-l-4 ${
component.alertType === 'info' ? 'bg-blue-50 border-blue-500 text-blue-800' :
component.alertType === 'warning' ? 'bg-amber-50 border-amber-500 text-amber-800' :
component.alertType === 'danger' ? 'bg-red-50 border-red-500 text-red-800' :
'bg-emerald-50 border-emerald-500 text-emerald-800'
}`}>
{component.alertTitle && <div className="font-bold mb-1">{component.alertTitle}</div>}
<div>{component.content}</div>
</div>
);
case 'divider':
return (
<hr key={component.id} className="border-gray-300" style={{ borderWidth: `${component.height || 1}px` }} />
);
case 'footer':
return (
<div key={component.id} className="text-center text-sm text-gray-500 py-4 border-t bg-gray-50">
{component.companyName && <div className="font-semibold text-gray-700">{component.companyName}</div>}
{(component.ceoName || component.businessNumber) && (
<div className="mt-1">
{component.ceoName && <span>: {component.ceoName}</span>}
{component.ceoName && component.businessNumber && <span className="mx-2">|</span>}
{component.businessNumber && <span>: {component.businessNumber}</span>}
</div>
)}
{component.address && <div className="mt-1">{component.address}</div>}
{(component.phone || component.email) && (
<div className="mt-1">
{component.phone && <span>Tel: {component.phone}</span>}
{component.phone && component.email && <span className="mx-2">|</span>}
{component.email && <span>Email: {component.email}</span>}
</div>
)}
{component.copyright && <div className="mt-2 text-xs text-gray-400">{component.copyright}</div>}
</div>
);
case 'numberedList':
return (
<div key={component.id} className="p-4">
{component.listTitle && <div className="font-semibold mb-2">{component.listTitle}</div>}
<ol className="list-decimal list-inside space-y-1">
{component.listItems?.map((item: string, i: number) => (
<li key={i}>{item}</li>
))}
</ol>
</div>
);
default: default:
return null; return null;

View File

@ -17,7 +17,7 @@ import { apiClient } from "@/lib/api/client";
import { commonCodeApi } from "@/lib/api/commonCode"; import { commonCodeApi } from "@/lib/api/commonCode";
import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin"; import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin";
import { ddlApi } from "@/lib/api/ddl"; 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 { CreateTableModal } from "@/components/admin/CreateTableModal";
import { AddColumnModal } from "@/components/admin/AddColumnModal"; import { AddColumnModal } from "@/components/admin/AddColumnModal";
import { DDLLogViewer } from "@/components/admin/DDLLogViewer"; import { DDLLogViewer } from "@/components/admin/DDLLogViewer";
@ -488,52 +488,69 @@ export default function TableManagementPage() {
if (response.data.success) { if (response.data.success) {
console.log("✅ 컬럼 설정 저장 성공"); console.log("✅ 컬럼 설정 저장 성공");
// 🆕 Category 타입인 경우 컬럼 매핑 생성 // 🆕 Category 타입인 경우 컬럼 매핑 처리
console.log("🔍 카테고리 조건 체크:", { console.log("🔍 카테고리 조건 체크:", {
isCategory: column.inputType === "category", isCategory: column.inputType === "category",
hasCategoryMenus: !!column.categoryMenus, hasCategoryMenus: !!column.categoryMenus,
length: column.categoryMenus?.length || 0, length: column.categoryMenus?.length || 0,
}); });
if (column.inputType === "category" && column.categoryMenus && column.categoryMenus.length > 0) { if (column.inputType === "category") {
console.log("📥 카테고리 메뉴 매핑 시작:", { // 1. 먼저 기존 매핑 모두 삭제
console.log("🗑️ 기존 카테고리 메뉴 매핑 삭제 시작:", {
tableName: selectedTable,
columnName: column.columnName, columnName: column.columnName,
categoryMenus: column.categoryMenus,
count: column.categoryMenus.length,
}); });
let successCount = 0; try {
let failCount = 0; const deleteResponse = await deleteColumnMappingsByColumn(selectedTable, column.columnName);
console.log("🗑️ 기존 매핑 삭제 결과:", deleteResponse);
for (const menuObjid of column.categoryMenus) { } catch (error) {
try { console.error("❌ 기존 매핑 삭제 실패:", error);
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++;
}
} }
// 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) { let successCount = 0;
toast.success(`컬럼 설정 및 ${successCount}개 메뉴 매핑이 저장되었습니다.`); let failCount = 0;
} else if (successCount > 0 && failCount > 0) {
toast.warning(`컬럼 설정 저장 성공. ${successCount}개 메뉴 매핑 성공, ${failCount}개 실패.`); for (const menuObjid of column.categoryMenus) {
} else if (failCount > 0) { try {
toast.error(`컬럼 설정 저장 성공. 메뉴 매핑 생성 실패.`); 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 { } else {
toast.success("컬럼 설정이 성공적으로 저장되었습니다."); toast.success("컬럼 설정이 성공적으로 저장되었습니다.");
@ -596,10 +613,8 @@ export default function TableManagementPage() {
); );
if (response.data.success) { if (response.data.success) {
// 🆕 Category 타입 컬럼들의 메뉴 매핑 생성 // 🆕 Category 타입 컬럼들의 메뉴 매핑 처리
const categoryColumns = columns.filter( const categoryColumns = columns.filter((col) => col.inputType === "category");
(col) => col.inputType === "category" && col.categoryMenus && col.categoryMenus.length > 0
);
console.log("📥 전체 저장: 카테고리 컬럼 확인", { console.log("📥 전체 저장: 카테고리 컬럼 확인", {
totalColumns: columns.length, totalColumns: columns.length,
@ -615,33 +630,49 @@ export default function TableManagementPage() {
let totalFailCount = 0; let totalFailCount = 0;
for (const column of categoryColumns) { for (const column of categoryColumns) {
for (const menuObjid of column.categoryMenus!) { // 1. 먼저 기존 매핑 모두 삭제
try { console.log("🗑️ 기존 카테고리 메뉴 매핑 삭제:", {
console.log("🔄 매핑 API 호출:", { tableName: selectedTable,
tableName: selectedTable, columnName: column.columnName,
columnName: column.columnName, });
menuObjid,
});
const mappingResponse = await createColumnMapping({ try {
tableName: selectedTable, const deleteResponse = await deleteColumnMappingsByColumn(selectedTable, column.columnName);
logicalColumnName: column.columnName, console.log("🗑️ 기존 매핑 삭제 결과:", deleteResponse);
physicalColumnName: column.columnName, } catch (error) {
menuObjid, console.error("❌ 기존 매핑 삭제 실패:", error);
description: `${column.displayName} (메뉴별 카테고리)`, }
});
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) { const mappingResponse = await createColumnMapping({
totalSuccessCount++; tableName: selectedTable,
} else { logicalColumnName: column.columnName,
console.error("❌ 매핑 생성 실패:", mappingResponse); 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++; totalFailCount++;
} }
} catch (error) {
console.error(`❌ 메뉴 ${menuObjid}에 대한 매핑 생성 실패:`, error);
totalFailCount++;
} }
} }
} }
@ -1108,14 +1139,11 @@ export default function TableManagementPage() {
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{/* 컬럼 헤더 */} {/* 컬럼 헤더 */}
<div className="text-foreground flex h-12 items-center border-b px-6 py-3 text-sm font-semibold"> <div className="text-foreground grid h-12 items-center border-b px-6 py-3 text-sm font-semibold" style={{ gridTemplateColumns: "160px 200px 250px 1fr" }}>
<div className="w-40 pr-4"></div> <div className="pr-4"></div>
<div className="w-48 px-4"></div> <div className="px-4"></div>
<div className="w-48 pr-6"> </div> <div className="pr-6"> </div>
<div className="flex-1 pl-6" style={{ maxWidth: "calc(100% - 808px)" }}> <div className="pl-4"></div>
</div>
<div className="w-80 pl-4"></div>
</div> </div>
{/* 컬럼 리스트 */} {/* 컬럼 리스트 */}
@ -1132,12 +1160,13 @@ export default function TableManagementPage() {
{columns.map((column, index) => ( {columns.map((column, index) => (
<div <div
key={column.columnName} key={column.columnName}
className="bg-background hover:bg-muted/50 flex min-h-16 items-center border-b px-6 py-3 transition-colors" className="bg-background hover:bg-muted/50 grid min-h-16 items-start border-b px-6 py-3 transition-colors"
style={{ gridTemplateColumns: "160px 200px 250px 1fr" }}
> >
<div className="w-40 pr-4"> <div className="pr-4 pt-1">
<div className="font-mono text-sm">{column.columnName}</div> <div className="font-mono text-sm">{column.columnName}</div>
</div> </div>
<div className="w-48 px-4"> <div className="px-4">
<Input <Input
value={column.displayName || ""} value={column.displayName || ""}
onChange={(e) => handleLabelChange(column.columnName, e.target.value)} onChange={(e) => handleLabelChange(column.columnName, e.target.value)}
@ -1145,107 +1174,106 @@ export default function TableManagementPage() {
className="h-8 text-xs" className="h-8 text-xs"
/> />
</div> </div>
<div className="w-48 pr-6"> <div className="pr-6">
<Select <div className="space-y-3">
value={column.inputType || "text"} {/* 입력 타입 선택 */}
onValueChange={(value) => handleInputTypeChange(column.columnName, value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="입력 타입 선택" />
</SelectTrigger>
<SelectContent>
{memoizedInputTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex-1 pl-6" style={{ maxWidth: "calc(100% - 808px)" }}>
{/* 입력 타입이 'code'인 경우 공통코드 선택 */}
{column.inputType === "code" && (
<Select <Select
value={column.codeCategory || "none"} value={column.inputType || "text"}
onValueChange={(value) => onValueChange={(value) => handleInputTypeChange(column.columnName, value)}
handleDetailSettingsChange(column.columnName, "code", value)
}
> >
<SelectTrigger className="h-8 text-xs"> <SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="공통코드 선택" /> <SelectValue placeholder="입력 타입 선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{commonCodeOptions.map((option, index) => ( {memoizedInputTypeOptions.map((option) => (
<SelectItem key={`code-${option.value}-${index}`} value={option.value}> <SelectItem key={option.value} value={option.value}>
{option.label} {option.label}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
)} {/* 입력 타입이 'code'인 경우 공통코드 선택 */}
{/* 입력 타입이 'category'인 경우 2레벨 메뉴 다중 선택 */} {column.inputType === "code" && (
{column.inputType === "category" && ( <Select
<div className="space-y-2"> value={column.codeCategory || "none"}
<label className="text-muted-foreground mb-1 block text-xs"> onValueChange={(value) =>
(2) handleDetailSettingsChange(column.columnName, "code", value)
</label> }
<div className="border rounded-lg p-3 space-y-2 max-h-48 overflow-y-auto"> >
{secondLevelMenus.length === 0 ? ( <SelectTrigger className="h-8 text-xs">
<p className="text-xs text-muted-foreground"> <SelectValue placeholder="공통코드 선택" />
2 . . </SelectTrigger>
</p> <SelectContent>
) : ( {commonCodeOptions.map((option, index) => (
secondLevelMenus.map((menu) => { <SelectItem key={`code-${option.value}-${index}`} value={option.value}>
// menuObjid를 숫자로 변환하여 비교 {option.label}
const menuObjidNum = Number(menu.menuObjid); </SelectItem>
const isChecked = (column.categoryMenus || []).includes(menuObjidNum); ))}
</SelectContent>
return ( </Select>
<div key={menu.menuObjid} className="flex items-center gap-2"> )}
<input {/* 입력 타입이 'category'인 경우 2레벨 메뉴 다중 선택 */}
type="checkbox" {column.inputType === "category" && (
id={`category-menu-${column.columnName}-${menu.menuObjid}`} <div className="space-y-2">
checked={isChecked} <label className="text-muted-foreground mb-1 block text-xs">
onChange={(e) => { (2)
const currentMenus = column.categoryMenus || []; </label>
const newMenus = e.target.checked <div className="border rounded-lg p-3 space-y-2 max-h-48 overflow-y-auto">
? [...currentMenus, menuObjidNum] {secondLevelMenus.length === 0 ? (
: currentMenus.filter((id) => id !== menuObjidNum); <p className="text-xs text-muted-foreground">
2 . .
</p>
) : (
secondLevelMenus.map((menu) => {
// menuObjid를 숫자로 변환하여 비교
const menuObjidNum = Number(menu.menuObjid);
const isChecked = (column.categoryMenus || []).includes(menuObjidNum);
return (
<div key={menu.menuObjid} className="flex items-center gap-2">
<input
type="checkbox"
id={`category-menu-${column.columnName}-${menu.menuObjid}`}
checked={isChecked}
onChange={(e) => {
const currentMenus = column.categoryMenus || [];
const newMenus = e.target.checked
? [...currentMenus, menuObjidNum]
: currentMenus.filter((id) => id !== menuObjidNum);
setColumns((prev) => setColumns((prev) =>
prev.map((col) => prev.map((col) =>
col.columnName === column.columnName col.columnName === column.columnName
? { ...col, categoryMenus: newMenus } ? { ...col, categoryMenus: newMenus }
: col : col
) )
); );
}} }}
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-ring" className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-ring"
/> />
<label <label
htmlFor={`category-menu-${column.columnName}-${menu.menuObjid}`} htmlFor={`category-menu-${column.columnName}-${menu.menuObjid}`}
className="text-xs cursor-pointer flex-1" className="text-xs cursor-pointer flex-1"
> >
{menu.parentMenuName} {menu.menuName} {menu.parentMenuName} {menu.menuName}
</label> </label>
</div> </div>
); );
}) })
)}
</div>
{column.categoryMenus && column.categoryMenus.length > 0 && (
<p className="text-primary text-xs">
{column.categoryMenus.length}
</p>
)} )}
</div> </div>
{column.categoryMenus && column.categoryMenus.length > 0 && ( )}
<p className="text-primary text-xs"> {/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
{column.categoryMenus.length} {column.inputType === "entity" && (
</p> <>
)}
</div>
)}
{/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
{column.inputType === "entity" && (
<div className="space-y-2">
<div className="grid grid-cols-3 gap-2">
{/* 참조 테이블 */} {/* 참조 테이블 */}
<div> <div className="w-48">
<label className="text-muted-foreground mb-1 block text-xs"> <label className="text-muted-foreground mb-1 block text-xs">
</label> </label>
@ -1255,7 +1283,7 @@ export default function TableManagementPage() {
handleDetailSettingsChange(column.columnName, "entity", value) handleDetailSettingsChange(column.columnName, "entity", value)
} }
> >
<SelectTrigger className="bg-background h-8 text-xs"> <SelectTrigger className="bg-background h-8 w-full text-xs">
<SelectValue placeholder="선택" /> <SelectValue placeholder="선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -1278,7 +1306,7 @@ export default function TableManagementPage() {
{/* 조인 컬럼 */} {/* 조인 컬럼 */}
{column.referenceTable && column.referenceTable !== "none" && ( {column.referenceTable && column.referenceTable !== "none" && (
<div> <div className="w-48">
<label className="text-muted-foreground mb-1 block text-xs"> <label className="text-muted-foreground mb-1 block text-xs">
</label> </label>
@ -1292,7 +1320,7 @@ export default function TableManagementPage() {
) )
} }
> >
<SelectTrigger className="bg-background h-8 text-xs"> <SelectTrigger className="bg-background h-8 w-full text-xs">
<SelectValue placeholder="선택" /> <SelectValue placeholder="선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -1324,7 +1352,7 @@ export default function TableManagementPage() {
column.referenceTable !== "none" && column.referenceTable !== "none" &&
column.referenceColumn && column.referenceColumn &&
column.referenceColumn !== "none" && ( column.referenceColumn !== "none" && (
<div> <div className="w-48">
<label className="text-muted-foreground mb-1 block text-xs"> <label className="text-muted-foreground mb-1 block text-xs">
</label> </label>
@ -1338,7 +1366,7 @@ export default function TableManagementPage() {
) )
} }
> >
<SelectTrigger className="bg-background h-8 text-xs"> <SelectTrigger className="bg-background h-8 w-full text-xs">
<SelectValue placeholder="선택" /> <SelectValue placeholder="선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -1364,37 +1392,29 @@ export default function TableManagementPage() {
</Select> </Select>
</div> </div>
)} )}
</div>
{/* 설정 완료 표시 */} {/* 설정 완료 표시 */}
{column.referenceTable && {column.referenceTable &&
column.referenceTable !== "none" && column.referenceTable !== "none" &&
column.referenceColumn && column.referenceColumn &&
column.referenceColumn !== "none" && column.referenceColumn !== "none" &&
column.displayColumn && column.displayColumn &&
column.displayColumn !== "none" && ( column.displayColumn !== "none" && (
<div className="bg-primary/10 text-primary mt-2 flex items-center gap-1 rounded px-2 py-1 text-xs"> <div className="bg-primary/10 text-primary flex items-center gap-1 rounded px-2 py-1 text-xs w-48">
<span></span> <span></span>
<span className="truncate"> <span className="truncate"> </span>
{column.columnName} {column.referenceTable}.{column.displayColumn} </div>
</span> )}
</div> </>
)} )}
</div> </div>
)}
{/* 다른 입력 타입인 경우 빈 공간 */}
{column.inputType !== "code" && column.inputType !== "category" && column.inputType !== "entity" && (
<div className="text-muted-foreground flex h-8 items-center justify-center text-xs">
-
</div>
)}
</div> </div>
<div className="w-80 pl-4"> <div className="pl-4">
<Input <Input
value={column.description || ""} value={column.description || ""}
onChange={(e) => handleColumnChange(index, "description", e.target.value)} onChange={(e) => handleColumnChange(index, "description", e.target.value)}
placeholder="설명" placeholder="설명"
className="h-8 text-xs" className="h-8 w-full text-xs"
/> />
</div> </div>
</div> </div>
@ -1585,3 +1605,4 @@ export default function TableManagementPage() {
</div> </div>
); );
} }

View File

@ -20,6 +20,7 @@ import { useAuth } from "@/hooks/useAuth"; // 🆕 사용자 정보
import { useResponsive } from "@/lib/hooks/useResponsive"; // 🆕 반응형 감지 import { useResponsive } from "@/lib/hooks/useResponsive"; // 🆕 반응형 감지
import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; // 🆕 테이블 옵션 import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; // 🆕 테이블 옵션
import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext"; // 🆕 높이 관리 import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext"; // 🆕 높이 관리
import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 🆕 컴포넌트 간 통신
function ScreenViewPage() { function ScreenViewPage() {
const params = useParams(); const params = useParams();
@ -239,17 +240,17 @@ function ScreenViewPage() {
// 가로 기준 스케일 계산 (좌우 여백 16px씩 고정) // 가로 기준 스케일 계산 (좌우 여백 16px씩 고정)
const newScale = availableWidth / designWidth; const newScale = availableWidth / designWidth;
console.log("📐 스케일 계산:", { // console.log("📐 스케일 계산:", {
containerWidth, // containerWidth,
containerHeight, // containerHeight,
MARGIN_X, // MARGIN_X,
availableWidth, // availableWidth,
designWidth, // designWidth,
designHeight, // designHeight,
finalScale: newScale, // finalScale: newScale,
"스케일된 화면 크기": `${designWidth * newScale}px × ${designHeight * newScale}px`, // "스케일된 화면 크기": `${designWidth * newScale}px × ${designHeight * newScale}px`,
"실제 좌우 여백": `${(containerWidth - designWidth * newScale) / 2}px씩`, // "실제 좌우 여백": `${(containerWidth - designWidth * newScale) / 2}px씩`,
}); // });
setScale(newScale); setScale(newScale);
// 컨테이너 너비 업데이트 // 컨테이너 너비 업데이트
@ -796,7 +797,9 @@ function ScreenViewPage() {
function ScreenViewPageWrapper() { function ScreenViewPageWrapper() {
return ( return (
<TableSearchWidgetHeightProvider> <TableSearchWidgetHeightProvider>
<ScreenViewPage /> <ScreenContextProvider>
<ScreenViewPage />
</ScreenContextProvider>
</TableSearchWidgetHeightProvider> </TableSearchWidgetHeightProvider>
); );
} }

View File

@ -0,0 +1,423 @@
import React, { useState, useEffect } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import { BatchAPI, BatchJob, BatchConfig } from "@/lib/api/batch";
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
// BatchJobModal에서 사용하던 config_json 구조 확장
interface RestApiConfigJson {
sourceConnectionId?: number;
targetConnectionId?: number;
targetTable?: string;
// REST API 관련 설정
apiUrl?: string;
apiKey?: string;
endpoint?: string;
httpMethod?: string;
apiBody?: string; // POST 요청용 Body
// 매핑 정보 등
mappings?: any[];
}
interface AdvancedBatchModalProps {
isOpen: boolean;
onClose: () => void;
onSave: () => void;
job?: BatchJob | null;
initialType?: "rest_to_db" | "db_to_rest"; // 초기 진입 시 타입 지정
}
export default function AdvancedBatchModal({
isOpen,
onClose,
onSave,
job,
initialType = "rest_to_db",
}: AdvancedBatchModalProps) {
// 기본 BatchJob 정보 관리
const [formData, setFormData] = useState<Partial<BatchJob>>({
job_name: "",
description: "",
job_type: initialType === "rest_to_db" ? "rest_to_db" : "db_to_rest",
schedule_cron: "",
is_active: "Y",
config_json: {},
});
// 상세 설정 (config_json 내부 값) 관리
const [configData, setConfigData] = useState<RestApiConfigJson>({
httpMethod: "GET", // 기본값
apiBody: "",
});
const [isLoading, setIsLoading] = useState(false);
const [connections, setConnections] = useState<any[]>([]); // 내부/외부 DB 연결 목록
const [targetTables, setTargetTables] = useState<string[]>([]); // 대상 테이블 목록 (DB가 타겟일 때)
const [schedulePresets, setSchedulePresets] = useState<Array<{ value: string; label: string }>>([]);
// 모달 열릴 때 초기화
useEffect(() => {
if (isOpen) {
loadConnections();
loadSchedulePresets();
if (job) {
// 수정 모드
setFormData({
...job,
config_json: job.config_json || {},
});
// 기존 config_json 내용을 상태로 복원
const savedConfig = job.config_json as RestApiConfigJson;
setConfigData({
...savedConfig,
httpMethod: savedConfig.httpMethod || "GET",
apiBody: savedConfig.apiBody || "",
});
// 타겟 연결이 있으면 테이블 목록 로드
if (savedConfig.targetConnectionId) {
loadTables(savedConfig.targetConnectionId);
}
} else {
// 생성 모드
setFormData({
job_name: "",
description: "",
job_type: initialType === "rest_to_db" ? "rest_to_db" : "db_to_rest", // props로 받은 타입 우선
schedule_cron: "",
is_active: "Y",
config_json: {},
});
setConfigData({
httpMethod: "GET",
apiBody: "",
});
}
}
}, [isOpen, job, initialType]);
const loadConnections = async () => {
try {
// 외부 DB 연결 목록 조회 (내부 DB 포함)
const list = await ExternalDbConnectionAPI.getConnections({ is_active: "Y" });
setConnections(list);
} catch (error) {
console.error("연결 목록 조회 오류:", error);
toast.error("연결 목록을 불러오는데 실패했습니다.");
}
};
const loadTables = async (connectionId: number) => {
try {
const result = await ExternalDbConnectionAPI.getTables(connectionId);
if (result.success && result.data) {
setTargetTables(result.data);
}
} catch (error) {
console.error("테이블 목록 조회 오류:", error);
}
};
const loadSchedulePresets = async () => {
try {
const presets = await BatchAPI.getSchedulePresets();
setSchedulePresets(presets);
} catch (error) {
console.error("스케줄 프리셋 조회 오류:", error);
}
};
// 폼 제출 핸들러
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.job_name) {
toast.error("배치명을 입력해주세요.");
return;
}
// REST API URL 필수 체크
if (!configData.apiUrl) {
toast.error("API 서버 URL을 입력해주세요.");
return;
}
// 타겟 DB 연결 필수 체크 (REST -> DB 인 경우)
if (formData.job_type === "rest_to_db" && !configData.targetConnectionId) {
toast.error("데이터를 저장할 대상 DB 연결을 선택해주세요.");
return;
}
setIsLoading(true);
try {
// 최종 저장할 데이터 조립
const finalJobData = {
...formData,
config_json: {
...configData,
// 추가적인 메타데이터가 필요하다면 여기에 포함
},
};
if (job?.id) {
await BatchAPI.updateBatchJob(job.id, finalJobData);
toast.success("배치 작업이 수정되었습니다.");
} else {
await BatchAPI.createBatchJob(finalJobData as BatchJob);
toast.success("배치 작업이 생성되었습니다.");
}
onSave();
onClose();
} catch (error) {
console.error("배치 저장 오류:", error);
toast.error(error instanceof Error ? error.message : "저장에 실패했습니다.");
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6 py-2">
{/* 1. 기본 정보 섹션 */}
<div className="space-y-4 border rounded-md p-4 bg-slate-50">
<h3 className="text-sm font-semibold text-slate-900"> </h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label className="text-xs"> *</Label>
<div className="mt-1 p-2 bg-white border rounded text-sm font-medium text-slate-600">
{formData.job_type === "rest_to_db" ? "🌐 REST API → 💾 DB" : "💾 DB → 🌐 REST API"}
</div>
<p className="text-[10px] text-slate-400 mt-1">
{formData.job_type === "rest_to_db"
? "REST API에서 데이터를 가져와 데이터베이스에 저장합니다."
: "데이터베이스의 데이터를 REST API로 전송합니다."}
</p>
</div>
<div>
<Label htmlFor="schedule_cron" className="text-xs"> *</Label>
<div className="flex gap-2 mt-1">
<Input
id="schedule_cron"
value={formData.schedule_cron || ""}
onChange={(e) => setFormData(prev => ({ ...prev, schedule_cron: e.target.value }))}
placeholder="예: 0 12 * * *"
className="text-sm"
/>
<Select onValueChange={(val) => setFormData(prev => ({ ...prev, schedule_cron: val }))}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="프리셋" />
</SelectTrigger>
<SelectContent>
{schedulePresets.map(p => (
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="sm:col-span-2">
<Label htmlFor="job_name" className="text-xs"> *</Label>
<Input
id="job_name"
value={formData.job_name || ""}
onChange={(e) => setFormData(prev => ({ ...prev, job_name: e.target.value }))}
placeholder="배치명을 입력하세요"
className="mt-1"
/>
</div>
<div className="sm:col-span-2">
<Label htmlFor="description" className="text-xs"></Label>
<Textarea
id="description"
value={formData.description || ""}
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
placeholder="배치에 대한 설명을 입력하세요"
className="mt-1 min-h-[60px]"
/>
</div>
</div>
</div>
{/* 2. REST API 설정 섹션 (Source) */}
<div className="space-y-4 border rounded-md p-4 bg-white">
<div className="flex items-center gap-2">
<span className="text-lg">🌐</span>
<h3 className="text-sm font-semibold text-slate-900">
{formData.job_type === "rest_to_db" ? "FROM: REST API (소스)" : "TO: REST API (대상)"}
</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="sm:col-span-2">
<Label htmlFor="api_url" className="text-xs">API URL *</Label>
<Input
id="api_url"
value={configData.apiUrl || ""}
onChange={(e) => setConfigData(prev => ({ ...prev, apiUrl: e.target.value }))}
placeholder="https://api.example.com"
className="mt-1"
/>
</div>
<div className="sm:col-span-2">
<Label htmlFor="api_key" className="text-xs">API ()</Label>
<Input
id="api_key"
type="password"
value={configData.apiKey || ""}
onChange={(e) => setConfigData(prev => ({ ...prev, apiKey: e.target.value }))}
placeholder="인증에 필요한 API Key가 있다면 입력하세요"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="endpoint" className="text-xs"> *</Label>
<Input
id="endpoint"
value={configData.endpoint || ""}
onChange={(e) => setConfigData(prev => ({ ...prev, endpoint: e.target.value }))}
placeholder="/api/token"
className="mt-1"
/>
</div>
<div>
<Label htmlFor="http_method" className="text-xs">HTTP </Label>
<Select
value={configData.httpMethod || "GET"}
onValueChange={(val) => setConfigData(prev => ({ ...prev, httpMethod: val }))}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET ( )</SelectItem>
<SelectItem value="POST">POST ( /)</SelectItem>
<SelectItem value="PUT">PUT ( )</SelectItem>
<SelectItem value="DELETE">DELETE ( )</SelectItem>
</SelectContent>
</Select>
</div>
{/* POST/PUT 일 때 Body 입력창 노출 */}
{(configData.httpMethod === "POST" || configData.httpMethod === "PUT") && (
<div className="sm:col-span-2 animate-in fade-in slide-in-from-top-2 duration-200">
<Label htmlFor="api_body" className="text-xs">Request Body (JSON)</Label>
<Textarea
id="api_body"
value={configData.apiBody || ""}
onChange={(e) => setConfigData(prev => ({ ...prev, apiBody: e.target.value }))}
placeholder='{"username": "myuser", "password": "mypassword"}'
className="mt-1 font-mono text-xs min-h-[100px]"
/>
<p className="text-[10px] text-slate-500 mt-1">
* JSON .
</p>
</div>
)}
</div>
</div>
{/* 3. 데이터베이스 설정 섹션 (Target) */}
<div className="space-y-4 border rounded-md p-4 bg-white">
<div className="flex items-center gap-2">
<span className="text-lg">💾</span>
<h3 className="text-sm font-semibold text-slate-900">
{formData.job_type === "rest_to_db" ? "TO: 데이터베이스 (대상)" : "FROM: 데이터베이스 (소스)"}
</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label className="text-xs"> </Label>
<Select
value={configData.targetConnectionId?.toString() || ""}
onValueChange={(val) => {
const connId = parseInt(val);
setConfigData(prev => ({ ...prev, targetConnectionId: connId }));
loadTables(connId); // 테이블 목록 로드
}}
>
<SelectTrigger className="mt-1">
<SelectValue placeholder="커넥션을 선택하세요" />
</SelectTrigger>
<SelectContent>
{connections.map(conn => (
<SelectItem key={conn.id} value={conn.id.toString()}>
{conn.connection_name || conn.name} ({conn.db_type})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-xs"> </Label>
<Select
value={configData.targetTable || ""}
onValueChange={(val) => setConfigData(prev => ({ ...prev, targetTable: val }))}
disabled={!configData.targetConnectionId}
>
<SelectTrigger className="mt-1">
<SelectValue placeholder="테이블을 선택하세요" />
</SelectTrigger>
<SelectContent>
{targetTables.length > 0 ? (
targetTables.map(table => (
<SelectItem key={table} value={table}>{table}</SelectItem>
))
) : (
<div className="p-2 text-xs text-center text-slate-400"> </div>
)}
</SelectContent>
</Select>
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? "저장 중..." : "저장"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -42,6 +42,7 @@ export function AuthenticationConfig({
<SelectItem value="bearer">Bearer Token</SelectItem> <SelectItem value="bearer">Bearer Token</SelectItem>
<SelectItem value="basic">Basic Auth</SelectItem> <SelectItem value="basic">Basic Auth</SelectItem>
<SelectItem value="oauth2">OAuth 2.0</SelectItem> <SelectItem value="oauth2">OAuth 2.0</SelectItem>
<SelectItem value="db-token">DB </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@ -192,6 +193,94 @@ export function AuthenticationConfig({
</div> </div>
)} )}
{authType === "db-token" && (
<div className="space-y-4 rounded-md border bg-gray-50 p-4">
<h4 className="text-sm font-medium">DB </h4>
<div className="space-y-2">
<Label htmlFor="db-table-name"></Label>
<Input
id="db-table-name"
type="text"
value={authConfig.dbTableName || ""}
onChange={(e) => updateAuthConfig("dbTableName", e.target.value)}
placeholder="예: auth_tokens"
/>
</div>
<div className="space-y-2">
<Label htmlFor="db-value-column"> </Label>
<Input
id="db-value-column"
type="text"
value={authConfig.dbValueColumn || ""}
onChange={(e) =>
updateAuthConfig("dbValueColumn", e.target.value)
}
placeholder="예: access_token"
/>
</div>
<div className="space-y-2">
<Label htmlFor="db-where-column"> </Label>
<Input
id="db-where-column"
type="text"
value={authConfig.dbWhereColumn || ""}
onChange={(e) =>
updateAuthConfig("dbWhereColumn", e.target.value)
}
placeholder="예: service_name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="db-where-value"> </Label>
<Input
id="db-where-value"
type="text"
value={authConfig.dbWhereValue || ""}
onChange={(e) =>
updateAuthConfig("dbWhereValue", e.target.value)
}
placeholder="예: kakao"
/>
</div>
<div className="space-y-2">
<Label htmlFor="db-header-name"> ()</Label>
<Input
id="db-header-name"
type="text"
value={authConfig.dbHeaderName || ""}
onChange={(e) =>
updateAuthConfig("dbHeaderName", e.target.value)
}
placeholder="기본값: Authorization"
/>
</div>
<div className="space-y-2">
<Label htmlFor="db-header-template">
릿 (, &#123;&#123;value&#125;&#125; )
</Label>
<Input
id="db-header-template"
type="text"
value={authConfig.dbHeaderTemplate || ""}
onChange={(e) =>
updateAuthConfig("dbHeaderTemplate", e.target.value)
}
placeholder='기본값: "Bearer {{value}}"'
/>
</div>
<p className="text-xs text-gray-500">
company_code는 .
</p>
</div>
)}
{authType === "none" && ( {authType === "none" && (
<div className="rounded-md border border-dashed p-4 text-center text-sm text-gray-500"> <div className="rounded-md border border-dashed p-4 text-center text-sm text-gray-500">
API입니다. API입니다.

View File

@ -162,7 +162,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
<TableCell className="h-16 px-6 py-3">{formatDiskUsage(company)}</TableCell> <TableCell className="h-16 px-6 py-3">{formatDiskUsage(company)}</TableCell>
<TableCell className="h-16 px-6 py-3"> <TableCell className="h-16 px-6 py-3">
<div className="flex gap-2"> <div className="flex gap-2">
<Button {/* <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => handleManageDepartments(company)} onClick={() => handleManageDepartments(company)}
@ -170,7 +170,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
aria-label="부서관리" aria-label="부서관리"
> >
<Users className="h-4 w-4" /> <Users className="h-4 w-4" />
</Button> </Button> */}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"

View File

@ -294,18 +294,10 @@ export function MenuCopyDialog({
<span className="text-muted-foreground">:</span>{" "} <span className="text-muted-foreground">:</span>{" "}
<span className="font-medium">{result.copiedScreens}</span> <span className="font-medium">{result.copiedScreens}</span>
</div> </div>
<div> <div className="col-span-2">
<span className="text-muted-foreground">:</span>{" "} <span className="text-muted-foreground">:</span>{" "}
<span className="font-medium">{result.copiedFlows}</span> <span className="font-medium">{result.copiedFlows}</span>
</div> </div>
<div>
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{result.copiedCategories}</span>
</div>
<div className="col-span-2">
<span className="text-muted-foreground">:</span>{" "}
<span className="font-medium">{result.copiedCodes}</span>
</div>
</div> </div>
</div> </div>
)} )}

View File

@ -33,6 +33,7 @@ const AUTH_TYPE_LABELS: Record<string, string> = {
bearer: "Bearer", bearer: "Bearer",
basic: "Basic Auth", basic: "Basic Auth",
oauth2: "OAuth 2.0", oauth2: "OAuth 2.0",
"db-token": "DB 토큰",
}; };
// 활성 상태 옵션 // 활성 상태 옵션
@ -158,6 +159,22 @@ export function RestApiConnectionList() {
setTestResults((prev) => new Map(prev).set(connection.id!, result.success)); setTestResults((prev) => new Map(prev).set(connection.id!, result.success));
// 현재 행의 "마지막 테스트" 정보만 낙관적으로 업데이트하여
// 전체 목록 리로딩 없이도 UI를 즉시 반영한다.
const nowIso = new Date().toISOString();
setConnections((prev) =>
prev.map((c) =>
c.id === connection.id
? {
...c,
last_test_date: nowIso as any,
last_test_result: result.success ? "Y" : "N",
last_test_message: result.message,
}
: c
)
);
if (result.success) { if (result.success) {
toast({ toast({
title: "연결 성공", title: "연결 성공",

View File

@ -21,10 +21,13 @@ import {
ExternalRestApiConnection, ExternalRestApiConnection,
AuthType, AuthType,
RestApiTestResult, RestApiTestResult,
RestApiTestRequest,
} from "@/lib/api/externalRestApiConnection"; } from "@/lib/api/externalRestApiConnection";
import { HeadersManager } from "./HeadersManager"; import { HeadersManager } from "./HeadersManager";
import { AuthenticationConfig } from "./AuthenticationConfig"; import { AuthenticationConfig } from "./AuthenticationConfig";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
interface RestApiConnectionModalProps { interface RestApiConnectionModalProps {
isOpen: boolean; isOpen: boolean;
@ -42,6 +45,8 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
const [baseUrl, setBaseUrl] = useState(""); const [baseUrl, setBaseUrl] = useState("");
const [endpointPath, setEndpointPath] = useState(""); const [endpointPath, setEndpointPath] = useState("");
const [defaultHeaders, setDefaultHeaders] = useState<Record<string, string>>({}); const [defaultHeaders, setDefaultHeaders] = useState<Record<string, string>>({});
const [defaultMethod, setDefaultMethod] = useState("GET");
const [defaultBody, setDefaultBody] = useState("");
const [authType, setAuthType] = useState<AuthType>("none"); const [authType, setAuthType] = useState<AuthType>("none");
const [authConfig, setAuthConfig] = useState<any>({}); const [authConfig, setAuthConfig] = useState<any>({});
const [timeout, setTimeout] = useState(30000); const [timeout, setTimeout] = useState(30000);
@ -52,6 +57,8 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
// UI 상태 // UI 상태
const [showAdvanced, setShowAdvanced] = useState(false); const [showAdvanced, setShowAdvanced] = useState(false);
const [testEndpoint, setTestEndpoint] = useState(""); const [testEndpoint, setTestEndpoint] = useState("");
const [testMethod, setTestMethod] = useState("GET");
const [testBody, setTestBody] = useState("");
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<RestApiTestResult | null>(null); const [testResult, setTestResult] = useState<RestApiTestResult | null>(null);
const [testRequestUrl, setTestRequestUrl] = useState<string>(""); const [testRequestUrl, setTestRequestUrl] = useState<string>("");
@ -65,12 +72,19 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
setBaseUrl(connection.base_url); setBaseUrl(connection.base_url);
setEndpointPath(connection.endpoint_path || ""); setEndpointPath(connection.endpoint_path || "");
setDefaultHeaders(connection.default_headers || {}); setDefaultHeaders(connection.default_headers || {});
setDefaultMethod(connection.default_method || "GET");
setDefaultBody(connection.default_body || "");
setAuthType(connection.auth_type); setAuthType(connection.auth_type);
setAuthConfig(connection.auth_config || {}); setAuthConfig(connection.auth_config || {});
setTimeout(connection.timeout || 30000); setTimeout(connection.timeout || 30000);
setRetryCount(connection.retry_count || 0); setRetryCount(connection.retry_count || 0);
setRetryDelay(connection.retry_delay || 1000); setRetryDelay(connection.retry_delay || 1000);
setIsActive(connection.is_active === "Y"); setIsActive(connection.is_active === "Y");
// 테스트 초기값 설정
setTestEndpoint("");
setTestMethod(connection.default_method || "GET");
setTestBody(connection.default_body || "");
} else { } else {
// 초기화 // 초기화
setConnectionName(""); setConnectionName("");
@ -78,16 +92,22 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
setBaseUrl(""); setBaseUrl("");
setEndpointPath(""); setEndpointPath("");
setDefaultHeaders({ "Content-Type": "application/json" }); setDefaultHeaders({ "Content-Type": "application/json" });
setDefaultMethod("GET");
setDefaultBody("");
setAuthType("none"); setAuthType("none");
setAuthConfig({}); setAuthConfig({});
setTimeout(30000); setTimeout(30000);
setRetryCount(0); setRetryCount(0);
setRetryDelay(1000); setRetryDelay(1000);
setIsActive(true); setIsActive(true);
// 테스트 초기값 설정
setTestEndpoint("");
setTestMethod("GET");
setTestBody("");
} }
setTestResult(null); setTestResult(null);
setTestEndpoint("");
setTestRequestUrl(""); setTestRequestUrl("");
}, [connection, isOpen]); }, [connection, isOpen]);
@ -111,14 +131,18 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
setTestRequestUrl(fullUrl); setTestRequestUrl(fullUrl);
try { try {
const result = await ExternalRestApiConnectionAPI.testConnection({ const testRequest: RestApiTestRequest = {
base_url: baseUrl, base_url: baseUrl,
endpoint: testEndpoint || undefined, endpoint: testEndpoint || undefined,
method: testMethod as any,
headers: defaultHeaders, headers: defaultHeaders,
body: testBody ? JSON.parse(testBody) : undefined,
auth_type: authType, auth_type: authType,
auth_config: authConfig, auth_config: authConfig,
timeout, timeout,
}); };
const result = await ExternalRestApiConnectionAPI.testConnection(testRequest);
setTestResult(result); setTestResult(result);
@ -178,6 +202,20 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
return; return;
} }
// JSON 유효성 검증
if (defaultBody && defaultMethod !== "GET" && defaultMethod !== "DELETE") {
try {
JSON.parse(defaultBody);
} catch {
toast({
title: "입력 오류",
description: "기본 Body가 올바른 JSON 형식이 아닙니다.",
variant: "destructive",
});
return;
}
}
setSaving(true); setSaving(true);
try { try {
@ -187,6 +225,8 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
base_url: baseUrl, base_url: baseUrl,
endpoint_path: endpointPath || undefined, endpoint_path: endpointPath || undefined,
default_headers: defaultHeaders, default_headers: defaultHeaders,
default_method: defaultMethod,
default_body: defaultBody.trim() || null, // 빈 문자열이면 null로 전송하여 DB 업데이트
auth_type: authType, auth_type: authType,
auth_config: authType === "none" ? undefined : authConfig, auth_config: authType === "none" ? undefined : authConfig,
timeout, timeout,
@ -196,6 +236,13 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
is_active: isActive ? "Y" : "N", is_active: isActive ? "Y" : "N",
}; };
console.log("저장하려는 데이터:", {
connection_name: connectionName,
default_method: defaultMethod,
endpoint_path: endpointPath,
base_url: baseUrl,
});
if (connection?.id) { if (connection?.id) {
await ExternalRestApiConnectionAPI.updateConnection(connection.id, data); await ExternalRestApiConnectionAPI.updateConnection(connection.id, data);
toast({ toast({
@ -262,12 +309,34 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
<Label htmlFor="base-url"> <Label htmlFor="base-url">
URL <span className="text-destructive">*</span> URL <span className="text-destructive">*</span>
</Label> </Label>
<Input <div className="flex gap-2">
id="base-url" <Select
value={baseUrl} value={defaultMethod}
onChange={(e) => setBaseUrl(e.target.value)} onValueChange={(val) => {
placeholder="https://api.example.com" setDefaultMethod(val);
/> setTestMethod(val); // 테스트 Method도 동기화
}}
>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="Method" />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
<SelectItem value="PATCH">PATCH</SelectItem>
</SelectContent>
</Select>
<div className="flex-1">
<Input
id="base-url"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://api.example.com"
/>
</div>
</div>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
(: https://apihub.kma.go.kr) (: https://apihub.kma.go.kr)
</p> </p>
@ -286,6 +355,21 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
</p> </p>
</div> </div>
{/* 기본 Body (POST, PUT, PATCH일 때만 표시) */}
{(defaultMethod === "POST" || defaultMethod === "PUT" || defaultMethod === "PATCH") && (
<div className="space-y-2">
<Label htmlFor="default-body"> Request Body (JSON)</Label>
<Textarea
id="default-body"
value={defaultBody}
onChange={(e) => setDefaultBody(e.target.value)}
placeholder='{"key": "value"}'
className="font-mono text-xs"
rows={5}
/>
</div>
)}
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Switch id="is-active" checked={isActive} onCheckedChange={setIsActive} /> <Switch id="is-active" checked={isActive} onCheckedChange={setIsActive} />
<Label htmlFor="is-active" className="cursor-pointer"> <Label htmlFor="is-active" className="cursor-pointer">
@ -370,13 +454,45 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
<h3 className="text-sm font-semibold"> </h3> <h3 className="text-sm font-semibold"> </h3>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="test-endpoint"> ()</Label> <Label htmlFor="test-endpoint"> </Label>
<Input <div className="flex gap-2 mb-2">
id="test-endpoint" <Select value={testMethod} onValueChange={setTestMethod}>
value={testEndpoint} <SelectTrigger className="w-[100px]">
onChange={(e) => setTestEndpoint(e.target.value)} <SelectValue placeholder="Method" />
placeholder="엔드포인트 또는 빈칸(기본 URL만 테스트)" </SelectTrigger>
/> <SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
<SelectItem value="PATCH">PATCH</SelectItem>
</SelectContent>
</Select>
<div className="flex-1">
<Input
id="test-endpoint"
value={testEndpoint}
onChange={(e) => setTestEndpoint(e.target.value)}
placeholder="엔드포인트 (예: /users/1)"
/>
</div>
</div>
{(testMethod === "POST" || testMethod === "PUT" || testMethod === "PATCH") && (
<div className="mt-2">
<Label htmlFor="test-body" className="text-xs text-muted-foreground mb-1 block">
Test Request Body (JSON)
</Label>
<Textarea
id="test-body"
value={testBody}
onChange={(e) => setTestBody(e.target.value)}
placeholder='{"test": "data"}'
className="font-mono text-xs"
rows={3}
/>
</div>
)}
</div> </div>
<Button type="button" variant="outline" onClick={handleTest} disabled={testing}> <Button type="button" variant="outline" onClick={handleTest} disabled={testing}>
@ -388,10 +504,22 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
{testRequestUrl && ( {testRequestUrl && (
<div className="bg-muted/30 space-y-3 rounded-md border p-3"> <div className="bg-muted/30 space-y-3 rounded-md border p-3">
<div> <div>
<div className="text-muted-foreground mb-1 text-xs font-medium"> URL</div> <div className="text-muted-foreground mb-1 text-xs font-medium"> </div>
<code className="text-foreground block text-xs break-all">GET {testRequestUrl}</code> <div className="flex items-center gap-2 mb-1">
<Badge variant="outline">{testMethod}</Badge>
<code className="text-foreground text-xs break-all">{testRequestUrl}</code>
</div>
</div> </div>
{testBody && (testMethod === "POST" || testMethod === "PUT" || testMethod === "PATCH") && (
<div>
<div className="text-muted-foreground mb-1 text-xs font-medium">Request Body</div>
<pre className="bg-muted p-2 rounded text-xs overflow-auto max-h-[100px]">
{testBody}
</pre>
</div>
)}
{Object.keys(defaultHeaders).length > 0 && ( {Object.keys(defaultHeaders).length > 0 && (
<div> <div>
<div className="text-muted-foreground mb-1 text-xs font-medium"> </div> <div className="text-muted-foreground mb-1 text-xs font-medium"> </div>

View File

@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { Plus, Trash2, Loader2, CheckCircle, XCircle } from "lucide-react"; import { Plus, Trash2, Loader2, CheckCircle, XCircle } from "lucide-react";
import { ExternalDbConnectionAPI, ExternalApiConnection } from "@/lib/api/externalDbConnection"; import { ExternalDbConnectionAPI, ExternalApiConnection } from "@/lib/api/externalDbConnection";
import { getApiUrl } from "@/lib/utils/apiUrl"; import { getApiUrl } from "@/lib/utils/apiUrl";
@ -20,7 +21,7 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
const [apiConnections, setApiConnections] = useState<ExternalApiConnection[]>([]); const [apiConnections, setApiConnections] = useState<ExternalApiConnection[]>([]);
const [selectedConnectionId, setSelectedConnectionId] = useState<string>(""); const [selectedConnectionId, setSelectedConnectionId] = useState<string>(dataSource.externalConnectionId || "");
const [availableColumns, setAvailableColumns] = useState<string[]>([]); // API 테스트 후 발견된 컬럼 목록 const [availableColumns, setAvailableColumns] = useState<string[]>([]); // API 테스트 후 발견된 컬럼 목록
const [columnTypes, setColumnTypes] = useState<Record<string, string>>({}); // 컬럼 타입 정보 const [columnTypes, setColumnTypes] = useState<Record<string, string>>({}); // 컬럼 타입 정보
const [sampleData, setSampleData] = useState<any[]>([]); // 샘플 데이터 (최대 3개) const [sampleData, setSampleData] = useState<any[]>([]); // 샘플 데이터 (최대 3개)
@ -35,6 +36,13 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
loadApiConnections(); loadApiConnections();
}, []); }, []);
// dataSource.externalConnectionId가 변경되면 selectedConnectionId 업데이트
useEffect(() => {
if (dataSource.externalConnectionId) {
setSelectedConnectionId(dataSource.externalConnectionId);
}
}, [dataSource.externalConnectionId]);
// 외부 커넥션 선택 핸들러 // 외부 커넥션 선택 핸들러
const handleConnectionSelect = async (connectionId: string) => { const handleConnectionSelect = async (connectionId: string) => {
setSelectedConnectionId(connectionId); setSelectedConnectionId(connectionId);
@ -58,11 +66,20 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
const updates: Partial<ChartDataSource> = { const updates: Partial<ChartDataSource> = {
endpoint: fullEndpoint, endpoint: fullEndpoint,
externalConnectionId: connectionId, // 외부 연결 ID 저장
}; };
const headers: KeyValuePair[] = []; const headers: KeyValuePair[] = [];
const queryParams: KeyValuePair[] = []; const queryParams: KeyValuePair[] = [];
// 기본 메서드/바디가 있으면 적용
if (connection.default_method) {
updates.method = connection.default_method as ChartDataSource["method"];
}
if (connection.default_body) {
updates.body = connection.default_body;
}
// 기본 헤더가 있으면 적용 // 기본 헤더가 있으면 적용
if (connection.default_headers && Object.keys(connection.default_headers).length > 0) { if (connection.default_headers && Object.keys(connection.default_headers).length > 0) {
Object.entries(connection.default_headers).forEach(([key, value]) => { Object.entries(connection.default_headers).forEach(([key, value]) => {
@ -210,6 +227,11 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
} }
}); });
const bodyPayload =
dataSource.body && dataSource.body.trim().length > 0
? dataSource.body
: undefined;
const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), { const response = await fetch(getApiUrl("/api/dashboards/fetch-external-api"), {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@ -219,6 +241,8 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
method: dataSource.method || "GET", method: dataSource.method || "GET",
headers, headers,
queryParams, queryParams,
body: bodyPayload,
externalConnectionId: dataSource.externalConnectionId, // 외부 연결 ID 전달
}), }),
}); });
@ -415,6 +439,58 @@ export default function MultiApiConfig({ dataSource, onChange, onTestResult }: M
</p> </p>
</div> </div>
{/* HTTP 메서드 */}
<div className="space-y-2">
<Label className="text-xs">HTTP </Label>
<Select
value={dataSource.method || "GET"}
onValueChange={(value) =>
onChange({
method: value as ChartDataSource["method"],
})
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET" className="text-xs">
GET
</SelectItem>
<SelectItem value="POST" className="text-xs">
POST
</SelectItem>
<SelectItem value="PUT" className="text-xs">
PUT
</SelectItem>
<SelectItem value="DELETE" className="text-xs">
DELETE
</SelectItem>
<SelectItem value="PATCH" className="text-xs">
PATCH
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Request Body (POST/PUT/PATCH 일 때만) */}
{(dataSource.method === "POST" ||
dataSource.method === "PUT" ||
dataSource.method === "PATCH") && (
<div className="space-y-2">
<Label className="text-xs">Request Body ()</Label>
<Textarea
value={dataSource.body || ""}
onChange={(e) => onChange({ body: e.target.value })}
placeholder='{"key": "value"} 또는 원시 페이로드를 그대로 입력하세요'
className="h-24 text-xs font-mono"
/>
<p className="text-[10px] text-muted-foreground">
API Body로 . JSON이 .
</p>
</div>
)}
{/* JSON Path */} {/* JSON Path */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor={`jsonPath-\${dataSource.id}`} className="text-xs"> <Label htmlFor={`jsonPath-\${dataSource.id}`} className="text-xs">

View File

@ -149,7 +149,10 @@ export interface ChartDataSource {
// API 관련 // API 관련
endpoint?: string; // API URL endpoint?: string; // API URL
method?: "GET"; // HTTP 메서드 (GET만 지원) // HTTP 메서드 (기본 GET, POST/PUT/DELETE/PATCH도 지원)
method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
// 요청 Body (옵션) - 문자열 그대로 전송 (JSON 또는 일반 텍스트)
body?: string;
headers?: KeyValuePair[]; // 커스텀 헤더 (배열) headers?: KeyValuePair[]; // 커스텀 헤더 (배열)
queryParams?: KeyValuePair[]; // URL 쿼리 파라미터 (배열) queryParams?: KeyValuePair[]; // URL 쿼리 파라미터 (배열)
jsonPath?: string; // JSON 응답에서 데이터 추출 경로 (예: "data.results") jsonPath?: string; // JSON 응답에서 데이터 추출 경로 (예: "data.results")

View File

@ -2,7 +2,7 @@
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ArrowLeft, Save, Loader2, Grid3x3, Move, Box, Package, Truck, Check } from "lucide-react"; import { ArrowLeft, Save, Loader2, Grid3x3, Move, Box, Package, Truck, Check, ParkingCircle } from "lucide-react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -39,6 +39,77 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
// 성능 최적화를 위한 디바운스/Blur 처리된 Input 컴포넌트
const DebouncedInput = ({
value,
onChange,
onCommit,
type = "text",
debounce = 0,
...props
}: React.InputHTMLAttributes<HTMLInputElement> & {
onCommit?: (value: any) => void;
debounce?: number;
}) => {
const [localValue, setLocalValue] = useState(value);
const [isEditing, setIsEditing] = useState(false);
useEffect(() => {
if (!isEditing) {
setLocalValue(value);
}
}, [value, isEditing]);
// 색상 입력 등을 위한 디바운스 커밋
useEffect(() => {
if (debounce > 0 && isEditing && onCommit) {
const timer = setTimeout(() => {
onCommit(type === "number" ? parseFloat(localValue as string) : localValue);
}, debounce);
return () => clearTimeout(timer);
}
}, [localValue, debounce, isEditing, onCommit, type]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setLocalValue(e.target.value);
if (onChange) onChange(e);
};
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
setIsEditing(false);
if (onCommit && debounce === 0) {
// 값이 변경되었을 때만 커밋하도록 하면 좋겠지만,
// 부모 상태와 비교하기 어려우므로 항상 커밋 (handleObjectUpdate 내부에서 처리됨)
onCommit(type === "number" ? parseFloat(localValue as string) : localValue);
}
if (props.onBlur) props.onBlur(e);
};
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
setIsEditing(true);
if (props.onFocus) props.onFocus(e);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.currentTarget.blur();
}
if (props.onKeyDown) props.onKeyDown(e);
};
return (
<Input
{...props}
type={type}
value={localValue}
onChange={handleChange}
onBlur={handleBlur}
onFocus={handleFocus}
onKeyDown={handleKeyDown}
/>
);
};
// 백엔드 DB 객체 타입 (snake_case) // 백엔드 DB 객체 타입 (snake_case)
interface DbObject { interface DbObject {
id: number; id: number;
@ -550,10 +621,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
areaKey: obj.area_key, areaKey: obj.area_key,
locaKey: obj.loca_key, locaKey: obj.loca_key,
locType: obj.loc_type, locType: obj.loc_type,
materialCount: obj.material_count, materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
materialPreview: obj.material_preview_height materialPreview:
? { height: parseFloat(obj.material_preview_height) } obj.loc_type === "STP" || !obj.material_preview_height
: undefined, ? undefined
: { height: parseFloat(obj.material_preview_height) },
parentId: obj.parent_id, parentId: obj.parent_id,
displayOrder: obj.display_order, displayOrder: obj.display_order,
locked: obj.locked, locked: obj.locked,
@ -761,12 +833,9 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
// 기본 크기 설정 // 기본 크기 설정
let objectSize = defaults.size || { x: 5, y: 5, z: 5 }; let objectSize = defaults.size || { x: 5, y: 5, z: 5 };
// Location 배치 시 자재 개수에 따라 높이 자동 설정 // Location 배치 시 자재 개수에 따라 높이 자동 설정 (BED/TMP/DES만 대상, STP는 자재 미적재)
if ( if (
(draggedTool === "location-bed" || (draggedTool === "location-bed" || draggedTool === "location-temp" || draggedTool === "location-dest") &&
draggedTool === "location-stp" ||
draggedTool === "location-temp" ||
draggedTool === "location-dest") &&
locaKey && locaKey &&
selectedDbConnection && selectedDbConnection &&
hierarchyConfig?.material hierarchyConfig?.material
@ -877,12 +946,9 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
setDraggedAreaData(null); setDraggedAreaData(null);
setDraggedLocationData(null); setDraggedLocationData(null);
// Location 배치 시 자재 개수 로드 // Location 배치 시 자재 개수 로드 (BED/TMP/DES만 대상, STP는 자재 미적재)
if ( if (
(draggedTool === "location-bed" || (draggedTool === "location-bed" || draggedTool === "location-temp" || draggedTool === "location-dest") &&
draggedTool === "location-stp" ||
draggedTool === "location-temp" ||
draggedTool === "location-dest") &&
locaKey locaKey
) { ) {
// 새 객체 추가 후 자재 개수 로드 (약간의 딜레이를 두어 state 업데이트 완료 후 실행) // 새 객체 추가 후 자재 개수 로드 (약간의 딜레이를 두어 state 업데이트 완료 후 실행)
@ -965,13 +1031,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
loadLocationsForArea(obj.areaKey); loadLocationsForArea(obj.areaKey);
setShowMaterialPanel(false); setShowMaterialPanel(false);
} }
// Location을 클릭한 경우, 해당 Location의 자재 목록 로드 // Location을 클릭한 경우, 해당 Location의 자재 목록 로드 (STP는 자재 미적재이므로 제외)
else if ( else if (
obj && obj &&
(obj.type === "location-bed" || (obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") &&
obj.type === "location-stp" ||
obj.type === "location-temp" ||
obj.type === "location-dest") &&
obj.locaKey && obj.locaKey &&
selectedDbConnection selectedDbConnection
) { ) {
@ -988,9 +1051,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
try { try {
const response = await getMaterialCounts(selectedDbConnection, selectedTables.material, locaKeys); const response = await getMaterialCounts(selectedDbConnection, selectedTables.material, locaKeys);
if (response.success && response.data) { if (response.success && response.data) {
// 각 Location 객체에 자재 개수 업데이트 // 각 Location 객체에 자재 개수 업데이트 (STP는 자재 미적재이므로 제외)
setPlacedObjects((prev) => setPlacedObjects((prev) =>
prev.map((obj) => { prev.map((obj) => {
if (
!obj.locaKey ||
obj.type === "location-stp" // STP는 자재 없음
) {
return obj;
}
const materialCount = response.data?.find((mc) => mc.LOCAKEY === obj.locaKey); const materialCount = response.data?.find((mc) => mc.LOCAKEY === obj.locaKey);
if (materialCount) { if (materialCount) {
return { return {
@ -1278,7 +1347,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
const oldSize = actualObject.size; const oldSize = actualObject.size;
const newSize = { ...oldSize, ...updates.size }; const newSize = { ...oldSize, ...updates.size };
// W, D를 5 단위로 스냅 // W, D를 5 단위로 스냅 (STP 포함)
newSize.x = Math.max(5, Math.round(newSize.x / 5) * 5); newSize.x = Math.max(5, Math.round(newSize.x / 5) * 5);
newSize.z = Math.max(5, Math.round(newSize.z / 5) * 5); newSize.z = Math.max(5, Math.round(newSize.z / 5) * 5);
@ -1391,10 +1460,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
areaKey: obj.area_key, areaKey: obj.area_key,
locaKey: obj.loca_key, locaKey: obj.loca_key,
locType: obj.loc_type, locType: obj.loc_type,
materialCount: obj.material_count, materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
materialPreview: obj.material_preview_height materialPreview:
? { height: parseFloat(obj.material_preview_height) } obj.loc_type === "STP" || !obj.material_preview_height
: undefined, ? undefined
: { height: parseFloat(obj.material_preview_height) },
parentId: obj.parent_id, parentId: obj.parent_id,
displayOrder: obj.display_order, displayOrder: obj.display_order,
locked: obj.locked, locked: obj.locked,
@ -1798,6 +1868,8 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
</div> </div>
{isLocationPlaced ? ( {isLocationPlaced ? (
<Check className="h-4 w-4 text-green-500" /> <Check className="h-4 w-4 text-green-500" />
) : locationType === "location-stp" ? (
<ParkingCircle className="text-muted-foreground h-4 w-4" />
) : ( ) : (
<Package className="text-muted-foreground h-4 w-4" /> <Package className="text-muted-foreground h-4 w-4" />
)} )}
@ -2069,10 +2141,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
<Label htmlFor="object-name" className="text-sm"> <Label htmlFor="object-name" className="text-sm">
</Label> </Label>
<Input <DebouncedInput
id="object-name" id="object-name"
value={selectedObject.name || ""} value={selectedObject.name || ""}
onChange={(e) => handleObjectUpdate({ name: e.target.value })} onCommit={(val) => handleObjectUpdate({ name: val })}
className="mt-1.5 h-9 text-sm" className="mt-1.5 h-9 text-sm"
/> />
</div> </div>
@ -2085,15 +2157,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
<Label htmlFor="pos-x" className="text-muted-foreground text-xs"> <Label htmlFor="pos-x" className="text-muted-foreground text-xs">
X X
</Label> </Label>
<Input <DebouncedInput
id="pos-x" id="pos-x"
type="number" type="number"
value={(selectedObject.position?.x || 0).toFixed(1)} value={(selectedObject.position?.x || 0).toFixed(1)}
onChange={(e) => onCommit={(val) =>
handleObjectUpdate({ handleObjectUpdate({
position: { position: {
...selectedObject.position, ...selectedObject.position,
x: parseFloat(e.target.value), x: val,
}, },
}) })
} }
@ -2104,15 +2176,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
<Label htmlFor="pos-z" className="text-muted-foreground text-xs"> <Label htmlFor="pos-z" className="text-muted-foreground text-xs">
Z Z
</Label> </Label>
<Input <DebouncedInput
id="pos-z" id="pos-z"
type="number" type="number"
value={(selectedObject.position?.z || 0).toFixed(1)} value={(selectedObject.position?.z || 0).toFixed(1)}
onChange={(e) => onCommit={(val) =>
handleObjectUpdate({ handleObjectUpdate({
position: { position: {
...selectedObject.position, ...selectedObject.position,
z: parseFloat(e.target.value), z: val,
}, },
}) })
} }
@ -2130,17 +2202,17 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
<Label htmlFor="size-x" className="text-muted-foreground text-xs"> <Label htmlFor="size-x" className="text-muted-foreground text-xs">
W (5 ) W (5 )
</Label> </Label>
<Input <DebouncedInput
id="size-x" id="size-x"
type="number" type="number"
step="5" step="5"
min="5" min="5"
value={selectedObject.size?.x || 5} value={selectedObject.size?.x || 5}
onChange={(e) => onCommit={(val) =>
handleObjectUpdate({ handleObjectUpdate({
size: { size: {
...selectedObject.size, ...selectedObject.size,
x: parseFloat(e.target.value), x: val,
}, },
}) })
} }
@ -2151,15 +2223,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
<Label htmlFor="size-y" className="text-muted-foreground text-xs"> <Label htmlFor="size-y" className="text-muted-foreground text-xs">
H H
</Label> </Label>
<Input <DebouncedInput
id="size-y" id="size-y"
type="number" type="number"
value={selectedObject.size?.y || 5} value={selectedObject.size?.y || 5}
onChange={(e) => onCommit={(val) =>
handleObjectUpdate({ handleObjectUpdate({
size: { size: {
...selectedObject.size, ...selectedObject.size,
y: parseFloat(e.target.value), y: val,
}, },
}) })
} }
@ -2170,17 +2242,17 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
<Label htmlFor="size-z" className="text-muted-foreground text-xs"> <Label htmlFor="size-z" className="text-muted-foreground text-xs">
D (5 ) D (5 )
</Label> </Label>
<Input <DebouncedInput
id="size-z" id="size-z"
type="number" type="number"
step="5" step="5"
min="5" min="5"
value={selectedObject.size?.z || 5} value={selectedObject.size?.z || 5}
onChange={(e) => onCommit={(val) =>
handleObjectUpdate({ handleObjectUpdate({
size: { size: {
...selectedObject.size, ...selectedObject.size,
z: parseFloat(e.target.value), z: val,
}, },
}) })
} }
@ -2195,11 +2267,12 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
<Label htmlFor="object-color" className="text-sm"> <Label htmlFor="object-color" className="text-sm">
</Label> </Label>
<Input <DebouncedInput
id="object-color" id="object-color"
type="color" type="color"
debounce={100}
value={selectedObject.color || "#3b82f6"} value={selectedObject.color || "#3b82f6"}
onChange={(e) => handleObjectUpdate({ color: e.target.value })} onCommit={(val) => handleObjectUpdate({ color: val })}
className="mt-1.5 h-9" className="mt-1.5 h-9"
/> />
</div> </div>

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo } from "react";
import { Loader2, Search, X, Grid3x3, Package } from "lucide-react"; import { Loader2, Search, X, Grid3x3, Package, ParkingCircle } from "lucide-react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -87,10 +87,11 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
areaKey: obj.area_key, areaKey: obj.area_key,
locaKey: obj.loca_key, locaKey: obj.loca_key,
locType: obj.loc_type, locType: obj.loc_type,
materialCount: obj.material_count, materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
materialPreview: obj.material_preview_height materialPreview:
? { height: parseFloat(obj.material_preview_height) } obj.loc_type === "STP" || !obj.material_preview_height
: undefined, ? undefined
: { height: parseFloat(obj.material_preview_height) },
parentId: obj.parent_id, parentId: obj.parent_id,
displayOrder: obj.display_order, displayOrder: obj.display_order,
locked: obj.locked, locked: obj.locked,
@ -166,13 +167,10 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
const obj = placedObjects.find((o) => o.id === objectId); const obj = placedObjects.find((o) => o.id === objectId);
setSelectedObject(obj || null); setSelectedObject(obj || null);
// Location을 클릭한 경우, 자재 정보 표시 // Location을 클릭한 경우, 자재 정보 표시 (STP는 자재 미적재이므로 제외)
if ( if (
obj && obj &&
(obj.type === "location-bed" || (obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") &&
obj.type === "location-stp" ||
obj.type === "location-temp" ||
obj.type === "location-dest") &&
obj.locaKey && obj.locaKey &&
externalDbConnectionId externalDbConnectionId
) { ) {
@ -363,59 +361,59 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
// Area가 없으면 기존 평면 리스트 유지 // Area가 없으면 기존 평면 리스트 유지
if (areaObjects.length === 0) { if (areaObjects.length === 0) {
return ( return (
<div className="space-y-2"> <div className="space-y-2">
{filteredObjects.map((obj) => { {filteredObjects.map((obj) => {
let typeLabel = obj.type; let typeLabel = obj.type;
if (obj.type === "location-bed") typeLabel = "베드(BED)"; if (obj.type === "location-bed") typeLabel = "베드(BED)";
else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)"; else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)";
else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)"; else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)";
else if (obj.type === "location-dest") typeLabel = "지정착지(DES)"; else if (obj.type === "location-dest") typeLabel = "지정착지(DES)";
else if (obj.type === "crane-mobile") typeLabel = "크레인"; else if (obj.type === "crane-mobile") typeLabel = "크레인";
else if (obj.type === "area") typeLabel = "Area"; else if (obj.type === "area") typeLabel = "Area";
else if (obj.type === "rack") typeLabel = "랙"; else if (obj.type === "rack") typeLabel = "랙";
return ( return (
<div <div
key={obj.id} key={obj.id}
onClick={() => handleObjectClick(obj.id)} onClick={() => handleObjectClick(obj.id)}
className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${ className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${
selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm" selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm"
}`} }`}
> >
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex-1"> <div className="flex-1">
<p className="text-sm font-medium">{obj.name}</p> <p className="text-sm font-medium">{obj.name}</p>
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs"> <div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
<span <span
className="inline-block h-2 w-2 rounded-full" className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: obj.color }} style={{ backgroundColor: obj.color }}
/> />
<span>{typeLabel}</span> <span>{typeLabel}</span>
</div>
</div>
</div>
<div className="mt-2 space-y-1">
{obj.areaKey && (
<p className="text-muted-foreground text-xs">
Area: <span className="font-medium">{obj.areaKey}</span>
</p>
)}
{obj.locaKey && (
<p className="text-muted-foreground text-xs">
Location: <span className="font-medium">{obj.locaKey}</span>
</p>
)}
{obj.materialCount !== undefined && obj.materialCount > 0 && (
<p className="text-xs text-yellow-600">
: <span className="font-semibold">{obj.materialCount}</span>
</p>
)}
</div>
</div> </div>
); </div>
})} </div>
<div className="mt-2 space-y-1">
{obj.areaKey && (
<p className="text-muted-foreground text-xs">
Area: <span className="font-medium">{obj.areaKey}</span>
</p>
)}
{obj.locaKey && (
<p className="text-muted-foreground text-xs">
Location: <span className="font-medium">{obj.locaKey}</span>
</p>
)}
{obj.materialCount !== undefined && obj.materialCount > 0 && (
<p className="text-xs text-yellow-600">
: <span className="font-semibold">{obj.materialCount}</span>
</p>
)}
</div>
</div> </div>
); );
})}
</div>
);
} }
// Area가 있는 경우: Area → Location 계층 아코디언 // Area가 있는 경우: Area → Location 계층 아코디언
@ -471,7 +469,11 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Package className="h-3 w-3" /> {locationObj.type === "location-stp" ? (
<ParkingCircle className="h-3 w-3" />
) : (
<Package className="h-3 w-3" />
)}
<span className="text-xs font-medium">{locationObj.name}</span> <span className="text-xs font-medium">{locationObj.name}</span>
</div> </div>
<span <span

View File

@ -131,13 +131,13 @@ export default function HierarchyConfigPanel({
try { try {
await Promise.all( await Promise.all(
tablesToFetch.map(async (tableName) => { tablesToFetch.map(async (tableName) => {
try { try {
const columns = await onLoadColumns(tableName); const columns = await onLoadColumns(tableName);
const normalized = normalizeColumns(columns); const normalized = normalizeColumns(columns);
setColumnsCache((prev) => ({ ...prev, [tableName]: normalized })); setColumnsCache((prev) => ({ ...prev, [tableName]: normalized }));
} catch (error) { } catch (error) {
console.error(`컬럼 로드 실패 (${tableName}):`, error); console.error(`컬럼 로드 실패 (${tableName}):`, error);
} }
}), }),
); );
} finally { } finally {

View File

@ -593,52 +593,58 @@ function MaterialBox({
); );
case "location-stp": case "location-stp":
// 정차포인트(STP): 주황색 낮은 플랫폼 // 정차포인트(STP): 회색 타원형 플랫폼 + 'P' 마크 (자재 미적재 영역)
return ( {
<> const baseRadius = 0.5; // 스케일로 실제 W/D를 반영 (타원형)
<Box args={[boxWidth, boxHeight, boxDepth]}> const labelFontSize = Math.min(boxWidth, boxDepth) * 0.15;
<meshStandardMaterial const iconFontSize = Math.min(boxWidth, boxDepth) * 0.3;
color={placement.color}
roughness={0.6}
metalness={0.2}
emissive={isSelected ? placement.color : "#000000"}
emissiveIntensity={isSelected ? glowIntensity * 0.8 : 0}
/>
</Box>
{/* Location 이름 */} return (
{placement.name && ( <>
{/* 타원형 플랫폼: 단위 실린더를 W/D로 스케일 */}
<mesh scale={[boxWidth, 1, boxDepth]}>
<cylinderGeometry args={[baseRadius, baseRadius, boxHeight, 32]} />
<meshStandardMaterial
color={placement.color}
roughness={0.6}
metalness={0.2}
emissive={isSelected ? placement.color : "#000000"}
emissiveIntensity={isSelected ? glowIntensity * 0.8 : 0}
/>
</mesh>
{/* 상단 'P' 마크 (주차 아이콘 역할) */}
<Text <Text
position={[0, boxHeight / 2 + 0.3, 0]} position={[0, boxHeight / 2 + 0.05, 0]}
rotation={[-Math.PI / 2, 0, 0]} rotation={[-Math.PI / 2, 0, 0]}
fontSize={Math.min(boxWidth, boxDepth) * 0.15} fontSize={iconFontSize}
color="#ffffff" color="#ffffff"
anchorX="center" anchorX="center"
anchorY="middle" anchorY="middle"
outlineWidth={0.03} outlineWidth={0.08}
outlineColor="#000000" outlineColor="#000000"
> >
{placement.name} P
</Text> </Text>
)}
{/* 자재 개수 (STP는 정차포인트라 자재가 없을 수 있음) */} {/* Location 이름 */}
{placement.material_count !== undefined && placement.material_count > 0 && ( {placement.name && (
<Text <Text
position={[0, boxHeight / 2 + 0.6, 0]} position={[0, boxHeight / 2 + 0.4, 0]}
rotation={[-Math.PI / 2, 0, 0]} rotation={[-Math.PI / 2, 0, 0]}
fontSize={Math.min(boxWidth, boxDepth) * 0.12} fontSize={labelFontSize}
color="#fbbf24" color="#ffffff"
anchorX="center" anchorX="center"
anchorY="middle" anchorY="middle"
outlineWidth={0.03} outlineWidth={0.03}
outlineColor="#000000" outlineColor="#000000"
> >
{`자재: ${placement.material_count}`} {placement.name}
</Text> </Text>
)} )}
</> </>
); );
}
// case "gantry-crane": // case "gantry-crane":
// // 겐트리 크레인: 기둥 2개 + 상단 빔 // // 겐트리 크레인: 기둥 2개 + 상단 빔
@ -1098,10 +1104,12 @@ function Scene({
orbitControlsRef={orbitControlsRef} orbitControlsRef={orbitControlsRef}
/> />
{/* 조명 */} {/* 조명 - 전체적으로 밝게 조정 */}
<ambientLight intensity={0.5} /> <ambientLight intensity={0.9} />
<directionalLight position={[10, 10, 5]} intensity={1} /> <directionalLight position={[10, 20, 10]} intensity={1.2} />
<directionalLight position={[-10, -10, -5]} intensity={0.3} /> <directionalLight position={[-10, 20, -10]} intensity={0.8} />
<directionalLight position={[0, 20, 0]} intensity={0.5} />
<hemisphereLight args={["#ffffff", "#bbbbbb", 0.8]} />
{/* 배경색 */} {/* 배경색 */}
<color attach="background" args={["#f3f4f6"]} /> <color attach="background" args={["#f3f4f6"]} />

View File

@ -164,3 +164,4 @@ export function getAllDescendants(

View File

@ -3,7 +3,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Plus, ChevronDown, ChevronRight, Users, Trash2 } from "lucide-react"; import { Plus, ChevronDown, ChevronRight, Users, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button"; 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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";

View File

@ -57,9 +57,12 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 폼 데이터 상태 추가 // 폼 데이터 상태 추가
const [formData, setFormData] = useState<Record<string, any>>({}); const [formData, setFormData] = useState<Record<string, any>>({});
// 🆕 원본 데이터 상태 (수정 모드에서 UPDATE 판단용)
const [originalData, setOriginalData] = useState<Record<string, any> | null>(null);
// 연속 등록 모드 상태 (state로 변경 - 체크박스 UI 업데이트를 위해) // 연속 등록 모드 상태 (state로 변경 - 체크박스 UI 업데이트를 위해)
const [continuousMode, setContinuousMode] = useState(false); const [continuousMode, setContinuousMode] = useState(false);
// 화면 리셋 키 (컴포넌트 강제 리마운트용) // 화면 리셋 키 (컴포넌트 강제 리마운트용)
const [resetKey, setResetKey] = useState(0); const [resetKey, setResetKey] = useState(0);
@ -68,7 +71,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
const savedMode = localStorage.getItem("screenModal_continuousMode"); const savedMode = localStorage.getItem("screenModal_continuousMode");
if (savedMode === "true") { if (savedMode === "true") {
setContinuousMode(true); setContinuousMode(true);
console.log("🔄 연속 모드 복원: true"); // console.log("🔄 연속 모드 복원: true");
} }
}, []); }, []);
@ -120,10 +123,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
}; };
}; };
// 모달이 열린 시간 추적 (저장 성공 이벤트 무시용)
const modalOpenedAtRef = React.useRef<number>(0);
// 전역 모달 이벤트 리스너 // 전역 모달 이벤트 리스너
useEffect(() => { useEffect(() => {
const handleOpenModal = (event: CustomEvent) => { 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에 추가 // 🆕 URL 파라미터가 있으면 현재 URL에 추가
if (urlParams && typeof window !== "undefined") { if (urlParams && typeof window !== "undefined") {
@ -136,6 +146,15 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
console.log("✅ URL 파라미터 추가:", urlParams); console.log("✅ URL 파라미터 추가:", urlParams);
} }
// 🆕 editData가 있으면 formData와 originalData로 설정 (수정 모드)
if (editData) {
console.log("📝 [ScreenModal] 수정 데이터 설정:", editData);
setFormData(editData);
setOriginalData(editData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
} else {
setOriginalData(null); // 신규 등록 모드
}
setModalState({ setModalState({
isOpen: true, isOpen: true,
screenId, screenId,
@ -164,6 +183,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
}); });
setScreenData(null); setScreenData(null);
setFormData({}); setFormData({});
setOriginalData(null); // 🆕 원본 데이터 초기화
setContinuousMode(false); setContinuousMode(false);
localStorage.setItem("screenModal_continuousMode", "false"); // localStorage에 저장 localStorage.setItem("screenModal_continuousMode", "false"); // localStorage에 저장
console.log("🔄 연속 모드 초기화: false"); console.log("🔄 연속 모드 초기화: false");
@ -171,6 +191,13 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 저장 성공 이벤트 처리 (연속 등록 모드 지원) // 저장 성공 이벤트 처리 (연속 등록 모드 지원)
const handleSaveSuccess = () => { const handleSaveSuccess = () => {
// 🆕 모달이 열린 후 500ms 이내의 저장 성공 이벤트는 무시 (이전 이벤트 방지)
const timeSinceOpen = Date.now() - modalOpenedAtRef.current;
if (timeSinceOpen < 500) {
console.log("⏭️ [ScreenModal] 모달 열린 직후 저장 성공 이벤트 무시:", { timeSinceOpen });
return;
}
const isContinuousMode = continuousMode; const isContinuousMode = continuousMode;
console.log("💾 저장 성공 이벤트 수신"); console.log("💾 저장 성공 이벤트 수신");
console.log("📌 현재 연속 모드 상태:", isContinuousMode); console.log("📌 현재 연속 모드 상태:", isContinuousMode);
@ -182,11 +209,11 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 1. 폼 데이터 초기화 // 1. 폼 데이터 초기화
setFormData({}); setFormData({});
// 2. 리셋 키 변경 (컴포넌트 강제 리마운트) // 2. 리셋 키 변경 (컴포넌트 강제 리마운트)
setResetKey(prev => prev + 1); setResetKey((prev) => prev + 1);
console.log("🔄 resetKey 증가 - 컴포넌트 리마운트"); console.log("🔄 resetKey 증가 - 컴포넌트 리마운트");
// 3. 화면 데이터 다시 로드 (채번 규칙 새로 생성) // 3. 화면 데이터 다시 로드 (채번 규칙 새로 생성)
if (modalState.screenId) { if (modalState.screenId) {
console.log("🔄 화면 데이터 다시 로드:", modalState.screenId); console.log("🔄 화면 데이터 다시 로드:", modalState.screenId);
@ -314,17 +341,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
if (Array.isArray(data)) { if (Array.isArray(data)) {
return data.map(normalizeDates); return data.map(normalizeDates);
} }
if (typeof data !== 'object' || data === null) { if (typeof data !== "object" || data === null) {
return data; return data;
} }
const normalized: any = {}; const normalized: any = {};
for (const [key, value] of Object.entries(data)) { 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만 추출 // ISO 날짜 형식 감지: YYYY-MM-DD만 추출
const before = value; const before = value;
const after = value.split('T')[0]; const after = value.split("T")[0];
console.log(`🔧 [날짜 정규화] ${key}: ${before}${after}`); console.log(`🔧 [날짜 정규화] ${key}: ${before}${after}`);
normalized[key] = after; normalized[key] = after;
} else { } else {
@ -333,21 +360,26 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
} }
return normalized; return normalized;
}; };
console.log("📥 [ScreenModal] API 응답 원본:", JSON.stringify(response.data, null, 2)); console.log("📥 [ScreenModal] API 응답 원본:", JSON.stringify(response.data, null, 2));
const normalizedData = normalizeDates(response.data); const normalizedData = normalizeDates(response.data);
console.log("📥 [ScreenModal] 정규화 후:", JSON.stringify(normalizedData, null, 2)); console.log("📥 [ScreenModal] 정규화 후:", JSON.stringify(normalizedData, null, 2));
// 🔧 배열 데이터는 formData로 설정하지 않음 (SelectedItemsDetailInput만 사용) // 🔧 배열 데이터는 formData로 설정하지 않음 (SelectedItemsDetailInput만 사용)
if (Array.isArray(normalizedData)) { if (Array.isArray(normalizedData)) {
console.log("⚠️ [ScreenModal] 그룹 레코드(배열)는 formData로 설정하지 않음. SelectedItemsDetailInput만 사용합니다."); console.log(
"⚠️ [ScreenModal] 그룹 레코드(배열)는 formData로 설정하지 않음. SelectedItemsDetailInput만 사용합니다.",
);
setFormData(normalizedData); // SelectedItemsDetailInput이 직접 사용 setFormData(normalizedData); // SelectedItemsDetailInput이 직접 사용
setOriginalData(normalizedData[0] || null); // 🆕 첫 번째 레코드를 원본으로 저장
} else { } else {
setFormData(normalizedData); setFormData(normalizedData);
setOriginalData(normalizedData); // 🆕 원본 데이터 저장 (UPDATE 판단용)
} }
// setFormData 직후 확인 // setFormData 직후 확인
console.log("🔄 setFormData 호출 완료 (날짜 정규화됨)"); console.log("🔄 setFormData 호출 완료 (날짜 정규화됨)");
console.log("🔄 setOriginalData 호출 완료 (UPDATE 판단용)");
} else { } else {
console.error("❌ 수정 데이터 로드 실패:", response.error); console.error("❌ 수정 데이터 로드 실패:", response.error);
toast.error("데이터를 불러올 수 없습니다."); toast.error("데이터를 불러올 수 없습니다.");
@ -416,7 +448,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
window.history.pushState({}, "", currentUrl.toString()); window.history.pushState({}, "", currentUrl.toString());
console.log("🧹 [ScreenModal] URL 파라미터 제거 (모달 닫힘)"); console.log("🧹 [ScreenModal] URL 파라미터 제거 (모달 닫힘)");
} }
setModalState({ setModalState({
isOpen: false, isOpen: false,
screenId: null, screenId: null,
@ -440,7 +472,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스 // 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스
const headerHeight = 60; // DialogHeader (타이틀 + 패딩) const headerHeight = 60; // DialogHeader (타이틀 + 패딩)
const footerHeight = 52; // 연속 등록 모드 체크박스 영역 const footerHeight = 52; // 연속 등록 모드 체크박스 영역
const totalHeight = screenDimensions.height + headerHeight + footerHeight; const totalHeight = screenDimensions.height + headerHeight + footerHeight;
return { return {
@ -581,17 +613,32 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
}, },
}; };
// 🆕 formData 전달 확인 로그
console.log("📝 [ScreenModal] InteractiveScreenViewerDynamic에 formData 전달:", {
componentId: component.id,
componentType: component.type,
componentComponentType: (component as any).componentType, // 🆕 실제 componentType 확인
hasFormData: !!formData,
formDataKeys: formData ? Object.keys(formData) : [],
});
return ( return (
<InteractiveScreenViewerDynamic <InteractiveScreenViewerDynamic
key={`${component.id}-${resetKey}`} key={`${component.id}-${resetKey}`}
component={adjustedComponent} component={adjustedComponent}
allComponents={screenData.components} allComponents={screenData.components}
formData={formData} formData={formData}
originalData={originalData} // 🆕 원본 데이터 전달 (UPDATE 판단용)
onFormDataChange={(fieldName, value) => { onFormDataChange={(fieldName, value) => {
setFormData((prev) => ({ console.log("🔧 [ScreenModal] onFormDataChange 호출:", { fieldName, value });
...prev, setFormData((prev) => {
[fieldName]: value, const newFormData = {
})); ...prev,
[fieldName]: value,
};
console.log("🔧 [ScreenModal] formData 업데이트:", { prev, newFormData });
return newFormData;
});
}} }}
onRefresh={() => { onRefresh={() => {
// 부모 화면의 테이블 새로고침 이벤트 발송 // 부모 화면의 테이블 새로고침 이벤트 발송

View File

@ -19,19 +19,50 @@ import {
Trash2, Trash2,
Settings, Settings,
Upload, Upload,
X X,
GripVertical,
ChevronUp,
ChevronDown,
LayoutTemplate,
Table2,
AlertCircle,
Minus,
Building2,
ListOrdered
} from "lucide-react"; } from "lucide-react";
import { getMailTemplates } from "@/lib/api/mail"; import { getMailTemplates } from "@/lib/api/mail";
export interface MailComponent { export interface MailComponent {
id: string; id: string;
type: "text" | "button" | "image" | "spacer" | "table"; type: "text" | "button" | "image" | "spacer" | "table" | "header" | "infoTable" | "alertBox" | "divider" | "footer" | "numberedList";
content?: string; content?: string;
text?: string; text?: string;
url?: string; url?: string;
src?: string; src?: string;
height?: number; height?: number;
styles?: Record<string, string>; styles?: Record<string, string>;
// 헤더 컴포넌트용
logoSrc?: string;
brandName?: string;
sendDate?: string;
headerBgColor?: string;
// 정보 테이블용
rows?: Array<{ label: string; value: string }>;
tableTitle?: string;
// 강조 박스용
alertType?: "info" | "warning" | "danger" | "success";
alertTitle?: string;
// 푸터용
companyName?: string;
ceoName?: string;
businessNumber?: string;
address?: string;
phone?: string;
email?: string;
copyright?: string;
// 번호 리스트용
listItems?: string[];
listTitle?: string;
} }
export interface QueryConfig { export interface QueryConfig {
@ -64,6 +95,10 @@ export default function MailDesigner({
const [subject, setSubject] = useState(""); const [subject, setSubject] = useState("");
const [queries, setQueries] = useState<QueryConfig[]>([]); const [queries, setQueries] = useState<QueryConfig[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
// 드래그 앤 드롭 상태
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
// 템플릿 데이터 로드 (수정 모드) // 템플릿 데이터 로드 (수정 모드)
useEffect(() => { useEffect(() => {
@ -96,10 +131,18 @@ export default function MailDesigner({
// 컴포넌트 타입 정의 // 컴포넌트 타입 정의
const componentTypes = [ const componentTypes = [
{ type: "text", icon: Type, label: "텍스트", color: "bg-primary/20 hover:bg-blue-200" }, // 레이아웃 컴포넌트
{ type: "button", icon: MousePointer, label: "버튼", color: "bg-success/20 hover:bg-success/30" }, { type: "header", icon: LayoutTemplate, label: "헤더", color: "bg-indigo-100 hover:bg-indigo-200", category: "layout" },
{ type: "image", icon: ImageIcon, label: "이미지", color: "bg-purple-100 hover:bg-purple-200" }, { type: "divider", icon: Minus, label: "구분선", color: "bg-gray-100 hover:bg-gray-200", category: "layout" },
{ type: "spacer", icon: Square, label: "여백", color: "bg-muted hover:bg-muted/80" }, { type: "spacer", icon: Square, label: "여백", color: "bg-muted hover:bg-muted/80", category: "layout" },
{ type: "footer", icon: Building2, label: "푸터", color: "bg-slate-100 hover:bg-slate-200", category: "layout" },
// 컨텐츠 컴포넌트
{ type: "text", icon: Type, label: "텍스트", color: "bg-primary/20 hover:bg-blue-200", category: "content" },
{ type: "button", icon: MousePointer, label: "버튼", color: "bg-success/20 hover:bg-success/30", category: "content" },
{ type: "image", icon: ImageIcon, label: "이미지", color: "bg-purple-100 hover:bg-purple-200", category: "content" },
{ type: "infoTable", icon: Table2, label: "정보 테이블", color: "bg-cyan-100 hover:bg-cyan-200", category: "content" },
{ type: "alertBox", icon: AlertCircle, label: "안내 박스", color: "bg-amber-100 hover:bg-amber-200", category: "content" },
{ type: "numberedList", icon: ListOrdered, label: "번호 리스트", color: "bg-emerald-100 hover:bg-emerald-200", category: "content" },
]; ];
// 컴포넌트 추가 // 컴포넌트 추가
@ -107,21 +150,75 @@ export default function MailDesigner({
const newComponent: MailComponent = { const newComponent: MailComponent = {
id: `comp-${Date.now()}`, id: `comp-${Date.now()}`,
type: type as any, type: type as any,
content: type === "text" ? "" : undefined, // 🎯 빈 문자열로 시작 (HTML 태그 제거) content: type === "text" ? "" : undefined,
text: type === "button" ? "버튼 텍스트" : undefined, // 🎯 더 명확한 기본값 text: type === "button" ? "버튼 텍스트" : undefined,
url: type === "button" || type === "image" ? "" : undefined, // 🎯 빈 문자열로 시작 url: type === "button" || type === "image" ? "" : undefined,
src: type === "image" ? "https://placehold.co/600x200/e5e7eb/64748b?text=이미지를+업로드하세요" : undefined, // 🎯 한글 안내 src: type === "image" ? "https://placehold.co/600x200/e5e7eb/64748b?text=이미지를+업로드하세요" : undefined,
height: type === "spacer" ? 30 : undefined, // 🎯 기본값 30px로 증가 (더 적절한 간격) height: type === "spacer" ? 30 : type === "divider" ? 1 : undefined,
styles: { styles: {
padding: "10px", padding: type === "divider" ? "0" : "10px",
backgroundColor: type === "button" ? "#007bff" : "transparent", backgroundColor: type === "button" ? "#007bff" : "transparent",
color: type === "button" ? "#fff" : "#333", color: type === "button" ? "#fff" : "#333",
}, },
// 헤더 기본값
logoSrc: type === "header" ? "" : undefined,
brandName: type === "header" ? "회사명" : undefined,
sendDate: type === "header" ? new Date().toLocaleDateString("ko-KR") : undefined,
headerBgColor: type === "header" ? "#f8f9fa" : undefined,
// 정보 테이블 기본값
rows: type === "infoTable" ? [{ label: "항목", value: "내용" }] : undefined,
tableTitle: type === "infoTable" ? "" : undefined,
// 안내 박스 기본값
alertType: type === "alertBox" ? "info" : undefined,
alertTitle: type === "alertBox" ? "안내" : undefined,
// 푸터 기본값
companyName: type === "footer" ? "회사명" : undefined,
ceoName: type === "footer" ? "" : undefined,
businessNumber: type === "footer" ? "" : undefined,
address: type === "footer" ? "" : undefined,
phone: type === "footer" ? "" : undefined,
email: type === "footer" ? "" : undefined,
copyright: type === "footer" ? `© ${new Date().getFullYear()} All rights reserved.` : undefined,
// 번호 리스트 기본값
listItems: type === "numberedList" ? ["첫 번째 항목"] : undefined,
listTitle: type === "numberedList" ? "" : undefined,
}; };
setComponents([...components, newComponent]); setComponents([...components, newComponent]);
}; };
// 드래그 앤 드롭 핸들러
const handleDragStart = (index: number) => {
setDraggedIndex(index);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
if (draggedIndex !== null && draggedIndex !== index) {
setDragOverIndex(index);
}
};
const handleDrop = (index: number) => {
if (draggedIndex !== null && draggedIndex !== index) {
moveComponent(draggedIndex, index);
}
setDraggedIndex(null);
setDragOverIndex(null);
};
const handleDragEnd = () => {
setDraggedIndex(null);
setDragOverIndex(null);
};
const moveComponent = (fromIndex: number, toIndex: number) => {
const newComponents = [...components];
const [movedItem] = newComponents.splice(fromIndex, 1);
newComponents.splice(toIndex, 0, movedItem);
setComponents(newComponents);
};
// 컴포넌트 삭제 // 컴포넌트 삭제
const removeComponent = (id: string) => { const removeComponent = (id: string) => {
setComponents(components.filter(c => c.id !== id)); setComponents(components.filter(c => c.id !== id));
@ -189,13 +286,35 @@ export default function MailDesigner({
<div className="flex h-screen bg-muted/30"> <div className="flex h-screen bg-muted/30">
{/* 왼쪽: 컴포넌트 팔레트 */} {/* 왼쪽: 컴포넌트 팔레트 */}
<div className="w-64 bg-white border-r p-4 space-y-4 overflow-y-auto"> <div className="w-64 bg-white border-r p-4 space-y-4 overflow-y-auto">
{/* 레이아웃 컴포넌트 */}
<div>
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center">
<LayoutTemplate className="w-4 h-4 mr-2 text-indigo-500" />
</h3>
<div className="space-y-2">
{componentTypes.filter(c => c.category === "layout").map(({ type, icon: Icon, label, color }) => (
<Button
key={type}
onClick={() => addComponent(type)}
variant="outline"
className={`w-full justify-start ${color} border`}
>
<Icon className="w-4 h-4 mr-2" />
{label}
</Button>
))}
</div>
</div>
{/* 컨텐츠 컴포넌트 */}
<div> <div>
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center"> <h3 className="text-sm font-semibold text-foreground mb-3 flex items-center">
<Mail className="w-4 h-4 mr-2 text-primary" /> <Mail className="w-4 h-4 mr-2 text-primary" />
</h3> </h3>
<div className="space-y-2"> <div className="space-y-2">
{componentTypes.map(({ type, icon: Icon, label, color }) => ( {componentTypes.filter(c => c.category === "content").map(({ type, icon: Icon, label, color }) => (
<Button <Button
key={type} key={type}
onClick={() => addComponent(type)} onClick={() => addComponent(type)}
@ -274,24 +393,57 @@ export default function MailDesigner({
)} )}
{/* 컴포넌트 렌더링 */} {/* 컴포넌트 렌더링 */}
<div className="p-6 space-y-4"> <div className="p-6 pl-14 space-y-4">
{components.length === 0 ? ( {components.length === 0 ? (
<div className="text-center py-16 text-muted-foreground/50"> <div className="text-center py-16 text-muted-foreground/50">
<Mail className="w-16 h-16 mx-auto mb-4 opacity-20" /> <Mail className="w-16 h-16 mx-auto mb-4 opacity-20" />
<p> </p> <p> </p>
</div> </div>
) : ( ) : (
components.map((comp) => ( components.map((comp, index) => (
<div <div
key={comp.id} key={comp.id}
draggable
onDragStart={() => handleDragStart(index)}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={() => handleDrop(index)}
onDragEnd={handleDragEnd}
onClick={() => selectComponent(comp.id)} onClick={() => selectComponent(comp.id)}
className={`relative group cursor-pointer rounded-lg transition-all ${ className={`relative group cursor-pointer rounded-lg transition-all ${
selectedComponent === comp.id selectedComponent === comp.id
? "ring-2 ring-orange-500 bg-orange-50/30" ? "ring-2 ring-orange-500 bg-orange-50/30"
: "hover:ring-2 hover:ring-gray-300" : "hover:ring-2 hover:ring-gray-300"
} ${draggedIndex === index ? "opacity-50 scale-95" : ""} ${
dragOverIndex === index ? "ring-2 ring-primary ring-dashed bg-primary/10" : ""
}`} }`}
style={comp.styles} style={comp.styles}
> >
{/* 드래그 핸들 & 순서 이동 버튼 */}
<div className="absolute -left-10 top-1/2 -translate-y-1/2 flex flex-col items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => { e.stopPropagation(); if (index > 0) moveComponent(index, index - 1); }}
className="p-1 hover:bg-gray-200 rounded disabled:opacity-30"
disabled={index === 0}
>
<ChevronUp className="w-3 h-3" />
</button>
<div className="cursor-grab active:cursor-grabbing p-1 hover:bg-gray-200 rounded">
<GripVertical className="w-4 h-4 text-gray-400" />
</div>
<button
onClick={(e) => { e.stopPropagation(); if (index < components.length - 1) moveComponent(index, index + 1); }}
className="p-1 hover:bg-gray-200 rounded disabled:opacity-30"
disabled={index === components.length - 1}
>
<ChevronDown className="w-3 h-3" />
</button>
</div>
{/* 순서 배지 */}
<div className="absolute -left-10 top-0 text-xs text-gray-400 opacity-0 group-hover:opacity-100">
{index + 1}
</div>
{/* 삭제 버튼 */} {/* 삭제 버튼 */}
<button <button
onClick={(e) => { onClick={(e) => {
@ -322,7 +474,82 @@ export default function MailDesigner({
<img src={comp.src} alt="메일 이미지" className="w-full rounded" /> <img src={comp.src} alt="메일 이미지" className="w-full rounded" />
)} )}
{comp.type === "spacer" && ( {comp.type === "spacer" && (
<div style={{ height: `${comp.height}px` }} /> <div style={{ height: `${comp.height}px` }} className="bg-gray-100 rounded flex items-center justify-center text-xs text-gray-400">
{comp.height}px
</div>
)}
{comp.type === "header" && (
<div className="p-4 rounded-lg" style={{ backgroundColor: comp.headerBgColor || "#f8f9fa" }}>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{comp.logoSrc && <img src={comp.logoSrc} alt="로고" className="h-10" />}
<span className="font-bold text-lg">{comp.brandName}</span>
</div>
<span className="text-sm text-gray-500">{comp.sendDate}</span>
</div>
</div>
)}
{comp.type === "infoTable" && (
<div className="border rounded-lg overflow-hidden">
{comp.tableTitle && (
<div className="bg-gray-50 px-4 py-2 font-semibold border-b">{comp.tableTitle}</div>
)}
<table className="w-full">
<tbody>
{comp.rows?.map((row, i) => (
<tr key={i} className={i % 2 === 0 ? "bg-white" : "bg-gray-50"}>
<td className="px-4 py-2 font-medium text-gray-600 w-1/3 border-r">{row.label}</td>
<td className="px-4 py-2">{row.value}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{comp.type === "alertBox" && (
<div className={`p-4 rounded-lg border-l-4 ${
comp.alertType === "info" ? "bg-blue-50 border-blue-500 text-blue-800" :
comp.alertType === "warning" ? "bg-amber-50 border-amber-500 text-amber-800" :
comp.alertType === "danger" ? "bg-red-50 border-red-500 text-red-800" :
"bg-emerald-50 border-emerald-500 text-emerald-800"
}`}>
{comp.alertTitle && <div className="font-bold mb-1">{comp.alertTitle}</div>}
<div>{comp.content}</div>
</div>
)}
{comp.type === "divider" && (
<hr className="border-gray-300" style={{ borderWidth: `${comp.height || 1}px` }} />
)}
{comp.type === "footer" && (
<div className="text-center text-sm text-gray-500 py-4 border-t bg-gray-50">
{comp.companyName && <div className="font-semibold text-gray-700">{comp.companyName}</div>}
{(comp.ceoName || comp.businessNumber) && (
<div className="mt-1">
{comp.ceoName && <span>: {comp.ceoName}</span>}
{comp.ceoName && comp.businessNumber && <span className="mx-2">|</span>}
{comp.businessNumber && <span>: {comp.businessNumber}</span>}
</div>
)}
{comp.address && <div className="mt-1">{comp.address}</div>}
{(comp.phone || comp.email) && (
<div className="mt-1">
{comp.phone && <span>Tel: {comp.phone}</span>}
{comp.phone && comp.email && <span className="mx-2">|</span>}
{comp.email && <span>Email: {comp.email}</span>}
</div>
)}
{comp.copyright && <div className="mt-2 text-xs text-gray-400">{comp.copyright}</div>}
</div>
)}
{comp.type === "numberedList" && (
<div className="p-4">
{comp.listTitle && <div className="font-semibold mb-2">{comp.listTitle}</div>}
<ol className="list-decimal list-inside space-y-1">
{comp.listItems?.map((item, i) => (
<li key={i}>{item}</li>
))}
</ol>
</div>
)} )}
</div> </div>
)) ))
@ -571,13 +798,299 @@ export default function MailDesigner({
/> />
<span className="text-sm text-muted-foreground"></span> <span className="text-sm text-muted-foreground"></span>
</div> </div>
<div className="mt-3 p-3 bg-primary/10 rounded-lg border border-primary/20"> </div>
<p className="text-xs text-primary"> </div>
<strong>:</strong><br/> )}
간격: 10~20 <br/>
간격: 30~50 <br/> {/* 헤더 컴포넌트 */}
간격: 60~100 {selected.type === "header" && (
</p> <div className="space-y-4">
<div>
<Label></Label>
<Input
value={selected.brandName || ""}
onChange={(e) => updateComponent(selected.id, { brandName: e.target.value })}
placeholder="회사명"
className="mt-1"
/>
</div>
<div>
<Label> URL</Label>
<Input
value={selected.logoSrc || ""}
onChange={(e) => updateComponent(selected.id, { logoSrc: e.target.value })}
placeholder="https://example.com/logo.png"
className="mt-1"
/>
</div>
<div>
<Label></Label>
<Input
value={selected.sendDate || ""}
onChange={(e) => updateComponent(selected.id, { sendDate: e.target.value })}
className="mt-1"
/>
</div>
<div>
<Label></Label>
<div className="flex items-center gap-3 mt-1">
<Input
type="color"
value={selected.headerBgColor || "#f8f9fa"}
onChange={(e) => updateComponent(selected.id, { headerBgColor: e.target.value })}
className="w-16 h-10"
/>
<span className="text-sm text-muted-foreground">{selected.headerBgColor || "#f8f9fa"}</span>
</div>
</div>
</div>
)}
{/* 정보 테이블 컴포넌트 */}
{selected.type === "infoTable" && (
<div className="space-y-4">
<div>
<Label> </Label>
<Input
value={selected.tableTitle || ""}
onChange={(e) => updateComponent(selected.id, { tableTitle: e.target.value })}
placeholder="예: 주문 정보"
className="mt-1"
/>
</div>
<div>
<Label> </Label>
<div className="space-y-2 mt-2">
{selected.rows?.map((row, i) => (
<div key={i} className="flex gap-2">
<Input
value={row.label}
onChange={(e) => {
const newRows = [...(selected.rows || [])];
newRows[i] = { ...newRows[i], label: e.target.value };
updateComponent(selected.id, { rows: newRows });
}}
placeholder="항목명"
className="flex-1"
/>
<Input
value={row.value}
onChange={(e) => {
const newRows = [...(selected.rows || [])];
newRows[i] = { ...newRows[i], value: e.target.value };
updateComponent(selected.id, { rows: newRows });
}}
placeholder="값"
className="flex-1"
/>
<Button
variant="ghost"
size="sm"
onClick={() => {
const newRows = selected.rows?.filter((_, idx) => idx !== i);
updateComponent(selected.id, { rows: newRows });
}}
>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={() => {
const newRows = [...(selected.rows || []), { label: "", value: "" }];
updateComponent(selected.id, { rows: newRows });
}}
className="w-full"
>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div>
</div>
</div>
)}
{/* 안내 박스 컴포넌트 */}
{selected.type === "alertBox" && (
<div className="space-y-4">
<div>
<Label> </Label>
<div className="grid grid-cols-2 gap-2 mt-2">
{(["info", "warning", "danger", "success"] as const).map((type) => (
<Button
key={type}
variant={selected.alertType === type ? "default" : "outline"}
size="sm"
onClick={() => updateComponent(selected.id, { alertType: type })}
className={
type === "info" ? "border-blue-300" :
type === "warning" ? "border-amber-300" :
type === "danger" ? "border-red-300" : "border-emerald-300"
}
>
{type === "info" ? "정보" : type === "warning" ? "주의" : type === "danger" ? "위험" : "성공"}
</Button>
))}
</div>
</div>
<div>
<Label></Label>
<Input
value={selected.alertTitle || ""}
onChange={(e) => updateComponent(selected.id, { alertTitle: e.target.value })}
placeholder="안내 제목"
className="mt-1"
/>
</div>
<div>
<Label></Label>
<Textarea
value={selected.content || ""}
onChange={(e) => updateComponent(selected.id, { content: e.target.value })}
placeholder="안내 내용을 입력하세요"
rows={4}
className="mt-1"
/>
</div>
</div>
)}
{/* 구분선 컴포넌트 */}
{selected.type === "divider" && (
<div className="space-y-4">
<div>
<Label> </Label>
<div className="flex items-center gap-3 mt-1">
<Input
type="number"
value={selected.height || 1}
onChange={(e) => updateComponent(selected.id, { height: parseInt(e.target.value) || 1 })}
className="w-24"
min="1"
max="10"
/>
<span className="text-sm text-muted-foreground"></span>
</div>
</div>
</div>
)}
{/* 푸터 컴포넌트 */}
{selected.type === "footer" && (
<div className="space-y-4">
<div>
<Label></Label>
<Input
value={selected.companyName || ""}
onChange={(e) => updateComponent(selected.id, { companyName: e.target.value })}
className="mt-1"
/>
</div>
<div>
<Label></Label>
<Input
value={selected.ceoName || ""}
onChange={(e) => updateComponent(selected.id, { ceoName: e.target.value })}
className="mt-1"
/>
</div>
<div>
<Label></Label>
<Input
value={selected.businessNumber || ""}
onChange={(e) => updateComponent(selected.id, { businessNumber: e.target.value })}
placeholder="000-00-00000"
className="mt-1"
/>
</div>
<div>
<Label></Label>
<Input
value={selected.address || ""}
onChange={(e) => updateComponent(selected.id, { address: e.target.value })}
className="mt-1"
/>
</div>
<div>
<Label></Label>
<Input
value={selected.phone || ""}
onChange={(e) => updateComponent(selected.id, { phone: e.target.value })}
className="mt-1"
/>
</div>
<div>
<Label></Label>
<Input
value={selected.email || ""}
onChange={(e) => updateComponent(selected.id, { email: e.target.value })}
className="mt-1"
/>
</div>
<div>
<Label> </Label>
<Input
value={selected.copyright || ""}
onChange={(e) => updateComponent(selected.id, { copyright: e.target.value })}
className="mt-1"
/>
</div>
</div>
)}
{/* 번호 리스트 컴포넌트 */}
{selected.type === "numberedList" && (
<div className="space-y-4">
<div>
<Label> </Label>
<Input
value={selected.listTitle || ""}
onChange={(e) => updateComponent(selected.id, { listTitle: e.target.value })}
placeholder="예: 안내 사항"
className="mt-1"
/>
</div>
<div>
<Label></Label>
<div className="space-y-2 mt-2">
{selected.listItems?.map((item, i) => (
<div key={i} className="flex gap-2">
<span className="flex items-center justify-center w-6 text-sm text-muted-foreground">{i + 1}.</span>
<Input
value={item}
onChange={(e) => {
const newItems = [...(selected.listItems || [])];
newItems[i] = e.target.value;
updateComponent(selected.id, { listItems: newItems });
}}
className="flex-1"
/>
<Button
variant="ghost"
size="sm"
onClick={() => {
const newItems = selected.listItems?.filter((_, idx) => idx !== i);
updateComponent(selected.id, { listItems: newItems });
}}
>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={() => {
const newItems = [...(selected.listItems || []), ""];
updateComponent(selected.id, { listItems: newItems });
}}
className="w-full"
>
<Plus className="w-4 h-4 mr-2" />
</Button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -75,6 +75,13 @@ const ORDER_COLUMNS: RepeaterColumnConfig[] = [
calculated: true, calculated: true,
width: "120px", width: "120px",
}, },
{
field: "order_date",
label: "수주일",
type: "date",
editable: true,
width: "130px",
},
{ {
field: "delivery_date", field: "delivery_date",
label: "납기일", label: "납기일",

View File

@ -0,0 +1,408 @@
/**
*
*
*/
"use client";
import React, { forwardRef, useImperativeHandle, useState, useEffect, useRef, useCallback } from "react";
import type {
ScreenEmbedding,
DataReceiver,
DataReceivable,
EmbeddedScreenHandle,
DataReceiveMode,
} from "@/types/screen-embedding";
import type { ComponentData } from "@/types/screen";
import { logger } from "@/lib/utils/logger";
import { applyMappingRules, filterDataByCondition } from "@/lib/utils/dataMapping";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { screenApi } from "@/lib/api/screen";
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
import { ScreenContextProvider } from "@/contexts/ScreenContext";
import { useAuth } from "@/hooks/useAuth";
interface EmbeddedScreenProps {
embedding: ScreenEmbedding;
onSelectionChanged?: (selectedRows: any[]) => void;
position?: SplitPanelPosition; // 분할 패널 내 위치 (left/right)
initialFormData?: Record<string, any>; // 🆕 수정 모드에서 전달되는 초기 데이터
}
/**
*
*/
export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenProps>(
({ embedding, onSelectionChanged, position, initialFormData }, ref) => {
const [layout, setLayout] = useState<ComponentData[]>([]);
const [selectedRows, setSelectedRows] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [screenInfo, setScreenInfo] = useState<any>(null);
const [formData, setFormData] = useState<Record<string, any>>(initialFormData || {}); // 🆕 초기 데이터로 시작
// 컴포넌트 참조 맵
const componentRefs = useRef<Map<string, DataReceivable>>(new Map());
// 분할 패널 컨텍스트 (분할 패널 내부에 있을 때만 사용)
const splitPanelContext = useSplitPanelContext();
// 🆕 사용자 정보 가져오기 (저장 액션에 필요)
const { userId, userName, companyCode } = useAuth();
// 컴포넌트들의 실제 영역 계산 (가로폭 맞춤을 위해)
const contentBounds = React.useMemo(() => {
if (layout.length === 0) return { width: 0, height: 0 };
let maxRight = 0;
let maxBottom = 0;
layout.forEach((component) => {
const { position: compPosition = { x: 0, y: 0 }, size = { width: 200, height: 40 } } = component;
const right = (compPosition.x || 0) + (size.width || 200);
const bottom = (compPosition.y || 0) + (size.height || 40);
if (right > maxRight) maxRight = right;
if (bottom > maxBottom) maxBottom = bottom;
});
return { width: maxRight, height: maxBottom };
}, [layout]);
// 필드 값 변경 핸들러
const handleFieldChange = useCallback((fieldName: string, value: any) => {
console.log("📝 [EmbeddedScreen] 필드 값 변경:", { fieldName, value });
setFormData((prev) => ({
...prev,
[fieldName]: value,
}));
}, []);
// 화면 데이터 로드
useEffect(() => {
loadScreenData();
}, [embedding.childScreenId]);
// 🆕 initialFormData 변경 시 formData 업데이트 (수정 모드)
useEffect(() => {
if (initialFormData && Object.keys(initialFormData).length > 0) {
console.log("📝 [EmbeddedScreen] 초기 폼 데이터 설정:", initialFormData);
setFormData(initialFormData);
}
}, [initialFormData]);
// 선택 변경 이벤트 전파
useEffect(() => {
onSelectionChanged?.(selectedRows);
}, [selectedRows, onSelectionChanged]);
/**
*
*/
const loadScreenData = async () => {
try {
setLoading(true);
setError(null);
// 화면 정보 로드 (screenApi.getScreen은 직접 ScreenDefinition 객체를 반환)
const screenData = await screenApi.getScreen(embedding.childScreenId);
console.log("📋 [EmbeddedScreen] 화면 정보 API 응답:", {
screenId: embedding.childScreenId,
hasData: !!screenData,
tableName: screenData?.tableName,
screenName: screenData?.name || screenData?.screenName,
position,
});
if (screenData) {
setScreenInfo(screenData);
} else {
console.warn("⚠️ [EmbeddedScreen] 화면 정보 로드 실패:", {
screenId: embedding.childScreenId,
});
}
// 화면 레이아웃 로드 (별도 API)
const layoutData = await screenApi.getLayout(embedding.childScreenId);
logger.info("📦 화면 레이아웃 로드 완료", {
screenId: embedding.childScreenId,
mode: embedding.mode,
hasLayoutData: !!layoutData,
componentsCount: layoutData?.components?.length || 0,
position,
});
if (layoutData && layoutData.components && Array.isArray(layoutData.components)) {
setLayout(layoutData.components);
logger.info("✅ 임베드 화면 컴포넌트 설정 완료", {
screenId: embedding.childScreenId,
componentsCount: layoutData.components.length,
});
} else {
logger.warn("⚠️ 화면에 컴포넌트가 없습니다", {
screenId: embedding.childScreenId,
layoutData,
});
setLayout([]);
}
} catch (err: any) {
logger.error("화면 레이아웃 로드 실패", err);
setError(err.message || "화면을 불러올 수 없습니다.");
} finally {
setLoading(false);
}
};
/**
*
*/
const registerComponent = useCallback((id: string, component: DataReceivable) => {
componentRefs.current.set(id, component);
logger.debug("컴포넌트 등록", {
componentId: id,
componentType: component.componentType,
});
}, []);
/**
*
*/
const unregisterComponent = useCallback((id: string) => {
componentRefs.current.delete(id);
logger.debug("컴포넌트 등록 해제", {
componentId: id,
});
}, []);
/**
*
*/
const handleSelectionChange = useCallback((rows: any[]) => {
setSelectedRows(rows);
}, []);
// 외부에서 호출 가능한 메서드
useImperativeHandle(ref, () => ({
/**
*
*/
getSelectedRows: () => {
return selectedRows;
},
/**
*
*/
clearSelection: () => {
setSelectedRows([]);
},
/**
*
*/
receiveData: async (data: any[], receivers: DataReceiver[]) => {
logger.info("데이터 수신 시작", {
dataCount: data.length,
receiversCount: receivers.length,
});
const errors: Array<{ componentId: string; error: string }> = [];
// 각 데이터 수신자에게 데이터 전달
for (const receiver of receivers) {
try {
const component = componentRefs.current.get(receiver.targetComponentId);
if (!component) {
const errorMsg = `컴포넌트를 찾을 수 없습니다: ${receiver.targetComponentId}`;
logger.warn(errorMsg);
errors.push({
componentId: receiver.targetComponentId,
error: errorMsg,
});
continue;
}
// 1. 조건 필터링
let filteredData = data;
if (receiver.condition) {
filteredData = filterDataByCondition(data, receiver.condition);
logger.debug("조건 필터링 적용", {
componentId: receiver.targetComponentId,
originalCount: data.length,
filteredCount: filteredData.length,
});
}
// 2. 매핑 규칙 적용
const mappedData = applyMappingRules(filteredData, receiver.mappingRules);
logger.debug("매핑 규칙 적용", {
componentId: receiver.targetComponentId,
mappingRulesCount: receiver.mappingRules.length,
});
// 3. 검증
if (receiver.validation) {
if (receiver.validation.required && mappedData.length === 0) {
throw new Error("필수 데이터가 없습니다.");
}
if (receiver.validation.minRows && mappedData.length < receiver.validation.minRows) {
throw new Error(`최소 ${receiver.validation.minRows}개의 데이터가 필요합니다.`);
}
if (receiver.validation.maxRows && mappedData.length > receiver.validation.maxRows) {
throw new Error(`최대 ${receiver.validation.maxRows}개까지만 허용됩니다.`);
}
}
// 4. 데이터 전달
await component.receiveData(mappedData, receiver.mode);
logger.info("데이터 전달 성공", {
componentId: receiver.targetComponentId,
componentType: receiver.targetComponentType,
mode: receiver.mode,
dataCount: mappedData.length,
});
} catch (err: any) {
logger.error("데이터 전달 실패", {
componentId: receiver.targetComponentId,
error: err.message,
});
errors.push({
componentId: receiver.targetComponentId,
error: err.message,
});
}
}
if (errors.length > 0) {
throw new Error(`일부 컴포넌트에 데이터 전달 실패: ${errors.map((e) => e.componentId).join(", ")}`);
}
},
/**
*
*/
getData: () => {
const allData: Record<string, any> = {};
componentRefs.current.forEach((component, id) => {
allData[id] = component.getData();
});
return allData;
},
}));
// 로딩 상태
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="border-primary h-8 w-8 animate-spin rounded-full border-4 border-t-transparent" />
<p className="text-muted-foreground text-sm"> ...</p>
</div>
</div>
);
}
// 에러 상태
if (error) {
return (
<div className="flex h-full items-center justify-center">
<div className="flex flex-col items-center gap-4 text-center">
<div className="bg-destructive/10 flex h-12 w-12 items-center justify-center rounded-full">
<svg className="text-destructive h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<div>
<p className="text-sm font-medium"> </p>
<p className="text-muted-foreground mt-1 text-xs">{error}</p>
</div>
<button onClick={loadScreenData} className="text-primary text-sm hover:underline">
</button>
</div>
</div>
);
}
// 화면 렌더링 - 절대 위치 기반 레이아웃 (원본 화면과 동일하게)
// position을 ScreenContextProvider에 전달하여 중첩된 화면에서도 위치를 알 수 있게 함
return (
<ScreenContextProvider
screenId={embedding.childScreenId}
tableName={screenInfo?.tableName}
splitPanelPosition={position}
>
<div className="relative h-full w-full overflow-auto p-4">
{layout.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm"> .</p>
</div>
) : (
<div
className="relative w-full"
style={{
minHeight: contentBounds.height + 20, // 여유 공간 추가
}}
>
{layout.map((component) => {
const { position: compPosition = { x: 0, y: 0, z: 1 }, size = { width: 200, height: 40 } } = component;
// 컴포넌트가 컨테이너 너비를 초과하지 않도록 너비 조정
// 부모 컨테이너의 100%를 기준으로 계산
const componentStyle: React.CSSProperties = {
left: compPosition.x || 0,
top: compPosition.y || 0,
width: size.width || 200,
height: size.height || 40,
zIndex: compPosition.z || 1,
// 컴포넌트가 오른쪽 경계를 넘어가면 너비 조정
maxWidth: `calc(100% - ${compPosition.x || 0}px)`,
};
return (
<div
key={component.id}
className="absolute"
style={componentStyle}
>
<DynamicComponentRenderer
component={component}
isInteractive={true}
screenId={embedding.childScreenId}
tableName={screenInfo?.tableName}
formData={formData}
onFormDataChange={handleFieldChange}
onSelectionChange={embedding.mode === "select" ? handleSelectionChange : undefined}
userId={userId}
userName={userName}
companyCode={companyCode}
/>
</div>
);
})}
</div>
)}
</div>
</ScreenContextProvider>
);
},
);
EmbeddedScreen.displayName = "EmbeddedScreen";

View File

@ -0,0 +1,183 @@
/**
*
* .
*
* transferData .
* : 좌측 TableListComponent + Button(transferData )
*/
"use client";
import React, { useState, useCallback, useMemo } from "react";
import { EmbeddedScreen } from "./EmbeddedScreen";
import { Columns2 } from "lucide-react";
import { SplitPanelProvider } from "@/contexts/SplitPanelContext";
interface ScreenSplitPanelProps {
screenId?: number;
config?: any; // 설정 패널에서 오는 config (leftScreenId, rightScreenId, splitRatio, resizable)
initialFormData?: Record<string, any>; // 🆕 수정 모드에서 전달되는 초기 데이터
}
/**
*
* .
*/
export function ScreenSplitPanel({ screenId, config, initialFormData }: ScreenSplitPanelProps) {
// config에서 splitRatio 추출 (기본값 50)
const configSplitRatio = config?.splitRatio ?? 50;
console.log("🎯 [ScreenSplitPanel] 렌더링됨!", {
screenId,
config,
leftScreenId: config?.leftScreenId,
rightScreenId: config?.rightScreenId,
configSplitRatio,
configKeys: config ? Object.keys(config) : [],
});
// 🆕 initialFormData 별도 로그 (명확한 확인)
console.log("📝 [ScreenSplitPanel] initialFormData 확인:", {
hasInitialFormData: !!initialFormData,
initialFormDataKeys: initialFormData ? Object.keys(initialFormData) : [],
initialFormData: initialFormData,
});
// 드래그로 조절 가능한 splitRatio 상태
const [splitRatio, setSplitRatio] = useState(configSplitRatio);
// config.splitRatio가 변경되면 동기화 (설정 패널에서 변경 시)
React.useEffect(() => {
console.log("📐 [ScreenSplitPanel] splitRatio 동기화:", { configSplitRatio, currentSplitRatio: splitRatio });
setSplitRatio(configSplitRatio);
}, [configSplitRatio]);
// 설정 패널에서 오는 간단한 config를 임베딩 설정으로 변환
const leftEmbedding = config?.leftScreenId
? {
id: 1,
parentScreenId: screenId || 0,
childScreenId: config.leftScreenId,
position: "left" as const,
mode: "view" as const, // 기본 view 모드 (select는 테이블 자체 설정)
config: {},
companyCode: "*",
createdAt: new Date(),
updatedAt: new Date(),
}
: null;
const rightEmbedding = config?.rightScreenId
? {
id: 2,
parentScreenId: screenId || 0,
childScreenId: config.rightScreenId,
position: "right" as const,
mode: "view" as const, // 기본 view 모드
config: {},
companyCode: "*",
createdAt: new Date(),
updatedAt: new Date(),
}
: null;
/**
*
*/
const handleResize = useCallback((newRatio: number) => {
setSplitRatio(Math.max(20, Math.min(80, newRatio)));
}, []);
// config가 없는 경우 (디자이너 모드 또는 초기 상태)
if (!config) {
return (
<div className="border-muted-foreground/25 flex h-full items-center justify-center rounded-lg border-2 border-dashed">
<div className="space-y-4 p-6 text-center">
<div className="flex items-center justify-center gap-3">
<div className="bg-muted flex h-16 w-16 items-center justify-center rounded-lg">
<Columns2 className="text-muted-foreground h-8 w-8" />
</div>
</div>
<div>
<p className="text-muted-foreground mb-2 text-base font-semibold"> </p>
<p className="text-muted-foreground/60 mb-1 text-xs"> </p>
<p className="text-muted-foreground/60 text-xs">
/
</p>
<p className="text-muted-foreground/60 mt-2 text-[10px]">
💡 전달: 좌측 transferData
</p>
</div>
</div>
</div>
);
}
// 좌측 또는 우측 화면이 설정되지 않은 경우 안내 메시지 표시
const hasLeftScreen = !!leftEmbedding;
const hasRightScreen = !!rightEmbedding;
// 분할 패널 고유 ID 생성
const splitPanelId = useMemo(() => `split-panel-${screenId || "unknown"}-${Date.now()}`, [screenId]);
return (
<SplitPanelProvider
splitPanelId={splitPanelId}
leftScreenId={config?.leftScreenId || null}
rightScreenId={config?.rightScreenId || null}
>
<div className="flex h-full">
{/* 좌측 패널 */}
<div style={{ width: `${splitRatio}%` }} className="h-full flex-shrink-0 overflow-hidden border-r">
{hasLeftScreen ? (
<EmbeddedScreen embedding={leftEmbedding!} position="left" initialFormData={initialFormData} />
) : (
<div className="flex h-full items-center justify-center bg-muted/30">
<p className="text-muted-foreground text-sm"> </p>
</div>
)}
</div>
{/* 리사이저 */}
{config?.resizable !== false && (
<div
className="group bg-border hover:bg-primary/20 relative w-1 flex-shrink-0 cursor-col-resize transition-colors"
onMouseDown={(e) => {
e.preventDefault();
const startX = e.clientX;
const startRatio = splitRatio;
const containerWidth = e.currentTarget.parentElement!.offsetWidth;
const handleMouseMove = (moveEvent: MouseEvent) => {
const deltaX = moveEvent.clientX - startX;
const deltaRatio = (deltaX / containerWidth) * 100;
handleResize(startRatio + deltaRatio);
};
const handleMouseUp = () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}}
>
<div className="bg-primary absolute inset-y-0 left-1/2 w-1 -translate-x-1/2 opacity-0 transition-opacity group-hover:opacity-100" />
</div>
)}
{/* 우측 패널 */}
<div style={{ width: `${100 - splitRatio}%` }} className="h-full flex-shrink-0 overflow-hidden">
{hasRightScreen ? (
<EmbeddedScreen embedding={rightEmbedding!} position="right" initialFormData={initialFormData} />
) : (
<div className="flex h-full items-center justify-center bg-muted/30">
<p className="text-muted-foreground text-sm"> </p>
</div>
)}
</div>
</div>
</SplitPanelProvider>
);
}

View File

@ -0,0 +1,7 @@
/**
*
*/
export { EmbeddedScreen } from "./EmbeddedScreen";
export { ScreenSplitPanel } from "./ScreenSplitPanel";

View File

@ -6,7 +6,6 @@ import {
ResizableDialogContent, ResizableDialogContent,
ResizableDialogHeader, ResizableDialogHeader,
ResizableDialogTitle, ResizableDialogTitle,
ResizableDialogDescription,
ResizableDialogFooter, ResizableDialogFooter,
} from "@/components/ui/resizable-dialog"; } from "@/components/ui/resizable-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -15,11 +14,13 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Search, X, Check, ChevronsUpDown, Database } from "lucide-react"; import { Search, X, Check, ChevronsUpDown, Database, Globe } from "lucide-react";
import Link from "next/link";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { screenApi, tableTypeApi } from "@/lib/api/screen"; import { screenApi, tableTypeApi } from "@/lib/api/screen";
import { ScreenDefinition } from "@/types/screen"; import { ScreenDefinition } from "@/types/screen";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection";
interface CreateScreenModalProps { interface CreateScreenModalProps {
open: boolean; open: boolean;
@ -39,12 +40,22 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
const [tableSearchTerm, setTableSearchTerm] = useState(""); const [tableSearchTerm, setTableSearchTerm] = useState("");
const searchInputRef = useRef<HTMLInputElement>(null); const searchInputRef = useRef<HTMLInputElement>(null);
// 데이터 소스 타입 (database: 데이터베이스, restapi: REST API)
const [dataSourceType, setDataSourceType] = useState<"database" | "restapi">("database");
// 외부 DB 연결 관련 상태 // 외부 DB 연결 관련 상태
const [selectedDbSource, setSelectedDbSource] = useState<"internal" | number>("internal"); const [selectedDbSource, setSelectedDbSource] = useState<"internal" | number>("internal");
const [externalConnections, setExternalConnections] = useState<any[]>([]); const [externalConnections, setExternalConnections] = useState<any[]>([]);
const [externalTableList, setExternalTableList] = useState<string[]>([]); const [externalTableList, setExternalTableList] = useState<string[]>([]);
const [loadingExternalTables, setLoadingExternalTables] = useState(false); const [loadingExternalTables, setLoadingExternalTables] = useState(false);
const [openDbSourceCombobox, setOpenDbSourceCombobox] = useState(false); const [openDbSourceCombobox, setOpenDbSourceCombobox] = useState(false);
// REST API 연결 관련 상태
const [restApiConnections, setRestApiConnections] = useState<ExternalRestApiConnection[]>([]);
const [selectedRestApiId, setSelectedRestApiId] = useState<number | null>(null);
const [openRestApiCombobox, setOpenRestApiCombobox] = useState(false);
const [restApiEndpoint, setRestApiEndpoint] = useState("");
const [restApiJsonPath, setRestApiJsonPath] = useState("data"); // 응답에서 데이터 추출 경로
// 화면 코드 자동 생성 // 화면 코드 자동 생성
const generateCode = async () => { const generateCode = async () => {
try { try {
@ -109,6 +120,21 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
loadConnections(); loadConnections();
}, [open]); }, [open]);
// REST API 연결 목록 로드
useEffect(() => {
if (!open) return;
const loadRestApiConnections = async () => {
try {
const connections = await ExternalRestApiConnectionAPI.getConnections({ is_active: "Y" });
setRestApiConnections(connections);
} catch (error) {
console.error("Failed to load REST API connections:", error);
setRestApiConnections([]);
}
};
loadRestApiConnections();
}, [open]);
// 외부 DB 테이블 목록 로드 // 외부 DB 테이블 목록 로드
useEffect(() => { useEffect(() => {
if (selectedDbSource === "internal" || !selectedDbSource) { if (selectedDbSource === "internal" || !selectedDbSource) {
@ -160,8 +186,15 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
}, [open, screenCode]); }, [open, screenCode]);
const isValid = useMemo(() => { const isValid = useMemo(() => {
return screenName.trim().length > 0 && screenCode.trim().length > 0 && tableName.trim().length > 0; const baseValid = screenName.trim().length > 0 && screenCode.trim().length > 0;
}, [screenName, screenCode, tableName]);
if (dataSourceType === "database") {
return baseValid && tableName.trim().length > 0;
} else {
// REST API: 연결 선택 필수
return baseValid && selectedRestApiId !== null;
}
}, [screenName, screenCode, tableName, dataSourceType, selectedRestApiId]);
// 테이블 필터링 (내부 DB용) // 테이블 필터링 (내부 DB용)
const filteredTables = useMemo(() => { const filteredTables = useMemo(() => {
@ -186,17 +219,30 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
setSubmitting(true); setSubmitting(true);
const companyCode = (user as any)?.company_code || (user as any)?.companyCode || "*"; const companyCode = (user as any)?.company_code || (user as any)?.companyCode || "*";
// DB 소스 정보 추가 // 데이터 소스 타입에 따라 다른 정보 전달
const created = await screenApi.createScreen({ const createData: any = {
screenName: screenName.trim(), screenName: screenName.trim(),
screenCode: screenCode.trim(), screenCode: screenCode.trim(),
tableName: tableName.trim(),
companyCode, companyCode,
description: description.trim() || undefined, description: description.trim() || undefined,
createdBy: (user as any)?.userId, createdBy: (user as any)?.userId,
dbSourceType: selectedDbSource === "internal" ? "internal" : "external", dataSourceType: dataSourceType,
dbConnectionId: selectedDbSource === "internal" ? undefined : Number(selectedDbSource), };
} as any);
if (dataSourceType === "database") {
// 데이터베이스 소스
createData.tableName = tableName.trim();
createData.dbSourceType = selectedDbSource === "internal" ? "internal" : "external";
createData.dbConnectionId = selectedDbSource === "internal" ? undefined : Number(selectedDbSource);
} else {
// REST API 소스
createData.tableName = `_restapi_${selectedRestApiId}`; // REST API용 가상 테이블명
createData.restApiConnectionId = selectedRestApiId;
createData.restApiEndpoint = restApiEndpoint.trim() || undefined;
createData.restApiJsonPath = restApiJsonPath.trim() || "data";
}
const created = await screenApi.createScreen(createData);
// 날짜 필드 보정 // 날짜 필드 보정
const mapped: ScreenDefinition = { const mapped: ScreenDefinition = {
@ -207,11 +253,16 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
onCreated?.(mapped); onCreated?.(mapped);
onOpenChange(false); onOpenChange(false);
// 폼 초기화
setScreenName(""); setScreenName("");
setScreenCode(""); setScreenCode("");
setTableName(""); setTableName("");
setDescription(""); setDescription("");
setSelectedDbSource("internal"); setSelectedDbSource("internal");
setDataSourceType("database");
setSelectedRestApiId(null);
setRestApiEndpoint("");
setRestApiJsonPath("data");
} catch (e) { } catch (e) {
// 필요 시 토스트 추가 가능 // 필요 시 토스트 추가 가능
} finally { } finally {
@ -263,83 +314,210 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
/> />
</div> </div>
{/* DB 소스 선택 */} {/* 데이터 소스 타입 선택 */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="dbSource"> </Label> <Label> </Label>
<Popover open={openDbSourceCombobox} onOpenChange={setOpenDbSourceCombobox}> <div className="flex gap-2">
<PopoverTrigger asChild> <Button
<Button type="button"
variant="outline" variant={dataSourceType === "database" ? "default" : "outline"}
role="combobox" className="flex-1"
aria-expanded={openDbSourceCombobox} onClick={() => {
className="w-full justify-between" setDataSourceType("database");
> setSelectedRestApiId(null);
<div className="flex items-center gap-2"> }}
<Database className="h-4 w-4" /> >
{selectedDbSource === "internal" <Database className="mr-2 h-4 w-4" />
? "내부 데이터베이스"
: externalConnections.find((conn) => conn.id === selectedDbSource)?.connection_name || </Button>
"선택하세요"} <Button
</div> type="button"
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> variant={dataSourceType === "restapi" ? "default" : "outline"}
</Button> className="flex-1"
</PopoverTrigger> onClick={() => {
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start"> setDataSourceType("restapi");
<Command> setTableName("");
<CommandInput placeholder="데이터베이스 검색..." /> setSelectedDbSource("internal");
<CommandList> }}
<CommandEmpty> .</CommandEmpty> >
<CommandGroup> <Globe className="mr-2 h-4 w-4" />
<CommandItem REST API
value="internal" </Button>
onSelect={() => { </div>
setSelectedDbSource("internal");
setTableName("");
setTableSearchTerm("");
setOpenDbSourceCombobox(false);
}}
>
<Check
className={cn("mr-2 h-4 w-4", selectedDbSource === "internal" ? "opacity-100" : "opacity-0")}
/>
<Database className="mr-2 h-4 w-4 text-blue-500" />
<div className="flex flex-col">
<span className="font-medium"> </span>
<span className="text-xs text-gray-500">PostgreSQL ( )</span>
</div>
</CommandItem>
{externalConnections.map((conn: any) => (
<CommandItem
key={conn.id}
value={`${conn.connection_name} ${conn.db_type}`}
onSelect={() => {
setSelectedDbSource(conn.id);
setTableName("");
setTableSearchTerm("");
setOpenDbSourceCombobox(false);
}}
>
<Check
className={cn("mr-2 h-4 w-4", selectedDbSource === conn.id ? "opacity-100" : "opacity-0")}
/>
<Database className="mr-2 h-4 w-4 text-green-500" />
<div className="flex flex-col">
<span className="font-medium">{conn.connection_name}</span>
<span className="text-xs text-gray-500">{conn.db_type?.toUpperCase()}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-xs text-gray-500"> </p>
</div> </div>
{/* 테이블 선택 */} {/* 데이터베이스 소스 설정 */}
{dataSourceType === "database" && (
<>
{/* DB 소스 선택 */}
<div className="space-y-2">
<Label htmlFor="dbSource"> </Label>
<Popover open={openDbSourceCombobox} onOpenChange={setOpenDbSourceCombobox}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openDbSourceCombobox}
className="w-full justify-between"
>
<div className="flex items-center gap-2">
<Database className="h-4 w-4" />
{selectedDbSource === "internal"
? "내부 데이터베이스"
: externalConnections.find((conn) => conn.id === selectedDbSource)?.connection_name ||
"선택하세요"}
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="데이터베이스 검색..." />
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
<CommandItem
value="internal"
onSelect={() => {
setSelectedDbSource("internal");
setTableName("");
setTableSearchTerm("");
setOpenDbSourceCombobox(false);
}}
>
<Check
className={cn("mr-2 h-4 w-4", selectedDbSource === "internal" ? "opacity-100" : "opacity-0")}
/>
<Database className="mr-2 h-4 w-4 text-blue-500" />
<div className="flex flex-col">
<span className="font-medium"> </span>
<span className="text-xs text-gray-500">PostgreSQL ( )</span>
</div>
</CommandItem>
{externalConnections.map((conn: any) => (
<CommandItem
key={conn.id}
value={`${conn.connection_name} ${conn.db_type}`}
onSelect={() => {
setSelectedDbSource(conn.id);
setTableName("");
setTableSearchTerm("");
setOpenDbSourceCombobox(false);
}}
>
<Check
className={cn("mr-2 h-4 w-4", selectedDbSource === conn.id ? "opacity-100" : "opacity-0")}
/>
<Database className="mr-2 h-4 w-4 text-green-500" />
<div className="flex flex-col">
<span className="font-medium">{conn.connection_name}</span>
<span className="text-xs text-gray-500">{conn.db_type?.toUpperCase()}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-xs text-gray-500"> </p>
</div>
</>
)}
{/* REST API 소스 설정 */}
{dataSourceType === "restapi" && (
<>
{/* REST API 연결 선택 */}
<div className="space-y-2">
<Label htmlFor="restApiConnection">REST API *</Label>
<Popover open={openRestApiCombobox} onOpenChange={setOpenRestApiCombobox}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openRestApiCombobox}
className="w-full justify-between"
>
<div className="flex items-center gap-2">
<Globe className="h-4 w-4" />
{selectedRestApiId
? restApiConnections.find((conn) => conn.id === selectedRestApiId)?.connection_name ||
"선택하세요"
: "REST API 연결을 선택하세요"}
</div>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="REST API 검색..." />
<CommandList>
<CommandEmpty> REST API .</CommandEmpty>
<CommandGroup>
{restApiConnections.map((conn) => (
<CommandItem
key={conn.id}
value={`${conn.connection_name} ${conn.base_url}`}
onSelect={() => {
setSelectedRestApiId(conn.id!);
setRestApiEndpoint(conn.endpoint_path || "");
setOpenRestApiCombobox(false);
}}
>
<Check
className={cn("mr-2 h-4 w-4", selectedRestApiId === conn.id ? "opacity-100" : "opacity-0")}
/>
<Globe className="mr-2 h-4 w-4 text-purple-500" />
<div className="flex flex-col">
<span className="font-medium">{conn.connection_name}</span>
<span className="text-xs text-gray-500 truncate max-w-[300px]">{conn.base_url}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-xs text-gray-500">
REST API .
<Link href="/admin/externalRestApi" className="ml-1 text-primary hover:underline">
</Link>
</p>
</div>
{/* 엔드포인트 경로 */}
<div className="space-y-2">
<Label htmlFor="restApiEndpoint"> </Label>
<Input
id="restApiEndpoint"
value={restApiEndpoint}
onChange={(e) => setRestApiEndpoint(e.target.value)}
placeholder="/api/data 또는 /users"
/>
<p className="text-xs text-gray-500"> URL ()</p>
</div>
{/* JSON Path */}
<div className="space-y-2">
<Label htmlFor="restApiJsonPath"> (JSON Path)</Label>
<Input
id="restApiJsonPath"
value={restApiJsonPath}
onChange={(e) => setRestApiJsonPath(e.target.value)}
placeholder="data 또는 result.items"
/>
<p className="text-xs text-gray-500">API (: data, result.items)</p>
</div>
</>
)}
{/* 테이블 선택 (데이터베이스 모드일 때만) */}
{dataSourceType === "database" && (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="tableName"></Label> <Label htmlFor="tableName"> *</Label>
<Select <Select
value={tableName} value={tableName}
onValueChange={setTableName} onValueChange={setTableName}
@ -422,11 +600,7 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
)}
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Input id="description" value={description} onChange={(e) => setDescription(e.target.value)} />
</div>
</div> </div>
<ResizableDialogFooter className="mt-4"> <ResizableDialogFooter className="mt-4">

View File

@ -53,6 +53,8 @@ interface InteractiveScreenViewerProps {
disabledFields?: string[]; disabledFields?: string[];
// 🆕 EditModal 내부인지 여부 (button-primary가 EditModal의 handleSave 사용하도록) // 🆕 EditModal 내부인지 여부 (button-primary가 EditModal의 handleSave 사용하도록)
isInModal?: boolean; isInModal?: boolean;
// 🆕 원본 데이터 (수정 모드에서 UPDATE 판단용)
originalData?: Record<string, any> | null;
} }
export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerProps> = ({ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerProps> = ({
@ -72,6 +74,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
groupedData, groupedData,
disabledFields = [], disabledFields = [],
isInModal = false, isInModal = false,
originalData, // 🆕 원본 데이터 (수정 모드에서 UPDATE 판단용)
}) => { }) => {
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인
const { userName: authUserName, user: authUser } = useAuth(); const { userName: authUserName, user: authUser } = useAuth();
@ -331,6 +334,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
component={comp} component={comp}
isInteractive={true} isInteractive={true}
formData={formData} formData={formData}
originalData={originalData || undefined} // 🆕 원본 데이터 전달 (UPDATE 판단용)
onFormDataChange={handleFormDataChange} onFormDataChange={handleFormDataChange}
screenId={screenInfo?.id} screenId={screenInfo?.id}
tableName={screenInfo?.tableName} tableName={screenInfo?.tableName}
@ -408,6 +412,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
value: currentValue, value: currentValue,
onChange: (value: any) => handleFormDataChange(fieldName, value), onChange: (value: any) => handleFormDataChange(fieldName, value),
onFormDataChange: handleFormDataChange, onFormDataChange: handleFormDataChange,
formData: formData, // 🆕 전체 formData 전달
isInteractive: true, isInteractive: true,
readonly: readonly, readonly: readonly,
required: required, required: required,
@ -415,6 +420,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
className: "w-full h-full", className: "w-full h-full",
isInModal: isInModal, // 🆕 EditModal 내부 여부 전달 isInModal: isInModal, // 🆕 EditModal 내부 여부 전달
onSave: onSave, // 🆕 EditModal의 handleSave 콜백 전달 onSave: onSave, // 🆕 EditModal의 handleSave 콜백 전달
groupedData: groupedData, // 🆕 그룹 데이터 전달 (RepeatScreenModal용)
}} }}
config={widget.webTypeConfig} config={widget.webTypeConfig}
onEvent={(event: string, data: any) => { onEvent={(event: string, data: any) => {

View File

@ -66,6 +66,7 @@ const calculateGridInfo = (width: number, height: number, settings: any) => {
import { GroupingToolbar } from "./GroupingToolbar"; import { GroupingToolbar } from "./GroupingToolbar";
import { screenApi, tableTypeApi } from "@/lib/api/screen"; import { screenApi, tableTypeApi } from "@/lib/api/screen";
import { tableManagementApi } from "@/lib/api/tableManagement"; import { tableManagementApi } from "@/lib/api/tableManagement";
import { ExternalRestApiConnectionAPI } from "@/lib/api/externalRestApiConnection";
import { toast } from "sonner"; import { toast } from "sonner";
import { MenuAssignmentModal } from "./MenuAssignmentModal"; import { MenuAssignmentModal } from "./MenuAssignmentModal";
import { FileAttachmentDetailModal } from "./FileAttachmentDetailModal"; import { FileAttachmentDetailModal } from "./FileAttachmentDetailModal";
@ -527,9 +528,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 🆕 size 변경 시 style도 함께 업데이트 (파란 테두리와 실제 크기 동기화) // 🆕 size 변경 시 style도 함께 업데이트 (파란 테두리와 실제 크기 동기화)
if (path === "size.width" || path === "size.height" || path === "size") { if (path === "size.width" || path === "size.height" || path === "size") {
if (!newComp.style) { // 🔧 style 객체를 새로 복사하여 불변성 유지
newComp.style = {}; newComp.style = { ...(newComp.style || {}) };
}
if (path === "size.width") { if (path === "size.width") {
newComp.style.width = `${value}px`; newComp.style.width = `${value}px`;
@ -835,9 +835,52 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
} }
}, []); }, []);
// 화면의 기본 테이블 정보 로드 (원래대로 복원) // 화면의 기본 테이블/REST API 정보 로드
useEffect(() => { useEffect(() => {
const loadScreenTable = async () => { const loadScreenDataSource = async () => {
// REST API 데이터 소스인 경우
if (selectedScreen?.dataSourceType === "restapi" && selectedScreen?.restApiConnectionId) {
try {
const restApiData = await ExternalRestApiConnectionAPI.fetchData(
selectedScreen.restApiConnectionId,
selectedScreen.restApiEndpoint,
selectedScreen.restApiJsonPath || "data",
);
// REST API 응답에서 컬럼 정보 생성
const columns: ColumnInfo[] = restApiData.columns.map((col) => ({
tableName: `restapi_${selectedScreen.restApiConnectionId}`,
columnName: col.columnName,
columnLabel: col.columnLabel,
dataType: col.dataType === "string" ? "varchar" : col.dataType === "number" ? "numeric" : col.dataType,
webType: col.dataType === "number" ? "number" : "text",
input_type: "text",
widgetType: col.dataType === "number" ? "number" : "text",
isNullable: "YES",
required: false,
}));
const tableInfo: TableInfo = {
tableName: `restapi_${selectedScreen.restApiConnectionId}`,
tableLabel: restApiData.connectionInfo.connectionName || "REST API 데이터",
columns,
};
setTables([tableInfo]);
console.log("REST API 데이터 소스 로드 완료:", {
connectionName: restApiData.connectionInfo.connectionName,
columnsCount: columns.length,
rowsCount: restApiData.total,
});
} catch (error) {
console.error("REST API 데이터 소스 로드 실패:", error);
toast.error("REST API 데이터를 불러오는데 실패했습니다.");
setTables([]);
}
return;
}
// 데이터베이스 데이터 소스인 경우 (기존 로직)
const tableName = selectedScreen?.tableName; const tableName = selectedScreen?.tableName;
if (!tableName) { if (!tableName) {
setTables([]); setTables([]);
@ -859,16 +902,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => { const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => {
const widgetType = col.widgetType || col.widget_type || col.webType || col.web_type; const widgetType = col.widgetType || col.widget_type || col.webType || col.web_type;
// 🔍 이미지 타입 디버깅
// if (widgetType === "image" || col.webType === "image" || col.web_type === "image") {
// console.log("🖼️ 이미지 컬럼 발견:", {
// columnName: col.columnName || col.column_name,
// widgetType,
// webType: col.webType || col.web_type,
// rawData: col,
// });
// }
return { return {
tableName: col.tableName || tableName, tableName: col.tableName || tableName,
columnName: col.columnName || col.column_name, columnName: col.columnName || col.column_name,
@ -899,8 +932,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
} }
}; };
loadScreenTable(); loadScreenDataSource();
}, [selectedScreen?.tableName, selectedScreen?.screenName]); }, [selectedScreen?.tableName, selectedScreen?.screenName, selectedScreen?.dataSourceType, selectedScreen?.restApiConnectionId, selectedScreen?.restApiEndpoint, selectedScreen?.restApiJsonPath]);
// 화면 레이아웃 로드 // 화면 레이아웃 로드
useEffect(() => { useEffect(() => {
@ -962,6 +995,17 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// console.log("🔧 기본 해상도 적용:", defaultResolution); // console.log("🔧 기본 해상도 적용:", defaultResolution);
} }
// 🔍 디버깅: 로드된 버튼 컴포넌트의 action 확인
const buttonComponents = layoutWithDefaultGrid.components.filter(
(c: any) => c.componentType?.startsWith("button")
);
console.log("🔍 [로드] 버튼 컴포넌트 action 확인:", buttonComponents.map((c: any) => ({
id: c.id,
type: c.componentType,
actionType: c.componentConfig?.action?.type,
fullAction: c.componentConfig?.action,
})));
setLayout(layoutWithDefaultGrid); setLayout(layoutWithDefaultGrid);
setHistory([layoutWithDefaultGrid]); setHistory([layoutWithDefaultGrid]);
setHistoryIndex(0); setHistoryIndex(0);
@ -1419,7 +1463,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
}; };
// 🔍 버튼 컴포넌트들의 action.type 확인 // 🔍 버튼 컴포넌트들의 action.type 확인
const buttonComponents = layoutWithResolution.components.filter( const buttonComponents = layoutWithResolution.components.filter(
(c: any) => c.type === "button" || c.type === "button-primary" || c.type === "button-secondary", (c: any) => c.componentType?.startsWith("button") || c.type === "button" || c.type === "button-primary",
); );
console.log("💾 저장 시작:", { console.log("💾 저장 시작:", {
screenId: selectedScreen.screenId, screenId: selectedScreen.screenId,
@ -1429,6 +1473,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
buttonComponents: buttonComponents.map((c: any) => ({ buttonComponents: buttonComponents.map((c: any) => ({
id: c.id, id: c.id,
type: c.type, type: c.type,
componentType: c.componentType,
text: c.componentConfig?.text, text: c.componentConfig?.text,
actionType: c.componentConfig?.action?.type, actionType: c.componentConfig?.action?.type,
fullAction: c.componentConfig?.action, fullAction: c.componentConfig?.action,

View File

@ -83,6 +83,14 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
const [blockTablePopoverOpen, setBlockTablePopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 테이블 Popover 열림 상태 const [blockTablePopoverOpen, setBlockTablePopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 테이블 Popover 열림 상태
const [blockColumnPopoverOpen, setBlockColumnPopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 컬럼 Popover 열림 상태 const [blockColumnPopoverOpen, setBlockColumnPopoverOpen] = useState<Record<string, boolean>>({}); // 블록별 컬럼 Popover 열림 상태
// 🆕 데이터 전달 필드 매핑용 상태
const [mappingSourceColumns, setMappingSourceColumns] = useState<Array<{ name: string; label: string }>>([]);
const [mappingTargetColumns, setMappingTargetColumns] = useState<Array<{ name: string; label: string }>>([]);
const [mappingSourcePopoverOpen, setMappingSourcePopoverOpen] = useState<Record<number, boolean>>({});
const [mappingTargetPopoverOpen, setMappingTargetPopoverOpen] = useState<Record<number, boolean>>({});
const [mappingSourceSearch, setMappingSourceSearch] = useState<Record<number, string>>({});
const [mappingTargetSearch, setMappingTargetSearch] = useState<Record<number, string>>({});
// 🎯 플로우 위젯이 화면에 있는지 확인 // 🎯 플로우 위젯이 화면에 있는지 확인
const hasFlowWidget = useMemo(() => { const hasFlowWidget = useMemo(() => {
const found = allComponents.some((comp: any) => { const found = allComponents.some((comp: any) => {
@ -258,6 +266,58 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
} }
}; };
// 🆕 데이터 전달 소스/타겟 테이블 컬럼 로드
useEffect(() => {
const sourceTable = config.action?.dataTransfer?.sourceTable;
const targetTable = config.action?.dataTransfer?.targetTable;
const loadColumns = async () => {
if (sourceTable) {
try {
const response = await apiClient.get(`/table-management/tables/${sourceTable}/columns`);
if (response.data.success) {
let columnData = response.data.data;
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
if (Array.isArray(columnData)) {
const columns = columnData.map((col: any) => ({
name: col.name || col.columnName,
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
}));
setMappingSourceColumns(columns);
}
}
} catch (error) {
console.error("소스 테이블 컬럼 로드 실패:", error);
}
}
if (targetTable) {
try {
const response = await apiClient.get(`/table-management/tables/${targetTable}/columns`);
if (response.data.success) {
let columnData = response.data.data;
if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns;
if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data;
if (Array.isArray(columnData)) {
const columns = columnData.map((col: any) => ({
name: col.name || col.columnName,
label: col.displayName || col.label || col.columnLabel || col.name || col.columnName,
}));
setMappingTargetColumns(columns);
}
}
} catch (error) {
console.error("타겟 테이블 컬럼 로드 실패:", error);
}
}
};
loadColumns();
}, [config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable]);
// 화면 목록 가져오기 (현재 편집 중인 화면의 회사 코드 기준) // 화면 목록 가져오기 (현재 편집 중인 화면의 회사 코드 기준)
useEffect(() => { useEffect(() => {
const fetchScreens = async () => { const fetchScreens = async () => {
@ -434,6 +494,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<SelectItem value="edit"></SelectItem> <SelectItem value="edit"></SelectItem>
<SelectItem value="copy"> ( )</SelectItem> <SelectItem value="copy"> ( )</SelectItem>
<SelectItem value="navigate"> </SelectItem> <SelectItem value="navigate"> </SelectItem>
<SelectItem value="transferData">📦 </SelectItem>
<SelectItem value="openModalWithData"> + 🆕</SelectItem> <SelectItem value="openModalWithData"> + 🆕</SelectItem>
<SelectItem value="modal"> </SelectItem> <SelectItem value="modal"> </SelectItem>
<SelectItem value="control"> </SelectItem> <SelectItem value="control"> </SelectItem>
@ -442,6 +503,8 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
<SelectItem value="excel_upload"> </SelectItem> <SelectItem value="excel_upload"> </SelectItem>
<SelectItem value="barcode_scan"> </SelectItem> <SelectItem value="barcode_scan"> </SelectItem>
<SelectItem value="code_merge"> </SelectItem> <SelectItem value="code_merge"> </SelectItem>
<SelectItem value="geolocation"> </SelectItem>
<SelectItem value="update_field"> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@ -1601,6 +1664,875 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
</div> </div>
)} )}
{/* 위치정보 가져오기 설정 */}
{(component.componentConfig?.action?.type || "save") === "geolocation" && (
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
<h4 className="text-sm font-medium text-foreground">📍 </h4>
{/* 테이블 선택 */}
<div>
<Label htmlFor="geolocation-table">
<span className="text-destructive">*</span>
</Label>
<Select
value={config.action?.geolocationTableName || currentTableName || ""}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.geolocationTableName", value);
onUpdateProperty("componentConfig.action.geolocationLatField", "");
onUpdateProperty("componentConfig.action.geolocationLngField", "");
onUpdateProperty("componentConfig.action.geolocationAccuracyField", "");
onUpdateProperty("componentConfig.action.geolocationTimestampField", "");
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table.name} value={table.name} className="text-xs">
{table.label || table.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-muted-foreground">
(기본: 현재 )
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="geolocation-lat-field">
<span className="text-destructive">*</span>
</Label>
<Input
id="geolocation-lat-field"
placeholder="예: latitude"
value={config.action?.geolocationLatField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationLatField", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label htmlFor="geolocation-lng-field">
<span className="text-destructive">*</span>
</Label>
<Input
id="geolocation-lng-field"
placeholder="예: longitude"
value={config.action?.geolocationLngField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationLngField", e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="geolocation-accuracy-field"> ()</Label>
<Input
id="geolocation-accuracy-field"
placeholder="예: accuracy"
value={config.action?.geolocationAccuracyField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationAccuracyField", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label htmlFor="geolocation-timestamp-field"> ()</Label>
<Input
id="geolocation-timestamp-field"
placeholder="예: location_time"
value={config.action?.geolocationTimestampField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.geolocationTimestampField", e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="geolocation-high-accuracy"> </Label>
<p className="text-xs text-muted-foreground">GPS를 ( )</p>
</div>
<Switch
id="geolocation-high-accuracy"
checked={config.action?.geolocationHighAccuracy !== false}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.geolocationHighAccuracy", checked)}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="geolocation-auto-save"> </Label>
<p className="text-xs text-muted-foreground"> </p>
</div>
<Switch
id="geolocation-auto-save"
checked={config.action?.geolocationAutoSave === true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.geolocationAutoSave", checked)}
/>
</div>
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
<p className="text-xs text-blue-900 dark:text-blue-100">
<strong> :</strong>
<br />
1.
<br />
2. GPS
<br />
3. /
<br />
<br />
<strong>:</strong> HTTPS .
</p>
</div>
</div>
)}
{/* 필드 값 변경 설정 */}
{(component.componentConfig?.action?.type || "save") === "update_field" && (
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
<h4 className="text-sm font-medium text-foreground">📝 </h4>
<div>
<Label htmlFor="update-table">
<span className="text-destructive">*</span>
</Label>
<Select
value={config.action?.updateTableName || currentTableName || ""}
onValueChange={(value) => {
onUpdateProperty("componentConfig.action.updateTableName", value);
onUpdateProperty("componentConfig.action.updateTargetField", "");
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{availableTables.map((table) => (
<SelectItem key={table.name} value={table.name} className="text-xs">
{table.label || table.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-muted-foreground">
(기본: 현재 )
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="update-target-field">
<span className="text-destructive">*</span>
</Label>
<Input
id="update-target-field"
placeholder="예: status"
value={config.action?.updateTargetField || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.updateTargetField", e.target.value)}
className="h-8 text-xs"
/>
<p className="mt-1 text-xs text-muted-foreground"> DB </p>
</div>
<div>
<Label htmlFor="update-target-value">
<span className="text-destructive">*</span>
</Label>
<Input
id="update-target-value"
placeholder="예: active"
value={config.action?.updateTargetValue || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.updateTargetValue", e.target.value)}
className="h-8 text-xs"
/>
<p className="mt-1 text-xs text-muted-foreground"> (, )</p>
</div>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="update-auto-save"> </Label>
<p className="text-xs text-muted-foreground"> DB에 </p>
</div>
<Switch
id="update-auto-save"
checked={config.action?.updateAutoSave !== false}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.updateAutoSave", checked)}
/>
</div>
<div>
<Label htmlFor="update-confirm-message"> ()</Label>
<Input
id="update-confirm-message"
placeholder="예: 운행을 시작하시겠습니까?"
value={config.action?.confirmMessage || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.confirmMessage", e.target.value)}
className="h-8 text-xs"
/>
<p className="mt-1 text-xs text-muted-foreground"> </p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="update-success-message"> ()</Label>
<Input
id="update-success-message"
placeholder="예: 운행이 시작되었습니다."
value={config.action?.successMessage || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.successMessage", e.target.value)}
className="h-8 text-xs"
/>
</div>
<div>
<Label htmlFor="update-error-message"> ()</Label>
<Input
id="update-error-message"
placeholder="예: 운행 시작에 실패했습니다."
value={config.action?.errorMessage || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.errorMessage", e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
<p className="text-xs text-blue-900 dark:text-blue-100">
<strong> :</strong>
<br />
- 버튼: status &quot;active&quot;
<br />
- 버튼: approval_status &quot;approved&quot;
<br />
- 버튼: is_completed &quot;Y&quot;
</p>
</div>
</div>
)}
{/* 데이터 전달 액션 설정 */}
{(component.componentConfig?.action?.type || "save") === "transferData" && (
<div className="mt-4 space-y-4 rounded-lg border bg-muted/50 p-4">
<h4 className="text-sm font-medium text-foreground">📦 </h4>
{/* 소스 컴포넌트 선택 (Combobox) */}
<div>
<Label>
<span className="text-destructive">*</span>
</Label>
<Select
value={config.action?.dataTransfer?.sourceComponentId || ""}
onValueChange={(value) => onUpdateProperty("componentConfig.action.dataTransfer.sourceComponentId", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="데이터를 가져올 컴포넌트 선택" />
</SelectTrigger>
<SelectContent>
{/* 데이터 제공 가능한 컴포넌트 필터링 */}
{allComponents
.filter((comp: any) => {
const type = comp.componentType || comp.type || "";
// 데이터를 제공할 수 있는 컴포넌트 타입들
return ["table-list", "repeater-field-group", "form-group", "data-table"].some(
(t) => type.includes(t)
);
})
.map((comp: any) => {
const compType = comp.componentType || comp.type || "unknown";
const compLabel = comp.label || comp.componentConfig?.title || comp.id;
return (
<SelectItem key={comp.id} value={comp.id}>
<div className="flex items-center gap-2">
<span className="text-xs font-medium">{compLabel}</span>
<span className="text-[10px] text-muted-foreground">({compType})</span>
</div>
</SelectItem>
);
})}
{allComponents.filter((comp: any) => {
const type = comp.componentType || comp.type || "";
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) => type.includes(t));
}).length === 0 && (
<SelectItem value="__none__" disabled>
</SelectItem>
)}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-muted-foreground">
,
</p>
</div>
<div>
<Label htmlFor="target-type">
<span className="text-destructive">*</span>
</Label>
<Select
value={config.action?.dataTransfer?.targetType || "component"}
onValueChange={(value) => onUpdateProperty("componentConfig.action.dataTransfer.targetType", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="component"> </SelectItem>
<SelectItem value="splitPanel"> </SelectItem>
<SelectItem value="screen" disabled> ( )</SelectItem>
</SelectContent>
</Select>
{config.action?.dataTransfer?.targetType === "splitPanel" && (
<p className="text-[10px] text-muted-foreground mt-1">
. , .
</p>
)}
</div>
{/* 타겟 컴포넌트 선택 (같은 화면의 컴포넌트일 때만) */}
{config.action?.dataTransfer?.targetType === "component" && (
<div>
<Label>
<span className="text-destructive">*</span>
</Label>
<Select
value={config.action?.dataTransfer?.targetComponentId || ""}
onValueChange={(value) => onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="데이터를 받을 컴포넌트 선택" />
</SelectTrigger>
<SelectContent>
{/* 데이터 수신 가능한 컴포넌트 필터링 (소스와 다른 컴포넌트만) */}
{allComponents
.filter((comp: any) => {
const type = comp.componentType || comp.type || "";
// 데이터를 받을 수 있는 컴포넌트 타입들
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some(
(t) => type.includes(t)
);
// 소스와 다른 컴포넌트만
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
})
.map((comp: any) => {
const compType = comp.componentType || comp.type || "unknown";
const compLabel = comp.label || comp.componentConfig?.title || comp.id;
return (
<SelectItem key={comp.id} value={comp.id}>
<div className="flex items-center gap-2">
<span className="text-xs font-medium">{compLabel}</span>
<span className="text-[10px] text-muted-foreground">({compType})</span>
</div>
</SelectItem>
);
})}
{allComponents.filter((comp: any) => {
const type = comp.componentType || comp.type || "";
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) => type.includes(t));
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
}).length === 0 && (
<SelectItem value="__none__" disabled>
</SelectItem>
)}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-muted-foreground">
,
</p>
</div>
)}
{/* 분할 패널 반대편 타겟 설정 */}
{config.action?.dataTransfer?.targetType === "splitPanel" && (
<div>
<Label>
ID ()
</Label>
<Input
value={config.action?.dataTransfer?.targetComponentId || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", e.target.value)}
placeholder="비워두면 첫 번째 수신 가능 컴포넌트로 전달"
className="h-8 text-xs"
/>
<p className="mt-1 text-xs text-muted-foreground">
ID를 , .
</p>
</div>
)}
<div>
<Label htmlFor="transfer-mode"> </Label>
<Select
value={config.action?.dataTransfer?.mode || "append"}
onValueChange={(value) => onUpdateProperty("componentConfig.action.dataTransfer.mode", value)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="append"> (Append)</SelectItem>
<SelectItem value="replace"> (Replace)</SelectItem>
<SelectItem value="merge"> (Merge)</SelectItem>
</SelectContent>
</Select>
<p className="mt-1 text-xs text-muted-foreground">
</p>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="clear-after-transfer"> </Label>
<p className="text-xs text-muted-foreground"> </p>
</div>
<Switch
id="clear-after-transfer"
checked={config.action?.dataTransfer?.clearAfterTransfer === true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.dataTransfer.clearAfterTransfer", checked)}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="confirm-before-transfer"> </Label>
<p className="text-xs text-muted-foreground"> </p>
</div>
<Switch
id="confirm-before-transfer"
checked={config.action?.dataTransfer?.confirmBeforeTransfer === true}
onCheckedChange={(checked) => onUpdateProperty("componentConfig.action.dataTransfer.confirmBeforeTransfer", checked)}
/>
</div>
{config.action?.dataTransfer?.confirmBeforeTransfer && (
<div>
<Label htmlFor="confirm-message"> </Label>
<Input
id="confirm-message"
placeholder="선택한 항목을 전달하시겠습니까?"
value={config.action?.dataTransfer?.confirmMessage || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.dataTransfer.confirmMessage", e.target.value)}
className="h-8 text-xs"
/>
</div>
)}
<div className="space-y-2">
<Label> </Label>
<div className="space-y-2 rounded-md border p-3">
<div className="flex items-center gap-2">
<Label htmlFor="min-selection" className="text-xs">
</Label>
<Input
id="min-selection"
type="number"
placeholder="0"
value={config.action?.dataTransfer?.validation?.minSelection || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.dataTransfer.validation.minSelection", parseInt(e.target.value) || 0)}
className="h-8 w-20 text-xs"
/>
</div>
<div className="flex items-center gap-2">
<Label htmlFor="max-selection" className="text-xs">
</Label>
<Input
id="max-selection"
type="number"
placeholder="제한없음"
value={config.action?.dataTransfer?.validation?.maxSelection || ""}
onChange={(e) => onUpdateProperty("componentConfig.action.dataTransfer.validation.maxSelection", parseInt(e.target.value) || undefined)}
className="h-8 w-20 text-xs"
/>
</div>
</div>
</div>
<div className="space-y-2">
<Label> ()</Label>
<p className="text-xs text-muted-foreground">
</p>
<div className="space-y-2 rounded-md border p-3">
<div>
<Label className="text-xs"> </Label>
<Select
value={config.action?.dataTransfer?.additionalSources?.[0]?.componentId || ""}
onValueChange={(value) => {
const currentSources = config.action?.dataTransfer?.additionalSources || [];
const newSources = [...currentSources];
if (newSources.length === 0) {
newSources.push({ componentId: value, fieldName: "" });
} else {
newSources[0] = { ...newSources[0], componentId: value };
}
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="추가 데이터 컴포넌트 선택 (선택사항)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__clear__">
<span className="text-muted-foreground"> </span>
</SelectItem>
{/* 추가 데이터 제공 가능한 컴포넌트 (조건부 컨테이너, 셀렉트박스 등) */}
{allComponents
.filter((comp: any) => {
const type = comp.componentType || comp.type || "";
// 소스/타겟과 다른 컴포넌트 중 값을 제공할 수 있는 타입
return ["conditional-container", "select-basic", "select", "combobox"].some(
(t) => type.includes(t)
);
})
.map((comp: any) => {
const compType = comp.componentType || comp.type || "unknown";
const compLabel = comp.label || comp.componentConfig?.controlLabel || comp.id;
return (
<SelectItem key={comp.id} value={comp.id}>
<div className="flex items-center gap-2">
<span className="text-xs font-medium">{compLabel}</span>
<span className="text-[10px] text-muted-foreground">({compType})</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-muted-foreground">
, ( )
</p>
</div>
<div>
<Label htmlFor="additional-field-name" className="text-xs">
()
</Label>
<Input
id="additional-field-name"
placeholder="예: inbound_type (비워두면 전체 데이터)"
value={config.action?.dataTransfer?.additionalSources?.[0]?.fieldName || ""}
onChange={(e) => {
const currentSources = config.action?.dataTransfer?.additionalSources || [];
const newSources = [...currentSources];
if (newSources.length === 0) {
newSources.push({ componentId: "", fieldName: e.target.value });
} else {
newSources[0] = { ...newSources[0], fieldName: e.target.value };
}
onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources);
}}
className="h-8 text-xs"
/>
<p className="mt-1 text-xs text-muted-foreground">
</p>
</div>
</div>
</div>
{/* 필드 매핑 규칙 */}
<div className="space-y-3">
<Label> </Label>
{/* 소스/타겟 테이블 선택 */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-8 w-full justify-between text-xs"
>
{config.action?.dataTransfer?.sourceTable
? availableTables.find((t) => t.name === config.action?.dataTransfer?.sourceTable)?.label ||
config.action?.dataTransfer?.sourceTable
: "테이블 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[250px] p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup>
{availableTables.map((table) => (
<CommandItem
key={table.name}
value={`${table.label} ${table.name}`}
onSelect={() => {
onUpdateProperty("componentConfig.action.dataTransfer.sourceTable", table.name);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.action?.dataTransfer?.sourceTable === table.name ? "opacity-100" : "opacity-0"
)}
/>
<span className="font-medium">{table.label}</span>
<span className="ml-1 text-muted-foreground">({table.name})</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-8 w-full justify-between text-xs"
>
{config.action?.dataTransfer?.targetTable
? availableTables.find((t) => t.name === config.action?.dataTransfer?.targetTable)?.label ||
config.action?.dataTransfer?.targetTable
: "테이블 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[250px] p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup>
{availableTables.map((table) => (
<CommandItem
key={table.name}
value={`${table.label} ${table.name}`}
onSelect={() => {
onUpdateProperty("componentConfig.action.dataTransfer.targetTable", table.name);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.action?.dataTransfer?.targetTable === table.name ? "opacity-100" : "opacity-0"
)}
/>
<span className="font-medium">{table.label}</span>
<span className="ml-1 text-muted-foreground">({table.name})</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
{/* 필드 매핑 규칙 */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs"> </Label>
<Button
type="button"
variant="outline"
size="sm"
className="h-6 text-[10px]"
onClick={() => {
const currentRules = config.action?.dataTransfer?.mappingRules || [];
const newRule = { sourceField: "", targetField: "", transform: "" };
onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", [...currentRules, newRule]);
}}
disabled={!config.action?.dataTransfer?.sourceTable || !config.action?.dataTransfer?.targetTable}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<p className="text-[10px] text-muted-foreground">
. .
</p>
{(!config.action?.dataTransfer?.sourceTable || !config.action?.dataTransfer?.targetTable) ? (
<div className="rounded-md border border-dashed p-3 text-center">
<p className="text-xs text-muted-foreground">
.
</p>
</div>
) : (config.action?.dataTransfer?.mappingRules || []).length === 0 ? (
<div className="rounded-md border border-dashed p-3 text-center">
<p className="text-xs text-muted-foreground">
. .
</p>
</div>
) : (
<div className="space-y-2">
{(config.action?.dataTransfer?.mappingRules || []).map((rule: any, index: number) => (
<div key={index} className="flex items-center gap-2 rounded-md border bg-background p-2">
{/* 소스 필드 선택 (Combobox) */}
<div className="flex-1">
<Popover
open={mappingSourcePopoverOpen[index] || false}
onOpenChange={(open) => setMappingSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-7 w-full justify-between text-xs"
>
{rule.sourceField
? mappingSourceColumns.find((c) => c.name === rule.sourceField)?.label || rule.sourceField
: "소스 필드"}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput
placeholder="컬럼 검색..."
className="h-8 text-xs"
value={mappingSourceSearch[index] || ""}
onValueChange={(value) => setMappingSourceSearch((prev) => ({ ...prev, [index]: value }))}
/>
<CommandList>
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup>
{mappingSourceColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.label} ${col.name}`}
onSelect={() => {
const rules = [...(config.action?.dataTransfer?.mappingRules || [])];
rules[index] = { ...rules[index], sourceField: col.name };
onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules);
setMappingSourcePopoverOpen((prev) => ({ ...prev, [index]: false }));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
rule.sourceField === col.name ? "opacity-100" : "opacity-0"
)}
/>
<span>{col.label}</span>
{col.label !== col.name && (
<span className="ml-1 text-muted-foreground">({col.name})</span>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<span className="text-xs text-muted-foreground"></span>
{/* 타겟 필드 선택 (Combobox) */}
<div className="flex-1">
<Popover
open={mappingTargetPopoverOpen[index] || false}
onOpenChange={(open) => setMappingTargetPopoverOpen((prev) => ({ ...prev, [index]: open }))}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-7 w-full justify-between text-xs"
>
{rule.targetField
? mappingTargetColumns.find((c) => c.name === rule.targetField)?.label || rule.targetField
: "타겟 필드"}
<ChevronsUpDown className="ml-1 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput
placeholder="컬럼 검색..."
className="h-8 text-xs"
value={mappingTargetSearch[index] || ""}
onValueChange={(value) => setMappingTargetSearch((prev) => ({ ...prev, [index]: value }))}
/>
<CommandList>
<CommandEmpty className="py-2 text-center text-xs"> </CommandEmpty>
<CommandGroup>
{mappingTargetColumns.map((col) => (
<CommandItem
key={col.name}
value={`${col.label} ${col.name}`}
onSelect={() => {
const rules = [...(config.action?.dataTransfer?.mappingRules || [])];
rules[index] = { ...rules[index], targetField: col.name };
onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules);
setMappingTargetPopoverOpen((prev) => ({ ...prev, [index]: false }));
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
rule.targetField === col.name ? "opacity-100" : "opacity-0"
)}
/>
<span>{col.label}</span>
{col.label !== col.name && (
<span className="ml-1 text-muted-foreground">({col.name})</span>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:bg-destructive/10"
onClick={() => {
const rules = [...(config.action?.dataTransfer?.mappingRules || [])];
rules.splice(index, 1);
onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules);
}}
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
<div className="rounded-md bg-blue-50 p-3 dark:bg-blue-950">
<p className="text-xs text-blue-900 dark:text-blue-100">
<strong> :</strong>
<br />
1.
<br />
2. (: 품번 )
<br />
3.
</p>
</div>
</div>
)}
{/* 제어 기능 섹션 */} {/* 제어 기능 섹션 */}
<div className="mt-8 border-t border-border pt-6"> <div className="mt-8 border-t border-border pt-6">
<ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} /> <ImprovedButtonControlConfigPanel component={component} onUpdateProperty={onUpdateProperty} />

View File

@ -740,6 +740,12 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
const handleConfigChange = (newConfig: WebTypeConfig) => { const handleConfigChange = (newConfig: WebTypeConfig) => {
// 강제 새 객체 생성으로 React 변경 감지 보장 // 강제 새 객체 생성으로 React 변경 감지 보장
const freshConfig = { ...newConfig }; const freshConfig = { ...newConfig };
console.log("🔧 [DetailSettingsPanel] handleConfigChange 호출:", {
widgetId: widget.id,
widgetLabel: widget.label,
widgetType: widget.widgetType,
newConfig: freshConfig,
});
onUpdateProperty(widget.id, "webTypeConfig", freshConfig); onUpdateProperty(widget.id, "webTypeConfig", freshConfig);
// TextTypeConfig의 자동입력 설정을 autoGeneration으로도 매핑 // TextTypeConfig의 자동입력 설정을 autoGeneration으로도 매핑
@ -863,27 +869,23 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
}); });
// 래퍼 컴포넌트: 새 ConfigPanel 인터페이스를 기존 패턴에 맞춤 // 래퍼 컴포넌트: 새 ConfigPanel 인터페이스를 기존 패턴에 맞춤
const ConfigPanelWrapper = () => { // Section Card, Section Paper 등 신규 컴포넌트는 componentConfig 바로 아래에 설정 저장
// Section Card, Section Paper 등 신규 컴포넌트는 componentConfig 바로 아래에 설정 저장 const config = currentConfig || definition.defaultProps?.componentConfig || {};
const config = currentConfig || definition.defaultProps?.componentConfig || {};
const handleConfigChange = (newConfig: any) => {
const handleConfigChange = (newConfig: any) => { // componentConfig 전체를 업데이트
// componentConfig 전체를 업데이트 onUpdateProperty(selectedComponent.id, "componentConfig", newConfig);
onUpdateProperty(selectedComponent.id, "componentConfig", newConfig);
};
return (
<div className="space-y-4">
<div className="flex items-center gap-2 border-b pb-2">
<Settings className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold">{definition.name} </h3>
</div>
<ConfigPanelComponent config={config} onChange={handleConfigChange} />
</div>
);
}; };
return <ConfigPanelWrapper key={selectedComponent.id} />; return (
<div className="space-y-4" key={selectedComponent.id}>
<div className="flex items-center gap-2 border-b pb-2">
<Settings className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold">{definition.name} </h3>
</div>
<ConfigPanelComponent config={config} onChange={handleConfigChange} />
</div>
);
} else { } else {
console.warn("⚠️ ConfigPanel 없음:", { console.warn("⚠️ ConfigPanel 없음:", {
componentId, componentId,

View File

@ -114,7 +114,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
}) => { }) => {
const { webTypes } = useWebTypes({ active: "Y" }); const { webTypes } = useWebTypes({ active: "Y" });
const [localComponentDetailType, setLocalComponentDetailType] = useState<string>(""); const [localComponentDetailType, setLocalComponentDetailType] = useState<string>("");
// 높이/너비 입력 로컬 상태 (자유 입력 허용) // 높이/너비 입력 로컬 상태 (자유 입력 허용)
const [localHeight, setLocalHeight] = useState<string>(""); const [localHeight, setLocalHeight] = useState<string>("");
const [localWidth, setLocalWidth] = useState<string>(""); const [localWidth, setLocalWidth] = useState<string>("");
@ -147,7 +147,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
} }
} }
}, [selectedComponent?.type, selectedComponent?.componentConfig?.webType, selectedComponent?.id]); }, [selectedComponent?.type, selectedComponent?.componentConfig?.webType, selectedComponent?.id]);
// 높이 값 동기화 // 높이 값 동기화
useEffect(() => { useEffect(() => {
if (selectedComponent?.size?.height !== undefined) { if (selectedComponent?.size?.height !== undefined) {
@ -179,7 +179,10 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
// 최대 컬럼 수 계산 // 최대 컬럼 수 계산
const MIN_COLUMN_WIDTH = 30; const MIN_COLUMN_WIDTH = 30;
const maxColumns = currentResolution const maxColumns = currentResolution
? Math.floor((currentResolution.width - gridSettings.padding * 2 + gridSettings.gap) / (MIN_COLUMN_WIDTH + gridSettings.gap)) ? Math.floor(
(currentResolution.width - gridSettings.padding * 2 + gridSettings.gap) /
(MIN_COLUMN_WIDTH + gridSettings.gap),
)
: 24; : 24;
const safeMaxColumns = Math.max(1, Math.min(maxColumns, 100)); // 최대 100개로 제한 const safeMaxColumns = Math.max(1, Math.min(maxColumns, 100)); // 최대 100개로 제한
@ -189,7 +192,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
<Grid3X3 className="text-primary h-3 w-3" /> <Grid3X3 className="text-primary h-3 w-3" />
<h4 className="text-xs font-semibold"> </h4> <h4 className="text-xs font-semibold"> </h4>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{/* 토글들 */} {/* 토글들 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -226,9 +229,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
{/* 10px 단위 스냅 안내 */} {/* 10px 단위 스냅 안내 */}
<div className="bg-muted/50 rounded-md p-2"> <div className="bg-muted/50 rounded-md p-2">
<p className="text-[10px] text-muted-foreground"> <p className="text-muted-foreground text-[10px]"> 10px .</p>
10px .
</p>
</div> </div>
</div> </div>
</div> </div>
@ -238,9 +239,9 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
// 컴포넌트가 선택되지 않았을 때도 해상도 설정과 격자 설정은 표시 // 컴포넌트가 선택되지 않았을 때도 해상도 설정과 격자 설정은 표시
if (!selectedComponent) { if (!selectedComponent) {
return ( return (
<div className="flex h-full flex-col bg-white"> <div className="flex h-full flex-col overflow-x-auto bg-white">
{/* 해상도 설정과 격자 설정 표시 */} {/* 해상도 설정과 격자 설정 표시 */}
<div className="flex-1 overflow-y-auto p-2"> <div className="flex-1 overflow-x-auto overflow-y-auto p-2">
<div className="space-y-4 text-xs"> <div className="space-y-4 text-xs">
{/* 해상도 설정 */} {/* 해상도 설정 */}
{currentResolution && onResolutionChange && ( {currentResolution && onResolutionChange && (
@ -287,9 +288,9 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
if (!selectedComponent) return null; if (!selectedComponent) return null;
// 🎯 Section Card, Section Paper 등 신규 컴포넌트는 componentType에서 감지 // 🎯 Section Card, Section Paper 등 신규 컴포넌트는 componentType에서 감지
const componentType = const componentType =
selectedComponent.componentType || // ⭐ 1순위: ScreenDesigner가 설정한 componentType (section-card 등) selectedComponent.componentType || // ⭐ 1순위: ScreenDesigner가 설정한 componentType (section-card 등)
selectedComponent.componentConfig?.type || selectedComponent.componentConfig?.type ||
selectedComponent.componentConfig?.id || selectedComponent.componentConfig?.id ||
selectedComponent.type; selectedComponent.type;
@ -305,15 +306,15 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
}; };
// 🆕 ComponentRegistry에서 ConfigPanel 가져오기 시도 // 🆕 ComponentRegistry에서 ConfigPanel 가져오기 시도
const componentId = const componentId =
selectedComponent.componentType || // ⭐ section-card 등 selectedComponent.componentType || // ⭐ section-card 등
selectedComponent.componentConfig?.type || selectedComponent.componentConfig?.type ||
selectedComponent.componentConfig?.id || selectedComponent.componentConfig?.id ||
(selectedComponent.type === "component" ? selectedComponent.id : null); // 🆕 독립 컴포넌트 (table-search-widget 등) (selectedComponent.type === "component" ? selectedComponent.id : null); // 🆕 독립 컴포넌트 (table-search-widget 등)
if (componentId) { if (componentId) {
const definition = ComponentRegistry.getComponent(componentId); const definition = ComponentRegistry.getComponent(componentId);
if (definition?.configPanel) { if (definition?.configPanel) {
const ConfigPanelComponent = definition.configPanel; const ConfigPanelComponent = definition.configPanel;
const currentConfig = selectedComponent.componentConfig || {}; const currentConfig = selectedComponent.componentConfig || {};
@ -325,41 +326,49 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
currentConfig, currentConfig,
}); });
// 래퍼 컴포넌트: 새 ConfigPanel 인터페이스를 기존 패턴에 맞춤 // 🔧 ConfigPanelWrapper를 인라인 함수 대신 직접 JSX 반환 (리마운트 방지)
const ConfigPanelWrapper = () => { const config = currentConfig || definition.defaultProps?.componentConfig || {};
// Section Card, Section Paper 등 신규 컴포넌트는 componentConfig 바로 아래에 설정 저장
const config = currentConfig || definition.defaultProps?.componentConfig || {};
const handleConfigChange = (newConfig: any) => {
// componentConfig 전체를 업데이트
onUpdateProperty(selectedComponent.id, "componentConfig", newConfig);
};
return ( const handlePanelConfigChange = (newConfig: any) => {
<div className="space-y-4"> // 🔧 Partial 업데이트: 기존 componentConfig를 유지하면서 새 설정만 병합
<div className="flex items-center gap-2 border-b pb-2"> const mergedConfig = {
<Settings className="h-4 w-4 text-primary" /> ...currentConfig, // 기존 설정 유지
<h3 className="text-sm font-semibold">{definition.name} </h3> ...newConfig, // 새 설정 병합
</div> };
<Suspense fallback={ console.log("🔧 [ConfigPanel] handleConfigChange:", {
<div className="flex items-center justify-center py-8"> componentId: selectedComponent.id,
<div className="text-sm text-muted-foreground"> ...</div> currentConfig,
</div> newConfig,
}> mergedConfig,
<ConfigPanelComponent });
config={config} onUpdateProperty(selectedComponent.id, "componentConfig", mergedConfig);
onChange={handleConfigChange}
tables={tables} // 테이블 정보 전달
allTables={allTables} // 🆕 전체 테이블 목록 전달 (selected-items-detail-input 등에서 사용)
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} // 🔧 화면 테이블명 전달
tableColumns={currentTable?.columns || []} // 🔧 테이블 컬럼 정보 전달
/>
</Suspense>
</div>
);
}; };
return <ConfigPanelWrapper key={selectedComponent.id} />; return (
<div key={selectedComponent.id} className="space-y-4">
<div className="flex items-center gap-2 border-b pb-2">
<Settings className="text-primary h-4 w-4" />
<h3 className="text-sm font-semibold">{definition.name} </h3>
</div>
<Suspense
fallback={
<div className="flex items-center justify-center py-8">
<div className="text-muted-foreground text-sm"> ...</div>
</div>
}
>
<ConfigPanelComponent
config={config}
onChange={handlePanelConfigChange}
onConfigChange={handlePanelConfigChange} // 🔧 autocomplete-search-input 등 일부 컴포넌트용
tables={tables} // 테이블 정보 전달
allTables={allTables} // 🆕 전체 테이블 목록 전달 (selected-items-detail-input 등에서 사용)
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} // 🔧 화면 테이블명 전달
tableColumns={currentTable?.columns || []} // 🔧 테이블 컬럼 정보 전달
/>
</Suspense>
</div>
);
} else { } else {
console.warn("⚠️ ComponentRegistry에서 ConfigPanel을 찾을 수 없음 - switch case로 이동:", { console.warn("⚠️ ComponentRegistry에서 ConfigPanel을 찾을 수 없음 - switch case로 이동:", {
componentId, componentId,
@ -418,9 +427,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
<div className="space-y-4 p-4"> <div className="space-y-4 p-4">
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-sm font-semibold">Section Card </h3> <h3 className="text-sm font-semibold">Section Card </h3>
<p className="text-xs text-muted-foreground"> <p className="text-muted-foreground text-xs"> </p>
</p>
</div> </div>
{/* 헤더 표시 */} {/* 헤더 표시 */}
@ -432,7 +439,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
handleUpdateProperty(selectedComponent.id, "componentConfig.showHeader", checked); handleUpdateProperty(selectedComponent.id, "componentConfig.showHeader", checked);
}} }}
/> />
<Label htmlFor="showHeader" className="text-xs cursor-pointer"> <Label htmlFor="showHeader" className="cursor-pointer text-xs">
</Label> </Label>
</div> </div>
@ -462,7 +469,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
handleUpdateProperty(selectedComponent.id, "componentConfig.description", e.target.value); handleUpdateProperty(selectedComponent.id, "componentConfig.description", e.target.value);
}} }}
placeholder="섹션 설명 입력" placeholder="섹션 설명 입력"
className="text-xs resize-none" className="resize-none text-xs"
rows={2} rows={2}
/> />
</div> </div>
@ -530,7 +537,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
</div> </div>
{/* 접기/펼치기 기능 */} {/* 접기/펼치기 기능 */}
<div className="space-y-2 pt-2 border-t"> <div className="space-y-2 border-t pt-2">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox
id="collapsible" id="collapsible"
@ -539,13 +546,13 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
handleUpdateProperty(selectedComponent.id, "componentConfig.collapsible", checked); handleUpdateProperty(selectedComponent.id, "componentConfig.collapsible", checked);
}} }}
/> />
<Label htmlFor="collapsible" className="text-xs cursor-pointer"> <Label htmlFor="collapsible" className="cursor-pointer text-xs">
/ /
</Label> </Label>
</div> </div>
{selectedComponent.componentConfig?.collapsible && ( {selectedComponent.componentConfig?.collapsible && (
<div className="flex items-center space-x-2 ml-6"> <div className="ml-6 flex items-center space-x-2">
<Checkbox <Checkbox
id="defaultOpen" id="defaultOpen"
checked={selectedComponent.componentConfig?.defaultOpen !== false} checked={selectedComponent.componentConfig?.defaultOpen !== false}
@ -553,7 +560,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
handleUpdateProperty(selectedComponent.id, "componentConfig.defaultOpen", checked); handleUpdateProperty(selectedComponent.id, "componentConfig.defaultOpen", checked);
}} }}
/> />
<Label htmlFor="defaultOpen" className="text-xs cursor-pointer"> <Label htmlFor="defaultOpen" className="cursor-pointer text-xs">
</Label> </Label>
</div> </div>
@ -567,9 +574,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
<div className="space-y-4 p-4"> <div className="space-y-4 p-4">
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-sm font-semibold">Section Paper </h3> <h3 className="text-sm font-semibold">Section Paper </h3>
<p className="text-xs text-muted-foreground"> <p className="text-muted-foreground text-xs"> </p>
</p>
</div> </div>
{/* 배경색 */} {/* 배경색 */}
@ -680,7 +685,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
handleUpdateProperty(selectedComponent.id, "componentConfig.showBorder", checked); handleUpdateProperty(selectedComponent.id, "componentConfig.showBorder", checked);
}} }}
/> />
<Label htmlFor="showBorder" className="text-xs cursor-pointer"> <Label htmlFor="showBorder" className="cursor-pointer text-xs">
</Label> </Label>
</div> </div>
@ -691,9 +696,9 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
// ConfigPanel이 없는 경우 경고 표시 // ConfigPanel이 없는 경우 경고 표시
return ( return (
<div className="flex h-full flex-col items-center justify-center p-6 text-center"> <div className="flex h-full flex-col items-center justify-center p-6 text-center">
<Settings className="mb-4 h-12 w-12 text-muted-foreground" /> <Settings className="text-muted-foreground mb-4 h-12 w-12" />
<h3 className="mb-2 text-base font-medium"> </h3> <h3 className="mb-2 text-base font-medium"> </h3>
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground text-sm">
"{componentId || componentType}" . "{componentId || componentType}" .
</p> </p>
</div> </div>
@ -1418,7 +1423,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
</div> </div>
{/* 통합 컨텐츠 (탭 제거) */} {/* 통합 컨텐츠 (탭 제거) */}
<div className="flex-1 overflow-y-auto p-2"> <div className="flex-1 overflow-x-auto overflow-y-auto p-2">
<div className="space-y-4 text-xs"> <div className="space-y-4 text-xs">
{/* 해상도 설정 - 항상 맨 위에 표시 */} {/* 해상도 설정 - 항상 맨 위에 표시 */}
{currentResolution && onResolutionChange && ( {currentResolution && onResolutionChange && (

View File

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

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useRef } from "react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
@ -8,8 +8,9 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge";
import { Plus, X, GripVertical, ChevronDown, ChevronUp } from "lucide-react"; import { Plus, X, GripVertical, ChevronDown, ChevronUp } from "lucide-react";
import { RepeaterFieldGroupConfig, RepeaterData, RepeaterItemData, RepeaterFieldDefinition } from "@/types/repeater"; import { RepeaterFieldGroupConfig, RepeaterData, RepeaterItemData, RepeaterFieldDefinition, CalculationFormula } from "@/types/repeater";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useBreakpoint } from "@/hooks/useBreakpoint"; import { useBreakpoint } from "@/hooks/useBreakpoint";
import { usePreviewBreakpoint } from "@/components/screen/ResponsivePreviewModal"; import { usePreviewBreakpoint } from "@/components/screen/ResponsivePreviewModal";
@ -21,6 +22,7 @@ export interface RepeaterInputProps {
disabled?: boolean; disabled?: boolean;
readonly?: boolean; readonly?: boolean;
className?: string; className?: string;
menuObjid?: number; // 카테고리 조회용 메뉴 ID
} }
/** /**
@ -34,6 +36,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
disabled = false, disabled = false,
readonly = false, readonly = false,
className, className,
menuObjid,
}) => { }) => {
// 현재 브레이크포인트 감지 // 현재 브레이크포인트 감지
const globalBreakpoint = useBreakpoint(); const globalBreakpoint = useBreakpoint();
@ -42,6 +45,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 미리보기 모달 내에서는 previewBreakpoint 우선 사용 // 미리보기 모달 내에서는 previewBreakpoint 우선 사용
const breakpoint = previewBreakpoint || globalBreakpoint; const breakpoint = previewBreakpoint || globalBreakpoint;
// 카테고리 매핑 데이터 (값 -> {label, color})
const [categoryMappings, setCategoryMappings] = useState<Record<string, Record<string, { label: string; color: string }>>>({});
// 설정 기본값 // 설정 기본값
const { const {
fields = [], fields = [],
@ -72,6 +78,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 접힌 상태 관리 (각 항목별) // 접힌 상태 관리 (각 항목별)
const [collapsedItems, setCollapsedItems] = useState<Set<number>>(new Set()); const [collapsedItems, setCollapsedItems] = useState<Set<number>>(new Set());
// 🆕 초기 계산 완료 여부 추적 (무한 루프 방지)
const initialCalcDoneRef = useRef(false);
// 🆕 삭제된 항목 ID 목록 추적 (ref로 관리하여 즉시 반영)
const deletedItemIdsRef = useRef<string[]>([]);
// 빈 항목 생성 // 빈 항목 생성
function createEmptyItem(): RepeaterItemData { function createEmptyItem(): RepeaterItemData {
@ -82,10 +94,39 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
return item; return item;
} }
// 외부 value 변경 시 동기화 // 외부 value 변경 시 동기화 및 초기 계산식 필드 업데이트
useEffect(() => { useEffect(() => {
if (value.length > 0) { if (value.length > 0) {
setItems(value); // 🆕 초기 로드 시 계산식 필드 자동 업데이트 (한 번만 실행)
const calculatedFields = fields.filter(f => f.type === "calculated");
if (calculatedFields.length > 0 && !initialCalcDoneRef.current) {
const updatedValue = value.map(item => {
const updatedItem = { ...item };
let hasChange = false;
calculatedFields.forEach(calcField => {
const calculatedValue = calculateValue(calcField.formula, updatedItem);
if (calculatedValue !== null && updatedItem[calcField.name] !== calculatedValue) {
updatedItem[calcField.name] = calculatedValue;
hasChange = true;
}
});
return hasChange ? updatedItem : item;
});
setItems(updatedValue);
initialCalcDoneRef.current = true;
// 계산된 값이 있으면 onChange 호출 (초기 1회만)
const dataWithMeta = config.targetTable
? updatedValue.map((item) => ({ ...item, _targetTable: config.targetTable }))
: updatedValue;
onChange?.(dataWithMeta);
} else {
setItems(value);
}
} }
}, [value]); }, [value]);
@ -111,14 +152,32 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
if (items.length <= minItems) { if (items.length <= minItems) {
return; return;
} }
// 🆕 삭제되는 항목의 ID 저장 (DB에서 삭제할 때 필요)
const removedItem = items[index];
if (removedItem?.id) {
console.log("🗑️ [RepeaterInput] 삭제할 항목 ID 추가:", removedItem.id);
deletedItemIdsRef.current = [...deletedItemIdsRef.current, removedItem.id];
}
const newItems = items.filter((_, i) => i !== index); const newItems = items.filter((_, i) => i !== index);
setItems(newItems); setItems(newItems);
// targetTable이 설정된 경우 각 항목에 메타데이터 추가 // targetTable이 설정된 경우 각 항목에 메타데이터 추가
// 🆕 삭제된 항목 ID 목록도 함께 전달 (ref에서 최신값 사용)
const currentDeletedIds = deletedItemIdsRef.current;
console.log("🗑️ [RepeaterInput] 현재 삭제 목록:", currentDeletedIds);
const dataWithMeta = config.targetTable const dataWithMeta = config.targetTable
? newItems.map((item) => ({ ...item, _targetTable: config.targetTable })) ? newItems.map((item, idx) => ({
...item,
_targetTable: config.targetTable,
// 첫 번째 항목에만 삭제 ID 목록 포함
...(idx === 0 ? { _deletedItemIds: currentDeletedIds } : {}),
}))
: newItems; : newItems;
console.log("🗑️ [RepeaterInput] onChange 호출 - dataWithMeta:", dataWithMeta);
onChange?.(dataWithMeta); onChange?.(dataWithMeta);
// 접힌 상태도 업데이트 // 접힌 상태도 업데이트
@ -134,6 +193,16 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
...newItems[itemIndex], ...newItems[itemIndex],
[fieldName]: value, [fieldName]: value,
}; };
// 🆕 계산식 필드 자동 업데이트: 변경된 항목의 모든 계산식 필드 값을 재계산
const calculatedFields = fields.filter(f => f.type === "calculated");
calculatedFields.forEach(calcField => {
const calculatedValue = calculateValue(calcField.formula, newItems[itemIndex]);
if (calculatedValue !== null) {
newItems[itemIndex][calcField.name] = calculatedValue;
}
});
setItems(newItems); setItems(newItems);
console.log("✏️ RepeaterInput 필드 변경, onChange 호출:", { console.log("✏️ RepeaterInput 필드 변경, onChange 호출:", {
itemIndex, itemIndex,
@ -143,8 +212,15 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
}); });
// targetTable이 설정된 경우 각 항목에 메타데이터 추가 // targetTable이 설정된 경우 각 항목에 메타데이터 추가
// 🆕 삭제된 항목 ID 목록도 유지
const currentDeletedIds = deletedItemIdsRef.current;
const dataWithMeta = config.targetTable const dataWithMeta = config.targetTable
? newItems.map((item) => ({ ...item, _targetTable: config.targetTable })) ? newItems.map((item, idx) => ({
...item,
_targetTable: config.targetTable,
// 첫 번째 항목에만 삭제 ID 목록 포함 (삭제된 항목이 있는 경우에만)
...(idx === 0 && currentDeletedIds.length > 0 ? { _deletedItemIds: currentDeletedIds } : {}),
}))
: newItems; : newItems;
onChange?.(dataWithMeta); onChange?.(dataWithMeta);
@ -192,24 +268,183 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
setDraggedIndex(null); setDraggedIndex(null);
}; };
/**
*
* @param formula
* @param item
* @returns
*/
const calculateValue = (formula: CalculationFormula | undefined, item: RepeaterItemData): number | null => {
if (!formula || !formula.field1) return null;
const value1 = parseFloat(item[formula.field1]) || 0;
const value2 = formula.field2
? (parseFloat(item[formula.field2]) || 0)
: (formula.constantValue ?? 0);
let result: number;
switch (formula.operator) {
case "+":
result = value1 + value2;
break;
case "-":
result = value1 - value2;
break;
case "*":
result = value1 * value2;
break;
case "/":
result = value2 !== 0 ? value1 / value2 : 0;
break;
case "%":
result = value2 !== 0 ? value1 % value2 : 0;
break;
case "round":
const decimalPlaces = formula.decimalPlaces ?? 0;
const multiplier = Math.pow(10, decimalPlaces);
result = Math.round(value1 * multiplier) / multiplier;
break;
case "floor":
const floorMultiplier = Math.pow(10, formula.decimalPlaces ?? 0);
result = Math.floor(value1 * floorMultiplier) / floorMultiplier;
break;
case "ceil":
const ceilMultiplier = Math.pow(10, formula.decimalPlaces ?? 0);
result = Math.ceil(value1 * ceilMultiplier) / ceilMultiplier;
break;
case "abs":
result = Math.abs(value1);
break;
default:
result = value1;
}
return result;
};
/**
*
* @param value
* @param format
* @returns
*/
const formatNumber = (
value: number | null,
format?: RepeaterFieldDefinition["numberFormat"]
): string => {
if (value === null || isNaN(value)) return "-";
let formattedValue = value;
// 소수점 자릿수 적용
if (format?.decimalPlaces !== undefined) {
formattedValue = parseFloat(value.toFixed(format.decimalPlaces));
}
// 천 단위 구분자
let result = format?.useThousandSeparator !== false
? formattedValue.toLocaleString("ko-KR", {
minimumFractionDigits: format?.minimumFractionDigits ?? 0,
maximumFractionDigits: format?.maximumFractionDigits ?? format?.decimalPlaces ?? 0,
})
: formattedValue.toString();
// 접두사/접미사 추가
if (format?.prefix) result = format.prefix + result;
if (format?.suffix) result = result + format.suffix;
return result;
};
// 개별 필드 렌더링 // 개별 필드 렌더링
const renderField = (field: RepeaterFieldDefinition, itemIndex: number, value: any) => { const renderField = (field: RepeaterFieldDefinition, itemIndex: number, value: any) => {
const isReadonly = disabled || readonly || field.readonly;
const commonProps = { const commonProps = {
value: value || "", value: value || "",
disabled: disabled || readonly, disabled: isReadonly,
placeholder: field.placeholder, placeholder: field.placeholder,
required: field.required, required: field.required,
}; };
// 계산식 필드: 자동으로 계산된 값을 표시 (읽기 전용)
if (field.type === "calculated") {
const item = items[itemIndex];
const calculatedValue = calculateValue(field.formula, item);
const formattedValue = formatNumber(calculatedValue, field.numberFormat);
return (
<span className="text-sm font-medium text-blue-700 min-w-[80px] inline-block">
{formattedValue}
</span>
);
}
// 카테고리 타입은 항상 배지로 표시 (카테고리 관리에서 설정한 색상 적용)
if (field.type === "category") {
if (!value) return <span className="text-muted-foreground text-sm">-</span>;
// field.name을 키로 사용 (테이블 리스트와 동일)
const mapping = categoryMappings[field.name];
const valueStr = String(value); // 값을 문자열로 변환
const categoryData = mapping?.[valueStr];
const displayLabel = categoryData?.label || valueStr;
const displayColor = categoryData?.color || "#64748b"; // 기본 색상 (slate)
console.log(`🏷️ [RepeaterInput] 카테고리 배지 렌더링:`, {
fieldName: field.name,
value: valueStr,
mapping,
categoryData,
displayLabel,
displayColor,
});
// 색상이 "none"이면 일반 텍스트로 표시
if (displayColor === "none") {
return <span className="text-sm">{displayLabel}</span>;
}
return (
<Badge
style={{
backgroundColor: displayColor,
borderColor: displayColor,
}}
className="text-white"
>
{displayLabel}
</Badge>
);
}
// 읽기 전용 모드: 텍스트로 표시
// displayMode가 "readonly"이면 isReadonly 여부와 관계없이 텍스트로 표시
if (field.displayMode === "readonly") {
// select 타입인 경우 옵션에서 라벨 찾기
if (field.type === "select" && value && field.options) {
const option = field.options.find(opt => opt.value === value);
return <span className="text-sm">{option?.label || value}</span>;
}
// 일반 텍스트
return (
<span className="text-sm text-foreground">
{value || "-"}
</span>
);
}
switch (field.type) { switch (field.type) {
case "select": case "select":
return ( return (
<Select <Select
value={value || ""} value={value || ""}
onValueChange={(val) => handleFieldChange(itemIndex, field.name, val)} onValueChange={(val) => handleFieldChange(itemIndex, field.name, val)}
disabled={disabled || readonly} disabled={isReadonly}
> >
<SelectTrigger className="w-full"> <SelectTrigger className="w-full min-w-[80px]">
<SelectValue placeholder={field.placeholder || "선택하세요"} /> <SelectValue placeholder={field.placeholder || "선택하세요"} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -228,7 +463,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
{...commonProps} {...commonProps}
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)} onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
rows={3} rows={3}
className="resize-none" className="resize-none min-w-[100px]"
/> />
); );
@ -238,10 +473,45 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
{...commonProps} {...commonProps}
type="date" type="date"
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)} onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
className="min-w-[120px]"
/> />
); );
case "number": case "number":
// 숫자 포맷이 설정된 경우 포맷팅된 텍스트로 표시
if (field.numberFormat?.useThousandSeparator || field.numberFormat?.prefix || field.numberFormat?.suffix) {
const numValue = parseFloat(value) || 0;
const formattedDisplay = formatNumber(numValue, field.numberFormat);
// 읽기 전용이면 포맷팅된 텍스트만 표시
if (isReadonly) {
return (
<span className="text-sm min-w-[80px] inline-block">
{formattedDisplay}
</span>
);
}
// 편집 가능: 입력은 숫자로, 표시는 포맷팅
return (
<div className="relative min-w-[80px]">
<Input
{...commonProps}
type="number"
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
min={field.validation?.min}
max={field.validation?.max}
className="pr-1"
/>
{value && (
<div className="text-muted-foreground text-[10px] mt-0.5">
{formattedDisplay}
</div>
)}
</div>
);
}
return ( return (
<Input <Input
{...commonProps} {...commonProps}
@ -249,6 +519,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)} onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
min={field.validation?.min} min={field.validation?.min}
max={field.validation?.max} max={field.validation?.max}
className="min-w-[80px]"
/> />
); );
@ -258,6 +529,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
{...commonProps} {...commonProps}
type="email" type="email"
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)} onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
className="min-w-[120px]"
/> />
); );
@ -267,6 +539,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
{...commonProps} {...commonProps}
type="tel" type="tel"
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)} onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
className="min-w-[100px]"
/> />
); );
@ -277,11 +550,69 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
type="text" type="text"
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)} onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
maxLength={field.validation?.maxLength} maxLength={field.validation?.maxLength}
className="min-w-[80px]"
/> />
); );
} }
}; };
// 카테고리 매핑 로드 (카테고리 필드가 있을 때 자동 로드)
// 테이블 리스트와 동일한 API 사용: /table-categories/{tableName}/{columnName}/values
useEffect(() => {
const categoryFields = fields.filter(f => f.type === "category");
if (categoryFields.length === 0) return;
const loadCategoryMappings = async () => {
const apiClient = (await import("@/lib/api/client")).apiClient;
for (const field of categoryFields) {
const columnName = field.name; // 실제 컬럼명
const categoryCode = field.categoryCode || columnName;
// 이미 로드된 경우 스킵
if (categoryMappings[columnName]) continue;
try {
// config에서 targetTable 가져오기, 없으면 스킵
const tableName = config.targetTable;
if (!tableName) {
console.warn(`[RepeaterInput] targetTable이 설정되지 않아 카테고리 매핑을 로드할 수 없습니다.`);
continue;
}
console.log(`📡 [RepeaterInput] 카테고리 매핑 로드: ${tableName}/${columnName}`);
// 테이블 리스트와 동일한 API 사용
const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`);
if (response.data.success && response.data.data && Array.isArray(response.data.data)) {
const mapping: Record<string, { label: string; color: string }> = {};
response.data.data.forEach((item: any) => {
// valueCode를 문자열로 변환하여 키로 사용 (테이블 리스트와 동일)
const key = String(item.valueCode);
mapping[key] = {
label: item.valueLabel || key,
color: item.color || "#64748b", // color 필드 사용 (DB 컬럼명과 동일)
};
});
console.log(`✅ [RepeaterInput] 카테고리 매핑 로드 완료 [${columnName}]:`, mapping);
setCategoryMappings(prev => ({
...prev,
[columnName]: mapping,
}));
}
} catch (error) {
console.error(`❌ [RepeaterInput] 카테고리 매핑 로드 실패 (${columnName}):`, error);
}
}
};
loadCategoryMappings();
}, [fields, config.targetTable]);
// 필드가 정의되지 않았을 때 // 필드가 정의되지 않았을 때
if (fields.length === 0) { if (fields.length === 0) {
return ( return (
@ -324,18 +655,18 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
<TableHeader> <TableHeader>
<TableRow className="bg-background"> <TableRow className="bg-background">
{showIndex && ( {showIndex && (
<TableHead className="h-12 w-12 px-6 py-3 text-center text-sm font-semibold">#</TableHead> <TableHead className="h-10 w-10 px-2.5 py-2 text-center text-sm font-semibold">#</TableHead>
)} )}
{allowReorder && ( {allowReorder && (
<TableHead className="h-12 w-12 px-6 py-3 text-center text-sm font-semibold"></TableHead> <TableHead className="h-10 w-10 px-2.5 py-2 text-center text-sm font-semibold"></TableHead>
)} )}
{fields.map((field) => ( {fields.map((field) => (
<TableHead key={field.name} className="h-12 px-6 py-3 text-sm font-semibold"> <TableHead key={field.name} className="h-10 px-2.5 py-2 text-sm font-semibold">
{field.label} {field.label}
{field.required && <span className="ml-1 text-destructive">*</span>} {field.required && <span className="ml-1 text-destructive">*</span>}
</TableHead> </TableHead>
))} ))}
<TableHead className="h-12 w-20 px-6 py-3 text-center text-sm font-semibold"></TableHead> <TableHead className="h-10 w-14 px-2.5 py-2 text-center text-sm font-semibold"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@ -354,27 +685,27 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
> >
{/* 인덱스 번호 */} {/* 인덱스 번호 */}
{showIndex && ( {showIndex && (
<TableCell className="h-16 px-6 py-3 text-center text-sm font-medium"> <TableCell className="h-12 px-2.5 py-2 text-center text-sm font-medium">
{itemIndex + 1} {itemIndex + 1}
</TableCell> </TableCell>
)} )}
{/* 드래그 핸들 */} {/* 드래그 핸들 */}
{allowReorder && !readonly && !disabled && ( {allowReorder && !readonly && !disabled && (
<TableCell className="h-16 px-6 py-3 text-center"> <TableCell className="h-12 px-2.5 py-2 text-center">
<GripVertical className="h-4 w-4 cursor-move text-muted-foreground" /> <GripVertical className="h-4 w-4 cursor-move text-muted-foreground" />
</TableCell> </TableCell>
)} )}
{/* 필드들 */} {/* 필드들 */}
{fields.map((field) => ( {fields.map((field) => (
<TableCell key={field.name} className="h-16 px-6 py-3"> <TableCell key={field.name} className="h-12 px-2.5 py-2">
{renderField(field, itemIndex, item[field.name])} {renderField(field, itemIndex, item[field.name])}
</TableCell> </TableCell>
))} ))}
{/* 삭제 버튼 */} {/* 삭제 버튼 */}
<TableCell className="h-16 px-6 py-3 text-center"> <TableCell className="h-12 px-2.5 py-2 text-center">
{!readonly && !disabled && items.length > minItems && ( {!readonly && !disabled && items.length > minItems && (
<Button <Button
type="button" type="button"

View File

@ -9,8 +9,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Plus, X, GripVertical, Check, ChevronsUpDown } from "lucide-react"; import { Plus, X, GripVertical, Check, ChevronsUpDown, Calculator } from "lucide-react";
import { RepeaterFieldGroupConfig, RepeaterFieldDefinition, RepeaterFieldType } from "@/types/repeater"; import { RepeaterFieldGroupConfig, RepeaterFieldDefinition, RepeaterFieldType, CalculationOperator, CalculationFormula } from "@/types/repeater";
import { ColumnInfo } from "@/types/screen"; import { ColumnInfo } from "@/types/screen";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -192,6 +192,32 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<p className="text-xs text-gray-500"> .</p> <p className="text-xs text-gray-500"> .</p>
</div> </div>
{/* 그룹화 컬럼 설정 */}
<div className="space-y-2">
<Label className="text-sm font-semibold"> ()</Label>
<Select
value={config.groupByColumn || "__none__"}
onValueChange={(value) => handleChange("groupByColumn", value === "__none__" ? undefined : value)}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="그룹화 컬럼 선택 (선택사항)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-gray-500">
.
<br />
: 입고번호를 .
</p>
</div>
{/* 필드 정의 */} {/* 필드 정의 */}
<div className="space-y-3"> <div className="space-y-3">
<Label className="text-sm font-semibold"> </Label> <Label className="text-sm font-semibold"> </Label>
@ -235,10 +261,23 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
key={column.columnName} key={column.columnName}
value={column.columnName} value={column.columnName}
onSelect={() => { onSelect={() => {
// input_type (DB에서 설정한 타입) 우선 사용, 없으면 webType/widgetType
const col = column as any;
const fieldType = col.input_type || col.inputType || col.webType || col.widgetType || "text";
console.log("🔍 [RepeaterConfigPanel] 필드 타입 결정:", {
columnName: column.columnName,
input_type: col.input_type,
inputType: col.inputType,
webType: col.webType,
widgetType: col.widgetType,
finalType: fieldType,
});
updateField(index, { updateField(index, {
name: column.columnName, name: column.columnName,
label: column.columnLabel || column.columnName, label: column.columnLabel || column.columnName,
type: (column.widgetType as RepeaterFieldType) || "text", type: fieldType as RepeaterFieldType,
}); });
// 로컬 입력 상태도 업데이트 // 로컬 입력 상태도 업데이트
setLocalInputs(prev => ({ setLocalInputs(prev => ({
@ -293,13 +332,25 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="text"></SelectItem> {/* 테이블 타입 관리에서 사용하는 input_type 목록 */}
<SelectItem value="number"></SelectItem> <SelectItem value="text"> (text)</SelectItem>
<SelectItem value="email"></SelectItem> <SelectItem value="number"> (number)</SelectItem>
<SelectItem value="tel"></SelectItem> <SelectItem value="textarea"> (textarea)</SelectItem>
<SelectItem value="date"></SelectItem> <SelectItem value="date"> (date)</SelectItem>
<SelectItem value="select"></SelectItem> <SelectItem value="select"> (select)</SelectItem>
<SelectItem value="textarea"></SelectItem> <SelectItem value="checkbox"> (checkbox)</SelectItem>
<SelectItem value="radio"> (radio)</SelectItem>
<SelectItem value="category"> (category)</SelectItem>
<SelectItem value="entity"> (entity)</SelectItem>
<SelectItem value="code"> (code)</SelectItem>
<SelectItem value="image"> (image)</SelectItem>
<SelectItem value="direct"> (direct)</SelectItem>
<SelectItem value="calculated">
<span className="flex items-center gap-1">
<Calculator className="h-3 w-3" />
(calculated)
</span>
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@ -316,16 +367,316 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
</div> </div>
</div> </div>
<div className="flex items-center space-x-2"> {/* 계산식 타입일 때 계산식 설정 */}
<Checkbox {field.type === "calculated" && (
id={`required-${index}`} <div className="space-y-3 rounded-lg border border-blue-200 bg-blue-50 p-3">
checked={field.required ?? false} <div className="flex items-center gap-2">
onCheckedChange={(checked) => updateField(index, { required: checked as boolean })} <Calculator className="h-4 w-4 text-blue-600" />
/> <Label className="text-xs font-semibold text-blue-800"> </Label>
<Label htmlFor={`required-${index}`} className="cursor-pointer text-xs font-normal"> </div>
</Label> {/* 필드 1 선택 */}
</div> <div className="space-y-1">
<Label className="text-[10px] text-blue-700"> 1</Label>
<Select
value={field.formula?.field1 || ""}
onValueChange={(value) => updateField(index, {
formula: { ...field.formula, field1: value } as CalculationFormula
})}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent className="z-[9999]">
{localFields
.filter((f, i) => i !== index && f.type !== "calculated" && f.type !== "category")
.map((f) => (
<SelectItem key={f.name} value={f.name} className="text-xs">
{f.label || f.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 연산자 선택 */}
<div className="space-y-1">
<Label className="text-[10px] text-blue-700"></Label>
<Select
value={field.formula?.operator || "+"}
onValueChange={(value) => updateField(index, {
formula: { ...field.formula, operator: value as CalculationOperator } as CalculationFormula
})}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[9999]">
<SelectItem value="+" className="text-xs">+ </SelectItem>
<SelectItem value="-" className="text-xs">- </SelectItem>
<SelectItem value="*" className="text-xs">× </SelectItem>
<SelectItem value="/" className="text-xs">÷ </SelectItem>
<SelectItem value="%" className="text-xs">% </SelectItem>
<SelectItem value="round" className="text-xs"></SelectItem>
<SelectItem value="floor" className="text-xs"></SelectItem>
<SelectItem value="ceil" className="text-xs"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 두 번째 필드 또는 상수값 */}
{!["round", "floor", "ceil", "abs"].includes(field.formula?.operator || "") ? (
<div className="space-y-1">
<Label className="text-[10px] text-blue-700"> 2 / </Label>
<Select
value={field.formula?.field2 || (field.formula?.constantValue !== undefined ? `__const__${field.formula.constantValue}` : "")}
onValueChange={(value) => {
if (value.startsWith("__const__")) {
updateField(index, {
formula: {
...field.formula,
field2: undefined,
constantValue: 0
} as CalculationFormula
});
} else {
updateField(index, {
formula: {
...field.formula,
field2: value,
constantValue: undefined
} as CalculationFormula
});
}
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent className="z-[9999]">
{localFields
.filter((f, i) => i !== index && f.type !== "calculated" && f.type !== "category")
.map((f) => (
<SelectItem key={f.name} value={f.name} className="text-xs">
{f.label || f.name}
</SelectItem>
))}
<SelectItem value="__const__0" className="text-xs text-blue-600">
</SelectItem>
</SelectContent>
</Select>
</div>
) : (
<div className="space-y-1">
<Label className="text-[10px] text-blue-700"> 릿</Label>
<Input
type="number"
min={0}
max={10}
value={field.formula?.decimalPlaces ?? 0}
onChange={(e) => updateField(index, {
formula: { ...field.formula, decimalPlaces: parseInt(e.target.value) || 0 } as CalculationFormula
})}
className="h-8 text-xs"
/>
</div>
)}
{/* 상수값 입력 필드 */}
{field.formula?.constantValue !== undefined && (
<div className="space-y-1">
<Label className="text-[10px] text-blue-700"></Label>
<Input
type="number"
value={field.formula.constantValue}
onChange={(e) => updateField(index, {
formula: { ...field.formula, constantValue: parseFloat(e.target.value) || 0 } as CalculationFormula
})}
placeholder="숫자 입력"
className="h-8 text-xs"
/>
</div>
)}
{/* 숫자 포맷 설정 */}
<div className="space-y-2 border-t border-blue-200 pt-2">
<Label className="text-[10px] text-blue-700"> </Label>
<div className="flex items-center gap-4">
<div className="flex items-center space-x-2">
<Checkbox
id={`thousand-sep-${index}`}
checked={field.numberFormat?.useThousandSeparator ?? true}
onCheckedChange={(checked) => updateField(index, {
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean }
})}
/>
<Label htmlFor={`thousand-sep-${index}`} className="cursor-pointer text-[10px]">
</Label>
</div>
<div className="flex items-center gap-1">
<Label className="text-[10px]">:</Label>
<Input
value={field.numberFormat?.decimalPlaces ?? 0}
onChange={(e) => updateField(index, {
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 }
})}
type="number"
min={0}
max={10}
className="h-6 w-12 text-[10px]"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<Input
value={field.numberFormat?.prefix || ""}
onChange={(e) => updateField(index, {
numberFormat: { ...field.numberFormat, prefix: e.target.value }
})}
placeholder="접두사 (₩)"
className="h-7 text-[10px]"
/>
<Input
value={field.numberFormat?.suffix || ""}
onChange={(e) => updateField(index, {
numberFormat: { ...field.numberFormat, suffix: e.target.value }
})}
placeholder="접미사 (원)"
className="h-7 text-[10px]"
/>
</div>
</div>
{/* 계산식 미리보기 */}
<div className="rounded bg-white p-2 text-xs">
<span className="text-gray-500">: </span>
<code className="font-mono text-blue-700">
{field.formula?.field1 || "필드1"} {field.formula?.operator || "+"} {
field.formula?.field2 ||
(field.formula?.constantValue !== undefined ? field.formula.constantValue : "필드2")
}
</code>
</div>
</div>
)}
{/* 숫자 타입일 때 숫자 표시 형식 설정 */}
{field.type === "number" && (
<div className="space-y-2 rounded-lg border border-gray-200 bg-gray-50 p-3">
<Label className="text-xs font-semibold text-gray-700"> </Label>
<div className="flex items-center gap-4">
<div className="flex items-center space-x-2">
<Checkbox
id={`number-thousand-sep-${index}`}
checked={field.numberFormat?.useThousandSeparator ?? false}
onCheckedChange={(checked) => updateField(index, {
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean }
})}
/>
<Label htmlFor={`number-thousand-sep-${index}`} className="cursor-pointer text-[10px]">
</Label>
</div>
<div className="flex items-center gap-1">
<Label className="text-[10px]">:</Label>
<Input
value={field.numberFormat?.decimalPlaces ?? 0}
onChange={(e) => updateField(index, {
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 }
})}
type="number"
min={0}
max={10}
className="h-6 w-12 text-[10px]"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<Input
value={field.numberFormat?.prefix || ""}
onChange={(e) => updateField(index, {
numberFormat: { ...field.numberFormat, prefix: e.target.value }
})}
placeholder="접두사 (₩)"
className="h-7 text-[10px]"
/>
<Input
value={field.numberFormat?.suffix || ""}
onChange={(e) => updateField(index, {
numberFormat: { ...field.numberFormat, suffix: e.target.value }
})}
placeholder="접미사 (원)"
className="h-7 text-[10px]"
/>
</div>
</div>
)}
{/* 카테고리 타입일 때 카테고리 코드 입력 */}
{field.type === "category" && (
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={field.categoryCode || field.name || ""}
onChange={(e) => updateField(index, { categoryCode: e.target.value })}
placeholder="카테고리 코드 (예: INBOUND_TYPE)"
className="h-8 w-full text-xs"
/>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
)}
{/* 카테고리 타입이 아닐 때만 표시 모드 선택 */}
{field.type !== "category" && (
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={field.displayMode || "input"}
onValueChange={(value) => updateField(index, { displayMode: value as any })}
>
<SelectTrigger className="h-8 w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="input"> ( )</SelectItem>
<SelectItem value="readonly"> ()</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-4 pt-5">
<div className="flex items-center space-x-2">
<Checkbox
id={`required-${index}`}
checked={field.required ?? false}
onCheckedChange={(checked) => updateField(index, { required: checked as boolean })}
/>
<Label htmlFor={`required-${index}`} className="cursor-pointer text-xs font-normal">
</Label>
</div>
</div>
</div>
)}
{/* 카테고리 타입일 때는 필수만 표시 */}
{field.type === "category" && (
<div className="flex items-center space-x-2">
<Checkbox
id={`required-${index}`}
checked={field.required ?? false}
onCheckedChange={(checked) => updateField(index, { required: checked as boolean })}
/>
<Label htmlFor={`required-${index}`} className="cursor-pointer text-xs font-normal">
</Label>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
))} ))}

View File

@ -0,0 +1,133 @@
/**
*
* .
*/
"use client";
import React, { createContext, useContext, useCallback, useRef } from "react";
import type { DataProvidable, DataReceivable } from "@/types/data-transfer";
import { logger } from "@/lib/utils/logger";
import type { SplitPanelPosition } from "@/contexts/SplitPanelContext";
interface ScreenContextValue {
screenId?: number;
tableName?: string;
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치 (left/right)
// 컴포넌트 등록
registerDataProvider: (componentId: string, provider: DataProvidable) => void;
unregisterDataProvider: (componentId: string) => void;
registerDataReceiver: (componentId: string, receiver: DataReceivable) => void;
unregisterDataReceiver: (componentId: string) => void;
// 컴포넌트 조회
getDataProvider: (componentId: string) => DataProvidable | undefined;
getDataReceiver: (componentId: string) => DataReceivable | undefined;
// 모든 컴포넌트 조회
getAllDataProviders: () => Map<string, DataProvidable>;
getAllDataReceivers: () => Map<string, DataReceivable>;
}
const ScreenContext = createContext<ScreenContextValue | null>(null);
interface ScreenContextProviderProps {
screenId?: number;
tableName?: string;
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치
children: React.ReactNode;
}
/**
*
*/
export function ScreenContextProvider({ screenId, tableName, splitPanelPosition, children }: ScreenContextProviderProps) {
const dataProvidersRef = useRef<Map<string, DataProvidable>>(new Map());
const dataReceiversRef = useRef<Map<string, DataReceivable>>(new Map());
const registerDataProvider = useCallback((componentId: string, provider: DataProvidable) => {
dataProvidersRef.current.set(componentId, provider);
logger.debug("데이터 제공자 등록", { componentId, componentType: provider.componentType });
}, []);
const unregisterDataProvider = useCallback((componentId: string) => {
dataProvidersRef.current.delete(componentId);
logger.debug("데이터 제공자 해제", { componentId });
}, []);
const registerDataReceiver = useCallback((componentId: string, receiver: DataReceivable) => {
dataReceiversRef.current.set(componentId, receiver);
logger.debug("데이터 수신자 등록", { componentId, componentType: receiver.componentType });
}, []);
const unregisterDataReceiver = useCallback((componentId: string) => {
dataReceiversRef.current.delete(componentId);
logger.debug("데이터 수신자 해제", { componentId });
}, []);
const getDataProvider = useCallback((componentId: string) => {
return dataProvidersRef.current.get(componentId);
}, []);
const getDataReceiver = useCallback((componentId: string) => {
return dataReceiversRef.current.get(componentId);
}, []);
const getAllDataProviders = useCallback(() => {
return new Map(dataProvidersRef.current);
}, []);
const getAllDataReceivers = useCallback(() => {
return new Map(dataReceiversRef.current);
}, []);
// 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지)
const value = React.useMemo<ScreenContextValue>(() => ({
screenId,
tableName,
splitPanelPosition,
registerDataProvider,
unregisterDataProvider,
registerDataReceiver,
unregisterDataReceiver,
getDataProvider,
getDataReceiver,
getAllDataProviders,
getAllDataReceivers,
}), [
screenId,
tableName,
splitPanelPosition,
registerDataProvider,
unregisterDataProvider,
registerDataReceiver,
unregisterDataReceiver,
getDataProvider,
getDataReceiver,
getAllDataProviders,
getAllDataReceivers,
]);
return <ScreenContext.Provider value={value}>{children}</ScreenContext.Provider>;
}
/**
*
*/
export function useScreenContext() {
const context = useContext(ScreenContext);
if (!context) {
throw new Error("useScreenContext는 ScreenContextProvider 내부에서만 사용할 수 있습니다.");
}
return context;
}
/**
* ()
* .
*/
export function useScreenContextOptional() {
return useContext(ScreenContext);
}

View File

@ -0,0 +1,286 @@
"use client";
import React, { createContext, useContext, useCallback, useRef, useState } from "react";
import { logger } from "@/lib/utils/logger";
/**
*
*/
export type SplitPanelPosition = "left" | "right";
/**
*
*/
export interface SplitPanelDataReceiver {
componentId: string;
componentType: string;
receiveData: (data: any[], mode: "append" | "replace" | "merge") => Promise<void>;
}
/**
*
*/
interface SplitPanelContextValue {
// 분할 패널 ID
splitPanelId: string;
// 좌측/우측 화면 ID
leftScreenId: number | null;
rightScreenId: number | null;
// 데이터 수신자 등록/해제
registerReceiver: (position: SplitPanelPosition, componentId: string, receiver: SplitPanelDataReceiver) => void;
unregisterReceiver: (position: SplitPanelPosition, componentId: string) => void;
// 반대편 화면으로 데이터 전달
transferToOtherSide: (
fromPosition: SplitPanelPosition,
data: any[],
targetComponentId?: string, // 특정 컴포넌트 지정 (없으면 첫 번째 수신자)
mode?: "append" | "replace" | "merge"
) => Promise<{ success: boolean; message: string }>;
// 반대편 화면의 수신자 목록 가져오기
getOtherSideReceivers: (fromPosition: SplitPanelPosition) => SplitPanelDataReceiver[];
// 현재 위치 확인
isInSplitPanel: boolean;
// screenId로 위치 찾기
getPositionByScreenId: (screenId: number) => SplitPanelPosition | null;
// 🆕 우측에 추가된 항목 ID 관리 (좌측 테이블에서 필터링용)
addedItemIds: Set<string>;
addItemIds: (ids: string[]) => void;
removeItemIds: (ids: string[]) => void;
clearItemIds: () => void;
}
const SplitPanelContext = createContext<SplitPanelContextValue | null>(null);
interface SplitPanelProviderProps {
splitPanelId: string;
leftScreenId: number | null;
rightScreenId: number | null;
children: React.ReactNode;
}
/**
*
*/
export function SplitPanelProvider({
splitPanelId,
leftScreenId,
rightScreenId,
children,
}: SplitPanelProviderProps) {
// 좌측/우측 화면의 데이터 수신자 맵
const leftReceiversRef = useRef<Map<string, SplitPanelDataReceiver>>(new Map());
const rightReceiversRef = useRef<Map<string, SplitPanelDataReceiver>>(new Map());
// 강제 리렌더링용 상태
const [, forceUpdate] = useState(0);
// 🆕 우측에 추가된 항목 ID 상태
const [addedItemIds, setAddedItemIds] = useState<Set<string>>(new Set());
/**
*
*/
const registerReceiver = useCallback(
(position: SplitPanelPosition, componentId: string, receiver: SplitPanelDataReceiver) => {
const receiversRef = position === "left" ? leftReceiversRef : rightReceiversRef;
receiversRef.current.set(componentId, receiver);
logger.debug(`[SplitPanelContext] 수신자 등록: ${position} - ${componentId}`, {
componentType: receiver.componentType,
});
forceUpdate((n) => n + 1);
},
[]
);
/**
*
*/
const unregisterReceiver = useCallback(
(position: SplitPanelPosition, componentId: string) => {
const receiversRef = position === "left" ? leftReceiversRef : rightReceiversRef;
receiversRef.current.delete(componentId);
logger.debug(`[SplitPanelContext] 수신자 해제: ${position} - ${componentId}`);
forceUpdate((n) => n + 1);
},
[]
);
/**
*
*/
const getOtherSideReceivers = useCallback(
(fromPosition: SplitPanelPosition): SplitPanelDataReceiver[] => {
const receiversRef = fromPosition === "left" ? rightReceiversRef : leftReceiversRef;
return Array.from(receiversRef.current.values());
},
[]
);
/**
*
*/
const transferToOtherSide = useCallback(
async (
fromPosition: SplitPanelPosition,
data: any[],
targetComponentId?: string,
mode: "append" | "replace" | "merge" = "append"
): Promise<{ success: boolean; message: string }> => {
const toPosition = fromPosition === "left" ? "right" : "left";
const receiversRef = fromPosition === "left" ? rightReceiversRef : leftReceiversRef;
logger.info(`[SplitPanelContext] 데이터 전달 시작: ${fromPosition}${toPosition}`, {
dataCount: data.length,
targetComponentId,
mode,
availableReceivers: Array.from(receiversRef.current.keys()),
});
if (receiversRef.current.size === 0) {
const message = `${toPosition === "left" ? "좌측" : "우측"} 화면에 데이터를 받을 수 있는 컴포넌트가 없습니다.`;
logger.warn(`[SplitPanelContext] ${message}`);
return { success: false, message };
}
try {
let targetReceiver: SplitPanelDataReceiver | undefined;
if (targetComponentId) {
// 특정 컴포넌트 지정
targetReceiver = receiversRef.current.get(targetComponentId);
if (!targetReceiver) {
const message = `타겟 컴포넌트 '${targetComponentId}'를 찾을 수 없습니다.`;
logger.warn(`[SplitPanelContext] ${message}`);
return { success: false, message };
}
} else {
// 첫 번째 수신자 사용
targetReceiver = receiversRef.current.values().next().value;
}
if (!targetReceiver) {
return { success: false, message: "데이터 수신자를 찾을 수 없습니다." };
}
await targetReceiver.receiveData(data, mode);
const message = `${data.length}개 항목이 ${toPosition === "left" ? "좌측" : "우측"} 화면으로 전달되었습니다.`;
logger.info(`[SplitPanelContext] ${message}`);
return { success: true, message };
} catch (error: any) {
const message = error.message || "데이터 전달 중 오류가 발생했습니다.";
logger.error(`[SplitPanelContext] 데이터 전달 실패`, error);
return { success: false, message };
}
},
[]
);
/**
* screenId로
*/
const getPositionByScreenId = useCallback(
(screenId: number): SplitPanelPosition | null => {
if (leftScreenId === screenId) return "left";
if (rightScreenId === screenId) return "right";
return null;
},
[leftScreenId, rightScreenId]
);
/**
* 🆕 ID
*/
const addItemIds = useCallback((ids: string[]) => {
setAddedItemIds((prev) => {
const newSet = new Set(prev);
ids.forEach((id) => newSet.add(id));
logger.debug(`[SplitPanelContext] 항목 ID 추가: ${ids.length}`, { ids });
return newSet;
});
}, []);
/**
* 🆕 ID
*/
const removeItemIds = useCallback((ids: string[]) => {
setAddedItemIds((prev) => {
const newSet = new Set(prev);
ids.forEach((id) => newSet.delete(id));
logger.debug(`[SplitPanelContext] 항목 ID 제거: ${ids.length}`, { ids });
return newSet;
});
}, []);
/**
* 🆕 ID
*/
const clearItemIds = useCallback(() => {
setAddedItemIds(new Set());
logger.debug(`[SplitPanelContext] 항목 ID 초기화`);
}, []);
// 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지)
const value = React.useMemo<SplitPanelContextValue>(() => ({
splitPanelId,
leftScreenId,
rightScreenId,
registerReceiver,
unregisterReceiver,
transferToOtherSide,
getOtherSideReceivers,
isInSplitPanel: true,
getPositionByScreenId,
addedItemIds,
addItemIds,
removeItemIds,
clearItemIds,
}), [
splitPanelId,
leftScreenId,
rightScreenId,
registerReceiver,
unregisterReceiver,
transferToOtherSide,
getOtherSideReceivers,
getPositionByScreenId,
addedItemIds,
addItemIds,
removeItemIds,
clearItemIds,
]);
return (
<SplitPanelContext.Provider value={value}>
{children}
</SplitPanelContext.Provider>
);
}
/**
*
*/
export function useSplitPanelContext() {
return useContext(SplitPanelContext);
}
/**
*
*/
export function useIsInSplitPanel(): boolean {
const context = useContext(SplitPanelContext);
return context?.isInSplitPanel ?? false;
}

View File

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

View File

@ -120,13 +120,14 @@ class BatchManagementAPIClass {
apiUrl: string, apiUrl: string,
apiKey: string, apiKey: string,
endpoint: string, endpoint: string,
method: 'GET' = 'GET', method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
paramInfo?: { paramInfo?: {
paramType: 'url' | 'query'; paramType: 'url' | 'query';
paramName: string; paramName: string;
paramValue: string; paramValue: string;
paramSource: 'static' | 'dynamic'; paramSource: 'static' | 'dynamic';
} },
requestBody?: string
): Promise<{ ): Promise<{
fields: string[]; fields: string[];
samples: any[]; samples: any[];
@ -137,7 +138,8 @@ class BatchManagementAPIClass {
apiUrl, apiUrl,
apiKey, apiKey,
endpoint, endpoint,
method method,
requestBody
}; };
// 파라미터 정보가 있으면 추가 // 파라미터 정보가 있으면 추가

View File

@ -90,6 +90,7 @@ export interface Dashboard {
thumbnailUrl?: string; thumbnailUrl?: string;
isPublic: boolean; isPublic: boolean;
createdBy: string; createdBy: string;
createdByName?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
tags?: string[]; tags?: string[];
@ -97,6 +98,7 @@ export interface Dashboard {
viewCount: number; viewCount: number;
elementsCount?: number; elementsCount?: number;
creatorName?: string; creatorName?: string;
companyCode?: string;
elements?: DashboardElement[]; elements?: DashboardElement[];
settings?: { settings?: {
resolution?: string; resolution?: string;

Some files were not shown because too many files have changed in this diff Show More