feat(studio): Phase G — KPI stats / chart / cardList / groupedTable + canonical container tabs

INV Studio 데이터 뷰 시리즈. 솔루션 개발 단계라 backward-compat alias 없이 깔끔하게.

Backend:
- TableManagementController + Service: /aggregate, /aggregate-group, /select-rows endpoint 추가
  sanitize + hasColumn 검증 + buildAggregateWhere 공유 헬퍼

Frontend canonical view components (신규):
- stats: DB-first KPI editor (CPSegment 메타 chip, 컬럼 dropdown, 디자인 모드 debounce 350ms preview)
- chart: recharts (bar / horizontalBar / line / donut)
- card-list: title/subtitles/metrics 카드 카탈로그 (list / grid 레이아웃)
- grouped-table: 클라이언트 측 groupBy + 그룹 헤더 row

Canonical container (Phase G.2 / G.2.5 / G.2.6):
- containerType='tabs' 활성 탭만 mount, ChildSlot 으로 자식 렌더
- ScreenDesigner.handleComponentDrop 가 canonical container tabs 도 인식
- 우측 V2PropertiesPanel 4-way 분기: tab child / panel child / selected / empty
  nested path update + saveToHistory, delete handler 동기화

Shared utilities:
- useDbColumns hook (모듈 캐시), ColumnPicker (CPSelect 기반)
- OptionFilterRow 자연어 카드 형식 (컬럼 dropdown / 조건 select / 값 입력)
- _shared/use-table-rows.ts (cardList + groupedTable 공용 fetch)
- IconPicker: 한글 키워드 80+ alias, 휠 스크롤 fix, 360px 상한, 결과 80→300

stats DB-first UX (Phase G.4.x):
- DB / 정적 모드 이분법 제거 — 항상 dataSource 시작
- collapsed: 라벨 input + KpiMetaSegment chip (테이블 · 집계 · 컬럼 · 필터수)
- expanded: 데이터 / 필터 / 외형 / 고급 flat CP rows
- useSlideToggle hook 으로 펼침/닫힘 양방향 애니메이션
- 변화량 (delta) 수동 입력 UI 제거 — 향후 DB 자동 계산 영역
- 카드 fetch state 명시: loading / error / 대기 중 / 테이블 미설정

기타:
- ScreenDesigner.tsx → InvyoneStudio.tsx rename (활성 빌더 파일)
- 모든 hardcoded #6c5ce7 fallback 제거, hsl(var(--primary)) 토큰만 사용 (light/dark/테마 자동 적응)
- StatsDefinition default_config 도 DB-first placeholder (value: 0 박지 않음)

Docs:
- notes/gbpark/2026-05-14-studio-data-view-roadmap.md (G.0 ~ G.4.2 진행 기록)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DDD1542
2026-05-14 17:41:50 +09:00
parent c3e04adb23
commit 3883031c0b
51 changed files with 13555 additions and 331 deletions
@@ -325,6 +325,57 @@ public class TableManagementController {
"테이블 데이터를 성공적으로 조회했습니다."));
}
/** POST /api/table-management/tables/:tableName/aggregate
* body: { aggregation: "count"|"sum"|..., columnName?: string, filters?: [...] }
* → { value: number }
*/
@PostMapping("/tables/{tableName}/aggregate")
public ResponseEntity<ApiResponse<Map<String, Object>>> aggregateTableData(
@PathVariable String tableName,
@RequestBody Map<String, Object> options) {
try {
return ResponseEntity.ok(ApiResponse.success(
tableManagementService.aggregateTableData(tableName, options == null ? Map.of() : options),
"테이블 집계를 성공적으로 조회했습니다."));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
}
/** POST /api/table-management/tables/:tableName/aggregate-group
* body: { aggregation, groupBy, valueColumn?, filters?, limit?, orderDir? }
* → { rows: [{ group, value }, ...] }
*/
@PostMapping("/tables/{tableName}/aggregate-group")
public ResponseEntity<ApiResponse<Map<String, Object>>> aggregateTableGroup(
@PathVariable String tableName,
@RequestBody Map<String, Object> options) {
try {
return ResponseEntity.ok(ApiResponse.success(
tableManagementService.aggregateTableGroup(tableName, options == null ? Map.of() : options),
"테이블 그룹 집계를 성공적으로 조회했습니다."));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
}
/** POST /api/table-management/tables/:tableName/select-rows
* body: { columns?, filters?, orderBy?, limit?, offset? }
* → { rows: [{...}, ...] }
*/
@PostMapping("/tables/{tableName}/select-rows")
public ResponseEntity<ApiResponse<Map<String, Object>>> selectTableRows(
@PathVariable String tableName,
@RequestBody Map<String, Object> options) {
try {
return ResponseEntity.ok(ApiResponse.success(
tableManagementService.selectTableRows(tableName, options == null ? Map.of() : options),
"테이블 row 를 성공적으로 조회했습니다."));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(400).body(ApiResponse.error(e.getMessage()));
}
}
/** POST /api/table-management/tables/:tableName/record (단일 레코드) */
@PostMapping("/tables/{tableName}/record")
public ResponseEntity<ApiResponse<Map<String, Object>>> getTableRecord(
@@ -455,6 +455,369 @@ public class TableManagementService extends BaseService {
return result;
}
// ──────────────────────────────────────────────────
// 동적 테이블 집계 (count / sum / avg / min / max / distinctCount)
// ──────────────────────────────────────────────────
private static final Set<String> AGG_TYPES = Set.of(
"count", "sum", "avg", "min", "max", "distinctCount"
);
private static final Set<String> FILTER_OPS = Set.of(
"=", "!=", ">", "<", ">=", "<=",
"like", "in", "notIn", "isNull", "isNotNull"
);
/**
* 단일 집계 값 계산.
*
* count — column 없이도 동작 (COUNT(*))
* sum/avg/min/max — column 필수
* distinctCount — column 필수 (COUNT(DISTINCT col))
*/
public Map<String, Object> aggregateTableData(String tableName, Map<String, Object> options) {
String safeTable = sanitize(tableName);
if (safeTable.isBlank() || !checkTableExists(safeTable)) {
throw new IllegalArgumentException("테이블이 존재하지 않습니다: " + tableName);
}
String aggregation = options.get("aggregation") instanceof String s ? s : "count";
if (!AGG_TYPES.contains(aggregation)) {
throw new IllegalArgumentException("지원하지 않는 집계 타입: " + aggregation);
}
String columnName = options.get("columnName") instanceof String s ? s : null;
String safeColumn = columnName != null ? sanitize(columnName) : "";
boolean columnRequired = !"count".equals(aggregation);
if (columnRequired) {
if (safeColumn.isBlank()) {
throw new IllegalArgumentException(aggregation + " 은 columnName 이 필요합니다.");
}
if (!hasColumn(safeTable, safeColumn)) {
throw new IllegalArgumentException("컬럼이 존재하지 않습니다: " + tableName + "." + columnName);
}
} else if (!safeColumn.isBlank() && !hasColumn(safeTable, safeColumn)) {
// count + columnName 가 들어왔지만 실제 없는 컬럼이면 명확히 거절
throw new IllegalArgumentException("컬럼이 존재하지 않습니다: " + tableName + "." + columnName);
}
List<Map<String, Object>> filters = normalizeAggregateFilters(options.get("filters"));
List<Object> values = new ArrayList<>();
String where = buildAggregateWhere(safeTable, filters, values);
String selectExpr;
if ("count".equals(aggregation)) {
selectExpr = !safeColumn.isBlank()
? String.format("COUNT(\"%s\")", safeColumn)
: "COUNT(*)";
} else if ("distinctCount".equals(aggregation)) {
selectExpr = String.format("COUNT(DISTINCT \"%s\")", safeColumn);
} else {
// sum / avg / min / max — 숫자 캐스팅 (avg 만 numeric, 나머지는 컬럼 타입 그대로)
String upper = aggregation.toUpperCase();
if ("AVG".equals(upper) || "SUM".equals(upper)) {
selectExpr = String.format("%s(CAST(\"%s\" AS NUMERIC))", upper, safeColumn);
} else {
selectExpr = String.format("%s(\"%s\")", upper, safeColumn);
}
}
String sql = String.format("SELECT %s AS agg_value FROM \"%s\" main %s",
selectExpr, safeTable, where);
Number raw = jdbcTemplate.queryForObject(sql, Number.class, values.toArray());
double value = raw != null ? raw.doubleValue() : 0d;
Map<String, Object> result = new LinkedHashMap<>();
result.put("value", value);
return result;
}
private String buildAggregateWhere(String safeTable, List<Map<String, Object>> filters, List<Object> values) {
if (filters == null || filters.isEmpty()) return "";
List<String> clauses = new ArrayList<>();
for (Map<String, Object> f : filters) {
if (f == null) continue;
String col = f.get("column") instanceof String s ? s : null;
String op = f.get("operator") instanceof String s ? s : "=";
if (col == null || col.isBlank()) continue;
String safeCol = sanitize(col);
if (safeCol.isBlank() || !hasColumn(safeTable, safeCol)) continue;
if (!FILTER_OPS.contains(op)) continue;
Object val = f.get("value");
switch (op) {
case "isNull":
clauses.add(String.format("\"%s\" IS NULL", safeCol));
break;
case "isNotNull":
clauses.add(String.format("\"%s\" IS NOT NULL", safeCol));
break;
case "in":
case "notIn": {
List<Object> list = toList(val);
if (list.isEmpty()) continue;
String marks = list.stream().map(v -> "?").collect(Collectors.joining(", "));
String kw = "in".equals(op) ? "IN" : "NOT IN";
clauses.add(String.format("\"%s\" %s (%s)", safeCol, kw, marks));
values.addAll(list);
break;
}
case "like":
if (isEmptyAggregateFilterValue(val)) continue;
clauses.add(String.format("\"%s\"::text ILIKE ?", safeCol));
values.add("%" + val + "%");
break;
default:
if (isEmptyAggregateFilterValue(val)) continue;
clauses.add(String.format("\"%s\" %s ?", safeCol, op));
values.add(val);
}
}
return clauses.isEmpty() ? "" : "WHERE " + String.join(" AND ", clauses);
}
private List<Map<String, Object>> normalizeAggregateFilters(Object rawFilters) {
if (!(rawFilters instanceof List<?> rawList) || rawList.isEmpty()) {
return Collections.emptyList();
}
List<Map<String, Object>> out = new ArrayList<>();
for (Object item : rawList) {
if (item instanceof Map<?, ?> rawMap) {
Map<String, Object> normalized = new LinkedHashMap<>();
for (Map.Entry<?, ?> entry : rawMap.entrySet()) {
if (entry.getKey() instanceof String key) {
normalized.put(key, entry.getValue());
}
}
if (!normalized.isEmpty()) out.add(normalized);
}
}
return out;
}
private boolean isEmptyAggregateFilterValue(Object val) {
if (val == null) return true;
if (val instanceof String s) return s.isBlank();
if (val instanceof Collection<?> c) return c.isEmpty();
return false;
}
private List<Object> toList(Object val) {
if (val == null) return List.of();
if (val instanceof List<?> l) {
List<Object> out = new ArrayList<>();
for (Object o : l) {
if (o == null) continue;
if (o instanceof String s && s.isBlank()) continue;
out.add(o);
}
return out;
}
if (val instanceof String s) {
if (s.isBlank()) return List.of();
return Arrays.stream(s.split(","))
.map(String::trim)
.filter(p -> !p.isEmpty())
.map(p -> (Object) p)
.collect(Collectors.toList());
}
return List.of(val);
}
// ──────────────────────────────────────────────────
// 그룹별 집계 (Phase G.3 — canonical chart 용)
// ──────────────────────────────────────────────────
/**
* groupBy 컬럼별로 집계 결과 반환. canonical chart 컴포넌트가 bar / line / donut /
* horizontalBar 모두에서 같은 endpoint 를 사용.
*
* body 예:
* { "groupBy": "status", "aggregation": "count", "filters": [...] }
* { "groupBy": "dept_code", "aggregation": "sum", "valueColumn": "amount", "limit": 12 }
*
* response:
* { "rows": [{ "group": "재직", "value": 35 }, { "group": "휴직", "value": 4 }] }
*/
public Map<String, Object> aggregateTableGroup(String tableName, Map<String, Object> options) {
String safeTable = sanitize(tableName);
if (safeTable.isBlank() || !checkTableExists(safeTable)) {
throw new IllegalArgumentException("테이블이 존재하지 않습니다: " + tableName);
}
String groupBy = options.get("groupBy") instanceof String s ? s : null;
String safeGroupBy = groupBy != null ? sanitize(groupBy) : "";
if (safeGroupBy.isBlank() || !hasColumn(safeTable, safeGroupBy)) {
throw new IllegalArgumentException("groupBy 컬럼이 존재하지 않습니다: " + tableName + "." + groupBy);
}
String aggregation = options.get("aggregation") instanceof String s ? s : "count";
if (!AGG_TYPES.contains(aggregation)) {
throw new IllegalArgumentException("지원하지 않는 집계 타입: " + aggregation);
}
String valueColumn = options.get("valueColumn") instanceof String s ? s : null;
if (valueColumn == null && options.get("columnName") instanceof String s) valueColumn = s;
String safeValueCol = valueColumn != null ? sanitize(valueColumn) : "";
boolean columnRequired = !"count".equals(aggregation);
if (columnRequired) {
if (safeValueCol.isBlank()) {
throw new IllegalArgumentException(aggregation + " 은 valueColumn 이 필요합니다.");
}
if (!hasColumn(safeTable, safeValueCol)) {
throw new IllegalArgumentException("valueColumn 이 존재하지 않습니다: " + tableName + "." + valueColumn);
}
} else if (!safeValueCol.isBlank() && !hasColumn(safeTable, safeValueCol)) {
throw new IllegalArgumentException("valueColumn 이 존재하지 않습니다: " + tableName + "." + valueColumn);
}
List<Map<String, Object>> filters = normalizeAggregateFilters(options.get("filters"));
int limit = toInt(options.get("limit"), 50);
if (limit < 1) limit = 50;
if (limit > 500) limit = 500;
String orderDir = options.get("orderDir") instanceof String s
&& ("asc".equalsIgnoreCase(s) || "desc".equalsIgnoreCase(s))
? s.toUpperCase()
: "DESC";
List<Object> values = new ArrayList<>();
String where = buildAggregateWhere(safeTable, filters, values);
String selectExpr;
if ("count".equals(aggregation)) {
selectExpr = !safeValueCol.isBlank()
? String.format("COUNT(\"%s\")", safeValueCol)
: "COUNT(*)";
} else if ("distinctCount".equals(aggregation)) {
selectExpr = String.format("COUNT(DISTINCT \"%s\")", safeValueCol);
} else {
String upper = aggregation.toUpperCase();
if ("AVG".equals(upper) || "SUM".equals(upper)) {
selectExpr = String.format("%s(CAST(\"%s\" AS NUMERIC))", upper, safeValueCol);
} else {
selectExpr = String.format("%s(\"%s\")", upper, safeValueCol);
}
}
String sql = String.format(
"SELECT \"%s\" AS group_value, %s AS agg_value " +
"FROM \"%s\" main %s " +
"GROUP BY \"%s\" " +
"ORDER BY agg_value %s NULLS LAST " +
"LIMIT %d",
safeGroupBy, selectExpr, safeTable, where, safeGroupBy, orderDir, limit);
List<Map<String, Object>> rawRows = jdbcTemplate.queryForList(sql, values.toArray());
List<Map<String, Object>> rows = new ArrayList<>();
for (Map<String, Object> r : rawRows) {
Object groupVal = r.get("group_value");
Object aggVal = r.get("agg_value");
double value = aggVal instanceof Number ? ((Number) aggVal).doubleValue() : 0d;
Map<String, Object> out = new LinkedHashMap<>();
out.put("group", groupVal);
out.put("value", value);
rows.add(out);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("rows", rows);
return result;
}
// ──────────────────────────────────────────────────
// 가벼운 select-rows (Phase G.3.1 — card-list / grouped-table 용)
// ──────────────────────────────────────────────────
/**
* OptionFilter 호환 필터 + orderBy + limit/offset 로 임의 컬럼들의 row 들을 반환.
* `getTableData` 는 페이지네이션 + ILIKE search 가 묶여 있어 view 컴포넌트가
* 사용하기 무겁다. 본 메서드는 raw rows 만 깔끔하게 반환.
*
* body 예:
* { "columns": ["user_name", "dept_code"], "filters": [...], "limit": 50 }
* { "groupBy 없이 단순 다중 컬럼", "orderBy": [{ "column": "created_date", "direction": "desc" }] }
*
* response:
* { "rows": [{...}, {...}] }
*/
public Map<String, Object> selectTableRows(String tableName, Map<String, Object> options) {
String safeTable = sanitize(tableName);
if (safeTable.isBlank() || !checkTableExists(safeTable)) {
throw new IllegalArgumentException("테이블이 존재하지 않습니다: " + tableName);
}
@SuppressWarnings("unchecked")
List<Object> rawColumns = options.get("columns") instanceof List<?> raw
? (List<Object>) raw : Collections.emptyList();
List<String> safeColumns = new ArrayList<>();
for (Object c : rawColumns) {
if (!(c instanceof String s)) continue;
String safe = sanitize(s);
if (safe.isBlank()) continue;
if (!hasColumn(safeTable, safe)) continue;
safeColumns.add(safe);
}
String selectExpr;
if (safeColumns.isEmpty()) {
selectExpr = "main.*";
} else {
selectExpr = safeColumns.stream()
.map(c -> "\"" + c + "\"")
.collect(Collectors.joining(", "));
}
List<Map<String, Object>> filters = normalizeAggregateFilters(options.get("filters"));
List<Object> values = new ArrayList<>();
String where = buildAggregateWhere(safeTable, filters, values);
// orderBy: [{ column, direction }]
List<Map<String, Object>> orderBy = normalizeAggregateFilters(options.get("orderBy"));
List<String> orderClauses = new ArrayList<>();
for (Map<String, Object> ob : orderBy) {
if (ob == null) continue;
String col = ob.get("column") instanceof String s ? s : null;
if (col == null) continue;
String safeCol = sanitize(col);
if (safeCol.isBlank() || !hasColumn(safeTable, safeCol)) continue;
String dir = ob.get("direction") instanceof String s
&& "desc".equalsIgnoreCase(s) ? "DESC" : "ASC";
orderClauses.add(String.format("\"%s\" %s", safeCol, dir));
}
String order = "";
if (!orderClauses.isEmpty()) {
order = "ORDER BY " + String.join(", ", orderClauses);
} else if (hasColumn(safeTable, "created_date")) {
order = "ORDER BY main.created_date DESC";
}
int limit = toInt(options.get("limit"), 50);
if (limit < 1) limit = 50;
if (limit > 500) limit = 500;
int offset = toInt(options.get("offset"), 0);
if (offset < 0) offset = 0;
String sql = String.format(
"SELECT %s FROM \"%s\" main %s %s LIMIT %d OFFSET %d",
selectExpr, safeTable, where, order, limit, offset);
List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql, values.toArray());
Map<String, Object> result = new LinkedHashMap<>();
result.put("rows", rows);
return result;
}
@Transactional
public Map<String, Object> addTableData(String tableName, Map<String, Object> data) {
String safeTable = sanitize(tableName);