Merge remote-tracking branch 'origin/main' into hjjeong

This commit is contained in:
hjjeong
2026-05-22 14:49:50 +09:00
149 changed files with 14386 additions and 14162 deletions
+79
View File
@@ -1,3 +1,82 @@
<!-- User customizations -->
# 절대 규칙: 검증 없는 주장 금지
내가 출력하는 모든 발언은 근거가 있어야 한다. 근거가 없으면 그 말을 하지 않는다. 위로·추정·일반론·"보통 그렇다"로 채우지 않는다.
## 위반 사례 (절대 하지 말 것)
- "100명 중 5명도 안 된다" 같은 통계를 출처 없이 만들어내기
- "통과 확률 70~80%" 같은 수치를 추정으로 제시하기
- "보통", "일반적으로", "대부분" 으로 시작하는 일반론
- 본인이 검증 안 한 SDK/API 동작을 단정적으로 설명하기
- 위로·격려를 위해 사실이 아닌 것을 끼워넣기
## 발화 전 자기 검증
한 문장이라도 출력하기 전에 다음을 확인:
1. **출처가 있는가?** — 코드(파일:라인), 명령 결과, 공식 문서, 사용자가 준 정보, 도구 호출 결과 중 하나
2. **출처가 없다면 추정인가?** — 추정이면 명시적으로 "추정이지만…" 또는 "확인 안 됐지만…" 으로 시작
3. **추정도 근거가 없으면?** — 말하지 않는다. "모릅니다" 또는 "확인이 필요합니다" 라고 한다
## 모를 때의 정답
- 검색·문서 조회·코드 읽기로 확인 가능하면 확인부터 한다
- 확인이 불가능하면 "모릅니다" 가 정답. 그럴듯한 답을 만들지 않는다
- 사용자 의사결정에 영향을 주는 사실일수록 더 엄격하게 적용
## 어겼을 때
사용자가 "그 근거 뭐야" 라고 묻거나 잘못된 사실을 지적하면:
- 즉시 인정. "맞습니다. 그 수치 제가 지어냈습니다." 같이 명시적으로 시인
- 변명·재포장 금지
- 무엇이 검증된 사실이고 무엇이 추정/날조였는지 다시 분리해서 제시
# 💬 사용자에게 설명할 때 — 그림으로 (★ 중요)
UI 변경 제안, 디자인 토론, 코드 구조 설명 등을 할 때는 **반드시 변경 전/후를 ASCII 표나 도식으로 그려서** 보여준다. 글로만 설명하면 사용자가 이해 못 한다.
## 원칙
1. **변경 제안은 무조건 Before / After 두 그림**
2. **코드 인용 (file:line, 변수명, CSS class) 최소화** — 결론과 시각적 영향 위주
3. **평어, 한국어, 짧은 문장**
4. **영문/SQL/전문용어 풀어쓰기** — "grid template" 대신 "표 컬럼 배치", "stopPropagation" 대신 "클릭이 위로 새는 거 막기"
5. **3줄 패턴 권장** — 무슨 일 / 사용자한테 보이는 영향 / 어떻게 고치는지
## 나쁜 예시 ❌
> "ColumnGrid.tsx:93-103 의 `grid-cols-[4px_140px_1fr_100px_160px_40px]` 를 5컬럼으로 축소하고, 라벨 셀에 sub-line 을 추가하면 entity/code/numbering 의 메타가 inline 으로..."
(사용자: "뭐라는지 모르겠어")
## 좋은 예시 ⭕
> **지금 모양:**
> ```
> 라벨·컬럼명 │ 참조/설정 │ 타입
> 거래처명 │ — │ 텍스트 ← 빈 칸
> 거래처ID │ customer_mng → ... │ 테이블참조
> ```
>
> **바꿔서:**
> ```
> 라벨·컬럼명 │ 타입
> 거래처명 │ 텍스트
> 거래처ID │ 테이블참조
> → customer_mng.id ← 정보 있을 때만 작게 밑에
> ```
## 옵션 제시할 땐 표로
```
| 옵션 | 핵심 | 단점 |
| A안 | 이름만 바꾸기 | 가장 가벼움 |
| B안 | 그룹을 잘게 쪼개기 | 그룹 수 늘어남 |
```
## 우선 순위
- 첫 시도에 글만 쓰지 말 것. 그림부터 그리고 글은 짧게 보충.
- 사용자가 "무슨 말인지 모르겠어" 하면 → 더 분해해서 다시 그림 그리기. 글 길어지면 더 헷갈림.
---
# INVYONE — Claude 작업 컨벤션 # INVYONE — Claude 작업 컨벤션
이 파일은 git 에 올라가는 **프로젝트 공용** Claude 가이드입니다. 모든 머신/팀원의 Claude Code 인스턴스가 이 컨벤션을 따라야 합니다. 이 파일은 git 에 올라가는 **프로젝트 공용** Claude 가이드입니다. 모든 머신/팀원의 Claude Code 인스턴스가 이 컨벤션을 따라야 합니다.
@@ -91,6 +91,32 @@ public class DdlController {
return ResponseEntity.status(400).body(ApiResponse.error((String) result.get("message"))); 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("user_id") String userId) {
if (!isSuperAdmin(companyCode)) {
return ResponseEntity.status(403).body(ApiResponse.error("최고 관리자 권한이 필요합니다."));
}
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} - 테이블 삭제 * DELETE /api/ddl/tables/{tableName} - 테이블 삭제
*/ */
@@ -57,6 +57,13 @@ public class SubstituteContextFilter extends OncePerRequestFilter {
return; return;
} }
// 대무자 컨텍스트가 의미 없는 경로 skip — 초기 페이지 로드 latency 의 큰 부분.
// ApprovalController 만 effective_user_ids 를 참조하므로 결재 외 경로는 DB 조회 불필요.
if (isSkippablePath(path)) {
chain.doFilter(request, response);
return;
}
String userId = (String) request.getAttribute("user_id"); String userId = (String) request.getAttribute("user_id");
String companyCode = (String) request.getAttribute("company_code"); String companyCode = (String) request.getAttribute("company_code");
@@ -85,4 +92,11 @@ public class SubstituteContextFilter extends OncePerRequestFilter {
chain.doFilter(request, response); 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");
}
} }
@@ -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 // VALIDATE
// ───────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────
@@ -16,6 +16,18 @@ public class ScreenGroupService extends BaseService {
private static final String NS = "screenGroup."; 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 // 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"; String screenType = "form";
if (widgetCounts.getOrDefault("table", 0) > 0) { if (countTableLikeWidgets(widgetCounts) > 0) {
screenType = "grid"; screenType = "grid";
} else if (widgetCounts.getOrDefault("custom", 0) > 2) { } else if (widgetCounts.getOrDefault("custom", 0) > 2) {
screenType = "dashboard"; screenType = "dashboard";
@@ -433,11 +447,11 @@ public class ScreenGroupService extends BaseService {
if (bottomEdge > toInt(summary.get("canvas_height"))) summary.put("canvas_height", bottomEdge); if (bottomEdge > toInt(summary.get("canvas_height"))) summary.put("canvas_height", bottomEdge);
} }
// 화면 타입 추론 // 화면 타입 추론 — canonical / legacy / hidden v2 모두 grid 로 인식
summaryMap.values().forEach(summary -> { summaryMap.values().forEach(summary -> {
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
Map<String, Integer> wc = (Map<String, Integer>) summary.get("widget_counts"); 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"); summary.put("screen_type", "grid");
} else if (wc.getOrDefault("table-search-widget", 0) > 1) { } else if (wc.getOrDefault("table-search-widget", 0) > 1) {
summary.put("screen_type", "dashboard"); summary.put("screen_type", "dashboard");
@@ -130,26 +130,8 @@
, COALESCE(V.LANG_KEY_DESC, '') AS LANG_KEY_DESC , COALESCE(V.LANG_KEY_DESC, '') AS LANG_KEY_DESC
, COALESCE(V.MENU_ICON, '') AS MENU_ICON , COALESCE(V.MENU_ICON, '') AS MENU_ICON
, COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME , COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME
, COALESCE( , COALESCE(MLT_NAME.LANG_TEXT, V.MENU_NAME_KOR) AS TRANSLATED_NAME
(SELECT MLT.LANG_TEXT , COALESCE(MLT_DESC.LANG_TEXT, COALESCE(V.MENU_DESC, '')) AS TRANSLATED_DESC
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
, CASE UPPER(V.STATUS) , CASE UPPER(V.STATUS)
WHEN 'ACTIVE' THEN '활성화' WHEN 'ACTIVE' THEN '활성화'
WHEN 'INACTIVE' THEN '비활성화' WHEN 'INACTIVE' THEN '비활성화'
@@ -158,6 +140,16 @@
FROM V_MENU V FROM V_MENU V
LEFT JOIN COMPANY_MNG CM LEFT JOIN COMPANY_MNG CM
ON V.COMPANY_CODE = CM.COMPANY_CODE 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 ORDER BY V.PATH, V.SEQ
</select> </select>
@@ -243,26 +235,8 @@
, COALESCE(V.LANG_KEY_DESC, '') AS LANG_KEY_DESC , COALESCE(V.LANG_KEY_DESC, '') AS LANG_KEY_DESC
, COALESCE(V.MENU_ICON, '') AS MENU_ICON , COALESCE(V.MENU_ICON, '') AS MENU_ICON
, COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME , COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME
, COALESCE( , COALESCE(MLT_NAME.LANG_TEXT, V.MENU_NAME_KOR) AS TRANSLATED_NAME
(SELECT MLT.LANG_TEXT , COALESCE(MLT_DESC.LANG_TEXT, COALESCE(V.MENU_DESC, '')) AS TRANSLATED_DESC
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
, CASE UPPER(V.STATUS) , CASE UPPER(V.STATUS)
WHEN 'ACTIVE' THEN '활성화' WHEN 'ACTIVE' THEN '활성화'
WHEN 'INACTIVE' THEN '비활성화' WHEN 'INACTIVE' THEN '비활성화'
@@ -271,6 +245,16 @@
FROM V_MENU V FROM V_MENU V
LEFT JOIN COMPANY_MNG CM LEFT JOIN COMPANY_MNG CM
ON V.COMPANY_CODE = CM.COMPANY_CODE 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 ORDER BY V.PATH, V.SEQ
</select> </select>
@@ -377,26 +361,8 @@
, COALESCE(V.LANG_KEY_DESC, '') AS LANG_KEY_DESC , COALESCE(V.LANG_KEY_DESC, '') AS LANG_KEY_DESC
, COALESCE(V.MENU_ICON, '') AS MENU_ICON , COALESCE(V.MENU_ICON, '') AS MENU_ICON
, COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME , COALESCE(CM.COMPANY_NAME, '미지정') AS COMPANY_NAME
, COALESCE( , COALESCE(MLT_NAME.LANG_TEXT, V.MENU_NAME_KOR) AS TRANSLATED_NAME
(SELECT MLT.LANG_TEXT , COALESCE(MLT_DESC.LANG_TEXT, COALESCE(V.MENU_DESC, '')) AS TRANSLATED_DESC
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
, CASE UPPER(V.STATUS) , CASE UPPER(V.STATUS)
WHEN 'ACTIVE' THEN '활성화' WHEN 'ACTIVE' THEN '활성화'
WHEN 'INACTIVE' THEN '비활성화' WHEN 'INACTIVE' THEN '비활성화'
@@ -405,6 +371,16 @@
FROM V_MENU V FROM V_MENU V
LEFT JOIN COMPANY_MNG CM LEFT JOIN COMPANY_MNG CM
ON V.COMPANY_CODE = CM.COMPANY_CODE 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 ORDER BY V.PATH, V.SEQ
</select> </select>
@@ -704,11 +704,19 @@
</foreach> </foreach>
AND SL.PROPERTIES->'componentConfig'->'action'->>'type' = 'save' AND SL.PROPERTIES->'componentConfig'->'action'->>'type' = 'save'
AND SL.PROPERTIES->'componentConfig'->'action'->>'targetScreenId' IS NULL 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 ( AND NOT EXISTS (
SELECT 1 FROM SCREEN_LAYOUTS SL_LIST SELECT 1 FROM SCREEN_LAYOUTS SL_LIST
WHERE SL_LIST.SCREEN_ID = SD.SCREEN_ID WHERE SL_LIST.SCREEN_ID = SD.SCREEN_ID
AND SL_LIST.PROPERTIES->>'componentType' = 'table-list' AND SL_LIST.PROPERTIES->>'componentType' IN ('table', 'table-list', 'v2-table-list')
AND (SL_LIST.PROPERTIES->'componentConfig'->'checkbox'->>'enabled')::BOOLEAN = TRUE AND (
(SL_LIST.PROPERTIES->'componentConfig'->'checkbox'->>'enabled')::BOOLEAN = TRUE
OR (SL_LIST.PROPERTIES->'componentConfig'->>'showCheckbox')::BOOLEAN = TRUE
)
) )
AND NOT EXISTS ( AND NOT EXISTS (
SELECT 1 FROM SCREEN_LAYOUTS SL_MODAL SELECT 1 FROM SCREEN_LAYOUTS SL_MODAL
@@ -21,7 +21,10 @@ import {
ChevronsUpDown, ChevronsUpDown,
Loader2, Loader2,
Pencil, Pencil,
Columns3,
Link2,
} from "lucide-react"; } from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { LoadingSpinner } from "@/components/common/LoadingSpinner"; import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -56,6 +59,7 @@ import type { TableInfo, ColumnTypeInfo, SecondLevelMenu } from "@/components/ad
import { TypeOverviewStrip } from "@/components/admin/table-type/TypeOverviewStrip"; import { TypeOverviewStrip } from "@/components/admin/table-type/TypeOverviewStrip";
import { ColumnGrid } from "@/components/admin/table-type/ColumnGrid"; import { ColumnGrid } from "@/components/admin/table-type/ColumnGrid";
import { ColumnDetailPanel } from "@/components/admin/table-type/ColumnDetailPanel"; import { ColumnDetailPanel } from "@/components/admin/table-type/ColumnDetailPanel";
import { ReferenceListView } from "@/components/admin/table-type/ReferenceListView";
export default function TableManagementPage() { export default function TableManagementPage() {
const { userLang, getText } = useMultiLang({ companyCode: "*" }); const { userLang, getText } = useMultiLang({ companyCode: "*" });
@@ -120,6 +124,9 @@ export default function TableManagementPage() {
// 테이블 삭제 확인 다이얼로그 상태 // 테이블 삭제 확인 다이얼로그 상태
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [tableToDelete, setTableToDelete] = useState<string>(""); 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); const [isDeleting, setIsDeleting] = useState(false);
// PK/인덱스 관리 상태 // PK/인덱스 관리 상태
@@ -984,7 +991,20 @@ export default function TableManagementPage() {
(table.display_name ?? '').toLowerCase().includes(searchTerm.toLowerCase()), (table.display_name ?? '').toLowerCase().includes(searchTerm.toLowerCase()),
); );
const isKorean = (str: string) => /^[가-힣ㄱ-ㅎ]/.test(str); 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) => { 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 nameA = a.display_name || a.table_name;
const nameB = b.display_name || b.table_name; const nameB = b.display_name || b.table_name;
const aKo = isKorean(nameA); const aKo = isKorean(nameA);
@@ -1188,6 +1208,37 @@ export default function TableManagementPage() {
setDeleteDialogOpen(true); 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 () => { const handleDeleteTable = async () => {
if (!tableToDelete) return; if (!tableToDelete) return;
@@ -1643,55 +1694,106 @@ export default function TableManagementPage() {
{getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")} {getTextFromUI(TABLE_MANAGEMENT_KEYS.MESSAGE_NO_COLUMNS, "컬럼이 없습니다")}
</div> </div>
) : ( ) : (
<> <Tabs defaultValue="columns" className="flex min-h-0 flex-1 flex-col">
<TypeOverviewStrip <TabsList className="h-auto w-full shrink-0 justify-start gap-1 rounded-none border-b bg-transparent p-0">
columns={columns} <TabsTrigger
activeFilter={typeFilter} value="columns"
onFilterChange={setTypeFilter} className={cn(
/> "flex items-center gap-2 rounded-none border-b-2 border-transparent bg-transparent px-4 py-2.5 text-sm font-medium text-muted-foreground transition-colors",
<ColumnGrid "hover:text-foreground",
columns={columns} "data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:text-primary data-[state=active]:shadow-none",
selectedColumn={selectedColumn} )}
onSelectColumn={(c) => setSelectedColumn((prev) => (prev === c ? null : c))} >
onColumnChange={(columnName, field, value) => { <Columns3 className="h-4 w-4" />
if (field === "is_unique") {
const currentColumn = columns.find((c) => c.column_name === columnName); </TabsTrigger>
if (currentColumn) { <TabsTrigger
handleUniqueToggle(columnName, currentColumn.is_unique || "NO"); value="references"
className={cn(
"flex items-center gap-2 rounded-none border-b-2 border-transparent bg-transparent px-4 py-2.5 text-sm font-medium text-muted-foreground transition-colors",
"hover:text-foreground",
"data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:text-primary data-[state=active]:shadow-none",
)}
>
<Link2 className="h-4 w-4" />
{(() => {
const refCount = columns.filter((c) =>
["entity", "code", "category", "numbering"].includes(c.input_type),
).length;
return refCount > 0 ? (
<Badge variant="secondary" className="ml-1.5 h-5 px-1.5 text-[11px]">
{refCount}
</Badge>
) : null;
})()}
</TabsTrigger>
</TabsList>
<TabsContent value="columns" className="mt-0 flex min-h-0 flex-1 flex-col">
<TypeOverviewStrip
columns={columns}
activeFilter={typeFilter}
onFilterChange={setTypeFilter}
/>
<ColumnGrid
columns={columns}
selectedColumn={selectedColumn}
onSelectColumn={(c) => setSelectedColumn((prev) => (prev === c ? null : c))}
onColumnChange={(columnName, field, value) => {
if (field === "is_unique") {
const currentColumn = columns.find((c) => c.column_name === columnName);
if (currentColumn) {
handleUniqueToggle(columnName, currentColumn.is_unique || "NO");
}
return;
} }
return; if (field === "is_nullable") {
} const currentColumn = columns.find((c) => c.column_name === columnName);
if (field === "is_nullable") { if (currentColumn) {
const currentColumn = columns.find((c) => c.column_name === columnName); handleNullableToggle(columnName, currentColumn.is_nullable || "YES");
if (currentColumn) { }
handleNullableToggle(columnName, currentColumn.is_nullable || "YES"); return;
} }
return; const idx = columns.findIndex((c) => c.column_name === columnName);
if (idx >= 0) handleColumnChange(idx, field, value);
}}
constraints={constraints}
typeFilter={typeFilter}
getColumnIndexState={getColumnIndexState}
onPkToggle={handlePkToggle}
onIndexToggle={(columnName, checked) =>
handleIndexToggle(columnName, "index", checked)
} }
const idx = columns.findIndex((c) => c.column_name === columnName); onDeleteColumn={handleDeleteColumnClick}
if (idx >= 0) handleColumnChange(idx, field, value); tables={tables}
}} referenceTableColumns={referenceTableColumns}
constraints={constraints} />
typeFilter={typeFilter} </TabsContent>
getColumnIndexState={getColumnIndexState}
onPkToggle={handlePkToggle} <TabsContent value="references" className="mt-0 flex min-h-0 flex-1 flex-col">
onIndexToggle={(columnName, checked) => <ReferenceListView
handleIndexToggle(columnName, "index", checked) columns={columns}
} tables={tables}
tables={tables} referenceTableColumns={referenceTableColumns}
referenceTableColumns={referenceTableColumns} selectedColumn={selectedColumn}
/> onSelectColumn={(c) => setSelectedColumn((prev) => (prev === c ? null : c))}
</> />
</TabsContent>
</Tabs>
)} )}
</> </>
)} )}
</div> </div>
{/* 우측: 상세 패널 (overlay slide-in/out — 가운데 본문 위에 부드럽게 등장) */} {/* 우측: 상세 패널
- 와이드 모니터 (xl 이상): 항상 보이는 고정 3-pane
- 좁은 화면: 기존처럼 슬라이드 in 오버레이 */}
<div <div
className={cn( className={cn(
"bg-card absolute top-0 right-0 bottom-0 z-20 flex w-[380px] flex-col overflow-hidden border-l shadow-2xl transition-transform duration-300 ease-out", "bg-card absolute top-0 right-0 bottom-0 z-20 flex w-[380px] flex-col overflow-hidden border-l shadow-2xl transition-transform duration-300 ease-out",
selectedColumn ? "translate-x-0" : "pointer-events-none translate-x-full", selectedColumn ? "translate-x-0" : "pointer-events-none translate-x-full",
"xl:relative xl:z-0 xl:flex-shrink-0 xl:translate-x-0 xl:pointer-events-auto xl:shadow-none xl:transition-none",
)} )}
> >
<ColumnDetailPanel <ColumnDetailPanel
@@ -1863,6 +1965,62 @@ export default function TableManagementPage() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </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>
</> </>
)} )}
@@ -10,6 +10,7 @@ import { LayerDefinition } from "@/types/screen-management";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { showErrorToast } from "@/lib/utils/toastUtils"; import { showErrorToast } from "@/lib/utils/toastUtils";
import { initializeComponents } from "@/lib/registry/components"; import { initializeComponents } from "@/lib/registry/components";
import { isTableLikeComponent } from "@/lib/utils/componentTypeUtils";
import { EditModal } from "@/components/screen/EditModal"; import { EditModal } from "@/components/screen/EditModal";
import { RealtimePreview } from "@/components/screen/RealtimePreviewDynamic"; import { RealtimePreview } from "@/components/screen/RealtimePreviewDynamic";
import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext"; import { ScreenPreviewProvider } from "@/contexts/ScreenPreviewContext";
@@ -428,10 +429,8 @@ function ScreenViewPage({ screenIdProp, menuObjidProp }: ScreenViewPageProps = {
} }
// 테이블 위젯이 있으면 자동 로드 건너뜀 (테이블 행 선택으로 데이터 로드) // 테이블 위젯이 있으면 자동 로드 건너뜀 (테이블 행 선택으로 데이터 로드)
const hasTableWidget = layout.components.some( // canonical table / legacy table-list / hidden v2-table-list / widgetType=table 모두 동일하게 skip
(comp: any) => const hasTableWidget = layout.components.some((comp: any) => isTableLikeComponent(comp));
comp.componentType === "table-list" || comp.componentType === "v2-table-list" || comp.widgetType === "table",
);
if (hasTableWidget) { if (hasTableWidget) {
return; return;
+3 -3
View File
@@ -92,10 +92,10 @@ export default function TestCardResponsivePage() {
{/* ── 1. v2-text-display (경량, 항상 동일) ── */} {/* ── 1. v2-text-display (경량, 항상 동일) ── */}
<div className="mb-2 text-base font-semibold text-slate-800"></div> <div className="mb-2 text-base font-semibold text-slate-800"></div>
{/* ── 2. v2-aggregation-widget (경량, container-type 만 부착) ── */} {/* ── 2. canonical stats (경량, container-type 만 부착) ── */}
<div <div
className="mb-3 grid grid-cols-4 gap-2 rounded border border-slate-200 bg-white p-2" 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" }, { label: "전체", v: "128" },
@@ -214,7 +214,7 @@ export default function TestCardResponsivePage() {
<b className="text-indigo-600">v2-table-search-widget</b> / (CSS @container ). <b className="text-indigo-600">v2-table-search-widget</b> / (CSS @container ).
</li> </li>
<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 . Phase 2 .
</li> </li>
</ul> </ul>
@@ -322,7 +322,7 @@ export function CreateTableModal({
return ( return (
<Dialog open={isOpen} onOpenChange={onClose}> <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> <DialogHeader>
<DialogTitle className="flex items-center gap-2"> <DialogTitle className="flex items-center gap-2">
<Plus className="h-5 w-5" /> <Plus className="h-5 w-5" />
@@ -336,7 +336,7 @@ export function CreateTableModal({
</DialogDescription> </DialogDescription>
</DialogHeader> </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="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2"> <div className="space-y-2">
@@ -113,8 +113,20 @@ export function ColumnDetailPanel({
}, [referenceTableOptions, tables]); }, [referenceTableOptions, tables]);
// early return 은 반드시 모든 hook 호출 뒤에 (Rules of Hooks). // early return 은 반드시 모든 hook 호출 뒤에 (Rules of Hooks).
// overlay 패턴으로 항상 마운트되므로 column null 케이스가 정상적으로 들어옴. // 컬럼 선택 안 한 상태에서도 패널이 항상 보이는 와이드 레이아웃 대응 — 빈 상태 안내 UI 표시.
if (!column) return null; if (!column) {
return (
<div className="flex h-full w-full flex-col items-center justify-center border-l bg-card 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>
);
}
return ( return (
<div className="flex h-full w-full flex-col border-l bg-card"> <div className="flex h-full w-full flex-col border-l bg-card">
@@ -183,12 +195,12 @@ export function ColumnDetailPanel({
isLegacy && "cursor-not-allowed", isLegacy && "cursor-not-allowed",
)} )}
> >
<span className={cn( <conf.Icon
"text-base font-bold leading-none", className={cn(
isSelected ? "text-primary" : conf.color, "h-4 w-4",
)}> isSelected ? "text-primary" : conf.color,
{conf.iconChar} )}
</span> />
<span className={cn( <span className={cn(
"text-[16px] font-semibold leading-tight", "text-[16px] font-semibold leading-tight",
isSelected ? "text-primary" : "text-foreground", isSelected ? "text-primary" : "text-foreground",
@@ -1,9 +1,15 @@
"use client"; "use client";
import React, { useMemo } from "react"; 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 { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ColumnTypeInfo, TableInfo } from "./types"; import type { ColumnTypeInfo, TableInfo } from "./types";
import { INPUT_TYPE_COLORS, getColumnGroup } from "./types"; import { INPUT_TYPE_COLORS, getColumnGroup } from "./types";
@@ -24,6 +30,7 @@ export interface ColumnGridProps {
getColumnIndexState?: (columnName: string) => { isPk: boolean; hasIndex: boolean }; getColumnIndexState?: (columnName: string) => { isPk: boolean; hasIndex: boolean };
onPkToggle?: (columnName: string, checked: boolean) => void; onPkToggle?: (columnName: string, checked: boolean) => void;
onIndexToggle?: (columnName: string, checked: boolean) => void; onIndexToggle?: (columnName: string, checked: boolean) => void;
onDeleteColumn?: (columnName: string) => void;
/** 호버 시 한글 라벨 표시용 (Badge title) */ /** 호버 시 한글 라벨 표시용 (Badge title) */
tables?: TableInfo[]; tables?: TableInfo[];
referenceTableColumns?: Record<string, ReferenceTableColumn[]>; referenceTableColumns?: Record<string, ReferenceTableColumn[]>;
@@ -40,11 +47,10 @@ function getIndexState(
return { isPk, hasIndex }; return { isPk, hasIndex };
} }
/** 그룹 헤더 라벨 */ /** 그룹 헤더 라벨 — 참조 컬럼은 별도 "참조" 탭에서 보여주므로 컬럼 탭에서는 사용자/시스템 2그룹으로만 분류 */
const GROUP_LABELS: Record<string, { icon: React.FC<{ className?: string }>; label: string }> = { const GROUP_LABELS: Record<"user" | "system", { icon: React.FC<{ className?: string }>; label: string }> = {
basic: { icon: FileStack, label: "기본 정보" }, user: { icon: FileStack, label: "사용자 컬럼" },
reference: { icon: Layers, label: "참조 정보" }, system: { icon: Database, label: "시스템 컬럼" },
meta: { icon: Database, label: "메타 정보" },
}; };
export function ColumnGrid({ export function ColumnGrid({
@@ -57,6 +63,7 @@ export function ColumnGrid({
getColumnIndexState: externalGetIndexState, getColumnIndexState: externalGetIndexState,
onPkToggle, onPkToggle,
onIndexToggle, onIndexToggle,
onDeleteColumn,
tables, tables,
referenceTableColumns, referenceTableColumns,
}: ColumnGridProps) { }: ColumnGridProps) {
@@ -65,30 +72,28 @@ export function ColumnGrid({
[constraints, externalGetIndexState], [constraints, externalGetIndexState],
); );
/** typeFilter 적용 후 그룹정렬 */ /** typeFilter 적용 후 사용자/시스템 그룹분류 (참조 컬럼은 참조 탭으로 분리됐으므로 사용자 컬럼에 합침) */
const filteredAndGrouped = useMemo(() => { const filteredAndGrouped = useMemo(() => {
const filtered = const filtered =
typeFilter != null ? columns.filter((c) => (c.input_type || "text") === typeFilter) : columns; typeFilter != null ? columns.filter((c) => (c.input_type || "text") === typeFilter) : columns;
const groups = { basic: [] as ColumnTypeInfo[], reference: [] as ColumnTypeInfo[], meta: [] as ColumnTypeInfo[] }; const groups = { user: [] as ColumnTypeInfo[], system: [] as ColumnTypeInfo[] };
for (const col of filtered) { for (const col of filtered) {
const group = getColumnGroup(col); const g = getColumnGroup(col) === "meta" ? "system" : "user";
groups[group].push(col); groups[g].push(col);
} }
return groups; return groups;
}, [columns, typeFilter]); }, [columns, typeFilter]);
const totalFiltered = const totalFiltered = filteredAndGrouped.user.length + filteredAndGrouped.system.length;
filteredAndGrouped.basic.length + filteredAndGrouped.reference.length + filteredAndGrouped.meta.length;
return ( return (
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
<div <div
className="grid flex-shrink-0 items-center border-b bg-muted/50 px-4 py-2 text-xs font-semibold text-foreground" className="grid flex-shrink-0 items-center border-b bg-muted/50 px-4 py-2 text-xs font-semibold text-foreground"
style={{ gridTemplateColumns: "4px 140px 1fr 100px 160px 40px" }} style={{ gridTemplateColumns: "4px 1fr 100px 160px 40px" }}
> >
<span /> <span />
<span> · </span> <span> · </span>
<span>/</span>
<span></span> <span></span>
<span className="text-center">PK / NN / IDX / UQ</span> <span className="text-center">PK / NN / IDX / UQ</span>
<span /> <span />
@@ -100,7 +105,7 @@ export function ColumnGrid({
{typeFilter ? "해당 타입의 컬럼이 없습니다." : "컬럼이 없습니다."} {typeFilter ? "해당 타입의 컬럼이 없습니다." : "컬럼이 없습니다."}
</div> </div>
) : ( ) : (
(["basic", "reference", "meta"] as const).map((groupKey) => { (["user", "system"] as const).map((groupKey) => {
const list = filteredAndGrouped[groupKey]; const list = filteredAndGrouped[groupKey];
if (list.length === 0) return null; if (list.length === 0) return null;
const { icon: Icon, label } = GROUP_LABELS[groupKey]; const { icon: Icon, label } = GROUP_LABELS[groupKey];
@@ -134,7 +139,7 @@ export function ColumnGrid({
}} }}
className={cn( className={cn(
"grid min-h-12 cursor-pointer items-center gap-2 rounded-md border px-4 py-2 transition-colors", "grid min-h-12 cursor-pointer items-center gap-2 rounded-md border px-4 py-2 transition-colors",
"grid-cols-[4px_140px_1fr_100px_160px_40px]", "grid-cols-[4px_1fr_100px_160px_40px]",
"bg-card border-transparent hover:border-border hover:shadow-sm", "bg-card border-transparent hover:border-border hover:shadow-sm",
isSelected && "border-primary/30 bg-primary/5 shadow-sm", isSelected && "border-primary/30 bg-primary/5 shadow-sm",
)} )}
@@ -151,66 +156,6 @@ export function ColumnGrid({
</div> </div>
</div> </div>
{/* 참조/설정 칩 */}
<div className="flex min-w-0 flex-wrap gap-1">
{column.input_type === "entity" && column.reference_table && column.reference_table !== "none" && (
<>
<Badge
variant="outline"
className="text-xs font-normal"
title={
tables
? (() => {
const t = tables.find((tb) => tb.table_name === column.reference_table);
return t?.display_name && t.display_name !== t.table_name
? `${t.display_name} (${column.reference_table})`
: column.reference_table;
})()
: column.reference_table
}
>
{column.reference_table}
</Badge>
<span className="text-muted-foreground text-xs"></span>
<Badge
variant="outline"
className="text-xs font-normal"
title={
referenceTableColumns?.[column.reference_table]
? (() => {
const refCols = referenceTableColumns[column.reference_table];
const c = refCols.find((rc) => rc.column_name === (column.reference_column ?? ""));
return c?.display_name && c.display_name !== c.column_name
? `${c.display_name} (${column.reference_column})`
: column.reference_column ?? "—";
})()
: column.reference_column ?? "—"
}
>
{column.reference_column || "—"}
</Badge>
</>
)}
{column.input_type === "code" && (
<span className="text-muted-foreground truncate text-xs">
{column.code_info ?? "—"} · {column.default_value ?? ""}
</span>
)}
{column.input_type === "numbering" && column.numbering_rule_id && (
<Badge variant="outline" className="text-xs font-normal">
{column.numbering_rule_id}
</Badge>
)}
{column.input_type !== "entity" &&
column.input_type !== "code" &&
column.input_type !== "numbering" &&
(column.default_value ? (
<span className="text-muted-foreground truncate text-xs">{column.default_value}</span>
) : (
<span className="text-muted-foreground/60 text-xs"></span>
))}
</div>
{/* 타입 뱃지 */} {/* 타입 뱃지 */}
<div className={cn("rounded-md border px-2 py-0.5 text-xs", typeConf.bgColor, typeConf.color)}> <div className={cn("rounded-md border px-2 py-0.5 text-xs", typeConf.bgColor, typeConf.color)}>
<span className="mr-1 inline-block h-1.5 w-1.5 rounded-full bg-current opacity-70" /> <span className="mr-1 inline-block h-1.5 w-1.5 rounded-full bg-current opacity-70" />
@@ -285,20 +230,37 @@ export function ColumnGrid({
</button> </button>
</div> </div>
<div className="flex items-center justify-center"> <div
<Button className="flex items-center justify-center"
type="button" onClick={(e) => e.stopPropagation()}
variant="ghost" onPointerDown={(e) => e.stopPropagation()}
size="icon" onMouseDown={(e) => e.stopPropagation()}
className="h-8 w-8" >
onClick={(e) => { <DropdownMenu>
e.stopPropagation(); <DropdownMenuTrigger asChild>
onSelectColumn(column.column_name); <Button
}} type="button"
aria-label="상세 설정" variant="ghost"
> size="icon"
<MoreHorizontal className="h-4 w-4" /> className="h-8 w-8"
</Button> 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>
</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 React, { useMemo } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ColumnTypeInfo } from "./types"; import type { ColumnTypeInfo } from "./types";
import { INPUT_TYPE_COLORS } from "./types"; import { INPUT_TYPE_COLORS, FALLBACK_TYPE_CONFIG } from "./types";
import { USER_SELECTABLE_INPUT_TYPE_ORDER } from "@/types/input-types"; import { USER_SELECTABLE_INPUT_TYPE_ORDER } from "@/types/input-types";
export interface TypeOverviewStripProps { export interface TypeOverviewStripProps {
@@ -57,20 +57,13 @@ export function TypeOverviewStrip({
/** stroke-dasharray: 비율만큼 둘레에 할당 (둘레 100 기준) */ /** stroke-dasharray: 비율만큼 둘레에 할당 (둘레 100 기준) */
const circumference = 100; const circumference = 100;
let offset = 0; let offset = 0;
const LEGACY_CONF = { const LEGACY_CONF = { ...FALLBACK_TYPE_CONFIG, color: "text-amber-600", bgColor: "bg-amber-50", barColor: "bg-amber-400" };
color: "text-amber-600",
bgColor: "bg-amber-50",
barColor: "bg-amber-400",
label: "Legacy",
desc: "구버전 타입",
iconChar: "?",
};
const segmentPaths = segments.map(({ type, ratio, isLegacy }) => { const segmentPaths = segments.map(({ type, ratio, isLegacy }) => {
const length = ratio * circumference; const length = ratio * circumference;
const dashArray = `${length} ${circumference - length}`; const dashArray = `${length} ${circumference - length}`;
const dashOffset = -offset; const dashOffset = -offset;
offset += length; offset += length;
const conf = isLegacy ? LEGACY_CONF : (INPUT_TYPE_COLORS[type] || { color: "text-muted-foreground", bgColor: "bg-muted" }); const conf = isLegacy ? LEGACY_CONF : (INPUT_TYPE_COLORS[type] || FALLBACK_TYPE_CONFIG);
return { return {
type, type,
dashArray, dashArray,
@@ -112,7 +105,7 @@ export function TypeOverviewStrip({
.filter((type) => (counts[type] || 0) > 0) .filter((type) => (counts[type] || 0) > 0)
.sort((a, b) => (counts[b] ?? 0) - (counts[a] ?? 0)) .sort((a, b) => (counts[b] ?? 0) - (counts[a] ?? 0))
.map((type) => { .map((type) => {
const conf = INPUT_TYPE_COLORS[type] || { color: "text-muted-foreground", bgColor: "bg-muted", label: type }; const conf = INPUT_TYPE_COLORS[type] || { ...FALLBACK_TYPE_CONFIG, label: type };
const isActive = activeFilter === null || activeFilter === type; const isActive = activeFilter === null || activeFilter === type;
return ( return (
<button <button
+42 -15
View File
@@ -3,6 +3,23 @@
* page.tsx에서 추출한 인터페이스 및 타입별 색상/그룹 유틸 * page.tsx에서 추출한 인터페이스 및 타입별 색상/그룹 유틸
*/ */
import {
AlignLeft,
Braces,
Calendar,
CheckSquare,
ChevronDown,
CircleDot,
FolderTree,
Hash,
HelpCircle,
Image as ImageIcon,
Link2,
ListOrdered,
Paperclip,
Type,
type LucideIcon,
} from "lucide-react";
import { USER_SELECTABLE_INPUT_TYPE_ORDER } from "@/types/input-types"; import { USER_SELECTABLE_INPUT_TYPE_ORDER } from "@/types/input-types";
export interface TableInfo { export interface TableInfo {
@@ -52,24 +69,34 @@ export interface TypeColorConfig {
barColor: string; barColor: string;
label: string; label: string;
desc: string; desc: string;
iconChar: string; Icon: LucideIcon;
} }
/** 입력 타입별 색상 맵 - iconChar는 카드 선택용 시각 아이콘 */ /** Legacy/알 수 없는 타입용 fallback config */
export const FALLBACK_TYPE_CONFIG: TypeColorConfig = {
color: "text-muted-foreground",
bgColor: "bg-muted",
barColor: "bg-muted",
label: "Legacy",
desc: "구버전 타입",
Icon: HelpCircle,
};
/** 입력 타입별 색상 맵 - Icon 은 lucide 컴포넌트로 통일 (letter/symbol/emoji 혼재 방지) */
export const INPUT_TYPE_COLORS: Record<string, TypeColorConfig> = { export const INPUT_TYPE_COLORS: Record<string, TypeColorConfig> = {
text: { color: "text-slate-600", bgColor: "bg-slate-50", barColor: "bg-slate-400", label: "텍스트", desc: "일반 텍스트 입력", iconChar: "T" }, text: { color: "text-slate-600", bgColor: "bg-slate-50", barColor: "bg-slate-400", label: "텍스트", desc: "일반 텍스트 입력", Icon: Type },
number: { color: "text-indigo-600", bgColor: "bg-indigo-50", barColor: "bg-indigo-500", label: "숫자", desc: "숫자만 입력", iconChar: "#" }, number: { color: "text-indigo-600", bgColor: "bg-indigo-50", barColor: "bg-indigo-500", label: "숫자", desc: "숫자만 입력", Icon: Hash },
date: { color: "text-amber-600", bgColor: "bg-amber-50", barColor: "bg-amber-500", label: "날짜", desc: "날짜 선택", iconChar: "D" }, date: { color: "text-amber-600", bgColor: "bg-amber-50", barColor: "bg-amber-500", label: "날짜", desc: "날짜 선택", Icon: Calendar },
code: { color: "text-emerald-600", bgColor: "bg-emerald-50", barColor: "bg-emerald-500", label: "코드", desc: "공통코드 선택", iconChar: "{}" }, code: { color: "text-emerald-600", bgColor: "bg-emerald-50", barColor: "bg-emerald-500", label: "코드", desc: "공통코드 선택", Icon: Braces },
entity: { color: "text-violet-600", bgColor: "bg-violet-50", barColor: "bg-violet-500", label: "테이블 참조", desc: "다른 테이블 연결", iconChar: "⊞" }, entity: { color: "text-violet-600", bgColor: "bg-violet-50", barColor: "bg-violet-500", label: "테이블 참조", desc: "다른 테이블 연결", Icon: Link2 },
select: { color: "text-cyan-600", bgColor: "bg-cyan-50", barColor: "bg-cyan-500", label: "셀렉트", desc: "직접 옵션 선택", iconChar: "☰" }, select: { color: "text-cyan-600", bgColor: "bg-cyan-50", barColor: "bg-cyan-500", label: "셀렉트", desc: "직접 옵션 선택", Icon: ChevronDown },
checkbox: { color: "text-pink-600", bgColor: "bg-pink-50", barColor: "bg-pink-500", label: "체크박스", desc: "예/아니오 선택", iconChar: "☑" }, checkbox: { color: "text-pink-600", bgColor: "bg-pink-50", barColor: "bg-pink-500", label: "체크박스", desc: "예/아니오 선택", Icon: CheckSquare },
numbering: { color: "text-orange-600", bgColor: "bg-orange-50", barColor: "bg-orange-500", label: "채번", desc: "자동 번호 생성", iconChar: "≡" }, numbering: { color: "text-orange-600", bgColor: "bg-orange-50", barColor: "bg-orange-500", label: "채번", desc: "자동 번호 생성", Icon: ListOrdered },
category: { color: "text-teal-600", bgColor: "bg-teal-50", barColor: "bg-teal-500", label: "카테고리", desc: "등록된 선택지", iconChar: "⊟" }, category: { color: "text-teal-600", bgColor: "bg-teal-50", barColor: "bg-teal-500", label: "카테고리", desc: "등록된 선택지", Icon: FolderTree },
textarea: { color: "text-indigo-600", bgColor: "bg-indigo-50", barColor: "bg-indigo-400", label: "여러 줄", desc: "긴 텍스트 입력", iconChar: "≡" }, textarea: { color: "text-indigo-600", bgColor: "bg-indigo-50", barColor: "bg-indigo-400", label: "여러 줄", desc: "긴 텍스트 입력", Icon: AlignLeft },
radio: { color: "text-rose-600", bgColor: "bg-rose-50", barColor: "bg-rose-500", label: "라디오", desc: "하나만 선택", iconChar: "◉" }, radio: { color: "text-rose-600", bgColor: "bg-rose-50", barColor: "bg-rose-500", label: "라디오", desc: "하나만 선택", Icon: CircleDot },
file: { color: "text-amber-600", bgColor: "bg-amber-50", barColor: "bg-amber-500", label: "파일", desc: "파일 업로드", iconChar: "📎" }, file: { color: "text-amber-600", bgColor: "bg-amber-50", barColor: "bg-amber-500", label: "파일", desc: "파일 업로드", Icon: Paperclip },
image: { color: "text-sky-600", bgColor: "bg-sky-50", barColor: "bg-sky-500", label: "이미지", desc: "이미지 표시", iconChar: "🖼" }, image: { color: "text-sky-600", bgColor: "bg-sky-50", barColor: "bg-sky-500", label: "이미지", desc: "이미지 표시", Icon: ImageIcon },
}; };
/** v3.2 — 사용자 선택 가능한 8개 입력 타입 색상 맵 (T2 드롭다운/카드 그리드용) */ /** v3.2 — 사용자 선택 가능한 8개 입력 타입 색상 맵 (T2 드롭다운/카드 그리드용) */
+15 -3
View File
@@ -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 { 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 클래스 + 마커 */ /** 타입별 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>
);
}
+156 -28
View File
@@ -1,11 +1,17 @@
'use client'; 'use client';
import { useRef } from 'react'; import { useEffect, useRef } from 'react';
import { MousePointerClick } from 'lucide-react';
import { useControlMode } from './hooks/useControlMode'; import { useControlMode } from './hooks/useControlMode';
import { ControlToolbar } from './ControlToolbar';
import { ControlPalette } from './ControlPalette';
import { FlowViewer } from './FlowViewer'; 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'; import '@/styles/control-mode.css';
interface ControlModeProps { 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) { 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; if (!active) return null;
return ( return (
<> <>
{/* 제어 모드 툴바 */} {/* 카드 미선택 — FlowViewer (호버 토폴로지) + 안내 칩 + FAB */}
<ControlToolbar dashboardId={dashboardId} /> {!selectedCard && (
<>
{/* 읽기 모드: 카드 클릭 → 흐름 시각화 */} <FlowViewer cards={cards} canvasRef={canvasRef} dashboardId={dashboardId} />
{mode === 'view' && ( <div className="ctrl-mode-hint">
<FlowViewer cards={cards} canvasRef={canvasRef} dashboardId={dashboardId} /> <MousePointerClick size={13} style={{ color: 'rgb(0, 154, 150)' }} />
<span> <b>Control IDE</b> </span>
</div>
<CtrlFab onExit={toggleControlMode} />
</>
)} )}
{/* 편집 모드: 규칙 빌더 */} {/* 카드 선택 — IDE 5-분할 takeover */}
{mode === 'edit' && ( {selectedCard && (
<RuleBuilder canvasRef={canvasRef} /> <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>
)} )}
</> </>
); );
} }
/** /** 호환성 stub — 외부에서 이름으로만 import 하는 경우 */
* wrapper
*/
export function ControlPaletteWrapper() { export function ControlPaletteWrapper() {
const { active, mode, addRuleNode } = useControlMode(); return null;
if (!active || mode !== 'edit') return null;
return (
<ControlPalette
onDropTable={() => {}}
onDropControl={() => {}}
/>
);
} }
+81 -37
View File
@@ -2,6 +2,7 @@
import { useRef, useCallback } from 'react'; import { useRef, useCallback } from 'react';
import { CTRL_NODE_TYPES, useControlMode } from './hooks/useControlMode'; import { CTRL_NODE_TYPES, useControlMode } from './hooks/useControlMode';
import { getNodeIcon } from './schemas';
import { PortHandle } from './PortHandle'; import { PortHandle } from './PortHandle';
interface ControlNodeProps { 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) { export function ControlNode({ node, onDragStart, onDragEnd }: ControlNodeProps) {
const { removeRuleNode, moveRuleNode, setConfigNodeId } = useControlMode(); const { removeRuleNode, moveRuleNode, setConfigNodeId, configNodeId } = useControlMode();
const nodeRef = useRef<HTMLDivElement>(null); const nodeRef = useRef<HTMLDivElement>(null);
const def = CTRL_NODE_TYPES[node.type]; const def = CTRL_NODE_TYPES[node.type];
if (!def) return null; if (!def) return null;
const rgb = def.rgb;
const Ic = getNodeIcon(node.type);
const outPorts = def.out || [{ port: 'out', label: '→', cls: '' }]; 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.preventDefault();
e.stopPropagation(); e.stopPropagation();
const sx = e.clientX, sy = e.clientY; const sx = e.clientX, sy = e.clientY;
const sl = node.x, st = node.y; const sl = node.x, st = node.y;
const el = nodeRef.current; const el = nodeRef.current;
if (el) el.style.zIndex = '30'; if (el) el.style.zIndex = '30';
let moved = false;
const mv = (ev: MouseEvent) => { 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 = () => { const up = () => {
if (el) el.style.zIndex = '20'; if (el) el.style.zIndex = '20';
document.removeEventListener('mousemove', mv); document.removeEventListener('mousemove', mv);
document.removeEventListener('mouseup', up); document.removeEventListener('mouseup', up);
if (!moved) setConfigNodeId(node.id === configNodeId ? null : node.id);
}; };
document.addEventListener('mousemove', mv); document.addEventListener('mousemove', mv);
document.addEventListener('mouseup', up); 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 ( return (
<div <div
ref={nodeRef} ref={nodeRef}
className="ctrl-action-node" className={`v3-rule-node${selected ? ' is-selected' : ''}${dim ? ' is-dim' : ''}`}
data-node-id={node.id} data-node-id={node.id}
data-node-type={node.type} data-node-type={node.type}
onMouseDown={handleNodeMouseDown}
style={{ style={{
left: node.x, left: node.x, top: node.y,
top: node.y, borderColor: `rgba(${rgb}, ${selected ? 0.85 : 0.4})`,
['--na-rgb' as string]: def.rgb, 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 포트 */} {/* cat-color stripe */}
<PortHandle <div className="v3-rule-node-stripe" style={{ background: `rgb(${rgb})` }} />
nodeId={node.id}
port="in"
type="in"
onDragEnd={onDragEnd}
/>
{/* 헤더 */} {/* body */}
<div className="ctrl-an-head" onMouseDown={handleHeadMouseDown}> <div className="v3-rule-node-body">
<div className="ctrl-an-icon">{def.icon}</div> <div className="v3-rule-node-cat">
<span className="ctrl-an-name">{def.label}</span> <div className="v3-rule-node-cat-ico"
<button style={{ background: `rgba(${rgb}, .14)`, color: `rgb(${rgb})` }}>
className="ctrl-an-del" <Ic size={11} />
onClick={(e) => { e.stopPropagation(); removeRuleNode(node.id); }} </div>
> <span className="v3-rule-node-cat-label" style={{ color: `rgb(${rgb})` }}>
{def.label}
</button> </span>
</div> <button
type="button"
{/* 본문 */} className="ctrl-an-del"
<div title="삭제"
className="ctrl-an-body" onClick={(e) => { e.stopPropagation(); removeRuleNode(node.id); }}
onClick={() => setConfigNodeId(node.id)} style={{ marginLeft: 'auto' }}
> >
<div className="ctrl-an-summary">
{node.config?.summary || '클릭하여 설정'} </button>
</div> </div>
<div className="v3-rule-node-label">{node.label ?? def.label}</div>
{summary && <div className="v3-rule-node-summary">{summary}</div>}
</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"> <div className="ctrl-an-ports-out">
{outPorts.map((p) => ( {outPorts.map((p) => (
<PortHandle <PortHandle
@@ -93,7 +138,6 @@ export function ControlNode({ node, onDragStart, onDragEnd }: ControlNodeProps)
port={p.port} port={p.port}
type="out" type="out"
cls={p.cls} cls={p.cls}
label={p.label}
onDragStart={onDragStart} onDragStart={onDragStart}
/> />
))} ))}
+190 -52
View File
@@ -1,7 +1,8 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { CTRL_NODE_TYPES } from './hooks/useControlMode'; import { Search, Star } from 'lucide-react';
import { CTRL_NODE_TYPES, useControlMode } from './hooks/useControlMode';
import { getMetaTableList } from '@/lib/api/meta'; import { getMetaTableList } from '@/lib/api/meta';
interface ControlPaletteProps { 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 [tables, setTables] = useState<Record<string, any>[]>([]);
const [search, setSearch] = useState('');
const mode = useControlMode((s) => s.mode);
const isEditMode = mode === 'edit';
useEffect(() => { useEffect(() => {
getMetaTableList().then(setTables).catch(() => {}); 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>) => { const handleDragStart = (e: React.DragEvent, data: Record<string, any>) => {
e.dataTransfer.setData('text/plain', JSON.stringify(data)); e.dataTransfer.setData('text/plain', JSON.stringify(data));
e.dataTransfer.effectAllowed = 'copy'; e.dataTransfer.effectAllowed = 'copy';
}; };
const catLabels: Record<string, string> = { const catLabels: Record<string, string> = {
'트리거': '트리거', : '트리거',
'조건': '조건 / 분기', : '조건 / 분기',
'액션': '액션', : '액션',
'흐름': '흐름 제어', : '흐름 제어',
'연동': '외부 연동', : '외부 연동',
'기록': '기록', : '기록',
}; };
const cats = ['트리거', '조건', '액션', '흐름', '연동', '기록']; const cats = ['트리거', '조건', '액션', '흐름', '연동', '기록'];
return ( const renderTableItem = (t: Record<string, any>, isRecommended: boolean) => {
<div style={{ overflowY: 'auto', flex: 1 }}> const name = t.table_name ?? t.TABLE_NAME;
{/* DB 테이블 섹션 */} const rawLabel = t.table_label ?? t.TABLE_LABEL;
<div className="ctrl-palette-section">DB </div> const label = rawLabel && rawLabel !== name ? rawLabel : null;
{tables.map((t) => { const icon = pickIcon(String(name));
const name = t.table_name ?? t.TABLE_NAME; return (
const label = t.table_label ?? t.TABLE_LABEL ?? name; <div
return ( key={name}
<div className={`ctrl-palette-item${isRecommended ? ' ctrl-palette-item-rec' : ''}`}
key={name} draggable
className="ctrl-palette-item" title={`${label ?? name}${label ? ` (${name})` : ''} — 캔버스로 드래그`}
draggable onDragStart={(e) => handleDragStart(e, { kind: 'table', name })}
title={`${label} — 캔버스로 드래그`} >
onDragStart={(e) => handleDragStart(e, { kind: 'table', name })} <span className="cp-icon">{icon}</span>
> <span className="cp-label">
<span className="cp-icon">🏢</span> <span className="cp-label-main">{label ?? name}</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{name}</span> {label && <span className="cp-label-sub">{name}</span>}
</div> </span>
); {isRecommended && <Star size={9} className="cp-rec-star" />}
})} </div>
);
};
{/* 제어 노드 — 카테고리별 그룹 */} return (
{cats.map((cat) => { <div className="ctrl-palette">
const items = Object.entries(CTRL_NODE_TYPES).filter(([, d]) => d.cat === cat); {/* 헤더 */}
if (!items.length) return null; <div className="ctrl-palette-header">
return ( <span className="ctrl-palette-header-title"> </span>
<div key={cat}> {!isEditMode && (
<div className="ctrl-palette-section">{catLabels[cat] ?? cat}</div> <span className="ctrl-palette-header-hint"> </span>
{items.map(([type, def]) => ( )}
<div </div>
key={type}
className="ctrl-palette-item" {/* 검색박스 */}
draggable <div className="ctrl-palette-search-wrap">
title={`${def.label} — 캔버스로 드래그`} <Search size={11} className="ctrl-palette-search-icon" />
onDragStart={(e) => handleDragStart(e, { kind: 'control', type })} <input
> type="text"
<span className="cp-icon">{def.icon}</span> className="ctrl-palette-search"
<span>{def.label}</span> placeholder="테이블 / 노드 검색…"
</div> value={search}
))} onChange={(e) => setSearch(e.target.value)}
</div> 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> </div>
); );
} }
+5 -1
View File
@@ -79,6 +79,7 @@ export function FlowViewer({ cards, canvasRef, dashboardId }: FlowViewerProps) {
flowEdges, flowEdges,
tablePositions, tablePositions,
setActiveFlowCard, setActiveFlowCard,
setSelectedCardId,
setFlowEdges, setFlowEdges,
setTablePositions, setTablePositions,
} = useControlMode(); } = useControlMode();
@@ -90,14 +91,17 @@ export function FlowViewer({ cards, canvasRef, dashboardId }: FlowViewerProps) {
const [ruleOverlays, setRuleOverlays] = useState<RuleOverlay[]>([]); const [ruleOverlays, setRuleOverlays] = useState<RuleOverlay[]>([]);
const animRef = useRef<ReturnType<typeof setTimeout>[]>([]); const animRef = useRef<ReturnType<typeof setTimeout>[]>([]);
// 카드 클릭 → 흐름 표시 // 카드 클릭 → 흐름 표시 + 카드 선택 (selectedCardId 동기화)
const handleCardClick = useCallback(async (cardId: string) => { const handleCardClick = useCallback(async (cardId: string) => {
// 같은 카드 클릭 → 닫기 // 같은 카드 클릭 → 닫기
if (activeFlowCardId === cardId) { if (activeFlowCardId === cardId) {
clearFlow(); clearFlow();
setSelectedCardId(null);
return; return;
} }
setSelectedCardId(cardId);
const card = cards.find((c) => (c.card_id ?? c.CARD_ID) === cardId); const card = cards.find((c) => (c.card_id ?? c.CARD_ID) === cardId);
if (!card) return; if (!card) return;
+403 -50
View File
@@ -1,20 +1,37 @@
'use client'; 'use client';
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef, useMemo } from 'react';
import { CTRL_NODE_TYPES, useControlMode } from './hooks/useControlMode'; 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() { export function NodeConfigPopover() {
const { configNodeId, ruleNodes, setConfigNodeId, updateRuleNode } = useControlMode(); const { configNodeId, ruleNodes, ruleConnections, setConfigNodeId, updateRuleNode } = useControlMode();
const popRef = useRef<HTMLDivElement>(null); const popRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const node = configNodeId ? ruleNodes.find((n) => n.id === configNodeId) : null; const node = configNodeId ? ruleNodes.find((n) => n.id === configNodeId) : null;
const def = node ? CTRL_NODE_TYPES[node.type] : 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(() => { useEffect(() => {
if (configNodeId && node) { if (configNodeId && node) {
requestAnimationFrame(() => setOpen(true)); requestAnimationFrame(() => setOpen(true));
@@ -23,12 +40,14 @@ export function NodeConfigPopover() {
} }
}, [configNodeId, node]); }, [configNodeId, node]);
// 외부 클릭 닫기
useEffect(() => { useEffect(() => {
const handler = (e: MouseEvent) => { const handler = (e: MouseEvent) => {
if (!configNodeId) return; if (!configNodeId) return;
if ((e.target as HTMLElement).closest('.ctrl-cfg-pop')) return; const t = e.target as HTMLElement;
if ((e.target as HTMLElement).closest('.ctrl-an-body')) return; 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); setConfigNodeId(null);
}; };
document.addEventListener('click', handler); document.addEventListener('click', handler);
@@ -49,52 +68,291 @@ export function NodeConfigPopover() {
style={{ left: node.x + 172, top: node.y }} style={{ left: node.x + 172, top: node.y }}
> >
<div className="cfg-hd">{def.icon} {def.label} </div> <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> </div>
); );
} }
function ConfigForm({ type, config, onSave, onClose }: { /* ─── Helpers ─── */
type: string; config: Record<string, any>;
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; onSave: (summary: string, config: Record<string, any>) => void;
onClose: () => void; onClose: () => void;
}) { }) {
const [vals, setVals] = useState<Record<string, any>>(config); const [vals, setVals] = useState<Record<string, any>>(config);
const set = (k: string, v: any) => setVals((p) => ({ ...p, [k]: v })); const set = (k: string, v: any) => setVals((p) => ({ ...p, [k]: v }));
const cols = useMemo(() => flattenColumns(connectedTables), [connectedTables]);
const handleSave = () => { const handleSave = () => {
let summary = ''; 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) { switch (type) {
case 'condition': case 'condition':
summary = `${vals.field || '?'} ${vals.op || '='} "${vals.value || '?'}"`; summary = `${fLabel(vals.field)} ${vals.op || '='} "${vals.value || '?'}"`;
break; break;
case 'status-change': case 'status-change':
summary = `${vals.table || '?'}.${vals.field || 'STATUS'} → "${vals.value || '?'}"`; summary = `${tLabel(vals.table)}.${fLabel(vals.field)} → "${vals.value || '?'}"`;
break; break;
case 'auto-insert': case 'auto-insert':
summary = `${vals.table || '?'} INSERT`; summary = `${tLabel(vals.table)} INSERT`;
break; break;
case 'timer': case 'timer':
summary = `${vals.field || '?'} +${vals.amount || 0}${vals.unit || '일'} 경과`; summary = `${fLabel(vals.field)} +${vals.amount || 0}${vals.unit || '일'} 경과`;
break; break;
case 'notification': case 'notification':
summary = `${vals.channel || '이메일'}${vals.target || '담당자'}`; summary = `${vals.channel || '이메일'}${vals.target || '담당자'}`;
break; break;
case 'approval': case 'approval':
summary = `${vals.approver || '팀장'} 승인 (${vals.condition || ''})`; summary = `${vals.approver || '팀장'} 승인${vals.condition ? ` (${vals.condition})` : ''}`;
break; break;
case 'calculation': case 'calculation':
summary = `${vals.table || '?'}.${vals.field || '?'} = ${vals.formula || '?'}`; summary = `${tLabel(vals.table)}.${fLabel(vals.field)} = ${vals.formula || '?'}`;
break; break;
case 'webhook': case 'webhook':
summary = `${vals.method || 'POST'} ${(vals.url || '').slice(0, 25)}...`; summary = `${vals.method || 'POST'} ${(vals.url || '').slice(0, 25)}...`;
break; break;
case 'validation': case 'validation':
summary = `${vals.field || '?'} ${vals.rule || '필수값'}`; summary = `${fLabel(vals.field)} ${vals.rule || '필수값'}`;
break; break;
case 'log': case 'log':
summary = `로그: ${vals.content || '?'}`; summary = `로그: ${vals.content || '?'}`;
break; 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: default:
summary = vals.summary || '설정됨'; summary = vals.summary || '설정됨';
} }
@@ -103,7 +361,7 @@ function ConfigForm({ type, config, onSave, onClose }: {
return ( return (
<> <>
{renderFields(type, vals, set)} {renderFields(type, vals, set, connectedTables)}
<div className="cfg-ft"> <div className="cfg-ft">
<button className="cfg-btn save" onClick={handleSave}></button> <button className="cfg-btn save" onClick={handleSave}></button>
<button className="cfg-btn" onClick={onClose}></button> <button className="cfg-btn" onClick={onClose}></button>
@@ -115,21 +373,25 @@ function ConfigForm({ type, config, onSave, onClose }: {
function renderFields( function renderFields(
type: string, type: string,
vals: Record<string, any>, vals: Record<string, any>,
set: (k: string, v: any) => void set: (k: string, v: any) => void,
tables: Record<string, any>[],
) { ) {
switch (type) { switch (type) {
/* ─── Phase 2 schema-driven 4종 ─── */
case 'condition': case 'condition':
return ( return (
<> <>
<CfgSec label="필드"> <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>
<CfgSec label="연산자"> <CfgSec label="연산자">
<CfgSelect value={vals.op ?? '='} onChange={(v) => set('op', v)} <CfgSelect value={vals.op ?? '='} onChange={(v) => set('op', v)}
options={['=', '≠', '>', '<', '기한 경과', '포함']} /> options={['=', '≠', '>', '<', '≥', '≤', '포함', '기한 경과']} />
</CfgSec> </CfgSec>
<CfgSec label="값"> <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> </CfgSec>
</> </>
); );
@@ -137,27 +399,61 @@ function renderFields(
return ( return (
<> <>
<CfgSec label="대상 테이블"> <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>
<CfgSec label="변경 필드"> <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>
<CfgSec label="변경값"> <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> </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': case 'auto-insert':
return ( return (
<CfgSec label="대상 테이블"> <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>
); );
case 'timer': case 'timer':
return ( return (
<> <>
<CfgSec label="기준 필드"> <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>
<CfgSec label="경과 기준"> <CfgSec label="경과 기준">
<div style={{ display: 'flex', gap: '.3rem' }}> <div style={{ display: 'flex', gap: '.3rem' }}>
@@ -196,20 +492,6 @@ function renderFields(
</CfgSec> </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': case 'webhook':
return ( return (
<> <>
@@ -222,22 +504,91 @@ function renderFields(
</CfgSec> </CfgSec>
</> </>
); );
case 'validation': case 'log':
return ( return (
<> <>
<CfgSec label="대상 필드"> <CfgSec label="로그 레벨">
<CfgInput value={vals.field ?? ''} onChange={(v) => set('field', v)} placeholder="필드명" /> <CfgSelect value={vals.level ?? 'info'} onChange={(v) => set('level', v)}
options={['info', 'warn', 'error', 'debug']} />
</CfgSec> </CfgSec>
<CfgSec label="검증 규칙"> <CfgSec label="내용">
<CfgSelect value={vals.rule ?? '필수값 (NOT NULL)'} onChange={(v) => set('rule', v)} <CfgInput value={vals.content ?? ''} onChange={(v) => set('content', v)} placeholder="액션 설명" />
options={['필수값 (NOT NULL)', '범위 체크', '정규식 매칭', '참조 무결성', '커스텀 조건']} />
</CfgSec> </CfgSec>
</> </>
); );
case 'log': case 'delete':
return ( 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> </CfgSec>
); );
default: default:
@@ -245,6 +596,8 @@ function renderFields(
} }
} }
/* ─── 공통 atoms ─── */
function CfgSec({ label, children }: { label: string; children: React.ReactNode }) { function CfgSec({ label, children }: { label: string; children: React.ReactNode }) {
return ( return (
<div className="cfg-sec"> <div className="cfg-sec">
+4 -2
View File
@@ -17,15 +17,17 @@ interface PortHandleProps {
} }
export function PortHandle({ nodeId, port, type, cls, label, isTable, onDragStart, onDragEnd }: PortHandleProps) { export function PortHandle({ nodeId, port, type, cls, label, isTable, onDragStart, onDragEnd }: PortHandleProps) {
// 단일 동그라미가 mousedown(연결 시작) + mouseup(연결 종료) 둘 다 받음
// (테이블 컬럼 port 처럼 시각적으로 하나만 보이는 경우)
const handleMouseDown = (e: React.MouseEvent) => { const handleMouseDown = (e: React.MouseEvent) => {
if (type !== 'out' || !onDragStart) return; if (!onDragStart) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
onDragStart(nodeId, port, e); onDragStart(nodeId, port, e);
}; };
const handleMouseUp = (e: React.MouseEvent) => { const handleMouseUp = (e: React.MouseEvent) => {
if (type !== 'in' || !onDragEnd) return; if (!onDragEnd) return;
e.stopPropagation(); e.stopPropagation();
onDragEnd(nodeId, port); onDragEnd(nodeId, port);
}; };
+81 -34
View File
@@ -56,7 +56,7 @@ export function RuleBuilder({ canvasRef }: RuleBuilderProps) {
} else { } else {
try { try {
const meta = await getMetaFields(d.name); 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; fieldCache[d.name] = cols;
} catch { /* 빈 필드 */ } } 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); const node = ruleNodes.find((n) => n.id === nodeId);
if (!node) return null; if (!node) return null;
if (node.type === 'table') { if (node.type === 'table') {
if (port === 'in') return { x: node.x, y: node.y + 18 }; // 테이블 단위 단일 port — 카드 좌측(in) / 우측(out) 중앙
return { x: node.x + 200, y: node.y + 18 }; // (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]); }, [ruleNodes]);
return ( return (
<> <div
{/* 드롭존 (캔버스 전체에 이벤트 걸기 위한 투명 레이어) */} className="rule-builder-canvas"
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'auto' }}
style={{ position: 'absolute', inset: 0, zIndex: 5 }} onDrop={handleDrop}
onDrop={handleDrop} onDragOver={handleDragOver}
onDragOver={handleDragOver} >
/>
{/* 연결선 SVG */} {/* 연결선 SVG */}
<svg className="ctrl-svg" id="rule-svg" width="100%" height="100%" style={{ overflow: 'visible' }}> <svg className="ctrl-svg" id="rule-svg" width="100%" height="100%" style={{ overflow: 'visible' }}>
<defs> <defs>
@@ -137,32 +142,76 @@ export function RuleBuilder({ canvasRef }: RuleBuilderProps) {
</defs> </defs>
{ruleConnections.map((c) => { {ruleConnections.map((c) => {
const f = portPos(c.from_node_id, c.from_port); const f = portPos(c.from_node_id, c.from_port, 'from');
const t = portPos(c.to_node_id, c.to_port); const t = portPos(c.to_node_id, c.to_port, 'to');
if (!f || !t) return null; if (!f || !t) return null;
const cls = c.from_port === 'yes' ? 'rule-conn-path conn-yes' // Phase 3: edge_type 별 stroke 분기 (yes/no 우선, 그 다음 edge_type)
: c.from_port === 'no' ? 'rule-conn-path conn-no' const portCls = c.from_port === 'yes' ? 'conn-yes'
: 'rule-conn-path'; : c.from_port === 'no' ? 'conn-no' : '';
const marker = c.from_port === 'yes' ? 'url(#arr-yes)' const edgeCls = c.edge_type ? `edge-${c.edge_type}` : '';
: c.from_port === 'no' ? 'url(#arr-no)' const cls = ['rule-conn-path', portCls, edgeCls].filter(Boolean).join(' ');
: 'url(#arr-rule)';
// 선 중간 라벨 — 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 ( return (
<path <g key={c.id}>
key={c.id} <path d={bezierPath(f.x, f.y, t.x, t.y)} className={cls} />
d={bezierPath(f.x, f.y, t.x, t.y)} {portLabel && (
className={cls} <g transform={`translate(${mx}, ${my - 11})`}>
markerEnd={marker} <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> </svg>
{/* 연결 삭제 뱃지 */} {/* 연결 삭제 뱃지 */}
{ruleConnections.map((c) => { {ruleConnections.map((c) => {
const f = portPos(c.from_node_id, c.from_port); const f = portPos(c.from_node_id, c.from_port, 'from');
const t = portPos(c.to_node_id, c.to_port); const t = portPos(c.to_node_id, c.to_port, 'to');
if (!f || !t) return null; if (!f || !t) return null;
const mx = (f.x + t.x) / 2, my = (f.y + t.y) / 2; 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} y={node.y}
onMove={(_, x, y) => moveRuleNode(node.id, x, y)} onMove={(_, x, y) => moveRuleNode(node.id, x, y)}
style={{ overflow: 'visible' }} 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> </div>
); );
} }
@@ -221,6 +268,6 @@ export function RuleBuilder({ canvasRef }: RuleBuilderProps) {
{/* 설정 팝오버 */} {/* 설정 팝오버 */}
<NodeConfigPopover /> <NodeConfigPopover />
</> </div>
); );
} }
+82 -30
View File
@@ -1,31 +1,56 @@
'use client'; 'use client';
import { useRef, useCallback } from 'react'; import { useRef, useCallback } from 'react';
import { Database, X } from 'lucide-react';
import { PortHandle } from './PortHandle';
import { useControlMode } from './hooks/useControlMode';
interface TableNodeProps { interface TableNodeProps {
tableName: string; tableName: string;
label: string; label: string;
icon: string; /** 호환용 — 더 이상 사용 X (V3 컴팩트로 갈아엎으면서 이모지 폐기, Lucide Database 아이콘 고정) */
icon?: string;
columns: Record<string, any>[]; columns: Record<string, any>[];
x: number; x: number;
y: number; y: number;
style?: React.CSSProperties; style?: React.CSSProperties;
onMove?: (name: string, x: number, y: number) => void; 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 nodeRef = useRef<HTMLDivElement>(null);
const removeRuleNode = useControlMode((s) => s.removeRuleNode);
const handleMouseDown = useCallback((e: React.MouseEvent) => { const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (!onMove) return; if (!onMove) return;
const target = e.target as HTMLElement;
if (target.closest('.ctrl-io-port, button')) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation();
const sx = e.clientX, sy = e.clientY; const sx = e.clientX, sy = e.clientY;
const sl = x, st = y; const sl = x, st = y;
const el = nodeRef.current; const el = nodeRef.current;
if (el) el.style.zIndex = '30'; if (el) el.style.zIndex = '30';
let moved = false;
const move = (ev: MouseEvent) => { 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 = () => { const up = () => {
if (el) el.style.zIndex = '20'; 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); document.addEventListener('mouseup', up);
}, [onMove, tableName, x, y]); }, [onMove, tableName, x, y]);
const dtypeIcons: Record<string, string> = { // stats
text: 'Aa', number: '#', date: '📅', select: '▼', checkbox: '☑', file: '📎', code: '⚡', const totalCols = columns?.length ?? 0;
textarea: 'Aa', datetime: '📅', entity: '🔗', 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 ( return (
<div <div
ref={nodeRef} ref={nodeRef}
className="tbl-node" className="tbl-node tbl-node-compact"
data-table={tableName} data-table={tableName}
data-node-id={nodeId}
onMouseDown={handleMouseDown}
style={{ left: x, top: y, ...style }} style={{ left: x, top: y, ...style }}
> >
<div className="tbl-node-head" onMouseDown={handleMouseDown}> {/* cyan top stripe (V3RuleNode cat-stripe 와 일관) */}
<div className="tbl-icon">{icon}</div> <div className="tbl-node-stripe" />
<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';
return ( <div className="tbl-node-head">
<div key={name} className="tbl-col" data-col={name}> <div className="tbl-node-ico"><Database size={11} /></div>
<div className={`tbl-port ${portCls}`} /> <div className="tbl-node-title">
<span className="tbl-col-name">{displayName}</span> <div className="tbl-node-label">{hasKoLabel ? label : tableName}</div>
<span className="tbl-col-type">{dtIcon} {type}</span> {hasKoLabel && <div className="tbl-node-sub">{tableName}</div>}
{mark && <span className={`tbl-col-mark ${mark.toLowerCase()}`}>{mark}</span>} </div>
</div> {nodeId && (
); <button
})} type="button"
className="tbl-node-del"
title="삭제"
onClick={(e) => { e.stopPropagation(); removeRuleNode(nodeId); }}
>
<X size={10} />
</button>
)}
</div> </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> </div>
); );
} }
@@ -34,9 +34,11 @@ export const CTRL_NODE_TYPES: Record<string, {
interface ControlModeState { interface ControlModeState {
/** 제어 모드 활성 여부 */ /** 제어 모드 활성 여부 */
active: boolean; active: boolean;
/** 읽기 / 편집 모드 */ /** 읽기 / 편집 / 실행 / 이력 모드 (선택된 카드 컨텍스트 안의 토글, v3 — IDE 4-segmented tabs) */
mode: 'view' | 'edit'; mode: 'view' | 'edit' | 'run' | 'history';
/** 활성 흐름 — 클릭된 카드 ID */ /** 선택된 카드 ID — 카드 클릭 시 좌측 축소 + 그 옆에 제어 패널 */
selectedCardId: string | null;
/** 활성 흐름 — FlowViewer 내부 상태 (selectedCardId 와 동기화) */
activeFlowCardId: string | null; activeFlowCardId: string | null;
/** 흐름 엣지 배열 (BFS 결과) */ /** 흐름 엣지 배열 (BFS 결과) */
flowEdges: Record<string, any>[]; flowEdges: Record<string, any>[];
@@ -55,7 +57,8 @@ interface ControlModeState {
// 액션 // 액션
toggleControlMode: () => void; toggleControlMode: () => void;
setMode: (mode: 'view' | 'edit') => void; setMode: (mode: 'view' | 'edit' | 'run' | 'history') => void;
setSelectedCardId: (cardId: string | null) => void;
setActiveFlowCard: (cardId: string | null) => void; setActiveFlowCard: (cardId: string | null) => void;
setFlowEdges: (edges: Record<string, any>[]) => void; setFlowEdges: (edges: Record<string, any>[]) => void;
setTablePositions: (pos: Record<string, { x: number; y: number }>) => void; setTablePositions: (pos: Record<string, { x: number; y: number }>) => void;
@@ -82,6 +85,7 @@ export const useControlMode = create<ControlModeState>()(
(set) => ({ (set) => ({
active: false, active: false,
mode: 'view', mode: 'view',
selectedCardId: null,
activeFlowCardId: null, activeFlowCardId: null,
flowEdges: [], flowEdges: [],
tablePositions: {}, tablePositions: {},
@@ -94,14 +98,29 @@ export const useControlMode = create<ControlModeState>()(
set((s) => ({ set((s) => ({
active: !s.active, active: !s.active,
mode: 'view', mode: 'view',
selectedCardId: null,
activeFlowCardId: null, activeFlowCardId: null,
flowEdges: [], flowEdges: [],
tablePositions: {}, tablePositions: {},
ruleNodes: [],
ruleConnections: [],
configNodeId: null, configNodeId: null,
})), })),
setMode: (mode) => set({ mode, 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 }), setActiveFlowCard: (cardId) => set({ activeFlowCardId: cardId }),
setFlowEdges: (edges) => set({ flowEdges: edges }), setFlowEdges: (edges) => set({ flowEdges: edges }),
@@ -152,6 +171,7 @@ export const useControlMode = create<ControlModeState>()(
set({ set({
active: false, active: false,
mode: 'view', mode: 'view',
selectedCardId: null,
activeFlowCardId: null, activeFlowCardId: null,
flowEdges: [], flowEdges: [],
tablePositions: {}, tablePositions: {},
@@ -59,24 +59,58 @@ export function usePortDrag(canvasRef: React.RefObject<HTMLDivElement | null>) {
cleanup(); cleanup();
return; return;
} }
// 중복 방지 // ★ [HIGH] port direction validation — output → output 역방향 엣지 차단
if (ruleConnections.find((c) => // 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 c.from_node_id === d.fromNodeId && c.from_port === d.fromPort && c.to_node_id === toNodeId
)) { )) {
cleanup(); cleanup();
return; 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({ addRuleConnection({
id: genConnId(), id: genConnId(),
from_node_id: d.fromNodeId, from_node_id: d.fromNodeId,
from_port: d.fromPort, from_port: d.fromPort,
to_node_id: toNodeId, to_node_id: toNodeId,
to_port: toPort, to_port: toPort,
edge_type: edgeType,
}); });
cleanup(); cleanup();
}, [addRuleConnection, ruleConnections]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [addRuleConnection]);
const cleanup = useCallback(() => { const cleanup = useCallback(() => {
const d = dragRef.current; const d = dragRef.current;
@@ -89,6 +123,8 @@ export function usePortDrag(canvasRef: React.RefObject<HTMLDivElement | null>) {
}, [canvasRef]); }, [canvasRef]);
// 마우스 이동/종료 전역 핸들러 // 마우스 이동/종료 전역 핸들러
// ★ mouseup 시 e.target 의 closest .ctrl-io-port 를 직접 찾아서 finishDrag 호출
// (PortHandle 의 onMouseUp 에 의존하면 race + 6px hit-target 문제로 연결 실패)
useEffect(() => { useEffect(() => {
const onMove = (e: MouseEvent) => { const onMove = (e: MouseEvent) => {
const d = dragRef.current; 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 x2 = e.clientX - cr.left + cv.scrollLeft;
const y2 = e.clientY - cr.top + cv.scrollTop; const y2 = e.clientY - cr.top + cv.scrollTop;
d.line.setAttribute('d', bezierPath(d.x1, d.y1, x2, y2)); 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 = () => { const onUp = (e: MouseEvent) => {
if (dragRef.current) cleanup(); 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); document.addEventListener('mousemove', onMove);
@@ -111,7 +180,7 @@ export function usePortDrag(canvasRef: React.RefObject<HTMLDivElement | null>) {
document.removeEventListener('mousemove', onMove); document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp); document.removeEventListener('mouseup', onUp);
}; };
}, [canvasRef, cleanup]); }, [canvasRef, cleanup, finishDrag]);
return { startDrag, finishDrag }; return { startDrag, finishDrag };
} }
+396
View File
@@ -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>
);
}
+156
View File
@@ -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;
}
+5 -1
View File
@@ -13,6 +13,7 @@
import React from 'react'; import React from 'react';
import type { BlockV2, CanvasV2 } from '@/types/invyone-component'; import type { BlockV2, CanvasV2 } from '@/types/invyone-component';
import { ComponentRegistry } from '@/lib/registry/ComponentRegistry'; import { ComponentRegistry } from '@/lib/registry/ComponentRegistry';
import { isTableLikeComponentType } from '@/lib/utils/componentTypeUtils';
// side-effect: 컴포넌트 레지스트리 등록 // side-effect: 컴포넌트 레지스트리 등록
import '@/lib/registry/components'; import '@/lib/registry/components';
import type { TemplateRenderContext, ViewKey } from './TemplateRenderer'; import type { TemplateRenderContext, ViewKey } from './TemplateRenderer';
@@ -68,7 +69,10 @@ export function BlockRenderer({
context.onFormRowChange?.(fieldNameOrPatch); 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) { if (!def?.component) {
return ( 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"> <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">
+5 -9
View File
@@ -15,7 +15,7 @@ import { DashboardCanvas } from './DashboardCanvas';
import { TemplateLibraryModal } from './TemplateLibraryModal'; import { TemplateLibraryModal } from './TemplateLibraryModal';
import { CardSettingsPanel } from './CardSettingsPanel'; import { CardSettingsPanel } from './CardSettingsPanel';
import { ControlMode } from '@/components/control/ControlMode'; import { ControlMode } from '@/components/control/ControlMode';
import { ControlPalette } from '@/components/control/ControlPalette'; // ControlPalette 는 ControlMode 의 IDE LeftRail 안에서만 사용됨 (외부 사이드바 교체 폐기)
import { useControlMode } from '@/components/control/hooks/useControlMode'; import { useControlMode } from '@/components/control/hooks/useControlMode';
import { useMenu } from '@/contexts/MenuContext'; import { useMenu } from '@/contexts/MenuContext';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -45,7 +45,7 @@ export function DashboardLayout({ dashboardId: singleDashboardId }: DashboardLay
} = useDashboardStore(); } = useDashboardStore();
const controlActive = useControlMode((s) => s.active); const controlActive = useControlMode((s) => s.active);
const controlMode = useControlMode((s) => s.mode); // controlMode 는 ControlMode 내부에서만 참조 (외부 사이드바 분기 폐기)
const { refreshMenus } = useMenu(); const { refreshMenus } = useMenu();
const isSingleMode = !!singleDashboardId; const isSingleMode = !!singleDashboardId;
@@ -243,13 +243,8 @@ export function DashboardLayout({ dashboardId: singleDashboardId }: DashboardLay
return ( return (
<div className="dash-shell"> <div className="dash-shell">
{/* AppLayout . {/* AppLayout .
. */} takeover ControlMode IDE LeftRail (v3 V3Takeover) X */}
{controlActive && controlMode === 'edit' ? ( {!isSingleMode && !controlActive ? (
<div className="dash-side">
<div className="dash-side-sec" style={{ color: 'var(--ctrl-cyan)' }}> </div>
<ControlPalette onDropTable={() => {}} onDropControl={() => {}} />
</div>
) : !isSingleMode ? (
<DashboardSidebar <DashboardSidebar
onAddDashboard={handleAddDashboard} onAddDashboard={handleAddDashboard}
onRenameDashboard={handleRenameDashboard} onRenameDashboard={handleRenameDashboard}
@@ -257,6 +252,7 @@ export function DashboardLayout({ dashboardId: singleDashboardId }: DashboardLay
onSwitchDashboard={handleSwitchDashboard} onSwitchDashboard={handleSwitchDashboard}
/> />
) : null} ) : null}
{/* 제어 모드 ON 이지만 카드 미선택 상태에서는 사이드바 자체를 숨김 (IDE 가 화면 takeover 할 자리 확보) */}
<div className="dash-content"> <div className="dash-content">
{activeDashboardId ? ( {activeDashboardId ? (
<> <>
@@ -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; 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; const TabsWidget = require("@/components/screen/widgets/TabsWidget").TabsWidget;
// componentConfig에서 탭 정보 추출 // componentConfig에서 탭 정보 추출
+37 -11
View File
@@ -3,6 +3,7 @@
import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { useState, useCallback, useEffect, useMemo, useRef } from "react";
import { Database, Cog, Monitor, Tablet, Smartphone, ChevronDown, Eye, EyeOff, Zap, Grid3X3, Settings } from "lucide-react"; import { Database, Cog, Monitor, Tablet, Smartphone, ChevronDown, Eye, EyeOff, Zap, Grid3X3, Settings } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
// 좌측 패널의 수평 탭 → 수직 <details> 아코디언으로 전환 (2026-04-11) // 좌측 패널의 수평 탭 → 수직 <details> 아코디언으로 전환 (2026-04-11)
// shadcn Tabs 사용 없음. 필요 시 아래 import 재활성화. // shadcn Tabs 사용 없음. 필요 시 아래 import 재활성화.
// import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; // import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -3660,8 +3661,11 @@ export default function InvyoneStudio({
for (const comp of layout.components) { for (const comp of layout.components) {
const compType = (comp as any)?.componentType || (comp as any)?.overrides?.type; const compType = (comp as any)?.componentType || (comp as any)?.overrides?.type;
const compConfig = (comp as any)?.componentConfig || (comp as any)?.overrides || {}; 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 || []; const tabs = compConfig.tabs || [];
for (const tab of tabs) { for (const tab of tabs) {
const found = (tab.components || []).find((c: any) => c.id === containerId); const found = (tab.components || []).find((c: any) => c.id === containerId);
@@ -3680,7 +3684,10 @@ export default function InvyoneStudio({
const panelComps = compConfig[side]?.components || []; const panelComps = compConfig[side]?.components || [];
for (const pc of panelComps) { for (const pc of panelComps) {
const pct = pc.componentType || pc.overrides?.type; 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 || []; const tabs = (pc.componentConfig || pc.overrides || {}).tabs || [];
for (const tab of tabs) { for (const tab of tabs) {
const found = (tab.components || []).find((c: any) => c.id === containerId); const found = (tab.components || []).find((c: any) => c.id === containerId);
@@ -4060,14 +4067,14 @@ export default function InvyoneStudio({
// 컴포넌트별 gridColumns 설정 및 크기 계산 // 컴포넌트별 gridColumns 설정 및 크기 계산
let componentSize = component.defaultSize; let componentSize = component.defaultSize;
const isTableList = component.id === "table-list"; const isTableLike = isTableLikeComponentType(component.id);
// 컴포넌트 타입별 기본 그리드 컬럼 수 설정 // 컴포넌트 타입별 기본 그리드 컬럼 수 설정
const currentGridColumns = layout.gridSettings.columns; // 현재 격자 컬럼 수 const currentGridColumns = layout.gridSettings.columns; // 현재 격자 컬럼 수
let gridColumns = 1; // 기본값 let gridColumns = 1; // 기본값
// 특수 컴포넌트 // 특수 컴포넌트
if (isTableList) { if (isTableLike) {
gridColumns = currentGridColumns; // 테이블은 전체 너비 gridColumns = currentGridColumns; // 테이블은 전체 너비
} else { } else {
// 웹타입별 적절한 그리드 컬럼 수 설정 // 웹타입별 적절한 그리드 컬럼 수 설정
@@ -4095,7 +4102,11 @@ export default function InvyoneStudio({
"divider-basic": 1, // 구분선 (100%) "divider-basic": 1, // 구분선 (100%)
"divider-line": 1, // 구분선 (100%) "divider-line": 1, // 구분선 (100%)
"accordion-basic": 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%) "image-display": 4 / 12, // 이미지 표시 (33%)
"split-panel-layout": 6 / 12, // 분할 패널 레이아웃 (50%) "split-panel-layout": 6 / 12, // 분할 패널 레이아웃 (50%)
"flow-widget": 1, // 플로우 위젯 (100%) "flow-widget": 1, // 플로우 위젯 (100%)
@@ -4398,8 +4409,11 @@ export default function InvyoneStudio({
for (const comp of layout.components) { for (const comp of layout.components) {
const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type; const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type;
const compConfig = (comp as any)?.componentConfig || (comp as any)?.overrides || {}; 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 || []; const tabs = compConfig.tabs || [];
for (const tab of tabs) { for (const tab of tabs) {
const found = (tab.components || []).find((c: any) => c.id === containerId); const found = (tab.components || []).find((c: any) => c.id === containerId);
@@ -4418,7 +4432,10 @@ export default function InvyoneStudio({
const panelComps = compConfig[side]?.components || []; const panelComps = compConfig[side]?.components || [];
for (const pc of panelComps) { for (const pc of panelComps) {
const pct = pc.componentType || pc.overrides?.type; 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 || []; const tabs = (pc.componentConfig || pc.overrides || {}).tabs || [];
for (const tab of tabs) { for (const tab of tabs) {
const found = (tab.components || []).find((c: any) => c.id === containerId); const found = (tab.components || []).find((c: any) => c.id === containerId);
@@ -4618,7 +4635,11 @@ export default function InvyoneStudio({
} }
const compType = (targetComponent as any)?.componentType; 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 currentConfig = (targetComponent as any).componentConfig || {};
const tabs = currentConfig.tabs || []; const tabs = currentConfig.tabs || [];
@@ -5508,11 +5529,16 @@ export default function InvyoneStudio({
} }
const compType = (targetComponent as any)?.componentType; 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 ( if (
targetComponent && targetComponent &&
(compType === "tabs-widget" || compType === "v2-tabs-widget") && (compType === "tabs-widget" || compType === "v2-tabs-widget" || isCanonicalTabsForSelfDrop) &&
dragState.draggedComponent !== containerId dragState.draggedComponent !== containerId
) { ) {
const draggedComponent = layout.components.find((c) => c.id === dragState.draggedComponent); const draggedComponent = layout.components.find((c) => c.id === dragState.draggedComponent);
@@ -7532,7 +7558,7 @@ export default function InvyoneStudio({
for (const comp of components) { for (const comp of components) {
const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type; const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type;
const cfg = (comp as any)?.componentConfig || (comp as any)?.overrides || {}; 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 || [])) { for (const tab of (cfg.tabs || [])) {
const nested = (tab.components || []).find((c: any) => c.id === splitPanelId); const nested = (tab.components || []).find((c: any) => c.id === splitPanelId);
if (nested) return { found: nested, path: "nested", parentTabId: comp.id, parentTabTabId: tab.id }; 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) { for (const comp of prevLayout.components) {
const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type; const ct = (comp as any)?.componentType || (comp as any)?.overrides?.type;
const cfg = (comp as any)?.componentConfig || (comp as any)?.overrides || {}; 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 || [])) { for (const tab of (cfg.tabs || [])) {
const nested = (tab.components || []).find((c: any) => c.id === splitPanelId); 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 }; 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 === "tabs" ||
(type === "component" && (type === "component" &&
((component as any).componentType === "tabs-widget" || (((component as any).componentType ?? (component as any).component_type) === "tabs-widget" ||
(component as any).componentId === "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("🎯 탭 컴포넌트 조건 충족:", { console.log("🎯 탭 컴포넌트 조건 충족:", {
type, type,
@@ -24,6 +24,7 @@ import {
subscribeDom as canvasSplitSubscribeDom, subscribeDom as canvasSplitSubscribeDom,
} from "@/lib/registry/components/v2-split-line/canvasSplitStore"; } from "@/lib/registry/components/v2-split-line/canvasSplitStore";
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext"; import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
import { isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
// 컴포넌트 렌더러들 자동 등록 // 컴포넌트 렌더러들 자동 등록
import "@/lib/registry/components"; import "@/lib/registry/components";
@@ -360,32 +361,44 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
return `${actualHeight}px`; 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) { if (!isDesignMode) {
const compType = (component as any).componentType || component.componentConfig?.type || "";
// 레이아웃 계열: 부모 래퍼를 꽉 채움 (ResponsiveGridRenderer가 % 높이 관리) // 레이아웃 계열: 부모 래퍼를 꽉 채움 (ResponsiveGridRenderer가 % 높이 관리)
const fillParentTypes = [ // ★ table 계열 (canonical 'table' / legacy 'table-list' / hidden 'v2-table-list' /
"table-list", "v2-table-list", // 'data-table' / 'datatable') 은 helper 로 통일. 그 외 layout/split/tabs 는 명시 목록.
const fillParentExtraTypes = [
"container",
"grouped-table", "card-list",
"split-panel-layout", "split-panel-layout2", "split-panel-layout", "split-panel-layout2",
"v2-split-panel-layout", "screen-split-panel", "v2-split-panel-layout", "screen-split-panel",
"v2-tab-container", "tab-container", "v2-tab-container", "tab-container",
"tabs-widget", "v2-tabs-widget", "tabs-widget", "v2-tabs-widget",
]; ];
if (fillParentTypes.some(t => compType === t)) { if (isTableLikeComponentType(sizingType) || fillParentExtraTypes.includes(sizingType)) {
return "100%"; return "100%";
} }
const autoHeightTypes = [ const autoHeightTypes = [
"table-search-widget", "v2-table-search-widget", "table-search-widget", "v2-table-search-widget",
"flow-widget", "flow-widget",
]; ];
if (autoHeightTypes.some(t => compType === t || compType.includes(t))) { if (autoHeightTypes.some(t => sizingType === t || sizingType.includes(t))) {
return "auto"; return "auto";
} }
} }
// 1순위: size.height가 있으면 우선 사용 // 1순위: size.height가 있으면 우선 사용
// (canonical 'table' / legacy 'table-list' / hidden 'v2-table-list' 모두 최소 200px 보장)
if (size?.height && size.height > 0) { if (size?.height && size.height > 0) {
if (component.componentConfig?.type === "table-list") { if (isTableLikeComponentType(sizingType)) {
return `${Math.max(size.height, 200)}px`; return `${Math.max(size.height, 200)}px`;
} }
return `${size.height}px`; return `${size.height}px`;
@@ -396,8 +409,8 @@ const RealtimePreviewDynamicComponent: React.FC<RealtimePreviewProps> = ({
return typeof componentStyle.height === "number" ? `${componentStyle.height}px` : componentStyle.height; return typeof componentStyle.height === "number" ? `${componentStyle.height}px` : componentStyle.height;
} }
// 3순위: 기본값 // 3순위: 기본값 (table-like 는 200px 최소 보장)
if (component.componentConfig?.type === "table-list") { if (isTableLikeComponentType(sizingType)) {
return "200px"; return "200px";
} }
+6 -3
View File
@@ -13,6 +13,7 @@ import {
Link2, Link2,
} from "lucide-react"; } from "lucide-react";
import { ScreenLayoutSummary } from "@/lib/api/screenGroup"; import { ScreenLayoutSummary } from "@/lib/api/screenGroup";
import { isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
// 글로우 펄스 애니메이션 CSS 주입 // 글로우 펄스 애니메이션 CSS 주입
if (typeof document !== "undefined") { 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) => { const getComponentColor = (componentKind: string) => {
// 테이블/그리드 관련 // 테이블/그리드 관련 (canonical table / legacy table-list / hidden v2-table-list 등)
if (componentKind === "table-list" || componentKind === "data-grid") { if (isTableLikeComponentType(componentKind) || TABLE_LIKE_EXTRA_KINDS.includes(componentKind)) {
return "bg-primary/20 border-primary/40"; return "bg-primary/20 border-primary/40";
} }
// 검색 필터 // 검색 필터
@@ -28,6 +28,17 @@ import { apiClient } from "@/lib/api/client";
import { QuickInsertConfigSection } from "../QuickInsertConfigSection"; import { QuickInsertConfigSection } from "../QuickInsertConfigSection";
import { getApprovalDefinitions, type ApprovalDefinition } from "@/lib/api/approval"; import { getApprovalDefinitions, type ApprovalDefinition } from "@/lib/api/approval";
import type { ButtonTabProps, TitleBlock, ScreenOption } from "./types"; 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> = ({ export const ActionTab: React.FC<ButtonTabProps> = ({
@@ -344,7 +355,7 @@ export const ActionTab: React.FC<ButtonTabProps> = ({
// 1. 소스 테이블 감지 (현재 화면) // 1. 소스 테이블 감지 (현재 화면)
let sourceTableName: string | null = currentTableName || null; let sourceTableName: string | null = currentTableName || null;
// allComponents에서 분할패널/테이블리스트/통합목록 감지 // allComponents에서 분할패널/테이블리스트(canonical+legacy+v2)/통합목록 감지
for (const comp of allComponents) { for (const comp of allComponents) {
const compType = comp.component_type || (comp as any).component_config?.type; const compType = comp.component_type || (comp as any).component_config?.type;
const compConfig = (comp as any).component_config || {}; 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; sourceTableName = compConfig.leftPanel?.table_name || compConfig.table_name || null;
if (sourceTableName) break; if (sourceTableName) break;
} }
if (compType === "table-list") { if (isTableLikeComponent(comp)) {
sourceTableName = compConfig.table_name || compConfig.selectedTable || null; sourceTableName = getTableNameFromTableLikeComponent(comp) || null;
if (sourceTableName) break; if (sourceTableName) break;
} }
if (compType === "v2-list") { if (compType === "v2-list") {
@@ -518,11 +529,11 @@ export const ActionTab: React.FC<ButtonTabProps> = ({
} }
} }
// 테이블 리스트 타입 // 테이블 계열 (canonical table / legacy table-list / hidden v2-table-list 모두)
if (compType === "table-list") { if (isTableLikeComponent(comp)) {
sourceTableName = compConfig?.table_name; sourceTableName = getTableNameFromTableLikeComponent(comp) ?? compConfig?.table_name;
if (sourceTableName) { if (sourceTableName) {
console.log(`✅ [openModalWithData] table-list에서 소스 테이블 감지: ${sourceTableName}`); console.log(`✅ [openModalWithData] table-like 컴포넌트에서 소스 테이블 감지: ${sourceTableName}`);
break; break;
} }
} }
@@ -2892,9 +2903,7 @@ export const ActionTab: React.FC<ButtonTabProps> = ({
{allComponents {allComponents
.filter((comp: any) => { .filter((comp: any) => {
const type = comp.componentType || comp.type || ""; const type = comp.componentType || comp.type || "";
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) => return isDataTransferComponentType(type);
type.includes(t),
);
}) })
.map((comp: any) => { .map((comp: any) => {
const compType = comp.componentType || comp.type || "unknown"; const compType = comp.componentType || comp.type || "unknown";
@@ -2916,9 +2925,7 @@ export const ActionTab: React.FC<ButtonTabProps> = ({
})} })}
{allComponents.filter((comp: any) => { {allComponents.filter((comp: any) => {
const type = comp.componentType || comp.type || ""; const type = comp.componentType || comp.type || "";
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) => return isDataTransferComponentType(type);
type.includes(t),
);
}).length === 0 && ( }).length === 0 && (
<SelectItem value="__none__" disabled> <SelectItem value="__none__" disabled>
@@ -2989,9 +2996,7 @@ export const ActionTab: React.FC<ButtonTabProps> = ({
{allComponents {allComponents
.filter((comp: any) => { .filter((comp: any) => {
const type = comp.componentType || comp.type || ""; const type = comp.componentType || comp.type || "";
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some( const isReceivable = isDataTransferComponentType(type);
(t) => type.includes(t),
);
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId; return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
}) })
.map((comp: any) => { .map((comp: any) => {
@@ -3014,9 +3019,7 @@ export const ActionTab: React.FC<ButtonTabProps> = ({
})} })}
{allComponents.filter((comp: any) => { {allComponents.filter((comp: any) => {
const type = comp.componentType || comp.type || ""; const type = comp.componentType || comp.type || "";
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) => const isReceivable = isDataTransferComponentType(type);
type.includes(t),
);
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId; return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
}).length === 0 && ( }).length === 0 && (
<SelectItem value="__none__" disabled> <SelectItem value="__none__" disabled>
@@ -4430,13 +4433,8 @@ const ExcelUploadConfigSection: React.FC<{
const compId = comp.componentId || comp.componentType; const compId = comp.componentId || comp.componentType;
const compConfig = comp.componentConfig || comp.config || comp; const compConfig = comp.componentConfig || comp.config || comp;
// 테이블 패널이나 데이터 테이블에서 테이블명 찾기 // 테이블 패널이나 데이터 테이블에서 테이블명 찾기 (canonical/legacy/v2 모두)
if ( if (compId === "table-panel" || compId === "simple-table" || isTableLikeComponentType(compId)) {
compId === "table-panel" ||
compId === "data-table" ||
compId === "table-list" ||
compId === "simple-table"
) {
const tableName = compConfig?.table_name || compConfig?.table; const tableName = compConfig?.table_name || compConfig?.table;
if (tableName) return tableName; if (tableName) return tableName;
} }
@@ -12,6 +12,7 @@ import { Check, ChevronsUpDown, Plus, X } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { QuickInsertConfigSection } from "../QuickInsertConfigSection"; import { QuickInsertConfigSection } from "../QuickInsertConfigSection";
import { ComponentData } from "@/types/screen"; import { ComponentData } from "@/types/screen";
import { isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
export interface DataTabProps { export interface DataTabProps {
config: any; 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> = ({ export const DataTab: React.FC<DataTabProps> = ({
config, config,
onChange, onChange,
@@ -106,9 +117,7 @@ export const DataTab: React.FC<DataTabProps> = ({
{allComponents {allComponents
.filter((comp: any) => { .filter((comp: any) => {
const type = comp.componentType || comp.type || ""; const type = comp.componentType || comp.type || "";
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) => return isDataTransferComponentType(type);
type.includes(t),
);
}) })
.map((comp: any) => { .map((comp: any) => {
const compType = comp.componentType || comp.type || "unknown"; const compType = comp.componentType || comp.type || "unknown";
@@ -130,9 +139,7 @@ export const DataTab: React.FC<DataTabProps> = ({
})} })}
{allComponents.filter((comp: any) => { {allComponents.filter((comp: any) => {
const type = comp.componentType || comp.type || ""; const type = comp.componentType || comp.type || "";
return ["table-list", "repeater-field-group", "form-group", "data-table"].some((t) => return isDataTransferComponentType(type);
type.includes(t),
);
}).length === 0 && ( }).length === 0 && (
<SelectItem value="__none__" disabled> <SelectItem value="__none__" disabled>
@@ -198,9 +205,7 @@ export const DataTab: React.FC<DataTabProps> = ({
{allComponents {allComponents
.filter((comp: any) => { .filter((comp: any) => {
const type = comp.componentType || comp.type || ""; const type = comp.componentType || comp.type || "";
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some( const isReceivable = isDataTransferComponentType(type);
(t) => type.includes(t),
);
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId; return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
}) })
.map((comp: any) => { .map((comp: any) => {
@@ -223,9 +228,7 @@ export const DataTab: React.FC<DataTabProps> = ({
})} })}
{allComponents.filter((comp: any) => { {allComponents.filter((comp: any) => {
const type = comp.componentType || comp.type || ""; const type = comp.componentType || comp.type || "";
const isReceivable = ["table-list", "repeater-field-group", "form-group", "data-table"].some( const isReceivable = isDataTransferComponentType(type);
(t) => type.includes(t),
);
return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId; return isReceivable && comp.id !== config.action?.dataTransfer?.sourceComponentId;
}).length === 0 && ( }).length === 0 && (
<SelectItem value="__none__" disabled> <SelectItem value="__none__" disabled>
@@ -147,11 +147,14 @@ interface MultilangSettingsModalProps {
} }
// 타입별 아이콘 매핑 // 타입별 아이콘 매핑
// canonical table / legacy table-list / hidden v2-table-list 모두 같은 table 아이콘.
const getTypeIcon = (type: string) => { const getTypeIcon = (type: string) => {
switch (type) { switch (type) {
case "button": case "button":
return <MousePointer className="h-4 w-4" />; return <MousePointer className="h-4 w-4" />;
case "table":
case "table-list": case "table-list":
case "v2-table-list":
return <Table2 className="h-4 w-4" />; return <Table2 className="h-4 w-4" />;
case "split-panel-layout": case "split-panel-layout":
return <LayoutPanelLeft className="h-4 w-4" />; 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([ const NON_INPUT_COMPONENT_TYPES = new Set([
"table",
"table-list", "table-list",
"v2-table-list",
"split-panel-layout", "split-panel-layout",
"tab-panel", "tab-panel",
"container", "container",
@@ -205,9 +211,35 @@ const NON_INPUT_COMPONENT_TYPES = new Set([
"modal", "modal",
"drawer", "drawer",
"form-layout", "form-layout",
// canonical stats + 옛 저장 화면 호환 (aggregation-widget / v2-aggregation-widget / v2-status-count)
"stats",
"aggregation-widget", "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 isInputComponent = (comp: any): boolean => {
const compType = comp.componentType || comp.type; const compType = comp.componentType || comp.type;
@@ -727,13 +759,14 @@ export const MultilangSettingsModal: React.FC<MultilangSettingsModalProps> = ({
}); });
} }
// 11. 위젯 (aggregation-widget) 항목 라벨 // 11. 카드 (canonical `stats` + legacy aggregation-widget 호환) 항목 라벨
if (compType === "aggregation-widget" && config?.items && Array.isArray(config.items)) { if (isStatsLikeComponentType(compType) && config?.items && Array.isArray(config.items)) {
config.items.forEach((item: any, index: number) => { config.items.forEach((item: any, index: number) => {
if (item.columnLabel && typeof item.columnLabel === "string") { const itemLabel = getStatsItemLabel(item);
if (itemLabel) {
addLabel( addLabel(
`${comp.id}_agg_${item.id || index}`, `${comp.id}_stats_${item.id || index}`,
item.columnLabel, itemLabel,
"label", "label",
compType, compType,
compLabel, compLabel,
@@ -42,7 +42,9 @@ export function ComponentsPanel({
// 레지스트리에서 모든 컴포넌트 조회 // 레지스트리에서 모든 컴포넌트 조회
const allComponents = useMemo(() => { const allComponents = useMemo(() => {
const components = ComponentRegistry.getAllComponents(); const components = ComponentRegistry.getAllComponents();
// v2-table-list가 자동 등록되므로 수동 추가 불필요 // ★ 새 생성 경로는 canonical 'table' (displayMode='table').
// v2-table-list 는 옛 저장 화면 호환 hard blocker 로 자동 등록되지만
// 팔레트에는 hidden 처리한다 (아래 hiddenComponents 참고).
return components; return components;
}, []); }, []);
@@ -134,8 +136,8 @@ export function ComponentsPanel({
// ===== V2로 대체된 기존 컴포넌트 (v2 버전만 사용) ===== // ===== V2로 대체된 기존 컴포넌트 (v2 버전만 사용) =====
"button-primary", // → v2-button-primary "button-primary", // → v2-button-primary
"split-panel-layout", // → v2-split-panel-layout "split-panel-layout", // → v2-split-panel-layout
"aggregation-widget", // → v2-aggregation-widget // aggregation-widget: 폴더/Renderer 삭제 (2026-05-19). ComponentRegistry 에 없음 — hidden 처리 불필요
"table-list", // → v2-table-list "table-list", // legacy hidden — 새 생성 경로는 canonical 'table'
"text-display", // → v2-text-display "text-display", // → v2-text-display
"divider-line", // → v2-divider-line "divider-line", // → v2-divider-line
// ★ 2026-04-11 통합 컴포넌트(Phase A-1): 구분선 3종 → `divider` // ★ 2026-04-11 통합 컴포넌트(Phase A-1): 구분선 3종 → `divider`
@@ -162,9 +164,10 @@ export function ComponentsPanel({
// radio-basic, toggle-switch (Phase F.1) // radio-basic, toggle-switch (Phase F.1)
// image-widget, entity-search-input, autocomplete-search-input, file-upload (일부) // image-widget, entity-search-input, autocomplete-search-input, file-upload (일부)
// ★ 2026-04-11 통합 컴포넌트(Phase B-2): 통계/KPI → `stats` // ★ 2026-04-11 통합 컴포넌트(Phase B-2): 통계/KPI → `stats`
"v2-aggregation-widget", // → stats // v2-aggregation-widget / v2-status-count: 폴더/Renderer 삭제 (2026-05-19).
"v2-status-count", // → stats // ComponentRegistry 에 없음 — hidden list 에 둘 필요 없음. 옛 저장 화면은
// aggregation-widget, card-display 는 기존 상단에서 이미 숨김 // DynamicComponentRenderer.LEGACY_TO_UNIFIED 로 canonical `stats` 라우팅.
// card-display 는 기존 상단에서 이미 숨김
// form 컴포넌트는 롤백됨 (2026-04-11): 3뷰 탭 구조로 처리 예정. // form 컴포넌트는 롤백됨 (2026-04-11): 3뷰 탭 구조로 처리 예정.
"field-example-1", // legacy form-layout 의 실제 id (숨김 유지) "field-example-1", // legacy form-layout 의 실제 id (숨김 유지)
// ★ 2026-04-11 통합 컴포넌트(Phase C-1): 데이터 테이블 → `table` // ★ 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, // table-list, split-panel-layout, split-panel-layout2, modal-repeater-table,
// simple-repeater-table, tax-invoice-list, pivot-grid 는 기존 상단에서 이미 숨김 // simple-repeater-table, tax-invoice-list, pivot-grid 는 기존 상단에서 이미 숨김
// ★ 2026-04-11 통합 컴포넌트(Phase C-2): 컨테이너 → `container` // ★ 2026-04-11 통합 컴포넌트(Phase C-2): 컨테이너 → `container`
"v2-tabs-widget", // → container (containerType='tabs') // v2-tabs-widget / v2-section-card / v2-section-paper / section-card / section-paper / tabs / tabs-widget:
"v2-section-card", // → container (containerType='section', sectionVariant='card') // 폴더/Renderer 삭제 (2026-05-19). ComponentRegistry 에 없음 — hidden 처리 불필요.
"v2-section-paper", // → container (containerType='section', sectionVariant='paper') // 옛 저장 화면은 DynamicComponentRenderer.LEGACY_TO_UNIFIED 로 canonical `container` 라우팅.
"v2-repeat-container", // → container (containerType='repeater') "v2-repeat-container", // → container (containerType='repeater')
"v2-repeater", // → container (containerType='repeater') "v2-repeater", // → container (containerType='repeater')
// accordion-basic, conditional-container, section-card, section-paper, // accordion-basic, conditional-container, repeat-container, repeat-screen-modal,
// tabs, repeat-container, repeat-screen-modal, repeater-field-group, // repeater-field-group, screen-split-panel 는 기존 상단에서 이미 숨김
// screen-split-panel 는 기존 상단에서 이미 숨김
// numbering-rule: 폐기 (2026-05-11) // numbering-rule: 폐기 (2026-05-11)
"split-panel-layout2", // → table (displayMode='split') Phase E 통합 "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 "location-swap-selector", // → v2-location-swap-selector
"rack-structure", // → v2-rack-structure "rack-structure", // → v2-rack-structure
"v2-repeater", // → v2-repeater (아래 v2Components에서 별도 처리) "v2-repeater", // → v2-repeater (아래 v2Components에서 별도 처리)
"repeat-container", // → v2-repeat-container "repeat-container", // → v2-repeat-container
"repeat-screen-modal", // → v2-repeat-screen-modal "repeat-screen-modal", // → v2-repeat-screen-modal
"table-search-widget", // → v2-table-search-widget "table-search-widget", // → v2-table-search-widget
"tabs", // → v2-tabs
"tabs-widget", // → v2-tabs-widget
]; ];
return { return {
@@ -20,7 +20,7 @@ import { LayoutComponent } from "@/types/layout";
// 레거시 ButtonConfigPanel 제거됨 // 레거시 ButtonConfigPanel 제거됨
import { FileComponentConfigPanel } from "./FileComponentConfigPanel"; import { FileComponentConfigPanel } from "./FileComponentConfigPanel";
import { WebTypeConfigPanel } from "./WebTypeConfigPanel"; 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 { BaseInputType, getBaseInputType, getDetailTypes, DetailTypeOption } from "@/types/input-type-mapping";
import { ConditionalConfigPanel } from "@/components/v2/ConditionalConfigPanel"; import { ConditionalConfigPanel } from "@/components/v2/ConditionalConfigPanel";
import { ConditionalConfig } from "@/types/v2-components"; import { ConditionalConfig } from "@/types/v2-components";
@@ -871,7 +871,8 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
// 🆕 ComponentRegistry에서 ConfigPanel 가져오기 // 🆕 ComponentRegistry에서 ConfigPanel 가져오기
const componentId = selectedComponent.component_config?.type || selectedComponent.component_config?.id; const componentId = selectedComponent.component_config?.type || selectedComponent.component_config?.id;
if (componentId) { if (componentId) {
const definition = ComponentRegistry.getComponent(componentId); const registryComponentId = isTableLikeComponentType(componentId) ? "table" : componentId;
const definition = ComponentRegistry.getComponent(registryComponentId);
if (definition?.config_panel) { if (definition?.config_panel) {
const ConfigPanelComponent = definition.config_panel; const ConfigPanelComponent = definition.config_panel;
const currentConfig = selectedComponent.component_config || {}; const currentConfig = selectedComponent.component_config || {};
@@ -32,7 +32,7 @@ import DataTableConfigPanel from "./DataTableConfigPanel";
import { WebTypeConfigPanel } from "./WebTypeConfigPanel"; import { WebTypeConfigPanel } from "./WebTypeConfigPanel";
import { FileComponentConfigPanel } from "./FileComponentConfigPanel"; import { FileComponentConfigPanel } from "./FileComponentConfigPanel";
import { useWebTypes } from "@/hooks/admin/useWebTypes"; import { useWebTypes } from "@/hooks/admin/useWebTypes";
import { isFileComponent } from "@/lib/utils/componentTypeUtils"; import { isFileComponent, isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
import { import {
BaseInputType, BaseInputType,
BASE_INPUT_TYPE_OPTIONS, BASE_INPUT_TYPE_OPTIONS,
@@ -266,7 +266,8 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
} }
if (componentId) { 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) 로 저장됨. // ★ 2026-04-11: ComponentDefinition 은 config_panel (snake_case) 로 저장됨.
// 기존 코드는 configPanel (camelCase) 만 찾아서 항상 false. 둘 다 체크. // 기존 코드는 configPanel (camelCase) 만 찾아서 항상 false. 둘 다 체크.
@@ -767,7 +768,8 @@ export const V2PropertiesPanel: React.FC<V2PropertiesPanelProps> = ({
// 🆕 ComponentRegistry에서 전용 ConfigPanel이 있는지 먼저 확인 // 🆕 ComponentRegistry에서 전용 ConfigPanel이 있는지 먼저 확인
// ★ 2026-04-11: ComponentDefinition 은 config_panel (snake_case) 로 저장됨. // ★ 2026-04-11: ComponentDefinition 은 config_panel (snake_case) 로 저장됨.
// 기존 코드는 configPanel (camelCase) 만 찾아서 항상 false. 둘 다 체크. // 기존 코드는 configPanel (camelCase) 만 찾아서 항상 false. 둘 다 체크.
const definition = ComponentRegistry.getComponent(componentId); const registryComponentId = isTableLikeComponentType(componentId) ? "table" : componentId;
const definition = ComponentRegistry.getComponent(registryComponentId);
const configPanelFromDef = const configPanelFromDef =
(definition as any)?.configPanel ?? (definition as any)?.config_panel; (definition as any)?.configPanel ?? (definition as any)?.config_panel;
if (configPanelFromDef) { if (configPanelFromDef) {
@@ -32,8 +32,10 @@ import {
import type { FlowDefinition, FlowStep } from "@/types/flow"; import type { FlowDefinition, FlowStep } from "@/types/flow";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { SingleTableWithSticky } from "@/lib/registry/components/table-list/SingleTableWithSticky"; import {
import type { ColumnConfig } from "@/lib/registry/components/table-list/types"; SingleTableWithSticky,
type ColumnConfig,
} from "@/lib/registry/components/table/_shared/SingleTableWithSticky";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { toast } from "sonner"; import { toast } from "sonner";
import { showErrorToast } from "@/lib/utils/toastUtils"; import { showErrorToast } from "@/lib/utils/toastUtils";
@@ -10,6 +10,7 @@ import { useActiveTab } from "@/contexts/ActiveTabContext";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer"; import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { screenApi } from "@/lib/api/screen"; import { screenApi } from "@/lib/api/screen";
import { ResponsiveGridRenderer } from "@/components/screen/ResponsiveGridRenderer"; import { ResponsiveGridRenderer } from "@/components/screen/ResponsiveGridRenderer";
import { isTableLikeComponent, getTableNameFromTableLikeComponent } from "@/lib/utils/componentTypeUtils";
// 확장된 TabItem 타입 (screen_id 지원) // 확장된 TabItem 타입 (screen_id 지원)
interface ExtendedTabItem extends TabItem { interface ExtendedTabItem extends TabItem {
@@ -142,11 +143,11 @@ export function TabsWidget({
for (const tab of tabs as ExtendedTabItem[]) { for (const tab of tabs as ExtendedTabItem[]) {
const inlineComponents = tab.components || []; const inlineComponents = tab.components || [];
if (inlineComponents.length > 0) { if (inlineComponents.length > 0) {
// 인라인 컴포넌트에서 테이블 컴포넌트의 selectedTable 추출 // 인라인 컴포넌트에서 table-like 컴포넌트의 selectedTable 추출
const tableComp = inlineComponents.find( // (canonical table / legacy table-list / hidden v2-table-list 모두 인식,
(c) => c.component_type === "v2-table-list" || c.component_type === "table-list", // camelCase / snake_case 양쪽 모두 처리)
); const tableComp = inlineComponents.find((c) => isTableLikeComponent(c));
const selectedTable = tableComp?.component_config?.selectedTable; const selectedTable = getTableNameFromTableLikeComponent(tableComp);
if (selectedTable || tab.screen_id) { if (selectedTable || tab.screen_id) {
map[tab.id] = { map[tab.id] = {
id: tab.screen_id, id: tab.screen_id,
@@ -229,7 +229,7 @@ export const UnifiedRepeater: React.FC<UnifiedRepeaterProps> = ({
// ============================================================ // ============================================================
// repeaterDataChange 이벤트 발행 // repeaterDataChange 이벤트 발행
// 데이터 변경 시 다른 컴포넌트(aggregation-widget 등)에 알림 // 데이터 변경 시 다른 컴포넌트(canonical stats 등)에 알림
// ============================================================ // ============================================================
const prevDataLengthRef = useRef(data.length); const prevDataLengthRef = useRef(data.length);
useEffect(() => { useEffect(() => {
+1 -1
View File
@@ -8,7 +8,7 @@
*/ */
import React, { forwardRef, useMemo } from "react"; 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"; import { V2ListProps } from "@/types/v2-components";
/** /**
@@ -71,6 +71,7 @@ import {
import { ImprovedButtonControlConfigPanel } from "@/components/screen/config-panels/ImprovedButtonControlConfigPanel"; import { ImprovedButtonControlConfigPanel } from "@/components/screen/config-panels/ImprovedButtonControlConfigPanel";
import { FlowVisibilityConfigPanel } from "@/components/screen/config-panels/FlowVisibilityConfigPanel"; import { FlowVisibilityConfigPanel } from "@/components/screen/config-panels/FlowVisibilityConfigPanel";
import type { ComponentData } from "@/types/screen"; import type { ComponentData } from "@/types/screen";
import { isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
// ─────────────────────────────────────────────────────── // ───────────────────────────────────────────────────────
// 상수: 액션 / 표시 / 변형 // 상수: 액션 / 표시 / 변형
@@ -112,6 +113,16 @@ const MODAL_SIZE_OPTIONS = [
{ value: "full", label: "전체" }, { value: "full", label: "전체" },
] as const; ] 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 = [ const TRANSFER_MODE_OPTIONS = [
{ value: "append", label: "추가" }, { value: "append", label: "추가" },
{ value: "replace", label: "교체" }, { value: "replace", label: "교체" },
@@ -810,9 +821,7 @@ function ActionDetailBody(p: ActionDetailBodyProps) {
{p.allComponents {p.allComponents
.filter((c: any) => { .filter((c: any) => {
const t = c.componentType || c.type || ""; const t = c.componentType || c.type || "";
return ["table-list", "repeater-field-group", "form-group", "data-table"].some( return isDataTransferComponentType(t);
(x) => t.includes(x),
);
}) })
.map((c: any) => ( .map((c: any) => (
<option key={c.id} value={c.id}> <option key={c.id} value={c.id}>
@@ -841,9 +850,7 @@ function ActionDetailBody(p: ActionDetailBodyProps) {
{p.allComponents {p.allComponents
.filter((c: any) => { .filter((c: any) => {
const t = c.componentType || c.type || ""; const t = c.componentType || c.type || "";
const ok = ["table-list", "repeater-field-group", "form-group", "data-table"].some( const ok = isDataTransferComponentType(t);
(x) => t.includes(x),
);
return ok && c.id !== dt.sourceComponentId; return ok && c.id !== dt.sourceComponentId;
}) })
.map((c: any) => ( .map((c: any) => (
File diff suppressed because it is too large Load Diff
@@ -22,8 +22,8 @@ import {
} from "@/components/ui/collapsible"; } from "@/components/ui/collapsible";
import { Table2, Settings, ChevronDown } from "lucide-react"; import { Table2, Settings, ChevronDown } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { TableListConfigPanel } from "@/lib/registry/components/table-list/TableListConfigPanel"; import { TableListConfigPanel } from "@/lib/registry/components/table/_shared/TableListConfigPanel";
import { TableListConfig } from "@/lib/registry/components/table-list/types"; import type { TableListConfig } from "@/lib/registry/components/table/_shared/tableListConfigTypes";
interface V2ListConfigPanelProps { interface V2ListConfigPanelProps {
config: Record<string, any>; 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">
. &gt; .
</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 { DndContext, closestCenter, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable"; import { SortableContext, useSortable, verticalListSortingStrategy, arrayMove } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; 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"; import { CPRow, CPSwitch } from "./_shared/cp";
// ─── DnD 정렬 가능한 컬럼 행 (접이식) ─── // ─── DnD 정렬 가능한 컬럼 행 (접이식) ───
+2 -22
View File
@@ -52,30 +52,10 @@ export function MenuProvider({ children }: { children: ReactNode }) {
try { try {
setLoading(true); 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만 표시 // 좌측 사이드바용: active만 표시
// 로케일은 useAuth.fetchCurrentUser 가 /auth/me 응답에서 세팅 완료 후 user.company_code 가 채워지므로
// 이 함수가 호출되는 시점에는 항상 __GLOBAL_USER_LANG 이 세팅되어 있음 → 별도 대기 불필요
const [adminResponse, userResponse] = await Promise.all([menuApi.getAdminMenus(), menuApi.getUserMenus()]); const [adminResponse, userResponse] = await Promise.all([menuApi.getAdminMenus(), menuApi.getUserMenus()]);
if (adminResponse.success && adminResponse.data) { if (adminResponse.success && adminResponse.data) {
+17 -45
View File
@@ -67,22 +67,11 @@ export const useAuth = () => {
const response = await apiCall<UserInfo>("GET", "/auth/me"); const response = await apiCall<UserInfo>("GET", "/auth/me");
if (response.success && response.data) { if (response.success && response.data) {
// 사용자 로케일 정보 조회 const userLocale = response.data.locale || "KR";
try { (window as any).__GLOBAL_USER_LANG = userLocale;
const localeResponse = await apiCall<string>("GET", "/admin/user-locale"); (window as any).__GLOBAL_USER_LOCALE_LOADED = true;
if (localeResponse.success && localeResponse.data) { localStorage.setItem("userLocale", userLocale);
const userLocale = localeResponse.data; localStorage.setItem("userLocaleLoaded", "true");
(window as any).__GLOBAL_USER_LANG = userLocale;
(window as any).__GLOBAL_USER_LOCALE_LOADED = true;
localStorage.setItem("userLocale", userLocale);
localStorage.setItem("userLocaleLoaded", "true");
}
} catch {
(window as any).__GLOBAL_USER_LANG = "KR";
(window as any).__GLOBAL_USER_LOCALE_LOADED = true;
localStorage.setItem("userLocale", "KR");
localStorage.setItem("userLocaleLoaded", "true");
}
const data = response.data; const data = response.data;
return { return {
@@ -100,30 +89,11 @@ export const useAuth = () => {
} }
}, []); }, []);
/**
*
*/
const checkAuthStatus = useCallback(async (): Promise<AuthStatus> => {
try {
const response = await apiCall<AuthStatus>("GET", "/auth/status");
if (response.success && response.data) {
return {
isLoggedIn: (response.data as any).isAuthenticated || response.data.isLoggedIn || false,
isAdmin: response.data.isAdmin || false,
};
}
return { isLoggedIn: false, isAdmin: false };
} catch {
return { isLoggedIn: false, isAdmin: false };
}
}, []);
/** /**
* *
* - /auth/me * - /auth/me
* - ( fallback * - /auth/me is_admin, locale, force_password_change
* 401 ) * /auth/status, /admin/user-locale ( )
*/ */
const refreshUserData = useCallback(async () => { const refreshUserData = useCallback(async () => {
try { try {
@@ -146,19 +116,21 @@ export const useAuth = () => {
isAdmin: false, isAdmin: false,
}); });
// /auth/me 성공 = 인증 확인 완료. /auth/status는 보조 정보(isAdmin)만 참조 const userInfo = await fetchCurrentUser();
// 두 API를 Promise.all로 호출 시, 토큰 만료 타이밍에 따라
// /auth/me는 401→갱신→성공, /auth/status는 200 isAuthenticated:false를 반환하는
// 레이스 컨디션이 발생할 수 있으므로, isLoggedIn 판단은 /auth/me 성공 여부로 결정
const [userInfo, authStatusData] = await Promise.all([fetchCurrentUser(), checkAuthStatus()]);
if (userInfo) { if (userInfo) {
setUser(userInfo); setUser(userInfo);
const isAdminFromUser = userInfo.user_id === "plm_admin" || userInfo.user_type === "ADMIN"; // 백엔드 AuthService.checkAuthStatus 와 동일한 판정 로직을 user_type 기반으로 적용.
// (별도 /auth/status 호출 없이 동일 결과)
const userType = userInfo.user_type;
const isAdmin = userInfo.user_id === "plm_admin"
|| userType === "ADMIN"
|| userType === "SUPER_ADMIN"
|| userType === "COMPANY_ADMIN";
const finalAuthStatus = { const finalAuthStatus = {
isLoggedIn: true, isLoggedIn: true,
isAdmin: authStatusData.isAdmin || isAdminFromUser, isAdmin,
}; };
setAuthStatus(finalAuthStatus); setAuthStatus(finalAuthStatus);
@@ -178,7 +150,7 @@ export const useAuth = () => {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [fetchCurrentUser, checkAuthStatus]); }, [fetchCurrentUser]);
/** /**
* (Invyone ) * (Invyone )
+111
View File
@@ -0,0 +1,111 @@
/**
* API stub
*
* DB . mock .
* API mock fetch .
*
* ():
* listExecutionHistory GET /api/control/executions?card_id=...&limit=...
* getNodeStats GET /api/control/nodes/{nodeId}/stats
* listNodeComments GET /api/control/nodes/{nodeId}/comments
* listPresence GET /api/control/dashboards/{dashboardId}/presence (WS )
* listRelations GET /api/control/tables/{tableName}/relations
* listRelatedRules GET /api/control/cards/{cardId}/related-rules
*/
export interface ExecutionRecord {
id: string;
ts: string; // HH:MM:SS or full ISO
who: string; // user@domain or "API · webhook"
trig: string; // trigger reference (e.g., shipment_no)
ok: boolean;
ms: number;
steps: number;
err?: string;
}
export interface NodeStats {
valid: boolean;
lastMs: number | null;
runs: number;
alert: string | null;
}
export interface NodeComment {
who: string;
short: string;
color: string; // RGB triplet "0,206,201"
text: string;
at: string; // relative time
}
export interface PresenceUser {
name: string;
short: string;
color: string;
mode: 'edit' | 'view';
}
export interface TableRelation {
from: string;
to: string;
label: string;
type: 'auto' | 'rel';
}
export interface RelatedRule {
id: string;
name: string;
status: 'active' | 'draft' | 'sched';
lastRun: string;
runs: number;
successRate: number | null;
nodes: number;
owner: string;
primary?: boolean;
}
/** 실행 이력 — 카드별 최근 실행 결과 */
export async function listExecutionHistory(
cardId: string,
options: { limit?: number } = {},
): Promise<ExecutionRecord[]> {
void cardId; void options;
// TODO: GET /api/control/executions?card_id=...&limit=...
return Promise.resolve([]);
}
/** 노드 통계 — 단일 노드의 valid/runs/lastMs/alert */
export async function getNodeStats(nodeId: string): Promise<NodeStats> {
void nodeId;
// TODO: GET /api/control/nodes/{nodeId}/stats
return Promise.resolve({ valid: true, lastMs: null, runs: 0, alert: null });
}
/** 노드 댓글 */
export async function listNodeComments(nodeId: string): Promise<NodeComment[]> {
void nodeId;
// TODO: GET /api/control/nodes/{nodeId}/comments
return Promise.resolve([]);
}
/** 현재 보고 있는 사용자 (presence) */
export async function listPresence(dashboardId: string): Promise<PresenceUser[]> {
void dashboardId;
// TODO: GET /api/control/dashboards/{dashboardId}/presence (WS 권장)
return Promise.resolve([]);
}
/** 테이블 관계 (view 모드의 fan-out 트리) */
export async function listRelations(tableName: string): Promise<TableRelation[]> {
void tableName;
// TODO: GET /api/control/tables/{tableName}/relations
return Promise.resolve([]);
}
/** 관련 룰 — 이 카드에 연결된 룰 목록 */
export async function listRelatedRules(cardId: string): Promise<RelatedRule[]> {
void cardId;
// TODO: GET /api/control/cards/{cardId}/related-rules
return Promise.resolve([]);
}
+8
View File
@@ -37,6 +37,14 @@ export const ddlApi = {
return response.data; return response.data;
}, },
/**
* (ALTER TABLE ... DROP COLUMN)
*/
dropColumn: async (tableName: string, columnName: string): Promise<DDLExecutionResult> => {
const response = await apiClient.delete(`/ddl/tables/${tableName}/columns/${columnName}`);
return response.data;
},
/** /**
* ( ) * ( )
*/ */
@@ -350,6 +350,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 레거시 타입을 v2 컴포넌트로 매핑 (v2 컴포넌트가 없으면 원본 유지) // 레거시 타입을 v2 컴포넌트로 매핑 (v2 컴포넌트가 없으면 원본 유지)
// ★ 2026-04-11: INVYONE 통합 컴포넌트(Phase A~) 는 v2- 매핑에서 제외. // ★ 2026-04-11: INVYONE 통합 컴포넌트(Phase A~) 는 v2- 매핑에서 제외.
// ★ 2026-05-12: V2 입력/선택은 완전 폐기 — 매핑/alias/fallback 모두 제거. // ★ 2026-05-12: V2 입력/선택은 완전 폐기 — 매핑/alias/fallback 모두 제거.
// ★ 2026-05-18: canonical data-view 정리 — chart / card-list / grouped-table
// 세 가지를 first-class 로 포함시켜 v2- prefix 자동 매핑(`v2-chart` 등) 으로
// 엉뚱한 컴포넌트가 잡히지 않도록 한다.
const INVYONE_UNIFIED_IDS = new Set([ const INVYONE_UNIFIED_IDS = new Set([
"divider", "divider",
"title", "title",
@@ -360,6 +363,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// "form" 롤백됨 — 3뷰 탭 구조로 처리 예정 // "form" 롤백됨 — 3뷰 탭 구조로 처리 예정
"table", "table",
"container", "container",
"chart",
"card-list",
"grouped-table",
]); ]);
// ── Phase E: v2-* → 통합 컴포넌트 역방향 alias (기존 저장 화면 호환) ── // ── Phase E: v2-* → 통합 컴포넌트 역방향 alias (기존 저장 화면 호환) ──
@@ -380,7 +386,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// table // table
"v2-table-list": "table", "table-list": "table", "v2-table-list": "table", "table-list": "table",
// container // container
"v2-tabs-widget": "container", "v2-section-card": "container", "v2-tabs-widget": "container", "tabs-widget": "container", "tabs": "container", "v2-tabs": "container",
"v2-section-card": "container",
"v2-section-paper": "container", "v2-repeat-container": "container", "v2-section-paper": "container", "v2-repeat-container": "container",
"section-card": "container", "section-paper": "container", "section-card": "container", "section-paper": "container",
"accordion-basic": "container", "accordion-basic": "container",
@@ -1,55 +0,0 @@
"use client";
import React from "react";
import { ComponentData } from "@/types/screen";
import { componentRegistry, ComponentRenderer } from "../DynamicComponentRenderer";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
// 탭 컴포넌트 렌더러
const TabsRenderer: ComponentRenderer = ({ component, children, ...props }) => {
const config = component.component_config || {};
const {
tabs = [
{ id: "tab1", label: "탭 1", content: "첫 번째 탭 내용" },
{ id: "tab2", label: "탭 2", content: "두 번째 탭 내용" },
{ id: "tab3", label: "탭 3", content: "세 번째 탭 내용" },
],
defaultTab = "tab1",
orientation = "horizontal", // horizontal, vertical
style = {},
} = config;
return (
<div className="h-full w-full p-2" style={style}>
<Tabs defaultValue={defaultTab} orientation={orientation} className="h-full">
<TabsList className="grid w-full grid-cols-3">
{tabs.map((tab: any) => (
<TabsTrigger key={tab.id} value={tab.id} className="pointer-events-none" disabled>
{tab.label}
</TabsTrigger>
))}
</TabsList>
{tabs.map((tab: any) => (
<TabsContent key={tab.id} value={tab.id} className="mt-4 flex-1">
{children && React.Children.count(children) > 0 ? (
children
) : (
<div className="flex h-full items-center justify-center rounded border border-dashed border-input bg-muted">
<div className="text-center">
<div className="text-sm text-muted-foreground">{tab.content}</div>
<div className="mt-1 text-xs text-muted-foreground/70"> </div>
</div>
</div>
)}
</TabsContent>
))}
</Tabs>
</div>
);
};
// 레지스트리에 등록
componentRegistry.register("tabs", TabsRenderer);
componentRegistry.register("tabs-horizontal", TabsRenderer);
export { TabsRenderer };
@@ -1,314 +0,0 @@
"use client";
import React, { useState, useEffect, useMemo } from "react";
import { ComponentRendererProps } from "@/types/component";
import { AggregationWidgetConfig, AggregationItem, AggregationResult, AggregationType } from "./types";
import { formatNumber } from "@/lib/formatting";
import { Calculator, TrendingUp, Hash, ArrowUp, ArrowDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
interface AggregationWidgetComponentProps extends ComponentRendererProps {
config?: AggregationWidgetConfig;
// 외부에서 데이터를 직접 전달받을 수 있음
externalData?: any[];
}
/**
*
*
*/
export function AggregationWidgetComponent({
component,
isDesignMode = false,
config: propsConfig,
externalData,
}: AggregationWidgetComponentProps) {
// 다국어 지원
const { getTranslatedText } = useScreenMultiLang();
const getText = (key: string | undefined) => key ? getTranslatedText(key, key) : undefined;
const componentConfig: AggregationWidgetConfig = {
dataSourceType: "manual",
items: [],
layout: "horizontal",
showLabels: true,
showIcons: true,
gap: "16px",
...propsConfig,
...component?.config,
};
// 다국어 라벨 가져오기
const getItemLabel = (item: AggregationItem): string => {
if (item.labelLangKey) {
const translated = getText(item.labelLangKey);
if (translated && translated !== item.labelLangKey) {
return translated;
}
}
return item.columnLabel || item.columnName || "컬럼";
};
const {
dataSourceType,
dataSourceComponentId,
items,
layout,
showLabels,
showIcons,
gap,
backgroundColor,
borderRadius,
padding,
fontSize,
labelFontSize,
valueFontSize,
labelColor,
valueColor,
} = componentConfig;
// 데이터 상태
const [data, setData] = useState<any[]>([]);
// 외부 데이터가 있으면 사용
useEffect(() => {
if (externalData && Array.isArray(externalData)) {
setData(externalData);
}
}, [externalData]);
// 컴포넌트 데이터 변경 이벤트 리스닝
useEffect(() => {
if (!dataSourceComponentId || isDesignMode) return;
const handleDataChange = (event: CustomEvent) => {
const { componentId, data: eventData } = event.detail || {};
if (componentId === dataSourceComponentId && Array.isArray(eventData)) {
setData(eventData);
}
};
// 리피터 데이터 변경 이벤트
window.addEventListener("repeaterDataChange" as any, handleDataChange);
// 테이블 리스트 데이터 변경 이벤트
window.addEventListener("tableListDataChange" as any, handleDataChange);
return () => {
window.removeEventListener("repeaterDataChange" as any, handleDataChange);
window.removeEventListener("tableListDataChange" as any, handleDataChange);
};
}, [dataSourceComponentId, isDesignMode]);
// 집계 계산
const aggregationResults = useMemo((): AggregationResult[] => {
if (!items || items.length === 0) {
return [];
}
return items.map((item) => {
const values = data
.map((row) => {
const val = row[item.columnName];
return typeof val === "number" ? val : parseFloat(val) || 0;
})
.filter((v) => !isNaN(v));
let value: number = 0;
switch (item.type) {
case "sum":
value = values.reduce((acc, v) => acc + v, 0);
break;
case "avg":
value = values.length > 0 ? values.reduce((acc, v) => acc + v, 0) / values.length : 0;
break;
case "count":
value = data.length;
break;
case "max":
value = values.length > 0 ? Math.max(...values) : 0;
break;
case "min":
value = values.length > 0 ? Math.min(...values) : 0;
break;
}
// 포맷팅
let formattedValue = value.toFixed(item.decimalPlaces ?? 0);
if (item.format === "currency") {
formattedValue = formatNumber(value);
} else if (item.format === "percent") {
formattedValue = `${(value * 100).toFixed(item.decimalPlaces ?? 1)}%`;
} else if (item.format === "number") {
formattedValue = formatNumber(value);
}
if (item.prefix) {
formattedValue = `${item.prefix}${formattedValue}`;
}
if (item.suffix) {
formattedValue = `${formattedValue}${item.suffix}`;
}
return {
id: item.id,
label: getItemLabel(item),
value,
formattedValue,
type: item.type,
};
});
}, [data, items, getText]);
// 집계 타입에 따른 아이콘
const getIcon = (type: AggregationType) => {
switch (type) {
case "sum":
return <Calculator className="h-4 w-4" />;
case "avg":
return <TrendingUp className="h-4 w-4" />;
case "count":
return <Hash className="h-4 w-4" />;
case "max":
return <ArrowUp className="h-4 w-4" />;
case "min":
return <ArrowDown className="h-4 w-4" />;
}
};
// 집계 타입 라벨
const getTypeLabel = (type: AggregationType) => {
switch (type) {
case "sum":
return "합계";
case "avg":
return "평균";
case "count":
return "개수";
case "max":
return "최대";
case "min":
return "최소";
}
};
// 디자인 모드 미리보기
if (isDesignMode) {
const previewItems: AggregationResult[] =
items.length > 0
? items.map((item) => ({
id: item.id,
label: getItemLabel(item),
value: 0,
formattedValue: item.prefix ? `${item.prefix}0${item.suffix || ""}` : `0${item.suffix || ""}`,
type: item.type,
}))
: [
{ id: "1", label: "총 수량", value: 150, formattedValue: "150", type: "sum" },
{ id: "2", label: "총 금액", value: 1500000, formattedValue: "₩1,500,000", type: "sum" },
{ id: "3", label: "건수", value: 5, formattedValue: "5건", type: "count" },
];
return (
<div
className={cn(
"flex items-center rounded-md border bg-slate-50 p-3",
layout === "vertical" ? "flex-col items-stretch" : "flex-row flex-wrap"
)}
style={{
gap: gap || "12px",
backgroundColor: backgroundColor || undefined,
borderRadius: borderRadius || undefined,
padding: padding || undefined,
fontSize: fontSize || undefined,
}}
>
{previewItems.map((result, index) => (
<div
key={result.id || index}
className={cn(
"flex items-center gap-2 rounded-md border bg-white px-3 py-2 shadow-sm",
layout === "vertical" ? "w-full justify-between" : ""
)}
>
{showIcons && (
<span className="text-muted-foreground">{getIcon(result.type)}</span>
)}
{showLabels && (
<span
className="text-muted-foreground text-xs"
style={{ fontSize: labelFontSize, color: labelColor }}
>
{result.label} ({getTypeLabel(result.type)}):
</span>
)}
<span
className="font-semibold"
style={{ fontSize: valueFontSize, color: valueColor }}
>
{result.formattedValue}
</span>
</div>
))}
</div>
);
}
// 실제 렌더링
if (aggregationResults.length === 0) {
return (
<div className="flex items-center justify-center rounded-md border border-dashed bg-slate-50 p-4 text-sm text-muted-foreground">
</div>
);
}
return (
<div
className={cn(
"flex items-center rounded-md border bg-slate-50 p-3",
layout === "vertical" ? "flex-col items-stretch" : "flex-row flex-wrap"
)}
style={{
gap: gap || "12px",
backgroundColor: backgroundColor || undefined,
borderRadius: borderRadius || undefined,
padding: padding || undefined,
fontSize: fontSize || undefined,
}}
>
{aggregationResults.map((result, index) => (
<div
key={result.id || index}
className={cn(
"flex items-center gap-2 rounded-md border bg-white px-3 py-2 shadow-sm",
layout === "vertical" ? "w-full justify-between" : ""
)}
>
{showIcons && (
<span className="text-muted-foreground">{getIcon(result.type)}</span>
)}
{showLabels && (
<span
className="text-muted-foreground text-xs"
style={{ fontSize: labelFontSize, color: labelColor }}
>
{result.label} ({getTypeLabel(result.type)}):
</span>
)}
<span
className="font-semibold"
style={{ fontSize: valueFontSize, color: valueColor }}
>
{result.formattedValue}
</span>
</div>
))}
</div>
);
}
export const AggregationWidgetWrapper = AggregationWidgetComponent;
@@ -1,539 +0,0 @@
"use client";
import React, { useState, useEffect, useMemo } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
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 { Plus, Trash2, GripVertical, Database, Table2, ChevronsUpDown, Check } from "lucide-react";
import { cn } from "@/lib/utils";
import { AggregationWidgetConfig, AggregationItem, AggregationType } from "./types";
import { tableManagementApi } from "@/lib/api/tableManagement";
import { tableTypeApi } from "@/lib/api/screen";
interface AggregationWidgetConfigPanelProps {
config: AggregationWidgetConfig;
onChange: (config: Partial<AggregationWidgetConfig>) => void;
screenTableName?: string;
}
/**
*
*/
export function AggregationWidgetConfigPanel({
config,
onChange,
screenTableName,
}: AggregationWidgetConfigPanelProps) {
const [columns, setColumns] = useState<Array<{ columnName: string; label?: string; dataType?: string; inputType?: string; web_type?: string }>>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
const [availableTables, setAvailableTables] = useState<Array<{ tableName: string; displayName?: string }>>([]);
const [loadingTables, setLoadingTables] = useState(false);
const [tableComboboxOpen, setTableComboboxOpen] = useState(false);
// 실제 사용할 테이블 이름 계산
const targetTableName = useMemo(() => {
if (config.useCustomTable && config.customTableName) {
return config.customTableName;
}
return config.tableName || screenTableName;
}, [config.useCustomTable, config.customTableName, config.tableName, screenTableName]);
// 화면 테이블명 자동 설정 (초기 한 번만)
useEffect(() => {
if (screenTableName && !config.tableName && !config.customTableName) {
onChange({ tableName: screenTableName });
}
}, [screenTableName, config.tableName, config.customTableName, onChange]);
// 전체 테이블 목록 로드
useEffect(() => {
const fetchTables = async () => {
setLoadingTables(true);
try {
const response = await tableTypeApi.getTables();
setAvailableTables(
response.map((table: any) => ({
tableName: table.tableName,
displayName: table.displayName || table.tableName,
}))
);
} catch (error) {
console.error("테이블 목록 가져오기 실패:", error);
} finally {
setLoadingTables(false);
}
};
fetchTables();
}, []);
// 테이블 컬럼 로드
useEffect(() => {
const loadColumns = async () => {
if (!targetTableName) {
setColumns([]);
return;
}
setLoadingColumns(true);
try {
const result = await tableManagementApi.getColumnList(targetTableName);
if (result.success && result.data?.columns) {
setColumns(
result.data.columns.map((col: any) => ({
columnName: col.columnName || col.column_name,
label: col.displayName || col.columnLabel || col.column_label || col.label || col.columnName || col.column_name,
dataType: col.dataType || col.data_type,
inputType: col.inputType || col.input_type,
web_type: col.web_type || col.webType,
}))
);
} else {
setColumns([]);
}
} catch (error) {
console.error("컬럼 로드 실패:", error);
setColumns([]);
} finally {
setLoadingColumns(false);
}
};
loadColumns();
}, [targetTableName]);
// 집계 항목 추가
const addItem = () => {
const newItem: AggregationItem = {
id: `agg-${Date.now()}`,
columnName: "",
columnLabel: "",
type: "sum",
format: "number",
decimalPlaces: 0,
};
onChange({
items: [...(config.items || []), newItem],
});
};
// 집계 항목 삭제
const removeItem = (id: string) => {
onChange({
items: (config.items || []).filter((item) => item.id !== id),
});
};
// 집계 항목 업데이트
const updateItem = (id: string, updates: Partial<AggregationItem>) => {
onChange({
items: (config.items || []).map((item) =>
item.id === id ? { ...item, ...updates } : item
),
});
};
// 숫자형 컬럼만 필터링 (count 제외) - 입력 타입(inputType/webType)으로만 확인
const numericColumns = columns.filter((col) => {
const inputType = (col.inputType || col.web_type || "")?.toLowerCase();
return (
inputType === "number" ||
inputType === "decimal" ||
inputType === "integer" ||
inputType === "float" ||
inputType === "currency" ||
inputType === "percent"
);
});
return (
<div className="space-y-4">
<div className="text-sm font-medium"> </div>
{/* 테이블 설정 (컴포넌트 개발 가이드 준수) */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"> </h3>
<p className="text-muted-foreground text-[10px]"> </p>
</div>
<hr className="border-border" />
{/* 현재 선택된 테이블 표시 (카드 형태) */}
<div className="flex items-center gap-2 rounded-md border bg-slate-50 p-2">
<Database className="h-4 w-4 text-primary" />
<div className="flex-1">
<div className="text-xs font-medium">
{config.customTableName || config.tableName || screenTableName || "테이블 미선택"}
</div>
<div className="text-[10px] text-muted-foreground">
{config.useCustomTable ? "커스텀 테이블" : "화면 기본 테이블"}
</div>
</div>
</div>
{/* 테이블 선택 Combobox */}
<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}
>
...
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-xs"> </CommandEmpty>
{/* 그룹 1: 화면 기본 테이블 */}
{screenTableName && (
<CommandGroup heading="기본 (화면 테이블)">
<CommandItem
key={`default-${screenTableName}`}
value={screenTableName}
onSelect={() => {
onChange({
useCustomTable: false,
customTableName: undefined,
tableName: screenTableName,
items: [], // 테이블 변경 시 집계 항목 초기화
});
setTableComboboxOpen(false);
}}
className="text-xs cursor-pointer"
>
<Check
className={cn(
"mr-2 h-3 w-3",
!config.useCustomTable ? "opacity-100" : "opacity-0"
)}
/>
<Database className="mr-2 h-3 w-3 text-primary" />
{screenTableName}
</CommandItem>
</CommandGroup>
)}
{/* 그룹 2: 전체 테이블 */}
<CommandGroup heading="전체 테이블">
{availableTables
.filter((table) => table.tableName !== screenTableName)
.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.tableName} ${table.displayName || ""}`}
onSelect={() => {
onChange({
useCustomTable: true,
customTableName: table.tableName,
tableName: table.tableName,
items: [], // 테이블 변경 시 집계 항목 초기화
});
setTableComboboxOpen(false);
}}
className="text-xs cursor-pointer"
>
<Check
className={cn(
"mr-2 h-3 w-3",
config.customTableName === table.tableName ? "opacity-100" : "opacity-0"
)}
/>
<Table2 className="mr-2 h-3 w-3 text-slate-400" />
{table.displayName || table.tableName}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 레이아웃 설정 */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"></h3>
</div>
<hr className="border-border" />
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={config.layout || "horizontal"}
onValueChange={(value) => onChange({ layout: value as "horizontal" | "vertical" })}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="horizontal"> </SelectItem>
<SelectItem value="vertical"> </SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={config.gap || "16px"}
onChange={(e) => onChange({ gap: e.target.value })}
placeholder="16px"
className="h-8 text-xs"
/>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Checkbox
id="showLabels"
checked={config.showLabels ?? true}
onCheckedChange={(checked) => onChange({ showLabels: checked as boolean })}
/>
<Label htmlFor="showLabels" className="text-xs">
</Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="showIcons"
checked={config.showIcons ?? true}
onCheckedChange={(checked) => onChange({ showIcons: checked as boolean })}
/>
<Label htmlFor="showIcons" className="text-xs">
</Label>
</div>
</div>
</div>
{/* 집계 항목 설정 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold"> </h3>
<Button variant="outline" size="sm" onClick={addItem} className="h-7 text-xs">
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
<hr className="border-border" />
{(config.items || []).length === 0 ? (
<div className="rounded-md border border-dashed p-4 text-center text-xs text-muted-foreground">
</div>
) : (
<div className="space-y-3">
{(config.items || []).map((item, index) => (
<div
key={item.id}
className="rounded-md border bg-slate-50 p-3 space-y-2"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GripVertical className="h-4 w-4 text-muted-foreground cursor-grab" />
<span className="text-xs font-medium"> {index + 1}</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => removeItem(item.id)}
className="h-6 w-6 p-0 text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<div className="grid grid-cols-2 gap-2">
{/* 컬럼 선택 */}
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<Select
value={item.columnName}
onValueChange={(value) => {
const col = columns.find((c) => c.columnName === value);
updateItem(item.id, {
columnName: value,
columnLabel: col?.label || value,
});
}}
disabled={loadingColumns || columns.length === 0}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue placeholder={
loadingColumns
? "로딩 중..."
: columns.length === 0
? "테이블을 선택하세요"
: "컬럼 선택"
} />
</SelectTrigger>
<SelectContent>
{(item.type === "count" ? columns : numericColumns).length === 0 ? (
<div className="p-2 text-xs text-muted-foreground text-center">
{item.type === "count"
? "컬럼이 없습니다"
: "숫자형 컬럼이 없습니다"}
</div>
) : (
(item.type === "count" ? columns : numericColumns).map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.label || col.columnName}
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
{/* 집계 타입 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={item.type}
onValueChange={(value) => updateItem(item.id, { type: value as AggregationType })}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sum"> (SUM)</SelectItem>
<SelectItem value="avg"> (AVG)</SelectItem>
<SelectItem value="count"> (COUNT)</SelectItem>
<SelectItem value="max"> (MAX)</SelectItem>
<SelectItem value="min"> (MIN)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 표시 라벨 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Input
value={item.columnLabel || ""}
onChange={(e) => updateItem(item.id, { columnLabel: e.target.value })}
placeholder="표시될 라벨"
className="h-7 text-xs"
/>
</div>
{/* 표시 형식 */}
<div className="space-y-1">
<Label className="text-[10px]"> </Label>
<Select
value={item.format || "number"}
onValueChange={(value) =>
updateItem(item.id, { format: value as "number" | "currency" | "percent" })
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="number"></SelectItem>
<SelectItem value="currency"></SelectItem>
<SelectItem value="percent"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 접두사 */}
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<Input
value={item.prefix || ""}
onChange={(e) => updateItem(item.id, { prefix: e.target.value })}
placeholder="예: ₩"
className="h-7 text-xs"
/>
</div>
{/* 접미사 */}
<div className="space-y-1">
<Label className="text-[10px]"></Label>
<Input
value={item.suffix || ""}
onChange={(e) => updateItem(item.id, { suffix: e.target.value })}
placeholder="예: 원, 개"
className="h-7 text-xs"
/>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* 스타일 설정 */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"></h3>
</div>
<hr className="border-border" />
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
type="color"
value={config.backgroundColor || "#f8fafc"}
onChange={(e) => onChange({ backgroundColor: e.target.value })}
className="h-8"
/>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={config.borderRadius || "6px"}
onChange={(e) => onChange({ borderRadius: e.target.value })}
placeholder="6px"
className="h-8 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
type="color"
value={config.labelColor || "#64748b"}
onChange={(e) => onChange({ labelColor: e.target.value })}
className="h-8"
/>
</div>
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
type="color"
value={config.valueColor || "#0f172a"}
onChange={(e) => onChange({ valueColor: e.target.value })}
className="h-8"
/>
</div>
</div>
</div>
</div>
);
}
@@ -1,12 +0,0 @@
"use client";
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
import { AggregationWidgetDefinition } from "./index";
// 컴포넌트 자동 등록
if (typeof window !== "undefined") {
ComponentRegistry.registerComponent(AggregationWidgetDefinition);
}
export {};
@@ -1,43 +0,0 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { AggregationWidgetWrapper } from "./AggregationWidgetComponent";
import { AggregationWidgetConfigPanel } from "./AggregationWidgetConfigPanel";
import type { AggregationWidgetConfig } from "./types";
/**
* AggregationWidget
* (, , )
*/
export const AggregationWidgetDefinition = createComponentDefinition({
id: "aggregation-widget",
name: "집계 위젯",
name_eng: "Aggregation Widget",
description: "데이터의 합계, 평균, 개수 등 집계 결과를 표시하는 위젯",
category: ComponentCategory.DISPLAY,
web_type: "text",
component: AggregationWidgetWrapper,
default_config: {
dataSourceType: "manual",
items: [],
layout: "horizontal",
showLabels: true,
showIcons: true,
gap: "16px",
backgroundColor: "#f8fafc",
borderRadius: "6px",
padding: "12px",
} as Partial<AggregationWidgetConfig>,
default_size: { width: 400, height: 60 },
config_panel: AggregationWidgetConfigPanel,
icon: "Calculator",
tags: ["집계", "합계", "평균", "개수", "통계", "데이터"],
version: "1.0.0",
author: "개발팀",
// hidden: true, // v2-aggregation-widget 사용으로 패널에서 숨김
});
// 타입 내보내기
export type { AggregationWidgetConfig, AggregationItem, AggregationType, AggregationResult } from "./types";
@@ -1,67 +0,0 @@
import { ComponentConfig } from "@/types/component";
/**
*
*/
export type AggregationType = "sum" | "avg" | "count" | "max" | "min";
/**
*
*/
export interface AggregationItem {
id: string;
columnName: string; // 집계할 컬럼
columnLabel?: string; // 표시 라벨
labelLangKeyId?: number; // 다국어 키 ID
labelLangKey?: string; // 다국어 키
type: AggregationType; // 집계 타입
format?: "number" | "currency" | "percent"; // 표시 형식
decimalPlaces?: number; // 소수점 자릿수
prefix?: string; // 접두사 (예: "₩")
suffix?: string; // 접미사 (예: "원", "개")
}
/**
*
*/
export interface AggregationWidgetConfig extends ComponentConfig {
// 데이터 소스 설정
dataSourceType: "repeater" | "tableList" | "manual"; // 데이터 소스 타입
dataSourceComponentId?: string; // 연결할 컴포넌트 ID (repeater 또는 tableList)
// 컴포넌트별 테이블 설정 (개발 가이드 준수)
tableName?: string; // 사용할 테이블명
customTableName?: string; // 커스텀 테이블명
useCustomTable?: boolean; // true: customTableName 사용
// 집계 항목들
items: AggregationItem[];
// 레이아웃 설정
layout: "horizontal" | "vertical"; // 배치 방향
showLabels: boolean; // 라벨 표시 여부
showIcons: boolean; // 아이콘 표시 여부
gap?: string; // 항목 간 간격
// 스타일 설정
backgroundColor?: string;
borderRadius?: string;
padding?: string;
fontSize?: string;
labelFontSize?: string;
valueFontSize?: string;
labelColor?: string;
valueColor?: string;
}
/**
*
*/
export interface AggregationResult {
id: string;
label: string;
value: number | string;
formattedValue: string;
type: AggregationType;
}
@@ -24,6 +24,7 @@ import { showErrorToast } from "@/lib/utils/toastUtils";
import { filterDOMProps } from "@/lib/utils/domPropsFilter"; import { filterDOMProps } from "@/lib/utils/domPropsFilter";
import { useCurrentFlowStep } from "@/stores/flowStepStore"; import { useCurrentFlowStep } from "@/stores/flowStepStore";
import { useScreenPreview } from "@/contexts/ScreenPreviewContext"; import { useScreenPreview } from "@/contexts/ScreenPreviewContext";
import { isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
import { useScreenContextOptional } from "@/contexts/ScreenContext"; import { useScreenContextOptional } from "@/contexts/ScreenContext";
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext"; import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
import { applyMappingRules } from "@/lib/utils/dataMapping"; import { applyMappingRules } from "@/lib/utils/dataMapping";
@@ -719,11 +720,11 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
const allProviders = screenContext.getAllDataProviders(); const allProviders = screenContext.getAllDataProviders();
// 테이블 리스트 우선 탐색 // table-like (canonical table / legacy table-list / hidden v2-table-list 등) 우선 탐색
for (const [id, provider] of allProviders) { for (const [id, provider] of allProviders) {
if (provider.component_type === "table-list") { if (isTableLikeComponentType(provider.component_type)) {
sourceProvider = provider; sourceProvider = provider;
console.log(`✅ [ButtonPrimary] 테이블 리스트 자동 발견: ${id}`); console.log(`✅ [ButtonPrimary] 테이블 자동 발견: ${id} (${provider.component_type})`);
break; break;
} }
} }
@@ -69,9 +69,27 @@ export const ContainerComponent: React.FC<ContainerComponentProps> = ({
const componentConfig = { const componentConfig = {
...config, ...config,
...((component as any).config ?? {}), ...((component as any).config ?? {}),
...((component as any).component_config ?? {}),
...((component as any).componentConfig ?? {}), ...((component as any).componentConfig ?? {}),
...fromProps, ...fromProps,
} as ContainerConfig; } as ContainerConfig;
const rawComponentType =
(component as any).componentType ??
(component as any).component_type ??
(component as any).componentId ??
(component as any).component_id ??
(component as any).type;
if (
componentConfig.containerType == null &&
(rawComponentType === "v2-tabs-widget" ||
rawComponentType === "tabs-widget" ||
rawComponentType === "tabs" ||
rawComponentType === "v2-tabs" ||
Array.isArray(componentConfig.tabs))
) {
componentConfig.containerType = "tabs";
}
const containerType: ContainerType = (VALID_TYPES as string[]).includes( const containerType: ContainerType = (VALID_TYPES as string[]).includes(
componentConfig.containerType as string, componentConfig.containerType as string,
+17 -16
View File
@@ -57,31 +57,31 @@ import "./related-data-buttons/RelatedDataButtonsRenderer"; // 좌측 선택 데
// 기존 컴포넌트들 (기존 화면 호환성 유지) // 기존 컴포넌트들 (기존 화면 호환성 유지)
// V2 버전도 별도로 존재하지만, 기존 화면은 이 컴포넌트들을 사용 // V2 버전도 별도로 존재하지만, 기존 화면은 이 컴포넌트들을 사용
// ============================================================ // ============================================================
// ★ 2026-05-19 canonical 정리: alias 라우팅으로 충분한 옛 Renderer 자동등록 제거.
// - aggregation-widget / v2-aggregation-widget / v2-status-count → canonical stats alias
// - table-list / v2-table-list → canonical table alias
// - tabs / v2-tabs-widget → canonical container alias (containerType=tabs)
// - section-card / v2-section-card / section-paper / v2-section-paper → canonical container alias (containerType=section + sectionVariant)
import "./button-primary/ButtonPrimaryRenderer"; import "./button-primary/ButtonPrimaryRenderer";
import "./text-display/TextDisplayRenderer"; import "./text-display/TextDisplayRenderer";
import "./divider-line/DividerLineRenderer"; import "./divider-line/DividerLineRenderer";
import "./table-list/TableListRenderer"; import "./split-panel-layout/SplitPanelLayoutRenderer"; // ★ SplitPanelContext 다수 사용처 + alias 없음 → 보존
import "./split-panel-layout/SplitPanelLayoutRenderer";
// numbering-rule 캔버스 컴포넌트는 폐기 (2026-05-11). admin 페이지 /admin/systemMng/numberingRuleList 로 대체. // numbering-rule 캔버스 컴포넌트는 폐기 (2026-05-11). admin 페이지 /admin/systemMng/numberingRuleList 로 대체.
import "./table-search-widget"; import "./table-search-widget";
import "./repeat-screen-modal/RepeatScreenModalRenderer"; import "./repeat-screen-modal/RepeatScreenModalRenderer";
import "./section-paper/SectionPaperRenderer"; // section-paper / section-card / tabs / aggregation-widget → canonical container/stats alias 로 라우팅 (auto-register 제거)
import "./section-card/SectionCardRenderer";
import "./tabs/tabs-component";
import "./location-swap-selector/LocationSwapSelectorRenderer"; import "./location-swap-selector/LocationSwapSelectorRenderer";
import "./rack-structure/RackStructureRenderer"; import "./rack-structure/RackStructureRenderer";
import "./aggregation-widget/AggregationWidgetRenderer"; import "./repeat-container/RepeatContainerRenderer"; // canonical container.containerType=repeater skeleton 부족 → 보존
import "./repeat-container/RepeatContainerRenderer";
// ============================================================ // ============================================================
// V2 컴포넌트들 (화면관리 전용 - 충돌 방지용 별도 버전) // V2 컴포넌트들 (화면관리 전용 - 충돌 방지용 별도 버전)
// ============================================================ // ============================================================
import "./v2-repeater/V2RepeaterRenderer"; import "./v2-repeater/V2RepeaterRenderer"; // basicV2Components palette item — 보존
import "./v2-button-primary/ButtonPrimaryRenderer"; import "./v2-button-primary/ButtonPrimaryRenderer";
import "./v2-split-panel-layout/SplitPanelLayoutRenderer"; import "./v2-split-panel-layout/SplitPanelLayoutRenderer"; // alias 없음 → 보존
import "./v2-aggregation-widget/AggregationWidgetRenderer"; // v2-aggregation-widget → canonical stats alias 로 라우팅 (auto-register 제거)
// v2-numbering-rule 캔버스 컴포넌트는 폐기 (2026-05-11). admin 페이지 /admin/systemMng/numberingRuleList 로 대체. // v2-numbering-rule 캔버스 컴포넌트는 폐기 (2026-05-11). admin 페이지 /admin/systemMng/numberingRuleList 로 대체.
import "./v2-table-list/TableListRenderer";
import "./v2-text-display/TextDisplayRenderer"; import "./v2-text-display/TextDisplayRenderer";
import "./v2-divider-line/DividerLineRenderer"; import "./v2-divider-line/DividerLineRenderer";
@@ -105,20 +105,21 @@ import "./grouped-table/GroupedTableRenderer"; // Phase G.3.1 — canonical 그
// 관련: notes/gbpark/2026-04-11-component-unification-plan.md §3.2 // 관련: notes/gbpark/2026-04-11-component-unification-plan.md §3.2
import "./table/TableRenderer"; // v2-table-list + v2-table-grouped + v2-pivot-grid + v2-split-panel-layout + legacy 9종 흡수 import "./table/TableRenderer"; // v2-table-list + v2-table-grouped + v2-pivot-grid + v2-split-panel-layout + legacy 9종 흡수
import "./container/ContainerRenderer"; // v2-tabs-widget + v2-section-card/paper + v2-repeat-container + accordion + conditional + legacy 11종 흡수 import "./container/ContainerRenderer"; // v2-tabs-widget + v2-section-card/paper + v2-repeat-container + accordion + conditional + legacy 11종 흡수
import "./v2-repeat-container/RepeatContainerRenderer"; import "./v2-repeat-container/RepeatContainerRenderer"; // canonical container.containerType=repeater skeleton 부족 → 보존
import "./v2-section-card/SectionCardRenderer"; // v2-section-card / v2-section-paper → canonical container alias (containerType=section + sectionVariant) 로 라우팅 (auto-register 제거)
import "./v2-section-paper/SectionPaperRenderer";
import "./domain/v2-rack-structure/RackStructureRenderer"; import "./domain/v2-rack-structure/RackStructureRenderer";
import "./domain/v2-location-swap-selector/LocationSwapSelectorRenderer"; import "./domain/v2-location-swap-selector/LocationSwapSelectorRenderer";
import "./v2-table-search-widget"; import "./v2-table-search-widget";
import "./v2-tabs-widget/tabs-component"; // v2-tabs-widget → canonical container alias (containerType=tabs) 로 라우팅 (auto-register 제거)
// v2-media / v2-file-upload renderer — Phase D.5 폐기. canonical input (FilePicker) 으로 흡수. // v2-media / v2-file-upload renderer — Phase D.5 폐기. canonical input (FilePicker) 으로 흡수.
import "./domain/v2-timeline-scheduler/TimelineSchedulerRenderer"; // 타임라인 스케줄러 import "./domain/v2-timeline-scheduler/TimelineSchedulerRenderer"; // 타임라인 스케줄러
import "./v2-split-line/SplitLineRenderer"; // V2 캔버스 분할선 import "./v2-split-line/SplitLineRenderer"; // V2 캔버스 분할선
import "./domain/v2-bom-tree/BomTreeRenderer"; // BOM 트리 뷰 import "./domain/v2-bom-tree/BomTreeRenderer"; // BOM 트리 뷰
import "./domain/v2-bom-item-editor/BomItemEditorRenderer"; // BOM 하위품목 편집기 import "./domain/v2-bom-item-editor/BomItemEditorRenderer"; // BOM 하위품목 편집기
import "./domain/v2-approval-step/ApprovalStepRenderer"; // 결재 단계 시각화 import "./domain/v2-approval-step/ApprovalStepRenderer"; // 결재 단계 시각화
import "./v2-status-count/StatusCountRenderer"; // 상태별 카운트 카드 // v2-status-count → canonical stats alias 로 라우팅 (auto-register 제거).
// relationColumn / parentColumn 부모 row 컨텍스트는 canonical stats DataPort 로
// 안전 매핑 안 됨 (Goal §3.1 stop condition). 옛 화면은 빈 stats 로 렌더.
import "./domain/v2-process-work-standard/ProcessWorkStandardRenderer"; // 공정 작업기준 import "./domain/v2-process-work-standard/ProcessWorkStandardRenderer"; // 공정 작업기준
import "./domain/v2-item-routing/ItemRoutingRenderer"; // 품목별 라우팅 import "./domain/v2-item-routing/ItemRoutingRenderer"; // 품목별 라우팅
import "./domain/v2-shipping-plan-editor/ShippingPlanEditorRenderer"; // 출하계획 동시등록 import "./domain/v2-shipping-plan-editor/ShippingPlanEditorRenderer"; // 출하계획 동시등록
@@ -153,12 +153,19 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
const embeddedScreenIds = new Set<number>(); const embeddedScreenIds = new Set<number>();
// 컴포넌트에서 임베드된 화면 ID 수집 // 컴포넌트에서 임베드된 화면 ID 수집
// ★ 2026-05-19 tabs 탐색 helper — legacy tabs-widget/v2-tabs-widget + canonical container(containerType=tabs)
const isTabsLikeForEmbedded = (ct: string | undefined, cfg: any): boolean =>
ct === "tabs-widget" ||
ct === "v2-tabs-widget" ||
(ct === "container" && ((cfg?.containerType ?? "section") === "tabs"));
const findEmbeddedScreens = (comps: any[]) => { const findEmbeddedScreens = (comps: any[]) => {
for (const comp of comps) { for (const comp of comps) {
const config = comp.componentConfig || {}; const config = comp.componentConfig || comp.component_config || {};
const componentType = comp.componentType || comp.component_type;
// TabsWidget의 탭들
if (comp.componentType === "tabs-widget" && config.tabs) { // TabsWidget / canonical container tabs 의 탭들
if (isTabsLikeForEmbedded(componentType, config) && config.tabs) {
for (const tab of config.tabs) { for (const tab of config.tabs) {
if (tab.screen_id) { if (tab.screen_id) {
embeddedScreenIds.add(tab.screen_id); embeddedScreenIds.add(tab.screen_id);
@@ -166,7 +173,7 @@ export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSpl
} }
} }
} }
// ScreenSplitPanel // ScreenSplitPanel
if (comp.componentType === "screen-split-panel") { if (comp.componentType === "screen-split-panel") {
if (config.leftScreenId) embeddedScreenIds.add(config.leftScreenId); if (config.leftScreenId) embeddedScreenIds.add(config.leftScreenId);
@@ -1,178 +0,0 @@
"use client";
import React from "react";
import { cn } from "@/lib/utils";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export interface SectionCardProps {
component?: {
id: string;
componentConfig?: {
title?: string;
description?: string;
showHeader?: boolean;
headerPosition?: "top" | "left";
padding?: "none" | "sm" | "md" | "lg";
backgroundColor?: "default" | "muted" | "transparent";
borderStyle?: "solid" | "dashed" | "none";
collapsible?: boolean;
defaultOpen?: boolean;
};
style?: React.CSSProperties;
};
children?: React.ReactNode;
className?: string;
onClick?: (e?: React.MouseEvent) => void;
isSelected?: boolean;
isDesignMode?: boolean;
}
/**
* Section Card
*
*/
export function SectionCardComponent({
component,
children,
className,
onClick,
isSelected = false,
isDesignMode = false,
}: SectionCardProps) {
const config = component?.componentConfig || {};
const [isOpen, setIsOpen] = React.useState(config.defaultOpen !== false);
// 🔄 실시간 업데이트를 위해 config에서 직접 읽기
const title = config.title || "";
const description = config.description || "";
const showHeader = config.showHeader !== false; // 기본값: true
const padding = config.padding || "md";
const backgroundColor = config.backgroundColor || "default";
const borderStyle = config.borderStyle || "solid";
const collapsible = config.collapsible || false;
// 🎯 디버깅: config 값 확인
React.useEffect(() => {
console.log("✅ Section Card Config:", {
title,
description,
showHeader,
fullConfig: config,
});
}, [config.title, config.description, config.showHeader]);
// 패딩 매핑
const paddingMap = {
none: "p-0",
sm: "p-3",
md: "p-6",
lg: "p-8",
};
// 배경색 매핑
const backgroundColorMap = {
default: "bg-card",
muted: "bg-muted/30",
transparent: "bg-transparent",
};
// 테두리 스타일 매핑
const borderStyleMap = {
solid: "border-solid",
dashed: "border-dashed",
none: "border-none",
};
const handleToggle = () => {
if (collapsible) {
setIsOpen(!isOpen);
}
};
return (
<Card
className={cn(
"transition-all",
backgroundColorMap[backgroundColor],
borderStyleMap[borderStyle],
borderStyle === "none" && "shadow-none",
isDesignMode && isSelected && "ring-2 ring-primary ring-offset-2",
isDesignMode && !children && "min-h-[150px]",
className
)}
style={component?.style}
onClick={onClick}
>
{/* 헤더 */}
{showHeader && (title || description || isDesignMode) && (
<CardHeader
className={cn(
"cursor-pointer",
collapsible && "hover:bg-accent/50 transition-colors"
)}
onClick={handleToggle}
>
<div className="flex items-center justify-between">
<div className="flex-1">
{(title || isDesignMode) && (
<CardTitle className="text-xl font-semibold">
{title || (isDesignMode ? "섹션 제목" : "")}
</CardTitle>
)}
{(description || isDesignMode) && (
<CardDescription className="text-sm text-muted-foreground mt-1.5">
{description || (isDesignMode ? "섹션 설명 (선택사항)" : "")}
</CardDescription>
)}
</div>
{collapsible && (
<div className={cn(
"ml-4 transition-transform",
isOpen ? "rotate-180" : "rotate-0"
)}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="m6 9 6 6 6-6" />
</svg>
</div>
)}
</div>
</CardHeader>
)}
{/* 컨텐츠 */}
{(!collapsible || isOpen) && (
<CardContent className={cn(paddingMap[padding])}>
{/* 디자인 모드에서 빈 상태 안내 */}
{isDesignMode && !children && (
<div className="flex items-center justify-center py-12 text-muted-foreground text-sm">
<div className="text-center">
<div className="mb-2">🃏 Section Card</div>
<div className="text-xs"> </div>
</div>
</div>
)}
{/* 자식 컴포넌트들 */}
{children}
</CardContent>
)}
</Card>
);
}
@@ -1,172 +0,0 @@
"use client";
import React from "react";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
import { Textarea } from "@/components/ui/textarea";
interface SectionCardConfigPanelProps {
config: any;
onChange: (config: any) => void;
}
export function SectionCardConfigPanel({
config,
onChange,
}: SectionCardConfigPanelProps) {
const handleChange = (key: string, value: any) => {
const newConfig = {
...config,
[key]: value,
};
onChange(newConfig);
// 🎯 실시간 업데이트를 위한 이벤트 발생
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("componentConfigChanged", {
detail: { config: newConfig }
}));
}
};
return (
<div className="space-y-4 p-4">
<div className="space-y-2">
<h3 className="text-sm font-semibold">Section Card </h3>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 헤더 표시 */}
<div className="flex items-center space-x-2">
<Checkbox
id="showHeader"
checked={config.showHeader !== false}
onCheckedChange={(checked) => handleChange("showHeader", checked)}
/>
<Label htmlFor="showHeader" className="text-xs cursor-pointer">
</Label>
</div>
{/* 제목 */}
{config.showHeader !== false && (
<div className="space-y-2">
<Label className="text-xs"></Label>
<Input
value={config.title || ""}
onChange={(e) => handleChange("title", e.target.value)}
placeholder="섹션 제목 입력"
className="h-9 text-xs"
/>
</div>
)}
{/* 설명 */}
{config.showHeader !== false && (
<div className="space-y-2">
<Label className="text-xs"> ()</Label>
<Textarea
value={config.description || ""}
onChange={(e) => handleChange("description", e.target.value)}
placeholder="섹션 설명 입력"
className="text-xs resize-none"
rows={2}
/>
</div>
)}
{/* 패딩 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={config.padding || "md"}
onValueChange={(value) => handleChange("padding", value)}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="sm"> (12px)</SelectItem>
<SelectItem value="md"> (24px)</SelectItem>
<SelectItem value="lg"> (32px)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 배경색 */}
<div className="space-y-2">
<Label className="text-xs"></Label>
<Select
value={config.backgroundColor || "default"}
onValueChange={(value) => handleChange("backgroundColor", value)}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default"> ()</SelectItem>
<SelectItem value="muted"></SelectItem>
<SelectItem value="transparent"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 테두리 스타일 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={config.borderStyle || "solid"}
onValueChange={(value) => handleChange("borderStyle", value)}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="solid"></SelectItem>
<SelectItem value="dashed"></SelectItem>
<SelectItem value="none"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 접기/펼치기 기능 */}
<div className="space-y-2 pt-2 border-t">
<div className="flex items-center space-x-2">
<Checkbox
id="collapsible"
checked={config.collapsible || false}
onCheckedChange={(checked) => handleChange("collapsible", checked)}
/>
<Label htmlFor="collapsible" className="text-xs cursor-pointer">
/
</Label>
</div>
{config.collapsible && (
<div className="flex items-center space-x-2 ml-6">
<Checkbox
id="defaultOpen"
checked={config.defaultOpen !== false}
onCheckedChange={(checked) => handleChange("defaultOpen", checked)}
/>
<Label htmlFor="defaultOpen" className="text-xs cursor-pointer">
</Label>
</div>
)}
</div>
</div>
);
}
@@ -1,27 +0,0 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { SectionCardDefinition } from "./index";
import { SectionCardComponent } from "./SectionCardComponent";
/**
* Section Card
*
*/
export class SectionCardRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = SectionCardDefinition;
render(): React.ReactElement {
return <SectionCardComponent {...(this.props as any)} renderer={this} />;
}
}
// 자동 등록 실행
SectionCardRenderer.registerSelf();
// Hot Reload 지원 (개발 모드)
if (process.env.NODE_ENV === "development") {
SectionCardRenderer.enableHotReload();
}
@@ -1,44 +0,0 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { SectionCardComponent } from "./SectionCardComponent";
import { SectionCardConfigPanel } from "./SectionCardConfigPanel";
/**
* Section Card
*
*/
export const SectionCardDefinition = createComponentDefinition({
id: "section-card",
name: "Section Card",
name_eng: "Section Card",
description: "제목과 테두리가 있는 명확한 그룹화 컨테이너",
category: ComponentCategory.LAYOUT,
web_type: "custom",
component: SectionCardComponent,
default_config: {
title: "섹션 제목",
description: "",
showHeader: true,
padding: "md",
backgroundColor: "default",
borderStyle: "solid",
collapsible: false,
defaultOpen: true,
},
default_size: { width: 800, height: 250 },
config_panel: SectionCardConfigPanel,
icon: "LayoutPanelTop",
tags: ["섹션", "그룹", "카드", "컨테이너", "제목", "card"],
version: "1.0.0",
author: "Invyone",
hidden: true, // v2-section-card 사용으로 패널에서 숨김
});
// 컴포넌트는 SectionCardRenderer에서 자동 등록됩니다
export { SectionCardComponent } from "./SectionCardComponent";
export { SectionCardConfigPanel } from "./SectionCardConfigPanel";
export { SectionCardRenderer } from "./SectionCardRenderer";
@@ -1,148 +0,0 @@
"use client";
import React from "react";
import { cn } from "@/lib/utils";
export interface SectionPaperProps {
component?: {
id: string;
componentConfig?: {
backgroundColor?: "default" | "muted" | "accent" | "primary" | "custom";
customColor?: string;
showBorder?: boolean;
borderStyle?: "none" | "subtle";
padding?: "none" | "sm" | "md" | "lg";
roundedCorners?: "none" | "sm" | "md" | "lg";
shadow?: "none" | "sm" | "md";
};
style?: React.CSSProperties;
};
children?: React.ReactNode;
className?: string;
onClick?: (e?: React.MouseEvent) => void;
isSelected?: boolean;
isDesignMode?: boolean;
}
/**
* Section Paper
* ( )
*/
export function SectionPaperComponent({
component,
children,
className,
onClick,
isSelected = false,
isDesignMode = false,
}: SectionPaperProps) {
const config = component?.componentConfig || {};
// 배경색 매핑
const backgroundColorMap = {
default: "bg-muted/40",
muted: "bg-muted/50",
accent: "bg-accent/30",
primary: "bg-primary/10",
custom: "",
};
// 패딩 매핑
const paddingMap = {
none: "p-0",
sm: "p-3",
md: "p-4",
lg: "p-6",
};
// 둥근 모서리 매핑
const roundedMap = {
none: "rounded-none",
sm: "rounded-sm",
md: "rounded-md",
lg: "rounded-lg",
};
// 그림자 매핑
const shadowMap = {
none: "",
sm: "shadow-sm",
md: "shadow-md",
};
const backgroundColor = config.backgroundColor || "default";
const padding = config.padding || "md";
const rounded = config.roundedCorners || "md";
const shadow = config.shadow || "none";
const showBorder = config.showBorder !== undefined ? config.showBorder : true;
const borderStyle = config.borderStyle || "subtle";
// 커스텀 배경색 처리
const customBgStyle =
backgroundColor === "custom" && config.customColor
? { backgroundColor: config.customColor }
: {};
// 선택 상태 테두리 처리 (outline 사용하여 크기 영향 없음)
const selectionStyle = isDesignMode && isSelected
? {
outline: "2px solid #3b82f6",
outlineOffset: "0px", // 크기에 영향 없이 딱 맞게 표시
}
: {};
return (
<div
className={cn(
// 기본 스타일
"relative transition-colors",
// 높이 고정을 위한 overflow 처리
"overflow-auto",
// 배경색
backgroundColor !== "custom" && backgroundColorMap[backgroundColor],
// 패딩
paddingMap[padding],
// 둥근 모서리
roundedMap[rounded],
// 그림자
shadowMap[shadow],
// 테두리 (선택 상태가 아닐 때만)
!isSelected && showBorder &&
borderStyle === "subtle" &&
"border border-border/30",
// 디자인 모드에서 빈 상태 표시 (테두리만, 최소 높이 제거)
isDesignMode && !children && "border-2 border-dashed border-muted-foreground/30",
className
)}
style={{
// 크기를 100%로 설정하여 부모 크기에 맞춤
width: "100%",
height: "100%",
boxSizing: "border-box", // padding과 border를 크기에 포함
...customBgStyle,
...selectionStyle,
...component?.style, // 사용자 설정이 최종 우선순위
}}
onClick={onClick}
>
{/* 자식 컴포넌트들 */}
{children || (isDesignMode && (
<div className="flex items-center justify-center h-full text-muted-foreground text-sm">
<div className="text-center">
<div className="mb-1">📄 Section Paper</div>
<div className="text-xs"> </div>
</div>
</div>
))}
</div>
);
}
@@ -1,151 +0,0 @@
"use client";
import React from "react";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Checkbox } from "@/components/ui/checkbox";
interface SectionPaperConfigPanelProps {
config: any;
onChange: (config: any) => void;
}
export function SectionPaperConfigPanel({
config,
onChange,
}: SectionPaperConfigPanelProps) {
const handleChange = (key: string, value: any) => {
const newConfig = {
...config,
[key]: value,
};
onChange(newConfig);
// 🎯 실시간 업데이트를 위한 이벤트 발생
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("componentConfigChanged", {
detail: { config: newConfig }
}));
}
};
return (
<div className="space-y-4 p-4">
<div className="space-y-2">
<h3 className="text-sm font-semibold">Section Paper </h3>
<p className="text-xs text-muted-foreground">
</p>
</div>
{/* 배경색 */}
<div className="space-y-2">
<Label className="text-xs"></Label>
<Select
value={config.backgroundColor || "default"}
onValueChange={(value) => handleChange("backgroundColor", value)}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="default"> ( )</SelectItem>
<SelectItem value="muted"></SelectItem>
<SelectItem value="accent"> ( )</SelectItem>
<SelectItem value="primary"> </SelectItem>
<SelectItem value="custom"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 커스텀 색상 */}
{config.backgroundColor === "custom" && (
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Input
type="color"
value={config.customColor || "#f0f0f0"}
onChange={(e) => handleChange("customColor", e.target.value)}
className="h-9"
/>
</div>
)}
{/* 패딩 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={config.padding || "md"}
onValueChange={(value) => handleChange("padding", value)}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="sm"> (12px)</SelectItem>
<SelectItem value="md"> (16px)</SelectItem>
<SelectItem value="lg"> (24px)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 둥근 모서리 */}
<div className="space-y-2">
<Label className="text-xs"> </Label>
<Select
value={config.roundedCorners || "md"}
onValueChange={(value) => handleChange("roundedCorners", value)}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="sm"> (2px)</SelectItem>
<SelectItem value="md"> (6px)</SelectItem>
<SelectItem value="lg"> (8px)</SelectItem>
</SelectContent>
</Select>
</div>
{/* 그림자 */}
<div className="space-y-2">
<Label className="text-xs"></Label>
<Select
value={config.shadow || "none"}
onValueChange={(value) => handleChange("shadow", value)}
>
<SelectTrigger className="h-9 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
<SelectItem value="sm"></SelectItem>
<SelectItem value="md"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 테두리 표시 */}
<div className="flex items-center space-x-2">
<Checkbox
id="showBorder"
checked={config.showBorder || false}
onCheckedChange={(checked) => handleChange("showBorder", checked)}
/>
<Label htmlFor="showBorder" className="text-xs cursor-pointer">
</Label>
</div>
</div>
);
}
@@ -1,28 +0,0 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { SectionPaperDefinition } from "./index";
import { SectionPaperComponent } from "./SectionPaperComponent";
/**
* Section Paper
*
*/
export class SectionPaperRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = SectionPaperDefinition;
render(): React.ReactElement {
return <SectionPaperComponent {...(this.props as any)} renderer={this} />;
}
}
// 자동 등록 실행
SectionPaperRenderer.registerSelf();
// Hot Reload 지원 (개발 모드)
if (process.env.NODE_ENV === "development") {
SectionPaperRenderer.enableHotReload();
}
@@ -1,41 +0,0 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { SectionPaperComponent } from "./SectionPaperComponent";
import { SectionPaperConfigPanel } from "./SectionPaperConfigPanel";
/**
* Section Paper
* ( )
*/
export const SectionPaperDefinition = createComponentDefinition({
id: "section-paper",
name: "Section Paper",
name_eng: "Section Paper",
description: "배경색 기반의 미니멀한 그룹화 컨테이너 (색종이 컨셉)",
category: ComponentCategory.LAYOUT,
web_type: "custom",
component: SectionPaperComponent,
default_config: {
backgroundColor: "default",
padding: "md",
roundedCorners: "md",
shadow: "none",
showBorder: false,
},
default_size: { width: 800, height: 200 },
config_panel: SectionPaperConfigPanel,
icon: "Square",
tags: ["섹션", "그룹", "배경", "컨테이너", "색종이", "paper"],
version: "1.0.0",
author: "Invyone",
hidden: true, // v2-section-paper 사용으로 패널에서 숨김
});
// 컴포넌트는 SectionPaperRenderer에서 자동 등록됩니다
export { SectionPaperComponent } from "./SectionPaperComponent";
export { SectionPaperConfigPanel } from "./SectionPaperConfigPanel";
export { SectionPaperRenderer } from "./SectionPaperRenderer";
@@ -1,220 +0,0 @@
"use client";
import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Eye, Edit, Trash2, MoreHorizontal } from "lucide-react";
import { CardDisplayConfig, ColumnConfig } from "./types";
interface CardModeRendererProps {
data: Record<string, any>[];
cardConfig: CardDisplayConfig;
visibleColumns: ColumnConfig[];
onRowClick?: (row: Record<string, any>, index: number, e: React.MouseEvent) => void;
onRowSelect?: (row: Record<string, any>, selected: boolean) => void;
selectedRows?: string[];
}
/**
*
*
*/
export const CardModeRenderer: React.FC<CardModeRendererProps> = ({
data,
cardConfig,
visibleColumns,
onRowClick,
selectedRows = [],
}) => {
// 기본값과 병합
const config = {
idColumn: cardConfig?.idColumn || "",
titleColumn: cardConfig?.titleColumn || "",
subtitleColumn: cardConfig?.subtitleColumn,
descriptionColumn: cardConfig?.descriptionColumn,
imageColumn: cardConfig?.imageColumn,
cardsPerRow: cardConfig?.cardsPerRow ?? 3,
cardSpacing: cardConfig?.cardSpacing ?? 16,
showActions: cardConfig?.showActions ?? true,
cardHeight: cardConfig?.cardHeight as number | "auto" | undefined,
};
// 디버깅: cardConfig 확인
console.log("🃏 CardModeRenderer config:", { cardConfig, mergedConfig: config });
// 카드 그리드 스타일 계산
const gridStyle: React.CSSProperties = {
display: "grid",
gridTemplateColumns: `repeat(${config.cardsPerRow}, 1fr)`,
gap: `${config.cardSpacing}px`,
padding: `${config.cardSpacing}px`,
overflow: "auto",
};
// 카드 높이 스타일
const cardStyle: React.CSSProperties = {
height: config.cardHeight === "auto" ? "auto" : `${config.cardHeight}px`,
cursor: onRowClick ? "pointer" : "default",
};
// 컬럼 값 가져오기 함수
const getColumnValue = (row: Record<string, any>, columnName?: string): string => {
if (!columnName || !row) return "";
return String(row[columnName] || "");
};
// 액션 버튼 렌더링
const renderActions = (_row: Record<string, any>) => {
if (!config.showActions) return null;
return (
<div className="mt-3 flex items-center justify-end space-x-1 border-t border-border pt-3">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
// 상세보기 액션
}}
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
// 편집 액션
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
// 삭제 액션
}}
>
<Trash2 className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
// 더보기 액션
}}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</div>
);
};
// 데이터가 없는 경우
if (!data || data.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="bg-muted mb-4 flex h-16 w-16 items-center justify-center rounded-2xl">
<div className="bg-muted-foreground/20 h-8 w-8 rounded-lg"></div>
</div>
<div className="text-muted-foreground mb-1 text-sm font-medium"> </div>
<div className="text-muted-foreground/60 text-xs"> </div>
</div>
);
}
return (
<div style={gridStyle} className="w-full">
{data.map((row, index) => {
const idValue = getColumnValue(row, config.idColumn);
const titleValue = getColumnValue(row, config.titleColumn);
const subtitleValue = getColumnValue(row, config.subtitleColumn);
const descriptionValue = getColumnValue(row, config.descriptionColumn);
const imageValue = getColumnValue(row, config.imageColumn);
const isSelected = selectedRows.includes(idValue);
return (
<Card
key={`card-${index}-${idValue}`}
style={cardStyle}
className={`transition-all duration-200 hover:shadow-md ${
isSelected ? "bg-primary/10/30 ring-2 ring-ring" : ""
}`}
onClick={(e) => onRowClick?.(row, index, e)}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<CardTitle className="truncate text-sm font-medium">{titleValue || "제목 없음"}</CardTitle>
{subtitleValue && <div className="mt-1 truncate text-xs text-muted-foreground">{subtitleValue}</div>}
</div>
{/* ID 뱃지 */}
{idValue && (
<Badge variant="secondary" className="ml-2 text-xs">
{idValue}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="pt-0">
{/* 이미지 표시 */}
{imageValue && (
<div className="mb-3">
<img
src={imageValue}
alt={titleValue}
className="h-24 w-full rounded-md bg-muted object-cover"
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = "none";
}}
/>
</div>
)}
{/* 설명 표시 */}
{descriptionValue && <div className="mb-3 line-clamp-2 text-xs text-muted-foreground">{descriptionValue}</div>}
{/* 추가 필드들 표시 (선택적) */}
<div className="space-y-1">
{(visibleColumns || [])
.filter(
(col) =>
col.columnName !== config.idColumn &&
col.columnName !== config.titleColumn &&
col.columnName !== config.subtitleColumn &&
col.columnName !== config.descriptionColumn &&
col.columnName !== config.imageColumn &&
col.columnName !== "__checkbox__" &&
col.visible,
)
.slice(0, 3) // 최대 3개 추가 필드만 표시
.map((col) => {
const value = getColumnValue(row, col.columnName);
if (!value) return null;
return (
<div key={col.columnName} className="flex items-center justify-between text-xs">
<span className="truncate text-muted-foreground">{col.displayName}:</span>
<span className="ml-2 truncate font-medium">{value}</span>
</div>
);
})}
</div>
{/* 액션 버튼들 */}
{renderActions(row)}
</CardContent>
</Card>
);
})}
</div>
);
};
@@ -1,241 +0,0 @@
# TableList 컴포넌트
데이터베이스 테이블의 데이터를 목록으로 표시하는 고급 테이블 컴포넌트
## 개요
- **ID**: `table-list`
- **카테고리**: display
- **웹타입**: table
- **작성자**: 개발팀
- **버전**: 1.0.0
## 특징
- ✅ **동적 테이블 연동**: 데이터베이스 테이블 자동 로드
- ✅ **고급 페이지네이션**: 대용량 데이터 효율적 처리
- ✅ **실시간 검색**: 빠른 데이터 검색 및 필터링
- ✅ **컬럼 커스터마이징**: 표시/숨김, 순서 변경, 정렬
- ✅ **정렬 기능**: 컬럼별 오름차순/내림차순 정렬
- ✅ **반응형 디자인**: 다양한 화면 크기 지원
- ✅ **다양한 테마**: 기본, 줄무늬, 테두리, 미니멀 테마
- ✅ **실시간 새로고침**: 데이터 자동/수동 새로고침
## 사용법
### 기본 사용법
```tsx
import { TableListComponent } from "@/lib/registry/components/table-list";
<TableListComponent
component={{
id: "my-table-list",
type: "widget",
webType: "table",
position: { x: 100, y: 100, z: 1 },
size: { width: 800, height: 400 },
config: {
selectedTable: "users",
title: "사용자 목록",
showHeader: true,
showFooter: true,
autoLoad: true,
pagination: {
enabled: true,
pageSize: 20,
showSizeSelector: true,
showPageInfo: true,
pageSizeOptions: [10, 20, 50, 100],
},
filter: {
enabled: true,
quickSearch: true,
advancedFilter: false,
},
},
}}
isDesignMode={false}
/>;
```
## 주요 설정 옵션
### 기본 설정
| 속성 | 타입 | 기본값 | 설명 |
| ------------- | ------------------------------- | ------ | ---------------------------- |
| selectedTable | string | - | 표시할 데이터베이스 테이블명 |
| title | string | - | 테이블 제목 |
| showHeader | boolean | true | 헤더 표시 여부 |
| showFooter | boolean | true | 푸터 표시 여부 |
| autoLoad | boolean | true | 자동 데이터 로드 |
| height | "auto" \| "fixed" \| "viewport" | "auto" | 높이 설정 모드 |
| fixedHeight | number | 400 | 고정 높이 (px) |
### 페이지네이션 설정
| 속성 | 타입 | 기본값 | 설명 |
| --------------------------- | -------- | -------------- | ----------------------- |
| pagination.enabled | boolean | true | 페이지네이션 사용 여부 |
| pagination.pageSize | number | 20 | 페이지당 표시 항목 수 |
| pagination.showSizeSelector | boolean | true | 페이지 크기 선택기 표시 |
| pagination.showPageInfo | boolean | true | 페이지 정보 표시 |
| pagination.pageSizeOptions | number[] | [10,20,50,100] | 선택 가능한 페이지 크기 |
### 컬럼 설정
| 속성 | 타입 | 설명 |
| --------------------- | ------------------------------------------------------- | ------------------- |
| columns | ColumnConfig[] | 컬럼 설정 배열 |
| columns[].columnName | string | 데이터베이스 컬럼명 |
| columns[].displayName | string | 화면 표시명 |
| columns[].visible | boolean | 표시 여부 |
| columns[].sortable | boolean | 정렬 가능 여부 |
| columns[].searchable | boolean | 검색 가능 여부 |
| columns[].align | "left" \| "center" \| "right" | 텍스트 정렬 |
| columns[].format | "text" \| "number" \| "date" \| "currency" \| "boolean" | 데이터 형식 |
| columns[].width | number | 컬럼 너비 (px) |
| columns[].order | number | 표시 순서 |
### 필터 설정
| 속성 | 타입 | 기본값 | 설명 |
| ------------------------ | -------- | ------ | ------------------- |
| filter.enabled | boolean | true | 필터 기능 사용 여부 |
| filter.quickSearch | boolean | true | 빠른 검색 사용 여부 |
| filter.advancedFilter | boolean | false | 고급 필터 사용 여부 |
| filter.filterableColumns | string[] | [] | 필터 가능 컬럼 목록 |
### 스타일 설정
| 속성 | 타입 | 기본값 | 설명 |
| ------------------------ | ------------------------------------------------- | --------- | ------------------- |
| tableStyle.theme | "default" \| "striped" \| "bordered" \| "minimal" | "default" | 테이블 테마 |
| tableStyle.headerStyle | "default" \| "dark" \| "light" | "default" | 헤더 스타일 |
| tableStyle.rowHeight | "compact" \| "normal" \| "comfortable" | "normal" | 행 높이 |
| tableStyle.alternateRows | boolean | true | 교대로 행 색상 변경 |
| tableStyle.hoverEffect | boolean | true | 마우스 오버 효과 |
| tableStyle.borderStyle | "none" \| "light" \| "heavy" | "light" | 테두리 스타일 |
| stickyHeader | boolean | false | 헤더 고정 |
## 이벤트
- `onRowClick`: 행 클릭 시
- `onRowDoubleClick`: 행 더블클릭 시
- `onSelectionChange`: 선택 변경 시
- `onPageChange`: 페이지 변경 시
- `onSortChange`: 정렬 변경 시
- `onFilterChange`: 필터 변경 시
- `onRefresh`: 새로고침 시
## API 연동
### 테이블 목록 조회
```
GET /api/tables
```
### 테이블 컬럼 정보 조회
```
GET /api/tables/{tableName}/columns
```
### 테이블 데이터 조회
```
GET /api/tables/{tableName}/data?page=1&limit=20&search=&sortBy=&sortDirection=
```
## 사용 예시
### 1. 기본 사용자 목록
```tsx
<TableListComponent
component={{
id: "user-list",
config: {
selectedTable: "users",
title: "사용자 관리",
pagination: { enabled: true, pageSize: 25 },
filter: { enabled: true, quickSearch: true },
columns: [
{ columnName: "id", displayName: "ID", visible: true, sortable: true },
{ columnName: "name", displayName: "이름", visible: true, sortable: true },
{ columnName: "email", displayName: "이메일", visible: true, sortable: true },
{ columnName: "created_at", displayName: "가입일", visible: true, format: "date" },
],
},
}}
/>
```
### 2. 매출 데이터 (통화 형식)
```tsx
<TableListComponent
component={{
id: "sales-list",
config: {
selectedTable: "sales",
title: "매출 현황",
tableStyle: { theme: "striped", rowHeight: "comfortable" },
columns: [
{ columnName: "product_name", displayName: "상품명", visible: true },
{ columnName: "amount", displayName: "금액", visible: true, format: "currency", align: "right" },
{ columnName: "quantity", displayName: "수량", visible: true, format: "number", align: "center" },
],
},
}}
/>
```
### 3. 고정 높이 테이블
```tsx
<TableListComponent
component={{
id: "fixed-table",
config: {
selectedTable: "products",
height: "fixed",
fixedHeight: 300,
stickyHeader: true,
pagination: { enabled: false },
},
}}
/>
```
## 상세설정 패널
컴포넌트 설정 패널은 5개의 탭으로 구성되어 있습니다:
1. **기본 탭**: 테이블 선택, 제목, 표시 설정, 높이, 페이지네이션
2. **컬럼 탭**: 컬럼 추가/제거, 표시 설정, 순서 변경, 형식 지정
3. **필터 탭**: 검색 및 필터 옵션 설정
4. **액션 탭**: 행 액션 버튼, 일괄 액션 설정
5. **스타일 탭**: 테마, 행 높이, 색상, 효과 설정
## 개발자 정보
- **생성일**: 2025-09-12
- **CLI 명령어**: `node scripts/create-component.js table-list "테이블 리스트" "데이터베이스 테이블의 데이터를 목록으로 표시하는 컴포넌트" display`
- **경로**: `lib/registry/components/table-list/`
## API 요구사항
이 컴포넌트가 정상 작동하려면 다음 API 엔드포인트가 구현되어 있어야 합니다:
- `GET /api/tables` - 사용 가능한 테이블 목록
- `GET /api/tables/{tableName}/columns` - 테이블 컬럼 정보
- `GET /api/tables/{tableName}/data` - 테이블 데이터 (페이징, 검색, 정렬 지원)
## 관련 문서
- [컴포넌트 시스템 가이드](../../docs/컴포넌트_시스템_가이드.md)
- [API 문서](https://docs.example.com/api/tables)
- [개발자 문서](https://docs.example.com/components/table-list)
@@ -1,376 +0,0 @@
"use client";
import React from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { ArrowUp, ArrowDown, ArrowUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { ColumnConfig } from "./types";
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
interface SingleTableWithStickyProps {
visibleColumns?: ColumnConfig[];
columns?: ColumnConfig[];
data: Record<string, any>[];
columnLabels: Record<string, string>;
sortColumn: string | null;
sortDirection: "asc" | "desc";
tableConfig?: any;
isDesignMode?: boolean;
isAllSelected?: boolean;
handleSort?: (columnName: string) => void;
onSort?: (columnName: string) => void;
handleSelectAll?: (checked: boolean) => void;
handleRowClick?: (row: any, index: number, e: React.MouseEvent) => void;
renderCheckboxCell?: (row: any, index: number) => React.ReactNode;
renderCheckboxHeader?: () => React.ReactNode;
formatCellValue: (value: any, format?: string, columnName?: string, rowData?: Record<string, any>) => string;
getColumnWidth: (column: ColumnConfig) => number;
containerWidth?: string; // 컨테이너 너비 설정
loading?: boolean;
error?: string | null;
// 인라인 편집 관련 props
onCellDoubleClick?: (rowIndex: number, colIndex: number, columnName: string, value: any) => void;
editingCell?: { rowIndex: number; colIndex: number; columnName: string; originalValue: any } | null;
editingValue?: string;
onEditingValueChange?: (value: string) => void;
onEditKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
editInputRef?: React.RefObject<HTMLInputElement>;
// 검색 하이라이트 관련 props
searchHighlights?: Set<string>;
currentSearchIndex?: number;
searchTerm?: string;
}
export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
visibleColumns,
columns,
data,
columnLabels,
sortColumn,
sortDirection,
tableConfig,
isDesignMode = false,
isAllSelected = false,
handleSort,
onSort,
handleSelectAll,
handleRowClick,
renderCheckboxCell,
renderCheckboxHeader,
formatCellValue,
getColumnWidth,
containerWidth,
loading = false,
error = null,
// 인라인 편집 관련 props
onCellDoubleClick,
editingCell,
editingValue,
onEditingValueChange,
onEditKeyDown,
editInputRef,
// 검색 하이라이트 관련 props
searchHighlights,
currentSearchIndex = 0,
searchTerm = "",
}) => {
const { getTranslatedText } = useScreenMultiLang();
const checkboxConfig = tableConfig?.checkbox || {};
const actualColumns = visibleColumns || columns || [];
const sortHandler = onSort || handleSort || (() => {});
const actualData = data || [];
return (
<div
className="bg-background relative flex flex-1 flex-col overflow-hidden shadow-sm"
style={{
width: "100%",
height: "100%",
boxSizing: "border-box",
}}
>
<div className="relative flex-1 overflow-auto">
<Table
noWrapper
className="w-full"
style={{
width: "100%",
tableLayout: "auto", // 테이블 크기 자동 조정
boxSizing: "border-box",
}}
>
<TableHeader
className={cn("bg-background border-b", tableConfig?.stickyHeader && "sticky top-0 z-30 shadow-sm")}
>
<TableRow className="border-b">
{actualColumns.map((column, colIndex) => {
// 왼쪽 고정 컬럼들의 누적 너비 계산
const leftFixedWidth = actualColumns
.slice(0, colIndex)
.filter((col) => col.fixed === "left")
.reduce((sum, col) => sum + getColumnWidth(col), 0);
// 오른쪽 고정 컬럼들의 누적 너비 계산
const rightFixedColumns = actualColumns.filter((col) => col.fixed === "right");
const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName);
const rightFixedWidth =
rightFixedIndex >= 0
? rightFixedColumns.slice(rightFixedIndex + 1).reduce((sum, col) => sum + getColumnWidth(col), 0)
: 0;
return (
<TableHead
key={column.columnName}
className={cn(
column.columnName === "__checkbox__"
? "bg-background h-9 border-0 px-3 py-1.5 text-center align-middle sm:px-4 sm:py-2"
: "text-foreground hover:text-foreground bg-background h-9 cursor-pointer border-0 px-3 py-1.5 text-left align-middle text-xs font-semibold whitespace-nowrap transition-all duration-200 select-none sm:px-4 sm:py-2 sm:text-sm",
`text-${column.align}`,
column.sortable && "hover:bg-primary/10",
// 고정 컬럼 스타일
column.fixed === "left" && "border-border bg-background sticky z-40 border-r shadow-sm",
column.fixed === "right" && "border-border bg-background sticky z-40 border-l shadow-sm",
// 숨김 컬럼 스타일 (디자인 모드에서만)
isDesignMode && column.hidden && "bg-muted/50 opacity-40",
)}
style={{
width: getColumnWidth(column),
minWidth: "100px", // 최소 너비 보장
maxWidth: "300px", // 최대 너비 제한
boxSizing: "border-box",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap", // 텍스트 줄바꿈 방지
backgroundColor: "hsl(var(--background))",
// sticky 위치 설정
...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }),
}}
onClick={() => column.sortable && sortHandler(column.columnName)}
>
<div className="flex items-center gap-2">
{column.columnName === "__checkbox__" ? (
checkboxConfig.selectAll && (
<Checkbox
checked={isAllSelected}
onCheckedChange={handleSelectAll}
aria-label="전체 선택"
style={{ zIndex: 1 }}
/>
)
) : (
<>
<span className="flex-1 truncate">
{/* langKey가 있으면 다국어 번역 사용, 없으면 기존 라벨 */}
{(column as any).langKey
? getTranslatedText(
(column as any).langKey,
columnLabels[column.columnName] || column.displayName || column.columnName,
)
: columnLabels[column.columnName] || column.displayName || column.columnName}
</span>
{column.sortable && sortColumn === column.columnName && (
<span className="bg-background/50 ml-1 flex h-4 w-4 items-center justify-center rounded-md shadow-sm sm:ml-2 sm:h-5 sm:w-5">
{sortDirection === "asc" ? (
<ArrowUp className="text-primary h-2.5 w-2.5 sm:h-3.5 sm:w-3.5" />
) : (
<ArrowDown className="text-primary h-2.5 w-2.5 sm:h-3.5 sm:w-3.5" />
)}
</span>
)}
</>
)}
</div>
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody>
{actualData.length === 0 ? (
<TableRow>
<TableCell colSpan={actualColumns.length || 1} className="py-12 text-center">
<div className="flex flex-col items-center justify-center space-y-3">
<div className="bg-muted flex h-12 w-12 items-center justify-center rounded-full">
<svg
className="text-muted-foreground h-6 w-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<span className="text-muted-foreground text-sm font-medium"> </span>
<span className="bg-muted text-muted-foreground rounded-full px-3 py-1 text-xs">
</span>
</div>
</TableCell>
</TableRow>
) : (
actualData.map((row, index) => (
<TableRow
key={`row-${index}`}
className={cn(
"bg-background h-10 cursor-pointer border-b transition-colors",
tableConfig.tableStyle?.hoverEffect && "hover:bg-muted/50",
)}
onClick={(e) => handleRowClick?.(row, index, e)}
>
{actualColumns.map((column, colIndex) => {
// 왼쪽 고정 컬럼들의 누적 너비 계산
const leftFixedWidth = actualColumns
.slice(0, colIndex)
.filter((col) => col.fixed === "left")
.reduce((sum, col) => sum + getColumnWidth(col), 0);
// 오른쪽 고정 컬럼들의 누적 너비 계산
const rightFixedColumns = actualColumns.filter((col) => col.fixed === "right");
const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName);
const rightFixedWidth =
rightFixedIndex >= 0
? rightFixedColumns
.slice(rightFixedIndex + 1)
.reduce((sum, col) => sum + getColumnWidth(col), 0)
: 0;
// 현재 셀이 편집 중인지 확인
const isEditing = editingCell?.rowIndex === index && editingCell?.colIndex === colIndex;
// 검색 하이라이트 확인 - 실제 셀 값에 검색어가 포함되어 있는지도 확인
const cellKey = `${index}-${colIndex}`;
const cellValue = String(row[column.columnName] ?? "").toLowerCase();
const hasSearchTerm = searchTerm ? cellValue.includes(searchTerm.toLowerCase()) : false;
// 인덱스 기반 하이라이트 + 실제 값 검증
const isHighlighted =
column.columnName !== "__checkbox__" &&
hasSearchTerm &&
(searchHighlights?.has(cellKey) ?? false);
// 현재 검색 결과인지 확인 (currentSearchIndex가 -1이면 현재 페이지에 없음)
const highlightArray = searchHighlights ? Array.from(searchHighlights) : [];
const isCurrentSearchResult =
isHighlighted &&
currentSearchIndex >= 0 &&
currentSearchIndex < highlightArray.length &&
highlightArray[currentSearchIndex] === cellKey;
// 셀 값에서 검색어 하이라이트 렌더링
const renderCellContent = () => {
const cellValue =
formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0";
if (!isHighlighted || !searchTerm || column.columnName === "__checkbox__") {
return cellValue;
}
// 검색어 하이라이트 처리
const lowerValue = String(cellValue).toLowerCase();
const lowerTerm = searchTerm.toLowerCase();
const startIndex = lowerValue.indexOf(lowerTerm);
if (startIndex === -1) return cellValue;
const before = String(cellValue).slice(0, startIndex);
const match = String(cellValue).slice(startIndex, startIndex + searchTerm.length);
const after = String(cellValue).slice(startIndex + searchTerm.length);
return (
<>
{before}
<mark
className={cn(
"rounded px-0.5",
isCurrentSearchResult
? "bg-orange-400 font-semibold text-white"
: "bg-yellow-200 text-yellow-900",
)}
>
{match}
</mark>
{after}
</>
);
};
return (
<TableCell
key={`cell-${column.columnName}`}
id={isCurrentSearchResult ? "current-search-result" : undefined}
className={cn(
"text-foreground h-10 px-3 py-1.5 align-middle text-xs whitespace-nowrap transition-colors sm:px-4 sm:py-2 sm:text-sm",
`text-${column.align}`,
// 고정 컬럼 스타일
column.fixed === "left" &&
"border-border bg-background/90 sticky z-10 border-r backdrop-blur-sm",
column.fixed === "right" &&
"border-border bg-background/90 sticky z-10 border-l backdrop-blur-sm",
// 편집 가능 셀 스타일
onCellDoubleClick && column.columnName !== "__checkbox__" && "cursor-text",
)}
style={{
width: getColumnWidth(column),
minWidth: "100px", // 최소 너비 보장
maxWidth: "300px", // 최대 너비 제한
boxSizing: "border-box",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
// sticky 위치 설정
...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }),
}}
onDoubleClick={(e) => {
if (onCellDoubleClick && column.columnName !== "__checkbox__") {
e.stopPropagation();
onCellDoubleClick(index, colIndex, column.columnName, row[column.columnName]);
}
}}
>
{column.columnName === "__checkbox__" ? (
renderCheckboxCell?.(row, index)
) : isEditing ? (
// 인라인 편집 입력 필드
<input
ref={editInputRef}
type="text"
value={editingValue ?? ""}
onChange={(e) => onEditingValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={() => {
// blur 시 저장 (Enter와 동일)
if (onEditKeyDown) {
const fakeEvent = {
key: "Enter",
preventDefault: () => {},
} as React.KeyboardEvent<HTMLInputElement>;
onEditKeyDown(fakeEvent);
}
}}
className="border-primary bg-background focus:ring-primary h-8 w-full rounded border px-2 text-xs focus:ring-2 focus:outline-none sm:text-sm"
onClick={(e) => e.stopPropagation()}
/>
) : (
renderCellContent()
)}
</TableCell>
);
})}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
};
@@ -1,76 +0,0 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { TableListDefinition } from "./index";
import { TableListComponent } from "./TableListComponent";
/**
* TableList
*
*/
export class TableListRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = TableListDefinition;
render(): React.ReactElement {
return <TableListComponent {...(this.props as any)} renderer={this} onConfigChange={this.handleConfigChange} />;
}
// 설정 변경 핸들러
protected handleConfigChange = (config: any) => {
console.log("📥 TableListRenderer에서 설정 변경 받음:", config);
// 상위 컴포넌트의 onConfigChange 호출 (화면 설계자에게 알림)
if (this.props.onConfigChange) {
this.props.onConfigChange(config);
} else {
console.log("⚠️ 상위 컴포넌트에서 onConfigChange가 전달되지 않음");
}
this.updateComponent({ config });
};
/**
*
*/
// text 타입 특화 속성 처리
protected getTableListProps() {
const baseProps = this.getWebTypeProps();
// text 타입에 특화된 추가 속성들
return {
...baseProps,
// 여기에 text 타입 특화 속성들 추가
};
}
// 값 변경 처리
protected handleValueChange = (value: any) => {
this.updateComponent({ value });
};
// 포커스 처리
protected handleFocus = () => {
// 포커스 로직
};
// 블러 처리
protected handleBlur = () => {
// 블러 로직
};
}
// 자동 등록 실행
TableListRenderer.registerSelf();
// 강제 등록 (디버깅용)
if (typeof window !== "undefined") {
setTimeout(() => {
try {
TableListRenderer.registerSelf();
} catch (error) {
console.error("❌ TableList 강제 등록 실패:", error);
}
}, 1000);
}
@@ -1,112 +0,0 @@
"use client";
import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import type { WebType } from "@/types/screen";
import { TableListWrapper } from "./TableListComponent";
import { TableListConfigPanel } from "./TableListConfigPanel";
import { TableListConfig } from "./types";
/**
* TableList
*
*/
export const TableListDefinition = createComponentDefinition({
id: "table-list",
name: "테이블 리스트",
name_eng: "TableList Component",
description: "데이터베이스 테이블의 데이터를 목록으로 표시하는 컴포넌트",
category: ComponentCategory.DISPLAY,
web_type: "text",
component: TableListWrapper,
default_config: {
// 표시 모드 설정
displayMode: "table" as const,
// 카드 모드 기본 설정
cardConfig: {
idColumn: "id",
titleColumn: "name",
cardsPerRow: 3,
cardSpacing: 16,
showActions: true,
},
// 테이블 기본 설정
showHeader: true,
showFooter: true,
height: "auto",
// 체크박스 설정
checkbox: {
enabled: true,
multiple: true,
position: "left",
selectAll: true,
},
// 컬럼 설정
columns: [],
autoWidth: true,
stickyHeader: false,
hidden: true, // v2-table-list 사용으로 패널에서 숨김
// 가로 스크롤 및 컬럼 고정 설정
horizontalScroll: {
enabled: true,
maxVisibleColumns: 8, // 8개 컬럼까지는 스크롤 없이 표시
minColumnWidth: 100,
maxColumnWidth: 300,
},
// 페이지네이션
pagination: {
enabled: true,
pageSize: 20,
showSizeSelector: true,
showPageInfo: true,
pageSizeOptions: [10, 20, 50, 100],
},
// 필터 설정
filter: {
enabled: true,
filters: [], // 사용자가 설정할 필터 목록
bottomSpacing: 40, // 필터와 리스트 사이 간격 (px)
},
// 액션 설정
actions: {
showActions: false,
actions: [],
bulkActions: false,
bulkActionList: [],
},
// 스타일 설정
tableStyle: {
theme: "default",
headerStyle: "default",
rowHeight: "normal",
alternateRows: true,
hoverEffect: true,
borderStyle: "light",
},
// 데이터 로딩
autoLoad: true,
},
default_size: { width: 1000, height: 600 }, // 테이블 리스트 기본 크기 (너비 1000px, 높이 600px)
config_panel: TableListConfigPanel,
icon: "Table",
tags: ["테이블", "데이터", "목록", "그리드"],
version: "1.0.0",
author: "개발팀",
documentation: "https://docs.example.com/components/table-list",
});
// 컴포넌트는 TableListRenderer에서 자동 등록됩니다
// 타입 내보내기
export type { TableListConfig } from "./types";
@@ -1,348 +0,0 @@
"use client";
import { ComponentConfig } from "@/types/component";
/**
* Entity
*/
export interface EntityJoinInfo {
sourceTable: string;
sourceColumn: string;
joinAlias: string;
}
/**
*
*/
export type AutoGenerationType =
| "uuid" // UUID 생성
| "current_user" // 현재 사용자 ID
| "current_time" // 현재 시간
| "sequence" // 시퀀스 번호
| "numbering_rule" // 채번 규칙
| "random_string" // 랜덤 문자열
| "random_number" // 랜덤 숫자
| "company_code" // 회사 코드
| "department" // 부서 코드
| "none"; // 자동생성 없음
/**
*
*/
export interface AutoGenerationConfig {
type: AutoGenerationType;
enabled: boolean;
options?: {
length?: number; // 랜덤 문자열/숫자 길이
prefix?: string; // 접두사
suffix?: string; // 접미사
format?: string; // 시간 형식 (current_time용)
startValue?: number; // 시퀀스 시작값
numberingRuleId?: string; // 채번 규칙 ID (numbering_rule 타입용)
};
}
/**
*
*/
export interface ColumnConfig {
columnName: string;
displayName: string;
visible: boolean;
sortable: boolean;
searchable: boolean;
width?: number;
align: "left" | "center" | "right";
format?: "text" | "number" | "date" | "currency" | "boolean";
order: number;
dataType?: string; // 컬럼 데이터 타입 (검색 컬럼 선택에 사용)
isEntityJoin?: boolean; // Entity 조인된 컬럼인지 여부
entityJoinInfo?: EntityJoinInfo; // Entity 조인 상세 정보
// 숫자 포맷팅 설정
thousandSeparator?: boolean; // 천단위 구분자 사용 여부 (기본: true)
// 🎯 엔티티 컬럼 표시 설정 (화면별 동적 설정)
entityDisplayConfig?: {
displayColumns: string[]; // 표시할 컬럼들 (기본 테이블 + 조인 테이블)
separator?: string; // 구분자 (기본: " - ")
sourceTable?: string; // 기본 테이블명
joinTable?: string; // 조인 테이블명
};
// 컬럼 고정 관련 속성
fixed?: "left" | "right" | false; // 컬럼 고정 위치 (왼쪽, 오른쪽, 고정 안함)
fixedOrder?: number; // 고정된 컬럼들 내에서의 순서
// 새로운 기능들
hidden?: boolean; // 숨김 기능 (편집기에서는 연하게, 실제 화면에서는 숨김)
autoGeneration?: AutoGenerationConfig; // 자동생성 설정
editable?: boolean; // 🆕 편집 가능 여부 (기본값: true, false면 인라인 편집 불가)
// 🎯 추가 조인 컬럼 정보 (조인 탭에서 추가한 컬럼들)
additionalJoinInfo?: {
sourceTable: string; // 원본 테이블
sourceColumn: string; // 원본 컬럼 (예: dept_code)
referenceTable?: string; // 참조 테이블 (예: dept_info)
joinAlias: string; // 조인 별칭 (예: dept_code_company_name)
};
}
/**
*
*/
export interface CardDisplayConfig {
idColumn: string; // ID 컬럼 (사번 등)
titleColumn: string; // 제목 컬럼 (이름 등)
subtitleColumn?: string; // 부제목 컬럼 (부서 등)
descriptionColumn?: string; // 설명 컬럼
imageColumn?: string; // 이미지 컬럼
cardsPerRow: number; // 한 행당 카드 수 (기본: 3)
cardSpacing: number; // 카드 간격 (기본: 16px)
showActions: boolean; // 액션 버튼 표시 여부
cardHeight?: number; // 카드 높이 (기본: auto)
}
/**
*
*/
export interface FilterConfig {
enabled: boolean;
// 사용할 필터 목록 (DataTableFilter 타입 사용)
filters: Array<{
columnName: string;
widgetType: string;
label: string;
gridColumns: number;
numberFilterMode?: "exact" | "range"; // 숫자 필터 모드
codeInfo?: string;
referenceTable?: string;
referenceColumn?: string;
displayColumn?: string;
}>;
// 필터와 리스트 사이 간격 (px 단위, 기본: 40)
bottomSpacing?: number;
}
/**
*
*/
export interface ActionConfig {
showActions: boolean;
actions: Array<{
type: "view" | "edit" | "delete" | "custom";
label: string;
icon?: string;
color?: string;
confirmMessage?: string;
targetScreen?: string;
}>;
bulkActions: boolean;
bulkActionList: string[];
}
/**
*
*/
export interface TableStyleConfig {
theme: "default" | "striped" | "bordered" | "minimal";
headerStyle: "default" | "dark" | "light";
rowHeight: "compact" | "normal" | "comfortable";
alternateRows: boolean;
hoverEffect: boolean;
borderStyle: "none" | "light" | "heavy";
}
/**
*
*/
export interface PaginationConfig {
enabled: boolean;
pageSize: number;
showSizeSelector: boolean;
showPageInfo: boolean;
pageSizeOptions: number[];
}
/**
*
*/
export interface ToolbarConfig {
showEditMode?: boolean; // 즉시 저장/배치 모드 버튼
showExcel?: boolean; // Excel 내보내기 버튼
showPdf?: boolean; // PDF 내보내기 버튼
showCopy?: boolean; // 복사 버튼
showSearch?: boolean; // 검색 버튼
showFilter?: boolean; // 필터 버튼
showRefresh?: boolean; // 상단 툴바 새로고침 버튼
showPaginationRefresh?: boolean; // 하단 페이지네이션 새로고침 버튼
}
/**
*
*/
export interface CheckboxConfig {
enabled: boolean; // 체크박스 활성화 여부
multiple: boolean; // 다중 선택 가능 여부 (true: 체크박스, false: 라디오)
position: "left" | "right"; // 체크박스 위치
selectAll: boolean; // 전체 선택/해제 버튼 표시 여부
}
/**
*
* ( )
*/
export interface LinkedFilterConfig {
sourceComponentId: string; // 소스 컴포넌트 ID (셀렉트박스 등)
sourceField?: string; // 소스 컴포넌트에서 가져올 필드명 (기본: value)
targetColumn: string; // 필터링할 테이블 컬럼명
operator?: "equals" | "contains" | "in"; // 필터 연산자 (기본: equals)
enabled?: boolean; // 활성화 여부 (기본: true)
}
/**
*
*
* : 거래처에
*/
export interface ExcludeFilterConfig {
enabled: boolean; // 제외 필터 활성화 여부
referenceTable: string; // 참조 테이블 (예: customer_item_mapping)
referenceColumn: string; // 참조 테이블의 비교 컬럼 (예: item_id)
sourceColumn: string; // 현재 테이블의 비교 컬럼 (예: item_number)
filterColumn?: string; // 참조 테이블의 필터 컬럼 (예: customer_id)
filterValueSource?: "url" | "formData" | "parentData"; // 필터 값 소스 (기본: url)
filterValueField?: string; // 필터 값 필드명 (예: customer_code)
}
/**
* TableList
*/
import { DataFilterConfig } from "@/types/screen-management";
export interface TableListConfig extends ComponentConfig {
// 표시 모드 설정
displayMode?: "table" | "card"; // 기본: "table"
// 카드 디스플레이 설정 (displayMode가 "card"일 때 사용)
cardConfig?: CardDisplayConfig;
// 테이블 기본 설정
selectedTable?: string;
tableName?: string;
title?: string;
showHeader: boolean;
showFooter: boolean;
// 🆕 커스텀 테이블 설정 (화면 메인 테이블과 다른 테이블 사용 시)
customTableName?: string; // 컴포넌트가 사용할 커스텀 테이블명
useCustomTable?: boolean; // true면 customTableName 사용, false면 화면 메인 테이블 사용
isReadOnly?: boolean; // 읽기전용 여부 (조회용 테이블인 경우 true)
// 체크박스 설정
checkbox: CheckboxConfig;
// 높이 설정
height: "auto" | "fixed" | "viewport";
fixedHeight?: number;
// 컬럼 설정
columns: ColumnConfig[];
autoWidth: boolean;
stickyHeader: boolean;
// 가로 스크롤 및 컬럼 고정 설정
horizontalScroll: {
enabled: boolean; // 가로 스크롤 활성화 여부
maxVisibleColumns?: number; // 스크롤 없이 표시할 최대 컬럼 수 (이 수를 넘으면 가로 스크롤)
minColumnWidth?: number; // 컬럼 최소 너비 (px)
maxColumnWidth?: number; // 컬럼 최대 너비 (px)
};
// 페이지네이션
pagination: PaginationConfig & {
currentPage?: number; // 현재 페이지 (추가)
};
// 필터 설정
filter: FilterConfig;
// 액션 설정
actions: ActionConfig;
// 스타일 설정
tableStyle: TableStyleConfig;
// 데이터 로딩
autoLoad: boolean;
refreshInterval?: number; // 초 단위
// 🆕 툴바 버튼 표시 설정
toolbar?: ToolbarConfig;
// 🆕 컬럼 값 기반 데이터 필터링
dataFilter?: DataFilterConfig;
// 🆕 연결된 필터 (다른 컴포넌트 값으로 필터링)
linkedFilters?: LinkedFilterConfig[];
// 🆕 제외 필터 (다른 테이블에 이미 존재하는 데이터 제외)
excludeFilter?: ExcludeFilterConfig;
// 이벤트 핸들러
onRowClick?: (row: any) => void;
onRowDoubleClick?: (row: any) => void;
onSelectionChange?: (selectedRows: any[]) => void;
onPageChange?: (page: number, pageSize: number) => void;
onSortChange?: (column: string, direction: "asc" | "desc") => void;
onFilterChange?: (filters: any) => void;
// 선택된 행 정보 전달 핸들러
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void;
}
/**
*
*/
export interface TableDataResponse {
data: any[];
pagination: {
page: number;
pageSize: number;
total: number;
totalPages: number;
};
columns?: Array<{
name: string;
type: string;
nullable: boolean;
}>;
}
/**
* TableList Props
*/
export interface TableListProps {
id?: string;
config?: TableListConfig;
className?: string;
style?: React.CSSProperties;
// 데이터 관련
data?: any[];
loading?: boolean;
error?: string;
// 이벤트 핸들러
onRowClick?: (row: any) => void;
onRowDoubleClick?: (row: any) => void;
onSelectionChange?: (selectedRows: any[]) => void;
onPageChange?: (page: number, pageSize: number) => void;
onSortChange?: (column: string, direction: "asc" | "desc") => void;
onFilterChange?: (filters: any) => void;
onRefresh?: () => void;
// 선택된 행 정보 전달 핸들러
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void;
}
@@ -1,15 +1,30 @@
"use client"; "use client";
/**
* CardModeRenderer shared card grid renderer
*
* 2026-05-20 table-list / v2-table-list CardModeRenderer .
* `variant="v2"` prop v2 URL (objid getFilePreviewUrl,
* path getFullImageUrl) fallback DOM .
*
* legacy table-list / v2-table-list local type files import .
* shared `./tableListConfigTypes` .
*/
import React from "react"; import React from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Eye, Edit, Trash2, MoreHorizontal } from "lucide-react"; import { Eye, Edit, Trash2, MoreHorizontal } from "lucide-react";
import { CardDisplayConfig, ColumnConfig } from "./types"; import type { CardDisplayConfig, ColumnConfig } from "./tableListConfigTypes";
import { getFullImageUrl } from "@/lib/api/client"; import { getFullImageUrl } from "@/lib/api/client";
import { getFilePreviewUrl } from "@/lib/api/file"; import { getFilePreviewUrl } from "@/lib/api/file";
export type CardModeVariant = "default" | "v2";
interface CardModeRendererProps { interface CardModeRendererProps {
/** 시각/동작 분기 — 기본은 table-list 기존 동작, "v2" 는 v2-table-list 흡수 분기 */
variant?: CardModeVariant;
data: Record<string, any>[]; data: Record<string, any>[];
cardConfig: CardDisplayConfig; cardConfig: CardDisplayConfig;
visibleColumns: ColumnConfig[]; visibleColumns: ColumnConfig[];
@@ -23,12 +38,14 @@ interface CardModeRendererProps {
* *
*/ */
export const CardModeRenderer: React.FC<CardModeRendererProps> = ({ export const CardModeRenderer: React.FC<CardModeRendererProps> = ({
variant = "default",
data, data,
cardConfig, cardConfig,
visibleColumns, visibleColumns,
onRowClick, onRowClick,
selectedRows = [], selectedRows = [],
}) => { }) => {
const isV2 = variant === "v2";
// 기본값과 병합 // 기본값과 병합
const config = { const config = {
idColumn: cardConfig?.idColumn || "", idColumn: cardConfig?.idColumn || "",
@@ -170,17 +187,24 @@ export const CardModeRenderer: React.FC<CardModeRendererProps> = ({
{imageValue && ( {imageValue && (
<div className="mb-3"> <div className="mb-3">
<img <img
src={(() => { src={
const strValue = String(imageValue); isV2
const isObjid = /^\d+$/.test(strValue); ? (() => {
return isObjid ? getFilePreviewUrl(strValue) : getFullImageUrl(strValue); // ★ v2 전용: 숫자 objid → getFilePreviewUrl, 그 외 path → getFullImageUrl 정규화
})()} const strValue = String(imageValue);
const isObjid = /^\d+$/.test(strValue);
return isObjid ? getFilePreviewUrl(strValue) : getFullImageUrl(strValue);
})()
: imageValue
}
alt={titleValue} alt={titleValue}
className="h-24 w-full rounded-md bg-muted object-cover" className="h-24 w-full rounded-md bg-muted object-cover"
onError={(e) => { onError={(e) => {
const target = e.target as HTMLImageElement; const target = e.target as HTMLImageElement;
// 이미지 로드 실패 시 폴백 표시 // 이미지 로드 실패 시 폴백 표시
target.style.display = "none"; target.style.display = "none";
// ★ v2 전용: 폴백 DOM 삽입 (data-image-fallback). default 는 단순 hide 만.
if (!isV2) return;
const parent = target.parentElement; const parent = target.parentElement;
if (parent && !parent.querySelector("[data-image-fallback]")) { if (parent && !parent.querySelector("[data-image-fallback]")) {
const fallback = document.createElement("div"); const fallback = document.createElement("div");
@@ -0,0 +1,591 @@
"use client";
/**
* SingleTableWithSticky shared table renderer
*
* 2026-05-19 FlowWidget hard blocker legacy
* `lib/registry/components/table-list/SingleTableWithSticky.tsx` .
*
* 2026-05-20 v2-table-list sticky . `variant="v2"` prop
* v2 // , (category/code select, date picker fallback,
* number input), mobile scroll/minWidth, null/undefined/"" "-" (0 ) .
*
* legacy `table-list` / `v2-table-list` / FlowWidget .
* legacy table-list / v2-table-list local type files import . minimal
* `ColumnConfig` export .
*/
import React from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { ArrowUp, ArrowDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
/**
* SingleTableWithSticky minimal .
*
* legacy table-list / v2-table-list `ColumnConfig`
* type superset . legacy
* .
*/
export interface ColumnConfig {
columnName: string;
displayName?: string;
sortable?: boolean;
align?: "left" | "center" | "right";
format?: string;
fixed?: "left" | "right" | false;
hidden?: boolean;
width?: number;
langKey?: string;
[key: string]: any;
}
export type SingleTableVariant = "default" | "v2";
interface SingleTableWithStickyProps<TColumn extends ColumnConfig = ColumnConfig> {
/** 시각/동작 분기 — 기본은 table-list / FlowWidget 기존 스타일, "v2" 는 v2-table-list 흡수 분기 */
variant?: SingleTableVariant;
visibleColumns?: TColumn[];
columns?: TColumn[];
data: Record<string, any>[];
columnLabels: Record<string, string>;
sortColumn: string | null;
sortDirection: "asc" | "desc";
tableConfig?: any;
isDesignMode?: boolean;
isAllSelected?: boolean;
handleSort?: (columnName: string) => void;
onSort?: (columnName: string) => void;
handleSelectAll?: (checked: boolean) => void;
handleRowClick?: (row: any, index: number, e: React.MouseEvent) => void;
renderCheckboxCell?: (row: any, index: number) => React.ReactNode;
renderCheckboxHeader?: () => React.ReactNode;
/** v2 에서는 ReactNode (이미지/JSX) 반환 가능. 기본 호출부는 string 반환해도 ReactNode subset 이라 호환 */
formatCellValue: (
value: any,
format?: string,
columnName?: string,
rowData?: Record<string, any>,
) => React.ReactNode;
getColumnWidth: (column: TColumn) => number;
containerWidth?: string;
loading?: boolean;
error?: string | null;
onCellDoubleClick?: (rowIndex: number, colIndex: number, columnName: string, value: any) => void;
editingCell?: { rowIndex: number; colIndex: number; columnName: string; originalValue: any } | null;
editingValue?: string;
onEditingValueChange?: (value: string) => void;
onEditKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
editInputRef?: React.RefObject<HTMLInputElement | null>;
/** v2 전용: Enter/blur 시 저장 콜백 (date picker fallback 포함 통합 저장 경로) */
onEditSave?: () => void;
/** v2 전용: 컬럼별 inputType (select/category/code, number, date, datetime) */
columnMeta?: Record<string, { inputType?: string }>;
/** v2 전용: category/code 컬럼의 옵션 매핑 */
categoryMappings?: Record<string, Record<string, { label: string }>>;
searchHighlights?: Set<string>;
currentSearchIndex?: number;
searchTerm?: string;
}
export function SingleTableWithSticky<TColumn extends ColumnConfig = ColumnConfig>({
variant = "default",
visibleColumns,
columns,
data,
columnLabels,
sortColumn,
sortDirection,
tableConfig,
isDesignMode = false,
isAllSelected = false,
handleSort,
onSort,
handleSelectAll,
handleRowClick,
renderCheckboxCell,
renderCheckboxHeader,
formatCellValue,
getColumnWidth,
containerWidth,
loading = false,
error = null,
onCellDoubleClick,
editingCell,
editingValue,
onEditingValueChange,
onEditKeyDown,
editInputRef,
onEditSave,
columnMeta,
categoryMappings,
searchHighlights,
currentSearchIndex = 0,
searchTerm = "",
}: SingleTableWithStickyProps<TColumn>) {
const { getTranslatedText } = useScreenMultiLang();
const checkboxConfig = tableConfig?.checkbox || {};
const actualColumns = visibleColumns || columns || [];
const sortHandler = onSort || handleSort || (() => {});
const actualData = data || [];
const isV2 = variant === "v2";
// ── 컨테이너/스크롤 분기 (v2 만 mobile scroll + minWidth 적용) ──
const scrollContainerStyle: React.CSSProperties = isV2 ? { WebkitOverflowScrolling: "touch" } : {};
const tableStyle: React.CSSProperties = {
width: "100%",
tableLayout: "auto",
boxSizing: "border-box",
...(isV2 ? { minWidth: `${Math.max(actualColumns.length * 80, 400)}px` } : {}),
};
// ── 헤더 스타일 분기 ──
const headerBaseClass = isV2 ? "border-b border-border/60" : "bg-background border-b";
const headerStyle: React.CSSProperties = isV2 ? { backgroundColor: "hsl(var(--muted) / 0.4)" } : {};
const headerRowClass = isV2 ? "border-b border-border/60" : "border-b";
return (
<div
className="bg-background relative flex flex-1 flex-col overflow-hidden shadow-sm"
style={{
width: "100%",
height: "100%",
boxSizing: "border-box",
}}
>
<div className="relative flex-1 overflow-auto" style={scrollContainerStyle}>
<Table noWrapper className="w-full" style={tableStyle}>
<TableHeader
className={cn(headerBaseClass, tableConfig?.stickyHeader && "sticky top-0 z-30 shadow-sm")}
style={headerStyle}
>
<TableRow className={headerRowClass}>
{actualColumns.map((column, colIndex) => {
const leftFixedWidth = actualColumns
.slice(0, colIndex)
.filter((col) => col.fixed === "left")
.reduce((sum, col) => sum + getColumnWidth(col), 0);
const rightFixedColumns = actualColumns.filter((col) => col.fixed === "right");
const rightFixedIndex = rightFixedColumns.findIndex((col) => col.columnName === column.columnName);
const rightFixedWidth =
rightFixedIndex >= 0
? rightFixedColumns.slice(rightFixedIndex + 1).reduce((sum, col) => sum + getColumnWidth(col), 0)
: 0;
const isCheckboxCol = column.columnName === "__checkbox__";
const headCheckboxBaseClass = isV2
? "h-9 border-0 px-3 py-1.5 text-center align-middle sm:px-4 sm:py-2"
: "bg-background h-9 border-0 px-3 py-1.5 text-center align-middle sm:px-4 sm:py-2";
const headDataBaseClass = isV2
? "text-muted-foreground hover:text-foreground h-9 cursor-pointer border-0 px-3 py-1.5 text-left align-middle text-[10px] font-bold uppercase tracking-[0.04em] whitespace-nowrap transition-all duration-200 select-none sm:px-4 sm:py-2 sm:text-xs"
: "text-foreground hover:text-foreground bg-background h-9 cursor-pointer border-0 px-3 py-1.5 text-left align-middle text-xs font-semibold whitespace-nowrap transition-all duration-200 select-none sm:px-4 sm:py-2 sm:text-sm";
const sortableHoverClass = isV2 ? "hover:bg-muted/50" : "hover:bg-primary/10";
// ── 셀 너비 / 헤더 width 분기 (v2 의 checkbox 48px 강제) ──
const checkboxFixedWidth = 48;
const headWidth = isV2 && isCheckboxCol ? checkboxFixedWidth : getColumnWidth(column);
const headMinWidth = isV2 && isCheckboxCol ? "48px" : "100px";
const headMaxWidth = isV2 && isCheckboxCol ? "48px" : "300px";
const headBackground = isV2 ? "hsl(var(--muted) / 0.4)" : "hsl(var(--background))";
return (
<TableHead
key={column.columnName}
className={cn(
isCheckboxCol ? headCheckboxBaseClass : headDataBaseClass,
`text-${column.align}`,
column.sortable && sortableHoverClass,
column.fixed === "left" && "border-border bg-background sticky z-40 border-r shadow-sm",
column.fixed === "right" && "border-border bg-background sticky z-40 border-l shadow-sm",
isDesignMode && column.hidden && "bg-muted/50 opacity-40",
)}
style={{
width: headWidth,
minWidth: headMinWidth,
maxWidth: headMaxWidth,
boxSizing: "border-box",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
backgroundColor: headBackground,
...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }),
}}
onClick={() => column.sortable && sortHandler(column.columnName)}
>
<div className={cn("flex items-center", isV2 && isCheckboxCol ? "justify-center" : "gap-2")}>
{isCheckboxCol ? (
checkboxConfig.selectAll && (
<Checkbox
checked={isAllSelected}
onCheckedChange={handleSelectAll}
aria-label="전체 선택"
style={
isV2
? {
width: 16,
height: 16,
borderWidth: 1.5,
borderColor: isAllSelected
? "hsl(var(--primary))"
: "hsl(var(--muted-foreground) / 0.5)",
zIndex: 1,
}
: { zIndex: 1 }
}
/>
)
) : (
<>
<span className="flex-1 truncate">
{(column as any).langKey
? getTranslatedText(
(column as any).langKey,
columnLabels[column.columnName] || column.displayName || column.columnName,
)
: columnLabels[column.columnName] || column.displayName || column.columnName}
</span>
{column.sortable && sortColumn === column.columnName && (
<span className="bg-background/50 ml-1 flex h-4 w-4 items-center justify-center rounded-md shadow-sm sm:ml-2 sm:h-5 sm:w-5">
{sortDirection === "asc" ? (
<ArrowUp className="text-primary h-2.5 w-2.5 sm:h-3.5 sm:w-3.5" />
) : (
<ArrowDown className="text-primary h-2.5 w-2.5 sm:h-3.5 sm:w-3.5" />
)}
</span>
)}
</>
)}
</div>
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody>
{actualData.length === 0 ? (
<TableRow>
<TableCell colSpan={actualColumns.length || 1} className="py-12 text-center">
<div className="flex flex-col items-center justify-center space-y-3">
<div className="bg-muted flex h-12 w-12 items-center justify-center rounded-full">
<svg
className="text-muted-foreground h-6 w-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<span className="text-muted-foreground text-sm font-medium"> </span>
<span className="bg-muted text-muted-foreground rounded-full px-3 py-1 text-xs">
</span>
</div>
</TableCell>
</TableRow>
) : (
actualData.map((row, index) => {
// ── 행 className 분기 (v2 alternate background + hoverEffect 기본 true) ──
const rowClass = isV2
? cn(
"cursor-pointer border-b border-border/50 transition-[background] duration-75",
index % 2 === 0 ? "bg-background" : "bg-muted/20",
tableConfig?.tableStyle?.hoverEffect !== false && "hover:bg-accent",
)
: cn(
"bg-background h-10 cursor-pointer border-b transition-colors",
tableConfig?.tableStyle?.hoverEffect && "hover:bg-muted/50",
);
return (
<TableRow key={`row-${index}`} className={rowClass} onClick={(e) => handleRowClick?.(row, index, e)}>
{actualColumns.map((column, colIndex) => {
const leftFixedWidth = actualColumns
.slice(0, colIndex)
.filter((col) => col.fixed === "left")
.reduce((sum, col) => sum + getColumnWidth(col), 0);
const rightFixedColumns = actualColumns.filter((col) => col.fixed === "right");
const rightFixedIndex = rightFixedColumns.findIndex(
(col) => col.columnName === column.columnName,
);
const rightFixedWidth =
rightFixedIndex >= 0
? rightFixedColumns
.slice(rightFixedIndex + 1)
.reduce((sum, col) => sum + getColumnWidth(col), 0)
: 0;
const isEditing = editingCell?.rowIndex === index && editingCell?.colIndex === colIndex;
const isCheckboxCol = column.columnName === "__checkbox__";
const cellKey = `${index}-${colIndex}`;
const cellValueStr = String(row[column.columnName] ?? "").toLowerCase();
const hasSearchTerm = searchTerm ? cellValueStr.includes(searchTerm.toLowerCase()) : false;
const isHighlighted =
!isCheckboxCol && hasSearchTerm && (searchHighlights?.has(cellKey) ?? false);
const highlightArray = searchHighlights ? Array.from(searchHighlights) : [];
const isCurrentSearchResult =
isHighlighted &&
currentSearchIndex >= 0 &&
currentSearchIndex < highlightArray.length &&
highlightArray[currentSearchIndex] === cellKey;
// ── 셀 값 분기 ──
// v2: null/undefined/"" → "-" 표시 (0 은 값 그대로), ReactElement 가능
// default: falsy 면 nbsp fallback
let rawCellValue: React.ReactNode;
let isReactElement = false;
if (isV2) {
const formatted = formatCellValue(row[column.columnName], column.format, column.columnName, row);
if (formatted === null || formatted === undefined || formatted === "") {
rawCellValue = <span className="text-muted-foreground/50">-</span>;
isReactElement = true;
} else {
rawCellValue = formatted;
isReactElement = typeof formatted === "object" && React.isValidElement(formatted);
}
} else {
rawCellValue =
formatCellValue(row[column.columnName], column.format, column.columnName, row) || "\u00A0";
}
const renderCellContent = () => {
// ReactElement (v2 의 이미지/JSX) 는 그대로 렌더
if (isReactElement) {
return rawCellValue;
}
if (!isHighlighted || !searchTerm || isCheckboxCol) {
return rawCellValue;
}
const strValue = String(rawCellValue);
const lowerValue = strValue.toLowerCase();
const lowerTerm = searchTerm.toLowerCase();
const startIndex = lowerValue.indexOf(lowerTerm);
if (startIndex === -1) return rawCellValue;
const before = strValue.slice(0, startIndex);
const match = strValue.slice(startIndex, startIndex + searchTerm.length);
const after = strValue.slice(startIndex + searchTerm.length);
return (
<>
{before}
<mark
className={cn(
"rounded px-0.5",
isCurrentSearchResult
? "bg-orange-400 font-semibold text-white"
: "bg-yellow-200 text-yellow-900",
)}
>
{match}
</mark>
{after}
</>
);
};
// ── 셀 className 분기 ──
const cellClass = isV2
? cn(
"text-foreground h-10 align-middle text-[11px] transition-colors",
isCheckboxCol ? "px-0 py-[7px] text-center" : "px-3 py-[7px]",
!isReactElement && "whitespace-nowrap",
!isCheckboxCol && `text-${column.align}`,
column.fixed === "left" &&
"border-border bg-background/90 sticky z-10 border-r backdrop-blur-sm",
column.fixed === "right" &&
"border-border bg-background/90 sticky z-10 border-l backdrop-blur-sm",
onCellDoubleClick && !isCheckboxCol && "cursor-text",
)
: cn(
"text-foreground h-10 px-3 py-1.5 align-middle text-xs whitespace-nowrap transition-colors sm:px-4 sm:py-2 sm:text-sm",
`text-${column.align}`,
column.fixed === "left" &&
"border-border bg-background/90 sticky z-10 border-r backdrop-blur-sm",
column.fixed === "right" &&
"border-border bg-background/90 sticky z-10 border-l backdrop-blur-sm",
onCellDoubleClick && !isCheckboxCol && "cursor-text",
);
// ── 셀 width/style 분기 (v2 의 checkbox 48px) ──
const cellFixedWidth = isV2 && isCheckboxCol ? 48 : getColumnWidth(column);
const cellMinWidth = isV2 && isCheckboxCol ? "48px" : "100px";
const cellMaxWidth = isV2 && isCheckboxCol ? "48px" : "300px";
const cellOverflowStyle: React.CSSProperties =
isV2 && isReactElement
? {}
: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" };
return (
<TableCell
key={`cell-${column.columnName}`}
id={isCurrentSearchResult ? "current-search-result" : undefined}
className={cellClass}
style={{
width: cellFixedWidth,
minWidth: cellMinWidth,
maxWidth: cellMaxWidth,
boxSizing: "border-box",
...cellOverflowStyle,
...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }),
}}
onDoubleClick={(e) => {
if (onCellDoubleClick && !isCheckboxCol) {
e.stopPropagation();
onCellDoubleClick(index, colIndex, column.columnName, row[column.columnName]);
}
}}
>
{isCheckboxCol ? (
renderCheckboxCell?.(row, index)
) : isEditing ? (
isV2 ? (
// ── v2 인라인 편집: inputType 에 따라 select(category/code), date/datetime, number, text ──
(() => {
const meta = columnMeta?.[column.columnName];
const inputType = meta?.inputType ?? (column as { inputType?: string }).inputType;
const isNumeric = inputType === "number" || inputType === "decimal";
const isCategoryType = inputType === "category" || inputType === "code";
const categoryOptions = categoryMappings?.[column.columnName];
const hasCategoryOptions =
isCategoryType && categoryOptions && Object.keys(categoryOptions).length > 0;
const commonInputClass =
"border-primary bg-background focus:ring-primary h-8 w-full shrink-0 rounded border px-2 text-xs focus:ring-2 focus:outline-none sm:text-sm";
const handleBlurSave = () => {
if (onEditKeyDown) {
const fakeEvent = {
key: "Enter",
preventDefault: () => {},
} as React.KeyboardEvent<HTMLInputElement>;
onEditKeyDown(fakeEvent);
}
onEditSave?.();
};
if (hasCategoryOptions) {
const selectOptions = Object.entries(categoryOptions).map(([value, info]) => ({
value,
label: info.label,
}));
return (
<select
ref={editInputRef as unknown as React.RefObject<HTMLSelectElement>}
value={editingValue ?? ""}
onChange={(e) => onEditingValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown as unknown as React.KeyboardEventHandler<HTMLSelectElement>}
onBlur={handleBlurSave}
className={cn(commonInputClass, "h-8")}
onClick={(e) => e.stopPropagation()}
>
<option value=""></option>
{selectOptions.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
);
}
if (inputType === "date" || inputType === "datetime") {
try {
// 외부 의존 모듈 — runtime require 실패 시 일반 text input 으로 폴백
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { InlineCellDatePicker } = require("@/components/screen/filters/InlineCellDatePicker");
return (
<InlineCellDatePicker
value={editingValue ?? ""}
onChange={(v: string) => onEditingValueChange?.(v)}
onSave={handleBlurSave}
onKeyDown={onEditKeyDown}
inputRef={editInputRef}
/>
);
} catch {
return (
<input
ref={editInputRef}
type="text"
value={editingValue ?? ""}
onChange={(e) => onEditingValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={handleBlurSave}
className={commonInputClass}
onClick={(e) => e.stopPropagation()}
/>
);
}
}
return (
<input
ref={editInputRef}
type={isNumeric ? "number" : "text"}
value={editingValue ?? ""}
onChange={(e) => onEditingValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={handleBlurSave}
className={commonInputClass}
style={isNumeric ? { textAlign: "right" } : undefined}
onClick={(e) => e.stopPropagation()}
/>
);
})()
) : (
// ── 기본 인라인 편집: 단순 text input (table-list / FlowWidget 기존 동작) ──
<input
ref={editInputRef}
type="text"
value={editingValue ?? ""}
onChange={(e) => onEditingValueChange?.(e.target.value)}
onKeyDown={onEditKeyDown}
onBlur={() => {
if (onEditKeyDown) {
const fakeEvent = {
key: "Enter",
preventDefault: () => {},
} as React.KeyboardEvent<HTMLInputElement>;
onEditKeyDown(fakeEvent);
}
}}
className="border-primary bg-background focus:ring-primary h-8 w-full rounded border px-2 text-xs focus:ring-2 focus:outline-none sm:text-sm"
onClick={(e) => e.stopPropagation()}
/>
)
) : (
renderCellContent()
)}
</TableCell>
);
})}
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</div>
);
}
@@ -1,7 +1,7 @@
"use client"; "use client";
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"; import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { TableListConfig, ColumnConfig } from "./types"; import type { TableListConfig, ColumnConfig } from "./tableListConfigTypes";
import type { WebType } from "@/types/screen"; import type { WebType } from "@/types/screen";
import { tableTypeApi } from "@/lib/api/screen"; import { tableTypeApi } from "@/lib/api/screen";
import { entityJoinApi } from "@/lib/api/entityJoin"; import { entityJoinApi } from "@/lib/api/entityJoin";
@@ -6,7 +6,7 @@ import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { TableListConfig, ColumnConfig } from "./types"; import type { TableListConfig, ColumnConfig } from "./tableListConfigTypes";
import { entityJoinApi } from "@/lib/api/entityJoin"; import { entityJoinApi } from "@/lib/api/entityJoin";
import { tableTypeApi } from "@/lib/api/screen"; import { tableTypeApi } from "@/lib/api/screen";
import { tableManagementApi } from "@/lib/api/tableManagement"; import { tableManagementApi } from "@/lib/api/tableManagement";
@@ -1359,4 +1359,3 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
</div> </div>
); );
}; };
@@ -1,7 +1,7 @@
"use client"; "use client";
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react"; import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { TableListConfig, ColumnConfig } from "./types"; import type { TableListConfig, ColumnConfig } from "./tableListConfigTypes";
import { tableTypeApi } from "@/lib/api/screen"; import { tableTypeApi } from "@/lib/api/screen";
import { entityJoinApi } from "@/lib/api/entityJoin"; import { entityJoinApi } from "@/lib/api/entityJoin";
import { codeCache } from "@/lib/caching/codeCache"; import { codeCache } from "@/lib/caching/codeCache";
@@ -5602,6 +5602,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
</div> </div>
) : ( ) : (
<CardModeRenderer <CardModeRenderer
variant="v2"
data={data} data={data}
cardConfig={ cardConfig={
tableConfig.cardConfig || { tableConfig.cardConfig || {
@@ -5656,6 +5657,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
<div style={{ flex: 1, overflow: "hidden" }}> <div style={{ flex: 1, overflow: "hidden" }}>
<SingleTableWithSticky <SingleTableWithSticky
variant="v2"
data={data} data={data}
columns={visibleColumns} columns={visibleColumns}
loading={loading} loading={loading}
@@ -1,7 +1,7 @@
"use client"; "use client";
import React, { useEffect, useMemo, useRef, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import { TableListWrapper } from "./TableListComponent"; import { TableListWrapper } from "./V2TableListComponent";
import { fieldsToColumns } from "@/lib/fieldConfig/adapters"; import { fieldsToColumns } from "@/lib/fieldConfig/adapters";
import type { FieldConfig } from "@/types/invyone-component"; import type { FieldConfig } from "@/types/invyone-component";
@@ -1,6 +1,4 @@
"use client"; import type { ComponentConfig } from "@/types/component";
import { ComponentConfig } from "@/types/component";
/** /**
* Entity * Entity
@@ -219,7 +217,7 @@ export interface ExcludeFilterConfig {
* TableList * TableList
*/ */
import { DataFilterConfig } from "@/types/screen-management"; import type { DataFilterConfig } from "@/types/screen-management";
export interface TableListConfig extends ComponentConfig { export interface TableListConfig extends ComponentConfig {
// 표시 모드 설정 // 표시 모드 설정
@@ -1,70 +0,0 @@
"use client";
import React from "react";
import { ComponentRegistry } from "../../ComponentRegistry";
import { ComponentCategory } from "@/types/component";
import { Folder } from "lucide-react";
import type { TabsComponent, TabItem } from "@/types/screen-management";
// TabsWidget 래퍼 컴포넌트
const TabsWidgetWrapper: React.FC<any> = (props) => {
const { component, ...restProps } = props;
// componentConfig에서 탭 정보 추출
const tabsConfig = component.componentConfig || {};
const tabsComponent = {
...component,
type: "tabs" as const,
tabs: tabsConfig.tabs || [],
defaultTab: tabsConfig.defaultTab,
orientation: tabsConfig.orientation || "horizontal",
variant: tabsConfig.variant || "default",
allowCloseable: tabsConfig.allowCloseable || false,
persistSelection: tabsConfig.persistSelection || false,
};
// TabsWidget 동적 로드
const TabsWidget = require("@/components/screen/widgets/TabsWidget").TabsWidget;
return (
<div className="h-full w-full">
<TabsWidget component={tabsComponent} {...restProps} />
</div>
);
};
/**
*
*
*
*/
ComponentRegistry.registerComponent({
id: "tabs-widget",
name: "탭 컴포넌트",
description: "화면을 탭으로 전환할 수 있는 컴포넌트입니다. 각 탭마다 다른 화면을 연결할 수 있습니다.",
category: ComponentCategory.LAYOUT,
web_type: "text" as any, // 레이아웃 컴포넌트이므로 임시값
component: TabsWidgetWrapper, // ✅ 실제 TabsWidget 렌더러
default_config: {},
tags: ["tabs", "navigation", "layout", "screen"],
icon: Folder,
version: "1.0.0",
default_size: {
width: 800,
height: 600,
},
hidden: true, // v2-tabs-widget 사용으로 패널에서 숨김
// 설정 패널 (동적 로딩)
config_panel: React.lazy(() =>
import("@/components/screen/config-panels/TabsConfigPanel").then(module => ({
default: module.TabsConfigPanel
}))
),
});
// console.log("✅ 탭 컴포넌트 등록 완료");
@@ -28,6 +28,7 @@ import { FieldDetailSettingsModal } from "./modals/FieldDetailSettingsModal";
import { SaveSettingsModal } from "./modals/SaveSettingsModal"; import { SaveSettingsModal } from "./modals/SaveSettingsModal";
import { SectionLayoutModal } from "./modals/SectionLayoutModal"; import { SectionLayoutModal } from "./modals/SectionLayoutModal";
import { TableSectionSettingsModal } from "./modals/TableSectionSettingsModal"; import { TableSectionSettingsModal } from "./modals/TableSectionSettingsModal";
import { isTableLikeComponentType } from "@/lib/utils/componentTypeUtils";
// 도움말 텍스트 컴포넌트 // 도움말 텍스트 컴포넌트
const HelpText = ({ children }: { children: React.ReactNode }) => ( const HelpText = ({ children }: { children: React.ReactNode }) => (
@@ -106,8 +107,9 @@ export function UniversalFormModalConfigPanel({
const compType = comp.componentId || comp.componentConfig?.type || comp.componentConfig?.id || comp.type; const compType = comp.componentId || comp.componentConfig?.type || comp.componentConfig?.id || comp.type;
const compConfig = comp.componentConfig || {}; const compConfig = comp.componentConfig || {};
// 1. TableList / InteractiveDataTable - 테이블 컬럼 추출 // 1. Table-like (canonical 'table' / legacy 'table-list' / hidden 'v2-table-list')
if (compType === "table-list" || compType === "interactive-data-table") { // + InteractiveDataTable - 테이블 컬럼 추출
if (isTableLikeComponentType(compType) || compType === "interactive-data-table") {
const tableName = compConfig.selectedTable || compConfig.tableName; const tableName = compConfig.selectedTable || compConfig.tableName;
if (tableName) { if (tableName) {
// 테이블 컬럼 로드 // 테이블 컬럼 로드
@@ -1,851 +0,0 @@
"use client";
import React, { useState, useEffect, useMemo, useCallback, useRef } from "react";
import { ComponentRendererProps } from "@/types/component";
import { AggregationWidgetConfig, AggregationItem, AggregationResult, AggregationType, FilterCondition, DataSourceType } from "./types";
import { Calculator, TrendingUp, Hash, ArrowUp, ArrowDown, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { useScreenMultiLang } from "@/contexts/ScreenMultiLangContext";
import { formatNumber } from "@/lib/formatting";
import { apiClient } from "@/lib/api/client";
import { v2EventBus, V2_EVENTS, V2ErrorBoundary } from "@/lib/v2-core";
interface AggregationWidgetComponentProps extends ComponentRendererProps {
config?: AggregationWidgetConfig;
// 외부에서 데이터를 직접 전달받을 수 있음
externalData?: any[];
// 폼 데이터 (필터 조건용)
formData?: Record<string, any>;
// 선택된 행 데이터
selectedRows?: any[];
// 선택된 행 전체 데이터 (표준 Props)
selectedRowsData?: any[];
// 멀티테넌시용 회사 코드
companyCode?: string;
// 새로고침 트리거 키
refreshKey?: number;
// 새로고침 콜백
onRefresh?: () => void;
}
/**
*
*/
function applyFilters(
data: any[],
filters: FilterCondition[],
filterLogic: "AND" | "OR",
formData: Record<string, any>,
selectedRows: any[]
): any[] {
if (!filters || filters.length === 0) {
return data;
}
const enabledFilters = filters.filter((f) => f.enabled && f.columnName);
if (enabledFilters.length === 0) {
return data;
}
return data.filter((row) => {
const results = enabledFilters.map((filter) => {
const rowValue = row[filter.columnName];
// 값 소스에 따라 비교 값 결정
let compareValue: any;
switch (filter.valueSourceType) {
case "static":
compareValue = filter.staticValue;
break;
case "formField":
compareValue = formData?.[filter.formFieldName || ""];
break;
case "selection":
// 선택된 행에서 값 가져오기 (첫 번째 선택 행 기준)
compareValue = selectedRows?.[0]?.[filter.sourceColumnName || ""];
break;
case "urlParam":
if (typeof window !== "undefined") {
const urlParams = new URLSearchParams(window.location.search);
compareValue = urlParams.get(filter.urlParamName || "");
}
break;
}
// 연산자에 따른 비교
switch (filter.operator) {
case "eq":
return rowValue == compareValue;
case "neq":
return rowValue != compareValue;
case "gt":
return Number(rowValue) > Number(compareValue);
case "gte":
return Number(rowValue) >= Number(compareValue);
case "lt":
return Number(rowValue) < Number(compareValue);
case "lte":
return Number(rowValue) <= Number(compareValue);
case "like":
return String(rowValue || "").toLowerCase().includes(String(compareValue || "").toLowerCase());
case "in":
const inValues = String(compareValue || "").split(",").map((v) => v.trim());
return inValues.includes(String(rowValue));
case "isNull":
return rowValue === null || rowValue === undefined || rowValue === "";
case "isNotNull":
return rowValue !== null && rowValue !== undefined && rowValue !== "";
default:
return true;
}
});
// AND/OR 논리 적용
return filterLogic === "AND" ? results.every((r) => r) : results.some((r) => r);
});
}
/**
*
*
*/
export function AggregationWidgetComponent({
component,
isDesignMode = false,
config: propsConfig,
externalData,
formData = {},
selectedRows = [],
selectedRowsData = [],
companyCode,
refreshKey,
onRefresh,
}: AggregationWidgetComponentProps) {
// 다국어 지원
const { getTranslatedText } = useScreenMultiLang();
// useMemo로 config 병합 (매 렌더링마다 새 객체 생성 방지)
const componentConfig = useMemo<AggregationWidgetConfig>(() => ({
dataSourceType: "table",
items: [],
layout: "horizontal",
showLabels: true,
showIcons: true,
gap: "16px",
...propsConfig,
...component?.config,
}), [propsConfig, component?.config]);
// 다국어 라벨 가져오기
const getItemLabel = (item: AggregationItem): string => {
if (item.labelLangKey) {
const translated = getTranslatedText(item.labelLangKey, item.labelLangKey!);
if (translated && translated !== item.labelLangKey) {
return translated;
}
}
return item.columnLabel || item.columnName || "컬럼";
};
const {
dataSourceType,
dataSourceComponentId,
tableName,
customTableName,
useCustomTable,
filters,
filterLogic = "AND",
items,
layout,
showLabels,
showIcons,
gap,
backgroundColor,
borderRadius,
padding,
fontSize,
labelFontSize,
valueFontSize,
labelColor,
valueColor,
autoRefresh,
refreshInterval,
refreshOnFormChange,
} = componentConfig;
// 데이터 상태
const [data, setData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// 사용할 테이블명 결정
const effectiveTableName = useCustomTable && customTableName ? customTableName : tableName;
// Refs로 최신 값 참조 (의존성 배열에서 제외하여 무한 루프 방지)
const filtersRef = React.useRef(filters);
const formDataRef = React.useRef(formData);
const selectedRowsRef = React.useRef(selectedRows);
// 값이 변경될 때마다 ref 업데이트
React.useEffect(() => {
filtersRef.current = filters;
}, [filters]);
React.useEffect(() => {
formDataRef.current = formData;
}, [formData]);
React.useEffect(() => {
selectedRowsRef.current = selectedRows;
}, [selectedRows]);
// 테이블에서 데이터 조회 (dataSourceType === "table"일 때)
const fetchTableData = useCallback(async () => {
if (isDesignMode || !effectiveTableName || dataSourceType !== "table") {
return;
}
try {
setLoading(true);
setError(null);
// 테이블 데이터 조회 API 호출
// 멀티테넌시: company_code 자동 필터링 활성화
const response = await apiClient.post(`/table-management/tables/${effectiveTableName}/data`, {
size: 10000, // 집계용이므로 충분한 데이터 조회
page: 1,
autoFilter: {
enabled: true,
filterColumn: "company_code",
userField: "company_code",
},
});
// 응답 구조: { success: true, data: { data: [...], total: ... } }
const raw = response.data?.data || response.data;
const rows = raw?.data || raw || [];
if (Array.isArray(rows)) {
// 필터 적용
const filteredData = applyFilters(
rows,
filtersRef.current || [],
filterLogic,
formDataRef.current,
selectedRowsRef.current
);
setData(filteredData);
}
} catch (err: any) {
console.error("집계 위젯 데이터 조회 오류:", err);
setError(err.message || "데이터 조회 실패");
} finally {
setLoading(false);
}
}, [effectiveTableName, dataSourceType, isDesignMode, filterLogic]);
// 테이블 데이터 조회 (초기 로드 + refreshKey 변경 시)
useEffect(() => {
if (dataSourceType === "table" && effectiveTableName && !isDesignMode) {
fetchTableData();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataSourceType, effectiveTableName, isDesignMode, refreshKey]);
// 폼 데이터 변경 시 재조회 (refreshOnFormChange가 true일 때)
const formDataKey = JSON.stringify(formData);
useEffect(() => {
if (dataSourceType === "table" && refreshOnFormChange && !isDesignMode && effectiveTableName) {
// 초기 로드 후에만 재조회
const timeoutId = setTimeout(() => {
fetchTableData();
}, 100);
return () => clearTimeout(timeoutId);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [formDataKey, refreshOnFormChange]);
// 자동 새로고침
useEffect(() => {
if (dataSourceType === "table" && autoRefresh && refreshInterval && !isDesignMode) {
const interval = setInterval(fetchTableData, refreshInterval * 1000);
return () => clearInterval(interval);
}
}, [dataSourceType, autoRefresh, refreshInterval, isDesignMode, fetchTableData]);
// 선택된 행 집계 (dataSourceType === "selection"일 때)
// props로 전달된 selectedRows 또는 selectedRowsData 사용
// 길이 정보를 포함하여 전체 데이터 변경 감지 개선
const selectedRowsKey = `${selectedRows?.length || 0}:${JSON.stringify(selectedRows?.slice(0, 5))}`;
const selectedRowsDataKey = `${selectedRowsData?.length || 0}:${JSON.stringify(selectedRowsData?.slice(0, 5))}`;
useEffect(() => {
// selectedRowsData가 있으면 우선 사용 (표준 Props)
const rowsToUse = selectedRowsData?.length > 0 ? selectedRowsData : selectedRows;
if (dataSourceType === "selection") {
if (Array.isArray(rowsToUse) && rowsToUse.length > 0) {
const filteredData = applyFilters(
rowsToUse,
filtersRef.current || [],
filterLogic,
formDataRef.current,
selectedRowsRef.current
);
setData(filteredData);
} else {
// 선택 해제 시 빈 배열로 초기화
setData([]);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataSourceType, selectedRowsKey, selectedRowsDataKey, filterLogic]);
// V2 이벤트 버스 구독 (selection 또는 component 타입일 때)
useEffect(() => {
if (isDesignMode) return;
if (dataSourceType !== "selection" && dataSourceType !== "component") return;
// 핸들러 함수 정의
const handleV2TableDataChange = (payload: any) => {
// component 타입: source가 dataSourceComponentId와 일치할 때만
// selection 타입: 모든 테이블 데이터 변경 수신
if (dataSourceType === "component" && payload.source !== dataSourceComponentId) {
return;
}
if (Array.isArray(payload.data)) {
const filteredData = applyFilters(
payload.data,
filtersRef.current || [],
filterLogic,
formDataRef.current,
selectedRowsRef.current
);
setData(filteredData);
}
};
const handleV2TableSelectionChange = (payload: any) => {
// component 타입: source가 dataSourceComponentId와 일치할 때만
// selection 타입: 모든 선택 변경 수신
if (dataSourceType === "component" && payload.source !== dataSourceComponentId) {
return;
}
if (Array.isArray(payload.selectedRows)) {
const filteredData = applyFilters(
payload.selectedRows,
filtersRef.current || [],
filterLogic,
formDataRef.current,
selectedRowsRef.current
);
setData(filteredData);
}
};
const handleV2RepeaterDataChange = (payload: any) => {
if (dataSourceType === "component" && payload.repeaterId !== dataSourceComponentId) {
return;
}
if (Array.isArray(payload.data)) {
const filteredData = applyFilters(
payload.data,
filtersRef.current || [],
filterLogic,
formDataRef.current,
selectedRowsRef.current
);
setData(filteredData);
}
};
// V2 이벤트 버스 구독
const unsubscribeTableData = v2EventBus.subscribe(
V2_EVENTS.TABLE_DATA_CHANGE,
handleV2TableDataChange
);
const unsubscribeTableSelection = v2EventBus.subscribe(
V2_EVENTS.TABLE_SELECTION_CHANGE,
handleV2TableSelectionChange
);
const unsubscribeRepeaterData = v2EventBus.subscribe(
V2_EVENTS.REPEATER_DATA_CHANGE,
handleV2RepeaterDataChange
);
return () => {
unsubscribeTableData();
unsubscribeTableSelection();
unsubscribeRepeaterData();
};
}, [dataSourceType, dataSourceComponentId, isDesignMode, filterLogic]);
// 전역 선택 이벤트 수신 - 레거시 지원 (dataSourceType === "selection"일 때)
useEffect(() => {
if (dataSourceType !== "selection" || isDesignMode) return;
// 테이블리스트에서 발생하는 선택 이벤트 수신
// tableListDataChange 이벤트의 data가 선택된 행들임
const handleTableListDataChange = (event: CustomEvent) => {
const { data: eventData, selectedRows: eventSelectedRows } = event.detail || {};
// data가 선택된 행 데이터 배열
const rows = eventData || [];
if (Array.isArray(rows)) {
// 필터 적용
const filteredData = applyFilters(
rows,
filtersRef.current || [],
filterLogic,
formDataRef.current,
selectedRowsRef.current
);
setData(filteredData);
}
};
// 리피터에서 발생하는 이벤트
const handleRepeaterDataChange = (event: CustomEvent) => {
const { data: eventData, selectedData } = event.detail || {};
const rows = selectedData || eventData || [];
if (Array.isArray(rows)) {
const filteredData = applyFilters(
rows,
filtersRef.current || [],
filterLogic,
formDataRef.current,
selectedRowsRef.current
);
setData(filteredData);
}
};
// 일반 선택 이벤트
const handleSelectionChange = (event: CustomEvent) => {
const { selectedRows: eventSelectedRows, selectedData, checkedRows, selectedItems } = event.detail || {};
const rows = selectedData || eventSelectedRows || checkedRows || selectedItems || [];
if (Array.isArray(rows)) {
const filteredData = applyFilters(
rows,
filtersRef.current || [],
filterLogic,
formDataRef.current,
selectedRowsRef.current
);
setData(filteredData);
}
};
// 다양한 선택 이벤트 수신
window.addEventListener("tableListDataChange" as any, handleTableListDataChange);
window.addEventListener("repeaterDataChange" as any, handleRepeaterDataChange);
window.addEventListener("selectionChange" as any, handleSelectionChange);
window.addEventListener("tableSelectionChange" as any, handleSelectionChange);
window.addEventListener("rowSelectionChange" as any, handleSelectionChange);
window.addEventListener("checkboxSelectionChange" as any, handleSelectionChange);
return () => {
window.removeEventListener("tableListDataChange" as any, handleTableListDataChange);
window.removeEventListener("repeaterDataChange" as any, handleRepeaterDataChange);
window.removeEventListener("selectionChange" as any, handleSelectionChange);
window.removeEventListener("tableSelectionChange" as any, handleSelectionChange);
window.removeEventListener("rowSelectionChange" as any, handleSelectionChange);
window.removeEventListener("checkboxSelectionChange" as any, handleSelectionChange);
};
}, [dataSourceType, isDesignMode, filterLogic]);
// 외부 데이터가 있으면 사용
// 길이 정보를 포함하여 전체 데이터 변경 감지 개선
const externalDataKey = externalData
? `${externalData.length}:${JSON.stringify(externalData.slice(0, 5))}`
: null;
useEffect(() => {
if (externalData && Array.isArray(externalData)) {
// 필터 적용
const filteredData = applyFilters(
externalData,
filtersRef.current || [],
filterLogic,
formDataRef.current,
selectedRowsRef.current
);
setData(filteredData);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [externalDataKey, filterLogic]);
// 컴포넌트 데이터 변경 이벤트 리스닝 (dataSourceType === "component"일 때)
useEffect(() => {
if (dataSourceType !== "component" || !dataSourceComponentId || isDesignMode) return;
const handleDataChange = (event: CustomEvent) => {
const { componentId, data: eventData } = event.detail || {};
if (componentId === dataSourceComponentId && Array.isArray(eventData)) {
// 필터 적용
const filteredData = applyFilters(
eventData,
filtersRef.current || [],
filterLogic,
formDataRef.current,
selectedRowsRef.current
);
setData(filteredData);
}
};
// 선택 변경 이벤트 (체크박스 선택 등)
const handleSelectionChange = (event: CustomEvent) => {
const { componentId, selectedData } = event.detail || {};
if (componentId === dataSourceComponentId && Array.isArray(selectedData)) {
// 선택된 데이터만 집계
const filteredData = applyFilters(
selectedData,
filtersRef.current || [],
filterLogic,
formDataRef.current,
selectedRowsRef.current
);
setData(filteredData);
}
};
// 리피터 데이터 변경 이벤트
window.addEventListener("repeaterDataChange" as any, handleDataChange);
// 테이블 리스트 데이터 변경 이벤트
window.addEventListener("tableListDataChange" as any, handleDataChange);
// 선택 변경 이벤트
window.addEventListener("selectionChange" as any, handleSelectionChange);
return () => {
window.removeEventListener("repeaterDataChange" as any, handleDataChange);
window.removeEventListener("tableListDataChange" as any, handleDataChange);
window.removeEventListener("selectionChange" as any, handleSelectionChange);
};
}, [dataSourceType, dataSourceComponentId, isDesignMode, filterLogic]);
// 집계 계산
const aggregationResults = useMemo((): AggregationResult[] => {
if (!items || items.length === 0) {
return [];
}
return items.map((item) => {
const values = data
.map((row) => {
const val = row[item.columnName];
const parsed = typeof val === "number" ? val : parseFloat(val) || 0;
return parsed;
})
.filter((v) => !isNaN(v));
let value: number = 0;
switch (item.type) {
case "sum":
value = values.reduce((acc, v) => acc + v, 0);
break;
case "avg":
value = values.length > 0 ? values.reduce((acc, v) => acc + v, 0) / values.length : 0;
break;
case "count":
value = data.length;
break;
case "max":
value = values.length > 0 ? Math.max(...values) : 0;
break;
case "min":
value = values.length > 0 ? Math.min(...values) : 0;
break;
}
// 포맷팅
let formattedValue = value.toFixed(item.decimalPlaces ?? 0);
if (item.format === "currency") {
formattedValue = formatNumber(value);
} else if (item.format === "percent") {
formattedValue = `${(value * 100).toFixed(item.decimalPlaces ?? 1)}%`;
} else if (item.format === "number") {
formattedValue = formatNumber(value);
}
if (item.prefix) {
formattedValue = `${item.prefix}${formattedValue}`;
}
if (item.suffix) {
formattedValue = `${formattedValue}${item.suffix}`;
}
return {
id: item.id,
label: getItemLabel(item),
value,
formattedValue,
type: item.type,
};
});
}, [data, items, getText]);
// aggregationResults를 ref로 유지 (이벤트 핸들러에서 최신 값 참조)
const aggregationResultsRef = useRef(aggregationResults);
aggregationResultsRef.current = aggregationResults;
// beforeFormSave 이벤트 리스너 (저장 시 집계 결과를 폼 데이터에 포함)
useEffect(() => {
if (isDesignMode) return;
const handleBeforeFormSave = (event: CustomEvent) => {
const componentKey = component?.id || "aggregation_data";
if (event.detail) {
// 집계 결과를 객체 형태로 저장
const aggregationData: Record<string, any> = {};
aggregationResultsRef.current.forEach((result) => {
aggregationData[result.id] = {
label: result.label,
value: result.value,
formattedValue: result.formattedValue,
type: result.type,
};
});
event.detail.formData[componentKey] = aggregationData;
}
};
// V2 이벤트 버스 구독
const unsubscribe = v2EventBus.subscribe(
V2_EVENTS.FORM_SAVE_COLLECT,
(payload) => {
const componentKey = component?.id || "aggregation_data";
const aggregationData: Record<string, any> = {};
aggregationResultsRef.current.forEach((result) => {
aggregationData[result.id] = {
label: result.label,
value: result.value,
formattedValue: result.formattedValue,
type: result.type,
};
});
// V2 이벤트로 응답
if (payload.formData) {
payload.formData[componentKey] = aggregationData;
}
}
);
// 레거시 이벤트도 지원
window.addEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
return () => {
unsubscribe();
window.removeEventListener("beforeFormSave", handleBeforeFormSave as EventListener);
};
}, [isDesignMode, component?.id]);
// 집계 타입에 따른 아이콘
const getIcon = (type: AggregationType) => {
switch (type) {
case "sum":
return <Calculator className="h-4 w-4" />;
case "avg":
return <TrendingUp className="h-4 w-4" />;
case "count":
return <Hash className="h-4 w-4" />;
case "max":
return <ArrowUp className="h-4 w-4" />;
case "min":
return <ArrowDown className="h-4 w-4" />;
}
};
// 집계 타입 라벨
const getTypeLabel = (type: AggregationType) => {
switch (type) {
case "sum":
return "합계";
case "avg":
return "평균";
case "count":
return "개수";
case "max":
return "최대";
case "min":
return "최소";
}
};
// 데이터 소스 타입 라벨
const getDataSourceLabel = (type: DataSourceType) => {
switch (type) {
case "table":
return "테이블";
case "component":
return "컴포넌트";
case "selection":
return "선택 데이터";
default:
return "수동";
}
};
// 디자인 모드 미리보기
if (isDesignMode) {
const previewItems: AggregationResult[] =
items.length > 0
? items.map((item) => ({
id: item.id,
label: getItemLabel(item),
value: 0,
formattedValue: item.prefix ? `${item.prefix}0${item.suffix || ""}` : `0${item.suffix || ""}`,
type: item.type,
}))
: [
{ id: "1", label: "총 수량", value: 150, formattedValue: "150", type: "sum" },
{ id: "2", label: "총 금액", value: 1500000, formattedValue: "₩1,500,000", type: "sum" },
{ id: "3", label: "건수", value: 5, formattedValue: "5건", type: "count" },
];
return (
<div className="space-y-1">
{/* 디자인 모드에서 데이터 소스 표시 */}
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
<span>[{getDataSourceLabel(dataSourceType)}]</span>
{dataSourceType === "table" && effectiveTableName && (
<span>{effectiveTableName}</span>
)}
{dataSourceType === "component" && dataSourceComponentId && (
<span>{dataSourceComponentId}</span>
)}
{(filters || []).length > 0 && (
<span className="text-primary"> {filters?.length}</span>
)}
</div>
<div
className={cn(
"flex items-center rounded-md border bg-slate-50 p-3",
layout === "vertical" ? "flex-col items-stretch" : "flex-row flex-wrap"
)}
style={{
gap: gap || "12px",
backgroundColor: backgroundColor || undefined,
borderRadius: borderRadius || undefined,
padding: padding || undefined,
fontSize: fontSize || undefined,
}}
>
{previewItems.map((result, index) => (
<div
key={result.id || index}
className={cn(
"flex items-center gap-2 rounded-md border bg-white px-3 py-2 shadow-sm",
layout === "vertical" ? "w-full justify-between" : ""
)}
>
{showIcons && (
<span className="text-muted-foreground">{getIcon(result.type)}</span>
)}
{showLabels && (
<span
className="text-muted-foreground text-xs"
style={{ fontSize: labelFontSize, color: labelColor }}
>
{result.label} ({getTypeLabel(result.type)}):
</span>
)}
<span
className="font-semibold"
style={{ fontSize: valueFontSize, color: valueColor }}
>
{result.formattedValue}
</span>
</div>
))}
</div>
</div>
);
}
// 로딩 상태
if (loading) {
return (
<div className="flex items-center justify-center rounded-md border bg-slate-50 p-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<span className="ml-2 text-sm text-muted-foreground"> ...</span>
</div>
);
}
// 에러 상태
if (error) {
return (
<div className="flex items-center justify-center rounded-md border border-destructive bg-destructive/10 p-4 text-sm text-destructive">
{error}
</div>
);
}
// 실제 렌더링
if (aggregationResults.length === 0) {
return (
<div className="flex items-center justify-center rounded-md border border-dashed bg-slate-50 p-4 text-sm text-muted-foreground">
</div>
);
}
return (
<V2ErrorBoundary
componentId={component?.id || "aggregation-widget"}
componentType="v2-aggregation-widget"
>
<div
className={cn(
"flex items-center rounded-md border bg-slate-50 p-3",
layout === "vertical" ? "flex-col items-stretch" : "flex-row flex-wrap"
)}
style={{
gap: gap || "12px",
backgroundColor: backgroundColor || undefined,
borderRadius: borderRadius || undefined,
padding: padding || undefined,
fontSize: fontSize || undefined,
}}
>
{aggregationResults.map((result, index) => (
<div
key={result.id || index}
className={cn(
"flex items-center gap-2 rounded-md border bg-white px-3 py-2 shadow-sm",
layout === "vertical" ? "w-full justify-between" : ""
)}
>
{showIcons && (
<span className="text-muted-foreground">{getIcon(result.type)}</span>
)}
{showLabels && (
<span
className="text-muted-foreground text-xs"
style={{ fontSize: labelFontSize, color: labelColor }}
>
{result.label} ({getTypeLabel(result.type)}):
</span>
)}
<span
className="font-semibold"
style={{ fontSize: valueFontSize, color: valueColor }}
>
{result.formattedValue}
</span>
</div>
))}
</div>
</V2ErrorBoundary>
);
}
export const AggregationWidgetWrapper = AggregationWidgetComponent;
@@ -1,12 +0,0 @@
"use client";
import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
import { V2AggregationWidgetDefinition } from "./index";
// 컴포넌트 자동 등록
if (typeof window !== "undefined") {
ComponentRegistry.registerComponent(V2AggregationWidgetDefinition);
}
export {};

Some files were not shown because too many files have changed in this diff Show More