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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user