Merge branch 'main' of http://39.117.244.52:3000/kjs/ERP-node into feature/screen-management
This commit is contained in:
commit
bd4e3e507d
42
PLAN.MD
42
PLAN.MD
|
|
@ -1,28 +1,36 @@
|
|||
# 프로젝트: Digital Twin 에디터 안정화
|
||||
# 프로젝트: 외부 REST API 커넥션 관리 확장 (POST/Body 지원)
|
||||
|
||||
## 개요
|
||||
|
||||
Digital Twin 에디터(`DigitalTwinEditor.tsx`)에서 발생한 런타임 에러(`TypeError: Cannot read properties of undefined`)를 수정하고, 전반적인 안정성을 확보합니다.
|
||||
현재 GET 방식 위주로 구현된 외부 REST API 커넥션 관리 기능을 확장하여, POST, PUT, DELETE 등 다양한 HTTP 메서드와 JSON Request Body를 설정하고 테스트할 수 있도록 개선합니다. 이를 통해 토큰 발급 API나 데이터 전송 API 등 다양한 외부 시스템과의 연동을 지원합니다.
|
||||
|
||||
## 핵심 기능
|
||||
|
||||
1. `DigitalTwinEditor` 버그 수정
|
||||
2. 비동기 함수 입력값 유효성 검증 강화
|
||||
3. 외부 DB 연결 상태에 따른 방어 코드 추가
|
||||
1. **DB 스키마 확장**: `external_rest_api_connections` 테이블에 `default_method`, `default_body` 컬럼 추가
|
||||
2. **백엔드 로직 개선**:
|
||||
- 커넥션 생성/수정 시 메서드와 바디 정보 저장
|
||||
- 연결 테스트 시 설정된 메서드와 바디를 사용하여 요청 수행
|
||||
- SSL 인증서 검증 우회 옵션 적용 (내부망/테스트망 지원)
|
||||
3. **프론트엔드 UI 개선**:
|
||||
- 커넥션 설정 모달에 HTTP 메서드 선택(Select) 및 Body 입력(Textarea/JSON Editor) 필드 추가
|
||||
- 테스트 기능에서 Body 데이터 포함하여 요청 전송
|
||||
|
||||
## 테스트 계획
|
||||
### 1단계: 기본 기능 및 DB 마이그레이션
|
||||
- [x] DB 마이그레이션 스크립트 작성 및 실행
|
||||
- [x] 백엔드 타입 정의 수정 (`default_method`, `default_body` 추가)
|
||||
|
||||
### 1단계: 긴급 버그 수정
|
||||
### 2단계: 백엔드 로직 구현
|
||||
- [x] 커넥션 생성/수정 API 수정 (필드 추가)
|
||||
- [x] 커넥션 상세 조회 API 확인
|
||||
- [x] 연결 테스트 API 수정 (Method, Body 반영하여 요청 전송)
|
||||
|
||||
- [x] `loadMaterialCountsForLocations` 함수에서 `locaKeys` undefined 체크 추가 (완료)
|
||||
- [ ] 에디터 로드 및 객체 조작 시 에러 발생 여부 확인
|
||||
### 3단계: 프론트엔드 구현
|
||||
- [x] 커넥션 관리 리스트/모달 UI 수정
|
||||
- [x] 연결 테스트 UI 수정 및 기능 확인
|
||||
|
||||
### 2단계: 잠재적 문제 점검
|
||||
|
||||
- [ ] `loadLayout` 등 주요 로딩 함수의 데이터 유효성 검사
|
||||
- [ ] `handleToolDragStart`, `handleCanvasDrop` 등 인터랙션 함수의 예외 처리
|
||||
## 에러 처리 계획
|
||||
- **JSON 파싱 에러**: Body 입력값이 유효한 JSON이 아닐 경우 에러 처리
|
||||
- **API 호출 에러**: 외부 API 호출 실패 시 상세 로그 기록 및 클라이언트에 에러 메시지 전달
|
||||
- **SSL 인증 에러**: `rejectUnauthorized: false` 옵션으로 처리 (기존 `RestApiConnector` 활용)
|
||||
|
||||
## 진행 상태
|
||||
|
||||
- [진행중] 1단계 긴급 버그 수정 완료 후 사용자 피드백 대기 중
|
||||
|
||||
- [완료] 모든 단계 구현 완료
|
||||
|
|
|
|||
|
|
@ -1,18 +0,0 @@
|
|||
{
|
||||
"id": "1d997eeb-3d61-427d-8b54-119d4372b9b3",
|
||||
"sentAt": "2025-10-22T07:13:30.905Z",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"subject": "Fwd: ㄴ",
|
||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">전달히야야양</p><p style=\"margin: 0 0 16px 0; line-height: 1.6;\"><br>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━<br>전달된 메일:</p><p style=\"margin: 0 0 16px 0; line-height: 1.6;\">보낸사람: \"이희진\" <zian9227@naver.com><br>날짜: 2025. 10. 22. 오후 12:58:15<br>제목: ㄴ<br>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</p><p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㄴㅇㄹㄴㅇㄹㄴㅇㄹ<br></p>\r\n </div>\r\n ",
|
||||
"status": "success",
|
||||
"messageId": "<d20cd501-04a4-bbe6-8b50-7f43e19bd70a@wace.me>",
|
||||
"accepted": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"rejected": []
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
{
|
||||
"id": "1e492bb1-d069-4242-8cbf-9829b8f6c7e6",
|
||||
"sentAt": "2025-10-13T01:08:34.764Z",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"subject": "제목 없음",
|
||||
"htmlContent": "\n<!DOCTYPE html>\n<html>\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n</head>\n<body style=\"margin: 0; padding: 0; background-color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;\">\n <table role=\"presentation\" style=\"width: 100%; border-collapse: collapse; background-color: #ffffff;\">\n <tr>\n <td style=\"padding: 20px;\">\n<div style=\"margin: 0 0 20px 0; color: #333; font-size: 15px; line-height: 1.6; text-align: left;\"><p>ㄴㅇㄹㄴㅇㄹ</p></div><div style=\"margin: 30px 0; text-align: left;\">\n <a href=\"https://example.com\" style=\"display: inline-block; padding: 14px 28px; background-color: #007bff; color: #fff; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 15px;\">ㄴㅇㄹ버튼</a>\n </div><div style=\"margin: 20px 0; text-align: left;\">\n <img src=\"https://placehold.co/600x200/e5e7eb/64748b?text=Image\" alt=\"\" style=\"max-width: 100%; height: auto; display: block; border-radius: 4px;\" />\n </div><div style=\"height: 20;\"></div><div style=\"margin: 0 0 20px 0; color: #333; font-size: 15px; line-height: 1.6; text-align: left;\"><p>ㄴㅇㄹ</p></div><div style=\"margin: 0 0 20px 0; color: #333; font-size: 15px; line-height: 1.6; text-align: left;\"><p>ㄴㅇㄹ</p></div>\n </td>\n </tr>\n </table>\n\n <div style=\"margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb;\">\n \r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㄴㅇㄹ</p>\r\n </div>\r\n \n </div>\n </body>\n</html>\n",
|
||||
"templateId": "template-1760315158387",
|
||||
"templateName": "테스트2",
|
||||
"attachments": [
|
||||
{
|
||||
"filename": "스크린샷 2025-10-13 오전 10.00.06.png",
|
||||
"originalName": "스크린샷 2025-10-13 오전 10.00.06.png",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1760317712416-622369845.png",
|
||||
"mimetype": "image/png"
|
||||
}
|
||||
],
|
||||
"status": "success",
|
||||
"messageId": "<f03bea59-9a77-b454-845e-7ad2a070bade@wace.me>",
|
||||
"accepted": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"rejected": []
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
{
|
||||
"id": "2d848b19-26e1-45ad-8e2c-9205f1f01c87",
|
||||
"sentAt": "2025-10-02T07:50:25.817Z",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"subject": "ㅣ;ㅏㅓ",
|
||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅓㅏㅣ</p>\r\n </div>\r\n ",
|
||||
"attachments": [
|
||||
{
|
||||
"filename": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
||||
"originalName": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759391422625-269479520_test____________________________33.jpg",
|
||||
"mimetype": "image/jpeg"
|
||||
},
|
||||
{
|
||||
"filename": "UI_áá
¢áá
¥á«áá
¡áá
¡á¼_áá
®á«áá
¥.md",
|
||||
"originalName": "UI_áá
¢áá
¥á«áá
¡áá
¡á¼_áá
®á«áá
¥.md",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759391422626-68453569_UI_______________________________________________.md",
|
||||
"mimetype": "text/x-markdown"
|
||||
},
|
||||
{
|
||||
"filename": "testáá
á¼ áá
栠
栠
µ2.png",
|
||||
"originalName": "testáá
á¼ áá
栠
栠
µ2.png",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759391422626-168170034_test____________________________2.png",
|
||||
"mimetype": "image/png"
|
||||
}
|
||||
],
|
||||
"status": "success",
|
||||
"messageId": "<9d5b8275-e059-3a71-a34a-dea800730aa3@wace.me>",
|
||||
"accepted": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"rejected": []
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
{
|
||||
"id": "331d95d6-3a13-4657-bc75-ab0811712eb8",
|
||||
"sentAt": "2025-10-22T07:18:18.240Z",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"subject": "ㅁㄴㅇㄹㅁㄴㅇㄹ",
|
||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹㅁㄴㅇㄹㄴㅇㄹㄴㅇㄹ</p>\r\n </div>\r\n ",
|
||||
"status": "success",
|
||||
"messageId": "<d4923c0d-f692-7d1d-d1b0-3b9e1e6cbab5@wace.me>",
|
||||
"accepted": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"rejected": []
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,41 +0,0 @@
|
|||
{
|
||||
"id": "37fce6a0-2301-431b-b573-82bdab9b8008",
|
||||
"sentAt": "2025-10-02T07:44:38.128Z",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"subject": "asd",
|
||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">asd</p>\r\n </div>\r\n ",
|
||||
"attachments": [
|
||||
{
|
||||
"filename": "áá
°áá
栠
³-áá
µá·áá
µá¨áá
¯á«-áá
³á
á
©áá
¡áá
µá¯-áá
栠
´áá
栮.key",
|
||||
"originalName": "áá
°áá
栠
³-áá
µá·áá
µá¨áá
¯á«-áá
³á
á
©áá
¡áá
µá¯-áá
栠
´áá
栮.key",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759391076653-58189058___________________-___________________________-___________________________-_____________________.key",
|
||||
"mimetype": "application/x-iwork-keynote-sffkey"
|
||||
},
|
||||
{
|
||||
"filename": "áá
°áá
栠
³-áá
µá·áá
µá¨áá
¯á«-áá
³á
á
©áá
¡áá
µá¯-áá
栠
´áá
栮.pptx",
|
||||
"originalName": "áá
°áá
栠
³-áá
µá·áá
µá¨áá
¯á«-áá
³á
á
©áá
¡áá
µá¯-áá
栠
´áá
栮.pptx",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759391076736-190208246___________________-___________________________-___________________________-_____________________.pptx",
|
||||
"mimetype": "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
},
|
||||
{
|
||||
"filename": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
||||
"originalName": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759391076738-240665795_test____________________________33.jpg",
|
||||
"mimetype": "image/jpeg"
|
||||
}
|
||||
],
|
||||
"status": "success",
|
||||
"messageId": "<796cb9a7-df62-31c4-ae6b-b42f383d82b4@wace.me>",
|
||||
"accepted": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"rejected": []
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1,18 +0,0 @@
|
|||
{
|
||||
"id": "84ee9619-49ff-4f61-a7fa-0bb0b0b7199a",
|
||||
"sentAt": "2025-10-22T04:27:51.044Z",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"\"이희진\" <zian9227@naver.com>"
|
||||
],
|
||||
"subject": "Re: ㅅㄷㄴㅅ",
|
||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">야야야야야야야야ㅑㅇ야ㅑㅇ</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border-left: 3px solid #ccc; padding-left: 10px; margin-left: 10px; color: #666;\">\r\n <p><strong>보낸 사람:</strong> \"이희진\" <zian9227@naver.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:03:03</p>\r\n <p><strong>제목:</strong> ㅅㄷㄴㅅ</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\r\n ",
|
||||
"status": "success",
|
||||
"messageId": "<5fa451ff-7d29-7da4-ce56-ca7391c147af@wace.me>",
|
||||
"accepted": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"rejected": []
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
{
|
||||
"id": "89a32ace-f39b-44fa-b614-c65d96548f92",
|
||||
"sentAt": "2025-10-22T03:49:48.461Z",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"subject": "Fwd: 기상청 API허브 회원가입 인증번호",
|
||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\"><br> <br/><br/><br> <div style=\"border: 1px solid #ccc; padding: 15px; margin: 10px 0; background-color: #f9f9f9;\"><br> <p><strong>---------- 전달된 메시지 ----------</strong></p><br> <p><strong>보낸 사람:</strong> \"기상청 API허브\" <noreply@apihube.kma.go.kr></p><br> <p><strong>날짜:</strong> 2025. 10. 13. 오후 4:26:45</p><br> <p><strong>제목:</strong> 기상청 API허브 회원가입 인증번호</p><br> <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" /><br> undefined<br> </div><br> </p>\r\n </div>\r\n ",
|
||||
"status": "success",
|
||||
"messageId": "<9b36ce56-4ef1-cf0c-1f39-2c73bcb521da@wace.me>",
|
||||
"accepted": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"rejected": []
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
{
|
||||
"id": "9eab902e-f77b-424f-ada4-0ea8709b36bf",
|
||||
"sentAt": "2025-10-13T00:53:55.193Z",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"subject": "제목 없음",
|
||||
"htmlContent": "<div style=\"max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;\"><p style=\"margin: 16px 0; color: #333; font-size: 14px;\"><p>텍스트를 입력하세요...</p></p><div style=\"text-align: center; margin: 24px 0;\">\n <a href=\"https://example.com\" style=\"display: inline-block; padding: 12px 24px; background-color: #007bff; color: #fff; text-decoration: none; border-radius: 4px;\">버튼</a>\n </div><div style=\"text-align: center; margin: 16px 0;\">\n <img src=\"https://placehold.co/600x200/e5e7eb/64748b?text=Image\" alt=\"\" style=\"max-width: 100%; height: auto;\" />\n </div><div style=\"height: 20;\"></div><p style=\"margin: 16px 0; color: #333; font-size: 14px;\"><p>텍스트를 입력하세요...</p></p><p style=\"margin: 16px 0; color: #333; font-size: 14px;\"><p>텍스트를 입력하세요...</p></p>\n <div style=\"margin-top: 32px; padding-top: 24px; border-top: 1px solid #e5e7eb;\">\n \r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">어덯게 나오는지 봅시다 추가메시지 영역이빈다.</p>\r\n </div>\r\n \n </div>\n </div>",
|
||||
"templateId": "template-1760315158387",
|
||||
"templateName": "테스트2",
|
||||
"attachments": [
|
||||
{
|
||||
"filename": "한글.txt",
|
||||
"originalName": "한글.txt",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1760316833254-789302611.txt",
|
||||
"mimetype": "text/plain"
|
||||
}
|
||||
],
|
||||
"status": "success",
|
||||
"messageId": "<3d0bef10-2e58-fd63-b175-c1f499af0102@wace.me>",
|
||||
"accepted": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"rejected": []
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
{
|
||||
"id": "a1ca39ad-4467-44e0-963a-fba5037c8896",
|
||||
"sentAt": "2025-10-02T08:22:14.721Z",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"subject": "ㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴ",
|
||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹㅁㄴㅇㄹㄴㅁㅇㄹ</p>\r\n </div>\r\n ",
|
||||
"attachments": [
|
||||
{
|
||||
"filename": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
||||
"originalName": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759393332207-791945862_test____________________________33.jpg",
|
||||
"mimetype": "image/jpeg"
|
||||
},
|
||||
{
|
||||
"filename": "UI_áá
¢áá
¥á«áá
¡áá
¡á¼_áá
®á«áá
¥.md",
|
||||
"originalName": "UI_áá
¢áá
¥á«áá
¡áá
¡á¼_áá
®á«áá
¥.md",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759393332208-660280542_UI_______________________________________________.md",
|
||||
"mimetype": "text/x-markdown"
|
||||
},
|
||||
{
|
||||
"filename": "testáá
á¼ áá
栠
栠
µ2.png",
|
||||
"originalName": "testáá
á¼ áá
栠
栠
µ2.png",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759393332208-149486455_test____________________________2.png",
|
||||
"mimetype": "image/png"
|
||||
}
|
||||
],
|
||||
"status": "success",
|
||||
"messageId": "<d52bab7c-4285-8a27-12ed-b501ff858d23@wace.me>",
|
||||
"accepted": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"rejected": []
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
{
|
||||
"id": "a3a9aab1-4334-46bd-bf50-b867305f66c0",
|
||||
"sentAt": "2025-10-02T08:41:42.086Z",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"subject": "한글테스트",
|
||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
|
||||
"attachments": [
|
||||
{
|
||||
"filename": "UI_áá
¢áá
¥á«áá
¡áá
¡á¼_áá
®á«áá
¥.md",
|
||||
"originalName": "UI_áá
¢áá
¥á«áá
¡áá
¡á¼_áá
®á«áá
¥.md",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759394500462-50127394_UI_______________________________________________.md",
|
||||
"mimetype": "text/x-markdown"
|
||||
},
|
||||
{
|
||||
"filename": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
||||
"originalName": "testáá
á¼ áá
栠
栠
µ33.jpg",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759394500463-68744474_test____________________________33.jpg",
|
||||
"mimetype": "image/jpeg"
|
||||
},
|
||||
{
|
||||
"filename": "testáá
á¼ áá
栠
栠
µ2.png",
|
||||
"originalName": "testáá
á¼ áá
栠
栠
µ2.png",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759394500463-464487722_test____________________________2.png",
|
||||
"mimetype": "image/png"
|
||||
}
|
||||
],
|
||||
"status": "success",
|
||||
"messageId": "<2dbfbf64-69c2-a83d-6bb7-515e4e654628@wace.me>",
|
||||
"accepted": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"rejected": []
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
{
|
||||
"id": "a638f7d0-ee31-47fa-9f72-de66ef31ea44",
|
||||
"sentAt": "2025-10-22T07:21:13.723Z",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"subject": "ㄹㅇㄴㅁㄹㅇㄴㅁ",
|
||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㄹㅇㄴㅁㄹㅇㄴㅁㅇㄹㅇㄴㅁ</p>\r\n </div>\r\n ",
|
||||
"status": "success",
|
||||
"messageId": "<5ea07d02-78bf-a655-8289-bcbd8eaf7741@wace.me>",
|
||||
"accepted": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"rejected": []
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
{
|
||||
"id": "b1d8f458-076c-4c44-982e-d2f46dcd4b03",
|
||||
"sentAt": "2025-10-02T08:57:48.412Z",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"subject": "ㅁㄴㅇㄹ",
|
||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
|
||||
"attachments": [
|
||||
{
|
||||
"filename": "웨이스-임직원-프로파일-이희진.key",
|
||||
"originalName": "웨이스-임직원-프로파일-이희진.key",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759395465488-120933172.key",
|
||||
"mimetype": "application/x-iwork-keynote-sffkey"
|
||||
},
|
||||
{
|
||||
"filename": "UI_개선사항_문서.md",
|
||||
"originalName": "UI_개선사항_문서.md",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759395465566-306126854.md",
|
||||
"mimetype": "text/x-markdown"
|
||||
},
|
||||
{
|
||||
"filename": "test용 이미지33.jpg",
|
||||
"originalName": "test용 이미지33.jpg",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759395465566-412984398.jpg",
|
||||
"mimetype": "image/jpeg"
|
||||
},
|
||||
{
|
||||
"filename": "test용 이미지2.png",
|
||||
"originalName": "test용 이미지2.png",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759395465567-143883587.png",
|
||||
"mimetype": "image/png"
|
||||
}
|
||||
],
|
||||
"status": "success",
|
||||
"messageId": "<e2796753-a1a9-fbac-c035-00341e29031c@wace.me>",
|
||||
"accepted": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"rejected": []
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
{
|
||||
"id": "b75d0b2b-7d8a-461b-b854-2bebdef959e8",
|
||||
"sentAt": "2025-10-02T08:49:30.356Z",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"subject": "한글2",
|
||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
|
||||
"attachments": [
|
||||
{
|
||||
"filename": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
|
||||
"originalName": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759394969516-74008147_UI__________________________.md",
|
||||
"mimetype": "text/x-markdown"
|
||||
},
|
||||
{
|
||||
"filename": "testááá¼ ááµááµááµ33.jpg",
|
||||
"originalName": "testááá¼ ááµááµááµ33.jpg",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759394969516-530544653_test_______________33.jpg",
|
||||
"mimetype": "image/jpeg"
|
||||
},
|
||||
{
|
||||
"filename": "testááá¼ ááµááµááµ2.png",
|
||||
"originalName": "testááá¼ ááµááµááµ2.png",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759394969517-260831218_test_______________2.png",
|
||||
"mimetype": "image/png"
|
||||
}
|
||||
],
|
||||
"status": "success",
|
||||
"messageId": "<80a431a1-bb4d-31b5-2564-93f8c2539fd4@wace.me>",
|
||||
"accepted": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"rejected": []
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
{
|
||||
"id": "ccdd8961-1b3f-4b88-b838-51d6ed8f1601",
|
||||
"sentAt": "2025-10-02T08:47:03.481Z",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"subject": "한글테스트222",
|
||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">2</p>\r\n </div>\r\n ",
|
||||
"attachments": [
|
||||
{
|
||||
"filename": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
|
||||
"originalName": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759394821751-229305880_UI__________________________.md",
|
||||
"mimetype": "text/x-markdown"
|
||||
},
|
||||
{
|
||||
"filename": "testááá¼ ááµááµááµ33.jpg",
|
||||
"originalName": "testááá¼ ááµááµááµ33.jpg",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759394821751-335146895_test_______________33.jpg",
|
||||
"mimetype": "image/jpeg"
|
||||
},
|
||||
{
|
||||
"filename": "testááá¼ ááµááµááµ2.png",
|
||||
"originalName": "testááá¼ ááµááµááµ2.png",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759394821753-911076131_test_______________2.png",
|
||||
"mimetype": "image/png"
|
||||
}
|
||||
],
|
||||
"status": "success",
|
||||
"messageId": "<69519c70-a5cd-421d-9976-8c7014d69b39@wace.me>",
|
||||
"accepted": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"rejected": []
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
|
@ -1,27 +0,0 @@
|
|||
{
|
||||
"id": "e93848a8-6901-44c4-b4db-27c8d2aeb8dd",
|
||||
"sentAt": "2025-10-22T04:28:42.686Z",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"\"권은아\" <chna8137s@gmail.com>"
|
||||
],
|
||||
"subject": "Re: 매우 졸린 오후예요",
|
||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">호홋 답장 기능을 구현했다죵<br>얼른 퇴근하고 싪네여</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border-left: 3px solid #ccc; padding-left: 10px; margin-left: 10px; color: #666;\">\r\n <p><strong>보낸 사람:</strong> \"권은아\" <chna8137s@gmail.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:10:37</p>\r\n <p><strong>제목:</strong> 매우 졸린 오후예요</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\r\n ",
|
||||
"attachments": [
|
||||
{
|
||||
"filename": "test용 이미지2.png",
|
||||
"originalName": "test용 이미지2.png",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1761107318152-717716316.png",
|
||||
"mimetype": "image/png"
|
||||
}
|
||||
],
|
||||
"status": "success",
|
||||
"messageId": "<19981423-259b-0a50-e76d-23c860692c16@wace.me>",
|
||||
"accepted": [
|
||||
"chna8137s@gmail.com"
|
||||
],
|
||||
"rejected": []
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
{
|
||||
"id": "ee0d162c-48ad-4c00-8c56-ade80be4503f",
|
||||
"sentAt": "2025-10-02T08:48:29.740Z",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"subject": "한글한글",
|
||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹㅁㄴㅇㄹㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
|
||||
"attachments": [
|
||||
{
|
||||
"filename": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
|
||||
"originalName": "UI_áá¢áá¥á«áá¡áá¡á¼_áá®á«áá¥.md",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759394908877-38147683_UI__________________________.md",
|
||||
"mimetype": "text/x-markdown"
|
||||
},
|
||||
{
|
||||
"filename": "testááá¼ ááµááµááµ33.jpg",
|
||||
"originalName": "testááá¼ ááµááµááµ33.jpg",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759394908879-80461065_test_______________33.jpg",
|
||||
"mimetype": "image/jpeg"
|
||||
},
|
||||
{
|
||||
"filename": "testááá¼ ááµááµááµ2.png",
|
||||
"originalName": "testááá¼ ááµááµááµ2.png",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1759394908880-475630926_test_______________2.png",
|
||||
"mimetype": "image/png"
|
||||
}
|
||||
],
|
||||
"status": "success",
|
||||
"messageId": "<96205714-1a6b-adb7-7ae5-0e1e3fcb700b@wace.me>",
|
||||
"accepted": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"rejected": []
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
{
|
||||
"id": "fc26aba3-6b6e-47ba-91e8-609ae25e0e7d",
|
||||
"sentAt": "2025-10-13T00:21:51.799Z",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"subject": "test용입니다.",
|
||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">ㅁㄴㅇㄹ</p>\r\n </div>\r\n ",
|
||||
"templateId": "template-1759302346758",
|
||||
"templateName": "test",
|
||||
"attachments": [
|
||||
{
|
||||
"filename": "웨이스-임직원-프로파일-이희진.key",
|
||||
"originalName": "웨이스-임직원-프로파일-이희진.key",
|
||||
"size": 0,
|
||||
"path": "/app/uploads/mail-attachments/1760314910154-84512253.key",
|
||||
"mimetype": "application/x-iwork-keynote-sffkey"
|
||||
}
|
||||
],
|
||||
"status": "success",
|
||||
"messageId": "<c84bcecc-2e8f-4a32-1b7f-44a91b195b2d@wace.me>",
|
||||
"accepted": [
|
||||
"zian9227@naver.com"
|
||||
],
|
||||
"rejected": []
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
{
|
||||
"id": "fcea6149-a098-4212-aa00-baef0cc083d6",
|
||||
"sentAt": "2025-10-22T04:24:54.126Z",
|
||||
"accountId": "account-1759310844272",
|
||||
"accountName": "이희진",
|
||||
"accountEmail": "hjlee@wace.me",
|
||||
"to": [
|
||||
"\"DHS\" <ddhhss0603@gmail.com>"
|
||||
],
|
||||
"subject": "Re: 안녕하세여",
|
||||
"htmlContent": "\r\n <div style=\"font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;\">\r\n <p style=\"margin: 0 0 16px 0; line-height: 1.6;\">어떻게 가는지 궁금한데 이따가 화면 보여주세영</p>\r\n </div>\r\n <br/><br/>\r\n <div style=\"border-left: 3px solid #ccc; padding-left: 10px; margin-left: 10px; color: #666;\">\r\n <p><strong>보낸 사람:</strong> \"DHS\" <ddhhss0603@gmail.com></p>\r\n <p><strong>날짜:</strong> 2025. 10. 22. 오후 1:09:49</p>\r\n <p><strong>제목:</strong> 안녕하세여</p>\r\n <hr style=\"border: none; border-top: 1px solid #ccc; margin: 10px 0;\" />\r\n undefined\r\n </div>\r\n ",
|
||||
"status": "success",
|
||||
"messageId": "<c24b04f0-b958-5e0b-4cc7-2bff30f23c2c@wace.me>",
|
||||
"accepted": [
|
||||
"ddhhss0603@gmail.com"
|
||||
],
|
||||
"rejected": []
|
||||
}
|
||||
|
|
@ -169,22 +169,18 @@ export class BatchController {
|
|||
static async getBatchConfigById(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
const batchConfig = await BatchService.getBatchConfigById(
|
||||
Number(id),
|
||||
userCompanyCode
|
||||
);
|
||||
const result = await BatchService.getBatchConfigById(Number(id));
|
||||
|
||||
if (!batchConfig) {
|
||||
if (!result.success || !result.data) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "배치 설정을 찾을 수 없습니다.",
|
||||
message: result.message || "배치 설정을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: batchConfig,
|
||||
data: result.data,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("배치 설정 조회 오류:", error);
|
||||
|
|
|
|||
|
|
@ -62,6 +62,11 @@ export class BatchExecutionLogController {
|
|||
try {
|
||||
const data: CreateBatchExecutionLogRequest = req.body;
|
||||
|
||||
// 멀티테넌시: company_code가 없으면 현재 사용자 회사 코드로 설정
|
||||
if (!data.company_code) {
|
||||
data.company_code = req.user?.companyCode || "*";
|
||||
}
|
||||
|
||||
const result = await BatchExecutionLogService.createExecutionLog(data);
|
||||
|
||||
if (result.success) {
|
||||
|
|
|
|||
|
|
@ -265,8 +265,12 @@ export class BatchManagementController {
|
|||
|
||||
try {
|
||||
// 실행 로그 생성
|
||||
executionLog = await BatchService.createExecutionLog({
|
||||
const { BatchExecutionLogService } = await import(
|
||||
"../services/batchExecutionLogService"
|
||||
);
|
||||
const logResult = await BatchExecutionLogService.createExecutionLog({
|
||||
batch_config_id: Number(id),
|
||||
company_code: batchConfig.company_code,
|
||||
execution_status: "RUNNING",
|
||||
start_time: startTime,
|
||||
total_records: 0,
|
||||
|
|
@ -274,6 +278,14 @@ export class BatchManagementController {
|
|||
failed_records: 0,
|
||||
});
|
||||
|
||||
if (!logResult.success || !logResult.data) {
|
||||
throw new Error(
|
||||
logResult.message || "배치 실행 로그를 생성할 수 없습니다."
|
||||
);
|
||||
}
|
||||
|
||||
executionLog = logResult.data;
|
||||
|
||||
// BatchSchedulerService의 executeBatchConfig 메서드 사용 (중복 로직 제거)
|
||||
const { BatchSchedulerService } = await import(
|
||||
"../services/batchSchedulerService"
|
||||
|
|
@ -290,7 +302,7 @@ export class BatchManagementController {
|
|||
const duration = endTime.getTime() - startTime.getTime();
|
||||
|
||||
// 실행 로그 업데이트 (성공)
|
||||
await BatchService.updateExecutionLog(executionLog.id, {
|
||||
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
||||
execution_status: "SUCCESS",
|
||||
end_time: endTime,
|
||||
duration_ms: duration,
|
||||
|
|
@ -406,22 +418,34 @@ export class BatchManagementController {
|
|||
paramName,
|
||||
paramValue,
|
||||
paramSource,
|
||||
requestBody,
|
||||
} = req.body;
|
||||
|
||||
if (!apiUrl || !apiKey || !endpoint) {
|
||||
// apiUrl, endpoint는 항상 필수
|
||||
if (!apiUrl || !endpoint) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "API URL, API Key, 엔드포인트는 필수입니다.",
|
||||
message: "API URL과 엔드포인트는 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// GET 요청일 때만 API Key 필수 (POST/PUT/DELETE는 선택)
|
||||
if ((!method || method === "GET") && !apiKey) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "GET 메서드에서는 API Key가 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
console.log("🔍 REST API 미리보기 요청:", {
|
||||
apiUrl,
|
||||
endpoint,
|
||||
method,
|
||||
paramType,
|
||||
paramName,
|
||||
paramValue,
|
||||
paramSource,
|
||||
requestBody: requestBody ? "Included" : "None",
|
||||
});
|
||||
|
||||
// RestApiConnector 사용하여 데이터 조회
|
||||
|
|
@ -429,7 +453,7 @@ export class BatchManagementController {
|
|||
|
||||
const connector = new RestApiConnector({
|
||||
baseUrl: apiUrl,
|
||||
apiKey: apiKey,
|
||||
apiKey: apiKey || "",
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
|
|
@ -456,9 +480,28 @@ export class BatchManagementController {
|
|||
|
||||
console.log("🔗 최종 엔드포인트:", finalEndpoint);
|
||||
|
||||
// 데이터 조회 (최대 5개만) - GET 메서드만 지원
|
||||
const result = await connector.executeQuery(finalEndpoint, method);
|
||||
console.log(`[previewRestApiData] executeQuery 결과:`, {
|
||||
// Request Body 파싱
|
||||
let parsedBody = undefined;
|
||||
if (requestBody && typeof requestBody === "string") {
|
||||
try {
|
||||
parsedBody = JSON.parse(requestBody);
|
||||
} catch (e) {
|
||||
console.warn("Request Body JSON 파싱 실패:", e);
|
||||
// 파싱 실패 시 원본 문자열 사용하거나 무시 (상황에 따라 결정, 여기선 undefined로 처리하거나 에러 반환 가능)
|
||||
// 여기서는 경고 로그 남기고 진행
|
||||
}
|
||||
} else if (requestBody) {
|
||||
parsedBody = requestBody;
|
||||
}
|
||||
|
||||
// 데이터 조회 - executeRequest 사용 (POST/PUT/DELETE 지원)
|
||||
const result = await connector.executeRequest(
|
||||
finalEndpoint,
|
||||
method as "GET" | "POST" | "PUT" | "DELETE",
|
||||
parsedBody
|
||||
);
|
||||
|
||||
console.log(`[previewRestApiData] executeRequest 결과:`, {
|
||||
rowCount: result.rowCount,
|
||||
rowsLength: result.rows ? result.rows.length : "undefined",
|
||||
firstRow:
|
||||
|
|
@ -532,15 +575,21 @@ export class BatchManagementController {
|
|||
apiMappings,
|
||||
});
|
||||
|
||||
// 🔐 멀티테넌시: 현재 사용자 회사 코드 사용 (프론트에서 받지 않음)
|
||||
const companyCode = req.user?.companyCode || "*";
|
||||
const userId = req.user?.userId;
|
||||
|
||||
// BatchService를 사용하여 배치 설정 저장
|
||||
const batchConfig: CreateBatchConfigRequest = {
|
||||
batchName: batchName,
|
||||
description: description || "",
|
||||
cronSchedule: cronSchedule,
|
||||
isActive: "Y",
|
||||
companyCode,
|
||||
mappings: apiMappings,
|
||||
};
|
||||
|
||||
const result = await BatchService.createBatchConfig(batchConfig);
|
||||
const result = await BatchService.createBatchConfig(batchConfig, userId);
|
||||
|
||||
if (result.success && result.data) {
|
||||
// 스케줄러에 자동 등록 ✅
|
||||
|
|
|
|||
|
|
@ -161,3 +161,4 @@ export const createMappingTemplate = async (
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import axios, { AxiosInstance, AxiosResponse } from "axios";
|
||||
import https from "https";
|
||||
import {
|
||||
DatabaseConnector,
|
||||
ConnectionConfig,
|
||||
|
|
@ -24,16 +25,26 @@ export class RestApiConnector implements DatabaseConnector {
|
|||
|
||||
constructor(config: RestApiConfig) {
|
||||
this.config = config;
|
||||
|
||||
// Axios 인스턴스 생성
|
||||
// 🔐 apiKey가 없을 수도 있으므로 Authorization 헤더는 선택적으로만 추가
|
||||
const defaultHeaders: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
};
|
||||
|
||||
if (config.apiKey) {
|
||||
defaultHeaders["Authorization"] = `Bearer ${config.apiKey}`;
|
||||
}
|
||||
|
||||
this.httpClient = axios.create({
|
||||
baseURL: config.baseUrl,
|
||||
timeout: config.timeout || 30000,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
headers: defaultHeaders,
|
||||
// ⚠️ 외부 API 중 자체 서명 인증서를 사용하는 경우가 있어서
|
||||
// 인증서 검증을 끈 HTTPS 에이전트를 사용한다.
|
||||
// 내부망/신뢰된 시스템 전용으로 사용해야 하며,
|
||||
// 공개 인터넷용 API에는 적용하면 안 된다.
|
||||
httpsAgent: new https.Agent({ rejectUnauthorized: false }),
|
||||
});
|
||||
|
||||
// 요청/응답 인터셉터 설정
|
||||
|
|
@ -75,26 +86,16 @@ export class RestApiConnector implements DatabaseConnector {
|
|||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
try {
|
||||
// 연결 테스트 - 기본 엔드포인트 호출
|
||||
await this.httpClient.get("/health", { timeout: 5000 });
|
||||
console.log(`[RestApiConnector] 연결 성공: ${this.config.baseUrl}`);
|
||||
} catch (error) {
|
||||
// health 엔드포인트가 없을 수 있으므로 404는 정상으로 처리
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
console.log(
|
||||
`[RestApiConnector] 연결 성공 (health 엔드포인트 없음): ${this.config.baseUrl}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.error(
|
||||
`[RestApiConnector] 연결 실패: ${this.config.baseUrl}`,
|
||||
error
|
||||
);
|
||||
throw new Error(
|
||||
`REST API 연결 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`
|
||||
);
|
||||
}
|
||||
// 기존에는 /health 엔드포인트를 호출해서 미리 연결을 검사했지만,
|
||||
// 일반 외부 API들은 /health가 없거나 401/500을 반환하는 경우가 많아
|
||||
// 불필요하게 예외가 나면서 미리보기/배치 실행이 막히는 문제가 있었다.
|
||||
//
|
||||
// 따라서 여기서는 "연결 준비 완료" 정도만 로그로 남기고
|
||||
// 실제 호출 실패 여부는 executeRequest 단계에서만 판단하도록 한다.
|
||||
console.log(
|
||||
`[RestApiConnector] 연결 준비 완료 (사전 헬스체크 생략): ${this.config.baseUrl}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -213,7 +213,10 @@ router.post(
|
|||
}
|
||||
|
||||
const result =
|
||||
await ExternalRestApiConnectionService.testConnection(testRequest);
|
||||
await ExternalRestApiConnectionService.testConnection(
|
||||
testRequest,
|
||||
req.user?.companyCode
|
||||
);
|
||||
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
|
|
@ -264,4 +267,46 @@ router.post(
|
|||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/external-rest-api-connections/:id/fetch
|
||||
* REST API 데이터 조회 (화면관리용 프록시)
|
||||
*/
|
||||
router.post(
|
||||
"/:id/fetch",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 ID입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const { endpoint, jsonPath } = req.body;
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
|
||||
logger.info(`REST API 데이터 조회 요청: 연결 ID=${id}, endpoint=${endpoint}, jsonPath=${jsonPath}`);
|
||||
|
||||
const result = await ExternalRestApiConnectionService.fetchData(
|
||||
id,
|
||||
endpoint,
|
||||
jsonPath,
|
||||
userCompanyCode
|
||||
);
|
||||
|
||||
return res.status(result.success ? 200 : 400).json(result);
|
||||
} catch (error) {
|
||||
logger.error("REST API 데이터 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "서버 내부 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -170,3 +170,4 @@ export class DigitalTwinTemplateService {
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -130,13 +130,14 @@ export class BatchExecutionLogService {
|
|||
try {
|
||||
const log = await queryOne<BatchExecutionLog>(
|
||||
`INSERT INTO batch_execution_logs (
|
||||
batch_config_id, execution_status, start_time, end_time,
|
||||
batch_config_id, company_code, execution_status, start_time, end_time,
|
||||
duration_ms, total_records, success_records, failed_records,
|
||||
error_message, error_details, server_name, process_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING *`,
|
||||
[
|
||||
data.batch_config_id,
|
||||
data.company_code,
|
||||
data.execution_status,
|
||||
data.start_time || new Date(),
|
||||
data.end_time,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,258 +1,114 @@
|
|||
// 배치 스케줄러 서비스
|
||||
// 작성일: 2024-12-24
|
||||
|
||||
import * as cron from "node-cron";
|
||||
import { query, queryOne } from "../database/db";
|
||||
import cron from "node-cron";
|
||||
import { BatchService } from "./batchService";
|
||||
import { BatchExecutionLogService } from "./batchExecutionLogService";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export class BatchSchedulerService {
|
||||
private static scheduledTasks: Map<number, cron.ScheduledTask> = new Map();
|
||||
private static isInitialized = false;
|
||||
private static executingBatches: Set<number> = new Set(); // 실행 중인 배치 추적
|
||||
|
||||
/**
|
||||
* 스케줄러 초기화
|
||||
* 모든 활성 배치의 스케줄링 초기화
|
||||
*/
|
||||
static async initialize() {
|
||||
static async initializeScheduler() {
|
||||
try {
|
||||
logger.info("배치 스케줄러 초기화 시작...");
|
||||
logger.info("배치 스케줄러 초기화 시작");
|
||||
|
||||
// 기존 모든 스케줄 정리 (중복 방지)
|
||||
this.clearAllSchedules();
|
||||
const batchConfigsResponse = await BatchService.getBatchConfigs({
|
||||
is_active: "Y",
|
||||
});
|
||||
|
||||
// 활성화된 배치 설정들을 로드하여 스케줄 등록
|
||||
await this.loadActiveBatchConfigs();
|
||||
|
||||
this.isInitialized = true;
|
||||
logger.info("배치 스케줄러 초기화 완료");
|
||||
} catch (error) {
|
||||
logger.error("배치 스케줄러 초기화 실패:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 스케줄 정리
|
||||
*/
|
||||
private static clearAllSchedules() {
|
||||
logger.info(`기존 스케줄 ${this.scheduledTasks.size}개 정리 중...`);
|
||||
|
||||
for (const [id, task] of this.scheduledTasks) {
|
||||
try {
|
||||
task.stop();
|
||||
task.destroy();
|
||||
logger.info(`스케줄 정리 완료: ID ${id}`);
|
||||
} catch (error) {
|
||||
logger.error(`스케줄 정리 실패: ID ${id}`, error);
|
||||
}
|
||||
}
|
||||
|
||||
this.scheduledTasks.clear();
|
||||
this.isInitialized = false;
|
||||
logger.info("모든 스케줄 정리 완료");
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성화된 배치 설정들을 로드하여 스케줄 등록
|
||||
*/
|
||||
private static async loadActiveBatchConfigs() {
|
||||
try {
|
||||
const activeConfigs = await query<any>(
|
||||
`SELECT
|
||||
bc.*,
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', bm.id,
|
||||
'batch_config_id', bm.batch_config_id,
|
||||
'from_connection_type', bm.from_connection_type,
|
||||
'from_connection_id', bm.from_connection_id,
|
||||
'from_table_name', bm.from_table_name,
|
||||
'from_column_name', bm.from_column_name,
|
||||
'from_column_type', bm.from_column_type,
|
||||
'to_connection_type', bm.to_connection_type,
|
||||
'to_connection_id', bm.to_connection_id,
|
||||
'to_table_name', bm.to_table_name,
|
||||
'to_column_name', bm.to_column_name,
|
||||
'to_column_type', bm.to_column_type,
|
||||
'mapping_order', bm.mapping_order,
|
||||
'from_api_url', bm.from_api_url,
|
||||
'from_api_key', bm.from_api_key,
|
||||
'from_api_method', bm.from_api_method,
|
||||
'from_api_param_type', bm.from_api_param_type,
|
||||
'from_api_param_name', bm.from_api_param_name,
|
||||
'from_api_param_value', bm.from_api_param_value,
|
||||
'from_api_param_source', bm.from_api_param_source,
|
||||
'to_api_url', bm.to_api_url,
|
||||
'to_api_key', bm.to_api_key,
|
||||
'to_api_method', bm.to_api_method,
|
||||
'to_api_body', bm.to_api_body
|
||||
)
|
||||
) FILTER (WHERE bm.id IS NOT NULL) as batch_mappings
|
||||
FROM batch_configs bc
|
||||
LEFT JOIN batch_mappings bm ON bc.id = bm.batch_config_id
|
||||
WHERE bc.is_active = 'Y'
|
||||
GROUP BY bc.id`,
|
||||
[]
|
||||
);
|
||||
|
||||
logger.info(`활성화된 배치 설정 ${activeConfigs.length}개 발견`);
|
||||
|
||||
for (const config of activeConfigs) {
|
||||
await this.scheduleBatchConfig(config);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("활성화된 배치 설정 로드 실패:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 설정을 스케줄에 등록
|
||||
*/
|
||||
static async scheduleBatchConfig(config: any) {
|
||||
try {
|
||||
const { id, batch_name, cron_schedule } = config;
|
||||
|
||||
// 기존 스케줄이 있다면 제거
|
||||
if (this.scheduledTasks.has(id)) {
|
||||
this.scheduledTasks.get(id)?.stop();
|
||||
this.scheduledTasks.delete(id);
|
||||
}
|
||||
|
||||
// cron 스케줄 유효성 검사
|
||||
if (!cron.validate(cron_schedule)) {
|
||||
logger.error(`잘못된 cron 스케줄: ${cron_schedule} (배치 ID: ${id})`);
|
||||
if (!batchConfigsResponse.success || !batchConfigsResponse.data) {
|
||||
logger.warn("스케줄링할 활성 배치 설정이 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 새로운 스케줄 등록
|
||||
const task = cron.schedule(cron_schedule, async () => {
|
||||
// 중복 실행 방지 체크
|
||||
if (this.executingBatches.has(id)) {
|
||||
logger.warn(
|
||||
`⚠️ 배치가 이미 실행 중입니다. 건너뜀: ${batch_name} (ID: ${id})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const batchConfigs = batchConfigsResponse.data;
|
||||
logger.info(`${batchConfigs.length}개의 배치 설정 스케줄링 등록`);
|
||||
|
||||
logger.info(`🔄 스케줄 배치 실행 시작: ${batch_name} (ID: ${id})`);
|
||||
for (const config of batchConfigs) {
|
||||
await this.scheduleBatch(config);
|
||||
}
|
||||
|
||||
// 실행 중 플래그 설정
|
||||
this.executingBatches.add(id);
|
||||
logger.info("배치 스케줄러 초기화 완료");
|
||||
} catch (error) {
|
||||
logger.error("배치 스케줄러 초기화 중 오류 발생:", error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await this.executeBatchConfig(config);
|
||||
} finally {
|
||||
// 실행 완료 후 플래그 제거
|
||||
this.executingBatches.delete(id);
|
||||
}
|
||||
/**
|
||||
* 개별 배치 작업 스케줄링
|
||||
*/
|
||||
static async scheduleBatch(config: any) {
|
||||
try {
|
||||
// 기존 스케줄이 있으면 제거
|
||||
if (this.scheduledTasks.has(config.id)) {
|
||||
this.scheduledTasks.get(config.id)?.stop();
|
||||
this.scheduledTasks.delete(config.id);
|
||||
}
|
||||
|
||||
if (config.is_active !== "Y") {
|
||||
logger.info(
|
||||
`배치 스케줄링 건너뜀 (비활성 상태): ${config.batch_name} (ID: ${config.id})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cron.validate(config.cron_schedule)) {
|
||||
logger.error(
|
||||
`유효하지 않은 Cron 표현식: ${config.cron_schedule} (Batch ID: ${config.id})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`배치 스케줄 등록: ${config.batch_name} (ID: ${config.id}, Cron: ${config.cron_schedule})`
|
||||
);
|
||||
|
||||
const task = cron.schedule(config.cron_schedule, async () => {
|
||||
logger.info(
|
||||
`스케줄에 의한 배치 실행 시작: ${config.batch_name} (ID: ${config.id})`
|
||||
);
|
||||
await this.executeBatchConfig(config);
|
||||
});
|
||||
|
||||
// 스케줄 시작 (기본적으로 시작되지만 명시적으로 호출)
|
||||
task.start();
|
||||
|
||||
this.scheduledTasks.set(id, task);
|
||||
logger.info(
|
||||
`배치 스케줄 등록 완료: ${batch_name} (ID: ${id}, Schedule: ${cron_schedule}) - 스케줄 시작됨`
|
||||
);
|
||||
this.scheduledTasks.set(config.id, task);
|
||||
} catch (error) {
|
||||
logger.error(`배치 스케줄 등록 실패 (ID: ${config.id}):`, error);
|
||||
logger.error(`배치 스케줄링 중 오류 발생 (ID: ${config.id}):`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 설정 스케줄 제거
|
||||
*/
|
||||
static async unscheduleBatchConfig(batchConfigId: number) {
|
||||
try {
|
||||
if (this.scheduledTasks.has(batchConfigId)) {
|
||||
this.scheduledTasks.get(batchConfigId)?.stop();
|
||||
this.scheduledTasks.delete(batchConfigId);
|
||||
logger.info(`배치 스케줄 제거 완료 (ID: ${batchConfigId})`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`배치 스케줄 제거 실패 (ID: ${batchConfigId}):`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 설정 업데이트 시 스케줄 재등록
|
||||
* 배치 스케줄 업데이트 (설정 변경 시 호출)
|
||||
*/
|
||||
static async updateBatchSchedule(
|
||||
configId: number,
|
||||
executeImmediately: boolean = true
|
||||
) {
|
||||
try {
|
||||
// 기존 스케줄 제거
|
||||
await this.unscheduleBatchConfig(configId);
|
||||
|
||||
// 업데이트된 배치 설정 조회
|
||||
const configResult = await query<any>(
|
||||
`SELECT
|
||||
bc.*,
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'id', bm.id,
|
||||
'batch_config_id', bm.batch_config_id,
|
||||
'from_connection_type', bm.from_connection_type,
|
||||
'from_connection_id', bm.from_connection_id,
|
||||
'from_table_name', bm.from_table_name,
|
||||
'from_column_name', bm.from_column_name,
|
||||
'from_column_type', bm.from_column_type,
|
||||
'to_connection_type', bm.to_connection_type,
|
||||
'to_connection_id', bm.to_connection_id,
|
||||
'to_table_name', bm.to_table_name,
|
||||
'to_column_name', bm.to_column_name,
|
||||
'to_column_type', bm.to_column_type,
|
||||
'mapping_order', bm.mapping_order,
|
||||
'from_api_url', bm.from_api_url,
|
||||
'from_api_key', bm.from_api_key,
|
||||
'from_api_method', bm.from_api_method,
|
||||
'from_api_param_type', bm.from_api_param_type,
|
||||
'from_api_param_name', bm.from_api_param_name,
|
||||
'from_api_param_value', bm.from_api_param_value,
|
||||
'from_api_param_source', bm.from_api_param_source,
|
||||
'to_api_url', bm.to_api_url,
|
||||
'to_api_key', bm.to_api_key,
|
||||
'to_api_method', bm.to_api_method,
|
||||
'to_api_body', bm.to_api_body
|
||||
)
|
||||
) FILTER (WHERE bm.id IS NOT NULL) as batch_mappings
|
||||
FROM batch_configs bc
|
||||
LEFT JOIN batch_mappings bm ON bc.id = bm.batch_config_id
|
||||
WHERE bc.id = $1
|
||||
GROUP BY bc.id`,
|
||||
[configId]
|
||||
);
|
||||
|
||||
const config = configResult[0] || null;
|
||||
|
||||
if (!config) {
|
||||
logger.warn(`배치 설정을 찾을 수 없습니다: ID ${configId}`);
|
||||
const result = await BatchService.getBatchConfigById(configId);
|
||||
if (!result.success || !result.data) {
|
||||
// 설정이 없으면 스케줄 제거
|
||||
if (this.scheduledTasks.has(configId)) {
|
||||
this.scheduledTasks.get(configId)?.stop();
|
||||
this.scheduledTasks.delete(configId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 활성화된 배치만 다시 스케줄 등록
|
||||
if (config.is_active === "Y") {
|
||||
await this.scheduleBatchConfig(config);
|
||||
logger.info(
|
||||
`배치 스케줄 업데이트 완료: ${config.batch_name} (ID: ${configId})`
|
||||
);
|
||||
const config = result.data;
|
||||
|
||||
// 활성화 시 즉시 실행 (옵션)
|
||||
if (executeImmediately) {
|
||||
logger.info(
|
||||
`🚀 배치 활성화 즉시 실행: ${config.batch_name} (ID: ${configId})`
|
||||
);
|
||||
await this.executeBatchConfig(config);
|
||||
}
|
||||
} else {
|
||||
logger.info(
|
||||
`비활성화된 배치 스케줄 제거: ${config.batch_name} (ID: ${configId})`
|
||||
// 스케줄 재등록
|
||||
await this.scheduleBatch(config);
|
||||
|
||||
// 즉시 실행 옵션이 있으면 실행
|
||||
/*
|
||||
if (executeImmediately && config.is_active === "Y") {
|
||||
logger.info(`배치 설정 변경 후 즉시 실행: ${config.batch_name}`);
|
||||
this.executeBatchConfig(config).catch((err) =>
|
||||
logger.error(`즉시 실행 중 오류 발생:`, err)
|
||||
);
|
||||
}
|
||||
*/
|
||||
} catch (error) {
|
||||
logger.error(`배치 스케줄 업데이트 실패: ID ${configId}`, error);
|
||||
}
|
||||
|
|
@ -272,6 +128,7 @@ export class BatchSchedulerService {
|
|||
const executionLogResponse =
|
||||
await BatchExecutionLogService.createExecutionLog({
|
||||
batch_config_id: config.id,
|
||||
company_code: config.company_code,
|
||||
execution_status: "RUNNING",
|
||||
start_time: startTime,
|
||||
total_records: 0,
|
||||
|
|
@ -313,21 +170,20 @@ export class BatchSchedulerService {
|
|||
// 성공 결과 반환
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`배치 실행 실패: ${config.batch_name}`, error);
|
||||
logger.error(`배치 실행 중 오류 발생: ${config.batch_name}`, error);
|
||||
|
||||
// 실행 로그 업데이트 (실패)
|
||||
if (executionLog) {
|
||||
await BatchExecutionLogService.updateExecutionLog(executionLog.id, {
|
||||
execution_status: "FAILED",
|
||||
execution_status: "FAILURE",
|
||||
end_time: new Date(),
|
||||
duration_ms: Date.now() - startTime.getTime(),
|
||||
error_message:
|
||||
error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
error_details: error instanceof Error ? error.stack : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
// 실패 시에도 결과 반환
|
||||
// 실패 결과 반환
|
||||
return {
|
||||
totalRecords: 0,
|
||||
successRecords: 0,
|
||||
|
|
@ -379,6 +235,8 @@ export class BatchSchedulerService {
|
|||
const { BatchExternalDbService } = await import(
|
||||
"./batchExternalDbService"
|
||||
);
|
||||
|
||||
// 👇 Body 파라미터 추가 (POST 요청 시)
|
||||
const apiResult = await BatchExternalDbService.getDataFromRestApi(
|
||||
firstMapping.from_api_url!,
|
||||
firstMapping.from_api_key!,
|
||||
|
|
@ -394,7 +252,9 @@ export class BatchSchedulerService {
|
|||
firstMapping.from_api_param_type,
|
||||
firstMapping.from_api_param_name,
|
||||
firstMapping.from_api_param_value,
|
||||
firstMapping.from_api_param_source
|
||||
firstMapping.from_api_param_source,
|
||||
// 👇 Body 전달 (FROM - REST API - POST 요청)
|
||||
firstMapping.from_api_body
|
||||
);
|
||||
|
||||
if (apiResult.success && apiResult.data) {
|
||||
|
|
@ -416,6 +276,17 @@ export class BatchSchedulerService {
|
|||
totalRecords += fromData.length;
|
||||
|
||||
// 컬럼 매핑 적용하여 TO 테이블 형식으로 변환
|
||||
// 유틸리티 함수: 점 표기법을 사용하여 중첩된 객체 값 가져오기
|
||||
const getValueByPath = (obj: any, path: string) => {
|
||||
if (!path) return undefined;
|
||||
// path가 'response.access_token' 처럼 점을 포함하는 경우
|
||||
if (path.includes(".")) {
|
||||
return path.split(".").reduce((acc, part) => acc && acc[part], obj);
|
||||
}
|
||||
// 단순 키인 경우
|
||||
return obj[path];
|
||||
};
|
||||
|
||||
const mappedData = fromData.map((row) => {
|
||||
const mappedRow: any = {};
|
||||
for (const mapping of mappings) {
|
||||
|
|
@ -428,10 +299,25 @@ export class BatchSchedulerService {
|
|||
mappedRow[mapping.from_column_name] =
|
||||
row[mapping.from_column_name];
|
||||
} else {
|
||||
// 기존 로직: to_column_name을 키로 사용
|
||||
mappedRow[mapping.to_column_name] = row[mapping.from_column_name];
|
||||
// REST API -> DB (POST 요청 포함) 또는 DB -> DB
|
||||
// row[mapping.from_column_name] 대신 getValueByPath 사용
|
||||
const value = getValueByPath(row, mapping.from_column_name);
|
||||
|
||||
mappedRow[mapping.to_column_name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// 멀티테넌시: TO가 DB일 때 company_code 자동 주입
|
||||
// - 배치 설정에 company_code가 있고
|
||||
// - 매핑에서 company_code를 명시적으로 다루지 않은 경우만
|
||||
if (
|
||||
firstMapping.to_connection_type !== "restapi" &&
|
||||
config.company_code &&
|
||||
mappedRow.company_code === undefined
|
||||
) {
|
||||
mappedRow.company_code = config.company_code;
|
||||
}
|
||||
|
||||
return mappedRow;
|
||||
});
|
||||
|
||||
|
|
@ -482,22 +368,12 @@ export class BatchSchedulerService {
|
|||
);
|
||||
}
|
||||
} else {
|
||||
// 기존 REST API 전송 (REST API → DB 배치)
|
||||
const apiResult = await BatchExternalDbService.sendDataToRestApi(
|
||||
firstMapping.to_api_url!,
|
||||
firstMapping.to_api_key!,
|
||||
firstMapping.to_table_name,
|
||||
(firstMapping.to_api_method as "POST" | "PUT") || "POST",
|
||||
mappedData
|
||||
// 기존 REST API 전송 (REST API → DB 배치) - 사실 이 경우는 거의 없음 (REST to REST)
|
||||
// 지원하지 않음
|
||||
logger.warn(
|
||||
"REST API -> REST API (단순 매핑)은 아직 지원하지 않습니다."
|
||||
);
|
||||
|
||||
if (apiResult.success && apiResult.data) {
|
||||
insertResult = apiResult.data;
|
||||
} else {
|
||||
throw new Error(
|
||||
`REST API 데이터 전송 실패: ${apiResult.message}`
|
||||
);
|
||||
}
|
||||
insertResult = { successCount: 0, failedCount: 0 };
|
||||
}
|
||||
} else {
|
||||
// DB에 데이터 삽입
|
||||
|
|
@ -511,167 +387,13 @@ export class BatchSchedulerService {
|
|||
|
||||
successRecords += insertResult.successCount;
|
||||
failedRecords += insertResult.failedCount;
|
||||
|
||||
logger.info(
|
||||
`테이블 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`테이블 처리 실패: ${tableKey}`, error);
|
||||
failedRecords += 1;
|
||||
logger.error(`테이블 처리 중 오류 발생: ${tableKey}`, error);
|
||||
// 해당 테이블 처리 실패는 전체 실패로 간주하지 않고, 실패 카운트만 증가?
|
||||
// 여기서는 일단 실패 로그만 남기고 계속 진행 (필요시 정책 변경)
|
||||
}
|
||||
}
|
||||
|
||||
return { totalRecords, successRecords, failedRecords };
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 매핑 처리 (기존 메서드 - 사용 안 함)
|
||||
*/
|
||||
private static async processBatchMappings(config: any) {
|
||||
const { batch_mappings } = config;
|
||||
let totalRecords = 0;
|
||||
let successRecords = 0;
|
||||
let failedRecords = 0;
|
||||
|
||||
if (!batch_mappings || batch_mappings.length === 0) {
|
||||
logger.warn(`배치 매핑이 없습니다: ${config.batch_name}`);
|
||||
return { totalRecords, successRecords, failedRecords };
|
||||
}
|
||||
|
||||
for (const mapping of batch_mappings) {
|
||||
try {
|
||||
logger.info(
|
||||
`매핑 처리 시작: ${mapping.from_table_name} -> ${mapping.to_table_name}`
|
||||
);
|
||||
|
||||
// FROM 테이블에서 데이터 조회
|
||||
const fromData = await this.getDataFromSource(mapping);
|
||||
totalRecords += fromData.length;
|
||||
|
||||
// TO 테이블에 데이터 삽입
|
||||
const insertResult = await this.insertDataToTarget(mapping, fromData);
|
||||
successRecords += insertResult.successCount;
|
||||
failedRecords += insertResult.failedCount;
|
||||
|
||||
logger.info(
|
||||
`매핑 처리 완료: ${insertResult.successCount}개 성공, ${insertResult.failedCount}개 실패`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`매핑 처리 실패: ${mapping.from_table_name} -> ${mapping.to_table_name}`,
|
||||
error
|
||||
);
|
||||
failedRecords += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { totalRecords, successRecords, failedRecords };
|
||||
}
|
||||
|
||||
/**
|
||||
* FROM 테이블에서 데이터 조회
|
||||
*/
|
||||
private static async getDataFromSource(mapping: any) {
|
||||
try {
|
||||
if (mapping.from_connection_type === "internal") {
|
||||
// 내부 DB에서 조회
|
||||
const result = await query<any>(
|
||||
`SELECT * FROM ${mapping.from_table_name}`,
|
||||
[]
|
||||
);
|
||||
return result;
|
||||
} else {
|
||||
// 외부 DB에서 조회 (구현 필요)
|
||||
logger.warn("외부 DB 조회는 아직 구현되지 않았습니다.");
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`FROM 테이블 데이터 조회 실패: ${mapping.from_table_name}`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TO 테이블에 데이터 삽입
|
||||
*/
|
||||
private static async insertDataToTarget(mapping: any, data: any[]) {
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
try {
|
||||
if (mapping.to_connection_type === "internal") {
|
||||
// 내부 DB에 삽입
|
||||
for (const record of data) {
|
||||
try {
|
||||
// 매핑된 컬럼만 추출
|
||||
const mappedData = this.mapColumns(record, mapping);
|
||||
|
||||
const columns = Object.keys(mappedData);
|
||||
const values = Object.values(mappedData);
|
||||
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
|
||||
|
||||
await query(
|
||||
`INSERT INTO ${mapping.to_table_name} (${columns.join(", ")}) VALUES (${placeholders})`,
|
||||
values
|
||||
);
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
logger.error(`레코드 삽입 실패:`, error);
|
||||
failedCount++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 외부 DB에 삽입 (구현 필요)
|
||||
logger.warn("외부 DB 삽입은 아직 구현되지 않았습니다.");
|
||||
failedCount = data.length;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`TO 테이블 데이터 삽입 실패: ${mapping.to_table_name}`,
|
||||
error
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return { successCount, failedCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 매핑
|
||||
*/
|
||||
private static mapColumns(record: any, mapping: any) {
|
||||
const mappedData: any = {};
|
||||
|
||||
// 단순한 컬럼 매핑 (실제로는 더 복잡한 로직 필요)
|
||||
mappedData[mapping.to_column_name] = record[mapping.from_column_name];
|
||||
|
||||
return mappedData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 스케줄 중지
|
||||
*/
|
||||
static async stopAllSchedules() {
|
||||
try {
|
||||
for (const [id, task] of this.scheduledTasks) {
|
||||
task.stop();
|
||||
logger.info(`배치 스케줄 중지: ID ${id}`);
|
||||
}
|
||||
this.scheduledTasks.clear();
|
||||
this.isInitialized = false;
|
||||
logger.info("모든 배치 스케줄이 중지되었습니다.");
|
||||
} catch (error) {
|
||||
logger.error("배치 스케줄 중지 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 등록된 스케줄 목록 조회
|
||||
*/
|
||||
static getScheduledTasks() {
|
||||
return Array.from(this.scheduledTasks.keys());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||
|
|
@ -158,6 +166,9 @@ export class ExternalRestApiConnectionService {
|
|||
? this.decryptSensitiveData(connection.auth_config)
|
||||
: null;
|
||||
|
||||
// 디버깅: 조회된 연결 정보 로깅
|
||||
logger.info(`REST API 연결 조회 결과 (ID: ${id}): connection_name=${connection.connection_name}, default_method=${connection.default_method}, endpoint_path=${connection.endpoint_path}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: connection,
|
||||
|
|
@ -194,9 +205,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 +218,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,
|
||||
|
|
@ -216,6 +230,15 @@ export class ExternalRestApiConnectionService {
|
|||
data.created_by || "system",
|
||||
];
|
||||
|
||||
// 디버깅: 저장하려는 데이터 로깅
|
||||
logger.info(`REST API 연결 생성 요청 데이터:`, {
|
||||
connection_name: data.connection_name,
|
||||
default_method: data.default_method,
|
||||
endpoint_path: data.endpoint_path,
|
||||
base_url: data.base_url,
|
||||
default_body: data.default_body ? "있음" : "없음",
|
||||
});
|
||||
|
||||
const result: QueryResult<any> = await pool.query(query, params);
|
||||
|
||||
logger.info(`REST API 연결 생성 성공: ${data.connection_name}`);
|
||||
|
|
@ -301,6 +324,20 @@ export class ExternalRestApiConnectionService {
|
|||
paramIndex++;
|
||||
}
|
||||
|
||||
if (data.default_method !== undefined) {
|
||||
updateFields.push(`default_method = $${paramIndex}`);
|
||||
params.push(data.default_method);
|
||||
paramIndex++;
|
||||
logger.info(`수정 요청 - default_method: ${data.default_method}`);
|
||||
}
|
||||
|
||||
if (data.default_body !== undefined) {
|
||||
updateFields.push(`default_request_body = $${paramIndex}`);
|
||||
params.push(data.default_body); // null이면 DB에서 NULL로 저장됨
|
||||
paramIndex++;
|
||||
logger.info(`수정 요청 - default_body: ${data.default_body ? "있음" : "삭제(null)"}`);
|
||||
}
|
||||
|
||||
if (data.auth_type !== undefined) {
|
||||
updateFields.push(`auth_type = $${paramIndex}`);
|
||||
params.push(data.auth_type);
|
||||
|
|
@ -441,7 +478,8 @@ export class ExternalRestApiConnectionService {
|
|||
* REST API 연결 테스트 (테스트 요청 데이터 기반)
|
||||
*/
|
||||
static async testConnection(
|
||||
testRequest: RestApiTestRequest
|
||||
testRequest: RestApiTestRequest,
|
||||
userCompanyCode?: string
|
||||
): Promise<RestApiTestResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
|
|
@ -450,7 +488,78 @@ export class ExternalRestApiConnectionService {
|
|||
const headers = { ...testRequest.headers };
|
||||
|
||||
// 인증 헤더 추가
|
||||
if (
|
||||
if (testRequest.auth_type === "db-token") {
|
||||
const cfg = testRequest.auth_config || {};
|
||||
const {
|
||||
dbTableName,
|
||||
dbValueColumn,
|
||||
dbWhereColumn,
|
||||
dbWhereValue,
|
||||
dbHeaderName,
|
||||
dbHeaderTemplate,
|
||||
} = cfg;
|
||||
|
||||
if (!dbTableName || !dbValueColumn) {
|
||||
throw new Error("DB 토큰 설정이 올바르지 않습니다.");
|
||||
}
|
||||
|
||||
if (!userCompanyCode) {
|
||||
throw new Error("DB 토큰 모드에서는 회사 코드가 필요합니다.");
|
||||
}
|
||||
|
||||
const hasWhereColumn = !!dbWhereColumn;
|
||||
const hasWhereValue =
|
||||
dbWhereValue !== undefined && dbWhereValue !== null && dbWhereValue !== "";
|
||||
|
||||
// where 컬럼/값은 둘 다 비우거나 둘 다 채워야 함
|
||||
if (hasWhereColumn !== hasWhereValue) {
|
||||
throw new Error(
|
||||
"DB 토큰 설정에서 조건 컬럼과 조건 값은 둘 다 비우거나 둘 다 입력해야 합니다."
|
||||
);
|
||||
}
|
||||
|
||||
// 식별자 검증 (간단한 화이트리스트)
|
||||
const identifierRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
if (
|
||||
!identifierRegex.test(dbTableName) ||
|
||||
!identifierRegex.test(dbValueColumn) ||
|
||||
(hasWhereColumn && !identifierRegex.test(dbWhereColumn as string))
|
||||
) {
|
||||
throw new Error(
|
||||
"DB 토큰 설정에 유효하지 않은 테이블 또는 컬럼명이 포함되어 있습니다."
|
||||
);
|
||||
}
|
||||
|
||||
let sql = `
|
||||
SELECT ${dbValueColumn} AS token_value
|
||||
FROM ${dbTableName}
|
||||
WHERE company_code = $1
|
||||
`;
|
||||
|
||||
const params: any[] = [userCompanyCode];
|
||||
|
||||
if (hasWhereColumn && hasWhereValue) {
|
||||
sql += ` AND ${dbWhereColumn} = $2`;
|
||||
params.push(dbWhereValue);
|
||||
}
|
||||
|
||||
sql += `
|
||||
ORDER BY updated_date DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const tokenResult: QueryResult<any> = await pool.query(sql, params);
|
||||
|
||||
if (tokenResult.rowCount === 0) {
|
||||
throw new Error("DB에서 토큰을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
const tokenValue = tokenResult.rows[0]["token_value"];
|
||||
const headerName = dbHeaderName || "Authorization";
|
||||
const template = dbHeaderTemplate || "Bearer {{value}}";
|
||||
|
||||
headers[headerName] = template.replace("{{value}}", tokenValue);
|
||||
} else if (
|
||||
testRequest.auth_type === "bearer" &&
|
||||
testRequest.auth_config?.token
|
||||
) {
|
||||
|
|
@ -493,25 +602,84 @@ export class ExternalRestApiConnectionService {
|
|||
`REST API 연결 테스트: ${testRequest.method || "GET"} ${url}`
|
||||
);
|
||||
|
||||
// HTTP 요청 실행
|
||||
const response = await fetch(url, {
|
||||
method: testRequest.method || "GET",
|
||||
headers,
|
||||
signal: AbortSignal.timeout(testRequest.timeout || 30000),
|
||||
});
|
||||
// Body 처리
|
||||
let body: any = undefined;
|
||||
if (testRequest.body) {
|
||||
// 이미 문자열이면 그대로, 객체면 JSON 문자열로 변환
|
||||
if (typeof testRequest.body === "string") {
|
||||
body = testRequest.body;
|
||||
} else {
|
||||
body = JSON.stringify(testRequest.body);
|
||||
}
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
let responseData = null;
|
||||
|
||||
try {
|
||||
responseData = await response.json();
|
||||
} catch {
|
||||
// JSON 파싱 실패는 무시 (텍스트 응답일 수 있음)
|
||||
// Content-Type 헤더가 없으면 기본적으로 application/json 추가
|
||||
const hasContentType = Object.keys(headers).some(
|
||||
(k) => k.toLowerCase() === "content-type"
|
||||
);
|
||||
if (!hasContentType) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP 요청 실행
|
||||
// [인수인계 중요] 2024-11-27 추가
|
||||
// 특정 레거시/내부망 API(예: thiratis.com)의 경우 SSL 인증서 체인 문제로 인해
|
||||
// Node.js 레벨에서 검증 실패(UNABLE_TO_VERIFY_LEAF_SIGNATURE)가 발생합니다.
|
||||
//
|
||||
// 원래는 인프라(OS/Docker)에 루트 CA를 등록하는 것이 정석이나,
|
||||
// 유지보수 및 설정 편의성을 위해 코드 레벨에서 '특정 도메인'에 한해서만
|
||||
// SSL 검증을 우회하도록 예외 처리를 해두었습니다.
|
||||
//
|
||||
// ※ 보안 주의: 여기에 모르는 도메인을 함부로 추가하면 중간자 공격(MITM)에 취약해질 수 있습니다.
|
||||
// 꼭 필요한 신뢰할 수 있는 도메인만 추가하세요.
|
||||
const bypassDomains = ["thiratis.com"];
|
||||
const shouldBypassTls = bypassDomains.some((domain) =>
|
||||
url.includes(domain)
|
||||
);
|
||||
|
||||
const httpsAgent = new https.Agent({
|
||||
// bypassDomains에 포함된 URL이면 검증을 무시(false), 아니면 정상 검증(true)
|
||||
rejectUnauthorized: !shouldBypassTls,
|
||||
});
|
||||
|
||||
const requestConfig = {
|
||||
url,
|
||||
method: (testRequest.method || "GET") as any,
|
||||
headers,
|
||||
data: body,
|
||||
httpsAgent,
|
||||
timeout: testRequest.timeout || 30000,
|
||||
// 4xx/5xx 도 예외가 아니라 응답 객체로 처리
|
||||
validateStatus: () => true,
|
||||
};
|
||||
|
||||
// 요청 상세 로그 (민감 정보는 최소화)
|
||||
logger.info(
|
||||
`REST API 연결 테스트 요청 상세: ${JSON.stringify({
|
||||
method: requestConfig.method,
|
||||
url: requestConfig.url,
|
||||
headers: {
|
||||
...requestConfig.headers,
|
||||
// Authorization 헤더는 마스킹
|
||||
Authorization: requestConfig.headers?.Authorization
|
||||
? "***masked***"
|
||||
: undefined,
|
||||
},
|
||||
hasBody: !!body,
|
||||
})}`
|
||||
);
|
||||
|
||||
const response: AxiosResponse = await axios.request(requestConfig);
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
// axios는 response.data에 이미 파싱된 응답 본문을 담아준다.
|
||||
// JSON이 아니어도 그대로 내려보내서 프론트에서 확인할 수 있게 한다.
|
||||
const responseData = response.data ?? null;
|
||||
|
||||
return {
|
||||
success: response.ok,
|
||||
message: response.ok
|
||||
success: response.status >= 200 && response.status < 300,
|
||||
message:
|
||||
response.status >= 200 && response.status < 300
|
||||
? "연결 성공"
|
||||
: `연결 실패 (${response.status} ${response.statusText})`,
|
||||
response_time: responseTime,
|
||||
|
|
@ -552,17 +720,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 +758,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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -683,6 +884,166 @@ export class ExternalRestApiConnectionService {
|
|||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* REST API 데이터 조회 (화면관리용 프록시)
|
||||
* 저장된 연결 정보를 사용하여 외부 REST API를 호출하고 데이터를 반환
|
||||
*/
|
||||
static async fetchData(
|
||||
connectionId: number,
|
||||
endpoint?: string,
|
||||
jsonPath?: string,
|
||||
userCompanyCode?: string
|
||||
): Promise<ApiResponse<any>> {
|
||||
try {
|
||||
// 연결 정보 조회
|
||||
const connectionResult = await this.getConnectionById(connectionId, userCompanyCode);
|
||||
|
||||
if (!connectionResult.success || !connectionResult.data) {
|
||||
return {
|
||||
success: false,
|
||||
message: "REST API 연결을 찾을 수 없습니다.",
|
||||
error: {
|
||||
code: "CONNECTION_NOT_FOUND",
|
||||
details: `연결 ID ${connectionId}를 찾을 수 없습니다.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const connection = connectionResult.data;
|
||||
|
||||
// 비활성화된 연결인지 확인
|
||||
if (connection.is_active !== "Y") {
|
||||
return {
|
||||
success: false,
|
||||
message: "비활성화된 REST API 연결입니다.",
|
||||
error: {
|
||||
code: "CONNECTION_INACTIVE",
|
||||
details: "연결이 비활성화 상태입니다.",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 엔드포인트 결정 (파라미터 > 저장된 값)
|
||||
const effectiveEndpoint = endpoint || connection.endpoint_path || "";
|
||||
|
||||
// API 호출을 위한 테스트 요청 생성
|
||||
const testRequest: RestApiTestRequest = {
|
||||
id: connection.id,
|
||||
base_url: connection.base_url,
|
||||
endpoint: effectiveEndpoint,
|
||||
method: (connection.default_method as any) || "GET",
|
||||
headers: connection.default_headers,
|
||||
body: connection.default_body,
|
||||
auth_type: connection.auth_type,
|
||||
auth_config: connection.auth_config,
|
||||
timeout: connection.timeout,
|
||||
};
|
||||
|
||||
// API 호출
|
||||
const result = await this.testConnection(testRequest, connection.company_code);
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: result.message || "REST API 호출에 실패했습니다.",
|
||||
error: {
|
||||
code: "API_CALL_FAILED",
|
||||
details: result.error_details,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 응답 데이터에서 jsonPath로 데이터 추출
|
||||
let extractedData = result.response_data;
|
||||
|
||||
logger.info(`REST API 원본 응답 데이터 타입: ${typeof result.response_data}`);
|
||||
logger.info(`REST API 원본 응답 데이터 (일부): ${JSON.stringify(result.response_data)?.substring(0, 500)}`);
|
||||
|
||||
if (jsonPath && result.response_data) {
|
||||
try {
|
||||
// jsonPath로 데이터 추출 (예: "data", "data.items", "result.list")
|
||||
const pathParts = jsonPath.split(".");
|
||||
logger.info(`JSON Path 파싱: ${jsonPath} -> [${pathParts.join(", ")}]`);
|
||||
|
||||
for (const part of pathParts) {
|
||||
if (extractedData && typeof extractedData === "object") {
|
||||
extractedData = (extractedData as any)[part];
|
||||
logger.info(`JSON Path '${part}' 추출 결과 타입: ${typeof extractedData}, 배열?: ${Array.isArray(extractedData)}`);
|
||||
} else {
|
||||
logger.warn(`JSON Path '${part}' 추출 실패: extractedData가 객체가 아님`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (pathError) {
|
||||
logger.warn(`JSON Path 추출 실패: ${jsonPath}`, pathError);
|
||||
// 추출 실패 시 원본 데이터 반환
|
||||
extractedData = result.response_data;
|
||||
}
|
||||
}
|
||||
|
||||
// 데이터가 배열이 아닌 경우 배열로 변환
|
||||
// null이나 undefined인 경우 빈 배열로 처리
|
||||
let dataArray: any[] = [];
|
||||
if (extractedData === null || extractedData === undefined) {
|
||||
logger.warn("추출된 데이터가 null/undefined입니다. 원본 응답 데이터를 사용합니다.");
|
||||
// jsonPath 추출 실패 시 원본 데이터에서 직접 컬럼 추출 시도
|
||||
if (result.response_data && typeof result.response_data === "object") {
|
||||
dataArray = Array.isArray(result.response_data) ? result.response_data : [result.response_data];
|
||||
}
|
||||
} else {
|
||||
dataArray = Array.isArray(extractedData) ? extractedData : [extractedData];
|
||||
}
|
||||
|
||||
logger.info(`최종 데이터 배열 길이: ${dataArray.length}`);
|
||||
if (dataArray.length > 0) {
|
||||
logger.info(`첫 번째 데이터 항목: ${JSON.stringify(dataArray[0])?.substring(0, 300)}`);
|
||||
}
|
||||
|
||||
// 컬럼 정보 추출 (첫 번째 유효한 데이터 기준)
|
||||
let columns: Array<{ columnName: string; columnLabel: string; dataType: string }> = [];
|
||||
|
||||
// 첫 번째 유효한 객체 찾기
|
||||
const firstValidItem = dataArray.find(item => item && typeof item === "object" && !Array.isArray(item));
|
||||
|
||||
if (firstValidItem) {
|
||||
columns = Object.keys(firstValidItem).map((key) => ({
|
||||
columnName: key,
|
||||
columnLabel: key,
|
||||
dataType: typeof firstValidItem[key],
|
||||
}));
|
||||
logger.info(`추출된 컬럼 수: ${columns.length}, 컬럼명: [${columns.map(c => c.columnName).join(", ")}]`);
|
||||
} else {
|
||||
logger.warn("유효한 데이터 항목을 찾을 수 없어 컬럼을 추출할 수 없습니다.");
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
rows: dataArray,
|
||||
columns,
|
||||
total: dataArray.length,
|
||||
connectionInfo: {
|
||||
connectionId: connection.id,
|
||||
connectionName: connection.connection_name,
|
||||
baseUrl: connection.base_url,
|
||||
endpoint: effectiveEndpoint,
|
||||
},
|
||||
},
|
||||
message: `${dataArray.length}개의 데이터를 조회했습니다.`,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("REST API 데이터 조회 오류:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "REST API 데이터 조회에 실패했습니다.",
|
||||
error: {
|
||||
code: "FETCH_ERROR",
|
||||
details: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 연결 데이터 유효성 검증
|
||||
*/
|
||||
|
|
@ -709,6 +1070,7 @@ export class ExternalRestApiConnectionService {
|
|||
"bearer",
|
||||
"basic",
|
||||
"oauth2",
|
||||
"db-token",
|
||||
];
|
||||
if (!validAuthTypes.includes(data.auth_type)) {
|
||||
throw new Error("올바르지 않은 인증 타입입니다.");
|
||||
|
|
|
|||
|
|
@ -334,9 +334,12 @@ class MailSendSimpleService {
|
|||
if (variables) {
|
||||
buttonText = this.replaceVariables(buttonText, variables);
|
||||
}
|
||||
// styles 객체 또는 직접 속성에서 색상 가져오기
|
||||
const buttonBgColor = component.styles?.backgroundColor || component.backgroundColor || '#007bff';
|
||||
const buttonTextColor = component.styles?.color || component.textColor || '#fff';
|
||||
// 버튼은 왼쪽 정렬 (text-align 제거)
|
||||
html += `<div style="margin: 30px 0; text-align: left;">
|
||||
<a href="${component.url || '#'}" style="display: inline-block; padding: 14px 28px; background-color: ${component.backgroundColor || '#007bff'}; color: ${component.textColor || '#fff'}; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 15px;">${buttonText}</a>
|
||||
<a href="${component.url || '#'}" style="display: inline-block; padding: 14px 28px; background-color: ${buttonBgColor}; color: ${buttonTextColor}; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 15px;">${buttonText}</a>
|
||||
</div>`;
|
||||
break;
|
||||
case 'image':
|
||||
|
|
@ -348,6 +351,89 @@ class MailSendSimpleService {
|
|||
case 'spacer':
|
||||
html += `<div style="height: ${component.height || '20px'};"></div>`;
|
||||
break;
|
||||
case 'header':
|
||||
html += `
|
||||
<div style="padding: 20px; background-color: ${component.headerBgColor || '#f8f9fa'}; border-radius: 8px; margin-bottom: 20px;">
|
||||
<table style="width: 100%;">
|
||||
<tr>
|
||||
<td style="vertical-align: middle;">
|
||||
${component.logoSrc ? `<img src="${component.logoSrc}" alt="로고" style="height: 40px; margin-right: 12px;">` : ''}
|
||||
<span style="font-size: 18px; font-weight: bold;">${component.brandName || ''}</span>
|
||||
</td>
|
||||
<td style="text-align: right; color: #6b7280; font-size: 14px;">
|
||||
${component.sendDate || ''}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case 'infoTable':
|
||||
html += `
|
||||
<div style="border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; margin: 16px 0;">
|
||||
${component.tableTitle ? `<div style="background-color: #f9fafb; padding: 12px 16px; font-weight: 600; border-bottom: 1px solid #e5e7eb;">${component.tableTitle}</div>` : ''}
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
${(component.rows || []).map((row: any, i: number) => `
|
||||
<tr style="background-color: ${i % 2 === 0 ? '#ffffff' : '#f9fafb'};">
|
||||
<td style="padding: 12px 16px; font-weight: 500; color: #4b5563; width: 35%; border-right: 1px solid #e5e7eb;">${row.label}</td>
|
||||
<td style="padding: 12px 16px;">${row.value}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case 'alertBox':
|
||||
const alertColors: Record<string, { bg: string; border: string; text: string }> = {
|
||||
info: { bg: '#eff6ff', border: '#3b82f6', text: '#1e40af' },
|
||||
warning: { bg: '#fffbeb', border: '#f59e0b', text: '#92400e' },
|
||||
danger: { bg: '#fef2f2', border: '#ef4444', text: '#991b1b' },
|
||||
success: { bg: '#ecfdf5', border: '#10b981', text: '#065f46' }
|
||||
};
|
||||
const colors = alertColors[component.alertType || 'info'];
|
||||
html += `
|
||||
<div style="padding: 16px; background-color: ${colors.bg}; border-left: 4px solid ${colors.border}; border-radius: 4px; margin: 16px 0; color: ${colors.text};">
|
||||
${component.alertTitle ? `<div style="font-weight: bold; margin-bottom: 8px;">${component.alertTitle}</div>` : ''}
|
||||
<div>${component.content || ''}</div>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case 'divider':
|
||||
html += `<hr style="border: none; border-top: ${component.height || 1}px solid #e5e7eb; margin: 20px 0;">`;
|
||||
break;
|
||||
case 'footer':
|
||||
html += `
|
||||
<div style="text-align: center; padding: 24px 16px; background-color: #f9fafb; border-top: 1px solid #e5e7eb; font-size: 14px; color: #6b7280;">
|
||||
${component.companyName ? `<div style="font-weight: 600; color: #374151; margin-bottom: 8px;">${component.companyName}</div>` : ''}
|
||||
${(component.ceoName || component.businessNumber) ? `
|
||||
<div style="margin-bottom: 4px;">
|
||||
${component.ceoName ? `대표: ${component.ceoName}` : ''}
|
||||
${component.ceoName && component.businessNumber ? ' | ' : ''}
|
||||
${component.businessNumber ? `사업자등록번호: ${component.businessNumber}` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
${component.address ? `<div style="margin-bottom: 4px;">${component.address}</div>` : ''}
|
||||
${(component.phone || component.email) ? `
|
||||
<div style="margin-bottom: 4px;">
|
||||
${component.phone ? `Tel: ${component.phone}` : ''}
|
||||
${component.phone && component.email ? ' | ' : ''}
|
||||
${component.email ? `Email: ${component.email}` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
${component.copyright ? `<div style="margin-top: 12px; font-size: 12px; color: #9ca3af;">${component.copyright}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case 'numberedList':
|
||||
html += `
|
||||
<div style="padding: 16px;">
|
||||
${component.listTitle ? `<div style="font-weight: 600; margin-bottom: 12px;">${component.listTitle}</div>` : ''}
|
||||
<ol style="margin: 0; padding-left: 20px;">
|
||||
${(component.listItems || []).map((item: string) => `<li style="margin-bottom: 8px;">${item}</li>`).join('')}
|
||||
</ol>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -4,13 +4,35 @@ import path from "path";
|
|||
// MailComponent 인터페이스 정의
|
||||
export interface MailComponent {
|
||||
id: string;
|
||||
type: "text" | "button" | "image" | "spacer";
|
||||
type: "text" | "button" | "image" | "spacer" | "header" | "infoTable" | "alertBox" | "divider" | "footer" | "numberedList";
|
||||
content?: string;
|
||||
text?: string;
|
||||
url?: string;
|
||||
src?: string;
|
||||
height?: number;
|
||||
styles?: Record<string, string>;
|
||||
// 헤더 컴포넌트용
|
||||
logoSrc?: string;
|
||||
brandName?: string;
|
||||
sendDate?: string;
|
||||
headerBgColor?: string;
|
||||
// 정보 테이블용
|
||||
rows?: Array<{ label: string; value: string }>;
|
||||
tableTitle?: string;
|
||||
// 강조 박스용
|
||||
alertType?: "info" | "warning" | "danger" | "success";
|
||||
alertTitle?: string;
|
||||
// 푸터용
|
||||
companyName?: string;
|
||||
ceoName?: string;
|
||||
businessNumber?: string;
|
||||
address?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
copyright?: string;
|
||||
// 번호 리스트용
|
||||
listItems?: string[];
|
||||
listTitle?: string;
|
||||
}
|
||||
|
||||
// QueryConfig 인터페이스 정의 (사용하지 않지만 타입 호환성 유지)
|
||||
|
|
@ -236,6 +258,89 @@ class MailTemplateFileService {
|
|||
case "spacer":
|
||||
html += `<div style="height: ${comp.height || 20}px;"></div>`;
|
||||
break;
|
||||
case "header":
|
||||
html += `
|
||||
<div style="padding: 20px; background-color: ${comp.headerBgColor || '#f8f9fa'}; border-radius: 8px; margin-bottom: 20px;">
|
||||
<table style="width: 100%;">
|
||||
<tr>
|
||||
<td style="vertical-align: middle;">
|
||||
${comp.logoSrc ? `<img src="${comp.logoSrc}" alt="로고" style="height: 40px; margin-right: 12px;">` : ''}
|
||||
<span style="font-size: 18px; font-weight: bold;">${comp.brandName || ''}</span>
|
||||
</td>
|
||||
<td style="text-align: right; color: #6b7280; font-size: 14px;">
|
||||
${comp.sendDate || ''}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case "infoTable":
|
||||
html += `
|
||||
<div style="border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; margin: 16px 0;">
|
||||
${comp.tableTitle ? `<div style="background-color: #f9fafb; padding: 12px 16px; font-weight: 600; border-bottom: 1px solid #e5e7eb;">${comp.tableTitle}</div>` : ''}
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
${(comp.rows || []).map((row, i) => `
|
||||
<tr style="background-color: ${i % 2 === 0 ? '#ffffff' : '#f9fafb'};">
|
||||
<td style="padding: 12px 16px; font-weight: 500; color: #4b5563; width: 35%; border-right: 1px solid #e5e7eb;">${row.label}</td>
|
||||
<td style="padding: 12px 16px;">${row.value}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case "alertBox":
|
||||
const alertColors: Record<string, { bg: string; border: string; text: string }> = {
|
||||
info: { bg: '#eff6ff', border: '#3b82f6', text: '#1e40af' },
|
||||
warning: { bg: '#fffbeb', border: '#f59e0b', text: '#92400e' },
|
||||
danger: { bg: '#fef2f2', border: '#ef4444', text: '#991b1b' },
|
||||
success: { bg: '#ecfdf5', border: '#10b981', text: '#065f46' }
|
||||
};
|
||||
const colors = alertColors[comp.alertType || 'info'];
|
||||
html += `
|
||||
<div style="padding: 16px; background-color: ${colors.bg}; border-left: 4px solid ${colors.border}; border-radius: 4px; margin: 16px 0; color: ${colors.text};">
|
||||
${comp.alertTitle ? `<div style="font-weight: bold; margin-bottom: 8px;">${comp.alertTitle}</div>` : ''}
|
||||
<div>${comp.content || ''}</div>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case "divider":
|
||||
html += `<hr style="border: none; border-top: ${comp.height || 1}px solid #e5e7eb; margin: 20px 0;">`;
|
||||
break;
|
||||
case "footer":
|
||||
html += `
|
||||
<div style="text-align: center; padding: 24px 16px; background-color: #f9fafb; border-top: 1px solid #e5e7eb; font-size: 14px; color: #6b7280;">
|
||||
${comp.companyName ? `<div style="font-weight: 600; color: #374151; margin-bottom: 8px;">${comp.companyName}</div>` : ''}
|
||||
${(comp.ceoName || comp.businessNumber) ? `
|
||||
<div style="margin-bottom: 4px;">
|
||||
${comp.ceoName ? `대표: ${comp.ceoName}` : ''}
|
||||
${comp.ceoName && comp.businessNumber ? ' | ' : ''}
|
||||
${comp.businessNumber ? `사업자등록번호: ${comp.businessNumber}` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
${comp.address ? `<div style="margin-bottom: 4px;">${comp.address}</div>` : ''}
|
||||
${(comp.phone || comp.email) ? `
|
||||
<div style="margin-bottom: 4px;">
|
||||
${comp.phone ? `Tel: ${comp.phone}` : ''}
|
||||
${comp.phone && comp.email ? ' | ' : ''}
|
||||
${comp.email ? `Email: ${comp.email}` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
${comp.copyright ? `<div style="margin-top: 12px; font-size: 12px; color: #9ca3af;">${comp.copyright}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
case "numberedList":
|
||||
html += `
|
||||
<div style="padding: 16px; ${styles}">
|
||||
${comp.listTitle ? `<div style="font-weight: 600; margin-bottom: 12px;">${comp.listTitle}</div>` : ''}
|
||||
<ol style="margin: 0; padding-left: 20px;">
|
||||
${(comp.listItems || []).map(item => `<li style="margin-bottom: 8px;">${item}</li>`).join('')}
|
||||
</ol>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -70,12 +70,13 @@ export class ScreenManagementService {
|
|||
throw new Error("이미 존재하는 화면 코드입니다.");
|
||||
}
|
||||
|
||||
// 화면 생성 (Raw Query)
|
||||
// 화면 생성 (Raw Query) - REST API 지원 추가
|
||||
const [screen] = await query<any>(
|
||||
`INSERT INTO screen_definitions (
|
||||
screen_name, screen_code, table_name, company_code, description, created_by,
|
||||
db_source_type, db_connection_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
db_source_type, db_connection_id, data_source_type, rest_api_connection_id,
|
||||
rest_api_endpoint, rest_api_json_path
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING *`,
|
||||
[
|
||||
screenData.screenName,
|
||||
|
|
@ -86,6 +87,10 @@ export class ScreenManagementService {
|
|||
screenData.createdBy,
|
||||
screenData.dbSourceType || "internal",
|
||||
screenData.dbConnectionId || null,
|
||||
(screenData as any).dataSourceType || "database",
|
||||
(screenData as any).restApiConnectionId || null,
|
||||
(screenData as any).restApiEndpoint || null,
|
||||
(screenData as any).restApiJsonPath || "data",
|
||||
]
|
||||
);
|
||||
|
||||
|
|
@ -1977,6 +1982,11 @@ export class ScreenManagementService {
|
|||
updatedBy: data.updated_by,
|
||||
dbSourceType: data.db_source_type || "internal",
|
||||
dbConnectionId: data.db_connection_id || undefined,
|
||||
// REST API 관련 필드
|
||||
dataSourceType: data.data_source_type || "database",
|
||||
restApiConnectionId: data.rest_api_connection_id || undefined,
|
||||
restApiEndpoint: data.rest_api_endpoint || undefined,
|
||||
restApiJsonPath: data.rest_api_json_path || "data",
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
export interface BatchExecutionLog {
|
||||
id?: number;
|
||||
batch_config_id: number;
|
||||
company_code?: string;
|
||||
execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED';
|
||||
start_time: Date;
|
||||
end_time?: Date | null;
|
||||
|
|
@ -19,6 +20,7 @@ export interface BatchExecutionLog {
|
|||
|
||||
export interface CreateBatchExecutionLogRequest {
|
||||
batch_config_id: number;
|
||||
company_code?: string;
|
||||
execution_status: 'RUNNING' | 'SUCCESS' | 'FAILED' | 'CANCELLED';
|
||||
start_time?: Date;
|
||||
end_time?: Date | null;
|
||||
|
|
|
|||
|
|
@ -1,86 +1,13 @@
|
|||
// 배치관리 타입 정의
|
||||
// 작성일: 2024-12-24
|
||||
import { ApiResponse, ColumnInfo } from './batchTypes';
|
||||
|
||||
// 배치 타입 정의
|
||||
export type BatchType = 'db-to-db' | 'db-to-restapi' | 'restapi-to-db' | 'restapi-to-restapi';
|
||||
|
||||
export interface BatchTypeOption {
|
||||
value: BatchType;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface BatchConfig {
|
||||
id?: number;
|
||||
batch_name: string;
|
||||
description?: string;
|
||||
cron_schedule: string;
|
||||
is_active?: string;
|
||||
company_code?: string;
|
||||
created_date?: Date;
|
||||
created_by?: string;
|
||||
updated_date?: Date;
|
||||
updated_by?: string;
|
||||
batch_mappings?: BatchMapping[];
|
||||
}
|
||||
|
||||
export interface BatchMapping {
|
||||
id?: number;
|
||||
batch_config_id?: number;
|
||||
|
||||
// FROM 정보
|
||||
from_connection_type: 'internal' | 'external' | 'restapi';
|
||||
from_connection_id?: number;
|
||||
from_table_name: string; // DB: 테이블명, REST API: 엔드포인트
|
||||
from_column_name: string; // DB: 컬럼명, REST API: JSON 필드명
|
||||
from_column_type?: string;
|
||||
from_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; // REST API 전용
|
||||
from_api_url?: string; // REST API 서버 URL
|
||||
from_api_key?: string; // REST API 키
|
||||
from_api_param_type?: 'url' | 'query'; // API 파라미터 타입
|
||||
from_api_param_name?: string; // API 파라미터명
|
||||
from_api_param_value?: string; // API 파라미터 값 또는 템플릿
|
||||
from_api_param_source?: 'static' | 'dynamic'; // 파라미터 소스 타입
|
||||
|
||||
// TO 정보
|
||||
to_connection_type: 'internal' | 'external' | 'restapi';
|
||||
to_connection_id?: number;
|
||||
to_table_name: string; // DB: 테이블명, REST API: 엔드포인트
|
||||
to_column_name: string; // DB: 컬럼명, REST API: JSON 필드명
|
||||
to_column_type?: string;
|
||||
to_api_method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; // REST API 전용
|
||||
to_api_url?: string; // REST API 서버 URL
|
||||
to_api_key?: string; // REST API 키
|
||||
to_api_body?: string; // Request Body 템플릿 (DB → REST API 배치용)
|
||||
|
||||
mapping_order?: number;
|
||||
created_date?: Date;
|
||||
created_by?: string;
|
||||
}
|
||||
|
||||
export interface BatchConfigFilter {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
batch_name?: string;
|
||||
is_active?: string;
|
||||
company_code?: string;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface ConnectionInfo {
|
||||
export interface BatchConnectionInfo {
|
||||
type: 'internal' | 'external';
|
||||
id?: number;
|
||||
name: string;
|
||||
db_type?: string;
|
||||
}
|
||||
|
||||
export interface TableInfo {
|
||||
table_name: string;
|
||||
columns: ColumnInfo[];
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface ColumnInfo {
|
||||
export interface BatchColumnInfo {
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable?: string;
|
||||
|
|
@ -100,6 +27,8 @@ export interface BatchMappingRequest {
|
|||
from_api_param_name?: string; // API 파라미터명
|
||||
from_api_param_value?: string; // API 파라미터 값 또는 템플릿
|
||||
from_api_param_source?: 'static' | 'dynamic'; // 파라미터 소스 타입
|
||||
// 👇 REST API Body 추가 (FROM - REST API에서 POST 요청 시 필요)
|
||||
from_api_body?: string;
|
||||
to_connection_type: 'internal' | 'external' | 'restapi';
|
||||
to_connection_id?: number;
|
||||
to_table_name: string;
|
||||
|
|
@ -116,6 +45,8 @@ export interface CreateBatchConfigRequest {
|
|||
batchName: string;
|
||||
description?: string;
|
||||
cronSchedule: string;
|
||||
isActive: 'Y' | 'N';
|
||||
companyCode: string;
|
||||
mappings: BatchMappingRequest[];
|
||||
}
|
||||
|
||||
|
|
@ -123,25 +54,11 @@ export interface UpdateBatchConfigRequest {
|
|||
batchName?: string;
|
||||
description?: string;
|
||||
cronSchedule?: string;
|
||||
isActive?: 'Y' | 'N';
|
||||
mappings?: BatchMappingRequest[];
|
||||
isActive?: string;
|
||||
}
|
||||
|
||||
export interface BatchValidationResult {
|
||||
isValid: boolean;
|
||||
errors: string[];
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
pagination?: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
// 외부 REST API 연결 관리 타입 정의
|
||||
|
||||
export type AuthType = "none" | "api-key" | "bearer" | "basic" | "oauth2";
|
||||
export type AuthType =
|
||||
| "none"
|
||||
| "api-key"
|
||||
| "bearer"
|
||||
| "basic"
|
||||
| "oauth2"
|
||||
| "db-token";
|
||||
|
||||
export interface ExternalRestApiConnection {
|
||||
id?: number;
|
||||
|
|
@ -9,6 +15,11 @@ export interface ExternalRestApiConnection {
|
|||
base_url: string;
|
||||
endpoint_path?: string;
|
||||
default_headers: Record<string, string>;
|
||||
|
||||
// 기본 메서드 및 바디 추가
|
||||
default_method?: string;
|
||||
default_body?: string;
|
||||
|
||||
auth_type: AuthType;
|
||||
auth_config?: {
|
||||
// API Key
|
||||
|
|
@ -28,6 +39,14 @@ export interface ExternalRestApiConnection {
|
|||
clientSecret?: string;
|
||||
tokenUrl?: string;
|
||||
accessToken?: string;
|
||||
|
||||
// DB 기반 토큰 모드
|
||||
dbTableName?: string;
|
||||
dbValueColumn?: string;
|
||||
dbWhereColumn?: string;
|
||||
dbWhereValue?: string;
|
||||
dbHeaderName?: string;
|
||||
dbHeaderTemplate?: string;
|
||||
};
|
||||
timeout?: number;
|
||||
retry_count?: number;
|
||||
|
|
@ -54,8 +73,9 @@ export interface RestApiTestRequest {
|
|||
id?: number;
|
||||
base_url: string;
|
||||
endpoint?: string;
|
||||
method?: "GET" | "POST" | "PUT" | "DELETE";
|
||||
method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
||||
headers?: Record<string, string>;
|
||||
body?: any; // 테스트 요청 바디 추가
|
||||
auth_type?: AuthType;
|
||||
auth_config?: any;
|
||||
timeout?: number;
|
||||
|
|
@ -76,4 +96,5 @@ export const AUTH_TYPE_OPTIONS = [
|
|||
{ value: "bearer", label: "Bearer Token" },
|
||||
{ value: "basic", label: "Basic Auth" },
|
||||
{ value: "oauth2", label: "OAuth 2.0" },
|
||||
{ value: "db-token", label: "DB 토큰" },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -154,6 +154,11 @@ export interface ScreenDefinition {
|
|||
updatedBy?: string;
|
||||
dbSourceType?: "internal" | "external";
|
||||
dbConnectionId?: number;
|
||||
// REST API 관련 필드
|
||||
dataSourceType?: "database" | "restapi";
|
||||
restApiConnectionId?: number;
|
||||
restApiEndpoint?: string;
|
||||
restApiJsonPath?: string;
|
||||
}
|
||||
|
||||
// 화면 생성 요청
|
||||
|
|
@ -166,6 +171,11 @@ export interface CreateScreenRequest {
|
|||
createdBy?: string;
|
||||
dbSourceType?: "internal" | "external";
|
||||
dbConnectionId?: number;
|
||||
// REST API 관련 필드
|
||||
dataSourceType?: "database" | "restapi";
|
||||
restApiConnectionId?: number;
|
||||
restApiEndpoint?: string;
|
||||
restApiJsonPath?: string;
|
||||
}
|
||||
|
||||
// 화면 수정 요청
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect, useMemo, memo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -33,6 +33,31 @@ interface BatchColumnInfo {
|
|||
is_nullable: string;
|
||||
}
|
||||
|
||||
interface RestApiToDbMappingCardProps {
|
||||
fromApiFields: string[];
|
||||
toColumns: BatchColumnInfo[];
|
||||
fromApiData: any[];
|
||||
apiFieldMappings: Record<string, string>;
|
||||
setApiFieldMappings: React.Dispatch<
|
||||
React.SetStateAction<Record<string, string>>
|
||||
>;
|
||||
apiFieldPathOverrides: Record<string, string>;
|
||||
setApiFieldPathOverrides: React.Dispatch<
|
||||
React.SetStateAction<Record<string, string>>
|
||||
>;
|
||||
}
|
||||
|
||||
interface DbToRestApiMappingCardProps {
|
||||
fromColumns: BatchColumnInfo[];
|
||||
selectedColumns: string[];
|
||||
toApiFields: string[];
|
||||
dbToApiFieldMapping: Record<string, string>;
|
||||
setDbToApiFieldMapping: React.Dispatch<
|
||||
React.SetStateAction<Record<string, string>>
|
||||
>;
|
||||
setToApiBody: (body: string) => void;
|
||||
}
|
||||
|
||||
export default function BatchManagementNewPage() {
|
||||
const router = useRouter();
|
||||
|
||||
|
|
@ -52,7 +77,8 @@ export default function BatchManagementNewPage() {
|
|||
const [fromApiUrl, setFromApiUrl] = useState("");
|
||||
const [fromApiKey, setFromApiKey] = useState("");
|
||||
const [fromEndpoint, setFromEndpoint] = useState("");
|
||||
const [fromApiMethod, setFromApiMethod] = useState<'GET'>('GET'); // GET만 지원
|
||||
const [fromApiMethod, setFromApiMethod] = useState<'GET' | 'POST' | 'PUT' | 'DELETE'>('GET');
|
||||
const [fromApiBody, setFromApiBody] = useState(""); // Request Body (JSON)
|
||||
|
||||
// REST API 파라미터 설정
|
||||
const [apiParamType, setApiParamType] = useState<'none' | 'url' | 'query'>('none');
|
||||
|
|
@ -83,6 +109,8 @@ export default function BatchManagementNewPage() {
|
|||
|
||||
// API 필드 → DB 컬럼 매핑
|
||||
const [apiFieldMappings, setApiFieldMappings] = useState<Record<string, string>>({});
|
||||
// API 필드별 JSON 경로 오버라이드 (예: "response.access_token")
|
||||
const [apiFieldPathOverrides, setApiFieldPathOverrides] = useState<Record<string, string>>({});
|
||||
|
||||
// 배치 타입 상태
|
||||
const [batchType, setBatchType] = useState<BatchType>('restapi-to-db');
|
||||
|
|
@ -182,24 +210,17 @@ export default function BatchManagementNewPage() {
|
|||
|
||||
// TO 테이블 변경 핸들러
|
||||
const handleToTableChange = async (tableName: string) => {
|
||||
console.log("🔍 테이블 변경:", { tableName, toConnection });
|
||||
setToTable(tableName);
|
||||
setToColumns([]);
|
||||
|
||||
if (toConnection && tableName) {
|
||||
try {
|
||||
const connectionType = toConnection.type === 'internal' ? 'internal' : 'external';
|
||||
console.log("🔍 컬럼 조회 시작:", { connectionType, connectionId: toConnection.id, tableName });
|
||||
|
||||
const result = await BatchManagementAPI.getTableColumns(connectionType, tableName, toConnection.id);
|
||||
console.log("🔍 컬럼 조회 결과:", result);
|
||||
|
||||
if (result && result.length > 0) {
|
||||
setToColumns(result);
|
||||
console.log("✅ 컬럼 설정 완료:", result.length, "개");
|
||||
} else {
|
||||
setToColumns([]);
|
||||
console.log("⚠️ 컬럼이 없음");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 컬럼 목록 로드 오류:", error);
|
||||
|
|
@ -239,7 +260,6 @@ export default function BatchManagementNewPage() {
|
|||
|
||||
// FROM 테이블 변경 핸들러 (DB → REST API용)
|
||||
const handleFromTableChange = async (tableName: string) => {
|
||||
console.log("🔍 FROM 테이블 변경:", { tableName, fromConnection });
|
||||
setFromTable(tableName);
|
||||
setFromColumns([]);
|
||||
setSelectedColumns([]); // 선택된 컬럼도 초기화
|
||||
|
|
@ -248,17 +268,11 @@ export default function BatchManagementNewPage() {
|
|||
if (fromConnection && tableName) {
|
||||
try {
|
||||
const connectionType = fromConnection.type === 'internal' ? 'internal' : 'external';
|
||||
console.log("🔍 FROM 컬럼 조회 시작:", { connectionType, connectionId: fromConnection.id, tableName });
|
||||
|
||||
const result = await BatchManagementAPI.getTableColumns(connectionType, tableName, fromConnection.id);
|
||||
console.log("🔍 FROM 컬럼 조회 결과:", result);
|
||||
|
||||
if (result && result.length > 0) {
|
||||
setFromColumns(result);
|
||||
console.log("✅ FROM 컬럼 설정 완료:", result.length, "개");
|
||||
} else {
|
||||
setFromColumns([]);
|
||||
console.log("⚠️ FROM 컬럼이 없음");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ FROM 컬럼 목록 로드 오류:", error);
|
||||
|
|
@ -276,8 +290,6 @@ export default function BatchManagementNewPage() {
|
|||
}
|
||||
|
||||
try {
|
||||
console.log("🔍 TO API 미리보기 시작:", { toApiUrl, toApiKey, toEndpoint, toApiMethod });
|
||||
|
||||
const result = await BatchManagementAPI.previewRestApiData(
|
||||
toApiUrl,
|
||||
toApiKey,
|
||||
|
|
@ -285,8 +297,6 @@ export default function BatchManagementNewPage() {
|
|||
'GET' // 미리보기는 항상 GET으로
|
||||
);
|
||||
|
||||
console.log("🔍 TO API 미리보기 결과:", result);
|
||||
|
||||
if (result.fields && result.fields.length > 0) {
|
||||
setToApiFields(result.fields);
|
||||
toast.success(`TO API 필드 ${result.fields.length}개를 조회했습니다.`);
|
||||
|
|
@ -303,17 +313,22 @@ export default function BatchManagementNewPage() {
|
|||
|
||||
// REST API 데이터 미리보기
|
||||
const previewRestApiData = async () => {
|
||||
if (!fromApiUrl || !fromApiKey || !fromEndpoint) {
|
||||
toast.error("API URL, API Key, 엔드포인트를 모두 입력해주세요.");
|
||||
// API URL, 엔드포인트는 항상 필수
|
||||
if (!fromApiUrl || !fromEndpoint) {
|
||||
toast.error("API URL과 엔드포인트를 모두 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// GET 메서드일 때만 API 키 필수
|
||||
if (fromApiMethod === "GET" && !fromApiKey) {
|
||||
toast.error("GET 메서드에서는 API 키를 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("REST API 데이터 미리보기 시작...");
|
||||
|
||||
const result = await BatchManagementAPI.previewRestApiData(
|
||||
fromApiUrl,
|
||||
fromApiKey,
|
||||
fromApiKey || "",
|
||||
fromEndpoint,
|
||||
fromApiMethod,
|
||||
// 파라미터 정보 추가
|
||||
|
|
@ -322,33 +337,23 @@ export default function BatchManagementNewPage() {
|
|||
paramName: apiParamName,
|
||||
paramValue: apiParamValue,
|
||||
paramSource: apiParamSource
|
||||
} : undefined
|
||||
} : undefined,
|
||||
// Request Body 추가 (POST/PUT/DELETE)
|
||||
(fromApiMethod === 'POST' || fromApiMethod === 'PUT' || fromApiMethod === 'DELETE') ? fromApiBody : undefined
|
||||
);
|
||||
|
||||
console.log("API 미리보기 결과:", result);
|
||||
console.log("result.fields:", result.fields);
|
||||
console.log("result.samples:", result.samples);
|
||||
console.log("result.totalCount:", result.totalCount);
|
||||
|
||||
if (result.fields && result.fields.length > 0) {
|
||||
console.log("✅ 백엔드에서 fields 제공됨:", result.fields);
|
||||
setFromApiFields(result.fields);
|
||||
setFromApiData(result.samples);
|
||||
|
||||
console.log("추출된 필드:", result.fields);
|
||||
toast.success(`API 데이터 미리보기 완료! ${result.fields.length}개 필드, ${result.totalCount}개 레코드`);
|
||||
} else if (result.samples && result.samples.length > 0) {
|
||||
// 백엔드에서 fields를 제대로 보내지 않은 경우, 프론트엔드에서 직접 추출
|
||||
console.log("⚠️ 백엔드에서 fields가 없어서 프론트엔드에서 추출");
|
||||
const extractedFields = Object.keys(result.samples[0]);
|
||||
console.log("프론트엔드에서 추출한 필드:", extractedFields);
|
||||
|
||||
setFromApiFields(extractedFields);
|
||||
setFromApiData(result.samples);
|
||||
|
||||
toast.success(`API 데이터 미리보기 완료! ${extractedFields.length}개 필드, ${result.samples.length}개 레코드`);
|
||||
} else {
|
||||
console.log("❌ 데이터가 없음");
|
||||
setFromApiFields([]);
|
||||
setFromApiData([]);
|
||||
toast.warning("API에서 데이터를 가져올 수 없습니다.");
|
||||
|
|
@ -370,38 +375,53 @@ export default function BatchManagementNewPage() {
|
|||
|
||||
// 배치 타입별 검증 및 저장
|
||||
if (batchType === 'restapi-to-db') {
|
||||
const mappedFields = Object.keys(apiFieldMappings).filter(field => apiFieldMappings[field]);
|
||||
const mappedFields = Object.keys(apiFieldMappings).filter(
|
||||
(field) => apiFieldMappings[field]
|
||||
);
|
||||
if (mappedFields.length === 0) {
|
||||
toast.error("최소 하나의 API 필드를 DB 컬럼에 매핑해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// API 필드 매핑을 배치 매핑 형태로 변환
|
||||
const apiMappings = mappedFields.map(apiField => ({
|
||||
from_connection_type: 'restapi' as const,
|
||||
from_table_name: fromEndpoint, // API 엔드포인트
|
||||
from_column_name: apiField, // API 필드명
|
||||
from_api_url: fromApiUrl,
|
||||
from_api_key: fromApiKey,
|
||||
from_api_method: fromApiMethod,
|
||||
// API 파라미터 정보 추가
|
||||
from_api_param_type: apiParamType !== 'none' ? apiParamType : undefined,
|
||||
from_api_param_name: apiParamType !== 'none' ? apiParamName : undefined,
|
||||
from_api_param_value: apiParamType !== 'none' ? apiParamValue : undefined,
|
||||
from_api_param_source: apiParamType !== 'none' ? apiParamSource : undefined,
|
||||
to_connection_type: toConnection?.type === 'internal' ? 'internal' : 'external',
|
||||
to_connection_id: toConnection?.type === 'internal' ? undefined : toConnection?.id,
|
||||
to_table_name: toTable,
|
||||
to_column_name: apiFieldMappings[apiField], // 매핑된 DB 컬럼
|
||||
mapping_type: 'direct' as const
|
||||
}));
|
||||
const apiMappings = mappedFields.map((apiField) => {
|
||||
const toColumnName = apiFieldMappings[apiField]; // 매핑된 DB 컬럼 (예: access_token)
|
||||
|
||||
console.log("REST API 배치 설정 저장:", {
|
||||
batchName,
|
||||
batchType,
|
||||
cronSchedule,
|
||||
description,
|
||||
apiMappings
|
||||
// 기본은 상위 필드 그대로 사용하되,
|
||||
// 사용자가 JSON 경로를 직접 입력한 경우 해당 경로를 우선 사용
|
||||
let fromColumnName = apiField;
|
||||
const overridePath = apiFieldPathOverrides[apiField];
|
||||
if (overridePath && overridePath.trim().length > 0) {
|
||||
fromColumnName = overridePath.trim();
|
||||
}
|
||||
|
||||
return {
|
||||
from_connection_type: "restapi" as const,
|
||||
from_table_name: fromEndpoint, // API 엔드포인트
|
||||
from_column_name: fromColumnName, // API 필드명 또는 중첩 경로
|
||||
from_api_url: fromApiUrl,
|
||||
from_api_key: fromApiKey,
|
||||
from_api_method: fromApiMethod,
|
||||
from_api_body:
|
||||
fromApiMethod === "POST" ||
|
||||
fromApiMethod === "PUT" ||
|
||||
fromApiMethod === "DELETE"
|
||||
? fromApiBody
|
||||
: undefined,
|
||||
// API 파라미터 정보 추가
|
||||
from_api_param_type: apiParamType !== "none" ? apiParamType : undefined,
|
||||
from_api_param_name: apiParamType !== "none" ? apiParamName : undefined,
|
||||
from_api_param_value: apiParamType !== "none" ? apiParamValue : undefined,
|
||||
from_api_param_source:
|
||||
apiParamType !== "none" ? apiParamSource : undefined,
|
||||
to_connection_type:
|
||||
toConnection?.type === "internal" ? "internal" : "external",
|
||||
to_connection_id:
|
||||
toConnection?.type === "internal" ? undefined : toConnection?.id,
|
||||
to_table_name: toTable,
|
||||
to_column_name: toColumnName, // 매핑된 DB 컬럼
|
||||
mapping_type: "direct" as const,
|
||||
};
|
||||
});
|
||||
|
||||
// 실제 API 호출
|
||||
|
|
@ -492,14 +512,6 @@ export default function BatchManagementNewPage() {
|
|||
}
|
||||
}
|
||||
|
||||
console.log("DB → REST API 배치 설정 저장:", {
|
||||
batchName,
|
||||
batchType,
|
||||
cronSchedule,
|
||||
description,
|
||||
dbMappings
|
||||
});
|
||||
|
||||
// 실제 API 호출 (기존 saveRestApiBatch 재사용)
|
||||
try {
|
||||
const result = await BatchManagementAPI.saveRestApiBatch({
|
||||
|
|
@ -645,13 +657,19 @@ export default function BatchManagementNewPage() {
|
|||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="fromApiKey">API 키 *</Label>
|
||||
<Label htmlFor="fromApiKey">
|
||||
API 키
|
||||
{fromApiMethod === "GET" && <span className="text-red-500 ml-0.5">*</span>}
|
||||
</Label>
|
||||
<Input
|
||||
id="fromApiKey"
|
||||
value={fromApiKey}
|
||||
onChange={(e) => setFromApiKey(e.target.value)}
|
||||
placeholder="ak_your_api_key_here"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
GET 메서드에서만 필수이며, POST/PUT/DELETE일 때는 선택 사항입니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -673,12 +691,33 @@ export default function BatchManagementNewPage() {
|
|||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="GET">GET (데이터 조회)</SelectItem>
|
||||
<SelectItem value="POST">POST (데이터 조회/전송)</SelectItem>
|
||||
<SelectItem value="PUT">PUT</SelectItem>
|
||||
<SelectItem value="DELETE">DELETE</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Request Body (POST/PUT/DELETE용) */}
|
||||
{(fromApiMethod === 'POST' || fromApiMethod === 'PUT' || fromApiMethod === 'DELETE') && (
|
||||
<div>
|
||||
<Label htmlFor="fromApiBody">Request Body (JSON)</Label>
|
||||
<Textarea
|
||||
id="fromApiBody"
|
||||
value={fromApiBody}
|
||||
onChange={(e) => setFromApiBody(e.target.value)}
|
||||
placeholder='{"username": "myuser", "token": "abc"}'
|
||||
className="min-h-[100px]"
|
||||
rows={5}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
API 호출 시 함께 전송할 JSON 데이터를 입력하세요.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API 파라미터 설정 */}
|
||||
<div className="space-y-4">
|
||||
<div className="border-t pt-4">
|
||||
|
|
@ -771,7 +810,10 @@ export default function BatchManagementNewPage() {
|
|||
)}
|
||||
</div>
|
||||
|
||||
{fromApiUrl && fromApiKey && fromEndpoint && (
|
||||
{/* API URL + 엔드포인트는 필수, GET일 때만 API 키 필수 */}
|
||||
{fromApiUrl &&
|
||||
fromEndpoint &&
|
||||
(fromApiMethod !== "GET" || fromApiKey) && (
|
||||
<div className="space-y-3">
|
||||
<div className="p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-sm font-medium text-gray-700">API 호출 미리보기</div>
|
||||
|
|
@ -786,7 +828,11 @@ export default function BatchManagementNewPage() {
|
|||
: ''
|
||||
}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">Headers: X-API-Key: {fromApiKey.substring(0, 10)}...</div>
|
||||
{fromApiKey && (
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Headers: X-API-Key: {fromApiKey.substring(0, 10)}...
|
||||
</div>
|
||||
)}
|
||||
{apiParamType !== 'none' && apiParamName && apiParamValue && (
|
||||
<div className="text-xs text-blue-600 mt-1">
|
||||
파라미터: {apiParamName} = {apiParamValue} ({apiParamSource === 'static' ? '고정값' : '동적값'})
|
||||
|
|
@ -980,172 +1026,33 @@ export default function BatchManagementNewPage() {
|
|||
|
||||
{/* 매핑 UI - 배치 타입별 동적 렌더링 */}
|
||||
{/* REST API → DB 매핑 */}
|
||||
{batchType === 'restapi-to-db' && fromApiFields.length > 0 && toColumns.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>API 필드 → DB 컬럼 매핑</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto border rounded-lg p-4">
|
||||
{fromApiFields.map((apiField) => (
|
||||
<div key={apiField} className="flex items-center space-x-4 p-3 bg-gray-50 rounded-lg">
|
||||
{/* API 필드 정보 */}
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{apiField}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{fromApiData.length > 0 && fromApiData[0][apiField] !== undefined
|
||||
? `예: ${String(fromApiData[0][apiField]).substring(0, 30)}${String(fromApiData[0][apiField]).length > 30 ? '...' : ''}`
|
||||
: 'API 필드'
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 화살표 */}
|
||||
<div className="text-gray-400">
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
{/* DB 컬럼 선택 */}
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
value={apiFieldMappings[apiField] || "none"}
|
||||
onValueChange={(value) => {
|
||||
setApiFieldMappings(prev => ({
|
||||
...prev,
|
||||
[apiField]: value === "none" ? "" : value
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="DB 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안함</SelectItem>
|
||||
{toColumns.map((column) => (
|
||||
<SelectItem key={column.column_name} value={column.column_name}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{column.column_name.toUpperCase()}</span>
|
||||
<span className="text-xs text-gray-500">{column.data_type}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{fromApiData.length > 0 && (
|
||||
<div className="mt-3 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-sm font-medium text-gray-700 mb-2">샘플 데이터 (최대 3개)</div>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{fromApiData.slice(0, 3).map((item, index) => (
|
||||
<div key={index} className="text-xs bg-white p-2 rounded border">
|
||||
<pre className="whitespace-pre-wrap">{JSON.stringify(item, null, 2)}</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{batchType === "restapi-to-db" &&
|
||||
fromApiFields.length > 0 &&
|
||||
toColumns.length > 0 && (
|
||||
<RestApiToDbMappingCard
|
||||
fromApiFields={fromApiFields}
|
||||
toColumns={toColumns}
|
||||
fromApiData={fromApiData}
|
||||
apiFieldMappings={apiFieldMappings}
|
||||
setApiFieldMappings={setApiFieldMappings}
|
||||
apiFieldPathOverrides={apiFieldPathOverrides}
|
||||
setApiFieldPathOverrides={setApiFieldPathOverrides}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* DB → REST API 매핑 */}
|
||||
{batchType === 'db-to-restapi' && selectedColumns.length > 0 && toApiFields.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>DB 컬럼 → API 필드 매핑</CardTitle>
|
||||
<CardDescription>
|
||||
DB 컬럼 값을 REST API Request Body에 매핑하세요. Request Body 템플릿에서 {`{{컬럼명}}`} 형태로 사용됩니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto border rounded-lg p-4">
|
||||
{fromColumns.filter(column => selectedColumns.includes(column.column_name)).map((column) => (
|
||||
<div key={column.column_name} className="flex items-center space-x-4 p-3 bg-gray-50 rounded-lg">
|
||||
{/* DB 컬럼 정보 */}
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{column.column_name}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
타입: {column.data_type} | NULL: {column.is_nullable ? 'Y' : 'N'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 화살표 */}
|
||||
<div className="text-gray-400">→</div>
|
||||
|
||||
{/* API 필드 선택 드롭다운 */}
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
value={dbToApiFieldMapping[column.column_name] || ''}
|
||||
onValueChange={(value) => {
|
||||
setDbToApiFieldMapping(prev => ({
|
||||
...prev,
|
||||
[column.column_name]: value === 'none' ? '' : value
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="API 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안함</SelectItem>
|
||||
{toApiFields.map((apiField) => (
|
||||
<SelectItem key={apiField} value={apiField}>
|
||||
{apiField}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="custom">직접 입력...</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 직접 입력 모드 */}
|
||||
{dbToApiFieldMapping[column.column_name] === 'custom' && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="API 필드명을 직접 입력하세요"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 mt-2"
|
||||
onChange={(e) => {
|
||||
setDbToApiFieldMapping(prev => ({
|
||||
...prev,
|
||||
[column.column_name]: e.target.value
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{dbToApiFieldMapping[column.column_name]
|
||||
? `매핑: ${column.column_name} → ${dbToApiFieldMapping[column.column_name]}`
|
||||
: `기본값: ${column.column_name} (DB 컬럼명 사용)`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 템플릿 미리보기 */}
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-mono bg-white p-2 rounded border">
|
||||
{`{{${column.column_name}}}`}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
실제 DB 값으로 치환됩니다
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="text-sm font-medium text-blue-800">매핑 사용 예시</div>
|
||||
<div className="text-xs text-blue-600 mt-1 font-mono">
|
||||
{`{"id": "{{id}}", "name": "{{user_name}}", "email": "{{email}}"}`}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
{batchType === "db-to-restapi" &&
|
||||
selectedColumns.length > 0 &&
|
||||
toApiFields.length > 0 && (
|
||||
<DbToRestApiMappingCard
|
||||
fromColumns={fromColumns}
|
||||
selectedColumns={selectedColumns}
|
||||
toApiFields={toApiFields}
|
||||
dbToApiFieldMapping={dbToApiFieldMapping}
|
||||
setDbToApiFieldMapping={setDbToApiFieldMapping}
|
||||
setToApiBody={setToApiBody}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* TO 설정 */}
|
||||
<Card>
|
||||
|
|
@ -1348,4 +1255,278 @@ export default function BatchManagementNewPage() {
|
|||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const RestApiToDbMappingCard = memo(function RestApiToDbMappingCard({
|
||||
fromApiFields,
|
||||
toColumns,
|
||||
fromApiData,
|
||||
apiFieldMappings,
|
||||
setApiFieldMappings,
|
||||
apiFieldPathOverrides,
|
||||
setApiFieldPathOverrides,
|
||||
}: RestApiToDbMappingCardProps) {
|
||||
// 샘플 JSON 문자열은 의존 데이터가 바뀔 때만 계산
|
||||
const sampleJsonList = useMemo(
|
||||
() =>
|
||||
fromApiData.slice(0, 3).map((item) => JSON.stringify(item, null, 2)),
|
||||
[fromApiData]
|
||||
);
|
||||
|
||||
const firstSample = fromApiData[0] || null;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>API 필드 → DB 컬럼 매핑</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto border rounded-lg p-4">
|
||||
{fromApiFields.map((apiField) => (
|
||||
<div
|
||||
key={apiField}
|
||||
className="flex items-center space-x-4 p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
{/* API 필드 정보 */}
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{apiField}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{firstSample && firstSample[apiField] !== undefined
|
||||
? `예: ${String(firstSample[apiField]).substring(0, 30)}${
|
||||
String(firstSample[apiField]).length > 30 ? "..." : ""
|
||||
}`
|
||||
: "API 필드"}
|
||||
</div>
|
||||
{/* JSON 경로 오버라이드 입력 */}
|
||||
<div className="mt-1.5">
|
||||
<Input
|
||||
value={apiFieldPathOverrides[apiField] || ""}
|
||||
onChange={(e) =>
|
||||
setApiFieldPathOverrides((prev) => ({
|
||||
...prev,
|
||||
[apiField]: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="JSON 경로 (예: response.access_token)"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<p className="text-[11px] text-gray-500 mt-0.5">
|
||||
비워두면 "{apiField}" 필드 전체를 사용하고, 입력하면 해당
|
||||
경로의 값을 사용합니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 화살표 */}
|
||||
<div className="text-gray-400">
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
{/* DB 컬럼 선택 */}
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
value={apiFieldMappings[apiField] || "none"}
|
||||
onValueChange={(value) => {
|
||||
setApiFieldMappings((prev) => ({
|
||||
...prev,
|
||||
[apiField]: value === "none" ? "" : value,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="DB 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안함</SelectItem>
|
||||
{toColumns.map((column) => (
|
||||
<SelectItem
|
||||
key={column.column_name}
|
||||
value={column.column_name}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">
|
||||
{column.column_name.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{column.data_type}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{sampleJsonList.length > 0 && (
|
||||
<div className="mt-3 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="text-sm font-medium text-gray-700 mb-2">
|
||||
샘플 데이터 (최대 3개)
|
||||
</div>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{sampleJsonList.map((json, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="text-xs bg-white p-2 rounded border"
|
||||
>
|
||||
<pre className="whitespace-pre-wrap">{json}</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
const DbToRestApiMappingCard = memo(function DbToRestApiMappingCard({
|
||||
fromColumns,
|
||||
selectedColumns,
|
||||
toApiFields,
|
||||
dbToApiFieldMapping,
|
||||
setDbToApiFieldMapping,
|
||||
setToApiBody,
|
||||
}: DbToRestApiMappingCardProps) {
|
||||
const selectedColumnObjects = useMemo(
|
||||
() =>
|
||||
fromColumns.filter((column) =>
|
||||
selectedColumns.includes(column.column_name)
|
||||
),
|
||||
[fromColumns, selectedColumns]
|
||||
);
|
||||
|
||||
const autoJsonPreview = useMemo(() => {
|
||||
if (selectedColumns.length === 0) {
|
||||
return "";
|
||||
}
|
||||
const obj = selectedColumns.reduce((acc, col) => {
|
||||
const apiField = dbToApiFieldMapping[col] || col;
|
||||
acc[apiField] = `{{${col}}}`;
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
return JSON.stringify(obj, null, 2);
|
||||
}, [selectedColumns, dbToApiFieldMapping]);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>DB 컬럼 → API 필드 매핑</CardTitle>
|
||||
<CardDescription>
|
||||
DB 컬럼 값을 REST API Request Body에 매핑하세요. Request Body
|
||||
템플릿에서 {`{{컬럼명}}`} 형태로 사용됩니다.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto border rounded-lg p-4">
|
||||
{selectedColumnObjects.map((column) => (
|
||||
<div
|
||||
key={column.column_name}
|
||||
className="flex items-center space-x-4 p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
{/* DB 컬럼 정보 */}
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-sm">{column.column_name}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
타입: {column.data_type} | NULL: {column.is_nullable ? "Y" : "N"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 화살표 */}
|
||||
<div className="text-gray-400">→</div>
|
||||
|
||||
{/* API 필드 선택 드롭다운 */}
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
value={dbToApiFieldMapping[column.column_name] || ""}
|
||||
onValueChange={(value) => {
|
||||
setDbToApiFieldMapping((prev) => ({
|
||||
...prev,
|
||||
[column.column_name]: value === "none" ? "" : value,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="API 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택 안함</SelectItem>
|
||||
{toApiFields.map((apiField) => (
|
||||
<SelectItem key={apiField} value={apiField}>
|
||||
{apiField}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="custom">직접 입력...</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 직접 입력 모드 */}
|
||||
{dbToApiFieldMapping[column.column_name] === "custom" && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="API 필드명을 직접 입력하세요"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 mt-2"
|
||||
onChange={(e) => {
|
||||
setDbToApiFieldMapping((prev) => ({
|
||||
...prev,
|
||||
[column.column_name]: e.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{dbToApiFieldMapping[column.column_name]
|
||||
? `매핑: ${column.column_name} → ${dbToApiFieldMapping[column.column_name]}`
|
||||
: `기본값: ${column.column_name} (DB 컬럼명 사용)`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 템플릿 미리보기 */}
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-mono bg-white p-2 rounded border">
|
||||
{`{{${column.column_name}}}`}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
실제 DB 값으로 치환됩니다
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{selectedColumns.length > 0 && (
|
||||
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="text-sm font-medium text-blue-800">
|
||||
자동 생성된 JSON 구조
|
||||
</div>
|
||||
<pre className="mt-1 text-xs text-blue-600 font-mono overflow-x-auto">
|
||||
{autoJsonPreview}
|
||||
</pre>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setToApiBody(autoJsonPreview);
|
||||
toast.success(
|
||||
"Request Body에 자동 생성된 JSON이 적용되었습니다."
|
||||
);
|
||||
}}
|
||||
className="mt-2 px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700"
|
||||
>
|
||||
Request Body에 적용
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<div className="text-sm font-medium text-blue-800">매핑 사용 예시</div>
|
||||
<div className="text-xs text-blue-600 mt-1 font-mono">
|
||||
{`{"id": "{{id}}", "name": "{{user_name}}", "email": "{{email}}"}`}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
|
@ -7,12 +7,24 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { RefreshCw, Save, ArrowLeft, Plus, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { BatchAPI, BatchConfig, BatchMapping, ConnectionInfo } from "@/lib/api/batch";
|
||||
import {
|
||||
BatchAPI,
|
||||
BatchConfig,
|
||||
BatchMapping,
|
||||
ConnectionInfo,
|
||||
} from "@/lib/api/batch";
|
||||
import { BatchManagementAPI } from "@/lib/api/batchManagement";
|
||||
|
||||
interface BatchColumnInfo {
|
||||
column_name: string;
|
||||
|
|
@ -66,6 +78,9 @@ export default function BatchEditPage() {
|
|||
// 배치 타입 감지
|
||||
const [batchType, setBatchType] = useState<'db-to-db' | 'restapi-to-db' | 'db-to-restapi' | null>(null);
|
||||
|
||||
// REST API 미리보기 상태
|
||||
const [apiPreviewData, setApiPreviewData] = useState<any[]>([]);
|
||||
|
||||
|
||||
// 페이지 로드 시 배치 정보 조회
|
||||
useEffect(() => {
|
||||
|
|
@ -335,6 +350,86 @@ export default function BatchEditPage() {
|
|||
setMappings([...mappings, newMapping]);
|
||||
};
|
||||
|
||||
// REST API → DB 매핑 추가
|
||||
const addRestapiToDbMapping = () => {
|
||||
if (!batchConfig || !batchConfig.batch_mappings || batchConfig.batch_mappings.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const first = batchConfig.batch_mappings[0] as any;
|
||||
|
||||
const newMapping: BatchMapping = {
|
||||
// FROM: REST API (기존 설정 그대로 복사)
|
||||
from_connection_type: "restapi" as any,
|
||||
from_connection_id: first.from_connection_id,
|
||||
from_table_name: first.from_table_name,
|
||||
from_column_name: "",
|
||||
from_column_type: "",
|
||||
// TO: DB (기존 설정 그대로 복사)
|
||||
to_connection_type: first.to_connection_type as any,
|
||||
to_connection_id: first.to_connection_id,
|
||||
to_table_name: first.to_table_name,
|
||||
to_column_name: "",
|
||||
to_column_type: "",
|
||||
mapping_type: (first.mapping_type as any) || "direct",
|
||||
mapping_order: mappings.length + 1,
|
||||
};
|
||||
|
||||
setMappings((prev) => [...prev, newMapping]);
|
||||
};
|
||||
|
||||
// REST API 데이터 미리보기 (수정 화면용)
|
||||
const previewRestApiData = async () => {
|
||||
if (!mappings || mappings.length === 0) {
|
||||
toast.error("미리보기할 REST API 매핑이 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const first: any = mappings[0];
|
||||
|
||||
if (!first.from_api_url || !first.from_table_name) {
|
||||
toast.error("API URL과 엔드포인트 정보가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const method =
|
||||
(first.from_api_method as "GET" | "POST" | "PUT" | "DELETE") || "GET";
|
||||
|
||||
const paramInfo =
|
||||
first.from_api_param_type &&
|
||||
first.from_api_param_name &&
|
||||
first.from_api_param_value
|
||||
? {
|
||||
paramType: first.from_api_param_type as "url" | "query",
|
||||
paramName: first.from_api_param_name as string,
|
||||
paramValue: first.from_api_param_value as string,
|
||||
paramSource:
|
||||
(first.from_api_param_source as "static" | "dynamic") ||
|
||||
"static",
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const result = await BatchManagementAPI.previewRestApiData(
|
||||
first.from_api_url,
|
||||
first.from_api_key || "",
|
||||
first.from_table_name,
|
||||
method,
|
||||
paramInfo,
|
||||
first.from_api_body || undefined
|
||||
);
|
||||
|
||||
setApiPreviewData(result.samples || []);
|
||||
|
||||
toast.success(
|
||||
`API 데이터 미리보기 완료! ${result.fields.length}개 필드, ${result.samples.length}개 레코드`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("REST API 미리보기 오류:", error);
|
||||
toast.error("API 데이터 미리보기에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// 매핑 삭제
|
||||
const removeMapping = (index: number) => {
|
||||
const updatedMappings = mappings.filter((_, i) => i !== index);
|
||||
|
|
@ -404,14 +499,16 @@ export default function BatchEditPage() {
|
|||
<h1 className="text-3xl font-bold">배치 설정 수정</h1>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button onClick={loadBatchConfig} variant="outline" disabled={loading}>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
<Button
|
||||
onClick={loadBatchConfig}
|
||||
variant="outline"
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`w-4 h-4 mr-2 ${loading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
새로고침
|
||||
</Button>
|
||||
<Button onClick={saveBatchConfig} disabled={loading}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -580,22 +677,91 @@ export default function BatchEditPage() {
|
|||
</div>
|
||||
|
||||
{mappings.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>API URL</Label>
|
||||
<Input value={mappings[0]?.from_api_url || ''} readOnly />
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>API URL</Label>
|
||||
<Input value={mappings[0]?.from_api_url || ""} readOnly />
|
||||
</div>
|
||||
<div>
|
||||
<Label>API 엔드포인트</Label>
|
||||
<Input
|
||||
value={mappings[0]?.from_table_name || ""}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>HTTP 메서드</Label>
|
||||
<Input
|
||||
value={mappings[0]?.from_api_method || "GET"}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>대상 테이블</Label>
|
||||
<Input
|
||||
value={mappings[0]?.to_table_name || ""}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Request Body (JSON) 편집 UI */}
|
||||
<div>
|
||||
<Label>API 엔드포인트</Label>
|
||||
<Input value={mappings[0]?.from_table_name || ''} readOnly />
|
||||
<Label>Request Body (JSON)</Label>
|
||||
<Textarea
|
||||
rows={5}
|
||||
className="font-mono text-sm"
|
||||
placeholder='{"id": "wace", "pwd": "wace!$%Pwdmo^^"}'
|
||||
value={mappings[0]?.from_api_body || ""}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setMappings((prev) => {
|
||||
if (prev.length === 0) return prev;
|
||||
const updated = [...prev];
|
||||
updated[0] = {
|
||||
...updated[0],
|
||||
from_api_body: value,
|
||||
} as any;
|
||||
return updated;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
토큰 발급 등 POST 요청에 사용할 JSON Request Body를 수정할 수 있습니다.
|
||||
배치가 실행될 때 이 내용이 그대로 전송됩니다.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label>HTTP 메서드</Label>
|
||||
<Input value={mappings[0]?.from_api_method || 'GET'} readOnly />
|
||||
</div>
|
||||
<div>
|
||||
<Label>대상 테이블</Label>
|
||||
<Input value={mappings[0]?.to_table_name || ''} readOnly />
|
||||
|
||||
{/* API 데이터 미리보기 */}
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={previewRestApiData}
|
||||
className="mt-2"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
API 데이터 미리보기
|
||||
</Button>
|
||||
|
||||
{apiPreviewData.length > 0 && (
|
||||
<div className="mt-2 rounded-lg border bg-muted p-3">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
샘플 데이터 (최대 3개)
|
||||
</p>
|
||||
<div className="mt-2 space-y-2 max-h-60 overflow-y-auto">
|
||||
{apiPreviewData.slice(0, 3).map((item, index) => (
|
||||
<pre
|
||||
key={index}
|
||||
className="whitespace-pre-wrap rounded border bg-background p-2 text-xs font-mono"
|
||||
>
|
||||
{JSON.stringify(item, null, 2)}
|
||||
</pre>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -647,6 +813,12 @@ export default function BatchEditPage() {
|
|||
매핑 추가
|
||||
</Button>
|
||||
)}
|
||||
{batchType === 'restapi-to-db' && (
|
||||
<Button onClick={addRestapiToDbMapping} size="sm">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
매핑 추가
|
||||
</Button>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -751,20 +923,73 @@ export default function BatchEditPage() {
|
|||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h4 className="font-medium">매핑 #{index + 1}</h4>
|
||||
<p className="text-sm text-gray-600">
|
||||
API 필드: {mapping.from_column_name} → DB 컬럼: {mapping.to_column_name}
|
||||
</p>
|
||||
{mapping.from_column_name && mapping.to_column_name && (
|
||||
<p className="text-sm text-gray-600">
|
||||
API 필드: {mapping.from_column_name} → DB 컬럼: {mapping.to_column_name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => removeMapping(index)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>API 필드명</Label>
|
||||
<Input value={mapping.from_column_name || ''} readOnly />
|
||||
<Label>API 필드명 (JSON 경로)</Label>
|
||||
<Input
|
||||
value={mapping.from_column_name || ""}
|
||||
onChange={(e) =>
|
||||
updateMapping(
|
||||
index,
|
||||
"from_column_name",
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
placeholder="response.access_token"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>DB 컬럼명</Label>
|
||||
<Input value={mapping.to_column_name || ''} readOnly />
|
||||
<Select
|
||||
value={mapping.to_column_name || ""}
|
||||
onValueChange={(value) => {
|
||||
updateMapping(index, "to_column_name", value);
|
||||
const selectedColumn = toColumns.find(
|
||||
(col) => col.column_name === value
|
||||
);
|
||||
if (selectedColumn) {
|
||||
updateMapping(
|
||||
index,
|
||||
"to_column_type",
|
||||
selectedColumn.data_type
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="대상 컬럼 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{toColumns.map((column) => (
|
||||
<SelectItem
|
||||
key={column.column_name}
|
||||
value={column.column_name}
|
||||
>
|
||||
{column.column_name} ({column.data_type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{toColumns.length === 0 && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
대상 테이블을 선택하면 컬럼 목록이 표시됩니다.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import {
|
|||
saveDraft,
|
||||
updateDraft,
|
||||
} from "@/lib/api/mail";
|
||||
import { API_BASE_URL } from "@/lib/api/client";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
export default function MailSendPage() {
|
||||
|
|
@ -498,7 +499,7 @@ ${data.originalBody}`;
|
|||
throw new Error("인증 토큰이 없습니다. 다시 로그인해주세요.");
|
||||
}
|
||||
|
||||
const response = await fetch("/api/mail/send/simple", {
|
||||
const response = await fetch(`${API_BASE_URL}/mail/send/simple`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
|
|
@ -1226,6 +1227,91 @@ ${data.originalBody}`;
|
|||
여백
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'header':
|
||||
return (
|
||||
<div key={component.id} className="p-4 rounded-lg" style={{ backgroundColor: component.headerBgColor || '#f8f9fa' }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{component.logoSrc && <img src={component.logoSrc} alt="로고" className="h-10" />}
|
||||
<span className="font-bold text-lg">{component.brandName}</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">{component.sendDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'infoTable':
|
||||
return (
|
||||
<div key={component.id} className="border rounded-lg overflow-hidden">
|
||||
{component.tableTitle && (
|
||||
<div className="bg-gray-50 px-4 py-2 font-semibold border-b">{component.tableTitle}</div>
|
||||
)}
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{component.rows?.map((row: any, i: number) => (
|
||||
<tr key={i} className={i % 2 === 0 ? 'bg-white' : 'bg-gray-50'}>
|
||||
<td className="px-4 py-2 font-medium text-gray-600 w-1/3 border-r">{row.label}</td>
|
||||
<td className="px-4 py-2">{row.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'alertBox':
|
||||
return (
|
||||
<div key={component.id} className={`p-4 rounded-lg border-l-4 ${
|
||||
component.alertType === 'info' ? 'bg-blue-50 border-blue-500 text-blue-800' :
|
||||
component.alertType === 'warning' ? 'bg-amber-50 border-amber-500 text-amber-800' :
|
||||
component.alertType === 'danger' ? 'bg-red-50 border-red-500 text-red-800' :
|
||||
'bg-emerald-50 border-emerald-500 text-emerald-800'
|
||||
}`}>
|
||||
{component.alertTitle && <div className="font-bold mb-1">{component.alertTitle}</div>}
|
||||
<div>{component.content}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'divider':
|
||||
return (
|
||||
<hr key={component.id} className="border-gray-300" style={{ borderWidth: `${component.height || 1}px` }} />
|
||||
);
|
||||
|
||||
case 'footer':
|
||||
return (
|
||||
<div key={component.id} className="text-center text-sm text-gray-500 py-4 border-t bg-gray-50">
|
||||
{component.companyName && <div className="font-semibold text-gray-700">{component.companyName}</div>}
|
||||
{(component.ceoName || component.businessNumber) && (
|
||||
<div className="mt-1">
|
||||
{component.ceoName && <span>대표: {component.ceoName}</span>}
|
||||
{component.ceoName && component.businessNumber && <span className="mx-2">|</span>}
|
||||
{component.businessNumber && <span>사업자등록번호: {component.businessNumber}</span>}
|
||||
</div>
|
||||
)}
|
||||
{component.address && <div className="mt-1">{component.address}</div>}
|
||||
{(component.phone || component.email) && (
|
||||
<div className="mt-1">
|
||||
{component.phone && <span>Tel: {component.phone}</span>}
|
||||
{component.phone && component.email && <span className="mx-2">|</span>}
|
||||
{component.email && <span>Email: {component.email}</span>}
|
||||
</div>
|
||||
)}
|
||||
{component.copyright && <div className="mt-2 text-xs text-gray-400">{component.copyright}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'numberedList':
|
||||
return (
|
||||
<div key={component.id} className="p-4">
|
||||
{component.listTitle && <div className="font-semibold mb-2">{component.listTitle}</div>}
|
||||
<ol className="list-decimal list-inside space-y-1">
|
||||
{component.listItems?.map((item: string, i: number) => (
|
||||
<li key={i}>{item}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -1139,14 +1139,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>
|
||||
|
||||
{/* 컬럼 리스트 */}
|
||||
|
|
@ -1163,12 +1160,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)}
|
||||
|
|
@ -1176,107 +1174,106 @@ export default function TableManagementPage() {
|
|||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-48 pr-6">
|
||||
<Select
|
||||
value={column.inputType || "text"}
|
||||
onValueChange={(value) => handleInputTypeChange(column.columnName, value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="입력 타입 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{memoizedInputTypeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex-1 pl-6" style={{ maxWidth: "calc(100% - 808px)" }}>
|
||||
{/* 입력 타입이 'code'인 경우 공통코드 선택 */}
|
||||
{column.inputType === "code" && (
|
||||
<div className="pr-6">
|
||||
<div className="space-y-3">
|
||||
{/* 입력 타입 선택 */}
|
||||
<Select
|
||||
value={column.codeCategory || "none"}
|
||||
onValueChange={(value) =>
|
||||
handleDetailSettingsChange(column.columnName, "code", value)
|
||||
}
|
||||
value={column.inputType || "text"}
|
||||
onValueChange={(value) => handleInputTypeChange(column.columnName, value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="공통코드 선택" />
|
||||
<SelectValue placeholder="입력 타입 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{commonCodeOptions.map((option, index) => (
|
||||
<SelectItem key={`code-${option.value}-${index}`} value={option.value}>
|
||||
{memoizedInputTypeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{/* 입력 타입이 'category'인 경우 2레벨 메뉴 다중 선택 */}
|
||||
{column.inputType === "category" && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-muted-foreground mb-1 block text-xs">
|
||||
적용할 메뉴 (2레벨)
|
||||
</label>
|
||||
<div className="border rounded-lg p-3 space-y-2 max-h-48 overflow-y-auto">
|
||||
{secondLevelMenus.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
2레벨 메뉴가 없습니다. 메뉴를 선택하지 않으면 모든 메뉴에서 사용 가능합니다.
|
||||
</p>
|
||||
) : (
|
||||
secondLevelMenus.map((menu) => {
|
||||
// menuObjid를 숫자로 변환하여 비교
|
||||
const menuObjidNum = Number(menu.menuObjid);
|
||||
const isChecked = (column.categoryMenus || []).includes(menuObjidNum);
|
||||
|
||||
return (
|
||||
<div key={menu.menuObjid} className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`category-menu-${column.columnName}-${menu.menuObjid}`}
|
||||
checked={isChecked}
|
||||
onChange={(e) => {
|
||||
const currentMenus = column.categoryMenus || [];
|
||||
const newMenus = e.target.checked
|
||||
? [...currentMenus, menuObjidNum]
|
||||
: currentMenus.filter((id) => id !== menuObjidNum);
|
||||
{/* 입력 타입이 'code'인 경우 공통코드 선택 */}
|
||||
{column.inputType === "code" && (
|
||||
<Select
|
||||
value={column.codeCategory || "none"}
|
||||
onValueChange={(value) =>
|
||||
handleDetailSettingsChange(column.columnName, "code", value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="공통코드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{commonCodeOptions.map((option, index) => (
|
||||
<SelectItem key={`code-${option.value}-${index}`} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{/* 입력 타입이 'category'인 경우 2레벨 메뉴 다중 선택 */}
|
||||
{column.inputType === "category" && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-muted-foreground mb-1 block text-xs">
|
||||
적용할 메뉴 (2레벨)
|
||||
</label>
|
||||
<div className="border rounded-lg p-3 space-y-2 max-h-48 overflow-y-auto">
|
||||
{secondLevelMenus.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
2레벨 메뉴가 없습니다. 메뉴를 선택하지 않으면 모든 메뉴에서 사용 가능합니다.
|
||||
</p>
|
||||
) : (
|
||||
secondLevelMenus.map((menu) => {
|
||||
// menuObjid를 숫자로 변환하여 비교
|
||||
const menuObjidNum = Number(menu.menuObjid);
|
||||
const isChecked = (column.categoryMenus || []).includes(menuObjidNum);
|
||||
|
||||
return (
|
||||
<div key={menu.menuObjid} className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`category-menu-${column.columnName}-${menu.menuObjid}`}
|
||||
checked={isChecked}
|
||||
onChange={(e) => {
|
||||
const currentMenus = column.categoryMenus || [];
|
||||
const newMenus = e.target.checked
|
||||
? [...currentMenus, menuObjidNum]
|
||||
: currentMenus.filter((id) => id !== menuObjidNum);
|
||||
|
||||
setColumns((prev) =>
|
||||
prev.map((col) =>
|
||||
col.columnName === column.columnName
|
||||
? { ...col, categoryMenus: newMenus }
|
||||
: col
|
||||
)
|
||||
);
|
||||
}}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<label
|
||||
htmlFor={`category-menu-${column.columnName}-${menu.menuObjid}`}
|
||||
className="text-xs cursor-pointer flex-1"
|
||||
>
|
||||
{menu.parentMenuName} → {menu.menuName}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
setColumns((prev) =>
|
||||
prev.map((col) =>
|
||||
col.columnName === column.columnName
|
||||
? { ...col, categoryMenus: newMenus }
|
||||
: col
|
||||
)
|
||||
);
|
||||
}}
|
||||
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
<label
|
||||
htmlFor={`category-menu-${column.columnName}-${menu.menuObjid}`}
|
||||
className="text-xs cursor-pointer flex-1"
|
||||
>
|
||||
{menu.parentMenuName} → {menu.menuName}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{column.categoryMenus && column.categoryMenus.length > 0 && (
|
||||
<p className="text-primary text-xs">
|
||||
{column.categoryMenus.length}개 메뉴 선택됨
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{column.categoryMenus && column.categoryMenus.length > 0 && (
|
||||
<p className="text-primary text-xs">
|
||||
{column.categoryMenus.length}개 메뉴 선택됨
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
|
||||
{column.inputType === "entity" && (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
)}
|
||||
{/* 입력 타입이 'entity'인 경우 참조 테이블 선택 */}
|
||||
{column.inputType === "entity" && (
|
||||
<>
|
||||
{/* 참조 테이블 */}
|
||||
<div>
|
||||
<div className="w-48">
|
||||
<label className="text-muted-foreground mb-1 block text-xs">
|
||||
참조 테이블
|
||||
</label>
|
||||
|
|
@ -1286,7 +1283,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>
|
||||
|
|
@ -1309,7 +1306,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>
|
||||
|
|
@ -1323,7 +1320,7 @@ export default function TableManagementPage() {
|
|||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="bg-background h-8 text-xs">
|
||||
<SelectTrigger className="bg-background h-8 w-full text-xs">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -1355,7 +1352,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>
|
||||
|
|
@ -1369,7 +1366,7 @@ export default function TableManagementPage() {
|
|||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="bg-background h-8 text-xs">
|
||||
<SelectTrigger className="bg-background h-8 w-full text-xs">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
|
@ -1395,37 +1392,29 @@ export default function TableManagementPage() {
|
|||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 설정 완료 표시 */}
|
||||
{column.referenceTable &&
|
||||
column.referenceTable !== "none" &&
|
||||
column.referenceColumn &&
|
||||
column.referenceColumn !== "none" &&
|
||||
column.displayColumn &&
|
||||
column.displayColumn !== "none" && (
|
||||
<div className="bg-primary/10 text-primary mt-2 flex items-center gap-1 rounded px-2 py-1 text-xs">
|
||||
<span>✓</span>
|
||||
<span className="truncate">
|
||||
{column.columnName} → {column.referenceTable}.{column.displayColumn}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* 다른 입력 타입인 경우 빈 공간 */}
|
||||
{column.inputType !== "code" && column.inputType !== "category" && column.inputType !== "entity" && (
|
||||
<div className="text-muted-foreground flex h-8 items-center justify-center text-xs">
|
||||
-
|
||||
</div>
|
||||
)}
|
||||
{/* 설정 완료 표시 */}
|
||||
{column.referenceTable &&
|
||||
column.referenceTable !== "none" &&
|
||||
column.referenceColumn &&
|
||||
column.referenceColumn !== "none" &&
|
||||
column.displayColumn &&
|
||||
column.displayColumn !== "none" && (
|
||||
<div className="bg-primary/10 text-primary flex items-center gap-1 rounded px-2 py-1 text-xs w-48">
|
||||
<span>✓</span>
|
||||
<span className="truncate">설정 완료</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-80 pl-4">
|
||||
<div className="pl-4">
|
||||
<Input
|
||||
value={column.description || ""}
|
||||
onChange={(e) => handleColumnChange(index, "description", e.target.value)}
|
||||
placeholder="설명"
|
||||
className="h-8 text-xs"
|
||||
className="h-8 w-full text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1616,3 +1605,4 @@ export default function TableManagementPage() {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -240,17 +240,17 @@ function ScreenViewPage() {
|
|||
// 가로 기준 스케일 계산 (좌우 여백 16px씩 고정)
|
||||
const newScale = availableWidth / designWidth;
|
||||
|
||||
console.log("📐 스케일 계산:", {
|
||||
containerWidth,
|
||||
containerHeight,
|
||||
MARGIN_X,
|
||||
availableWidth,
|
||||
designWidth,
|
||||
designHeight,
|
||||
finalScale: newScale,
|
||||
"스케일된 화면 크기": `${designWidth * newScale}px × ${designHeight * newScale}px`,
|
||||
"실제 좌우 여백": `${(containerWidth - designWidth * newScale) / 2}px씩`,
|
||||
});
|
||||
// console.log("📐 스케일 계산:", {
|
||||
// containerWidth,
|
||||
// containerHeight,
|
||||
// MARGIN_X,
|
||||
// availableWidth,
|
||||
// designWidth,
|
||||
// designHeight,
|
||||
// finalScale: newScale,
|
||||
// "스케일된 화면 크기": `${designWidth * newScale}px × ${designHeight * newScale}px`,
|
||||
// "실제 좌우 여백": `${(containerWidth - designWidth * newScale) / 2}px씩`,
|
||||
// });
|
||||
|
||||
setScale(newScale);
|
||||
// 컨테이너 너비 업데이트
|
||||
|
|
|
|||
|
|
@ -0,0 +1,423 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
import { BatchAPI, BatchJob, BatchConfig } from "@/lib/api/batch";
|
||||
import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection";
|
||||
|
||||
// BatchJobModal에서 사용하던 config_json 구조 확장
|
||||
interface RestApiConfigJson {
|
||||
sourceConnectionId?: number;
|
||||
targetConnectionId?: number;
|
||||
targetTable?: string;
|
||||
// REST API 관련 설정
|
||||
apiUrl?: string;
|
||||
apiKey?: string;
|
||||
endpoint?: string;
|
||||
httpMethod?: string;
|
||||
apiBody?: string; // POST 요청용 Body
|
||||
// 매핑 정보 등
|
||||
mappings?: any[];
|
||||
}
|
||||
|
||||
interface AdvancedBatchModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
job?: BatchJob | null;
|
||||
initialType?: "rest_to_db" | "db_to_rest"; // 초기 진입 시 타입 지정
|
||||
}
|
||||
|
||||
export default function AdvancedBatchModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
job,
|
||||
initialType = "rest_to_db",
|
||||
}: AdvancedBatchModalProps) {
|
||||
// 기본 BatchJob 정보 관리
|
||||
const [formData, setFormData] = useState<Partial<BatchJob>>({
|
||||
job_name: "",
|
||||
description: "",
|
||||
job_type: initialType === "rest_to_db" ? "rest_to_db" : "db_to_rest",
|
||||
schedule_cron: "",
|
||||
is_active: "Y",
|
||||
config_json: {},
|
||||
});
|
||||
|
||||
// 상세 설정 (config_json 내부 값) 관리
|
||||
const [configData, setConfigData] = useState<RestApiConfigJson>({
|
||||
httpMethod: "GET", // 기본값
|
||||
apiBody: "",
|
||||
});
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [connections, setConnections] = useState<any[]>([]); // 내부/외부 DB 연결 목록
|
||||
const [targetTables, setTargetTables] = useState<string[]>([]); // 대상 테이블 목록 (DB가 타겟일 때)
|
||||
const [schedulePresets, setSchedulePresets] = useState<Array<{ value: string; label: string }>>([]);
|
||||
|
||||
// 모달 열릴 때 초기화
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadConnections();
|
||||
loadSchedulePresets();
|
||||
|
||||
if (job) {
|
||||
// 수정 모드
|
||||
setFormData({
|
||||
...job,
|
||||
config_json: job.config_json || {},
|
||||
});
|
||||
// 기존 config_json 내용을 상태로 복원
|
||||
const savedConfig = job.config_json as RestApiConfigJson;
|
||||
setConfigData({
|
||||
...savedConfig,
|
||||
httpMethod: savedConfig.httpMethod || "GET",
|
||||
apiBody: savedConfig.apiBody || "",
|
||||
});
|
||||
|
||||
// 타겟 연결이 있으면 테이블 목록 로드
|
||||
if (savedConfig.targetConnectionId) {
|
||||
loadTables(savedConfig.targetConnectionId);
|
||||
}
|
||||
} else {
|
||||
// 생성 모드
|
||||
setFormData({
|
||||
job_name: "",
|
||||
description: "",
|
||||
job_type: initialType === "rest_to_db" ? "rest_to_db" : "db_to_rest", // props로 받은 타입 우선
|
||||
schedule_cron: "",
|
||||
is_active: "Y",
|
||||
config_json: {},
|
||||
});
|
||||
setConfigData({
|
||||
httpMethod: "GET",
|
||||
apiBody: "",
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [isOpen, job, initialType]);
|
||||
|
||||
const loadConnections = async () => {
|
||||
try {
|
||||
// 외부 DB 연결 목록 조회 (내부 DB 포함)
|
||||
const list = await ExternalDbConnectionAPI.getConnections({ is_active: "Y" });
|
||||
setConnections(list);
|
||||
} catch (error) {
|
||||
console.error("연결 목록 조회 오류:", error);
|
||||
toast.error("연결 목록을 불러오는데 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const loadTables = async (connectionId: number) => {
|
||||
try {
|
||||
const result = await ExternalDbConnectionAPI.getTables(connectionId);
|
||||
if (result.success && result.data) {
|
||||
setTargetTables(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 조회 오류:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSchedulePresets = async () => {
|
||||
try {
|
||||
const presets = await BatchAPI.getSchedulePresets();
|
||||
setSchedulePresets(presets);
|
||||
} catch (error) {
|
||||
console.error("스케줄 프리셋 조회 오류:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 폼 제출 핸들러
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.job_name) {
|
||||
toast.error("배치명을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// REST API URL 필수 체크
|
||||
if (!configData.apiUrl) {
|
||||
toast.error("API 서버 URL을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
// 타겟 DB 연결 필수 체크 (REST -> DB 인 경우)
|
||||
if (formData.job_type === "rest_to_db" && !configData.targetConnectionId) {
|
||||
toast.error("데이터를 저장할 대상 DB 연결을 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// 최종 저장할 데이터 조립
|
||||
const finalJobData = {
|
||||
...formData,
|
||||
config_json: {
|
||||
...configData,
|
||||
// 추가적인 메타데이터가 필요하다면 여기에 포함
|
||||
},
|
||||
};
|
||||
|
||||
if (job?.id) {
|
||||
await BatchAPI.updateBatchJob(job.id, finalJobData);
|
||||
toast.success("배치 작업이 수정되었습니다.");
|
||||
} else {
|
||||
await BatchAPI.createBatchJob(finalJobData as BatchJob);
|
||||
toast.success("배치 작업이 생성되었습니다.");
|
||||
}
|
||||
onSave();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("배치 저장 오류:", error);
|
||||
toast.error(error instanceof Error ? error.message : "저장에 실패했습니다.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>고급 배치 생성</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6 py-2">
|
||||
{/* 1. 기본 정보 섹션 */}
|
||||
<div className="space-y-4 border rounded-md p-4 bg-slate-50">
|
||||
<h3 className="text-sm font-semibold text-slate-900">기본 정보</h3>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs">배치 타입 *</Label>
|
||||
<div className="mt-1 p-2 bg-white border rounded text-sm font-medium text-slate-600">
|
||||
{formData.job_type === "rest_to_db" ? "🌐 REST API → 💾 DB" : "💾 DB → 🌐 REST API"}
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 mt-1">
|
||||
{formData.job_type === "rest_to_db"
|
||||
? "REST API에서 데이터를 가져와 데이터베이스에 저장합니다."
|
||||
: "데이터베이스의 데이터를 REST API로 전송합니다."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="schedule_cron" className="text-xs">실행 스케줄 *</Label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Input
|
||||
id="schedule_cron"
|
||||
value={formData.schedule_cron || ""}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, schedule_cron: e.target.value }))}
|
||||
placeholder="예: 0 12 * * *"
|
||||
className="text-sm"
|
||||
/>
|
||||
<Select onValueChange={(val) => setFormData(prev => ({ ...prev, schedule_cron: val }))}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="프리셋" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{schedulePresets.map(p => (
|
||||
<SelectItem key={p.value} value={p.value}>{p.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="job_name" className="text-xs">배치명 *</Label>
|
||||
<Input
|
||||
id="job_name"
|
||||
value={formData.job_name || ""}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, job_name: e.target.value }))}
|
||||
placeholder="배치명을 입력하세요"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="description" className="text-xs">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description || ""}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="배치에 대한 설명을 입력하세요"
|
||||
className="mt-1 min-h-[60px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. REST API 설정 섹션 (Source) */}
|
||||
<div className="space-y-4 border rounded-md p-4 bg-white">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">🌐</span>
|
||||
<h3 className="text-sm font-semibold text-slate-900">
|
||||
{formData.job_type === "rest_to_db" ? "FROM: REST API (소스)" : "TO: REST API (대상)"}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="api_url" className="text-xs">API 서버 URL *</Label>
|
||||
<Input
|
||||
id="api_url"
|
||||
value={configData.apiUrl || ""}
|
||||
onChange={(e) => setConfigData(prev => ({ ...prev, apiUrl: e.target.value }))}
|
||||
placeholder="https://api.example.com"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<Label htmlFor="api_key" className="text-xs">API 키 (선택)</Label>
|
||||
<Input
|
||||
id="api_key"
|
||||
type="password"
|
||||
value={configData.apiKey || ""}
|
||||
onChange={(e) => setConfigData(prev => ({ ...prev, apiKey: e.target.value }))}
|
||||
placeholder="인증에 필요한 API Key가 있다면 입력하세요"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="endpoint" className="text-xs">엔드포인트 *</Label>
|
||||
<Input
|
||||
id="endpoint"
|
||||
value={configData.endpoint || ""}
|
||||
onChange={(e) => setConfigData(prev => ({ ...prev, endpoint: e.target.value }))}
|
||||
placeholder="/api/token"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="http_method" className="text-xs">HTTP 메서드</Label>
|
||||
<Select
|
||||
value={configData.httpMethod || "GET"}
|
||||
onValueChange={(val) => setConfigData(prev => ({ ...prev, httpMethod: val }))}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="GET">GET (데이터 조회)</SelectItem>
|
||||
<SelectItem value="POST">POST (데이터 생성/요청)</SelectItem>
|
||||
<SelectItem value="PUT">PUT (데이터 수정)</SelectItem>
|
||||
<SelectItem value="DELETE">DELETE (데이터 삭제)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* POST/PUT 일 때 Body 입력창 노출 */}
|
||||
{(configData.httpMethod === "POST" || configData.httpMethod === "PUT") && (
|
||||
<div className="sm:col-span-2 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
<Label htmlFor="api_body" className="text-xs">Request Body (JSON)</Label>
|
||||
<Textarea
|
||||
id="api_body"
|
||||
value={configData.apiBody || ""}
|
||||
onChange={(e) => setConfigData(prev => ({ ...prev, apiBody: e.target.value }))}
|
||||
placeholder='{"username": "myuser", "password": "mypassword"}'
|
||||
className="mt-1 font-mono text-xs min-h-[100px]"
|
||||
/>
|
||||
<p className="text-[10px] text-slate-500 mt-1">
|
||||
* 토큰 발급 요청 시 인증 정보를 JSON 형식으로 입력하세요.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. 데이터베이스 설정 섹션 (Target) */}
|
||||
<div className="space-y-4 border rounded-md p-4 bg-white">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">💾</span>
|
||||
<h3 className="text-sm font-semibold text-slate-900">
|
||||
{formData.job_type === "rest_to_db" ? "TO: 데이터베이스 (대상)" : "FROM: 데이터베이스 (소스)"}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs">데이터베이스 커넥션 선택</Label>
|
||||
<Select
|
||||
value={configData.targetConnectionId?.toString() || ""}
|
||||
onValueChange={(val) => {
|
||||
const connId = parseInt(val);
|
||||
setConfigData(prev => ({ ...prev, targetConnectionId: connId }));
|
||||
loadTables(connId); // 테이블 목록 로드
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="커넥션을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{connections.map(conn => (
|
||||
<SelectItem key={conn.id} value={conn.id.toString()}>
|
||||
{conn.connection_name || conn.name} ({conn.db_type})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">테이블 선택</Label>
|
||||
<Select
|
||||
value={configData.targetTable || ""}
|
||||
onValueChange={(val) => setConfigData(prev => ({ ...prev, targetTable: val }))}
|
||||
disabled={!configData.targetConnectionId}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="테이블을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{targetTables.length > 0 ? (
|
||||
targetTables.map(table => (
|
||||
<SelectItem key={table} value={table}>{table}</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<div className="p-2 text-xs text-center text-slate-400">테이블 없음</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? "저장 중..." : "저장"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -42,6 +42,7 @@ export function AuthenticationConfig({
|
|||
<SelectItem value="bearer">Bearer Token</SelectItem>
|
||||
<SelectItem value="basic">Basic Auth</SelectItem>
|
||||
<SelectItem value="oauth2">OAuth 2.0</SelectItem>
|
||||
<SelectItem value="db-token">DB 토큰</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
|
@ -192,6 +193,94 @@ export function AuthenticationConfig({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{authType === "db-token" && (
|
||||
<div className="space-y-4 rounded-md border bg-gray-50 p-4">
|
||||
<h4 className="text-sm font-medium">DB 기반 토큰 설정</h4>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="db-table-name">테이블명</Label>
|
||||
<Input
|
||||
id="db-table-name"
|
||||
type="text"
|
||||
value={authConfig.dbTableName || ""}
|
||||
onChange={(e) => updateAuthConfig("dbTableName", e.target.value)}
|
||||
placeholder="예: auth_tokens"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="db-value-column">값 컬럼명</Label>
|
||||
<Input
|
||||
id="db-value-column"
|
||||
type="text"
|
||||
value={authConfig.dbValueColumn || ""}
|
||||
onChange={(e) =>
|
||||
updateAuthConfig("dbValueColumn", e.target.value)
|
||||
}
|
||||
placeholder="예: access_token"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="db-where-column">조건 컬럼명</Label>
|
||||
<Input
|
||||
id="db-where-column"
|
||||
type="text"
|
||||
value={authConfig.dbWhereColumn || ""}
|
||||
onChange={(e) =>
|
||||
updateAuthConfig("dbWhereColumn", e.target.value)
|
||||
}
|
||||
placeholder="예: service_name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="db-where-value">조건 값</Label>
|
||||
<Input
|
||||
id="db-where-value"
|
||||
type="text"
|
||||
value={authConfig.dbWhereValue || ""}
|
||||
onChange={(e) =>
|
||||
updateAuthConfig("dbWhereValue", e.target.value)
|
||||
}
|
||||
placeholder="예: kakao"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="db-header-name">헤더 이름 (선택)</Label>
|
||||
<Input
|
||||
id="db-header-name"
|
||||
type="text"
|
||||
value={authConfig.dbHeaderName || ""}
|
||||
onChange={(e) =>
|
||||
updateAuthConfig("dbHeaderName", e.target.value)
|
||||
}
|
||||
placeholder="기본값: Authorization"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="db-header-template">
|
||||
헤더 템플릿 (선택, {{value}} 치환)
|
||||
</Label>
|
||||
<Input
|
||||
id="db-header-template"
|
||||
type="text"
|
||||
value={authConfig.dbHeaderTemplate || ""}
|
||||
onChange={(e) =>
|
||||
updateAuthConfig("dbHeaderTemplate", e.target.value)
|
||||
}
|
||||
placeholder='기본값: "Bearer {{value}}"'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500">
|
||||
company_code는 현재 로그인한 사용자의 회사 코드로 자동 필터링됩니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{authType === "none" && (
|
||||
<div className="rounded-md border border-dashed p-4 text-center text-sm text-gray-500">
|
||||
인증이 필요하지 않은 공개 API입니다.
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
|||
<TableCell className="h-16 px-6 py-3">{formatDiskUsage(company)}</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
{/* <Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleManageDepartments(company)}
|
||||
|
|
@ -170,7 +170,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
|
|||
aria-label="부서관리"
|
||||
>
|
||||
<Users className="h-4 w-4" />
|
||||
</Button>
|
||||
</Button> */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ const AUTH_TYPE_LABELS: Record<string, string> = {
|
|||
bearer: "Bearer",
|
||||
basic: "Basic Auth",
|
||||
oauth2: "OAuth 2.0",
|
||||
"db-token": "DB 토큰",
|
||||
};
|
||||
|
||||
// 활성 상태 옵션
|
||||
|
|
@ -158,6 +159,22 @@ export function RestApiConnectionList() {
|
|||
|
||||
setTestResults((prev) => new Map(prev).set(connection.id!, result.success));
|
||||
|
||||
// 현재 행의 "마지막 테스트" 정보만 낙관적으로 업데이트하여
|
||||
// 전체 목록 리로딩 없이도 UI를 즉시 반영한다.
|
||||
const nowIso = new Date().toISOString();
|
||||
setConnections((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === connection.id
|
||||
? {
|
||||
...c,
|
||||
last_test_date: nowIso as any,
|
||||
last_test_result: result.success ? "Y" : "N",
|
||||
last_test_message: result.message,
|
||||
}
|
||||
: c
|
||||
)
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
toast({
|
||||
title: "연결 성공",
|
||||
|
|
|
|||
|
|
@ -21,10 +21,13 @@ import {
|
|||
ExternalRestApiConnection,
|
||||
AuthType,
|
||||
RestApiTestResult,
|
||||
RestApiTestRequest,
|
||||
} from "@/lib/api/externalRestApiConnection";
|
||||
import { HeadersManager } from "./HeadersManager";
|
||||
import { AuthenticationConfig } from "./AuthenticationConfig";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
interface RestApiConnectionModalProps {
|
||||
isOpen: boolean;
|
||||
|
|
@ -42,6 +45,8 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
const [baseUrl, setBaseUrl] = useState("");
|
||||
const [endpointPath, setEndpointPath] = useState("");
|
||||
const [defaultHeaders, setDefaultHeaders] = useState<Record<string, string>>({});
|
||||
const [defaultMethod, setDefaultMethod] = useState("GET");
|
||||
const [defaultBody, setDefaultBody] = useState("");
|
||||
const [authType, setAuthType] = useState<AuthType>("none");
|
||||
const [authConfig, setAuthConfig] = useState<any>({});
|
||||
const [timeout, setTimeout] = useState(30000);
|
||||
|
|
@ -52,6 +57,8 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
// UI 상태
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [testEndpoint, setTestEndpoint] = useState("");
|
||||
const [testMethod, setTestMethod] = useState("GET");
|
||||
const [testBody, setTestBody] = useState("");
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<RestApiTestResult | null>(null);
|
||||
const [testRequestUrl, setTestRequestUrl] = useState<string>("");
|
||||
|
|
@ -65,12 +72,19 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
setBaseUrl(connection.base_url);
|
||||
setEndpointPath(connection.endpoint_path || "");
|
||||
setDefaultHeaders(connection.default_headers || {});
|
||||
setDefaultMethod(connection.default_method || "GET");
|
||||
setDefaultBody(connection.default_body || "");
|
||||
setAuthType(connection.auth_type);
|
||||
setAuthConfig(connection.auth_config || {});
|
||||
setTimeout(connection.timeout || 30000);
|
||||
setRetryCount(connection.retry_count || 0);
|
||||
setRetryDelay(connection.retry_delay || 1000);
|
||||
setIsActive(connection.is_active === "Y");
|
||||
|
||||
// 테스트 초기값 설정
|
||||
setTestEndpoint("");
|
||||
setTestMethod(connection.default_method || "GET");
|
||||
setTestBody(connection.default_body || "");
|
||||
} else {
|
||||
// 초기화
|
||||
setConnectionName("");
|
||||
|
|
@ -78,16 +92,22 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
setBaseUrl("");
|
||||
setEndpointPath("");
|
||||
setDefaultHeaders({ "Content-Type": "application/json" });
|
||||
setDefaultMethod("GET");
|
||||
setDefaultBody("");
|
||||
setAuthType("none");
|
||||
setAuthConfig({});
|
||||
setTimeout(30000);
|
||||
setRetryCount(0);
|
||||
setRetryDelay(1000);
|
||||
setIsActive(true);
|
||||
|
||||
// 테스트 초기값 설정
|
||||
setTestEndpoint("");
|
||||
setTestMethod("GET");
|
||||
setTestBody("");
|
||||
}
|
||||
|
||||
setTestResult(null);
|
||||
setTestEndpoint("");
|
||||
setTestRequestUrl("");
|
||||
}, [connection, isOpen]);
|
||||
|
||||
|
|
@ -111,14 +131,18 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
setTestRequestUrl(fullUrl);
|
||||
|
||||
try {
|
||||
const result = await ExternalRestApiConnectionAPI.testConnection({
|
||||
const testRequest: RestApiTestRequest = {
|
||||
base_url: baseUrl,
|
||||
endpoint: testEndpoint || undefined,
|
||||
method: testMethod as any,
|
||||
headers: defaultHeaders,
|
||||
body: testBody ? JSON.parse(testBody) : undefined,
|
||||
auth_type: authType,
|
||||
auth_config: authConfig,
|
||||
timeout,
|
||||
});
|
||||
};
|
||||
|
||||
const result = await ExternalRestApiConnectionAPI.testConnection(testRequest);
|
||||
|
||||
setTestResult(result);
|
||||
|
||||
|
|
@ -178,6 +202,20 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
return;
|
||||
}
|
||||
|
||||
// JSON 유효성 검증
|
||||
if (defaultBody && defaultMethod !== "GET" && defaultMethod !== "DELETE") {
|
||||
try {
|
||||
JSON.parse(defaultBody);
|
||||
} catch {
|
||||
toast({
|
||||
title: "입력 오류",
|
||||
description: "기본 Body가 올바른 JSON 형식이 아닙니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
|
|
@ -187,6 +225,8 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
base_url: baseUrl,
|
||||
endpoint_path: endpointPath || undefined,
|
||||
default_headers: defaultHeaders,
|
||||
default_method: defaultMethod,
|
||||
default_body: defaultBody.trim() || null, // 빈 문자열이면 null로 전송하여 DB 업데이트
|
||||
auth_type: authType,
|
||||
auth_config: authType === "none" ? undefined : authConfig,
|
||||
timeout,
|
||||
|
|
@ -196,6 +236,13 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
is_active: isActive ? "Y" : "N",
|
||||
};
|
||||
|
||||
console.log("저장하려는 데이터:", {
|
||||
connection_name: connectionName,
|
||||
default_method: defaultMethod,
|
||||
endpoint_path: endpointPath,
|
||||
base_url: baseUrl,
|
||||
});
|
||||
|
||||
if (connection?.id) {
|
||||
await ExternalRestApiConnectionAPI.updateConnection(connection.id, data);
|
||||
toast({
|
||||
|
|
@ -262,12 +309,34 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
<Label htmlFor="base-url">
|
||||
기본 URL <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="base-url"
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder="https://api.example.com"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Select
|
||||
value={defaultMethod}
|
||||
onValueChange={(val) => {
|
||||
setDefaultMethod(val);
|
||||
setTestMethod(val); // 테스트 Method도 동기화
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="Method" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="GET">GET</SelectItem>
|
||||
<SelectItem value="POST">POST</SelectItem>
|
||||
<SelectItem value="PUT">PUT</SelectItem>
|
||||
<SelectItem value="DELETE">DELETE</SelectItem>
|
||||
<SelectItem value="PATCH">PATCH</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
id="base-url"
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder="https://api.example.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
도메인 부분만 입력하세요 (예: https://apihub.kma.go.kr)
|
||||
</p>
|
||||
|
|
@ -286,6 +355,21 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{/* 기본 Body (POST, PUT, PATCH일 때만 표시) */}
|
||||
{(defaultMethod === "POST" || defaultMethod === "PUT" || defaultMethod === "PATCH") && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default-body">기본 Request Body (JSON)</Label>
|
||||
<Textarea
|
||||
id="default-body"
|
||||
value={defaultBody}
|
||||
onChange={(e) => setDefaultBody(e.target.value)}
|
||||
placeholder='{"key": "value"}'
|
||||
className="font-mono text-xs"
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch id="is-active" checked={isActive} onCheckedChange={setIsActive} />
|
||||
<Label htmlFor="is-active" className="cursor-pointer">
|
||||
|
|
@ -370,13 +454,45 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
<h3 className="text-sm font-semibold">연결 테스트</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="test-endpoint">테스트 엔드포인트 (선택)</Label>
|
||||
<Input
|
||||
id="test-endpoint"
|
||||
value={testEndpoint}
|
||||
onChange={(e) => setTestEndpoint(e.target.value)}
|
||||
placeholder="엔드포인트 또는 빈칸(기본 URL만 테스트)"
|
||||
/>
|
||||
<Label htmlFor="test-endpoint">테스트 설정</Label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<Select value={testMethod} onValueChange={setTestMethod}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="Method" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="GET">GET</SelectItem>
|
||||
<SelectItem value="POST">POST</SelectItem>
|
||||
<SelectItem value="PUT">PUT</SelectItem>
|
||||
<SelectItem value="DELETE">DELETE</SelectItem>
|
||||
<SelectItem value="PATCH">PATCH</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
id="test-endpoint"
|
||||
value={testEndpoint}
|
||||
onChange={(e) => setTestEndpoint(e.target.value)}
|
||||
placeholder="엔드포인트 (예: /users/1)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(testMethod === "POST" || testMethod === "PUT" || testMethod === "PATCH") && (
|
||||
<div className="mt-2">
|
||||
<Label htmlFor="test-body" className="text-xs text-muted-foreground mb-1 block">
|
||||
Test Request Body (JSON)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="test-body"
|
||||
value={testBody}
|
||||
onChange={(e) => setTestBody(e.target.value)}
|
||||
placeholder='{"test": "data"}'
|
||||
className="font-mono text-xs"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="button" variant="outline" onClick={handleTest} disabled={testing}>
|
||||
|
|
@ -388,10 +504,22 @@ export function RestApiConnectionModal({ isOpen, onClose, onSave, connection }:
|
|||
{testRequestUrl && (
|
||||
<div className="bg-muted/30 space-y-3 rounded-md border p-3">
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1 text-xs font-medium">테스트 요청 URL</div>
|
||||
<code className="text-foreground block text-xs break-all">GET {testRequestUrl}</code>
|
||||
<div className="text-muted-foreground mb-1 text-xs font-medium">테스트 요청</div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge variant="outline">{testMethod}</Badge>
|
||||
<code className="text-foreground text-xs break-all">{testRequestUrl}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{testBody && (testMethod === "POST" || testMethod === "PUT" || testMethod === "PATCH") && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1 text-xs font-medium">Request Body</div>
|
||||
<pre className="bg-muted p-2 rounded text-xs overflow-auto max-h-[100px]">
|
||||
{testBody}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Object.keys(defaultHeaders).length > 0 && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1 text-xs font-medium">요청 헤더</div>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft, Save, Loader2, Grid3x3, Move, Box, Package, Truck, Check } from "lucide-react";
|
||||
import { ArrowLeft, Save, Loader2, Grid3x3, Move, Box, Package, Truck, Check, ParkingCircle } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
|
|
@ -39,6 +39,77 @@ import {
|
|||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
// 성능 최적화를 위한 디바운스/Blur 처리된 Input 컴포넌트
|
||||
const DebouncedInput = ({
|
||||
value,
|
||||
onChange,
|
||||
onCommit,
|
||||
type = "text",
|
||||
debounce = 0,
|
||||
...props
|
||||
}: React.InputHTMLAttributes<HTMLInputElement> & {
|
||||
onCommit?: (value: any) => void;
|
||||
debounce?: number;
|
||||
}) => {
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditing) {
|
||||
setLocalValue(value);
|
||||
}
|
||||
}, [value, isEditing]);
|
||||
|
||||
// 색상 입력 등을 위한 디바운스 커밋
|
||||
useEffect(() => {
|
||||
if (debounce > 0 && isEditing && onCommit) {
|
||||
const timer = setTimeout(() => {
|
||||
onCommit(type === "number" ? parseFloat(localValue as string) : localValue);
|
||||
}, debounce);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [localValue, debounce, isEditing, onCommit, type]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setLocalValue(e.target.value);
|
||||
if (onChange) onChange(e);
|
||||
};
|
||||
|
||||
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
setIsEditing(false);
|
||||
if (onCommit && debounce === 0) {
|
||||
// 값이 변경되었을 때만 커밋하도록 하면 좋겠지만,
|
||||
// 부모 상태와 비교하기 어려우므로 항상 커밋 (handleObjectUpdate 내부에서 처리됨)
|
||||
onCommit(type === "number" ? parseFloat(localValue as string) : localValue);
|
||||
}
|
||||
if (props.onBlur) props.onBlur(e);
|
||||
};
|
||||
|
||||
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
setIsEditing(true);
|
||||
if (props.onFocus) props.onFocus(e);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
if (props.onKeyDown) props.onKeyDown(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
{...props}
|
||||
type={type}
|
||||
value={localValue}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onFocus={handleFocus}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 백엔드 DB 객체 타입 (snake_case)
|
||||
interface DbObject {
|
||||
id: number;
|
||||
|
|
@ -550,10 +621,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
areaKey: obj.area_key,
|
||||
locaKey: obj.loca_key,
|
||||
locType: obj.loc_type,
|
||||
materialCount: obj.material_count,
|
||||
materialPreview: obj.material_preview_height
|
||||
? { height: parseFloat(obj.material_preview_height) }
|
||||
: undefined,
|
||||
materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
|
||||
materialPreview:
|
||||
obj.loc_type === "STP" || !obj.material_preview_height
|
||||
? undefined
|
||||
: { height: parseFloat(obj.material_preview_height) },
|
||||
parentId: obj.parent_id,
|
||||
displayOrder: obj.display_order,
|
||||
locked: obj.locked,
|
||||
|
|
@ -761,12 +833,9 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
// 기본 크기 설정
|
||||
let objectSize = defaults.size || { x: 5, y: 5, z: 5 };
|
||||
|
||||
// Location 배치 시 자재 개수에 따라 높이 자동 설정
|
||||
// Location 배치 시 자재 개수에 따라 높이 자동 설정 (BED/TMP/DES만 대상, STP는 자재 미적재)
|
||||
if (
|
||||
(draggedTool === "location-bed" ||
|
||||
draggedTool === "location-stp" ||
|
||||
draggedTool === "location-temp" ||
|
||||
draggedTool === "location-dest") &&
|
||||
(draggedTool === "location-bed" || draggedTool === "location-temp" || draggedTool === "location-dest") &&
|
||||
locaKey &&
|
||||
selectedDbConnection &&
|
||||
hierarchyConfig?.material
|
||||
|
|
@ -877,12 +946,9 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
setDraggedAreaData(null);
|
||||
setDraggedLocationData(null);
|
||||
|
||||
// Location 배치 시 자재 개수 로드
|
||||
// Location 배치 시 자재 개수 로드 (BED/TMP/DES만 대상, STP는 자재 미적재)
|
||||
if (
|
||||
(draggedTool === "location-bed" ||
|
||||
draggedTool === "location-stp" ||
|
||||
draggedTool === "location-temp" ||
|
||||
draggedTool === "location-dest") &&
|
||||
(draggedTool === "location-bed" || draggedTool === "location-temp" || draggedTool === "location-dest") &&
|
||||
locaKey
|
||||
) {
|
||||
// 새 객체 추가 후 자재 개수 로드 (약간의 딜레이를 두어 state 업데이트 완료 후 실행)
|
||||
|
|
@ -965,13 +1031,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
loadLocationsForArea(obj.areaKey);
|
||||
setShowMaterialPanel(false);
|
||||
}
|
||||
// Location을 클릭한 경우, 해당 Location의 자재 목록 로드
|
||||
// Location을 클릭한 경우, 해당 Location의 자재 목록 로드 (STP는 자재 미적재이므로 제외)
|
||||
else if (
|
||||
obj &&
|
||||
(obj.type === "location-bed" ||
|
||||
obj.type === "location-stp" ||
|
||||
obj.type === "location-temp" ||
|
||||
obj.type === "location-dest") &&
|
||||
(obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") &&
|
||||
obj.locaKey &&
|
||||
selectedDbConnection
|
||||
) {
|
||||
|
|
@ -988,9 +1051,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
try {
|
||||
const response = await getMaterialCounts(selectedDbConnection, selectedTables.material, locaKeys);
|
||||
if (response.success && response.data) {
|
||||
// 각 Location 객체에 자재 개수 업데이트
|
||||
// 각 Location 객체에 자재 개수 업데이트 (STP는 자재 미적재이므로 제외)
|
||||
setPlacedObjects((prev) =>
|
||||
prev.map((obj) => {
|
||||
if (
|
||||
!obj.locaKey ||
|
||||
obj.type === "location-stp" // STP는 자재 없음
|
||||
) {
|
||||
return obj;
|
||||
}
|
||||
const materialCount = response.data?.find((mc) => mc.LOCAKEY === obj.locaKey);
|
||||
if (materialCount) {
|
||||
return {
|
||||
|
|
@ -1278,7 +1347,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
const oldSize = actualObject.size;
|
||||
const newSize = { ...oldSize, ...updates.size };
|
||||
|
||||
// W, D를 5 단위로 스냅
|
||||
// W, D를 5 단위로 스냅 (STP 포함)
|
||||
newSize.x = Math.max(5, Math.round(newSize.x / 5) * 5);
|
||||
newSize.z = Math.max(5, Math.round(newSize.z / 5) * 5);
|
||||
|
||||
|
|
@ -1391,10 +1460,11 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
areaKey: obj.area_key,
|
||||
locaKey: obj.loca_key,
|
||||
locType: obj.loc_type,
|
||||
materialCount: obj.material_count,
|
||||
materialPreview: obj.material_preview_height
|
||||
? { height: parseFloat(obj.material_preview_height) }
|
||||
: undefined,
|
||||
materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
|
||||
materialPreview:
|
||||
obj.loc_type === "STP" || !obj.material_preview_height
|
||||
? undefined
|
||||
: { height: parseFloat(obj.material_preview_height) },
|
||||
parentId: obj.parent_id,
|
||||
displayOrder: obj.display_order,
|
||||
locked: obj.locked,
|
||||
|
|
@ -1798,6 +1868,8 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
</div>
|
||||
{isLocationPlaced ? (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
) : locationType === "location-stp" ? (
|
||||
<ParkingCircle className="text-muted-foreground h-4 w-4" />
|
||||
) : (
|
||||
<Package className="text-muted-foreground h-4 w-4" />
|
||||
)}
|
||||
|
|
@ -2069,10 +2141,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
<Label htmlFor="object-name" className="text-sm">
|
||||
이름
|
||||
</Label>
|
||||
<Input
|
||||
<DebouncedInput
|
||||
id="object-name"
|
||||
value={selectedObject.name || ""}
|
||||
onChange={(e) => handleObjectUpdate({ name: e.target.value })}
|
||||
onCommit={(val) => handleObjectUpdate({ name: val })}
|
||||
className="mt-1.5 h-9 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -2085,15 +2157,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
<Label htmlFor="pos-x" className="text-muted-foreground text-xs">
|
||||
X
|
||||
</Label>
|
||||
<Input
|
||||
<DebouncedInput
|
||||
id="pos-x"
|
||||
type="number"
|
||||
value={(selectedObject.position?.x || 0).toFixed(1)}
|
||||
onChange={(e) =>
|
||||
onCommit={(val) =>
|
||||
handleObjectUpdate({
|
||||
position: {
|
||||
...selectedObject.position,
|
||||
x: parseFloat(e.target.value),
|
||||
x: val,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -2104,15 +2176,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
<Label htmlFor="pos-z" className="text-muted-foreground text-xs">
|
||||
Z
|
||||
</Label>
|
||||
<Input
|
||||
<DebouncedInput
|
||||
id="pos-z"
|
||||
type="number"
|
||||
value={(selectedObject.position?.z || 0).toFixed(1)}
|
||||
onChange={(e) =>
|
||||
onCommit={(val) =>
|
||||
handleObjectUpdate({
|
||||
position: {
|
||||
...selectedObject.position,
|
||||
z: parseFloat(e.target.value),
|
||||
z: val,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -2130,17 +2202,17 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
<Label htmlFor="size-x" className="text-muted-foreground text-xs">
|
||||
W (5 단위)
|
||||
</Label>
|
||||
<Input
|
||||
<DebouncedInput
|
||||
id="size-x"
|
||||
type="number"
|
||||
step="5"
|
||||
min="5"
|
||||
value={selectedObject.size?.x || 5}
|
||||
onChange={(e) =>
|
||||
onCommit={(val) =>
|
||||
handleObjectUpdate({
|
||||
size: {
|
||||
...selectedObject.size,
|
||||
x: parseFloat(e.target.value),
|
||||
x: val,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -2151,15 +2223,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
<Label htmlFor="size-y" className="text-muted-foreground text-xs">
|
||||
H
|
||||
</Label>
|
||||
<Input
|
||||
<DebouncedInput
|
||||
id="size-y"
|
||||
type="number"
|
||||
value={selectedObject.size?.y || 5}
|
||||
onChange={(e) =>
|
||||
onCommit={(val) =>
|
||||
handleObjectUpdate({
|
||||
size: {
|
||||
...selectedObject.size,
|
||||
y: parseFloat(e.target.value),
|
||||
y: val,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -2170,17 +2242,17 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
<Label htmlFor="size-z" className="text-muted-foreground text-xs">
|
||||
D (5 단위)
|
||||
</Label>
|
||||
<Input
|
||||
<DebouncedInput
|
||||
id="size-z"
|
||||
type="number"
|
||||
step="5"
|
||||
min="5"
|
||||
value={selectedObject.size?.z || 5}
|
||||
onChange={(e) =>
|
||||
onCommit={(val) =>
|
||||
handleObjectUpdate({
|
||||
size: {
|
||||
...selectedObject.size,
|
||||
z: parseFloat(e.target.value),
|
||||
z: val,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -2195,11 +2267,12 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
<Label htmlFor="object-color" className="text-sm">
|
||||
색상
|
||||
</Label>
|
||||
<Input
|
||||
<DebouncedInput
|
||||
id="object-color"
|
||||
type="color"
|
||||
debounce={100}
|
||||
value={selectedObject.color || "#3b82f6"}
|
||||
onChange={(e) => handleObjectUpdate({ color: e.target.value })}
|
||||
onCommit={(val) => handleObjectUpdate({ color: val })}
|
||||
className="mt-1.5 h-9"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Loader2, Search, X, Grid3x3, Package } from "lucide-react";
|
||||
import { Loader2, Search, X, Grid3x3, Package, ParkingCircle } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -87,10 +87,11 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
areaKey: obj.area_key,
|
||||
locaKey: obj.loca_key,
|
||||
locType: obj.loc_type,
|
||||
materialCount: obj.material_count,
|
||||
materialPreview: obj.material_preview_height
|
||||
? { height: parseFloat(obj.material_preview_height) }
|
||||
: undefined,
|
||||
materialCount: obj.loc_type === "STP" ? undefined : obj.material_count,
|
||||
materialPreview:
|
||||
obj.loc_type === "STP" || !obj.material_preview_height
|
||||
? undefined
|
||||
: { height: parseFloat(obj.material_preview_height) },
|
||||
parentId: obj.parent_id,
|
||||
displayOrder: obj.display_order,
|
||||
locked: obj.locked,
|
||||
|
|
@ -166,13 +167,10 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
const obj = placedObjects.find((o) => o.id === objectId);
|
||||
setSelectedObject(obj || null);
|
||||
|
||||
// Location을 클릭한 경우, 자재 정보 표시
|
||||
// Location을 클릭한 경우, 자재 정보 표시 (STP는 자재 미적재이므로 제외)
|
||||
if (
|
||||
obj &&
|
||||
(obj.type === "location-bed" ||
|
||||
obj.type === "location-stp" ||
|
||||
obj.type === "location-temp" ||
|
||||
obj.type === "location-dest") &&
|
||||
(obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") &&
|
||||
obj.locaKey &&
|
||||
externalDbConnectionId
|
||||
) {
|
||||
|
|
@ -363,59 +361,59 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
// Area가 없으면 기존 평면 리스트 유지
|
||||
if (areaObjects.length === 0) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{filteredObjects.map((obj) => {
|
||||
let typeLabel = obj.type;
|
||||
if (obj.type === "location-bed") typeLabel = "베드(BED)";
|
||||
else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)";
|
||||
else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)";
|
||||
else if (obj.type === "location-dest") typeLabel = "지정착지(DES)";
|
||||
else if (obj.type === "crane-mobile") typeLabel = "크레인";
|
||||
else if (obj.type === "area") typeLabel = "Area";
|
||||
else if (obj.type === "rack") typeLabel = "랙";
|
||||
<div className="space-y-2">
|
||||
{filteredObjects.map((obj) => {
|
||||
let typeLabel = obj.type;
|
||||
if (obj.type === "location-bed") typeLabel = "베드(BED)";
|
||||
else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)";
|
||||
else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)";
|
||||
else if (obj.type === "location-dest") typeLabel = "지정착지(DES)";
|
||||
else if (obj.type === "crane-mobile") typeLabel = "크레인";
|
||||
else if (obj.type === "area") typeLabel = "Area";
|
||||
else if (obj.type === "rack") typeLabel = "랙";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={obj.id}
|
||||
onClick={() => handleObjectClick(obj.id)}
|
||||
className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${
|
||||
selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{obj.name}</p>
|
||||
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: obj.color }}
|
||||
/>
|
||||
<span>{typeLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
{obj.areaKey && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Area: <span className="font-medium">{obj.areaKey}</span>
|
||||
</p>
|
||||
)}
|
||||
{obj.locaKey && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Location: <span className="font-medium">{obj.locaKey}</span>
|
||||
</p>
|
||||
)}
|
||||
{obj.materialCount !== undefined && obj.materialCount > 0 && (
|
||||
<p className="text-xs text-yellow-600">
|
||||
자재: <span className="font-semibold">{obj.materialCount}개</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
return (
|
||||
<div
|
||||
key={obj.id}
|
||||
onClick={() => handleObjectClick(obj.id)}
|
||||
className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${
|
||||
selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{obj.name}</p>
|
||||
<div className="text-muted-foreground mt-1 flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: obj.color }}
|
||||
/>
|
||||
<span>{typeLabel}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
{obj.areaKey && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Area: <span className="font-medium">{obj.areaKey}</span>
|
||||
</p>
|
||||
)}
|
||||
{obj.locaKey && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Location: <span className="font-medium">{obj.locaKey}</span>
|
||||
</p>
|
||||
)}
|
||||
{obj.materialCount !== undefined && obj.materialCount > 0 && (
|
||||
<p className="text-xs text-yellow-600">
|
||||
자재: <span className="font-semibold">{obj.materialCount}개</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Area가 있는 경우: Area → Location 계층 아코디언
|
||||
|
|
@ -471,7 +469,11 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-3 w-3" />
|
||||
{locationObj.type === "location-stp" ? (
|
||||
<ParkingCircle className="h-3 w-3" />
|
||||
) : (
|
||||
<Package className="h-3 w-3" />
|
||||
)}
|
||||
<span className="text-xs font-medium">{locationObj.name}</span>
|
||||
</div>
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -131,13 +131,13 @@ export default function HierarchyConfigPanel({
|
|||
try {
|
||||
await Promise.all(
|
||||
tablesToFetch.map(async (tableName) => {
|
||||
try {
|
||||
const columns = await onLoadColumns(tableName);
|
||||
const normalized = normalizeColumns(columns);
|
||||
setColumnsCache((prev) => ({ ...prev, [tableName]: normalized }));
|
||||
} catch (error) {
|
||||
console.error(`컬럼 로드 실패 (${tableName}):`, error);
|
||||
}
|
||||
try {
|
||||
const columns = await onLoadColumns(tableName);
|
||||
const normalized = normalizeColumns(columns);
|
||||
setColumnsCache((prev) => ({ ...prev, [tableName]: normalized }));
|
||||
} catch (error) {
|
||||
console.error(`컬럼 로드 실패 (${tableName}):`, error);
|
||||
}
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -593,52 +593,58 @@ function MaterialBox({
|
|||
);
|
||||
|
||||
case "location-stp":
|
||||
// 정차포인트(STP): 주황색 낮은 플랫폼
|
||||
return (
|
||||
<>
|
||||
<Box args={[boxWidth, boxHeight, boxDepth]}>
|
||||
<meshStandardMaterial
|
||||
color={placement.color}
|
||||
roughness={0.6}
|
||||
metalness={0.2}
|
||||
emissive={isSelected ? placement.color : "#000000"}
|
||||
emissiveIntensity={isSelected ? glowIntensity * 0.8 : 0}
|
||||
/>
|
||||
</Box>
|
||||
// 정차포인트(STP): 회색 타원형 플랫폼 + 'P' 마크 (자재 미적재 영역)
|
||||
{
|
||||
const baseRadius = 0.5; // 스케일로 실제 W/D를 반영 (타원형)
|
||||
const labelFontSize = Math.min(boxWidth, boxDepth) * 0.15;
|
||||
const iconFontSize = Math.min(boxWidth, boxDepth) * 0.3;
|
||||
|
||||
{/* Location 이름 */}
|
||||
{placement.name && (
|
||||
return (
|
||||
<>
|
||||
{/* 타원형 플랫폼: 단위 실린더를 W/D로 스케일 */}
|
||||
<mesh scale={[boxWidth, 1, boxDepth]}>
|
||||
<cylinderGeometry args={[baseRadius, baseRadius, boxHeight, 32]} />
|
||||
<meshStandardMaterial
|
||||
color={placement.color}
|
||||
roughness={0.6}
|
||||
metalness={0.2}
|
||||
emissive={isSelected ? placement.color : "#000000"}
|
||||
emissiveIntensity={isSelected ? glowIntensity * 0.8 : 0}
|
||||
/>
|
||||
</mesh>
|
||||
|
||||
{/* 상단 'P' 마크 (주차 아이콘 역할) */}
|
||||
<Text
|
||||
position={[0, boxHeight / 2 + 0.3, 0]}
|
||||
position={[0, boxHeight / 2 + 0.05, 0]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
fontSize={Math.min(boxWidth, boxDepth) * 0.15}
|
||||
fontSize={iconFontSize}
|
||||
color="#ffffff"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0.03}
|
||||
outlineWidth={0.08}
|
||||
outlineColor="#000000"
|
||||
>
|
||||
{placement.name}
|
||||
P
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 자재 개수 (STP는 정차포인트라 자재가 없을 수 있음) */}
|
||||
{placement.material_count !== undefined && placement.material_count > 0 && (
|
||||
<Text
|
||||
position={[0, boxHeight / 2 + 0.6, 0]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
fontSize={Math.min(boxWidth, boxDepth) * 0.12}
|
||||
color="#fbbf24"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0.03}
|
||||
outlineColor="#000000"
|
||||
>
|
||||
{`자재: ${placement.material_count}개`}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
{/* Location 이름 */}
|
||||
{placement.name && (
|
||||
<Text
|
||||
position={[0, boxHeight / 2 + 0.4, 0]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
fontSize={labelFontSize}
|
||||
color="#ffffff"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0.03}
|
||||
outlineColor="#000000"
|
||||
>
|
||||
{placement.name}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// case "gantry-crane":
|
||||
// // 겐트리 크레인: 기둥 2개 + 상단 빔
|
||||
|
|
@ -1098,10 +1104,12 @@ function Scene({
|
|||
orbitControlsRef={orbitControlsRef}
|
||||
/>
|
||||
|
||||
{/* 조명 */}
|
||||
<ambientLight intensity={0.5} />
|
||||
<directionalLight position={[10, 10, 5]} intensity={1} />
|
||||
<directionalLight position={[-10, -10, -5]} intensity={0.3} />
|
||||
{/* 조명 - 전체적으로 밝게 조정 */}
|
||||
<ambientLight intensity={0.9} />
|
||||
<directionalLight position={[10, 20, 10]} intensity={1.2} />
|
||||
<directionalLight position={[-10, 20, -10]} intensity={0.8} />
|
||||
<directionalLight position={[0, 20, 0]} intensity={0.5} />
|
||||
<hemisphereLight args={["#ffffff", "#bbbbbb", 0.8]} />
|
||||
|
||||
{/* 배경색 */}
|
||||
<color attach="background" args={["#f3f4f6"]} />
|
||||
|
|
|
|||
|
|
@ -164,3 +164,4 @@ export function getAllDescendants(
|
|||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { Plus, ChevronDown, ChevronRight, Users, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
|
||||
// 연속 등록 모드 상태 (state로 변경 - 체크박스 UI 업데이트를 위해)
|
||||
const [continuousMode, setContinuousMode] = useState(false);
|
||||
|
||||
|
||||
// 화면 리셋 키 (컴포넌트 강제 리마운트용)
|
||||
const [resetKey, setResetKey] = useState(0);
|
||||
|
||||
|
|
@ -68,7 +68,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
const savedMode = localStorage.getItem("screenModal_continuousMode");
|
||||
if (savedMode === "true") {
|
||||
setContinuousMode(true);
|
||||
console.log("🔄 연속 모드 복원: true");
|
||||
// console.log("🔄 연속 모드 복원: true");
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
@ -177,6 +177,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
});
|
||||
setScreenData(null);
|
||||
setFormData({});
|
||||
setSelectedData([]); // 🆕 선택된 데이터 초기화
|
||||
setContinuousMode(false);
|
||||
localStorage.setItem("screenModal_continuousMode", "false"); // localStorage에 저장
|
||||
console.log("🔄 연속 모드 초기화: false");
|
||||
|
|
@ -202,11 +203,11 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
|
||||
// 1. 폼 데이터 초기화
|
||||
setFormData({});
|
||||
|
||||
|
||||
// 2. 리셋 키 변경 (컴포넌트 강제 리마운트)
|
||||
setResetKey(prev => prev + 1);
|
||||
setResetKey((prev) => prev + 1);
|
||||
console.log("🔄 resetKey 증가 - 컴포넌트 리마운트");
|
||||
|
||||
|
||||
// 3. 화면 데이터 다시 로드 (채번 규칙 새로 생성)
|
||||
if (modalState.screenId) {
|
||||
console.log("🔄 화면 데이터 다시 로드:", modalState.screenId);
|
||||
|
|
@ -334,17 +335,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
if (Array.isArray(data)) {
|
||||
return data.map(normalizeDates);
|
||||
}
|
||||
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
|
||||
if (typeof data !== "object" || data === null) {
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
const normalized: any = {};
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
|
||||
if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/.test(value)) {
|
||||
// ISO 날짜 형식 감지: YYYY-MM-DD만 추출
|
||||
const before = value;
|
||||
const after = value.split('T')[0];
|
||||
const after = value.split("T")[0];
|
||||
console.log(`🔧 [날짜 정규화] ${key}: ${before} → ${after}`);
|
||||
normalized[key] = after;
|
||||
} else {
|
||||
|
|
@ -353,14 +354,16 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
|
||||
console.log("📥 [ScreenModal] API 응답 원본:", JSON.stringify(response.data, null, 2));
|
||||
const normalizedData = normalizeDates(response.data);
|
||||
console.log("📥 [ScreenModal] 정규화 후:", JSON.stringify(normalizedData, null, 2));
|
||||
|
||||
|
||||
// 🔧 배열 데이터는 formData로 설정하지 않음 (SelectedItemsDetailInput만 사용)
|
||||
if (Array.isArray(normalizedData)) {
|
||||
console.log("⚠️ [ScreenModal] 그룹 레코드(배열)는 formData로 설정하지 않음. SelectedItemsDetailInput만 사용합니다.");
|
||||
console.log(
|
||||
"⚠️ [ScreenModal] 그룹 레코드(배열)는 formData로 설정하지 않음. SelectedItemsDetailInput만 사용합니다.",
|
||||
);
|
||||
setFormData(normalizedData); // SelectedItemsDetailInput이 직접 사용
|
||||
} else {
|
||||
setFormData(normalizedData);
|
||||
|
|
@ -436,7 +439,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
window.history.pushState({}, "", currentUrl.toString());
|
||||
console.log("🧹 [ScreenModal] URL 파라미터 제거 (모달 닫힘)");
|
||||
}
|
||||
|
||||
|
||||
setModalState({
|
||||
isOpen: false,
|
||||
screenId: null,
|
||||
|
|
@ -460,7 +463,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
// 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스
|
||||
const headerHeight = 60; // DialogHeader (타이틀 + 패딩)
|
||||
const footerHeight = 52; // 연속 등록 모드 체크박스 영역
|
||||
|
||||
|
||||
const totalHeight = screenDimensions.height + headerHeight + footerHeight;
|
||||
|
||||
return {
|
||||
|
|
@ -634,6 +637,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
|||
userId={userId}
|
||||
userName={userName}
|
||||
companyCode={user?.companyCode}
|
||||
// 🆕 선택된 데이터 전달 (RepeatScreenModal 등에서 사용)
|
||||
groupedData={selectedData.length > 0 ? selectedData : undefined}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -19,19 +19,50 @@ import {
|
|||
Trash2,
|
||||
Settings,
|
||||
Upload,
|
||||
X
|
||||
X,
|
||||
GripVertical,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
LayoutTemplate,
|
||||
Table2,
|
||||
AlertCircle,
|
||||
Minus,
|
||||
Building2,
|
||||
ListOrdered
|
||||
} from "lucide-react";
|
||||
import { getMailTemplates } from "@/lib/api/mail";
|
||||
|
||||
export interface MailComponent {
|
||||
id: string;
|
||||
type: "text" | "button" | "image" | "spacer" | "table";
|
||||
type: "text" | "button" | "image" | "spacer" | "table" | "header" | "infoTable" | "alertBox" | "divider" | "footer" | "numberedList";
|
||||
content?: string;
|
||||
text?: string;
|
||||
url?: string;
|
||||
src?: string;
|
||||
height?: number;
|
||||
styles?: Record<string, string>;
|
||||
// 헤더 컴포넌트용
|
||||
logoSrc?: string;
|
||||
brandName?: string;
|
||||
sendDate?: string;
|
||||
headerBgColor?: string;
|
||||
// 정보 테이블용
|
||||
rows?: Array<{ label: string; value: string }>;
|
||||
tableTitle?: string;
|
||||
// 강조 박스용
|
||||
alertType?: "info" | "warning" | "danger" | "success";
|
||||
alertTitle?: string;
|
||||
// 푸터용
|
||||
companyName?: string;
|
||||
ceoName?: string;
|
||||
businessNumber?: string;
|
||||
address?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
copyright?: string;
|
||||
// 번호 리스트용
|
||||
listItems?: string[];
|
||||
listTitle?: string;
|
||||
}
|
||||
|
||||
export interface QueryConfig {
|
||||
|
|
@ -64,6 +95,10 @@ export default function MailDesigner({
|
|||
const [subject, setSubject] = useState("");
|
||||
const [queries, setQueries] = useState<QueryConfig[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 드래그 앤 드롭 상태
|
||||
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
|
||||
// 템플릿 데이터 로드 (수정 모드)
|
||||
useEffect(() => {
|
||||
|
|
@ -96,10 +131,18 @@ export default function MailDesigner({
|
|||
|
||||
// 컴포넌트 타입 정의
|
||||
const componentTypes = [
|
||||
{ type: "text", icon: Type, label: "텍스트", color: "bg-primary/20 hover:bg-blue-200" },
|
||||
{ type: "button", icon: MousePointer, label: "버튼", color: "bg-success/20 hover:bg-success/30" },
|
||||
{ type: "image", icon: ImageIcon, label: "이미지", color: "bg-purple-100 hover:bg-purple-200" },
|
||||
{ type: "spacer", icon: Square, label: "여백", color: "bg-muted hover:bg-muted/80" },
|
||||
// 레이아웃 컴포넌트
|
||||
{ type: "header", icon: LayoutTemplate, label: "헤더", color: "bg-indigo-100 hover:bg-indigo-200", category: "layout" },
|
||||
{ type: "divider", icon: Minus, label: "구분선", color: "bg-gray-100 hover:bg-gray-200", category: "layout" },
|
||||
{ type: "spacer", icon: Square, label: "여백", color: "bg-muted hover:bg-muted/80", category: "layout" },
|
||||
{ type: "footer", icon: Building2, label: "푸터", color: "bg-slate-100 hover:bg-slate-200", category: "layout" },
|
||||
// 컨텐츠 컴포넌트
|
||||
{ type: "text", icon: Type, label: "텍스트", color: "bg-primary/20 hover:bg-blue-200", category: "content" },
|
||||
{ type: "button", icon: MousePointer, label: "버튼", color: "bg-success/20 hover:bg-success/30", category: "content" },
|
||||
{ type: "image", icon: ImageIcon, label: "이미지", color: "bg-purple-100 hover:bg-purple-200", category: "content" },
|
||||
{ type: "infoTable", icon: Table2, label: "정보 테이블", color: "bg-cyan-100 hover:bg-cyan-200", category: "content" },
|
||||
{ type: "alertBox", icon: AlertCircle, label: "안내 박스", color: "bg-amber-100 hover:bg-amber-200", category: "content" },
|
||||
{ type: "numberedList", icon: ListOrdered, label: "번호 리스트", color: "bg-emerald-100 hover:bg-emerald-200", category: "content" },
|
||||
];
|
||||
|
||||
// 컴포넌트 추가
|
||||
|
|
@ -107,21 +150,75 @@ export default function MailDesigner({
|
|||
const newComponent: MailComponent = {
|
||||
id: `comp-${Date.now()}`,
|
||||
type: type as any,
|
||||
content: type === "text" ? "" : undefined, // 🎯 빈 문자열로 시작 (HTML 태그 제거)
|
||||
text: type === "button" ? "버튼 텍스트" : undefined, // 🎯 더 명확한 기본값
|
||||
url: type === "button" || type === "image" ? "" : undefined, // 🎯 빈 문자열로 시작
|
||||
src: type === "image" ? "https://placehold.co/600x200/e5e7eb/64748b?text=이미지를+업로드하세요" : undefined, // 🎯 한글 안내
|
||||
height: type === "spacer" ? 30 : undefined, // 🎯 기본값 30px로 증가 (더 적절한 간격)
|
||||
content: type === "text" ? "" : undefined,
|
||||
text: type === "button" ? "버튼 텍스트" : undefined,
|
||||
url: type === "button" || type === "image" ? "" : undefined,
|
||||
src: type === "image" ? "https://placehold.co/600x200/e5e7eb/64748b?text=이미지를+업로드하세요" : undefined,
|
||||
height: type === "spacer" ? 30 : type === "divider" ? 1 : undefined,
|
||||
styles: {
|
||||
padding: "10px",
|
||||
padding: type === "divider" ? "0" : "10px",
|
||||
backgroundColor: type === "button" ? "#007bff" : "transparent",
|
||||
color: type === "button" ? "#fff" : "#333",
|
||||
},
|
||||
// 헤더 기본값
|
||||
logoSrc: type === "header" ? "" : undefined,
|
||||
brandName: type === "header" ? "회사명" : undefined,
|
||||
sendDate: type === "header" ? new Date().toLocaleDateString("ko-KR") : undefined,
|
||||
headerBgColor: type === "header" ? "#f8f9fa" : undefined,
|
||||
// 정보 테이블 기본값
|
||||
rows: type === "infoTable" ? [{ label: "항목", value: "내용" }] : undefined,
|
||||
tableTitle: type === "infoTable" ? "" : undefined,
|
||||
// 안내 박스 기본값
|
||||
alertType: type === "alertBox" ? "info" : undefined,
|
||||
alertTitle: type === "alertBox" ? "안내" : undefined,
|
||||
// 푸터 기본값
|
||||
companyName: type === "footer" ? "회사명" : undefined,
|
||||
ceoName: type === "footer" ? "" : undefined,
|
||||
businessNumber: type === "footer" ? "" : undefined,
|
||||
address: type === "footer" ? "" : undefined,
|
||||
phone: type === "footer" ? "" : undefined,
|
||||
email: type === "footer" ? "" : undefined,
|
||||
copyright: type === "footer" ? `© ${new Date().getFullYear()} All rights reserved.` : undefined,
|
||||
// 번호 리스트 기본값
|
||||
listItems: type === "numberedList" ? ["첫 번째 항목"] : undefined,
|
||||
listTitle: type === "numberedList" ? "" : undefined,
|
||||
};
|
||||
|
||||
setComponents([...components, newComponent]);
|
||||
};
|
||||
|
||||
// 드래그 앤 드롭 핸들러
|
||||
const handleDragStart = (index: number) => {
|
||||
setDraggedIndex(index);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
if (draggedIndex !== null && draggedIndex !== index) {
|
||||
setDragOverIndex(index);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (index: number) => {
|
||||
if (draggedIndex !== null && draggedIndex !== index) {
|
||||
moveComponent(draggedIndex, index);
|
||||
}
|
||||
setDraggedIndex(null);
|
||||
setDragOverIndex(null);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedIndex(null);
|
||||
setDragOverIndex(null);
|
||||
};
|
||||
|
||||
const moveComponent = (fromIndex: number, toIndex: number) => {
|
||||
const newComponents = [...components];
|
||||
const [movedItem] = newComponents.splice(fromIndex, 1);
|
||||
newComponents.splice(toIndex, 0, movedItem);
|
||||
setComponents(newComponents);
|
||||
};
|
||||
|
||||
// 컴포넌트 삭제
|
||||
const removeComponent = (id: string) => {
|
||||
setComponents(components.filter(c => c.id !== id));
|
||||
|
|
@ -189,13 +286,35 @@ export default function MailDesigner({
|
|||
<div className="flex h-screen bg-muted/30">
|
||||
{/* 왼쪽: 컴포넌트 팔레트 */}
|
||||
<div className="w-64 bg-white border-r p-4 space-y-4 overflow-y-auto">
|
||||
{/* 레이아웃 컴포넌트 */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center">
|
||||
<LayoutTemplate className="w-4 h-4 mr-2 text-indigo-500" />
|
||||
레이아웃
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{componentTypes.filter(c => c.category === "layout").map(({ type, icon: Icon, label, color }) => (
|
||||
<Button
|
||||
key={type}
|
||||
onClick={() => addComponent(type)}
|
||||
variant="outline"
|
||||
className={`w-full justify-start ${color} border`}
|
||||
>
|
||||
<Icon className="w-4 h-4 mr-2" />
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 컨텐츠 컴포넌트 */}
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-3 flex items-center">
|
||||
<Mail className="w-4 h-4 mr-2 text-primary" />
|
||||
컴포넌트
|
||||
컨텐츠
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{componentTypes.map(({ type, icon: Icon, label, color }) => (
|
||||
{componentTypes.filter(c => c.category === "content").map(({ type, icon: Icon, label, color }) => (
|
||||
<Button
|
||||
key={type}
|
||||
onClick={() => addComponent(type)}
|
||||
|
|
@ -274,24 +393,57 @@ export default function MailDesigner({
|
|||
)}
|
||||
|
||||
{/* 컴포넌트 렌더링 */}
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="p-6 pl-14 space-y-4">
|
||||
{components.length === 0 ? (
|
||||
<div className="text-center py-16 text-muted-foreground/50">
|
||||
<Mail className="w-16 h-16 mx-auto mb-4 opacity-20" />
|
||||
<p>왼쪽에서 컴포넌트를 추가하세요</p>
|
||||
</div>
|
||||
) : (
|
||||
components.map((comp) => (
|
||||
components.map((comp, index) => (
|
||||
<div
|
||||
key={comp.id}
|
||||
draggable
|
||||
onDragStart={() => handleDragStart(index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={() => handleDrop(index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onClick={() => selectComponent(comp.id)}
|
||||
className={`relative group cursor-pointer rounded-lg transition-all ${
|
||||
selectedComponent === comp.id
|
||||
? "ring-2 ring-orange-500 bg-orange-50/30"
|
||||
: "hover:ring-2 hover:ring-gray-300"
|
||||
} ${draggedIndex === index ? "opacity-50 scale-95" : ""} ${
|
||||
dragOverIndex === index ? "ring-2 ring-primary ring-dashed bg-primary/10" : ""
|
||||
}`}
|
||||
style={comp.styles}
|
||||
>
|
||||
{/* 드래그 핸들 & 순서 이동 버튼 */}
|
||||
<div className="absolute -left-10 top-1/2 -translate-y-1/2 flex flex-col items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); if (index > 0) moveComponent(index, index - 1); }}
|
||||
className="p-1 hover:bg-gray-200 rounded disabled:opacity-30"
|
||||
disabled={index === 0}
|
||||
>
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
</button>
|
||||
<div className="cursor-grab active:cursor-grabbing p-1 hover:bg-gray-200 rounded">
|
||||
<GripVertical className="w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); if (index < components.length - 1) moveComponent(index, index + 1); }}
|
||||
className="p-1 hover:bg-gray-200 rounded disabled:opacity-30"
|
||||
disabled={index === components.length - 1}
|
||||
>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 순서 배지 */}
|
||||
<div className="absolute -left-10 top-0 text-xs text-gray-400 opacity-0 group-hover:opacity-100">
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
|
|
@ -322,7 +474,82 @@ export default function MailDesigner({
|
|||
<img src={comp.src} alt="메일 이미지" className="w-full rounded" />
|
||||
)}
|
||||
{comp.type === "spacer" && (
|
||||
<div style={{ height: `${comp.height}px` }} />
|
||||
<div style={{ height: `${comp.height}px` }} className="bg-gray-100 rounded flex items-center justify-center text-xs text-gray-400">
|
||||
여백 {comp.height}px
|
||||
</div>
|
||||
)}
|
||||
{comp.type === "header" && (
|
||||
<div className="p-4 rounded-lg" style={{ backgroundColor: comp.headerBgColor || "#f8f9fa" }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{comp.logoSrc && <img src={comp.logoSrc} alt="로고" className="h-10" />}
|
||||
<span className="font-bold text-lg">{comp.brandName}</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500">{comp.sendDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{comp.type === "infoTable" && (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
{comp.tableTitle && (
|
||||
<div className="bg-gray-50 px-4 py-2 font-semibold border-b">{comp.tableTitle}</div>
|
||||
)}
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
{comp.rows?.map((row, i) => (
|
||||
<tr key={i} className={i % 2 === 0 ? "bg-white" : "bg-gray-50"}>
|
||||
<td className="px-4 py-2 font-medium text-gray-600 w-1/3 border-r">{row.label}</td>
|
||||
<td className="px-4 py-2">{row.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{comp.type === "alertBox" && (
|
||||
<div className={`p-4 rounded-lg border-l-4 ${
|
||||
comp.alertType === "info" ? "bg-blue-50 border-blue-500 text-blue-800" :
|
||||
comp.alertType === "warning" ? "bg-amber-50 border-amber-500 text-amber-800" :
|
||||
comp.alertType === "danger" ? "bg-red-50 border-red-500 text-red-800" :
|
||||
"bg-emerald-50 border-emerald-500 text-emerald-800"
|
||||
}`}>
|
||||
{comp.alertTitle && <div className="font-bold mb-1">{comp.alertTitle}</div>}
|
||||
<div>{comp.content}</div>
|
||||
</div>
|
||||
)}
|
||||
{comp.type === "divider" && (
|
||||
<hr className="border-gray-300" style={{ borderWidth: `${comp.height || 1}px` }} />
|
||||
)}
|
||||
{comp.type === "footer" && (
|
||||
<div className="text-center text-sm text-gray-500 py-4 border-t bg-gray-50">
|
||||
{comp.companyName && <div className="font-semibold text-gray-700">{comp.companyName}</div>}
|
||||
{(comp.ceoName || comp.businessNumber) && (
|
||||
<div className="mt-1">
|
||||
{comp.ceoName && <span>대표: {comp.ceoName}</span>}
|
||||
{comp.ceoName && comp.businessNumber && <span className="mx-2">|</span>}
|
||||
{comp.businessNumber && <span>사업자등록번호: {comp.businessNumber}</span>}
|
||||
</div>
|
||||
)}
|
||||
{comp.address && <div className="mt-1">{comp.address}</div>}
|
||||
{(comp.phone || comp.email) && (
|
||||
<div className="mt-1">
|
||||
{comp.phone && <span>Tel: {comp.phone}</span>}
|
||||
{comp.phone && comp.email && <span className="mx-2">|</span>}
|
||||
{comp.email && <span>Email: {comp.email}</span>}
|
||||
</div>
|
||||
)}
|
||||
{comp.copyright && <div className="mt-2 text-xs text-gray-400">{comp.copyright}</div>}
|
||||
</div>
|
||||
)}
|
||||
{comp.type === "numberedList" && (
|
||||
<div className="p-4">
|
||||
{comp.listTitle && <div className="font-semibold mb-2">{comp.listTitle}</div>}
|
||||
<ol className="list-decimal list-inside space-y-1">
|
||||
{comp.listItems?.map((item, i) => (
|
||||
<li key={i}>{item}</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
|
|
@ -571,13 +798,299 @@ export default function MailDesigner({
|
|||
/>
|
||||
<span className="text-sm text-muted-foreground">픽셀</span>
|
||||
</div>
|
||||
<div className="mt-3 p-3 bg-primary/10 rounded-lg border border-primary/20">
|
||||
<p className="text-xs text-primary">
|
||||
<strong>추천값:</strong><br/>
|
||||
• 좁은 간격: 10~20 픽셀<br/>
|
||||
• 보통 간격: 30~50 픽셀<br/>
|
||||
• 넓은 간격: 60~100 픽셀
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 헤더 컴포넌트 */}
|
||||
{selected.type === "header" && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>브랜드명</Label>
|
||||
<Input
|
||||
value={selected.brandName || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { brandName: e.target.value })}
|
||||
placeholder="회사명"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>로고 이미지 URL</Label>
|
||||
<Input
|
||||
value={selected.logoSrc || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { logoSrc: e.target.value })}
|
||||
placeholder="https://example.com/logo.png"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>발송일</Label>
|
||||
<Input
|
||||
value={selected.sendDate || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { sendDate: e.target.value })}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>배경색</Label>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<Input
|
||||
type="color"
|
||||
value={selected.headerBgColor || "#f8f9fa"}
|
||||
onChange={(e) => updateComponent(selected.id, { headerBgColor: e.target.value })}
|
||||
className="w-16 h-10"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">{selected.headerBgColor || "#f8f9fa"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 정보 테이블 컴포넌트 */}
|
||||
{selected.type === "infoTable" && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>테이블 제목</Label>
|
||||
<Input
|
||||
value={selected.tableTitle || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { tableTitle: e.target.value })}
|
||||
placeholder="예: 주문 정보"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>테이블 항목</Label>
|
||||
<div className="space-y-2 mt-2">
|
||||
{selected.rows?.map((row, i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<Input
|
||||
value={row.label}
|
||||
onChange={(e) => {
|
||||
const newRows = [...(selected.rows || [])];
|
||||
newRows[i] = { ...newRows[i], label: e.target.value };
|
||||
updateComponent(selected.id, { rows: newRows });
|
||||
}}
|
||||
placeholder="항목명"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
value={row.value}
|
||||
onChange={(e) => {
|
||||
const newRows = [...(selected.rows || [])];
|
||||
newRows[i] = { ...newRows[i], value: e.target.value };
|
||||
updateComponent(selected.id, { rows: newRows });
|
||||
}}
|
||||
placeholder="값"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newRows = selected.rows?.filter((_, idx) => idx !== i);
|
||||
updateComponent(selected.id, { rows: newRows });
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newRows = [...(selected.rows || []), { label: "", value: "" }];
|
||||
updateComponent(selected.id, { rows: newRows });
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
항목 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 안내 박스 컴포넌트 */}
|
||||
{selected.type === "alertBox" && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>박스 유형</Label>
|
||||
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||
{(["info", "warning", "danger", "success"] as const).map((type) => (
|
||||
<Button
|
||||
key={type}
|
||||
variant={selected.alertType === type ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => updateComponent(selected.id, { alertType: type })}
|
||||
className={
|
||||
type === "info" ? "border-blue-300" :
|
||||
type === "warning" ? "border-amber-300" :
|
||||
type === "danger" ? "border-red-300" : "border-emerald-300"
|
||||
}
|
||||
>
|
||||
{type === "info" ? "정보" : type === "warning" ? "주의" : type === "danger" ? "위험" : "성공"}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>제목</Label>
|
||||
<Input
|
||||
value={selected.alertTitle || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { alertTitle: e.target.value })}
|
||||
placeholder="안내 제목"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>내용</Label>
|
||||
<Textarea
|
||||
value={selected.content || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { content: e.target.value })}
|
||||
placeholder="안내 내용을 입력하세요"
|
||||
rows={4}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 구분선 컴포넌트 */}
|
||||
{selected.type === "divider" && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>선 두께</Label>
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<Input
|
||||
type="number"
|
||||
value={selected.height || 1}
|
||||
onChange={(e) => updateComponent(selected.id, { height: parseInt(e.target.value) || 1 })}
|
||||
className="w-24"
|
||||
min="1"
|
||||
max="10"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">픽셀</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 푸터 컴포넌트 */}
|
||||
{selected.type === "footer" && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>회사명</Label>
|
||||
<Input
|
||||
value={selected.companyName || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { companyName: e.target.value })}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>대표자</Label>
|
||||
<Input
|
||||
value={selected.ceoName || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { ceoName: e.target.value })}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>사업자등록번호</Label>
|
||||
<Input
|
||||
value={selected.businessNumber || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { businessNumber: e.target.value })}
|
||||
placeholder="000-00-00000"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>주소</Label>
|
||||
<Input
|
||||
value={selected.address || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { address: e.target.value })}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>전화번호</Label>
|
||||
<Input
|
||||
value={selected.phone || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { phone: e.target.value })}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>이메일</Label>
|
||||
<Input
|
||||
value={selected.email || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { email: e.target.value })}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>저작권 문구</Label>
|
||||
<Input
|
||||
value={selected.copyright || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { copyright: e.target.value })}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 번호 리스트 컴포넌트 */}
|
||||
{selected.type === "numberedList" && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>리스트 제목</Label>
|
||||
<Input
|
||||
value={selected.listTitle || ""}
|
||||
onChange={(e) => updateComponent(selected.id, { listTitle: e.target.value })}
|
||||
placeholder="예: 안내 사항"
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>항목</Label>
|
||||
<div className="space-y-2 mt-2">
|
||||
{selected.listItems?.map((item, i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<span className="flex items-center justify-center w-6 text-sm text-muted-foreground">{i + 1}.</span>
|
||||
<Input
|
||||
value={item}
|
||||
onChange={(e) => {
|
||||
const newItems = [...(selected.listItems || [])];
|
||||
newItems[i] = e.target.value;
|
||||
updateComponent(selected.id, { listItems: newItems });
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newItems = selected.listItems?.filter((_, idx) => idx !== i);
|
||||
updateComponent(selected.id, { listItems: newItems });
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newItems = [...(selected.listItems || []), ""];
|
||||
updateComponent(selected.id, { listItems: newItems });
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
항목 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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: "납기일",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
ResizableDialogContent,
|
||||
ResizableDialogHeader,
|
||||
ResizableDialogTitle,
|
||||
ResizableDialogDescription,
|
||||
ResizableDialogFooter,
|
||||
} from "@/components/ui/resizable-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -15,11 +14,13 @@ import { Label } from "@/components/ui/label";
|
|||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Search, X, Check, ChevronsUpDown, Database } from "lucide-react";
|
||||
import { Search, X, Check, ChevronsUpDown, Database, Globe } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { screenApi, tableTypeApi } from "@/lib/api/screen";
|
||||
import { ScreenDefinition } from "@/types/screen";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { ExternalRestApiConnectionAPI, ExternalRestApiConnection } from "@/lib/api/externalRestApiConnection";
|
||||
|
||||
interface CreateScreenModalProps {
|
||||
open: boolean;
|
||||
|
|
@ -39,12 +40,22 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|||
const [tableSearchTerm, setTableSearchTerm] = useState("");
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 데이터 소스 타입 (database: 데이터베이스, restapi: REST API)
|
||||
const [dataSourceType, setDataSourceType] = useState<"database" | "restapi">("database");
|
||||
|
||||
// 외부 DB 연결 관련 상태
|
||||
const [selectedDbSource, setSelectedDbSource] = useState<"internal" | number>("internal");
|
||||
const [externalConnections, setExternalConnections] = useState<any[]>([]);
|
||||
const [externalTableList, setExternalTableList] = useState<string[]>([]);
|
||||
const [loadingExternalTables, setLoadingExternalTables] = useState(false);
|
||||
const [openDbSourceCombobox, setOpenDbSourceCombobox] = useState(false);
|
||||
|
||||
// REST API 연결 관련 상태
|
||||
const [restApiConnections, setRestApiConnections] = useState<ExternalRestApiConnection[]>([]);
|
||||
const [selectedRestApiId, setSelectedRestApiId] = useState<number | null>(null);
|
||||
const [openRestApiCombobox, setOpenRestApiCombobox] = useState(false);
|
||||
const [restApiEndpoint, setRestApiEndpoint] = useState("");
|
||||
const [restApiJsonPath, setRestApiJsonPath] = useState("data"); // 응답에서 데이터 추출 경로
|
||||
// 화면 코드 자동 생성
|
||||
const generateCode = async () => {
|
||||
try {
|
||||
|
|
@ -109,6 +120,21 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|||
loadConnections();
|
||||
}, [open]);
|
||||
|
||||
// REST API 연결 목록 로드
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const loadRestApiConnections = async () => {
|
||||
try {
|
||||
const connections = await ExternalRestApiConnectionAPI.getConnections({ is_active: "Y" });
|
||||
setRestApiConnections(connections);
|
||||
} catch (error) {
|
||||
console.error("Failed to load REST API connections:", error);
|
||||
setRestApiConnections([]);
|
||||
}
|
||||
};
|
||||
loadRestApiConnections();
|
||||
}, [open]);
|
||||
|
||||
// 외부 DB 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
if (selectedDbSource === "internal" || !selectedDbSource) {
|
||||
|
|
@ -160,8 +186,15 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|||
}, [open, screenCode]);
|
||||
|
||||
const isValid = useMemo(() => {
|
||||
return screenName.trim().length > 0 && screenCode.trim().length > 0 && tableName.trim().length > 0;
|
||||
}, [screenName, screenCode, tableName]);
|
||||
const baseValid = screenName.trim().length > 0 && screenCode.trim().length > 0;
|
||||
|
||||
if (dataSourceType === "database") {
|
||||
return baseValid && tableName.trim().length > 0;
|
||||
} else {
|
||||
// REST API: 연결 선택 필수
|
||||
return baseValid && selectedRestApiId !== null;
|
||||
}
|
||||
}, [screenName, screenCode, tableName, dataSourceType, selectedRestApiId]);
|
||||
|
||||
// 테이블 필터링 (내부 DB용)
|
||||
const filteredTables = useMemo(() => {
|
||||
|
|
@ -186,17 +219,30 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|||
setSubmitting(true);
|
||||
const companyCode = (user as any)?.company_code || (user as any)?.companyCode || "*";
|
||||
|
||||
// DB 소스 정보 추가
|
||||
const created = await screenApi.createScreen({
|
||||
// 데이터 소스 타입에 따라 다른 정보 전달
|
||||
const createData: any = {
|
||||
screenName: screenName.trim(),
|
||||
screenCode: screenCode.trim(),
|
||||
tableName: tableName.trim(),
|
||||
companyCode,
|
||||
description: description.trim() || undefined,
|
||||
createdBy: (user as any)?.userId,
|
||||
dbSourceType: selectedDbSource === "internal" ? "internal" : "external",
|
||||
dbConnectionId: selectedDbSource === "internal" ? undefined : Number(selectedDbSource),
|
||||
} as any);
|
||||
dataSourceType: dataSourceType,
|
||||
};
|
||||
|
||||
if (dataSourceType === "database") {
|
||||
// 데이터베이스 소스
|
||||
createData.tableName = tableName.trim();
|
||||
createData.dbSourceType = selectedDbSource === "internal" ? "internal" : "external";
|
||||
createData.dbConnectionId = selectedDbSource === "internal" ? undefined : Number(selectedDbSource);
|
||||
} else {
|
||||
// REST API 소스
|
||||
createData.tableName = `_restapi_${selectedRestApiId}`; // REST API용 가상 테이블명
|
||||
createData.restApiConnectionId = selectedRestApiId;
|
||||
createData.restApiEndpoint = restApiEndpoint.trim() || undefined;
|
||||
createData.restApiJsonPath = restApiJsonPath.trim() || "data";
|
||||
}
|
||||
|
||||
const created = await screenApi.createScreen(createData);
|
||||
|
||||
// 날짜 필드 보정
|
||||
const mapped: ScreenDefinition = {
|
||||
|
|
@ -207,11 +253,16 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|||
|
||||
onCreated?.(mapped);
|
||||
onOpenChange(false);
|
||||
// 폼 초기화
|
||||
setScreenName("");
|
||||
setScreenCode("");
|
||||
setTableName("");
|
||||
setDescription("");
|
||||
setSelectedDbSource("internal");
|
||||
setDataSourceType("database");
|
||||
setSelectedRestApiId(null);
|
||||
setRestApiEndpoint("");
|
||||
setRestApiJsonPath("data");
|
||||
} catch (e) {
|
||||
// 필요 시 토스트 추가 가능
|
||||
} finally {
|
||||
|
|
@ -263,83 +314,210 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* DB 소스 선택 */}
|
||||
{/* 데이터 소스 타입 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dbSource">데이터베이스 소스</Label>
|
||||
<Popover open={openDbSourceCombobox} onOpenChange={setOpenDbSourceCombobox}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openDbSourceCombobox}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4" />
|
||||
{selectedDbSource === "internal"
|
||||
? "내부 데이터베이스"
|
||||
: externalConnections.find((conn) => conn.id === selectedDbSource)?.connection_name ||
|
||||
"선택하세요"}
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="데이터베이스 검색..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>데이터베이스를 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="internal"
|
||||
onSelect={() => {
|
||||
setSelectedDbSource("internal");
|
||||
setTableName("");
|
||||
setTableSearchTerm("");
|
||||
setOpenDbSourceCombobox(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-4 w-4", selectedDbSource === "internal" ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
<Database className="mr-2 h-4 w-4 text-blue-500" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">내부 데이터베이스</span>
|
||||
<span className="text-xs text-gray-500">PostgreSQL (현재 시스템)</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
{externalConnections.map((conn: any) => (
|
||||
<CommandItem
|
||||
key={conn.id}
|
||||
value={`${conn.connection_name} ${conn.db_type}`}
|
||||
onSelect={() => {
|
||||
setSelectedDbSource(conn.id);
|
||||
setTableName("");
|
||||
setTableSearchTerm("");
|
||||
setOpenDbSourceCombobox(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-4 w-4", selectedDbSource === conn.id ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
<Database className="mr-2 h-4 w-4 text-green-500" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{conn.connection_name}</span>
|
||||
<span className="text-xs text-gray-500">{conn.db_type?.toUpperCase()}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-xs text-gray-500">화면에서 사용할 데이터베이스를 선택합니다</p>
|
||||
<Label>데이터 소스 타입</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={dataSourceType === "database" ? "default" : "outline"}
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setDataSourceType("database");
|
||||
setSelectedRestApiId(null);
|
||||
}}
|
||||
>
|
||||
<Database className="mr-2 h-4 w-4" />
|
||||
데이터베이스
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={dataSourceType === "restapi" ? "default" : "outline"}
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setDataSourceType("restapi");
|
||||
setTableName("");
|
||||
setSelectedDbSource("internal");
|
||||
}}
|
||||
>
|
||||
<Globe className="mr-2 h-4 w-4" />
|
||||
REST API
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 선택 */}
|
||||
{/* 데이터베이스 소스 설정 */}
|
||||
{dataSourceType === "database" && (
|
||||
<>
|
||||
{/* DB 소스 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="dbSource">데이터베이스 소스</Label>
|
||||
<Popover open={openDbSourceCombobox} onOpenChange={setOpenDbSourceCombobox}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openDbSourceCombobox}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4" />
|
||||
{selectedDbSource === "internal"
|
||||
? "내부 데이터베이스"
|
||||
: externalConnections.find((conn) => conn.id === selectedDbSource)?.connection_name ||
|
||||
"선택하세요"}
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="데이터베이스 검색..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>데이터베이스를 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="internal"
|
||||
onSelect={() => {
|
||||
setSelectedDbSource("internal");
|
||||
setTableName("");
|
||||
setTableSearchTerm("");
|
||||
setOpenDbSourceCombobox(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-4 w-4", selectedDbSource === "internal" ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
<Database className="mr-2 h-4 w-4 text-blue-500" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">내부 데이터베이스</span>
|
||||
<span className="text-xs text-gray-500">PostgreSQL (현재 시스템)</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
{externalConnections.map((conn: any) => (
|
||||
<CommandItem
|
||||
key={conn.id}
|
||||
value={`${conn.connection_name} ${conn.db_type}`}
|
||||
onSelect={() => {
|
||||
setSelectedDbSource(conn.id);
|
||||
setTableName("");
|
||||
setTableSearchTerm("");
|
||||
setOpenDbSourceCombobox(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-4 w-4", selectedDbSource === conn.id ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
<Database className="mr-2 h-4 w-4 text-green-500" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{conn.connection_name}</span>
|
||||
<span className="text-xs text-gray-500">{conn.db_type?.toUpperCase()}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-xs text-gray-500">화면에서 사용할 데이터베이스를 선택합니다</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* REST API 소스 설정 */}
|
||||
{dataSourceType === "restapi" && (
|
||||
<>
|
||||
{/* REST API 연결 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="restApiConnection">REST API 연결 *</Label>
|
||||
<Popover open={openRestApiCombobox} onOpenChange={setOpenRestApiCombobox}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openRestApiCombobox}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
{selectedRestApiId
|
||||
? restApiConnections.find((conn) => conn.id === selectedRestApiId)?.connection_name ||
|
||||
"선택하세요"
|
||||
: "REST API 연결을 선택하세요"}
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="REST API 검색..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>등록된 REST API 연결이 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{restApiConnections.map((conn) => (
|
||||
<CommandItem
|
||||
key={conn.id}
|
||||
value={`${conn.connection_name} ${conn.base_url}`}
|
||||
onSelect={() => {
|
||||
setSelectedRestApiId(conn.id!);
|
||||
setRestApiEndpoint(conn.endpoint_path || "");
|
||||
setOpenRestApiCombobox(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-4 w-4", selectedRestApiId === conn.id ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
<Globe className="mr-2 h-4 w-4 text-purple-500" />
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{conn.connection_name}</span>
|
||||
<span className="text-xs text-gray-500 truncate max-w-[300px]">{conn.base_url}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="text-xs text-gray-500">
|
||||
등록된 REST API 연결을 선택합니다.
|
||||
<Link href="/admin/externalRestApi" className="ml-1 text-primary hover:underline">
|
||||
새 연결 등록
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 엔드포인트 경로 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="restApiEndpoint">엔드포인트 경로</Label>
|
||||
<Input
|
||||
id="restApiEndpoint"
|
||||
value={restApiEndpoint}
|
||||
onChange={(e) => setRestApiEndpoint(e.target.value)}
|
||||
placeholder="/api/data 또는 /users"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">기본 URL 뒤에 추가될 경로 (선택사항)</p>
|
||||
</div>
|
||||
|
||||
{/* JSON Path */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="restApiJsonPath">데이터 경로 (JSON Path)</Label>
|
||||
<Input
|
||||
id="restApiJsonPath"
|
||||
value={restApiJsonPath}
|
||||
onChange={(e) => setRestApiJsonPath(e.target.value)}
|
||||
placeholder="data 또는 result.items"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">API 응답에서 데이터 배열을 추출할 경로 (예: data, result.items)</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 테이블 선택 (데이터베이스 모드일 때만) */}
|
||||
{dataSourceType === "database" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tableName">테이블</Label>
|
||||
<Label htmlFor="tableName">테이블 *</Label>
|
||||
<Select
|
||||
value={tableName}
|
||||
onValueChange={setTableName}
|
||||
|
|
@ -422,11 +600,7 @@ export default function CreateScreenModal({ open, onOpenChange, onCreated }: Cre
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Input id="description" value={description} onChange={(e) => setDescription(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ResizableDialogFooter className="mt-4">
|
||||
|
|
|
|||
|
|
@ -408,6 +408,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
value: currentValue,
|
||||
onChange: (value: any) => handleFormDataChange(fieldName, value),
|
||||
onFormDataChange: handleFormDataChange,
|
||||
formData: formData, // 🆕 전체 formData 전달
|
||||
isInteractive: true,
|
||||
readonly: readonly,
|
||||
required: required,
|
||||
|
|
@ -415,6 +416,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
|||
className: "w-full h-full",
|
||||
isInModal: isInModal, // 🆕 EditModal 내부 여부 전달
|
||||
onSave: onSave, // 🆕 EditModal의 handleSave 콜백 전달
|
||||
groupedData: groupedData, // 🆕 그룹 데이터 전달 (RepeatScreenModal용)
|
||||
}}
|
||||
config={widget.webTypeConfig}
|
||||
onEvent={(event: string, data: any) => {
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ const calculateGridInfo = (width: number, height: number, settings: any) => {
|
|||
import { GroupingToolbar } from "./GroupingToolbar";
|
||||
import { screenApi, tableTypeApi } from "@/lib/api/screen";
|
||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
||||
import { ExternalRestApiConnectionAPI } from "@/lib/api/externalRestApiConnection";
|
||||
import { toast } from "sonner";
|
||||
import { MenuAssignmentModal } from "./MenuAssignmentModal";
|
||||
import { FileAttachmentDetailModal } from "./FileAttachmentDetailModal";
|
||||
|
|
@ -834,9 +835,52 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
}
|
||||
}, []);
|
||||
|
||||
// 화면의 기본 테이블 정보 로드 (원래대로 복원)
|
||||
// 화면의 기본 테이블/REST API 정보 로드
|
||||
useEffect(() => {
|
||||
const loadScreenTable = async () => {
|
||||
const loadScreenDataSource = async () => {
|
||||
// REST API 데이터 소스인 경우
|
||||
if (selectedScreen?.dataSourceType === "restapi" && selectedScreen?.restApiConnectionId) {
|
||||
try {
|
||||
const restApiData = await ExternalRestApiConnectionAPI.fetchData(
|
||||
selectedScreen.restApiConnectionId,
|
||||
selectedScreen.restApiEndpoint,
|
||||
selectedScreen.restApiJsonPath || "data",
|
||||
);
|
||||
|
||||
// REST API 응답에서 컬럼 정보 생성
|
||||
const columns: ColumnInfo[] = restApiData.columns.map((col) => ({
|
||||
tableName: `restapi_${selectedScreen.restApiConnectionId}`,
|
||||
columnName: col.columnName,
|
||||
columnLabel: col.columnLabel,
|
||||
dataType: col.dataType === "string" ? "varchar" : col.dataType === "number" ? "numeric" : col.dataType,
|
||||
webType: col.dataType === "number" ? "number" : "text",
|
||||
input_type: "text",
|
||||
widgetType: col.dataType === "number" ? "number" : "text",
|
||||
isNullable: "YES",
|
||||
required: false,
|
||||
}));
|
||||
|
||||
const tableInfo: TableInfo = {
|
||||
tableName: `restapi_${selectedScreen.restApiConnectionId}`,
|
||||
tableLabel: restApiData.connectionInfo.connectionName || "REST API 데이터",
|
||||
columns,
|
||||
};
|
||||
|
||||
setTables([tableInfo]);
|
||||
console.log("REST API 데이터 소스 로드 완료:", {
|
||||
connectionName: restApiData.connectionInfo.connectionName,
|
||||
columnsCount: columns.length,
|
||||
rowsCount: restApiData.total,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("REST API 데이터 소스 로드 실패:", error);
|
||||
toast.error("REST API 데이터를 불러오는데 실패했습니다.");
|
||||
setTables([]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 데이터베이스 데이터 소스인 경우 (기존 로직)
|
||||
const tableName = selectedScreen?.tableName;
|
||||
if (!tableName) {
|
||||
setTables([]);
|
||||
|
|
@ -858,16 +902,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
const columns: ColumnInfo[] = (columnsResponse || []).map((col: any) => {
|
||||
const widgetType = col.widgetType || col.widget_type || col.webType || col.web_type;
|
||||
|
||||
// 🔍 이미지 타입 디버깅
|
||||
// if (widgetType === "image" || col.webType === "image" || col.web_type === "image") {
|
||||
// console.log("🖼️ 이미지 컬럼 발견:", {
|
||||
// columnName: col.columnName || col.column_name,
|
||||
// widgetType,
|
||||
// webType: col.webType || col.web_type,
|
||||
// rawData: col,
|
||||
// });
|
||||
// }
|
||||
|
||||
return {
|
||||
tableName: col.tableName || tableName,
|
||||
columnName: col.columnName || col.column_name,
|
||||
|
|
@ -898,8 +932,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
}
|
||||
};
|
||||
|
||||
loadScreenTable();
|
||||
}, [selectedScreen?.tableName, selectedScreen?.screenName]);
|
||||
loadScreenDataSource();
|
||||
}, [selectedScreen?.tableName, selectedScreen?.screenName, selectedScreen?.dataSourceType, selectedScreen?.restApiConnectionId, selectedScreen?.restApiEndpoint, selectedScreen?.restApiJsonPath]);
|
||||
|
||||
// 화면 레이아웃 로드
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -869,27 +869,23 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
|||
});
|
||||
|
||||
// 래퍼 컴포넌트: 새 ConfigPanel 인터페이스를 기존 패턴에 맞춤
|
||||
const ConfigPanelWrapper = () => {
|
||||
// Section Card, Section Paper 등 신규 컴포넌트는 componentConfig 바로 아래에 설정 저장
|
||||
const config = currentConfig || definition.defaultProps?.componentConfig || {};
|
||||
|
||||
const handleConfigChange = (newConfig: any) => {
|
||||
// componentConfig 전체를 업데이트
|
||||
onUpdateProperty(selectedComponent.id, "componentConfig", newConfig);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 border-b pb-2">
|
||||
<Settings className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-semibold">{definition.name} 설정</h3>
|
||||
</div>
|
||||
<ConfigPanelComponent config={config} onChange={handleConfigChange} />
|
||||
</div>
|
||||
);
|
||||
// Section Card, Section Paper 등 신규 컴포넌트는 componentConfig 바로 아래에 설정 저장
|
||||
const config = currentConfig || definition.defaultProps?.componentConfig || {};
|
||||
|
||||
const handleConfigChange = (newConfig: any) => {
|
||||
// componentConfig 전체를 업데이트
|
||||
onUpdateProperty(selectedComponent.id, "componentConfig", newConfig);
|
||||
};
|
||||
|
||||
return <ConfigPanelWrapper key={selectedComponent.id} />;
|
||||
return (
|
||||
<div className="space-y-4" key={selectedComponent.id}>
|
||||
<div className="flex items-center gap-2 border-b pb-2">
|
||||
<Settings className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-semibold">{definition.name} 설정</h3>
|
||||
</div>
|
||||
<ConfigPanelComponent config={config} onChange={handleConfigChange} />
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
console.warn("⚠️ ConfigPanel 없음:", {
|
||||
componentId,
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
}) => {
|
||||
const { webTypes } = useWebTypes({ active: "Y" });
|
||||
const [localComponentDetailType, setLocalComponentDetailType] = useState<string>("");
|
||||
|
||||
|
||||
// 높이/너비 입력 로컬 상태 (자유 입력 허용)
|
||||
const [localHeight, setLocalHeight] = useState<string>("");
|
||||
const [localWidth, setLocalWidth] = useState<string>("");
|
||||
|
|
@ -147,7 +147,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
}
|
||||
}
|
||||
}, [selectedComponent?.type, selectedComponent?.componentConfig?.webType, selectedComponent?.id]);
|
||||
|
||||
|
||||
// 높이 값 동기화
|
||||
useEffect(() => {
|
||||
if (selectedComponent?.size?.height !== undefined) {
|
||||
|
|
@ -179,7 +179,10 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
// 최대 컬럼 수 계산
|
||||
const MIN_COLUMN_WIDTH = 30;
|
||||
const maxColumns = currentResolution
|
||||
? Math.floor((currentResolution.width - gridSettings.padding * 2 + gridSettings.gap) / (MIN_COLUMN_WIDTH + gridSettings.gap))
|
||||
? Math.floor(
|
||||
(currentResolution.width - gridSettings.padding * 2 + gridSettings.gap) /
|
||||
(MIN_COLUMN_WIDTH + gridSettings.gap),
|
||||
)
|
||||
: 24;
|
||||
const safeMaxColumns = Math.max(1, Math.min(maxColumns, 100)); // 최대 100개로 제한
|
||||
|
||||
|
|
@ -189,7 +192,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
<Grid3X3 className="text-primary h-3 w-3" />
|
||||
<h4 className="text-xs font-semibold">격자 설정</h4>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* 토글들 */}
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -226,9 +229,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
|
||||
{/* 10px 단위 스냅 안내 */}
|
||||
<div className="bg-muted/50 rounded-md p-2">
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
모든 컴포넌트는 10px 단위로 자동 배치됩니다.
|
||||
</p>
|
||||
<p className="text-muted-foreground text-[10px]">모든 컴포넌트는 10px 단위로 자동 배치됩니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -238,9 +239,9 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
// 컴포넌트가 선택되지 않았을 때도 해상도 설정과 격자 설정은 표시
|
||||
if (!selectedComponent) {
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-white">
|
||||
<div className="flex h-full flex-col overflow-x-auto bg-white">
|
||||
{/* 해상도 설정과 격자 설정 표시 */}
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
<div className="flex-1 overflow-x-auto overflow-y-auto p-2">
|
||||
<div className="space-y-4 text-xs">
|
||||
{/* 해상도 설정 */}
|
||||
{currentResolution && onResolutionChange && (
|
||||
|
|
@ -287,9 +288,9 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
if (!selectedComponent) return null;
|
||||
|
||||
// 🎯 Section Card, Section Paper 등 신규 컴포넌트는 componentType에서 감지
|
||||
const componentType =
|
||||
selectedComponent.componentType || // ⭐ 1순위: ScreenDesigner가 설정한 componentType (section-card 등)
|
||||
selectedComponent.componentConfig?.type ||
|
||||
const componentType =
|
||||
selectedComponent.componentType || // ⭐ 1순위: ScreenDesigner가 설정한 componentType (section-card 등)
|
||||
selectedComponent.componentConfig?.type ||
|
||||
selectedComponent.componentConfig?.id ||
|
||||
selectedComponent.type;
|
||||
|
||||
|
|
@ -305,15 +306,15 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
};
|
||||
|
||||
// 🆕 ComponentRegistry에서 ConfigPanel 가져오기 시도
|
||||
const componentId =
|
||||
selectedComponent.componentType || // ⭐ section-card 등
|
||||
selectedComponent.componentConfig?.type ||
|
||||
const componentId =
|
||||
selectedComponent.componentType || // ⭐ section-card 등
|
||||
selectedComponent.componentConfig?.type ||
|
||||
selectedComponent.componentConfig?.id ||
|
||||
(selectedComponent.type === "component" ? selectedComponent.id : null); // 🆕 독립 컴포넌트 (table-search-widget 등)
|
||||
|
||||
|
||||
if (componentId) {
|
||||
const definition = ComponentRegistry.getComponent(componentId);
|
||||
|
||||
|
||||
if (definition?.configPanel) {
|
||||
const ConfigPanelComponent = definition.configPanel;
|
||||
const currentConfig = selectedComponent.componentConfig || {};
|
||||
|
|
@ -327,12 +328,12 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
|
||||
// 🔧 ConfigPanelWrapper를 인라인 함수 대신 직접 JSX 반환 (리마운트 방지)
|
||||
const config = currentConfig || definition.defaultProps?.componentConfig || {};
|
||||
|
||||
|
||||
const handlePanelConfigChange = (newConfig: any) => {
|
||||
// 🔧 Partial 업데이트: 기존 componentConfig를 유지하면서 새 설정만 병합
|
||||
const mergedConfig = {
|
||||
...currentConfig, // 기존 설정 유지
|
||||
...newConfig, // 새 설정 병합
|
||||
...currentConfig, // 기존 설정 유지
|
||||
...newConfig, // 새 설정 병합
|
||||
};
|
||||
console.log("🔧 [ConfigPanel] handleConfigChange:", {
|
||||
componentId: selectedComponent.id,
|
||||
|
|
@ -346,16 +347,18 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
return (
|
||||
<div key={selectedComponent.id} className="space-y-4">
|
||||
<div className="flex items-center gap-2 border-b pb-2">
|
||||
<Settings className="h-4 w-4 text-primary" />
|
||||
<Settings className="text-primary h-4 w-4" />
|
||||
<h3 className="text-sm font-semibold">{definition.name} 설정</h3>
|
||||
</div>
|
||||
<Suspense fallback={
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-sm text-muted-foreground">설정 패널 로딩 중...</div>
|
||||
</div>
|
||||
}>
|
||||
<ConfigPanelComponent
|
||||
config={config}
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-muted-foreground text-sm">설정 패널 로딩 중...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ConfigPanelComponent
|
||||
config={config}
|
||||
onChange={handlePanelConfigChange}
|
||||
tables={tables} // 테이블 정보 전달
|
||||
allTables={allTables} // 🆕 전체 테이블 목록 전달 (selected-items-detail-input 등에서 사용)
|
||||
|
|
@ -423,9 +426,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">Section Card 설정</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
제목과 테두리가 있는 명확한 그룹화 컨테이너
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">제목과 테두리가 있는 명확한 그룹화 컨테이너</p>
|
||||
</div>
|
||||
|
||||
{/* 헤더 표시 */}
|
||||
|
|
@ -437,7 +438,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
handleUpdateProperty(selectedComponent.id, "componentConfig.showHeader", checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="showHeader" className="text-xs cursor-pointer">
|
||||
<Label htmlFor="showHeader" className="cursor-pointer text-xs">
|
||||
헤더 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -467,7 +468,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
handleUpdateProperty(selectedComponent.id, "componentConfig.description", e.target.value);
|
||||
}}
|
||||
placeholder="섹션 설명 입력"
|
||||
className="text-xs resize-none"
|
||||
className="resize-none text-xs"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -535,7 +536,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
</div>
|
||||
|
||||
{/* 접기/펼치기 기능 */}
|
||||
<div className="space-y-2 pt-2 border-t">
|
||||
<div className="space-y-2 border-t pt-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="collapsible"
|
||||
|
|
@ -544,13 +545,13 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
handleUpdateProperty(selectedComponent.id, "componentConfig.collapsible", checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="collapsible" className="text-xs cursor-pointer">
|
||||
<Label htmlFor="collapsible" className="cursor-pointer text-xs">
|
||||
접기/펼치기 가능
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{selectedComponent.componentConfig?.collapsible && (
|
||||
<div className="flex items-center space-x-2 ml-6">
|
||||
<div className="ml-6 flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="defaultOpen"
|
||||
checked={selectedComponent.componentConfig?.defaultOpen !== false}
|
||||
|
|
@ -558,7 +559,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
handleUpdateProperty(selectedComponent.id, "componentConfig.defaultOpen", checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="defaultOpen" className="text-xs cursor-pointer">
|
||||
<Label htmlFor="defaultOpen" className="cursor-pointer text-xs">
|
||||
기본으로 펼치기
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -572,9 +573,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">Section Paper 설정</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
배경색 기반의 미니멀한 그룹화 컨테이너
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">배경색 기반의 미니멀한 그룹화 컨테이너</p>
|
||||
</div>
|
||||
|
||||
{/* 배경색 */}
|
||||
|
|
@ -685,7 +684,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
handleUpdateProperty(selectedComponent.id, "componentConfig.showBorder", checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="showBorder" className="text-xs cursor-pointer">
|
||||
<Label htmlFor="showBorder" className="cursor-pointer text-xs">
|
||||
미묘한 테두리 표시
|
||||
</Label>
|
||||
</div>
|
||||
|
|
@ -696,9 +695,9 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
// ConfigPanel이 없는 경우 경고 표시
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center p-6 text-center">
|
||||
<Settings className="mb-4 h-12 w-12 text-muted-foreground" />
|
||||
<Settings className="text-muted-foreground mb-4 h-12 w-12" />
|
||||
<h3 className="mb-2 text-base font-medium">⚠️ 설정 패널 없음</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
컴포넌트 "{componentId || componentType}"에 대한 설정 패널이 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -1423,7 +1422,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
</div>
|
||||
|
||||
{/* 통합 컨텐츠 (탭 제거) */}
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
<div className="flex-1 overflow-x-auto overflow-y-auto p-2">
|
||||
<div className="space-y-4 text-xs">
|
||||
{/* 해상도 설정 - 항상 맨 위에 표시 */}
|
||||
{currentResolution && onResolutionChange && (
|
||||
|
|
|
|||
|
|
@ -176,7 +176,7 @@ const ResizableDialogContent = React.forwardRef<
|
|||
height: Math.max(minHeight, Math.min(maxHeight, parsed.height)),
|
||||
userResized: true,
|
||||
};
|
||||
console.log("💾 사용자가 리사이징한 크기 복원:", savedSize);
|
||||
// console.log("💾 사용자가 리사이징한 크기 복원:", savedSize);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -221,11 +221,11 @@ export const useAuth = () => {
|
|||
|
||||
setAuthStatus(finalAuthStatus);
|
||||
|
||||
console.log("✅ 최종 사용자 상태:", {
|
||||
userId: userInfo?.userId,
|
||||
userName: userInfo?.userName,
|
||||
companyCode: userInfo?.companyCode || userInfo?.company_code,
|
||||
});
|
||||
// console.log("✅ 최종 사용자 상태:", {
|
||||
// userId: userInfo?.userId,
|
||||
// userName: userInfo?.userName,
|
||||
// companyCode: userInfo?.companyCode || userInfo?.company_code,
|
||||
// });
|
||||
|
||||
// 디버깅용 로그
|
||||
|
||||
|
|
|
|||
|
|
@ -120,13 +120,14 @@ class BatchManagementAPIClass {
|
|||
apiUrl: string,
|
||||
apiKey: string,
|
||||
endpoint: string,
|
||||
method: 'GET' = 'GET',
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET',
|
||||
paramInfo?: {
|
||||
paramType: 'url' | 'query';
|
||||
paramName: string;
|
||||
paramValue: string;
|
||||
paramSource: 'static' | 'dynamic';
|
||||
}
|
||||
},
|
||||
requestBody?: string
|
||||
): Promise<{
|
||||
fields: string[];
|
||||
samples: any[];
|
||||
|
|
@ -137,7 +138,8 @@ class BatchManagementAPIClass {
|
|||
apiUrl,
|
||||
apiKey,
|
||||
endpoint,
|
||||
method
|
||||
method,
|
||||
requestBody
|
||||
};
|
||||
|
||||
// 파라미터 정보가 있으면 추가
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { apiClient } from "./client";
|
||||
|
||||
export type AuthType = "none" | "api-key" | "bearer" | "basic" | "oauth2";
|
||||
export type AuthType = "none" | "api-key" | "bearer" | "basic" | "oauth2" | "db-token";
|
||||
|
||||
export interface ExternalRestApiConnection {
|
||||
id?: number;
|
||||
|
|
@ -11,18 +11,34 @@ export interface ExternalRestApiConnection {
|
|||
base_url: string;
|
||||
endpoint_path?: string;
|
||||
default_headers: Record<string, string>;
|
||||
// 기본 메서드 및 바디 추가
|
||||
default_method?: string;
|
||||
default_body?: string;
|
||||
|
||||
auth_type: AuthType;
|
||||
auth_config?: {
|
||||
// API Key
|
||||
keyLocation?: "header" | "query";
|
||||
keyName?: string;
|
||||
keyValue?: string;
|
||||
// Bearer Token
|
||||
token?: string;
|
||||
// Basic Auth
|
||||
username?: string;
|
||||
password?: string;
|
||||
// OAuth2
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
tokenUrl?: string;
|
||||
accessToken?: string;
|
||||
|
||||
// DB 기반 토큰 모드
|
||||
dbTableName?: string;
|
||||
dbValueColumn?: string;
|
||||
dbWhereColumn?: string;
|
||||
dbWhereValue?: string;
|
||||
dbHeaderName?: string;
|
||||
dbHeaderTemplate?: string;
|
||||
};
|
||||
timeout?: number;
|
||||
retry_count?: number;
|
||||
|
|
@ -49,9 +65,11 @@ export interface RestApiTestRequest {
|
|||
id?: number;
|
||||
base_url: string;
|
||||
endpoint?: string;
|
||||
method?: "GET" | "POST" | "PUT" | "DELETE";
|
||||
method?: "GET" | "POST" | "PUT" | "DELETE" | "PATCH";
|
||||
headers?: Record<string, string>;
|
||||
body?: unknown; // 테스트 요청 바디 추가
|
||||
auth_type?: AuthType;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
auth_config?: any;
|
||||
timeout?: number;
|
||||
}
|
||||
|
|
@ -61,7 +79,7 @@ export interface RestApiTestResult {
|
|||
message: string;
|
||||
response_time?: number;
|
||||
status_code?: number;
|
||||
response_data?: any;
|
||||
response_data?: unknown;
|
||||
error_details?: string;
|
||||
}
|
||||
|
||||
|
|
@ -71,7 +89,7 @@ export interface ApiResponse<T> {
|
|||
message?: string;
|
||||
error?: {
|
||||
code: string;
|
||||
details?: any;
|
||||
details?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -174,6 +192,43 @@ export class ExternalRestApiConnectionAPI {
|
|||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* REST API 데이터 조회 (화면관리용 프록시)
|
||||
*/
|
||||
static async fetchData(
|
||||
connectionId: number,
|
||||
endpoint?: string,
|
||||
jsonPath?: string,
|
||||
): Promise<{
|
||||
rows: any[];
|
||||
columns: Array<{ columnName: string; columnLabel: string; dataType: string }>;
|
||||
total: number;
|
||||
connectionInfo: {
|
||||
connectionId: number;
|
||||
connectionName: string;
|
||||
baseUrl: string;
|
||||
endpoint: string;
|
||||
};
|
||||
}> {
|
||||
const response = await apiClient.post<ApiResponse<{
|
||||
rows: any[];
|
||||
columns: Array<{ columnName: string; columnLabel: string; dataType: string }>;
|
||||
total: number;
|
||||
connectionInfo: {
|
||||
connectionId: number;
|
||||
connectionName: string;
|
||||
baseUrl: string;
|
||||
endpoint: string;
|
||||
};
|
||||
}>>(`${this.BASE_PATH}/${connectionId}/fetch`, { endpoint, jsonPath });
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "REST API 데이터 조회에 실패했습니다.");
|
||||
}
|
||||
|
||||
return response.data.data!;
|
||||
}
|
||||
|
||||
/**
|
||||
* 지원되는 인증 타입 목록
|
||||
*/
|
||||
|
|
@ -184,6 +239,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 토큰" },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,13 +42,35 @@ export interface UpdateMailAccountDto extends Partial<CreateMailAccountDto> {
|
|||
|
||||
export interface MailComponent {
|
||||
id: string;
|
||||
type: 'text' | 'button' | 'image' | 'spacer';
|
||||
type: 'text' | 'button' | 'image' | 'spacer' | 'header' | 'infoTable' | 'alertBox' | 'divider' | 'footer' | 'numberedList';
|
||||
content?: string;
|
||||
text?: string;
|
||||
url?: string;
|
||||
src?: string;
|
||||
height?: number;
|
||||
styles?: Record<string, string>;
|
||||
// 헤더 컴포넌트용
|
||||
logoSrc?: string;
|
||||
brandName?: string;
|
||||
sendDate?: string;
|
||||
headerBgColor?: string;
|
||||
// 정보 테이블용
|
||||
rows?: Array<{ label: string; value: string }>;
|
||||
tableTitle?: string;
|
||||
// 강조 박스용
|
||||
alertType?: 'info' | 'warning' | 'danger' | 'success';
|
||||
alertTitle?: string;
|
||||
// 푸터용
|
||||
companyName?: string;
|
||||
ceoName?: string;
|
||||
businessNumber?: string;
|
||||
address?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
copyright?: string;
|
||||
// 번호 리스트용
|
||||
listItems?: string[];
|
||||
listTitle?: string;
|
||||
}
|
||||
|
||||
export interface MailTemplate {
|
||||
|
|
@ -470,6 +492,95 @@ export function renderTemplateToHtml(
|
|||
case 'spacer':
|
||||
html += `<div style="height: ${component.height || 20}px;"></div>`;
|
||||
break;
|
||||
|
||||
case 'header':
|
||||
html += `
|
||||
<div style="padding: 20px; background-color: ${component.headerBgColor || '#f8f9fa'}; border-radius: 8px; margin-bottom: 20px;">
|
||||
<table style="width: 100%;">
|
||||
<tr>
|
||||
<td style="vertical-align: middle;">
|
||||
${component.logoSrc ? `<img src="${component.logoSrc}" alt="로고" style="height: 40px; margin-right: 12px;">` : ''}
|
||||
<span style="font-size: 18px; font-weight: bold;">${component.brandName || ''}</span>
|
||||
</td>
|
||||
<td style="text-align: right; color: #6b7280; font-size: 14px;">
|
||||
${component.sendDate || ''}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
|
||||
case 'infoTable':
|
||||
html += `
|
||||
<div style="border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; margin: 16px 0;">
|
||||
${component.tableTitle ? `<div style="background-color: #f9fafb; padding: 12px 16px; font-weight: 600; border-bottom: 1px solid #e5e7eb;">${component.tableTitle}</div>` : ''}
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
${(component.rows || []).map((row, i) => `
|
||||
<tr style="background-color: ${i % 2 === 0 ? '#ffffff' : '#f9fafb'};">
|
||||
<td style="padding: 12px 16px; font-weight: 500; color: #4b5563; width: 35%; border-right: 1px solid #e5e7eb;">${row.label}</td>
|
||||
<td style="padding: 12px 16px;">${row.value}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
|
||||
case 'alertBox':
|
||||
const alertColors = {
|
||||
info: { bg: '#eff6ff', border: '#3b82f6', text: '#1e40af' },
|
||||
warning: { bg: '#fffbeb', border: '#f59e0b', text: '#92400e' },
|
||||
danger: { bg: '#fef2f2', border: '#ef4444', text: '#991b1b' },
|
||||
success: { bg: '#ecfdf5', border: '#10b981', text: '#065f46' }
|
||||
};
|
||||
const colors = alertColors[component.alertType || 'info'];
|
||||
html += `
|
||||
<div style="padding: 16px; background-color: ${colors.bg}; border-left: 4px solid ${colors.border}; border-radius: 4px; margin: 16px 0; color: ${colors.text};">
|
||||
${component.alertTitle ? `<div style="font-weight: bold; margin-bottom: 8px;">${component.alertTitle}</div>` : ''}
|
||||
<div>${component.content || ''}</div>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
|
||||
case 'divider':
|
||||
html += `<hr style="border: none; border-top: ${component.height || 1}px solid #e5e7eb; margin: 20px 0;">`;
|
||||
break;
|
||||
|
||||
case 'footer':
|
||||
html += `
|
||||
<div style="text-align: center; padding: 24px 16px; background-color: #f9fafb; border-top: 1px solid #e5e7eb; font-size: 14px; color: #6b7280;">
|
||||
${component.companyName ? `<div style="font-weight: 600; color: #374151; margin-bottom: 8px;">${component.companyName}</div>` : ''}
|
||||
${(component.ceoName || component.businessNumber) ? `
|
||||
<div style="margin-bottom: 4px;">
|
||||
${component.ceoName ? `대표: ${component.ceoName}` : ''}
|
||||
${component.ceoName && component.businessNumber ? ' | ' : ''}
|
||||
${component.businessNumber ? `사업자등록번호: ${component.businessNumber}` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
${component.address ? `<div style="margin-bottom: 4px;">${component.address}</div>` : ''}
|
||||
${(component.phone || component.email) ? `
|
||||
<div style="margin-bottom: 4px;">
|
||||
${component.phone ? `Tel: ${component.phone}` : ''}
|
||||
${component.phone && component.email ? ' | ' : ''}
|
||||
${component.email ? `Email: ${component.email}` : ''}
|
||||
</div>
|
||||
` : ''}
|
||||
${component.copyright ? `<div style="margin-top: 12px; font-size: 12px; color: #9ca3af;">${component.copyright}</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
|
||||
case 'numberedList':
|
||||
html += `
|
||||
<div style="padding: 16px; ${styleObjectToString(component.styles)}">
|
||||
${component.listTitle ? `<div style="font-weight: 600; margin-bottom: 12px;">${component.listTitle}</div>` : ''}
|
||||
<ol style="margin: 0; padding-left: 20px;">
|
||||
${(component.listItems || []).map(item => `<li style="margin-bottom: 8px;">${item}</li>`).join('')}
|
||||
</ol>
|
||||
</div>
|
||||
`;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ class CodeCache {
|
|||
* 여러 코드 카테고리를 배치로 미리 로딩
|
||||
*/
|
||||
async preloadCodes(categories: string[]): Promise<void> {
|
||||
console.log(`🔄 코드 배치 로딩 시작: ${categories.join(", ")}`);
|
||||
// console.log(`🔄 코드 배치 로딩 시작: ${categories.join(", ")}`);
|
||||
|
||||
const promises = categories.map(async (category) => {
|
||||
try {
|
||||
|
|
@ -101,7 +101,7 @@ class CodeCache {
|
|||
if (response.success && response.data) {
|
||||
const cacheKey = this.createCodeKey(category);
|
||||
this.set(cacheKey, response.data, this.defaultTTL);
|
||||
console.log(`✅ 코드 로딩 완료: ${category} (${response.data.length}개)`);
|
||||
// console.log(`✅ 코드 로딩 완료: ${category} (${response.data.length}개)`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 코드 로딩 실패: ${category}`, error);
|
||||
|
|
@ -109,7 +109,7 @@ class CodeCache {
|
|||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
console.log(`✅ 코드 배치 로딩 완료: ${categories.length}개 카테고리`);
|
||||
// console.log(`✅ 코드 배치 로딩 완료: ${categories.length}개 카테고리`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ export function useEntityJoinOptimization(columnMeta: Record<string, ColumnMetaI
|
|||
batches.push(categories.slice(i, i + maxBatchSize));
|
||||
}
|
||||
|
||||
console.log(`🔄 배치 코드 로딩 시작: ${categories.length}개 카테고리 (${batches.length}개 배치)`);
|
||||
// console.log(`🔄 배치 코드 로딩 시작: ${categories.length}개 카테고리 (${batches.length}개 배치)`);
|
||||
|
||||
for (const batch of batches) {
|
||||
// 로딩 상태 업데이트
|
||||
|
|
@ -125,7 +125,7 @@ export function useEntityJoinOptimization(columnMeta: Record<string, ColumnMetaI
|
|||
const responseTime = Date.now() - startTime;
|
||||
requestTimes.current.push(responseTime);
|
||||
|
||||
console.log(`✅ 배치 코드 로딩 완료: ${responseTime}ms`);
|
||||
// console.log(`✅ 배치 코드 로딩 완료: ${responseTime}ms`);
|
||||
} catch (error) {
|
||||
console.error("❌ 배치 코드 로딩 실패:", error);
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -72,5 +72,5 @@ ComponentRegistry.registerComponent({
|
|||
},
|
||||
});
|
||||
|
||||
console.log("✅ 카테고리 관리 컴포넌트 등록 완료");
|
||||
// console.log("✅ 카테고리 관리 컴포넌트 등록 완료");
|
||||
|
||||
|
|
|
|||
|
|
@ -6,5 +6,5 @@ import { CustomerItemMappingDefinition } from "./index";
|
|||
// 컴포넌트 자동 등록
|
||||
ComponentRegistry.registerComponent(CustomerItemMappingDefinition);
|
||||
|
||||
console.log("✅ CustomerItemMapping 컴포넌트 등록 완료");
|
||||
// console.log("✅ CustomerItemMapping 컴포넌트 등록 완료");
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ import "./customer-item-mapping/CustomerItemMappingRenderer"; // 🆕 거래처
|
|||
import "./autocomplete-search-input/AutocompleteSearchInputRenderer";
|
||||
import "./entity-search-input/EntitySearchInputRenderer";
|
||||
import "./modal-repeater-table/ModalRepeaterTableRenderer";
|
||||
import "./simple-repeater-table/SimpleRepeaterTableRenderer"; // 🆕 단순 반복 테이블
|
||||
import "./repeat-screen-modal/RepeatScreenModalRenderer"; // 🆕 반복 화면 모달 (카드 형태)
|
||||
import "./order-registration-modal/OrderRegistrationModalRenderer";
|
||||
|
||||
// 🆕 조건부 컨테이너 컴포넌트
|
||||
|
|
|
|||
|
|
@ -197,13 +197,6 @@ export function ModalRepeaterTableComponent({
|
|||
|
||||
// ✅ onChange 래퍼 (기존 onChange 콜백 + onFormDataChange 호출 + 납기일 일괄 적용)
|
||||
const handleChange = (newData: any[]) => {
|
||||
console.log("🔄 ModalRepeaterTableComponent.handleChange 호출:", {
|
||||
dataLength: newData.length,
|
||||
columnName,
|
||||
hasExternalOnChange: !!(componentConfig?.onChange || propOnChange),
|
||||
hasOnFormDataChange: !!(onFormDataChange && columnName),
|
||||
});
|
||||
|
||||
// 🆕 납기일 일괄 적용 로직 (납기일 필드가 있는 경우만)
|
||||
let processedData = newData;
|
||||
|
||||
|
|
@ -229,22 +222,41 @@ export function ModalRepeaterTableComponent({
|
|||
}));
|
||||
|
||||
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,
|
||||
}));
|
||||
|
||||
console.log("✅ 납기일 일괄 적용 완료:", selectedDate);
|
||||
console.log(` - 대상: ${itemsWithoutDate.length}개 행에 ${selectedDate} 적용`);
|
||||
setIsOrderDateApplied(true); // 플래그 활성화
|
||||
}
|
||||
}
|
||||
|
||||
// 기존 onChange 콜백 호출 (호환성)
|
||||
const externalOnChange = componentConfig?.onChange || propOnChange;
|
||||
if (externalOnChange) {
|
||||
console.log("📤 외부 onChange 호출");
|
||||
externalOnChange(processedData);
|
||||
}
|
||||
|
||||
// 🆕 onFormDataChange 호출하여 EditModal의 groupData 업데이트
|
||||
if (onFormDataChange && columnName) {
|
||||
console.log("📤 onFormDataChange 호출:", columnName);
|
||||
onFormDataChange(columnName, processedData);
|
||||
}
|
||||
};
|
||||
|
|
@ -261,6 +273,9 @@ export function ModalRepeaterTableComponent({
|
|||
|
||||
// 🆕 납기일 일괄 적용 플래그 (딱 한 번만 실행)
|
||||
const [isDeliveryDateApplied, setIsDeliveryDateApplied] = useState(false);
|
||||
|
||||
// 🆕 수주일 일괄 적용 플래그 (딱 한 번만 실행)
|
||||
const [isOrderDateApplied, setIsOrderDateApplied] = useState(false);
|
||||
|
||||
// columns가 비어있으면 sourceColumns로부터 자동 생성
|
||||
const columns = React.useMemo((): RepeaterColumnConfig[] => {
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,206 @@
|
|||
# RepeatScreenModal 컴포넌트 v3
|
||||
|
||||
## 개요
|
||||
|
||||
`RepeatScreenModal`은 선택한 데이터를 기반으로 여러 개의 카드를 생성하고, 각 카드의 내부 레이아웃을 자유롭게 구성할 수 있는 컴포넌트입니다.
|
||||
|
||||
## v3 주요 변경사항
|
||||
|
||||
### 자유 레이아웃 시스템
|
||||
|
||||
기존의 "simple 모드 / withTable 모드" 구분을 없애고, **행(Row)을 추가하고 각 행마다 타입을 선택**하는 방식으로 변경되었습니다.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 카드 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ [행 1] 타입: 헤더 → 품목코드, 품목명, 규격 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ [행 2] 타입: 집계 → 총수주잔량, 현재고, 가용재고 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ [행 3] 타입: 테이블 → 수주번호, 거래처, 납기일, 출하계획 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ [행 4] 타입: 테이블 → 또 다른 테이블도 가능! │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 행 타입
|
||||
|
||||
| 타입 | 설명 | 사용 시나리오 |
|
||||
|------|------|---------------|
|
||||
| **헤더 (header)** | 필드들을 가로/세로로 나열 | 품목정보, 거래처정보 표시 |
|
||||
| **필드 (fields)** | 헤더와 동일, 편집 가능 | 폼 입력 영역 |
|
||||
| **집계 (aggregation)** | 그룹 내 데이터 집계값 표시 | 총수량, 합계금액 등 |
|
||||
| **테이블 (table)** | 그룹 내 각 행을 테이블로 표시 | 수주목록, 품목목록 등 |
|
||||
|
||||
### 자유로운 조합
|
||||
|
||||
```
|
||||
예시 1: 헤더 + 집계 + 테이블 (출하계획)
|
||||
├── [행 1] 헤더: 품목코드, 품목명
|
||||
├── [행 2] 집계: 총수주잔량, 현재고
|
||||
└── [행 3] 테이블: 수주별 출하계획
|
||||
|
||||
예시 2: 집계만
|
||||
└── [행 1] 집계: 총매출, 총비용, 순이익
|
||||
|
||||
예시 3: 테이블만
|
||||
└── [행 1] 테이블: 품목 목록
|
||||
|
||||
예시 4: 테이블 2개
|
||||
├── [행 1] 테이블: 입고 내역
|
||||
└── [행 2] 테이블: 출고 내역
|
||||
|
||||
예시 5: 헤더 + 헤더 + 필드
|
||||
├── [행 1] 헤더: 기본 정보 (읽기전용)
|
||||
├── [행 2] 헤더: 상세 정보 (읽기전용)
|
||||
└── [행 3] 필드: 입력 필드 (편집가능)
|
||||
```
|
||||
|
||||
## 설정 방법
|
||||
|
||||
### 1. 기본 설정 탭
|
||||
|
||||
- **카드 제목 표시**: 카드 상단에 제목을 표시할지 여부
|
||||
- **카드 제목 템플릿**: `{field_name}` 형식으로 동적 제목 생성
|
||||
- **카드 간격**: 카드 사이의 간격 (8px ~ 32px)
|
||||
- **테두리**: 카드 테두리 표시 여부
|
||||
- **저장 모드**: 전체 저장 / 개별 저장
|
||||
|
||||
### 2. 데이터 소스 탭
|
||||
|
||||
- **소스 테이블**: 데이터를 조회할 테이블
|
||||
- **필터 필드**: formData에서 필터링할 필드 (예: selectedIds)
|
||||
|
||||
### 3. 그룹 탭
|
||||
|
||||
- **그룹핑 활성화**: 여러 행을 하나의 카드로 묶을지 여부
|
||||
- **그룹 기준 필드**: 그룹핑할 필드 (예: part_code)
|
||||
- **집계 설정**:
|
||||
- 원본 필드: 합계할 필드 (예: balance_qty)
|
||||
- 집계 타입: sum, count, avg, min, max
|
||||
- 결과 필드명: 집계 결과를 저장할 필드명
|
||||
- 라벨: 표시될 라벨
|
||||
|
||||
### 4. 레이아웃 탭
|
||||
|
||||
#### 행 추가
|
||||
|
||||
4가지 타입의 행을 추가할 수 있습니다:
|
||||
- **헤더**: 필드 정보 표시 (읽기전용)
|
||||
- **집계**: 그룹 집계값 표시
|
||||
- **테이블**: 그룹 내 행들을 테이블로 표시
|
||||
- **필드**: 입력 필드 (편집가능)
|
||||
|
||||
#### 헤더/필드 행 설정
|
||||
|
||||
- **방향**: 가로 / 세로
|
||||
- **배경색**: 없음, 파랑, 초록, 보라, 주황
|
||||
- **컬럼**: 필드명, 라벨, 타입, 너비, 편집 가능, 필수
|
||||
- **소스 설정**: 직접 / 조인 / 수동
|
||||
- **저장 설정**: 저장할 테이블과 컬럼
|
||||
|
||||
#### 집계 행 설정
|
||||
|
||||
- **레이아웃**: 가로 나열 / 그리드
|
||||
- **그리드 컬럼 수**: 2, 3, 4개
|
||||
- **집계 필드**: 그룹 탭에서 정의한 집계 결과 선택
|
||||
- **스타일**: 배경색, 폰트 크기
|
||||
|
||||
#### 테이블 행 설정
|
||||
|
||||
- **테이블 제목**: 선택사항
|
||||
- **헤더 표시**: 테이블 헤더 표시 여부
|
||||
- **테이블 컬럼**: 필드명, 라벨, 타입, 너비, 편집 가능
|
||||
- **저장 설정**: 편집 가능한 컬럼의 저장 위치
|
||||
|
||||
## 데이터 흐름
|
||||
|
||||
```
|
||||
1. formData에서 selectedIds 가져오기
|
||||
↓
|
||||
2. 소스 테이블에서 해당 ID들의 데이터 조회
|
||||
↓
|
||||
3. 그룹핑 활성화 시 groupByField 기준으로 그룹화
|
||||
↓
|
||||
4. 각 그룹에 대해 집계값 계산
|
||||
↓
|
||||
5. 카드 렌더링 (contentRows 기반)
|
||||
↓
|
||||
6. 사용자 편집
|
||||
↓
|
||||
7. 저장 시 targetConfig에 따라 테이블별로 데이터 분류 후 저장
|
||||
```
|
||||
|
||||
## 사용 예시
|
||||
|
||||
### 출하계획 등록
|
||||
|
||||
```typescript
|
||||
{
|
||||
showCardTitle: true,
|
||||
cardTitle: "{part_code} - {part_name}",
|
||||
dataSource: {
|
||||
sourceTable: "sales_order_mng",
|
||||
filterField: "selectedIds"
|
||||
},
|
||||
grouping: {
|
||||
enabled: true,
|
||||
groupByField: "part_code",
|
||||
aggregations: [
|
||||
{ sourceField: "balance_qty", type: "sum", resultField: "total_balance", label: "총수주잔량" },
|
||||
{ sourceField: "id", type: "count", resultField: "order_count", label: "수주건수" }
|
||||
]
|
||||
},
|
||||
contentRows: [
|
||||
{
|
||||
id: "row-1",
|
||||
type: "header",
|
||||
columns: [
|
||||
{ id: "c1", field: "part_code", label: "품목코드", type: "text", editable: false },
|
||||
{ id: "c2", field: "part_name", label: "품목명", type: "text", editable: false }
|
||||
],
|
||||
layout: "horizontal"
|
||||
},
|
||||
{
|
||||
id: "row-2",
|
||||
type: "aggregation",
|
||||
aggregationLayout: "horizontal",
|
||||
aggregationFields: [
|
||||
{ aggregationResultField: "total_balance", label: "총수주잔량", backgroundColor: "blue" },
|
||||
{ aggregationResultField: "order_count", label: "수주건수", backgroundColor: "green" }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "row-3",
|
||||
type: "table",
|
||||
tableTitle: "수주 목록",
|
||||
showTableHeader: true,
|
||||
tableColumns: [
|
||||
{ id: "tc1", field: "order_no", label: "수주번호", type: "text", editable: false },
|
||||
{ id: "tc2", field: "partner_name", label: "거래처", type: "text", editable: false },
|
||||
{ id: "tc3", field: "balance_qty", label: "미출하", type: "number", editable: false },
|
||||
{
|
||||
id: "tc4",
|
||||
field: "plan_qty",
|
||||
label: "출하계획",
|
||||
type: "number",
|
||||
editable: true,
|
||||
targetConfig: { targetTable: "shipment_plan", targetColumn: "plan_qty", saveEnabled: true }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 레거시 호환
|
||||
|
||||
v2에서 사용하던 `cardMode`, `cardLayout`, `tableLayout` 설정도 계속 지원됩니다.
|
||||
새로운 프로젝트에서는 `contentRows`를 사용하는 것을 권장합니다.
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. **집계는 그룹핑 필수**: 집계 행은 그룹핑이 활성화되어 있어야 의미가 있습니다.
|
||||
2. **테이블은 그룹핑 필수**: 테이블 행도 그룹핑이 활성화되어 있어야 그룹 내 행들을 표시할 수 있습니다.
|
||||
3. **단순 모드**: 그룹핑 없이 사용하면 1행 = 1카드로 동작합니다. 이 경우 헤더/필드 타입만 사용 가능합니다.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,13 @@
|
|||
"use client";
|
||||
|
||||
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
|
||||
import { RepeatScreenModalDefinition } from "./index";
|
||||
|
||||
// 컴포넌트 자동 등록
|
||||
if (typeof window !== "undefined") {
|
||||
ComponentRegistry.registerComponent(RepeatScreenModalDefinition);
|
||||
console.log("✅ RepeatScreenModal 컴포넌트 등록 완료");
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
"use client";
|
||||
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { RepeatScreenModalComponent } from "./RepeatScreenModalComponent";
|
||||
import { RepeatScreenModalConfigPanel } from "./RepeatScreenModalConfigPanel";
|
||||
import type {
|
||||
RepeatScreenModalProps,
|
||||
CardRowConfig,
|
||||
CardColumnConfig,
|
||||
ColumnSourceConfig,
|
||||
ColumnTargetConfig,
|
||||
DataSourceConfig,
|
||||
CardData,
|
||||
GroupingConfig,
|
||||
AggregationConfig,
|
||||
TableLayoutConfig,
|
||||
TableColumnConfig,
|
||||
GroupedCardData,
|
||||
CardRowData,
|
||||
CardContentRowConfig,
|
||||
AggregationDisplayConfig,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* RepeatScreenModal 컴포넌트 정의 v3
|
||||
* 반복 화면 모달 - 선택한 행 개수만큼 카드를 생성하며, 각 카드는 커스터마이징 가능한 레이아웃
|
||||
*
|
||||
* 주요 기능:
|
||||
* - 🆕 v3: 자유 레이아웃 - 행(Row)을 추가하고 각 행마다 타입(헤더/집계/테이블/필드) 선택
|
||||
* - 그룹핑: 특정 필드 기준으로 여러 행을 하나의 카드로 묶기
|
||||
* - 집계: 그룹 내 데이터의 합계/평균/개수 등 자동 계산
|
||||
* - 카드 내 테이블: 그룹 내 각 행을 테이블 형태로 표시
|
||||
* - 유연한 레이아웃: 행 타입 자유 선택, 순서 자유 배치
|
||||
* - 컬럼별 소스 설정: 직접 조회/조인 조회/수동 입력
|
||||
* - 컬럼별 타겟 설정: 어느 테이블의 어느 컬럼에 저장할지 설정
|
||||
* - 다중 테이블 저장: 하나의 카드에서 여러 테이블 동시 저장
|
||||
*
|
||||
* 사용 시나리오:
|
||||
* - 출하계획 동시 등록 (품목별 그룹핑 + 수주별 테이블)
|
||||
* - 구매발주 일괄 등록 (공급업체별 그룹핑 + 품목별 테이블)
|
||||
* - 생산계획 일괄 등록 (제품별 그룹핑 + 작업지시별 테이블)
|
||||
* - 입고검사 일괄 처리 (발주번호별 그룹핑 + 품목별 검사결과)
|
||||
*/
|
||||
export const RepeatScreenModalDefinition = createComponentDefinition({
|
||||
id: "repeat-screen-modal",
|
||||
name: "반복 화면 모달",
|
||||
nameEng: "Repeat Screen Modal",
|
||||
description:
|
||||
"선택한 행을 그룹핑하여 카드로 표시하고, 각 카드는 헤더/집계/테이블을 자유롭게 구성 가능한 폼 (출하계획, 구매발주 등)",
|
||||
category: ComponentCategory.DATA,
|
||||
webType: "form",
|
||||
component: RepeatScreenModalComponent,
|
||||
defaultConfig: {
|
||||
// 기본 설정
|
||||
showCardTitle: true,
|
||||
cardTitle: "카드 {index}",
|
||||
cardSpacing: "24px",
|
||||
showCardBorder: true,
|
||||
saveMode: "all",
|
||||
|
||||
// 데이터 소스
|
||||
dataSource: {
|
||||
sourceTable: "",
|
||||
filterField: "selectedIds",
|
||||
},
|
||||
|
||||
// 그룹핑 설정
|
||||
grouping: {
|
||||
enabled: false,
|
||||
groupByField: "",
|
||||
aggregations: [],
|
||||
},
|
||||
|
||||
// 🆕 v3: 자유 레이아웃 (행 추가 후 타입 선택)
|
||||
contentRows: [],
|
||||
|
||||
// (레거시 호환)
|
||||
cardMode: "simple",
|
||||
cardLayout: [],
|
||||
tableLayout: {
|
||||
headerRows: [],
|
||||
tableColumns: [],
|
||||
},
|
||||
} as Partial<RepeatScreenModalProps>,
|
||||
defaultSize: { width: 1000, height: 800 },
|
||||
configPanel: RepeatScreenModalConfigPanel,
|
||||
icon: "LayoutGrid",
|
||||
tags: ["모달", "폼", "반복", "카드", "그룹핑", "집계", "테이블", "편집", "데이터", "출하계획", "일괄등록", "자유레이아웃"],
|
||||
version: "3.0.0",
|
||||
author: "개발팀",
|
||||
});
|
||||
|
||||
// 타입 재 export
|
||||
export type {
|
||||
RepeatScreenModalProps,
|
||||
CardRowConfig,
|
||||
CardColumnConfig,
|
||||
ColumnSourceConfig,
|
||||
ColumnTargetConfig,
|
||||
DataSourceConfig,
|
||||
CardData,
|
||||
GroupingConfig,
|
||||
AggregationConfig,
|
||||
TableLayoutConfig,
|
||||
TableColumnConfig,
|
||||
GroupedCardData,
|
||||
CardRowData,
|
||||
CardContentRowConfig,
|
||||
AggregationDisplayConfig,
|
||||
};
|
||||
|
||||
// 컴포넌트 재 export
|
||||
export { RepeatScreenModalComponent, RepeatScreenModalConfigPanel };
|
||||
|
|
@ -0,0 +1,256 @@
|
|||
import { ComponentRendererProps } from "@/types/component";
|
||||
|
||||
/**
|
||||
* RepeatScreenModal Props
|
||||
* 선택한 행 개수만큼 카드를 생성하며, 각 카드는 커스터마이징 가능한 레이아웃을 가짐
|
||||
*
|
||||
* 🆕 v3: 행(Row) 기반 자유 레이아웃 - 각 행마다 타입(헤더/집계/테이블) 선택 가능
|
||||
*/
|
||||
export interface RepeatScreenModalProps {
|
||||
// === 기본 설정 ===
|
||||
showCardTitle?: boolean; // 카드 제목 표시 여부
|
||||
cardTitle?: string; // 카드 제목 템플릿 (예: "{order_no} - {item_code}")
|
||||
cardSpacing?: string; // 카드 간 간격 (기본: 24px)
|
||||
showCardBorder?: boolean; // 카드 테두리 표시 여부
|
||||
saveMode?: "all" | "individual"; // 저장 모드
|
||||
|
||||
// === 데이터 소스 ===
|
||||
dataSource?: DataSourceConfig; // 데이터 소스 설정
|
||||
|
||||
// === 그룹핑 설정 ===
|
||||
grouping?: GroupingConfig; // 그룹핑 설정
|
||||
|
||||
// === 🆕 v3: 자유 레이아웃 ===
|
||||
contentRows?: CardContentRowConfig[]; // 카드 내부 행들 (각 행마다 타입 선택)
|
||||
|
||||
// === (레거시 호환) ===
|
||||
cardMode?: "simple" | "withTable"; // @deprecated - contentRows 사용 권장
|
||||
cardLayout?: CardRowConfig[]; // @deprecated - contentRows 사용 권장
|
||||
tableLayout?: TableLayoutConfig; // @deprecated - contentRows 사용 권장
|
||||
|
||||
// === 값 ===
|
||||
value?: any[];
|
||||
onChange?: (newData: any[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터 소스 설정
|
||||
*/
|
||||
export interface DataSourceConfig {
|
||||
sourceTable: string; // 조회할 테이블 (예: "sales_order_mng")
|
||||
filterField?: string; // formData에서 필터링할 필드 (예: "selectedIds")
|
||||
selectColumns?: string[]; // 선택할 컬럼 목록
|
||||
}
|
||||
|
||||
/**
|
||||
* 그룹핑 설정
|
||||
* 특정 필드 기준으로 여러 행을 하나의 카드로 묶음
|
||||
*/
|
||||
export interface GroupingConfig {
|
||||
enabled: boolean; // 그룹핑 활성화 여부
|
||||
groupByField: string; // 그룹 기준 필드 (예: "part_code")
|
||||
|
||||
// 집계 설정 (그룹별 합계, 개수 등)
|
||||
aggregations?: AggregationConfig[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v3: 카드 내부 행 설정
|
||||
* 각 행마다 타입(헤더/집계/테이블)을 선택할 수 있음
|
||||
*/
|
||||
export interface CardContentRowConfig {
|
||||
id: string; // 행 고유 ID
|
||||
type: "header" | "aggregation" | "table" | "fields"; // 행 타입
|
||||
|
||||
// === header/fields 타입일 때 ===
|
||||
columns?: CardColumnConfig[]; // 컬럼 설정
|
||||
layout?: "horizontal" | "vertical"; // 레이아웃 방향
|
||||
gap?: string; // 컬럼 간 간격
|
||||
backgroundColor?: string; // 배경색
|
||||
padding?: string; // 패딩
|
||||
|
||||
// === aggregation 타입일 때 ===
|
||||
aggregationFields?: AggregationDisplayConfig[]; // 표시할 집계 필드들
|
||||
aggregationLayout?: "horizontal" | "grid"; // 집계 레이아웃 (가로 나열 / 그리드)
|
||||
aggregationColumns?: number; // grid일 때 컬럼 수 (기본: 4)
|
||||
|
||||
// === table 타입일 때 ===
|
||||
tableColumns?: TableColumnConfig[]; // 테이블 컬럼 설정
|
||||
tableTitle?: string; // 테이블 제목
|
||||
showTableHeader?: boolean; // 테이블 헤더 표시 여부
|
||||
tableMaxHeight?: string; // 테이블 최대 높이
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 v3: 집계 표시 설정
|
||||
*/
|
||||
export interface AggregationDisplayConfig {
|
||||
aggregationResultField: string; // 그룹핑 설정의 resultField 참조
|
||||
label: string; // 표시 라벨
|
||||
icon?: string; // 아이콘 (lucide 아이콘명)
|
||||
backgroundColor?: string; // 배경색
|
||||
textColor?: string; // 텍스트 색상
|
||||
fontSize?: "xs" | "sm" | "base" | "lg" | "xl" | "2xl"; // 폰트 크기
|
||||
}
|
||||
|
||||
/**
|
||||
* 집계 설정
|
||||
*/
|
||||
export interface AggregationConfig {
|
||||
sourceField: string; // 원본 필드 (예: "balance_qty")
|
||||
type: "sum" | "count" | "avg" | "min" | "max"; // 집계 타입
|
||||
resultField: string; // 결과 필드명 (예: "total_balance_qty")
|
||||
label: string; // 표시 라벨 (예: "총수주잔량")
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated v3에서는 contentRows 사용 권장
|
||||
* 테이블 포함 레이아웃 설정
|
||||
*/
|
||||
export interface TableLayoutConfig {
|
||||
headerRows: CardRowConfig[];
|
||||
tableColumns: TableColumnConfig[];
|
||||
tableTitle?: string;
|
||||
showTableHeader?: boolean;
|
||||
tableMaxHeight?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 컬럼 설정
|
||||
*/
|
||||
export interface TableColumnConfig {
|
||||
id: string; // 컬럼 고유 ID
|
||||
field: string; // 필드명
|
||||
label: string; // 헤더 라벨
|
||||
type: "text" | "number" | "date" | "select" | "badge"; // 타입
|
||||
width?: string; // 너비 (예: "100px", "20%")
|
||||
align?: "left" | "center" | "right"; // 정렬
|
||||
editable: boolean; // 편집 가능 여부
|
||||
required?: boolean; // 필수 입력 여부
|
||||
|
||||
// Select 타입 옵션
|
||||
selectOptions?: { value: string; label: string }[];
|
||||
|
||||
// Badge 타입 설정
|
||||
badgeVariant?: "default" | "secondary" | "destructive" | "outline";
|
||||
badgeColorMap?: Record<string, string>; // 값별 색상 매핑
|
||||
|
||||
// 데이터 소스 설정
|
||||
sourceConfig?: ColumnSourceConfig;
|
||||
|
||||
// 데이터 타겟 설정
|
||||
targetConfig?: ColumnTargetConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 행 설정
|
||||
* 카드는 여러 행(Row)으로 구성되며, 각 행은 여러 컬럼을 가짐
|
||||
*/
|
||||
export interface CardRowConfig {
|
||||
id: string; // 행 고유 ID
|
||||
columns: CardColumnConfig[]; // 이 행에 배치할 컬럼들
|
||||
gap?: string; // 컬럼 간 간격 (기본: 16px)
|
||||
layout?: "horizontal" | "vertical"; // 레이아웃 방향 (기본: horizontal)
|
||||
|
||||
// 🆕 행 스타일 설정
|
||||
backgroundColor?: string; // 배경색 (예: "blue", "green")
|
||||
padding?: string; // 패딩
|
||||
rounded?: boolean; // 둥근 모서리
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 컬럼 설정
|
||||
*/
|
||||
export interface CardColumnConfig {
|
||||
id: string; // 컬럼 고유 ID
|
||||
field: string; // 필드명 (데이터 바인딩)
|
||||
label: string; // 라벨
|
||||
type: "text" | "number" | "date" | "select" | "textarea" | "component" | "aggregation"; // 🆕 aggregation 추가
|
||||
width?: string; // 너비 (예: "50%", "200px", "1fr")
|
||||
editable: boolean; // 편집 가능 여부
|
||||
required?: boolean; // 필수 입력 여부
|
||||
placeholder?: string; // 플레이스홀더
|
||||
|
||||
// Select 타입 옵션
|
||||
selectOptions?: { value: string; label: string }[];
|
||||
|
||||
// 데이터 소스 설정 (어디서 조회?)
|
||||
sourceConfig?: ColumnSourceConfig;
|
||||
|
||||
// 데이터 타겟 설정 (어디에 저장?)
|
||||
targetConfig?: ColumnTargetConfig;
|
||||
|
||||
// Component 타입일 때
|
||||
componentType?: string; // 삽입할 컴포넌트 타입 (예: "simple-repeater-table")
|
||||
componentConfig?: any; // 컴포넌트 설정
|
||||
|
||||
// 🆕 Aggregation 타입일 때 (집계값 표시)
|
||||
aggregationField?: string; // 표시할 집계 필드명 (GroupingConfig.aggregations의 resultField)
|
||||
|
||||
// 🆕 스타일 설정
|
||||
textColor?: string; // 텍스트 색상
|
||||
fontSize?: "xs" | "sm" | "base" | "lg" | "xl" | "2xl"; // 폰트 크기
|
||||
fontWeight?: "normal" | "medium" | "semibold" | "bold"; // 폰트 굵기
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 데이터 소스 설정 (SimpleRepeaterTable과 동일)
|
||||
*/
|
||||
export interface ColumnSourceConfig {
|
||||
type: "direct" | "join" | "manual"; // 조회 타입
|
||||
sourceTable?: string; // type: "direct" - 조회할 테이블
|
||||
sourceColumn?: string; // type: "direct" - 조회할 컬럼
|
||||
joinTable?: string; // type: "join" - 조인할 테이블
|
||||
joinColumn?: string; // type: "join" - 조인 테이블에서 가져올 컬럼
|
||||
joinKey?: string; // type: "join" - 현재 데이터의 조인 키 컬럼
|
||||
joinRefKey?: string; // type: "join" - 조인 테이블의 참조 키 컬럼
|
||||
}
|
||||
|
||||
/**
|
||||
* 컬럼 데이터 타겟 설정 (SimpleRepeaterTable과 동일)
|
||||
*/
|
||||
export interface ColumnTargetConfig {
|
||||
targetTable: string; // 저장할 테이블
|
||||
targetColumn: string; // 저장할 컬럼
|
||||
saveEnabled?: boolean; // 저장 활성화 여부 (기본 true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 데이터 (각 카드의 상태)
|
||||
*/
|
||||
export interface CardData {
|
||||
_cardId: string; // 카드 고유 ID
|
||||
_originalData: Record<string, any>; // 원본 데이터 (조회된 데이터)
|
||||
_isDirty: boolean; // 수정 여부
|
||||
[key: string]: any; // 실제 필드 데이터
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 그룹화된 카드 데이터
|
||||
*/
|
||||
export interface GroupedCardData {
|
||||
_cardId: string; // 카드 고유 ID
|
||||
_groupKey: string; // 그룹 키 값 (예: "PROD-001")
|
||||
_groupField: string; // 그룹 기준 필드명 (예: "part_code")
|
||||
_aggregations: Record<string, number>; // 집계 결과 (예: { total_balance_qty: 100 })
|
||||
_rows: CardRowData[]; // 그룹 내 각 행 데이터
|
||||
_representativeData: Record<string, any>; // 그룹 대표 데이터 (첫 번째 행 기준)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🆕 그룹 내 행 데이터
|
||||
*/
|
||||
export interface CardRowData {
|
||||
_rowId: string; // 행 고유 ID
|
||||
_originalData: Record<string, any>; // 원본 데이터
|
||||
_isDirty: boolean; // 수정 여부
|
||||
[key: string]: any; // 실제 필드 데이터
|
||||
}
|
||||
|
||||
/**
|
||||
* 테이블 정보 (API 응답용)
|
||||
*/
|
||||
export interface TableInfo {
|
||||
tableName: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,535 @@
|
|||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Trash2, Loader2, X } from "lucide-react";
|
||||
import { SimpleRepeaterTableProps, SimpleRepeaterColumnConfig } from "./types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
import { useCalculation } from "./useCalculation";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
|
||||
export interface SimpleRepeaterTableComponentProps extends ComponentRendererProps {
|
||||
config?: SimpleRepeaterTableProps;
|
||||
// SimpleRepeaterTableProps의 개별 prop들도 지원 (호환성)
|
||||
value?: any[];
|
||||
onChange?: (newData: any[]) => void;
|
||||
columns?: SimpleRepeaterColumnConfig[];
|
||||
calculationRules?: any[];
|
||||
readOnly?: boolean;
|
||||
showRowNumber?: boolean;
|
||||
allowDelete?: boolean;
|
||||
maxHeight?: string;
|
||||
}
|
||||
|
||||
export function SimpleRepeaterTableComponent({
|
||||
// ComponentRendererProps (자동 전달)
|
||||
component,
|
||||
isDesignMode = false,
|
||||
isSelected = false,
|
||||
isInteractive = false,
|
||||
onClick,
|
||||
className,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
|
||||
// SimpleRepeaterTable 전용 props
|
||||
config,
|
||||
value: propValue,
|
||||
onChange: propOnChange,
|
||||
columns: propColumns,
|
||||
calculationRules: propCalculationRules,
|
||||
readOnly: propReadOnly,
|
||||
showRowNumber: propShowRowNumber,
|
||||
allowDelete: propAllowDelete,
|
||||
maxHeight: propMaxHeight,
|
||||
|
||||
...props
|
||||
}: SimpleRepeaterTableComponentProps) {
|
||||
// config 또는 component.config 또는 개별 prop 우선순위로 병합
|
||||
const componentConfig = {
|
||||
...config,
|
||||
...component?.config,
|
||||
};
|
||||
|
||||
// config prop 우선, 없으면 개별 prop 사용
|
||||
const columns = componentConfig?.columns || propColumns || [];
|
||||
const calculationRules = componentConfig?.calculationRules || propCalculationRules || [];
|
||||
const readOnly = componentConfig?.readOnly ?? propReadOnly ?? false;
|
||||
const showRowNumber = componentConfig?.showRowNumber ?? propShowRowNumber ?? true;
|
||||
const allowDelete = componentConfig?.allowDelete ?? propAllowDelete ?? true;
|
||||
const maxHeight = componentConfig?.maxHeight || propMaxHeight || "240px";
|
||||
|
||||
// value는 formData[columnName] 우선, 없으면 prop 사용
|
||||
const columnName = component?.columnName;
|
||||
const value = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
|
||||
|
||||
// 🆕 로딩 상태
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
// onChange 래퍼 (기존 onChange 콜백 + onFormDataChange 호출)
|
||||
const handleChange = (newData: any[]) => {
|
||||
// 기존 onChange 콜백 호출 (호환성)
|
||||
const externalOnChange = componentConfig?.onChange || propOnChange;
|
||||
if (externalOnChange) {
|
||||
externalOnChange(newData);
|
||||
}
|
||||
|
||||
// onFormDataChange 호출하여 EditModal의 groupData 업데이트
|
||||
if (onFormDataChange && columnName) {
|
||||
onFormDataChange(columnName, newData);
|
||||
}
|
||||
};
|
||||
|
||||
// 계산 hook
|
||||
const { calculateRow, calculateAll } = useCalculation(calculationRules);
|
||||
|
||||
// 🆕 초기 데이터 로드
|
||||
useEffect(() => {
|
||||
const loadInitialData = async () => {
|
||||
const initialConfig = componentConfig?.initialDataConfig;
|
||||
if (!initialConfig || !initialConfig.sourceTable) {
|
||||
return; // 초기 데이터 설정이 없으면 로드하지 않음
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setLoadError(null);
|
||||
|
||||
try {
|
||||
// 필터 조건 생성
|
||||
const filters: Record<string, any> = {};
|
||||
|
||||
if (initialConfig.filterConditions) {
|
||||
for (const condition of initialConfig.filterConditions) {
|
||||
let filterValue = condition.value;
|
||||
|
||||
// formData에서 값 가져오기
|
||||
if (condition.valueFromField && formData) {
|
||||
filterValue = formData[condition.valueFromField];
|
||||
}
|
||||
|
||||
filters[condition.field] = filterValue;
|
||||
}
|
||||
}
|
||||
|
||||
// API 호출
|
||||
const response = await apiClient.post(
|
||||
`/table-management/tables/${initialConfig.sourceTable}/data`,
|
||||
{
|
||||
search: filters,
|
||||
page: 1,
|
||||
size: 1000, // 대량 조회
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data.success && response.data.data?.data) {
|
||||
const loadedData = response.data.data.data;
|
||||
|
||||
// 1. 기본 데이터 매핑 (Direct & Manual)
|
||||
const baseMappedData = loadedData.map((row: any) => {
|
||||
const mappedRow: any = { ...row }; // 원본 데이터 유지 (조인 키 참조용)
|
||||
|
||||
for (const col of columns) {
|
||||
if (col.sourceConfig) {
|
||||
if (col.sourceConfig.type === "direct" && col.sourceConfig.sourceColumn) {
|
||||
mappedRow[col.field] = row[col.sourceConfig.sourceColumn];
|
||||
} else if (col.sourceConfig.type === "manual") {
|
||||
mappedRow[col.field] = col.defaultValue;
|
||||
}
|
||||
// Join은 2단계에서 처리
|
||||
} else {
|
||||
mappedRow[col.field] = row[col.field] ?? col.defaultValue;
|
||||
}
|
||||
}
|
||||
return mappedRow;
|
||||
});
|
||||
|
||||
// 2. 조인 데이터 처리
|
||||
const joinColumns = columns.filter(
|
||||
(col) => col.sourceConfig?.type === "join" && col.sourceConfig.joinTable && col.sourceConfig.joinKey
|
||||
);
|
||||
|
||||
if (joinColumns.length > 0) {
|
||||
// 조인 테이블별로 그룹화
|
||||
const joinGroups = new Map<string, { key: string; refKey: string; cols: typeof columns }>();
|
||||
|
||||
joinColumns.forEach((col) => {
|
||||
const table = col.sourceConfig!.joinTable!;
|
||||
const key = col.sourceConfig!.joinKey!;
|
||||
// refKey가 없으면 key와 동일하다고 가정 (하위 호환성)
|
||||
const refKey = col.sourceConfig!.joinRefKey || key;
|
||||
const groupKey = `${table}:${key}:${refKey}`;
|
||||
|
||||
if (!joinGroups.has(groupKey)) {
|
||||
joinGroups.set(groupKey, { key, refKey, cols: [] });
|
||||
}
|
||||
joinGroups.get(groupKey)!.cols.push(col);
|
||||
});
|
||||
|
||||
// 각 그룹별로 데이터 조회 및 병합
|
||||
await Promise.all(
|
||||
Array.from(joinGroups.entries()).map(async ([groupKey, { key, refKey, cols }]) => {
|
||||
const [tableName] = groupKey.split(":");
|
||||
|
||||
// 조인 키 값 수집 (중복 제거)
|
||||
const keyValues = Array.from(new Set(
|
||||
baseMappedData
|
||||
.map((row: any) => row[key])
|
||||
.filter((v: any) => v !== undefined && v !== null)
|
||||
));
|
||||
|
||||
if (keyValues.length === 0) return;
|
||||
|
||||
try {
|
||||
// 조인 테이블 조회
|
||||
// refKey(타겟 테이블 컬럼)로 검색
|
||||
const response = await apiClient.post(
|
||||
`/table-management/tables/${tableName}/data`,
|
||||
{
|
||||
search: { [refKey]: keyValues }, // { id: [1, 2, 3] }
|
||||
page: 1,
|
||||
size: 1000,
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data.success && response.data.data?.data) {
|
||||
const joinedRows = response.data.data.data;
|
||||
// 조인 데이터 맵 생성 (refKey -> row)
|
||||
const joinMap = new Map(joinedRows.map((r: any) => [r[refKey], r]));
|
||||
|
||||
// 데이터 병합
|
||||
baseMappedData.forEach((row: any) => {
|
||||
const keyValue = row[key];
|
||||
const joinedRow = joinMap.get(keyValue);
|
||||
|
||||
if (joinedRow) {
|
||||
cols.forEach((col) => {
|
||||
if (col.sourceConfig?.joinColumn) {
|
||||
row[col.field] = joinedRow[col.sourceConfig.joinColumn];
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`조인 실패 (${tableName}):`, error);
|
||||
// 실패 시 무시하고 진행 (값은 undefined)
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const mappedData = baseMappedData;
|
||||
|
||||
// 계산 필드 적용
|
||||
const calculatedData = calculateAll(mappedData);
|
||||
handleChange(calculatedData);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("초기 데이터 로드 실패:", error);
|
||||
setLoadError(error.message || "데이터를 불러올 수 없습니다");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadInitialData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [componentConfig?.initialDataConfig]);
|
||||
|
||||
// 초기 데이터에 계산 필드 적용
|
||||
useEffect(() => {
|
||||
if (value.length > 0 && calculationRules.length > 0) {
|
||||
const calculated = calculateAll(value);
|
||||
// 값이 실제로 변경된 경우만 업데이트
|
||||
if (JSON.stringify(calculated) !== JSON.stringify(value)) {
|
||||
handleChange(calculated);
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 🆕 저장 요청 시 테이블별로 데이터 그룹화 (beforeFormSave 이벤트 리스너)
|
||||
useEffect(() => {
|
||||
const handleSaveRequest = async (event: Event) => {
|
||||
if (value.length === 0) {
|
||||
console.warn("⚠️ [SimpleRepeaterTable] 저장할 데이터 없음");
|
||||
return;
|
||||
}
|
||||
|
||||
// 🆕 테이블별로 데이터 그룹화
|
||||
const dataByTable: Record<string, any[]> = {};
|
||||
|
||||
for (const row of value) {
|
||||
// 각 행의 데이터를 테이블별로 분리
|
||||
for (const col of columns) {
|
||||
// 저장 설정이 있고 저장이 활성화된 경우에만
|
||||
if (col.targetConfig && col.targetConfig.targetTable && col.targetConfig.saveEnabled !== false) {
|
||||
const targetTable = col.targetConfig.targetTable;
|
||||
const targetColumn = col.targetConfig.targetColumn || col.field;
|
||||
|
||||
// 테이블 그룹 초기화
|
||||
if (!dataByTable[targetTable]) {
|
||||
dataByTable[targetTable] = [];
|
||||
}
|
||||
|
||||
// 해당 테이블의 데이터 찾기 또는 생성
|
||||
let tableRow = dataByTable[targetTable].find((r: any) => r._rowIndex === row._rowIndex);
|
||||
if (!tableRow) {
|
||||
tableRow = { _rowIndex: row._rowIndex };
|
||||
dataByTable[targetTable].push(tableRow);
|
||||
}
|
||||
|
||||
// 컬럼 값 저장
|
||||
tableRow[targetColumn] = row[col.field];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// _rowIndex 제거
|
||||
Object.keys(dataByTable).forEach((tableName) => {
|
||||
dataByTable[tableName] = dataByTable[tableName].map((row: any) => {
|
||||
const { _rowIndex, ...rest } = row;
|
||||
return rest;
|
||||
});
|
||||
});
|
||||
|
||||
console.log("✅ [SimpleRepeaterTable] 테이블별 저장 데이터:", dataByTable);
|
||||
|
||||
// CustomEvent의 detail에 테이블별 데이터 추가
|
||||
if (event instanceof CustomEvent && event.detail) {
|
||||
// 각 테이블별로 데이터 전달
|
||||
Object.entries(dataByTable).forEach(([tableName, rows]) => {
|
||||
const key = `${columnName || component?.id}_${tableName}`;
|
||||
event.detail.formData[key] = rows.map((row: any) => ({
|
||||
...row,
|
||||
_targetTable: tableName,
|
||||
}));
|
||||
});
|
||||
|
||||
console.log("✅ [SimpleRepeaterTable] 저장 데이터 준비:", {
|
||||
tables: Object.keys(dataByTable),
|
||||
totalRows: Object.values(dataByTable).reduce((sum, rows) => sum + rows.length, 0),
|
||||
});
|
||||
}
|
||||
|
||||
// 기존 onFormDataChange도 호출 (호환성)
|
||||
if (onFormDataChange && columnName) {
|
||||
// 테이블별 데이터를 통합하여 전달
|
||||
onFormDataChange(columnName, Object.entries(dataByTable).flatMap(([table, rows]) =>
|
||||
rows.map((row: any) => ({ ...row, _targetTable: table }))
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// 저장 버튼 클릭 시 데이터 수집
|
||||
window.addEventListener("beforeFormSave", handleSaveRequest as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener);
|
||||
};
|
||||
}, [value, columns, columnName, component?.id, onFormDataChange]);
|
||||
|
||||
const handleCellEdit = (rowIndex: number, field: string, cellValue: any) => {
|
||||
const newRow = { ...value[rowIndex], [field]: cellValue };
|
||||
|
||||
// 계산 필드 업데이트
|
||||
const calculatedRow = calculateRow(newRow);
|
||||
|
||||
const newData = [...value];
|
||||
newData[rowIndex] = calculatedRow;
|
||||
handleChange(newData);
|
||||
};
|
||||
|
||||
const handleRowDelete = (rowIndex: number) => {
|
||||
const newData = value.filter((_, i) => i !== rowIndex);
|
||||
handleChange(newData);
|
||||
};
|
||||
|
||||
const renderCell = (
|
||||
row: any,
|
||||
column: SimpleRepeaterColumnConfig,
|
||||
rowIndex: number
|
||||
) => {
|
||||
const cellValue = row[column.field];
|
||||
|
||||
// 계산 필드는 편집 불가
|
||||
if (column.calculated || !column.editable || readOnly) {
|
||||
return (
|
||||
<div className="px-2 py-1">
|
||||
{column.type === "number"
|
||||
? typeof cellValue === "number"
|
||||
? cellValue.toLocaleString()
|
||||
: cellValue || "0"
|
||||
: cellValue || "-"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 편집 가능한 필드
|
||||
switch (column.type) {
|
||||
case "number":
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
value={cellValue || ""}
|
||||
onChange={(e) =>
|
||||
handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0)
|
||||
}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
);
|
||||
|
||||
case "date":
|
||||
return (
|
||||
<Input
|
||||
type="date"
|
||||
value={cellValue || ""}
|
||||
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
);
|
||||
|
||||
case "select":
|
||||
return (
|
||||
<Select
|
||||
value={cellValue || ""}
|
||||
onValueChange={(newValue) =>
|
||||
handleCellEdit(rowIndex, column.field, newValue)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{column.selectOptions?.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
|
||||
default: // text
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
value={cellValue || ""}
|
||||
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 로딩 중일 때
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={cn("border rounded-md overflow-hidden bg-background", className)}>
|
||||
<div className="flex items-center justify-center py-12" style={{ minHeight: maxHeight }}>
|
||||
<div className="text-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 발생 시
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className={cn("border rounded-md overflow-hidden bg-background", className)}>
|
||||
<div className="flex items-center justify-center py-12" style={{ minHeight: maxHeight }}>
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center mx-auto mb-2">
|
||||
<X className="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-destructive mb-1">데이터 로드 실패</p>
|
||||
<p className="text-xs text-muted-foreground">{loadError}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("border rounded-md overflow-hidden bg-background", className)}>
|
||||
<div
|
||||
className="overflow-x-auto overflow-y-auto"
|
||||
style={{ maxHeight }}
|
||||
>
|
||||
<table className="w-full text-xs sm:text-sm">
|
||||
<thead className="bg-muted sticky top-0 z-10">
|
||||
<tr>
|
||||
{showRowNumber && (
|
||||
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-12">
|
||||
#
|
||||
</th>
|
||||
)}
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.field}
|
||||
className="px-4 py-2 text-left font-medium text-muted-foreground"
|
||||
style={{ width: col.width }}
|
||||
>
|
||||
{col.label}
|
||||
{col.required && <span className="text-destructive ml-1">*</span>}
|
||||
</th>
|
||||
))}
|
||||
{!readOnly && allowDelete && (
|
||||
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-20">
|
||||
삭제
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-background">
|
||||
{value.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length + (showRowNumber ? 1 : 0) + (allowDelete && !readOnly ? 1 : 0)}
|
||||
className="px-4 py-8 text-center text-muted-foreground"
|
||||
>
|
||||
표시할 데이터가 없습니다
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
value.map((row, rowIndex) => (
|
||||
<tr key={rowIndex} className="border-t hover:bg-accent/50">
|
||||
{showRowNumber && (
|
||||
<td className="px-4 py-2 text-center text-muted-foreground">
|
||||
{rowIndex + 1}
|
||||
</td>
|
||||
)}
|
||||
{columns.map((col) => (
|
||||
<td key={col.field} className="px-2 py-1">
|
||||
{renderCell(row, col, rowIndex)}
|
||||
</td>
|
||||
))}
|
||||
{!readOnly && allowDelete && (
|
||||
<td className="px-4 py-2 text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRowDelete(rowIndex)}
|
||||
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,16 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ComponentRegistry } from "../../ComponentRegistry";
|
||||
import { SimpleRepeaterTableDefinition } from "./index";
|
||||
import { SimpleRepeaterTableComponent } from "./SimpleRepeaterTableComponent";
|
||||
import { ComponentRendererProps } from "@/types/component";
|
||||
|
||||
// 컴포넌트 자동 등록
|
||||
ComponentRegistry.registerComponent(SimpleRepeaterTableDefinition);
|
||||
|
||||
console.log("✅ SimpleRepeaterTable 컴포넌트 등록 완료");
|
||||
|
||||
export function SimpleRepeaterTableRenderer(props: ComponentRendererProps) {
|
||||
return <SimpleRepeaterTableComponent {...props} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
"use client";
|
||||
|
||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||
import { ComponentCategory } from "@/types/component";
|
||||
import { SimpleRepeaterTableComponent } from "./SimpleRepeaterTableComponent";
|
||||
import { SimpleRepeaterTableConfigPanel } from "./SimpleRepeaterTableConfigPanel";
|
||||
|
||||
/**
|
||||
* 🆕 SimpleRepeaterTable 컴포넌트 정의
|
||||
* 단순 반복 테이블 - 검색/추가 없이 데이터 표시 및 편집만
|
||||
*
|
||||
* 주요 기능:
|
||||
* - 초기 데이터 로드: 어떤 테이블에서 어떤 조건으로 데이터를 가져올지 설정
|
||||
* - 컬럼별 소스 설정: 각 컬럼의 데이터를 어디서 조회할지 설정 (직접 조회/조인 조회/수동 입력)
|
||||
* - 컬럼별 타겟 설정: 각 컬럼의 데이터를 어느 테이블의 어느 컬럼에 저장할지 설정
|
||||
* - 자동 계산: 수량 * 단가 = 금액 같은 자동 계산 지원
|
||||
* - 읽기 전용 모드: 전체 테이블을 보기 전용으로 설정
|
||||
*/
|
||||
export const SimpleRepeaterTableDefinition = createComponentDefinition({
|
||||
id: "simple-repeater-table",
|
||||
name: "단순 반복 테이블",
|
||||
nameEng: "Simple Repeater Table",
|
||||
description: "어떤 테이블에서 조회하고 어떤 테이블에 저장할지 컬럼별로 설정 가능한 반복 테이블 (검색/추가 없음, 자동 계산 지원)",
|
||||
category: ComponentCategory.DATA,
|
||||
webType: "table",
|
||||
component: SimpleRepeaterTableComponent,
|
||||
defaultConfig: {
|
||||
columns: [],
|
||||
calculationRules: [],
|
||||
initialDataConfig: undefined,
|
||||
readOnly: false,
|
||||
showRowNumber: true,
|
||||
allowDelete: true,
|
||||
maxHeight: "240px",
|
||||
},
|
||||
defaultSize: { width: 800, height: 400 },
|
||||
configPanel: SimpleRepeaterTableConfigPanel,
|
||||
icon: "Table",
|
||||
tags: ["테이블", "반복", "편집", "데이터", "목록", "계산", "조회", "저장"],
|
||||
version: "2.0.0",
|
||||
author: "개발팀",
|
||||
});
|
||||
|
||||
// 타입 내보내기
|
||||
export type {
|
||||
SimpleRepeaterTableProps,
|
||||
SimpleRepeaterColumnConfig,
|
||||
CalculationRule,
|
||||
ColumnSourceConfig,
|
||||
ColumnTargetConfig,
|
||||
InitialDataConfig,
|
||||
DataFilterCondition,
|
||||
SourceJoinCondition,
|
||||
} from "./types";
|
||||
|
||||
// 컴포넌트 내보내기
|
||||
export { SimpleRepeaterTableComponent } from "./SimpleRepeaterTableComponent";
|
||||
export { SimpleRepeaterTableConfigPanel } from "./SimpleRepeaterTableConfigPanel";
|
||||
export { useCalculation } from "./useCalculation";
|
||||
|
||||
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { useCallback } from "react";
|
||||
import { CalculationRule } from "./types";
|
||||
|
||||
/**
|
||||
* 계산 필드 자동 업데이트 훅
|
||||
*/
|
||||
export function useCalculation(calculationRules: CalculationRule[] = []) {
|
||||
/**
|
||||
* 단일 행의 계산 필드 업데이트
|
||||
*/
|
||||
const calculateRow = useCallback(
|
||||
(row: any): any => {
|
||||
if (calculationRules.length === 0) return row;
|
||||
|
||||
const updatedRow = { ...row };
|
||||
|
||||
for (const rule of calculationRules) {
|
||||
try {
|
||||
// formula에서 필드명 자동 추출 (영문자, 숫자, 언더스코어로 구성된 단어)
|
||||
let formula = rule.formula;
|
||||
const fieldMatches = formula.match(/[a-zA-Z_][a-zA-Z0-9_]*/g) || [];
|
||||
|
||||
// 추출된 필드명들을 사용 (dependencies가 없으면 자동 추출 사용)
|
||||
const dependencies = rule.dependencies && rule.dependencies.length > 0
|
||||
? rule.dependencies
|
||||
: fieldMatches;
|
||||
|
||||
// 필드명을 실제 값으로 대체
|
||||
for (const dep of dependencies) {
|
||||
// 결과 필드는 제외
|
||||
if (dep === rule.result) continue;
|
||||
|
||||
const value = parseFloat(row[dep]) || 0;
|
||||
// 정확한 필드명만 대체 (단어 경계 사용)
|
||||
formula = formula.replace(new RegExp(`\\b${dep}\\b`, "g"), value.toString());
|
||||
}
|
||||
|
||||
// 계산 실행 (Function 사용)
|
||||
const result = new Function(`return ${formula}`)();
|
||||
updatedRow[rule.result] = result;
|
||||
} catch (error) {
|
||||
console.error(`계산 오류 (${rule.formula}):`, error);
|
||||
updatedRow[rule.result] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return updatedRow;
|
||||
},
|
||||
[calculationRules]
|
||||
);
|
||||
|
||||
/**
|
||||
* 전체 데이터의 계산 필드 업데이트
|
||||
*/
|
||||
const calculateAll = useCallback(
|
||||
(data: any[]): any[] => {
|
||||
return data.map((row) => calculateRow(row));
|
||||
},
|
||||
[calculateRow]
|
||||
);
|
||||
|
||||
return {
|
||||
calculateRow,
|
||||
calculateAll,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -280,10 +280,10 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
}
|
||||
});
|
||||
|
||||
console.log("🔍 [TableListComponent] filters → searchValues:", {
|
||||
filters: filters.length,
|
||||
searchValues: newSearchValues,
|
||||
});
|
||||
// console.log("🔍 [TableListComponent] filters → searchValues:", {
|
||||
// filters: filters.length,
|
||||
// searchValues: newSearchValues,
|
||||
// });
|
||||
|
||||
setSearchValues(newSearchValues);
|
||||
setCurrentPage(1); // 필터 변경 시 첫 페이지로
|
||||
|
|
@ -1066,13 +1066,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
referenceTable: col.additionalJoinInfo!.referenceTable,
|
||||
}));
|
||||
|
||||
console.log("🔍 [TableList] API 호출 시작", {
|
||||
tableName: tableConfig.selectedTable,
|
||||
page,
|
||||
pageSize,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
});
|
||||
// console.log("🔍 [TableList] API 호출 시작", {
|
||||
// tableName: tableConfig.selectedTable,
|
||||
// page,
|
||||
// pageSize,
|
||||
// sortBy,
|
||||
// sortOrder,
|
||||
// });
|
||||
|
||||
// 🎯 항상 entityJoinApi 사용 (writer 컬럼 자동 조인 지원)
|
||||
const response = await entityJoinApi.getTableDataWithJoins(tableConfig.selectedTable, {
|
||||
|
|
@ -1090,12 +1090,12 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const itemNumbers = (response.data || []).map((item: any) => item.item_number);
|
||||
const uniqueItemNumbers = [...new Set(itemNumbers)];
|
||||
|
||||
console.log("✅ [TableList] API 응답 받음");
|
||||
console.log(` - dataLength: ${response.data?.length || 0}`);
|
||||
console.log(` - total: ${response.total}`);
|
||||
console.log(` - itemNumbers: ${JSON.stringify(itemNumbers)}`);
|
||||
console.log(` - uniqueItemNumbers: ${JSON.stringify(uniqueItemNumbers)}`);
|
||||
console.log(` - isDuplicated: ${itemNumbers.length !== uniqueItemNumbers.length}`);
|
||||
// console.log("✅ [TableList] API 응답 받음");
|
||||
// console.log(` - dataLength: ${response.data?.length || 0}`);
|
||||
// console.log(` - total: ${response.total}`);
|
||||
// console.log(` - itemNumbers: ${JSON.stringify(itemNumbers)}`);
|
||||
// console.log(` - uniqueItemNumbers: ${JSON.stringify(uniqueItemNumbers)}`);
|
||||
// console.log(` - isDuplicated: ${itemNumbers.length !== uniqueItemNumbers.length}`);
|
||||
|
||||
setData(response.data || []);
|
||||
setTotalPages(response.totalPages || 0);
|
||||
|
|
@ -1517,41 +1517,41 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
const lastColumnOrderRef = useRef<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
console.log("🔍 [컬럼 순서 전달 useEffect] 실행됨:", {
|
||||
hasCallback: !!onSelectedRowsChange,
|
||||
visibleColumnsLength: visibleColumns.length,
|
||||
visibleColumnsNames: visibleColumns.map((c) => c.columnName),
|
||||
});
|
||||
// console.log("🔍 [컬럼 순서 전달 useEffect] 실행됨:", {
|
||||
// hasCallback: !!onSelectedRowsChange,
|
||||
// visibleColumnsLength: visibleColumns.length,
|
||||
// visibleColumnsNames: visibleColumns.map((c) => c.columnName),
|
||||
// });
|
||||
|
||||
if (!onSelectedRowsChange) {
|
||||
console.warn("⚠️ onSelectedRowsChange 콜백이 없습니다!");
|
||||
// console.warn("⚠️ onSelectedRowsChange 콜백이 없습니다!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (visibleColumns.length === 0) {
|
||||
console.warn("⚠️ visibleColumns가 비어있습니다!");
|
||||
// console.warn("⚠️ visibleColumns가 비어있습니다!");
|
||||
return;
|
||||
}
|
||||
|
||||
const currentColumnOrder = visibleColumns.map((col) => col.columnName).filter((name) => name !== "__checkbox__"); // 체크박스 컬럼 제외
|
||||
|
||||
console.log("🔍 [컬럼 순서] 체크박스 제외 후:", currentColumnOrder);
|
||||
// console.log("🔍 [컬럼 순서] 체크박스 제외 후:", currentColumnOrder);
|
||||
|
||||
// 컬럼 순서가 실제로 변경되었을 때만 전달 (무한 루프 방지)
|
||||
const columnOrderString = currentColumnOrder.join(",");
|
||||
console.log("🔍 [컬럼 순서] 비교:", {
|
||||
current: columnOrderString,
|
||||
last: lastColumnOrderRef.current,
|
||||
isDifferent: columnOrderString !== lastColumnOrderRef.current,
|
||||
});
|
||||
// console.log("🔍 [컬럼 순서] 비교:", {
|
||||
// current: columnOrderString,
|
||||
// last: lastColumnOrderRef.current,
|
||||
// isDifferent: columnOrderString !== lastColumnOrderRef.current,
|
||||
// });
|
||||
|
||||
if (columnOrderString === lastColumnOrderRef.current) {
|
||||
console.log("⏭️ 컬럼 순서 변경 없음, 전달 스킵");
|
||||
// console.log("⏭️ 컬럼 순서 변경 없음, 전달 스킵");
|
||||
return;
|
||||
}
|
||||
|
||||
lastColumnOrderRef.current = columnOrderString;
|
||||
console.log("📊 현재 화면 컬럼 순서 전달:", currentColumnOrder);
|
||||
// console.log("📊 현재 화면 컬럼 순서 전달:", currentColumnOrder);
|
||||
|
||||
// 선택된 행 데이터 가져오기
|
||||
const selectedRowsData = data.filter((row, index) => selectedRows.has(getRowKey(row, index)));
|
||||
|
|
@ -2069,13 +2069,13 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
|||
}, [tableConfig.selectedTable, fetchColumnLabels, fetchTableLabel]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("🔍 [TableList] useEffect 실행 - 데이터 조회 트리거", {
|
||||
isDesignMode,
|
||||
tableName: tableConfig.selectedTable,
|
||||
currentPage,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
});
|
||||
// console.log("🔍 [TableList] useEffect 실행 - 데이터 조회 트리거", {
|
||||
// isDesignMode,
|
||||
// tableName: tableConfig.selectedTable,
|
||||
// currentPage,
|
||||
// sortColumn,
|
||||
// sortDirection,
|
||||
// });
|
||||
|
||||
if (!isDesignMode && tableConfig.selectedTable) {
|
||||
fetchTableDataDebounced();
|
||||
|
|
|
|||
|
|
@ -161,5 +161,5 @@ ComponentRegistry.registerComponent({
|
|||
},
|
||||
});
|
||||
|
||||
console.log("✅ 탭 컴포넌트 등록 완료");
|
||||
// console.log("✅ 탭 컴포넌트 등록 완료");
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -430,27 +430,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("✅ 채번 규칙 할당 완료");
|
||||
|
|
@ -1055,6 +1040,7 @@ export class ButtonActionExecutor {
|
|||
title: config.modalTitle,
|
||||
size: config.modalSize,
|
||||
targetScreenId: config.targetScreenId,
|
||||
selectedRowsData: context.selectedRowsData,
|
||||
});
|
||||
|
||||
if (config.targetScreenId) {
|
||||
|
|
@ -1071,6 +1057,10 @@ export class ButtonActionExecutor {
|
|||
}
|
||||
}
|
||||
|
||||
// 🆕 선택된 행 데이터 수집
|
||||
const selectedData = context.selectedRowsData || [];
|
||||
console.log("📦 [handleModal] 선택된 데이터:", selectedData);
|
||||
|
||||
// 전역 모달 상태 업데이트를 위한 이벤트 발생
|
||||
const modalEvent = new CustomEvent("openScreenModal", {
|
||||
detail: {
|
||||
|
|
@ -1078,6 +1068,9 @@ export class ButtonActionExecutor {
|
|||
title: config.modalTitle || "화면",
|
||||
description: description,
|
||||
size: config.modalSize || "md",
|
||||
// 🆕 선택된 행 데이터 전달
|
||||
selectedData: selectedData,
|
||||
selectedIds: selectedData.map((row: any) => row.id).filter(Boolean),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -549,6 +549,11 @@ export interface ScreenDefinition {
|
|||
updatedBy?: string;
|
||||
dbSourceType?: "internal" | "external";
|
||||
dbConnectionId?: number;
|
||||
// REST API 관련 필드
|
||||
dataSourceType?: "database" | "restapi";
|
||||
restApiConnectionId?: number;
|
||||
restApiEndpoint?: string;
|
||||
restApiJsonPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -563,6 +568,11 @@ export interface CreateScreenRequest {
|
|||
description?: string;
|
||||
dbSourceType?: "internal" | "external";
|
||||
dbConnectionId?: number;
|
||||
// REST API 관련 필드
|
||||
dataSourceType?: "database" | "restapi";
|
||||
restApiConnectionId?: number;
|
||||
restApiEndpoint?: string;
|
||||
restApiJsonPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue