Compare commits
27 Commits
gbpark-node
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f8ef029e10 | |||
| 5cd8e72bf0 | |||
| 387a1ae611 | |||
| eeb130e3a8 | |||
| 3ffa5c8ff5 | |||
| acbab68a12 | |||
| db63ba6901 | |||
| ff95c1950e | |||
| 8a9285f13e | |||
| 88b0549a6d | |||
| 33f0647c61 | |||
| 8606f0aaa3 | |||
| 24106929fa | |||
| f530b3cf31 | |||
| 99487049fb | |||
| 6233877029 | |||
| 4031fe8b60 | |||
| a5288647c9 | |||
| 7dbeccc182 | |||
| c857e4f715 | |||
| cf5f7ef9af | |||
| 7e71730015 | |||
| 2d39d17428 | |||
| 30ebb14023 | |||
| 895cb48ee0 | |||
| 067193efa9 | |||
| 8a10edd8e1 |
@@ -1,3 +1,82 @@
|
|||||||
|
<!-- User customizations -->
|
||||||
|
# 절대 규칙: 검증 없는 주장 금지
|
||||||
|
|
||||||
|
내가 출력하는 모든 발언은 근거가 있어야 한다. 근거가 없으면 그 말을 하지 않는다. 위로·추정·일반론·"보통 그렇다"로 채우지 않는다.
|
||||||
|
|
||||||
|
## 위반 사례 (절대 하지 말 것)
|
||||||
|
- "100명 중 5명도 안 된다" 같은 통계를 출처 없이 만들어내기
|
||||||
|
- "통과 확률 70~80%" 같은 수치를 추정으로 제시하기
|
||||||
|
- "보통", "일반적으로", "대부분" 으로 시작하는 일반론
|
||||||
|
- 본인이 검증 안 한 SDK/API 동작을 단정적으로 설명하기
|
||||||
|
- 위로·격려를 위해 사실이 아닌 것을 끼워넣기
|
||||||
|
|
||||||
|
## 발화 전 자기 검증
|
||||||
|
한 문장이라도 출력하기 전에 다음을 확인:
|
||||||
|
1. **출처가 있는가?** — 코드(파일:라인), 명령 결과, 공식 문서, 사용자가 준 정보, 도구 호출 결과 중 하나
|
||||||
|
2. **출처가 없다면 추정인가?** — 추정이면 명시적으로 "추정이지만…" 또는 "확인 안 됐지만…" 으로 시작
|
||||||
|
3. **추정도 근거가 없으면?** — 말하지 않는다. "모릅니다" 또는 "확인이 필요합니다" 라고 한다
|
||||||
|
|
||||||
|
## 모를 때의 정답
|
||||||
|
- 검색·문서 조회·코드 읽기로 확인 가능하면 확인부터 한다
|
||||||
|
- 확인이 불가능하면 "모릅니다" 가 정답. 그럴듯한 답을 만들지 않는다
|
||||||
|
- 사용자 의사결정에 영향을 주는 사실일수록 더 엄격하게 적용
|
||||||
|
|
||||||
|
## 어겼을 때
|
||||||
|
사용자가 "그 근거 뭐야" 라고 묻거나 잘못된 사실을 지적하면:
|
||||||
|
- 즉시 인정. "맞습니다. 그 수치 제가 지어냈습니다." 같이 명시적으로 시인
|
||||||
|
- 변명·재포장 금지
|
||||||
|
- 무엇이 검증된 사실이고 무엇이 추정/날조였는지 다시 분리해서 제시
|
||||||
|
|
||||||
|
|
||||||
|
# 💬 사용자에게 설명할 때 — 그림으로 (★ 중요)
|
||||||
|
|
||||||
|
UI 변경 제안, 디자인 토론, 코드 구조 설명 등을 할 때는 **반드시 변경 전/후를 ASCII 표나 도식으로 그려서** 보여준다. 글로만 설명하면 사용자가 이해 못 한다.
|
||||||
|
|
||||||
|
## 원칙
|
||||||
|
|
||||||
|
1. **변경 제안은 무조건 Before / After 두 그림**
|
||||||
|
2. **코드 인용 (file:line, 변수명, CSS class) 최소화** — 결론과 시각적 영향 위주
|
||||||
|
3. **평어, 한국어, 짧은 문장**
|
||||||
|
4. **영문/SQL/전문용어 풀어쓰기** — "grid template" 대신 "표 컬럼 배치", "stopPropagation" 대신 "클릭이 위로 새는 거 막기"
|
||||||
|
5. **3줄 패턴 권장** — 무슨 일 / 사용자한테 보이는 영향 / 어떻게 고치는지
|
||||||
|
|
||||||
|
## 나쁜 예시 ❌
|
||||||
|
|
||||||
|
> "ColumnGrid.tsx:93-103 의 `grid-cols-[4px_140px_1fr_100px_160px_40px]` 를 5컬럼으로 축소하고, 라벨 셀에 sub-line 을 추가하면 entity/code/numbering 의 메타가 inline 으로..."
|
||||||
|
|
||||||
|
(사용자: "뭐라는지 모르겠어")
|
||||||
|
|
||||||
|
## 좋은 예시 ⭕
|
||||||
|
|
||||||
|
> **지금 모양:**
|
||||||
|
> ```
|
||||||
|
> 라벨·컬럼명 │ 참조/설정 │ 타입
|
||||||
|
> 거래처명 │ — │ 텍스트 ← 빈 칸
|
||||||
|
> 거래처ID │ customer_mng → ... │ 테이블참조
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> **바꿔서:**
|
||||||
|
> ```
|
||||||
|
> 라벨·컬럼명 │ 타입
|
||||||
|
> 거래처명 │ 텍스트
|
||||||
|
> 거래처ID │ 테이블참조
|
||||||
|
> → customer_mng.id ← 정보 있을 때만 작게 밑에
|
||||||
|
> ```
|
||||||
|
|
||||||
|
## 옵션 제시할 땐 표로
|
||||||
|
|
||||||
|
```
|
||||||
|
| 옵션 | 핵심 | 단점 |
|
||||||
|
| A안 | 이름만 바꾸기 | 가장 가벼움 |
|
||||||
|
| B안 | 그룹을 잘게 쪼개기 | 그룹 수 늘어남 |
|
||||||
|
```
|
||||||
|
|
||||||
|
## 우선 순위
|
||||||
|
- 첫 시도에 글만 쓰지 말 것. 그림부터 그리고 글은 짧게 보충.
|
||||||
|
- 사용자가 "무슨 말인지 모르겠어" 하면 → 더 분해해서 다시 그림 그리기. 글 길어지면 더 헷갈림.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# INVYONE — Claude 작업 컨벤션
|
# INVYONE — Claude 작업 컨벤션
|
||||||
|
|
||||||
이 파일은 git 에 올라가는 **프로젝트 공용** Claude 가이드입니다. 모든 머신/팀원의 Claude Code 인스턴스가 이 컨벤션을 따라야 합니다.
|
이 파일은 git 에 올라가는 **프로젝트 공용** Claude 가이드입니다. 모든 머신/팀원의 Claude Code 인스턴스가 이 컨벤션을 따라야 합니다.
|
||||||
|
|||||||
@@ -136,6 +136,15 @@ public class BatchManagementController {
|
|||||||
return ResponseEntity.ok(ApiResponse.success(batchManagementService.getBatchSparkline(params)));
|
return ResponseEntity.ok(ApiResponse.success(batchManagementService.getBatchSparkline(params)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GET /api/batch-management/sparkline — 회사 전체 배치의 최근 24시간 1시간 단위 실행 집계 (24개 슬롯 고정) */
|
||||||
|
@GetMapping("/sparkline")
|
||||||
|
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getGlobalSparkline(
|
||||||
|
@RequestAttribute("company_code") String companyCode) {
|
||||||
|
Map<String, Object> params = new HashMap<>();
|
||||||
|
params.put("company_code", companyCode);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(batchManagementService.getGlobalSparkline(params)));
|
||||||
|
}
|
||||||
|
|
||||||
/** GET /api/batch-management/batch-configs/:id/recent-logs — 최근 실행 로그 (최대 20건) */
|
/** GET /api/batch-management/batch-configs/:id/recent-logs — 최근 실행 로그 (최대 20건) */
|
||||||
@GetMapping("/batch-configs/{id}/recent-logs")
|
@GetMapping("/batch-configs/{id}/recent-logs")
|
||||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getBatchRecentLogs(
|
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getBatchRecentLogs(
|
||||||
|
|||||||
@@ -29,11 +29,12 @@ public class DdlController {
|
|||||||
@PostMapping("/tables")
|
@PostMapping("/tables")
|
||||||
public ResponseEntity<ApiResponse<?>> createTable(
|
public ResponseEntity<ApiResponse<?>> createTable(
|
||||||
@RequestAttribute("company_code") String companyCode,
|
@RequestAttribute("company_code") String companyCode,
|
||||||
|
@RequestAttribute(value = "role", required = false) String role,
|
||||||
@RequestAttribute("user_id") String userId,
|
@RequestAttribute("user_id") String userId,
|
||||||
@RequestBody Map<String, Object> body) {
|
@RequestBody Map<String, Object> body) {
|
||||||
|
|
||||||
if (!isSuperAdmin(companyCode)) {
|
if (!isSuperAdmin(companyCode, role)) {
|
||||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||||
}
|
}
|
||||||
|
|
||||||
String tableName = (String) body.get("table_name");
|
String tableName = (String) body.get("table_name");
|
||||||
@@ -65,11 +66,12 @@ public class DdlController {
|
|||||||
public ResponseEntity<ApiResponse<?>> addColumn(
|
public ResponseEntity<ApiResponse<?>> addColumn(
|
||||||
@PathVariable String tableName,
|
@PathVariable String tableName,
|
||||||
@RequestAttribute("company_code") String companyCode,
|
@RequestAttribute("company_code") String companyCode,
|
||||||
|
@RequestAttribute(value = "role", required = false) String role,
|
||||||
@RequestAttribute("user_id") String userId,
|
@RequestAttribute("user_id") String userId,
|
||||||
@RequestBody Map<String, Object> body) {
|
@RequestBody Map<String, Object> body) {
|
||||||
|
|
||||||
if (!isSuperAdmin(companyCode)) {
|
if (!isSuperAdmin(companyCode, role)) {
|
||||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
@@ -99,10 +101,11 @@ public class DdlController {
|
|||||||
@PathVariable String tableName,
|
@PathVariable String tableName,
|
||||||
@PathVariable String columnName,
|
@PathVariable String columnName,
|
||||||
@RequestAttribute("company_code") String companyCode,
|
@RequestAttribute("company_code") String companyCode,
|
||||||
|
@RequestAttribute(value = "role", required = false) String role,
|
||||||
@RequestAttribute("user_id") String userId) {
|
@RequestAttribute("user_id") String userId) {
|
||||||
|
|
||||||
if (!isSuperAdmin(companyCode)) {
|
if (!isSuperAdmin(companyCode, role)) {
|
||||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Object> result = ddlService.dropColumn(tableName, columnName, companyCode, userId);
|
Map<String, Object> result = ddlService.dropColumn(tableName, columnName, companyCode, userId);
|
||||||
@@ -124,10 +127,11 @@ public class DdlController {
|
|||||||
public ResponseEntity<ApiResponse<?>> dropTable(
|
public ResponseEntity<ApiResponse<?>> dropTable(
|
||||||
@PathVariable String tableName,
|
@PathVariable String tableName,
|
||||||
@RequestAttribute("company_code") String companyCode,
|
@RequestAttribute("company_code") String companyCode,
|
||||||
|
@RequestAttribute(value = "role", required = false) String role,
|
||||||
@RequestAttribute("user_id") String userId) {
|
@RequestAttribute("user_id") String userId) {
|
||||||
|
|
||||||
if (!isSuperAdmin(companyCode)) {
|
if (!isSuperAdmin(companyCode, role)) {
|
||||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Object> result = ddlService.dropTable(tableName, companyCode, userId);
|
Map<String, Object> result = ddlService.dropTable(tableName, companyCode, userId);
|
||||||
@@ -147,10 +151,11 @@ public class DdlController {
|
|||||||
@PostMapping("/validate/table")
|
@PostMapping("/validate/table")
|
||||||
public ResponseEntity<ApiResponse<?>> validateTableCreation(
|
public ResponseEntity<ApiResponse<?>> validateTableCreation(
|
||||||
@RequestAttribute("company_code") String companyCode,
|
@RequestAttribute("company_code") String companyCode,
|
||||||
|
@RequestAttribute(value = "role", required = false) String role,
|
||||||
@RequestBody Map<String, Object> body) {
|
@RequestBody Map<String, Object> body) {
|
||||||
|
|
||||||
if (!isSuperAdmin(companyCode)) {
|
if (!isSuperAdmin(companyCode, role)) {
|
||||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||||
}
|
}
|
||||||
|
|
||||||
String tableName = (String) body.get("table_name");
|
String tableName = (String) body.get("table_name");
|
||||||
@@ -176,12 +181,13 @@ public class DdlController {
|
|||||||
@GetMapping("/logs")
|
@GetMapping("/logs")
|
||||||
public ResponseEntity<ApiResponse<?>> getDdlLogs(
|
public ResponseEntity<ApiResponse<?>> getDdlLogs(
|
||||||
@RequestAttribute("company_code") String companyCode,
|
@RequestAttribute("company_code") String companyCode,
|
||||||
|
@RequestAttribute(value = "role", required = false) String role,
|
||||||
@RequestParam(required = false, defaultValue = "50") int limit,
|
@RequestParam(required = false, defaultValue = "50") int limit,
|
||||||
@RequestParam(required = false) String userId,
|
@RequestParam(required = false) String userId,
|
||||||
@RequestParam(required = false) String ddlType) {
|
@RequestParam(required = false) String ddlType) {
|
||||||
|
|
||||||
if (!isSuperAdmin(companyCode)) {
|
if (!isSuperAdmin(companyCode, role)) {
|
||||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Map<String, Object>> logs = ddlService.getDdlLogs(limit, userId, ddlType);
|
List<Map<String, Object>> logs = ddlService.getDdlLogs(limit, userId, ddlType);
|
||||||
@@ -195,11 +201,12 @@ public class DdlController {
|
|||||||
@GetMapping("/statistics")
|
@GetMapping("/statistics")
|
||||||
public ResponseEntity<ApiResponse<?>> getDdlStatistics(
|
public ResponseEntity<ApiResponse<?>> getDdlStatistics(
|
||||||
@RequestAttribute("company_code") String companyCode,
|
@RequestAttribute("company_code") String companyCode,
|
||||||
|
@RequestAttribute(value = "role", required = false) String role,
|
||||||
@RequestParam(required = false) String fromDate,
|
@RequestParam(required = false) String fromDate,
|
||||||
@RequestParam(required = false) String toDate) {
|
@RequestParam(required = false) String toDate) {
|
||||||
|
|
||||||
if (!isSuperAdmin(companyCode)) {
|
if (!isSuperAdmin(companyCode, role)) {
|
||||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Object> statistics = ddlService.getDdlStatistics(fromDate, toDate);
|
Map<String, Object> statistics = ddlService.getDdlStatistics(fromDate, toDate);
|
||||||
@@ -212,10 +219,11 @@ public class DdlController {
|
|||||||
@GetMapping("/tables/{tableName}/history")
|
@GetMapping("/tables/{tableName}/history")
|
||||||
public ResponseEntity<ApiResponse<?>> getTableDdlHistory(
|
public ResponseEntity<ApiResponse<?>> getTableDdlHistory(
|
||||||
@PathVariable String tableName,
|
@PathVariable String tableName,
|
||||||
@RequestAttribute("company_code") String companyCode) {
|
@RequestAttribute("company_code") String companyCode,
|
||||||
|
@RequestAttribute(value = "role", required = false) String role) {
|
||||||
|
|
||||||
if (!isSuperAdmin(companyCode)) {
|
if (!isSuperAdmin(companyCode, role)) {
|
||||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Map<String, Object>> history = ddlService.getTableDdlHistory(tableName);
|
List<Map<String, Object>> history = ddlService.getTableDdlHistory(tableName);
|
||||||
@@ -230,10 +238,11 @@ public class DdlController {
|
|||||||
@GetMapping("/tables/{tableName}/info")
|
@GetMapping("/tables/{tableName}/info")
|
||||||
public ResponseEntity<ApiResponse<?>> getTableInfo(
|
public ResponseEntity<ApiResponse<?>> getTableInfo(
|
||||||
@PathVariable String tableName,
|
@PathVariable String tableName,
|
||||||
@RequestAttribute("company_code") String companyCode) {
|
@RequestAttribute("company_code") String companyCode,
|
||||||
|
@RequestAttribute(value = "role", required = false) String role) {
|
||||||
|
|
||||||
if (!isSuperAdmin(companyCode)) {
|
if (!isSuperAdmin(companyCode, role)) {
|
||||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, Object> tableInfo = ddlService.getTableInfo(tableName);
|
Map<String, Object> tableInfo = ddlService.getTableInfo(tableName);
|
||||||
@@ -255,10 +264,11 @@ public class DdlController {
|
|||||||
@DeleteMapping("/logs/cleanup")
|
@DeleteMapping("/logs/cleanup")
|
||||||
public ResponseEntity<ApiResponse<?>> cleanupOldLogs(
|
public ResponseEntity<ApiResponse<?>> cleanupOldLogs(
|
||||||
@RequestAttribute("company_code") String companyCode,
|
@RequestAttribute("company_code") String companyCode,
|
||||||
|
@RequestAttribute(value = "role", required = false) String role,
|
||||||
@RequestParam(required = false, defaultValue = "90") int retentionDays) {
|
@RequestParam(required = false, defaultValue = "90") int retentionDays) {
|
||||||
|
|
||||||
if (!isSuperAdmin(companyCode)) {
|
if (!isSuperAdmin(companyCode, role)) {
|
||||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||||
}
|
}
|
||||||
|
|
||||||
int deletedCount = ddlService.cleanupOldLogs(retentionDays);
|
int deletedCount = ddlService.cleanupOldLogs(retentionDays);
|
||||||
@@ -272,10 +282,11 @@ public class DdlController {
|
|||||||
*/
|
*/
|
||||||
@GetMapping("/info")
|
@GetMapping("/info")
|
||||||
public ResponseEntity<ApiResponse<?>> getInfo(
|
public ResponseEntity<ApiResponse<?>> getInfo(
|
||||||
@RequestAttribute("company_code") String companyCode) {
|
@RequestAttribute("company_code") String companyCode,
|
||||||
|
@RequestAttribute(value = "role", required = false) String role) {
|
||||||
|
|
||||||
if (!isSuperAdmin(companyCode)) {
|
if (!isSuperAdmin(companyCode, role)) {
|
||||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||||
}
|
}
|
||||||
|
|
||||||
return ResponseEntity.ok(ApiResponse.success(Map.of(
|
return ResponseEntity.ok(ApiResponse.success(Map.of(
|
||||||
@@ -318,7 +329,9 @@ public class DdlController {
|
|||||||
// 내부 유틸
|
// 내부 유틸
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private boolean isSuperAdmin(String companyCode) {
|
private boolean isSuperAdmin(String companyCode, String role) {
|
||||||
return "*".equals(companyCode);
|
// company_code 가 '*' 이고 role 이 SUPER_ADMIN 둘 다 충족해야 통과 (이중 체크).
|
||||||
|
// 토큰 변조 또는 회사코드만으로 super 권한이 발급되는 사고 방지.
|
||||||
|
return "*".equals(companyCode) && "SUPER_ADMIN".equals(role);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,7 +187,8 @@ public class TableManagementController {
|
|||||||
@PathVariable String tableName,
|
@PathVariable String tableName,
|
||||||
@PathVariable String columnName,
|
@PathVariable String columnName,
|
||||||
@RequestBody Map<String, Object> body,
|
@RequestBody Map<String, Object> body,
|
||||||
@RequestAttribute("role") String role) {
|
@RequestAttribute("role") String role,
|
||||||
|
@RequestAttribute("company_code") String companyCode) {
|
||||||
if (!isAdmin(role)) {
|
if (!isAdmin(role)) {
|
||||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||||
}
|
}
|
||||||
@@ -197,7 +198,8 @@ public class TableManagementController {
|
|||||||
}
|
}
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
Map<String, Object> detailSettings = (Map<String, Object>) body.get("detail_settings");
|
Map<String, Object> detailSettings = (Map<String, Object>) body.get("detail_settings");
|
||||||
tableManagementService.updateColumnWebType(tableName, columnName, webType, detailSettings);
|
// 멀티테넌트 격리: SUPER_ADMIN(company_code='*') 가 아니면 자기 회사 코드로 저장
|
||||||
|
tableManagementService.updateColumnWebType(tableName, columnName, webType, detailSettings, companyCode);
|
||||||
return ResponseEntity.ok(ApiResponse.success(null, "컬럼 웹타입이 설정되었습니다."));
|
return ResponseEntity.ok(ApiResponse.success(null, "컬럼 웹타입이 설정되었습니다."));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,7 +274,7 @@ public class TableManagementController {
|
|||||||
@RequestBody Map<String, Object> body,
|
@RequestBody Map<String, Object> body,
|
||||||
@RequestAttribute("role") String role) {
|
@RequestAttribute("role") String role) {
|
||||||
if (!isSuperAdmin(role)) {
|
if (!isSuperAdmin(role)) {
|
||||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||||
}
|
}
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
List<String> columns = (List<String>) body.get("columns");
|
List<String> columns = (List<String>) body.get("columns");
|
||||||
@@ -291,7 +293,7 @@ public class TableManagementController {
|
|||||||
@RequestBody Map<String, Object> body,
|
@RequestBody Map<String, Object> body,
|
||||||
@RequestAttribute("role") String role) {
|
@RequestAttribute("role") String role) {
|
||||||
if (!isSuperAdmin(role)) {
|
if (!isSuperAdmin(role)) {
|
||||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||||
}
|
}
|
||||||
String columnName = (String) body.get("column_name");
|
String columnName = (String) body.get("column_name");
|
||||||
String indexType = (String) body.get("index_type");
|
String indexType = (String) body.get("index_type");
|
||||||
@@ -320,7 +322,7 @@ public class TableManagementController {
|
|||||||
@RequestAttribute("role") String role,
|
@RequestAttribute("role") String role,
|
||||||
@RequestAttribute("company_code") String companyCode) {
|
@RequestAttribute("company_code") String companyCode) {
|
||||||
if (!isSuperAdmin(role)) {
|
if (!isSuperAdmin(role)) {
|
||||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||||
}
|
}
|
||||||
Object nullableObj = body.get("nullable");
|
Object nullableObj = body.get("nullable");
|
||||||
if (tableName == null || columnName == null || !(nullableObj instanceof Boolean)) {
|
if (tableName == null || columnName == null || !(nullableObj instanceof Boolean)) {
|
||||||
@@ -342,7 +344,7 @@ public class TableManagementController {
|
|||||||
@RequestAttribute("role") String role,
|
@RequestAttribute("role") String role,
|
||||||
@RequestAttribute("company_code") String companyCode) {
|
@RequestAttribute("company_code") String companyCode) {
|
||||||
if (!isSuperAdmin(role)) {
|
if (!isSuperAdmin(role)) {
|
||||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||||
}
|
}
|
||||||
Object uniqueObj = body.get("unique");
|
Object uniqueObj = body.get("unique");
|
||||||
if (tableName == null || columnName == null || !(uniqueObj instanceof Boolean)) {
|
if (tableName == null || columnName == null || !(uniqueObj instanceof Boolean)) {
|
||||||
@@ -567,7 +569,7 @@ public class TableManagementController {
|
|||||||
@RequestBody Map<String, Object> body,
|
@RequestBody Map<String, Object> body,
|
||||||
@RequestAttribute("role") String role) {
|
@RequestAttribute("role") String role) {
|
||||||
if (!isSuperAdmin(role)) {
|
if (!isSuperAdmin(role)) {
|
||||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||||
}
|
}
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
List<String> logColumns = (List<String>) body.get("log_columns");
|
List<String> logColumns = (List<String>) body.get("log_columns");
|
||||||
|
|||||||
@@ -296,6 +296,11 @@ public class BatchManagementService extends BaseService {
|
|||||||
return sqlSession.selectList(NS + "getBatchManagementSparklineData", params);
|
return sqlSession.selectList(NS + "getBatchManagementSparklineData", params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<Map<String, Object>> getGlobalSparkline(Map<String, Object> params) {
|
||||||
|
commonService.applyCompanyCodeFilter(params);
|
||||||
|
return sqlSession.selectList(NS + "getBatchManagementGlobalSparklineData", params);
|
||||||
|
}
|
||||||
|
|
||||||
public List<Map<String, Object>> getBatchRecentLogs(Map<String, Object> params) {
|
public List<Map<String, Object>> getBatchRecentLogs(Map<String, Object> params) {
|
||||||
return sqlSession.selectList(NS + "getBatchManagementRecentLogList", params);
|
return sqlSession.selectList(NS + "getBatchManagementRecentLogList", params);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -176,6 +176,21 @@ public class TableManagementService extends BaseService {
|
|||||||
params.put("display_column", "entity".equals(inputType) ? settings.get("display_column") : null);
|
params.put("display_column", "entity".equals(inputType) ? settings.get("display_column") : null);
|
||||||
params.put("display_order", settings.getOrDefault("display_order", 0));
|
params.put("display_order", settings.getOrDefault("display_order", 0));
|
||||||
params.put("is_visible", settings.getOrDefault("is_visible", true));
|
params.put("is_visible", settings.getOrDefault("is_visible", true));
|
||||||
|
// is_nullable: 'Y'/'N' 또는 null. null 이면 mapper 의 COALESCE 로 기존 값 유지.
|
||||||
|
Object rawIsNullable = settings.get("is_nullable");
|
||||||
|
if (rawIsNullable != null) {
|
||||||
|
String s = rawIsNullable.toString();
|
||||||
|
// 프론트가 'YES'/'NO' 또는 'Y'/'N' 어느 쪽이든 보낼 수 있어 정규화
|
||||||
|
if ("NO".equalsIgnoreCase(s) || "N".equalsIgnoreCase(s) || "FALSE".equalsIgnoreCase(s)) {
|
||||||
|
params.put("is_nullable", "N");
|
||||||
|
} else if ("YES".equalsIgnoreCase(s) || "Y".equalsIgnoreCase(s) || "TRUE".equalsIgnoreCase(s)) {
|
||||||
|
params.put("is_nullable", "Y");
|
||||||
|
} else {
|
||||||
|
params.put("is_nullable", null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
params.put("is_nullable", null);
|
||||||
|
}
|
||||||
params.put("company_code", companyCode);
|
params.put("company_code", companyCode);
|
||||||
params.put("category_ref", "category".equals(inputType) ? settings.get("category_ref") : null);
|
params.put("category_ref", "category".equals(inputType) ? settings.get("category_ref") : null);
|
||||||
sqlSession.update(NS + "upsertColumnSettings", params);
|
sqlSession.update(NS + "upsertColumnSettings", params);
|
||||||
@@ -200,19 +215,21 @@ public class TableManagementService extends BaseService {
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void updateColumnWebType(String tableName, String columnName,
|
public void updateColumnWebType(String tableName, String columnName,
|
||||||
String webType, Map<String, Object> detailSettings) {
|
String webType, Map<String, Object> detailSettings,
|
||||||
|
String companyCode) {
|
||||||
String finalType = normalizeInputType(webType);
|
String finalType = normalizeInputType(webType);
|
||||||
Map<String, Object> params = new HashMap<>();
|
Map<String, Object> params = new HashMap<>();
|
||||||
params.put("table_name", tableName);
|
params.put("table_name", tableName);
|
||||||
params.put("column_name", columnName);
|
params.put("column_name", columnName);
|
||||||
params.put("input_type", finalType);
|
params.put("input_type", finalType);
|
||||||
params.put("detail_settings", detailSettings != null ? toJsonString(detailSettings) : "{}");
|
params.put("detail_settings", detailSettings != null ? toJsonString(detailSettings) : "{}");
|
||||||
params.put("company_code", "*");
|
// 멀티테넌트 격리: SUPER_ADMIN("*") 은 공통 설정, 그 외는 회사별 설정
|
||||||
|
params.put("company_code", companyCode != null ? companyCode : "*");
|
||||||
params.put("clear_entity", false);
|
params.put("clear_entity", false);
|
||||||
params.put("clear_code", false);
|
params.put("clear_code", false);
|
||||||
params.put("clear_category", false);
|
params.put("clear_category", false);
|
||||||
sqlSession.update(NS + "upsertColumnInputType", params);
|
sqlSession.update(NS + "upsertColumnInputType", params);
|
||||||
log.info("컬럼 웹타입 설정: {}.{} = {}", tableName, columnName, finalType);
|
log.info("컬럼 웹타입 설정: {}.{} = {} (company={})", tableName, columnName, finalType, companyCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -383,12 +400,14 @@ public class TableManagementService extends BaseService {
|
|||||||
String safeTable = sanitize(tableName);
|
String safeTable = sanitize(tableName);
|
||||||
List<String> violations = new ArrayList<>();
|
List<String> violations = new ArrayList<>();
|
||||||
|
|
||||||
|
// N+N → N+1 최적화: hasColumn 은 information_schema 조회라 비싸. 루프 밖에서 한 번만 수행.
|
||||||
|
boolean hasCompanyCode = hasColumn(safeTable, "company_code");
|
||||||
|
|
||||||
for (Map<String, Object> col : uniqueCols) {
|
for (Map<String, Object> col : uniqueCols) {
|
||||||
String colName = (String) col.get("column_name");
|
String colName = (String) col.get("column_name");
|
||||||
Object val = data.get(colName);
|
Object val = data.get(colName);
|
||||||
if (val == null) continue;
|
if (val == null) continue;
|
||||||
|
|
||||||
boolean hasCompanyCode = hasColumn(safeTable, "company_code");
|
|
||||||
String sql;
|
String sql;
|
||||||
List<Object> sqlParams = new ArrayList<>();
|
List<Object> sqlParams = new ArrayList<>();
|
||||||
|
|
||||||
@@ -1252,9 +1271,40 @@ public class TableManagementService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** SQL injection 방지용 식별자 정리 */
|
/** SQL injection 방지용 식별자 정리 */
|
||||||
|
/**
|
||||||
|
* SQL 식별자(테이블/컬럼명) 살균.
|
||||||
|
* - 영숫자/언더스코어만 허용 (PostgreSQL identifier 규칙)
|
||||||
|
* - 빈 문자열, 숫자로 시작, 63자 초과, SQL 예약어 거부 → IllegalArgumentException
|
||||||
|
*
|
||||||
|
* 이렇게 가드해두지 않으면 동적 SQL 에 빈 식별자가 들어가거나 예약어가 통과해
|
||||||
|
* 의도치 않은 컬럼에 접근하거나 SQL 문법 깨짐(500) 이 생김.
|
||||||
|
*/
|
||||||
|
private static final java.util.Set<String> SQL_RESERVED_WORDS = java.util.Set.of(
|
||||||
|
"user", "order", "group", "table", "column", "index", "select", "insert",
|
||||||
|
"update", "delete", "from", "where", "join", "on", "as", "and", "or", "not",
|
||||||
|
"null", "true", "false", "create", "alter", "drop", "primary", "key",
|
||||||
|
"foreign", "references", "constraint", "default", "unique", "check",
|
||||||
|
"view", "procedure", "function"
|
||||||
|
);
|
||||||
|
|
||||||
private String sanitize(String name) {
|
private String sanitize(String name) {
|
||||||
if (name == null) return "";
|
if (name == null) {
|
||||||
return name.replaceAll("[^a-zA-Z0-9_]", "");
|
throw new IllegalArgumentException("식별자가 null 입니다.");
|
||||||
|
}
|
||||||
|
String cleaned = name.replaceAll("[^a-zA-Z0-9_]", "");
|
||||||
|
if (cleaned.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException("식별자가 비어있거나 유효하지 않습니다: " + name);
|
||||||
|
}
|
||||||
|
if (cleaned.length() > 63) {
|
||||||
|
throw new IllegalArgumentException("식별자가 63자를 초과합니다: " + cleaned);
|
||||||
|
}
|
||||||
|
if (Character.isDigit(cleaned.charAt(0))) {
|
||||||
|
throw new IllegalArgumentException("식별자는 숫자로 시작할 수 없습니다: " + cleaned);
|
||||||
|
}
|
||||||
|
if (SQL_RESERVED_WORDS.contains(cleaned.toLowerCase())) {
|
||||||
|
throw new IllegalArgumentException("'" + cleaned + "' 은 SQL 예약어라 식별자로 사용할 수 없습니다.");
|
||||||
|
}
|
||||||
|
return cleaned;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** "direct" / "auto" → "text" 변환 (legacy 호출처 보호 — system-normalize 동작) */
|
/** "direct" / "auto" → "text" 변환 (legacy 호출처 보호 — system-normalize 동작) */
|
||||||
|
|||||||
@@ -87,6 +87,32 @@
|
|||||||
ORDER BY hour_slot
|
ORDER BY hour_slot
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<!-- 글로벌 스파크라인: 회사 전체 배치의 최근 24시간 1시간 단위 실행 집계 (빈 슬롯 포함 24개 고정) -->
|
||||||
|
<select id="getBatchManagementGlobalSparklineData" parameterType="map" resultType="map">
|
||||||
|
WITH hours AS (
|
||||||
|
SELECT generate_series(
|
||||||
|
DATE_TRUNC('hour', NOW() - INTERVAL '23 hours'),
|
||||||
|
DATE_TRUNC('hour', NOW()),
|
||||||
|
INTERVAL '1 hour'
|
||||||
|
) AS hour_slot
|
||||||
|
),
|
||||||
|
filtered_logs AS (
|
||||||
|
SELECT DATE_TRUNC('hour', start_time) AS hour_slot,
|
||||||
|
execution_status
|
||||||
|
FROM batch_execution_logs
|
||||||
|
WHERE start_time >= NOW() - INTERVAL '24 hours'
|
||||||
|
<include refid="common.companyCodeFilter"/>
|
||||||
|
)
|
||||||
|
SELECT h.hour_slot,
|
||||||
|
COUNT(l.execution_status) AS total_count,
|
||||||
|
COALESCE(SUM(CASE WHEN l.execution_status = 'SUCCESS' THEN 1 ELSE 0 END), 0) AS success_count,
|
||||||
|
COALESCE(SUM(CASE WHEN l.execution_status = 'FAILED' THEN 1 ELSE 0 END), 0) AS failed_count
|
||||||
|
FROM hours h
|
||||||
|
LEFT JOIN filtered_logs l ON l.hour_slot = h.hour_slot
|
||||||
|
GROUP BY h.hour_slot
|
||||||
|
ORDER BY h.hour_slot
|
||||||
|
</select>
|
||||||
|
|
||||||
<!-- 최근 실행 로그 목록 (최대 20건) -->
|
<!-- 최근 실행 로그 목록 (최대 20건) -->
|
||||||
<select id="getBatchManagementRecentLogList" parameterType="map" resultType="map">
|
<select id="getBatchManagementRecentLogList" parameterType="map" resultType="map">
|
||||||
SELECT id,
|
SELECT id,
|
||||||
|
|||||||
@@ -300,7 +300,7 @@
|
|||||||
, #{display_column}
|
, #{display_column}
|
||||||
, #{display_order}
|
, #{display_order}
|
||||||
, #{is_visible}
|
, #{is_visible}
|
||||||
, 'Y'
|
, COALESCE(#{is_nullable}, 'Y')
|
||||||
, #{company_code}
|
, #{company_code}
|
||||||
, #{category_ref}
|
, #{category_ref}
|
||||||
, NOW()
|
, NOW()
|
||||||
@@ -318,6 +318,7 @@
|
|||||||
, DISPLAY_COLUMN = EXCLUDED.DISPLAY_COLUMN
|
, DISPLAY_COLUMN = EXCLUDED.DISPLAY_COLUMN
|
||||||
, DISPLAY_ORDER = COALESCE(EXCLUDED.DISPLAY_ORDER, TABLE_TYPE_COLUMNS.DISPLAY_ORDER)
|
, DISPLAY_ORDER = COALESCE(EXCLUDED.DISPLAY_ORDER, TABLE_TYPE_COLUMNS.DISPLAY_ORDER)
|
||||||
, IS_VISIBLE = COALESCE(EXCLUDED.IS_VISIBLE, TABLE_TYPE_COLUMNS.IS_VISIBLE)
|
, IS_VISIBLE = COALESCE(EXCLUDED.IS_VISIBLE, TABLE_TYPE_COLUMNS.IS_VISIBLE)
|
||||||
|
, IS_NULLABLE = COALESCE(EXCLUDED.IS_NULLABLE, TABLE_TYPE_COLUMNS.IS_NULLABLE)
|
||||||
, CATEGORY_REF = EXCLUDED.CATEGORY_REF
|
, CATEGORY_REF = EXCLUDED.CATEGORY_REF
|
||||||
, UPDATED_DATE = NOW()
|
, UPDATED_DATE = NOW()
|
||||||
</insert>
|
</insert>
|
||||||
|
|||||||
@@ -128,9 +128,11 @@ function Sparkline({ data }: { data: SparklineData[] }) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex h-8 items-end gap-[2px]">
|
<div className="flex h-8 items-end gap-[2px]">
|
||||||
{data.map((slot, i) => {
|
{data.map((slot, i) => {
|
||||||
const hasFail = slot.failed > 0;
|
const failed = Number(slot.failed_count) || 0;
|
||||||
const hasSuccess = slot.success > 0;
|
const success = Number(slot.success_count) || 0;
|
||||||
const height = hasFail ? "40%" : hasSuccess ? `${Math.max(30, Math.min(95, 50 + slot.success * 10))}%` : "8%";
|
const hasFail = failed > 0;
|
||||||
|
const hasSuccess = success > 0;
|
||||||
|
const height = hasFail ? "40%" : hasSuccess ? `${Math.max(30, Math.min(95, 50 + success * 10))}%` : "8%";
|
||||||
const colorClass = hasFail
|
const colorClass = hasFail
|
||||||
? "bg-destructive/70 hover:bg-destructive"
|
? "bg-destructive/70 hover:bg-destructive"
|
||||||
: hasSuccess
|
: hasSuccess
|
||||||
@@ -141,7 +143,7 @@ function Sparkline({ data }: { data: SparklineData[] }) {
|
|||||||
key={i}
|
key={i}
|
||||||
className={`min-w-[4px] flex-1 rounded-t-sm transition-colors ${colorClass}`}
|
className={`min-w-[4px] flex-1 rounded-t-sm transition-colors ${colorClass}`}
|
||||||
style={{ height }}
|
style={{ height }}
|
||||||
title={`${slot.hour?.slice(11, 16) || i}시 | 성공: ${slot.success} 실패: ${slot.failed}`}
|
title={`${slot.hour_slot?.slice(11, 16) || i}시 | 성공: ${success} 실패: ${failed}`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -278,8 +280,10 @@ function BatchDetailPanel({ batch, sparkline, recentLogs }: { batch: BatchConfig
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function GlobalSparkline({ stats }: { stats: BatchStats | null }) {
|
function GlobalSparkline({ data }: { data: SparklineData[] }) {
|
||||||
if (!stats) return null;
|
if (!data || data.length === 0) return null;
|
||||||
|
// 24개 슬롯 중 가장 큰 success_count 를 100% 로 맞춰 비율 스케일링
|
||||||
|
const maxSuccess = data.reduce((m, s) => Math.max(m, Number(s.success_count) || 0), 0);
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border bg-card p-4">
|
<div className="rounded-lg border bg-card p-4">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
@@ -294,22 +298,31 @@ function GlobalSparkline({ stats }: { stats: BatchStats | null }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex h-10 items-end gap-[3px]">
|
<div className="flex h-10 items-end gap-[3px]">
|
||||||
{Array.from({ length: 24 }).map((_, i) => {
|
{data.map((slot, i) => {
|
||||||
const hasExec = Math.random() > 0.3;
|
const success = Number(slot.success_count) || 0;
|
||||||
const hasFail = hasExec && Math.random() < 0.08;
|
const failed = Number(slot.failed_count) || 0;
|
||||||
const h = hasFail ? 35 : hasExec ? 25 + Math.random() * 70 : 6;
|
const hasFail = failed > 0;
|
||||||
|
const hasExec = success > 0 || hasFail;
|
||||||
|
// 실패가 하나라도 있으면 실패 색으로 강조, 아니면 success 비율
|
||||||
|
const h = hasFail
|
||||||
|
? Math.max(35, Math.min(95, 35 + (failed / Math.max(maxSuccess, 1)) * 60))
|
||||||
|
: hasExec
|
||||||
|
? Math.max(20, Math.min(95, (success / Math.max(maxSuccess, 1)) * 90))
|
||||||
|
: 6;
|
||||||
|
const hour = slot.hour_slot?.slice(11, 16) || "";
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={`flex-1 rounded-t-sm transition-colors ${hasFail ? "bg-destructive/60 hover:bg-destructive" : hasExec ? "bg-emerald-500/40 hover:bg-emerald-500/70" : "bg-muted-foreground/8"}`}
|
className={`flex-1 rounded-t-sm transition-colors ${hasFail ? "bg-destructive/60 hover:bg-destructive" : hasExec ? "bg-emerald-500/40 hover:bg-emerald-500/70" : "bg-muted-foreground/10"}`}
|
||||||
style={{ height: `${h}%` }}
|
style={{ height: `${h}%` }}
|
||||||
|
title={`${hour} | 성공 ${success} 실패 ${failed}`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 flex justify-between text-[10px] text-muted-foreground">
|
<div className="mt-1 flex justify-between text-[10px] text-muted-foreground">
|
||||||
|
<span>24시간 전</span>
|
||||||
<span>12시간 전</span>
|
<span>12시간 전</span>
|
||||||
<span>6시간 전</span>
|
|
||||||
<span>지금</span>
|
<span>지금</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -327,6 +340,7 @@ export default function BatchManagementPage() {
|
|||||||
const [executingBatch, setExecutingBatch] = useState<number | null>(null);
|
const [executingBatch, setExecutingBatch] = useState<number | null>(null);
|
||||||
const [expandedBatch, setExpandedBatch] = useState<number | null>(null);
|
const [expandedBatch, setExpandedBatch] = useState<number | null>(null);
|
||||||
const [stats, setStats] = useState<BatchStats | null>(null);
|
const [stats, setStats] = useState<BatchStats | null>(null);
|
||||||
|
const [globalSparkline, setGlobalSparkline] = useState<SparklineData[]>([]);
|
||||||
const [sparklineCache, setSparklineCache] = useState<Record<number, SparklineData[]>>({});
|
const [sparklineCache, setSparklineCache] = useState<Record<number, SparklineData[]>>({});
|
||||||
const [recentLogsCache, setRecentLogsCache] = useState<Record<number, RecentLog[]>>({});
|
const [recentLogsCache, setRecentLogsCache] = useState<Record<number, RecentLog[]>>({});
|
||||||
const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false);
|
const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false);
|
||||||
@@ -339,10 +353,12 @@ export default function BatchManagementPage() {
|
|||||||
const loadBatchConfigs = useCallback(async () => {
|
const loadBatchConfigs = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const [configsResponse, statsData] = await Promise.all([
|
const [configsResponse, statsData, globalSpark] = await Promise.all([
|
||||||
BatchAPI.getBatchConfigs({ page: 1, limit: 200 }),
|
BatchAPI.getBatchConfigs({ page: 1, limit: 200 }),
|
||||||
BatchAPI.getBatchStats(),
|
BatchAPI.getBatchStats(),
|
||||||
|
BatchAPI.getGlobalSparkline(),
|
||||||
]);
|
]);
|
||||||
|
setGlobalSparkline(globalSpark);
|
||||||
// cross-tenant 메타 (단일 모드면 undefined → null)
|
// cross-tenant 메타 (단일 모드면 undefined → null)
|
||||||
setCrossTenantMeta((configsResponse as any)?.cross_tenant_meta ?? null);
|
setCrossTenantMeta((configsResponse as any)?.cross_tenant_meta ?? null);
|
||||||
if (configsResponse.success && configsResponse.data) {
|
if (configsResponse.success && configsResponse.data) {
|
||||||
@@ -461,8 +477,12 @@ export default function BatchManagementPage() {
|
|||||||
|
|
||||||
const activeBatches = batchConfigs.filter(b => b.is_active === "Y").length;
|
const activeBatches = batchConfigs.filter(b => b.is_active === "Y").length;
|
||||||
const inactiveBatches = batchConfigs.length - activeBatches;
|
const inactiveBatches = batchConfigs.length - activeBatches;
|
||||||
const execDiff = stats ? stats.todayExecutions - stats.prevDayExecutions : 0;
|
const todayExec = Number(stats?.today_count) || 0;
|
||||||
const failDiff = stats ? stats.todayFailures - stats.prevDayFailures : 0;
|
const todayFail = Number(stats?.today_failed_count) || 0;
|
||||||
|
const yestExec = Number(stats?.yesterday_count) || 0;
|
||||||
|
const yestFail = Number(stats?.yesterday_failed_count) || 0;
|
||||||
|
const execDiff = todayExec - yestExec;
|
||||||
|
const failDiff = todayFail - yestFail;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background flex h-full min-h-0 w-full flex-col overflow-hidden">
|
<div className="bg-background flex h-full min-h-0 w-full flex-col overflow-hidden">
|
||||||
@@ -502,7 +522,7 @@ export default function BatchManagementPage() {
|
|||||||
<div className="h-8 w-px bg-border" />
|
<div className="h-8 w-px bg-border" />
|
||||||
<div className="flex flex-1 flex-col px-4 py-3">
|
<div className="flex flex-1 flex-col px-4 py-3">
|
||||||
<span className="text-[11px] text-muted-foreground">오늘 실행</span>
|
<span className="text-[11px] text-muted-foreground">오늘 실행</span>
|
||||||
<span className="text-lg font-bold text-emerald-600">{stats.todayExecutions}</span>
|
<span className="text-lg font-bold text-emerald-600">{todayExec}</span>
|
||||||
{execDiff !== 0 && (
|
{execDiff !== 0 && (
|
||||||
<span className={`text-[10px] ${execDiff > 0 ? "text-emerald-500" : "text-muted-foreground"}`}>
|
<span className={`text-[10px] ${execDiff > 0 ? "text-emerald-500" : "text-muted-foreground"}`}>
|
||||||
어제보다 {execDiff > 0 ? "+" : ""}{execDiff}
|
어제보다 {execDiff > 0 ? "+" : ""}{execDiff}
|
||||||
@@ -512,8 +532,8 @@ export default function BatchManagementPage() {
|
|||||||
<div className="h-8 w-px bg-border" />
|
<div className="h-8 w-px bg-border" />
|
||||||
<div className="flex flex-1 flex-col px-4 py-3">
|
<div className="flex flex-1 flex-col px-4 py-3">
|
||||||
<span className="text-[11px] text-muted-foreground">실패</span>
|
<span className="text-[11px] text-muted-foreground">실패</span>
|
||||||
<span className={`text-lg font-bold ${stats.todayFailures > 0 ? "text-destructive" : "text-muted-foreground"}`}>
|
<span className={`text-lg font-bold ${todayFail > 0 ? "text-destructive" : "text-muted-foreground"}`}>
|
||||||
{stats.todayFailures}
|
{todayFail}
|
||||||
</span>
|
</span>
|
||||||
{failDiff !== 0 && (
|
{failDiff !== 0 && (
|
||||||
<span className={`text-[10px] ${failDiff > 0 ? "text-destructive" : "text-emerald-500"}`}>
|
<span className={`text-[10px] ${failDiff > 0 ? "text-destructive" : "text-emerald-500"}`}>
|
||||||
@@ -525,7 +545,7 @@ export default function BatchManagementPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 24시간 차트 */}
|
{/* 24시간 차트 */}
|
||||||
<GlobalSparkline stats={stats} />
|
<GlobalSparkline data={globalSparkline} />
|
||||||
|
|
||||||
{/* 검색 + 필터 */}
|
{/* 검색 + 필터 */}
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
|||||||
@@ -331,14 +331,14 @@ export default function BatchManagementNewPage() {
|
|||||||
// 내부 데이터베이스 선택
|
// 내부 데이터베이스 선택
|
||||||
connection = connections.find((conn) => conn.type === "internal") || null;
|
connection = connections.find((conn) => conn.type === "internal") || null;
|
||||||
} else {
|
} else {
|
||||||
// 외부 데이터베이스 선택
|
// 외부 데이터베이스 선택 — id 가 number/string 어느 쪽이든 안전하게 비교
|
||||||
const connectionId = parseInt(connectionValue);
|
connection = connections.find((conn) => conn.id?.toString() === connectionValue) || null;
|
||||||
connection = connections.find((conn) => conn.id === connectionId) || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setToConnection(connection);
|
setToConnection(connection);
|
||||||
setToTable("");
|
setToTable("");
|
||||||
setToColumns([]);
|
setToColumns([]);
|
||||||
|
setToTables([]);
|
||||||
|
|
||||||
if (connection) {
|
if (connection) {
|
||||||
try {
|
try {
|
||||||
@@ -383,12 +383,12 @@ export default function BatchManagementNewPage() {
|
|||||||
if (connectionValue === "internal") {
|
if (connectionValue === "internal") {
|
||||||
connection = connections.find((conn) => conn.type === "internal") || null;
|
connection = connections.find((conn) => conn.type === "internal") || null;
|
||||||
} else {
|
} else {
|
||||||
const connectionId = parseInt(connectionValue);
|
connection = connections.find((conn) => conn.id?.toString() === connectionValue) || null;
|
||||||
connection = connections.find((conn) => conn.id === connectionId) || null;
|
|
||||||
}
|
}
|
||||||
setFromConnection(connection);
|
setFromConnection(connection);
|
||||||
setFromTable("");
|
setFromTable("");
|
||||||
setFromColumns([]);
|
setFromColumns([]);
|
||||||
|
setFromTables([]);
|
||||||
|
|
||||||
if (connection) {
|
if (connection) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ import {
|
|||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
Loader2,
|
Loader2,
|
||||||
Pencil,
|
Pencil,
|
||||||
|
Columns3,
|
||||||
|
Link2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -56,6 +59,7 @@ import type { TableInfo, ColumnTypeInfo, SecondLevelMenu } from "@/components/ad
|
|||||||
import { TypeOverviewStrip } from "@/components/admin/table-type/TypeOverviewStrip";
|
import { TypeOverviewStrip } from "@/components/admin/table-type/TypeOverviewStrip";
|
||||||
import { ColumnGrid } from "@/components/admin/table-type/ColumnGrid";
|
import { ColumnGrid } from "@/components/admin/table-type/ColumnGrid";
|
||||||
import { ColumnDetailPanel } from "@/components/admin/table-type/ColumnDetailPanel";
|
import { ColumnDetailPanel } from "@/components/admin/table-type/ColumnDetailPanel";
|
||||||
|
import { ReferenceListView } from "@/components/admin/table-type/ReferenceListView";
|
||||||
|
|
||||||
export default function TableManagementPage() {
|
export default function TableManagementPage() {
|
||||||
const { userLang, getText } = useMultiLang({ companyCode: "*" });
|
const { userLang, getText } = useMultiLang({ companyCode: "*" });
|
||||||
@@ -131,6 +135,8 @@ export default function TableManagementPage() {
|
|||||||
indexes: Array<{ name: string; columns: string[]; is_unique: boolean }>;
|
indexes: Array<{ name: string; columns: string[]; is_unique: boolean }>;
|
||||||
}>({ primaryKey: { name: "", columns: [] }, indexes: [] });
|
}>({ primaryKey: { name: "", columns: [] }, indexes: [] });
|
||||||
const [pkDialogOpen, setPkDialogOpen] = useState(false);
|
const [pkDialogOpen, setPkDialogOpen] = useState(false);
|
||||||
|
// 이번 세션 동안 PK 변경 확인 다이얼로그 건너뛰기 (composite PK 만들 때 매번 다이얼로그 뜨는 답답함 해소)
|
||||||
|
const [pkSkipConfirmSession, setPkSkipConfirmSession] = useState(false);
|
||||||
const [pendingPkColumns, setPendingPkColumns] = useState<string[]>([]);
|
const [pendingPkColumns, setPendingPkColumns] = useState<string[]>([]);
|
||||||
|
|
||||||
// 선택된 테이블 목록 (체크박스)
|
// 선택된 테이블 목록 (체크박스)
|
||||||
@@ -273,11 +279,9 @@ export default function TableManagementPage() {
|
|||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
setSecondLevelMenus(response.data);
|
setSecondLevelMenus(response.data);
|
||||||
} else {
|
} else {
|
||||||
console.warn("⚠️ 2레벨 메뉴 로드 실패:", response);
|
|
||||||
setSecondLevelMenus([]); // 빈 배열로 설정하여 로딩 상태 해제
|
setSecondLevelMenus([]); // 빈 배열로 설정하여 로딩 상태 해제
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 2레벨 메뉴 로드 에러:", error);
|
|
||||||
setSecondLevelMenus([]); // 에러 발생 시에도 빈 배열로 설정
|
setSecondLevelMenus([]); // 에러 발생 시에도 빈 배열로 설정
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -320,12 +324,6 @@ export default function TableManagementPage() {
|
|||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
const data = response.data.data;
|
const data = response.data.data;
|
||||||
|
|
||||||
console.log("📥 원본 API 응답:", {
|
|
||||||
hasColumns: !!(data.columns || data),
|
|
||||||
firstColumn: (data.columns || data)[0],
|
|
||||||
statusColumn: (data.columns || data).find((col: any) => col.column_name === "status"),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 컬럼 데이터에 기본값 설정
|
// 컬럼 데이터에 기본값 설정
|
||||||
const processedColumns = (data.columns || data).map((col: any) => {
|
const processedColumns = (data.columns || data).map((col: any) => {
|
||||||
let hierarchyRole: "large" | "medium" | "small" | undefined = undefined;
|
let hierarchyRole: "large" | "medium" | "small" | undefined = undefined;
|
||||||
@@ -395,9 +393,38 @@ export default function TableManagementPage() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// ESC 키로 우측 상세 패널 닫기 (좁은 화면에서 stuck 방지)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedColumn) return;
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") setSelectedColumn(null);
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, [selectedColumn]);
|
||||||
|
|
||||||
|
// 저장 안 한 변경 사항이 있는지 — columns 와 originalColumns 의 reference 비교 (immutable update 패턴 의존)
|
||||||
|
const hasUnsavedChanges = useMemo(() => {
|
||||||
|
if (columns.length === 0 || originalColumns.length === 0) return false;
|
||||||
|
if (columns.length !== originalColumns.length) return true;
|
||||||
|
// 직렬화 비교 (얕은 ref 만으론 부족 — handleColumnChange 가 새 객체를 만들지만 다른 필드는 같은 ref 일 수 있어서)
|
||||||
|
try {
|
||||||
|
return JSON.stringify(columns) !== JSON.stringify(originalColumns);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [columns, originalColumns]);
|
||||||
|
|
||||||
// 테이블 선택
|
// 테이블 선택
|
||||||
const handleTableSelect = useCallback(
|
const handleTableSelect = useCallback(
|
||||||
(tableName: string) => {
|
(tableName: string) => {
|
||||||
|
if (tableName === selectedTable) return;
|
||||||
|
if (hasUnsavedChanges) {
|
||||||
|
const ok = typeof window !== "undefined"
|
||||||
|
? window.confirm("저장하지 않은 컬럼 변경 사항이 있습니다. 이동하면 변경 내용이 사라집니다. 계속할까요?")
|
||||||
|
: true;
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
setSelectedTable(tableName);
|
setSelectedTable(tableName);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
setColumns([]);
|
setColumns([]);
|
||||||
@@ -412,12 +439,17 @@ export default function TableManagementPage() {
|
|||||||
loadColumnTypes(tableName, 1, pageSize);
|
loadColumnTypes(tableName, 1, pageSize);
|
||||||
loadConstraints(tableName);
|
loadConstraints(tableName);
|
||||||
},
|
},
|
||||||
[loadColumnTypes, loadConstraints, pageSize, tables],
|
[hasUnsavedChanges, loadColumnTypes, loadConstraints, pageSize, selectedTable, tables],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 입력 타입 변경 - 이전 타입의 설정값 초기화 포함
|
// 입력 타입 변경 - 이전 타입의 설정값 초기화 포함
|
||||||
const handleInputTypeChange = useCallback(
|
const handleInputTypeChange = useCallback(
|
||||||
(columnName: string, newInputType: string) => {
|
(columnName: string, newInputType: string) => {
|
||||||
|
// typeFilter 가 활성화된 상태에서 변경된 input_type 이 필터와 불일치하면 자동으로 필터 해제
|
||||||
|
// (그렇지 않으면 사용자가 방금 편집한 행이 그리드에서 갑자기 사라져 혼란)
|
||||||
|
if (typeFilter && typeFilter !== newInputType) {
|
||||||
|
setTypeFilter(null);
|
||||||
|
}
|
||||||
setColumns((prev) =>
|
setColumns((prev) =>
|
||||||
prev.map((col) => {
|
prev.map((col) => {
|
||||||
if (col.column_name === columnName) {
|
if (col.column_name === columnName) {
|
||||||
@@ -608,7 +640,6 @@ export default function TableManagementPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
finalDetailSettings = JSON.stringify(entitySettings);
|
finalDetailSettings = JSON.stringify(entitySettings);
|
||||||
console.log("🔧 Entity 설정 JSON 생성:", entitySettings);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🎯 Code 타입인 경우 hierarchyRole을 detailSettings에 포함
|
// 🎯 Code 타입인 경우 hierarchyRole을 detailSettings에 포함
|
||||||
@@ -628,7 +659,6 @@ export default function TableManagementPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
finalDetailSettings = JSON.stringify(codeSettings);
|
finalDetailSettings = JSON.stringify(codeSettings);
|
||||||
console.log("🔧 Code 계층 역할 설정 JSON 생성:", codeSettings);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const columnSetting = {
|
const columnSetting = {
|
||||||
@@ -646,74 +676,39 @@ export default function TableManagementPage() {
|
|||||||
|
|
||||||
// console.log("저장할 컬럼 설정:", columnSetting);
|
// console.log("저장할 컬럼 설정:", columnSetting);
|
||||||
|
|
||||||
console.log("💾 저장할 컬럼 정보:", {
|
|
||||||
columnName: column.column_name,
|
|
||||||
inputType: column.input_type,
|
|
||||||
categoryMenus: column.category_menus,
|
|
||||||
hasCategoryMenus: !!column.category_menus,
|
|
||||||
categoryMenusLength: column.category_menus?.length || 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await apiClient.post(`/table-management/tables/${selectedTable}/columns/settings`, [
|
const response = await apiClient.post(`/table-management/tables/${selectedTable}/columns/settings`, [
|
||||||
columnSetting,
|
columnSetting,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
console.log("✅ 컬럼 설정 저장 성공");
|
|
||||||
|
|
||||||
// 🆕 Category 타입인 경우 컬럼 매핑 처리
|
// 🆕 Category 타입인 경우 컬럼 매핑 처리
|
||||||
console.log("🔍 카테고리 조건 체크:", {
|
|
||||||
isCategory: column.input_type === "category",
|
|
||||||
hasCategoryMenus: !!column.category_menus,
|
|
||||||
length: column.category_menus?.length || 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (column.input_type === "category" && !column.category_ref) {
|
if (column.input_type === "category" && !column.category_ref) {
|
||||||
// 참조가 아닌 자체 카테고리만 메뉴 매핑 처리
|
// 참조가 아닌 자체 카테고리만 메뉴 매핑 처리
|
||||||
console.log("기존 카테고리 메뉴 매핑 삭제 시작:", {
|
|
||||||
tableName: selectedTable,
|
|
||||||
columnName: column.column_name,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const deleteResponse = await deleteColumnMappingsByColumn(selectedTable, column.column_name);
|
await deleteColumnMappingsByColumn(selectedTable, column.column_name);
|
||||||
console.log("🗑️ 기존 매핑 삭제 결과:", deleteResponse);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 기존 매핑 삭제 실패:", error);
|
console.error("❌ 기존 매핑 삭제 실패:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 새로운 매핑 추가 (선택된 메뉴가 있는 경우만)
|
// 2. 새로운 매핑 추가 (선택된 메뉴가 있는 경우만)
|
||||||
if (column.category_menus && column.category_menus.length > 0) {
|
if (column.category_menus && column.category_menus.length > 0) {
|
||||||
console.log("📥 카테고리 메뉴 매핑 시작:", {
|
|
||||||
columnName: column.column_name,
|
|
||||||
categoryMenus: column.category_menus,
|
|
||||||
count: column.category_menus.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
let successCount = 0;
|
// 직렬 await 대신 Promise.allSettled 로 병렬 호출 (메뉴가 많으면 직렬은 수십 초 멈춤)
|
||||||
let failCount = 0;
|
const mappingResults = await Promise.allSettled(
|
||||||
|
column.category_menus.map((menuObjid) =>
|
||||||
for (const menuObjid of column.category_menus) {
|
createColumnMapping({
|
||||||
try {
|
|
||||||
const mappingResponse = await createColumnMapping({
|
|
||||||
tableName: selectedTable,
|
tableName: selectedTable,
|
||||||
logicalColumnName: column.column_name,
|
logicalColumnName: column.column_name,
|
||||||
physicalColumnName: column.column_name,
|
physicalColumnName: column.column_name,
|
||||||
menuObjid,
|
menuObjid,
|
||||||
description: `${column.display_name} (메뉴별 카테고리)`,
|
description: `${column.display_name} (메뉴별 카테고리)`,
|
||||||
});
|
}),
|
||||||
|
),
|
||||||
if (mappingResponse.success) {
|
);
|
||||||
successCount++;
|
const successCount = mappingResults.filter(
|
||||||
} else {
|
(r) => r.status === "fulfilled" && r.value.success,
|
||||||
console.error("❌ 매핑 생성 실패:", mappingResponse);
|
).length;
|
||||||
failCount++;
|
const failCount = mappingResults.length - successCount;
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`❌ 메뉴 ${menuObjid}에 대한 매핑 생성 실패:`, error);
|
|
||||||
failCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (successCount > 0 && failCount === 0) {
|
if (successCount > 0 && failCount === 0) {
|
||||||
toast.success(`컬럼 설정 및 ${successCount}개 메뉴 매핑이 저장되었습니다.`);
|
toast.success(`컬럼 설정 및 ${successCount}개 메뉴 매핑이 저장되었습니다.`);
|
||||||
@@ -732,10 +727,8 @@ export default function TableManagementPage() {
|
|||||||
// 원본 데이터 업데이트
|
// 원본 데이터 업데이트
|
||||||
setOriginalColumns((prev) => prev.map((col) => (col.column_name === column.column_name ? column : col)));
|
setOriginalColumns((prev) => prev.map((col) => (col.column_name === column.column_name ? column : col)));
|
||||||
|
|
||||||
// 저장 후 데이터 확인을 위해 다시 로드
|
// 저장 후 데이터 확인을 위해 다시 로드 (await 로 즉시 reload — race + 깜빡임 회피)
|
||||||
setTimeout(() => {
|
await loadColumnTypes(selectedTable);
|
||||||
loadColumnTypes(selectedTable);
|
|
||||||
}, 1000);
|
|
||||||
} else {
|
} else {
|
||||||
showErrorToast("컬럼 설정 저장에 실패했습니다", response.data.message, {
|
showErrorToast("컬럼 설정 저장에 실패했습니다", response.data.message, {
|
||||||
guidance: "입력 데이터를 확인하고 다시 시도해 주세요.",
|
guidance: "입력 데이터를 확인하고 다시 시도해 주세요.",
|
||||||
@@ -860,69 +853,39 @@ export default function TableManagementPage() {
|
|||||||
// 자체 카테고리 컬럼만 메뉴 매핑 처리 (참조 컬럼 제외)
|
// 자체 카테고리 컬럼만 메뉴 매핑 처리 (참조 컬럼 제외)
|
||||||
const categoryColumns = columns.filter((col) => col.input_type === "category" && !col.category_ref);
|
const categoryColumns = columns.filter((col) => col.input_type === "category" && !col.category_ref);
|
||||||
|
|
||||||
console.log("📥 전체 저장: 카테고리 컬럼 확인", {
|
|
||||||
totalColumns: columns.length,
|
|
||||||
categoryColumns: categoryColumns.length,
|
|
||||||
categoryColumnsData: categoryColumns.map((col) => ({
|
|
||||||
columnName: col.column_name,
|
|
||||||
categoryMenus: col.category_menus,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (categoryColumns.length > 0) {
|
if (categoryColumns.length > 0) {
|
||||||
let totalSuccessCount = 0;
|
let totalSuccessCount = 0;
|
||||||
let totalFailCount = 0;
|
let totalFailCount = 0;
|
||||||
|
|
||||||
for (const column of categoryColumns) {
|
for (const column of categoryColumns) {
|
||||||
// 1. 먼저 기존 매핑 모두 삭제
|
// 1. 먼저 기존 매핑 모두 삭제
|
||||||
console.log("🗑️ 기존 카테고리 메뉴 매핑 삭제:", {
|
|
||||||
tableName: selectedTable,
|
|
||||||
columnName: column.column_name,
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const deleteResponse = await deleteColumnMappingsByColumn(selectedTable, column.column_name);
|
await deleteColumnMappingsByColumn(selectedTable, column.column_name);
|
||||||
console.log("🗑️ 기존 매핑 삭제 결과:", deleteResponse);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 기존 매핑 삭제 실패:", error);
|
console.error("❌ 기존 매핑 삭제 실패:", error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 새로운 매핑 추가 (선택된 메뉴가 있는 경우만)
|
// 2. 새로운 매핑 추가 (선택된 메뉴가 있는 경우만) — 직렬 await 대신 Promise.allSettled 병렬 호출
|
||||||
if (column.category_menus && column.category_menus.length > 0) {
|
if (column.category_menus && column.category_menus.length > 0) {
|
||||||
for (const menuObjid of column.category_menus) {
|
const mappingResults = await Promise.allSettled(
|
||||||
try {
|
column.category_menus.map((menuObjid) =>
|
||||||
console.log("🔄 매핑 API 호출:", {
|
createColumnMapping({
|
||||||
tableName: selectedTable,
|
|
||||||
columnName: column.column_name,
|
|
||||||
menuObjid,
|
|
||||||
});
|
|
||||||
|
|
||||||
const mappingResponse = await createColumnMapping({
|
|
||||||
tableName: selectedTable,
|
tableName: selectedTable,
|
||||||
logicalColumnName: column.column_name,
|
logicalColumnName: column.column_name,
|
||||||
physicalColumnName: column.column_name,
|
physicalColumnName: column.column_name,
|
||||||
menuObjid,
|
menuObjid,
|
||||||
description: `${column.display_name} (메뉴별 카테고리)`,
|
description: `${column.display_name} (메뉴별 카테고리)`,
|
||||||
});
|
}),
|
||||||
|
),
|
||||||
console.log("✅ 매핑 API 응답:", mappingResponse);
|
);
|
||||||
|
const colSuccess = mappingResults.filter(
|
||||||
if (mappingResponse.success) {
|
(r) => r.status === "fulfilled" && r.value.success,
|
||||||
totalSuccessCount++;
|
).length;
|
||||||
} else {
|
totalSuccessCount += colSuccess;
|
||||||
console.error("❌ 매핑 생성 실패:", mappingResponse);
|
totalFailCount += mappingResults.length - colSuccess;
|
||||||
totalFailCount++;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`❌ 메뉴 ${menuObjid}에 대한 매핑 생성 실패:`, error);
|
|
||||||
totalFailCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("📊 전체 매핑 결과:", { totalSuccessCount, totalFailCount });
|
|
||||||
|
|
||||||
if (totalSuccessCount > 0) {
|
if (totalSuccessCount > 0) {
|
||||||
toast.success(`테이블 설정 및 ${totalSuccessCount}개 카테고리 메뉴 매핑이 저장되었습니다.`);
|
toast.success(`테이블 설정 및 ${totalSuccessCount}개 카테고리 메뉴 매핑이 저장되었습니다.`);
|
||||||
} else if (totalFailCount > 0) {
|
} else if (totalFailCount > 0) {
|
||||||
@@ -940,10 +903,8 @@ export default function TableManagementPage() {
|
|||||||
// 테이블 목록 새로고침 (라벨 변경 반영)
|
// 테이블 목록 새로고침 (라벨 변경 반영)
|
||||||
loadTables();
|
loadTables();
|
||||||
|
|
||||||
// 저장 후 데이터 다시 로드
|
// 저장 후 데이터 다시 로드 (await 로 즉시 reload — race + 깜빡임 회피)
|
||||||
setTimeout(() => {
|
await loadColumnTypes(selectedTable, 1, pageSize);
|
||||||
loadColumnTypes(selectedTable, 1, pageSize);
|
|
||||||
}, 1000);
|
|
||||||
} else {
|
} else {
|
||||||
showErrorToast("설정 저장에 실패했습니다", response.data.message, {
|
showErrorToast("설정 저장에 실패했습니다", response.data.message, {
|
||||||
guidance: "잠시 후 다시 시도해 주세요.",
|
guidance: "잠시 후 다시 시도해 주세요.",
|
||||||
@@ -1054,24 +1015,28 @@ export default function TableManagementPage() {
|
|||||||
} else {
|
} else {
|
||||||
newPkCols = currentPkCols.filter((c) => c !== columnName);
|
newPkCols = currentPkCols.filter((c) => c !== columnName);
|
||||||
}
|
}
|
||||||
|
// 이번 세션 동안 묻지 않기로 한 경우 즉시 적용
|
||||||
|
if (pkSkipConfirmSession) {
|
||||||
|
applyPkChange(newPkCols);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// PK 변경은 확인 다이얼로그 표시
|
// PK 변경은 확인 다이얼로그 표시
|
||||||
setPendingPkColumns(newPkCols);
|
setPendingPkColumns(newPkCols);
|
||||||
setPkDialogOpen(true);
|
setPkDialogOpen(true);
|
||||||
},
|
},
|
||||||
[constraints.primaryKey?.columns],
|
[constraints.primaryKey?.columns, pkSkipConfirmSession],
|
||||||
);
|
);
|
||||||
|
|
||||||
// PK 변경 확인
|
// PK 변경 실제 적용 (다이얼로그 거치지 않거나 거친 후 호출)
|
||||||
const handlePkConfirm = async () => {
|
const applyPkChange = async (newPkCols: string[]) => {
|
||||||
if (!selectedTable) return;
|
if (!selectedTable) return;
|
||||||
try {
|
try {
|
||||||
if (pendingPkColumns.length === 0) {
|
if (newPkCols.length === 0) {
|
||||||
toast.error("PK 컬럼을 최소 1개 이상 선택해야 합니다.");
|
toast.error("PK 컬럼을 최소 1개 이상 선택해야 합니다.");
|
||||||
setPkDialogOpen(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const response = await apiClient.put(`/table-management/tables/${selectedTable}/primary-key`, {
|
const response = await apiClient.put(`/table-management/tables/${selectedTable}/primary-key`, {
|
||||||
columns: pendingPkColumns,
|
columns: newPkCols,
|
||||||
});
|
});
|
||||||
if (response.data.success) {
|
if (response.data.success) {
|
||||||
toast.success(response.data.message);
|
toast.success(response.data.message);
|
||||||
@@ -1083,11 +1048,15 @@ export default function TableManagementPage() {
|
|||||||
showErrorToast("PK 설정에 실패했습니다", error, {
|
showErrorToast("PK 설정에 실패했습니다", error, {
|
||||||
guidance: "컬럼 정보를 확인하고 다시 시도해 주세요.",
|
guidance: "컬럼 정보를 확인하고 다시 시도해 주세요.",
|
||||||
});
|
});
|
||||||
} finally {
|
|
||||||
setPkDialogOpen(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// PK 변경 확인 (다이얼로그에서 호출)
|
||||||
|
const handlePkConfirm = async () => {
|
||||||
|
setPkDialogOpen(false);
|
||||||
|
await applyPkChange(pendingPkColumns);
|
||||||
|
};
|
||||||
|
|
||||||
// 인덱스 토글 핸들러 (일반 인덱스만 DB 레벨 - 유니크는 앱 레벨 소프트 제약조건으로 분리됨)
|
// 인덱스 토글 핸들러 (일반 인덱스만 DB 레벨 - 유니크는 앱 레벨 소프트 제약조건으로 분리됨)
|
||||||
const handleIndexToggle = useCallback(
|
const handleIndexToggle = useCallback(
|
||||||
async (columnName: string, indexType: "index", checked: boolean) => {
|
async (columnName: string, indexType: "index", checked: boolean) => {
|
||||||
@@ -1690,56 +1659,117 @@ export default function TableManagementPage() {
|
|||||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")}
|
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<Tabs defaultValue="columns" className="flex min-h-0 flex-1 flex-col">
|
||||||
<TypeOverviewStrip
|
<TabsList
|
||||||
columns={columns}
|
className={cn(
|
||||||
activeFilter={typeFilter}
|
"h-9 w-full shrink-0 justify-start gap-1 rounded-none bg-transparent p-0 px-2 pt-1",
|
||||||
onFilterChange={setTypeFilter}
|
"border-b border-border",
|
||||||
/>
|
)}
|
||||||
<ColumnGrid
|
>
|
||||||
columns={columns}
|
<TabsTrigger
|
||||||
selectedColumn={selectedColumn}
|
value="columns"
|
||||||
onSelectColumn={(c) => setSelectedColumn((prev) => (prev === c ? null : c))}
|
className={cn(
|
||||||
onColumnChange={(columnName, field, value) => {
|
"flex flex-none items-center gap-2 rounded-t-md rounded-b-none border border-border bg-transparent -mb-px",
|
||||||
if (field === "is_unique") {
|
"px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors",
|
||||||
const currentColumn = columns.find((c) => c.column_name === columnName);
|
"hover:bg-muted/40 hover:text-foreground",
|
||||||
if (currentColumn) {
|
"data-[state=active]:bg-card data-[state=active]:text-primary data-[state=active]:font-semibold",
|
||||||
handleUniqueToggle(columnName, currentColumn.is_unique || "NO");
|
"data-[state=active]:border-b-card",
|
||||||
|
"data-[state=active]:shadow-[inset_0_2px_0_hsl(var(--primary))]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Columns3 className="h-4 w-4" />
|
||||||
|
컬럼
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="references"
|
||||||
|
className={cn(
|
||||||
|
"flex flex-none items-center gap-2 rounded-t-md rounded-b-none border border-border bg-transparent -mb-px",
|
||||||
|
"px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors",
|
||||||
|
"hover:bg-muted/40 hover:text-foreground",
|
||||||
|
"data-[state=active]:bg-card data-[state=active]:text-primary data-[state=active]:font-semibold",
|
||||||
|
"data-[state=active]:border-b-card",
|
||||||
|
"data-[state=active]:shadow-[inset_0_2px_0_hsl(var(--primary))]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Link2 className="h-4 w-4" />
|
||||||
|
참조
|
||||||
|
{(() => {
|
||||||
|
const refCount = columns.filter((c) =>
|
||||||
|
["entity", "code", "category", "numbering"].includes(c.input_type),
|
||||||
|
).length;
|
||||||
|
return refCount > 0 ? (
|
||||||
|
<Badge variant="secondary" className="ml-1.5 h-5 px-1.5 text-[11px]">
|
||||||
|
{refCount}
|
||||||
|
</Badge>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="columns" className="mt-0 flex min-h-0 flex-1 flex-col">
|
||||||
|
<TypeOverviewStrip
|
||||||
|
columns={columns}
|
||||||
|
activeFilter={typeFilter}
|
||||||
|
onFilterChange={setTypeFilter}
|
||||||
|
/>
|
||||||
|
<ColumnGrid
|
||||||
|
columns={columns}
|
||||||
|
selectedColumn={selectedColumn}
|
||||||
|
onSelectColumn={(c) => setSelectedColumn((prev) => (prev === c ? null : c))}
|
||||||
|
onColumnChange={(columnName, field, value) => {
|
||||||
|
if (field === "is_unique") {
|
||||||
|
const currentColumn = columns.find((c) => c.column_name === columnName);
|
||||||
|
if (currentColumn) {
|
||||||
|
handleUniqueToggle(columnName, currentColumn.is_unique || "NO");
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return;
|
if (field === "is_nullable") {
|
||||||
}
|
const currentColumn = columns.find((c) => c.column_name === columnName);
|
||||||
if (field === "is_nullable") {
|
if (currentColumn) {
|
||||||
const currentColumn = columns.find((c) => c.column_name === columnName);
|
handleNullableToggle(columnName, currentColumn.is_nullable || "YES");
|
||||||
if (currentColumn) {
|
}
|
||||||
handleNullableToggle(columnName, currentColumn.is_nullable || "YES");
|
return;
|
||||||
}
|
}
|
||||||
return;
|
const idx = columns.findIndex((c) => c.column_name === columnName);
|
||||||
|
if (idx >= 0) handleColumnChange(idx, field, value);
|
||||||
|
}}
|
||||||
|
constraints={constraints}
|
||||||
|
typeFilter={typeFilter}
|
||||||
|
getColumnIndexState={getColumnIndexState}
|
||||||
|
onPkToggle={handlePkToggle}
|
||||||
|
onIndexToggle={(columnName, checked) =>
|
||||||
|
handleIndexToggle(columnName, "index", checked)
|
||||||
}
|
}
|
||||||
const idx = columns.findIndex((c) => c.column_name === columnName);
|
onDeleteColumn={handleDeleteColumnClick}
|
||||||
if (idx >= 0) handleColumnChange(idx, field, value);
|
tables={tables}
|
||||||
}}
|
referenceTableColumns={referenceTableColumns}
|
||||||
constraints={constraints}
|
/>
|
||||||
typeFilter={typeFilter}
|
</TabsContent>
|
||||||
getColumnIndexState={getColumnIndexState}
|
|
||||||
onPkToggle={handlePkToggle}
|
<TabsContent value="references" className="mt-0 flex min-h-0 flex-1 flex-col">
|
||||||
onIndexToggle={(columnName, checked) =>
|
<ReferenceListView
|
||||||
handleIndexToggle(columnName, "index", checked)
|
columns={columns}
|
||||||
}
|
tables={tables}
|
||||||
onDeleteColumn={handleDeleteColumnClick}
|
referenceTableColumns={referenceTableColumns}
|
||||||
tables={tables}
|
selectedColumn={selectedColumn}
|
||||||
referenceTableColumns={referenceTableColumns}
|
onSelectColumn={(c) => setSelectedColumn((prev) => (prev === c ? null : c))}
|
||||||
/>
|
/>
|
||||||
</>
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 우측: 상세 패널 (overlay slide-in/out — 가운데 본문 위에 부드럽게 등장) */}
|
{/* 우측: 상세 패널
|
||||||
|
- 와이드 모니터 (xl 이상): 항상 보이는 고정 3-pane
|
||||||
|
- 좁은 화면: 기존처럼 슬라이드 in 오버레이 */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-card absolute top-0 right-0 bottom-0 z-20 flex w-[380px] flex-col overflow-hidden border-l shadow-2xl transition-transform duration-300 ease-out",
|
"bg-card absolute top-0 right-0 bottom-0 z-20 flex w-[380px] flex-col overflow-hidden border-l shadow-2xl transition-transform duration-300 ease-out",
|
||||||
selectedColumn ? "translate-x-0" : "pointer-events-none translate-x-full",
|
selectedColumn ? "translate-x-0" : "pointer-events-none translate-x-full",
|
||||||
|
"xl:relative xl:z-0 xl:flex-shrink-0 xl:translate-x-0 xl:pointer-events-auto xl:shadow-none xl:transition-none",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ColumnDetailPanel
|
<ColumnDetailPanel
|
||||||
@@ -1754,6 +1784,21 @@ export default function TableManagementPage() {
|
|||||||
handleInputTypeChange(selectedColumn, value as string);
|
handleInputTypeChange(selectedColumn, value as string);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 그리드 칩과 동일하게 is_nullable/is_unique 는 즉시 저장
|
||||||
|
if (field === "is_nullable") {
|
||||||
|
const currentColumn = columns.find((c) => c.column_name === selectedColumn);
|
||||||
|
if (currentColumn) {
|
||||||
|
handleNullableToggle(selectedColumn, currentColumn.is_nullable || "YES");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (field === "is_unique") {
|
||||||
|
const currentColumn = columns.find((c) => c.column_name === selectedColumn);
|
||||||
|
if (currentColumn) {
|
||||||
|
handleUniqueToggle(selectedColumn, currentColumn.is_unique || "NO");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (field === "reference_table" && value) {
|
if (field === "reference_table" && value) {
|
||||||
loadReferenceTableColumns(value as string);
|
loadReferenceTableColumns(value as string);
|
||||||
}
|
}
|
||||||
@@ -1803,13 +1848,13 @@ export default function TableManagementPage() {
|
|||||||
setDuplicateSourceTable(null);
|
setDuplicateSourceTable(null);
|
||||||
}}
|
}}
|
||||||
mode={duplicateModalMode}
|
mode={duplicateModalMode}
|
||||||
sourceTableName={duplicateSourceTable || undefined}
|
source_table_name={duplicateSourceTable || undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddColumnModal
|
<AddColumnModal
|
||||||
isOpen={addColumnModalOpen}
|
isOpen={addColumnModalOpen}
|
||||||
onClose={() => setAddColumnModalOpen(false)}
|
onClose={() => setAddColumnModalOpen(false)}
|
||||||
tableName={selectedTable || ""}
|
table_name={selectedTable || ""}
|
||||||
onSuccess={async (result) => {
|
onSuccess={async (result) => {
|
||||||
toast.success("컬럼이 성공적으로 추가되었습니다!");
|
toast.success("컬럼이 성공적으로 추가되었습니다!");
|
||||||
// 테이블 목록 새로고침 (컬럼 수 업데이트)
|
// 테이블 목록 새로고침 (컬럼 수 업데이트)
|
||||||
@@ -2001,6 +2046,14 @@ export default function TableManagementPage() {
|
|||||||
<p className="text-destructive mt-2 text-sm">PK가 모두 제거됩니다</p>
|
<p className="text-destructive mt-2 text-sm">PK가 모두 제거됩니다</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer select-none">
|
||||||
|
<Checkbox
|
||||||
|
checked={pkSkipConfirmSession}
|
||||||
|
onCheckedChange={(v) => setPkSkipConfirmSession(v === true)}
|
||||||
|
/>
|
||||||
|
이번 세션 동안 PK 변경 확인 다이얼로그 건너뛰기 (composite PK 만들 때 편함)
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter className="gap-2 sm:gap-0">
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Loader2, Info, AlertCircle, CheckCircle2, Plus } from "lucide-react";
|
||||||
import { Loader2, Info, AlertCircle, CheckCircle2, Plus, Activity } from "lucide-react";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { ColumnDefinitionTable } from "./ColumnDefinitionTable";
|
import { ColumnDefinitionTable } from "./ColumnDefinitionTable";
|
||||||
import { ddlApi } from "../../lib/api/ddl";
|
import { ddlApi } from "../../lib/api/ddl";
|
||||||
@@ -57,8 +56,6 @@ export function CreateTableModal({
|
|||||||
const [validating, setValidating] = useState(false);
|
const [validating, setValidating] = useState(false);
|
||||||
const [tableNameError, setTableNameError] = useState("");
|
const [tableNameError, setTableNameError] = useState("");
|
||||||
const [validationResult, setValidationResult] = useState<any>(null);
|
const [validationResult, setValidationResult] = useState<any>(null);
|
||||||
const [useLogTable, setUseLogTable] = useState(false);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 모달 리셋
|
* 모달 리셋
|
||||||
*/
|
*/
|
||||||
@@ -76,7 +73,6 @@ export function CreateTableModal({
|
|||||||
]);
|
]);
|
||||||
setTableNameError("");
|
setTableNameError("");
|
||||||
setValidationResult(null);
|
setValidationResult(null);
|
||||||
setUseLogTable(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -107,15 +103,11 @@ export function CreateTableModal({
|
|||||||
try {
|
try {
|
||||||
// 1. 테이블 컬럼 정보 조회
|
// 1. 테이블 컬럼 정보 조회
|
||||||
const columnsResponse = await tableManagementApi.getColumnList(tableName);
|
const columnsResponse = await tableManagementApi.getColumnList(tableName);
|
||||||
|
|
||||||
console.log("🔍 컬럼 조회 응답:", columnsResponse);
|
|
||||||
|
|
||||||
if (columnsResponse.success && columnsResponse.data) {
|
if (columnsResponse.success && columnsResponse.data) {
|
||||||
// API는 { columns, total, page, size } 형태로 반환
|
// API는 { columns, total, page, size } 형태로 반환
|
||||||
const columnsList = columnsResponse.data.columns;
|
const columnsList = columnsResponse.data.columns;
|
||||||
|
|
||||||
console.log("🔍 컬럼 리스트:", columnsList);
|
|
||||||
|
|
||||||
if (columnsList && columnsList.length > 0) {
|
if (columnsList && columnsList.length > 0) {
|
||||||
// 첫 번째 컬럼에서 테이블 설명 가져오기 (모든 컬럼이 같은 테이블 설명을 가짐)
|
// 첫 번째 컬럼에서 테이블 설명 가져오기 (모든 컬럼이 같은 테이블 설명을 가짐)
|
||||||
const firstColumn = columnsList[0];
|
const firstColumn = columnsList[0];
|
||||||
@@ -285,23 +277,6 @@ export function CreateTableModal({
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
|
|
||||||
// 로그 테이블 생성 옵션이 선택되었다면 로그 테이블 생성
|
|
||||||
if (useLogTable) {
|
|
||||||
try {
|
|
||||||
const pkColumn = { columnName: "id", dataType: "integer" };
|
|
||||||
const logResult = await tableManagementApi.createLogTable(tableName, pkColumn);
|
|
||||||
|
|
||||||
if (logResult.success) {
|
|
||||||
toast.success(`${tableName}_log 테이블이 생성되었습니다.`);
|
|
||||||
} else {
|
|
||||||
toast.warning(`테이블은 생성되었으나 로그 테이블 생성 실패: ${logResult.message}`);
|
|
||||||
}
|
|
||||||
} catch (logError) {
|
|
||||||
toast.warning("테이블은 생성되었으나 로그 테이블 생성 중 오류가 발생했습니다.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onSuccess(result);
|
onSuccess(result);
|
||||||
onClose();
|
onClose();
|
||||||
} else {
|
} else {
|
||||||
@@ -380,29 +355,6 @@ export function CreateTableModal({
|
|||||||
<ColumnDefinitionTable columns={columns} onChange={setColumns} disabled={loading} />
|
<ColumnDefinitionTable columns={columns} onChange={setColumns} disabled={loading} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 로그 테이블 생성 옵션 - 통합 변경 이력 시스템으로 대체됨 (숨김 처리) */}
|
|
||||||
{/* <div className="flex items-start space-x-3 rounded-lg border p-4">
|
|
||||||
<Checkbox
|
|
||||||
id="useLogTable"
|
|
||||||
checked={useLogTable}
|
|
||||||
onCheckedChange={(checked) => setUseLogTable(checked as boolean)}
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
<div className="grid gap-1.5 leading-none">
|
|
||||||
<label
|
|
||||||
htmlFor="useLogTable"
|
|
||||||
className="flex cursor-pointer items-center gap-2 text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
||||||
>
|
|
||||||
<Activity className="h-4 w-4" />
|
|
||||||
변경 이력 로그 테이블 생성
|
|
||||||
</label>
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
선택 시 <code className="bg-muted rounded px-1 py-0.5">{tableName || "table"}_log</code> 테이블이
|
|
||||||
자동으로 생성되어 INSERT/UPDATE/DELETE 변경 이력을 기록합니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div> */}
|
|
||||||
|
|
||||||
{/* 자동 추가 컬럼 안내 */}
|
{/* 자동 추가 컬럼 안내 */}
|
||||||
<Alert>
|
<Alert>
|
||||||
<Info className="h-4 w-4" />
|
<Info className="h-4 w-4" />
|
||||||
|
|||||||
@@ -113,8 +113,29 @@ export function ColumnDetailPanel({
|
|||||||
}, [referenceTableOptions, tables]);
|
}, [referenceTableOptions, tables]);
|
||||||
|
|
||||||
// early return 은 반드시 모든 hook 호출 뒤에 (Rules of Hooks).
|
// early return 은 반드시 모든 hook 호출 뒤에 (Rules of Hooks).
|
||||||
// overlay 패턴으로 항상 마운트되므로 column null 케이스가 정상적으로 들어옴.
|
// 컬럼 선택 안 한 상태에서도 패널이 항상 보이는 와이드 레이아웃 대응 — 빈 상태 안내 UI 표시.
|
||||||
if (!column) return null;
|
if (!column) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col border-l bg-card">
|
||||||
|
{/* 좁은 화면에서 패널이 슬라이드 in 된 상태로 column=null 이 되면 닫을 수단이 없어
|
||||||
|
stuck 되는 문제 방지 — 빈 상태에도 X 버튼 유지 */}
|
||||||
|
<div className="flex flex-shrink-0 items-center justify-end px-4 py-3">
|
||||||
|
<Button variant="ghost" size="icon" onClick={onClose} aria-label="닫기" className="h-7 w-7">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center px-6 text-center">
|
||||||
|
<div className="rounded-full bg-muted/60 p-4">
|
||||||
|
<Settings2 className="h-8 w-8 text-muted-foreground/60" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-sm font-medium text-foreground">컬럼을 선택해주세요</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
좌측 그리드에서 컬럼을 선택하면 여기에 상세 설정이 표시됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-full flex-col border-l bg-card">
|
<div className="flex h-full w-full flex-col border-l bg-card">
|
||||||
@@ -183,12 +204,12 @@ export function ColumnDetailPanel({
|
|||||||
isLegacy && "cursor-not-allowed",
|
isLegacy && "cursor-not-allowed",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className={cn(
|
<conf.Icon
|
||||||
"text-base font-bold leading-none",
|
className={cn(
|
||||||
isSelected ? "text-primary" : conf.color,
|
"h-4 w-4",
|
||||||
)}>
|
isSelected ? "text-primary" : conf.color,
|
||||||
{conf.iconChar}
|
)}
|
||||||
</span>
|
/>
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
"text-[16px] font-semibold leading-tight",
|
"text-[16px] font-semibold leading-tight",
|
||||||
isSelected ? "text-primary" : "text-foreground",
|
isSelected ? "text-primary" : "text-foreground",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
import { MoreHorizontal, Database, Layers, FileStack, Trash2 } from "lucide-react";
|
import { MoreHorizontal, Database, FileStack, Trash2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
@@ -47,11 +47,10 @@ function getIndexState(
|
|||||||
return { isPk, hasIndex };
|
return { isPk, hasIndex };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 그룹 헤더 라벨 */
|
/** 그룹 헤더 라벨 — 참조 컬럼은 별도 "참조" 탭에서 보여주므로 컬럼 탭에서는 사용자/시스템 2그룹으로만 분류 */
|
||||||
const GROUP_LABELS: Record<string, { icon: React.FC<{ className?: string }>; label: string }> = {
|
const GROUP_LABELS: Record<"user" | "system", { icon: React.FC<{ className?: string }>; label: string }> = {
|
||||||
basic: { icon: FileStack, label: "기본 정보" },
|
user: { icon: FileStack, label: "사용자 컬럼" },
|
||||||
reference: { icon: Layers, label: "참조 정보" },
|
system: { icon: Database, label: "시스템 컬럼" },
|
||||||
meta: { icon: Database, label: "메타 정보" },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ColumnGrid({
|
export function ColumnGrid({
|
||||||
@@ -73,30 +72,28 @@ export function ColumnGrid({
|
|||||||
[constraints, externalGetIndexState],
|
[constraints, externalGetIndexState],
|
||||||
);
|
);
|
||||||
|
|
||||||
/** typeFilter 적용 후 그룹별로 정렬 */
|
/** typeFilter 적용 후 사용자/시스템 그룹으로 분류 (참조 컬럼은 참조 탭으로 분리됐으므로 사용자 컬럼에 합침) */
|
||||||
const filteredAndGrouped = useMemo(() => {
|
const filteredAndGrouped = useMemo(() => {
|
||||||
const filtered =
|
const filtered =
|
||||||
typeFilter != null ? columns.filter((c) => (c.input_type || "text") === typeFilter) : columns;
|
typeFilter != null ? columns.filter((c) => (c.input_type || "text") === typeFilter) : columns;
|
||||||
const groups = { basic: [] as ColumnTypeInfo[], reference: [] as ColumnTypeInfo[], meta: [] as ColumnTypeInfo[] };
|
const groups = { user: [] as ColumnTypeInfo[], system: [] as ColumnTypeInfo[] };
|
||||||
for (const col of filtered) {
|
for (const col of filtered) {
|
||||||
const group = getColumnGroup(col);
|
const g = getColumnGroup(col) === "meta" ? "system" : "user";
|
||||||
groups[group].push(col);
|
groups[g].push(col);
|
||||||
}
|
}
|
||||||
return groups;
|
return groups;
|
||||||
}, [columns, typeFilter]);
|
}, [columns, typeFilter]);
|
||||||
|
|
||||||
const totalFiltered =
|
const totalFiltered = filteredAndGrouped.user.length + filteredAndGrouped.system.length;
|
||||||
filteredAndGrouped.basic.length + filteredAndGrouped.reference.length + filteredAndGrouped.meta.length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="grid flex-shrink-0 items-center border-b bg-muted/50 px-4 py-2 text-xs font-semibold text-foreground"
|
className="grid flex-shrink-0 items-center border-b bg-muted/50 px-4 py-2 text-xs font-semibold text-foreground"
|
||||||
style={{ gridTemplateColumns: "4px 140px 1fr 100px 160px 40px" }}
|
style={{ gridTemplateColumns: "4px 1fr 100px 160px 40px" }}
|
||||||
>
|
>
|
||||||
<span />
|
<span />
|
||||||
<span>라벨 · 컬럼명</span>
|
<span>라벨 · 컬럼명</span>
|
||||||
<span>참조/설정</span>
|
|
||||||
<span>타입</span>
|
<span>타입</span>
|
||||||
<span className="text-center">PK / NN / IDX / UQ</span>
|
<span className="text-center">PK / NN / IDX / UQ</span>
|
||||||
<span />
|
<span />
|
||||||
@@ -108,7 +105,7 @@ export function ColumnGrid({
|
|||||||
{typeFilter ? "해당 타입의 컬럼이 없습니다." : "컬럼이 없습니다."}
|
{typeFilter ? "해당 타입의 컬럼이 없습니다." : "컬럼이 없습니다."}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
(["basic", "reference", "meta"] as const).map((groupKey) => {
|
(["user", "system"] as const).map((groupKey) => {
|
||||||
const list = filteredAndGrouped[groupKey];
|
const list = filteredAndGrouped[groupKey];
|
||||||
if (list.length === 0) return null;
|
if (list.length === 0) return null;
|
||||||
const { icon: Icon, label } = GROUP_LABELS[groupKey];
|
const { icon: Icon, label } = GROUP_LABELS[groupKey];
|
||||||
@@ -142,7 +139,7 @@ export function ColumnGrid({
|
|||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"grid min-h-12 cursor-pointer items-center gap-2 rounded-md border px-4 py-2 transition-colors",
|
"grid min-h-12 cursor-pointer items-center gap-2 rounded-md border px-4 py-2 transition-colors",
|
||||||
"grid-cols-[4px_140px_1fr_100px_160px_40px]",
|
"grid-cols-[4px_1fr_100px_160px_40px]",
|
||||||
"bg-card border-transparent hover:border-border hover:shadow-sm",
|
"bg-card border-transparent hover:border-border hover:shadow-sm",
|
||||||
isSelected && "border-primary/30 bg-primary/5 shadow-sm",
|
isSelected && "border-primary/30 bg-primary/5 shadow-sm",
|
||||||
)}
|
)}
|
||||||
@@ -159,66 +156,6 @@ export function ColumnGrid({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 참조/설정 칩 */}
|
|
||||||
<div className="flex min-w-0 flex-wrap gap-1">
|
|
||||||
{column.input_type === "entity" && column.reference_table && column.reference_table !== "none" && (
|
|
||||||
<>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-xs font-normal"
|
|
||||||
title={
|
|
||||||
tables
|
|
||||||
? (() => {
|
|
||||||
const t = tables.find((tb) => tb.table_name === column.reference_table);
|
|
||||||
return t?.display_name && t.display_name !== t.table_name
|
|
||||||
? `${t.display_name} (${column.reference_table})`
|
|
||||||
: column.reference_table;
|
|
||||||
})()
|
|
||||||
: column.reference_table
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{column.reference_table}
|
|
||||||
</Badge>
|
|
||||||
<span className="text-muted-foreground text-xs">→</span>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-xs font-normal"
|
|
||||||
title={
|
|
||||||
referenceTableColumns?.[column.reference_table]
|
|
||||||
? (() => {
|
|
||||||
const refCols = referenceTableColumns[column.reference_table];
|
|
||||||
const c = refCols.find((rc) => rc.column_name === (column.reference_column ?? ""));
|
|
||||||
return c?.display_name && c.display_name !== c.column_name
|
|
||||||
? `${c.display_name} (${column.reference_column})`
|
|
||||||
: column.reference_column ?? "—";
|
|
||||||
})()
|
|
||||||
: column.reference_column ?? "—"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{column.reference_column || "—"}
|
|
||||||
</Badge>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{column.input_type === "code" && (
|
|
||||||
<span className="text-muted-foreground truncate text-xs">
|
|
||||||
{column.code_info ?? "—"} · {column.default_value ?? ""}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{column.input_type === "numbering" && column.numbering_rule_id && (
|
|
||||||
<Badge variant="outline" className="text-xs font-normal">
|
|
||||||
{column.numbering_rule_id}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{column.input_type !== "entity" &&
|
|
||||||
column.input_type !== "code" &&
|
|
||||||
column.input_type !== "numbering" &&
|
|
||||||
(column.default_value ? (
|
|
||||||
<span className="text-muted-foreground truncate text-xs">{column.default_value}</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground/60 text-xs">—</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 타입 뱃지 */}
|
{/* 타입 뱃지 */}
|
||||||
<div className={cn("rounded-md border px-2 py-0.5 text-xs", typeConf.bgColor, typeConf.color)}>
|
<div className={cn("rounded-md border px-2 py-0.5 text-xs", typeConf.bgColor, typeConf.color)}>
|
||||||
<span className="mr-1 inline-block h-1.5 w-1.5 rounded-full bg-current opacity-70" />
|
<span className="mr-1 inline-block h-1.5 w-1.5 rounded-full bg-current opacity-70" />
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Database, FolderTree, Hash, Link2, FileCode2 } from "lucide-react";
|
||||||
|
import type { ColumnTypeInfo, TableInfo } from "./types";
|
||||||
|
import { INPUT_TYPE_COLORS } from "./types";
|
||||||
|
import type { ReferenceTableColumn } from "@/lib/api/entityJoin";
|
||||||
|
|
||||||
|
export interface ReferenceListViewProps {
|
||||||
|
columns: ColumnTypeInfo[];
|
||||||
|
tables?: TableInfo[];
|
||||||
|
referenceTableColumns?: Record<string, ReferenceTableColumn[]>;
|
||||||
|
onSelectColumn?: (columnName: string) => void;
|
||||||
|
selectedColumn?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RefKind = "entity" | "code" | "category" | "numbering";
|
||||||
|
|
||||||
|
const KIND_META: Record<
|
||||||
|
RefKind,
|
||||||
|
{ icon: React.FC<{ className?: string }>; label: string; color: string; bgColor: string }
|
||||||
|
> = {
|
||||||
|
entity: { icon: Link2, label: "테이블 참조", color: "text-violet-600", bgColor: "bg-violet-50" },
|
||||||
|
code: { icon: FileCode2, label: "공통코드", color: "text-emerald-600", bgColor: "bg-emerald-50" },
|
||||||
|
category: { icon: FolderTree, label: "카테고리", color: "text-teal-600", bgColor: "bg-teal-50" },
|
||||||
|
numbering: { icon: Hash, label: "채번", color: "text-orange-600", bgColor: "bg-orange-50" },
|
||||||
|
};
|
||||||
|
|
||||||
|
function getRefKind(col: ColumnTypeInfo): RefKind | null {
|
||||||
|
const t = col.input_type;
|
||||||
|
if (t === "entity" || t === "code" || t === "category" || t === "numbering") return t;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReferenceListView({
|
||||||
|
columns,
|
||||||
|
tables,
|
||||||
|
referenceTableColumns,
|
||||||
|
onSelectColumn,
|
||||||
|
selectedColumn = null,
|
||||||
|
}: ReferenceListViewProps) {
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const groups: Record<RefKind, ColumnTypeInfo[]> = {
|
||||||
|
entity: [],
|
||||||
|
code: [],
|
||||||
|
category: [],
|
||||||
|
numbering: [],
|
||||||
|
};
|
||||||
|
for (const col of columns) {
|
||||||
|
const kind = getRefKind(col);
|
||||||
|
if (kind) groups[kind].push(col);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}, [columns]);
|
||||||
|
|
||||||
|
const totalRefs =
|
||||||
|
grouped.entity.length + grouped.code.length + grouped.category.length + grouped.numbering.length;
|
||||||
|
|
||||||
|
if (totalRefs === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 items-center justify-center py-12 text-sm text-muted-foreground">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<Database className="h-8 w-8 text-muted-foreground/50" />
|
||||||
|
<span>이 테이블에는 참조 컬럼이 없어요.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div
|
||||||
|
className="grid flex-shrink-0 items-center border-b bg-muted/50 px-4 py-2 text-xs font-semibold text-foreground"
|
||||||
|
style={{ gridTemplateColumns: "4px 220px 110px 1fr" }}
|
||||||
|
>
|
||||||
|
<span />
|
||||||
|
<span>소스 컬럼</span>
|
||||||
|
<span>참조 종류</span>
|
||||||
|
<span>참조 대상</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 그룹별 행 */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
{(["entity", "code", "category", "numbering"] as const).map((kind) => {
|
||||||
|
const list = grouped[kind];
|
||||||
|
if (list.length === 0) return null;
|
||||||
|
const meta = KIND_META[kind];
|
||||||
|
const KindIcon = meta.icon;
|
||||||
|
return (
|
||||||
|
<div key={kind} className="space-y-1 py-2">
|
||||||
|
<div className="flex items-center gap-2 border-b border-border/60 px-4 pb-1.5">
|
||||||
|
<KindIcon className={cn("h-4 w-4", meta.color)} />
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{list.length}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{list.map((column) => {
|
||||||
|
const typeConf = INPUT_TYPE_COLORS[column.input_type || "text"] || INPUT_TYPE_COLORS.text;
|
||||||
|
const isSelected = selectedColumn === column.column_name;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={column.column_name}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => onSelectColumn?.(column.column_name)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
onSelectColumn?.(column.column_name);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"grid min-h-10 cursor-pointer items-center gap-2 rounded-md border px-4 py-2 transition-colors",
|
||||||
|
"bg-card border-transparent hover:border-border hover:shadow-sm",
|
||||||
|
isSelected && "border-primary/30 bg-primary/5 shadow-sm",
|
||||||
|
)}
|
||||||
|
style={{ gridTemplateColumns: "4px 220px 110px 1fr" }}
|
||||||
|
>
|
||||||
|
{/* 색상바 */}
|
||||||
|
<div className={cn("h-full min-h-8 w-1 rounded-full", typeConf.barColor)} />
|
||||||
|
|
||||||
|
{/* 소스 컬럼명 */}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-xs font-medium">
|
||||||
|
{column.display_name && column.display_name !== column.column_name
|
||||||
|
? `${column.display_name} (${column.column_name})`
|
||||||
|
: column.column_name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 참조 종류 칩 */}
|
||||||
|
<div className={cn("inline-flex w-fit items-center gap-1 rounded-md border px-2 py-0.5 text-xs", meta.bgColor, meta.color)}>
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-current opacity-70" />
|
||||||
|
{meta.label}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 참조 대상 */}
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center gap-1">
|
||||||
|
{kind === "entity" && column.reference_table && column.reference_table !== "none" ? (
|
||||||
|
<>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs font-normal"
|
||||||
|
title={
|
||||||
|
tables
|
||||||
|
? (() => {
|
||||||
|
const t = tables.find((tb) => tb.table_name === column.reference_table);
|
||||||
|
return t?.display_name && t.display_name !== t.table_name
|
||||||
|
? `${t.display_name} (${column.reference_table})`
|
||||||
|
: column.reference_table;
|
||||||
|
})()
|
||||||
|
: column.reference_table
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{column.reference_table}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-muted-foreground text-xs">→</span>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs font-normal"
|
||||||
|
title={
|
||||||
|
referenceTableColumns?.[column.reference_table]
|
||||||
|
? (() => {
|
||||||
|
const refCols = referenceTableColumns[column.reference_table];
|
||||||
|
const c = refCols.find((rc) => rc.column_name === (column.reference_column ?? ""));
|
||||||
|
return c?.display_name && c.display_name !== c.column_name
|
||||||
|
? `${c.display_name} (${column.reference_column})`
|
||||||
|
: column.reference_column ?? "—";
|
||||||
|
})()
|
||||||
|
: column.reference_column ?? "—"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{column.reference_column || "—"}
|
||||||
|
</Badge>
|
||||||
|
</>
|
||||||
|
) : kind === "code" ? (
|
||||||
|
column.code_info ? (
|
||||||
|
<Badge variant="outline" className="text-xs font-normal">
|
||||||
|
코드: {column.code_info}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground/60 text-xs">— (코드 그룹 미지정)</span>
|
||||||
|
)
|
||||||
|
) : kind === "category" ? (
|
||||||
|
column.category_ref ? (
|
||||||
|
<Badge variant="outline" className="text-xs font-normal">
|
||||||
|
카테고리: {column.category_ref}
|
||||||
|
</Badge>
|
||||||
|
) : column.category_menus && column.category_menus.length > 0 ? (
|
||||||
|
<Badge variant="outline" className="text-xs font-normal">
|
||||||
|
카테고리 메뉴 {column.category_menus.length}개
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground/60 text-xs">— (카테고리 미지정)</span>
|
||||||
|
)
|
||||||
|
) : kind === "numbering" ? (
|
||||||
|
column.numbering_rule_id ? (
|
||||||
|
<Badge variant="outline" className="text-xs font-normal">
|
||||||
|
채번: {column.numbering_rule_id}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground/60 text-xs">— (채번 규칙 미지정)</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground/60 text-xs">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import React, { useMemo } from "react";
|
import React, { useMemo } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ColumnTypeInfo } from "./types";
|
import type { ColumnTypeInfo } from "./types";
|
||||||
import { INPUT_TYPE_COLORS } from "./types";
|
import { INPUT_TYPE_COLORS, FALLBACK_TYPE_CONFIG } from "./types";
|
||||||
import { USER_SELECTABLE_INPUT_TYPE_ORDER } from "@/types/input-types";
|
import { USER_SELECTABLE_INPUT_TYPE_ORDER } from "@/types/input-types";
|
||||||
|
|
||||||
export interface TypeOverviewStripProps {
|
export interface TypeOverviewStripProps {
|
||||||
@@ -57,20 +57,13 @@ export function TypeOverviewStrip({
|
|||||||
/** stroke-dasharray: 비율만큼 둘레에 할당 (둘레 100 기준) */
|
/** stroke-dasharray: 비율만큼 둘레에 할당 (둘레 100 기준) */
|
||||||
const circumference = 100;
|
const circumference = 100;
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
const LEGACY_CONF = {
|
const LEGACY_CONF = { ...FALLBACK_TYPE_CONFIG, color: "text-amber-600", bgColor: "bg-amber-50", barColor: "bg-amber-400" };
|
||||||
color: "text-amber-600",
|
|
||||||
bgColor: "bg-amber-50",
|
|
||||||
barColor: "bg-amber-400",
|
|
||||||
label: "Legacy",
|
|
||||||
desc: "구버전 타입",
|
|
||||||
iconChar: "?",
|
|
||||||
};
|
|
||||||
const segmentPaths = segments.map(({ type, ratio, isLegacy }) => {
|
const segmentPaths = segments.map(({ type, ratio, isLegacy }) => {
|
||||||
const length = ratio * circumference;
|
const length = ratio * circumference;
|
||||||
const dashArray = `${length} ${circumference - length}`;
|
const dashArray = `${length} ${circumference - length}`;
|
||||||
const dashOffset = -offset;
|
const dashOffset = -offset;
|
||||||
offset += length;
|
offset += length;
|
||||||
const conf = isLegacy ? LEGACY_CONF : (INPUT_TYPE_COLORS[type] || { color: "text-muted-foreground", bgColor: "bg-muted" });
|
const conf = isLegacy ? LEGACY_CONF : (INPUT_TYPE_COLORS[type] || FALLBACK_TYPE_CONFIG);
|
||||||
return {
|
return {
|
||||||
type,
|
type,
|
||||||
dashArray,
|
dashArray,
|
||||||
@@ -112,7 +105,7 @@ export function TypeOverviewStrip({
|
|||||||
.filter((type) => (counts[type] || 0) > 0)
|
.filter((type) => (counts[type] || 0) > 0)
|
||||||
.sort((a, b) => (counts[b] ?? 0) - (counts[a] ?? 0))
|
.sort((a, b) => (counts[b] ?? 0) - (counts[a] ?? 0))
|
||||||
.map((type) => {
|
.map((type) => {
|
||||||
const conf = INPUT_TYPE_COLORS[type] || { color: "text-muted-foreground", bgColor: "bg-muted", label: type };
|
const conf = INPUT_TYPE_COLORS[type] || { ...FALLBACK_TYPE_CONFIG, label: type };
|
||||||
const isActive = activeFilter === null || activeFilter === type;
|
const isActive = activeFilter === null || activeFilter === type;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -3,6 +3,23 @@
|
|||||||
* page.tsx에서 추출한 인터페이스 및 타입별 색상/그룹 유틸
|
* page.tsx에서 추출한 인터페이스 및 타입별 색상/그룹 유틸
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlignLeft,
|
||||||
|
Braces,
|
||||||
|
Calendar,
|
||||||
|
CheckSquare,
|
||||||
|
ChevronDown,
|
||||||
|
CircleDot,
|
||||||
|
FolderTree,
|
||||||
|
Hash,
|
||||||
|
HelpCircle,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Link2,
|
||||||
|
ListOrdered,
|
||||||
|
Paperclip,
|
||||||
|
Type,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
import { USER_SELECTABLE_INPUT_TYPE_ORDER } from "@/types/input-types";
|
import { USER_SELECTABLE_INPUT_TYPE_ORDER } from "@/types/input-types";
|
||||||
|
|
||||||
export interface TableInfo {
|
export interface TableInfo {
|
||||||
@@ -52,24 +69,34 @@ export interface TypeColorConfig {
|
|||||||
barColor: string;
|
barColor: string;
|
||||||
label: string;
|
label: string;
|
||||||
desc: string;
|
desc: string;
|
||||||
iconChar: string;
|
Icon: LucideIcon;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 입력 타입별 색상 맵 - iconChar는 카드 선택용 시각 아이콘 */
|
/** Legacy/알 수 없는 타입용 fallback config */
|
||||||
|
export const FALLBACK_TYPE_CONFIG: TypeColorConfig = {
|
||||||
|
color: "text-muted-foreground",
|
||||||
|
bgColor: "bg-muted",
|
||||||
|
barColor: "bg-muted",
|
||||||
|
label: "Legacy",
|
||||||
|
desc: "구버전 타입",
|
||||||
|
Icon: HelpCircle,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 입력 타입별 색상 맵 - Icon 은 lucide 컴포넌트로 통일 (letter/symbol/emoji 혼재 방지) */
|
||||||
export const INPUT_TYPE_COLORS: Record<string, TypeColorConfig> = {
|
export const INPUT_TYPE_COLORS: Record<string, TypeColorConfig> = {
|
||||||
text: { color: "text-slate-600", bgColor: "bg-slate-50", barColor: "bg-slate-400", label: "텍스트", desc: "일반 텍스트 입력", iconChar: "T" },
|
text: { color: "text-slate-600", bgColor: "bg-slate-50", barColor: "bg-slate-400", label: "텍스트", desc: "일반 텍스트 입력", Icon: Type },
|
||||||
number: { color: "text-indigo-600", bgColor: "bg-indigo-50", barColor: "bg-indigo-500", label: "숫자", desc: "숫자만 입력", iconChar: "#" },
|
number: { color: "text-indigo-600", bgColor: "bg-indigo-50", barColor: "bg-indigo-500", label: "숫자", desc: "숫자만 입력", Icon: Hash },
|
||||||
date: { color: "text-amber-600", bgColor: "bg-amber-50", barColor: "bg-amber-500", label: "날짜", desc: "날짜 선택", iconChar: "D" },
|
date: { color: "text-amber-600", bgColor: "bg-amber-50", barColor: "bg-amber-500", label: "날짜", desc: "날짜 선택", Icon: Calendar },
|
||||||
code: { color: "text-emerald-600", bgColor: "bg-emerald-50", barColor: "bg-emerald-500", label: "코드", desc: "공통코드 선택", iconChar: "{}" },
|
code: { color: "text-emerald-600", bgColor: "bg-emerald-50", barColor: "bg-emerald-500", label: "코드", desc: "공통코드 선택", Icon: Braces },
|
||||||
entity: { color: "text-violet-600", bgColor: "bg-violet-50", barColor: "bg-violet-500", label: "테이블 참조", desc: "다른 테이블 연결", iconChar: "⊞" },
|
entity: { color: "text-violet-600", bgColor: "bg-violet-50", barColor: "bg-violet-500", label: "테이블 참조", desc: "다른 테이블 연결", Icon: Link2 },
|
||||||
select: { color: "text-cyan-600", bgColor: "bg-cyan-50", barColor: "bg-cyan-500", label: "셀렉트", desc: "직접 옵션 선택", iconChar: "☰" },
|
select: { color: "text-cyan-600", bgColor: "bg-cyan-50", barColor: "bg-cyan-500", label: "셀렉트", desc: "직접 옵션 선택", Icon: ChevronDown },
|
||||||
checkbox: { color: "text-pink-600", bgColor: "bg-pink-50", barColor: "bg-pink-500", label: "체크박스", desc: "예/아니오 선택", iconChar: "☑" },
|
checkbox: { color: "text-pink-600", bgColor: "bg-pink-50", barColor: "bg-pink-500", label: "체크박스", desc: "예/아니오 선택", Icon: CheckSquare },
|
||||||
numbering: { color: "text-orange-600", bgColor: "bg-orange-50", barColor: "bg-orange-500", label: "채번", desc: "자동 번호 생성", iconChar: "≡" },
|
numbering: { color: "text-orange-600", bgColor: "bg-orange-50", barColor: "bg-orange-500", label: "채번", desc: "자동 번호 생성", Icon: ListOrdered },
|
||||||
category: { color: "text-teal-600", bgColor: "bg-teal-50", barColor: "bg-teal-500", label: "카테고리", desc: "등록된 선택지", iconChar: "⊟" },
|
category: { color: "text-teal-600", bgColor: "bg-teal-50", barColor: "bg-teal-500", label: "카테고리", desc: "등록된 선택지", Icon: FolderTree },
|
||||||
textarea: { color: "text-indigo-600", bgColor: "bg-indigo-50", barColor: "bg-indigo-400", label: "여러 줄", desc: "긴 텍스트 입력", iconChar: "≡" },
|
textarea: { color: "text-indigo-600", bgColor: "bg-indigo-50", barColor: "bg-indigo-400", label: "여러 줄", desc: "긴 텍스트 입력", Icon: AlignLeft },
|
||||||
radio: { color: "text-rose-600", bgColor: "bg-rose-50", barColor: "bg-rose-500", label: "라디오", desc: "하나만 선택", iconChar: "◉" },
|
radio: { color: "text-rose-600", bgColor: "bg-rose-50", barColor: "bg-rose-500", label: "라디오", desc: "하나만 선택", Icon: CircleDot },
|
||||||
file: { color: "text-amber-600", bgColor: "bg-amber-50", barColor: "bg-amber-500", label: "파일", desc: "파일 업로드", iconChar: "📎" },
|
file: { color: "text-amber-600", bgColor: "bg-amber-50", barColor: "bg-amber-500", label: "파일", desc: "파일 업로드", Icon: Paperclip },
|
||||||
image: { color: "text-sky-600", bgColor: "bg-sky-50", barColor: "bg-sky-500", label: "이미지", desc: "이미지 표시", iconChar: "🖼" },
|
image: { color: "text-sky-600", bgColor: "bg-sky-50", barColor: "bg-sky-500", label: "이미지", desc: "이미지 표시", Icon: ImageIcon },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** v3.2 — 사용자 선택 가능한 8개 입력 타입 색상 맵 (T2 드롭다운/카드 그리드용) */
|
/** v3.2 — 사용자 선택 가능한 8개 입력 타입 색상 맵 (T2 드롭다운/카드 그리드용) */
|
||||||
@@ -81,9 +108,23 @@ export const USER_SELECTABLE_INPUT_TYPE_COLORS = USER_SELECTABLE_INPUT_TYPE_ORDE
|
|||||||
{} as Record<string, TypeColorConfig>,
|
{} as Record<string, TypeColorConfig>,
|
||||||
);
|
);
|
||||||
|
|
||||||
/** 컬럼 그룹 판별 */
|
/** 컬럼 그룹 판별 — 시스템 자동 생성 컬럼은 meta 로 분류 (사용자가 거의 수정하지 않으므로 시각 분리) */
|
||||||
export function getColumnGroup(col: ColumnTypeInfo): ColumnGroup {
|
export function getColumnGroup(col: ColumnTypeInfo): ColumnGroup {
|
||||||
const metaCols = ["id", "created_date", "updated_date", "writer", "company_code"];
|
// 시스템 컬럼: invyone 자동 생성 (id/날짜/작성자/회사) 외에 VEX 계승 (objid), 멀티테넌트 (tenant_id),
|
||||||
|
// 수정자/생성자 변형 (creator/modifier/created_at/updated_at) 까지 모두 포함
|
||||||
|
const metaCols = [
|
||||||
|
"id",
|
||||||
|
"objid",
|
||||||
|
"tenant_id",
|
||||||
|
"created_date",
|
||||||
|
"updated_date",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
"writer",
|
||||||
|
"creator",
|
||||||
|
"modifier",
|
||||||
|
"company_code",
|
||||||
|
];
|
||||||
if (metaCols.includes(col.column_name)) return "meta";
|
if (metaCols.includes(col.column_name)) return "meta";
|
||||||
if (["entity", "code", "category"].includes(col.input_type)) return "reference";
|
if (["entity", "code", "category"].includes(col.input_type)) return "reference";
|
||||||
return "basic";
|
return "basic";
|
||||||
|
|||||||
@@ -37,19 +37,22 @@ export interface NodeFlowInfo {
|
|||||||
node_count: number;
|
node_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 백엔드 mapper (batchManagement.xml) 가 snake_case 로 응답하므로 그대로 사용한다.
|
||||||
|
// 프로젝트 컨벤션: Map 키 = snake_case (CLAUDE.md 백엔드 규칙 참조)
|
||||||
export interface BatchStats {
|
export interface BatchStats {
|
||||||
totalBatches: number;
|
total_count: number;
|
||||||
activeBatches: number;
|
active_count: number;
|
||||||
todayExecutions: number;
|
today_count: number;
|
||||||
todayFailures: number;
|
today_failed_count: number;
|
||||||
prevDayExecutions: number;
|
yesterday_count: number;
|
||||||
prevDayFailures: number;
|
yesterday_failed_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SparklineData {
|
export interface SparklineData {
|
||||||
hour: string;
|
hour_slot: string;
|
||||||
success: number;
|
total_count: number;
|
||||||
failed: number;
|
success_count: number;
|
||||||
|
failed_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RecentLog {
|
export interface RecentLog {
|
||||||
@@ -621,6 +624,24 @@ export class BatchAPI {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회사 전체 배치의 최근 24시간 스파크라인 데이터 (24개 슬롯 고정)
|
||||||
|
*/
|
||||||
|
static async getGlobalSparkline(): Promise<SparklineData[]> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<ApiResponse<SparklineData[]>>(
|
||||||
|
`/batch-management/sparkline`
|
||||||
|
);
|
||||||
|
if (response.data.success) {
|
||||||
|
return response.data.data || [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error("글로벌 스파크라인 조회 오류:", error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 배치별 최근 24시간 스파크라인 데이터
|
* 배치별 최근 24시간 스파크라인 데이터
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
# 배치 파이프라인 작업 핸드오프 (2026-05-13, 2026-05-14 업데이트)
|
||||||
|
|
||||||
|
작성자: hjjeong
|
||||||
|
관련 시작 노트: `notes/hjjeong/2026-05-12-batch-pipeline-current-state.md`
|
||||||
|
|
||||||
|
다음 새 세션에서 이 노트만 읽으면 즉시 컨텍스트 잡고 이어갈 수 있게 정리.
|
||||||
|
|
||||||
|
## 한 줄 상태
|
||||||
|
|
||||||
|
vexplor_rps → INVYONE 배치 파이프라인 이식 **Phase 0~5 코드 완료 + 푸시 완료 + 정적 검증 완료 + PR #12 머지 완료 (2026-05-13)**. **Phase 3 conditional 룰 종단 런타임 검증은 첫 사용자 시점으로 deferred** (사용자 결정 2026-05-14). JUnit @SpringBootTest 종단 시도 (2026-05-14) → SELECT path OK, INSERT path 는 transaction context 차이로 검증 불가 → 옵션 B (보류) 결정.
|
||||||
|
|
||||||
|
## 2026-05-14 세션 업데이트 (정적 점검 + 운영 DB read-only 확인)
|
||||||
|
|
||||||
|
이번 세션에서 한 일:
|
||||||
|
1. **코드 ↔ XML namespace.id 매칭 정합성 확인** — `batchExecutionLog.{insert,update}BatchExecutionLog` 둘 다 존재. 매핑 OK
|
||||||
|
2. **`batch_execution_logs` 실 컬럼 타입 vs Service INSERT 값 정합성** — 운영 4개 DB (`invyone`, `siflex_invyone`, `test01_invyone`, `test02_invyone`) read-only 조회. `id`/`batch_config_id`/`duration_ms`/`*_records` 전부 VARCHAR. Service 가 `String.valueOf` 명시 송출 → 정합 OK
|
||||||
|
3. **`useGeneratedKeys="true" keyProperty="id"` 동작 가능성** — `id` 가 VARCHAR + `nextval('batch_execution_logs_id_seq')` 디폴트. PG JDBC 가 String 으로 회수 → mapper UPDATE `WHERE id = #{id}` 그대로 사용 가능. OK
|
||||||
|
4. **tenant routing 정합성 (정적)** — `executeBatchConfig` 가 `@Transactional` 아님 → 매 `sqlSession.getConnection()` 마다 `TenantRoutingDataSource.determineCurrentLookupKey()` 호출 → `TenantHolder` 통과. OK
|
||||||
|
5. **과거 dev 실행 흔적 발견** — 메타 DB `invyone.batch_execution_logs` 에서:
|
||||||
|
- `id=1027 (2026-02-08, total=1, success=1, server_name='unknown', process_id=54848)` — 신 코드 `safeHostName()` "unknown" fallback 지문 + 높은 PID = 로컬 dev JVM. **Phase 4 ETL 1행 처리 성공 흔적**
|
||||||
|
- `id=14 (2026-04-03, total=0, server_name='unknown', process_id=54454)` — **Phase 5 INSERT/UPDATE 성공 흔적** (전체 코드 경로 통과 후 SUCCESS 마무리)
|
||||||
|
6. **Phase 3 conditional 룰은 0건 사용 중** — 4개 DB 통틀어 `batch_mappings.mapping_config` 채워진 행 0건. UI 는 살아있지만 사용자가 입력한 적 없음 → 운영 첫 사용 시점에 종단 검증 필요
|
||||||
|
|
||||||
|
결론: **정적 점검 + dev 흔적 기준으로 Phase 4/5 골격은 동작 확인.** PR 올리고 종결, Phase 3 conditional 종단은 첫 운영 사용자 시점에 함께 검증.
|
||||||
|
|
||||||
|
## 2026-05-14 후반 세션 — JUnit @SpringBootTest 종단 시도 → 옵션 B 보류
|
||||||
|
|
||||||
|
PR #12 머지 후, 사용자가 종단 검증을 자동화로 시도하길 요청. `@SpringBootTest` 통합 테스트를 만들어 운영 invyone 메타 DB 에 임시 `__phase3_*` 테이블 + `BatchExecutor.execute` 직접 호출로 검증 시도.
|
||||||
|
|
||||||
|
### 시도한 것
|
||||||
|
|
||||||
|
- `backend-spring/src/test/java/com/erp/batch/BatchExecutorIntegrationTest.java` (이 파일은 검증 후 삭제 — 운영 DB 가리키는 위험한 테스트라 PR 에 남기지 않음)
|
||||||
|
- 3개 테스트: `conditional_endToEnd`, `upsert_secondRunUpdates`, `rowFailureIsolation`
|
||||||
|
- `@Transactional @Commit` 으로 transaction context 부여, `DataSource` 직접 주입으로 setup/teardown
|
||||||
|
- Cleanup: `@BeforeAll` setupSchema (DROP/CREATE), `@AfterAll` teardown (DROP), `@BeforeEach` TRUNCATE
|
||||||
|
|
||||||
|
### 발견한 것
|
||||||
|
|
||||||
|
| 단계 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| Spring 컨텍스트 로딩 | ✅ OK (`JWT_SECRET` 더미 env 만 필요) |
|
||||||
|
| 운영 invyone 메타 connection | ✅ OK (`TenantRoutingDataSource` 가 META fallback) |
|
||||||
|
| 임시 테이블 setup/teardown | ✅ OK (잔여 0건) |
|
||||||
|
| `BatchExecutor.readFromInternal` (SELECT) | ✅ `totalRecords=4` 정확히 통과 |
|
||||||
|
| `BatchExecutor.writeToInternal` (INSERT) | ❌ "Connection is closed" — 전 row INSERT fail |
|
||||||
|
|
||||||
|
### 진단
|
||||||
|
|
||||||
|
운영 HTTP 요청 흐름 (Tomcat thread + Spring transaction-aware connection 관리) 에서는 `try (Connection c = sqlSession.getConnection()) { ... c.prepareStatement(...) }` 패턴이 잘 동작 (dev 흔적 `id=1027` 가 증거).
|
||||||
|
|
||||||
|
JUnit `@SpringBootTest` 는 `@Transactional` 붙여도 `SqlSessionTemplate` 의 transaction binding 이 완벽히 작동 안 함 → 외부 `try (Connection c = ...)` 의 c 가 body 진입 시점에 release 되어 다음 `c.prepareStatement(...)` 가 "Connection is closed" 던짐.
|
||||||
|
|
||||||
|
흥미로운 점: SELECT path 는 `try (Connection c = ...; PreparedStatement ps = c.prepareStatement(...); ResultSet rs = ...)` 처럼 한 줄에서 resource 전부 init → 시간차 없어 OK. INSERT path 는 외부 try body 에서 별도 prepareStatement 호출이라 fail.
|
||||||
|
|
||||||
|
### 결정 (사용자, 2026-05-14)
|
||||||
|
|
||||||
|
옵션 B (보류) 선택. 이유:
|
||||||
|
|
||||||
|
- BatchExecutor 의 핵심 변환 로직 (MappingTransformer) 은 이미 단위 테스트 18건으로 검증됨
|
||||||
|
- INSERT 흐름의 connection 관리는 production HTTP 컨텍스트에서 dev 흔적 (`id=14, 1027`) 으로 검증됨
|
||||||
|
- JUnit 으로 종단 검증하려면 ① backend 띄우고 HTTP curl (셋업 ~30분) 또는 ② BatchExecutor 코드 수정 (머지된 코드라 위험) 둘 다 비용 vs 가치 불균형
|
||||||
|
- 원래 2026-05-14 초반 결정: 종단은 운영 첫 사용자 시점에 6개 검증 항목 (A~F) 으로 흡수
|
||||||
|
|
||||||
|
### 다음 세션 참고
|
||||||
|
|
||||||
|
만약 종단 검증을 꼭 자동화하려면 **backend 띄우고 HTTP curl** 이 정공법:
|
||||||
|
|
||||||
|
1. `docker compose -f docker/dev/docker-compose.backend.mac.yml up -d` 또는 `./gradlew bootRun` (운영 DB 가리킴)
|
||||||
|
2. 인증 토큰 발급 (`POST /api/auth/login` 같은 endpoint — admin 계정 필요)
|
||||||
|
3. 메타 DB 에 batch_config + batch_mappings INSERT (SQL)
|
||||||
|
4. `POST /api/batch-management/batch-configs/{id}/execute` 호출
|
||||||
|
5. `batch_execution_logs` 확인 + cleanup
|
||||||
|
|
||||||
|
BatchExecutor 코드 자체 수정은 PR #12 머지된 코드라 의도적 회피.
|
||||||
|
|
||||||
|
## Git 상태 (세션 끝 기준)
|
||||||
|
|
||||||
|
- 현재 브랜치: `hjjeong`
|
||||||
|
- 푸시: `origin/hjjeong` 에 22 커밋 (`7315603f..f53307a7`)
|
||||||
|
- `origin/main` 과 무충돌 머지 완료 (`f53307a7`)
|
||||||
|
- PR 미생성 — 사용자가 직접 https://git.junggomoa.com/gbpark/invyone/pulls/new/hjjeong 에서 생성 예정
|
||||||
|
|
||||||
|
## 완료된 커밋 (12개, 가독성 순)
|
||||||
|
|
||||||
|
```
|
||||||
|
54a8f97f fix(batch): 미리보기 → 매핑 카드 표시 흐름 정상화 + 매핑 카드 컴팩트화
|
||||||
|
cbf94dc9 feat(batch): TO DB 자동 선택 (internal) + Select 컴포넌트 controlled 화
|
||||||
|
b752de23 fix(batch): previewRestApiData convertCamelToSnake (실은 no-op, 54a8f97f 에서 정정)
|
||||||
|
6fcb101f style(batch): API 파라미터 설정을 collapsible 로 변경 — 기본 접힘
|
||||||
|
47eed680 fix(external-rest-api): WHERE ID = #{id} 에 ::varchar 캐스팅 추가
|
||||||
|
d8f606ab style(batch): 기본 정보를 모드 토글과 한 행으로 통합
|
||||||
|
e8f517ed fix(batch): batch-management-new 도 풀폭 적용
|
||||||
|
d02bc38f style(batch): FROM 카드 행 그룹화 + 컴팩트
|
||||||
|
0c9e22a6 feat(batch): 등록 REST API 연결 자동 호출 + 응답 필드 추출
|
||||||
|
570b3267 feat(batch): batch-management-new 에 conditional 매핑 추가
|
||||||
|
0bba1836 fix(batch): 빈 화면 원인 openTab 키명 정정 + 풀폭
|
||||||
|
f70719ae fix(batch): batch_execution_logs VARCHAR 컬럼에 String.valueOf
|
||||||
|
3ab7deb1 test(batch): Phase 3 — MappingTransformerTest (18 cases)
|
||||||
|
d5925472 fix(batch): writeTo @Transactional 제거 + end_time Timestamp 객체
|
||||||
|
6f8461a5 feat(batch): Phase 5 — executeBatchConfig + batch_execution_logs 기록
|
||||||
|
17172cf9 feat(batch): Phase 4 — BatchExecutor ETL 본체
|
||||||
|
f9a9c678 feat(batch): Phase 3 — MappingTransformer lookup 엔진
|
||||||
|
f31a7f85 feat(batch): Phase 2 — 프런트 ConditionalEditor + 조건 변환 매핑 UI
|
||||||
|
2675c829 feat(batch): Phase 1 — MAPPING_CONFIG JSONB 컬럼 + JSON 직렬화
|
||||||
|
dce665ca feat(batch): Phase 0 — batch_mappings CRUD path
|
||||||
|
f53307a7 Merge remote-tracking branch 'origin/main' into hjjeong
|
||||||
|
```
|
||||||
|
|
||||||
|
## 검증 상태 (2026-05-14 갱신)
|
||||||
|
|
||||||
|
| Phase | 검증 방식 | 결과 |
|
||||||
|
|---|---|---|
|
||||||
|
| Phase 0 (CRUD path) | DB 스키마 + GET 응답 — 부분 검증 | ✅ 스키마 OK / 런타임 미검증 |
|
||||||
|
| Phase 1 (MAPPING_CONFIG) | 운영 DB 컬럼 존재 확인 | ✅ 이미 컬럼 있음 (멱등 안전) |
|
||||||
|
| Phase 2 (ConditionalEditor) | 사용자 브라우저 UI 검증 | ✅ 화면 표시/조작 확인 |
|
||||||
|
| Phase 3 (MappingTransformer) | JUnit 18 tests | ✅ 전부 통과 |
|
||||||
|
| Phase 4 (BatchExecutor) | 정적 점검 + dev 실행 흔적 | 🟡 골격 OK (`id=1027 (2026-02-08, total=1)` 1행 처리 성공 흔적). 실 운영 데이터로 종단 미검증 |
|
||||||
|
| Phase 5 (executeBatchConfig + 로그) | 정적 점검 + dev 실행 흔적 | 🟡 INSERT/UPDATE 동작 확인 (`id=14 (2026-04-03, server_name='unknown')`). 다중 회사 동시 실행은 미검증 |
|
||||||
|
|
||||||
|
## 🚨 아직 테스트 안 한 것 / 운영 첫 사용 시 검증할 것 (★ 필독)
|
||||||
|
|
||||||
|
PR 머지 후 첫 운영 사용자 또는 QA 가 반드시 직접 확인할 항목.
|
||||||
|
|
||||||
|
### A. Phase 3 conditional 룰 종단 (가장 큰 갭)
|
||||||
|
|
||||||
|
- 현재 4개 DB 통틀어 `batch_mappings.mapping_config` 채워진 행 **0건**
|
||||||
|
- 첫 사용자가 ConditionalEditor 로 룰 입력 → save → execute 까지의 전체 흐름이 종단으로 동작하는 적이 한 번도 없었음
|
||||||
|
- **확인 절차**:
|
||||||
|
1. `batchmngList/edit/[id]` 또는 `batch-management-new` 진입
|
||||||
|
2. ConditionalEditor 로 conditional 룰 1건 추가 (예: `from_value='A' → to_value='가나'`)
|
||||||
|
3. 저장 → DB 에서 `batch_mappings.mapping_config` 가 JSONB 로 잘 들어갔는지 확인 (`SELECT id, mapping_type, mapping_config FROM batch_mappings WHERE batch_config_id=...`)
|
||||||
|
4. 수동 실행 버튼 → `batch_execution_logs` 새 행 추가 확인 + TO 테이블에 `to_value` 가 `'가나'` 로 변환되어 들어갔는지 확인
|
||||||
|
- 잠재 이슈: `MappingTransformer.applyConditional` 의 default fallback / 매칭 우선순위 / NULL 처리 — 단위 테스트 통과지만 실 데이터 형태가 다를 수 있음
|
||||||
|
|
||||||
|
### B. Phase 4 외부 소스/대상 종단
|
||||||
|
|
||||||
|
- **FROM = `external_db`** (외부 DB SELECT) — `ExternalDbConnectionService.executeQuery` 경유. dev 흔적 없음
|
||||||
|
- **FROM = `restapi`** (등록 REST API 호출) — `ExternalRestApiConnectionService.fetchData` + `dataArrayPath` 추출. dev 흔적 없음
|
||||||
|
- **TO = `restapi`** (행 단위 POST/PUT/DELETE) — `ExternalRestApiConnectionService.testConnection` 경유. dev 흔적 없음
|
||||||
|
- 확인: 각 타입별로 1건씩 실 호출 성공/실패 카운트가 `batch_execution_logs.success_records`/`failed_records` 에 정확히 반영되는지
|
||||||
|
|
||||||
|
### C. Phase 5 다중 회사 동시 실행 (tenant routing 충돌 가능성)
|
||||||
|
|
||||||
|
- 정적으로는 `executeBatchConfig` 가 `@Transactional` 아님 + 매번 `sqlSession.getConnection()` 새로 borrow → 안전하게 보임
|
||||||
|
- 하지만 같은 시각에 회사 A 의 cron 과 회사 B 의 수동 실행이 겹칠 때 `TenantHolder` (ThreadLocal) 가 정확히 매번 set/clear 되는지 미검증
|
||||||
|
- 확인: 회사 2개 (`siflex`, `test01`) 에서 동시에 같은 batch 실행 → 각자의 `batch_execution_logs` 에만 행 추가되고 cross-DB 오염 없는지
|
||||||
|
|
||||||
|
### D. UPSERT (`save_mode=UPSERT` + `conflict_key`) 종단
|
||||||
|
|
||||||
|
- `BatchExecutor.buildInsertSql` 의 `ON CONFLICT (...) DO UPDATE SET ... = EXCLUDED. ...` 분기 — dev 흔적 없음
|
||||||
|
- 확인: UPSERT 모드 batch 1건 만들어 실행 → 같은 키로 두 번째 실행 시 INSERT 가 아니라 UPDATE 로 동작하는지
|
||||||
|
|
||||||
|
### E. 행 단위 실패 격리
|
||||||
|
|
||||||
|
- `writeToInternal` 가 try-catch 로 row-level 실패만 카운트 — 전체 트랜잭션 롤백 없음 (vexplor_rps 와 동일 패턴, 의도적)
|
||||||
|
- 확인: 일부러 NOT NULL 위반 행 1건 + 정상 행 9건 섞어 실행 → `success=9, failed=1` 정확히 집계되는지
|
||||||
|
|
||||||
|
### F. 회사 코드 필터 (`companyCode`) 누수
|
||||||
|
|
||||||
|
- `BatchExecutor.execute` 가 `config.get("company_code")` 로만 회사 식별 → MappingTransformer 의 fixed/conditional 룰에 회사 정보 주입은 정확한지
|
||||||
|
- 확인: 회사 A 의 batch 가 회사 B 의 데이터를 fetch/write 하지 않는지 (multi-tenant 핵심)
|
||||||
|
|
||||||
|
## 알려진 잠재 리스크 (정적 점검에서 OK 였지만 운영 시 마주칠 가능성)
|
||||||
|
|
||||||
|
1. ~~**`sqlSession.getConnection()` + tenant routing**~~ — ✅ 2026-05-14 정적 OK 확인 (`@Transactional` 미사용 → 매번 routing). 다중 회사 동시 실행 종단 검증은 위 "🚨 C" 항목
|
||||||
|
2. ~~**MyBatis namespace.id 매칭**~~ — ✅ 2026-05-14 XML 대조로 `batchExecutionLog.{insert,update}BatchExecutionLog` 둘 다 존재 확인
|
||||||
|
3. **`@Transactional writeTo` self-call** — `d5925472` 에서 제거함. 행 단위 독립 commit (vexplor_rps 와 동일 패턴)
|
||||||
|
4. ~~**`duration_ms / *_records` VARCHAR**~~ — ✅ 2026-05-14 운영 DB read-only 로 컬럼 타입 일치 확인 + `String.valueOf` 송출 정합 OK
|
||||||
|
5. **외부 호출 인증 안내** — Wehago HMAC 자동 안내 / `auth_tokens` 자동 조회는 의도적 미구현 (Amaranth 회사 전용이라 INVYONE 일반에는 부적합)
|
||||||
|
|
||||||
|
## Phase 4 의 의도적 단순화 (vexplor_rps 대비)
|
||||||
|
|
||||||
|
- `to_api_body` 템플릿 기반 일괄 전송 — 미지원 (행 단위 POST/PUT/DELETE 만)
|
||||||
|
- `URL_PATH_PARAM` 컬럼 처리 — 미지원
|
||||||
|
- inline-mode REST(`from_connection_id` 없이 직접 URL/Key) — 미지원
|
||||||
|
- `auth_tokens` 자동 조회 — 미지원
|
||||||
|
- `row_filter_config` — 미지원
|
||||||
|
- `external_db` TO 쓰기 — 미지원 (INVYONE `ExternalDbConnectionService` 가 보안상 SELECT-only)
|
||||||
|
|
||||||
|
위 항목이 운영 배치에 필요해지면 Phase 4.x 로 incremental 추가.
|
||||||
|
|
||||||
|
## 다음 후보 작업 (우선순위 무관)
|
||||||
|
|
||||||
|
### A. 편집 화면 일관성
|
||||||
|
`batchmngList/edit/[id]/page.tsx` 에 신규 생성 화면 (batch-management-new) 과 동일한 흐름 적용:
|
||||||
|
- TO DB 자동 선택
|
||||||
|
- Select controlled (value prop)
|
||||||
|
- ConditionalEditor 동작 검증 (Phase 2 에서 이미 추가했지만 사용자 직접 확인 필요)
|
||||||
|
|
||||||
|
### B. 같은 패턴 일괄 점검
|
||||||
|
- Select `value` prop 누락 다른 페이지에 있을 가능성
|
||||||
|
- ::varchar 캐스팅 누락 매퍼들 (`externalDbConnection.xml`, `externalCallConfig.xml`, `booking.xml`, `delivery.xml`, `multiConnection.xml`, `taxInvoice.xml` 등)
|
||||||
|
- camelCase / snake_case mismatch 다른 진입점에 있을 가능성
|
||||||
|
|
||||||
|
### ~~C. Phase 4+5 통합 검증 ★~~ — 2026-05-14 deferred
|
||||||
|
|
||||||
|
PR 머지 + 운영 첫 사용 시점에 위 "🚨 아직 테스트 안 한 것" 의 A~F 항목으로 흡수. 별도 사전 검증 라운드는 안 함 (사용자 결정).
|
||||||
|
|
||||||
|
### D. vexplor_rps 차이 보강
|
||||||
|
- 응답 미리보기 Quick Test 버튼 (`runQuickResponseTest`) — 등록 연결 외 inline 모드에서도 즉시 응답 확인
|
||||||
|
- 인증 토큰 자동 안내 UI
|
||||||
|
|
||||||
|
### E. 사이드 이슈
|
||||||
|
- 사이드바 메뉴 로딩 timeout (사용자 사용 중 단발성 발생, `/admin/user-menus` 30s timeout) — 단발성이라 패스했지만 재현되면 별도 진단 필요
|
||||||
|
- Frontend tsc 타입 에러 2871건 — pre-existing 누적, dev 동작은 OK 라 무시 가능하지만 정리 가치 있음
|
||||||
|
|
||||||
|
## 참고 메모리
|
||||||
|
|
||||||
|
- `feedback_no_db_no_settings.md` — 명시 요청 없으면 DB/env/build 변경 금지
|
||||||
|
- `feedback_commit_after_solved.md` — 중간 단계마다 커밋 X, 해결 후 묶어서
|
||||||
|
- `project_batch_varchar.md` — `batch_*` 의 `_id` 컬럼은 VARCHAR. ::varchar 캐스팅 필수
|
||||||
|
|
||||||
|
## 운영 DB 접속 (검증용 read-only 만)
|
||||||
|
|
||||||
|
```
|
||||||
|
host: 183.99.177.40
|
||||||
|
port: 5432
|
||||||
|
user: postgres
|
||||||
|
password: invyone0909!!
|
||||||
|
db: invyone (메타) / 테넌트 별 invyone_* DB
|
||||||
|
```
|
||||||
|
|
||||||
|
사용자 허락한 범위: **read-only SELECT 만**.
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# 외부 DB 커넥션 멀티 DB 지원 + UI 정돈 핸드오프
|
||||||
|
|
||||||
|
**작업자**: hjjeong
|
||||||
|
**날짜**: 2026-05-18
|
||||||
|
**관련 PR**: #21 (머지 완료, main 반영)
|
||||||
|
**관련 커밋**:
|
||||||
|
- `d61777ab` fix(admin): 외부커넥션 mapper varchar 캐스팅 + 외부커넥션/배치관리 UI 정돈
|
||||||
|
- `46707bd1` feat(admin): 외부 DB 커넥션 멀티 DB 테스트 + 프로비저닝 시퀀스 reset 보강
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 배경
|
||||||
|
|
||||||
|
`/admin/automaticMng/exconList` (외부커넥션관리) 페이지가 작동은 했지만 다음 문제가 누적:
|
||||||
|
- 페이지 자체 스크롤이 안 생겨 잘림, 컬럼/탭 폰트가 다른 admin 페이지보다 큼
|
||||||
|
- 연결 테스트 시 500 (mapper SQL 의 VARCHAR vs bigint 비교 오류)
|
||||||
|
- 비-PostgreSQL DB 등록은 되는데 테스트 단계에서 "PostgreSQL 만 지원" 가드로 막힘
|
||||||
|
- 모달이 길어지면 저장/취소 버튼이 화면 밖으로 밀려나감
|
||||||
|
- 회사 프로비저닝 시 외부커넥션 INSERT 가 `duplicate key (id)=(5)` 로 실패
|
||||||
|
|
||||||
|
배치관리 (`/admin/automaticMng/batchmngList`) 도 같은 컨테이너 잘림 + 페이지네이션 부재.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 변경 사항
|
||||||
|
|
||||||
|
### 2.1 UI / 레이아웃
|
||||||
|
|
||||||
|
#### `frontend/app/(main)/admin/automaticMng/exconList/page.tsx`
|
||||||
|
- 컨테이너: `flex h-full min-h-0 overflow-hidden` + Tabs/TabsContent 가 flex 컬럼 → 페이지는 viewport 에 고정, 테이블만 자체 스크롤
|
||||||
|
- 폰트 컴팩트: `text-3xl → text-lg`, `text-sm → text-xs`, `h-10 → h-8`
|
||||||
|
- ResponsiveDataView 에 `scrollContainer` + `compact` 활성화
|
||||||
|
|
||||||
|
#### `frontend/components/admin/RestApiConnectionList.tsx`
|
||||||
|
- 동일 패턴: `flex flex-1 min-h-0 overflow-hidden` 컨테이너, `<Table divClassName="flex-1 overflow-auto">`, `<TableHeader className="sticky top-0 z-10 bg-muted">`
|
||||||
|
- 테이블 헤더/행 컴팩트화
|
||||||
|
|
||||||
|
#### `frontend/components/common/ResponsiveDataView.tsx`
|
||||||
|
- `compact` 모드일 때 폰트/셀패딩/카드 폰트도 함께 축소
|
||||||
|
- `scrollContainer` 모드일 때 `@3xl:block` 이 `flex` 를 덮어쓰던 우선순위 충돌 수정 (`@3xl:flex` + `flex-col` 분기)
|
||||||
|
- sticky header bg 알파 50% → 100% (bg-muted) — 본문이 헤더 뒤로 비치던 문제
|
||||||
|
|
||||||
|
#### `frontend/components/admin/ExternalDbConnectionModal.tsx`
|
||||||
|
- DialogContent 를 flex 컬럼으로, 본문 div 자체 스크롤, DialogFooter `shrink-0` → 폼이 길어도 저장/취소 항상 보임
|
||||||
|
|
||||||
|
#### `frontend/app/(main)/admin/automaticMng/batchmngList/page.tsx`
|
||||||
|
- 컨테이너 잘림 해결 (동일 패턴)
|
||||||
|
- **페이지네이션 추가**: RPS `vexplor_rps` 배치관리 페이지 참고. `Pagination` 컴포넌트(`frontend/components/common/Pagination.tsx`) 활용. 페이지당 10/20/50/100 선택, 필터 변경 시 1페이지 리셋
|
||||||
|
|
||||||
|
### 2.2 mapper SQL — VARCHAR 캐스팅
|
||||||
|
|
||||||
|
V001 legacy 마이그레이션(`notes/gbpark/2026-05-03-legacy-sql-archive/V001__varchar_migration.sql`)이 `EXTERNAL_DB_CONNECTIONS` 의 다음 컬럼들을 VARCHAR 로 바꿔놨는데 mapper 가 안 따라가서 500 발생:
|
||||||
|
|
||||||
|
| 컬럼 | 원래 타입 | V001 후 | 영향 SQL |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `ID` | bigint | varchar | WHERE ID = #{id} 비교 |
|
||||||
|
| `port` | int | varchar | INSERT/UPDATE 바인딩 |
|
||||||
|
| `connection_timeout` | int | varchar | 동일 |
|
||||||
|
| `query_timeout` | int | varchar | 동일 |
|
||||||
|
| `max_connections` | int | varchar | 동일 |
|
||||||
|
|
||||||
|
**`backend-spring/src/main/resources/mapper/externalDbConnection.xml`** 수정 — 모든 위치에 `::varchar` 캐스팅 추가:
|
||||||
|
- 단건 조회 / 비밀번호 조회 / 이름 중복 체크(exclude) / UPDATE / DELETE 의 ID 비교 6곳
|
||||||
|
- INSERT/UPDATE 의 숫자→VARCHAR 컬럼 4종
|
||||||
|
|
||||||
|
이미 `externalRestApiConnection.xml` 은 캐스팅 처리돼 있어서(REST API 테스트는 정상이었음) 정작 누락된 건 DB mapper 만이었음. 동일 패턴이 다른 mapper 에도 잠재. 의심되는 mapper 가 있으면 `WHERE *_id = #{*_id}` 패턴 검색해서 `::varchar` 캐스트 보강 필요.
|
||||||
|
|
||||||
|
### 2.3 백엔드 — 멀티 DB 테스트 지원
|
||||||
|
|
||||||
|
**`backend-spring/build.gradle`** — 드라이버 4종 `runtimeOnly` 추가:
|
||||||
|
```
|
||||||
|
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client:3.4.1'
|
||||||
|
runtimeOnly 'com.mysql:mysql-connector-j:8.4.0'
|
||||||
|
runtimeOnly 'com.microsoft.sqlserver:mssql-jdbc:12.8.1.jre11'
|
||||||
|
runtimeOnly 'org.xerial:sqlite-jdbc:3.46.1.0'
|
||||||
|
```
|
||||||
|
|
||||||
|
Oracle 은 라이선스로 미포함 (UI 에 옵션 있지만 백엔드 default 분기로 "지원하지 않는 DB 타입" 응답).
|
||||||
|
|
||||||
|
**`backend-spring/src/main/java/com/erp/service/ExternalDbConnectionService.java`**
|
||||||
|
- `executeConnectionTest` 의 PostgreSQL-only 가드 제거
|
||||||
|
- `switch (type)` 으로 dbType 별 JDBC URL/props 분기:
|
||||||
|
- **postgresql**: `jdbc:postgresql://h:p/d` + `connect_timeout` / `sslmode=require`
|
||||||
|
- **mysql**: `jdbc:mysql://h:p/d` + `connectTimeout`(ms) / `useSSL` / `allowPublicKeyRetrieval`
|
||||||
|
- **mariadb**: `jdbc:mariadb://h:p/d` + `connectTimeout`(ms) / `useSsl`
|
||||||
|
- **mssql/sqlserver**: `jdbc:sqlserver://h:p;databaseName=d;loginTimeout=...;encrypt=...`
|
||||||
|
- **sqlite**: `jdbc:sqlite:<database_name 을 파일경로로>` (host/port 무시)
|
||||||
|
- `defaultPort(String dbType)` 헬퍼 추가 (mysql/mariadb=3306, mssql=1433 등)
|
||||||
|
|
||||||
|
### 2.4 프로비저닝 — VARCHAR PK 시퀀스 reset
|
||||||
|
|
||||||
|
**문제**: 회사 신규 생성 시 `DataCopier.resetSequences()` 가 integer 컬럼만 setval 하고 VARCHAR PK 는 건너뛰어, V001 으로 INT→VARCHAR 변환됐지만 `DEFAULT nextval(...)` 의존성이 남은 컬럼(external_db_connections.id 등) 이 매번 새 회사에서 충돌.
|
||||||
|
|
||||||
|
**원래 코드의 의도** (DataCopier.java 의 주석):
|
||||||
|
> 레거시 DB 에선 SERIAL 이었다가 나중에 TEXT 로 타입 변경된 컬럼이 있을 수 있음. 이런 컬럼에 setval 을 호출하면 "COALESCE types text and integer cannot be matched" 예외 발생.
|
||||||
|
> invyone 은 대다수 PK 가 VARCHAR (문자열 PK). 시퀀스가 연결되어 있어도 실제 INSERT 때 nextval 을 사용하지 않으므로 setval 은 no-op.
|
||||||
|
|
||||||
|
→ 두 번째 가정이 V001 영향 테이블에는 안 맞았음.
|
||||||
|
|
||||||
|
**`backend-spring/src/main/java/com/erp/provisioning/DataCopier.java`** `resetSequences()` 확장:
|
||||||
|
- `isIntegerLike()` 외에 `isVarcharLike()` 분기 추가
|
||||||
|
- VARCHAR 인 경우 `setval(seq, GREATEST(COALESCE((SELECT MAX(col::bigint) FROM tbl WHERE col ~ '^[0-9]+$'), 0), 1))` 사용
|
||||||
|
- `::bigint` 명시 캐스트로 COALESCE 타입 충돌 회피
|
||||||
|
- `^[0-9]+$` 정규식으로 UUID 같은 비숫자 PK 거름 → 0 으로 떨어져 무해
|
||||||
|
|
||||||
|
→ 새 회사 생성 시 자동 처리. 기존 회사 DB 들은 1회성 SQL 필요:
|
||||||
|
```sql
|
||||||
|
SELECT setval(
|
||||||
|
pg_get_serial_sequence('external_db_connections', 'id'),
|
||||||
|
COALESCE((SELECT MAX(id::bigint) FROM external_db_connections), 0)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 미해결 / 후속 작업
|
||||||
|
|
||||||
|
1. **암호화 키 mismatch 데이터** — `external_db_connections.password` 컬럼이 다른 인스턴스에서 가져온 경우(예: VEX or 다른 INVYONE) AES 키가 달라 `Given final block not properly padded` 로 복호화 실패. 코드 차원 해결 불가. 각 행 편집해서 비밀번호 재입력하거나, 이전 인스턴스의 `encryption.secret-key` 값을 알면 SQL 일괄 재암호화 가능.
|
||||||
|
|
||||||
|
2. **SQL 실행 / 테이블 메타 조회 가드** — `ExternalDbConnectionService.executeQuery` (L369), `getTables` (L406 부근), `getColumns` (L446 부근) 에 동일한 PostgreSQL-only 가드가 남아있음. 멀티 DB 테스트는 풀었지만 이 기능들은 아직 PG 전용. 사용자 요청 시 같은 패턴으로 풀 수 있음.
|
||||||
|
|
||||||
|
3. **Oracle 드라이버 미포함** — `com.oracle.database.jdbc:ojdbc11` 추가 + service 의 `switch` 에 `oracle` case 추가하면 됨. 라이선스 검토 필요.
|
||||||
|
|
||||||
|
4. **다른 mapper 의 VARCHAR 캐스팅 누락 잠재** — V001 로 INT→VARCHAR 변환된 컬럼이 다른 mapper 에도 있을 수 있음. 신규 500 보이면 mapper 가 `WHERE *_id = #{*_id}` 패턴인지 먼저 의심.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 검증
|
||||||
|
|
||||||
|
- `./gradlew build` (backend) — BUILD SUCCESSFUL, unit test 통과
|
||||||
|
- `npm run build` (frontend) — 전체 페이지 빌드 통과 (115+ 라우트)
|
||||||
|
- main 머지 후 fast-forward 동기화 완료
|
||||||
Reference in New Issue
Block a user