Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into logistream

This commit is contained in:
dohyeons 2025-11-28 11:59:02 +09:00
commit 0c897ad8fd
88 changed files with 4404 additions and 3454 deletions

42
PLAN.MD
View File

@ -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단계 긴급 버그 수정 완료 후 사용자 피드백 대기 중
- [완료] 모든 단계 구현 완료

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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> \"이희진\" &lt;zian9227@naver.com&gt;</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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

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

View File

@ -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) {

View File

@ -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) {
// 스케줄러에 자동 등록 ✅

View File

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

View File

@ -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] 채번 규칙 생성 성공:", {

View File

@ -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,27 +86,17 @@ 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) {
// 기존에는 /health 엔드포인트를 호출해서 미리 연결을 검사했지만,
// 일반 외부 API들은 /health가 없거나 401/500을 반환하는 경우가 많아
// 불필요하게 예외가 나면서 미리보기/배치 실행이 막히는 문제가 있었다.
//
// 따라서 여기서는 "연결 준비 완료" 정도만 로그로 남기고
// 실제 호출 실패 여부는 executeRequest 단계에서만 판단하도록 한다.
console.log(
`[RestApiConnector] 연결 성공 (health 엔드포인트 없음): ${this.config.baseUrl}`
`[RestApiConnector] 연결 준비 완료 (사전 헬스체크 생략): ${this.config.baseUrl}`
);
return;
}
console.error(
`[RestApiConnector] 연결 실패: ${this.config.baseUrl}`,
error
);
throw new Error(
`REST API 연결 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`
);
}
}
async disconnect(): Promise<void> {
// REST API는 연결 해제가 필요 없음

View File

@ -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) {

View File

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

View File

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

View File

@ -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();
// 활성화된 배치 설정들을 로드하여 스케줄 등록
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})`);
return;
}
// 새로운 스케줄 등록
const task = cron.schedule(cron_schedule, async () => {
// 중복 실행 방지 체크
if (this.executingBatches.has(id)) {
logger.warn(
`⚠️ 배치가 이미 실행 중입니다. 건너뜀: ${batch_name} (ID: ${id})`
);
return;
}
logger.info(`🔄 스케줄 배치 실행 시작: ${batch_name} (ID: ${id})`);
// 실행 중 플래그 설정
this.executingBatches.add(id);
try {
await this.executeBatchConfig(config);
} finally {
// 실행 완료 후 플래그 제거
this.executingBatches.delete(id);
}
const batchConfigsResponse = await BatchService.getBatchConfigs({
is_active: "Y",
});
// 스케줄 시작 (기본적으로 시작되지만 명시적으로 호출)
task.start();
if (!batchConfigsResponse.success || !batchConfigsResponse.data) {
logger.warn("스케줄링할 활성 배치 설정이 없습니다.");
return;
}
this.scheduledTasks.set(id, task);
logger.info(
`배치 스케줄 등록 완료: ${batch_name} (ID: ${id}, Schedule: ${cron_schedule}) - 스케줄 시작됨`
);
const batchConfigs = batchConfigsResponse.data;
logger.info(`${batchConfigs.length}개의 배치 설정 스케줄링 등록`);
for (const config of batchConfigs) {
await this.scheduleBatch(config);
}
logger.info("배치 스케줄러 초기화 완료");
} catch (error) {
logger.error(`배치 스케줄 등록 실패 (ID: ${config.id}):`, error);
logger.error("배치 스케줄러 초기화 중 오류 발생:", error);
}
}
/**
*
*
*/
static async unscheduleBatchConfig(batchConfigId: number) {
static async scheduleBatch(config: any) {
try {
if (this.scheduledTasks.has(batchConfigId)) {
this.scheduledTasks.get(batchConfigId)?.stop();
this.scheduledTasks.delete(batchConfigId);
logger.info(`배치 스케줄 제거 완료 (ID: ${batchConfigId})`);
// 기존 스케줄이 있으면 제거
if (this.scheduledTasks.has(config.id)) {
this.scheduledTasks.get(config.id)?.stop();
this.scheduledTasks.delete(config.id);
}
if (config.is_active !== "Y") {
logger.info(
`배치 스케줄링 건너뜀 (비활성 상태): ${config.batch_name} (ID: ${config.id})`
);
return;
}
if (!cron.validate(config.cron_schedule)) {
logger.error(
`유효하지 않은 Cron 표현식: ${config.cron_schedule} (Batch ID: ${config.id})`
);
return;
}
logger.info(
`배치 스케줄 등록: ${config.batch_name} (ID: ${config.id}, Cron: ${config.cron_schedule})`
);
const task = cron.schedule(config.cron_schedule, async () => {
logger.info(
`스케줄에 의한 배치 실행 시작: ${config.batch_name} (ID: ${config.id})`
);
await this.executeBatchConfig(config);
});
this.scheduledTasks.set(config.id, task);
} catch (error) {
logger.error(`배치 스케줄 제거 실패 (ID: ${batchConfigId}):`, error);
logger.error(`배치 스케줄링 중 오류 발생 (ID: ${config.id}):`, 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

View File

@ -99,11 +99,19 @@ export class DynamicFormService {
}
try {
// YYYY-MM-DD 형식인 경우 시간 추가해서 Date 객체 생성
// YYYY-MM-DD 형식인 경우
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
console.log(`📅 날짜 타입 변환: ${value} -> Date 객체`);
// 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 {
console.log(`📅 날짜 타입 변환: ${value} -> Date 객체`);
@ -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 *
`;

View File

@ -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 (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),
});
const responseTime = Date.now() - startTime;
let responseData = null;
try {
responseData = await response.json();
} catch {
// JSON 파싱 실패는 무시 (텍스트 응답일 수 있음)
// Body 처리
let body: any = undefined;
if (testRequest.body) {
// 이미 문자열이면 그대로, 객체면 JSON 문자열로 변환
if (typeof testRequest.body === "string") {
body = testRequest.body;
} else {
body = JSON.stringify(testRequest.body);
}
// 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("올바르지 않은 인증 타입입니다.");

View File

@ -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, // 🆕 테이블명 추가
};
}

View File

@ -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}`,

View File

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

View File

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

View File

@ -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 토큰" },
];

View File

@ -101,6 +101,7 @@ export interface LayoutData {
components: ComponentData[];
gridSettings?: GridSettings;
screenResolution?: ScreenResolution;
tableName?: string; // 🆕 화면에 연결된 테이블명
}
// 그리드 설정

View File

@ -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,
const apiMappings = mappedFields.map((apiField) => {
const toColumnName = apiFieldMappings[apiField]; // 매핑된 DB 컬럼 (예: access_token)
// 기본은 상위 필드 그대로 사용하되,
// 사용자가 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: apiField, // 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,
from_api_param_type: apiParamType !== "none" ? apiParamType : undefined,
from_api_param_name: apiParamType !== "none" ? apiParamName : undefined,
from_api_param_value: apiParamType !== "none" ? apiParamValue : undefined,
from_api_param_source:
apiParamType !== "none" ? apiParamSource : undefined,
to_connection_type:
toConnection?.type === "internal" ? "internal" : "external",
to_connection_id:
toConnection?.type === "internal" ? undefined : toConnection?.id,
to_table_name: toTable,
to_column_name: apiFieldMappings[apiField], // 매핑된 DB 컬럼
mapping_type: 'direct' as const
}));
console.log("REST API 배치 설정 저장:", {
batchName,
batchType,
cronSchedule,
description,
apiMappings
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,171 +1026,32 @@ 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>
)}
{/* 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
}));
}}
{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}
/>
)}
<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>
{/* DB → REST API 매핑 */}
{batchType === "db-to-restapi" &&
selectedColumns.length > 0 &&
toApiFields.length > 0 && (
<DbToRestApiMappingCard
fromColumns={fromColumns}
selectedColumns={selectedColumns}
toApiFields={toApiFields}
dbToApiFieldMapping={dbToApiFieldMapping}
setDbToApiFieldMapping={setDbToApiFieldMapping}
setToApiBody={setToApiBody}
/>
)}
{/* TO 설정 */}
@ -1349,3 +1256,277 @@ export default function BatchManagementNewPage() {
</div>
);
}
const RestApiToDbMappingCard = memo(function RestApiToDbMappingCard({
fromApiFields,
toColumns,
fromApiData,
apiFieldMappings,
setApiFieldMappings,
apiFieldPathOverrides,
setApiFieldPathOverrides,
}: RestApiToDbMappingCardProps) {
// 샘플 JSON 문자열은 의존 데이터가 바뀔 때만 계산
const sampleJsonList = useMemo(
() =>
fromApiData.slice(0, 3).map((item) => JSON.stringify(item, null, 2)),
[fromApiData]
);
const firstSample = fromApiData[0] || null;
return (
<Card>
<CardHeader>
<CardTitle>API DB </CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3 max-h-96 overflow-y-auto border rounded-lg p-4">
{fromApiFields.map((apiField) => (
<div
key={apiField}
className="flex items-center space-x-4 p-3 bg-gray-50 rounded-lg"
>
{/* API 필드 정보 */}
<div className="flex-1">
<div className="font-medium text-sm">{apiField}</div>
<div className="text-xs text-gray-500">
{firstSample && firstSample[apiField] !== undefined
? `예: ${String(firstSample[apiField]).substring(0, 30)}${
String(firstSample[apiField]).length > 30 ? "..." : ""
}`
: "API 필드"}
</div>
{/* JSON 경로 오버라이드 입력 */}
<div className="mt-1.5">
<Input
value={apiFieldPathOverrides[apiField] || ""}
onChange={(e) =>
setApiFieldPathOverrides((prev) => ({
...prev,
[apiField]: e.target.value,
}))
}
placeholder="JSON 경로 (예: response.access_token)"
className="h-7 text-xs"
/>
<p className="text-[11px] text-gray-500 mt-0.5">
"{apiField}" ,
.
</p>
</div>
</div>
{/* 화살표 */}
<div className="text-gray-400">
<ArrowRight className="w-4 h-4" />
</div>
{/* DB 컬럼 선택 */}
<div className="flex-1">
<Select
value={apiFieldMappings[apiField] || "none"}
onValueChange={(value) => {
setApiFieldMappings((prev) => ({
...prev,
[apiField]: value === "none" ? "" : value,
}));
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="DB 컬럼 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{toColumns.map((column) => (
<SelectItem
key={column.column_name}
value={column.column_name}
>
<div className="flex flex-col">
<span className="font-medium">
{column.column_name.toUpperCase()}
</span>
<span className="text-xs text-gray-500">
{column.data_type}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
))}
</div>
{sampleJsonList.length > 0 && (
<div className="mt-3 p-3 bg-gray-50 rounded-lg">
<div className="text-sm font-medium text-gray-700 mb-2">
( 3)
</div>
<div className="space-y-2 max-h-40 overflow-y-auto">
{sampleJsonList.map((json, index) => (
<div
key={index}
className="text-xs bg-white p-2 rounded border"
>
<pre className="whitespace-pre-wrap">{json}</pre>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
);
});
const DbToRestApiMappingCard = memo(function DbToRestApiMappingCard({
fromColumns,
selectedColumns,
toApiFields,
dbToApiFieldMapping,
setDbToApiFieldMapping,
setToApiBody,
}: DbToRestApiMappingCardProps) {
const selectedColumnObjects = useMemo(
() =>
fromColumns.filter((column) =>
selectedColumns.includes(column.column_name)
),
[fromColumns, selectedColumns]
);
const autoJsonPreview = useMemo(() => {
if (selectedColumns.length === 0) {
return "";
}
const obj = selectedColumns.reduce((acc, col) => {
const apiField = dbToApiFieldMapping[col] || col;
acc[apiField] = `{{${col}}}`;
return acc;
}, {} as Record<string, string>);
return JSON.stringify(obj, null, 2);
}, [selectedColumns, dbToApiFieldMapping]);
return (
<Card>
<CardHeader>
<CardTitle>DB API </CardTitle>
<CardDescription>
DB REST API Request Body에 . Request Body
릿 {`{{컬럼명}}`} .
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3 max-h-96 overflow-y-auto border rounded-lg p-4">
{selectedColumnObjects.map((column) => (
<div
key={column.column_name}
className="flex items-center space-x-4 p-3 bg-gray-50 rounded-lg"
>
{/* DB 컬럼 정보 */}
<div className="flex-1">
<div className="font-medium text-sm">{column.column_name}</div>
<div className="text-xs text-gray-500">
: {column.data_type} | NULL: {column.is_nullable ? "Y" : "N"}
</div>
</div>
{/* 화살표 */}
<div className="text-gray-400"></div>
{/* API 필드 선택 드롭다운 */}
<div className="flex-1">
<Select
value={dbToApiFieldMapping[column.column_name] || ""}
onValueChange={(value) => {
setDbToApiFieldMapping((prev) => ({
...prev,
[column.column_name]: value === "none" ? "" : value,
}));
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="API 필드 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
{toApiFields.map((apiField) => (
<SelectItem key={apiField} value={apiField}>
{apiField}
</SelectItem>
))}
<SelectItem value="custom"> ...</SelectItem>
</SelectContent>
</Select>
{/* 직접 입력 모드 */}
{dbToApiFieldMapping[column.column_name] === "custom" && (
<input
type="text"
placeholder="API 필드명을 직접 입력하세요"
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 mt-2"
onChange={(e) => {
setDbToApiFieldMapping((prev) => ({
...prev,
[column.column_name]: e.target.value,
}));
}}
/>
)}
<div className="text-xs text-gray-500 mt-1">
{dbToApiFieldMapping[column.column_name]
? `매핑: ${column.column_name}${dbToApiFieldMapping[column.column_name]}`
: `기본값: ${column.column_name} (DB 컬럼명 사용)`}
</div>
</div>
{/* 템플릿 미리보기 */}
<div className="flex-1">
<div className="text-sm font-mono bg-white p-2 rounded border">
{`{{${column.column_name}}}`}
</div>
<div className="text-xs text-gray-500 mt-1">
DB
</div>
</div>
</div>
))}
</div>
{selectedColumns.length > 0 && (
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<div className="text-sm font-medium text-blue-800">
JSON
</div>
<pre className="mt-1 text-xs text-blue-600 font-mono overflow-x-auto">
{autoJsonPreview}
</pre>
<button
type="button"
onClick={() => {
setToApiBody(autoJsonPreview);
toast.success(
"Request Body에 자동 생성된 JSON이 적용되었습니다."
);
}}
className="mt-2 px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700"
>
Request Body에
</button>
</div>
)}
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<div className="text-sm font-medium text-blue-800"> </div>
<div className="text-xs text-blue-600 mt-1 font-mono">
{`{"id": "{{id}}", "name": "{{user_name}}", "email": "{{email}}"}`}
</div>
</div>
</CardContent>
</Card>
);
});

View File

@ -7,12 +7,24 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { 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="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 />
<Input value={mappings[0]?.from_api_url || ""} readOnly />
</div>
<div>
<Label>API </Label>
<Input value={mappings[0]?.from_table_name || ''} readOnly />
<Input
value={mappings[0]?.from_table_name || ""}
readOnly
/>
</div>
<div>
<Label>HTTP </Label>
<Input value={mappings[0]?.from_api_method || 'GET'} readOnly />
<Input
value={mappings[0]?.from_api_method || "GET"}
readOnly
/>
</div>
<div>
<Label> </Label>
<Input value={mappings[0]?.to_table_name || ''} readOnly />
<Input
value={mappings[0]?.to_table_name || ""}
readOnly
/>
</div>
</div>
{/* Request Body (JSON) 편집 UI */}
<div>
<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>
{/* 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>
{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>

View File

@ -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,7 +1143,9 @@ export default function TableManagementPage() {
className="h-8 text-xs"
/>
</div>
<div className="w-48 pr-6">
<div className="pr-6">
<div className="space-y-3">
{/* 입력 타입 선택 */}
<Select
value={column.inputType || "text"}
onValueChange={(value) => handleInputTypeChange(column.columnName, value)}
@ -1161,8 +1161,6 @@ export default function TableManagementPage() {
))}
</SelectContent>
</Select>
</div>
<div className="flex-1 pl-6" style={{ maxWidth: "calc(100% - 808px)" }}>
{/* 입력 타입이 'code'인 경우 공통코드 선택 */}
{column.inputType === "code" && (
<Select
@ -1242,10 +1240,9 @@ export default function TableManagementPage() {
)}
{/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
{column.inputType === "entity" && (
<div className="space-y-2">
<div className="grid grid-cols-3 gap-2">
<>
{/* 참조 테이블 */}
<div>
<div className="w-48">
<label className="text-muted-foreground mb-1 block text-xs">
</label>
@ -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,7 +1361,6 @@ export default function TableManagementPage() {
</Select>
</div>
)}
</div>
{/* 설정 완료 표시 */}
{column.referenceTable &&
@ -1373,28 +1369,21 @@ export default function TableManagementPage() {
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">
<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">
{column.columnName} {column.referenceTable}.{column.displayColumn}
</span>
<span className="truncate"> </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>
</>
)}
</div>
<div className="w-80 pl-4">
</div>
<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>
);
}

View File

@ -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([]); // 선택 해제
}}

View File

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

View File

@ -42,6 +42,7 @@ export function AuthenticationConfig({
<SelectItem value="bearer">Bearer Token</SelectItem>
<SelectItem value="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">
릿 (, &#123;&#123;value&#125;&#125; )
</Label>
<Input
id="db-header-template"
type="text"
value={authConfig.dbHeaderTemplate || ""}
onChange={(e) =>
updateAuthConfig("dbHeaderTemplate", e.target.value)
}
placeholder='기본값: "Bearer {{value}}"'
/>
</div>
<p className="text-xs text-gray-500">
company_code는 .
</p>
</div>
)}
{authType === "none" && (
<div className="rounded-md border border-dashed p-4 text-center text-sm text-gray-500">
API입니다.

View File

@ -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: "연결 성공",

View File

@ -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>
<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,14 +441,46 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
<h3 className="text-sm font-semibold"> </h3>
<div className="space-y-2">
<Label htmlFor="test-endpoint"> ()</Label>
<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="엔드포인트 또는 빈칸(기본 URL만 테스트)"
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}>
<TestTube className="mr-2 h-4 w-4" />
@ -388,9 +491,21 @@ 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>

View File

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

View File

@ -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
) {
@ -471,7 +469,11 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{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

View File

@ -593,10 +593,17 @@ function MaterialBox({
);
case "location-stp":
// 정차포인트(STP): 주황색 낮은 플랫폼
// 정차포인트(STP): 회색 타원형 플랫폼 + 'P' 마크 (자재 미적재 영역)
{
const baseRadius = 0.5; // 스케일로 실제 W/D를 반영 (타원형)
const labelFontSize = Math.min(boxWidth, boxDepth) * 0.15;
const iconFontSize = Math.min(boxWidth, boxDepth) * 0.3;
return (
<>
<Box args={[boxWidth, boxHeight, boxDepth]}>
{/* 타원형 플랫폼: 단위 실린더를 W/D로 스케일 */}
<mesh scale={[boxWidth, 1, boxDepth]}>
<cylinderGeometry args={[baseRadius, baseRadius, boxHeight, 32]} />
<meshStandardMaterial
color={placement.color}
roughness={0.6}
@ -604,14 +611,28 @@ function MaterialBox({
emissive={isSelected ? placement.color : "#000000"}
emissiveIntensity={isSelected ? glowIntensity * 0.8 : 0}
/>
</Box>
</mesh>
{/* 상단 'P' 마크 (주차 아이콘 역할) */}
<Text
position={[0, boxHeight / 2 + 0.05, 0]}
rotation={[-Math.PI / 2, 0, 0]}
fontSize={iconFontSize}
color="#ffffff"
anchorX="center"
anchorY="middle"
outlineWidth={0.08}
outlineColor="#000000"
>
P
</Text>
{/* Location 이름 */}
{placement.name && (
<Text
position={[0, boxHeight / 2 + 0.3, 0]}
position={[0, boxHeight / 2 + 0.4, 0]}
rotation={[-Math.PI / 2, 0, 0]}
fontSize={Math.min(boxWidth, boxDepth) * 0.15}
fontSize={labelFontSize}
color="#ffffff"
anchorX="center"
anchorY="middle"
@ -621,24 +642,9 @@ function MaterialBox({
{placement.name}
</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>
)}
</>
);
}
// 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"]} />

View File

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

View File

@ -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 설정 (필터링용)
};

View File

@ -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: "납기일",

View File

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

View File

@ -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,24 +386,33 @@ 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) {
// 날짜 필드인 경우 정규화
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]);
}
}
});
console.log("📦 [신규 품목] 최종 insertData:", insertData);
@ -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;

View File

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

View File

@ -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} // ✅ 회사 코드 전달

View File

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

View File

@ -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) => {

View File

@ -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,6 +1318,8 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
<DialogHeader>
<DialogTitle> - {screenToPreview?.screenName}</DialogTitle>
</DialogHeader>
<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">
@ -1329,10 +1334,24 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
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,67 +1433,16 @@ 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>
)}
{/* 실제 컴포넌트 */}
<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}
<RealtimePreview
key={component.id}
component={component}
isSelected={false}
isDesignMode={false}
onClick={() => {}}
screenId={screenToPreview!.screenId}
tableName={screenToPreview?.tableName}
formData={previewFormData}
onFormDataChange={(fieldName, value) => {
setPreviewFormData((prev) => ({
@ -1482,47 +1450,44 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
[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,
},
};
return (
<RealtimePreview
key={child.id}
component={relativeChildComponent}
isSelected={false}
isDesignMode={false}
onClick={() => {}}
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;
formData={previewFormData}
onFormDataChange={(fieldName, value) => {
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>
);
})}
</RealtimePreview>
);
})}
</div>
@ -1537,6 +1502,8 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
</div>
)}
</div>
</TableOptionsProvider>
</ScreenPreviewProvider>
<DialogFooter>
<Button variant="outline" onClick={() => setPreviewDialogOpen(false)}>

View File

@ -48,6 +48,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(() => {
const currentConfig = (widget.webTypeConfig as CheckboxTypeConfig) || {};
@ -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" />

View File

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

View File

@ -51,11 +51,9 @@ 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 || "",
@ -75,8 +73,7 @@ export const EntityConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
apiEndpoint: currentConfig.apiEndpoint || "",
filters: currentConfig.filters || {},
});
}
}, [widget.webTypeConfig, isUserEditing]);
}, [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>

View File

@ -44,6 +44,12 @@ export const RadioConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
const [newOptionValue, setNewOptionValue] = useState("");
const [bulkOptions, setBulkOptions] = useState("");
// 입력 필드용 로컬 상태
const [localInputs, setLocalInputs] = useState({
groupLabel: config.groupLabel || "",
groupName: config.groupName || "",
});
// 컴포넌트 변경 시 로컬 상태 동기화
useEffect(() => {
const currentConfig = (widget.webTypeConfig as RadioTypeConfig) || {};
@ -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" />

View File

@ -45,6 +45,12 @@ export const SelectConfigPanel: React.FC<WebTypeConfigPanelProps> = ({
const [newOptionValue, setNewOptionValue] = useState("");
const [bulkOptions, setBulkOptions] = useState("");
// 입력 필드용 로컬 상태
const [localInputs, setLocalInputs] = useState({
placeholder: config.placeholder || "",
emptyMessage: config.emptyMessage || "",
});
// 컴포넌트 변경 시 로컬 상태 동기화
useEffect(() => {
const currentConfig = (widget.webTypeConfig as SelectTypeConfig) || {};
@ -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" />

View File

@ -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";
@ -35,6 +35,17 @@ export const ModernDatePicker: React.FC<ModernDatePickerProps> = ({ label, value
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 "";
if (includeTime) {
@ -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}>

View File

@ -78,3 +78,4 @@ export const numberingRuleTemplate = {

View File

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

View File

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

View File

@ -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";
@ -35,6 +35,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(() => {
return new Set(localFields.map((f) => f.name));
@ -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"
/>

View File

@ -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
};
// 파라미터 정보가 있으면 추가

View File

@ -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 토큰" },
];
}
}

View File

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

View File

@ -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] || "";
}

View File

@ -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) => {

View File

@ -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 호출 완료");
}
};

View File

@ -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 = () => {

View File

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

View File

@ -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>
{expandedCategoryMappings.discountType ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-2 pt-2">
@ -1595,7 +1978,16 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
</div>
{/* 2단계: 카테고리 선택 */}
{(config.autoCalculation.valueMapping as any)?._selectedMenus?.discountType && (
{(() => {
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
@ -1610,7 +2002,7 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
<SelectValue placeholder="카테고리 선택" />
</SelectTrigger>
<SelectContent>
{(categoryColumns.discountType || []).map((col: any) => (
{columns.map((col: any) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
@ -1618,7 +2010,8 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
</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>
{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>
{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 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: e.target.value };
updated[index] = { ...updated[index], defaultValue: currentValue || undefined };
handleChange("parentDataMapping", updated);
}
}}
placeholder="값이 없을 때 사용할 기본값"
className="h-7 text-xs"
@ -2200,10 +2621,11 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
</div>
{/* 삭제 버튼 */}
<div className="flex justify-end pt-2">
<Button
size="sm"
variant="ghost"
className="h-6 w-full text-xs text-destructive hover:text-destructive"
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);
@ -2213,33 +2635,10 @@ export const SelectedItemsDetailInputConfigPanel: React.FC<SelectedItemsDetailIn
</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;

View File

@ -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);
@ -212,9 +214,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(() => {
if (relationshipType === "join") {
@ -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>

View File

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

View File

@ -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) => {

View File

@ -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) => ({
.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: values[filter.columnName] || "",
}))
.filter((f) => f.value !== "");
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" />

View File

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

View File

@ -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("✅ 채번 규칙 할당 완료");

View File

@ -378,3 +378,4 @@ interface TablePermission {