Compare commits
52 Commits
2348800e68
...
johngreen
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 318cac4f68 | |||
| 2f398ae0b3 | |||
| 58ede650ae | |||
| 4c5b672f40 | |||
| 904fdd33e7 | |||
| f73e468f66 | |||
| b25a6324f8 | |||
| 8a10edd8e1 | |||
| fc615a70be | |||
| 947b31eff5 | |||
| 46707bd116 | |||
| 467a41a3a8 | |||
| 75f6883497 | |||
| d306ac2865 | |||
| 78c5e3e358 | |||
| 6b204806b6 | |||
| d8877b243a | |||
| 90787d837f | |||
| 752e4fb644 | |||
| 14832a28ab | |||
| a0a4dc3bf5 | |||
| 8fff53b165 | |||
| c530a67cee | |||
| 34060d9534 | |||
| d61777ab5f | |||
| d5f9814865 | |||
| 824a3100ce |
@@ -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 작업 컨벤션
|
||||
|
||||
이 파일은 git 에 올라가는 **프로젝트 공용** Claude 가이드입니다. 모든 머신/팀원의 Claude Code 인스턴스가 이 컨벤션을 따라야 합니다.
|
||||
|
||||
@@ -33,6 +33,11 @@ dependencies {
|
||||
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
|
||||
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'
|
||||
implementation 'org.postgresql:postgresql'
|
||||
// 외부 커넥션 테스트용 JDBC 드라이버 (runtimeOnly — 내부 비즈니스 DB 는 PostgreSQL 만 사용)
|
||||
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'
|
||||
compileOnly 'org.projectlombok:lombok'
|
||||
annotationProcessor 'org.projectlombok:lombok'
|
||||
implementation 'org.flywaydb:flyway-core'
|
||||
|
||||
@@ -5,9 +5,15 @@ import java.util.Set;
|
||||
public final class InputTypeConstants {
|
||||
private InputTypeConstants() {}
|
||||
|
||||
/** 사용자가 직접 선택 가능한 INPUT_TYPE 8종 (INSERT/UPDATE-type 검증용) */
|
||||
/**
|
||||
* INSERT/UPDATE-type 검증용 허용 INPUT_TYPE.
|
||||
* 신규 표준 8종 + 운영 DB 에 잔존하는 legacy 7종(category/select/textarea/checkbox/radio/datetime/boolean).
|
||||
* 5/15 common-code 재설계가 화이트리스트를 8종으로 좁히면서도 옛 데이터/프론트 정리를 빠뜨려
|
||||
* 컬럼 설정 저장 batch 가 일괄 거부됐던 회귀 회복. legacy 정리는 별도 PR 로.
|
||||
*/
|
||||
public static final Set<String> USER_SELECTABLE_INPUT_TYPES = Set.of(
|
||||
"text", "number", "date", "code", "entity",
|
||||
"numbering", "file", "image"
|
||||
"numbering", "file", "image",
|
||||
"category", "select", "textarea", "checkbox", "radio", "datetime", "boolean"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.provisioning.SuperAdminGuard;
|
||||
import com.erp.service.AdminService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -30,13 +32,17 @@ public class AdminController {
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
@RequestParam Map<String, Object> params,
|
||||
HttpServletRequest request) {
|
||||
params.put("company_code", companyCode);
|
||||
params.put("user_type", role);
|
||||
params.put("user_id", userId);
|
||||
params.putIfAbsent("user_lang", "ko");
|
||||
params.put("is_management_screen",
|
||||
params.get("menu_type") == null || "true".equals(params.get("include_inactive")));
|
||||
// 관리 호스트(solution.invyone.com 등) 여부 — 테넌트 호스트이면 IS_SOLUTION_ONLY 메뉴를 SQL 단계에서 제외
|
||||
String host = request.getHeader("Host");
|
||||
params.put("is_management_host", !SuperAdminGuard.isTenantHost(host));
|
||||
return ResponseEntity.ok(ApiResponse.success(adminService.getAdminMenuList(params), "관리자 메뉴 목록 조회 성공"));
|
||||
}
|
||||
|
||||
@@ -49,11 +55,15 @@ public class AdminController {
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestParam Map<String, Object> params) {
|
||||
@RequestParam Map<String, Object> params,
|
||||
HttpServletRequest request) {
|
||||
params.put("company_code", companyCode);
|
||||
params.put("user_type", role);
|
||||
params.put("user_id", userId);
|
||||
params.putIfAbsent("user_lang", "ko");
|
||||
// 관리 호스트(solution.invyone.com 등) 여부 — 테넌트 호스트이면 IS_SOLUTION_ONLY 메뉴를 SQL 단계에서 제외
|
||||
String host = request.getHeader("Host");
|
||||
params.put("is_management_host", !SuperAdminGuard.isTenantHost(host));
|
||||
return ResponseEntity.ok(ApiResponse.success(adminService.getUserMenuList(params), "사용자 메뉴 목록 조회 성공"));
|
||||
}
|
||||
|
||||
|
||||
@@ -136,6 +136,15 @@ public class BatchManagementController {
|
||||
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건) */
|
||||
@GetMapping("/batch-configs/{id}/recent-logs")
|
||||
public ResponseEntity<ApiResponse<List<Map<String, Object>>>> getBatchRecentLogs(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.erp.controller;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.provisioning.SuperAdminGuard;
|
||||
import com.erp.service.CompanyManagementService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -16,6 +18,7 @@ import java.util.Map;
|
||||
@Slf4j
|
||||
public class CompanyManagementController {
|
||||
|
||||
private final SuperAdminGuard guard;
|
||||
private final CompanyManagementService companyManagementService;
|
||||
|
||||
/**
|
||||
@@ -24,9 +27,12 @@ public class CompanyManagementController {
|
||||
*/
|
||||
@DeleteMapping("/{companyCode}")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> deleteCompany(
|
||||
HttpServletRequest request,
|
||||
@PathVariable String companyCode,
|
||||
@RequestBody(required = false) Map<String, Object> body) {
|
||||
|
||||
guard.enforce(request);
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("company_code", companyCode);
|
||||
if (body != null) {
|
||||
@@ -52,7 +58,11 @@ public class CompanyManagementController {
|
||||
* ※ /{companyCode}/disk-usage 보다 먼저 정의 (경로 특이성으로 충돌 없음)
|
||||
*/
|
||||
@GetMapping("/disk-usage/all")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getAllCompaniesDiskUsage() {
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getAllCompaniesDiskUsage(
|
||||
HttpServletRequest request) {
|
||||
|
||||
guard.enforce(request);
|
||||
|
||||
try {
|
||||
Map<String, Object> data = companyManagementService.getAllCompaniesDiskUsage();
|
||||
return ResponseEntity.ok(ApiResponse.success(data));
|
||||
@@ -68,7 +78,11 @@ public class CompanyManagementController {
|
||||
*/
|
||||
@GetMapping("/{companyCode}/disk-usage")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> getCompanyDiskUsage(
|
||||
HttpServletRequest request,
|
||||
@PathVariable String companyCode) {
|
||||
|
||||
guard.enforce(request);
|
||||
|
||||
try {
|
||||
Map<String, Object> data = companyManagementService.getCompanyDiskUsage(companyCode);
|
||||
return ResponseEntity.ok(ApiResponse.success(data));
|
||||
|
||||
@@ -29,11 +29,12 @@ public class DdlController {
|
||||
@PostMapping("/tables")
|
||||
public ResponseEntity<ApiResponse<?>> createTable(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "role", required = false) String role,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
if (!isSuperAdmin(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
if (!isSuperAdmin(companyCode, role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
String tableName = (String) body.get("table_name");
|
||||
@@ -65,11 +66,12 @@ public class DdlController {
|
||||
public ResponseEntity<ApiResponse<?>> addColumn(
|
||||
@PathVariable String tableName,
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "role", required = false) String role,
|
||||
@RequestAttribute("user_id") String userId,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
if (!isSuperAdmin(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
if (!isSuperAdmin(companyCode, role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@@ -91,6 +93,33 @@ public class DdlController {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error((String) result.get("message")));
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/ddl/tables/{tableName}/columns/{columnName} - 컬럼 삭제
|
||||
*/
|
||||
@DeleteMapping("/tables/{tableName}/columns/{columnName}")
|
||||
public ResponseEntity<ApiResponse<?>> dropColumn(
|
||||
@PathVariable String tableName,
|
||||
@PathVariable String columnName,
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "role", required = false) String role,
|
||||
@RequestAttribute("user_id") String userId) {
|
||||
|
||||
if (!isSuperAdmin(companyCode, role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
Map<String, Object> result = ddlService.dropColumn(tableName, columnName, companyCode, userId);
|
||||
|
||||
if (Boolean.TRUE.equals(result.get("success"))) {
|
||||
return ResponseEntity.ok(ApiResponse.success(Map.of(
|
||||
"table_name", result.get("table_name"),
|
||||
"column_name", result.get("column_name"),
|
||||
"executed_query", result.get("executed_query")
|
||||
), (String) result.get("message")));
|
||||
}
|
||||
return ResponseEntity.status(400).body(ApiResponse.error((String) result.get("message")));
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/ddl/tables/{tableName} - 테이블 삭제
|
||||
*/
|
||||
@@ -98,10 +127,11 @@ public class DdlController {
|
||||
public ResponseEntity<ApiResponse<?>> dropTable(
|
||||
@PathVariable String tableName,
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "role", required = false) String role,
|
||||
@RequestAttribute("user_id") String userId) {
|
||||
|
||||
if (!isSuperAdmin(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
if (!isSuperAdmin(companyCode, role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
Map<String, Object> result = ddlService.dropTable(tableName, companyCode, userId);
|
||||
@@ -121,10 +151,11 @@ public class DdlController {
|
||||
@PostMapping("/validate/table")
|
||||
public ResponseEntity<ApiResponse<?>> validateTableCreation(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "role", required = false) String role,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
if (!isSuperAdmin(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
if (!isSuperAdmin(companyCode, role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
String tableName = (String) body.get("table_name");
|
||||
@@ -150,12 +181,13 @@ public class DdlController {
|
||||
@GetMapping("/logs")
|
||||
public ResponseEntity<ApiResponse<?>> getDdlLogs(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "role", required = false) String role,
|
||||
@RequestParam(required = false, defaultValue = "50") int limit,
|
||||
@RequestParam(required = false) String userId,
|
||||
@RequestParam(required = false) String ddlType) {
|
||||
|
||||
if (!isSuperAdmin(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
if (!isSuperAdmin(companyCode, role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
List<Map<String, Object>> logs = ddlService.getDdlLogs(limit, userId, ddlType);
|
||||
@@ -169,11 +201,12 @@ public class DdlController {
|
||||
@GetMapping("/statistics")
|
||||
public ResponseEntity<ApiResponse<?>> getDdlStatistics(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "role", required = false) String role,
|
||||
@RequestParam(required = false) String fromDate,
|
||||
@RequestParam(required = false) String toDate) {
|
||||
|
||||
if (!isSuperAdmin(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
if (!isSuperAdmin(companyCode, role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
Map<String, Object> statistics = ddlService.getDdlStatistics(fromDate, toDate);
|
||||
@@ -186,10 +219,11 @@ public class DdlController {
|
||||
@GetMapping("/tables/{tableName}/history")
|
||||
public ResponseEntity<ApiResponse<?>> getTableDdlHistory(
|
||||
@PathVariable String tableName,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "role", required = false) String role) {
|
||||
|
||||
if (!isSuperAdmin(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
if (!isSuperAdmin(companyCode, role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
List<Map<String, Object>> history = ddlService.getTableDdlHistory(tableName);
|
||||
@@ -204,10 +238,11 @@ public class DdlController {
|
||||
@GetMapping("/tables/{tableName}/info")
|
||||
public ResponseEntity<ApiResponse<?>> getTableInfo(
|
||||
@PathVariable String tableName,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "role", required = false) String role) {
|
||||
|
||||
if (!isSuperAdmin(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
if (!isSuperAdmin(companyCode, role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
Map<String, Object> tableInfo = ddlService.getTableInfo(tableName);
|
||||
@@ -229,10 +264,11 @@ public class DdlController {
|
||||
@DeleteMapping("/logs/cleanup")
|
||||
public ResponseEntity<ApiResponse<?>> cleanupOldLogs(
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "role", required = false) String role,
|
||||
@RequestParam(required = false, defaultValue = "90") int retentionDays) {
|
||||
|
||||
if (!isSuperAdmin(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
if (!isSuperAdmin(companyCode, role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
int deletedCount = ddlService.cleanupOldLogs(retentionDays);
|
||||
@@ -246,10 +282,11 @@ public class DdlController {
|
||||
*/
|
||||
@GetMapping("/info")
|
||||
public ResponseEntity<ApiResponse<?>> getInfo(
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
@RequestAttribute("company_code") String companyCode,
|
||||
@RequestAttribute(value = "role", required = false) String role) {
|
||||
|
||||
if (!isSuperAdmin(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
if (!isSuperAdmin(companyCode, role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(Map.of(
|
||||
@@ -292,7 +329,9 @@ public class DdlController {
|
||||
// 내부 유틸
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private boolean isSuperAdmin(String companyCode) {
|
||||
return "*".equals(companyCode);
|
||||
private boolean isSuperAdmin(String companyCode, String role) {
|
||||
// company_code 가 '*' 이고 role 이 SUPER_ADMIN 둘 다 충족해야 통과 (이중 체크).
|
||||
// 토큰 변조 또는 회사코드만으로 super 권한이 발급되는 사고 방지.
|
||||
return "*".equals(companyCode) && "SUPER_ADMIN".equals(role);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +142,135 @@ public class DepartmentController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄 미리보기 (read-only validation).
|
||||
* POST /api/departments/companies/{companyCode}/departments/bulk/preview
|
||||
* body: { action: "create"|"update_department"|"update_manager", rows: List<Map> }
|
||||
* response: { rows: [...with row_index/result/error_detail], ok_count, error_count }
|
||||
*/
|
||||
@PostMapping("/companies/{companyCode}/departments/bulk/preview")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> bulkPreview(
|
||||
@PathVariable String companyCode,
|
||||
@RequestAttribute("company_code") String userCompanyCode,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 부서를 처리할 권한이 없습니다."));
|
||||
}
|
||||
String action = body.get("action") != null ? body.get("action").toString() : "";
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> rows = body.get("rows") instanceof List
|
||||
? (List<Map<String, Object>>) body.get("rows") : null;
|
||||
if (rows == null) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("rows 가 없습니다."));
|
||||
}
|
||||
try {
|
||||
List<Map<String, Object>> result;
|
||||
switch (action) {
|
||||
case "create":
|
||||
result = departmentService.bulkPreviewCreate(companyCode, rows);
|
||||
break;
|
||||
case "update_department":
|
||||
result = departmentService.bulkPreviewUpdate(companyCode, "department", rows);
|
||||
break;
|
||||
case "update_manager":
|
||||
result = departmentService.bulkPreviewUpdate(companyCode, "manager", rows);
|
||||
break;
|
||||
default:
|
||||
return ResponseEntity.status(400)
|
||||
.body(ApiResponse.error("action 은 create|update_department|update_manager 중 하나."));
|
||||
}
|
||||
int ok = 0, err = 0;
|
||||
for (Map<String, Object> r : result) {
|
||||
if ("ok".equals(r.get("result"))) ok++; else err++;
|
||||
}
|
||||
Map<String, Object> data = new java.util.HashMap<>();
|
||||
data.put("rows", result);
|
||||
data.put("ok_count", ok);
|
||||
data.put("error_count", err);
|
||||
return ResponseEntity.ok(ApiResponse.success(data, "미리보기 완료"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄등록 적용 (@Transactional, all-or-nothing).
|
||||
* POST /api/departments/companies/{companyCode}/departments/bulk/create
|
||||
* body: { rows: List<Map> } — 클라이언트가 미리보기 결과 중 ok 인 row 만 보내야 함.
|
||||
*/
|
||||
@PostMapping("/companies/{companyCode}/departments/bulk/create")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> bulkCreate(
|
||||
@PathVariable String companyCode,
|
||||
@RequestAttribute("company_code") String userCompanyCode,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 부서를 등록할 권한이 없습니다."));
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> rows = body.get("rows") instanceof List
|
||||
? (List<Map<String, Object>>) body.get("rows") : null;
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("등록할 데이터가 없습니다."));
|
||||
}
|
||||
try {
|
||||
int inserted = departmentService.bulkSaveCreate(companyCode, rows);
|
||||
Map<String, Object> data = new java.util.HashMap<>();
|
||||
data.put("inserted", inserted);
|
||||
return ResponseEntity.status(201).body(ApiResponse.success(data, inserted + "건이 등록되었습니다."));
|
||||
} catch (DepartmentService.DuplicateDeptNameException e) {
|
||||
return ResponseEntity.status(409).body(ApiResponse.error(e.getMessage()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄업데이트 적용 (@Transactional). mode = department | manager.
|
||||
* POST /api/departments/companies/{companyCode}/departments/bulk/update
|
||||
* body: { mode: "department"|"manager", rows: List<Map> } — 각 row 에 dept_code 필수.
|
||||
*/
|
||||
@PostMapping("/companies/{companyCode}/departments/bulk/update")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> bulkUpdate(
|
||||
@PathVariable String companyCode,
|
||||
@RequestAttribute("company_code") String userCompanyCode,
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestBody Map<String, Object> body) {
|
||||
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
if (!isSuperAdmin(userCompanyCode) && !userCompanyCode.equals(companyCode)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("해당 회사의 부서를 수정할 권한이 없습니다."));
|
||||
}
|
||||
String mode = body.get("mode") != null ? body.get("mode").toString() : "";
|
||||
@SuppressWarnings("unchecked")
|
||||
List<Map<String, Object>> rows = body.get("rows") instanceof List
|
||||
? (List<Map<String, Object>>) body.get("rows") : null;
|
||||
if (rows == null || rows.isEmpty()) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error("수정할 데이터가 없습니다."));
|
||||
}
|
||||
try {
|
||||
int updated = departmentService.bulkUpdate(companyCode, mode, rows);
|
||||
Map<String, Object> data = new java.util.HashMap<>();
|
||||
data.put("updated", updated);
|
||||
return ResponseEntity.ok(ApiResponse.success(data, updated + "건이 수정되었습니다."));
|
||||
} catch (DepartmentService.DuplicateDeptNameException e) {
|
||||
return ResponseEntity.status(409).body(ApiResponse.error(e.getMessage()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부서 삭제 (soft-delete, V1 slim scope).
|
||||
* - 기존 hard-delete → DELETED_AT = NOW() 마킹으로 변경
|
||||
|
||||
@@ -187,7 +187,8 @@ public class TableManagementController {
|
||||
@PathVariable String tableName,
|
||||
@PathVariable String columnName,
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role) {
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
if (!isAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("관리자 권한이 필요합니다."));
|
||||
}
|
||||
@@ -197,7 +198,8 @@ public class TableManagementController {
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
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, "컬럼 웹타입이 설정되었습니다."));
|
||||
}
|
||||
|
||||
@@ -272,7 +274,7 @@ public class TableManagementController {
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role) {
|
||||
if (!isSuperAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> columns = (List<String>) body.get("columns");
|
||||
@@ -291,7 +293,7 @@ public class TableManagementController {
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String 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 indexType = (String) body.get("index_type");
|
||||
@@ -320,7 +322,7 @@ public class TableManagementController {
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
if (!isSuperAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
Object nullableObj = body.get("nullable");
|
||||
if (tableName == null || columnName == null || !(nullableObj instanceof Boolean)) {
|
||||
@@ -342,7 +344,7 @@ public class TableManagementController {
|
||||
@RequestAttribute("role") String role,
|
||||
@RequestAttribute("company_code") String companyCode) {
|
||||
if (!isSuperAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
Object uniqueObj = body.get("unique");
|
||||
if (tableName == null || columnName == null || !(uniqueObj instanceof Boolean)) {
|
||||
@@ -567,7 +569,7 @@ public class TableManagementController {
|
||||
@RequestBody Map<String, Object> body,
|
||||
@RequestAttribute("role") String role) {
|
||||
if (!isSuperAdmin(role)) {
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
|
||||
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자(SUPER_ADMIN) 권한이 필요합니다."));
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
List<String> logColumns = (List<String>) body.get("log_columns");
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.erp.crosstenant;
|
||||
|
||||
import com.erp.tenant.DbContextHolder;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
/**
|
||||
* Cross-tenant 어드민 엔드포인트 진입 가드.
|
||||
@@ -42,4 +44,16 @@ public final class CrossTenantContext {
|
||||
public static boolean isMetaContext() {
|
||||
return DbContextHolder.isMeta();
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리 호스트(solution.invyone.com / admin.invyone.com / localhost / 베이스 도메인) 외엔 거절.
|
||||
* cross-tenant 작업은 plane 격리상 관리 호스트에서만 허용. SuperAdminGuard.isTenantHost 와 동일 규칙.
|
||||
*/
|
||||
public static void requireManagementHost(HttpServletRequest request) {
|
||||
String host = request.getHeader("Host");
|
||||
if (com.erp.provisioning.SuperAdminGuard.isTenantHost(host)) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
||||
"Cross-tenant operations are only available on the management site");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,12 @@ public class CrossTenantController {
|
||||
*/
|
||||
@GetMapping("/_active-companies")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> activeCompaniesSmoke(HttpServletRequest request) {
|
||||
try {
|
||||
CrossTenantContext.requireManagementHost(request);
|
||||
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||
@@ -92,6 +98,12 @@ public class CrossTenantController {
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> listUsers(
|
||||
HttpServletRequest request,
|
||||
@RequestParam Map<String, Object> queryParams) {
|
||||
try {
|
||||
CrossTenantContext.requireManagementHost(request);
|
||||
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||
@@ -173,6 +185,12 @@ public class CrossTenantController {
|
||||
Map<String, Object> queryParams,
|
||||
String mapperId,
|
||||
boolean wrapSearchWithPercent) {
|
||||
try {
|
||||
CrossTenantContext.requireManagementHost(request);
|
||||
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||
|
||||
@@ -39,6 +39,12 @@ public class CrossTenantDeptController {
|
||||
public ResponseEntity<Map<String, Object>> listDepartments(
|
||||
HttpServletRequest request,
|
||||
@RequestParam("company_code") String companyCode) {
|
||||
try {
|
||||
CrossTenantContext.requireManagementHost(request);
|
||||
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(errorBody(e.getReason(), request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(errorBody("super_admin_required", request.getRequestURI()));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.erp.crosstenant;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.provisioning.CompanyAuditLogService;
|
||||
import com.erp.service.RoleService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -33,6 +34,7 @@ public class CrossTenantRoleController {
|
||||
|
||||
private final CrossTenantExecutor executor;
|
||||
private final RoleService roleService;
|
||||
private final CompanyAuditLogService auditLogService;
|
||||
|
||||
// ── 권한 그룹 CRUD ──────────────────────────────────────────────
|
||||
|
||||
@@ -49,6 +51,7 @@ public class CrossTenantRoleController {
|
||||
if (g != null) return g;
|
||||
|
||||
String targetCompany = (String) body.get("company_code");
|
||||
String actorId = (String) request.getAttribute("user_id");
|
||||
try {
|
||||
Map<String, Object> result = executor.runInCompany(targetCompany, () -> {
|
||||
Map<String, Object> params = new HashMap<>(body);
|
||||
@@ -62,6 +65,10 @@ public class CrossTenantRoleController {
|
||||
}
|
||||
return roleService.createRoleGroup(params);
|
||||
});
|
||||
auditLogService.log(targetCompany, actorId,
|
||||
CompanyAuditLogService.ACTION_CT_ROLE_UPDATE,
|
||||
(String) body.get("auth_code"),
|
||||
auditDetails(request, null));
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(result, "권한 그룹 생성 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
@@ -81,6 +88,7 @@ public class CrossTenantRoleController {
|
||||
if (g != null) return g;
|
||||
|
||||
String targetCompany = (String) body.get("company_code");
|
||||
String actorId = (String) request.getAttribute("user_id");
|
||||
try {
|
||||
Map<String, Object> result = executor.runInCompany(targetCompany, () -> {
|
||||
Map<String, Object> params = new HashMap<>(body);
|
||||
@@ -94,6 +102,10 @@ public class CrossTenantRoleController {
|
||||
}
|
||||
return roleService.updateRoleGroup(params);
|
||||
});
|
||||
auditLogService.log(targetCompany, actorId,
|
||||
CompanyAuditLogService.ACTION_CT_ROLE_UPDATE,
|
||||
id,
|
||||
auditDetails(request, id));
|
||||
return ResponseEntity.ok(ApiResponse.success(result, "권한 그룹 수정 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
@@ -111,12 +123,17 @@ public class CrossTenantRoleController {
|
||||
ResponseEntity<ApiResponse<Void>> g = guardVoid(request);
|
||||
if (g != null) return g;
|
||||
|
||||
String actorId = (String) request.getAttribute("user_id");
|
||||
try {
|
||||
executor.runInCompany(companyCode, () -> {
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
p.put("objid", id);
|
||||
roleService.deleteRoleGroup(p);
|
||||
});
|
||||
auditLogService.log(companyCode, actorId,
|
||||
CompanyAuditLogService.ACTION_CT_ROLE_UPDATE,
|
||||
id,
|
||||
auditDetails(request, id));
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "권한 그룹 삭제 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
@@ -266,6 +283,12 @@ public class CrossTenantRoleController {
|
||||
// ── 가드 헬퍼 (응답 타입별로 3가지 — Map/Void/List) ────────
|
||||
|
||||
private ResponseEntity<ApiResponse<Map<String, Object>>> guardMap(HttpServletRequest request) {
|
||||
try {
|
||||
CrossTenantContext.requireManagementHost(request);
|
||||
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||
@@ -278,6 +301,12 @@ public class CrossTenantRoleController {
|
||||
}
|
||||
|
||||
private ResponseEntity<ApiResponse<Void>> guardVoid(HttpServletRequest request) {
|
||||
try {
|
||||
CrossTenantContext.requireManagementHost(request);
|
||||
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||
@@ -290,6 +319,12 @@ public class CrossTenantRoleController {
|
||||
}
|
||||
|
||||
private ResponseEntity<ApiResponse<List<Map<String, Object>>>> guardList(HttpServletRequest request) {
|
||||
try {
|
||||
CrossTenantContext.requireManagementHost(request);
|
||||
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||
@@ -301,6 +336,14 @@ public class CrossTenantRoleController {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** audit log details 기본 맵 생성 헬퍼. */
|
||||
private Map<String, Object> auditDetails(HttpServletRequest request, String roleId) {
|
||||
Map<String, Object> d = new HashMap<>();
|
||||
d.put("host", request.getHeader("Host"));
|
||||
if (roleId != null) d.put("role_id", roleId);
|
||||
return d;
|
||||
}
|
||||
|
||||
/** "Y"/"N"/null 정규화 — RoleController 의 동일 헬퍼 미러. */
|
||||
private String asYn(Object raw) {
|
||||
if (raw == null) return null;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.erp.crosstenant;
|
||||
|
||||
import com.erp.dto.ApiResponse;
|
||||
import com.erp.provisioning.CompanyAuditLogService;
|
||||
import com.erp.service.AdminService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -36,6 +37,7 @@ public class CrossTenantUserController {
|
||||
|
||||
private final CrossTenantExecutor executor;
|
||||
private final AdminService adminService;
|
||||
private final CompanyAuditLogService auditLogService;
|
||||
|
||||
// ── 등록 / 수정 ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -51,9 +53,14 @@ public class CrossTenantUserController {
|
||||
if (guard != null) return guard;
|
||||
|
||||
String targetCompanyCode = (String) body.get("company_code");
|
||||
String actorId = (String) request.getAttribute("user_id");
|
||||
try {
|
||||
Map<String, Object> result = executor.runInCompany(targetCompanyCode,
|
||||
() -> adminService.saveUser(body));
|
||||
auditLogService.log(targetCompanyCode, actorId,
|
||||
CompanyAuditLogService.ACTION_CT_USER_CREATE,
|
||||
(String) body.get("user_id"),
|
||||
auditDetails(request, (String) body.get("user_id")));
|
||||
return ResponseEntity.ok(ApiResponse.success(result, "사용자 저장 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(ApiResponse.error(e.getMessage()));
|
||||
@@ -116,6 +123,7 @@ public class CrossTenantUserController {
|
||||
ResponseEntity<ApiResponse<Void>> guard = guardVoid(request);
|
||||
if (guard != null) return guard;
|
||||
|
||||
String actorId = (String) request.getAttribute("user_id");
|
||||
try {
|
||||
executor.runInCompany(companyCode, () -> {
|
||||
Map<String, Object> existing = adminService.getUserInfo(userId);
|
||||
@@ -124,6 +132,10 @@ public class CrossTenantUserController {
|
||||
}
|
||||
adminService.changeUserStatus(userId, "inactive");
|
||||
});
|
||||
auditLogService.log(companyCode, actorId,
|
||||
CompanyAuditLogService.ACTION_CT_USER_DELETE,
|
||||
userId,
|
||||
auditDetails(request, userId));
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "사용자 삭제 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage()));
|
||||
@@ -166,9 +178,14 @@ public class CrossTenantUserController {
|
||||
|
||||
String targetCompanyCode = (String) body.get("company_code");
|
||||
String userId = (String) body.get("user_id");
|
||||
String actorId = (String) request.getAttribute("user_id");
|
||||
try {
|
||||
executor.runInCompany(targetCompanyCode, () ->
|
||||
adminService.resetUserPassword(userId));
|
||||
auditLogService.log(targetCompanyCode, actorId,
|
||||
CompanyAuditLogService.ACTION_CT_PW_RESET,
|
||||
userId,
|
||||
auditDetails(request, userId));
|
||||
return ResponseEntity.ok(ApiResponse.success(null, "비밀번호 초기화 성공"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error(e.getMessage()));
|
||||
@@ -260,6 +277,12 @@ public class CrossTenantUserController {
|
||||
|
||||
/** Map<String,Object> 응답용 가드 — null 이면 통과, 아니면 그대로 반환. */
|
||||
private ResponseEntity<ApiResponse<Map<String, Object>>> guard(HttpServletRequest request) {
|
||||
try {
|
||||
CrossTenantContext.requireManagementHost(request);
|
||||
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||
@@ -273,6 +296,12 @@ public class CrossTenantUserController {
|
||||
|
||||
/** Void 응답용 가드 (제네릭만 다름). */
|
||||
private ResponseEntity<ApiResponse<Void>> guardVoid(HttpServletRequest request) {
|
||||
try {
|
||||
CrossTenantContext.requireManagementHost(request);
|
||||
} catch (org.springframework.web.server.ResponseStatusException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error(e.getReason(), request.getRequestURI()));
|
||||
}
|
||||
if (!CrossTenantContext.isSuperAdmin(request)) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.error("super_admin_required", request.getRequestURI()));
|
||||
@@ -283,4 +312,12 @@ public class CrossTenantUserController {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** audit log details 기본 맵 생성 헬퍼. */
|
||||
private Map<String, Object> auditDetails(HttpServletRequest request, String targetUserId) {
|
||||
Map<String, Object> d = new HashMap<>();
|
||||
d.put("host", request.getHeader("Host"));
|
||||
if (targetUserId != null) d.put("target_user_id", targetUserId);
|
||||
return d;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,7 +203,110 @@ public class StartupSchemaMigrator {
|
||||
FOREIGN KEY (DEPT_CODE) REFERENCES DEPT_INFO(DEPT_CODE) ON DELETE CASCADE
|
||||
)
|
||||
""",
|
||||
"CREATE INDEX IF NOT EXISTS idx_dept_managers_role ON DEPT_MANAGERS (DEPT_CODE, ROLE, SORT_ORDER)"
|
||||
"CREATE INDEX IF NOT EXISTS idx_dept_managers_role ON DEPT_MANAGERS (DEPT_CODE, ROLE, SORT_ORDER)",
|
||||
|
||||
// V023 / RUN_089: MENU_INFO 에 IS_SOLUTION_ONLY 컬럼 추가.
|
||||
// 솔루션 관리 호스트(solution.invyone.com 등) 에서만 노출되는 메뉴 플래그.
|
||||
// 테넌트 사이트에선 mapper SQL 단계에서 제외. 메타 DB 는 Flyway V023 으로도 적용되지만
|
||||
// 프로비저닝된 테넌트 DB 는 부팅 때 동기화.
|
||||
"ALTER TABLE MENU_INFO ADD COLUMN IF NOT EXISTS IS_SOLUTION_ONLY BOOLEAN DEFAULT FALSE NOT NULL",
|
||||
|
||||
// V023 데이터 동기화: 솔루션 전용 메뉴 마킹.
|
||||
// 회사관리 / 회사 프로비저닝 / 감사로그는 관리 호스트에서만 노출돼야 함.
|
||||
// 이미 TRUE 인 행은 그대로 두기 위해 false 인 행만 갱신.
|
||||
"""
|
||||
UPDATE MENU_INFO
|
||||
SET IS_SOLUTION_ONLY = TRUE
|
||||
WHERE IS_SOLUTION_ONLY = FALSE
|
||||
AND MENU_URL IN (
|
||||
'/admin/sysMng/subdomainList',
|
||||
'/admin/userMng/companyList',
|
||||
'/admin/audit-log'
|
||||
)
|
||||
""",
|
||||
|
||||
// V024 / RUN_089: TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO rename.
|
||||
// 5/15 common-code 재설계(commit 2348800e) 가 mapper SQL 의 컬럼 참조명만
|
||||
// 바꾸고 DB rename 을 빠뜨려, 테이블타입관리 컬럼 조회 API 가 500 반환.
|
||||
// PostgreSQL 은 RENAME COLUMN 에 IF EXISTS 가 없어서 DO 블록으로 멱등 처리:
|
||||
// - CODE_CATEGORY 만 있는 기존 테넌트: rename 수행
|
||||
// - 이미 CODE_INFO 인 신규 테넌트: no-op
|
||||
// - 둘 다 있거나 둘 다 없는 비정상 상태: no-op (방어적)
|
||||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'table_type_columns'
|
||||
AND column_name = 'code_category'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'table_type_columns'
|
||||
AND column_name = 'code_info'
|
||||
) THEN
|
||||
ALTER TABLE TABLE_TYPE_COLUMNS
|
||||
RENAME COLUMN CODE_CATEGORY TO CODE_INFO;
|
||||
END IF;
|
||||
END $$
|
||||
""",
|
||||
|
||||
// V025 / RUN_090 (1) TABLE_TYPE_COLUMNS 중복 행 정리.
|
||||
// PK 가 id 단일 (varchar) 인데 (TABLE_NAME, COLUMN_NAME, COMPANY_CODE) 에는
|
||||
// UNIQUE 가 없어서 같은 키로 row 가 여러 개 INSERT 된 이력이 있음.
|
||||
// 메타 DB 실측: 35K rows 중 2 그룹 4 row 가 중복. 그 그룹들은 동일 데이터를
|
||||
// updated_date NULL 짜리 옛 row 와 2026-03-16 마지막 갱신 row 가 공존하는 형태.
|
||||
// 가장 최근 (updated_date DESC NULLS LAST, id::bigint DESC) 행만 남기고 제거.
|
||||
// 테넌트 DB 들은 실측상 중복 없음 → DELETE 0건. 멱등 (재실행해도 변화 없음).
|
||||
"""
|
||||
DELETE FROM TABLE_TYPE_COLUMNS
|
||||
WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY TABLE_NAME, COLUMN_NAME, COMPANY_CODE
|
||||
ORDER BY UPDATED_DATE DESC NULLS LAST,
|
||||
id::bigint DESC
|
||||
) AS rn
|
||||
FROM TABLE_TYPE_COLUMNS
|
||||
) r
|
||||
WHERE r.rn > 1
|
||||
)
|
||||
""",
|
||||
|
||||
// V025 / RUN_090 (2) ON CONFLICT 매칭용 UNIQUE INDEX 추가.
|
||||
// mapper 의 upsertColumnSettings / upsertNullable / upsertUnique /
|
||||
// upsertColumnInputType 모두 ON CONFLICT (TABLE_NAME, COLUMN_NAME, COMPANY_CODE)
|
||||
// 를 쓰는데 DB 엔 매칭 unique 제약이 없어서 모든 쓰기 API 가 500.
|
||||
// 인덱스 형태로 등록하면 ON CONFLICT 가 인식하고 ADD CONSTRAINT 식의
|
||||
// IF NOT EXISTS 누락 문제도 회피.
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS UX_TABLE_TYPE_COLUMNS_TCC ON TABLE_TYPE_COLUMNS (TABLE_NAME, COLUMN_NAME, COMPANY_CODE)",
|
||||
|
||||
// V026 / RUN_091: TABLE_TYPE_COLUMNS.INPUT_TYPE legacy → 표준 8종 정리.
|
||||
// 5/15 common-code 재설계가 화이트리스트를 8종으로 좁혔지만 운영 DB 의
|
||||
// 옛 값(category 886, select 149, textarea 102, checkbox 55, radio 12,
|
||||
// datetime 2, boolean 1) 을 정리하는 마이그레이션을 빠뜨림.
|
||||
// 매핑:
|
||||
// category / select / radio / checkbox / boolean → code (commonCode 통합 의도)
|
||||
// textarea → text (single/multi line 구분 손실 — UI 동작 가벼움)
|
||||
// datetime → date
|
||||
// 메타 DB 1,207 row 갱신. 테넌트 DB 들은 비어있어 영향 0.
|
||||
// WHERE 절로 멱등 (재실행 시 0 row).
|
||||
"""
|
||||
UPDATE TABLE_TYPE_COLUMNS
|
||||
SET INPUT_TYPE = CASE INPUT_TYPE
|
||||
WHEN 'category' THEN 'code'
|
||||
WHEN 'select' THEN 'code'
|
||||
WHEN 'radio' THEN 'code'
|
||||
WHEN 'checkbox' THEN 'code'
|
||||
WHEN 'boolean' THEN 'code'
|
||||
WHEN 'textarea' THEN 'text'
|
||||
WHEN 'datetime' THEN 'date'
|
||||
END,
|
||||
UPDATED_DATE = NOW()
|
||||
WHERE INPUT_TYPE IN ('category','select','radio','checkbox','boolean','textarea','datetime')
|
||||
"""
|
||||
);
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
|
||||
@@ -40,6 +40,12 @@ public class CompanyAuditLogService {
|
||||
public static final String ACTION_PW_RESET = "ADMIN_PASSWORD_RESET";
|
||||
public static final String ACTION_RECOPY = "TEMPLATES_RECOPY";
|
||||
|
||||
// cross-tenant write 감사 액션
|
||||
public static final String ACTION_CT_USER_CREATE = "CROSS_TENANT_USER_CREATE";
|
||||
public static final String ACTION_CT_USER_DELETE = "CROSS_TENANT_USER_DELETE";
|
||||
public static final String ACTION_CT_PW_RESET = "CROSS_TENANT_PASSWORD_RESET";
|
||||
public static final String ACTION_CT_ROLE_UPDATE = "CROSS_TENANT_ROLE_UPDATE";
|
||||
|
||||
private final SqlSession sqlSession;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
|
||||
@@ -100,13 +100,22 @@ public class DataCopier {
|
||||
try (Statement us = dst.createStatement()) {
|
||||
for (String[] r : rows) {
|
||||
String seq = r[0], tbl = r[1], col = r[2], coltype = r[3];
|
||||
if (!isIntegerLike(coltype)) {
|
||||
String sql;
|
||||
if (isIntegerLike(coltype)) {
|
||||
sql = String.format(
|
||||
"SELECT setval('%s', GREATEST(COALESCE((SELECT MAX(\"%s\") FROM \"%s\"), 0), 1))",
|
||||
seq.replace("'", "''"), col, tbl);
|
||||
} else if (isVarcharLike(coltype)) {
|
||||
// V001 마이그레이션으로 INT → VARCHAR 로 바뀐 PK 컬럼도 시퀀스가 연결되어 있고,
|
||||
// INSERT 시 DEFAULT nextval 이 호출되므로 max 재설정 필요. 비숫자 PK(UUID 등) 가
|
||||
// 섞여 있어도 정규식으로 거르고 숫자 PK 만 max 계산.
|
||||
sql = String.format(
|
||||
"SELECT setval('%s', GREATEST(COALESCE((SELECT MAX(\"%s\"::bigint) FROM \"%s\" WHERE \"%s\" ~ '^[0-9]+$'), 0), 1))",
|
||||
seq.replace("'", "''"), col, tbl, col);
|
||||
} else {
|
||||
skippedType++;
|
||||
continue;
|
||||
}
|
||||
String sql = String.format(
|
||||
"SELECT setval('%s', GREATEST(COALESCE((SELECT MAX(\"%s\") FROM \"%s\"), 0), 1))",
|
||||
seq.replace("'", "''"), col, tbl);
|
||||
try {
|
||||
us.execute(sql);
|
||||
updated++;
|
||||
@@ -117,14 +126,8 @@ public class DataCopier {
|
||||
}
|
||||
}
|
||||
}
|
||||
// invyone 은 대다수 PK 가 VARCHAR (문자열 PK). 시퀀스가 연결되어 있어도 실제 INSERT 때
|
||||
// nextval 을 사용하지 않으므로 setval 은 no-op. skipped_non_integer 값이 높아도 정상.
|
||||
if (updated == 0 && skippedErr == 0) {
|
||||
log.info("[Provisioning] RESET SEQUENCES: skipped all {} (string-PK schema, no-op)", rows.size());
|
||||
} else {
|
||||
log.info("[Provisioning] RESET SEQUENCES: updated={} skipped_non_integer={} skipped_error={} total={}",
|
||||
updated, skippedType, skippedErr, rows.size());
|
||||
}
|
||||
log.info("[Provisioning] RESET SEQUENCES: updated={} skipped_non_numeric={} skipped_error={} total={}",
|
||||
updated, skippedType, skippedErr, rows.size());
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -135,6 +138,12 @@ public class DataCopier {
|
||||
|| t.startsWith("int4") || t.startsWith("int8") || t.startsWith("int2");
|
||||
}
|
||||
|
||||
private static boolean isVarcharLike(String coltype) {
|
||||
if (coltype == null) return false;
|
||||
String t = coltype.toLowerCase();
|
||||
return t.startsWith("character varying") || t.startsWith("varchar") || t.startsWith("text");
|
||||
}
|
||||
|
||||
private List<String> listColumns(Connection conn, String table) throws SQLException {
|
||||
List<String> cols = new ArrayList<>();
|
||||
try (PreparedStatement ps = conn.prepareStatement(
|
||||
|
||||
@@ -5,12 +5,9 @@ import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.ibatis.session.SqlSession;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Arrays;
|
||||
@@ -40,13 +37,7 @@ public class ProvisioningController {
|
||||
private final ProvisioningRegistry registry;
|
||||
private final SqlSession sqlSession;
|
||||
private final CompanyStatsService statsService;
|
||||
|
||||
/**
|
||||
* 프로덕션 배포 시엔 반드시 true 로. 개발 중엔 JWT 없는 curl 테스트를 허용하기 위해 false 기본.
|
||||
* 환경변수: TENANT_PROVISIONING_REQUIRE_SUPER_ADMIN=true
|
||||
*/
|
||||
@Value("${tenant.provisioning.require-super-admin:false}")
|
||||
private boolean requireSuperAdmin;
|
||||
private final SuperAdminGuard guard;
|
||||
|
||||
@GetMapping("/table-groups")
|
||||
public ResponseEntity<List<Map<String, Object>>> tableGroups(HttpServletRequest request) {
|
||||
@@ -208,23 +199,11 @@ public class ProvisioningController {
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 권한 체크
|
||||
//
|
||||
// 현재 `/api/**` 가 permitAll 이라 Controller 레벨에서 수동 검증.
|
||||
// JWT 가 있으면 JwtAuthenticationFilter 가 request.getAttribute("user_type") 세팅.
|
||||
// 개발 모드(requireSuperAdmin=false): JWT 없이도 통과 (curl 테스트용). 단 다른 role 은 차단.
|
||||
// 프로덕션 모드(requireSuperAdmin=true): SUPER_ADMIN 아니면 모두 403.
|
||||
// 권한 체크 — SuperAdminGuard 로 위임 (호스트 격리 + role 검증).
|
||||
// CompanyMgmtController 와 동일한 가드를 공유.
|
||||
// ------------------------------------------------------------------
|
||||
private void enforceSuperAdmin(HttpServletRequest request) {
|
||||
String userType = (String) request.getAttribute("user_type");
|
||||
if ("SUPER_ADMIN".equals(userType)) return;
|
||||
|
||||
if (!requireSuperAdmin && userType == null) {
|
||||
log.warn("[Provisioning] anonymous access allowed in dev mode (set " +
|
||||
"tenant.provisioning.require-super-admin=true in production)");
|
||||
return;
|
||||
}
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "SUPER_ADMIN only");
|
||||
guard.enforce(request);
|
||||
}
|
||||
|
||||
// --- Validation helpers ---
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.erp.provisioning;
|
||||
|
||||
import com.erp.tenant.ReservedSubdomains;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -7,9 +8,14 @@ import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* `/api/admin/provisioning/*` 계열 엔드포인트 공통 권한 가드.
|
||||
*
|
||||
* - 호스트 격리: 테넌트 서브도메인(qnc.invyone.com 등)에서 호출하면 무조건 403.
|
||||
* 프로비저닝 plane 은 solution/admin/localhost/베이스 도메인 같은 "관리 호스트" 에서만 동작.
|
||||
* (한 번 SUPER_ADMIN 토큰이 새도 임의의 테넌트 사이트에서는 회사를 만들 수 없게 막음)
|
||||
* - 프로덕션 (tenant.provisioning.require-super-admin=true): SUPER_ADMIN 만 통과
|
||||
* - 개발 (기본값 false): JWT 없어도 통과 (curl 테스트). 다른 role 은 여전히 차단
|
||||
*
|
||||
@@ -19,10 +25,22 @@ import org.springframework.web.server.ResponseStatusException;
|
||||
@Slf4j
|
||||
public class SuperAdminGuard {
|
||||
|
||||
private static final Pattern IPV4 = Pattern.compile("^\\d{1,3}(\\.\\d{1,3}){3}$");
|
||||
|
||||
@Value("${tenant.provisioning.require-super-admin:false}")
|
||||
private boolean requireSuperAdmin;
|
||||
|
||||
public void enforce(HttpServletRequest request) {
|
||||
// 1) 호스트 격리 — role 보다 먼저 체크. 어떤 role 도 테넌트 사이트에서는 통과 못 함.
|
||||
String host = request.getHeader("Host");
|
||||
if (isTenantHost(host)) {
|
||||
log.warn("[ProvisioningGuard] blocked tenant-host call: host={} path={} userType={}",
|
||||
host, request.getRequestURI(), request.getAttribute("user_type"));
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
||||
"Provisioning is only available on the management site");
|
||||
}
|
||||
|
||||
// 2) role 체크
|
||||
String userType = (String) request.getAttribute("user_type");
|
||||
if ("SUPER_ADMIN".equals(userType)) return;
|
||||
if (!requireSuperAdmin && userType == null) {
|
||||
@@ -37,4 +55,40 @@ public class SuperAdminGuard {
|
||||
String userId = (String) request.getAttribute("user_id");
|
||||
return userId == null ? "dev-anonymous" : userId;
|
||||
}
|
||||
|
||||
/** 감사 로그에 기록할 호스트 (Host 헤더 그대로, 포트 포함). null safe. */
|
||||
public String requestHost(HttpServletRequest request) {
|
||||
String host = request.getHeader("Host");
|
||||
return host == null ? "" : host;
|
||||
}
|
||||
|
||||
/**
|
||||
* "테넌트 사이트에서 온 요청인지" 판단. SubdomainResolverFilter.extractSubdomain 와 같은 규칙.
|
||||
* - localhost / IP / 베이스 도메인 → false (관리 호스트)
|
||||
* - solution.invyone.com 등 예약어 prefix → false (관리 호스트)
|
||||
* - qnc.invyone.com / test02.invyone.com 같은 실제 테넌트 → true
|
||||
*/
|
||||
public static boolean isTenantHost(String host) {
|
||||
if (host == null || host.isBlank()) return false;
|
||||
|
||||
int colon = host.indexOf(':');
|
||||
if (colon != -1) host = host.substring(0, colon);
|
||||
host = host.toLowerCase();
|
||||
|
||||
if ("localhost".equals(host)) return false;
|
||||
if (IPV4.matcher(host).matches()) return false;
|
||||
|
||||
String[] parts = host.split("\\.");
|
||||
if (parts.length == 2) {
|
||||
// {sub}.localhost (dev)
|
||||
if (!"localhost".equals(parts[1])) return false;
|
||||
String first = parts[0];
|
||||
if (first.isEmpty()) return false;
|
||||
return !ReservedSubdomains.VALUES.contains(first);
|
||||
}
|
||||
if (parts.length < 3) return false;
|
||||
|
||||
String first = parts[0];
|
||||
return !ReservedSubdomains.VALUES.contains(first);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,13 @@ public class SubstituteContextFilter extends OncePerRequestFilter {
|
||||
return;
|
||||
}
|
||||
|
||||
// 대무자 컨텍스트가 의미 없는 경로 skip — 초기 페이지 로드 latency 의 큰 부분.
|
||||
// ApprovalController 만 effective_user_ids 를 참조하므로 결재 외 경로는 DB 조회 불필요.
|
||||
if (isSkippablePath(path)) {
|
||||
chain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
String userId = (String) request.getAttribute("user_id");
|
||||
String companyCode = (String) request.getAttribute("company_code");
|
||||
|
||||
@@ -85,4 +92,11 @@ public class SubstituteContextFilter extends OncePerRequestFilter {
|
||||
|
||||
chain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private static boolean isSkippablePath(String path) {
|
||||
return path.startsWith("/api/auth/")
|
||||
|| path.equals("/api/admin/menus")
|
||||
|| path.equals("/api/admin/user-menus")
|
||||
|| path.equals("/api/admin/user-locale");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,6 +296,11 @@ public class BatchManagementService extends BaseService {
|
||||
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) {
|
||||
return sqlSession.selectList(NS + "getBatchManagementRecentLogList", params);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,13 @@ public class CommonCodeService extends BaseService {
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> insertCodeInfo(Map<String, Object> body, String companyCode, String userId) {
|
||||
Object rawCodeInfo = body.get("code_info");
|
||||
String codeInfo = rawCodeInfo == null ? null : rawCodeInfo.toString().trim();
|
||||
if (codeInfo != null && !codeInfo.isEmpty()
|
||||
&& getCodeInfoInfo(codeInfo, companyCode) != null) {
|
||||
throw new IllegalArgumentException("이미 존재하는 그룹 코드입니다: " + codeInfo);
|
||||
}
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("code_info", body.get("code_info"));
|
||||
params.put("code_name", body.get("code_name"));
|
||||
|
||||
@@ -226,6 +226,79 @@ public class DdlService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// DROP COLUMN (DBeaver 방식: FK 등 위반은 Postgres 가 던지는 에러를 그대로 노출)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public Map<String, Object> dropColumn(String tableName, String columnName,
|
||||
String companyCode, String userId) {
|
||||
// 1. 시스템 테이블 보호
|
||||
if (SYSTEM_TABLES.contains(tableName.toLowerCase())) {
|
||||
String errorMsg = "'" + tableName + "'은 시스템 테이블이므로 컬럼을 삭제할 수 없습니다.";
|
||||
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName,
|
||||
"SYSTEM_TABLE_PROTECTED", false, errorMsg);
|
||||
return Map.of("success", false, "message", errorMsg, "error_code", "SYSTEM_TABLE_PROTECTED");
|
||||
}
|
||||
|
||||
// 2. 예약 컬럼 보호 (id / created_date / updated_date / company_code / writer)
|
||||
if (RESERVED_COLUMNS.contains(columnName.toLowerCase()) || "writer".equalsIgnoreCase(columnName)) {
|
||||
String errorMsg = "'" + columnName + "'은 시스템 예약 컬럼이므로 삭제할 수 없습니다.";
|
||||
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName,
|
||||
"RESERVED_COLUMN_PROTECTED", false, errorMsg);
|
||||
return Map.of("success", false, "message", errorMsg, "error_code", "RESERVED_COLUMN_PROTECTED");
|
||||
}
|
||||
|
||||
// 3. 테이블/컬럼 존재 여부
|
||||
if (!tableExists(tableName)) {
|
||||
String errorMsg = "테이블 '" + tableName + "'이 존재하지 않습니다.";
|
||||
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName, "TABLE_NOT_FOUND", false, errorMsg);
|
||||
return Map.of("success", false, "message", errorMsg, "error_code", "TABLE_NOT_FOUND");
|
||||
}
|
||||
if (!columnExists(tableName, columnName)) {
|
||||
String errorMsg = "컬럼 '" + columnName + "'이 존재하지 않습니다.";
|
||||
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName, "COLUMN_NOT_FOUND", false, errorMsg);
|
||||
return Map.of("success", false, "message", errorMsg, "error_code", "COLUMN_NOT_FOUND");
|
||||
}
|
||||
|
||||
// 4. DDL 실행 — CASCADE 안 붙임 → FK 참조 있으면 Postgres 가 거부 (DBeaver 와 동일)
|
||||
String ddlQuery = "ALTER TABLE \"" + sanitize(tableName) + "\" DROP COLUMN \"" + sanitize(columnName) + "\"";
|
||||
|
||||
try {
|
||||
transactionTemplate.execute(status -> {
|
||||
jdbcTemplate.execute(ddlQuery);
|
||||
// 컬럼 메타 청소
|
||||
jdbcTemplate.update(
|
||||
"DELETE FROM table_type_columns WHERE table_name = ? AND column_name = ?",
|
||||
tableName, columnName);
|
||||
jdbcTemplate.update(
|
||||
"DELETE FROM column_labels WHERE table_name = ? AND column_name = ?",
|
||||
tableName, columnName);
|
||||
return null;
|
||||
});
|
||||
|
||||
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName, ddlQuery, true, null);
|
||||
log.info("컬럼 삭제 성공: {}.{}, 사용자: {}", tableName, columnName, userId);
|
||||
|
||||
return Map.of(
|
||||
"success", true,
|
||||
"message", "컬럼 '" + columnName + "'이 성공적으로 삭제되었습니다.",
|
||||
"table_name", tableName,
|
||||
"column_name", columnName,
|
||||
"executed_query", ddlQuery
|
||||
);
|
||||
} catch (Exception e) {
|
||||
String rawMsg = e.getMessage() != null ? e.getMessage() : "";
|
||||
String guidance = rawMsg.toLowerCase().contains("depend") || rawMsg.toLowerCase().contains("foreign key")
|
||||
? " (다른 테이블에서 외래키로 참조 중인 컬럼은 삭제할 수 없습니다)"
|
||||
: "";
|
||||
String errorMsg = "컬럼 삭제 실패: " + rawMsg + guidance;
|
||||
logDdlOperation(userId, companyCode, "DROP_COLUMN", tableName,
|
||||
"FAILED: " + rawMsg, false, errorMsg);
|
||||
log.error("컬럼 삭제 실패: {}.{}, 사용자: {}, 오류: {}", tableName, columnName, userId, rawMsg, e);
|
||||
return Map.of("success", false, "message", errorMsg, "error_code", "EXECUTION_FAILED");
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// VALIDATE
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
@@ -365,6 +366,330 @@ public class DepartmentService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// 일괄등록 / 일괄업데이트 (Bulk)
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
private static final int BULK_MAX_ROWS = 1000;
|
||||
|
||||
/**
|
||||
* 일괄등록 — preview (read-only validation). DB 쓰기 없음.
|
||||
* batch 내 dept_name 중복 + DB active 중복 + parent/날짜/매니저 검증.
|
||||
* 각 row 에 row_index / result(ok|error) / error_detail 채워서 반환.
|
||||
*/
|
||||
public List<Map<String, Object>> bulkPreviewCreate(String companyCode, List<Map<String, Object>> rows) {
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
if (rows == null || rows.isEmpty()) return results;
|
||||
if (rows.size() > BULK_MAX_ROWS) {
|
||||
throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 처리 가능합니다.");
|
||||
}
|
||||
Set<String> existingNames = collectActiveDeptNames(companyCode);
|
||||
Set<String> batchNames = new HashSet<>();
|
||||
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
Map<String, Object> input = rows.get(i);
|
||||
Map<String, Object> out = new HashMap<>(input);
|
||||
out.put("row_index", i);
|
||||
String error = validateBulkCreateRow(input, companyCode, existingNames, batchNames);
|
||||
if (error == null) {
|
||||
out.put("result", "ok");
|
||||
out.put("error_detail", null);
|
||||
String dn = trimString(input.get("dept_name"));
|
||||
if (dn != null) batchNames.add(dn.toLowerCase());
|
||||
} else {
|
||||
out.put("result", "error");
|
||||
out.put("error_detail", error);
|
||||
}
|
||||
results.add(out);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄업데이트 — preview (read-only). mode = department | manager.
|
||||
*/
|
||||
public List<Map<String, Object>> bulkPreviewUpdate(String companyCode, String mode, List<Map<String, Object>> rows) {
|
||||
List<Map<String, Object>> results = new ArrayList<>();
|
||||
if (rows == null || rows.isEmpty()) return results;
|
||||
if (rows.size() > BULK_MAX_ROWS) {
|
||||
throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 처리 가능합니다.");
|
||||
}
|
||||
if (!"department".equals(mode) && !"manager".equals(mode)) {
|
||||
throw new IllegalArgumentException("mode 는 department | manager 여야 합니다.");
|
||||
}
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
Map<String, Object> input = rows.get(i);
|
||||
Map<String, Object> out = new HashMap<>(input);
|
||||
out.put("row_index", i);
|
||||
String error = validateBulkUpdateRow(input, companyCode, mode);
|
||||
if (error == null) {
|
||||
out.put("result", "ok");
|
||||
out.put("error_detail", null);
|
||||
} else {
|
||||
out.put("result", "error");
|
||||
out.put("error_detail", error);
|
||||
}
|
||||
results.add(out);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄등록 — 실제 저장 (@Transactional, all-or-nothing).
|
||||
* 각 row 를 createDepartment 로 위임 — 검증 + manager sync 까지 동일 흐름.
|
||||
* 중간 실패 시 IllegalArgumentException 으로 행번호+사유 합쳐서 던짐 → 전체 롤백.
|
||||
*/
|
||||
@Transactional
|
||||
public int bulkSaveCreate(String companyCode, List<Map<String, Object>> rows) {
|
||||
if (rows == null || rows.isEmpty()) return 0;
|
||||
if (rows.size() > BULK_MAX_ROWS) {
|
||||
throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 등록 가능합니다.");
|
||||
}
|
||||
int inserted = 0;
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
Map<String, Object> row = rows.get(i);
|
||||
String label = trimString(row.get("dept_name"));
|
||||
try {
|
||||
createDepartment(companyCode, row);
|
||||
inserted++;
|
||||
} catch (DuplicateDeptNameException | IllegalArgumentException | IllegalStateException e) {
|
||||
throw new IllegalArgumentException("행 " + (i + 1) + " (" + (label != null ? label : "?") + "): " + e.getMessage());
|
||||
}
|
||||
}
|
||||
log.info("부서 일괄등록 성공: company={}, inserted={}", companyCode, inserted);
|
||||
return inserted;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄업데이트 — 실제 적용 (@Transactional). mode = department | manager.
|
||||
* department: 부서 정보 부분 업데이트 (row 의 null/미지정 필드는 기존값 보존).
|
||||
* manager: row 에 명시된 매니저 role 만 sync (delete-all + insert-all).
|
||||
*/
|
||||
@Transactional
|
||||
public int bulkUpdate(String companyCode, String mode, List<Map<String, Object>> rows) {
|
||||
if (rows == null || rows.isEmpty()) return 0;
|
||||
if (rows.size() > BULK_MAX_ROWS) {
|
||||
throw new IllegalArgumentException("한 번에 최대 " + BULK_MAX_ROWS + "건까지 수정 가능합니다.");
|
||||
}
|
||||
if (!"department".equals(mode) && !"manager".equals(mode)) {
|
||||
throw new IllegalArgumentException("mode 는 department | manager 여야 합니다.");
|
||||
}
|
||||
int updated = 0;
|
||||
for (int i = 0; i < rows.size(); i++) {
|
||||
Map<String, Object> row = rows.get(i);
|
||||
String deptCode = trimString(row.get("dept_code"));
|
||||
if (deptCode == null) {
|
||||
throw new IllegalArgumentException("행 " + (i + 1) + ": 부서코드(dept_code) 필수.");
|
||||
}
|
||||
Map<String, Object> existing = getDepartment(deptCode);
|
||||
if (existing == null) {
|
||||
throw new IllegalArgumentException("행 " + (i + 1) + ": 부서를 찾을 수 없습니다: " + deptCode);
|
||||
}
|
||||
String deptCompanyCode = existing.get("company_code") != null
|
||||
? existing.get("company_code").toString() : null;
|
||||
if (!companyCode.equals(deptCompanyCode) && !"*".equals(deptCompanyCode)) {
|
||||
throw new IllegalArgumentException("행 " + (i + 1) + ": 다른 회사의 부서입니다: " + deptCode);
|
||||
}
|
||||
try {
|
||||
if ("department".equals(mode)) {
|
||||
Map<String, Object> merged = buildMergedDeptBody(existing, row);
|
||||
Map<String, Object> result = updateDepartment(deptCode, merged);
|
||||
if (result == null) {
|
||||
throw new IllegalStateException("수정 실패: " + deptCode);
|
||||
}
|
||||
} else {
|
||||
// manager mode — row 에 명시된 role 만 sync
|
||||
if (row.containsKey("approval_managers")) {
|
||||
syncManagers(deptCode, companyCode, row, "approval");
|
||||
}
|
||||
if (row.containsKey("dept_managers")) {
|
||||
syncManagers(deptCode, companyCode, row, "dept");
|
||||
}
|
||||
if (row.containsKey("org_leaders")) {
|
||||
syncManagers(deptCode, companyCode, row, "org_leader");
|
||||
}
|
||||
}
|
||||
updated++;
|
||||
} catch (DuplicateDeptNameException | IllegalArgumentException | IllegalStateException e) {
|
||||
throw new IllegalArgumentException("행 " + (i + 1) + " (" + deptCode + "): " + e.getMessage());
|
||||
}
|
||||
}
|
||||
log.info("부서 일괄수정 성공: company={}, mode={}, updated={}", companyCode, mode, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** company 의 active 부서명 lowercase set — 일괄등록 중복검증용 */
|
||||
private Set<String> collectActiveDeptNames(String companyCode) {
|
||||
Set<String> names = new HashSet<>();
|
||||
for (Map<String, Object> d : getDepartments(companyCode, false, null)) {
|
||||
Object name = d.get("dept_name");
|
||||
if (name != null) names.add(name.toString().trim().toLowerCase());
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄등록 row 검증. null = ok. 에러 메시지 반환 시 해당 row 는 error.
|
||||
*/
|
||||
private String validateBulkCreateRow(Map<String, Object> row, String companyCode,
|
||||
Set<String> existingNames, Set<String> batchNames) {
|
||||
String deptName = trimString(row.get("dept_name"));
|
||||
if (deptName == null || deptName.isEmpty()) return "부서명은 필수입니다.";
|
||||
String lower = deptName.toLowerCase();
|
||||
if (batchNames.contains(lower)) return "동일 일괄 내 부서명 중복: " + deptName;
|
||||
if (existingNames.contains(lower)) return "이미 존재하는 부서명: " + deptName;
|
||||
|
||||
String dt = trimString(row.get("dept_type"));
|
||||
if (dt != null && !"dept".equals(dt) && !"team".equals(dt) && !"temp".equals(dt)) {
|
||||
return "부서유형은 dept|team|temp 중 하나: " + dt;
|
||||
}
|
||||
String parent = trimString(row.get("parent_dept_code"));
|
||||
String parentErr = validateParentForBulk(parent, companyCode);
|
||||
if (parentErr != null) return parentErr;
|
||||
|
||||
String dateErr = validateDateRange(row);
|
||||
if (dateErr != null) return dateErr;
|
||||
|
||||
String mgrErr = validateManagerIds(row, companyCode);
|
||||
if (mgrErr != null) return mgrErr;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄업데이트 row 검증. dept_code 필수 + 회사 격리 + (department mode 한정) 부서명/유형/날짜/부모 검증.
|
||||
*/
|
||||
private String validateBulkUpdateRow(Map<String, Object> row, String companyCode, String mode) {
|
||||
String deptCode = trimString(row.get("dept_code"));
|
||||
if (deptCode == null) return "부서코드(dept_code) 필수.";
|
||||
Map<String, Object> existing = getDepartment(deptCode);
|
||||
if (existing == null) return "부서를 찾을 수 없습니다: " + deptCode;
|
||||
String deptCompanyCode = existing.get("company_code") != null
|
||||
? existing.get("company_code").toString() : null;
|
||||
if (!companyCode.equals(deptCompanyCode) && !"*".equals(deptCompanyCode)) {
|
||||
return "다른 회사의 부서: " + deptCode;
|
||||
}
|
||||
if ("department".equals(mode)) {
|
||||
String newName = trimString(row.get("dept_name"));
|
||||
if (newName != null && !newName.isEmpty()) {
|
||||
String existingName = existing.get("dept_name") != null
|
||||
? existing.get("dept_name").toString().trim() : "";
|
||||
if (!newName.equalsIgnoreCase(existingName)) {
|
||||
Map<String, Object> dupParams = new HashMap<>();
|
||||
dupParams.put("company_code", companyCode);
|
||||
dupParams.put("dept_name", newName);
|
||||
Map<String, Object> dup = sqlSession.selectOne("department.selectDuplicateDeptName", dupParams);
|
||||
if (dup != null && !deptCode.equals(dup.get("dept_code"))) {
|
||||
return "이미 존재하는 부서명: " + newName;
|
||||
}
|
||||
}
|
||||
}
|
||||
String dt = trimString(row.get("dept_type"));
|
||||
if (dt != null && !"dept".equals(dt) && !"team".equals(dt) && !"temp".equals(dt)) {
|
||||
return "부서유형은 dept|team|temp 중 하나: " + dt;
|
||||
}
|
||||
String dateErr = validateDateRange(row);
|
||||
if (dateErr != null) return dateErr;
|
||||
String parent = trimString(row.get("parent_dept_code"));
|
||||
String parentErr = validateParentForBulk(parent, companyCode);
|
||||
if (parentErr != null) return parentErr;
|
||||
}
|
||||
return validateManagerIds(row, companyCode);
|
||||
}
|
||||
|
||||
private String validateParentForBulk(String parent, String companyCode) {
|
||||
if (parent == null) return null;
|
||||
Map<String, Object> p = sqlSession.selectOne(
|
||||
"department.selectDepartmentByCodeIncludingDeleted", Map.of("dept_code", parent));
|
||||
if (p == null) return "상위 부서를 찾을 수 없습니다: " + parent;
|
||||
if (p.get("deleted_at") != null) return "삭제된 부서를 상위로 지정할 수 없음: " + parent;
|
||||
Object pc = p.get("company_code");
|
||||
if (pc == null || (!companyCode.equals(pc.toString()) && !"*".equals(pc.toString()))) {
|
||||
return "다른 회사의 부서를 상위로 지정 불가: " + parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String validateDateRange(Map<String, Object> row) {
|
||||
String sd = trimString(row.get("start_date"));
|
||||
String ed = trimString(row.get("end_date"));
|
||||
if (sd != null && !sd.matches("\\d{4}-\\d{2}-\\d{2}")) return "시작일 형식 오류 (YYYY-MM-DD): " + sd;
|
||||
if (ed != null && !ed.matches("\\d{4}-\\d{2}-\\d{2}")) return "종료일 형식 오류 (YYYY-MM-DD): " + ed;
|
||||
if (sd != null && ed != null && sd.compareTo(ed) > 0) return "시작일이 종료일보다 늦을 수 없음.";
|
||||
return null;
|
||||
}
|
||||
|
||||
private String validateManagerIds(Map<String, Object> row, String companyCode) {
|
||||
for (String key : new String[]{"approval_managers", "dept_managers", "org_leaders"}) {
|
||||
Object raw = row.get(key);
|
||||
if (raw instanceof List<?> list && list.size() > 10) {
|
||||
return key + " 는 최대 10명까지 등록 가능합니다.";
|
||||
}
|
||||
}
|
||||
List<String> ids = collectManagerUserIds(row);
|
||||
if (ids.isEmpty()) return null;
|
||||
Map<String, Object> vParams = new HashMap<>();
|
||||
vParams.put("user_ids", ids);
|
||||
vParams.put("company_code", companyCode);
|
||||
List<String> valid = sqlSession.selectList("department.selectValidUserIds", vParams);
|
||||
if (valid == null || valid.size() != ids.size()) {
|
||||
Set<String> invalid = new HashSet<>(ids);
|
||||
if (valid != null) invalid.removeAll(valid);
|
||||
return "유효하지 않은 사용자 ID: " + invalid;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<String> collectManagerUserIds(Map<String, Object> row) {
|
||||
List<String> ids = new ArrayList<>();
|
||||
for (String key : new String[]{"approval_managers", "dept_managers", "org_leaders"}) {
|
||||
Object raw = row.get(key);
|
||||
if (raw instanceof List<?> list) {
|
||||
for (Object item : list) {
|
||||
String uid = null;
|
||||
if (item instanceof Map<?, ?> m) {
|
||||
Object v = m.get("user_id");
|
||||
if (v != null) uid = v.toString().trim();
|
||||
} else if (item != null) {
|
||||
uid = item.toString().trim();
|
||||
}
|
||||
if (uid != null && !uid.isEmpty() && !ids.contains(uid)) ids.add(uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* 일괄업데이트 department mode — 기존값 + row override 머지.
|
||||
* row 값이 null/미지정이면 기존값 보존 (PATCH semantic).
|
||||
* 매니저 매핑 키는 항상 제거 (department mode 에서는 안 다룸).
|
||||
*/
|
||||
private Map<String, Object> buildMergedDeptBody(Map<String, Object> existing, Map<String, Object> row) {
|
||||
Map<String, Object> merged = new HashMap<>();
|
||||
String[] textKeys = {
|
||||
"dept_name", "parent_dept_code", "short_name", "dept_type", "org_system",
|
||||
"approval_manager", "dept_manager", "zipcode", "address1", "address2",
|
||||
"sort_order", "status", "location"
|
||||
};
|
||||
for (String k : textKeys) merged.put(k, existing.get(k));
|
||||
merged.put("start_date", stringifyDate(existing.get("start_date")));
|
||||
merged.put("end_date", stringifyDate(existing.get("end_date")));
|
||||
for (Map.Entry<String, Object> e : row.entrySet()) {
|
||||
String k = e.getKey();
|
||||
if ("dept_code".equals(k)) continue;
|
||||
if (e.getValue() == null) continue;
|
||||
if ("approval_managers".equals(k) || "dept_managers".equals(k) || "org_leaders".equals(k)) continue;
|
||||
merged.put(k, e.getValue());
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
private String stringifyDate(Object date) {
|
||||
if (date == null) return null;
|
||||
String s = date.toString();
|
||||
return s.length() >= 10 ? s.substring(0, 10) : null;
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────
|
||||
// 부서원 관리
|
||||
// ──────────────────────────────────────────────────
|
||||
|
||||
@@ -297,29 +297,61 @@ public class ExternalDbConnectionService extends BaseService {
|
||||
private Map<String, Object> executeConnectionTest(
|
||||
String dbType, Map<String, Object> conn, String password) {
|
||||
|
||||
String type = dbType == null ? "" : dbType.toLowerCase();
|
||||
String host = str(conn, "host");
|
||||
int port = toInt(conn, "port", 5432);
|
||||
int port = toInt(conn, "port", defaultPort(type));
|
||||
String database = str(conn, "database_name");
|
||||
String username = str(conn, "username");
|
||||
String sslEnabled = str(conn, "ssl_enabled");
|
||||
int connTimeout = toInt(conn, "connection_timeout", 30);
|
||||
boolean ssl = "Y".equalsIgnoreCase(sslEnabled);
|
||||
|
||||
if (!"postgresql".equalsIgnoreCase(dbType)) {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("success", false);
|
||||
result.put("message", "이 버전에서는 PostgreSQL 연결만 테스트가 지원됩니다.");
|
||||
return result;
|
||||
}
|
||||
|
||||
String url = String.format("jdbc:postgresql://%s:%d/%s", host, port, database);
|
||||
String url;
|
||||
Properties props = new Properties();
|
||||
props.setProperty("user", username);
|
||||
props.setProperty("password", password);
|
||||
props.setProperty("connect_timeout", String.valueOf(connTimeout));
|
||||
props.setProperty("socket_timeout", "30");
|
||||
if ("Y".equalsIgnoreCase(sslEnabled)) {
|
||||
props.setProperty("ssl", "true");
|
||||
props.setProperty("sslmode", "require");
|
||||
if (username != null) props.setProperty("user", username);
|
||||
if (password != null) props.setProperty("password", password);
|
||||
|
||||
switch (type) {
|
||||
case "postgresql" -> {
|
||||
url = String.format("jdbc:postgresql://%s:%d/%s", host, port, database);
|
||||
props.setProperty("connect_timeout", String.valueOf(connTimeout));
|
||||
props.setProperty("socket_timeout", "30");
|
||||
if (ssl) {
|
||||
props.setProperty("ssl", "true");
|
||||
props.setProperty("sslmode", "require");
|
||||
}
|
||||
}
|
||||
case "mysql" -> {
|
||||
url = String.format("jdbc:mysql://%s:%d/%s", host, port, database);
|
||||
props.setProperty("connectTimeout", String.valueOf(connTimeout * 1000));
|
||||
props.setProperty("socketTimeout", "30000");
|
||||
props.setProperty("useSSL", String.valueOf(ssl));
|
||||
props.setProperty("allowPublicKeyRetrieval", "true");
|
||||
}
|
||||
case "mariadb" -> {
|
||||
url = String.format("jdbc:mariadb://%s:%d/%s", host, port, database);
|
||||
props.setProperty("connectTimeout", String.valueOf(connTimeout * 1000));
|
||||
props.setProperty("socketTimeout", "30000");
|
||||
if (ssl) props.setProperty("useSsl", "true");
|
||||
}
|
||||
case "mssql", "sqlserver" -> {
|
||||
StringBuilder sb = new StringBuilder()
|
||||
.append("jdbc:sqlserver://").append(host).append(':').append(port)
|
||||
.append(";databaseName=").append(database)
|
||||
.append(";loginTimeout=").append(connTimeout)
|
||||
.append(";encrypt=").append(ssl ? "true;trustServerCertificate=true" : "false");
|
||||
url = sb.toString();
|
||||
}
|
||||
case "sqlite" -> {
|
||||
// SQLite: host/port 무의미. database_name 을 파일 경로로 사용 (비면 in-memory)
|
||||
url = "jdbc:sqlite:" + (database != null && !database.isBlank() ? database : ":memory:");
|
||||
}
|
||||
default -> {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("success", false);
|
||||
result.put("message", "지원하지 않는 DB 타입입니다: " + dbType);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
try (Connection c = DriverManager.getConnection(url, props);
|
||||
@@ -328,7 +360,11 @@ public class ExternalDbConnectionService extends BaseService {
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("success", true);
|
||||
result.put("message", "연결 성공");
|
||||
result.put("details", Map.of("host", host, "database", database, "port", port));
|
||||
Map<String, Object> details = new LinkedHashMap<>();
|
||||
details.put("host", host == null ? "" : host);
|
||||
details.put("database", database == null ? "" : database);
|
||||
details.put("port", port);
|
||||
result.put("details", details);
|
||||
return result;
|
||||
} catch (SQLException e) {
|
||||
log.warn("DB 연결 테스트 실패 ({}): {}", url, e.getMessage());
|
||||
@@ -342,6 +378,16 @@ public class ExternalDbConnectionService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
private int defaultPort(String dbType) {
|
||||
if (dbType == null) return 5432;
|
||||
return switch (dbType.toLowerCase()) {
|
||||
case "mysql", "mariadb" -> 3306;
|
||||
case "mssql", "sqlserver" -> 1433;
|
||||
case "sqlite" -> 0;
|
||||
default -> 5432;
|
||||
};
|
||||
}
|
||||
|
||||
// ── SQL 쿼리 실행 (SELECT only) ────────────────────────────────────────────
|
||||
|
||||
public Map<String, Object> executeQuery(long id, String sql) {
|
||||
|
||||
@@ -16,6 +16,18 @@ public class ScreenGroupService extends BaseService {
|
||||
|
||||
private static final String NS = "screenGroup.";
|
||||
|
||||
/**
|
||||
* canonical table / legacy table-list / hidden v2-table-list 위젯 카운트 합산.
|
||||
* screen type inference 시 셋 모두 grid 화면으로 인식해야 한다 (frontend
|
||||
* isTableLikeComponentType 와 동일 정책 — 2026-05-19 canonical cleanup follow-up).
|
||||
*/
|
||||
private static int countTableLikeWidgets(Map<String, Integer> widgetCounts) {
|
||||
if (widgetCounts == null) return 0;
|
||||
return widgetCounts.getOrDefault("table", 0)
|
||||
+ widgetCounts.getOrDefault("table-list", 0)
|
||||
+ widgetCounts.getOrDefault("v2-table-list", 0);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
// Screen Groups
|
||||
// ══════════════════════════════════════════════════════════════
|
||||
@@ -356,8 +368,10 @@ public class ScreenGroupService extends BaseService {
|
||||
}
|
||||
|
||||
// 화면 타입 추론
|
||||
// table-like (canonical 'table' / legacy 'table-list' / hidden 'v2-table-list')
|
||||
// 어느 것이든 있으면 grid 로 본다.
|
||||
String screenType = "form";
|
||||
if (widgetCounts.getOrDefault("table", 0) > 0) {
|
||||
if (countTableLikeWidgets(widgetCounts) > 0) {
|
||||
screenType = "grid";
|
||||
} else if (widgetCounts.getOrDefault("custom", 0) > 2) {
|
||||
screenType = "dashboard";
|
||||
@@ -433,11 +447,11 @@ public class ScreenGroupService extends BaseService {
|
||||
if (bottomEdge > toInt(summary.get("canvas_height"))) summary.put("canvas_height", bottomEdge);
|
||||
}
|
||||
|
||||
// 화면 타입 추론
|
||||
// 화면 타입 추론 — canonical / legacy / hidden v2 모두 grid 로 인식
|
||||
summaryMap.values().forEach(summary -> {
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Integer> wc = (Map<String, Integer>) summary.get("widget_counts");
|
||||
if (wc.getOrDefault("table-list", 0) > 0) {
|
||||
if (countTableLikeWidgets(wc) > 0) {
|
||||
summary.put("screen_type", "grid");
|
||||
} else if (wc.getOrDefault("table-search-widget", 0) > 1) {
|
||||
summary.put("screen_type", "dashboard");
|
||||
|
||||
@@ -176,6 +176,21 @@ public class TableManagementService extends BaseService {
|
||||
params.put("display_column", "entity".equals(inputType) ? settings.get("display_column") : null);
|
||||
params.put("display_order", settings.getOrDefault("display_order", 0));
|
||||
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("category_ref", "category".equals(inputType) ? settings.get("category_ref") : null);
|
||||
sqlSession.update(NS + "upsertColumnSettings", params);
|
||||
@@ -200,19 +215,21 @@ public class TableManagementService extends BaseService {
|
||||
|
||||
@Transactional
|
||||
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);
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("table_name", tableName);
|
||||
params.put("column_name", columnName);
|
||||
params.put("input_type", finalType);
|
||||
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_code", false);
|
||||
params.put("clear_category", false);
|
||||
sqlSession.update(NS + "upsertColumnInputType", params);
|
||||
log.info("컬럼 웹타입 설정: {}.{} = {}", tableName, columnName, finalType);
|
||||
log.info("컬럼 웹타입 설정: {}.{} = {} (company={})", tableName, columnName, finalType, companyCode);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -383,12 +400,14 @@ public class TableManagementService extends BaseService {
|
||||
String safeTable = sanitize(tableName);
|
||||
List<String> violations = new ArrayList<>();
|
||||
|
||||
// N+N → N+1 최적화: hasColumn 은 information_schema 조회라 비싸. 루프 밖에서 한 번만 수행.
|
||||
boolean hasCompanyCode = hasColumn(safeTable, "company_code");
|
||||
|
||||
for (Map<String, Object> col : uniqueCols) {
|
||||
String colName = (String) col.get("column_name");
|
||||
Object val = data.get(colName);
|
||||
if (val == null) continue;
|
||||
|
||||
boolean hasCompanyCode = hasColumn(safeTable, "company_code");
|
||||
String sql;
|
||||
List<Object> sqlParams = new ArrayList<>();
|
||||
|
||||
@@ -1252,9 +1271,40 @@ public class TableManagementService extends BaseService {
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
if (name == null) return "";
|
||||
return name.replaceAll("[^a-zA-Z0-9_]", "");
|
||||
if (name == null) {
|
||||
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 동작) */
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
ALTER TABLE MENU_INFO
|
||||
ADD COLUMN IF NOT EXISTS IS_SOLUTION_ONLY BOOLEAN DEFAULT FALSE NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN MENU_INFO.IS_SOLUTION_ONLY IS '솔루션 사이트(solution.invyone.com 등 관리 호스트) 에서만 노출되는 메뉴. 테넌트 사이트에선 SQL 단계에서 제외.';
|
||||
|
||||
-- 솔루션 전용 메뉴 마킹
|
||||
UPDATE MENU_INFO SET IS_SOLUTION_ONLY = TRUE
|
||||
WHERE MENU_URL IN (
|
||||
'/admin/sysMng/subdomainList',
|
||||
'/admin/userMng/companyList',
|
||||
'/admin/audit-log'
|
||||
);
|
||||
@@ -58,6 +58,9 @@
|
||||
AND RMA.READ_YN = 'Y'
|
||||
)
|
||||
</if>
|
||||
<if test='is_management_host == false'>
|
||||
AND MENU.IS_SOLUTION_ONLY = FALSE
|
||||
</if>
|
||||
|
||||
UNION ALL
|
||||
|
||||
@@ -105,6 +108,9 @@
|
||||
AND RMA.READ_YN = 'Y'
|
||||
)
|
||||
</if>
|
||||
<if test='is_management_host == false'>
|
||||
AND S.IS_SOLUTION_ONLY = FALSE
|
||||
</if>
|
||||
)
|
||||
SELECT
|
||||
V.LEV
|
||||
@@ -124,26 +130,8 @@
|
||||
, COALESCE(V.LANG_KEY_DESC, '') AS LANG_KEY_DESC
|
||||
, COALESCE(V.MENU_ICON, '') AS MENU_ICON
|
||||
, COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME
|
||||
, COALESCE(
|
||||
(SELECT MLT.LANG_TEXT
|
||||
FROM MULTI_LANG_KEY_MASTER MLKM
|
||||
JOIN MULTI_LANG_TEXT MLT
|
||||
ON MLKM.KEY_ID = MLT.KEY_ID
|
||||
WHERE MLKM.LANG_KEY = V.LANG_KEY
|
||||
AND MLT.LANG_CODE = #{user_lang}
|
||||
LIMIT 1),
|
||||
V.MENU_NAME_KOR
|
||||
) AS TRANSLATED_NAME
|
||||
, COALESCE(
|
||||
(SELECT MLT.LANG_TEXT
|
||||
FROM MULTI_LANG_KEY_MASTER MLKM
|
||||
JOIN MULTI_LANG_TEXT MLT
|
||||
ON MLKM.KEY_ID = MLT.KEY_ID
|
||||
WHERE MLKM.LANG_KEY = V.LANG_KEY_DESC
|
||||
AND MLT.LANG_CODE = #{user_lang}
|
||||
LIMIT 1),
|
||||
COALESCE(V.MENU_DESC, '')
|
||||
) AS TRANSLATED_DESC
|
||||
, COALESCE(MLT_NAME.LANG_TEXT, V.MENU_NAME_KOR) AS TRANSLATED_NAME
|
||||
, COALESCE(MLT_DESC.LANG_TEXT, COALESCE(V.MENU_DESC, '')) AS TRANSLATED_DESC
|
||||
, CASE UPPER(V.STATUS)
|
||||
WHEN 'ACTIVE' THEN '활성화'
|
||||
WHEN 'INACTIVE' THEN '비활성화'
|
||||
@@ -152,6 +140,16 @@
|
||||
FROM V_MENU V
|
||||
LEFT JOIN COMPANY_MNG CM
|
||||
ON V.COMPANY_CODE = CM.COMPANY_CODE
|
||||
LEFT JOIN MULTI_LANG_KEY_MASTER MLKM_NAME
|
||||
ON MLKM_NAME.LANG_KEY = V.LANG_KEY
|
||||
LEFT JOIN MULTI_LANG_TEXT MLT_NAME
|
||||
ON MLT_NAME.KEY_ID = MLKM_NAME.KEY_ID
|
||||
AND MLT_NAME.LANG_CODE = #{user_lang}
|
||||
LEFT JOIN MULTI_LANG_KEY_MASTER MLKM_DESC
|
||||
ON MLKM_DESC.LANG_KEY = V.LANG_KEY_DESC
|
||||
LEFT JOIN MULTI_LANG_TEXT MLT_DESC
|
||||
ON MLT_DESC.KEY_ID = MLKM_DESC.KEY_ID
|
||||
AND MLT_DESC.LANG_CODE = #{user_lang}
|
||||
ORDER BY V.PATH, V.SEQ
|
||||
</select>
|
||||
|
||||
@@ -187,6 +185,9 @@
|
||||
AND MENU.COMPANY_CODE = #{company_code}
|
||||
</otherwise>
|
||||
</choose>
|
||||
<if test='is_management_host == false'>
|
||||
AND MENU.IS_SOLUTION_ONLY = FALSE
|
||||
</if>
|
||||
|
||||
UNION ALL
|
||||
|
||||
@@ -212,6 +213,9 @@
|
||||
ON S.PARENT_OBJ_ID = V.OBJID
|
||||
WHERE S.OBJID != ALL(V.PATH)
|
||||
AND S.STATUS = 'active'
|
||||
<if test='is_management_host == false'>
|
||||
AND S.IS_SOLUTION_ONLY = FALSE
|
||||
</if>
|
||||
)
|
||||
SELECT
|
||||
V.LEV
|
||||
@@ -231,26 +235,8 @@
|
||||
, COALESCE(V.LANG_KEY_DESC, '') AS LANG_KEY_DESC
|
||||
, COALESCE(V.MENU_ICON, '') AS MENU_ICON
|
||||
, COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME
|
||||
, COALESCE(
|
||||
(SELECT MLT.LANG_TEXT
|
||||
FROM MULTI_LANG_KEY_MASTER MLKM
|
||||
JOIN MULTI_LANG_TEXT MLT
|
||||
ON MLKM.KEY_ID = MLT.KEY_ID
|
||||
WHERE MLKM.LANG_KEY = V.LANG_KEY
|
||||
AND MLT.LANG_CODE = #{user_lang}
|
||||
LIMIT 1),
|
||||
V.MENU_NAME_KOR
|
||||
) AS TRANSLATED_NAME
|
||||
, COALESCE(
|
||||
(SELECT MLT.LANG_TEXT
|
||||
FROM MULTI_LANG_KEY_MASTER MLKM
|
||||
JOIN MULTI_LANG_TEXT MLT
|
||||
ON MLKM.KEY_ID = MLT.KEY_ID
|
||||
WHERE MLKM.LANG_KEY = V.LANG_KEY_DESC
|
||||
AND MLT.LANG_CODE = #{user_lang}
|
||||
LIMIT 1),
|
||||
COALESCE(V.MENU_DESC, '')
|
||||
) AS TRANSLATED_DESC
|
||||
, COALESCE(MLT_NAME.LANG_TEXT, V.MENU_NAME_KOR) AS TRANSLATED_NAME
|
||||
, COALESCE(MLT_DESC.LANG_TEXT, COALESCE(V.MENU_DESC, '')) AS TRANSLATED_DESC
|
||||
, CASE UPPER(V.STATUS)
|
||||
WHEN 'ACTIVE' THEN '활성화'
|
||||
WHEN 'INACTIVE' THEN '비활성화'
|
||||
@@ -259,6 +245,16 @@
|
||||
FROM V_MENU V
|
||||
LEFT JOIN COMPANY_MNG CM
|
||||
ON V.COMPANY_CODE = CM.COMPANY_CODE
|
||||
LEFT JOIN MULTI_LANG_KEY_MASTER MLKM_NAME
|
||||
ON MLKM_NAME.LANG_KEY = V.LANG_KEY
|
||||
LEFT JOIN MULTI_LANG_TEXT MLT_NAME
|
||||
ON MLT_NAME.KEY_ID = MLKM_NAME.KEY_ID
|
||||
AND MLT_NAME.LANG_CODE = #{user_lang}
|
||||
LEFT JOIN MULTI_LANG_KEY_MASTER MLKM_DESC
|
||||
ON MLKM_DESC.LANG_KEY = V.LANG_KEY_DESC
|
||||
LEFT JOIN MULTI_LANG_TEXT MLT_DESC
|
||||
ON MLT_DESC.KEY_ID = MLKM_DESC.KEY_ID
|
||||
AND MLT_DESC.LANG_CODE = #{user_lang}
|
||||
ORDER BY V.PATH, V.SEQ
|
||||
</select>
|
||||
|
||||
@@ -365,26 +361,8 @@
|
||||
, COALESCE(V.LANG_KEY_DESC, '') AS LANG_KEY_DESC
|
||||
, COALESCE(V.MENU_ICON, '') AS MENU_ICON
|
||||
, COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME
|
||||
, COALESCE(
|
||||
(SELECT MLT.LANG_TEXT
|
||||
FROM MULTI_LANG_KEY_MASTER MLKM
|
||||
JOIN MULTI_LANG_TEXT MLT
|
||||
ON MLKM.KEY_ID = MLT.KEY_ID
|
||||
WHERE MLKM.LANG_KEY = V.LANG_KEY
|
||||
AND MLT.LANG_CODE = #{user_lang}
|
||||
LIMIT 1),
|
||||
V.MENU_NAME_KOR
|
||||
) AS TRANSLATED_NAME
|
||||
, COALESCE(
|
||||
(SELECT MLT.LANG_TEXT
|
||||
FROM MULTI_LANG_KEY_MASTER MLKM
|
||||
JOIN MULTI_LANG_TEXT MLT
|
||||
ON MLKM.KEY_ID = MLT.KEY_ID
|
||||
WHERE MLKM.LANG_KEY = V.LANG_KEY_DESC
|
||||
AND MLT.LANG_CODE = #{user_lang}
|
||||
LIMIT 1),
|
||||
COALESCE(V.MENU_DESC, '')
|
||||
) AS TRANSLATED_DESC
|
||||
, COALESCE(MLT_NAME.LANG_TEXT, V.MENU_NAME_KOR) AS TRANSLATED_NAME
|
||||
, COALESCE(MLT_DESC.LANG_TEXT, COALESCE(V.MENU_DESC, '')) AS TRANSLATED_DESC
|
||||
, CASE UPPER(V.STATUS)
|
||||
WHEN 'ACTIVE' THEN '활성화'
|
||||
WHEN 'INACTIVE' THEN '비활성화'
|
||||
@@ -393,6 +371,16 @@
|
||||
FROM V_MENU V
|
||||
LEFT JOIN COMPANY_MNG CM
|
||||
ON V.COMPANY_CODE = CM.COMPANY_CODE
|
||||
LEFT JOIN MULTI_LANG_KEY_MASTER MLKM_NAME
|
||||
ON MLKM_NAME.LANG_KEY = V.LANG_KEY
|
||||
LEFT JOIN MULTI_LANG_TEXT MLT_NAME
|
||||
ON MLT_NAME.KEY_ID = MLKM_NAME.KEY_ID
|
||||
AND MLT_NAME.LANG_CODE = #{user_lang}
|
||||
LEFT JOIN MULTI_LANG_KEY_MASTER MLKM_DESC
|
||||
ON MLKM_DESC.LANG_KEY = V.LANG_KEY_DESC
|
||||
LEFT JOIN MULTI_LANG_TEXT MLT_DESC
|
||||
ON MLT_DESC.KEY_ID = MLKM_DESC.KEY_ID
|
||||
AND MLT_DESC.LANG_CODE = #{user_lang}
|
||||
ORDER BY V.PATH, V.SEQ
|
||||
</select>
|
||||
|
||||
|
||||
@@ -87,6 +87,32 @@
|
||||
ORDER BY hour_slot
|
||||
</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건) -->
|
||||
<select id="getBatchManagementRecentLogList" parameterType="map" resultType="map">
|
||||
SELECT id,
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
, E.CREATED_DATE
|
||||
, E.UPDATED_DATE
|
||||
FROM EXTERNAL_DB_CONNECTIONS E
|
||||
WHERE E.ID = #{id}
|
||||
WHERE E.ID = #{id}::varchar
|
||||
</select>
|
||||
|
||||
<!-- 단건 조회 (비밀번호 포함 - 내부 전용) -->
|
||||
@@ -109,14 +109,14 @@
|
||||
, CREATED_DATE
|
||||
, UPDATED_DATE
|
||||
FROM EXTERNAL_DB_CONNECTIONS
|
||||
WHERE ID = #{id}
|
||||
WHERE ID = #{id}::varchar
|
||||
</select>
|
||||
|
||||
<!-- 비밀번호만 조회 -->
|
||||
<select id="getExternalDbConnectionPassword" parameterType="map" resultType="map">
|
||||
SELECT PASSWORD
|
||||
FROM EXTERNAL_DB_CONNECTIONS
|
||||
WHERE ID = #{id}
|
||||
WHERE ID = #{id}::varchar
|
||||
</select>
|
||||
|
||||
<!-- 이름+회사 중복 확인 -->
|
||||
@@ -134,7 +134,7 @@
|
||||
FROM EXTERNAL_DB_CONNECTIONS
|
||||
WHERE CONNECTION_NAME = #{connection_name}
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
AND ID != #{exclude_id}
|
||||
AND ID != #{exclude_id}::varchar
|
||||
LIMIT 1
|
||||
</select>
|
||||
|
||||
@@ -166,13 +166,13 @@
|
||||
, #{description}
|
||||
, #{db_type}
|
||||
, #{host}
|
||||
, #{port}
|
||||
, #{port}::varchar
|
||||
, #{database_name}
|
||||
, #{username}
|
||||
, #{password}
|
||||
, #{connection_timeout}
|
||||
, #{query_timeout}
|
||||
, #{max_connections}
|
||||
, #{connection_timeout}::varchar
|
||||
, #{query_timeout}::varchar
|
||||
, #{max_connections}::varchar
|
||||
, #{ssl_enabled}
|
||||
, #{ssl_cert_path}
|
||||
, #{connection_options}::JSONB
|
||||
@@ -193,13 +193,13 @@
|
||||
<if test="description != null">DESCRIPTION = #{description},</if>
|
||||
<if test="db_type != null">DB_TYPE = #{db_type},</if>
|
||||
<if test="host != null">HOST = #{host},</if>
|
||||
<if test="port != null">PORT = #{port},</if>
|
||||
<if test="port != null">PORT = #{port}::varchar,</if>
|
||||
<if test="database_name != null">DATABASE_NAME = #{database_name},</if>
|
||||
<if test="username != null">USERNAME = #{username},</if>
|
||||
<if test="password != null">PASSWORD = #{password},</if>
|
||||
<if test="connection_timeout != null">CONNECTION_TIMEOUT = #{connection_timeout},</if>
|
||||
<if test="query_timeout != null">QUERY_TIMEOUT = #{query_timeout},</if>
|
||||
<if test="max_connections != null">MAX_CONNECTIONS = #{max_connections},</if>
|
||||
<if test="connection_timeout != null">CONNECTION_TIMEOUT = #{connection_timeout}::varchar,</if>
|
||||
<if test="query_timeout != null">QUERY_TIMEOUT = #{query_timeout}::varchar,</if>
|
||||
<if test="max_connections != null">MAX_CONNECTIONS = #{max_connections}::varchar,</if>
|
||||
<if test="ssl_enabled != null">SSL_ENABLED = #{ssl_enabled},</if>
|
||||
<if test="ssl_cert_path != null">SSL_CERT_PATH = #{ssl_cert_path},</if>
|
||||
<if test="connection_options != null">CONNECTION_OPTIONS = #{connection_options}::JSONB,</if>
|
||||
@@ -208,13 +208,13 @@
|
||||
<if test="updated_by != null">UPDATED_BY = #{updated_by},</if>
|
||||
UPDATED_DATE = NOW()
|
||||
</set>
|
||||
WHERE ID = #{id}
|
||||
WHERE ID = #{id}::varchar
|
||||
</update>
|
||||
|
||||
<!-- 삭제 -->
|
||||
<delete id="deleteExternalDbConnection" parameterType="map">
|
||||
DELETE FROM EXTERNAL_DB_CONNECTIONS
|
||||
WHERE ID = #{id}
|
||||
WHERE ID = #{id}::varchar
|
||||
<if test="company_code != null and company_code != "*"">
|
||||
AND (COMPANY_CODE = #{company_code} OR COMPANY_CODE = '*')
|
||||
</if>
|
||||
|
||||
@@ -704,11 +704,19 @@
|
||||
</foreach>
|
||||
AND SL.PROPERTIES->'componentConfig'->'action'->>'type' = 'save'
|
||||
AND SL.PROPERTIES->'componentConfig'->'action'->>'targetScreenId' IS NULL
|
||||
<!-- table-like 화면 (canonical 'table' / legacy 'table-list' / hidden 'v2-table-list')
|
||||
중 체크박스가 활성화된 것이 있으면 제외.
|
||||
체크박스 config 경로가 두 가지로 구분된다:
|
||||
- legacy table-list / v2-table-list : componentConfig.checkbox.enabled (boolean)
|
||||
- canonical table : componentConfig.showCheckbox (boolean) -->
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM SCREEN_LAYOUTS SL_LIST
|
||||
WHERE SL_LIST.SCREEN_ID = SD.SCREEN_ID
|
||||
AND SL_LIST.PROPERTIES->>'componentType' = 'table-list'
|
||||
AND (SL_LIST.PROPERTIES->'componentConfig'->'checkbox'->>'enabled')::BOOLEAN = TRUE
|
||||
AND SL_LIST.PROPERTIES->>'componentType' IN ('table', 'table-list', 'v2-table-list')
|
||||
AND (
|
||||
(SL_LIST.PROPERTIES->'componentConfig'->'checkbox'->>'enabled')::BOOLEAN = TRUE
|
||||
OR (SL_LIST.PROPERTIES->'componentConfig'->>'showCheckbox')::BOOLEAN = TRUE
|
||||
)
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM SCREEN_LAYOUTS SL_MODAL
|
||||
|
||||
@@ -300,7 +300,7 @@
|
||||
, #{display_column}
|
||||
, #{display_order}
|
||||
, #{is_visible}
|
||||
, 'Y'
|
||||
, COALESCE(#{is_nullable}, 'Y')
|
||||
, #{company_code}
|
||||
, #{category_ref}
|
||||
, NOW()
|
||||
@@ -318,6 +318,7 @@
|
||||
, DISPLAY_COLUMN = EXCLUDED.DISPLAY_COLUMN
|
||||
, DISPLAY_ORDER = COALESCE(EXCLUDED.DISPLAY_ORDER, TABLE_TYPE_COLUMNS.DISPLAY_ORDER)
|
||||
, 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
|
||||
, UPDATED_DATE = NOW()
|
||||
</insert>
|
||||
@@ -667,15 +668,15 @@
|
||||
SET
|
||||
PROPERTIES = JSONB_SET(
|
||||
JSONB_SET(
|
||||
SL.PROPERTIES,
|
||||
SL.PROPERTIES::JSONB,
|
||||
'{widgetType}', TO_JSONB(#{component_id}::TEXT)
|
||||
),
|
||||
'{componentType}', TO_JSONB(#{component_id}::TEXT)
|
||||
)
|
||||
)::TEXT
|
||||
FROM SCREEN_DEFINITIONS SD
|
||||
WHERE SL.SCREEN_ID = SD.SCREEN_ID
|
||||
AND SL.PROPERTIES->>'tableName' = #{table_name}
|
||||
AND SL.PROPERTIES->>'columnName' = #{column_name}
|
||||
AND SL.PROPERTIES::JSONB->>'tableName' = #{table_name}
|
||||
AND SL.PROPERTIES::JSONB->>'columnName' = #{column_name}
|
||||
AND ((SD.COMPANY_CODE = #{company_code} OR SD.COMPANY_CODE = '*') OR #{company_code} = '*')
|
||||
</update>
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
# 089 마이그레이션 — IS_SOLUTION_ONLY 메뉴 플래그 + TABLE_TYPE_COLUMNS.CODE_CATEGORY rename
|
||||
|
||||
작성일: 2026-05-15
|
||||
작성자: johngreen
|
||||
관련:
|
||||
- (V023) 멀티테넌시 메뉴 격리 — 5/15 fix (commit c530a67c)
|
||||
- (V024) common-code 마스터-디테일 재설계 — 5/15 refactor (commit 2348800e)
|
||||
|
||||
## 목적
|
||||
|
||||
V023 과 V024 두 건의 누락된 운영 문서를 합본 처리.
|
||||
앱 부팅 시 `StartupSchemaMigrator` 가 idempotent 로 메타 DB + 활성 테넌트 DB 전부에 자동 적용한다.
|
||||
|
||||
### V023 — MENU_INFO.IS_SOLUTION_ONLY 컬럼 (회상)
|
||||
|
||||
테넌트 사이트(`*.invyone.com`)에서 솔루션 전용 관리자 메뉴(회사관리/회사 프로비저닝/감사로그)를 숨기기 위한 플래그.
|
||||
- 메뉴 mapper SQL(`selectAdminMenuList`, `selectUserMenuList`)이 `is_management_host` 파라미터를 보고 `IS_SOLUTION_ONLY=TRUE` 행을 제외.
|
||||
- 이미 부팅 마이그레이션으로는 적용 중이지만 RUN_*.md 운영 문서가 빠져있어 이번 089 에 합본.
|
||||
|
||||
### V024 — TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO (★ 신규, 본 PR 의 핵심)
|
||||
|
||||
5/15 의 commonCode 마스터-디테일 재설계(commit `2348800e`)가 mapper SQL 6 군데에서
|
||||
`CL.CODE_CATEGORY` → `CL.CODE_INFO` 로 컬럼 참조명을 바꿨지만, **DB 컬럼 rename SQL 을 빠뜨린 채 머지**됨.
|
||||
그 결과 모든 테넌트 DB 의 `테이블 타입관리 > 테이블 클릭 > 컬럼 목록` API
|
||||
(`GET /api/table-management/tables/{name}/columns`) 가 **500** 반환:
|
||||
|
||||
```
|
||||
ERROR: column cl.code_info does not exist
|
||||
```
|
||||
|
||||
본 089 마이그레이션이 `CODE_CATEGORY` → `CODE_INFO` 로 컬럼명을 안전하게 변경한다.
|
||||
|
||||
## 스키마
|
||||
|
||||
### MENU_INFO (V023)
|
||||
|
||||
| 컬럼 | 타입 | 제약 | 설명 |
|
||||
|---|---|---|---|
|
||||
| `IS_SOLUTION_ONLY` | BOOLEAN | NOT NULL DEFAULT FALSE | TRUE 인 메뉴는 솔루션 관리 호스트에서만 노출 |
|
||||
|
||||
### TABLE_TYPE_COLUMNS (V024)
|
||||
|
||||
| 변경 | 설명 |
|
||||
|---|---|
|
||||
| `CODE_CATEGORY` → `CODE_INFO` | 컬럼 RENAME (값/타입/제약 그대로) |
|
||||
|
||||
## SQL
|
||||
|
||||
```sql
|
||||
-- =================================================================
|
||||
-- 089-V023: MENU_INFO.IS_SOLUTION_ONLY (idempotent)
|
||||
-- =================================================================
|
||||
|
||||
ALTER TABLE MENU_INFO
|
||||
ADD COLUMN IF NOT EXISTS IS_SOLUTION_ONLY BOOLEAN DEFAULT FALSE NOT NULL;
|
||||
|
||||
UPDATE MENU_INFO
|
||||
SET IS_SOLUTION_ONLY = TRUE
|
||||
WHERE IS_SOLUTION_ONLY = FALSE
|
||||
AND MENU_URL IN (
|
||||
'/admin/sysMng/subdomainList',
|
||||
'/admin/userMng/companyList',
|
||||
'/admin/audit-log'
|
||||
);
|
||||
|
||||
-- =================================================================
|
||||
-- 089-V024: TABLE_TYPE_COLUMNS.CODE_CATEGORY → CODE_INFO (idempotent)
|
||||
-- =================================================================
|
||||
-- PostgreSQL 은 RENAME COLUMN 에 IF EXISTS 가 없으므로 DO 블록으로
|
||||
-- 멱등성 보장 (이미 CODE_INFO 면 no-op, CODE_CATEGORY 만 존재할 때만 rename).
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'table_type_columns'
|
||||
AND column_name = 'code_category'
|
||||
) AND NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = 'table_type_columns'
|
||||
AND column_name = 'code_info'
|
||||
) THEN
|
||||
ALTER TABLE TABLE_TYPE_COLUMNS
|
||||
RENAME COLUMN CODE_CATEGORY TO CODE_INFO;
|
||||
END IF;
|
||||
END $$;
|
||||
```
|
||||
|
||||
## 멱등성
|
||||
|
||||
- V023: `ADD COLUMN IF NOT EXISTS` + UPDATE `WHERE IS_SOLUTION_ONLY = FALSE` 로 중복 실행 안전.
|
||||
- V024: DO 블록 안에서 information_schema 로 현재 상태 확인 후 분기.
|
||||
- 신규 테넌트 DB (이미 CODE_INFO 면): no-op
|
||||
- 기존 테넌트 DB (CODE_CATEGORY 만 있으면): rename 수행
|
||||
- 둘 다 있거나 둘 다 없으면: no-op (방어적)
|
||||
|
||||
## 적용 방법
|
||||
|
||||
부팅 시 자동 적용 — 별도 작업 불필요.
|
||||
`backend-spring/src/main/java/com/erp/migration/StartupSchemaMigrator.java` 의 MIGRATIONS 리스트에
|
||||
위 SQL 이 등록되어 있어서 앱이 시작할 때 모든 활성 테넌트 DB 에 idempotent 로 실행된다.
|
||||
|
||||
수동 적용이 필요한 경우 (예: 새 환경 부트스트랩 전):
|
||||
```bash
|
||||
psql -h <host> -U <user> -d <tenant_db> -f - <<'SQL'
|
||||
-- 위 SQL 본문 붙여넣기
|
||||
SQL
|
||||
```
|
||||
|
||||
## 검증
|
||||
|
||||
```sql
|
||||
-- V023
|
||||
SELECT COLUMN_NAME FROM information_schema.columns
|
||||
WHERE TABLE_NAME = 'menu_info' AND COLUMN_NAME = 'is_solution_only';
|
||||
-- → 1 row
|
||||
|
||||
SELECT MENU_URL, IS_SOLUTION_ONLY FROM MENU_INFO
|
||||
WHERE MENU_URL IN ('/admin/sysMng/subdomainList', '/admin/userMng/companyList', '/admin/audit-log');
|
||||
-- → 모두 IS_SOLUTION_ONLY = TRUE
|
||||
|
||||
-- V024
|
||||
SELECT COLUMN_NAME FROM information_schema.columns
|
||||
WHERE TABLE_NAME = 'table_type_columns' AND COLUMN_NAME IN ('code_category', 'code_info');
|
||||
-- → 1 row: code_info (code_category 는 존재하면 안 됨)
|
||||
```
|
||||
|
||||
## 영향 범위
|
||||
|
||||
- 테이블 타입관리 페이지 컬럼 조회 500 에러 해소.
|
||||
- common-code 재설계 후속 (mapper/Service/Frontend 는 이미 5/15 에 머지됨).
|
||||
- 부팅 시점 1회 실행 — 런타임 트래픽에는 영향 없음.
|
||||
|
||||
## 롤백
|
||||
|
||||
V024 rename 을 되돌리려면 mapper SQL 도 같이 되돌려야 하므로 일반적으로 권장하지 않음.
|
||||
만약 필요하면:
|
||||
```sql
|
||||
ALTER TABLE TABLE_TYPE_COLUMNS RENAME COLUMN CODE_INFO TO CODE_CATEGORY;
|
||||
```
|
||||
+ `mapper/tableManagement.xml`, `commonCode.xml`, FE `commonCode.ts` 등 5/15 변경분 revert.
|
||||
@@ -0,0 +1,109 @@
|
||||
# 090 마이그레이션 — TABLE_TYPE_COLUMNS 중복 정리 + ON CONFLICT 용 UNIQUE INDEX
|
||||
|
||||
작성일: 2026-05-15
|
||||
작성자: johngreen
|
||||
관련 버그: 테이블 타입관리에서 모든 쓰기 API (UNIQUE 토글 / NOT NULL 토글 / 컬럼 설정 저장) 가 500 반환.
|
||||
|
||||
## 증상
|
||||
|
||||
```
|
||||
PSQLException: ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specification
|
||||
mapper: tableManagement.upsertColumnSettings / upsertNullable / upsertUnique / upsertColumnInputType
|
||||
```
|
||||
|
||||
## 원인
|
||||
|
||||
`TABLE_TYPE_COLUMNS` 의 PK 는 `id` 단일(varchar). 운영 DB 어디에도
|
||||
`(TABLE_NAME, COLUMN_NAME, COMPANY_CODE)` UNIQUE 제약/인덱스가 없음.
|
||||
mapper 의 `INSERT … ON CONFLICT (TABLE_NAME, COLUMN_NAME, COMPANY_CODE) DO UPDATE …`
|
||||
구문이 매칭할 unique constraint 를 찾지 못해 즉시 BadSqlGrammar 로 500.
|
||||
|
||||
RUN_044 가 company_code 컬럼을 추가했지만 함께 도입했어야 할 unique index 가
|
||||
빠진 채로 운영에 들어간 것으로 보이며, 그 후 mapper 가 ON CONFLICT 패턴으로 작성되면서
|
||||
실제로는 한 번도 정상 동작하지 못한 채로 잠복했던 정황 (운영 메타 DB 의 35,316 행 중
|
||||
중복 키 그룹 2개 = 추가 4 row 가 그 흔적).
|
||||
|
||||
## 조치
|
||||
|
||||
### (1) 중복 행 정리
|
||||
|
||||
각 `(TABLE_NAME, COLUMN_NAME, COMPANY_CODE)` 그룹에서
|
||||
`updated_date DESC NULLS LAST, id::bigint DESC` 로 정렬해 첫 행만 유지, 나머지 DELETE.
|
||||
|
||||
```sql
|
||||
DELETE FROM TABLE_TYPE_COLUMNS
|
||||
WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY TABLE_NAME, COLUMN_NAME, COMPANY_CODE
|
||||
ORDER BY UPDATED_DATE DESC NULLS LAST,
|
||||
id::bigint DESC
|
||||
) AS rn
|
||||
FROM TABLE_TYPE_COLUMNS
|
||||
) r
|
||||
WHERE r.rn > 1
|
||||
);
|
||||
```
|
||||
|
||||
실측(2026-05-15) 중복:
|
||||
|
||||
| DB | 중복 그룹 | 삭제될 row |
|
||||
|---|---|---|
|
||||
| meta `invyone` | 2 (`sales_order_mng.incoterms@COMPANY_16`, `sales_order_mng.payment_term@COMPANY_16`) | 2 |
|
||||
| `siflex_invyone` | 0 | 0 |
|
||||
| `test01_invyone` | 0 | 0 |
|
||||
| `test02_invyone` | 0 | 0 |
|
||||
|
||||
남는 행은 가장 최근에 갱신된 동일 키 row (column_label/input_type 모두 동일 — 옛 NULL updated_date row 가 제거 대상).
|
||||
|
||||
### (2) UNIQUE INDEX 추가
|
||||
|
||||
```sql
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS UX_TABLE_TYPE_COLUMNS_TCC
|
||||
ON TABLE_TYPE_COLUMNS (TABLE_NAME, COLUMN_NAME, COMPANY_CODE);
|
||||
```
|
||||
|
||||
PostgreSQL 은 ON CONFLICT 가 인덱스도 인식하므로 mapper 의 모든 upsert SQL 이
|
||||
즉시 정상 동작. `IF NOT EXISTS` 로 멱등.
|
||||
|
||||
## 적용 방법
|
||||
|
||||
부팅 시 자동 적용 — 별도 작업 불필요. `StartupSchemaMigrator.MIGRATIONS` 리스트에
|
||||
V025 / RUN_090 (1) (2) 항목으로 등록되어 있어서 앱이 시작할 때 메타 DB + 모든 활성
|
||||
테넌트 DB 에 차례로 실행된다.
|
||||
|
||||
## 검증
|
||||
|
||||
```sql
|
||||
-- 중복 없음
|
||||
SELECT COUNT(*) FROM (
|
||||
SELECT 1 FROM TABLE_TYPE_COLUMNS
|
||||
GROUP BY TABLE_NAME, COLUMN_NAME, COMPANY_CODE HAVING COUNT(*) > 1
|
||||
) d;
|
||||
-- → 0
|
||||
|
||||
-- 인덱스 존재
|
||||
SELECT indexname FROM pg_indexes
|
||||
WHERE tablename = 'table_type_columns' AND indexname = 'ux_table_type_columns_tcc';
|
||||
-- → 1 row
|
||||
```
|
||||
|
||||
브라우저 검증:
|
||||
1. 솔루션 또는 테넌트 사이트 > 시스템 관리 > 테이블 타입관리 > 거래처 클릭
|
||||
2. 어느 컬럼이든 `UQ` / `NN` 토글 클릭 → 200, 토스트 "UNIQUE/NOT NULL 제약이 설정되었습니다"
|
||||
3. "컬럼 설정 저장" 버튼 클릭 → 200, 토스트 "모든 컬럼 설정을 성공적으로 저장했습니다"
|
||||
|
||||
## 영향 범위
|
||||
|
||||
- 테이블 타입관리 페이지 쓰기 API 4종 (`unique`, `nullable`, `columns/settings`, `columns/{c}/input-type`) 정상화.
|
||||
- 멱등 — 재실행 시 DELETE 0건, CREATE INDEX 도 IF NOT EXISTS 라 skip.
|
||||
- 부팅 시점 1회 실행, 런타임 트래픽에는 영향 없음.
|
||||
|
||||
## 롤백
|
||||
|
||||
```sql
|
||||
DROP INDEX IF EXISTS UX_TABLE_TYPE_COLUMNS_TCC;
|
||||
```
|
||||
DELETE 된 중복 row 는 정보 손실 없음 (남은 row 와 column_label/input_type 동일) 이라
|
||||
복구가 의미 없음. 그래도 굳이 되돌리려면 사전 백업 필요.
|
||||
@@ -0,0 +1,81 @@
|
||||
# 091 마이그레이션 — TABLE_TYPE_COLUMNS.INPUT_TYPE legacy → 표준 8종 정리
|
||||
|
||||
작성일: 2026-05-16
|
||||
작성자: johngreen
|
||||
관련: 5/15 common-code 재설계 (commit `2348800e`) 후속 데이터 마이그레이션.
|
||||
|
||||
## 배경
|
||||
|
||||
5/15 PR 이 `InputTypeConstants.USER_SELECTABLE_INPUT_TYPES` 화이트리스트를
|
||||
표준 8종(`text/number/date/code/entity/numbering/file/image`) 으로 좁혔지만,
|
||||
운영 DB 에 잔존하는 옛 input_type 값들을 정리하는 데이터 마이그레이션이 빠지고
|
||||
프론트엔드도 옛 값을 그대로 echo 했기 때문에 컬럼 설정 저장 batch 가 400 으로 거부됐다.
|
||||
|
||||
긴급 회복은 `90787d83` 에서 화이트리스트에 legacy 7종을 다시 인정하는 방식으로
|
||||
끝냈고, 본 091 마이그레이션은 그 뒤로 **데이터를 표준으로 통합**하는 후속 정리.
|
||||
|
||||
## 매핑
|
||||
|
||||
| Legacy | → | Standard | 사유 |
|
||||
|---|---|---|---|
|
||||
| `category` | → | `code` | commonCode 통합 의도와 일치 |
|
||||
| `select` | → | `code` | 미리 정의된 코드 선택 = code 와 동등 |
|
||||
| `radio` | → | `code` | enum 선택 |
|
||||
| `checkbox` | → | `code` | enum/boolean → code 매핑 (표준에 boolean 없음) |
|
||||
| `boolean` | → | `code` | 표준에 boolean 없음 — code 가 가장 근접 |
|
||||
| `textarea` | → | `text` | single/multi line 구분 UI 손실 (가벼움) |
|
||||
| `datetime` | → | `date` | 표준에 datetime 분리 없음 |
|
||||
|
||||
## 영향 범위 (실측 2026-05-16)
|
||||
|
||||
| DB | 갱신 row |
|
||||
|---|---|
|
||||
| meta `invyone` | 1,207 (category 886 + select 149 + textarea 102 + checkbox 55 + radio 12 + datetime 2 + boolean 1) |
|
||||
| `siflex_invyone` | 0 (테이블 비어있음) |
|
||||
| `test01_invyone` | 0 |
|
||||
| `test02_invyone` | 0 |
|
||||
|
||||
## SQL
|
||||
|
||||
```sql
|
||||
UPDATE TABLE_TYPE_COLUMNS
|
||||
SET INPUT_TYPE = CASE INPUT_TYPE
|
||||
WHEN 'category' THEN 'code'
|
||||
WHEN 'select' THEN 'code'
|
||||
WHEN 'radio' THEN 'code'
|
||||
WHEN 'checkbox' THEN 'code'
|
||||
WHEN 'boolean' THEN 'code'
|
||||
WHEN 'textarea' THEN 'text'
|
||||
WHEN 'datetime' THEN 'date'
|
||||
END,
|
||||
UPDATED_DATE = NOW()
|
||||
WHERE INPUT_TYPE IN ('category','select','radio','checkbox','boolean','textarea','datetime');
|
||||
```
|
||||
|
||||
## 멱등성
|
||||
|
||||
`WHERE INPUT_TYPE IN (...)` 으로 두 번째 실행 시 매칭 row 0 → no-op.
|
||||
|
||||
## 적용 방법
|
||||
|
||||
부팅 시 자동 적용. `StartupSchemaMigrator.MIGRATIONS` 리스트에 V026 / RUN_091 항목으로
|
||||
등록되어 있어서 backend 시작 시 메타 DB + 활성 테넌트 DB 전부에 idempotent 로 실행된다.
|
||||
|
||||
## 검증
|
||||
|
||||
```sql
|
||||
-- 화이트리스트 밖 row 0 이어야 함
|
||||
SELECT input_type, COUNT(*) FROM table_type_columns
|
||||
WHERE input_type NOT IN ('text','number','date','code','entity','numbering','file','image')
|
||||
GROUP BY 1;
|
||||
-- → 0 rows
|
||||
```
|
||||
|
||||
## 후속 cleanup (별도 PR 거리)
|
||||
|
||||
본 마이그레이션이 모든 환경에 한 번 적용된 다음에는:
|
||||
1. `InputTypeConstants.USER_SELECTABLE_INPUT_TYPES` 에서 legacy 7종 다시 제거.
|
||||
2. 프론트엔드 input type 선택 UI 에서 legacy 옵션 제거 (이미 있을 수도).
|
||||
3. mapper/Service 에서 legacy 값 참조 흔적 grep + 정리.
|
||||
|
||||
이번 PR 은 데이터 정리만. 화이트리스트 축소는 운영 안정 확인 후.
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -64,6 +65,7 @@ import {
|
||||
import { getCompanyList } from "@/lib/api/company";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { Company } from "@/types/company";
|
||||
import { isManagementHost } from "@/lib/tenant/subdomain";
|
||||
|
||||
const RESOURCE_TYPE_CONFIG: Record<
|
||||
string,
|
||||
@@ -290,6 +292,16 @@ function groupByDate(entries: AuditLogEntry[]): Map<string, AuditLogEntry[]> {
|
||||
}
|
||||
|
||||
export default function AuditLogPage() {
|
||||
const router = useRouter();
|
||||
const [hostBlocked, setHostBlocked] = useState(false);
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!isManagementHost(window.location.hostname)) {
|
||||
setHostBlocked(true);
|
||||
router.replace("/main");
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const { user } = useAuth();
|
||||
const isSuperAdmin = user?.company_code === "*";
|
||||
|
||||
@@ -393,6 +405,8 @@ export default function AuditLogPage() {
|
||||
setDetailOpen(true);
|
||||
};
|
||||
|
||||
if (hostBlocked) return null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-4 p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from "@/lib/api/batch";
|
||||
import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
import { CrossTenantBanner } from "@/components/common/CrossTenantBanner";
|
||||
import { Pagination } from "@/components/common/Pagination";
|
||||
import { useTabStore } from "@/stores/tabStore";
|
||||
|
||||
function cronToKorean(cron: string): string {
|
||||
@@ -127,9 +128,11 @@ function Sparkline({ data }: { data: SparklineData[] }) {
|
||||
return (
|
||||
<div className="flex h-8 items-end gap-[2px]">
|
||||
{data.map((slot, i) => {
|
||||
const hasFail = slot.failed > 0;
|
||||
const hasSuccess = slot.success > 0;
|
||||
const height = hasFail ? "40%" : hasSuccess ? `${Math.max(30, Math.min(95, 50 + slot.success * 10))}%` : "8%";
|
||||
const failed = Number(slot.failed_count) || 0;
|
||||
const success = Number(slot.success_count) || 0;
|
||||
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
|
||||
? "bg-destructive/70 hover:bg-destructive"
|
||||
: hasSuccess
|
||||
@@ -140,7 +143,7 @@ function Sparkline({ data }: { data: SparklineData[] }) {
|
||||
key={i}
|
||||
className={`min-w-[4px] flex-1 rounded-t-sm transition-colors ${colorClass}`}
|
||||
style={{ height }}
|
||||
title={`${slot.hour?.slice(11, 16) || i}시 | 성공: ${slot.success} 실패: ${slot.failed}`}
|
||||
title={`${slot.hour_slot?.slice(11, 16) || i}시 | 성공: ${success} 실패: ${failed}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -277,8 +280,10 @@ function BatchDetailPanel({ batch, sparkline, recentLogs }: { batch: BatchConfig
|
||||
);
|
||||
}
|
||||
|
||||
function GlobalSparkline({ stats }: { stats: BatchStats | null }) {
|
||||
if (!stats) return null;
|
||||
function GlobalSparkline({ data }: { data: SparklineData[] }) {
|
||||
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 (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
@@ -293,22 +298,31 @@ function GlobalSparkline({ stats }: { stats: BatchStats | null }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-10 items-end gap-[3px]">
|
||||
{Array.from({ length: 24 }).map((_, i) => {
|
||||
const hasExec = Math.random() > 0.3;
|
||||
const hasFail = hasExec && Math.random() < 0.08;
|
||||
const h = hasFail ? 35 : hasExec ? 25 + Math.random() * 70 : 6;
|
||||
{data.map((slot, i) => {
|
||||
const success = Number(slot.success_count) || 0;
|
||||
const failed = Number(slot.failed_count) || 0;
|
||||
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 (
|
||||
<div
|
||||
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}%` }}
|
||||
title={`${hour} | 성공 ${success} 실패 ${failed}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between text-[10px] text-muted-foreground">
|
||||
<span>24시간 전</span>
|
||||
<span>12시간 전</span>
|
||||
<span>6시간 전</span>
|
||||
<span>지금</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -326,18 +340,25 @@ export default function BatchManagementPage() {
|
||||
const [executingBatch, setExecutingBatch] = useState<number | null>(null);
|
||||
const [expandedBatch, setExpandedBatch] = useState<number | null>(null);
|
||||
const [stats, setStats] = useState<BatchStats | null>(null);
|
||||
const [globalSparkline, setGlobalSparkline] = useState<SparklineData[]>([]);
|
||||
const [sparklineCache, setSparklineCache] = useState<Record<number, SparklineData[]>>({});
|
||||
const [recentLogsCache, setRecentLogsCache] = useState<Record<number, RecentLog[]>>({});
|
||||
const [isBatchTypeModalOpen, setIsBatchTypeModalOpen] = useState(false);
|
||||
const [togglingBatch, setTogglingBatch] = useState<number | null>(null);
|
||||
|
||||
// 페이지네이션 상태
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(20);
|
||||
|
||||
const loadBatchConfigs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [configsResponse, statsData] = await Promise.all([
|
||||
const [configsResponse, statsData, globalSpark] = await Promise.all([
|
||||
BatchAPI.getBatchConfigs({ page: 1, limit: 200 }),
|
||||
BatchAPI.getBatchStats(),
|
||||
BatchAPI.getGlobalSparkline(),
|
||||
]);
|
||||
setGlobalSparkline(globalSpark);
|
||||
// cross-tenant 메타 (단일 모드면 undefined → null)
|
||||
setCrossTenantMeta((configsResponse as any)?.cross_tenant_meta ?? null);
|
||||
if (configsResponse.success && configsResponse.data) {
|
||||
@@ -364,6 +385,9 @@ export default function BatchManagementPage() {
|
||||
|
||||
useEffect(() => { loadBatchConfigs(); }, [loadBatchConfigs]);
|
||||
|
||||
// 검색/필터 변경 시 1페이지로 리셋
|
||||
useEffect(() => { setCurrentPage(1); }, [searchTerm, statusFilter]);
|
||||
|
||||
const handleRowClick = async (batchId: number) => {
|
||||
if (expandedBatch === batchId) { setExpandedBatch(null); return; }
|
||||
setExpandedBatch(batchId);
|
||||
@@ -443,14 +467,26 @@ export default function BatchManagementPage() {
|
||||
return true;
|
||||
});
|
||||
|
||||
// 페이지네이션 계산
|
||||
const totalItems = filteredBatches.length;
|
||||
const totalPages = Math.max(1, Math.ceil(totalItems / itemsPerPage));
|
||||
const safePage = Math.min(currentPage, totalPages);
|
||||
const startIdx = (safePage - 1) * itemsPerPage;
|
||||
const endIdx = Math.min(startIdx + itemsPerPage, totalItems);
|
||||
const pagedBatches = filteredBatches.slice(startIdx, endIdx);
|
||||
|
||||
const activeBatches = batchConfigs.filter(b => b.is_active === "Y").length;
|
||||
const inactiveBatches = batchConfigs.length - activeBatches;
|
||||
const execDiff = stats ? stats.todayExecutions - stats.prevDayExecutions : 0;
|
||||
const failDiff = stats ? stats.todayFailures - stats.prevDayFailures : 0;
|
||||
const todayExec = Number(stats?.today_count) || 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 (
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
<div className="w-full space-y-4 px-4 py-6 sm:px-6">
|
||||
<div className="bg-background flex h-full min-h-0 w-full flex-col overflow-hidden">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 px-4 py-6 sm:px-6">
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -486,7 +522,7 @@ export default function BatchManagementPage() {
|
||||
<div className="h-8 w-px bg-border" />
|
||||
<div className="flex flex-1 flex-col px-4 py-3">
|
||||
<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 && (
|
||||
<span className={`text-[10px] ${execDiff > 0 ? "text-emerald-500" : "text-muted-foreground"}`}>
|
||||
어제보다 {execDiff > 0 ? "+" : ""}{execDiff}
|
||||
@@ -496,8 +532,8 @@ export default function BatchManagementPage() {
|
||||
<div className="h-8 w-px bg-border" />
|
||||
<div className="flex flex-1 flex-col px-4 py-3">
|
||||
<span className="text-[11px] text-muted-foreground">실패</span>
|
||||
<span className={`text-lg font-bold ${stats.todayFailures > 0 ? "text-destructive" : "text-muted-foreground"}`}>
|
||||
{stats.todayFailures}
|
||||
<span className={`text-lg font-bold ${todayFail > 0 ? "text-destructive" : "text-muted-foreground"}`}>
|
||||
{todayFail}
|
||||
</span>
|
||||
{failDiff !== 0 && (
|
||||
<span className={`text-[10px] ${failDiff > 0 ? "text-destructive" : "text-emerald-500"}`}>
|
||||
@@ -509,7 +545,7 @@ export default function BatchManagementPage() {
|
||||
)}
|
||||
|
||||
{/* 24시간 차트 */}
|
||||
<GlobalSparkline stats={stats} />
|
||||
<GlobalSparkline data={globalSparkline} />
|
||||
|
||||
{/* 검색 + 필터 */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
@@ -534,8 +570,8 @@ export default function BatchManagementPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 배치 리스트 */}
|
||||
<div className="space-y-1.5">
|
||||
{/* 배치 리스트 - 자체 스크롤 */}
|
||||
<div className="min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1">
|
||||
{loading && batchConfigs.length === 0 && (
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
@@ -549,7 +585,7 @@ export default function BatchManagementPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredBatches.map((batch) => {
|
||||
{pagedBatches.map((batch) => {
|
||||
const batchId = batch.id!;
|
||||
const isExpanded = expandedBatch === batchId;
|
||||
const isExecuting = executingBatch === batchId;
|
||||
@@ -674,6 +710,29 @@ export default function BatchManagementPage() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 — 리스트 영역 아래 고정 */}
|
||||
{!loading && (
|
||||
<div className="shrink-0 rounded-lg border bg-card p-2 shadow-sm">
|
||||
<Pagination
|
||||
paginationInfo={{
|
||||
currentPage: safePage,
|
||||
totalPages,
|
||||
totalItems,
|
||||
itemsPerPage,
|
||||
startItem: totalItems === 0 ? 0 : startIdx + 1,
|
||||
endItem: endIdx,
|
||||
}}
|
||||
onPageChange={setCurrentPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setItemsPerPage(size);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
showPageSizeSelector
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 배치 타입 선택 모달 */}
|
||||
{isBatchTypeModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm" onClick={() => setIsBatchTypeModalOpen(false)}>
|
||||
|
||||
@@ -231,15 +231,15 @@ export default function ExternalConnectionsPage() {
|
||||
) },
|
||||
{ key: "id", label: "연결 테스트", width: "150px", hideOnMobile: true,
|
||||
render: (_v, row) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button variant="outline" size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); handleTestConnection(row); }}
|
||||
disabled={testingConnections.has(row.id!)}
|
||||
className="h-9 text-sm">
|
||||
className="h-7 px-2 text-xs">
|
||||
{testingConnections.has(row.id!) ? "테스트 중..." : "테스트"}
|
||||
</Button>
|
||||
{testResults.has(row.id!) && (
|
||||
<Badge variant={testResults.get(row.id!) ? "default" : "destructive"}>
|
||||
<Badge variant={testResults.get(row.id!) ? "default" : "destructive"} className="text-[10px]">
|
||||
{testResults.get(row.id!) ? "성공" : "실패"}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -264,68 +264,68 @@ export default function ExternalConnectionsPage() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
<div className="space-y-6 p-6">
|
||||
<div className="bg-background flex h-full min-h-0 w-full flex-col overflow-hidden">
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-4 px-4 py-4 sm:px-6">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="space-y-2 border-b pb-4">
|
||||
<h1 className="text-3xl font-bold tracking-tight">외부 커넥션 관리</h1>
|
||||
<p className="text-sm text-muted-foreground">외부 데이터베이스 및 REST API 연결 정보를 관리합니다</p>
|
||||
<div className="shrink-0 space-y-0.5 border-b pb-3">
|
||||
<h1 className="text-lg font-bold tracking-tight">외부 커넥션 관리</h1>
|
||||
<p className="text-xs text-muted-foreground">외부 데이터베이스 및 REST API 연결 정보를 관리합니다</p>
|
||||
</div>
|
||||
|
||||
{/* 탭 */}
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as ConnectionTabType)}>
|
||||
<TabsList className="grid w-full max-w-[400px] grid-cols-2">
|
||||
<TabsTrigger value="database" className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4" />
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as ConnectionTabType)} className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<TabsList className="grid h-8 w-full max-w-[320px] shrink-0 grid-cols-2">
|
||||
<TabsTrigger value="database" className="flex items-center gap-1.5 text-xs">
|
||||
<Database className="h-3.5 w-3.5" />
|
||||
데이터베이스 연결
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="rest-api" className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4" />
|
||||
<TabsTrigger value="rest-api" className="flex items-center gap-1.5 text-xs">
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
REST API 연결
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 데이터베이스 연결 탭 */}
|
||||
<TabsContent value="database" className="space-y-6">
|
||||
<TabsContent value="database" className="mt-0 flex min-h-0 flex-1 flex-col gap-3">
|
||||
{/* 검색 및 필터 */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative w-full sm:w-[300px]">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<div className="flex shrink-0 flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="relative w-full sm:w-[260px]">
|
||||
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="연결명 또는 설명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
className="h-8 pl-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Select value={dbTypeFilter} onValueChange={setDbTypeFilter}>
|
||||
<SelectTrigger className="h-10 w-full sm:w-[160px]">
|
||||
<SelectTrigger className="h-8 w-full text-xs sm:w-[140px]">
|
||||
<SelectValue placeholder="DB 타입" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{supportedDbTypes.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
<SelectItem key={type.value} value={type.value} className="text-xs">
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={activeStatusFilter} onValueChange={setActiveStatusFilter}>
|
||||
<SelectTrigger className="h-10 w-full sm:w-[120px]">
|
||||
<SelectTrigger className="h-8 w-full text-xs sm:w-[110px]">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACTIVE_STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<SelectItem key={option.value} value={option.value} className="text-xs">
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={handleAddConnection} className="h-10 gap-2 text-sm font-medium">
|
||||
<Plus className="h-4 w-4" />
|
||||
<Button onClick={handleAddConnection} size="sm" className="h-8 gap-1 text-xs font-medium">
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
새 연결 추가
|
||||
</Button>
|
||||
</div>
|
||||
@@ -338,10 +338,12 @@ export default function ExternalConnectionsPage() {
|
||||
isLoading={loading}
|
||||
emptyMessage="등록된 연결이 없습니다"
|
||||
skeletonCount={5}
|
||||
compact
|
||||
scrollContainer
|
||||
cardTitle={(c) => c.connection_name}
|
||||
cardSubtitle={(c) => <span className="font-mono text-xs">{c.host}:{c.port}/{c.database_name}</span>}
|
||||
cardHeaderRight={(c) => (
|
||||
<Badge variant={c.is_active === "Y" ? "default" : "secondary"}>
|
||||
<Badge variant={c.is_active === "Y" ? "default" : "secondary"} className="text-[10px]">
|
||||
{c.is_active === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -351,7 +353,7 @@ export default function ExternalConnectionsPage() {
|
||||
<Button variant="outline" size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); handleTestConnection(c); }}
|
||||
disabled={testingConnections.has(c.id!)}
|
||||
className="h-9 flex-1 gap-2 text-sm">
|
||||
className="h-7 flex-1 gap-1 text-xs">
|
||||
{testingConnections.has(c.id!) ? "테스트 중..." : "테스트"}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm"
|
||||
@@ -360,20 +362,20 @@ export default function ExternalConnectionsPage() {
|
||||
setSelectedConnection(c);
|
||||
setSqlModalOpen(true);
|
||||
}}
|
||||
className="h-9 flex-1 gap-2 text-sm">
|
||||
<Terminal className="h-4 w-4" />
|
||||
className="h-7 flex-1 gap-1 text-xs">
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
SQL
|
||||
</Button>
|
||||
<Button variant="outline" size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); handleEditConnection(c); }}
|
||||
className="h-9 flex-1 gap-2 text-sm">
|
||||
<Pencil className="h-4 w-4" />
|
||||
className="h-7 flex-1 gap-1 text-xs">
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
편집
|
||||
</Button>
|
||||
<Button variant="outline" size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); handleDeleteConnection(c); }}
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-9 flex-1 gap-2 text-sm">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
className="text-destructive hover:bg-destructive/10 hover:text-destructive h-7 flex-1 gap-1 text-xs">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
삭제
|
||||
</Button>
|
||||
</>
|
||||
@@ -436,7 +438,7 @@ export default function ExternalConnectionsPage() {
|
||||
</TabsContent>
|
||||
|
||||
{/* REST API 연결 탭 */}
|
||||
<TabsContent value="rest-api" className="space-y-6">
|
||||
<TabsContent value="rest-api" className="mt-0 flex min-h-0 flex-1 flex-col gap-3">
|
||||
<RestApiConnectionList />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -331,14 +331,14 @@ export default function BatchManagementNewPage() {
|
||||
// 내부 데이터베이스 선택
|
||||
connection = connections.find((conn) => conn.type === "internal") || null;
|
||||
} else {
|
||||
// 외부 데이터베이스 선택
|
||||
const connectionId = parseInt(connectionValue);
|
||||
connection = connections.find((conn) => conn.id === connectionId) || null;
|
||||
// 외부 데이터베이스 선택 — id 가 number/string 어느 쪽이든 안전하게 비교
|
||||
connection = connections.find((conn) => conn.id?.toString() === connectionValue) || null;
|
||||
}
|
||||
|
||||
setToConnection(connection);
|
||||
setToTable("");
|
||||
setToColumns([]);
|
||||
setToTables([]);
|
||||
|
||||
if (connection) {
|
||||
try {
|
||||
@@ -383,12 +383,12 @@ export default function BatchManagementNewPage() {
|
||||
if (connectionValue === "internal") {
|
||||
connection = connections.find((conn) => conn.type === "internal") || null;
|
||||
} else {
|
||||
const connectionId = parseInt(connectionValue);
|
||||
connection = connections.find((conn) => conn.id === connectionId) || null;
|
||||
connection = connections.find((conn) => conn.id?.toString() === connectionValue) || null;
|
||||
}
|
||||
setFromConnection(connection);
|
||||
setFromTable("");
|
||||
setFromColumns([]);
|
||||
setFromTables([]);
|
||||
|
||||
if (connection) {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { FileText, Download, Plus, Search, RefreshCw, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { getCompaniesStats } from "@/lib/api/provisioning";
|
||||
@@ -9,6 +10,7 @@ import CompanyAccordionRow from "@/components/admin/provisioning/CompanyAccordio
|
||||
import Wizard from "@/components/admin/provisioning/wizard/Wizard";
|
||||
import AuditLogDrawer from "@/components/admin/provisioning/AuditLogDrawer";
|
||||
import { toCsvString, downloadCsv } from "@/lib/csvExport";
|
||||
import { isManagementHost } from "@/lib/tenant/subdomain";
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
@@ -18,8 +20,22 @@ const PAGE_SIZE = 10;
|
||||
*
|
||||
* 기존 /admin/userMng/companyList (회사 기본 CRUD) 와는 스코프가 다름.
|
||||
* 이 페이지는 "테넌트 DB 생성 + 서브도메인 라우팅 + 회사 라이프사이클" 전용.
|
||||
*
|
||||
* 호스트 격리: 솔루션/관리 호스트(solution.invyone.com, localhost 등) 에서만 접근 가능.
|
||||
* 테넌트 사이트(qnc.invyone.com 등) 에서 URL 직접 진입 시 /main 으로 리다이렉트.
|
||||
* 백엔드 SuperAdminGuard 도 동일 정책으로 API 자체를 거절.
|
||||
*/
|
||||
export default function SubdomainListPage() {
|
||||
const router = useRouter();
|
||||
const [hostBlocked, setHostBlocked] = useState(false);
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!isManagementHost(window.location.hostname)) {
|
||||
setHostBlocked(true);
|
||||
router.replace("/main");
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const [openKey, setOpenKey] = useState<string | null>(null);
|
||||
const [q, setQ] = useState("");
|
||||
const [filter, setFilter] = useState<"all" | "active" | "provisioning" | "inactive" | "failed">("all");
|
||||
@@ -51,6 +67,7 @@ export default function SubdomainListPage() {
|
||||
const { data: rows = [], isLoading, refetch, dataUpdatedAt } = useQuery({
|
||||
queryKey: ["companies-stats"],
|
||||
queryFn: getCompaniesStats,
|
||||
enabled: !hostBlocked, // 테넌트 사이트에서는 API 도 안 부르고 곧장 redirect
|
||||
refetchInterval: (query) => {
|
||||
// provisioning 중인 회사 있으면 3초 폴링, 없으면 30초
|
||||
const hasProvisioning = Array.isArray(query.state.data)
|
||||
@@ -95,6 +112,12 @@ export default function SubdomainListPage() {
|
||||
const provisCount = rows.filter((r) => r.db_status === "provisioning").length;
|
||||
const inactCount = rows.filter((r) => r.db_status === "inactive" || r.status === "inactive").length;
|
||||
|
||||
// 호스트 격리 — 테넌트 사이트에서 진입한 경우 redirect 대기 중 빈 화면.
|
||||
// 데이터/UI 가 잠깐이라도 노출되지 않도록 본 render 보다 먼저 차단.
|
||||
if (hostBlocked) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -20,7 +20,11 @@ import {
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Columns3,
|
||||
Link2,
|
||||
} from "lucide-react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
|
||||
import { toast } from "sonner";
|
||||
@@ -55,6 +59,7 @@ import type { TableInfo, ColumnTypeInfo, SecondLevelMenu } from "@/components/ad
|
||||
import { TypeOverviewStrip } from "@/components/admin/table-type/TypeOverviewStrip";
|
||||
import { ColumnGrid } from "@/components/admin/table-type/ColumnGrid";
|
||||
import { ColumnDetailPanel } from "@/components/admin/table-type/ColumnDetailPanel";
|
||||
import { ReferenceListView } from "@/components/admin/table-type/ReferenceListView";
|
||||
|
||||
export default function TableManagementPage() {
|
||||
const { userLang, getText } = useMultiLang({ companyCode: "*" });
|
||||
@@ -119,6 +124,9 @@ export default function TableManagementPage() {
|
||||
// 테이블 삭제 확인 다이얼로그 상태
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [tableToDelete, setTableToDelete] = useState<string>("");
|
||||
const [deleteColumnDialogOpen, setDeleteColumnDialogOpen] = useState(false);
|
||||
const [columnToDelete, setColumnToDelete] = useState<string>("");
|
||||
const [isDeletingColumn, setIsDeletingColumn] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// PK/인덱스 관리 상태
|
||||
@@ -127,6 +135,8 @@ export default function TableManagementPage() {
|
||||
indexes: Array<{ name: string; columns: string[]; is_unique: boolean }>;
|
||||
}>({ primaryKey: { name: "", columns: [] }, indexes: [] });
|
||||
const [pkDialogOpen, setPkDialogOpen] = useState(false);
|
||||
// 이번 세션 동안 PK 변경 확인 다이얼로그 건너뛰기 (composite PK 만들 때 매번 다이얼로그 뜨는 답답함 해소)
|
||||
const [pkSkipConfirmSession, setPkSkipConfirmSession] = useState(false);
|
||||
const [pendingPkColumns, setPendingPkColumns] = useState<string[]>([]);
|
||||
|
||||
// 선택된 테이블 목록 (체크박스)
|
||||
@@ -269,11 +279,9 @@ export default function TableManagementPage() {
|
||||
if (response.success && response.data) {
|
||||
setSecondLevelMenus(response.data);
|
||||
} else {
|
||||
console.warn("⚠️ 2레벨 메뉴 로드 실패:", response);
|
||||
setSecondLevelMenus([]); // 빈 배열로 설정하여 로딩 상태 해제
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ 2레벨 메뉴 로드 에러:", error);
|
||||
setSecondLevelMenus([]); // 에러 발생 시에도 빈 배열로 설정
|
||||
}
|
||||
};
|
||||
@@ -316,12 +324,6 @@ export default function TableManagementPage() {
|
||||
if (response.data.success) {
|
||||
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) => {
|
||||
let hierarchyRole: "large" | "medium" | "small" | undefined = undefined;
|
||||
@@ -391,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(
|
||||
(tableName: string) => {
|
||||
if (tableName === selectedTable) return;
|
||||
if (hasUnsavedChanges) {
|
||||
const ok = typeof window !== "undefined"
|
||||
? window.confirm("저장하지 않은 컬럼 변경 사항이 있습니다. 이동하면 변경 내용이 사라집니다. 계속할까요?")
|
||||
: true;
|
||||
if (!ok) return;
|
||||
}
|
||||
setSelectedTable(tableName);
|
||||
setCurrentPage(1);
|
||||
setColumns([]);
|
||||
@@ -408,12 +439,17 @@ export default function TableManagementPage() {
|
||||
loadColumnTypes(tableName, 1, pageSize);
|
||||
loadConstraints(tableName);
|
||||
},
|
||||
[loadColumnTypes, loadConstraints, pageSize, tables],
|
||||
[hasUnsavedChanges, loadColumnTypes, loadConstraints, pageSize, selectedTable, tables],
|
||||
);
|
||||
|
||||
// 입력 타입 변경 - 이전 타입의 설정값 초기화 포함
|
||||
const handleInputTypeChange = useCallback(
|
||||
(columnName: string, newInputType: string) => {
|
||||
// typeFilter 가 활성화된 상태에서 변경된 input_type 이 필터와 불일치하면 자동으로 필터 해제
|
||||
// (그렇지 않으면 사용자가 방금 편집한 행이 그리드에서 갑자기 사라져 혼란)
|
||||
if (typeFilter && typeFilter !== newInputType) {
|
||||
setTypeFilter(null);
|
||||
}
|
||||
setColumns((prev) =>
|
||||
prev.map((col) => {
|
||||
if (col.column_name === columnName) {
|
||||
@@ -604,7 +640,6 @@ export default function TableManagementPage() {
|
||||
};
|
||||
|
||||
finalDetailSettings = JSON.stringify(entitySettings);
|
||||
console.log("🔧 Entity 설정 JSON 생성:", entitySettings);
|
||||
}
|
||||
|
||||
// 🎯 Code 타입인 경우 hierarchyRole을 detailSettings에 포함
|
||||
@@ -624,7 +659,6 @@ export default function TableManagementPage() {
|
||||
};
|
||||
|
||||
finalDetailSettings = JSON.stringify(codeSettings);
|
||||
console.log("🔧 Code 계층 역할 설정 JSON 생성:", codeSettings);
|
||||
}
|
||||
|
||||
const columnSetting = {
|
||||
@@ -642,74 +676,39 @@ export default function TableManagementPage() {
|
||||
|
||||
// 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`, [
|
||||
columnSetting,
|
||||
]);
|
||||
|
||||
if (response.data.success) {
|
||||
console.log("✅ 컬럼 설정 저장 성공");
|
||||
|
||||
// 🆕 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) {
|
||||
// 참조가 아닌 자체 카테고리만 메뉴 매핑 처리
|
||||
console.log("기존 카테고리 메뉴 매핑 삭제 시작:", {
|
||||
tableName: selectedTable,
|
||||
columnName: column.column_name,
|
||||
});
|
||||
|
||||
try {
|
||||
const deleteResponse = await deleteColumnMappingsByColumn(selectedTable, column.column_name);
|
||||
console.log("🗑️ 기존 매핑 삭제 결과:", deleteResponse);
|
||||
await deleteColumnMappingsByColumn(selectedTable, column.column_name);
|
||||
} catch (error) {
|
||||
console.error("❌ 기존 매핑 삭제 실패:", error);
|
||||
}
|
||||
|
||||
// 2. 새로운 매핑 추가 (선택된 메뉴가 있는 경우만)
|
||||
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;
|
||||
let failCount = 0;
|
||||
|
||||
for (const menuObjid of column.category_menus) {
|
||||
try {
|
||||
const mappingResponse = await createColumnMapping({
|
||||
// 직렬 await 대신 Promise.allSettled 로 병렬 호출 (메뉴가 많으면 직렬은 수십 초 멈춤)
|
||||
const mappingResults = await Promise.allSettled(
|
||||
column.category_menus.map((menuObjid) =>
|
||||
createColumnMapping({
|
||||
tableName: selectedTable,
|
||||
logicalColumnName: column.column_name,
|
||||
physicalColumnName: column.column_name,
|
||||
menuObjid,
|
||||
description: `${column.display_name} (메뉴별 카테고리)`,
|
||||
});
|
||||
|
||||
if (mappingResponse.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
console.error("❌ 매핑 생성 실패:", mappingResponse);
|
||||
failCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 메뉴 ${menuObjid}에 대한 매핑 생성 실패:`, error);
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
const successCount = mappingResults.filter(
|
||||
(r) => r.status === "fulfilled" && r.value.success,
|
||||
).length;
|
||||
const failCount = mappingResults.length - successCount;
|
||||
|
||||
if (successCount > 0 && failCount === 0) {
|
||||
toast.success(`컬럼 설정 및 ${successCount}개 메뉴 매핑이 저장되었습니다.`);
|
||||
@@ -728,10 +727,8 @@ export default function TableManagementPage() {
|
||||
// 원본 데이터 업데이트
|
||||
setOriginalColumns((prev) => prev.map((col) => (col.column_name === column.column_name ? column : col)));
|
||||
|
||||
// 저장 후 데이터 확인을 위해 다시 로드
|
||||
setTimeout(() => {
|
||||
loadColumnTypes(selectedTable);
|
||||
}, 1000);
|
||||
// 저장 후 데이터 확인을 위해 다시 로드 (await 로 즉시 reload — race + 깜빡임 회피)
|
||||
await loadColumnTypes(selectedTable);
|
||||
} else {
|
||||
showErrorToast("컬럼 설정 저장에 실패했습니다", response.data.message, {
|
||||
guidance: "입력 데이터를 확인하고 다시 시도해 주세요.",
|
||||
@@ -856,69 +853,39 @@ export default function TableManagementPage() {
|
||||
// 자체 카테고리 컬럼만 메뉴 매핑 처리 (참조 컬럼 제외)
|
||||
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) {
|
||||
let totalSuccessCount = 0;
|
||||
let totalFailCount = 0;
|
||||
|
||||
for (const column of categoryColumns) {
|
||||
// 1. 먼저 기존 매핑 모두 삭제
|
||||
console.log("🗑️ 기존 카테고리 메뉴 매핑 삭제:", {
|
||||
tableName: selectedTable,
|
||||
columnName: column.column_name,
|
||||
});
|
||||
|
||||
try {
|
||||
const deleteResponse = await deleteColumnMappingsByColumn(selectedTable, column.column_name);
|
||||
console.log("🗑️ 기존 매핑 삭제 결과:", deleteResponse);
|
||||
await deleteColumnMappingsByColumn(selectedTable, column.column_name);
|
||||
} catch (error) {
|
||||
console.error("❌ 기존 매핑 삭제 실패:", error);
|
||||
}
|
||||
|
||||
// 2. 새로운 매핑 추가 (선택된 메뉴가 있는 경우만)
|
||||
// 2. 새로운 매핑 추가 (선택된 메뉴가 있는 경우만) — 직렬 await 대신 Promise.allSettled 병렬 호출
|
||||
if (column.category_menus && column.category_menus.length > 0) {
|
||||
for (const menuObjid of column.category_menus) {
|
||||
try {
|
||||
console.log("🔄 매핑 API 호출:", {
|
||||
tableName: selectedTable,
|
||||
columnName: column.column_name,
|
||||
menuObjid,
|
||||
});
|
||||
|
||||
const mappingResponse = await createColumnMapping({
|
||||
const mappingResults = await Promise.allSettled(
|
||||
column.category_menus.map((menuObjid) =>
|
||||
createColumnMapping({
|
||||
tableName: selectedTable,
|
||||
logicalColumnName: column.column_name,
|
||||
physicalColumnName: column.column_name,
|
||||
menuObjid,
|
||||
description: `${column.display_name} (메뉴별 카테고리)`,
|
||||
});
|
||||
|
||||
console.log("✅ 매핑 API 응답:", mappingResponse);
|
||||
|
||||
if (mappingResponse.success) {
|
||||
totalSuccessCount++;
|
||||
} else {
|
||||
console.error("❌ 매핑 생성 실패:", mappingResponse);
|
||||
totalFailCount++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ 메뉴 ${menuObjid}에 대한 매핑 생성 실패:`, error);
|
||||
totalFailCount++;
|
||||
}
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
const colSuccess = mappingResults.filter(
|
||||
(r) => r.status === "fulfilled" && r.value.success,
|
||||
).length;
|
||||
totalSuccessCount += colSuccess;
|
||||
totalFailCount += mappingResults.length - colSuccess;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("📊 전체 매핑 결과:", { totalSuccessCount, totalFailCount });
|
||||
|
||||
if (totalSuccessCount > 0) {
|
||||
toast.success(`테이블 설정 및 ${totalSuccessCount}개 카테고리 메뉴 매핑이 저장되었습니다.`);
|
||||
} else if (totalFailCount > 0) {
|
||||
@@ -936,10 +903,8 @@ export default function TableManagementPage() {
|
||||
// 테이블 목록 새로고침 (라벨 변경 반영)
|
||||
loadTables();
|
||||
|
||||
// 저장 후 데이터 다시 로드
|
||||
setTimeout(() => {
|
||||
loadColumnTypes(selectedTable, 1, pageSize);
|
||||
}, 1000);
|
||||
// 저장 후 데이터 다시 로드 (await 로 즉시 reload — race + 깜빡임 회피)
|
||||
await loadColumnTypes(selectedTable, 1, pageSize);
|
||||
} else {
|
||||
showErrorToast("설정 저장에 실패했습니다", response.data.message, {
|
||||
guidance: "잠시 후 다시 시도해 주세요.",
|
||||
@@ -983,7 +948,20 @@ export default function TableManagementPage() {
|
||||
(table.display_name ?? '').toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
const isKorean = (str: string) => /^[가-힣ㄱ-ㅎ]/.test(str);
|
||||
const q = searchTerm.trim().toLowerCase();
|
||||
// 검색 매치 강도: 0=정확, 1=시작, 2=포함 — 낮을수록 위
|
||||
const matchScore = (t: typeof tables[number]) => {
|
||||
if (!q) return 0;
|
||||
const tn = (t.table_name ?? "").toLowerCase();
|
||||
const dn = (t.display_name ?? "").toLowerCase();
|
||||
if (tn === q || dn === q) return 0;
|
||||
if (tn.startsWith(q) || dn.startsWith(q)) return 1;
|
||||
return 2;
|
||||
};
|
||||
return filtered.sort((a, b) => {
|
||||
const sa = matchScore(a);
|
||||
const sb = matchScore(b);
|
||||
if (sa !== sb) return sa - sb;
|
||||
const nameA = a.display_name || a.table_name;
|
||||
const nameB = b.display_name || b.table_name;
|
||||
const aKo = isKorean(nameA);
|
||||
@@ -1037,24 +1015,28 @@ export default function TableManagementPage() {
|
||||
} else {
|
||||
newPkCols = currentPkCols.filter((c) => c !== columnName);
|
||||
}
|
||||
// 이번 세션 동안 묻지 않기로 한 경우 즉시 적용
|
||||
if (pkSkipConfirmSession) {
|
||||
applyPkChange(newPkCols);
|
||||
return;
|
||||
}
|
||||
// PK 변경은 확인 다이얼로그 표시
|
||||
setPendingPkColumns(newPkCols);
|
||||
setPkDialogOpen(true);
|
||||
},
|
||||
[constraints.primaryKey?.columns],
|
||||
[constraints.primaryKey?.columns, pkSkipConfirmSession],
|
||||
);
|
||||
|
||||
// PK 변경 확인
|
||||
const handlePkConfirm = async () => {
|
||||
// PK 변경 실제 적용 (다이얼로그 거치지 않거나 거친 후 호출)
|
||||
const applyPkChange = async (newPkCols: string[]) => {
|
||||
if (!selectedTable) return;
|
||||
try {
|
||||
if (pendingPkColumns.length === 0) {
|
||||
if (newPkCols.length === 0) {
|
||||
toast.error("PK 컬럼을 최소 1개 이상 선택해야 합니다.");
|
||||
setPkDialogOpen(false);
|
||||
return;
|
||||
}
|
||||
const response = await apiClient.put(`/table-management/tables/${selectedTable}/primary-key`, {
|
||||
columns: pendingPkColumns,
|
||||
columns: newPkCols,
|
||||
});
|
||||
if (response.data.success) {
|
||||
toast.success(response.data.message);
|
||||
@@ -1066,11 +1048,15 @@ export default function TableManagementPage() {
|
||||
showErrorToast("PK 설정에 실패했습니다", error, {
|
||||
guidance: "컬럼 정보를 확인하고 다시 시도해 주세요.",
|
||||
});
|
||||
} finally {
|
||||
setPkDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// PK 변경 확인 (다이얼로그에서 호출)
|
||||
const handlePkConfirm = async () => {
|
||||
setPkDialogOpen(false);
|
||||
await applyPkChange(pendingPkColumns);
|
||||
};
|
||||
|
||||
// 인덱스 토글 핸들러 (일반 인덱스만 DB 레벨 - 유니크는 앱 레벨 소프트 제약조건으로 분리됨)
|
||||
const handleIndexToggle = useCallback(
|
||||
async (columnName: string, indexType: "index", checked: boolean) => {
|
||||
@@ -1187,6 +1173,37 @@ export default function TableManagementPage() {
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 컬럼 삭제 (DBeaver 방식 — FK 참조 있으면 Postgres 가 거부)
|
||||
const handleDeleteColumnClick = (columnName: string) => {
|
||||
setColumnToDelete(columnName);
|
||||
setDeleteColumnDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteColumn = async () => {
|
||||
if (!selectedTable || !columnToDelete) return;
|
||||
setIsDeletingColumn(true);
|
||||
try {
|
||||
const result = await ddlApi.dropColumn(selectedTable, columnToDelete);
|
||||
if (result.success) {
|
||||
toast.success(`컬럼 '${columnToDelete}'이 삭제되었습니다.`);
|
||||
if (selectedColumn === columnToDelete) setSelectedColumn(null);
|
||||
await loadColumnTypes(selectedTable);
|
||||
} else {
|
||||
showErrorToast("컬럼 삭제에 실패했습니다", result.message, {
|
||||
guidance: "다른 테이블에서 외래키로 참조 중이거나 종속 객체가 있는지 확인해 주세요.",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast("컬럼 삭제에 실패했습니다", error, {
|
||||
guidance: "다른 테이블에서 외래키로 참조 중이거나 종속 객체가 있는지 확인해 주세요.",
|
||||
});
|
||||
} finally {
|
||||
setIsDeletingColumn(false);
|
||||
setDeleteColumnDialogOpen(false);
|
||||
setColumnToDelete("");
|
||||
}
|
||||
};
|
||||
|
||||
// 테이블 삭제 실행
|
||||
const handleDeleteTable = async () => {
|
||||
if (!tableToDelete) return;
|
||||
@@ -1385,8 +1402,8 @@ export default function TableManagementPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3패널 메인 */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* 메인 (우측 패널은 overlay 라 2패널 layout) */}
|
||||
<div className="relative flex flex-1 overflow-hidden">
|
||||
{/* 좌측: 테이블 목록 (240px) */}
|
||||
<div className="bg-card flex w-[280px] min-w-[280px] flex-shrink-0 flex-col border-r">
|
||||
{/* 검색 */}
|
||||
@@ -1401,7 +1418,7 @@ export default function TableManagementPage() {
|
||||
/>
|
||||
</div>
|
||||
{isSuperAdmin && (
|
||||
<div className="mt-2 flex items-center justify-between border-b pb-2">
|
||||
<div className="mt-2 flex min-h-9 items-center justify-between border-b pb-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
checked={
|
||||
@@ -1458,7 +1475,7 @@ export default function TableManagementPage() {
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex items-center gap-2 rounded-md px-2.5 py-[7px] transition-colors",
|
||||
"group relative flex items-center gap-2 rounded-md px-2.5 py-1.5 transition-colors",
|
||||
isActive
|
||||
? "bg-accent text-foreground"
|
||||
: "text-foreground/80 hover:bg-accent/50",
|
||||
@@ -1488,13 +1505,13 @@ export default function TableManagementPage() {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className={cn(
|
||||
"truncate text-[16px] leading-tight",
|
||||
"truncate text-[13px] leading-tight",
|
||||
isActive ? "font-bold" : "font-medium",
|
||||
)}>
|
||||
{table.display_name || table.table_name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground truncate font-mono text-[12px] leading-tight tracking-tight">
|
||||
<div className="text-muted-foreground truncate font-mono text-[10.5px] leading-tight tracking-tight">
|
||||
{table.table_name}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1551,26 +1568,24 @@ export default function TableManagementPage() {
|
||||
className="h-7 -mx-2 px-2 text-[15px] font-bold tracking-tight"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
setEditingHeaderValue(tableLabel);
|
||||
setEditingHeaderField("label");
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
<div className="group flex items-center gap-1.5">
|
||||
<span className="text-[15px] font-bold tracking-tight">
|
||||
{tableLabel || (
|
||||
<span className="text-muted-foreground/60">{selectedTable}</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingHeaderValue(tableLabel);
|
||||
setEditingHeaderField("label");
|
||||
}
|
||||
}}
|
||||
className="-mx-2 cursor-text rounded px-2 py-0.5 text-[15px] font-bold tracking-tight hover:bg-muted/60 transition-colors"
|
||||
title="클릭하여 표시명 편집"
|
||||
>
|
||||
{tableLabel || (
|
||||
<span className="text-muted-foreground/60">{selectedTable}</span>
|
||||
)}
|
||||
}}
|
||||
className="text-muted-foreground/50 hover:text-foreground transition-colors"
|
||||
title="표시명 편집"
|
||||
aria-label="표시명 편집"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* table_name (코드, 편집 불가) */}
|
||||
@@ -1596,26 +1611,24 @@ export default function TableManagementPage() {
|
||||
className="mt-1 h-7 -mx-2 px-2 text-xs"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
setEditingHeaderValue(tableDescription);
|
||||
setEditingHeaderField("description");
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
<div className="group mt-0.5 flex items-center gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{tableDescription || (
|
||||
<span className="text-muted-foreground/50">+ 설명 추가</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setEditingHeaderValue(tableDescription);
|
||||
setEditingHeaderField("description");
|
||||
}
|
||||
}}
|
||||
className="-mx-2 mt-0.5 cursor-text rounded px-2 py-0.5 text-xs text-muted-foreground hover:bg-muted/60 transition-colors"
|
||||
title="클릭하여 설명 편집"
|
||||
>
|
||||
{tableDescription || (
|
||||
<span className="text-muted-foreground/50">+ 설명 추가</span>
|
||||
)}
|
||||
}}
|
||||
className="text-muted-foreground/50 hover:text-foreground transition-colors"
|
||||
title="설명 편집"
|
||||
aria-label="설명 편집"
|
||||
>
|
||||
<Pencil className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1646,54 +1659,120 @@ export default function TableManagementPage() {
|
||||
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<TypeOverviewStrip
|
||||
columns={columns}
|
||||
activeFilter={typeFilter}
|
||||
onFilterChange={setTypeFilter}
|
||||
/>
|
||||
<ColumnGrid
|
||||
columns={columns}
|
||||
selectedColumn={selectedColumn}
|
||||
onSelectColumn={setSelectedColumn}
|
||||
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");
|
||||
<Tabs defaultValue="columns" className="flex min-h-0 flex-1 flex-col">
|
||||
<TabsList
|
||||
className={cn(
|
||||
"h-9 w-full shrink-0 justify-start gap-1 rounded-none bg-transparent p-0 px-2 pt-1",
|
||||
"border-b border-border",
|
||||
)}
|
||||
>
|
||||
<TabsTrigger
|
||||
value="columns"
|
||||
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))]",
|
||||
)}
|
||||
>
|
||||
<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 (currentColumn) {
|
||||
handleNullableToggle(columnName, currentColumn.is_nullable || "YES");
|
||||
if (field === "is_nullable") {
|
||||
const currentColumn = columns.find((c) => c.column_name === columnName);
|
||||
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);
|
||||
if (idx >= 0) handleColumnChange(idx, field, value);
|
||||
}}
|
||||
constraints={constraints}
|
||||
typeFilter={typeFilter}
|
||||
getColumnIndexState={getColumnIndexState}
|
||||
onPkToggle={handlePkToggle}
|
||||
onIndexToggle={(columnName, checked) =>
|
||||
handleIndexToggle(columnName, "index", checked)
|
||||
}
|
||||
tables={tables}
|
||||
referenceTableColumns={referenceTableColumns}
|
||||
/>
|
||||
</>
|
||||
onDeleteColumn={handleDeleteColumnClick}
|
||||
tables={tables}
|
||||
referenceTableColumns={referenceTableColumns}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="references" className="mt-0 flex min-h-0 flex-1 flex-col">
|
||||
<ReferenceListView
|
||||
columns={columns}
|
||||
tables={tables}
|
||||
referenceTableColumns={referenceTableColumns}
|
||||
selectedColumn={selectedColumn}
|
||||
onSelectColumn={(c) => setSelectedColumn((prev) => (prev === c ? null : c))}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 우측: 상세 패널 (selectedColumn 있을 때만) */}
|
||||
{selectedColumn && (
|
||||
<div className="w-[380px] min-w-[380px] flex-shrink-0 overflow-hidden">
|
||||
<ColumnDetailPanel
|
||||
{/* 우측: 상세 패널
|
||||
- 와이드 모니터 (xl 이상): 항상 보이는 고정 3-pane
|
||||
- 좁은 화면: 기존처럼 슬라이드 in 오버레이 */}
|
||||
<div
|
||||
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",
|
||||
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
|
||||
column={columns.find((c) => c.column_name === selectedColumn) ?? null}
|
||||
tables={tables}
|
||||
referenceTableColumns={referenceTableColumns}
|
||||
@@ -1705,6 +1784,21 @@ export default function TableManagementPage() {
|
||||
handleInputTypeChange(selectedColumn, value as string);
|
||||
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) {
|
||||
loadReferenceTableColumns(value as string);
|
||||
}
|
||||
@@ -1719,8 +1813,7 @@ export default function TableManagementPage() {
|
||||
codeInfoOptions={commonCodeOptions}
|
||||
referenceTableOptions={referenceTableOptions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DDL 모달 컴포넌트들 */}
|
||||
@@ -1755,13 +1848,13 @@ export default function TableManagementPage() {
|
||||
setDuplicateSourceTable(null);
|
||||
}}
|
||||
mode={duplicateModalMode}
|
||||
sourceTableName={duplicateSourceTable || undefined}
|
||||
source_table_name={duplicateSourceTable || undefined}
|
||||
/>
|
||||
|
||||
<AddColumnModal
|
||||
isOpen={addColumnModalOpen}
|
||||
onClose={() => setAddColumnModalOpen(false)}
|
||||
tableName={selectedTable || ""}
|
||||
table_name={selectedTable || ""}
|
||||
onSuccess={async (result) => {
|
||||
toast.success("컬럼이 성공적으로 추가되었습니다!");
|
||||
// 테이블 목록 새로고침 (컬럼 수 업데이트)
|
||||
@@ -1863,6 +1956,62 @@ export default function TableManagementPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 컬럼 삭제 확인 다이얼로그 */}
|
||||
<Dialog open={deleteColumnDialogOpen} onOpenChange={setDeleteColumnDialogOpen}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">컬럼 삭제 확인</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
정말 삭제할까요? 이 작업은 되돌릴 수 없습니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{columnToDelete && (
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
<div className="border-destructive/50 bg-destructive/10 rounded-lg border p-4">
|
||||
<p className="text-destructive text-sm font-semibold">경고</p>
|
||||
<p className="text-destructive/80 mt-1.5 text-sm">
|
||||
<span className="font-mono font-bold">{selectedTable}.{columnToDelete}</span> 컬럼과 해당 컬럼의
|
||||
모든 데이터가 영구적으로 삭제됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setDeleteColumnDialogOpen(false);
|
||||
setColumnToDelete("");
|
||||
}}
|
||||
disabled={isDeletingColumn}
|
||||
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeleteColumn}
|
||||
disabled={isDeletingColumn}
|
||||
className="h-8 flex-1 gap-2 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||
>
|
||||
{isDeletingColumn ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
삭제 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
삭제
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1897,6 +2046,14 @@ export default function TableManagementPage() {
|
||||
<p className="text-destructive mt-2 text-sm">PK가 모두 제거됩니다</p>
|
||||
)}
|
||||
</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>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { isManagementHost } from "@/lib/tenant/subdomain";
|
||||
import { useCompanyManagement } from "@/hooks/useCompanyManagement";
|
||||
import { CompanyToolbar } from "@/components/admin/CompanyToolbar";
|
||||
import { CompanyTable } from "@/components/admin/CompanyTable";
|
||||
@@ -13,6 +16,16 @@ import { ScrollToTop } from "@/components/common/ScrollToTop";
|
||||
* 모든 회사 관리 기능을 통합하여 제공
|
||||
*/
|
||||
export default function CompanyPage() {
|
||||
const router = useRouter();
|
||||
const [hostBlocked, setHostBlocked] = useState(false);
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!isManagementHost(window.location.hostname)) {
|
||||
setHostBlocked(true);
|
||||
router.replace("/main");
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
const {
|
||||
// 데이터
|
||||
companies,
|
||||
@@ -51,6 +64,8 @@ export default function CompanyPage() {
|
||||
clearError,
|
||||
} = useCompanyManagement();
|
||||
|
||||
if (hostBlocked) return null;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-background">
|
||||
<div className="space-y-6 p-6">
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import * as XLSX from "xlsx";
|
||||
import {
|
||||
ArrowDownToLine,
|
||||
ArrowUpToLine,
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
ChevronsUpDown,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileDown,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
FolderTree,
|
||||
@@ -28,6 +31,7 @@ import {
|
||||
Upload,
|
||||
Users,
|
||||
X,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -42,7 +46,9 @@ import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -152,11 +158,15 @@ export default function DeptMngListPage() {
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [pendingDeleteDept, setPendingDeleteDept] = useState<{ code: string; name: string } | null>(null);
|
||||
|
||||
// ── 일괄등록 / 변경이력 모달 ─────────────────────────
|
||||
// ── 일괄등록 / 일괄업데이트 모달 ─────────────────────
|
||||
const [bulkOpen, setBulkOpen] = useState(false);
|
||||
const [bulkText, setBulkText] = useState("");
|
||||
const [bulkUploading, setBulkUploading] = useState(false);
|
||||
const [bulkFailures, setBulkFailures] = useState<{ line: number; deptName: string; reason: string }[]>([]);
|
||||
const [bulkTab, setBulkTab] = useState<"create" | "update">("create");
|
||||
const [bulkUpdateMode, setBulkUpdateMode] = useState<"department" | "manager">("department");
|
||||
const [bulkRows, setBulkRows] = useState<Record<string, any>[]>([]);
|
||||
const [bulkPreviewRows, setBulkPreviewRows] = useState<departmentAPI.BulkPreviewRow[]>([]);
|
||||
const [bulkSelected, setBulkSelected] = useState<Set<number>>(new Set());
|
||||
const [bulkBusy, setBulkBusy] = useState(false);
|
||||
const [bulkFileName, setBulkFileName] = useState<string>("");
|
||||
|
||||
// ── 트리 ⋮ 메뉴: 이동/삭제 대상 ───────────────────────
|
||||
const [moveTargetDept, setMoveTargetDept] = useState<Department | null>(null);
|
||||
@@ -611,6 +621,251 @@ export default function DeptMngListPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────
|
||||
// 일괄등록 / 일괄업데이트 helpers
|
||||
// ─────────────────────────────────────────────────────
|
||||
|
||||
const BULK_HEADERS_CREATE: Record<string, string> = {
|
||||
"부서명": "dept_name",
|
||||
"상위부서코드": "parent_dept_code",
|
||||
"부서유형": "dept_type",
|
||||
"약칭": "short_name",
|
||||
"조직체계": "org_system",
|
||||
"정렬순서": "sort_order",
|
||||
"사용여부": "status",
|
||||
"시작일": "start_date",
|
||||
"종료일": "end_date",
|
||||
"결재관리자": "approval_managers",
|
||||
"부서관리자": "dept_managers",
|
||||
"조직장": "org_leaders",
|
||||
};
|
||||
const BULK_HEADERS_UPDATE_DEPT: Record<string, string> = {
|
||||
"부서코드": "dept_code",
|
||||
"부서명": "dept_name",
|
||||
"상위부서코드": "parent_dept_code",
|
||||
"부서유형": "dept_type",
|
||||
"약칭": "short_name",
|
||||
"조직체계": "org_system",
|
||||
"정렬순서": "sort_order",
|
||||
"사용여부": "status",
|
||||
"시작일": "start_date",
|
||||
"종료일": "end_date",
|
||||
};
|
||||
const BULK_HEADERS_UPDATE_MGR: Record<string, string> = {
|
||||
"부서코드": "dept_code",
|
||||
"결재관리자": "approval_managers",
|
||||
"부서관리자": "dept_managers",
|
||||
"조직장": "org_leaders",
|
||||
};
|
||||
const MANAGER_KEYS = new Set(["approval_managers", "dept_managers", "org_leaders"]);
|
||||
|
||||
const currentHeaderMap = () =>
|
||||
bulkTab === "create"
|
||||
? BULK_HEADERS_CREATE
|
||||
: bulkUpdateMode === "department"
|
||||
? BULK_HEADERS_UPDATE_DEPT
|
||||
: BULK_HEADERS_UPDATE_MGR;
|
||||
|
||||
const currentBulkAction = (): departmentAPI.BulkAction =>
|
||||
bulkTab === "create"
|
||||
? "create"
|
||||
: bulkUpdateMode === "department"
|
||||
? "update_department"
|
||||
: "update_manager";
|
||||
|
||||
const resetBulkData = useCallback(() => {
|
||||
setBulkRows([]);
|
||||
setBulkPreviewRows([]);
|
||||
setBulkSelected(new Set());
|
||||
setBulkFileName("");
|
||||
}, []);
|
||||
|
||||
const openBulkModal = () => {
|
||||
if (!selectedCompanyCode) {
|
||||
toast({ title: "회사를 먼저 선택하세요", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
setBulkTab("create");
|
||||
setBulkUpdateMode("department");
|
||||
resetBulkData();
|
||||
setBulkOpen(true);
|
||||
};
|
||||
|
||||
/** 엑셀 템플릿 다운로드 — action 별 컬럼 다름. 예시 row 1개 포함 */
|
||||
const downloadBulkTemplate = () => {
|
||||
const action = currentBulkAction();
|
||||
const headerMap =
|
||||
action === "create"
|
||||
? BULK_HEADERS_CREATE
|
||||
: action === "update_department"
|
||||
? BULK_HEADERS_UPDATE_DEPT
|
||||
: BULK_HEADERS_UPDATE_MGR;
|
||||
const columns = Object.keys(headerMap);
|
||||
const example: Record<string, any> = {};
|
||||
columns.forEach((c) => {
|
||||
const snake = headerMap[c];
|
||||
if (snake === "dept_name") example[c] = "경영지원본부";
|
||||
else if (snake === "dept_code") example[c] = "DEPT_1";
|
||||
else if (snake === "dept_type") example[c] = "dept";
|
||||
else if (snake === "status") example[c] = "active";
|
||||
else if (snake === "sort_order") example[c] = 10;
|
||||
else if (MANAGER_KEYS.has(snake)) example[c] = action === "update_manager" ? "user001,user002" : "";
|
||||
else example[c] = "";
|
||||
});
|
||||
const ws = XLSX.utils.json_to_sheet([example], { header: columns });
|
||||
ws["!cols"] = columns.map(() => ({ wch: 16 }));
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, "부서");
|
||||
const fileName =
|
||||
action === "create"
|
||||
? "부서_일괄등록_템플릿.xlsx"
|
||||
: action === "update_department"
|
||||
? "부서정보_일괄업데이트_템플릿.xlsx"
|
||||
: "부서관리자_일괄업데이트_템플릿.xlsx";
|
||||
XLSX.writeFile(wb, fileName);
|
||||
};
|
||||
|
||||
/** 업로드된 xlsx → 한글 헤더를 snake_case 로 매핑 + 매니저 필드는 CSV 분해 */
|
||||
const handleBulkFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setBulkFileName(file.name);
|
||||
try {
|
||||
const buf = await file.arrayBuffer();
|
||||
const wb = XLSX.read(buf, { type: "array" });
|
||||
const ws = wb.Sheets[wb.SheetNames[0]];
|
||||
const raw = XLSX.utils.sheet_to_json<Record<string, any>>(ws, { defval: "" });
|
||||
const headerMap = currentHeaderMap();
|
||||
const rows = raw
|
||||
.map((row) => {
|
||||
const out: Record<string, any> = {};
|
||||
for (const [korean, snake] of Object.entries(headerMap)) {
|
||||
const v = row[korean];
|
||||
if (v === undefined || v === null || v === "") continue;
|
||||
if (MANAGER_KEYS.has(snake)) {
|
||||
const ids = String(v).split(/[,;]/).map((s) => s.trim()).filter(Boolean);
|
||||
if (ids.length > 0) out[snake] = ids;
|
||||
} else if (snake === "sort_order") {
|
||||
const n = Number(v);
|
||||
if (!Number.isNaN(n)) out[snake] = n;
|
||||
} else {
|
||||
out[snake] = String(v).trim();
|
||||
}
|
||||
}
|
||||
return out;
|
||||
})
|
||||
.filter((r) => Object.keys(r).length > 0);
|
||||
setBulkRows(rows);
|
||||
setBulkPreviewRows([]);
|
||||
setBulkSelected(new Set());
|
||||
toast({ title: `${rows.length}건 로드됨`, description: "[미리보기] 를 눌러 검증하세요." });
|
||||
} catch (err: any) {
|
||||
toast({ title: "파일 읽기 실패", description: err.message || String(err), variant: "destructive" });
|
||||
} finally {
|
||||
// 동일 파일 재선택 가능하도록
|
||||
e.target.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkPreview = async () => {
|
||||
if (bulkRows.length === 0) return;
|
||||
setBulkBusy(true);
|
||||
try {
|
||||
const res = await departmentAPI.bulkPreviewDepartments(selectedCompanyCode, currentBulkAction(), bulkRows);
|
||||
if (res.success && (res as any).data) {
|
||||
const rows: departmentAPI.BulkPreviewRow[] = (res as any).data.rows;
|
||||
setBulkPreviewRows(rows);
|
||||
// 기본: ok 인 row 만 선택
|
||||
setBulkSelected(new Set(rows.filter((r) => r.result === "ok").map((r) => r.row_index)));
|
||||
} else {
|
||||
toast({
|
||||
title: "미리보기 실패",
|
||||
description: (res as any).error || (res as any).message || "오류",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setBulkBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkApply = async () => {
|
||||
const okSelected = bulkPreviewRows.filter(
|
||||
(r) => bulkSelected.has(r.row_index) && r.result === "ok",
|
||||
);
|
||||
if (okSelected.length === 0) {
|
||||
toast({ title: "반영할 정상 행이 없습니다", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
const payload = okSelected.map((r) => {
|
||||
const { row_index, result, error_detail, ...rest } = r as any;
|
||||
return rest as Record<string, any>;
|
||||
});
|
||||
setBulkBusy(true);
|
||||
try {
|
||||
const res =
|
||||
bulkTab === "create"
|
||||
? await departmentAPI.bulkCreateDepartments(selectedCompanyCode, payload)
|
||||
: await departmentAPI.bulkUpdateDepartments(selectedCompanyCode, bulkUpdateMode, payload);
|
||||
if (res.success) {
|
||||
const count =
|
||||
(res as any).data?.inserted ?? (res as any).data?.updated ?? payload.length;
|
||||
toast({
|
||||
title: bulkTab === "create" ? "일괄등록 완료" : "일괄업데이트 완료",
|
||||
description: `${count}건 처리됨`,
|
||||
});
|
||||
setBulkOpen(false);
|
||||
resetBulkData();
|
||||
await loadDepartments();
|
||||
} else {
|
||||
toast({
|
||||
title: bulkTab === "create" ? "일괄등록 실패" : "일괄업데이트 실패",
|
||||
description: (res as any).error || (res as any).message || "오류",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setBulkBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const previewColumns = useMemo(() => {
|
||||
if (bulkTab === "create") {
|
||||
return [
|
||||
{ key: "dept_name", label: "부서명" },
|
||||
{ key: "parent_dept_code", label: "상위부서코드" },
|
||||
{ key: "dept_type", label: "유형" },
|
||||
{ key: "sort_order", label: "순서" },
|
||||
{ key: "approval_managers", label: "결재관리자", manager: true },
|
||||
{ key: "dept_managers", label: "부서관리자", manager: true },
|
||||
{ key: "org_leaders", label: "조직장", manager: true },
|
||||
];
|
||||
}
|
||||
if (bulkUpdateMode === "department") {
|
||||
return [
|
||||
{ key: "dept_code", label: "부서코드" },
|
||||
{ key: "dept_name", label: "부서명" },
|
||||
{ key: "parent_dept_code", label: "상위부서코드" },
|
||||
{ key: "dept_type", label: "유형" },
|
||||
{ key: "sort_order", label: "순서" },
|
||||
];
|
||||
}
|
||||
return [
|
||||
{ key: "dept_code", label: "부서코드" },
|
||||
{ key: "approval_managers", label: "결재관리자", manager: true },
|
||||
{ key: "dept_managers", label: "부서관리자", manager: true },
|
||||
{ key: "org_leaders", label: "조직장", manager: true },
|
||||
];
|
||||
}, [bulkTab, bulkUpdateMode]);
|
||||
|
||||
const bulkOkCount = bulkPreviewRows.filter((r) => r.result === "ok").length;
|
||||
const bulkErrCount = bulkPreviewRows.length - bulkOkCount;
|
||||
const allOkSelected =
|
||||
bulkOkCount > 0 &&
|
||||
bulkPreviewRows
|
||||
.filter((r) => r.result === "ok")
|
||||
.every((r) => bulkSelected.has(r.row_index));
|
||||
|
||||
const isDirty = originalDraft
|
||||
? JSON.stringify(originalDraft) !== JSON.stringify(draft)
|
||||
: isNewMode && (draft.dept_name.trim() !== "" || draft.parent_dept_code !== null);
|
||||
@@ -636,14 +891,7 @@ export default function DeptMngListPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 text-xs"
|
||||
onClick={() => {
|
||||
if (!selectedCompanyCode) {
|
||||
toast({ title: "회사를 먼저 선택하세요", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
setBulkText("");
|
||||
setBulkOpen(true);
|
||||
}}
|
||||
onClick={openBulkModal}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
일괄등록
|
||||
@@ -1013,106 +1261,229 @@ export default function DeptMngListPage() {
|
||||
title={moveTargetDept ? `"${moveTargetDept.dept_name}" — 새 상위 부서 선택` : "부서 선택"}
|
||||
/>
|
||||
|
||||
{/* 일괄등록 */}
|
||||
{/* 일괄등록 / 일괄업데이트 */}
|
||||
<Dialog open={bulkOpen} onOpenChange={setBulkOpen}>
|
||||
<DialogContent className="max-w-[640px]">
|
||||
<DialogContent className="flex max-h-[88vh] max-w-[1040px] flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>부서 일괄등록</DialogTitle>
|
||||
<DialogTitle>부서 일괄등록 / 일괄업데이트</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-md border bg-muted/30 p-3 text-xs leading-relaxed">
|
||||
<p className="mb-1.5 font-semibold">CSV 형식으로 한 줄에 하나씩 입력하세요</p>
|
||||
<p className="text-muted-foreground">
|
||||
형식: <code className="rounded bg-background px-1 py-0.5 font-mono">부서명,상위부서코드,부서유형(dept|team|temp)</code>
|
||||
</p>
|
||||
<p className="mt-1 text-muted-foreground">부서코드는 저장 시 자동 부여됩니다 (DEPT_n).</p>
|
||||
<p className="mt-1 text-muted-foreground">예시: <code className="rounded bg-background px-1 py-0.5 font-mono">경영지원본부,,dept</code></p>
|
||||
</div>
|
||||
<textarea
|
||||
value={bulkText}
|
||||
onChange={(e) => setBulkText(e.target.value)}
|
||||
placeholder={"경영지원본부,,dept\n인사팀,DEPT_1,team"}
|
||||
className="h-48 w-full resize-none rounded-md border bg-background p-2 font-mono text-xs focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setBulkOpen(false)}>취소</Button>
|
||||
<Button
|
||||
disabled={bulkUploading || !bulkText.trim()}
|
||||
onClick={async () => {
|
||||
const lines = bulkText.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
||||
if (lines.length === 0) return;
|
||||
setBulkUploading(true);
|
||||
const failures: { line: number; deptName: string; reason: string }[] = [];
|
||||
let success = 0;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const cols = line.split(",").map((c) => c.trim());
|
||||
const [dept_name, parent, dept_type] = cols;
|
||||
if (!dept_name) {
|
||||
failures.push({ line: i + 1, deptName: "(빈 줄)", reason: "부서명 필수" });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const res = await departmentAPI.createDepartment(selectedCompanyCode, {
|
||||
dept_name,
|
||||
parent_dept_code: parent || null,
|
||||
dept_type: (dept_type || "dept") as any,
|
||||
} as any);
|
||||
if (res.success) success++;
|
||||
else failures.push({ line: i + 1, deptName: dept_name, reason: (res as any).error || "알 수 없는 오류" });
|
||||
} catch (e: any) {
|
||||
failures.push({ line: i + 1, deptName: dept_name, reason: e?.message || "예외 발생" });
|
||||
}
|
||||
}
|
||||
setBulkUploading(false);
|
||||
toast({
|
||||
title: `일괄등록 완료`,
|
||||
description: `성공 ${success}건 / 실패 ${failures.length}건`,
|
||||
variant: failures.length > 0 ? "destructive" : "default",
|
||||
});
|
||||
if (failures.length > 0) {
|
||||
setBulkFailures(failures);
|
||||
} else {
|
||||
setBulkOpen(false);
|
||||
}
|
||||
await loadDepartments();
|
||||
}}
|
||||
>
|
||||
{bulkUploading ? "등록 중..." : "등록"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 일괄등록 실패 결과 */}
|
||||
<Dialog open={bulkFailures.length > 0} onOpenChange={(o) => !o && setBulkFailures([])}>
|
||||
<DialogContent className="max-w-[640px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>일괄등록 실패 항목 ({bulkFailures.length}건)</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[480px] overflow-y-auto rounded-md border bg-muted/30">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-muted/50 sticky top-0">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-semibold w-16">라인</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">부서명</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">사유</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{bulkFailures.map((f, idx) => (
|
||||
<tr key={idx}>
|
||||
<td className="px-3 py-1.5 font-mono">{f.line}</td>
|
||||
<td className="px-3 py-1.5">{f.deptName}</td>
|
||||
<td className="px-3 py-1.5 text-destructive">{f.reason}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => { setBulkFailures([]); setBulkOpen(false); }}>닫기</Button>
|
||||
<Tabs
|
||||
value={bulkTab}
|
||||
onValueChange={(v) => {
|
||||
setBulkTab(v as "create" | "update");
|
||||
resetBulkData();
|
||||
}}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<TabsList className="mb-2">
|
||||
<TabsTrigger value="create">일괄등록</TabsTrigger>
|
||||
<TabsTrigger value="update">일괄업데이트</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="create" className="m-0 space-y-2">
|
||||
<div className="rounded-md border bg-muted/30 p-3 text-xs leading-relaxed">
|
||||
<p className="mb-1 font-semibold">신규 조직도를 일괄 등록합니다</p>
|
||||
<ul className="list-inside list-disc space-y-0.5 text-muted-foreground">
|
||||
<li>[엑셀 템플릿] 을 다운로드해 작성 후 업로드하세요.</li>
|
||||
<li>업로드 → [미리보기] 로 검증 → 정상 행만 선택해 [반영].</li>
|
||||
<li>부서코드는 저장 시 자동 부여됩니다 (DEPT_n).</li>
|
||||
<li>관리자 컬럼은 user_id 를 쉼표 (,) 로 구분해 입력하세요. 최대 10명/role.</li>
|
||||
<li>한 번에 최대 1000건까지 처리 가능.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="update" className="m-0 space-y-2">
|
||||
<div className="rounded-md border bg-muted/30 p-3 text-xs leading-relaxed">
|
||||
<p className="mb-1 font-semibold">기존 부서 정보 또는 관리자 매핑을 일괄 수정합니다</p>
|
||||
<ul className="list-inside list-disc space-y-0.5 text-muted-foreground">
|
||||
<li>각 행에 <code className="rounded bg-background px-1 font-mono">부서코드(dept_code)</code> 필수.</li>
|
||||
<li><b>부서 정보</b>: 부서명/유형/순서/날짜 등 변경. 빈 셀은 기존값 유지.</li>
|
||||
<li><b>관리자 정보</b>: 결재/부서/조직장 매핑만 변경. 명시된 role 만 동기화.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 px-1">
|
||||
<Label className="text-xs font-semibold">업로드 항목 선택</Label>
|
||||
<RadioGroup
|
||||
value={bulkUpdateMode}
|
||||
onValueChange={(v) => {
|
||||
setBulkUpdateMode(v as "department" | "manager");
|
||||
resetBulkData();
|
||||
}}
|
||||
className="flex items-center gap-4"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RadioGroupItem value="department" id="bulk-mode-dept" className="h-3.5 w-3.5" />
|
||||
<Label htmlFor="bulk-mode-dept" className="cursor-pointer text-xs">부서 정보</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RadioGroupItem value="manager" id="bulk-mode-mgr" className="h-3.5 w-3.5" />
|
||||
<Label htmlFor="bulk-mode-mgr" className="cursor-pointer text-xs">관리자 정보</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 회사 + 파일 선택 (탭 공통) */}
|
||||
<div className="mt-2 space-y-2 rounded-md border p-3">
|
||||
<div className="grid grid-cols-[100px_1fr_auto] items-center gap-3">
|
||||
<Label className="text-xs font-semibold">회사 선택</Label>
|
||||
<div className="text-xs">
|
||||
<span className="font-mono">{selectedCompanyCode}</span>
|
||||
{selectedCompany?.company_name && (
|
||||
<span className="ml-2 text-muted-foreground">{selectedCompany.company_name}</span>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="h-7 gap-1.5 text-xs" onClick={downloadBulkTemplate}>
|
||||
<FileDown className="h-3.5 w-3.5" />
|
||||
엑셀 템플릿
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-[100px_1fr] items-center gap-3">
|
||||
<Label className="text-xs font-semibold">파일 선택</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="file"
|
||||
accept=".xlsx,.xls,.csv"
|
||||
onChange={handleBulkFile}
|
||||
className="h-8 cursor-pointer text-xs file:mr-2 file:rounded file:border-0 file:bg-muted file:px-2 file:py-1 file:text-xs"
|
||||
/>
|
||||
{bulkFileName && (
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{bulkRows.length}건 로드됨
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 미리보기 테이블 */}
|
||||
<div className="mt-3 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border">
|
||||
<div className="flex items-center justify-between bg-muted/40 px-3 py-1.5 text-xs">
|
||||
<span className="font-semibold">
|
||||
반영할 부서 목록 ({bulkSelected.size}/{bulkPreviewRows.length})
|
||||
</span>
|
||||
{bulkPreviewRows.length > 0 && (
|
||||
<span className="text-muted-foreground">
|
||||
<span className="text-emerald-600 dark:text-emerald-400">정상 {bulkOkCount}건</span>
|
||||
{" / "}
|
||||
<span className="text-destructive">오류 {bulkErrCount}건</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-h-[200px] flex-1 overflow-auto">
|
||||
{bulkPreviewRows.length === 0 ? (
|
||||
<div className="flex h-full min-h-[200px] flex-col items-center justify-center gap-2 text-xs text-muted-foreground">
|
||||
<FileDown className="h-6 w-6 opacity-30" />
|
||||
<p>{bulkRows.length === 0 ? "엑셀 파일을 업로드하세요" : "[미리보기] 버튼을 눌러 검증하세요"}</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 z-10 bg-muted/60">
|
||||
<tr>
|
||||
<th className="w-9 px-2 py-1.5">
|
||||
<Checkbox
|
||||
checked={allOkSelected}
|
||||
onCheckedChange={(c) => {
|
||||
if (c) {
|
||||
setBulkSelected(
|
||||
new Set(
|
||||
bulkPreviewRows.filter((r) => r.result === "ok").map((r) => r.row_index),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setBulkSelected(new Set());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</th>
|
||||
{previewColumns.map((c) => (
|
||||
<th key={c.key} className="px-2 py-1.5 text-left font-semibold">{c.label}</th>
|
||||
))}
|
||||
<th className="w-16 px-2 py-1.5 text-left font-semibold">결과</th>
|
||||
<th className="px-2 py-1.5 text-left font-semibold">오류상세</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{bulkPreviewRows.map((r) => {
|
||||
const isErr = r.result === "error";
|
||||
return (
|
||||
<tr
|
||||
key={r.row_index}
|
||||
className={cn("hover:bg-muted/30", isErr && "bg-destructive/5")}
|
||||
>
|
||||
<td className="px-2 py-1.5">
|
||||
<Checkbox
|
||||
disabled={isErr}
|
||||
checked={bulkSelected.has(r.row_index)}
|
||||
onCheckedChange={(c) => {
|
||||
setBulkSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (c) next.add(r.row_index);
|
||||
else next.delete(r.row_index);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
{previewColumns.map((c) => {
|
||||
const v = (r as any)[c.key];
|
||||
const display = (c as any).manager
|
||||
? Array.isArray(v) && v.length > 0 ? v.join(", ") : "-"
|
||||
: v != null && v !== "" ? String(v) : "-";
|
||||
return (
|
||||
<td key={c.key} className="max-w-[180px] truncate px-2 py-1.5" title={display}>
|
||||
{display}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="px-2 py-1.5">
|
||||
{isErr ? (
|
||||
<Badge variant="destructive" className="gap-1 text-[10px]">
|
||||
<XCircle className="h-3 w-3" />
|
||||
오류
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="gap-1 border-emerald-500/30 bg-emerald-500/15 text-[10px] text-emerald-700 hover:bg-emerald-500/20 dark:text-emerald-300">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
정상
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
className="max-w-[300px] truncate px-2 py-1.5 text-destructive"
|
||||
title={r.error_detail || ""}
|
||||
>
|
||||
{r.error_detail || ""}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => setBulkOpen(false)} disabled={bulkBusy}>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleBulkPreview}
|
||||
disabled={bulkBusy || bulkRows.length === 0}
|
||||
>
|
||||
{bulkBusy ? "검증 중..." : "미리보기"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleBulkApply}
|
||||
disabled={bulkBusy || bulkSelected.size === 0}
|
||||
>
|
||||
{bulkBusy ? "처리 중..." : `반영 (${bulkSelected.size}건)`}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { LayerDefinition } from "@/types/screen-management";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||
import { initializeComponents } from "@/lib/registry/components";
|
||||
import { isTableLikeComponent } from "@/lib/utils/componentTypeUtils";
|
||||
import { EditModal } from "@/components/screen/EditModal";
|
||||
import { RealtimePreview } from "@/components/screen/RealtimePreviewDynamic";
|
||||
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
|
||||
@@ -428,10 +429,8 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
|
||||
}
|
||||
|
||||
// 테이블 위젯이 있으면 자동 로드 건너뜀 (테이블 행 선택으로 데이터 로드)
|
||||
const hasTableWidget = layout.components.some(
|
||||
(comp: any) =>
|
||||
comp.componentType === "table-list" || comp.componentType === "v2-table-list" || comp.widgetType === "table",
|
||||
);
|
||||
// canonical table / legacy table-list / hidden v2-table-list / widgetType=table 모두 동일하게 skip
|
||||
const hasTableWidget = layout.components.some((comp: any) => isTableLikeComponent(comp));
|
||||
|
||||
if (hasTableWidget) {
|
||||
return;
|
||||
|
||||
@@ -92,10 +92,10 @@ export default function TestCardResponsivePage() {
|
||||
{/* ── 1. v2-text-display (경량, 항상 동일) ── */}
|
||||
<div className="mb-2 text-base font-semibold text-slate-800">수주관리</div>
|
||||
|
||||
{/* ── 2. v2-aggregation-widget (경량, container-type 만 부착) ── */}
|
||||
{/* ── 2. canonical stats (경량, container-type 만 부착) ── */}
|
||||
<div
|
||||
className="mb-3 grid grid-cols-4 gap-2 rounded border border-slate-200 bg-white p-2"
|
||||
style={{ containerType: "inline-size", containerName: "v2-aggregation-widget" }}
|
||||
style={{ containerType: "inline-size", containerName: "stats" }}
|
||||
>
|
||||
{[
|
||||
{ label: "전체", v: "128" },
|
||||
@@ -214,7 +214,7 @@ export default function TestCardResponsivePage() {
|
||||
같은 조건에서 <b className="text-indigo-600">v2-table-search-widget</b> 의 필터/버튼이 가로 → 세로 스택으로 재배열 (CSS @container 기반).
|
||||
</li>
|
||||
<li>
|
||||
나머지 컴포넌트(text-display, aggregation-widget, button-primary)는 <b>container-type: inline-size</b> 만 부착된 상태.
|
||||
나머지 컴포넌트(text-display, stats, button-primary)는 <b>container-type: inline-size</b> 만 부착된 상태.
|
||||
모드 분기는 Phase 2 에서 개별 재작성.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -19,8 +19,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Loader2, Info, AlertCircle, CheckCircle2, Plus, Activity } from "lucide-react";
|
||||
import { Loader2, Info, AlertCircle, CheckCircle2, Plus } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { ColumnDefinitionTable } from "./ColumnDefinitionTable";
|
||||
import { ddlApi } from "../../lib/api/ddl";
|
||||
@@ -57,8 +56,6 @@ export function CreateTableModal({
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [tableNameError, setTableNameError] = useState("");
|
||||
const [validationResult, setValidationResult] = useState<any>(null);
|
||||
const [useLogTable, setUseLogTable] = useState(false);
|
||||
|
||||
/**
|
||||
* 모달 리셋
|
||||
*/
|
||||
@@ -76,7 +73,6 @@ export function CreateTableModal({
|
||||
]);
|
||||
setTableNameError("");
|
||||
setValidationResult(null);
|
||||
setUseLogTable(false);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -107,15 +103,11 @@ export function CreateTableModal({
|
||||
try {
|
||||
// 1. 테이블 컬럼 정보 조회
|
||||
const columnsResponse = await tableManagementApi.getColumnList(tableName);
|
||||
|
||||
console.log("🔍 컬럼 조회 응답:", columnsResponse);
|
||||
|
||||
|
||||
if (columnsResponse.success && columnsResponse.data) {
|
||||
// API는 { columns, total, page, size } 형태로 반환
|
||||
const columnsList = columnsResponse.data.columns;
|
||||
|
||||
console.log("🔍 컬럼 리스트:", columnsList);
|
||||
|
||||
|
||||
if (columnsList && columnsList.length > 0) {
|
||||
// 첫 번째 컬럼에서 테이블 설명 가져오기 (모든 컬럼이 같은 테이블 설명을 가짐)
|
||||
const firstColumn = columnsList[0];
|
||||
@@ -285,23 +277,6 @@ export function CreateTableModal({
|
||||
|
||||
if (result.success) {
|
||||
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);
|
||||
onClose();
|
||||
} else {
|
||||
@@ -322,7 +297,7 @@ export function CreateTableModal({
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-h-[90vh] max-w-6xl overflow-hidden">
|
||||
<DialogContent className="flex max-h-[90vh] max-w-6xl flex-col overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Plus className="h-5 w-5" />
|
||||
@@ -336,7 +311,7 @@ export function CreateTableModal({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex-1 space-y-6 overflow-y-auto pr-1">
|
||||
{/* 테이블 기본 정보 */}
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
@@ -380,29 +355,6 @@ export function CreateTableModal({
|
||||
<ColumnDefinitionTable columns={columns} onChange={setColumns} disabled={loading} />
|
||||
</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>
|
||||
<Info className="h-4 w-4" />
|
||||
|
||||
@@ -312,14 +312,14 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-h-[90vh] max-w-[95vw] overflow-hidden sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogContent className="flex max-h-[90vh] max-w-[95vw] flex-col overflow-hidden sm:max-w-2xl">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
{isEditMode ? "연결 정보 수정" : "새 외부 DB 연결 추가"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
<div className="-mr-1 min-h-0 flex-1 space-y-3 overflow-y-auto pr-1 sm:space-y-4">
|
||||
{/* 기본 정보 */}
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
<h3 className="text-sm font-semibold sm:text-base">기본 정보</h3>
|
||||
@@ -607,7 +607,7 @@ export const ExternalDbConnectionModal: React.FC<ExternalDbConnectionModalProps>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<DialogFooter className="shrink-0 gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
|
||||
@@ -219,27 +219,27 @@ export function RestApiConnectionList() {
|
||||
return (
|
||||
<>
|
||||
{/* 검색 및 필터 */}
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center">
|
||||
<div className="flex shrink-0 flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
{/* 검색 */}
|
||||
<div className="relative w-full sm:w-[300px]">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||
<div className="relative w-full sm:w-[260px]">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-3 h-3.5 w-3.5 -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="연결명 또는 URL로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-10 pl-10 text-sm"
|
||||
className="h-8 pl-9 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 인증 타입 필터 */}
|
||||
<Select value={authTypeFilter} onValueChange={setAuthTypeFilter}>
|
||||
<SelectTrigger className="h-10 w-full sm:w-[160px]">
|
||||
<SelectTrigger className="h-8 w-full text-xs sm:w-[140px]">
|
||||
<SelectValue placeholder="인증 타입" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{supportedAuthTypes.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
<SelectItem key={type.value} value={type.value} className="text-xs">
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -248,12 +248,12 @@ export function RestApiConnectionList() {
|
||||
|
||||
{/* 활성 상태 필터 */}
|
||||
<Select value={activeStatusFilter} onValueChange={setActiveStatusFilter}>
|
||||
<SelectTrigger className="h-10 w-full sm:w-[120px]">
|
||||
<SelectTrigger className="h-8 w-full text-xs sm:w-[110px]">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACTIVE_STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<SelectItem key={option.value} value={option.value} className="text-xs">
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -262,79 +262,79 @@ export function RestApiConnectionList() {
|
||||
</div>
|
||||
|
||||
{/* 추가 버튼 */}
|
||||
<Button onClick={handleAddConnection} className="h-10 gap-2 text-sm font-medium">
|
||||
<Plus className="h-4 w-4" />새 연결 추가
|
||||
<Button onClick={handleAddConnection} size="sm" className="h-8 gap-1 text-xs font-medium">
|
||||
<Plus className="h-3.5 w-3.5" />새 연결 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 연결 목록 */}
|
||||
{loading ? (
|
||||
<div className="flex h-64 items-center justify-center bg-card">
|
||||
<div className="text-sm text-muted-foreground">로딩 중...</div>
|
||||
<div className="flex h-40 shrink-0 items-center justify-center rounded-lg border bg-card">
|
||||
<div className="text-xs text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
) : connections.length === 0 ? (
|
||||
<div className="flex h-64 flex-col items-center justify-center bg-card">
|
||||
<div className="flex h-40 shrink-0 flex-col items-center justify-center rounded-lg border bg-card">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<p className="text-sm text-muted-foreground">등록된 REST API 연결이 없습니다</p>
|
||||
<p className="text-xs text-muted-foreground">등록된 REST API 연결이 없습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-background">
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">연결명</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">회사</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">기본 URL</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">인증 타입</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">헤더 수</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">상태</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">마지막 테스트</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-sm font-semibold">연결 테스트</TableHead>
|
||||
<TableHead className="h-12 px-6 py-3 text-right text-sm font-semibold">작업</TableHead>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-lg border bg-card shadow-sm">
|
||||
<Table divClassName="flex-1 overflow-auto">
|
||||
<TableHeader className="sticky top-0 z-10 bg-muted">
|
||||
<TableRow className="border-b bg-muted hover:bg-muted">
|
||||
<TableHead className="h-9 px-3 text-xs font-semibold">연결명</TableHead>
|
||||
<TableHead className="h-9 px-3 text-xs font-semibold">회사</TableHead>
|
||||
<TableHead className="h-9 px-3 text-xs font-semibold">기본 URL</TableHead>
|
||||
<TableHead className="h-9 px-3 text-xs font-semibold">인증 타입</TableHead>
|
||||
<TableHead className="h-9 px-3 text-xs font-semibold">헤더 수</TableHead>
|
||||
<TableHead className="h-9 px-3 text-xs font-semibold">상태</TableHead>
|
||||
<TableHead className="h-9 px-3 text-xs font-semibold">마지막 테스트</TableHead>
|
||||
<TableHead className="h-9 px-3 text-xs font-semibold">연결 테스트</TableHead>
|
||||
<TableHead className="h-9 px-3 text-right text-xs font-semibold">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{connections.map((connection) => (
|
||||
<TableRow key={connection.id} className="bg-background transition-colors hover:bg-muted/50">
|
||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||
<TableRow key={connection.id} className="border-b transition-colors hover:bg-muted/50">
|
||||
<TableCell className="h-10 px-3 text-xs">
|
||||
<div className="max-w-[200px]">
|
||||
<div className="truncate font-medium" title={connection.connection_name}>
|
||||
{connection.connection_name}
|
||||
</div>
|
||||
{connection.description && (
|
||||
<div className="text-muted-foreground mt-1 truncate text-xs" title={connection.description}>
|
||||
<div className="text-muted-foreground mt-0.5 truncate text-[10px]" title={connection.description}>
|
||||
{connection.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||
<TableCell className="h-10 px-3 text-xs">
|
||||
{(connection as any).company_name || connection.company_code}
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 font-mono text-sm">
|
||||
<TableCell className="h-10 px-3 font-mono text-xs">
|
||||
<div className="max-w-[300px] truncate" title={connection.base_url}>
|
||||
{connection.base_url}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||
<Badge variant="outline">{AUTH_TYPE_LABELS[connection.auth_type] || connection.auth_type}</Badge>
|
||||
<TableCell className="h-10 px-3 text-xs">
|
||||
<Badge variant="outline" className="text-[10px]">{AUTH_TYPE_LABELS[connection.auth_type] || connection.auth_type}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 text-center text-sm">
|
||||
<TableCell className="h-10 px-3 text-center text-xs">
|
||||
{Object.keys(connection.default_headers || {}).length}
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||
<Badge variant={connection.is_active === "Y" ? "default" : "secondary"}>
|
||||
<TableCell className="h-10 px-3 text-xs">
|
||||
<Badge variant={connection.is_active === "Y" ? "default" : "secondary"} className="text-[10px]">
|
||||
{connection.is_active === "Y" ? "활성" : "비활성"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||
<TableCell className="h-10 px-3 text-xs">
|
||||
{connection.last_test_date ? (
|
||||
<div>
|
||||
<div>{new Date(connection.last_test_date).toLocaleDateString()}</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span>{new Date(connection.last_test_date).toLocaleDateString()}</span>
|
||||
<Badge
|
||||
variant={connection.last_test_result === "Y" ? "default" : "destructive"}
|
||||
className="mt-1"
|
||||
className="text-[10px]"
|
||||
>
|
||||
{connection.last_test_result === "Y" ? "성공" : "실패"}
|
||||
</Badge>
|
||||
@@ -343,41 +343,41 @@ export function RestApiConnectionList() {
|
||||
<span className="text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<TableCell className="h-10 px-3 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleTestConnection(connection)}
|
||||
disabled={testingConnections.has(connection.id!)}
|
||||
className="h-9 text-sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
>
|
||||
{testingConnections.has(connection.id!) ? "테스트 중..." : "테스트"}
|
||||
</Button>
|
||||
{testResults.has(connection.id!) && (
|
||||
<Badge variant={testResults.get(connection.id!) ? "default" : "destructive"}>
|
||||
<Badge variant={testResults.get(connection.id!) ? "default" : "destructive"} className="text-[10px]">
|
||||
{testResults.get(connection.id!) ? "성공" : "실패"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="h-16 px-6 py-3 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<TableCell className="h-10 px-3 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEditConnection(connection)}
|
||||
className="h-8 w-8"
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteConnection(connection)}
|
||||
className="text-destructive hover:bg-destructive/10 h-8 w-8"
|
||||
className="text-destructive hover:bg-destructive/10 h-7 w-7"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -75,11 +75,9 @@ export function ColumnDetailPanel({
|
||||
return n;
|
||||
}, [column]);
|
||||
|
||||
if (!column) return null;
|
||||
|
||||
const refTableOpts = useMemo(() => {
|
||||
const hasKorean = (s: string) => /[가-힣]/.test(s);
|
||||
const raw = referenceTableOptions.length
|
||||
const rawSource = referenceTableOptions.length
|
||||
? [...referenceTableOptions]
|
||||
: [
|
||||
{ value: "none", label: "없음" },
|
||||
@@ -92,6 +90,14 @@ export function ColumnDetailPanel({
|
||||
})),
|
||||
];
|
||||
|
||||
// value 기준 dedupe — referenceTableOptions/tables 어디서든 중복 들어오면 React key 충돌
|
||||
const seen = new Set<string>();
|
||||
const raw = rawSource.filter((o) => {
|
||||
if (seen.has(o.value)) return false;
|
||||
seen.add(o.value);
|
||||
return true;
|
||||
});
|
||||
|
||||
const noneOpt = raw.find((o) => o.value === "none");
|
||||
const rest = raw.filter((o) => o.value !== "none");
|
||||
|
||||
@@ -106,6 +112,31 @@ export function ColumnDetailPanel({
|
||||
return noneOpt ? [noneOpt, ...rest] : rest;
|
||||
}, [referenceTableOptions, tables]);
|
||||
|
||||
// early return 은 반드시 모든 hook 호출 뒤에 (Rules of Hooks).
|
||||
// 컬럼 선택 안 한 상태에서도 패널이 항상 보이는 와이드 레이아웃 대응 — 빈 상태 안내 UI 표시.
|
||||
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 (
|
||||
<div className="flex h-full w-full flex-col border-l bg-card">
|
||||
{/* 헤더 */}
|
||||
@@ -173,12 +204,12 @@ export function ColumnDetailPanel({
|
||||
isLegacy && "cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"text-base font-bold leading-none",
|
||||
isSelected ? "text-primary" : conf.color,
|
||||
)}>
|
||||
{conf.iconChar}
|
||||
</span>
|
||||
<conf.Icon
|
||||
className={cn(
|
||||
"h-4 w-4",
|
||||
isSelected ? "text-primary" : conf.color,
|
||||
)}
|
||||
/>
|
||||
<span className={cn(
|
||||
"text-[16px] font-semibold leading-tight",
|
||||
isSelected ? "text-primary" : "text-foreground",
|
||||
@@ -372,7 +403,10 @@ export function ColumnDetailPanel({
|
||||
<SelectValue placeholder="코드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{[{ value: "none", label: "선택 안함" }, ...codeInfoOptions].map((opt) => (
|
||||
{[
|
||||
{ value: "none", label: "선택 안함" },
|
||||
...codeInfoOptions.filter((opt) => opt.value !== "none"),
|
||||
].map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { MoreHorizontal, Database, Layers, FileStack } from "lucide-react";
|
||||
import { MoreHorizontal, Database, FileStack, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ColumnTypeInfo, TableInfo } from "./types";
|
||||
import { INPUT_TYPE_COLORS, getColumnGroup } from "./types";
|
||||
@@ -24,6 +30,7 @@ export interface ColumnGridProps {
|
||||
getColumnIndexState?: (columnName: string) => { isPk: boolean; hasIndex: boolean };
|
||||
onPkToggle?: (columnName: string, checked: boolean) => void;
|
||||
onIndexToggle?: (columnName: string, checked: boolean) => void;
|
||||
onDeleteColumn?: (columnName: string) => void;
|
||||
/** 호버 시 한글 라벨 표시용 (Badge title) */
|
||||
tables?: TableInfo[];
|
||||
referenceTableColumns?: Record<string, ReferenceTableColumn[]>;
|
||||
@@ -40,11 +47,10 @@ function getIndexState(
|
||||
return { isPk, hasIndex };
|
||||
}
|
||||
|
||||
/** 그룹 헤더 라벨 */
|
||||
const GROUP_LABELS: Record<string, { icon: React.FC<{ className?: string }>; label: string }> = {
|
||||
basic: { icon: FileStack, label: "기본 정보" },
|
||||
reference: { icon: Layers, label: "참조 정보" },
|
||||
meta: { icon: Database, label: "메타 정보" },
|
||||
/** 그룹 헤더 라벨 — 참조 컬럼은 별도 "참조" 탭에서 보여주므로 컬럼 탭에서는 사용자/시스템 2그룹으로만 분류 */
|
||||
const GROUP_LABELS: Record<"user" | "system", { icon: React.FC<{ className?: string }>; label: string }> = {
|
||||
user: { icon: FileStack, label: "사용자 컬럼" },
|
||||
system: { icon: Database, label: "시스템 컬럼" },
|
||||
};
|
||||
|
||||
export function ColumnGrid({
|
||||
@@ -57,6 +63,7 @@ export function ColumnGrid({
|
||||
getColumnIndexState: externalGetIndexState,
|
||||
onPkToggle,
|
||||
onIndexToggle,
|
||||
onDeleteColumn,
|
||||
tables,
|
||||
referenceTableColumns,
|
||||
}: ColumnGridProps) {
|
||||
@@ -65,30 +72,28 @@ export function ColumnGrid({
|
||||
[constraints, externalGetIndexState],
|
||||
);
|
||||
|
||||
/** typeFilter 적용 후 그룹별로 정렬 */
|
||||
/** typeFilter 적용 후 사용자/시스템 그룹으로 분류 (참조 컬럼은 참조 탭으로 분리됐으므로 사용자 컬럼에 합침) */
|
||||
const filteredAndGrouped = useMemo(() => {
|
||||
const filtered =
|
||||
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) {
|
||||
const group = getColumnGroup(col);
|
||||
groups[group].push(col);
|
||||
const g = getColumnGroup(col) === "meta" ? "system" : "user";
|
||||
groups[g].push(col);
|
||||
}
|
||||
return groups;
|
||||
}, [columns, typeFilter]);
|
||||
|
||||
const totalFiltered =
|
||||
filteredAndGrouped.basic.length + filteredAndGrouped.reference.length + filteredAndGrouped.meta.length;
|
||||
const totalFiltered = filteredAndGrouped.user.length + filteredAndGrouped.system.length;
|
||||
|
||||
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 140px 1fr 100px 160px 40px" }}
|
||||
style={{ gridTemplateColumns: "4px 1fr 100px 160px 40px" }}
|
||||
>
|
||||
<span />
|
||||
<span>라벨 · 컬럼명</span>
|
||||
<span>참조/설정</span>
|
||||
<span>타입</span>
|
||||
<span className="text-center">PK / NN / IDX / UQ</span>
|
||||
<span />
|
||||
@@ -100,7 +105,7 @@ export function ColumnGrid({
|
||||
{typeFilter ? "해당 타입의 컬럼이 없습니다." : "컬럼이 없습니다."}
|
||||
</div>
|
||||
) : (
|
||||
(["basic", "reference", "meta"] as const).map((groupKey) => {
|
||||
(["user", "system"] as const).map((groupKey) => {
|
||||
const list = filteredAndGrouped[groupKey];
|
||||
if (list.length === 0) return null;
|
||||
const { icon: Icon, label } = GROUP_LABELS[groupKey];
|
||||
@@ -134,7 +139,7 @@ export function ColumnGrid({
|
||||
}}
|
||||
className={cn(
|
||||
"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",
|
||||
isSelected && "border-primary/30 bg-primary/5 shadow-sm",
|
||||
)}
|
||||
@@ -144,73 +149,13 @@ export function ColumnGrid({
|
||||
|
||||
{/* 라벨 + 컬럼명 (한글라벨 (영어명) 동시 표시) */}
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">
|
||||
<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="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)}>
|
||||
<span className="mr-1 inline-block h-1.5 w-1.5 rounded-full bg-current opacity-70" />
|
||||
@@ -285,20 +230,37 @@ export function ColumnGrid({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectColumn(column.column_name);
|
||||
}}
|
||||
aria-label="상세 설정"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
<div
|
||||
className="flex items-center justify-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
aria-label="컬럼 액션 메뉴"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
onDeleteColumn?.(column.column_name);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
컬럼 삭제
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 { cn } from "@/lib/utils";
|
||||
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";
|
||||
|
||||
export interface TypeOverviewStripProps {
|
||||
@@ -57,20 +57,13 @@ export function TypeOverviewStrip({
|
||||
/** stroke-dasharray: 비율만큼 둘레에 할당 (둘레 100 기준) */
|
||||
const circumference = 100;
|
||||
let offset = 0;
|
||||
const LEGACY_CONF = {
|
||||
color: "text-amber-600",
|
||||
bgColor: "bg-amber-50",
|
||||
barColor: "bg-amber-400",
|
||||
label: "Legacy",
|
||||
desc: "구버전 타입",
|
||||
iconChar: "?",
|
||||
};
|
||||
const LEGACY_CONF = { ...FALLBACK_TYPE_CONFIG, color: "text-amber-600", bgColor: "bg-amber-50", barColor: "bg-amber-400" };
|
||||
const segmentPaths = segments.map(({ type, ratio, isLegacy }) => {
|
||||
const length = ratio * circumference;
|
||||
const dashArray = `${length} ${circumference - length}`;
|
||||
const dashOffset = -offset;
|
||||
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 {
|
||||
type,
|
||||
dashArray,
|
||||
@@ -112,7 +105,7 @@ export function TypeOverviewStrip({
|
||||
.filter((type) => (counts[type] || 0) > 0)
|
||||
.sort((a, b) => (counts[b] ?? 0) - (counts[a] ?? 0))
|
||||
.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;
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -3,6 +3,23 @@
|
||||
* 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";
|
||||
|
||||
export interface TableInfo {
|
||||
@@ -52,24 +69,34 @@ export interface TypeColorConfig {
|
||||
barColor: string;
|
||||
label: 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> = {
|
||||
text: { color: "text-slate-600", bgColor: "bg-slate-50", barColor: "bg-slate-400", label: "텍스트", desc: "일반 텍스트 입력", iconChar: "T" },
|
||||
number: { color: "text-indigo-600", bgColor: "bg-indigo-50", barColor: "bg-indigo-500", label: "숫자", desc: "숫자만 입력", iconChar: "#" },
|
||||
date: { color: "text-amber-600", bgColor: "bg-amber-50", barColor: "bg-amber-500", label: "날짜", desc: "날짜 선택", iconChar: "D" },
|
||||
code: { color: "text-emerald-600", bgColor: "bg-emerald-50", barColor: "bg-emerald-500", label: "코드", desc: "공통코드 선택", iconChar: "{}" },
|
||||
entity: { color: "text-violet-600", bgColor: "bg-violet-50", barColor: "bg-violet-500", label: "테이블 참조", desc: "다른 테이블 연결", iconChar: "⊞" },
|
||||
select: { color: "text-cyan-600", bgColor: "bg-cyan-50", barColor: "bg-cyan-500", label: "셀렉트", desc: "직접 옵션 선택", iconChar: "☰" },
|
||||
checkbox: { color: "text-pink-600", bgColor: "bg-pink-50", barColor: "bg-pink-500", label: "체크박스", desc: "예/아니오 선택", iconChar: "☑" },
|
||||
numbering: { color: "text-orange-600", bgColor: "bg-orange-50", barColor: "bg-orange-500", label: "채번", desc: "자동 번호 생성", iconChar: "≡" },
|
||||
category: { color: "text-teal-600", bgColor: "bg-teal-50", barColor: "bg-teal-500", label: "카테고리", desc: "등록된 선택지", iconChar: "⊟" },
|
||||
textarea: { color: "text-indigo-600", bgColor: "bg-indigo-50", barColor: "bg-indigo-400", label: "여러 줄", desc: "긴 텍스트 입력", iconChar: "≡" },
|
||||
radio: { color: "text-rose-600", bgColor: "bg-rose-50", barColor: "bg-rose-500", label: "라디오", desc: "하나만 선택", iconChar: "◉" },
|
||||
file: { color: "text-amber-600", bgColor: "bg-amber-50", barColor: "bg-amber-500", label: "파일", desc: "파일 업로드", iconChar: "📎" },
|
||||
image: { color: "text-sky-600", bgColor: "bg-sky-50", barColor: "bg-sky-500", label: "이미지", desc: "이미지 표시", iconChar: "🖼" },
|
||||
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: "숫자만 입력", Icon: Hash },
|
||||
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: "공통코드 선택", Icon: Braces },
|
||||
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: "직접 옵션 선택", Icon: ChevronDown },
|
||||
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: "자동 번호 생성", Icon: ListOrdered },
|
||||
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: "긴 텍스트 입력", Icon: AlignLeft },
|
||||
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: "파일 업로드", Icon: Paperclip },
|
||||
image: { color: "text-sky-600", bgColor: "bg-sky-50", barColor: "bg-sky-500", label: "이미지", desc: "이미지 표시", Icon: ImageIcon },
|
||||
};
|
||||
|
||||
/** v3.2 — 사용자 선택 가능한 8개 입력 타입 색상 맵 (T2 드롭다운/카드 그리드용) */
|
||||
@@ -81,9 +108,23 @@ export const USER_SELECTABLE_INPUT_TYPE_COLORS = USER_SELECTABLE_INPUT_TYPE_ORDE
|
||||
{} as Record<string, TypeColorConfig>,
|
||||
);
|
||||
|
||||
/** 컬럼 그룹 판별 */
|
||||
/** 컬럼 그룹 판별 — 시스템 자동 생성 컬럼은 meta 로 분류 (사용자가 거의 수정하지 않으므로 시각 분리) */
|
||||
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 (["entity", "code", "category"].includes(col.input_type)) return "reference";
|
||||
return "basic";
|
||||
|
||||
@@ -92,6 +92,11 @@ export function ResponsiveDataView<T>({
|
||||
}: ResponsiveDataViewProps<T>) {
|
||||
const rowHeight = compact ? "h-10" : "h-16";
|
||||
const headHeight = compact ? "h-9" : "h-12";
|
||||
const bodyText = compact ? "text-xs" : "text-sm";
|
||||
const headText = compact ? "text-xs" : "text-sm";
|
||||
const cellPad = compact ? "px-3" : "";
|
||||
const cardTitleClass = compact ? "text-sm" : "text-base";
|
||||
const cardSubText = compact ? "text-xs" : "text-sm";
|
||||
// cardFields 미지정 시 columns에서 자동 생성
|
||||
function resolveCardFields(item: T): RDVCardField<T>[] {
|
||||
if (typeof cardFields === "function") return cardFields(item);
|
||||
@@ -233,16 +238,20 @@ export function ResponsiveDataView<T>({
|
||||
{/* 데스크톱 테이블 (컨테이너 ≥ 48rem / 768px) */}
|
||||
<div
|
||||
className={cn(
|
||||
"hidden rounded-lg border bg-card shadow-sm @3xl:block",
|
||||
// scrollContainer 모드: 부모 flex 안에서 가용 height 다 차지 + 자체 세로 스크롤 + sticky 헤더
|
||||
scrollContainer && "min-h-0 flex-1 overflow-y-auto overflow-x-auto",
|
||||
// scrollContainer 모드는 flex 컨테이너로, 아니면 block 으로 표시 (둘 다 < @3xl 에서는 hidden)
|
||||
scrollContainer
|
||||
? "hidden flex-col rounded-lg border bg-card shadow-sm @3xl:flex"
|
||||
: "hidden rounded-lg border bg-card shadow-sm @3xl:block",
|
||||
// 부모 flex 안에서 가용 height 다 차지. 실제 스크롤은 Table wrapper 가 담당
|
||||
// (Table 컴포넌트가 만드는 내부 wrapper 에 flex-1 overflow-auto 를 주면 sticky header 가 그 wrapper 기준으로 작동).
|
||||
scrollContainer && "min-h-0 flex-1 overflow-hidden",
|
||||
tableContainerClassName
|
||||
)}
|
||||
>
|
||||
<Table>
|
||||
<Table divClassName={scrollContainer ? "flex-1 overflow-auto" : undefined}>
|
||||
<TableHeader
|
||||
className={cn(
|
||||
scrollContainer && "sticky top-0 z-10 bg-card"
|
||||
scrollContainer && "sticky top-0 z-10 bg-muted"
|
||||
)}
|
||||
>
|
||||
<TableRow className="border-b bg-muted/50 hover:bg-muted/50">
|
||||
@@ -250,7 +259,7 @@ export function ResponsiveDataView<T>({
|
||||
<TableHead
|
||||
key={col.key}
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
className={cn(headHeight, "text-sm font-semibold")}
|
||||
className={cn(headHeight, cellPad, headText, "font-semibold")}
|
||||
>
|
||||
{col.label}
|
||||
</TableHead>
|
||||
@@ -258,7 +267,7 @@ export function ResponsiveDataView<T>({
|
||||
{renderActions && (
|
||||
<TableHead
|
||||
style={{ width: actionsWidth || "120px" }}
|
||||
className={cn(headHeight, "text-sm font-semibold")}
|
||||
className={cn(headHeight, cellPad, headText, "font-semibold")}
|
||||
>
|
||||
{actionsLabel || "작업"}
|
||||
</TableHead>
|
||||
@@ -278,7 +287,7 @@ export function ResponsiveDataView<T>({
|
||||
{columns.map((col) => (
|
||||
<TableCell
|
||||
key={col.key}
|
||||
className={cn(rowHeight, "text-sm", col.className)}
|
||||
className={cn(rowHeight, cellPad, bodyText, col.className)}
|
||||
>
|
||||
{col.render
|
||||
? col.render(getNestedValue(item, col.key), item, index)
|
||||
@@ -286,7 +295,7 @@ export function ResponsiveDataView<T>({
|
||||
</TableCell>
|
||||
))}
|
||||
{renderActions && (
|
||||
<TableCell className={cn(rowHeight, "text-sm")}>
|
||||
<TableCell className={cn(rowHeight, cellPad, bodyText)}>
|
||||
<div className="flex gap-2">{renderActions(item)}</div>
|
||||
</TableCell>
|
||||
)}
|
||||
@@ -319,11 +328,11 @@ export function ResponsiveDataView<T>({
|
||||
{/* 카드 헤더 */}
|
||||
<div className="mb-3 flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate text-base font-semibold">
|
||||
<h3 className={cn("truncate font-semibold", cardTitleClass)}>
|
||||
{cardTitle(item)}
|
||||
</h3>
|
||||
{cardSubtitle && (
|
||||
<p className="mt-0.5 truncate text-sm text-muted-foreground">
|
||||
<p className={cn("mt-0.5 truncate text-muted-foreground", cardSubText)}>
|
||||
{cardSubtitle(item)}
|
||||
</p>
|
||||
)}
|
||||
@@ -337,7 +346,7 @@ export function ResponsiveDataView<T>({
|
||||
{fields.length > 0 && (
|
||||
<div className="space-y-1.5 border-t pt-3">
|
||||
{fields.map((field, i) => (
|
||||
<div key={i} className="flex justify-between text-sm">
|
||||
<div key={i} className={cn("flex justify-between", cardSubText)}>
|
||||
<span className="text-muted-foreground">
|
||||
{field.label}
|
||||
</span>
|
||||
|
||||
@@ -41,10 +41,22 @@ export function ConnectionSvg({ children }: ConnectionSvgProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** bezier 경로 계산: from(x1,y1) → to(x2,y2) */
|
||||
/**
|
||||
* 연결선 path — mockup v3 EditCanvas 의 orthogonal-with-rounded-corners 스타일
|
||||
* from(x1,y1) → 가로 → 둥근 코너 → 세로 → 둥근 코너 → 가로 → to(x2,y2)
|
||||
* 같은 y 면 직선, 역방향(x1>x2)이면 부드러운 베지어로 fallback (어색한 backward 회피)
|
||||
*/
|
||||
export function bezierPath(x1: number, y1: number, x2: number, y2: number): string {
|
||||
const dx = x2 - x1;
|
||||
return `M${x1},${y1} C${x1 + dx * 0.5},${y1} ${x1 + dx * 0.5},${y2} ${x2},${y2}`;
|
||||
// 역방향 (오른쪽→왼쪽): 직각 라우팅이 카드 위로 휘감으면 어색 → 베지어 사용
|
||||
if (x2 < x1 - 20) {
|
||||
const dx = x2 - x1;
|
||||
return `M ${x1} ${y1} C ${x1 + Math.abs(dx) * 0.4} ${y1}, ${x2 - Math.abs(dx) * 0.4} ${y2}, ${x2} ${y2}`;
|
||||
}
|
||||
const sign = Math.sign(y2 - y1);
|
||||
if (sign === 0) return `M ${x1} ${y1} L ${x2} ${y2}`;
|
||||
const mx = (x1 + x2) / 2;
|
||||
const r = Math.min(10, Math.abs(y2 - y1) / 2, Math.abs(x2 - x1) / 4);
|
||||
return `M ${x1} ${y1} L ${mx - r} ${y1} Q ${mx} ${y1}, ${mx} ${y1 + sign * r} L ${mx} ${y2 - sign * r} Q ${mx} ${y2}, ${mx + r} ${y2} L ${x2} ${y2}`;
|
||||
}
|
||||
|
||||
/** 타입별 CSS 클래스 + 마커 */
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Eye, Wrench, Save, FolderOpen, X, Database } from 'lucide-react';
|
||||
import { useControlMode } from './hooks/useControlMode';
|
||||
import {
|
||||
getBusinessRuleList,
|
||||
getBusinessRuleInfo,
|
||||
insertBusinessRule,
|
||||
updateBusinessRule,
|
||||
} from '@/lib/api/businessRule';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface ControlCardPanelProps {
|
||||
dashboardId: string;
|
||||
card: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택된 카드의 부속 제어 패널
|
||||
* - 카드가 좌측 상단(예: left:20, top:90, 320x240)으로 축소되면
|
||||
* 이 패널이 카드 바로 오른쪽 (left:360, top:90 부근)에 floating
|
||||
* - 패널 안: 카드명 / [읽기 | 편집] 토글 / 액션 / ✕ 닫기
|
||||
*/
|
||||
export function ControlCardPanel({ dashboardId, card }: ControlCardPanelProps) {
|
||||
const {
|
||||
mode,
|
||||
setMode,
|
||||
setSelectedCardId,
|
||||
ruleNodes,
|
||||
ruleConnections,
|
||||
activeRuleId,
|
||||
setActiveRuleId,
|
||||
setRuleNodes,
|
||||
setRuleConnections,
|
||||
} = useControlMode();
|
||||
const [ruleList, setRuleList] = useState<Record<string, any>[]>([]);
|
||||
const [showRuleList, setShowRuleList] = useState(false);
|
||||
|
||||
const cardLabel =
|
||||
card.title ?? card.TITLE ?? card.template_name ?? card.TEMPLATE_NAME ?? '제목 없음';
|
||||
const cardTable =
|
||||
card.primary_table ?? card.PRIMARY_TABLE ?? card.source_table ?? card.SOURCE_TABLE ?? null;
|
||||
const cardType =
|
||||
card.component_type ?? card.COMPONENT_TYPE ?? card.template_type ?? card.TEMPLATE_TYPE ?? null;
|
||||
|
||||
// 편집 모드에서만 규칙 목록 로드
|
||||
useEffect(() => {
|
||||
if (mode !== 'edit') return;
|
||||
getBusinessRuleList(dashboardId)
|
||||
.then((res) => setRuleList(res?.list ?? res?.data ?? []))
|
||||
.catch(() => setRuleList([]));
|
||||
}, [mode, dashboardId]);
|
||||
|
||||
const handleLoadRule = useCallback(
|
||||
async (ruleId: string) => {
|
||||
try {
|
||||
const detail = await getBusinessRuleInfo(ruleId);
|
||||
if (!detail) {
|
||||
toast.error('규칙을 찾을 수 없습니다');
|
||||
return;
|
||||
}
|
||||
setRuleNodes(detail.nodes ?? []);
|
||||
setRuleConnections(detail.connections ?? []);
|
||||
setActiveRuleId(ruleId);
|
||||
setShowRuleList(false);
|
||||
toast.success(`"${detail.name ?? ruleId}" 로드됨`);
|
||||
} catch {
|
||||
toast.error('규칙 로드 실패');
|
||||
}
|
||||
},
|
||||
[setRuleNodes, setRuleConnections, setActiveRuleId],
|
||||
);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (ruleNodes.length === 0) {
|
||||
toast.warning('저장할 노드가 없습니다');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const data = {
|
||||
name: `${cardLabel} 규칙 ${new Date().toLocaleString('ko-KR')}`,
|
||||
nodes: ruleNodes,
|
||||
connections: ruleConnections,
|
||||
card_id: card.card_id ?? card.CARD_ID ?? card.id,
|
||||
};
|
||||
if (activeRuleId) {
|
||||
await updateBusinessRule(activeRuleId, data);
|
||||
toast.success('규칙 저장됨');
|
||||
} else {
|
||||
const result = await insertBusinessRule(dashboardId, data);
|
||||
if (result?.rule_id) setActiveRuleId(result.rule_id);
|
||||
toast.success('규칙 생성됨');
|
||||
}
|
||||
} catch {
|
||||
toast.error('저장 실패');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSourceDragStart = (e: React.DragEvent) => {
|
||||
if (!cardTable) return;
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({ kind: 'table', name: cardTable }));
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ctrl-card-panel">
|
||||
{/* 헤더 — "제어" + ✕ 닫기 (카드명은 좌측 카드 자체에 이미 보이므로 중복 X) */}
|
||||
<div className="ctrl-card-panel-head">
|
||||
<div className="ctrl-card-panel-icon">⚡</div>
|
||||
<div className="ctrl-card-panel-title-wrap">
|
||||
<div className="ctrl-card-panel-title">제어</div>
|
||||
{cardType && <div className="ctrl-card-panel-type">{cardType}</div>}
|
||||
</div>
|
||||
<button
|
||||
className="ctrl-card-panel-close"
|
||||
onClick={() => setSelectedCardId(null)}
|
||||
title="제어 해제"
|
||||
>
|
||||
<X size={11} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 데이터 소스 칩 (드래그 가능, 편집 모드에서 룰 빌더로 추가) */}
|
||||
{cardTable && (
|
||||
<div className="ctrl-card-panel-source">
|
||||
<span
|
||||
className="ctrl-card-panel-source-chip"
|
||||
draggable={mode === 'edit'}
|
||||
onDragStart={mode === 'edit' ? handleSourceDragStart : undefined}
|
||||
title={mode === 'edit' ? '드래그해서 룰 빌더에 추가' : '데이터 소스'}
|
||||
>
|
||||
<Database size={9} />
|
||||
<span>{cardTable}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 모드 토글 — 카드 컨텍스트 안의 segmented */}
|
||||
<div className="ctrl-card-panel-mode">
|
||||
<button
|
||||
className={`ctrl-card-panel-mode-btn${mode === 'view' ? ' on' : ''}`}
|
||||
onClick={() => setMode('view')}
|
||||
title="읽기 — 자동 트리 자람"
|
||||
>
|
||||
<Eye size={10} />
|
||||
<span>읽기</span>
|
||||
</button>
|
||||
<button
|
||||
className={`ctrl-card-panel-mode-btn${mode === 'edit' ? ' on' : ''}`}
|
||||
onClick={() => setMode('edit')}
|
||||
title="편집 — 팔레트에서 직접 작성"
|
||||
>
|
||||
<Wrench size={10} />
|
||||
<span>편집</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 편집 모드 액션 */}
|
||||
{mode === 'edit' && (
|
||||
<>
|
||||
<div className="ctrl-card-panel-actions">
|
||||
<div style={{ position: 'relative', flex: 1 }}>
|
||||
<button
|
||||
className="ctrl-card-panel-btn"
|
||||
onClick={() => setShowRuleList(!showRuleList)}
|
||||
disabled={ruleList.length === 0}
|
||||
title="저장된 규칙 불러오기"
|
||||
>
|
||||
<FolderOpen size={10} />
|
||||
<span>불러오기{ruleList.length > 0 ? ` (${ruleList.length})` : ''}</span>
|
||||
</button>
|
||||
{showRuleList && ruleList.length > 0 && (
|
||||
<div className="ctrl-card-panel-dropdown">
|
||||
{ruleList.map((rule) => {
|
||||
const id = rule.rule_id ?? rule.RULE_ID;
|
||||
const name = rule.name ?? rule.NAME ?? id;
|
||||
const isActive = id === activeRuleId;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
className={`ctrl-card-panel-dropdown-item${isActive ? ' active' : ''}`}
|
||||
onClick={() => handleLoadRule(id)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="ctrl-card-panel-btn primary"
|
||||
onClick={handleSave}
|
||||
disabled={ruleNodes.length === 0}
|
||||
title="현재 룰 저장"
|
||||
>
|
||||
<Save size={10} />
|
||||
<span>저장</span>
|
||||
</button>
|
||||
</div>
|
||||
{ruleNodes.length > 0 && (
|
||||
<div className="ctrl-card-panel-status">
|
||||
{ruleNodes.length}개 노드 · {ruleConnections.length}개 연결
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === 'view' && (
|
||||
<div className="ctrl-card-panel-hint">
|
||||
우측에 데이터 흐름이 자동으로 펼쳐집니다
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useRef } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { MousePointerClick } from 'lucide-react';
|
||||
import { useControlMode } from './hooks/useControlMode';
|
||||
import { ControlToolbar } from './ControlToolbar';
|
||||
import { ControlPalette } from './ControlPalette';
|
||||
import { FlowViewer } from './FlowViewer';
|
||||
import { RuleBuilder } from './RuleBuilder';
|
||||
import { getMetaFields } from '@/lib/api/meta';
|
||||
import type { FieldConfig } from '@/types/invyone-component';
|
||||
import { ContextBar } from './ide/ContextBar';
|
||||
import { LeftRail } from './ide/LeftRail';
|
||||
import { RightRail } from './ide/RightRail';
|
||||
import { Canvas } from './ide/Canvas';
|
||||
import { StatusBar } from './ide/StatusBar';
|
||||
import { CtrlFab } from './ide/CtrlFab';
|
||||
import '@/styles/control-mode.css';
|
||||
|
||||
interface ControlModeProps {
|
||||
@@ -15,43 +21,165 @@ interface ControlModeProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* 제어 모드 오버레이 — 캔버스 위에 렌더
|
||||
* ⚡ 버튼으로 토글, 읽기/편집 모드 전환
|
||||
* 제어 모드 — Control IDE (v3 V3Takeover 베이스)
|
||||
*
|
||||
* 흐름:
|
||||
* 1) ⚡ 토글 ON → 대시보드 카드들이 흐려지고 FlowViewer 가 호버 토폴로지 표시 + ctrl-mode-hint + FAB
|
||||
* 2) 카드 클릭 → IDE 5-분할 takeover (ContextBar / LeftRail / Canvas / RightRail / StatusBar)
|
||||
* 3) ContextBar 의 4-segmented tabs 로 READ / EDIT / RUN / HISTORY 전환
|
||||
* 4) ContextBar 의 ✕ 닫기 → 카드 선택 해제 (제어 유지)
|
||||
* 5) ContextBar 의 제어 종료 → 제어 모드 OFF
|
||||
*/
|
||||
export function ControlMode({ dashboardId, cards, canvasRef }: ControlModeProps) {
|
||||
const { active, mode } = useControlMode();
|
||||
const active = useControlMode((s) => s.active);
|
||||
const mode = useControlMode((s) => s.mode);
|
||||
const selectedCardId = useControlMode((s) => s.selectedCardId);
|
||||
const tablePositions = useControlMode((s) => s.tablePositions);
|
||||
const flowEdges = useControlMode((s) => s.flowEdges);
|
||||
const setSelectedCardId = useControlMode((s) => s.setSelectedCardId);
|
||||
const toggleControlMode = useControlMode((s) => s.toggleControlMode);
|
||||
const setRuleNodes = useControlMode((s) => s.setRuleNodes);
|
||||
const setRuleConnections = useControlMode((s) => s.setRuleConnections);
|
||||
const editInitDone = useRef<string | null>(null);
|
||||
|
||||
const selectedCard = selectedCardId
|
||||
? cards.find((c) => (c.card_id ?? c.CARD_ID ?? c.id) === selectedCardId) ?? null
|
||||
: null;
|
||||
|
||||
// edit 진입 시 자동 노드 등록:
|
||||
// - view 에서 펼쳐진 테이블이 있으면 그것들 + 관계선
|
||||
// - 없으면 primary_table 1개만 좌측에 카드로 등장 (사용자가 거기서 컬럼별 마우스 연결로 룰 작성)
|
||||
useEffect(() => {
|
||||
if (!active || mode !== 'edit' || !selectedCardId) return;
|
||||
const key = `${selectedCardId}:${mode}:${Object.keys(tablePositions).join(',')}`;
|
||||
if (editInitDone.current === key) return;
|
||||
const { ruleNodes } = useControlMode.getState();
|
||||
if (ruleNodes.length > 0) {
|
||||
editInitDone.current = key;
|
||||
return;
|
||||
}
|
||||
editInitDone.current = key;
|
||||
|
||||
const hasView = Object.keys(tablePositions).length > 0;
|
||||
|
||||
if (!hasView) return; // primary_table 자동 등장 X — 사용자가 LeftRail 에서 드래그할 때만 추가
|
||||
|
||||
// view 에서 펼쳐진 테이블 우선
|
||||
if (hasView) {
|
||||
const tableIdMap: Record<string, string> = {};
|
||||
Object.keys(tablePositions).forEach((name) => { tableIdMap[name] = `tbl-${name}`; });
|
||||
const xs = Object.values(tablePositions).map((p) => p.x);
|
||||
const ys = Object.values(tablePositions).map((p) => p.y);
|
||||
const minX = xs.length ? Math.min(...xs) : 0;
|
||||
const minY = ys.length ? Math.min(...ys) : 0;
|
||||
|
||||
(async () => {
|
||||
const newNodes: Record<string, any>[] = await Promise.all(
|
||||
Object.entries(tablePositions).map(async ([name, pos]) => {
|
||||
let columns: FieldConfig[] = [];
|
||||
let label = name;
|
||||
try {
|
||||
const meta = await getMetaFields(name);
|
||||
columns = (meta.fields ?? []).filter((f: FieldConfig) => !f.system); // 모든 컬럼 로드 (Phase 2 dropdown 용)
|
||||
label = meta.table_label ?? name;
|
||||
} catch { /* 빈 컬럼 */ }
|
||||
return {
|
||||
id: tableIdMap[name], type: 'table', table_name: name, label,
|
||||
x: pos.x - minX + 50, y: pos.y - minY + 50, columns,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const newConns: Record<string, any>[] = [];
|
||||
flowEdges.forEach((edge: Record<string, any>, i: number) => {
|
||||
if (typeof edge.from === 'string' && edge.from.startsWith('CARD:')) return;
|
||||
const fromId = tableIdMap[edge.from];
|
||||
const toId = tableIdMap[edge.to];
|
||||
if (!fromId || !toId) return;
|
||||
newConns.push({
|
||||
id: `conn-edit-${i}`,
|
||||
from_node_id: fromId, from_port: 'out',
|
||||
to_node_id: toId, to_port: 'in',
|
||||
});
|
||||
});
|
||||
setRuleNodes(newNodes);
|
||||
setRuleConnections(newConns);
|
||||
})();
|
||||
}
|
||||
}, [active, mode, selectedCardId, tablePositions, flowEdges, setRuleNodes, setRuleConnections]);
|
||||
|
||||
// mode 가 view 로 돌아가거나 카드 변경 시 init guard 리셋
|
||||
useEffect(() => {
|
||||
if (mode === 'view' || !selectedCardId) {
|
||||
editInitDone.current = null;
|
||||
}
|
||||
}, [mode, selectedCardId]);
|
||||
|
||||
// 캔버스에 mode 클래스 + 선택 카드 강조 클래스
|
||||
useEffect(() => {
|
||||
const cv = canvasRef.current;
|
||||
if (!cv) return;
|
||||
cv.classList.toggle('control-mode-edit', active && mode === 'edit');
|
||||
return () => {
|
||||
cv.classList.remove('control-mode-edit');
|
||||
};
|
||||
}, [active, mode, canvasRef]);
|
||||
|
||||
// 선택된 카드 element 에 data-flow-active 토글
|
||||
useEffect(() => {
|
||||
const cv = canvasRef.current;
|
||||
if (!cv) return;
|
||||
cv.querySelectorAll<HTMLElement>('[data-card-id]').forEach((el) => {
|
||||
const id = el.dataset.cardId;
|
||||
if (active && id === selectedCardId) {
|
||||
el.setAttribute('data-flow-active', '1');
|
||||
} else {
|
||||
el.removeAttribute('data-flow-active');
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cv.querySelectorAll<HTMLElement>('[data-card-id]').forEach((el) => {
|
||||
el.removeAttribute('data-flow-active');
|
||||
});
|
||||
};
|
||||
}, [active, selectedCardId, canvasRef]);
|
||||
|
||||
if (!active) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 제어 모드 툴바 */}
|
||||
<ControlToolbar dashboardId={dashboardId} />
|
||||
|
||||
{/* 읽기 모드: 카드 클릭 → 흐름 시각화 */}
|
||||
{mode === 'view' && (
|
||||
<FlowViewer cards={cards} canvasRef={canvasRef} dashboardId={dashboardId} />
|
||||
{/* 카드 미선택 — FlowViewer (호버 토폴로지) + 안내 칩 + FAB */}
|
||||
{!selectedCard && (
|
||||
<>
|
||||
<FlowViewer cards={cards} canvasRef={canvasRef} dashboardId={dashboardId} />
|
||||
<div className="ctrl-mode-hint">
|
||||
<MousePointerClick size={13} style={{ color: 'rgb(0, 154, 150)' }} />
|
||||
<span>카드를 클릭하면 <b>Control IDE</b> 가 펼쳐집니다</span>
|
||||
</div>
|
||||
<CtrlFab onExit={toggleControlMode} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 편집 모드: 규칙 빌더 */}
|
||||
{mode === 'edit' && (
|
||||
<RuleBuilder canvasRef={canvasRef} />
|
||||
{/* 카드 선택 — IDE 5-분할 takeover */}
|
||||
{selectedCard && (
|
||||
<div className="ctrl-ide-shell">
|
||||
<ContextBar
|
||||
selectedCard={selectedCard}
|
||||
onExit={() => setSelectedCardId(null)}
|
||||
onCtrlExit={toggleControlMode}
|
||||
/>
|
||||
<LeftRail cards={cards} selectedCardId={selectedCardId!} />
|
||||
<div className="ctrl-ide-canvas">
|
||||
<Canvas card={selectedCard} canvasRef={canvasRef} dashboardId={dashboardId} />
|
||||
</div>
|
||||
<RightRail selectedCard={selectedCard} />
|
||||
<StatusBar selectedCard={selectedCard} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 제어 모드 팔레트 wrapper — 사이드바에 삽입
|
||||
*/
|
||||
/** 호환성 stub — 외부에서 이름으로만 import 하는 경우 */
|
||||
export function ControlPaletteWrapper() {
|
||||
const { active, mode, addRuleNode } = useControlMode();
|
||||
if (!active || mode !== 'edit') return null;
|
||||
|
||||
return (
|
||||
<ControlPalette
|
||||
onDropTable={() => {}}
|
||||
onDropControl={() => {}}
|
||||
/>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useRef, useCallback } from 'react';
|
||||
import { CTRL_NODE_TYPES, useControlMode } from './hooks/useControlMode';
|
||||
import { getNodeIcon } from './schemas';
|
||||
import { PortHandle } from './PortHandle';
|
||||
|
||||
interface ControlNodeProps {
|
||||
@@ -11,80 +12,124 @@ interface ControlNodeProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* 제어 노드 (16종) — mockup buildCtrlNode 포팅
|
||||
* 제어 노드 (16종) — mockup V3RuleNode 비주얼 (cat-stripe + cat-chip header + label + summary + ports)
|
||||
*/
|
||||
export function ControlNode({ node, onDragStart, onDragEnd }: ControlNodeProps) {
|
||||
const { removeRuleNode, moveRuleNode, setConfigNodeId } = useControlMode();
|
||||
const { removeRuleNode, moveRuleNode, setConfigNodeId, configNodeId } = useControlMode();
|
||||
const nodeRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const def = CTRL_NODE_TYPES[node.type];
|
||||
if (!def) return null;
|
||||
|
||||
const rgb = def.rgb;
|
||||
const Ic = getNodeIcon(node.type);
|
||||
const outPorts = def.out || [{ port: 'out', label: '→', cls: '' }];
|
||||
const selected = configNodeId === node.id;
|
||||
const dim = !!configNodeId && configNodeId !== node.id;
|
||||
|
||||
const handleHeadMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
const handleNodeMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
// port / del 버튼 클릭은 드래그 X
|
||||
if (target.closest('.ctrl-io-port, button')) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const sx = e.clientX, sy = e.clientY;
|
||||
const sl = node.x, st = node.y;
|
||||
const el = nodeRef.current;
|
||||
if (el) el.style.zIndex = '30';
|
||||
let moved = false;
|
||||
|
||||
const mv = (ev: MouseEvent) => {
|
||||
moveRuleNode(node.id, sl + ev.clientX - sx, st + ev.clientY - sy);
|
||||
const dx = ev.clientX - sx, dy = ev.clientY - sy;
|
||||
if (!moved && Math.abs(dx) + Math.abs(dy) < 2) return;
|
||||
moved = true;
|
||||
moveRuleNode(node.id, sl + dx, st + dy);
|
||||
};
|
||||
const up = () => {
|
||||
if (el) el.style.zIndex = '20';
|
||||
document.removeEventListener('mousemove', mv);
|
||||
document.removeEventListener('mouseup', up);
|
||||
if (!moved) setConfigNodeId(node.id === configNodeId ? null : node.id);
|
||||
};
|
||||
document.addEventListener('mousemove', mv);
|
||||
document.addEventListener('mouseup', up);
|
||||
}, [node.id, node.x, node.y, moveRuleNode]);
|
||||
}, [node.id, node.x, node.y, moveRuleNode, setConfigNodeId, configNodeId]);
|
||||
|
||||
// summary 표시 우선순위:
|
||||
// 1. node.config.summary — NodeConfigPopover 가 저장한 한글 라벨 (예: "결재상태 = '결재완료'")
|
||||
// 2. node.summary[0] — mock/seed 데이터의 summary
|
||||
// 3. config entries fallback — { field, op, value, ... } 의 핵심 값을 chip 으로
|
||||
// 4. '클릭하여 설정'
|
||||
const formatVal = (v: any): string => {
|
||||
if (v == null || v === '') return '';
|
||||
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return String(v);
|
||||
if (typeof v === 'object') {
|
||||
// fully qualified field { table, column }
|
||||
if (v.column) return String(v.label ?? v.column);
|
||||
return '';
|
||||
}
|
||||
return String(v);
|
||||
};
|
||||
const summary = (() => {
|
||||
if (node.config?.summary) return String(node.config.summary);
|
||||
if (node.summary?.[0]) return String(node.summary[0]);
|
||||
if (node.config && Object.keys(node.config).length > 0) {
|
||||
const parts = Object.entries(node.config)
|
||||
.filter(([k]) => k !== 'summary')
|
||||
.map(([k, v]) => `${k}: ${formatVal(v)}`)
|
||||
.filter((s) => !s.endsWith(': '))
|
||||
.slice(0, 2);
|
||||
if (parts.length > 0) return parts.join(' · ');
|
||||
}
|
||||
return '클릭하여 설정';
|
||||
})();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={nodeRef}
|
||||
className="ctrl-action-node"
|
||||
className={`v3-rule-node${selected ? ' is-selected' : ''}${dim ? ' is-dim' : ''}`}
|
||||
data-node-id={node.id}
|
||||
data-node-type={node.type}
|
||||
onMouseDown={handleNodeMouseDown}
|
||||
style={{
|
||||
left: node.x,
|
||||
top: node.y,
|
||||
['--na-rgb' as string]: def.rgb,
|
||||
left: node.x, top: node.y,
|
||||
borderColor: `rgba(${rgb}, ${selected ? 0.85 : 0.4})`,
|
||||
boxShadow: selected
|
||||
? `0 0 0 4px rgba(${rgb}, .14), 0 0 24px rgba(${rgb}, .22)`
|
||||
: '0 4px 12px -4px rgba(0, 0, 0, .08)',
|
||||
}}
|
||||
>
|
||||
{/* Input 포트 */}
|
||||
<PortHandle
|
||||
nodeId={node.id}
|
||||
port="in"
|
||||
type="in"
|
||||
onDragEnd={onDragEnd}
|
||||
/>
|
||||
{/* cat-color stripe */}
|
||||
<div className="v3-rule-node-stripe" style={{ background: `rgb(${rgb})` }} />
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="ctrl-an-head" onMouseDown={handleHeadMouseDown}>
|
||||
<div className="ctrl-an-icon">{def.icon}</div>
|
||||
<span className="ctrl-an-name">{def.label}</span>
|
||||
<button
|
||||
className="ctrl-an-del"
|
||||
onClick={(e) => { e.stopPropagation(); removeRuleNode(node.id); }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div
|
||||
className="ctrl-an-body"
|
||||
onClick={() => setConfigNodeId(node.id)}
|
||||
>
|
||||
<div className="ctrl-an-summary">
|
||||
{node.config?.summary || '클릭하여 설정'}
|
||||
{/* body */}
|
||||
<div className="v3-rule-node-body">
|
||||
<div className="v3-rule-node-cat">
|
||||
<div className="v3-rule-node-cat-ico"
|
||||
style={{ background: `rgba(${rgb}, .14)`, color: `rgb(${rgb})` }}>
|
||||
<Ic size={11} />
|
||||
</div>
|
||||
<span className="v3-rule-node-cat-label" style={{ color: `rgb(${rgb})` }}>
|
||||
{def.label}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ctrl-an-del"
|
||||
title="삭제"
|
||||
onClick={(e) => { e.stopPropagation(); removeRuleNode(node.id); }}
|
||||
style={{ marginLeft: 'auto' }}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="v3-rule-node-label">{node.label ?? def.label}</div>
|
||||
{summary && <div className="v3-rule-node-summary">{summary}</div>}
|
||||
</div>
|
||||
|
||||
{/* Output 포트 */}
|
||||
{/* Input 포트 (좌측) */}
|
||||
<PortHandle nodeId={node.id} port="in" type="in" onDragEnd={onDragEnd} />
|
||||
|
||||
{/* Output 포트 (우측, 다중 지원) — label 텍스트(✓/✗) 없이 색만으로 구분 (yes=초록, no=회색 dashed) */}
|
||||
<div className="ctrl-an-ports-out">
|
||||
{outPorts.map((p) => (
|
||||
<PortHandle
|
||||
@@ -93,7 +138,6 @@ export function ControlNode({ node, onDragStart, onDragEnd }: ControlNodeProps)
|
||||
port={p.port}
|
||||
type="out"
|
||||
cls={p.cls}
|
||||
label={p.label}
|
||||
onDragStart={onDragStart}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { CTRL_NODE_TYPES } from './hooks/useControlMode';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Search, Star } from 'lucide-react';
|
||||
import { CTRL_NODE_TYPES, useControlMode } from './hooks/useControlMode';
|
||||
import { getMetaTableList } from '@/lib/api/meta';
|
||||
|
||||
interface ControlPaletteProps {
|
||||
@@ -11,73 +12,210 @@ interface ControlPaletteProps {
|
||||
|
||||
/**
|
||||
* 제어 모드 팔레트 — 사이드바 교체
|
||||
* mockup renderCtrlPalette 포팅
|
||||
* - 검색박스
|
||||
* - ⭐ 시연용 추천 (화이트리스트)
|
||||
* - DB 테이블 max-height + 내부 스크롤
|
||||
* - 영어/한국어 동시 표시
|
||||
* - 제어 노드 16종 카테고리별 그룹
|
||||
*/
|
||||
export function ControlPalette({ onDropTable, onDropControl }: ControlPaletteProps) {
|
||||
|
||||
// 시연용 추천 화이트리스트 (있을 만한 ERP 표준 테이블 + 메뉴 캡쳐에서 확인된 것)
|
||||
const RECOMMENDED_TABLES = [
|
||||
'user_info',
|
||||
'department',
|
||||
'role_info',
|
||||
'menu_master',
|
||||
'authority_master',
|
||||
'approval_definitions',
|
||||
'approval_requests',
|
||||
'approval_lines',
|
||||
'audit_log',
|
||||
'attach_file_info',
|
||||
];
|
||||
|
||||
// 도메인 아이콘 매핑 (prefix 기준)
|
||||
function pickIcon(name: string): string {
|
||||
const n = name.toLowerCase();
|
||||
if (n.startsWith('user') || n === 'user_info') return '👤';
|
||||
if (n.startsWith('department') || n.startsWith('dept')) return '🏢';
|
||||
if (n.startsWith('role') || n.startsWith('authority')) return '🛡';
|
||||
if (n.startsWith('menu')) return '📂';
|
||||
if (n.startsWith('approval')) return '✋';
|
||||
if (n.startsWith('audit') || n.startsWith('log')) return '📜';
|
||||
if (n.startsWith('attach') || n.startsWith('file')) return '📎';
|
||||
if (n.startsWith('mail')) return '📨';
|
||||
if (n.startsWith('ai_')) return '🤖';
|
||||
if (n.startsWith('order')) return '📦';
|
||||
if (n.startsWith('project')) return '📋';
|
||||
if (n.startsWith('barcode') || n.startsWith('label')) return '🏷';
|
||||
if (n.startsWith('batch')) return '⚙';
|
||||
if (n.startsWith('config') || n.startsWith('setting')) return '⚙';
|
||||
return '🗂';
|
||||
}
|
||||
|
||||
export function ControlPalette(_props: ControlPaletteProps) {
|
||||
const [tables, setTables] = useState<Record<string, any>[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const mode = useControlMode((s) => s.mode);
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
useEffect(() => {
|
||||
getMetaTableList().then(setTables).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// 검색 + 추천/일반 분리
|
||||
const { recommended, others } = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
const filtered = q
|
||||
? tables.filter((t) => {
|
||||
const name = String(t.table_name ?? t.TABLE_NAME ?? '').toLowerCase();
|
||||
const label = String(t.table_label ?? t.TABLE_LABEL ?? '').toLowerCase();
|
||||
return name.includes(q) || label.includes(q);
|
||||
})
|
||||
: tables;
|
||||
|
||||
const rec: Record<string, any>[] = [];
|
||||
const oth: Record<string, any>[] = [];
|
||||
filtered.forEach((t) => {
|
||||
const name = String(t.table_name ?? t.TABLE_NAME ?? '').toLowerCase();
|
||||
if (RECOMMENDED_TABLES.includes(name)) rec.push(t);
|
||||
else oth.push(t);
|
||||
});
|
||||
|
||||
// 추천은 화이트리스트 순서 유지
|
||||
rec.sort((a, b) => {
|
||||
const an = String(a.table_name ?? a.TABLE_NAME ?? '').toLowerCase();
|
||||
const bn = String(b.table_name ?? b.TABLE_NAME ?? '').toLowerCase();
|
||||
return RECOMMENDED_TABLES.indexOf(an) - RECOMMENDED_TABLES.indexOf(bn);
|
||||
});
|
||||
|
||||
return { recommended: rec, others: oth };
|
||||
}, [tables, search]);
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, data: Record<string, any>) => {
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify(data));
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
};
|
||||
|
||||
const catLabels: Record<string, string> = {
|
||||
'트리거': '트리거',
|
||||
'조건': '조건 / 분기',
|
||||
'액션': '액션',
|
||||
'흐름': '흐름 제어',
|
||||
'연동': '외부 연동',
|
||||
'기록': '기록',
|
||||
트리거: '트리거',
|
||||
조건: '조건 / 분기',
|
||||
액션: '액션',
|
||||
흐름: '흐름 제어',
|
||||
연동: '외부 연동',
|
||||
기록: '기록',
|
||||
};
|
||||
const cats = ['트리거', '조건', '액션', '흐름', '연동', '기록'];
|
||||
|
||||
return (
|
||||
<div style={{ overflowY: 'auto', flex: 1 }}>
|
||||
{/* DB 테이블 섹션 */}
|
||||
<div className="ctrl-palette-section">DB 테이블</div>
|
||||
{tables.map((t) => {
|
||||
const name = t.table_name ?? t.TABLE_NAME;
|
||||
const label = t.table_label ?? t.TABLE_LABEL ?? name;
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className="ctrl-palette-item"
|
||||
draggable
|
||||
title={`${label} — 캔버스로 드래그`}
|
||||
onDragStart={(e) => handleDragStart(e, { kind: 'table', name })}
|
||||
>
|
||||
<span className="cp-icon">🏢</span>
|
||||
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
const renderTableItem = (t: Record<string, any>, isRecommended: boolean) => {
|
||||
const name = t.table_name ?? t.TABLE_NAME;
|
||||
const rawLabel = t.table_label ?? t.TABLE_LABEL;
|
||||
const label = rawLabel && rawLabel !== name ? rawLabel : null;
|
||||
const icon = pickIcon(String(name));
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className={`ctrl-palette-item${isRecommended ? ' ctrl-palette-item-rec' : ''}`}
|
||||
draggable
|
||||
title={`${label ?? name}${label ? ` (${name})` : ''} — 캔버스로 드래그`}
|
||||
onDragStart={(e) => handleDragStart(e, { kind: 'table', name })}
|
||||
>
|
||||
<span className="cp-icon">{icon}</span>
|
||||
<span className="cp-label">
|
||||
<span className="cp-label-main">{label ?? name}</span>
|
||||
{label && <span className="cp-label-sub">{name}</span>}
|
||||
</span>
|
||||
{isRecommended && <Star size={9} className="cp-rec-star" />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
{/* 제어 노드 — 카테고리별 그룹 */}
|
||||
{cats.map((cat) => {
|
||||
const items = Object.entries(CTRL_NODE_TYPES).filter(([, d]) => d.cat === cat);
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<div key={cat}>
|
||||
<div className="ctrl-palette-section">{catLabels[cat] ?? cat}</div>
|
||||
{items.map(([type, def]) => (
|
||||
<div
|
||||
key={type}
|
||||
className="ctrl-palette-item"
|
||||
draggable
|
||||
title={`${def.label} — 캔버스로 드래그`}
|
||||
onDragStart={(e) => handleDragStart(e, { kind: 'control', type })}
|
||||
>
|
||||
<span className="cp-icon">{def.icon}</span>
|
||||
<span>{def.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<div className="ctrl-palette">
|
||||
{/* 헤더 */}
|
||||
<div className="ctrl-palette-header">
|
||||
<span className="ctrl-palette-header-title">제어 팔레트</span>
|
||||
{!isEditMode && (
|
||||
<span className="ctrl-palette-header-hint">편집 모드에서 활성</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 검색박스 */}
|
||||
<div className="ctrl-palette-search-wrap">
|
||||
<Search size={11} className="ctrl-palette-search-icon" />
|
||||
<input
|
||||
type="text"
|
||||
className="ctrl-palette-search"
|
||||
placeholder="테이블 / 노드 검색…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
disabled={!isEditMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`ctrl-palette-scroll${!isEditMode ? ' disabled' : ''}`}
|
||||
style={{ pointerEvents: isEditMode ? 'auto' : 'none' }}
|
||||
>
|
||||
{/* 주요 테이블 (자주 쓰는 ERP 표준) */}
|
||||
{recommended.length > 0 && (
|
||||
<>
|
||||
<div className="ctrl-palette-section ctrl-palette-section-rec">
|
||||
<Star size={9} style={{ marginRight: 3, fill: 'currentColor' }} />
|
||||
주요 테이블
|
||||
<span className="ctrl-palette-section-count">{recommended.length}</span>
|
||||
</div>
|
||||
<div className="ctrl-palette-tables">
|
||||
{recommended.map((t) => renderTableItem(t, true))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 전체 DB 테이블 (max-height + 내부 스크롤) */}
|
||||
<div className="ctrl-palette-section">
|
||||
DB 테이블
|
||||
{others.length > 0 && <span className="ctrl-palette-section-count">{others.length}</span>}
|
||||
</div>
|
||||
<div className="ctrl-palette-tables ctrl-palette-tables-others">
|
||||
{others.map((t) => renderTableItem(t, false))}
|
||||
{others.length === 0 && search && (
|
||||
<div className="ctrl-palette-empty">검색 결과 없음</div>
|
||||
)}
|
||||
{others.length === 0 && !search && tables.length === 0 && (
|
||||
<div className="ctrl-palette-empty">로딩 중…</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 제어 노드 카테고리별 */}
|
||||
{cats.map((cat) => {
|
||||
const items = Object.entries(CTRL_NODE_TYPES).filter(([, d]) => {
|
||||
if (d.cat !== cat) return false;
|
||||
if (!search.trim()) return true;
|
||||
const q = search.trim().toLowerCase();
|
||||
return d.label.toLowerCase().includes(q);
|
||||
});
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<div key={cat}>
|
||||
<div className="ctrl-palette-section">{catLabels[cat] ?? cat}</div>
|
||||
{items.map(([type, def]) => (
|
||||
<div
|
||||
key={type}
|
||||
className="ctrl-palette-item"
|
||||
draggable
|
||||
title={`${def.label} — 캔버스로 드래그`}
|
||||
onDragStart={(e) => handleDragStart(e, { kind: 'control', type })}
|
||||
>
|
||||
<span className="cp-icon">{def.icon}</span>
|
||||
<span className="cp-label">
|
||||
<span className="cp-label-main">{def.label}</span>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -79,6 +79,7 @@ export function FlowViewer({ cards, canvasRef, dashboardId }: FlowViewerProps) {
|
||||
flowEdges,
|
||||
tablePositions,
|
||||
setActiveFlowCard,
|
||||
setSelectedCardId,
|
||||
setFlowEdges,
|
||||
setTablePositions,
|
||||
} = useControlMode();
|
||||
@@ -90,14 +91,17 @@ export function FlowViewer({ cards, canvasRef, dashboardId }: FlowViewerProps) {
|
||||
const [ruleOverlays, setRuleOverlays] = useState<RuleOverlay[]>([]);
|
||||
const animRef = useRef<ReturnType<typeof setTimeout>[]>([]);
|
||||
|
||||
// 카드 클릭 → 흐름 표시
|
||||
// 카드 클릭 → 흐름 표시 + 카드 선택 (selectedCardId 동기화)
|
||||
const handleCardClick = useCallback(async (cardId: string) => {
|
||||
// 같은 카드 클릭 → 닫기
|
||||
if (activeFlowCardId === cardId) {
|
||||
clearFlow();
|
||||
setSelectedCardId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedCardId(cardId);
|
||||
|
||||
const card = cards.find((c) => (c.card_id ?? c.CARD_ID) === cardId);
|
||||
if (!card) return;
|
||||
|
||||
|
||||
@@ -1,20 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { CTRL_NODE_TYPES, useControlMode } from './hooks/useControlMode';
|
||||
|
||||
/**
|
||||
* 노드 설정 팝오버 (mockup showNodeConfig/_buildCfgForm 포팅)
|
||||
* 노드 타입별 설정 폼
|
||||
* 노드 설정 팝오버 — Phase 2: schema-driven dropdown
|
||||
*
|
||||
* 핵심: 노드와 연결된 테이블의 컬럼/enum 메타를 dropdown 으로 자동 매핑.
|
||||
* - 영어 자유 입력 폐기 (실사용 불가)
|
||||
* - 한글 라벨 우선 + 영문 컬럼 sub
|
||||
* - enum 컬럼이면 값도 dropdown
|
||||
* - multi-table 시 optgroup 으로 namespace 구분
|
||||
* - 저장은 fully qualified { table, column } 객체 (Phase 3 준비)
|
||||
*/
|
||||
export function NodeConfigPopover() {
|
||||
const { configNodeId, ruleNodes, setConfigNodeId, updateRuleNode } = useControlMode();
|
||||
const { configNodeId, ruleNodes, ruleConnections, setConfigNodeId, updateRuleNode } = useControlMode();
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const node = configNodeId ? ruleNodes.find((n) => n.id === configNodeId) : null;
|
||||
const def = node ? CTRL_NODE_TYPES[node.type] : null;
|
||||
|
||||
// 현재 노드와 연결된 테이블 노드들 (양방향 — from/to 어느 쪽이든)
|
||||
const connectedTables = useMemo<Record<string, any>[]>(() => {
|
||||
if (!configNodeId) return [];
|
||||
const tableNodeIds = new Set<string>();
|
||||
ruleConnections.forEach((c) => {
|
||||
if (c.from_node_id === configNodeId) tableNodeIds.add(c.to_node_id);
|
||||
if (c.to_node_id === configNodeId) tableNodeIds.add(c.from_node_id);
|
||||
});
|
||||
return ruleNodes.filter((n) => n.type === 'table' && tableNodeIds.has(n.id));
|
||||
}, [configNodeId, ruleNodes, ruleConnections]);
|
||||
|
||||
useEffect(() => {
|
||||
if (configNodeId && node) {
|
||||
requestAnimationFrame(() => setOpen(true));
|
||||
@@ -23,12 +40,14 @@ export function NodeConfigPopover() {
|
||||
}
|
||||
}, [configNodeId, node]);
|
||||
|
||||
// 외부 클릭 닫기
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (!configNodeId) return;
|
||||
if ((e.target as HTMLElement).closest('.ctrl-cfg-pop')) return;
|
||||
if ((e.target as HTMLElement).closest('.ctrl-an-body')) return;
|
||||
const t = e.target as HTMLElement;
|
||||
if (t.closest('.ctrl-cfg-pop')) return;
|
||||
if (t.closest('.v3-rule-node')) return;
|
||||
if (t.closest('.tbl-node')) return;
|
||||
if (t.closest('.ctrl-an-body')) return;
|
||||
setConfigNodeId(null);
|
||||
};
|
||||
document.addEventListener('click', handler);
|
||||
@@ -49,52 +68,291 @@ export function NodeConfigPopover() {
|
||||
style={{ left: node.x + 172, top: node.y }}
|
||||
>
|
||||
<div className="cfg-hd">{def.icon} {def.label} 설정</div>
|
||||
<ConfigForm type={node.type} config={node.config ?? {}} onSave={handleSave} onClose={() => setConfigNodeId(null)} />
|
||||
<ConfigForm
|
||||
type={node.type}
|
||||
config={node.config ?? {}}
|
||||
connectedTables={connectedTables}
|
||||
onSave={handleSave}
|
||||
onClose={() => setConfigNodeId(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigForm({ type, config, onSave, onClose }: {
|
||||
type: string; config: Record<string, any>;
|
||||
/* ─── Helpers ─── */
|
||||
|
||||
interface ColumnMeta {
|
||||
tableName: string;
|
||||
tableLabel: string;
|
||||
column: string;
|
||||
label: string;
|
||||
type: string;
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
pk?: boolean;
|
||||
}
|
||||
|
||||
/** 연결된 테이블들의 모든 컬럼을 flat 으로 + 표시 정보 포함 */
|
||||
function flattenColumns(tables: Record<string, any>[]): ColumnMeta[] {
|
||||
const out: ColumnMeta[] = [];
|
||||
tables.forEach((t) => {
|
||||
const tName = t.table_name ?? t.tableName ?? '';
|
||||
const tLabel = t.label ?? tName;
|
||||
(t.columns ?? []).forEach((c: Record<string, any>) => {
|
||||
const colName = c.column ?? c.name ?? c.COLUMN_NAME ?? '';
|
||||
if (!colName) return;
|
||||
out.push({
|
||||
tableName: tName,
|
||||
tableLabel: tLabel,
|
||||
column: colName,
|
||||
label: c.label ?? c.dname ?? colName,
|
||||
type: c.type ?? c.dtype ?? 'text',
|
||||
options: c.options,
|
||||
pk: !!c.pk,
|
||||
});
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** fully qualified id ↔ 객체 변환 */
|
||||
function serializeField(field: any): string {
|
||||
if (!field) return '';
|
||||
if (typeof field === 'string') return field; // legacy
|
||||
if (field.table && field.column) return `${field.table}|${field.column}`;
|
||||
return '';
|
||||
}
|
||||
function deserializeField(s: string): { table: string; column: string } | null {
|
||||
if (!s || !s.includes('|')) return null;
|
||||
const [table, column] = s.split('|');
|
||||
return { table, column };
|
||||
}
|
||||
|
||||
/** field value (string or {table,column}) 으로 ColumnMeta 찾기 */
|
||||
function findColumn(cols: ColumnMeta[], field: any): ColumnMeta | null {
|
||||
if (!field) return null;
|
||||
if (typeof field === 'string') return cols.find((c) => c.column === field) ?? null;
|
||||
if (field.table && field.column) {
|
||||
return cols.find((c) => c.tableName === field.table && c.column === field.column) ?? null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 한글 라벨 표시 (field) */
|
||||
function displayField(field: any, cols: ColumnMeta[]): string {
|
||||
const col = findColumn(cols, field);
|
||||
if (col) return col.label;
|
||||
if (typeof field === 'string') return field;
|
||||
if (field?.column) return field.column;
|
||||
return '?';
|
||||
}
|
||||
|
||||
/* ─── Reusable pickers ─── */
|
||||
|
||||
function FieldPicker({
|
||||
tables, value, onChange, placeholder,
|
||||
}: {
|
||||
tables: Record<string, any>[];
|
||||
value: any;
|
||||
onChange: (field: { table: string; column: string }) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const cols = useMemo(() => flattenColumns(tables), [tables]);
|
||||
if (tables.length === 0) {
|
||||
return <div className="cfg-empty">연결된 테이블 없음 — 먼저 테이블 카드를 노드에 연결</div>;
|
||||
}
|
||||
const currentId = serializeField(value);
|
||||
|
||||
return (
|
||||
<select
|
||||
className="cfg-sel"
|
||||
value={currentId}
|
||||
onChange={(e) => {
|
||||
const f = deserializeField(e.target.value);
|
||||
if (f) onChange(f);
|
||||
}}
|
||||
>
|
||||
<option value="">{placeholder ?? '컬럼 선택...'}</option>
|
||||
{tables.map((tbl) => {
|
||||
const tName = tbl.table_name ?? tbl.tableName ?? '';
|
||||
const tLabel = tbl.label ?? tName;
|
||||
const tableCols = cols.filter((c) => c.tableName === tName);
|
||||
if (tableCols.length === 0) return null;
|
||||
const groupLabel = tLabel !== tName ? `${tLabel} · ${tName}` : tName;
|
||||
return (
|
||||
<optgroup key={tName} label={groupLabel}>
|
||||
{tableCols.map((c) => {
|
||||
const id = `${c.tableName}|${c.column}`;
|
||||
const dispLabel = c.label !== c.column ? `${c.label} (${c.column})` : c.column;
|
||||
return (
|
||||
<option key={id} value={id}>
|
||||
{dispLabel}{c.pk ? ' · PK' : ''}{c.type === 'select' ? ' · enum' : ''}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</optgroup>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function TablePicker({
|
||||
tables, value, onChange, placeholder,
|
||||
}: {
|
||||
tables: Record<string, any>[];
|
||||
value: any;
|
||||
onChange: (tableName: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
// 자동 채움 — Strict 모드 안전 useEffect (committed lifecycle 에서만 실행)
|
||||
const single = tables.length === 1
|
||||
? (tables[0].table_name ?? tables[0].tableName ?? '')
|
||||
: null;
|
||||
useEffect(() => {
|
||||
if (single && value !== single) onChange(single);
|
||||
}, [single, value, onChange]);
|
||||
|
||||
if (tables.length === 0) {
|
||||
return <div className="cfg-empty">연결된 테이블 없음 — 먼저 테이블 카드를 노드에 연결</div>;
|
||||
}
|
||||
// 1개면 자동 readonly
|
||||
if (tables.length === 1) {
|
||||
const t = tables[0];
|
||||
const tName = t.table_name ?? t.tableName ?? '';
|
||||
const tLabel = t.label ?? tName;
|
||||
return (
|
||||
<div className="cfg-static">
|
||||
<span className="cfg-static-main">{tLabel}</span>
|
||||
{tLabel !== tName && <span className="cfg-static-sub">{tName}</span>}
|
||||
<span className="cfg-static-hint">(자동)</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 2개+ 면 dropdown
|
||||
const current = typeof value === 'string' ? value : (value?.table ?? '');
|
||||
return (
|
||||
<select className="cfg-sel" value={current} onChange={(e) => onChange(e.target.value)}>
|
||||
<option value="">{placeholder ?? '테이블 선택...'}</option>
|
||||
{tables.map((t) => {
|
||||
const tName = t.table_name ?? t.tableName ?? '';
|
||||
const tLabel = t.label ?? tName;
|
||||
return (
|
||||
<option key={tName} value={tName}>
|
||||
{tLabel}{tLabel !== tName ? ` (${tName})` : ''}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function ValuePicker({
|
||||
tables, fieldRef, value, onChange, placeholder,
|
||||
}: {
|
||||
tables: Record<string, any>[];
|
||||
fieldRef: any; // 어느 컬럼의 값인지
|
||||
value: any;
|
||||
onChange: (v: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const cols = useMemo(() => flattenColumns(tables), [tables]);
|
||||
const col = findColumn(cols, fieldRef);
|
||||
|
||||
// enum 컬럼이면 dropdown
|
||||
if (col?.type === 'select' && col.options && col.options.length > 0) {
|
||||
return (
|
||||
<select className="cfg-sel" value={value ?? ''} onChange={(e) => onChange(e.target.value)}>
|
||||
<option value="">{placeholder ?? '값 선택...'}</option>
|
||||
{col.options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}{opt.label !== opt.value ? ` (${opt.value})` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
// 기본 typed input
|
||||
return (
|
||||
<input
|
||||
className="cfg-inp"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder ?? (col ? `${col.label} 값` : '값 입력')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── ConfigForm ─── */
|
||||
|
||||
function ConfigForm({
|
||||
type, config, connectedTables, onSave, onClose,
|
||||
}: {
|
||||
type: string;
|
||||
config: Record<string, any>;
|
||||
connectedTables: Record<string, any>[];
|
||||
onSave: (summary: string, config: Record<string, any>) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [vals, setVals] = useState<Record<string, any>>(config);
|
||||
const set = (k: string, v: any) => setVals((p) => ({ ...p, [k]: v }));
|
||||
const cols = useMemo(() => flattenColumns(connectedTables), [connectedTables]);
|
||||
|
||||
const handleSave = () => {
|
||||
let summary = '';
|
||||
const fLabel = (f: any) => displayField(f, cols);
|
||||
const tLabel = (tName: string) => {
|
||||
const t = connectedTables.find((x) => (x.table_name ?? x.tableName) === tName);
|
||||
return t?.label ?? tName ?? '?';
|
||||
};
|
||||
switch (type) {
|
||||
case 'condition':
|
||||
summary = `${vals.field || '?'} ${vals.op || '='} "${vals.value || '?'}"`;
|
||||
summary = `${fLabel(vals.field)} ${vals.op || '='} "${vals.value || '?'}"`;
|
||||
break;
|
||||
case 'status-change':
|
||||
summary = `${vals.table || '?'}.${vals.field || 'STATUS'} → "${vals.value || '?'}"`;
|
||||
summary = `${tLabel(vals.table)}.${fLabel(vals.field)} → "${vals.value || '?'}"`;
|
||||
break;
|
||||
case 'auto-insert':
|
||||
summary = `→ ${vals.table || '?'} INSERT`;
|
||||
summary = `→ ${tLabel(vals.table)} INSERT`;
|
||||
break;
|
||||
case 'timer':
|
||||
summary = `${vals.field || '?'} +${vals.amount || 0}${vals.unit || '일'} 경과`;
|
||||
summary = `${fLabel(vals.field)} +${vals.amount || 0}${vals.unit || '일'} 경과`;
|
||||
break;
|
||||
case 'notification':
|
||||
summary = `${vals.channel || '이메일'} → ${vals.target || '담당자'}`;
|
||||
break;
|
||||
case 'approval':
|
||||
summary = `${vals.approver || '팀장'} 승인 (${vals.condition || ''})`;
|
||||
summary = `${vals.approver || '팀장'} 승인${vals.condition ? ` (${vals.condition})` : ''}`;
|
||||
break;
|
||||
case 'calculation':
|
||||
summary = `${vals.table || '?'}.${vals.field || '?'} = ${vals.formula || '?'}`;
|
||||
summary = `${tLabel(vals.table)}.${fLabel(vals.field)} = ${vals.formula || '?'}`;
|
||||
break;
|
||||
case 'webhook':
|
||||
summary = `${vals.method || 'POST'} ${(vals.url || '').slice(0, 25)}...`;
|
||||
break;
|
||||
case 'validation':
|
||||
summary = `${vals.field || '?'} ${vals.rule || '필수값'}`;
|
||||
summary = `${fLabel(vals.field)} ${vals.rule || '필수값'}`;
|
||||
break;
|
||||
case 'log':
|
||||
summary = `로그: ${vals.content || '?'}`;
|
||||
break;
|
||||
case 'delete':
|
||||
summary = `${tLabel(vals.table)} ${vals.mode === 'soft' ? 'soft delete' : 'hard delete'}`;
|
||||
break;
|
||||
case 'document':
|
||||
summary = `${vals.template || '?'} → ${vals.format || 'pdf'}`;
|
||||
break;
|
||||
case 'delay':
|
||||
summary = `${vals.amount || 0}${vals.unit || '분'} 대기`;
|
||||
break;
|
||||
case 'loop':
|
||||
summary = vals.iterField ? `for each ${vals.iterField}` : `${vals.count || 1}회 반복`;
|
||||
break;
|
||||
case 'parallel':
|
||||
summary = `${vals.branches || 2}개 병렬 실행`;
|
||||
break;
|
||||
case 'merge':
|
||||
summary = vals.strategy === 'all' ? '모든 분기 대기 (all)' : '먼저 도착 (any)';
|
||||
break;
|
||||
default:
|
||||
summary = vals.summary || '설정됨';
|
||||
}
|
||||
@@ -103,7 +361,7 @@ function ConfigForm({ type, config, onSave, onClose }: {
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderFields(type, vals, set)}
|
||||
{renderFields(type, vals, set, connectedTables)}
|
||||
<div className="cfg-ft">
|
||||
<button className="cfg-btn save" onClick={handleSave}>저장</button>
|
||||
<button className="cfg-btn" onClick={onClose}>닫기</button>
|
||||
@@ -115,21 +373,25 @@ function ConfigForm({ type, config, onSave, onClose }: {
|
||||
function renderFields(
|
||||
type: string,
|
||||
vals: Record<string, any>,
|
||||
set: (k: string, v: any) => void
|
||||
set: (k: string, v: any) => void,
|
||||
tables: Record<string, any>[],
|
||||
) {
|
||||
switch (type) {
|
||||
/* ─── Phase 2 schema-driven 4종 ─── */
|
||||
case 'condition':
|
||||
return (
|
||||
<>
|
||||
<CfgSec label="필드">
|
||||
<CfgInput value={vals.field ?? ''} onChange={(v) => set('field', v)} placeholder="STATUS" />
|
||||
<FieldPicker tables={tables} value={vals.field} onChange={(f) => set('field', f)}
|
||||
placeholder="비교할 컬럼 선택..." />
|
||||
</CfgSec>
|
||||
<CfgSec label="연산자">
|
||||
<CfgSelect value={vals.op ?? '='} onChange={(v) => set('op', v)}
|
||||
options={['=', '≠', '>', '<', '기한 경과', '포함']} />
|
||||
options={['=', '≠', '>', '<', '≥', '≤', '포함', '기한 경과']} />
|
||||
</CfgSec>
|
||||
<CfgSec label="값">
|
||||
<CfgInput value={vals.value ?? ''} onChange={(v) => set('value', v)} placeholder="비교값" />
|
||||
<ValuePicker tables={tables} fieldRef={vals.field} value={vals.value}
|
||||
onChange={(v) => set('value', v)} />
|
||||
</CfgSec>
|
||||
</>
|
||||
);
|
||||
@@ -137,27 +399,61 @@ function renderFields(
|
||||
return (
|
||||
<>
|
||||
<CfgSec label="대상 테이블">
|
||||
<CfgInput value={vals.table ?? ''} onChange={(v) => set('table', v)} placeholder="테이블명" />
|
||||
<TablePicker tables={tables} value={vals.table} onChange={(v) => set('table', v)} />
|
||||
</CfgSec>
|
||||
<CfgSec label="변경 필드">
|
||||
<CfgInput value={vals.field ?? 'STATUS'} onChange={(v) => set('field', v)} />
|
||||
<FieldPicker tables={tables} value={vals.field} onChange={(f) => set('field', f)}
|
||||
placeholder="변경할 컬럼 선택..." />
|
||||
</CfgSec>
|
||||
<CfgSec label="변경값">
|
||||
<CfgInput value={vals.value ?? ''} onChange={(v) => set('value', v)} placeholder="새 값" />
|
||||
<ValuePicker tables={tables} fieldRef={vals.field} value={vals.value}
|
||||
onChange={(v) => set('value', v)} placeholder="새 값" />
|
||||
</CfgSec>
|
||||
</>
|
||||
);
|
||||
case 'calculation':
|
||||
return (
|
||||
<>
|
||||
<CfgSec label="대상 테이블">
|
||||
<TablePicker tables={tables} value={vals.table} onChange={(v) => set('table', v)} />
|
||||
</CfgSec>
|
||||
<CfgSec label="결과 필드">
|
||||
<FieldPicker tables={tables} value={vals.field} onChange={(f) => set('field', f)}
|
||||
placeholder="저장할 컬럼 선택..." />
|
||||
</CfgSec>
|
||||
<CfgSec label="수식">
|
||||
<CfgInput value={vals.formula ?? ''} onChange={(v) => set('formula', v)}
|
||||
placeholder="QTY * UNIT_PRICE" />
|
||||
</CfgSec>
|
||||
</>
|
||||
);
|
||||
case 'validation':
|
||||
return (
|
||||
<>
|
||||
<CfgSec label="대상 필드">
|
||||
<FieldPicker tables={tables} value={vals.field} onChange={(f) => set('field', f)}
|
||||
placeholder="검증할 컬럼 선택..." />
|
||||
</CfgSec>
|
||||
<CfgSec label="검증 규칙">
|
||||
<CfgSelect value={vals.rule ?? '필수값 (NOT NULL)'} onChange={(v) => set('rule', v)}
|
||||
options={['필수값 (NOT NULL)', '범위 체크', '정규식 매칭', '참조 무결성', '커스텀 조건']} />
|
||||
</CfgSec>
|
||||
</>
|
||||
);
|
||||
|
||||
/* ─── 기존 케이스 유지 (테이블 컬럼 의존성 없는 노드들) ─── */
|
||||
case 'auto-insert':
|
||||
return (
|
||||
<CfgSec label="대상 테이블">
|
||||
<CfgInput value={vals.table ?? ''} onChange={(v) => set('table', v)} placeholder="테이블명" />
|
||||
<TablePicker tables={tables} value={vals.table} onChange={(v) => set('table', v)} />
|
||||
</CfgSec>
|
||||
);
|
||||
case 'timer':
|
||||
return (
|
||||
<>
|
||||
<CfgSec label="기준 필드">
|
||||
<CfgInput value={vals.field ?? ''} onChange={(v) => set('field', v)} placeholder="ORDER_DATE" />
|
||||
<FieldPicker tables={tables} value={vals.field} onChange={(f) => set('field', f)}
|
||||
placeholder="시간 기준 컬럼..." />
|
||||
</CfgSec>
|
||||
<CfgSec label="경과 기준">
|
||||
<div style={{ display: 'flex', gap: '.3rem' }}>
|
||||
@@ -196,20 +492,6 @@ function renderFields(
|
||||
</CfgSec>
|
||||
</>
|
||||
);
|
||||
case 'calculation':
|
||||
return (
|
||||
<>
|
||||
<CfgSec label="대상 테이블">
|
||||
<CfgInput value={vals.table ?? ''} onChange={(v) => set('table', v)} placeholder="테이블명" />
|
||||
</CfgSec>
|
||||
<CfgSec label="결과 필드">
|
||||
<CfgInput value={vals.field ?? ''} onChange={(v) => set('field', v)} placeholder="필드명" />
|
||||
</CfgSec>
|
||||
<CfgSec label="수식">
|
||||
<CfgInput value={vals.formula ?? ''} onChange={(v) => set('formula', v)} placeholder="QTY * UNIT_PRICE" />
|
||||
</CfgSec>
|
||||
</>
|
||||
);
|
||||
case 'webhook':
|
||||
return (
|
||||
<>
|
||||
@@ -222,22 +504,91 @@ function renderFields(
|
||||
</CfgSec>
|
||||
</>
|
||||
);
|
||||
case 'validation':
|
||||
case 'log':
|
||||
return (
|
||||
<>
|
||||
<CfgSec label="대상 필드">
|
||||
<CfgInput value={vals.field ?? ''} onChange={(v) => set('field', v)} placeholder="필드명" />
|
||||
<CfgSec label="로그 레벨">
|
||||
<CfgSelect value={vals.level ?? 'info'} onChange={(v) => set('level', v)}
|
||||
options={['info', 'warn', 'error', 'debug']} />
|
||||
</CfgSec>
|
||||
<CfgSec label="검증 규칙">
|
||||
<CfgSelect value={vals.rule ?? '필수값 (NOT NULL)'} onChange={(v) => set('rule', v)}
|
||||
options={['필수값 (NOT NULL)', '범위 체크', '정규식 매칭', '참조 무결성', '커스텀 조건']} />
|
||||
<CfgSec label="내용">
|
||||
<CfgInput value={vals.content ?? ''} onChange={(v) => set('content', v)} placeholder="액션 설명" />
|
||||
</CfgSec>
|
||||
</>
|
||||
);
|
||||
case 'log':
|
||||
case 'delete':
|
||||
return (
|
||||
<CfgSec label="내용">
|
||||
<CfgInput value={vals.content ?? ''} onChange={(v) => set('content', v)} placeholder="액션 설명" />
|
||||
<>
|
||||
<CfgSec label="대상 테이블">
|
||||
<TablePicker tables={tables} value={vals.table} onChange={(v) => set('table', v)} />
|
||||
</CfgSec>
|
||||
<CfgSec label="삭제 방식">
|
||||
<CfgSelect value={vals.mode ?? 'soft'} onChange={(v) => set('mode', v)}
|
||||
options={['soft', 'hard']} />
|
||||
</CfgSec>
|
||||
<CfgSec label="조건 (WHERE)">
|
||||
<CfgInput value={vals.where ?? ''} onChange={(v) => set('where', v)} placeholder="id = ?" />
|
||||
</CfgSec>
|
||||
</>
|
||||
);
|
||||
case 'document':
|
||||
return (
|
||||
<>
|
||||
<CfgSec label="템플릿">
|
||||
<CfgInput value={vals.template ?? ''} onChange={(v) => set('template', v)} placeholder="출고확인서.docx" />
|
||||
</CfgSec>
|
||||
<CfgSec label="출력 경로">
|
||||
<CfgInput value={vals.output ?? ''} onChange={(v) => set('output', v)} placeholder="/docs/{id}.pdf" />
|
||||
</CfgSec>
|
||||
<CfgSec label="포맷">
|
||||
<CfgSelect value={vals.format ?? 'pdf'} onChange={(v) => set('format', v)}
|
||||
options={['pdf', 'docx', 'xlsx', 'html']} />
|
||||
</CfgSec>
|
||||
</>
|
||||
);
|
||||
case 'delay':
|
||||
return (
|
||||
<CfgSec label="지연 시간">
|
||||
<div style={{ display: 'flex', gap: '.3rem' }}>
|
||||
<CfgInput value={vals.amount ?? '0'} onChange={(v) => set('amount', v)} placeholder="0" />
|
||||
<CfgSelect value={vals.unit ?? '분'} onChange={(v) => set('unit', v)}
|
||||
options={['초', '분', '시간', '일']} />
|
||||
</div>
|
||||
</CfgSec>
|
||||
);
|
||||
case 'loop':
|
||||
return (
|
||||
<>
|
||||
<CfgSec label="반복 방식">
|
||||
<CfgSelect value={vals.mode ?? 'count'} onChange={(v) => set('mode', v)}
|
||||
options={['count', 'forEach', 'while']} />
|
||||
</CfgSec>
|
||||
{vals.mode === 'forEach' ? (
|
||||
<CfgSec label="반복 대상 필드">
|
||||
<FieldPicker tables={tables} value={vals.iterField} onChange={(f) => set('iterField', f)} />
|
||||
</CfgSec>
|
||||
) : vals.mode === 'while' ? (
|
||||
<CfgSec label="조건식">
|
||||
<CfgInput value={vals.condition ?? ''} onChange={(v) => set('condition', v)} placeholder="x < 10" />
|
||||
</CfgSec>
|
||||
) : (
|
||||
<CfgSec label="횟수">
|
||||
<CfgInput value={vals.count ?? '1'} onChange={(v) => set('count', v)} placeholder="1" />
|
||||
</CfgSec>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
case 'parallel':
|
||||
return (
|
||||
<CfgSec label="병렬 분기 수">
|
||||
<CfgInput value={vals.branches ?? '2'} onChange={(v) => set('branches', v)} placeholder="2" />
|
||||
</CfgSec>
|
||||
);
|
||||
case 'merge':
|
||||
return (
|
||||
<CfgSec label="합류 전략">
|
||||
<CfgSelect value={vals.strategy ?? 'any'} onChange={(v) => set('strategy', v)}
|
||||
options={['any', 'all']} />
|
||||
</CfgSec>
|
||||
);
|
||||
default:
|
||||
@@ -245,6 +596,8 @@ function renderFields(
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── 공통 atoms ─── */
|
||||
|
||||
function CfgSec({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="cfg-sec">
|
||||
|
||||
@@ -17,15 +17,17 @@ interface PortHandleProps {
|
||||
}
|
||||
|
||||
export function PortHandle({ nodeId, port, type, cls, label, isTable, onDragStart, onDragEnd }: PortHandleProps) {
|
||||
// 단일 동그라미가 mousedown(연결 시작) + mouseup(연결 종료) 둘 다 받음
|
||||
// (테이블 컬럼 port 처럼 시각적으로 하나만 보이는 경우)
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (type !== 'out' || !onDragStart) return;
|
||||
if (!onDragStart) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDragStart(nodeId, port, e);
|
||||
};
|
||||
|
||||
const handleMouseUp = (e: React.MouseEvent) => {
|
||||
if (type !== 'in' || !onDragEnd) return;
|
||||
if (!onDragEnd) return;
|
||||
e.stopPropagation();
|
||||
onDragEnd(nodeId, port);
|
||||
};
|
||||
|
||||
@@ -56,7 +56,7 @@ export function RuleBuilder({ canvasRef }: RuleBuilderProps) {
|
||||
} else {
|
||||
try {
|
||||
const meta = await getMetaFields(d.name);
|
||||
cols = (meta.fields ?? []).filter((f: FieldConfig) => !f.system).slice(0, 8);
|
||||
cols = (meta.fields ?? []).filter((f: FieldConfig) => !f.system); // 모든 컬럼 로드 (Phase 2 dropdown 용)
|
||||
fieldCache[d.name] = cols;
|
||||
} catch { /* 빈 필드 */ }
|
||||
}
|
||||
@@ -88,13 +88,20 @@ export function RuleBuilder({ canvasRef }: RuleBuilderProps) {
|
||||
}, []);
|
||||
|
||||
// 노드 좌표에서 포트 위치 계산
|
||||
const portPos = useCallback((nodeId: string, port: string) => {
|
||||
// dir: 'from' (출력측, 우측) | 'to' (입력측, 좌측) — 컬럼별 port 의 좌/우 결정용
|
||||
const portPos = useCallback((nodeId: string, port: string, dir: 'from' | 'to' = 'from') => {
|
||||
const node = ruleNodes.find((n) => n.id === nodeId);
|
||||
if (!node) return null;
|
||||
|
||||
if (node.type === 'table') {
|
||||
if (port === 'in') return { x: node.x, y: node.y + 18 };
|
||||
return { x: node.x + 200, y: node.y + 18 };
|
||||
// 테이블 단위 단일 port — 카드 좌측(in) / 우측(out) 중앙
|
||||
// (Phase 1: 컬럼별 port 폐기. 컬럼 선택은 NodeConfigPopover dropdown 에서)
|
||||
void dir;
|
||||
const cardW = 180;
|
||||
const cardH = 70; // stripe + head + stats
|
||||
const yMid = node.y + cardH / 2;
|
||||
if (port === 'in') return { x: node.x, y: yMid };
|
||||
return { x: node.x + cardW, y: yMid };
|
||||
}
|
||||
|
||||
// 제어 노드
|
||||
@@ -114,14 +121,12 @@ export function RuleBuilder({ canvasRef }: RuleBuilderProps) {
|
||||
}, [ruleNodes]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 드롭존 (캔버스 전체에 이벤트 걸기 위한 투명 레이어) */}
|
||||
<div
|
||||
style={{ position: 'absolute', inset: 0, zIndex: 5 }}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="rule-builder-canvas"
|
||||
style={{ position: 'absolute', inset: 0, pointerEvents: 'auto' }}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
>
|
||||
{/* 연결선 SVG */}
|
||||
<svg className="ctrl-svg" id="rule-svg" width="100%" height="100%" style={{ overflow: 'visible' }}>
|
||||
<defs>
|
||||
@@ -137,32 +142,76 @@ export function RuleBuilder({ canvasRef }: RuleBuilderProps) {
|
||||
</defs>
|
||||
|
||||
{ruleConnections.map((c) => {
|
||||
const f = portPos(c.from_node_id, c.from_port);
|
||||
const t = portPos(c.to_node_id, c.to_port);
|
||||
const f = portPos(c.from_node_id, c.from_port, 'from');
|
||||
const t = portPos(c.to_node_id, c.to_port, 'to');
|
||||
if (!f || !t) return null;
|
||||
|
||||
const cls = c.from_port === 'yes' ? 'rule-conn-path conn-yes'
|
||||
: c.from_port === 'no' ? 'rule-conn-path conn-no'
|
||||
: 'rule-conn-path';
|
||||
const marker = c.from_port === 'yes' ? 'url(#arr-yes)'
|
||||
: c.from_port === 'no' ? 'url(#arr-no)'
|
||||
: 'url(#arr-rule)';
|
||||
// Phase 3: edge_type 별 stroke 분기 (yes/no 우선, 그 다음 edge_type)
|
||||
const portCls = c.from_port === 'yes' ? 'conn-yes'
|
||||
: c.from_port === 'no' ? 'conn-no' : '';
|
||||
const edgeCls = c.edge_type ? `edge-${c.edge_type}` : '';
|
||||
const cls = ['rule-conn-path', portCls, edgeCls].filter(Boolean).join(' ');
|
||||
|
||||
// 선 중간 라벨 — yes/no 같은 분기 + edge_type 시각화 (mockup v3 EditCanvas style)
|
||||
const portLabel =
|
||||
c.label ??
|
||||
(c.from_port === 'yes' ? '예'
|
||||
: c.from_port === 'no' ? '아니오'
|
||||
: c.from_port === 'pass' ? '통과'
|
||||
: c.from_port === 'fail' ? '실패'
|
||||
: c.from_port === 'approved'? '승인'
|
||||
: c.from_port === 'rejected'? '반려'
|
||||
: c.from_port === 'each' ? '반복'
|
||||
: c.from_port === 'done' ? '완료'
|
||||
: null);
|
||||
const labelColor = c.from_port === 'yes' ? 'var(--ctrl-green)'
|
||||
: c.from_port === 'no' ? 'var(--v5-text-muted, #888)'
|
||||
: c.from_port === 'pass' ? 'var(--ctrl-green)'
|
||||
: c.from_port === 'fail' ? 'rgb(255, 71, 87)'
|
||||
: c.from_port === 'approved' ? 'var(--ctrl-green)'
|
||||
: c.from_port === 'rejected' ? 'var(--v5-text-muted, #888)'
|
||||
: c.edge_type === 'table-mutation' ? 'rgb(253, 121, 168)'
|
||||
: c.edge_type === 'execution-flow' ? 'var(--ctrl-primary)'
|
||||
: c.edge_type === 'lookup' ? 'var(--ctrl-green)'
|
||||
: 'var(--ctrl-cyan)';
|
||||
const mx = (f.x + t.x) / 2;
|
||||
const my = (f.y + t.y) / 2;
|
||||
const labelW = Math.max(36, (portLabel?.length ?? 0) * 8 + 14);
|
||||
|
||||
return (
|
||||
<path
|
||||
key={c.id}
|
||||
d={bezierPath(f.x, f.y, t.x, t.y)}
|
||||
className={cls}
|
||||
markerEnd={marker}
|
||||
/>
|
||||
<g key={c.id}>
|
||||
<path d={bezierPath(f.x, f.y, t.x, t.y)} className={cls} />
|
||||
{portLabel && (
|
||||
<g transform={`translate(${mx}, ${my - 11})`}>
|
||||
<rect
|
||||
x={-labelW / 2} y={-9}
|
||||
width={labelW} height={18} rx={4}
|
||||
fill="var(--v5-surface-solid)"
|
||||
stroke={labelColor}
|
||||
strokeWidth={1}
|
||||
opacity={0.95}
|
||||
/>
|
||||
<text
|
||||
y={4}
|
||||
textAnchor="middle"
|
||||
fontSize={10}
|
||||
fontWeight={700}
|
||||
fill={labelColor}
|
||||
fontFamily="var(--v5-font-mono)"
|
||||
>
|
||||
{portLabel}
|
||||
</text>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* 연결 삭제 뱃지 */}
|
||||
{ruleConnections.map((c) => {
|
||||
const f = portPos(c.from_node_id, c.from_port);
|
||||
const t = portPos(c.to_node_id, c.to_port);
|
||||
const f = portPos(c.from_node_id, c.from_port, 'from');
|
||||
const t = portPos(c.to_node_id, c.to_port, 'to');
|
||||
if (!f || !t) return null;
|
||||
const mx = (f.x + t.x) / 2, my = (f.y + t.y) / 2;
|
||||
|
||||
@@ -199,12 +248,10 @@ export function RuleBuilder({ canvasRef }: RuleBuilderProps) {
|
||||
y={node.y}
|
||||
onMove={(_, x, y) => moveRuleNode(node.id, x, y)}
|
||||
style={{ overflow: 'visible' }}
|
||||
nodeId={node.id}
|
||||
onPortDragStart={startDrag}
|
||||
onPortDragEnd={finishDrag}
|
||||
/>
|
||||
{/* I/O 포트 */}
|
||||
<PortHandle nodeId={node.id} port="in" type="in" isTable onDragEnd={finishDrag} />
|
||||
<div style={{ position: 'absolute', left: node.x + 194, top: node.y + 12 }}>
|
||||
<PortHandle nodeId={node.id} port="out" type="out" isTable label="→" onDragStart={startDrag} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -221,6 +268,6 @@ export function RuleBuilder({ canvasRef }: RuleBuilderProps) {
|
||||
|
||||
{/* 설정 팝오버 */}
|
||||
<NodeConfigPopover />
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { useRef, useCallback } from 'react';
|
||||
import { Database, X } from 'lucide-react';
|
||||
import { PortHandle } from './PortHandle';
|
||||
import { useControlMode } from './hooks/useControlMode';
|
||||
|
||||
interface TableNodeProps {
|
||||
tableName: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
/** 호환용 — 더 이상 사용 X (V3 컴팩트로 갈아엎으면서 이모지 폐기, Lucide Database 아이콘 고정) */
|
||||
icon?: string;
|
||||
columns: Record<string, any>[];
|
||||
x: number;
|
||||
y: number;
|
||||
style?: React.CSSProperties;
|
||||
onMove?: (name: string, x: number, y: number) => void;
|
||||
/** 룰 노드 ID (PortHandle 연결용). 없으면 시각 카드만 (read-only) */
|
||||
nodeId?: string;
|
||||
onPortDragStart?: (nodeId: string, port: string, e: React.MouseEvent) => void;
|
||||
onPortDragEnd?: (nodeId: string, port: string) => void;
|
||||
}
|
||||
|
||||
export function TableNode({ tableName, label, icon, columns, x, y, style, onMove }: TableNodeProps) {
|
||||
/**
|
||||
* 테이블 카드 — V3RuleNode 와 일관된 컴팩트 디자인
|
||||
* - 180px 폭, cyan top stripe, Lucide Database 아이콘
|
||||
* - 한글 라벨 메인 + mono 영문 sub
|
||||
* - stats row: `{N} cols · {K} FK`
|
||||
* - 좌·우 edge 에 단일 port 1개씩 (테이블 단위 연결 — 컬럼은 노드 설정창 dropdown 에서)
|
||||
*/
|
||||
export function TableNode({
|
||||
tableName, label, columns, x, y, style, onMove, nodeId, onPortDragStart, onPortDragEnd,
|
||||
}: TableNodeProps) {
|
||||
const nodeRef = useRef<HTMLDivElement>(null);
|
||||
const removeRuleNode = useControlMode((s) => s.removeRuleNode);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (!onMove) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('.ctrl-io-port, button')) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const sx = e.clientX, sy = e.clientY;
|
||||
const sl = x, st = y;
|
||||
const el = nodeRef.current;
|
||||
if (el) el.style.zIndex = '30';
|
||||
let moved = false;
|
||||
|
||||
const move = (ev: MouseEvent) => {
|
||||
onMove(tableName, sl + ev.clientX - sx, st + ev.clientY - sy);
|
||||
const dx = ev.clientX - sx, dy = ev.clientY - sy;
|
||||
if (!moved && Math.abs(dx) + Math.abs(dy) < 2) return;
|
||||
moved = true;
|
||||
onMove(tableName, sl + dx, st + dy);
|
||||
};
|
||||
const up = () => {
|
||||
if (el) el.style.zIndex = '20';
|
||||
@@ -36,42 +61,69 @@ export function TableNode({ tableName, label, icon, columns, x, y, style, onMove
|
||||
document.addEventListener('mouseup', up);
|
||||
}, [onMove, tableName, x, y]);
|
||||
|
||||
const dtypeIcons: Record<string, string> = {
|
||||
text: 'Aa', number: '#', date: '📅', select: '▼', checkbox: '☑', file: '📎', code: '⚡',
|
||||
textarea: 'Aa', datetime: '📅', entity: '🔗',
|
||||
};
|
||||
// stats
|
||||
const totalCols = columns?.length ?? 0;
|
||||
const fkCount = (columns ?? []).filter((c) => c.mark === 'FK' || c.type === 'entity').length;
|
||||
const pkCount = (columns ?? []).filter((c) => c.pk).length;
|
||||
const hasKoLabel = label && label !== tableName;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={nodeRef}
|
||||
className="tbl-node"
|
||||
className="tbl-node tbl-node-compact"
|
||||
data-table={tableName}
|
||||
data-node-id={nodeId}
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{ left: x, top: y, ...style }}
|
||||
>
|
||||
<div className="tbl-node-head" onMouseDown={handleMouseDown}>
|
||||
<div className="tbl-icon">{icon}</div>
|
||||
<span className="tbl-name">{tableName}</span>
|
||||
<span className="tbl-badge">{label}</span>
|
||||
</div>
|
||||
<div className="tbl-node-cols">
|
||||
{columns.map((col) => {
|
||||
const name = col.column ?? col.name ?? col.COLUMN_NAME ?? '';
|
||||
const type = col.type ?? col.dtype ?? 'text';
|
||||
const mark = col.pk ? 'PK' : col.mark === 'FK' ? 'FK' : '';
|
||||
const portCls = mark === 'PK' ? 'pk' : mark === 'FK' ? 'fk' : '';
|
||||
const displayName = col.label ?? col.dname ?? name;
|
||||
const dtIcon = dtypeIcons[type] || 'Aa';
|
||||
{/* cyan top stripe (V3RuleNode cat-stripe 와 일관) */}
|
||||
<div className="tbl-node-stripe" />
|
||||
|
||||
return (
|
||||
<div key={name} className="tbl-col" data-col={name}>
|
||||
<div className={`tbl-port ${portCls}`} />
|
||||
<span className="tbl-col-name">{displayName}</span>
|
||||
<span className="tbl-col-type">{dtIcon} {type}</span>
|
||||
{mark && <span className={`tbl-col-mark ${mark.toLowerCase()}`}>{mark}</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="tbl-node-head">
|
||||
<div className="tbl-node-ico"><Database size={11} /></div>
|
||||
<div className="tbl-node-title">
|
||||
<div className="tbl-node-label">{hasKoLabel ? label : tableName}</div>
|
||||
{hasKoLabel && <div className="tbl-node-sub">{tableName}</div>}
|
||||
</div>
|
||||
{nodeId && (
|
||||
<button
|
||||
type="button"
|
||||
className="tbl-node-del"
|
||||
title="삭제"
|
||||
onClick={(e) => { e.stopPropagation(); removeRuleNode(nodeId); }}
|
||||
>
|
||||
<X size={10} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="tbl-node-stats">
|
||||
<span>{totalCols} cols</span>
|
||||
{pkCount > 0 && <span>· {pkCount} PK</span>}
|
||||
{fkCount > 0 && <span>· {fkCount} FK</span>}
|
||||
</div>
|
||||
|
||||
{/* 좌·우 단일 port — 테이블 단위 연결 (컬럼 선택은 노드 설정창 dropdown) */}
|
||||
{nodeId && (
|
||||
<>
|
||||
<PortHandle
|
||||
nodeId={nodeId}
|
||||
port="in"
|
||||
type="in"
|
||||
onDragEnd={onPortDragEnd}
|
||||
onDragStart={onPortDragStart}
|
||||
/>
|
||||
<div className="ctrl-an-ports-out">
|
||||
<PortHandle
|
||||
nodeId={nodeId}
|
||||
port="out"
|
||||
type="out"
|
||||
onDragStart={onPortDragStart}
|
||||
onDragEnd={onPortDragEnd}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,9 +34,11 @@ export const CTRL_NODE_TYPES: Record<string, {
|
||||
interface ControlModeState {
|
||||
/** 제어 모드 활성 여부 */
|
||||
active: boolean;
|
||||
/** 읽기 / 편집 모드 */
|
||||
mode: 'view' | 'edit';
|
||||
/** 활성 흐름 — 클릭된 카드 ID */
|
||||
/** 읽기 / 편집 / 실행 / 이력 모드 (선택된 카드 컨텍스트 안의 토글, v3 — IDE 4-segmented tabs) */
|
||||
mode: 'view' | 'edit' | 'run' | 'history';
|
||||
/** 선택된 카드 ID — 카드 클릭 시 좌측 축소 + 그 옆에 제어 패널 */
|
||||
selectedCardId: string | null;
|
||||
/** 활성 흐름 — FlowViewer 내부 상태 (selectedCardId 와 동기화) */
|
||||
activeFlowCardId: string | null;
|
||||
/** 흐름 엣지 배열 (BFS 결과) */
|
||||
flowEdges: Record<string, any>[];
|
||||
@@ -55,7 +57,8 @@ interface ControlModeState {
|
||||
|
||||
// 액션
|
||||
toggleControlMode: () => void;
|
||||
setMode: (mode: 'view' | 'edit') => void;
|
||||
setMode: (mode: 'view' | 'edit' | 'run' | 'history') => void;
|
||||
setSelectedCardId: (cardId: string | null) => void;
|
||||
setActiveFlowCard: (cardId: string | null) => void;
|
||||
setFlowEdges: (edges: Record<string, any>[]) => void;
|
||||
setTablePositions: (pos: Record<string, { x: number; y: number }>) => void;
|
||||
@@ -82,6 +85,7 @@ export const useControlMode = create<ControlModeState>()(
|
||||
(set) => ({
|
||||
active: false,
|
||||
mode: 'view',
|
||||
selectedCardId: null,
|
||||
activeFlowCardId: null,
|
||||
flowEdges: [],
|
||||
tablePositions: {},
|
||||
@@ -94,14 +98,29 @@ export const useControlMode = create<ControlModeState>()(
|
||||
set((s) => ({
|
||||
active: !s.active,
|
||||
mode: 'view',
|
||||
selectedCardId: null,
|
||||
activeFlowCardId: null,
|
||||
flowEdges: [],
|
||||
tablePositions: {},
|
||||
ruleNodes: [],
|
||||
ruleConnections: [],
|
||||
configNodeId: null,
|
||||
})),
|
||||
|
||||
setMode: (mode) => set({ mode, configNodeId: null }),
|
||||
|
||||
setSelectedCardId: (cardId) =>
|
||||
set({
|
||||
selectedCardId: cardId,
|
||||
// 카드 바꾸면 모드/룰 초기화 (각 카드는 자기 제어 컨텍스트)
|
||||
mode: 'view',
|
||||
activeFlowCardId: cardId,
|
||||
ruleNodes: [],
|
||||
ruleConnections: [],
|
||||
activeRuleId: null,
|
||||
configNodeId: null,
|
||||
}),
|
||||
|
||||
setActiveFlowCard: (cardId) => set({ activeFlowCardId: cardId }),
|
||||
|
||||
setFlowEdges: (edges) => set({ flowEdges: edges }),
|
||||
@@ -152,6 +171,7 @@ export const useControlMode = create<ControlModeState>()(
|
||||
set({
|
||||
active: false,
|
||||
mode: 'view',
|
||||
selectedCardId: null,
|
||||
activeFlowCardId: null,
|
||||
flowEdges: [],
|
||||
tablePositions: {},
|
||||
|
||||
@@ -59,24 +59,58 @@ export function usePortDrag(canvasRef: React.RefObject<HTMLDivElement | null>) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
// 중복 방지
|
||||
if (ruleConnections.find((c) =>
|
||||
// ★ [HIGH] port direction validation — output → output 역방향 엣지 차단
|
||||
// from_port 는 in/out/yes/no/pass/fail/approved/rejected 등 (output port 만 허용)
|
||||
// to_port 는 in 만 허용 (input port 도착점)
|
||||
// 단 테이블 port 는 양방향 (in/out 둘 다 가능, PortHandle 단일 dot 양방향화)
|
||||
// → 노드 type 으로 분기
|
||||
const stateForValidate = useControlMode.getState();
|
||||
const fromNodeForVal = stateForValidate.ruleNodes.find((n) => n.id === d.fromNodeId);
|
||||
const toNodeForVal = stateForValidate.ruleNodes.find((n) => n.id === toNodeId);
|
||||
// 도착이 action 노드면 to_port 는 'in' 이어야 함 (action 노드는 좌측 in 만 mouseup 받음)
|
||||
if (toNodeForVal && toNodeForVal.type !== 'table' && toPort !== 'in') {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
// 출발이 action 노드면 from_port 는 in 이 아니어야 함 (action 노드의 in 에서 시작은 의미 없음)
|
||||
if (fromNodeForVal && fromNodeForVal.type !== 'table' && d.fromPort === 'in') {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
// 중복 방지 — getState() 로 최신 ruleConnections 사용 (render-captured stale 회피)
|
||||
const currentConns = stateForValidate.ruleConnections;
|
||||
if (currentConns.find((c) =>
|
||||
c.from_node_id === d.fromNodeId && c.from_port === d.fromPort && c.to_node_id === toNodeId
|
||||
)) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 3: edge_type 자동 추론 (위 validation 에서 가져온 노드 재사용)
|
||||
// table → table = lookup (FK 참조)
|
||||
// table → action = data-context (테이블 데이터를 노드 입력으로)
|
||||
// action → table = table-mutation (노드 결과를 테이블에 저장/수정)
|
||||
// action → action = execution-flow (실행 순서)
|
||||
const fromIsTable = fromNodeForVal?.type === 'table';
|
||||
const toIsTable = toNodeForVal?.type === 'table';
|
||||
let edgeType: 'data-context' | 'execution-flow' | 'table-mutation' | 'lookup';
|
||||
if (fromIsTable && toIsTable) edgeType = 'lookup';
|
||||
else if (fromIsTable && !toIsTable) edgeType = 'data-context';
|
||||
else if (!fromIsTable && toIsTable) edgeType = 'table-mutation';
|
||||
else edgeType = 'execution-flow';
|
||||
|
||||
addRuleConnection({
|
||||
id: genConnId(),
|
||||
from_node_id: d.fromNodeId,
|
||||
from_port: d.fromPort,
|
||||
to_node_id: toNodeId,
|
||||
to_port: toPort,
|
||||
edge_type: edgeType,
|
||||
});
|
||||
|
||||
cleanup();
|
||||
}, [addRuleConnection, ruleConnections]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [addRuleConnection]);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
const d = dragRef.current;
|
||||
@@ -89,6 +123,8 @@ export function usePortDrag(canvasRef: React.RefObject<HTMLDivElement | null>) {
|
||||
}, [canvasRef]);
|
||||
|
||||
// 마우스 이동/종료 전역 핸들러
|
||||
// ★ mouseup 시 e.target 의 closest .ctrl-io-port 를 직접 찾아서 finishDrag 호출
|
||||
// (PortHandle 의 onMouseUp 에 의존하면 race + 6px hit-target 문제로 연결 실패)
|
||||
useEffect(() => {
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const d = dragRef.current;
|
||||
@@ -99,10 +135,43 @@ export function usePortDrag(canvasRef: React.RefObject<HTMLDivElement | null>) {
|
||||
const x2 = e.clientX - cr.left + cv.scrollLeft;
|
||||
const y2 = e.clientY - cr.top + cv.scrollTop;
|
||||
d.line.setAttribute('d', bezierPath(d.x1, d.y1, x2, y2));
|
||||
|
||||
// 호버 중인 port 강조
|
||||
document.querySelectorAll('.ctrl-io-port.port-hover').forEach((el) => el.classList.remove('port-hover'));
|
||||
const hoverPort = (e.target as HTMLElement)?.closest?.('.ctrl-io-port') as HTMLElement | null;
|
||||
if (hoverPort && hoverPort.dataset.node !== d.fromNodeId) {
|
||||
hoverPort.classList.add('port-hover');
|
||||
}
|
||||
};
|
||||
|
||||
const onUp = () => {
|
||||
if (dragRef.current) cleanup();
|
||||
const onUp = (e: MouseEvent) => {
|
||||
if (!dragRef.current) return;
|
||||
// ① e.target 의 closest 로 port 찾기 (정확히 port 위에서 mouseup 한 경우)
|
||||
let portEl = (e.target as HTMLElement | null)?.closest?.('.ctrl-io-port') as HTMLElement | null;
|
||||
// ② 못 찾으면 마우스 좌표 주변 20px 내 가장 가까운 port 검색 (port 근처에서 mouseup)
|
||||
if (!portEl) {
|
||||
const candidates = document.querySelectorAll<HTMLElement>('.ctrl-io-port');
|
||||
let best: { el: HTMLElement; dist: number } | null = null;
|
||||
candidates.forEach((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
|
||||
const dx = e.clientX - cx, dy = e.clientY - cy;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < 24 && (!best || dist < best.dist)) {
|
||||
best = { el, dist };
|
||||
}
|
||||
});
|
||||
if (best) portEl = (best as { el: HTMLElement; dist: number }).el;
|
||||
}
|
||||
if (portEl) {
|
||||
const toNodeId = portEl.dataset.node;
|
||||
const toPort = portEl.dataset.port;
|
||||
if (toNodeId && toPort) {
|
||||
finishDrag(toNodeId, toPort);
|
||||
return;
|
||||
}
|
||||
}
|
||||
cleanup();
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', onMove);
|
||||
@@ -111,7 +180,7 @@ export function usePortDrag(canvasRef: React.RefObject<HTMLDivElement | null>) {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
}, [canvasRef, cleanup]);
|
||||
}, [canvasRef, cleanup, finishDrag]);
|
||||
|
||||
return { startDrag, finishDrag };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Canvas — 4-모드 중앙 캔버스 (v3 V3Canvas / V3ViewCanvas / V3EditCanvas / V3RunCanvas / V3HistoryCanvas)
|
||||
*
|
||||
* view : 관계 트리 (listRelations API)
|
||||
* edit : 룰 에디터 (기존 RuleBuilder 호출, 단계 6 에서 PanZoomStage 베이스로 갈아끼움)
|
||||
* run : 단계별 실행 시각화 (mock 진행)
|
||||
* history : 실행 이력 테이블 (listExecutionHistory API)
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Table2, History as HistoryIcon, Play, Pause, SkipBack, SkipForward,
|
||||
ChevronLeft, ChevronRight, Check,
|
||||
} from 'lucide-react';
|
||||
import { useControlMode, CTRL_NODE_TYPES } from '../hooks/useControlMode';
|
||||
import { PanZoomStage } from './PanZoomStage';
|
||||
import { RuleBuilder } from '../RuleBuilder';
|
||||
import {
|
||||
listRelations, listExecutionHistory,
|
||||
type TableRelation, type ExecutionRecord,
|
||||
} from '@/lib/api/control';
|
||||
|
||||
interface CanvasProps {
|
||||
card: Record<string, any>;
|
||||
/** DashboardCanvas ref (호환용, IDE EditCanvas 는 자체 ref 사용) */
|
||||
canvasRef: React.RefObject<HTMLDivElement | null>;
|
||||
dashboardId: string;
|
||||
}
|
||||
|
||||
export function Canvas({ card, dashboardId }: CanvasProps) {
|
||||
const mode = useControlMode((s) => s.mode);
|
||||
|
||||
return (
|
||||
<div className="ctrl-ide-canvas-inner">
|
||||
{mode === 'view' && <ViewCanvas card={card} dashboardId={dashboardId} />}
|
||||
{mode === 'edit' && <EditCanvas />}
|
||||
{mode === 'run' && <RunCanvas />}
|
||||
{mode === 'history' && <HistoryCanvas card={card} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── VIEW — 관계 트리 ─── */
|
||||
function ViewCanvas({ card }: { card: Record<string, any>; dashboardId: string }) {
|
||||
const tableName = card.primary_table ?? card.PRIMARY_TABLE ?? '';
|
||||
const cardTitle = card.title ?? card.TITLE ?? '카드';
|
||||
const [rels, setRels] = useState<TableRelation[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!tableName) return;
|
||||
let alive = true;
|
||||
listRelations(tableName).then((r) => { if (alive) setRels(r); });
|
||||
return () => { alive = false; };
|
||||
}, [tableName]);
|
||||
|
||||
const W = 1000, H = 540;
|
||||
const targets = useMemo(() => {
|
||||
if (rels.length === 0) return [];
|
||||
return rels.map((r, i) => {
|
||||
const t = rels.length === 1 ? 0.5 : i / (rels.length - 1);
|
||||
return { x: 750, y: 80 + t * 380, name: r.to, type: r.type, edgeLabel: r.label };
|
||||
});
|
||||
}, [rels]);
|
||||
|
||||
return (
|
||||
<PanZoomStage width={W} height={H}>
|
||||
<svg width={W} height={H} style={{ position: 'absolute', inset: 0 }}>
|
||||
<defs>
|
||||
<pattern id="v3-dots" width={16} height={16} patternUnits="userSpaceOnUse">
|
||||
<circle cx={1} cy={1} r={0.7} fill="rgba(var(--v5-cyan-rgb), .16)" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width={W} height={H} fill="url(#v3-dots)" />
|
||||
|
||||
{/* edges */}
|
||||
{targets.map((t, i) => {
|
||||
const isAuto = t.type === 'auto';
|
||||
const rgb = isAuto ? '108,92,231' : '0,154,150';
|
||||
const x1 = 250, y1 = H / 2, x2 = t.x - 100, y2 = t.y;
|
||||
const mx = (x1 + x2) / 2;
|
||||
return (
|
||||
<g key={i}>
|
||||
<path
|
||||
d={`M ${x1} ${y1} C ${mx} ${y1}, ${mx} ${y2}, ${x2} ${y2}`}
|
||||
stroke={`rgb(${rgb})`} strokeWidth={2} opacity={0.5}
|
||||
fill="none" strokeDasharray={isAuto ? '0' : '6 4'}
|
||||
/>
|
||||
<g transform={`translate(${mx}, ${(y1 + y2) / 2 - 10})`}>
|
||||
<rect x={-40} y={-10} width={80} height={20} rx={10}
|
||||
fill="var(--v5-surface-solid)" stroke={`rgba(${rgb}, .45)`} strokeWidth={1.2} />
|
||||
<text y={4} textAnchor="middle" fontSize={10} fontWeight={700} fill={`rgb(${rgb})`}>
|
||||
{t.edgeLabel}
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* source highlight */}
|
||||
<rect x={50} y={H / 2 - 56} width={200} height={112} rx={12}
|
||||
fill="rgba(var(--v5-cyan-rgb), .05)" stroke="rgb(var(--v5-cyan-rgb))" strokeWidth={2} />
|
||||
</svg>
|
||||
|
||||
{/* source label */}
|
||||
<div style={{
|
||||
position: 'absolute', left: 50, top: H / 2 - 56, width: 200, height: 112,
|
||||
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 4,
|
||||
}}>
|
||||
<div className="ctrl-canvas-tag" style={{ color: 'rgb(0, 154, 150)' }}>SOURCE</div>
|
||||
<div style={{ fontSize: '.85rem', fontWeight: 800, letterSpacing: '-.01em' }}>{cardTitle}</div>
|
||||
<div className="ctrl-canvas-mono">{tableName || '—'}</div>
|
||||
</div>
|
||||
|
||||
{/* target nodes */}
|
||||
{targets.map((t) => (
|
||||
<div key={t.name} className="ctrl-canvas-relnode" style={{
|
||||
position: 'absolute', left: t.x - 100, top: t.y - 36, width: 200,
|
||||
borderColor: t.type === 'auto'
|
||||
? 'rgba(var(--v5-primary-rgb), .5)'
|
||||
: 'rgba(var(--v5-cyan-rgb), .5)',
|
||||
}}>
|
||||
<div className="ctrl-canvas-tag" style={{
|
||||
color: t.type === 'auto' ? 'rgb(var(--v5-primary-rgb))' : 'rgb(0, 154, 150)',
|
||||
marginBottom: 5, display: 'flex', alignItems: 'center', gap: 5,
|
||||
}}>
|
||||
<Table2 size={10} />
|
||||
{t.type === 'auto' ? 'AUTO' : 'FK'}
|
||||
</div>
|
||||
<div style={{ fontSize: '.78rem', fontWeight: 700, marginBottom: 4 }}>{t.name}</div>
|
||||
<div className="ctrl-canvas-mono">
|
||||
{t.type === 'auto' ? '동기화' : '참조'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{targets.length === 0 && (
|
||||
<div className="ctrl-canvas-empty">
|
||||
이 테이블의 관계 정보가 없습니다
|
||||
<small>API: GET /api/control/tables/{tableName}/relations</small>
|
||||
</div>
|
||||
)}
|
||||
</PanZoomStage>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── EDIT — RuleBuilder 위임 (컬럼별 마우스 연결 + 노드 드래그 + 팔레트 드롭) ─── */
|
||||
function EditCanvas() {
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<div ref={canvasRef} className="ctrl-edit-canvas-host">
|
||||
<RuleBuilder canvasRef={canvasRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── RUN — 단계별 실행 시각화 (mock 진행) ─── */
|
||||
function RunCanvas() {
|
||||
const ruleNodes = useControlMode((s) => s.ruleNodes);
|
||||
const [playState, setPlayState] = useState<'paused' | 'playing'>('paused');
|
||||
const [playStep, setPlayStep] = useState(0);
|
||||
|
||||
const totalSteps = Math.max(ruleNodes.length, 1);
|
||||
const current = Math.min(playStep, totalSteps);
|
||||
|
||||
useEffect(() => {
|
||||
if (playState !== 'playing') return;
|
||||
if (current >= totalSteps) return;
|
||||
const t = setTimeout(() => setPlayStep((s) => s + 1), 700);
|
||||
return () => clearTimeout(t);
|
||||
}, [playState, current, totalSteps]);
|
||||
|
||||
return (
|
||||
<div className="ctrl-run-shell">
|
||||
{/* top — playback controls */}
|
||||
<div className="ctrl-run-top">
|
||||
<div className={`ctrl-run-state ${playState}`}>
|
||||
{playState === 'playing' ? <Play size={16} /> : <Pause size={16} />}
|
||||
</div>
|
||||
<div>
|
||||
<div className={`ctrl-canvas-tag ${playState === 'playing' ? 'is-play' : 'is-pause'}`}>
|
||||
{playState === 'playing' ? 'LIVE TRACE · 재생 중' : 'LIVE TRACE · 일시정지'}
|
||||
</div>
|
||||
<div style={{ fontSize: '.92rem', fontWeight: 700, marginTop: 2 }}>
|
||||
{ruleNodes.length === 0 ? '룰 없음' : `노드 ${ruleNodes.length}개`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
<div className="ctrl-run-btns">
|
||||
<PlayBtn Ic={SkipBack} onClick={() => setPlayStep(0)} title="처음" />
|
||||
<PlayBtn Ic={ChevronLeft} onClick={() => setPlayStep((s) => Math.max(0, s - 1))} title="이전" />
|
||||
<PlayBtn
|
||||
Ic={playState === 'playing' ? Pause : Play}
|
||||
primary
|
||||
onClick={() => setPlayState((p) => (p === 'playing' ? 'paused' : 'playing'))}
|
||||
title={playState === 'playing' ? '일시정지' : '재생'}
|
||||
/>
|
||||
<PlayBtn Ic={ChevronRight} onClick={() => setPlayStep((s) => Math.min(totalSteps, s + 1))} title="다음" />
|
||||
<PlayBtn Ic={SkipForward} onClick={() => setPlayStep(totalSteps)} title="끝" />
|
||||
</div>
|
||||
|
||||
<div className="ctrl-run-counter">
|
||||
<div className="ctrl-run-counter-num">{current}/{totalSteps}</div>
|
||||
<div className="ctrl-canvas-mono">{Math.round((current / totalSteps) * 100)}%</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* progress */}
|
||||
<div className="ctrl-run-progress">
|
||||
<div className="ctrl-run-progress-bar" style={{ width: `${(current / totalSteps) * 100}%` }} />
|
||||
</div>
|
||||
|
||||
{/* steps */}
|
||||
<div className="ctrl-run-steps">
|
||||
{ruleNodes.length === 0 && (
|
||||
<div className="ctrl-canvas-empty">
|
||||
실행할 룰이 없습니다 — EDIT 모드에서 노드를 추가하세요
|
||||
</div>
|
||||
)}
|
||||
{ruleNodes.map((n, i) => {
|
||||
const def = CTRL_NODE_TYPES[n.type];
|
||||
const rgb = def?.rgb ?? '108,92,231';
|
||||
const done = i < current;
|
||||
const active = i === current - 1 && playState === 'playing';
|
||||
const pending = i >= current;
|
||||
return (
|
||||
<div
|
||||
key={n.id}
|
||||
className={`ctrl-run-step ${active ? 'is-active' : ''} ${done ? 'is-done' : ''} ${pending ? 'is-pending' : ''}`}
|
||||
>
|
||||
<div
|
||||
className="ctrl-run-step-num"
|
||||
style={{
|
||||
background: done ? 'var(--v5-green)' : active ? `rgb(${rgb})` : 'var(--v5-bg-subtle)',
|
||||
color: done || active ? '#fff' : 'var(--v5-text-muted)',
|
||||
boxShadow: active ? `0 0 12px rgba(${rgb}, .5)` : 'none',
|
||||
}}
|
||||
>
|
||||
{done ? <Check size={10} /> : i + 1}
|
||||
</div>
|
||||
<div
|
||||
className="ctrl-run-step-ico"
|
||||
style={{ background: `rgba(${rgb}, .14)`, color: `rgb(${rgb})` }}
|
||||
>
|
||||
{def?.icon ?? '?'}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: '.73rem', fontWeight: 700 }}>{n.label ?? def?.label ?? n.type}</div>
|
||||
<div className="ctrl-canvas-mono">{n.summary?.[0] ?? ''}</div>
|
||||
</div>
|
||||
<LatencyBar ms={Math.round(20 + Math.random() * 60)} max={100} />
|
||||
<span className={`ctrl-run-step-status ${active ? 'is-active' : ''}`}>
|
||||
{done ? '완료' : active ? '진행 중…' : '대기'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayBtn({
|
||||
Ic, onClick, primary, title,
|
||||
}: { Ic: any; onClick: () => void; primary?: boolean; title: string }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
className={`ctrl-run-btn${primary ? ' primary' : ''}`}
|
||||
>
|
||||
<Ic size={11} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function LatencyBar({ ms, max }: { ms: number; max: number }) {
|
||||
const pct = Math.min(100, (ms / max) * 100);
|
||||
const color = pct < 50 ? 'var(--v5-green)' : pct < 80 ? 'var(--v5-amber)' : 'var(--v5-red)';
|
||||
return (
|
||||
<div className="ctrl-latency-bar" title={`${ms}ms`}>
|
||||
<div style={{ width: `${pct}%`, background: color }} />
|
||||
<span>{ms}ms</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── HISTORY — 실행 이력 테이블 ─── */
|
||||
function HistoryCanvas({ card }: { card: Record<string, any> }) {
|
||||
const cardId = card.card_id ?? card.CARD_ID ?? card.id ?? '';
|
||||
const [items, setItems] = useState<ExecutionRecord[]>([]);
|
||||
const [filter, setFilter] = useState<'all' | 'ok' | 'fail'>('all');
|
||||
|
||||
useEffect(() => {
|
||||
if (!cardId) return;
|
||||
let alive = true;
|
||||
listExecutionHistory(cardId, { limit: 50 }).then((r) => { if (alive) setItems(r); });
|
||||
return () => { alive = false; };
|
||||
}, [cardId]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (filter === 'all') return items;
|
||||
if (filter === 'ok') return items.filter((i) => i.ok);
|
||||
return items.filter((i) => !i.ok);
|
||||
}, [items, filter]);
|
||||
|
||||
const okCount = items.filter((i) => i.ok).length;
|
||||
const failCount = items.length - okCount;
|
||||
|
||||
return (
|
||||
<div className="ctrl-history-shell">
|
||||
<div className="ctrl-history-top">
|
||||
<div className="ctrl-history-tag">
|
||||
<HistoryIcon size={11} />
|
||||
EXECUTION HISTORY
|
||||
</div>
|
||||
<div className="ctrl-canvas-mono">
|
||||
최근 <b>{items.length}</b>회 · 24h
|
||||
</div>
|
||||
<div style={{ flex: 1 }} />
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value as 'all' | 'ok' | 'fail')}
|
||||
className="ctrl-history-select"
|
||||
>
|
||||
<option value="all">전체 ({items.length})</option>
|
||||
<option value="ok">성공 ({okCount})</option>
|
||||
<option value="fail">실패 ({failCount})</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="ctrl-history-body">
|
||||
<table className="ctrl-history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>TS</th>
|
||||
<th>TRIGGER</th>
|
||||
<th>WHO</th>
|
||||
<th style={{ textAlign: 'right' }}>STEPS</th>
|
||||
<th style={{ textAlign: 'right' }}>LATENCY</th>
|
||||
<th>RESULT</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((ex) => (
|
||||
<tr key={ex.id}>
|
||||
<td>
|
||||
<span
|
||||
className="ctrl-history-dot"
|
||||
style={{
|
||||
background: ex.ok ? 'var(--v5-green)' : 'var(--v5-red)',
|
||||
boxShadow: ex.ok
|
||||
? '0 0 6px var(--v5-green)'
|
||||
: '0 0 6px var(--v5-red)',
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="ctrl-history-mono">{ex.ts}</td>
|
||||
<td className="ctrl-history-mono">{ex.trig}</td>
|
||||
<td className="ctrl-history-mono ctrl-history-sec">{ex.who}</td>
|
||||
<td className="ctrl-history-mono" style={{ textAlign: 'right' }}>{ex.steps}/8</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<LatencyBar ms={ex.ms} max={400} />
|
||||
</td>
|
||||
<td>
|
||||
<span className={`ctrl-history-result ${ex.ok ? 'ok' : 'fail'}`}>
|
||||
{ex.ok ? 'OK' : 'FAIL'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button className="ctrl-history-more">
|
||||
<ChevronRight size={11} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8}>
|
||||
<div className="ctrl-canvas-empty" style={{ position: 'static' }}>
|
||||
실행 이력 없음
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Eye, Pencil, Play, History, Zap, LayoutDashboard,
|
||||
Save, Undo2, FolderOpen, Search, X,
|
||||
} from 'lucide-react';
|
||||
import { useControlMode } from '../hooks/useControlMode';
|
||||
import { listPresence, type PresenceUser } from '@/lib/api/control';
|
||||
|
||||
interface ContextBarProps {
|
||||
selectedCard: Record<string, any>;
|
||||
onExit: () => void; // 카드 닫기 (제어 유지)
|
||||
onCtrlExit: () => void; // 제어 종료
|
||||
}
|
||||
|
||||
const MODE_TABS = [
|
||||
{ k: 'view' as const, Ic: Eye, label: 'READ' },
|
||||
{ k: 'edit' as const, Ic: Pencil, label: 'EDIT' },
|
||||
{ k: 'run' as const, Ic: Play, label: 'RUN' },
|
||||
{ k: 'history' as const, Ic: History, label: 'HISTORY' },
|
||||
];
|
||||
|
||||
export function ContextBar({ selectedCard, onExit, onCtrlExit }: ContextBarProps) {
|
||||
const mode = useControlMode((s) => s.mode);
|
||||
const setMode = useControlMode((s) => s.setMode);
|
||||
|
||||
const [presence, setPresence] = useState<PresenceUser[]>([]);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
listPresence('').then((p) => { if (alive) setPresence(p); });
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
|
||||
const tableName = selectedCard.primary_table ?? selectedCard.PRIMARY_TABLE ?? '';
|
||||
const cardTitle = selectedCard.title ?? selectedCard.TITLE ?? '카드';
|
||||
const dirtyCount = 0; // TODO 단계 6에서 store 도입
|
||||
|
||||
return (
|
||||
<div className="ctrl-ide-ctxbar">
|
||||
{/* 좌측 — 배지 + brumb */}
|
||||
<div className="ctrl-ide-badge">
|
||||
<Zap size={10} strokeWidth={2.5} />
|
||||
CONTROL IDE
|
||||
</div>
|
||||
<span className="ctrl-ide-sep">/</span>
|
||||
<button className="ctrl-ide-tool" disabled>
|
||||
<LayoutDashboard size={11} />
|
||||
운영 대시보드
|
||||
</button>
|
||||
<span className="ctrl-ide-sep">/</span>
|
||||
<div className="ctrl-ide-crumb-card">
|
||||
{cardTitle}
|
||||
{tableName && <span className="ctrl-ide-crumb-tbl">{tableName}</span>}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
{/* presence stack — 빈 배열이면 미렌더 */}
|
||||
{presence.length > 0 && (
|
||||
<>
|
||||
<div className="ctrl-presence">
|
||||
{presence.slice(0, 4).map((p, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={`ctrl-presence-avatar${p.mode === 'edit' ? ' is-edit' : ''}`}
|
||||
style={{ background: `rgb(${p.color})` }}
|
||||
title={`${p.name} · ${p.mode === 'edit' ? '편집중' : '보는중'}`}
|
||||
>
|
||||
{p.short}
|
||||
</span>
|
||||
))}
|
||||
{presence.length > 4 && (
|
||||
<span className="ctrl-presence-more">+{presence.length - 4}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="ctrl-ide-vsep" aria-hidden="true" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* cmd-k */}
|
||||
<button className="ctrl-ide-tool ctrl-ide-cmdk" title="명령 팔레트 (⌘K)">
|
||||
<Search size={10} />
|
||||
⌘K
|
||||
</button>
|
||||
<span className="ctrl-ide-vsep" aria-hidden="true" />
|
||||
|
||||
{/* mode 4-segmented tabs */}
|
||||
<div className="ctrl-ide-mode-tabs">
|
||||
{MODE_TABS.map(({ k, Ic, label }) => (
|
||||
<button
|
||||
key={k}
|
||||
className={`ctrl-ide-mode-tab${mode === k ? ' on' : ''}`}
|
||||
onClick={() => setMode(k)}
|
||||
>
|
||||
<Ic size={10} />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* toolbar */}
|
||||
<button className="ctrl-ide-tool" title="불러오기">
|
||||
<FolderOpen size={11} />
|
||||
<span>불러오기</span>
|
||||
</button>
|
||||
<button className={`ctrl-ide-tool${dirtyCount > 0 ? ' primary' : ''}`} title="저장">
|
||||
<Save size={11} />
|
||||
<span>{dirtyCount > 0 ? `저장 · ${dirtyCount}` : '저장'}</span>
|
||||
</button>
|
||||
<button className="ctrl-ide-tool" title="되돌리기">
|
||||
<Undo2 size={11} />
|
||||
</button>
|
||||
|
||||
<span className="ctrl-ide-vsep" aria-hidden="true" />
|
||||
|
||||
{/* 카드 닫기 (제어 유지) */}
|
||||
<button
|
||||
onClick={onExit}
|
||||
title="닫고 대시보드로 (제어 유지)"
|
||||
className="ctrl-ide-tool ctrl-ide-close"
|
||||
>
|
||||
<X size={11} />
|
||||
<span>닫기</span>
|
||||
</button>
|
||||
|
||||
{/* 제어 종료 */}
|
||||
<button
|
||||
onClick={onCtrlExit}
|
||||
title="제어 모드 종료"
|
||||
className="ctrl-ide-tool ctrl-ide-exit"
|
||||
>
|
||||
제어 종료
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { X, Zap } from 'lucide-react';
|
||||
|
||||
interface CtrlFabProps {
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
export function CtrlFab({ onExit }: CtrlFabProps) {
|
||||
return (
|
||||
<div className="ctrl-fab">
|
||||
<span className="ctrl-fab-dot" />
|
||||
<Zap size={11} strokeWidth={2.5} />
|
||||
<span>제어 활성 — 카드를 선택하세요</span>
|
||||
<span className="ctrl-fab-sep" />
|
||||
<button onClick={onExit} className="ctrl-fab-x" title="제어 종료">
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Search, LayoutDashboard, Boxes, Database, ChevronRight } from 'lucide-react';
|
||||
import { useControlMode, CTRL_NODE_TYPES } from '../hooks/useControlMode';
|
||||
import { NODE_CATEGORIES, ctrlCatToV3, getNodeIcon } from '../schemas';
|
||||
import { getMetaTableList } from '@/lib/api/meta';
|
||||
|
||||
interface LeftRailProps {
|
||||
cards: Record<string, any>[];
|
||||
selectedCardId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* LeftRail — v3 V3LeftRail 베이스 + invyone 테이블 팔레트
|
||||
*
|
||||
* 섹션:
|
||||
* 1) 이 대시보드의 카드
|
||||
* 2) DB 테이블 — 한글 라벨 가나다순 우선, 영문 name 보조. 이모티콘 / 추천 화이트리스트 없음
|
||||
* 3) 노드 팔레트 (edit 모드만)
|
||||
*
|
||||
* dataTransfer 포맷: text/plain = JSON({ kind: 'table'|'control', name|type })
|
||||
*/
|
||||
export function LeftRail({ cards, selectedCardId }: LeftRailProps) {
|
||||
const mode = useControlMode((s) => s.mode);
|
||||
const setSelectedCardId = useControlMode((s) => s.setSelectedCardId);
|
||||
const [query, setQuery] = useState('');
|
||||
const [tables, setTables] = useState<Record<string, any>[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'edit') return;
|
||||
getMetaTableList().then(setTables).catch(() => {});
|
||||
}, [mode]);
|
||||
|
||||
const { sortedTables, nodeEntries } = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
|
||||
// 테이블 필터 + 정렬
|
||||
const filtered = q
|
||||
? tables.filter((t) => {
|
||||
const name = String(t.table_name ?? t.TABLE_NAME ?? '').toLowerCase();
|
||||
const label = String(t.table_label ?? t.TABLE_LABEL ?? '').toLowerCase();
|
||||
return name.includes(q) || label.includes(q);
|
||||
})
|
||||
: tables;
|
||||
|
||||
// 정렬: 한글 label 있는 것 가나다순 → label 없는 것 (영문 name) 알파벳순
|
||||
const koCollator = new Intl.Collator('ko');
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
const aLabel = String(a.table_label ?? a.TABLE_LABEL ?? '');
|
||||
const bLabel = String(b.table_label ?? b.TABLE_LABEL ?? '');
|
||||
const aName = String(a.table_name ?? a.TABLE_NAME ?? '');
|
||||
const bName = String(b.table_name ?? b.TABLE_NAME ?? '');
|
||||
const aHasKo = !!aLabel && aLabel !== aName;
|
||||
const bHasKo = !!bLabel && bLabel !== bName;
|
||||
if (aHasKo !== bHasKo) return aHasKo ? -1 : 1;
|
||||
if (aHasKo) return koCollator.compare(aLabel, bLabel);
|
||||
return aName.localeCompare(bName);
|
||||
});
|
||||
|
||||
// 노드 필터
|
||||
const filteredNodes = Object.entries(CTRL_NODE_TYPES).filter(([type, def]) => {
|
||||
if (!q) return true;
|
||||
return def.label.toLowerCase().includes(q) || type.toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
return { sortedTables: sorted, nodeEntries: filteredNodes };
|
||||
}, [tables, query]);
|
||||
|
||||
/** 드래그 시작 — text/plain JSON, EditCanvas.handleCanvasDrop 와 호환 */
|
||||
const onDragTable = (e: React.DragEvent, name: string) => {
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({ kind: 'table', name }));
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
};
|
||||
const onDragNode = (e: React.DragEvent, type: string) => {
|
||||
e.dataTransfer.setData('text/plain', JSON.stringify({ kind: 'control', type }));
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
};
|
||||
|
||||
const renderTableItem = (t: Record<string, any>) => {
|
||||
const name = t.table_name ?? t.TABLE_NAME;
|
||||
const rawLabel = t.table_label ?? t.TABLE_LABEL;
|
||||
const hasKoLabel = !!rawLabel && rawLabel !== name;
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className="ctrl-rail-tbl"
|
||||
draggable
|
||||
title={`${rawLabel ?? name}${hasKoLabel ? ` (${name})` : ''} — 캔버스로 드래그`}
|
||||
onDragStart={(e) => onDragTable(e, name)}
|
||||
>
|
||||
<Database size={11} className="ctrl-rail-tbl-ico" />
|
||||
<span className="ctrl-rail-tbl-main">
|
||||
<span className="ctrl-rail-tbl-label">{hasKoLabel ? rawLabel : name}</span>
|
||||
{hasKoLabel && <span className="ctrl-rail-tbl-sub">{name}</span>}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ctrl-ide-leftrail">
|
||||
{/* 검색 */}
|
||||
<div className="ctrl-rail-search">
|
||||
<Search size={11} />
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="테이블 / 노드 검색…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ① 카드 */}
|
||||
<RailSection ic={<LayoutDashboard size={11} />} title="이 대시보드의 카드" count={cards.length}>
|
||||
<div className="ctrl-rail-cards">
|
||||
{cards.map((c) => {
|
||||
const id = c.card_id ?? c.CARD_ID ?? c.id;
|
||||
const title = c.title ?? c.TITLE ?? '카드';
|
||||
const table = c.primary_table ?? c.PRIMARY_TABLE ?? '';
|
||||
const sel = id === selectedCardId;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
className={`ctrl-rail-card${sel ? ' on' : ''}`}
|
||||
onClick={() => setSelectedCardId(id)}
|
||||
>
|
||||
<div className="ctrl-rail-card-ico">
|
||||
<Database size={12} />
|
||||
</div>
|
||||
<div className="ctrl-rail-card-body">
|
||||
<div className="ctrl-rail-card-title">{title}</div>
|
||||
{table && <div className="ctrl-rail-card-tbl">{table}</div>}
|
||||
</div>
|
||||
{sel && <ChevronRight size={10} className="ctrl-rail-card-chev" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{cards.length === 0 && <div className="ctrl-rail-empty">카드 없음</div>}
|
||||
</div>
|
||||
</RailSection>
|
||||
|
||||
{/* ② DB 테이블 (edit 모드일 때만) — 한글 라벨 가나다순 우선, 이모티콘 없음 */}
|
||||
{mode === 'edit' && (
|
||||
<RailSection ic={<Database size={11} />} title="DB 테이블" count={sortedTables.length} expand>
|
||||
<div className="ctrl-rail-tbls">
|
||||
{sortedTables.map((t) => renderTableItem(t))}
|
||||
{sortedTables.length === 0 && query && (
|
||||
<div className="ctrl-rail-empty">검색 결과 없음</div>
|
||||
)}
|
||||
{sortedTables.length === 0 && !query && tables.length === 0 && (
|
||||
<div className="ctrl-rail-empty">로딩 중…</div>
|
||||
)}
|
||||
</div>
|
||||
</RailSection>
|
||||
)}
|
||||
|
||||
{/* ③ 노드 팔레트 (edit 모드만) */}
|
||||
{mode === 'edit' && (
|
||||
<RailSection ic={<Boxes size={11} />} title="노드 팔레트" count={Object.keys(CTRL_NODE_TYPES).length} expand>
|
||||
<div className="ctrl-rail-nodes">
|
||||
{NODE_CATEGORIES.map((cat) => {
|
||||
const items = nodeEntries.filter(([, def]) => ctrlCatToV3(def.cat) === cat.id);
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div key={cat.id} className="ctrl-rail-nodecat">
|
||||
<div className="ctrl-rail-cat-label" style={{ color: `rgb(${cat.rgb})` }}>
|
||||
<span className="ctrl-rail-cat-dot" style={{ background: `rgb(${cat.rgb})` }} />
|
||||
<span>{cat.label}</span>
|
||||
<span className="ctrl-rail-cat-count">{items.length}</span>
|
||||
</div>
|
||||
<div className="ctrl-rail-nodes-grid">
|
||||
{items.map(([type, def]) => {
|
||||
const Ic = getNodeIcon(type);
|
||||
return (
|
||||
<div
|
||||
key={type}
|
||||
className="ctrl-rail-node"
|
||||
draggable
|
||||
onDragStart={(e) => onDragNode(e, type)}
|
||||
title={`${def.label} (${type}) — 캔버스로 드래그`}
|
||||
>
|
||||
<Ic size={10} style={{ color: `rgb(${def.rgb})`, flexShrink: 0 }} />
|
||||
<span className="ctrl-rail-node-label">{def.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{nodeEntries.length === 0 && (
|
||||
<div className="ctrl-rail-empty">검색 결과 없음</div>
|
||||
)}
|
||||
</div>
|
||||
</RailSection>
|
||||
)}
|
||||
|
||||
{mode !== 'edit' && (
|
||||
<div className="ctrl-rail-hint">
|
||||
<Boxes size={14} />
|
||||
<span>EDIT 모드에서 DB 테이블 / 노드 팔레트가 열립니다</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RailSection({
|
||||
ic, title, count, expand, children,
|
||||
}: {
|
||||
ic: React.ReactNode;
|
||||
title: string;
|
||||
count: number;
|
||||
expand?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={`ctrl-rail-sec${expand ? ' expand' : ''}`}>
|
||||
<div className="ctrl-rail-sec-head">
|
||||
{ic}
|
||||
<span className="ctrl-rail-sec-title">{title}</span>
|
||||
<span className="ctrl-rail-sec-count">{count}</span>
|
||||
</div>
|
||||
<div className="ctrl-rail-sec-body">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* PanZoomStage — 휠 줌 + 드래그 팬 + 드롭 핸들러
|
||||
* v3 rich-ui.jsx 의 PanZoomStage 포팅
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { ZoomIn, ZoomOut, Maximize, Hand } from 'lucide-react';
|
||||
|
||||
interface PanZoomStageProps {
|
||||
width: number;
|
||||
height: number;
|
||||
initialFit?: boolean;
|
||||
minScale?: number;
|
||||
maxScale?: number;
|
||||
onCanvasDrop?: (drop: { x: number; y: number; type: string }) => void;
|
||||
children: ReactNode | ((ctx: { scale: number }) => ReactNode);
|
||||
}
|
||||
|
||||
export function PanZoomStage({
|
||||
width, height,
|
||||
initialFit = true,
|
||||
minScale = 0.25, maxScale = 1.6,
|
||||
onCanvasDrop,
|
||||
children,
|
||||
}: PanZoomStageProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const innerRef = useRef<HTMLDivElement>(null);
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
const [scale, setScale] = useState(1);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [dropHover, setDropHover] = useState(false);
|
||||
const dragStart = useRef<{ x: number; y: number } | null>(null);
|
||||
const panStart = useRef<{ x: number; y: number } | null>(null);
|
||||
const scaleRef = useRef(1);
|
||||
const panRef = useRef({ x: 0, y: 0 });
|
||||
|
||||
useEffect(() => { scaleRef.current = scale; }, [scale]);
|
||||
useEffect(() => { panRef.current = pan; }, [pan]);
|
||||
|
||||
// initial fit + recompute on resize
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const fit = () => {
|
||||
const el = ref.current; if (!el) return;
|
||||
const pw = el.clientWidth, ph = el.clientHeight;
|
||||
if (initialFit) {
|
||||
const s = Math.min(pw / width, ph / height, 1);
|
||||
setScale(s);
|
||||
setPan({ x: (pw - width * s) / 2, y: (ph - height * s) / 2 });
|
||||
} else {
|
||||
setPan({ x: (pw - width) / 2, y: (ph - height) / 2 });
|
||||
}
|
||||
};
|
||||
fit();
|
||||
const ro = new ResizeObserver(fit);
|
||||
ro.observe(ref.current);
|
||||
return () => ro.disconnect();
|
||||
}, [width, height, initialFit]);
|
||||
|
||||
const screenToCanvas = (clientX: number, clientY: number) => {
|
||||
if (!ref.current) return { x: 0, y: 0 };
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
return {
|
||||
x: (clientX - rect.left - panRef.current.x) / scaleRef.current,
|
||||
y: (clientY - rect.top - panRef.current.y) / scaleRef.current,
|
||||
};
|
||||
};
|
||||
|
||||
const onMouseDown = (e: React.MouseEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-pz-node], button, input, select, textarea, a')) return;
|
||||
setDragging(true);
|
||||
dragStart.current = { x: e.clientX, y: e.clientY };
|
||||
panStart.current = { ...pan };
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragging) return;
|
||||
const onMove = (e: MouseEvent) => {
|
||||
if (!dragStart.current || !panStart.current) return;
|
||||
const dx = e.clientX - dragStart.current.x;
|
||||
const dy = e.clientY - dragStart.current.y;
|
||||
setPan({ x: panStart.current.x + dx, y: panStart.current.y + dy });
|
||||
};
|
||||
const onUp = () => setDragging(false);
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
}, [dragging]);
|
||||
|
||||
const onWheel = (e: React.WheelEvent) => {
|
||||
if (!ref.current) return;
|
||||
e.preventDefault();
|
||||
const delta = -e.deltaY * 0.0015;
|
||||
const next = Math.max(minScale, Math.min(maxScale, scale * (1 + delta)));
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
const cx = e.clientX - rect.left;
|
||||
const cy = e.clientY - rect.top;
|
||||
const k = next / scale;
|
||||
setPan({ x: cx - (cx - pan.x) * k, y: cy - (cy - pan.y) * k });
|
||||
setScale(next);
|
||||
};
|
||||
|
||||
const onDragOver = (e: React.DragEvent) => {
|
||||
if (!onCanvasDrop) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
setDropHover(true);
|
||||
};
|
||||
const onDragLeave = () => setDropHover(false);
|
||||
const onDrop = (e: React.DragEvent) => {
|
||||
if (!onCanvasDrop) return;
|
||||
e.preventDefault();
|
||||
setDropHover(false);
|
||||
const type = e.dataTransfer.getData('application/x-ctrl-node-type')
|
||||
|| e.dataTransfer.getData('text/plain');
|
||||
if (!type) return;
|
||||
const { x, y } = screenToCanvas(e.clientX, e.clientY);
|
||||
onCanvasDrop({ x, y, type });
|
||||
};
|
||||
|
||||
const zoomIn = () => setScale((s) => Math.min(maxScale, s * 1.15));
|
||||
const zoomOut = () => setScale((s) => Math.max(minScale, s / 1.15));
|
||||
const reset = () => {
|
||||
if (!ref.current) return;
|
||||
const pw = ref.current.clientWidth, ph = ref.current.clientHeight;
|
||||
const s = Math.min(pw / width, ph / height, 1);
|
||||
setScale(s);
|
||||
setPan({ x: (pw - width * s) / 2, y: (ph - height * s) / 2 });
|
||||
};
|
||||
|
||||
const childOut = typeof children === 'function' ? children({ scale }) : children;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={ref}
|
||||
onMouseDown={onMouseDown}
|
||||
onWheel={onWheel}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
className="ctrl-pz-stage"
|
||||
style={{
|
||||
cursor: dragging ? 'grabbing' : 'grab',
|
||||
background: dropHover ? 'rgba(var(--v5-cyan-rgb), .04)' : 'transparent',
|
||||
boxShadow: dropHover ? 'inset 0 0 0 2px rgba(var(--v5-cyan-rgb), .4)' : 'none',
|
||||
userSelect: dragging ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={innerRef}
|
||||
style={{
|
||||
position: 'absolute', left: 0, top: 0,
|
||||
width, height,
|
||||
transform: `translate(${pan.x}px, ${pan.y}px) scale(${scale})`,
|
||||
transformOrigin: '0 0',
|
||||
}}
|
||||
>
|
||||
{childOut}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ctrl-pz-zoom">
|
||||
<button onClick={zoomOut} title="축소"><ZoomOut size={11} /></button>
|
||||
<button onClick={reset} title="맞춤"><Maximize size={11} /></button>
|
||||
<button onClick={zoomIn} title="확대"><ZoomIn size={11} /></button>
|
||||
<span className="ctrl-pz-pct">{Math.round(scale * 100)}%</span>
|
||||
</div>
|
||||
<div className="ctrl-pz-hint">
|
||||
<Hand size={10} />
|
||||
{onCanvasDrop ? '드래그로 이동 · 휠로 확대/축소 · 팔레트 드롭으로 노드 추가' : '드래그로 이동 · 휠로 확대/축소'}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Info, Database, ScrollText, Trash2, Activity, Wrench } from 'lucide-react';
|
||||
import { useControlMode, CTRL_NODE_TYPES } from '../hooks/useControlMode';
|
||||
import { NODE_TYPE_SCHEMAS, type NodeFieldSchema } from '../schemas';
|
||||
import { getNodeStats, listNodeComments, type NodeStats, type NodeComment } from '@/lib/api/control';
|
||||
|
||||
interface RightRailProps {
|
||||
selectedCard: Record<string, any>;
|
||||
}
|
||||
|
||||
export function RightRail({ selectedCard }: RightRailProps) {
|
||||
const configNodeId = useControlMode((s) => s.configNodeId);
|
||||
const ruleNodes = useControlMode((s) => s.ruleNodes);
|
||||
const updateRuleNode = useControlMode((s) => s.updateRuleNode);
|
||||
const removeRuleNode = useControlMode((s) => s.removeRuleNode);
|
||||
const setConfigNodeId = useControlMode((s) => s.setConfigNodeId);
|
||||
|
||||
const selectedNode = configNodeId ? ruleNodes.find((n) => n.id === configNodeId) : null;
|
||||
|
||||
return (
|
||||
<div className="ctrl-ide-rightrail">
|
||||
{/* 섹션 1: 노드 설정 / 카드 정보 */}
|
||||
<div className="ctrl-rail-sec">
|
||||
<div className="ctrl-rail-sec-head">
|
||||
{selectedNode ? <Wrench size={11} /> : <Info size={11} />}
|
||||
<span className="ctrl-rail-sec-title">
|
||||
{selectedNode ? '노드 설정' : '데이터 인스펙터'}
|
||||
</span>
|
||||
<span className="ctrl-rail-sec-count">
|
||||
{selectedNode ? selectedNode.id : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ctrl-rail-sec-body">
|
||||
{selectedNode ? (
|
||||
<NodeInspector
|
||||
node={selectedNode}
|
||||
onChange={(patch) => updateRuleNode(selectedNode.id, patch)}
|
||||
onDelete={() => { removeRuleNode(selectedNode.id); setConfigNodeId(null); }}
|
||||
/>
|
||||
) : (
|
||||
<CardInfo card={selectedCard} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 섹션 2: 실행 상태 (v3 V3LiveItem 4개 미러) — 실 데이터 없으면 '—' fallback */}
|
||||
<ActivitySection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivitySection() {
|
||||
// 실 데이터 연결 전: 모든 값 '—' (control.ts 에 getControlActivity API 추가 시 연결)
|
||||
// TODO: API listControlActivity(cardId) 추가 후 useEffect 로 fetch
|
||||
const items: Array<{ label: string; value: string; dot?: 'ok' | 'warn' | 'bad' }> = [
|
||||
{ label: '최근 트리거', value: '—' },
|
||||
{ label: '오늘 실행', value: '—' },
|
||||
{ label: '평균 latency', value: '—' },
|
||||
{ label: '대기 큐', value: '—' },
|
||||
];
|
||||
return (
|
||||
<div className="ctrl-rail-sec">
|
||||
<div className="ctrl-rail-sec-head">
|
||||
<Activity size={11} />
|
||||
<span className="ctrl-rail-sec-title">실행 상태</span>
|
||||
<span className="ctrl-rail-sec-count">live</span>
|
||||
</div>
|
||||
<div className="ctrl-rail-sec-body">
|
||||
<div className="ctrl-activity">
|
||||
{items.map((it) => (
|
||||
<div key={it.label} className="ctrl-activity-row">
|
||||
<span className="ctrl-activity-label">{it.label}</span>
|
||||
<span className="ctrl-activity-value">
|
||||
{it.dot && <span className={`ctrl-activity-dot ${it.dot}`} />}
|
||||
{it.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeInspector({
|
||||
node, onChange, onDelete,
|
||||
}: {
|
||||
node: Record<string, any>;
|
||||
onChange: (patch: Record<string, any>) => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const schema: NodeFieldSchema[] = NODE_TYPE_SCHEMAS[node.type] ?? [];
|
||||
const config: Record<string, any> = node.config ?? {};
|
||||
const def = CTRL_NODE_TYPES[node.type];
|
||||
|
||||
const [stats, setStats] = useState<NodeStats | null>(null);
|
||||
const [comments, setComments] = useState<NodeComment[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
getNodeStats(node.id).then((s) => { if (alive) setStats(s); });
|
||||
listNodeComments(node.id).then((c) => { if (alive) setComments(c); });
|
||||
return () => { alive = false; };
|
||||
}, [node.id]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="ctrl-sec-head">
|
||||
<span className="ctrl-sec-ico"><Info size={11} /></span>
|
||||
Inspector
|
||||
<span className="ctrl-sec-count">{def?.label ?? node.type}</span>
|
||||
<span className="ctrl-sec-right">
|
||||
<button
|
||||
type="button"
|
||||
className="ctrl-ide-tool ctrl-ide-mini"
|
||||
onClick={onDelete}
|
||||
title="노드 삭제"
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="ctrl-ide-inspector">
|
||||
{/* node 식별 */}
|
||||
<div className="ctrl-ide-field ctrl-ide-field-meta">
|
||||
<div>
|
||||
<span className="ctrl-ide-field-k">노드 ID</span>
|
||||
<code>{node.id}</code>
|
||||
</div>
|
||||
{def && (
|
||||
<div>
|
||||
<span className="ctrl-ide-field-k">타입</span>
|
||||
<span style={{ color: `rgb(${def.rgb})`, fontWeight: 700 }}>
|
||||
{def.icon} {def.label}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* schema 기반 필드 */}
|
||||
{schema.length === 0 && (
|
||||
<div className="ctrl-ide-empty">설정 가능한 필드 없음</div>
|
||||
)}
|
||||
{schema.map((f) => (
|
||||
<div key={f.k} className="ctrl-ide-field">
|
||||
<label className="ctrl-ide-field-label">
|
||||
{f.l}
|
||||
{f.locked && <span className="ctrl-ide-field-locked"> · 잠김</span>}
|
||||
</label>
|
||||
{f.select ? (
|
||||
<select
|
||||
value={config[f.k] ?? f.v ?? ''}
|
||||
onChange={(e) => onChange({ config: { ...config, [f.k]: e.target.value } })}
|
||||
disabled={f.locked}
|
||||
className={`ctrl-ide-field-input${f.mono ? ' mono' : ''}`}
|
||||
>
|
||||
{f.select.map((o) => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
) : f.multiline ? (
|
||||
<textarea
|
||||
value={config[f.k] ?? f.v ?? ''}
|
||||
onChange={(e) => onChange({ config: { ...config, [f.k]: e.target.value } })}
|
||||
disabled={f.locked}
|
||||
placeholder={f.hint}
|
||||
className={`ctrl-ide-field-input${f.mono ? ' mono' : ''}`}
|
||||
rows={3}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={config[f.k] ?? f.v ?? ''}
|
||||
onChange={(e) => onChange({ config: { ...config, [f.k]: e.target.value } })}
|
||||
disabled={f.locked}
|
||||
placeholder={f.hint}
|
||||
className={`ctrl-ide-field-input${f.mono ? ' mono' : ''}`}
|
||||
/>
|
||||
)}
|
||||
{f.hint && !f.multiline && <div className="ctrl-ide-field-hint">{f.hint}</div>}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 통계 */}
|
||||
{stats && (
|
||||
<>
|
||||
<div className="ctrl-sec-head" style={{ marginTop: 12 }}>
|
||||
<span className="ctrl-sec-ico"><Activity size={11} /></span>
|
||||
실행 통계
|
||||
</div>
|
||||
<div className="ctrl-ide-stats">
|
||||
<div><span className="ctrl-ide-field-k">실행</span><code>{stats.runs}</code></div>
|
||||
<div><span className="ctrl-ide-field-k">최근 ms</span><code>{stats.lastMs ?? '—'}</code></div>
|
||||
<div>
|
||||
<span className="ctrl-ide-field-k">상태</span>
|
||||
<span className={`ctrl-validation-dot ${stats.valid ? 'ok' : 'bad'}`} />
|
||||
</div>
|
||||
{stats.alert && (
|
||||
<div className="ctrl-ide-stat-alert">{stats.alert}</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 댓글 */}
|
||||
{comments.length > 0 && (
|
||||
<>
|
||||
<div className="ctrl-sec-head" style={{ marginTop: 12 }}>
|
||||
<span className="ctrl-sec-ico"><ScrollText size={11} /></span>
|
||||
댓글
|
||||
<span className="ctrl-sec-count">{comments.length}</span>
|
||||
</div>
|
||||
<div className="ctrl-ide-comments">
|
||||
{comments.map((c, i) => (
|
||||
<div key={i} className="ctrl-ide-comment">
|
||||
<span
|
||||
className="ctrl-ide-avatar"
|
||||
style={{ background: `rgb(${c.color})` }}
|
||||
title={c.who}
|
||||
>
|
||||
{c.short}
|
||||
</span>
|
||||
<div>
|
||||
<div className="ctrl-ide-comment-meta">
|
||||
<b>{c.who}</b><span> · {c.at}</span>
|
||||
</div>
|
||||
<div className="ctrl-ide-comment-text">{c.text}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CardInfo({ card }: { card: Record<string, any> }) {
|
||||
const title = card.title ?? card.TITLE ?? '카드';
|
||||
const table = card.primary_table ?? card.PRIMARY_TABLE ?? '';
|
||||
const cardId = card.card_id ?? card.CARD_ID ?? card.id ?? '';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="ctrl-sec-head">
|
||||
<span className="ctrl-sec-ico"><Database size={11} /></span>
|
||||
카드 정보
|
||||
</div>
|
||||
<div className="ctrl-ide-card-info">
|
||||
<div className="ctrl-ide-field-row">
|
||||
<span className="ctrl-ide-field-k">제목</span>
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
<div className="ctrl-ide-field-row">
|
||||
<span className="ctrl-ide-field-k">테이블</span>
|
||||
<code>{table || '—'}</code>
|
||||
</div>
|
||||
<div className="ctrl-ide-field-row">
|
||||
<span className="ctrl-ide-field-k">ID</span>
|
||||
<code>{cardId}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ctrl-sec-head" style={{ marginTop: 16 }}>
|
||||
<span className="ctrl-sec-ico"><ScrollText size={11} /></span>
|
||||
도움말
|
||||
</div>
|
||||
<div className="ctrl-ide-help">
|
||||
<p>중앙 캔버스에서 노드를 클릭하면 이 패널에서 설정을 편집할 수 있습니다.</p>
|
||||
<p>좌측 팔레트의 노드를 캔버스에 드래그하여 룰을 만드세요.</p>
|
||||
<p>상단 모드 탭으로 <b>READ / EDIT / RUN / HISTORY</b> 를 전환합니다.</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* StatusBar — v3 V3StatusBar 미러
|
||||
* 좌측: Workflow icon + 룰 이름 + dirty + 노드/연결 카운트
|
||||
* 중간: 진행 dot (pulse) + 라텐시
|
||||
* 우측: 최근 실행 시간
|
||||
*
|
||||
* 실 데이터 없는 필드는 '—' fallback (시안 mock 글자 박지 않음)
|
||||
*/
|
||||
|
||||
import { Workflow } from 'lucide-react';
|
||||
import { useControlMode } from '../hooks/useControlMode';
|
||||
|
||||
interface StatusBarProps {
|
||||
selectedCard: Record<string, any>;
|
||||
}
|
||||
|
||||
export function StatusBar({ selectedCard }: StatusBarProps) {
|
||||
void selectedCard;
|
||||
const mode = useControlMode((s) => s.mode);
|
||||
const ruleNodes = useControlMode((s) => s.ruleNodes);
|
||||
const ruleConnections = useControlMode((s) => s.ruleConnections);
|
||||
const activeRuleId = useControlMode((s) => s.activeRuleId);
|
||||
|
||||
// 룰 이름 — store 에 룰 메타 없으면 '—' (룰 메타 API 연결 후 채워짐)
|
||||
const ruleName = activeRuleId ?? '—';
|
||||
|
||||
return (
|
||||
<div className="ctrl-ide-statusbar">
|
||||
<span className="ctrl-status-rule">
|
||||
<Workflow size={11} style={{ color: 'rgb(var(--v5-primary-rgb))' }} />
|
||||
{ruleName}
|
||||
<span className="ctrl-status-ver">v0</span>
|
||||
</span>
|
||||
|
||||
<span><b>NODES</b> <code>{ruleNodes.length}</code></span>
|
||||
<span><b>EDGES</b> <code>{ruleConnections.length}</code></span>
|
||||
<span><b>MODE</b> <code>{mode.toUpperCase()}</code></span>
|
||||
|
||||
<div style={{ flex: 1 }} />
|
||||
|
||||
<span className="ctrl-status-pulse" title="live" />
|
||||
<span><b>최근 실행</b> <code>—</code></span>
|
||||
<span><b>평균</b> <code>—ms</code></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* V3RuleNode — v3 시안 v3-canvas.jsx 의 V3RuleNode 정확 포팅
|
||||
* cat-color stripe + validation dot + comment avatar + cat-chip header + label + summary + stats + ports
|
||||
*/
|
||||
|
||||
import { CTRL_NODE_TYPES } from '../hooks/useControlMode';
|
||||
import { getNodeIcon } from '../schemas';
|
||||
import type { NodeStats, NodeComment } from '@/lib/api/control';
|
||||
|
||||
interface V3RuleNodeProps {
|
||||
node: Record<string, any> & { cx: number; cy: number };
|
||||
scale: number;
|
||||
selected: boolean;
|
||||
dim: boolean;
|
||||
stats?: NodeStats;
|
||||
comments?: NodeComment[];
|
||||
onSelect: () => void;
|
||||
onDrag: (dx: number, dy: number) => void;
|
||||
onContextMenu: (canvasX: number, canvasY: number) => void;
|
||||
}
|
||||
|
||||
export function V3RuleNode({
|
||||
node, scale, selected, dim, stats, comments,
|
||||
onSelect, onDrag, onContextMenu,
|
||||
}: V3RuleNodeProps) {
|
||||
const def = CTRL_NODE_TYPES[node.type];
|
||||
if (!def) return null;
|
||||
const rgb = def.rgb;
|
||||
const Ic = getNodeIcon(node.type);
|
||||
|
||||
// Pointer Events + setPointerCapture — transform/scale 안에서도 mouse 이벤트 안정적으로 받음
|
||||
const onPointerDown = (e: React.PointerEvent) => {
|
||||
if (e.button !== 0) return;
|
||||
if ((e.target as HTMLElement).closest('button, input, select, textarea')) return;
|
||||
e.stopPropagation();
|
||||
const el = e.currentTarget as HTMLDivElement;
|
||||
const pointerId = e.pointerId;
|
||||
try { el.setPointerCapture(pointerId); } catch { /* unsupported */ }
|
||||
const start = { x: e.clientX, y: e.clientY };
|
||||
let moved = false;
|
||||
const onMove = (ev: Event) => {
|
||||
const pe = ev as PointerEvent;
|
||||
const dx = (pe.clientX - start.x) / (scale || 1);
|
||||
const dy = (pe.clientY - start.y) / (scale || 1);
|
||||
if (!moved && Math.abs(dx) + Math.abs(dy) < 2) return;
|
||||
moved = true;
|
||||
onDrag(dx, dy);
|
||||
start.x = pe.clientX;
|
||||
start.y = pe.clientY;
|
||||
};
|
||||
const onUp = () => {
|
||||
try { el.releasePointerCapture(pointerId); } catch { /* */ }
|
||||
el.removeEventListener('pointermove', onMove);
|
||||
el.removeEventListener('pointerup', onUp);
|
||||
el.removeEventListener('pointercancel', onUp);
|
||||
if (!moved) onSelect();
|
||||
};
|
||||
el.addEventListener('pointermove', onMove);
|
||||
el.addEventListener('pointerup', onUp);
|
||||
el.addEventListener('pointercancel', onUp);
|
||||
};
|
||||
|
||||
const onCtx = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onContextMenu(node.cx, node.cy + 70);
|
||||
};
|
||||
|
||||
const lastMs = stats?.lastMs ?? null;
|
||||
const latColor =
|
||||
lastMs == null ? undefined :
|
||||
lastMs < 30 ? 'var(--v5-green)' :
|
||||
lastMs < 100 ? 'var(--v5-text-sec)' :
|
||||
'var(--v5-amber)';
|
||||
const summary = node.summary?.[0]
|
||||
?? (node.config ? Object.entries(node.config).slice(0, 1).map(([k, v]) => `${k}: ${v}`)[0] : null);
|
||||
const firstComment = comments?.[0];
|
||||
|
||||
return (
|
||||
<div
|
||||
data-pz-node="true"
|
||||
draggable={false}
|
||||
className={`v3-rule-node${selected ? ' is-selected' : ''}${dim ? ' is-dim' : ''}`}
|
||||
onPointerDown={onPointerDown}
|
||||
onContextMenu={onCtx}
|
||||
style={{
|
||||
left: node.cx, top: node.cy,
|
||||
borderColor: `rgba(${rgb}, ${selected ? 0.85 : 0.4})`,
|
||||
boxShadow: selected
|
||||
? `0 0 0 4px rgba(${rgb}, .14), 0 0 24px rgba(${rgb}, .22)`
|
||||
: '0 4px 12px -4px rgba(0, 0, 0, .08)',
|
||||
touchAction: 'none',
|
||||
}}
|
||||
>
|
||||
{/* cat-color stripe */}
|
||||
<div className="v3-rule-node-stripe" style={{ background: `rgb(${rgb})` }} />
|
||||
|
||||
{/* validation dot */}
|
||||
{stats && (
|
||||
<span
|
||||
className="v3-rule-node-vdot"
|
||||
style={{
|
||||
background: stats.valid
|
||||
? (stats.alert ? 'var(--v5-amber)' : 'var(--v5-green)')
|
||||
: 'var(--v5-red)',
|
||||
boxShadow: stats.valid
|
||||
? (stats.alert ? '0 0 5px var(--v5-amber)' : '0 0 5px var(--v5-green)')
|
||||
: '0 0 5px var(--v5-red)',
|
||||
}}
|
||||
title={stats.alert || (stats.valid ? '정상' : '검증 실패')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* comment avatar */}
|
||||
{firstComment && (
|
||||
<span
|
||||
className="v3-rule-node-comment"
|
||||
title={firstComment.text}
|
||||
style={{ background: `rgb(${firstComment.color})` }}
|
||||
>
|
||||
{firstComment.short}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* body */}
|
||||
<div className="v3-rule-node-body">
|
||||
<div className="v3-rule-node-cat">
|
||||
<div
|
||||
className="v3-rule-node-cat-ico"
|
||||
style={{ background: `rgba(${rgb}, .14)`, color: `rgb(${rgb})` }}
|
||||
>
|
||||
<Ic size={11} />
|
||||
</div>
|
||||
<span className="v3-rule-node-cat-label" style={{ color: `rgb(${rgb})` }}>
|
||||
{def.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="v3-rule-node-label">{node.label ?? def.label}</div>
|
||||
{summary && <div className="v3-rule-node-summary">{summary}</div>}
|
||||
{stats && (
|
||||
<div className="v3-rule-node-stats">
|
||||
<span>{stats.runs.toLocaleString()} runs</span>
|
||||
{lastMs != null && (
|
||||
<span style={{ fontWeight: 700, color: latColor }}>{lastMs}ms</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ports */}
|
||||
<div className="v3-rule-node-port v3-rule-node-port-in"
|
||||
style={{ borderColor: `rgb(${rgb})` }} />
|
||||
<div className="v3-rule-node-port v3-rule-node-port-out"
|
||||
style={{ background: `rgb(${rgb})` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* 제어 모드 — 노드 타입별 설정 schema (Inspector 자동 렌더용)
|
||||
* v3 시안 shared.jsx 의 NODE_TYPE_SCHEMAS 미러
|
||||
*
|
||||
* 이건 코드 상수 (DB 에 들어갈 데이터 아님). 노드 16종의 "필드 정의" 자체.
|
||||
* 노드 인스턴스의 실제 값은 ruleNode.config 에 들어감.
|
||||
*/
|
||||
|
||||
export interface NodeFieldSchema {
|
||||
k: string;
|
||||
l: string;
|
||||
v?: string;
|
||||
mono?: boolean;
|
||||
select?: string[];
|
||||
multiline?: boolean;
|
||||
hint?: string;
|
||||
locked?: boolean;
|
||||
}
|
||||
|
||||
export const NODE_TYPE_SCHEMAS: Record<string, NodeFieldSchema[]> = {
|
||||
'timer': [
|
||||
{ k: 'schedule', l: '스케줄 (cron)', v: '0 0 * * *', mono: true, hint: '매일 자정' },
|
||||
{ k: 'timezone', l: '타임존', v: 'Asia/Seoul', select: ['Asia/Seoul', 'UTC', 'America/Los_Angeles'] },
|
||||
{ k: 'max_runs', l: '1회 최대 실행 수', v: '1000', mono: true },
|
||||
],
|
||||
'status-change': [
|
||||
{ k: 'table', l: '대상 테이블', v: '', mono: true, locked: true },
|
||||
{ k: 'from', l: '이전 상태', v: '', mono: true },
|
||||
{ k: 'to', l: '변경 상태', v: '', mono: true, hint: '트리거 조건' },
|
||||
],
|
||||
'condition': [
|
||||
{ k: 'expr', l: '조건식', v: '', mono: true, hint: 'JS 표현식 — true/false 반환' },
|
||||
{ k: 'yes_label', l: 'YES 분기 라벨', v: '예' },
|
||||
{ k: 'no_label', l: 'NO 분기 라벨', v: '아니오' },
|
||||
],
|
||||
'validation': [
|
||||
{ k: 'rules', l: '검증 룰', v: '', mono: true, multiline: true },
|
||||
{ k: 'on_fail', l: '실패 시 동작', v: 'abort', select: ['abort', 'skip', 'log'] },
|
||||
{ k: 'alert_owner', l: '실패 알림 대상', v: '' },
|
||||
],
|
||||
'auto-insert': [
|
||||
{ k: 'target', l: '대상 테이블', v: '', mono: true },
|
||||
{ k: 'mapping', l: '필드 매핑', v: '', mono: true, multiline: true },
|
||||
{ k: 'fk_link', l: 'FK 연결 키', v: '', mono: true },
|
||||
],
|
||||
'calculation': [
|
||||
{ k: 'expr', l: '수식', v: '', mono: true },
|
||||
{ k: 'out_field', l: '결과 필드', v: '', mono: true },
|
||||
{ k: 'round', l: '소수점', v: '2' },
|
||||
],
|
||||
'delete': [
|
||||
{ k: 'target', l: '대상 테이블', v: '', mono: true },
|
||||
{ k: 'soft_delete', l: 'Soft delete', v: 'true', select: ['true', 'false'] },
|
||||
{ k: 'archive_to', l: '보관 테이블', v: '', mono: true },
|
||||
],
|
||||
'document': [
|
||||
{ k: 'template', l: '템플릿', v: '', mono: true },
|
||||
{ k: 'output', l: '출력 경로', v: '', mono: true },
|
||||
{ k: 'format', l: '포맷', v: 'pdf', select: ['pdf', 'docx', 'html'] },
|
||||
],
|
||||
'approval': [
|
||||
{ k: 'approver', l: '결재자', v: '' },
|
||||
{ k: 'sla', l: 'SLA (시간)', v: '4', mono: true },
|
||||
{ k: 'on_reject', l: '반려 시', v: 'rollback', select: ['rollback', 'manual', 'log'] },
|
||||
],
|
||||
'delay': [
|
||||
{ k: 'duration', l: '대기 시간', v: '30m', mono: true, hint: '예: 30m / 2h / 1d' },
|
||||
{ k: 'unit', l: '단위', v: 'minute', select: ['second', 'minute', 'hour', 'day'] },
|
||||
],
|
||||
'loop': [
|
||||
{ k: 'source', l: '반복 대상', v: '', mono: true },
|
||||
{ k: 'max', l: '최대 반복', v: '100', mono: true },
|
||||
],
|
||||
'parallel': [
|
||||
{ k: 'branches', l: '병렬 브랜치 수', v: '2', mono: true },
|
||||
{ k: 'wait', l: 'join 대기', v: 'all', select: ['all', 'any', 'first'] },
|
||||
],
|
||||
'merge': [
|
||||
{ k: 'strategy', l: '병합 전략', v: 'overwrite', select: ['overwrite', 'keep', 'custom'] },
|
||||
],
|
||||
'webhook': [
|
||||
{ k: 'url', l: 'URL', v: '', mono: true },
|
||||
{ k: 'method', l: '메서드', v: 'POST', select: ['GET', 'POST', 'PUT', 'DELETE'] },
|
||||
{ k: 'headers', l: '헤더', v: '', mono: true, multiline: true },
|
||||
],
|
||||
'notification': [
|
||||
{ k: 'channel', l: '채널', v: 'slack', select: ['slack', 'email', 'teams', 'webhook'] },
|
||||
{ k: 'target', l: '대상', v: '', mono: true },
|
||||
{ k: 'template', l: '메시지', v: '', mono: true, multiline: true },
|
||||
],
|
||||
'log': [
|
||||
{ k: 'table', l: '대상', v: 'audit_log', mono: true },
|
||||
{ k: 'level', l: '레벨', v: 'info', select: ['debug', 'info', 'warn', 'error'] },
|
||||
{ k: 'msg', l: '메시지', v: '', mono: true },
|
||||
],
|
||||
};
|
||||
|
||||
/** 카테고리 메타 — palette / inspector 색상 매핑 */
|
||||
export const NODE_CATEGORIES: Array<{
|
||||
id: 'trigger' | 'cond' | 'action' | 'flow' | 'extern' | 'log';
|
||||
label: string;
|
||||
cls: string;
|
||||
rgb: string;
|
||||
}> = [
|
||||
{ id: 'trigger', label: '트리거', cls: 'c-trigger', rgb: '0,206,201' },
|
||||
{ id: 'cond', label: '조건', cls: 'c-cond', rgb: '253,203,110' },
|
||||
{ id: 'action', label: '액션', cls: 'c-action', rgb: '108,92,231' },
|
||||
{ id: 'flow', label: '흐름', cls: 'c-flow', rgb: '253,121,168' },
|
||||
{ id: 'extern', label: '연동', cls: 'c-extern', rgb: '0,184,148' },
|
||||
{ id: 'log', label: '기록', cls: 'c-log', rgb: '107,107,118' },
|
||||
];
|
||||
|
||||
/** invyone CTRL_NODE_TYPES 의 cat (한글) → v3 cat (영문) 매핑 */
|
||||
export function ctrlCatToV3(catKo: string): 'trigger' | 'cond' | 'action' | 'flow' | 'extern' | 'log' {
|
||||
switch (catKo) {
|
||||
case '트리거': return 'trigger';
|
||||
case '조건': return 'cond';
|
||||
case '액션': return 'action';
|
||||
case '흐름': return 'flow';
|
||||
case '연동': return 'extern';
|
||||
case '기록': return 'log';
|
||||
default: return 'action';
|
||||
}
|
||||
}
|
||||
|
||||
import {
|
||||
Clock4, Activity, GitBranch, ShieldCheck,
|
||||
FilePlus2, Calculator, Archive, FileText,
|
||||
Stamp, Timer, Repeat, GitMerge, Combine,
|
||||
Webhook, BellRing, ScrollText, Circle,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
/** 노드 타입 → Lucide 아이콘 매핑 (v3 시안 NODE_TYPES.icon 미러) */
|
||||
const NODE_LUCIDE: Record<string, LucideIcon> = {
|
||||
'timer': Clock4,
|
||||
'status-change': Activity,
|
||||
'condition': GitBranch,
|
||||
'validation': ShieldCheck,
|
||||
'auto-insert': FilePlus2,
|
||||
'calculation': Calculator,
|
||||
'delete': Archive,
|
||||
'document': FileText,
|
||||
'approval': Stamp,
|
||||
'delay': Timer,
|
||||
'loop': Repeat,
|
||||
'parallel': GitMerge,
|
||||
'merge': Combine,
|
||||
'webhook': Webhook,
|
||||
'notification': BellRing,
|
||||
'log': ScrollText,
|
||||
};
|
||||
|
||||
export function getNodeIcon(nodeType: string): LucideIcon {
|
||||
return NODE_LUCIDE[nodeType] ?? Circle;
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
import React from 'react';
|
||||
import type { BlockV2, CanvasV2 } from '@/types/invyone-component';
|
||||
import { ComponentRegistry } from '@/lib/registry/ComponentRegistry';
|
||||
import { isTableLikeComponentType } from '@/lib/utils/componentTypeUtils';
|
||||
// side-effect: 컴포넌트 레지스트리 등록
|
||||
import '@/lib/registry/components';
|
||||
import type { TemplateRenderContext, ViewKey } from './TemplateRenderer';
|
||||
@@ -68,7 +69,10 @@ export function BlockRenderer({
|
||||
context.onFormRowChange?.(fieldNameOrPatch);
|
||||
};
|
||||
|
||||
const def = ComponentRegistry.getComponent(block.componentId);
|
||||
const registryComponentId = isTableLikeComponentType(block.componentId)
|
||||
? 'table'
|
||||
: block.componentId;
|
||||
const def = ComponentRegistry.getComponent(registryComponentId);
|
||||
if (!def?.component) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center rounded border border-dashed border-slate-300 bg-slate-50 p-2 text-center text-[10px] text-slate-500 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-400">
|
||||
|
||||
@@ -15,7 +15,7 @@ import { DashboardCanvas } from './DashboardCanvas';
|
||||
import { TemplateLibraryModal } from './TemplateLibraryModal';
|
||||
import { CardSettingsPanel } from './CardSettingsPanel';
|
||||
import { ControlMode } from '@/components/control/ControlMode';
|
||||
import { ControlPalette } from '@/components/control/ControlPalette';
|
||||
// ControlPalette 는 ControlMode 의 IDE LeftRail 안에서만 사용됨 (외부 사이드바 교체 폐기)
|
||||
import { useControlMode } from '@/components/control/hooks/useControlMode';
|
||||
import { useMenu } from '@/contexts/MenuContext';
|
||||
import { toast } from 'sonner';
|
||||
@@ -45,7 +45,7 @@ export function DashboardLayout({ dashboardId: singleDashboardId }: DashboardLay
|
||||
} = useDashboardStore();
|
||||
|
||||
const controlActive = useControlMode((s) => s.active);
|
||||
const controlMode = useControlMode((s) => s.mode);
|
||||
// controlMode 는 ControlMode 내부에서만 참조 (외부 사이드바 분기 폐기)
|
||||
const { refreshMenus } = useMenu();
|
||||
const isSingleMode = !!singleDashboardId;
|
||||
|
||||
@@ -243,13 +243,8 @@ export function DashboardLayout({ dashboardId: singleDashboardId }: DashboardLay
|
||||
return (
|
||||
<div className="dash-shell">
|
||||
{/* 사이드바 — 단일 모드에선 AppLayout 메뉴가 대시보드 목록 역할이므로 자체 사이드바 숨김.
|
||||
제어 편집 모드에서만 팔레트로 오버레이. */}
|
||||
{controlActive && controlMode === 'edit' ? (
|
||||
<div className="dash-side">
|
||||
<div className="dash-side-sec" style={{ color: 'var(--ctrl-cyan)' }}>제어 팔레트</div>
|
||||
<ControlPalette onDropTable={() => {}} onDropControl={() => {}} />
|
||||
</div>
|
||||
) : !isSingleMode ? (
|
||||
제어 모드 takeover 는 ControlMode 의 IDE LeftRail 이 담당 (v3 V3Takeover) — 외부 사이드바 교체 X */}
|
||||
{!isSingleMode && !controlActive ? (
|
||||
<DashboardSidebar
|
||||
onAddDashboard={handleAddDashboard}
|
||||
onRenameDashboard={handleRenameDashboard}
|
||||
@@ -257,6 +252,7 @@ export function DashboardLayout({ dashboardId: singleDashboardId }: DashboardLay
|
||||
onSwitchDashboard={handleSwitchDashboard}
|
||||
/>
|
||||
) : null}
|
||||
{/* 제어 모드 ON 이지만 카드 미선택 상태에서는 사이드바 자체를 숨김 (IDE 가 화면 takeover 할 자리 확보) */}
|
||||
<div className="dash-content">
|
||||
{activeDashboardId ? (
|
||||
<>
|
||||
|
||||
@@ -73,6 +73,9 @@ import { CompanySwitcher } from "@/components/admin/CompanySwitcher";
|
||||
import { getIconComponent } from "@/components/admin/MenuIconPicker";
|
||||
import { animatedThemeChange } from "@/lib/themeTransition";
|
||||
|
||||
// MANAGEMENT_ONLY_MENU_URLS — DB 컬럼 IS_SOLUTION_ONLY 로 이전 (PR #D).
|
||||
// 백엔드 /api/admin/user-menus 가 Host 헤더 기반으로 SQL 단계에서 필터하므로 프론트 Set 불필요.
|
||||
|
||||
interface ExtendedUserInfo {
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
@@ -286,6 +289,9 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
const [showCompanySwitcher, setShowCompanySwitcher] = useState(false);
|
||||
const [currentCompanyName, setCurrentCompanyName] = useState<string>("");
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
// isMgmtSite / MANAGEMENT_ONLY_MENU_URLS — DB IS_SOLUTION_ONLY 컬럼으로 이전 (PR #D).
|
||||
// 백엔드가 Host 헤더 기반으로 SQL 단계에서 필터하므로 프론트 상태 불필요.
|
||||
const tweaksAnchorRef = useRef<HTMLButtonElement>(null);
|
||||
const { theme, setTheme: rawSetTheme } = useTheme();
|
||||
|
||||
@@ -924,6 +930,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// 솔루션 전용 메뉴 필터는 백엔드 IS_SOLUTION_ONLY 컬럼 + Host 헤더 기반 SQL 필터로 위임 (PR #D).
|
||||
const uiMenus = user ? convertMenuToUI(currentMenus, user as ExtendedUserInfo) : [];
|
||||
|
||||
// 활성 탭이 바뀔 때 한 번만 부모 메뉴 자동 확장.
|
||||
|
||||
@@ -529,9 +529,16 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||
);
|
||||
}
|
||||
|
||||
// 탭 컴포넌트 처리 (v1, v2 모두 지원)
|
||||
// 탭 컴포넌트 처리 (v1, v2 + canonical container 모두 지원)
|
||||
const componentType = (comp as any).componentType || (comp as any).componentId;
|
||||
if (comp.type === "tabs" || (comp.type === "component" && (componentType === "tabs-widget" || componentType === "v2-tabs-widget"))) {
|
||||
const isCanonicalTabsContainer =
|
||||
componentType === "container" &&
|
||||
(((comp as any).component_config?.containerType ?? (comp as any).componentConfig?.containerType ?? "section") === "tabs");
|
||||
if (
|
||||
comp.type === "tabs" ||
|
||||
(comp.type === "component" &&
|
||||
(componentType === "tabs-widget" || componentType === "v2-tabs-widget" || isCanonicalTabsContainer))
|
||||
) {
|
||||
const TabsWidget = require("@/components/screen/widgets/TabsWidget").TabsWidget;
|
||||
|
||||
// componentConfig에서 탭 정보 추출
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { Database, Cog, Monitor, Tablet, Smartphone, ChevronDown, Eye, EyeOff, Zap, Grid3X3, Settings } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
|
||||
// 좌측 패널의 수평 탭 → 수직 <details> 아코디언으로 전환 (2026-04-11)
|
||||
// shadcn Tabs 사용 없음. 필요 시 아래 import 재활성화.
|
||||
// import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
@@ -3660,8 +3661,11 @@ export default function InvyoneStudio({
|
||||
for (const comp of layout.components) {
|
||||
const compType = (comp as any)?.componentType || (comp as any)?.overrides?.type;
|
||||
const compConfig = (comp as any)?.componentConfig || (comp as any)?.overrides || {};
|
||||
// ★ 2026-05-18 canonical container(containerType=tabs) 동일 분기로 처리
|
||||
const isCanonicalTabs =
|
||||
compType === "container" && (compConfig.containerType ?? "section") === "tabs";
|
||||
|
||||
if (compType === "tabs-widget" || compType === "v2-tabs-widget") {
|
||||
if (compType === "tabs-widget" || compType === "v2-tabs-widget" || isCanonicalTabs) {
|
||||
const tabs = compConfig.tabs || [];
|
||||
for (const tab of tabs) {
|
||||
const found = (tab.components || []).find((c: any) => c.id === containerId);
|
||||
@@ -3680,7 +3684,10 @@ export default function InvyoneStudio({
|
||||
const panelComps = compConfig[side]?.components || [];
|
||||
for (const pc of panelComps) {
|
||||
const pct = pc.componentType || pc.overrides?.type;
|
||||
if (pct === "tabs-widget" || pct === "v2-tabs-widget") {
|
||||
const pcConfig = pc.componentConfig || pc.overrides || {};
|
||||
const pcIsCanonicalTabs =
|
||||
pct === "container" && (pcConfig.containerType ?? "section") === "tabs";
|
||||
if (pct === "tabs-widget" || pct === "v2-tabs-widget" || pcIsCanonicalTabs) {
|
||||
const tabs = (pc.componentConfig || pc.overrides || {}).tabs || [];
|
||||
for (const tab of tabs) {
|
||||
const found = (tab.components || []).find((c: any) => c.id === containerId);
|
||||
@@ -4060,14 +4067,14 @@ export default function InvyoneStudio({
|
||||
|
||||
// 컴포넌트별 gridColumns 설정 및 크기 계산
|
||||
let componentSize = component.defaultSize;
|
||||
const isTableList = component.id === "table-list";
|
||||
const isTableLike = isTableLikeComponentType(component.id);
|
||||
|
||||
// 컴포넌트 타입별 기본 그리드 컬럼 수 설정
|
||||
const currentGridColumns = layout.gridSettings.columns; // 현재 격자 컬럼 수
|
||||
let gridColumns = 1; // 기본값
|
||||
|
||||
// 특수 컴포넌트
|
||||
if (isTableList) {
|
||||
if (isTableLike) {
|
||||
gridColumns = currentGridColumns; // 테이블은 전체 너비
|
||||
} else {
|
||||
// 웹타입별 적절한 그리드 컬럼 수 설정
|
||||
@@ -4095,7 +4102,11 @@ export default function InvyoneStudio({
|
||||
"divider-basic": 1, // 구분선 (100%)
|
||||
"divider-line": 1, // 구분선 (100%)
|
||||
"accordion-basic": 1, // 아코디언 (100%)
|
||||
"table-list": 1, // 테이블 리스트 (100%)
|
||||
"table": 1, // canonical 테이블 (100%)
|
||||
"table-list": 1, // legacy 테이블 리스트 (100%)
|
||||
"v2-table-list": 1, // hidden legacy 테이블 리스트 (100%)
|
||||
"data-table": 1, // 데이터 테이블 (100%)
|
||||
"datatable": 1, // 데이터 테이블 (100%)
|
||||
"image-display": 4 / 12, // 이미지 표시 (33%)
|
||||
"split-panel-layout": 6 / 12, // 분할 패널 레이아웃 (50%)
|
||||
"flow-widget": 1, // 플로우 위젯 (100%)
|
||||
@@ -4398,8 +4409,11 @@ export default function InvyoneStudio({
|
||||
for (const comp of layout.components) {
|
||||
const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type;
|
||||
const compConfig = (comp as any)?.componentConfig || (comp as any)?.overrides || {};
|
||||
// ★ 2026-05-18 canonical container(containerType=tabs) 동일 분기로 처리
|
||||
const isCanonicalTabs =
|
||||
ct === "container" && (compConfig.containerType ?? "section") === "tabs";
|
||||
|
||||
if (ct === "tabs-widget" || ct === "v2-tabs-widget") {
|
||||
if (ct === "tabs-widget" || ct === "v2-tabs-widget" || isCanonicalTabs) {
|
||||
const tabs = compConfig.tabs || [];
|
||||
for (const tab of tabs) {
|
||||
const found = (tab.components || []).find((c: any) => c.id === containerId);
|
||||
@@ -4418,7 +4432,10 @@ export default function InvyoneStudio({
|
||||
const panelComps = compConfig[side]?.components || [];
|
||||
for (const pc of panelComps) {
|
||||
const pct = pc.componentType || pc.overrides?.type;
|
||||
if (pct === "tabs-widget" || pct === "v2-tabs-widget") {
|
||||
const pcConfig = pc.componentConfig || pc.overrides || {};
|
||||
const pcIsCanonicalTabs =
|
||||
pct === "container" && (pcConfig.containerType ?? "section") === "tabs";
|
||||
if (pct === "tabs-widget" || pct === "v2-tabs-widget" || pcIsCanonicalTabs) {
|
||||
const tabs = (pc.componentConfig || pc.overrides || {}).tabs || [];
|
||||
for (const tab of tabs) {
|
||||
const found = (tab.components || []).find((c: any) => c.id === containerId);
|
||||
@@ -4618,7 +4635,11 @@ export default function InvyoneStudio({
|
||||
}
|
||||
|
||||
const compType = (targetComponent as any)?.componentType;
|
||||
if (targetComponent && (compType === "tabs-widget" || compType === "v2-tabs-widget")) {
|
||||
const compConfigForCheck = (targetComponent as any)?.componentConfig || {};
|
||||
// ★ 2026-05-18 canonical container(containerType=tabs) 동일 분기로 처리
|
||||
const isCanonicalTabs =
|
||||
compType === "container" && (compConfigForCheck.containerType ?? "section") === "tabs";
|
||||
if (targetComponent && (compType === "tabs-widget" || compType === "v2-tabs-widget" || isCanonicalTabs)) {
|
||||
const currentConfig = (targetComponent as any).componentConfig || {};
|
||||
const tabs = currentConfig.tabs || [];
|
||||
|
||||
@@ -5508,11 +5529,16 @@ export default function InvyoneStudio({
|
||||
}
|
||||
|
||||
const compType = (targetComponent as any)?.componentType;
|
||||
const compConfigForSelfDropCheck = (targetComponent as any)?.componentConfig || {};
|
||||
// ★ 2026-05-18 canonical container(containerType=tabs) 동일 분기로 처리
|
||||
const isCanonicalTabsForSelfDrop =
|
||||
compType === "container" &&
|
||||
(compConfigForSelfDropCheck.containerType ?? "section") === "tabs";
|
||||
|
||||
// 자기 자신을 자신에게 드롭하는 것 방지
|
||||
if (
|
||||
targetComponent &&
|
||||
(compType === "tabs-widget" || compType === "v2-tabs-widget") &&
|
||||
(compType === "tabs-widget" || compType === "v2-tabs-widget" || isCanonicalTabsForSelfDrop) &&
|
||||
dragState.draggedComponent !== containerId
|
||||
) {
|
||||
const draggedComponent = layout.components.find((c) => c.id === dragState.draggedComponent);
|
||||
@@ -7532,7 +7558,7 @@ export default function InvyoneStudio({
|
||||
for (const comp of components) {
|
||||
const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type;
|
||||
const cfg = (comp as any)?.componentConfig || (comp as any)?.overrides || {};
|
||||
if (ct === "tabs-widget" || ct === "v2-tabs-widget") {
|
||||
if (ct === "tabs-widget" || ct === "v2-tabs-widget" || (ct === "container" && (cfg.containerType ?? "section") === "tabs")) {
|
||||
for (const tab of (cfg.tabs || [])) {
|
||||
const nested = (tab.components || []).find((c: any) => c.id === splitPanelId);
|
||||
if (nested) return { found: nested, path: "nested", parentTabId: comp.id, parentTabTabId: tab.id };
|
||||
@@ -7627,7 +7653,7 @@ export default function InvyoneStudio({
|
||||
for (const comp of prevLayout.components) {
|
||||
const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type;
|
||||
const cfg = (comp as any)?.componentConfig || (comp as any)?.overrides || {};
|
||||
if (ct === "tabs-widget" || ct === "v2-tabs-widget") {
|
||||
if (ct === "tabs-widget" || ct === "v2-tabs-widget" || (ct === "container" && (cfg.containerType ?? "section") === "tabs")) {
|
||||
for (const tab of (cfg.tabs || [])) {
|
||||
const nested = (tab.components || []).find((c: any) => c.id === splitPanelId);
|
||||
if (nested) return { found: nested, path: "nested" as const, parentTabId: comp.id, parentTabTabId: tab.id };
|
||||
|
||||
@@ -671,11 +671,16 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* 탭 컴포넌트 타입 */}
|
||||
{/* 탭 컴포넌트 타입 (legacy tabs-widget/v2-tabs-widget + canonical container.containerType=tabs) */}
|
||||
{(type === "tabs" ||
|
||||
(type === "component" &&
|
||||
((component as any).componentType === "tabs-widget" ||
|
||||
(component as any).componentId === "tabs-widget"))) &&
|
||||
(((component as any).componentType ?? (component as any).component_type) === "tabs-widget" ||
|
||||
((component as any).componentType ?? (component as any).component_type) === "v2-tabs-widget" ||
|
||||
((component as any).componentId ?? (component as any).component_id) === "tabs-widget" ||
|
||||
((component as any).componentId ?? (component as any).component_id) === "v2-tabs-widget" ||
|
||||
(((component as any).componentType ?? (component as any).component_type) === "container" &&
|
||||
((((component as any).componentConfig ?? (component as any).component_config)?.containerType ?? "section") ===
|
||||
"tabs"))))) &&
|
||||
(() => {
|
||||
console.log("🎯 탭 컴포넌트 조건 충족:", {
|
||||
type,
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
subscribeDom as canvasSplitSubscribeDom,
|
||||
} from "@/lib/registry/components/v2-split-line/canvasSplitStore";
|
||||
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
|
||||
import { isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
|
||||
|
||||
// 컴포넌트 렌더러들 자동 등록
|
||||
import "@/lib/registry/components";
|
||||
@@ -360,32 +361,44 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
||||
return `${actualHeight}px`;
|
||||
}
|
||||
|
||||
const sizingType =
|
||||
(component as any).componentType ||
|
||||
(component as any).component_type ||
|
||||
(component.componentConfig as any)?.type ||
|
||||
(component as any).widgetType ||
|
||||
(component as any).widget_type ||
|
||||
type ||
|
||||
"";
|
||||
|
||||
// 런타임 모드에서 컴포넌트 타입별 높이 처리
|
||||
if (!isDesignMode) {
|
||||
const compType = (component as any).componentType || component.componentConfig?.type || "";
|
||||
// 레이아웃 계열: 부모 래퍼를 꽉 채움 (ResponsiveGridRenderer가 % 높이 관리)
|
||||
const fillParentTypes = [
|
||||
"table-list", "v2-table-list",
|
||||
// ★ table 계열 (canonical 'table' / legacy 'table-list' / hidden 'v2-table-list' /
|
||||
// 'data-table' / 'datatable') 은 helper 로 통일. 그 외 layout/split/tabs 는 명시 목록.
|
||||
const fillParentExtraTypes = [
|
||||
"container",
|
||||
"grouped-table", "card-list",
|
||||
"split-panel-layout", "split-panel-layout2",
|
||||
"v2-split-panel-layout", "screen-split-panel",
|
||||
"v2-tab-container", "tab-container",
|
||||
"tabs-widget", "v2-tabs-widget",
|
||||
];
|
||||
if (fillParentTypes.some(t => compType === t)) {
|
||||
if (isTableLikeComponentType(sizingType) || fillParentExtraTypes.includes(sizingType)) {
|
||||
return "100%";
|
||||
}
|
||||
const autoHeightTypes = [
|
||||
"table-search-widget", "v2-table-search-widget",
|
||||
"flow-widget",
|
||||
];
|
||||
if (autoHeightTypes.some(t => compType === t || compType.includes(t))) {
|
||||
if (autoHeightTypes.some(t => sizingType === t || sizingType.includes(t))) {
|
||||
return "auto";
|
||||
}
|
||||
}
|
||||
|
||||
// 1순위: size.height가 있으면 우선 사용
|
||||
// (canonical 'table' / legacy 'table-list' / hidden 'v2-table-list' 모두 최소 200px 보장)
|
||||
if (size?.height && size.height > 0) {
|
||||
if (component.componentConfig?.type === "table-list") {
|
||||
if (isTableLikeComponentType(sizingType)) {
|
||||
return `${Math.max(size.height, 200)}px`;
|
||||
}
|
||||
return `${size.height}px`;
|
||||
@@ -396,8 +409,8 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
|
||||
return typeof componentStyle.height === "number" ? `${componentStyle.height}px` : componentStyle.height;
|
||||
}
|
||||
|
||||
// 3순위: 기본값
|
||||
if (component.componentConfig?.type === "table-list") {
|
||||
// 3순위: 기본값 (table-like 는 200px 최소 보장)
|
||||
if (isTableLikeComponentType(sizingType)) {
|
||||
return "200px";
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Link2,
|
||||
} from "lucide-react";
|
||||
import { ScreenLayoutSummary } from "@/lib/api/screenGroup";
|
||||
import { isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
|
||||
|
||||
// 글로우 펄스 애니메이션 CSS 주입
|
||||
if (typeof document !== "undefined") {
|
||||
@@ -224,10 +225,12 @@ export const ScreenNode: React.FC<{ data: ScreenNodeData }> = ({ data }) => {
|
||||
};
|
||||
|
||||
// ========== 컴포넌트 종류별 미니어처 색상 ==========
|
||||
// componentKind는 더 정확한 컴포넌트 타입 (table-list, button-primary 등)
|
||||
// componentKind 는 더 정확한 컴포넌트 타입 (canonical 'table' / legacy 'table-list' /
|
||||
// hidden 'v2-table-list' / 'button-primary' 등)
|
||||
const TABLE_LIKE_EXTRA_KINDS = ["grouped-table", "card-list", "data-grid"];
|
||||
const getComponentColor = (componentKind: string) => {
|
||||
// 테이블/그리드 관련
|
||||
if (componentKind === "table-list" || componentKind === "data-grid") {
|
||||
// 테이블/그리드 관련 (canonical table / legacy table-list / hidden v2-table-list 등)
|
||||
if (isTableLikeComponentType(componentKind) || TABLE_LIKE_EXTRA_KINDS.includes(componentKind)) {
|
||||
return "bg-primary/20 border-primary/40";
|
||||
}
|
||||
// 검색 필터
|
||||
|
||||
@@ -28,6 +28,17 @@ import { apiClient } from "@/lib/api/client";
|
||||
import { QuickInsertConfigSection } from "../QuickInsertConfigSection";
|
||||
import { getApprovalDefinitions, type ApprovalDefinition } from "@/lib/api/approval";
|
||||
import type { ButtonTabProps, TitleBlock, ScreenOption } from "./types";
|
||||
import { isTableLikeComponentType, isTableLikeComponent, getTableNameFromTableLikeComponent } from "@/lib/utils/componentTypeUtils";
|
||||
|
||||
// canonical table / legacy table-list / hidden v2-table-list / data-table / datatable
|
||||
// 은 table-like helper 로 통일. 추가로 repeater-field-group / form-group 도 데이터 전송
|
||||
// 호환 대상으로 함께 인식.
|
||||
const DATA_TRANSFER_EXTRA_PATTERNS = ["repeater-field-group", "form-group"] as const;
|
||||
const isDataTransferComponentType = (typeValue: unknown): boolean => {
|
||||
if (isTableLikeComponentType(typeValue)) return true;
|
||||
if (typeof typeValue !== "string") return false;
|
||||
return DATA_TRANSFER_EXTRA_PATTERNS.some((t) => typeValue.includes(t));
|
||||
};
|
||||
|
||||
/** 액션 탭: 액션 유형별 상세 설정 (모달/이동/엑셀/결재/이벤트 등) */
|
||||
export const ActionTab: React.FC<ButtonTabProps> = ({
|
||||
@@ -344,7 +355,7 @@ export const ActionTab: React.FC<ButtonTabProps> = ({
|
||||
// 1. 소스 테이블 감지 (현재 화면)
|
||||
let sourceTableName: string | null = currentTableName || null;
|
||||
|
||||
// allComponents에서 분할패널/테이블리스트/통합목록 감지
|
||||
// allComponents에서 분할패널/테이블리스트(canonical+legacy+v2)/통합목록 감지
|
||||
for (const comp of allComponents) {
|
||||
const compType = comp.component_type || (comp as any).component_config?.type;
|
||||
const compConfig = (comp as any).component_config || {};
|
||||
@@ -353,8 +364,8 @@ export const ActionTab: React.FC<ButtonTabProps> = ({
|
||||
sourceTableName = compConfig.leftPanel?.table_name || compConfig.table_name || null;
|
||||
if (sourceTableName) break;
|
||||
}
|
||||
if (compType === "table-list") {
|
||||
sourceTableName = compConfig.table_name || compConfig.selectedTable || null;
|
||||
if (isTableLikeComponent(comp)) {
|
||||
sourceTableName = getTableNameFromTableLikeComponent(comp) || null;
|
||||
if (sourceTableName) break;
|
||||
}
|
||||
if (compType === "v2-list") {
|
||||
@@ -518,11 +529,11 @@ export const ActionTab: React.FC<ButtonTabProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// 테이블 리스트 타입
|
||||
if (compType === "table-list") {
|
||||
sourceTableName = compConfig?.table_name;
|
||||
// 테이블 계열 (canonical table / legacy table-list / hidden v2-table-list 모두)
|
||||
if (isTableLikeComponent(comp)) {
|
||||
sourceTableName = getTableNameFromTableLikeComponent(comp) ?? compConfig?.table_name;
|
||||
if (sourceTableName) {
|
||||
console.log(`✅ [openModalWithData] table-list에서 소스 테이블 감지: ${sourceTableName}`);
|
||||
console.log(`✅ [openModalWithData] table-like 컴포넌트에서 소스 테이블 감지: ${sourceTableName}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -2892,9 +2903,7 @@ export const ActionTab: React.FC<ButtonTabProps> = ({
|
||||
{allComponents
|
||||
.filter((comp: any) => {
|
||||
const type = comp.componentType || comp.type || "";
|
||||
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) =>
|
||||
type.includes(t),
|
||||
);
|
||||
return isDataTransferComponentType(type);
|
||||
})
|
||||
.map((comp: any) => {
|
||||
const compType = comp.componentType || comp.type || "unknown";
|
||||
@@ -2916,9 +2925,7 @@ export const ActionTab: React.FC<ButtonTabProps> = ({
|
||||
})}
|
||||
{allComponents.filter((comp: any) => {
|
||||
const type = comp.componentType || comp.type || "";
|
||||
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) =>
|
||||
type.includes(t),
|
||||
);
|
||||
return isDataTransferComponentType(type);
|
||||
}).length === 0 && (
|
||||
<SelectItem value="__none__" disabled>
|
||||
데이터 제공 가능한 컴포넌트가 없습니다
|
||||
@@ -2989,9 +2996,7 @@ export const ActionTab: React.FC<ButtonTabProps> = ({
|
||||
{allComponents
|
||||
.filter((comp: any) => {
|
||||
const type = comp.componentType || comp.type || "";
|
||||
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some(
|
||||
(t) => type.includes(t),
|
||||
);
|
||||
const isReceivable = isDataTransferComponentType(type);
|
||||
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
|
||||
})
|
||||
.map((comp: any) => {
|
||||
@@ -3014,9 +3019,7 @@ export const ActionTab: React.FC<ButtonTabProps> = ({
|
||||
})}
|
||||
{allComponents.filter((comp: any) => {
|
||||
const type = comp.componentType || comp.type || "";
|
||||
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) =>
|
||||
type.includes(t),
|
||||
);
|
||||
const isReceivable = isDataTransferComponentType(type);
|
||||
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
|
||||
}).length === 0 && (
|
||||
<SelectItem value="__none__" disabled>
|
||||
@@ -4430,13 +4433,8 @@ const ExcelUploadConfigSection: React.FC<{
|
||||
const compId = comp.componentId || comp.componentType;
|
||||
const compConfig = comp.componentConfig || comp.config || comp;
|
||||
|
||||
// 테이블 패널이나 데이터 테이블에서 테이블명 찾기
|
||||
if (
|
||||
compId === "table-panel" ||
|
||||
compId === "data-table" ||
|
||||
compId === "table-list" ||
|
||||
compId === "simple-table"
|
||||
) {
|
||||
// 테이블 패널이나 데이터 테이블에서 테이블명 찾기 (canonical/legacy/v2 모두)
|
||||
if (compId === "table-panel" || compId === "simple-table" || isTableLikeComponentType(compId)) {
|
||||
const tableName = compConfig?.table_name || compConfig?.table;
|
||||
if (tableName) return tableName;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Check, ChevronsUpDown, Plus, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { QuickInsertConfigSection } from "../QuickInsertConfigSection";
|
||||
import { ComponentData } from "@/types/screen";
|
||||
import { isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
|
||||
|
||||
export interface DataTabProps {
|
||||
config: any;
|
||||
@@ -35,6 +36,16 @@ export interface DataTabProps {
|
||||
>;
|
||||
}
|
||||
|
||||
// canonical table / legacy table-list / hidden v2-table-list / data-table / datatable
|
||||
// 은 table-like helper 로 통일. 추가로 repeater-field-group / form-group 도 데이터 전송
|
||||
// 호환 대상으로 함께 인식.
|
||||
const DATA_TRANSFER_EXTRA_PATTERNS = ["repeater-field-group", "form-group"] as const;
|
||||
const isDataTransferComponentType = (typeValue: unknown): boolean => {
|
||||
if (isTableLikeComponentType(typeValue)) return true;
|
||||
if (typeof typeValue !== "string") return false;
|
||||
return DATA_TRANSFER_EXTRA_PATTERNS.some((t) => typeValue.includes(t));
|
||||
};
|
||||
|
||||
export const DataTab: React.FC<DataTabProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
@@ -106,9 +117,7 @@ export const DataTab: React.FC<DataTabProps> = ({
|
||||
{allComponents
|
||||
.filter((comp: any) => {
|
||||
const type = comp.componentType || comp.type || "";
|
||||
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) =>
|
||||
type.includes(t),
|
||||
);
|
||||
return isDataTransferComponentType(type);
|
||||
})
|
||||
.map((comp: any) => {
|
||||
const compType = comp.componentType || comp.type || "unknown";
|
||||
@@ -130,9 +139,7 @@ export const DataTab: React.FC<DataTabProps> = ({
|
||||
})}
|
||||
{allComponents.filter((comp: any) => {
|
||||
const type = comp.componentType || comp.type || "";
|
||||
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) =>
|
||||
type.includes(t),
|
||||
);
|
||||
return isDataTransferComponentType(type);
|
||||
}).length === 0 && (
|
||||
<SelectItem value="__none__" disabled>
|
||||
데이터 제공 가능한 컴포넌트가 없습니다
|
||||
@@ -198,9 +205,7 @@ export const DataTab: React.FC<DataTabProps> = ({
|
||||
{allComponents
|
||||
.filter((comp: any) => {
|
||||
const type = comp.componentType || comp.type || "";
|
||||
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some(
|
||||
(t) => type.includes(t),
|
||||
);
|
||||
const isReceivable = isDataTransferComponentType(type);
|
||||
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
|
||||
})
|
||||
.map((comp: any) => {
|
||||
@@ -223,9 +228,7 @@ export const DataTab: React.FC<DataTabProps> = ({
|
||||
})}
|
||||
{allComponents.filter((comp: any) => {
|
||||
const type = comp.componentType || comp.type || "";
|
||||
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some(
|
||||
(t) => type.includes(t),
|
||||
);
|
||||
const isReceivable = isDataTransferComponentType(type);
|
||||
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
|
||||
}).length === 0 && (
|
||||
<SelectItem value="__none__" disabled>
|
||||
|
||||
@@ -147,11 +147,14 @@ interface MultilangSettingsModalProps {
|
||||
}
|
||||
|
||||
// 타입별 아이콘 매핑
|
||||
// canonical table / legacy table-list / hidden v2-table-list 모두 같은 table 아이콘.
|
||||
const getTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case "button":
|
||||
return <MousePointer className="h-4 w-4" />;
|
||||
case "table":
|
||||
case "table-list":
|
||||
case "v2-table-list":
|
||||
return <Table2 className="h-4 w-4" />;
|
||||
case "split-panel-layout":
|
||||
return <LayoutPanelLeft className="h-4 w-4" />;
|
||||
@@ -192,8 +195,11 @@ const getTypeLabel = (type: string) => {
|
||||
};
|
||||
|
||||
// 라벨 다국어 처리가 필요 없는 컴포넌트 타입 (테이블, 분할패널 등)
|
||||
// canonical table 및 hidden legacy v2-table-list 도 모두 non-input 으로 분류.
|
||||
const NON_INPUT_COMPONENT_TYPES = new Set([
|
||||
"table",
|
||||
"table-list",
|
||||
"v2-table-list",
|
||||
"split-panel-layout",
|
||||
"tab-panel",
|
||||
"container",
|
||||
@@ -205,9 +211,35 @@ const NON_INPUT_COMPONENT_TYPES = new Set([
|
||||
"modal",
|
||||
"drawer",
|
||||
"form-layout",
|
||||
// canonical stats + 옛 저장 화면 호환 (aggregation-widget / v2-aggregation-widget / v2-status-count)
|
||||
"stats",
|
||||
"aggregation-widget",
|
||||
"v2-aggregation-widget",
|
||||
"v2-status-count",
|
||||
]);
|
||||
|
||||
/**
|
||||
* canonical stats + 옛 저장 화면의 stats 계열 ID 통합 판단 (private helper).
|
||||
*
|
||||
* i18n 추출/매핑 분기는 raw layout JSON 의 compType 을 직접 읽으므로 옛 ID 도
|
||||
* 함께 받는다. 새 생성은 canonical `"stats"` 로만.
|
||||
*/
|
||||
const isStatsLikeComponentType = (compType: string | undefined | null): boolean => {
|
||||
if (!compType) return false;
|
||||
return (
|
||||
compType === "stats" ||
|
||||
compType === "aggregation-widget" ||
|
||||
compType === "v2-aggregation-widget" ||
|
||||
compType === "v2-status-count"
|
||||
);
|
||||
};
|
||||
|
||||
const getStatsItemLabel = (item: any): string | undefined => {
|
||||
if (!item) return undefined;
|
||||
const v = item.label ?? item.columnLabel;
|
||||
return typeof v === "string" && v.length > 0 ? v : undefined;
|
||||
};
|
||||
|
||||
// 컴포넌트가 입력 폼인지 확인
|
||||
const isInputComponent = (comp: any): boolean => {
|
||||
const compType = comp.componentType || comp.type;
|
||||
@@ -727,13 +759,14 @@ export const MultilangSettingsModal: React.FC<MultilangSettingsModalProps> = ({
|
||||
});
|
||||
}
|
||||
|
||||
// 11. 집계 위젯 (aggregation-widget) 항목 라벨
|
||||
if (compType === "aggregation-widget" && config?.items && Array.isArray(config.items)) {
|
||||
// 11. 통계 카드 (canonical `stats` + legacy aggregation-widget 호환) 항목 라벨
|
||||
if (isStatsLikeComponentType(compType) && config?.items && Array.isArray(config.items)) {
|
||||
config.items.forEach((item: any, index: number) => {
|
||||
if (item.columnLabel && typeof item.columnLabel === "string") {
|
||||
const itemLabel = getStatsItemLabel(item);
|
||||
if (itemLabel) {
|
||||
addLabel(
|
||||
`${comp.id}_agg_${item.id || index}`,
|
||||
item.columnLabel,
|
||||
`${comp.id}_stats_${item.id || index}`,
|
||||
itemLabel,
|
||||
"label",
|
||||
compType,
|
||||
compLabel,
|
||||
|
||||
@@ -42,7 +42,9 @@ export function ComponentsPanel({
|
||||
// 레지스트리에서 모든 컴포넌트 조회
|
||||
const allComponents = useMemo(() => {
|
||||
const components = ComponentRegistry.getAllComponents();
|
||||
// v2-table-list가 자동 등록되므로 수동 추가 불필요
|
||||
// ★ 새 생성 경로는 canonical 'table' (displayMode='table').
|
||||
// v2-table-list 는 옛 저장 화면 호환 hard blocker 로 자동 등록되지만
|
||||
// 팔레트에는 hidden 처리한다 (아래 hiddenComponents 참고).
|
||||
return components;
|
||||
}, []);
|
||||
|
||||
@@ -134,8 +136,8 @@ export function ComponentsPanel({
|
||||
// ===== V2로 대체된 기존 컴포넌트 (v2 버전만 사용) =====
|
||||
"button-primary", // → v2-button-primary
|
||||
"split-panel-layout", // → v2-split-panel-layout
|
||||
"aggregation-widget", // → v2-aggregation-widget
|
||||
"table-list", // → v2-table-list
|
||||
// aggregation-widget: 폴더/Renderer 삭제 (2026-05-19). ComponentRegistry 에 없음 — hidden 처리 불필요
|
||||
"table-list", // legacy hidden — 새 생성 경로는 canonical 'table'
|
||||
"text-display", // → v2-text-display
|
||||
"divider-line", // → v2-divider-line
|
||||
// ★ 2026-04-11 통합 컴포넌트(Phase A-1): 구분선 3종 → `divider`
|
||||
@@ -162,9 +164,10 @@ export function ComponentsPanel({
|
||||
// radio-basic, toggle-switch (Phase F.1)
|
||||
// image-widget, entity-search-input, autocomplete-search-input, file-upload (일부)
|
||||
// ★ 2026-04-11 통합 컴포넌트(Phase B-2): 통계/KPI → `stats`
|
||||
"v2-aggregation-widget", // → stats
|
||||
"v2-status-count", // → stats
|
||||
// aggregation-widget, card-display 는 기존 상단에서 이미 숨김
|
||||
// v2-aggregation-widget / v2-status-count: 폴더/Renderer 삭제 (2026-05-19).
|
||||
// ComponentRegistry 에 없음 — hidden list 에 둘 필요 없음. 옛 저장 화면은
|
||||
// DynamicComponentRenderer.LEGACY_TO_UNIFIED 로 canonical `stats` 라우팅.
|
||||
// card-display 는 기존 상단에서 이미 숨김
|
||||
// form 컴포넌트는 롤백됨 (2026-04-11): 3뷰 탭 구조로 처리 예정.
|
||||
"field-example-1", // legacy form-layout 의 실제 id (숨김 유지)
|
||||
// ★ 2026-04-11 통합 컴포넌트(Phase C-1): 데이터 테이블 → `table`
|
||||
@@ -173,26 +176,21 @@ export function ComponentsPanel({
|
||||
// table-list, split-panel-layout, split-panel-layout2, modal-repeater-table,
|
||||
// simple-repeater-table, tax-invoice-list, pivot-grid 는 기존 상단에서 이미 숨김
|
||||
// ★ 2026-04-11 통합 컴포넌트(Phase C-2): 컨테이너 → `container`
|
||||
"v2-tabs-widget", // → container (containerType='tabs')
|
||||
"v2-section-card", // → container (containerType='section', sectionVariant='card')
|
||||
"v2-section-paper", // → container (containerType='section', sectionVariant='paper')
|
||||
// v2-tabs-widget / v2-section-card / v2-section-paper / section-card / section-paper / tabs / tabs-widget:
|
||||
// 폴더/Renderer 삭제 (2026-05-19). ComponentRegistry 에 없음 — hidden 처리 불필요.
|
||||
// 옛 저장 화면은 DynamicComponentRenderer.LEGACY_TO_UNIFIED 로 canonical `container` 라우팅.
|
||||
"v2-repeat-container", // → container (containerType='repeater')
|
||||
"v2-repeater", // → container (containerType='repeater')
|
||||
// accordion-basic, conditional-container, section-card, section-paper,
|
||||
// tabs, repeat-container, repeat-screen-modal, repeater-field-group,
|
||||
// screen-split-panel 는 기존 상단에서 이미 숨김
|
||||
// accordion-basic, conditional-container, repeat-container, repeat-screen-modal,
|
||||
// repeater-field-group, screen-split-panel 는 기존 상단에서 이미 숨김
|
||||
// numbering-rule: 폐기 (2026-05-11)
|
||||
"split-panel-layout2", // → table (displayMode='split') Phase E 통합
|
||||
"section-paper", // → v2-section-paper
|
||||
"section-card", // → v2-section-card
|
||||
"location-swap-selector", // → v2-location-swap-selector
|
||||
"rack-structure", // → v2-rack-structure
|
||||
"v2-repeater", // → v2-repeater (아래 v2Components에서 별도 처리)
|
||||
"repeat-container", // → v2-repeat-container
|
||||
"repeat-screen-modal", // → v2-repeat-screen-modal
|
||||
"table-search-widget", // → v2-table-search-widget
|
||||
"tabs", // → v2-tabs
|
||||
"tabs-widget", // → v2-tabs-widget
|
||||
];
|
||||
|
||||
return {
|
||||
|
||||
@@ -20,7 +20,7 @@ import { LayoutComponent } from "@/types/layout";
|
||||
// 레거시 ButtonConfigPanel 제거됨
|
||||
import { FileComponentConfigPanel } from "./FileComponentConfigPanel";
|
||||
import { WebTypeConfigPanel } from "./WebTypeConfigPanel";
|
||||
import { isFileComponent } from "@/lib/utils/componentTypeUtils";
|
||||
import { isFileComponent, isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
|
||||
import { BaseInputType, getBaseInputType, getDetailTypes, DetailTypeOption } from "@/types/input-type-mapping";
|
||||
import { ConditionalConfigPanel } from "@/components/v2/ConditionalConfigPanel";
|
||||
import { ConditionalConfig } from "@/types/v2-components";
|
||||
@@ -871,7 +871,8 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||
// 🆕 ComponentRegistry에서 ConfigPanel 가져오기
|
||||
const componentId = selectedComponent.component_config?.type || selectedComponent.component_config?.id;
|
||||
if (componentId) {
|
||||
const definition = ComponentRegistry.getComponent(componentId);
|
||||
const registryComponentId = isTableLikeComponentType(componentId) ? "table" : componentId;
|
||||
const definition = ComponentRegistry.getComponent(registryComponentId);
|
||||
if (definition?.config_panel) {
|
||||
const ConfigPanelComponent = definition.config_panel;
|
||||
const currentConfig = selectedComponent.component_config || {};
|
||||
|
||||
@@ -32,7 +32,7 @@ import DataTableConfigPanel from "./DataTableConfigPanel";
|
||||
import { WebTypeConfigPanel } from "./WebTypeConfigPanel";
|
||||
import { FileComponentConfigPanel } from "./FileComponentConfigPanel";
|
||||
import { useWebTypes } from "@/hooks/admin/useWebTypes";
|
||||
import { isFileComponent } from "@/lib/utils/componentTypeUtils";
|
||||
import { isFileComponent, isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
|
||||
import {
|
||||
BaseInputType,
|
||||
BASE_INPUT_TYPE_OPTIONS,
|
||||
@@ -266,7 +266,8 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||
}
|
||||
|
||||
if (componentId) {
|
||||
const definition = ComponentRegistry.getComponent(componentId);
|
||||
const registryComponentId = isTableLikeComponentType(componentId) ? "table" : componentId;
|
||||
const definition = ComponentRegistry.getComponent(registryComponentId);
|
||||
|
||||
// ★ 2026-04-11: ComponentDefinition 은 config_panel (snake_case) 로 저장됨.
|
||||
// 기존 코드는 configPanel (camelCase) 만 찾아서 항상 false. 둘 다 체크.
|
||||
@@ -767,7 +768,8 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
|
||||
// 🆕 ComponentRegistry에서 전용 ConfigPanel이 있는지 먼저 확인
|
||||
// ★ 2026-04-11: ComponentDefinition 은 config_panel (snake_case) 로 저장됨.
|
||||
// 기존 코드는 configPanel (camelCase) 만 찾아서 항상 false. 둘 다 체크.
|
||||
const definition = ComponentRegistry.getComponent(componentId);
|
||||
const registryComponentId = isTableLikeComponentType(componentId) ? "table" : componentId;
|
||||
const definition = ComponentRegistry.getComponent(registryComponentId);
|
||||
const configPanelFromDef =
|
||||
(definition as any)?.configPanel ?? (definition as any)?.config_panel;
|
||||
if (configPanelFromDef) {
|
||||
|
||||
@@ -32,8 +32,10 @@ import {
|
||||
import type { FlowDefinition, FlowStep } from "@/types/flow";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { SingleTableWithSticky } from "@/lib/registry/components/table-list/SingleTableWithSticky";
|
||||
import type { ColumnConfig } from "@/lib/registry/components/table-list/types";
|
||||
import {
|
||||
SingleTableWithSticky,
|
||||
type ColumnConfig,
|
||||
} from "@/lib/registry/components/table/_shared/SingleTableWithSticky";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
import { showErrorToast } from "@/lib/utils/toastUtils";
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useActiveTab } from "@/contexts/ActiveTabContext";
|
||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||
import { screenApi } from "@/lib/api/screen";
|
||||
import { ResponsiveGridRenderer } from "@/components/screen/ResponsiveGridRenderer";
|
||||
import { isTableLikeComponent, getTableNameFromTableLikeComponent } from "@/lib/utils/componentTypeUtils";
|
||||
|
||||
// 확장된 TabItem 타입 (screen_id 지원)
|
||||
interface ExtendedTabItem extends TabItem {
|
||||
@@ -142,11 +143,11 @@ export function TabsWidget({
|
||||
for (const tab of tabs as ExtendedTabItem[]) {
|
||||
const inlineComponents = tab.components || [];
|
||||
if (inlineComponents.length > 0) {
|
||||
// 인라인 컴포넌트에서 테이블 컴포넌트의 selectedTable 추출
|
||||
const tableComp = inlineComponents.find(
|
||||
(c) => c.component_type === "v2-table-list" || c.component_type === "table-list",
|
||||
);
|
||||
const selectedTable = tableComp?.component_config?.selectedTable;
|
||||
// 인라인 컴포넌트에서 table-like 컴포넌트의 selectedTable 추출
|
||||
// (canonical table / legacy table-list / hidden v2-table-list 모두 인식,
|
||||
// camelCase / snake_case 양쪽 모두 처리)
|
||||
const tableComp = inlineComponents.find((c) => isTableLikeComponent(c));
|
||||
const selectedTable = getTableNameFromTableLikeComponent(tableComp);
|
||||
if (selectedTable || tab.screen_id) {
|
||||
map[tab.id] = {
|
||||
id: tab.screen_id,
|
||||
|
||||
@@ -229,7 +229,7 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
|
||||
|
||||
// ============================================================
|
||||
// repeaterDataChange 이벤트 발행
|
||||
// 데이터 변경 시 다른 컴포넌트(aggregation-widget 등)에 알림
|
||||
// 데이터 변경 시 다른 컴포넌트(canonical stats 등)에 알림
|
||||
// ============================================================
|
||||
const prevDataLengthRef = useRef(data.length);
|
||||
useEffect(() => {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import React, { forwardRef, useMemo } from "react";
|
||||
import { TableListComponent } from "@/lib/registry/components/table-list/TableListComponent";
|
||||
import { TableListComponent } from "@/lib/registry/components/table/_shared/TableListComponent";
|
||||
import { V2ListProps } from "@/types/v2-components";
|
||||
|
||||
/**
|
||||
|
||||
@@ -71,6 +71,7 @@ import {
|
||||
import { ImprovedButtonControlConfigPanel } from "@/components/screen/config-panels/ImprovedButtonControlConfigPanel";
|
||||
import { FlowVisibilityConfigPanel } from "@/components/screen/config-panels/FlowVisibilityConfigPanel";
|
||||
import type { ComponentData } from "@/types/screen";
|
||||
import { isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
|
||||
|
||||
// ───────────────────────────────────────────────────────
|
||||
// 상수: 액션 / 표시 / 변형
|
||||
@@ -112,6 +113,16 @@ const MODAL_SIZE_OPTIONS = [
|
||||
{ value: "full", label: "전체" },
|
||||
] as const;
|
||||
|
||||
// canonical table / legacy table-list / hidden v2-table-list / data-table / datatable
|
||||
// 은 table-like helper 로 통일. 추가로 repeater-field-group / form-group 도 데이터 전송
|
||||
// 호환 대상으로 함께 인식.
|
||||
const DATA_TRANSFER_EXTRA_PATTERNS = ["repeater-field-group", "form-group"] as const;
|
||||
const isDataTransferComponentType = (typeValue: unknown): boolean => {
|
||||
if (isTableLikeComponentType(typeValue)) return true;
|
||||
if (typeof typeValue !== "string") return false;
|
||||
return DATA_TRANSFER_EXTRA_PATTERNS.some((t) => typeValue.includes(t));
|
||||
};
|
||||
|
||||
const TRANSFER_MODE_OPTIONS = [
|
||||
{ value: "append", label: "추가" },
|
||||
{ value: "replace", label: "교체" },
|
||||
@@ -810,9 +821,7 @@ function ActionDetailBody(p: ActionDetailBodyProps) {
|
||||
{p.allComponents
|
||||
.filter((c: any) => {
|
||||
const t = c.componentType || c.type || "";
|
||||
return ["table-list", "repeater-field-group", "form-group", "data-table"].some(
|
||||
(x) => t.includes(x),
|
||||
);
|
||||
return isDataTransferComponentType(t);
|
||||
})
|
||||
.map((c: any) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
@@ -841,9 +850,7 @@ function ActionDetailBody(p: ActionDetailBodyProps) {
|
||||
{p.allComponents
|
||||
.filter((c: any) => {
|
||||
const t = c.componentType || c.type || "";
|
||||
const ok = ["table-list", "repeater-field-group", "form-group", "data-table"].some(
|
||||
(x) => t.includes(x),
|
||||
);
|
||||
const ok = isDataTransferComponentType(t);
|
||||
return ok && c.id !== dt.sourceComponentId;
|
||||
})
|
||||
.map((c: any) => (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,8 +22,8 @@ import {
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Table2, Settings, ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TableListConfigPanel } from "@/lib/registry/components/table-list/TableListConfigPanel";
|
||||
import { TableListConfig } from "@/lib/registry/components/table-list/types";
|
||||
import { TableListConfigPanel } from "@/lib/registry/components/table/_shared/TableListConfigPanel";
|
||||
import type { TableListConfig } from "@/lib/registry/components/table/_shared/tableListConfigTypes";
|
||||
|
||||
interface V2ListConfigPanelProps {
|
||||
config: Record<string, any>;
|
||||
|
||||
@@ -1,679 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* V2StatusCount 설정 패널
|
||||
* 토스식 단계별 UX: 데이터 소스 -> 컬럼 매핑 -> 상태 항목 관리 -> 표시 설정(접힘)
|
||||
* 기존 StatusCountConfigPanel의 모든 기능을 자체 UI로 완전 구현
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Table2,
|
||||
Columns3,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
Loader2,
|
||||
Link2,
|
||||
Plus,
|
||||
Trash2,
|
||||
BarChart3,
|
||||
Type,
|
||||
Maximize2,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { entityJoinApi, type EntityJoinConfig } from "@/lib/api/entityJoin";
|
||||
import { apiClient } from "@/lib/api/client";
|
||||
import type { StatusCountConfig, StatusCountItem } from "@/lib/registry/components/v2-status-count/types";
|
||||
import { STATUS_COLOR_MAP } from "@/lib/registry/components/v2-status-count/types";
|
||||
|
||||
const COLOR_OPTIONS = Object.keys(STATUS_COLOR_MAP);
|
||||
|
||||
// ─── 카드 크기 선택 카드 ───
|
||||
const SIZE_CARDS = [
|
||||
{ value: "sm", title: "작게", description: "컴팩트" },
|
||||
{ value: "md", title: "보통", description: "기본 크기" },
|
||||
{ value: "lg", title: "크게", description: "넓은 카드" },
|
||||
] as const;
|
||||
|
||||
// ─── 섹션 헤더 컴포넌트 ───
|
||||
function SectionHeader({ icon: Icon, title, description }: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
title: string;
|
||||
description?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
</div>
|
||||
{description && <p className="text-muted-foreground text-[10px]">{description}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 수평 라벨 + 컨트롤 Row ───
|
||||
function LabeledRow({ label, description, children }: {
|
||||
label: string;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
{description && <p className="text-[10px] text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface V2StatusCountConfigPanelProps {
|
||||
config: StatusCountConfig;
|
||||
onChange: (config: Partial<StatusCountConfig>) => void;
|
||||
}
|
||||
|
||||
export const V2StatusCountConfigPanel: React.FC<V2StatusCountConfigPanelProps> = ({
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
// componentConfigChanged 이벤트 발행 래퍼
|
||||
const handleChange = useCallback((newConfig: Partial<StatusCountConfig>) => {
|
||||
onChange(newConfig);
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("componentConfigChanged", {
|
||||
detail: { config: { ...config, ...newConfig } },
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [onChange, config]);
|
||||
|
||||
const updateField = useCallback((key: keyof StatusCountConfig, value: any) => {
|
||||
handleChange({ [key]: value });
|
||||
}, [handleChange]);
|
||||
|
||||
// ─── 상태 ───
|
||||
const [tables, setTables] = useState<Array<{ tableName: string; displayName: string }>>([]);
|
||||
const [columns, setColumns] = useState<Array<{ columnName: string; columnLabel: string }>>([]);
|
||||
const [entityJoins, setEntityJoins] = useState<EntityJoinConfig[]>([]);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
const [loadingJoins, setLoadingJoins] = useState(false);
|
||||
|
||||
const [statusCategoryValues, setStatusCategoryValues] = useState<Array<{ value: string; label: string }>>([]);
|
||||
const [loadingCategoryValues, setLoadingCategoryValues] = useState(false);
|
||||
|
||||
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
|
||||
const [statusColumnOpen, setStatusColumnOpen] = useState(false);
|
||||
const [relationOpen, setRelationOpen] = useState(false);
|
||||
const items = config.items || [];
|
||||
|
||||
// ─── 테이블 목록 로드 ───
|
||||
useEffect(() => {
|
||||
const loadTables = async () => {
|
||||
setLoadingTables(true);
|
||||
try {
|
||||
const result = await tableTypeApi.getTables();
|
||||
setTables(
|
||||
(result || []).map((t: any) => ({
|
||||
tableName: t.tableName || t.table_name,
|
||||
displayName: t.displayName || t.tableName || t.table_name,
|
||||
}))
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("테이블 목록 로드 실패:", err);
|
||||
} finally {
|
||||
setLoadingTables(false);
|
||||
}
|
||||
};
|
||||
loadTables();
|
||||
}, []);
|
||||
|
||||
// ─── 선택된 테이블의 컬럼 + 엔티티 조인 로드 ───
|
||||
useEffect(() => {
|
||||
if (!config.tableName) {
|
||||
setColumns([]);
|
||||
setEntityJoins([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadColumns = async () => {
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const result = await tableTypeApi.getColumns(config.tableName);
|
||||
setColumns(
|
||||
(result || []).map((c: any) => ({
|
||||
columnName: c.columnName || c.column_name,
|
||||
columnLabel: c.columnLabel || c.column_label || c.displayName || c.columnName || c.column_name,
|
||||
}))
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("컬럼 목록 로드 실패:", err);
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadEntityJoins = async () => {
|
||||
setLoadingJoins(true);
|
||||
try {
|
||||
const result = await entityJoinApi.getEntityJoinConfigs(config.tableName);
|
||||
setEntityJoins(result?.joinConfigs || []);
|
||||
} catch (err) {
|
||||
console.error("엔티티 조인 설정 로드 실패:", err);
|
||||
setEntityJoins([]);
|
||||
} finally {
|
||||
setLoadingJoins(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadColumns();
|
||||
loadEntityJoins();
|
||||
}, [config.tableName]);
|
||||
|
||||
// ─── 상태 컬럼의 카테고리 값 로드 ───
|
||||
useEffect(() => {
|
||||
if (!config.tableName || !config.statusColumn) {
|
||||
setStatusCategoryValues([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const loadCategoryValues = async () => {
|
||||
setLoadingCategoryValues(true);
|
||||
try {
|
||||
const response = await apiClient.get(
|
||||
`/table-categories/${config.tableName}/${config.statusColumn}/values`
|
||||
);
|
||||
if (response.data?.success && response.data?.data) {
|
||||
const flatValues: Array<{ value: string; label: string }> = [];
|
||||
const flatten = (categoryItems: any[]) => {
|
||||
for (const item of categoryItems) {
|
||||
flatValues.push({
|
||||
value: item.valueCode || item.value_code,
|
||||
label: item.valueLabel || item.value_label,
|
||||
});
|
||||
if (item.children?.length > 0) flatten(item.children);
|
||||
}
|
||||
};
|
||||
flatten(response.data.data);
|
||||
setStatusCategoryValues(flatValues);
|
||||
}
|
||||
} catch {
|
||||
setStatusCategoryValues([]);
|
||||
} finally {
|
||||
setLoadingCategoryValues(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadCategoryValues();
|
||||
}, [config.tableName, config.statusColumn]);
|
||||
|
||||
// ─── 엔티티 관계 Combobox 아이템 ───
|
||||
const relationComboItems = useMemo(() => {
|
||||
return entityJoins.map((ej) => {
|
||||
const refTableLabel = tables.find((t) => t.tableName === ej.referenceTable)?.displayName || ej.referenceTable;
|
||||
return {
|
||||
value: `${ej.sourceColumn}::${ej.referenceTable}.${ej.referenceColumn}`,
|
||||
label: `${ej.sourceColumn} -> ${refTableLabel}`,
|
||||
sublabel: `${ej.referenceTable}.${ej.referenceColumn}`,
|
||||
};
|
||||
});
|
||||
}, [entityJoins, tables]);
|
||||
|
||||
const currentRelationValue = useMemo(() => {
|
||||
if (!config.relationColumn) return "";
|
||||
return relationComboItems.find((item) => {
|
||||
const [srcCol] = item.value.split("::");
|
||||
return srcCol === config.relationColumn;
|
||||
})?.value || "";
|
||||
}, [config.relationColumn, relationComboItems]);
|
||||
|
||||
// ─── 상태 항목 관리 ───
|
||||
const addItem = useCallback(() => {
|
||||
updateField("items", [...items, { value: "", label: "새 상태", color: "gray" }]);
|
||||
}, [items, updateField]);
|
||||
|
||||
const removeItem = useCallback((index: number) => {
|
||||
updateField("items", items.filter((_: StatusCountItem, i: number) => i !== index));
|
||||
}, [items, updateField]);
|
||||
|
||||
const updateItem = useCallback((index: number, key: keyof StatusCountItem, value: string) => {
|
||||
const newItems = [...items];
|
||||
newItems[index] = { ...newItems[index], [key]: value };
|
||||
updateField("items", newItems);
|
||||
}, [items, updateField]);
|
||||
|
||||
// ─── 테이블 변경 핸들러 ───
|
||||
const handleTableChange = useCallback((newTableName: string) => {
|
||||
handleChange({ tableName: newTableName, statusColumn: "", relationColumn: "", parentColumn: "" });
|
||||
setTableComboboxOpen(false);
|
||||
}, [handleChange]);
|
||||
|
||||
// ─── 렌더링 ───
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* ═══════════════════════════════════════ */}
|
||||
{/* 1단계: 데이터 소스 (테이블 선택) */}
|
||||
{/* ═══════════════════════════════════════ */}
|
||||
<div className="space-y-3">
|
||||
<SectionHeader icon={Table2} title="데이터 소스" description="상태를 집계할 테이블을 선택하세요" />
|
||||
<Separator />
|
||||
|
||||
{/* 제목 */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Type className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-medium truncate">제목</span>
|
||||
</div>
|
||||
<Input
|
||||
value={config.title || ""}
|
||||
onChange={(e) => updateField("title", e.target.value)}
|
||||
placeholder="예: 일련번호 현황"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 테이블 선택 */}
|
||||
<Popover open={tableComboboxOpen} onOpenChange={setTableComboboxOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tableComboboxOpen}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
disabled={loadingTables}
|
||||
>
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<Table2 className="h-3 w-3 shrink-0" />
|
||||
<span className="truncate">
|
||||
{loadingTables
|
||||
? "테이블 로딩 중..."
|
||||
: config.tableName
|
||||
? tables.find((t) => t.tableName === config.tableName)?.displayName || config.tableName
|
||||
: "테이블 선택"}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs">테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.displayName} ${table.tableName}`}
|
||||
onSelect={() => handleTableChange(table.tableName)}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-3 w-3", config.tableName === table.tableName ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span>{table.displayName}</span>
|
||||
{table.displayName !== table.tableName && (
|
||||
<span className="text-[10px] text-muted-foreground/70">{table.tableName}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════════════════════════════ */}
|
||||
{/* 2단계: 컬럼 매핑 */}
|
||||
{/* ═══════════════════════════════════════ */}
|
||||
{config.tableName && (
|
||||
<div className="space-y-3">
|
||||
<SectionHeader icon={Columns3} title="컬럼 매핑" description="상태 컬럼과 부모 관계를 설정하세요" />
|
||||
<Separator />
|
||||
|
||||
{/* 상태 컬럼 */}
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs font-medium truncate">상태 컬럼 *</span>
|
||||
<Popover open={statusColumnOpen} onOpenChange={setStatusColumnOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={statusColumnOpen}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
disabled={loadingColumns}
|
||||
>
|
||||
<span className="truncate">
|
||||
{loadingColumns
|
||||
? "컬럼 로딩 중..."
|
||||
: config.statusColumn
|
||||
? columns.find((c) => c.columnName === config.statusColumn)?.columnLabel || config.statusColumn
|
||||
: "상태 컬럼 선택"}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="컬럼 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{columns.map((col) => (
|
||||
<CommandItem
|
||||
key={col.columnName}
|
||||
value={`${col.columnLabel} ${col.columnName}`}
|
||||
onSelect={() => {
|
||||
updateField("statusColumn", col.columnName);
|
||||
setStatusColumnOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-3 w-3", config.statusColumn === col.columnName ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span>{col.columnLabel}</span>
|
||||
{col.columnLabel !== col.columnName && (
|
||||
<span className="text-[10px] text-muted-foreground/70">{col.columnName}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* 엔티티 관계 */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link2 className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-medium truncate">엔티티 관계</span>
|
||||
</div>
|
||||
|
||||
{loadingJoins ? (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> 로딩중...
|
||||
</div>
|
||||
) : entityJoins.length > 0 ? (
|
||||
<Popover open={relationOpen} onOpenChange={setRelationOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={relationOpen}
|
||||
className="h-8 w-full justify-between text-xs"
|
||||
>
|
||||
<span className="truncate">
|
||||
{currentRelationValue
|
||||
? relationComboItems.find((r) => r.value === currentRelationValue)?.label || "관계 선택"
|
||||
: "엔티티 관계 선택"}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="관계 검색..." className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs">엔티티 관계가 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{relationComboItems.map((item) => (
|
||||
<CommandItem
|
||||
key={item.value}
|
||||
value={`${item.label} ${item.sublabel}`}
|
||||
onSelect={() => {
|
||||
if (item.value === currentRelationValue) {
|
||||
handleChange({ relationColumn: "", parentColumn: "" });
|
||||
} else {
|
||||
const [sourceCol, refPart] = item.value.split("::");
|
||||
const [, refCol] = refPart.split(".");
|
||||
handleChange({ relationColumn: sourceCol, parentColumn: refCol });
|
||||
}
|
||||
setRelationOpen(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
<Check
|
||||
className={cn("mr-2 h-3 w-3", currentRelationValue === item.value ? "opacity-100" : "opacity-0")}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span>{item.label}</span>
|
||||
<span className="text-[10px] text-muted-foreground/70">{item.sublabel}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<div className="rounded-lg border-2 border-dashed py-3 text-center">
|
||||
<p className="text-[10px] text-muted-foreground">설정된 엔티티 관계가 없습니다</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config.relationColumn && config.parentColumn && (
|
||||
<div className="rounded bg-muted/50 px-2 py-1.5 text-[10px] text-muted-foreground">
|
||||
자식 FK: <span className="font-medium text-foreground">{config.relationColumn}</span>
|
||||
{" -> "}
|
||||
부모 매칭: <span className="font-medium text-foreground">{config.parentColumn}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 테이블 미선택 안내 */}
|
||||
{!config.tableName && (
|
||||
<div className="rounded-lg border-2 border-dashed p-6 text-center">
|
||||
<Table2 className="mx-auto mb-2 h-8 w-8 text-muted-foreground opacity-30" />
|
||||
<p className="text-sm text-muted-foreground">테이블이 선택되지 않았습니다</p>
|
||||
<p className="text-xs text-muted-foreground">위 데이터 소스에서 테이블을 선택하세요</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══════════════════════════════════════ */}
|
||||
{/* 3단계: 카드 크기 (카드 선택 UI) */}
|
||||
{/* ═══════════════════════════════════════ */}
|
||||
<div className="space-y-3">
|
||||
<SectionHeader icon={Maximize2} title="카드 크기" description="상태 카드의 크기를 선택하세요" />
|
||||
<Separator />
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{SIZE_CARDS.map((card) => {
|
||||
const isSelected = (config.cardSize || "md") === card.value;
|
||||
return (
|
||||
<button
|
||||
key={card.value}
|
||||
type="button"
|
||||
onClick={() => updateField("cardSize", card.value)}
|
||||
className={cn(
|
||||
"flex min-h-[60px] flex-col items-center justify-center rounded-lg border p-2 text-center transition-all",
|
||||
isSelected
|
||||
? "border-primary bg-primary/5 ring-1 ring-primary/20"
|
||||
: "border-border hover:border-primary/50 hover:bg-muted/50"
|
||||
)}
|
||||
>
|
||||
<span className="text-xs font-medium leading-tight">{card.title}</span>
|
||||
<span className="mt-0.5 text-[10px] leading-tight text-muted-foreground">{card.description}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════════════════════════════ */}
|
||||
{/* 4단계: 상태 항목 관리 */}
|
||||
{/* ═══════════════════════════════════════ */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<SectionHeader icon={BarChart3} title="상태 항목" description="집계할 상태 값과 표시 스타일을 설정하세요" />
|
||||
<Badge variant="secondary" className="text-[10px] h-5">{items.length}개</Badge>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addItem}
|
||||
className="h-6 shrink-0 px-2 text-xs"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
{loadingCategoryValues && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" /> 카테고리 값 로딩...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="rounded-lg border-2 border-dashed py-6 text-center">
|
||||
<BarChart3 className="mx-auto mb-2 h-8 w-8 text-muted-foreground opacity-30" />
|
||||
<p className="text-sm text-muted-foreground">아직 상태 항목이 없어요</p>
|
||||
<p className="text-xs text-muted-foreground">위의 추가 버튼으로 항목을 만들어보세요</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{items.map((item: StatusCountItem, i: number) => (
|
||||
<div key={i} className="space-y-1.5 rounded-md border p-2.5">
|
||||
{/* 첫 번째 줄: 상태값 + 삭제 */}
|
||||
<div className="flex items-center gap-1">
|
||||
{statusCategoryValues.length > 0 ? (
|
||||
<Select
|
||||
value={item.value || ""}
|
||||
onValueChange={(v) => {
|
||||
updateItem(i, "value", v);
|
||||
if (v === "__ALL__" && !item.label) {
|
||||
updateItem(i, "label", "전체");
|
||||
} else {
|
||||
const catVal = statusCategoryValues.find((cv) => cv.value === v);
|
||||
if (catVal && !item.label) {
|
||||
updateItem(i, "label", catVal.label);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 flex-1 text-xs">
|
||||
<SelectValue placeholder="카테고리 값 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__ALL__" className="text-xs font-medium">
|
||||
전체
|
||||
</SelectItem>
|
||||
{statusCategoryValues.map((cv) => (
|
||||
<SelectItem key={cv.value} value={cv.value} className="text-xs">
|
||||
{cv.label} ({cv.value})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={item.value}
|
||||
onChange={(e) => updateItem(i, "value", e.target.value)}
|
||||
placeholder="상태값 (예: IN_USE)"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeItem(i)}
|
||||
className="h-6 w-6 shrink-0 p-0 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 두 번째 줄: 라벨 + 색상 */}
|
||||
<div className="flex gap-1">
|
||||
<Input
|
||||
value={item.label}
|
||||
onChange={(e) => updateItem(i, "label", e.target.value)}
|
||||
placeholder="표시 라벨"
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
<Select
|
||||
value={item.color}
|
||||
onValueChange={(v) => updateItem(i, "color", v)}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-24 shrink-0 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COLOR_OPTIONS.map((c) => (
|
||||
<SelectItem key={c} value={c} className="text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className={cn("h-3 w-3 rounded-full border", STATUS_COLOR_MAP[c].bg, STATUS_COLOR_MAP[c].border)}
|
||||
/>
|
||||
{c}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loadingCategoryValues && statusCategoryValues.length === 0 && config.tableName && config.statusColumn && (
|
||||
<div className="rounded bg-amber-50 px-2 py-1.5 text-[10px] text-amber-700 dark:bg-amber-950/30 dark:text-amber-400">
|
||||
카테고리 값이 없습니다. 옵션설정 > 카테고리설정에서 값을 추가하거나 직접 입력하세요.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 미리보기 */}
|
||||
{items.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<span className="text-xs text-muted-foreground truncate">미리보기</span>
|
||||
<div className="flex gap-1.5 rounded-md bg-muted/30 p-2">
|
||||
{items.map((item, i) => {
|
||||
const colors = STATUS_COLOR_MAP[item.color] || STATUS_COLOR_MAP.gray;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={cn("flex flex-1 flex-col items-center rounded-md border p-1.5", colors.bg, colors.border)}
|
||||
>
|
||||
<span className={cn("text-sm font-bold", colors.text)}>0</span>
|
||||
<span className={cn("text-[10px]", colors.text)}>{item.label || "라벨"}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
V2StatusCountConfigPanel.displayName = "V2StatusCountConfigPanel";
|
||||
|
||||
export default V2StatusCountConfigPanel;
|
||||
@@ -47,7 +47,7 @@ import { DataFilterConfigPanel } from "@/components/screen/config-panels/DataFil
|
||||
import { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
|
||||
import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import type { TableListConfig, ColumnConfig } from "@/lib/registry/components/v2-table-list/types";
|
||||
import type { TableListConfig, ColumnConfig } from "@/lib/registry/components/table/_shared/tableListConfigTypes";
|
||||
import { CPRow, CPSwitch } from "./_shared/cp";
|
||||
|
||||
// ─── DnD 정렬 가능한 컬럼 행 (접이식) ───
|
||||
|
||||
@@ -52,30 +52,10 @@ export function MenuProvider({ children }: { children: ReactNode }) {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 사용자 로케일이 로드될 때까지 잠시 대기
|
||||
let retryCount = 0;
|
||||
const maxRetries = 20; // 최대 2초 대기 (100ms * 20)
|
||||
|
||||
while (retryCount < maxRetries) {
|
||||
if (typeof window !== "undefined") {
|
||||
const hasGlobalLang = !!(window as any).__GLOBAL_USER_LANG;
|
||||
const hasStoredLang = !!localStorage.getItem("userLocale");
|
||||
|
||||
if (hasGlobalLang || hasStoredLang) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
retryCount++;
|
||||
}
|
||||
|
||||
if (retryCount >= maxRetries) {
|
||||
console.warn("⚠️ 사용자 로케일 로드 타임아웃, 기본값으로 진행");
|
||||
}
|
||||
|
||||
// 관리자 메뉴와 사용자 메뉴를 병렬로 로드
|
||||
// 좌측 사이드바용: active만 표시
|
||||
// 로케일은 useAuth.fetchCurrentUser 가 /auth/me 응답에서 세팅 완료 후 user.company_code 가 채워지므로
|
||||
// 이 함수가 호출되는 시점에는 항상 __GLOBAL_USER_LANG 이 세팅되어 있음 → 별도 대기 불필요
|
||||
const [adminResponse, userResponse] = await Promise.all([menuApi.getAdminMenus(), menuApi.getUserMenus()]);
|
||||
|
||||
if (adminResponse.success && adminResponse.data) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user