Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into logistream
This commit is contained in:
commit
0c897ad8fd
42
PLAN.MD
42
PLAN.MD
|
|
@ -1,28 +1,36 @@
|
|||
# 프로젝트: Digital Twin 에디터 안정화
|
||||
# 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원)
|
||||
|
||||
## 개요
|
||||
|
||||
Digital Twin 에디터(`DigitalTwinEditor.tsx`)에서 발생한 런타임 에러(`TypeError: Cannot read properties of undefined`)를 수정하고, 전반적인 안정성을 확보합니다.
|
||||
현재 GET 방식 위주로 구현된 외부 REST API 커넥션 관리 기능을 확장하여, POST, PUT, DELETE 등 다양한 HTTP 메서드와 JSON Request Body를 설정하고 테스트할 수 있도록 개선합니다. 이를 통해 토큰 발급 API나 데이터 전송 API 등 다양한 외부 시스템과의 연동을 지원합니다.
|
||||
|
||||
## 핵심 기능
|
||||
|
||||
1. `DigitalTwinEditor` 버그 수정
|
||||
2. 비동기 함수 입력값 유효성 검증 강화
|
||||
3. 외부 DB 연결 상태에 따른 방어 코드 추가
|
||||
1. **DB 스키마 확장**: `external_rest_api_connections` 테이블에 `default_method`, `default_body` 컬럼 추가
|
||||
2. **백엔드 로직 개선**:
|
||||
- 커넥션 생성/수정 시 메서드와 바디 정보 저장
|
||||
- 연결 테스트 시 설정된 메서드와 바디를 사용하여 요청 수행
|
||||
- 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단계: 잠재적 문제 점검
|
||||
|
||||
- [ ] `loadLayout` 등 주요 로딩 함수의 데이터 유효성 검사
|
||||
- [ ] `handleToolDragStart`, `handleCanvasDrop` 등 인터랙션 함수의 예외 처리
|
||||
## 에러 처리 계획
|
||||
- **JSON 파싱 에러**: Body 입력값이 유효한 JSON이 아닐 경우 에러 처리
|
||||
- **API 호출 에러**: 외부 API 호출 실패 시 상세 로그 기록 및 클라이언트에 에러 메시지 전달
|
||||
- **SSL 인증 에러**: `rejectUnauthorized: false` 옵션으로 처리 (기존 `RestApiConnector` 활용)
|
||||
|
||||
## 진행 상태
|
||||
|
||||
- [진행중] 1단계 긴급 버그 수정 완료 후 사용자 피드백 대기 중
|
||||
|
||||
- [완료] 모든 단계 구현 완료
|
||||
|
|
|
|||
|
|
@ -1,19 +0,0 @@
|
|||
{
|
||||
"id": "12b583c9-a6b2-4c7f-8340-fd0e700aa32e",
|
||||
"sentAt": "2025-10-22T05:17:38.303Z",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"subject": "Fwd: ㅏㅣ",
|
||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㄴㅇㄹㄴㅇㄹㄴㅇㄹㅇ리'ㅐㅔ'ㅑ678463ㅎㄱ휼췇흍츄</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border: 1px solid #ccc; padding: 15px; margin: 10px 0; background-color: #f9f9f9;\">\r\n <p><strong>---------- 전달된 메시지 ----------</strong></p>\r\n <p><strong>보낸 사람:</strong> \"이희진\" <zian9227@naver.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:32:34</p>\r\n <p><strong>제목:</strong> ㅏㅣ</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\r\n ",
|
||||
"status": "success",
|
||||
"messageId": "<74dbd467-6185-024d-dd60-bf4459ff9ea4@wace.me>",
|
||||
"accepted": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"rejected": [],
|
||||
"deletedAt": "2025-10-22T06:36:10.876Z"
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
{
|
||||
"id": "1bb5ebfe-3f6c-4884-a043-161ae3f74f75",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [],
|
||||
"cc": [],
|
||||
"bcc": [],
|
||||
"subject": "Fwd: ㄴㅇㄹㅇㄴㄴㄹ 테스트트트",
|
||||
"htmlContent": "\n\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n전달된 메일:\n\n보낸사람: \"이희진\" <zian9227@naver.com>\n날짜: 2025. 10. 22. 오후 4:24:54\n제목: ㄴㅇㄹㅇㄴㄴㄹ\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\nㄹㅇㄴㄹㅇㄴㄹㅇㄴ\n",
|
||||
"sentAt": "2025-10-22T07:49:50.811Z",
|
||||
"status": "draft",
|
||||
"isDraft": true,
|
||||
"updatedAt": "2025-10-22T07:49:50.811Z",
|
||||
"deletedAt": "2025-10-22T07:50:14.211Z"
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
{
|
||||
"id": "375f2326-ca86-468a-bfc3-2d4c3825577b",
|
||||
"sentAt": "2025-10-22T04:57:39.706Z",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"\"이희진\" <zian9227@naver.com>"
|
||||
],
|
||||
"subject": "Re: ㅏㅣ",
|
||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹㅁㅇㄴㄹㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㄴㅁㅇㄹ</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border-left: 3px solid #ccc; padding-left: 10px; margin-left: 10px; color: #666;\">\r\n <p><strong>보낸 사람:</strong> \"이희진\" <zian9227@naver.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:32:34</p>\r\n <p><strong>제목:</strong> ㅏㅣ</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\r\n ",
|
||||
"status": "success",
|
||||
"messageId": "<f085efa6-2668-0293-57de-88b1e7009dd1@wace.me>",
|
||||
"accepted": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"rejected": [],
|
||||
"deletedAt": "2025-10-22T07:11:04.666Z"
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
{
|
||||
"id": "386e334a-df76-440c-ae8a-9bf06982fdc8",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [],
|
||||
"cc": [],
|
||||
"bcc": [],
|
||||
"subject": "Fwd: ㄴ",
|
||||
"htmlContent": "\n <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>---------- 전달된 메일 ----------</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 12:58:15</p>\n <p><strong>제목:</strong> ㄴ</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n <p style=\"white-space: pre-wrap;\">ㄴㅇㄹㄴㅇㄹㄴㅇㄹ\n</p>\n </div>\n ",
|
||||
"sentAt": "2025-10-22T07:04:27.192Z",
|
||||
"status": "draft",
|
||||
"isDraft": true,
|
||||
"updatedAt": "2025-10-22T07:04:57.280Z",
|
||||
"deletedAt": "2025-10-22T07:50:17.136Z"
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
{
|
||||
"id": "3d411dc4-69a6-4236-b878-9693dff881be",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"cc": [],
|
||||
"bcc": [],
|
||||
"subject": "Re: ㄴ",
|
||||
"htmlContent": "\n <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>원본 메일:</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 12:58:15</p>\n <p><strong>제목:</strong> ㄴ</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n <p style=\"white-space: pre-wrap;\">undefined</p>\n </div>\n ",
|
||||
"sentAt": "2025-10-22T06:56:51.060Z",
|
||||
"status": "draft",
|
||||
"isDraft": true,
|
||||
"updatedAt": "2025-10-22T06:56:51.060Z",
|
||||
"deletedAt": "2025-10-22T07:50:22.989Z"
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
{
|
||||
"id": "3e30a264-8431-44c7-96ef-eed551e66a11",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [],
|
||||
"cc": [],
|
||||
"bcc": [],
|
||||
"subject": "Fwd: ㄴ",
|
||||
"htmlContent": "\n <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>---------- 전달된 메일 ----------</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 12:58:15</p>\n <p><strong>제목:</strong> ㄴ</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n <p style=\"white-space: pre-wrap;\"></p>\n </div>\n ",
|
||||
"sentAt": "2025-10-22T06:57:53.335Z",
|
||||
"status": "draft",
|
||||
"isDraft": true,
|
||||
"updatedAt": "2025-10-22T07:00:23.394Z",
|
||||
"deletedAt": "2025-10-22T07:50:20.510Z"
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
{
|
||||
"id": "4a32bab5-364e-4037-bb00-31d2905824db",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [],
|
||||
"cc": [],
|
||||
"bcc": [],
|
||||
"subject": "테스트 마지가",
|
||||
"htmlContent": "ㅁㄴㅇㄹ",
|
||||
"sentAt": "2025-10-22T07:49:29.948Z",
|
||||
"status": "draft",
|
||||
"isDraft": true,
|
||||
"updatedAt": "2025-10-22T07:49:29.948Z",
|
||||
"deletedAt": "2025-10-22T07:50:12.374Z"
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
{
|
||||
"id": "5bfb2acd-023a-4865-a738-2900179db5fb",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [],
|
||||
"cc": [],
|
||||
"bcc": [],
|
||||
"subject": "Fwd: ㄴ",
|
||||
"htmlContent": "\n <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>---------- 전달된 메일 ----------</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 12:58:15</p>\n <p><strong>제목:</strong> ㄴ</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n <p style=\"white-space: pre-wrap;\">ㄴㅇㄹㄴㅇㄹㄴㅇㄹ\n</p>\n </div>\n ",
|
||||
"sentAt": "2025-10-22T07:03:09.080Z",
|
||||
"status": "draft",
|
||||
"isDraft": true,
|
||||
"updatedAt": "2025-10-22T07:03:39.150Z",
|
||||
"deletedAt": "2025-10-22T07:50:19.035Z"
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
{
|
||||
"id": "683c1323-1895-403a-bb9a-4e111a8909f6",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"cc": [],
|
||||
"bcc": [],
|
||||
"subject": "Re: ㄴ",
|
||||
"htmlContent": "\n <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>원본 메일:</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 12:58:15</p>\n <p><strong>제목:</strong> ㄴ</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n <p style=\"white-space: pre-wrap;\">undefined</p>\n </div>\n ",
|
||||
"sentAt": "2025-10-22T06:54:55.097Z",
|
||||
"status": "draft",
|
||||
"isDraft": true,
|
||||
"updatedAt": "2025-10-22T06:54:55.097Z",
|
||||
"deletedAt": "2025-10-22T07:50:24.672Z"
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
{
|
||||
"id": "7bed27d5-dae4-4ba8-85d0-c474c4fb907a",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [],
|
||||
"cc": [],
|
||||
"bcc": [],
|
||||
"subject": "Fwd: ㅏㅣ",
|
||||
"htmlContent": "\n <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>---------- 전달된 메일 ----------</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:32:34</p>\n <p><strong>제목:</strong> ㅏㅣ</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n undefined\n </div>\n ",
|
||||
"sentAt": "2025-10-22T06:41:52.984Z",
|
||||
"status": "draft",
|
||||
"isDraft": true,
|
||||
"updatedAt": "2025-10-22T06:46:23.051Z",
|
||||
"deletedAt": "2025-10-22T07:50:29.124Z"
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"id": "8990ea86-3112-4e7c-b3e0-8b494181c4e0",
|
||||
"accountName": "",
|
||||
"accountEmail": "",
|
||||
"to": [],
|
||||
"subject": "",
|
||||
"htmlContent": "",
|
||||
"sentAt": "2025-10-22T06:17:31.379Z",
|
||||
"status": "draft",
|
||||
"isDraft": true,
|
||||
"updatedAt": "2025-10-22T06:17:31.379Z",
|
||||
"deletedAt": "2025-10-22T07:50:30.736Z"
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"id": "99703f2c-740c-492e-a866-a04289a9b699",
|
||||
"accountName": "",
|
||||
"accountEmail": "",
|
||||
"to": [],
|
||||
"subject": "",
|
||||
"htmlContent": "",
|
||||
"sentAt": "2025-10-22T06:20:08.450Z",
|
||||
"status": "draft",
|
||||
"isDraft": true,
|
||||
"updatedAt": "2025-10-22T06:20:08.450Z",
|
||||
"deletedAt": "2025-10-22T06:36:07.797Z"
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
{
|
||||
"id": "9ab1e5ee-4f5e-4b79-9769-5e2a1e1ffc8e",
|
||||
"sentAt": "2025-10-22T04:31:17.175Z",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"\"이희진\" <zian9227@naver.com>"
|
||||
],
|
||||
"subject": "Re: ㅅㄷㄴㅅ",
|
||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">배불르고 졸린데 커피먹으니깐 졸린건 괜찮아졋고 배불러서 물배찼당아아아아</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border-left: 3px solid #ccc; padding-left: 10px; margin-left: 10px; color: #666;\">\r\n <p><strong>보낸 사람:</strong> \"이희진\" <zian9227@naver.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:03:03</p>\r\n <p><strong>제목:</strong> ㅅㄷㄴㅅ</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\r\n ",
|
||||
"status": "success",
|
||||
"messageId": "<0f215ba8-a1e4-8c5a-f43f-962f0717c161@wace.me>",
|
||||
"accepted": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"rejected": [],
|
||||
"deletedAt": "2025-10-22T07:11:10.245Z"
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
{
|
||||
"id": "9d0b9fcf-cabf-4053-b6b6-6e110add22de",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"cc": [],
|
||||
"bcc": [],
|
||||
"subject": "Re: ㅏㅣ",
|
||||
"htmlContent": "\n <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>원본 메일:</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:32:34</p>\n <p><strong>제목:</strong> ㅏㅣ</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n <p style=\"white-space: pre-wrap;\">undefined</p>\n </div>\n ",
|
||||
"sentAt": "2025-10-22T06:50:04.224Z",
|
||||
"status": "draft",
|
||||
"isDraft": true,
|
||||
"updatedAt": "2025-10-22T06:50:04.224Z",
|
||||
"deletedAt": "2025-10-22T07:50:26.224Z"
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
{
|
||||
"id": "b293e530-2b2d-4b8a-8081-d103fab5a13f",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"cc": [],
|
||||
"bcc": [],
|
||||
"subject": "Re: 수신메일확인용",
|
||||
"htmlContent": "\n <br/><br/>\n <div style=\"border-left: 3px solid #ccc; padding-left: 15px; margin-top: 20px; color: #666;\">\n <p><strong>원본 메일:</strong></p>\n <p><strong>보낸사람:</strong> \"이희진\" <zian9227@naver.com></p>\n <p><strong>날짜:</strong> 2025. 10. 13. 오전 10:40:30</p>\n <p><strong>제목:</strong> 수신메일확인용</p>\n <hr style=\"border: none; border-top: 1px solid #ddd; margin: 10px 0;\"/>\n undefined\n </div>\n ",
|
||||
"sentAt": "2025-10-22T06:47:53.815Z",
|
||||
"status": "draft",
|
||||
"isDraft": true,
|
||||
"updatedAt": "2025-10-22T06:48:53.876Z",
|
||||
"deletedAt": "2025-10-22T07:50:27.706Z"
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
{
|
||||
"id": "cf892a77-1998-4165-bb9d-b390451465b2",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [],
|
||||
"cc": [],
|
||||
"bcc": [],
|
||||
"subject": "Fwd: ㄴ",
|
||||
"htmlContent": "\n\n\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n전달된 메일:\n\n보낸사람: \"이희진\" <zian9227@naver.com>\n날짜: 2025. 10. 22. 오후 12:58:15\n제목: ㄴ\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\nㄴㅇㄹㄴㅇㄹㄴㅇㄹ\n",
|
||||
"sentAt": "2025-10-22T07:06:11.620Z",
|
||||
"status": "draft",
|
||||
"isDraft": true,
|
||||
"updatedAt": "2025-10-22T07:07:11.749Z",
|
||||
"deletedAt": "2025-10-22T07:50:15.739Z"
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"id": "e3501abc-cd31-4b20-bb02-3c7ddbe54eb8",
|
||||
"accountName": "",
|
||||
"accountEmail": "",
|
||||
"to": [],
|
||||
"subject": "",
|
||||
"htmlContent": "",
|
||||
"sentAt": "2025-10-22T06:15:02.128Z",
|
||||
"status": "draft",
|
||||
"isDraft": true,
|
||||
"updatedAt": "2025-10-22T06:15:02.128Z",
|
||||
"deletedAt": "2025-10-22T07:08:43.543Z"
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
{
|
||||
"id": "eb92ed00-cc4f-4cc8-94c9-9bef312d16db",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [],
|
||||
"cc": [],
|
||||
"bcc": [],
|
||||
"subject": "메일 임시저장 테스트 4",
|
||||
"htmlContent": "asd",
|
||||
"sentAt": "2025-10-22T06:21:40.019Z",
|
||||
"status": "draft",
|
||||
"isDraft": true,
|
||||
"updatedAt": "2025-10-22T06:21:40.019Z",
|
||||
"deletedAt": "2025-10-22T06:36:05.306Z"
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
{
|
||||
"id": "fd2a8b41-2e6e-4e5e-b8e8-63d31efc5082",
|
||||
"sentAt": "2025-10-22T04:29:14.738Z",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"\"이희진\" <zian9227@naver.com>"
|
||||
],
|
||||
"subject": "Re: ㅅㄷㄴㅅ",
|
||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㄴㅇㄹㄴㅇㄹ</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border-left: 3px solid #ccc; padding-left: 10px; margin-left: 10px; color: #666;\">\r\n <p><strong>보낸 사람:</strong> \"이희진\" <zian9227@naver.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:03:03</p>\r\n <p><strong>제목:</strong> ㅅㄷㄴㅅ</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\r\n ",
|
||||
"attachments": [
|
||||
{
|
||||
"filename": "test용 이미지2.png",
|
||||
"originalName": "test용 이미지2.png",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1761107350246-298369766.png",
|
||||
"mimetype": "image/png"
|
||||
}
|
||||
],
|
||||
"status": "success",
|
||||
"messageId": "<e68a0501-f79a-8713-a625-e882f711b30d@wace.me>",
|
||||
"accepted": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"rejected": [],
|
||||
"deletedAt": "2025-10-22T07:11:12.907Z"
|
||||
}
|
||||
|
|
@ -169,22 +169,18 @@ export class BatchController {
|
|||
static async getBatchConfigById(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
const batchConfig = await BatchService.getBatchConfigById(
|
||||
Number(id),
|
||||
userCompanyCode
|
||||
);
|
||||
const result = await BatchService.getBatchConfigById(Number(id));
|
||||
|
||||
if (!batchConfig) {
|
||||
if (!result.success || !result.data) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "배치 설정을 찾을 수 없습니다.",
|
||||
message: result.message || "배치 설정을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: batchConfig,
|
||||
data: result.data,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("배치 설정 조회 오류:", error);
|
||||
|
|
|
|||
|
|
@ -62,6 +62,11 @@ export class BatchExecutionLogController {
|
|||
try {
|
||||
const data: CreateBatchExecutionLogRequest = req.body;
|
||||
|
||||
// 멀티테넌시: company_code가 없으면 현재 사용자 회사 코드로 설정
|
||||
if (!data.company_code) {
|
||||
data.company_code = req.user?.companyCode || "*";
|
||||
}
|
||||
|
||||
const result = await BatchExecutionLogService.createExecutionLog(data);
|
||||
|
||||
if (result.success) {
|
||||
|
|
|
|||
|
|
@ -265,8 +265,12 @@ export class BatchManagementController {
|
|||
|
||||
try {
|
||||
// 실행 로그 생성
|
||||
executionLog = await BatchService.createExecutionLog({
|
||||
const { BatchExecutionLogService } = await import(
|
||||
"../services/batchExecutionLogService"
|
||||
);
|
||||
const logResult = await BatchExecutionLogService.createExecutionLog({
|
||||
batch_config_id: Number(id),
|
||||
company_code: batchConfig.company_code,
|
||||
execution_status: "RUNNING",
|
||||
start_time: startTime,
|
||||
total_records: 0,
|
||||
|
|
@ -274,6 +278,14 @@ export class BatchManagementController {
|
|||
failed_records: 0,
|
||||
});
|
||||
|
||||
if (!logResult.success || !logResult.data) {
|
||||
throw new Error(
|
||||
logResult.message || "배치 실행 로그를 생성할 수 없습니다."
|
||||
);
|
||||
}
|
||||
|
||||
executionLog = logResult.data;
|
||||
|
||||
// BatchSchedulerService의 executeBatchConfig 메서드 사용 (중복 로직 제거)
|
||||
const { BatchSchedulerService } = await import(
|
||||
"../services/batchSchedulerService"
|
||||
|
|
@ -290,7 +302,7 @@ export class BatchManagementController {
|
|||
const duration = endTime.getTime() - startTime.getTime();
|
||||
|
||||
// 실행 로그 업데이트 (성공)
|
||||
await BatchService.updateExecutionLog(executionLog.id, {
|
||||
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
||||
execution_status: "SUCCESS",
|
||||
end_time: endTime,
|
||||
duration_ms: duration,
|
||||
|
|
@ -406,22 +418,34 @@ export class BatchManagementController {
|
|||
paramName,
|
||||
paramValue,
|
||||
paramSource,
|
||||
requestBody,
|
||||
} = req.body;
|
||||
|
||||
if (!apiUrl || !apiKey || !endpoint) {
|
||||
// apiUrl, endpoint는 항상 필수
|
||||
if (!apiUrl || !endpoint) {
|
||||
return res.status(400).json({
|
||||
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 미리보기 요청:", {
|
||||
apiUrl,
|
||||
endpoint,
|
||||
method,
|
||||
paramType,
|
||||
paramName,
|
||||
paramValue,
|
||||
paramSource,
|
||||
requestBody: requestBody ? "Included" : "None",
|
||||
});
|
||||
|
||||
// RestApiConnector 사용하여 데이터 조회
|
||||
|
|
@ -429,7 +453,7 @@ export class BatchManagementController {
|
|||
|
||||
const connector = new RestApiConnector({
|
||||
baseUrl: apiUrl,
|
||||
apiKey: apiKey,
|
||||
apiKey: apiKey || "",
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
|
|
@ -456,9 +480,28 @@ export class BatchManagementController {
|
|||
|
||||
console.log("🔗 최종 엔드포인트:", finalEndpoint);
|
||||
|
||||
// 데이터 조회 (최대 5개만) - GET 메서드만 지원
|
||||
const result = await connector.executeQuery(finalEndpoint, method);
|
||||
console.log(`[previewRestApiData] executeQuery 결과:`, {
|
||||
// Request Body 파싱
|
||||
let parsedBody = undefined;
|
||||
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,
|
||||
rowsLength: result.rows ? result.rows.length : "undefined",
|
||||
firstRow:
|
||||
|
|
@ -532,15 +575,21 @@ export class BatchManagementController {
|
|||
apiMappings,
|
||||
});
|
||||
|
||||
// 🔐 멀티테넌시: 현재 사용자 회사 코드 사용 (프론트에서 받지 않음)
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId;
|
||||
|
||||
// BatchService를 사용하여 배치 설정 저장
|
||||
const batchConfig: CreateBatchConfigRequest = {
|
||||
batchName: batchName,
|
||||
description: description || "",
|
||||
cronSchedule: cronSchedule,
|
||||
isActive: "Y",
|
||||
companyCode,
|
||||
mappings: apiMappings,
|
||||
};
|
||||
|
||||
const result = await BatchService.createBatchConfig(batchConfig);
|
||||
const result = await BatchService.createBatchConfig(batchConfig, userId);
|
||||
|
||||
if (result.success && result.data) {
|
||||
// 스케줄러에 자동 등록 ✅
|
||||
|
|
|
|||
|
|
@ -161,3 +161,4 @@ export const createMappingTemplate = async (
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -132,6 +132,16 @@ router.post("/", authenticateToken, async (req: AuthenticatedRequest, res: Respo
|
|||
return res.status(400).json({ success: false, error: "최소 1개 이상의 규칙 파트가 필요합니다" });
|
||||
}
|
||||
|
||||
// 🆕 scopeType이 'table'인 경우 tableName 필수 체크
|
||||
if (ruleConfig.scopeType === "table") {
|
||||
if (!ruleConfig.tableName || ruleConfig.tableName.trim() === "") {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "테이블 범위 규칙은 테이블명(tableName)이 필수입니다",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const newRule = await numberingRuleService.createRule(ruleConfig, companyCode, userId);
|
||||
|
||||
logger.info("✅ [POST /numbering-rules] 채번 규칙 생성 성공:", {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import axios, { AxiosInstance, AxiosResponse } from "axios";
|
||||
import https from "https";
|
||||
import {
|
||||
DatabaseConnector,
|
||||
ConnectionConfig,
|
||||
|
|
@ -24,16 +25,26 @@ export class RestApiConnector implements DatabaseConnector {
|
|||
|
||||
constructor(config: RestApiConfig) {
|
||||
this.config = config;
|
||||
|
||||
// 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({
|
||||
baseURL: config.baseUrl,
|
||||
timeout: config.timeout || 30000,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
headers: defaultHeaders,
|
||||
// ⚠️ 외부 API 중 자체 서명 인증서를 사용하는 경우가 있어서
|
||||
// 인증서 검증을 끈 HTTPS 에이전트를 사용한다.
|
||||
// 내부망/신뢰된 시스템 전용으로 사용해야 하며,
|
||||
// 공개 인터넷용 API에는 적용하면 안 된다.
|
||||
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
||||
});
|
||||
|
||||
// 요청/응답 인터셉터 설정
|
||||
|
|
@ -75,26 +86,16 @@ export class RestApiConnector implements DatabaseConnector {
|
|||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
try {
|
||||
// 연결 테스트 - 기본 엔드포인트 호출
|
||||
await this.httpClient.get("/health", { timeout: 5000 });
|
||||
console.log(`[RestApiConnector] 연결 성공: ${this.config.baseUrl}`);
|
||||
} catch (error) {
|
||||
// health 엔드포인트가 없을 수 있으므로 404는 정상으로 처리
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
console.log(
|
||||
`[RestApiConnector] 연결 성공 (health 엔드포인트 없음): ${this.config.baseUrl}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.error(
|
||||
`[RestApiConnector] 연결 실패: ${this.config.baseUrl}`,
|
||||
error
|
||||
);
|
||||
throw new Error(
|
||||
`REST API 연결 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`
|
||||
);
|
||||
}
|
||||
// 기존에는 /health 엔드포인트를 호출해서 미리 연결을 검사했지만,
|
||||
// 일반 외부 API들은 /health가 없거나 401/500을 반환하는 경우가 많아
|
||||
// 불필요하게 예외가 나면서 미리보기/배치 실행이 막히는 문제가 있었다.
|
||||
//
|
||||
// 따라서 여기서는 "연결 준비 완료" 정도만 로그로 남기고
|
||||
// 실제 호출 실패 여부는 executeRequest 단계에서만 판단하도록 한다.
|
||||
console.log(
|
||||
`[RestApiConnector] 연결 준비 완료 (사전 헬스체크 생략): ${this.config.baseUrl}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -213,7 +213,10 @@ router.post(
|
|||
}
|
||||
|
||||
const result =
|
||||
await ExternalRestApiConnectionService.testConnection(testRequest);
|
||||
await ExternalRestApiConnectionService.testConnection(
|
||||
testRequest,
|
||||
req.user?.companyCode
|
||||
);
|
||||
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -170,3 +170,4 @@ export class DigitalTwinTemplateService {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -130,13 +130,14 @@ export class BatchExecutionLogService {
|
|||
try {
|
||||
const log = await queryOne<BatchExecutionLog>(
|
||||
`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,
|
||||
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 *`,
|
||||
[
|
||||
data.batch_config_id,
|
||||
data.company_code,
|
||||
data.execution_status,
|
||||
data.start_time || new Date(),
|
||||
data.end_time,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,258 +1,114 @@
|
|||
// 배치 스케줄러 서비스
|
||||
// 작성일: 2024-12-24
|
||||
|
||||
import * as cron from "node-cron";
|
||||
import { query, queryOne } from "../database/db";
|
||||
import cron from "node-cron";
|
||||
import { BatchService } from "./batchService";
|
||||
import { BatchExecutionLogService } from "./batchExecutionLogService";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export class BatchSchedulerService {
|
||||
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 {
|
||||
logger.info("배치 스케줄러 초기화 시작...");
|
||||
logger.info("배치 스케줄러 초기화 시작");
|
||||
|
||||
// 기존 모든 스케줄 정리 (중복 방지)
|
||||
this.clearAllSchedules();
|
||||
const batchConfigsResponse = await BatchService.getBatchConfigs({
|
||||
is_active: "Y",
|
||||
});
|
||||
|
||||
// 활성화된 배치 설정들을 로드하여 스케줄 등록
|
||||
await this.loadActiveBatchConfigs();
|
||||
|
||||
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})`);
|
||||
if (!batchConfigsResponse.success || !batchConfigsResponse.data) {
|
||||
logger.warn("스케줄링할 활성 배치 설정이 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 새로운 스케줄 등록
|
||||
const task = cron.schedule(cron_schedule, async () => {
|
||||
// 중복 실행 방지 체크
|
||||
if (this.executingBatches.has(id)) {
|
||||
logger.warn(
|
||||
`⚠️ 배치가 이미 실행 중입니다. 건너뜀: ${batch_name} (ID: ${id})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const batchConfigs = batchConfigsResponse.data;
|
||||
logger.info(`${batchConfigs.length}개의 배치 설정 스케줄링 등록`);
|
||||
|
||||
logger.info(`🔄 스케줄 배치 실행 시작: ${batch_name} (ID: ${id})`);
|
||||
for (const config of batchConfigs) {
|
||||
await this.scheduleBatch(config);
|
||||
}
|
||||
|
||||
// 실행 중 플래그 설정
|
||||
this.executingBatches.add(id);
|
||||
logger.info("배치 스케줄러 초기화 완료");
|
||||
} catch (error) {
|
||||
logger.error("배치 스케줄러 초기화 중 오류 발생:", error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await this.executeBatchConfig(config);
|
||||
} finally {
|
||||
// 실행 완료 후 플래그 제거
|
||||
this.executingBatches.delete(id);
|
||||
}
|
||||
/**
|
||||
* 개별 배치 작업 스케줄링
|
||||
*/
|
||||
static async scheduleBatch(config: any) {
|
||||
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);
|
||||
});
|
||||
|
||||
// 스케줄 시작 (기본적으로 시작되지만 명시적으로 호출)
|
||||
task.start();
|
||||
|
||||
this.scheduledTasks.set(id, task);
|
||||
logger.info(
|
||||
`배치 스케줄 등록 완료: ${batch_name} (ID: ${id}, Schedule: ${cron_schedule}) - 스케줄 시작됨`
|
||||
);
|
||||
this.scheduledTasks.set(config.id, task);
|
||||
} 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(
|
||||
configId: number,
|
||||
executeImmediately: boolean = true
|
||||
) {
|
||||
try {
|
||||
// 기존 스케줄 제거
|
||||
await this.unscheduleBatchConfig(configId);
|
||||
|
||||
// 업데이트된 배치 설정 조회
|
||||
const configResult = 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.id = $1
|
||||
GROUP BY bc.id`,
|
||||
[configId]
|
||||
);
|
||||
|
||||
const config = configResult[0] || null;
|
||||
|
||||
if (!config) {
|
||||
logger.warn(`배치 설정을 찾을 수 없습니다: ID ${configId}`);
|
||||
const result = await BatchService.getBatchConfigById(configId);
|
||||
if (!result.success || !result.data) {
|
||||
// 설정이 없으면 스케줄 제거
|
||||
if (this.scheduledTasks.has(configId)) {
|
||||
this.scheduledTasks.get(configId)?.stop();
|
||||
this.scheduledTasks.delete(configId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 활성화된 배치만 다시 스케줄 등록
|
||||
if (config.is_active === "Y") {
|
||||
await this.scheduleBatchConfig(config);
|
||||
logger.info(
|
||||
`배치 스케줄 업데이트 완료: ${config.batch_name} (ID: ${configId})`
|
||||
);
|
||||
const config = result.data;
|
||||
|
||||
// 활성화 시 즉시 실행 (옵션)
|
||||
if (executeImmediately) {
|
||||
logger.info(
|
||||
`🚀 배치 활성화 즉시 실행: ${config.batch_name} (ID: ${configId})`
|
||||
);
|
||||
await this.executeBatchConfig(config);
|
||||
}
|
||||
} else {
|
||||
logger.info(
|
||||
`비활성화된 배치 스케줄 제거: ${config.batch_name} (ID: ${configId})`
|
||||
// 스케줄 재등록
|
||||
await this.scheduleBatch(config);
|
||||
|
||||
// 즉시 실행 옵션이 있으면 실행
|
||||
/*
|
||||
if (executeImmediately && config.is_active === "Y") {
|
||||
logger.info(`배치 설정 변경 후 즉시 실행: ${config.batch_name}`);
|
||||
this.executeBatchConfig(config).catch((err) =>
|
||||
logger.error(`즉시 실행 중 오류 발생:`, err)
|
||||
);
|
||||
}
|
||||
*/
|
||||
} catch (error) {
|
||||
logger.error(`배치 스케줄 업데이트 실패: ID ${configId}`, error);
|
||||
}
|
||||
|
|
@ -272,6 +128,7 @@ export class BatchSchedulerService {
|
|||
const executionLogResponse =
|
||||
await BatchExecutionLogService.createExecutionLog({
|
||||
batch_config_id: config.id,
|
||||
company_code: config.company_code,
|
||||
execution_status: "RUNNING",
|
||||
start_time: startTime,
|
||||
total_records: 0,
|
||||
|
|
@ -313,21 +170,20 @@ export class BatchSchedulerService {
|
|||
// 성공 결과 반환
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`배치 실행 실패: ${config.batch_name}`, error);
|
||||
logger.error(`배치 실행 중 오류 발생: ${config.batch_name}`, error);
|
||||
|
||||
// 실행 로그 업데이트 (실패)
|
||||
if (executionLog) {
|
||||
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
||||
execution_status: "FAILED",
|
||||
execution_status: "FAILURE",
|
||||
end_time: new Date(),
|
||||
duration_ms: Date.now() - startTime.getTime(),
|
||||
error_message:
|
||||
error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
error_details: error instanceof Error ? error.stack : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
// 실패 시에도 결과 반환
|
||||
// 실패 결과 반환
|
||||
return {
|
||||
totalRecords: 0,
|
||||
successRecords: 0,
|
||||
|
|
@ -379,6 +235,8 @@ export class BatchSchedulerService {
|
|||
const { BatchExternalDbService } = await import(
|
||||
"./batchExternalDbService"
|
||||
);
|
||||
|
||||
// 👇 Body 파라미터 추가 (POST 요청 시)
|
||||
const apiResult = await BatchExternalDbService.getDataFromRestApi(
|
||||
firstMapping.from_api_url!,
|
||||
firstMapping.from_api_key!,
|
||||
|
|
@ -394,7 +252,9 @@ export class BatchSchedulerService {
|
|||
firstMapping.from_api_param_type,
|
||||
firstMapping.from_api_param_name,
|
||||
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) {
|
||||
|
|
@ -416,6 +276,17 @@ export class BatchSchedulerService {
|
|||
totalRecords += fromData.length;
|
||||
|
||||
// 컬럼 매핑 적용하여 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 mappedRow: any = {};
|
||||
for (const mapping of mappings) {
|
||||
|
|
@ -428,10 +299,25 @@ export class BatchSchedulerService {
|
|||
mappedRow[mapping.from_column_name] =
|
||||
row[mapping.from_column_name];
|
||||
} else {
|
||||
// 기존 로직: to_column_name을 키로 사용
|
||||
mappedRow[mapping.to_column_name] = row[mapping.from_column_name];
|
||||
// REST API -> DB (POST 요청 포함) 또는 DB -> DB
|
||||
// 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;
|
||||
});
|
||||
|
||||
|
|
@ -482,22 +368,12 @@ export class BatchSchedulerService {
|
|||
);
|
||||
}
|
||||
} else {
|
||||
// 기존 REST API 전송 (REST API → DB 배치)
|
||||
const apiResult = await BatchExternalDbService.sendDataToRestApi(
|
||||
firstMapping.to_api_url!,
|
||||
firstMapping.to_api_key!,
|
||||
firstMapping.to_table_name,
|
||||
(firstMapping.to_api_method as "POST" | "PUT") || "POST",
|
||||
mappedData
|
||||
// 기존 REST API 전송 (REST API → DB 배치) - 사실 이 경우는 거의 없음 (REST to REST)
|
||||
// 지원하지 않음
|
||||
logger.warn(
|
||||
"REST API -> REST API (단순 매핑)은 아직 지원하지 않습니다."
|
||||
);
|
||||
|
||||
if (apiResult.success && apiResult.data) {
|
||||
insertResult = apiResult.data;
|
||||
} else {
|
||||
throw new Error(
|
||||
`REST API 데이터 전송 실패: ${apiResult.message}`
|
||||
);
|
||||
}
|
||||
insertResult = { successCount: 0, failedCount: 0 };
|
||||
}
|
||||
} else {
|
||||
// DB에 데이터 삽입
|
||||
|
|
@ -511,167 +387,13 @@ export class BatchSchedulerService {
|
|||
|
||||
successRecords += insertResult.successCount;
|
||||
failedRecords += insertResult.failedCount;
|
||||
|
||||
logger.info(
|
||||
`테이블 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`테이블 처리 실패: ${tableKey}`, error);
|
||||
failedRecords += 1;
|
||||
logger.error(`테이블 처리 중 오류 발생: ${tableKey}`, error);
|
||||
// 해당 테이블 처리 실패는 전체 실패로 간주하지 않고, 실패 카운트만 증가?
|
||||
// 여기서는 일단 실패 로그만 남기고 계속 진행 (필요시 정책 변경)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
|
@ -99,10 +99,18 @@ export class DynamicFormService {
|
|||
}
|
||||
|
||||
try {
|
||||
// YYYY-MM-DD 형식인 경우 시간 추가해서 Date 객체 생성
|
||||
// YYYY-MM-DD 형식인 경우
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||
console.log(`📅 날짜 타입 변환: ${value} -> Date 객체`);
|
||||
return new Date(value + "T00:00:00");
|
||||
// DATE 타입이면 문자열 그대로 유지
|
||||
if (lowerDataType === "date") {
|
||||
console.log(`📅 날짜 문자열 유지: ${value} -> "${value}" (DATE 타입)`);
|
||||
return value; // 문자열 그대로 반환
|
||||
}
|
||||
// TIMESTAMP 타입이면 Date 객체로 변환
|
||||
else {
|
||||
console.log(`📅 날짜시간 변환: ${value} -> Date 객체 (TIMESTAMP 타입)`);
|
||||
return new Date(value + "T00:00:00");
|
||||
}
|
||||
}
|
||||
// 다른 날짜 형식도 Date 객체로 변환
|
||||
else {
|
||||
|
|
@ -300,13 +308,13 @@ export class DynamicFormService {
|
|||
) {
|
||||
// YYYY-MM-DD HH:mm:ss 형태의 문자열을 Date 객체로 변환
|
||||
if (value.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/)) {
|
||||
console.log(`📅 날짜 변환: ${key} = "${value}" -> Date 객체`);
|
||||
console.log(`📅 날짜시간 변환: ${key} = "${value}" -> Date 객체`);
|
||||
dataToInsert[key] = new Date(value);
|
||||
}
|
||||
// YYYY-MM-DD 형태의 문자열을 Date 객체로 변환
|
||||
// YYYY-MM-DD 형태의 문자열은 그대로 유지 (DATE 타입으로 저장)
|
||||
else if (value.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
||||
console.log(`📅 날짜 변환: ${key} = "${value}" -> Date 객체`);
|
||||
dataToInsert[key] = new Date(value + "T00:00:00");
|
||||
console.log(`📅 날짜 유지: ${key} = "${value}" -> 문자열 그대로 (DATE 타입)`);
|
||||
// dataToInsert[key] = value; // 문자열 그대로 유지 (이미 올바른 형식)
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -849,10 +857,22 @@ export class DynamicFormService {
|
|||
const values: any[] = Object.values(changedFields);
|
||||
values.push(id); // WHERE 조건용 ID 추가
|
||||
|
||||
// 🔑 Primary Key 타입에 맞게 캐스팅
|
||||
const pkDataType = columnTypes[primaryKeyColumn];
|
||||
let pkCast = '';
|
||||
if (pkDataType === 'integer' || pkDataType === 'bigint' || pkDataType === 'smallint') {
|
||||
pkCast = '::integer';
|
||||
} else if (pkDataType === 'numeric' || pkDataType === 'decimal') {
|
||||
pkCast = '::numeric';
|
||||
} else if (pkDataType === 'uuid') {
|
||||
pkCast = '::uuid';
|
||||
}
|
||||
// text, varchar 등은 캐스팅 불필요
|
||||
|
||||
const updateQuery = `
|
||||
UPDATE ${tableName}
|
||||
SET ${setClause}
|
||||
WHERE ${primaryKeyColumn} = $${values.length}::text
|
||||
WHERE ${primaryKeyColumn} = $${values.length}${pkCast}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { Pool, QueryResult } from "pg";
|
||||
import axios, { AxiosResponse } from "axios";
|
||||
import https from "https";
|
||||
import { getPool } from "../database/db";
|
||||
import logger from "../utils/logger";
|
||||
import {
|
||||
|
|
@ -30,6 +32,10 @@ export class ExternalRestApiConnectionService {
|
|||
let query = `
|
||||
SELECT
|
||||
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,
|
||||
company_code, is_active, created_date, created_by,
|
||||
updated_date, updated_by, last_test_date, last_test_result, last_test_message
|
||||
|
|
@ -129,6 +135,8 @@ export class ExternalRestApiConnectionService {
|
|||
let query = `
|
||||
SELECT
|
||||
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,
|
||||
company_code, is_active, created_date, created_by,
|
||||
updated_date, updated_by, last_test_date, last_test_result, last_test_message
|
||||
|
|
@ -194,9 +202,10 @@ export class ExternalRestApiConnectionService {
|
|||
const query = `
|
||||
INSERT INTO external_rest_api_connections (
|
||||
connection_name, description, base_url, endpoint_path, default_headers,
|
||||
default_method, default_request_body,
|
||||
auth_type, auth_config, timeout, retry_count, retry_delay,
|
||||
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 *
|
||||
`;
|
||||
|
||||
|
|
@ -206,6 +215,8 @@ export class ExternalRestApiConnectionService {
|
|||
data.base_url,
|
||||
data.endpoint_path || null,
|
||||
JSON.stringify(data.default_headers || {}),
|
||||
data.default_method || "GET",
|
||||
data.default_body || null,
|
||||
data.auth_type,
|
||||
encryptedAuthConfig ? JSON.stringify(encryptedAuthConfig) : null,
|
||||
data.timeout || 30000,
|
||||
|
|
@ -301,6 +312,18 @@ export class ExternalRestApiConnectionService {
|
|||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.default_method !== undefined) {
|
||||
updateFields.push(`default_method = $${paramIndex}`);
|
||||
params.push(data.default_method);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.default_body !== undefined) {
|
||||
updateFields.push(`default_request_body = $${paramIndex}`);
|
||||
params.push(data.default_body);
|
||||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.auth_type !== undefined) {
|
||||
updateFields.push(`auth_type = $${paramIndex}`);
|
||||
params.push(data.auth_type);
|
||||
|
|
@ -441,7 +464,8 @@ export class ExternalRestApiConnectionService {
|
|||
* REST API 연결 테스트 (테스트 요청 데이터 기반)
|
||||
*/
|
||||
static async testConnection(
|
||||
testRequest: RestApiTestRequest
|
||||
testRequest: RestApiTestRequest,
|
||||
userCompanyCode?: string
|
||||
): Promise<RestApiTestResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
|
|
@ -450,7 +474,78 @@ export class ExternalRestApiConnectionService {
|
|||
const headers = { ...testRequest.headers };
|
||||
|
||||
// 인증 헤더 추가
|
||||
if (
|
||||
if (testRequest.auth_type === "db-token") {
|
||||
const cfg = testRequest.auth_config || {};
|
||||
const {
|
||||
dbTableName,
|
||||
dbValueColumn,
|
||||
dbWhereColumn,
|
||||
dbWhereValue,
|
||||
dbHeaderName,
|
||||
dbHeaderTemplate,
|
||||
} = cfg;
|
||||
|
||||
if (!dbTableName || !dbValueColumn) {
|
||||
throw new Error("DB 토큰 설정이 올바르지 않습니다.");
|
||||
}
|
||||
|
||||
if (!userCompanyCode) {
|
||||
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[] = [userCompanyCode];
|
||||
|
||||
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 (
|
||||
testRequest.auth_type === "bearer" &&
|
||||
testRequest.auth_config?.token
|
||||
) {
|
||||
|
|
@ -493,25 +588,84 @@ export class ExternalRestApiConnectionService {
|
|||
`REST API 연결 테스트: ${testRequest.method || "GET"} ${url}`
|
||||
);
|
||||
|
||||
// HTTP 요청 실행
|
||||
const response = await fetch(url, {
|
||||
method: testRequest.method || "GET",
|
||||
headers,
|
||||
signal: AbortSignal.timeout(testRequest.timeout || 30000),
|
||||
});
|
||||
// Body 처리
|
||||
let body: any = undefined;
|
||||
if (testRequest.body) {
|
||||
// 이미 문자열이면 그대로, 객체면 JSON 문자열로 변환
|
||||
if (typeof testRequest.body === "string") {
|
||||
body = testRequest.body;
|
||||
} else {
|
||||
body = JSON.stringify(testRequest.body);
|
||||
}
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
let responseData = null;
|
||||
|
||||
try {
|
||||
responseData = await response.json();
|
||||
} catch {
|
||||
// JSON 파싱 실패는 무시 (텍스트 응답일 수 있음)
|
||||
// Content-Type 헤더가 없으면 기본적으로 application/json 추가
|
||||
const hasContentType = Object.keys(headers).some(
|
||||
(k) => k.toLowerCase() === "content-type"
|
||||
);
|
||||
if (!hasContentType) {
|
||||
headers["Content-Type"] = "application/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 {
|
||||
success: response.ok,
|
||||
message: response.ok
|
||||
success: response.status >= 200 && response.status < 300,
|
||||
message:
|
||||
response.status >= 200 && response.status < 300
|
||||
? "연결 성공"
|
||||
: `연결 실패 (${response.status} ${response.statusText})`,
|
||||
response_time: responseTime,
|
||||
|
|
@ -552,17 +706,27 @@ export class ExternalRestApiConnectionService {
|
|||
|
||||
const connection = connectionResult.data;
|
||||
|
||||
// 리스트에서 endpoint를 넘기지 않으면,
|
||||
// 저장된 endpoint_path를 기본 엔드포인트로 사용
|
||||
const effectiveEndpoint =
|
||||
endpoint || connection.endpoint_path || undefined;
|
||||
|
||||
const testRequest: RestApiTestRequest = {
|
||||
id: connection.id,
|
||||
base_url: connection.base_url,
|
||||
endpoint,
|
||||
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,
|
||||
};
|
||||
|
||||
const result = await this.testConnection(testRequest);
|
||||
const result = await this.testConnection(
|
||||
testRequest,
|
||||
connection.company_code
|
||||
);
|
||||
|
||||
// 테스트 결과 저장
|
||||
await pool.query(
|
||||
|
|
@ -580,11 +744,34 @@ export class ExternalRestApiConnectionService {
|
|||
return result;
|
||||
} catch (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 {
|
||||
success: false,
|
||||
message: "연결 테스트에 실패했습니다.",
|
||||
error_details:
|
||||
error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
error_details: errorMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -709,6 +896,7 @@ export class ExternalRestApiConnectionService {
|
|||
"bearer",
|
||||
"basic",
|
||||
"oauth2",
|
||||
"db-token",
|
||||
];
|
||||
if (!validAuthTypes.includes(data.auth_type)) {
|
||||
throw new Error("올바르지 않은 인증 타입입니다.");
|
||||
|
|
|
|||
|
|
@ -1418,9 +1418,9 @@ export class ScreenManagementService {
|
|||
console.log(`=== 레이아웃 로드 시작 ===`);
|
||||
console.log(`화면 ID: ${screenId}`);
|
||||
|
||||
// 권한 확인
|
||||
const screens = await query<{ company_code: string | null }>(
|
||||
`SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
|
||||
// 권한 확인 및 테이블명 조회
|
||||
const screens = await query<{ company_code: string | null; table_name: string | null }>(
|
||||
`SELECT company_code, table_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`,
|
||||
[screenId]
|
||||
);
|
||||
|
||||
|
|
@ -1512,11 +1512,13 @@ export class ScreenManagementService {
|
|||
console.log(`반환할 컴포넌트 수: ${components.length}`);
|
||||
console.log(`최종 격자 설정:`, gridSettings);
|
||||
console.log(`최종 해상도 설정:`, screenResolution);
|
||||
console.log(`테이블명:`, existingScreen.table_name);
|
||||
|
||||
return {
|
||||
components,
|
||||
gridSettings,
|
||||
screenResolution,
|
||||
tableName: existingScreen.table_name, // 🆕 테이블명 추가
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1165,6 +1165,23 @@ export class TableManagementService {
|
|||
paramCount: number;
|
||||
} | null> {
|
||||
try {
|
||||
// 🔧 날짜 범위 문자열 "YYYY-MM-DD|YYYY-MM-DD" 체크 (최우선!)
|
||||
if (typeof value === "string" && value.includes("|")) {
|
||||
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
|
||||
if (columnInfo && (columnInfo.webType === "date" || columnInfo.webType === "datetime")) {
|
||||
return this.buildDateRangeCondition(columnName, value, paramIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// 🔧 날짜 범위 객체 {from, to} 체크
|
||||
if (typeof value === "object" && value !== null && ("from" in value || "to" in value)) {
|
||||
// 날짜 범위 객체는 그대로 전달
|
||||
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
|
||||
if (columnInfo && (columnInfo.webType === "date" || columnInfo.webType === "datetime")) {
|
||||
return this.buildDateRangeCondition(columnName, value, paramIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// 🔧 {value, operator} 형태의 필터 객체 처리
|
||||
let actualValue = value;
|
||||
let operator = "contains"; // 기본값
|
||||
|
|
@ -1193,6 +1210,12 @@ export class TableManagementService {
|
|||
|
||||
// 컬럼 타입 정보 조회
|
||||
const columnInfo = await this.getColumnWebTypeInfo(tableName, columnName);
|
||||
logger.info(`🔍 [buildAdvancedSearchCondition] ${tableName}.${columnName}`,
|
||||
`webType=${columnInfo?.webType || 'NULL'}`,
|
||||
`inputType=${columnInfo?.inputType || 'NULL'}`,
|
||||
`actualValue=${JSON.stringify(actualValue)}`,
|
||||
`operator=${operator}`
|
||||
);
|
||||
|
||||
if (!columnInfo) {
|
||||
// 컬럼 정보가 없으면 operator에 따른 기본 검색
|
||||
|
|
@ -1292,20 +1315,41 @@ export class TableManagementService {
|
|||
const values: any[] = [];
|
||||
let paramCount = 0;
|
||||
|
||||
if (typeof value === "object" && value !== null) {
|
||||
// 문자열 형식의 날짜 범위 파싱 ("YYYY-MM-DD|YYYY-MM-DD")
|
||||
if (typeof value === "string" && value.includes("|")) {
|
||||
const [fromStr, toStr] = value.split("|");
|
||||
|
||||
if (fromStr && fromStr.trim() !== "") {
|
||||
// VARCHAR 컬럼을 DATE로 캐스팅하여 비교
|
||||
conditions.push(`${columnName}::date >= $${paramIndex + paramCount}::date`);
|
||||
values.push(fromStr.trim());
|
||||
paramCount++;
|
||||
}
|
||||
if (toStr && toStr.trim() !== "") {
|
||||
// 종료일은 해당 날짜의 23:59:59까지 포함
|
||||
conditions.push(`${columnName}::date <= $${paramIndex + paramCount}::date`);
|
||||
values.push(toStr.trim());
|
||||
paramCount++;
|
||||
}
|
||||
}
|
||||
// 객체 형식의 날짜 범위 ({from, to})
|
||||
else if (typeof value === "object" && value !== null) {
|
||||
if (value.from) {
|
||||
conditions.push(`${columnName} >= $${paramIndex + paramCount}`);
|
||||
// VARCHAR 컬럼을 DATE로 캐스팅하여 비교
|
||||
conditions.push(`${columnName}::date >= $${paramIndex + paramCount}::date`);
|
||||
values.push(value.from);
|
||||
paramCount++;
|
||||
}
|
||||
if (value.to) {
|
||||
conditions.push(`${columnName} <= $${paramIndex + paramCount}`);
|
||||
// 종료일은 해당 날짜의 23:59:59까지 포함
|
||||
conditions.push(`${columnName}::date <= $${paramIndex + paramCount}::date`);
|
||||
values.push(value.to);
|
||||
paramCount++;
|
||||
}
|
||||
} else if (typeof value === "string" && value.trim() !== "") {
|
||||
// 단일 날짜 검색 (해당 날짜의 데이터)
|
||||
conditions.push(`DATE(${columnName}) = DATE($${paramIndex})`);
|
||||
}
|
||||
// 단일 날짜 검색
|
||||
else if (typeof value === "string" && value.trim() !== "") {
|
||||
conditions.push(`${columnName}::date = $${paramIndex}::date`);
|
||||
values.push(value);
|
||||
paramCount = 1;
|
||||
}
|
||||
|
|
@ -1544,6 +1588,7 @@ export class TableManagementService {
|
|||
columnName: string
|
||||
): Promise<{
|
||||
webType: string;
|
||||
inputType?: string;
|
||||
codeCategory?: string;
|
||||
referenceTable?: string;
|
||||
referenceColumn?: string;
|
||||
|
|
@ -1552,29 +1597,44 @@ export class TableManagementService {
|
|||
try {
|
||||
const result = await queryOne<{
|
||||
web_type: string | null;
|
||||
input_type: string | null;
|
||||
code_category: string | null;
|
||||
reference_table: string | null;
|
||||
reference_column: string | null;
|
||||
display_column: string | null;
|
||||
}>(
|
||||
`SELECT web_type, code_category, reference_table, reference_column, display_column
|
||||
`SELECT web_type, input_type, code_category, reference_table, reference_column, display_column
|
||||
FROM column_labels
|
||||
WHERE table_name = $1 AND column_name = $2
|
||||
LIMIT 1`,
|
||||
[tableName, columnName]
|
||||
);
|
||||
|
||||
logger.info(`🔍 [getColumnWebTypeInfo] ${tableName}.${columnName} 조회 결과:`, {
|
||||
found: !!result,
|
||||
web_type: result?.web_type,
|
||||
input_type: result?.input_type,
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
logger.warn(`⚠️ [getColumnWebTypeInfo] 컬럼 정보 없음: ${tableName}.${columnName}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
webType: result.web_type || "",
|
||||
// web_type이 없으면 input_type을 사용 (레거시 호환)
|
||||
const webType = result.web_type || result.input_type || "";
|
||||
|
||||
const columnInfo = {
|
||||
webType: webType,
|
||||
inputType: result.input_type || "",
|
||||
codeCategory: result.code_category || undefined,
|
||||
referenceTable: result.reference_table || undefined,
|
||||
referenceColumn: result.reference_column || undefined,
|
||||
displayColumn: result.display_column || undefined,
|
||||
};
|
||||
|
||||
logger.info(`✅ [getColumnWebTypeInfo] 반환값: webType=${columnInfo.webType}, inputType=${columnInfo.inputType}`);
|
||||
return columnInfo;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`컬럼 웹타입 정보 조회 실패: ${tableName}.${columnName}`,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
export interface BatchExecutionLog {
|
||||
id?: number;
|
||||
batch_config_id: number;
|
||||
company_code?: string;
|
||||
execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED';
|
||||
start_time: Date;
|
||||
end_time?: Date | null;
|
||||
|
|
@ -19,6 +20,7 @@ export interface BatchExecutionLog {
|
|||
|
||||
export interface CreateBatchExecutionLogRequest {
|
||||
batch_config_id: number;
|
||||
company_code?: string;
|
||||
execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED';
|
||||
start_time?: Date;
|
||||
end_time?: Date | null;
|
||||
|
|
|
|||
|
|
@ -1,86 +1,13 @@
|
|||
// 배치관리 타입 정의
|
||||
// 작성일: 2024-12-24
|
||||
import { ApiResponse, ColumnInfo } from './batchTypes';
|
||||
|
||||
// 배치 타입 정의
|
||||
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 {
|
||||
export interface BatchConnectionInfo {
|
||||
type: 'internal' | 'external';
|
||||
id?: number;
|
||||
name: string;
|
||||
db_type?: string;
|
||||
}
|
||||
|
||||
export interface TableInfo {
|
||||
table_name: string;
|
||||
columns: ColumnInfo[];
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface ColumnInfo {
|
||||
export interface BatchColumnInfo {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable?: string;
|
||||
|
|
@ -100,6 +27,8 @@ export interface BatchMappingRequest {
|
|||
from_api_param_name?: string; // API 파라미터명
|
||||
from_api_param_value?: string; // API 파라미터 값 또는 템플릿
|
||||
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_id?: number;
|
||||
to_table_name: string;
|
||||
|
|
@ -116,6 +45,8 @@ export interface CreateBatchConfigRequest {
|
|||
batchName: string;
|
||||
description?: string;
|
||||
cronSchedule: string;
|
||||
isActive: 'Y' | 'N';
|
||||
companyCode: string;
|
||||
mappings: BatchMappingRequest[];
|
||||
}
|
||||
|
||||
|
|
@ -123,25 +54,11 @@ export interface UpdateBatchConfigRequest {
|
|||
batchName?: string;
|
||||
description?: string;
|
||||
cronSchedule?: string;
|
||||
isActive?: 'Y' | 'N';
|
||||
mappings?: BatchMappingRequest[];
|
||||
isActive?: string;
|
||||
}
|
||||
|
||||
export interface BatchValidationResult {
|
||||
isValid: boolean;
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
// 외부 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 {
|
||||
id?: number;
|
||||
|
|
@ -9,6 +15,11 @@ export interface ExternalRestApiConnection {
|
|||
base_url: string;
|
||||
endpoint_path?: string;
|
||||
default_headers: Record<string, string>;
|
||||
|
||||
// 기본 메서드 및 바디 추가
|
||||
default_method?: string;
|
||||
default_body?: string;
|
||||
|
||||
auth_type: AuthType;
|
||||
auth_config?: {
|
||||
// API Key
|
||||
|
|
@ -28,6 +39,14 @@ export interface ExternalRestApiConnection {
|
|||
clientSecret?: string;
|
||||
tokenUrl?: string;
|
||||
accessToken?: string;
|
||||
|
||||
// DB 기반 토큰 모드
|
||||
dbTableName?: string;
|
||||
dbValueColumn?: string;
|
||||
dbWhereColumn?: string;
|
||||
dbWhereValue?: string;
|
||||
dbHeaderName?: string;
|
||||
dbHeaderTemplate?: string;
|
||||
};
|
||||
timeout?: number;
|
||||
retry_count?: number;
|
||||
|
|
@ -54,8 +73,9 @@ export interface RestApiTestRequest {
|
|||
id?: number;
|
||||
base_url: string;
|
||||
endpoint?: string;
|
||||
method?: "GET" | "POST" | "PUT" | "DELETE";
|
||||
method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
||||
headers?: Record<string, string>;
|
||||
body?: any; // 테스트 요청 바디 추가
|
||||
auth_type?: AuthType;
|
||||
auth_config?: any;
|
||||
timeout?: number;
|
||||
|
|
@ -76,4 +96,5 @@ export const AUTH_TYPE_OPTIONS = [
|
|||
{ value: "bearer", label: "Bearer Token" },
|
||||
{ value: "basic", label: "Basic Auth" },
|
||||
{ value: "oauth2", label: "OAuth 2.0" },
|
||||
{ value: "db-token", label: "DB 토큰" },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ export interface LayoutData {
|
|||
components: ComponentData[];
|
||||
gridSettings?: GridSettings;
|
||||
screenResolution?: ScreenResolution;
|
||||
tableName?: string; // 🆕 화면에 연결된 테이블명
|
||||
}
|
||||
|
||||
// 그리드 설정
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useMemo, memo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -33,6 +33,31 @@ interface BatchColumnInfo {
|
|||
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() {
|
||||
const router = useRouter();
|
||||
|
||||
|
|
@ -52,7 +77,8 @@ export default function BatchManagementNewPage() {
|
|||
const [fromApiUrl, setFromApiUrl] = useState("");
|
||||
const [fromApiKey, setFromApiKey] = 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 파라미터 설정
|
||||
const [apiParamType, setApiParamType] = useState<'none' | 'url' | 'query'>('none');
|
||||
|
|
@ -83,6 +109,8 @@ export default function BatchManagementNewPage() {
|
|||
|
||||
// API 필드 → DB 컬럼 매핑
|
||||
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');
|
||||
|
|
@ -182,24 +210,17 @@ export default function BatchManagementNewPage() {
|
|||
|
||||
// TO 테이블 변경 핸들러
|
||||
const handleToTableChange = async (tableName: string) => {
|
||||
console.log("🔍 테이블 변경:", { tableName, toConnection });
|
||||
setToTable(tableName);
|
||||
setToColumns([]);
|
||||
|
||||
if (toConnection && tableName) {
|
||||
try {
|
||||
const connectionType = toConnection.type === 'internal' ? 'internal' : 'external';
|
||||
console.log("🔍 컬럼 조회 시작:", { connectionType, connectionId: toConnection.id, tableName });
|
||||
|
||||
const result = await BatchManagementAPI.getTableColumns(connectionType, tableName, toConnection.id);
|
||||
console.log("🔍 컬럼 조회 결과:", result);
|
||||
|
||||
if (result && result.length > 0) {
|
||||
setToColumns(result);
|
||||
console.log("✅ 컬럼 설정 완료:", result.length, "개");
|
||||
} else {
|
||||
setToColumns([]);
|
||||
console.log("⚠️ 컬럼이 없음");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 컬럼 목록 로드 오류:", error);
|
||||
|
|
@ -239,7 +260,6 @@ export default function BatchManagementNewPage() {
|
|||
|
||||
// FROM 테이블 변경 핸들러 (DB → REST API용)
|
||||
const handleFromTableChange = async (tableName: string) => {
|
||||
console.log("🔍 FROM 테이블 변경:", { tableName, fromConnection });
|
||||
setFromTable(tableName);
|
||||
setFromColumns([]);
|
||||
setSelectedColumns([]); // 선택된 컬럼도 초기화
|
||||
|
|
@ -248,17 +268,11 @@ export default function BatchManagementNewPage() {
|
|||
if (fromConnection && tableName) {
|
||||
try {
|
||||
const connectionType = fromConnection.type === 'internal' ? 'internal' : 'external';
|
||||
console.log("🔍 FROM 컬럼 조회 시작:", { connectionType, connectionId: fromConnection.id, tableName });
|
||||
|
||||
const result = await BatchManagementAPI.getTableColumns(connectionType, tableName, fromConnection.id);
|
||||
console.log("🔍 FROM 컬럼 조회 결과:", result);
|
||||
|
||||
if (result && result.length > 0) {
|
||||
setFromColumns(result);
|
||||
console.log("✅ FROM 컬럼 설정 완료:", result.length, "개");
|
||||
} else {
|
||||
setFromColumns([]);
|
||||
console.log("⚠️ FROM 컬럼이 없음");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ FROM 컬럼 목록 로드 오류:", error);
|
||||
|
|
@ -276,8 +290,6 @@ export default function BatchManagementNewPage() {
|
|||
}
|
||||
|
||||
try {
|
||||
console.log("🔍 TO API 미리보기 시작:", { toApiUrl, toApiKey, toEndpoint, toApiMethod });
|
||||
|
||||
const result = await BatchManagementAPI.previewRestApiData(
|
||||
toApiUrl,
|
||||
toApiKey,
|
||||
|
|
@ -285,8 +297,6 @@ export default function BatchManagementNewPage() {
|
|||
'GET' // 미리보기는 항상 GET으로
|
||||
);
|
||||
|
||||
console.log("🔍 TO API 미리보기 결과:", result);
|
||||
|
||||
if (result.fields && result.fields.length > 0) {
|
||||
setToApiFields(result.fields);
|
||||
toast.success(`TO API 필드 ${result.fields.length}개를 조회했습니다.`);
|
||||
|
|
@ -303,17 +313,22 @@ export default function BatchManagementNewPage() {
|
|||
|
||||
// REST API 데이터 미리보기
|
||||
const previewRestApiData = async () => {
|
||||
if (!fromApiUrl || !fromApiKey || !fromEndpoint) {
|
||||
toast.error("API URL, API Key, 엔드포인트를 모두 입력해주세요.");
|
||||
// API URL, 엔드포인트는 항상 필수
|
||||
if (!fromApiUrl || !fromEndpoint) {
|
||||
toast.error("API URL과 엔드포인트를 모두 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// GET 메서드일 때만 API 키 필수
|
||||
if (fromApiMethod === "GET" && !fromApiKey) {
|
||||
toast.error("GET 메서드에서는 API 키를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("REST API 데이터 미리보기 시작...");
|
||||
|
||||
const result = await BatchManagementAPI.previewRestApiData(
|
||||
fromApiUrl,
|
||||
fromApiKey,
|
||||
fromApiKey || "",
|
||||
fromEndpoint,
|
||||
fromApiMethod,
|
||||
// 파라미터 정보 추가
|
||||
|
|
@ -322,33 +337,23 @@ export default function BatchManagementNewPage() {
|
|||
paramName: apiParamName,
|
||||
paramValue: apiParamValue,
|
||||
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) {
|
||||
console.log("✅ 백엔드에서 fields 제공됨:", result.fields);
|
||||
setFromApiFields(result.fields);
|
||||
setFromApiData(result.samples);
|
||||
|
||||
console.log("추출된 필드:", result.fields);
|
||||
toast.success(`API 데이터 미리보기 완료! ${result.fields.length}개 필드, ${result.totalCount}개 레코드`);
|
||||
} else if (result.samples && result.samples.length > 0) {
|
||||
// 백엔드에서 fields를 제대로 보내지 않은 경우, 프론트엔드에서 직접 추출
|
||||
console.log("⚠️ 백엔드에서 fields가 없어서 프론트엔드에서 추출");
|
||||
const extractedFields = Object.keys(result.samples[0]);
|
||||
console.log("프론트엔드에서 추출한 필드:", extractedFields);
|
||||
|
||||
setFromApiFields(extractedFields);
|
||||
setFromApiData(result.samples);
|
||||
|
||||
toast.success(`API 데이터 미리보기 완료! ${extractedFields.length}개 필드, ${result.samples.length}개 레코드`);
|
||||
} else {
|
||||
console.log("❌ 데이터가 없음");
|
||||
setFromApiFields([]);
|
||||
setFromApiData([]);
|
||||
toast.warning("API에서 데이터를 가져올 수 없습니다.");
|
||||
|
|
@ -370,38 +375,53 @@ export default function BatchManagementNewPage() {
|
|||
|
||||
// 배치 타입별 검증 및 저장
|
||||
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) {
|
||||
toast.error("최소 하나의 API 필드를 DB 컬럼에 매핑해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// API 필드 매핑을 배치 매핑 형태로 변환
|
||||
const apiMappings = mappedFields.map(apiField => ({
|
||||
from_connection_type: 'restapi' as const,
|
||||
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
|
||||
}));
|
||||
const apiMappings = mappedFields.map((apiField) => {
|
||||
const toColumnName = apiFieldMappings[apiField]; // 매핑된 DB 컬럼 (예: access_token)
|
||||
|
||||
console.log("REST API 배치 설정 저장:", {
|
||||
batchName,
|
||||
batchType,
|
||||
cronSchedule,
|
||||
description,
|
||||
apiMappings
|
||||
// 기본은 상위 필드 그대로 사용하되,
|
||||
// 사용자가 JSON 경로를 직접 입력한 경우 해당 경로를 우선 사용
|
||||
let fromColumnName = apiField;
|
||||
const overridePath = apiFieldPathOverrides[apiField];
|
||||
if (overridePath && overridePath.trim().length > 0) {
|
||||
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 호출
|
||||
|
|
@ -492,14 +512,6 @@ export default function BatchManagementNewPage() {
|
|||
}
|
||||
}
|
||||
|
||||
console.log("DB → REST API 배치 설정 저장:", {
|
||||
batchName,
|
||||
batchType,
|
||||
cronSchedule,
|
||||
description,
|
||||
dbMappings
|
||||
});
|
||||
|
||||
// 실제 API 호출 (기존 saveRestApiBatch 재사용)
|
||||
try {
|
||||
const result = await BatchManagementAPI.saveRestApiBatch({
|
||||
|
|
@ -645,13 +657,19 @@ export default function BatchManagementNewPage() {
|
|||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="fromApiKey">API 키 *</Label>
|
||||
<Label htmlFor="fromApiKey">
|
||||
API 키
|
||||
{fromApiMethod === "GET" && <span className="text-red-500 ml-0.5">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
id="fromApiKey"
|
||||
value={fromApiKey}
|
||||
onChange={(e) => setFromApiKey(e.target.value)}
|
||||
placeholder="ak_your_api_key_here"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
GET 메서드에서만 필수이며, POST/PUT/DELETE일 때는 선택 사항입니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -673,12 +691,33 @@ export default function BatchManagementNewPage() {
|
|||
</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>
|
||||
|
||||
</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 파라미터 설정 */}
|
||||
<div className="space-y-4">
|
||||
<div className="border-t pt-4">
|
||||
|
|
@ -771,7 +810,10 @@ export default function BatchManagementNewPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{fromApiUrl && fromApiKey && fromEndpoint && (
|
||||
{/* API URL + 엔드포인트는 필수, GET일 때만 API 키 필수 */}
|
||||
{fromApiUrl &&
|
||||
fromEndpoint &&
|
||||
(fromApiMethod !== "GET" || fromApiKey) && (
|
||||
<div className="space-y-3">
|
||||
<div className="p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-sm font-medium text-gray-700">API 호출 미리보기</div>
|
||||
|
|
@ -786,7 +828,11 @@ export default function BatchManagementNewPage() {
|
|||
: ''
|
||||
}
|
||||
</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 && (
|
||||
<div className="text-xs text-blue-600 mt-1">
|
||||
파라미터: {apiParamName} = {apiParamValue} ({apiParamSource === 'static' ? '고정값' : '동적값'})
|
||||
|
|
@ -980,172 +1026,33 @@ export default function BatchManagementNewPage() {
|
|||
|
||||
{/* 매핑 UI - 배치 타입별 동적 렌더링 */}
|
||||
{/* REST API → DB 매핑 */}
|
||||
{batchType === 'restapi-to-db' && fromApiFields.length > 0 && toColumns.length > 0 && (
|
||||
<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">
|
||||
{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>
|
||||
)}
|
||||
{batchType === "restapi-to-db" &&
|
||||
fromApiFields.length > 0 &&
|
||||
toColumns.length > 0 && (
|
||||
<RestApiToDbMappingCard
|
||||
fromApiFields={fromApiFields}
|
||||
toColumns={toColumns}
|
||||
fromApiData={fromApiData}
|
||||
apiFieldMappings={apiFieldMappings}
|
||||
setApiFieldMappings={setApiFieldMappings}
|
||||
apiFieldPathOverrides={apiFieldPathOverrides}
|
||||
setApiFieldPathOverrides={setApiFieldPathOverrides}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* DB → REST API 매핑 */}
|
||||
{batchType === 'db-to-restapi' && selectedColumns.length > 0 && toApiFields.length > 0 && (
|
||||
<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">
|
||||
{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>
|
||||
)}
|
||||
{batchType === "db-to-restapi" &&
|
||||
selectedColumns.length > 0 &&
|
||||
toApiFields.length > 0 && (
|
||||
<DbToRestApiMappingCard
|
||||
fromColumns={fromColumns}
|
||||
selectedColumns={selectedColumns}
|
||||
toApiFields={toApiFields}
|
||||
dbToApiFieldMapping={dbToApiFieldMapping}
|
||||
setDbToApiFieldMapping={setDbToApiFieldMapping}
|
||||
setToApiBody={setToApiBody}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* TO 설정 */}
|
||||
<Card>
|
||||
|
|
@ -1348,4 +1255,278 @@ export default function BatchManagementNewPage() {
|
|||
</Card>
|
||||
</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>
|
||||
);
|
||||
});
|
||||
|
|
@ -7,12 +7,24 @@ 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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { RefreshCw, Save, ArrowLeft, Plus, Trash2 } from "lucide-react";
|
||||
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 {
|
||||
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);
|
||||
|
||||
// REST API 미리보기 상태
|
||||
const [apiPreviewData, setApiPreviewData] = useState<any[]>([]);
|
||||
|
||||
|
||||
// 페이지 로드 시 배치 정보 조회
|
||||
useEffect(() => {
|
||||
|
|
@ -335,6 +350,86 @@ export default function BatchEditPage() {
|
|||
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 updatedMappings = mappings.filter((_, i) => i !== index);
|
||||
|
|
@ -404,14 +499,16 @@ export default function BatchEditPage() {
|
|||
<h1 className="text-3xl font-bold">배치 설정 수정</h1>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button onClick={loadBatchConfig} variant="outline" disabled={loading}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
<Button
|
||||
onClick={loadBatchConfig}
|
||||
variant="outline"
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
새로고침
|
||||
</Button>
|
||||
<Button onClick={saveBatchConfig} disabled={loading}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -580,22 +677,91 @@ export default function BatchEditPage() {
|
|||
</div>
|
||||
|
||||
{mappings.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>API URL</Label>
|
||||
<Input value={mappings[0]?.from_api_url || ''} readOnly />
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
{/* Request Body (JSON) 편집 UI */}
|
||||
<div>
|
||||
<Label>API 엔드포인트</Label>
|
||||
<Input value={mappings[0]?.from_table_name || ''} readOnly />
|
||||
<Label>Request Body (JSON)</Label>
|
||||
<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>
|
||||
<Label>HTTP 메서드</Label>
|
||||
<Input value={mappings[0]?.from_api_method || 'GET'} readOnly />
|
||||
</div>
|
||||
<div>
|
||||
<Label>대상 테이블</Label>
|
||||
<Input value={mappings[0]?.to_table_name || ''} readOnly />
|
||||
|
||||
{/* API 데이터 미리보기 */}
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
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>
|
||||
)}
|
||||
|
|
@ -647,6 +813,12 @@ export default function BatchEditPage() {
|
|||
매핑 추가
|
||||
</Button>
|
||||
)}
|
||||
{batchType === 'restapi-to-db' && (
|
||||
<Button onClick={addRestapiToDbMapping} size="sm">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
매핑 추가
|
||||
</Button>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -751,20 +923,73 @@ export default function BatchEditPage() {
|
|||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h4 className="font-medium">매핑 #{index + 1}</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
API 필드: {mapping.from_column_name} → DB 컬럼: {mapping.to_column_name}
|
||||
</p>
|
||||
{mapping.from_column_name && mapping.to_column_name && (
|
||||
<p className="text-sm text-gray-600">
|
||||
API 필드: {mapping.from_column_name} → DB 컬럼: {mapping.to_column_name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => removeMapping(index)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>API 필드명</Label>
|
||||
<Input value={mapping.from_column_name || ''} readOnly />
|
||||
<Label>API 필드명 (JSON 경로)</Label>
|
||||
<Input
|
||||
value={mapping.from_column_name || ""}
|
||||
onChange={(e) =>
|
||||
updateMapping(
|
||||
index,
|
||||
"from_column_name",
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
placeholder="response.access_token"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1108,14 +1108,11 @@ export default function TableManagementPage() {
|
|||
) : (
|
||||
<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="w-40 pr-4">컬럼명</div>
|
||||
<div className="w-48 px-4">라벨</div>
|
||||
<div className="w-48 pr-6">입력 타입</div>
|
||||
<div className="flex-1 pl-6" style={{ maxWidth: "calc(100% - 808px)" }}>
|
||||
상세 설정
|
||||
</div>
|
||||
<div className="w-80 pl-4">설명</div>
|
||||
<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="pr-4">컬럼명</div>
|
||||
<div className="px-4">라벨</div>
|
||||
<div className="pr-6">입력 타입</div>
|
||||
<div className="pl-4">설명</div>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 리스트 */}
|
||||
|
|
@ -1132,12 +1129,13 @@ export default function TableManagementPage() {
|
|||
{columns.map((column, index) => (
|
||||
<div
|
||||
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>
|
||||
<div className="w-48 px-4">
|
||||
<div className="px-4">
|
||||
<Input
|
||||
value={column.displayName || ""}
|
||||
onChange={(e) => handleLabelChange(column.columnName, e.target.value)}
|
||||
|
|
@ -1145,107 +1143,106 @@ export default function TableManagementPage() {
|
|||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-48 pr-6">
|
||||
<Select
|
||||
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" && (
|
||||
<div className="pr-6">
|
||||
<div className="space-y-3">
|
||||
{/* 입력 타입 선택 */}
|
||||
<Select
|
||||
value={column.codeCategory || "none"}
|
||||
onValueChange={(value) =>
|
||||
handleDetailSettingsChange(column.columnName, "code", value)
|
||||
}
|
||||
value={column.inputType || "text"}
|
||||
onValueChange={(value) => handleInputTypeChange(column.columnName, value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="공통코드 선택" />
|
||||
<SelectValue placeholder="입력 타입 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{commonCodeOptions.map((option, index) => (
|
||||
<SelectItem key={`code-${option.value}-${index}`} value={option.value}>
|
||||
{memoizedInputTypeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{/* 입력 타입이 'category'인 경우 2레벨 메뉴 다중 선택 */}
|
||||
{column.inputType === "category" && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-muted-foreground mb-1 block text-xs">
|
||||
적용할 메뉴 (2레벨)
|
||||
</label>
|
||||
<div className="border rounded-lg p-3 space-y-2 max-h-48 overflow-y-auto">
|
||||
{secondLevelMenus.length === 0 ? (
|
||||
<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);
|
||||
{/* 입력 타입이 'code'인 경우 공통코드 선택 */}
|
||||
{column.inputType === "code" && (
|
||||
<Select
|
||||
value={column.codeCategory || "none"}
|
||||
onValueChange={(value) =>
|
||||
handleDetailSettingsChange(column.columnName, "code", value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="공통코드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{commonCodeOptions.map((option, index) => (
|
||||
<SelectItem key={`code-${option.value}-${index}`} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{/* 입력 타입이 'category'인 경우 2레벨 메뉴 다중 선택 */}
|
||||
{column.inputType === "category" && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-muted-foreground mb-1 block text-xs">
|
||||
적용할 메뉴 (2레벨)
|
||||
</label>
|
||||
<div className="border rounded-lg p-3 space-y-2 max-h-48 overflow-y-auto">
|
||||
{secondLevelMenus.length === 0 ? (
|
||||
<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) =>
|
||||
prev.map((col) =>
|
||||
col.columnName === column.columnName
|
||||
? { ...col, categoryMenus: newMenus }
|
||||
: col
|
||||
)
|
||||
);
|
||||
}}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<label
|
||||
htmlFor={`category-menu-${column.columnName}-${menu.menuObjid}`}
|
||||
className="text-xs cursor-pointer flex-1"
|
||||
>
|
||||
{menu.parentMenuName} → {menu.menuName}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
setColumns((prev) =>
|
||||
prev.map((col) =>
|
||||
col.columnName === column.columnName
|
||||
? { ...col, categoryMenus: newMenus }
|
||||
: col
|
||||
)
|
||||
);
|
||||
}}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<label
|
||||
htmlFor={`category-menu-${column.columnName}-${menu.menuObjid}`}
|
||||
className="text-xs cursor-pointer flex-1"
|
||||
>
|
||||
{menu.parentMenuName} → {menu.menuName}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{column.categoryMenus && column.categoryMenus.length > 0 && (
|
||||
<p className="text-primary text-xs">
|
||||
{column.categoryMenus.length}개 메뉴 선택됨
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{column.categoryMenus && column.categoryMenus.length > 0 && (
|
||||
<p className="text-primary text-xs">
|
||||
{column.categoryMenus.length}개 메뉴 선택됨
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
|
||||
{column.inputType === "entity" && (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
)}
|
||||
{/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
|
||||
{column.inputType === "entity" && (
|
||||
<>
|
||||
{/* 참조 테이블 */}
|
||||
<div>
|
||||
<div className="w-48">
|
||||
<label className="text-muted-foreground mb-1 block text-xs">
|
||||
참조 테이블
|
||||
</label>
|
||||
|
|
@ -1255,7 +1252,7 @@ export default function TableManagementPage() {
|
|||
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="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -1278,7 +1275,7 @@ export default function TableManagementPage() {
|
|||
|
||||
{/* 조인 컬럼 */}
|
||||
{column.referenceTable && column.referenceTable !== "none" && (
|
||||
<div>
|
||||
<div className="w-48">
|
||||
<label className="text-muted-foreground mb-1 block text-xs">
|
||||
조인 컬럼
|
||||
</label>
|
||||
|
|
@ -1292,7 +1289,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="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -1324,7 +1321,7 @@ export default function TableManagementPage() {
|
|||
column.referenceTable !== "none" &&
|
||||
column.referenceColumn &&
|
||||
column.referenceColumn !== "none" && (
|
||||
<div>
|
||||
<div className="w-48">
|
||||
<label className="text-muted-foreground mb-1 block text-xs">
|
||||
표시 컬럼
|
||||
</label>
|
||||
|
|
@ -1338,7 +1335,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="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -1364,37 +1361,29 @@ export default function TableManagementPage() {
|
|||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 설정 완료 표시 */}
|
||||
{column.referenceTable &&
|
||||
column.referenceTable !== "none" &&
|
||||
column.referenceColumn &&
|
||||
column.referenceColumn !== "none" &&
|
||||
column.displayColumn &&
|
||||
column.displayColumn !== "none" && (
|
||||
<div className="bg-primary/10 text-primary mt-2 flex items-center gap-1 rounded px-2 py-1 text-xs">
|
||||
<span>✓</span>
|
||||
<span className="truncate">
|
||||
{column.columnName} → {column.referenceTable}.{column.displayColumn}
|
||||
</span>
|
||||
</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>
|
||||
)}
|
||||
{/* 설정 완료 표시 */}
|
||||
{column.referenceTable &&
|
||||
column.referenceTable !== "none" &&
|
||||
column.referenceColumn &&
|
||||
column.referenceColumn !== "none" &&
|
||||
column.displayColumn &&
|
||||
column.displayColumn !== "none" && (
|
||||
<div className="bg-primary/10 text-primary flex items-center gap-1 rounded px-2 py-1 text-xs w-48">
|
||||
<span>✓</span>
|
||||
<span className="truncate">설정 완료</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-80 pl-4">
|
||||
<div className="pl-4">
|
||||
<Input
|
||||
value={column.description || ""}
|
||||
onChange={(e) => handleColumnChange(index, "description", e.target.value)}
|
||||
placeholder="설명"
|
||||
className="h-8 text-xs"
|
||||
className="h-8 w-full text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1585,3 +1574,4 @@ export default function TableManagementPage() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -356,17 +356,6 @@ function ScreenViewPage() {
|
|||
return isButton;
|
||||
});
|
||||
|
||||
console.log(
|
||||
"🔍 메뉴에서 발견된 전체 버튼:",
|
||||
allButtons.map((b) => ({
|
||||
id: b.id,
|
||||
label: b.label,
|
||||
positionX: b.position.x,
|
||||
positionY: b.position.y,
|
||||
width: b.size?.width,
|
||||
height: b.size?.height,
|
||||
})),
|
||||
);
|
||||
|
||||
topLevelComponents.forEach((component) => {
|
||||
const isButton =
|
||||
|
|
@ -406,33 +395,13 @@ function ScreenViewPage() {
|
|||
(c) => (c as any).componentId === "table-search-widget",
|
||||
);
|
||||
|
||||
// 디버그: 모든 컴포넌트 타입 확인
|
||||
console.log(
|
||||
"🔍 전체 컴포넌트 타입:",
|
||||
regularComponents.map((c) => ({
|
||||
id: c.id,
|
||||
type: c.type,
|
||||
componentType: (c as any).componentType,
|
||||
componentId: (c as any).componentId,
|
||||
})),
|
||||
);
|
||||
|
||||
// 🆕 조건부 컨테이너들을 찾기
|
||||
// 조건부 컨테이너들을 찾기
|
||||
const conditionalContainers = regularComponents.filter(
|
||||
(c) =>
|
||||
(c as any).componentId === "conditional-container" ||
|
||||
(c as any).componentType === "conditional-container",
|
||||
);
|
||||
|
||||
console.log(
|
||||
"🔍 조건부 컨테이너 발견:",
|
||||
conditionalContainers.map((c) => ({
|
||||
id: c.id,
|
||||
y: c.position.y,
|
||||
size: c.size,
|
||||
})),
|
||||
);
|
||||
|
||||
// TableSearchWidget 및 조건부 컨테이너 높이 차이를 계산하여 Y 위치 조정
|
||||
const adjustedComponents = regularComponents.map((component) => {
|
||||
const isTableSearchWidget = (component as any).componentId === "table-search-widget";
|
||||
|
|
@ -520,12 +489,6 @@ function ScreenViewPage() {
|
|||
columnOrder={tableColumnOrder}
|
||||
tableDisplayData={tableDisplayData}
|
||||
onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder, tableDisplayData) => {
|
||||
console.log("🔍 화면에서 선택된 행 데이터:", selectedData);
|
||||
console.log("📊 정렬 정보:", { sortBy, sortOrder, columnOrder });
|
||||
console.log("📊 화면 표시 데이터:", {
|
||||
count: tableDisplayData?.length,
|
||||
firstRow: tableDisplayData?.[0],
|
||||
});
|
||||
setSelectedRowsData(selectedData);
|
||||
setTableSortBy(sortBy);
|
||||
setTableSortOrder(sortOrder || "asc");
|
||||
|
|
@ -604,12 +567,6 @@ function ScreenViewPage() {
|
|||
columnOrder,
|
||||
tableDisplayData,
|
||||
) => {
|
||||
console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData);
|
||||
console.log("📊 정렬 정보 (자식):", { sortBy, sortOrder, columnOrder });
|
||||
console.log("📊 화면 표시 데이터 (자식):", {
|
||||
count: tableDisplayData?.length,
|
||||
firstRow: tableDisplayData?.[0],
|
||||
});
|
||||
setSelectedRowsData(selectedData);
|
||||
setTableSortBy(sortBy);
|
||||
setTableSortOrder(sortOrder || "asc");
|
||||
|
|
@ -618,7 +575,6 @@ function ScreenViewPage() {
|
|||
}}
|
||||
refreshKey={tableRefreshKey}
|
||||
onRefresh={() => {
|
||||
console.log("🔄 테이블 새로고침 요청됨 (자식)");
|
||||
setTableRefreshKey((prev) => prev + 1);
|
||||
setSelectedRowsData([]); // 선택 해제
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -42,6 +42,7 @@ export function AuthenticationConfig({
|
|||
<SelectItem value="bearer">Bearer Token</SelectItem>
|
||||
<SelectItem value="basic">Basic Auth</SelectItem>
|
||||
<SelectItem value="oauth2">OAuth 2.0</SelectItem>
|
||||
<SelectItem value="db-token">DB 토큰</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
@ -192,6 +193,94 @@ export function AuthenticationConfig({
|
|||
</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">
|
||||
헤더 템플릿 (선택, {{value}} 치환)
|
||||
</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" && (
|
||||
<div className="rounded-md border border-dashed p-4 text-center text-sm text-gray-500">
|
||||
인증이 필요하지 않은 공개 API입니다.
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ const AUTH_TYPE_LABELS: Record<string, string> = {
|
|||
bearer: "Bearer",
|
||||
basic: "Basic Auth",
|
||||
oauth2: "OAuth 2.0",
|
||||
"db-token": "DB 토큰",
|
||||
};
|
||||
|
||||
// 활성 상태 옵션
|
||||
|
|
@ -158,6 +159,22 @@ export function RestApiConnectionList() {
|
|||
|
||||
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) {
|
||||
toast({
|
||||
title: "연결 성공",
|
||||
|
|
|
|||
|
|
@ -21,10 +21,13 @@ import {
|
|||
ExternalRestApiConnection,
|
||||
AuthType,
|
||||
RestApiTestResult,
|
||||
RestApiTestRequest,
|
||||
} from "@/lib/api/externalRestApiConnection";
|
||||
import { HeadersManager } from "./HeadersManager";
|
||||
import { AuthenticationConfig } from "./AuthenticationConfig";
|
||||
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 {
|
||||
isOpen: boolean;
|
||||
|
|
@ -42,6 +45,8 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
const [baseUrl, setBaseUrl] = useState("");
|
||||
const [endpointPath, setEndpointPath] = useState("");
|
||||
const [defaultHeaders, setDefaultHeaders] = useState<Record<string, string>>({});
|
||||
const [defaultMethod, setDefaultMethod] = useState("GET");
|
||||
const [defaultBody, setDefaultBody] = useState("");
|
||||
const [authType, setAuthType] = useState<AuthType>("none");
|
||||
const [authConfig, setAuthConfig] = useState<any>({});
|
||||
const [timeout, setTimeout] = useState(30000);
|
||||
|
|
@ -52,6 +57,8 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
// UI 상태
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [testEndpoint, setTestEndpoint] = useState("");
|
||||
const [testMethod, setTestMethod] = useState("GET");
|
||||
const [testBody, setTestBody] = useState("");
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<RestApiTestResult | null>(null);
|
||||
const [testRequestUrl, setTestRequestUrl] = useState<string>("");
|
||||
|
|
@ -65,12 +72,19 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
setBaseUrl(connection.base_url);
|
||||
setEndpointPath(connection.endpoint_path || "");
|
||||
setDefaultHeaders(connection.default_headers || {});
|
||||
setDefaultMethod(connection.default_method || "GET");
|
||||
setDefaultBody(connection.default_body || "");
|
||||
setAuthType(connection.auth_type);
|
||||
setAuthConfig(connection.auth_config || {});
|
||||
setTimeout(connection.timeout || 30000);
|
||||
setRetryCount(connection.retry_count || 0);
|
||||
setRetryDelay(connection.retry_delay || 1000);
|
||||
setIsActive(connection.is_active === "Y");
|
||||
|
||||
// 테스트 초기값 설정
|
||||
setTestEndpoint("");
|
||||
setTestMethod(connection.default_method || "GET");
|
||||
setTestBody(connection.default_body || "");
|
||||
} else {
|
||||
// 초기화
|
||||
setConnectionName("");
|
||||
|
|
@ -78,16 +92,22 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
setBaseUrl("");
|
||||
setEndpointPath("");
|
||||
setDefaultHeaders({ "Content-Type": "application/json" });
|
||||
setDefaultMethod("GET");
|
||||
setDefaultBody("");
|
||||
setAuthType("none");
|
||||
setAuthConfig({});
|
||||
setTimeout(30000);
|
||||
setRetryCount(0);
|
||||
setRetryDelay(1000);
|
||||
setIsActive(true);
|
||||
|
||||
// 테스트 초기값 설정
|
||||
setTestEndpoint("");
|
||||
setTestMethod("GET");
|
||||
setTestBody("");
|
||||
}
|
||||
|
||||
setTestResult(null);
|
||||
setTestEndpoint("");
|
||||
setTestRequestUrl("");
|
||||
}, [connection, isOpen]);
|
||||
|
||||
|
|
@ -111,14 +131,18 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
setTestRequestUrl(fullUrl);
|
||||
|
||||
try {
|
||||
const result = await ExternalRestApiConnectionAPI.testConnection({
|
||||
const testRequest: RestApiTestRequest = {
|
||||
base_url: baseUrl,
|
||||
endpoint: testEndpoint || undefined,
|
||||
method: testMethod as any,
|
||||
headers: defaultHeaders,
|
||||
body: testBody ? JSON.parse(testBody) : undefined,
|
||||
auth_type: authType,
|
||||
auth_config: authConfig,
|
||||
timeout,
|
||||
});
|
||||
};
|
||||
|
||||
const result = await ExternalRestApiConnectionAPI.testConnection(testRequest);
|
||||
|
||||
setTestResult(result);
|
||||
|
||||
|
|
@ -178,6 +202,20 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
return;
|
||||
}
|
||||
|
||||
// JSON 유효성 검증
|
||||
if (defaultBody && defaultMethod !== "GET" && defaultMethod !== "DELETE") {
|
||||
try {
|
||||
JSON.parse(defaultBody);
|
||||
} catch {
|
||||
toast({
|
||||
title: "입력 오류",
|
||||
description: "기본 Body가 올바른 JSON 형식이 아닙니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
|
|
@ -187,6 +225,8 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
base_url: baseUrl,
|
||||
endpoint_path: endpointPath || undefined,
|
||||
default_headers: defaultHeaders,
|
||||
default_method: defaultMethod,
|
||||
default_body: defaultBody || undefined,
|
||||
auth_type: authType,
|
||||
auth_config: authType === "none" ? undefined : authConfig,
|
||||
timeout,
|
||||
|
|
@ -262,12 +302,28 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
<Label htmlFor="base-url">
|
||||
기본 URL <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="base-url"
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder="https://api.example.com"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Select value={defaultMethod} onValueChange={setDefaultMethod}>
|
||||
<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">
|
||||
도메인 부분만 입력하세요 (예: https://apihub.kma.go.kr)
|
||||
</p>
|
||||
|
|
@ -286,6 +342,21 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
</p>
|
||||
</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">
|
||||
<Switch id="is-active" checked={isActive} onCheckedChange={setIsActive} />
|
||||
<Label htmlFor="is-active" className="cursor-pointer">
|
||||
|
|
@ -370,13 +441,45 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
<h3 className="text-sm font-semibold">연결 테스트</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="test-endpoint">테스트 엔드포인트 (선택)</Label>
|
||||
<Input
|
||||
id="test-endpoint"
|
||||
value={testEndpoint}
|
||||
onChange={(e) => setTestEndpoint(e.target.value)}
|
||||
placeholder="엔드포인트 또는 빈칸(기본 URL만 테스트)"
|
||||
/>
|
||||
<Label htmlFor="test-endpoint">테스트 설정</Label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<Select value={testMethod} onValueChange={setTestMethod}>
|
||||
<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="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>
|
||||
|
||||
<Button type="button" variant="outline" onClick={handleTest} disabled={testing}>
|
||||
|
|
@ -388,10 +491,22 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
{testRequestUrl && (
|
||||
<div className="bg-muted/30 space-y-3 rounded-md border p-3">
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1 text-xs font-medium">테스트 요청 URL</div>
|
||||
<code className="text-foreground block text-xs break-all">GET {testRequestUrl}</code>
|
||||
<div className="text-muted-foreground mb-1 text-xs font-medium">테스트 요청</div>
|
||||
<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>
|
||||
|
||||
{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 && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1 text-xs font-medium">요청 헤더</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
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 { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
|
@ -39,6 +39,77 @@ import {
|
|||
DialogTitle,
|
||||
} 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)
|
||||
interface DbObject {
|
||||
id: number;
|
||||
|
|
@ -550,10 +621,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
areaKey: obj.area_key,
|
||||
locaKey: obj.loca_key,
|
||||
locType: obj.loc_type,
|
||||
materialCount: obj.material_count,
|
||||
materialPreview: obj.material_preview_height
|
||||
? { height: parseFloat(obj.material_preview_height) }
|
||||
: undefined,
|
||||
materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
|
||||
materialPreview:
|
||||
obj.loc_type === "STP" || !obj.material_preview_height
|
||||
? undefined
|
||||
: { height: parseFloat(obj.material_preview_height) },
|
||||
parentId: obj.parent_id,
|
||||
displayOrder: obj.display_order,
|
||||
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 };
|
||||
|
||||
// Location 배치 시 자재 개수에 따라 높이 자동 설정
|
||||
// Location 배치 시 자재 개수에 따라 높이 자동 설정 (BED/TMP/DES만 대상, STP는 자재 미적재)
|
||||
if (
|
||||
(draggedTool === "location-bed" ||
|
||||
draggedTool === "location-stp" ||
|
||||
draggedTool === "location-temp" ||
|
||||
draggedTool === "location-dest") &&
|
||||
(draggedTool === "location-bed" || draggedTool === "location-temp" || draggedTool === "location-dest") &&
|
||||
locaKey &&
|
||||
selectedDbConnection &&
|
||||
hierarchyConfig?.material
|
||||
|
|
@ -877,12 +946,9 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
setDraggedAreaData(null);
|
||||
setDraggedLocationData(null);
|
||||
|
||||
// Location 배치 시 자재 개수 로드
|
||||
// Location 배치 시 자재 개수 로드 (BED/TMP/DES만 대상, STP는 자재 미적재)
|
||||
if (
|
||||
(draggedTool === "location-bed" ||
|
||||
draggedTool === "location-stp" ||
|
||||
draggedTool === "location-temp" ||
|
||||
draggedTool === "location-dest") &&
|
||||
(draggedTool === "location-bed" || draggedTool === "location-temp" || draggedTool === "location-dest") &&
|
||||
locaKey
|
||||
) {
|
||||
// 새 객체 추가 후 자재 개수 로드 (약간의 딜레이를 두어 state 업데이트 완료 후 실행)
|
||||
|
|
@ -965,13 +1031,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
loadLocationsForArea(obj.areaKey);
|
||||
setShowMaterialPanel(false);
|
||||
}
|
||||
// Location을 클릭한 경우, 해당 Location의 자재 목록 로드
|
||||
// Location을 클릭한 경우, 해당 Location의 자재 목록 로드 (STP는 자재 미적재이므로 제외)
|
||||
else if (
|
||||
obj &&
|
||||
(obj.type === "location-bed" ||
|
||||
obj.type === "location-stp" ||
|
||||
obj.type === "location-temp" ||
|
||||
obj.type === "location-dest") &&
|
||||
(obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") &&
|
||||
obj.locaKey &&
|
||||
selectedDbConnection
|
||||
) {
|
||||
|
|
@ -988,9 +1051,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
try {
|
||||
const response = await getMaterialCounts(selectedDbConnection, selectedTables.material, locaKeys);
|
||||
if (response.success && response.data) {
|
||||
// 각 Location 객체에 자재 개수 업데이트
|
||||
// 각 Location 객체에 자재 개수 업데이트 (STP는 자재 미적재이므로 제외)
|
||||
setPlacedObjects((prev) =>
|
||||
prev.map((obj) => {
|
||||
if (
|
||||
!obj.locaKey ||
|
||||
obj.type === "location-stp" // STP는 자재 없음
|
||||
) {
|
||||
return obj;
|
||||
}
|
||||
const materialCount = response.data?.find((mc) => mc.LOCAKEY === obj.locaKey);
|
||||
if (materialCount) {
|
||||
return {
|
||||
|
|
@ -1278,7 +1347,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
const oldSize = actualObject.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.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,
|
||||
locaKey: obj.loca_key,
|
||||
locType: obj.loc_type,
|
||||
materialCount: obj.material_count,
|
||||
materialPreview: obj.material_preview_height
|
||||
? { height: parseFloat(obj.material_preview_height) }
|
||||
: undefined,
|
||||
materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
|
||||
materialPreview:
|
||||
obj.loc_type === "STP" || !obj.material_preview_height
|
||||
? undefined
|
||||
: { height: parseFloat(obj.material_preview_height) },
|
||||
parentId: obj.parent_id,
|
||||
displayOrder: obj.display_order,
|
||||
locked: obj.locked,
|
||||
|
|
@ -1798,6 +1868,8 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
</div>
|
||||
{isLocationPlaced ? (
|
||||
<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" />
|
||||
)}
|
||||
|
|
@ -2069,10 +2141,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
<Label htmlFor="object-name" className="text-sm">
|
||||
이름
|
||||
</Label>
|
||||
<Input
|
||||
<DebouncedInput
|
||||
id="object-name"
|
||||
value={selectedObject.name || ""}
|
||||
onChange={(e) => handleObjectUpdate({ name: e.target.value })}
|
||||
onCommit={(val) => handleObjectUpdate({ name: val })}
|
||||
className="mt-1.5 h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -2085,15 +2157,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
<Label htmlFor="pos-x" className="text-muted-foreground text-xs">
|
||||
X
|
||||
</Label>
|
||||
<Input
|
||||
<DebouncedInput
|
||||
id="pos-x"
|
||||
type="number"
|
||||
value={(selectedObject.position?.x || 0).toFixed(1)}
|
||||
onChange={(e) =>
|
||||
onCommit={(val) =>
|
||||
handleObjectUpdate({
|
||||
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">
|
||||
Z
|
||||
</Label>
|
||||
<Input
|
||||
<DebouncedInput
|
||||
id="pos-z"
|
||||
type="number"
|
||||
value={(selectedObject.position?.z || 0).toFixed(1)}
|
||||
onChange={(e) =>
|
||||
onCommit={(val) =>
|
||||
handleObjectUpdate({
|
||||
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">
|
||||
W (5 단위)
|
||||
</Label>
|
||||
<Input
|
||||
<DebouncedInput
|
||||
id="size-x"
|
||||
type="number"
|
||||
step="5"
|
||||
min="5"
|
||||
value={selectedObject.size?.x || 5}
|
||||
onChange={(e) =>
|
||||
onCommit={(val) =>
|
||||
handleObjectUpdate({
|
||||
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">
|
||||
H
|
||||
</Label>
|
||||
<Input
|
||||
<DebouncedInput
|
||||
id="size-y"
|
||||
type="number"
|
||||
value={selectedObject.size?.y || 5}
|
||||
onChange={(e) =>
|
||||
onCommit={(val) =>
|
||||
handleObjectUpdate({
|
||||
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">
|
||||
D (5 단위)
|
||||
</Label>
|
||||
<Input
|
||||
<DebouncedInput
|
||||
id="size-z"
|
||||
type="number"
|
||||
step="5"
|
||||
min="5"
|
||||
value={selectedObject.size?.z || 5}
|
||||
onChange={(e) =>
|
||||
onCommit={(val) =>
|
||||
handleObjectUpdate({
|
||||
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>
|
||||
<Input
|
||||
<DebouncedInput
|
||||
id="object-color"
|
||||
type="color"
|
||||
debounce={100}
|
||||
value={selectedObject.color || "#3b82f6"}
|
||||
onChange={(e) => handleObjectUpdate({ color: e.target.value })}
|
||||
onCommit={(val) => handleObjectUpdate({ color: val })}
|
||||
className="mt-1.5 h-9"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
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 { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -87,10 +87,11 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
areaKey: obj.area_key,
|
||||
locaKey: obj.loca_key,
|
||||
locType: obj.loc_type,
|
||||
materialCount: obj.material_count,
|
||||
materialPreview: obj.material_preview_height
|
||||
? { height: parseFloat(obj.material_preview_height) }
|
||||
: undefined,
|
||||
materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
|
||||
materialPreview:
|
||||
obj.loc_type === "STP" || !obj.material_preview_height
|
||||
? undefined
|
||||
: { height: parseFloat(obj.material_preview_height) },
|
||||
parentId: obj.parent_id,
|
||||
displayOrder: obj.display_order,
|
||||
locked: obj.locked,
|
||||
|
|
@ -166,13 +167,10 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
const obj = placedObjects.find((o) => o.id === objectId);
|
||||
setSelectedObject(obj || null);
|
||||
|
||||
// Location을 클릭한 경우, 자재 정보 표시
|
||||
// Location을 클릭한 경우, 자재 정보 표시 (STP는 자재 미적재이므로 제외)
|
||||
if (
|
||||
obj &&
|
||||
(obj.type === "location-bed" ||
|
||||
obj.type === "location-stp" ||
|
||||
obj.type === "location-temp" ||
|
||||
obj.type === "location-dest") &&
|
||||
(obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") &&
|
||||
obj.locaKey &&
|
||||
externalDbConnectionId
|
||||
) {
|
||||
|
|
@ -363,59 +361,59 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
// Area가 없으면 기존 평면 리스트 유지
|
||||
if (areaObjects.length === 0) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{filteredObjects.map((obj) => {
|
||||
let typeLabel = obj.type;
|
||||
if (obj.type === "location-bed") typeLabel = "베드(BED)";
|
||||
else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)";
|
||||
else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)";
|
||||
else if (obj.type === "location-dest") typeLabel = "지정착지(DES)";
|
||||
else if (obj.type === "crane-mobile") typeLabel = "크레인";
|
||||
else if (obj.type === "area") typeLabel = "Area";
|
||||
else if (obj.type === "rack") typeLabel = "랙";
|
||||
<div className="space-y-2">
|
||||
{filteredObjects.map((obj) => {
|
||||
let typeLabel = obj.type;
|
||||
if (obj.type === "location-bed") typeLabel = "베드(BED)";
|
||||
else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)";
|
||||
else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)";
|
||||
else if (obj.type === "location-dest") typeLabel = "지정착지(DES)";
|
||||
else if (obj.type === "crane-mobile") typeLabel = "크레인";
|
||||
else if (obj.type === "area") typeLabel = "Area";
|
||||
else if (obj.type === "rack") typeLabel = "랙";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={obj.id}
|
||||
onClick={() => handleObjectClick(obj.id)}
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{obj.name}</p>
|
||||
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: obj.color }}
|
||||
/>
|
||||
<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>
|
||||
return (
|
||||
<div
|
||||
key={obj.id}
|
||||
onClick={() => handleObjectClick(obj.id)}
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{obj.name}</p>
|
||||
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: obj.color }}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 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>
|
||||
</div>
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -131,13 +131,13 @@ export default function HierarchyConfigPanel({
|
|||
try {
|
||||
await Promise.all(
|
||||
tablesToFetch.map(async (tableName) => {
|
||||
try {
|
||||
const columns = await onLoadColumns(tableName);
|
||||
const normalized = normalizeColumns(columns);
|
||||
setColumnsCache((prev) => ({ ...prev, [tableName]: normalized }));
|
||||
} catch (error) {
|
||||
console.error(`컬럼 로드 실패 (${tableName}):`, error);
|
||||
}
|
||||
try {
|
||||
const columns = await onLoadColumns(tableName);
|
||||
const normalized = normalizeColumns(columns);
|
||||
setColumnsCache((prev) => ({ ...prev, [tableName]: normalized }));
|
||||
} catch (error) {
|
||||
console.error(`컬럼 로드 실패 (${tableName}):`, error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -593,52 +593,58 @@ function MaterialBox({
|
|||
);
|
||||
|
||||
case "location-stp":
|
||||
// 정차포인트(STP): 주황색 낮은 플랫폼
|
||||
return (
|
||||
<>
|
||||
<Box args={[boxWidth, boxHeight, boxDepth]}>
|
||||
<meshStandardMaterial
|
||||
color={placement.color}
|
||||
roughness={0.6}
|
||||
metalness={0.2}
|
||||
emissive={isSelected ? placement.color : "#000000"}
|
||||
emissiveIntensity={isSelected ? glowIntensity * 0.8 : 0}
|
||||
/>
|
||||
</Box>
|
||||
// 정차포인트(STP): 회색 타원형 플랫폼 + 'P' 마크 (자재 미적재 영역)
|
||||
{
|
||||
const baseRadius = 0.5; // 스케일로 실제 W/D를 반영 (타원형)
|
||||
const labelFontSize = Math.min(boxWidth, boxDepth) * 0.15;
|
||||
const iconFontSize = Math.min(boxWidth, boxDepth) * 0.3;
|
||||
|
||||
{/* Location 이름 */}
|
||||
{placement.name && (
|
||||
return (
|
||||
<>
|
||||
{/* 타원형 플랫폼: 단위 실린더를 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
|
||||
position={[0, boxHeight / 2 + 0.3, 0]}
|
||||
position={[0, boxHeight / 2 + 0.05, 0]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
fontSize={Math.min(boxWidth, boxDepth) * 0.15}
|
||||
fontSize={iconFontSize}
|
||||
color="#ffffff"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0.03}
|
||||
outlineWidth={0.08}
|
||||
outlineColor="#000000"
|
||||
>
|
||||
{placement.name}
|
||||
P
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 자재 개수 (STP는 정차포인트라 자재가 없을 수 있음) */}
|
||||
{placement.material_count !== undefined && placement.material_count > 0 && (
|
||||
<Text
|
||||
position={[0, boxHeight / 2 + 0.6, 0]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
fontSize={Math.min(boxWidth, boxDepth) * 0.12}
|
||||
color="#fbbf24"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0.03}
|
||||
outlineColor="#000000"
|
||||
>
|
||||
{`자재: ${placement.material_count}개`}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
{/* Location 이름 */}
|
||||
{placement.name && (
|
||||
<Text
|
||||
position={[0, boxHeight / 2 + 0.4, 0]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
fontSize={labelFontSize}
|
||||
color="#ffffff"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0.03}
|
||||
outlineColor="#000000"
|
||||
>
|
||||
{placement.name}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// case "gantry-crane":
|
||||
// // 겐트리 크레인: 기둥 2개 + 상단 빔
|
||||
|
|
@ -1098,10 +1104,12 @@ function Scene({
|
|||
orbitControlsRef={orbitControlsRef}
|
||||
/>
|
||||
|
||||
{/* 조명 */}
|
||||
<ambientLight intensity={0.5} />
|
||||
<directionalLight position={[10, 10, 5]} intensity={1} />
|
||||
<directionalLight position={[-10, -10, -5]} intensity={0.3} />
|
||||
{/* 조명 - 전체적으로 밝게 조정 */}
|
||||
<ambientLight intensity={0.9} />
|
||||
<directionalLight position={[10, 20, 10]} intensity={1.2} />
|
||||
<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"]} />
|
||||
|
|
|
|||
|
|
@ -164,3 +164,4 @@ export function getAllDescendants(
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -152,7 +152,7 @@ export const NumberingRuleDesigner: React.FC<NumberingRuleDesignerProps> = ({
|
|||
const ruleToSave = {
|
||||
...currentRule,
|
||||
scopeType: "table" as const, // ⚠️ 임시: DB 제약 조건 때문에 table 유지
|
||||
tableName: currentTableName || currentRule.tableName || "", // 현재 테이블명 자동 설정
|
||||
tableName: currentTableName || currentRule.tableName || null, // 현재 테이블명 자동 설정 (빈 값은 null)
|
||||
menuObjid: menuObjid || currentRule.menuObjid || null, // 🆕 메뉴 OBJID 설정 (필터링용)
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -75,6 +75,13 @@ const ORDER_COLUMNS: RepeaterColumnConfig[] = [
|
|||
calculated: true,
|
||||
width: "120px",
|
||||
},
|
||||
{
|
||||
field: "order_date",
|
||||
label: "수주일",
|
||||
type: "date",
|
||||
editable: true,
|
||||
width: "130px",
|
||||
},
|
||||
{
|
||||
field: "delivery_date",
|
||||
label: "납기일",
|
||||
|
|
|
|||
|
|
@ -64,6 +64,9 @@ export function OrderRegistrationModal({
|
|||
// 선택된 품목 목록
|
||||
const [selectedItems, setSelectedItems] = useState<any[]>([]);
|
||||
|
||||
// 납기일 일괄 적용 플래그 (딱 한 번만 실행)
|
||||
const [isDeliveryDateApplied, setIsDeliveryDateApplied] = useState(false);
|
||||
|
||||
// 저장 중
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
|
|
@ -158,6 +161,45 @@ export function OrderRegistrationModal({
|
|||
hsCode: "",
|
||||
});
|
||||
setSelectedItems([]);
|
||||
setIsDeliveryDateApplied(false); // 플래그 초기화
|
||||
};
|
||||
|
||||
// 품목 목록 변경 핸들러 (납기일 일괄 적용 로직 포함)
|
||||
const handleItemsChange = (newItems: any[]) => {
|
||||
// 1️⃣ 플래그가 이미 true면 그냥 업데이트만 (일괄 적용 완료 상태)
|
||||
if (isDeliveryDateApplied) {
|
||||
setSelectedItems(newItems);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2️⃣ 품목이 없으면 그냥 업데이트
|
||||
if (newItems.length === 0) {
|
||||
setSelectedItems(newItems);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3️⃣ 현재 상태: 납기일이 있는 행과 없는 행 개수 체크
|
||||
const itemsWithDate = newItems.filter((item) => item.delivery_date);
|
||||
const itemsWithoutDate = newItems.filter((item) => !item.delivery_date);
|
||||
|
||||
// 4️⃣ 조건: 정확히 1개만 날짜가 있고, 나머지는 모두 비어있을 때 일괄 적용
|
||||
if (itemsWithDate.length === 1 && itemsWithoutDate.length > 0) {
|
||||
// 5️⃣ 전체 일괄 적용
|
||||
const selectedDate = itemsWithDate[0].delivery_date;
|
||||
const updatedItems = newItems.map((item) => ({
|
||||
...item,
|
||||
delivery_date: selectedDate, // 모든 행에 동일한 납기일 적용
|
||||
}));
|
||||
|
||||
setSelectedItems(updatedItems);
|
||||
setIsDeliveryDateApplied(true); // 플래그 활성화 (다음부터는 일괄 적용 안 함)
|
||||
|
||||
console.log("✅ 납기일 일괄 적용 완료:", selectedDate);
|
||||
console.log(` - 대상: ${itemsWithoutDate.length}개 행에 ${selectedDate} 적용`);
|
||||
} else {
|
||||
// 그냥 업데이트
|
||||
setSelectedItems(newItems);
|
||||
}
|
||||
};
|
||||
|
||||
// 전체 금액 계산
|
||||
|
|
@ -338,7 +380,7 @@ export function OrderRegistrationModal({
|
|||
<Label className="text-xs sm:text-sm">추가된 품목</Label>
|
||||
<OrderItemRepeaterTable
|
||||
value={selectedItems}
|
||||
onChange={setSelectedItems}
|
||||
onChange={handleItemsChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -316,6 +316,33 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
screenId: modalState.screenId,
|
||||
});
|
||||
|
||||
// 🆕 날짜 필드 정규화 함수 (YYYY-MM-DD 형식으로 변환)
|
||||
const normalizeDateField = (value: any): string | null => {
|
||||
if (!value) return null;
|
||||
|
||||
// ISO 8601 형식 (2025-11-26T00:00:00.000Z) 또는 Date 객체
|
||||
if (value instanceof Date || typeof value === "string") {
|
||||
try {
|
||||
const date = new Date(value);
|
||||
if (isNaN(date.getTime())) return null;
|
||||
|
||||
// YYYY-MM-DD 형식으로 변환
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
} catch (error) {
|
||||
console.warn("날짜 변환 실패:", value, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// 날짜 필드 목록
|
||||
const dateFields = ["item_due_date", "delivery_date", "due_date", "order_date"];
|
||||
|
||||
let insertedCount = 0;
|
||||
let updatedCount = 0;
|
||||
let deletedCount = 0;
|
||||
|
|
@ -333,6 +360,17 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
|
||||
delete insertData.id; // id는 자동 생성되므로 제거
|
||||
|
||||
// 🆕 날짜 필드 정규화 (YYYY-MM-DD 형식으로 변환)
|
||||
dateFields.forEach((fieldName) => {
|
||||
if (insertData[fieldName]) {
|
||||
const normalizedDate = normalizeDateField(insertData[fieldName]);
|
||||
if (normalizedDate) {
|
||||
insertData[fieldName] = normalizedDate;
|
||||
console.log(`📅 [날짜 정규화] ${fieldName}: ${currentData[fieldName]} → ${normalizedDate}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 🆕 groupByColumns의 값을 강제로 포함 (order_no 등)
|
||||
if (modalState.groupByColumns && modalState.groupByColumns.length > 0) {
|
||||
modalState.groupByColumns.forEach((colName) => {
|
||||
|
|
@ -348,23 +386,32 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
// 🆕 공통 필드 추가 (거래처, 담당자, 납품처, 메모 등)
|
||||
// formData에서 품목별 필드가 아닌 공통 필드를 복사
|
||||
const commonFields = [
|
||||
'partner_id', // 거래처
|
||||
'manager_id', // 담당자
|
||||
'delivery_partner_id', // 납품처
|
||||
'delivery_address', // 납품장소
|
||||
'memo', // 메모
|
||||
'order_date', // 주문일
|
||||
'due_date', // 납기일
|
||||
'shipping_method', // 배송방법
|
||||
'status', // 상태
|
||||
'sales_type', // 영업유형
|
||||
"partner_id", // 거래처
|
||||
"manager_id", // 담당자
|
||||
"delivery_partner_id", // 납품처
|
||||
"delivery_address", // 납품장소
|
||||
"memo", // 메모
|
||||
"order_date", // 주문일
|
||||
"due_date", // 납기일
|
||||
"shipping_method", // 배송방법
|
||||
"status", // 상태
|
||||
"sales_type", // 영업유형
|
||||
];
|
||||
|
||||
commonFields.forEach((fieldName) => {
|
||||
// formData에 값이 있으면 추가
|
||||
if (formData[fieldName] !== undefined && formData[fieldName] !== null) {
|
||||
insertData[fieldName] = formData[fieldName];
|
||||
console.log(`🔗 [공통 필드] ${fieldName} 값 추가:`, formData[fieldName]);
|
||||
// 날짜 필드인 경우 정규화
|
||||
if (dateFields.includes(fieldName)) {
|
||||
const normalizedDate = normalizeDateField(formData[fieldName]);
|
||||
if (normalizedDate) {
|
||||
insertData[fieldName] = normalizedDate;
|
||||
console.log(`🔗 [공통 필드 - 날짜] ${fieldName} 값 추가:`, normalizedDate);
|
||||
}
|
||||
} else {
|
||||
insertData[fieldName] = formData[fieldName];
|
||||
console.log(`🔗 [공통 필드] ${fieldName} 값 추가:`, formData[fieldName]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -404,8 +451,15 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
}
|
||||
|
||||
// 🆕 값 정규화 함수 (타입 통일)
|
||||
const normalizeValue = (val: any): any => {
|
||||
const normalizeValue = (val: any, fieldName?: string): any => {
|
||||
if (val === null || val === undefined || val === "") return null;
|
||||
|
||||
// 날짜 필드인 경우 YYYY-MM-DD 형식으로 정규화
|
||||
if (fieldName && dateFields.includes(fieldName)) {
|
||||
const normalizedDate = normalizeDateField(val);
|
||||
return normalizedDate;
|
||||
}
|
||||
|
||||
if (typeof val === "string" && !isNaN(Number(val))) {
|
||||
// 숫자로 변환 가능한 문자열은 숫자로
|
||||
return Number(val);
|
||||
|
|
@ -422,13 +476,14 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
}
|
||||
|
||||
// 🆕 타입 정규화 후 비교
|
||||
const currentValue = normalizeValue(currentData[key]);
|
||||
const originalValue = normalizeValue(originalItemData[key]);
|
||||
const currentValue = normalizeValue(currentData[key], key);
|
||||
const originalValue = normalizeValue(originalItemData[key], key);
|
||||
|
||||
// 값이 변경된 경우만 포함
|
||||
if (currentValue !== originalValue) {
|
||||
console.log(`🔍 [품목 수정 감지] ${key}: ${originalValue} → ${currentValue}`);
|
||||
changedData[key] = currentData[key]; // 원본 값 사용 (문자열 그대로)
|
||||
// 날짜 필드는 정규화된 값 사용, 나머지는 원본 값 사용
|
||||
changedData[key] = dateFields.includes(key) ? currentValue : currentData[key];
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -631,13 +686,6 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
|
|||
maxHeight: "100%",
|
||||
}}
|
||||
>
|
||||
{/* 🆕 그룹 데이터가 있으면 안내 메시지 표시 */}
|
||||
{groupData.length > 1 && (
|
||||
<div className="absolute left-4 top-4 z-10 rounded-md bg-blue-50 px-3 py-2 text-xs text-blue-700 shadow-sm">
|
||||
{groupData.length}개의 관련 품목을 함께 수정합니다
|
||||
</div>
|
||||
)}
|
||||
|
||||
{screenData.components.map((component) => {
|
||||
// 컴포넌트 위치를 offset만큼 조정
|
||||
const offsetX = screenDimensions?.offsetX || 0;
|
||||
|
|
|
|||
|
|
@ -433,7 +433,10 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
|||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<TabsWidget component={tabsComponent as any} />
|
||||
<TabsWidget
|
||||
component={tabsComponent as any}
|
||||
menuObjid={menuObjid} // 🆕 부모의 menuObjid 전달
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ interface InteractiveScreenViewerProps {
|
|||
id: number;
|
||||
tableName?: string;
|
||||
};
|
||||
menuObjid?: number; // 🆕 메뉴 OBJID (코드 스코프용)
|
||||
onSave?: () => Promise<void>;
|
||||
onRefresh?: () => void;
|
||||
onFlowRefresh?: () => void;
|
||||
|
|
@ -61,6 +62,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
onFormDataChange,
|
||||
hideLabel = false,
|
||||
screenInfo,
|
||||
menuObjid,
|
||||
onSave,
|
||||
onRefresh,
|
||||
onFlowRefresh,
|
||||
|
|
@ -332,6 +334,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
onFormDataChange={handleFormDataChange}
|
||||
screenId={screenInfo?.id}
|
||||
tableName={screenInfo?.tableName}
|
||||
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
||||
userId={user?.userId} // ✅ 사용자 ID 전달
|
||||
userName={user?.userName} // ✅ 사용자 이름 전달
|
||||
companyCode={user?.companyCode} // ✅ 회사 코드 전달
|
||||
|
|
|
|||
|
|
@ -401,22 +401,10 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
|
||||
// 컴포넌트 스타일 계산
|
||||
const isFlowWidget = type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget");
|
||||
const isSectionPaper = type === "component" && (component as any).componentConfig?.type === "section-paper";
|
||||
|
||||
// 높이 결정 로직
|
||||
let finalHeight = size?.height || 10;
|
||||
if (isFlowWidget && actualHeight) {
|
||||
finalHeight = actualHeight;
|
||||
}
|
||||
|
||||
// 🔍 디버깅: position.x 값 확인
|
||||
const positionX = position?.x || 0;
|
||||
console.log("🔍 RealtimePreview componentStyle 설정:", {
|
||||
componentId: id,
|
||||
positionX,
|
||||
sizeWidth: size?.width,
|
||||
styleWidth: style?.width,
|
||||
willUse100Percent: positionX === 0,
|
||||
});
|
||||
const positionY = position?.y || 0;
|
||||
|
||||
// 너비 결정 로직: style.width (퍼센트) > 조건부 100% > size.width (픽셀)
|
||||
const getWidth = () => {
|
||||
|
|
@ -432,20 +420,35 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
return size?.width || 200;
|
||||
};
|
||||
|
||||
// 높이 결정 로직: style.height > actualHeight (Flow Widget) > size.height
|
||||
const getHeight = () => {
|
||||
// 1순위: style.height가 있으면 우선 사용 (픽셀/퍼센트 값)
|
||||
if (style?.height) {
|
||||
return style.height;
|
||||
}
|
||||
// 2순위: Flow Widget의 실제 측정 높이
|
||||
if (isFlowWidget && actualHeight) {
|
||||
return actualHeight;
|
||||
}
|
||||
// 3순위: size.height 픽셀 값
|
||||
return size?.height || 10;
|
||||
};
|
||||
|
||||
const componentStyle = {
|
||||
position: "absolute" as const,
|
||||
...style, // 먼저 적용하고
|
||||
left: positionX,
|
||||
top: position?.y || 0,
|
||||
top: positionY,
|
||||
width: getWidth(), // 우선순위에 따른 너비
|
||||
height: finalHeight,
|
||||
height: getHeight(), // 우선순위에 따른 높이
|
||||
zIndex: position?.z || 1,
|
||||
// right 속성 강제 제거
|
||||
right: undefined,
|
||||
};
|
||||
|
||||
// 선택된 컴포넌트 스타일
|
||||
const selectionStyle = isSelected
|
||||
// Section Paper는 자체적으로 선택 상태 테두리를 처리하므로 outline 제거
|
||||
const selectionStyle = isSelected && !isSectionPaper
|
||||
? {
|
||||
outline: "2px solid rgb(59, 130, 246)",
|
||||
outlineOffset: "2px",
|
||||
|
|
@ -628,6 +631,24 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 컴포넌트 타입 - 레지스트리 기반 렌더링 (Section Paper, Section Card 등) */}
|
||||
{type === "component" && (() => {
|
||||
const { DynamicComponentRenderer } = require("@/lib/registry/DynamicComponentRenderer");
|
||||
return (
|
||||
<DynamicComponentRenderer
|
||||
component={component}
|
||||
isSelected={isSelected}
|
||||
isDesignMode={isDesignMode}
|
||||
onClick={onClick}
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
{...restProps}
|
||||
>
|
||||
{children}
|
||||
</DynamicComponentRenderer>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 위젯 타입 - 동적 렌더링 (파일 컴포넌트 제외) */}
|
||||
{type === "widget" && !isFileComponent(component) && (
|
||||
<div className="h-full w-full">
|
||||
|
|
|
|||
|
|
@ -4603,10 +4603,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
});
|
||||
}}
|
||||
>
|
||||
{/* 컨테이너, 그룹, 영역의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */}
|
||||
{/* 컨테이너, 그룹, 영역, 컴포넌트의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */}
|
||||
{(component.type === "group" ||
|
||||
component.type === "container" ||
|
||||
component.type === "area") &&
|
||||
component.type === "area" ||
|
||||
component.type === "component") &&
|
||||
layout.components
|
||||
.filter((child) => child.parentId === component.id)
|
||||
.map((child) => {
|
||||
|
|
|
|||
|
|
@ -47,6 +47,9 @@ import dynamic from "next/dynamic";
|
|||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||
import { DynamicWebTypeRenderer } from "@/lib/registry";
|
||||
import { isFileComponent, getComponentWebType } from "@/lib/utils/componentTypeUtils";
|
||||
import { TableOptionsProvider } from "@/contexts/TableOptionsContext";
|
||||
import { RealtimePreview } from "./RealtimePreviewDynamic";
|
||||
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
|
||||
|
||||
// InteractiveScreenViewer를 동적으로 import (SSR 비활성화)
|
||||
const InteractiveScreenViewer = dynamic(
|
||||
|
|
@ -1315,24 +1318,40 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
<DialogHeader>
|
||||
<DialogTitle>화면 미리보기 - {screenToPreview?.screenName}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-1 items-center justify-center overflow-hidden bg-gradient-to-br from-gray-50 to-slate-100 p-6">
|
||||
{isLoadingPreview ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-lg font-medium">레이아웃 로딩 중...</div>
|
||||
<div className="text-muted-foreground text-sm">화면 정보를 불러오고 있습니다.</div>
|
||||
<ScreenPreviewProvider isPreviewMode={true}>
|
||||
<TableOptionsProvider>
|
||||
<div className="flex flex-1 items-center justify-center overflow-hidden bg-gradient-to-br from-gray-50 to-slate-100 p-6">
|
||||
{isLoadingPreview ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-lg font-medium">레이아웃 로딩 중...</div>
|
||||
<div className="text-muted-foreground text-sm">화면 정보를 불러오고 있습니다.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : previewLayout && previewLayout.components ? (
|
||||
) : previewLayout && previewLayout.components ? (
|
||||
(() => {
|
||||
const screenWidth = previewLayout.screenResolution?.width || 1200;
|
||||
const screenHeight = previewLayout.screenResolution?.height || 800;
|
||||
|
||||
// 모달 내부 가용 공간 계산 (헤더, 푸터, 패딩 제외)
|
||||
const availableWidth = typeof window !== "undefined" ? window.innerWidth * 0.95 - 100 : 1800; // 95vw - 패딩
|
||||
const modalPadding = 100; // 헤더 + 푸터 + 패딩
|
||||
const availableWidth = typeof window !== "undefined" ? window.innerWidth * 0.95 - modalPadding : 1700;
|
||||
const availableHeight = typeof window !== "undefined" ? window.innerHeight * 0.95 - modalPadding : 900;
|
||||
|
||||
// 가로폭 기준으로 스케일 계산 (가로폭에 맞춤)
|
||||
const scale = availableWidth / screenWidth;
|
||||
// 가로/세로 비율을 모두 고려하여 작은 쪽에 맞춤 (화면이 잘리지 않도록)
|
||||
const scaleX = availableWidth / screenWidth;
|
||||
const scaleY = availableHeight / screenHeight;
|
||||
const scale = Math.min(scaleX, scaleY, 1); // 최대 1배율 (확대 방지)
|
||||
|
||||
console.log("📐 미리보기 스케일 계산:", {
|
||||
screenWidth,
|
||||
screenHeight,
|
||||
availableWidth,
|
||||
availableHeight,
|
||||
scaleX,
|
||||
scaleY,
|
||||
finalScale: scale,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -1414,115 +1433,61 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
);
|
||||
}
|
||||
|
||||
// 라벨 표시 여부 계산
|
||||
const templateTypes = ["datatable"];
|
||||
const shouldShowLabel =
|
||||
component.style?.labelDisplay !== false &&
|
||||
(component.label || component.style?.labelText) &&
|
||||
!templateTypes.includes(component.type);
|
||||
|
||||
const labelText = component.style?.labelText || component.label || "";
|
||||
const labelStyle = {
|
||||
fontSize: component.style?.labelFontSize || "14px",
|
||||
color: component.style?.labelColor || "#212121",
|
||||
fontWeight: component.style?.labelFontWeight || "500",
|
||||
backgroundColor: component.style?.labelBackgroundColor || "transparent",
|
||||
};
|
||||
const labelMarginBottom = component.style?.labelMarginBottom || "4px";
|
||||
|
||||
// 일반 컴포넌트 렌더링
|
||||
// 일반 컴포넌트 렌더링 - RealtimePreview 사용 (실제 화면과 동일)
|
||||
return (
|
||||
<div key={component.id}>
|
||||
{/* 라벨을 외부에 별도로 렌더링 */}
|
||||
{shouldShowLabel && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${component.position.x}px`,
|
||||
top: `${component.position.y - 25}px`, // 컴포넌트 위쪽에 라벨 배치
|
||||
zIndex: (component.position.z || 1) + 1,
|
||||
...labelStyle,
|
||||
}}
|
||||
>
|
||||
{labelText}
|
||||
{component.required && <span style={{ color: "#f97316", marginLeft: "2px" }}>*</span>}
|
||||
</div>
|
||||
)}
|
||||
<RealtimePreview
|
||||
key={component.id}
|
||||
component={component}
|
||||
isSelected={false}
|
||||
isDesignMode={false}
|
||||
onClick={() => {}}
|
||||
screenId={screenToPreview!.screenId}
|
||||
tableName={screenToPreview?.tableName}
|
||||
formData={previewFormData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
setPreviewFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
{/* 자식 컴포넌트들 */}
|
||||
{(component.type === "group" ||
|
||||
component.type === "container" ||
|
||||
component.type === "area") &&
|
||||
previewLayout.components
|
||||
.filter((child: any) => child.parentId === component.id)
|
||||
.map((child: any) => {
|
||||
// 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정
|
||||
const relativeChildComponent = {
|
||||
...child,
|
||||
position: {
|
||||
x: child.position.x - component.position.x,
|
||||
y: child.position.y - component.position.y,
|
||||
z: child.position.z || 1,
|
||||
},
|
||||
};
|
||||
|
||||
{/* 실제 컴포넌트 */}
|
||||
<div
|
||||
style={(() => {
|
||||
const style = {
|
||||
position: "absolute" as const,
|
||||
left: `${component.position.x}px`,
|
||||
top: `${component.position.y}px`,
|
||||
width: component.style?.width || `${component.size.width}px`,
|
||||
height: component.style?.height || `${component.size.height}px`,
|
||||
zIndex: component.position.z || 1,
|
||||
};
|
||||
|
||||
return style;
|
||||
})()}
|
||||
>
|
||||
{/* 위젯 컴포넌트가 아닌 경우 DynamicComponentRenderer 사용 */}
|
||||
{component.type !== "widget" ? (
|
||||
<DynamicComponentRenderer
|
||||
component={{
|
||||
...component,
|
||||
style: {
|
||||
...component.style,
|
||||
labelDisplay: shouldShowLabel ? false : (component.style?.labelDisplay ?? true), // 상위에서 라벨을 표시했으면 컴포넌트 내부에서는 숨김
|
||||
},
|
||||
}}
|
||||
isInteractive={true}
|
||||
formData={previewFormData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
setPreviewFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
}}
|
||||
screenId={screenToPreview!.screenId}
|
||||
tableName={screenToPreview?.tableName}
|
||||
/>
|
||||
) : (
|
||||
<DynamicWebTypeRenderer
|
||||
webType={(() => {
|
||||
// 유틸리티 함수로 파일 컴포넌트 감지
|
||||
if (isFileComponent(component)) {
|
||||
return "file";
|
||||
}
|
||||
// 다른 컴포넌트는 유틸리티 함수로 webType 결정
|
||||
return getComponentWebType(component) || "text";
|
||||
})()}
|
||||
config={component.webTypeConfig}
|
||||
props={{
|
||||
component: component,
|
||||
value: previewFormData[component.columnName || component.id] || "",
|
||||
onChange: (value: any) => {
|
||||
const fieldName = component.columnName || component.id;
|
||||
setPreviewFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
},
|
||||
onFormDataChange: (fieldName, value) => {
|
||||
setPreviewFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
},
|
||||
isInteractive: true,
|
||||
formData: previewFormData,
|
||||
readonly: component.readonly,
|
||||
required: component.required,
|
||||
placeholder: component.placeholder,
|
||||
className: "w-full h-full",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<RealtimePreview
|
||||
key={child.id}
|
||||
component={relativeChildComponent}
|
||||
isSelected={false}
|
||||
isDesignMode={false}
|
||||
onClick={() => {}}
|
||||
screenId={screenToPreview!.screenId}
|
||||
tableName={screenToPreview?.tableName}
|
||||
formData={previewFormData}
|
||||
onFormDataChange={(fieldName, value) => {
|
||||
setPreviewFormData((prev) => ({
|
||||
...prev,
|
||||
[fieldName]: value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</RealtimePreview>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
|
@ -1536,7 +1501,9 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableOptionsProvider>
|
||||
</ScreenPreviewProvider>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setPreviewDialogOpen(false)}>
|
||||
닫기
|
||||
|
|
|
|||
|
|
@ -47,6 +47,14 @@ export const CheckboxConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
// 새 옵션 추가용 상태
|
||||
const [newOptionLabel, setNewOptionLabel] = useState("");
|
||||
const [newOptionValue, setNewOptionValue] = useState("");
|
||||
|
||||
// 입력 필드용 로컬 상태
|
||||
const [localInputs, setLocalInputs] = useState({
|
||||
label: config.label || "",
|
||||
checkedValue: config.checkedValue || "Y",
|
||||
uncheckedValue: config.uncheckedValue || "N",
|
||||
groupLabel: config.groupLabel || "",
|
||||
});
|
||||
|
||||
// 컴포넌트 변경 시 로컬 상태 동기화
|
||||
useEffect(() => {
|
||||
|
|
@ -63,6 +71,14 @@ export const CheckboxConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
readonly: currentConfig.readonly || false,
|
||||
inline: currentConfig.inline !== false,
|
||||
});
|
||||
|
||||
// 입력 필드 로컬 상태도 동기화
|
||||
setLocalInputs({
|
||||
label: currentConfig.label || "",
|
||||
checkedValue: currentConfig.checkedValue || "Y",
|
||||
uncheckedValue: currentConfig.uncheckedValue || "N",
|
||||
groupLabel: currentConfig.groupLabel || "",
|
||||
});
|
||||
}, [widget.webTypeConfig]);
|
||||
|
||||
// 설정 업데이트 핸들러
|
||||
|
|
@ -107,11 +123,16 @@ export const CheckboxConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
updateConfig("options", newOptions);
|
||||
};
|
||||
|
||||
// 옵션 업데이트
|
||||
const updateOption = (index: number, field: keyof CheckboxOption, value: any) => {
|
||||
// 옵션 업데이트 (입력 필드용 - 로컬 상태만)
|
||||
const updateOptionLocal = (index: number, field: keyof CheckboxOption, value: any) => {
|
||||
const newOptions = [...localConfig.options];
|
||||
newOptions[index] = { ...newOptions[index], [field]: value };
|
||||
updateConfig("options", newOptions);
|
||||
setLocalConfig({ ...localConfig, options: newOptions });
|
||||
};
|
||||
|
||||
// 옵션 업데이트 완료 (onBlur)
|
||||
const handleOptionBlur = () => {
|
||||
onUpdateProperty("webTypeConfig", localConfig);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -170,8 +191,9 @@ export const CheckboxConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
</Label>
|
||||
<Input
|
||||
id="label"
|
||||
value={localConfig.label || ""}
|
||||
onChange={(e) => updateConfig("label", e.target.value)}
|
||||
value={localInputs.label}
|
||||
onChange={(e) => setLocalInputs({ ...localInputs, label: e.target.value })}
|
||||
onBlur={() => updateConfig("label", localInputs.label)}
|
||||
placeholder="체크박스 라벨"
|
||||
className="text-xs"
|
||||
/>
|
||||
|
|
@ -184,8 +206,9 @@ export const CheckboxConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
</Label>
|
||||
<Input
|
||||
id="checkedValue"
|
||||
value={localConfig.checkedValue || ""}
|
||||
onChange={(e) => updateConfig("checkedValue", e.target.value)}
|
||||
value={localInputs.checkedValue}
|
||||
onChange={(e) => setLocalInputs({ ...localInputs, checkedValue: e.target.value })}
|
||||
onBlur={() => updateConfig("checkedValue", localInputs.checkedValue)}
|
||||
placeholder="Y"
|
||||
className="text-xs"
|
||||
/>
|
||||
|
|
@ -196,8 +219,9 @@ export const CheckboxConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
</Label>
|
||||
<Input
|
||||
id="uncheckedValue"
|
||||
value={localConfig.uncheckedValue || ""}
|
||||
onChange={(e) => updateConfig("uncheckedValue", e.target.value)}
|
||||
value={localInputs.uncheckedValue}
|
||||
onChange={(e) => setLocalInputs({ ...localInputs, uncheckedValue: e.target.value })}
|
||||
onBlur={() => updateConfig("uncheckedValue", localInputs.uncheckedValue)}
|
||||
placeholder="N"
|
||||
className="text-xs"
|
||||
/>
|
||||
|
|
@ -229,8 +253,9 @@ export const CheckboxConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
</Label>
|
||||
<Input
|
||||
id="groupLabel"
|
||||
value={localConfig.groupLabel || ""}
|
||||
onChange={(e) => updateConfig("groupLabel", e.target.value)}
|
||||
value={localInputs.groupLabel}
|
||||
onChange={(e) => setLocalInputs({ ...localInputs, groupLabel: e.target.value })}
|
||||
onBlur={() => updateConfig("groupLabel", localInputs.groupLabel)}
|
||||
placeholder="체크박스 그룹 제목"
|
||||
className="text-xs"
|
||||
/>
|
||||
|
|
@ -268,26 +293,40 @@ export const CheckboxConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
<Label className="text-xs">옵션 목록 ({localConfig.options.length}개)</Label>
|
||||
<div className="max-h-40 space-y-2 overflow-y-auto">
|
||||
{localConfig.options.map((option, index) => (
|
||||
<div key={index} className="flex items-center gap-2 rounded border p-2">
|
||||
<div key={`${option.value}-${index}`} className="flex items-center gap-2 rounded border p-2">
|
||||
<Switch
|
||||
checked={option.checked || false}
|
||||
onCheckedChange={(checked) => updateOption(index, "checked", checked)}
|
||||
onCheckedChange={(checked) => {
|
||||
const newOptions = [...localConfig.options];
|
||||
newOptions[index] = { ...newOptions[index], checked };
|
||||
const newConfig = { ...localConfig, options: newOptions };
|
||||
setLocalConfig(newConfig);
|
||||
onUpdateProperty("webTypeConfig", newConfig);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
value={option.label}
|
||||
onChange={(e) => updateOption(index, "label", e.target.value)}
|
||||
onChange={(e) => updateOptionLocal(index, "label", e.target.value)}
|
||||
onBlur={handleOptionBlur}
|
||||
placeholder="라벨"
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={option.value}
|
||||
onChange={(e) => updateOption(index, "value", e.target.value)}
|
||||
onChange={(e) => updateOptionLocal(index, "value", e.target.value)}
|
||||
onBlur={handleOptionBlur}
|
||||
placeholder="값"
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Switch
|
||||
checked={!option.disabled}
|
||||
onCheckedChange={(checked) => updateOption(index, "disabled", !checked)}
|
||||
onCheckedChange={(checked) => {
|
||||
const newOptions = [...localConfig.options];
|
||||
newOptions[index] = { ...newOptions[index], disabled: !checked };
|
||||
const newConfig = { ...localConfig, options: newOptions };
|
||||
setLocalConfig(newConfig);
|
||||
onUpdateProperty("webTypeConfig", newConfig);
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" variant="destructive" onClick={() => removeOption(index)} className="p-1 text-xs">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
|
|
|
|||
|
|
@ -9,13 +9,14 @@ import { Switch } from "@/components/ui/switch";
|
|||
import { Trash2, Plus } from "lucide-react";
|
||||
import { ColumnFilter, DataFilterConfig } from "@/types/screen-management";
|
||||
import { UnifiedColumnInfo } from "@/types/table-management";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import { getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
|
||||
interface DataFilterConfigPanelProps {
|
||||
tableName?: string;
|
||||
columns?: UnifiedColumnInfo[];
|
||||
config?: DataFilterConfig;
|
||||
onConfigChange: (config: DataFilterConfig) => void;
|
||||
menuObjid?: number; // 🆕 메뉴 OBJID (카테고리 값 조회 시 필요)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -27,7 +28,15 @@ export function DataFilterConfigPanel({
|
|||
columns = [],
|
||||
config,
|
||||
onConfigChange,
|
||||
menuObjid, // 🆕 메뉴 OBJID
|
||||
}: DataFilterConfigPanelProps) {
|
||||
console.log("🔍 [DataFilterConfigPanel] 초기화:", {
|
||||
tableName,
|
||||
columnsCount: columns.length,
|
||||
menuObjid,
|
||||
sampleColumns: columns.slice(0, 3),
|
||||
});
|
||||
|
||||
const [localConfig, setLocalConfig] = useState<DataFilterConfig>(
|
||||
config || {
|
||||
enabled: false,
|
||||
|
|
@ -43,6 +52,14 @@ export function DataFilterConfigPanel({
|
|||
useEffect(() => {
|
||||
if (config) {
|
||||
setLocalConfig(config);
|
||||
|
||||
// 🆕 기존 필터 중 카테고리 타입인 것들의 값을 로드
|
||||
config.filters?.forEach((filter) => {
|
||||
if (filter.valueType === "category" && filter.columnName) {
|
||||
console.log("🔄 기존 카테고리 필터 감지, 값 로딩:", filter.columnName);
|
||||
loadCategoryValues(filter.columnName);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
|
|
@ -55,20 +72,34 @@ export function DataFilterConfigPanel({
|
|||
setLoadingCategories(prev => ({ ...prev, [columnName]: true }));
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(
|
||||
`/table-categories/${tableName}/${columnName}/values`
|
||||
console.log("🔍 카테고리 값 로드 시작:", {
|
||||
tableName,
|
||||
columnName,
|
||||
menuObjid,
|
||||
});
|
||||
|
||||
const response = await getCategoryValues(
|
||||
tableName,
|
||||
columnName,
|
||||
false, // includeInactive
|
||||
menuObjid // 🆕 메뉴 OBJID 전달
|
||||
);
|
||||
|
||||
if (response.data.success && response.data.data) {
|
||||
const values = response.data.data.map((item: any) => ({
|
||||
console.log("📦 카테고리 값 로드 응답:", response);
|
||||
|
||||
if (response.success && response.data) {
|
||||
const values = response.data.map((item: any) => ({
|
||||
value: item.valueCode,
|
||||
label: item.valueLabel,
|
||||
}));
|
||||
|
||||
console.log("✅ 카테고리 값 설정:", { columnName, valuesCount: values.length });
|
||||
setCategoryValues(prev => ({ ...prev, [columnName]: values }));
|
||||
} else {
|
||||
console.warn("⚠️ 카테고리 값 로드 실패 또는 데이터 없음:", response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`카테고리 값 로드 실패 (${columnName}):`, error);
|
||||
console.error(`❌ 카테고리 값 로드 실패 (${columnName}):`, error);
|
||||
} finally {
|
||||
setLoadingCategories(prev => ({ ...prev, [columnName]: false }));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,32 +51,29 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
const [newFieldName, setNewFieldName] = useState("");
|
||||
const [newFieldLabel, setNewFieldLabel] = useState("");
|
||||
const [newFieldType, setNewFieldType] = useState("string");
|
||||
const [isUserEditing, setIsUserEditing] = useState(false);
|
||||
|
||||
// 컴포넌트 변경 시 로컬 상태 동기화 (사용자가 입력 중이 아닐 때만)
|
||||
// 컴포넌트 변경 시 로컬 상태 동기화
|
||||
useEffect(() => {
|
||||
if (!isUserEditing) {
|
||||
const currentConfig = (widget.webTypeConfig as EntityTypeConfig) || {};
|
||||
setLocalConfig({
|
||||
entityType: currentConfig.entityType || "",
|
||||
displayFields: currentConfig.displayFields || [],
|
||||
searchFields: currentConfig.searchFields || [],
|
||||
valueField: currentConfig.valueField || "id",
|
||||
labelField: currentConfig.labelField || "name",
|
||||
multiple: currentConfig.multiple || false,
|
||||
searchable: currentConfig.searchable !== false,
|
||||
placeholder: currentConfig.placeholder || "엔티티를 선택하세요",
|
||||
emptyMessage: currentConfig.emptyMessage || "검색 결과가 없습니다",
|
||||
pageSize: currentConfig.pageSize || 20,
|
||||
minSearchLength: currentConfig.minSearchLength || 1,
|
||||
defaultValue: currentConfig.defaultValue || "",
|
||||
required: currentConfig.required || false,
|
||||
readonly: currentConfig.readonly || false,
|
||||
apiEndpoint: currentConfig.apiEndpoint || "",
|
||||
filters: currentConfig.filters || {},
|
||||
});
|
||||
}
|
||||
}, [widget.webTypeConfig, isUserEditing]);
|
||||
const currentConfig = (widget.webTypeConfig as EntityTypeConfig) || {};
|
||||
setLocalConfig({
|
||||
entityType: currentConfig.entityType || "",
|
||||
displayFields: currentConfig.displayFields || [],
|
||||
searchFields: currentConfig.searchFields || [],
|
||||
valueField: currentConfig.valueField || "id",
|
||||
labelField: currentConfig.labelField || "name",
|
||||
multiple: currentConfig.multiple || false,
|
||||
searchable: currentConfig.searchable !== false,
|
||||
placeholder: currentConfig.placeholder || "엔티티를 선택하세요",
|
||||
emptyMessage: currentConfig.emptyMessage || "검색 결과가 없습니다",
|
||||
pageSize: currentConfig.pageSize || 20,
|
||||
minSearchLength: currentConfig.minSearchLength || 1,
|
||||
defaultValue: currentConfig.defaultValue || "",
|
||||
required: currentConfig.required || false,
|
||||
readonly: currentConfig.readonly || false,
|
||||
apiEndpoint: currentConfig.apiEndpoint || "",
|
||||
filters: currentConfig.filters || {},
|
||||
});
|
||||
}, [widget.webTypeConfig]);
|
||||
|
||||
// 설정 업데이트 핸들러 (즉시 부모에게 전달 - 드롭다운, 체크박스 등)
|
||||
const updateConfig = (field: keyof EntityTypeConfig, value: any) => {
|
||||
|
|
@ -87,13 +84,11 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
|
||||
// 입력 필드용 업데이트 (로컬 상태만)
|
||||
const updateConfigLocal = (field: keyof EntityTypeConfig, value: any) => {
|
||||
setIsUserEditing(true);
|
||||
setLocalConfig({ ...localConfig, [field]: value });
|
||||
};
|
||||
|
||||
// 입력 완료 시 부모에게 전달
|
||||
const handleInputBlur = () => {
|
||||
setIsUserEditing(false);
|
||||
onUpdateProperty("webTypeConfig", localConfig);
|
||||
};
|
||||
|
||||
|
|
@ -121,17 +116,15 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
updateConfig("displayFields", newFields);
|
||||
};
|
||||
|
||||
// 필드 업데이트 (입력 중)
|
||||
// 필드 업데이트 (입력 중) - 로컬 상태만 업데이트
|
||||
const updateDisplayField = (index: number, field: keyof EntityField, value: any) => {
|
||||
setIsUserEditing(true);
|
||||
const newFields = [...localConfig.displayFields];
|
||||
newFields[index] = { ...newFields[index], [field]: value };
|
||||
setLocalConfig({ ...localConfig, displayFields: newFields });
|
||||
};
|
||||
|
||||
// 필드 업데이트 완료 (onBlur)
|
||||
// 필드 업데이트 완료 (onBlur) - 부모에게 전달
|
||||
const handleFieldBlur = () => {
|
||||
setIsUserEditing(false);
|
||||
onUpdateProperty("webTypeConfig", localConfig);
|
||||
};
|
||||
|
||||
|
|
@ -325,12 +318,15 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
<Label className="text-xs">표시 필드 ({localConfig.displayFields.length}개)</Label>
|
||||
<div className="max-h-40 space-y-2 overflow-y-auto">
|
||||
{localConfig.displayFields.map((field, index) => (
|
||||
<div key={index} className="flex items-center gap-2 rounded border p-2">
|
||||
<div key={`${field.name}-${index}`} className="flex items-center gap-2 rounded border p-2">
|
||||
<Switch
|
||||
checked={field.visible}
|
||||
onCheckedChange={(checked) => {
|
||||
updateDisplayField(index, "visible", checked);
|
||||
handleFieldBlur();
|
||||
const newFields = [...localConfig.displayFields];
|
||||
newFields[index] = { ...newFields[index], visible: checked };
|
||||
const newConfig = { ...localConfig, displayFields: newFields };
|
||||
setLocalConfig(newConfig);
|
||||
onUpdateProperty("webTypeConfig", newConfig);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
|
|
@ -347,7 +343,16 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
placeholder="라벨"
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Select value={field.type} onValueChange={(value) => updateDisplayField(index, "type", value)}>
|
||||
<Select
|
||||
value={field.type}
|
||||
onValueChange={(value) => {
|
||||
const newFields = [...localConfig.displayFields];
|
||||
newFields[index] = { ...newFields[index], type: value };
|
||||
const newConfig = { ...localConfig, displayFields: newFields };
|
||||
setLocalConfig(newConfig);
|
||||
onUpdateProperty("webTypeConfig", newConfig);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-24 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
|
|
|
|||
|
|
@ -43,6 +43,12 @@ export const RadioConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
const [newOptionLabel, setNewOptionLabel] = useState("");
|
||||
const [newOptionValue, setNewOptionValue] = useState("");
|
||||
const [bulkOptions, setBulkOptions] = useState("");
|
||||
|
||||
// 입력 필드용 로컬 상태
|
||||
const [localInputs, setLocalInputs] = useState({
|
||||
groupLabel: config.groupLabel || "",
|
||||
groupName: config.groupName || "",
|
||||
});
|
||||
|
||||
// 컴포넌트 변경 시 로컬 상태 동기화
|
||||
useEffect(() => {
|
||||
|
|
@ -59,6 +65,12 @@ export const RadioConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
inline: currentConfig.inline !== false,
|
||||
groupLabel: currentConfig.groupLabel || "",
|
||||
});
|
||||
|
||||
// 입력 필드 로컬 상태도 동기화
|
||||
setLocalInputs({
|
||||
groupLabel: currentConfig.groupLabel || "",
|
||||
groupName: currentConfig.groupName || "",
|
||||
});
|
||||
}, [widget.webTypeConfig]);
|
||||
|
||||
// 설정 업데이트 핸들러
|
||||
|
|
@ -95,17 +107,24 @@ export const RadioConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// 옵션 업데이트
|
||||
const updateOption = (index: number, field: keyof RadioOption, value: any) => {
|
||||
// 옵션 업데이트 (입력 필드용 - 로컬 상태만)
|
||||
const updateOptionLocal = (index: number, field: keyof RadioOption, value: any) => {
|
||||
const newOptions = [...localConfig.options];
|
||||
const oldValue = newOptions[index].value;
|
||||
newOptions[index] = { ...newOptions[index], [field]: value };
|
||||
updateConfig("options", newOptions);
|
||||
|
||||
|
||||
// 값이 변경되고 해당 값이 기본값이었다면 기본값도 업데이트
|
||||
const newConfig = { ...localConfig, options: newOptions };
|
||||
if (field === "value" && localConfig.defaultValue === oldValue) {
|
||||
updateConfig("defaultValue", value);
|
||||
newConfig.defaultValue = value;
|
||||
}
|
||||
|
||||
setLocalConfig(newConfig);
|
||||
};
|
||||
|
||||
// 옵션 업데이트 완료 (onBlur)
|
||||
const handleOptionBlur = () => {
|
||||
onUpdateProperty("webTypeConfig", localConfig);
|
||||
};
|
||||
|
||||
// 벌크 옵션 추가
|
||||
|
|
@ -185,8 +204,9 @@ export const RadioConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
</Label>
|
||||
<Input
|
||||
id="groupLabel"
|
||||
value={localConfig.groupLabel || ""}
|
||||
onChange={(e) => updateConfig("groupLabel", e.target.value)}
|
||||
value={localInputs.groupLabel}
|
||||
onChange={(e) => setLocalInputs({ ...localInputs, groupLabel: e.target.value })}
|
||||
onBlur={() => updateConfig("groupLabel", localInputs.groupLabel)}
|
||||
placeholder="라디오버튼 그룹 제목"
|
||||
className="text-xs"
|
||||
/>
|
||||
|
|
@ -198,8 +218,9 @@ export const RadioConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
</Label>
|
||||
<Input
|
||||
id="groupName"
|
||||
value={localConfig.groupName || ""}
|
||||
onChange={(e) => updateConfig("groupName", e.target.value)}
|
||||
value={localInputs.groupName}
|
||||
onChange={(e) => setLocalInputs({ ...localInputs, groupName: e.target.value })}
|
||||
onBlur={() => updateConfig("groupName", localInputs.groupName)}
|
||||
placeholder="자동 생성 (필드명 기반)"
|
||||
className="text-xs"
|
||||
/>
|
||||
|
|
@ -290,22 +311,30 @@ export const RadioConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
<Label className="text-xs">현재 옵션 ({localConfig.options.length}개)</Label>
|
||||
<div className="max-h-40 space-y-2 overflow-y-auto">
|
||||
{localConfig.options.map((option, index) => (
|
||||
<div key={index} className="flex items-center gap-2 rounded border p-2">
|
||||
<div key={`${option.value}-${index}`} className="flex items-center gap-2 rounded border p-2">
|
||||
<Input
|
||||
value={option.label}
|
||||
onChange={(e) => updateOption(index, "label", e.target.value)}
|
||||
onChange={(e) => updateOptionLocal(index, "label", e.target.value)}
|
||||
onBlur={handleOptionBlur}
|
||||
placeholder="라벨"
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={option.value}
|
||||
onChange={(e) => updateOption(index, "value", e.target.value)}
|
||||
onChange={(e) => updateOptionLocal(index, "value", e.target.value)}
|
||||
onBlur={handleOptionBlur}
|
||||
placeholder="값"
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Switch
|
||||
checked={!option.disabled}
|
||||
onCheckedChange={(checked) => updateOption(index, "disabled", !checked)}
|
||||
onCheckedChange={(checked) => {
|
||||
const newOptions = [...localConfig.options];
|
||||
newOptions[index] = { ...newOptions[index], disabled: !checked };
|
||||
const newConfig = { ...localConfig, options: newOptions };
|
||||
setLocalConfig(newConfig);
|
||||
onUpdateProperty("webTypeConfig", newConfig);
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" variant="destructive" onClick={() => removeOption(index)} className="p-1 text-xs">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
|
|
|
|||
|
|
@ -44,6 +44,12 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
const [newOptionLabel, setNewOptionLabel] = useState("");
|
||||
const [newOptionValue, setNewOptionValue] = useState("");
|
||||
const [bulkOptions, setBulkOptions] = useState("");
|
||||
|
||||
// 입력 필드용 로컬 상태
|
||||
const [localInputs, setLocalInputs] = useState({
|
||||
placeholder: config.placeholder || "",
|
||||
emptyMessage: config.emptyMessage || "",
|
||||
});
|
||||
|
||||
// 컴포넌트 변경 시 로컬 상태 동기화
|
||||
useEffect(() => {
|
||||
|
|
@ -61,6 +67,12 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
readonly: currentConfig.readonly || false,
|
||||
emptyMessage: currentConfig.emptyMessage || "선택 가능한 옵션이 없습니다",
|
||||
});
|
||||
|
||||
// 입력 필드 로컬 상태도 동기화
|
||||
setLocalInputs({
|
||||
placeholder: currentConfig.placeholder || "",
|
||||
emptyMessage: currentConfig.emptyMessage || "",
|
||||
});
|
||||
}, [widget.webTypeConfig]);
|
||||
|
||||
// 설정 업데이트 핸들러
|
||||
|
|
@ -91,11 +103,16 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
updateConfig("options", newOptions);
|
||||
};
|
||||
|
||||
// 옵션 업데이트
|
||||
const updateOption = (index: number, field: keyof SelectOption, value: any) => {
|
||||
// 옵션 업데이트 (입력 필드용 - 로컬 상태만)
|
||||
const updateOptionLocal = (index: number, field: keyof SelectOption, value: any) => {
|
||||
const newOptions = [...localConfig.options];
|
||||
newOptions[index] = { ...newOptions[index], [field]: value };
|
||||
updateConfig("options", newOptions);
|
||||
setLocalConfig({ ...localConfig, options: newOptions });
|
||||
};
|
||||
|
||||
// 옵션 업데이트 완료 (onBlur)
|
||||
const handleOptionBlur = () => {
|
||||
onUpdateProperty("webTypeConfig", localConfig);
|
||||
};
|
||||
|
||||
// 벌크 옵션 추가
|
||||
|
|
@ -170,8 +187,9 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
</Label>
|
||||
<Input
|
||||
id="placeholder"
|
||||
value={localConfig.placeholder || ""}
|
||||
onChange={(e) => updateConfig("placeholder", e.target.value)}
|
||||
value={localInputs.placeholder}
|
||||
onChange={(e) => setLocalInputs({ ...localInputs, placeholder: e.target.value })}
|
||||
onBlur={() => updateConfig("placeholder", localInputs.placeholder)}
|
||||
placeholder="선택하세요"
|
||||
className="text-xs"
|
||||
/>
|
||||
|
|
@ -183,8 +201,9 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
</Label>
|
||||
<Input
|
||||
id="emptyMessage"
|
||||
value={localConfig.emptyMessage || ""}
|
||||
onChange={(e) => updateConfig("emptyMessage", e.target.value)}
|
||||
value={localInputs.emptyMessage}
|
||||
onChange={(e) => setLocalInputs({ ...localInputs, emptyMessage: e.target.value })}
|
||||
onBlur={() => updateConfig("emptyMessage", localInputs.emptyMessage)}
|
||||
placeholder="선택 가능한 옵션이 없습니다"
|
||||
className="text-xs"
|
||||
/>
|
||||
|
|
@ -285,22 +304,30 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
|
|||
<Label className="text-xs">현재 옵션 ({localConfig.options.length}개)</Label>
|
||||
<div className="max-h-40 space-y-2 overflow-y-auto">
|
||||
{localConfig.options.map((option, index) => (
|
||||
<div key={index} className="flex items-center gap-2 rounded border p-2">
|
||||
<div key={`${option.value}-${index}`} className="flex items-center gap-2 rounded border p-2">
|
||||
<Input
|
||||
value={option.label}
|
||||
onChange={(e) => updateOption(index, "label", e.target.value)}
|
||||
onChange={(e) => updateOptionLocal(index, "label", e.target.value)}
|
||||
onBlur={handleOptionBlur}
|
||||
placeholder="라벨"
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={option.value}
|
||||
onChange={(e) => updateOption(index, "value", e.target.value)}
|
||||
onChange={(e) => updateOptionLocal(index, "value", e.target.value)}
|
||||
onBlur={handleOptionBlur}
|
||||
placeholder="값"
|
||||
className="flex-1 text-xs"
|
||||
/>
|
||||
<Switch
|
||||
checked={!option.disabled}
|
||||
onCheckedChange={(checked) => updateOption(index, "disabled", !checked)}
|
||||
onCheckedChange={(checked) => {
|
||||
const newOptions = [...localConfig.options];
|
||||
newOptions[index] = { ...newOptions[index], disabled: !checked };
|
||||
const newConfig = { ...localConfig, options: newOptions };
|
||||
setLocalConfig(newConfig);
|
||||
onUpdateProperty("webTypeConfig", newConfig);
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" variant="destructive" onClick={() => removeOption(index)} className="p-1 text-xs">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { CalendarIcon, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
|
@ -34,6 +34,17 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
|
|||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
const [selectingType, setSelectingType] = useState<"from" | "to">("from");
|
||||
|
||||
// 로컬 임시 상태 (확인 버튼 누르기 전까지 임시 저장)
|
||||
const [tempValue, setTempValue] = useState<DateRangeValue>(value || {});
|
||||
|
||||
// 팝오버가 열릴 때 현재 값으로 초기화
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTempValue(value || {});
|
||||
setSelectingType("from");
|
||||
}
|
||||
}, [isOpen, value]);
|
||||
|
||||
const formatDate = (date: Date | undefined) => {
|
||||
if (!date) return "";
|
||||
|
|
@ -57,26 +68,91 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
|
|||
};
|
||||
|
||||
const handleDateClick = (date: Date) => {
|
||||
// 로컬 상태만 업데이트 (onChange 호출 안 함)
|
||||
if (selectingType === "from") {
|
||||
const newValue = { ...value, from: date };
|
||||
onChange(newValue);
|
||||
setTempValue({ ...tempValue, from: date });
|
||||
setSelectingType("to");
|
||||
} else {
|
||||
const newValue = { ...value, to: date };
|
||||
onChange(newValue);
|
||||
setTempValue({ ...tempValue, to: date });
|
||||
setSelectingType("from");
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
onChange({});
|
||||
setTempValue({});
|
||||
setSelectingType("from");
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
// 확인 버튼을 눌렀을 때만 onChange 호출
|
||||
onChange(tempValue);
|
||||
setIsOpen(false);
|
||||
setSelectingType("from");
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
// 취소 시 임시 값 버리고 팝오버 닫기
|
||||
setTempValue(value || {});
|
||||
setIsOpen(false);
|
||||
setSelectingType("from");
|
||||
};
|
||||
|
||||
// 빠른 기간 선택 함수들 (즉시 적용 + 팝오버 닫기)
|
||||
const setToday = () => {
|
||||
const today = new Date();
|
||||
const newValue = { from: today, to: today };
|
||||
setTempValue(newValue);
|
||||
onChange(newValue);
|
||||
setIsOpen(false);
|
||||
setSelectingType("from");
|
||||
};
|
||||
|
||||
const setThisWeek = () => {
|
||||
const today = new Date();
|
||||
const dayOfWeek = today.getDay();
|
||||
const diff = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; // 월요일 기준
|
||||
const monday = new Date(today);
|
||||
monday.setDate(today.getDate() + diff);
|
||||
const sunday = new Date(monday);
|
||||
sunday.setDate(monday.getDate() + 6);
|
||||
const newValue = { from: monday, to: sunday };
|
||||
setTempValue(newValue);
|
||||
onChange(newValue);
|
||||
setIsOpen(false);
|
||||
setSelectingType("from");
|
||||
};
|
||||
|
||||
const setThisMonth = () => {
|
||||
const today = new Date();
|
||||
const firstDay = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
const lastDay = new Date(today.getFullYear(), today.getMonth() + 1, 0);
|
||||
const newValue = { from: firstDay, to: lastDay };
|
||||
setTempValue(newValue);
|
||||
onChange(newValue);
|
||||
setIsOpen(false);
|
||||
setSelectingType("from");
|
||||
};
|
||||
|
||||
const setLast7Days = () => {
|
||||
const today = new Date();
|
||||
const sevenDaysAgo = new Date(today);
|
||||
sevenDaysAgo.setDate(today.getDate() - 6);
|
||||
const newValue = { from: sevenDaysAgo, to: today };
|
||||
setTempValue(newValue);
|
||||
onChange(newValue);
|
||||
setIsOpen(false);
|
||||
setSelectingType("from");
|
||||
};
|
||||
|
||||
const setLast30Days = () => {
|
||||
const today = new Date();
|
||||
const thirtyDaysAgo = new Date(today);
|
||||
thirtyDaysAgo.setDate(today.getDate() - 29);
|
||||
const newValue = { from: thirtyDaysAgo, to: today };
|
||||
setTempValue(newValue);
|
||||
onChange(newValue);
|
||||
setIsOpen(false);
|
||||
setSelectingType("from");
|
||||
// 날짜는 이미 선택 시점에 onChange가 호출되므로 중복 호출 제거
|
||||
};
|
||||
|
||||
const monthStart = startOfMonth(currentMonth);
|
||||
|
|
@ -91,16 +167,16 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
|
|||
const allDays = [...Array(paddingDays).fill(null), ...days];
|
||||
|
||||
const isInRange = (date: Date) => {
|
||||
if (!value.from || !value.to) return false;
|
||||
return date >= value.from && date <= value.to;
|
||||
if (!tempValue.from || !tempValue.to) return false;
|
||||
return date >= tempValue.from && date <= tempValue.to;
|
||||
};
|
||||
|
||||
const isRangeStart = (date: Date) => {
|
||||
return value.from && isSameDay(date, value.from);
|
||||
return tempValue.from && isSameDay(date, tempValue.from);
|
||||
};
|
||||
|
||||
const isRangeEnd = (date: Date) => {
|
||||
return value.to && isSameDay(date, value.to);
|
||||
return tempValue.to && isSameDay(date, tempValue.to);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -127,6 +203,25 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* 빠른 선택 버튼 */}
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={setToday}>
|
||||
오늘
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={setThisWeek}>
|
||||
이번 주
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={setThisMonth}>
|
||||
이번 달
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={setLast7Days}>
|
||||
최근 7일
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={setLast30Days}>
|
||||
최근 30일
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 월 네비게이션 */}
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}>
|
||||
|
|
@ -183,13 +278,13 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
|
|||
</div>
|
||||
|
||||
{/* 선택된 범위 표시 */}
|
||||
{(value.from || value.to) && (
|
||||
{(tempValue.from || tempValue.to) && (
|
||||
<div className="bg-muted mb-4 rounded-md p-2">
|
||||
<div className="text-muted-foreground mb-1 text-xs">선택된 기간</div>
|
||||
<div className="text-sm">
|
||||
{value.from && <span className="font-medium">시작: {formatDate(value.from)}</span>}
|
||||
{value.from && value.to && <span className="mx-2">~</span>}
|
||||
{value.to && <span className="font-medium">종료: {formatDate(value.to)}</span>}
|
||||
{tempValue.from && <span className="font-medium">시작: {formatDate(tempValue.from)}</span>}
|
||||
{tempValue.from && tempValue.to && <span className="mx-2">~</span>}
|
||||
{tempValue.to && <span className="font-medium">종료: {formatDate(tempValue.to)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -200,7 +295,7 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
|
|||
초기화
|
||||
</Button>
|
||||
<div className="space-x-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setIsOpen(false)}>
|
||||
<Button variant="outline" size="sm" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleConfirm}>
|
||||
|
|
|
|||
|
|
@ -78,3 +78,4 @@ export const numberingRuleTemplate = {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -11,9 +11,10 @@ interface TabsWidgetProps {
|
|||
component: TabsComponent;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
menuObjid?: number; // 🆕 부모 화면의 메뉴 OBJID
|
||||
}
|
||||
|
||||
export function TabsWidget({ component, className, style }: TabsWidgetProps) {
|
||||
export function TabsWidget({ component, className, style, menuObjid }: TabsWidgetProps) {
|
||||
const {
|
||||
tabs = [],
|
||||
defaultTab,
|
||||
|
|
@ -233,6 +234,11 @@ export function TabsWidget({ component, className, style }: TabsWidgetProps) {
|
|||
key={component.id}
|
||||
component={component}
|
||||
allComponents={components}
|
||||
screenInfo={{
|
||||
id: tab.screenId,
|
||||
tableName: layoutData.tableName,
|
||||
}}
|
||||
menuObjid={menuObjid} // 🆕 부모의 menuObjid 전달
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -122,10 +122,6 @@ const ResizableDialogContent = React.forwardRef<
|
|||
|
||||
// 1순위: userStyle에서 크기 추출 (화면관리에서 지정한 크기 - 항상 초기값으로 사용)
|
||||
if (userStyle) {
|
||||
console.log("🔍 userStyle 감지:", userStyle);
|
||||
console.log("🔍 userStyle.width 타입:", typeof userStyle.width, "값:", userStyle.width);
|
||||
console.log("🔍 userStyle.height 타입:", typeof userStyle.height, "값:", userStyle.height);
|
||||
|
||||
const styleWidth = typeof userStyle.width === 'string'
|
||||
? parseInt(userStyle.width)
|
||||
: userStyle.width;
|
||||
|
|
@ -133,31 +129,15 @@ const ResizableDialogContent = React.forwardRef<
|
|||
? parseInt(userStyle.height)
|
||||
: userStyle.height;
|
||||
|
||||
console.log("📏 파싱된 크기:", {
|
||||
styleWidth,
|
||||
styleHeight,
|
||||
"styleWidth truthy?": !!styleWidth,
|
||||
"styleHeight truthy?": !!styleHeight,
|
||||
minWidth,
|
||||
maxWidth,
|
||||
minHeight,
|
||||
maxHeight
|
||||
});
|
||||
|
||||
if (styleWidth && styleHeight) {
|
||||
const finalSize = {
|
||||
width: Math.max(minWidth, Math.min(maxWidth, styleWidth)),
|
||||
height: Math.max(minHeight, Math.min(maxHeight, styleHeight)),
|
||||
};
|
||||
console.log("✅ userStyle 크기 사용:", finalSize);
|
||||
return finalSize;
|
||||
} else {
|
||||
console.log("❌ styleWidth 또는 styleHeight가 falsy:", { styleWidth, styleHeight });
|
||||
}
|
||||
}
|
||||
|
||||
console.log("⚠️ userStyle 없음, defaultWidth/defaultHeight 사용:", { defaultWidth, defaultHeight });
|
||||
|
||||
// 2순위: 현재 렌더링된 크기 사용 (주석처리 - 모달이 열린 후 늘어나는 현상 방지)
|
||||
// if (contentRef.current) {
|
||||
// const rect = contentRef.current.getBoundingClientRect();
|
||||
|
|
@ -209,7 +189,6 @@ const ResizableDialogContent = React.forwardRef<
|
|||
// 사용자가 리사이징한 크기 우선
|
||||
setSize({ width: savedSize.width, height: savedSize.height });
|
||||
setUserResized(true);
|
||||
console.log("✅ 사용자 리사이징 크기 적용:", savedSize);
|
||||
} else if (userStyle && userStyle.width && userStyle.height) {
|
||||
// 화면관리에서 설정한 크기
|
||||
const styleWidth = typeof userStyle.width === 'string'
|
||||
|
|
@ -224,7 +203,6 @@ const ResizableDialogContent = React.forwardRef<
|
|||
width: Math.max(minWidth, Math.min(maxWidth, styleWidth)),
|
||||
height: Math.max(minHeight, Math.min(maxHeight, styleHeight)),
|
||||
};
|
||||
console.log("🔄 userStyle 크기 적용:", newSize);
|
||||
setSize(newSize);
|
||||
}
|
||||
}
|
||||
|
|
@ -452,7 +430,7 @@ const ResizableDialogContent = React.forwardRef<
|
|||
<div
|
||||
ref={contentRef}
|
||||
className="h-full w-full relative"
|
||||
style={{ display: 'block', overflow: 'hidden', pointerEvents: 'auto', zIndex: 1 }}
|
||||
style={{ display: 'block', overflow: 'auto', pointerEvents: 'auto', zIndex: 1 }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import React, { useState, useMemo, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -34,6 +34,21 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
}) => {
|
||||
const [localFields, setLocalFields] = useState<RepeaterFieldDefinition[]>(config.fields || []);
|
||||
const [fieldNamePopoverOpen, setFieldNamePopoverOpen] = useState<Record<number, boolean>>({});
|
||||
|
||||
// 로컬 입력 상태 (각 필드의 라벨, placeholder 등)
|
||||
const [localInputs, setLocalInputs] = useState<Record<number, { label: string; placeholder: string }>>({});
|
||||
|
||||
// 설정 입력 필드의 로컬 상태
|
||||
const [localConfigInputs, setLocalConfigInputs] = useState({
|
||||
addButtonText: config.addButtonText || "",
|
||||
});
|
||||
|
||||
// config 변경 시 로컬 상태 동기화
|
||||
useEffect(() => {
|
||||
setLocalConfigInputs({
|
||||
addButtonText: config.addButtonText || "",
|
||||
});
|
||||
}, [config.addButtonText]);
|
||||
|
||||
// 이미 사용된 컬럼명 목록
|
||||
const usedColumnNames = useMemo(() => {
|
||||
|
|
@ -72,7 +87,32 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
handleFieldsChange(localFields.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// 필드 수정
|
||||
// 필드 수정 (입력 중 - 로컬 상태만)
|
||||
const updateFieldLocal = (index: number, field: 'label' | 'placeholder', value: string) => {
|
||||
setLocalInputs(prev => ({
|
||||
...prev,
|
||||
[index]: {
|
||||
...prev[index],
|
||||
[field]: value
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
// 필드 수정 완료 (onBlur - 실제 업데이트)
|
||||
const handleFieldBlur = (index: number) => {
|
||||
const localInput = localInputs[index];
|
||||
if (localInput) {
|
||||
const newFields = [...localFields];
|
||||
newFields[index] = {
|
||||
...newFields[index],
|
||||
label: localInput.label,
|
||||
placeholder: localInput.placeholder
|
||||
};
|
||||
handleFieldsChange(newFields);
|
||||
}
|
||||
};
|
||||
|
||||
// 필드 수정 (즉시 반영 - 드롭다운, 체크박스 등)
|
||||
const updateField = (index: number, updates: Partial<RepeaterFieldDefinition>) => {
|
||||
const newFields = [...localFields];
|
||||
newFields[index] = { ...newFields[index], ...updates };
|
||||
|
|
@ -157,7 +197,7 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<Label className="text-sm font-semibold">필드 정의</Label>
|
||||
|
||||
{localFields.map((field, index) => (
|
||||
<Card key={index} className="border-2">
|
||||
<Card key={`${field.name}-${index}`} className="border-2">
|
||||
<CardContent className="space-y-3 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold text-gray-700">필드 {index + 1}</span>
|
||||
|
|
@ -200,6 +240,14 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
label: column.columnLabel || column.columnName,
|
||||
type: (column.widgetType as RepeaterFieldType) || "text",
|
||||
});
|
||||
// 로컬 입력 상태도 업데이트
|
||||
setLocalInputs(prev => ({
|
||||
...prev,
|
||||
[index]: {
|
||||
label: column.columnLabel || column.columnName,
|
||||
placeholder: prev[index]?.placeholder || ""
|
||||
}
|
||||
}));
|
||||
setFieldNamePopoverOpen({ ...fieldNamePopoverOpen, [index]: false });
|
||||
}}
|
||||
className="text-xs"
|
||||
|
|
@ -225,8 +273,9 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<div className="space-y-1">
|
||||
<Label className="text-xs">라벨</Label>
|
||||
<Input
|
||||
value={field.label}
|
||||
onChange={(e) => updateField(index, { label: e.target.value })}
|
||||
value={localInputs[index]?.label !== undefined ? localInputs[index].label : field.label}
|
||||
onChange={(e) => updateFieldLocal(index, 'label', e.target.value)}
|
||||
onBlur={() => handleFieldBlur(index)}
|
||||
placeholder="필드 라벨"
|
||||
className="h-8 w-full text-xs"
|
||||
/>
|
||||
|
|
@ -258,10 +307,11 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<div className="space-y-1">
|
||||
<Label className="text-xs">Placeholder</Label>
|
||||
<Input
|
||||
value={field.placeholder || ""}
|
||||
onChange={(e) => updateField(index, { placeholder: e.target.value })}
|
||||
value={localInputs[index]?.placeholder !== undefined ? localInputs[index].placeholder : (field.placeholder || "")}
|
||||
onChange={(e) => updateFieldLocal(index, 'placeholder', e.target.value)}
|
||||
onBlur={() => handleFieldBlur(index)}
|
||||
placeholder="입력 안내"
|
||||
className="h-8 w-full"
|
||||
className="h-8 w-full text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -329,8 +379,9 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
|
|||
<Input
|
||||
id="repeater-add-button-text"
|
||||
type="text"
|
||||
value={config.addButtonText || ""}
|
||||
onChange={(e) => handleChange("addButtonText", e.target.value)}
|
||||
value={localConfigInputs.addButtonText}
|
||||
onChange={(e) => setLocalConfigInputs({ ...localConfigInputs, addButtonText: e.target.value })}
|
||||
onBlur={() => handleChange("addButtonText", localConfigInputs.addButtonText)}
|
||||
placeholder="항목 추가"
|
||||
className="h-8"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -120,13 +120,14 @@ class BatchManagementAPIClass {
|
|||
apiUrl: string,
|
||||
apiKey: string,
|
||||
endpoint: string,
|
||||
method: 'GET' = 'GET',
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
|
||||
paramInfo?: {
|
||||
paramType: 'url' | 'query';
|
||||
paramName: string;
|
||||
paramValue: string;
|
||||
paramSource: 'static' | 'dynamic';
|
||||
}
|
||||
},
|
||||
requestBody?: string
|
||||
): Promise<{
|
||||
fields: string[];
|
||||
samples: any[];
|
||||
|
|
@ -137,7 +138,8 @@ class BatchManagementAPIClass {
|
|||
apiUrl,
|
||||
apiKey,
|
||||
endpoint,
|
||||
method
|
||||
method,
|
||||
requestBody
|
||||
};
|
||||
|
||||
// 파라미터 정보가 있으면 추가
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export type AuthType = "none" | "api-key" | "bearer" | "basic" | "oauth2";
|
||||
export type AuthType = "none" | "api-key" | "bearer" | "basic" | "oauth2" | "db-token";
|
||||
|
||||
export interface ExternalRestApiConnection {
|
||||
id?: number;
|
||||
|
|
@ -11,18 +11,34 @@ export interface ExternalRestApiConnection {
|
|||
base_url: string;
|
||||
endpoint_path?: string;
|
||||
default_headers: Record<string, string>;
|
||||
// 기본 메서드 및 바디 추가
|
||||
default_method?: string;
|
||||
default_body?: string;
|
||||
|
||||
auth_type: AuthType;
|
||||
auth_config?: {
|
||||
// API Key
|
||||
keyLocation?: "header" | "query";
|
||||
keyName?: string;
|
||||
keyValue?: string;
|
||||
// Bearer Token
|
||||
token?: string;
|
||||
// Basic Auth
|
||||
username?: string;
|
||||
password?: string;
|
||||
// OAuth2
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
tokenUrl?: string;
|
||||
accessToken?: string;
|
||||
|
||||
// DB 기반 토큰 모드
|
||||
dbTableName?: string;
|
||||
dbValueColumn?: string;
|
||||
dbWhereColumn?: string;
|
||||
dbWhereValue?: string;
|
||||
dbHeaderName?: string;
|
||||
dbHeaderTemplate?: string;
|
||||
};
|
||||
timeout?: number;
|
||||
retry_count?: number;
|
||||
|
|
@ -49,9 +65,11 @@ export interface RestApiTestRequest {
|
|||
id?: number;
|
||||
base_url: string;
|
||||
endpoint?: string;
|
||||
method?: "GET" | "POST" | "PUT" | "DELETE";
|
||||
method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
||||
headers?: Record<string, string>;
|
||||
body?: unknown; // 테스트 요청 바디 추가
|
||||
auth_type?: AuthType;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
auth_config?: any;
|
||||
timeout?: number;
|
||||
}
|
||||
|
|
@ -61,7 +79,7 @@ export interface RestApiTestResult {
|
|||
message: string;
|
||||
response_time?: number;
|
||||
status_code?: number;
|
||||
response_data?: any;
|
||||
response_data?: unknown;
|
||||
error_details?: string;
|
||||
}
|
||||
|
||||
|
|
@ -71,7 +89,7 @@ export interface ApiResponse<T> {
|
|||
message?: string;
|
||||
error?: {
|
||||
code: string;
|
||||
details?: any;
|
||||
details?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -184,6 +202,7 @@ export class ExternalRestApiConnectionAPI {
|
|||
{ value: "bearer", label: "Bearer Token" },
|
||||
{ value: "basic", label: "Basic Auth" },
|
||||
{ value: "oauth2", label: "OAuth 2.0" },
|
||||
{ value: "db-token", label: "DB 토큰" },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,25 @@ export async function getCategoryColumns(tableName: string) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메뉴별 카테고리 컬럼 목록 조회
|
||||
*
|
||||
* @param menuObjid 메뉴 OBJID
|
||||
* @returns 해당 메뉴와 상위 메뉴들이 설정한 모든 카테고리 컬럼
|
||||
*/
|
||||
export async function getCategoryColumnsByMenu(menuObjid: number) {
|
||||
try {
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: CategoryColumn[];
|
||||
}>(`/table-management/menu/${menuObjid}/category-columns`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
console.error("메뉴별 카테고리 컬럼 조회 실패:", error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 목록 조회 (메뉴 스코프)
|
||||
*
|
||||
|
|
|
|||
|
|
@ -289,17 +289,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
// modal-repeater-table은 배열 데이터를 다루므로 빈 배열로 초기화
|
||||
let currentValue;
|
||||
if (componentType === "modal-repeater-table") {
|
||||
// 🆕 EditModal에서 전달된 groupedData가 있으면 우선 사용
|
||||
// EditModal에서 전달된 groupedData가 있으면 우선 사용
|
||||
currentValue = props.groupedData || formData?.[fieldName] || [];
|
||||
|
||||
// 디버깅 로그
|
||||
console.log("🔍 [DynamicComponentRenderer] ModalRepeaterTable value 설정:", {
|
||||
hasGroupedData: !!props.groupedData,
|
||||
groupedDataLength: props.groupedData?.length || 0,
|
||||
fieldName,
|
||||
formDataValue: formData?.[fieldName],
|
||||
finalValueLength: Array.isArray(currentValue) ? currentValue.length : 0,
|
||||
});
|
||||
} else {
|
||||
currentValue = formData?.[fieldName] || "";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,8 +13,6 @@ import { ConditionalContainerProps, ConditionalSection } from "./types";
|
|||
import { ConditionalSectionViewer } from "./ConditionalSectionViewer";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
console.log("🚀 ConditionalContainerComponent 모듈 로드됨!");
|
||||
|
||||
/**
|
||||
* 조건부 컨테이너 컴포넌트
|
||||
* 상단 셀렉트박스 값에 따라 하단에 다른 UI를 표시
|
||||
|
|
@ -43,11 +41,6 @@ export function ConditionalContainerComponent({
|
|||
groupedData, // 🆕 그룹 데이터
|
||||
onSave, // 🆕 EditModal의 handleSave 콜백
|
||||
}: ConditionalContainerProps) {
|
||||
console.log("🎯 ConditionalContainerComponent 렌더링!", {
|
||||
isDesignMode,
|
||||
hasOnHeightChange: !!onHeightChange,
|
||||
componentId,
|
||||
});
|
||||
|
||||
// config prop 우선, 없으면 개별 prop 사용
|
||||
const controlField = config?.controlField || propControlField || "condition";
|
||||
|
|
@ -86,24 +79,8 @@ export function ConditionalContainerComponent({
|
|||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const previousHeightRef = useRef<number>(0);
|
||||
|
||||
// 🔍 디버그: props 확인
|
||||
useEffect(() => {
|
||||
console.log("🔍 ConditionalContainer props:", {
|
||||
isDesignMode,
|
||||
hasOnHeightChange: !!onHeightChange,
|
||||
componentId,
|
||||
selectedValue,
|
||||
});
|
||||
}, [isDesignMode, onHeightChange, componentId, selectedValue]);
|
||||
|
||||
// 높이 변화 감지 및 콜백 호출
|
||||
useEffect(() => {
|
||||
console.log("🔍 ResizeObserver 등록 조건:", {
|
||||
hasContainer: !!containerRef.current,
|
||||
isDesignMode,
|
||||
hasOnHeightChange: !!onHeightChange,
|
||||
});
|
||||
|
||||
if (!containerRef.current || isDesignMode || !onHeightChange) return;
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
|
|
|
|||
|
|
@ -195,17 +195,69 @@ export function ModalRepeaterTableComponent({
|
|||
const columnName = component?.columnName;
|
||||
const value = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
|
||||
|
||||
// ✅ onChange 래퍼 (기존 onChange 콜백 + onFormDataChange 호출)
|
||||
// ✅ onChange 래퍼 (기존 onChange 콜백 + onFormDataChange 호출 + 납기일 일괄 적용)
|
||||
const handleChange = (newData: any[]) => {
|
||||
// 🆕 납기일 일괄 적용 로직 (납기일 필드가 있는 경우만)
|
||||
let processedData = newData;
|
||||
|
||||
// 납기일 필드 찾기 (item_due_date, delivery_date, due_date 등)
|
||||
const dateField = columns.find(
|
||||
(col) =>
|
||||
col.field === "item_due_date" ||
|
||||
col.field === "delivery_date" ||
|
||||
col.field === "due_date"
|
||||
);
|
||||
|
||||
if (dateField && !isDeliveryDateApplied && newData.length > 0) {
|
||||
// 현재 상태: 납기일이 있는 행과 없는 행 개수 체크
|
||||
const itemsWithDate = newData.filter((item) => item[dateField.field]);
|
||||
const itemsWithoutDate = newData.filter((item) => !item[dateField.field]);
|
||||
|
||||
// 정확히 1개만 날짜가 있고, 나머지는 모두 비어있을 때 일괄 적용
|
||||
if (itemsWithDate.length === 1 && itemsWithoutDate.length > 0) {
|
||||
const selectedDate = itemsWithDate[0][dateField.field];
|
||||
processedData = newData.map((item) => ({
|
||||
...item,
|
||||
[dateField.field]: selectedDate, // 모든 행에 동일한 납기일 적용
|
||||
}));
|
||||
|
||||
setIsDeliveryDateApplied(true); // 플래그 활성화
|
||||
}
|
||||
}
|
||||
|
||||
// 🆕 수주일 일괄 적용 로직 (order_date 필드가 있는 경우만)
|
||||
const orderDateField = columns.find(
|
||||
(col) =>
|
||||
col.field === "order_date" ||
|
||||
col.field === "ordered_date"
|
||||
);
|
||||
|
||||
if (orderDateField && !isOrderDateApplied && newData.length > 0) {
|
||||
// ⚠️ 중요: 원본 newData를 참조해야 납기일의 영향을 받지 않음
|
||||
const itemsWithOrderDate = newData.filter((item) => item[orderDateField.field]);
|
||||
const itemsWithoutOrderDate = newData.filter((item) => !item[orderDateField.field]);
|
||||
|
||||
// ✅ 조건: 모든 행이 비어있는 초기 상태 → 어느 행에서든 첫 선택 시 전체 적용
|
||||
if (itemsWithOrderDate.length === 1 && itemsWithoutOrderDate.length === newData.length - 1) {
|
||||
const selectedOrderDate = itemsWithOrderDate[0][orderDateField.field];
|
||||
processedData = processedData.map((item) => ({
|
||||
...item,
|
||||
[orderDateField.field]: selectedOrderDate,
|
||||
}));
|
||||
|
||||
setIsOrderDateApplied(true); // 플래그 활성화
|
||||
}
|
||||
}
|
||||
|
||||
// 기존 onChange 콜백 호출 (호환성)
|
||||
const externalOnChange = componentConfig?.onChange || propOnChange;
|
||||
if (externalOnChange) {
|
||||
externalOnChange(newData);
|
||||
externalOnChange(processedData);
|
||||
}
|
||||
|
||||
// 🆕 onFormDataChange 호출하여 EditModal의 groupData 업데이트
|
||||
if (onFormDataChange && columnName) {
|
||||
onFormDataChange(columnName, newData);
|
||||
onFormDataChange(columnName, processedData);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -219,18 +271,22 @@ export function ModalRepeaterTableComponent({
|
|||
const companyCode = componentConfig?.companyCode || propCompanyCode;
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
// 🆕 납기일 일괄 적용 플래그 (딱 한 번만 실행)
|
||||
const [isDeliveryDateApplied, setIsDeliveryDateApplied] = useState(false);
|
||||
|
||||
// 🆕 수주일 일괄 적용 플래그 (딱 한 번만 실행)
|
||||
const [isOrderDateApplied, setIsOrderDateApplied] = useState(false);
|
||||
|
||||
// columns가 비어있으면 sourceColumns로부터 자동 생성
|
||||
const columns = React.useMemo((): RepeaterColumnConfig[] => {
|
||||
const configuredColumns = componentConfig?.columns || propColumns || [];
|
||||
|
||||
if (configuredColumns.length > 0) {
|
||||
console.log("✅ 설정된 columns 사용:", configuredColumns);
|
||||
return configuredColumns;
|
||||
}
|
||||
|
||||
// columns가 비어있으면 sourceColumns로부터 자동 생성
|
||||
if (sourceColumns.length > 0) {
|
||||
console.log("🔄 sourceColumns로부터 자동 생성:", sourceColumns);
|
||||
const autoColumns: RepeaterColumnConfig[] = sourceColumns.map((field) => ({
|
||||
field: field,
|
||||
label: field, // 필드명을 라벨로 사용 (나중에 설정에서 변경 가능)
|
||||
|
|
@ -238,99 +294,72 @@ export function ModalRepeaterTableComponent({
|
|||
type: "text" as const,
|
||||
width: "150px",
|
||||
}));
|
||||
console.log("📋 자동 생성된 columns:", autoColumns);
|
||||
return autoColumns;
|
||||
}
|
||||
|
||||
console.warn("⚠️ columns와 sourceColumns 모두 비어있음!");
|
||||
console.warn("⚠️ [ModalRepeaterTable] columns와 sourceColumns 모두 비어있음!");
|
||||
return [];
|
||||
}, [componentConfig?.columns, propColumns, sourceColumns]);
|
||||
|
||||
// 초기 props 로깅
|
||||
// 초기 props 검증
|
||||
useEffect(() => {
|
||||
if (rawSourceColumns.length !== sourceColumns.length) {
|
||||
console.warn(`⚠️ sourceColumns 필터링: ${rawSourceColumns.length}개 → ${sourceColumns.length}개 (빈 문자열 제거)`);
|
||||
console.warn(`⚠️ [ModalRepeaterTable] sourceColumns 필터링: ${rawSourceColumns.length}개 → ${sourceColumns.length}개`);
|
||||
}
|
||||
|
||||
if (rawUniqueField !== uniqueField) {
|
||||
console.warn(`⚠️ uniqueField 자동 보정: "${rawUniqueField}" → "${uniqueField}"`);
|
||||
console.warn(`⚠️ [ModalRepeaterTable] uniqueField 자동 보정: "${rawUniqueField}" → "${uniqueField}"`);
|
||||
}
|
||||
|
||||
console.log("🎬 ModalRepeaterTableComponent 마운트:", {
|
||||
columnsLength: columns.length,
|
||||
sourceTable,
|
||||
sourceColumns,
|
||||
uniqueField,
|
||||
});
|
||||
|
||||
if (columns.length === 0) {
|
||||
console.error("❌ columns가 비어있습니다! sourceColumns:", sourceColumns);
|
||||
} else {
|
||||
console.log("✅ columns 설정 완료:", columns.map(c => c.label || c.field).join(", "));
|
||||
console.error("❌ [ModalRepeaterTable] columns가 비어있습니다!", { sourceColumns });
|
||||
}
|
||||
}, []);
|
||||
|
||||
// value 변경 감지
|
||||
useEffect(() => {
|
||||
console.log("📦 ModalRepeaterTableComponent value 변경:", {
|
||||
valueLength: value.length,
|
||||
});
|
||||
}, [value]);
|
||||
|
||||
// 🆕 저장 요청 시에만 데이터 전달 (beforeFormSave 이벤트 리스너)
|
||||
useEffect(() => {
|
||||
const handleSaveRequest = async (event: Event) => {
|
||||
const componentKey = columnName || component?.id || "modal_repeater_data";
|
||||
|
||||
console.log("🔔 [ModalRepeaterTable] beforeFormSave 이벤트 수신!", {
|
||||
componentKey,
|
||||
itemsCount: value.length,
|
||||
hasOnFormDataChange: !!onFormDataChange,
|
||||
columnName,
|
||||
componentId: component?.id,
|
||||
targetTable,
|
||||
});
|
||||
|
||||
if (value.length === 0) {
|
||||
console.warn("⚠️ [ModalRepeaterTable] 저장할 데이터 없음");
|
||||
return;
|
||||
}
|
||||
|
||||
// 🔥 sourceColumns에 포함된 컬럼 제외 (조인된 컬럼 제거)
|
||||
console.log("🔍 [ModalRepeaterTable] 필터링 전 데이터:", {
|
||||
sourceColumns,
|
||||
sourceTable,
|
||||
targetTable,
|
||||
sampleItem: value[0],
|
||||
itemKeys: value[0] ? Object.keys(value[0]) : [],
|
||||
});
|
||||
// sourceColumns에 포함된 컬럼 제외 (조인된 컬럼 제거)
|
||||
// 단, columnMappings에 정의된 컬럼은 저장해야 하므로 제외하지 않음
|
||||
const mappedFields = columns
|
||||
.filter(col => col.mapping?.type === "source" && col.mapping?.sourceField)
|
||||
.map(col => col.field);
|
||||
|
||||
const filteredData = value.map((item: any) => {
|
||||
const filtered: Record<string, any> = {};
|
||||
|
||||
Object.keys(item).forEach((key) => {
|
||||
// sourceColumns에 포함된 컬럼은 제외 (item_info 테이블의 컬럼)
|
||||
if (sourceColumns.includes(key)) {
|
||||
console.log(` ⛔ ${key} 제외 (sourceColumn)`);
|
||||
return;
|
||||
}
|
||||
// 메타데이터 필드도 제외
|
||||
// 메타데이터 필드 제외
|
||||
if (key.startsWith("_")) {
|
||||
console.log(` ⛔ ${key} 제외 (메타데이터)`);
|
||||
return;
|
||||
}
|
||||
|
||||
// sourceColumns에 포함되어 있지만 columnMappings에도 정의된 경우 → 저장함
|
||||
if (mappedFields.includes(key)) {
|
||||
filtered[key] = item[key];
|
||||
return;
|
||||
}
|
||||
|
||||
// sourceColumns에만 있고 매핑 안 된 경우 → 제외 (조인 전용)
|
||||
if (sourceColumns.includes(key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 나머지는 모두 저장
|
||||
filtered[key] = item[key];
|
||||
});
|
||||
|
||||
return filtered;
|
||||
});
|
||||
|
||||
console.log("✅ [ModalRepeaterTable] 필터링 후 데이터:", {
|
||||
filteredItemKeys: filteredData[0] ? Object.keys(filteredData[0]) : [],
|
||||
sampleFilteredItem: filteredData[0],
|
||||
});
|
||||
|
||||
// 🔥 targetTable 메타데이터를 배열 항목에 추가
|
||||
// targetTable 메타데이터를 배열 항목에 추가
|
||||
const dataWithTargetTable = targetTable
|
||||
? filteredData.map((item: any) => ({
|
||||
...item,
|
||||
|
|
@ -338,21 +367,19 @@ export function ModalRepeaterTableComponent({
|
|||
}))
|
||||
: filteredData;
|
||||
|
||||
// ✅ CustomEvent의 detail에 데이터 추가
|
||||
// CustomEvent의 detail에 데이터 추가
|
||||
if (event instanceof CustomEvent && event.detail) {
|
||||
event.detail.formData[componentKey] = dataWithTargetTable;
|
||||
console.log("✅ [ModalRepeaterTable] context.formData에 데이터 추가 완료:", {
|
||||
console.log("✅ [ModalRepeaterTable] 저장 데이터 준비:", {
|
||||
key: componentKey,
|
||||
itemCount: dataWithTargetTable.length,
|
||||
targetTable: targetTable || "미설정 (화면 설계에서 설정 필요)",
|
||||
sampleItem: dataWithTargetTable[0],
|
||||
targetTable: targetTable || "미설정",
|
||||
});
|
||||
}
|
||||
|
||||
// 기존 onFormDataChange도 호출 (호환성)
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange(componentKey, dataWithTargetTable);
|
||||
console.log("✅ [ModalRepeaterTable] onFormDataChange 호출 완료");
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ import { cn } from "@/lib/utils";
|
|||
|
||||
interface ModalRepeaterTableConfigPanelProps {
|
||||
config: Partial<ModalRepeaterTableProps>;
|
||||
onConfigChange: (config: Partial<ModalRepeaterTableProps>) => void;
|
||||
onChange: (config: Partial<ModalRepeaterTableProps>) => void;
|
||||
onConfigChange?: (config: Partial<ModalRepeaterTableProps>) => void; // 하위 호환성
|
||||
}
|
||||
|
||||
// 소스 컬럼 선택기 (동적 테이블별 컬럼 로드)
|
||||
|
|
@ -124,8 +125,11 @@ function ReferenceColumnSelector({
|
|||
|
||||
export function ModalRepeaterTableConfigPanel({
|
||||
config,
|
||||
onChange,
|
||||
onConfigChange,
|
||||
}: ModalRepeaterTableConfigPanelProps) {
|
||||
// 하위 호환성: onConfigChange가 있으면 사용, 없으면 onChange 사용
|
||||
const handleConfigChange = onConfigChange || onChange;
|
||||
// 초기 설정 정리: 계산 규칙과 컬럼 설정 동기화
|
||||
const cleanupInitialConfig = (initialConfig: Partial<ModalRepeaterTableProps>): Partial<ModalRepeaterTableProps> => {
|
||||
// 계산 규칙이 없으면 모든 컬럼의 calculated 속성 제거
|
||||
|
|
@ -241,7 +245,7 @@ export function ModalRepeaterTableConfigPanel({
|
|||
const updateConfig = (updates: Partial<ModalRepeaterTableProps>) => {
|
||||
const newConfig = { ...localConfig, ...updates };
|
||||
setLocalConfig(newConfig);
|
||||
onConfigChange(newConfig);
|
||||
handleConfigChange(newConfig);
|
||||
};
|
||||
|
||||
const addSourceColumn = () => {
|
||||
|
|
|
|||
|
|
@ -83,11 +83,22 @@ export function SectionPaperComponent({
|
|||
? { backgroundColor: config.customColor }
|
||||
: {};
|
||||
|
||||
// 선택 상태 테두리 처리 (outline 사용하여 크기 영향 없음)
|
||||
const selectionStyle = isDesignMode && isSelected
|
||||
? {
|
||||
outline: "2px solid #3b82f6",
|
||||
outlineOffset: "0px", // 크기에 영향 없이 딱 맞게 표시
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
// 기본 스타일
|
||||
"relative transition-colors overflow-visible",
|
||||
"relative transition-colors",
|
||||
|
||||
// 높이 고정을 위한 overflow 처리
|
||||
"overflow-auto",
|
||||
|
||||
// 배경색
|
||||
backgroundColor !== "custom" && backgroundColorMap[backgroundColor],
|
||||
|
|
@ -101,37 +112,36 @@ export function SectionPaperComponent({
|
|||
// 그림자
|
||||
shadowMap[shadow],
|
||||
|
||||
// 테두리 (선택)
|
||||
showBorder &&
|
||||
// 테두리 (선택 상태가 아닐 때만)
|
||||
!isSelected && showBorder &&
|
||||
borderStyle === "subtle" &&
|
||||
"border border-border/30",
|
||||
|
||||
// 디자인 모드에서 선택된 상태
|
||||
isDesignMode && isSelected && "ring-2 ring-primary ring-offset-2",
|
||||
|
||||
// 디자인 모드에서 빈 상태 표시
|
||||
isDesignMode && !children && "min-h-[100px] border-2 border-dashed border-muted-foreground/30",
|
||||
// 디자인 모드에서 빈 상태 표시 (테두리만, 최소 높이 제거)
|
||||
isDesignMode && !children && "border-2 border-dashed border-muted-foreground/30",
|
||||
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
// 크기를 100%로 설정하여 부모 크기에 맞춤
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
boxSizing: "border-box", // padding과 border를 크기에 포함
|
||||
...customBgStyle,
|
||||
...component?.style,
|
||||
...selectionStyle,
|
||||
...component?.style, // 사용자 설정이 최종 우선순위
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* 디자인 모드에서 빈 상태 안내 */}
|
||||
{isDesignMode && !children && (
|
||||
{/* 자식 컴포넌트들 */}
|
||||
{children || (isDesignMode && (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
|
||||
<div className="text-center">
|
||||
<div className="mb-1">📄 Section Paper</div>
|
||||
<div className="text-xs">컴포넌트를 이곳에 배치하세요</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 자식 컴포넌트들 */}
|
||||
{children}
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,14 +15,14 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command
|
|||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getSecondLevelMenus, getCategoryColumns, getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
import { getSecondLevelMenus, getCategoryColumns, getCategoryColumnsByMenu, getCategoryValues } from "@/lib/api/tableCategoryValue";
|
||||
import { CalculationBuilder } from "./CalculationBuilder";
|
||||
|
||||
export interface SelectedItemsDetailInputConfigPanelProps {
|
||||
config: SelectedItemsDetailInputConfig;
|
||||
onChange: (config: Partial<SelectedItemsDetailInputConfig>) => void;
|
||||
sourceTableColumns?: Array<{ columnName: string; columnLabel?: string; dataType?: string }>; // 🆕 원본 테이블 컬럼
|
||||
targetTableColumns?: Array<{ columnName: string; columnLabel?: string; dataType?: string }>; // 🆕 대상 테이블 컬럼
|
||||
sourceTableColumns?: Array<{ columnName: string; columnLabel?: string; dataType?: string; inputType?: string }>; // 🆕 원본 테이블 컬럼 (inputType 추가)
|
||||
targetTableColumns?: Array<{ columnName: string; columnLabel?: string; dataType?: string; inputType?: string; codeCategory?: string }>; // 🆕 대상 테이블 컬럼 (inputType, codeCategory 추가)
|
||||
allTables?: Array<{ tableName: string; displayName?: string }>;
|
||||
screenTableName?: string; // 🆕 현재 화면의 테이블명 (자동 설정용)
|
||||
onSourceTableChange?: (tableName: string) => void; // 🆕 원본 테이블 변경 콜백
|
||||
|
|
@ -50,6 +50,18 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
// 🆕 필드 그룹 상태
|
||||
const [localFieldGroups, setLocalFieldGroups] = useState<FieldGroup[]>(config.fieldGroups || []);
|
||||
|
||||
// 🆕 그룹 입력값을 위한 로컬 상태 (포커스 유지용)
|
||||
const [localGroupInputs, setLocalGroupInputs] = useState<Record<string, { id?: string; title?: string; description?: string; order?: number }>>({});
|
||||
|
||||
// 🆕 필드 입력값을 위한 로컬 상태 (포커스 유지용)
|
||||
const [localFieldInputs, setLocalFieldInputs] = useState<Record<number, { label?: string; placeholder?: string }>>({});
|
||||
|
||||
// 🆕 표시 항목의 입력값을 위한 로컬 상태 (포커스 유지용)
|
||||
const [localDisplayItemInputs, setLocalDisplayItemInputs] = useState<Record<string, Record<number, { label?: string; value?: string }>>>({});
|
||||
|
||||
// 🆕 부모 데이터 매핑의 기본값 입력을 위한 로컬 상태 (포커스 유지용)
|
||||
const [localMappingInputs, setLocalMappingInputs] = useState<Record<number, string>>({});
|
||||
|
||||
|
||||
// 🆕 그룹별 펼침/접힘 상태
|
||||
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
|
||||
|
|
@ -57,6 +69,13 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
// 🆕 그룹별 표시 항목 설정 펼침/접힘 상태
|
||||
const [expandedDisplayItems, setExpandedDisplayItems] = useState<Record<string, boolean>>({});
|
||||
|
||||
// 🆕 카테고리 매핑 아코디언 펼침/접힘 상태
|
||||
const [expandedCategoryMappings, setExpandedCategoryMappings] = useState<Record<string, boolean>>({
|
||||
discountType: false,
|
||||
roundingType: false,
|
||||
roundingUnit: false,
|
||||
});
|
||||
|
||||
// 🆕 원본 테이블 선택 상태
|
||||
const [sourceTableSelectOpen, setSourceTableSelectOpen] = useState(false);
|
||||
const [sourceTableSearchValue, setSourceTableSearchValue] = useState("");
|
||||
|
|
@ -77,8 +96,8 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
const [autoFillTableColumns, setAutoFillTableColumns] = useState<Record<number, Array<{ columnName: string; columnLabel?: string; dataType?: string }>>>({});
|
||||
|
||||
// 🆕 원본/대상 테이블 컬럼 상태 (내부에서 로드)
|
||||
const [loadedSourceTableColumns, setLoadedSourceTableColumns] = useState<Array<{ columnName: string; columnLabel?: string; dataType?: string }>>([]);
|
||||
const [loadedTargetTableColumns, setLoadedTargetTableColumns] = useState<Array<{ columnName: string; columnLabel?: string; dataType?: string }>>([]);
|
||||
const [loadedSourceTableColumns, setLoadedSourceTableColumns] = useState<Array<{ columnName: string; columnLabel?: string; dataType?: string; inputType?: string }>>([]);
|
||||
const [loadedTargetTableColumns, setLoadedTargetTableColumns] = useState<Array<{ columnName: string; columnLabel?: string; dataType?: string; inputType?: string; codeCategory?: string }>>([]);
|
||||
|
||||
// 🆕 원본 테이블 컬럼 로드
|
||||
useEffect(() => {
|
||||
|
|
@ -99,6 +118,7 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
columnName: col.columnName,
|
||||
columnLabel: col.displayName || col.columnLabel || col.columnName,
|
||||
dataType: col.dataType,
|
||||
inputType: col.inputType, // 🔧 inputType 추가
|
||||
})));
|
||||
console.log("✅ 원본 테이블 컬럼 로드 성공:", columns.length);
|
||||
}
|
||||
|
|
@ -129,6 +149,7 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
columnName: col.columnName,
|
||||
columnLabel: col.displayName || col.columnLabel || col.columnName,
|
||||
dataType: col.dataType,
|
||||
inputType: col.inputType, // 🔧 inputType 추가
|
||||
})));
|
||||
console.log("✅ 대상 테이블 컬럼 로드 성공:", columns.length);
|
||||
}
|
||||
|
|
@ -140,6 +161,39 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
loadColumns();
|
||||
}, [config.targetTable]);
|
||||
|
||||
// 🆕 필드 그룹 변경 시 로컬 입력 상태 동기화
|
||||
useEffect(() => {
|
||||
setLocalFieldGroups(config.fieldGroups || []);
|
||||
|
||||
// 로컬 입력 상태는 기존 값 보존하면서 새 그룹만 추가
|
||||
setLocalGroupInputs(prev => {
|
||||
const newInputs = { ...prev };
|
||||
(config.fieldGroups || []).forEach(group => {
|
||||
if (!(group.id in newInputs)) {
|
||||
newInputs[group.id] = {
|
||||
id: group.id,
|
||||
title: group.title,
|
||||
description: group.description,
|
||||
order: group.order,
|
||||
};
|
||||
}
|
||||
});
|
||||
return newInputs;
|
||||
});
|
||||
|
||||
// 🔧 표시 항목이 있는 그룹은 아코디언을 열린 상태로 초기화
|
||||
setExpandedDisplayItems(prev => {
|
||||
const newExpanded = { ...prev };
|
||||
(config.fieldGroups || []).forEach(group => {
|
||||
// 이미 상태가 있으면 유지, 없으면 displayItems가 있을 때만 열기
|
||||
if (!(group.id in newExpanded) && group.displayItems && group.displayItems.length > 0) {
|
||||
newExpanded[group.id] = true;
|
||||
}
|
||||
});
|
||||
return newExpanded;
|
||||
});
|
||||
}, [config.fieldGroups]);
|
||||
|
||||
// 🆕 초기 렌더링 시 기존 필드들의 autoFillFromTable 컬럼 로드
|
||||
useEffect(() => {
|
||||
if (!localFields || localFields.length === 0) return;
|
||||
|
|
@ -211,6 +265,36 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
}
|
||||
};
|
||||
|
||||
// 🆕 저장된 부모 데이터 매핑의 컬럼 자동 로드
|
||||
useEffect(() => {
|
||||
const loadSavedMappingColumns = async () => {
|
||||
if (!config.parentDataMapping || config.parentDataMapping.length === 0) {
|
||||
console.log("📭 [부모 데이터 매핑] 매핑이 없습니다");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("🔍 [부모 데이터 매핑] 저장된 매핑 컬럼 자동 로드 시작:", config.parentDataMapping.length);
|
||||
|
||||
for (let i = 0; i < config.parentDataMapping.length; i++) {
|
||||
const mapping = config.parentDataMapping[i];
|
||||
|
||||
// 이미 로드된 컬럼이 있으면 스킵
|
||||
if (mappingSourceColumns[i] && mappingSourceColumns[i].length > 0) {
|
||||
console.log(`⏭️ [매핑 ${i}] 이미 로드된 컬럼이 있음`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 소스 테이블이 선택되어 있으면 컬럼 로드
|
||||
if (mapping.sourceTable) {
|
||||
console.log(`📡 [매핑 ${i}] 소스 테이블 컬럼 자동 로드:`, mapping.sourceTable);
|
||||
await loadMappingSourceColumns(mapping.sourceTable, i);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadSavedMappingColumns();
|
||||
}, [config.parentDataMapping]);
|
||||
|
||||
// 2레벨 메뉴 목록 로드
|
||||
useEffect(() => {
|
||||
const loadMenus = async () => {
|
||||
|
|
@ -224,26 +308,39 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
|
||||
// 메뉴 선택 시 카테고리 목록 로드
|
||||
const handleMenuSelect = async (menuObjid: number, fieldType: "discountType" | "roundingType" | "roundingUnit") => {
|
||||
if (!config.targetTable) {
|
||||
console.warn("⚠️ targetTable이 설정되지 않았습니다");
|
||||
return;
|
||||
}
|
||||
console.log("🔍 [handleMenuSelect] 시작", { menuObjid, fieldType });
|
||||
|
||||
console.log("🔍 카테고리 목록 로드 시작", { targetTable: config.targetTable, menuObjid, fieldType });
|
||||
// 🔧 1단계: 아코디언 먼저 열기 (리렌더링 전에)
|
||||
setExpandedCategoryMappings(prev => {
|
||||
const newState = { ...prev, [fieldType]: true };
|
||||
console.log("🔄 [handleMenuSelect] 아코디언 열기:", newState);
|
||||
return newState;
|
||||
});
|
||||
|
||||
const response = await getCategoryColumns(config.targetTable);
|
||||
// 🔧 2단계: 메뉴별 카테고리 컬럼 API 호출
|
||||
const response = await getCategoryColumnsByMenu(menuObjid);
|
||||
|
||||
console.log("📥 getCategoryColumns 응답:", response);
|
||||
console.log("📥 [handleMenuSelect] API 응답:", response);
|
||||
|
||||
if (response.success && response.data) {
|
||||
console.log("✅ 카테고리 컬럼 데이터:", response.data);
|
||||
setCategoryColumns(prev => ({ ...prev, [fieldType]: response.data }));
|
||||
console.log("✅ [handleMenuSelect] 카테고리 컬럼 데이터:", {
|
||||
fieldType,
|
||||
columns: response.data,
|
||||
count: response.data.length
|
||||
});
|
||||
|
||||
// 카테고리 컬럼 상태 업데이트
|
||||
setCategoryColumns(prev => {
|
||||
const newState = { ...prev, [fieldType]: response.data };
|
||||
console.log("🔄 [handleMenuSelect] categoryColumns 업데이트:", newState);
|
||||
return newState;
|
||||
});
|
||||
} else {
|
||||
console.error("❌ 카테고리 컬럼 로드 실패:", response);
|
||||
console.error("❌ [handleMenuSelect] 카테고리 컬럼 로드 실패:", response);
|
||||
}
|
||||
|
||||
// valueMapping 업데이트
|
||||
handleChange("autoCalculation", {
|
||||
// 🔧 3단계: valueMapping 업데이트 (마지막에)
|
||||
const newConfig = {
|
||||
...config.autoCalculation,
|
||||
valueMapping: {
|
||||
...config.autoCalculation.valueMapping,
|
||||
|
|
@ -252,20 +349,50 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
[fieldType]: menuObjid,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
console.log("🔄 [handleMenuSelect] valueMapping 업데이트:", newConfig);
|
||||
handleChange("autoCalculation", newConfig);
|
||||
};
|
||||
|
||||
// 카테고리 선택 시 카테고리 값 목록 로드
|
||||
const handleCategorySelect = async (columnName: string, menuObjid: number, fieldType: "discountType" | "roundingType" | "roundingUnit") => {
|
||||
if (!config.targetTable) return;
|
||||
console.log("🔍 [handleCategorySelect] 시작", { columnName, menuObjid, fieldType, targetTable: config.targetTable });
|
||||
|
||||
const response = await getCategoryValues(config.targetTable, columnName, false, menuObjid);
|
||||
if (response.success && response.data) {
|
||||
setCategoryValues(prev => ({ ...prev, [fieldType]: response.data }));
|
||||
if (!config.targetTable) {
|
||||
console.warn("⚠️ [handleCategorySelect] targetTable이 없습니다");
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await getCategoryValues(config.targetTable, columnName, false, menuObjid);
|
||||
|
||||
console.log("📥 [handleCategorySelect] API 응답:", response);
|
||||
|
||||
if (response.success && response.data) {
|
||||
console.log("✅ [handleCategorySelect] 카테고리 값 데이터:", {
|
||||
fieldType,
|
||||
values: response.data,
|
||||
count: response.data.length
|
||||
});
|
||||
|
||||
setCategoryValues(prev => {
|
||||
const newState = { ...prev, [fieldType]: response.data };
|
||||
console.log("🔄 [handleCategorySelect] categoryValues 업데이트:", newState);
|
||||
return newState;
|
||||
});
|
||||
} else {
|
||||
console.error("❌ [handleCategorySelect] 카테고리 값 로드 실패:", response);
|
||||
}
|
||||
|
||||
// 🔧 카테고리 선택 시 아코디언 열기 (이미 열려있을 수도 있음)
|
||||
setExpandedCategoryMappings(prev => {
|
||||
const newState = { ...prev, [fieldType]: true };
|
||||
console.log("🔄 [handleCategorySelect] 아코디언 상태:", newState);
|
||||
return newState;
|
||||
});
|
||||
|
||||
// valueMapping 업데이트
|
||||
handleChange("autoCalculation", {
|
||||
const newConfig = {
|
||||
...config.autoCalculation,
|
||||
valueMapping: {
|
||||
...config.autoCalculation.valueMapping,
|
||||
|
|
@ -274,9 +401,99 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
[fieldType]: columnName,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
console.log("🔄 [handleCategorySelect] valueMapping 업데이트:", newConfig);
|
||||
handleChange("autoCalculation", newConfig);
|
||||
};
|
||||
|
||||
// 🆕 저장된 설정에서 카테고리 정보 복원
|
||||
useEffect(() => {
|
||||
const loadSavedCategories = async () => {
|
||||
console.log("🔍 [loadSavedCategories] useEffect 실행", {
|
||||
hasTargetTable: !!config.targetTable,
|
||||
hasAutoCalc: !!config.autoCalculation,
|
||||
hasValueMapping: !!config.autoCalculation?.valueMapping
|
||||
});
|
||||
|
||||
if (!config.targetTable || !config.autoCalculation?.valueMapping) {
|
||||
console.warn("⚠️ [loadSavedCategories] targetTable 또는 valueMapping이 없어 종료");
|
||||
return;
|
||||
}
|
||||
|
||||
const savedMenus = (config.autoCalculation.valueMapping as any)?._selectedMenus;
|
||||
const savedCategories = (config.autoCalculation.valueMapping as any)?._selectedCategories;
|
||||
|
||||
console.log("🔄 [loadSavedCategories] 저장된 카테고리 설정 복원 시작:", { savedMenus, savedCategories });
|
||||
|
||||
// 각 필드 타입별로 저장된 카테고리 값 로드
|
||||
const fieldTypes: Array<"discountType" | "roundingType" | "roundingUnit"> = ["discountType", "roundingType", "roundingUnit"];
|
||||
|
||||
// 🔧 복원할 아코디언 상태 준비
|
||||
const newExpandedState: Record<string, boolean> = {};
|
||||
|
||||
for (const fieldType of fieldTypes) {
|
||||
const menuObjid = savedMenus?.[fieldType];
|
||||
const columnName = savedCategories?.[fieldType];
|
||||
|
||||
console.log(`🔍 [loadSavedCategories] ${fieldType} 처리`, { menuObjid, columnName });
|
||||
|
||||
// 🔧 메뉴만 선택된 경우에도 카테고리 컬럼 로드
|
||||
if (menuObjid) {
|
||||
console.log(`✅ [loadSavedCategories] ${fieldType} 메뉴 발견, 카테고리 컬럼 로드 시작:`, { menuObjid });
|
||||
|
||||
// 🔧 메뉴가 선택되어 있으면 아코디언 열기
|
||||
newExpandedState[fieldType] = true;
|
||||
|
||||
// 🔧 메뉴별 카테고리 컬럼 로드 (카테고리 선택 여부와 무관)
|
||||
console.log(`📡 [loadSavedCategories] ${fieldType} 카테고리 컬럼 API 호출`, { menuObjid });
|
||||
const columnsResponse = await getCategoryColumnsByMenu(menuObjid);
|
||||
console.log(`📥 [loadSavedCategories] ${fieldType} 컬럼 응답:`, columnsResponse);
|
||||
|
||||
if (columnsResponse.success && columnsResponse.data) {
|
||||
setCategoryColumns(prev => {
|
||||
const newState = { ...prev, [fieldType]: columnsResponse.data };
|
||||
console.log(`🔄 [loadSavedCategories] ${fieldType} categoryColumns 업데이트:`, newState);
|
||||
return newState;
|
||||
});
|
||||
} else {
|
||||
console.error(`❌ [loadSavedCategories] ${fieldType} 컬럼 로드 실패:`, columnsResponse);
|
||||
}
|
||||
|
||||
// 🔧 카테고리까지 선택된 경우에만 값 로드
|
||||
if (columnName) {
|
||||
console.log(`📡 [loadSavedCategories] ${fieldType} 카테고리 값 API 호출`, { columnName });
|
||||
const valuesResponse = await getCategoryValues(config.targetTable, columnName, false, menuObjid);
|
||||
console.log(`📥 [loadSavedCategories] ${fieldType} 값 응답:`, valuesResponse);
|
||||
|
||||
if (valuesResponse.success && valuesResponse.data) {
|
||||
console.log(`✅ [loadSavedCategories] ${fieldType} 카테고리 값:`, valuesResponse.data);
|
||||
setCategoryValues(prev => {
|
||||
const newState = { ...prev, [fieldType]: valuesResponse.data };
|
||||
console.log(`🔄 [loadSavedCategories] ${fieldType} categoryValues 업데이트:`, newState);
|
||||
return newState;
|
||||
});
|
||||
} else {
|
||||
console.error(`❌ [loadSavedCategories] ${fieldType} 값 로드 실패:`, valuesResponse);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🔧 저장된 설정이 있는 아코디언들 열기
|
||||
if (Object.keys(newExpandedState).length > 0) {
|
||||
console.log("🔓 [loadSavedCategories] 아코디언 열기:", newExpandedState);
|
||||
setExpandedCategoryMappings(prev => {
|
||||
const finalState = { ...prev, ...newExpandedState };
|
||||
console.log("🔄 [loadSavedCategories] 최종 아코디언 상태:", finalState);
|
||||
return finalState;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
loadSavedCategories();
|
||||
}, [config.targetTable, config.autoCalculation?.valueMapping]);
|
||||
|
||||
// 🆕 초기 로드 시 screenTableName을 targetTable로 자동 설정
|
||||
React.useEffect(() => {
|
||||
if (screenTableName && !config.targetTable) {
|
||||
|
|
@ -317,10 +534,37 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
|
||||
// 필드 제거
|
||||
const removeField = (index: number) => {
|
||||
// 로컬 입력 상태에서도 제거
|
||||
setLocalFieldInputs(prev => {
|
||||
const newInputs = { ...prev };
|
||||
delete newInputs[index];
|
||||
return newInputs;
|
||||
});
|
||||
handleFieldsChange(localFields.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// 필드 수정
|
||||
// 🆕 로컬 필드 입력 업데이트 (포커스 유지용)
|
||||
const updateFieldLocal = (index: number, field: 'label' | 'placeholder', value: string) => {
|
||||
setLocalFieldInputs(prev => ({
|
||||
...prev,
|
||||
[index]: {
|
||||
...prev[index],
|
||||
[field]: value
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
// 🆕 실제 필드 데이터 업데이트 (onBlur 시 호출)
|
||||
const handleFieldBlur = (index: number) => {
|
||||
const localInput = localFieldInputs[index];
|
||||
if (localInput) {
|
||||
const newFields = [...localFields];
|
||||
newFields[index] = { ...newFields[index], ...localInput };
|
||||
handleFieldsChange(newFields);
|
||||
}
|
||||
};
|
||||
|
||||
// 필드 수정 (Switch 같은 즉시 업데이트가 필요한 경우에만 사용)
|
||||
const updateField = (index: number, updates: Partial<AdditionalFieldDefinition>) => {
|
||||
const newFields = [...localFields];
|
||||
newFields[index] = { ...newFields[index], ...updates };
|
||||
|
|
@ -343,6 +587,13 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
};
|
||||
|
||||
const removeFieldGroup = (groupId: string) => {
|
||||
// 로컬 입력 상태에서 해당 그룹 제거
|
||||
setLocalGroupInputs(prev => {
|
||||
const newInputs = { ...prev };
|
||||
delete newInputs[groupId];
|
||||
return newInputs;
|
||||
});
|
||||
|
||||
// 그룹 삭제 시 해당 그룹에 속한 필드들의 groupId도 제거
|
||||
const updatedFields = localFields.map(field =>
|
||||
field.groupId === groupId ? { ...field, groupId: undefined } : field
|
||||
|
|
@ -352,7 +603,30 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
handleFieldGroupsChange(localFieldGroups.filter(g => g.id !== groupId));
|
||||
};
|
||||
|
||||
// 🆕 로컬 그룹 입력 업데이트 (포커스 유지용)
|
||||
const updateGroupLocal = (groupId: string, field: 'id' | 'title' | 'description' | 'order', value: any) => {
|
||||
setLocalGroupInputs(prev => ({
|
||||
...prev,
|
||||
[groupId]: {
|
||||
...prev[groupId],
|
||||
[field]: value
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
// 🆕 실제 그룹 데이터 업데이트 (onBlur 시 호출)
|
||||
const handleGroupBlur = (groupId: string) => {
|
||||
const localInput = localGroupInputs[groupId];
|
||||
if (localInput) {
|
||||
const newGroups = localFieldGroups.map(g =>
|
||||
g.id === groupId ? { ...g, ...localInput } : g
|
||||
);
|
||||
handleFieldGroupsChange(newGroups);
|
||||
}
|
||||
};
|
||||
|
||||
const updateFieldGroup = (groupId: string, updates: Partial<FieldGroup>) => {
|
||||
// 2. 실제 그룹 데이터 업데이트 (Switch 같은 즉시 업데이트가 필요한 경우에만 사용)
|
||||
const newGroups = localFieldGroups.map(g =>
|
||||
g.id === groupId ? { ...g, ...updates } : g
|
||||
);
|
||||
|
|
@ -426,6 +700,12 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
return g;
|
||||
});
|
||||
|
||||
// 🔧 아이템 추가 시 해당 그룹의 아코디언을 열린 상태로 유지
|
||||
setExpandedDisplayItems(prev => ({
|
||||
...prev,
|
||||
[groupId]: true
|
||||
}));
|
||||
|
||||
setLocalFieldGroups(updatedGroups);
|
||||
handleChange("fieldGroups", updatedGroups);
|
||||
};
|
||||
|
|
@ -755,8 +1035,9 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
<div className="space-y-1">
|
||||
<Label className="text-[10px] sm:text-xs">라벨</Label>
|
||||
<Input
|
||||
value={field.label}
|
||||
onChange={(e) => updateField(index, { label: e.target.value })}
|
||||
value={localFieldInputs[index]?.label !== undefined ? localFieldInputs[index].label : field.label}
|
||||
onChange={(e) => updateFieldLocal(index, 'label', e.target.value)}
|
||||
onBlur={() => handleFieldBlur(index)}
|
||||
placeholder="필드 라벨"
|
||||
className="h-6 w-full text-[10px] sm:h-7 sm:text-xs"
|
||||
/>
|
||||
|
|
@ -780,8 +1061,9 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
<div className="space-y-1">
|
||||
<Label className="text-[10px] sm:text-xs">Placeholder</Label>
|
||||
<Input
|
||||
value={field.placeholder || ""}
|
||||
onChange={(e) => updateField(index, { placeholder: e.target.value })}
|
||||
value={localFieldInputs[index]?.placeholder !== undefined ? localFieldInputs[index].placeholder : (field.placeholder || "")}
|
||||
onChange={(e) => updateFieldLocal(index, 'placeholder', e.target.value)}
|
||||
onBlur={() => handleFieldBlur(index)}
|
||||
placeholder="입력 안내"
|
||||
className="h-6 w-full text-[10px] sm:h-7 sm:text-xs"
|
||||
/>
|
||||
|
|
@ -1036,8 +1318,9 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
<div className="space-y-1">
|
||||
<Label className="text-[10px] sm:text-xs">그룹 ID</Label>
|
||||
<Input
|
||||
value={group.id}
|
||||
onChange={(e) => updateFieldGroup(group.id, { id: e.target.value })}
|
||||
value={localGroupInputs[group.id]?.id !== undefined ? localGroupInputs[group.id].id : group.id}
|
||||
onChange={(e) => updateGroupLocal(group.id, 'id', e.target.value)}
|
||||
onBlur={() => handleGroupBlur(group.id)}
|
||||
className="h-7 text-xs sm:h-8 sm:text-sm"
|
||||
placeholder="group_customer"
|
||||
/>
|
||||
|
|
@ -1047,8 +1330,9 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
<div className="space-y-1">
|
||||
<Label className="text-[10px] sm:text-xs">그룹 제목</Label>
|
||||
<Input
|
||||
value={group.title}
|
||||
onChange={(e) => updateFieldGroup(group.id, { title: e.target.value })}
|
||||
value={localGroupInputs[group.id]?.title !== undefined ? localGroupInputs[group.id].title : group.title}
|
||||
onChange={(e) => updateGroupLocal(group.id, 'title', e.target.value)}
|
||||
onBlur={() => handleGroupBlur(group.id)}
|
||||
className="h-7 text-xs sm:h-8 sm:text-sm"
|
||||
placeholder="거래처 정보"
|
||||
/>
|
||||
|
|
@ -1058,8 +1342,9 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
<div className="space-y-1">
|
||||
<Label className="text-[10px] sm:text-xs">그룹 설명 (선택사항)</Label>
|
||||
<Input
|
||||
value={group.description || ""}
|
||||
onChange={(e) => updateFieldGroup(group.id, { description: e.target.value })}
|
||||
value={localGroupInputs[group.id]?.description !== undefined ? localGroupInputs[group.id].description : (group.description || "")}
|
||||
onChange={(e) => updateGroupLocal(group.id, 'description', e.target.value)}
|
||||
onBlur={() => handleGroupBlur(group.id)}
|
||||
className="h-7 text-xs sm:h-8 sm:text-sm"
|
||||
placeholder="거래처 관련 정보를 입력합니다"
|
||||
/>
|
||||
|
|
@ -1070,8 +1355,9 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
<Label className="text-[10px] sm:text-xs">표시 순서</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={group.order || 0}
|
||||
onChange={(e) => updateFieldGroup(group.id, { order: parseInt(e.target.value) || 0 })}
|
||||
value={localGroupInputs[group.id]?.order !== undefined ? localGroupInputs[group.id].order : (group.order || 0)}
|
||||
onChange={(e) => updateGroupLocal(group.id, 'order', parseInt(e.target.value) || 0)}
|
||||
onBlur={() => handleGroupBlur(group.id)}
|
||||
className="h-7 text-xs sm:h-8 sm:text-sm"
|
||||
min="0"
|
||||
/>
|
||||
|
|
@ -1167,8 +1453,30 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
{/* 아이콘 설정 */}
|
||||
{item.type === "icon" && (
|
||||
<Input
|
||||
value={item.icon || ""}
|
||||
onChange={(e) => updateDisplayItemInGroup(group.id, itemIndex, { icon: e.target.value })}
|
||||
value={
|
||||
localDisplayItemInputs[group.id]?.[itemIndex]?.value !== undefined
|
||||
? localDisplayItemInputs[group.id][itemIndex].value
|
||||
: item.icon || ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalDisplayItemInputs(prev => ({
|
||||
...prev,
|
||||
[group.id]: {
|
||||
...prev[group.id],
|
||||
[itemIndex]: {
|
||||
...prev[group.id]?.[itemIndex],
|
||||
value: newValue
|
||||
}
|
||||
}
|
||||
}));
|
||||
}}
|
||||
onBlur={() => {
|
||||
const localValue = localDisplayItemInputs[group.id]?.[itemIndex]?.value;
|
||||
if (localValue !== undefined) {
|
||||
updateDisplayItemInGroup(group.id, itemIndex, { icon: localValue });
|
||||
}
|
||||
}}
|
||||
placeholder="Building"
|
||||
className="h-6 text-[9px] sm:text-[10px]"
|
||||
/>
|
||||
|
|
@ -1177,8 +1485,31 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
{/* 텍스트 설정 */}
|
||||
{item.type === "text" && (
|
||||
<Input
|
||||
value={item.value || ""}
|
||||
onChange={(e) => updateDisplayItemInGroup(group.id, itemIndex, { value: e.target.value })}
|
||||
value={
|
||||
localDisplayItemInputs[group.id]?.[itemIndex]?.value !== undefined
|
||||
? localDisplayItemInputs[group.id][itemIndex].value
|
||||
: item.value || ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
// 로컬 상태 즉시 업데이트 (포커스 유지)
|
||||
setLocalDisplayItemInputs(prev => ({
|
||||
...prev,
|
||||
[group.id]: {
|
||||
...prev[group.id],
|
||||
[itemIndex]: {
|
||||
...prev[group.id]?.[itemIndex],
|
||||
value: newValue
|
||||
}
|
||||
}
|
||||
}));
|
||||
}}
|
||||
onBlur={() => {
|
||||
const localValue = localDisplayItemInputs[group.id]?.[itemIndex]?.value;
|
||||
if (localValue !== undefined) {
|
||||
updateDisplayItemInGroup(group.id, itemIndex, { value: localValue });
|
||||
}
|
||||
}}
|
||||
placeholder="| , / , -"
|
||||
className="h-6 text-[9px] sm:text-[10px]"
|
||||
/>
|
||||
|
|
@ -1206,8 +1537,31 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
|
||||
{/* 라벨 */}
|
||||
<Input
|
||||
value={item.label || ""}
|
||||
onChange={(e) => updateDisplayItemInGroup(group.id, itemIndex, { label: e.target.value })}
|
||||
value={
|
||||
localDisplayItemInputs[group.id]?.[itemIndex]?.label !== undefined
|
||||
? localDisplayItemInputs[group.id][itemIndex].label
|
||||
: item.label || ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
// 로컬 상태 즉시 업데이트 (포커스 유지)
|
||||
setLocalDisplayItemInputs(prev => ({
|
||||
...prev,
|
||||
[group.id]: {
|
||||
...prev[group.id],
|
||||
[itemIndex]: {
|
||||
...prev[group.id]?.[itemIndex],
|
||||
label: newValue
|
||||
}
|
||||
}
|
||||
}));
|
||||
}}
|
||||
onBlur={() => {
|
||||
const localValue = localDisplayItemInputs[group.id]?.[itemIndex]?.label;
|
||||
if (localValue !== undefined) {
|
||||
updateDisplayItemInGroup(group.id, itemIndex, { label: localValue });
|
||||
}
|
||||
}}
|
||||
placeholder="라벨 (예: 거래처:)"
|
||||
className="h-6 w-full text-[9px] sm:text-[10px]"
|
||||
/>
|
||||
|
|
@ -1247,8 +1601,30 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
{/* 기본값 */}
|
||||
{item.emptyBehavior === "default" && (
|
||||
<Input
|
||||
value={item.defaultValue || ""}
|
||||
onChange={(e) => updateDisplayItemInGroup(group.id, itemIndex, { defaultValue: e.target.value })}
|
||||
value={
|
||||
localDisplayItemInputs[group.id]?.[itemIndex]?.value !== undefined
|
||||
? localDisplayItemInputs[group.id][itemIndex].value
|
||||
: item.defaultValue || ""
|
||||
}
|
||||
onChange={(e) => {
|
||||
const newValue = e.target.value;
|
||||
setLocalDisplayItemInputs(prev => ({
|
||||
...prev,
|
||||
[group.id]: {
|
||||
...prev[group.id],
|
||||
[itemIndex]: {
|
||||
...prev[group.id]?.[itemIndex],
|
||||
value: newValue
|
||||
}
|
||||
}
|
||||
}));
|
||||
}}
|
||||
onBlur={() => {
|
||||
const localValue = localDisplayItemInputs[group.id]?.[itemIndex]?.value;
|
||||
if (localValue !== undefined) {
|
||||
updateDisplayItemInGroup(group.id, itemIndex, { defaultValue: localValue });
|
||||
}
|
||||
}}
|
||||
placeholder="미입력"
|
||||
className="h-6 w-full text-[9px] sm:text-[10px]"
|
||||
/>
|
||||
|
|
@ -1563,14 +1939,21 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
<Label className="text-[10px] font-semibold sm:text-xs">카테고리 값 매핑</Label>
|
||||
|
||||
{/* 할인 방식 매핑 */}
|
||||
<Collapsible>
|
||||
<Collapsible
|
||||
open={expandedCategoryMappings.discountType}
|
||||
onOpenChange={(open) => setExpandedCategoryMappings(prev => ({ ...prev, discountType: open }))}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex w-full items-center justify-between p-2 hover:bg-muted"
|
||||
>
|
||||
<span className="text-xs font-medium">할인 방식 연산 매핑</span>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
{expandedCategoryMappings.discountType ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-2 pt-2">
|
||||
|
|
@ -1595,30 +1978,40 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
</div>
|
||||
|
||||
{/* 2단계: 카테고리 선택 */}
|
||||
{(config.autoCalculation.valueMapping as any)?._selectedMenus?.discountType && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[9px] sm:text-[10px]">2단계: 카테고리 선택</Label>
|
||||
<Select
|
||||
value={(config.autoCalculation.valueMapping as any)?._selectedCategories?.discountType || ""}
|
||||
onValueChange={(value) => handleCategorySelect(
|
||||
value,
|
||||
(config.autoCalculation.valueMapping as any)._selectedMenus.discountType,
|
||||
"discountType"
|
||||
)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="카테고리 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(categoryColumns.discountType || []).map((col: any) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
const hasSelectedMenu = !!(config.autoCalculation.valueMapping as any)?._selectedMenus?.discountType;
|
||||
const columns = categoryColumns.discountType || [];
|
||||
console.log("🎨 [렌더링] 2단계 카테고리 선택", {
|
||||
hasSelectedMenu,
|
||||
columns,
|
||||
columnsCount: columns.length,
|
||||
categoryColumnsState: categoryColumns
|
||||
});
|
||||
return hasSelectedMenu ? (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[9px] sm:text-[10px]">2단계: 카테고리 선택</Label>
|
||||
<Select
|
||||
value={(config.autoCalculation.valueMapping as any)?._selectedCategories?.discountType || ""}
|
||||
onValueChange={(value) => handleCategorySelect(
|
||||
value,
|
||||
(config.autoCalculation.valueMapping as any)._selectedMenus.discountType,
|
||||
"discountType"
|
||||
)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue placeholder="카테고리 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((col: any) => (
|
||||
<SelectItem key={col.columnName} value={col.columnName}>
|
||||
{col.columnLabel || col.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
{/* 3단계: 값 매핑 */}
|
||||
{(config.autoCalculation.valueMapping as any)?._selectedCategories?.discountType && (
|
||||
|
|
@ -1673,14 +2066,21 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
</Collapsible>
|
||||
|
||||
{/* 반올림 방식 매핑 */}
|
||||
<Collapsible>
|
||||
<Collapsible
|
||||
open={expandedCategoryMappings.roundingType}
|
||||
onOpenChange={(open) => setExpandedCategoryMappings(prev => ({ ...prev, roundingType: open }))}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex w-full items-center justify-between p-2 hover:bg-muted"
|
||||
>
|
||||
<span className="text-xs font-medium">반올림 방식 연산 매핑</span>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
{expandedCategoryMappings.roundingType ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-2 pt-2">
|
||||
|
|
@ -1783,14 +2183,21 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
</Collapsible>
|
||||
|
||||
{/* 반올림 단위 매핑 */}
|
||||
<Collapsible>
|
||||
<Collapsible
|
||||
open={expandedCategoryMappings.roundingUnit}
|
||||
onOpenChange={(open) => setExpandedCategoryMappings(prev => ({ ...prev, roundingUnit: open }))}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="flex w-full items-center justify-between p-2 hover:bg-muted"
|
||||
>
|
||||
<span className="text-xs font-medium">반올림 단위 값 매핑</span>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
{expandedCategoryMappings.roundingUnit ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-2 pt-2">
|
||||
|
|
@ -2128,10 +2535,10 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
variant="outline"
|
||||
role="combobox"
|
||||
className="h-7 w-full justify-between text-xs font-normal"
|
||||
disabled={targetTableColumns.length === 0}
|
||||
disabled={!config.targetTable || loadedTargetTableColumns.length === 0}
|
||||
>
|
||||
{mapping.targetField
|
||||
? targetTableColumns.find((c) => c.columnName === mapping.targetField)?.columnLabel ||
|
||||
? loadedTargetTableColumns.find((c) => c.columnName === mapping.targetField)?.columnLabel ||
|
||||
mapping.targetField
|
||||
: "저장 테이블 컬럼 선택"}
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
|
|
@ -2141,13 +2548,15 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
|
||||
<CommandList>
|
||||
{targetTableColumns.length === 0 ? (
|
||||
<CommandEmpty className="text-xs">저장 테이블을 먼저 선택하세요</CommandEmpty>
|
||||
{!config.targetTable ? (
|
||||
<CommandEmpty className="text-xs">저장 대상 테이블을 먼저 선택하세요</CommandEmpty>
|
||||
) : loadedTargetTableColumns.length === 0 ? (
|
||||
<CommandEmpty className="text-xs">컬럼 로딩 중...</CommandEmpty>
|
||||
) : (
|
||||
<>
|
||||
<CommandEmpty className="text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{targetTableColumns.map((col) => {
|
||||
{loadedTargetTableColumns.map((col) => {
|
||||
const searchValue = `${col.columnLabel || col.columnName} ${col.columnName} ${col.dataType || ""}`.toLowerCase();
|
||||
return (
|
||||
<CommandItem
|
||||
|
|
@ -2169,7 +2578,9 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
<div className="flex flex-col">
|
||||
<span>{col.columnLabel || col.columnName}</span>
|
||||
{col.dataType && (
|
||||
<span className="text-[10px] text-muted-foreground">{col.dataType}</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{col.dataType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
|
|
@ -2182,17 +2593,27 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-[8px] text-muted-foreground">
|
||||
현재 화면의 저장 대상 테이블 ({config.targetTable || "미선택"})의 컬럼
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 기본값 (선택사항) */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[9px] sm:text-[10px]">기본값 (선택사항)</Label>
|
||||
<Input
|
||||
value={mapping.defaultValue || ""}
|
||||
value={localMappingInputs[index] !== undefined ? localMappingInputs[index] : mapping.defaultValue || ""}
|
||||
onChange={(e) => {
|
||||
const updated = [...(config.parentDataMapping || [])];
|
||||
updated[index] = { ...updated[index], defaultValue: e.target.value };
|
||||
handleChange("parentDataMapping", updated);
|
||||
const newValue = e.target.value;
|
||||
setLocalMappingInputs(prev => ({ ...prev, [index]: newValue }));
|
||||
}}
|
||||
onBlur={() => {
|
||||
const currentValue = localMappingInputs[index];
|
||||
if (currentValue !== undefined) {
|
||||
const updated = [...(config.parentDataMapping || [])];
|
||||
updated[index] = { ...updated[index], defaultValue: currentValue || undefined };
|
||||
handleChange("parentDataMapping", updated);
|
||||
}
|
||||
}}
|
||||
placeholder="값이 없을 때 사용할 기본값"
|
||||
className="h-7 text-xs"
|
||||
|
|
@ -2200,46 +2621,24 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
</div>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-full text-xs text-destructive hover:text-destructive"
|
||||
onClick={() => {
|
||||
const updated = (config.parentDataMapping || []).filter((_, i) => i !== index);
|
||||
handleChange("parentDataMapping", updated);
|
||||
}}
|
||||
>
|
||||
<X className="mr-1 h-3 w-3" />
|
||||
삭제
|
||||
</Button>
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 text-xs text-destructive hover:bg-destructive/10 hover:text-destructive"
|
||||
onClick={() => {
|
||||
const updated = (config.parentDataMapping || []).filter((_, i) => i !== index);
|
||||
handleChange("parentDataMapping", updated);
|
||||
}}
|
||||
>
|
||||
<X className="mr-1 h-3 w-3" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(config.parentDataMapping || []).length === 0 && (
|
||||
<p className="text-center text-[10px] text-muted-foreground py-4">
|
||||
매핑 설정이 없습니다. "추가" 버튼을 클릭하여 설정하세요.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* 예시 */}
|
||||
<div className="rounded-lg bg-green-50 p-2 text-xs">
|
||||
<p className="mb-1 text-[10px] font-medium text-green-900">💡 예시</p>
|
||||
<div className="space-y-1 text-[9px] text-green-700">
|
||||
<p><strong>매핑 1: 거래처 ID</strong></p>
|
||||
<p className="ml-2">• 소스 테이블: <code className="bg-green-100 px-1">customer_mng</code></p>
|
||||
<p className="ml-2">• 원본 필드: <code className="bg-green-100 px-1">id</code> → 저장 필드: <code className="bg-green-100 px-1">customer_id</code></p>
|
||||
|
||||
<p className="mt-1"><strong>매핑 2: 품목 ID</strong></p>
|
||||
<p className="ml-2">• 소스 테이블: <code className="bg-green-100 px-1">item_info</code></p>
|
||||
<p className="ml-2">• 원본 필드: <code className="bg-green-100 px-1">id</code> → 저장 필드: <code className="bg-green-100 px-1">item_id</code></p>
|
||||
|
||||
<p className="mt-1"><strong>매핑 3: 품목 기준단가</strong></p>
|
||||
<p className="ml-2">• 소스 테이블: <code className="bg-green-100 px-1">item_info</code></p>
|
||||
<p className="ml-2">• 원본 필드: <code className="bg-green-100 px-1">standard_price</code> → 저장 필드: <code className="bg-green-100 px-1">base_price</code></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 사용 예시 */}
|
||||
|
|
@ -2256,3 +2655,5 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
|
|||
};
|
||||
|
||||
SelectedItemsDetailInputConfigPanel.displayName = "SelectedItemsDetailInputConfigPanel";
|
||||
|
||||
export default SelectedItemsDetailInputConfigPanel;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ interface SplitPanelLayoutConfigPanelProps {
|
|||
onChange: (config: SplitPanelLayoutConfig) => void;
|
||||
tables?: TableInfo[]; // 전체 테이블 목록 (선택적)
|
||||
screenTableName?: string; // 현재 화면의 테이블명 (좌측 패널에서 사용)
|
||||
menuObjid?: number; // 🆕 메뉴 OBJID (카테고리 값 조회 시 필요)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -201,6 +202,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
onChange,
|
||||
tables = [], // 기본값 빈 배열 (현재 화면 테이블만)
|
||||
screenTableName, // 현재 화면의 테이블명
|
||||
menuObjid, // 🆕 메뉴 OBJID
|
||||
}) => {
|
||||
const [rightTableOpen, setRightTableOpen] = useState(false);
|
||||
const [leftColumnOpen, setLeftColumnOpen] = useState(false);
|
||||
|
|
@ -211,9 +213,26 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
// 엔티티 참조 테이블 컬럼
|
||||
type EntityRefTable = { tableName: string; columns: ColumnInfo[] };
|
||||
const [entityReferenceTables, setEntityReferenceTables] = useState<Record<string, EntityRefTable[]>>({});
|
||||
|
||||
// 🆕 입력 필드용 로컬 상태
|
||||
const [isUserEditing, setIsUserEditing] = useState(false);
|
||||
const [localTitles, setLocalTitles] = useState({
|
||||
left: config.leftPanel?.title || "",
|
||||
right: config.rightPanel?.title || "",
|
||||
});
|
||||
|
||||
// 관계 타입
|
||||
const relationshipType = config.rightPanel?.relation?.type || "detail";
|
||||
|
||||
// config 변경 시 로컬 타이틀 동기화 (사용자가 입력 중이 아닐 때만)
|
||||
useEffect(() => {
|
||||
if (!isUserEditing) {
|
||||
setLocalTitles({
|
||||
left: config.leftPanel?.title || "",
|
||||
right: config.rightPanel?.title || "",
|
||||
});
|
||||
}
|
||||
}, [config.leftPanel?.title, config.rightPanel?.title, isUserEditing]);
|
||||
|
||||
// 조인 모드일 때만 전체 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
|
|
@ -568,8 +587,15 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
<div className="space-y-2">
|
||||
<Label>패널 제목</Label>
|
||||
<Input
|
||||
value={config.leftPanel?.title || ""}
|
||||
onChange={(e) => updateLeftPanel({ title: e.target.value })}
|
||||
value={localTitles.left}
|
||||
onChange={(e) => {
|
||||
setIsUserEditing(true);
|
||||
setLocalTitles(prev => ({ ...prev, left: e.target.value }));
|
||||
}}
|
||||
onBlur={() => {
|
||||
setIsUserEditing(false);
|
||||
updateLeftPanel({ title: localTitles.left });
|
||||
}}
|
||||
placeholder="좌측 패널 제목"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -1345,6 +1371,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
} as any))}
|
||||
config={config.leftPanel?.dataFilter}
|
||||
onConfigChange={(dataFilter) => updateLeftPanel({ dataFilter })}
|
||||
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -1355,8 +1382,15 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
<div className="space-y-2">
|
||||
<Label>패널 제목</Label>
|
||||
<Input
|
||||
value={config.rightPanel?.title || ""}
|
||||
onChange={(e) => updateRightPanel({ title: e.target.value })}
|
||||
value={localTitles.right}
|
||||
onChange={(e) => {
|
||||
setIsUserEditing(true);
|
||||
setLocalTitles(prev => ({ ...prev, right: e.target.value }));
|
||||
}}
|
||||
onBlur={() => {
|
||||
setIsUserEditing(false);
|
||||
updateRightPanel({ title: localTitles.right });
|
||||
}}
|
||||
placeholder="우측 패널 제목"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -2270,6 +2304,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
|
|||
} as any))}
|
||||
config={config.rightPanel?.dataFilter}
|
||||
onConfigChange={(dataFilter) => updateRightPanel({ dataFilter })}
|
||||
menuObjid={menuObjid} // 🆕 메뉴 OBJID 전달
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -2404,18 +2404,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (() => {
|
||||
console.log("🔍 [TableList] 렌더링 조건 체크", {
|
||||
groupByColumns: groupByColumns.length,
|
||||
groupedDataLength: groupedData.length,
|
||||
willRenderGrouped: groupByColumns.length > 0 && groupedData.length > 0,
|
||||
dataLength: data.length,
|
||||
});
|
||||
return groupByColumns.length > 0 && groupedData.length > 0;
|
||||
})() ? (
|
||||
) : groupByColumns.length > 0 && groupedData.length > 0 ? (
|
||||
// 그룹화된 렌더링
|
||||
groupedData.map((group) => {
|
||||
console.log("📊 [TableList] 그룹 렌더링:", group.groupKey, group.count);
|
||||
const isCollapsed = collapsedGroups.has(group.groupKey);
|
||||
return (
|
||||
<React.Fragment key={group.groupKey}>
|
||||
|
|
@ -2508,10 +2499,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
})
|
||||
) : (
|
||||
// 일반 렌더링 (그룹 없음)
|
||||
(() => {
|
||||
console.log("📋 [TableList] 일반 렌더링 시작:", data.length, "개 행");
|
||||
return data;
|
||||
})().map((row, index) => (
|
||||
data.map((row, index) => (
|
||||
<tr
|
||||
key={index}
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -255,7 +255,8 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
|
|||
}, [config.columns]);
|
||||
|
||||
const handleChange = (key: keyof TableListConfig, value: any) => {
|
||||
onChange({ [key]: value });
|
||||
// 기존 config와 병합하여 전달 (다른 속성 손실 방지)
|
||||
onChange({ ...config, [key]: value });
|
||||
};
|
||||
|
||||
const handleNestedChange = (parentKey: keyof TableListConfig, childKey: string, value: any) => {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import { FilterPanel } from "@/components/screen/table-options/FilterPanel";
|
|||
import { GroupingPanel } from "@/components/screen/table-options/GroupingPanel";
|
||||
import { TableFilter } from "@/types/table-options";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { ModernDatePicker } from "@/components/screen/filters/ModernDatePicker";
|
||||
import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
|
||||
|
||||
interface PresetFilter {
|
||||
id: string;
|
||||
|
|
@ -43,6 +45,7 @@ interface TableSearchWidgetProps {
|
|||
|
||||
export function TableSearchWidget({ component, screenId, onHeightChange }: TableSearchWidgetProps) {
|
||||
const { registeredTables, selectedTableId, setSelectedTableId, getTable } = useTableOptions();
|
||||
const { isPreviewMode } = useScreenPreview(); // 미리보기 모드 확인
|
||||
|
||||
// 높이 관리 context (실제 화면에서만 사용)
|
||||
let setWidgetHeight:
|
||||
|
|
@ -62,7 +65,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
|
||||
// 활성화된 필터 목록
|
||||
const [activeFilters, setActiveFilters] = useState<TableFilter[]>([]);
|
||||
const [filterValues, setFilterValues] = useState<Record<string, string>>({});
|
||||
const [filterValues, setFilterValues] = useState<Record<string, any>>({});
|
||||
// select 타입 필터의 옵션들
|
||||
const [selectOptions, setSelectOptions] = useState<Record<string, Array<{ label: string; value: string }>>>({});
|
||||
// 선택된 값의 라벨 저장 (데이터 없을 때도 라벨 유지)
|
||||
|
|
@ -230,7 +233,7 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
const hasMultipleTables = tableList.length > 1;
|
||||
|
||||
// 필터 값 변경 핸들러
|
||||
const handleFilterChange = (columnName: string, value: string) => {
|
||||
const handleFilterChange = (columnName: string, value: any) => {
|
||||
const newValues = {
|
||||
...filterValues,
|
||||
[columnName]: value,
|
||||
|
|
@ -243,14 +246,51 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
};
|
||||
|
||||
// 필터 적용 함수
|
||||
const applyFilters = (values: Record<string, string> = filterValues) => {
|
||||
const applyFilters = (values: Record<string, any> = filterValues) => {
|
||||
// 빈 값이 아닌 필터만 적용
|
||||
const filtersWithValues = activeFilters
|
||||
.map((filter) => ({
|
||||
...filter,
|
||||
value: values[filter.columnName] || "",
|
||||
}))
|
||||
.filter((f) => f.value !== "");
|
||||
.map((filter) => {
|
||||
let filterValue = values[filter.columnName];
|
||||
|
||||
// 날짜 범위 객체를 처리
|
||||
if (filter.filterType === "date" && filterValue && typeof filterValue === "object" && (filterValue.from || filterValue.to)) {
|
||||
// 날짜 범위 객체를 문자열 형식으로 변환 (백엔드 재시작 불필요)
|
||||
const formatDate = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
// "YYYY-MM-DD|YYYY-MM-DD" 형식으로 변환
|
||||
const fromStr = filterValue.from ? formatDate(filterValue.from) : "";
|
||||
const toStr = filterValue.to ? formatDate(filterValue.to) : "";
|
||||
|
||||
if (fromStr && toStr) {
|
||||
// 둘 다 있으면 파이프로 연결
|
||||
filterValue = `${fromStr}|${toStr}`;
|
||||
} else if (fromStr) {
|
||||
// 시작일만 있으면
|
||||
filterValue = `${fromStr}|`;
|
||||
} else if (toStr) {
|
||||
// 종료일만 있으면
|
||||
filterValue = `|${toStr}`;
|
||||
} else {
|
||||
filterValue = "";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...filter,
|
||||
value: filterValue || "",
|
||||
};
|
||||
})
|
||||
.filter((f) => {
|
||||
// 빈 값 체크
|
||||
if (!f.value) return false;
|
||||
if (typeof f.value === "string" && f.value === "") return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
currentTable?.onFilterChange(filtersWithValues);
|
||||
};
|
||||
|
|
@ -271,14 +311,21 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
switch (filter.filterType) {
|
||||
case "date":
|
||||
return (
|
||||
<Input
|
||||
type="date"
|
||||
value={value}
|
||||
onChange={(e) => handleFilterChange(filter.columnName, e.target.value)}
|
||||
className="h-9 text-xs focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none sm:text-sm"
|
||||
style={{ width: `${width}px`, height: "36px", minHeight: "36px", outline: "none", boxShadow: "none" }}
|
||||
placeholder={column?.columnLabel}
|
||||
/>
|
||||
<div style={{ width: `${width}px` }}>
|
||||
<ModernDatePicker
|
||||
label={column?.columnLabel || filter.columnName}
|
||||
value={value ? (typeof value === 'string' ? { from: new Date(value), to: new Date(value) } : value) : {}}
|
||||
onChange={(dateRange) => {
|
||||
if (dateRange.from && dateRange.to) {
|
||||
// 기간이 선택되면 from과 to를 모두 저장
|
||||
handleFilterChange(filter.columnName, dateRange);
|
||||
} else {
|
||||
handleFilterChange(filter.columnName, "");
|
||||
}
|
||||
}}
|
||||
includeTime={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "number":
|
||||
|
|
@ -400,14 +447,14 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 동적 모드일 때만 설정 버튼들 표시 */}
|
||||
{/* 동적 모드일 때만 설정 버튼들 표시 (미리보기에서는 비활성화) */}
|
||||
{filterMode === "dynamic" && (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setColumnVisibilityOpen(true)}
|
||||
disabled={!selectedTableId}
|
||||
onClick={() => !isPreviewMode && setColumnVisibilityOpen(true)}
|
||||
disabled={!selectedTableId || isPreviewMode}
|
||||
className="h-8 text-xs sm:h-9 sm:text-sm"
|
||||
>
|
||||
<Settings className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
|
||||
|
|
@ -417,8 +464,8 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setFilterOpen(true)}
|
||||
disabled={!selectedTableId}
|
||||
onClick={() => !isPreviewMode && setFilterOpen(true)}
|
||||
disabled={!selectedTableId || isPreviewMode}
|
||||
className="h-8 text-xs sm:h-9 sm:text-sm"
|
||||
>
|
||||
<Filter className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
|
||||
|
|
@ -428,8 +475,8 @@ export function TableSearchWidget({ component, screenId, onHeightChange }: Table
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setGroupingOpen(true)}
|
||||
disabled={!selectedTableId}
|
||||
onClick={() => !isPreviewMode && setGroupingOpen(true)}
|
||||
disabled={!selectedTableId || isPreviewMode}
|
||||
className="h-8 text-xs sm:h-9 sm:text-sm"
|
||||
>
|
||||
<Layers className="mr-1 h-3 w-3 sm:h-4 sm:w-4" />
|
||||
|
|
|
|||
|
|
@ -83,6 +83,10 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
// autoGeneratedValue,
|
||||
// });
|
||||
|
||||
// 자동생성 원본 값 추적 (수동/자동 모드 구분용)
|
||||
const [originalAutoGeneratedValue, setOriginalAutoGeneratedValue] = useState<string>("");
|
||||
const [isManualMode, setIsManualMode] = useState<boolean>(false);
|
||||
|
||||
// 자동생성 값 생성 (컴포넌트 마운트 시 한 번만 실행)
|
||||
useEffect(() => {
|
||||
const generateAutoValue = async () => {
|
||||
|
|
@ -136,6 +140,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
if (generatedValue) {
|
||||
console.log("✅ 자동생성 값 설정:", generatedValue);
|
||||
setAutoGeneratedValue(generatedValue);
|
||||
setOriginalAutoGeneratedValue(generatedValue); // 🆕 원본 값 저장
|
||||
hasGeneratedRef.current = true; // 생성 완료 플래그
|
||||
|
||||
// 폼 데이터에 자동생성된 값 설정 (인터랙티브 모드에서만)
|
||||
|
|
@ -684,6 +689,20 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
</label>
|
||||
)}
|
||||
|
||||
{/* 수동/자동 모드 표시 배지 */}
|
||||
{testAutoGeneration.enabled && testAutoGeneration.type === "numbering_rule" && isInteractive && (
|
||||
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
||||
<span className={cn(
|
||||
"text-[10px] px-2 py-0.5 rounded-full font-medium",
|
||||
isManualMode
|
||||
? "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400"
|
||||
: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
|
||||
)}>
|
||||
{isManualMode ? "수동" : "자동"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
type={inputType}
|
||||
defaultValue={(() => {
|
||||
|
|
@ -704,20 +723,24 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
})()}
|
||||
placeholder={
|
||||
testAutoGeneration.enabled && testAutoGeneration.type !== "none"
|
||||
? `자동생성: ${AutoGenerationUtils.getTypeDescription(testAutoGeneration.type)}`
|
||||
? isManualMode
|
||||
? "수동 입력 모드"
|
||||
: `자동생성: ${AutoGenerationUtils.getTypeDescription(testAutoGeneration.type)}`
|
||||
: componentConfig.placeholder || defaultPlaceholder
|
||||
}
|
||||
pattern={validationPattern}
|
||||
title={
|
||||
webType === "tel"
|
||||
? "전화번호 형식: 010-1234-5678"
|
||||
: isManualMode
|
||||
? `${component.label} (수동 입력 모드 - 채번 규칙 미적용)`
|
||||
: component.label
|
||||
? `${component.label}${component.columnName ? ` (${component.columnName})` : ""}`
|
||||
: component.columnName || undefined
|
||||
}
|
||||
disabled={componentConfig.disabled || false}
|
||||
required={componentConfig.required || false}
|
||||
readOnly={componentConfig.readonly || (testAutoGeneration.enabled && testAutoGeneration.type !== "none")}
|
||||
readOnly={componentConfig.readonly || false}
|
||||
className={cn(
|
||||
"box-border h-full w-full max-w-full rounded-md border px-3 py-2 text-sm shadow-sm transition-all duration-200 outline-none",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
|
|
@ -742,6 +765,44 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
|||
// hasOnChange: !!props.onChange,
|
||||
// });
|
||||
|
||||
// 🆕 사용자 수정 감지 (자동 생성 값과 다르면 수동 모드로 전환)
|
||||
if (testAutoGeneration.enabled && testAutoGeneration.type === "numbering_rule") {
|
||||
if (originalAutoGeneratedValue && newValue !== originalAutoGeneratedValue) {
|
||||
if (!isManualMode) {
|
||||
setIsManualMode(true);
|
||||
console.log("🔄 수동 모드로 전환:", {
|
||||
field: component.columnName,
|
||||
original: originalAutoGeneratedValue,
|
||||
modified: newValue
|
||||
});
|
||||
|
||||
// 🆕 채번 규칙 ID 제거 (수동 모드이므로 더 이상 채번 규칙 사용 안 함)
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
const ruleIdKey = `${component.columnName}_numberingRuleId`;
|
||||
onFormDataChange(ruleIdKey, null);
|
||||
console.log("🗑️ 채번 규칙 ID 제거 (수동 모드):", ruleIdKey);
|
||||
}
|
||||
}
|
||||
} else if (isManualMode && newValue === originalAutoGeneratedValue) {
|
||||
// 사용자가 원본 값으로 되돌렸을 때 자동 모드로 복구
|
||||
setIsManualMode(false);
|
||||
console.log("🔄 자동 모드로 복구:", {
|
||||
field: component.columnName,
|
||||
value: newValue
|
||||
});
|
||||
|
||||
// 채번 규칙 ID 복구
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
const ruleId = testAutoGeneration.options?.numberingRuleId;
|
||||
if (ruleId) {
|
||||
const ruleIdKey = `${component.columnName}_numberingRuleId`;
|
||||
onFormDataChange(ruleIdKey, ruleId);
|
||||
console.log("✅ 채번 규칙 ID 복구 (자동 모드):", ruleIdKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isInteractive 모드에서는 formData 업데이트
|
||||
if (isInteractive && onFormDataChange && component.columnName) {
|
||||
onFormDataChange(component.columnName, newValue);
|
||||
|
|
|
|||
|
|
@ -392,27 +392,12 @@ export class ButtonActionExecutor {
|
|||
// console.log("📋 채번 규칙이 설정된 필드:", fieldsWithNumbering);
|
||||
// console.log("📊 필드 개수:", Object.keys(fieldsWithNumbering).length);
|
||||
|
||||
// 각 필드에 대해 실제 코드 할당
|
||||
for (const [fieldName, ruleId] of Object.entries(fieldsWithNumbering)) {
|
||||
try {
|
||||
// console.log(`🎫 ${fieldName} 필드에 채번 규칙 ${ruleId} 할당 시작`);
|
||||
const { allocateNumberingCode } = await import("@/lib/api/numberingRule");
|
||||
const response = await allocateNumberingCode(ruleId);
|
||||
|
||||
// console.log(`📡 API 응답 (${fieldName}):`, response);
|
||||
|
||||
if (response.success && response.data) {
|
||||
const generatedCode = response.data.generatedCode;
|
||||
formData[fieldName] = generatedCode;
|
||||
// console.log(`✅ ${fieldName} = ${generatedCode} (할당 완료)`);
|
||||
} else {
|
||||
console.error(`❌ 채번 규칙 할당 실패 (${fieldName}):`, response.error);
|
||||
toast.error(`${fieldName} 채번 규칙 할당 실패: ${response.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 채번 규칙 할당 오류 (${fieldName}):`, error);
|
||||
toast.error(`${fieldName} 채번 규칙 할당 오류`);
|
||||
}
|
||||
// 사용자 입력 값 유지 (재할당하지 않음)
|
||||
// 채번 규칙은 TextInputComponent 마운트 시 이미 생성되었으므로
|
||||
// 저장 시점에는 사용자가 수정한 값을 그대로 사용
|
||||
if (Object.keys(fieldsWithNumbering).length > 0) {
|
||||
console.log("ℹ️ 채번 규칙 필드 감지:", Object.keys(fieldsWithNumbering));
|
||||
console.log("ℹ️ 사용자 입력 값 유지 (재할당 하지 않음)");
|
||||
}
|
||||
|
||||
// console.log("✅ 채번 규칙 할당 완료");
|
||||
|
|
|
|||
|
|
@ -378,3 +378,4 @@ interface TablePermission {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue